@hraness/dawg 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,206 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { isValidKey } from "./credentials.ts";
3
+
4
+ /**
5
+ * OpenRouter's OAuth PKCE flow for apps
6
+ * (https://openrouter.ai/docs/use-cases/oauth-pkce): open
7
+ * `https://openrouter.ai/auth?callback_url=…&code_challenge=…&code_challenge_method=S256`,
8
+ * receive `?code=` on a localhost callback (any port is allowed), then
9
+ * exchange it with `POST /api/v1/auth/keys` for a user-controlled key.
10
+ */
11
+ export const OPENROUTER_AUTH_URL = "https://openrouter.ai/auth";
12
+ export const OPENROUTER_API_URL = "https://openrouter.ai/api/v1";
13
+ export const OPENROUTER_KEYS_URL = "https://openrouter.ai/settings/keys";
14
+ export const OAUTH_TIMEOUT_MS = 5 * 60_000;
15
+ const MAX_EXCHANGE_BYTES = 16 * 1024;
16
+ const CODE_PATTERN = /^[A-Za-z0-9._~-]{8,512}$/;
17
+
18
+ type Fetcher = (
19
+ input: RequestInfo | URL,
20
+ init?: RequestInit,
21
+ ) => Promise<Response>;
22
+
23
+ export type Pkce = Readonly<{
24
+ verifier: string;
25
+ challenge: string;
26
+ state: string;
27
+ }>;
28
+
29
+ function base64url(bytes: Buffer): string {
30
+ return bytes
31
+ .toString("base64")
32
+ .replace(/\+/g, "-")
33
+ .replace(/\//g, "_")
34
+ .replace(/=+$/, "");
35
+ }
36
+
37
+ /** A 43-character verifier, its S256 challenge, and a CSRF state. */
38
+ export function createPkce(
39
+ random: (size: number) => Buffer = randomBytes,
40
+ ): Pkce {
41
+ const verifier = base64url(random(32));
42
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
43
+ return { verifier, challenge, state: base64url(random(16)) };
44
+ }
45
+
46
+ export function authorizeUrl(
47
+ callbackUrl: string,
48
+ pkce: Pkce,
49
+ base = OPENROUTER_AUTH_URL,
50
+ ): string {
51
+ const url = new URL(base);
52
+ url.searchParams.set("callback_url", callbackUrl);
53
+ url.searchParams.set("code_challenge", pkce.challenge);
54
+ url.searchParams.set("code_challenge_method", "S256");
55
+ url.searchParams.set("key_label", "dawg");
56
+ url.searchParams.set("state", pkce.state);
57
+ return url.toString();
58
+ }
59
+
60
+ function sameSecret(a: string, b: string): boolean {
61
+ const left = Buffer.from(a);
62
+ const right = Buffer.from(b);
63
+ return left.length === right.length && timingSafeEqual(left, right);
64
+ }
65
+
66
+ const PAGE = (title: string, body: string) =>
67
+ `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;margin:3rem;max-width:32rem"><h1 style="font-size:1.4rem">${title}</h1><p>${body}</p></body>`;
68
+
69
+ export type CallbackServer = Readonly<{
70
+ /** `http://127.0.0.1:<port>/callback`. */
71
+ url: string;
72
+ /** Resolves with the code once a request carries the right state. */
73
+ code: Promise<string>;
74
+ stop(): void;
75
+ }>;
76
+
77
+ /**
78
+ * A one-shot callback listener on 127.0.0.1 and an ephemeral port. Requests
79
+ * with a missing or wrong `state` are refused and do not end the wait.
80
+ */
81
+ export function startCallbackServer(
82
+ state: string,
83
+ options: { signal?: AbortSignal; timeoutMs?: number } = {},
84
+ ): CallbackServer {
85
+ let settle: { resolve(code: string): void; reject(error: Error): void };
86
+ const code = new Promise<string>((resolve, reject) => {
87
+ settle = { resolve, reject };
88
+ });
89
+ code.catch(() => undefined);
90
+ const server = Bun.serve({
91
+ hostname: "127.0.0.1",
92
+ port: 0,
93
+ fetch(request) {
94
+ const url = new URL(request.url);
95
+ if (url.pathname !== "/callback")
96
+ return new Response("not found", { status: 404 });
97
+ const html = (status: number, title: string, body: string) =>
98
+ new Response(PAGE(title, body), {
99
+ status,
100
+ headers: {
101
+ "content-type": "text/html; charset=utf-8",
102
+ "cache-control": "no-store",
103
+ "referrer-policy": "no-referrer",
104
+ },
105
+ });
106
+ const got = url.searchParams.get("state") ?? "";
107
+ const value = url.searchParams.get("code") ?? "";
108
+ if (!sameSecret(got, state))
109
+ return html(
110
+ 400,
111
+ "Sign-in not recognized",
112
+ "This link does not match the sign-in dawg started. Return to the terminal and try again.",
113
+ );
114
+ if (!CODE_PATTERN.test(value)) {
115
+ settle.reject(
116
+ new Error("OpenRouter did not return a code (access denied?)"),
117
+ );
118
+ return html(
119
+ 400,
120
+ "Sign-in cancelled",
121
+ "No authorization code came back. You can close this tab.",
122
+ );
123
+ }
124
+ settle.resolve(value);
125
+ return html(
126
+ 200,
127
+ "dawg is connected to OpenRouter",
128
+ "You can close this tab and return to the terminal.",
129
+ );
130
+ },
131
+ });
132
+ const timer = setTimeout(
133
+ () => settle.reject(new Error("timed out waiting for the browser")),
134
+ options.timeoutMs ?? OAUTH_TIMEOUT_MS,
135
+ );
136
+ const onAbort = () => settle.reject(new Error("sign-in cancelled"));
137
+ options.signal?.addEventListener("abort", onAbort, { once: true });
138
+ const stop = () => {
139
+ clearTimeout(timer);
140
+ options.signal?.removeEventListener("abort", onAbort);
141
+ // Graceful first so the browser still gets the "connected" page, then
142
+ // force-close any idle keep-alive sockets.
143
+ void server.stop(false);
144
+ setTimeout(() => void server.stop(true), 1_000).unref?.();
145
+ };
146
+ void code.then(stop, stop);
147
+ return { url: `http://127.0.0.1:${server.port}/callback`, code, stop };
148
+ }
149
+
150
+ /** `POST /api/v1/auth/keys` → the user's new key. */
151
+ export async function exchangeCode(
152
+ code: string,
153
+ pkce: Pick<Pkce, "verifier">,
154
+ options: { fetcher?: Fetcher; baseUrl?: string } = {},
155
+ ): Promise<string> {
156
+ const fetcher = options.fetcher ?? fetch;
157
+ const base = (options.baseUrl ?? OPENROUTER_API_URL).replace(/\/$/, "");
158
+ const response = await fetcher(`${base}/auth/keys`, {
159
+ method: "POST",
160
+ headers: { "content-type": "application/json" },
161
+ body: JSON.stringify({
162
+ code,
163
+ code_verifier: pkce.verifier,
164
+ code_challenge_method: "S256",
165
+ }),
166
+ signal: AbortSignal.timeout(20_000),
167
+ });
168
+ const text = (await response.text()).slice(0, MAX_EXCHANGE_BYTES);
169
+ if (!response.ok)
170
+ throw new Error(
171
+ response.status === 403
172
+ ? "OpenRouter refused the code (expired or already used)"
173
+ : `OpenRouter key exchange failed (${response.status})`,
174
+ );
175
+ let key: unknown;
176
+ try {
177
+ key = (JSON.parse(text) as { key?: unknown }).key;
178
+ } catch {
179
+ key = undefined;
180
+ }
181
+ if (!isValidKey(key)) throw new Error("OpenRouter returned no usable key");
182
+ return key;
183
+ }
184
+
185
+ export type KeyCheck = "valid" | "rejected" | "unverified";
186
+
187
+ /** `GET /api/v1/key`: cheap, authenticated, and spends nothing. */
188
+ export async function checkOpenRouterKey(
189
+ key: string,
190
+ options: { fetcher?: Fetcher; baseUrl?: string } = {},
191
+ ): Promise<KeyCheck> {
192
+ const fetcher = options.fetcher ?? fetch;
193
+ const base = (options.baseUrl ?? OPENROUTER_API_URL).replace(/\/$/, "");
194
+ try {
195
+ const response = await fetcher(`${base}/key`, {
196
+ headers: { authorization: `Bearer ${key}` },
197
+ signal: AbortSignal.timeout(8_000),
198
+ });
199
+ await response.body?.cancel().catch(() => undefined);
200
+ if (response.ok) return "valid";
201
+ if (response.status === 401 || response.status === 403) return "rejected";
202
+ return "unverified";
203
+ } catch {
204
+ return "unverified";
205
+ }
206
+ }
@@ -0,0 +1,282 @@
1
+ import { stdin, stdout } from "node:process";
2
+
3
+ /**
4
+ * A small inline picker for the shell (`dawg login`, `dawg model`), in the
5
+ * style of the Codex CLI sign-in: ↑/↓ or j/k move, Enter chooses, 1-9 jump
6
+ * and choose, typing filters (when enabled), Esc or Ctrl-C cancels. It draws
7
+ * below the cursor and erases itself afterwards, so the scrollback keeps only
8
+ * the one-line result the caller prints.
9
+ */
10
+ export type ChoiceItem = Readonly<{
11
+ label: string;
12
+ /** Right-hand muted text: a status, a price. */
13
+ detail?: string;
14
+ /** Section heading shown above the first row of each group. */
15
+ group?: string;
16
+ /** Rows that cannot be chosen still show (e.g. "not set up"). */
17
+ disabled?: boolean;
18
+ /** Marked with ● (the current model). */
19
+ current?: boolean;
20
+ }>;
21
+
22
+ export type ChooseOptions = Readonly<{
23
+ initial?: number;
24
+ /** Type-to-filter on label and detail. */
25
+ filter?: boolean;
26
+ /** Footer hint override. */
27
+ hint?: string;
28
+ }>;
29
+
30
+ export type PickerKey =
31
+ | { type: "up" }
32
+ | { type: "down" }
33
+ | { type: "enter" }
34
+ | { type: "cancel" }
35
+ | { type: "digit"; value: number }
36
+ | { type: "backspace" }
37
+ | { type: "text"; text: string };
38
+
39
+ /** Split one raw stdin read into picker keys. */
40
+ export function parsePickerKeys(data: string): PickerKey[] {
41
+ const keys: PickerKey[] = [];
42
+ let rest = data.replace(/\u001b\[20[01]~/g, "");
43
+ while (rest.length > 0) {
44
+ if (rest.startsWith("\u001b[A") || rest.startsWith("\u001bOA")) {
45
+ keys.push({ type: "up" });
46
+ rest = rest.slice(3);
47
+ } else if (rest.startsWith("\u001b[B") || rest.startsWith("\u001bOB")) {
48
+ keys.push({ type: "down" });
49
+ rest = rest.slice(3);
50
+ } else if (rest.startsWith("\u001b[")) {
51
+ // Other CSI sequences (left/right, home, …) are ignored.
52
+ const match = /^\u001b\[[0-9;]*[A-Za-z~]/.exec(rest);
53
+ rest = rest.slice(match ? match[0].length : 2);
54
+ } else {
55
+ const char = rest[0]!;
56
+ rest = rest.slice(1);
57
+ if (char === "\r" || char === "\n") keys.push({ type: "enter" });
58
+ else if (char === "\u001b" || char === "\u0003")
59
+ keys.push({ type: "cancel" });
60
+ else if (char === "\u007f" || char === "\b")
61
+ keys.push({ type: "backspace" });
62
+ else if (char === "\u0010") keys.push({ type: "up" });
63
+ else if (char === "\u000e") keys.push({ type: "down" });
64
+ else if (char >= " ") keys.push({ type: "text", text: char });
65
+ }
66
+ }
67
+ return keys;
68
+ }
69
+
70
+ /** The picker's pure state; `step` applies one key. */
71
+ export class PickerModel {
72
+ index: number;
73
+ query = "";
74
+ constructor(
75
+ readonly items: readonly ChoiceItem[],
76
+ readonly options: ChooseOptions = {},
77
+ ) {
78
+ const visible = this.visible();
79
+ const initial = options.initial ?? 0;
80
+ const start = visible.indexOf(initial) >= 0 ? initial : (visible[0] ?? 0);
81
+ this.index = start;
82
+ }
83
+
84
+ /** Indices of rows matching the filter, in order. */
85
+ visible(): number[] {
86
+ const query = this.query.toLowerCase();
87
+ return this.items
88
+ .map((item, index) => ({ item, index }))
89
+ .filter(
90
+ ({ item }) =>
91
+ !query ||
92
+ item.label.toLowerCase().includes(query) ||
93
+ (item.detail ?? "").toLowerCase().includes(query) ||
94
+ (item.group ?? "").toLowerCase().includes(query),
95
+ )
96
+ .map(({ index }) => index);
97
+ }
98
+
99
+ /** `undefined` while picking, a row index when chosen, `null` on cancel. */
100
+ step(key: PickerKey): number | null | undefined {
101
+ const visible = this.visible();
102
+ const position = Math.max(0, visible.indexOf(this.index));
103
+ const move = (delta: number) => {
104
+ if (visible.length === 0) return;
105
+ for (let offset = 1; offset <= visible.length; offset += 1) {
106
+ const n = visible.length;
107
+ const next = visible[(((position + delta * offset) % n) + n) % n]!;
108
+ if (!this.items[next]?.disabled) {
109
+ this.index = next;
110
+ return;
111
+ }
112
+ }
113
+ };
114
+ switch (key.type) {
115
+ case "up":
116
+ move(-1);
117
+ return undefined;
118
+ case "down":
119
+ move(1);
120
+ return undefined;
121
+ case "enter":
122
+ return visible.includes(this.index) && !this.items[this.index]?.disabled
123
+ ? this.index
124
+ : undefined;
125
+ case "cancel":
126
+ if (this.query) {
127
+ this.query = "";
128
+ this.refocus();
129
+ return undefined;
130
+ }
131
+ return null;
132
+ case "backspace":
133
+ this.query = this.query.slice(0, -1);
134
+ this.refocus();
135
+ return undefined;
136
+ case "digit": {
137
+ const target = visible[key.value - 1];
138
+ if (target !== undefined && !this.items[target]?.disabled)
139
+ return target;
140
+ return undefined;
141
+ }
142
+ case "text":
143
+ if (/^[1-9]$/.test(key.text) && !this.query)
144
+ return this.step({ type: "digit", value: Number(key.text) });
145
+ if (!this.options.filter) {
146
+ if (key.text === "k") move(-1);
147
+ if (key.text === "j") move(1);
148
+ return undefined;
149
+ }
150
+ if (this.query.length < 40) this.query += key.text;
151
+ this.refocus();
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ private refocus(): void {
157
+ const visible = this.visible().filter(
158
+ (index) => !this.items[index]?.disabled,
159
+ );
160
+ if (!visible.includes(this.index) && visible.length > 0)
161
+ this.index = visible[0]!;
162
+ }
163
+
164
+ /** Plain lines (no colour); the renderer styles them. */
165
+ lines(
166
+ title: string,
167
+ maxRows = 14,
168
+ ): {
169
+ text: string;
170
+ kind: "title" | "group" | "row" | "selected" | "muted" | "hint";
171
+ }[] {
172
+ const out: {
173
+ text: string;
174
+ kind: "title" | "group" | "row" | "selected" | "muted" | "hint";
175
+ }[] = [
176
+ {
177
+ text: title + (this.query ? ` filter: ${this.query}` : ""),
178
+ kind: "title",
179
+ },
180
+ ];
181
+ const visible = this.visible();
182
+ if (visible.length === 0) out.push({ text: " no matches", kind: "muted" });
183
+ const position = Math.max(0, visible.indexOf(this.index));
184
+ const first = Math.max(
185
+ 0,
186
+ Math.min(visible.length - maxRows, position - Math.floor(maxRows / 2)),
187
+ );
188
+ let lastGroup: string | undefined;
189
+ visible.slice(first, first + maxRows).forEach((index, offset) => {
190
+ const item = this.items[index]!;
191
+ if (item.group && item.group !== lastGroup) {
192
+ out.push({ text: ` ${item.group}`, kind: "group" });
193
+ }
194
+ lastGroup = item.group;
195
+ const selected = index === this.index;
196
+ const number =
197
+ first + offset < 9 && !this.query ? `${first + offset + 1}.` : " ";
198
+ const mark = item.current ? "●" : " ";
199
+ out.push({
200
+ text: `${selected ? "›" : " "} ${number} ${mark} ${item.label}${item.detail ? ` ${item.detail}` : ""}`,
201
+ kind: selected ? "selected" : item.disabled ? "muted" : "row",
202
+ });
203
+ });
204
+ out.push({
205
+ text:
206
+ this.options.hint ??
207
+ ` ↑/↓ move · enter choose · 1-9 pick${this.options.filter ? " · type to filter" : ""} · esc cancel`,
208
+ kind: "hint",
209
+ });
210
+ return out;
211
+ }
212
+ }
213
+
214
+ const ESC = "\u001b[";
215
+
216
+ function style(kind: string, text: string, color: boolean): string {
217
+ if (!color) return text;
218
+ if (kind === "title") return `${ESC}1m${text}${ESC}0m`;
219
+ if (kind === "selected") return `${ESC}1;36m${text}${ESC}0m`;
220
+ if (kind === "group") return `${ESC}2;4m${text}${ESC}0m`;
221
+ if (kind === "muted" || kind === "hint") return `${ESC}2m${text}${ESC}0m`;
222
+ return text;
223
+ }
224
+
225
+ /**
226
+ * Run a picker on the real terminal. Resolves the chosen index, or
227
+ * `undefined` on Esc / Ctrl-C. Requires a TTY on stdin and stdout.
228
+ */
229
+ export function chooseOnTerminal(
230
+ title: string,
231
+ items: readonly ChoiceItem[],
232
+ options: ChooseOptions = {},
233
+ ): Promise<number | undefined> {
234
+ const model = new PickerModel(items, options);
235
+ const color = !process.env.NO_COLOR && process.env.TERM !== "dumb";
236
+ const width = () => Math.max(20, (stdout.columns ?? 80) - 1);
237
+ let drawn = 0;
238
+ const draw = () => {
239
+ const lines = model.lines(
240
+ title,
241
+ Math.max(4, Math.min(14, (stdout.rows ?? 24) - 6)),
242
+ );
243
+ let out = drawn > 0 ? `\r${ESC}${drawn - 1}A` : "\r";
244
+ out += `${ESC}J`;
245
+ out += lines
246
+ .map((line) => style(line.kind, truncate(line.text, width()), color))
247
+ .join("\r\n");
248
+ drawn = lines.length;
249
+ stdout.write(`${ESC}?25l${out}`);
250
+ };
251
+ const erase = () => {
252
+ if (drawn > 0) stdout.write(`\r${ESC}${drawn - 1}A${ESC}J`);
253
+ stdout.write(`${ESC}?25h`);
254
+ };
255
+ return new Promise((resolve) => {
256
+ const wasRaw = stdin.isRaw;
257
+ stdin.setRawMode?.(true);
258
+ stdin.resume();
259
+ const finish = (result: number | undefined) => {
260
+ stdin.off("data", onData);
261
+ stdin.setRawMode?.(wasRaw);
262
+ stdin.pause();
263
+ erase();
264
+ resolve(result);
265
+ };
266
+ const onData = (chunk: Buffer) => {
267
+ for (const key of parsePickerKeys(chunk.toString("utf8"))) {
268
+ const result = model.step(key);
269
+ if (result === null) return finish(undefined);
270
+ if (result !== undefined) return finish(result);
271
+ }
272
+ draw();
273
+ };
274
+ stdin.on("data", onData);
275
+ draw();
276
+ });
277
+ }
278
+
279
+ function truncate(text: string, width: number): string {
280
+ const chars = [...text];
281
+ return chars.length > width ? `${chars.slice(0, width - 1).join("")}…` : text;
282
+ }
@@ -14,6 +14,11 @@ export type RunOptions = Readonly<{
14
14
  /** Bound on captured stdout/stderr; excess is dropped. */
15
15
  maxOutputBytes?: number;
16
16
  env?: Readonly<Record<string, string | undefined>>;
17
+ /**
18
+ * Called with each decoded chunk as it arrives (progress parsing). Chunks
19
+ * keep flowing past `maxOutputBytes`; only the captured text is bounded.
20
+ */
21
+ onOutput?: (stream: "stdout" | "stderr", chunk: string) => void;
17
22
  }>;
18
23
 
19
24
  export type RunResult = Readonly<{
@@ -73,9 +78,18 @@ export const systemRunner: CommandRunner = {
73
78
  // The child may exit before reading its input; its exit code reports why.
74
79
  }
75
80
  }
81
+ const tap = options.onOutput;
76
82
  const [stdout, stderr, code] = await Promise.all([
77
- readBounded(child.stdout, maxBytes),
78
- readBounded(child.stderr, maxBytes),
83
+ readBounded(
84
+ child.stdout,
85
+ maxBytes,
86
+ tap && ((chunk) => tap("stdout", chunk)),
87
+ ),
88
+ readBounded(
89
+ child.stderr,
90
+ maxBytes,
91
+ tap && ((chunk) => tap("stderr", chunk)),
92
+ ),
79
93
  child.exited,
80
94
  ]);
81
95
  return { code, stdout, stderr, killed: stop.killed() };
@@ -127,12 +141,21 @@ function watchKill(
127
141
  async function readBounded(
128
142
  stream: ReadableStream<Uint8Array> | undefined | null,
129
143
  maxBytes: number,
144
+ tap?: (chunk: string) => void,
130
145
  ): Promise<string> {
131
146
  if (!stream) return "";
132
147
  const decoder = new TextDecoder();
148
+ const tapDecoder = tap ? new TextDecoder() : undefined;
133
149
  let text = "";
134
150
  let bytes = 0;
135
151
  for await (const chunk of stream) {
152
+ if (tap && tapDecoder) {
153
+ try {
154
+ tap(tapDecoder.decode(chunk, { stream: true }));
155
+ } catch {
156
+ // A progress parser must never stop the drain.
157
+ }
158
+ }
136
159
  if (bytes >= maxBytes) continue; // Keep draining so the child never blocks.
137
160
  const room = maxBytes - bytes;
138
161
  const part = chunk.byteLength > room ? chunk.subarray(0, room) : chunk;
package/src/auth/tui.ts CHANGED
@@ -2,23 +2,28 @@ import { hostname } from "node:os";
2
2
  import { defaultAuthEnv } from "./credentials.ts";
3
3
  import {
4
4
  authStatus,
5
- listXcbChoices,
6
- login,
5
+ isLogoutTarget,
7
6
  logout,
7
+ modelChoices,
8
+ saveModelChoice,
8
9
  type LoginIO,
9
10
  } from "./login.ts";
10
11
  import { systemRunner, type CommandRunner } from "./runner.ts";
11
- import type { GatewayModel } from "../agent/gateway.ts";
12
12
  import { audioStatusLine } from "../audio/engine.ts";
13
+ import {
14
+ providerLabel,
15
+ selectProvider,
16
+ type ProviderSelection,
17
+ } from "../agent/provider.ts";
18
+ import { parseLoginArgs } from "./cli.ts";
13
19
 
14
20
  /**
15
- * `/login [--xcb]`, `/logout` and `/auth` inside the TUI. The terminal is in
16
- * raw mode, so there is no hidden input or `vercel login` handoff here: those
17
- * flows tell the user to run `dawg login` in a shell instead.
21
+ * `/logout [provider]` and `/auth [--check]` inside the TUI: they print only,
22
+ * so they run without leaving the screen and return their lines as cards.
23
+ * `/login` hands the terminal to the shell flow instead (see main.ts).
18
24
  */
19
25
  export async function tuiAuthCommand(
20
26
  command: string,
21
- gatewayModel: GatewayModel,
22
27
  runner: CommandRunner = systemRunner,
23
28
  ): Promise<string[]> {
24
29
  const lines: string[] = [];
@@ -31,60 +36,72 @@ export async function tuiAuthCommand(
31
36
  const deps = { auth: defaultAuthEnv(runner), io, hostname: hostname() };
32
37
  const [name, ...rest] = command.trim().split(/\s+/);
33
38
  try {
34
- if (/^\/logout$/i.test(name ?? "")) await logout(deps);
35
- else if (/^\/auth$/i.test(name ?? ""))
39
+ if (/^\/logout$/i.test(name ?? "")) {
40
+ const target = rest[0];
41
+ if (target !== undefined && !isLogoutTarget(target))
42
+ lines.push("usage · /logout [gateway|openrouter|codex|claude|xcb]");
43
+ else await logout(deps, target);
44
+ } else if (/^\/auth$/i.test(name ?? ""))
36
45
  lines.push(
37
46
  ...(await authStatus(deps, {
38
47
  verify: rest.includes("--check"),
39
- gatewayModel,
40
48
  })),
41
49
  audioStatusLine(),
42
50
  );
43
- else if (rest.includes("--xcb")) {
44
- const account = flagValue(rest, "--account");
45
- const model = flagValue(rest, "--model");
46
- await login(
47
- "xcb",
48
- deps,
49
- account && model ? { xcb: { account, model } } : {},
50
- );
51
- } else if (rest.includes("--key"))
52
- lines.push(
53
- "Paste a key from a shell: `dawg login --key` (input is hidden there).",
54
- );
55
- else await login("gateway", deps);
51
+ else lines.push(`unknown auth command: ${(name ?? "").slice(0, 20)}`);
56
52
  } catch (error) {
57
53
  lines.push(
58
- `auth failed: ${error instanceof Error ? error.message : String(error)}`,
54
+ `auth failed · ${error instanceof Error ? error.message : String(error)} · dawg login in a shell`,
59
55
  );
60
56
  }
61
57
  return lines.map((line) => line.slice(0, 240));
62
58
  }
63
59
 
64
- function flagValue(args: readonly string[], flag: string): string | undefined {
65
- const index = args.indexOf(flag);
66
- const value = index >= 0 ? args[index + 1] : undefined;
67
- return value && !value.startsWith("--") ? value : undefined;
60
+ /** Validate `/login …` arguments before suspending the screen. */
61
+ export function tuiLoginArgs(command: string) {
62
+ return parseLoginArgs(command.trim().split(/\s+/).slice(1));
68
63
  }
69
64
 
70
- export type XcbPickerItem = Readonly<{ label: string; value: string }>;
65
+ export type ModelPickerItem = Readonly<{
66
+ label: string;
67
+ value: string;
68
+ detail: string;
69
+ current: boolean;
70
+ }>;
71
71
 
72
72
  /**
73
- * Picker rows for `/login --xcb`: one per usable account and model, valued as
74
- * the follow-up command that saves it. Undefined when xcb is missing or
75
- * unreadable (the plain login path then prints the guidance).
73
+ * `/model` picker rows for the active provider, valued as the follow-up
74
+ * `/model <id>` command that saves the choice.
76
75
  */
77
- export async function xcbPickerItems(
76
+ export async function modelPickerItems(
77
+ selection: ProviderSelection,
78
78
  runner: CommandRunner = systemRunner,
79
- ): Promise<XcbPickerItem[] | undefined> {
80
- const choices = await listXcbChoices(defaultAuthEnv(runner));
81
- return choices?.map(({ account, model }) => ({
82
- label: [
83
- account.label,
84
- account.provider,
85
- model.label === model.key ? model.key : `${model.label} (${model.key})`,
86
- ...(account.admission === "pending" ? ["admits on first use"] : []),
87
- ].join(" · "),
88
- value: `/login --xcb --account ${account.id} --model ${model.key}`,
79
+ ): Promise<ModelPickerItem[]> {
80
+ const rows = await modelChoices({ auth: defaultAuthEnv(runner) }, selection);
81
+ return rows.map((row) => ({
82
+ label: row.label,
83
+ detail: `${row.group} · ${row.detail}`,
84
+ current: row.current,
85
+ value: `/model ${row.value}`,
89
86
  }));
90
87
  }
88
+
89
+ /** `/model <value>`: save for the active provider; returns the receipt. */
90
+ export async function tuiSetModel(
91
+ value: string,
92
+ runner: CommandRunner = systemRunner,
93
+ ): Promise<string> {
94
+ const auth = defaultAuthEnv(runner);
95
+ const selection = await selectProvider(auth);
96
+ if (selection.kind === "offline")
97
+ return `model · unavailable: ${selection.reason}`;
98
+ try {
99
+ return `model · ${await saveModelChoice(auth, selection, value)}`;
100
+ } catch (error) {
101
+ const rows = await modelPickerItems(selection, runner).catch(() => []);
102
+ return `model · ${error instanceof Error ? error.message : String(error)}; try ${rows
103
+ .slice(0, 8)
104
+ .map((row) => row.label)
105
+ .join(", ")} · current ${providerLabel(selection)}`;
106
+ }
107
+ }