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