diff --git a/ProwlCLI/Commands/SelectorOptions.swift b/ProwlCLI/Commands/SelectorOptions.swift index c5e6392a..fc92b69a 100644 --- a/ProwlCLI/Commands/SelectorOptions.swift +++ b/ProwlCLI/Commands/SelectorOptions.swift @@ -11,10 +11,10 @@ struct SelectorOptions: ParsableArguments { @Option(name: .long, help: "Target worktree by id, name, or path.") var worktree: String? - @Option(name: .long, help: "Target tab by id.") + @Option(name: .long, help: "Target tab by UUID or short handle (for example, t4).") var tab: String? - @Option(name: .long, help: "Target pane by id.") + @Option(name: .long, help: "Target pane by UUID or short handle (for example, p3).") var pane: String? /// Validate mutual exclusivity and return typed selector. diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index 23f8f851..ca407a98 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -178,9 +178,10 @@ enum OutputRenderer { guard let tabItems = tabGroups[tabID], let firstTab = tabItems.first else { continue } let tabNum = "Tab \(tabIndex + 1):" + let tabHandle = firstTab.tab.handle.map { "t\($0)" } ?? firstTab.tab.id let selectedMark = firstTab.tab.selected ? "*".yellow : " " let tabTitle = firstTab.tab.selected ? firstTab.tab.title.yellow : firstTab.tab.title - lines.append(" [\(selectedMark)] \(tabNum.dim) \(tabTitle)") + lines.append(" [\(selectedMark)] \(tabNum.dim) \(tabTitle) \(tabHandle.dim)") for (paneIndex, item) in tabItems.enumerated() { let focusMark = item.pane.focused ? ">".green.bold : " " @@ -194,7 +195,8 @@ enum OutputRenderer { paneLine += " \(cwd.dim)" } - paneLine += " \(item.pane.id.dim)" + let paneHandle = item.pane.handle.map { "p\($0)" } ?? item.pane.id + paneLine += " \(paneHandle.dim)" lines.append(paneLine) } } @@ -227,8 +229,9 @@ enum OutputRenderer { return sortedAgents.map { agent in let statusLabel = agentStatusLabel(agent.status) let projectLabel = "\(agent.project.name):\(agent.project.branch)" + let paneHandle = agent.pane.handle.map { "p\($0)" } ?? agent.pane.id let sessionLabel = agent.session.map { " session=\($0.id) [\($0.confidence)]" } ?? "" - return "\(statusLabel) \(agent.name) \(projectLabel) \(agent.tab.title) \(agent.pane.id)\(sessionLabel)" + return "\(statusLabel) \(agent.name) \(projectLabel) \(agent.tab.title) \(paneHandle)\(sessionLabel)" }.joined(separator: "\n") } diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 860cf14f..c63216d0 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -469,11 +469,13 @@ final class ProwlCLIIntegrationTests: XCTestCase { ), tab: ListTab( id: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", + handle: 7, title: "Prowl 1", selected: true ), pane: ListPane( id: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", + handle: 8, title: "zsh", cwd: "/Users/onevcat/Projects/Prowl", focused: true @@ -493,11 +495,10 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(result.exitCode, 0) XCTAssertTrue(result.stdout.contains("Prowl:Prowl (running)"), "Missing worktree header: \(result.stdout)") XCTAssertTrue(result.stdout.contains("Tab 1:"), "Missing tab label: \(result.stdout)") + XCTAssertTrue(result.stdout.contains("t7"), "Missing tab handle: \(result.stdout)") XCTAssertTrue(result.stdout.contains("Pane 1:"), "Missing pane label: \(result.stdout)") - XCTAssertTrue( - result.stdout.contains("6E1A2A10-D99F-4E3F-920C-D93AA3C05764"), - "Missing pane ID: \(result.stdout)" - ) + XCTAssertTrue(result.stdout.contains("p8"), "Missing pane handle: \(result.stdout)") + XCTAssertFalse(result.stdout.contains("6E1A2A10-D99F-4E3F-920C-D93AA3C05764")) } func testListEmptyPayloadShowsNoPanesFound() throws { @@ -544,6 +545,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { ), makeAgentResponse( id: "blocked-pane", + handle: 3, name: "omp", status: "blocked", projectName: "Prowl", @@ -575,7 +577,8 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertTrue(lines[0].contains("omp"), "Missing agent name: \(result.stdout)") XCTAssertTrue(lines[0].contains("Prowl:feature/cli-agents"), "Missing project label: \(result.stdout)") XCTAssertTrue(lines[0].contains("issue 330"), "Missing tab title: \(result.stdout)") - XCTAssertTrue(lines[0].contains("blocked-pane"), "Missing pane id: \(result.stdout)") + XCTAssertTrue(lines[0].contains("p3"), "Missing pane handle: \(result.stdout)") + XCTAssertFalse(lines[0].contains("blocked-pane"), "Unexpected UUID fallback: \(result.stdout)") XCTAssertTrue(lines[1].contains("Working"), "Expected working second: \(result.stdout)") XCTAssertTrue(lines[2].contains("Done"), "Expected done third: \(result.stdout)") XCTAssertTrue(lines[2].contains("session=019f4e9e-1234-4567-89ab-0123456789ab [exact]")) @@ -1577,6 +1580,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { private func makeAgentResponse( id: String, + handle: Int? = nil, name: String, status: String, projectName: String, @@ -1600,7 +1604,14 @@ final class ProwlCLIIntegrationTests: XCTestCase { kind: "git" ), tab: ListTab(id: "\(id)-tab", title: tabTitle, selected: true), - pane: AgentsResponsePane(id: id, index: 1, title: name, cwd: "/Projects/\(projectName)", focused: false), + pane: AgentsResponsePane( + id: id, + handle: handle, + index: 1, + title: name, + cwd: "/Projects/\(projectName)", + focused: false + ), session: session ) } @@ -1786,15 +1797,32 @@ private struct ListWorktree: Encodable { private struct ListTab: Encodable { let id: String + let handle: Int? let title: String let selected: Bool + + init(id: String, handle: Int? = nil, title: String, selected: Bool) { + self.id = id + self.handle = handle + self.title = title + self.selected = selected + } } private struct ListPane: Encodable { let id: String + let handle: Int? let title: String let cwd: String? let focused: Bool + + init(id: String, handle: Int? = nil, title: String, cwd: String?, focused: Bool) { + self.id = id + self.handle = handle + self.title = title + self.cwd = cwd + self.focused = focused + } } private struct ListTask: Encodable { @@ -1850,10 +1878,20 @@ private struct AgentsResponseProject: Encodable { private struct AgentsResponsePane: Encodable { let id: String + let handle: Int? let index: Int let title: String let cwd: String? let focused: Bool + + init(id: String, handle: Int? = nil, index: Int, title: String, cwd: String?, focused: Bool) { + self.id = id + self.handle = handle + self.index = index + self.title = title + self.cwd = cwd + self.focused = focused + } } private struct FocusResponseData: Encodable { diff --git a/docs-ai/013-prowl-cli/contracts/input.md b/docs-ai/013-prowl-cli/contracts/input.md index 1d12c95d..ebd83317 100644 --- a/docs-ai/013-prowl-cli/contracts/input.md +++ b/docs-ai/013-prowl-cli/contracts/input.md @@ -81,8 +81,13 @@ Path-like first arg (v1): - `-t ` / `--target ` — auto-resolve: try pane UUID → tab UUID → worktree id/name/path. - `--worktree ` — explicit worktree selector. -- `--tab ` — explicit tab UUID selector. -- `--pane ` — explicit pane UUID selector. +- `--tab ` — explicit tab UUID or current short handle (`tN` or bare `N`). +- `--pane ` — explicit pane UUID or current short handle (`pN` or bare `N`). + +Short handles are process-scoped, globally monotonic, and never reused after a +target closes. They are intentionally unsupported for `--target` and positional +auto-targeting, where a bare number can be a worktree name. JSON `id` fields +remain canonical UUIDs. ### 3.2 Positional target shorthand @@ -317,6 +322,8 @@ prowl . prowl open ~/Projects/Prowl prowl focus 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 # auto-resolve pane UUID prowl focus --pane 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 # explicit pane +prowl read --pane p3 --last 200 # explicit pane handle +prowl tab close --tab t4 --force # explicit tab handle prowl focus main # auto-resolve worktree name prowl send "echo hello" # text to current pane prowl send 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 "echo hi" # target + text diff --git a/docs-ai/046-cli-short-handles/000-plan.md b/docs-ai/046-cli-short-handles/000-plan.md new file mode 100644 index 00000000..ace6ad8b --- /dev/null +++ b/docs-ai/046-cli-short-handles/000-plan.md @@ -0,0 +1,72 @@ +# 046 — CLI Short Handles: Plan + +| | | +| --- | --- | +| **Status** | Implemented | +| **Anchor date** | 2026-07-13 | +| **Primary PRs** | — | +| **Related** | [013-prowl-cli](../013-prowl-cli/000-plan.md), `docs/components/cli.md`, `docs-ai/013-prowl-cli/contracts/input.md` | + +## Background + +Prowl's CLI currently exposes a UUID for every tab and pane and only resolves explicit +`--tab` and `--pane` selectors after parsing a UUID. UUIDs are valid canonical runtime +identifiers, but they are expensive and error-prone handles for a human or a coding agent +to copy between CLI calls. Issue #474 requests compact session-scoped selectors. + +## Goals + +- Assign a process-lifetime, globally monotonic, non-reused short handle to every live + tab and pane observed by the CLI snapshot builders. +- Display tab and pane handles in text `prowl list` and pane handles in text `prowl agents`. +- Accept a canonical UUID, a prefixed handle (`tN` / `pN`), or the numeric suffix in + explicit `--tab` / `--pane` selectors. +- Preserve UUIDs as the `id` values in every `--json` payload and retain the v1 JSON schema. +- Add focused unit tests for handle allocation, snapshot propagation, resolver behavior, + text payloads, and JSON compatibility. + +### Non-goals + +- Do not add handles to `--target`; a bare number can be a worktree name, so explicit + selector flags remain the unambiguous short-handle interface. +- Do not change persisted tab/surface UUIDs or terminal layout persistence. +- Do not add a UI copy control until the issue reporter clarifies whether it should copy + a Prowl pane handle or a native agent session identifier. + +## Design / Approach + +`WorktreeTerminalManager` will own a small allocator keyed by canonical UUID and terminal +kind. It will allocate from one increasing sequence, release a mapping when its target +closes, and never decrement the sequence, so new UUIDs never reuse an exposed handle while +the app is running. Snapshot builders will ask the manager for handles while building +`ListRuntimeSnapshot` and `TargetResolutionSnapshot`; this keeps allocation centralized +without changing terminal layout models. + +The snapshot tab and pane records will carry their handle alongside their canonical UUID. +`TargetResolver` will first retain UUID behavior and then match the type-appropriate +handle for explicit tab or pane selectors. The auto selector will remain UUID/worktree +only to avoid a numeric-worktree ambiguity. + +`ListCommandHandler` and `AgentsCommandHandler` will include handles only when the envelope +requests text output. The shared payload types will make those fields optional, so +`--json` encodes exactly the existing UUID-only shape. `ProwlCLI/Output/OutputRenderer.swift` +will render the handles instead of UUIDs in its human-facing list and agents output. + +## Alternatives & decisions + +| Option | Decision | +| --- | --- | +| Replace JSON `id` with a short value | Rejected: v1 contracts define UUID `id` fields and consumers may depend on them. | +| Add a permanent JSON `handle` field | Deferred: useful, but it expands a strict v1 schema and needs an explicit contract-versioning decision. | +| Use per-worktree or reusable counters | Rejected: a CLI handle needs to be globally unambiguous for its terminal kind and must not silently retarget after a close. | +| Use only bare integers | Rejected as canonical rendering: `pN` / `tN` communicate selector kind; numeric suffixes remain accepted by explicit flags for ergonomic compatibility. | + +## Verification + +- Run targeted Swift Testing suites for the allocator/snapshot/resolver and CLI handlers. +- Run the required CLI build, smoke, and integration commands, `make check`, and `make build-app`. +- Launch an isolated debug Prowl instance and prove `list` shows a short pane handle while + `list --json` still returns UUIDs, then use that handle with `read --pane` and close the + temporary tab by its short tab handle. + +## Amendments diff --git a/docs-ai/046-cli-short-handles/001-action.md b/docs-ai/046-cli-short-handles/001-action.md new file mode 100644 index 00000000..f20d2e5e --- /dev/null +++ b/docs-ai/046-cli-short-handles/001-action.md @@ -0,0 +1,51 @@ +# 046 — CLI Short Handles: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-07-13 | Added session-scoped short tab and pane handles for explicit CLI targeting, while preserving UUID-only JSON contracts. | Issue #474 | + +## Outcome & current state + +- `TerminalTargetHandleRegistry` is owned by `WorktreeTerminalManager` and shared by all + active `WorktreeTerminalState` instances. It allocates one global, increasing sequence + for tab and pane UUIDs, releases a mapping at teardown, and never reuses a number during + the app process. +- Tab creation, splits, layout restoration, tab/pane close paths, and worktree pruning + register or unregister their targets. Restoring a persisted UUID therefore gets a fresh + short handle rather than inheriting a stale one. +- Explicit `--pane` accepts a pane UUID, `pN`, or bare `N`; explicit `--tab` accepts a tab + UUID, `tN`, or bare `N`. Auto and positional target resolution deliberately remain + UUID/worktree-only because a bare number can be a worktree name. +- Text `prowl list` displays `tN` and `pN`; text `prowl agents` displays `pN`. The app only + includes optional handle fields in text-mode socket payloads, so `--json` remains exactly + the existing UUID-only v1 shape. +- User-facing CLI, terminal, and Active Agents documentation now explains the lifetime, + explicit-selector requirement, and UUID-versus-handle choice. The bundled `prowl-cli` + skill follows the same guidance. + +## Tests and verification + +- Added allocator, resolver, lifecycle/restore, list/agents payload, and CLI text rendering + regression coverage. The targeted Swift Testing invocation passed 9/9 tests; Xcode also + emitted existing SwiftPM dependency-scan diagnostics without test failures. +- `make check` passed. +- `make build-app` passed with 0 errors and 0 warnings. +- `make build-cli` and `make test-cli-smoke` passed; `make test-cli-integration` passed + 55/55 tests. +- An isolated Debug Prowl instance showed `t5` / `p6` in text `list`, kept UUID-only + `list --json` output, resolved the created pane through both `read --pane p6` and + `read --pane 6`, and closed the exact created tab with `tab close --tab t5`. The temporary + tabs, Debug app, and dedicated socket were removed afterwards. +- The full `make test` run reached 1,792 passing tests before the unrelated + `ExternalDiffToolTests.snapshotPairIncludesModifiedAndUntrackedFiles()` fixture failed: + the host-wide `core.hooksPath` ran an identity-enforcement `commit-msg` hook against its + temporary `git commit -m Initial`. A standalone reproduction succeeds with hooks disabled; + no unrelated test or production code was changed for this task. + +## Open questions + +- The requested copy affordance is intentionally not included. The reporter needs to clarify + whether it should copy the Prowl pane handle or the native agent session identifier; those + values serve different handoff workflows. diff --git a/docs-ai/README.md b/docs-ai/README.md index 96b99f53..feb4bc30 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -92,3 +92,4 @@ behavior (`docs/` is the agent-facing manual for that). | 043 | [canvas-tile-layout](043-canvas-tile-layout/000-plan.md) | 2026-06-24 | Tile layout + default-layout setting | | 044 | [foundation-model-branch-names](044-foundation-model-branch-names/000-plan.md) | 2026-06-27 | On-device FM branch-name suggestions | | 045 | [native-agent-session-detection](045-native-agent-session-detection/000-plan.md) | 2026-07-12 | Native agent session identity (successor to 030's heuristics) | +| 046 | [cli-short-handles](046-cli-short-handles/000-plan.md) | 2026-07-13 | Session-scoped tab and pane handles for CLI targeting | diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index bd89567c..f61b84e7 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -70,9 +70,10 @@ When nothing is running: "New agents will appear here". - **Agent detection** ([agent-detection](agent-detection.md)) feeds this panel. - **CLI** ([cli](cli.md)) exposes the same roster through `prowl agents` and - `prowl agents --json`. The command is read-only; use the returned - `pane.id` with `prowl focus --pane`, `prowl read --pane`, or - `prowl send --pane` for follow-up actions. + `prowl agents --json`. The command is read-only; text output shows a current + `pN` pane handle, while JSON keeps the canonical `pane.id`. Use either with + `prowl focus --pane`, `prowl read --pane`, or `prowl send --pane` for + follow-up actions. - **Notifications** ([notifications](notifications.md)) are driven by a separate signal — terminal bell / OSC desktop notifications and command-finished events — which usually coincides with, but is not the same as, a detected finish. diff --git a/docs/components/cli.md b/docs/components/cli.md index d0886e28..86d5a6ae 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -42,8 +42,10 @@ check the exit code before piping to `jq`. Most commands accept one selector (mutually exclusive): -- `--pane ` — a specific pane (safest for automation). -- `--tab ` — a specific tab (its focused/first pane). +- `--pane ` — a specific pane. `pN` is the short handle shown in + text output; bare `N` is accepted too. +- `--tab ` — a specific tab (its focused/first pane). `tN` is the + short handle shown in text output; bare `N` is accepted too. - `--worktree ` — a worktree (its selected/first tab → focused/first pane). - `-t, --target ` — auto-resolve: tries pane UUID, then tab UUID, then @@ -54,8 +56,16 @@ Most commands accept one selector (mutually exclusive): **Rules:** at most one selector (else `INVALID_ARGUMENT`); prefer explicit `--pane`. The focused pane is not stable — `open` and `focus` change it. -> **Never target by tab title.** Titles are free-form and can lie. Resolve a -> concrete `pane.id` from `prowl list --json` first. +Text `list` and `agents` output exposes short, type-prefixed handles such as +`p7` and `t6`. They are valid only for the current app process, are globally +monotonic, and are never reused after a tab or pane closes. Use them with an +explicit `--pane` or `--tab`; `--target` and positional targets deliberately do +not resolve handles because a bare number can be a worktree name. JSON always +keeps the canonical UUID in `id`; do not cache either form across an app restart. + +> **Never target by tab title.** Titles are free-form and can lie. For scripts, +> resolve a concrete UUID `pane.id` from `prowl list --json`; for an interactive +> same-session handoff, copy the `pN` handle from text `prowl list`. ## Commands @@ -71,6 +81,16 @@ Each item contains: - `pane`: `id`, `title`, `cwd`, `focused` - `task`: `status` (`running` | `idle` | null) +These are JSON fields, so `tab.id` and `pane.id` remain UUIDs. Plain `prowl list` +instead shows `tN` for each tab and `pN` for each pane; pass either handle back +with the corresponding explicit selector: + +```bash +prowl list +prowl read --pane p7 --last 120 --wait-stable +prowl tab close --tab t6 --force +``` + `task.status` is **running** when any pane in the worktree is busy — a terminal command reporting progress, or a detected agent that is Working/Blocked (including Claude running a background **workflow**); otherwise **idle**. See the @@ -119,7 +139,9 @@ prowl read --pane "$pane" --last 120 --wait-stable ``` Text output is sorted for triage: `Blocked`, `Working`, `Done`, then `Idle`. -Empty output prints `No agents found.`. +It prints a pane handle such as `p7` for each agent; use it as +`prowl read --pane p7`, not as an `agents` subcommand. Empty output prints +`No agents found.`. ### `prowl read [target]` Read a pane's content. @@ -239,7 +261,7 @@ inside-root / new-root), `app_launched`, `brought_to_front`, `created_tab`, and |------|--------------------| | `APP_NOT_RUNNING` | Prowl is not reachable, or the socket is missing/stale. Start or restart Prowl, then retry. | | `SOCKET_PERMISSION_DENIED` | The socket exists but the client cannot connect, usually because a sandbox blocked the Unix socket. Allowlist the socket path, run outside the sandbox, or use matching `PROWL_CLI_SOCKET` values for both app and CLI. | -| `TARGET_NOT_FOUND` | Selector matched nothing — re-run `list` and pick a UUID. | +| `TARGET_NOT_FOUND` | Selector matched nothing — re-run `list` and pick a UUID or current short handle. | | `TARGET_NOT_UNIQUE` | Selector matched several — be more specific (use `--pane`). | | `NO_ACTIVE_PANE` | No pane for focused-target; pass an explicit `--pane`. | | `EMPTY_INPUT` | `send` got neither argv nor stdin (or both). | @@ -273,8 +295,8 @@ prowl pane close --pane "$pane" --json ## Gotchas for agents (quick list) -- Resolve a `pane.id` before `read`/`send`/`key`/`focus`/close — never trust tab - titles. +- Resolve a UUID `pane.id` or current text `pN` before `read`/`send`/`key`/ + `focus`/close — never trust tab titles. - Use `prowl agents --json` when you need agent status; use `prowl list --json` when you need all panes, including ordinary shells. - `--capture` needs shell integration; otherwise `read --wait-stable` or file diff --git a/docs/components/terminal.md b/docs/components/terminal.md index 79eeaf9f..22f5e6e8 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -73,9 +73,10 @@ lifetime. Prowl also "learns" your shell's idle prompt so it doesn't mistake it for a meaningful title. > **Titles are free-form and can lie or lag.** Any program can set any title. -> When automating, never target a pane by its title — use the stable `pane.id` -> from [`prowl list`](cli.md). The bundled [`prowl-cli` skill](cli.md) repeats -> this for good reason. +> When automating, never target a pane by its title — use the JSON `pane.id` or, +> for a same-session handoff, the text `pN` handle from +> [`prowl list`](cli.md). The bundled [`prowl-cli` skill](cli.md) repeats this +> for good reason. ## Tab icons diff --git a/docs/concepts.md b/docs/concepts.md index d44f215a..ae6ca350 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -35,9 +35,10 @@ Repository a git repo, workspace, or plain folder added to Prowl starts with one pane and can be **split** horizontally/vertically into more. "Pane" and "surface" mean the same thing; the CLI and UI both use "pane". -> **For the `prowl` CLI:** every pane has a stable **UUID** (`pane.id`), every tab -> has a `tab.id`, and every worktree has a `worktree.id` (its path). Target work -> by these IDs — never by tab title, which is free-form and can lie. See +> **For the `prowl` CLI:** JSON exposes canonical UUIDs as `pane.id` and `tab.id`; +> text `list` and `agents` output also exposes compact, process-scoped `pN` / `tN` +> handles for explicit targeting. Neither should be cached across app restarts. +> Never target by tab title, which is free-form and can lie. See > [`components/cli.md`](components/cli.md). ## View modes — three ways to see the same worktrees diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index c42cfca3..cf1b7c7b 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -10,7 +10,7 @@ Use `prowl` only when the task is to inspect or control the running Prowl GUI ap ## Safe Default Workflow -Always resolve a concrete pane UUID before `read`, `send`, `key`, `focus`, or destructive close commands. +For automation, always resolve a concrete pane UUID before `read`, `send`, `key`, `focus`, or destructive close commands. Text `list` and `agents` output also exposes current-process `pN` / `tN` handles for concise same-session handoffs; use them only with explicit `--pane` / `--tab` flags. ```bash prowl list --json @@ -213,7 +213,7 @@ prowl read --pane "$pane" --last 120 --wait-stable --json When no agent is blocked, use the same pattern with `working`, `done`, or `idle` depending on the task. The JSON payload also includes `.project.name`, `.project.branch`, `.worktree.path`, `.tab.title`, and `.pane.focused`, so automation can filter by human project label while still targeting the concrete pane. -`-t/--target` can auto-resolve pane UUID, tab UUID, or worktree id/name/path, but explicit `--pane ` is safer for automation. +`-t/--target` can auto-resolve pane UUID, tab UUID, or worktree id/name/path, but not short handles. Explicit `--pane ` is safer for automation; explicit `--pane ` is useful for a same-session human or agent handoff. ## Argument Rules @@ -276,7 +276,7 @@ Common codes and recovery: - `APP_NOT_RUNNING`: Prowl is not reachable, or the socket is missing/stale. Ask before restarting the app. - `SOCKET_PERMISSION_DENIED`: the socket exists but the sandbox or filesystem permissions blocked `connect()`. Report this as a permission/sandbox problem, not as an app-liveness problem. - `TRANSPORT_FAILED`: the socket connection broke or the socket path is invalid (for example `ENOTSOCK` or a too-long `PROWL_CLI_SOCKET`). Recheck which Prowl instance owns the socket. -- `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`: run `prowl list --json` again and choose an explicit pane UUID. +- `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`: run `prowl list --json` again and choose an explicit pane UUID, or refresh text `prowl list` and use its current `pN` handle. - `EMPTY_INPUT`: `send` got neither argv text nor stdin. - `NO_ACTIVE_PANE`: no pane resolved for positional (focused-pane) targeting; pass an explicit `--pane`. - `INVALID_ARGUMENT`: illegal flag or flag combination, such as `--capture --no-wait`. diff --git a/supacode/CLIService/AgentsCommandHandler.swift b/supacode/CLIService/AgentsCommandHandler.swift index 69a5b5c4..4290233c 100644 --- a/supacode/CLIService/AgentsCommandHandler.swift +++ b/supacode/CLIService/AgentsCommandHandler.swift @@ -26,10 +26,10 @@ final class AgentsCommandHandler: CommandHandler { } // swiftlint:disable:next async_without_await - func handle(envelope _: CommandEnvelope) async -> CommandResponse { + func handle(envelope: CommandEnvelope) async -> CommandResponse { do { let snapshot = try snapshotProvider() - let payload = makePayload(from: snapshot) + let payload = makePayload(from: snapshot, includeHandles: envelope.output == .text) return try CommandResponse( ok: true, command: "agents", @@ -49,7 +49,10 @@ final class AgentsCommandHandler: CommandHandler { } } - private func makePayload(from snapshot: AgentsRuntimeSnapshot) -> AgentsCommandPayload { + private func makePayload( + from snapshot: AgentsRuntimeSnapshot, + includeHandles: Bool + ) -> AgentsCommandPayload { let repositoriesState = snapshot.repositoriesState let metadata = SidebarListView.activeAgentWorktreeMetadata( repositories: repositoriesState.repositories, @@ -101,6 +104,7 @@ final class AgentsCommandHandler: CommandHandler { ), pane: AgentsCommandPane( id: terminalContext.pane.id.uuidString, + handle: includeHandles ? terminalContext.pane.handle : nil, index: entry.paneIndex, title: terminalContext.pane.title, cwd: terminalContext.pane.cwd, diff --git a/supacode/CLIService/ListCommandHandler.swift b/supacode/CLIService/ListCommandHandler.swift index bb437874..c57a32c7 100644 --- a/supacode/CLIService/ListCommandHandler.swift +++ b/supacode/CLIService/ListCommandHandler.swift @@ -13,16 +13,41 @@ struct ListRuntimeSnapshot: Sendable { struct Tab: Sendable { let id: UUID + let handle: Int? let title: String let selected: Bool let focusedPaneID: UUID? let panes: [Pane] + + init( + id: UUID, + handle: Int? = nil, + title: String, + selected: Bool, + focusedPaneID: UUID?, + panes: [Pane] + ) { + self.id = id + self.handle = handle + self.title = title + self.selected = selected + self.focusedPaneID = focusedPaneID + self.panes = panes + } } struct Pane: Sendable { let id: UUID + let handle: Int? let title: String let cwd: String? + + init(id: UUID, handle: Int? = nil, title: String, cwd: String?) { + self.id = id + self.handle = handle + self.title = title + self.cwd = cwd + } } let worktrees: [Worktree] @@ -39,10 +64,10 @@ final class ListCommandHandler: CommandHandler { } // swiftlint:disable:next async_without_await - func handle(envelope _: CommandEnvelope) async -> CommandResponse { + func handle(envelope: CommandEnvelope) async -> CommandResponse { do { let snapshot = try snapshotProvider() - let payload = makePayload(from: snapshot) + let payload = makePayload(from: snapshot, includeHandles: envelope.output == .text) return try CommandResponse( ok: true, command: "list", @@ -62,7 +87,10 @@ final class ListCommandHandler: CommandHandler { } } - private func makePayload(from snapshot: ListRuntimeSnapshot) -> ListCommandPayload { + private func makePayload( + from snapshot: ListRuntimeSnapshot, + includeHandles: Bool + ) -> ListCommandPayload { var items: [ListCommandItem] = [] var didAssignFocusedPane = false @@ -90,11 +118,13 @@ final class ListCommandHandler: CommandHandler { ), tab: ListCommandTab( id: tab.id.uuidString, + handle: includeHandles ? tab.handle : nil, title: tab.title, selected: tab.selected ), pane: ListCommandPane( id: pane.id.uuidString, + handle: includeHandles ? pane.handle : nil, title: pane.title, cwd: pane.cwd, focused: isFocused diff --git a/supacode/CLIService/ListRuntimeSnapshotBuilder.swift b/supacode/CLIService/ListRuntimeSnapshotBuilder.swift index bae2e5a9..3182a92f 100644 --- a/supacode/CLIService/ListRuntimeSnapshotBuilder.swift +++ b/supacode/CLIService/ListRuntimeSnapshotBuilder.swift @@ -33,6 +33,7 @@ enum ListRuntimeSnapshotBuilder { let panes = tabSnapshot.panes.map { paneSnapshot in ListRuntimeSnapshot.Pane( id: paneSnapshot.id, + handle: paneSnapshot.handle, title: paneSnapshot.title, cwd: normalizeAbsolutePath(paneSnapshot.cwd) ) @@ -44,6 +45,7 @@ enum ListRuntimeSnapshotBuilder { return ListRuntimeSnapshot.Tab( id: tabSnapshot.id, + handle: tabSnapshot.handle, title: tabSnapshot.title, selected: tabSnapshot.selected, focusedPaneID: tabSnapshot.focusedPaneID, diff --git a/supacode/CLIService/Shared/AgentsCommandPayload.swift b/supacode/CLIService/Shared/AgentsCommandPayload.swift index 2e1951aa..152e996d 100644 --- a/supacode/CLIService/Shared/AgentsCommandPayload.swift +++ b/supacode/CLIService/Shared/AgentsCommandPayload.swift @@ -135,13 +135,15 @@ public struct AgentsCommandTab: Codable, Equatable { public struct AgentsCommandPane: Codable, Equatable { public let id: String + public let handle: Int? public let index: Int public let title: String public let cwd: String? public let focused: Bool - public init(id: String, index: Int, title: String, cwd: String?, focused: Bool) { + public init(id: String, handle: Int? = nil, index: Int, title: String, cwd: String?, focused: Bool) { self.id = id + self.handle = handle self.index = index self.title = title self.cwd = cwd diff --git a/supacode/CLIService/Shared/ListCommandPayload.swift b/supacode/CLIService/Shared/ListCommandPayload.swift index 251bb2a8..c1aa36a1 100644 --- a/supacode/CLIService/Shared/ListCommandPayload.swift +++ b/supacode/CLIService/Shared/ListCommandPayload.swift @@ -61,11 +61,13 @@ public struct ListCommandWorktree: Codable, Equatable { public struct ListCommandTab: Codable, Equatable { public let id: String + public let handle: Int? public let title: String public let selected: Bool - public init(id: String, title: String, selected: Bool) { + public init(id: String, handle: Int? = nil, title: String, selected: Bool) { self.id = id + self.handle = handle self.title = title self.selected = selected } @@ -73,12 +75,14 @@ public struct ListCommandTab: Codable, Equatable { public struct ListCommandPane: Codable, Equatable { public let id: String + public let handle: Int? public let title: String public let cwd: String? public let focused: Bool - public init(id: String, title: String, cwd: String?, focused: Bool) { + public init(id: String, handle: Int? = nil, title: String, cwd: String?, focused: Bool) { self.id = id + self.handle = handle self.title = title self.cwd = cwd self.focused = focused diff --git a/supacode/CLIService/TargetResolver.swift b/supacode/CLIService/TargetResolver.swift index 9e5da629..5adad53d 100644 --- a/supacode/CLIService/TargetResolver.swift +++ b/supacode/CLIService/TargetResolver.swift @@ -98,17 +98,14 @@ final class TargetResolver { return .success(makeTarget(worktree: worktree, tab: tab, pane: pane, focusedWorktreeID: snapshot.focusedWorktreeID)) } - // MARK: - .tab: find by UUID + // MARK: - .tab: find by UUID or short handle private func resolveTab( _ value: String, _ snapshot: TargetResolutionSnapshot ) -> Result { - guard let uuid = UUID(uuidString: value) else { - return .failure(.notFound("Invalid tab UUID: '\(value)'.")) - } for worktree in snapshot.worktrees { - for tab in worktree.tabs where tab.id == uuid { + for tab in worktree.tabs where matches(tab: tab, selector: value) { guard let pane = tab.focusedPane ?? tab.panes.first else { return .failure(.notFound("No panes in tab '\(value)'.")) } @@ -124,18 +121,15 @@ final class TargetResolver { return .failure(.notFound("Tab '\(value)' not found.")) } - // MARK: - .pane: find by UUID across all worktrees/tabs + // MARK: - .pane: find by UUID or short handle across all worktrees/tabs private func resolvePane( _ value: String, _ snapshot: TargetResolutionSnapshot ) -> Result { - guard let uuid = UUID(uuidString: value) else { - return .failure(.notFound("Invalid pane UUID: '\(value)'.")) - } for worktree in snapshot.worktrees { for tab in worktree.tabs { - for pane in tab.panes where pane.id == uuid { + for pane in tab.panes where matches(pane: pane, selector: value) { return .success( makeTarget( worktree: worktree, @@ -173,6 +167,39 @@ final class TargetResolver { // MARK: - Helpers + private func matches(tab: TargetResolutionSnapshot.Tab, selector: String) -> Bool { + guard let handle = shortHandle(in: selector, prefix: "t") else { + return UUID(uuidString: selector) == tab.id + } + return tab.handle == handle + } + + private func matches(pane: TargetResolutionSnapshot.Pane, selector: String) -> Bool { + guard let handle = shortHandle(in: selector, prefix: "p") else { + return UUID(uuidString: selector) == pane.id + } + return pane.handle == handle + } + + private func shortHandle(in selector: String, prefix: Character) -> Int? { + let normalized = selector.lowercased() + let digits: Substring + if normalized.first == prefix { + digits = normalized.dropFirst() + } else { + digits = normalized[...] + } + guard + !digits.isEmpty, + digits.allSatisfy({ $0.isASCII && $0.isNumber }), + let handle = Int(digits), + handle > 0 + else { + return nil + } + return handle + } + private func makeTarget( worktree: TargetResolutionSnapshot.Worktree, tab: TargetResolutionSnapshot.Tab, @@ -212,11 +239,28 @@ struct TargetResolutionSnapshot: Sendable { struct Tab: Sendable { let id: UUID + let handle: Int? let title: String let selected: Bool let panes: [Pane] let focusedPaneID: UUID? + init( + id: UUID, + handle: Int? = nil, + title: String, + selected: Bool, + panes: [Pane], + focusedPaneID: UUID? + ) { + self.id = id + self.handle = handle + self.title = title + self.selected = selected + self.panes = panes + self.focusedPaneID = focusedPaneID + } + var focusedPane: Pane? { guard let focusedPaneID else { return nil } return panes.first { $0.id == focusedPaneID } @@ -225,10 +269,27 @@ struct TargetResolutionSnapshot: Sendable { struct Pane: @unchecked Sendable { let id: UUID + let handle: Int? let title: String let cwd: String? let isFocusedInTab: Bool let surfaceView: GhosttySurfaceView + + init( + id: UUID, + handle: Int? = nil, + title: String, + cwd: String?, + isFocusedInTab: Bool, + surfaceView: GhosttySurfaceView + ) { + self.id = id + self.handle = handle + self.title = title + self.cwd = cwd + self.isFocusedInTab = isFocusedInTab + self.surfaceView = surfaceView + } } let worktrees: [Worktree] @@ -260,6 +321,7 @@ enum TargetResolutionSnapshotBuilder { guard let snapshot else { return nil } return TargetResolutionSnapshot.Tab( id: tab.id.rawValue, + handle: state.registerTargetHandle(for: tab.id), title: tab.displayTitle, selected: tab.id == selectedTabID, panes: snapshot.panes, diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 72f26b04..2f396007 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -11,6 +11,7 @@ private let layoutRestoreFailureMessage = "Saved terminal layout was invalid and final class WorktreeTerminalManager { private let runtime: GhosttyRuntime? private let layoutPersistence: TerminalLayoutPersistenceClient + private let targetHandleRegistry = TerminalTargetHandleRegistry() private var states: [Worktree.ID: WorktreeTerminalState] = [:] private var notificationsEnabled = true private var commandFinishedNotificationEnabled = true @@ -235,7 +236,8 @@ final class WorktreeTerminalManager { runtime: runtime!, worktree: worktree, runSetupScript: runSetupScript, - defaultFontSize: preferredFontSize + defaultFontSize: preferredFontSize, + targetHandleRegistry: targetHandleRegistry ) state.setNotificationsEnabled(notificationsEnabled) state.setCommandFinishedNotification( diff --git a/supacode/Features/Terminal/Models/TerminalTargetHandleRegistry.swift b/supacode/Features/Terminal/Models/TerminalTargetHandleRegistry.swift new file mode 100644 index 00000000..898f3632 --- /dev/null +++ b/supacode/Features/Terminal/Models/TerminalTargetHandleRegistry.swift @@ -0,0 +1,53 @@ +import Foundation + +@MainActor +final class TerminalTargetHandleRegistry { + private var tabHandles: [TerminalTabID: Int] = [:] + private var paneHandles: [UUID: Int] = [:] + private var nextHandle: Int + + init(firstHandle: Int = 1) { + precondition(firstHandle > 0) + nextHandle = firstHandle + } + + func register(tabID: TerminalTabID) -> Int { + if let handle = tabHandles[tabID] { + return handle + } + let handle = allocateHandle() + tabHandles[tabID] = handle + return handle + } + + func register(paneID: UUID) -> Int { + if let handle = paneHandles[paneID] { + return handle + } + let handle = allocateHandle() + paneHandles[paneID] = handle + return handle + } + + func handle(for tabID: TerminalTabID) -> Int? { + tabHandles[tabID] + } + + func handle(for paneID: UUID) -> Int? { + paneHandles[paneID] + } + + func unregister(tabID: TerminalTabID) { + tabHandles.removeValue(forKey: tabID) + } + + func unregister(paneID: UUID) { + paneHandles.removeValue(forKey: paneID) + } + + private func allocateHandle() -> Int { + let handle = nextHandle + nextHandle += 1 + return handle + } +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+CLI.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+CLI.swift index e5db9e30..06b552bd 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+CLI.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+CLI.swift @@ -8,6 +8,7 @@ struct CLIWorktreeTerminalSnapshot: Sendable { struct CLITerminalTabSnapshot: Sendable { let id: UUID + let handle: Int? let title: String let selected: Bool let focusedPaneID: UUID? @@ -16,6 +17,7 @@ struct CLITerminalTabSnapshot: Sendable { struct CLITerminalPaneSnapshot: Sendable { let id: UUID + let handle: Int? let title: String let cwd: String? } @@ -33,11 +35,17 @@ extension WorktreeTerminalState { ).workingDirectory?.path(percentEncoded: false) let title = paneTitle(surfaceID: paneID, fallbackTabTitle: tab.displayTitle) - return CLITerminalPaneSnapshot(id: paneID, title: title, cwd: cwd) + return CLITerminalPaneSnapshot( + id: paneID, + handle: registerTargetHandle(for: paneID), + title: title, + cwd: cwd + ) } return CLITerminalTabSnapshot( id: tab.id.rawValue, + handle: registerTargetHandle(for: tab.id), title: tab.displayTitle, selected: tab.id == selectedTabID, focusedPaneID: focusedSurfaceIdByTab[tab.id], @@ -107,6 +115,7 @@ extension WorktreeTerminalState { let title = paneTitle(surfaceID: paneID, fallbackTabTitle: "") return TargetResolutionSnapshot.Pane( id: paneID, + handle: registerTargetHandle(for: paneID), title: title, cwd: cwd, isFocusedInTab: paneID == focusedPaneID, diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+LayoutSnapshot.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+LayoutSnapshot.swift index 2fe34fe7..2e00c9d7 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+LayoutSnapshot.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+LayoutSnapshot.swift @@ -141,6 +141,13 @@ extension WorktreeTerminalState { tabManager.selectedTabId = selectedTabID setRunScriptTabId(nil) + for tab in restoredTabs { + _ = registerTargetHandle(for: tab.id) + for surface in restoredTrees[tab.id]?.leaves() ?? [] { + _ = registerTargetHandle(for: surface.id) + } + } + // Explicitly unfocus all restored surfaces so only the focused one blinks. for surface in surfaces.values { surface.focusDidChange(false) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 564da7cb..093b47ab 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -126,6 +126,7 @@ extension WorktreeTerminalState { newSurface.setOcclusion(true) } focusSurface(newSurface, in: tabId) + _ = registerTargetHandle(for: newSurface.id) return newSurface.id } catch { newSurface.closeSurface() @@ -212,6 +213,7 @@ extension WorktreeTerminalState { newSurface.setOcclusion(true) } focusSurface(newSurface, in: tabId) + _ = registerTargetHandle(for: newSurface.id) return true } catch { newSurface.closeSurface() @@ -312,7 +314,11 @@ extension WorktreeTerminalState { } func closeAllSurfaces() { + for tab in tabManager.tabs { + unregisterTargetHandle(for: tab.id) + } for surface in surfaces.values { + unregisterTargetHandle(for: surface.id) surface.closeSurface() } surfaces.removeAll() @@ -612,6 +618,7 @@ extension WorktreeTerminalState { /// without dropping them here the worktree's unseen indicator (bell + Dock /// badge) would stay lit until the user manually dismisses everything. func forgetSurface(_ surfaceID: UUID) { + unregisterTargetHandle(for: surfaceID) surfaces.removeValue(forKey: surfaceID) surfaceRunningStartedAtById.removeValue(forKey: surfaceID) autoCloseSurfaceIds.remove(surfaceID) @@ -831,6 +838,7 @@ extension WorktreeTerminalState { focusedSurfaceIdByTab.removeValue(forKey: tabId) removeBoundDirectoryTab(tabId) tabManager.closeTab(tabId) + unregisterTargetHandle(for: tabId) if tabId == runScriptTabId { setRunScriptTabId(nil) } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index d01bfa90..baacae9f 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -64,6 +64,7 @@ final class WorktreeTerminalState { let tabManager: TerminalTabManager let runtime: GhosttyRuntime let worktree: Worktree + private let targetHandleRegistry: TerminalTargetHandleRegistry @ObservationIgnored @SharedReader private var repositorySettings: RepositorySettings var trees: [TerminalTabID: SplitTree] = [:] @@ -181,10 +182,12 @@ final class WorktreeTerminalState { runtime: GhosttyRuntime, worktree: Worktree, runSetupScript: Bool = false, - defaultFontSize: Float32? = nil + defaultFontSize: Float32? = nil, + targetHandleRegistry: TerminalTargetHandleRegistry? = nil ) { self.runtime = runtime self.worktree = worktree + self.targetHandleRegistry = targetHandleRegistry ?? TerminalTargetHandleRegistry() self.pendingSetupScript = runSetupScript self.defaultFontSize = defaultFontSize self.tabManager = TerminalTabManager() @@ -198,6 +201,30 @@ final class WorktreeTerminalState { var worktreeName: String { worktree.name } var repositoryRootURL: URL { worktree.repositoryRootURL } + func registerTargetHandle(for tabID: TerminalTabID) -> Int { + targetHandleRegistry.register(tabID: tabID) + } + + func registerTargetHandle(for paneID: UUID) -> Int { + targetHandleRegistry.register(paneID: paneID) + } + + func tabHandle(for tabID: TerminalTabID) -> Int? { + targetHandleRegistry.handle(for: tabID) + } + + func paneHandle(for paneID: UUID) -> Int? { + targetHandleRegistry.handle(for: paneID) + } + + func unregisterTargetHandle(for tabID: TerminalTabID) { + targetHandleRegistry.unregister(tabID: tabID) + } + + func unregisterTargetHandle(for paneID: UUID) { + targetHandleRegistry.unregister(paneID: paneID) + } + var activeSurfaceView: GhosttySurfaceView? { guard let selectedTabId = tabManager.selectedTabId, let surfaceId = focusedSurfaceIdByTab[selectedTabId] @@ -436,6 +463,10 @@ final class WorktreeTerminalState { workingDirectoryOverride: creation.workingDirectoryOverride, context: creation.context ) + _ = registerTargetHandle(for: tabId) + for surface in tree.leaves() { + _ = registerTargetHandle(for: surface.id) + } tabIsRunningById[tabId] = false if creation.focusing, let surface = tree.root?.leftmostLeaf() { focusSurface(surface, in: tabId) @@ -608,6 +639,7 @@ final class WorktreeTerminalState { guard confirmCloseIfNeeded(tabIds: [tabId], mode: confirmation) else { return false } let wasRunScriptTab = tabId == runScriptTabId removeTree(for: tabId) + unregisterTargetHandle(for: tabId) removeBoundDirectoryTab(tabId) tabManager.closeTab(tabId) if let selected = tabManager.selectedTabId { diff --git a/supacodeTests/CLIAgentsCommandHandlerTests.swift b/supacodeTests/CLIAgentsCommandHandlerTests.swift index f06a7b1e..d3cb80a5 100644 --- a/supacodeTests/CLIAgentsCommandHandlerTests.swift +++ b/supacodeTests/CLIAgentsCommandHandlerTests.swift @@ -41,6 +41,7 @@ struct CLIAgentsCommandHandlerTests { #expect(agent.tab.title == "issue 330") #expect(agent.tab.selected) #expect(agent.pane.id == fixture.tabPaneID.uuidString) + #expect(agent.pane.handle == nil) #expect(agent.pane.index == 2) #expect(agent.pane.title == "omp") #expect(agent.pane.cwd == "/tmp/project-repo/Sources") @@ -55,6 +56,26 @@ struct CLIAgentsCommandHandlerTests { #expect(idleAgent.project.name == "Tab Repo") #expect(idleAgent.project.branch == "main") #expect(idleAgent.pane.focused == false) + let rawPayload = try #require(response.data?.bytes) + let rawPayloadString = try #require(String(bytes: rawPayload, encoding: .utf8)) + #expect(!rawPayloadString.contains("\"handle\"")) + } + + @Test func includesPaneHandlesOnlyInTextPayload() async throws { + let fixture = makePayloadFixture() + let handler = AgentsCommandHandler { + fixture.snapshot + } + + let response = await handler.handle( + envelope: CommandEnvelope(output: .text, command: .agents(AgentsInput())) + ) + let payload = try #require(try response.data?.decode(as: AgentsCommandPayload.self)) + + #expect(payload.agents[0].id == fixture.tabPaneID.uuidString) + #expect(payload.agents[0].pane.id == fixture.tabPaneID.uuidString) + #expect(payload.agents[0].pane.handle == 12) + #expect(payload.agents[1].pane.handle == 11) } @Test func returnsAgentsFailedWhenSnapshotProviderThrows() async { @@ -200,12 +221,13 @@ struct CLIAgentsCommandHandlerTests { tabs: [ .init( id: tabID, + handle: 10, title: "issue 330", selected: true, focusedPaneID: tabPaneID, panes: [ - .init(id: otherPaneID, title: "zsh", cwd: "/tmp/tab-repo"), - .init(id: tabPaneID, title: "omp", cwd: "/tmp/project-repo/Sources"), + .init(id: otherPaneID, handle: 11, title: "zsh", cwd: "/tmp/tab-repo"), + .init(id: tabPaneID, handle: 12, title: "omp", cwd: "/tmp/project-repo/Sources"), ] ) ] diff --git a/supacodeTests/CLIListCommandHandlerTests.swift b/supacodeTests/CLIListCommandHandlerTests.swift index 9585f42d..95adb2a3 100644 --- a/supacodeTests/CLIListCommandHandlerTests.swift +++ b/supacodeTests/CLIListCommandHandlerTests.swift @@ -20,17 +20,20 @@ struct CLIListCommandHandlerTests { tabs: [ .init( id: UUID(uuidString: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0")!, + handle: 1, title: "Prowl 1", selected: true, focusedPaneID: UUID(uuidString: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764")!, panes: [ .init( id: UUID(uuidString: "1344AEF5-3BA6-4B75-A07E-1F36C63A34B0")!, + handle: 2, title: "tests", cwd: "/Users/onevcat/Projects/Prowl" ), .init( id: UUID(uuidString: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764")!, + handle: 3, title: "build", cwd: "/Users/onevcat/Projects/Prowl" ), @@ -48,12 +51,14 @@ struct CLIListCommandHandlerTests { tabs: [ .init( id: UUID(uuidString: "A2B07BBA-9DD0-4C77-9D76-2B3E0AF12096")!, + handle: 4, title: "Notes", selected: true, focusedPaneID: UUID(uuidString: "EF65FF31-1B72-40B2-80DA-3AA87B7B6858")!, panes: [ .init( id: UUID(uuidString: "EF65FF31-1B72-40B2-80DA-3AA87B7B6858")!, + handle: 5, title: "notes", cwd: "/Users/onevcat/Projects/Notes" ) @@ -90,6 +95,50 @@ struct CLIListCommandHandlerTests { #expect(payload.items[0].task.status == .running) #expect(payload.items[2].task.status == .idle) + #expect(payload.items.allSatisfy { $0.tab.handle == nil && $0.pane.handle == nil }) + let rawPayload = try #require(response.data?.bytes) + let rawPayloadString = try #require(String(bytes: rawPayload, encoding: .utf8)) + #expect(!rawPayloadString.contains("\"handle\"")) + } + + @Test func includesShortHandlesInTextPayload() async throws { + let tabID = UUID() + let paneID = UUID() + let handler = ListCommandHandler { + ListRuntimeSnapshot( + worktrees: [ + .init( + id: "worktree", + name: "main", + path: "/tmp/worktree", + rootPath: "/tmp/worktree", + kind: .git, + taskStatus: .idle, + tabs: [ + .init( + id: tabID, + handle: 12, + title: "Tab", + selected: true, + focusedPaneID: paneID, + panes: [.init(id: paneID, handle: 13, title: "shell", cwd: "/tmp/worktree")] + ) + ] + ) + ], + focusedWorktreeID: "worktree" + ) + } + + let response = await handler.handle( + envelope: CommandEnvelope(output: .text, command: .list(ListInput())) + ) + let payload = try #require(try response.data?.decode(as: ListCommandPayload.self)) + + #expect(payload.items[0].tab.id == tabID.uuidString) + #expect(payload.items[0].tab.handle == 12) + #expect(payload.items[0].pane.id == paneID.uuidString) + #expect(payload.items[0].pane.handle == 13) } @Test func returnsListFailedWhenSnapshotProviderThrows() async { diff --git a/supacodeTests/CLITargetResolverTests.swift b/supacodeTests/CLITargetResolverTests.swift new file mode 100644 index 00000000..bd896595 --- /dev/null +++ b/supacodeTests/CLITargetResolverTests.swift @@ -0,0 +1,124 @@ +import Foundation +import GhosttyKit +import Testing + +@testable import supacode + +@MainActor +struct CLITargetResolverTests { + @Test func explicitTabAndPaneSelectorsAcceptUUIDsAndShortHandles() throws { + let tabID = UUID() + let firstPaneID = UUID() + let focusedPaneID = UUID() + let snapshot = makeSnapshot( + worktreeID: "worktree", + worktreeName: "main", + tab: (id: tabID, handle: 4), + panes: [(id: firstPaneID, handle: 5), (id: focusedPaneID, handle: 6)], + focusedPaneID: focusedPaneID + ) + let resolver = TargetResolver { snapshot } + + for selector in [tabID.uuidString, "4", "t4"] { + let target = try resolvedTarget(from: resolver.resolve(.tab(selector))) + #expect(target.tabID == tabID) + #expect(target.paneID == focusedPaneID) + } + + for selector in [firstPaneID.uuidString, "5", "p5"] { + let target = try resolvedTarget(from: resolver.resolve(.pane(selector))) + #expect(target.paneID == firstPaneID) + } + } + + @Test func autoSelectorKeepsNumericValuesForWorktrees() throws { + let paneSnapshot = makeSnapshot( + worktreeID: "pane-worktree", + worktreeName: "other", + tab: (id: UUID(), handle: 1), + panes: [(id: UUID(), handle: 3)], + focusedPaneID: nil + ) + let numericWorktreeSnapshot = makeSnapshot( + worktreeID: "numeric-worktree", + worktreeName: "3", + tab: (id: UUID(), handle: 4), + panes: [(id: UUID(), handle: 5)], + focusedPaneID: nil + ) + let resolver = TargetResolver { + TargetResolutionSnapshot( + worktrees: [paneSnapshot.worktrees[0], numericWorktreeSnapshot.worktrees[0]], + focusedWorktreeID: nil + ) + } + + let numericTarget = try resolvedTarget(from: resolver.resolve(.auto("3"))) + #expect(numericTarget.worktreeID == "numeric-worktree") + + if case .failure(.notFound) = resolver.resolve(.auto("p3")) { + // Expected: short handles are explicit-selector-only. + } else { + Issue.record("--target must not resolve a short pane handle") + } + } + + private func makeSnapshot( + worktreeID: String, + worktreeName: String, + tab tabInfo: (id: UUID, handle: Int), + panes: [(id: UUID, handle: Int)], + focusedPaneID: UUID? + ) -> TargetResolutionSnapshot { + let runtime = GhosttyRuntime() + let surfaceView = GhosttySurfaceView( + runtime: runtime, + workingDirectory: nil, + context: GHOSTTY_SURFACE_CONTEXT_TAB, + skipsSurfaceCreationForTesting: true + ) + let targetPanes = panes.map { pane in + TargetResolutionSnapshot.Pane( + id: pane.id, + handle: pane.handle, + title: "shell", + cwd: "/tmp/\(worktreeID)", + isFocusedInTab: pane.id == focusedPaneID, + surfaceView: surfaceView + ) + } + let tab = TargetResolutionSnapshot.Tab( + id: tabInfo.id, + handle: tabInfo.handle, + title: "Tab", + selected: true, + panes: targetPanes, + focusedPaneID: focusedPaneID + ) + return TargetResolutionSnapshot( + worktrees: [ + .init( + id: worktreeID, + name: worktreeName, + path: "/tmp/\(worktreeID)", + rootPath: "/tmp/\(worktreeID)", + kind: .git, + tabs: [tab] + ) + ], + focusedWorktreeID: worktreeID + ) + } + + private func resolvedTarget( + from result: Result + ) throws -> ResolvedTarget { + switch result { + case .success(let target): + return target + case .failure(let error): + Issue.record("Unexpected resolution failure: \(error)") + throw error + } + } +} diff --git a/supacodeTests/TerminalTargetHandleRegistryTests.swift b/supacodeTests/TerminalTargetHandleRegistryTests.swift new file mode 100644 index 00000000..5cd910b4 --- /dev/null +++ b/supacodeTests/TerminalTargetHandleRegistryTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct TerminalTargetHandleRegistryTests { + @Test func allocatesGloballyAndNeverReusesClosedTargets() { + let registry = TerminalTargetHandleRegistry() + let tabID = TerminalTabID(rawValue: UUID()) + let paneID = UUID() + + #expect(registry.register(tabID: tabID) == 1) + #expect(registry.register(paneID: paneID) == 2) + #expect(registry.register(tabID: tabID) == 1) + + registry.unregister(tabID: tabID) + registry.unregister(paneID: paneID) + + #expect(registry.handle(for: tabID) == nil) + #expect(registry.handle(for: paneID) == nil) + #expect(registry.register(tabID: tabID) == 3) + #expect(registry.register(paneID: paneID) == 4) + } +} diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 4d6adab0..2aca99af 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -193,6 +193,57 @@ struct WorktreeTerminalManagerTests { #expect(state.closeSurface(id: surfaceId, confirmation: .skip) == false) } + @Test func targetHandlesAreGlobalAndReassignedAfterClose() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let firstWorktree = makeWorktree() + let secondWorktree = makeWorktree(id: "/tmp/repo/wt-2", name: "wt-2") + let firstState = manager.state(for: firstWorktree) + let secondState = manager.state(for: secondWorktree) + + let firstTabID = try #require(firstState.createTab()) + let firstPaneID = try #require(firstState.focusedSurfaceId(in: firstTabID)) + let secondTabID = try #require(secondState.createTab()) + let secondPaneID = try #require(secondState.focusedSurfaceId(in: secondTabID)) + + #expect(firstState.tabHandle(for: firstTabID) == 1) + #expect(firstState.paneHandle(for: firstPaneID) == 2) + #expect(secondState.tabHandle(for: secondTabID) == 3) + #expect(secondState.paneHandle(for: secondPaneID) == 4) + + #expect(firstState.closeTab(firstTabID, confirmation: .skip)) + #expect(firstState.tabHandle(for: firstTabID) == nil) + #expect(firstState.paneHandle(for: firstPaneID) == nil) + + let replacementTabID = try #require(firstState.createTab()) + let replacementPaneID = try #require(firstState.focusedSurfaceId(in: replacementTabID)) + + #expect(firstState.tabHandle(for: replacementTabID) == 5) + #expect(firstState.paneHandle(for: replacementPaneID) == 6) + } + + @Test func layoutRestoreReassignsHandlesForRestoredTabIDs() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + let tabID = try #require(state.createTab()) + let originalPaneID = try #require(state.focusedSurfaceId(in: tabID)) + let originalTabHandle = try #require(state.tabHandle(for: tabID)) + let originalPaneHandle = try #require(state.paneHandle(for: originalPaneID)) + let snapshot = try #require(state.makeLayoutSnapshotWorktree()) + + #expect(state.applyLayoutSnapshot(snapshot)) + + let restoredTabID = try #require(state.tabManager.tabs.first?.id) + let restoredPaneID = try #require(state.focusedSurfaceId(in: restoredTabID)) + let restoredTabHandle = try #require(state.tabHandle(for: restoredTabID)) + let restoredPaneHandle = try #require(state.paneHandle(for: restoredPaneID)) + + #expect(restoredTabID == tabID) + #expect(restoredTabHandle > originalTabHandle) + #expect(restoredPaneHandle > originalPaneHandle) + #expect(state.paneHandle(for: originalPaneID) == nil) + } + @Test func notificationIndicatorUsesCurrentCountOnStreamStart() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree()