@hraness/dawg 0.2.0 → 0.3.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 (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
package/tui/activity.ts CHANGED
@@ -45,7 +45,9 @@ export type AgentActivityEvent =
45
45
  | { type: "start"; prompt?: string | undefined; model?: string | undefined }
46
46
  | { type: "step"; step: number }
47
47
  | { type: "text-delta"; delta?: string; text?: string }
48
+ | { type: "activity"; message: string }
48
49
  | { type: "tool-start"; name: string; callId?: string; step?: number }
50
+ | { type: "tool-progress"; line: string; name?: string; callId?: string }
49
51
  | {
50
52
  type: "tool-applied";
51
53
  summary: string;
@@ -238,6 +240,9 @@ export class ActivityFeed {
238
240
  event.step <= 1 ? "thinking" : `thinking · step ${event.step}`,
239
241
  );
240
242
  return;
243
+ case "activity":
244
+ this.setSpinner(event.message);
245
+ return;
241
246
  case "text-delta": {
242
247
  const chunk = event.delta ?? event.text ?? "";
243
248
  const joined = `${this.streamText}${chunk}`.replace(/\s+/g, " ");
@@ -249,15 +254,27 @@ export class ActivityFeed {
249
254
  case "tool-start":
250
255
  this.setSpinner(`${event.name.replace(/_/g, " ")}…`);
251
256
  return;
252
- case "tool-applied":
257
+ case "tool-progress":
258
+ this.setSpinner(
259
+ event.name
260
+ ? `${event.name.replace(/_/g, " ")} · ${event.line}`
261
+ : event.line,
262
+ );
263
+ return;
264
+ case "tool-applied": {
265
+ // A media or explain result leaves the revision alone: nothing to undo.
266
+ const unchanged =
267
+ event.baseRevision !== undefined &&
268
+ event.baseRevision === event.resultRevision;
253
269
  this.pushCard(event.summary, {
254
270
  tone: "success",
255
271
  baseRevision: event.baseRevision,
256
272
  resultRevision: event.resultRevision,
257
273
  trackId: event.trackId,
258
- hint: "^z undo",
274
+ hint: unchanged ? undefined : "^z undo",
259
275
  });
260
276
  return;
277
+ }
261
278
  case "tool-rejected": {
262
279
  const what = event.summary ?? event.name?.replace(/_/g, " ");
263
280
  const why = event.reason ?? event.diagnostic;
@@ -309,9 +326,31 @@ export class ActivityFeed {
309
326
  }
310
327
  }
311
328
 
329
+ /**
330
+ * A command receipt with its outcome carried structurally: `ok: true` is a
331
+ * success, `false` a failure, `"warn"` a no-op or conflict. Handlers return
332
+ * these so the strip never infers failure from prose.
333
+ */
334
+ export type Receipt = Readonly<{ ok: boolean | "warn"; text: string }>;
335
+
336
+ export const ok = (text: string): Receipt => ({ ok: true, text });
337
+ export const fail = (text: string): Receipt => ({ ok: false, text });
338
+ export const warn = (text: string): Receipt => ({ ok: "warn", text });
339
+
340
+ /** Tone of a structured receipt; strings fall back to `receiptTone`. */
341
+ export function toneOf(receipt: string | Receipt): CardTone {
342
+ if (typeof receipt === "string") return receiptTone(receipt);
343
+ return receipt.ok === true
344
+ ? "success"
345
+ : receipt.ok === "warn"
346
+ ? "warning"
347
+ : "error";
348
+ }
349
+
312
350
  /**
313
351
  * Classify a legacy command receipt string (e.g. "undid · rev 41",
314
- * "tempo error · …") into a card tone so existing handlers need no rewrite.
352
+ * "tempo error · …") into a card tone. Fallback for handlers that still
353
+ * return plain strings; new handlers return a `Receipt`.
315
354
  */
316
355
  export function receiptTone(message: string): CardTone {
317
356
  const lower = message.toLowerCase();
package/tui/app.ts CHANGED
@@ -26,6 +26,11 @@ import {
26
26
  } from "./highway.ts";
27
27
  import { classifyKey, overlayKey, type UiCommand } from "./keys.ts";
28
28
  import { PromptModel, type PromptAction, type PromptMode } from "./prompt.ts";
29
+ import {
30
+ paintPlayHeader,
31
+ paintPlayStrip,
32
+ type PlayHeaderView,
33
+ } from "./play-strip.ts";
29
34
  import { CellBuffer, ScreenWriter, type CursorPosition } from "./screen.ts";
30
35
  import { displayWidth, truncate } from "./text.ts";
31
36
  import {
@@ -54,18 +59,43 @@ export interface AppView {
54
59
  sessionName?: string | undefined;
55
60
  /** Live windows on this session (presence); shown when more than one. */
56
61
  windows?: number | undefined;
62
+ /** Project typecheck result; `types ✓` or `types ✗ N` beside sync. */
63
+ types?: TypesIndicator | undefined;
64
+ /**
65
+ * The line under the prompt: `$0.12 session · $0.48 today · opus-5.5 ·
66
+ * gateway`, or `no model · dawg login`. The caller sizes it to the width.
67
+ */
68
+ spend?: string | undefined;
69
+ /** No agent provider: the placeholder teaches commands, no STEER pill. */
70
+ agentOffline?: boolean | undefined;
71
+ /** Play mode: replaces the header and adds the keyboard strip row. */
72
+ play?: PlayHeaderView | undefined;
57
73
  }
58
74
 
75
+ export type TypesIndicator = Readonly<{ ok: boolean; errors: number }>;
76
+
59
77
  export interface UiState {
60
78
  prompt: PromptModel;
61
79
  activity: ActivityFeed;
62
80
  theme: Theme;
63
81
  capabilities: TerminalCapabilities;
64
82
  reducedMotion: boolean;
65
- overlay: "log" | "picker" | undefined;
83
+ overlay: Overlay;
66
84
  /** Transcript scroll (entries up from the newest) and filter. */
67
85
  log?: LogView | undefined;
68
86
  picker?: PickerState | undefined;
87
+ text?: TextView | undefined;
88
+ }
89
+
90
+ /** `log` is the transcript, `picker` an arrow-key list, `text` static lines. */
91
+ export type Overlay = "log" | "picker" | "text" | undefined;
92
+
93
+ /** A scrollable read-only panel (`/help`, `/sessions`, `/tracks`). */
94
+ export interface TextView {
95
+ title: string;
96
+ lines: readonly string[];
97
+ /** Rows scrolled down from the top. */
98
+ scroll: number;
69
99
  }
70
100
 
71
101
  export type LogFilter = "all" | "requests" | "ops" | "errors";
@@ -88,6 +118,13 @@ export interface PickerState {
88
118
  title: string;
89
119
  items: readonly PickerItem[];
90
120
  index: number;
121
+ /** Type-to-filter on label and detail (`/model`). */
122
+ filterable?: boolean | undefined;
123
+ /** Current filter text; `items` is then the matching subset of `all`. */
124
+ query?: string | undefined;
125
+ all?: readonly PickerItem[] | undefined;
126
+ /** Footer keys, replacing the default move/choose/cancel hint. */
127
+ hint?: string | undefined;
91
128
  }
92
129
 
93
130
  export interface PickerItem {
@@ -95,6 +132,8 @@ export interface PickerItem {
95
132
  /** Opaque value returned on Enter. */
96
133
  value: string;
97
134
  detail?: string | undefined;
135
+ /** Marked with ● (the current model). */
136
+ current?: boolean | undefined;
98
137
  }
99
138
 
100
139
  /** Most picker rows kept; longer lists are truncated by the caller's order. */
@@ -350,6 +389,14 @@ function paintHeader(
350
389
  style: roles.text,
351
390
  priority: 1,
352
391
  });
392
+ if (view.types)
393
+ right.push({
394
+ text: view.types.ok
395
+ ? `types ${unicode ? "✓" : "ok"}`
396
+ : `types ${unicode ? "✗" : "x"} ${view.types.errors}`,
397
+ style: view.types.ok ? roles.success : roles.error,
398
+ priority: 4,
399
+ });
353
400
  const sync = syncSegment(view.sync, theme, unicode);
354
401
  if (sync) right.push(sync);
355
402
  paintSegments(buffer, 0, left, width, " · ", roles.faint, roles.panel, right);
@@ -500,9 +547,14 @@ function paintPrompt(
500
547
  const mode = prompt.snapshot.mode;
501
548
  const pillStyle = mode === "queue" ? roles.pillQueue : roles.pillSteer;
502
549
  let x = 2;
503
- x += buffer.text(x, top, pill(mode), pillStyle);
550
+ if (!view.agentOffline) x += buffer.text(x, top, pill(mode), pillStyle);
504
551
  const status: string[] = [];
505
- if (view.model) status.push(view.model);
552
+ // The model shows once, in the header; the spend line carries it here.
553
+ // Views without a spend line (embedders such as the site demo) keep the
554
+ // model label.
555
+ if (view.spend) {
556
+ if (!layout.footer) status.push(view.spend);
557
+ } else if (view.model) status.push(view.model);
506
558
  if (activity.queueDepth > 0) status.push(`queue ${activity.queueDepth}`);
507
559
  const layoutInfo = prompt.layout(rows);
508
560
  if (layoutInfo.total > rows)
@@ -541,8 +593,9 @@ function paintPrompt(
541
593
  buffer.text(width - 3, y, capabilities.unicode ? "↓" : "v", faint);
542
594
  }
543
595
  if (prompt.value.length === 0) {
544
- const placeholder =
545
- mode === "queue"
596
+ const placeholder = view.agentOffline
597
+ ? "try: tempo 96 · add C4 at 0 · /help (dawg login enables the agent)"
598
+ : mode === "queue"
546
599
  ? "queue a request for after the current one…"
547
600
  : "describe a change — “add a walking bass in A minor”";
548
601
  buffer.text(5, top + 1, truncate(placeholder, editorWidth - 1), faint);
@@ -563,8 +616,19 @@ function paintPrompt(
563
616
  : width >= 44
564
617
  ? " enter · ^q queue · ^z undo · ^o log "
565
618
  : "";
566
- const shown = hints;
619
+ const spend = view.spend ? ` ${view.spend} ` : "";
620
+ const spendWidth = displayWidth(spend);
621
+ // Spend wins over hints when both do not fit.
622
+ const shown =
623
+ hints && displayWidth(hints) + spendWidth + 6 <= width ? hints : "";
567
624
  if (shown) buffer.text(2, y, shown, onBackground(roles.muted, panel));
625
+ if (spend && spendWidth + 4 <= width)
626
+ buffer.text(
627
+ width - 2 - spendWidth,
628
+ y,
629
+ spend,
630
+ onBackground(roles.muted, panel),
631
+ );
568
632
  }
569
633
 
570
634
  // Cursor: shown as a reverse cell and as the real terminal cursor.
@@ -678,6 +742,67 @@ function paintOverlay(
678
742
  });
679
743
  }
680
744
 
745
+ /** Static lines in a bordered panel; the title sits on the top border. */
746
+ function paintText(
747
+ buffer: CellBuffer,
748
+ ui: UiState,
749
+ region: { y: number; height: number },
750
+ width: number,
751
+ ): void {
752
+ const text = ui.text;
753
+ if (!text) return;
754
+ const roles = ui.theme.roles;
755
+ const left = width >= 60 ? 2 : 0;
756
+ const boxWidth = width - left * 2;
757
+ const height = region.height;
758
+ if (height < 3 || boxWidth < 10) return;
759
+ const panel = paintBox(buffer, ui, {
760
+ left,
761
+ top: region.y,
762
+ width: boxWidth,
763
+ height,
764
+ });
765
+ const inner = height - 2;
766
+ const maxScroll = Math.max(0, text.lines.length - inner);
767
+ const scroll = Math.max(0, Math.min(maxScroll, text.scroll));
768
+ const position =
769
+ maxScroll > 0
770
+ ? ` · ${scroll + 1}-${scroll + inner}/${text.lines.length}`
771
+ : "";
772
+ buffer.text(
773
+ left + 2,
774
+ region.y,
775
+ ` ${text.title}${position} `,
776
+ onBackground({ ...roles.text, bold: true }, panel),
777
+ boxWidth - 4,
778
+ );
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)
783
+ buffer.text(
784
+ left + boxWidth - 2 - hint.length,
785
+ region.y + height - 1,
786
+ hint,
787
+ onBackground(roles.muted, panel),
788
+ );
789
+ text.lines.slice(scroll, scroll + inner).forEach((line, index) => {
790
+ const heading = line.startsWith("── ");
791
+ buffer.text(
792
+ left + 2,
793
+ region.y + 1 + index,
794
+ truncate(
795
+ heading && !ui.capabilities.unicode ? line.replace("── ", "-- ") : line,
796
+ boxWidth - 4,
797
+ ),
798
+ onBackground(
799
+ heading ? { ...roles.borderFocus, bold: true } : roles.text,
800
+ panel,
801
+ ),
802
+ );
803
+ });
804
+ }
805
+
681
806
  function paintBox(
682
807
  buffer: CellBuffer,
683
808
  ui: UiState,
@@ -715,7 +840,7 @@ function paintPicker(
715
840
  const roles = ui.theme.roles;
716
841
  const left = width >= 60 ? 2 : 0;
717
842
  const boxWidth = width - left * 2;
718
- const height = Math.min(region.height, picker.items.length + 2);
843
+ const height = Math.min(region.height, Math.max(1, picker.items.length) + 2);
719
844
  if (height < 3 || boxWidth < 10) return;
720
845
  const panel = paintBox(buffer, ui, {
721
846
  left,
@@ -726,13 +851,15 @@ function paintPicker(
726
851
  buffer.text(
727
852
  left + 2,
728
853
  region.y,
729
- ` ${picker.title} `,
854
+ ` ${picker.title}${picker.query ? ` · ${picker.query}` : picker.filterable ? " · type to filter" : ""} `,
730
855
  onBackground({ ...roles.text, bold: true }, panel),
731
856
  boxWidth - 4,
732
857
  );
733
- const hint = ui.capabilities.unicode
734
- ? " ↑↓ move · enter choose · esc cancel "
735
- : " up/dn move · enter choose · esc cancel ";
858
+ const hint =
859
+ picker.hint ??
860
+ (ui.capabilities.unicode
861
+ ? " ↑↓ move · enter choose · esc cancel "
862
+ : " up/dn move · enter choose · esc cancel ");
736
863
  if (boxWidth > hint.length + 4)
737
864
  buffer.text(
738
865
  left + boxWidth - 2 - hint.length,
@@ -745,6 +872,16 @@ function paintPicker(
745
872
  0,
746
873
  Math.min(picker.items.length - inner, picker.index - inner + 1),
747
874
  );
875
+ const marks = (picker.all ?? picker.items).some(
876
+ (item) => item.current !== undefined,
877
+ );
878
+ if (picker.items.length === 0)
879
+ buffer.text(
880
+ left + 4,
881
+ region.y + 1,
882
+ "no matches",
883
+ onBackground(roles.muted, panel),
884
+ );
748
885
  picker.items.slice(first, first + inner).forEach((item, offset) => {
749
886
  const index = first + offset;
750
887
  const y = region.y + 1 + offset;
@@ -754,11 +891,25 @@ function paintPicker(
754
891
  ? onBackground({ ...roles.borderFocus, bold: true }, panel)
755
892
  : onBackground(roles.text, panel);
756
893
  buffer.text(left + 2, y, marker, style);
757
- const room = boxWidth - 6;
758
- const used = buffer.text(left + 4, y, truncate(item.label, room), style);
894
+ // A column for the current-item mark only when the picker uses one.
895
+ const pad = marks ? 2 : 0;
896
+ if (item.current)
897
+ buffer.text(
898
+ left + 4,
899
+ y,
900
+ ui.capabilities.unicode ? "●" : "*",
901
+ onBackground(roles.success, panel),
902
+ );
903
+ const room = boxWidth - 6 - pad;
904
+ const used = buffer.text(
905
+ left + 4 + pad,
906
+ y,
907
+ truncate(item.label, room),
908
+ style,
909
+ );
759
910
  if (item.detail && used + 3 < room)
760
911
  buffer.text(
761
- left + 4 + used + 2,
912
+ left + 4 + pad + used + 2,
762
913
  y,
763
914
  truncate(item.detail, room - used - 2),
764
915
  onBackground(roles.muted, panel),
@@ -802,12 +953,30 @@ export function composeFrame(
802
953
  paintTooSmall(buffer, ui, { width, height });
803
954
  return { buffer, cursor: undefined, promptRows: 0, layout };
804
955
  }
805
- paintHeader(buffer, view, ui, width);
956
+ if (view.play) {
957
+ paintPlayHeader(
958
+ buffer,
959
+ layout.header,
960
+ width,
961
+ view.play,
962
+ ui.theme,
963
+ ui.capabilities.unicode,
964
+ );
965
+ if (layout.highway.height > 1) {
966
+ paintPlayStrip(buffer, layout.highway.y, width, view.play.keys, ui.theme);
967
+ layout.highway = {
968
+ y: layout.highway.y + 1,
969
+ height: layout.highway.height - 1,
970
+ };
971
+ }
972
+ } else paintHeader(buffer, view, ui, width);
806
973
  const beat = view.beat ?? resolveBeat(view.score, nowMs);
807
974
  if (layout.highway.height > 0) {
808
975
  if (ui.overlay === "log") paintOverlay(buffer, ui, layout.highway, width);
809
976
  else if (ui.overlay === "picker" && ui.picker)
810
977
  paintPicker(buffer, ui, layout.highway, width);
978
+ else if (ui.overlay === "text" && ui.text)
979
+ paintText(buffer, ui, layout.highway, width);
811
980
  else
812
981
  paintHighway(
813
982
  buffer,
@@ -864,9 +1033,10 @@ export class TuiApp {
864
1033
  capabilities: TerminalCapabilities;
865
1034
  themeName: ThemeName;
866
1035
  reducedMotion: boolean;
867
- overlay: "log" | "picker" | undefined;
1036
+ overlay: Overlay;
868
1037
  log: LogView = { scroll: 0, filter: "all" };
869
1038
  picker: PickerState | undefined;
1039
+ text: TextView | undefined;
870
1040
  /** `/view all` overlays every unmuted track; `/view focus` shows one. */
871
1041
  highwayView: "all" | "focus" = "all";
872
1042
  private writer: ScreenWriter;
@@ -902,6 +1072,7 @@ export class TuiApp {
902
1072
  overlay: this.overlay,
903
1073
  log: this.log,
904
1074
  picker: this.picker,
1075
+ text: this.text,
905
1076
  };
906
1077
  }
907
1078
 
@@ -971,6 +1142,18 @@ export class TuiApp {
971
1142
  this.closePicker();
972
1143
  return { type: "pick-cancel", picker: picker.id };
973
1144
  }
1145
+ if (picker.filterable && (value === "\u007f" || value === "\b")) {
1146
+ this.filterPicker((picker.query ?? "").slice(0, -1));
1147
+ return { type: "overlay" };
1148
+ }
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" };
1156
+ }
974
1157
  if (key.type === "text" && /^[1-9]$/.test(key.text)) {
975
1158
  const index = Number(key.text) - 1;
976
1159
  if (index < picker.items.length) {
@@ -990,6 +1173,30 @@ export class TuiApp {
990
1173
  }
991
1174
  return { type: "overlay" };
992
1175
  }
1176
+ if (this.overlay === "text" && this.text) {
1177
+ const nav = overlayKey(value);
1178
+ const page = Math.max(1, this.io.rows() - 10);
1179
+ if (nav && nav !== "enter") {
1180
+ const step =
1181
+ nav === "down"
1182
+ ? 1
1183
+ : nav === "up"
1184
+ ? -1
1185
+ : nav === "pgdn"
1186
+ ? page
1187
+ : nav === "pgup"
1188
+ ? -page
1189
+ : 0;
1190
+ const total = this.text.lines.length;
1191
+ this.text.scroll =
1192
+ nav === "home"
1193
+ ? 0
1194
+ : nav === "end"
1195
+ ? total
1196
+ : Math.max(0, Math.min(total, this.text.scroll + step));
1197
+ return { type: "overlay" };
1198
+ }
1199
+ }
993
1200
  if (this.overlay === "log") {
994
1201
  const nav = overlayKey(value);
995
1202
  const page = Math.max(1, this.io.rows() - 10);
@@ -1041,8 +1248,13 @@ export class TuiApp {
1041
1248
  }
1042
1249
  if (key.command === "redraw") this.invalidate();
1043
1250
  return { type: "ui", command: key.command };
1044
- case "prompt":
1045
- return { type: "action", action: this.prompt.handle(key.key) };
1251
+ case "prompt": {
1252
+ const action = this.prompt.handle(key.key);
1253
+ // Submitting from under a text panel closes it so the receipt shows.
1254
+ if (action.kind === "submit" && this.overlay === "text")
1255
+ this.closeText();
1256
+ return { type: "action", action };
1257
+ }
1046
1258
  case "text": {
1047
1259
  let action: PromptAction = {
1048
1260
  kind: "noop",
@@ -1069,11 +1281,42 @@ export class TuiApp {
1069
1281
  this.overlay = "picker";
1070
1282
  }
1071
1283
 
1284
+ /** Narrow a filterable picker to rows matching `query`. */
1285
+ private filterPicker(query: string): void {
1286
+ const picker = this.picker;
1287
+ if (!picker) return;
1288
+ const all = picker.all ?? picker.items;
1289
+ const needle = query.toLowerCase();
1290
+ const items = all.filter(
1291
+ (item) =>
1292
+ !needle ||
1293
+ item.label.toLowerCase().includes(needle) ||
1294
+ (item.detail ?? "").toLowerCase().includes(needle),
1295
+ );
1296
+ const keep = picker.items[picker.index]?.value;
1297
+ const index = Math.max(
1298
+ 0,
1299
+ items.findIndex((item) => item.value === keep),
1300
+ );
1301
+ this.picker = { ...picker, all, query, items, index };
1302
+ }
1303
+
1072
1304
  closePicker(): void {
1073
1305
  this.picker = undefined;
1074
1306
  if (this.overlay === "picker") this.overlay = undefined;
1075
1307
  }
1076
1308
 
1309
+ /** Show static lines over the highway (`/help`, lists); replaces any overlay. */
1310
+ openText(title: string, lines: readonly string[]): void {
1311
+ this.text = { title, lines: lines.slice(0, 512), scroll: 0 };
1312
+ this.overlay = "text";
1313
+ }
1314
+
1315
+ closeText(): void {
1316
+ this.text = undefined;
1317
+ if (this.overlay === "text") this.overlay = undefined;
1318
+ }
1319
+
1077
1320
  /**
1078
1321
  * Handle TUI-local slash commands (`/log`, `/theme`, `/motion`).
1079
1322
  * Returns a receipt, or undefined when the command is not a UI command.
package/tui/drums.ts CHANGED
@@ -3,6 +3,11 @@
3
3
  * (kick, snare, clap, rim, tom, hat, open hat) instead of by pitch.
4
4
  */
5
5
  import { DRUM_VOICES, drumLane, isDrumInstrument } from "../core/drums.ts";
6
+ import {
7
+ isSamplerInstrument,
8
+ samplerVoiceSlots,
9
+ type Track,
10
+ } from "../core/score.ts";
6
11
  import type { NoteSnapshot, TrackScoreSnapshot } from "./render.ts";
7
12
 
8
13
  export function drumSnapshotFields(
@@ -19,3 +24,42 @@ export function drumSnapshotFields(
19
24
  })),
20
25
  };
21
26
  }
27
+
28
+ /**
29
+ * Sampler adapter: oneshot samplers stream one lane per voice (named, in
30
+ * pitch-slot order from 36); keyed samplers keep the pitch axis, labelled by
31
+ * note name like any melodic track.
32
+ */
33
+ export function samplerSnapshotFields(
34
+ track: Pick<Track, "instrument" | "sampler"> | undefined,
35
+ notes: readonly NoteSnapshot[],
36
+ ): Partial<TrackScoreSnapshot> {
37
+ const lanes = samplerLanes(track);
38
+ if (!lanes) return {};
39
+ return {
40
+ laneCount: lanes.labels.length,
41
+ laneLabels: lanes.labels,
42
+ notes: notes.flatMap((note) => {
43
+ const lane = lanes.laneOf(note.pitch ?? -1);
44
+ return lane === undefined ? [] : [{ ...note, lane }];
45
+ }),
46
+ };
47
+ }
48
+
49
+ /** Oneshot sampler lanes, or undefined for keyed/non-sampler tracks. */
50
+ export function samplerLanes(
51
+ track: Pick<Track, "instrument" | "sampler"> | undefined,
52
+ ):
53
+ | { labels: string[]; laneOf: (pitch: number) => number | undefined }
54
+ | undefined {
55
+ if (!track || !isSamplerInstrument(track.instrument) || !track.sampler)
56
+ return undefined;
57
+ if (track.sampler.mode !== "oneshot") return undefined;
58
+ const slots = [...samplerVoiceSlots(track.sampler)];
59
+ if (slots.length === 0) return undefined;
60
+ const byPitch = new Map(slots.map(([, slot], index) => [slot, index]));
61
+ return {
62
+ labels: slots.map(([voice]) => voice),
63
+ laneOf: (pitch) => byPitch.get(pitch),
64
+ };
65
+ }
package/tui/highway.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { CellBuffer } from "./screen.ts";
14
+ import { truncate } from "./text.ts";
14
15
  import {
15
16
  accentStyle,
16
17
  onBackground,
@@ -472,6 +473,11 @@ export function paintHighway(
472
473
  const rowBeat = (row: number): number => beat + (hitRow - row) / rowsPerBeat;
473
474
  const legendRow = layout.legendRow;
474
475
  const lastNoteRow = (legendRow ?? region.height) - 1;
476
+ // Nothing to label yet: bar numbers and the lane legend stay off and a
477
+ // centred hint says how to start. The hit line and grid still draw.
478
+ const empty =
479
+ score.notes.length === 0 &&
480
+ !(score.layers ?? []).some((layer) => layer.notes.length > 0);
475
481
 
476
482
  // Beat, bar, and loop rules of increasing strength.
477
483
  for (let row = 0; row <= lastNoteRow; row += 1) {
@@ -497,7 +503,7 @@ export function paintHighway(
497
503
  if (!isBar && !isLoop && (column - gutter) % 2 === 1) continue;
498
504
  painter.put(column, row, glyph, style);
499
505
  }
500
- if (gutter > 0 && (isBar || isLoop)) {
506
+ if (gutter > 0 && (isBar || isLoop) && !empty) {
501
507
  const label = isLoop
502
508
  ? glyphs.loop
503
509
  : String(Math.floor(inLoop / beatsPerBar) + 1);
@@ -553,8 +559,18 @@ export function paintHighway(
553
559
  }
554
560
  }
555
561
 
562
+ if (empty) {
563
+ const name = score.trackName ?? score.trackId ?? "track";
564
+ const start = projection.kind === "pitch" ? "add C4 at 0" : "hit kick at 0";
565
+ const text = truncate(`${name} · empty · ${start} to start`, areaWidth);
566
+ const x = gutter + Math.max(0, Math.floor((areaWidth - text.length) / 2));
567
+ const y = Math.max(0, Math.floor(hitRow / 2));
568
+ for (let index = 0; index < text.length; index += 1)
569
+ painter.put(x + index, y, text[index]!, roles.muted);
570
+ }
571
+
556
572
  // Legend for drum voices (or C-octave markers for pitch lanes).
557
- if (legendRow !== undefined && projection.label) {
573
+ if (legendRow !== undefined && projection.label && !empty) {
558
574
  let nextFree = 0;
559
575
  for (let lane = 0; lane < layout.laneCount; lane += 1) {
560
576
  const label = projection.label(lane);
package/tui/layers.ts CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  MAX_LAYER_NOTES,
10
10
  type HighwayLayer,
11
11
  } from "./highway.ts";
12
+ import { samplerLanes } from "./drums.ts";
13
+ import type { Sampler } from "../core/score.ts";
12
14
 
13
15
  export interface LayerTrack {
14
16
  id: string;
@@ -16,6 +18,7 @@ export interface LayerTrack {
16
18
  instrument: string;
17
19
  muted: boolean;
18
20
  solo?: boolean | undefined;
21
+ sampler?: Sampler | undefined;
19
22
  }
20
23
 
21
24
  export interface LayerNote {
@@ -43,17 +46,24 @@ export function highwayLayers(
43
46
  let budget = MAX_LAYER_NOTES;
44
47
  return audible.map((track) => {
45
48
  const drum = isDrumInstrument(track.instrument);
49
+ const sampled = drum ? undefined : samplerLanes(track);
46
50
  const own = [];
47
51
  for (const note of notes) {
48
52
  if (note.trackId !== track.id) continue;
49
53
  if (budget <= 0) break;
54
+ const lane = drum
55
+ ? drumLane(note.pitch)
56
+ : sampled
57
+ ? sampled.laneOf(note.pitch)
58
+ : undefined;
59
+ if (sampled && lane === undefined) continue;
50
60
  budget -= 1;
51
61
  own.push({
52
62
  startBeat: note.startTick / tpb,
53
63
  durationBeats: note.durationTicks / tpb,
54
64
  pitch: note.pitch,
55
65
  velocity: note.velocity,
56
- ...(drum ? { lane: drumLane(note.pitch) } : {}),
66
+ ...(lane !== undefined ? { lane } : {}),
57
67
  });
58
68
  }
59
69
  return {
@@ -62,7 +72,9 @@ export function highwayLayers(
62
72
  notes: own,
63
73
  projection: drum
64
74
  ? drumLaneProjection(DRUM_VOICES.map((info) => info.label))
65
- : undefined,
75
+ : sampled
76
+ ? drumLaneProjection(sampled.labels)
77
+ : undefined,
66
78
  };
67
79
  });
68
80
  }