@hraness/dawg 0.6.1 → 0.7.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 (253) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/DAWG.md +343 -116
  3. package/README.md +27 -25
  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 +182 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/instruments.ts +19 -0
  11. package/core/keys.ts +3 -3
  12. package/core/loop.ts +5 -0
  13. package/core/lyrics.ts +297 -0
  14. package/core/master.ts +3 -3
  15. package/core/resonators.ts +16 -2
  16. package/core/routing.ts +165 -0
  17. package/core/score.ts +832 -13
  18. package/core/sdk/eval-child.ts +7 -2
  19. package/core/sdk/eval.ts +35 -6
  20. package/core/sdk/print.ts +276 -3
  21. package/core/sdk/sync-lyrics.ts +49 -0
  22. package/core/sdk/v1.ts +1849 -42
  23. package/core/sections.ts +292 -26
  24. package/core/sing.ts +815 -0
  25. package/core/style-provenance.ts +80 -0
  26. package/core/styles/africa-mena-southasia.ts +2893 -0
  27. package/core/styles/americas.ts +3810 -0
  28. package/core/styles/art.ts +4993 -0
  29. package/core/styles/base.ts +123 -0
  30. package/core/styles/cycles.ts +106 -0
  31. package/core/styles/electronic.ts +2723 -0
  32. package/core/styles/europe-asia-pacific.ts +2838 -0
  33. package/core/styles/excerpt.ts +29 -0
  34. package/core/styles/gamelan.ts +283 -0
  35. package/core/styles/generate.ts +2199 -0
  36. package/core/styles/index.ts +515 -0
  37. package/core/styles/parts.ts +106 -0
  38. package/core/styles/pop.ts +3189 -0
  39. package/core/styles/rock.ts +2993 -0
  40. package/core/styles/roots.ts +4175 -0
  41. package/core/styles/schema.ts +429 -0
  42. package/core/styles/taxonomy.ts +940 -0
  43. package/core/styles/validate.ts +528 -0
  44. package/core/tempo.ts +32 -2
  45. package/core/tuning.ts +19 -3
  46. package/core/vocoder.ts +524 -0
  47. package/guides/agent.md +29 -0
  48. package/guides/arrange.md +30 -0
  49. package/guides/audition.md +20 -14
  50. package/guides/automation.md +12 -7
  51. package/guides/chords.md +15 -15
  52. package/guides/effects.md +17 -16
  53. package/guides/faders.md +20 -16
  54. package/guides/files.md +13 -9
  55. package/guides/getting-started.md +15 -11
  56. package/guides/keys.md +19 -15
  57. package/guides/media.md +17 -12
  58. package/guides/mix.md +12 -6
  59. package/guides/music.md +23 -8
  60. package/guides/notes.md +15 -9
  61. package/guides/performance.md +15 -13
  62. package/guides/play.md +21 -13
  63. package/guides/project.md +25 -8
  64. package/guides/providers.md +20 -14
  65. package/guides/resample.md +15 -9
  66. package/guides/rhythm.md +17 -13
  67. package/guides/sessions.md +17 -7
  68. package/guides/show-me.md +31 -0
  69. package/guides/sound.md +25 -9
  70. package/guides/sounds.md +15 -13
  71. package/guides/styles.md +31 -0
  72. package/guides/tempo.md +15 -10
  73. package/guides/tracks.md +15 -10
  74. package/guides/tuning.md +32 -0
  75. package/guides/voice.md +31 -0
  76. package/guides/web-search.md +18 -8
  77. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  78. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  79. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  80. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  81. package/native/prebuilt/manifest.json +21 -0
  82. package/package.json +5 -2
  83. package/src/agent/agent.ts +126 -14
  84. package/src/agent/calibration-tools.ts +53 -0
  85. package/src/agent/clip-tools.ts +453 -0
  86. package/src/agent/command-agent.ts +369 -0
  87. package/src/agent/drum-tools.ts +2 -2
  88. package/src/agent/expression-tools.ts +1 -1
  89. package/src/agent/gateway.ts +246 -60
  90. package/src/agent/models.ts +53 -12
  91. package/src/agent/ops.ts +12 -1
  92. package/src/agent/pack-tools.ts +1 -1
  93. package/src/agent/planner.ts +13 -0
  94. package/src/agent/portable-schema.ts +80 -0
  95. package/src/agent/preview-tool.ts +4 -1
  96. package/src/agent/provider.ts +22 -8
  97. package/src/agent/rhythm-tools.ts +1 -1
  98. package/src/agent/section-tools.ts +1 -1
  99. package/src/agent/show-me.ts +497 -0
  100. package/src/agent/steer.ts +15 -0
  101. package/src/agent/style-tools.ts +217 -0
  102. package/src/agent/tool-error.ts +12 -0
  103. package/src/agent/tools.ts +108 -23
  104. package/src/agent/usage.ts +2 -2
  105. package/src/agent/voice-tools.ts +925 -0
  106. package/src/agent/xcb-agent.ts +11 -7
  107. package/src/argv.ts +38 -0
  108. package/src/audio/analysis.ts +253 -0
  109. package/src/audio/arrange.ts +37 -3
  110. package/src/audio/autotune-engine.ts +101 -0
  111. package/src/audio/autotune.ts +640 -0
  112. package/src/audio/clips.ts +240 -0
  113. package/src/audio/doctor.ts +86 -0
  114. package/src/audio/dsp/bandbank.ts +138 -0
  115. package/src/audio/dsp/envelope.ts +10 -0
  116. package/src/audio/dsp/follow.ts +120 -0
  117. package/src/audio/dsp/formant.ts +427 -0
  118. package/src/audio/dsp/glottal.ts +243 -0
  119. package/src/audio/dsp/interp.ts +7 -2
  120. package/src/audio/dsp/lpc.ts +50 -0
  121. package/src/audio/dsp/periodicity.ts +59 -0
  122. package/src/audio/dsp/pitch.ts +995 -0
  123. package/src/audio/dsp/psola.ts +199 -0
  124. package/src/audio/effects/chain.ts +3 -1
  125. package/src/audio/effects/common.ts +43 -0
  126. package/src/audio/effects/convolution.ts +7 -4
  127. package/src/audio/effects/filter.ts +48 -69
  128. package/src/audio/effects/formant.ts +263 -0
  129. package/src/audio/engine.ts +160 -28
  130. package/src/audio/fit.ts +35 -3
  131. package/src/audio/instrument-check.ts +59 -43
  132. package/src/audio/instruments.ts +4 -0
  133. package/src/audio/keys/calibration.ts +56 -0
  134. package/src/audio/keys/electric.ts +8 -1
  135. package/src/audio/keys/engine.ts +13 -1
  136. package/src/audio/keys/piano.ts +22 -2
  137. package/src/audio/kits.ts +135 -6
  138. package/src/audio/live.ts +114 -20
  139. package/src/audio/native.ts +615 -0
  140. package/src/audio/preview.ts +30 -2
  141. package/src/audio/render-worker.ts +2 -0
  142. package/src/audio/renderer.ts +2 -0
  143. package/src/audio/resample.ts +2 -1
  144. package/src/audio/sampler.ts +85 -4
  145. package/src/audio/samples.ts +20 -2
  146. package/src/audio/sing/analysis.ts +193 -0
  147. package/src/audio/sing/engine.ts +949 -0
  148. package/src/audio/strings/bow.ts +48 -5
  149. package/src/audio/strings/engine.ts +8 -2
  150. package/src/audio/synth/oscillators.ts +31 -21
  151. package/src/audio/synth/voice.ts +34 -1
  152. package/src/audio/vocoder/bank.ts +314 -0
  153. package/src/audio/vocoder/carrier.ts +165 -0
  154. package/src/audio/vocoder/control.ts +68 -0
  155. package/src/audio/vocoder/detect.ts +50 -0
  156. package/src/audio/vocoder/index.ts +304 -0
  157. package/src/audio/vocoder/talkbox.ts +143 -0
  158. package/src/audio/wav.ts +500 -77
  159. package/src/audio/winds/engine.ts +5 -1
  160. package/src/audio/winds/trim.ts +28 -4
  161. package/src/audio/winds/trims1.ts +297 -0
  162. package/src/audio/winds/voice.ts +15 -2
  163. package/src/auth/cli.ts +38 -36
  164. package/src/auth/credentials.ts +30 -1
  165. package/src/auth/login.ts +15 -9
  166. package/src/auth/tui.ts +19 -10
  167. package/src/commands/arrange.ts +44 -29
  168. package/src/commands/autotune.ts +421 -0
  169. package/src/commands/calibration.ts +74 -0
  170. package/src/commands/clips.ts +887 -0
  171. package/src/commands/drums.ts +3 -2
  172. package/src/commands/edit.ts +11 -4
  173. package/src/commands/expression.ts +1 -1
  174. package/src/commands/formant.ts +221 -0
  175. package/src/commands/fx.ts +101 -32
  176. package/src/commands/grammar.ts +558 -0
  177. package/src/commands/help.ts +610 -380
  178. package/src/commands/history.ts +18 -0
  179. package/src/commands/keys.ts +8 -8
  180. package/src/commands/modal.ts +1 -1
  181. package/src/commands/music.ts +1 -1
  182. package/src/commands/nearest.ts +53 -0
  183. package/src/commands/pack.ts +9 -2
  184. package/src/commands/param-range.ts +56 -0
  185. package/src/commands/parses.ts +130 -0
  186. package/src/commands/progression.ts +170 -0
  187. package/src/commands/rhythm.ts +3 -0
  188. package/src/commands/rig.ts +3 -24
  189. package/src/commands/sing.ts +478 -0
  190. package/src/commands/strum.ts +13 -1
  191. package/src/commands/style.ts +415 -0
  192. package/src/commands/time.ts +6 -3
  193. package/src/commands/tuning.ts +3 -3
  194. package/src/commands/vocal-pitch.ts +616 -0
  195. package/src/commands/vocal.ts +147 -0
  196. package/src/commands/vocoder.ts +627 -0
  197. package/src/commands/wind.ts +2 -2
  198. package/src/fs/durable.ts +50 -0
  199. package/src/lang/glossary.ts +493 -0
  200. package/src/launch-args.ts +163 -0
  201. package/src/main.ts +1397 -253
  202. package/src/media/cli.ts +20 -3
  203. package/src/media/import.ts +3 -1
  204. package/src/project/check.ts +21 -2
  205. package/src/project/clip-pins.ts +72 -0
  206. package/src/project/init.ts +23 -8
  207. package/src/project/sync.ts +418 -86
  208. package/src/render.ts +20 -1
  209. package/src/session/daemon.ts +3 -0
  210. package/src/session/meta.ts +14 -0
  211. package/src/session/origin.ts +154 -0
  212. package/src/session/port.ts +22 -4
  213. package/src/session/presence.ts +34 -4
  214. package/src/session/protocol.ts +5 -1
  215. package/src/session/rebase.ts +18 -5
  216. package/src/session/receipt.ts +258 -0
  217. package/src/session/store.ts +65 -43
  218. package/src/tui/arrange-menu.ts +65 -31
  219. package/src/tui/audition.ts +1 -1
  220. package/src/tui/euclid.ts +18 -13
  221. package/src/tui/fader.ts +228 -41
  222. package/src/tui/granular-menu.ts +2 -4
  223. package/src/tui/menu-clips.ts +297 -0
  224. package/src/tui/menu-time.ts +20 -13
  225. package/src/tui/menu-voice.ts +405 -0
  226. package/src/tui/menu.ts +759 -177
  227. package/src/tui/modal-menu.ts +6 -6
  228. package/src/tui/performance-menu.ts +5 -2
  229. package/src/tui/play-chords.ts +4 -2
  230. package/src/tui/play-mode.ts +15 -1
  231. package/src/tui/play-session.ts +47 -7
  232. package/src/tui/sing-menu.ts +278 -0
  233. package/src/tui/style-menu.ts +104 -0
  234. package/src/tui/vocoder-menu.ts +244 -0
  235. package/src/tui/wind-menu.ts +3 -3
  236. package/src/version.ts +8 -0
  237. package/src/web/fetch.ts +115 -29
  238. package/tui/activity.ts +180 -9
  239. package/tui/app.ts +274 -40
  240. package/tui/clip-row.ts +132 -0
  241. package/tui/delight.ts +144 -0
  242. package/tui/drawer.ts +70 -22
  243. package/tui/frame-gate.ts +76 -0
  244. package/tui/grammar.ts +112 -63
  245. package/tui/guide.ts +42 -4
  246. package/tui/highway.ts +269 -25
  247. package/tui/hints.ts +192 -0
  248. package/tui/input.ts +60 -9
  249. package/tui/keys.ts +1 -1
  250. package/tui/play-strip.ts +68 -14
  251. package/tui/prompt.ts +1 -1
  252. package/tui/screen.ts +144 -14
  253. package/tui/theme.ts +27 -0
package/src/main.ts CHANGED
@@ -1,11 +1,43 @@
1
1
  #!/usr/bin/env bun
2
+ import { TOPIC_ALIASES } from "./lang/glossary.ts";
3
+ import { isGuideInstrument, vocalChainPatch } from "../core/clips.ts";
4
+ import { isSingWord } from "../core/sing.ts";
5
+ import { commandParses, parseExact } from "./commands/parses.ts";
6
+ import { paramRangeError } from "./commands/param-range.ts";
7
+ import { noteName as midiNoteName } from "./media/notes.ts";
2
8
  import {
9
+ EVERYDAY_VERBS,
10
+ EXPORT_USAGE,
11
+ elsewhereHint,
12
+ FREE_TEXT_HINTS,
13
+ noTrack,
14
+ stemsReceipt,
15
+ LOOP_USAGE,
16
+ WINDOW_VERBS,
17
+ NO_AGENT,
18
+ RANGES,
19
+ canonicalWindowForm,
20
+ friendlyCoreError,
21
+ knownVerbs,
22
+ nearest,
23
+ noNote,
24
+ parseExportCommand,
25
+ parseLoopCommand,
26
+ recover,
27
+ usageCard,
28
+ usageError,
29
+ verbOf,
30
+ } from "./commands/grammar.ts";
31
+ import {
32
+ instrumentListLines,
3
33
  isUnknownInstrument,
4
34
  plainSineAdvice,
35
+ unknownInstrumentMessage,
5
36
  } from "./audio/instrument-check.ts";
6
37
  import { randomUUID } from "node:crypto";
7
38
  import { readFileSync, writeSync } from "node:fs";
8
- import { readFile, stat, writeFile } from "node:fs/promises";
39
+ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
40
+ import { tmpdir } from "node:os";
9
41
  import { join, relative, resolve } from "node:path";
10
42
  import { stdin, stdout } from "node:process";
