@hraness/dawg 0.7.0 → 0.8.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 (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/DAWG.md +117 -30
  3. package/README.md +7 -5
  4. package/core/diff.ts +2 -0
  5. package/core/ids.ts +52 -0
  6. package/core/loop.ts +3 -0
  7. package/core/range.ts +581 -0
  8. package/core/score.ts +123 -1
  9. package/core/sdk/print.ts +11 -2
  10. package/core/sdk/v1.ts +281 -2
  11. package/core/sections.ts +198 -19
  12. package/guides/arrange.md +9 -8
  13. package/guides/audio.md +31 -0
  14. package/guides/mix.md +3 -1
  15. package/guides/panes.md +32 -0
  16. package/guides/project.md +3 -2
  17. package/guides/rhythm.md +2 -1
  18. package/guides/tape.md +32 -0
  19. package/package.json +2 -1
  20. package/src/agent/agent.ts +4 -4
  21. package/src/agent/command-agent.ts +19 -10
  22. package/src/agent/ops.ts +15 -0
  23. package/src/agent/planner.ts +12 -0
  24. package/src/agent/range-tools.ts +216 -0
  25. package/src/agent/show-me.ts +1 -1
  26. package/src/agent/tools.ts +2 -0
  27. package/src/agent/xcb-agent.ts +2 -3
  28. package/src/audio/arrange.ts +12 -2
  29. package/src/audio/audio-command.ts +218 -0
  30. package/src/audio/devices.ts +264 -0
  31. package/src/audio/doctor.ts +84 -13
  32. package/src/audio/engine.ts +216 -28
  33. package/src/commands/arrange.ts +60 -25
  34. package/src/commands/grammar.ts +3 -1
  35. package/src/commands/help.ts +187 -9
  36. package/src/commands/history.ts +122 -11
  37. package/src/commands/keys.ts +17 -0
  38. package/src/commands/parses.ts +6 -1
  39. package/src/commands/range.ts +763 -0
  40. package/src/commands/resample.ts +13 -10
  41. package/src/commands/sample.ts +11 -1
  42. package/src/commands/sing.ts +2 -6
  43. package/src/commands/vocal-pitch.ts +2 -1
  44. package/src/identity/actor.ts +127 -0
  45. package/src/lang/glossary.ts +18 -0
  46. package/src/launch-args.ts +105 -1
  47. package/src/main.ts +1142 -86
  48. package/src/session/client.ts +107 -3
  49. package/src/session/clipboard.ts +79 -0
  50. package/src/session/daemon.ts +196 -5
  51. package/src/session/live-host.ts +232 -0
  52. package/src/session/origin.ts +20 -0
  53. package/src/session/port.ts +87 -10
  54. package/src/session/protocol.ts +360 -6
  55. package/src/session/rebase.ts +46 -8
  56. package/src/session/receipt.ts +7 -0
  57. package/src/session/shared-live.ts +131 -0
  58. package/src/session/store.ts +61 -2
  59. package/src/tui/arrange-menu.ts +169 -1
  60. package/src/tui/euclid.ts +95 -14
  61. package/src/tui/fader.ts +55 -2
  62. package/src/tui/knob-fields.ts +107 -0
  63. package/src/tui/knob-map.ts +198 -0
  64. package/src/tui/menu.ts +228 -2
  65. package/src/tui/play-session.ts +196 -21
  66. package/src/tui/tape-mode.ts +580 -0
  67. package/src/tui/tape-view.ts +263 -0
  68. package/tui/app.ts +217 -44
  69. package/tui/delight.ts +49 -0
  70. package/tui/drawer.ts +167 -9
  71. package/tui/grammar.ts +34 -5
  72. package/tui/guide.ts +8 -1
  73. package/tui/highway.ts +40 -5
  74. package/tui/hints.ts +5 -0
  75. package/tui/hits.ts +7 -1
  76. package/tui/knobs.ts +268 -0
  77. package/tui/play-strip.ts +12 -0
  78. package/tui/screen.ts +7 -4
  79. package/tui/tape.ts +439 -0
  80. package/tui/text.ts +35 -2
  81. package/tui/theme.ts +19 -1
package/tui/tape.ts ADDED
@@ -0,0 +1,439 @@
1
+ /**
2
+ * TAPE (op1-ux §6, §8.3): every track across the bars, as a pure painter.
3
+ *
4
+ * The caller (src/tui/tape-view.ts) turns a score into a `TapeView`: one
5
+ * density level per cell and track, the sections, the loop range, the
6
+ * playhead and the knob slots. This module only draws it, so the layout is
7
+ * testable without a session. Colour is never the only cue: the playhead is
8
+ * `▼`/`│`, the loop `[══]`, the focused row `›`, a muted row dim `·`, a
9
+ * form repeat `░` (ASCII `~`), and every knob carries its shape glyph
10
+ * (NO_COLOR and the mono theme).
11
+ */
12
+ import type { HitMap } from "./hits.ts";
13
+ import { paintKnobStrip, type KnobIndex, type KnobSlots } from "./knobs.ts";
14
+ import type { CellBuffer } from "./screen.ts";
15
+ import { displayWidth, truncate } from "./text.ts";
16
+ import { accentStyle, type Style, type Theme } from "./theme.ts";
17
+
18
+ /** One track row: its density levels (0 empty … 8 dense), one per cell. */
19
+ export type TapeRow = Readonly<{
20
+ id: string;
21
+ name: string;
22
+ muted: boolean;
23
+ levels: readonly number[];
24
+ /** Other panes on this track (§12.7): `B`, `C●`; empty when none. */
25
+ marks: string;
26
+ }>;
27
+
28
+ export type TapeSection = Readonly<{
29
+ name: string;
30
+ startBar: number;
31
+ bars: number;
32
+ }>;
33
+
34
+ /** Cells per bar: half beats, beats or whole bars (`-` / `=` zoom). */
35
+ export type TapeZoom = "half" | "beat" | "bar";
36
+ export const TAPE_ZOOMS: readonly TapeZoom[] = ["bar", "beat", "half"];
37
+
38
+ export type TapeView = Readonly<{
39
+ bars: number;
40
+ /** The bar (0-based) each cell belongs to, in order. */
41
+ cellBars: readonly number[];
42
+ /** Cell under the playhead. */
43
+ playheadCell: number;
44
+ /** The loop range (`score.loop`, or the looped section's bars). */
45
+ loop?: Readonly<{ startBar: number; bars: number }> | undefined;
46
+ sections: readonly TapeSection[];
47
+ /**
48
+ * An unrolled form (op1-ux §4): per cell, whether it is a later pass of a
49
+ * section (ghosted `░`, ASCII `~`), and which form pass it belongs to
50
+ * (-1 for bars the form never plays). Absent without a form.
51
+ */
52
+ ghosts?: readonly boolean[] | undefined;
53
+ passes?: readonly number[] | undefined;
54
+ /** Each form pass's label (`chorus ×2`), indexed by `passes`. */
55
+ passNames?: readonly string[] | undefined;
56
+ /** The looped section's name, drawn reversed. */
57
+ loopSection?: string | undefined;
58
+ /** Tempo changes (`♩96` marks); empty when the tempo is constant. */
59
+ tempo: readonly Readonly<{ cell: number; bpm: number }>[];
60
+ rows: readonly TapeRow[];
61
+ focused: number;
62
+ /** `range: bass · bars 5–6 (loop)`. */
63
+ range: string;
64
+ /** `clipboard: drums · 2 bars`, when the pane holds one. */
65
+ clipboard?: string | undefined;
66
+ knobs: KnobSlots;
67
+ selected: KnobIndex;
68
+ /** The gesture row, from the screen's key table. */
69
+ hint: string;
70
+ /** The reels (`◐◓◑◒`, §9.1), turning on beats while playing. */
71
+ reel?: string | undefined;
72
+ /**
73
+ * The loop just wrapped (a recording pass closed, §9.3): the brackets
74
+ * draw reversed and bold and the fill turns `━`, so it reads in mono.
75
+ */
76
+ loopFlash?: boolean | undefined;
77
+ }>;
78
+
79
+ /** Glyph for a density level, 1 … 8. */
80
+ const LEVELS = " ▁▂▃▄▅▆▇█";
81
+ const ASCII_LEVELS = " .:-=+*#@";
82
+ /** A later pass of a section on an unrolled form. */
83
+ const GHOST = "░";
84
+ const ASCII_GHOST = "~";
85
+
86
+ /** Columns of the row gutter: ` 2›bass `. */
87
+ export const TAPE_GUTTER = 9;
88
+
89
+ /** The rows the tape keeps below the tracks: range, knobs, hints. */
90
+ const FOOTER_ROWS = 3;
91
+
92
+ /** First visible cell: pages so the playhead stays on screen. */
93
+ export function tapeFirstCell(view: TapeView, cells: number): number {
94
+ if (view.cellBars.length <= cells) return 0;
95
+ const page = Math.floor(view.playheadCell / cells);
96
+ return Math.min(page * cells, view.cellBars.length - cells);
97
+ }
98
+
99
+ /** First visible track row: keeps the focused row on screen. */
100
+ export function tapeFirstRow(view: TapeView, rows: number): number {
101
+ if (view.rows.length <= rows) return 0;
102
+ return Math.max(
103
+ 0,
104
+ Math.min(view.focused - rows + 1, view.rows.length - rows),
105
+ );
106
+ }
107
+
108
+ export type TapePaintOptions = Readonly<{
109
+ theme: Theme;
110
+ unicode: boolean;
111
+ hits?: HitMap | undefined;
112
+ }>;
113
+
114
+ /** What the painter laid out, for mouse hits and tests. */
115
+ export type TapeLayout = Readonly<{
116
+ rulerY: number;
117
+ firstCell: number;
118
+ cells: number;
119
+ firstRow: number;
120
+ rowsY: number;
121
+ rowCount: number;
122
+ }>;
123
+
124
+ export function paintTape(
125
+ buffer: CellBuffer,
126
+ rect: Readonly<{ x: number; y: number; width: number; height: number }>,
127
+ view: TapeView,
128
+ options: TapePaintOptions,
129
+ ): TapeLayout {
130
+ const { theme, unicode } = options;
131
+ const roles = theme.roles;
132
+ // ` B C●`: room for up to three pane letters right of the cells.
133
+ const marksWidth = view.rows.some((row) => row.marks) ? 7 : 0;
134
+ const cells = Math.max(
135
+ 0,
136
+ Math.min(view.cellBars.length, rect.width - TAPE_GUTTER - marksWidth),
137
+ );
138
+ const firstCell = tapeFirstCell(view, cells);
139
+ const left = rect.x + TAPE_GUTTER;
140
+ const bottom = rect.y + rect.height;
141
+ const cellBar = (cell: number): number => view.cellBars[cell] ?? view.bars;
142
+ const ghost = (cell: number): boolean => view.ghosts?.[cell] ?? false;
143
+ const passOf = (cell: number): number | undefined => view.passes?.[cell];
144
+ const barStart = (cell: number): boolean =>
145
+ cell === 0 ||
146
+ cellBar(cell) !== cellBar(cell - 1) ||
147
+ passOf(cell) !== passOf(cell - 1);
148
+ const loopStart = view.loop?.startBar;
149
+ const loopEnd = view.loop ? view.loop.startBar + view.loop.bars : undefined;
150
+ // The loop brackets its source bars, never a ghost pass of them.
151
+ const inLoop = (cell: number): boolean =>
152
+ loopStart !== undefined &&
153
+ !ghost(cell) &&
154
+ cellBar(cell) >= loopStart &&
155
+ cellBar(cell) < loopEnd!;
156
+
157
+ // Ruler: bar numbers every 4 bars and at the loop edges; `[══]` in green.
158
+ let y = rect.y;
159
+ const ruler = Array.from({ length: cells }, () => " ");
160
+ const rulerStyle: (Style | undefined)[] = Array.from(
161
+ { length: cells },
162
+ () => roles.muted,
163
+ );
164
+ const flash = view.loopFlash === true;
165
+ const bracketStyle: Style = flash
166
+ ? { ...roles.knob2, bold: true, reverse: true }
167
+ : roles.knob2;
168
+ for (let index = 0; index < cells; index += 1) {
169
+ const cell = firstCell + index;
170
+ if (inLoop(cell)) {
171
+ ruler[index] = flash ? (unicode ? "━" : "#") : unicode ? "═" : "=";
172
+ rulerStyle[index] = flash ? { ...roles.knob2, bold: true } : roles.knob2;
173
+ }
174
+ }
175
+ for (let index = 0; index < cells; index += 1) {
176
+ const cell = firstCell + index;
177
+ if (!barStart(cell)) continue;
178
+ const bar = cellBar(cell);
179
+ const edge = !ghost(cell) && (bar === loopStart || bar + 1 === loopEnd);
180
+ if (bar % 4 !== 0 && !edge && bar !== view.bars - 1) continue;
181
+ const label = String(bar + 1);
182
+ const at = index + (edge && bar === loopStart ? 1 : 0);
183
+ // A number never overwrites a neighbour's digits.
184
+ if (ruler.slice(at, at + label.length).some((ch) => /\d/.test(ch)))
185
+ continue;
186
+ for (let k = 0; k < label.length && at + k < cells; k += 1) {
187
+ ruler[at + k] = label[k]!;
188
+ if (!inLoop(cell)) rulerStyle[at + k] = roles.muted;
189
+ }
190
+ }
191
+ if (view.loop) {
192
+ for (let index = 0; index < cells; index += 1) {
193
+ const cell = firstCell + index;
194
+ if (barStart(cell) && cellBar(cell) === loopStart && !ghost(cell)) {
195
+ ruler[index] = "[";
196
+ rulerStyle[index] = bracketStyle;
197
+ }
198
+ if (inLoop(cell) && !inLoop(cell + 1)) {
199
+ ruler[index] = "]";
200
+ rulerStyle[index] = bracketStyle;
201
+ }
202
+ }
203
+ }
204
+ const head = view.playheadCell - firstCell;
205
+ if (head >= 0 && head < cells) {
206
+ // On a loop bracket the bracket stays and the playhead reverses it, so
207
+ // both read without color (the rows still carry the `│` column).
208
+ if (ruler[head] === "[" || ruler[head] === "]")
209
+ rulerStyle[head] = { ...roles.knob1, reverse: true };
210
+ else {
211
+ ruler[head] = unicode ? "▼" : "v";
212
+ rulerStyle[head] = roles.knob1;
213
+ }
214
+ }
215
+ buffer.text(rect.x, y, " bar", roles.faint);
216
+ // The reels sit in the gutter: a glyph that turns, so never color-only.
217
+ if (view.reel) buffer.text(rect.x + 6, y, view.reel, roles.warning);
218
+ ruler.forEach((ch, index) =>
219
+ buffer.set(left + index, y, ch, rulerStyle[index]),
220
+ );
221
+ const rulerY = y;
222
+ options.hits?.add(left, y, cells, 1, { kind: "tape-ruler", left, firstCell });
223
+ y += 1;
224
+
225
+ // Sections: `▀name▀▀▀`; the looped one reversed; a form repeat `░name░`.
226
+ if (y < bottom - FOOTER_ROWS) {
227
+ buffer.text(rect.x, y, " sect", roles.faint);
228
+ const sectionAt = (cell: number): TapeSection | undefined => {
229
+ const bar = cellBar(cell);
230
+ return view.sections.find(
231
+ (item) => bar >= item.startBar && bar < item.startBar + item.bars,
232
+ );
233
+ };
234
+ const sectionStyle = (section: TapeSection, cell: number): Style => ({
235
+ ...accentStyle(theme, `section:${section.name}`),
236
+ ...(ghost(cell) ? { dim: true } : {}),
237
+ ...(section.name === view.loopSection && !ghost(cell)
238
+ ? { reverse: true }
239
+ : {}),
240
+ });
241
+ for (let index = 0; index < cells; index += 1) {
242
+ const cell = firstCell + index;
243
+ const section = sectionAt(cell);
244
+ if (!section) {
245
+ buffer.set(left + index, y, " ", undefined);
246
+ continue;
247
+ }
248
+ buffer.set(
249
+ left + index,
250
+ y,
251
+ ghost(cell) ? (unicode ? GHOST : ASCII_GHOST) : unicode ? "▀" : "-",
252
+ sectionStyle(section, cell),
253
+ );
254
+ }
255
+ // A label at each section's first cell (each form pass when unrolled).
256
+ for (let index = 0; index < cells; index += 1) {
257
+ const cell = firstCell + index;
258
+ const section = sectionAt(cell);
259
+ if (!section) continue;
260
+ const pass = passOf(cell);
261
+ const starts =
262
+ cell === firstCell
263
+ ? true
264
+ : sectionAt(cell - 1) !== section || pass !== passOf(cell - 1);
265
+ if (!starts) continue;
266
+ let room = 1;
267
+ while (
268
+ index + room < cells &&
269
+ sectionAt(cell + room) === section &&
270
+ passOf(cell + room) === pass
271
+ )
272
+ room += 1;
273
+ const name =
274
+ pass !== undefined && pass >= 0
275
+ ? (view.passNames?.[pass] ?? section.name)
276
+ : section.name;
277
+ // A ghost keeps a leading `░` so the repeat reads in mono too.
278
+ if (ghost(cell) && room <= 2) continue;
279
+ const offset = ghost(cell) ? 1 : 0;
280
+ const label = truncate(name, room - offset, "");
281
+ buffer.text(left + index + offset, y, label, {
282
+ ...sectionStyle(section, cell),
283
+ bold: true,
284
+ });
285
+ }
286
+ y += 1;
287
+ }
288
+
289
+ // Tempo marks, only when the tempo changes.
290
+ if (view.tempo.length > 0 && y < bottom - FOOTER_ROWS) {
291
+ buffer.text(rect.x, y, " tempo", roles.faint);
292
+ for (const mark of view.tempo) {
293
+ const at = mark.cell - firstCell;
294
+ if (at < 0 || at >= cells) continue;
295
+ buffer.text(
296
+ left + at,
297
+ y,
298
+ truncate(`${unicode ? "♩" : "q"}${mark.bpm}`, cells - at, ""),
299
+ roles.knob3,
300
+ );
301
+ }
302
+ y += 1;
303
+ }
304
+
305
+ // Track rows.
306
+ const rowsY = y;
307
+ const rowRoom = Math.max(0, bottom - FOOTER_ROWS - y);
308
+ const firstRow = tapeFirstRow(view, rowRoom);
309
+ const levels = unicode ? LEVELS : ASCII_LEVELS;
310
+ const visible = view.rows.slice(firstRow, firstRow + rowRoom);
311
+ visible.forEach((row, offset) => {
312
+ const index = firstRow + offset;
313
+ const focused = index === view.focused;
314
+ const number = index < 9 ? String(index + 1) : " ";
315
+ const accent = accentStyle(theme, row.id);
316
+ buffer.text(rect.x, y, ` ${number}`, roles.faint);
317
+ buffer.text(rect.x + 2, y, focused ? (unicode ? "›" : ">") : " ", {
318
+ ...roles.text,
319
+ bold: true,
320
+ });
321
+ const name = truncate(row.name, 6, "").padEnd(6);
322
+ buffer.text(
323
+ rect.x + 3,
324
+ y,
325
+ name,
326
+ focused
327
+ ? { ...accent, bold: true, reverse: true }
328
+ : row.muted
329
+ ? roles.mutedNote
330
+ : accent,
331
+ );
332
+ for (let column = 0; column < cells; column += 1) {
333
+ const cell = firstCell + column;
334
+ const level = row.levels[cell] ?? 0;
335
+ const repeat = ghost(cell);
336
+ const style: Style | undefined =
337
+ column === head
338
+ ? // Over a note the playhead reverses it: visible in mono too.
339
+ { ...roles.knob1, ...(level > 0 ? { reverse: true } : {}) }
340
+ : row.muted
341
+ ? roles.mutedNote
342
+ : level > 0
343
+ ? repeat
344
+ ? { ...accent, dim: true }
345
+ : accent
346
+ : barStart(cell)
347
+ ? roles.barRule
348
+ : roles.beatRule;
349
+ const ch =
350
+ level > 0
351
+ ? repeat
352
+ ? unicode
353
+ ? GHOST
354
+ : ASCII_GHOST
355
+ : levels[Math.min(8, level)]!
356
+ : column === head
357
+ ? unicode
358
+ ? "│"
359
+ : "|"
360
+ : barStart(cell)
361
+ ? unicode
362
+ ? "┊"
363
+ : ":"
364
+ : "·";
365
+ buffer.set(left + column, y, ch, style);
366
+ }
367
+ // Other panes, dim; a recording pane's `●` in the record red.
368
+ [
369
+ ...truncate(
370
+ unicode ? row.marks : row.marks.replace(/●/g, "*"),
371
+ marksWidth - 1,
372
+ "",
373
+ ),
374
+ ].forEach((ch, k) =>
375
+ buffer.set(
376
+ left + cells + 1 + k,
377
+ y,
378
+ ch,
379
+ ch === "●" || ch === "*" ? roles.error : { ...roles.muted, dim: true },
380
+ ),
381
+ );
382
+ options.hits?.add(rect.x, y, rect.width, 1, {
383
+ kind: "tape-row",
384
+ row: index,
385
+ left,
386
+ firstCell,
387
+ });
388
+ y += 1;
389
+ });
390
+
391
+ // Footer: range line with the clipboard chip, knob strip, gesture hints.
392
+ const footerY = bottom - FOOTER_ROWS;
393
+ if (footerY >= rowsY) {
394
+ buffer.text(
395
+ rect.x + 1,
396
+ footerY,
397
+ truncate(view.range, rect.width - 2),
398
+ roles.text,
399
+ );
400
+ if (view.clipboard) {
401
+ const chip = `${unicode ? "◆" : "*"} ${view.clipboard}`;
402
+ const room = rect.width - 2 - displayWidth(view.range) - 3;
403
+ if (room >= 12) {
404
+ const text = truncate(chip, room);
405
+ const x = rect.x + rect.width - 1 - displayWidth(text);
406
+ buffer.text(x, footerY, text, roles.warning);
407
+ options.hits?.add(x, footerY, displayWidth(text), 1, {
408
+ kind: "tape-clipboard",
409
+ });
410
+ }
411
+ }
412
+ paintKnobStrip(
413
+ buffer,
414
+ rect.x + 1,
415
+ footerY + 1,
416
+ rect.width - 2,
417
+ view.knobs,
418
+ view.selected,
419
+ {
420
+ theme,
421
+ unicode,
422
+ },
423
+ );
424
+ buffer.text(
425
+ rect.x + 1,
426
+ footerY + 2,
427
+ truncate(view.hint, rect.width - 2),
428
+ roles.muted,
429
+ );
430
+ }
431
+ return {
432
+ rulerY,
433
+ firstCell,
434
+ cells,
435
+ firstRow,
436
+ rowsY,
437
+ rowCount: visible.length,
438
+ };
439
+ }
package/tui/text.ts CHANGED
@@ -30,9 +30,37 @@ export function graphemeWidth(cluster: string): number {
30
30
  return Math.max(0, Math.min(2, width));
31
31
  }
32
32
 
33
- /** Segment text into grapheme clusters with code-point offsets and widths. */
34
- export function graphemes(text: string): Grapheme[] {
33
+ /** Printable ASCII only: one cell per character, no segmentation needed. */
34
+ const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
35
+
36
+ /**
37
+ * Recent segmentations. A frame paints the same strings every tick (help
38
+ * rows, labels, hints), and segmenting them is most of a large frame's
39
+ * cost; the cache holds a frame's worth and is dropped whole when full.
40
+ */
41
+ const segmented = new Map<string, readonly Grapheme[]>();
42
+ const SEGMENTED_MAX = 4096;
43
+
44
+ /**
45
+ * Segment text into grapheme clusters with code-point offsets and widths.
46
+ * The result is shared between calls: read it, never mutate it.
47
+ */
48
+ export function graphemes(text: string): readonly Grapheme[] {
49
+ const cached = segmented.get(text);
50
+ if (cached) return cached;
51
+ const result = segment(text);
52
+ if (segmented.size >= SEGMENTED_MAX) segmented.clear();
53
+ segmented.set(text, result);
54
+ return result;
55
+ }
56
+
57
+ function segment(text: string): Grapheme[] {
35
58
  const result: Grapheme[] = [];
59
+ if (PRINTABLE_ASCII.test(text)) {
60
+ for (let index = 0; index < text.length; index += 1)
61
+ result.push({ text: text[index]!, start: index, length: 1, width: 1 });
62
+ return result;
63
+ }
36
64
  let start = 0;
37
65
  const clusters = segmenter
38
66
  ? Array.from(segmenter.segment(text), (part) => part.segment)
@@ -51,6 +79,7 @@ export function graphemes(text: string): Grapheme[] {
51
79
  }
52
80
 
53
81
  export function displayWidth(text: string): number {
82
+ if (PRINTABLE_ASCII.test(text)) return text.length;
54
83
  let width = 0;
55
84
  for (const cluster of graphemes(text)) width += cluster.width;
56
85
  return width;
@@ -61,6 +90,10 @@ export function truncate(text: string, width: number, ellipsis = "…"): string
61
90
  if (width <= 0) return "";
62
91
  if (displayWidth(text) <= width) return text;
63
92
  const room = Math.max(0, width - displayWidth(ellipsis));
93
+ if (PRINTABLE_ASCII.test(text))
94
+ return room === 0
95
+ ? ellipsis.slice(0, width)
96
+ : `${text.slice(0, room)}${ellipsis}`;
64
97
  let used = 0;
65
98
  let out = "";
66
99
  for (const cluster of graphemes(text)) {
package/tui/theme.ts CHANGED
@@ -56,7 +56,12 @@ export type SemanticRole =
56
56
  | "promptBg"
57
57
  | "promptText"
58
58
  | "pillSteer"
59
- | "pillQueue";
59
+ | "pillQueue"
60
+ /** The four knobs (design §7.1): reserved, never reused for other things. */
61
+ | "knob1"
62
+ | "knob2"
63
+ | "knob3"
64
+ | "knob4";
60
65
 
61
66
  export interface Rgb {
62
67
  readonly r: number;
@@ -115,6 +120,10 @@ const defaultTheme: Theme = {
115
120
  promptText: { fg: rgb(232, 237, 246) },
116
121
  pillSteer: { fg: rgb(17, 20, 26), bg: rgb(91, 211, 145), bold: true },
117
122
  pillQueue: { fg: rgb(17, 20, 26), bg: rgb(255, 193, 87), bold: true },
123
+ knob1: { fg: rgb(80, 160, 255), bold: true },
124
+ knob2: { fg: rgb(90, 210, 140), bold: true },
125
+ knob3: { fg: rgb(225, 228, 236), bold: true },
126
+ knob4: { fg: rgb(255, 140, 60), bold: true },
118
127
  },
119
128
  accents: [
120
129
  rgb(95, 181, 255),
@@ -160,6 +169,10 @@ const highContrastTheme: Theme = {
160
169
  promptText: { fg: rgb(255, 255, 255), bold: true },
161
170
  pillSteer: { fg: rgb(0, 0, 0), bg: rgb(0, 255, 0), bold: true },
162
171
  pillQueue: { fg: rgb(0, 0, 0), bg: rgb(255, 255, 0), bold: true },
172
+ knob1: { fg: rgb(60, 150, 255), bold: true },
173
+ knob2: { fg: rgb(60, 255, 140), bold: true },
174
+ knob3: { fg: rgb(236, 240, 255), bold: true },
175
+ knob4: { fg: rgb(255, 130, 30), bold: true },
163
176
  },
164
177
  accents: [
165
178
  rgb(0, 200, 255),
@@ -206,6 +219,11 @@ const monoTheme: Theme = {
206
219
  promptText: {},
207
220
  pillSteer: { reverse: true, bold: true },
208
221
  pillQueue: { reverse: true, bold: true, underline: true },
222
+ // No hue: the knob glyphs (● ▲ ■ ◆) carry which knob is which.
223
+ knob1: { bold: true },
224
+ knob2: { bold: true },
225
+ knob3: { bold: true },
226
+ knob4: { bold: true },
209
227
  },
210
228
  accents: [],
211
229
  };