diff --git a/ProwlCLI/Commands/CreateCommand.swift b/ProwlCLI/Commands/CreateCommand.swift index 4eecab4c..778de65a 100644 --- a/ProwlCLI/Commands/CreateCommand.swift +++ b/ProwlCLI/Commands/CreateCommand.swift @@ -10,6 +10,7 @@ struct CreateCommand: ParsableCommand { abstract: "Create a terminal resource.", subcommands: [ CreateTabCommand.self, + CreatePaneCommand.self, ] ) } @@ -33,18 +34,20 @@ struct CreateTabCommand: ParsableCommand { try CLIExecution.run(command: "create", output: options.outputMode, colorEnabled: options.colorEnabled) { let envelope = CommandEnvelope( output: options.outputMode, - command: .create( - CreateInput( - resource: .tab, - selector: try selector.resolveWorktree(positionalTarget: worktree), - path: normalizedPath() - ) - ) + command: .create(try makeInput()) ) try CLIRunner.execute(envelope) } } + func makeInput() throws -> CreateInput { + CreateInput( + resource: .tab, + selector: try selector.resolveWorktree(positionalTarget: worktree), + path: normalizedPath() + ) + } + private func normalizedPath() -> String? { guard let path else { return nil } return URL(fileURLWithPath: path, isDirectory: true) @@ -54,6 +57,56 @@ struct CreateTabCommand: ParsableCommand { } } +struct CreatePaneCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "pane", + abstract: "Create a split pane beside an existing pane." + ) + + enum Direction: String, ExpressibleByArgument { + case right + case left + case up + case down + + var value: CreatePaneDirection { + switch self { + case .right: .right + case .left: .left + case .up: .upward + case .down: .down + } + } + } + + @Argument(help: "Anchor pane UUID or short handle (for example, p3).") + var anchor: String? + + @OptionGroup var selector: LifecycleSelectorOptions + @OptionGroup var options: GlobalOptions + + @Option(name: .long, help: "Split direction: right, left, up, or down.") + var direction: Direction + + mutating func run() throws { + try CLIExecution.run(command: "create", output: options.outputMode, colorEnabled: options.colorEnabled) { + let envelope = CommandEnvelope( + output: options.outputMode, + command: .create(try makeInput()) + ) + try CLIRunner.execute(envelope) + } + } + + func makeInput() throws -> CreateInput { + CreateInput( + resource: .pane, + selector: try selector.resolvePane(positionalTarget: anchor), + direction: direction.value + ) + } +} + private extension String { func trimmingTrailingSlash() -> String { var value = self diff --git a/ProwlCLI/Commands/LifecycleSelectorOptions.swift b/ProwlCLI/Commands/LifecycleSelectorOptions.swift index 0fcd55e8..c75c9edf 100644 --- a/ProwlCLI/Commands/LifecycleSelectorOptions.swift +++ b/ProwlCLI/Commands/LifecycleSelectorOptions.swift @@ -19,6 +19,52 @@ struct LifecycleSelectorOptions: ParsableArguments { try resolve(positionalTarget: positionalTarget, acceptedSelector: .worktree) } + func resolvePane(positionalTarget: String?) throws -> TargetSelector { + let provided = [worktree, tab, pane].compactMap { $0 } + guard provided.count <= 1 else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "At most one target selector (--worktree, --tab, --pane) is allowed." + ) + } + + if let positionalTarget { + guard provided.isEmpty else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "Use either a positional pane or --pane." + ) + } + guard isPaneReference(positionalTarget) else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "create pane requires a pane UUID or prefixed handle (pN)." + ) + } + return .pane(positionalTarget) + } + + if worktree != nil || tab != nil { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "create pane accepts only --pane, not --worktree or --tab." + ) + } + guard let pane else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "create pane requires an explicit pane anchor." + ) + } + guard isPaneReference(pane) else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "create pane requires a pane UUID or prefixed handle (pN)." + ) + } + return .pane(pane) + } + func resolveTerminalTarget(positionalTarget: String?) throws -> TargetSelector { let provided = [worktree, tab, pane].compactMap { $0 } guard provided.count <= 1 else { @@ -98,7 +144,11 @@ struct LifecycleSelectorOptions: ParsableArguments { } private func isTerminalReference(_ value: String) -> Bool { - UUID(uuidString: value) != nil || isPrefixedHandle(value, prefix: "p") || isPrefixedHandle(value, prefix: "t") + isPaneReference(value) || isPrefixedHandle(value, prefix: "t") + } + + private func isPaneReference(_ value: String) -> Bool { + UUID(uuidString: value) != nil || isPrefixedHandle(value, prefix: "p") } private func isPrefixedHandle(_ value: String, prefix: Character) -> Bool { diff --git a/ProwlCLIContracts/Resources/cli-output-schema.json b/ProwlCLIContracts/Resources/cli-output-schema.json index eb8b3c2f..a2f9177c 100644 --- a/ProwlCLIContracts/Resources/cli-output-schema.json +++ b/ProwlCLIContracts/Resources/cli-output-schema.json @@ -173,6 +173,55 @@ } } }, + "createData": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "resource", + "target" + ], + "properties": { + "resource": { + "const": "tab" + }, + "target": { + "$ref": "#/$defs/target" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "resource", + "anchor", + "direction", + "target" + ], + "properties": { + "resource": { + "const": "pane" + }, + "anchor": { + "$ref": "#/$defs/target" + }, + "direction": { + "enum": [ + "right", + "left", + "up", + "down" + ] + }, + "target": { + "$ref": "#/$defs/target" + } + } + } + ] + }, "lifecycleData": { "type": "object", "additionalProperties": false, @@ -1790,7 +1839,7 @@ "const": "prowl.cli.create.v1" }, "data": { - "$ref": "#/$defs/lifecycleData" + "$ref": "#/$defs/createData" } } }, diff --git a/ProwlCLITests/CreateCommandParsingTests.swift b/ProwlCLITests/CreateCommandParsingTests.swift new file mode 100644 index 00000000..6ce1e4e7 --- /dev/null +++ b/ProwlCLITests/CreateCommandParsingTests.swift @@ -0,0 +1,42 @@ +import ProwlCLIShared +import XCTest + +@testable import prowl + +final class CreateCommandParsingTests: XCTestCase { + func testPaneParsesPositionalAnchorAndDirection() throws { + let command = try CreatePaneCommand.parse(["p12", "--direction", "up"]) + + XCTAssertEqual(try command.makeInput().selector, .pane("p12")) + XCTAssertEqual(try command.makeInput().direction, .upward) + } + + func testPaneParsesTypedAnchorAndDirection() throws { + let anchor = UUID().uuidString + let command = try CreatePaneCommand.parse(["--pane", anchor, "--direction", "left"]) + + XCTAssertEqual(try command.makeInput().selector, .pane(anchor)) + XCTAssertEqual(try command.makeInput().direction, .left) + } + + func testPaneRequiresDirection() { + XCTAssertThrowsError(try CreatePaneCommand.parse(["p12"])) + } + + func testPaneRejectsNonPaneSelectors() throws { + let worktree = try CreatePaneCommand.parse(["--worktree", "App", "--direction", "right"]) + let tab = try CreatePaneCommand.parse(["--tab", "t4", "--direction", "right"]) + + XCTAssertThrowsError(try worktree.makeInput()) + XCTAssertThrowsError(try tab.makeInput()) + XCTAssertThrowsError(try CreatePaneCommand.parse(["--target", "p12", "--direction", "right"])) + } + + func testPaneRejectsAmbiguousAndInvalidAnchors() throws { + let mixed = try CreatePaneCommand.parse(["p12", "--pane", "p13", "--direction", "down"]) + let bareNumber = try CreatePaneCommand.parse(["12", "--direction", "down"]) + + XCTAssertThrowsError(try mixed.makeInput()) + XCTAssertThrowsError(try bareNumber.makeInput()) + } +} diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 46d31fe4..b9303b0d 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -393,6 +393,39 @@ final class ProwlCLIIntegrationTests: XCTestCase { } } + func testCreatePaneCommandRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "create-pane") + let response = try CommandResponse( + ok: true, + command: "create", + schemaVersion: "prowl.cli.create.v1", + data: RawJSON( + encoding: makeLifecyclePayload( + resource: .pane, + anchor: makeTabTarget(paneID: "anchor-pane"), + direction: .upward + ) + ) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["create", "pane", "p12", "--direction", "up", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .create(let input) = envelope.command { + XCTAssertEqual(input.resource, .pane) + XCTAssertEqual(input.selector, .pane("p12")) + XCTAssertEqual(input.direction, .upward) + XCTAssertNil(input.path) + } else { + XCTFail("Expected create command envelope") + } + } + func testCloseCommandRoundTripsOverSocket() throws { let socketPath = temporarySocketPath(suffix: "close-pane") let response = try CommandResponse( @@ -2200,8 +2233,17 @@ final class ProwlCLIIntegrationTests: XCTestCase { ) } - private func makeLifecyclePayload(resource: LifecycleResource) -> LifecycleCommandPayload { - LifecycleCommandPayload(resource: resource, target: makeTabTarget()) + private func makeLifecyclePayload( + resource: LifecycleResource, + anchor: TabTarget? = nil, + direction: CreatePaneDirection? = nil + ) -> LifecycleCommandPayload { + LifecycleCommandPayload( + resource: resource, + anchor: anchor, + direction: direction, + target: makeTabTarget() + ) } private func makeTabPayload(action: TabAction) -> TabCommandPayload { @@ -2252,7 +2294,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { ) } - private func makeTabTarget() -> TabTarget { + private func makeTabTarget(paneID: String = "pane-123") -> TabTarget { TabTarget( worktree: TabTargetWorktree( id: "App:/Projects/App", @@ -2262,7 +2304,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { kind: "git" ), tab: TabTargetTab(id: "tab-123", title: "App 1", selected: true), - pane: TabTargetPane(id: "pane-123", title: "zsh", cwd: "/Projects/App", focused: true) + pane: TabTargetPane(id: paneID, title: "zsh", cwd: "/Projects/App", focused: true) ) } diff --git a/docs-ai/013-prowl-cli/contracts/create.md b/docs-ai/013-prowl-cli/contracts/create.md index 372d27b4..3ddffe1b 100644 --- a/docs-ai/013-prowl-cli/contracts/create.md +++ b/docs-ai/013-prowl-cli/contracts/create.md @@ -4,24 +4,32 @@ Current version: `prowl.cli.create.v1`. -`create` is the action-first lifecycle namespace. V1 exposes only `create tab`; -`create pane` is reserved for [#699](https://github.com/onevcat/Prowl/issues/699). +`create` is the action-first lifecycle namespace. V1 exposes deterministic tab and split-pane creation. ## Input ```bash prowl create tab [--path ] [--json] prowl create tab --worktree [--path ] [--json] +prowl create pane --direction [--json] +prowl create pane --pane --direction [--json] ``` -Exactly one positional worktree reference or `--worktree` is required. `--pane`, -`--tab`, `--target`, and a positional-plus-flag combination fail before transport -with `INVALID_ARGUMENT`. +`create tab` requires exactly one positional worktree reference or `--worktree`. `--pane`, +`--tab`, and a positional-plus-flag combination fail before transport with `INVALID_ARGUMENT`. +`--path` is normalized by the CLI and must be the resolved worktree root or a subdirectory +of it. A path outside the worktree fails with `PATH_NOT_ALLOWED`. -`--path` is normalized by the CLI and must be the resolved worktree root or a -subdirectory of it. A path outside the worktree fails with `PATH_NOT_ALLOWED`. +`create pane` requires exactly one pane UUID or current-process `pN` handle, positionally or +through `--pane`, plus an explicit direction. It rejects `--target`, `--worktree`, `--tab`, +bare numeric handles, `--path`, and positional-plus-flag targeting. Public `up` maps to the +terminal layer's internal top direction. The operation resolves the anchor directly; it never +focuses a different pane as an intermediate targeting step. -## Success +The new pane inherits the anchor's working directory and split surface configuration. On +success it becomes the focused pane in the anchor tab, following normal tab-local focus behavior. + +## Success: tab ```json { @@ -39,11 +47,35 @@ subdirectory of it. A path outside the worktree fails with `PATH_NOT_ALLOWED`. } ``` -`target` identifies the newly created tab and its initial pane. Its UUID fields -are the automation-safe output of this command. +## Success: pane + +```json +{ + "ok": true, + "command": "create", + "schema_version": "prowl.cli.create.v1", + "data": { + "resource": "pane", + "anchor": { + "worktree": { "id": "…", "name": "App", "path": "/…", "root_path": "/…", "kind": "git" }, + "tab": { "id": "…", "title": "zsh", "selected": true }, + "pane": { "id": "anchor-uuid", "title": "zsh", "cwd": "/…", "focused": true } + }, + "direction": "right", + "target": { + "worktree": { "id": "…", "name": "App", "path": "/…", "root_path": "/…", "kind": "git" }, + "tab": { "id": "…", "title": "zsh", "selected": true }, + "pane": { "id": "created-uuid", "title": "zsh", "cwd": "/…", "focused": true } + } + } +} +``` + +`target` identifies the newly created resource. Pane creation additionally records the resolved +`anchor` and public `direction`. UUID fields are the automation-safe output of this command. ## Errors -`INVALID_ARGUMENT`, `TARGET_NOT_FOUND`, `TARGET_NOT_UNIQUE`, `PATH_NOT_ALLOWED`, -and `CREATE_FAILED` use the common error envelope in -[`schema-bundle.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). +`INVALID_ARGUMENT`, `TARGET_NOT_FOUND`, `TARGET_NOT_UNIQUE`, `PATH_NOT_ALLOWED`, and +`CREATE_FAILED` use the common error envelope in +[`cli-output-schema.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). diff --git a/docs-ai/013-prowl-cli/contracts/input.md b/docs-ai/013-prowl-cli/contracts/input.md index 895aa8ae..20f60680 100644 --- a/docs-ai/013-prowl-cli/contracts/input.md +++ b/docs-ai/013-prowl-cli/contracts/input.md @@ -38,14 +38,18 @@ succeeds. ```bash prowl create tab [--path ] prowl create tab --worktree [--path ] +prowl create pane --direction +prowl create pane --pane --direction prowl close [--force] prowl close --pane [--force] prowl close --tab [--force] ``` -`create tab` requires a worktree-only target. `close` requires a pane-or-tab-only -target and rejects `--target`, `--worktree`, bare-number positions, and focus -fallback. See [create.md](create.md) and [close.md](close.md). +`create tab` requires a worktree-only target. `create pane` requires a pane-only +anchor and explicit direction; it rejects `--target`, `--worktree`, `--tab`, bare +numbers, and focus fallback. `close` requires a pane-or-tab-only target and rejects +`--target`, `--worktree`, bare-number positions, and focus fallback. See +[create.md](create.md) and [close.md](close.md). `tab create`, `tab close`, and `pane close` remain deprecated aliases for one shipped release. They keep their legacy parser/transport behavior while emitting a diff --git a/docs-ai/013-prowl-cli/contracts/targeting.md b/docs-ai/013-prowl-cli/contracts/targeting.md index 0a932c19..a357a2f3 100644 --- a/docs-ai/013-prowl-cli/contracts/targeting.md +++ b/docs-ai/013-prowl-cli/contracts/targeting.md @@ -59,10 +59,11 @@ Lifecycle commands intentionally do not use generic worktree projection: ```text create tab | --worktree +create pane | --pane close | --pane | --tab ``` -`close` rejects `--target`, `--worktree`, bare-number positionals, UI-focus -fallback, and mixed positional/flag targeting. `create pane` is reserved for -[#699](https://github.com/onevcat/Prowl/issues/699): it will require a pane-only -anchor and direction. +`create pane` requires `--direction right|left|up|down`; it resolves its anchor +directly and rejects `--target`, `--worktree`, `--tab`, bare-number anchors, and +mixed positional/flag targeting. `close` rejects `--target`, `--worktree`, +bare-number positionals, UI-focus fallback, and mixed positional/flag targeting. diff --git a/docs-ai/063-agent-workflows/000-plan.md b/docs-ai/063-agent-workflows/000-plan.md index 1600f4f4..89088a7d 100644 --- a/docs-ai/063-agent-workflows/000-plan.md +++ b/docs-ai/063-agent-workflows/000-plan.md @@ -564,4 +564,4 @@ attaches hooks through A2's launch boundary. ## Amendments -(append `- Updated 2026-MM-DD: ... — see [00N-topic.md](00N-topic.md)` lines here) +- Updated 2026-08-22: Implemented A1 with the direct anchored split primitive and schema-governed `prowl create pane` command — see [002-cli-create-pane.md](002-cli-create-pane.md). diff --git a/docs-ai/063-agent-workflows/002-cli-create-pane.md b/docs-ai/063-agent-workflows/002-cli-create-pane.md new file mode 100644 index 00000000..8c0da0f2 --- /dev/null +++ b/docs-ai/063-agent-workflows/002-cli-create-pane.md @@ -0,0 +1,36 @@ +# 063.002 — Anchored Split and `prowl create pane` (A1) + +## Context + +Release R1 needs deterministic split creation for both direct CLI orchestration and the profile launch boundary in A2. The terminal already split a supplied surface for Ghostty actions, but that path returned only a Boolean; the CLI had no pane-creation leaf under the action-first lifecycle grammar. + +## Change + +- `WorktreeTerminalState.createSplit(of:direction:initialInput:additionalEnvironment:focusing:)` resolves an explicit anchor, returns the created surface UUID, and reports typed `SplitCreationError` failures. +- Existing focused-surface and Ghostty action paths delegate to that primitive. +- `prowl create pane --direction right|left|up|down` accepts a pane UUID or current `pN`; `up` maps to the terminal layer's `.top` direction. +- The lifecycle handler receives a dedicated `createPane` provider. The app splits the resolved anchor before changing worktree or tab selection, so mutable UI focus cannot retarget creation. +- `prowl.cli.create.v1` remains additive: pane responses include `resource`, the resolved `anchor`, public `direction`, and created `target`; tab responses remain unchanged. +- Parser, handler, socket/schema, terminal-layer, contracts, user manual, and bundled skill coverage ship together. + +## Decisions + +- Pane creation inherits the anchor through Ghostty's existing split-surface configuration path; A1 does not add command execution or profile launch behavior. +- The provider selects the created pane only after direct anchored creation succeeds. This preserves normal focused-result behavior without using focus as an input to targeting. +- Bare numeric handles are rejected for the new pane anchor. Only UUIDs and explicit `pN` handles participate in this pane-only grammar. + +## Verification + +- Terminal and lifecycle handler suites: 12 tests passed; their RED runs first failed on the missing anchored primitive and pane provider/wire types. +- Parser suite: 5 tests passed; focused create socket/schema coverage: 3 tests passed. +- `make check` passed. +- `make build-cli` and `make test-cli-smoke` passed; `make test-cli-integration` passed 79 tests. +- `make test` verified 2,371 tests with zero failures; five dependency-scan warnings remain pre-existing. +- `make build-app` passed with zero warnings and errors. +- Live Debug verification created an `up` split through an isolated socket, confirmed the response anchor/direction/new UUID and list visibility, then closed the created pane successfully. + +## Refs + +- Slice: 063-A1 +- Branch: `feat/cli-create-pane` +- Issue: #699 diff --git a/docs/components/cli.md b/docs/components/cli.md index d2b7fbcd..1624e3e9 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -277,6 +277,21 @@ either positionally or with `--worktree`; `--path` must remain inside it. pane="$(prowl create tab "$wt" --json | jq -r '.data.target.pane.id')" ``` +### `prowl create pane` +Create a split beside an explicit pane anchor. The anchor is a pane UUID or current-process +`pN` handle, supplied positionally or with `--pane`; `--direction` is required. + +```bash +pane="$(prowl create pane "$anchor" --direction right --json | jq -r '.data.target.pane.id')" +``` + +Directions are `right`, `left`, `up`, and `down`. The created pane inherits the anchor's +working directory and terminal configuration, becomes focused in that tab, and is returned +as `.data.target.pane.id`. `.data.anchor` records the resolved source pane and +`.data.direction` records the public direction. The operation targets the anchor directly; +it never depends on current UI focus. Use `prowl send --pane "$pane" …` after creation when +you want to run input. + ### `prowl close` Close one explicit tab or pane. The positional form uses a UUID, `pN`, or `tN`; the long forms are `--pane ` and `--tab `. `close` rejects @@ -444,6 +459,6 @@ prowl close "$pane" --json when you need all panes, including ordinary shells. - `--capture` needs shell integration; otherwise `read --wait-stable` or file redirection. -- `open` is navigation, not a guaranteed new pane — use `create tab`. +- `open` is navigation, not a guaranteed new pane — use `create tab` or `create pane`. - In zsh, don't name a variable `status` (it's readonly). - Pass shell values into `jq` with `--arg`. diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index ef25ef53..c1937122 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -67,7 +67,18 @@ test "$pane" != "$self_pane" Prefer a `worktree.id` or `worktree.name` returned by `prowl list` over a hand-typed path; list preserves normalization such as trailing slashes. Use `--path` only for the new tab's working directory inside the selected worktree. -`prowl open /path` opens or focuses a matching project/path and may create a tab when needed. It is not guaranteed to create a new pane. Use `prowl create tab` for deterministic new terminal sessions. +Create a sibling split from a positively identified anchor pane and capture the new UUID: + +```bash +pane="$(prowl create pane "$anchor" --direction right --json | jq -r '.data.target.pane.id')" +test "$pane" != "$anchor" +``` + +The anchor must be a pane UUID or current `pN` handle. Directions are `right`, `left`, `up`, +and `down`. Creation inherits the anchor's working directory and returns the exact new pane; +run input afterward with an explicit `prowl send --pane "$pane" …`. + +`prowl open /path` opens or focuses a matching project/path and may create a tab when needed. It is not guaranteed to create a new pane. Use `prowl create tab` or `prowl create pane` for deterministic new terminal sessions. Run a command and capture its result: @@ -129,6 +140,12 @@ created="$(prowl create tab "$worktree" --json)" printf '%s\n' "$created" | jq -r '.data.target.pane.id' printf '%s\n' "$created" | jq -r '.data.target.tab.id' +# create pane: resolved anchor, direction, and new pane id +split="$(prowl create pane "$anchor" --direction right --json)" +printf '%s\n' "$split" | jq -r '.data.anchor.pane.id' +printf '%s\n' "$split" | jq -r '.data.direction' +printf '%s\n' "$split" | jq -r '.data.target.pane.id' + # list / agents: ids and status prowl list --json | jq -r '.data.items[].pane.id' prowl list --json | jq -r '.data.items[] | select(.pane.focused) | .pane.id' @@ -145,6 +162,7 @@ Key fields by command (see `docs/components/cli.md` for the full contract): - `list` / `agents` → `.data.items[]` / `.data.agents[]`, each with `.pane.id`, `.tab.id`, and `.worktree.{id,name,path}`. Agent entries also include `.status`, `.raw_state`, and optional `.detection_reason`; list entries include `.task.status`. - `agents read` → `.data.agent` (current status/reason), `.data.blocker.text` when blocked, and `.data.result`. Only `.data.result.state == "complete"` carries `.data.result.text`; `pending`, `unavailable`, `missing`, `incomplete`, and `too_large` deliberately carry no partial text. - `create tab` / `open` → `.data.target.{pane,tab,worktree}`. +- `create pane` → `.data.anchor`, `.data.direction`, and the created `.data.target.{pane,tab,worktree}`. ## Reading Agent Output @@ -288,7 +306,7 @@ Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, - Never omit `--pane` for `send`, `key`, `read`, or `focus` in automation. - Use `prowl agents --json` for discovery and `prowl agents read --json` for a supported agent's current semantic snapshot; use `prowl list --json` for all panes and worktree-level `task.status`. - `open /path` is a project/path navigation command. It may refocus an existing pane and is not a deterministic create command. -- Use `create tab` when automation needs a fresh shell, and capture the returned `pane.id` before sending input. +- Use `create tab` or `create pane` when automation needs a fresh shell, and capture the returned `pane.id` before sending input. - Focused pane is not stable; `open` and `focus` change it. - `read --wait-stable` sees rendered screen only. It cannot recover content folded by a TUI. - `read` returning fewer lines than `--last` requested is normally `truncated: false` — the pane simply has less history and you already have it all, so do not retry for more. `truncated: true` flags a possibly-incomplete result (the full scrollback could not be read). @@ -354,4 +372,4 @@ Write the briefing from your current working knowledge — required sections are ## Command Set -Current commands: `list`, `agents`, `agents read`, `read`, `send`, `key`, `focus`, `create tab`, `close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with an explicit `close` target. `tab create`, `tab close`, and `pane close` remain deprecated aliases for one release. +Current commands: `list`, `agents`, `agents read`, `read`, `send`, `key`, `focus`, `create tab`, `create pane`, `close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with an explicit `close` target. `tab create`, `tab close`, and `pane close` remain deprecated aliases for one release. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index e29511b2..a6e88c0f 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -843,6 +843,14 @@ struct SupacodeApp: App { return nil } } + let createPane: LifecycleCommandHandler.CreatePaneProvider = { anchor, direction in + createCLIPane( + anchor: anchor, + direction: direction, + appStore: appStore, + terminalManager: terminalManager + ) + } let closeTab: TabCommandHandler.CloseTabProvider = { target, force in guard let tabUUID = UUID(uuidString: target.tabID), let state = terminalManager.stateIfExists(for: target.worktreeID) @@ -869,6 +877,7 @@ struct SupacodeApp: App { resolveCreateTarget: resolveTabTarget, resolveCloseTarget: resolveLifecycleTarget, createTab: createTab, + createPane: createPane, closeTab: closeTab, closePane: closePane ) @@ -1216,6 +1225,50 @@ struct SupacodeApp: App { return nil } + private static func createCLIPane( + anchor: TabResolvedTarget, + direction: CreatePaneDirection, + appStore: StoreOf, + terminalManager: WorktreeTerminalManager + ) -> TabResolvedTarget? { + guard let anchorPaneID = UUID(uuidString: anchor.paneID), + let state = terminalManager.stateIfExists(for: anchor.worktreeID) + else { + return nil + } + let createdPaneID: UUID + switch state.createSplit( + of: anchorPaneID, + direction: direction.terminalSplitDirection, + initialInput: nil, + additionalEnvironment: [:], + focusing: true + ) { + case .success(let paneID): + createdPaneID = paneID + case .failure: + return nil + } + + // Resolve and split the explicit anchor before changing any UI focus. + // Selection happens only after creation so mutable focus can never retarget the split. + selectCLIWorktreeContext( + worktreeID: anchor.worktreeID, + appStore: appStore, + terminalManager: terminalManager + ) + if let tabID = state.tabId(containing: createdPaneID) { + state.selectTab(tabID) + } + let resolver = makeTargetResolver(appStore: appStore, terminalManager: terminalManager) + switch resolver.resolve(.pane(createdPaneID.uuidString)) { + case .success(let resolved): + return TabResolvedTarget(from: resolved) + case .failure: + return nil + } + } + private static func selectCLIWorktreeContext( worktreeID: Worktree.ID, appStore: StoreOf, diff --git a/supacode/CLIService/LifecycleCommandHandler.swift b/supacode/CLIService/LifecycleCommandHandler.swift index 0cefa321..5d3cc588 100644 --- a/supacode/CLIService/LifecycleCommandHandler.swift +++ b/supacode/CLIService/LifecycleCommandHandler.swift @@ -11,12 +11,14 @@ final class LifecycleCommandHandler: CommandHandler { typealias ResolveCloseTargetProvider = @MainActor (TargetSelector) -> Result typealias CreateTabProvider = @MainActor (TabResolvedTarget, String?) -> TabResolvedTarget? + typealias CreatePaneProvider = @MainActor (TabResolvedTarget, CreatePaneDirection) -> TabResolvedTarget? typealias CloseTabProvider = @MainActor (TabResolvedTarget, Bool) -> Bool typealias ClosePaneProvider = @MainActor (TabResolvedTarget, Bool) -> Bool private let resolveCreateTarget: ResolveCreateTargetProvider private let resolveCloseTarget: ResolveCloseTargetProvider private let createTab: CreateTabProvider + private let createPane: CreatePaneProvider private let closeTab: CloseTabProvider private let closePane: ClosePaneProvider @@ -24,12 +26,14 @@ final class LifecycleCommandHandler: CommandHandler { resolveCreateTarget: @escaping ResolveCreateTargetProvider, resolveCloseTarget: @escaping ResolveCloseTargetProvider, createTab: @escaping CreateTabProvider, + createPane: @escaping CreatePaneProvider, closeTab: @escaping CloseTabProvider, closePane: @escaping ClosePaneProvider ) { self.resolveCreateTarget = resolveCreateTarget self.resolveCloseTarget = resolveCloseTarget self.createTab = createTab + self.createPane = createPane self.closeTab = closeTab self.closePane = closePane } @@ -48,18 +52,20 @@ final class LifecycleCommandHandler: CommandHandler { } private func handleCreate(_ input: CreateInput) -> CommandResponse { - guard input.resource == .tab else { - return errorResponse( - command: "create", - code: CLIErrorCode.invalidArgument, - message: "create pane is not available yet." - ) + switch input.resource { + case .tab: + return handleCreateTab(input) + case .pane: + return handleCreatePane(input) } - guard case .worktree = input.selector else { + } + + private func handleCreateTab(_ input: CreateInput) -> CommandResponse { + guard case .worktree = input.selector, input.direction == nil else { return errorResponse( command: "create", code: CLIErrorCode.invalidArgument, - message: "create tab requires a worktree target." + message: "create tab requires a worktree target and does not accept a direction." ) } @@ -85,6 +91,35 @@ final class LifecycleCommandHandler: CommandHandler { return success(command: "create", resource: .tab, target: createdTarget) } + private func handleCreatePane(_ input: CreateInput) -> CommandResponse { + guard case .pane = input.selector, input.path == nil, let direction = input.direction else { + return errorResponse( + command: "create", + code: CLIErrorCode.invalidArgument, + message: "create pane requires a pane target and an explicit direction." + ) + } + + let anchor: TabResolvedTarget + switch resolveCreateTarget(input.selector) { + case .success(let resolved): + anchor = resolved + case .failure(let error): + return mapResolverError(command: "create", error: error) + } + + guard let createdTarget = createPane(anchor, direction) else { + return errorResponse(command: "create", code: CLIErrorCode.createFailed, message: "Failed to create pane.") + } + return success( + command: "create", + resource: .pane, + target: createdTarget, + anchor: anchor, + direction: direction + ) + } + private func handleClose(_ input: CloseInput) -> CommandResponse { guard !input.selector.isNone else { return errorResponse( @@ -136,13 +171,26 @@ final class LifecycleCommandHandler: CommandHandler { .trimmingTrailingSlash() } - private func success(command: String, resource: LifecycleResource, target: TabResolvedTarget) -> CommandResponse { + private func success( + command: String, + resource: LifecycleResource, + target: TabResolvedTarget, + anchor: TabResolvedTarget? = nil, + direction: CreatePaneDirection? = nil + ) -> CommandResponse { do { return try CommandResponse( ok: true, command: command, schemaVersion: "prowl.cli.\(command).v1", - data: RawJSON(encoding: LifecycleCommandPayload(resource: resource, target: makePayloadTarget(from: target))) + data: RawJSON( + encoding: LifecycleCommandPayload( + resource: resource, + anchor: anchor.map { makePayloadTarget(from: $0) }, + direction: direction, + target: makePayloadTarget(from: target) + ) + ) ) } catch { return errorResponse(command: command, code: CLIErrorCode.createFailed, message: "Failed to encode response.") @@ -191,6 +239,17 @@ final class LifecycleCommandHandler: CommandHandler { } } +extension CreatePaneDirection { + var terminalSplitDirection: UserCustomSplitDirection { + switch self { + case .right: .right + case .left: .left + case .upward: .top + case .down: .down + } + } +} + extension String { fileprivate func trimmingTrailingSlash() -> String { var value = self diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index d4c7ba01..09c95c6b 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -205,15 +205,29 @@ public enum LifecycleResource: String, Codable, Sendable, Equatable { case pane } +public enum CreatePaneDirection: String, Codable, CaseIterable, Sendable, Equatable { + case right + case left + case upward = "up" + case down +} + public struct CreateInput: Codable, Sendable { public let resource: LifecycleResource public let selector: TargetSelector public let path: String? + public let direction: CreatePaneDirection? - public init(resource: LifecycleResource, selector: TargetSelector, path: String? = nil) { + public init( + resource: LifecycleResource, + selector: TargetSelector, + path: String? = nil, + direction: CreatePaneDirection? = nil + ) { self.resource = resource self.selector = selector self.path = path + self.direction = direction } } diff --git a/supacode/CLIService/Shared/LifecycleCommandPayload.swift b/supacode/CLIService/Shared/LifecycleCommandPayload.swift index 05e119e3..24fb4eb7 100644 --- a/supacode/CLIService/Shared/LifecycleCommandPayload.swift +++ b/supacode/CLIService/Shared/LifecycleCommandPayload.swift @@ -2,10 +2,19 @@ import Foundation public struct LifecycleCommandPayload: Codable, Sendable, Equatable { public let resource: LifecycleResource + public let anchor: TabTarget? + public let direction: CreatePaneDirection? public let target: TabTarget - public init(resource: LifecycleResource, target: TabTarget) { + public init( + resource: LifecycleResource, + anchor: TabTarget? = nil, + direction: CreatePaneDirection? = nil, + target: TabTarget + ) { self.resource = resource + self.anchor = anchor + self.direction = direction self.target = target } } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index bf8c12c4..4ee8b05c 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -3,6 +3,11 @@ import CoreGraphics import Foundation import GhosttyKit +enum SplitCreationError: Error, Equatable, Sendable { + case anchorNotFound(UUID) + case insertionFailed +} + extension WorktreeTerminalState { func confirmCloseIfNeeded( tabIds: [TerminalTabID], @@ -91,49 +96,75 @@ extension WorktreeTerminalState { return tree } - /// Splits the currently focused surface and seeds the new pane with `initialInput`. - /// Returns the new surface id, or nil if the split could not be created. + /// Splits an explicit anchor surface and returns the new pane identity. + /// The anchor is resolved directly; callers never need to mutate UI focus before splitting. @discardableResult - func createSplitOnFocusedSurface( + func createSplit( + of anchorSurfaceID: UUID, direction: UserCustomSplitDirection, - initialInput: String, - additionalEnvironment: [String: String] = [:] - ) -> UUID? { - guard let tabId = tabManager.selectedTabId, - let parentSurfaceId = focusedSurfaceIdByTab[tabId], - let tree = trees[tabId], - let parentSurface = surfaces[parentSurfaceId] + initialInput: String?, + additionalEnvironment: [String: String] = [:], + focusing: Bool = true + ) -> Result { + guard let tabID = tabId(containing: anchorSurfaceID), + let tree = trees[tabID], + let anchorSurface = surfaces[anchorSurfaceID] else { - return nil + return .failure(.anchorNotFound(anchorSurfaceID)) } + let newSurface = createSurface( - tabId: tabId, - initialInput: runScriptInput(initialInput), - inheritingFromSurfaceId: parentSurfaceId, + tabId: tabID, + initialInput: initialInput.flatMap { runScriptInput($0) }, + inheritingFromSurfaceId: anchorSurfaceID, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, additionalEnvironment: additionalEnvironment ) do { let newTree = try tree.inserting( view: newSurface, - at: parentSurface, + at: anchorSurface, direction: mapUserSplitDirection(direction) ) - updateTree(newTree, for: tabId) + updateTree(newTree, for: tabID) if isCanvasManaged { newSurface.setOcclusion(true) } - focusSurface(newSurface, in: tabId) + if focusing { + focusSurface(newSurface, in: tabID) + } _ = registerTargetHandle(for: newSurface.id) - return newSurface.id + return .success(newSurface.id) } catch { newSurface.closeSurface() surfaces.removeValue(forKey: newSurface.id) surfaceRunningStartedAtById.removeValue(forKey: newSurface.id) cleanupCommandDetectorState(forSurfaceId: newSurface.id) cleanupAgentDetectionState(forSurfaceId: newSurface.id) + return .failure(.insertionFailed) + } + } + + /// Splits the currently focused surface and seeds the new pane with `initialInput`. + /// Returns the new surface id, or nil if the split could not be created. + @discardableResult + func createSplitOnFocusedSurface( + direction: UserCustomSplitDirection, + initialInput: String, + additionalEnvironment: [String: String] = [:] + ) -> UUID? { + guard let tabID = tabManager.selectedTabId, + let anchorSurfaceID = focusedSurfaceIdByTab[tabID] + else { return nil } + return try? createSplit( + of: anchorSurfaceID, + direction: direction, + initialInput: initialInput, + additionalEnvironment: additionalEnvironment, + focusing: true + ).get() } /// Returns the focused surface id for a given tab, if any. @@ -193,33 +224,15 @@ extension WorktreeTerminalState { switch action { case .newSplit(let direction): - let newSurface = createSurface( - tabId: tabId, + switch createSplit( + of: surfaceId, + direction: mapGhosttySplitDirection(direction), initialInput: nil, - inheritingFromSurfaceId: surfaceId, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT - ) - do { - let newTree = try tree.inserting( - view: newSurface, - at: targetSurface, - direction: mapSplitDirection(direction) - ) - updateTree(newTree, for: tabId) - // Canvas manages occlusion directly; ensure the new pane renders. - if isCanvasManaged { - newSurface.setOcclusion(true) - } - focusSurface(newSurface, in: tabId) - _ = registerTargetHandle(for: newSurface.id) + focusing: true + ) { + case .success: return true - } catch { - newSurface.closeSurface() - surfaces.removeValue(forKey: newSurface.id) - surfaceRunningStartedAtById.removeValue(forKey: newSurface.id) - cleanupCommandDetectorState(forSurfaceId: newSurface.id) - cleanupAgentDetectionState(forSurfaceId: newSurface.id) - + case .failure: return false } @@ -737,9 +750,7 @@ extension WorktreeTerminalState { } } - func mapSplitDirection(_ direction: GhosttySplitAction.NewDirection) - -> SplitTree.NewDirection - { + func mapGhosttySplitDirection(_ direction: GhosttySplitAction.NewDirection) -> UserCustomSplitDirection { switch direction { case .left: return .left diff --git a/supacodeTests/CLILifecycleCommandHandlerTests.swift b/supacodeTests/CLILifecycleCommandHandlerTests.swift index 58caaff5..629e9790 100644 --- a/supacodeTests/CLILifecycleCommandHandlerTests.swift +++ b/supacodeTests/CLILifecycleCommandHandlerTests.swift @@ -5,6 +5,13 @@ import Testing @MainActor struct CLILifecycleCommandHandlerTests { + @Test func createPaneDirectionsMapToTerminalDirections() { + #expect(CreatePaneDirection.right.terminalSplitDirection == .right) + #expect(CreatePaneDirection.left.terminalSplitDirection == .left) + #expect(CreatePaneDirection.upward.terminalSplitDirection == .top) + #expect(CreatePaneDirection.down.terminalSplitDirection == .down) + } + @Test func createTabResolvesWorktreeCreatesTabAndReturnsCreatePayload() async throws { let base = makeTarget(tabID: "base-tab", paneID: "base-pane") let created = makeTarget(tabID: "created-tab", paneID: "created-pane") @@ -20,6 +27,7 @@ struct CLILifecycleCommandHandlerTests { createPath = path return created }, + createPane: { _, _ in nil }, closeTab: { _, _ in true }, closePane: { _, _ in true } ) @@ -42,6 +50,83 @@ struct CLILifecycleCommandHandlerTests { #expect(payload.target.tab.id == "created-tab") } + @Test func createPaneUsesResolvedAnchorAndReturnsCreatePayload() async throws { + let anchor = makeTarget(tabID: "anchor-tab", paneID: "anchor-pane") + let created = makeTarget(tabID: "anchor-tab", paneID: "created-pane") + var resolvedSelector: TargetSelector? + var createdFrom: TabResolvedTarget? + var createdDirection: CreatePaneDirection? + let handler = LifecycleCommandHandler( + resolveCreateTarget: { selector in + resolvedSelector = selector + return .success(anchor) + }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: anchor)) }, + createTab: { _, _ in nil }, + createPane: { target, direction in + createdFrom = target + createdDirection = direction + return created + }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput(resource: .pane, selector: .pane("p12"), direction: .upward) + ) + ) + ) + + #expect(response.ok) + #expect(resolvedSelector == .pane("p12")) + #expect(createdFrom == anchor) + #expect(createdDirection == .upward) + let data = try #require(response.data) + let payload = try data.decode(as: LifecycleCommandPayload.self) + #expect(payload.resource == .pane) + #expect(payload.anchor?.pane.id == "anchor-pane") + #expect(payload.direction == .upward) + #expect(payload.target.pane.id == "created-pane") + } + + @Test func createPaneRejectsNonPaneSocketInputBeforeResolution() async { + let target = makeTarget() + var didResolve = false + var didCreate = false + let handler = LifecycleCommandHandler( + resolveCreateTarget: { _ in + didResolve = true + return .success(target) + }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: target)) }, + createTab: { _, _ in nil }, + createPane: { _, _ in + didCreate = true + return target + }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput(resource: .pane, selector: .worktree("App"), direction: .right) + ) + ) + ) + + #expect(!response.ok) + #expect(response.error?.code == CLIErrorCode.invalidArgument) + #expect(!didResolve) + #expect(!didCreate) + } + @Test func closeUsesResolvedResourceAndReturnsClosePayload() async throws { let target = makeTarget(tabID: "tab-to-close", paneID: "pane-to-close") var closedPane: TabResolvedTarget? @@ -52,6 +137,7 @@ struct CLILifecycleCommandHandlerTests { return .success(LifecycleResolvedTarget(resource: .pane, target: target)) }, createTab: { _, _ in nil }, + createPane: { _, _ in nil }, closeTab: { _, _ in false }, closePane: { target, force in #expect(force) diff --git a/supacodeTests/SplitTreeTests.swift b/supacodeTests/SplitTreeTests.swift index 50fa512a..b9cf665d 100644 --- a/supacodeTests/SplitTreeTests.swift +++ b/supacodeTests/SplitTreeTests.swift @@ -38,6 +38,65 @@ struct SplitTreeTests { #expect(emissions == [first.id, second.id]) } + @Test func explicitAnchorSplitDoesNotUseTheFocusedSurface() throws { + let state = makeWorktreeTerminalState() + let tabID = try #require(state.createTab()) + let anchorID = try #require(state.focusedSurfaceId(in: tabID)) + + #expect(state.performSplitAction(.newSplit(direction: .right), for: anchorID)) + let previouslyFocusedID = try #require(state.focusedSurfaceId(in: tabID)) + #expect(previouslyFocusedID != anchorID) + + let createdID = try state.createSplit( + of: anchorID, + direction: .left, + initialInput: nil, + additionalEnvironment: [:], + focusing: true + ).get() + + #expect(state.trees[tabID]?.leaves().map(\.id) == [createdID, anchorID, previouslyFocusedID]) + #expect(state.focusedSurfaceId(in: tabID) == createdID) + } + + @Test func explicitAnchorSplitCanPreserveFocus() throws { + let state = makeWorktreeTerminalState() + let tabID = try #require(state.createTab()) + let anchorID = try #require(state.focusedSurfaceId(in: tabID)) + + #expect(state.performSplitAction(.newSplit(direction: .right), for: anchorID)) + let focusedID = try #require(state.focusedSurfaceId(in: tabID)) + + _ = try state.createSplit( + of: anchorID, + direction: .left, + initialInput: nil, + additionalEnvironment: [:], + focusing: false + ).get() + + #expect(state.focusedSurfaceId(in: tabID) == focusedID) + } + + @Test func explicitAnchorSplitReportsMissingAnchor() throws { + let state = makeWorktreeTerminalState() + _ = try #require(state.createTab()) + let missingID = UUID() + + switch state.createSplit( + of: missingID, + direction: .right, + initialInput: nil, + additionalEnvironment: [:], + focusing: true + ) { + case .success: + Issue.record("Expected the split to reject an unknown anchor") + case .failure(let error): + #expect(error == .anchorNotFound(missingID)) + } + } + @Test func focusTargetAfterClosingUsesNextForLeftmostLeaf() throws { let first = SplitTreeTestView() let second = SplitTreeTestView()