nfo-cli 0.0.4-improve-prompting → 0.0.5
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 +0 -0
- 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/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/package.json +8 -1
- 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,325 +0,0 @@
|
|
|
1
|
-
import xtermHeadless from '@xterm/headless';
|
|
2
|
-
import type { IBufferCell, IBufferLine, Terminal as XTermTerminal } from '@xterm/headless';
|
|
3
|
-
import { spawn, type IPty } from 'node-pty';
|
|
4
|
-
|
|
5
|
-
const { Terminal } = xtermHeadless;
|
|
6
|
-
|
|
7
|
-
export interface EmbeddedTerminalSpan {
|
|
8
|
-
text: string;
|
|
9
|
-
color?: string;
|
|
10
|
-
backgroundColor?: string;
|
|
11
|
-
dimColor?: boolean;
|
|
12
|
-
bold?: boolean;
|
|
13
|
-
italic?: boolean;
|
|
14
|
-
underline?: boolean;
|
|
15
|
-
strikethrough?: boolean;
|
|
16
|
-
inverse?: boolean;
|
|
17
|
-
cursor?: boolean;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface EmbeddedTerminalLine {
|
|
21
|
-
spans: EmbeddedTerminalSpan[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface EmbeddedTerminalSnapshot {
|
|
25
|
-
title: string;
|
|
26
|
-
lines: EmbeddedTerminalLine[];
|
|
27
|
-
connected: boolean;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface EmbeddedTerminalOptions {
|
|
31
|
-
sessionName: string;
|
|
32
|
-
cwd: string;
|
|
33
|
-
cols: number;
|
|
34
|
-
rows: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
type Listener = (snapshot: EmbeddedTerminalSnapshot) => void;
|
|
38
|
-
type Disposable = { dispose(): void };
|
|
39
|
-
|
|
40
|
-
function toRgbColor(value: number): string {
|
|
41
|
-
const red = (value >> 16) & 0xff;
|
|
42
|
-
const green = (value >> 8) & 0xff;
|
|
43
|
-
const blue = value & 0xff;
|
|
44
|
-
return `rgb(${red}, ${green}, ${blue})`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function toInkColor(cell: IBufferCell, layer: 'foreground' | 'background'): string | undefined {
|
|
48
|
-
if (layer === 'foreground') {
|
|
49
|
-
if (cell.isFgRGB()) {
|
|
50
|
-
return toRgbColor(cell.getFgColor());
|
|
51
|
-
}
|
|
52
|
-
if (cell.isFgPalette()) {
|
|
53
|
-
return `ansi256(${cell.getFgColor()})`;
|
|
54
|
-
}
|
|
55
|
-
return undefined;
|
|
56
|
-
}
|
|
57
|
-
if (cell.isBgRGB()) {
|
|
58
|
-
return toRgbColor(cell.getBgColor());
|
|
59
|
-
}
|
|
60
|
-
if (cell.isBgPalette()) {
|
|
61
|
-
return `ansi256(${cell.getBgColor()})`;
|
|
62
|
-
}
|
|
63
|
-
return undefined;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function getSpanStyle(cell: IBufferCell): Omit<EmbeddedTerminalSpan, 'text'> {
|
|
67
|
-
const style: Omit<EmbeddedTerminalSpan, 'text'> = {};
|
|
68
|
-
const color = toInkColor(cell, 'foreground');
|
|
69
|
-
const backgroundColor = toInkColor(cell, 'background');
|
|
70
|
-
if (color !== undefined) {
|
|
71
|
-
style.color = color;
|
|
72
|
-
}
|
|
73
|
-
if (backgroundColor !== undefined) {
|
|
74
|
-
style.backgroundColor = backgroundColor;
|
|
75
|
-
}
|
|
76
|
-
if (cell.isDim() === 1) {
|
|
77
|
-
style.dimColor = true;
|
|
78
|
-
}
|
|
79
|
-
if (cell.isBold() === 1) {
|
|
80
|
-
style.bold = true;
|
|
81
|
-
}
|
|
82
|
-
if (cell.isItalic() === 1) {
|
|
83
|
-
style.italic = true;
|
|
84
|
-
}
|
|
85
|
-
if (cell.isUnderline() === 1) {
|
|
86
|
-
style.underline = true;
|
|
87
|
-
}
|
|
88
|
-
if (cell.isStrikethrough() === 1) {
|
|
89
|
-
style.strikethrough = true;
|
|
90
|
-
}
|
|
91
|
-
if (cell.isInverse() === 1) {
|
|
92
|
-
style.inverse = true;
|
|
93
|
-
}
|
|
94
|
-
return style;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function sameStyle(left: Omit<EmbeddedTerminalSpan, 'text'>, right: Omit<EmbeddedTerminalSpan, 'text'>): boolean {
|
|
98
|
-
return left.color === right.color
|
|
99
|
-
&& left.backgroundColor === right.backgroundColor
|
|
100
|
-
&& left.dimColor === right.dimColor
|
|
101
|
-
&& left.bold === right.bold
|
|
102
|
-
&& left.italic === right.italic
|
|
103
|
-
&& left.underline === right.underline
|
|
104
|
-
&& left.strikethrough === right.strikethrough
|
|
105
|
-
&& left.inverse === right.inverse
|
|
106
|
-
&& left.cursor === right.cursor;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function appendSpan(spans: EmbeddedTerminalSpan[], next: EmbeddedTerminalSpan): void {
|
|
110
|
-
if (next.text.length === 0) {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
const previous = spans.at(-1);
|
|
114
|
-
if (previous && sameStyle(previous, next)) {
|
|
115
|
-
previous.text += next.text;
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
spans.push({ ...next });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function hasStyle(span: EmbeddedTerminalSpan): boolean {
|
|
122
|
-
return span.color !== undefined
|
|
123
|
-
|| span.backgroundColor !== undefined
|
|
124
|
-
|| span.dimColor === true
|
|
125
|
-
|| span.bold === true
|
|
126
|
-
|| span.italic === true
|
|
127
|
-
|| span.underline === true
|
|
128
|
-
|| span.strikethrough === true
|
|
129
|
-
|| span.inverse === true
|
|
130
|
-
|| span.cursor === true;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function trimTrailingPlainWhitespace(spans: EmbeddedTerminalSpan[]): EmbeddedTerminalSpan[] {
|
|
134
|
-
const trimmed = spans.map((span) => {
|
|
135
|
-
return { ...span };
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
while (trimmed.length > 0) {
|
|
139
|
-
const last = trimmed.at(-1);
|
|
140
|
-
if (!last || hasStyle(last)) {
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
const text = last.text.replace(/\s+$/u, '');
|
|
144
|
-
if (text.length === 0) {
|
|
145
|
-
trimmed.pop();
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
if (text !== last.text) {
|
|
149
|
-
last.text = text;
|
|
150
|
-
}
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return trimmed;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function buildLineSnapshot(
|
|
158
|
-
line: IBufferLine | undefined,
|
|
159
|
-
cols: number,
|
|
160
|
-
blankCell: IBufferCell,
|
|
161
|
-
cursorColumn: number | null,
|
|
162
|
-
): EmbeddedTerminalLine {
|
|
163
|
-
if (!line) {
|
|
164
|
-
return cursorColumn === cols
|
|
165
|
-
? { spans: [{ text: ' ', cursor: true }] }
|
|
166
|
-
: { spans: [] };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const spans: EmbeddedTerminalSpan[] = [];
|
|
170
|
-
for (let col = 0; col < cols; col += 1) {
|
|
171
|
-
const cell = line.getCell(col, blankCell);
|
|
172
|
-
if (!cell) {
|
|
173
|
-
appendSpan(spans, { text: ' ' });
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
if (cell.getWidth() === 0) {
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const chars = cell.isInvisible() === 1
|
|
181
|
-
? ' '.repeat(Math.max(1, cell.getWidth()))
|
|
182
|
-
: (cell.getChars() || ' ');
|
|
183
|
-
|
|
184
|
-
appendSpan(spans, {
|
|
185
|
-
text: chars,
|
|
186
|
-
...getSpanStyle(cell),
|
|
187
|
-
...(cursorColumn === col ? { cursor: true } : {}),
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (cursorColumn === cols) {
|
|
192
|
-
appendSpan(spans, { text: ' ', cursor: true });
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return {
|
|
196
|
-
spans: trimTrailingPlainWhitespace(spans),
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export function buildSnapshot(terminal: XTermTerminal, title: string, connected: boolean): EmbeddedTerminalSnapshot {
|
|
201
|
-
const buffer = terminal.buffer.active;
|
|
202
|
-
const blankCell = buffer.getNullCell();
|
|
203
|
-
const cursorRow = (buffer.baseY + buffer.cursorY) - buffer.viewportY;
|
|
204
|
-
const visibleCursorRow = cursorRow >= 0 && cursorRow < terminal.rows ? cursorRow : null;
|
|
205
|
-
const visibleCursorColumn = visibleCursorRow !== null ? Math.min(buffer.cursorX, terminal.cols) : null;
|
|
206
|
-
const lines: EmbeddedTerminalLine[] = [];
|
|
207
|
-
for (let row = 0; row < terminal.rows; row += 1) {
|
|
208
|
-
const line = buffer.getLine(buffer.viewportY + row);
|
|
209
|
-
lines.push(buildLineSnapshot(
|
|
210
|
-
line,
|
|
211
|
-
terminal.cols,
|
|
212
|
-
blankCell,
|
|
213
|
-
visibleCursorRow === row ? visibleCursorColumn : null,
|
|
214
|
-
));
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return {
|
|
218
|
-
title,
|
|
219
|
-
lines,
|
|
220
|
-
connected,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export class EmbeddedTerminal {
|
|
225
|
-
private readonly terminal: XTermTerminal;
|
|
226
|
-
private readonly pty: IPty;
|
|
227
|
-
private readonly listeners = new Set<Listener>();
|
|
228
|
-
private readonly disposables: Disposable[] = [];
|
|
229
|
-
private title = 'Claude';
|
|
230
|
-
private connected = true;
|
|
231
|
-
|
|
232
|
-
public constructor(options: EmbeddedTerminalOptions) {
|
|
233
|
-
this.terminal = new Terminal({
|
|
234
|
-
allowProposedApi: true,
|
|
235
|
-
cols: options.cols,
|
|
236
|
-
rows: options.rows,
|
|
237
|
-
scrollback: 5000,
|
|
238
|
-
});
|
|
239
|
-
this.pty = spawn('tmux', ['attach-session', '-t', options.sessionName], {
|
|
240
|
-
name: 'xterm-256color',
|
|
241
|
-
cols: options.cols,
|
|
242
|
-
rows: options.rows,
|
|
243
|
-
cwd: options.cwd,
|
|
244
|
-
env: {
|
|
245
|
-
...process.env,
|
|
246
|
-
TERM: 'xterm-256color',
|
|
247
|
-
},
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
this.disposables.push(
|
|
251
|
-
this.pty.onData((data) => {
|
|
252
|
-
this.terminal.write(data, () => {
|
|
253
|
-
this.publish();
|
|
254
|
-
});
|
|
255
|
-
}),
|
|
256
|
-
this.pty.onExit(() => {
|
|
257
|
-
this.connected = false;
|
|
258
|
-
this.publish();
|
|
259
|
-
}),
|
|
260
|
-
this.terminal.onTitleChange((title) => {
|
|
261
|
-
this.title = title.length > 0 ? title : 'Claude';
|
|
262
|
-
this.publish();
|
|
263
|
-
}),
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
public onChange(listener: Listener): () => void {
|
|
268
|
-
this.listeners.add(listener);
|
|
269
|
-
listener(this.snapshot());
|
|
270
|
-
return () => {
|
|
271
|
-
this.listeners.delete(listener);
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
public snapshot(): EmbeddedTerminalSnapshot {
|
|
276
|
-
return buildSnapshot(this.terminal, this.title, this.connected);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
public resize(cols: number, rows: number): void {
|
|
280
|
-
this.pty.resize(cols, rows);
|
|
281
|
-
this.terminal.resize(cols, rows);
|
|
282
|
-
this.publish();
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
public scrollPages(pageCount: number): void {
|
|
286
|
-
this.terminal.scrollPages(pageCount);
|
|
287
|
-
this.publish();
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
public scrollLines(lineCount: number): void {
|
|
291
|
-
this.terminal.scrollLines(lineCount);
|
|
292
|
-
this.publish();
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
public scrollToTop(): void {
|
|
296
|
-
this.terminal.scrollToTop();
|
|
297
|
-
this.publish();
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
public scrollToBottom(): void {
|
|
301
|
-
this.terminal.scrollToBottom();
|
|
302
|
-
this.publish();
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
public write(data: string): void {
|
|
306
|
-
this.pty.write(data);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
public dispose(): void {
|
|
310
|
-
for (const disposable of this.disposables) {
|
|
311
|
-
disposable.dispose();
|
|
312
|
-
}
|
|
313
|
-
this.disposables.length = 0;
|
|
314
|
-
this.listeners.clear();
|
|
315
|
-
this.terminal.dispose();
|
|
316
|
-
this.pty.kill();
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
private publish(): void {
|
|
320
|
-
const snapshot = this.snapshot();
|
|
321
|
-
for (const listener of this.listeners) {
|
|
322
|
-
listener(snapshot);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
package/src/tui/format-time.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
export function formatRelativeTime(iso: string, nowIso: string): string {
|
|
2
|
-
const then = Date.parse(iso);
|
|
3
|
-
const now = Date.parse(nowIso);
|
|
4
|
-
if (Number.isNaN(then) || Number.isNaN(now)) {
|
|
5
|
-
return '?';
|
|
6
|
-
}
|
|
7
|
-
const deltaMs = now - then;
|
|
8
|
-
if (deltaMs < 1000) {
|
|
9
|
-
return '<1s';
|
|
10
|
-
}
|
|
11
|
-
const seconds = Math.floor(deltaMs / 1000);
|
|
12
|
-
if (seconds < 60) {
|
|
13
|
-
return `${seconds}s`;
|
|
14
|
-
}
|
|
15
|
-
const minutes = Math.floor(seconds / 60);
|
|
16
|
-
if (minutes < 60) {
|
|
17
|
-
return `${minutes}m`;
|
|
18
|
-
}
|
|
19
|
-
const hours = Math.floor(minutes / 60);
|
|
20
|
-
if (hours < 24) {
|
|
21
|
-
return `${hours}h`;
|
|
22
|
-
}
|
|
23
|
-
const days = Math.floor(hours / 24);
|
|
24
|
-
return `${days}d`;
|
|
25
|
-
}
|
package/src/tui/keymap.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
export interface UiState {
|
|
2
|
-
selectedIndex: number;
|
|
3
|
-
musicianCount: number;
|
|
4
|
-
pendingDismissIndex: number | null;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export interface KeyInput {
|
|
8
|
-
input: string;
|
|
9
|
-
downArrow: boolean;
|
|
10
|
-
upArrow: boolean;
|
|
11
|
-
tab: boolean;
|
|
12
|
-
shiftTab: boolean;
|
|
13
|
-
return: boolean;
|
|
14
|
-
escape: boolean;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export type KeyAction =
|
|
18
|
-
| { kind: 'open-target'; selectedIndex: number }
|
|
19
|
-
| { kind: 'request-dismiss-musician'; index: number }
|
|
20
|
-
| { kind: 'dismiss-musician'; index: number }
|
|
21
|
-
| { kind: 'next-orchestra' }
|
|
22
|
-
| { kind: 'prev-orchestra' }
|
|
23
|
-
| { kind: 'open-notes' }
|
|
24
|
-
| { kind: 'detach-session' }
|
|
25
|
-
| { kind: 'jump-to-pending' }
|
|
26
|
-
| { kind: 'toggle-help' };
|
|
27
|
-
|
|
28
|
-
export interface ReduceResult {
|
|
29
|
-
ui: UiState;
|
|
30
|
-
action?: KeyAction;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function clamp(value: number, min: number, max: number): number {
|
|
34
|
-
if (value < min) {
|
|
35
|
-
return min;
|
|
36
|
-
}
|
|
37
|
-
if (value > max) {
|
|
38
|
-
return max;
|
|
39
|
-
}
|
|
40
|
-
return value;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function reduceKey(ui: UiState, key: KeyInput): ReduceResult {
|
|
44
|
-
const maxIndex = Math.max(0, ui.musicianCount);
|
|
45
|
-
const selectedMusicianIndex = ui.selectedIndex - 1;
|
|
46
|
-
|
|
47
|
-
if (key.input === 'd') {
|
|
48
|
-
if (selectedMusicianIndex < 0 || selectedMusicianIndex >= ui.musicianCount) {
|
|
49
|
-
return { ui };
|
|
50
|
-
}
|
|
51
|
-
if (ui.pendingDismissIndex === selectedMusicianIndex) {
|
|
52
|
-
return {
|
|
53
|
-
ui: { ...ui, pendingDismissIndex: null },
|
|
54
|
-
action: { kind: 'dismiss-musician', index: selectedMusicianIndex },
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
return {
|
|
58
|
-
ui: { ...ui, pendingDismissIndex: selectedMusicianIndex },
|
|
59
|
-
action: { kind: 'request-dismiss-musician', index: selectedMusicianIndex },
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (ui.pendingDismissIndex !== null) {
|
|
64
|
-
if (key.input === 'y' || key.return) {
|
|
65
|
-
return {
|
|
66
|
-
ui: { ...ui, pendingDismissIndex: null },
|
|
67
|
-
action: { kind: 'dismiss-musician', index: ui.pendingDismissIndex },
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
if (key.input === 'n' || key.escape) {
|
|
71
|
-
return { ui: { ...ui, pendingDismissIndex: null } };
|
|
72
|
-
}
|
|
73
|
-
ui = { ...ui, pendingDismissIndex: null };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (key.downArrow || key.input === 'j') {
|
|
77
|
-
return { ui: { ...ui, selectedIndex: clamp(ui.selectedIndex + 1, 0, maxIndex) } };
|
|
78
|
-
}
|
|
79
|
-
if (key.upArrow || key.input === 'k') {
|
|
80
|
-
return { ui: { ...ui, selectedIndex: clamp(ui.selectedIndex - 1, 0, maxIndex) } };
|
|
81
|
-
}
|
|
82
|
-
if (key.tab) {
|
|
83
|
-
return { ui, action: { kind: 'next-orchestra' } };
|
|
84
|
-
}
|
|
85
|
-
if (key.shiftTab) {
|
|
86
|
-
return { ui, action: { kind: 'prev-orchestra' } };
|
|
87
|
-
}
|
|
88
|
-
if (key.return) {
|
|
89
|
-
return { ui, action: { kind: 'open-target', selectedIndex: ui.selectedIndex } };
|
|
90
|
-
}
|
|
91
|
-
if (key.input === 'n') {
|
|
92
|
-
return { ui, action: { kind: 'open-notes' } };
|
|
93
|
-
}
|
|
94
|
-
if (key.input === 'q') {
|
|
95
|
-
return { ui, action: { kind: 'detach-session' } };
|
|
96
|
-
}
|
|
97
|
-
if (key.input === 'p') {
|
|
98
|
-
return { ui, action: { kind: 'jump-to-pending' } };
|
|
99
|
-
}
|
|
100
|
-
if (key.input === '?') {
|
|
101
|
-
return { ui, action: { kind: 'toggle-help' } };
|
|
102
|
-
}
|
|
103
|
-
return { ui };
|
|
104
|
-
}
|
package/src/tui/poll-activity.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { capturePane, sessionName } from '../tmux.js';
|
|
2
|
-
import { extractActivityLine } from './activity-line.js';
|
|
3
|
-
import type { OrchestraState } from '../state.types.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* For each non-stopped musician, capture the last lines of its tmux window
|
|
7
|
-
* and reduce to a single activity hint. Failures (e.g. a window that no longer
|
|
8
|
-
* exists) are swallowed per-musician so one dead pane never breaks the poll.
|
|
9
|
-
*/
|
|
10
|
-
export async function pollActivity(state: OrchestraState): Promise<Record<string, string>> {
|
|
11
|
-
const session = sessionName(state.orchestra_id);
|
|
12
|
-
const result: Record<string, string> = {};
|
|
13
|
-
for (const musician of state.musicians) {
|
|
14
|
-
if (musician.status === 'stopped') {
|
|
15
|
-
continue;
|
|
16
|
-
}
|
|
17
|
-
try {
|
|
18
|
-
const pane = await capturePane(`${session}:${musician.tmux_window_id}`, 10);
|
|
19
|
-
result[musician.id] = extractActivityLine(pane);
|
|
20
|
-
} catch {
|
|
21
|
-
result[musician.id] = '';
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return result;
|
|
25
|
-
}
|
package/src/tui/poll-idle.ts
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
import { readState } from '../state.js';
|
|
2
|
-
import type { Musician, OrchestraState } from '../state.types.js';
|
|
3
|
-
import { setMusicianStatus } from '../state-updaters.js';
|
|
4
|
-
import { captureVisiblePane, sessionName } from '../tmux.js';
|
|
5
|
-
import { drainQueuedMusicianMessages } from '../musicians/message.js';
|
|
6
|
-
|
|
7
|
-
export const IDLE_THRESHOLD_MS = 30_000;
|
|
8
|
-
|
|
9
|
-
export interface MusicianIdleSnapshot {
|
|
10
|
-
signature: string;
|
|
11
|
-
unchangedSince: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export type MusicianIdleTracker = Record<string, MusicianIdleSnapshot>;
|
|
15
|
-
|
|
16
|
-
export interface IdlePollResult {
|
|
17
|
-
nextTracker: MusicianIdleTracker;
|
|
18
|
-
idleMusicianIds: string[];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function hasClaudeInputPrompt(paneText: string): boolean {
|
|
22
|
-
const lines = paneText
|
|
23
|
-
.replace(/\u00a0/g, ' ')
|
|
24
|
-
.replace(/\r/g, '')
|
|
25
|
-
.split('\n')
|
|
26
|
-
.map((line) => { return line.trim(); });
|
|
27
|
-
let promptIndex = -1;
|
|
28
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
29
|
-
if (/^(?:❯|>|›)$/.test(lines[index])) {
|
|
30
|
-
promptIndex = index;
|
|
31
|
-
break;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
if (promptIndex === -1) {
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
const trailingNonEmpty = lines.slice(promptIndex + 1).filter((line) => { return line.length > 0; });
|
|
38
|
-
return trailingNonEmpty.length <= 2;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function normalisePaneSignature(paneText: string): string {
|
|
42
|
-
return paneText
|
|
43
|
-
.replace(/\u00a0/g, ' ')
|
|
44
|
-
.replace(/\r/g, '')
|
|
45
|
-
.trimEnd();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function millisSince(earlier: string, later: string): number {
|
|
49
|
-
const earlierMs = Date.parse(earlier);
|
|
50
|
-
const laterMs = Date.parse(later);
|
|
51
|
-
if (Number.isNaN(earlierMs) || Number.isNaN(laterMs)) {
|
|
52
|
-
return 0;
|
|
53
|
-
}
|
|
54
|
-
return laterMs - earlierMs;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function workingMusicians(state: OrchestraState): Musician[] {
|
|
58
|
-
return state.musicians.filter((musician) => { return musician.status === 'working'; });
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function detectIdleMusicians(
|
|
62
|
-
state: OrchestraState,
|
|
63
|
-
panes: Record<string, string>,
|
|
64
|
-
tracker: MusicianIdleTracker,
|
|
65
|
-
now = new Date().toISOString(),
|
|
66
|
-
): IdlePollResult {
|
|
67
|
-
const nextTracker: MusicianIdleTracker = {};
|
|
68
|
-
const idleMusicianIds: string[] = [];
|
|
69
|
-
|
|
70
|
-
for (const musician of workingMusicians(state)) {
|
|
71
|
-
const paneText = panes[musician.id];
|
|
72
|
-
if (!paneText) {
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const signature = normalisePaneSignature(paneText);
|
|
77
|
-
const promptVisible = hasClaudeInputPrompt(signature);
|
|
78
|
-
const previous = tracker[musician.id];
|
|
79
|
-
const unchangedSince = previous?.signature === signature
|
|
80
|
-
? previous.unchangedSince
|
|
81
|
-
: (promptVisible ? musician.last_activity : now);
|
|
82
|
-
|
|
83
|
-
nextTracker[musician.id] = {
|
|
84
|
-
signature,
|
|
85
|
-
unchangedSince,
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
if (!promptVisible) {
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (millisSince(unchangedSince, now) >= IDLE_THRESHOLD_MS) {
|
|
93
|
-
idleMusicianIds.push(musician.id);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return { nextTracker, idleMusicianIds };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export async function pollIdleMusicians(
|
|
101
|
-
state: OrchestraState,
|
|
102
|
-
tracker: MusicianIdleTracker,
|
|
103
|
-
now = new Date().toISOString(),
|
|
104
|
-
): Promise<IdlePollResult> {
|
|
105
|
-
const session = sessionName(state.orchestra_id);
|
|
106
|
-
const panes = Object.fromEntries(await Promise.all(workingMusicians(state).map(async (musician) => {
|
|
107
|
-
try {
|
|
108
|
-
const pane = await captureVisiblePane(`${session}:${musician.tmux_window_id}`);
|
|
109
|
-
return [musician.id, pane] as const;
|
|
110
|
-
} catch {
|
|
111
|
-
return [musician.id, ''] as const;
|
|
112
|
-
}
|
|
113
|
-
})));
|
|
114
|
-
return detectIdleMusicians(state, panes, tracker, now);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export async function syncMusicianIdleState(
|
|
118
|
-
orchestraId: string,
|
|
119
|
-
tracker: MusicianIdleTracker,
|
|
120
|
-
now = new Date().toISOString(),
|
|
121
|
-
): Promise<MusicianIdleTracker> {
|
|
122
|
-
const state = await readState(orchestraId);
|
|
123
|
-
if (!state) {
|
|
124
|
-
return {};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const { nextTracker, idleMusicianIds } = await pollIdleMusicians(state, tracker, now);
|
|
128
|
-
for (const musicianId of idleMusicianIds) {
|
|
129
|
-
try {
|
|
130
|
-
const fresh = await readState(orchestraId);
|
|
131
|
-
const musician = fresh?.musicians.find((candidate) => { return candidate.id === musicianId; });
|
|
132
|
-
if (!musician || musician.status !== 'working') {
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const deliveredMessages = await drainQueuedMusicianMessages(orchestraId, musicianId);
|
|
137
|
-
if (deliveredMessages > 0) {
|
|
138
|
-
await setMusicianStatus(orchestraId, musicianId, 'working', null);
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
await setMusicianStatus(orchestraId, musicianId, 'idle', null);
|
|
143
|
-
} catch {
|
|
144
|
-
// Musician state can race with dismissals/restores; keep polling the rest.
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return nextTracker;
|
|
149
|
-
}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { capturePane, sessionName } from '../tmux.js';
|
|
2
|
-
import { detectPermissionPrompt } from './detect-permission.js';
|
|
3
|
-
import type { OrchestraState } from '../state.types.js';
|
|
4
|
-
|
|
5
|
-
export interface PermissionTransition {
|
|
6
|
-
musicianId: string;
|
|
7
|
-
newStatus: 'awaiting_permission' | 'working';
|
|
8
|
-
pendingPermission: string | null;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* For each non-stopped musician, capture the last lines of its tmux window,
|
|
13
|
-
* run the permission-prompt detector, and emit a transition only when the
|
|
14
|
-
* detected state differs from the musician's current status. Failures (e.g.
|
|
15
|
-
* a window that no longer exists) are swallowed per-musician so one dead pane
|
|
16
|
-
* never breaks the poll.
|
|
17
|
-
*/
|
|
18
|
-
export async function pollPermissions(state: OrchestraState): Promise<PermissionTransition[]> {
|
|
19
|
-
const session = sessionName(state.orchestra_id);
|
|
20
|
-
const transitions: PermissionTransition[] = [];
|
|
21
|
-
|
|
22
|
-
for (const musician of state.musicians) {
|
|
23
|
-
if (musician.status === 'stopped') {
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
const paneText = await capturePane(`${session}:${musician.tmux_window_id}`, 20);
|
|
29
|
-
const detected = detectPermissionPrompt(paneText);
|
|
30
|
-
|
|
31
|
-
if (detected.pending && musician.status !== 'awaiting_permission') {
|
|
32
|
-
transitions.push({
|
|
33
|
-
musicianId: musician.id,
|
|
34
|
-
newStatus: 'awaiting_permission',
|
|
35
|
-
pendingPermission: detected.tool ?? 'tool',
|
|
36
|
-
});
|
|
37
|
-
} else if (!detected.pending && musician.status === 'awaiting_permission') {
|
|
38
|
-
transitions.push({
|
|
39
|
-
musicianId: musician.id,
|
|
40
|
-
newStatus: 'working',
|
|
41
|
-
pendingPermission: null,
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
} catch {
|
|
45
|
-
/* swallow — a dead window must not break the poll */
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return transitions;
|
|
50
|
-
}
|
package/src/tui/status-icon.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import type { MusicianStatus } from '../state.types.js';
|
|
2
|
-
|
|
3
|
-
export function statusIcon(status: MusicianStatus): string {
|
|
4
|
-
switch (status) {
|
|
5
|
-
case 'working': {
|
|
6
|
-
return '●';
|
|
7
|
-
}
|
|
8
|
-
case 'idle': {
|
|
9
|
-
return '◐';
|
|
10
|
-
}
|
|
11
|
-
case 'awaiting_permission': {
|
|
12
|
-
return '⚠';
|
|
13
|
-
}
|
|
14
|
-
case 'stopped': {
|
|
15
|
-
return '○';
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function statusColor(status: MusicianStatus): string {
|
|
21
|
-
switch (status) {
|
|
22
|
-
case 'working': {
|
|
23
|
-
return 'green';
|
|
24
|
-
}
|
|
25
|
-
case 'idle': {
|
|
26
|
-
return 'yellow';
|
|
27
|
-
}
|
|
28
|
-
case 'awaiting_permission': {
|
|
29
|
-
return 'red';
|
|
30
|
-
}
|
|
31
|
-
case 'stopped': {
|
|
32
|
-
return 'gray';
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|