@hraness/dawg 0.7.0 → 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 +26 -0
- package/DAWG.md +117 -30
- package/README.md +7 -5
- package/core/diff.ts +2 -0
- package/core/ids.ts +52 -0
- package/core/loop.ts +3 -0
- package/core/range.ts +581 -0
- package/core/score.ts +123 -1
- package/core/sdk/print.ts +11 -2
- package/core/sdk/v1.ts +281 -2
- package/core/sections.ts +198 -19
- package/guides/arrange.md +9 -8
- package/guides/audio.md +31 -0
- package/guides/mix.md +3 -1
- package/guides/panes.md +32 -0
- package/guides/project.md +3 -2
- package/guides/rhythm.md +2 -1
- package/guides/tape.md +32 -0
- package/package.json +2 -1
- package/src/agent/agent.ts +4 -4
- package/src/agent/command-agent.ts +19 -10
- package/src/agent/ops.ts +15 -0
- package/src/agent/planner.ts +12 -0
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/show-me.ts +1 -1
- package/src/agent/tools.ts +2 -0
- package/src/agent/xcb-agent.ts +2 -3
- package/src/audio/arrange.ts +12 -2
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +84 -13
- package/src/audio/engine.ts +216 -28
- package/src/commands/arrange.ts +60 -25
- package/src/commands/grammar.ts +3 -1
- package/src/commands/help.ts +187 -9
- package/src/commands/history.ts +122 -11
- package/src/commands/keys.ts +17 -0
- package/src/commands/parses.ts +6 -1
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +2 -6
- package/src/commands/vocal-pitch.ts +2 -1
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +18 -0
- package/src/launch-args.ts +105 -1
- package/src/main.ts +1142 -86
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +196 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/origin.ts +20 -0
- package/src/session/port.ts +87 -10
- package/src/session/protocol.ts +360 -6
- package/src/session/rebase.ts +46 -8
- package/src/session/receipt.ts +7 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +61 -2
- package/src/tui/arrange-menu.ts +169 -1
- package/src/tui/euclid.ts +95 -14
- package/src/tui/fader.ts +55 -2
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu.ts +228 -2
- package/src/tui/play-session.ts +196 -21
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/tui/app.ts +217 -44
- package/tui/delight.ts +49 -0
- package/tui/drawer.ts +167 -9
- package/tui/grammar.ts +34 -5
- package/tui/guide.ts +8 -1
- package/tui/highway.ts +40 -5
- package/tui/hints.ts +5 -0
- package/tui/hits.ts +7 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +12 -0
- package/tui/screen.ts +7 -4
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +19 -1
package/src/commands/resample.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* track (the `cloud` preset) holding one note across the range. The source
|
|
9
9
|
* stays as it is; mute it to hear only the resample.
|
|
10
10
|
*/
|
|
11
|
+
import { newId } from "../../core/ids.ts";
|
|
11
12
|
import { mkdir, realpath, rename, writeFile } from "node:fs/promises";
|
|
12
13
|
import { join } from "node:path";
|
|
13
14
|
import { MAX_ORBIT } from "../../core/fx.ts";
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
TrackScore,
|
|
19
20
|
addTrack,
|
|
20
21
|
type SampleRef,
|
|
22
|
+
requirePinnedRef,
|
|
21
23
|
type TrackInput,
|
|
22
24
|
} from "../../core/score.ts";
|
|
23
25
|
import { trackDirectories } from "../../core/sdk/print.ts";
|
|
@@ -155,13 +157,16 @@ export function addResampleTrack(
|
|
|
155
157
|
trackId: string,
|
|
156
158
|
src: string,
|
|
157
159
|
): TrackScore {
|
|
158
|
-
const ref: SampleRef =
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
const ref: SampleRef = requirePinnedRef(
|
|
161
|
+
{
|
|
162
|
+
src,
|
|
163
|
+
sha256: render.sha256,
|
|
164
|
+
from: render.from,
|
|
165
|
+
// A bounce starts at full level: no anti-click fade-in.
|
|
166
|
+
...(command.grain ? {} : { gain: RESAMPLE_GAIN, fadeInTime: 0 }),
|
|
167
|
+
},
|
|
168
|
+
"resample",
|
|
169
|
+
);
|
|
165
170
|
const track: TrackInput = command.grain
|
|
166
171
|
? {
|
|
167
172
|
id: trackId,
|
|
@@ -183,15 +188,13 @@ export function addResampleTrack(
|
|
|
183
188
|
// plays the source at its own pitch.
|
|
184
189
|
const pitch = command.grain ? 60 : 36;
|
|
185
190
|
const notes = next.notes;
|
|
186
|
-
let n = 1;
|
|
187
|
-
while (notes.some((note) => note.id === `${trackId}-${n}`)) n += 1;
|
|
188
191
|
const ticks = Math.min(render.durationTicks, next.bars * barTicks(next));
|
|
189
192
|
next = new TrackScore({
|
|
190
193
|
...next.toJSON(),
|
|
191
194
|
notes: [
|
|
192
195
|
...notes,
|
|
193
196
|
{
|
|
194
|
-
id:
|
|
197
|
+
id: newId(trackId),
|
|
195
198
|
trackId,
|
|
196
199
|
startTick: render.startTick,
|
|
197
200
|
durationTicks: Math.max(1, ticks),
|
package/src/commands/sample.ts
CHANGED
|
@@ -24,6 +24,9 @@ import {
|
|
|
24
24
|
samplerVoiceSlots,
|
|
25
25
|
type Sampler,
|
|
26
26
|
type SampleRef,
|
|
27
|
+
type PinnedSampleRef,
|
|
28
|
+
requirePinnedRef,
|
|
29
|
+
ScoreValidationError,
|
|
27
30
|
type Track,
|
|
28
31
|
} from "../../core/score.ts";
|
|
29
32
|
import { trackDirectories } from "../../core/sdk/print.ts";
|
|
@@ -317,8 +320,15 @@ export function addSampleVoice(
|
|
|
317
320
|
score: TrackScore,
|
|
318
321
|
trackId: string,
|
|
319
322
|
voice: string,
|
|
320
|
-
ref:
|
|
323
|
+
ref: PinnedSampleRef,
|
|
321
324
|
): AddVoiceResult {
|
|
325
|
+
try {
|
|
326
|
+
requirePinnedRef(ref, `sample ${voice}`);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (error instanceof ScoreValidationError)
|
|
329
|
+
return { ok: false, message: `sample · ${error.message}` };
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
322
332
|
if (!VOICE.test(voice))
|
|
323
333
|
return {
|
|
324
334
|
ok: false,
|
package/src/commands/sing.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* Any edit makes the track a sing track (`instrument "sing"` plus a `sing`
|
|
20
20
|
* field). One `updateTrack` revision and one undo step per command.
|
|
21
21
|
*/
|
|
22
|
+
import { newId } from "../../core/ids.ts";
|
|
22
23
|
import { isGuideInstrument } from "../../core/clips.ts";
|
|
23
24
|
import { parseKey } from "../../core/chords.ts";
|
|
24
25
|
import { FxValidationError } from "../../core/params.ts";
|
|
@@ -330,18 +331,13 @@ export function throatDemo(
|
|
|
330
331
|
if (drone === undefined) return undefined;
|
|
331
332
|
const step = score.ticksPerBeat * 2;
|
|
332
333
|
const total = score.ticksPerBeat * score.beatsPerBar * score.bars;
|
|
333
|
-
const ids = new Set(score.notes.map((note) => note.id));
|
|
334
334
|
let next = score;
|
|
335
335
|
let count = 0;
|
|
336
336
|
for (const [index, offset] of THROAT_DEMO_STEPS.entries()) {
|
|
337
337
|
const startTick = index * step;
|
|
338
338
|
if (startTick + step > total) break;
|
|
339
|
-
let id = `${trackId}-demo-${index + 1}`;
|
|
340
|
-
for (let n = 2; ids.has(id); n += 1)
|
|
341
|
-
id = `${trackId}-demo-${index + 1}-${n}`;
|
|
342
|
-
ids.add(id);
|
|
343
339
|
next = addNote(next, {
|
|
344
|
-
id,
|
|
340
|
+
id: newId(`${trackId}-demo`),
|
|
345
341
|
trackId,
|
|
346
342
|
startTick,
|
|
347
343
|
durationTicks: step,
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* through `core/key.ts`, the same estimator `/media analyze` uses), so a
|
|
9
9
|
* long held tonic weighs more than a passing tone.
|
|
10
10
|
*/
|
|
11
|
+
import { newId } from "../../core/ids.ts";
|
|
11
12
|
import {
|
|
12
13
|
TrackScore,
|
|
13
14
|
SCORE_LIMITS,
|
|
@@ -338,7 +339,7 @@ export function guideNotesScore(
|
|
|
338
339
|
);
|
|
339
340
|
if (startTick < 0 || startTick >= end) continue;
|
|
340
341
|
notes.push({
|
|
341
|
-
id:
|
|
342
|
+
id: newId(trackId),
|
|
342
343
|
trackId,
|
|
343
344
|
startTick,
|
|
344
345
|
durationTicks: Math.max(1, endTick - startTick),
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actor identity placeholder: who made an edit, stable across windows and
|
|
3
|
+
* restarts on one machine. No keys and no login; a later signed identity can
|
|
4
|
+
* bind to this id. It is sent only on the session protocol (`hello`), never
|
|
5
|
+
* to model gateways or anywhere else.
|
|
6
|
+
*
|
|
7
|
+
* File: `<config>/actor.json` = `{ "id": "a_<22 base32>", "name": "$USER" }`,
|
|
8
|
+
* created once with an exclusive atomic write so concurrent first runs agree.
|
|
9
|
+
*/
|
|
10
|
+
import { link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { userInfo } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { configDir } from "../auth/credentials.ts";
|
|
15
|
+
import { randomBase32, setIdSalt } from "../../core/ids.ts";
|
|
16
|
+
|
|
17
|
+
export type Actor = Readonly<{ id: string; name: string }>;
|
|
18
|
+
|
|
19
|
+
export const ACTOR_ID = /^a_[a-z2-7]{22}$/;
|
|
20
|
+
const MAX_NAME_LENGTH = 64;
|
|
21
|
+
|
|
22
|
+
export function isActorId(value: unknown): value is string {
|
|
23
|
+
return typeof value === "string" && ACTOR_ID.test(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function actorPath(dir: string = configDir()): string {
|
|
27
|
+
return join(dir, "actor.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function defaultName(env: Readonly<Record<string, string | undefined>>) {
|
|
31
|
+
let name = env.USER || env.LOGNAME || "";
|
|
32
|
+
if (!name) {
|
|
33
|
+
try {
|
|
34
|
+
name = userInfo().username;
|
|
35
|
+
} catch {
|
|
36
|
+
name = "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return cleanName(name) || "me";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cleanName(value: unknown): string {
|
|
43
|
+
if (typeof value !== "string") return "";
|
|
44
|
+
return value
|
|
45
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
46
|
+
.trim()
|
|
47
|
+
.slice(0, MAX_NAME_LENGTH);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseActor(text: string): Actor | undefined {
|
|
51
|
+
try {
|
|
52
|
+
const value = JSON.parse(text) as Record<string, unknown>;
|
|
53
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
54
|
+
if (!isActorId(value.id)) return undefined;
|
|
55
|
+
return { id: value.id, name: cleanName(value.name) || "me" };
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let cached: Actor | undefined;
|
|
62
|
+
|
|
63
|
+
/** A process-local actor that is never written (tests, read-only configs). */
|
|
64
|
+
export function ephemeralActor(name = "me"): Actor {
|
|
65
|
+
return { id: `a_${randomBase32(22)}`, name: cleanName(name) || "me" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads the actor file, creating it on first run. A corrupt file is replaced
|
|
70
|
+
* (the old id is unrecoverable anyway). Never throws: an unwritable config
|
|
71
|
+
* dir yields an ephemeral actor for this process.
|
|
72
|
+
*/
|
|
73
|
+
export async function loadActor(
|
|
74
|
+
options: {
|
|
75
|
+
dir?: string;
|
|
76
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
77
|
+
} = {},
|
|
78
|
+
): Promise<Actor> {
|
|
79
|
+
const env = options.env ?? process.env;
|
|
80
|
+
const dir = options.dir ?? configDir(env);
|
|
81
|
+
const path = actorPath(dir);
|
|
82
|
+
const existing = await readFile(path, "utf8").then(
|
|
83
|
+
parseActor,
|
|
84
|
+
() => undefined,
|
|
85
|
+
);
|
|
86
|
+
if (existing) return existing;
|
|
87
|
+
const fresh = ephemeralActor(defaultName(env));
|
|
88
|
+
try {
|
|
89
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
90
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
91
|
+
await writeFile(temp, `${JSON.stringify(fresh)}\n`, { mode: 0o600 });
|
|
92
|
+
try {
|
|
93
|
+
// link() fails if the file exists: the first writer wins, others adopt it.
|
|
94
|
+
await link(temp, path);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const code = (error as { code?: string }).code;
|
|
97
|
+
if (code !== "EEXIST") throw error;
|
|
98
|
+
const winner = await readFile(path, "utf8").then(
|
|
99
|
+
parseActor,
|
|
100
|
+
() => undefined,
|
|
101
|
+
);
|
|
102
|
+
if (winner) return winner;
|
|
103
|
+
// Corrupt file: replace it.
|
|
104
|
+
await rm(path, { force: true });
|
|
105
|
+
await link(temp, path);
|
|
106
|
+
} finally {
|
|
107
|
+
await rm(temp, { force: true });
|
|
108
|
+
}
|
|
109
|
+
return fresh;
|
|
110
|
+
} catch {
|
|
111
|
+
return fresh;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Process-wide actor, loaded once; also salts `newId`. */
|
|
116
|
+
export async function currentActor(): Promise<Actor> {
|
|
117
|
+
if (!cached) {
|
|
118
|
+
cached = await loadActor();
|
|
119
|
+
setIdSalt(cached.id);
|
|
120
|
+
}
|
|
121
|
+
return cached;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Tests only. */
|
|
125
|
+
export function resetActorCache(): void {
|
|
126
|
+
cached = undefined;
|
|
127
|
+
}
|
package/src/lang/glossary.ts
CHANGED
|
@@ -398,6 +398,24 @@ export const TERMS: readonly Term[] = [
|
|
|
398
398
|
losers: ["STEER", "QUEUE"],
|
|
399
399
|
aliases: [],
|
|
400
400
|
},
|
|
401
|
+
{
|
|
402
|
+
term: "range",
|
|
403
|
+
meaning: "bars a to b on one track or all (copy bass 5-6 to 7)",
|
|
404
|
+
losers: [],
|
|
405
|
+
aliases: [],
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
term: "clipboard",
|
|
409
|
+
meaning: "what copy without `to` holds until paste (tape c, x, v)",
|
|
410
|
+
losers: [],
|
|
411
|
+
aliases: [],
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
term: "pane",
|
|
415
|
+
meaning: "one terminal on a shared session, with a letter and own undo",
|
|
416
|
+
losers: [],
|
|
417
|
+
aliases: [],
|
|
418
|
+
},
|
|
401
419
|
];
|
|
402
420
|
|
|
403
421
|
/** British spellings dawg writes the American way (lowercase keys). */
|
package/src/launch-args.ts
CHANGED
|
@@ -40,8 +40,27 @@ export const BOOLEAN_FLAGS: readonly string[] = [
|
|
|
40
40
|
/** A `--track` value resolved to the id `/track` would use. */
|
|
41
41
|
export type TrackArg = { id: string; name: string };
|
|
42
42
|
|
|
43
|
+
/** Screens a pane can open on (§12.5). */
|
|
44
|
+
export const PANE_SCREENS = ["home", "play", "tape", "sound", "menu"] as const;
|
|
45
|
+
export type PaneScreen = (typeof PANE_SCREENS)[number];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `dawg pane <screen> [track] [param] [pin | follow [letter]]`: what this
|
|
49
|
+
* terminal shows of the session in the current directory. `sound` opens
|
|
50
|
+
* the drawer on `param` (default volume); `menu` opens `param` as a menu
|
|
51
|
+
* section. `follow` tracks another pane's focus (`*` = the latest).
|
|
52
|
+
*/
|
|
53
|
+
export type PaneArgs = {
|
|
54
|
+
screen: PaneScreen;
|
|
55
|
+
param: string | undefined;
|
|
56
|
+
pin: boolean;
|
|
57
|
+
follow: string | undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
43
60
|
export type LaunchArgs = {
|
|
44
61
|
subcommand: string | undefined;
|
|
62
|
+
/** `dawg pane …` (its track is `track`). */
|
|
63
|
+
pane?: PaneArgs;
|
|
45
64
|
flags: ReadonlySet<string>;
|
|
46
65
|
session: string | undefined;
|
|
47
66
|
track: TrackArg | undefined;
|
|
@@ -90,6 +109,7 @@ export function parseLaunchArgs(argv: readonly string[]): LaunchParse {
|
|
|
90
109
|
const flags = new Set<string>();
|
|
91
110
|
const values = new Map<ValueFlag, string>();
|
|
92
111
|
const first = argv[0];
|
|
112
|
+
if (first === "pane") return parsePaneArgs(argv.slice(1));
|
|
93
113
|
if (first !== undefined && !first.startsWith("-")) {
|
|
94
114
|
if (SUBCOMMANDS.includes(first))
|
|
95
115
|
return { ok: true, args: emptyArgs(first, flags) };
|
|
@@ -146,7 +166,91 @@ export function parseLaunchArgs(argv: readonly string[]): LaunchParse {
|
|
|
146
166
|
};
|
|
147
167
|
}
|
|
148
168
|
|
|
149
|
-
|
|
169
|
+
/** Drawer parameters `dawg pane sound <word>` opens (else the word is a track). */
|
|
170
|
+
const SOUND_PARAMS: readonly string[] = [
|
|
171
|
+
"volume",
|
|
172
|
+
"pan",
|
|
173
|
+
"tempo",
|
|
174
|
+
"bpm",
|
|
175
|
+
"bars",
|
|
176
|
+
"meter",
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const PANE_USAGE =
|
|
180
|
+
"usage: dawg pane home|play|tape|sound|menu [track] [volume|pan|section] [pin|follow [A-Z]]";
|
|
181
|
+
|
|
182
|
+
export function parsePaneArgs(argv: readonly string[]): LaunchParse {
|
|
183
|
+
const [screenWord, ...rest] = argv;
|
|
184
|
+
const screen = PANE_SCREENS.find(
|
|
185
|
+
(name) => name === screenWord?.toLowerCase(),
|
|
186
|
+
);
|
|
187
|
+
if (!screen)
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
problem: screenWord
|
|
191
|
+
? `unknown pane screen ${clip(screenWord)} · ${PANE_USAGE}`
|
|
192
|
+
: PANE_USAGE,
|
|
193
|
+
};
|
|
194
|
+
let pin = false;
|
|
195
|
+
let follow: string | undefined;
|
|
196
|
+
const words: string[] = [];
|
|
197
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
198
|
+
const word = rest[index]!;
|
|
199
|
+
if (word === "pin") pin = true;
|
|
200
|
+
else if (word === "follow") {
|
|
201
|
+
const letter = rest[index + 1];
|
|
202
|
+
if (letter !== undefined && /^[A-Z]$/i.test(letter)) {
|
|
203
|
+
follow = letter.toUpperCase();
|
|
204
|
+
index += 1;
|
|
205
|
+
} else follow = "*";
|
|
206
|
+
} else if (word.startsWith("-"))
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
problem: `unknown option · ${clip(word)} · ${PANE_USAGE}`,
|
|
210
|
+
};
|
|
211
|
+
else words.push(word);
|
|
212
|
+
}
|
|
213
|
+
if (pin && follow)
|
|
214
|
+
return { ok: false, problem: `pin or follow, not both · ${PANE_USAGE}` };
|
|
215
|
+
if (words.length > 2)
|
|
216
|
+
return { ok: false, problem: `too many words · ${PANE_USAGE}` };
|
|
217
|
+
let track: TrackArg | undefined;
|
|
218
|
+
// `menu mix`, `sound filter`: one word after these is the section or the
|
|
219
|
+
// parameter; a track then comes first (`sound bass filter`).
|
|
220
|
+
const [first, second] = words;
|
|
221
|
+
let param: string | undefined;
|
|
222
|
+
const trackFirst =
|
|
223
|
+
words.length === 2 ||
|
|
224
|
+
(first !== undefined &&
|
|
225
|
+
(screen === "home" ||
|
|
226
|
+
screen === "play" ||
|
|
227
|
+
(screen === "sound" && !SOUND_PARAMS.includes(first.toLowerCase()))));
|
|
228
|
+
if (trackFirst) {
|
|
229
|
+
track = normalizeTrackArg(first!);
|
|
230
|
+
if (!track) return { ok: false, problem: "invalid track name" };
|
|
231
|
+
param = second;
|
|
232
|
+
} else param = first;
|
|
233
|
+
if (param !== undefined && !/^[a-z][a-z0-9 ._-]{0,63}$/i.test(param))
|
|
234
|
+
return { ok: false, problem: `invalid pane parameter · ${PANE_USAGE}` };
|
|
235
|
+
if (param !== undefined && (screen === "home" || screen === "play"))
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
problem: `${screen} takes a track only · ${PANE_USAGE}`,
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
args: {
|
|
243
|
+
...emptyArgs(undefined, new Set()),
|
|
244
|
+
track,
|
|
245
|
+
pane: { screen, param: param?.toLowerCase(), pin, follow },
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function emptyArgs(
|
|
251
|
+
subcommand: string | undefined,
|
|
252
|
+
flags: Set<string>,
|
|
253
|
+
): LaunchArgs {
|
|
150
254
|
return {
|
|
151
255
|
subcommand,
|
|
152
256
|
flags,
|