nfo-cli 0.0.4-improve-prompting → 0.0.6-a89844d-dev
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude-command.js +6 -1
- package/dist/claude-command.js.map +1 -1
- package/dist/claude-trust.js +46 -0
- package/dist/claude-trust.js.map +1 -0
- package/dist/cli.js +5 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/attach.js +8 -8
- package/dist/commands/attach.js.map +1 -1
- package/dist/commands/launch.js +3 -6
- package/dist/commands/launch.js.map +1 -1
- package/dist/commands/restore.js +6 -10
- package/dist/commands/restore.js.map +1 -1
- package/dist/commands/tui.js +17 -1
- package/dist/commands/tui.js.map +1 -1
- package/dist/mcp/handlers.js +5 -0
- package/dist/mcp/handlers.js.map +1 -1
- package/dist/mcp/tool-defs.js +10 -0
- package/dist/mcp/tool-defs.js.map +1 -1
- package/dist/musicians/dismiss.js +1 -1
- package/dist/musicians/dismiss.js.map +1 -1
- package/dist/musicians/reconcile.js +27 -0
- package/dist/musicians/reconcile.js.map +1 -0
- package/dist/musicians/roles.js +15 -0
- package/dist/musicians/roles.js.map +1 -0
- package/dist/musicians/spawn.js +53 -18
- package/dist/musicians/spawn.js.map +1 -1
- package/dist/permission.js +6 -0
- package/dist/permission.js.map +1 -1
- package/dist/prompts/musician-role.js +2 -1
- package/dist/prompts/musician-role.js.map +1 -1
- package/dist/prompts/orchestrator-role.js +18 -6
- package/dist/prompts/orchestrator-role.js.map +1 -1
- package/dist/prompts/tool-discipline.js +7 -3
- package/dist/prompts/tool-discipline.js.map +1 -1
- package/dist/tmux.js +23 -0
- package/dist/tmux.js.map +1 -1
- package/dist/tui/components/App.js +8 -12
- package/dist/tui/components/App.js.map +1 -1
- package/dist/tui/components/Help.js +1 -1
- package/dist/tui/components/Help.js.map +1 -1
- package/dist/tui/keymap.js +1 -1
- package/dist/tui/keymap.js.map +1 -1
- package/package.json +18 -8
- package/assets/agent-screen.png +0 -0
- package/assets/main-screen.png +0 -0
- package/assets/orche-clawd.png +0 -0
- package/dist/tui/App.js +0 -428
- package/dist/tui/App.js.map +0 -1
- package/dist/tui/AppView.js +0 -13
- package/dist/tui/AppView.js.map +0 -1
- package/dist/tui/Auditorium.js +0 -17
- package/dist/tui/Auditorium.js.map +0 -1
- package/dist/tui/ConcertHall.js +0 -11
- package/dist/tui/ConcertHall.js.map +0 -1
- package/dist/tui/Help.js +0 -49
- package/dist/tui/Help.js.map +0 -1
- package/dist/tui/OrchestratorPane.js +0 -34
- package/dist/tui/OrchestratorPane.js.map +0 -1
- package/dist/tui/SidebarHeader.js +0 -6
- package/dist/tui/SidebarHeader.js.map +0 -1
- package/dist/tui/StatusBar.js +0 -6
- package/dist/tui/StatusBar.js.map +0 -1
- package/docs/plans/2026-05-29-nfo-phase-1-bootstrap.md +0 -2152
- package/docs/plans/2026-05-29-nfo-phase-2-mcp-musicians.md +0 -2467
- package/docs/plans/2026-05-29-nfo-phase-3-ink-tui.md +0 -1611
- package/docs/plans/2026-05-29-nfo-phase-4-permission-prompts.md +0 -460
- package/docs/plans/2026-05-29-nfo-phase-5-help-and-notify.md +0 -933
- package/docs/specs/2026-05-29-nfo-design.md +0 -468
- package/plan-explorer-musician-hardening.md +0 -56
- package/src/claude-command.ts +0 -35
- package/src/claude-detect.ts +0 -42
- package/src/cli.ts +0 -197
- package/src/commands/attach.ts +0 -24
- package/src/commands/dashboard-window.ts +0 -33
- package/src/commands/kill.ts +0 -50
- package/src/commands/launch.ts +0 -134
- package/src/commands/list.ts +0 -43
- package/src/commands/mcp-server.ts +0 -18
- package/src/commands/notes.ts +0 -18
- package/src/commands/restore.ts +0 -153
- package/src/commands/tui.tsx +0 -22
- package/src/config.ts +0 -44
- package/src/dashboard.ts +0 -1
- package/src/mcp/config.ts +0 -39
- package/src/mcp/handlers.ts +0 -141
- package/src/mcp/server.ts +0 -50
- package/src/mcp/tool-defs.ts +0 -151
- package/src/musicians/dismiss.ts +0 -60
- package/src/musicians/ids.ts +0 -21
- package/src/musicians/lookup.ts +0 -13
- package/src/musicians/message-log.ts +0 -152
- package/src/musicians/message.ts +0 -99
- package/src/musicians/query.ts +0 -19
- package/src/musicians/spawn.ts +0 -139
- package/src/notes.ts +0 -39
- package/src/notify.ts +0 -62
- package/src/orchestrator/report-back.ts +0 -33
- package/src/permission.ts +0 -30
- package/src/project-key.ts +0 -12
- package/src/prompts/musician-role.ts +0 -22
- package/src/prompts/orchestrator-role.ts +0 -84
- package/src/prompts/tool-discipline.ts +0 -41
- package/src/repo.ts +0 -14
- package/src/shell-quote.ts +0 -7
- package/src/state-updaters.ts +0 -132
- package/src/state.ts +0 -49
- package/src/state.types.ts +0 -67
- package/src/tmux.ts +0 -226
- package/src/tui/activity-line.ts +0 -16
- package/src/tui/components/App.tsx +0 -534
- package/src/tui/components/AppView.tsx +0 -98
- package/src/tui/components/Auditorium.tsx +0 -56
- package/src/tui/components/ConcertHall.tsx +0 -31
- package/src/tui/components/Help.tsx +0 -63
- package/src/tui/components/OrchestratorPane.tsx +0 -98
- package/src/tui/components/SidebarHeader.tsx +0 -34
- package/src/tui/components/StatusBar.tsx +0 -42
- package/src/tui/detect-permission.ts +0 -93
- package/src/tui/embedded-session-lifecycle.ts +0 -44
- package/src/tui/embedded-terminal.ts +0 -325
- package/src/tui/format-time.ts +0 -25
- package/src/tui/keymap.ts +0 -104
- package/src/tui/poll-activity.ts +0 -25
- package/src/tui/poll-idle.ts +0 -149
- package/src/tui/poll-permission.ts +0 -50
- package/src/tui/status-icon.ts +0 -35
- package/src/tui/terminal-input.ts +0 -136
- package/src/tui/watch-state.ts +0 -43
- package/src/worktree.ts +0 -41
- package/tests/claude-command.test.ts +0 -30
- package/tests/claude-detect.test.ts +0 -14
- package/tests/commands/attach.test.ts +0 -60
- package/tests/commands/kill.test.ts +0 -66
- package/tests/commands/launch.test.ts +0 -75
- package/tests/commands/list.test.ts +0 -47
- package/tests/commands/notes.test.ts +0 -53
- package/tests/commands/restore.test.ts +0 -126
- package/tests/helpers/tmp-config.ts +0 -16
- package/tests/helpers/tmp-repo.ts +0 -29
- package/tests/integration/orchestrator-spawn.test.ts +0 -108
- package/tests/mcp/handlers.test.ts +0 -163
- package/tests/mcp/tool-defs.test.ts +0 -35
- package/tests/musicians/dismiss.test.ts +0 -102
- package/tests/musicians/message.test.ts +0 -159
- package/tests/musicians/query.test.ts +0 -65
- package/tests/musicians/spawn.test.ts +0 -125
- package/tests/notes.test.ts +0 -56
- package/tests/notify.test.ts +0 -80
- package/tests/orchestrator/report-back.test.ts +0 -18
- package/tests/permission.test.ts +0 -39
- package/tests/project-key.test.ts +0 -33
- package/tests/prompts/tool-discipline.test.ts +0 -25
- package/tests/repo.test.ts +0 -38
- package/tests/state-updaters.test.ts +0 -126
- package/tests/state.test.ts +0 -85
- package/tests/tmux.test.ts +0 -126
- package/tests/tui/AppView.test.tsx +0 -92
- package/tests/tui/Auditorium.test.tsx +0 -67
- package/tests/tui/ConcertHall.test.tsx +0 -22
- package/tests/tui/Help.test.tsx +0 -38
- package/tests/tui/OrchestratorPane.test.ts +0 -30
- package/tests/tui/SidebarHeader.test.tsx +0 -20
- package/tests/tui/StatusBar.test.tsx +0 -51
- package/tests/tui/activity-line.test.ts +0 -21
- package/tests/tui/detect-permission.test.ts +0 -92
- package/tests/tui/embedded-session-lifecycle.test.ts +0 -55
- package/tests/tui/embedded-terminal.test.ts +0 -80
- package/tests/tui/format-time.test.ts +0 -25
- package/tests/tui/keymap.test.ts +0 -93
- package/tests/tui/poll-activity.test.ts +0 -81
- package/tests/tui/poll-idle.test.ts +0 -159
- package/tests/tui/poll-permission.test.ts +0 -222
- package/tests/tui/status-icon.test.ts +0 -27
- package/tests/tui/terminal-input.test.ts +0 -113
- package/tests/tui/watch-state.test.ts +0 -54
- package/tests/worktree.test.ts +0 -73
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -12
|
@@ -1,1611 +0,0 @@
|
|
|
1
|
-
# NFO Phase 3 — Ink TUI Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** Replace the Phase 1 placeholder right pane with a real Ink TUI: a **Concert Hall** (tabs for every known orchestra with status), an **Auditorium** (live musician roster with status indicators and a one-line activity hint), and a **status bar** (permission level, token hint, key hints). Keyboard navigation lets the user jump into a Musician's tmux window, cycle orchestras, open notes, dismiss a musician, and return to the Orchestrator pane.
|
|
6
|
-
|
|
7
|
-
**Architecture:** A single Ink (React-for-terminals) app launched as `nfo tui --orchestra-id <id>` in the right tmux pane. The app is split into pure, testable units (a relative-time formatter, status-icon mapper, activity-line extractor, a keyboard reducer) and thin presentational React components fed by two polling loops: a **state watcher** (chokidar on `state.json` + 1 s poll fallback) and an **activity poller** (every 2 s, `tmux capture-pane` per musician). The container component wires hooks → a pure `reduceKey` reducer → side effects (tmux window/pane selection, notes, dismiss). All side-effecting modules already exist from Phases 1–2; Phase 3 only adds the view layer and the polling glue.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** `ink@^7`, `react@^19`, `react-dom@^19` (peer of react), `ink-testing-library` (for component tests), `chokidar@^5` (state file watching). TypeScript with `jsx: react-jsx`. Vitest with esbuild automatic JSX.
|
|
10
|
-
|
|
11
|
-
**Component return type (applies to every `.tsx` component):** annotate components with `ReactElement` from React, NOT the global `JSX.Element` (React 19 moved the JSX namespace and the global `JSX.Element` may not resolve). Add `import type { ReactElement } from 'react';` and write `export function Foo(props: FooProps): ReactElement { ... }`. The code samples below show `JSX.Element` for brevity — substitute `ReactElement` when transcribing. If a build genuinely accepts `JSX.Element`, that is also fine, but prefer `ReactElement` for robustness.
|
|
12
|
-
|
|
13
|
-
**Reference spec:** `docs/specs/2026-05-29-nfo-design.md` §8 (TUI design — layout, sections, keybindings, update mechanism), plus §5.2.1 status enum (`awaiting_permission` is rendered but Phase 4 populates it), §3.3 (TUI is a non-load-bearing viewer).
|
|
14
|
-
|
|
15
|
-
**MANDATORY code style (applies to every task):**
|
|
16
|
-
- Control flow uses explicit braced multi-line blocks. Never the brace-less single-line form (`if (c) { return x; }`, never `if (c) return x;`). Same for `for`/`while`/`else`/`switch`.
|
|
17
|
-
- Arrow functions use explicit `{ return ... }` bodies, never implicit-return expression bodies — EXCEPT React component definitions returning JSX, which use `(props) => { return (<JSX/>); }` (still an explicit braced return). Array callbacks: `.map((m) => { return <Row .../>; })`, never `.map(m => <Row/>)`.
|
|
18
|
-
- Ternaries (`a ? b : c`) ARE allowed, including inside JSX (`{cond ? <A/> : <B/>}`).
|
|
19
|
-
- Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Local git identity (Javier Furus <javierfurus@gmail.com>) is already configured — do not change it. Use the HEREDOC commit form.
|
|
20
|
-
- Every code task runs `npm run build` (tsc) AND `npm test` before commit — `npm test` already has a `pretest: tsc` hook, but run build explicitly too when iterating.
|
|
21
|
-
|
|
22
|
-
**Explicitly NOT in Phase 3 (must not creep in):**
|
|
23
|
-
- Permission-prompt detection / populating `awaiting_permission` (Phase 4). The TUI must RENDER that status if present, but nothing sets it yet.
|
|
24
|
-
- Mouse click handling beyond what Ink gives for free (keyboard nav is the contract).
|
|
25
|
-
- Bell / desktop notifications (Phase 4).
|
|
26
|
-
- Real token/cost computation — the status bar shows a placeholder/`—` (Phase 3 does not parse claude's token line).
|
|
27
|
-
- A `?` help overlay (deferred to Phase 4). Do NOT advertise `[?] help` in the status bar since no handler exists.
|
|
28
|
-
- Concert Hall orchestra-switching (Tab/Shift-Tab actually attaching a different session). The reducer emits the actions and the Concert Hall renders all orchestras, but switching is a no-op in Phase 3.
|
|
29
|
-
- Any change to the MCP server, musician primitives, or state schema.
|
|
30
|
-
|
|
31
|
-
---
|
|
32
|
-
|
|
33
|
-
## File Structure
|
|
34
|
-
|
|
35
|
-
```
|
|
36
|
-
package.json # MODIFY: add ink/react/react-dom/chokidar + @types/react + ink-testing-library
|
|
37
|
-
tsconfig.json # MODIFY: add "jsx": "react-jsx"
|
|
38
|
-
vitest.config.ts # MODIFY: esbuild jsx automatic
|
|
39
|
-
src/
|
|
40
|
-
├── tui/
|
|
41
|
-
│ ├── format-time.ts # NEW: formatRelativeTime(iso, now) → "2m", "<1s", "3h"
|
|
42
|
-
│ ├── status-icon.ts # NEW: statusIcon(status) / statusLabel(status)
|
|
43
|
-
│ ├── activity-line.ts # NEW: extractActivityLine(paneText) → last meaningful line
|
|
44
|
-
│ ├── keymap.ts # NEW: reduceKey(ui, input, key) pure reducer → {ui, action?}
|
|
45
|
-
│ ├── poll-activity.ts # NEW: pollActivity(state) → Record<musicianId, string>
|
|
46
|
-
│ ├── watch-state.ts # NEW: watchOrchestraState(id, onChange) → stop()
|
|
47
|
-
│ ├── StatusBar.tsx # NEW: presentational
|
|
48
|
-
│ ├── Auditorium.tsx # NEW: presentational
|
|
49
|
-
│ ├── ConcertHall.tsx # NEW: presentational
|
|
50
|
-
│ ├── AppView.tsx # NEW: presentational composition (props → full UI)
|
|
51
|
-
│ └── App.tsx # NEW: container (hooks + useInput → reduceKey → effects)
|
|
52
|
-
├── commands/
|
|
53
|
-
│ ├── tui.ts # NEW: runTui({orchestraId}) — renders <App/>
|
|
54
|
-
│ ├── launch.ts # MODIFY: right pane runs `nfo tui` not placeholder
|
|
55
|
-
│ └── restore.ts # MODIFY: right pane runs `nfo tui` not placeholder
|
|
56
|
-
├── tmux.ts # MODIFY: add selectWindow + selectPane
|
|
57
|
-
└── cli.ts # MODIFY: register `tui` subcommand (hidden)
|
|
58
|
-
tests/
|
|
59
|
-
├── tui/
|
|
60
|
-
│ ├── format-time.test.ts
|
|
61
|
-
│ ├── status-icon.test.ts
|
|
62
|
-
│ ├── activity-line.test.ts
|
|
63
|
-
│ ├── keymap.test.ts
|
|
64
|
-
│ ├── poll-activity.test.ts
|
|
65
|
-
│ ├── watch-state.test.ts
|
|
66
|
-
│ ├── StatusBar.test.tsx
|
|
67
|
-
│ ├── Auditorium.test.tsx
|
|
68
|
-
│ ├── ConcertHall.test.tsx
|
|
69
|
-
│ └── AppView.test.tsx
|
|
70
|
-
└── tmux.test.ts # MODIFY: add selectWindow/selectPane cases
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
---
|
|
74
|
-
|
|
75
|
-
## Task 1: Dependencies + JSX config
|
|
76
|
-
|
|
77
|
-
**Files:** `package.json`, `tsconfig.json`, `vitest.config.ts`
|
|
78
|
-
|
|
79
|
-
- [ ] **Step 1: Add dependencies**
|
|
80
|
-
|
|
81
|
-
Add to `package.json` `dependencies` (alphabetical): `"chokidar": "^5.0.0"`, `"ink": "^7.0.0"`, `"react": "^19.0.0"`, `"react-dom": "^19.0.0"`.
|
|
82
|
-
Add to `devDependencies`: `"@types/react": "^19.0.0"`, `"ink-testing-library": "^4.0.0"`.
|
|
83
|
-
|
|
84
|
-
- [ ] **Step 2: Install and resolve peer conflicts**
|
|
85
|
-
|
|
86
|
-
Run: `npm install`.
|
|
87
|
-
|
|
88
|
-
COMPATIBILITY RISK: `ink-testing-library@4` may declare a peer range that excludes `ink@7`. If `npm install` fails with a peer-dep error (or `ink-testing-library`'s render is incompatible at runtime in Step 5), resolve by aligning versions to a known-good combination. Preferred order:
|
|
89
|
-
1. Try `ink@^7` + `ink-testing-library@^4` first.
|
|
90
|
-
2. If incompatible, pin `ink` down to the highest major that `ink-testing-library@4` supports (check `npm view ink-testing-library@4 peerDependencies`), e.g. `ink@^5`. Our component code only uses `Box`, `Text`, `render`, `useInput`, `useApp` — stable across ink 5/6/7 — so downgrading ink is safe.
|
|
91
|
-
Report the EXACT final versions installed.
|
|
92
|
-
|
|
93
|
-
- [ ] **Step 3: Enable JSX in `tsconfig.json`**
|
|
94
|
-
|
|
95
|
-
Add `"jsx": "react-jsx"` to `compilerOptions`. (With `react-jsx` you do NOT need `import React` in every file.)
|
|
96
|
-
|
|
97
|
-
- [ ] **Step 4: Enable JSX in vitest**
|
|
98
|
-
|
|
99
|
-
Edit `vitest.config.ts` to add an `esbuild` block so `.tsx` files transform with the automatic JSX runtime:
|
|
100
|
-
|
|
101
|
-
```typescript
|
|
102
|
-
import { defineConfig } from 'vitest/config';
|
|
103
|
-
|
|
104
|
-
export default defineConfig({
|
|
105
|
-
test: {
|
|
106
|
-
include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
|
|
107
|
-
environment: 'node',
|
|
108
|
-
testTimeout: 10000,
|
|
109
|
-
},
|
|
110
|
-
esbuild: {
|
|
111
|
-
jsx: 'automatic',
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
(Note the `include` now also matches `.test.tsx`.)
|
|
117
|
-
|
|
118
|
-
- [ ] **Step 5: Smoke-render an Ink component to prove the toolchain works**
|
|
119
|
-
|
|
120
|
-
Create a throwaway check (do NOT commit it). Make `tests/tui/_smoke.test.tsx` temporarily:
|
|
121
|
-
|
|
122
|
-
```tsx
|
|
123
|
-
import { describe, it, expect } from 'vitest';
|
|
124
|
-
import { render } from 'ink-testing-library';
|
|
125
|
-
import { Text } from 'ink';
|
|
126
|
-
|
|
127
|
-
describe('ink toolchain smoke', () => {
|
|
128
|
-
it('renders text', () => {
|
|
129
|
-
const { lastFrame } = render(<Text>hello-ink</Text>);
|
|
130
|
-
expect(lastFrame()).toContain('hello-ink');
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
Run: `npm test -- tui/_smoke`. Confirm PASS. Then DELETE `tests/tui/_smoke.test.tsx` (it was only to prove the toolchain).
|
|
136
|
-
|
|
137
|
-
- [ ] **Step 6: Verify full build + suite**
|
|
138
|
-
|
|
139
|
-
```
|
|
140
|
-
npm run build
|
|
141
|
-
npm test
|
|
142
|
-
```
|
|
143
|
-
Build must pass (tsc compiles .tsx). Full suite still 59/59 (the deleted smoke test leaves no trace).
|
|
144
|
-
|
|
145
|
-
- [ ] **Step 7: Commit**
|
|
146
|
-
|
|
147
|
-
```
|
|
148
|
-
git add package.json package-lock.json tsconfig.json vitest.config.ts
|
|
149
|
-
git commit -m "$(cat <<'EOF'
|
|
150
|
-
chore: add ink/react/chokidar deps and enable JSX (tsc + vitest)
|
|
151
|
-
|
|
152
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
153
|
-
EOF
|
|
154
|
-
)"
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
---
|
|
158
|
-
|
|
159
|
-
## Task 2: Relative-time formatter
|
|
160
|
-
|
|
161
|
-
**Files:** `tests/tui/format-time.test.ts`, `src/tui/format-time.ts`
|
|
162
|
-
|
|
163
|
-
- [ ] **Step 1: Write the failing test**
|
|
164
|
-
|
|
165
|
-
```typescript
|
|
166
|
-
import { describe, it, expect } from 'vitest';
|
|
167
|
-
import { formatRelativeTime } from '../../src/tui/format-time.js';
|
|
168
|
-
|
|
169
|
-
const NOW = '2026-05-29T12:00:00Z';
|
|
170
|
-
|
|
171
|
-
describe('formatRelativeTime', () => {
|
|
172
|
-
it('shows <1s for sub-second deltas', () => {
|
|
173
|
-
expect(formatRelativeTime('2026-05-29T11:59:59.500Z', NOW)).toBe('<1s');
|
|
174
|
-
});
|
|
175
|
-
it('shows seconds', () => {
|
|
176
|
-
expect(formatRelativeTime('2026-05-29T11:59:52Z', NOW)).toBe('8s');
|
|
177
|
-
});
|
|
178
|
-
it('shows minutes', () => {
|
|
179
|
-
expect(formatRelativeTime('2026-05-29T11:58:00Z', NOW)).toBe('2m');
|
|
180
|
-
});
|
|
181
|
-
it('shows hours', () => {
|
|
182
|
-
expect(formatRelativeTime('2026-05-29T09:00:00Z', NOW)).toBe('3h');
|
|
183
|
-
});
|
|
184
|
-
it('shows days', () => {
|
|
185
|
-
expect(formatRelativeTime('2026-05-27T12:00:00Z', NOW)).toBe('2d');
|
|
186
|
-
});
|
|
187
|
-
it('returns ? for an unparseable timestamp', () => {
|
|
188
|
-
expect(formatRelativeTime('not-a-date', NOW)).toBe('?');
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
- [ ] **Step 2: Run, confirm FAIL** — `npm test -- format-time`.
|
|
194
|
-
|
|
195
|
-
- [ ] **Step 3: Implement `src/tui/format-time.ts`**
|
|
196
|
-
|
|
197
|
-
```typescript
|
|
198
|
-
export function formatRelativeTime(iso: string, nowIso: string): string {
|
|
199
|
-
const then = Date.parse(iso);
|
|
200
|
-
const now = Date.parse(nowIso);
|
|
201
|
-
if (Number.isNaN(then) || Number.isNaN(now)) {
|
|
202
|
-
return '?';
|
|
203
|
-
}
|
|
204
|
-
const deltaMs = now - then;
|
|
205
|
-
if (deltaMs < 1000) {
|
|
206
|
-
return '<1s';
|
|
207
|
-
}
|
|
208
|
-
const seconds = Math.floor(deltaMs / 1000);
|
|
209
|
-
if (seconds < 60) {
|
|
210
|
-
return `${seconds}s`;
|
|
211
|
-
}
|
|
212
|
-
const minutes = Math.floor(seconds / 60);
|
|
213
|
-
if (minutes < 60) {
|
|
214
|
-
return `${minutes}m`;
|
|
215
|
-
}
|
|
216
|
-
const hours = Math.floor(minutes / 60);
|
|
217
|
-
if (hours < 24) {
|
|
218
|
-
return `${hours}h`;
|
|
219
|
-
}
|
|
220
|
-
const days = Math.floor(hours / 24);
|
|
221
|
-
return `${days}d`;
|
|
222
|
-
}
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
- [ ] **Step 4: Run, confirm PASS** (6/6).
|
|
226
|
-
|
|
227
|
-
- [ ] **Step 5: Commit**
|
|
228
|
-
|
|
229
|
-
```
|
|
230
|
-
git add src/tui/format-time.ts tests/tui/format-time.test.ts
|
|
231
|
-
git commit -m "$(cat <<'EOF'
|
|
232
|
-
feat(tui): relative-time formatter for the Auditorium
|
|
233
|
-
|
|
234
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
235
|
-
EOF
|
|
236
|
-
)"
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
---
|
|
240
|
-
|
|
241
|
-
## Task 3: Status icon + label
|
|
242
|
-
|
|
243
|
-
**Files:** `tests/tui/status-icon.test.ts`, `src/tui/status-icon.ts`
|
|
244
|
-
|
|
245
|
-
- [ ] **Step 1: Write the failing test**
|
|
246
|
-
|
|
247
|
-
```typescript
|
|
248
|
-
import { describe, it, expect } from 'vitest';
|
|
249
|
-
import { statusIcon, statusColor } from '../../src/tui/status-icon.js';
|
|
250
|
-
import type { MusicianStatus } from '../../src/state.types.js';
|
|
251
|
-
|
|
252
|
-
describe('statusIcon', () => {
|
|
253
|
-
it('maps each status to an icon', () => {
|
|
254
|
-
expect(statusIcon('working')).toBe('●');
|
|
255
|
-
expect(statusIcon('idle')).toBe('◐');
|
|
256
|
-
expect(statusIcon('awaiting_permission')).toBe('⚠');
|
|
257
|
-
expect(statusIcon('stopped')).toBe('○');
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
describe('statusColor', () => {
|
|
262
|
-
it('maps each status to an ink color name', () => {
|
|
263
|
-
const colors: Record<MusicianStatus, string> = {
|
|
264
|
-
working: statusColor('working'),
|
|
265
|
-
idle: statusColor('idle'),
|
|
266
|
-
awaiting_permission: statusColor('awaiting_permission'),
|
|
267
|
-
stopped: statusColor('stopped'),
|
|
268
|
-
};
|
|
269
|
-
expect(colors.working).toBe('green');
|
|
270
|
-
expect(colors.idle).toBe('yellow');
|
|
271
|
-
expect(colors.awaiting_permission).toBe('red');
|
|
272
|
-
expect(colors.stopped).toBe('gray');
|
|
273
|
-
});
|
|
274
|
-
});
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
- [ ] **Step 2: Run, confirm FAIL.**
|
|
278
|
-
|
|
279
|
-
- [ ] **Step 3: Implement `src/tui/status-icon.ts`**
|
|
280
|
-
|
|
281
|
-
```typescript
|
|
282
|
-
import type { MusicianStatus } from '../state.types.js';
|
|
283
|
-
|
|
284
|
-
export function statusIcon(status: MusicianStatus): string {
|
|
285
|
-
switch (status) {
|
|
286
|
-
case 'working': {
|
|
287
|
-
return '●';
|
|
288
|
-
}
|
|
289
|
-
case 'idle': {
|
|
290
|
-
return '◐';
|
|
291
|
-
}
|
|
292
|
-
case 'awaiting_permission': {
|
|
293
|
-
return '⚠';
|
|
294
|
-
}
|
|
295
|
-
case 'stopped': {
|
|
296
|
-
return '○';
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export function statusColor(status: MusicianStatus): string {
|
|
302
|
-
switch (status) {
|
|
303
|
-
case 'working': {
|
|
304
|
-
return 'green';
|
|
305
|
-
}
|
|
306
|
-
case 'idle': {
|
|
307
|
-
return 'yellow';
|
|
308
|
-
}
|
|
309
|
-
case 'awaiting_permission': {
|
|
310
|
-
return 'red';
|
|
311
|
-
}
|
|
312
|
-
case 'stopped': {
|
|
313
|
-
return 'gray';
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
- [ ] **Step 4: Run, confirm PASS.**
|
|
320
|
-
|
|
321
|
-
- [ ] **Step 5: Commit**
|
|
322
|
-
|
|
323
|
-
```
|
|
324
|
-
git add src/tui/status-icon.ts tests/tui/status-icon.test.ts
|
|
325
|
-
git commit -m "$(cat <<'EOF'
|
|
326
|
-
feat(tui): musician status icon + color mapping
|
|
327
|
-
|
|
328
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
329
|
-
EOF
|
|
330
|
-
)"
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
---
|
|
334
|
-
|
|
335
|
-
## Task 4: Activity-line extractor
|
|
336
|
-
|
|
337
|
-
**Files:** `tests/tui/activity-line.test.ts`, `src/tui/activity-line.ts`
|
|
338
|
-
|
|
339
|
-
- [ ] **Step 1: Write the failing test**
|
|
340
|
-
|
|
341
|
-
```typescript
|
|
342
|
-
import { describe, it, expect } from 'vitest';
|
|
343
|
-
import { extractActivityLine } from '../../src/tui/activity-line.js';
|
|
344
|
-
|
|
345
|
-
describe('extractActivityLine', () => {
|
|
346
|
-
it('returns the last non-empty trimmed line', () => {
|
|
347
|
-
const pane = 'first line\nsecond line\n\n \nthird line\n\n';
|
|
348
|
-
expect(extractActivityLine(pane)).toBe('third line');
|
|
349
|
-
});
|
|
350
|
-
it('returns empty string for all-blank input', () => {
|
|
351
|
-
expect(extractActivityLine('\n \n\t\n')).toBe('');
|
|
352
|
-
});
|
|
353
|
-
it('truncates very long lines to 60 chars with an ellipsis', () => {
|
|
354
|
-
const long = 'x'.repeat(100);
|
|
355
|
-
const out = extractActivityLine(long);
|
|
356
|
-
expect(out.length).toBe(60);
|
|
357
|
-
expect(out.endsWith('…')).toBe(true);
|
|
358
|
-
});
|
|
359
|
-
it('handles empty string', () => {
|
|
360
|
-
expect(extractActivityLine('')).toBe('');
|
|
361
|
-
});
|
|
362
|
-
});
|
|
363
|
-
```
|
|
364
|
-
|
|
365
|
-
- [ ] **Step 2: Run, confirm FAIL.**
|
|
366
|
-
|
|
367
|
-
- [ ] **Step 3: Implement `src/tui/activity-line.ts`**
|
|
368
|
-
|
|
369
|
-
```typescript
|
|
370
|
-
const MAX_LEN = 60;
|
|
371
|
-
|
|
372
|
-
export function extractActivityLine(paneText: string): string {
|
|
373
|
-
const lines = paneText.split('\n');
|
|
374
|
-
let last = '';
|
|
375
|
-
for (const line of lines) {
|
|
376
|
-
const trimmed = line.trim();
|
|
377
|
-
if (trimmed.length > 0) {
|
|
378
|
-
last = trimmed;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
if (last.length > MAX_LEN) {
|
|
382
|
-
return last.slice(0, MAX_LEN - 1) + '…';
|
|
383
|
-
}
|
|
384
|
-
return last;
|
|
385
|
-
}
|
|
386
|
-
```
|
|
387
|
-
|
|
388
|
-
- [ ] **Step 4: Run, confirm PASS.**
|
|
389
|
-
|
|
390
|
-
- [ ] **Step 5: Commit**
|
|
391
|
-
|
|
392
|
-
```
|
|
393
|
-
git add src/tui/activity-line.ts tests/tui/activity-line.test.ts
|
|
394
|
-
git commit -m "$(cat <<'EOF'
|
|
395
|
-
feat(tui): extract last meaningful line from a captured pane
|
|
396
|
-
|
|
397
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
398
|
-
EOF
|
|
399
|
-
)"
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
---
|
|
403
|
-
|
|
404
|
-
## Task 5: tmux selectWindow + selectPane
|
|
405
|
-
|
|
406
|
-
**Files:** `src/tmux.ts` (MODIFY), `tests/tmux.test.ts` (MODIFY)
|
|
407
|
-
|
|
408
|
-
- [ ] **Step 1: Add the failing tests to `tests/tmux.test.ts`**
|
|
409
|
-
|
|
410
|
-
Add these imports to the existing import block at the top: `selectWindow`, `selectPane`. Then add inside the existing `describe('tmux wrapper', ...)`:
|
|
411
|
-
|
|
412
|
-
```typescript
|
|
413
|
-
it('selectWindow makes a window active', async () => {
|
|
414
|
-
const name = `nfo-test-selwin-${Date.now()}`;
|
|
415
|
-
sessionsToKill.push(name);
|
|
416
|
-
await createDetachedSession(name, '/tmp');
|
|
417
|
-
const { execa } = await import('execa');
|
|
418
|
-
const { stdout: winId } = await execa('tmux', [
|
|
419
|
-
'new-window', '-t', name, '-n', 'second', '-c', '/tmp', '-d',
|
|
420
|
-
'-P', '-F', '#{window_id}',
|
|
421
|
-
]);
|
|
422
|
-
await selectWindow(name, winId.trim());
|
|
423
|
-
const { stdout: active } = await execa('tmux', [
|
|
424
|
-
'display-message', '-p', '-t', name, '#{window_id}',
|
|
425
|
-
]);
|
|
426
|
-
expect(active.trim()).toBe(winId.trim());
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
it('selectPane makes a pane active', async () => {
|
|
430
|
-
const name = `nfo-test-selpane-${Date.now()}`;
|
|
431
|
-
sessionsToKill.push(name);
|
|
432
|
-
await createDetachedSession(name, '/tmp');
|
|
433
|
-
// split so there are two panes (0.0 and 0.1)
|
|
434
|
-
const { execa } = await import('execa');
|
|
435
|
-
await execa('tmux', ['split-window', '-h', '-t', `${name}:0`, '-c', '/tmp']);
|
|
436
|
-
await selectPane(`${name}:0.0`);
|
|
437
|
-
const { stdout: active } = await execa('tmux', [
|
|
438
|
-
'display-message', '-p', '-t', name, '#{pane_index}',
|
|
439
|
-
]);
|
|
440
|
-
expect(active.trim()).toBe('0');
|
|
441
|
-
});
|
|
442
|
-
```
|
|
443
|
-
|
|
444
|
-
- [ ] **Step 2: Run, confirm FAIL** — `npm test -- tmux` (the two new tests fail / functions missing).
|
|
445
|
-
|
|
446
|
-
- [ ] **Step 3: Add to `src/tmux.ts`**
|
|
447
|
-
|
|
448
|
-
```typescript
|
|
449
|
-
export async function selectWindow(name: string, windowTarget: string): Promise<void> {
|
|
450
|
-
await execa('tmux', ['select-window', '-t', `${name}:${windowTarget}`]);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
export async function selectPane(target: string): Promise<void> {
|
|
454
|
-
await execa('tmux', ['select-pane', '-t', target]);
|
|
455
|
-
}
|
|
456
|
-
```
|
|
457
|
-
|
|
458
|
-
- [ ] **Step 4: Run, confirm PASS** (all tmux tests, including the 2 new).
|
|
459
|
-
|
|
460
|
-
- [ ] **Step 5: Commit**
|
|
461
|
-
|
|
462
|
-
```
|
|
463
|
-
git add src/tmux.ts tests/tmux.test.ts
|
|
464
|
-
git commit -m "$(cat <<'EOF'
|
|
465
|
-
feat(tmux): selectWindow + selectPane for TUI navigation
|
|
466
|
-
|
|
467
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
468
|
-
EOF
|
|
469
|
-
)"
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
---
|
|
473
|
-
|
|
474
|
-
## Task 6: Activity poller service
|
|
475
|
-
|
|
476
|
-
**Files:** `tests/tui/poll-activity.test.ts`, `src/tui/poll-activity.ts`
|
|
477
|
-
|
|
478
|
-
- [ ] **Step 1: Write the failing test**
|
|
479
|
-
|
|
480
|
-
```typescript
|
|
481
|
-
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
|
482
|
-
import { pollActivity } from '../../src/tui/poll-activity.js';
|
|
483
|
-
import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
|
|
484
|
-
import { makeTmpConfig } from '../helpers/tmp-config.js';
|
|
485
|
-
import { ensureOrchestraDir, writeState } from '../../src/state.js';
|
|
486
|
-
import { makeInitialState } from '../../src/state.types.js';
|
|
487
|
-
import { projectKeyFromPath } from '../../src/project-key.js';
|
|
488
|
-
import { addMusician } from '../../src/state-updaters.js';
|
|
489
|
-
import { createDetachedSession, sessionName, killSession, sendKeys } from '../../src/tmux.js';
|
|
490
|
-
import { readState } from '../../src/state.js';
|
|
491
|
-
import { execa } from 'execa';
|
|
492
|
-
|
|
493
|
-
describe('pollActivity', () => {
|
|
494
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
495
|
-
const sessionsToKill: string[] = [];
|
|
496
|
-
|
|
497
|
-
beforeEach(() => { process.env.NFO_HOME = ''; });
|
|
498
|
-
afterEach(async () => {
|
|
499
|
-
for (const s of sessionsToKill) {
|
|
500
|
-
try { await killSession(s); } catch { /* ignore */ }
|
|
501
|
-
}
|
|
502
|
-
sessionsToKill.length = 0;
|
|
503
|
-
for (const c of cleanups) { await c(); }
|
|
504
|
-
cleanups.length = 0;
|
|
505
|
-
delete process.env.NFO_HOME;
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
it('returns the last activity line per active musician', async () => {
|
|
509
|
-
const cfg = await makeTmpConfig();
|
|
510
|
-
cleanups.push(cfg.cleanup);
|
|
511
|
-
process.env.NFO_HOME = cfg.path;
|
|
512
|
-
const repo: TmpRepo = await makeTmpRepo();
|
|
513
|
-
cleanups.push(repo.cleanup);
|
|
514
|
-
const orchId = projectKeyFromPath(repo.path);
|
|
515
|
-
await ensureOrchestraDir(orchId);
|
|
516
|
-
await writeState(orchId, makeInitialState({
|
|
517
|
-
orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
|
|
518
|
-
}));
|
|
519
|
-
const sess = sessionName(orchId);
|
|
520
|
-
sessionsToKill.push(sess);
|
|
521
|
-
await createDetachedSession(sess, repo.path, 220, 50);
|
|
522
|
-
const { stdout: winId } = await execa('tmux', [
|
|
523
|
-
'new-window', '-t', sess, '-n', 'mus-001-x', '-c', repo.path, '-d',
|
|
524
|
-
'-P', '-F', '#{window_id}',
|
|
525
|
-
]);
|
|
526
|
-
await addMusician(orchId, {
|
|
527
|
-
id: 'mus-001', name: 'x', task_summary: 't', status: 'working',
|
|
528
|
-
tmux_window_id: winId.trim(), claude_session_id: null,
|
|
529
|
-
worktree_path: null, branch: null,
|
|
530
|
-
spawned_at: '2026-05-29T10:00:00Z', last_activity: '2026-05-29T10:00:00Z',
|
|
531
|
-
});
|
|
532
|
-
await sendKeys(`${sess}:${winId.trim()}`, 'echo nfo-activity-xyz', true);
|
|
533
|
-
await new Promise((r) => { setTimeout(r, 250); });
|
|
534
|
-
|
|
535
|
-
const state = await readState(orchId);
|
|
536
|
-
const activity = await pollActivity(state!);
|
|
537
|
-
expect(activity['mus-001']).toContain('nfo-activity-xyz');
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
it('skips stopped musicians', async () => {
|
|
541
|
-
const cfg = await makeTmpConfig();
|
|
542
|
-
cleanups.push(cfg.cleanup);
|
|
543
|
-
process.env.NFO_HOME = cfg.path;
|
|
544
|
-
const repo: TmpRepo = await makeTmpRepo();
|
|
545
|
-
cleanups.push(repo.cleanup);
|
|
546
|
-
const orchId = projectKeyFromPath(repo.path);
|
|
547
|
-
await ensureOrchestraDir(orchId);
|
|
548
|
-
const initial = makeInitialState({
|
|
549
|
-
orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
|
|
550
|
-
});
|
|
551
|
-
initial.musicians.push({
|
|
552
|
-
id: 'mus-001', name: 'x', task_summary: 't', status: 'stopped',
|
|
553
|
-
tmux_window_id: '@gone', claude_session_id: null,
|
|
554
|
-
worktree_path: null, branch: null,
|
|
555
|
-
spawned_at: '2026-05-29T10:00:00Z', last_activity: '2026-05-29T10:00:00Z',
|
|
556
|
-
});
|
|
557
|
-
await writeState(orchId, initial);
|
|
558
|
-
const state = await readState(orchId);
|
|
559
|
-
const activity = await pollActivity(state!);
|
|
560
|
-
expect(activity['mus-001']).toBeUndefined();
|
|
561
|
-
});
|
|
562
|
-
});
|
|
563
|
-
```
|
|
564
|
-
|
|
565
|
-
- [ ] **Step 2: Run, confirm FAIL.**
|
|
566
|
-
|
|
567
|
-
- [ ] **Step 3: Implement `src/tui/poll-activity.ts`**
|
|
568
|
-
|
|
569
|
-
```typescript
|
|
570
|
-
import { capturePane, sessionName } from '../tmux.js';
|
|
571
|
-
import { extractActivityLine } from './activity-line.js';
|
|
572
|
-
import type { OrchestraState } from '../state.types.js';
|
|
573
|
-
|
|
574
|
-
/**
|
|
575
|
-
* For each non-stopped musician, capture the last lines of its tmux window
|
|
576
|
-
* and reduce to a single activity hint. Failures (e.g. a window that no longer
|
|
577
|
-
* exists) are swallowed per-musician so one dead pane never breaks the poll.
|
|
578
|
-
*/
|
|
579
|
-
export async function pollActivity(state: OrchestraState): Promise<Record<string, string>> {
|
|
580
|
-
const session = sessionName(state.orchestra_id);
|
|
581
|
-
const result: Record<string, string> = {};
|
|
582
|
-
for (const musician of state.musicians) {
|
|
583
|
-
if (musician.status === 'stopped') {
|
|
584
|
-
continue;
|
|
585
|
-
}
|
|
586
|
-
try {
|
|
587
|
-
const pane = await capturePane(`${session}:${musician.tmux_window_id}`, 10);
|
|
588
|
-
result[musician.id] = extractActivityLine(pane);
|
|
589
|
-
} catch {
|
|
590
|
-
result[musician.id] = '';
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
return result;
|
|
594
|
-
}
|
|
595
|
-
```
|
|
596
|
-
|
|
597
|
-
- [ ] **Step 4: Run, confirm PASS** (2/2).
|
|
598
|
-
|
|
599
|
-
- [ ] **Step 5: Commit**
|
|
600
|
-
|
|
601
|
-
```
|
|
602
|
-
git add src/tui/poll-activity.ts tests/tui/poll-activity.test.ts
|
|
603
|
-
git commit -m "$(cat <<'EOF'
|
|
604
|
-
feat(tui): activity poller — capture-pane per musician into a hint map
|
|
605
|
-
|
|
606
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
607
|
-
EOF
|
|
608
|
-
)"
|
|
609
|
-
```
|
|
610
|
-
|
|
611
|
-
---
|
|
612
|
-
|
|
613
|
-
## Task 7: State watcher
|
|
614
|
-
|
|
615
|
-
**Files:** `tests/tui/watch-state.test.ts`, `src/tui/watch-state.ts`
|
|
616
|
-
|
|
617
|
-
- [ ] **Step 1: Write the failing test**
|
|
618
|
-
|
|
619
|
-
```typescript
|
|
620
|
-
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
|
621
|
-
import { watchOrchestraState } from '../../src/tui/watch-state.js';
|
|
622
|
-
import { makeTmpConfig } from '../helpers/tmp-config.js';
|
|
623
|
-
import { ensureOrchestraDir, writeState } from '../../src/state.js';
|
|
624
|
-
import { makeInitialState } from '../../src/state.types.js';
|
|
625
|
-
import { setOrchestratorSessionId } from '../../src/state-updaters.js';
|
|
626
|
-
import type { OrchestraState } from '../../src/state.types.js';
|
|
627
|
-
|
|
628
|
-
describe('watchOrchestraState', () => {
|
|
629
|
-
const cleanups: Array<() => Promise<void>> = [];
|
|
630
|
-
const stops: Array<() => Promise<void>> = [];
|
|
631
|
-
|
|
632
|
-
beforeEach(() => { process.env.NFO_HOME = ''; });
|
|
633
|
-
afterEach(async () => {
|
|
634
|
-
for (const stop of stops) { await stop(); }
|
|
635
|
-
stops.length = 0;
|
|
636
|
-
for (const c of cleanups) { await c(); }
|
|
637
|
-
cleanups.length = 0;
|
|
638
|
-
delete process.env.NFO_HOME;
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
it('emits the current state immediately and again on change', async () => {
|
|
642
|
-
const cfg = await makeTmpConfig();
|
|
643
|
-
cleanups.push(cfg.cleanup);
|
|
644
|
-
process.env.NFO_HOME = cfg.path;
|
|
645
|
-
await ensureOrchestraDir('orch-w');
|
|
646
|
-
await writeState('orch-w', makeInitialState({
|
|
647
|
-
orchestraId: 'orch-w', projectPath: '/tmp/x', permissionLevel: 'supervised',
|
|
648
|
-
}));
|
|
649
|
-
|
|
650
|
-
const seen: OrchestraState[] = [];
|
|
651
|
-
const stop = await watchOrchestraState('orch-w', (s) => { seen.push(s); });
|
|
652
|
-
stops.push(stop);
|
|
653
|
-
|
|
654
|
-
// initial emit
|
|
655
|
-
await waitFor(() => { return seen.length >= 1; });
|
|
656
|
-
expect(seen[0].orchestra_id).toBe('orch-w');
|
|
657
|
-
|
|
658
|
-
// mutate → expect another emit
|
|
659
|
-
await setOrchestratorSessionId('orch-w', 'sess-123');
|
|
660
|
-
await waitFor(() => { return seen.some((s) => { return s.orchestrator_session_id === 'sess-123'; }); }, 4000);
|
|
661
|
-
expect(seen.some((s) => { return s.orchestrator_session_id === 'sess-123'; })).toBe(true);
|
|
662
|
-
});
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
async function waitFor(pred: () => boolean, timeoutMs = 3000): Promise<void> {
|
|
666
|
-
const start = Date.now();
|
|
667
|
-
while (!pred()) {
|
|
668
|
-
if (Date.now() - start > timeoutMs) {
|
|
669
|
-
throw new Error('Timed out');
|
|
670
|
-
}
|
|
671
|
-
await new Promise((r) => { setTimeout(r, 25); });
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
```
|
|
675
|
-
|
|
676
|
-
- [ ] **Step 2: Run, confirm FAIL.**
|
|
677
|
-
|
|
678
|
-
- [ ] **Step 3: Implement `src/tui/watch-state.ts`**
|
|
679
|
-
|
|
680
|
-
```typescript
|
|
681
|
-
import chokidar from 'chokidar';
|
|
682
|
-
import { stateFile } from '../config.js';
|
|
683
|
-
import { readState } from '../state.js';
|
|
684
|
-
import type { OrchestraState } from '../state.types.js';
|
|
685
|
-
|
|
686
|
-
export type StopWatching = () => Promise<void>;
|
|
687
|
-
|
|
688
|
-
/**
|
|
689
|
-
* Watch an orchestra's state.json and invoke `onChange` with the parsed state:
|
|
690
|
-
* once immediately, then on every file change. A chokidar watcher handles the
|
|
691
|
-
* common case; a 1s poll fallback covers filesystems without reliable inotify.
|
|
692
|
-
* Reads that fail mid-write (partial JSON) are swallowed — the next event wins.
|
|
693
|
-
*/
|
|
694
|
-
export async function watchOrchestraState(
|
|
695
|
-
orchestraId: string,
|
|
696
|
-
onChange: (state: OrchestraState) => void,
|
|
697
|
-
): Promise<StopWatching> {
|
|
698
|
-
const file = stateFile(orchestraId);
|
|
699
|
-
|
|
700
|
-
async function emit(): Promise<void> {
|
|
701
|
-
try {
|
|
702
|
-
const state = await readState(orchestraId);
|
|
703
|
-
if (state) {
|
|
704
|
-
onChange(state);
|
|
705
|
-
}
|
|
706
|
-
} catch {
|
|
707
|
-
// partial write / transient read error — ignore, next tick re-reads
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
await emit();
|
|
712
|
-
|
|
713
|
-
const watcher = chokidar.watch(file, { ignoreInitial: true });
|
|
714
|
-
watcher.on('change', () => { void emit(); });
|
|
715
|
-
watcher.on('add', () => { void emit(); });
|
|
716
|
-
|
|
717
|
-
const poll = setInterval(() => { void emit(); }, 1000);
|
|
718
|
-
|
|
719
|
-
return async () => {
|
|
720
|
-
clearInterval(poll);
|
|
721
|
-
await watcher.close();
|
|
722
|
-
};
|
|
723
|
-
}
|
|
724
|
-
```
|
|
725
|
-
|
|
726
|
-
- [ ] **Step 4: Run, confirm PASS.** If chokidar's change event is flaky on the dev FS, the 1s poll fallback still satisfies the test within the 4s window — that is by design. Do not weaken the assertion.
|
|
727
|
-
|
|
728
|
-
- [ ] **Step 5: Commit**
|
|
729
|
-
|
|
730
|
-
```
|
|
731
|
-
git add src/tui/watch-state.ts tests/tui/watch-state.test.ts
|
|
732
|
-
git commit -m "$(cat <<'EOF'
|
|
733
|
-
feat(tui): watch state.json (chokidar + 1s poll fallback)
|
|
734
|
-
|
|
735
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
736
|
-
EOF
|
|
737
|
-
)"
|
|
738
|
-
```
|
|
739
|
-
|
|
740
|
-
---
|
|
741
|
-
|
|
742
|
-
## Task 8: Keyboard reducer
|
|
743
|
-
|
|
744
|
-
**Files:** `tests/tui/keymap.test.ts`, `src/tui/keymap.ts`
|
|
745
|
-
|
|
746
|
-
The reducer is pure: it maps the current UI state + a keypress to a new UI state and an optional side-effect action. The container performs the action.
|
|
747
|
-
|
|
748
|
-
- [ ] **Step 1: Write the failing test**
|
|
749
|
-
|
|
750
|
-
```typescript
|
|
751
|
-
import { describe, it, expect } from 'vitest';
|
|
752
|
-
import { reduceKey, type UiState, type KeyInput } from '../../src/tui/keymap.js';
|
|
753
|
-
|
|
754
|
-
function ui(over: Partial<UiState> = {}): UiState {
|
|
755
|
-
return { selectedIndex: 0, musicianCount: 3, ...over };
|
|
756
|
-
}
|
|
757
|
-
function key(over: Partial<KeyInput> = {}): KeyInput {
|
|
758
|
-
return { input: '', downArrow: false, upArrow: false, tab: false, shiftTab: false, return: false, ...over };
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
describe('reduceKey', () => {
|
|
762
|
-
it('down arrow / j moves selection down, clamped', () => {
|
|
763
|
-
expect(reduceKey(ui({ selectedIndex: 0 }), key({ downArrow: true })).ui.selectedIndex).toBe(1);
|
|
764
|
-
expect(reduceKey(ui({ selectedIndex: 0 }), key({ input: 'j' })).ui.selectedIndex).toBe(1);
|
|
765
|
-
expect(reduceKey(ui({ selectedIndex: 2, musicianCount: 3 }), key({ downArrow: true })).ui.selectedIndex).toBe(2);
|
|
766
|
-
});
|
|
767
|
-
it('up arrow / k moves selection up, clamped', () => {
|
|
768
|
-
expect(reduceKey(ui({ selectedIndex: 2 }), key({ upArrow: true })).ui.selectedIndex).toBe(1);
|
|
769
|
-
expect(reduceKey(ui({ selectedIndex: 0 }), key({ input: 'k' })).ui.selectedIndex).toBe(0);
|
|
770
|
-
});
|
|
771
|
-
it('Enter emits an enter-musician action for the selected index', () => {
|
|
772
|
-
const r = reduceKey(ui({ selectedIndex: 1 }), key({ return: true }));
|
|
773
|
-
expect(r.action).toEqual({ kind: 'enter-musician', index: 1 });
|
|
774
|
-
});
|
|
775
|
-
it('Enter with zero musicians emits no action', () => {
|
|
776
|
-
const r = reduceKey(ui({ selectedIndex: 0, musicianCount: 0 }), key({ return: true }));
|
|
777
|
-
expect(r.action).toBeUndefined();
|
|
778
|
-
});
|
|
779
|
-
it('Tab emits next-orchestra, Shift-Tab prev-orchestra', () => {
|
|
780
|
-
expect(reduceKey(ui(), key({ tab: true })).action).toEqual({ kind: 'next-orchestra' });
|
|
781
|
-
expect(reduceKey(ui(), key({ shiftTab: true })).action).toEqual({ kind: 'prev-orchestra' });
|
|
782
|
-
});
|
|
783
|
-
it('n emits open-notes, d emits dismiss, q emits focus-orchestrator', () => {
|
|
784
|
-
expect(reduceKey(ui(), key({ input: 'n' })).action).toEqual({ kind: 'open-notes' });
|
|
785
|
-
expect(reduceKey(ui({ selectedIndex: 2 }), key({ input: 'd' })).action).toEqual({ kind: 'dismiss-musician', index: 2 });
|
|
786
|
-
expect(reduceKey(ui(), key({ input: 'q' })).action).toEqual({ kind: 'focus-orchestrator' });
|
|
787
|
-
});
|
|
788
|
-
it('unknown key is a no-op', () => {
|
|
789
|
-
const r = reduceKey(ui({ selectedIndex: 1 }), key({ input: 'z' }));
|
|
790
|
-
expect(r.ui.selectedIndex).toBe(1);
|
|
791
|
-
expect(r.action).toBeUndefined();
|
|
792
|
-
});
|
|
793
|
-
});
|
|
794
|
-
```
|
|
795
|
-
|
|
796
|
-
- [ ] **Step 2: Run, confirm FAIL.**
|
|
797
|
-
|
|
798
|
-
- [ ] **Step 3: Implement `src/tui/keymap.ts`**
|
|
799
|
-
|
|
800
|
-
```typescript
|
|
801
|
-
export interface UiState {
|
|
802
|
-
selectedIndex: number;
|
|
803
|
-
musicianCount: number;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
export interface KeyInput {
|
|
807
|
-
input: string;
|
|
808
|
-
downArrow: boolean;
|
|
809
|
-
upArrow: boolean;
|
|
810
|
-
tab: boolean;
|
|
811
|
-
shiftTab: boolean;
|
|
812
|
-
return: boolean;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
export type KeyAction =
|
|
816
|
-
| { kind: 'enter-musician'; index: number }
|
|
817
|
-
| { kind: 'dismiss-musician'; index: number }
|
|
818
|
-
| { kind: 'next-orchestra' }
|
|
819
|
-
| { kind: 'prev-orchestra' }
|
|
820
|
-
| { kind: 'open-notes' }
|
|
821
|
-
| { kind: 'focus-orchestrator' };
|
|
822
|
-
|
|
823
|
-
export interface ReduceResult {
|
|
824
|
-
ui: UiState;
|
|
825
|
-
action?: KeyAction;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
function clamp(value: number, min: number, max: number): number {
|
|
829
|
-
if (value < min) {
|
|
830
|
-
return min;
|
|
831
|
-
}
|
|
832
|
-
if (value > max) {
|
|
833
|
-
return max;
|
|
834
|
-
}
|
|
835
|
-
return value;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
export function reduceKey(ui: UiState, key: KeyInput): ReduceResult {
|
|
839
|
-
const maxIndex = Math.max(0, ui.musicianCount - 1);
|
|
840
|
-
|
|
841
|
-
if (key.downArrow || key.input === 'j') {
|
|
842
|
-
return { ui: { ...ui, selectedIndex: clamp(ui.selectedIndex + 1, 0, maxIndex) } };
|
|
843
|
-
}
|
|
844
|
-
if (key.upArrow || key.input === 'k') {
|
|
845
|
-
return { ui: { ...ui, selectedIndex: clamp(ui.selectedIndex - 1, 0, maxIndex) } };
|
|
846
|
-
}
|
|
847
|
-
if (key.tab) {
|
|
848
|
-
return { ui, action: { kind: 'next-orchestra' } };
|
|
849
|
-
}
|
|
850
|
-
if (key.shiftTab) {
|
|
851
|
-
return { ui, action: { kind: 'prev-orchestra' } };
|
|
852
|
-
}
|
|
853
|
-
if (key.return) {
|
|
854
|
-
if (ui.musicianCount === 0) {
|
|
855
|
-
return { ui };
|
|
856
|
-
}
|
|
857
|
-
return { ui, action: { kind: 'enter-musician', index: ui.selectedIndex } };
|
|
858
|
-
}
|
|
859
|
-
if (key.input === 'n') {
|
|
860
|
-
return { ui, action: { kind: 'open-notes' } };
|
|
861
|
-
}
|
|
862
|
-
if (key.input === 'd') {
|
|
863
|
-
if (ui.musicianCount === 0) {
|
|
864
|
-
return { ui };
|
|
865
|
-
}
|
|
866
|
-
return { ui, action: { kind: 'dismiss-musician', index: ui.selectedIndex } };
|
|
867
|
-
}
|
|
868
|
-
if (key.input === 'q') {
|
|
869
|
-
return { ui, action: { kind: 'focus-orchestrator' } };
|
|
870
|
-
}
|
|
871
|
-
return { ui };
|
|
872
|
-
}
|
|
873
|
-
```
|
|
874
|
-
|
|
875
|
-
- [ ] **Step 4: Run, confirm PASS.**
|
|
876
|
-
|
|
877
|
-
- [ ] **Step 5: Commit**
|
|
878
|
-
|
|
879
|
-
```
|
|
880
|
-
git add src/tui/keymap.ts tests/tui/keymap.test.ts
|
|
881
|
-
git commit -m "$(cat <<'EOF'
|
|
882
|
-
feat(tui): pure keyboard reducer (nav + actions)
|
|
883
|
-
|
|
884
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
885
|
-
EOF
|
|
886
|
-
)"
|
|
887
|
-
```
|
|
888
|
-
|
|
889
|
-
---
|
|
890
|
-
|
|
891
|
-
## Task 9: Presentational components (StatusBar, Auditorium, ConcertHall)
|
|
892
|
-
|
|
893
|
-
**Files:** `src/tui/StatusBar.tsx`, `src/tui/Auditorium.tsx`, `src/tui/ConcertHall.tsx`, `tests/tui/StatusBar.test.tsx`, `tests/tui/Auditorium.test.tsx`, `tests/tui/ConcertHall.test.tsx`
|
|
894
|
-
|
|
895
|
-
All three are pure: props in, JSX out. No hooks, no side effects.
|
|
896
|
-
|
|
897
|
-
- [ ] **Step 1: Write `tests/tui/StatusBar.test.tsx`**
|
|
898
|
-
|
|
899
|
-
```tsx
|
|
900
|
-
import { describe, it, expect } from 'vitest';
|
|
901
|
-
import { render } from 'ink-testing-library';
|
|
902
|
-
import { StatusBar } from '../../src/tui/StatusBar.js';
|
|
903
|
-
|
|
904
|
-
describe('StatusBar', () => {
|
|
905
|
-
it('shows permission level and the token placeholder', () => {
|
|
906
|
-
const { lastFrame } = render(<StatusBar permissionLevel="supervised" tokenHint="—" />);
|
|
907
|
-
const frame = lastFrame() ?? '';
|
|
908
|
-
expect(frame).toContain('supervised');
|
|
909
|
-
expect(frame).toContain('—');
|
|
910
|
-
});
|
|
911
|
-
it('shows key hints', () => {
|
|
912
|
-
const { lastFrame } = render(<StatusBar permissionLevel="auto" tokenHint="—" />);
|
|
913
|
-
expect(lastFrame() ?? '').toContain('nav');
|
|
914
|
-
});
|
|
915
|
-
});
|
|
916
|
-
```
|
|
917
|
-
|
|
918
|
-
- [ ] **Step 2: Implement `src/tui/StatusBar.tsx`**
|
|
919
|
-
|
|
920
|
-
```tsx
|
|
921
|
-
import { Box, Text } from 'ink';
|
|
922
|
-
|
|
923
|
-
export interface StatusBarProps {
|
|
924
|
-
permissionLevel: string;
|
|
925
|
-
tokenHint: string;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
export function StatusBar(props: StatusBarProps): JSX.Element {
|
|
929
|
-
return (
|
|
930
|
-
<Box flexDirection="column" borderStyle="single" borderTop={true} paddingX={1}>
|
|
931
|
-
<Text>
|
|
932
|
-
{props.permissionLevel} · {props.tokenHint}
|
|
933
|
-
</Text>
|
|
934
|
-
<Text dimColor={true}>[↑↓] nav [⏎] enter [n] notes [d] dismiss [q] back</Text>
|
|
935
|
-
</Box>
|
|
936
|
-
);
|
|
937
|
-
}
|
|
938
|
-
```
|
|
939
|
-
|
|
940
|
-
If `JSX.Element` is not in scope under the automatic runtime, use `import type { ReactElement } from 'react';` and return `ReactElement`. Pick whichever the build accepts; report which.
|
|
941
|
-
|
|
942
|
-
- [ ] **Step 3: Write `tests/tui/Auditorium.test.tsx`**
|
|
943
|
-
|
|
944
|
-
```tsx
|
|
945
|
-
import { describe, it, expect } from 'vitest';
|
|
946
|
-
import { render } from 'ink-testing-library';
|
|
947
|
-
import { Auditorium } from '../../src/tui/Auditorium.js';
|
|
948
|
-
import type { Musician } from '../../src/state.types.js';
|
|
949
|
-
|
|
950
|
-
function mus(over: Partial<Musician>): Musician {
|
|
951
|
-
return {
|
|
952
|
-
id: 'mus-001', name: 'tester', task_summary: 't', status: 'working',
|
|
953
|
-
tmux_window_id: '@1', claude_session_id: null, worktree_path: null, branch: null,
|
|
954
|
-
spawned_at: '2026-05-29T10:00:00Z', last_activity: '2026-05-29T10:00:00Z',
|
|
955
|
-
...over,
|
|
956
|
-
};
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
describe('Auditorium', () => {
|
|
960
|
-
it('renders one row per musician with name and activity', () => {
|
|
961
|
-
const musicians = [
|
|
962
|
-
mus({ id: 'mus-001', name: 'alpha' }),
|
|
963
|
-
mus({ id: 'mus-002', name: 'beta', status: 'idle' }),
|
|
964
|
-
];
|
|
965
|
-
const activity = { 'mus-001': 'Running tests', 'mus-002': 'done' };
|
|
966
|
-
const { lastFrame } = render(
|
|
967
|
-
<Auditorium musicians={musicians} activity={activity} selectedIndex={0} now="2026-05-29T10:02:00Z" />,
|
|
968
|
-
);
|
|
969
|
-
const frame = lastFrame() ?? '';
|
|
970
|
-
expect(frame).toContain('alpha');
|
|
971
|
-
expect(frame).toContain('beta');
|
|
972
|
-
expect(frame).toContain('Running tests');
|
|
973
|
-
});
|
|
974
|
-
it('marks the selected row', () => {
|
|
975
|
-
const musicians = [mus({ id: 'mus-001', name: 'alpha' })];
|
|
976
|
-
const { lastFrame } = render(
|
|
977
|
-
<Auditorium musicians={musicians} activity={{}} selectedIndex={0} now="2026-05-29T10:02:00Z" />,
|
|
978
|
-
);
|
|
979
|
-
expect(lastFrame() ?? '').toContain('▸');
|
|
980
|
-
});
|
|
981
|
-
it('shows an empty-state message when there are no musicians', () => {
|
|
982
|
-
const { lastFrame } = render(
|
|
983
|
-
<Auditorium musicians={[]} activity={{}} selectedIndex={0} now="2026-05-29T10:02:00Z" />,
|
|
984
|
-
);
|
|
985
|
-
expect(lastFrame() ?? '').toContain('No musicians');
|
|
986
|
-
});
|
|
987
|
-
});
|
|
988
|
-
```
|
|
989
|
-
|
|
990
|
-
- [ ] **Step 4: Implement `src/tui/Auditorium.tsx`**
|
|
991
|
-
|
|
992
|
-
```tsx
|
|
993
|
-
import { Box, Text } from 'ink';
|
|
994
|
-
import type { Musician } from '../state.types.js';
|
|
995
|
-
import { statusIcon, statusColor } from './status-icon.js';
|
|
996
|
-
import { formatRelativeTime } from './format-time.js';
|
|
997
|
-
|
|
998
|
-
export interface AuditoriumProps {
|
|
999
|
-
musicians: Musician[];
|
|
1000
|
-
activity: Record<string, string>;
|
|
1001
|
-
selectedIndex: number;
|
|
1002
|
-
now: string;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
export function Auditorium(props: AuditoriumProps): JSX.Element {
|
|
1006
|
-
if (props.musicians.length === 0) {
|
|
1007
|
-
return (
|
|
1008
|
-
<Box flexDirection="column" paddingX={1}>
|
|
1009
|
-
<Text bold={true}>Auditorium</Text>
|
|
1010
|
-
<Text dimColor={true}>No musicians yet.</Text>
|
|
1011
|
-
</Box>
|
|
1012
|
-
);
|
|
1013
|
-
}
|
|
1014
|
-
return (
|
|
1015
|
-
<Box flexDirection="column" paddingX={1}>
|
|
1016
|
-
<Text bold={true}>Auditorium</Text>
|
|
1017
|
-
{props.musicians.map((m, i) => {
|
|
1018
|
-
const selected = i === props.selectedIndex;
|
|
1019
|
-
const marker = selected ? '▸' : ' ';
|
|
1020
|
-
const since = formatRelativeTime(m.last_activity, props.now);
|
|
1021
|
-
const line = props.activity[m.id] ?? '';
|
|
1022
|
-
return (
|
|
1023
|
-
<Box key={m.id} flexDirection="column">
|
|
1024
|
-
<Text>
|
|
1025
|
-
{marker} <Text color={statusColor(m.status)}>{statusIcon(m.status)}</Text> {m.id} {m.name}
|
|
1026
|
-
</Text>
|
|
1027
|
-
<Text dimColor={true}> {since} · {line}</Text>
|
|
1028
|
-
</Box>
|
|
1029
|
-
);
|
|
1030
|
-
})}
|
|
1031
|
-
</Box>
|
|
1032
|
-
);
|
|
1033
|
-
}
|
|
1034
|
-
```
|
|
1035
|
-
|
|
1036
|
-
- [ ] **Step 5: Write `tests/tui/ConcertHall.test.tsx`**
|
|
1037
|
-
|
|
1038
|
-
```tsx
|
|
1039
|
-
import { describe, it, expect } from 'vitest';
|
|
1040
|
-
import { render } from 'ink-testing-library';
|
|
1041
|
-
import { ConcertHall } from '../../src/tui/ConcertHall.js';
|
|
1042
|
-
import type { OrchestraSummary } from '../../src/commands/list.js';
|
|
1043
|
-
|
|
1044
|
-
function orch(over: Partial<OrchestraSummary>): OrchestraSummary {
|
|
1045
|
-
return {
|
|
1046
|
-
id: 'aaa-one', project_path: '/tmp/one', permission_level: 'supervised',
|
|
1047
|
-
created_at: '2026-05-29T10:00:00Z', running: true, musician_count: 2, ...over,
|
|
1048
|
-
};
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
describe('ConcertHall', () => {
|
|
1052
|
-
it('lists orchestras and marks the current one', () => {
|
|
1053
|
-
const list = [orch({ id: 'aaa-one' }), orch({ id: 'bbb-two', running: false })];
|
|
1054
|
-
const { lastFrame } = render(<ConcertHall orchestras={list} currentId="aaa-one" />);
|
|
1055
|
-
const frame = lastFrame() ?? '';
|
|
1056
|
-
expect(frame).toContain('aaa-one');
|
|
1057
|
-
expect(frame).toContain('bbb-two');
|
|
1058
|
-
expect(frame).toContain('▸');
|
|
1059
|
-
});
|
|
1060
|
-
});
|
|
1061
|
-
```
|
|
1062
|
-
|
|
1063
|
-
- [ ] **Step 6: Implement `src/tui/ConcertHall.tsx`**
|
|
1064
|
-
|
|
1065
|
-
```tsx
|
|
1066
|
-
import { Box, Text } from 'ink';
|
|
1067
|
-
import type { OrchestraSummary } from '../commands/list.js';
|
|
1068
|
-
|
|
1069
|
-
export interface ConcertHallProps {
|
|
1070
|
-
orchestras: OrchestraSummary[];
|
|
1071
|
-
currentId: string;
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
export function ConcertHall(props: ConcertHallProps): JSX.Element {
|
|
1075
|
-
return (
|
|
1076
|
-
<Box flexDirection="column" borderStyle="single" borderBottom={true} paddingX={1}>
|
|
1077
|
-
<Text bold={true}>Concert Hall</Text>
|
|
1078
|
-
{props.orchestras.map((o) => {
|
|
1079
|
-
const current = o.id === props.currentId;
|
|
1080
|
-
const marker = current ? '▸' : ' ';
|
|
1081
|
-
const dot = o.running ? '●' : '○';
|
|
1082
|
-
return (
|
|
1083
|
-
<Text key={o.id} bold={current}>
|
|
1084
|
-
{marker} {dot} {o.id} ({o.musician_count})
|
|
1085
|
-
</Text>
|
|
1086
|
-
);
|
|
1087
|
-
})}
|
|
1088
|
-
</Box>
|
|
1089
|
-
);
|
|
1090
|
-
}
|
|
1091
|
-
```
|
|
1092
|
-
|
|
1093
|
-
- [ ] **Step 7: Run all three test files** — `npm test -- tui/StatusBar tui/Auditorium tui/ConcertHall`. Confirm all pass. Then `npm run build` (tsc must accept the JSX + the `JSX.Element`/`ReactElement` return type).
|
|
1094
|
-
|
|
1095
|
-
- [ ] **Step 8: Commit**
|
|
1096
|
-
|
|
1097
|
-
```
|
|
1098
|
-
git add src/tui/StatusBar.tsx src/tui/Auditorium.tsx src/tui/ConcertHall.tsx tests/tui/StatusBar.test.tsx tests/tui/Auditorium.test.tsx tests/tui/ConcertHall.test.tsx
|
|
1099
|
-
git commit -m "$(cat <<'EOF'
|
|
1100
|
-
feat(tui): StatusBar, Auditorium, ConcertHall presentational components
|
|
1101
|
-
|
|
1102
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
1103
|
-
EOF
|
|
1104
|
-
)"
|
|
1105
|
-
```
|
|
1106
|
-
|
|
1107
|
-
---
|
|
1108
|
-
|
|
1109
|
-
## Task 10: AppView composition
|
|
1110
|
-
|
|
1111
|
-
**Files:** `src/tui/AppView.tsx`, `tests/tui/AppView.test.tsx`
|
|
1112
|
-
|
|
1113
|
-
`AppView` is the presentational composition: it arranges ConcertHall (top), Auditorium (middle), StatusBar (bottom) from props. No hooks.
|
|
1114
|
-
|
|
1115
|
-
- [ ] **Step 1: Write `tests/tui/AppView.test.tsx`**
|
|
1116
|
-
|
|
1117
|
-
```tsx
|
|
1118
|
-
import { describe, it, expect } from 'vitest';
|
|
1119
|
-
import { render } from 'ink-testing-library';
|
|
1120
|
-
import { AppView } from '../../src/tui/AppView.js';
|
|
1121
|
-
import type { Musician } from '../../src/state.types.js';
|
|
1122
|
-
import type { OrchestraSummary } from '../../src/commands/list.js';
|
|
1123
|
-
|
|
1124
|
-
const musicians: Musician[] = [{
|
|
1125
|
-
id: 'mus-001', name: 'alpha', task_summary: 't', status: 'working',
|
|
1126
|
-
tmux_window_id: '@1', claude_session_id: null, worktree_path: null, branch: null,
|
|
1127
|
-
spawned_at: '2026-05-29T10:00:00Z', last_activity: '2026-05-29T10:00:00Z',
|
|
1128
|
-
}];
|
|
1129
|
-
const orchestras: OrchestraSummary[] = [{
|
|
1130
|
-
id: 'aaa-one', project_path: '/tmp/one', permission_level: 'supervised',
|
|
1131
|
-
created_at: '2026-05-29T10:00:00Z', running: true, musician_count: 1,
|
|
1132
|
-
}];
|
|
1133
|
-
|
|
1134
|
-
describe('AppView', () => {
|
|
1135
|
-
it('renders concert hall, auditorium, and status bar together', () => {
|
|
1136
|
-
const { lastFrame } = render(
|
|
1137
|
-
<AppView
|
|
1138
|
-
orchestras={orchestras}
|
|
1139
|
-
currentId="aaa-one"
|
|
1140
|
-
musicians={musicians}
|
|
1141
|
-
activity={{ 'mus-001': 'building' }}
|
|
1142
|
-
selectedIndex={0}
|
|
1143
|
-
permissionLevel="supervised"
|
|
1144
|
-
tokenHint="—"
|
|
1145
|
-
now="2026-05-29T10:01:00Z"
|
|
1146
|
-
/>,
|
|
1147
|
-
);
|
|
1148
|
-
const frame = lastFrame() ?? '';
|
|
1149
|
-
expect(frame).toContain('Concert Hall');
|
|
1150
|
-
expect(frame).toContain('Auditorium');
|
|
1151
|
-
expect(frame).toContain('alpha');
|
|
1152
|
-
expect(frame).toContain('building');
|
|
1153
|
-
expect(frame).toContain('supervised');
|
|
1154
|
-
});
|
|
1155
|
-
});
|
|
1156
|
-
```
|
|
1157
|
-
|
|
1158
|
-
- [ ] **Step 2: Implement `src/tui/AppView.tsx`**
|
|
1159
|
-
|
|
1160
|
-
```tsx
|
|
1161
|
-
import { Box } from 'ink';
|
|
1162
|
-
import type { Musician } from '../state.types.js';
|
|
1163
|
-
import type { OrchestraSummary } from '../commands/list.js';
|
|
1164
|
-
import { ConcertHall } from './ConcertHall.js';
|
|
1165
|
-
import { Auditorium } from './Auditorium.js';
|
|
1166
|
-
import { StatusBar } from './StatusBar.js';
|
|
1167
|
-
|
|
1168
|
-
export interface AppViewProps {
|
|
1169
|
-
orchestras: OrchestraSummary[];
|
|
1170
|
-
currentId: string;
|
|
1171
|
-
musicians: Musician[];
|
|
1172
|
-
activity: Record<string, string>;
|
|
1173
|
-
selectedIndex: number;
|
|
1174
|
-
permissionLevel: string;
|
|
1175
|
-
tokenHint: string;
|
|
1176
|
-
now: string;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
export function AppView(props: AppViewProps): JSX.Element {
|
|
1180
|
-
return (
|
|
1181
|
-
<Box flexDirection="column">
|
|
1182
|
-
<ConcertHall orchestras={props.orchestras} currentId={props.currentId} />
|
|
1183
|
-
<Auditorium
|
|
1184
|
-
musicians={props.musicians}
|
|
1185
|
-
activity={props.activity}
|
|
1186
|
-
selectedIndex={props.selectedIndex}
|
|
1187
|
-
now={props.now}
|
|
1188
|
-
/>
|
|
1189
|
-
<StatusBar permissionLevel={props.permissionLevel} tokenHint={props.tokenHint} />
|
|
1190
|
-
</Box>
|
|
1191
|
-
);
|
|
1192
|
-
}
|
|
1193
|
-
```
|
|
1194
|
-
|
|
1195
|
-
- [ ] **Step 3: Run, confirm PASS.** `npm test -- tui/AppView`.
|
|
1196
|
-
|
|
1197
|
-
- [ ] **Step 4: Commit**
|
|
1198
|
-
|
|
1199
|
-
```
|
|
1200
|
-
git add src/tui/AppView.tsx tests/tui/AppView.test.tsx
|
|
1201
|
-
git commit -m "$(cat <<'EOF'
|
|
1202
|
-
feat(tui): AppView composition of the three panes
|
|
1203
|
-
|
|
1204
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
1205
|
-
EOF
|
|
1206
|
-
)"
|
|
1207
|
-
```
|
|
1208
|
-
|
|
1209
|
-
---
|
|
1210
|
-
|
|
1211
|
-
## Task 11: App container (hooks + input wiring)
|
|
1212
|
-
|
|
1213
|
-
**Files:** `src/tui/App.tsx`
|
|
1214
|
-
|
|
1215
|
-
The container holds state, runs the two polling loops via effects, wires `useInput` → `reduceKey` → side effects. It is the least unit-testable piece (timers + tmux side effects), so it stays THIN: all logic it uses is already tested (reduceKey, pollActivity, watchOrchestraState, listOrchestras, selectWindow/selectPane, openNotes, dismissMusician). No new test file for App — it is covered by the manual smoke in Task 14. Keep it small enough to read in one screen.
|
|
1216
|
-
|
|
1217
|
-
- [ ] **Step 1: Implement `src/tui/App.tsx`**
|
|
1218
|
-
|
|
1219
|
-
```tsx
|
|
1220
|
-
import { useEffect, useState } from 'react';
|
|
1221
|
-
import { useApp, useInput } from 'ink';
|
|
1222
|
-
import { AppView } from './AppView.js';
|
|
1223
|
-
import { reduceKey } from './keymap.js';
|
|
1224
|
-
import { pollActivity } from './poll-activity.js';
|
|
1225
|
-
import { watchOrchestraState, type StopWatching } from './watch-state.js';
|
|
1226
|
-
import { listOrchestras, type OrchestraSummary } from '../commands/list.js';
|
|
1227
|
-
import { selectWindow, selectPane, sessionName } from '../tmux.js';
|
|
1228
|
-
import { openNotes } from '../commands/notes.js';
|
|
1229
|
-
import { dismissMusician } from '../musicians/dismiss.js';
|
|
1230
|
-
import { readState } from '../state.js';
|
|
1231
|
-
import type { Musician, OrchestraState } from '../state.types.js';
|
|
1232
|
-
|
|
1233
|
-
export interface AppProps {
|
|
1234
|
-
orchestraId: string;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
export function App(props: AppProps): JSX.Element {
|
|
1238
|
-
const { exit } = useApp();
|
|
1239
|
-
const [state, setState] = useState<OrchestraState | null>(null);
|
|
1240
|
-
const [orchestras, setOrchestras] = useState<OrchestraSummary[]>([]);
|
|
1241
|
-
const [activity, setActivity] = useState<Record<string, string>>({});
|
|
1242
|
-
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
1243
|
-
const [now, setNow] = useState(new Date().toISOString());
|
|
1244
|
-
|
|
1245
|
-
// Watch state.json.
|
|
1246
|
-
useEffect(() => {
|
|
1247
|
-
let stop: StopWatching | undefined;
|
|
1248
|
-
void watchOrchestraState(props.orchestraId, (s) => { setState(s); }).then((fn) => { stop = fn; });
|
|
1249
|
-
return () => {
|
|
1250
|
-
if (stop) {
|
|
1251
|
-
void stop();
|
|
1252
|
-
}
|
|
1253
|
-
};
|
|
1254
|
-
}, [props.orchestraId]);
|
|
1255
|
-
|
|
1256
|
-
// Poll activity + clock every 2s.
|
|
1257
|
-
useEffect(() => {
|
|
1258
|
-
const tick = async (): Promise<void> => {
|
|
1259
|
-
setNow(new Date().toISOString());
|
|
1260
|
-
const s = await readState(props.orchestraId);
|
|
1261
|
-
if (s) {
|
|
1262
|
-
const a = await pollActivity(s);
|
|
1263
|
-
setActivity(a);
|
|
1264
|
-
}
|
|
1265
|
-
};
|
|
1266
|
-
void tick();
|
|
1267
|
-
const timer = setInterval(() => { void tick(); }, 2000);
|
|
1268
|
-
return () => { clearInterval(timer); };
|
|
1269
|
-
}, [props.orchestraId]);
|
|
1270
|
-
|
|
1271
|
-
// Refresh the orchestra list every 3s.
|
|
1272
|
-
useEffect(() => {
|
|
1273
|
-
const tick = async (): Promise<void> => {
|
|
1274
|
-
const list = await listOrchestras();
|
|
1275
|
-
setOrchestras(list);
|
|
1276
|
-
};
|
|
1277
|
-
void tick();
|
|
1278
|
-
const timer = setInterval(() => { void tick(); }, 3000);
|
|
1279
|
-
return () => { clearInterval(timer); };
|
|
1280
|
-
}, []);
|
|
1281
|
-
|
|
1282
|
-
const musicians: Musician[] = state ? state.musicians : [];
|
|
1283
|
-
const session = sessionName(props.orchestraId);
|
|
1284
|
-
|
|
1285
|
-
useInput((input, key) => {
|
|
1286
|
-
// Ink reports key.tab=true for BOTH Tab and Shift-Tab (with key.shift set on
|
|
1287
|
-
// the latter). Disambiguate so the reducer's `tab`-before-`shiftTab` order is
|
|
1288
|
-
// correct: plain Tab only when shift is NOT held.
|
|
1289
|
-
const isTab = key.tab && !key.shift;
|
|
1290
|
-
const isShiftTab = key.tab && key.shift;
|
|
1291
|
-
const result = reduceKey(
|
|
1292
|
-
{ selectedIndex, musicianCount: musicians.length },
|
|
1293
|
-
{
|
|
1294
|
-
input,
|
|
1295
|
-
downArrow: key.downArrow,
|
|
1296
|
-
upArrow: key.upArrow,
|
|
1297
|
-
tab: isTab,
|
|
1298
|
-
shiftTab: isShiftTab,
|
|
1299
|
-
return: key.return,
|
|
1300
|
-
},
|
|
1301
|
-
);
|
|
1302
|
-
setSelectedIndex(result.ui.selectedIndex);
|
|
1303
|
-
if (!result.action) {
|
|
1304
|
-
return;
|
|
1305
|
-
}
|
|
1306
|
-
const action = result.action;
|
|
1307
|
-
if (action.kind === 'enter-musician') {
|
|
1308
|
-
const m = musicians[action.index];
|
|
1309
|
-
if (m) {
|
|
1310
|
-
void selectWindow(session, m.tmux_window_id);
|
|
1311
|
-
}
|
|
1312
|
-
return;
|
|
1313
|
-
}
|
|
1314
|
-
if (action.kind === 'focus-orchestrator') {
|
|
1315
|
-
void selectPane(`${session}:0.0`);
|
|
1316
|
-
return;
|
|
1317
|
-
}
|
|
1318
|
-
if (action.kind === 'open-notes') {
|
|
1319
|
-
void openNotes(props.orchestraId);
|
|
1320
|
-
return;
|
|
1321
|
-
}
|
|
1322
|
-
if (action.kind === 'dismiss-musician') {
|
|
1323
|
-
const m = musicians[action.index];
|
|
1324
|
-
if (m) {
|
|
1325
|
-
void dismissMusician({ orchestraId: props.orchestraId, musicianId: m.id });
|
|
1326
|
-
}
|
|
1327
|
-
return;
|
|
1328
|
-
}
|
|
1329
|
-
// next-orchestra / prev-orchestra: Phase 3 leaves switching to a later
|
|
1330
|
-
// iteration (attaching a different session from inside Ink needs care);
|
|
1331
|
-
// for now these are no-ops beyond selection. Intentionally do nothing.
|
|
1332
|
-
});
|
|
1333
|
-
|
|
1334
|
-
// `exit` is wired so a future quit key can call it; unused for now.
|
|
1335
|
-
void exit;
|
|
1336
|
-
|
|
1337
|
-
const permissionLevel = state ? state.permission_level : '…';
|
|
1338
|
-
|
|
1339
|
-
return (
|
|
1340
|
-
<AppView
|
|
1341
|
-
orchestras={orchestras}
|
|
1342
|
-
currentId={props.orchestraId}
|
|
1343
|
-
musicians={musicians}
|
|
1344
|
-
activity={activity}
|
|
1345
|
-
selectedIndex={selectedIndex}
|
|
1346
|
-
permissionLevel={permissionLevel}
|
|
1347
|
-
tokenHint="—"
|
|
1348
|
-
now={now}
|
|
1349
|
-
/>
|
|
1350
|
-
);
|
|
1351
|
-
}
|
|
1352
|
-
```
|
|
1353
|
-
|
|
1354
|
-
NOTE on `shiftTab`: Ink exposes `key.shift` and `key.tab`; we derive shift-tab as `key.shift && key.tab`. NOTE on `next-orchestra`/`prev-orchestra`: switching the attached tmux session from within the Ink pane is deferred (documented inline) — the Concert Hall still renders all orchestras; actually switching is a Phase 3.1/Phase 4 refinement. Do NOT implement session-switching here; leave the documented no-op.
|
|
1355
|
-
|
|
1356
|
-
- [ ] **Step 2: Build + typecheck**
|
|
1357
|
-
|
|
1358
|
-
```
|
|
1359
|
-
npm run build
|
|
1360
|
-
npm run typecheck
|
|
1361
|
-
npm test
|
|
1362
|
-
```
|
|
1363
|
-
All pass. App has no unit test (covered by Task 14 smoke), but it MUST compile cleanly and not break the suite.
|
|
1364
|
-
|
|
1365
|
-
- [ ] **Step 3: Self-review** — confirm explicit-block style: every `if` braced, arrow callbacks use explicit returns where they return values (the `.then((fn) => { stop = fn; })` and `setState((s)=>...)` are void bodies — fine as braced statement blocks; the `useInput((input, key) => { ... })` is a void callback — fine).
|
|
1366
|
-
|
|
1367
|
-
- [ ] **Step 4: Commit**
|
|
1368
|
-
|
|
1369
|
-
```
|
|
1370
|
-
git add src/tui/App.tsx
|
|
1371
|
-
git commit -m "$(cat <<'EOF'
|
|
1372
|
-
feat(tui): App container — state watch, activity poll, input wiring
|
|
1373
|
-
|
|
1374
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
1375
|
-
EOF
|
|
1376
|
-
)"
|
|
1377
|
-
```
|
|
1378
|
-
|
|
1379
|
-
---
|
|
1380
|
-
|
|
1381
|
-
## Task 12: `nfo tui` subcommand
|
|
1382
|
-
|
|
1383
|
-
**Files:** `src/commands/tui.ts`, `src/cli.ts` (MODIFY)
|
|
1384
|
-
|
|
1385
|
-
- [ ] **Step 1: Implement `src/commands/tui.ts`**
|
|
1386
|
-
|
|
1387
|
-
```tsx
|
|
1388
|
-
import { render } from 'ink';
|
|
1389
|
-
import { App } from '../tui/App.js';
|
|
1390
|
-
import { readState } from '../state.js';
|
|
1391
|
-
|
|
1392
|
-
export interface RunTuiOptions {
|
|
1393
|
-
orchestraId: string;
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
export async function runTui(opts: RunTuiOptions): Promise<void> {
|
|
1397
|
-
const state = await readState(opts.orchestraId);
|
|
1398
|
-
if (!state) {
|
|
1399
|
-
throw new Error(`Unknown orchestra: ${opts.orchestraId}`);
|
|
1400
|
-
}
|
|
1401
|
-
const instance = render(<App orchestraId={opts.orchestraId} />);
|
|
1402
|
-
await instance.waitUntilExit();
|
|
1403
|
-
}
|
|
1404
|
-
```
|
|
1405
|
-
|
|
1406
|
-
(This file is `.tsx` because it renders JSX.)
|
|
1407
|
-
|
|
1408
|
-
- [ ] **Step 2: Wire the `tui` subcommand in `src/cli.ts`**
|
|
1409
|
-
|
|
1410
|
-
Among the other `program.command(...)` registrations (after `mcp-server`), add:
|
|
1411
|
-
|
|
1412
|
-
```typescript
|
|
1413
|
-
program
|
|
1414
|
-
.command('tui', { hidden: true })
|
|
1415
|
-
.description('(internal) Run the NFO Ink TUI for an orchestra')
|
|
1416
|
-
.requiredOption('--orchestra-id <id>', 'Orchestra id')
|
|
1417
|
-
.action(async (opts: { orchestraId: string }) => {
|
|
1418
|
-
const { runTui } = await import('./commands/tui.js');
|
|
1419
|
-
await runTui({ orchestraId: opts.orchestraId });
|
|
1420
|
-
});
|
|
1421
|
-
```
|
|
1422
|
-
|
|
1423
|
-
- [ ] **Step 3: Build + typecheck + test**
|
|
1424
|
-
|
|
1425
|
-
```
|
|
1426
|
-
npm run build
|
|
1427
|
-
npm run typecheck
|
|
1428
|
-
npm test
|
|
1429
|
-
```
|
|
1430
|
-
All pass. Confirm `node dist/cli.js --help` does NOT show `tui` (hidden), and `node dist/cli.js tui --help` works.
|
|
1431
|
-
|
|
1432
|
-
- [ ] **Step 4: Commit**
|
|
1433
|
-
|
|
1434
|
-
```
|
|
1435
|
-
git add src/commands/tui.ts src/cli.ts
|
|
1436
|
-
git commit -m "$(cat <<'EOF'
|
|
1437
|
-
feat(tui): hidden `nfo tui` subcommand renders the Ink app
|
|
1438
|
-
|
|
1439
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
1440
|
-
EOF
|
|
1441
|
-
)"
|
|
1442
|
-
```
|
|
1443
|
-
|
|
1444
|
-
---
|
|
1445
|
-
|
|
1446
|
-
## Task 13: Launch the TUI in the right pane (launch + restore)
|
|
1447
|
-
|
|
1448
|
-
**Files:** `src/commands/launch.ts` (MODIFY), `src/commands/restore.ts` (MODIFY), `tests/commands/launch.test.ts` (MODIFY)
|
|
1449
|
-
|
|
1450
|
-
Replace the placeholder shell in the right pane with `nfo tui --orchestra-id <id>`.
|
|
1451
|
-
|
|
1452
|
-
- [ ] **Step 1: Update `createOrchestra` in `src/commands/launch.ts`**
|
|
1453
|
-
|
|
1454
|
-
Find the placeholder split:
|
|
1455
|
-
```typescript
|
|
1456
|
-
const placeholderShell = `bash -c 'echo "NFO Auditorium pane (placeholder — Phase 3 ships the Ink TUI)" && echo "Orchestra: ${opts.orchestraId}" && echo "Permission: ${opts.permissionLevel}" && exec ${process.env.SHELL ?? '/bin/bash'}'`;
|
|
1457
|
-
await splitWindowHorizontal(`${name}:0`, 77, placeholderShell);
|
|
1458
|
-
```
|
|
1459
|
-
Replace with:
|
|
1460
|
-
```typescript
|
|
1461
|
-
const tuiCommand = `nfo tui --orchestra-id ${opts.orchestraId}`;
|
|
1462
|
-
await splitWindowHorizontal(`${name}:0`, 77, tuiCommand);
|
|
1463
|
-
```
|
|
1464
|
-
|
|
1465
|
-
- [ ] **Step 2: Update `restoreOrchestra` in `src/commands/restore.ts`**
|
|
1466
|
-
|
|
1467
|
-
Find its placeholder split:
|
|
1468
|
-
```typescript
|
|
1469
|
-
const placeholderShell = `bash -c 'echo "NFO Auditorium pane (placeholder)" && echo "Restored orchestra ${orchestraId}" && exec ${process.env.SHELL ?? '/bin/bash'}'`;
|
|
1470
|
-
await splitWindowHorizontal(`${name}:0`, 77, placeholderShell);
|
|
1471
|
-
```
|
|
1472
|
-
Replace with:
|
|
1473
|
-
```typescript
|
|
1474
|
-
const tuiCommand = `nfo tui --orchestra-id ${orchestraId}`;
|
|
1475
|
-
await splitWindowHorizontal(`${name}:0`, 77, tuiCommand);
|
|
1476
|
-
```
|
|
1477
|
-
|
|
1478
|
-
- [ ] **Step 3: Update `tests/commands/launch.test.ts`**
|
|
1479
|
-
|
|
1480
|
-
The existing create test asserts the tmux session exists. The placeholder is gone, but in `dryRun` the session + split still happen and the tmux command text sent into the right pane is `nfo tui ...` (which won't actually run `nfo` in the test env — that's fine; the split pane just runs the command which may error, but the session and panes exist). No assertion currently checks the pane command, so no test change is strictly required. HOWEVER, add a lightweight assertion that the session has two panes after createOrchestra, to lock in the split:
|
|
1481
|
-
|
|
1482
|
-
Add near the end of the create test (after the mcp-config assertions):
|
|
1483
|
-
```typescript
|
|
1484
|
-
const { execa } = await import('execa');
|
|
1485
|
-
const { stdout: paneCount } = await execa('tmux', [
|
|
1486
|
-
'list-panes', '-t', `${sessionName(result.orchestraId)}:0`, '-F', '#{pane_index}',
|
|
1487
|
-
]);
|
|
1488
|
-
expect(paneCount.trim().split('\n').length).toBe(2);
|
|
1489
|
-
```
|
|
1490
|
-
Ensure `sessionName` is imported in the test (it already is, from prior tasks). If not, add it.
|
|
1491
|
-
|
|
1492
|
-
- [ ] **Step 4: Build + typecheck + test**
|
|
1493
|
-
|
|
1494
|
-
```
|
|
1495
|
-
npm run build
|
|
1496
|
-
npm test
|
|
1497
|
-
```
|
|
1498
|
-
All pass. The new pane-count assertion confirms the split happened.
|
|
1499
|
-
|
|
1500
|
-
- [ ] **Step 5: Commit**
|
|
1501
|
-
|
|
1502
|
-
```
|
|
1503
|
-
git add src/commands/launch.ts src/commands/restore.ts tests/commands/launch.test.ts
|
|
1504
|
-
git commit -m "$(cat <<'EOF'
|
|
1505
|
-
feat(tui): run `nfo tui` in the right pane on launch and restore
|
|
1506
|
-
|
|
1507
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
1508
|
-
EOF
|
|
1509
|
-
)"
|
|
1510
|
-
```
|
|
1511
|
-
|
|
1512
|
-
---
|
|
1513
|
-
|
|
1514
|
-
## Task 14: Manual end-to-end smoke
|
|
1515
|
-
|
|
1516
|
-
Not a code task — a verification gate. The TUI is interactive and not unit-tested at the container level, so this is where we confirm it actually renders and navigates.
|
|
1517
|
-
|
|
1518
|
-
- [ ] **Step 1: Build + link**
|
|
1519
|
-
|
|
1520
|
-
```
|
|
1521
|
-
npm run build
|
|
1522
|
-
npm link # so `nfo` resolves on PATH (the split pane runs `nfo tui ...`)
|
|
1523
|
-
```
|
|
1524
|
-
|
|
1525
|
-
- [ ] **Step 2: Launch a real orchestra and observe the TUI**
|
|
1526
|
-
|
|
1527
|
-
```
|
|
1528
|
-
export NFO_HOME=/tmp/nfo-phase3-home
|
|
1529
|
-
rm -rf "$NFO_HOME" /tmp/nfo-phase3-repo
|
|
1530
|
-
mkdir /tmp/nfo-phase3-repo && cd /tmp/nfo-phase3-repo
|
|
1531
|
-
git init -q && git commit --allow-empty -m init
|
|
1532
|
-
nfo # pick supervised
|
|
1533
|
-
```
|
|
1534
|
-
Confirm: the right pane shows the Ink TUI — "Concert Hall" with this orchestra marked, an "Auditorium" reading "No musicians yet.", and the status bar showing `supervised · —` plus key hints.
|
|
1535
|
-
|
|
1536
|
-
- [ ] **Step 3: Spawn a musician from the Orchestrator pane and watch the Auditorium update**
|
|
1537
|
-
|
|
1538
|
-
In the left (Orchestrator) pane, ask claude: "Use spawn_musician with name 'echo-test' and task 'print hello then wait'." Within ~2 s the Auditorium should show a `● mus-001 echo-test` row with an activity line. Use `↓`/`↑` to move the `▸` marker. Press `Enter` on the musician → tmux should switch to that musician's window. Switch back to window 0 (`prefix 0`). Press `q` in the TUI pane → focus returns to the Orchestrator pane.
|
|
1539
|
-
|
|
1540
|
-
- [ ] **Step 4: Report findings**
|
|
1541
|
-
|
|
1542
|
-
Document anything that didn't render or navigate as expected. Known acceptable gaps: Tab/Shift-Tab orchestra switching is a documented no-op in Phase 3; token hint is always `—`.
|
|
1543
|
-
|
|
1544
|
-
- [ ] **Step 5 (cleanup):** `npm unlink` is optional; leave the link if convenient for further testing.
|
|
1545
|
-
|
|
1546
|
-
---
|
|
1547
|
-
|
|
1548
|
-
## Task 15: README for Phase 3
|
|
1549
|
-
|
|
1550
|
-
**Files:** `README.md` (MODIFY)
|
|
1551
|
-
|
|
1552
|
-
- [ ] **Step 1: Update the Status section**
|
|
1553
|
-
|
|
1554
|
-
Replace the Phase 2 status paragraph with:
|
|
1555
|
-
|
|
1556
|
-
```markdown
|
|
1557
|
-
## Status
|
|
1558
|
-
|
|
1559
|
-
Phase 3. Everything from Phase 2, plus a real Ink TUI in the right tmux pane: a **Concert Hall** listing all orchestras, an **Auditorium** showing the live musician roster (status icon, time since last activity, a one-line activity hint), and a status bar. Keyboard nav: `↑/↓` (or `j/k`) to move, `⏎` to jump into a Musician's tmux window, `n` to open notes, `d` to dismiss the selected Musician, `q` to return to the Orchestrator pane. Permission-prompt detection and Concert Hall orchestra-switching ship in a later phase.
|
|
1560
|
-
```
|
|
1561
|
-
|
|
1562
|
-
- [ ] **Step 2: Commit**
|
|
1563
|
-
|
|
1564
|
-
```
|
|
1565
|
-
git add README.md
|
|
1566
|
-
git commit -m "$(cat <<'EOF'
|
|
1567
|
-
docs: README for Phase 3 (Ink TUI)
|
|
1568
|
-
|
|
1569
|
-
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
1570
|
-
EOF
|
|
1571
|
-
)"
|
|
1572
|
-
```
|
|
1573
|
-
|
|
1574
|
-
---
|
|
1575
|
-
|
|
1576
|
-
## Task 16: Final audit + tag
|
|
1577
|
-
|
|
1578
|
-
Verification gate. Do NOT write code except the tag.
|
|
1579
|
-
|
|
1580
|
-
- [ ] **Step 1: Build + typecheck + test**
|
|
1581
|
-
|
|
1582
|
-
```
|
|
1583
|
-
npm run build
|
|
1584
|
-
npm run typecheck
|
|
1585
|
-
npm test
|
|
1586
|
-
```
|
|
1587
|
-
Report pass/fail and the exact test count (Phase 3 adds roughly: format-time 6, status-icon 2, activity-line 4, tmux +2, poll-activity 2, watch-state 1, keymap 7, StatusBar 2, Auditorium 3, ConcertHall 1, AppView 1 — about 31 new, for ~90 total).
|
|
1588
|
-
|
|
1589
|
-
- [ ] **Step 2: Style audit of Phase 3 source**
|
|
1590
|
-
|
|
1591
|
-
Grep every `src/tui/*.ts(x)`, `src/commands/tui.ts`, and the edited regions of `launch.ts`/`restore.ts`/`tmux.ts`/`cli.ts` for: brace-less `if`/`for`/`while`/`else`; implicit-return arrow callbacks in `.map/.filter/.find/.some/.forEach` (e.g. `.map(m => <Row/>)` — must be `.map((m) => { return <Row/>; })`). Report violations with file:line. React component bodies must be `(props) => { return (<JSX/>); }`.
|
|
1592
|
-
|
|
1593
|
-
- [ ] **Step 3: Commit-trailer audit**
|
|
1594
|
-
|
|
1595
|
-
```
|
|
1596
|
-
git log phase-2-complete..HEAD --format='%H %s' | while read sha rest; do echo "$sha"; git log -1 --format='%b' "$sha" | grep -i 'co-authored' || echo " NO TRAILER"; done
|
|
1597
|
-
```
|
|
1598
|
-
Confirm every Phase 3 commit uses `Claude Opus 4.8`.
|
|
1599
|
-
|
|
1600
|
-
- [ ] **Step 4: Confirm the toolchain decisions**
|
|
1601
|
-
|
|
1602
|
-
Report: the final pinned versions of ink / react / ink-testing-library / chokidar, and whether `JSX.Element` or `ReactElement` was used as the component return type.
|
|
1603
|
-
|
|
1604
|
-
- [ ] **Step 5: Tag (only if Steps 1-2 pass clean)**
|
|
1605
|
-
|
|
1606
|
-
```
|
|
1607
|
-
git tag phase-3-complete
|
|
1608
|
-
git tag -l
|
|
1609
|
-
```
|
|
1610
|
-
|
|
1611
|
-
- [ ] **Step 6: Verdict** — PHASE 3 READY | NEEDS FIXES, with test summary and any Phase 4 follow-ups (e.g. permission-prompt detection, Concert Hall orchestra-switching, real token hint, shared `sanitiseName` util noted in Phase 2).
|