@hraness/dawg 0.0.0-stage → 0.2.0

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 (57) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DAWG.md +142 -0
  3. package/LICENSE +21 -0
  4. package/README.md +164 -2
  5. package/core/drums.ts +102 -0
  6. package/core/loop.ts +78 -0
  7. package/core/score.ts +1065 -0
  8. package/package.json +42 -4
  9. package/src/agent/agent.ts +587 -0
  10. package/src/agent/brief.ts +140 -0
  11. package/src/agent/gateway.ts +267 -0
  12. package/src/agent/ops.ts +172 -0
  13. package/src/agent/planner.ts +221 -0
  14. package/src/agent/provider.ts +326 -0
  15. package/src/agent/sse.ts +90 -0
  16. package/src/agent/tools.ts +976 -0
  17. package/src/agent/xcb-agent.ts +247 -0
  18. package/src/agent/xcb.ts +367 -0
  19. package/src/audio/clock.ts +68 -0
  20. package/src/audio/engine.ts +467 -0
  21. package/src/audio/lock.ts +57 -0
  22. package/src/audio/player.ts +119 -0
  23. package/src/audio/wav.ts +647 -0
  24. package/src/auth/cli.ts +112 -0
  25. package/src/auth/credentials.ts +285 -0
  26. package/src/auth/login.ts +434 -0
  27. package/src/auth/runner.ts +184 -0
  28. package/src/auth/tui.ts +90 -0
  29. package/src/commands/history.ts +56 -0
  30. package/src/commands/music.ts +444 -0
  31. package/src/daemon.ts +31 -0
  32. package/src/main.ts +1332 -0
  33. package/src/render.ts +99 -0
  34. package/src/session/attach.ts +182 -0
  35. package/src/session/client.ts +498 -0
  36. package/src/session/daemon.ts +723 -0
  37. package/src/session/list.ts +180 -0
  38. package/src/session/lock.ts +92 -0
  39. package/src/session/meta.ts +253 -0
  40. package/src/session/naming.ts +464 -0
  41. package/src/session/port.ts +461 -0
  42. package/src/session/presence.ts +159 -0
  43. package/src/session/protocol.ts +618 -0
  44. package/src/session/rebase.ts +138 -0
  45. package/src/session/store.ts +486 -0
  46. package/tui/activity.ts +325 -0
  47. package/tui/app.ts +1129 -0
  48. package/tui/drums.ts +21 -0
  49. package/tui/highway.ts +905 -0
  50. package/tui/input.ts +63 -0
  51. package/tui/keys.ts +102 -0
  52. package/tui/layers.ts +68 -0
  53. package/tui/prompt.ts +609 -0
  54. package/tui/render.ts +124 -0
  55. package/tui/screen.ts +247 -0
  56. package/tui/text.ts +72 -0
  57. package/tui/theme.ts +451 -0
