@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.
- package/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- 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-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
758
|
-
const
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
:
|
|
75
|
+
: sampled
|
|
76
|
+
? drumLaneProjection(sampled.labels)
|
|
77
|
+
: undefined,
|
|
66
78
|
};
|
|
67
79
|
});
|
|
68
80
|
}
|