@hraness/dawg 0.6.1 → 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 (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
package/tui/app.ts CHANGED
@@ -12,21 +12,34 @@
12
12
  * injectable `TerminalIO`.
13
13
  */
14
14
 
15
+ import { paintTape, type TapeView } from "./tape.ts";
15
16
  import {
16
17
  ActivityFeed,
18
+ ERROR_FADE_MS,
19
+ fail,
17
20
  revisionLabel,
18
21
  spinnerFrame,
19
22
  type ActivityCard,
23
+ type Receipt,
20
24
  type TranscriptEntry,
21
25
  } from "./activity.ts";
22
26
  import {
23
27
  paintHighway,
28
+ projectionFor,
24
29
  resolveBeat,
25
30
  type TrackScoreSnapshot,
26
31
  } from "./highway.ts";
32
+ import { placeholderHint } from "./hints.ts";
33
+ import {
34
+ Delight,
35
+ downbeatGlint,
36
+ FIRST_LOOP_CARD,
37
+ sweepProgress,
38
+ } from "./delight.ts";
27
39
  import { asciiHint, fitHint, HINTS } from "./grammar.ts";
28
40
  import { GuideBrowser } from "./guide.ts";
29
41
  import { listGuides } from "../guides/index.ts";
42
+ import { topicMiss } from "../src/lang/glossary.ts";
30
43
  import { classifyKey, overlayKey, type UiCommand } from "./keys.ts";
31
44
  import { PromptModel, type PromptAction, type PromptMode } from "./prompt.ts";
32
45
  import {
@@ -64,19 +77,29 @@ export interface AppView {
64
77
  sync?: SyncState | undefined;
65
78
  /** Human session name; replaces the short id in the header when set. */
66
79
  sessionName?: string | undefined;
67
- /** Live windows on this session (presence); shown when more than one. */
80
+ /** Live panes on this session (presence); shown when more than one. */
68
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;
69
84
  /** Project typecheck result; `types ✓` or `types ✗ N` beside sync. */
70
85
  types?: TypesIndicator | undefined;
71
86
  /**
72
87
  * The line under the prompt: `$0.12 session · $0.48 today · opus-5.5 ·
73
- * gateway`, or `no model · dawg login`. The caller sizes it to the width.
88
+ * gateway`, or `commands only`. The caller sizes it to the width.
74
89
  */
75
90
  spend?: string | undefined;
76
- /** No agent provider: the placeholder teaches commands, no STEER pill. */
91
+ /** No agent provider: the placeholder teaches commands, no NOW pill. */
77
92
  agentOffline?: boolean | undefined;
93
+ /**
94
+ * Show-me: the command the agent is writing, as it streams (ghost text in
95
+ * an empty prompt bar), and the caption naming the human gesture.
96
+ */
97
+ showMe?:
98
+ { ghost?: string | undefined; caption?: string | undefined } | undefined;
78
99
  /** Play mode: replaces the header and adds the keyboard strip row. */
79
100
  play?: PlayHeaderView | undefined;
101
+ /** TAPE (op1-ux §6): replaces the highway and the arrange strip. */
102
+ tape?: TapeView | undefined;
80
103
  /** Song master meter: integrated LUFS and true peak of the playing loop. */
81
104
  loudness?: LoudnessView | undefined;
82
105
  /** Song sections over the timeline (0.5); no row when absent. */
@@ -136,6 +159,8 @@ export interface UiState {
136
159
  guide?: GuideBrowser | undefined;
137
160
  /** The fader drawer, docked over the bottom of the piano roll. */
138
161
  drawer?: DrawerView | undefined;
162
+ /** Downbeat glint, first-loop sweep and ghost-note lanes (delight.ts). */
163
+ delight?: Delight | undefined;
139
164
  }
140
165
 
141
166
  /**
@@ -247,8 +272,40 @@ export interface FrameLayout {
247
272
  tooSmall: boolean;
248
273
  }
249
274
 
250
- export const MIN_WIDTH = 24;
251
- 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
+
252
309
  export const MAX_PROMPT_ROWS = 8;
253
310
  /** New cards glow for this long before settling. */
254
311
  export const CARD_GLOW_MS = 700;
@@ -286,7 +343,7 @@ export function computeLayout(
286
343
  size: FrameSize,
287
344
  promptWrappedRows: number,
288
345
  ): FrameLayout {
289
- const tooSmall = size.width < MIN_WIDTH || size.height < MIN_HEIGHT;
346
+ const tooSmall = isTooSmall(size);
290
347
  const rows = Math.max(
291
348
  1,
292
349
  Math.min(promptWrappedRows, promptRowCap(size.height)),
@@ -412,52 +469,135 @@ function syncSegment(
412
469
  return { text: `${glyph[sync]} ${sync}`, style: style[sync], priority: 4 };
413
470
  }
414
471
 
472
+ /**
473
+ * The song header compressed for play mode's row: transport, tempo, the
474
+ * track and the revision (`▶ 120 BPM · drums · rev 1`).
475
+ */
476
+ export function playContext(
477
+ score: TrackScoreSnapshot,
478
+ unicode: boolean,
479
+ ): string {
480
+ const playing = score.playing === true;
481
+ const glyph = playing ? (unicode ? "▶" : ">") : unicode ? "⏸" : "||";
482
+ const name = score.trackName ?? score.trackId ?? "track";
483
+ return [
484
+ `${glyph} ${score.bpm ?? 120} BPM`,
485
+ name,
486
+ ...(score.revision !== undefined ? [`rev ${score.revision}`] : []),
487
+ ].join(" · ");
488
+ }
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
+
415
538
  function paintHeader(
416
539
  buffer: CellBuffer,
417
540
  view: AppView,
418
541
  ui: UiState,
419
542
  width: number,
420
543
  hits?: HitMap,
544
+ beat = 0,
421
545
  ): void {
422
546
  const { theme, capabilities } = ui;
423
547
  const roles = theme.roles;
424
548
  const score = view.score;
425
549
  const unicode = capabilities.unicode;
426
550
  const playing = score.playing === true;
551
+ // The ▶ brightens for a moment on each bar's downbeat.
552
+ const glint = downbeatGlint({
553
+ playing,
554
+ beat,
555
+ bpm: score.bpm ?? 120,
556
+ beatsPerBar: score.beatsPerBar ?? 4,
557
+ barBeats: score.barBeats,
558
+ loopBeats: score.loopBeats,
559
+ reducedMotion: ui.reducedMotion,
560
+ });
427
561
  const transport = `${playing ? (unicode ? "▶" : ">") : unicode ? "⏸" : "||"} ${score.bpm ?? 120} BPM`;
428
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.
429
566
  const left: Segment[] = [
430
567
  { text: "dawg", style: { ...roles.muted, bold: true }, priority: 6 },
431
- {
432
- text: name,
433
- style: { ...accentStyle(theme, score.trackId ?? name), bold: true },
434
- priority: 0,
435
- target: { kind: "tracks" },
436
- },
437
568
  {
438
569
  text: transport,
439
- style: playing ? roles.transport : roles.paused,
570
+ style: playing
571
+ ? glint
572
+ ? { ...shade(roles.transport, 0.55), bold: true }
573
+ : roles.transport
574
+ : roles.paused,
440
575
  priority: 0,
441
576
  target: { kind: "transport" },
442
577
  },
578
+ {
579
+ text: barBeatLabel(beat, score.beatsPerBar ?? 4, score.barBeats),
580
+ style: roles.text,
581
+ priority: 2,
582
+ },
443
583
  ];
444
- if (score.key)
445
- left.push({ text: score.key, style: roles.muted, priority: 5 });
446
- if (view.sessionName)
447
- left.push({ text: view.sessionName, style: roles.muted, priority: 3 });
448
- else if (score.sessionId)
584
+ if (score.loopRange)
449
585
  left.push({
450
- text: `session ${score.sessionId.slice(0, 8)}`,
451
- 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,
452
589
  priority: 3,
453
590
  });
454
- if (view.windows !== undefined && view.windows > 1)
455
- left.push({
456
- text: `${view.windows} windows`,
457
- style: roles.muted,
458
- priority: 4,
459
- });
591
+ left.push({
592
+ text: name,
593
+ style: { ...accentStyle(theme, score.trackId ?? name), bold: true },
594
+ priority: 0,
595
+ target: { kind: "tracks" },
596
+ });
460
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 });
461
601
  if (view.model)
462
602
  right.push({
463
603
  text: view.model,
@@ -471,6 +611,12 @@ function paintHeader(
471
611
  style: roles.text,
472
612
  priority: 1,
473
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
+ });
474
620
  if (view.types)
475
621
  right.push({
476
622
  text: view.types.ok
@@ -527,7 +673,9 @@ function cardMarker(card: ActivityCard, unicode: boolean): string {
527
673
  }
528
674
 
529
675
  export function cardText(card: ActivityCard, unicode: boolean): string {
530
- const parts = [`${cardMarker(card, unicode)} ${card.text}`];
676
+ const count =
677
+ card.count && card.count > 1 ? ` ${unicode ? "×" : "x"}${card.count}` : "";
678
+ const parts = [`${cardMarker(card, unicode)} ${card.text}${count}`];
531
679
  const revision = revisionLabel(
532
680
  card.baseRevision,
533
681
  card.resultRevision,
@@ -535,15 +683,46 @@ export function cardText(card: ActivityCard, unicode: boolean): string {
535
683
  );
536
684
  if (revision) parts.push(revision);
537
685
  if (card.hint) parts.push(card.hint);
686
+ if (card.suffix) parts.push(card.suffix);
538
687
  return parts.join(" · ");
539
688
  }
540
689
 
690
+ /** How long each one-time note leads when they cannot all fit at once. */
691
+ export const ONCE_ROTATE_MS = 3_000;
692
+
693
+ /**
694
+ * The cards for the strip's width: when the one-time notes cannot all show
695
+ * whole beside the receipts, they take turns (one every `ONCE_ROTATE_MS`,
696
+ * on the frame clock), so each is read in full at least once.
697
+ */
698
+ export function stripCards(
699
+ cards: readonly ActivityCard[],
700
+ room: number,
701
+ nowMs: number,
702
+ unicode: boolean,
703
+ ): ActivityCard[] {
704
+ const width = (list: readonly ActivityCard[]) =>
705
+ list.reduce(
706
+ (sum, card, index) =>
707
+ sum + (index > 0 ? 3 : 0) + displayWidth(cardText(card, unicode)),
708
+ 0,
709
+ );
710
+ if (width(cards) <= room) return [...cards];
711
+ const steady = cards.filter((card) => !card.once);
712
+ const once = cards.filter((card) => card.once);
713
+ if (once.length < 2) return [...cards];
714
+ const turn =
715
+ once[Math.floor(Math.max(0, nowMs) / ONCE_ROTATE_MS) % once.length]!;
716
+ return [...steady, turn];
717
+ }
718
+
541
719
  function paintActivity(
542
720
  buffer: CellBuffer,
543
721
  y: number,
544
722
  ui: UiState,
545
723
  width: number,
546
724
  nowMs: number,
725
+ agentOffline = false,
547
726
  ): void {
548
727
  const { theme, capabilities, activity } = ui;
549
728
  const roles = theme.roles;
@@ -567,9 +746,10 @@ function paintActivity(
567
746
  ...roles.agent,
568
747
  bold: true,
569
748
  });
570
- if (activity.streaming && x < limit - 4) {
749
+ // The agent's latest sentence, faint: what it is doing, in its words.
750
+ if (activity.streamSentence && x < limit - 4) {
571
751
  x += buffer.text(x, y, " · ", roles.faint);
572
- const tail = activity.streaming;
752
+ const tail = activity.streamSentence;
573
753
  const room = Math.max(0, limit - x);
574
754
  const shown =
575
755
  displayWidth(tail) > room
@@ -577,13 +757,24 @@ function paintActivity(
577
757
  .slice(-(room - 1))
578
758
  .join("")}`
579
759
  : tail;
580
- x += buffer.text(x, y, truncate(shown, room), roles.muted);
760
+ x += buffer.text(x, y, truncate(shown, room), roles.faint);
581
761
  }
582
762
  } else {
583
- const cards = [...activity.cards].reverse();
763
+ const cards = stripCards(
764
+ activity.visible(nowMs),
765
+ Math.max(0, limit - x),
766
+ nowMs,
767
+ capabilities.unicode,
768
+ );
584
769
  cards.forEach((card, index) => {
585
770
  if (x >= limit) return;
586
771
  const text = cardText(card, capabilities.unicode);
772
+ // A one-time note behind the first slot shows whole or not at all
773
+ // (it stays in ctrl-o); a cut note loses its key word.
774
+ if (index > 0 && card.once && x + 3 + displayWidth(text) > limit) {
775
+ x = limit;
776
+ return;
777
+ }
587
778
  if (index > 0) {
588
779
  if (x + 3 + Math.min(12, displayWidth(text)) > limit) {
589
780
  x = limit;
@@ -602,7 +793,14 @@ function paintActivity(
602
793
  : card.tone === "success"
603
794
  ? roles.success
604
795
  : roles.text;
605
- if (index > 0) style = card.tone === "error" ? roles.error : roles.faint;
796
+ // Behind the first slot everything is faint; an error keeps its
797
+ // colour until it is old, then fades with the rest.
798
+ if (index > 0)
799
+ style =
800
+ card.tone === "error" && age < ERROR_FADE_MS
801
+ ? roles.error
802
+ : roles.faint;
803
+ else if (card.once) style = roles.faint;
606
804
  else if (!ui.reducedMotion && age >= 0 && age < CARD_GLOW_MS)
607
805
  style = { ...shade(style, 0.4 * (1 - age / CARD_GLOW_MS)), bold: true };
608
806
  x += buffer.text(x, y, truncate(text, Math.max(0, limit - x)), style);
@@ -612,7 +810,9 @@ function paintActivity(
612
810
  x,
613
811
  y,
614
812
  truncate(
615
- "type a request · space plays on an empty prompt · /help",
813
+ agentOffline
814
+ ? "type a command · space plays on an empty prompt · /help"
815
+ : "type a request · space plays on an empty prompt · /help",
616
816
  limit - x,
617
817
  ),
618
818
  roles.faint,
@@ -626,7 +826,7 @@ function paintActivity(
626
826
  // Prompt panel
627
827
 
628
828
  function pill(mode: PromptMode): string {
629
- return mode === "queue" ? " QUEUE " : " STEER ";
829
+ return mode === "queue" ? " NEXT " : " NOW ";
630
830
  }
631
831
 
632
832
  function paintPrompt(
@@ -661,6 +861,7 @@ function paintPrompt(
661
861
  if (view.spend) {
662
862
  if (!layout.footer) status.push(view.spend);
663
863
  } else if (view.model) status.push(view.model);
864
+ if (!layout.footer && view.sessionName) status.unshift(view.sessionName);
664
865
  if (activity.queueDepth > 0) status.push(`queue ${activity.queueDepth}`);
665
866
  const layoutInfo = prompt.layout(rows);
666
867
  if (layoutInfo.total > rows)
@@ -698,12 +899,27 @@ function paintPrompt(
698
899
  if (index === rows - 1 && layoutInfo.first + rows < layoutInfo.total)
699
900
  buffer.text(width - 3, y, capabilities.unicode ? "↓" : "v", faint);
700
901
  }
701
- if (prompt.value.length === 0) {
702
- const placeholder = view.agentOffline
703
- ? "try: tempo 96 · add C4 at 0 · /help (dawg login enables the agent)"
704
- : mode === "queue"
705
- ? "queue a request for after the current one…"
706
- : "describe a change — “add a walking bass in A minor”";
902
+ const ghost = view.showMe?.ghost;
903
+ const caption = view.showMe?.caption;
904
+ if (prompt.value.length === 0 && (ghost || caption)) {
905
+ // The agent's command as it streams, then the gesture it stands for.
906
+ const typed = ghost ? `${ghost}${capabilities.unicode ? "▏" : "_"}` : "";
907
+ const line = typed && caption ? `${typed} ${caption}` : typed || caption!;
908
+ buffer.text(5, top + 1, truncate(line, editorWidth - 1), faint);
909
+ } else if (prompt.value.length === 0) {
910
+ // Seeded per session (tui/hints.ts): the words stay put while you
911
+ // work and change between sessions, chosen by agent and song state.
912
+ const score = view.score;
913
+ const placeholder = placeholderHint({
914
+ seed: score.sessionId ?? "dawg",
915
+ agent: !view.agentOffline,
916
+ filled:
917
+ score.notes.length > 0 ||
918
+ (score.clips ?? []).length > 0 ||
919
+ (score.layers ?? []).some((layer) => layer.notes.length > 0),
920
+ drums: projectionFor(score).kind !== "pitch",
921
+ queue: mode === "queue",
922
+ });
707
923
  buffer.text(5, top + 1, truncate(placeholder, editorWidth - 1), faint);
708
924
  }
709
925
 
@@ -714,15 +930,17 @@ function paintPrompt(
714
930
  for (let column = 1; column < width - 1; column += 1)
715
931
  buffer.set(column, y, box.h, border);
716
932
  buffer.set(width - 1, y, box.br, border);
933
+ // One notation everywhere: ctrl-<key>, as /help and the menu spell it.
717
934
  const hints =
718
- width >= 100
719
- ? " enter send · shift+enter newline · ^q queue · ^z undo · ^o log · ^c quit "
720
- : width >= 72
721
- ? " enter send · ^j newline · ^q queue · ^z undo · ^o log "
722
- : width >= 44
723
- ? " enter · ^q queue · ^z undo · ^o log "
935
+ width >= 112
936
+ ? " enter send · shift-enter newline · ctrl-q now/next · ctrl-z undo · ctrl-o log · ctrl-c quit "
937
+ : width >= 80
938
+ ? " enter send · ctrl-j newline · ctrl-q now/next · ctrl-z undo · ctrl-o log "
939
+ : width >= 52
940
+ ? " enter · ctrl-q now/next · ctrl-z undo · ctrl-o log "
724
941
  : "";
725
- const spend = view.spend ? ` ${view.spend} ` : "";
942
+ const right = footerStatus(view, view.score.sessionId);
943
+ const spend = right ? ` ${right} ` : "";
726
944
  const spendWidth = displayWidth(spend);
727
945
  // Spend wins over hints when both do not fit.
728
946
  const shown =
@@ -773,8 +991,7 @@ function paintOverlay(
773
991
  const box = capabilities.unicode ? UNICODE_BOX : ASCII_BOX;
774
992
  const panel = roles.panel;
775
993
  const border = onBackground(roles.border, panel);
776
- const left = width >= 60 ? 2 : 0;
777
- const boxWidth = width - left * 2;
994
+ const { left, boxWidth } = panelRect(width);
778
995
  const top = region.y;
779
996
  const height = region.height;
780
997
  if (height < 3 || boxWidth < 10) return;
@@ -858,8 +1075,7 @@ function paintText(
858
1075
  const text = options.text ?? ui.text;
859
1076
  if (!text) return;
860
1077
  const roles = ui.theme.roles;
861
- const left = width >= 60 ? 2 : 0;
862
- const boxWidth = width - left * 2;
1078
+ const { left, boxWidth } = panelRect(width);
863
1079
  const height = region.height;
864
1080
  if (height < 3 || boxWidth < 10) return;
865
1081
  const panel = paintBox(buffer, ui, {
@@ -921,8 +1137,7 @@ function paintGuide(
921
1137
  if (!guide) return;
922
1138
  const roles = ui.theme.roles;
923
1139
  const unicode = ui.capabilities.unicode;
924
- const left = width >= 60 ? 2 : 0;
925
- const boxWidth = width - left * 2;
1140
+ const { left, boxWidth } = panelRect(width);
926
1141
  const height = region.height;
927
1142
  if (height < 3 || boxWidth < 10) return;
928
1143
  const panel = paintBox(buffer, ui, {
@@ -1002,8 +1217,7 @@ function paintPicker(
1002
1217
  const picker = ui.picker;
1003
1218
  if (!picker) return;
1004
1219
  const roles = ui.theme.roles;
1005
- const left = width >= 60 ? 2 : 0;
1006
- const boxWidth = width - left * 2;
1220
+ const { left, boxWidth } = panelRect(width);
1007
1221
  const noteRows = picker.note && region.height >= 6 ? 1 : 0;
1008
1222
  const height = Math.min(
1009
1223
  region.height,
@@ -1118,12 +1332,56 @@ export function footerHint(
1118
1332
 
1119
1333
  // ---------------------------------------------------------------------------
1120
1334
 
1121
- function paintTooSmall(buffer: CellBuffer, ui: UiState, size: FrameSize): void {
1122
- const roles = ui.theme.roles;
1123
- const lines = [
1124
- "resize terminal",
1125
- `${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],
1126
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);
1127
1385
  const top = Math.max(0, Math.floor((size.height - lines.length) / 2));
1128
1386
  lines.forEach((line, index) => {
1129
1387
  const text = truncate(line, size.width);
@@ -1132,7 +1390,7 @@ function paintTooSmall(buffer: CellBuffer, ui: UiState, size: FrameSize): void {
1132
1390
  x,
1133
1391
  top + index,
1134
1392
  text,
1135
- index === 0 ? roles.warning : roles.muted,
1393
+ index === 0 ? { ...roles.warning, bold: true } : roles.muted,
1136
1394
  );
1137
1395
  });
1138
1396
  }
@@ -1150,7 +1408,7 @@ export function composeFrame(
1150
1408
  const layout = computeLayout({ width, height }, ui.prompt.wrappedRows);
1151
1409
  const hits = new HitMap();
1152
1410
  if (layout.tooSmall) {
1153
- paintTooSmall(buffer, ui, { width, height });
1411
+ paintTooSmall(buffer, ui, { width, height }, view.score.playing === true);
1154
1412
  return {
1155
1413
  buffer,
1156
1414
  cursor: undefined,
@@ -1164,7 +1422,10 @@ export function composeFrame(
1164
1422
  buffer,
1165
1423
  layout.header,
1166
1424
  width,
1167
- view.play,
1425
+ {
1426
+ ...view.play,
1427
+ context: playContext(view.score, ui.capabilities.unicode),
1428
+ },
1168
1429
  ui.theme,
1169
1430
  ui.capabilities.unicode,
1170
1431
  );
@@ -1188,8 +1449,16 @@ export function composeFrame(
1188
1449
  height: layout.highway.height - 1,
1189
1450
  };
1190
1451
  }
1191
- } else paintHeader(buffer, view, ui, width, hits);
1192
- if (view.arrange && layout.highway.height > 4) {
1452
+ } else
1453
+ paintHeader(
1454
+ buffer,
1455
+ view,
1456
+ ui,
1457
+ width,
1458
+ hits,
1459
+ view.beat ?? resolveBeat(view.score, nowMs),
1460
+ );
1461
+ if (view.arrange && !view.tape && layout.highway.height > 4) {
1193
1462
  paintArrangeStrip(
1194
1463
  buffer,
1195
1464
  layout.highway.y,
@@ -1210,6 +1479,7 @@ export function composeFrame(
1210
1479
  kind: ui.overlay && !ui.drawer ? "text" : "highway",
1211
1480
  });
1212
1481
  // The drawer replaces the menu's list: the piano roll shows above it.
1482
+ let panel: number | undefined = TEXT_MEASURE;
1213
1483
  if (ui.overlay === "log" && !ui.drawer)
1214
1484
  paintOverlay(buffer, ui, layout.highway, width);
1215
1485
  else if (ui.overlay === "picker" && ui.picker && !ui.drawer)
@@ -1218,16 +1488,48 @@ export function composeFrame(
1218
1488
  paintText(buffer, ui, layout.highway, width);
1219
1489
  else if (ui.overlay === "guide" && ui.guide)
1220
1490
  paintGuide(buffer, ui, layout.highway, width);
1221
- 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)
1222
1514
  paintHighway(
1223
1515
  buffer,
1224
- { x: 0, y: layout.highway.y, width, height: layout.highway.height },
1516
+ { ...side, y: layout.highway.y, height: layout.highway.height },
1225
1517
  view.score,
1226
1518
  beat,
1227
1519
  {
1228
1520
  theme: ui.theme,
1229
1521
  capabilities: ui.capabilities,
1230
1522
  reducedMotion: ui.reducedMotion,
1523
+ hint: {
1524
+ seed: view.score.sessionId ?? "dawg",
1525
+ agent: !view.agentOffline,
1526
+ },
1527
+ sweep: sweepProgress(
1528
+ ui.delight?.sweepStartedAtMs,
1529
+ nowMs,
1530
+ ui.reducedMotion,
1531
+ ),
1532
+ glows: ui.delight?.glows(nowMs, ui.reducedMotion),
1231
1533
  },
1232
1534
  );
1233
1535
  if (ui.drawer)
@@ -1236,7 +1538,11 @@ export function composeFrame(
1236
1538
  layout.highway,
1237
1539
  width,
1238
1540
  ui.drawer,
1239
- { theme: ui.theme, unicode: ui.capabilities.unicode },
1541
+ {
1542
+ theme: ui.theme,
1543
+ unicode: ui.capabilities.unicode,
1544
+ reducedMotion: ui.reducedMotion,
1545
+ },
1240
1546
  hits,
1241
1547
  );
1242
1548
  }
@@ -1245,7 +1551,14 @@ export function composeFrame(
1245
1551
  text: ui.keys,
1246
1552
  hint: HINTS.keys,
1247
1553
  });
1248
- paintActivity(buffer, layout.activity, ui, width, nowMs);
1554
+ paintActivity(
1555
+ buffer,
1556
+ layout.activity,
1557
+ ui,
1558
+ width,
1559
+ nowMs,
1560
+ view.agentOffline ?? false,
1561
+ );
1249
1562
  const prompt = paintPrompt(buffer, view, ui, layout, width);
1250
1563
  return {
1251
1564
  buffer,
@@ -1260,8 +1573,15 @@ export function composeFrame(
1260
1573
  // ---------------------------------------------------------------------------
1261
1574
  // Mutable app
1262
1575
 
1576
+ /** The longest a lagging terminal waits between frames. */
1577
+ export const MAX_BACKOFF_MS = 250;
1578
+
1263
1579
  export interface TerminalIO {
1264
- write(data: string): void;
1580
+ /**
1581
+ * Writes a frame. `false` means the terminal is not keeping up (the
1582
+ * stream's buffer is full): frames back off until a write is accepted.
1583
+ */
1584
+ write(data: string): void | boolean;
1265
1585
  columns(): number;
1266
1586
  rows(): number;
1267
1587
  }
@@ -1314,8 +1634,19 @@ export class TuiApp {
1314
1634
  private writer: ScreenWriter;
1315
1635
  private lastFrame: Frame | undefined;
1316
1636
  private lastFrameAt = Number.NEGATIVE_INFINITY;
1637
+ /** Compose plus encode time of the last rendered frame, in ms. */
1638
+ lastRenderMs = 0;
1317
1639
  /** Minimum interval between frames (~30 fps). */
1318
1640
  frameIntervalMs = 33;
1641
+ /**
1642
+ * Extra spacing while the terminal lags: doubles on each refused write up
1643
+ * to `MAX_BACKOFF_MS`, resets on the first accepted one.
1644
+ */
1645
+ backoffMs = 0;
1646
+ /** Session-only delight; `heardLoop` comes from the session's metadata. */
1647
+ readonly delight = new Delight();
1648
+ /** The song wrapped for the first time: the host records it in meta. */
1649
+ onFirstLoop: (() => void) | undefined;
1319
1650
 
1320
1651
  constructor(options: TuiAppOptions) {
1321
1652
  this.io = options.io;
@@ -1348,6 +1679,7 @@ export class TuiApp {
1348
1679
  keys: this.keys,
1349
1680
  guide: this.guide,
1350
1681
  drawer: this.drawer,
1682
+ delight: this.delight,
1351
1683
  };
1352
1684
  }
1353
1685
 
@@ -1367,21 +1699,68 @@ export class TuiApp {
1367
1699
  */
1368
1700
  render(view: AppView, options: { force?: boolean } = {}): string {
1369
1701
  const now = this.clock();
1370
- if (!options.force && now - this.lastFrameAt < this.frameIntervalMs)
1702
+ if (
1703
+ !options.force &&
1704
+ now - this.lastFrameAt < this.frameIntervalMs + this.backoffMs
1705
+ )
1371
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
+ }
1372
1715
  this.lastFrameAt = now;
1716
+ this.watchFirstLoop(view, now);
1717
+ const started = performance.now();
1373
1718
  const frame = composeFrame(
1374
1719
  view,
1375
1720
  this.ui,
1376
- { width: this.io.columns(), height: this.io.rows() },
1721
+ { width: columns, height: rows },
1377
1722
  now,
1378
1723
  );
1379
1724
  this.lastFrame = frame;
1380
1725
  const out = this.writer.frame(frame.buffer, frame.cursor);
1381
- if (out) this.io.write(out);
1726
+ this.lastRenderMs = performance.now() - started;
1727
+ if (out) {
1728
+ const accepted = this.io.write(out);
1729
+ this.backoffMs =
1730
+ accepted === false
1731
+ ? Math.min(
1732
+ MAX_BACKOFF_MS,
1733
+ Math.max(this.frameIntervalMs, this.backoffMs * 2),
1734
+ )
1735
+ : 0;
1736
+ }
1382
1737
  return out;
1383
1738
  }
1384
1739
 
1740
+ /**
1741
+ * The first wrap of a song that never wrapped before: a one-shot sweep
1742
+ * and the card `↻ first loop`. The card shows with motion off too (it is
1743
+ * information); the sweep does not.
1744
+ */
1745
+ private watchFirstLoop(view: AppView, now: number): void {
1746
+ const score = view.score;
1747
+ const wrapped = this.delight.observe(
1748
+ {
1749
+ playing: score.playing === true,
1750
+ beat: view.beat ?? resolveBeat(score, now),
1751
+ loopBeats: score.loopBeats,
1752
+ empty:
1753
+ score.notes.length === 0 &&
1754
+ (score.clips ?? []).length === 0 &&
1755
+ !(score.layers ?? []).some((layer) => layer.notes.length > 0),
1756
+ },
1757
+ now,
1758
+ );
1759
+ if (!wrapped) return;
1760
+ this.activity.pushCard(FIRST_LOOP_CARD, { tone: "info", once: true });
1761
+ this.onFirstLoop?.();
1762
+ }
1763
+
1385
1764
  /** Decode one key sequence (from TerminalInputDecoder) into an input. */
1386
1765
  input(value: string | { type: "paste"; text: string }): AppInput {
1387
1766
  if (typeof value !== "string")
@@ -1485,7 +1864,7 @@ export class TuiApp {
1485
1864
  }
1486
1865
  if (this.overlay === "text" && this.text) {
1487
1866
  const nav = overlayKey(value);
1488
- const page = Math.max(1, this.io.rows() - 10);
1867
+ const page = this.overlayRows();
1489
1868
  if (nav && nav !== "enter") {
1490
1869
  const step =
1491
1870
  nav === "down"
@@ -1497,19 +1876,25 @@ export class TuiApp {
1497
1876
  : nav === "pgup"
1498
1877
  ? -page
1499
1878
  : 0;
1500
- const total = this.text.lines.length;
1501
- this.text.scroll =
1879
+ // Clamp to the last full page, the same bound paintText uses, so
1880
+ // End then ↑ moves the view on the first press.
1881
+ const max = Math.max(0, this.text.lines.length - page);
1882
+ const from = Math.min(max, this.text.scroll);
1883
+ const next =
1502
1884
  nav === "home"
1503
1885
  ? 0
1504
1886
  : nav === "end"
1505
- ? total
1506
- : Math.max(0, Math.min(total, this.text.scroll + step));
1887
+ ? max
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;
1507
1892
  return { type: "overlay" };
1508
1893
  }
1509
1894
  }
1510
1895
  if (this.overlay === "log") {
1511
1896
  const nav = overlayKey(value);
1512
- const page = Math.max(1, this.io.rows() - 10);
1897
+ const page = this.overlayRows();
1513
1898
  if (nav && nav !== "enter") {
1514
1899
  const step =
1515
1900
  nav === "up"
@@ -1525,12 +1910,15 @@ export class TuiApp {
1525
1910
  this.activity.transcript,
1526
1911
  this.log.filter,
1527
1912
  ).length;
1913
+ // Scroll counts rows up from the newest; paintOverlay's bound.
1914
+ const max = Math.max(0, total - page);
1915
+ const from = Math.min(max, this.log.scroll);
1528
1916
  this.log.scroll =
1529
1917
  nav === "home"
1530
- ? total
1918
+ ? max
1531
1919
  : nav === "end"
1532
1920
  ? 0
1533
- : Math.max(0, Math.min(total, this.log.scroll + step));
1921
+ : Math.max(0, Math.min(max, from + step));
1534
1922
  return { type: "overlay" };
1535
1923
  }
1536
1924
  if (key.type === "text" && key.text === "/") {
@@ -1624,6 +2012,19 @@ export class TuiApp {
1624
2012
  if (this.overlay === "picker") this.overlay = undefined;
1625
2013
  }
1626
2014
 
2015
+ /**
2016
+ * Rows of text a panel over the highway shows (its height less the two
2017
+ * borders), as last rendered; a guess from the terminal height before the
2018
+ * first frame.
2019
+ */
2020
+ private overlayRows(): number {
2021
+ const highway = this.lastFrame?.layout.highway.height;
2022
+ return Math.max(
2023
+ 1,
2024
+ highway !== undefined && highway > 2 ? highway - 2 : this.io.rows() - 10,
2025
+ );
2026
+ }
2027
+
1627
2028
  /** Show static lines over the highway (`/help`, lists); replaces any overlay. */
1628
2029
  openText(title: string, lines: readonly string[]): void {
1629
2030
  this.text = { title, lines: lines.slice(0, 512), scroll: 0 };
@@ -1681,9 +2082,10 @@ export class TuiApp {
1681
2082
 
1682
2083
  /**
1683
2084
  * Handle TUI-local slash commands (`/log`, `/theme`, `/motion`, `/guide`).
1684
- * Returns a receipt, or undefined when the command is not a UI command.
2085
+ * Returns a receipt (a structured one for a refusal), or undefined when
2086
+ * the command is not a UI command.
1685
2087
  */
1686
- command(text: string): string | undefined {
2088
+ command(text: string): string | Receipt | undefined {
1687
2089
  const command = text.trim();
1688
2090
  if (/^\/(log|transcript)$/i.test(command)) {
1689
2091
  this.overlay = this.overlay === "log" ? undefined : "log";
@@ -1708,7 +2110,12 @@ export class TuiApp {
1708
2110
  if (guide) {
1709
2111
  const topic = guide[1]?.trim();
1710
2112
  if (!this.openGuide(topic))
1711
- return `no guide named ${topic} · /guide lists them all`;
2113
+ return fail(
2114
+ topicMiss(
2115
+ topic!,
2116
+ (this.guide?.guides ?? listGuides()).map((g) => g.id),
2117
+ ),
2118
+ );
1712
2119
  return topic
1713
2120
  ? `guide · ${this.guide?.guides.find((g) => g.id === this.guide?.page)?.title ?? topic} · esc back`
1714
2121
  : "guides · → open · esc closes";