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.
Files changed (178) hide show
  1. package/dist/claude-command.js +6 -1
  2. package/dist/claude-command.js.map +1 -1
  3. package/dist/claude-trust.js +46 -0
  4. package/dist/claude-trust.js.map +1 -0
  5. package/dist/cli.js +5 -4
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/attach.js +8 -8
  8. package/dist/commands/attach.js.map +1 -1
  9. package/dist/commands/launch.js +3 -6
  10. package/dist/commands/launch.js.map +1 -1
  11. package/dist/commands/restore.js +6 -10
  12. package/dist/commands/restore.js.map +1 -1
  13. package/dist/commands/tui.js +17 -1
  14. package/dist/commands/tui.js.map +1 -1
  15. package/dist/mcp/handlers.js +5 -0
  16. package/dist/mcp/handlers.js.map +1 -1
  17. package/dist/mcp/tool-defs.js +10 -0
  18. package/dist/mcp/tool-defs.js.map +1 -1
  19. package/dist/musicians/dismiss.js +1 -1
  20. package/dist/musicians/dismiss.js.map +1 -1
  21. package/dist/musicians/reconcile.js +27 -0
  22. package/dist/musicians/reconcile.js.map +1 -0
  23. package/dist/musicians/roles.js +15 -0
  24. package/dist/musicians/roles.js.map +1 -0
  25. package/dist/musicians/spawn.js +53 -18
  26. package/dist/musicians/spawn.js.map +1 -1
  27. package/dist/permission.js +6 -0
  28. package/dist/permission.js.map +1 -1
  29. package/dist/prompts/musician-role.js +2 -1
  30. package/dist/prompts/musician-role.js.map +1 -1
  31. package/dist/prompts/orchestrator-role.js +18 -6
  32. package/dist/prompts/orchestrator-role.js.map +1 -1
  33. package/dist/prompts/tool-discipline.js +7 -3
  34. package/dist/prompts/tool-discipline.js.map +1 -1
  35. package/dist/tmux.js +23 -0
  36. package/dist/tmux.js.map +1 -1
  37. package/dist/tui/components/App.js +8 -12
  38. package/dist/tui/components/App.js.map +1 -1
  39. package/dist/tui/components/Help.js +1 -1
  40. package/dist/tui/components/Help.js.map +1 -1
  41. package/dist/tui/keymap.js +1 -1
  42. package/dist/tui/keymap.js.map +1 -1
  43. package/package.json +18 -8
  44. package/assets/agent-screen.png +0 -0
  45. package/assets/main-screen.png +0 -0
  46. package/assets/orche-clawd.png +0 -0
  47. package/dist/tui/App.js +0 -428
  48. package/dist/tui/App.js.map +0 -1
  49. package/dist/tui/AppView.js +0 -13
  50. package/dist/tui/AppView.js.map +0 -1
  51. package/dist/tui/Auditorium.js +0 -17
  52. package/dist/tui/Auditorium.js.map +0 -1
  53. package/dist/tui/ConcertHall.js +0 -11
  54. package/dist/tui/ConcertHall.js.map +0 -1
  55. package/dist/tui/Help.js +0 -49
  56. package/dist/tui/Help.js.map +0 -1
  57. package/dist/tui/OrchestratorPane.js +0 -34
  58. package/dist/tui/OrchestratorPane.js.map +0 -1
  59. package/dist/tui/SidebarHeader.js +0 -6
  60. package/dist/tui/SidebarHeader.js.map +0 -1
  61. package/dist/tui/StatusBar.js +0 -6
  62. package/dist/tui/StatusBar.js.map +0 -1
  63. package/docs/plans/2026-05-29-nfo-phase-1-bootstrap.md +0 -2152
  64. package/docs/plans/2026-05-29-nfo-phase-2-mcp-musicians.md +0 -2467
  65. package/docs/plans/2026-05-29-nfo-phase-3-ink-tui.md +0 -1611
  66. package/docs/plans/2026-05-29-nfo-phase-4-permission-prompts.md +0 -460
  67. package/docs/plans/2026-05-29-nfo-phase-5-help-and-notify.md +0 -933
  68. package/docs/specs/2026-05-29-nfo-design.md +0 -468
  69. package/plan-explorer-musician-hardening.md +0 -56
  70. package/src/claude-command.ts +0 -35
  71. package/src/claude-detect.ts +0 -42
  72. package/src/cli.ts +0 -197
  73. package/src/commands/attach.ts +0 -24
  74. package/src/commands/dashboard-window.ts +0 -33
  75. package/src/commands/kill.ts +0 -50
  76. package/src/commands/launch.ts +0 -134
  77. package/src/commands/list.ts +0 -43
  78. package/src/commands/mcp-server.ts +0 -18
  79. package/src/commands/notes.ts +0 -18
  80. package/src/commands/restore.ts +0 -153
  81. package/src/commands/tui.tsx +0 -22
  82. package/src/config.ts +0 -44
  83. package/src/dashboard.ts +0 -1
  84. package/src/mcp/config.ts +0 -39
  85. package/src/mcp/handlers.ts +0 -141
  86. package/src/mcp/server.ts +0 -50
  87. package/src/mcp/tool-defs.ts +0 -151
  88. package/src/musicians/dismiss.ts +0 -60
  89. package/src/musicians/ids.ts +0 -21
  90. package/src/musicians/lookup.ts +0 -13
  91. package/src/musicians/message-log.ts +0 -152
  92. package/src/musicians/message.ts +0 -99
  93. package/src/musicians/query.ts +0 -19
  94. package/src/musicians/spawn.ts +0 -139
  95. package/src/notes.ts +0 -39
  96. package/src/notify.ts +0 -62
  97. package/src/orchestrator/report-back.ts +0 -33
  98. package/src/permission.ts +0 -30
  99. package/src/project-key.ts +0 -12
  100. package/src/prompts/musician-role.ts +0 -22
  101. package/src/prompts/orchestrator-role.ts +0 -84
  102. package/src/prompts/tool-discipline.ts +0 -41
  103. package/src/repo.ts +0 -14
  104. package/src/shell-quote.ts +0 -7
  105. package/src/state-updaters.ts +0 -132
  106. package/src/state.ts +0 -49
  107. package/src/state.types.ts +0 -67
  108. package/src/tmux.ts +0 -226
  109. package/src/tui/activity-line.ts +0 -16
  110. package/src/tui/components/App.tsx +0 -534
  111. package/src/tui/components/AppView.tsx +0 -98
  112. package/src/tui/components/Auditorium.tsx +0 -56
  113. package/src/tui/components/ConcertHall.tsx +0 -31
  114. package/src/tui/components/Help.tsx +0 -63
  115. package/src/tui/components/OrchestratorPane.tsx +0 -98
  116. package/src/tui/components/SidebarHeader.tsx +0 -34
  117. package/src/tui/components/StatusBar.tsx +0 -42
  118. package/src/tui/detect-permission.ts +0 -93
  119. package/src/tui/embedded-session-lifecycle.ts +0 -44
  120. package/src/tui/embedded-terminal.ts +0 -325
  121. package/src/tui/format-time.ts +0 -25
  122. package/src/tui/keymap.ts +0 -104
  123. package/src/tui/poll-activity.ts +0 -25
  124. package/src/tui/poll-idle.ts +0 -149
  125. package/src/tui/poll-permission.ts +0 -50
  126. package/src/tui/status-icon.ts +0 -35
  127. package/src/tui/terminal-input.ts +0 -136
  128. package/src/tui/watch-state.ts +0 -43
  129. package/src/worktree.ts +0 -41
  130. package/tests/claude-command.test.ts +0 -30
  131. package/tests/claude-detect.test.ts +0 -14
  132. package/tests/commands/attach.test.ts +0 -60
  133. package/tests/commands/kill.test.ts +0 -66
  134. package/tests/commands/launch.test.ts +0 -75
  135. package/tests/commands/list.test.ts +0 -47
  136. package/tests/commands/notes.test.ts +0 -53
  137. package/tests/commands/restore.test.ts +0 -126
  138. package/tests/helpers/tmp-config.ts +0 -16
  139. package/tests/helpers/tmp-repo.ts +0 -29
  140. package/tests/integration/orchestrator-spawn.test.ts +0 -108
  141. package/tests/mcp/handlers.test.ts +0 -163
  142. package/tests/mcp/tool-defs.test.ts +0 -35
  143. package/tests/musicians/dismiss.test.ts +0 -102
  144. package/tests/musicians/message.test.ts +0 -159
  145. package/tests/musicians/query.test.ts +0 -65
  146. package/tests/musicians/spawn.test.ts +0 -125
  147. package/tests/notes.test.ts +0 -56
  148. package/tests/notify.test.ts +0 -80
  149. package/tests/orchestrator/report-back.test.ts +0 -18
  150. package/tests/permission.test.ts +0 -39
  151. package/tests/project-key.test.ts +0 -33
  152. package/tests/prompts/tool-discipline.test.ts +0 -25
  153. package/tests/repo.test.ts +0 -38
  154. package/tests/state-updaters.test.ts +0 -126
  155. package/tests/state.test.ts +0 -85
  156. package/tests/tmux.test.ts +0 -126
  157. package/tests/tui/AppView.test.tsx +0 -92
  158. package/tests/tui/Auditorium.test.tsx +0 -67
  159. package/tests/tui/ConcertHall.test.tsx +0 -22
  160. package/tests/tui/Help.test.tsx +0 -38
  161. package/tests/tui/OrchestratorPane.test.ts +0 -30
  162. package/tests/tui/SidebarHeader.test.tsx +0 -20
  163. package/tests/tui/StatusBar.test.tsx +0 -51
  164. package/tests/tui/activity-line.test.ts +0 -21
  165. package/tests/tui/detect-permission.test.ts +0 -92
  166. package/tests/tui/embedded-session-lifecycle.test.ts +0 -55
  167. package/tests/tui/embedded-terminal.test.ts +0 -80
  168. package/tests/tui/format-time.test.ts +0 -25
  169. package/tests/tui/keymap.test.ts +0 -93
  170. package/tests/tui/poll-activity.test.ts +0 -81
  171. package/tests/tui/poll-idle.test.ts +0 -159
  172. package/tests/tui/poll-permission.test.ts +0 -222
  173. package/tests/tui/status-icon.test.ts +0 -27
  174. package/tests/tui/terminal-input.test.ts +0 -113
  175. package/tests/tui/watch-state.test.ts +0 -54
  176. package/tests/worktree.test.ts +0 -73
  177. package/tsconfig.json +0 -19
  178. 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).