diff --git a/AGENTS.md b/AGENTS.md index 5681ae2..798675b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ A tab's auto-title is, by default, the pane's live **foreground process name** ( ### Control CLI (`macterm`) -A bundled CLI (issue #107) controls the running app over a Unix socket at `/control.sock` (per build flavor; `MACTERM_BENCHMARK_DATA_DIR` relocates it with the rest of app data). Built as the `MactermCLI` tool target — `PRODUCT_NAME` is `macterm` but `PRODUCT_MODULE_NAME` must stay `MactermCLI` (a `macterm` module would collide case-insensitively with the app's `Macterm.swiftmodule` in the shared products dir). A post-build script copies it to `Contents/Resources/bin/macterm`; the app prepends that dir to `PATH` and exports `MACTERM_SOCKET` before any shell spawns, so `macterm` works inside every pane. +A bundled CLI (issue #107) controls the running app over a Unix socket at `/control.sock` (per build flavor; `MACTERM_BENCHMARK_DATA_DIR` relocates it with the rest of app data). Full grammar + protocol reference in `docs/cli.md`. Built as the `MactermCLI` tool target — `PRODUCT_NAME` is `macterm` but `PRODUCT_MODULE_NAME` must stay `MactermCLI` (a `macterm` module would collide case-insensitively with the app's `Macterm.swiftmodule` in the shared products dir). A post-build script copies it to `Contents/Resources/bin/macterm`; the app prepends that dir to `PATH` and exports `MACTERM_SOCKET` before any shell spawns, so `macterm` works inside every pane. Verbs: `status`; `project list/create/select`; `tab list/new/select/close`; `pane list/split/focus/close/run`; `grid RxC`; `session list/info/kill`; `layout apply/save`. New panes/tabs take `--run CMD` (spawn-time `initial_input`, the layout `run:` path); `pane run` types into an *existing* live shell via `GhosttyTerminalNSView.sendText` (the paste path — `ghostty_surface_key` with `.text`). Every pane's env carries `MACTERM_SESSION` (its restart-stable session name) so a bare `macterm pane split` inside a pane self-targets; close verbs demand explicit targets and return a typed `busy` error instead of staging UI confirmation dialogs (headless callers can't click). - **Wire protocol** (`ControlProtocol.swift`, compiled into both targets so the codec can't drift): one request per connection; newline-terminated JSON both ways; client half-closes after sending. Requests are `{v, id, command, args}` with `command` namespaced `noun.verb` (`pane.list`); responses echo `id` with `{ok, data}` or `{ok:false, error:{code, message, action?}}` — `action` is a human recovery hint. Errors use snake_case codes (`not_found`, `busy`, `no_surface`, `starting`…). - **Server** (`ControlSocketServer`): dedicated thread polling a non-blocking listen fd (20ms) — never a blocking `accept()` on a queue shared with `stop()`, which would starve shutdown. Per-connection dispatch hops to `@MainActor`. Starts in `applicationDidFinishLaunching` (skipped under xctest); the handler attaches later in `installResponders` when AppState exists — early requests get a `starting` error. `FD_CLOEXEC` on the listen fd so spawned shells can't hold the socket past quit. diff --git a/CLI/MactermCommand.swift b/CLI/MactermCommand.swift index 3240f82..d8db83c 100644 --- a/CLI/MactermCommand.swift +++ b/CLI/MactermCommand.swift @@ -11,7 +11,15 @@ struct MactermCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "macterm", abstract: "Control a running Macterm: projects, tabs, panes, zmx sessions.", - subcommands: [Status.self, ProjectCommand.self, TabCommand.self, PaneCommand.self, SessionCommand.self] + subcommands: [ + Status.self, + ProjectCommand.self, + TabCommand.self, + PaneCommand.self, + Grid.self, + SessionCommand.self, + LayoutCommand.self, + ] ) } @@ -43,7 +51,41 @@ func runControlCommand(command: String, args: ControlArgs? = nil, options: Conne throw ExitCode(1) } -// MARK: - Subcommands +/// The pane self-address the app injects into every pane's shell. Used as the +/// implicit target when a pane verb names no explicit one (Zentty-style +/// "current pane" context) — explicit selectors always win. +func sessionFromEnvironment() -> String? { + let value = ProcessInfo.processInfo.environment[ControlProtocol.sessionEnvVar] + return (value?.isEmpty ?? true) ? nil : value +} + +/// Shared pane-target options: `--session`/`--pane` are explicit; inside a +/// Macterm pane, `MACTERM_SESSION` fills in when neither is given (nor a tab +/// scope — an explicit tab means "that tab's focused pane", not self). +struct PaneTarget: ParsableArguments { + @Option(help: "Project scope (name, UUID, or index). Defaults to the active project.") + var project: String? + + @Option(help: "Tab scope (title, UUID, or index).") + var tab: String? + + @Option(help: "Target pane by UUID, or index within the tab (pane:2).") + var pane: String? + + @Option(help: "Target pane by zmx session name (restart-stable).") + var session: String? + + func controlArgs() -> ControlArgs { + ControlArgs( + project: project, + tab: tab, + pane: pane, + session: session ?? (pane == nil && tab == nil ? sessionFromEnvironment() : nil) + ) + } +} + +// MARK: - Status struct Status: ParsableCommand { static let configuration = CommandConfiguration( @@ -57,11 +99,13 @@ struct Status: ParsableCommand { } } +// MARK: - Project + struct ProjectCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "project", - abstract: "List and inspect projects.", - subcommands: [List.self], + abstract: "List, create, and select projects.", + subcommands: [List.self, Create.self, Select.self], defaultSubcommand: List.self ) @@ -74,13 +118,53 @@ struct ProjectCommand: ParsableCommand { try runControlCommand(command: "project.list", options: options) } } + + struct Create: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Add a project for a local directory (idempotent by path)." + ) + + @Argument(help: "Project directory (absolute or ~-prefixed).") + var path: String + + @Option(help: "Display name. Defaults to the directory name.") + var name: String? + + @Flag(help: "Also select it (applies a matching project file's layout on first open).") + var select = false + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand( + command: "project.create", + args: ControlArgs(path: path, name: name, select: select), + options: options + ) + } + } + + struct Select: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Make a project active.") + + @Argument(help: "Project name, UUID, or index.") + var project: String + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "project.select", args: ControlArgs(project: project), options: options) + } + } } +// MARK: - Tab + struct TabCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "tab", - abstract: "List and inspect tabs.", - subcommands: [List.self], + abstract: "List, create, select, and close tabs.", + subcommands: [List.self, New.self, Select.self, Close.self], defaultSubcommand: List.self ) @@ -96,13 +180,80 @@ struct TabCommand: ParsableCommand { try runControlCommand(command: "tab.list", args: ControlArgs(project: project), options: options) } } + + struct New: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Open a new tab (becomes active).") + + @Option(help: "Project (name, UUID, or index). Defaults to the active project.") + var project: String? + + @Option(name: .customLong("run"), help: "Command to run in the new tab's shell.") + var runCommand: String? + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand( + command: "tab.new", + args: ControlArgs(project: project, run: runCommand), + options: options + ) + } + } + + struct Select: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Activate a tab.") + + @Argument(help: "Tab title, UUID, or index (tab:3).") + var tab: String + + @Option(help: "Project scope. Defaults to the active project.") + var project: String? + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand( + command: "tab.select", + args: ControlArgs(project: project, tab: tab), + options: options + ) + } + } + + struct Close: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Close a tab (kills its panes' zmx sessions)." + ) + + @Argument(help: "Tab title, UUID, or index (tab:3).") + var tab: String + + @Option(help: "Project scope. Defaults to the active project.") + var project: String? + + @Flag(help: "Close even if a pane has a running program.") + var force = false + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand( + command: "tab.close", + args: ControlArgs(project: project, tab: tab, force: force), + options: options + ) + } + } } +// MARK: - Pane + struct PaneCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "pane", - abstract: "List and inspect panes.", - subcommands: [List.self], + abstract: "List, split, focus, close panes, and run commands in them.", + subcommands: [List.self, Split.self, Focus.self, Close.self, Run.self], defaultSubcommand: List.self ) @@ -125,13 +276,137 @@ struct PaneCommand: ParsableCommand { ) } } + + struct Split: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Split a pane. Defaults to the focused pane (or the pane you're in)." + ) + + @Option(help: "right, down, or auto (longer on-screen axis).") + var direction: String = "auto" + + @Option(name: .customLong("run"), help: "Command to run in the new pane's shell.") + var runCommand: String? + + @OptionGroup var target: PaneTarget + @OptionGroup var options: ConnectionOptions + + func run() throws { + var args = target.controlArgs() + args.direction = direction + args.run = runCommand + try runControlCommand(command: "pane.split", args: args, options: options) + } + } + + struct Focus: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Focus a pane (selects its tab and fronts the window)." + ) + + @OptionGroup var target: PaneTarget + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "pane.focus", args: target.controlArgs(), options: options) + } + } + + struct Close: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Close a pane (kills its zmx session)." + ) + + @OptionGroup var target: PaneTarget + + @Flag(help: "Close even if a program is running.") + var force = false + + @OptionGroup var options: ConnectionOptions + + func run() throws { + var args = ControlArgs( + project: target.project, + tab: target.tab, + pane: target.pane, + // Deliberately NOT defaulted from MACTERM_SESSION: closing + // "whatever pane I'm in" because no target was given is a + // destructive surprise. Explicit targets only. + session: target.session + ) + args.force = force + guard args.pane != nil || args.session != nil else { + Output.printError( + "pane close requires --pane or --session", + action: "run `macterm pane list` for targets" + ) + throw ExitCode(1) + } + try runControlCommand(command: "pane.close", args: args, options: options) + } + } + + struct Run: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Type a command into a live pane's shell (adds a newline)." + ) + + @Argument(parsing: .captureForPassthrough, help: "The command line to run.") + var command: [String] + + @OptionGroup var target: PaneTarget + @OptionGroup var options: ConnectionOptions + + func run() throws { + let line = command.joined(separator: " ") + guard !line.isEmpty else { + Output.printError("nothing to run") + throw ExitCode(1) + } + var args = target.controlArgs() + args.run = line + try runControlCommand(command: "pane.run", args: args, options: options) + } + } } +// MARK: - Grid + +struct Grid: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Split a pane into an equal ROWSxCOLS grid." + ) + + @Argument(help: "Grid shape, e.g. 2x2 or 3x1.") + var shape: String + + @Option(name: .customLong("run"), help: "Command to run in each NEW pane (the source pane keeps its shell).") + var runCommand: String? + + @OptionGroup var target: PaneTarget + @OptionGroup var options: ConnectionOptions + + func run() throws { + let parts = shape.lowercased().split(separator: "x") + guard parts.count == 2, let rows = Int(parts[0]), let cols = Int(parts[1]) else { + Output.printError("grid shape must look like 2x2") + throw ExitCode(1) + } + var args = target.controlArgs() + args.rows = rows + args.cols = cols + args.run = runCommand + try runControlCommand(command: "grid", args: args, options: options) + } +} + +// MARK: - Session + struct SessionCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "session", - abstract: "Inspect zmx-backed terminal sessions.", - subcommands: [List.self, Info.self], + abstract: "Inspect and kill zmx-backed terminal sessions.", + subcommands: [List.self, Info.self, Kill.self], defaultSubcommand: List.self ) @@ -157,4 +432,66 @@ struct SessionCommand: ParsableCommand { try runControlCommand(command: "session.info", args: ControlArgs(session: name), options: options) } } + + struct Kill: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Kill a zmx session (its shell dies; an attached pane's shell exits)." + ) + + @Argument(help: "Session name (macterm--).") + var name: String + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "session.kill", args: ControlArgs(session: name), options: options) + } + } +} + +// MARK: - Layout + +struct LayoutCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "layout", + abstract: "Apply or save a project's declarative layout file.", + subcommands: [Apply.self, Save.self] + ) + + struct Apply: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Reconcile the workspace to the project's central layout file." + ) + + @Option(help: "Project (name, UUID, or index). Defaults to the active project.") + var project: String? + + @Flag(help: "Apply even when it would close panes.") + var force = false + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand( + command: "layout.apply", + args: ControlArgs(project: project, force: force), + options: options + ) + } + } + + struct Save: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Save the live workspace as the project's layout file." + ) + + @Option(help: "Project (name, UUID, or index). Defaults to the active project.") + var project: String? + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "layout.save", args: ControlArgs(project: project), options: options) + } + } } diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index ca73bbf..6dc15e3 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -665,11 +665,16 @@ final class AppState { // MARK: - Tabs - func createTab(projectID: UUID, projectPath: String) { - guard let ws = workspaces[projectID] else { return } - ws.createTab(projectPath: projectPath) + /// A `command` spawns in the new tab's pane via `initial_input` (layout + /// `run:` semantics). Returns the new tab's ID, nil when the project has + /// no live workspace. + @discardableResult + func createTab(projectID: UUID, projectPath: String, command: String? = nil) -> UUID? { + guard let ws = workspaces[projectID] else { return nil } + let tab = ws.createTab(projectPath: projectPath, command: command) logger.debug("createTab: project=\(projectID, privacy: .public) tabs=\(ws.tabs.count, privacy: .public)") saveWorkspaces() + return tab.id } /// Convenience overload: look up the project's canonical path from the @@ -842,6 +847,44 @@ final class AppState { saveWorkspaces() } + /// Split a SPECIFIC pane — found in whichever of the project's tabs holds + /// it, unlike the focused-pane overload above — optionally spawning + /// `command` in the new pane. The control CLI's split path. Returns the + /// new pane's ID. + @discardableResult + func splitPane( + _ paneID: UUID, + direction: SplitDirection, + projectID: UUID, + command: String? = nil + ) -> UUID? { + guard let ws = workspaces[projectID], + let tab = ws.tabs.first(where: { $0.splitRoot.findPane(id: paneID) != nil }) + else { return nil } + let newID = tab.split(paneID: paneID, direction: direction, command: command) + saveWorkspaces() + return newID + } + + /// Split a pane into an equal `rows`×`columns` grid (see + /// `TerminalTab.makeGrid`), spawning `command` in each new pane. Returns + /// the new pane IDs. + @discardableResult + func makeGrid( + _ paneID: UUID, + rows: Int, + columns: Int, + projectID: UUID, + command: String? = nil + ) -> [UUID] { + guard let ws = workspaces[projectID], + let tab = ws.tabs.first(where: { $0.splitRoot.findPane(id: paneID) != nil }) + else { return [] } + let created = tab.makeGrid(paneID: paneID, rows: rows, columns: columns, command: command) + if !created.isEmpty { saveWorkspaces() } + return created + } + /// Split the focused pane along its longer on-screen axis (Ghostty's /// `new_split` / BSP behavior). Direction is decided by `TerminalTab.autoSplit`. func autoSplitPane(projectID: UUID) { diff --git a/Macterm/Control/ControlHandler.swift b/Macterm/Control/ControlHandler.swift index acd427c..14a8006 100644 --- a/Macterm/Control/ControlHandler.swift +++ b/Macterm/Control/ControlHandler.swift @@ -57,10 +57,23 @@ final class ControlHandler { switch request.command { case "status": return status() case "project.list": return projectList() + case "project.create": return try projectCreate(args) + case "project.select": return try projectSelect(args) case "tab.list": return try tabList(args) + case "tab.new": return try tabNew(args) + case "tab.select": return try tabSelect(args) + case "tab.close": return try tabClose(args) case "pane.list": return try paneList(args) + case "pane.split": return try paneSplit(args) + case "pane.focus": return try paneFocus(args) + case "pane.close": return try paneClose(args) + case "pane.run": return try paneRun(args) + case "grid": return try grid(args) case "session.list": return try await sessionList() case "session.info": return try await sessionInfo(args) + case "session.kill": return try await sessionKill(args) + case "layout.apply": return try layoutApply(args) + case "layout.save": return try layoutSave(args) default: throw ControlError( code: .unknownCommand, @@ -115,21 +128,8 @@ final class ControlHandler { } else { tabs = Array(zip(1..., workspace.tabs)) } - var infos: [ControlPaneInfo] = [] - for (tabIndex, tab) in tabs { - for (paneIndex, pane) in zip(1..., tab.splitRoot.allPanes()) { - infos.append(ControlPaneInfo( - index: paneIndex, - id: pane.id.uuidString, - session: pane.sessionName, - tabIndex: tabIndex, - tabID: tab.id.uuidString, - title: pane.displayTitle, - process: pane.foregroundProcessName, - cwd: pane.nsView?.currentPwd ?? pane.projectPath, - focused: tab.id == workspace.activeTabID && pane.id == tab.focusedPaneID - )) - } + let infos = tabs.flatMap { _, tab in + tab.splitRoot.allPanes().map { paneInfo($0, in: tab, workspace: workspace) } } return ControlData(panes: infos) } @@ -170,6 +170,243 @@ final class ControlHandler { return ControlData(sessions: [match]) } + // MARK: - Project mutations + + /// Create (or find) a project for a local path. Idempotent by canonical + /// path — re-creating an existing project returns it instead of erroring, + /// so scripted setups (the benchmark) can run unconditionally. + private func projectCreate(_ args: ControlArgs) throws -> ControlData { + guard let rawPath = args.path, !rawPath.isEmpty else { + throw ControlError(code: .badRequest, message: "project.create requires a path") + } + guard let parsed = ProjectPath.parse(rawPath) else { + throw ControlError(code: .badRequest, message: "\"\(rawPath)\" is not an absolute or ~-prefixed path") + } + guard case .local = parsed else { + throw ControlError( + code: .badRequest, + message: "remote projects aren't supported yet (#104)", + action: "pass a local directory path" + ) + } + let canonical = ProjectPath.canonicalLocal(rawPath) + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: canonical, isDirectory: &isDirectory), isDirectory.boolValue else { + throw ControlError(code: .notFound, message: "no directory at \(canonical)") + } + + let project: Project + if let existing = projectStore.projects.first(where: { ProjectPath.canonicalLocal($0.path) == canonical }) { + project = existing + } else { + let name = args.name ?? (canonical as NSString).lastPathComponent + project = Project(name: name, path: canonical, sortOrder: projectStore.projects.count) + projectStore.add(project) + } + if args.select == true { + // selectProject runs the same first-open path the sidebar does — + // including auto-applying a matching central project file, so a + // declared layout spawns its tabs. + appState.selectProject(project) + } + return projectData(project) + } + + private func projectSelect(_ args: ControlArgs) throws -> ControlData { + guard args.project != nil else { + throw ControlError(code: .badRequest, message: "project.select requires a project selector") + } + let project = try resolveProject(args.project) + appState.selectProject(project) + return projectData(project) + } + + // MARK: - Tab mutations + + private func tabNew(_ args: ControlArgs) throws -> ControlData { + let (project, workspace) = try resolveWorkspace(args) + guard let tabID = appState.createTab(projectID: project.id, projectPath: project.path, command: args.run), + let index = workspace.tabs.firstIndex(where: { $0.id == tabID }) + else { + throw ControlError(code: .internalError, message: "tab creation failed") + } + return ControlData(tabs: [tabInfo(workspace.tabs[index], index: index + 1, in: workspace)]) + } + + private func tabSelect(_ args: ControlArgs) throws -> ControlData { + guard args.tab != nil else { + throw ControlError(code: .badRequest, message: "tab.select requires a tab selector") + } + let (project, workspace) = try resolveWorkspace(args) + let (index, tab) = try resolveTab(args, in: workspace) + appState.selectTab(tab.id, projectID: project.id) + return ControlData(tabs: [tabInfo(tab, index: index, in: workspace)]) + } + + private func tabClose(_ args: ControlArgs) throws -> ControlData { + guard args.tab != nil else { + throw ControlError(code: .badRequest, message: "tab.close requires a tab selector") + } + let (project, workspace) = try resolveWorkspace(args) + let (_, tab) = try resolveTab(args, in: workspace) + // Closing kills the panes' zmx sessions. The UI stages a confirmation + // dialog for busy tabs; a headless caller gets a typed `busy` error + // instead — never a dialog the CLI can't answer. + let busy = tab.splitRoot.allPanes().contains { $0.nsView?.needsConfirmQuit() == true } + if busy, args.force != true { + throw ControlError( + code: .busy, + message: "a pane in that tab has a running program (closing kills its session)", + action: "re-run with --force to close anyway" + ) + } + appState.closeTab(tab.id, projectID: project.id) + return ControlData() + } + + // MARK: - Pane mutations + + private func paneSplit(_ args: ControlArgs) throws -> ControlData { + let (project, workspace) = try resolveWorkspace(args) + let target = try resolvePane(args, in: workspace) + let direction: SplitDirection + switch args.direction ?? "auto" { + case "right": direction = .horizontal + case "down": direction = .vertical + case "auto": + // The UI's auto-split picks the longer on-screen axis from the + // pane's live NSView bounds; a never-shown pane measures zero and + // falls back to horizontal — same as TerminalTab.autoSplit. + let bounds = target.pane.nsView?.bounds.size ?? .zero + direction = bounds.height > bounds.width ? .vertical : .horizontal + default: + throw ControlError(code: .badRequest, message: "direction must be right, down, or auto") + } + guard let newID = appState.splitPane( + target.pane.id, direction: direction, projectID: project.id, command: args.run + ), let newPane = target.tab.splitRoot.findPane(id: newID) + else { + throw ControlError(code: .internalError, message: "split failed") + } + return ControlData(panes: [paneInfo(newPane, in: target.tab, workspace: workspace)]) + } + + private func paneFocus(_ args: ControlArgs) throws -> ControlData { + let (project, workspace) = try resolveWorkspace(args) + let target = try resolvePane(args, in: workspace) + // navigateToPane selects the containing tab, fronts the window, and + // restores first responder — everything "focus" means for a human. + appState.navigateToPane(target.pane.id, projectID: project.id) + return ControlData(panes: [paneInfo(target.pane, in: target.tab, workspace: workspace)]) + } + + private func paneClose(_ args: ControlArgs) throws -> ControlData { + let (project, workspace) = try resolveWorkspace(args) + guard args.pane != nil || args.session != nil else { + throw ControlError(code: .badRequest, message: "pane.close requires a pane or session selector") + } + let target = try resolvePane(args, in: workspace) + let busy = target.pane.nsView?.needsConfirmQuit() == true + if busy, args.force != true { + throw ControlError( + code: .busy, + message: "that pane has a running program (closing kills its session)", + action: "re-run with --force to close anyway" + ) + } + appState.closePane(target.pane.id, projectID: project.id) + return ControlData() + } + + private func paneRun(_ args: ControlArgs) throws -> ControlData { + guard let command = args.run, !command.isEmpty else { + throw ControlError(code: .badRequest, message: "pane.run requires a command") + } + let (_, workspace) = try resolveWorkspace(args) + let target = try resolvePane(args, in: workspace) + guard let view = target.pane.nsView, view.sendText(command + "\n") else { + throw ControlError( + code: .noSurface, + message: "the pane's terminal isn't live yet", + action: "select its tab once so the surface spawns, then retry" + ) + } + return ControlData(panes: [paneInfo(target.pane, in: target.tab, workspace: workspace)]) + } + + private func grid(_ args: ControlArgs) throws -> ControlData { + guard let rows = args.rows, let cols = args.cols, rows >= 1, cols >= 1, rows * cols > 1 else { + throw ControlError(code: .badRequest, message: "grid requires rows×cols with at least 2 cells") + } + let cellCap = 16 + guard rows * cols <= cellCap else { + throw ControlError(code: .badRequest, message: "grid caps at \(cellCap) cells") + } + let (project, workspace) = try resolveWorkspace(args) + let target = try resolvePane(args, in: workspace) + let created = appState.makeGrid( + target.pane.id, rows: rows, columns: cols, projectID: project.id, command: args.run + ) + guard !created.isEmpty else { + throw ControlError(code: .internalError, message: "grid produced no panes") + } + let infos = created.compactMap { id in + target.tab.splitRoot.findPane(id: id).map { paneInfo($0, in: target.tab, workspace: workspace) } + } + return ControlData(panes: infos) + } + + // MARK: - Session / layout mutations + + private func sessionKill(_ args: ControlArgs) async throws -> ControlData { + guard let name = args.session, !name.isEmpty else { + throw ControlError(code: .badRequest, message: "session.kill requires a session name") + } + guard let entries = await zmx.listSessionsWithClients() else { + throw ControlError(code: .internalError, message: "zmx session listing unavailable") + } + guard entries.contains(where: { $0.name == name }) else { + throw ControlError( + code: .notFound, + message: "no zmx session named \"\(name)\"", + action: "run `macterm session list` to see live sessions" + ) + } + await zmx.killSession(name) + return ControlData() + } + + private func layoutApply(_ args: ControlArgs) throws -> ControlData { + let project = try resolveProject(args.project) + if let error = appState.applyLayout(project: project) { + throw ControlError(code: .notFound, message: error.localizedDescription) + } + // A destructive reconcile is staged for UI confirmation; headless + // callers either force it through or get a typed `busy` — the staged + // dialog must never dangle waiting for a click that won't come. + if appState.pendingLayoutApply != nil { + if args.force == true { + appState.confirmPendingLayoutApply() + } else { + appState.cancelPendingLayoutApply() + throw ControlError( + code: .busy, + message: "applying would close panes and end their processes", + action: "re-run with --force to apply anyway" + ) + } + } + return ControlData() + } + + private func layoutSave(_ args: ControlArgs) throws -> ControlData { + let project = try resolveProject(args.project) + if let error = appState.saveLayout(project: project) { + throw ControlError(code: .internalError, message: error.localizedDescription) + } + return ControlData() + } + // MARK: - Selector resolution /// Resolve the project selector (name, UUID, or 1-based list index) to a @@ -267,20 +504,101 @@ final class ControlHandler { return value } + /// Resolve the pane target: `session` (restart-stable name, searched + /// across the whole workspace), `pane` (UUID anywhere, or `pane:N` index + /// within the resolved tab), else the focused pane of the active tab. + /// `session` and `pane` together conflict — an explicit error, never a + /// silent winner. + private func resolvePane(_ args: ControlArgs, in workspace: Workspace) throws -> (tab: TerminalTab, pane: Pane) { + if args.session != nil, args.pane != nil { + throw ControlError(code: .badRequest, message: "pass either --session or --pane, not both") + } + if let session = args.session, !session.isEmpty { + for tab in workspace.tabs { + if let pane = tab.splitRoot.allPanes().first(where: { $0.sessionName == session }) { + return (tab, pane) + } + } + throw ControlError( + code: .notFound, + message: "no pane in this project runs session \"\(session)\"", + action: "run `macterm pane list` for live panes" + ) + } + if let selector = args.pane, !selector.isEmpty { + if let id = UUID(uuidString: selector) { + for tab in workspace.tabs { + if let pane = tab.splitRoot.findPane(id: id) { return (tab, pane) } + } + throw ControlError(code: .notFound, message: "no pane with id \(selector)") + } + let (_, tab) = try resolveTab(args, in: workspace) + let panes = tab.splitRoot.allPanes() + guard let index = parseIndex(selector, prefix: "pane"), panes.indices.contains(index - 1) else { + throw ControlError( + code: .notFound, + message: "no pane \(selector) in that tab", + action: "run `macterm pane list` for indexes" + ) + } + return (tab, panes[index - 1]) + } + // No pane selector: an explicit tab selector means "that tab's + // focused pane"; otherwise the active tab's. + let (_, tab) = try resolveTab(args, in: workspace) + guard let focusedID = tab.focusedPaneID, let pane = tab.splitRoot.findPane(id: focusedID) else { + throw ControlError(code: .notFound, message: "the tab has no focused pane") + } + return (tab, pane) + } + // MARK: - Shared projections + private func projectData(_ project: Project) -> ControlData { + let info = ControlProjectInfo( + id: project.id.uuidString, + name: project.name, + path: project.path, + active: project.id == appState.activeProjectID, + loaded: appState.workspaces[project.id] != nil, + tabCount: appState.workspaces[project.id]?.tabs.count + ) + return ControlData(projects: [info]) + } + + private func tabInfo(_ tab: TerminalTab, index: Int, in workspace: Workspace) -> ControlTabInfo { + ControlTabInfo( + index: index, + id: tab.id.uuidString, + title: tab.sidebarTitle, + active: tab.id == workspace.activeTabID, + paneCount: tab.splitRoot.allPanes().count + ) + } + private func tabInfos(in workspace: Workspace) -> [ControlTabInfo] { zip(1..., workspace.tabs).map { index, tab in - ControlTabInfo( - index: index, - id: tab.id.uuidString, - title: tab.sidebarTitle, - active: tab.id == workspace.activeTabID, - paneCount: tab.splitRoot.allPanes().count - ) + tabInfo(tab, index: index, in: workspace) } } + private func paneInfo(_ pane: Pane, in tab: TerminalTab, workspace: Workspace) -> ControlPaneInfo { + let panes = tab.splitRoot.allPanes() + let paneIndex = (panes.firstIndex(where: { $0.id == pane.id }) ?? 0) + 1 + let tabIndex = (workspace.tabs.firstIndex(where: { $0.id == tab.id }) ?? 0) + 1 + return ControlPaneInfo( + index: paneIndex, + id: pane.id.uuidString, + session: pane.sessionName, + tabIndex: tabIndex, + tabID: tab.id.uuidString, + title: pane.displayTitle, + process: pane.foregroundProcessName, + cwd: pane.nsView?.currentPwd ?? pane.projectPath, + focused: tab.id == workspace.activeTabID && pane.id == tab.focusedPaneID + ) + } + private func paneIDsBySessionName() -> [String: String] { var map: [String: String] = [:] for workspace in appState.workspaces.values { diff --git a/Macterm/Control/ControlProtocol.swift b/Macterm/Control/ControlProtocol.swift index 6f19486..87a0bbd 100644 --- a/Macterm/Control/ControlProtocol.swift +++ b/Macterm/Control/ControlProtocol.swift @@ -65,17 +65,51 @@ struct ControlArgs: Codable, Equatable { /// zmx session name (`macterm--`) — the restart-stable pane /// address. var session: String? + /// Filesystem path (`project.create`). + var path: String? + /// Display name (`project.create`). + var name: String? + /// Also select/activate what was created (`project.create`). + var select: Bool? + /// Command to run: spawned via `initial_input` in new panes + /// (`tab.new`, `pane.split`, `grid`), typed into the live shell for + /// `pane.run`. + var run: String? + /// Split direction: `right`, `down`, or `auto`. + var direction: String? + /// Skip the busy-confirmation and destructive-plan guards + /// (`tab.close`, `pane.close`, `layout.apply`). + var force: Bool? + /// Grid shape (`grid`). + var rows: Int? + var cols: Int? init( project: String? = nil, tab: String? = nil, pane: String? = nil, - session: String? = nil + session: String? = nil, + path: String? = nil, + name: String? = nil, + select: Bool? = nil, + run: String? = nil, + direction: String? = nil, + force: Bool? = nil, + rows: Int? = nil, + cols: Int? = nil ) { self.project = project self.tab = tab self.pane = pane self.session = session + self.path = path + self.name = name + self.select = select + self.run = run + self.direction = direction + self.force = force + self.rows = rows + self.cols = cols } } diff --git a/Macterm/Model/SplitNode.swift b/Macterm/Model/SplitNode.swift index 8036fcb..2f5058d 100644 --- a/Macterm/Model/SplitNode.swift +++ b/Macterm/Model/SplitNode.swift @@ -469,12 +469,21 @@ final class Pane: Identifiable { func ensureNSView() -> GhosttyTerminalNSView { if let existing = _nsView { return existing } + // Every pane's shell learns its own restart-stable address so + // `macterm` invoked inside it can self-target (`MACTERM_SESSION`). + // Injected at spawn, which means a zmx-reattached shell keeps the + // value from its original spawn — correct, because the session name + // is persisted verbatim and survives restarts (pane UUIDs don't). + // Our value wins over a layout-declared duplicate: this is identity, + // not configuration. + var mergedEnv = env ?? [:] + mergedEnv[ControlProtocol.sessionEnvVar] = sessionName let view = GhosttyTerminalNSView( workingDirectory: projectPath, sessionName: sessionName, command: command, shell: shell, - env: env + env: mergedEnv ) _nsView = view return view @@ -657,13 +666,16 @@ extension SplitNode { direction: SplitDirection, position: SplitPosition, projectPath: String, - projectID: UUID + projectID: UUID, + command: String? = nil ) -> (node: SplitNode, newPaneID: UUID?) { switch self { case let .pane(p) where p.id == paneID: // Inherit the source pane's session slug so the new sibling groups // under the same project in `zmx ls`. - let newPane = Pane(projectPath: projectPath, projectID: projectID, sessionSlug: p.sessionSlug) + let newPane = Pane( + projectPath: projectPath, projectID: projectID, sessionSlug: p.sessionSlug, command: command + ) let first: SplitNode = position == .first ? .pane(newPane) : .pane(p) let second: SplitNode = position == .first ? .pane(p) : .pane(newPane) return (.split(SplitBranch(direction: direction, first: first, second: second)), newPane.id) @@ -675,7 +687,8 @@ extension SplitNode { direction: direction, position: position, projectPath: projectPath, - projectID: projectID + projectID: projectID, + command: command ) branch.first = newFirst if id1 != nil { return (.split(branch), id1) } @@ -684,7 +697,8 @@ extension SplitNode { direction: direction, position: position, projectPath: projectPath, - projectID: projectID + projectID: projectID, + command: command ) branch.second = newSecond return (.split(branch), id2) diff --git a/Macterm/Model/Workspace.swift b/Macterm/Model/Workspace.swift index 2360c06..b8cb23e 100644 --- a/Macterm/Model/Workspace.swift +++ b/Macterm/Model/Workspace.swift @@ -64,9 +64,9 @@ final class TerminalTab: Identifiable { return didAcknowledge } - init(projectPath: String, projectID: UUID, sessionSlug: String? = nil) { + init(projectPath: String, projectID: UUID, sessionSlug: String? = nil, command: String? = nil) { id = UUID() - let pane = Pane(projectPath: projectPath, projectID: projectID, sessionSlug: sessionSlug) + let pane = Pane(projectPath: projectPath, projectID: projectID, sessionSlug: sessionSlug, command: command) splitRoot = .pane(pane) focusedPaneID = pane.id } @@ -100,8 +100,10 @@ final class TerminalTab: Identifiable { /// Split the focused pane (or a specific pane) in `direction`, placing the /// new pane in the `.second` position. Returns the new pane ID if created. + /// A `command` spawns in the new pane via libghostty's `initial_input` + /// (the layout `run:` path — typed into the fresh shell verbatim). @discardableResult - func split(paneID: UUID, direction: SplitDirection) -> UUID? { + func split(paneID: UUID, direction: SplitDirection, command: String? = nil) -> UUID? { let pane = splitRoot.findPane(id: paneID) // Inherit the source pane's cwd. Prefer the shell's OSC 7-reported pwd // (most accurate when shell integration is active), then fall back to @@ -112,7 +114,12 @@ final class TerminalTab: Identifiable { let sourcePath = livePwd ?? pane?.projectPath ?? NSHomeDirectory() let sourceProjectID = pane?.projectID ?? UUID() let (newRoot, newID) = splitRoot.splitting( - paneID: paneID, direction: direction, position: .second, projectPath: sourcePath, projectID: sourceProjectID + paneID: paneID, + direction: direction, + position: .second, + projectPath: sourcePath, + projectID: sourceProjectID, + command: command ) splitRoot = newRoot // Splitting reveals a new pane — exit zoom so it's visible. @@ -122,6 +129,42 @@ final class TerminalTab: Identifiable { return newID } + /// Split a pane into a `rows`×`columns` grid of equal cells (row-major), + /// optionally spawning `command` in every NEW pane. The source pane + /// becomes the top-left cell and keeps its running shell — a command + /// can only be injected at spawn (`initial_input`), so the caller runs + /// text into it separately if needed. Returns the new pane IDs. + @discardableResult + func makeGrid(paneID: UUID, rows: Int, columns: Int, command: String? = nil) -> [UUID] { + guard rows >= 1, columns >= 1, rows * columns > 1, + splitRoot.findPane(id: paneID) != nil + else { return [] } + var created: [UUID] = [] + // Build the row column first (splitting the newest pane each time + // keeps top-to-bottom order), then widen each row left-to-right. + var rowHeads = [paneID] + for _ in 1 ..< rows { + guard let previous = rowHeads.last, + let newID = split(paneID: previous, direction: .vertical, command: command) + else { break } + rowHeads.append(newID) + created.append(newID) + } + for head in rowHeads { + var current = head + for _ in 1 ..< columns { + guard let newID = split(paneID: current, direction: .horizontal, command: command) else { break } + created.append(newID) + current = newID + } + } + // Equal cells regardless of the auto-tiling preference — a grid is + // an explicit request for uniformity. + splitRoot = splitRoot.rebalanced() + focusPane(paneID) + return created + } + /// Split the focused pane along its longer on-screen axis (Ghostty's /// `new_split` / BSP behavior): a wide pane splits left/right, a tall pane /// splits top/bottom. Falls back to a horizontal split when the focused @@ -236,8 +279,8 @@ final class Workspace: Identifiable { } @discardableResult - func createTab(projectPath: String) -> TerminalTab { - let tab = TerminalTab(projectPath: projectPath, projectID: projectID) + func createTab(projectPath: String, command: String? = nil) -> TerminalTab { + let tab = TerminalTab(projectPath: projectPath, projectID: projectID, command: command) tabs.append(tab) if let current = activeTabID { tabHistory.push(current) } activeTabID = tab.id diff --git a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift index e63cca3..3b5c5bd 100644 --- a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift +++ b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift @@ -1001,6 +1001,31 @@ final class GhosttyTerminalNSView: NSView { } } +// MARK: - Programmatic text input + +extension GhosttyTerminalNSView { + /// Write text to the terminal as if pasted — the same `ghostty_surface_key` + /// text path `insertText` takes outside a key event, bypassing keyboard + /// handling entirely. The control CLI's `pane run` uses this to type a + /// command (plus newline) into a live shell. Returns false when the + /// surface doesn't exist yet, so callers can report the miss instead of + /// silently dropping input. + @discardableResult + func sendText(_ text: String) -> Bool { + guard let surface, !text.isEmpty else { return false } + // Same liveness signal a keystroke sends (execution tracking + poll + // resume), so an injected command updates the tab title promptly. + onInteraction?() + text.withCString { ptr in + var ke = ghostty_input_key_s() + ke.action = GHOSTTY_ACTION_PRESS + ke.text = ptr + _ = ghostty_surface_key(surface, ke) + } + return true + } +} + // MARK: - NSTextInputClient extension GhosttyTerminalNSView: @preconcurrency NSTextInputClient { diff --git a/MactermTests/Control/ControlHandlerTests.swift b/MactermTests/Control/ControlHandlerTests.swift index ee59649..145a468 100644 --- a/MactermTests/Control/ControlHandlerTests.swift +++ b/MactermTests/Control/ControlHandlerTests.swift @@ -283,4 +283,291 @@ struct ControlHandlerTests { let empty = await handler.handle(request("session.info")) #expect(empty.error?.code == .badRequest) } + + // MARK: - project.create / project.select + + @Test + func project_create_adds_and_optionally_selects() async throws { + let (handler, appState, projectStore) = makeHandler() + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("macterm-create-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let created = await handler.handle(request( + "project.create", args: ControlArgs(path: dir.path, name: "fresh", select: true) + )) + #expect(created.ok) + let info = created.data?.projects?.first + #expect(info?.name == "fresh") + #expect(info?.active == true) + #expect(info?.loaded == true) + #expect(projectStore.projects.count == 1) + #expect(appState.activeProjectID?.uuidString == info?.id) + + // Idempotent: same path returns the existing project, adds nothing. + let again = await handler.handle(request("project.create", args: ControlArgs(path: dir.path))) + #expect(again.data?.projects?.first?.id == info?.id) + #expect(projectStore.projects.count == 1) + } + + @Test + func project_create_rejects_bad_paths() async { + let (handler, _, projectStore) = makeHandler() + let relative = await handler.handle(request("project.create", args: ControlArgs(path: "dev/api"))) + #expect(relative.error?.code == .badRequest) + let remote = await handler.handle(request("project.create", args: ControlArgs(path: "host:~/dev/api"))) + #expect(remote.error?.code == .badRequest) + #expect(remote.error?.message.contains("#104") == true) + let missing = await handler.handle(request( + "project.create", args: ControlArgs(path: "/nonexistent-\(UUID().uuidString)") + )) + #expect(missing.error?.code == .notFound) + let empty = await handler.handle(request("project.create")) + #expect(empty.error?.code == .badRequest) + #expect(projectStore.projects.isEmpty) + } + + @Test + func project_select_switches_active() async { + let (handler, appState, projectStore) = makeHandler() + _ = seedProject(appState, projectStore, name: "one") + let two = seedProject(appState, projectStore, name: "two", select: false) + let response = await handler.handle(request("project.select", args: ControlArgs(project: "two"))) + #expect(response.ok) + #expect(appState.activeProjectID == two.id) + + let empty = await handler.handle(request("project.select")) + #expect(empty.error?.code == .badRequest) + } + + // MARK: - tab.new / tab.select / tab.close + + @Test + func tab_new_creates_selects_and_reports() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let response = await handler.handle(request("tab.new", args: ControlArgs(run: "btop"))) + #expect(response.ok) + let info = try #require(response.data?.tabs?.first) + #expect(info.index == 2) + #expect(info.active == true) + let workspace = try #require(appState.workspaces[project.id]) + #expect(workspace.tabs.count == 2) + // The declared command reaches the new tab's pane (spawns via + // initial_input when the surface is created). + #expect(workspace.tabs.last?.splitRoot.allPanes().first?.command == "btop") + } + + @Test + func tab_select_activates_by_index() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + appState.createTab(projectID: project.id, projectPath: project.path) + let workspace = try #require(appState.workspaces[project.id]) + let firstID = try #require(workspace.tabs.first?.id) + + let response = await handler.handle(request("tab.select", args: ControlArgs(tab: "tab:1"))) + #expect(response.ok) + #expect(workspace.activeTabID == firstID) + + let empty = await handler.handle(request("tab.select")) + #expect(empty.error?.code == .badRequest) + } + + @Test + func tab_close_removes_tab_and_kills_sessions() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + appState.createTab(projectID: project.id, projectPath: project.path) + let workspace = try #require(appState.workspaces[project.id]) + #expect(workspace.tabs.count == 2) + + let response = await handler.handle(request("tab.close", args: ControlArgs(tab: "tab:2"))) + #expect(response.ok) + #expect(workspace.tabs.count == 1) + + let empty = await handler.handle(request("tab.close")) + #expect(empty.error?.code == .badRequest) + } + + // MARK: - pane.split / pane.focus / pane.close / pane.run + + @Test + func pane_split_directions_and_command() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let tab = try #require(appState.workspaces[project.id]?.activeTab) + + let right = await handler.handle(request( + "pane.split", args: ControlArgs(run: "yes", direction: "right") + )) + #expect(right.ok) + let newInfo = try #require(right.data?.panes?.first) + #expect(tab.splitRoot.allPanes().count == 2) + let newID = try #require(UUID(uuidString: newInfo.id)) + let newPane = try #require(tab.splitRoot.findPane(id: newID)) + #expect(newPane.command == "yes") + + let bogus = await handler.handle(request("pane.split", args: ControlArgs(direction: "sideways"))) + #expect(bogus.error?.code == .badRequest) + + // Headless auto (no NSView bounds) falls back to horizontal. + let auto = await handler.handle(request("pane.split", args: ControlArgs(direction: "auto"))) + #expect(auto.ok) + #expect(tab.splitRoot.allPanes().count == 3) + } + + @Test + func pane_split_targets_session_selector() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let tab = try #require(appState.workspaces[project.id]?.activeTab) + let source = try #require(tab.splitRoot.allPanes().first) + + let response = await handler.handle(request( + "pane.split", args: ControlArgs(session: source.sessionName, direction: "down") + )) + #expect(response.ok) + #expect(tab.splitRoot.allPanes().count == 2) + + let both = await handler.handle(request( + "pane.split", args: ControlArgs(pane: "pane:1", session: source.sessionName, direction: "down") + )) + #expect(both.error?.code == .badRequest) + + let unknown = await handler.handle(request( + "pane.split", args: ControlArgs(session: "macterm-nope-000000000000", direction: "down") + )) + #expect(unknown.error?.code == .notFound) + } + + @Test + func pane_focus_selects_pane_across_tabs() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let workspace = try #require(appState.workspaces[project.id]) + let firstTab = try #require(workspace.tabs.first) + let firstPane = try #require(firstTab.splitRoot.allPanes().first) + appState.createTab(projectID: project.id, projectPath: project.path) + #expect(workspace.activeTabID != firstTab.id) + + let response = await handler.handle(request( + "pane.focus", args: ControlArgs(pane: firstPane.id.uuidString) + )) + #expect(response.ok) + #expect(workspace.activeTabID == firstTab.id) + #expect(firstTab.focusedPaneID == firstPane.id) + } + + @Test + func pane_close_requires_explicit_target() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let tab = try #require(appState.workspaces[project.id]?.activeTab) + appState.splitPane(direction: .horizontal, projectID: project.id) + #expect(tab.splitRoot.allPanes().count == 2) + + let bare = await handler.handle(request("pane.close")) + #expect(bare.error?.code == .badRequest) + + let second = try #require(tab.splitRoot.allPanes().last) + let response = await handler.handle(request( + "pane.close", args: ControlArgs(pane: second.id.uuidString) + )) + #expect(response.ok) + #expect(tab.splitRoot.allPanes().count == 1) + } + + @Test + func pane_run_without_surface_is_no_surface() async { + let (handler, appState, projectStore) = makeHandler() + _ = seedProject(appState, projectStore) + // Headless test panes never create an NSView/surface, so this is the + // no-surface path; the live path is covered by manual verification. + let response = await handler.handle(request("pane.run", args: ControlArgs(run: "echo hi"))) + #expect(response.error?.code == .noSurface) + + let empty = await handler.handle(request("pane.run")) + #expect(empty.error?.code == .badRequest) + } + + // MARK: - grid + + @Test + func grid_builds_cells_and_reports_created_panes() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let tab = try #require(appState.workspaces[project.id]?.activeTab) + + let response = await handler.handle(request( + "grid", args: ControlArgs(run: "yes", rows: 2, cols: 2) + )) + #expect(response.ok) + #expect(response.data?.panes?.count == 3) + #expect(tab.splitRoot.allPanes().count == 4) + + let degenerate = await handler.handle(request("grid", args: ControlArgs(rows: 1, cols: 1))) + #expect(degenerate.error?.code == .badRequest) + + let huge = await handler.handle(request("grid", args: ControlArgs(rows: 10, cols: 10))) + #expect(huge.error?.code == .badRequest) + } + + // MARK: - session.kill + + @Test + func session_kill_verifies_then_kills() async { + let (handler, appState, _) = makeHandler() + let killed = KilledNames() + appState.zmx = ZmxClient( + executableURL: { nil }, + isBundled: { true }, + killSession: { name in await killed.append(name) }, + listSessionsWithClients: { [.init(name: "macterm-x-000011112222", clients: 0)] }, + sessionLeaderPIDs: { [:] } + ) + + let miss = await handler.handle(request("session.kill", args: ControlArgs(session: "macterm-y-000011112222"))) + #expect(miss.error?.code == .notFound) + #expect(await killed.names.isEmpty) + + let hit = await handler.handle(request("session.kill", args: ControlArgs(session: "macterm-x-000011112222"))) + #expect(hit.ok) + #expect(await killed.names == ["macterm-x-000011112222"]) + } + + // MARK: - layout.apply / layout.save + + @Test + func layout_save_then_apply_round_trips() async throws { + let (handler, appState, projectStore) = makeHandler() + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("macterm-layout-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let project = Project(name: "roundtrip", path: dir.path, sortOrder: 0) + projectStore.add(project) + appState.selectProject(project) + + // No file yet → apply reports the miss. + let before = await handler.handle(request("layout.apply")) + #expect(before.error?.code == .notFound) + + let saved = await handler.handle(request("layout.save")) + #expect(saved.ok) + #expect(appState.projectFiles.find(forProjectPath: dir.path) != nil) + + // Reconciling the unchanged workspace against its own save is + // non-destructive: applies cleanly without --force. + let applied = await handler.handle(request("layout.apply")) + #expect(applied.ok) + #expect(appState.pendingLayoutApply == nil) + } +} + +/// Actor recording killed session names (kills hop through async closures). +private actor KilledNames { + private(set) var names: Set = [] + func append(_ name: String) { + names.insert(name) + } } diff --git a/MactermTests/Model/TerminalTabTests.swift b/MactermTests/Model/TerminalTabTests.swift index a274ee4..2ad0950 100644 --- a/MactermTests/Model/TerminalTabTests.swift +++ b/MactermTests/Model/TerminalTabTests.swift @@ -378,4 +378,57 @@ struct TerminalTabTests { #expect(tab.focusedPaneID != nil) #expect(try remaining.contains(#require(tab.focusedPaneID))) } + + // MARK: - split with command + + @Test + func split_threads_command_into_new_pane() throws { + let (tab, ids) = makeTab(H(pane("a"), pane("b")), focused: "a") + let aID = try #require(ids["a"]) + let newID = try #require(tab.split(paneID: aID, direction: .vertical, command: "btop")) + let newPane = try #require(tab.splitRoot.findPane(id: newID)) + #expect(newPane.command == "btop") + // The source pane's command is untouched. + let sourcePane = try #require(tab.splitRoot.findPane(id: aID)) + #expect(sourcePane.command == nil) + } + + // MARK: - makeGrid + + @Test + func makeGrid_creates_rows_times_cols_panes() throws { + let tab = TerminalTab(projectPath: "/", projectID: UUID()) + let source = try #require(tab.focusedPaneID) + let created = tab.makeGrid(paneID: source, rows: 2, columns: 3, command: "yes") + #expect(created.count == 5) + #expect(tab.splitRoot.allPanes().count == 6) + // Every NEW pane carries the command; the source pane doesn't. + for id in created { + let newPane = try #require(tab.splitRoot.findPane(id: id)) + #expect(newPane.command == "yes") + } + let sourcePane = try #require(tab.splitRoot.findPane(id: source)) + #expect(sourcePane.command == nil) + // Focus returns to the source (top-left) pane. + #expect(tab.focusedPaneID == source) + } + + @Test + func makeGrid_single_column_stacks_rows() throws { + let tab = TerminalTab(projectPath: "/", projectID: UUID()) + let source = try #require(tab.focusedPaneID) + let created = tab.makeGrid(paneID: source, rows: 3, columns: 1) + #expect(created.count == 2) + #expect(tab.splitRoot.allPanes().count == 3) + } + + @Test + func makeGrid_rejects_degenerate_shapes() throws { + let tab = TerminalTab(projectPath: "/", projectID: UUID()) + let source = try #require(tab.focusedPaneID) + #expect(tab.makeGrid(paneID: source, rows: 1, columns: 1).isEmpty) + #expect(tab.makeGrid(paneID: source, rows: 0, columns: 4).isEmpty) + #expect(tab.makeGrid(paneID: UUID(), rows: 2, columns: 2).isEmpty) + #expect(tab.splitRoot.allPanes().count == 1) + } } diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..fef9dd0 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,136 @@ +# The `macterm` CLI + +Macterm bundles a control CLI at `Macterm.app/Contents/Resources/bin/macterm`. +It drives the **running app** — projects, tabs, panes, zmx sessions — over a +local Unix socket, so AI agents, scripts, and other apps can orchestrate the +terminal (issue #107). Shells spawned by Macterm get it on `PATH` +automatically; from elsewhere, invoke it by bundle path or symlink it +somewhere on your `PATH`. + +```console +$ macterm status +Macterm 1.4.0 (pid 4242) — active project: api + +$ macterm tab new --run "npm run dev" +tab:3 * npm 1 pane + +$ macterm grid 2x2 --run "tail -f log/dev.log" +$ macterm pane run -- git pull +$ macterm session list +macterm-api-8f327ce4a3f8 clients:1 pid:4310 attached-pane +``` + +Every command takes `--json` (raw response payload, stable field names) and +`--socket ` (explicit socket override). `--help` works on every level +of the tree. + +## Commands + +| Command | Description | +|---|---| +| `status` | Liveness probe: version, pid, active project. | +| `project list` | All projects with refs (`project:1`), active/loaded markers, tab counts. | +| `project create [--name N] [--select]` | Add a project for a local directory. Idempotent by canonical path. `--select` activates it — and, on first open, applies a matching [central project file](../assets/project.schema.json), spawning its declared tabs. | +| `project select ` | Make a project active. | +| `tab list [--project P]` | Tabs of a project (default: active project). | +| `tab new [--project P] [--run CMD]` | New tab, becomes active. `--run` types CMD into the fresh shell (layout `run:` semantics). | +| `tab select ` | Activate a tab (`tab:3`, index, UUID, or exact title). | +| `tab close [--force]` | Close a tab — kills its panes' zmx sessions. Refuses with a `busy` error when a pane has a running program, unless forced. | +| `pane list [--project P] [--tab T]` | Panes with refs, session names, cwd, foreground process, focus marker. | +| `pane split [--direction right\|down\|auto] [--run CMD]` | Split a pane; the new pane inherits the source's cwd. | +| `pane focus` | Focus a pane: selects its tab, fronts the window, restores keyboard focus. | +| `pane close (--pane P \| --session S) [--force]` | Close a pane (kills its session). Always explicit — never defaults to "the pane you're in". | +| `pane run ` | Type a command (plus newline) into a live pane's shell — works on an existing shell, unlike `--run` which only applies at spawn. | +| `grid [--run CMD]` | Split a pane into an equal R×C grid (≤16 cells). `--run` spawns CMD in every **new** pane; the source pane keeps its shell. | +| `session list` / `session info ` | zmx sessions as the daemon reports them — including orphans from crashed/other instances — with attached-pane mapping. | +| `session kill ` | Kill a zmx session. An attached pane's shell exits; an orphan is reaped. | +| `layout apply [--project P] [--force]` | Reconcile the workspace to the project's central layout file. A reconcile that would close panes returns `busy` unless forced. | +| `layout save [--project P]` | Write the live workspace to `~/.config/macterm/projects/.yaml`. | + +## Targeting + +Projects and tabs accept a **name/title**, a **UUID**, or the **1-based +index** shown in list output (bare `3` or ref-style `tab:3`). Duplicate names +are an explicit `ambiguous` error, never a silent first-match. + +Pane verbs resolve their target in this order: + +1. `--session ` — the zmx session name (`macterm--`). + This is the **restart-stable** address: pane UUIDs are regenerated on + every launch, session names are persisted verbatim. +2. `--pane ` — a pane UUID (searched project-wide) or an index + within the tab scope. +3. `MACTERM_SESSION` — inside a Macterm pane, the app injects this env var, + so a bare `macterm pane split` splits *the pane you're running in* + (self-targeting). An explicit `--tab` disables this fallback. +4. Otherwise: the focused pane of the active tab. + +`--session` and `--pane` together are an error. `pane close` never uses the +env fallback — destroying "whatever pane I happen to be in" because no target +was given is a footgun; it demands an explicit target. + +## Environment + +The app exports into every spawned shell: + +- `MACTERM_SOCKET` — the control socket path. A discovery *hint*, not a pin: + if the hinted socket doesn't answer (the app restarted since this shell + spawned), the CLI falls back to the well-known locations. Only `--socket` + pins hard. +- `MACTERM_SESSION` — the pane's own session name, for self-targeting. +- `PATH` — prepended with the bundle's `Resources/bin`. + +## Exit codes and output contract + +- `0` — success. stdout carries the result (and *only* then). +- `1` — the app returned an error. stderr gets the message plus, when the + app can suggest one, a recovery hint. +- `2` — no running Macterm reachable. stderr lists every socket path tried. + +This safe-fail contract makes the CLI pipeline-safe: anything captured from +stdout is real output. + +## Wire protocol (for non-CLI clients) + +Any same-user process can speak the protocol directly; the CLI is just a +convenience. One request per connection to the Unix socket at +`~/Library/Application Support/Macterm[ Debug]/control.sock`: + +1. Connect, write a single newline-terminated JSON line, then half-close + your write end (`shutdown(fd, SHUT_WR)`): + + ```json + {"v":1,"id":"","command":"pane.split","args":{"direction":"down","run":"btop"}} + ``` + +2. Read one newline-terminated JSON line back; the server closes: + + ```json + {"v":1,"id":"","ok":true,"data":{"panes":[{"id":"…","session":"macterm-api-1a2b3c4d5e6f","index":2,…}]}} + ``` + + Failures are `{"ok":false,"error":{"code":"busy","message":"…","action":"…"}}` + with snake_case codes: `starting`, `unknown_command`, `bad_request`, + `not_found`, `ambiguous`, `busy`, `no_surface`, `internal`. + +Commands are `noun.verb` (`project.list`, `tab.new`, `pane.run`, `grid`, +`session.kill`, `layout.apply`); args is a flat object of optional fields +(see `Macterm/Control/ControlProtocol.swift`, the single source of truth +compiled into both the app and the CLI). Unknown fields are ignored on both +sides, so additive evolution never breaks a client. Debuggable by hand: +`echo '{"v":1,"id":"x","command":"status"}' | nc -U `. + +## Security + +The boundary is filesystem permissions, same-user only: the socket is mode +0600 in a 0700 directory, and the CLI refuses to connect to a socket owned by +another user. There is deliberately no token handshake — this matches the +posture of the zmx session daemon the panes already run under. If +mutually-untrusted local agents ever need scoping, the request envelope has +room for an `auth` field (Zentty's HMAC pane tokens are the model), without +breaking existing clients. + +Sessions listed by `session list` may belong to *other* Macterm instances +(debug + release share the zmx daemon); such sessions show as `orphan` here +because no pane of *this* instance is bound to them. Killing one kills it for +its real owner too — check before you reap.