From 0e9dcd784356d602e1bf314cb71e061052023387 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 22 Aug 2026 11:13:29 +0900 Subject: [PATCH 1/3] feat: add deterministic pane creation --- ProwlCLI/Commands/CreateCommand.swift | 67 ++++++++++-- .../Commands/LifecycleSelectorOptions.swift | 52 ++++++++- .../Resources/cli-output-schema.json | 51 ++++++++- ProwlCLITests/CreateCommandParsingTests.swift | 42 +++++++ ProwlCLITests/ProwlCLIIntegrationTests.swift | 50 ++++++++- docs-ai/013-prowl-cli/contracts/create.md | 58 +++++++--- docs-ai/013-prowl-cli/contracts/input.md | 10 +- docs-ai/013-prowl-cli/contracts/targeting.md | 9 +- docs-ai/063-agent-workflows/000-plan.md | 2 +- .../002-cli-create-pane.md | 36 ++++++ docs/components/cli.md | 17 ++- skills/prowl-cli/SKILL.md | 24 +++- supacode/App/supacodeApp.swift | 53 +++++++++ .../CLIService/LifecycleCommandHandler.swift | 79 ++++++++++++-- supacode/CLIService/Shared/InputModels.swift | 16 ++- .../Shared/LifecycleCommandPayload.swift | 11 +- .../WorktreeTerminalState+Surfaces.swift | 103 ++++++++++-------- .../CLILifecycleCommandHandlerTests.swift | 86 +++++++++++++++ supacodeTests/SplitTreeTests.swift | 59 ++++++++++ 19 files changed, 729 insertions(+), 96 deletions(-) create mode 100644 ProwlCLITests/CreateCommandParsingTests.swift create mode 100644 docs-ai/063-agent-workflows/002-cli-create-pane.md 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() -- 2.51.2 From 1f3f98192296a9c1d5795614cc9edf24409b6da4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 22 Aug 2026 11:14:37 +0900 Subject: [PATCH 2/3] docs: link pane creation pull request --- docs-ai/063-agent-workflows/002-cli-create-pane.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs-ai/063-agent-workflows/002-cli-create-pane.md b/docs-ai/063-agent-workflows/002-cli-create-pane.md index 8c0da0f2..9ea318e1 100644 --- a/docs-ai/063-agent-workflows/002-cli-create-pane.md +++ b/docs-ai/063-agent-workflows/002-cli-create-pane.md @@ -34,3 +34,4 @@ Release R1 needs deterministic split creation for both direct CLI orchestration - Slice: 063-A1 - Branch: `feat/cli-create-pane` - Issue: #699 +- PR: #710 -- 2.51.2 From 27c57fb927e6112e9c98380b1bad96f827eafe85 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 22 Aug 2026 15:53:02 +0900 Subject: [PATCH 3/3] docs(cli): record create pane review decisions and render pane anchors Merge main's C0 amendment and renumber the A1 record to 003. State in the contract and manual that create pane selects the anchor's worktree and tab and that the anchor payload is the pre-split snapshot; record the explicit-anchor decision and the new A1b PROWL_PANE_ID slice in the 063 plan and release plan. Human output now lists the anchor and direction, covered by a socket round trip, and the handler reports CREATE_FAILED when the split cannot be made. Claude-Session: https://claude.ai/code/session_01YUytym5xEn5FKMhWjSkNkm --- ProwlCLI/Output/OutputRenderer.swift | 10 +++++-- ProwlCLITests/ProwlCLIIntegrationTests.swift | 28 +++++++++++++++++ docs-ai/013-prowl-cli/contracts/create.md | 10 +++++-- docs-ai/063-agent-workflows/000-plan.md | 3 +- .../003-cli-create-pane.md | 3 ++ docs-ai/063-agent-workflows/release-plan.md | 3 ++ docs/components/cli.md | 4 ++- .../CLILifecycleCommandHandlerTests.swift | 30 +++++++++++++++++++ 8 files changed, 85 insertions(+), 6 deletions(-) diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index ed1a1800..aa702470 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -358,8 +358,14 @@ enum OutputRenderer { return "\(verb) tab \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(tab.title.yellow)" + " \(tab.id.dim)\n \("pane:".dim) \(pane.title.green) \(pane.id.dim)" case .pane: - return "\(verb) pane \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(pane.title.green)" - + " \(pane.id.dim)" + var lines = [ + "\(verb) pane \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(pane.title.green)" + + " \(pane.id.dim)" + ] + if let anchor = payload.anchor, let direction = payload.direction { + lines.append(" \("anchor:".dim) \(anchor.pane.id.dim) \("direction:".dim) \(direction.rawValue)") + } + return lines.joined(separator: "\n") } } diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index b9303b0d..cc8b1738 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -426,6 +426,34 @@ final class ProwlCLIIntegrationTests: XCTestCase { } } + func testCreatePaneCommandRendersAnchorAndDirection() throws { + let socketPath = temporarySocketPath(suffix: "create-pane-human") + 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 (_, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["create", "pane", "p12", "--direction", "up", "--no-color"] + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertTrue(result.stdout.contains("Created pane"), result.stdout) + XCTAssertTrue(result.stdout.contains("pane-123"), result.stdout) + XCTAssertTrue(result.stdout.contains("anchor: anchor-pane"), result.stdout) + XCTAssertTrue(result.stdout.contains("direction: up"), result.stdout) + } + func testCloseCommandRoundTripsOverSocket() throws { let socketPath = temporarySocketPath(suffix: "close-pane") let response = try CommandResponse( diff --git a/docs-ai/013-prowl-cli/contracts/create.md b/docs-ai/013-prowl-cli/contracts/create.md index 3ddffe1b..aecdbe88 100644 --- a/docs-ai/013-prowl-cli/contracts/create.md +++ b/docs-ai/013-prowl-cli/contracts/create.md @@ -27,7 +27,10 @@ terminal layer's internal top direction. The operation resolves the anchor direc focuses a different pane as an intermediate targeting step. 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 it becomes the focused pane in the anchor tab, and Prowl selects the anchor's +worktree and tab exactly as `create tab` selects the target worktree — an anchor in another +tab or worktree therefore brings that tab into view. A background placement is not part of +V1; it belongs to the profile launch placement work (063-A2). ## Success: tab @@ -72,7 +75,10 @@ success it becomes the focused pane in the anchor tab, following normal tab-loca ``` `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. +`anchor` and public `direction`. `anchor` is the snapshot taken when the selector was resolved, +before the split: its `focused` / `selected` flags describe the pre-split state and may read +`true` alongside the same flags on `target`. UUID fields are the automation-safe output of this +command. ## Errors diff --git a/docs-ai/063-agent-workflows/000-plan.md b/docs-ai/063-agent-workflows/000-plan.md index cbf4b26e..731386d0 100644 --- a/docs-ai/063-agent-workflows/000-plan.md +++ b/docs-ai/063-agent-workflows/000-plan.md @@ -409,6 +409,7 @@ attaches hooks through A2's launch boundary. | --- | --- | --- | --- | | **C0** | C | — | Settings IA: `Section("Agents")` with **Profiles** (today's Agents page, renamed) and **Command Line Tool** (moved from Advanced); the Workflows page comes with D1. Independent, small; decides where everything lands. | | **A1** | A | 060 | `prowl create pane` (#699) + target-surface split primitive returning the surface id; CLI four layers. Foundation for every `launch` into a split. | +| **A1b** | A | A1 | `PROWL_PANE_ID` injected into every pane's environment (beside `PROWL_WORKTREE_PATH` / `PROWL_ROOT_PATH`), documented in `docs/components/cli.md`, and the `prowl-cli` skill's self-identification rewritten around it. Convenience identity only — trusted attribution (064 `agents signal`, `workflow done`) stays on caller-PID resolution. | | **A2** | A | A1 | Profile launch boundary (`.prompt`, placement override, anchor, background, synchronous `LaunchedSurface` result) + `prowl create tab/pane --profile --prompt -` + `prowl profiles list`; exposes the seam 064-S3 uses for launch-scoped hooks. Unlocks the CLI-driven route; the runner's `launch` boundary. | | **B1** | B | — | Definitions: Yams, `AgentWorkflow` model + validator + JSON Schema, three-source discovery, `prowl workflow list/validate/schema`. Makes the DSL concrete and authorable (no user-facing surface until R2). | | **B2** | B | B1 | Runner core (pure): run state machine incl. `repeat`, run store, template renderer, `WorkflowRequestRegistry`, action registry, watchdog with injected clock — tested against fake boundaries. | @@ -586,5 +587,5 @@ attaches hooks through A2's launch boundary. ## Amendments -- 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). - Updated 2026-08-22: Shipped C0 with the Agents sidebar group, Profiles page, and Command Line Tool page; Workflows remains deferred to D1 — see [002-settings-agents-group.md](002-settings-agents-group.md). +- Updated 2026-08-22: Implemented A1 with the direct anchored split primitive and schema-governed `prowl create pane` command — see [003-cli-create-pane.md](003-cli-create-pane.md). diff --git a/docs-ai/063-agent-workflows/003-cli-create-pane.md b/docs-ai/063-agent-workflows/003-cli-create-pane.md index 9ea318e1..19882117 100644 --- a/docs-ai/063-agent-workflows/003-cli-create-pane.md +++ b/docs-ai/063-agent-workflows/003-cli-create-pane.md @@ -18,6 +18,9 @@ Release R1 needs deterministic split creation for both direct CLI orchestration - 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. +- The anchor stays explicit (review decision 2026-08-22): no caller-pane default, so an unset shell variable fails with `INVALID_ARGUMENT` instead of silently splitting the caller's own pane. Agent self-identification is delivered separately through a per-pane `PROWL_PANE_ID` environment variable (its own R1 slice in [release-plan.md](release-plan.md)). +- Creating beside a non-visible anchor selects the anchor's worktree and tab, mirroring `create tab`; a background placement is deferred to A2's launch `placement`. +- The `anchor` payload is the pre-split resolution snapshot, so its `focused` / `selected` flags describe the state before creation. ## Verification diff --git a/docs-ai/063-agent-workflows/release-plan.md b/docs-ai/063-agent-workflows/release-plan.md index 5221d772..fc70849f 100644 --- a/docs-ai/063-agent-workflows/release-plan.md +++ b/docs-ai/063-agent-workflows/release-plan.md @@ -30,6 +30,7 @@ user-facing surface may merge before "their" release and stay dormant. Three rel | --- | --- | --- | --- | --- | | 1 | **C0** Settings IA: `Section("Agents")` with Profiles (renamed) + Command Line Tool (from Advanced); no Workflows page yet | 063 | — | CLI install lives with Agents | | 1 | **A1** `prowl create pane` (#699) + anchored split primitive | 063 | 060 | CLI can split | +| 1 | **A1b** `PROWL_PANE_ID` per-pane environment variable (joins `PROWL_WORKTREE_PATH` / `PROWL_ROOT_PATH`) + `prowl-cli` skill self-identification rewrite | 063 | A1 | agents address their own pane deterministically (`--pane "$PROWL_PANE_ID"`) instead of guessing from `focused` | | 1 | **065-S0/K1** skill-target spike; `embed-skills` + `ProwlSkills` registry | 065 | — | skills ship in the bundle; D1 prerequisite | | 2 | **A2** profile launch boundary + `create tab\|pane --profile

