@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/app.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * injectable `TerminalIO`.
13
13
  */
14
14
 
15
+ import { paintTape, type TapeView } from "./tape.ts";
15
16
  import {
16
17
  ActivityFeed,
17
18
  ERROR_FADE_MS,
@@ -76,8 +77,10 @@ export interface AppView {
76
77
  sync?: SyncState | undefined;
77
78
  /** Human session name; replaces the short id in the header when set. */
78
79
  sessionName?: string | undefined;
79
- /** Live windows on this session (presence); shown when more than one. */
80
+ /** Live panes on this session (presence); shown when more than one. */
80
81
  windows?: number | undefined;
82
+ /** This pane's letter (dawgd), shown beside the count: `B ⧉3` (`B 3 panes` in ASCII). */
83
+ pane?: string | undefined;
81
84
  /** Project typecheck result; `types ✓` or `types ✗ N` beside sync. */
82
85
  types?: TypesIndicator | undefined;
83
86
  /**
@@ -95,6 +98,8 @@ export interface AppView {
95
98
  { ghost?: string | undefined; caption?: string | undefined } | undefined;
96
99
  /** Play mode: replaces the header and adds the keyboard strip row. */
97
100
  play?: PlayHeaderView | undefined;
101
+ /** TAPE (op1-ux §6): replaces the highway and the arrange strip. */
102
+ tape?: TapeView | undefined;
98
103
  /** Song master meter: integrated LUFS and true peak of the playing loop. */
99
104
  loudness?: LoudnessView | undefined;
100
105
  /** Song sections over the timeline (0.5); no row when absent. */
@@ -267,8 +272,40 @@ export interface FrameLayout {
267
272
  tooSmall: boolean;
268
273
  }
269
274
 
270
- export const MIN_WIDTH = 24;
271
- export const MIN_HEIGHT = 8;
275
+ /**
276
+ * The smallest terminal the real UI draws in (DAWG.md "Terminal
277
+ * sizes"). Measured with `bun run sizes`: below 60 columns the play-mode
278
+ * key strip and the header's bar position drop out and the prompt's spend
279
+ * line clips; below 16 rows the prompt loses its footer and the drawer its
280
+ * last knob. 80x24 stays the design target.
281
+ */
282
+ export const MIN_WIDTH = 60;
283
+ export const MIN_HEIGHT = 16;
284
+
285
+ /** True when a terminal of this size shows the too-small screen instead. */
286
+ export function isTooSmall(size: FrameSize): boolean {
287
+ return !(size.width >= MIN_WIDTH && size.height >= MIN_HEIGHT);
288
+ }
289
+ /**
290
+ * The longest line a text panel (help, guides, the transcript, menus) sets:
291
+ * past about 100 columns prose stops being readable and a command table's
292
+ * two columns drift apart, so on a wide terminal the panel holds this
293
+ * measure, left-aligned, and the song keeps drawing beside it (see
294
+ * `SIDE_MIN_WIDTH`).
295
+ */
296
+ export const TEXT_MEASURE = 100;
297
+ /** The fewest columns beside a capped panel worth drawing the view into. */
298
+ export const SIDE_MIN_WIDTH = 36;
299
+
300
+ /** Where a text panel sits in a terminal `width` columns wide. */
301
+ export function panelRect(
302
+ width: number,
303
+ measure = TEXT_MEASURE,
304
+ ): { left: number; boxWidth: number } {
305
+ const left = width >= 60 ? 2 : 0;
306
+ return { left, boxWidth: Math.min(width - left * 2, measure + 4) };
307
+ }
308
+
272
309
  export const MAX_PROMPT_ROWS = 8;
273
310
  /** New cards glow for this long before settling. */
274
311
  export const CARD_GLOW_MS = 700;
@@ -306,7 +343,7 @@ export function computeLayout(
306
343
  size: FrameSize,
307
344
  promptWrappedRows: number,
308
345
  ): FrameLayout {
309
- const tooSmall = size.width < MIN_WIDTH || size.height < MIN_HEIGHT;
346
+ const tooSmall = isTooSmall(size);
310
347
  const rows = Math.max(
311
348
  1,
312
349
  Math.min(promptWrappedRows, promptRowCap(size.height)),
@@ -450,6 +487,54 @@ export function playContext(
450
487
  ].join(" · ");
451
488
  }
452
489
 
490
+ /** `5.3`: the 1-based bar and beat of a song beat. */
491
+ export function barBeatLabel(
492
+ beat: number,
493
+ beatsPerBar: number,
494
+ barBeats?: readonly number[],
495
+ ): string {
496
+ const at = Number.isFinite(beat) ? Math.max(0, beat) : 0;
497
+ if (barBeats && barBeats.length > 0) {
498
+ let bar = 0;
499
+ while (bar + 1 < barBeats.length && barBeats[bar + 1]! <= at + 1e-9)
500
+ bar += 1;
501
+ return `${bar + 1}.${Math.floor(at - barBeats[bar]! + 1e-9) + 1}`;
502
+ }
503
+ const per = Math.max(1, beatsPerBar);
504
+ const bar = Math.floor(at / per + 1e-9);
505
+ return `${bar + 1}.${Math.floor(at - bar * per + 1e-9) + 1}`;
506
+ }
507
+
508
+ /** `↻ 5–6` (`loop 5-6` without Unicode): the loop range, 1-based bars. */
509
+ export function loopRangeLabel(
510
+ range: Readonly<{ startBar: number; bars: number }>,
511
+ unicode: boolean,
512
+ ): string {
513
+ const first = range.startBar + 1;
514
+ const last = range.startBar + range.bars;
515
+ const bars =
516
+ first === last ? `${first}` : `${first}${unicode ? "–" : "-"}${last}`;
517
+ return unicode ? `↻ ${bars}` : `loop ${bars}`;
518
+ }
519
+
520
+ /** `B ⧉3`: this pane's letter and how many panes are live. */
521
+ export function paneLabel(
522
+ windows: number,
523
+ pane: string | undefined,
524
+ unicode: boolean,
525
+ ): string {
526
+ const count = unicode ? `⧉${windows}` : `${windows} panes`;
527
+ return pane ? `${pane} ${count}` : count;
528
+ }
529
+
530
+ /** The footer's right side: the session name, then the spend line. */
531
+ export function footerStatus(view: AppView, sessionId?: string): string {
532
+ const session =
533
+ view.sessionName ??
534
+ (sessionId ? `session ${sessionId.slice(0, 8)}` : undefined);
535
+ return [session, view.spend].filter(Boolean).join(" · ");
536
+ }
537
+
453
538
  function paintHeader(
454
539
  buffer: CellBuffer,
455
540
  view: AppView,
@@ -475,14 +560,11 @@ function paintHeader(
475
560
  });
476
561
  const transport = `${playing ? (unicode ? "▶" : ">") : unicode ? "⏸" : "||"} ${score.bpm ?? 120} BPM`;
477
562
  const name = score.trackName ?? score.trackId ?? "track";
563
+ // dawg · ▶ BPM · bar.beat · ↻ loop · track ··· key · model · rev · pane
564
+ // (design §8.2): where you are in the song reads first; the session name
565
+ // lives in the footer.
478
566
  const left: Segment[] = [
479
567
  { text: "dawg", style: { ...roles.muted, bold: true }, priority: 6 },
480
- {
481
- text: name,
482
- style: { ...accentStyle(theme, score.trackId ?? name), bold: true },
483
- priority: 0,
484
- target: { kind: "tracks" },
485
- },
486
568
  {
487
569
  text: transport,
488
570
  style: playing
@@ -493,24 +575,29 @@ function paintHeader(
493
575
  priority: 0,
494
576
  target: { kind: "transport" },
495
577
  },
578
+ {
579
+ text: barBeatLabel(beat, score.beatsPerBar ?? 4, score.barBeats),
580
+ style: roles.text,
581
+ priority: 2,
582
+ },
496
583
  ];
497
- if (score.key)
498
- left.push({ text: score.key, style: roles.muted, priority: 5 });
499
- if (view.sessionName)
500
- left.push({ text: view.sessionName, style: roles.muted, priority: 3 });
501
- else if (score.sessionId)
584
+ if (score.loopRange)
502
585
  left.push({
503
- text: `session ${score.sessionId.slice(0, 8)}`,
504
- style: roles.muted,
586
+ text: loopRangeLabel(score.loopRange, unicode),
587
+ // The loop is the green knob's (§8.1): it wears that colour.
588
+ style: roles.knob2,
505
589
  priority: 3,
506
590
  });
507
- if (view.windows !== undefined && view.windows > 1)
508
- left.push({
509
- text: `${view.windows} windows`,
510
- style: roles.muted,
511
- priority: 4,
512
- });
591
+ left.push({
592
+ text: name,
593
+ style: { ...accentStyle(theme, score.trackId ?? name), bold: true },
594
+ priority: 0,
595
+ target: { kind: "tracks" },
596
+ });
513
597
  const right: Segment[] = [];
598
+ // The key never drops before the model, rev or sync at 80 columns.
599
+ if (score.key)
600
+ right.push({ text: score.key, style: roles.muted, priority: 1 });
514
601
  if (view.model)
515
602
  right.push({
516
603
  text: view.model,
@@ -524,6 +611,12 @@ function paintHeader(
524
611
  style: roles.text,
525
612
  priority: 1,
526
613
  });
614
+ if (view.windows !== undefined && view.windows > 1)
615
+ right.push({
616
+ text: paneLabel(view.windows, view.pane, unicode),
617
+ style: roles.muted,
618
+ priority: 4,
619
+ });
527
620
  if (view.types)
528
621
  right.push({
529
622
  text: view.types.ok
@@ -768,6 +861,7 @@ function paintPrompt(
768
861
  if (view.spend) {
769
862
  if (!layout.footer) status.push(view.spend);
770
863
  } else if (view.model) status.push(view.model);
864
+ if (!layout.footer && view.sessionName) status.unshift(view.sessionName);
771
865
  if (activity.queueDepth > 0) status.push(`queue ${activity.queueDepth}`);
772
866
  const layoutInfo = prompt.layout(rows);
773
867
  if (layoutInfo.total > rows)
@@ -845,7 +939,8 @@ function paintPrompt(
845
939
  : width >= 52
846
940
  ? " enter · ctrl-q now/next · ctrl-z undo · ctrl-o log "
847
941
  : "";
848
- const spend = view.spend ? ` ${view.spend} ` : "";
942
+ const right = footerStatus(view, view.score.sessionId);
943
+ const spend = right ? ` ${right} ` : "";
849
944
  const spendWidth = displayWidth(spend);
850
945
  // Spend wins over hints when both do not fit.
851
946
  const shown =
@@ -896,8 +991,7 @@ function paintOverlay(
896
991
  const box = capabilities.unicode ? UNICODE_BOX : ASCII_BOX;
897
992
  const panel = roles.panel;
898
993
  const border = onBackground(roles.border, panel);
899
- const left = width >= 60 ? 2 : 0;
900
- const boxWidth = width - left * 2;
994
+ const { left, boxWidth } = panelRect(width);
901
995
  const top = region.y;
902
996
  const height = region.height;
903
997
  if (height < 3 || boxWidth < 10) return;
@@ -981,8 +1075,7 @@ function paintText(
981
1075
  const text = options.text ?? ui.text;
982
1076
  if (!text) return;
983
1077
  const roles = ui.theme.roles;
984
- const left = width >= 60 ? 2 : 0;
985
- const boxWidth = width - left * 2;
1078
+ const { left, boxWidth } = panelRect(width);
986
1079
  const height = region.height;
987
1080
  if (height < 3 || boxWidth < 10) return;
988
1081
  const panel = paintBox(buffer, ui, {
@@ -1044,8 +1137,7 @@ function paintGuide(
1044
1137
  if (!guide) return;
1045
1138
  const roles = ui.theme.roles;
1046
1139
  const unicode = ui.capabilities.unicode;
1047
- const left = width >= 60 ? 2 : 0;
1048
- const boxWidth = width - left * 2;
1140
+ const { left, boxWidth } = panelRect(width);
1049
1141
  const height = region.height;
1050
1142
  if (height < 3 || boxWidth < 10) return;
1051
1143
  const panel = paintBox(buffer, ui, {
@@ -1125,8 +1217,7 @@ function paintPicker(
1125
1217
  const picker = ui.picker;
1126
1218
  if (!picker) return;
1127
1219
  const roles = ui.theme.roles;
1128
- const left = width >= 60 ? 2 : 0;
1129
- const boxWidth = width - left * 2;
1220
+ const { left, boxWidth } = panelRect(width);
1130
1221
  const noteRows = picker.note && region.height >= 6 ? 1 : 0;
1131
1222
  const height = Math.min(
1132
1223
  region.height,
@@ -1241,12 +1332,56 @@ export function footerHint(
1241
1332
 
1242
1333
  // ---------------------------------------------------------------------------
1243
1334
 
1244
- function paintTooSmall(buffer: CellBuffer, ui: UiState, size: FrameSize): void {
1245
- const roles = ui.theme.roles;
1246
- const lines = [
1247
- "resize terminal",
1248
- `${size.width}x${size.height} · need ${MIN_WIDTH}x${MIN_HEIGHT}`,
1335
+ /**
1336
+ * The too-small screen's lines, widest layout that fits first: one line
1337
+ * (`terminal too small · 58×14 · need ≥ 60×16`), then stacked, then bare
1338
+ * numbers. The last line is the keys that still work.
1339
+ */
1340
+ export function tooSmallLines(
1341
+ size: FrameSize,
1342
+ unicode: boolean,
1343
+ playing: boolean,
1344
+ ): string[] {
1345
+ const x = unicode ? "×" : "x";
1346
+ const dot = unicode ? " · " : " - ";
1347
+ const now = `${size.width}${x}${size.height}`;
1348
+ const need = `need ${unicode ? "≥" : ">="} ${MIN_WIDTH}${x}${MIN_HEIGHT}`;
1349
+ const keys = `space ${playing ? "stop" : "play"}${dot}q quit`;
1350
+ const fits = (lines: string[]) =>
1351
+ lines.length <= size.height &&
1352
+ lines.every((line) => displayWidth(line) <= size.width);
1353
+ const layouts = [
1354
+ [`terminal too small${dot}${now}${dot}${need}`, "", keys],
1355
+ ["terminal too small", `${now}${dot}${need}`, "", keys],
1356
+ ["terminal too small", now, need, "", keys],
1357
+ ["terminal too small", now, need],
1358
+ ["too small", now, need],
1359
+ [now, need],
1360
+ [`${now} ${unicode ? "≥" : ">="}${MIN_WIDTH}${x}${MIN_HEIGHT}`],
1361
+ [now],
1249
1362
  ];
1363
+ return layouts.find(fits) ?? [now];
1364
+ }
1365
+
1366
+ /**
1367
+ * What a key does while the too-small screen is up: ctrl-c and q quit,
1368
+ * space starts or stops playback, everything else (typing, mouse, menu
1369
+ * keys) is dropped so the hidden UI's state is untouched.
1370
+ */
1371
+ export function tooSmallKey(value: unknown): "quit" | "play" | "ignore" {
1372
+ if (value === "\u0003" || value === "q" || value === "Q") return "quit";
1373
+ if (value === " ") return "play";
1374
+ return "ignore";
1375
+ }
1376
+
1377
+ function paintTooSmall(
1378
+ buffer: CellBuffer,
1379
+ ui: UiState,
1380
+ size: FrameSize,
1381
+ playing: boolean,
1382
+ ): void {
1383
+ const roles = ui.theme.roles;
1384
+ const lines = tooSmallLines(size, ui.capabilities.unicode, playing);
1250
1385
  const top = Math.max(0, Math.floor((size.height - lines.length) / 2));
1251
1386
  lines.forEach((line, index) => {
1252
1387
  const text = truncate(line, size.width);
@@ -1255,7 +1390,7 @@ function paintTooSmall(buffer: CellBuffer, ui: UiState, size: FrameSize): void {
1255
1390
  x,
1256
1391
  top + index,
1257
1392
  text,
1258
- index === 0 ? roles.warning : roles.muted,
1393
+ index === 0 ? { ...roles.warning, bold: true } : roles.muted,
1259
1394
  );
1260
1395
  });
1261
1396
  }
@@ -1273,7 +1408,7 @@ export function composeFrame(
1273
1408
  const layout = computeLayout({ width, height }, ui.prompt.wrappedRows);
1274
1409
  const hits = new HitMap();
1275
1410
  if (layout.tooSmall) {
1276
- paintTooSmall(buffer, ui, { width, height });
1411
+ paintTooSmall(buffer, ui, { width, height }, view.score.playing === true);
1277
1412
  return {
1278
1413
  buffer,
1279
1414
  cursor: undefined,
@@ -1323,7 +1458,7 @@ export function composeFrame(
1323
1458
  hits,
1324
1459
  view.beat ?? resolveBeat(view.score, nowMs),
1325
1460
  );
1326
- if (view.arrange && layout.highway.height > 4) {
1461
+ if (view.arrange && !view.tape && layout.highway.height > 4) {
1327
1462
  paintArrangeStrip(
1328
1463
  buffer,
1329
1464
  layout.highway.y,
@@ -1344,6 +1479,7 @@ export function composeFrame(
1344
1479
  kind: ui.overlay && !ui.drawer ? "text" : "highway",
1345
1480
  });
1346
1481
  // The drawer replaces the menu's list: the piano roll shows above it.
1482
+ let panel: number | undefined = TEXT_MEASURE;
1347
1483
  if (ui.overlay === "log" && !ui.drawer)
1348
1484
  paintOverlay(buffer, ui, layout.highway, width);
1349
1485
  else if (ui.overlay === "picker" && ui.picker && !ui.drawer)
@@ -1352,10 +1488,32 @@ export function composeFrame(
1352
1488
  paintText(buffer, ui, layout.highway, width);
1353
1489
  else if (ui.overlay === "guide" && ui.guide)
1354
1490
  paintGuide(buffer, ui, layout.highway, width);
1355
- else
1491
+ else panel = undefined;
1492
+ // A text panel holds its measure; on a wide terminal the song keeps
1493
+ // drawing beside it instead of an empty margin.
1494
+ const { left: panelLeft, boxWidth } = panelRect(width, panel);
1495
+ const sideX = panelLeft + boxWidth + 1;
1496
+ const side =
1497
+ panel !== undefined
1498
+ ? width - sideX >= SIDE_MIN_WIDTH
1499
+ ? { x: sideX, width: width - sideX }
1500
+ : undefined
1501
+ : { x: 0, width };
1502
+ if (side && view.tape)
1503
+ paintTape(
1504
+ buffer,
1505
+ { ...side, y: layout.highway.y, height: layout.highway.height },
1506
+ view.tape,
1507
+ {
1508
+ theme: ui.theme,
1509
+ unicode: ui.capabilities.unicode,
1510
+ ...(panel !== undefined ? {} : { hits }),
1511
+ },
1512
+ );
1513
+ else if (side)
1356
1514
  paintHighway(
1357
1515
  buffer,
1358
- { x: 0, y: layout.highway.y, width, height: layout.highway.height },
1516
+ { ...side, y: layout.highway.y, height: layout.highway.height },
1359
1517
  view.score,
1360
1518
  beat,
1361
1519
  {
@@ -1476,6 +1634,8 @@ export class TuiApp {
1476
1634
  private writer: ScreenWriter;
1477
1635
  private lastFrame: Frame | undefined;
1478
1636
  private lastFrameAt = Number.NEGATIVE_INFINITY;
1637
+ /** Compose plus encode time of the last rendered frame, in ms. */
1638
+ lastRenderMs = 0;
1479
1639
  /** Minimum interval between frames (~30 fps). */
1480
1640
  frameIntervalMs = 33;
1481
1641
  /**
@@ -1544,16 +1704,26 @@ export class TuiApp {
1544
1704
  now - this.lastFrameAt < this.frameIntervalMs + this.backoffMs
1545
1705
  )
1546
1706
  return "";
1707
+ // A zero-size (or unknown) terminal has no cell to draw in; any write
1708
+ // would wrap. Draw again in full once it has one.
1709
+ const columns = Math.floor(this.io.columns() || 0);
1710
+ const rows = Math.floor(this.io.rows() || 0);
1711
+ if (columns < 1 || rows < 1) {
1712
+ this.writer.invalidate();
1713
+ return "";
1714
+ }
1547
1715
  this.lastFrameAt = now;
1548
1716
  this.watchFirstLoop(view, now);
1717
+ const started = performance.now();
1549
1718
  const frame = composeFrame(
1550
1719
  view,
1551
1720
  this.ui,
1552
- { width: this.io.columns(), height: this.io.rows() },
1721
+ { width: columns, height: rows },
1553
1722
  now,
1554
1723
  );
1555
1724
  this.lastFrame = frame;
1556
1725
  const out = this.writer.frame(frame.buffer, frame.cursor);
1726
+ this.lastRenderMs = performance.now() - started;
1557
1727
  if (out) {
1558
1728
  const accepted = this.io.write(out);
1559
1729
  this.backoffMs =
@@ -1710,12 +1880,15 @@ export class TuiApp {
1710
1880
  // End then ↑ moves the view on the first press.
1711
1881
  const max = Math.max(0, this.text.lines.length - page);
1712
1882
  const from = Math.min(max, this.text.scroll);
1713
- this.text.scroll =
1883
+ const next =
1714
1884
  nav === "home"
1715
1885
  ? 0
1716
1886
  : nav === "end"
1717
1887
  ? max
1718
1888
  : Math.max(0, Math.min(max, from + step));
1889
+ // At the last page the panel pins to the end: a resize keeps the
1890
+ // last line in view (paintText clamps Infinity to the new bound).
1891
+ this.text.scroll = max > 0 && next === max ? Infinity : next;
1719
1892
  return { type: "overlay" };
1720
1893
  }
1721
1894
  }
package/tui/delight.ts CHANGED
@@ -62,6 +62,55 @@ export function sweepProgress(
62
62
  return age / SWEEP_MS;
63
63
  }
64
64
 
65
+ /** The TAPE reels, one step per beat (op1-ux §9.1). */
66
+ export const REELS = "◐◓◑◒";
67
+ /** ASCII reels for terminals without unicode. */
68
+ export const ASCII_REELS = "|/-\\";
69
+ /** How long the loop brackets stay bright after each wrap (§9.3). */
70
+ export const LOOP_FLASH_MS = 150;
71
+
72
+ /**
73
+ * The TAPE reel glyph: turns one step per transport beat while playing,
74
+ * stands still when stopped or with `/motion off`. A pure function of the
75
+ * beat, so two panes on one transport turn together.
76
+ */
77
+ export function reelGlyph(
78
+ input: Readonly<{
79
+ playing: boolean;
80
+ beat: number;
81
+ reducedMotion: boolean;
82
+ unicode: boolean;
83
+ }>,
84
+ ): string {
85
+ const reels = input.unicode ? REELS : ASCII_REELS;
86
+ if (!input.playing || input.reducedMotion || !Number.isFinite(input.beat))
87
+ return reels[0]!;
88
+ const step = Math.floor(Math.max(0, input.beat));
89
+ return reels[step % reels.length]!;
90
+ }
91
+
92
+ /**
93
+ * Whether the loop brackets flash now: within `LOOP_FLASH_MS` after the
94
+ * transport wraps the loop (each recording pass closes there). Never on
95
+ * the very first pass, never stopped, never with `/motion off`.
96
+ */
97
+ export function loopFlash(
98
+ input: Readonly<{
99
+ playing: boolean;
100
+ /** Transport beat, unwrapped. */
101
+ beat: number;
102
+ loopBeats?: number | undefined;
103
+ bpm: number;
104
+ reducedMotion: boolean;
105
+ }>,
106
+ ): boolean {
107
+ const loop = input.loopBeats;
108
+ if (!input.playing || input.reducedMotion || !loop || loop <= 0) return false;
109
+ if (!Number.isFinite(input.beat) || input.beat < loop) return false;
110
+ const since = input.beat % loop;
111
+ return (since * 60_000) / Math.max(1, input.bpm) < LOOP_FLASH_MS;
112
+ }
113
+
65
114
  /** A glowing lane: its pitch and strength (1 at the press, fading to 0). */
66
115
  export type LaneGlow = Readonly<{ pitch: number; strength: number }>;
67
116