Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
257773fb3b | ||
|
|
529e935dea |
@@ -1,20 +0,0 @@
|
|||||||
# The integration suites drive real tmux servers and real git repos, and most
|
|
||||||
# of their wall time is spent waiting on those subprocesses rather than on the
|
|
||||||
# CPU. nextest runs test binaries in parallel — which plain `cargo test` does
|
|
||||||
# not — but at its `num-cpus` default the tmux-backed tests starve each other
|
|
||||||
# and the tighter `wait_for_async` deadlines (5s) start timing out. Capping the
|
|
||||||
# concurrency keeps the cross-binary win without the contention.
|
|
||||||
|
|
||||||
[profile.ci]
|
|
||||||
test-threads = 4
|
|
||||||
|
|
||||||
# Anything past this is reported as slow while it keeps running, so a genuine
|
|
||||||
# hang is visible in the log instead of only showing up as a job timeout.
|
|
||||||
slow-timeout = { period = "30s", terminate-after = 8 }
|
|
||||||
|
|
||||||
failure-output = "immediate-final"
|
|
||||||
final-status-level = "slow"
|
|
||||||
|
|
||||||
# nextest does not run doctests. There are none today (every ``` fence in the
|
|
||||||
# crates is `toml` or `text`), so nothing is lost; adding a real doctest means
|
|
||||||
# adding `cargo test --doc` to CI alongside this.
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
target/
|
||||||
|
worktrees/
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.claude/
|
||||||
|
docs/
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
+12
-172
@@ -7,14 +7,6 @@ on:
|
|||||||
- 'linear/**'
|
- 'linear/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
CARGO_INCREMENTAL: 0
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -34,36 +26,23 @@ jobs:
|
|||||||
--component rustfmt --component clippy
|
--component rustfmt --component clippy
|
||||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
# `crates/maestro-web/build.rs` shells out to pnpm whenever
|
- name: Install Node.js + pnpm
|
||||||
# MAESTRO_WEB_DIST is unset, so the Rust build needs node here too.
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- name: Enable pnpm
|
|
||||||
run: |
|
run: |
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y --no-install-recommends nodejs
|
||||||
corepack enable
|
corepack enable
|
||||||
corepack prepare pnpm@9.15.4 --activate
|
corepack prepare pnpm@9.15.4 --activate
|
||||||
|
|
||||||
# Caching all of `target/` under a prefix restore-key let every stale
|
|
||||||
# artifact a previous run dragged in survive forever, so the entry grew
|
|
||||||
# without bound and cost ~60s to move. rust-cache keys on the rustc
|
|
||||||
# version plus the lockfile and prunes target/ to what that lockfile
|
|
||||||
# actually needs. Only main writes the cache; PRs restore from it and
|
|
||||||
# skip the save, which also keeps concurrent PRs from thrashing it.
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
# nextest runs test *binaries* in parallel, which plain `cargo test`
|
~/.cargo/git
|
||||||
# does not — this suite is 20 integration binaries that mostly sit
|
target
|
||||||
# waiting on tmux and git. Thread count is capped in
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
|
||||||
# `.config/nextest.toml`; see the comment there.
|
restore-keys: |
|
||||||
- name: Install cargo-nextest
|
${{ runner.os }}-cargo-
|
||||||
run: |
|
|
||||||
curl -sSfL https://get.nexte.st/latest/linux \
|
|
||||||
| tar zxf - -C "$HOME/.cargo/bin"
|
|
||||||
|
|
||||||
- name: fmt
|
- name: fmt
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
@@ -72,143 +51,4 @@ jobs:
|
|||||||
run: cargo clippy --all-targets -- -D warnings
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: cargo nextest run --all --profile ci
|
run: cargo test --all
|
||||||
|
|
||||||
# NAP-271: the umbrella `maestro` binary is feature-gated (cli / daemon /
|
|
||||||
# tui). Verify each subset compiles and its tests pass on its own, and
|
|
||||||
# that building with no features at all is rejected.
|
|
||||||
- name: feature-gate matrix
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for f in cli daemon tui; do
|
|
||||||
echo "::group::maestro --no-default-features --features $f"
|
|
||||||
cargo build -p maestro --no-default-features --features "$f"
|
|
||||||
cargo nextest run -p maestro --no-default-features --features "$f" \
|
|
||||||
--profile ci
|
|
||||||
echo "::endgroup::"
|
|
||||||
done
|
|
||||||
echo "::group::maestro with no features must fail to compile"
|
|
||||||
if cargo build -p maestro --no-default-features 2>/dev/null; then
|
|
||||||
echo "expected build with no features to fail, but it succeeded" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "::endgroup::"
|
|
||||||
|
|
||||||
web:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- name: Enable pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@9.15.4 --activate
|
|
||||||
|
|
||||||
- name: Install deps
|
|
||||||
run: pnpm -C web install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: typecheck
|
|
||||||
run: pnpm -C web typecheck
|
|
||||||
|
|
||||||
- name: test
|
|
||||||
run: pnpm -C web test
|
|
||||||
|
|
||||||
- name: build
|
|
||||||
run: pnpm -C web build
|
|
||||||
|
|
||||||
# The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile
|
|
||||||
# change invalidates the pin silently elsewhere, so the packaged build has to
|
|
||||||
# fail here too.
|
|
||||||
flake:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# A cold /nix means every run recompiles the entire release closure —
|
|
||||||
# every third-party crate under `lto = "thin"` + `codegen-units = 1`,
|
|
||||||
# which was ~8.5 minutes and the whole pipeline's critical path. With the
|
|
||||||
# store restored, crane's deps-only derivation is already built and only
|
|
||||||
# maestro's own crates recompile.
|
|
||||||
#
|
|
||||||
# The key is the lockfiles that closure derives from, not the sources: a
|
|
||||||
# source-only push is an exact hit, which makes actions/cache skip the
|
|
||||||
# (multi-GB) save. Only the cheap final crates rebuild in that case.
|
|
||||||
- name: Restore nix store
|
|
||||||
id: nix-cache
|
|
||||||
uses: actions/cache/restore@v4
|
|
||||||
with:
|
|
||||||
path: /nix
|
|
||||||
key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'web/pnpm-lock.yaml', '**/*.nix') }}
|
|
||||||
restore-keys: |
|
|
||||||
nix-${{ runner.os }}-
|
|
||||||
|
|
||||||
# The runner container is root with no `nixbld` group, but the installer
|
|
||||||
# writes `build-users-group = nixbld` anyway and then fails on it, so the
|
|
||||||
# config goes in first. Flakes are off by default and `nix build .#…`
|
|
||||||
# needs them. Written again afterwards in case the installer replaced it.
|
|
||||||
#
|
|
||||||
# A restored /nix already holds nix itself — the single-user installer
|
|
||||||
# puts its profile under /nix/var/nix/profiles/per-user/root, which is
|
|
||||||
# inside the cached path. Only $HOME (the ~/.nix-profile symlink, the
|
|
||||||
# PATH entry, NIX_SSL_CERT_FILE) lives outside it and has to be redone.
|
|
||||||
- name: Install nix
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p /etc/nix
|
|
||||||
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
|
||||||
profile=/nix/var/nix/profiles/per-user/root/profile
|
|
||||||
if [ -x "$profile/bin/nix" ]; then
|
|
||||||
ln -sfn "$profile" "$HOME/.nix-profile"
|
|
||||||
else
|
|
||||||
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes
|
|
||||||
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
|
||||||
fi
|
|
||||||
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
|
||||||
# Sourcing the installer's profile script is what normally sets this;
|
|
||||||
# without it a restored store cannot talk to cache.nixos.org.
|
|
||||||
if [ -z "${NIX_SSL_CERT_FILE:-}" ] && [ -r /etc/ssl/certs/ca-certificates.crt ]; then
|
|
||||||
echo "NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fail here rather than 8 minutes into a build if a restored store is
|
|
||||||
# unusable.
|
|
||||||
- name: Check nix works
|
|
||||||
run: nix --extra-experimental-features "nix-command flakes" --version
|
|
||||||
|
|
||||||
# The installer does not enable flakes, so `nix build .#…` needs the
|
|
||||||
# features passed explicitly — a local machine that has them in
|
|
||||||
# nix.conf will build fine while CI fails without this.
|
|
||||||
#
|
|
||||||
# This is the step that guards pnpmDeps.hash, and it is cheap, so it runs
|
|
||||||
# on every event.
|
|
||||||
- name: build SPA package
|
|
||||||
run: |
|
|
||||||
nix --extra-experimental-features "nix-command flakes" build \
|
|
||||||
.#webFrontend \
|
|
||||||
--no-link
|
|
||||||
|
|
||||||
# The release binaries are a full LTO compile of the workspace. The rust
|
|
||||||
# job already builds, lints and tests the same sources, so the only extra
|
|
||||||
# signal here is that the *packaging* works — worth a few minutes on main,
|
|
||||||
# not on every PR push. Trade-off: a break that is specific to the Nix
|
|
||||||
# packaging surfaces on main instead of on the PR.
|
|
||||||
- name: build release packages
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
run: |
|
|
||||||
nix --extra-experimental-features "nix-command flakes" build \
|
|
||||||
.#maestro \
|
|
||||||
--no-link
|
|
||||||
|
|
||||||
# Saved only from a push, so a PR that built webFrontend alone can never
|
|
||||||
# write a store missing the Rust closure and have a later run restore it
|
|
||||||
# through the prefix key.
|
|
||||||
- name: Save nix store
|
|
||||||
if: github.event_name == 'push' && steps.nix-cache.outputs.cache-hit != 'true'
|
|
||||||
uses: actions/cache/save@v4
|
|
||||||
with:
|
|
||||||
path: /nix
|
|
||||||
key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'web/pnpm-lock.yaml', '**/*.nix') }}
|
|
||||||
|
|||||||
+2
-12
@@ -6,15 +6,5 @@ Cargo.lock.bak
|
|||||||
# Local git worktrees for in-flight branches (never tracked)
|
# Local git worktrees for in-flight branches (never tracked)
|
||||||
worktrees/
|
worktrees/
|
||||||
|
|
||||||
# nix build output symlinks
|
# direnv / nix dev shell
|
||||||
/result
|
/.direnv
|
||||||
/result-*
|
|
||||||
|
|
||||||
# direnv (`use flake`)
|
|
||||||
/.direnv/
|
|
||||||
|
|
||||||
# `nix run .#dev` scratch state (tmux socket)
|
|
||||||
/.dev/
|
|
||||||
|
|
||||||
# Per-machine Claude Code tool permissions
|
|
||||||
.claude/settings.local.json
|
|
||||||
|
|||||||
@@ -1,114 +1,54 @@
|
|||||||
# Maestro
|
# Maestro
|
||||||
|
|
||||||
A terminal-session orchestrator for one machine. One daemon per host owns every
|
A unified terminal-session orchestrator. One place — laptop, desktop, or phone — to see and attach to every interactive shell or agent (Claude Code, opencode, plain bash) running across every machine you care about. tmux for persistence, WebSocket for transport, daemon-per-host federated by a small always-on registry.
|
||||||
interactive shell or agent (Claude Code, opencode, plain bash) running on it;
|
|
||||||
clients — CLI, TUI, browser — attach over a plain HTTP + WebSocket API. tmux for
|
|
||||||
persistence, WebSocket for transport.
|
|
||||||
|
|
||||||
## Architecture
|
## Where the spec lives
|
||||||
|
|
||||||
```
|
`docs/superpowers/specs/2026-05-24-maestro-design.md` is the source of truth for architecture, wire protocol, session model, auth, repo layout, and scope boundary. **Read it before writing any code in this repo.**
|
||||||
maestro daemon tmux wrapper + session API + SPA on 127.0.0.1:7070
|
|
||||||
├── GET / browser SPA, embedded (feature `web`)
|
|
||||||
├── GET/POST /api/sessions list / spawn
|
|
||||||
├── DELETE /api/sessions/:id kill
|
|
||||||
├── POST /api/sessions/:id/input type text into a session
|
|
||||||
├── POST /api/sessions/:id/prompt an agent reports its first prompt (#131)
|
|
||||||
├── GET /api/info hostname, version, home dir, spawn presets
|
|
||||||
├── GET /api/dirs directory listing (spawn autocomplete)
|
|
||||||
├── GET /api/recent-dirs spawn history
|
|
||||||
├── GET /api/gitroot enclosing repo of a path
|
|
||||||
├── WS /api/events Snapshot + session deltas
|
|
||||||
└── WS /api/attach/:id raw PTY bytes
|
|
||||||
|
|
||||||
maestro list/spawn/attach/kill/send CLI client
|
|
||||||
maestro tui ratatui client
|
|
||||||
browser loads the SPA from the daemon, same port
|
|
||||||
```
|
|
||||||
|
|
||||||
**No authentication layer, no registry, no peer-to-peer.** Whoever can reach the
|
|
||||||
daemon's bind address can drive every session on the host — same trust model as
|
|
||||||
tmux itself. The operator picks the perimeter: `127.0.0.1` for local-only, a
|
|
||||||
WireGuard/LAN address to share with their own devices, never a public IP.
|
|
||||||
|
|
||||||
Every config field has a default, so `~/.config/maestro/config.toml` is
|
|
||||||
optional.
|
|
||||||
|
|
||||||
## Where the work is
|
## Where the work is
|
||||||
|
|
||||||
Gitea issues on [`naps62/maestro`](https://git.naps.pt/naps62/maestro), grouped
|
Linear project **Maestro** under team **Naps62**. Issues `NAP-211..218` cover the prototype; each has `blockedBy` set so the dependency chain is explicit. **`NAP-211` is the only unblocked starting point.**
|
||||||
into milestones.
|
|
||||||
|
|
||||||
`.claude/linear.json` (legacy filename, `tracker: gitea`) wires `/yolo` and
|
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from "available now" (status backlog/pending, not blocked).
|
||||||
`/work` to that tracker — they pick from open, unassigned issues whose
|
|
||||||
dependencies are all closed. `/blitz <milestone>` sweeps a whole milestone.
|
|
||||||
|
|
||||||
Labels carry one value per axis: `area/*`, `type/*`, `difficulty/*`.
|
|
||||||
`difficulty` drives model selection downstream, so it tracks the shape of the
|
|
||||||
work, not its size.
|
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
Cargo workspace. NAP-245 unified the per-binary crates behind a single
|
Cargo workspace. NAP-245 unified the per-binary crates behind a single
|
||||||
`crates/maestro/` umbrella that ships the `maestro` binary with feature gates
|
`crates/maestro/` umbrella that ships the `maestro` binary with feature gates
|
||||||
(`cli` / `daemon` / `tui` / `web`, default = all four).
|
(`cli` / `daemon` / `registry`, default = all three). The library crates kept
|
||||||
|
their existing shape; only the per-binary `main.rs` files were retired.
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/
|
crates/
|
||||||
├── maestro/ # umbrella binary `maestro` (cli + daemon + tui, feature-gated) (NAP-245)
|
├── maestro/ # umbrella binary `maestro` (cli + daemon + registry, feature-gated) (NAP-245)
|
||||||
├── maestro-protocol/ # shared wire types (NAP-211)
|
├── maestro-protocol/ # shared wire types (NAP-211)
|
||||||
├── maestro-config/ # shared on-disk config schema (NAP-233)
|
├── maestro-config/ # shared on-disk config schema (NAP-233)
|
||||||
├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216)
|
├── maestro-names/ # orchestra-themed session-handle generator (#23)
|
||||||
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + HTTP/WS API + SPA (NAP-212, NAP-214)
|
├── maestro-cli/ # `maestro list/spawn/attach/kill/policy` dispatch (NAP-216)
|
||||||
|
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + WS endpoints (NAP-212, NAP-214)
|
||||||
|
├── maestro-registry/ # `maestro registry` — phone book + HTTP API (NAP-213, NAP-215)
|
||||||
|
├── maestro-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249)
|
||||||
├── maestro-tui/ # `maestro tui` — ratatui session manager (NAP-275 epic)
|
├── maestro-tui/ # `maestro tui` — ratatui session manager (NAP-275 epic)
|
||||||
├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225)
|
├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225)
|
||||||
└── maestro-e2e/ # cross-process integration tests (NAP-217)
|
└── maestro-e2e/ # cross-process integration tests (NAP-217)
|
||||||
web/ # browser SPA (NAP-218), embedded by the daemon's `web` feature
|
web/ # browser SPA (NAP-218)
|
||||||
flake.nix # Nix flake: packages, checks, dev shell, `nix run .#dev`
|
|
||||||
nix/ # Home Manager module (`services.maestro`)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See `docs/INSTALL.md` for end-user install instructions.
|
See `docs/INSTALL.md` for end-user install instructions.
|
||||||
|
|
||||||
`docs/WORKTREES.md` is the contract for session worktrees — placement,
|
|
||||||
collisions, what blocks a kill, and what `--force` leaves behind. Read it
|
|
||||||
before changing anything that creates or removes one.
|
|
||||||
|
|
||||||
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
|
|
||||||
sidebars — line layout, activity colours, duration format, state precedence.
|
|
||||||
Read it before changing sidebar rendering.
|
|
||||||
|
|
||||||
`docs/PREVIEW.md` is the contract for what the content pane shows while the
|
|
||||||
operator moves the sidebar highlight — what a preview may touch, what ends it,
|
|
||||||
what commits it. Read it before changing sidebar-driven navigation.
|
|
||||||
|
|
||||||
`docs/RESTART.md` is the contract for what clients do when the daemon restarts
|
|
||||||
or is upgraded under them — the build stamp on `/api/info`, the status
|
|
||||||
indicator's states, the blocking overlay, grace and poll timings. Read it
|
|
||||||
before changing reconnect behaviour on either client.
|
|
||||||
|
|
||||||
`docs/COMMAND-BAR.md` is the contract for the `ctrl-k` command bar in both
|
|
||||||
clients — how it opens, the sigils, the key model, the match-and-rank algorithm
|
|
||||||
the two matchers must agree on, and the fixture table they are tested against.
|
|
||||||
Read it before changing either bar or either matcher.
|
|
||||||
|
|
||||||
`docs/STRUCTURED-VIEW.md` is the contract for a structured session (an agent
|
|
||||||
run as a daemon child over ACP instead of in a tmux pane) — the eight
|
|
||||||
transcript row kinds, the two projections and their cursor/replay semantics,
|
|
||||||
the handshake and declined capabilities, and the approval nonce rules. Read
|
|
||||||
it before changing anything that renders a structured session.
|
|
||||||
|
|
||||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
|
||||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
|
||||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
|
||||||
the UI is exposed on the network. The dev port is deliberately not the daemon's
|
|
||||||
own default 7070 (`devDaemonPort` in `flake.nix`), so the stack never fights a
|
|
||||||
real daemon on the same host.
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **TDD by default.** Failing test first, then minimal impl. Commit test + impl together.
|
- **TDD by default.** Failing test first, then minimal impl. Commit test + impl together.
|
||||||
- **Frequent commits.** One logical unit per commit; don't pile work up.
|
- **Frequent commits.** One logical unit per commit; don't pile work up.
|
||||||
- **Conventional commits with `maestro` scope.** Example: `feat(maestro): add Hello message round-trip test`.
|
- **Conventional commits with `maestro` scope.** Example: `feat(maestro): add Hello message round-trip test`.
|
||||||
- **`cargo fmt` + `cargo clippy --all-targets -- -D warnings`** before every commit.
|
- **`cargo fmt` + `cargo clippy --all-targets -- -D warnings`** before every commit. (Add to CI as part of NAP-211.)
|
||||||
- **Integration tests use real `tmux` and `git`.** Don't mock them. Isolate via temp `$HOME` and `TMUX_TMPDIR`.
|
- **Integration tests use real `tmux` and `git`.** Don't mock them. Isolate via temp `$HOME` and `TMUX_TMPDIR`.
|
||||||
|
|
||||||
|
## Network assumption
|
||||||
|
|
||||||
|
Maestro is network-agnostic. The operator chooses bind addresses; the perimeter is whatever VPN (Wireguard, etc.) or private LAN they've set up. Maestro does not terminate TLS itself or police the network. See spec §Auth.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Empty repo. Spec approved and committed. **NAP-211 is the natural start.**
|
||||||
|
|||||||
Generated
+168
-713
File diff suppressed because it is too large
Load Diff
+3
-10
@@ -10,27 +10,22 @@ repository = "https://git.naps.pt/naps62/maestro"
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
agent-client-protocol = { version = "2.1", features = ["unstable_tool_call_name"] }
|
|
||||||
axum = { version = "0.7", features = ["ws"] }
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||||
|
jsonwebtoken = { version = "9", default-features = false }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
maestro-acp = { path = "crates/maestro-acp" }
|
|
||||||
maestro-config = { path = "crates/maestro-config" }
|
maestro-config = { path = "crates/maestro-config" }
|
||||||
# `default-features = false` so each consumer opts into `haiku-autoname`
|
|
||||||
# through its own feature of the same name (#131); a workspace entry that
|
|
||||||
# leaves defaults on would ignore the members' `default-features = false`.
|
|
||||||
maestro-hooks = { path = "crates/maestro-hooks", default-features = false }
|
|
||||||
maestro-names = { path = "crates/maestro-names" }
|
maestro-names = { path = "crates/maestro-names" }
|
||||||
maestro-protocol = { path = "crates/maestro-protocol" }
|
maestro-protocol = { path = "crates/maestro-protocol" }
|
||||||
maestro-testing = { path = "crates/maestro-testing" }
|
|
||||||
maestro-tui = { path = "crates/maestro-tui" }
|
maestro-tui = { path = "crates/maestro-tui" }
|
||||||
nix = { version = "0.29", default-features = false, features = ["term", "signal"] }
|
nix = { version = "0.29", default-features = false, features = ["term", "signal"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "stream"] }
|
||||||
|
rust-embed = "8"
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
@@ -40,11 +35,9 @@ tempfile = "3"
|
|||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
|
||||||
tokio-tungstenite = "0.21"
|
tokio-tungstenite = "0.21"
|
||||||
tokio-util = { version = "0.7", features = ["compat"] }
|
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
unicode-width = "0.1"
|
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
ratatui = "0.28"
|
ratatui = "0.28"
|
||||||
vt100 = "0.15"
|
vt100 = "0.15"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Tints/glow derive from the accent at low alpha: `--accent-tint` (0.12),
|
|||||||
## Typography
|
## Typography
|
||||||
|
|
||||||
**Monospace is the UI typeface** (brand identity), system-stack only (offline-
|
**Monospace is the UI typeface** (brand identity), system-stack only (offline-
|
||||||
safe, zero web-font dependency, embedded clean into the web binary):
|
safe, zero web-font dependency, embedded clean into the registry binary):
|
||||||
|
|
||||||
- `--font-mono`: `ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", "Roboto Mono", Menlo, Consolas, monospace` — chrome, labels, data, ids, the wordmark.
|
- `--font-mono`: `ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", "Roboto Mono", Menlo, Consolas, monospace` — chrome, labels, data, ids, the wordmark.
|
||||||
- `--font-sans`: `system-ui` stack — reserved for the few prose strings (empty-state sentences).
|
- `--font-sans`: `system-ui` stack — reserved for the few prose strings (empty-state sentences).
|
||||||
@@ -79,32 +79,31 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
|
|||||||
`connecting` state pill pulses.
|
`connecting` state pill pulses.
|
||||||
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
|
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
|
||||||
rotation, theme-toggle sun/moon swap.
|
rotation, theme-toggle sun/moon swap.
|
||||||
|
- Modal: bottom-sheet slide-up on mobile, scale-pop on ≥600px.
|
||||||
- Every animation has a `prefers-reduced-motion: reduce` path (pulses off,
|
- Every animation has a `prefers-reduced-motion: reduce` path (pulses off,
|
||||||
transitions near-instant).
|
transitions near-instant).
|
||||||
|
|
||||||
## Layout & structure
|
## Layout & structure
|
||||||
|
|
||||||
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
||||||
breakpoint stacks the header (full-width search, wrapped actions). A faint
|
breakpoint stacks the header (full-width search, wrapped actions) and drops the
|
||||||
slate grid texture sits on `--bg` as terminal atmosphere (very low contrast,
|
daemon chips to their own wrapped line. `≥600px` switches the spawn modal from
|
||||||
not decoration).
|
bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
|
||||||
|
terminal atmosphere (very low contrast, not decoration).
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
Three surfaces, all DOM built in TS, styled by class:
|
Three surfaces, all DOM built in TS, styled by class:
|
||||||
|
|
||||||
- **Catalog** — sticky blurred header (pulsing wordmark, search, theme toggle,
|
- **Catalog** — sticky blurred header (pulsing wordmark, search, theme toggle,
|
||||||
Spawn); status bar (`N sessions · M live` tally); session tree (collapsible
|
Spawn); status bar (`N sessions · M live` tally + daemon chips); session tree
|
||||||
directory folders with counts, leaf rows with status dot, name, command meta,
|
(collapsible folders with counts, leaf rows with status dot, name, `@daemon`
|
||||||
exited badge, reveal-on-hover kill). Empty states are distinct and teach:
|
+ command meta, exited badge, reveal-on-hover kill). Empty states are
|
||||||
no-sessions, no-search-match.
|
distinct and teach: waiting-for-daemon, no-sessions, no-search-match.
|
||||||
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
||||||
a framed dark xterm with a green cursor.
|
a framed dark xterm with a green cursor.
|
||||||
- **Spawn modal** — the spawn form in a centered dialog (a bottom sheet below
|
- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus
|
||||||
600px) over a blurred backdrop; mono inputs with green focus ring; grouped
|
ring; grouped worktree fieldset.
|
||||||
worktree fieldset. Escape, Cancel, ✕ and a backdrop click all dismiss it.
|
|
||||||
Submitting gives the new session its own tab; "Spawn similar" seeds the form
|
|
||||||
from a session's dir + command and puts the result beside it.
|
|
||||||
|
|
||||||
Interactive elements ship default / hover / focus-visible / active / disabled
|
Interactive elements ship default / hover / focus-visible / active / disabled
|
||||||
states; the accent green is used only for action, selection, focus, and live
|
states; the accent green is used only for action, selection, focus, and live
|
||||||
@@ -113,5 +112,5 @@ status — never decoration.
|
|||||||
## Dev review aids (DEV-only, stripped from production)
|
## Dev review aids (DEV-only, stripped from production)
|
||||||
|
|
||||||
`web/src/mock.ts` + query params seed a representative catalog without a running
|
`web/src/mock.ts` + query params seed a representative catalog without a running
|
||||||
daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal),
|
registry: `?mock` (seed sessions/daemons), `?mock&spawn` (open the spawn
|
||||||
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
|
modal), `?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Image for the Maestro registry.
|
||||||
|
#
|
||||||
|
# NAP-245 retired the per-binary crates: the registry now ships as the
|
||||||
|
# `registry` subcommand of the unified `maestro` binary. This image builds that
|
||||||
|
# binary with only the `registry` feature enabled (no cli/daemon/tui), so it
|
||||||
|
# stays small and pulls only maestro-registry + protocol + config.
|
||||||
|
#
|
||||||
|
# Build: docker build -t maestro-registry .
|
||||||
|
# Run: docker run --rm -p 8080:8080 -e MAESTRO_TOKEN=<secret> maestro-registry
|
||||||
|
#
|
||||||
|
# On Dokploy: point at this Dockerfile, expose port 8080 (or whatever you
|
||||||
|
# set MAESTRO_REGISTRY_BIND_ADDR to), and set MAESTRO_TOKEN as a secret env.
|
||||||
|
# No persistent volume is required when MAESTRO_TOKEN is provided.
|
||||||
|
|
||||||
|
FROM rust:1-bookworm AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,target=/build/target \
|
||||||
|
cargo build --release -p maestro --no-default-features --features registry \
|
||||||
|
&& cp /build/target/release/maestro /maestro-registry
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=builder /maestro-registry /usr/local/bin/maestro-registry
|
||||||
|
|
||||||
|
# Default to all-interfaces so Dokploy / Traefik can reach the service.
|
||||||
|
# Override via env on the platform.
|
||||||
|
ENV MAESTRO_REGISTRY_BIND_ADDR=0.0.0.0:8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# The on-disk binary is the unified `maestro`; run its `registry` subcommand.
|
||||||
|
ENTRYPOINT ["/usr/local/bin/maestro-registry", "registry"]
|
||||||
@@ -1,19 +1,51 @@
|
|||||||
# Common dev / build entry points. Run `just` with no args to list.
|
# Common dev / build entry points. Run `just` with no args to list.
|
||||||
|
|
||||||
# Run the full local stack — daemon on 127.0.0.1:7071 plus Vite on :5173,
|
# The registry (:8080) is the phone book, the daemon (:7070) runs the tmux
|
||||||
# ordered and health-gated by process-compose. Ctrl-C stops everything.
|
# sessions, and Vite serves the UI at http://localhost:5173 (attach is proxied
|
||||||
|
# to the daemon; see web/vite.config.ts). Override the secret with MAESTRO_TOKEN.
|
||||||
|
# Run the full local stack — registry + daemon + web UI; Ctrl-C stops all three.
|
||||||
dev:
|
dev:
|
||||||
nix run .#dev
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
token="${MAESTRO_TOKEN:-devtoken}"
|
||||||
|
bin="target/debug/maestro"
|
||||||
|
|
||||||
# Build the maestro binary together with the SPA bundle its daemon embeds.
|
echo "▶ building maestro…"
|
||||||
|
cargo build --bin maestro
|
||||||
|
|
||||||
|
pids=()
|
||||||
|
cleanup() { echo; echo "▶ stopping stack…"; for p in "${pids[@]:-}"; do kill "$p" 2>/dev/null || true; done; }
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
echo "▶ registry → http://127.0.0.1:8080"
|
||||||
|
MAESTRO_TOKEN="$token" MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8080 "$bin" registry &
|
||||||
|
pids+=($!)
|
||||||
|
# Wait for the registry to accept connections before starting the daemon.
|
||||||
|
for _ in $(seq 1 50); do (exec 3<>/dev/tcp/127.0.0.1/8080) 2>/dev/null && break || sleep 0.2; done
|
||||||
|
|
||||||
|
echo "▶ daemon → ws://127.0.0.1:7070 (id: $(hostname))"
|
||||||
|
MAESTRO_TOKEN="$token" \
|
||||||
|
MAESTRO_REGISTRY_URL=http://127.0.0.1:8080 \
|
||||||
|
MAESTRO_DAEMON_BIND_ADDR=127.0.0.1:7070 \
|
||||||
|
MAESTRO_DAEMON_URL=ws://127.0.0.1:7070 \
|
||||||
|
"$bin" daemon run &
|
||||||
|
pids+=($!)
|
||||||
|
for _ in $(seq 1 50); do (exec 3<>/dev/tcp/127.0.0.1/7070) 2>/dev/null && break || sleep 0.2; done
|
||||||
|
|
||||||
|
echo "▶ web UI → http://localhost:5173 (Ctrl-C to stop everything)"
|
||||||
|
pnpm -C web install
|
||||||
|
MAESTRO_REGISTRY=http://127.0.0.1:8080 MAESTRO_DAEMON=http://127.0.0.1:7070 \
|
||||||
|
pnpm -C web dev
|
||||||
|
|
||||||
|
# Build the maestro-web binary together with the SPA bundle it embeds.
|
||||||
web:
|
web:
|
||||||
pnpm -C web install --frozen-lockfile
|
pnpm -C web install --frozen-lockfile
|
||||||
pnpm -C web build
|
pnpm -C web build
|
||||||
cargo build -p maestro --release
|
cargo build -p maestro-web --release
|
||||||
|
|
||||||
# Run the frontend in dev mode against a local daemon — Vite serves it with
|
# Run the web bundle in dev mode against a local daemon — Vite serves the
|
||||||
# HMR, and `/api/*` is proxied directly to the daemon at 127.0.0.1:7070
|
# frontend with HMR, and `/api/*` is proxied directly to the daemon at
|
||||||
# (see vite.config.ts).
|
# 127.0.0.1:7070 (or wherever $MAESTRO_REGISTRY points; see vite.config.ts).
|
||||||
web-dev:
|
web-dev:
|
||||||
pnpm -C web install
|
pnpm -C web install
|
||||||
pnpm -C web dev
|
pnpm -C web dev
|
||||||
|
|||||||
+13
-12
@@ -7,21 +7,22 @@ product
|
|||||||
## Users
|
## Users
|
||||||
|
|
||||||
A single technical operator (a developer) managing every interactive shell and
|
A single technical operator (a developer) managing every interactive shell and
|
||||||
agent — Claude Code, opencode, plain bash — running on a machine. The
|
agent — Claude Code, opencode, plain bash — running across multiple machines.
|
||||||
job-to-be-done: from any device (laptop, desktop, **phone**), get an accurate
|
The job-to-be-done: from any device (laptop, desktop, **phone**), get an
|
||||||
at-a-glance picture of all live sessions on that host, then attach to one and
|
accurate at-a-glance picture of all live sessions across all hosts, then attach
|
||||||
drive its terminal. Usage is often glance-and-go: checking on a
|
to one and drive its terminal. Usage is often glance-and-go: checking on a
|
||||||
long-running agent, killing a stray session, or hopping into a shell mid-task.
|
long-running agent, killing a stray session, or hopping into a shell mid-task.
|
||||||
Mobile-first is a hard requirement — touch targets, viewport-fit safe areas,
|
Mobile-first is a hard requirement — touch targets, viewport-fit safe areas,
|
||||||
and bottom-sheet modals already reflect this.
|
and bottom-sheet modals already reflect this.
|
||||||
|
|
||||||
## Product Purpose
|
## Product Purpose
|
||||||
|
|
||||||
Maestro is a terminal-session orchestrator: one place to see and attach to
|
Maestro is a unified terminal-session orchestrator: one place to see and attach
|
||||||
every interactive session running on a machine. tmux for persistence,
|
to every interactive session running across every machine the operator cares
|
||||||
WebSocket for transport, one daemon per host. The web UI is the browser face of
|
about. tmux for persistence, WebSocket for transport, a daemon-per-host
|
||||||
that system — it renders the session catalog and provides the attach terminal
|
federated by a small always-on registry. The web UI is the browser face of
|
||||||
(xterm.js). Success = the operator trusts the picture is accurate and
|
that system — it renders the cross-host session catalog and provides the attach
|
||||||
|
terminal (xterm.js). Success = the operator trusts the picture is accurate and
|
||||||
current, and can get from "open the page" to "typing in the right session" in
|
current, and can get from "open the page" to "typing in the right session" in
|
||||||
seconds, on any device.
|
seconds, on any device.
|
||||||
|
|
||||||
@@ -50,14 +51,14 @@ efficient, calm / uncluttered, with earned moments of delight.
|
|||||||
## Design Principles
|
## Design Principles
|
||||||
|
|
||||||
1. **The scan is the product.** Optimize for instant comprehension of "what's
|
1. **The scan is the product.** Optimize for instant comprehension of "what's
|
||||||
running, where, and is it healthy" across many sessions. Status legibility
|
running, where, and is it healthy" across many sessions and hosts. Status
|
||||||
beats everything else.
|
legibility beats everything else.
|
||||||
2. **Calm under load.** Stay uncluttered whether there are 3 sessions or 300;
|
2. **Calm under load.** Stay uncluttered whether there are 3 sessions or 300;
|
||||||
hierarchy and grouping carry the weight, not color-shouting every row.
|
hierarchy and grouping carry the weight, not color-shouting every row.
|
||||||
3. **Fast and keyboard-literate.** Low-friction, efficient, gets out of the
|
3. **Fast and keyboard-literate.** Low-friction, efficient, gets out of the
|
||||||
way; honor keyboard navigation, don't make the operator hunt.
|
way; honor keyboard navigation, don't make the operator hunt.
|
||||||
4. **Monospace as identity.** Treat the mono/sans pairing as deliberate brand
|
4. **Monospace as identity.** Treat the mono/sans pairing as deliberate brand
|
||||||
typography, not just terminal output. Ids, paths, counts, and state read as
|
typography, not just terminal output. Ids, hosts, counts, and state read as
|
||||||
precise technical data.
|
precise technical data.
|
||||||
5. **Earned delight.** Personality lives in motion and micro-detail (live-status
|
5. **Earned delight.** Personality lives in motion and micro-detail (live-status
|
||||||
pulse, attach transitions, cursor cues), never in noise — always craft.
|
pulse, attach transitions, cursor cues), never in noise — always craft.
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
*.iml
|
|
||||||
.gradle/
|
|
||||||
/local.properties
|
|
||||||
/.idea/
|
|
||||||
.DS_Store
|
|
||||||
/build/
|
|
||||||
/captures/
|
|
||||||
.externalNativeBuild/
|
|
||||||
.cxx/
|
|
||||||
*.apk
|
|
||||||
*.aab
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.android.application)
|
|
||||||
alias(libs.plugins.kotlin.android)
|
|
||||||
alias(libs.plugins.kotlin.compose)
|
|
||||||
alias(libs.plugins.kotlin.serialization)
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "pt.naps.maestro"
|
|
||||||
compileSdk = 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "pt.naps.maestro"
|
|
||||||
minSdk = 26
|
|
||||||
targetSdk = 34
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "0.1.0"
|
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
getByName("debug") {
|
|
||||||
storeFile = file("debug.keystore")
|
|
||||||
storePassword = "android"
|
|
||||||
keyAlias = "androiddebugkey"
|
|
||||||
keyPassword = "android"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
debug {
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
compose = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.androidx.core.ktx)
|
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
|
||||||
implementation(libs.androidx.lifecycle.process)
|
|
||||||
implementation(libs.androidx.activity.compose)
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
|
||||||
implementation(libs.androidx.ui)
|
|
||||||
implementation(libs.androidx.ui.graphics)
|
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
|
||||||
implementation(libs.androidx.material3)
|
|
||||||
implementation(libs.androidx.navigation.compose)
|
|
||||||
implementation(libs.okhttp)
|
|
||||||
implementation(libs.kotlinx.serialization.json)
|
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
|
||||||
testImplementation(libs.okhttp.mockwebserver)
|
|
||||||
testImplementation(libs.turbine)
|
|
||||||
|
|
||||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
|
||||||
}
|
|
||||||
Binary file not shown.
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
# Add project specific ProGuard rules here.
|
|
||||||
# You can control the set of applied configuration files using the
|
|
||||||
# proguardFiles setting in build.gradle.kts.
|
|
||||||
#
|
|
||||||
# For more details, see
|
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package pt.naps.maestro
|
|
||||||
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class MainActivityTest {
|
|
||||||
@get:Rule
|
|
||||||
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun placeholderScreen_showsAppName() {
|
|
||||||
composeTestRule.onNodeWithText("Maestro").assertExists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:usesCleartextTraffic="true"
|
|
||||||
android:theme="@style/Theme.Maestro">
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:windowSoftInputMode="adjustResize"
|
|
||||||
android:theme="@style/Theme.Maestro">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
|
||||||
<link rel="stylesheet" href="vendor/xterm.css" />
|
|
||||||
<style>
|
|
||||||
html, body, #terminal { height: 100%; margin: 0; padding: 0; background: #121419; overflow: hidden; }
|
|
||||||
.xterm { padding: 4px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="terminal"></div>
|
|
||||||
<script src="vendor/xterm.js"></script>
|
|
||||||
<script src="vendor/addon-fit.js"></script>
|
|
||||||
<script>
|
|
||||||
// Owns the `WS /api/attach/:id` connection directly, the same shape as
|
|
||||||
// web/src/components/terminal/TerminalPane.tsx — PTY bytes in binary
|
|
||||||
// frames both ways, `ClientControl` JSON in text frames. The native
|
|
||||||
// Android side (TerminalWebView.kt) never touches the socket; it only
|
|
||||||
// calls the maestro* bridge functions below for the extra key bar.
|
|
||||||
var params = new URLSearchParams(location.search);
|
|
||||||
var wsUrl = params.get("ws");
|
|
||||||
|
|
||||||
var term = new Terminal({
|
|
||||||
cursorBlink: true,
|
|
||||||
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
||||||
fontSize: 13,
|
|
||||||
convertEol: false,
|
|
||||||
scrollback: 2000,
|
|
||||||
theme: {
|
|
||||||
background: "#121419",
|
|
||||||
foreground: "#e6e8ec",
|
|
||||||
cursor: "#5fe3a1",
|
|
||||||
cursorAccent: "#121419",
|
|
||||||
selectionBackground: "#2b6b4f",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
var fitAddon = new FitAddon.FitAddon();
|
|
||||||
term.loadAddon(fitAddon);
|
|
||||||
var container = document.getElementById("terminal");
|
|
||||||
term.open(container);
|
|
||||||
fitAddon.fit();
|
|
||||||
|
|
||||||
var ws = null;
|
|
||||||
var awaitingReplay = true;
|
|
||||||
var armed = { ctrl: false, alt: false };
|
|
||||||
|
|
||||||
function sendControl(msg) {
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendInput(data) {
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
awaitingReplay = true;
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
ws.binaryType = "arraybuffer";
|
|
||||||
|
|
||||||
ws.onopen = function () {
|
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = function (ev) {
|
|
||||||
if (typeof ev.data === "string") return; // control frame, nothing to render
|
|
||||||
var replay = awaitingReplay;
|
|
||||||
awaitingReplay = false;
|
|
||||||
term.write(new Uint8Array(ev.data), function () {
|
|
||||||
if (replay) term.scrollToBottom();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = function (ev) {
|
|
||||||
if (ev.code === 1000) return;
|
|
||||||
term.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl maps to the C0 control range (A-Z, [, \, ], ^, _); Alt prefixes ESC
|
|
||||||
// — the same encoding buildKeyBar/applyModsToChar use on the web client.
|
|
||||||
function applyModsToChar(ch, ctrl, alt) {
|
|
||||||
var out = ch;
|
|
||||||
if (ctrl) {
|
|
||||||
var code = ch.toUpperCase().charCodeAt(0);
|
|
||||||
if (code >= 64 && code <= 95) out = String.fromCharCode(code - 64);
|
|
||||||
}
|
|
||||||
if (alt) out = "\x1b" + out;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSI cursor sequence with the modifier parameter xterm expects:
|
|
||||||
// `1;<mod>` where 3=alt, 5=ctrl, 7=ctrl+alt.
|
|
||||||
function cursorSeq(final, ctrl, alt) {
|
|
||||||
if (!ctrl && !alt) return "\x1b[" + final;
|
|
||||||
var mod = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
|
|
||||||
return "\x1b[1;" + mod + final;
|
|
||||||
}
|
|
||||||
|
|
||||||
term.onData(function (data) {
|
|
||||||
if ((armed.ctrl || armed.alt) && data.length === 1) {
|
|
||||||
var hadCtrl = armed.ctrl;
|
|
||||||
var hadAlt = armed.alt;
|
|
||||||
sendInput(applyModsToChar(data, hadCtrl, hadAlt));
|
|
||||||
armed.ctrl = false;
|
|
||||||
armed.alt = false;
|
|
||||||
if (window.AndroidBridge) {
|
|
||||||
if (hadCtrl) AndroidBridge.onModConsumed("ctrl");
|
|
||||||
if (hadAlt) AndroidBridge.onModConsumed("alt");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sendInput(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
term.onResize(function (size) {
|
|
||||||
sendControl({ type: "Resize", cols: size.cols, rows: size.rows });
|
|
||||||
});
|
|
||||||
|
|
||||||
container.addEventListener("click", function () {
|
|
||||||
term.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
var ro = new ResizeObserver(function () {
|
|
||||||
try {
|
|
||||||
fitAddon.fit();
|
|
||||||
} catch (e) {
|
|
||||||
// container not measurable yet (e.g. mid-teardown) — next observation retries
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ro.observe(container);
|
|
||||||
|
|
||||||
// Bridge called from TerminalWebViewController via evaluateJavascript —
|
|
||||||
// the native extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles.
|
|
||||||
window.maestroKey = function (key) {
|
|
||||||
var ctrl = armed.ctrl;
|
|
||||||
var alt = armed.alt;
|
|
||||||
var seq;
|
|
||||||
switch (key) {
|
|
||||||
case "Tab": seq = "\t"; break;
|
|
||||||
case "Esc": seq = "\x1b"; break;
|
|
||||||
case "Up": seq = cursorSeq("A", ctrl, alt); break;
|
|
||||||
case "Down": seq = cursorSeq("B", ctrl, alt); break;
|
|
||||||
case "Right": seq = cursorSeq("C", ctrl, alt); break;
|
|
||||||
case "Left": seq = cursorSeq("D", ctrl, alt); break;
|
|
||||||
default: return;
|
|
||||||
}
|
|
||||||
sendInput(seq);
|
|
||||||
armed.ctrl = false;
|
|
||||||
armed.alt = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.maestroToggleMod = function (name) {
|
|
||||||
if (name === "ctrl" || name === "alt") armed[name] = !armed[name];
|
|
||||||
};
|
|
||||||
|
|
||||||
window.maestroFocus = function () {
|
|
||||||
term.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
|
||||||
//# sourceMappingURL=addon-fit.js.map
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
|
||||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
|
||||||
* https://github.com/chjj/term.js
|
|
||||||
* @license MIT
|
|
||||||
*
|
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
* of this software and associated documentation files (the "Software"), to deal
|
|
||||||
* in the Software without restriction, including without limitation the rights
|
|
||||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
* copies of the Software, and to permit persons to whom the Software is
|
|
||||||
* furnished to do so, subject to the following conditions:
|
|
||||||
*
|
|
||||||
* The above copyright notice and this permission notice shall be included in
|
|
||||||
* all copies or substantial portions of the Software.
|
|
||||||
*
|
|
||||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
* THE SOFTWARE.
|
|
||||||
*
|
|
||||||
* Originally forked from (with the author's permission):
|
|
||||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
|
||||||
* http://bellard.org/jslinux/
|
|
||||||
* Copyright (c) 2011 Fabrice Bellard
|
|
||||||
* The original design remains. The terminal itself
|
|
||||||
* has been extended to include xterm CSI codes, among
|
|
||||||
* other features.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default styles for xterm.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
.xterm {
|
|
||||||
cursor: text;
|
|
||||||
position: relative;
|
|
||||||
user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm.focus,
|
|
||||||
.xterm:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-helpers {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
/**
|
|
||||||
* The z-index of the helpers must be higher than the canvases in order for
|
|
||||||
* IMEs to appear on top.
|
|
||||||
*/
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-helper-textarea {
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
margin: 0;
|
|
||||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
left: -9999em;
|
|
||||||
top: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
z-index: -5;
|
|
||||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .composition-view {
|
|
||||||
/* TODO: Composition position got messed up somewhere */
|
|
||||||
background: #000;
|
|
||||||
color: #FFF;
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
white-space: nowrap;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .composition-view.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-viewport {
|
|
||||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
|
||||||
background-color: #000;
|
|
||||||
overflow-y: scroll;
|
|
||||||
cursor: default;
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-screen {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-screen canvas {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-scroll-area {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm-char-measure-element {
|
|
||||||
display: inline-block;
|
|
||||||
visibility: hidden;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -9999em;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm.enable-mouse-events {
|
|
||||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm.xterm-cursor-pointer,
|
|
||||||
.xterm .xterm-cursor-pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm.column-select.focus {
|
|
||||||
/* Column selection mode */
|
|
||||||
cursor: crosshair;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-accessibility:not(.debug),
|
|
||||||
.xterm .xterm-message {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
color: transparent;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
|
||||||
color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-accessibility-tree {
|
|
||||||
user-select: text;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .live-region {
|
|
||||||
position: absolute;
|
|
||||||
left: -9999px;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm-dim {
|
|
||||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
|
||||||
* explicitly in the generated class and reset to 1 here */
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm-underline-1 { text-decoration: underline; }
|
|
||||||
.xterm-underline-2 { text-decoration: double underline; }
|
|
||||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
|
||||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
|
||||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
|
||||||
|
|
||||||
.xterm-overline {
|
|
||||||
text-decoration: overline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
|
||||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
|
||||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
|
||||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
|
||||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
|
||||||
|
|
||||||
.xterm-strikethrough {
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
|
||||||
z-index: 6;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
|
||||||
z-index: 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm-decoration-overview-ruler {
|
|
||||||
z-index: 8;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm-decoration-top {
|
|
||||||
z-index: 2;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,151 +0,0 @@
|
|||||||
package pt.naps.maestro
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.DrawerValue
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.rememberDrawerState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import pt.naps.maestro.connection.DaemonLink
|
|
||||||
import pt.naps.maestro.connection.DaemonPrefs
|
|
||||||
import pt.naps.maestro.connection.LinkState
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SessionView
|
|
||||||
import pt.naps.maestro.ui.sidebar.SidebarContent
|
|
||||||
import pt.naps.maestro.ui.sidebar.sessionName
|
|
||||||
import pt.naps.maestro.ui.structured.StructuredScreen
|
|
||||||
import pt.naps.maestro.ui.terminal.TerminalScreen
|
|
||||||
import pt.naps.maestro.ui.theme.MaestroTheme
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
private lateinit var daemonLink: DaemonLink
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enableEdgeToEdge()
|
|
||||||
|
|
||||||
val prefs = DaemonPrefs(applicationContext)
|
|
||||||
intent?.getStringExtra(DaemonPrefs.EXTRA_DAEMON_URL)?.let { prefs.setBaseUrl(it) }
|
|
||||||
daemonLink = DaemonLink(applicationContext, prefs).also { it.start() }
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
MaestroTheme {
|
|
||||||
val linkState by daemonLink.state.collectAsState()
|
|
||||||
val baseUrl by prefs.baseUrl.collectAsState()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var openSession by remember { mutableStateOf<Session?>(null) }
|
|
||||||
|
|
||||||
val session = openSession
|
|
||||||
if (session != null && session.view == SessionView.Structured) {
|
|
||||||
StructuredScreen(
|
|
||||||
daemonBaseUrl = baseUrl,
|
|
||||||
sessionId = session.id,
|
|
||||||
sessionTitle = sessionName(session),
|
|
||||||
onBack = { openSession = null },
|
|
||||||
)
|
|
||||||
} else if (session != null) {
|
|
||||||
TerminalScreen(
|
|
||||||
daemonBaseUrl = baseUrl,
|
|
||||||
sessionId = session.id,
|
|
||||||
sessionTitle = sessionName(session),
|
|
||||||
onBack = { openSession = null },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ModalNavigationDrawer(
|
|
||||||
drawerState = drawerState,
|
|
||||||
drawerContent = {
|
|
||||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
|
||||||
SidebarContent(
|
|
||||||
catalogStore = daemonLink.catalog,
|
|
||||||
onOpenSession = { s ->
|
|
||||||
openSession = s
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text("Maestro") },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
|
||||||
Text("☰")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
daemonLink.close()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Maestro",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.placeholder_message),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Daemon: ${linkState.name.lowercase()}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun PlaceholderScreenPreview() {
|
|
||||||
MaestroTheme {
|
|
||||||
PlaceholderScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package pt.naps.maestro.api
|
|
||||||
|
|
||||||
import pt.naps.maestro.data.InTheWay
|
|
||||||
|
|
||||||
/** A `/api` failure with the daemon's own error envelope kept intact, so a
|
|
||||||
* caller can branch on [code] and render [unsavedWork] — the kill guard —
|
|
||||||
* rather than re-parsing [detail]. Mirrors the web client's
|
|
||||||
* `ApiRequestError`. */
|
|
||||||
class MaestroApiException(
|
|
||||||
val status: Int,
|
|
||||||
val code: String,
|
|
||||||
val detail: String,
|
|
||||||
val unsavedWork: InTheWay? = null,
|
|
||||||
) : Exception(buildMessage(status, code, detail)) {
|
|
||||||
private companion object {
|
|
||||||
fun buildMessage(status: Int, code: String, detail: String): String {
|
|
||||||
val info = if (code.isNotEmpty()) "$code: $detail" else detail
|
|
||||||
return if (info.isNotEmpty()) "$status — $info" else "$status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
package pt.naps.maestro.api
|
|
||||||
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import okhttp3.Call
|
|
||||||
import okhttp3.Callback
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.Response
|
|
||||||
import pt.naps.maestro.data.ApiError
|
|
||||||
import pt.naps.maestro.data.BranchInfo
|
|
||||||
import pt.naps.maestro.data.CreateSessionRequest
|
|
||||||
import pt.naps.maestro.data.DaemonInfo
|
|
||||||
import pt.naps.maestro.data.DirListing
|
|
||||||
import pt.naps.maestro.data.GitRoot
|
|
||||||
import pt.naps.maestro.data.NameBatch
|
|
||||||
import pt.naps.maestro.data.Project
|
|
||||||
import pt.naps.maestro.data.RecentDirs
|
|
||||||
import pt.naps.maestro.data.RenameSessionRequest
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SetFocusRequest
|
|
||||||
import pt.naps.maestro.data.StructuredAttachmentResponse
|
|
||||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
|
||||||
import pt.naps.maestro.data.StructuredConfigRequest
|
|
||||||
import pt.naps.maestro.data.StructuredPromptRequest
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
import java.io.IOException
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.resumeWithException
|
|
||||||
|
|
||||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
|
||||||
private val OCTET_STREAM_MEDIA_TYPE = "application/octet-stream".toMediaType()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typed client for the daemon's `/api` surface (REST half). Mirrors
|
|
||||||
* `maestro_protocol::client::DaemonClient` / `web/src/api.ts`.
|
|
||||||
*
|
|
||||||
* There is no authentication layer: reaching the daemon's bind address is
|
|
||||||
* the authorization, same trust model as tmux itself.
|
|
||||||
*/
|
|
||||||
class MaestroClient(
|
|
||||||
private val baseUrl: String,
|
|
||||||
private val httpClient: OkHttpClient = OkHttpClient(),
|
|
||||||
) {
|
|
||||||
private val json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
isLenient = true
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun listSessions(): List<Session> = get(Routes.SESSIONS)
|
|
||||||
|
|
||||||
suspend fun createSession(request: CreateSessionRequest): Session =
|
|
||||||
post(Routes.SESSIONS, request)
|
|
||||||
|
|
||||||
/** `PATCH /api/sessions/:id`: rename a live session. An empty [name]
|
|
||||||
* clears the custom one, so the card falls back to the command. */
|
|
||||||
suspend fun renameSession(id: Uuid, name: String): Session =
|
|
||||||
patch(Routes.session(id), RenameSessionRequest(name))
|
|
||||||
|
|
||||||
/** `DELETE /api/sessions/:id`. With [force], the daemon overrides the
|
|
||||||
* kill refusal over a worktree holding unsaved work and leaves that
|
|
||||||
* worktree on disk. */
|
|
||||||
suspend fun killSession(id: Uuid, force: Boolean = false) {
|
|
||||||
val query = if (force) listOf(Routes.FORCE_QUERY to "true") else emptyList()
|
|
||||||
executeNoContent(newRequest("DELETE", Routes.session(id), query = query))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/focus`: report whether this client's pane is
|
|
||||||
* the one currently showing [id] — not merely highlighted in a
|
|
||||||
* sidebar. */
|
|
||||||
suspend fun setSessionFocus(id: Uuid, focused: Boolean) {
|
|
||||||
executeNoContent(
|
|
||||||
newRequest(
|
|
||||||
"POST",
|
|
||||||
Routes.sessionFocus(id),
|
|
||||||
body = json.encodeToString(SetFocusRequest(focused)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GET /api/dirs?path=`: one directory's immediate subdirectories.
|
|
||||||
* [path] may be empty, `~`, relative, or absolute — the daemon resolves
|
|
||||||
* it against its home dir. */
|
|
||||||
suspend fun listDir(path: String): DirListing =
|
|
||||||
get(Routes.DIRS, listOf(Routes.PATH_QUERY to path))
|
|
||||||
|
|
||||||
/** `GET /api/info`: identity + spawn presets of the connected daemon. */
|
|
||||||
suspend fun getDaemonInfo(): DaemonInfo = get(Routes.INFO)
|
|
||||||
|
|
||||||
/** `GET /api/recent-dirs`: recently-used working dirs, newest-first and
|
|
||||||
* deduped by dir. */
|
|
||||||
suspend fun recentDirs(): RecentDirs = get(Routes.RECENT_DIRS)
|
|
||||||
|
|
||||||
/** `GET /api/projects`: every discovered git repository/worktree,
|
|
||||||
* newest-activity-first. */
|
|
||||||
suspend fun listProjects(): List<Project> = get(Routes.PROJECTS)
|
|
||||||
|
|
||||||
/** `GET /api/gitroot?path=`: the enclosing git repo of [path]; absent
|
|
||||||
* when [path] isn't inside a repo. */
|
|
||||||
suspend fun gitRoot(path: String): GitRoot =
|
|
||||||
get(Routes.GITROOT, listOf(Routes.PATH_QUERY to path))
|
|
||||||
|
|
||||||
/** `GET /api/names?count=`: a batch of fresh session handles. */
|
|
||||||
suspend fun fetchNames(count: Int): NameBatch =
|
|
||||||
get(Routes.NAMES, listOf(Routes.COUNT_QUERY to count.toString()))
|
|
||||||
|
|
||||||
/** `GET /api/branches?path=`: one repository's local branches, with
|
|
||||||
* worktree occupancy. */
|
|
||||||
suspend fun listBranches(path: String): List<BranchInfo> =
|
|
||||||
get(Routes.BRANCHES, listOf(Routes.PATH_QUERY to path))
|
|
||||||
|
|
||||||
/** Send a prompt to a structured session's ACP turn. `409
|
|
||||||
* turn_in_flight` when one is already active. */
|
|
||||||
suspend fun sendStructuredPrompt(id: Uuid, prompt: String) {
|
|
||||||
executeNoContent(
|
|
||||||
newRequest(
|
|
||||||
"POST",
|
|
||||||
Routes.structuredPrompt(id),
|
|
||||||
body = json.encodeToString(StructuredPromptRequest(prompt)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Upload a file attachment for a structured session's next prompt. */
|
|
||||||
suspend fun uploadStructuredAttachment(
|
|
||||||
id: Uuid,
|
|
||||||
fileName: String,
|
|
||||||
contents: ByteArray,
|
|
||||||
): StructuredAttachmentResponse {
|
|
||||||
val url = Routes.httpUrl(baseUrl, Routes.structuredAttachment(id)).toHttpUrl()
|
|
||||||
.newBuilder()
|
|
||||||
.addQueryParameter("name", fileName)
|
|
||||||
.build()
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.post(contents.toRequestBody(OCTET_STREAM_MEDIA_TYPE))
|
|
||||||
.build()
|
|
||||||
return decode(execute(request))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cancel a structured session's active ACP turn. */
|
|
||||||
suspend fun cancelStructuredTurn(id: Uuid) {
|
|
||||||
executeNoContent(newRequest("POST", Routes.structuredCancel(id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Change one option advertised by a live ACP session. */
|
|
||||||
suspend fun setStructuredConfig(id: Uuid, configId: String, value: JsonElement) {
|
|
||||||
executeNoContent(
|
|
||||||
newRequest(
|
|
||||||
"POST",
|
|
||||||
Routes.structuredConfig(id),
|
|
||||||
body = json.encodeToString(StructuredConfigRequest(configId, value)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answer a pending ACP permission request. Always resolves once the
|
|
||||||
* session itself is a live structured session — a nonce that is unknown
|
|
||||||
* or already consumed still answers success. */
|
|
||||||
suspend fun resolveStructuredApproval(
|
|
||||||
id: Uuid,
|
|
||||||
nonce: String,
|
|
||||||
decision: StructuredApprovalDecision,
|
|
||||||
) {
|
|
||||||
executeNoContent(
|
|
||||||
newRequest(
|
|
||||||
"POST",
|
|
||||||
Routes.structuredApproval(id, nonce),
|
|
||||||
body = json.encodeToString(decision),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- plumbing ------------------------------------------------------
|
|
||||||
|
|
||||||
private suspend inline fun <reified T> get(
|
|
||||||
path: String,
|
|
||||||
query: List<Pair<String, String>> = emptyList(),
|
|
||||||
): T = decode(execute(newRequest("GET", path, query = query)))
|
|
||||||
|
|
||||||
private suspend inline fun <reified B, reified T> post(path: String, body: B): T =
|
|
||||||
decode(execute(newRequest("POST", path, body = json.encodeToString(body))))
|
|
||||||
|
|
||||||
private suspend inline fun <reified B, reified T> patch(path: String, body: B): T =
|
|
||||||
decode(execute(newRequest("PATCH", path, body = json.encodeToString(body))))
|
|
||||||
|
|
||||||
private fun newRequest(
|
|
||||||
method: String,
|
|
||||||
path: String,
|
|
||||||
query: List<Pair<String, String>> = emptyList(),
|
|
||||||
body: String? = null,
|
|
||||||
): Request {
|
|
||||||
val urlBuilder = Routes.httpUrl(baseUrl, path).toHttpUrl().newBuilder()
|
|
||||||
for ((key, value) in query) urlBuilder.addQueryParameter(key, value)
|
|
||||||
// OkHttp refuses a null body on POST/PUT/PATCH even when the daemon
|
|
||||||
// expects none (e.g. the structured-turn cancel) — an empty body
|
|
||||||
// satisfies it without implying a JSON payload.
|
|
||||||
val requestBody = when {
|
|
||||||
body != null -> body.toRequestBody(JSON_MEDIA_TYPE)
|
|
||||||
method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody(null)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
return Request.Builder()
|
|
||||||
.url(urlBuilder.build())
|
|
||||||
.method(method, requestBody)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun execute(request: Request): Response =
|
|
||||||
suspendCancellableCoroutine { cont ->
|
|
||||||
val call = httpClient.newCall(request)
|
|
||||||
cont.invokeOnCancellation { call.cancel() }
|
|
||||||
call.enqueue(
|
|
||||||
object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
if (!cont.isCancelled) cont.resumeWithException(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
cont.resume(response)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun executeNoContent(request: Request) {
|
|
||||||
execute(request).use { if (!it.isSuccessful) throw toApiException(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> decode(response: Response): T =
|
|
||||||
response.use {
|
|
||||||
if (!it.isSuccessful) throw toApiException(it)
|
|
||||||
json.decodeFromString<T>(it.body?.string().orEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toApiException(response: Response): MaestroApiException {
|
|
||||||
val text = response.body?.string().orEmpty()
|
|
||||||
val info = runCatching { json.decodeFromString<ApiError>(text) }.getOrNull()?.error
|
|
||||||
return MaestroApiException(
|
|
||||||
status = response.code,
|
|
||||||
code = info?.code ?: "",
|
|
||||||
detail = info?.message ?: text,
|
|
||||||
unsavedWork = info?.unsaved_work,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
package pt.naps.maestro.api
|
|
||||||
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.WebSocket
|
|
||||||
import okhttp3.WebSocketListener
|
|
||||||
import okio.ByteString
|
|
||||||
import pt.naps.maestro.data.CatalogEvent
|
|
||||||
import pt.naps.maestro.data.ClientControl
|
|
||||||
import pt.naps.maestro.data.StructuredEvent
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
private val wsJson = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
isLenient = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One frame received on `WS /api/attach/:id`: raw PTY bytes, or a decoded
|
|
||||||
* [ClientControl] text message. */
|
|
||||||
sealed class AttachFrame {
|
|
||||||
data class Pty(val bytes: ByteArray) : AttachFrame()
|
|
||||||
data class Control(val message: ClientControl) : AttachFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A live `WS /api/attach/:id` connection. PTY bytes travel in binary frames
|
|
||||||
* both directions; control messages ([ClientControl]) travel in text
|
|
||||||
* frames. */
|
|
||||||
class AttachConnection internal constructor(
|
|
||||||
private val socket: WebSocket,
|
|
||||||
val frames: Flow<AttachFrame>,
|
|
||||||
) {
|
|
||||||
/** Type raw bytes into the session's pane, as if typed on an attached
|
|
||||||
* terminal. */
|
|
||||||
fun sendInput(bytes: ByteArray) {
|
|
||||||
socket.send(ByteString.of(*bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resize(cols: Int, rows: Int) {
|
|
||||||
sendControl(ClientControl.Resize(cols, rows))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ping() {
|
|
||||||
sendControl(ClientControl.Ping)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendControl(message: ClientControl) {
|
|
||||||
socket.send(wsJson.encodeToString(message))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
socket.close(1000, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A live `WS /api/structured/:id` connection: transcript/state snapshots
|
|
||||||
* and live deltas for one structured session. */
|
|
||||||
class StructuredConnection internal constructor(
|
|
||||||
private val socket: WebSocket,
|
|
||||||
val events: Flow<StructuredEvent>,
|
|
||||||
) {
|
|
||||||
fun close() {
|
|
||||||
socket.close(1000, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A live `WS /api/events` connection: catalog snapshot + session deltas. */
|
|
||||||
class EventsConnection internal constructor(
|
|
||||||
private val socket: WebSocket,
|
|
||||||
val events: Flow<CatalogEvent>,
|
|
||||||
) {
|
|
||||||
fun close() {
|
|
||||||
socket.close(1000, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket half of the daemon's `/api` surface. Mirrors the `*_url`
|
|
||||||
* helpers in `crates/maestro-protocol/src/routes.rs`.
|
|
||||||
*
|
|
||||||
* Each `connect*` call opens the socket immediately and returns a handle
|
|
||||||
* whose `Flow` starts buffering frames right away — a slow collector never
|
|
||||||
* misses a frame, it only sees it later. Call `close()` on the returned
|
|
||||||
* connection when done; there is no automatic reconnect.
|
|
||||||
*/
|
|
||||||
class MaestroWebSocket(
|
|
||||||
private val baseUrl: String,
|
|
||||||
private val httpClient: OkHttpClient = OkHttpClient(),
|
|
||||||
) {
|
|
||||||
/** `WS /api/events`: catalog snapshot + session deltas. */
|
|
||||||
fun connectEvents(): EventsConnection {
|
|
||||||
val channel = Channel<CatalogEvent>(Channel.BUFFERED)
|
|
||||||
val socket = open(
|
|
||||||
Routes.eventsWsUrl(baseUrl),
|
|
||||||
object : WebSocketListener() {
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
||||||
runCatching { wsJson.decodeFromString<CatalogEvent>(text) }
|
|
||||||
.onSuccess { channel.trySend(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
||||||
channel.close(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
||||||
channel.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return EventsConnection(socket, channel.receiveAsFlow())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `WS /api/attach/:id`: raw PTY bytes both directions, control messages
|
|
||||||
* as text JSON. */
|
|
||||||
fun connectAttach(id: Uuid): AttachConnection {
|
|
||||||
val channel = Channel<AttachFrame>(Channel.BUFFERED)
|
|
||||||
val socket = open(
|
|
||||||
Routes.attachWsUrl(baseUrl, id),
|
|
||||||
object : WebSocketListener() {
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
||||||
runCatching { wsJson.decodeFromString<ClientControl>(text) }
|
|
||||||
.onSuccess { channel.trySend(AttachFrame.Control(it)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
|
||||||
channel.trySend(AttachFrame.Pty(bytes.toByteArray()))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
||||||
channel.close(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
||||||
channel.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return AttachConnection(socket, channel.receiveAsFlow())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `WS /api/structured/:id`: transcript/state snapshots and live deltas
|
|
||||||
* for one structured session. */
|
|
||||||
fun connectStructured(id: Uuid): StructuredConnection {
|
|
||||||
val channel = Channel<StructuredEvent>(Channel.BUFFERED)
|
|
||||||
val socket = open(
|
|
||||||
Routes.structuredWsUrl(baseUrl, id),
|
|
||||||
object : WebSocketListener() {
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
||||||
runCatching { wsJson.decodeFromString<StructuredEvent>(text) }
|
|
||||||
.onSuccess { channel.trySend(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
||||||
channel.close(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
||||||
channel.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return StructuredConnection(socket, channel.receiveAsFlow())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun open(url: String, listener: WebSocketListener): WebSocket {
|
|
||||||
val request = Request.Builder().url(url).build()
|
|
||||||
return httpClient.newWebSocket(request, listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package pt.naps.maestro.api
|
|
||||||
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
/** The daemon's `/api` surface: paths, and the scheme swap needed to reach
|
|
||||||
* the WebSocket endpoints. Mirrors `maestro-protocol::routes` /
|
|
||||||
* `web/src/routes.ts`. */
|
|
||||||
object Routes {
|
|
||||||
const val API_PREFIX = "/api"
|
|
||||||
|
|
||||||
const val SESSIONS = "/api/sessions"
|
|
||||||
const val INFO = "/api/info"
|
|
||||||
const val DIRS = "/api/dirs"
|
|
||||||
const val RECENT_DIRS = "/api/recent-dirs"
|
|
||||||
const val PROJECTS = "/api/projects"
|
|
||||||
const val GITROOT = "/api/gitroot"
|
|
||||||
const val NAMES = "/api/names"
|
|
||||||
const val BRANCHES = "/api/branches"
|
|
||||||
const val EVENTS = "/api/events"
|
|
||||||
|
|
||||||
/** Name of the query parameter [DIRS], [GITROOT] and [BRANCHES] take. */
|
|
||||||
const val PATH_QUERY = "path"
|
|
||||||
|
|
||||||
/** Name of the query parameter `DELETE /api/sessions/:id` takes:
|
|
||||||
* `force=true` overrides the kill refusal over a guarded worktree. */
|
|
||||||
const val FORCE_QUERY = "force"
|
|
||||||
|
|
||||||
/** Name of the query parameter [NAMES] takes. */
|
|
||||||
const val COUNT_QUERY = "count"
|
|
||||||
|
|
||||||
fun session(id: Uuid): String = "$SESSIONS/$id"
|
|
||||||
|
|
||||||
fun sessionFocus(id: Uuid): String = "${session(id)}/focus"
|
|
||||||
|
|
||||||
fun attach(id: Uuid): String = "$API_PREFIX/attach/$id"
|
|
||||||
|
|
||||||
/** `WS /api/structured/:id` — transcript/state snapshots and live
|
|
||||||
* deltas. */
|
|
||||||
fun structured(id: Uuid): String = "$API_PREFIX/structured/$id"
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
|
|
||||||
fun structuredPrompt(id: Uuid): String = "${session(id)}/structured/prompt"
|
|
||||||
|
|
||||||
fun structuredAttachment(id: Uuid): String = "${session(id)}/structured/attachments"
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
|
|
||||||
fun structuredCancel(id: Uuid): String = "${session(id)}/structured/cancel"
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/config` — change an ACP session
|
|
||||||
* option. */
|
|
||||||
fun structuredConfig(id: Uuid): String = "${session(id)}/structured/config"
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a
|
|
||||||
* permission request. */
|
|
||||||
fun structuredApproval(id: Uuid, nonce: String): String =
|
|
||||||
"${session(id)}/structured/approvals/$nonce"
|
|
||||||
|
|
||||||
/** Strip a trailing slash so `{base}{path}` never doubles up. */
|
|
||||||
fun normalizeBase(base: String): String = base.trimEnd('/')
|
|
||||||
|
|
||||||
/** Join a daemon base URL and one of this object's paths. */
|
|
||||||
fun httpUrl(base: String, path: String): String = "${normalizeBase(base)}$path"
|
|
||||||
|
|
||||||
/** Swap an HTTP base URL for its WebSocket equivalent: `http://` becomes
|
|
||||||
* `ws://`, `https://` becomes `wss://`; a base already `ws(s)://` passes
|
|
||||||
* through. */
|
|
||||||
fun wsBase(httpBase: String): String {
|
|
||||||
val base = normalizeBase(httpBase)
|
|
||||||
return when {
|
|
||||||
base.startsWith("https://") -> "wss://${base.removePrefix("https://")}"
|
|
||||||
base.startsWith("http://") -> "ws://${base.removePrefix("http://")}"
|
|
||||||
base.startsWith("wss://") || base.startsWith("ws://") -> base
|
|
||||||
else -> throw IllegalArgumentException(
|
|
||||||
"daemon url has no supported scheme (want http/https/ws/wss): $httpBase",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun eventsWsUrl(base: String): String = "${wsBase(base)}$EVENTS"
|
|
||||||
|
|
||||||
fun attachWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${attach(id)}"
|
|
||||||
|
|
||||||
fun structuredWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${structured(id)}"
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exponential backoff for daemon reconnect attempts: doubles from
|
|
||||||
* [initialMs] up to [maxMs], then holds. A phone's radio swinging between
|
|
||||||
* wifi and cellular (or losing signal outright) is a bigger, longer-lived
|
|
||||||
* change than the LAN blips the web/TUI clients ride out with a flat poll,
|
|
||||||
* so this backs off instead of hammering the daemon while nothing changed.
|
|
||||||
*/
|
|
||||||
class Backoff(
|
|
||||||
private val initialMs: Long = INITIAL_MS,
|
|
||||||
private val maxMs: Long = MAX_MS,
|
|
||||||
) {
|
|
||||||
private var currentMs = initialMs
|
|
||||||
|
|
||||||
/** Delay before the next attempt; advances the sequence for the one after. */
|
|
||||||
fun nextDelayMs(): Long {
|
|
||||||
val delay = currentMs
|
|
||||||
currentMs = (currentMs * 2).coerceAtMost(maxMs)
|
|
||||||
return delay
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Back to [initialMs] after a successful connection. */
|
|
||||||
fun reset() {
|
|
||||||
currentMs = initialMs
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val INITIAL_MS = 500L
|
|
||||||
const val MAX_MS = 10_000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import pt.naps.maestro.data.CatalogEvent
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session catalog folded from `WS /api/events`. A [CatalogEvent.Snapshot]
|
|
||||||
* replaces the map outright; deltas mutate one entry.
|
|
||||||
*/
|
|
||||||
class CatalogStore {
|
|
||||||
private val _sessions = MutableStateFlow<Map<Uuid, Session>>(emptyMap())
|
|
||||||
val sessions: StateFlow<Map<Uuid, Session>> = _sessions.asStateFlow()
|
|
||||||
|
|
||||||
/** Applies one event. Returns the skip count for [CatalogEvent.Lagged] —
|
|
||||||
* the caller re-fetches a full snapshot over REST and calls [replace],
|
|
||||||
* mirroring how the web client reacts to a dropped broadcast frame. */
|
|
||||||
fun apply(event: CatalogEvent): Long? =
|
|
||||||
when (event) {
|
|
||||||
is CatalogEvent.Snapshot -> {
|
|
||||||
_sessions.value = event.sessions.associateBy { it.id }
|
|
||||||
null
|
|
||||||
}
|
|
||||||
is CatalogEvent.SessionAdded -> {
|
|
||||||
_sessions.value = _sessions.value + (event.session.id to event.session)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
is CatalogEvent.SessionUpdated -> {
|
|
||||||
_sessions.value = _sessions.value + (event.session.id to event.session)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
is CatalogEvent.SessionRemoved -> {
|
|
||||||
_sessions.value = _sessions.value - event.id
|
|
||||||
null
|
|
||||||
}
|
|
||||||
is CatalogEvent.Lagged -> event.skipped
|
|
||||||
}
|
|
||||||
|
|
||||||
fun replace(sessions: List<Session>) {
|
|
||||||
_sessions.value = sessions.associateBy { it.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
_sessions.value = emptyMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notifies [onAvailable] whenever a usable network appears — including the
|
|
||||||
* wifi/cellular handoff, which Android reports as one network disappearing
|
|
||||||
* and another becoming available rather than a single "changed" event.
|
|
||||||
* [DaemonLink] treats every call as "worth reconnecting for" rather than
|
|
||||||
* inspecting which network it is.
|
|
||||||
*/
|
|
||||||
class ConnectivityObserver(
|
|
||||||
context: Context,
|
|
||||||
private val onAvailable: () -> Unit,
|
|
||||||
) {
|
|
||||||
private val connectivityManager =
|
|
||||||
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
|
|
||||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
|
||||||
override fun onAvailable(network: Network) = onAvailable.invoke()
|
|
||||||
|
|
||||||
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
|
||||||
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
|
||||||
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
|
||||||
) {
|
|
||||||
onAvailable.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var registered = false
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
if (registered) return
|
|
||||||
val request = NetworkRequest.Builder()
|
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
||||||
.build()
|
|
||||||
connectivityManager.registerNetworkCallback(request, callback)
|
|
||||||
registered = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
if (!registered) return
|
|
||||||
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
|
|
||||||
registered = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import pt.naps.maestro.data.BuildInfo
|
|
||||||
import pt.naps.maestro.data.DaemonInfo
|
|
||||||
|
|
||||||
/** What happened between two `/api/info` replies. */
|
|
||||||
enum class RestartKind { SAME, RESTARTED, UPGRADED }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare two daemon replies. Mirrors `compareIdentity` in
|
|
||||||
* `web/src/components/daemon/DaemonLink.tsx` / `compare_identity` in
|
|
||||||
* `crates/maestro-tui/src/daemon_link.rs`.
|
|
||||||
*
|
|
||||||
* The commit decides first: a different one is new server code. `instance`
|
|
||||||
* catches everything else — a restart of the same build — and is the only
|
|
||||||
* signal for a daemon built outside a git checkout, where the commit is
|
|
||||||
* always empty and an empty-vs-empty pair must never read as an upgrade.
|
|
||||||
*/
|
|
||||||
fun compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
|
|
||||||
val a = before.build.commit
|
|
||||||
val b = after.build.commit
|
|
||||||
if (a.isNotEmpty() && b.isNotEmpty() && a != b) return RestartKind.UPGRADED
|
|
||||||
if (before.instance.isNotEmpty() && after.instance.isNotEmpty() && before.instance != after.instance) {
|
|
||||||
return RestartKind.RESTARTED
|
|
||||||
}
|
|
||||||
return RestartKind.SAME
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
|
|
||||||
* build carried uncommitted changes. Mirrors `formatRelease` / `format_release`. */
|
|
||||||
fun formatRelease(build: BuildInfo): String {
|
|
||||||
if (build.commit.isEmpty()) return "unknown release"
|
|
||||||
val parts = mutableListOf(build.commit)
|
|
||||||
if (build.commit_date.length >= 10) parts.add("· ${build.commit_date.take(10)}")
|
|
||||||
if (build.dirty) parts.add("(dirty)")
|
|
||||||
return parts.joinToString(" ")
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import pt.naps.maestro.api.EventsConnection
|
|
||||||
import pt.naps.maestro.api.MaestroClient
|
|
||||||
import pt.naps.maestro.api.MaestroWebSocket
|
|
||||||
import pt.naps.maestro.data.BuildInfo
|
|
||||||
import pt.naps.maestro.data.DaemonInfo
|
|
||||||
|
|
||||||
/** How long a dropped link stays [LinkState.RECONNECTING] before flipping to
|
|
||||||
* [LinkState.DOWN] — long enough to ride out a blip, short enough that a real
|
|
||||||
* outage shows up. Mirrors the grace window in `docs/RESTART.md`. */
|
|
||||||
private const val GRACE_MS = 1_200L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the connection to one daemon for the app's whole life: liveness,
|
|
||||||
* identity, and the session catalog. A Kotlin port of the state machine in
|
|
||||||
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
|
||||||
* — see `docs/RESTART.md` for the contract both mirror — with exponential
|
|
||||||
* [Backoff] standing in for their flat poll, since a phone's network changes
|
|
||||||
* are bigger and longer-lived than the LAN blips those clients ride out.
|
|
||||||
*
|
|
||||||
* Everything that can change the link — a probe landing, the events socket
|
|
||||||
* dropping, the app resuming, the network coming back, a new daemon URL —
|
|
||||||
* goes through one [signals] channel consumed by one coroutine, so state
|
|
||||||
* transitions never race each other.
|
|
||||||
*/
|
|
||||||
class DaemonLink(
|
|
||||||
context: Context,
|
|
||||||
private val prefs: DaemonPrefs,
|
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
|
||||||
private val httpClient: OkHttpClient = OkHttpClient(),
|
|
||||||
) : DefaultLifecycleObserver {
|
|
||||||
private val appContext = context.applicationContext
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(LinkState.CONNECTING)
|
|
||||||
val state: StateFlow<LinkState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private val _build = MutableStateFlow<BuildInfo?>(null)
|
|
||||||
val build: StateFlow<BuildInfo?> = _build.asStateFlow()
|
|
||||||
|
|
||||||
private val _attempts = MutableStateFlow(0)
|
|
||||||
val attempts: StateFlow<Int> = _attempts.asStateFlow()
|
|
||||||
|
|
||||||
val catalog = CatalogStore()
|
|
||||||
|
|
||||||
private val backoff = Backoff()
|
|
||||||
private val signals = Channel<Signal>(Channel.UNLIMITED)
|
|
||||||
private val connectivity = ConnectivityObserver(appContext) { signals.trySend(Signal.NetworkAvailable) }
|
|
||||||
|
|
||||||
private var generation = 0
|
|
||||||
private var currentBaseUrl: String = prefs.baseUrl.value
|
|
||||||
private var client = MaestroClient(currentBaseUrl, httpClient)
|
|
||||||
private var socket = MaestroWebSocket(currentBaseUrl, httpClient)
|
|
||||||
private var identity: DaemonInfo? = null
|
|
||||||
|
|
||||||
private var eventsJob: Job? = null
|
|
||||||
private var eventsConnection: EventsConnection? = null
|
|
||||||
private var retryJob: Job? = null
|
|
||||||
private var graceJob: Job? = null
|
|
||||||
private var loopJob: Job? = null
|
|
||||||
private var started = false
|
|
||||||
|
|
||||||
private sealed class Signal {
|
|
||||||
object Start : Signal()
|
|
||||||
object Resume : Signal()
|
|
||||||
object NetworkAvailable : Signal()
|
|
||||||
object SocketDropped : Signal()
|
|
||||||
data class BaseUrlChanged(val url: String) : Signal()
|
|
||||||
data class ProbeResult(val generation: Int, val info: DaemonInfo?) : Signal()
|
|
||||||
data class Lagged(val skipped: Long) : Signal()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Starts the link: subscribes to process lifecycle and connectivity, and
|
|
||||||
* kicks off the first probe. Call once. */
|
|
||||||
fun start() {
|
|
||||||
if (started) return
|
|
||||||
started = true
|
|
||||||
loopJob = scope.launch { runLoop() }
|
|
||||||
connectivity.start()
|
|
||||||
scope.launch(Dispatchers.Main.immediate) {
|
|
||||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this@DaemonLink)
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
prefs.baseUrl.collect { url ->
|
|
||||||
if (url != currentBaseUrl) signals.trySend(Signal.BaseUrlChanged(url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
signals.trySend(Signal.Start)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tears everything down. Only needed by tests and by an owner that is
|
|
||||||
* itself going away for good (this link is meant to outlive an Activity). */
|
|
||||||
fun close() {
|
|
||||||
connectivity.stop()
|
|
||||||
cancelRetry()
|
|
||||||
cancelGrace()
|
|
||||||
closeEvents()
|
|
||||||
loopJob?.cancel()
|
|
||||||
signals.close()
|
|
||||||
scope.launch(Dispatchers.Main.immediate) {
|
|
||||||
runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this@DaemonLink) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
|
||||||
signals.trySend(Signal.Resume)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runLoop() {
|
|
||||||
for (signal in signals) {
|
|
||||||
when (signal) {
|
|
||||||
is Signal.Start -> probeNow()
|
|
||||||
is Signal.Resume -> {
|
|
||||||
probeNow()
|
|
||||||
ensureEventsConnected()
|
|
||||||
}
|
|
||||||
is Signal.NetworkAvailable -> {
|
|
||||||
cancelRetry()
|
|
||||||
probeNow()
|
|
||||||
}
|
|
||||||
is Signal.SocketDropped -> handleSocketDropped()
|
|
||||||
is Signal.BaseUrlChanged -> handleBaseUrlChanged(signal.url)
|
|
||||||
is Signal.ProbeResult -> {
|
|
||||||
if (signal.generation == generation) handleProbeResult(signal.info)
|
|
||||||
}
|
|
||||||
is Signal.Lagged -> handleLagged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun probeNow() {
|
|
||||||
val gen = generation
|
|
||||||
val currentClient = client
|
|
||||||
scope.launch {
|
|
||||||
val info = runCatching { currentClient.getDaemonInfo() }.getOrNull()
|
|
||||||
signals.trySend(Signal.ProbeResult(gen, info))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleProbeResult(info: DaemonInfo?) {
|
|
||||||
if (info == null) {
|
|
||||||
onProbeFailed()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val before = identity
|
|
||||||
identity = info
|
|
||||||
_build.value = info.build
|
|
||||||
if (before != null && compareIdentity(before, info) == RestartKind.UPGRADED) {
|
|
||||||
_state.value = LinkState.UPDATING
|
|
||||||
fullReconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
backoff.reset()
|
|
||||||
cancelRetry()
|
|
||||||
cancelGrace()
|
|
||||||
_attempts.value = 0
|
|
||||||
_state.value = LinkState.ONLINE
|
|
||||||
ensureEventsConnected()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onProbeFailed() {
|
|
||||||
_attempts.value += 1
|
|
||||||
if (_state.value != LinkState.DOWN && _state.value != LinkState.UPDATING) {
|
|
||||||
_state.value = LinkState.RECONNECTING
|
|
||||||
armGrace()
|
|
||||||
}
|
|
||||||
scheduleRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun armGrace() {
|
|
||||||
if (graceJob?.isActive == true) return
|
|
||||||
graceJob = scope.launch {
|
|
||||||
delay(GRACE_MS)
|
|
||||||
if (_state.value == LinkState.RECONNECTING) _state.value = LinkState.DOWN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cancelGrace() {
|
|
||||||
graceJob?.cancel()
|
|
||||||
graceJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleRetry() {
|
|
||||||
if (retryJob?.isActive == true) return
|
|
||||||
val delayMs = backoff.nextDelayMs()
|
|
||||||
retryJob = scope.launch {
|
|
||||||
delay(delayMs)
|
|
||||||
retryJob = null
|
|
||||||
probeNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cancelRetry() {
|
|
||||||
retryJob?.cancel()
|
|
||||||
retryJob = null
|
|
||||||
backoff.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A build-stamp change: drop the events socket and the catalog it fed,
|
|
||||||
* then probe again shortly. The re-probe sees the same identity it just
|
|
||||||
* recorded, so it resolves straight to [LinkState.ONLINE] and reopens
|
|
||||||
* the socket — this *is* the full reconnect. */
|
|
||||||
private fun fullReconnect() {
|
|
||||||
closeEvents()
|
|
||||||
catalog.clear()
|
|
||||||
backoff.reset()
|
|
||||||
scope.launch {
|
|
||||||
delay(200)
|
|
||||||
probeNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleSocketDropped() {
|
|
||||||
if (_state.value == LinkState.DOWN || _state.value == LinkState.UPDATING) return
|
|
||||||
_state.value = LinkState.RECONNECTING
|
|
||||||
armGrace()
|
|
||||||
probeNow()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleBaseUrlChanged(url: String) {
|
|
||||||
currentBaseUrl = url
|
|
||||||
generation += 1
|
|
||||||
client = MaestroClient(url, httpClient)
|
|
||||||
socket = MaestroWebSocket(url, httpClient)
|
|
||||||
identity = null
|
|
||||||
_build.value = null
|
|
||||||
_attempts.value = 0
|
|
||||||
backoff.reset()
|
|
||||||
cancelRetry()
|
|
||||||
cancelGrace()
|
|
||||||
closeEvents()
|
|
||||||
catalog.clear()
|
|
||||||
_state.value = LinkState.CONNECTING
|
|
||||||
probeNow()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The broadcast channel dropped frames: re-fetch the full list over REST
|
|
||||||
* rather than trusting the map to still be complete. */
|
|
||||||
private fun handleLagged() {
|
|
||||||
val currentClient = client
|
|
||||||
scope.launch {
|
|
||||||
runCatching { currentClient.listSessions() }.onSuccess { catalog.replace(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureEventsConnected() {
|
|
||||||
if (eventsJob?.isActive == true) return
|
|
||||||
val connection = socket.connectEvents()
|
|
||||||
eventsConnection = connection
|
|
||||||
eventsJob = scope.launch {
|
|
||||||
try {
|
|
||||||
connection.events.collect { event ->
|
|
||||||
val skipped = catalog.apply(event)
|
|
||||||
if (skipped != null) signals.trySend(Signal.Lagged(skipped))
|
|
||||||
}
|
|
||||||
signals.trySend(Signal.SocketDropped)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
signals.trySend(Signal.SocketDropped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun closeEvents() {
|
|
||||||
eventsJob?.cancel()
|
|
||||||
eventsJob = null
|
|
||||||
eventsConnection?.close()
|
|
||||||
eventsConnection = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the daemon base URL lives between launches. A settings screen and
|
|
||||||
* the [EXTRA_DAEMON_URL] intent extra both write through here; [DaemonLink]
|
|
||||||
* reads [baseUrl] and reconnects on change.
|
|
||||||
*/
|
|
||||||
class DaemonPrefs(context: Context) {
|
|
||||||
private val prefs = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
private val _baseUrl =
|
|
||||||
MutableStateFlow(prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL)
|
|
||||||
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
|
|
||||||
|
|
||||||
fun setBaseUrl(url: String) {
|
|
||||||
val trimmed = url.trim()
|
|
||||||
if (trimmed.isEmpty() || trimmed == _baseUrl.value) return
|
|
||||||
prefs.edit { putString(KEY_BASE_URL, trimmed) }
|
|
||||||
_baseUrl.value = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val FILE = "daemon_prefs"
|
|
||||||
private const val KEY_BASE_URL = "base_url"
|
|
||||||
|
|
||||||
/** Shown until the user points the app at their own daemon. */
|
|
||||||
const val DEFAULT_BASE_URL = "http://192.168.1.x:7070"
|
|
||||||
|
|
||||||
/** Intent extra a launcher may set to point this launch at a specific
|
|
||||||
* daemon without going through settings. Persisted on read, so it
|
|
||||||
* sticks after the launch that carried it. */
|
|
||||||
const val EXTRA_DAEMON_URL = "pt.naps.maestro.EXTRA_DAEMON_URL"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connection lifecycle to the daemon. Mirrors the states in
|
|
||||||
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
|
||||||
* (see `docs/RESTART.md`), plus [UPDATING] for a build-stamp change.
|
|
||||||
*
|
|
||||||
* - [CONNECTING] — first probe hasn't landed yet.
|
|
||||||
* - [ONLINE] — the daemon answered and identity is settled.
|
|
||||||
* - [RECONNECTING] — the link dropped, still inside the grace window.
|
|
||||||
* - [DOWN] — unreachable past the grace window; the restart overlay blocks.
|
|
||||||
* - [UPDATING] — the daemon answered on a different commit; reconnecting fresh.
|
|
||||||
*/
|
|
||||||
enum class LinkState { CONNECTING, ONLINE, RECONNECTING, DOWN, UPDATING }
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
|
||||||
import kotlinx.serialization.encoding.Decoder
|
|
||||||
import kotlinx.serialization.encoding.Encoder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session activity, orthogonal to [SessionStatus]. Mirrors
|
|
||||||
* `maestro_protocol::ActivityState`.
|
|
||||||
*
|
|
||||||
* Deserializes leniently: an unknown variant from a newer daemon decodes as
|
|
||||||
* [Idle] instead of failing the whole [Session].
|
|
||||||
*/
|
|
||||||
@Serializable(with = ActivityStateSerializer::class)
|
|
||||||
enum class ActivityState {
|
|
||||||
Active,
|
|
||||||
Idle,
|
|
||||||
AwaitingInput,
|
|
||||||
Error,
|
|
||||||
Starting,
|
|
||||||
}
|
|
||||||
|
|
||||||
object ActivityStateSerializer : KSerializer<ActivityState> {
|
|
||||||
override val descriptor: SerialDescriptor =
|
|
||||||
PrimitiveSerialDescriptor("ActivityState", PrimitiveKind.STRING)
|
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: ActivityState) {
|
|
||||||
encoder.encodeString(value.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): ActivityState =
|
|
||||||
when (decoder.decodeString()) {
|
|
||||||
"Active" -> ActivityState.Active
|
|
||||||
"AwaitingInput" -> ActivityState.AwaitingInput
|
|
||||||
"Error" -> ActivityState.Error
|
|
||||||
"Starting" -> ActivityState.Starting
|
|
||||||
else -> ActivityState.Idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** Structured failure body inside an [ApiError] envelope. Mirrors
|
|
||||||
* `maestro_protocol::ErrorInfo`. */
|
|
||||||
@Serializable
|
|
||||||
data class ErrorInfo(
|
|
||||||
val code: String,
|
|
||||||
val message: String,
|
|
||||||
/** Set only by the kill guard: what blocked an unforced
|
|
||||||
* `DELETE /api/sessions/:id`. */
|
|
||||||
val unsaved_work: InTheWay? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The envelope every `/api` failure is wrapped in: `{"error": {...}}`.
|
|
||||||
* Mirrors `maestro_protocol::ApiError`. */
|
|
||||||
@Serializable
|
|
||||||
data class ApiError(val error: ErrorInfo)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** One local branch of a repository, from `GET /api/branches`. A client
|
|
||||||
* disables the entry when [is_head] is set or [worktree] is present — git
|
|
||||||
* refuses to check the same branch out in two worktrees at once. Mirrors
|
|
||||||
* `maestro_protocol::BranchInfo`. */
|
|
||||||
@Serializable
|
|
||||||
data class BranchInfo(
|
|
||||||
/** Short branch name (`main`, `feature/foo`). */
|
|
||||||
val name: String,
|
|
||||||
/** Whether this branch is the main checkout's current HEAD. */
|
|
||||||
val is_head: Boolean,
|
|
||||||
/** Toplevel path of the linked worktree holding this branch, if any. */
|
|
||||||
val worktree: String? = null,
|
|
||||||
)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Messages the daemon broadcasts to clients over `WS /api/events`. Mirrors
|
|
||||||
* `maestro_protocol::CatalogEvent`; tagged by a `type` field.
|
|
||||||
*
|
|
||||||
* A subscriber receives a single [Snapshot] immediately on connect, then a
|
|
||||||
* stream of deltas as sessions are created, updated, and killed. Skipped
|
|
||||||
* events (broadcast channel lag) surface as [Lagged].
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
sealed class CatalogEvent {
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Snapshot")
|
|
||||||
data class Snapshot(val sessions: List<Session>) : CatalogEvent()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("SessionAdded")
|
|
||||||
data class SessionAdded(val session: Session) : CatalogEvent()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("SessionRemoved")
|
|
||||||
data class SessionRemoved(val id: Uuid) : CatalogEvent()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("SessionUpdated")
|
|
||||||
data class SessionUpdated(val session: Session) : CatalogEvent()
|
|
||||||
|
|
||||||
/** Server-issued notification that the broadcast queue dropped frames.
|
|
||||||
* Clients should re-fetch a full snapshot. */
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Lagged")
|
|
||||||
data class Lagged(val skipped: Long) : CatalogEvent()
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** Text-frame control messages on a `WS /api/attach/:id` connection. PTY
|
|
||||||
* bytes travel in binary frames and are not modeled here. Mirrors
|
|
||||||
* `maestro_protocol::ClientControl`; tagged by a `type` field. */
|
|
||||||
@Serializable
|
|
||||||
sealed class ClientControl {
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Resize")
|
|
||||||
data class Resize(val cols: Int, val rows: Int) : ClientControl()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Ping")
|
|
||||||
object Ping : ClientControl()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Pong")
|
|
||||||
object Pong : ClientControl()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("Error")
|
|
||||||
data class Error(
|
|
||||||
val code: String,
|
|
||||||
val message: String,
|
|
||||||
val unsaved_work: InTheWay? = null,
|
|
||||||
) : ClientControl()
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** `GET /api/info` — the daemon's identity plus its spawn presets. Mirrors
|
|
||||||
* `maestro_protocol::DaemonInfo`. */
|
|
||||||
@Serializable
|
|
||||||
data class DaemonInfo(
|
|
||||||
val hostname: String = "",
|
|
||||||
/** The protocol version the daemon implements. */
|
|
||||||
val version: String = "",
|
|
||||||
/** Home dir of the user the daemon runs as; empty for older daemons. */
|
|
||||||
val home_dir: String = "",
|
|
||||||
val presets: List<SpawnPreset> = emptyList(),
|
|
||||||
val build: BuildInfo = BuildInfo(),
|
|
||||||
/** Identity of the daemon *process*, regenerated on every start — a
|
|
||||||
* changed value across a reconnect means the daemon restarted. */
|
|
||||||
val instance: String = "",
|
|
||||||
/** Canonical leader key the operator configured (`[tui].leader`), so a
|
|
||||||
* client binds the same chord the TUI does. Empty means "use your
|
|
||||||
* default". */
|
|
||||||
val leader: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A spawn-form command preset advertised by the daemon. */
|
|
||||||
@Serializable
|
|
||||||
data class SpawnPreset(
|
|
||||||
val id: String,
|
|
||||||
val label: String,
|
|
||||||
val command: String,
|
|
||||||
val auto_approve: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Build provenance of the running daemon. Every field is empty when the
|
|
||||||
* binary was built outside a git checkout. */
|
|
||||||
@Serializable
|
|
||||||
data class BuildInfo(
|
|
||||||
val commit: String = "",
|
|
||||||
val commit_date: String = "",
|
|
||||||
val dirty: Boolean = false,
|
|
||||||
)
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** `GET /api/dirs?path=` — one directory's immediate subdirectories. Mirrors
|
|
||||||
* `maestro_protocol::DirListing`. */
|
|
||||||
@Serializable
|
|
||||||
data class DirListing(
|
|
||||||
/** Resolved absolute directory that was listed (after ~/relative
|
|
||||||
* expansion). */
|
|
||||||
val path: String,
|
|
||||||
/** Immediate subdirectory names, sorted case-insensitively. */
|
|
||||||
val entries: List<String> = emptyList(),
|
|
||||||
/** True when the listing was capped server-side. */
|
|
||||||
val truncated: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** One deduplicated recent working dir for the spawn form's recents picker.
|
|
||||||
* Mirrors `maestro_protocol::RecentDir`. */
|
|
||||||
@Serializable
|
|
||||||
data class RecentDir(
|
|
||||||
val cwd: String,
|
|
||||||
val last_command: String,
|
|
||||||
val last_used: IsoTimestamp,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `GET /api/recent-dirs` — the daemon's recent working dirs, newest-first
|
|
||||||
* and deduped by `cwd`. */
|
|
||||||
@Serializable
|
|
||||||
data class RecentDirs(
|
|
||||||
val dirs: List<RecentDir> = emptyList(),
|
|
||||||
val truncated: Boolean = false,
|
|
||||||
)
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or
|
|
||||||
* `null` when the path isn't inside a repo. Mirrors
|
|
||||||
* `maestro_protocol::GitRoot`. */
|
|
||||||
@Serializable
|
|
||||||
data class GitRoot(val root: String? = null)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
/** A session id. Mirrors `uuid::Uuid` on the wire — a plain UUID string. */
|
|
||||||
typealias Uuid = String
|
|
||||||
|
|
||||||
/** An RFC 3339 timestamp, as the daemon serializes `chrono::DateTime<Utc>`. */
|
|
||||||
typealias IsoTimestamp = String
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** What currently blocks an unforced kill of a session's worktree,
|
|
||||||
* recomputed by the daemon on every read. Mirrors
|
|
||||||
* `maestro_protocol::InTheWay`. */
|
|
||||||
@Serializable
|
|
||||||
data class InTheWay(
|
|
||||||
val uncommitted_files: Int = 0,
|
|
||||||
val untracked_files: Int = 0,
|
|
||||||
val unpushed_commits: Int = 0,
|
|
||||||
/** Lines added across the tracked changes, staged and unstaged together. */
|
|
||||||
val insertions: Int = 0,
|
|
||||||
/** Lines removed across the same tracked changes. */
|
|
||||||
val deletions: Int = 0,
|
|
||||||
)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** `GET /api/names?count=N` — a batch of fresh session handles. Mirrors
|
|
||||||
* `maestro_protocol::NameBatch`. */
|
|
||||||
@Serializable
|
|
||||||
data class NameBatch(val names: List<String>)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
|
||||||
import kotlinx.serialization.encoding.Decoder
|
|
||||||
import kotlinx.serialization.encoding.Encoder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a session's stored name (`metadata["name"]`) came from. Mirrors
|
|
||||||
* `maestro_protocol::NameSource`.
|
|
||||||
*
|
|
||||||
* Deserializes leniently: an unknown spelling decodes as [User], the settled
|
|
||||||
* variant — a name wrongly painted as settled is only a missed dimming; one
|
|
||||||
* wrongly painted as provisional invites overwriting a name a person chose.
|
|
||||||
*/
|
|
||||||
@Serializable(with = NameSourceSerializer::class)
|
|
||||||
enum class NameSource {
|
|
||||||
User,
|
|
||||||
Generated,
|
|
||||||
Haiku,
|
|
||||||
}
|
|
||||||
|
|
||||||
object NameSourceSerializer : KSerializer<NameSource> {
|
|
||||||
override val descriptor: SerialDescriptor =
|
|
||||||
PrimitiveSerialDescriptor("NameSource", PrimitiveKind.STRING)
|
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: NameSource) {
|
|
||||||
encoder.encodeString(value.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): NameSource =
|
|
||||||
when (decoder.decodeString()) {
|
|
||||||
"Generated" -> NameSource.Generated
|
|
||||||
"Haiku" -> NameSource.Haiku
|
|
||||||
else -> NameSource.User
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/** One discovered git repository (or linked worktree) for `GET
|
|
||||||
* /api/projects`. Mirrors `maestro_protocol::Project`. */
|
|
||||||
@Serializable
|
|
||||||
data class Project(
|
|
||||||
/** Absolute path of the repository/worktree toplevel. */
|
|
||||||
val path: String,
|
|
||||||
/** Basename of [path]. */
|
|
||||||
val name: String,
|
|
||||||
val is_worktree: Boolean = false,
|
|
||||||
/** The main checkout this worktree belongs to; `null` on plain repos. */
|
|
||||||
val base_repo: String? = null,
|
|
||||||
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
|
|
||||||
* any directory inside it; `null` when neither is known. */
|
|
||||||
val last_activity: IsoTimestamp? = null,
|
|
||||||
/** Command last spawned in this project, from spawn history. */
|
|
||||||
val last_command: String? = null,
|
|
||||||
)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
|
|
||||||
/** Default for [Session.last_activity_at] / [Session.activity_since] when an
|
|
||||||
* older payload omits them — mirrors the daemon's Unix-epoch default. */
|
|
||||||
const val EPOCH: IsoTimestamp = "1970-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
/** A live or recently-dead tmux session managed by the daemon. Mirrors
|
|
||||||
* `maestro_protocol::Session`. */
|
|
||||||
@Serializable
|
|
||||||
data class Session(
|
|
||||||
val id: Uuid,
|
|
||||||
val cwd: String,
|
|
||||||
val command: String,
|
|
||||||
val env: Map<String, String> = emptyMap(),
|
|
||||||
val metadata: Map<String, JsonElement> = emptyMap(),
|
|
||||||
/** The worktree this session owns, when it was spawned with one. */
|
|
||||||
val worktree: SessionWorktree? = null,
|
|
||||||
val status: SessionStatus,
|
|
||||||
val created_at: IsoTimestamp,
|
|
||||||
/** Current foreground command in the pane (e.g. `claude`, `bash`), as the
|
|
||||||
* daemon's activity watcher observed it. `null` until observed. */
|
|
||||||
val foreground: String? = null,
|
|
||||||
/** Inferred activity dimension, orthogonal to [status]. */
|
|
||||||
val activity: ActivityState = ActivityState.Idle,
|
|
||||||
/** Short human string an agent reported alongside its activity — e.g. why
|
|
||||||
* it is in [ActivityState.Error]. `null` unless an agent reported one. */
|
|
||||||
val activity_detail: String? = null,
|
|
||||||
val last_activity_at: IsoTimestamp = EPOCH,
|
|
||||||
val activity_since: IsoTimestamp = EPOCH,
|
|
||||||
/** The pane's *current* working directory, distinct from [cwd] (the spawn
|
|
||||||
* dir). `null` until first observed. */
|
|
||||||
val current_cwd: String? = null,
|
|
||||||
/** What currently blocks an unforced kill of this session, as the
|
|
||||||
* daemon's watcher last probed it. `null` while clear or not yet probed. */
|
|
||||||
val blocked_work: InTheWay? = null,
|
|
||||||
/** When this session last became the pane a client was showing. `null`
|
|
||||||
* until the session has been focused at least once. */
|
|
||||||
val last_focused_at: IsoTimestamp? = null,
|
|
||||||
/** Terminal (PTY bytes) or structured (ACP rows). Fixed at spawn and
|
|
||||||
* immutable for the session's life. */
|
|
||||||
val view: SessionView = SessionView.Terminal,
|
|
||||||
/** The ACP session id the selected provider adapter returned from
|
|
||||||
* `session/new`. Only a [SessionView.Structured] session whose handshake
|
|
||||||
* succeeded has one. */
|
|
||||||
val acp_session_id: String? = null,
|
|
||||||
/** When `true`, the daemon auto-approves every ACP permission request for
|
|
||||||
* this session instead of surfacing them to the client. */
|
|
||||||
val auto_approve: Boolean = false,
|
|
||||||
/** Prompts queued on the daemon while a turn was in flight or the adapter
|
|
||||||
* was still booting. */
|
|
||||||
val queued_prompts: List<String> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class SessionStatus { Running, Dead }
|
|
||||||
|
|
||||||
/** A session's process shape: mirrors `maestro_protocol::SessionView`.
|
|
||||||
* `Terminal` is the default. */
|
|
||||||
@Serializable
|
|
||||||
enum class SessionView { Terminal, Structured }
|
|
||||||
|
|
||||||
/** A session's daemon-created worktree. `base_repo` is the repository's main
|
|
||||||
* checkout, which is what the session tree groups by. */
|
|
||||||
@Serializable
|
|
||||||
data class SessionWorktree(
|
|
||||||
val path: String,
|
|
||||||
val base_repo: String,
|
|
||||||
)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
|
|
||||||
/** `POST /api/sessions` body. Mirrors
|
|
||||||
* `maestro_protocol::CreateSessionRequest`. */
|
|
||||||
@Serializable
|
|
||||||
data class CreateSessionRequest(
|
|
||||||
val cwd: String,
|
|
||||||
val command: String,
|
|
||||||
val env: Map<String, String> = emptyMap(),
|
|
||||||
val metadata: Map<String, JsonElement> = emptyMap(),
|
|
||||||
val worktree: WorktreeSpec? = null,
|
|
||||||
/** The process shape to spawn. Defaults to a `Terminal` session. */
|
|
||||||
val view: SessionView = SessionView.Terminal,
|
|
||||||
/** When `true`, the daemon auto-approves every ACP permission request for
|
|
||||||
* this structured session. */
|
|
||||||
val auto_approve: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Optional worktree request attached to a [CreateSessionRequest]. Mirrors
|
|
||||||
* `maestro_protocol::WorktreeSpec`. */
|
|
||||||
@Serializable
|
|
||||||
data class WorktreeSpec(
|
|
||||||
val base_repo: String,
|
|
||||||
val branch: String,
|
|
||||||
/** When `false`, [branch] already exists and is checked out as-is.
|
|
||||||
* Defaults to `true` (create a new branch). */
|
|
||||||
val create_branch: Boolean = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `PATCH /api/sessions/:id` body: rename a live session. An empty [name]
|
|
||||||
* clears the custom one, so the card falls back to the command. Mirrors
|
|
||||||
* `maestro_protocol::RenameSessionRequest`. */
|
|
||||||
@Serializable
|
|
||||||
data class RenameSessionRequest(val name: String)
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/focus` body: a client reports whether its pane is
|
|
||||||
* the one currently showing this session. Mirrors
|
|
||||||
* `maestro_protocol::SetFocusRequest`. */
|
|
||||||
@Serializable
|
|
||||||
data class SetFocusRequest(val focused: Boolean)
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
|
|
||||||
/** The semantic shape of a [TranscriptRow]. Mirrors
|
|
||||||
* `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
|
|
||||||
@Serializable
|
|
||||||
enum class TranscriptRowKind {
|
|
||||||
@SerialName("user") User,
|
|
||||||
@SerialName("agent_text") AgentText,
|
|
||||||
@SerialName("agent_thought") AgentThought,
|
|
||||||
@SerialName("tool_call") ToolCall,
|
|
||||||
@SerialName("tool_diff") ToolDiff,
|
|
||||||
@SerialName("approval") Approval,
|
|
||||||
@SerialName("plan") Plan,
|
|
||||||
@SerialName("notice") Notice,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One client-neutral item in a structured session transcript. [data]
|
|
||||||
* preserves the ACP payload verbatim without committing to an ACP
|
|
||||||
* dependency. Mirrors `maestro_protocol::TranscriptRow`. */
|
|
||||||
@Serializable
|
|
||||||
data class TranscriptRow(
|
|
||||||
/** Stable within one session, so later deltas can address this row. */
|
|
||||||
val id: String,
|
|
||||||
val kind: TranscriptRowKind,
|
|
||||||
val data: JsonElement,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The complete transcript at a durable event-log position — a structured
|
|
||||||
* session's first `WS /api/structured/:id` message. Mirrors
|
|
||||||
* `maestro_protocol::TranscriptSnapshot`. */
|
|
||||||
@Serializable
|
|
||||||
data class TranscriptSnapshot(
|
|
||||||
val cursor: Long,
|
|
||||||
val rows: List<TranscriptRow> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A change to one transcript row, addressed by [id]. Mirrors
|
|
||||||
* `maestro_protocol::TranscriptDelta`; tagged by a snake_case `type` field. */
|
|
||||||
@Serializable
|
|
||||||
sealed class TranscriptDelta {
|
|
||||||
@Serializable
|
|
||||||
@SerialName("append")
|
|
||||||
data class Append(val row: TranscriptRow) : TranscriptDelta()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("patch")
|
|
||||||
data class Patch(val row: TranscriptRow) : TranscriptDelta()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("remove")
|
|
||||||
data class Remove(val id: String) : TranscriptDelta()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Per-turn indicators derived from the ACP stream — read verbatim, never
|
|
||||||
* re-derived client-side. Mirrors `maestro_protocol::TurnState`. */
|
|
||||||
@Serializable
|
|
||||||
data class TurnState(
|
|
||||||
val responding: Boolean = false,
|
|
||||||
val thinking: Boolean = false,
|
|
||||||
val active_tool_calls: List<String> = emptyList(),
|
|
||||||
/** Wall-clock start of the active prompt, so a reconnecting client keeps
|
|
||||||
* one continuous elapsed-time display instead of restarting at zero. */
|
|
||||||
val started_at: IsoTimestamp? = null,
|
|
||||||
val started_context_tokens: Long? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A whole-session view derived from ACP notifications. Mirrors
|
|
||||||
* `maestro_protocol::ReducedState`.
|
|
||||||
*
|
|
||||||
* [pending_approval], [plan], [usage], [context] and [config_options] carry
|
|
||||||
* opaque ACP JSON — their shape is ACP's, not covered by this crate's own
|
|
||||||
* compatibility guarantee, so they are kept as [JsonElement] rather than
|
|
||||||
* modeled strongly.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class ReducedState(
|
|
||||||
val turn: TurnState = TurnState(),
|
|
||||||
val pending_approval: JsonElement? = null,
|
|
||||||
val plan: JsonElement? = null,
|
|
||||||
val usage: JsonElement? = null,
|
|
||||||
val context: JsonElement? = null,
|
|
||||||
val model: String? = null,
|
|
||||||
val mode: String? = null,
|
|
||||||
val config_options: JsonElement? = null,
|
|
||||||
/** Prompts queued on the daemon while a turn is in flight. */
|
|
||||||
val queued_prompts: List<String> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Server messages on `WS /api/structured/:id`. Mirrors
|
|
||||||
* `maestro_protocol::StructuredEvent`; tagged by a snake_case `type` field. */
|
|
||||||
@Serializable
|
|
||||||
sealed class StructuredEvent {
|
|
||||||
@Serializable
|
|
||||||
@SerialName("transcript_snapshot")
|
|
||||||
data class TranscriptSnapshotEvent(val snapshot: TranscriptSnapshot) : StructuredEvent()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("reduced_state")
|
|
||||||
data class ReducedStateEvent(val state: ReducedState) : StructuredEvent()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("projection_update")
|
|
||||||
data class ProjectionUpdate(
|
|
||||||
val transcript: TranscriptDelta? = null,
|
|
||||||
val state: ReducedState,
|
|
||||||
) : StructuredEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/prompt` body. */
|
|
||||||
@Serializable
|
|
||||||
data class StructuredPromptRequest(val prompt: String)
|
|
||||||
|
|
||||||
/** `POST /api/sessions/:id/structured/config` body: change one option
|
|
||||||
* advertised by a live ACP session. */
|
|
||||||
@Serializable
|
|
||||||
data class StructuredConfigRequest(val config_id: String, val value: JsonElement)
|
|
||||||
|
|
||||||
/** Operator decision for a pending ACP permission request
|
|
||||||
* (`POST /api/sessions/:id/structured/approvals/:nonce` body) — a bare JSON
|
|
||||||
* string on the wire, not `{decision: ...}`. Mirrors
|
|
||||||
* `maestro_protocol::StructuredApprovalDecision`. */
|
|
||||||
@Serializable
|
|
||||||
enum class StructuredApprovalDecision {
|
|
||||||
@SerialName("allow_once") AllowOnce,
|
|
||||||
@SerialName("allow_for_session") AllowForSession,
|
|
||||||
@SerialName("deny") Deny,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response of `POST /api/sessions/:id/structured/attachments`. */
|
|
||||||
@Serializable
|
|
||||||
data class StructuredAttachmentResponse(val path: String)
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.InfiniteTransition
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import pt.naps.maestro.data.ActivityState
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: the rail's width in the web client. Android mirrors it in
|
|
||||||
* density-independent pixels rather than raw pixels. */
|
|
||||||
val ACTIVITY_RAIL_WIDTH = 7.dp
|
|
||||||
|
|
||||||
private val RAIL_ACTIVE = Color(0xFF3DDC84)
|
|
||||||
private val RAIL_IDLE = Color(0xFF9AA0A6)
|
|
||||||
private val RAIL_AWAITING = Color(0xFFF5A623)
|
|
||||||
private val RAIL_ERROR = Color(0xFFE5484D)
|
|
||||||
|
|
||||||
/** Colour per activity state (docs/SIDEBAR.md's colour table): `Starting`
|
|
||||||
* shares `Idle`'s colour, and an unrecognised state degrades to `Idle` so
|
|
||||||
* rendering never fails (Precedence). */
|
|
||||||
fun activityColor(activity: ActivityState): Color =
|
|
||||||
when (activity) {
|
|
||||||
ActivityState.Active -> RAIL_ACTIVE
|
|
||||||
ActivityState.AwaitingInput -> RAIL_AWAITING
|
|
||||||
ActivityState.Error -> RAIL_ERROR
|
|
||||||
ActivityState.Idle, ActivityState.Starting -> RAIL_IDLE
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The accessible name the rail carries, since colour is never the only
|
|
||||||
* signal (docs/SIDEBAR.md). */
|
|
||||||
fun activityStateWord(activity: ActivityState): String =
|
|
||||||
when (activity) {
|
|
||||||
ActivityState.Active -> "Active"
|
|
||||||
ActivityState.Idle -> "Idle"
|
|
||||||
ActivityState.Starting -> "Starting"
|
|
||||||
ActivityState.AwaitingInput -> "Awaiting input"
|
|
||||||
ActivityState.Error -> "Error"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A held bar that pulses its alpha between 1 and [minAlpha] every
|
|
||||||
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
|
|
||||||
@Composable
|
|
||||||
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
|
|
||||||
val alpha by animateFloat(
|
|
||||||
initialValue = 1f,
|
|
||||||
targetValue = minAlpha,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(durationMillis = periodMs, easing = LinearEasing),
|
|
||||||
repeatMode = RepeatMode.Reverse,
|
|
||||||
),
|
|
||||||
label = "activity-rail-alpha",
|
|
||||||
)
|
|
||||||
return alpha
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The card's rail: a coloured bar in the gutter, full card height. It paints
|
|
||||||
* for every card, `Idle` included, and carries motion only for states that
|
|
||||||
* need attention — a hard blink for `AwaitingInput`, a fast flash for
|
|
||||||
* `Error`. `Active` and `Idle` stay solid; colour alone is their signal. */
|
|
||||||
@Composable
|
|
||||||
fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
|
|
||||||
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
|
|
||||||
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
|
|
||||||
val alpha = when (activity) {
|
|
||||||
ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600)
|
|
||||||
ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220)
|
|
||||||
else -> 1f
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxHeight()
|
|
||||||
.width(ACTIVITY_RAIL_WIDTH)
|
|
||||||
.semantics { contentDescription = activityStateWord(activity) }
|
|
||||||
.background(color.copy(alpha = alpha)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: a collapsible directory in the sidebar tree. An expanded
|
|
||||||
* folder's cards are one row away, so it carries no highlight of its own —
|
|
||||||
* only a collapsed header is ever a keyboard-walk stop, but that distinction
|
|
||||||
* belongs to the caller driving the walk, not this composable. */
|
|
||||||
@Composable
|
|
||||||
fun FolderNode(
|
|
||||||
label: String,
|
|
||||||
collapsed: Boolean,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
depthPadding: Int = 0,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onToggle)
|
|
||||||
.padding(start = depthPadding.dp, top = 6.dp, bottom = 6.dp, end = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (collapsed) "▸" else "▾",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
AnimatedVisibility(visible = !collapsed) {
|
|
||||||
Column { content() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import pt.naps.maestro.data.InTheWay
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SessionStatus
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: a card is always two lines behind an activity rail —
|
|
||||||
* line 1 the name, line 2 the adaptive meta line. Long-press opens a context
|
|
||||||
* menu (Rename / Kill / Spawn similar); the daemon confirms rename and kill
|
|
||||||
* only through the callbacks the caller supplies. */
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun SessionCard(
|
|
||||||
session: Session,
|
|
||||||
nowMs: Long,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
depthPadding: Int = 0,
|
|
||||||
onOpen: (Session) -> Unit = {},
|
|
||||||
onRename: (Session, String) -> Unit = { _, _ -> },
|
|
||||||
onKill: (Session) -> Unit = {},
|
|
||||||
onSpawnSimilar: (Session) -> Unit = {},
|
|
||||||
) {
|
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
|
||||||
var renaming by remember { mutableStateOf(false) }
|
|
||||||
var confirmingKill by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val running = session.status == SessionStatus.Running
|
|
||||||
val name = sessionName(session)
|
|
||||||
val muted = isProvisionalName(session)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.combinedClickable(
|
|
||||||
onClick = { onOpen(session) },
|
|
||||||
onLongClick = { menuExpanded = true },
|
|
||||||
)
|
|
||||||
.padding(start = (8 + depthPadding).dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
ActivityRail(activity = session.activity, modifier = Modifier.height(36.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = name,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = when {
|
|
||||||
!running -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
muted -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
MetaLine(session = session, nowMs = nowMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Rename") },
|
|
||||||
onClick = {
|
|
||||||
menuExpanded = false
|
|
||||||
renaming = true
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Spawn similar") },
|
|
||||||
onClick = {
|
|
||||||
menuExpanded = false
|
|
||||||
onSpawnSimilar(session)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Kill") },
|
|
||||||
onClick = {
|
|
||||||
menuExpanded = false
|
|
||||||
confirmingKill = true
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (renaming) {
|
|
||||||
RenameDialog(
|
|
||||||
initial = customSessionName(session) ?: "",
|
|
||||||
onCommit = {
|
|
||||||
renaming = false
|
|
||||||
onRename(session, it)
|
|
||||||
},
|
|
||||||
onCancel = { renaming = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (confirmingKill) {
|
|
||||||
KillConfirmDialog(
|
|
||||||
session = session,
|
|
||||||
onConfirm = {
|
|
||||||
confirmingKill = false
|
|
||||||
onKill(session)
|
|
||||||
},
|
|
||||||
onCancel = { confirmingKill = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`
|
|
||||||
* — the line itself is unconditional, only its fragments are adaptive. */
|
|
||||||
@Composable
|
|
||||||
fun MetaLine(session: Session, nowMs: Long, modifier: Modifier = Modifier) {
|
|
||||||
val heldMs = activityHeldMs(session.activity_since, nowMs)
|
|
||||||
val duration = if (heldMs != null && heldMs >= META_LINE_MIN_HOLD_MS) {
|
|
||||||
activityDurationLabel(heldMs)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val blockers = sessionBlockers(session)
|
|
||||||
|
|
||||||
Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (duration != null) {
|
|
||||||
Text(
|
|
||||||
text = duration,
|
|
||||||
color = activityColor(session.activity),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
}
|
|
||||||
if (session.activity_detail != null) {
|
|
||||||
Text(
|
|
||||||
text = session.activity_detail,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = programName(session.command),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
if (blockers != null) {
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
BlockedFragment(blockers)
|
|
||||||
}
|
|
||||||
if (session.status == SessionStatus.Dead) {
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = "exited",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BlockedFragment(blockers: InTheWay) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
blockerStats(blockers).forEachIndexed { index, stat ->
|
|
||||||
if (index > 0) Spacer(Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stat.text,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = when (stat.kind) {
|
|
||||||
"add" -> MaterialTheme.colorScheme.primary
|
|
||||||
"del" -> MaterialTheme.colorScheme.error
|
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) {
|
|
||||||
var text by remember { mutableStateOf(initial) }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onCancel,
|
|
||||||
title = { Text("Rename session") },
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } },
|
|
||||||
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) {
|
|
||||||
val blockers = sessionBlockers(session)
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onCancel,
|
|
||||||
title = { Text("Kill “${sessionName(session)}”?") },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
if (blockers != null) {
|
|
||||||
"This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced."
|
|
||||||
} else {
|
|
||||||
"This ends the session's process. This can't be undone."
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } },
|
|
||||||
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import java.time.Instant
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import pt.naps.maestro.data.InTheWay
|
|
||||||
import pt.naps.maestro.data.NameSource
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: a state held shorter than this has no duration fragment. */
|
|
||||||
const val META_LINE_MIN_HOLD_MS: Long = 30_000L
|
|
||||||
|
|
||||||
/** Payloads predating #102 default `activity_since` to the Unix epoch;
|
|
||||||
* anything implausibly old is "never observed" rather than a huge duration. */
|
|
||||||
private val PLAUSIBLE_EPOCH_MS = Instant.parse("2010-01-01T00:00:00Z").toEpochMilli()
|
|
||||||
|
|
||||||
/** Milliseconds held in the current activity state, or `null` when `since` is
|
|
||||||
* missing/unparsable/implausible. Mirrors the web's `activityHeldMs`. */
|
|
||||||
fun activityHeldMs(since: String?, nowMs: Long): Long? {
|
|
||||||
if (since.isNullOrEmpty()) return null
|
|
||||||
val t = runCatching { Instant.parse(since).toEpochMilli() }.getOrNull() ?: return null
|
|
||||||
if (t < PLAUSIBLE_EPOCH_MS) return null
|
|
||||||
return (nowMs - t).coerceAtLeast(0L)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `<60s` whole seconds, `<60m` whole minutes, else whole hours — floored
|
|
||||||
* everywhere so the counter never shows a duration the state hasn't reached. */
|
|
||||||
fun activityDurationLabel(heldMs: Long): String {
|
|
||||||
val seconds = heldMs / 1000
|
|
||||||
if (seconds < 60) return "${seconds}s"
|
|
||||||
val minutes = seconds / 60
|
|
||||||
if (minutes < 60) return "${minutes}m"
|
|
||||||
return "${minutes / 60}h"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The session's own name (#125), without the command fallback: `null` for a
|
|
||||||
* session that was never given one. */
|
|
||||||
fun customSessionName(session: Session): String? {
|
|
||||||
val element = session.metadata["name"] ?: return null
|
|
||||||
val primitive = element as? JsonPrimitive ?: return null
|
|
||||||
if (!primitive.isString) return null
|
|
||||||
return primitive.content.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Line 1's name: `metadata.name` if present, else the command line. */
|
|
||||||
fun sessionName(session: Session): String = customSessionName(session) ?: session.command
|
|
||||||
|
|
||||||
/** Where the session's name came from (#131), read leniently: anything
|
|
||||||
* unrecognised — including a session named before the field existed — reads
|
|
||||||
* as [NameSource.User], so an existing name is never dimmed or overwritten. */
|
|
||||||
fun sessionNameSource(session: Session): NameSource {
|
|
||||||
val element = session.metadata["name_source"] as? JsonPrimitive ?: return NameSource.User
|
|
||||||
return when (element.jsonPrimitive.content) {
|
|
||||||
"Generated" -> NameSource.Generated
|
|
||||||
"Haiku" -> NameSource.Haiku
|
|
||||||
else -> NameSource.User
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether line 1 renders the name muted (#131): a handle the daemon
|
|
||||||
* generated at spawn and may still replace with one derived from the first
|
|
||||||
* prompt. */
|
|
||||||
fun isProvisionalName(session: Session): Boolean =
|
|
||||||
customSessionName(session) != null && sessionNameSource(session) == NameSource.Generated
|
|
||||||
|
|
||||||
/** One fragment of the compact blocked-work stat (docs/SIDEBAR.md). `kind`
|
|
||||||
* picks the colour; `text` is what the card prints. */
|
|
||||||
data class BlockerStat(val kind: String, val text: String)
|
|
||||||
|
|
||||||
/** #83: this session's blocked-work breakdown when something actually blocks
|
|
||||||
* an unforced kill; `null` while clear or not yet probed. */
|
|
||||||
fun sessionBlockers(session: Session): InTheWay? {
|
|
||||||
val w = session.blocked_work ?: return null
|
|
||||||
val blocked = w.uncommitted_files > 0 || w.untracked_files > 0 || w.unpushed_commits > 0
|
|
||||||
return if (blocked) w else null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`. A tree whose
|
|
||||||
* only tracked change carries no line delta falls back to the file count
|
|
||||||
* (`~2`) rather than claiming `+0 -0`. */
|
|
||||||
fun blockerStats(w: InTheWay): List<BlockerStat> {
|
|
||||||
val stats = mutableListOf<BlockerStat>()
|
|
||||||
if (w.insertions > 0 || w.deletions > 0) {
|
|
||||||
if (w.insertions > 0) stats += BlockerStat("add", "+${w.insertions}")
|
|
||||||
if (w.deletions > 0) stats += BlockerStat("del", "-${w.deletions}")
|
|
||||||
} else if (w.uncommitted_files > 0) {
|
|
||||||
stats += BlockerStat("files", "~${w.uncommitted_files}")
|
|
||||||
}
|
|
||||||
if (w.untracked_files > 0) stats += BlockerStat("untracked", "?${w.untracked_files}")
|
|
||||||
if (w.unpushed_commits > 0) stats += BlockerStat("unpushed", "↑${w.unpushed_commits}")
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The spelled-out phrasing shared by the kill refusal and every client's
|
|
||||||
* tooltip, so no two describe the same tree differently. */
|
|
||||||
fun formatInTheWay(w: InTheWay): String {
|
|
||||||
val parts = mutableListOf<String>()
|
|
||||||
fun push(n: Int, unit: String) {
|
|
||||||
if (n <= 0) return
|
|
||||||
parts += if (n == 1) "1 $unit" else "$n ${unit}s"
|
|
||||||
}
|
|
||||||
push(w.uncommitted_files, "uncommitted file")
|
|
||||||
push(w.untracked_files, "untracked file")
|
|
||||||
push(w.unpushed_commits, "unpushed commit")
|
|
||||||
return if (parts.isNotEmpty()) parts.joinToString(", ") else "no unsaved work"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val ENV_ASSIGNMENT = Regex("^[A-Za-z_][A-Za-z0-9_]*=.*$")
|
|
||||||
private val PLAIN_WRAPPERS = setOf("sudo", "env")
|
|
||||||
private val DLX_CAPABLE = setOf("pnpm", "yarn")
|
|
||||||
|
|
||||||
/** The program a command line runs — never its arguments (docs/SIDEBAR.md).
|
|
||||||
* Looks through leading `FOO=bar` assignments, `sudo`/`env` wrappers (and
|
|
||||||
* their own flags), and JS-toolchain launchers (`npx`, `bunx`, `pnpm`/`yarn
|
|
||||||
* dlx`), then basenames what is left. A command that is nothing but wrappers
|
|
||||||
* is returned as written rather than as an empty string. */
|
|
||||||
fun programName(command: String): String {
|
|
||||||
val trimmed = command.trim()
|
|
||||||
val words = trimmed.split(Regex("\\s+")).filter { it.isNotEmpty() }
|
|
||||||
if (words.isEmpty()) return trimmed
|
|
||||||
|
|
||||||
var i = 0
|
|
||||||
while (i < words.size && ENV_ASSIGNMENT.matches(words[i])) i++
|
|
||||||
while (i < words.size && words[i] in PLAIN_WRAPPERS) {
|
|
||||||
i++
|
|
||||||
while (i < words.size && (ENV_ASSIGNMENT.matches(words[i]) || words[i].startsWith("-"))) i++
|
|
||||||
}
|
|
||||||
if (i >= words.size) return basename(words.last())
|
|
||||||
|
|
||||||
when {
|
|
||||||
words[i] in DLX_CAPABLE && words.getOrNull(i + 1) == "dlx" -> i += 2
|
|
||||||
words[i] == "npx" || words[i] == "bunx" -> i += 1
|
|
||||||
words[i] == "bun" && words.getOrNull(i + 1) == "x" -> i += 2
|
|
||||||
}
|
|
||||||
val program = words.getOrNull(i) ?: return basename(words.last())
|
|
||||||
return basename(program)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun basename(path: String): String = path.substringAfterLast('/')
|
|
||||||
|
|
||||||
/** Sessions are grouped by the repository they belong to (#265): a
|
|
||||||
* worktree's `base_repo`, or the session's own `cwd` when it owns none. */
|
|
||||||
fun sessionGroupKey(session: Session): String = session.worktree?.base_repo ?: session.cwd
|
|
||||||
|
|
||||||
/** One folder in the sidebar tree: a repo/cwd group and the sessions in it,
|
|
||||||
* sorted by name. */
|
|
||||||
data class SessionGroup(val key: String, val label: String, val sessions: List<Session>)
|
|
||||||
|
|
||||||
/** Group sessions by [sessionGroupKey], each group sorted by name and the
|
|
||||||
* groups themselves sorted by label. */
|
|
||||||
fun groupSessions(sessions: Collection<Session>): List<SessionGroup> =
|
|
||||||
sessions
|
|
||||||
.groupBy(::sessionGroupKey)
|
|
||||||
.map { (key, members) ->
|
|
||||||
SessionGroup(
|
|
||||||
key = key,
|
|
||||||
label = key.trimEnd('/').substringAfterLast('/').ifEmpty { key },
|
|
||||||
sessions = members.sortedBy { sessionName(it).lowercase() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.sortedBy { it.label.lowercase() }
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import pt.naps.maestro.connection.CatalogStore
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
/** A clock that advances at least once every 30s (docs/SIDEBAR.md), ticked
|
|
||||||
* faster than that so a duration's cutover lands within one visible frame. */
|
|
||||||
@Composable
|
|
||||||
fun rememberTickingNowMs(periodMs: Long = 5_000L): Long {
|
|
||||||
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
|
||||||
LaunchedEffect(periodMs) {
|
|
||||||
while (true) {
|
|
||||||
delay(periodMs)
|
|
||||||
now = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return now
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assembles the full sidebar from the [CatalogStore] built in #264: sessions
|
|
||||||
* grouped into collapsible folders by repository, each rendered as a
|
|
||||||
* two-line [SessionCard] behind its [ActivityRail]. */
|
|
||||||
@Composable
|
|
||||||
fun SidebarContent(
|
|
||||||
catalogStore: CatalogStore,
|
|
||||||
onOpenSession: (Session) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
|
|
||||||
onKillSession: (Uuid) -> Unit = {},
|
|
||||||
onSpawnSimilar: (Session) -> Unit = {},
|
|
||||||
) {
|
|
||||||
val sessions by catalogStore.sessions.collectAsState()
|
|
||||||
SidebarContent(
|
|
||||||
sessions = sessions.values.toList(),
|
|
||||||
onOpenSession = onOpenSession,
|
|
||||||
modifier = modifier,
|
|
||||||
onRenameSession = onRenameSession,
|
|
||||||
onKillSession = onKillSession,
|
|
||||||
onSpawnSimilar = onSpawnSimilar,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same as the [CatalogStore]-driven overload, but takes the session list
|
|
||||||
* directly — the shape used by tests and previews. */
|
|
||||||
@Composable
|
|
||||||
fun SidebarContent(
|
|
||||||
sessions: List<Session>,
|
|
||||||
onOpenSession: (Session) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
|
|
||||||
onKillSession: (Uuid) -> Unit = {},
|
|
||||||
onSpawnSimilar: (Session) -> Unit = {},
|
|
||||||
) {
|
|
||||||
val nowMs = rememberTickingNowMs()
|
|
||||||
val groups = remember(sessions) { groupSessions(sessions) }
|
|
||||||
val collapsed = remember { mutableStateMapOf<String, Boolean>() }
|
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
|
||||||
Text(
|
|
||||||
text = "Sessions",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
)
|
|
||||||
if (groups.isEmpty()) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "No sessions",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
for (group in groups) {
|
|
||||||
val isCollapsed = collapsed[group.key] == true
|
|
||||||
item(key = "folder:${group.key}") {
|
|
||||||
FolderNode(
|
|
||||||
label = group.label,
|
|
||||||
collapsed = isCollapsed,
|
|
||||||
onToggle = { collapsed[group.key] = !isCollapsed },
|
|
||||||
depthPadding = 8,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
for (session in group.sessions) {
|
|
||||||
SessionCard(
|
|
||||||
session = session,
|
|
||||||
nowMs = nowMs,
|
|
||||||
depthPadding = 16,
|
|
||||||
onOpen = onOpenSession,
|
|
||||||
onRename = { s, name -> onRenameSession(s.id, name) },
|
|
||||||
onKill = { s -> onKillSession(s.id) },
|
|
||||||
onSpawnSimilar = onSpawnSimilar,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.spawn
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import pt.naps.maestro.data.CreateSessionRequest
|
|
||||||
import pt.naps.maestro.data.SessionView
|
|
||||||
import pt.naps.maestro.data.SpawnPreset
|
|
||||||
import pt.naps.maestro.data.WorktreeSpec
|
|
||||||
|
|
||||||
/** Tools whose sessions can render as a structured (ACP) transcript instead
|
|
||||||
* of a raw terminal. Mirrors `STRUCTURED_VIEW_TOOLS` in
|
|
||||||
* `web/src/components/spawn/model.ts`. */
|
|
||||||
private val STRUCTURED_VIEW_TOOLS = setOf("claude", "codex")
|
|
||||||
|
|
||||||
/** Commands that just launch another program, looked through to find the
|
|
||||||
* real one: `env -i codex` and `sudo codex` both resolve to `codex`. */
|
|
||||||
private val WRAPPER_PROGRAMS = setOf("env", "sudo", "nohup", "command")
|
|
||||||
|
|
||||||
private val ENV_ASSIGNMENT = Regex("[A-Za-z_][A-Za-z0-9_]*=.*")
|
|
||||||
|
|
||||||
/** Program a command line would run, stripped of leading `FOO=bar`
|
|
||||||
* assignments, known wrappers, its own arguments, and any path prefix. A
|
|
||||||
* simplified stand-in for the shell-grammar-aware `splitCommand` the web
|
|
||||||
* client uses — good enough for the preset commands and the common
|
|
||||||
* `env FOO=1 claude` shapes the spawn form actually produces. */
|
|
||||||
internal fun resolveProgram(command: String): String {
|
|
||||||
val words = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
|
||||||
var i = 0
|
|
||||||
while (i < words.size) {
|
|
||||||
while (i < words.size && words[i].matches(ENV_ASSIGNMENT)) i++
|
|
||||||
if (i >= words.size) return ""
|
|
||||||
val word = words[i].substringAfterLast('/').lowercase()
|
|
||||||
if (word !in WRAPPER_PROGRAMS) return word
|
|
||||||
i++
|
|
||||||
while (i < words.size && words[i].startsWith("-")) i++
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fun supportsStructuredView(command: String): Boolean = resolveProgram(command) in STRUCTURED_VIEW_TOOLS
|
|
||||||
|
|
||||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
|
||||||
* from the repository's existing branches. Mirrors `WorktreeBranchMode` in
|
|
||||||
* `web/src/components/spawn/model.ts`. */
|
|
||||||
enum class WorktreeBranchMode { New, Existing }
|
|
||||||
|
|
||||||
/** The spawn form's current values, gathered at submit time. Mirrors
|
|
||||||
* `SpawnFormValues` in `web/src/components/spawn/model.ts`. */
|
|
||||||
data class SpawnFormValues(
|
|
||||||
val command: String,
|
|
||||||
val cwd: String,
|
|
||||||
val name: String,
|
|
||||||
val structuredView: Boolean,
|
|
||||||
val autoApprove: Boolean,
|
|
||||||
val worktreeEnabled: Boolean,
|
|
||||||
val worktreeBranch: String,
|
|
||||||
val worktreeBranchMode: WorktreeBranchMode,
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed interface SpawnBuildResult {
|
|
||||||
data class Ok(val request: CreateSessionRequest) : SpawnBuildResult
|
|
||||||
data class Error(val message: String) : SpawnBuildResult
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Turns validated form values into a `POST /api/sessions` body, or an error
|
|
||||||
* message when something required is missing. Mirrors `buildSpawnRequest` in
|
|
||||||
* `web/src/components/spawn/model.ts`. */
|
|
||||||
fun buildSpawnRequest(values: SpawnFormValues, baseRepo: String?): SpawnBuildResult {
|
|
||||||
if (values.cwd.isBlank()) return SpawnBuildResult.Error("Pick a working directory.")
|
|
||||||
if (values.command.isBlank()) return SpawnBuildResult.Error("Pick a command (or fill the custom command).")
|
|
||||||
|
|
||||||
val name = values.name.trim()
|
|
||||||
val metadata = if (name.isNotEmpty()) mapOf("name" to JsonPrimitive(name)) else emptyMap()
|
|
||||||
|
|
||||||
var worktree: WorktreeSpec? = null
|
|
||||||
if (values.worktreeEnabled) {
|
|
||||||
val branch = values.worktreeBranch.trim()
|
|
||||||
if (baseRepo.isNullOrBlank()) {
|
|
||||||
return SpawnBuildResult.Error("Worktree needs a directory inside a git repository.")
|
|
||||||
}
|
|
||||||
if (branch.isEmpty()) {
|
|
||||||
return SpawnBuildResult.Error(
|
|
||||||
if (values.worktreeBranchMode == WorktreeBranchMode.Existing) "Pick an existing branch."
|
|
||||||
else "Worktree requires a branch name.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
worktree = WorktreeSpec(
|
|
||||||
base_repo = baseRepo,
|
|
||||||
branch = branch,
|
|
||||||
create_branch = values.worktreeBranchMode != WorktreeBranchMode.Existing,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return SpawnBuildResult.Ok(
|
|
||||||
CreateSessionRequest(
|
|
||||||
cwd = values.cwd,
|
|
||||||
command = values.command,
|
|
||||||
metadata = metadata,
|
|
||||||
worktree = worktree,
|
|
||||||
view = if (values.structuredView && supportsStructuredView(values.command)) {
|
|
||||||
SessionView.Structured
|
|
||||||
} else {
|
|
||||||
SessionView.Terminal
|
|
||||||
},
|
|
||||||
auto_approve = values.autoApprove,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Built-ins shown when the daemon's `/api/info` presets haven't loaded yet
|
|
||||||
* (or can't be fetched). Mirrors `FALLBACK_PRESETS` in
|
|
||||||
* `web/src/components/spawn/model.ts`, reordered per #269's chip row. */
|
|
||||||
val FALLBACK_PRESETS: List<SpawnPreset> = listOf(
|
|
||||||
SpawnPreset(id = "claude", label = "Claude", command = "claude"),
|
|
||||||
SpawnPreset(id = "codex", label = "Codex", command = "codex"),
|
|
||||||
SpawnPreset(id = "zsh", label = "Zsh", command = "zsh"),
|
|
||||||
SpawnPreset(id = "custom", label = "Custom…", command = ""),
|
|
||||||
)
|
|
||||||
@@ -1,432 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.spawn
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.SheetState
|
|
||||||
import androidx.compose.material3.Snackbar
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import pt.naps.maestro.data.BranchInfo
|
|
||||||
import pt.naps.maestro.data.Project
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SpawnPreset
|
|
||||||
|
|
||||||
/** Bottom sheet spawn form (#269), matching the web spawn modal's flow:
|
|
||||||
* a preset chip row, a searchable project list, a raw-path autocomplete
|
|
||||||
* fallback, a branch/worktree picker, a session name, and the
|
|
||||||
* structured-view toggle. [SpawnSheet] wires this to a live [SpawnViewModel];
|
|
||||||
* [SpawnSheetContent] is the pure, state-driven body used by previews and
|
|
||||||
* tests. */
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SpawnSheet(
|
|
||||||
viewModel: SpawnViewModel,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onSpawned: (Session) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsState()
|
|
||||||
|
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
|
||||||
SpawnSheetContent(
|
|
||||||
state = state,
|
|
||||||
onSelectPreset = viewModel::selectPreset,
|
|
||||||
onCustomCommandChange = viewModel::setCustomCommand,
|
|
||||||
onStructuredViewChange = viewModel::setStructuredView,
|
|
||||||
onProjectQueryChange = viewModel::setProjectQuery,
|
|
||||||
onSelectProject = viewModel::selectProject,
|
|
||||||
onCwdChange = viewModel::setCwd,
|
|
||||||
onPickDirSuggestion = viewModel::pickDirSuggestion,
|
|
||||||
onWorktreeEnabledChange = viewModel::setWorktreeEnabled,
|
|
||||||
onBranchModeChange = viewModel::setBranchMode,
|
|
||||||
onNewBranchChange = viewModel::setNewBranch,
|
|
||||||
onExistingBranchChange = viewModel::setExistingBranch,
|
|
||||||
onNameChange = viewModel::setName,
|
|
||||||
onReroll = viewModel::rerollName,
|
|
||||||
onDismissError = viewModel::dismissError,
|
|
||||||
onSubmit = { viewModel.submit(onSpawned) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SpawnSheetContent(
|
|
||||||
state: SpawnUiState,
|
|
||||||
onSelectPreset: (String) -> Unit,
|
|
||||||
onCustomCommandChange: (String) -> Unit,
|
|
||||||
onStructuredViewChange: (Boolean) -> Unit,
|
|
||||||
onProjectQueryChange: (String) -> Unit,
|
|
||||||
onSelectProject: (Project) -> Unit,
|
|
||||||
onCwdChange: (String) -> Unit,
|
|
||||||
onPickDirSuggestion: (String) -> Unit,
|
|
||||||
onWorktreeEnabledChange: (Boolean) -> Unit,
|
|
||||||
onBranchModeChange: (WorktreeBranchMode) -> Unit,
|
|
||||||
onNewBranchChange: (String) -> Unit,
|
|
||||||
onExistingBranchChange: (String) -> Unit,
|
|
||||||
onNameChange: (String) -> Unit,
|
|
||||||
onReroll: () -> Unit,
|
|
||||||
onDismissError: () -> Unit,
|
|
||||||
onSubmit: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
LaunchedEffect(state.error) {
|
|
||||||
val message = state.error ?: return@LaunchedEffect
|
|
||||||
snackbarHostState.showSnackbar(message)
|
|
||||||
onDismissError()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(bottom = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Text("Spawn session", style = MaterialTheme.typography.titleMedium)
|
|
||||||
|
|
||||||
PresetRow(
|
|
||||||
presets = state.presets,
|
|
||||||
selectedPresetId = state.selectedPresetId,
|
|
||||||
onSelectPreset = onSelectPreset,
|
|
||||||
)
|
|
||||||
if (state.isCustomSelected) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.customCommand,
|
|
||||||
onValueChange = onCustomCommandChange,
|
|
||||||
label = { Text("Command") },
|
|
||||||
placeholder = { Text("bash -l") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ProjectPicker(
|
|
||||||
query = state.projectQuery,
|
|
||||||
onQueryChange = onProjectQueryChange,
|
|
||||||
projects = state.visibleProjects,
|
|
||||||
loading = state.projectsLoading,
|
|
||||||
selectedPath = state.selectedProjectPath,
|
|
||||||
homeDir = state.homeDir,
|
|
||||||
onSelectProject = onSelectProject,
|
|
||||||
)
|
|
||||||
|
|
||||||
DirAutocompleteField(
|
|
||||||
cwd = state.cwd,
|
|
||||||
suggestions = state.dirSuggestions,
|
|
||||||
onCwdChange = onCwdChange,
|
|
||||||
onPickSuggestion = onPickDirSuggestion,
|
|
||||||
)
|
|
||||||
|
|
||||||
BranchSection(
|
|
||||||
enabled = state.worktreeEnabled,
|
|
||||||
onEnabledChange = onWorktreeEnabledChange,
|
|
||||||
baseRepo = state.baseRepo,
|
|
||||||
mode = state.branchMode,
|
|
||||||
onModeChange = onBranchModeChange,
|
|
||||||
newBranch = state.newBranch,
|
|
||||||
onNewBranchChange = onNewBranchChange,
|
|
||||||
branches = state.branches,
|
|
||||||
branchesLoading = state.branchesLoading,
|
|
||||||
existingBranch = state.effectiveExistingBranch,
|
|
||||||
onExistingBranchChange = onExistingBranchChange,
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.name,
|
|
||||||
onValueChange = onNameChange,
|
|
||||||
label = { Text("Session name") },
|
|
||||||
placeholder = { Text("named after your first prompt") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
TextButton(onClick = onReroll) { Text("Reroll") }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supportsStructuredView(state.command)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Checkbox(checked = state.structuredView, onCheckedChange = onStructuredViewChange)
|
|
||||||
Text("Structured view")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onSubmit,
|
|
||||||
enabled = !state.submitting,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
if (state.submitting) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
|
|
||||||
} else {
|
|
||||||
Text("Spawn")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) {
|
|
||||||
Snackbar(snackbarData = it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A row is its own small touch target: tap anywhere in it, not just the
|
|
||||||
* leading control, to select it. */
|
|
||||||
private fun Modifier.clickableRow(enabled: Boolean = true, onClick: () -> Unit): Modifier =
|
|
||||||
this.clickable(enabled = enabled, onClick = onClick)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PresetRow(presets: List<SpawnPreset>, selectedPresetId: String, onSelectPreset: (String) -> Unit) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text("Harness", style = MaterialTheme.typography.labelLarge)
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
|
||||||
) {
|
|
||||||
for (preset in presets) {
|
|
||||||
val label = if (preset.command.isEmpty()) "Custom…" else preset.label
|
|
||||||
FilterChip(
|
|
||||||
selected = preset.id == selectedPresetId,
|
|
||||||
onClick = { onSelectPreset(preset.id) },
|
|
||||||
label = { Text(label) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ProjectPicker(
|
|
||||||
query: String,
|
|
||||||
onQueryChange: (String) -> Unit,
|
|
||||||
projects: List<Project>,
|
|
||||||
loading: Boolean,
|
|
||||||
selectedPath: String?,
|
|
||||||
homeDir: String,
|
|
||||||
onSelectProject: (Project) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text("Project", style = MaterialTheme.typography.labelLarge)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = query,
|
|
||||||
onValueChange = onQueryChange,
|
|
||||||
placeholder = { Text("Filter projects…") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
when {
|
|
||||||
loading -> Text(
|
|
||||||
"Loading projects…",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
projects.isEmpty() -> Text(
|
|
||||||
"No projects match.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
|
|
||||||
items(projects, key = { it.path }) { project ->
|
|
||||||
ProjectRow(
|
|
||||||
project = project,
|
|
||||||
active = project.path == selectedPath,
|
|
||||||
homeDir = homeDir,
|
|
||||||
onClick = { onSelectProject(project) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ProjectRow(project: Project, active: Boolean, homeDir: String, onClick: () -> Unit) {
|
|
||||||
val background = if (active) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
.background(background)
|
|
||||||
.clickableRow(onClick = onClick)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
Text(project.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
Text(
|
|
||||||
abbreviateHome(parentDir(project.path), homeDir),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun abbreviateHome(path: String, homeDir: String): String =
|
|
||||||
if (homeDir.isNotEmpty() && path.startsWith(homeDir)) "~${path.removePrefix(homeDir)}" else path
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DirAutocompleteField(
|
|
||||||
cwd: String,
|
|
||||||
suggestions: List<String>,
|
|
||||||
onCwdChange: (String) -> Unit,
|
|
||||||
onPickSuggestion: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = cwd,
|
|
||||||
onValueChange = onCwdChange,
|
|
||||||
label = { Text("Or type a directory") },
|
|
||||||
placeholder = { Text("/home/user/project") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
if (suggestions.isNotEmpty()) {
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp)) {
|
|
||||||
items(suggestions) { entry ->
|
|
||||||
Text(
|
|
||||||
text = "$entry/",
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickableRow { onPickSuggestion(entry) }
|
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BranchSection(
|
|
||||||
enabled: Boolean,
|
|
||||||
onEnabledChange: (Boolean) -> Unit,
|
|
||||||
baseRepo: String?,
|
|
||||||
mode: WorktreeBranchMode,
|
|
||||||
onModeChange: (WorktreeBranchMode) -> Unit,
|
|
||||||
newBranch: String,
|
|
||||||
onNewBranchChange: (String) -> Unit,
|
|
||||||
branches: List<BranchInfo>,
|
|
||||||
branchesLoading: Boolean,
|
|
||||||
existingBranch: String,
|
|
||||||
onExistingBranchChange: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Checkbox(checked = enabled, onCheckedChange = onEnabledChange)
|
|
||||||
Text("Create git worktree")
|
|
||||||
}
|
|
||||||
if (!enabled) return@Column
|
|
||||||
|
|
||||||
if (baseRepo == null) {
|
|
||||||
Text(
|
|
||||||
"Choose a directory inside a git repository first.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
return@Column
|
|
||||||
}
|
|
||||||
Text("repo: $baseRepo", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
RadioButton(selected = mode == WorktreeBranchMode.New, onClick = { onModeChange(WorktreeBranchMode.New) })
|
|
||||||
Text("New branch")
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
RadioButton(selected = mode == WorktreeBranchMode.Existing, onClick = { onModeChange(WorktreeBranchMode.Existing) })
|
|
||||||
Text("Existing branch")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode == WorktreeBranchMode.New) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = newBranch,
|
|
||||||
onValueChange = onNewBranchChange,
|
|
||||||
label = { Text("New branch name") },
|
|
||||||
placeholder = { Text("feat/foo") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
when {
|
|
||||||
branchesLoading -> Text(
|
|
||||||
"Loading branches…",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
branches.isEmpty() -> Text(
|
|
||||||
"No local branches found.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp)) {
|
|
||||||
items(branches, key = { it.name }) { branch ->
|
|
||||||
val held = branch.worktree ?: (if (branch.is_head) "the main checkout" else null)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickableRow(enabled = held == null) { onExistingBranchChange(branch.name) }
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = branch.name == existingBranch,
|
|
||||||
onClick = { onExistingBranchChange(branch.name) },
|
|
||||||
enabled = held == null,
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(branch.name, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
if (held != null) {
|
|
||||||
Text(
|
|
||||||
"held by $held",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.spawn
|
|
||||||
|
|
||||||
import java.time.Instant
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import pt.naps.maestro.api.MaestroApiException
|
|
||||||
import pt.naps.maestro.api.MaestroClient
|
|
||||||
import pt.naps.maestro.data.BranchInfo
|
|
||||||
import pt.naps.maestro.data.Project
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SpawnPreset
|
|
||||||
|
|
||||||
/** Quiet period after the operator types a raw path before `/api/dirs` and
|
|
||||||
* `/api/gitroot` fire (#269). */
|
|
||||||
private const val DIR_AUTOCOMPLETE_DEBOUNCE_MS = 300L
|
|
||||||
|
|
||||||
/** Everything above the last segment of an absolute path: `/a/b/c` -> `/a/b`.
|
|
||||||
* A top-level path keeps `/`, and a trailing slash is ignored. Mirrors
|
|
||||||
* `parentDir` in `web/src/paths.ts`. */
|
|
||||||
internal fun parentDir(path: String): String {
|
|
||||||
val trimmed = if (path.length > 1 && path.endsWith("/")) path.dropLast(1) else path
|
|
||||||
val slash = trimmed.lastIndexOf('/')
|
|
||||||
return when {
|
|
||||||
slash < 0 -> ""
|
|
||||||
slash == 0 -> "/"
|
|
||||||
else -> trimmed.substring(0, slash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Splits a path into its directory and trailing (possibly partial) segment.
|
|
||||||
* Mirrors `splitPath` in `web/src/paths.ts`. */
|
|
||||||
internal fun splitPath(value: String): Pair<String, String> {
|
|
||||||
val slash = value.lastIndexOf('/')
|
|
||||||
return if (slash == -1) "" to value else value.substring(0, slash + 1) to value.substring(slash + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SpawnUiState(
|
|
||||||
val presets: List<SpawnPreset> = FALLBACK_PRESETS,
|
|
||||||
val selectedPresetId: String = "",
|
|
||||||
val customCommand: String = "",
|
|
||||||
val commandTouched: Boolean = false,
|
|
||||||
val structuredView: Boolean = false,
|
|
||||||
val structuredViewTouched: Boolean = false,
|
|
||||||
val homeDir: String = "",
|
|
||||||
val projects: List<Project> = emptyList(),
|
|
||||||
val projectsLoading: Boolean = true,
|
|
||||||
val projectQuery: String = "",
|
|
||||||
val selectedProjectPath: String? = null,
|
|
||||||
val cwd: String = "",
|
|
||||||
val dirSuggestions: List<String> = emptyList(),
|
|
||||||
val baseRepo: String? = null,
|
|
||||||
val worktreeEnabled: Boolean = false,
|
|
||||||
val branchMode: WorktreeBranchMode = WorktreeBranchMode.New,
|
|
||||||
val newBranch: String = "",
|
|
||||||
val existingBranch: String = "",
|
|
||||||
val branches: List<BranchInfo> = emptyList(),
|
|
||||||
val branchesLoading: Boolean = false,
|
|
||||||
val name: String = "",
|
|
||||||
val submitting: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
) {
|
|
||||||
val activePreset: SpawnPreset? get() = presets.find { it.id == selectedPresetId }
|
|
||||||
val isCustomSelected: Boolean get() = activePreset == null || activePreset?.command == ""
|
|
||||||
val command: String get() = if (isCustomSelected) customCommand.trim() else activePreset?.command.orEmpty()
|
|
||||||
|
|
||||||
val visibleProjects: List<Project>
|
|
||||||
get() {
|
|
||||||
val query = projectQuery.trim().lowercase()
|
|
||||||
val filtered = if (query.isEmpty()) {
|
|
||||||
projects
|
|
||||||
} else {
|
|
||||||
projects.filter { it.name.lowercase().contains(query) || it.path.lowercase().contains(query) }
|
|
||||||
}
|
|
||||||
return filtered.sortedByDescending { runCatching { Instant.parse(it.last_activity) }.getOrNull() }
|
|
||||||
}
|
|
||||||
|
|
||||||
val effectiveExistingBranch: String
|
|
||||||
get() = existingBranch.ifEmpty {
|
|
||||||
branches.firstOrNull { it.worktree == null && !it.is_head }?.name.orEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Form state and API calls behind [SpawnSheet]. A plain class rather than an
|
|
||||||
* `androidx.lifecycle.ViewModel` — the sheet's own `remember` scope is enough
|
|
||||||
* for a short-lived form, and this keeps the class trivially testable with a
|
|
||||||
* `TestScope`, the same shape as [pt.naps.maestro.connection.DaemonLink].
|
|
||||||
*/
|
|
||||||
class SpawnViewModel(
|
|
||||||
private val client: MaestroClient,
|
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
|
||||||
) {
|
|
||||||
private val _state = MutableStateFlow(SpawnUiState())
|
|
||||||
val state: StateFlow<SpawnUiState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private var dirJob: Job? = null
|
|
||||||
private var branchesJob: Job? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
val info = runCatching { client.getDaemonInfo() }.getOrNull()
|
|
||||||
_state.update { s ->
|
|
||||||
val presets = info?.presets?.takeIf { it.isNotEmpty() } ?: FALLBACK_PRESETS
|
|
||||||
val seeded = if (!s.commandTouched && s.selectedPresetId.isEmpty()) {
|
|
||||||
presets.firstOrNull { it.command.isNotEmpty() }?.id ?: s.selectedPresetId
|
|
||||||
} else {
|
|
||||||
s.selectedPresetId
|
|
||||||
}
|
|
||||||
s.copy(homeDir = info?.home_dir.orEmpty(), presets = presets, selectedPresetId = seeded)
|
|
||||||
.withSyncedStructuredView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
val projects = runCatching { client.listProjects() }.getOrDefault(emptyList())
|
|
||||||
_state.update { it.copy(projects = projects, projectsLoading = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectPreset(id: String) {
|
|
||||||
_state.update { it.copy(selectedPresetId = id, commandTouched = true).withSyncedStructuredView() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCustomCommand(text: String) {
|
|
||||||
_state.update { it.copy(customCommand = text, commandTouched = true).withSyncedStructuredView() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setStructuredView(enabled: Boolean) {
|
|
||||||
_state.update { it.copy(structuredView = enabled, structuredViewTouched = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-derives the structured-view checkbox from the command this state
|
|
||||||
* carries, unless the operator has already ticked/unticked it by hand. */
|
|
||||||
private fun SpawnUiState.withSyncedStructuredView(): SpawnUiState =
|
|
||||||
if (structuredViewTouched) this else copy(structuredView = supportsStructuredView(command))
|
|
||||||
|
|
||||||
fun setProjectQuery(query: String) {
|
|
||||||
_state.update { it.copy(projectQuery = query) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectProject(project: Project) {
|
|
||||||
dirJob?.cancel()
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
selectedProjectPath = project.path,
|
|
||||||
cwd = project.path,
|
|
||||||
dirSuggestions = emptyList(),
|
|
||||||
baseRepo = project.base_repo ?: project.path,
|
|
||||||
branches = emptyList(),
|
|
||||||
existingBranch = "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The operator typed a path by hand: clears any project pick, and
|
|
||||||
* schedules a debounced refresh of both the directory-suggestion
|
|
||||||
* dropdown and the git-root probe that resolves [SpawnUiState.baseRepo]. */
|
|
||||||
fun setCwd(raw: String) {
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
cwd = raw,
|
|
||||||
selectedProjectPath = null,
|
|
||||||
baseRepo = null,
|
|
||||||
branches = emptyList(),
|
|
||||||
existingBranch = "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
dirJob?.cancel()
|
|
||||||
dirJob = scope.launch {
|
|
||||||
delay(DIR_AUTOCOMPLETE_DEBOUNCE_MS)
|
|
||||||
refreshDirSuggestions(raw)
|
|
||||||
refreshBaseRepo(raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Appends [entry] as the next path segment, matching the `cd dir/sub`
|
|
||||||
* drill-in gesture the web `DirAutocomplete` uses. */
|
|
||||||
fun pickDirSuggestion(entry: String) {
|
|
||||||
val (dir, _) = splitPath(_state.value.cwd)
|
|
||||||
setCwd("$dir$entry/")
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshDirSuggestions(raw: String) {
|
|
||||||
val (dir, prefix) = splitPath(raw)
|
|
||||||
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
|
|
||||||
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
|
||||||
val filtered = entries.filter { it.startsWith(prefix, ignoreCase = true) }
|
|
||||||
_state.update { it.copy(dirSuggestions = filtered) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshBaseRepo(raw: String) {
|
|
||||||
val root = runCatching { client.gitRoot(raw).root }.getOrNull()
|
|
||||||
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
|
||||||
_state.update { it.copy(baseRepo = root) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWorktreeEnabled(enabled: Boolean) {
|
|
||||||
_state.update { it.copy(worktreeEnabled = enabled) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setBranchMode(mode: WorktreeBranchMode) {
|
|
||||||
_state.update { it.copy(branchMode = mode) }
|
|
||||||
if (mode == WorktreeBranchMode.Existing) loadBranchesIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNewBranch(text: String) {
|
|
||||||
_state.update { it.copy(newBranch = text) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setExistingBranch(text: String) {
|
|
||||||
_state.update { it.copy(existingBranch = text) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadBranchesIfNeeded() {
|
|
||||||
val cwd = _state.value.cwd
|
|
||||||
if (cwd.isEmpty() || _state.value.branchesLoading) return
|
|
||||||
branchesJob?.cancel()
|
|
||||||
_state.update { it.copy(branchesLoading = true) }
|
|
||||||
branchesJob = scope.launch {
|
|
||||||
val branches = runCatching { client.listBranches(cwd) }.getOrDefault(emptyList())
|
|
||||||
if (_state.value.cwd != cwd) return@launch // a newer cwd superseded this fetch
|
|
||||||
_state.update { it.copy(branches = branches, branchesLoading = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setName(text: String) {
|
|
||||||
_state.update { it.copy(name = text) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GET /api/names?count=1`: replaces the session-name field with a
|
|
||||||
* freshly generated handle. */
|
|
||||||
fun rerollName() {
|
|
||||||
scope.launch {
|
|
||||||
val handle = runCatching { client.fetchNames(1).names.firstOrNull() }.getOrNull()
|
|
||||||
if (handle != null) _state.update { it.copy(name = handle) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validates the form, `POST`s it, and calls [onSuccess] with the new
|
|
||||||
* session. Any failure — validation or API — lands in
|
|
||||||
* [SpawnUiState.error] instead. */
|
|
||||||
fun submit(onSuccess: (Session) -> Unit) {
|
|
||||||
val s = _state.value
|
|
||||||
val values = SpawnFormValues(
|
|
||||||
command = s.command,
|
|
||||||
cwd = s.cwd,
|
|
||||||
name = s.name,
|
|
||||||
structuredView = s.structuredView,
|
|
||||||
autoApprove = s.activePreset?.auto_approve ?: false,
|
|
||||||
worktreeEnabled = s.worktreeEnabled,
|
|
||||||
worktreeBranch = if (s.branchMode == WorktreeBranchMode.Existing) s.effectiveExistingBranch else s.newBranch,
|
|
||||||
worktreeBranchMode = s.branchMode,
|
|
||||||
)
|
|
||||||
when (val built = buildSpawnRequest(values, s.baseRepo)) {
|
|
||||||
is SpawnBuildResult.Error -> _state.update { it.copy(error = built.message) }
|
|
||||||
is SpawnBuildResult.Ok -> {
|
|
||||||
_state.update { it.copy(submitting = true, error = null) }
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val session = client.createSession(built.request)
|
|
||||||
_state.update { it.copy(submitting = false) }
|
|
||||||
onSuccess(session)
|
|
||||||
} catch (e: MaestroApiException) {
|
|
||||||
_state.update { it.copy(submitting = false, error = e.message ?: e.detail) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_state.update { it.copy(submitting = false, error = e.message ?: "Failed to spawn session.") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissError() {
|
|
||||||
_state.update { it.copy(error = null) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.JsonArray
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.contentOrNull
|
|
||||||
import kotlinx.serialization.json.longOrNull
|
|
||||||
import pt.naps.maestro.data.ReducedState
|
|
||||||
import pt.naps.maestro.data.TranscriptRow
|
|
||||||
import pt.naps.maestro.data.TranscriptRowKind
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loose parsing of the opaque ACP JSON a [TranscriptRow.data] /
|
|
||||||
* [ReducedState] field carries (docs/STRUCTURED-VIEW.md: "shaped to
|
|
||||||
* `claude-code-acp`'s ACP v1 schema, not covered by maestro-protocol's own
|
|
||||||
* compatibility guarantee"). Every accessor degrades to a default rather
|
|
||||||
* than throwing on an unexpected shape — ported from `web/src/types.ts`'s
|
|
||||||
* `Acp*` interfaces and `model.ts`'s readers.
|
|
||||||
*/
|
|
||||||
|
|
||||||
internal fun JsonObject.stringField(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull
|
|
||||||
|
|
||||||
internal fun JsonObject.longField(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
|
|
||||||
|
|
||||||
sealed class AcpContentBlock {
|
|
||||||
data class Text(val text: String) : AcpContentBlock()
|
|
||||||
data class Image(val mimeType: String?, val data: String?, val uri: String?) : AcpContentBlock()
|
|
||||||
data class Audio(val mimeType: String?, val data: String?) : AcpContentBlock()
|
|
||||||
data class ResourceLink(val name: String?, val uri: String?) : AcpContentBlock()
|
|
||||||
data class Resource(val uri: String?, val text: String?) : AcpContentBlock()
|
|
||||||
object Unknown : AcpContentBlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class AcpToolCallContent {
|
|
||||||
data class Content(val block: AcpContentBlock) : AcpToolCallContent()
|
|
||||||
data class Diff(val path: String, val oldText: String?, val newText: String) : AcpToolCallContent()
|
|
||||||
data class Terminal(val terminalId: String?) : AcpToolCallContent()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AcpToolCallLike(
|
|
||||||
val title: String? = null,
|
|
||||||
val name: String? = null,
|
|
||||||
val kind: String? = null,
|
|
||||||
val status: String? = null,
|
|
||||||
val content: List<AcpToolCallContent> = emptyList(),
|
|
||||||
val locations: List<String> = emptyList(),
|
|
||||||
val rawInput: JsonElement? = null,
|
|
||||||
val rawOutput: JsonElement? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AcpPermissionOption(val optionId: String, val name: String, val kind: String)
|
|
||||||
|
|
||||||
data class AcpApprovalRequest(val toolCall: AcpToolCallLike, val options: List<AcpPermissionOption>)
|
|
||||||
|
|
||||||
/** `maestroTurnMeta` — the daemon-owned durable footer for a settled turn. */
|
|
||||||
data class TurnMetaPayload(
|
|
||||||
val elapsedMs: Long,
|
|
||||||
val outcome: String,
|
|
||||||
val model: String? = null,
|
|
||||||
val contextTokens: Long? = null,
|
|
||||||
val contextDelta: Long? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun parseContentBlock(element: JsonElement?): AcpContentBlock {
|
|
||||||
val obj = element as? JsonObject ?: return AcpContentBlock.Unknown
|
|
||||||
return when (obj.stringField("type")) {
|
|
||||||
"text" -> AcpContentBlock.Text(obj.stringField("text").orEmpty())
|
|
||||||
"image" -> AcpContentBlock.Image(obj.stringField("mimeType"), obj.stringField("data"), obj.stringField("uri"))
|
|
||||||
"audio" -> AcpContentBlock.Audio(obj.stringField("mimeType"), obj.stringField("data"))
|
|
||||||
"resource_link" -> AcpContentBlock.ResourceLink(obj.stringField("name"), obj.stringField("uri"))
|
|
||||||
"resource" -> {
|
|
||||||
val resource = obj["resource"] as? JsonObject
|
|
||||||
AcpContentBlock.Resource(resource?.stringField("uri"), resource?.stringField("text"))
|
|
||||||
}
|
|
||||||
else -> AcpContentBlock.Unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Content-block text, or a labelled placeholder for anything that isn't
|
|
||||||
* text — never dropped silently. Mirrors `model.ts`'s `contentBlockText`. */
|
|
||||||
fun contentBlockText(block: AcpContentBlock): String = when (block) {
|
|
||||||
is AcpContentBlock.Text -> block.text
|
|
||||||
is AcpContentBlock.Image -> "[image]"
|
|
||||||
is AcpContentBlock.Audio -> "[audio]"
|
|
||||||
is AcpContentBlock.ResourceLink -> "[resource: ${block.name ?: block.uri ?: "linked"}]"
|
|
||||||
is AcpContentBlock.Resource -> "[resource]"
|
|
||||||
AcpContentBlock.Unknown -> ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A `User`/`AgentText`/`AgentThought` row's `data` is a `ContentChunk`:
|
|
||||||
* `{content: ContentBlock, messageId?}`. */
|
|
||||||
fun contentChunkText(data: JsonElement): String {
|
|
||||||
val obj = data as? JsonObject ?: return ""
|
|
||||||
return contentBlockText(parseContentBlock(obj["content"]))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseToolCallContent(element: JsonElement): AcpToolCallContent {
|
|
||||||
val obj = element as? JsonObject ?: return AcpToolCallContent.Terminal(null)
|
|
||||||
return when (obj.stringField("type")) {
|
|
||||||
"content" -> AcpToolCallContent.Content(parseContentBlock(obj["content"]))
|
|
||||||
"diff" -> AcpToolCallContent.Diff(
|
|
||||||
path = obj.stringField("path").orEmpty(),
|
|
||||||
oldText = obj.stringField("oldText"),
|
|
||||||
newText = obj.stringField("newText").orEmpty(),
|
|
||||||
)
|
|
||||||
else -> AcpToolCallContent.Terminal(obj.stringField("terminalId"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A `ToolCall`/`ToolDiff` row's `data`, or an `Approval` row's
|
|
||||||
* `data.toolCall` (`AcpToolCallLike` — both share this shape). */
|
|
||||||
fun parseToolCall(data: JsonElement): AcpToolCallLike {
|
|
||||||
val obj = data as? JsonObject ?: return AcpToolCallLike()
|
|
||||||
return AcpToolCallLike(
|
|
||||||
title = obj.stringField("title"),
|
|
||||||
name = obj.stringField("name"),
|
|
||||||
kind = obj.stringField("kind"),
|
|
||||||
status = obj.stringField("status"),
|
|
||||||
content = (obj["content"] as? JsonArray)?.map(::parseToolCallContent) ?: emptyList(),
|
|
||||||
locations = (obj["locations"] as? JsonArray)
|
|
||||||
?.mapNotNull { (it as? JsonObject)?.stringField("path") }
|
|
||||||
?: emptyList(),
|
|
||||||
rawInput = obj["rawInput"],
|
|
||||||
rawOutput = obj["rawOutput"],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An `Approval` row's `data` — a `RequestPermissionRequest` serialized
|
|
||||||
* without its nonce (docs/STRUCTURED-VIEW.md's "Approvals"). */
|
|
||||||
fun parseApprovalRequest(data: JsonElement): AcpApprovalRequest {
|
|
||||||
val obj = data as? JsonObject ?: return AcpApprovalRequest(AcpToolCallLike(), emptyList())
|
|
||||||
val toolCall = (obj["toolCall"] as? JsonObject)?.let(::parseToolCall) ?: AcpToolCallLike()
|
|
||||||
val options = (obj["options"] as? JsonArray)?.mapNotNull { option ->
|
|
||||||
val o = option as? JsonObject ?: return@mapNotNull null
|
|
||||||
val optionId = o.stringField("optionId") ?: return@mapNotNull null
|
|
||||||
val name = o.stringField("name") ?: return@mapNotNull null
|
|
||||||
val kind = o.stringField("kind") ?: return@mapNotNull null
|
|
||||||
AcpPermissionOption(optionId, name, kind)
|
|
||||||
} ?: emptyList()
|
|
||||||
return AcpApprovalRequest(toolCall, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The `maestroTurnMeta` footer a `Notice` row carries when it is a
|
|
||||||
* completed, cancelled or failed turn's durable footer — `null` for every
|
|
||||||
* other notice. */
|
|
||||||
fun parseTurnMeta(row: TranscriptRow): TurnMetaPayload? {
|
|
||||||
if (row.kind != TranscriptRowKind.Notice) return null
|
|
||||||
val obj = row.data as? JsonObject ?: return null
|
|
||||||
val meta = obj["maestroTurnMeta"] as? JsonObject ?: return null
|
|
||||||
val elapsedMs = meta.longField("elapsedMs") ?: return null
|
|
||||||
val outcome = meta.stringField("outcome") ?: return null
|
|
||||||
return TurnMetaPayload(
|
|
||||||
elapsedMs = elapsedMs,
|
|
||||||
outcome = outcome,
|
|
||||||
model = meta.stringField("model"),
|
|
||||||
contextTokens = meta.longField("contextTokens"),
|
|
||||||
contextDelta = meta.longField("contextDelta"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Notices the client consumes as state rather than showing in the
|
|
||||||
* transcript (docs/STRUCTURED-VIEW.md, "A client decides"). */
|
|
||||||
fun isStateOnlyNotice(row: TranscriptRow): Boolean {
|
|
||||||
if (row.kind != TranscriptRowKind.Notice) return false
|
|
||||||
val update = (row.data as? JsonObject)?.stringField("sessionUpdate")
|
|
||||||
return update == "available_commands_update" || update == "session_info_update"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `ReducedState.pending_approval` is `{nonce, request}` while a permission
|
|
||||||
* request is live, absent otherwise. */
|
|
||||||
fun pendingApprovalNonce(state: ReducedState): String? =
|
|
||||||
(state.pending_approval as? JsonObject)?.stringField("nonce")
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
|
||||||
|
|
||||||
private val DECISION_LABELS = listOf(
|
|
||||||
StructuredApprovalDecision.AllowOnce to "Allow once",
|
|
||||||
StructuredApprovalDecision.AllowForSession to "Allow for session",
|
|
||||||
StructuredApprovalDecision.Deny to "Deny",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An `Approval` row. The daemon exposes exactly three decisions
|
|
||||||
* (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of
|
|
||||||
* them. A decision the request offers no matching ACP option for is not
|
|
||||||
* shown — its button would "allow" something that silently resolves
|
|
||||||
* `Cancelled`.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ApprovalCard(
|
|
||||||
request: AcpApprovalRequest,
|
|
||||||
pending: Boolean,
|
|
||||||
busy: Boolean,
|
|
||||||
onDecide: (StructuredApprovalDecision) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val what = request.toolCall.title ?: StructuredModel.toolKindLabel(request.toolCall.kind)
|
|
||||||
|
|
||||||
if (!pending) {
|
|
||||||
Text(
|
|
||||||
text = "$what — approved",
|
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val offered = StructuredModel.offeredDecisions(request.options)
|
|
||||||
val path = request.toolCall.locations.firstOrNull()
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f), RoundedCornerShape(8.dp))
|
|
||||||
.padding(12.dp),
|
|
||||||
) {
|
|
||||||
Text("⚠ Permission requested", style = MaterialTheme.typography.labelMedium)
|
|
||||||
Text(what, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
if (path != null) {
|
|
||||||
Text(path, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
if (offered.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"no decision offered",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
for ((decision, label) in DECISION_LABELS) {
|
|
||||||
if (decision !in offered) continue
|
|
||||||
val isDeny = decision == StructuredApprovalDecision.Deny
|
|
||||||
Button(
|
|
||||||
onClick = { onDecide(decision) },
|
|
||||||
enabled = !busy,
|
|
||||||
colors = if (isDeny) {
|
|
||||||
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
|
||||||
} else {
|
|
||||||
ButtonDefaults.buttonColors()
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-line draft input plus send/cancel (#267). Sending is the pane's
|
|
||||||
* job — this only hands back the current draft text.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Composer(
|
|
||||||
draft: String,
|
|
||||||
onDraftChange: (String) -> Unit,
|
|
||||||
onSend: () -> Unit,
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
turnActive: Boolean,
|
|
||||||
sendEnabled: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = modifier.fillMaxWidth().padding(8.dp),
|
|
||||||
verticalAlignment = Alignment.Bottom,
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = draft,
|
|
||||||
onValueChange = onDraftChange,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
placeholder = { Text("Reply…") },
|
|
||||||
minLines = 1,
|
|
||||||
maxLines = 6,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
if (turnActive) {
|
|
||||||
IconButton(onClick = onCancel) {
|
|
||||||
Text(text = "■", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IconButton(onClick = onSend, enabled = sendEnabled && draft.isNotBlank()) {
|
|
||||||
Text(text = "➤")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
/** One rendered line of a [ToolDiff][pt.naps.maestro.data.TranscriptRowKind]
|
|
||||||
* content block. A `Hunk` line is a human summary, not source text; an `Eof`
|
|
||||||
* line marks a missing trailing newline. Ported from `web/src/components/
|
|
||||||
* structured/diff.ts`. */
|
|
||||||
data class DiffLine(val kind: Kind, val text: String) {
|
|
||||||
enum class Kind { Add, Del, Ctx, Hunk, Eof }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Line-level diff for a `Diff` content block (#140). ACP hands over whole
|
|
||||||
* `oldText`/`newText`, not a unified-diff patch, so the coloured hunks a
|
|
||||||
* diff view needs are computed here rather than parsed out of a patch.
|
|
||||||
*/
|
|
||||||
object DiffModel {
|
|
||||||
/** Interior unchanged runs longer than this collapse to a summary line
|
|
||||||
* flanked by this many lines of real context on each side. */
|
|
||||||
private const val CONTEXT = 3
|
|
||||||
|
|
||||||
/** Bounds the O(n·m) LCS table: above this cell count, the changed
|
|
||||||
* middle is shown as one coarse del-then-add block instead of a
|
|
||||||
* fine-grained diff — still correct, just not minimal. */
|
|
||||||
private const val LCS_CELL_LIMIT = 250_000
|
|
||||||
|
|
||||||
private data class SourceLine(val text: String, val terminated: Boolean)
|
|
||||||
|
|
||||||
private fun splitLines(text: String): List<SourceLine> {
|
|
||||||
if (text.isEmpty()) return emptyList()
|
|
||||||
val hasFinalNewline = text.endsWith("\n")
|
|
||||||
val parts = text.split("\n").let { if (hasFinalNewline) it.dropLast(1) else it }
|
|
||||||
return parts.mapIndexed { index, line -> SourceLine(line, index < parts.size - 1 || hasFinalNewline) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class Op(val kind: DiffLine.Kind, val line: SourceLine)
|
|
||||||
|
|
||||||
private fun sameLine(a: SourceLine, b: SourceLine) = a.text == b.text && a.terminated == b.terminated
|
|
||||||
|
|
||||||
/** Longest-common-subsequence diff of two line arrays, already trimmed
|
|
||||||
* of any shared prefix/suffix by the caller. */
|
|
||||||
private fun lcsDiff(a: List<SourceLine>, b: List<SourceLine>): List<Op> {
|
|
||||||
val n = a.size
|
|
||||||
val m = b.size
|
|
||||||
if (n == 0) return b.map { Op(DiffLine.Kind.Add, it) }
|
|
||||||
if (m == 0) return a.map { Op(DiffLine.Kind.Del, it) }
|
|
||||||
if (n.toLong() * m.toLong() > LCS_CELL_LIMIT) {
|
|
||||||
return a.map { Op(DiffLine.Kind.Del, it) } + b.map { Op(DiffLine.Kind.Add, it) }
|
|
||||||
}
|
|
||||||
val columns = m + 1
|
|
||||||
val dp = IntArray((n + 1) * columns)
|
|
||||||
for (i in n - 1 downTo 0) {
|
|
||||||
for (j in m - 1 downTo 0) {
|
|
||||||
dp[i * columns + j] = if (sameLine(a[i], b[j])) {
|
|
||||||
dp[(i + 1) * columns + j + 1] + 1
|
|
||||||
} else {
|
|
||||||
maxOf(dp[(i + 1) * columns + j], dp[i * columns + j + 1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val ops = mutableListOf<Op>()
|
|
||||||
var i = 0
|
|
||||||
var j = 0
|
|
||||||
while (i < n && j < m) {
|
|
||||||
when {
|
|
||||||
sameLine(a[i], b[j]) -> {
|
|
||||||
ops.add(Op(DiffLine.Kind.Ctx, a[i]))
|
|
||||||
i++
|
|
||||||
j++
|
|
||||||
}
|
|
||||||
dp[(i + 1) * columns + j] >= dp[i * columns + j + 1] -> {
|
|
||||||
ops.add(Op(DiffLine.Kind.Del, a[i]))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
ops.add(Op(DiffLine.Kind.Add, b[j]))
|
|
||||||
j++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while (i < n) ops.add(Op(DiffLine.Kind.Del, a[i++]))
|
|
||||||
while (j < m) ops.add(Op(DiffLine.Kind.Add, b[j++]))
|
|
||||||
return ops
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun diffLines(a: List<SourceLine>, b: List<SourceLine>): List<Op> {
|
|
||||||
var pre = 0
|
|
||||||
while (pre < a.size && pre < b.size && sameLine(a[pre], b[pre])) pre++
|
|
||||||
var suf = 0
|
|
||||||
while (suf < a.size - pre && suf < b.size - pre && sameLine(a[a.size - 1 - suf], b[b.size - 1 - suf])) suf++
|
|
||||||
val prefixCtx = a.subList(0, pre).map { Op(DiffLine.Kind.Ctx, it) }
|
|
||||||
val suffixCtx = a.subList(a.size - suf, a.size).map { Op(DiffLine.Kind.Ctx, it) }
|
|
||||||
val middle = lcsDiff(a.subList(pre, a.size - suf), b.subList(pre, b.size - suf))
|
|
||||||
return prefixCtx + middle + suffixCtx
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collapse a long run of unchanged lines to a summary, keeping up to
|
|
||||||
* [CONTEXT] real lines adjacent to each neighbouring change. */
|
|
||||||
private fun hunk(ops: List<Op>): List<DiffLine> {
|
|
||||||
val out = mutableListOf<DiffLine>()
|
|
||||||
fun push(op: Op) {
|
|
||||||
out.add(DiffLine(op.kind, op.line.text))
|
|
||||||
if (!op.line.terminated) out.add(DiffLine(DiffLine.Kind.Eof, "\\ No newline at end of file"))
|
|
||||||
}
|
|
||||||
var i = 0
|
|
||||||
while (i < ops.size) {
|
|
||||||
if (ops[i].kind != DiffLine.Kind.Ctx) {
|
|
||||||
push(ops[i])
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var j = i
|
|
||||||
while (j < ops.size && ops[j].kind == DiffLine.Kind.Ctx) j++
|
|
||||||
val run = ops.subList(i, j)
|
|
||||||
val wholeDiff = i == 0 && j == ops.size
|
|
||||||
val keepHead = if (wholeDiff || i > 0) CONTEXT else 0
|
|
||||||
val keepTail = if (wholeDiff || j < ops.size) CONTEXT else 0
|
|
||||||
if (run.size <= keepHead + keepTail) {
|
|
||||||
run.forEach(::push)
|
|
||||||
} else {
|
|
||||||
if (keepHead > 0) run.subList(0, keepHead).forEach(::push)
|
|
||||||
val collapsed = run.subList(keepHead, run.size - keepTail)
|
|
||||||
out.add(DiffLine(DiffLine.Kind.Hunk, "⋯ ${collapsed.size} unchanged lines ⋯"))
|
|
||||||
if (collapsed.any { !it.line.terminated }) {
|
|
||||||
out.add(DiffLine(DiffLine.Kind.Eof, "\\ No newline at end of file"))
|
|
||||||
}
|
|
||||||
if (keepTail > 0) run.subList(run.size - keepTail, run.size).forEach(::push)
|
|
||||||
}
|
|
||||||
i = j
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Coloured, hunked diff lines for a `Diff` content block. `oldText`
|
|
||||||
* absent (a new file) renders every line of `newText` as added. */
|
|
||||||
fun computeDiffLines(oldText: String?, newText: String): List<DiffLine> {
|
|
||||||
val newLines = splitLines(newText)
|
|
||||||
if (oldText == null) return hunk(newLines.map { Op(DiffLine.Kind.Add, it) })
|
|
||||||
return hunk(diffLines(splitLines(oldText), newLines))
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Stats(val added: Int, val removed: Int)
|
|
||||||
|
|
||||||
/** `+added -removed` counts straight off the diff — real derived data,
|
|
||||||
* never a fabricated count. */
|
|
||||||
fun diffStats(lines: List<DiffLine>): Stats {
|
|
||||||
var added = 0
|
|
||||||
var removed = 0
|
|
||||||
for (line in lines) when (line.kind) {
|
|
||||||
DiffLine.Kind.Add -> added++
|
|
||||||
DiffLine.Kind.Del -> removed++
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
return Stats(added, removed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
private val DiffAddColor = Color(0xFF2E7D32)
|
|
||||||
private val DiffAddBackground = Color(0x1A2E7D32)
|
|
||||||
private val DiffDelBackground = Color(0x1AD32F2F)
|
|
||||||
|
|
||||||
/** A `ToolDiff` row's diff content: `+` lines green, `-` lines red
|
|
||||||
* (docs/STRUCTURED-VIEW.md's "diff view" is a client decision). Lines are
|
|
||||||
* pre-computed by [DiffModel]. */
|
|
||||||
@Composable
|
|
||||||
fun DiffView(path: String, oldText: String?, newText: String, modifier: Modifier = Modifier) {
|
|
||||||
val lines = remember(oldText, newText) { DiffModel.computeDiffLines(oldText, newText) }
|
|
||||||
Column(modifier = modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
||||||
Text(
|
|
||||||
text = path,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
for (line in lines) {
|
|
||||||
val (prefix, color, background) = when (line.kind) {
|
|
||||||
DiffLine.Kind.Add -> Triple("+ ", DiffAddColor, DiffAddBackground)
|
|
||||||
DiffLine.Kind.Del -> Triple("- ", MaterialTheme.colorScheme.error, DiffDelBackground)
|
|
||||||
DiffLine.Kind.Ctx -> Triple(" ", MaterialTheme.colorScheme.onSurfaceVariant, Color.Transparent)
|
|
||||||
DiffLine.Kind.Hunk, DiffLine.Kind.Eof -> Triple("", MaterialTheme.colorScheme.onSurfaceVariant, Color.Transparent)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = prefix + line.text,
|
|
||||||
color = color,
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(background)
|
|
||||||
.padding(horizontal = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal markdown-to-[AnnotatedString] renderer for agent replies (#267
|
|
||||||
* v1): fenced code blocks, `#` headers, `-`/`*` bullets, and inline
|
|
||||||
* `**bold**`/`*italic*`/`` `code` ``. Not a CommonMark implementation —
|
|
||||||
* good enough for model prose without pulling in a markdown library.
|
|
||||||
*/
|
|
||||||
fun renderSimpleMarkdown(text: String): AnnotatedString = buildAnnotatedString {
|
|
||||||
var inCodeBlock = false
|
|
||||||
val lines = text.split("\n")
|
|
||||||
lines.forEachIndexed { index, rawLine ->
|
|
||||||
when {
|
|
||||||
rawLine.trim().startsWith("```") -> inCodeBlock = !inCodeBlock
|
|
||||||
inCodeBlock -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(rawLine) }
|
|
||||||
else -> {
|
|
||||||
val header = HEADER_PATTERN.matchEntire(rawLine)
|
|
||||||
val bullet = BULLET_PATTERN.matchEntire(rawLine)
|
|
||||||
when {
|
|
||||||
header != null -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
|
||||||
appendInlineMarkdown(header.groupValues[2])
|
|
||||||
}
|
|
||||||
bullet != null -> {
|
|
||||||
append("• ")
|
|
||||||
appendInlineMarkdown(bullet.groupValues[1])
|
|
||||||
}
|
|
||||||
else -> appendInlineMarkdown(rawLine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (index != lines.lastIndex) append("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val HEADER_PATTERN = Regex("^(#{1,6})\\s+(.*)$")
|
|
||||||
private val BULLET_PATTERN = Regex("^[-*]\\s+(.*)$")
|
|
||||||
|
|
||||||
/** Scans for the next inline marker (`` ` ``, `**`, `*`) and applies its
|
|
||||||
* span style, falling back to plain text for anything before it. */
|
|
||||||
private fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
|
|
||||||
var i = 0
|
|
||||||
while (i < text.length) {
|
|
||||||
when {
|
|
||||||
text.startsWith("**", i) -> {
|
|
||||||
val end = text.indexOf("**", i + 2)
|
|
||||||
if (end == -1) {
|
|
||||||
append(text.substring(i))
|
|
||||||
i = text.length
|
|
||||||
} else {
|
|
||||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, end)) }
|
|
||||||
i = end + 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
text.startsWith("`", i) -> {
|
|
||||||
val end = text.indexOf("`", i + 1)
|
|
||||||
if (end == -1) {
|
|
||||||
append(text.substring(i))
|
|
||||||
i = text.length
|
|
||||||
} else {
|
|
||||||
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(text.substring(i + 1, end)) }
|
|
||||||
i = end + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
text.startsWith("*", i) -> {
|
|
||||||
val end = text.indexOf("*", i + 1)
|
|
||||||
if (end == -1) {
|
|
||||||
append(text.substring(i))
|
|
||||||
i = text.length
|
|
||||||
} else {
|
|
||||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(text.substring(i + 1, end)) }
|
|
||||||
i = end + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val next = listOf(text.indexOf("**", i), text.indexOf("`", i), text.indexOf("*", i))
|
|
||||||
.filter { it >= 0 }
|
|
||||||
.minOrNull() ?: text.length
|
|
||||||
append(text.substring(i, next))
|
|
||||||
i = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import pt.naps.maestro.data.ReducedState
|
|
||||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
|
||||||
import pt.naps.maestro.data.TranscriptRow
|
|
||||||
import pt.naps.maestro.data.TranscriptRowKind
|
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure transcript logic for the structured pane (#267), kept out of the
|
|
||||||
* composables so it is testable without Compose. Ported from
|
|
||||||
* `web/src/components/structured/model.ts`; docs/STRUCTURED-VIEW.md is the
|
|
||||||
* contract for what a client, versus the daemon, decides.
|
|
||||||
*/
|
|
||||||
object StructuredModel {
|
|
||||||
private val KIND_LABELS = mapOf(
|
|
||||||
"read" to "Read",
|
|
||||||
"edit" to "Edit",
|
|
||||||
"delete" to "Delete",
|
|
||||||
"move" to "Move",
|
|
||||||
"search" to "Search",
|
|
||||||
"execute" to "Execute",
|
|
||||||
"think" to "Think",
|
|
||||||
"fetch" to "Fetch",
|
|
||||||
"switch_mode" to "Switch mode",
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Human label behind a `ToolKind`. An unrecognised/absent kind is
|
|
||||||
* "Tool" — never a blank label. */
|
|
||||||
fun toolKindLabel(kind: String?): String = kind?.let { KIND_LABELS[it] } ?: "Tool"
|
|
||||||
|
|
||||||
data class ToolTitle(val label: String, val arg: String)
|
|
||||||
|
|
||||||
private val TITLE_PATTERN = Regex("^(\\S+)\\(([\\s\\S]*)\\)$")
|
|
||||||
|
|
||||||
/** Split a title into `{label, arg}` when it follows the adapter's own
|
|
||||||
* `Name(argument)` convention; otherwise the whole title is the arg and
|
|
||||||
* `kind` supplies a generic label. */
|
|
||||||
fun splitToolTitle(title: String?, name: String?, kind: String?): ToolTitle {
|
|
||||||
val label = name ?: toolKindLabel(kind)
|
|
||||||
if (title.isNullOrEmpty()) return ToolTitle(label, "")
|
|
||||||
val match = TITLE_PATTERN.matchEntire(title)
|
|
||||||
return if (match != null) {
|
|
||||||
ToolTitle(name ?: match.groupValues[1], match.groupValues[2])
|
|
||||||
} else {
|
|
||||||
ToolTitle(label, title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class StatusMeta(val text: String, val kind: String)
|
|
||||||
|
|
||||||
fun statusMeta(status: String?, hasDiff: Boolean): StatusMeta? = when {
|
|
||||||
status == "in_progress" -> StatusMeta("running", "running")
|
|
||||||
status == "failed" -> StatusMeta("failed", "failed")
|
|
||||||
status == "pending" && !hasDiff -> StatusMeta("pending", "pending")
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun lastRowIdOfKind(rows: List<TranscriptRow>, kind: TranscriptRowKind): String? =
|
|
||||||
rows.lastOrNull { it.kind == kind }?.id
|
|
||||||
|
|
||||||
/** The row id a live `pending_approval` names: the newest `Approval`
|
|
||||||
* row. Only one approval is ever live (docs/STRUCTURED-VIEW.md), so the
|
|
||||||
* last one is the pending one. */
|
|
||||||
fun pendingApprovalRowId(rows: List<TranscriptRow>, state: ReducedState): String? =
|
|
||||||
if (state.pending_approval != null) lastRowIdOfKind(rows, TranscriptRowKind.Approval) else null
|
|
||||||
|
|
||||||
fun turnIsActive(state: ReducedState): Boolean = state.turn.responding || state.turn.thinking
|
|
||||||
|
|
||||||
/** Which decisions a permission request actually backs, by the
|
|
||||||
* `PermissionOptionKind` values it offers. A decision with no matching
|
|
||||||
* option resolves `Cancelled` server-side regardless — offering its
|
|
||||||
* button would let the operator "allow" something that silently
|
|
||||||
* no-ops. */
|
|
||||||
fun offeredDecisions(options: List<AcpPermissionOption>): List<StructuredApprovalDecision> {
|
|
||||||
val kinds = options.map { it.kind }.toSet()
|
|
||||||
val decisions = mutableListOf<StructuredApprovalDecision>()
|
|
||||||
if ("allow_once" in kinds) decisions += StructuredApprovalDecision.AllowOnce
|
|
||||||
if ("allow_always" in kinds) decisions += StructuredApprovalDecision.AllowForSession
|
|
||||||
if ("reject_once" in kinds || "reject_always" in kinds) decisions += StructuredApprovalDecision.Deny
|
|
||||||
return decisions
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ActivityGroup(
|
|
||||||
val toolCount: Int,
|
|
||||||
val rows: List<TranscriptRow>,
|
|
||||||
val lastToolLabel: String,
|
|
||||||
val lastToolArg: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Group contiguous tool rows (and resolved approvals between them) into
|
|
||||||
* collapsible blocks. An `AgentText`/`AgentThought` row between tools
|
|
||||||
* splits the run. A group must contain at least two tools to qualify; a
|
|
||||||
* single tool stays standalone. Keyed by `turn:<userId>:<seq>`.
|
|
||||||
*/
|
|
||||||
fun activityGroupsByTurn(rows: List<TranscriptRow>, pendingApprovalId: String?): Map<String, ActivityGroup> {
|
|
||||||
val groups = LinkedHashMap<String, ActivityGroup>()
|
|
||||||
var turnId = "before-first-message"
|
|
||||||
var runSeq = 0
|
|
||||||
var runTools = mutableListOf<TranscriptRow>()
|
|
||||||
var runPending = mutableListOf<TranscriptRow>()
|
|
||||||
|
|
||||||
fun flushRun() {
|
|
||||||
if (runTools.size >= 2) {
|
|
||||||
val gid = "turn:$turnId:$runSeq"
|
|
||||||
val tool = parseToolCall(runTools.last().data)
|
|
||||||
val title = splitToolTitle(tool.title, tool.name, tool.kind)
|
|
||||||
groups[gid] = ActivityGroup(runTools.size, runTools.toList(), title.label, title.arg)
|
|
||||||
runSeq++
|
|
||||||
}
|
|
||||||
runTools = mutableListOf()
|
|
||||||
runPending = mutableListOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
for (row in rows) {
|
|
||||||
when {
|
|
||||||
row.kind == TranscriptRowKind.User -> {
|
|
||||||
flushRun()
|
|
||||||
turnId = row.id
|
|
||||||
runSeq = 0
|
|
||||||
}
|
|
||||||
row.kind == TranscriptRowKind.AgentText || row.kind == TranscriptRowKind.AgentThought -> flushRun()
|
|
||||||
row.kind == TranscriptRowKind.Approval && row.id != pendingApprovalId -> runPending.add(row)
|
|
||||||
row.kind == TranscriptRowKind.ToolCall || row.kind == TranscriptRowKind.ToolDiff -> {
|
|
||||||
runTools.addAll(runPending)
|
|
||||||
runPending = mutableListOf()
|
|
||||||
runTools.add(row)
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushRun()
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One entry of the flattened render list: either a standalone row or a
|
|
||||||
* whole activity group. */
|
|
||||||
sealed class TranscriptItem {
|
|
||||||
data class RowItem(val row: TranscriptRow) : TranscriptItem()
|
|
||||||
data class GroupItem(val id: String, val group: ActivityGroup) : TranscriptItem()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The transcript in render order: rows, with each contiguous tool
|
|
||||||
* stretch folded into a single group item where one exists. `Plan` rows
|
|
||||||
* and state-only notices are dropped (both clients omit `Plan`). A turn
|
|
||||||
* footer only survives as the final item, so old footers stop reading
|
|
||||||
* like transcript content.
|
|
||||||
*/
|
|
||||||
fun transcriptItems(rows: List<TranscriptRow>, pendingApprovalId: String?): List<TranscriptItem> {
|
|
||||||
val groups = activityGroupsByTurn(rows, pendingApprovalId)
|
|
||||||
val rowToGroup = HashMap<String, String>()
|
|
||||||
for ((groupId, group) in groups) for (r in group.rows) rowToGroup[r.id] = groupId
|
|
||||||
|
|
||||||
val items = mutableListOf<TranscriptItem>()
|
|
||||||
val emitted = mutableSetOf<String>()
|
|
||||||
for (row in rows) {
|
|
||||||
val groupId = rowToGroup[row.id]
|
|
||||||
if (groupId != null) {
|
|
||||||
if (emitted.add(groupId)) items.add(TranscriptItem.GroupItem(groupId, groups.getValue(groupId)))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (row.kind == TranscriptRowKind.Plan) continue
|
|
||||||
if (row.kind == TranscriptRowKind.Notice && isStateOnlyNotice(row)) continue
|
|
||||||
if (row.kind == TranscriptRowKind.Approval && row.id != pendingApprovalId) continue
|
|
||||||
items.add(TranscriptItem.RowItem(row))
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.filterIndexed { index, item ->
|
|
||||||
!(item is TranscriptItem.RowItem && parseTurnMeta(item.row) != null) || index == items.size - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun formatElapsed(milliseconds: Long): String {
|
|
||||||
val seconds = maxOf(0L, milliseconds / 1_000)
|
|
||||||
if (seconds < 60) return "${seconds}s"
|
|
||||||
val minutes = seconds / 60
|
|
||||||
val remainder = seconds % 60
|
|
||||||
return if (remainder == 0L) "${minutes}m" else "${minutes}m ${remainder}s"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun compactNumber(value: Long): String {
|
|
||||||
val magnitude = abs(value)
|
|
||||||
return when {
|
|
||||||
magnitude < 1_000 -> value.toString()
|
|
||||||
magnitude < 1_000_000 -> {
|
|
||||||
if (value % 1_000 == 0L) "${value / 1_000}k" else "%.1fk".format(value / 1_000.0)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
if (value % 1_000_000 == 0L) "${value / 1_000_000}m" else "%.1fm".format(value / 1_000_000.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun formatContextTokens(value: Long): String = "${compactNumber(value)} context"
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import pt.naps.maestro.api.MaestroClient
|
|
||||||
import pt.naps.maestro.api.MaestroWebSocket
|
|
||||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full-screen structured pane for one session (issue #267): an ACP
|
|
||||||
* transcript over `WS /api/structured/:id`, the composer that drives
|
|
||||||
* `.../prompt` and `.../cancel`, and the approval controls that answer
|
|
||||||
* `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun StructuredScreen(
|
|
||||||
daemonBaseUrl: String,
|
|
||||||
sessionId: Uuid,
|
|
||||||
sessionTitle: String,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val client = remember(daemonBaseUrl) { MaestroClient(daemonBaseUrl) }
|
|
||||||
val webSocket = remember(daemonBaseUrl) { MaestroWebSocket(daemonBaseUrl) }
|
|
||||||
val viewModel = remember(sessionId, daemonBaseUrl) { StructuredViewModel(client, webSocket, sessionId) }
|
|
||||||
DisposableEffect(viewModel) { onDispose { viewModel.close() } }
|
|
||||||
|
|
||||||
val uiState by viewModel.state.collectAsState()
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(sessionTitle) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Text(text = "←", style = MaterialTheme.typography.titleLarge)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
StructuredPane(
|
|
||||||
state = uiState,
|
|
||||||
onDraftChange = viewModel::setDraft,
|
|
||||||
onSend = viewModel::sendPrompt,
|
|
||||||
onCancel = viewModel::cancelTurn,
|
|
||||||
onDecide = viewModel::resolveApproval,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The pane's body, independent of the screen chrome above — easier to
|
|
||||||
* preview/test without a daemon or a `Scaffold`. */
|
|
||||||
@Composable
|
|
||||||
fun StructuredPane(
|
|
||||||
state: StructuredUiState,
|
|
||||||
onDraftChange: (String) -> Unit,
|
|
||||||
onSend: () -> Unit,
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
onDecide: (rowId: String, decision: StructuredApprovalDecision) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
var expandedRows by remember { mutableStateOf(setOf<String>()) }
|
|
||||||
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
|
|
||||||
var followBottom by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
val items = state.items
|
|
||||||
val pendingRowId = state.pendingApprovalRowId
|
|
||||||
|
|
||||||
// Track whether the operator is at (or near) the bottom, so new content
|
|
||||||
// only pulls the view down while they were already following it.
|
|
||||||
LaunchedEffect(listState) {
|
|
||||||
snapshotFlow { listState.layoutInfo }.collect { info ->
|
|
||||||
val last = info.visibleItemsInfo.lastOrNull()
|
|
||||||
followBottom = last == null || last.index >= info.totalItemsCount - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(items, state.activityLabel()) {
|
|
||||||
if (followBottom && items.isNotEmpty()) {
|
|
||||||
listState.scrollToItem(items.size - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
) {
|
|
||||||
items(
|
|
||||||
items,
|
|
||||||
key = { item ->
|
|
||||||
when (item) {
|
|
||||||
is StructuredModel.TranscriptItem.RowItem -> item.row.id
|
|
||||||
is StructuredModel.TranscriptItem.GroupItem -> item.id
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { item ->
|
|
||||||
when (item) {
|
|
||||||
is StructuredModel.TranscriptItem.RowItem -> TranscriptRowView(
|
|
||||||
row = item.row,
|
|
||||||
pendingApproval = item.row.id == pendingRowId,
|
|
||||||
approvalBusy = state.approvalBusy,
|
|
||||||
expanded = expandedRows.contains(item.row.id),
|
|
||||||
onExpandedChange = { expanded -> expandedRows = toggled(expandedRows, item.row.id, expanded) },
|
|
||||||
onDecide = { decision -> onDecide(item.row.id, decision) },
|
|
||||||
)
|
|
||||||
is StructuredModel.TranscriptItem.GroupItem -> ToolGroup(
|
|
||||||
toolCount = item.group.toolCount,
|
|
||||||
lastToolLabel = item.group.lastToolLabel,
|
|
||||||
lastToolArg = item.group.lastToolArg,
|
|
||||||
expanded = expandedGroups.contains(item.id),
|
|
||||||
onExpandedChange = { expanded -> expandedGroups = toggled(expandedGroups, item.id, expanded) },
|
|
||||||
) {
|
|
||||||
for (row in item.group.rows) {
|
|
||||||
TranscriptRowView(
|
|
||||||
row = row,
|
|
||||||
pendingApproval = row.id == pendingRowId,
|
|
||||||
approvalBusy = state.approvalBusy,
|
|
||||||
expanded = expandedRows.contains(row.id),
|
|
||||||
onExpandedChange = { expanded -> expandedRows = toggled(expandedRows, row.id, expanded) },
|
|
||||||
onDecide = { decision -> onDecide(row.id, decision) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TurnStatusBar(label = state.activityLabel())
|
|
||||||
if (state.error != null) {
|
|
||||||
Text(
|
|
||||||
text = state.error,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Composer(
|
|
||||||
draft = state.draft,
|
|
||||||
onDraftChange = onDraftChange,
|
|
||||||
onSend = onSend,
|
|
||||||
onCancel = onCancel,
|
|
||||||
turnActive = state.turnActive,
|
|
||||||
sendEnabled = !state.posting,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toggled(set: Set<String>, id: String, expanded: Boolean): Set<String> =
|
|
||||||
if (expanded) set + id else set - id
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import pt.naps.maestro.api.MaestroClient
|
|
||||||
import pt.naps.maestro.api.MaestroWebSocket
|
|
||||||
import pt.naps.maestro.api.StructuredConnection
|
|
||||||
import pt.naps.maestro.data.ReducedState
|
|
||||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
|
||||||
import pt.naps.maestro.data.StructuredEvent
|
|
||||||
import pt.naps.maestro.data.TranscriptDelta
|
|
||||||
import pt.naps.maestro.data.TranscriptRow
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
enum class StructuredConnectionStatus { Connecting, Open, Closed, Error }
|
|
||||||
|
|
||||||
data class StructuredUiState(
|
|
||||||
val rows: List<TranscriptRow> = emptyList(),
|
|
||||||
val reducedState: ReducedState = ReducedState(),
|
|
||||||
val connection: StructuredConnectionStatus = StructuredConnectionStatus.Connecting,
|
|
||||||
val connectionDetail: String? = null,
|
|
||||||
val draft: String = "",
|
|
||||||
val posting: Boolean = false,
|
|
||||||
val approvalBusy: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
val nowMs: Long = System.currentTimeMillis(),
|
|
||||||
) {
|
|
||||||
val turnActive: Boolean get() = StructuredModel.turnIsActive(reducedState)
|
|
||||||
val pendingApprovalRowId: String? get() = StructuredModel.pendingApprovalRowId(rows, reducedState)
|
|
||||||
val items: List<StructuredModel.TranscriptItem> get() = StructuredModel.transcriptItems(rows, pendingApprovalRowId)
|
|
||||||
|
|
||||||
/** The turn-status strip's label, or `null` while nothing is happening
|
|
||||||
* (docs/STRUCTURED-VIEW.md's turn indicators, rendered as a client
|
|
||||||
* decides). */
|
|
||||||
fun activityLabel(): String? {
|
|
||||||
val turn = reducedState.turn
|
|
||||||
if (!turnActive && !posting) return null
|
|
||||||
val tools = turn.active_tool_calls.size
|
|
||||||
val label = when {
|
|
||||||
posting && !turnActive -> "Sending"
|
|
||||||
turn.thinking -> "Thinking"
|
|
||||||
tools > 0 -> "Running $tools ${if (tools == 1) "tool" else "tools"}"
|
|
||||||
else -> "Responding"
|
|
||||||
}
|
|
||||||
val startedAtMs = turn.started_at?.let { runCatching { java.time.Instant.parse(it).toEpochMilli() }.getOrNull() }
|
|
||||||
val elapsed = startedAtMs?.let { maxOf(0L, nowMs - it) } ?: 0L
|
|
||||||
return "$label · ${StructuredModel.formatElapsed(elapsed)}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the `WS /api/structured/:id` connection and the REST calls the
|
|
||||||
* pane's composer/approval controls make. A plain class over the socket's
|
|
||||||
* `Flow` rather than an `androidx.lifecycle.ViewModel` — same shape as
|
|
||||||
* [pt.naps.maestro.ui.spawn.SpawnViewModel]; the caller's own `remember`
|
|
||||||
* scope owns its lifetime.
|
|
||||||
*/
|
|
||||||
class StructuredViewModel(
|
|
||||||
private val client: MaestroClient,
|
|
||||||
private val webSocket: MaestroWebSocket,
|
|
||||||
private val sessionId: Uuid,
|
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
|
||||||
) {
|
|
||||||
private val _state = MutableStateFlow(StructuredUiState())
|
|
||||||
val state: StateFlow<StructuredUiState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private var connection: StructuredConnection? = null
|
|
||||||
private var socketJob: Job? = null
|
|
||||||
private var clockJob: Job? = null
|
|
||||||
|
|
||||||
/** The nonce this view model's own in-flight `resolveApproval` call
|
|
||||||
* targets, so a later projection update can tell "resolved" apart from
|
|
||||||
* "superseded by a new request" without waiting on the POST itself. */
|
|
||||||
private var approvalInFlightNonce: String? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
connect()
|
|
||||||
clockJob = scope.launch {
|
|
||||||
while (true) {
|
|
||||||
delay(1_000)
|
|
||||||
_state.update { it.copy(nowMs = System.currentTimeMillis()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** (Re)opens the socket. Every connect resends the whole transcript
|
|
||||||
* (docs/STRUCTURED-VIEW.md), so state is replaced wholesale rather than
|
|
||||||
* merged into what came before. */
|
|
||||||
fun connect() {
|
|
||||||
socketJob?.cancel()
|
|
||||||
connection?.close()
|
|
||||||
_state.update { it.copy(connection = StructuredConnectionStatus.Connecting, connectionDetail = null) }
|
|
||||||
val conn = webSocket.connectStructured(sessionId)
|
|
||||||
connection = conn
|
|
||||||
socketJob = scope.launch {
|
|
||||||
try {
|
|
||||||
conn.events.collect { event ->
|
|
||||||
_state.update { it.copy(connection = StructuredConnectionStatus.Open) }
|
|
||||||
applyEvent(event)
|
|
||||||
}
|
|
||||||
_state.update { it.copy(connection = StructuredConnectionStatus.Closed) }
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
_state.update { it.copy(connection = StructuredConnectionStatus.Error, connectionDetail = e.message) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyEvent(event: StructuredEvent) {
|
|
||||||
_state.update { current ->
|
|
||||||
when (event) {
|
|
||||||
is StructuredEvent.TranscriptSnapshotEvent -> current.copy(rows = event.snapshot.rows)
|
|
||||||
is StructuredEvent.ReducedStateEvent -> withReducedState(current, event.state)
|
|
||||||
is StructuredEvent.ProjectionUpdate -> {
|
|
||||||
val rows = event.transcript?.let { applyDelta(current.rows, it) } ?: current.rows
|
|
||||||
val next = withReducedState(current, event.state)
|
|
||||||
val posting = if (StructuredModel.turnIsActive(event.state)) false else next.posting
|
|
||||||
next.copy(rows = rows, posting = posting)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recomputes [StructuredUiState.approvalBusy] against whatever nonce
|
|
||||||
* this view model's own approval call is waiting on — cleared the
|
|
||||||
* moment the daemon's own state no longer names that nonce as pending,
|
|
||||||
* whether because it was answered or superseded. */
|
|
||||||
private fun withReducedState(current: StructuredUiState, state: ReducedState): StructuredUiState {
|
|
||||||
val inFlight = approvalInFlightNonce != null && pendingApprovalNonce(state) == approvalInFlightNonce
|
|
||||||
if (!inFlight) approvalInFlightNonce = null
|
|
||||||
return current.copy(reducedState = state, approvalBusy = inFlight)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyDelta(rows: List<TranscriptRow>, delta: TranscriptDelta): List<TranscriptRow> = when (delta) {
|
|
||||||
is TranscriptDelta.Append -> upsert(rows, delta.row)
|
|
||||||
is TranscriptDelta.Patch -> upsert(rows, delta.row)
|
|
||||||
is TranscriptDelta.Remove -> rows.filterNot { it.id == delta.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun upsert(rows: List<TranscriptRow>, row: TranscriptRow): List<TranscriptRow> {
|
|
||||||
val index = rows.indexOfFirst { it.id == row.id }
|
|
||||||
return if (index == -1) rows + row else rows.toMutableList().also { it[index] = row }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDraft(text: String) {
|
|
||||||
_state.update { it.copy(draft = text) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `POST .../prompt`: accepted at any time — idle or mid-turn, the
|
|
||||||
* daemon queues it server-side (docs/STRUCTURED-VIEW.md), so this never
|
|
||||||
* queues locally. */
|
|
||||||
fun sendPrompt() {
|
|
||||||
val text = _state.value.draft.trim()
|
|
||||||
if (text.isEmpty() || _state.value.posting) return
|
|
||||||
_state.update { it.copy(draft = "", posting = true, error = null) }
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
client.sendStructuredPrompt(sessionId, text)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_state.update { it.copy(posting = false, draft = text, error = e.message ?: "Failed to send prompt") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelTurn() {
|
|
||||||
_state.update { it.copy(error = null) }
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
client.cancelStructuredTurn(sessionId)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_state.update { it.copy(error = e.message ?: "Failed to cancel turn") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answers a pending permission request. A no-op — not an error — once
|
|
||||||
* this is no longer the pending approval (the nonce moved on, or
|
|
||||||
* resolved elsewhere), matching the daemon's own always-`204`
|
|
||||||
* semantics. */
|
|
||||||
fun resolveApproval(rowId: String, decision: StructuredApprovalDecision) {
|
|
||||||
val current = _state.value
|
|
||||||
val nonce = pendingApprovalNonce(current.reducedState)
|
|
||||||
if (nonce == null || rowId != current.pendingApprovalRowId) return
|
|
||||||
approvalInFlightNonce = nonce
|
|
||||||
_state.update { it.copy(approvalBusy = true) }
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
client.resolveStructuredApproval(sessionId, nonce, decision)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (approvalInFlightNonce == nonce) {
|
|
||||||
approvalInFlightNonce = null
|
|
||||||
_state.update { it.copy(approvalBusy = false, error = e.message ?: "Failed to resolve approval") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissError() {
|
|
||||||
_state.update { it.copy(error = null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
clockJob?.cancel()
|
|
||||||
socketJob?.cancel()
|
|
||||||
connection?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A turn's collapsed activity block: two or more tool rows folded by
|
|
||||||
* [StructuredModel.activityGroupsByTurn]. The header counts tool rows only
|
|
||||||
* and shows the last/current tool label so the operator sees activity at a
|
|
||||||
* glance without expanding (docs/STRUCTURED-VIEW.md). Opening it reveals
|
|
||||||
* tools in chronological order.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ToolGroup(
|
|
||||||
toolCount: Int,
|
|
||||||
lastToolLabel: String,
|
|
||||||
lastToolArg: String,
|
|
||||||
expanded: Boolean,
|
|
||||||
onExpandedChange: (Boolean) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val summary = if (lastToolArg.isNotEmpty()) {
|
|
||||||
"$toolCount tool calls · $lastToolLabel '$lastToolArg'"
|
|
||||||
} else {
|
|
||||||
"$toolCount tool calls"
|
|
||||||
}
|
|
||||||
Column(modifier = modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onExpandedChange(!expanded) }
|
|
||||||
.padding(vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(if (expanded) "▾" else "›", modifier = Modifier.width(20.dp))
|
|
||||||
Text(
|
|
||||||
text = summary,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (expanded) {
|
|
||||||
Column(modifier = Modifier.padding(start = 20.dp), content = content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
|
||||||
import pt.naps.maestro.data.TranscriptRow
|
|
||||||
import pt.naps.maestro.data.TranscriptRowKind
|
|
||||||
|
|
||||||
private val prettyJson = Json { prettyPrint = true }
|
|
||||||
|
|
||||||
/** One transcript row, dispatched by kind (docs/STRUCTURED-VIEW.md fixes
|
|
||||||
* the eight kinds; rendering is entirely a client decision). `Plan` rows
|
|
||||||
* and state-only notices are filtered out upstream by
|
|
||||||
* [StructuredModel.transcriptItems] and never reach here. */
|
|
||||||
@Composable
|
|
||||||
fun TranscriptRowView(
|
|
||||||
row: TranscriptRow,
|
|
||||||
pendingApproval: Boolean,
|
|
||||||
approvalBusy: Boolean,
|
|
||||||
expanded: Boolean,
|
|
||||||
onExpandedChange: (Boolean) -> Unit,
|
|
||||||
onDecide: (StructuredApprovalDecision) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
when (row.kind) {
|
|
||||||
TranscriptRowKind.User -> UserRow(row, modifier)
|
|
||||||
TranscriptRowKind.AgentText -> AgentTextRow(row, modifier)
|
|
||||||
TranscriptRowKind.AgentThought -> AgentThoughtRow(row, expanded, onExpandedChange, modifier)
|
|
||||||
TranscriptRowKind.ToolCall, TranscriptRowKind.ToolDiff -> ToolCallRow(row, expanded, onExpandedChange, modifier)
|
|
||||||
TranscriptRowKind.Approval -> {
|
|
||||||
val request = remember(row.data) { parseApprovalRequest(row.data) }
|
|
||||||
ApprovalCard(request = request, pending = pendingApproval, busy = approvalBusy, onDecide = onDecide, modifier = modifier)
|
|
||||||
}
|
|
||||||
TranscriptRowKind.Plan -> {}
|
|
||||||
TranscriptRowKind.Notice -> {
|
|
||||||
val meta = remember(row.data) { parseTurnMeta(row) }
|
|
||||||
if (meta != null) TurnMetaRow(meta, modifier) else NoticeRow(row, modifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UserRow(row: TranscriptRow, modifier: Modifier = Modifier) {
|
|
||||||
val text = remember(row.data) { contentChunkText(row.data) }
|
|
||||||
if (text.isBlank()) return
|
|
||||||
Box(modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), contentAlignment = Alignment.CenterEnd) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.wrapContentWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(12.dp))
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
Text(text, color = MaterialTheme.colorScheme.onPrimaryContainer, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AgentTextRow(row: TranscriptRow, modifier: Modifier = Modifier) {
|
|
||||||
val text = remember(row.data) { contentChunkText(row.data) }
|
|
||||||
if (text.isBlank()) return
|
|
||||||
Text(
|
|
||||||
text = renderSimpleMarkdown(text),
|
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AgentThoughtRow(
|
|
||||||
row: TranscriptRow,
|
|
||||||
expanded: Boolean,
|
|
||||||
onExpandedChange: (Boolean) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val text = remember(row.data) { contentChunkText(row.data).trim() }
|
|
||||||
if (text.isBlank()) return
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onExpandedChange(!expanded) }
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
Text("Thinking", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
maxLines = if (expanded) Int.MAX_VALUE else 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ToolCallRow(
|
|
||||||
row: TranscriptRow,
|
|
||||||
expanded: Boolean,
|
|
||||||
onExpandedChange: (Boolean) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val tool = remember(row.data) { parseToolCall(row.data) }
|
|
||||||
val hasDiff = tool.content.any { it is AcpToolCallContent.Diff }
|
|
||||||
val title = remember(tool) { StructuredModel.splitToolTitle(tool.title, tool.name, tool.kind) }
|
|
||||||
val statusMeta = remember(tool.status, hasDiff) { StructuredModel.statusMeta(tool.status, hasDiff) }
|
|
||||||
val diffSummary = if (hasDiff) remember(tool.content) { diffStatsSummary(tool.content) } else null
|
|
||||||
val disclosable = tool.content.isNotEmpty() || tool.rawInput != null || tool.rawOutput != null
|
|
||||||
val metaText = listOfNotNull(diffSummary, statusMeta?.text).joinToString(" · ")
|
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.let { if (disclosable) it.clickable { onExpandedChange(!expanded) } else it }
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(title.label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Medium)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
title.arg,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
)
|
|
||||||
if (metaText.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
metaText,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = when (statusMeta?.kind) {
|
|
||||||
"failed" -> MaterialTheme.colorScheme.error
|
|
||||||
"running" -> MaterialTheme.colorScheme.primary
|
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (disclosable && expanded) {
|
|
||||||
Column(modifier = Modifier.padding(start = 8.dp, top = 4.dp)) {
|
|
||||||
for (block in tool.content) {
|
|
||||||
when (block) {
|
|
||||||
is AcpToolCallContent.Content -> {
|
|
||||||
val text = contentBlockText(block.block)
|
|
||||||
if (text.isNotBlank()) {
|
|
||||||
Text(text, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is AcpToolCallContent.Diff -> DiffView(path = block.path, oldText = block.oldText, newText = block.newText)
|
|
||||||
is AcpToolCallContent.Terminal -> Text(
|
|
||||||
block.terminalId?.let { "Terminal · $it" } ?: "Embedded terminal",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tool.rawInput?.let { ToolDetailBlock("Input", prettyPrintJson(it)) }
|
|
||||||
tool.rawOutput?.let { ToolDetailBlock("Output", prettyPrintJson(it)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ToolDetailBlock(label: String, text: String) {
|
|
||||||
Column(modifier = Modifier.padding(top = 4.dp)) {
|
|
||||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Text(text, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun diffStatsSummary(content: List<AcpToolCallContent>): String {
|
|
||||||
var added = 0
|
|
||||||
var removed = 0
|
|
||||||
for (block in content) {
|
|
||||||
if (block !is AcpToolCallContent.Diff) continue
|
|
||||||
val stats = DiffModel.diffStats(DiffModel.computeDiffLines(block.oldText, block.newText))
|
|
||||||
added += stats.added
|
|
||||||
removed += stats.removed
|
|
||||||
}
|
|
||||||
return "+$added −$removed"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun prettyPrintJson(element: JsonElement): String =
|
|
||||||
runCatching { prettyJson.encodeToString(JsonElement.serializer(), element) }.getOrElse { element.toString() }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun NoticeRow(row: TranscriptRow, modifier: Modifier = Modifier) {
|
|
||||||
Text(
|
|
||||||
text = "[${row.kind.name}]",
|
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TurnMetaRow(meta: TurnMetaPayload, modifier: Modifier = Modifier) {
|
|
||||||
val parts = mutableListOf(StructuredModel.formatElapsed(meta.elapsedMs))
|
|
||||||
when {
|
|
||||||
meta.contextDelta != null -> {
|
|
||||||
val sign = if (meta.contextDelta > 0) "+" else ""
|
|
||||||
parts += "$sign${meta.contextDelta} context tokens"
|
|
||||||
}
|
|
||||||
meta.contextTokens != null -> parts += StructuredModel.formatContextTokens(meta.contextTokens)
|
|
||||||
}
|
|
||||||
meta.model?.let { parts += it }
|
|
||||||
if (meta.outcome != "completed") parts += meta.outcome
|
|
||||||
Text(
|
|
||||||
text = parts.joinToString(" · "),
|
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/** Strip above the composer: responding/thinking/active tool calls plus
|
|
||||||
* elapsed time (docs/STRUCTURED-VIEW.md's turn indicators, drawn as a
|
|
||||||
* client decides). Hidden while nothing is happening. */
|
|
||||||
@Composable
|
|
||||||
fun TurnStatusBar(label: String?, modifier: Modifier = Modifier) {
|
|
||||||
if (label == null) return
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.terminal
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/** One button on the [TerminalKeyBar]. A [Modifier] toggles Ctrl/Alt state
|
|
||||||
* in the terminal page's own JS instead of sending a byte sequence. */
|
|
||||||
sealed class TerminalKeyAction(val label: String) {
|
|
||||||
data class Modifier(val name: String, val display: String) : TerminalKeyAction(display)
|
|
||||||
data class Send(val display: String, val key: String) : TerminalKeyAction(display)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Termux-style extra key row for the on-screen keyboard (issue #266): Tab,
|
|
||||||
* Esc, Ctrl/Alt, and the four cursor arrows. Ctrl/Alt are one-shot modifiers
|
|
||||||
* — armed here, then folded into the very next key (tapped on this bar or
|
|
||||||
* typed on the soft keyboard) by `assets/terminal/index.html`'s own JS.
|
|
||||||
*/
|
|
||||||
val TERMINAL_KEY_ACTIONS: List<TerminalKeyAction> = listOf(
|
|
||||||
TerminalKeyAction.Send("Esc", "Esc"),
|
|
||||||
TerminalKeyAction.Send("Tab", "Tab"),
|
|
||||||
TerminalKeyAction.Modifier("ctrl", "Ctrl"),
|
|
||||||
TerminalKeyAction.Modifier("alt", "Alt"),
|
|
||||||
TerminalKeyAction.Send("↑", "Up"),
|
|
||||||
TerminalKeyAction.Send("↓", "Down"),
|
|
||||||
TerminalKeyAction.Send("←", "Left"),
|
|
||||||
TerminalKeyAction.Send("→", "Right"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TerminalKeyBar(
|
|
||||||
armed: Set<String>,
|
|
||||||
onSend: (String) -> Unit,
|
|
||||||
onToggleModifier: (String) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.horizontalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
|
||||||
) {
|
|
||||||
for (action in TERMINAL_KEY_ACTIONS) {
|
|
||||||
when (action) {
|
|
||||||
is TerminalKeyAction.Modifier -> {
|
|
||||||
val isArmed = action.name in armed
|
|
||||||
TextButton(onClick = { onToggleModifier(action.name) }) {
|
|
||||||
Text(
|
|
||||||
text = action.display,
|
|
||||||
color = if (isArmed) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is TerminalKeyAction.Send -> {
|
|
||||||
TextButton(onClick = { onSend(action.key) }) {
|
|
||||||
Text(text = action.display)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.terminal
|
|
||||||
|
|
||||||
import java.net.URLEncoder
|
|
||||||
import pt.naps.maestro.api.Routes
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `file:///android_asset/terminal/index.html` URL a [TerminalWebView]
|
|
||||||
* loads for one session. The embedded xterm.js page reads its `ws` query
|
|
||||||
* param and opens that as its own `WS /api/attach/:id` connection directly
|
|
||||||
* — this object only computes the URL, it never touches the socket.
|
|
||||||
*/
|
|
||||||
object TerminalPageUrl {
|
|
||||||
private const val ASSET_URL = "file:///android_asset/terminal/index.html"
|
|
||||||
|
|
||||||
fun build(daemonBaseUrl: String, sessionId: Uuid): String {
|
|
||||||
val wsUrl = Routes.attachWsUrl(daemonBaseUrl, sessionId)
|
|
||||||
val encoded = URLEncoder.encode(wsUrl, "UTF-8")
|
|
||||||
return "$ASSET_URL?ws=$encoded"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.terminal
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.isImeVisible
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import pt.naps.maestro.data.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full-screen terminal pane for one session (issue #266): a [TerminalWebView]
|
|
||||||
* showing xterm.js bound to `WS /api/attach/:id`, plus the native
|
|
||||||
* [TerminalKeyBar] overlaid above the soft keyboard while it's open.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
fun TerminalScreen(
|
|
||||||
daemonBaseUrl: String,
|
|
||||||
sessionId: Uuid,
|
|
||||||
sessionTitle: String,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val pageUrl = remember(daemonBaseUrl, sessionId) { TerminalPageUrl.build(daemonBaseUrl, sessionId) }
|
|
||||||
var controller by remember { mutableStateOf<TerminalWebViewController?>(null) }
|
|
||||||
var armed by remember { mutableStateOf(emptySet<String>()) }
|
|
||||||
val keyboardOpen = WindowInsets.isImeVisible
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(sessionTitle) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Text(text = "←", style = MaterialTheme.typography.titleLarge)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
) {
|
|
||||||
TerminalWebView(
|
|
||||||
pageUrl = pageUrl,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxSize(),
|
|
||||||
onReady = { controller = it },
|
|
||||||
onModifierConsumed = { name -> armed = armed - name },
|
|
||||||
)
|
|
||||||
if (keyboardOpen) {
|
|
||||||
TerminalKeyBar(
|
|
||||||
armed = armed,
|
|
||||||
onSend = { key ->
|
|
||||||
controller?.sendKey(key)
|
|
||||||
armed = emptySet()
|
|
||||||
},
|
|
||||||
onToggleModifier = { name ->
|
|
||||||
armed = if (name in armed) armed - name else armed + name
|
|
||||||
controller?.toggleModifier(name)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.terminal
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.webkit.JavascriptInterface
|
|
||||||
import android.webkit.WebSettings
|
|
||||||
import android.webkit.WebView
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/** Matches `#121419` in `assets/terminal/index.html` and the web SPA's own
|
|
||||||
* xterm theme, so the WebView doesn't flash white while the page loads. */
|
|
||||||
private const val TERMINAL_BACKGROUND_ARGB = 0xFF121419.toInt()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the terminal page loaded into a [TerminalWebView] from native code
|
|
||||||
* — the extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles. The page
|
|
||||||
* itself owns the `WS /api/attach/:id` socket and the xterm.js instance;
|
|
||||||
* this controller only calls the `maestro*` bridge functions it exposes.
|
|
||||||
*/
|
|
||||||
class TerminalWebViewController internal constructor(private val webView: WebView) {
|
|
||||||
fun sendKey(key: String) {
|
|
||||||
val arg = JSONObject.quote(key)
|
|
||||||
webView.evaluateJavascript("window.maestroKey && window.maestroKey($arg)", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleModifier(name: String) {
|
|
||||||
val arg = JSONObject.quote(name)
|
|
||||||
webView.evaluateJavascript("window.maestroToggleMod && window.maestroToggleMod($arg)", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun focus() {
|
|
||||||
webView.requestFocus()
|
|
||||||
webView.evaluateJavascript("window.maestroFocus && window.maestroFocus()", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Terminal pane composable for issue #266: a [WebView] loading the bundled
|
|
||||||
* xterm.js page (`assets/terminal/index.html`), which connects straight to
|
|
||||||
* the daemon's `WS /api/attach/:id` for this session. [onModifierConsumed]
|
|
||||||
* fires when the page's own JS folds an armed Ctrl/Alt into a key typed on
|
|
||||||
* the soft keyboard, so the native key bar can clear its toggle to match.
|
|
||||||
*/
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
|
||||||
@Composable
|
|
||||||
fun TerminalWebView(
|
|
||||||
pageUrl: String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onReady: (TerminalWebViewController) -> Unit = {},
|
|
||||||
onModifierConsumed: (String) -> Unit = {},
|
|
||||||
) {
|
|
||||||
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
|
||||||
AndroidView(
|
|
||||||
modifier = modifier,
|
|
||||||
factory = { context ->
|
|
||||||
WebView(context).apply {
|
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
)
|
|
||||||
settings.javaScriptEnabled = true
|
|
||||||
settings.domStorageEnabled = true
|
|
||||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
|
||||||
setBackgroundColor(TERMINAL_BACKGROUND_ARGB)
|
|
||||||
addJavascriptInterface(
|
|
||||||
object {
|
|
||||||
@JavascriptInterface
|
|
||||||
fun onModConsumed(name: String) {
|
|
||||||
mainHandler.post { onModifierConsumed(name) }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AndroidBridge",
|
|
||||||
)
|
|
||||||
onReady(TerminalWebViewController(this))
|
|
||||||
loadUrl(pageUrl)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update = { webView ->
|
|
||||||
if (webView.url != pageUrl) webView.loadUrl(pageUrl)
|
|
||||||
},
|
|
||||||
onRelease = { it.destroy() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
|
||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
|
||||||
val Pink40 = Color(0xFF7D5260)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.theme
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
|
||||||
primary = Purple80,
|
|
||||||
secondary = PurpleGrey80,
|
|
||||||
tertiary = Pink80,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
|
||||||
primary = Purple40,
|
|
||||||
secondary = PurpleGrey40,
|
|
||||||
tertiary = Pink40,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MaestroTheme(
|
|
||||||
darkTheme: Boolean = true,
|
|
||||||
dynamicColor: Boolean = false,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val useDarkTheme = darkTheme || isSystemInDarkTheme()
|
|
||||||
val colorScheme = when {
|
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
|
||||||
val context = LocalContext.current
|
|
||||||
if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
useDarkTheme -> DarkColorScheme
|
|
||||||
else -> LightColorScheme
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
typography = Typography,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
val Typography = Typography(
|
|
||||||
bodyLarge = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 24.sp,
|
|
||||||
letterSpacing = 0.5.sp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#1B1B1B"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#D0BCFF"
|
|
||||||
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="app_name">Maestro</string>
|
|
||||||
<string name="placeholder_message">Maestro is not connected to a daemon yet.</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
|
|
||||||
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
|
|
||||||
<item name="android:windowBackground">@android:color/black</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package pt.naps.maestro
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class ExampleUnitTest {
|
|
||||||
@Test
|
|
||||||
fun addition_isCorrect() {
|
|
||||||
assertEquals(4, 2 + 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class BackoffTest {
|
|
||||||
@Test
|
|
||||||
fun `doubles from 500ms and caps at 10s`() {
|
|
||||||
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
|
||||||
val delays = List(8) { backoff.nextDelayMs() }
|
|
||||||
assertEquals(listOf(500L, 1000L, 2000L, 4000L, 8000L, 10_000L, 10_000L, 10_000L), delays)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `reset returns to the initial delay`() {
|
|
||||||
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
|
||||||
repeat(4) { backoff.nextDelayMs() }
|
|
||||||
backoff.reset()
|
|
||||||
assertEquals(500L, backoff.nextDelayMs())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.data.CatalogEvent
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SessionStatus
|
|
||||||
|
|
||||||
class CatalogStoreTest {
|
|
||||||
private fun session(id: String) = Session(
|
|
||||||
id = id,
|
|
||||||
cwd = "/tmp",
|
|
||||||
command = "bash",
|
|
||||||
status = SessionStatus.Running,
|
|
||||||
created_at = "2026-05-24T12:00:00Z",
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `snapshot replaces the map keyed by id`() {
|
|
||||||
val store = CatalogStore()
|
|
||||||
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
|
||||||
assertEquals(setOf("a", "b"), store.sessions.value.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `added and updated upsert one entry`() {
|
|
||||||
val store = CatalogStore()
|
|
||||||
store.apply(CatalogEvent.Snapshot(emptyList()))
|
|
||||||
store.apply(CatalogEvent.SessionAdded(session("a")))
|
|
||||||
assertEquals(1, store.sessions.value.size)
|
|
||||||
|
|
||||||
val renamed = session("a").copy(command = "zsh")
|
|
||||||
store.apply(CatalogEvent.SessionUpdated(renamed))
|
|
||||||
assertEquals("zsh", store.sessions.value.getValue("a").command)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `removed drops the entry`() {
|
|
||||||
val store = CatalogStore()
|
|
||||||
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
|
||||||
store.apply(CatalogEvent.SessionRemoved("a"))
|
|
||||||
assertEquals(setOf("b"), store.sessions.value.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `lagged reports the skip count without touching the map`() {
|
|
||||||
val store = CatalogStore()
|
|
||||||
store.apply(CatalogEvent.Snapshot(listOf(session("a"))))
|
|
||||||
val skipped = store.apply(CatalogEvent.Lagged(3))
|
|
||||||
assertEquals(3L, skipped)
|
|
||||||
assertEquals(setOf("a"), store.sessions.value.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `non-lagged events report no skip`() {
|
|
||||||
val store = CatalogStore()
|
|
||||||
assertNull(store.apply(CatalogEvent.Snapshot(emptyList())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package pt.naps.maestro.connection
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.data.BuildInfo
|
|
||||||
import pt.naps.maestro.data.DaemonInfo
|
|
||||||
|
|
||||||
class DaemonIdentityTest {
|
|
||||||
private fun info(commit: String = "", instance: String = "") =
|
|
||||||
DaemonInfo(build = BuildInfo(commit = commit), instance = instance)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `same commit and instance is Same`() {
|
|
||||||
val a = info(commit = "abc123", instance = "i1")
|
|
||||||
assertEquals(RestartKind.SAME, compareIdentity(a, a.copy()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `different commit is Upgraded`() {
|
|
||||||
val before = info(commit = "abc123", instance = "i1")
|
|
||||||
val after = info(commit = "def456", instance = "i2")
|
|
||||||
assertEquals(RestartKind.UPGRADED, compareIdentity(before, after))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `same commit but different instance is Restarted`() {
|
|
||||||
val before = info(commit = "abc123", instance = "i1")
|
|
||||||
val after = info(commit = "abc123", instance = "i2")
|
|
||||||
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `empty commits on both sides never read as an upgrade`() {
|
|
||||||
val before = info(commit = "", instance = "i1")
|
|
||||||
val after = info(commit = "", instance = "i2")
|
|
||||||
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `format release names the commit and short date`() {
|
|
||||||
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = false)
|
|
||||||
assertEquals("f89be4a716c7 · 2026-09-02", formatRelease(build))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `dirty build is flagged`() {
|
|
||||||
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = true)
|
|
||||||
assertEquals("f89be4a716c7 · 2026-09-02 (dirty)", formatRelease(build))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `empty commit is unknown release`() {
|
|
||||||
assertEquals("unknown release", formatRelease(BuildInfo()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package pt.naps.maestro.data
|
|
||||||
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/** Wire-format checks against fixtures mirrored from
|
|
||||||
* `crates/maestro-protocol/src/lib.rs`'s own test suite, so a Kotlin type
|
|
||||||
* that drifts from the Rust source of truth fails here first. */
|
|
||||||
class WireSerializationTest {
|
|
||||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
|
||||||
|
|
||||||
private fun legacySessionJson(extra: String = "") = """
|
|
||||||
{"id":"00000000-0000-0000-0000-000000000001","cwd":"/tmp","command":"bash",
|
|
||||||
"status":"Running","created_at":"2026-05-24T12:00:00Z"$extra}
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `legacy session payload defaults new fields`() {
|
|
||||||
val s = json.decodeFromString<Session>(legacySessionJson())
|
|
||||||
assertEquals(ActivityState.Idle, s.activity)
|
|
||||||
assertEquals(SessionView.Terminal, s.view)
|
|
||||||
assertNull(s.foreground)
|
|
||||||
assertNull(s.blocked_work)
|
|
||||||
assertEquals(EPOCH, s.last_activity_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `unknown activity state degrades to Idle`() {
|
|
||||||
val s = json.decodeFromString<Session>(legacySessionJson(""","activity":"QuantumEntangled""""))
|
|
||||||
assertEquals(ActivityState.Idle, s.activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `unknown name source degrades to User`() {
|
|
||||||
assertEquals(NameSource.User, json.decodeFromString<NameSource>("\"Rolled\""))
|
|
||||||
assertEquals(NameSource.Haiku, json.decodeFromString<NameSource>("\"Haiku\""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `catalog event tags by type`() {
|
|
||||||
assertEquals(
|
|
||||||
"""{"type":"Lagged","skipped":3}""",
|
|
||||||
json.encodeToString<CatalogEvent>(CatalogEvent.Lagged(3)),
|
|
||||||
)
|
|
||||||
val removed = CatalogEvent.SessionRemoved("00000000-0000-0000-0000-000000000001")
|
|
||||||
assertEquals(removed, json.decodeFromString<CatalogEvent>(json.encodeToString<CatalogEvent>(removed)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `client control tags by type`() {
|
|
||||||
assertEquals(
|
|
||||||
"""{"type":"Resize","cols":120,"rows":40}""",
|
|
||||||
json.encodeToString<ClientControl>(ClientControl.Resize(120, 40)),
|
|
||||||
)
|
|
||||||
assertEquals(ClientControl.Ping, json.decodeFromString<ClientControl>("""{"type":"Ping"}"""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `structured approval decision is a bare string`() {
|
|
||||||
assertEquals(
|
|
||||||
StructuredApprovalDecision.AllowOnce,
|
|
||||||
json.decodeFromString<StructuredApprovalDecision>("\"allow_once\""),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"\"allow_for_session\"",
|
|
||||||
json.encodeToString(StructuredApprovalDecision.AllowForSession),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `structured event tags snake_case`() {
|
|
||||||
val event = json.decodeFromString<StructuredEvent>(
|
|
||||||
"""{"type":"transcript_snapshot","snapshot":{"cursor":7,"rows":[]}}""",
|
|
||||||
)
|
|
||||||
assertTrue(event is StructuredEvent.TranscriptSnapshotEvent)
|
|
||||||
assertEquals(7L, (event as StructuredEvent.TranscriptSnapshotEvent).snapshot.cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `transcript delta tags snake_case and round trips`() {
|
|
||||||
val row = TranscriptRow(
|
|
||||||
id = "tool:read-1",
|
|
||||||
kind = TranscriptRowKind.ToolCall,
|
|
||||||
data = json.parseToJsonElement("""{"name":"vendor.read"}"""),
|
|
||||||
)
|
|
||||||
val patch = TranscriptDelta.Patch(row)
|
|
||||||
val encoded = json.encodeToString<TranscriptDelta>(patch)
|
|
||||||
assertTrue(encoded.startsWith("""{"type":"patch","row":"""))
|
|
||||||
assertEquals(patch, json.decodeFromString<TranscriptDelta>(encoded))
|
|
||||||
assertEquals("\"agent_thought\"", json.encodeToString(TranscriptRowKind.AgentThought))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rename request encodes plainly`() {
|
|
||||||
assertEquals(
|
|
||||||
"""{"name":"amber-falcon"}""",
|
|
||||||
json.encodeToString(RenameSessionRequest("amber-falcon")),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `api error decodes nested unsaved work with defaults`() {
|
|
||||||
val apiError = json.decodeFromString<ApiError>(
|
|
||||||
"""{"error":{"code":"unsaved_work","message":"nope","unsaved_work":{"uncommitted_files":3}}}""",
|
|
||||||
)
|
|
||||||
assertEquals(3, apiError.error.unsaved_work?.uncommitted_files)
|
|
||||||
assertEquals(0, apiError.error.unsaved_work?.unpushed_commits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.sidebar
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.data.InTheWay
|
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SessionStatus
|
|
||||||
import pt.naps.maestro.data.SessionWorktree
|
|
||||||
|
|
||||||
private fun session(
|
|
||||||
command: String = "claude",
|
|
||||||
cwd: String = "/home/user/project",
|
|
||||||
metadata: Map<String, JsonPrimitive> = emptyMap(),
|
|
||||||
worktree: SessionWorktree? = null,
|
|
||||||
blockedWork: InTheWay? = null,
|
|
||||||
activitySince: String = "1970-01-01T00:00:00Z",
|
|
||||||
) = Session(
|
|
||||||
id = "s1",
|
|
||||||
cwd = cwd,
|
|
||||||
command = command,
|
|
||||||
metadata = metadata,
|
|
||||||
worktree = worktree,
|
|
||||||
status = SessionStatus.Running,
|
|
||||||
created_at = "2026-01-01T00:00:00Z",
|
|
||||||
blocked_work = blockedWork,
|
|
||||||
activity_since = activitySince,
|
|
||||||
)
|
|
||||||
|
|
||||||
class DurationFormattingTest {
|
|
||||||
@Test
|
|
||||||
fun `floors seconds under a minute`() {
|
|
||||||
assertEquals("30s", activityDurationLabel(30_000))
|
|
||||||
assertEquals("59s", activityDurationLabel(59_999))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `floors minutes under an hour`() {
|
|
||||||
assertEquals("4m", activityDurationLabel(4 * 60_000 + 59_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `floors hours with no days unit`() {
|
|
||||||
assertEquals("2h", activityDurationLabel(2 * 3_600_000 + 59 * 60_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `held ms is null for missing or implausible since`() {
|
|
||||||
assertNull(activityHeldMs(null, 1_000))
|
|
||||||
assertNull(activityHeldMs("", 1_000))
|
|
||||||
assertNull(activityHeldMs("1970-01-01T00:00:00Z", 1_000))
|
|
||||||
assertNull(activityHeldMs("not-a-timestamp", 1_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `held ms is the gap to now for a plausible timestamp`() {
|
|
||||||
val since = "2026-01-01T00:00:00Z"
|
|
||||||
val nowMs = java.time.Instant.parse("2026-01-01T00:00:31Z").toEpochMilli()
|
|
||||||
assertEquals(31_000L, activityHeldMs(since, nowMs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SessionNameTest {
|
|
||||||
@Test
|
|
||||||
fun `falls back to the command when no name is set`() {
|
|
||||||
assertEquals("claude --resume", sessionName(session(command = "claude --resume")))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `uses the custom name when set`() {
|
|
||||||
val s = session(metadata = mapOf("name" to JsonPrimitive("api-work")))
|
|
||||||
assertEquals("api-work", sessionName(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an empty stored name is treated as unset`() {
|
|
||||||
val s = session(metadata = mapOf("name" to JsonPrimitive("")))
|
|
||||||
assertNull(customSessionName(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `generated name is provisional, user and haiku are not`() {
|
|
||||||
val generated = session(
|
|
||||||
metadata = mapOf(
|
|
||||||
"name" to JsonPrimitive("brave-otter"),
|
|
||||||
"name_source" to JsonPrimitive("Generated"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val user = session(
|
|
||||||
metadata = mapOf(
|
|
||||||
"name" to JsonPrimitive("api-work"),
|
|
||||||
"name_source" to JsonPrimitive("User"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val unknownSource = session(
|
|
||||||
metadata = mapOf(
|
|
||||||
"name" to JsonPrimitive("api-work"),
|
|
||||||
"name_source" to JsonPrimitive("something-new"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertTrue(isProvisionalName(generated))
|
|
||||||
assertFalse(isProvisionalName(user))
|
|
||||||
assertFalse(isProvisionalName(unknownSource))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class BlockerStatsTest {
|
|
||||||
@Test
|
|
||||||
fun `renders insertions and deletions in diff notation`() {
|
|
||||||
val w = InTheWay(uncommitted_files = 2, untracked_files = 3, unpushed_commits = 2, insertions = 124, deletions = 18)
|
|
||||||
val stats = blockerStats(w).associate { it.kind to it.text }
|
|
||||||
assertEquals("+124", stats["add"])
|
|
||||||
assertEquals("-18", stats["del"])
|
|
||||||
assertEquals("?3", stats["untracked"])
|
|
||||||
assertEquals("↑2", stats["unpushed"])
|
|
||||||
assertNull(stats["files"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `falls back to a file count when a tracked change carries no line delta`() {
|
|
||||||
val w = InTheWay(uncommitted_files = 2, insertions = 0, deletions = 0)
|
|
||||||
val stats = blockerStats(w)
|
|
||||||
assertEquals(listOf(BlockerStat("files", "~2")), stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `session blockers are null when nothing blocks a kill`() {
|
|
||||||
assertNull(sessionBlockers(session(blockedWork = null)))
|
|
||||||
assertNull(sessionBlockers(session(blockedWork = InTheWay())))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `format in the way spells out zero as no unsaved work`() {
|
|
||||||
assertEquals("no unsaved work", formatInTheWay(InTheWay()))
|
|
||||||
assertEquals("1 uncommitted file, 2 untracked files", formatInTheWay(InTheWay(uncommitted_files = 1, untracked_files = 2)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProgramNameTest {
|
|
||||||
@Test
|
|
||||||
fun `drops arguments and basenames the path`() {
|
|
||||||
assertEquals("claude", programName("/usr/local/bin/claude -p hi"))
|
|
||||||
assertEquals("claude", programName("claude --dangerously-skip-permissions"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `looks through leading env assignments`() {
|
|
||||||
assertEquals("claude", programName("FOO=bar BAZ=qux claude --resume"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `looks through sudo and env wrappers with their own flags`() {
|
|
||||||
assertEquals("claude", programName("sudo claude"))
|
|
||||||
assertEquals("claude", programName("env -i FOO=bar claude"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `looks through js-toolchain launchers`() {
|
|
||||||
assertEquals("claude-code", programName("npx claude-code"))
|
|
||||||
assertEquals("claude", programName("pnpm dlx claude"))
|
|
||||||
assertEquals("claude", programName("bunx claude"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a command that is nothing but wrappers is shown as written`() {
|
|
||||||
assertEquals("sudo", programName("sudo"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class GroupingTest {
|
|
||||||
@Test
|
|
||||||
fun `groups by worktree base_repo when present`() {
|
|
||||||
val a = session(cwd = "/wt/a", worktree = SessionWorktree(path = "/wt/a", base_repo = "/home/user/maestro"))
|
|
||||||
val b = session(cwd = "/wt/b", worktree = SessionWorktree(path = "/wt/b", base_repo = "/home/user/maestro"))
|
|
||||||
val groups = groupSessions(listOf(a, b))
|
|
||||||
assertEquals(1, groups.size)
|
|
||||||
assertEquals("maestro", groups.single().label)
|
|
||||||
assertEquals(2, groups.single().sessions.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `falls back to cwd with no worktree`() {
|
|
||||||
val a = session(cwd = "/home/user/scratch")
|
|
||||||
val groups = groupSessions(listOf(a))
|
|
||||||
assertEquals("scratch", groups.single().label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.spawn
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.data.SessionView
|
|
||||||
|
|
||||||
private val base = SpawnFormValues(
|
|
||||||
command = "claude",
|
|
||||||
cwd = "/home/user/project",
|
|
||||||
name = "amber-fox",
|
|
||||||
structuredView = false,
|
|
||||||
autoApprove = false,
|
|
||||||
worktreeEnabled = false,
|
|
||||||
worktreeBranch = "",
|
|
||||||
worktreeBranchMode = WorktreeBranchMode.New,
|
|
||||||
)
|
|
||||||
|
|
||||||
class SpawnModelTest {
|
|
||||||
@Test
|
|
||||||
fun `builds a plain request with the picked cwd and command`() {
|
|
||||||
val result = buildSpawnRequest(base, null) as SpawnBuildResult.Ok
|
|
||||||
assertEquals("/home/user/project", result.request.cwd)
|
|
||||||
assertEquals("claude", result.request.command)
|
|
||||||
assertEquals(mapOf("name" to JsonPrimitive("amber-fox")), result.request.metadata)
|
|
||||||
assertEquals(SessionView.Terminal, result.request.view)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `omits metadata name when the name is blank`() {
|
|
||||||
val result = buildSpawnRequest(base.copy(name = " "), null) as SpawnBuildResult.Ok
|
|
||||||
assertTrue(result.request.metadata.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `requests structured view for resolved claude and codex commands`() {
|
|
||||||
for (command in listOf("claude", "FOO=1 codex", "env -i codex")) {
|
|
||||||
val result = buildSpawnRequest(base.copy(command = command, structuredView = true), null) as SpawnBuildResult.Ok
|
|
||||||
assertEquals("expected structured view for `$command`", SessionView.Structured, result.request.view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `does not request structured view for unsupported commands`() {
|
|
||||||
val result = buildSpawnRequest(base.copy(command = "zsh", structuredView = true), null) as SpawnBuildResult.Ok
|
|
||||||
assertEquals(SessionView.Terminal, result.request.view)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `attaches a trimmed branch when worktree is enabled`() {
|
|
||||||
val result = buildSpawnRequest(
|
|
||||||
base.copy(worktreeEnabled = true, worktreeBranch = " feat/foo "),
|
|
||||||
"/home/user/project",
|
|
||||||
) as SpawnBuildResult.Ok
|
|
||||||
assertEquals("/home/user/project", result.request.worktree?.base_repo)
|
|
||||||
assertEquals("feat/foo", result.request.worktree?.branch)
|
|
||||||
assertTrue(result.request.worktree?.create_branch == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `checks out an existing branch when the mode is existing`() {
|
|
||||||
val result = buildSpawnRequest(
|
|
||||||
base.copy(worktreeEnabled = true, worktreeBranch = "spawn-v2", worktreeBranchMode = WorktreeBranchMode.Existing),
|
|
||||||
"/home/user/project",
|
|
||||||
) as SpawnBuildResult.Ok
|
|
||||||
assertEquals(false, result.request.worktree?.create_branch)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rejects an existing-mode worktree with no branch picked`() {
|
|
||||||
val result = buildSpawnRequest(
|
|
||||||
base.copy(worktreeEnabled = true, worktreeBranch = "", worktreeBranchMode = WorktreeBranchMode.Existing),
|
|
||||||
"/repo",
|
|
||||||
)
|
|
||||||
assertTrue(result is SpawnBuildResult.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rejects a missing working directory`() {
|
|
||||||
assertTrue(buildSpawnRequest(base.copy(cwd = ""), null) is SpawnBuildResult.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rejects a missing command`() {
|
|
||||||
assertTrue(buildSpawnRequest(base.copy(command = ""), null) is SpawnBuildResult.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rejects a worktree without an inferred base repo`() {
|
|
||||||
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = "b"), null)
|
|
||||||
assertTrue(result is SpawnBuildResult.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rejects a worktree without a branch name`() {
|
|
||||||
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = " "), "/repo")
|
|
||||||
assertTrue(result is SpawnBuildResult.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `resolveProgram strips env assignments and paths`() {
|
|
||||||
assertEquals("codex", resolveProgram("FOO=1 /usr/local/bin/codex --flag"))
|
|
||||||
assertEquals("claude", resolveProgram("claude"))
|
|
||||||
assertEquals("zsh", resolveProgram("zsh -l"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.spawn
|
|
||||||
|
|
||||||
import app.cash.turbine.test
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import okhttp3.mockwebserver.Dispatcher
|
|
||||||
import okhttp3.mockwebserver.MockResponse
|
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
|
||||||
import okhttp3.mockwebserver.RecordedRequest
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.api.MaestroClient
|
|
||||||
import pt.naps.maestro.data.Project
|
|
||||||
|
|
||||||
/** Routes each enqueued response by request path rather than arrival order —
|
|
||||||
* [SpawnViewModel]'s init fires `/api/info` and `/api/projects` concurrently,
|
|
||||||
* so a plain FIFO queue can hand either endpoint the other's response. */
|
|
||||||
private class RoutedDispatcher : Dispatcher() {
|
|
||||||
private val queues = ConcurrentHashMap<String, ConcurrentLinkedQueue<MockResponse>>()
|
|
||||||
|
|
||||||
fun enqueue(path: String, response: MockResponse) {
|
|
||||||
queues.getOrPut(path) { ConcurrentLinkedQueue() }.add(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
|
||||||
val path = request.path.orEmpty().substringBefore('?')
|
|
||||||
return queues[path]?.poll() ?: MockResponse().setResponseCode(404)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SpawnViewModelTest {
|
|
||||||
private lateinit var server: MockWebServer
|
|
||||||
private lateinit var dispatcher: RoutedDispatcher
|
|
||||||
private lateinit var client: MaestroClient
|
|
||||||
private lateinit var scope: CoroutineScope
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
dispatcher = RoutedDispatcher()
|
|
||||||
server = MockWebServer()
|
|
||||||
server.dispatcher = dispatcher
|
|
||||||
server.start()
|
|
||||||
client = MaestroClient(server.url("/").toString())
|
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
scope.cancel()
|
|
||||||
server.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun jsonResponse(body: String) =
|
|
||||||
MockResponse().setHeader("Content-Type", "application/json").setBody(body)
|
|
||||||
|
|
||||||
/** Enqueues a passing `/api/info` and an empty `/api/projects`, the pair
|
|
||||||
* every test needs before it can observe anything past init. */
|
|
||||||
private fun enqueueInit(infoBody: String = """{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}""") {
|
|
||||||
dispatcher.enqueue("/api/info", jsonResponse(infoBody))
|
|
||||||
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun newViewModel() = SpawnViewModel(client, scope)
|
|
||||||
|
|
||||||
private suspend fun SpawnViewModel.awaitState(predicate: (SpawnUiState) -> Boolean): SpawnUiState {
|
|
||||||
var result: SpawnUiState? = null
|
|
||||||
state.test {
|
|
||||||
var item = awaitItem()
|
|
||||||
while (!predicate(item)) item = awaitItem()
|
|
||||||
result = item
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
return result ?: error("state predicate never satisfied")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `loads presets from daemon info and defaults to the first one`() = runBlocking {
|
|
||||||
enqueueInit(
|
|
||||||
"""{"hostname":"h","version":"1","home_dir":"/home/x","presets":[
|
|
||||||
{"id":"claude","label":"Claude","command":"claude"},
|
|
||||||
{"id":"custom","label":"Custom…","command":""}
|
|
||||||
],"instance":"i"}""",
|
|
||||||
)
|
|
||||||
|
|
||||||
val vm = newViewModel()
|
|
||||||
val settled = vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
|
|
||||||
assertEquals("claude", settled.command)
|
|
||||||
assertTrue("structured view should auto-tick for claude", settled.structuredView)
|
|
||||||
assertEquals("/home/x", settled.homeDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `falls back to built-in presets when daemon info fails`() = runBlocking {
|
|
||||||
dispatcher.enqueue("/api/info", MockResponse().setResponseCode(500))
|
|
||||||
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
|
||||||
|
|
||||||
val vm = newViewModel()
|
|
||||||
val settled = vm.awaitState { it.selectedPresetId.isNotEmpty() }
|
|
||||||
|
|
||||||
assertEquals(FALLBACK_PRESETS, settled.presets)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `selecting a preset updates the command and structured view`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { it.selectedPresetId.isNotEmpty() } // let init settle on "claude"
|
|
||||||
|
|
||||||
vm.selectPreset("zsh")
|
|
||||||
val afterZsh = vm.awaitState { it.selectedPresetId == "zsh" }
|
|
||||||
assertEquals("zsh", afterZsh.command)
|
|
||||||
assertTrue("zsh has no structured view", !afterZsh.structuredView)
|
|
||||||
|
|
||||||
vm.selectPreset("claude")
|
|
||||||
val afterClaude = vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
assertTrue(afterClaude.structuredView)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `manual structured view toggle survives a later preset change`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
|
|
||||||
vm.setStructuredView(false)
|
|
||||||
vm.selectPreset("codex")
|
|
||||||
val after = vm.awaitState { it.selectedPresetId == "codex" }
|
|
||||||
assertTrue("operator's untick should stick", !after.structuredView)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `selecting a project fills cwd and base repo`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { !it.projectsLoading }
|
|
||||||
|
|
||||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
|
||||||
val after = vm.awaitState { it.cwd == "/repo" }
|
|
||||||
assertEquals("/repo", after.baseRepo)
|
|
||||||
assertEquals("/repo", after.selectedProjectPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `typing a raw path debounces dir suggestions and base repo`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { !it.projectsLoading }
|
|
||||||
|
|
||||||
dispatcher.enqueue("/api/dirs", jsonResponse("""{"path":"/home/user","entries":["proj-a","proj-b"]}"""))
|
|
||||||
dispatcher.enqueue("/api/gitroot", jsonResponse("""{"root":"/home/user/proj-a"}"""))
|
|
||||||
|
|
||||||
vm.setCwd("/home/user/pro")
|
|
||||||
val settled = vm.awaitState { it.dirSuggestions.isNotEmpty() }
|
|
||||||
assertEquals(listOf("proj-a", "proj-b"), settled.dirSuggestions)
|
|
||||||
val withRepo = vm.awaitState { it.baseRepo != null }
|
|
||||||
assertEquals("/home/user/proj-a", withRepo.baseRepo)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `reroll fills the name field from the daemon`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { !it.projectsLoading }
|
|
||||||
|
|
||||||
dispatcher.enqueue("/api/names", jsonResponse("""{"names":["amber-fox"]}"""))
|
|
||||||
vm.rerollName()
|
|
||||||
val after = vm.awaitState { it.name == "amber-fox" }
|
|
||||||
assertEquals("amber-fox", after.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `submit rejects a missing working directory without a network call`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
|
|
||||||
var succeeded = false
|
|
||||||
vm.submit { succeeded = true }
|
|
||||||
val after = vm.awaitState { it.error != null }
|
|
||||||
assertNotNull(after.error)
|
|
||||||
assertTrue(!succeeded)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `submit posts the built request and reports the new session`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
|
||||||
vm.awaitState { it.cwd == "/repo" }
|
|
||||||
|
|
||||||
dispatcher.enqueue(
|
|
||||||
"/api/sessions",
|
|
||||||
jsonResponse(
|
|
||||||
"""{"id":"s1","cwd":"/repo","command":"claude","status":"Running","created_at":"2026-01-01T00:00:00Z"}""",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
var result: pt.naps.maestro.data.Session? = null
|
|
||||||
vm.submit { result = it }
|
|
||||||
val after = vm.awaitState { !it.submitting && it.error == null }
|
|
||||||
assertNull(after.error)
|
|
||||||
assertEquals("s1", result?.id)
|
|
||||||
|
|
||||||
var request = server.takeRequest()
|
|
||||||
while (request.method != "POST") request = server.takeRequest()
|
|
||||||
assertEquals("/api/sessions", request.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `submit surfaces the daemon's error detail on failure`() = runBlocking {
|
|
||||||
enqueueInit()
|
|
||||||
val vm = newViewModel()
|
|
||||||
vm.awaitState { it.selectedPresetId == "claude" }
|
|
||||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
|
||||||
vm.awaitState { it.cwd == "/repo" }
|
|
||||||
|
|
||||||
dispatcher.enqueue(
|
|
||||||
"/api/sessions",
|
|
||||||
MockResponse().setResponseCode(400).setHeader("Content-Type", "application/json")
|
|
||||||
.setBody("""{"error":{"code":"bad_request","message":"cwd does not exist"}}"""),
|
|
||||||
)
|
|
||||||
vm.submit {}
|
|
||||||
val after = vm.awaitState { it.error != null }
|
|
||||||
assertTrue(after.error!!.contains("cwd does not exist"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.data.ReducedState
|
|
||||||
import pt.naps.maestro.data.TranscriptRow
|
|
||||||
import pt.naps.maestro.data.TranscriptRowKind
|
|
||||||
|
|
||||||
private fun json(text: String) = Json.parseToJsonElement(text)
|
|
||||||
|
|
||||||
class AcpPayloadTest {
|
|
||||||
@Test
|
|
||||||
fun `contentChunkText reads a text content block`() {
|
|
||||||
assertEquals("hello", contentChunkText(json("""{"content":{"type":"text","text":"hello"}}""")))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `contentBlockText placeholders non-text blocks instead of dropping them`() {
|
|
||||||
assertEquals("[image]", contentChunkText(json("""{"content":{"type":"image"}}""")))
|
|
||||||
assertEquals("[audio]", contentChunkText(json("""{"content":{"type":"audio"}}""")))
|
|
||||||
assertEquals(
|
|
||||||
"[resource: notes.txt]",
|
|
||||||
contentChunkText(json("""{"content":{"type":"resource_link","name":"notes.txt"}}""")),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `parseToolCall reads the fields ToolRow needs`() {
|
|
||||||
val tool = parseToolCall(
|
|
||||||
json(
|
|
||||||
"""{"title":"Bash(ls)","kind":"execute","status":"in_progress",
|
|
||||||
"content":[{"type":"diff","path":"a.txt","oldText":"x","newText":"y"}],
|
|
||||||
"locations":[{"path":"a.txt"}]}""",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals("Bash(ls)", tool.title)
|
|
||||||
assertEquals("execute", tool.kind)
|
|
||||||
assertEquals("in_progress", tool.status)
|
|
||||||
assertEquals(listOf("a.txt"), tool.locations)
|
|
||||||
assertTrue(tool.content.single() is AcpToolCallContent.Diff)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `parseToolCall degrades gracefully on a malformed shape`() {
|
|
||||||
val tool = parseToolCall(json("""["not", "an", "object"]"""))
|
|
||||||
assertNull(tool.title)
|
|
||||||
assertEquals(emptyList<AcpToolCallContent>(), tool.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `parseApprovalRequest reads the tool call and permission options`() {
|
|
||||||
val request = parseApprovalRequest(
|
|
||||||
json(
|
|
||||||
"""{"toolCall":{"title":"Write file"},
|
|
||||||
"options":[{"optionId":"1","name":"Allow","kind":"allow_once"}]}""",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals("Write file", request.toolCall.title)
|
|
||||||
assertEquals(1, request.options.size)
|
|
||||||
assertEquals("allow_once", request.options.single().kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `parseTurnMeta reads the daemon footer and ignores rows without one`() {
|
|
||||||
val footer = TranscriptRow(
|
|
||||||
id = "notice:1",
|
|
||||||
kind = TranscriptRowKind.Notice,
|
|
||||||
data = json("""{"maestroTurnMeta":{"elapsedMs":4200,"outcome":"cancelled","model":"claude"}}"""),
|
|
||||||
)
|
|
||||||
val meta = parseTurnMeta(footer)
|
|
||||||
assertEquals(4200L, meta?.elapsedMs)
|
|
||||||
assertEquals("cancelled", meta?.outcome)
|
|
||||||
assertEquals("claude", meta?.model)
|
|
||||||
|
|
||||||
val plain = TranscriptRow(id = "notice:2", kind = TranscriptRowKind.Notice, data = json("""{"sessionUpdate":"x"}"""))
|
|
||||||
assertNull(parseTurnMeta(plain))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `isStateOnlyNotice recognises the two consumed-as-state variants`() {
|
|
||||||
fun notice(update: String) =
|
|
||||||
TranscriptRow(id = "n", kind = TranscriptRowKind.Notice, data = json("""{"sessionUpdate":"$update"}"""))
|
|
||||||
assertTrue(isStateOnlyNotice(notice("available_commands_update")))
|
|
||||||
assertTrue(isStateOnlyNotice(notice("session_info_update")))
|
|
||||||
assertTrue(!isStateOnlyNotice(notice("agent_message_chunk")))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `pendingApprovalNonce reads the nonce only while one is present`() {
|
|
||||||
assertEquals("abc", pendingApprovalNonce(ReducedState(pending_approval = json("""{"nonce":"abc"}"""))))
|
|
||||||
assertNull(pendingApprovalNonce(ReducedState()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class DiffModelTest {
|
|
||||||
@Test
|
|
||||||
fun `new file adds every line`() {
|
|
||||||
val lines = DiffModel.computeDiffLines(null, "a\nb\n")
|
|
||||||
assertEquals(listOf(DiffLine.Kind.Add, DiffLine.Kind.Add), lines.map { it.kind })
|
|
||||||
assertEquals(listOf("a", "b"), lines.map { it.text })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `identical text is all context, no hunk collapsing for a short file`() {
|
|
||||||
val lines = DiffModel.computeDiffLines("a\nb\nc\n", "a\nb\nc\n")
|
|
||||||
assertEquals(listOf(DiffLine.Kind.Ctx, DiffLine.Kind.Ctx, DiffLine.Kind.Ctx), lines.map { it.kind })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a changed middle line reports one del and one add`() {
|
|
||||||
val lines = DiffModel.computeDiffLines("a\nb\nc\n", "a\nx\nc\n")
|
|
||||||
val stats = DiffModel.diffStats(lines)
|
|
||||||
assertEquals(1, stats.added)
|
|
||||||
assertEquals(1, stats.removed)
|
|
||||||
assertEquals(listOf(DiffLine.Kind.Ctx, DiffLine.Kind.Del, DiffLine.Kind.Add, DiffLine.Kind.Ctx), lines.map { it.kind })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a long unchanged run collapses to a hunk between real context`() {
|
|
||||||
val old = (1..20).joinToString("\n") { "line$it" } + "\n"
|
|
||||||
val new = old.replaceFirst("line1\n", "line1\nINSERTED\n")
|
|
||||||
val lines = DiffModel.computeDiffLines(old, new)
|
|
||||||
assertEquals(1, lines.count { it.kind == DiffLine.Kind.Hunk })
|
|
||||||
assertEquals(1, DiffModel.diffStats(lines).added)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a missing trailing newline is marked`() {
|
|
||||||
val lines = DiffModel.computeDiffLines(null, "a")
|
|
||||||
assertEquals(listOf(DiffLine.Kind.Add, DiffLine.Kind.Eof), lines.map { it.kind })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `empty old and new text produce no lines`() {
|
|
||||||
assertEquals(emptyList<DiffLine>(), DiffModel.computeDiffLines("", ""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class MarkdownTextTest {
|
|
||||||
@Test
|
|
||||||
fun `plain text passes through unchanged`() {
|
|
||||||
val result = renderSimpleMarkdown("just some text")
|
|
||||||
assertEquals("just some text", result.text)
|
|
||||||
assertTrue(result.spanStyles.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `bold and italic markers are stripped from the text and styled`() {
|
|
||||||
val result = renderSimpleMarkdown("**bold** and *italic*")
|
|
||||||
assertEquals("bold and italic", result.text)
|
|
||||||
assertTrue(result.spanStyles.any { it.item == SpanStyle(fontWeight = FontWeight.Bold) })
|
|
||||||
assertTrue(result.spanStyles.any { it.item == SpanStyle(fontStyle = FontStyle.Italic) })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `inline code keeps its text without the backticks`() {
|
|
||||||
val result = renderSimpleMarkdown("run `ls -la` now")
|
|
||||||
assertEquals("run ls -la now", result.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a header line is bold and a bullet gets a leading dot`() {
|
|
||||||
val header = renderSimpleMarkdown("# Title")
|
|
||||||
assertEquals("Title", header.text)
|
|
||||||
|
|
||||||
val bullet = renderSimpleMarkdown("- one\n- two")
|
|
||||||
assertEquals("• one\n• two", bullet.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a fenced code block keeps its content without the fence markers`() {
|
|
||||||
val result = renderSimpleMarkdown("```\nlet x = 1;\n```")
|
|
||||||
assertEquals("\nlet x = 1;\n", result.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.data.ReducedState
|
|
||||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
|
||||||
import pt.naps.maestro.data.TranscriptRow
|
|
||||||
import pt.naps.maestro.data.TranscriptRowKind
|
|
||||||
import pt.naps.maestro.data.TurnState
|
|
||||||
|
|
||||||
private fun row(id: String, kind: TranscriptRowKind, json: String = "{}") =
|
|
||||||
TranscriptRow(id = id, kind = kind, data = Json.parseToJsonElement(json))
|
|
||||||
|
|
||||||
class StructuredModelTest {
|
|
||||||
// -- splitToolTitle / toolKindLabel --------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `splitToolTitle reads the Name(argument) convention`() {
|
|
||||||
val title = StructuredModel.splitToolTitle("Bash(git status)", null, "execute")
|
|
||||||
assertEquals("Bash", title.label)
|
|
||||||
assertEquals("git status", title.arg)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `splitToolTitle prefers an explicit name over the parsed one`() {
|
|
||||||
val title = StructuredModel.splitToolTitle("Bash(git status)", "Shell", "execute")
|
|
||||||
assertEquals("Shell", title.label)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `splitToolTitle without the convention keeps the whole title as the arg`() {
|
|
||||||
val title = StructuredModel.splitToolTitle("Reading src/main.rs", null, "read")
|
|
||||||
assertEquals("Read", title.label)
|
|
||||||
assertEquals("Reading src/main.rs", title.arg)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `splitToolTitle with no title at all has an empty arg`() {
|
|
||||||
val title = StructuredModel.splitToolTitle(null, null, null)
|
|
||||||
assertEquals("Tool", title.label)
|
|
||||||
assertEquals("", title.arg)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `toolKindLabel falls back to Tool for an unrecognised kind`() {
|
|
||||||
assertEquals("Tool", StructuredModel.toolKindLabel("something_new"))
|
|
||||||
assertEquals("Execute", StructuredModel.toolKindLabel("execute"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- statusMeta -----------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `statusMeta reports running and failed regardless of diff`() {
|
|
||||||
assertEquals("running", StructuredModel.statusMeta("in_progress", hasDiff = true)?.text)
|
|
||||||
assertEquals("failed", StructuredModel.statusMeta("failed", hasDiff = false)?.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `statusMeta hides pending once a diff is present`() {
|
|
||||||
assertEquals("pending", StructuredModel.statusMeta("pending", hasDiff = false)?.text)
|
|
||||||
assertNull(StructuredModel.statusMeta("pending", hasDiff = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `statusMeta is null once a tool has completed`() {
|
|
||||||
assertNull(StructuredModel.statusMeta("completed", hasDiff = false))
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- turn / approval state -------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `turnIsActive is true while responding or thinking`() {
|
|
||||||
assertTrue(StructuredModel.turnIsActive(ReducedState(turn = TurnState(responding = true))))
|
|
||||||
assertTrue(StructuredModel.turnIsActive(ReducedState(turn = TurnState(thinking = true))))
|
|
||||||
assertTrue(!StructuredModel.turnIsActive(ReducedState(turn = TurnState())))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `pendingApprovalRowId is the newest approval row only while one is pending`() {
|
|
||||||
val rows = listOf(
|
|
||||||
row("approval:1", TranscriptRowKind.Approval),
|
|
||||||
row("tool:1", TranscriptRowKind.ToolCall),
|
|
||||||
row("approval:2", TranscriptRowKind.Approval),
|
|
||||||
)
|
|
||||||
val pending = ReducedState(pending_approval = Json.parseToJsonElement("""{"nonce":"n"}"""))
|
|
||||||
assertEquals("approval:2", StructuredModel.pendingApprovalRowId(rows, pending))
|
|
||||||
assertNull(StructuredModel.pendingApprovalRowId(rows, ReducedState()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `offeredDecisions maps ACP option kinds to the three daemon decisions`() {
|
|
||||||
val decisions = StructuredModel.offeredDecisions(
|
|
||||||
listOf(
|
|
||||||
AcpPermissionOption("1", "Allow", "allow_once"),
|
|
||||||
AcpPermissionOption("2", "Reject", "reject_always"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(listOf(StructuredApprovalDecision.AllowOnce, StructuredApprovalDecision.Deny), decisions)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `offeredDecisions is empty when nothing recognisable is offered`() {
|
|
||||||
assertEquals(emptyList<StructuredApprovalDecision>(), StructuredModel.offeredDecisions(emptyList()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- grouping / transcriptItems ---------------------------------------
|
|
||||||
|
|
||||||
private fun toolRow(id: String, title: String) = row(id, TranscriptRowKind.ToolCall, """{"title":"$title"}""")
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `two or more consecutive tool rows fold into a group`() {
|
|
||||||
val rows = listOf(
|
|
||||||
row("user:1", TranscriptRowKind.User),
|
|
||||||
toolRow("tool:1", "Bash(ls)"),
|
|
||||||
toolRow("tool:2", "Bash(pwd)"),
|
|
||||||
)
|
|
||||||
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
|
|
||||||
assertEquals(2, items.size) // user row + one group
|
|
||||||
val group = items[1] as StructuredModel.TranscriptItem.GroupItem
|
|
||||||
assertEquals(2, group.group.toolCount)
|
|
||||||
assertEquals("Bash", group.group.lastToolLabel)
|
|
||||||
assertEquals("pwd", group.group.lastToolArg)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a single tool row stays standalone`() {
|
|
||||||
val rows = listOf(toolRow("tool:1", "Bash(ls)"))
|
|
||||||
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
|
|
||||||
assertEquals(1, items.size)
|
|
||||||
assertTrue(items[0] is StructuredModel.TranscriptItem.RowItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `agent text between tool calls splits the run into separate groups`() {
|
|
||||||
val rows = listOf(
|
|
||||||
toolRow("tool:1", "Bash(a)"),
|
|
||||||
toolRow("tool:2", "Bash(b)"),
|
|
||||||
row("agent:1", TranscriptRowKind.AgentText, """{"content":{"type":"text","text":"hi"}}"""),
|
|
||||||
toolRow("tool:3", "Bash(c)"),
|
|
||||||
toolRow("tool:4", "Bash(d)"),
|
|
||||||
)
|
|
||||||
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
|
|
||||||
val groups = items.filterIsInstance<StructuredModel.TranscriptItem.GroupItem>()
|
|
||||||
assertEquals(2, groups.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `plan rows and state-only notices never render`() {
|
|
||||||
val rows = listOf(
|
|
||||||
row("plan", TranscriptRowKind.Plan, """{"entries":[]}"""),
|
|
||||||
row("notice:1", TranscriptRowKind.Notice, """{"sessionUpdate":"session_info_update"}"""),
|
|
||||||
)
|
|
||||||
assertEquals(emptyList<StructuredModel.TranscriptItem>(), StructuredModel.transcriptItems(rows, null))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a resolved approval outside a group is dropped, a pending one always renders`() {
|
|
||||||
val rows = listOf(row("approval:1", TranscriptRowKind.Approval))
|
|
||||||
assertEquals(emptyList<StructuredModel.TranscriptItem>(), StructuredModel.transcriptItems(rows, pendingApprovalId = null))
|
|
||||||
val pendingItems = StructuredModel.transcriptItems(rows, pendingApprovalId = "approval:1")
|
|
||||||
assertEquals(1, pendingItems.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `only the final row may be a turn footer, earlier ones are dropped`() {
|
|
||||||
fun footer(id: String) = row(
|
|
||||||
id,
|
|
||||||
TranscriptRowKind.Notice,
|
|
||||||
"""{"maestroTurnMeta":{"elapsedMs":1000,"outcome":"completed"}}""",
|
|
||||||
)
|
|
||||||
val rows = listOf(footer("turn_meta:1"), toolRow("tool:1", "Bash(ls)"), footer("turn_meta:2"))
|
|
||||||
val items = StructuredModel.transcriptItems(rows, pendingApprovalId = null)
|
|
||||||
assertEquals(2, items.size)
|
|
||||||
assertEquals("turn_meta:2", (items.last() as StructuredModel.TranscriptItem.RowItem).row.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- formatting ---------------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `formatElapsed rounds down to whole seconds and switches to minutes at 60s`() {
|
|
||||||
assertEquals("0s", StructuredModel.formatElapsed(0))
|
|
||||||
assertEquals("59s", StructuredModel.formatElapsed(59_000))
|
|
||||||
assertEquals("1m", StructuredModel.formatElapsed(60_000))
|
|
||||||
assertEquals("1m 1s", StructuredModel.formatElapsed(61_000))
|
|
||||||
assertEquals("2m", StructuredModel.formatElapsed(120_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `formatContextTokens compacts large values`() {
|
|
||||||
assertEquals("999 context", StructuredModel.formatContextTokens(999))
|
|
||||||
assertEquals("1k context", StructuredModel.formatContextTokens(1_000))
|
|
||||||
assertEquals("1.5k context", StructuredModel.formatContextTokens(1_500))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.structured
|
|
||||||
|
|
||||||
import app.cash.turbine.test
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.WebSocket
|
|
||||||
import okhttp3.WebSocketListener
|
|
||||||
import okhttp3.mockwebserver.MockResponse
|
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import pt.naps.maestro.api.MaestroClient
|
|
||||||
import pt.naps.maestro.api.MaestroWebSocket
|
|
||||||
import pt.naps.maestro.data.StructuredApprovalDecision
|
|
||||||
|
|
||||||
/** Exercises [StructuredViewModel] end to end against a real
|
|
||||||
* `MaestroWebSocket`/`MaestroClient` pair, using `MockWebServer`'s socket
|
|
||||||
* upgrade to stand in for the daemon (docs/STRUCTURED-VIEW.md). */
|
|
||||||
class StructuredViewModelTest {
|
|
||||||
private lateinit var server: MockWebServer
|
|
||||||
private lateinit var client: MaestroClient
|
|
||||||
private lateinit var webSocket: MaestroWebSocket
|
|
||||||
private lateinit var scope: CoroutineScope
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
server = MockWebServer()
|
|
||||||
server.start()
|
|
||||||
val baseUrl = server.url("/").toString()
|
|
||||||
client = MaestroClient(baseUrl)
|
|
||||||
webSocket = MaestroWebSocket(baseUrl)
|
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
scope.cancel()
|
|
||||||
server.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enqueues one server-side socket upgrade; the returned handle
|
|
||||||
* resolves once the client's connect has completed the handshake. */
|
|
||||||
private fun enqueueSocket(): CompletableDeferred<WebSocket> {
|
|
||||||
val opened = CompletableDeferred<WebSocket>()
|
|
||||||
server.enqueue(
|
|
||||||
MockResponse().withWebSocketUpgrade(
|
|
||||||
object : WebSocketListener() {
|
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
||||||
opened.complete(webSocket)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return opened
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun StructuredViewModel.awaitState(predicate: (StructuredUiState) -> Boolean): StructuredUiState {
|
|
||||||
var result: StructuredUiState? = null
|
|
||||||
state.test(timeout = 5.seconds) {
|
|
||||||
var item = awaitItem()
|
|
||||||
while (!predicate(item)) item = awaitItem()
|
|
||||||
result = item
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
return result ?: error("state predicate never satisfied")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `applies a transcript snapshot from the socket`() = runBlocking {
|
|
||||||
val opened = enqueueSocket()
|
|
||||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
|
||||||
val serverSocket = opened.await()
|
|
||||||
|
|
||||||
serverSocket.send(
|
|
||||||
"""{"type":"transcript_snapshot","snapshot":{"cursor":1,
|
|
||||||
"rows":[{"id":"user:1","kind":"user","data":{"content":{"type":"text","text":"hi"}}}]}}""",
|
|
||||||
)
|
|
||||||
|
|
||||||
val after = vm.awaitState { it.rows.isNotEmpty() }
|
|
||||||
assertEquals(1, after.rows.size)
|
|
||||||
assertEquals("user:1", after.rows.first().id)
|
|
||||||
assertEquals(StructuredConnectionStatus.Open, after.connection)
|
|
||||||
vm.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `appends and patches rows via projection updates, driven by reduced state`() = runBlocking {
|
|
||||||
val opened = enqueueSocket()
|
|
||||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
|
||||||
val serverSocket = opened.await()
|
|
||||||
|
|
||||||
serverSocket.send("""{"type":"transcript_snapshot","snapshot":{"cursor":1,"rows":[]}}""")
|
|
||||||
vm.awaitState { it.connection == StructuredConnectionStatus.Open }
|
|
||||||
|
|
||||||
serverSocket.send(
|
|
||||||
"""{"type":"projection_update","transcript":{"type":"append","row":
|
|
||||||
{"id":"agent:1","kind":"agent_text","data":{"content":{"type":"text","text":"hi"}}}},
|
|
||||||
"state":{"turn":{"responding":true,"thinking":false,"active_tool_calls":[]}}}""",
|
|
||||||
)
|
|
||||||
val afterAppend = vm.awaitState { it.rows.size == 1 }
|
|
||||||
assertTrue(afterAppend.turnActive)
|
|
||||||
|
|
||||||
serverSocket.send(
|
|
||||||
"""{"type":"projection_update","transcript":{"type":"patch","row":
|
|
||||||
{"id":"agent:1","kind":"agent_text","data":{"content":{"type":"text","text":"hi there"}}}},
|
|
||||||
"state":{"turn":{"responding":false,"thinking":false,"active_tool_calls":[]}}}""",
|
|
||||||
)
|
|
||||||
val afterPatch = vm.awaitState { !it.turnActive }
|
|
||||||
assertEquals(1, afterPatch.rows.size)
|
|
||||||
assertEquals("hi there", contentChunkText(afterPatch.rows.single().data))
|
|
||||||
vm.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a remove delta drops the row`() = runBlocking {
|
|
||||||
val opened = enqueueSocket()
|
|
||||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
|
||||||
val serverSocket = opened.await()
|
|
||||||
|
|
||||||
serverSocket.send(
|
|
||||||
"""{"type":"transcript_snapshot","snapshot":{"cursor":1,
|
|
||||||
"rows":[{"id":"notice:1","kind":"notice","data":{}}]}}""",
|
|
||||||
)
|
|
||||||
vm.awaitState { it.rows.size == 1 }
|
|
||||||
|
|
||||||
serverSocket.send(
|
|
||||||
"""{"type":"projection_update","transcript":{"type":"remove","id":"notice:1"},
|
|
||||||
"state":{"turn":{"responding":false,"thinking":false,"active_tool_calls":[]}}}""",
|
|
||||||
)
|
|
||||||
val after = vm.awaitState { it.rows.isEmpty() }
|
|
||||||
assertEquals(emptyList<Any>(), after.rows)
|
|
||||||
vm.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `sendPrompt posts to the daemon and clears the draft`() = runBlocking {
|
|
||||||
val opened = enqueueSocket()
|
|
||||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
|
||||||
opened.await()
|
|
||||||
|
|
||||||
server.enqueue(MockResponse().setResponseCode(204))
|
|
||||||
vm.setDraft("hello")
|
|
||||||
vm.sendPrompt()
|
|
||||||
|
|
||||||
val after = vm.awaitState { !it.posting }
|
|
||||||
assertEquals("", after.draft)
|
|
||||||
|
|
||||||
var request = server.takeRequest()
|
|
||||||
while (request.method != "POST") request = server.takeRequest()
|
|
||||||
assertEquals("/api/sessions/s1/structured/prompt", request.path)
|
|
||||||
vm.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a failed prompt restores the draft and surfaces an error`() = runBlocking {
|
|
||||||
val opened = enqueueSocket()
|
|
||||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
|
||||||
opened.await()
|
|
||||||
|
|
||||||
server.enqueue(MockResponse().setResponseCode(500))
|
|
||||||
vm.setDraft("hello")
|
|
||||||
vm.sendPrompt()
|
|
||||||
|
|
||||||
val after = vm.awaitState { it.error != null }
|
|
||||||
assertEquals("hello", after.draft)
|
|
||||||
vm.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `cancelTurn posts to the cancel endpoint`() = runBlocking {
|
|
||||||
val opened = enqueueSocket()
|
|
||||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
|
||||||
opened.await()
|
|
||||||
|
|
||||||
server.enqueue(MockResponse().setResponseCode(204))
|
|
||||||
vm.cancelTurn()
|
|
||||||
|
|
||||||
var request = server.takeRequest()
|
|
||||||
while (request.method != "POST") request = server.takeRequest()
|
|
||||||
assertEquals("/api/sessions/s1/structured/cancel", request.path)
|
|
||||||
vm.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `resolveApproval is a no-op for a stale row id and posts for the pending one`() = runBlocking {
|
|
||||||
val opened = enqueueSocket()
|
|
||||||
val vm = StructuredViewModel(client, webSocket, "s1", scope)
|
|
||||||
val serverSocket = opened.await()
|
|
||||||
|
|
||||||
serverSocket.send(
|
|
||||||
"""{"type":"transcript_snapshot","snapshot":{"cursor":1,
|
|
||||||
"rows":[{"id":"approval:1","kind":"approval","data":{}}]}}""",
|
|
||||||
)
|
|
||||||
serverSocket.send(
|
|
||||||
"""{"type":"reduced_state","state":{"turn":{"responding":false,"thinking":false,"active_tool_calls":[]},
|
|
||||||
"pending_approval":{"nonce":"n1"}}}""",
|
|
||||||
)
|
|
||||||
vm.awaitState { it.pendingApprovalRowId == "approval:1" }
|
|
||||||
|
|
||||||
// A row id that is not the pending one never reaches the network —
|
|
||||||
// the daemon's own nonce rules make this a no-op, not an error.
|
|
||||||
vm.resolveApproval("approval:0", StructuredApprovalDecision.AllowOnce)
|
|
||||||
assertEquals(1, server.requestCount) // only the socket upgrade so far
|
|
||||||
|
|
||||||
server.enqueue(MockResponse().setResponseCode(204))
|
|
||||||
vm.resolveApproval("approval:1", StructuredApprovalDecision.AllowOnce)
|
|
||||||
|
|
||||||
var request = server.takeRequest()
|
|
||||||
while (request.method != "POST") request = server.takeRequest()
|
|
||||||
assertEquals("/api/sessions/s1/structured/approvals/n1", request.path)
|
|
||||||
vm.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.terminal
|
|
||||||
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class TerminalKeyBarActionsTest {
|
|
||||||
@Test
|
|
||||||
fun `contains tab esc and all four arrows`() {
|
|
||||||
val labels = TERMINAL_KEY_ACTIONS.map { it.label }
|
|
||||||
assertTrue(labels.containsAll(listOf("Tab", "Esc", "↑", "↓", "←", "→")))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `ctrl and alt are modifiers rather than sends`() {
|
|
||||||
val modifierNames = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Modifier>().map { it.name }
|
|
||||||
assertTrue(modifierNames.containsAll(listOf("ctrl", "alt")))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `arrow keys carry the maestroKey names the terminal page expects`() {
|
|
||||||
val sendKeys = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Send>().map { it.key }
|
|
||||||
assertTrue(sendKeys.containsAll(listOf("Up", "Down", "Left", "Right", "Tab", "Esc")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package pt.naps.maestro.ui.terminal
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class TerminalPageUrlTest {
|
|
||||||
@Test
|
|
||||||
fun `builds asset url with the encoded attach ws url`() {
|
|
||||||
val url = TerminalPageUrl.build("http://192.168.1.5:7070", "abc-123")
|
|
||||||
assertEquals(
|
|
||||||
"file:///android_asset/terminal/index.html" +
|
|
||||||
"?ws=ws%3A%2F%2F192.168.1.5%3A7070%2Fapi%2Fattach%2Fabc-123",
|
|
||||||
url,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `swaps an https base to wss`() {
|
|
||||||
val url = TerminalPageUrl.build("https://daemon.example.com", "s1")
|
|
||||||
assertTrue(url.contains("wss%3A%2F%2Fdaemon.example.com"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `strips a trailing slash from the daemon base url`() {
|
|
||||||
val url = TerminalPageUrl.build("http://host:7070/", "s1")
|
|
||||||
assertTrue(url.contains("ws%3A%2F%2Fhost%3A7070%2Fapi%2Fattach%2Fs1"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.android.application) apply false
|
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
||||||
org.gradle.parallel=true
|
|
||||||
android.useAndroidX=true
|
|
||||||
kotlin.code.style=official
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user