--prompt -` + `profiles list` | 063 | A1 | CLI launches a profile with a kickoff prompt and gets the pane back | | 2 | **S1** signal bus + `ObservedAgentState` multicast observer + `prowl agents signal` | 064 | — | layer-0 signals for every runtime | @@ -88,6 +89,8 @@ R3+: V2 / S5 rest; delete HANDOFF_RETIRED stubs ## Change log +- 2026-08-22 — A1 review: added **A1b** (`PROWL_PANE_ID`) to R1; `create pane` keeps an explicit + anchor (no caller-pane default) and a background placement stays with A2. - 2026-08-22 — first version: three releases agreed; `ObservedAgentState` observer moved from 063-B3 to 064-S1; C0 ships without the Workflows page; `prowl agents wait` owned by 064-S2. diff --git a/docs/components/cli.md b/docs/components/cli.md index c6f8cb85..a5c5c497 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -289,7 +289,9 @@ pane="$(prowl create pane "$anchor" --direction right --json | jq -r '.data.targ 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 +as `.data.target.pane.id`. Like `create tab`, the command selects the anchor's worktree and +tab, so an anchor in another tab or worktree is brought into view. `.data.anchor` records the +source pane as resolved before the split (its `focused` flag is pre-split state) 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. diff --git a/supacodeTests/CLILifecycleCommandHandlerTests.swift b/supacodeTests/CLILifecycleCommandHandlerTests.swift index 629e9790..be16cf10 100644 --- a/supacodeTests/CLILifecycleCommandHandlerTests.swift +++ b/supacodeTests/CLILifecycleCommandHandlerTests.swift @@ -127,6 +127,36 @@ struct CLILifecycleCommandHandlerTests { #expect(!didCreate) } + @Test func createPaneReportsCreateFailedWhenTheSplitCannotBeMade() async throws { + let anchor = makeTarget(tabID: "anchor-tab", paneID: "anchor-pane") + var createAttempts = 0 + let handler = LifecycleCommandHandler( + resolveCreateTarget: { _ in .success(anchor) }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: anchor)) }, + createTab: { _, _ in nil }, + createPane: { _, _ in + createAttempts += 1 + return nil + }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput(resource: .pane, selector: .pane("p12"), direction: .down) + ) + ) + ) + + #expect(!response.ok) + #expect(response.error?.code == CLIErrorCode.createFailed) + #expect(createAttempts == 1) + #expect(response.data == nil) + } + @Test func closeUsesResolvedResourceAndReturnsClosePayload() async throws { let target = makeTarget(tabID: "tab-to-close", paneID: "pane-to-close") var closedPane: TabResolvedTarget? -- 2.51.2