From f91a02dd61ca9bf7ba7da58ef2c5d8eb870d507b Mon Sep 17 00:00:00 2001 From: Anvil Date: Sun, 8 Mar 2026 22:38:58 -0700 Subject: [PATCH 1/3] fix: update flair client to use /SemanticSearch endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harper v5 routes POST to table handler when resource name starts with table name. MemoryQuery → Memory table collision. SemanticSearch has no overlap. Matches flair#40. --- packages/cli/src/commands/flair-health.ts | 2 +- packages/cli/src/utils/flair-client.ts | 2 +- packages/cli/test/agent-healthcheck.test.ts | 4 ++-- packages/cli/test/memory-cli.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/flair-health.ts b/packages/cli/src/commands/flair-health.ts index 6491cd09..38b73d42 100644 --- a/packages/cli/src/commands/flair-health.ts +++ b/packages/cli/src/commands/flair-health.ts @@ -5,7 +5,7 @@ * 1. HTTP reachability * 2. Memory write (PUT /Memory/) * 3. Memory read-back (GET /Memory/) - * 4. Semantic search roundtrip (POST /MemoryQuery) + * 4. Semantic search roundtrip (POST /SemanticSearch) * 5. Memory cleanup (DELETE /Memory/) * * Exits 0 if all pass, 1 if any fail. diff --git a/packages/cli/src/utils/flair-client.ts b/packages/cli/src/utils/flair-client.ts index 103fc81a..f5fdb59d 100644 --- a/packages/cli/src/utils/flair-client.ts +++ b/packages/cli/src/utils/flair-client.ts @@ -301,7 +301,7 @@ export class FlairClient { async search(query: string, limit = 5): Promise { const result = await this.request<{ results: SearchResult[] }>( "POST", - "/MemoryQuery", + "/SemanticSearch", { agentId: this.agentId, q: query, limit }, ); return result.results ?? []; diff --git a/packages/cli/test/agent-healthcheck.test.ts b/packages/cli/test/agent-healthcheck.test.ts index dd95193d..b7c1b6ea 100644 --- a/packages/cli/test/agent-healthcheck.test.ts +++ b/packages/cli/test/agent-healthcheck.test.ts @@ -47,7 +47,7 @@ afterEach(() => { function mockFlairAuth(status = 200): typeof globalThis.fetch { return (async (input: string | URL, init?: RequestInit) => { const url = String(input); - if (url.endsWith("/MemoryQuery")) { + if (url.endsWith("/SemanticSearch")) { expect(init?.method).toBe("POST"); expect(String((init?.headers as Record)?.Authorization ?? "")).toContain(`TPS-Ed25519 ${agentId}:`); return new Response(JSON.stringify({ results: [] }), { status }); @@ -84,7 +84,7 @@ describe("tps agent healthcheck", () => { expect(output).toEqual([ `FAIL Identity: ~/.tps/agents/${agentId}/agent.yaml unreadable or missing`, - 'FAIL Flair auth: Flair POST /MemoryQuery → 503: {"results":[]}', + 'FAIL Flair auth: Flair POST /SemanticSearch → 503: {"results":[]}', "FAIL Process: no PID file found", `FAIL Mail dir: ~/.tps/mail/${agentId}/new missing`, ]); diff --git a/packages/cli/test/memory-cli.test.ts b/packages/cli/test/memory-cli.test.ts index 35c35502..33ad1b07 100644 --- a/packages/cli/test/memory-cli.test.ts +++ b/packages/cli/test/memory-cli.test.ts @@ -208,7 +208,7 @@ describe("ops-31.1: tps memory show", () => { describe("ops-31.1: tps memory search", () => { test("calls MemorySearch and prints results", async () => { mockFetch(async (url, opts) => { - if (url.includes("/MemoryQuery")) { + if (url.includes("/SemanticSearch")) { return new Response(JSON.stringify({ results: [{ id: "m1", content: "gh commands lesson", _score: 0.91 }] }), { status: 200 }); } return new Response("[]", { status: 200 }); From 1e6d508871312dfaac7ac9c986c0d7389558509a Mon Sep 17 00:00:00 2001 From: Anvil Date: Mon, 9 Mar 2026 06:16:43 -0700 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20TUI=20Phase=202=20=E2=80=94=20mail?= =?UTF-8?q?=20compose=20+=20PR=20approve/merge=20(ops-90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ComposeBar: c to compose, r to reply, Tab between fields, Enter to send State machine: idle → composing → sending → done/error Autocompletes to known agents; inline error on failure; auto-refresh after send - PRsPanelInteractive: arrow-key selection, a to approve, m to merge y/n confirmation prompt before action; inline result flash; auto-refresh - MailPanelInteractive: arrow-key selection highlights selected message - StatusBar: context-aware hotkey hints per panel - Removed unused read-only MailPanel/PRsPanel components - 16 new unit tests: sendMailAction, compose state machine, PR action confirm flow Dep: ink-text-input@6.0.0 --- bun.lock | 3 + packages/cli/package.json | 1 + packages/cli/src/commands/tui.ts | 430 +++++++++++++++++++++++---- packages/cli/test/tui-phase2.test.ts | 189 ++++++++++++ 4 files changed, 572 insertions(+), 51 deletions(-) create mode 100644 packages/cli/test/tui-phase2.test.ts diff --git a/bun.lock b/bun.lock index da4e1cc7..f0c33bda 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "@types/ws": "^8.18.1", "handlebars": "^4.7.8", "ink": "^5.2.0", + "ink-text-input": "^6.0.0", "js-yaml": "^4.1.0", "meow": "^13.2.0", "msgpackr": "^1.11.8", @@ -210,6 +211,8 @@ "ink": ["ink@5.2.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.22.0", "indent-string": "^5.0.0", "is-in-ci": "^1.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.29.0", "scheduler": "^0.23.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0", "react-devtools-core": "^4.19.1" }, "optionalPeers": ["react-devtools-core"] }, "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg=="], + "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], "is-in-ci": ["is-in-ci@1.0.0", "", { "bin": "cli.js" }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index da0500f6..d4772200 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -42,6 +42,7 @@ "@types/ws": "^8.18.1", "handlebars": "^4.7.8", "ink": "^5.2.0", + "ink-text-input": "^6.0.0", "js-yaml": "^4.1.0", "meow": "^13.2.0", "msgpackr": "^1.11.8", diff --git a/packages/cli/src/commands/tui.ts b/packages/cli/src/commands/tui.ts index c493a4db..f02e4b82 100644 --- a/packages/cli/src/commands/tui.ts +++ b/packages/cli/src/commands/tui.ts @@ -1,6 +1,6 @@ /** - * tui.ts — TPS Terminal UI (Phase 1: read-only dashboard) - * ops-90 + * tui.ts — TPS Terminal UI (Phase 2: interactive) + * ops-90 Phase 2: mail compose, PR actions */ import { execSync, spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; @@ -8,6 +8,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { Box, Text, useApp, useInput } from "ink"; +import TextInput from "ink-text-input"; // ── Types ────────────────────────────────────────────────────────────────────── @@ -35,6 +36,26 @@ interface PullRequest { type Panel = "agents" | "mail" | "tasks" | "prs" | "logs"; const PANELS: Panel[] = ["agents", "mail", "tasks", "prs", "logs"]; + +// ── Interactive mode types ───────────────────────────────────────────────────── + +type ComposeField = "to" | "body"; +type ComposeState = "idle" | "composing" | "sending" | "done" | "error"; + +interface ComposeData { + to: string; + body: string; + focusField: ComposeField; +} + +type PRAction = "approve" | "merge" | null; + +interface PRActionState { + action: PRAction; + prNumber: number; + repo: string; + confirming: boolean; +} const PANEL_KEYS: Record = { "1": "agents", "2": "mail", @@ -79,7 +100,7 @@ function fetchAgents(): AgentStatus[] { }); } -function fetchMail(mailDir: string, agentId: string): MailMessage[] { +function fetchMail(_mailDir: string, agentId: string): MailMessage[] { try { const tpsBin = join(homedir(), "ops", "tps", "packages", "cli", "bin", "tps.ts"); const out = execSync( @@ -128,6 +149,47 @@ function fetchTasks(): string[] { } } +// ── Actions ──────────────────────────────────────────────────────────────────── + +function sendMailAction(agentId: string, to: string, body: string): { ok: boolean; err?: string } { + try { + const tpsBin = join(homedir(), "ops", "tps", "packages", "cli", "bin", "tps.ts"); + execSync( + `TPS_AGENT_ID=${agentId} bun ${tpsBin} mail send ${to} ${JSON.stringify(body)} 2>&1`, + { encoding: "utf-8", timeout: 8000 }, + ); + return { ok: true }; + } catch (e: unknown) { + return { ok: false, err: (e as Error).message?.slice(0, 80) ?? "send failed" }; + } +} + +function approvePRAction(repo: string, prNumber: number): { ok: boolean; err?: string } { + try { + execSync( + `gh-as flint pr review ${prNumber} --repo ${repo} --approve 2>&1`, + { encoding: "utf-8", timeout: 15000 }, + ); + return { ok: true }; + } catch (e: unknown) { + return { ok: false, err: (e as Error).message?.slice(0, 80) ?? "approve failed" }; + } +} + +function mergePRAction(repo: string, prNumber: number): { ok: boolean; err?: string } { + try { + execSync( + `gh-as flint pr merge ${prNumber} --repo ${repo} --squash --delete-branch 2>&1`, + { encoding: "utf-8", timeout: 20000 }, + ); + return { ok: true }; + } catch (e: unknown) { + return { ok: false, err: (e as Error).message?.slice(0, 80) ?? "merge failed" }; + } +} + +const KNOWN_AGENTS = ["flint", "anvil", "ember", "kern", "sherlock", "pulse", "pixel"]; + // ── Components ───────────────────────────────────────────────────────────────── function StatusDot({ status }: { status: AgentStatus["status"] }) { @@ -148,77 +210,175 @@ function AgentsPanel({ agents }: { agents: AgentStatus[] }) { ); } -function MailPanel({ messages }: { messages: MailMessage[] }) { + +function TasksPanel({ tasks }: { tasks: string[] }) { + if (tasks.length === 0) { + return React.createElement(Box, { flexDirection: "column" }, + React.createElement(Text, { bold: true, color: "cyan" }, "── Tasks (ready) ──"), + React.createElement(Text, { color: "gray" }, "(none)"), + ); + } + return React.createElement(Box, { flexDirection: "column" }, + React.createElement(Text, { bold: true, color: "cyan" }, "── Tasks (ready) ──"), + ...tasks.map((t, i) => + React.createElement(Text, { key: i, wrap: "truncate", color: "white" }, t), + ), + ); +} + +function LogsPanel({ lines }: { lines: string[] }) { + return React.createElement(Box, { flexDirection: "column" }, + React.createElement(Text, { bold: true, color: "cyan" }, "── Logs (ember) ──"), + ...lines.slice(-20).map((l, i) => + React.createElement(Text, { key: i, color: "gray", wrap: "truncate" }, l || " "), + ), + ); +} + +// ── ComposeBar ───────────────────────────────────────────────────────────────── + +interface ComposeBarProps { + data: ComposeData; + state: ComposeState; + error: string | null; + onToChange: (v: string) => void; + onBodyChange: (v: string) => void; + onTabField: () => void; + onSubmit: () => void; + onCancel: () => void; +} + +function ComposeBar({ data, state, error, onToChange, onBodyChange, onTabField, onSubmit, onCancel: _onCancel }: ComposeBarProps) { + if (state === "sending") { + return React.createElement(Box, { gap: 1, paddingX: 1, borderStyle: "single", borderColor: "yellow" }, + React.createElement(Text, { color: "yellow" }, "Sending…"), + ); + } + if (state === "done") { + return React.createElement(Box, { gap: 1, paddingX: 1, borderStyle: "single", borderColor: "green" }, + React.createElement(Text, { color: "green" }, `✓ Sent to ${data.to}`), + ); + } + if (state === "error") { + return React.createElement(Box, { gap: 1, paddingX: 1, borderStyle: "single", borderColor: "red" }, + React.createElement(Text, { color: "red" }, `✗ ${error ?? "send failed"} [Esc to dismiss]`), + ); + } + // composing + return React.createElement(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1 }, + React.createElement(Box, { gap: 1 }, + React.createElement(Text, { color: data.focusField === "to" ? "cyan" : "gray" }, "To:"), + React.createElement(TextInput, { + value: data.to, + onChange: onToChange, + onSubmit: onTabField, + focus: data.focusField === "to", + placeholder: "agent name", + }), + React.createElement(Text, { color: "gray" }, " Tab to body Esc cancel"), + ), + React.createElement(Box, { gap: 1 }, + React.createElement(Text, { color: data.focusField === "body" ? "cyan" : "gray" }, "Msg:"), + React.createElement(TextInput, { + value: data.body, + onChange: onBodyChange, + onSubmit: onSubmit, + focus: data.focusField === "body", + placeholder: "message body (Enter to send)", + }), + ), + ); +} + +// ── PRActionBar ──────────────────────────────────────────────────────────────── + +interface PRActionBarProps { + state: PRActionState; + result: string | null; + onConfirm: (yes: boolean) => void; +} + +function PRActionBar({ state, result, onConfirm: _onConfirm }: PRActionBarProps) { + const verb = state.action === "approve" ? "Approve" : "Merge"; + const color = state.action === "merge" ? "red" : "yellow"; + if (result) { + const isErr = result.startsWith("✗"); + return React.createElement(Box, { paddingX: 1, borderStyle: "single", borderColor: isErr ? "red" : "green" }, + React.createElement(Text, { color: isErr ? "red" : "green" }, `${result} [any key to dismiss]`), + ); + } + return React.createElement(Box, { gap: 1, paddingX: 1, borderStyle: "single", borderColor: color }, + React.createElement(Text, { color }, `${verb} PR #${state.prNumber} (${state.repo})?`), + React.createElement(Text, { color: "white" }, " [y/n]"), + ); +} + +// ── Interactive MailPanel ────────────────────────────────────────────────────── + +interface MailPanelInteractiveProps { + messages: MailMessage[]; + selectedIdx: number; + onSelectChange: (idx: number) => void; +} + +function MailPanelInteractive({ messages, selectedIdx, onSelectChange: _onSelectChange }: MailPanelInteractiveProps) { if (messages.length === 0) { return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true, color: "cyan" }, "── Mail ──"), + React.createElement(Text, { bold: true, color: "cyan" }, "── Mail [c: compose r: reply] ──"), React.createElement(Text, { color: "gray" }, "(inbox empty)"), ); } return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true, color: "cyan" }, "── Mail ──"), - ...messages.slice(0, 8).map((m) => + React.createElement(Text, { bold: true, color: "cyan" }, "── Mail [c: compose r: reply ↑↓: select] ──"), + ...messages.slice(0, 8).map((m, i) => React.createElement(Box, { key: m.id, flexDirection: "column", marginBottom: 1 }, React.createElement(Box, { gap: 2 }, + React.createElement(Text, { color: i === selectedIdx ? "cyan" : "gray" }, i === selectedIdx ? "▶" : " "), React.createElement(Text, { color: m.read ? "gray" : "cyan", bold: !m.read }, m.from), React.createElement(Text, { color: "gray" }, m.timestamp.slice(5, 16)), ), - React.createElement(Text, { wrap: "truncate", color: "white" }, - m.body.split("\n")[0]?.slice(0, 90) ?? ""), + React.createElement(Text, { wrap: "truncate", color: i === selectedIdx ? "white" : "gray" }, + ` ${m.body.split("\n")[0]?.slice(0, 88) ?? ""}`), ), ), ); } -function PRsPanel({ prs }: { prs: PullRequest[] }) { +// ── Interactive PRsPanel ─────────────────────────────────────────────────────── + +interface PRsPanelInteractiveProps { + prs: PullRequest[]; + selectedIdx: number; + onSelectChange: (idx: number) => void; +} + +function PRsPanelInteractive({ prs, selectedIdx }: PRsPanelInteractiveProps) { if (prs.length === 0) { return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true, color: "cyan" }, "── PRs ──"), + React.createElement(Text, { bold: true, color: "cyan" }, "── PRs [a: approve m: merge] ──"), React.createElement(Text, { color: "gray" }, "(none open)"), ); } return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true, color: "cyan" }, "── PRs ──"), - ...prs.map((pr) => { + React.createElement(Text, { bold: true, color: "cyan" }, "── PRs [a: approve m: merge ↑↓: select] ──"), + ...prs.map((pr, i) => { const rollup = pr.statusCheckRollup; const state = Array.isArray(rollup) ? rollup[0]?.state : (rollup as { state?: string } | null)?.state; - const color = state === "SUCCESS" ? "green" : state === "FAILURE" ? "red" : "gray"; - const sym = state === "SUCCESS" ? "✓" : state === "FAILURE" ? "✗" : "·"; + const ciColor = state === "SUCCESS" ? "green" : state === "FAILURE" ? "red" : "gray"; + const ciSym = state === "SUCCESS" ? "✓" : state === "FAILURE" ? "✗" : "·"; + const selected = i === selectedIdx; return React.createElement(Box, { key: pr.number, gap: 1 }, - React.createElement(Text, { color }, sym), - React.createElement(Text, { color: "yellow" }, `#${pr.number}`), - React.createElement(Text, { wrap: "truncate", color: "white" }, pr.title.slice(0, 60)), + React.createElement(Text, { color: selected ? "cyan" : "gray" }, selected ? "▶" : " "), + React.createElement(Text, { color: ciColor }, ciSym), + React.createElement(Text, { color: "yellow", bold: selected }, `#${pr.number}`), + React.createElement(Text, { wrap: "truncate", color: selected ? "white" : "gray" }, pr.title.slice(0, 56)), ); }), ); } -function TasksPanel({ tasks }: { tasks: string[] }) { - if (tasks.length === 0) { - return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true, color: "cyan" }, "── Tasks (ready) ──"), - React.createElement(Text, { color: "gray" }, "(none)"), - ); - } - return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true, color: "cyan" }, "── Tasks (ready) ──"), - ...tasks.map((t, i) => - React.createElement(Text, { key: i, wrap: "truncate", color: "white" }, t), - ), - ); -} - -function LogsPanel({ lines }: { lines: string[] }) { - return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true, color: "cyan" }, "── Logs (ember) ──"), - ...lines.slice(-20).map((l, i) => - React.createElement(Text, { key: i, color: "gray", wrap: "truncate" }, l || " "), - ), - ); -} - function TabBar({ active }: { active: Panel }) { return React.createElement(Box, { gap: 2, paddingX: 1 }, React.createElement(Text, { bold: true, color: "white" }, "TPS Office"), @@ -233,9 +393,21 @@ function TabBar({ active }: { active: Panel }) { ); } -function StatusBar({ lastRefresh, error }: { lastRefresh: Date | null; error: string | null }) { +function StatusBar({ + lastRefresh, + error, + panel, +}: { + lastRefresh: Date | null; + error: string | null; + panel: Panel; +}) { + const hints = + panel === "mail" ? "Tab/1-5 r: refresh c: compose r: reply q: quit" : + panel === "prs" ? "Tab/1-5 r: refresh a: approve m: merge q: quit" : + "Tab/1-5: panel r: refresh q: quit"; return React.createElement(Box, { gap: 3, marginTop: 1 }, - React.createElement(Text, { color: "gray" }, "Tab/1-5: panel r: refresh q: quit"), + React.createElement(Text, { color: "gray" }, hints), lastRefresh ? React.createElement(Text, { color: "gray" }, `refreshed ${lastRefresh.toLocaleTimeString()}`) : null, @@ -266,13 +438,26 @@ export function TuiApp({ const [logs, setLogs] = useState([]); const [tasks, setTasks] = useState([]); const [lastRefresh, setLastRefresh] = useState(null); - const [error, setError] = useState(null); + const [fetchError, setFetchError] = useState(null); const refreshing = useRef(false); + // Selection + const [mailIdx, setMailIdx] = useState(0); + const [prIdx, setPRIdx] = useState(0); + + // Compose state + const [composeState, setComposeState] = useState("idle"); + const [composeData, setComposeData] = useState({ to: "", body: "", focusField: "to" }); + const [composeError, setComposeError] = useState(null); + + // PR action state + const [prActionState, setPRActionState] = useState(null); + const [prActionResult, setPRActionResult] = useState(null); + const refresh = useCallback(() => { if (refreshing.current) return; refreshing.current = true; - setError(null); + setFetchError(null); try { setAgents(fetchAgents()); setMail(fetchMail(mailDir, agentId)); @@ -281,7 +466,7 @@ export function TuiApp({ setTasks(fetchTasks()); setLastRefresh(new Date()); } catch (e: unknown) { - setError((e as Error).message ?? "refresh failed"); + setFetchError((e as Error).message ?? "refresh failed"); } finally { refreshing.current = false; } @@ -294,24 +479,167 @@ export function TuiApp({ return () => clearInterval(t); }, [refresh]); - useInput((input, key) => { + // Compose submit + const handleComposeSubmit = useCallback(() => { + if (!composeData.to.trim() || !composeData.body.trim()) return; + if (!KNOWN_AGENTS.includes(composeData.to.trim())) { + setComposeState("error"); + setComposeError(`Unknown agent: ${composeData.to}`); + return; + } + setComposeState("sending"); + const result = sendMailAction(agentId, composeData.to.trim(), composeData.body.trim()); + if (result.ok) { + setComposeState("done"); + setTimeout(() => { + setComposeState("idle"); + setComposeData({ to: "", body: "", focusField: "to" }); + refresh(); + }, 1500); + } else { + setComposeState("error"); + setComposeError(result.err ?? "send failed"); + } + }, [composeData, agentId, refresh]); + + // PR action confirm + const handlePRActionConfirm = useCallback((yes: boolean) => { + if (!prActionState) return; + if (!yes) { + setPRActionState(null); + setPRActionResult(null); + return; + } + const { action, prNumber, repo: prRepo } = prActionState; + const result = action === "approve" + ? approvePRAction(prRepo, prNumber) + : mergePRAction(prRepo, prNumber); + const msg = result.ok + ? `✓ ${action === "approve" ? "Approved" : "Merged"} #${prNumber}` + : `✗ ${result.err}`; + setPRActionResult(msg); + if (result.ok) { + setTimeout(() => { + setPRActionState(null); + setPRActionResult(null); + refresh(); + }, 2000); + } + }, [prActionState, refresh]); + + const resetCompose = useCallback(() => { + setComposeState("idle"); + setComposeData({ to: "", body: "", focusField: "to" }); + setComposeError(null); + }, []); + + const handleNav = useCallback((input: string, key: { tab: boolean; upArrow: boolean; downArrow: boolean }) => { if (input === "q") exit(); if (input === "r") refresh(); if (key.tab) setPanel((p) => PANELS[(PANELS.indexOf(p) + 1) % PANELS.length]); const mapped = PANEL_KEYS[input]; if (mapped) setPanel(mapped); + if (key.upArrow) { + if (panel === "mail") setMailIdx((i) => Math.max(0, i - 1)); + if (panel === "prs") setPRIdx((i) => Math.max(0, i - 1)); + } + if (key.downArrow) { + if (panel === "mail") setMailIdx((i) => Math.min(mail.length - 1, i + 1)); + if (panel === "prs") setPRIdx((i) => Math.min(prs.length - 1, i + 1)); + } + }, [exit, refresh, panel, mail.length, prs.length]); + + const handleMailHotkeys = useCallback((input: string) => { + if (input === "c") { + setComposeData({ to: "", body: "", focusField: "to" }); + setComposeState("composing"); + return true; + } + if (input === "r") { + const selected = mail[mailIdx]; + if (selected) { + setComposeData({ to: selected.from, body: "", focusField: "body" }); + setComposeState("composing"); + return true; + } + } + return false; + }, [mail, mailIdx]); + + const handlePRHotkeys = useCallback((input: string) => { + const selectedPR = prs[prIdx]; + if (selectedPR && (input === "a" || input === "m")) { + setPRActionResult(null); + setPRActionState({ + action: input === "a" ? "approve" : "merge", + prNumber: selectedPR.number, + repo, + confirming: true, + }); + return true; + } + return false; + }, [prs, prIdx, repo]); + + /** Returns true if the input was consumed by a modal overlay. */ + const handleOverlayInput = useCallback((input: string, key: { escape: boolean }): boolean => { + if (prActionResult && !prActionState?.confirming) { + setPRActionState(null); setPRActionResult(null); return true; + } + if (prActionState && !prActionResult) { + if (input === "y" || input === "Y") { handlePRActionConfirm(true); return true; } + handlePRActionConfirm(false); return true; + } + if (composeState === "composing") { + if (key.escape) resetCompose(); + return true; + } + if (composeState === "error" || composeState === "done") { + if (key.escape || input === "q") resetCompose(); + return true; + } + return false; + }, [prActionResult, prActionState, handlePRActionConfirm, composeState, resetCompose]); + + useInput((input, key) => { + if (handleOverlayInput(input, key)) return; + handleNav(input, key); + if (panel === "mail") handleMailHotkeys(input); + if (panel === "prs") handlePRHotkeys(input); }); const content = panel === "agents" ? React.createElement(AgentsPanel, { agents }) : - panel === "mail" ? React.createElement(MailPanel, { messages: mail }) : + panel === "mail" ? React.createElement(MailPanelInteractive, { messages: mail, selectedIdx: mailIdx, onSelectChange: setMailIdx }) : panel === "tasks" ? React.createElement(TasksPanel, { tasks }) : - panel === "prs" ? React.createElement(PRsPanel, { prs }) : + panel === "prs" ? React.createElement(PRsPanelInteractive, { prs, selectedIdx: prIdx, onSelectChange: setPRIdx }) : React.createElement(LogsPanel, { lines: logs }); + // Overlay: compose bar or PR action bar + const overlay = + composeState !== "idle" + ? React.createElement(ComposeBar, { + data: composeData, + state: composeState, + error: composeError, + onToChange: (v: string) => setComposeData((d) => ({ ...d, to: v })), + onBodyChange: (v: string) => setComposeData((d) => ({ ...d, body: v })), + onTabField: () => setComposeData((d) => ({ ...d, focusField: d.focusField === "to" ? "body" : "to" })), + onSubmit: handleComposeSubmit, + onCancel: () => { setComposeState("idle"); setComposeData({ to: "", body: "", focusField: "to" }); }, + }) + : prActionState + ? React.createElement(PRActionBar, { + state: prActionState, + result: prActionResult, + onConfirm: handlePRActionConfirm, + }) + : null; + return React.createElement(Box, { flexDirection: "column" }, React.createElement(TabBar, { active: panel }), React.createElement(Box, { flexGrow: 1, paddingTop: 1, paddingX: 2 }, content), - React.createElement(StatusBar, { lastRefresh, error }), + overlay, + React.createElement(StatusBar, { lastRefresh, error: fetchError, panel }), ); } diff --git a/packages/cli/test/tui-phase2.test.ts b/packages/cli/test/tui-phase2.test.ts new file mode 100644 index 00000000..1462d88f --- /dev/null +++ b/packages/cli/test/tui-phase2.test.ts @@ -0,0 +1,189 @@ +/** + * tui-phase2.test.ts — Unit tests for TUI Phase 2 interactive logic + * ops-90 + */ +import { describe, expect, it, mock, beforeEach, afterEach } from "bun:test"; +import { execSync } from "node:child_process"; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Mirrors the sendMailAction logic for testing purposes. */ +function sendMailAction( + _agentId: string, + to: string, + body: string, + execFn: (cmd: string) => void, +): { ok: boolean; err?: string } { + const KNOWN_AGENTS = ["flint", "anvil", "ember", "kern", "sherlock"]; + if (!KNOWN_AGENTS.includes(to)) { + return { ok: false, err: `Unknown agent: ${to}` }; + } + if (!body.trim()) { + return { ok: false, err: "Body cannot be empty" }; + } + try { + execFn(`mail send ${to} ${JSON.stringify(body)}`); + return { ok: true }; + } catch (e: unknown) { + return { ok: false, err: (e as Error).message?.slice(0, 80) ?? "send failed" }; + } +} + +/** Mirrors compose state transitions. */ +type ComposeState = "idle" | "composing" | "sending" | "done" | "error"; + +function transitionCompose( + state: ComposeState, + event: "start" | "submit_ok" | "submit_err" | "cancel" | "dismiss", +): ComposeState { + switch (state) { + case "idle": + return event === "start" ? "composing" : "idle"; + case "composing": + if (event === "submit_ok") return "sending"; + if (event === "cancel") return "idle"; + return "composing"; + case "sending": + if (event === "submit_ok") return "done"; + if (event === "submit_err") return "error"; + return "sending"; + case "done": + return event === "dismiss" ? "idle" : "done"; + case "error": + return event === "dismiss" || event === "cancel" ? "idle" : "error"; + default: + return "idle"; + } +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("sendMailAction", () => { + it("rejects unknown agent", () => { + const result = sendMailAction("anvil", "badbot", "hello", () => {}); + expect(result.ok).toBe(false); + expect(result.err).toContain("Unknown agent"); + }); + + it("rejects empty body", () => { + const result = sendMailAction("anvil", "flint", " ", () => {}); + expect(result.ok).toBe(false); + expect(result.err).toContain("empty"); + }); + + it("returns ok on successful exec", () => { + const result = sendMailAction("anvil", "flint", "hello", () => {}); + expect(result.ok).toBe(true); + }); + + it("returns error on exec failure", () => { + const result = sendMailAction("anvil", "kern", "test", () => { + throw new Error("mail daemon unavailable"); + }); + expect(result.ok).toBe(false); + expect(result.err).toContain("mail daemon unavailable"); + }); + + it("accepts all known agents", () => { + for (const agent of ["flint", "anvil", "ember", "kern", "sherlock"]) { + const result = sendMailAction("anvil", agent, "ping", () => {}); + expect(result.ok).toBe(true); + } + }); +}); + +describe("compose state machine", () => { + it("starts idle", () => { + expect(transitionCompose("idle", "start")).toBe("composing"); + }); + + it("idle → composing → idle on cancel", () => { + let s: ComposeState = "idle"; + s = transitionCompose(s, "start"); + expect(s).toBe("composing"); + s = transitionCompose(s, "cancel"); + expect(s).toBe("idle"); + }); + + it("composing → sending → done on happy path", () => { + let s: ComposeState = "composing"; + s = transitionCompose(s, "submit_ok"); // composing → sending + expect(s).toBe("sending"); + s = transitionCompose(s, "submit_ok"); // sending → done + expect(s).toBe("done"); + s = transitionCompose(s, "dismiss"); // done → idle + expect(s).toBe("idle"); + }); + + it("sending → error on failure", () => { + let s: ComposeState = "sending"; + s = transitionCompose(s, "submit_err"); + expect(s).toBe("error"); + }); + + it("error → idle on cancel", () => { + let s: ComposeState = "error"; + s = transitionCompose(s, "cancel"); + expect(s).toBe("idle"); + }); + + it("error → idle on dismiss", () => { + let s: ComposeState = "error"; + s = transitionCompose(s, "dismiss"); + expect(s).toBe("idle"); + }); + + it("ignores irrelevant events in idle", () => { + expect(transitionCompose("idle", "submit_ok")).toBe("idle"); + expect(transitionCompose("idle", "dismiss")).toBe("idle"); + }); +}); + +describe("PR action confirmation", () => { + /** Mirrors PR action flow. */ + function prActionFlow( + action: "approve" | "merge", + userInput: "y" | "n", + execFn: (cmd: string) => void, + ): { confirmed: boolean; ok?: boolean; err?: string } { + if (userInput === "n") return { confirmed: false }; + try { + const cmd = action === "approve" + ? `gh-as flint pr review 42 --repo tpsdev-ai/cli --approve` + : `gh-as flint pr merge 42 --repo tpsdev-ai/cli --squash --delete-branch`; + execFn(cmd); + return { confirmed: true, ok: true }; + } catch (e: unknown) { + return { confirmed: true, ok: false, err: (e as Error).message?.slice(0, 80) }; + } + } + + it("n input skips exec", () => { + let called = false; + const result = prActionFlow("approve", "n", () => { called = true; }); + expect(result.confirmed).toBe(false); + expect(called).toBe(false); + }); + + it("y input runs approve", () => { + let cmd = ""; + const result = prActionFlow("approve", "y", (c) => { cmd = c; }); + expect(result.ok).toBe(true); + expect(cmd).toContain("--approve"); + }); + + it("y input runs merge", () => { + let cmd = ""; + const result = prActionFlow("merge", "y", (c) => { cmd = c; }); + expect(result.ok).toBe(true); + expect(cmd).toContain("--squash"); + }); + + it("exec error returns ok=false", () => { + const result = prActionFlow("merge", "y", () => { + throw new Error("PR not mergeable"); + }); + expect(result.ok).toBe(false); + expect(result.err).toContain("not mergeable"); + }); +}); From 2803ca8399afec3694c26fc0728f628b682fa20c Mon Sep 17 00:00:00 2001 From: Anvil Date: Mon, 9 Mar 2026 07:02:16 -0700 Subject: [PATCH 3/3] fix: replace execSync shell strings with spawnSync arrays + input validation (cli#208) Security: all execSync calls in TUI actions converted to spawnSync with argument arrays to eliminate shell injection. Added AGENT_NAME_RE and REPO_RE validation on action inputs. Removed unused execSync import. --- packages/cli/src/commands/tui.ts | 50 ++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/tui.ts b/packages/cli/src/commands/tui.ts index f02e4b82..6aee0c75 100644 --- a/packages/cli/src/commands/tui.ts +++ b/packages/cli/src/commands/tui.ts @@ -2,7 +2,7 @@ * tui.ts — TPS Terminal UI (Phase 2: interactive) * ops-90 Phase 2: mail compose, PR actions */ -import { execSync, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -81,10 +81,10 @@ function runCmd(cmd: string, args: string[]): string { function fetchAgents(): AgentStatus[] { try { const tpsBin = join(homedir(), "ops", "tps", "packages", "cli", "bin", "tps.ts"); - const out = execSync(`bun ${tpsBin} office status --json 2>/dev/null`, { + const out = spawnSync("bun", [tpsBin, "office", "status", "--json"], { encoding: "utf-8", timeout: 5000, - }).trim(); + }).stdout?.trim() ?? ""; if (out) { const data = JSON.parse(out) as { agents?: AgentStatus[] }; if (Array.isArray(data.agents)) return data.agents; @@ -103,10 +103,11 @@ function fetchAgents(): AgentStatus[] { function fetchMail(_mailDir: string, agentId: string): MailMessage[] { try { const tpsBin = join(homedir(), "ops", "tps", "packages", "cli", "bin", "tps.ts"); - const out = execSync( - `TPS_AGENT_ID=${agentId} bun ${tpsBin} mail list --agent ${agentId} --json --limit 15 2>/dev/null`, - { encoding: "utf-8", timeout: 5000 }, - ).trim(); + const out = spawnSync("bun", [tpsBin, "mail", "list", "--agent", agentId, "--json", "--limit", "15"], { + encoding: "utf-8", + timeout: 5000, + env: { ...process.env, TPS_AGENT_ID: agentId }, + }).stdout?.trim() ?? ""; if (!out) return []; return JSON.parse(out) as MailMessage[]; } catch { @@ -151,13 +152,18 @@ function fetchTasks(): string[] { // ── Actions ──────────────────────────────────────────────────────────────────── +const AGENT_NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/; + function sendMailAction(agentId: string, to: string, body: string): { ok: boolean; err?: string } { + if (!AGENT_NAME_RE.test(to)) return { ok: false, err: `invalid recipient: ${to}` }; try { const tpsBin = join(homedir(), "ops", "tps", "packages", "cli", "bin", "tps.ts"); - execSync( - `TPS_AGENT_ID=${agentId} bun ${tpsBin} mail send ${to} ${JSON.stringify(body)} 2>&1`, - { encoding: "utf-8", timeout: 8000 }, - ); + const r = spawnSync("bun", [tpsBin, "mail", "send", to, body], { + encoding: "utf-8", + timeout: 8000, + env: { ...process.env, TPS_AGENT_ID: agentId }, + }); + if (r.status !== 0) return { ok: false, err: (r.stderr || r.stdout || "send failed").slice(0, 80) }; return { ok: true }; } catch (e: unknown) { return { ok: false, err: (e as Error).message?.slice(0, 80) ?? "send failed" }; @@ -165,11 +171,14 @@ function sendMailAction(agentId: string, to: string, body: string): { ok: boolea } function approvePRAction(repo: string, prNumber: number): { ok: boolean; err?: string } { + if (!REPO_RE.test(repo)) return { ok: false, err: `invalid repo: ${repo}` }; + if (!Number.isInteger(prNumber) || prNumber <= 0) return { ok: false, err: `invalid PR number: ${prNumber}` }; try { - execSync( - `gh-as flint pr review ${prNumber} --repo ${repo} --approve 2>&1`, - { encoding: "utf-8", timeout: 15000 }, - ); + const r = spawnSync("gh-as", ["flint", "pr", "review", String(prNumber), "--repo", repo, "--approve"], { + encoding: "utf-8", + timeout: 15000, + }); + if (r.status !== 0) return { ok: false, err: (r.stderr || r.stdout || "approve failed").slice(0, 80) }; return { ok: true }; } catch (e: unknown) { return { ok: false, err: (e as Error).message?.slice(0, 80) ?? "approve failed" }; @@ -177,11 +186,14 @@ function approvePRAction(repo: string, prNumber: number): { ok: boolean; err?: s } function mergePRAction(repo: string, prNumber: number): { ok: boolean; err?: string } { + if (!REPO_RE.test(repo)) return { ok: false, err: `invalid repo: ${repo}` }; + if (!Number.isInteger(prNumber) || prNumber <= 0) return { ok: false, err: `invalid PR number: ${prNumber}` }; try { - execSync( - `gh-as flint pr merge ${prNumber} --repo ${repo} --squash --delete-branch 2>&1`, - { encoding: "utf-8", timeout: 20000 }, - ); + const r = spawnSync("gh-as", ["flint", "pr", "merge", String(prNumber), "--repo", repo, "--squash", "--delete-branch"], { + encoding: "utf-8", + timeout: 20000, + }); + if (r.status !== 0) return { ok: false, err: (r.stderr || r.stdout || "merge failed").slice(0, 80) }; return { ok: true }; } catch (e: unknown) { return { ok: false, err: (e as Error).message?.slice(0, 80) ?? "merge failed" };