package/tui/app.ts ADDED
@@ -0,0 +1,1129 @@
1
+ /**
2
+ * Frame composition and UI state for the interactive dawg TUI.
3
+ *
4
+ * ┌ header: dawg · track · session · ▶ BPM · model · rev · sync ┐
5
+ * │ highway (notes fall toward the hit line) │
6
+ * │ activity strip: spinner, operation cards, queue depth │
7
+ * └ prompt panel: bg fill, mode pill, wrapped draft ┘
8
+ *
9
+ * `composeFrame` is pure (view + UI state + size + time → cell buffer), so
10
+ * tests snapshot exact frames. `TuiApp` owns the mutable UI state (prompt,
11
+ * activity feed, theme, motion, overlay) and writes differential frames to an
12
+ * injectable `TerminalIO`.
13
+ */
14
+
15
+ import {
16
+ ActivityFeed,
17
+ revisionLabel,
18
+ spinnerFrame,
19
+ type ActivityCard,
20
+ type TranscriptEntry,
21
+ } from "./activity.ts";
22
+ import {
23
+ paintHighway,
24
+ resolveBeat,
25
+ type TrackScoreSnapshot,
26
+ } from "./highway.ts";
27
+ import { classifyKey, overlayKey, type UiCommand } from "./keys.ts";
28
+ import { PromptModel, type PromptAction, type PromptMode } from "./prompt.ts";
29
+ import { CellBuffer, ScreenWriter, type CursorPosition } from "./screen.ts";
30
+ import { displayWidth, truncate } from "./text.ts";
31
+ import {
32
+ accentStyle,
33
+ detectTerminalCapabilities,
34
+ effectiveTheme,
35
+ onBackground,
36
+ parseThemeName,
37
+ shade,
38
+ THEME_NAMES,
39
+ type Style,
40
+ type TerminalCapabilities,
41
+ type Theme,
42
+ type ThemeName,
43
+ } from "./theme.ts";
44
+
45
+ export type SyncState = "synced" | "syncing" | "conflict" | "offline" | "local";
46
+
47
+ export interface AppView {
48
+ score: TrackScoreSnapshot;
49
+ /** Transport beat; when omitted it is derived from the snapshot. */
50
+ beat?: number | undefined;
51
+ model?: string | undefined;
52
+ sync?: SyncState | undefined;
53
+ /** Human session name; replaces the short id in the header when set. */
54
+ sessionName?: string | undefined;
55
+ /** Live windows on this session (presence); shown when more than one. */
56
+ windows?: number | undefined;
57
+ }
58
+
59
+ export interface UiState {
60
+ prompt: PromptModel;
61
+ activity: ActivityFeed;
62
+ theme: Theme;
63
+ capabilities: TerminalCapabilities;
64
+ reducedMotion: boolean;
65
+ overlay: "log" | "picker" | undefined;
66
+ /** Transcript scroll (entries up from the newest) and filter. */
67
+ log?: LogView | undefined;
68
+ picker?: PickerState | undefined;
69
+ }
70
+
71
+ export type LogFilter = "all" | "requests" | "ops" | "errors";
72
+ export const LOG_FILTERS: readonly LogFilter[] = [
73
+ "all",
74
+ "requests",
75
+ "ops",
76
+ "errors",
77
+ ];
78
+
79
+ export interface LogView {
80
+ scroll: number;
81
+ filter: LogFilter;
82
+ }
83
+
84
+ /** An arrow-key list overlay (`/resume`, `/login --xcb`). */
85
+ export interface PickerState {
86
+ /** Caller tag returned with the choice. */
87
+ id: string;
88
+ title: string;
89
+ items: readonly PickerItem[];
90
+ index: number;
91
+ }
92
+
93
+ export interface PickerItem {
94
+ label: string;
95
+ /** Opaque value returned on Enter. */
96
+ value: string;
97
+ detail?: string | undefined;
98
+ }
99
+
100
+ /** Most picker rows kept; longer lists are truncated by the caller's order. */
101
+ export const MAX_PICKER_ITEMS = 64;
102
+
103
+ export function logEntriesFor(
104
+ entries: readonly TranscriptEntry[],
105
+ filter: LogFilter,
106
+ ): readonly TranscriptEntry[] {
107
+ if (filter === "all") return entries;
108
+ return entries.filter((entry) =>
109
+ filter === "requests"
110
+ ? entry.kind === "request"
111
+ : filter === "errors"
112
+ ? entry.kind === "error"
113
+ : entry.kind === "op" ||
114
+ entry.kind === "revision" ||
115
+ entry.kind === "agent",
116
+ );
117
+ }
118
+
119
+ export interface FrameSize {
120
+ width: number;
121
+ height: number;
122
+ }
123
+
124
+ export interface Frame {
125
+ buffer: CellBuffer;
126
+ cursor: CursorPosition | undefined;
127
+ /** Rows used by the prompt editor (excluding borders). */
128
+ promptRows: number;
129
+ layout: FrameLayout;
130
+ }
131
+
132
+ export interface FrameLayout {
133
+ header: number;
134
+ highway: { y: number; height: number };
135
+ activity: number;
136
+ prompt: { y: number; height: number };
137
+ /** Editor rows inside the prompt panel. */
138
+ promptRows: number;
139
+ /** Whether the prompt panel has a bottom border with key hints. */
140
+ footer: boolean;
141
+ tooSmall: boolean;
142
+ }
143
+
144
+ export const MIN_WIDTH = 24;
145
+ export const MIN_HEIGHT = 8;
146
+ export const MAX_PROMPT_ROWS = 8;
147
+ /** New cards glow for this long before settling. */
148
+ export const CARD_GLOW_MS = 700;
149
+
150
+ /** Prompt rows allowed for a viewport: 1..8, capped at 30% of its height. */
151
+ export function promptRowCap(height: number): number {
152
+ return Math.max(1, Math.min(MAX_PROMPT_ROWS, Math.floor(height * 0.3)));
153
+ }
154
+
155
+ /** Width available to the prompt editor inside the panel. */
156
+ export function promptEditorWidth(width: number): number {
157
+ // "│ › " + text + cursor cell + " │"
158
+ return Math.max(4, width - 7);
159
+ }
160
+
161
+ interface Box {
162
+ tl: string;
163
+ tr: string;
164
+ bl: string;
165
+ br: string;
166
+ h: string;
167
+ v: string;
168
+ }
169
+ const UNICODE_BOX: Box = {
170
+ tl: "╭",
171
+ tr: "╮",
172
+ bl: "╰",
173
+ br: "╯",
174
+ h: "─",
175
+ v: "│",
176
+ };
177
+ const ASCII_BOX: Box = { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" };
178
+
179
+ export function computeLayout(
180
+ size: FrameSize,
181
+ promptWrappedRows: number,
182
+ ): FrameLayout {
183
+ const tooSmall = size.width < MIN_WIDTH || size.height < MIN_HEIGHT;
184
+ const rows = Math.max(
185
+ 1,
186
+ Math.min(promptWrappedRows, promptRowCap(size.height)),
187
+ );
188
+ const footer = size.height >= 16 ? 1 : 0;
189
+ const promptHeight = 1 + rows + footer;
190
+ const promptY = size.height - promptHeight;
191
+ const activity = promptY - 1;
192
+ const highwayY = 1;
193
+ return {
194
+ header: 0,
195
+ highway: { y: highwayY, height: Math.max(0, activity - highwayY) },
196
+ activity,
197
+ prompt: { y: promptY, height: promptHeight },
198
+ promptRows: rows,
199
+ footer: footer === 1,
200
+ tooSmall,
201
+ };
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Header
206
+
207
+ interface Segment {
208
+ text: string;
209
+ style: Style;
210
+ /** Lower numbers survive longer when space is short. */
211
+ priority: number;
212
+ }
213
+
214
+ function paintSegments(
215
+ buffer: CellBuffer,
216
+ y: number,
217
+ segments: Segment[],
218
+ width: number,
219
+ separator: string,
220
+ separatorStyle: Style,
221
+ background: Style,
222
+ rightSegments: Segment[] = [],
223
+ ): void {
224
+ const sepWidth = displayWidth(separator);
225
+ const total = (list: Segment[]) =>
226
+ list.reduce((sum, segment) => sum + displayWidth(segment.text), 0) +
227
+ Math.max(0, list.length - 1) * sepWidth;
228
+ let left = [...segments];
229
+ let right = [...rightSegments];
230
+ const fits = () =>
231
+ total(left) + (right.length ? total(right) + 2 : 0) + 2 <= width;
232
+ while (!fits()) {
233
+ const all = [...left, ...right];
234
+ if (all.length <= 1) break;
235
+ const worst = all.reduce((a, b) => (b.priority > a.priority ? b : a));
236
+ left = left.filter((segment) => segment !== worst);
237
+ right = right.filter((segment) => segment !== worst);
238
+ }
239
+ buffer.fill(0, y, width, 1, background);
240
+ let x = 1;
241
+ left.forEach((segment, index) => {
242
+ if (index > 0)
243
+ x += buffer.text(
244
+ x,
245
+ y,
246
+ separator,
247
+ onBackground(separatorStyle, background),
248
+ );
249
+ x += buffer.text(
250
+ x,
251
+ y,
252
+ truncate(segment.text, Math.max(1, width - x - 1)),
253
+ onBackground(segment.style, background),
254
+ );
255
+ });
256
+ if (right.length) {
257
+ let rx = width - 1 - total(right);
258
+ if (rx <= x) return;
259
+ right.forEach((segment, index) => {
260
+ if (index > 0)
261
+ rx += buffer.text(
262
+ rx,
263
+ y,
264
+ separator,
265
+ onBackground(separatorStyle, background),
266
+ );
267
+ rx += buffer.text(
268
+ rx,
269
+ y,
270
+ segment.text,
271
+ onBackground(segment.style, background),
272
+ );
273
+ });
274
+ }
275
+ }
276
+
277
+ function syncSegment(
278
+ sync: SyncState | undefined,
279
+ theme: Theme,
280
+ unicode: boolean,
281
+ ): Segment | undefined {
282
+ if (!sync) return undefined;
283
+ const glyph: Record<SyncState, string> = unicode
284
+ ? {
285
+ synced: "●",
286
+ syncing: "◌",
287
+ conflict: "▲",
288
+ offline: "✗",
289
+ local: "○",
290
+ }
291
+ : { synced: "*", syncing: "o", conflict: "!", offline: "x", local: "o" };
292
+ const style: Record<SyncState, Style> = {
293
+ synced: theme.roles.success,
294
+ syncing: theme.roles.muted,
295
+ conflict: theme.roles.warning,
296
+ offline: theme.roles.error,
297
+ local: theme.roles.muted,
298
+ };
299
+ return { text: `${glyph[sync]} ${sync}`, style: style[sync], priority: 4 };
300
+ }
301
+
302
+ function paintHeader(
303
+ buffer: CellBuffer,
304
+ view: AppView,
305
+ ui: UiState,
306
+ width: number,
307
+ ): void {
308
+ const { theme, capabilities } = ui;
309
+ const roles = theme.roles;
310
+ const score = view.score;
311
+ const unicode = capabilities.unicode;
312
+ const playing = score.playing === true;
313
+ const transport = `${playing ? (unicode ? "▶" : ">") : unicode ? "⏸" : "||"} ${score.bpm ?? 120} BPM`;
314
+ const name = score.trackName ?? score.trackId ?? "track";
315
+ const left: Segment[] = [
316
+ { text: "dawg", style: { ...roles.muted, bold: true }, priority: 6 },
317
+ {
318
+ text: name,
319
+ style: { ...accentStyle(theme, score.trackId ?? name), bold: true },
320
+ priority: 0,
321
+ },
322
+ {
323
+ text: transport,
324
+ style: playing ? roles.transport : roles.paused,
325
+ priority: 0,
326
+ },
327
+ ];
328
+ if (score.key)
329
+ left.push({ text: score.key, style: roles.muted, priority: 5 });
330
+ if (view.sessionName)
331
+ left.push({ text: view.sessionName, style: roles.muted, priority: 3 });
332
+ else if (score.sessionId)
333
+ left.push({
334
+ text: `session ${score.sessionId.slice(0, 8)}`,
335
+ style: roles.muted,
336
+ priority: 3,
337
+ });
338
+ if (view.windows !== undefined && view.windows > 1)
339
+ left.push({
340
+ text: `${view.windows} windows`,
341
+ style: roles.muted,
342
+ priority: 4,
343
+ });
344
+ const right: Segment[] = [];
345
+ if (view.model)
346
+ right.push({ text: view.model, style: roles.agent, priority: 2 });
347
+ if (score.revision !== undefined)
348
+ right.push({
349
+ text: `rev ${score.revision}`,
350
+ style: roles.text,
351
+ priority: 1,
352
+ });
353
+ const sync = syncSegment(view.sync, theme, unicode);
354
+ if (sync) right.push(sync);
355
+ paintSegments(buffer, 0, left, width, " · ", roles.faint, roles.panel, right);
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Activity strip
360
+
361
+ function cardMarker(card: ActivityCard, unicode: boolean): string {
362
+ switch (card.tone) {
363
+ case "error":
364
+ return unicode ? "✗" : "x";
365
+ case "warning":
366
+ return "!";
367
+ case "agent":
368
+ return unicode ? "◆" : "*";
369
+ case "success":
370
+ return unicode ? "✓" : "+";
371
+ default:
372
+ return unicode ? "•" : "-";
373
+ }
374
+ }
375
+
376
+ export function cardText(card: ActivityCard, unicode: boolean): string {
377
+ const parts = [`${cardMarker(card, unicode)} ${card.text}`];
378
+ const revision = revisionLabel(
379
+ card.baseRevision,
380
+ card.resultRevision,
381
+ unicode,
382
+ );
383
+ if (revision) parts.push(revision);
384
+ if (card.hint) parts.push(card.hint);
385
+ return parts.join(" · ");
386
+ }
387
+
388
+ function paintActivity(
389
+ buffer: CellBuffer,
390
+ y: number,
391
+ ui: UiState,
392
+ width: number,
393
+ nowMs: number,
394
+ ): void {
395
+ const { theme, capabilities, activity } = ui;
396
+ const roles = theme.roles;
397
+ const background = roles.canvas;
398
+ buffer.fill(0, y, width, 1, background);
399
+ let x = 1;
400
+ const right: string[] = [];
401
+ if (activity.queueDepth > 0) right.push(`queue ${activity.queueDepth}`);
402
+ if (ui.reducedMotion) right.push("motion off");
403
+ const rightText = right.join(" · ");
404
+ const limit = width - 1 - (rightText ? displayWidth(rightText) + 2 : 0);
405
+ const spinner = activity.spinner;
406
+ if (spinner) {
407
+ const frame = spinnerFrame(
408
+ nowMs - spinner.sinceMs,
409
+ capabilities.unicode,
410
+ ui.reducedMotion,
411
+ );
412
+ x += buffer.text(x, y, `${frame} `, roles.agent);
413
+ x += buffer.text(x, y, truncate(spinner.label, Math.max(0, limit - x)), {
414
+ ...roles.agent,
415
+ bold: true,
416
+ });
417
+ if (activity.streaming && x < limit - 4) {
418
+ x += buffer.text(x, y, " · ", roles.faint);
419
+ const tail = activity.streaming;
420
+ const room = Math.max(0, limit - x);
421
+ const shown =
422
+ displayWidth(tail) > room
423
+ ? `…${Array.from(tail)
424
+ .slice(-(room - 1))
425
+ .join("")}`
426
+ : tail;
427
+ x += buffer.text(x, y, truncate(shown, room), roles.muted);
428
+ }
429
+ } else {
430
+ const cards = [...activity.cards].reverse();
431
+ cards.forEach((card, index) => {
432
+ if (x >= limit) return;
433
+ const text = cardText(card, capabilities.unicode);
434
+ if (index > 0) {
435
+ if (x + 3 + Math.min(12, displayWidth(text)) > limit) {
436
+ x = limit;
437
+ return;
438
+ }
439
+ x += buffer.text(x, y, " ", background);
440
+ }
441
+ const age = nowMs - card.atMs;
442
+ let style: Style =
443
+ card.tone === "error"
444
+ ? roles.error
445
+ : card.tone === "warning"
446
+ ? roles.warning
447
+ : card.tone === "agent"
448
+ ? roles.agent
449
+ : card.tone === "success"
450
+ ? roles.success
451
+ : roles.text;
452
+ if (index > 0) style = card.tone === "error" ? roles.error : roles.faint;
453
+ else if (!ui.reducedMotion && age >= 0 && age < CARD_GLOW_MS)
454
+ style = { ...shade(style, 0.4 * (1 - age / CARD_GLOW_MS)), bold: true };
455
+ x += buffer.text(x, y, truncate(text, Math.max(0, limit - x)), style);
456
+ });
457
+ if (cards.length === 0)
458
+ buffer.text(
459
+ x,
460
+ y,
461
+ truncate(
462
+ "type a request · space plays on an empty prompt · /help",
463
+ limit - x,
464
+ ),
465
+ roles.faint,
466
+ );
467
+ }
468
+ if (rightText)
469
+ buffer.text(width - 1 - displayWidth(rightText), y, rightText, roles.muted);
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // Prompt panel
474
+
475
+ function pill(mode: PromptMode): string {
476
+ return mode === "queue" ? " QUEUE " : " STEER ";
477
+ }
478
+
479
+ function paintPrompt(
480
+ buffer: CellBuffer,
481
+ view: AppView,
482
+ ui: UiState,
483
+ layout: FrameLayout,
484
+ width: number,
485
+ ): { cursor: CursorPosition; rows: number } {
486
+ const { theme, capabilities, prompt, activity } = ui;
487
+ const roles = theme.roles;
488
+ const box = capabilities.unicode ? UNICODE_BOX : ASCII_BOX;
489
+ const panel: Style = { ...roles.promptBg };
490
+ const border = onBackground(roles.borderFocus, panel);
491
+ const top = layout.prompt.y;
492
+ const height = layout.prompt.height;
493
+ buffer.fill(0, top, width, height, panel);
494
+ const rows = layout.promptRows;
495
+
496
+ // Top border with the mode pill and right-aligned status.
497
+ buffer.set(0, top, box.tl, border);
498
+ for (let x = 1; x < width - 1; x += 1) buffer.set(x, top, box.h, border);
499
+ buffer.set(width - 1, top, box.tr, border);
500
+ const mode = prompt.snapshot.mode;
501
+ const pillStyle = mode === "queue" ? roles.pillQueue : roles.pillSteer;
502
+ let x = 2;
503
+ x += buffer.text(x, top, pill(mode), pillStyle);
504
+ const status: string[] = [];
505
+ if (view.model) status.push(view.model);
506
+ if (activity.queueDepth > 0) status.push(`queue ${activity.queueDepth}`);
507
+ const layoutInfo = prompt.layout(rows);
508
+ if (layoutInfo.total > rows)
509
+ status.push(
510
+ `${layoutInfo.first + layoutInfo.cursorRow + 1}/${layoutInfo.total}`,
511
+ );
512
+ const statusText = ` ${status.join(" · ")} `;
513
+ if (status.length && width - 2 - displayWidth(statusText) > x + 1)
514
+ buffer.text(
515
+ width - 2 - displayWidth(statusText),
516
+ top,
517
+ statusText,
518
+ onBackground(roles.muted, panel),
519
+ );
520
+
521
+ // Editor rows.
522
+ const text = onBackground(roles.promptText, panel);
523
+ const faint = onBackground(roles.faint, panel);
524
+ const editorWidth = promptEditorWidth(width);
525
+ for (let index = 0; index < rows; index += 1) {
526
+ const y = top + 1 + index;
527
+ buffer.set(0, y, box.v, border);
528
+ buffer.set(width - 1, y, box.v, border);
529
+ const row = layoutInfo.rows[index];
530
+ const isFirst = layoutInfo.first + index === 0;
531
+ buffer.text(
532
+ 2,
533
+ y,
534
+ isFirst ? (capabilities.unicode ? "›" : ">") : " ",
535
+ onBackground(roles.borderFocus, panel),
536
+ );
537
+ if (row) buffer.text(4, y, row.text, text, editorWidth + 1);
538
+ if (index === 0 && layoutInfo.first > 0)
539
+ buffer.text(width - 3, y, capabilities.unicode ? "↑" : "^", faint);
540
+ if (index === rows - 1 && layoutInfo.first + rows < layoutInfo.total)
541
+ buffer.text(width - 3, y, capabilities.unicode ? "↓" : "v", faint);
542
+ }
543
+ if (prompt.value.length === 0) {
544
+ const placeholder =
545
+ mode === "queue"
546
+ ? "queue a request for after the current one…"
547
+ : "describe a change — “add a walking bass in A minor”";
548
+ buffer.text(5, top + 1, truncate(placeholder, editorWidth - 1), faint);
549
+ }
550
+
551
+ // Footer hints.
552
+ if (layout.footer) {
553
+ const y = top + height - 1;
554
+ buffer.set(0, y, box.bl, border);
555
+ for (let column = 1; column < width - 1; column += 1)
556
+ buffer.set(column, y, box.h, border);
557
+ buffer.set(width - 1, y, box.br, border);
558
+ const hints =
559
+ width >= 100
560
+ ? " enter send · shift+enter newline · ^q queue · ^z undo · ^o log · ^c quit "
561
+ : width >= 72
562
+ ? " enter send · ^j newline · ^q queue · ^z undo · ^o log "
563
+ : width >= 44
564
+ ? " enter · ^q queue · ^z undo · ^o log "
565
+ : "";
566
+ const shown = hints;
567
+ if (shown) buffer.text(2, y, shown, onBackground(roles.muted, panel));
568
+ }
569
+
570
+ // Cursor: shown as a reverse cell and as the real terminal cursor.
571
+ const cursorX = Math.min(width - 2, 4 + layoutInfo.cursorColumn);
572
+ const cursorY = top + 1 + Math.min(rows - 1, layoutInfo.cursorRow);
573
+ const under = buffer.get(cursorX, cursorY);
574
+ buffer.set(
575
+ cursorX,
576
+ cursorY,
577
+ under && under.ch !== "" && prompt.value.length > 0 ? under.ch : " ",
578
+ { ...text, ...roles.cursor },
579
+ );
580
+ return { cursor: { x: cursorX, y: cursorY }, rows };
581
+ }
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // Transcript overlay
585
+
586
+ const KIND_TAGS: Record<TranscriptEntry["kind"], string> = {
587
+ request: "you",
588
+ op: "op ",
589
+ revision: "rev",
590
+ error: "ERR",
591
+ agent: "ai ",
592
+ note: " ",
593
+ };
594
+
595
+ function paintOverlay(
596
+ buffer: CellBuffer,
597
+ ui: UiState,
598
+ region: { y: number; height: number },
599
+ width: number,
600
+ ): void {
601
+ const { theme, capabilities, activity } = ui;
602
+ const roles = theme.roles;
603
+ const box = capabilities.unicode ? UNICODE_BOX : ASCII_BOX;
604
+ const panel = roles.panel;
605
+ const border = onBackground(roles.border, panel);
606
+ const left = width >= 60 ? 2 : 0;
607
+ const boxWidth = width - left * 2;
608
+ const top = region.y;
609
+ const height = region.height;
610
+ if (height < 3 || boxWidth < 10) return;
611
+ buffer.fill(left, top, boxWidth, height, panel);
612
+ buffer.set(left, top, box.tl, border);
613
+ buffer.set(left + boxWidth - 1, top, box.tr, border);
614
+ buffer.set(left, top + height - 1, box.bl, border);
615
+ buffer.set(left + boxWidth - 1, top + height - 1, box.br, border);
616
+ for (let x = left + 1; x < left + boxWidth - 1; x += 1) {
617
+ buffer.set(x, top, box.h, border);
618
+ buffer.set(x, top + height - 1, box.h, border);
619
+ }
620
+ for (let y = top + 1; y < top + height - 1; y += 1) {
621
+ buffer.set(left, y, box.v, border);
622
+ buffer.set(left + boxWidth - 1, y, box.v, border);
623
+ }
624
+ const view = ui.log ?? { scroll: 0, filter: "all" };
625
+ const inner = height - 2;
626
+ const all = logEntriesFor(activity.transcript, view.filter);
627
+ const maxScroll = Math.max(0, all.length - inner);
628
+ const scroll = Math.max(0, Math.min(maxScroll, view.scroll));
629
+ const end = all.length - scroll;
630
+ const entries = all.slice(Math.max(0, end - inner), end);
631
+ const position =
632
+ scroll > 0 ? ` · ${end}/${all.length}` : all.length > inner ? " · end" : "";
633
+ buffer.text(
634
+ left + 2,
635
+ top,
636
+ ` transcript · ${view.filter}${position} `,
637
+ onBackground({ ...roles.text, bold: true }, panel),
638
+ boxWidth - 4,
639
+ );
640
+ const hint = " ↑↓ pgup pgdn scroll · / filter · esc close ";
641
+ const hintText = capabilities.unicode ? hint : hint.replace("↑↓", "up dn");
642
+ if (boxWidth > hintText.length + 4)
643
+ buffer.text(
644
+ left + boxWidth - 2 - hintText.length,
645
+ top + height - 1,
646
+ hintText,
647
+ onBackground(roles.muted, panel),
648
+ );
649
+ if (entries.length === 0)
650
+ buffer.text(
651
+ left + 2,
652
+ top + 1,
653
+ view.filter === "all" ? "nothing yet" : `no ${view.filter} yet`,
654
+ onBackground(roles.faint, panel),
655
+ );
656
+ entries.forEach((entry, index) => {
657
+ const y = top + 1 + index;
658
+ const tagStyle =
659
+ entry.kind === "error"
660
+ ? roles.error
661
+ : entry.kind === "request"
662
+ ? roles.borderFocus
663
+ : entry.kind === "agent"
664
+ ? roles.agent
665
+ : roles.muted;
666
+ buffer.text(
667
+ left + 2,
668
+ y,
669
+ KIND_TAGS[entry.kind],
670
+ onBackground({ ...tagStyle, bold: true }, panel),
671
+ );
672
+ buffer.text(
673
+ left + 6,
674
+ y,
675
+ truncate(entry.text.replace(/\s+/g, " "), boxWidth - 8),
676
+ onBackground(entry.kind === "error" ? roles.error : roles.text, panel),
677
+ );
678
+ });
679
+ }
680
+
681
+ function paintBox(
682
+ buffer: CellBuffer,
683
+ ui: UiState,
684
+ rect: { left: number; top: number; width: number; height: number },
685
+ ): Style {
686
+ const roles = ui.theme.roles;
687
+ const box = ui.capabilities.unicode ? UNICODE_BOX : ASCII_BOX;
688
+ const panel = roles.panel;
689
+ const border = onBackground(roles.borderFocus, panel);
690
+ const { left, top, width, height } = rect;
691
+ buffer.fill(left, top, width, height, panel);
692
+ buffer.set(left, top, box.tl, border);
693
+ buffer.set(left + width - 1, top, box.tr, border);
694
+ buffer.set(left, top + height - 1, box.bl, border);
695
+ buffer.set(left + width - 1, top + height - 1, box.br, border);
696
+ for (let x = left + 1; x < left + width - 1; x += 1) {
697
+ buffer.set(x, top, box.h, border);
698
+ buffer.set(x, top + height - 1, box.h, border);
699
+ }
700
+ for (let y = top + 1; y < top + height - 1; y += 1) {
701
+ buffer.set(left, y, box.v, border);
702
+ buffer.set(left + width - 1, y, box.v, border);
703
+ }
704
+ return panel;
705
+ }
706
+
707
+ function paintPicker(
708
+ buffer: CellBuffer,
709
+ ui: UiState,
710
+ region: { y: number; height: number },
711
+ width: number,
712
+ ): void {
713
+ const picker = ui.picker;
714
+ if (!picker) return;
715
+ const roles = ui.theme.roles;
716
+ const left = width >= 60 ? 2 : 0;
717
+ const boxWidth = width - left * 2;
718
+ const height = Math.min(region.height, picker.items.length + 2);
719
+ if (height < 3 || boxWidth < 10) return;
720
+ const panel = paintBox(buffer, ui, {
721
+ left,
722
+ top: region.y,
723
+ width: boxWidth,
724
+ height,
725
+ });
726
+ buffer.text(
727
+ left + 2,
728
+ region.y,
729
+ ` ${picker.title} `,
730
+ onBackground({ ...roles.text, bold: true }, panel),
731
+ boxWidth - 4,
732
+ );
733
+ const hint = ui.capabilities.unicode
734
+ ? " ↑↓ move · enter choose · esc cancel "
735
+ : " up/dn move · enter choose · esc cancel ";
736
+ if (boxWidth > hint.length + 4)
737
+ buffer.text(
738
+ left + boxWidth - 2 - hint.length,
739
+ region.y + height - 1,
740
+ hint,
741
+ onBackground(roles.muted, panel),
742
+ );
743
+ const inner = height - 2;
744
+ const first = Math.max(
745
+ 0,
746
+ Math.min(picker.items.length - inner, picker.index - inner + 1),
747
+ );
748
+ picker.items.slice(first, first + inner).forEach((item, offset) => {
749
+ const index = first + offset;
750
+ const y = region.y + 1 + offset;
751
+ const selected = index === picker.index;
752
+ const marker = selected ? (ui.capabilities.unicode ? "›" : ">") : " ";
753
+ const style = selected
754
+ ? onBackground({ ...roles.borderFocus, bold: true }, panel)
755
+ : onBackground(roles.text, panel);
756
+ buffer.text(left + 2, y, marker, style);
757
+ const room = boxWidth - 6;
758
+ const used = buffer.text(left + 4, y, truncate(item.label, room), style);
759
+ if (item.detail && used + 3 < room)
760
+ buffer.text(
761
+ left + 4 + used + 2,
762
+ y,
763
+ truncate(item.detail, room - used - 2),
764
+ onBackground(roles.muted, panel),
765
+ );
766
+ });
767
+ }
768
+
769
+ // ---------------------------------------------------------------------------
770
+
771
+ function paintTooSmall(buffer: CellBuffer, ui: UiState, size: FrameSize): void {
772
+ const roles = ui.theme.roles;
773
+ const lines = [
774
+ "resize terminal",
775
+ `${size.width}x${size.height} · need ${MIN_WIDTH}x${MIN_HEIGHT}`,
776
+ ];
777
+ const top = Math.max(0, Math.floor((size.height - lines.length) / 2));
778
+ lines.forEach((line, index) => {
779
+ const text = truncate(line, size.width);
780
+ const x = Math.max(0, Math.floor((size.width - displayWidth(text)) / 2));
781
+ buffer.text(
782
+ x,
783
+ top + index,
784
+ text,
785
+ index === 0 ? roles.warning : roles.muted,
786
+ );
787
+ });
788
+ }
789
+
790
+ export function composeFrame(
791
+ view: AppView,
792
+ ui: UiState,
793
+ size: FrameSize,
794
+ nowMs: number,
795
+ ): Frame {
796
+ const width = Math.max(1, Math.floor(size.width));
797
+ const height = Math.max(1, Math.floor(size.height));
798
+ const buffer = new CellBuffer(width, height, ui.theme.roles.canvas);
799
+ ui.prompt.setWidth(promptEditorWidth(width));
800
+ const layout = computeLayout({ width, height }, ui.prompt.wrappedRows);
801
+ if (layout.tooSmall) {
802
+ paintTooSmall(buffer, ui, { width, height });
803
+ return { buffer, cursor: undefined, promptRows: 0, layout };
804
+ }
805
+ paintHeader(buffer, view, ui, width);
806
+ const beat = view.beat ?? resolveBeat(view.score, nowMs);
807
+ if (layout.highway.height > 0) {
808
+ if (ui.overlay === "log") paintOverlay(buffer, ui, layout.highway, width);
809
+ else if (ui.overlay === "picker" && ui.picker)
810
+ paintPicker(buffer, ui, layout.highway, width);
811
+ else
812
+ paintHighway(
813
+ buffer,
814
+ { x: 0, y: layout.highway.y, width, height: layout.highway.height },
815
+ view.score,
816
+ beat,
817
+ {
818
+ theme: ui.theme,
819
+ capabilities: ui.capabilities,
820
+ reducedMotion: ui.reducedMotion,
821
+ },
822
+ );
823
+ }
824
+ paintActivity(buffer, layout.activity, ui, width, nowMs);
825
+ const prompt = paintPrompt(buffer, view, ui, layout, width);
826
+ return { buffer, cursor: prompt.cursor, promptRows: prompt.rows, layout };
827
+ }
828
+
829
+ // ---------------------------------------------------------------------------
830
+ // Mutable app
831
+
832
+ export interface TerminalIO {
833
+ write(data: string): void;
834
+ columns(): number;
835
+ rows(): number;
836
+ }
837
+
838
+ export interface TuiAppOptions {
839
+ io: TerminalIO;
840
+ capabilities?: TerminalCapabilities;
841
+ theme?: ThemeName;
842
+ reducedMotion?: boolean;
843
+ clock?: () => number;
844
+ prompt?: PromptModel;
845
+ activity?: ActivityFeed;
846
+ }
847
+
848
+ export type AppInput =
849
+ | { type: "action"; action: PromptAction }
850
+ | { type: "ui"; command: UiCommand }
851
+ /** Enter on a picker row; `value` is the chosen item's value. */
852
+ | { type: "pick"; picker: string; value: string }
853
+ /** Esc on a picker. */
854
+ | { type: "pick-cancel"; picker: string }
855
+ /** Consumed by an overlay (scroll, filter, move). */
856
+ | { type: "overlay" }
857
+ | { type: "none" };
858
+
859
+ export class TuiApp {
860
+ readonly prompt: PromptModel;
861
+ readonly activity: ActivityFeed;
862
+ readonly io: TerminalIO;
863
+ readonly clock: () => number;
864
+ capabilities: TerminalCapabilities;
865
+ themeName: ThemeName;
866
+ reducedMotion: boolean;
867
+ overlay: "log" | "picker" | undefined;
868
+ log: LogView = { scroll: 0, filter: "all" };
869
+ picker: PickerState | undefined;
870
+ /** `/view all` overlays every unmuted track; `/view focus` shows one. */
871
+ highwayView: "all" | "focus" = "all";
872
+ private writer: ScreenWriter;
873
+ private lastFrame: Frame | undefined;
874
+ private lastFrameAt = Number.NEGATIVE_INFINITY;
875
+ /** Minimum interval between frames (~30 fps). */
876
+ frameIntervalMs = 33;
877
+
878
+ constructor(options: TuiAppOptions) {
879
+ this.io = options.io;
880
+ this.clock = options.clock ?? Date.now;
881
+ this.capabilities = options.capabilities ?? detectTerminalCapabilities();
882
+ this.themeName = options.theme ?? "default";
883
+ this.reducedMotion = options.reducedMotion ?? false;
884
+ this.overlay = undefined;
885
+ this.prompt =
886
+ options.prompt ?? new PromptModel({ width: 72, maxVisualRows: 8 });
887
+ this.activity = options.activity ?? new ActivityFeed({ clock: this.clock });
888
+ this.writer = new ScreenWriter(this.capabilities);
889
+ }
890
+
891
+ get theme(): Theme {
892
+ return effectiveTheme(this.themeName, this.capabilities);
893
+ }
894
+
895
+ get ui(): UiState {
896
+ return {
897
+ prompt: this.prompt,
898
+ activity: this.activity,
899
+ theme: this.theme,
900
+ capabilities: this.capabilities,
901
+ reducedMotion: this.reducedMotion,
902
+ overlay: this.overlay,
903
+ log: this.log,
904
+ picker: this.picker,
905
+ };
906
+ }
907
+
908
+ get frame(): Frame | undefined {
909
+ return this.lastFrame;
910
+ }
911
+
912
+ /** Repaint everything on the next render (resize, theme change, ^L). */
913
+ invalidate(): void {
914
+ this.writer.invalidate();
915
+ this.lastFrameAt = Number.NEGATIVE_INFINITY;
916
+ }
917
+
918
+ /**
919
+ * Render a frame if the frame budget allows. Returns the bytes written
920
+ * (empty when throttled or nothing changed).
921
+ */
922
+ render(view: AppView, options: { force?: boolean } = {}): string {
923
+ const now = this.clock();
924
+ if (!options.force && now - this.lastFrameAt < this.frameIntervalMs)
925
+ return "";
926
+ this.lastFrameAt = now;
927
+ const frame = composeFrame(
928
+ view,
929
+ this.ui,
930
+ { width: this.io.columns(), height: this.io.rows() },
931
+ now,
932
+ );
933
+ this.lastFrame = frame;
934
+ const out = this.writer.frame(frame.buffer, frame.cursor);
935
+ if (out) this.io.write(out);
936
+ return out;
937
+ }
938
+
939
+ /** Decode one key sequence (from TerminalInputDecoder) into an input. */
940
+ input(value: string | { type: "paste"; text: string }): AppInput {
941
+ if (typeof value !== "string")
942
+ return {
943
+ type: "action",
944
+ action: this.prompt.handle({ type: "paste", text: value.text }),
945
+ };
946
+ const key = classifyKey(value);
947
+ if (this.overlay === "picker" && this.picker) {
948
+ const picker = this.picker;
949
+ const nav = overlayKey(value);
950
+ if (nav === "up" || nav === "down" || nav === "pgup" || nav === "pgdn") {
951
+ const step =
952
+ nav === "up" ? -1 : nav === "down" ? 1 : nav === "pgup" ? -8 : 8;
953
+ picker.index = Math.max(
954
+ 0,
955
+ Math.min(picker.items.length - 1, picker.index + step),
956
+ );
957
+ return { type: "overlay" };
958
+ }
959
+ if (nav === "home" || nav === "end") {
960
+ picker.index = nav === "home" ? 0 : picker.items.length - 1;
961
+ return { type: "overlay" };
962
+ }
963
+ if (nav === "enter") {
964
+ const item = picker.items[picker.index];
965
+ this.closePicker();
966
+ return item
967
+ ? { type: "pick", picker: picker.id, value: item.value }
968
+ : { type: "pick-cancel", picker: picker.id };
969
+ }
970
+ if (key.type === "ui" && key.command === "close-overlay") {
971
+ this.closePicker();
972
+ return { type: "pick-cancel", picker: picker.id };
973
+ }
974
+ if (key.type === "text" && /^[1-9]$/.test(key.text)) {
975
+ const index = Number(key.text) - 1;
976
+ if (index < picker.items.length) {
977
+ const item = picker.items[index]!;
978
+ this.closePicker();
979
+ return { type: "pick", picker: picker.id, value: item.value };
980
+ }
981
+ return { type: "overlay" };
982
+ }
983
+ // Quit and redraw still work; everything else is swallowed.
984
+ if (
985
+ key.type === "ui" &&
986
+ (key.command === "quit" || key.command === "redraw")
987
+ ) {
988
+ if (key.command === "redraw") this.invalidate();
989
+ return { type: "ui", command: key.command };
990
+ }
991
+ return { type: "overlay" };
992
+ }
993
+ if (this.overlay === "log") {
994
+ const nav = overlayKey(value);
995
+ const page = Math.max(1, this.io.rows() - 10);
996
+ if (nav && nav !== "enter") {
997
+ const step =
998
+ nav === "up"
999
+ ? 1
1000
+ : nav === "down"
1001
+ ? -1
1002
+ : nav === "pgup"
1003
+ ? page
1004
+ : nav === "pgdn"
1005
+ ? -page
1006
+ : 0;
1007
+ const total = logEntriesFor(
1008
+ this.activity.transcript,
1009
+ this.log.filter,
1010
+ ).length;
1011
+ this.log.scroll =
1012
+ nav === "home"
1013
+ ? total
1014
+ : nav === "end"
1015
+ ? 0
1016
+ : Math.max(0, Math.min(total, this.log.scroll + step));
1017
+ return { type: "overlay" };
1018
+ }
1019
+ if (key.type === "text" && key.text === "/") {
1020
+ const next =
1021
+ LOG_FILTERS[
1022
+ (LOG_FILTERS.indexOf(this.log.filter) + 1) % LOG_FILTERS.length
1023
+ ]!;
1024
+ this.log = { scroll: 0, filter: next };
1025
+ return { type: "overlay" };
1026
+ }
1027
+ }
1028
+ switch (key.type) {
1029
+ case "ui":
1030
+ if (key.command === "toggle-log") {
1031
+ this.overlay = this.overlay === "log" ? undefined : "log";
1032
+ if (this.overlay === "log") this.log = { ...this.log, scroll: 0 };
1033
+ return { type: "ui", command: key.command };
1034
+ }
1035
+ if (key.command === "close-overlay") {
1036
+ if (this.overlay) {
1037
+ this.overlay = undefined;
1038
+ return { type: "ui", command: key.command };
1039
+ }
1040
+ return { type: "action", action: this.prompt.handle("ESC") };
1041
+ }
1042
+ if (key.command === "redraw") this.invalidate();
1043
+ return { type: "ui", command: key.command };
1044
+ case "prompt":
1045
+ return { type: "action", action: this.prompt.handle(key.key) };
1046
+ case "text": {
1047
+ let action: PromptAction = {
1048
+ kind: "noop",
1049
+ state: this.prompt.snapshot,
1050
+ };
1051
+ for (const character of Array.from(key.text))
1052
+ action = this.prompt.handle(character);
1053
+ return { type: "action", action };
1054
+ }
1055
+ default:
1056
+ return { type: "none" };
1057
+ }
1058
+ }
1059
+
1060
+ /** Show an arrow-key picker over the highway; replaces any overlay. */
1061
+ openPicker(picker: Omit<PickerState, "index"> & { index?: number }): void {
1062
+ const items = picker.items.slice(0, MAX_PICKER_ITEMS);
1063
+ if (items.length === 0) return;
1064
+ this.picker = {
1065
+ ...picker,
1066
+ items,
1067
+ index: Math.max(0, Math.min(items.length - 1, picker.index ?? 0)),
1068
+ };
1069
+ this.overlay = "picker";
1070
+ }
1071
+
1072
+ closePicker(): void {
1073
+ this.picker = undefined;
1074
+ if (this.overlay === "picker") this.overlay = undefined;
1075
+ }
1076
+
1077
+ /**
1078
+ * Handle TUI-local slash commands (`/log`, `/theme`, `/motion`).
1079
+ * Returns a receipt, or undefined when the command is not a UI command.
1080
+ */
1081
+ command(text: string): string | undefined {
1082
+ const command = text.trim();
1083
+ if (/^\/(log|transcript)$/i.test(command)) {
1084
+ this.overlay = this.overlay === "log" ? undefined : "log";
1085
+ if (this.overlay === "log") this.log = { ...this.log, scroll: 0 };
1086
+ return this.overlay
1087
+ ? "transcript open · esc closes"
1088
+ : "transcript closed";
1089
+ }
1090
+ const theme = command.match(/^\/theme(?:\s+(\S+))?$/i);
1091
+ if (theme) {
1092
+ if (!theme[1])
1093
+ return `theme ${this.themeName} · ${THEME_NAMES.join(" | ")}`;
1094
+ const name = parseThemeName(theme[1]);
1095
+ if (!name) return `unknown theme · ${THEME_NAMES.join(" | ")}`;
1096
+ this.themeName = name;
1097
+ this.invalidate();
1098
+ return this.capabilities.colorDepth === "none" && name !== "mono"
1099
+ ? `theme ${name} · terminal has no color, showing mono`
1100
+ : `theme ${name}`;
1101
+ }
1102
+ const view = command.match(/^\/view(?:\s+(\S+))?$/i);
1103
+ if (view) {
1104
+ const value = view[1]?.toLowerCase();
1105
+ if (value === undefined)
1106
+ this.highwayView = this.highwayView === "all" ? "focus" : "all";
1107
+ else if (value === "all" || value === "focus") this.highwayView = value;
1108
+ else return "view · /view focus | all";
1109
+ return this.highwayView === "all"
1110
+ ? "view all · every unmuted track, focused track on top"
1111
+ : "view focus · focused track only";
1112
+ }
1113
+ const motion = command.match(/^\/motion(?:\s+(on|off))?$/i);
1114
+ if (motion) {
1115
+ if (motion[1]) this.reducedMotion = motion[1].toLowerCase() === "off";
1116
+ else this.reducedMotion = !this.reducedMotion;
1117
+ return `motion ${this.reducedMotion ? "off" : "on"}`;
1118
+ }
1119
+ return undefined;
1120
+ }
1121
+ }
1122
+
1123
+ /** Plain text of a frame (for tests and the non-interactive demo). */
1124
+ export function frameText(frame: Frame): string {
1125
+ return frame.buffer
1126
+ .lines()
1127
+ .map((line) => line.replace(/\s+$/, ""))
1128
+ .join("\n");
1129
+ }