@hraness/dawg 0.2.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
package/tui/app.ts CHANGED
@@ -24,8 +24,15 @@ 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";
30
+ import {
31
+ paintChordLegend,
32
+ paintPlayHeader,
33
+ paintPlayStrip,
34
+ type PlayHeaderView,
35
+ } from "./play-strip.ts";
29
36
  import { CellBuffer, ScreenWriter, type CursorPosition } from "./screen.ts";
30
37
  import { displayWidth, truncate } from "./text.ts";
31
38
  import {
@@ -54,18 +61,45 @@ export interface AppView {
54
61
  sessionName?: string | undefined;
55
62
  /** Live windows on this session (presence); shown when more than one. */
56
63
  windows?: number | undefined;
64
+ /** Project typecheck result; `types ✓` or `types ✗ N` beside sync. */
65
+ types?: TypesIndicator | undefined;
66
+ /**
67
+ * The line under the prompt: `$0.12 session · $0.48 today · opus-5.5 ·
68
+ * gateway`, or `no model · dawg login`. The caller sizes it to the width.
69
+ */
70
+ spend?: string | undefined;
71
+ /** No agent provider: the placeholder teaches commands, no STEER pill. */
72
+ agentOffline?: boolean | undefined;
73
+ /** Play mode: replaces the header and adds the keyboard strip row. */
74
+ play?: PlayHeaderView | undefined;
57
75
  }
58
76
 
77
+ export type TypesIndicator = Readonly<{ ok: boolean; errors: number }>;
78
+
59
79
  export interface UiState {
60
80
  prompt: PromptModel;
61
81
  activity: ActivityFeed;
62
82
  theme: Theme;
63
83
  capabilities: TerminalCapabilities;
64
84
  reducedMotion: boolean;
65
- overlay: "log" | "picker" | undefined;
85
+ overlay: Overlay;
66
86
  /** Transcript scroll (entries up from the newest) and filter. */
67
87
  log?: LogView | undefined;
68
88
  picker?: PickerState | undefined;
89
+ text?: TextView | undefined;
90
+ /** The `?` panel: keys for the screen underneath, drawn over it. */
91
+ keys?: TextView | undefined;
92
+ }
93
+
94
+ /** `log` is the transcript, `picker` an arrow-key list, `text` static lines. */
95
+ export type Overlay = "log" | "picker" | "text" | undefined;
96
+
97
+ /** A scrollable read-only panel (`/help`, `/sessions`, `/tracks`). */
98
+ export interface TextView {
99
+ title: string;
100
+ lines: readonly string[];
101
+ /** Rows scrolled down from the top. */
102
+ scroll: number;
69
103
  }
70
104
 
71
105
  export type LogFilter = "all" | "requests" | "ops" | "errors";
@@ -88,6 +122,22 @@ export interface PickerState {
88
122
  title: string;
89
123
  items: readonly PickerItem[];
90
124
  index: number;
125
+ /** Type-to-filter on label and detail (`/model`). */
126
+ filterable?: boolean | undefined;
127
+ /** Current filter text; `items` is then the matching subset of `all`. */
128
+ query?: string | undefined;
129
+ /** `/` was pressed: printable keys type into the filter. */
130
+ filtering?: boolean | undefined;
131
+ all?: readonly PickerItem[] | undefined;
132
+ /** Footer keys, replacing the default move/choose/cancel hint. */
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;
91
141
  }
92
142
 
93
143
  export interface PickerItem {
@@ -95,6 +145,8 @@ export interface PickerItem {
95
145
  /** Opaque value returned on Enter. */
96
146
  value: string;
97
147
  detail?: string | undefined;
148
+ /** Marked with ● (the current model). */
149
+ current?: boolean | undefined;
98
150
  }
99
151
 
100
152
  /** Most picker rows kept; longer lists are truncated by the caller's order. */
@@ -350,6 +402,14 @@ function paintHeader(
350
402
  style: roles.text,
351
403
  priority: 1,
352
404
  });
405
+ if (view.types)
406
+ right.push({
407
+ text: view.types.ok
408
+ ? `types ${unicode ? "✓" : "ok"}`
409
+ : `types ${unicode ? "✗" : "x"} ${view.types.errors}`,
410
+ style: view.types.ok ? roles.success : roles.error,
411
+ priority: 4,
412
+ });
353
413
  const sync = syncSegment(view.sync, theme, unicode);
354
414
  if (sync) right.push(sync);
355
415
  paintSegments(buffer, 0, left, width, " · ", roles.faint, roles.panel, right);
@@ -500,9 +560,14 @@ function paintPrompt(
500
560
  const mode = prompt.snapshot.mode;
501
561
  const pillStyle = mode === "queue" ? roles.pillQueue : roles.pillSteer;
502
562
  let x = 2;
503
- x += buffer.text(x, top, pill(mode), pillStyle);
563
+ if (!view.agentOffline) x += buffer.text(x, top, pill(mode), pillStyle);
504
564
  const status: string[] = [];
505
- if (view.model) status.push(view.model);
565
+ // The model shows once, in the header; the spend line carries it here.
566
+ // Views without a spend line (embedders such as the site demo) keep the
567
+ // model label.
568
+ if (view.spend) {
569
+ if (!layout.footer) status.push(view.spend);
570
+ } else if (view.model) status.push(view.model);
506
571
  if (activity.queueDepth > 0) status.push(`queue ${activity.queueDepth}`);
507
572
  const layoutInfo = prompt.layout(rows);
508
573
  if (layoutInfo.total > rows)
@@ -541,8 +606,9 @@ function paintPrompt(
541
606
  buffer.text(width - 3, y, capabilities.unicode ? "↓" : "v", faint);
542
607
  }
543
608
  if (prompt.value.length === 0) {
544
- const placeholder =
545
- mode === "queue"
609
+ const placeholder = view.agentOffline
610
+ ? "try: tempo 96 · add C4 at 0 · /help (dawg login enables the agent)"
611
+ : mode === "queue"
546
612
  ? "queue a request for after the current one…"
547
613
  : "describe a change — “add a walking bass in A minor”";
548
614
  buffer.text(5, top + 1, truncate(placeholder, editorWidth - 1), faint);
@@ -563,8 +629,19 @@ function paintPrompt(
563
629
  : width >= 44
564
630
  ? " enter · ^q queue · ^z undo · ^o log "
565
631
  : "";
566
- const shown = hints;
632
+ const spend = view.spend ? ` ${view.spend} ` : "";
633
+ const spendWidth = displayWidth(spend);
634
+ // Spend wins over hints when both do not fit.
635
+ const shown =
636
+ hints && displayWidth(hints) + spendWidth + 6 <= width ? hints : "";
567
637
  if (shown) buffer.text(2, y, shown, onBackground(roles.muted, panel));
638
+ if (spend && spendWidth + 4 <= width)
639
+ buffer.text(
640
+ width - 2 - spendWidth,
641
+ y,
642
+ spend,
643
+ onBackground(roles.muted, panel),
644
+ );
568
645
  }
569
646
 
570
647
  // Cursor: shown as a reverse cell and as the real terminal cursor.
@@ -637,11 +714,10 @@ function paintOverlay(
637
714
  onBackground({ ...roles.text, bold: true }, panel),
638
715
  boxWidth - 4,
639
716
  );
640
- const hint = " ↑↓ pgup pgdn scroll · / filter · esc close ";
641
- const hintText = capabilities.unicode ? hint : hint.replace("↑↓", "up dn");
642
- if (boxWidth > hintText.length + 4)
717
+ const hintText = footerHint(HINTS.log, boxWidth - 4, capabilities.unicode);
718
+ if (hintText)
643
719
  buffer.text(
644
- left + boxWidth - 2 - hintText.length,
720
+ left + boxWidth - 2 - displayWidth(hintText),
645
721
  top + height - 1,
646
722
  hintText,
647
723
  onBackground(roles.muted, panel),
@@ -678,6 +754,70 @@ function paintOverlay(
678
754
  });
679
755
  }
680
756
 
757
+ /** Static lines in a bordered panel; the title sits on the top border. */
758
+ function paintText(
759
+ buffer: CellBuffer,
760
+ ui: UiState,
761
+ region: { y: number; height: number },
762
+ width: number,
763
+ options: { text?: TextView | undefined; hint?: string } = {},
764
+ ): void {
765
+ const text = options.text ?? ui.text;
766
+ if (!text) return;
767
+ const roles = ui.theme.roles;
768
+ const left = width >= 60 ? 2 : 0;
769
+ const boxWidth = width - left * 2;
770
+ const height = region.height;
771
+ if (height < 3 || boxWidth < 10) return;
772
+ const panel = paintBox(buffer, ui, {
773
+ left,
774
+ top: region.y,
775
+ width: boxWidth,
776
+ height,
777
+ });
778
+ const inner = height - 2;
779
+ const maxScroll = Math.max(0, text.lines.length - inner);
780
+ const scroll = Math.max(0, Math.min(maxScroll, text.scroll));
781
+ const position =
782
+ maxScroll > 0
783
+ ? ` · ${scroll + 1}-${scroll + inner}/${text.lines.length}`
784
+ : "";
785
+ buffer.text(
786
+ left + 2,
787
+ region.y,
788
+ ` ${text.title}${position} `,
789
+ onBackground({ ...roles.text, bold: true }, panel),
790
+ boxWidth - 4,
791
+ );
792
+ const hint = footerHint(
793
+ options.hint ?? HINTS.text,
794
+ boxWidth - 4,
795
+ ui.capabilities.unicode,
796
+ );
797
+ if (hint)
798
+ buffer.text(
799
+ left + boxWidth - 2 - displayWidth(hint),
800
+ region.y + height - 1,
801
+ hint,
802
+ onBackground(roles.muted, panel),
803
+ );
804
+ text.lines.slice(scroll, scroll + inner).forEach((line, index) => {
805
+ const heading = line.startsWith("── ");
806
+ buffer.text(
807
+ left + 2,
808
+ region.y + 1 + index,
809
+ truncate(
810
+ heading && !ui.capabilities.unicode ? line.replace("── ", "-- ") : line,
811
+ boxWidth - 4,
812
+ ),
813
+ onBackground(
814
+ heading ? { ...roles.borderFocus, bold: true } : roles.text,
815
+ panel,
816
+ ),
817
+ );
818
+ });
819
+ }
820
+
681
821
  function paintBox(
682
822
  buffer: CellBuffer,
683
823
  ui: UiState,
@@ -715,7 +855,11 @@ function paintPicker(
715
855
  const roles = ui.theme.roles;
716
856
  const left = width >= 60 ? 2 : 0;
717
857
  const boxWidth = width - left * 2;
718
- const height = Math.min(region.height, 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
+ );
719
863
  if (height < 3 || boxWidth < 10) return;
720
864
  const panel = paintBox(buffer, ui, {
721
865
  left,
@@ -726,25 +870,49 @@ function paintPicker(
726
870
  buffer.text(
727
871
  left + 2,
728
872
  region.y,
729
- ` ${picker.title} `,
873
+ ` ${picker.title}${picker.filtering || picker.query ? ` · /${picker.query ?? ""}${picker.filtering ? "▏" : ""}` : ""} `,
730
874
  onBackground({ ...roles.text, bold: true }, panel),
731
875
  boxWidth - 4,
732
876
  );
733
- const hint = ui.capabilities.unicode
734
- ? " ↑↓ move · enter choose · esc cancel "
735
- : " up/dn move · enter choose · esc cancel ";
736
- 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)
737
888
  buffer.text(
738
- left + boxWidth - 2 - hint.length,
889
+ left + boxWidth - 2 - displayWidth(hint),
739
890
  region.y + height - 1,
740
891
  hint,
741
892
  onBackground(roles.muted, panel),
742
893
  );
743
- 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
+ );
744
902
  const first = Math.max(
745
903
  0,
746
904
  Math.min(picker.items.length - inner, picker.index - inner + 1),
747
905
  );
906
+ const marks = (picker.all ?? picker.items).some(
907
+ (item) => item.current !== undefined,
908
+ );
909
+ if (picker.items.length === 0)
910
+ buffer.text(
911
+ left + 4,
912
+ region.y + 1,
913
+ "no matches",
914
+ onBackground(roles.muted, panel),
915
+ );
748
916
  picker.items.slice(first, first + inner).forEach((item, offset) => {
749
917
  const index = first + offset;
750
918
  const y = region.y + 1 + offset;
@@ -754,11 +922,25 @@ function paintPicker(
754
922
  ? onBackground({ ...roles.borderFocus, bold: true }, panel)
755
923
  : onBackground(roles.text, panel);
756
924
  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);
925
+ // A column for the current-item mark only when the picker uses one.
926
+ const pad = marks ? 2 : 0;
927
+ if (item.current)
928
+ buffer.text(
929
+ left + 4,
930
+ y,
931
+ ui.capabilities.unicode ? "●" : "*",
932
+ onBackground(roles.success, panel),
933
+ );
934
+ const room = boxWidth - 6 - pad;
935
+ const used = buffer.text(
936
+ left + 4 + pad,
937
+ y,
938
+ truncate(item.label, room),
939
+ style,
940
+ );
759
941
  if (item.detail && used + 3 < room)
760
942
  buffer.text(
761
- left + 4 + used + 2,
943
+ left + 4 + pad + used + 2,
762
944
  y,
763
945
  truncate(item.detail, room - used - 2),
764
946
  onBackground(roles.muted, panel),
@@ -766,6 +948,24 @@ function paintPicker(
766
948
  });
767
949
  }
768
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
+
769
969
  // ---------------------------------------------------------------------------
770
970
 
771
971
  function paintTooSmall(buffer: CellBuffer, ui: UiState, size: FrameSize): void {
@@ -802,12 +1002,43 @@ export function composeFrame(
802
1002
  paintTooSmall(buffer, ui, { width, height });
803
1003
  return { buffer, cursor: undefined, promptRows: 0, layout };
804
1004
  }
805
- paintHeader(buffer, view, ui, width);
1005
+ if (view.play) {
1006
+ paintPlayHeader(
1007
+ buffer,
1008
+ layout.header,
1009
+ width,
1010
+ view.play,
1011
+ ui.theme,
1012
+ ui.capabilities.unicode,
1013
+ );
1014
+ if (layout.highway.height > 1) {
1015
+ paintPlayStrip(buffer, layout.highway.y, width, view.play.keys, ui.theme);
1016
+ layout.highway = {
1017
+ y: layout.highway.y + 1,
1018
+ height: layout.highway.height - 1,
1019
+ };
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
+ }
1034
+ } else paintHeader(buffer, view, ui, width);
806
1035
  const beat = view.beat ?? resolveBeat(view.score, nowMs);
807
1036
  if (layout.highway.height > 0) {
808
1037
  if (ui.overlay === "log") paintOverlay(buffer, ui, layout.highway, width);
809
1038
  else if (ui.overlay === "picker" && ui.picker)
810
1039
  paintPicker(buffer, ui, layout.highway, width);
1040
+ else if (ui.overlay === "text" && ui.text)
1041
+ paintText(buffer, ui, layout.highway, width);
811
1042
  else
812
1043
  paintHighway(
813
1044
  buffer,
@@ -821,6 +1052,11 @@ export function composeFrame(
821
1052
  },
822
1053
  );
823
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
+ });
824
1060
  paintActivity(buffer, layout.activity, ui, width, nowMs);
825
1061
  const prompt = paintPrompt(buffer, view, ui, layout, width);
826
1062
  return { buffer, cursor: prompt.cursor, promptRows: prompt.rows, layout };
@@ -852,6 +1088,14 @@ export type AppInput =
852
1088
  | { type: "pick"; picker: string; value: string }
853
1089
  /** Esc on a picker. */
854
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" }
855
1099
  /** Consumed by an overlay (scroll, filter, move). */
856
1100
  | { type: "overlay" }
857
1101
  | { type: "none" };
@@ -864,9 +1108,10 @@ export class TuiApp {
864
1108
  capabilities: TerminalCapabilities;
865
1109
  themeName: ThemeName;
866
1110
  reducedMotion: boolean;
867
- overlay: "log" | "picker" | undefined;
1111
+ overlay: Overlay;
868
1112
  log: LogView = { scroll: 0, filter: "all" };
869
1113
  picker: PickerState | undefined;
1114
+ text: TextView | undefined;
870
1115
  /** `/view all` overlays every unmuted track; `/view focus` shows one. */
871
1116
  highwayView: "all" | "focus" = "all";
872
1117
  private writer: ScreenWriter;
@@ -902,6 +1147,8 @@ export class TuiApp {
902
1147
  overlay: this.overlay,
903
1148
  log: this.log,
904
1149
  picker: this.picker,
1150
+ text: this.text,
1151
+ keys: this.keys,
905
1152
  };
906
1153
  }
907
1154
 
@@ -950,15 +1197,17 @@ export class TuiApp {
950
1197
  if (nav === "up" || nav === "down" || nav === "pgup" || nav === "pgdn") {
951
1198
  const step =
952
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;
953
1202
  picker.index = Math.max(
954
1203
  0,
955
1204
  Math.min(picker.items.length - 1, picker.index + step),
956
1205
  );
957
- return { type: "overlay" };
1206
+ return this.pickerMoved(picker);
958
1207
  }
959
1208
  if (nav === "home" || nav === "end") {
960
1209
  picker.index = nav === "home" ? 0 : picker.items.length - 1;
961
- return { type: "overlay" };
1210
+ return this.pickerMoved(picker);
962
1211
  }
963
1212
  if (nav === "enter") {
964
1213
  const item = picker.items[picker.index];
@@ -968,18 +1217,51 @@ export class TuiApp {
968
1217
  : { type: "pick-cancel", picker: picker.id };
969
1218
  }
970
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
+ }
971
1226
  this.closePicker();
972
1227
  return { type: "pick-cancel", picker: picker.id };
973
1228
  }
974
- if (key.type === "text" && /^[1-9]$/.test(key.text)) {
975
- const index = Number(key.text) - 1;
976
- if (index < picker.items.length) {
977
- const item = picker.items[index]!;
978
- this.closePicker();
979
- return { type: "pick", picker: picker.id, value: item.value };
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" };
980
1239
  }
1240
+ }
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 };
1252
+ }
1253
+ if (picker.filterable && key.type === "text" && key.text === "/") {
1254
+ picker.filtering = true;
981
1255
  return { type: "overlay" };
982
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
+ }
983
1265
  // Quit and redraw still work; everything else is swallowed.
984
1266
  if (
985
1267
  key.type === "ui" &&
@@ -990,6 +1272,30 @@ export class TuiApp {
990
1272
  }
991
1273
  return { type: "overlay" };
992
1274
  }
1275
+ if (this.overlay === "text" && this.text) {
1276
+ const nav = overlayKey(value);
1277
+ const page = Math.max(1, this.io.rows() - 10);
1278
+ if (nav && nav !== "enter") {
1279
+ const step =
1280
+ nav === "down"
1281
+ ? 1
1282
+ : nav === "up"
1283
+ ? -1
1284
+ : nav === "pgdn"
1285
+ ? page
1286
+ : nav === "pgup"
1287
+ ? -page
1288
+ : 0;
1289
+ const total = this.text.lines.length;
1290
+ this.text.scroll =
1291
+ nav === "home"
1292
+ ? 0
1293
+ : nav === "end"
1294
+ ? total
1295
+ : Math.max(0, Math.min(total, this.text.scroll + step));
1296
+ return { type: "overlay" };
1297
+ }
1298
+ }
993
1299
  if (this.overlay === "log") {
994
1300
  const nav = overlayKey(value);
995
1301
  const page = Math.max(1, this.io.rows() - 10);
@@ -1041,8 +1347,13 @@ export class TuiApp {
1041
1347
  }
1042
1348
  if (key.command === "redraw") this.invalidate();
1043
1349
  return { type: "ui", command: key.command };
1044
- case "prompt":
1045
- return { type: "action", action: this.prompt.handle(key.key) };
1350
+ case "prompt": {
1351
+ const action = this.prompt.handle(key.key);
1352
+ // Submitting from under a text panel closes it so the receipt shows.
1353
+ if (action.kind === "submit" && this.overlay === "text")
1354
+ this.closeText();
1355
+ return { type: "action", action };
1356
+ }
1046
1357
  case "text": {
1047
1358
  let action: PromptAction = {
1048
1359
  kind: "noop",
@@ -1058,6 +1369,14 @@ export class TuiApp {
1058
1369
  }
1059
1370
 
1060
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
+
1061
1380
  openPicker(picker: Omit<PickerState, "index"> & { index?: number }): void {
1062
1381
  const items = picker.items.slice(0, MAX_PICKER_ITEMS);
1063
1382
  if (items.length === 0) return;
@@ -1069,11 +1388,57 @@ export class TuiApp {
1069
1388
  this.overlay = "picker";
1070
1389
  }
1071
1390
 
1391
+ /** Narrow a filterable picker to rows matching `query`. */
1392
+ private filterPicker(query: string): void {
1393
+ const picker = this.picker;
1394
+ if (!picker) return;
1395
+ const all = picker.all ?? picker.items;
1396
+ const needle = query.toLowerCase();
1397
+ const items = all.filter(
1398
+ (item) =>
1399
+ !needle ||
1400
+ item.label.toLowerCase().includes(needle) ||
1401
+ (item.detail ?? "").toLowerCase().includes(needle),
1402
+ );
1403
+ const keep = picker.items[picker.index]?.value;
1404
+ const index = Math.max(
1405
+ 0,
1406
+ items.findIndex((item) => item.value === keep),
1407
+ );
1408
+ this.picker = { ...picker, all, query, items, index };
1409
+ }
1410
+
1072
1411
  closePicker(): void {
1073
1412
  this.picker = undefined;
1074
1413
  if (this.overlay === "picker") this.overlay = undefined;
1075
1414
  }
1076
1415
 
1416
+ /** Show static lines over the highway (`/help`, lists); replaces any overlay. */
1417
+ openText(title: string, lines: readonly string[]): void {
1418
+ this.text = { title, lines: lines.slice(0, 512), scroll: 0 };
1419
+ this.overlay = "text";
1420
+ }
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
+
1437
+ closeText(): void {
1438
+ this.text = undefined;
1439
+ if (this.overlay === "text") this.overlay = undefined;
1440
+ }
1441
+
1077
1442
  /**
1078
1443
  * Handle TUI-local slash commands (`/log`, `/theme`, `/motion`).
1079
1444
  * Returns a receipt, or undefined when the command is not a UI command.