11
43
  import {
@@ -14,6 +46,12 @@ import {
14
46
  type SessionRecord,
15
47
  } from "./session/store.ts";
16
48
  import { openSessionPort } from "./session/port.ts";
49
+ import {
50
+ OwnWrites,
51
+ editedTrack,
52
+ foreignEvents,
53
+ otherWindowName,
54
+ } from "./session/origin.ts";
17
55
  import { compositionDigest, monotonicEpochMs } from "./session/protocol.ts";
18
56
  import {
19
57
  formatSessionLine,
@@ -69,6 +107,10 @@ import {
69
107
  rigTrackFields,
70
108
  rigWordPatch,
71
109
  } from "./commands/rig.ts";
110
+ import {
111
+ applyProgressionCommand,
112
+ parseProgressionCommand,
113
+ } from "./commands/progression.ts";
72
114
  import {
73
115
  applyGuitarCommand,
74
116
  applyStrumCommand,
@@ -85,6 +127,11 @@ import {
85
127
  parseWindCommand,
86
128
  windListLines,
87
129
  } from "./commands/wind.ts";
130
+ import {
131
+ applySingCommand,
132
+ parseSingCommand,
133
+ singListLines,
134
+ } from "./commands/sing.ts";
88
135
  import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
89
136
  import { applyStringCommand, parseStringCommand } from "./commands/string.ts";
90
137
  import { instrumentPatchForWord, isModalWord } from "../core/resonators.ts";
@@ -105,26 +152,38 @@ import {
105
152
  parseTuningCommand,
106
153
  type TuningCommand,
107
154
  } from "./commands/tuning.ts";
155
+ import {
156
+ applyCalibrationCommand,
157
+ parseCalibrationCommand,
158
+ } from "./commands/calibration.ts";
108
159
  import { TuningError, displayTag, resolveTuning } from "../core/tuning.ts";
109
160
  import {
110
161
  applyMasterCommand,
111
162
  measurementLine,
112
163
  parseMasterCommand,
113
164
  } from "./commands/master.ts";
165
+ import { applyStyleCommand, parseStyleCommand } from "./commands/style.ts";
114
166
  import { exportSampleRate, measureScoreOffThread } from "./audio/measure.ts";
115
167
  import {
116
168
  applySectionCommand,
169
+ loopSpan,
117
170
  parseSectionCommand,
118
171
  } from "./commands/arrange.ts";
119
172
  import {
120
- HELP_TOPICS,
173
+ helpMiss,
121
174
  helpText,
175
+ helpTitle,
122
176
  helpTopicLines,
123
177
  nearestCommand,
124
178
  typoFix,
125
179
  usageHint,
126
180
  } from "./commands/help.ts";
127
- import { historyTarget, REDO_KIND, UNDO_KIND } from "./commands/history.ts";
181
+ import {
182
+ historyReceipt,
183
+ historyTarget,
184
+ REDO_KIND,
185
+ UNDO_KIND,
186
+ } from "./commands/history.ts";
128
187
 
129
188
  import {
130
189
  SamplePlacementError,
@@ -139,6 +198,30 @@ import {
139
198
  } from "./commands/sample.ts";
140
199
  import { applyFitCommand, fitVoice, parseFitCommand } from "./commands/fit.ts";
141
200
  import { applyShiftCommand, parseShiftCommand } from "./commands/shift.ts";
201
+ import { parseVocalCommand, runVocalCommand } from "./commands/vocal.ts";
202
+ import {
203
+ applyFormantCommand,
204
+ parseFormantCommand,
205
+ parseVowelCommand,
206
+ } from "./commands/formant.ts";
207
+ import {
208
+ applyLyrics,
209
+ parseClipCommand,
210
+ parseLyricsCommand,
211
+ runClipCommand,
212
+ setClipImportDeps,
213
+ } from "./commands/clips.ts";
214
+ import { pitchTraceFor } from "./commands/vocal-pitch.ts";
215
+ import {
216
+ applyVocoderCommand,
217
+ parseVocoderCommand,
218
+ vocoderListLines,
219
+ } from "./commands/vocoder.ts";
220
+ import {
221
+ applyAutotuneCommand,
222
+ autotuneListLines,
223
+ parseAutotuneCommand,
224
+ } from "./commands/autotune.ts";
142
225
  import { parseResampleCommand, runResample } from "./commands/resample.ts";
143
226
  import { suggestFitMode } from "./audio/dsp/onset.ts";
144
227
  import {
@@ -148,7 +231,14 @@ import {
148
231
  type SampleBank,
149
232
  type SampleProblem,
150
233
  } from "./audio/samples.ts";
234
+ import {
235
+ ANALYSIS_CACHE_BYTES,
236
+ analysisCacheStatus,
237
+ analysisDir,
238
+ pruneAnalysisCache,
239
+ } from "./audio/analysis.ts";
151
240
  import { drumSnapshotFields, samplerSnapshotFields } from "../tui/drums.ts";
241
+ import { clipSnapshots, loadClipPeaks } from "../tui/clip-row.ts";
152
242
  import { highwayLayers } from "../tui/layers.ts";
153
243
  import { drumVoicePitch, isDrumInstrument } from "../core/drums.ts";
154
244
  import {
@@ -157,6 +247,7 @@ import {
157
247
  type AgentEvent,
158
248
  type AgentHost,
159
249
  } from "./agent/agent.ts";
250
+ import { routeDuringTurn } from "./agent/steer.ts";
160
251
  import {
161
252
  isApiSelection,
162
253
  providerFingerprint,
@@ -175,8 +266,24 @@ import {
175
266
  spendLine as formatSpendLine,
176
267
  webHostFor,
177
268
  } from "./agent/usage.ts";
178
- import { configDir } from "./auth/credentials.ts";
179
- import { runAuthCommand, runFirstRunLogin, runTuiLogin } from "./auth/cli.ts";
269
+ import { configDir, readConfig, writeConfig } from "./auth/credentials.ts";
270
+ import type { CommandOutcome } from "./agent/command-agent.ts";
271
+ import {
272
+ GLIDE_STEP_MS,
273
+ NoteScheduler,
274
+ finishHint,
275
+ gestureFor,
276
+ glideValues,
277
+ agentPathsAllowed,
278
+ isAgentCommand,
279
+ isBrokenCommand,
280
+ brokenCommandReceipt,
281
+ parseShowMe,
282
+ toolCaption,
283
+ type ShowMeLevel,
284
+ } from "./agent/show-me.ts";
285
+ import { firstRunCard, runAuthCommand, runTuiLogin } from "./auth/cli.ts";
286
+ import { musicalReceipt } from "./session/receipt.ts";
180
287
  import {
181
288
  modelPickerItems,
182
289
  tuiAuthCommand,
@@ -192,10 +299,16 @@ import {
192
299
  PlaySession,
193
300
  type LiveEngine,
194
301
  } from "./tui/play-session.ts";
195
- import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
302
+ import {
303
+ EditMenu,
304
+ menuUsage,
305
+ menuSectionPath,
306
+ type MenuContext,
307
+ } from "./tui/menu.ts";
196
308
  import {
197
309
  drawerView,
198
310
  faderChoose,
311
+ faderCommand,
199
312
  faderKeyPress,
200
313
  faderSetPosition,
201
314
  faderStep,
@@ -217,7 +330,15 @@ import {
217
330
  isStageable,
218
331
  } from "./tui/audition.ts";
219
332
  import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
220
- import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
333
+ import {
334
+ ARROW_DOWN,
335
+ ARROW_UP,
336
+ closesKeys,
337
+ HINTS,
338
+ KEYS,
339
+ keyLines,
340
+ type KeySection,
341
+ } from "../tui/grammar.ts";
221
342
  import { renderAudition } from "./audio/audition.ts";
222
343
  import { exportScore, playbackTime, scoreBeatAt } from "./audio/arrange.ts";
223
344
  import { formatForm } from "../core/sections.ts";
@@ -265,6 +386,7 @@ import {
265
386
  applyScoreOperation,
266
387
  createScore,
267
388
  isSamplerInstrument,
389
+ isTrackAudible,
268
390
  PACK_PREFIX,
269
391
  scoreFromJSON,
270
392
  SCORE_LIMITS,
@@ -277,8 +399,19 @@ import { decodeLoop, encodeLoop } from "../core/loop.ts";
277
399
  import { scoreToMidi } from "../core/midi.ts";
278
400
  import type { TrackScoreSnapshot } from "../tui/render.ts";
279
401
  import { PromptModel } from "../tui/prompt.ts";
280
- import { TerminalInputDecoder } from "../tui/input.ts";
281
402
  import {
403
+ ESCAPE_FLUSH_MS,
404
+ INPUT_FLUSH,
405
+ PASTE_FLUSH_MS,
406
+ TerminalInputDecoder,
407
+ } from "../tui/input.ts";
408
+ import {
409
+ FrameGate,
410
+ IDLE_HEARTBEAT_MS,
411
+ MIN_FRAME_GAP_MS,
412
+ } from "../tui/frame-gate.ts";
413
+ import {
414
+ CARD_GLOW_MS,
282
415
  composeFrame,
283
416
  TuiApp,
284
417
  type AppView,
@@ -286,13 +419,19 @@ import {
286
419
  type SyncState,
287
420
  type TypesIndicator,
288
421
  } from "../tui/app.ts";
289
- import { fail, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
422
+ import { fail, note, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
290
423
  import { systemRunner } from "./auth/runner.ts";
291
424
  import type { MediaServices } from "./media/types.ts";
292
425
  import { encodeBuffer } from "../tui/screen.ts";
293
426
  import { parseThemeName } from "../tui/theme.ts";
294
427
  import { formatDiagnostic } from "../core/sdk/eval.ts";
295
428
  import { isProject } from "./project/init.ts";
429
+ import {
430
+ parseLaunchArgs,
431
+ parseSimpleArgv,
432
+ resolveTrackArg,
433
+ } from "./launch-args.ts";
434
+ import { RENDER_USAGE, runRenderCommand } from "./render.ts";
296
435
  import { typecheckProject } from "./project/typecheck.ts";
297
436
  import {
298
437
  startProjectSync,
@@ -300,112 +439,100 @@ import {
300
439
  type SyncHost,
301
440
  } from "./project/sync.ts";
302
441
 
442
+ /** Set while `submit` runs a grammar reading, so it reads only once. */
443
+ let recovering = false;
444
+
445
+ /** Slash words `submit` handles itself (no parser in commandParses). */
446
+ const SLASH_HANDLED: ReadonlySet<string> = new Set([
447
+ ...WINDOW_VERBS,
448
+ "chords",
449
+ "click",
450
+ "help",
451
+ ]);
452
+
303
453
  /** Whether a bare command parses (no side effects): for typo suggestions. */
304
454
  function parsesLocally(text: string): boolean {
305
- return [
306
- parsePrompt,
307
- parseMusicCommand,
308
- parseEditCommand,
309
- parseRhythmCommand,
310
- parseFxCommand,
311
- parseSynthCommand,
312
- parseStringCommand,
313
- parseGranularCommand,
314
- parseKeysCommand,
315
- parseExpressionCommand,
316
- parseMasterCommand,
317
- (value: string) => parseSectionCommand(value, score),
318
- parsePatternCommand,
319
- parseKitCommand,
320
- parsePackCommand,
321
- parseSampleCommand,
322
- parseFitCommand,
323
- parseShiftCommand,
324
- parseResampleCommand,
325
- parseWavetableCommand,
326
- parseTimeCommand,
327
- parseTuningCommand,
328
- parseRigCommand,
329
- parseModalCommand,
330
- parseGuitarCommand,
331
- parseStrumCommand,
332
- parseWindCommand,
333
- ].some((parse) => parse(text) !== undefined);
455
+ return commandParses(text, score);
334
456
  }
335
457
 
336
458
  const ESC = "\u001b[";
337
459
  /** `/sessions` rows shown in the overlay. */
338
460
  const MAX_LISTED_SESSIONS = 64;
339
461
 
340
- const SUBCOMMANDS = [
341
- "login",
342
- "logout",
343
- "auth",
344
- "sessions",
345
- "render",
346
- "init",
347
- "check",
348
- ];
349
- const VALUE_FLAGS = ["--session", "--track", "--import", "--export", "--theme"];
350
- const FLAGS = [
351
- ...VALUE_FLAGS,
352
- "--new",
353
- "--demo",
354
- "--help",
355
- "-h",
356
- "--version",
357
- "-v",
358
- "--reduce-motion",
359
- "--no-mouse",
360
- ];
462
+ /** Wraps a usage line at spaces before `width`, indenting continuations. */
463
+ function wrapUsage(text: string, width: number, indent: string): string {
464
+ const lines: string[] = [];
465
+ let line = "";
466
+ for (const word of text.split(" ")) {
467
+ const prefix = lines.length === 0 ? " " : indent;
468
+ if (line && prefix.length + line.length + 1 + word.length > width) {
469
+ lines.push(line);
470
+ line = word;
471
+ } else line = line ? `${line} ${word}` : word;
472
+ }
473
+ lines.push(line);
474
+ return lines.join(`\n${indent}`);
475
+ }
361
476
  const HELP_TEXT = `dawg · local-first terminal music workstation
362
477
 
363
478
  Usage:
364
479
  dawg [--new] [--session <name|id>] [--track <name>]
365
- dawg --import <file> --export <file>
480
+ dawg --import <file> --export <file> convert a loop file (no session)
366
481
  dawg sessions
367
- dawg render <out.wav|out.mid> [--session <name|id>] [--import <file>]
368
- [--normalize <lufs|streaming|club|loud|…>] [--measure]
369
- dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
482
+ ${wrapUsage(RENDER_USAGE.replace(/^usage: /, ""), 76, " ")}
483
+ dawg init [dir] song.ts, tracks/<slug>/track.ts and .dawg/sdk
370
484
  dawg check typecheck + evaluate the project; exit 1 on problems
371
- dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
485
+ dawg <command> --help usage: sessions render init check media model
486
+ dawg doctor audio backend, native sink and devices
487
+ dawg media doctor|download|stems|analyze|notes|sample|lyrics (--help)
372
488
  dawg --version
373
489
 
374
- Usage flags:
490
+ Display options:
375
491
  --reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
376
492
  --theme <name> default | high-contrast | mono (NO_COLOR forces mono)
377
493
  --no-mouse keys only; no click/wheel reporting (also DAWG_MOUSE=0)
378
494
 
379
495
  Prompt:
380
- Enter submit · Shift-Enter newline · Alt-Enter queue · Ctrl-Q toggle queue
381
- Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc cancel/close · Ctrl-C exit
496
+ Enter submit · Shift-Enter newline · Alt-Enter next · Ctrl-Q now / next
497
+ Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc close · Ctrl-C exit
382
498
  Space on an empty prompt toggles playback
383
499
 
384
500
  Commands (bare music words; app commands take a slash):
385
501
  ${helpText()}
386
502
 
387
- Sign in (the choice is saved and reused until you log out):
388
- dawg login find existing setups and pick a provider
389
- dawg login gateway|openrouter|codex|claude
390
- dawg model [alias] pick a model, with the estimated cost per prompt
503
+ Agent (optional; the choice is saved and reused until you log out):
504
+ dawg model key find existing setups and pick a provider
505
+ dawg model key gateway|openrouter|codex|claude
506
+ dawg model [alias] pick a model, with the cost per prompt
391
507
  dawg logout [provider] · dawg auth status [--check]
392
508
  Unrecognized requests go to the agent once a provider is configured
393
509
  (DAWG_PROVIDER=gateway|openrouter|codex|claude|auto, DAWG_MODEL=<alias>;
394
510
  DAWG_AI=0 disables the agent).`;
395
511
  const args = new Set(process.argv.slice(2));
396
- const requestedSession = optionValue("--session");
397
- const explicitTrack = optionValue("--track");
512
+ /** One parse for every launch flag; problems are reported below. */
513
+ const launch = parseLaunchArgs(process.argv.slice(2));
514
+ const launchArgs = launch.ok ? launch.args : undefined;
515
+ const requestedSession = launchArgs?.session;
516
+ /** `--track` normalized as `/track` does; matched to an existing track below. */
517
+ let explicitTrack = launchArgs?.track?.id;
398
518
  /** The focused track; claimed at startup unless `--track` is given. */
399
519
  let requestedTrack = explicitTrack ?? "main";
400
520
  const initialInstrument = isDrumInstrument(requestedTrack) ? "kit" : "sine";
401
- const importPath = optionValue("--import");
402
- const exportPath = optionValue("--export");
521
+ const importPath = launchArgs?.importPath;
522
+ const exportPath = launchArgs?.exportPath;
523
+ // `dawg model key [provider]` is the canonical spelling of `dawg login`.
524
+ if (process.argv[2] === "model" && process.argv[3] === "key")
525
+ process.exit(await runAuthCommand(["login", ...process.argv.slice(4)]));
403
526
  if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
404
527
  process.exit(await runAuthCommand(process.argv.slice(2)));
405
528
  {
406
529
  // An unknown DAWG_MODEL is an error, never a silent fallback.
407
530
  const fromEnv = process.env.DAWG_MODEL?.trim();
408
- if (fromEnv && !resolveModelChoice("gateway", fromEnv)) {
531
+ if (
532
+ fromEnv &&
533
+ !resolveModelChoice("gateway", fromEnv) &&
534
+ !resolveModelChoice("openrouter", fromEnv)
535
+ ) {
409
536
  process.stderr.write(
410
537
  `dawg: unknown DAWG_MODEL "${fromEnv.slice(0, 40)}"; use one of ${MODEL_CATALOG.map((row) => row.alias).join(", ")} or a vendor/model ID\n`,
411
538
  );
@@ -413,10 +540,14 @@ if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
413
540
  }
414
541
  }
415
542
  if (process.argv[2] === "sessions") {
416
- if (args.has("--help") || args.has("-h"))
417
- stdout.write(
418
- "usage: dawg sessions · lists this workspace's sessions, newest first (* marks the current one)\n",
419
- );
543
+ const usage =
544
+ "usage: dawg sessions · lists this workspace's sessions, newest first (* marks the current one)";
545
+ const parsed = parseSimpleArgv(process.argv.slice(3), 0);
546
+ if (parsed.kind === "error") {
547
+ process.stderr.write(`${parsed.problem} · ${usage}\n`);
548
+ process.exit(2);
549
+ }
550
+ if (parsed.kind === "help") stdout.write(`${usage}\n`);
420
551
  else await printSessions(process.cwd(), stdout);
421
552
  process.exit(0);
422
553
  }
@@ -429,6 +560,10 @@ if (process.argv[2] === "init" || process.argv[2] === "check") {
429
560
  await command(process.argv.slice(2), process.cwd(), stdout, process.stderr),
430
561
  );
431
562
  }
563
+ if (process.argv[2] === "doctor") {
564
+ const { runDoctorCommand } = await import("./audio/doctor.ts");
565
+ process.exit(await runDoctorCommand(process.argv.slice(3), stdout));
566
+ }
432
567
  if (process.argv[2] === "media") {
433
568
  const { runMediaCommand } = await import("./media/cli.ts");
434
569
  process.exit(
@@ -461,18 +596,37 @@ if (args.has("--help") || args.has("-h")) {
461
596
  }
462
597
  // Argument mistakes are rejected here, before `.dawg/` could be created.
463
598
  {
464
- const problem = argumentProblem(process.argv.slice(2));
465
- if (problem) {
466
- process.stderr.write(`${problem} · dawg --help\n`);
599
+ if (!launch.ok) {
600
+ process.stderr.write(`${launch.problem} · dawg --help\n`);
467
601
  process.exit(2);
468
602
  }
469
603
  }
604
+ // `--import X --export Y` converts one loop file to another: no session.
605
+ if (importPath && exportPath) {
606
+ try {
607
+ const converted = decodeLoop(await readLoopFile(importPath));
608
+ await writeFile(resolve(exportPath), encodeLoop(converted), "utf8");
609
+ } catch (error) {
610
+ const reason = error instanceof Error ? error.message : String(error);
611
+ const midi = /\.midi?$/i.test(importPath)
612
+ ? " · MIDI files are not imported; --import takes a .track.json loop"
613
+ : "";
614
+ process.stderr.write(
615
+ `dawg: cannot import ${importPath} · ${reason.split("\n")[0]!.slice(0, 160)}${midi}\n`,
616
+ );
617
+ process.exit(1);
618
+ }
619
+ stdout.write(`converted ${importPath} → ${exportPath}\n`);
620
+ process.exit(0);
621
+ }
470
622
  const demo =
471
623
  args.has("--demo") || process.env.DAWG_DEMO === "1" || !stdin.isTTY;
472
- // First run with no provider (or a saved one that stopped working): the
473
- // sign-in picker, in the shell, before the TUI takes the screen.
474
- if (!demo && process.env.DAWG_AI !== "0" && stdout.isTTY)
475
- await runFirstRunLogin();
624
+ // Music first: the TUI opens directly. The first session with no provider
625
+ // (or a saved one that stopped working) gets one card instead of a picker.
626
+ const launchCard =
627
+ !demo && process.env.DAWG_AI !== "0" && stdout.isTTY
628
+ ? firstRunCard().catch(() => undefined)
629
+ : undefined;
476
630
 
477
631
  const initial = createScore({
478
632
  tracks: [
@@ -521,7 +675,16 @@ if (importPath) {
521
675
  process.exit(1);
522
676
  }
523
677
  }
524
- const session = await ensureSession(initial.toJSON(), sessionOptions);
678
+ // A demo frame only renders: with no workspace yet (and nothing imported),
679
+ // it runs on a throwaway session so the cwd gets no `.dawg/`.
680
+ const ephemeralWorkspace =
681
+ demo && freshWorkspace && !importPath && selectedSession === undefined
682
+ ? await mkdtemp(join(tmpdir(), "dawg-demo-"))
683
+ : undefined;
684
+ const session = await ensureSession(initial.toJSON(), {
685
+ ...sessionOptions,
686
+ ...(ephemeralWorkspace ? { workspace: ephemeralWorkspace } : {}),
687
+ });
525
688
  // dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
526
689
  let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
527
690
  paths: session.paths,
@@ -533,6 +696,12 @@ let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
533
696
  let record: SessionRecord<ReturnType<TrackScore["toJSON"]>> =
534
697
  port.mode === "daemon" ? await port.load() : session.record;
535
698
  let score = scoreFromJSON(record.composition);
699
+ if (importPath && importedScore) score = importedScore;
700
+ // `--track Bass` focuses an existing `bass` (or a track named "Bass").
701
+ if (launchArgs?.track) {
702
+ explicitTrack = resolveTrackArg(score.tracks, launchArgs.track);
703
+ requestedTrack = explicitTrack;
704
+ }
536
705
  if (importPath && importedScore) {
537
706
  score = importedScore;
538
707
  record = await port.append(
@@ -573,7 +742,7 @@ const tui = new TuiApp({
573
742
  rows: () => stdout.rows ?? 24,
574
743
  },
575
744
  prompt,
576
- theme: parseThemeName(optionValue("--theme") ?? process.env.DAWG_THEME),
745
+ theme: launchArgs?.theme ?? parseThemeName(process.env.DAWG_THEME),
577
746
  reducedMotion:
578
747
  args.has("--reduce-motion") || process.env.DAWG_REDUCE_MOTION === "1",
579
748
  });
@@ -630,8 +799,25 @@ let requestFrame: () => void = () => undefined;
630
799
  let runPromptLater: (command: string) => void = () => undefined;
631
800
  /** The fader drawer's focus and typing, while one is open over the menu. */
632
801
  let fader: FaderState | undefined;
633
- const KEY_UP = "\u001b[A";
634
- const KEY_DOWN = "\u001b[B";
802
+ /** Show-me state (see the show-me section below). */
803
+ const showMe: {
804
+ level: ShowMeLevel;
805
+ ghost?: string | undefined;
806
+ caption?: string | undefined;
807
+ commands: string[];
808
+ notes: NoteScheduler;
809
+ clear?: ReturnType<typeof setTimeout> | undefined;
810
+ } = {
811
+ level: parseShowMe(process.env.DAWG_SHOWME ?? "") ?? "on",
812
+ commands: [],
813
+ notes: new NoteScheduler(() => score.tempoBpm),
814
+ };
815
+ if (!process.env.DAWG_SHOWME)
816
+ void readConfig({ dir: configDir() })
817
+ .then((config) => {
818
+ if (config.showMe) showMe.level = config.showMe;
819
+ })
820
+ .catch(() => undefined);
635
821
  /** The fader bar a left-button drag started on. */
636
822
  let dragging: { field: number; left: number; width: number } | undefined;
637
823
  /** The open drawer came from a command, not the menu. */
@@ -664,7 +850,8 @@ let handoff: <T>(flow: () => Promise<T>) => Promise<T> = (flow) => flow();
664
850
  function spendLine(): string {
665
851
  const width = stdout.columns ?? 80;
666
852
  if (!providerName || providerName === "offline")
667
- return width >= 40 ? "no model · dawg login" : "";
853
+ // A state, not a nag: the first session's card already named /login.
854
+ return width >= 40 ? formatSpendLine({ kind: "offline" }) : "";
668
855
  const [model, provider] = providerName.split(" · ");
669
856
  return formatSpendLine(
670
857
  {
@@ -689,6 +876,10 @@ if (demo) {
689
876
  "add C4 at 0 for 1",
690
877
  0,
691
878
  );
879
+ if (ephemeralWorkspace) {
880
+ await port.close().catch(() => undefined);
881
+ await rm(ephemeralWorkspace, { recursive: true, force: true });
882
+ }
692
883
  process.exit(0);
693
884
  }
694
885
 
@@ -699,36 +890,8 @@ if (exportPath) {
699
890
 
700
891
  await runInteractive();
701
892
 
702
- /** Describes an unknown subcommand or option in `argv`, or undefined. */
703
- function argumentProblem(argv: readonly string[]): string | undefined {
704
- if (argv[0] && !argv[0].startsWith("-") && !SUBCOMMANDS.includes(argv[0]))
705
- return `unknown command · ${argv[0]}`;
706
- if (argv[0] && SUBCOMMANDS.includes(argv[0])) return undefined;
707
- for (let index = 0; index < argv.length; index += 1) {
708
- const arg = argv[index]!;
709
- if (VALUE_FLAGS.includes(arg)) {
710
- index += 1;
711
- continue;
712
- }
713
- if (arg.startsWith("-") && !FLAGS.includes(arg))
714
- return `unknown option · ${arg}`;
715
- if (!arg.startsWith("-")) return `unknown command · ${arg}`;
716
- }
717
- return undefined;
718
- }
719
-
720
893
  async function packageVersion(): Promise<string> {
721
- const raw = await readFile(
722
- new URL("../package.json", import.meta.url),
723
- "utf8",
724
- );
725
- return (JSON.parse(raw) as { version?: string }).version ?? "0.0.0";
726
- }
727
-
728
- function optionValue(name: string): string | undefined {
729
- const index = process.argv.indexOf(name);
730
- const value = index >= 0 ? process.argv[index + 1] : undefined;
731
- return value && !value.startsWith("--") ? value : undefined;
894
+ return (await import("./version.ts")).VERSION;
732
895
  }
733
896
 
734
897
  function seedDemo(value: TrackScore, trackId: string): TrackScore {
@@ -818,16 +981,24 @@ function snapshot(
818
981
  muted: focused?.muted,
819
982
  ...(tag?.cents !== undefined ? { cents: tag.cents } : {}),
820
983
  ...(tag?.name !== undefined ? { centsFrom: tag.name } : {}),
984
+ ...(note.lyric !== undefined ? { lyric: note.lyric } : {}),
821
985
  };
822
986
  });
987
+ // 0.7 clips: the clip row; peaks load off the frame path, then redraw.
988
+ const clips = clipSnapshots(value, requestedTrack);
989
+ if (clips)
990
+ void loadClipPeaks(process.cwd(), focused).then((changed) => {
991
+ if (changed) requestFrame();
992
+ });
823
993
  return {
824
994
  notes,
995
+ ...(clips ? { clips } : {}),
825
996
  trackName:
826
997
  value.tracks.find((track) => track.id === requestedTrack)?.name ??
827
998
  requestedTrack,
828
999
  trackId: requestedTrack,
829
1000
  sessionId: record.sessionId,
830
- revision: record.revision,
1001
+ revision: shownRevision(record),
831
1002
  bpm: value.time?.tempo
832
1003
  ? bpmAtTick(value, beat * value.ticksPerBeat)
833
1004
  : value.tempoBpm,
@@ -846,6 +1017,7 @@ function snapshot(
846
1017
  currentBeat: scoreBeatAt(value, beat),
847
1018
  playing: clock.playing,
848
1019
  activity,
1020
+ ...(focused?.instrument === "vocal" ? { vocal: true } : {}),
849
1021
  ...drumSnapshotFields(
850
1022
  value.tracks.find((track) => track.id === requestedTrack)?.instrument,
851
1023
  notes,
@@ -857,6 +1029,7 @@ function snapshot(
857
1029
  ...(table && table.linear && table.size !== 12
858
1030
  ? { tuningPeriod: { size: table.size, root: table.root } }
859
1031
  : {}),
1032
+ pitchTrace: pitchTraceFor(requestedTrack, value),
860
1033
  layers:
861
1034
  tui.highwayView === "all"
862
1035
  ? highwayLayers(
@@ -898,6 +1071,10 @@ function appView(value: TrackScore, beat: number): AppView {
898
1071
  providerName && providerName !== "offline" ? providerName : undefined,
899
1072
  spend: spendLine(),
900
1073
  agentOffline: providerName === "offline" || process.env.DAWG_AI === "0",
1074
+ showMe:
1075
+ showMe.ghost || showMe.caption
1076
+ ? { ghost: showMe.ghost, caption: showMe.caption }
1077
+ : undefined,
901
1078
  sync: syncState,
902
1079
  sessionName: record.meta.name,
903
1080
  windows: windowCount,
@@ -1001,17 +1178,20 @@ function receipt(result: string | Receipt, base?: Baseline): void {
1001
1178
  tui.activity.pushError(message);
1002
1179
  return;
1003
1180
  }
1181
+ // A receipt that names its own undo key needs no second hint.
1004
1182
  const hint =
1005
- scoreChanged && undoHintsShown < MAX_UNDO_HINTS
1183
+ scoreChanged &&
1184
+ undoHintsShown < MAX_UNDO_HINTS &&
1185
+ !message.endsWith("ctrl-z undo")
1006
1186
  ? message.startsWith("undid")
1007
- ? "^y redo"
1008
- : "^z undo"
1187
+ ? "ctrl-y redo"
1188
+ : "ctrl-z undo"
1009
1189
  : undefined;
1010
1190
  if (hint) undoHintsShown += 1;
1011
1191
  tui.activity.pushCard(message, {
1012
1192
  tone,
1013
- baseRevision: changed ? base.revision : undefined,
1014
- resultRevision: changed ? record.revision : undefined,
1193
+ baseRevision: changed ? shownRevision(record, base.revision) : undefined,
1194
+ resultRevision: changed ? shownRevision(record) : undefined,
1015
1195
  hint,
1016
1196
  trackId: requestedTrack,
1017
1197
  });
@@ -1023,6 +1203,10 @@ function describeError(command: string, error: unknown): string {
1023
1203
  const message = error instanceof Error ? error.message : String(error);
1024
1204
  if (detail?.code === "ENOENT" && typeof detail.path === "string")
1025
1205
  return `no such file · ${relative(process.cwd(), detail.path)}`;
1206
+ // `tempo 900` → `tempo 900 · tempo takes 20…300 BPM · tempo 128`: no raw
1207
+ // core keys (`tempoBpm`) on a card.
1208
+ const friendly = friendlyCoreError(truncateForCard(command), message);
1209
+ if (friendly) return friendly;
1026
1210
  const verb = command.trim().split(/\s+/)[0]?.replace(/^\//, "") || "command";
1027
1211
  return `${verb} failed · ${message}`;
1028
1212
  }
@@ -1049,25 +1233,108 @@ function mouseOn(): string {
1049
1233
  return mouseEnabled() ? MOUSE_ON : "";
1050
1234
  }
1051
1235
 
1236
+ /**
1237
+ * Mouse off, plain colors, plain paste, cursor shown, main screen. A
1238
+ * function, not a const: `await runInteractive()` runs above this line.
1239
+ */
1240
+ function terminalRestore(): string {
1241
+ return `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`;
1242
+ }
1243
+
1244
+ /** `dawg: <stack>` for a crash printed after leaving the alternate screen. */
1245
+ function crashText(error: unknown): string {
1246
+ const text =
1247
+ error instanceof Error ? (error.stack ?? error.message) : String(error);
1248
+ return `dawg: ${text}\n`;
1249
+ }
1250
+
1052
1251
  async function runInteractive(): Promise<void> {
1053
- // A crash or a stray process.exit must not leave the terminal reporting
1054
- // mouse events into the shell.
1055
- process.once("exit", () => {
1056
- if (mouseEnabled()) writeSync(1, MOUSE_OFF);
1057
- });
1058
- // Nor may a kill or a closed terminal: restore it, then exit as killed.
1252
+ // Every way out (quit, a failing teardown step, a crash, a kill, a stray
1253
+ // process.exit) restores the whole terminal, once, synchronously: the
1254
+ // shell must not stay on the alternate screen with paste brackets on.
1255
+ let terminalRestored = false;
1256
+ const restoreTerminal = () => {
1257
+ if (terminalRestored) return;
1258
+ terminalRestored = true;
1259
+ try {
1260
+ stdin.setRawMode?.(false);
1261
+ } catch {
1262
+ // stdin may already be closed.
1263
+ }
1264
+ try {
1265
+ writeSync(1, terminalRestore());
1266
+ } catch {
1267
+ // The terminal may already be gone (SIGHUP).
1268
+ }
1269
+ };
1270
+ process.once("exit", restoreTerminal);
1271
+ // A kill or a closed terminal: restore it, then exit as killed.
1059
1272
  for (const [signal, code] of [
1060
1273
  ["SIGTERM", 143],
1061
1274
  ["SIGHUP", 129],
1062
1275
  ] as const)
1063
1276
  process.once(signal, () => {
1277
+ restoreTerminal();
1278
+ process.exit(code);
1279
+ });
1280
+ // An exception nothing caught leaves the editor in an unknown state: leave
1281
+ // the alternate screen first, so the stack lands in the shell, then exit.
1282
+ const onUncaught = (error: unknown) => {
1283
+ restoreTerminal();
1284
+ try {
1285
+ writeSync(2, crashText(error));
1286
+ } catch {
1287
+ // Nowhere left to report it.
1288
+ }
1289
+ process.exit(1);
1290
+ };
1291
+ // A rejected fire-and-forget promise is one failed action, not a broken
1292
+ // editor: report it on the activity strip and repaint over anything the
1293
+ // runtime wrote.
1294
+ const onRejection = (error: unknown) => {
1295
+ if (terminalRestored || screenSuspended) {
1064
1296
  try {
1065
- writeSync(1, `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1297
+ writeSync(2, crashText(error));
1066
1298
  } catch {
1067
- // The terminal may already be gone (SIGHUP).
1299
+ // Nowhere left to report it.
1068
1300
  }
1069
- process.exit(code);
1070
- });
1301
+ return;
1302
+ }
1303
+ tui.activity.pushError(
1304
+ `failed · ${error instanceof Error ? error.message : String(error)}`,
1305
+ );
1306
+ tui.invalidate();
1307
+ requestFrame();
1308
+ };
1309
+ // Runtime warnings and console output would land on top of the frame; the
1310
+ // writer never repaints rows it did not change. Route them to the
1311
+ // transcript instead, and repaint.
1312
+ const onWarning = (warning: Error) => {
1313
+ if (screenSuspended || terminalRestored) return;
1314
+ tui.activity.pushNote(`warning · ${warning.message}`, "error");
1315
+ tui.invalidate();
1316
+ requestFrame();
1317
+ };
1318
+ const consoleError = console.error;
1319
+ const consoleWarn = console.warn;
1320
+ const consoleToTranscript =
1321
+ (original: (...data: unknown[]) => void) =>
1322
+ (...data: unknown[]) => {
1323
+ if (screenSuspended || terminalRestored) return original(...data);
1324
+ tui.activity.pushNote(
1325
+ data
1326
+ .map((item) => (item instanceof Error ? item.message : String(item)))
1327
+ .join(" "),
1328
+ "error",
1329
+ );
1330
+ tui.invalidate();
1331
+ requestFrame();
1332
+ };
1333
+ process.on("uncaughtException", onUncaught);
1334
+ process.on("unhandledRejection", onRejection);
1335
+ process.on("warning", onWarning);
1336
+ console.error = consoleToTranscript(consoleError);
1337
+ console.warn = consoleToTranscript(consoleWarn);
1071
1338
  stdin.setRawMode?.(true);
1072
1339
  stdin.resume();
1073
1340
  // Alternate screen, hidden cursor, bracketed paste.
@@ -1078,7 +1345,8 @@ async function runInteractive(): Promise<void> {
1078
1345
  const runPrompt = async (text: string): Promise<void> => {
1079
1346
  const ui = tui.command(text);
1080
1347
  if (ui !== undefined) {
1081
- tui.activity.pushCard(ui, { tone: "info" });
1348
+ if (typeof ui === "string") tui.activity.pushCard(ui, { tone: "info" });
1349
+ else tui.activity.pushCard(ui.text, { tone: toneOf(ui) });
1082
1350
  return;
1083
1351
  }
1084
1352
  tui.activity.pushRequest(text);
@@ -1102,6 +1370,16 @@ async function runInteractive(): Promise<void> {
1102
1370
  }
1103
1371
  };
1104
1372
  let agentReported = false;
1373
+ /** A typed command during an agent turn; leaves the turn's receipt alone. */
1374
+ const runLocalBesideAgent = async (text: string): Promise<void> => {
1375
+ tui.activity.pushRequest(text);
1376
+ const base = baseline();
1377
+ try {
1378
+ receipt(await submit(text), base);
1379
+ } catch (error) {
1380
+ tui.activity.pushError(describeError(text, error));
1381
+ }
1382
+ };
1105
1383
  const drainQueue = async (): Promise<void> => {
1106
1384
  if (processingQueue) return;
1107
1385
  processingQueue = true;
@@ -1122,17 +1400,64 @@ async function runInteractive(): Promise<void> {
1122
1400
  tui.activity.setQueueDepth(queuedPrompts.length);
1123
1401
  }
1124
1402
  };
1403
+ // Builds a frame only when something can have changed (tui/frame-gate.ts):
1404
+ // an idle editor no longer rebuilds the whole view 30 times a second.
1405
+ const frameGate = new FrameGate(IDLE_HEARTBEAT_MS, MIN_FRAME_GAP_MS);
1406
+ const unwatchActivity = tui.activity.subscribe(() => frameGate.markDirty());
1407
+ const animating = (): boolean => {
1408
+ if (clock.playing || play?.on || auditionLoop?.looping) return true;
1409
+ const activity = tui.activity;
1410
+ if (activity.spinner || activity.streaming) return true;
1411
+ if (tui.delight.animating(Date.now())) return true;
1412
+ const latest = activity.latest;
1413
+ return (
1414
+ !tui.ui.reducedMotion &&
1415
+ latest !== undefined &&
1416
+ Date.now() - latest.atMs < CARD_GLOW_MS
1417
+ );
1418
+ };
1125
1419
  const tick = (force = false) => {
1126
1420
  if (screenSuspended) return;
1421
+ if (
1422
+ !frameGate.shouldBuild({
1423
+ nowMs: Date.now(),
1424
+ force,
1425
+ animating: animating(),
1426
+ keys: [
1427
+ score,
1428
+ record.revision,
1429
+ record.sessionId,
1430
+ record.meta.name,
1431
+ syncState,
1432
+ windowCount,
1433
+ typesIndicator,
1434
+ menu.open,
1435
+ euclid.open,
1436
+ tui.ui.overlay,
1437
+ ],
1438
+ })
1439
+ )
1440
+ return;
1127
1441
  followCommitted();
1442
+ tui.delight.song(record.sessionId, record.meta.heardLoop === true);
1128
1443
  play?.tick();
1129
1444
  // Values in the menu follow the score as edits land.
1130
1445
  if (menu.open) refreshMenu();
1131
1446
  if (euclid.open) refreshEuclid();
1132
1447
  refreshAuditionPicker();
1133
- tui.render(appView(score, clock.beatAt()), { force });
1448
+ // The gate already paces frames; the app's own throttle would drop an
1449
+ // approved frame that lands just after a forced one, and the gate would
1450
+ // not ask again until the heartbeat.
1451
+ tui.render(appView(score, clock.beatAt()), { force: true });
1134
1452
  };
1135
1453
  requestFrame = () => tick(true);
1454
+ // The first wrap is remembered per song in .dawg metadata, never the score.
1455
+ tui.onFirstLoop = () => {
1456
+ void port
1457
+ .updateMeta({ heardLoop: true })
1458
+ .then((result) => adoptMeta(result.meta))
1459
+ .catch(() => undefined);
1460
+ };
1136
1461
  runPromptLater = (command) => {
1137
1462
  queuedPrompts.unshift(command);
1138
1463
  void drainQueue();
@@ -1142,6 +1467,16 @@ async function runInteractive(): Promise<void> {
1142
1467
  };
1143
1468
  agentEventSink = (event) => {
1144
1469
  if (event.type === "usage") return;
1470
+ if (event.type === "command-typing") {
1471
+ showMeTyping(event.text);
1472
+ return;
1473
+ }
1474
+ if (event.type === "command") return;
1475
+ if (event.type === "tool-start") {
1476
+ const caption = toolCaption(event.name);
1477
+ if (caption) showMeCaption(caption);
1478
+ }
1479
+ if (event.type === "done" || event.type === "error") showMeFinish();
1145
1480
  tui.activity.applyAgentEvent(event);
1146
1481
  if (event.type === "done" || event.type === "error") agentReported = true;
1147
1482
  };
@@ -1152,13 +1487,21 @@ async function runInteractive(): Promise<void> {
1152
1487
  tick(true);
1153
1488
  };
1154
1489
  stdout.on("resize", onResize);
1490
+ const ownWrites = new OwnWrites();
1491
+ let presenceClients: readonly {
1492
+ clientId: string;
1493
+ focusedTrackId?: string | null;
1494
+ }[] = [];
1155
1495
  let applying: Promise<void> = Promise.resolve();
1156
1496
  const applyLatest = (latest: typeof record): Promise<void> =>
1157
1497
  (applying = applying.then(() => applyRecord(latest)));
1158
1498
  const applyRecord = async (latest: typeof record): Promise<void> => {
1159
1499
  try {
1500
+ // Queued before a /fork or /resume swapped the session: stale.
1501
+ if (latest.sessionId !== record.sessionId) return;
1160
1502
  if (latest.revision > record.revision) {
1161
1503
  const previousRevision = record.revision;
1504
+ const before = score;
1162
1505
  record = latest;
1163
1506
  if (stageCapture)
1164
1507
  stageCapture.committed = scoreFromJSON(record.composition);
@@ -1195,11 +1538,27 @@ async function runInteractive(): Promise<void> {
1195
1538
  else if (payload.action === "toggle") await setTransport("toggle");
1196
1539
  }
1197
1540
  projectSync?.scoreChanged(score);
1198
- tui.activity.pushCard("synced from another window", {
1199
- tone: "info",
1200
- baseRevision: previousRevision,
1201
- resultRevision: record.revision,
1202
- });
1541
+ // Another window's play/pause is followed, not announced, and this
1542
+ // window's own writes (which can echo back before the append
1543
+ // resolves) are never "synced".
1544
+ const from = shownRevision(record, previousRevision);
1545
+ const to = shownRevision(record);
1546
+ await ownWrites.settled();
1547
+ const foreign = foreignEvents(latest, previousRevision, ownWrites).some(
1548
+ (event) => event.kind !== "transport",
1549
+ );
1550
+ if (to !== from && foreign)
1551
+ tui.activity.pushCard(
1552
+ `synced · ${otherWindowName(presenceClients, port.clientId, {
1553
+ editedTrackId: editedTrack(
1554
+ before,
1555
+ scoreFromJSON(latest.composition),
1556
+ ),
1557
+ trackName: (id) =>
1558
+ score.tracks.find((track) => track.id === id)?.name ?? id,
1559
+ })}`,
1560
+ { tone: "info", baseRevision: from, resultRevision: to },
1561
+ );
1203
1562
  }
1204
1563
  } catch {
1205
1564
  // An invalid composition is skipped; the next update retries.
@@ -1210,8 +1569,10 @@ async function runInteractive(): Promise<void> {
1210
1569
  void applyLatest(update.record);
1211
1570
  adoptMeta(update.record.meta);
1212
1571
  } else if (update.type === "meta") adoptMeta(update.meta);
1213
- else if (update.type === "presence") windowCount = update.clients.length;
1214
- else if (update.type === "sync") syncState = update.sync;
1572
+ else if (update.type === "presence") {
1573
+ presenceClients = update.clients;
1574
+ windowCount = update.clients.length;
1575
+ } else if (update.type === "sync") syncState = update.sync;
1215
1576
  else if (update.type === "transport") {
1216
1577
  // Every window renders the same hit line from dawgd's timestamp.
1217
1578
  const { playing, beat, bpm, atMs } = update.transport;
@@ -1225,11 +1586,23 @@ async function runInteractive(): Promise<void> {
1225
1586
  });
1226
1587
  }
1227
1588
  };
1228
- let unsubscribe = port.subscribe(onUpdate);
1589
+ // Each subscription answers only while its port is the live one: /fork and
1590
+ // /resume swap the port, and a load the old port started before the swap
1591
+ // could otherwise land afterwards and pull the old session's name and
1592
+ // score into the new one.
1593
+ const subscribeLive = (): (() => void) => {
1594
+ const bound = port;
1595
+ ownWrites.attach(bound);
1596
+ return bound.subscribe((update) => {
1597
+ if (bound === port) onUpdate(update);
1598
+ });
1599
+ };
1600
+ let unsubscribe = subscribeLive();
1229
1601
  const refreshPresence = () =>
1230
1602
  void port
1231
1603
  .presence()
1232
1604
  .then((clients) => {
1605
+ presenceClients = clients;
1233
1606
  windowCount = Math.max(1, clients.length);
1234
1607
  })
1235
1608
  .catch(() => undefined);
@@ -1241,12 +1614,13 @@ async function runInteractive(): Promise<void> {
1241
1614
  rebindPort = () => {
1242
1615
  unsubscribe();
1243
1616
  syncState = port.sync;
1244
- unsubscribe = port.subscribe(onUpdate);
1617
+ unsubscribe = subscribeLive();
1245
1618
  refreshPresence();
1246
1619
  };
1247
1620
  if (freshWorkspace)
1248
1621
  tui.activity.pushCard("created .dawg/ · add it to .gitignore", {
1249
1622
  tone: "info",
1623
+ once: true,
1250
1624
  });
1251
1625
  if (attachNotice)
1252
1626
  tui.activity.pushCard(attachNotice, {
@@ -1256,6 +1630,9 @@ async function runInteractive(): Promise<void> {
1256
1630
  });
1257
1631
  if (port.status !== "file session")
1258
1632
  tui.activity.pushCard(port.status, { tone: "info" });
1633
+ void launchCard?.then((card) => {
1634
+ if (card) tui.activity.pushCard(card.text, { tone: card.tone, once: true });
1635
+ });
1259
1636
  if (await isProject(process.cwd()))
1260
1637
  projectSync = startProjectSync(syncHost());
1261
1638
  void currentProvider().then(() => tick(true));
@@ -1277,8 +1654,28 @@ async function runInteractive(): Promise<void> {
1277
1654
  stdin.on("end", onEnd);
1278
1655
  async function* chunks(): AsyncGenerator<string> {
1279
1656
  for (;;) {
1280
- while (inbox.length === 0)
1281
- await new Promise<void>((resolve) => (wake = resolve));
1657
+ while (inbox.length === 0) {
1658
+ // A partial escape (Alt+[, a cut-off paste) is flushed after a short
1659
+ // idle so it never holds back the keys behind it.
1660
+ const held = inputDecoder.pending();
1661
+ const idle =
1662
+ held === "paste"
1663
+ ? PASTE_FLUSH_MS
1664
+ : held === "escape"
1665
+ ? ESCAPE_FLUSH_MS
1666
+ : undefined;
1667
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1668
+ const timedOut = await new Promise<boolean>((resolve) => {
1669
+ wake = () => resolve(false);
1670
+ if (idle !== undefined)
1671
+ timeout = setTimeout(() => resolve(true), idle);
1672
+ });
1673
+ clearTimeout(timeout);
1674
+ if (timedOut && inbox.length === 0) {
1675
+ wake = undefined;
1676
+ yield INPUT_FLUSH;
1677
+ }
1678
+ }
1282
1679
  wake = undefined;
1283
1680
  const next = inbox.shift()!;
1284
1681
  if (next === "\u0000eof") return;
@@ -1293,7 +1690,7 @@ async function runInteractive(): Promise<void> {
1293
1690
  stdin.pause();
1294
1691
  stdin.setRawMode?.(false);
1295
1692
  // Leave the alternate screen; restore the cursor and plain paste.
1296
- stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1693
+ stdout.write(terminalRestore());
1297
1694
  try {
1298
1695
  return await flow();
1299
1696
  } finally {
@@ -1312,10 +1709,13 @@ async function runInteractive(): Promise<void> {
1312
1709
  let exiting = false;
1313
1710
  for await (const text of chunks()) {
1314
1711
  // A read that is exactly ESC is the Esc key, not the start of a sequence.
1315
- const values = [
1316
- ...inputDecoder.push(text),
1317
- ...(text === "\u001b" ? inputDecoder.flush() : []),
1318
- ];
1712
+ const values =
1713
+ text === INPUT_FLUSH
1714
+ ? inputDecoder.flush()
1715
+ : [
1716
+ ...inputDecoder.push(text),
1717
+ ...(text === "\u001b" ? inputDecoder.flush() : []),
1718
+ ];
1319
1719
  while (values.length) {
1320
1720
  const value = values.shift()!;
1321
1721
  // A mouse report acts on what the last frame painted under it; some
@@ -1341,9 +1741,9 @@ async function runInteractive(): Promise<void> {
1341
1741
  continue;
1342
1742
  }
1343
1743
  }
1344
- // The `?` panel closes on any key (Ctrl-C still quits).
1744
+ // The `?` panel closes on esc or ?; other keys wait (Ctrl-C quits).
1345
1745
  if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
1346
- tui.closeKeys();
1746
+ if (closesKeys(value)) tui.closeKeys();
1347
1747
  tick(true);
1348
1748
  continue;
1349
1749
  }
@@ -1474,12 +1874,7 @@ async function runInteractive(): Promise<void> {
1474
1874
  // Never await a daemon round trip here: the key loop must stay
1475
1875
  // live for Esc, quit and redraws while the toggle is in flight.
1476
1876
  void toggleTransport()
1477
- .catch((error: unknown) => {
1478
- tui.activity.pushCard(
1479
- `transport failed · ${error instanceof Error ? error.message : String(error)}`,
1480
- { tone: "error" },
1481
- );
1482
- })
1877
+ .catch((error: unknown) => transportFailed(error))
1483
1878
  .finally(() => tick(true));
1484
1879
  } else {
1485
1880
  const input = tui.input(value);
@@ -1508,7 +1903,9 @@ async function runInteractive(): Promise<void> {
1508
1903
  if (auditionLoop?.looping && isStageable(input.value))
1509
1904
  hoverItem(input.value, `picker:${input.picker}`);
1510
1905
  else if (input.picker === "pattern")
1511
- previewPattern(input.value.replace(/^\/pattern\s+/, ""));
1906
+ previewPattern(
1907
+ input.value.replace(/^\/(?:pattern|groove)\s+/, ""),
1908
+ );
1512
1909
  } else if (input.type === "pick-audition") {
1513
1910
  pickerAuditionKey(input.key);
1514
1911
  } else if (input.type === "pick-cancel") {
@@ -1560,6 +1957,16 @@ async function runInteractive(): Promise<void> {
1560
1957
  else if (action?.kind === "cancel" && agentTurn) {
1561
1958
  agentTurn.controller.abort();
1562
1959
  tui.activity.setSpinner("cancelling");
1960
+ } else if (
1961
+ action?.kind === "submit" &&
1962
+ action.value &&
1963
+ agentTurn &&
1964
+ routeDuringTurn(action.value, parsesLocally) === "local"
1965
+ ) {
1966
+ // A typed command never waits on the agent: it commits its own
1967
+ // revision now, and the agent's next call re-reads the score.
1968
+ const value = action.value;
1969
+ void runLocalBesideAgent(value).finally(() => tick(true));
1563
1970
  } else if (action?.kind === "submit" && action.value && agentTurn) {
1564
1971
  // Enter during a turn steers it; Alt-Enter still queues a follow-up.
1565
1972
  agentTurn.steering.push(action.value);
@@ -1590,17 +1997,36 @@ async function runInteractive(): Promise<void> {
1590
1997
  stdin.off("end", onEnd);
1591
1998
  clearInterval(timer);
1592
1999
  clearInterval(presenceTimer);
1593
- await projectSync?.stop();
1594
- namer.dispose();
1595
2000
  stdout.off("resize", onResize);
1596
- unsubscribe();
1597
- await play?.exit().catch(() => undefined);
1598
- await monitorEngine?.dispose().catch(() => undefined);
1599
- audio.stop();
1600
- await port.close();
1601
- stdin.setRawMode?.(false);
2001
+ // The terminal first: a teardown step that throws (a read-only .dawg,
2002
+ // a dead engine) must not leave the shell on the alternate screen.
2003
+ restoreTerminal();
1602
2004
  stdin.pause();
1603
- stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
2005
+ console.error = consoleError;
2006
+ console.warn = consoleWarn;
2007
+ process.off("warning", onWarning);
2008
+ unwatchActivity();
2009
+ // Each step on its own: one failure does not skip the rest.
2010
+ const failures: unknown[] = [];
2011
+ const steps: Array<() => unknown> = [
2012
+ () => projectSync?.stop(),
2013
+ () => namer.dispose(),
2014
+ () => unsubscribe(),
2015
+ () => play?.exit(),
2016
+ () => monitorEngine?.dispose(),
2017
+ () => audio.stop(),
2018
+ () => port.close(),
2019
+ ];
2020
+ for (const step of steps) {
2021
+ try {
2022
+ await step();
2023
+ } catch (error) {
2024
+ failures.push(error);
2025
+ }
2026
+ }
2027
+ process.off("unhandledRejection", onRejection);
2028
+ process.off("uncaughtException", onUncaught);
2029
+ if (failures.length > 0) throw failures[0];
1604
2030
  }
1605
2031
  }
1606
2032
 
@@ -1615,23 +2041,23 @@ function unknownCommand(command: string): string {
1615
2041
 
1616
2042
  async function submit(prompt: string): Promise<string | Receipt> {
1617
2043
  const command = prompt.trim();
2044
+ // Canonical names for window commands: `model key` (login), `models`,
2045
+ // `voice` (the Voice topic).
2046
+ const canonical = canonicalWindowForm(command);
2047
+ if (canonical) return submit(canonical);
1618
2048
  const helpCommand = command.match(/^\/?(?:help|\?)(?:\s+(\S+))?$/i);
1619
2049
  if (helpCommand) {
1620
2050
  const topic = helpCommand[1];
2051
+ // The panel's inner width (tui/app.ts paintText): rows clip there with
2052
+ // an ellipsis, the same as every other panel.
2053
+ const columns = stdout.columns ?? 80;
1621
2054
  const lines = helpTopicLines(
1622
2055
  topic,
1623
- Math.max(40, (stdout.columns ?? 80) - 10),
1624
- );
1625
- if (!lines)
1626
- return fail(
1627
- `no help topic ${topic} · /help all · ${HELP_TOPICS.join(" ")}`,
1628
- );
1629
- tui.openText(topic ? `help · ${topic.toLowerCase()}` : "help", lines);
1630
- return ok(
1631
- topic
1632
- ? `help · ${topic.toLowerCase()}`
1633
- : "help · /help all for every command",
2056
+ Math.max(10, columns - (columns >= 60 ? 8 : 4)),
1634
2057
  );
2058
+ if (!lines) return fail(helpMiss(topic!));
2059
+ tui.openText(helpTitle(topic), lines);
2060
+ return ok(topic ? helpTitle(topic) : "help · help all for every command");
1635
2061
  }
1636
2062
  if (/^\/?tracks$/i.test(command)) {
1637
2063
  const problems = await sampleProblems(score);
@@ -1663,7 +2089,32 @@ async function submit(prompt: string): Promise<string | Receipt> {
1663
2089
  items.findIndex((item) => item.current),
1664
2090
  ),
1665
2091
  });
1666
- return ok(`${items.length} track${items.length === 1 ? "" : "s"}`);
2092
+ return note(`${items.length} track${items.length === 1 ? "" : "s"}`);
2093
+ }
2094
+ if (/^\/?(?:instruments|instrument\s+(?:list|ls|presets))$/i.test(command)) {
2095
+ // `instrument list`: every word `instrument <name>` takes, by family.
2096
+ const columns = stdout.columns ?? 80;
2097
+ const lines = instrumentListLines(
2098
+ Math.max(10, columns - (columns >= 60 ? 8 : 4)),
2099
+ );
2100
+ tui.openText("instruments", lines);
2101
+ return note("instruments · instrument <name> on the focused track");
2102
+ }
2103
+ if (/^\/?notes(?:\s+(?:list|ls))?$/i.test(command)) {
2104
+ // `notes`: the focused track's note ids, the names `remove <id>` takes.
2105
+ const notes = score.notes
2106
+ .filter((entry) => entry.trackId === requestedTrack)
2107
+ .slice()
2108
+ .sort((a, b) => a.startTick - b.startTick);
2109
+ const lines = notes.map(
2110
+ (entry) =>
2111
+ `${entry.id} · ${midiNoteName(entry.pitch)} · beat ${+(entry.startTick / score.ticksPerBeat).toFixed(2)}`,
2112
+ );
2113
+ if (lines.length === 0) return note(`notes · ${requestedTrack} has none`);
2114
+ tui.openText(`notes · ${requestedTrack}`, lines);
2115
+ return note(
2116
+ `${lines.length} note${lines.length === 1 ? "" : "s"} · ${requestedTrack} · remove <id>`,
2117
+ );
1667
2118
  }
1668
2119
  const playCommand = command.match(
1669
2120
  /^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
@@ -1696,6 +2147,24 @@ async function submit(prompt: string): Promise<string | Receipt> {
1696
2147
  );
1697
2148
  return result.ok ? ok(result.message) : fail(result.message);
1698
2149
  }
2150
+ const showMeCommand = command.match(/^\/show-?me(?:\s+(\S+))?$/i);
2151
+ if (showMeCommand) {
2152
+ if (!showMeCommand[1])
2153
+ return ok(`show me · ${showMe.level} · /showme on|quiet|off`);
2154
+ const level = parseShowMe(showMeCommand[1]);
2155
+ if (!level) return fail("usage · /showme on|quiet|off");
2156
+ showMe.level = level;
2157
+ void writeConfig({ dir: configDir() }, { showMe: level }).catch(
2158
+ () => undefined,
2159
+ );
2160
+ return ok(
2161
+ level === "off"
2162
+ ? "show me off · the agent edits with tools"
2163
+ : level === "quiet"
2164
+ ? "show me quiet · the agent types commands, no captions"
2165
+ : "show me on · the agent types commands, slides faders, plays keys",
2166
+ );
2167
+ }
1699
2168
  const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
1700
2169
  if (countIn) return ok(playSession().setCountIn(Number(countIn[1])));
1701
2170
  const euclidCommand = command.match(/^\/euclid(?:\s+(\S+))?$/i);
@@ -1710,21 +2179,45 @@ async function submit(prompt: string): Promise<string | Receipt> {
1710
2179
  }
1711
2180
  const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
1712
2181
  if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
1713
- // A bare parameter (`volume`, `pan`, `fx filter`, `fx reverb mix`)
1714
- // opens the fader drawer on it, with its related params stacked below.
1715
- if (/^\/?(?:volume|pan|fx\s+\S+(?:\s+\S+)?)$/i.test(command)) {
1716
- const label = menu.showFader(menuContext(), command);
2182
+ // A bare scalar (`volume`, `pan`, `fx reverb mix`, and the song's
2183
+ // `tempo`, `bars` and `meter`) opens the fader drawer on it, with its
2184
+ // related params stacked below.
2185
+ if (
2186
+ /^\/?(?:volume|pan|tempo|bpm|bars|meter|fx\s+\S+(?:\s+\S+)?)$/i.test(
2187
+ command,
2188
+ )
2189
+ ) {
2190
+ const context = menuContext();
2191
+ const label =
2192
+ menu.showFader(context, command) ?? songFader(context, command);
1717
2193
  if (label) {
2194
+ const field = menu
2195
+ .faderFields(context)
2196
+ .find((candidate) => candidate.label === label);
2197
+ // The receipt names the value it opened on: `tempo · 120 BPM · …`.
2198
+ const value =
2199
+ field?.kind === "number"
2200
+ ? field.value !== undefined
2201
+ ? field.format(field.value)
2202
+ : field.off
2203
+ : undefined;
1718
2204
  openFader(label, true);
1719
2205
  refreshMenu();
1720
- return ok(`${label} · ←→ adjust · enter keep · esc revert`);
2206
+ return ok(
2207
+ `${label}${value ? ` · ${value}` : ""} · ←→ adjust · enter keep · esc revert`,
2208
+ );
1721
2209
  }
1722
2210
  }
1723
2211
  const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
1724
2212
  if (menuCommand) {
1725
2213
  const section = menuCommand[1]?.toLowerCase();
1726
- if (section && !(MENU_SECTIONS as readonly string[]).includes(section))
1727
- return fail(`/menu [${MENU_SECTIONS.join("|")}]`);
2214
+ // §4: the keys topic has no menu page; it opens the ? panel.
2215
+ if (section && (section === "keys" || TOPIC_ALIASES[section] === "keys")) {
2216
+ tui.showKeys("keys", keyLines(KEYS.prompt));
2217
+ return ok("keys");
2218
+ }
2219
+ if (section && !menuSectionPath(menuContext(), section))
2220
+ return fail(menuUsage(menuContext(), section));
1728
2221
  openMenu(section);
1729
2222
  return ok("menu");
1730
2223
  }
@@ -1754,6 +2247,73 @@ async function submit(prompt: string): Promise<string | Receipt> {
1754
2247
  if (requestedTrack !== grainTrack[1]!.toLowerCase()) return focused;
1755
2248
  return submit(grainTrack[2]!);
1756
2249
  }
2250
+ // `/track rm <name>` (aliases remove, delete) and `/track move <name>
2251
+ // <position>`: the human surface for the removeTrack and moveTrack
2252
+ // operations. Removing a track also drops any vocoder src or autotune from
2253
+ // that named it; ^z brings everything back.
2254
+ const trackEdit = command.match(
2255
+ /^\/?track\s+(rm|remove|delete|move)\s+(.{1,64}?)\s*$/i,
2256
+ );
2257
+ if (trackEdit) {
2258
+ const verb = trackEdit[1]!.toLowerCase() === "move" ? "move" : "rm";
2259
+ let rest = trackEdit[2]!.trim();
2260
+ let position: number | undefined;
2261
+ if (verb === "move") {
2262
+ const tail = rest.match(/^(.+?)\s+(\d{1,3})$/);
2263
+ if (!tail)
2264
+ return fail(`usage · /track move <name> <1..${score.tracks.length}>`);
2265
+ rest = tail[1]!;
2266
+ position = Number(tail[2]);
2267
+ }
2268
+ const wanted = rest
2269
+ .replace(/^["']|["']$/g, "")
2270
+ .trim()
2271
+ .replace(/\s+/g, " ")
2272
+ .toLowerCase();
2273
+ const found = score.tracks.find(
2274
+ (track) =>
2275
+ track.id.toLowerCase() === wanted ||
2276
+ track.id.toLowerCase() === wanted.replace(/ /g, "-") ||
2277
+ (track.name ?? "").toLowerCase() === wanted,
2278
+ );
2279
+ if (!found) return fail(noTrack(rest));
2280
+ if (verb === "move") {
2281
+ if (position! < 1 || position! > score.tracks.length)
2282
+ return fail(`usage · /track move <name> <1..${score.tracks.length}>`);
2283
+ const next = applyScoreOperation(score, {
2284
+ type: "moveTrack",
2285
+ trackId: found.id,
2286
+ index: position! - 1,
2287
+ });
2288
+ await commitScore(next, "track.move", { trackId: found.id });
2289
+ await projectSync?.flushScore();
2290
+ return ok(`moved ${found.id} to position ${position}`);
2291
+ }
2292
+ if (score.tracks.length <= 1)
2293
+ return fail("the last track stays · /clear empties it");
2294
+ const next = applyScoreOperation(score, {
2295
+ type: "removeTrack",
2296
+ trackId: found.id,
2297
+ });
2298
+ const dropped = score.tracks
2299
+ .filter((track) => track.id !== found.id)
2300
+ .filter((track) => {
2301
+ const after = next.tracks.find((t) => t.id === track.id);
2302
+ return (
2303
+ (track.vocoder?.src !== undefined &&
2304
+ after?.vocoder?.src === undefined) ||
2305
+ (track.autotune?.from !== undefined &&
2306
+ after?.autotune?.from === undefined)
2307
+ );
2308
+ })
2309
+ .map((track) => track.id);
2310
+ await commitScore(next, "track.remove", { trackId: found.id });
2311
+ await projectSync?.flushScore();
2312
+ if (found.id === requestedTrack) await focusTrack(next.tracks[0]!.id);
2313
+ return ok(
2314
+ `removed ${found.id}${dropped.length ? ` · dropped references on ${dropped.join(", ")}` : ""} · ctrl-z undoes`,
2315
+ );
2316
+ }
1757
2317
  // `/track piano b`: a name with spaces focuses the track of that name, or
1758
2318
  // creates `piano-b` named "piano b".
1759
2319
  const namedTrack = command.match(/^\/track\s+([a-z0-9._ -]{1,64})$/i);
@@ -1778,12 +2338,90 @@ async function submit(prompt: string): Promise<string | Receipt> {
1778
2338
  }
1779
2339
  const resample = parseResampleCommand(command);
1780
2340
  if (resample) return resampleCommand(resample);
2341
+ // 0.7 clips: `/clip` edits and `/lyrics` on the focused track.
2342
+ const clipCommand = parseClipCommand(command);
2343
+ if (clipCommand) {
2344
+ if ("error" in clipCommand) return fail(clipCommand.error);
2345
+ const result = await runClipCommand(clipCommand, {
2346
+ score,
2347
+ trackId: requestedTrack,
2348
+ cwd: process.cwd(),
2349
+ });
2350
+ if (!result.ok) return fail(result.message);
2351
+ if (result.next && result.kind) {
2352
+ await commitScore(result.next, result.kind, {
2353
+ trackId: requestedTrack,
2354
+ ...result.payload,
2355
+ });
2356
+ await projectSync?.flushScore();
2357
+ }
2358
+ return ok(result.message);
2359
+ }
2360
+ const lyricsCommand = parseLyricsCommand(command);
2361
+ if (lyricsCommand) {
2362
+ const result = applyLyrics(score, requestedTrack, lyricsCommand);
2363
+ if (!result.ok) return fail(result.message);
2364
+ if (result.next && result.kind) {
2365
+ await commitScore(result.next, result.kind, {
2366
+ trackId: requestedTrack,
2367
+ ...result.payload,
2368
+ });
2369
+ await projectSync?.flushScore();
2370
+ }
2371
+ return ok(result.message);
2372
+ }
2373
+ // 0.7 autotune: `/autotune …` (and `/tune <preset>` pointing here).
2374
+ // `/vocal autotune …` takes the same path (presets panel, draft, commit).
2375
+ const autotune = parseAutotuneCommand(
2376
+ command.replace(/^\/?vocal\s+(?=autotune\b)/i, ""),
2377
+ );
2378
+ if (autotune) {
2379
+ if (autotune.type === "autotune-list")
2380
+ tui.openText("autotune presets", autotuneListLines());
2381
+ if (autotune.type !== "autotune-usage" && autotune.type !== "autotune-list")
2382
+ await materializeDraft();
2383
+ const result = applyAutotuneCommand(score, requestedTrack, autotune);
2384
+ if (result.next && result.kind)
2385
+ await commitScore(result.next, result.kind, result.payload);
2386
+ return result.ok ? ok(result.message) : fail(result.message);
2387
+ }
2388
+ // 0.7 Voice: `/vocal <verb>`; lanes register verbs in VOCAL_VERBS.
2389
+ setClipImportDeps({
2390
+ media: {
2391
+ ...mediaServices(),
2392
+ signal: new AbortController().signal,
2393
+ progress: () => undefined,
2394
+ },
2395
+ });
2396
+ const vocal = parseVocalCommand(command);
2397
+ if (vocal) {
2398
+ if (vocal.kind === "verb") await materializeDraft();
2399
+ const result = await runVocalCommand(vocal, {
2400
+ score,
2401
+ trackId: requestedTrack,
2402
+ cwd: process.cwd(),
2403
+ });
2404
+ if (!result.ok) return fail(result.message);
2405
+ if (result.next && result.kind) {
2406
+ await commitScore(result.next, result.kind, {
2407
+ trackId: requestedTrack,
2408
+ ...result.payload,
2409
+ });
2410
+ await projectSync?.flushScore();
2411
+ }
2412
+ // A verb that made or chose another track (a vocoder carrier): focus it.
2413
+ if (result.trackId && result.trackId !== requestedTrack) {
2414
+ await port.focus(result.trackId);
2415
+ requestedTrack = result.trackId;
2416
+ }
2417
+ return ok(result.message);
2418
+ }
1781
2419
  const pack = parsePackCommand(command);
1782
2420
  if (pack) return packCommand(pack);
1783
2421
  const pattern = parsePatternCommand(command);
1784
2422
  if (pattern) return patternCommand(pattern);
1785
2423
  const kit = parseKitCommand(command);
1786
- if (kit) return kitCommand(kit, /^\/kit\s*$/i.test(command.trim()));
2424
+ if (kit) return kitCommand(kit, /^\/?kits?\s*$/i.test(command.trim()));
1787
2425
  const wavetable = parseWavetableCommand(command);
1788
2426
  if (wavetable) return wavetableCommand(wavetable);
1789
2427
  const sessionReply = await sessionCommand(command);
@@ -1821,6 +2459,19 @@ async function submit(prompt: string): Promise<string | Receipt> {
1821
2459
  await commitScore(result.next, result.kind, result.payload);
1822
2460
  return result.ok ? ok(result.message) : fail(result.message);
1823
2461
  }
2462
+ const progression = parseProgressionCommand(command);
2463
+ if (progression) {
2464
+ if (progression.type === "progression") await materializeDraft();
2465
+ const result = applyProgressionCommand(
2466
+ score,
2467
+ requestedTrack,
2468
+ progression,
2469
+ () => randomUUID().slice(0, 12),
2470
+ );
2471
+ if (result.next && result.kind)
2472
+ await commitScore(result.next, result.kind, result.payload);
2473
+ return result.ok ? ok(result.message) : fail(result.message);
2474
+ }
1824
2475
  const strum = parseStrumCommand(command);
1825
2476
  if (strum) {
1826
2477
  if (strum.type === "strum") await materializeDraft();
@@ -1836,6 +2487,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
1836
2487
  if (rig.type !== "rig-show" && rig.type !== "rig-hint")
1837
2488
  await materializeDraft();
1838
2489
  const result = applyRigCommand(score, requestedTrack, rig);
2490
+ if (result.next && result.kind)
2491
+ await commitScore(result.next, result.kind, result.payload);
2492
+ return readOrDone(result);
2493
+ }
2494
+ // 0.7 `/formant` and `/vowel`: short forms of `fx formant|vowel`.
2495
+ const formant = parseFormantCommand(command) ?? parseVowelCommand(command);
2496
+ if (formant) {
2497
+ if (formant.type === "fx") await materializeDraft();
2498
+ const result = applyFormantCommand(score, requestedTrack, formant);
1839
2499
  if (result.next && result.kind)
1840
2500
  await commitScore(result.next, result.kind, result.payload);
1841
2501
  return result.ok ? ok(result.message) : fail(result.message);
@@ -1857,6 +2517,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
1857
2517
  const result = applyFxCommand(score, requestedTrack, fx, pinnedIr);
1858
2518
  if (result.next && result.kind)
1859
2519
  await commitScore(result.next, result.kind, result.payload);
2520
+ // Bare `fx` reads the chain (`•`); it changes nothing.
2521
+ if (result.ok && fx.type === "fx-list") return note(result.message);
1860
2522
  return result.ok ? ok(result.message) : fail(result.message);
1861
2523
  }
1862
2524
  const unknownFx = unknownFxMessage(command);
@@ -1872,6 +2534,13 @@ async function submit(prompt: string): Promise<string | Receipt> {
1872
2534
  }
1873
2535
  const tuning = parseTuningCommand(command);
1874
2536
  if (tuning) return tuningCommand(tuning);
2537
+ const calibration = parseCalibrationCommand(command);
2538
+ if (calibration) {
2539
+ const result = applyCalibrationCommand(score, calibration);
2540
+ if (result.next && result.kind)
2541
+ await commitScore(result.next, result.kind, result.payload);
2542
+ return result.ok ? ok(result.message) : fail(result.message);
2543
+ }
1875
2544
  const keysCommand = parseKeysCommand(command);
1876
2545
  if (keysCommand) {
1877
2546
  const reads =
@@ -1893,6 +2562,25 @@ async function submit(prompt: string): Promise<string | Receipt> {
1893
2562
  await commitScore(result.next, result.kind, result.payload);
1894
2563
  return result.ok ? ok(result.message) : fail(result.message);
1895
2564
  }
2565
+ const vocoderCommand = parseVocoderCommand(command);
2566
+ if (vocoderCommand) {
2567
+ if (vocoderCommand.type === "vocoder-list")
2568
+ tui.openText("vocoder presets", vocoderListLines());
2569
+ if (
2570
+ vocoderCommand.type !== "vocoder-list" &&
2571
+ vocoderCommand.type !== "vocoder-usage"
2572
+ )
2573
+ await materializeDraft();
2574
+ const result = applyVocoderCommand(score, requestedTrack, vocoderCommand);
2575
+ if (result.next && result.kind)
2576
+ await commitScore(result.next, result.kind, result.payload);
2577
+ // `/vocoder` on a vocal makes a carrier track: focus it.
2578
+ if (result.trackId && result.trackId !== requestedTrack) {
2579
+ await port.focus(result.trackId);
2580
+ requestedTrack = result.trackId;
2581
+ }
2582
+ return result.ok ? ok(result.message) : fail(result.message);
2583
+ }
1896
2584
  const windCommand = parseWindCommand(command);
1897
2585
  if (windCommand) {
1898
2586
  if (windCommand.type === "wind-list")
@@ -1904,13 +2592,24 @@ async function submit(prompt: string): Promise<string | Receipt> {
1904
2592
  await commitScore(result.next, result.kind, result.payload);
1905
2593
  return result.ok ? ok(result.message) : fail(result.message);
1906
2594
  }
2595
+ const singCommand = parseSingCommand(command);
2596
+ if (singCommand) {
2597
+ if (singCommand.type === "sing-list")
2598
+ tui.openText("sing presets", singListLines());
2599
+ if (singCommand.type !== "sing-show" && singCommand.type !== "sing-list")
2600
+ await materializeDraft();
2601
+ const result = applySingCommand(score, requestedTrack, singCommand);
2602
+ if (result.next && result.kind)
2603
+ await commitScore(result.next, result.kind, result.payload);
2604
+ return result.ok ? ok(result.message) : fail(result.message);
2605
+ }
1907
2606
  const synth = parseSynthCommand(command);
1908
2607
  if (synth) {
1909
2608
  if (synth.type !== "synth-list") await materializeDraft();
1910
2609
  const result = applySynthCommand(score, requestedTrack, synth);
1911
2610
  if (result.next && result.kind)
1912
2611
  await commitScore(result.next, result.kind, result.payload);
1913
- return result.ok ? ok(result.message) : fail(result.message);
2612
+ return readOrDone(result);
1914
2613
  }
1915
2614
  const stringCommand = parseStringCommand(command);
1916
2615
  if (stringCommand) {
@@ -1922,7 +2621,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1922
2621
  const result = applyStringCommand(score, requestedTrack, stringCommand);
1923
2622
  if (result.next && result.kind)
1924
2623
  await commitScore(result.next, result.kind, result.payload);
1925
- return result.ok ? ok(result.message) : fail(result.message);
2624
+ return readOrDone(result);
1926
2625
  }
1927
2626
  const grainHint = grainSrcHint(command);
1928
2627
  if (grainHint) return fail(grainHint);
@@ -1935,6 +2634,50 @@ async function submit(prompt: string): Promise<string | Receipt> {
1935
2634
  await commitScore(result.next, result.kind, result.payload);
1936
2635
  return result.ok ? ok(result.message) : fail(result.message);
1937
2636
  }
2637
+ const styleCommand = parseStyleCommand(
2638
+ /^\/?styles?\s+presets$/i.test(command) ? "style list" : command,
2639
+ );
2640
+ if (styleCommand) {
2641
+ const writes =
2642
+ styleCommand.type === "style-apply" ||
2643
+ styleCommand.type === "style-again";
2644
+ // Bare `style` opens Arrange › style; `style list|ls|presets` lists.
2645
+ if (
2646
+ styleCommand.type === "style-families" &&
2647
+ /^\/?styles?$/i.test(command)
2648
+ ) {
2649
+ openMenu("style");
2650
+ return ok(
2651
+ "style · ↑/↓ browse · enter makes the song · style list prints it",
2652
+ );
2653
+ }
2654
+ if (writes) await materializeDraft();
2655
+ const result = applyStyleCommand(score, styleCommand);
2656
+ if (result.next && result.kind)
2657
+ await commitScore(result.next, result.kind, result.payload);
2658
+ if (result.log) tui.activity.pushNote(result.log);
2659
+ if (!result.ok) return fail(result.message);
2660
+ // A multi-line listing opens as text; the card keeps its first line
2661
+ // so lines never run together on the one-line strip.
2662
+ if (!writes && result.message.includes("\n")) {
2663
+ const [head = "styles", ...rest] = result.message.split("\n");
2664
+ tui.openText(head, rest);
2665
+ return note(head);
2666
+ }
2667
+ // A new song: focus its first melodic track, so play and the menu
2668
+ // land on something tonal instead of a track the style replaced.
2669
+ const melodic = writes
2670
+ ? result.next?.tracks.find((track) => !isDrumInstrument(track.instrument))
2671
+ : undefined;
2672
+ if (melodic && melodic.id !== requestedTrack) {
2673
+ await port.focus(melodic.id);
2674
+ requestedTrack = melodic.id;
2675
+ draftTrack = false;
2676
+ }
2677
+ return ok(
2678
+ melodic ? `${result.message} · focused ${melodic.id}` : result.message,
2679
+ );
2680
+ }
1938
2681
  const masterCommand = parseMasterCommand(command);
1939
2682
  if (masterCommand) {
1940
2683
  if (masterCommand.type === "master-measure")
@@ -1947,7 +2690,33 @@ async function submit(prompt: string): Promise<string | Receipt> {
1947
2690
  const result = applyMasterCommand(score, masterCommand);
1948
2691
  if (result.next && result.kind)
1949
2692
  await commitScore(result.next, result.kind, result.payload);
1950
- return result.ok ? ok(result.message) : fail(result.message);
2693
+ return readOrDone(result);
2694
+ }
2695
+ // `loop <section> | <a>-<b> | off`: what playback loops, a section.
2696
+ const loop = parseLoopCommand(command);
2697
+ if (loop) {
2698
+ if (loop.type === "loop-show")
2699
+ return note(
2700
+ score.loopSection
2701
+ ? `loop · ${score.loopSection} · loop off plays the song`
2702
+ : `loop · the song · ${LOOP_USAGE}`,
2703
+ );
2704
+ if (loop.type === "loop-off") return submit("section loop off");
2705
+ if (loop.type === "loop-section")
2706
+ return submit(`section loop ${loop.name}`);
2707
+ const span = score.sections.find(
2708
+ (section) =>
2709
+ section.startBar + 1 === loop.from &&
2710
+ section.startBar + section.bars === loop.to,
2711
+ );
2712
+ if (span) return submit(`section loop ${span.name}`);
2713
+ // No section spans the bars: mark (or move) the section named `loop`
2714
+ // and loop it, as one revision.
2715
+ await materializeDraft();
2716
+ const result = loopSpan(score, requestedTrack, loop.from, loop.to);
2717
+ if (!result.ok) return fail(result.message);
2718
+ await commitScore(result.next, "score.sections", { loop: result.name });
2719
+ return ok(result.message);
1951
2720
  }
1952
2721
  const arrange = parseSectionCommand(command, score);
1953
2722
  if (arrange) {
@@ -1961,7 +2730,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1961
2730
  if (result.next && result.kind)
1962
2731
  await commitScore(result.next, result.kind, result.payload);
1963
2732
  if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
1964
- return result.ok ? ok(result.message) : fail(result.message);
2733
+ return readOrDone(result);
1965
2734
  }
1966
2735
  const music = parseMusicCommand(command);
1967
2736
  if (music) {
@@ -1973,20 +2742,21 @@ async function submit(prompt: string): Promise<string | Receipt> {
1973
2742
  await commitScore(result.next, result.kind, result.payload);
1974
2743
  return result.ok ? ok(result.message) : fail(result.message);
1975
2744
  }
1976
- const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
2745
+ const exportCommand = parseExportCommand(command);
1977
2746
  if (exportCommand) {
1978
- const path = resolve(exportCommand[1]!);
1979
- if (/\.midi?$/i.test(path)) {
2747
+ const path = resolve(exportCommand.path);
2748
+ if (exportCommand.format === "mid") {
1980
2749
  await writeFile(path, scoreToMidi(exportScore(score)));
1981
- return `exported midi · ${exportCommand[1]}`;
2750
+ return `exported midi · ${exportCommand.path}`;
1982
2751
  }
1983
- // Audio comes from the offline renderer, never JSON under an audio name.
1984
- if (/\.(wav|aiff?|flac|mp3|ogg|m4a)$/i.test(path))
1985
- return fail(
1986
- `/export writes .track.json or .mid · render audio with: dawg render ${exportCommand[1]}`,
1987
- );
2752
+ // Audio goes through the `dawg render` path, the same bytes as the CLI.
2753
+ if (exportCommand.format === "wav")
2754
+ return exportWav(exportCommand.path, exportCommand.stems);
2755
+ // Other audio names are not formats dawg writes.
2756
+ if (/\.(aiff?|flac|mp3|ogg|m4a)$/i.test(path))
2757
+ return fail(`export · ${exportCommand.path} · ${EXPORT_USAGE}`);
1988
2758
  await writeFile(path, encodeLoop(score), "utf8");
1989
- return `exported · ${exportCommand[1]}`;
2759
+ return `exported · ${exportCommand.path}`;
1990
2760
  }
1991
2761
  const importCommand = command.match(/^\/?import\s+([^\s]+)$/i);
1992
2762
  if (importCommand) {
@@ -2021,14 +2791,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
2021
2791
  tui.activity.setSpinner(undefined);
2022
2792
  }
2023
2793
  }
2024
- const receiptText = await tuiSetModel(modelMatch[1]);
2794
+ const result = await tuiSetModel(modelMatch[1]);
2025
2795
  provider = undefined;
2026
2796
  await currentProvider();
2027
- return receiptText;
2797
+ return result.ok ? result.text : fail(result.text);
2028
2798
  }
2029
2799
  if (/^\/login\b/i.test(prompt.trim())) {
2030
2800
  const parsed = tuiLoginArgs(prompt.trim());
2031
- if (typeof parsed === "string") return `login · ${parsed}`;
2801
+ if (typeof parsed === "string") return `model key · ${parsed}`;
2032
2802
  await handoff(() =>
2033
2803
  runTuiLogin(
2034
2804
  parsed.target === "auto" ? "pick" : parsed.target,
@@ -2038,8 +2808,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
2038
2808
  provider = undefined;
2039
2809
  await currentProvider();
2040
2810
  return providerName === "offline"
2041
- ? "login · not signed in · direct commands still work"
2042
- : `signed in · ${providerName}`;
2811
+ ? "no agent key · model key adds one · direct commands still work"
2812
+ : `agent key · ${providerName}`;
2043
2813
  }
2044
2814
  if (/^\/(logout|auth)\b/i.test(prompt.trim())) {
2045
2815
  tui.activity.setSpinner(prompt.trim().split(/\s+/)[0]!.slice(1));
@@ -2054,8 +2824,46 @@ async function submit(prompt: string): Promise<string | Receipt> {
2054
2824
  tui.activity.setSpinner(undefined);
2055
2825
  }
2056
2826
  }
2827
+ // The grammar's second reading (design §3): a line no handler took runs
2828
+ // in its other spellings — `/x` ≡ `x`, aliases, `rm|remove|delete`,
2829
+ // `list|presets|ls` — so only lines that failed before get one.
2830
+ // A line the core prompt parser takes is already understood.
2831
+ if (!recovering && !parsePrompt(prompt)) {
2832
+ const retry = recover(
2833
+ command,
2834
+ (candidate) =>
2835
+ parseExact(candidate, score) !== undefined ||
2836
+ // Window verbs get only the slash toggle, never a listing word.
2837
+ (candidate.startsWith("/") &&
2838
+ SLASH_HANDLED.has(verbOf(candidate)) &&
2839
+ candidate.split(/\s+/).length === command.trim().split(/\s+/).length),
2840
+ );
2841
+ if (retry !== undefined) {
2842
+ recovering = true;
2843
+ try {
2844
+ return await submit(retry);
2845
+ } finally {
2846
+ recovering = false;
2847
+ }
2848
+ }
2849
+ }
2850
+ // Bare `rename`, `fork`, `resume`, `login` take free text: a hint only.
2851
+ const freeText = FREE_TEXT_HINTS[command.toLowerCase()];
2852
+ if (freeText !== undefined) return note(freeText);
2853
+ const elsewhere = elsewhereHint(command);
2854
+ if (elsewhere !== undefined) return note(elsewhere);
2057
2855
  // `/model` belongs to its own handler above; every other slash word that
2058
2856
  // reached here is unknown or misused, and never a question for the agent.
2857
+ // `/instrument aah` is `instrument aah`; `instrument sing choir` is
2858
+ // `instrument choir`.
2859
+ const singWord = command.match(/^\/?instrument\s+sing\s+([a-z]+)$/i);
2860
+ if (singWord && isSingWord(singWord[1]!.toLowerCase()))
2861
+ return submit(`instrument ${singWord[1]!.toLowerCase()}`);
2862
+ if (/^\/instrument\s/i.test(command) && parsePrompt(command.slice(1)))
2863
+ return submit(command.slice(1));
2864
+ // A synth or effect number out of range names its range.
2865
+ const range = paramRangeError(command);
2866
+ if (range) return fail(range);
2059
2867
  if (command.startsWith("/") && !/^\/model\b/i.test(command)) {
2060
2868
  const hint = usageHint(command);
2061
2869
  return fail(
@@ -2070,6 +2878,10 @@ async function submit(prompt: string): Promise<string | Receipt> {
2070
2878
  // A one-letter slip on a command whose arguments parse stays local.
2071
2879
  const fix = typoFix(command, parsesLocally);
2072
2880
  if (fix) return fail(`${truncateForCard(command)} · did you mean ${fix}?`);
2881
+ // A grammar verb (not everyday English) never reaches the agent.
2882
+ const verb = verbOf(command);
2883
+ if (knownVerbs().has(verb) && !EVERYDAY_VERBS.has(verb))
2884
+ return fail(usageCard(truncateForCard(command), `/help ${verb}`));
2073
2885
  if (process.env.DAWG_AI === "0")
2074
2886
  return fail(`unrecognized · ${truncateForCard(command)} · /help`);
2075
2887
  await materializeDraft();
@@ -2099,6 +2911,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
2099
2911
  return parsed.action;
2100
2912
  }
2101
2913
  if (parsed.type === "set-tempo") {
2914
+ const range = RANGES.tempo!;
2915
+ if (parsed.tempoBpm < range.min || parsed.tempoBpm > range.max)
2916
+ return fail(usageError(truncateForCard(command), range));
2102
2917
  const next = score.withTempo(parsed.tempoBpm);
2103
2918
  await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
2104
2919
  clock.follow(next);
@@ -2126,15 +2941,10 @@ async function submit(prompt: string): Promise<string | Receipt> {
2126
2941
  if (parsed.type === "track") {
2127
2942
  // An unknown word would store and play a plain sine: say so instead.
2128
2943
  const word = parsed.patch.instrument;
2129
- if (word !== undefined && isUnknownInstrument(word)) {
2130
- const advice = plainSineAdvice(word) ?? "";
2131
- const near = advice.match(/did you mean (\S+)\?/)?.[1];
2132
- return fail(
2133
- `instrument ${truncateForCard(word)} · not a dawg instrument${near ? ` · did you mean ${near}?` : ""} · /menu sounds lists them`,
2134
- );
2135
- }
2944
+ if (word !== undefined && isUnknownInstrument(word))
2945
+ return fail(unknownInstrumentMessage(word));
2136
2946
  // `instrument jangle`: a guitar alias also loads its rig.
2137
- const patch = parsed.word
2947
+ const rigged = parsed.word
2138
2948
  ? {
2139
2949
  ...parsed.patch,
2140
2950
  ...rigWordPatch(
@@ -2143,6 +2953,11 @@ async function submit(prompt: string): Promise<string | Receipt> {
2143
2953
  ),
2144
2954
  }
2145
2955
  : parsed.patch;
2956
+ // `instrument vocal` (0.7): the vocal chain fills unset effects.
2957
+ const chain = isGuideInstrument(parsed.patch.instrument)
2958
+ ? vocalChainPatch(score.tracks.find((t) => t.id === requestedTrack))
2959
+ : {};
2960
+ const patch = { ...chain, ...rigged };
2146
2961
  const next = applyScoreOperation(score, {
2147
2962
  type: "updateTrack",
2148
2963
  trackId: requestedTrack,
@@ -2163,6 +2978,13 @@ async function submit(prompt: string): Promise<string | Receipt> {
2163
2978
  ? plainSineAdvice(word)
2164
2979
  : undefined;
2165
2980
  if (sine) return `track · ${requestedTrack} · ${sine}`;
2981
+ const added = [
2982
+ chain.filter ? "hpf 90 Hz" : "",
2983
+ chain.fx?.compressor ? "compressor 3:1" : "",
2984
+ chain.reverb ? "plate 0.14" : "",
2985
+ ].filter(Boolean);
2986
+ if (added.length)
2987
+ return `track · ${requestedTrack} · vocal · added ${added.join(", ")} · fx … off to remove`;
2166
2988
  return `track · ${requestedTrack}`;
2167
2989
  }
2168
2990
  if (parsed.type === "automation") {
@@ -2207,6 +3029,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
2207
3029
  return `cleared · ${requestedTrack}`;
2208
3030
  }
2209
3031
  if (parsed.type === "remove-note") {
3032
+ // A receipt never claims a removal that did not happen.
3033
+ if (!score.notes.some((note) => note.id === parsed.noteId))
3034
+ return fail(noNote(parsed.noteId));
2210
3035
  const next = applyScoreOperation(score, {
2211
3036
  type: "removeNote",
2212
3037
  noteId: parsed.noteId,
@@ -2324,6 +3149,10 @@ async function focusTrack(trackId: string): Promise<Receipt> {
2324
3149
  ...newPianoTrack(trackId),
2325
3150
  // `track vibes`: a 0.6 modal word names the track and its preset.
2326
3151
  ...(isModalWord(trackId) ? instrumentPatchForWord(trackId) : {}),
3152
+ // `track vocal` (0.7): a clip track with the vocal chain.
3153
+ ...(isGuideInstrument(trackId)
3154
+ ? { instrument: "vocal", ...vocalChainPatch(undefined) }
3155
+ : {}),
2327
3156
  },
2328
3157
  });
2329
3158
  await commitScore(next, "track.create", { trackId });
@@ -2624,11 +3453,19 @@ async function packCommand(command: PackCommand): Promise<Receipt> {
2624
3453
  const store = packs();
2625
3454
  const a = await store.pruneCache(to(store.maxFileCacheBytes));
2626
3455
  const b = await library.pruneCache(to(library.maxCacheBytes));
2627
- freed = { files: a.removed + b.removed, bytes: a.freed + b.freed };
3456
+ const c = await pruneAnalysisCache(
3457
+ analysisDir(process.cwd()),
3458
+ to(ANALYSIS_CACHE_BYTES),
3459
+ );
3460
+ freed = {
3461
+ files: a.removed + b.removed + c.removed,
3462
+ bytes: a.freed + b.freed + c.freed,
3463
+ };
2628
3464
  }
2629
3465
  const lines = cacheLines({
2630
3466
  packs: await packs().cacheStatus(),
2631
3467
  assets: await library.cacheStatus(),
3468
+ analysis: await analysisCacheStatus(process.cwd()),
2632
3469
  ...(freed ? { freed } : {}),
2633
3470
  });
2634
3471
  tui.openText("cache", lines);
@@ -2703,27 +3540,38 @@ async function packCommand(command: PackCommand): Promise<Receipt> {
2703
3540
  }
2704
3541
  }
2705
3542
 
3543
+ /**
3544
+ * A command result as a receipt: a refusal is ✗, a change is ✓, and a read
3545
+ * that changed nothing (a listing or a show) is •.
3546
+ */
3547
+ function readOrDone(
3548
+ result: Readonly<{ ok: boolean; message: string; next?: unknown }>,
3549
+ ): Receipt {
3550
+ if (!result.ok) return fail(result.message);
3551
+ return result.next ? ok(result.message) : note(result.message);
3552
+ }
3553
+
2706
3554
  async function patternCommand(command: PatternCommand): Promise<Receipt> {
2707
3555
  if (command.kind === "list") {
2708
- tui.openText("patterns", DRUM_PATTERNS.map(patternLine));
2709
- return ok(`patterns · ${DRUM_PATTERNS.length} · /pattern <name>`);
3556
+ tui.openText("grooves", DRUM_PATTERNS.map(patternLine));
3557
+ return note(`grooves · ${DRUM_PATTERNS.length} · groove <name>`);
2710
3558
  }
2711
3559
  if (command.kind === "browse") {
2712
3560
  tui.openPicker({
2713
3561
  id: "pattern",
2714
- title: "drum patterns",
3562
+ title: "grooves",
2715
3563
  hint: HINTS.audition,
2716
3564
  audition: true,
2717
3565
  items: DRUM_PATTERNS.map((entry) => ({
2718
3566
  label: `${entry.label.padEnd(22)} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
2719
- value: `/pattern ${entry.name}`,
3567
+ value: `/groove ${entry.name}`,
2720
3568
  })),
2721
3569
  filterable: true,
2722
3570
  index: 0,
2723
3571
  });
2724
3572
  patternPreview = undefined;
2725
3573
  previewPattern(DRUM_PATTERNS[0]?.name);
2726
- return ok("patterns · ↑/↓ preview · Enter applies on the focused track");
3574
+ return note("grooves · ↑/↓ preview · Enter applies on the focused track");
2727
3575
  }
2728
3576
  await materializeDraft();
2729
3577
  const result = applyDrumPattern(
@@ -2784,7 +3632,7 @@ function openKitPicker(): Receipt {
2784
3632
  }));
2785
3633
  tui.openPicker({
2786
3634
  id: "kit",
2787
- title: "drum kits",
3635
+ title: "kits",
2788
3636
  hint: HINTS.audition,
2789
3637
  audition: true,
2790
3638
  items,
@@ -2816,7 +3664,7 @@ async function kitCommand(command: KitCommand, bare = false): Promise<Receipt> {
2816
3664
  .map((entry) => `${entry.name} · synth · ${entry.detail}`);
2817
3665
  lines.push(...kitListLines(await packs().bankAliases(ALIASED_PACK)));
2818
3666
  tui.openText("kits", lines);
2819
- return ok("kits · /kit <name or nickname> on the focused drum track");
3667
+ return note("kits · /kit <name or nickname> on the focused drum track");
2820
3668
  }
2821
3669
  await materializeDraft();
2822
3670
  const trackId = kitTarget(score, requestedTrack);
@@ -2927,12 +3775,10 @@ function adoptMeta(meta: typeof record.meta): void {
2927
3775
  record = { ...record, meta };
2928
3776
  if (meta.name !== announcedName) {
2929
3777
  announcedName = meta.name;
2930
- tui.activity.pushCard(
2931
- meta.nameSource === "auto"
2932
- ? `${meta.name} (auto-named) · rename with /rename <name>`
2933
- : `session · ${meta.name}`,
2934
- { tone: "info" },
2935
- );
3778
+ // The auto-name joins the receipt that earned it, as a quiet suffix.
3779
+ if (meta.nameSource === "auto")
3780
+ tui.activity.attachNote(`named “${meta.name}” · /rename`);
3781
+ else tui.activity.pushCard(`session · ${meta.name}`, { tone: "info" });
2936
3782
  }
2937
3783
  }
2938
3784
 
@@ -3096,11 +3942,13 @@ function menuContext(): MenuContext {
3096
3942
  committedChords: chordSettings,
3097
3943
  },
3098
3944
  trackId: requestedTrack,
3945
+ sessionName: record.meta.name,
3099
3946
  playing: clock.playing,
3100
3947
  grid: session?.grid ?? DEFAULT_GRID,
3101
3948
  grids: GRIDS.map((grid) => grid.label),
3102
3949
  clickOn: session?.clickOn ?? false,
3103
3950
  countInBars: session?.countInBars ?? 1,
3951
+ showMe: showMe.level,
3104
3952
  chords: stagedChordSettings() ?? session?.chords.settings ?? chordSettings,
3105
3953
  projectRoot: process.cwd(),
3106
3954
  };
@@ -3390,7 +4238,7 @@ async function startAuditionLoop(): Promise<void> {
3390
4238
  loop.focus(requestedTrack);
3391
4239
  // One sound at a time: the song stops while the loop plays.
3392
4240
  if (clock.playing) await setTransport("pause");
3393
- previewEngine().setLeadMs(PLAY_LEAD_MS);
4241
+ previewEngine().setLeadMs(previewEngine().playLeadMs);
3394
4242
  loop.start();
3395
4243
  }
3396
4244
 
@@ -3606,12 +4454,17 @@ function refreshMenu(): void {
3606
4454
  if (fader) {
3607
4455
  const fields = menu.faderFields(context);
3608
4456
  if (fields.length === 0) closeFader();
3609
- else
4457
+ else {
4458
+ const focusedField = fields[focusIndex(fader, fields)];
3610
4459
  tui.drawer = drawerView(fader, fields, menu.faderCommitted(context), {
3611
4460
  title: menu.crumbs,
3612
4461
  dirty: context.audition?.dirty ?? false,
3613
4462
  status: context.audition?.status,
4463
+ atOnce:
4464
+ focusedField !== undefined &&
4465
+ !stageableNow(faderCommand(focusedField)),
3614
4466
  });
4467
+ }
3615
4468
  }
3616
4469
  const view = menu.view(context);
3617
4470
  tui.openPicker({
@@ -3629,6 +4482,73 @@ function refreshMenu(): void {
3629
4482
  // ── the fader drawer ─────────────────────────────────────────────────
3630
4483
 
3631
4484
  /** Enter on a number row (or a bare `volume`, `fx filter`): the drawer. */
4485
+ /**
4486
+ * `export song.wav [stems]`: render the song as `dawg render song.wav` would
4487
+ * (the offline renderer, deterministic), then with `stems` one WAV per
4488
+ * audible track beside it (`song-bass.wav`), each that track soloed.
4489
+ */
4490
+ async function exportWav(path: string, stems: boolean): Promise<Receipt> {
4491
+ const directory = await mkdtemp(join(tmpdir(), "dawg-export-"));
4492
+ const errors: string[] = [];
4493
+ const sink = { write: (text: string) => errors.push(text.trim()) };
4494
+ const quiet = { write: () => undefined };
4495
+ const render = async (song: TrackScore, target: string): Promise<boolean> => {
4496
+ const file = join(directory, "song.track.json");
4497
+ await writeFile(file, encodeLoop(song), "utf8");
4498
+ const code = await runRenderCommand(
4499
+ ["render", target, "--import", file],
4500
+ process.cwd(),
4501
+ quiet,
4502
+ sink,
4503
+ );
4504
+ return code === 0;
4505
+ };
4506
+ tui.activity.setSpinner(`export ${path}`);
4507
+ try {
4508
+ if (!(await render(score, path)))
4509
+ return fail(errors.at(-1) ?? `export failed · ${path}`);
4510
+ if (!stems) return ok(`exported · ${path}`);
4511
+ const written: string[] = [];
4512
+ const base = path.replace(/\.wav$/i, "");
4513
+ for (const track of score.tracks) {
4514
+ if (!isTrackAudible(score, track.id)) continue;
4515
+ let solo = score;
4516
+ for (const other of score.tracks)
4517
+ solo = applyScoreOperation(solo, {
4518
+ type: "updateTrack",
4519
+ trackId: other.id,
4520
+ patch: { solo: other.id === track.id },
4521
+ });
4522
+ const target = `${base}-${track.id}.wav`;
4523
+ if (!(await render(solo, target)))
4524
+ return fail(errors.at(-1) ?? `export failed · ${target}`);
4525
+ written.push(target);
4526
+ }
4527
+ return ok(stemsReceipt(path, written));
4528
+ } finally {
4529
+ tui.activity.setSpinner(undefined);
4530
+ await rm(directory, { recursive: true, force: true });
4531
+ }
4532
+ }
4533
+
4534
+ /** `tempo`, `bars`, `meter`: open Project and name its fader, if it has one. */
4535
+ function songFader(context: MenuContext, command: string): string | undefined {
4536
+ // Bare song scalars and the Project fader each opens.
4537
+ const faders: Readonly<Record<string, string>> = {
4538
+ tempo: "tempo",
4539
+ bpm: "tempo",
4540
+ bars: "loop length",
4541
+ meter: "beats per bar",
4542
+ };
4543
+ const label = faders[command.replace(/^\//, "").toLowerCase()];
4544
+ if (!label) return undefined;
4545
+ menu.show(context, "project");
4546
+ if (menu.faderFields(context).some((field) => field.label === label))
4547
+ return label;
4548
+ menu.close();
4549
+ return undefined;
4550
+ }
4551
+
3632
4552
  /**
3633
4553
  * Open the drawer on `label`. `standalone` drawers came from a command or a
3634
4554
  * click, not from inside the menu, so closing one closes the menu too.
@@ -3731,7 +4651,7 @@ function mouseInput(event: MouseEvent): string[] {
3731
4651
  return [];
3732
4652
  }
3733
4653
  if (fader) return [];
3734
- if (tui.ui.overlay) return [event.delta < 0 ? KEY_UP : KEY_DOWN];
4654
+ if (tui.ui.overlay) return [event.delta < 0 ? ARROW_UP : ARROW_DOWN];
3735
4655
  return [];
3736
4656
  }
3737
4657
  // A drag keeps moving the fader it started on, even past the bar's ends.
@@ -3821,7 +4741,9 @@ function mouseInput(event: MouseEvent): string[] {
3821
4741
  }
3822
4742
  case "transport":
3823
4743
  if (tui.ui.overlay) return [];
3824
- void toggleTransport().finally(() => requestFrame());
4744
+ void toggleTransport()
4745
+ .catch((error: unknown) => transportFailed(error))
4746
+ .finally(() => requestFrame());
3825
4747
  return [];
3826
4748
  case "tracks":
3827
4749
  if (tui.ui.overlay) return [];
@@ -3852,13 +4774,13 @@ function playSession(): PlaySession {
3852
4774
  }
3853
4775
 
3854
4776
  async function enterPlay(): Promise<Receipt> {
3855
- if (play?.on) return ok(`play · ${play.track} · esc leaves`);
4777
+ if (play?.on) return ok(`play mode · ${play.track} · esc leaves`);
3856
4778
  await materializeDraft();
3857
4779
  const session = playSession();
3858
4780
  await session.enter();
3859
4781
  if (hasSamplerTracks(score)) void sampleProblems(score);
3860
4782
  return ok(
3861
- `play · ${session.track} · ${session.keyboard.range} · ? keys · esc leaves`,
4783
+ `play mode · ${session.track} · ${session.layout.drums ? "kit" : session.keyboard.range} · ? keys · esc leaves`,
3862
4784
  );
3863
4785
  }
3864
4786
 
@@ -3888,9 +4810,7 @@ function playKey(value: string): boolean {
3888
4810
  else if (result.command === "transport") {
3889
4811
  if (!session.startWithCountIn())
3890
4812
  void toggleTransport().catch((error: unknown) =>
3891
- tui.activity.pushError(
3892
- `transport failed · ${error instanceof Error ? error.message : String(error)}`,
3893
- ),
4813
+ transportFailed(error),
3894
4814
  );
3895
4815
  } else if (result.command === "menu") return false;
3896
4816
  return true;
@@ -3952,13 +4872,18 @@ function playHost() {
3952
4872
  async stopTransport(): Promise<void> {
3953
4873
  await setTransport("pause");
3954
4874
  },
4875
+ ghost(pitch: number) {
4876
+ tui.delight.ghost(pitch, Date.now());
4877
+ requestFrame();
4878
+ },
3955
4879
  card(text: string, tone: "info" | "success" | "warning" | "error") {
3956
4880
  if (tone === "error") tui.activity.pushError(text);
3957
4881
  else
3958
4882
  tui.activity.pushCard(text, {
3959
4883
  tone,
3960
4884
  trackId: requestedTrack,
3961
- resultRevision: tone === "success" ? record.revision : undefined,
4885
+ resultRevision:
4886
+ tone === "success" ? shownRevision(record) : undefined,
3962
4887
  });
3963
4888
  },
3964
4889
  newNoteId: () => randomUUID().slice(0, 12),
@@ -4005,6 +4930,9 @@ function timingChanged(previous: TrackScore, next: TrackScore): boolean {
4005
4930
  function syncHost(): SyncHost {
4006
4931
  return {
4007
4932
  project: process.cwd(),
4933
+ // The files belong to one session at a time; another session's window
4934
+ // stays detached instead of reprinting over them.
4935
+ sessionId: () => record.sessionId,
4008
4936
  current: () => score,
4009
4937
  async commit(plan, summary) {
4010
4938
  const baseRevision = record.revision;
@@ -4030,7 +4958,8 @@ function syncHost(): SyncHost {
4030
4958
  tui.activity.pushCard(text, {
4031
4959
  tone,
4032
4960
  hint,
4033
- resultRevision: tone === "success" ? record.revision : undefined,
4961
+ resultRevision:
4962
+ tone === "success" ? shownRevision(record) : undefined,
4034
4963
  });
4035
4964
  },
4036
4965
  types(state) {
@@ -4061,10 +4990,17 @@ async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
4061
4990
  },
4062
4991
  restored.toJSON(),
4063
4992
  );
4993
+ // Compare with what was undone, which another window may have written.
4994
+ const before = scoreFromJSON(latest.composition);
4064
4995
  score = restored;
4065
4996
  if (clock.playing) void audio.play(score);
4066
4997
  return ok(
4067
- `${direction === "undo" ? "undid" : "redid"} · rev ${target.revision}`,
4998
+ historyReceipt(
4999
+ direction,
5000
+ before,
5001
+ restored,
5002
+ shownRevision(record, target.revision),
5003
+ ),
4068
5004
  );
4069
5005
  } catch (error) {
4070
5006
  if (error instanceof SessionConflictError)
@@ -4075,7 +5011,36 @@ async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
4075
5011
  }
4076
5012
  }
4077
5013
 
4078
- /** The space-bar toggle: flip the transport, then record it for other windows. */
5014
+ /** Space, the menu and a header click all report a failed toggle the same way. */
5015
+ function transportFailed(error: unknown): void {
5016
+ tui.activity.pushCard(
5017
+ `transport failed · ${error instanceof Error ? error.message : String(error)}`,
5018
+ { tone: "error" },
5019
+ );
5020
+ }
5021
+
5022
+ /**
5023
+ * The revision dawg shows (header, receipts, undo): edits to the song.
5024
+ * Play and pause are logged so other windows follow them, but they are not
5025
+ * edits, so they never move the number: a revision shows as itself minus
5026
+ * the transport events at or before it.
5027
+ */
5028
+ function shownRevision(
5029
+ value: typeof record,
5030
+ revision = value.revision,
5031
+ ): number {
5032
+ let transports = 0;
5033
+ for (const event of value.events)
5034
+ if (event.revision <= revision && event.kind === "transport")
5035
+ transports += 1;
5036
+ return revision - transports;
5037
+ }
5038
+
5039
+ /**
5040
+ * The space-bar toggle: flip the transport, then record it for other
5041
+ * windows. The log entry keeps windows in step; the header's revision
5042
+ * (`shownRevision`) skips it, since play/pause is not an edit.
5043
+ */
4079
5044
  async function toggleTransport(): Promise<void> {
4080
5045
  await setTransport("toggle");
4081
5046
  try {
@@ -4145,11 +5110,11 @@ async function runAgent(text: string): Promise<string | Receipt> {
4145
5110
  if (agentTurn) return warn("agent busy · Esc cancels");
4146
5111
  const selection = await currentProvider();
4147
5112
  if (selection.kind === "offline")
4148
- return fail(
4149
- `unrecognized · ${truncateForCard(text)} · ${selection.reason}`,
4150
- );
5113
+ return fail(`${truncateForCard(text)} · ${NO_AGENT}`);
4151
5114
  const turn = { controller: new AbortController(), steering: [] as string[] };
4152
5115
  agentTurn = turn;
5116
+ // The turn ends on one musical receipt of what it changed.
5117
+ const before = score;
4153
5118
  reportAgentActivity(`${providerName} · thinking…`);
4154
5119
  // xcb admits a pending account on its first call, which takes longer.
4155
5120
  let admitting = selection.kind === "xcb" && selection.admissionPending;
@@ -4182,6 +5147,8 @@ async function runAgent(text: string): Promise<string | Receipt> {
4182
5147
  }
4183
5148
  return;
4184
5149
  }
5150
+ if (event.type === "done")
5151
+ tui.activity.setTurnReceipt(musicalReceipt(before, score));
4185
5152
  agentEventSink(event);
4186
5153
  },
4187
5154
  });
@@ -4213,7 +5180,7 @@ function currentProvider(): Promise<ProviderSelection> {
4213
5180
  provider = selectProvider().catch((): ProviderSelection => ({
4214
5181
  kind: "offline",
4215
5182
  choice: "auto",
4216
- reason: "provider unavailable; run `dawg login`",
5183
+ reason: "provider unavailable; run `dawg model key`",
4217
5184
  }));
4218
5185
  }
4219
5186
  return provider.then((selection) => {
@@ -4225,9 +5192,9 @@ function currentProvider(): Promise<ProviderSelection> {
4225
5192
  !invalidNoticeShown
4226
5193
  ) {
4227
5194
  invalidNoticeShown = true;
4228
- tui.activity.pushCard(`sign-in stopped working · ${selection.reason}`, {
5195
+ tui.activity.pushCard(`agent key stopped working · ${selection.reason}`, {
4229
5196
  tone: "warning",
4230
- hint: "/login",
5197
+ hint: "/model key",
4231
5198
  });
4232
5199
  }
4233
5200
  providerStamp.offline = selection.kind === "offline";
@@ -4302,6 +5269,180 @@ function agentPreviewHost(): PreviewHost {
4302
5269
  };
4303
5270
  }
4304
5271
 
5272
+ // ── show-me ──────────────────────────────────────────────────────────
5273
+
5274
+ /**
5275
+ * Show-me (docs/show-me.md): with an API provider the agent writes prompt
5276
+ * commands, streamed. The line being written is ghost text in the empty
5277
+ * prompt bar at the model's own speed; each complete line runs through
5278
+ * `submit()`, the path a typed Enter takes, at once. A parameter value
5279
+ * glides there over ~150 ms while the loop plays (you hear it), notes and
5280
+ * hits sound as they arrive (in time when the stream is ahead of the tempo,
5281
+ * as step entry when it is behind), and the caption names the key or fader
5282
+ * a person would use. Nothing waits for the turn to end.
5283
+ */
5284
+
5285
+ function showMeTyping(text: string): void {
5286
+ showMe.ghost = text || undefined;
5287
+ if (text && showMe.level === "on") showMe.caption = undefined;
5288
+ requestFrame();
5289
+ }
5290
+
5291
+ function showMeCaption(text: string | undefined): void {
5292
+ if (showMe.level !== "on") return;
5293
+ showMe.caption = text;
5294
+ if (showMe.clear) clearTimeout(showMe.clear);
5295
+ showMe.clear = undefined;
5296
+ requestFrame();
5297
+ }
5298
+
5299
+ /** End of a turn: the ghost goes, the caption becomes the do-it-yourself hint. */
5300
+ function showMeFinish(): void {
5301
+ showMe.ghost = undefined;
5302
+ showMe.notes.reset();
5303
+ const hint = finishHint(showMe.commands);
5304
+ showMe.commands = [];
5305
+ if (hint && showMe.level === "on") {
5306
+ showMeCaption(hint);
5307
+ showMe.clear = setTimeout(() => {
5308
+ showMe.caption = undefined;
5309
+ showMe.clear = undefined;
5310
+ requestFrame();
5311
+ }, 8_000);
5312
+ } else {
5313
+ showMe.caption = undefined;
5314
+ requestFrame();
5315
+ }
5316
+ }
5317
+
5318
+ /**
5319
+ * Slide a fader to its new value the way the drawer does: each eased step is
5320
+ * the field's own command, staged on a scratch score and played, so the loop
5321
+ * moves through the values. Only while the loop plays (otherwise nothing is
5322
+ * heard and the glide would only cost time); never longer than ~150 ms.
5323
+ */
5324
+ async function glideFader(param: string, target: number): Promise<void> {
5325
+ if (!clock.playing || !stdout.isTTY) return;
5326
+ const scratch = new EditMenu();
5327
+ const context = { ...menuContext(), score };
5328
+ const label = scratch.showFader(context, param);
5329
+ if (!label) return;
5330
+ const field = scratch
5331
+ .faderFields(context)
5332
+ .find((candidate) => candidate.label === label);
5333
+ scratch.close();
5334
+ if (field?.kind !== "number") return;
5335
+ const from = field.value ?? field.start ?? target;
5336
+ const steps = glideValues(from, target).slice(0, -1);
5337
+ for (const value of steps) {
5338
+ const staged = await applyStaged(score, field.command(value));
5339
+ if (staged.next && clock.playing) void audio.play(staged.next);
5340
+ await Bun.sleep(GLIDE_STEP_MS);
5341
+ }
5342
+ }
5343
+
5344
+ /** Sound one streamed note on the audition voice (the loop is stopped). */
5345
+ function soundStreamedNote(
5346
+ value: TrackScore,
5347
+ trackId: string,
5348
+ pitch: number,
5349
+ startBeat: number,
5350
+ ): void {
5351
+ if (clock.playing) return; // The loop itself plays it.
5352
+ const engine = liveEngine();
5353
+ if (!engine?.canMonitor) return;
5354
+ const tick = Math.round(startBeat * value.ticksPerBeat);
5355
+ const note = value.notes.find(
5356
+ (candidate) =>
5357
+ candidate.trackId === trackId &&
5358
+ candidate.pitch === pitch &&
5359
+ candidate.startTick === tick,
5360
+ );
5361
+ if (!note) return;
5362
+ try {
5363
+ const json = value.toJSON() as unknown as Record<string, unknown>;
5364
+ const single = scoreFromJSON({
5365
+ ...json,
5366
+ notes: [{ ...note, startTick: 0 }],
5367
+ });
5368
+ const pcm = renderAudition({
5369
+ score: single,
5370
+ trackId,
5371
+ sampleRate: engine.sampleRate,
5372
+ ...(liveSampleBank ? { samples: liveSampleBank } : {}),
5373
+ });
5374
+ if (!pcm) return;
5375
+ void engine
5376
+ .monitor(true)
5377
+ .then(() => engine.noteOn(AUDITION_VOICE, pcm))
5378
+ .catch(() => undefined);
5379
+ } catch {
5380
+ // A note that cannot render is still in the score.
5381
+ }
5382
+ }
5383
+
5384
+ /** The command host the show-me agent loop runs lines through. */
5385
+ function showMeCommandHost(): AgentHost["commands"] {
5386
+ return {
5387
+ isCommand: (line) =>
5388
+ isAgentCommand(line, score) || isBrokenCommand(line, score),
5389
+ async run(line): Promise<CommandOutcome> {
5390
+ if (!agentPathsAllowed(line)) {
5391
+ const revision = shownRevision(record);
5392
+ return {
5393
+ ok: false,
5394
+ message: `${line} · the agent works only inside this folder`,
5395
+ baseRevision: revision,
5396
+ resultRevision: revision,
5397
+ };
5398
+ }
5399
+ if (isBrokenCommand(line, score)) {
5400
+ // A command that does not parse is a red receipt, never prose.
5401
+ const base = baseline();
5402
+ receipt(fail(brokenCommandReceipt(line)), base);
5403
+ return {
5404
+ ok: false,
5405
+ message: brokenCommandReceipt(line),
5406
+ baseRevision: base.revision,
5407
+ resultRevision: base.revision,
5408
+ };
5409
+ }
5410
+ const gesture = gestureFor(line, { score, trackId: requestedTrack });
5411
+ showMeCaption(gesture.caption);
5412
+ showMe.commands.push(line);
5413
+ if (gesture.kind === "fader" && showMe.level === "on")
5414
+ await glideFader(gesture.param, gesture.value);
5415
+ const base = baseline();
5416
+ tui.activity.pushNote(`agent › ${line}`, "request");
5417
+ let result: string | Receipt;
5418
+ try {
5419
+ result = await submit(line);
5420
+ } catch (error) {
5421
+ result = fail(describeError(line, error));
5422
+ }
5423
+ receipt(result, base);
5424
+ if (gesture.kind === "keys" && showMe.level === "on") {
5425
+ const trackId = requestedTrack;
5426
+ const after = score;
5427
+ for (const note of gesture.notes) {
5428
+ const { atMs } = showMe.notes.schedule(note.start, performance.now());
5429
+ const delay = Math.max(0, atMs - performance.now());
5430
+ setTimeout(
5431
+ () => soundStreamedNote(after, trackId, note.pitch, note.start),
5432
+ delay,
5433
+ );
5434
+ }
5435
+ }
5436
+ return {
5437
+ ok: toneOf(result) !== "error",
5438
+ message: typeof result === "string" ? result : result.text,
5439
+ baseRevision: shownRevision(record, base.revision),
5440
+ resultRevision: shownRevision(record),
5441
+ };
5442
+ },
5443
+ };
5444
+ }
5445
+
4305
5446
  function agentHost(
4306
5447
  turn: { steering: string[] },
4307
5448
  selection: ProviderSelection,
@@ -4376,6 +5517,9 @@ function agentHost(
4376
5517
  }
4377
5518
  },
4378
5519
  takeSteering: () => turn.steering.splice(0),
5520
+ ...(showMe.level !== "off" && stdout.isTTY
5521
+ ? { commands: showMeCommandHost() }
5522
+ : {}),
4379
5523
  workspace: { root: process.cwd() },
4380
5524
  // A written project source is applied before the tool result returns,
4381
5525
  // so the model reads the outcome and any type errors in the same step.