@hraness/dawg 0.6.1 → 0.8.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 +118 -0
- package/DAWG.md +454 -140
- package/README.md +30 -26
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +184 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/ids.ts +52 -0
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +8 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/range.ts +581 -0
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +955 -14
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +287 -5
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +2132 -46
- package/core/sections.ts +480 -35
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +31 -0
- package/guides/audio.md +31 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +15 -7
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/panes.md +32 -0
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +26 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +18 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tape.md +32 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +6 -2
- package/src/agent/agent.ts +130 -18
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +378 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +27 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +25 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +110 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +13 -10
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +49 -5
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +157 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +360 -40
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +82 -32
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +560 -0
- package/src/commands/help.ts +774 -366
- package/src/commands/history.ts +139 -10
- package/src/commands/keys.ts +25 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +135 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +474 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +617 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +511 -0
- package/src/launch-args.ts +267 -0
- package/src/main.ts +2508 -308
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +199 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +174 -0
- package/src/session/port.ts +109 -14
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +365 -7
- package/src/session/rebase.ts +63 -12
- package/src/session/receipt.ts +265 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +126 -45
- package/src/tui/arrange-menu.ts +225 -23
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +113 -27
- package/src/tui/fader.ts +282 -42
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +987 -179
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +243 -28
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +491 -84
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +193 -0
- package/tui/drawer.ts +233 -27
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +143 -65
- package/tui/guide.ts +50 -5
- package/tui/highway.ts +309 -30
- package/tui/hints.ts +197 -0
- package/tui/hits.ts +7 -1
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +80 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +151 -18
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +46 -1
package/tui/app.ts
CHANGED
|
@@ -12,21 +12,34 @@
|
|
|
12
12
|
* injectable `TerminalIO`.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { paintTape, type TapeView } from "./tape.ts";
|
|
15
16
|
import {
|
|
16
17
|
ActivityFeed,
|
|
18
|
+
ERROR_FADE_MS,
|
|
19
|
+
fail,
|
|
17
20
|
revisionLabel,
|
|
18
21
|
spinnerFrame,
|
|
19
22
|
type ActivityCard,
|
|
23
|
+
type Receipt,
|
|
20
24
|
type TranscriptEntry,
|
|
21
25
|
} from "./activity.ts";
|
|
22
26
|
import {
|
|
23
27
|
paintHighway,
|
|
28
|
+
projectionFor,
|
|
24
29
|
resolveBeat,
|
|
25
30
|
type TrackScoreSnapshot,
|
|
26
31
|
} from "./highway.ts";
|
|
32
|
+
import { placeholderHint } from "./hints.ts";
|
|
33
|
+
import {
|
|
34
|
+
Delight,
|
|
35
|
+
downbeatGlint,
|
|
36
|
+
FIRST_LOOP_CARD,
|
|
37
|
+
sweepProgress,
|
|
38
|
+
} from "./delight.ts";
|
|
27
39
|
import { asciiHint, fitHint, HINTS } from "./grammar.ts";
|
|
28
40
|
import { GuideBrowser } from "./guide.ts";
|
|
29
41
|
import { listGuides } from "../guides/index.ts";
|
|
42
|
+
import { topicMiss } from "../src/lang/glossary.ts";
|
|
30
43
|
import { classifyKey, overlayKey, type UiCommand } from "./keys.ts";
|
|
31
44
|
import { PromptModel, type PromptAction, type PromptMode } from "./prompt.ts";
|
|
32
45
|
import {
|
|
@@ -64,19 +77,29 @@ export interface AppView {
|
|
|
64
77
|
sync?: SyncState | undefined;
|
|
65
78
|
/** Human session name; replaces the short id in the header when set. */
|
|
66
79
|
sessionName?: string | undefined;
|
|
67
|
-
/** Live
|
|
80
|
+
/** Live panes on this session (presence); shown when more than one. */
|
|
68
81
|
windows?: number | undefined;
|
|
82
|
+
/** This pane's letter (dawgd), shown beside the count: `B ⧉3` (`B 3 panes` in ASCII). */
|
|
83
|
+
pane?: string | undefined;
|
|
69
84
|
/** Project typecheck result; `types ✓` or `types ✗ N` beside sync. */
|
|
70
85
|
types?: TypesIndicator | undefined;
|
|
71
86
|
/**
|
|
72
87
|
* The line under the prompt: `$0.12 session · $0.48 today · opus-5.5 ·
|
|
73
|
-
* gateway`, or `
|
|
88
|
+
* gateway`, or `commands only`. The caller sizes it to the width.
|
|
74
89
|
*/
|
|
75
90
|
spend?: string | undefined;
|
|
76
|
-
/** No agent provider: the placeholder teaches commands, no
|
|
91
|
+
/** No agent provider: the placeholder teaches commands, no NOW pill. */
|
|
77
92
|
agentOffline?: boolean | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Show-me: the command the agent is writing, as it streams (ghost text in
|
|
95
|
+
* an empty prompt bar), and the caption naming the human gesture.
|
|
96
|
+
*/
|
|
97
|
+
showMe?:
|
|
98
|
+
{ ghost?: string | undefined; caption?: string | undefined } | undefined;
|
|
78
99
|
/** Play mode: replaces the header and adds the keyboard strip row. */
|
|
79
100
|
play?: PlayHeaderView | undefined;
|
|
101
|
+
/** TAPE (op1-ux §6): replaces the highway and the arrange strip. */
|
|
102
|
+
tape?: TapeView | undefined;
|
|
80
103
|
/** Song master meter: integrated LUFS and true peak of the playing loop. */
|
|
81
104
|
loudness?: LoudnessView | undefined;
|
|
82
105
|
/** Song sections over the timeline (0.5); no row when absent. */
|
|
@@ -136,6 +159,8 @@ export interface UiState {
|
|
|
136
159
|
guide?: GuideBrowser | undefined;
|
|
137
160
|
/** The fader drawer, docked over the bottom of the piano roll. */
|
|
138
161
|
drawer?: DrawerView | undefined;
|
|
162
|
+
/** Downbeat glint, first-loop sweep and ghost-note lanes (delight.ts). */
|
|
163
|
+
delight?: Delight | undefined;
|
|
139
164
|
}
|
|
140
165
|
|
|
141
166
|
/**
|
|
@@ -247,8 +272,40 @@ export interface FrameLayout {
|
|
|
247
272
|
tooSmall: boolean;
|
|
248
273
|
}
|
|
249
274
|
|
|
250
|
-
|
|
251
|
-
|
|
275
|
+
/**
|
|
276
|
+
* The smallest terminal the real UI draws in (DAWG.md "Terminal
|
|
277
|
+
* sizes"). Measured with `bun run sizes`: below 60 columns the play-mode
|
|
278
|
+
* key strip and the header's bar position drop out and the prompt's spend
|
|
279
|
+
* line clips; below 16 rows the prompt loses its footer and the drawer its
|
|
280
|
+
* last knob. 80x24 stays the design target.
|
|
281
|
+
*/
|
|
282
|
+
export const MIN_WIDTH = 60;
|
|
283
|
+
export const MIN_HEIGHT = 16;
|
|
284
|
+
|
|
285
|
+
/** True when a terminal of this size shows the too-small screen instead. */
|
|
286
|
+
export function isTooSmall(size: FrameSize): boolean {
|
|
287
|
+
return !(size.width >= MIN_WIDTH && size.height >= MIN_HEIGHT);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The longest line a text panel (help, guides, the transcript, menus) sets:
|
|
291
|
+
* past about 100 columns prose stops being readable and a command table's
|
|
292
|
+
* two columns drift apart, so on a wide terminal the panel holds this
|
|
293
|
+
* measure, left-aligned, and the song keeps drawing beside it (see
|
|
294
|
+
* `SIDE_MIN_WIDTH`).
|
|
295
|
+
*/
|
|
296
|
+
export const TEXT_MEASURE = 100;
|
|
297
|
+
/** The fewest columns beside a capped panel worth drawing the view into. */
|
|
298
|
+
export const SIDE_MIN_WIDTH = 36;
|
|
299
|
+
|
|
300
|
+
/** Where a text panel sits in a terminal `width` columns wide. */
|
|
301
|
+
export function panelRect(
|
|
302
|
+
width: number,
|
|
303
|
+
measure = TEXT_MEASURE,
|
|
304
|
+
): { left: number; boxWidth: number } {
|
|
305
|
+
const left = width >= 60 ? 2 : 0;
|
|
306
|
+
return { left, boxWidth: Math.min(width - left * 2, measure + 4) };
|
|
307
|
+
}
|
|
308
|
+
|
|
252
309
|
export const MAX_PROMPT_ROWS = 8;
|
|
253
310
|
/** New cards glow for this long before settling. */
|
|
254
311
|
export const CARD_GLOW_MS = 700;
|
|
@@ -286,7 +343,7 @@ export function computeLayout(
|
|
|
286
343
|
size: FrameSize,
|
|
287
344
|
promptWrappedRows: number,
|
|
288
345
|
): FrameLayout {
|
|
289
|
-
const tooSmall = size
|
|
346
|
+
const tooSmall = isTooSmall(size);
|
|
290
347
|
const rows = Math.max(
|
|
291
348
|
1,
|
|
292
349
|
Math.min(promptWrappedRows, promptRowCap(size.height)),
|
|
@@ -412,52 +469,135 @@ function syncSegment(
|
|
|
412
469
|
return { text: `${glyph[sync]} ${sync}`, style: style[sync], priority: 4 };
|
|
413
470
|
}
|
|
414
471
|
|
|
472
|
+
/**
|
|
473
|
+
* The song header compressed for play mode's row: transport, tempo, the
|
|
474
|
+
* track and the revision (`▶ 120 BPM · drums · rev 1`).
|
|
475
|
+
*/
|
|
476
|
+
export function playContext(
|
|
477
|
+
score: TrackScoreSnapshot,
|
|
478
|
+
unicode: boolean,
|
|
479
|
+
): string {
|
|
480
|
+
const playing = score.playing === true;
|
|
481
|
+
const glyph = playing ? (unicode ? "▶" : ">") : unicode ? "⏸" : "||";
|
|
482
|
+
const name = score.trackName ?? score.trackId ?? "track";
|
|
483
|
+
return [
|
|
484
|
+
`${glyph} ${score.bpm ?? 120} BPM`,
|
|
485
|
+
name,
|
|
486
|
+
...(score.revision !== undefined ? [`rev ${score.revision}`] : []),
|
|
487
|
+
].join(" · ");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** `5.3`: the 1-based bar and beat of a song beat. */
|
|
491
|
+
export function barBeatLabel(
|
|
492
|
+
beat: number,
|
|
493
|
+
beatsPerBar: number,
|
|
494
|
+
barBeats?: readonly number[],
|
|
495
|
+
): string {
|
|
496
|
+
const at = Number.isFinite(beat) ? Math.max(0, beat) : 0;
|
|
497
|
+
if (barBeats && barBeats.length > 0) {
|
|
498
|
+
let bar = 0;
|
|
499
|
+
while (bar + 1 < barBeats.length && barBeats[bar + 1]! <= at + 1e-9)
|
|
500
|
+
bar += 1;
|
|
501
|
+
return `${bar + 1}.${Math.floor(at - barBeats[bar]! + 1e-9) + 1}`;
|
|
502
|
+
}
|
|
503
|
+
const per = Math.max(1, beatsPerBar);
|
|
504
|
+
const bar = Math.floor(at / per + 1e-9);
|
|
505
|
+
return `${bar + 1}.${Math.floor(at - bar * per + 1e-9) + 1}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** `↻ 5–6` (`loop 5-6` without Unicode): the loop range, 1-based bars. */
|
|
509
|
+
export function loopRangeLabel(
|
|
510
|
+
range: Readonly<{ startBar: number; bars: number }>,
|
|
511
|
+
unicode: boolean,
|
|
512
|
+
): string {
|
|
513
|
+
const first = range.startBar + 1;
|
|
514
|
+
const last = range.startBar + range.bars;
|
|
515
|
+
const bars =
|
|
516
|
+
first === last ? `${first}` : `${first}${unicode ? "–" : "-"}${last}`;
|
|
517
|
+
return unicode ? `↻ ${bars}` : `loop ${bars}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** `B ⧉3`: this pane's letter and how many panes are live. */
|
|
521
|
+
export function paneLabel(
|
|
522
|
+
windows: number,
|
|
523
|
+
pane: string | undefined,
|
|
524
|
+
unicode: boolean,
|
|
525
|
+
): string {
|
|
526
|
+
const count = unicode ? `⧉${windows}` : `${windows} panes`;
|
|
527
|
+
return pane ? `${pane} ${count}` : count;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The footer's right side: the session name, then the spend line. */
|
|
531
|
+
export function footerStatus(view: AppView, sessionId?: string): string {
|
|
532
|
+
const session =
|
|
533
|
+
view.sessionName ??
|
|
534
|
+
(sessionId ? `session ${sessionId.slice(0, 8)}` : undefined);
|
|
535
|
+
return [session, view.spend].filter(Boolean).join(" · ");
|
|
536
|
+
}
|
|
537
|
+
|
|
415
538
|
function paintHeader(
|
|
416
539
|
buffer: CellBuffer,
|
|
417
540
|
view: AppView,
|
|
418
541
|
ui: UiState,
|
|
419
542
|
width: number,
|
|
420
543
|
hits?: HitMap,
|
|
544
|
+
beat = 0,
|
|
421
545
|
): void {
|
|
422
546
|
const { theme, capabilities } = ui;
|
|
423
547
|
const roles = theme.roles;
|
|
424
548
|
const score = view.score;
|
|
425
549
|
const unicode = capabilities.unicode;
|
|
426
550
|
const playing = score.playing === true;
|
|
551
|
+
// The ▶ brightens for a moment on each bar's downbeat.
|
|
552
|
+
const glint = downbeatGlint({
|
|
553
|
+
playing,
|
|
554
|
+
beat,
|
|
555
|
+
bpm: score.bpm ?? 120,
|
|
556
|
+
beatsPerBar: score.beatsPerBar ?? 4,
|
|
557
|
+
barBeats: score.barBeats,
|
|
558
|
+
loopBeats: score.loopBeats,
|
|
559
|
+
reducedMotion: ui.reducedMotion,
|
|
560
|
+
});
|
|
427
561
|
const transport = `${playing ? (unicode ? "▶" : ">") : unicode ? "⏸" : "||"} ${score.bpm ?? 120} BPM`;
|
|
428
562
|
const name = score.trackName ?? score.trackId ?? "track";
|
|
563
|
+
// dawg · ▶ BPM · bar.beat · ↻ loop · track ··· key · model · rev · pane
|
|
564
|
+
// (design §8.2): where you are in the song reads first; the session name
|
|
565
|
+
// lives in the footer.
|
|
429
566
|
const left: Segment[] = [
|
|
430
567
|
{ text: "dawg", style: { ...roles.muted, bold: true }, priority: 6 },
|
|
431
|
-
{
|
|
432
|
-
text: name,
|
|
433
|
-
style: { ...accentStyle(theme, score.trackId ?? name), bold: true },
|
|
434
|
-
priority: 0,
|
|
435
|
-
target: { kind: "tracks" },
|
|
436
|
-
},
|
|
437
568
|
{
|
|
438
569
|
text: transport,
|
|
439
|
-
style: playing
|
|
570
|
+
style: playing
|
|
571
|
+
? glint
|
|
572
|
+
? { ...shade(roles.transport, 0.55), bold: true }
|
|
573
|
+
: roles.transport
|
|
574
|
+
: roles.paused,
|
|
440
575
|
priority: 0,
|
|
441
576
|
target: { kind: "transport" },
|
|
442
577
|
},
|
|
578
|
+
{
|
|
579
|
+
text: barBeatLabel(beat, score.beatsPerBar ?? 4, score.barBeats),
|
|
580
|
+
style: roles.text,
|
|
581
|
+
priority: 2,
|
|
582
|
+
},
|
|
443
583
|
];
|
|
444
|
-
if (score.
|
|
445
|
-
left.push({ text: score.key, style: roles.muted, priority: 5 });
|
|
446
|
-
if (view.sessionName)
|
|
447
|
-
left.push({ text: view.sessionName, style: roles.muted, priority: 3 });
|
|
448
|
-
else if (score.sessionId)
|
|
584
|
+
if (score.loopRange)
|
|
449
585
|
left.push({
|
|
450
|
-
text:
|
|
451
|
-
|
|
586
|
+
text: loopRangeLabel(score.loopRange, unicode),
|
|
587
|
+
// The loop is the green knob's (§8.1): it wears that colour.
|
|
588
|
+
style: roles.knob2,
|
|
452
589
|
priority: 3,
|
|
453
590
|
});
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
591
|
+
left.push({
|
|
592
|
+
text: name,
|
|
593
|
+
style: { ...accentStyle(theme, score.trackId ?? name), bold: true },
|
|
594
|
+
priority: 0,
|
|
595
|
+
target: { kind: "tracks" },
|
|
596
|
+
});
|
|
460
597
|
const right: Segment[] = [];
|
|
598
|
+
// The key never drops before the model, rev or sync at 80 columns.
|
|
599
|
+
if (score.key)
|
|
600
|
+
right.push({ text: score.key, style: roles.muted, priority: 1 });
|
|
461
601
|
if (view.model)
|
|
462
602
|
right.push({
|
|
463
603
|
text: view.model,
|
|
@@ -471,6 +611,12 @@ function paintHeader(
|
|
|
471
611
|
style: roles.text,
|
|
472
612
|
priority: 1,
|
|
473
613
|
});
|
|
614
|
+
if (view.windows !== undefined && view.windows > 1)
|
|
615
|
+
right.push({
|
|
616
|
+
text: paneLabel(view.windows, view.pane, unicode),
|
|
617
|
+
style: roles.muted,
|
|
618
|
+
priority: 4,
|
|
619
|
+
});
|
|
474
620
|
if (view.types)
|
|
475
621
|
right.push({
|
|
476
622
|
text: view.types.ok
|
|
@@ -527,7 +673,9 @@ function cardMarker(card: ActivityCard, unicode: boolean): string {
|
|
|
527
673
|
}
|
|
528
674
|
|
|
529
675
|
export function cardText(card: ActivityCard, unicode: boolean): string {
|
|
530
|
-
const
|
|
676
|
+
const count =
|
|
677
|
+
card.count && card.count > 1 ? ` ${unicode ? "×" : "x"}${card.count}` : "";
|
|
678
|
+
const parts = [`${cardMarker(card, unicode)} ${card.text}${count}`];
|
|
531
679
|
const revision = revisionLabel(
|
|
532
680
|
card.baseRevision,
|
|
533
681
|
card.resultRevision,
|
|
@@ -535,15 +683,46 @@ export function cardText(card: ActivityCard, unicode: boolean): string {
|
|
|
535
683
|
);
|
|
536
684
|
if (revision) parts.push(revision);
|
|
537
685
|
if (card.hint) parts.push(card.hint);
|
|
686
|
+
if (card.suffix) parts.push(card.suffix);
|
|
538
687
|
return parts.join(" · ");
|
|
539
688
|
}
|
|
540
689
|
|
|
690
|
+
/** How long each one-time note leads when they cannot all fit at once. */
|
|
691
|
+
export const ONCE_ROTATE_MS = 3_000;
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* The cards for the strip's width: when the one-time notes cannot all show
|
|
695
|
+
* whole beside the receipts, they take turns (one every `ONCE_ROTATE_MS`,
|
|
696
|
+
* on the frame clock), so each is read in full at least once.
|
|
697
|
+
*/
|
|
698
|
+
export function stripCards(
|
|
699
|
+
cards: readonly ActivityCard[],
|
|
700
|
+
room: number,
|
|
701
|
+
nowMs: number,
|
|
702
|
+
unicode: boolean,
|
|
703
|
+
): ActivityCard[] {
|
|
704
|
+
const width = (list: readonly ActivityCard[]) =>
|
|
705
|
+
list.reduce(
|
|
706
|
+
(sum, card, index) =>
|
|
707
|
+
sum + (index > 0 ? 3 : 0) + displayWidth(cardText(card, unicode)),
|
|
708
|
+
0,
|
|
709
|
+
);
|
|
710
|
+
if (width(cards) <= room) return [...cards];
|
|
711
|
+
const steady = cards.filter((card) => !card.once);
|
|
712
|
+
const once = cards.filter((card) => card.once);
|
|
713
|
+
if (once.length < 2) return [...cards];
|
|
714
|
+
const turn =
|
|
715
|
+
once[Math.floor(Math.max(0, nowMs) / ONCE_ROTATE_MS) % once.length]!;
|
|
716
|
+
return [...steady, turn];
|
|
717
|
+
}
|
|
718
|
+
|
|
541
719
|
function paintActivity(
|
|
542
720
|
buffer: CellBuffer,
|
|
543
721
|
y: number,
|
|
544
722
|
ui: UiState,
|
|
545
723
|
width: number,
|
|
546
724
|
nowMs: number,
|
|
725
|
+
agentOffline = false,
|
|
547
726
|
): void {
|
|
548
727
|
const { theme, capabilities, activity } = ui;
|
|
549
728
|
const roles = theme.roles;
|
|
@@ -567,9 +746,10 @@ function paintActivity(
|
|
|
567
746
|
...roles.agent,
|
|
568
747
|
bold: true,
|
|
569
748
|
});
|
|
570
|
-
|
|
749
|
+
// The agent's latest sentence, faint: what it is doing, in its words.
|
|
750
|
+
if (activity.streamSentence && x < limit - 4) {
|
|
571
751
|
x += buffer.text(x, y, " · ", roles.faint);
|
|
572
|
-
const tail = activity.
|
|
752
|
+
const tail = activity.streamSentence;
|
|
573
753
|
const room = Math.max(0, limit - x);
|
|
574
754
|
const shown =
|
|
575
755
|
displayWidth(tail) > room
|
|
@@ -577,13 +757,24 @@ function paintActivity(
|
|
|
577
757
|
.slice(-(room - 1))
|
|
578
758
|
.join("")}`
|
|
579
759
|
: tail;
|
|
580
|
-
x += buffer.text(x, y, truncate(shown, room), roles.
|
|
760
|
+
x += buffer.text(x, y, truncate(shown, room), roles.faint);
|
|
581
761
|
}
|
|
582
762
|
} else {
|
|
583
|
-
const cards =
|
|
763
|
+
const cards = stripCards(
|
|
764
|
+
activity.visible(nowMs),
|
|
765
|
+
Math.max(0, limit - x),
|
|
766
|
+
nowMs,
|
|
767
|
+
capabilities.unicode,
|
|
768
|
+
);
|
|
584
769
|
cards.forEach((card, index) => {
|
|
585
770
|
if (x >= limit) return;
|
|
586
771
|
const text = cardText(card, capabilities.unicode);
|
|
772
|
+
// A one-time note behind the first slot shows whole or not at all
|
|
773
|
+
// (it stays in ctrl-o); a cut note loses its key word.
|
|
774
|
+
if (index > 0 && card.once && x + 3 + displayWidth(text) > limit) {
|
|
775
|
+
x = limit;
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
587
778
|
if (index > 0) {
|
|
588
779
|
if (x + 3 + Math.min(12, displayWidth(text)) > limit) {
|
|
589
780
|
x = limit;
|
|
@@ -602,7 +793,14 @@ function paintActivity(
|
|
|
602
793
|
: card.tone === "success"
|
|
603
794
|
? roles.success
|
|
604
795
|
: roles.text;
|
|
605
|
-
|
|
796
|
+
// Behind the first slot everything is faint; an error keeps its
|
|
797
|
+
// colour until it is old, then fades with the rest.
|
|
798
|
+
if (index > 0)
|
|
799
|
+
style =
|
|
800
|
+
card.tone === "error" && age < ERROR_FADE_MS
|
|
801
|
+
? roles.error
|
|
802
|
+
: roles.faint;
|
|
803
|
+
else if (card.once) style = roles.faint;
|
|
606
804
|
else if (!ui.reducedMotion && age >= 0 && age < CARD_GLOW_MS)
|
|
607
805
|
style = { ...shade(style, 0.4 * (1 - age / CARD_GLOW_MS)), bold: true };
|
|
608
806
|
x += buffer.text(x, y, truncate(text, Math.max(0, limit - x)), style);
|
|
@@ -612,7 +810,9 @@ function paintActivity(
|
|
|
612
810
|
x,
|
|
613
811
|
y,
|
|
614
812
|
truncate(
|
|
615
|
-
|
|
813
|
+
agentOffline
|
|
814
|
+
? "type a command · space plays on an empty prompt · /help"
|
|
815
|
+
: "type a request · space plays on an empty prompt · /help",
|
|
616
816
|
limit - x,
|
|
617
817
|
),
|
|
618
818
|
roles.faint,
|
|
@@ -626,7 +826,7 @@ function paintActivity(
|
|
|
626
826
|
// Prompt panel
|
|
627
827
|
|
|
628
828
|
function pill(mode: PromptMode): string {
|
|
629
|
-
return mode === "queue" ? "
|
|
829
|
+
return mode === "queue" ? " NEXT " : " NOW ";
|
|
630
830
|
}
|
|
631
831
|
|
|
632
832
|
function paintPrompt(
|
|
@@ -661,6 +861,7 @@ function paintPrompt(
|
|
|
661
861
|
if (view.spend) {
|
|
662
862
|
if (!layout.footer) status.push(view.spend);
|
|
663
863
|
} else if (view.model) status.push(view.model);
|
|
864
|
+
if (!layout.footer && view.sessionName) status.unshift(view.sessionName);
|
|
664
865
|
if (activity.queueDepth > 0) status.push(`queue ${activity.queueDepth}`);
|
|
665
866
|
const layoutInfo = prompt.layout(rows);
|
|
666
867
|
if (layoutInfo.total > rows)
|
|
@@ -698,12 +899,27 @@ function paintPrompt(
|
|
|
698
899
|
if (index === rows - 1 && layoutInfo.first + rows < layoutInfo.total)
|
|
699
900
|
buffer.text(width - 3, y, capabilities.unicode ? "↓" : "v", faint);
|
|
700
901
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
902
|
+
const ghost = view.showMe?.ghost;
|
|
903
|
+
const caption = view.showMe?.caption;
|
|
904
|
+
if (prompt.value.length === 0 && (ghost || caption)) {
|
|
905
|
+
// The agent's command as it streams, then the gesture it stands for.
|
|
906
|
+
const typed = ghost ? `${ghost}${capabilities.unicode ? "▏" : "_"}` : "";
|
|
907
|
+
const line = typed && caption ? `${typed} ${caption}` : typed || caption!;
|
|
908
|
+
buffer.text(5, top + 1, truncate(line, editorWidth - 1), faint);
|
|
909
|
+
} else if (prompt.value.length === 0) {
|
|
910
|
+
// Seeded per session (tui/hints.ts): the words stay put while you
|
|
911
|
+
// work and change between sessions, chosen by agent and song state.
|
|
912
|
+
const score = view.score;
|
|
913
|
+
const placeholder = placeholderHint({
|
|
914
|
+
seed: score.sessionId ?? "dawg",
|
|
915
|
+
agent: !view.agentOffline,
|
|
916
|
+
filled:
|
|
917
|
+
score.notes.length > 0 ||
|
|
918
|
+
(score.clips ?? []).length > 0 ||
|
|
919
|
+
(score.layers ?? []).some((layer) => layer.notes.length > 0),
|
|
920
|
+
drums: projectionFor(score).kind !== "pitch",
|
|
921
|
+
queue: mode === "queue",
|
|
922
|
+
});
|
|
707
923
|
buffer.text(5, top + 1, truncate(placeholder, editorWidth - 1), faint);
|
|
708
924
|
}
|
|
709
925
|
|
|
@@ -714,15 +930,17 @@ function paintPrompt(
|
|
|
714
930
|
for (let column = 1; column < width - 1; column += 1)
|
|
715
931
|
buffer.set(column, y, box.h, border);
|
|
716
932
|
buffer.set(width - 1, y, box.br, border);
|
|
933
|
+
// One notation everywhere: ctrl-<key>, as /help and the menu spell it.
|
|
717
934
|
const hints =
|
|
718
|
-
width >=
|
|
719
|
-
? " enter send · shift
|
|
720
|
-
: width >=
|
|
721
|
-
? " enter send ·
|
|
722
|
-
: width >=
|
|
723
|
-
? " enter ·
|
|
935
|
+
width >= 112
|
|
936
|
+
? " enter send · shift-enter newline · ctrl-q now/next · ctrl-z undo · ctrl-o log · ctrl-c quit "
|
|
937
|
+
: width >= 80
|
|
938
|
+
? " enter send · ctrl-j newline · ctrl-q now/next · ctrl-z undo · ctrl-o log "
|
|
939
|
+
: width >= 52
|
|
940
|
+
? " enter · ctrl-q now/next · ctrl-z undo · ctrl-o log "
|
|
724
941
|
: "";
|
|
725
|
-
const
|
|
942
|
+
const right = footerStatus(view, view.score.sessionId);
|
|
943
|
+
const spend = right ? ` ${right} ` : "";
|
|
726
944
|
const spendWidth = displayWidth(spend);
|
|
727
945
|
// Spend wins over hints when both do not fit.
|
|
728
946
|
const shown =
|
|
@@ -773,8 +991,7 @@ function paintOverlay(
|
|
|
773
991
|
const box = capabilities.unicode ? UNICODE_BOX : ASCII_BOX;
|
|
774
992
|
const panel = roles.panel;
|
|
775
993
|
const border = onBackground(roles.border, panel);
|
|
776
|
-
const left = width
|
|
777
|
-
const boxWidth = width - left * 2;
|
|
994
|
+
const { left, boxWidth } = panelRect(width);
|
|
778
995
|
const top = region.y;
|
|
779
996
|
const height = region.height;
|
|
780
997
|
if (height < 3 || boxWidth < 10) return;
|
|
@@ -858,8 +1075,7 @@ function paintText(
|
|
|
858
1075
|
const text = options.text ?? ui.text;
|
|
859
1076
|
if (!text) return;
|
|
860
1077
|
const roles = ui.theme.roles;
|
|
861
|
-
const left = width
|
|
862
|
-
const boxWidth = width - left * 2;
|
|
1078
|
+
const { left, boxWidth } = panelRect(width);
|
|
863
1079
|
const height = region.height;
|
|
864
1080
|
if (height < 3 || boxWidth < 10) return;
|
|
865
1081
|
const panel = paintBox(buffer, ui, {
|
|
@@ -921,8 +1137,7 @@ function paintGuide(
|
|
|
921
1137
|
if (!guide) return;
|
|
922
1138
|
const roles = ui.theme.roles;
|
|
923
1139
|
const unicode = ui.capabilities.unicode;
|
|
924
|
-
const left = width
|
|
925
|
-
const boxWidth = width - left * 2;
|
|
1140
|
+
const { left, boxWidth } = panelRect(width);
|
|
926
1141
|
const height = region.height;
|
|
927
1142
|
if (height < 3 || boxWidth < 10) return;
|
|
928
1143
|
const panel = paintBox(buffer, ui, {
|
|
@@ -1002,8 +1217,7 @@ function paintPicker(
|
|
|
1002
1217
|
const picker = ui.picker;
|
|
1003
1218
|
if (!picker) return;
|
|
1004
1219
|
const roles = ui.theme.roles;
|
|
1005
|
-
const left = width
|
|
1006
|
-
const boxWidth = width - left * 2;
|
|
1220
|
+
const { left, boxWidth } = panelRect(width);
|
|
1007
1221
|
const noteRows = picker.note && region.height >= 6 ? 1 : 0;
|
|
1008
1222
|
const height = Math.min(
|
|
1009
1223
|
region.height,
|
|
@@ -1118,12 +1332,56 @@ export function footerHint(
|
|
|
1118
1332
|
|
|
1119
1333
|
// ---------------------------------------------------------------------------
|
|
1120
1334
|
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1335
|
+
/**
|
|
1336
|
+
* The too-small screen's lines, widest layout that fits first: one line
|
|
1337
|
+
* (`terminal too small · 58×14 · need ≥ 60×16`), then stacked, then bare
|
|
1338
|
+
* numbers. The last line is the keys that still work.
|
|
1339
|
+
*/
|
|
1340
|
+
export function tooSmallLines(
|
|
1341
|
+
size: FrameSize,
|
|
1342
|
+
unicode: boolean,
|
|
1343
|
+
playing: boolean,
|
|
1344
|
+
): string[] {
|
|
1345
|
+
const x = unicode ? "×" : "x";
|
|
1346
|
+
const dot = unicode ? " · " : " - ";
|
|
1347
|
+
const now = `${size.width}${x}${size.height}`;
|
|
1348
|
+
const need = `need ${unicode ? "≥" : ">="} ${MIN_WIDTH}${x}${MIN_HEIGHT}`;
|
|
1349
|
+
const keys = `space ${playing ? "stop" : "play"}${dot}q quit`;
|
|
1350
|
+
const fits = (lines: string[]) =>
|
|
1351
|
+
lines.length <= size.height &&
|
|
1352
|
+
lines.every((line) => displayWidth(line) <= size.width);
|
|
1353
|
+
const layouts = [
|
|
1354
|
+
[`terminal too small${dot}${now}${dot}${need}`, "", keys],
|
|
1355
|
+
["terminal too small", `${now}${dot}${need}`, "", keys],
|
|
1356
|
+
["terminal too small", now, need, "", keys],
|
|
1357
|
+
["terminal too small", now, need],
|
|
1358
|
+
["too small", now, need],
|
|
1359
|
+
[now, need],
|
|
1360
|
+
[`${now} ${unicode ? "≥" : ">="}${MIN_WIDTH}${x}${MIN_HEIGHT}`],
|
|
1361
|
+
[now],
|
|
1126
1362
|
];
|
|
1363
|
+
return layouts.find(fits) ?? [now];
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* What a key does while the too-small screen is up: ctrl-c and q quit,
|
|
1368
|
+
* space starts or stops playback, everything else (typing, mouse, menu
|
|
1369
|
+
* keys) is dropped so the hidden UI's state is untouched.
|
|
1370
|
+
*/
|
|
1371
|
+
export function tooSmallKey(value: unknown): "quit" | "play" | "ignore" {
|
|
1372
|
+
if (value === "\u0003" || value === "q" || value === "Q") return "quit";
|
|
1373
|
+
if (value === " ") return "play";
|
|
1374
|
+
return "ignore";
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function paintTooSmall(
|
|
1378
|
+
buffer: CellBuffer,
|
|
1379
|
+
ui: UiState,
|
|
1380
|
+
size: FrameSize,
|
|
1381
|
+
playing: boolean,
|
|
1382
|
+
): void {
|
|
1383
|
+
const roles = ui.theme.roles;
|
|
1384
|
+
const lines = tooSmallLines(size, ui.capabilities.unicode, playing);
|
|
1127
1385
|
const top = Math.max(0, Math.floor((size.height - lines.length) / 2));
|
|
1128
1386
|
lines.forEach((line, index) => {
|
|
1129
1387
|
const text = truncate(line, size.width);
|
|
@@ -1132,7 +1390,7 @@ function paintTooSmall(buffer: CellBuffer, ui: UiState, size: FrameSize): void {
|
|
|
1132
1390
|
x,
|
|
1133
1391
|
top + index,
|
|
1134
1392
|
text,
|
|
1135
|
-
index === 0 ? roles.warning : roles.muted,
|
|
1393
|
+
index === 0 ? { ...roles.warning, bold: true } : roles.muted,
|
|
1136
1394
|
);
|
|
1137
1395
|
});
|
|
1138
1396
|
}
|
|
@@ -1150,7 +1408,7 @@ export function composeFrame(
|
|
|
1150
1408
|
const layout = computeLayout({ width, height }, ui.prompt.wrappedRows);
|
|
1151
1409
|
const hits = new HitMap();
|
|
1152
1410
|
if (layout.tooSmall) {
|
|
1153
|
-
paintTooSmall(buffer, ui, { width, height });
|
|
1411
|
+
paintTooSmall(buffer, ui, { width, height }, view.score.playing === true);
|
|
1154
1412
|
return {
|
|
1155
1413
|
buffer,
|
|
1156
1414
|
cursor: undefined,
|
|
@@ -1164,7 +1422,10 @@ export function composeFrame(
|
|
|
1164
1422
|
buffer,
|
|
1165
1423
|
layout.header,
|
|
1166
1424
|
width,
|
|
1167
|
-
|
|
1425
|
+
{
|
|
1426
|
+
...view.play,
|
|
1427
|
+
context: playContext(view.score, ui.capabilities.unicode),
|
|
1428
|
+
},
|
|
1168
1429
|
ui.theme,
|
|
1169
1430
|
ui.capabilities.unicode,
|
|
1170
1431
|
);
|
|
@@ -1188,8 +1449,16 @@ export function composeFrame(
|
|
|
1188
1449
|
height: layout.highway.height - 1,
|
|
1189
1450
|
};
|
|
1190
1451
|
}
|
|
1191
|
-
} else
|
|
1192
|
-
|
|
1452
|
+
} else
|
|
1453
|
+
paintHeader(
|
|
1454
|
+
buffer,
|
|
1455
|
+
view,
|
|
1456
|
+
ui,
|
|
1457
|
+
width,
|
|
1458
|
+
hits,
|
|
1459
|
+
view.beat ?? resolveBeat(view.score, nowMs),
|
|
1460
|
+
);
|
|
1461
|
+
if (view.arrange && !view.tape && layout.highway.height > 4) {
|
|
1193
1462
|
paintArrangeStrip(
|
|
1194
1463
|
buffer,
|
|
1195
1464
|
layout.highway.y,
|
|
@@ -1210,6 +1479,7 @@ export function composeFrame(
|
|
|
1210
1479
|
kind: ui.overlay && !ui.drawer ? "text" : "highway",
|
|
1211
1480
|
});
|
|
1212
1481
|
// The drawer replaces the menu's list: the piano roll shows above it.
|
|
1482
|
+
let panel: number | undefined = TEXT_MEASURE;
|
|
1213
1483
|
if (ui.overlay === "log" && !ui.drawer)
|
|
1214
1484
|
paintOverlay(buffer, ui, layout.highway, width);
|
|
1215
1485
|
else if (ui.overlay === "picker" && ui.picker && !ui.drawer)
|
|
@@ -1218,16 +1488,48 @@ export function composeFrame(
|
|
|
1218
1488
|
paintText(buffer, ui, layout.highway, width);
|
|
1219
1489
|
else if (ui.overlay === "guide" && ui.guide)
|
|
1220
1490
|
paintGuide(buffer, ui, layout.highway, width);
|
|
1221
|
-
else
|
|
1491
|
+
else panel = undefined;
|
|
1492
|
+
// A text panel holds its measure; on a wide terminal the song keeps
|
|
1493
|
+
// drawing beside it instead of an empty margin.
|
|
1494
|
+
const { left: panelLeft, boxWidth } = panelRect(width, panel);
|
|
1495
|
+
const sideX = panelLeft + boxWidth + 1;
|
|
1496
|
+
const side =
|
|
1497
|
+
panel !== undefined
|
|
1498
|
+
? width - sideX >= SIDE_MIN_WIDTH
|
|
1499
|
+
? { x: sideX, width: width - sideX }
|
|
1500
|
+
: undefined
|
|
1501
|
+
: { x: 0, width };
|
|
1502
|
+
if (side && view.tape)
|
|
1503
|
+
paintTape(
|
|
1504
|
+
buffer,
|
|
1505
|
+
{ ...side, y: layout.highway.y, height: layout.highway.height },
|
|
1506
|
+
view.tape,
|
|
1507
|
+
{
|
|
1508
|
+
theme: ui.theme,
|
|
1509
|
+
unicode: ui.capabilities.unicode,
|
|
1510
|
+
...(panel !== undefined ? {} : { hits }),
|
|
1511
|
+
},
|
|
1512
|
+
);
|
|
1513
|
+
else if (side)
|
|
1222
1514
|
paintHighway(
|
|
1223
1515
|
buffer,
|
|
1224
|
-
{
|
|
1516
|
+
{ ...side, y: layout.highway.y, height: layout.highway.height },
|
|
1225
1517
|
view.score,
|
|
1226
1518
|
beat,
|
|
1227
1519
|
{
|
|
1228
1520
|
theme: ui.theme,
|
|
1229
1521
|
capabilities: ui.capabilities,
|
|
1230
1522
|
reducedMotion: ui.reducedMotion,
|
|
1523
|
+
hint: {
|
|
1524
|
+
seed: view.score.sessionId ?? "dawg",
|
|
1525
|
+
agent: !view.agentOffline,
|
|
1526
|
+
},
|
|
1527
|
+
sweep: sweepProgress(
|
|
1528
|
+
ui.delight?.sweepStartedAtMs,
|
|
1529
|
+
nowMs,
|
|
1530
|
+
ui.reducedMotion,
|
|
1531
|
+
),
|
|
1532
|
+
glows: ui.delight?.glows(nowMs, ui.reducedMotion),
|
|
1231
1533
|
},
|
|
1232
1534
|
);
|
|
1233
1535
|
if (ui.drawer)
|
|
@@ -1236,7 +1538,11 @@ export function composeFrame(
|
|
|
1236
1538
|
layout.highway,
|
|
1237
1539
|
width,
|
|
1238
1540
|
ui.drawer,
|
|
1239
|
-
{
|
|
1541
|
+
{
|
|
1542
|
+
theme: ui.theme,
|
|
1543
|
+
unicode: ui.capabilities.unicode,
|
|
1544
|
+
reducedMotion: ui.reducedMotion,
|
|
1545
|
+
},
|
|
1240
1546
|
hits,
|
|
1241
1547
|
);
|
|
1242
1548
|
}
|
|
@@ -1245,7 +1551,14 @@ export function composeFrame(
|
|
|
1245
1551
|
text: ui.keys,
|
|
1246
1552
|
hint: HINTS.keys,
|
|
1247
1553
|
});
|
|
1248
|
-
paintActivity(
|
|
1554
|
+
paintActivity(
|
|
1555
|
+
buffer,
|
|
1556
|
+
layout.activity,
|
|
1557
|
+
ui,
|
|
1558
|
+
width,
|
|
1559
|
+
nowMs,
|
|
1560
|
+
view.agentOffline ?? false,
|
|
1561
|
+
);
|
|
1249
1562
|
const prompt = paintPrompt(buffer, view, ui, layout, width);
|
|
1250
1563
|
return {
|
|
1251
1564
|
buffer,
|
|
@@ -1260,8 +1573,15 @@ export function composeFrame(
|
|
|
1260
1573
|
// ---------------------------------------------------------------------------
|
|
1261
1574
|
// Mutable app
|
|
1262
1575
|
|
|
1576
|
+
/** The longest a lagging terminal waits between frames. */
|
|
1577
|
+
export const MAX_BACKOFF_MS = 250;
|
|
1578
|
+
|
|
1263
1579
|
export interface TerminalIO {
|
|
1264
|
-
|
|
1580
|
+
/**
|
|
1581
|
+
* Writes a frame. `false` means the terminal is not keeping up (the
|
|
1582
|
+
* stream's buffer is full): frames back off until a write is accepted.
|
|
1583
|
+
*/
|
|
1584
|
+
write(data: string): void | boolean;
|
|
1265
1585
|
columns(): number;
|
|
1266
1586
|
rows(): number;
|
|
1267
1587
|
}
|
|
@@ -1314,8 +1634,19 @@ export class TuiApp {
|
|
|
1314
1634
|
private writer: ScreenWriter;
|
|
1315
1635
|
private lastFrame: Frame | undefined;
|
|
1316
1636
|
private lastFrameAt = Number.NEGATIVE_INFINITY;
|
|
1637
|
+
/** Compose plus encode time of the last rendered frame, in ms. */
|
|
1638
|
+
lastRenderMs = 0;
|
|
1317
1639
|
/** Minimum interval between frames (~30 fps). */
|
|
1318
1640
|
frameIntervalMs = 33;
|
|
1641
|
+
/**
|
|
1642
|
+
* Extra spacing while the terminal lags: doubles on each refused write up
|
|
1643
|
+
* to `MAX_BACKOFF_MS`, resets on the first accepted one.
|
|
1644
|
+
*/
|
|
1645
|
+
backoffMs = 0;
|
|
1646
|
+
/** Session-only delight; `heardLoop` comes from the session's metadata. */
|
|
1647
|
+
readonly delight = new Delight();
|
|
1648
|
+
/** The song wrapped for the first time: the host records it in meta. */
|
|
1649
|
+
onFirstLoop: (() => void) | undefined;
|
|
1319
1650
|
|
|
1320
1651
|
constructor(options: TuiAppOptions) {
|
|
1321
1652
|
this.io = options.io;
|
|
@@ -1348,6 +1679,7 @@ export class TuiApp {
|
|
|
1348
1679
|
keys: this.keys,
|
|
1349
1680
|
guide: this.guide,
|
|
1350
1681
|
drawer: this.drawer,
|
|
1682
|
+
delight: this.delight,
|
|
1351
1683
|
};
|
|
1352
1684
|
}
|
|
1353
1685
|
|
|
@@ -1367,21 +1699,68 @@ export class TuiApp {
|
|
|
1367
1699
|
*/
|
|
1368
1700
|
render(view: AppView, options: { force?: boolean } = {}): string {
|
|
1369
1701
|
const now = this.clock();
|
|
1370
|
-
if (
|
|
1702
|
+
if (
|
|
1703
|
+
!options.force &&
|
|
1704
|
+
now - this.lastFrameAt < this.frameIntervalMs + this.backoffMs
|
|
1705
|
+
)
|
|
1371
1706
|
return "";
|
|
1707
|
+
// A zero-size (or unknown) terminal has no cell to draw in; any write
|
|
1708
|
+
// would wrap. Draw again in full once it has one.
|
|
1709
|
+
const columns = Math.floor(this.io.columns() || 0);
|
|
1710
|
+
const rows = Math.floor(this.io.rows() || 0);
|
|
1711
|
+
if (columns < 1 || rows < 1) {
|
|
1712
|
+
this.writer.invalidate();
|
|
1713
|
+
return "";
|
|
1714
|
+
}
|
|
1372
1715
|
this.lastFrameAt = now;
|
|
1716
|
+
this.watchFirstLoop(view, now);
|
|
1717
|
+
const started = performance.now();
|
|
1373
1718
|
const frame = composeFrame(
|
|
1374
1719
|
view,
|
|
1375
1720
|
this.ui,
|
|
1376
|
-
{ width:
|
|
1721
|
+
{ width: columns, height: rows },
|
|
1377
1722
|
now,
|
|
1378
1723
|
);
|
|
1379
1724
|
this.lastFrame = frame;
|
|
1380
1725
|
const out = this.writer.frame(frame.buffer, frame.cursor);
|
|
1381
|
-
|
|
1726
|
+
this.lastRenderMs = performance.now() - started;
|
|
1727
|
+
if (out) {
|
|
1728
|
+
const accepted = this.io.write(out);
|
|
1729
|
+
this.backoffMs =
|
|
1730
|
+
accepted === false
|
|
1731
|
+
? Math.min(
|
|
1732
|
+
MAX_BACKOFF_MS,
|
|
1733
|
+
Math.max(this.frameIntervalMs, this.backoffMs * 2),
|
|
1734
|
+
)
|
|
1735
|
+
: 0;
|
|
1736
|
+
}
|
|
1382
1737
|
return out;
|
|
1383
1738
|
}
|
|
1384
1739
|
|
|
1740
|
+
/**
|
|
1741
|
+
* The first wrap of a song that never wrapped before: a one-shot sweep
|
|
1742
|
+
* and the card `↻ first loop`. The card shows with motion off too (it is
|
|
1743
|
+
* information); the sweep does not.
|
|
1744
|
+
*/
|
|
1745
|
+
private watchFirstLoop(view: AppView, now: number): void {
|
|
1746
|
+
const score = view.score;
|
|
1747
|
+
const wrapped = this.delight.observe(
|
|
1748
|
+
{
|
|
1749
|
+
playing: score.playing === true,
|
|
1750
|
+
beat: view.beat ?? resolveBeat(score, now),
|
|
1751
|
+
loopBeats: score.loopBeats,
|
|
1752
|
+
empty:
|
|
1753
|
+
score.notes.length === 0 &&
|
|
1754
|
+
(score.clips ?? []).length === 0 &&
|
|
1755
|
+
!(score.layers ?? []).some((layer) => layer.notes.length > 0),
|
|
1756
|
+
},
|
|
1757
|
+
now,
|
|
1758
|
+
);
|
|
1759
|
+
if (!wrapped) return;
|
|
1760
|
+
this.activity.pushCard(FIRST_LOOP_CARD, { tone: "info", once: true });
|
|
1761
|
+
this.onFirstLoop?.();
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1385
1764
|
/** Decode one key sequence (from TerminalInputDecoder) into an input. */
|
|
1386
1765
|
input(value: string | { type: "paste"; text: string }): AppInput {
|
|
1387
1766
|
if (typeof value !== "string")
|
|
@@ -1485,7 +1864,7 @@ export class TuiApp {
|
|
|
1485
1864
|
}
|
|
1486
1865
|
if (this.overlay === "text" && this.text) {
|
|
1487
1866
|
const nav = overlayKey(value);
|
|
1488
|
-
const page =
|
|
1867
|
+
const page = this.overlayRows();
|
|
1489
1868
|
if (nav && nav !== "enter") {
|
|
1490
1869
|
const step =
|
|
1491
1870
|
nav === "down"
|
|
@@ -1497,19 +1876,25 @@ export class TuiApp {
|
|
|
1497
1876
|
: nav === "pgup"
|
|
1498
1877
|
? -page
|
|
1499
1878
|
: 0;
|
|
1500
|
-
|
|
1501
|
-
|
|
1879
|
+
// Clamp to the last full page, the same bound paintText uses, so
|
|
1880
|
+
// End then ↑ moves the view on the first press.
|
|
1881
|
+
const max = Math.max(0, this.text.lines.length - page);
|
|
1882
|
+
const from = Math.min(max, this.text.scroll);
|
|
1883
|
+
const next =
|
|
1502
1884
|
nav === "home"
|
|
1503
1885
|
? 0
|
|
1504
1886
|
: nav === "end"
|
|
1505
|
-
?
|
|
1506
|
-
: Math.max(0, Math.min(
|
|
1887
|
+
? max
|
|
1888
|
+
: Math.max(0, Math.min(max, from + step));
|
|
1889
|
+
// At the last page the panel pins to the end: a resize keeps the
|
|
1890
|
+
// last line in view (paintText clamps Infinity to the new bound).
|
|
1891
|
+
this.text.scroll = max > 0 && next === max ? Infinity : next;
|
|
1507
1892
|
return { type: "overlay" };
|
|
1508
1893
|
}
|
|
1509
1894
|
}
|
|
1510
1895
|
if (this.overlay === "log") {
|
|
1511
1896
|
const nav = overlayKey(value);
|
|
1512
|
-
const page =
|
|
1897
|
+
const page = this.overlayRows();
|
|
1513
1898
|
if (nav && nav !== "enter") {
|
|
1514
1899
|
const step =
|
|
1515
1900
|
nav === "up"
|
|
@@ -1525,12 +1910,15 @@ export class TuiApp {
|
|
|
1525
1910
|
this.activity.transcript,
|
|
1526
1911
|
this.log.filter,
|
|
1527
1912
|
).length;
|
|
1913
|
+
// Scroll counts rows up from the newest; paintOverlay's bound.
|
|
1914
|
+
const max = Math.max(0, total - page);
|
|
1915
|
+
const from = Math.min(max, this.log.scroll);
|
|
1528
1916
|
this.log.scroll =
|
|
1529
1917
|
nav === "home"
|
|
1530
|
-
?
|
|
1918
|
+
? max
|
|
1531
1919
|
: nav === "end"
|
|
1532
1920
|
? 0
|
|
1533
|
-
: Math.max(0, Math.min(
|
|
1921
|
+
: Math.max(0, Math.min(max, from + step));
|
|
1534
1922
|
return { type: "overlay" };
|
|
1535
1923
|
}
|
|
1536
1924
|
if (key.type === "text" && key.text === "/") {
|
|
@@ -1624,6 +2012,19 @@ export class TuiApp {
|
|
|
1624
2012
|
if (this.overlay === "picker") this.overlay = undefined;
|
|
1625
2013
|
}
|
|
1626
2014
|
|
|
2015
|
+
/**
|
|
2016
|
+
* Rows of text a panel over the highway shows (its height less the two
|
|
2017
|
+
* borders), as last rendered; a guess from the terminal height before the
|
|
2018
|
+
* first frame.
|
|
2019
|
+
*/
|
|
2020
|
+
private overlayRows(): number {
|
|
2021
|
+
const highway = this.lastFrame?.layout.highway.height;
|
|
2022
|
+
return Math.max(
|
|
2023
|
+
1,
|
|
2024
|
+
highway !== undefined && highway > 2 ? highway - 2 : this.io.rows() - 10,
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
1627
2028
|
/** Show static lines over the highway (`/help`, lists); replaces any overlay. */
|
|
1628
2029
|
openText(title: string, lines: readonly string[]): void {
|
|
1629
2030
|
this.text = { title, lines: lines.slice(0, 512), scroll: 0 };
|
|
@@ -1681,9 +2082,10 @@ export class TuiApp {
|
|
|
1681
2082
|
|
|
1682
2083
|
/**
|
|
1683
2084
|
* Handle TUI-local slash commands (`/log`, `/theme`, `/motion`, `/guide`).
|
|
1684
|
-
* Returns a receipt
|
|
2085
|
+
* Returns a receipt (a structured one for a refusal), or undefined when
|
|
2086
|
+
* the command is not a UI command.
|
|
1685
2087
|
*/
|
|
1686
|
-
command(text: string): string | undefined {
|
|
2088
|
+
command(text: string): string | Receipt | undefined {
|
|
1687
2089
|
const command = text.trim();
|
|
1688
2090
|
if (/^\/(log|transcript)$/i.test(command)) {
|
|
1689
2091
|
this.overlay = this.overlay === "log" ? undefined : "log";
|
|
@@ -1708,7 +2110,12 @@ export class TuiApp {
|
|
|
1708
2110
|
if (guide) {
|
|
1709
2111
|
const topic = guide[1]?.trim();
|
|
1710
2112
|
if (!this.openGuide(topic))
|
|
1711
|
-
return
|
|
2113
|
+
return fail(
|
|
2114
|
+
topicMiss(
|
|
2115
|
+
topic!,
|
|
2116
|
+
(this.guide?.guides ?? listGuides()).map((g) => g.id),
|
|
2117
|
+
),
|
|
2118
|
+
);
|
|
1712
2119
|
return topic
|
|
1713
2120
|
? `guide · ${this.guide?.guides.find((g) => g.id === this.guide?.page)?.title ?? topic} · esc back`
|
|
1714
2121
|
: "guides · → open · esc closes";
|