@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.
- package/CHANGELOG.md +126 -0
- package/DAWG.md +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- 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
|
+
}
|