@hraness/dawg 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/DAWG.md +605 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +99 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +301 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +337 -0
  42. package/src/audio/engine.ts +27 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +470 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +280 -278
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1119 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +501 -0
  72. package/src/tui/euclid.ts +472 -0
  73. package/src/tui/menu.ts +1286 -244
  74. package/src/tui/play-chords.ts +538 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +281 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
package/tui/app.ts CHANGED
@@ -24,9 +24,11 @@ import {
24
24
  resolveBeat,
25
25
  type TrackScoreSnapshot,
26
26
  } from "./highway.ts";
27
+ import { asciiHint, fitHint, HINTS } from "./grammar.ts";
27
28
  import { classifyKey, overlayKey, type UiCommand } from "./keys.ts";
28
29
  import { PromptModel, type PromptAction, type PromptMode } from "./prompt.ts";
29
30
  import {
31
+ paintChordLegend,
30
32
  paintPlayHeader,
31
33
  paintPlayStrip,
32
34
  type PlayHeaderView,
@@ -85,6 +87,8 @@ export interface UiState {
85
87
  log?: LogView | undefined;
86
88
  picker?: PickerState | undefined;
87
89
  text?: TextView | undefined;
90
+ /** The `?` panel: keys for the screen underneath, drawn over it. */
91
+ keys?: TextView | undefined;
88
92
  }
89
93
 
90
94
  /** `log` is the transcript, `picker` an arrow-key list, `text` static lines. */
@@ -122,9 +126,18 @@ export interface PickerState {
122
126
  filterable?: boolean | undefined;
123
127
  /** Current filter text; `items` is then the matching subset of `all`. */
124
128
  query?: string | undefined;
129
+ /** `/` was pressed: printable keys type into the filter. */
130
+ filtering?: boolean | undefined;
125
131
  all?: readonly PickerItem[] | undefined;
126
132
  /** Footer keys, replacing the default move/choose/cancel hint. */
127
133
  hint?: string | undefined;
134
+ /** A dim line under the rows: what the focused row does. */
135
+ note?: string | undefined;
136
+ /**
137
+ * Hosts the audition loop (src/tui/audition.ts): Space, `a` and `c` are
138
+ * returned as `pick-audition` instead of being swallowed.
139
+ */
140
+ audition?: boolean | undefined;
128
141
  }
129
142
 
130
143
  export interface PickerItem {
@@ -701,11 +714,10 @@ function paintOverlay(
701
714
  onBackground({ ...roles.text, bold: true }, panel),
702
715
  boxWidth - 4,
703
716
  );
704
- const hint = " ↑↓ pgup pgdn scroll · / filter · esc close ";
705
- const hintText = capabilities.unicode ? hint : hint.replace("↑↓", "up dn");
706
- if (boxWidth > hintText.length + 4)
717
+ const hintText = footerHint(HINTS.log, boxWidth - 4, capabilities.unicode);
718
+ if (hintText)
707
719
  buffer.text(
708
- left + boxWidth - 2 - hintText.length,
720
+ left + boxWidth - 2 - displayWidth(hintText),
709
721
  top + height - 1,
710
722
  hintText,
711
723
  onBackground(roles.muted, panel),
@@ -748,8 +760,9 @@ function paintText(
748
760
  ui: UiState,
749
761
  region: { y: number; height: number },
750
762
  width: number,
763
+ options: { text?: TextView | undefined; hint?: string } = {},
751
764
  ): void {
752
- const text = ui.text;
765
+ const text = options.text ?? ui.text;
753
766
  if (!text) return;
754
767
  const roles = ui.theme.roles;
755
768
  const left = width >= 60 ? 2 : 0;
@@ -776,12 +789,14 @@ function paintText(
776
789
  onBackground({ ...roles.text, bold: true }, panel),
777
790
  boxWidth - 4,
778
791
  );
779
- const hint = ui.capabilities.unicode
780
- ? " ↑↓ pgup pgdn scroll · esc close "
781
- : " up dn pgup pgdn scroll · esc close ";
782
- if (boxWidth > hint.length + 4)
792
+ const hint = footerHint(
793
+ options.hint ?? HINTS.text,
794
+ boxWidth - 4,
795
+ ui.capabilities.unicode,
796
+ );
797
+ if (hint)
783
798
  buffer.text(
784
- left + boxWidth - 2 - hint.length,
799
+ left + boxWidth - 2 - displayWidth(hint),
785
800
  region.y + height - 1,
786
801
  hint,
787
802
  onBackground(roles.muted, panel),
@@ -840,7 +855,11 @@ function paintPicker(
840
855
  const roles = ui.theme.roles;
841
856
  const left = width >= 60 ? 2 : 0;
842
857
  const boxWidth = width - left * 2;
843
- const height = Math.min(region.height, Math.max(1, picker.items.length) + 2);
858
+ const noteRows = picker.note && region.height >= 6 ? 1 : 0;
859
+ const height = Math.min(
860
+ region.height,
861
+ Math.max(1, picker.items.length) + 2 + noteRows,
862
+ );
844
863
  if (height < 3 || boxWidth < 10) return;
845
864
  const panel = paintBox(buffer, ui, {
846
865
  left,
@@ -851,23 +870,35 @@ function paintPicker(
851
870
  buffer.text(
852
871
  left + 2,
853
872
  region.y,
854
- ` ${picker.title}${picker.query ? ` · ${picker.query}` : picker.filterable ? " · type to filter" : ""} `,
873
+ ` ${picker.title}${picker.filtering || picker.query ? ` · /${picker.query ?? ""}${picker.filtering ? "▏" : ""}` : ""} `,
855
874
  onBackground({ ...roles.text, bold: true }, panel),
856
875
  boxWidth - 4,
857
876
  );
858
- const hint =
859
- picker.hint ??
860
- (ui.capabilities.unicode
861
- ? " ↑↓ move · enter choose · esc cancel "
862
- : " up/dn move · enter choose · esc cancel ");
863
- if (boxWidth > hint.length + 4)
877
+ const hint = footerHint(
878
+ picker.filtering
879
+ ? HINTS.filtering
880
+ : (picker.hint ??
881
+ (picker.filterable
882
+ ? HINTS.list
883
+ : HINTS.list.replace(" · / filter", ""))),
884
+ boxWidth - 4,
885
+ ui.capabilities.unicode,
886
+ );
887
+ if (hint)
864
888
  buffer.text(
865
- left + boxWidth - 2 - hint.length,
889
+ left + boxWidth - 2 - displayWidth(hint),
866
890
  region.y + height - 1,
867
891
  hint,
868
892
  onBackground(roles.muted, panel),
869
893
  );
870
- const inner = height - 2;
894
+ const inner = height - 2 - noteRows;
895
+ if (noteRows && picker.note)
896
+ buffer.text(
897
+ left + 2,
898
+ region.y + height - 2,
899
+ truncate(picker.note, boxWidth - 4),
900
+ onBackground(roles.muted, panel),
901
+ );
871
902
  const first = Math.max(
872
903
  0,
873
904
  Math.min(picker.items.length - inner, picker.index - inner + 1),
@@ -917,6 +948,24 @@ function paintPicker(
917
948
  });
918
949
  }
919
950
 
951
+ /** The `?` panel sits at the bottom of the highway, as tall as it needs. */
952
+ function keysRegion(
953
+ region: { y: number; height: number },
954
+ keys: TextView,
955
+ ): { y: number; height: number } {
956
+ const height = Math.min(region.height, keys.lines.length + 2);
957
+ return { y: region.y + region.height - height, height };
958
+ }
959
+
960
+ /** A footer hint fitted to `width`, spelled in ASCII when needed. */
961
+ export function footerHint(
962
+ hint: string,
963
+ width: number,
964
+ unicode: boolean,
965
+ ): string {
966
+ return fitHint(unicode ? hint : asciiHint(hint), width);
967
+ }
968
+
920
969
  // ---------------------------------------------------------------------------
921
970
 
922
971
  function paintTooSmall(buffer: CellBuffer, ui: UiState, size: FrameSize): void {
@@ -969,6 +1018,19 @@ export function composeFrame(
969
1018
  height: layout.highway.height - 1,
970
1019
  };
971
1020
  }
1021
+ if (view.play.legend && layout.highway.height > 4) {
1022
+ paintChordLegend(
1023
+ buffer,
1024
+ layout.highway.y,
1025
+ width,
1026
+ view.play.legend,
1027
+ ui.theme,
1028
+ );
1029
+ layout.highway = {
1030
+ y: layout.highway.y + 1,
1031
+ height: layout.highway.height - 1,
1032
+ };
1033
+ }
972
1034
  } else paintHeader(buffer, view, ui, width);
973
1035
  const beat = view.beat ?? resolveBeat(view.score, nowMs);
974
1036
  if (layout.highway.height > 0) {
@@ -990,6 +1052,11 @@ export function composeFrame(
990
1052
  },
991
1053
  );
992
1054
  }
1055
+ if (ui.keys && layout.highway.height > 0)
1056
+ paintText(buffer, ui, keysRegion(layout.highway, ui.keys), width, {
1057
+ text: ui.keys,
1058
+ hint: HINTS.keys,
1059
+ });
993
1060
  paintActivity(buffer, layout.activity, ui, width, nowMs);
994
1061
  const prompt = paintPrompt(buffer, view, ui, layout, width);
995
1062
  return { buffer, cursor: prompt.cursor, promptRows: prompt.rows, layout };
@@ -1021,6 +1088,14 @@ export type AppInput =
1021
1088
  | { type: "pick"; picker: string; value: string }
1022
1089
  /** Esc on a picker. */
1023
1090
  | { type: "pick-cancel"; picker: string }
1091
+ /**
1092
+ * The highlighted row changed (move, page, filter): the hook for live
1093
+ * previews (/pattern today; an audition controller can listen here too).
1094
+ * Pickers raise `pick-move` → `pick` (commit) or `pick-cancel` (cancel).
1095
+ */
1096
+ | { type: "pick-move"; picker: string; value: string }
1097
+ /** Space, `a` or `c` on a picker that hosts the audition loop. */
1098
+ | { type: "pick-audition"; picker: string; key: "loop" | "ab" | "context" }
1024
1099
  /** Consumed by an overlay (scroll, filter, move). */
1025
1100
  | { type: "overlay" }
1026
1101
  | { type: "none" };
@@ -1073,6 +1148,7 @@ export class TuiApp {
1073
1148
  log: this.log,
1074
1149
  picker: this.picker,
1075
1150
  text: this.text,
1151
+ keys: this.keys,
1076
1152
  };
1077
1153
  }
1078
1154
 
@@ -1121,15 +1197,17 @@ export class TuiApp {
1121
1197
  if (nav === "up" || nav === "down" || nav === "pgup" || nav === "pgdn") {
1122
1198
  const step =
1123
1199
  nav === "up" ? -1 : nav === "down" ? 1 : nav === "pgup" ? -8 : 8;
1200
+ // Moving ends typing into the filter; the filter itself stays.
1201
+ picker.filtering = false;
1124
1202
  picker.index = Math.max(
1125
1203
  0,
1126
1204
  Math.min(picker.items.length - 1, picker.index + step),
1127
1205
  );
1128
- return { type: "overlay" };
1206
+ return this.pickerMoved(picker);
1129
1207
  }
1130
1208
  if (nav === "home" || nav === "end") {
1131
1209
  picker.index = nav === "home" ? 0 : picker.items.length - 1;
1132
- return { type: "overlay" };
1210
+ return this.pickerMoved(picker);
1133
1211
  }
1134
1212
  if (nav === "enter") {
1135
1213
  const item = picker.items[picker.index];
@@ -1139,30 +1217,51 @@ export class TuiApp {
1139
1217
  : { type: "pick-cancel", picker: picker.id };
1140
1218
  }
1141
1219
  if (key.type === "ui" && key.command === "close-overlay") {
1220
+ // Esc clears the filter first, then closes.
1221
+ if (picker.query || picker.filtering) {
1222
+ this.filterPicker("");
1223
+ if (this.picker) this.picker.filtering = false;
1224
+ return { type: "overlay" };
1225
+ }
1142
1226
  this.closePicker();
1143
1227
  return { type: "pick-cancel", picker: picker.id };
1144
1228
  }
1145
- if (picker.filterable && (value === "\u007f" || value === "\b")) {
1146
- this.filterPicker((picker.query ?? "").slice(0, -1));
1147
- return { type: "overlay" };
1229
+ if (picker.filterable && picker.filtering) {
1230
+ if (value === "\u007f" || value === "\b") {
1231
+ const query = (picker.query ?? "").slice(0, -1);
1232
+ this.filterPicker(query);
1233
+ if (!query && this.picker) this.picker.filtering = false;
1234
+ return { type: "overlay" };
1235
+ }
1236
+ if (key.type === "text") {
1237
+ this.filterPicker(((picker.query ?? "") + key.text).slice(0, 40));
1238
+ return { type: "overlay" };
1239
+ }
1148
1240
  }
1149
- if (
1150
- picker.filterable &&
1151
- key.type === "text" &&
1152
- (picker.query || !/^[1-9]$/.test(key.text))
1153
- ) {
1154
- this.filterPicker(((picker.query ?? "") + key.text).slice(0, 40));
1155
- return { type: "overlay" };
1241
+ if (picker.audition && !picker.filtering) {
1242
+ const audition =
1243
+ value === " "
1244
+ ? "loop"
1245
+ : value === "a"
1246
+ ? "ab"
1247
+ : value === "c"
1248
+ ? "context"
1249
+ : undefined;
1250
+ if (audition)
1251
+ return { type: "pick-audition", picker: picker.id, key: audition };
1156
1252
  }
1157
- if (key.type === "text" && /^[1-9]$/.test(key.text)) {
1158
- const index = Number(key.text) - 1;
1159
- if (index < picker.items.length) {
1160
- const item = picker.items[index]!;
1161
- this.closePicker();
1162
- return { type: "pick", picker: picker.id, value: item.value };
1163
- }
1253
+ if (picker.filterable && key.type === "text" && key.text === "/") {
1254
+ picker.filtering = true;
1164
1255
  return { type: "overlay" };
1165
1256
  }
1257
+ if (key.type === "text" && (key.text === "j" || key.text === "k")) {
1258
+ const step = key.text === "k" ? -1 : 1;
1259
+ picker.index = Math.max(
1260
+ 0,
1261
+ Math.min(picker.items.length - 1, picker.index + step),
1262
+ );
1263
+ return this.pickerMoved(picker);
1264
+ }
1166
1265
  // Quit and redraw still work; everything else is swallowed.
1167
1266
  if (
1168
1267
  key.type === "ui" &&
@@ -1270,6 +1369,14 @@ export class TuiApp {
1270
1369
  }
1271
1370
 
1272
1371
  /** Show an arrow-key picker over the highway; replaces any overlay. */
1372
+ /** `pick-move` for the row now highlighted, or a plain overlay input. */
1373
+ private pickerMoved(picker: PickerState): AppInput {
1374
+ const item = picker.items[picker.index];
1375
+ return item
1376
+ ? { type: "pick-move", picker: picker.id, value: item.value }
1377
+ : { type: "overlay" };
1378
+ }
1379
+
1273
1380
  openPicker(picker: Omit<PickerState, "index"> & { index?: number }): void {
1274
1381
  const items = picker.items.slice(0, MAX_PICKER_ITEMS);
1275
1382
  if (items.length === 0) return;
@@ -1312,6 +1419,21 @@ export class TuiApp {
1312
1419
  this.overlay = "text";
1313
1420
  }
1314
1421
 
1422
+ /** The `?` panel over the current screen; any key closes it. */
1423
+ keys: TextView | undefined;
1424
+ showKeys(title: string, lines: readonly string[]): void {
1425
+ this.keys = { title, lines, scroll: 0 };
1426
+ }
1427
+
1428
+ closeKeys(): void {
1429
+ this.keys = undefined;
1430
+ }
1431
+
1432
+ /** True while a picker's `/` filter is taking typed text. */
1433
+ get pickerTyping(): boolean {
1434
+ return this.overlay === "picker" && this.picker?.filtering === true;
1435
+ }
1436
+
1315
1437
  closeText(): void {
1316
1438
  this.text = undefined;
1317
1439
  if (this.overlay === "text") this.overlay = undefined;
package/tui/grammar.ts ADDED
@@ -0,0 +1,281 @@
1
+ /**
2
+ * The shared key grammar: one vocabulary for every picker, editor and menu,
3
+ * the one-line footer hint each screen shows, and the `?` panel listing the
4
+ * keys of the current screen.
5
+ *
6
+ * ↑↓ (j k) move Enter open or confirm
7
+ * ←→ (h l, - +) adjust a value Space audition or toggle
8
+ * / filter Esc back one level (filter first)
9
+ * ? keys for this screen
10
+ * digits type a value, only where a value is focused
11
+ *
12
+ * Play mode is the one exception (its letters are piano keys, by the
13
+ * GarageBand "Musical Typing" convention); its panel says so.
14
+ */
15
+ import { displayWidth } from "./text.ts";
16
+
17
+ /** A `key action` pair for the `?` panel. */
18
+ export type KeyRow = readonly [keys: string, action: string];
19
+ export type KeySection = Readonly<{ title: string; rows: readonly KeyRow[] }>;
20
+
21
+ /**
22
+ * Fit a ` a · b · esc back · ? keys ` hint into `width` columns by dropping
23
+ * parts from the middle: the way out (`esc …`) and `? keys` stay longest,
24
+ * since they are how a user leaves or learns the rest. Returns "" when not
25
+ * even `? keys` fits.
26
+ */
27
+ export function fitHint(hint: string, width: number): string {
28
+ const key = `${width}\u0000${hint}`;
29
+ const cached = fitted.get(key);
30
+ if (cached !== undefined) return cached;
31
+ const text = fitHintUncached(hint, width);
32
+ if (fitted.size > 256) fitted.clear();
33
+ fitted.set(key, text);
34
+ return text;
35
+ }
36
+
37
+ /** Hints repeat every frame; fit each (hint, width) once. */
38
+ const fitted = new Map<string, string>();
39
+
40
+ function fitHintUncached(hint: string, width: number): string {
41
+ const parts = hint
42
+ .trim()
43
+ .split(" · ")
44
+ .filter((part) => part.length > 0);
45
+ if (parts.length === 0) return "";
46
+ const tailAt = parts.findIndex(
47
+ (part) => part.startsWith("esc ") || part.startsWith("? "),
48
+ );
49
+ const head = tailAt < 0 ? parts.slice(0, -1) : parts.slice(0, tailAt);
50
+ const tail = tailAt < 0 ? parts.slice(-1) : parts.slice(tailAt);
51
+ for (;;) {
52
+ const text = ` ${[...head, ...tail].join(" · ")} `;
53
+ if (displayWidth(text) <= width) return text;
54
+ if (head.length > 0) head.pop();
55
+ else if (tail.length > 1) tail.shift();
56
+ else return "";
57
+ }
58
+ }
59
+
60
+ /** The ASCII spelling of a hint, for terminals without Unicode arrows. */
61
+ export function asciiHint(hint: string): string {
62
+ return hint
63
+ .replace(/↑↓/g, "up/dn")
64
+ .replace(/←→/g, "lt/rt")
65
+ .replace(/–/g, "-");
66
+ }
67
+
68
+ // ── footer hints, one per screen ──────────────────────────────────────
69
+
70
+ export const HINTS = {
71
+ list: " ↑↓ move · enter choose · / filter · esc back · ? keys ",
72
+ preview: " ↑↓ preview · enter apply · / filter · esc back · ? keys ",
73
+ hover: " ↑↓ hear · enter choose · a A/B · c context · esc back · ? keys ",
74
+ audition:
75
+ " ↑↓ hear · enter keep · space loop · a A/B · c mix · / filter · esc back · ? keys ",
76
+ filtering: " type to filter · enter choose · esc clear · ? keys ",
77
+ menu: " ↑↓ move · enter open · / filter · esc back · ? keys ",
78
+ value: " ←→ adjust · enter type · x reset · esc back · ? keys ",
79
+ point: " ←→ adjust · enter type · x delete · esc back · ? keys ",
80
+ action: " ↑↓ move · enter apply · / filter · esc back · ? keys ",
81
+ typing: " type a value · enter apply · esc cancel ",
82
+ euclid:
83
+ " ↑↓ voice · ←→ adjust · tab field · space hear · x off · esc back · ? keys ",
84
+ text: " ↑↓ scroll · pgup pgdn page · esc back · ? keys ",
85
+ log: " ↑↓ scroll · / filter · esc back · ? keys ",
86
+ keys: " any key closes ",
87
+ } as const;
88
+
89
+ // ── the `?` panel ─────────────────────────────────────────────────────
90
+
91
+ const LIST: readonly KeyRow[] = [
92
+ ["↑ ↓ j k", "move"],
93
+ ["pgup pgdn", "page"],
94
+ ["enter", "choose"],
95
+ ["/", "filter (type, then enter or esc)"],
96
+ ["esc", "clear the filter, then back"],
97
+ ];
98
+
99
+ export const KEYS = {
100
+ prompt: [
101
+ {
102
+ title: "start here",
103
+ rows: [
104
+ ["type + enter", "ask for a change in plain words, or a command"],
105
+ ["ctrl-p", "play notes on the keyboard (chords: q)"],
106
+ ["ctrl-k", "menu: sound, effects, rhythm, chords, mix"],
107
+ ["space", "play / pause (empty prompt)"],
108
+ ["/help", "what dawg can do · /help <topic> for more"],
109
+ ],
110
+ },
111
+ {
112
+ title: "prompt",
113
+ rows: [
114
+ ["enter", "send"],
115
+ ["shift-enter ctrl-j", "new line"],
116
+ ["alt-enter ctrl-q", "queue after the current request"],
117
+ ["ctrl-z ctrl-y", "undo / redo"],
118
+ ["ctrl-o", "transcript"],
119
+ ["esc", "cancel the agent · close a panel"],
120
+ ["ctrl-c", "quit"],
121
+ ],
122
+ },
123
+ ],
124
+ list: [{ title: "list", rows: LIST }],
125
+ audition: [
126
+ {
127
+ title: "auditioning list",
128
+ rows: [
129
+ ["space", "loop the focused track · again stops"],
130
+ ["↑ ↓ j k", "move; while looping, hear the row on the loop"],
131
+ ["a", "A/B: before ↔ the highlighted row"],
132
+ ["c", "solo ↔ in context (the whole mix)"],
133
+ ["enter", "keep it (one undo step)"],
134
+ ["esc", "back; nothing changes"],
135
+ ["/", "filter (type, then enter or esc)"],
136
+ ],
137
+ },
138
+ ],
139
+ preview: [
140
+ {
141
+ title: "patterns",
142
+ rows: [
143
+ ["↑ ↓ j k", "move and hear the pattern"],
144
+ ["enter", "apply it to the focused track"],
145
+ ...LIST.slice(3),
146
+ ],
147
+ },
148
+ ],
149
+ menu: [
150
+ {
151
+ title: "menu",
152
+ rows: [
153
+ ["↑ ↓ j k", "move"],
154
+ ["enter → l", "open a section"],
155
+ ["esc ← h", "back one level"],
156
+ ["/", "filter this level"],
157
+ ],
158
+ },
159
+ {
160
+ title: "on a value",
161
+ rows: [
162
+ ["← → h l - +", "adjust"],
163
+ ["0-9", "type a value, enter applies"],
164
+ ["enter", "type a value · pick from a list"],
165
+ ["space", "toggle on/off (elsewhere: hear the track)"],
166
+ ["x delete", "reset to default (deletes an automation point)"],
167
+ ],
168
+ },
169
+ {
170
+ title: "auditioning",
171
+ rows: [
172
+ ["space", "loop the focused track · again stops"],
173
+ ["c", "solo ↔ in context (the whole mix)"],
174
+ ["a", "A/B: committed ↔ staged"],
175
+ ["enter", "keep staged changes (one undo step)"],
176
+ ["esc", "revert staged changes"],
177
+ ],
178
+ },
179
+ {
180
+ title: "every change",
181
+ rows: [
182
+ ["", "while looping, stages; otherwise runs the command shown"],
183
+ ["ctrl-z", "undoes it"],
184
+ ],
185
+ },
186
+ ],
187
+ euclid: [
188
+ {
189
+ title: "rhythm editor",
190
+ rows: [
191
+ ["↑ ↓ j k", "voice"],
192
+ ["← → h l - +", "adjust the field"],
193
+ ["tab shift-tab ] [", "next / previous field"],
194
+ ["0-9", "type a value, enter applies"],
195
+ ["enter", "add a row (or type a value)"],
196
+ ["space", "hear the voice"],
197
+ ["x", "turn the row off"],
198
+ ["f", "freeze into plain hits"],
199
+ ["esc", "back"],
200
+ ],
201
+ },
202
+ ],
203
+ text: [
204
+ {
205
+ title: "panel",
206
+ rows: [
207
+ ["↑ ↓ j k", "scroll"],
208
+ ["pgup pgdn home end", "page"],
209
+ ["esc", "close"],
210
+ ],
211
+ },
212
+ ],
213
+ log: [
214
+ {
215
+ title: "transcript",
216
+ rows: [
217
+ ["↑ ↓", "scroll"],
218
+ ["pgup pgdn home end", "page"],
219
+ ["/", "next filter: all, requests, ops, errors"],
220
+ ["esc ctrl-o", "close"],
221
+ ],
222
+ },
223
+ ],
224
+ play: [
225
+ {
226
+ title: "play mode · letters are piano keys",
227
+ rows: [
228
+ ["a s d f g h j k l ; '", "white keys"],
229
+ ["w e t y u o p", "black keys"],
230
+ ["z x", "octave down / up"],
231
+ ["c v", "velocity down / up"],
232
+ ["shift · tab", "sustain while held · sustain latch"],
233
+ ["space", "play / pause (with count-in)"],
234
+ ["r · R", "record · record replacing"],
235
+ ["m", "metronome click"],
236
+ ["q", "chord mode: auto ⇄ manual"],
237
+ ["/", "type a command, still in play mode"],
238
+ ["ctrl-k", "menu"],
239
+ ["esc", "leave play mode"],
240
+ ],
241
+ },
242
+ ],
243
+ chords: [
244
+ {
245
+ title: "chord mode · number row latches",
246
+ rows: [
247
+ ["1 2 3 4", "dim · min · maj · sus (two combine)"],
248
+ ["5 6 7 8", "add 6 · m7 · M7 · 9"],
249
+ ["0", "clear the latches"],
250
+ ["- =", "voicing down / up"],
251
+ ["9", "next perform mode (block, strum, arp…)"],
252
+ ["b", "next bass mode"],
253
+ ["n", "play the suggested next chord"],
254
+ ["q", "auto (chords fit the key) ⇄ manual"],
255
+ ],
256
+ },
257
+ ],
258
+ } satisfies Record<string, readonly KeySection[]>;
259
+
260
+ export type KeyScreen = keyof typeof KEYS;
261
+
262
+ /** `?` panel lines: a heading per section, then aligned `keys action`. */
263
+ export function keyLines(sections: readonly KeySection[]): string[] {
264
+ const column =
265
+ Math.max(
266
+ 0,
267
+ ...sections.flatMap((section) =>
268
+ section.rows.map(([keys]) => displayWidth(keys)),
269
+ ),
270
+ ) + 2;
271
+ const lines: string[] = [];
272
+ for (const section of sections) {
273
+ if (lines.length) lines.push("");
274
+ lines.push(`── ${section.title}`);
275
+ for (const [keys, action] of section.rows)
276
+ lines.push(
277
+ `${keys}${" ".repeat(Math.max(1, column - displayWidth(keys)))}${action}`,
278
+ );
279
+ }
280
+ return lines;
281
+ }
package/tui/highway.ts CHANGED
@@ -562,7 +562,13 @@ export function paintHighway(
562
562
  if (empty) {
563
563
  const name = score.trackName ?? score.trackId ?? "track";
564
564
  const start = projection.kind === "pitch" ? "add C4 at 0" : "hit kick at 0";
565
- const text = truncate(`${name} · empty · ${start} to start`, areaWidth);
565
+ // First run: name the three ways in (ask, play, menu); narrow
566
+ // windows fall back to one prompt command.
567
+ const full = `${name} · empty · type a request · ctrl-p play · ctrl-k menu`;
568
+ const text = truncate(
569
+ full.length <= areaWidth ? full : `${name} · empty · ${start} to start`,
570
+ areaWidth,
571
+ );
566
572
  const x = gutter + Math.max(0, Math.floor((areaWidth - text.length) / 2));
567
573
  const y = Math.max(0, Math.floor(hitRow / 2));
568
574
  for (let index = 0; index < text.length; index += 1)