mini-coder 0.7.4 → 0.8.1

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 (49) hide show
  1. package/AGENTS.md +114 -0
  2. package/README.md +53 -66
  3. package/bin/mini-coder.ts +2 -0
  4. package/demo.gif +0 -0
  5. package/package.json +17 -20
  6. package/src/agent.ts +193 -272
  7. package/src/auth.ts +84 -0
  8. package/src/cli.ts +99 -0
  9. package/src/config.ts +179 -0
  10. package/src/prompt.ts +54 -207
  11. package/src/session.ts +124 -69
  12. package/src/tools/bash.ts +86 -0
  13. package/src/tools/common.ts +32 -0
  14. package/src/tools/edit.ts +41 -0
  15. package/src/tools/index.ts +47 -0
  16. package/src/tools/read.ts +64 -0
  17. package/src/tui/commands.ts +199 -0
  18. package/src/tui/complete.ts +85 -0
  19. package/src/tui/editor.ts +320 -0
  20. package/src/tui/highlight.ts +189 -0
  21. package/src/tui/stream.ts +142 -0
  22. package/src/tui/styles.ts +20 -0
  23. package/src/tui/term.ts +436 -0
  24. package/src/tui/theme.ts +120 -0
  25. package/src/tui/tui.ts +758 -0
  26. package/src/tui/usage.ts +67 -0
  27. package/tsconfig.json +8 -8
  28. package/bin/mc.ts +0 -11
  29. package/bun.lock +0 -350
  30. package/nono-mini-coder.json +0 -42
  31. package/src/args.ts +0 -252
  32. package/src/error-handling.test.ts +0 -163
  33. package/src/git.ts +0 -23
  34. package/src/headless.ts +0 -66
  35. package/src/index.ts +0 -43
  36. package/src/models.ts +0 -191
  37. package/src/oauth.ts +0 -147
  38. package/src/shared.ts +0 -119
  39. package/src/themes.ts +0 -234
  40. package/src/tool-bash.ts +0 -77
  41. package/src/tool-edit.ts +0 -121
  42. package/src/tool-read.ts +0 -100
  43. package/src/tui-components.ts +0 -127
  44. package/src/tui-conversation.ts +0 -218
  45. package/src/tui-editor.ts +0 -29
  46. package/src/tui-overlay.ts +0 -604
  47. package/src/tui.ts +0 -314
  48. package/src/types.ts +0 -194
  49. package/src/update.ts +0 -171
package/src/session.ts CHANGED
@@ -1,91 +1,146 @@
1
- import { mkdir } from "node:fs/promises";
1
+ import {
2
+ closeSync,
3
+ fsyncSync,
4
+ mkdirSync,
5
+ openSync,
6
+ writeSync,
7
+ } from "node:fs";
8
+ import { randomBytes } from "node:crypto";
2
9
  import { join } from "node:path";
3
- import type { Message } from "@earendil-works/pi-ai";
4
- import { Value } from "typebox/value";
5
- import { SESSIONS_DIR } from "./shared";
6
- import { type Session, SessionSchema } from "./types";
10
+ import type { Message, Tool } from "@earendil-works/pi-ai";
7
11
 
8
- export async function ensureSessionsDir(): Promise<void> {
9
- await mkdir(SESSIONS_DIR, { recursive: true });
12
+ interface SessionHeader {
13
+ type: "session";
14
+ version: 1;
15
+ id: string;
16
+ cwd: string;
17
+ createdAt: string;
18
+ title: string;
10
19
  }
11
20
 
12
- export async function getSession(id: string): Promise<Session | undefined> {
13
- const file = Bun.file(join(SESSIONS_DIR, `${id}.json`));
21
+ interface RequestRecord {
22
+ type: "request";
23
+ at: string;
24
+ provider: string;
25
+ model: string;
26
+ api: string;
27
+ thinkingEffort: string;
28
+ systemPrompt: string;
29
+ tools: Tool[];
30
+ }
14
31
 
15
- if (!(await file.exists())) {
16
- return;
17
- }
32
+ interface MessageRecord {
33
+ type: "message";
34
+ at: string;
35
+ message: Message;
36
+ }
18
37
 
19
- try {
20
- const sessionJson = await file.text();
21
- const parsed = JSON.parse(sessionJson) as unknown;
22
- const valid = Value.Check(SessionSchema, parsed);
38
+ type SessionRecord = SessionHeader | RequestRecord | MessageRecord;
23
39
 
24
- if (valid) return parsed;
25
- } catch {
26
- return;
27
- }
40
+ function slugify(text: string): string {
41
+ const slug = text
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9]+/g, "-")
44
+ .replace(/^-+|-+$/g, "")
45
+ .slice(0, 40)
46
+ .replace(/-+$/, "");
47
+ return slug || "session";
48
+ }
49
+
50
+ const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
28
51
 
29
- return;
52
+ function shortId(): string {
53
+ const bytes = randomBytes(6);
54
+ let out = "";
55
+ for (let i = 0; i < 6; i++) out += ALPHABET[bytes[i] % 36];
56
+ return out;
30
57
  }
31
58
 
32
- function latestMessageTimestamp(session: Session): number {
33
- return Math.max(0, ...session.messages.map((message) => message.timestamp));
59
+ function timestamp(date: Date): string {
60
+ const p = (n: number) => String(n).padStart(2, "0");
61
+ return (
62
+ `${date.getUTCFullYear()}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}` +
63
+ `-${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}`
64
+ );
34
65
  }
35
66
 
36
- export async function listSessionsForCwd(): Promise<Session[]> {
37
- const sessions: Session[] = [];
38
- const sessionFiles = new Bun.Glob("*.json");
39
- const cwd = process.cwd();
67
+ function titleFor(message: Message): string {
68
+ if (message.role !== "user") return "session";
69
+ const text = typeof message.content === "string"
70
+ ? message.content
71
+ : message.content.map((block) => (block.type === "text" ? block.text : "")).join(" ");
72
+ return slugify(text);
73
+ }
40
74
 
41
- try {
42
- for await (const entry of sessionFiles.scan({
43
- cwd: SESSIONS_DIR,
44
- dot: true,
45
- })) {
46
- try {
47
- const sessionJson = await Bun.file(join(SESSIONS_DIR, entry)).text();
48
- const parsed = JSON.parse(sessionJson) as unknown;
49
-
50
- if (Value.Check(SessionSchema, parsed) && cwd === parsed.cwd) {
51
- sessions.push(parsed);
52
- }
53
- } catch {
54
- // Ignore invalid session files.
55
- }
56
- }
57
- } catch {
58
- return [];
75
+ export class Session {
76
+ private readonly sessionsDir: string;
77
+ private readonly cwd: string;
78
+ id: string | null = null;
79
+ private fd: number | null = null;
80
+ private closed = false;
81
+
82
+ constructor(sessionsDir: string, cwd: string) {
83
+ this.sessionsDir = sessionsDir;
84
+ this.cwd = cwd;
59
85
  }
60
86
 
61
- return sessions.sort(
62
- (a, b) => latestMessageTimestamp(b) - latestMessageTimestamp(a),
63
- );
64
- }
87
+ appendMessage(message: Message): void {
88
+ if (this.closed) return;
89
+ this.ensure(titleFor(message));
90
+ this.write({
91
+ type: "message",
92
+ at: new Date().toISOString(),
93
+ message,
94
+ });
95
+ }
65
96
 
66
- export async function saveSession(s: Session) {
67
- await ensureSessionsDir();
68
- const file = Bun.file(join(SESSIONS_DIR, `${s.id}.json`));
69
- await Bun.write(file, JSON.stringify(s));
70
- }
97
+ appendRequest(input: Omit<RequestRecord, "type" | "at">): void {
98
+ if (this.closed) return;
99
+ this.ensure("session");
100
+ this.write({ type: "request", at: new Date().toISOString(), ...input });
101
+ }
71
102
 
72
- export async function updateSession(id: string, messages: Message[]) {
73
- const existing = await getSession(id);
74
- if (existing) {
75
- // Only append new messages, so we don't save compacted messages.
76
- if (existing.messages.length < messages.length) {
77
- const newMessages = messages.slice(existing.messages.length);
78
- existing.messages = [...existing.messages, ...newMessages];
79
- await saveSession(existing);
103
+ close(): void {
104
+ this.closed = true;
105
+ if (this.fd !== null) {
106
+ closeSync(this.fd);
107
+ this.fd = null;
80
108
  }
81
- return;
82
109
  }
83
110
 
84
- const s = {
85
- id,
86
- cwd: process.cwd(),
87
- messages,
88
- };
111
+ private ensure(title: string): void {
112
+ if (this.fd !== null) return;
113
+ mkdirSync(this.sessionsDir, { recursive: true });
114
+ const stamp = timestamp(new Date());
115
+ for (let attempt = 0; attempt < 16; attempt++) {
116
+ const name = `${stamp}-${title}-${shortId()}`;
117
+ const dir = join(this.sessionsDir, name);
118
+ try {
119
+ mkdirSync(dir);
120
+ } catch (error) {
121
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
122
+ throw error;
123
+ }
124
+ this.id = name;
125
+ const logPath = join(dir, "session.jsonl");
126
+ this.fd = openSync(logPath, "a");
127
+ const header: SessionHeader = {
128
+ type: "session",
129
+ version: 1,
130
+ id: name,
131
+ cwd: this.cwd,
132
+ createdAt: new Date().toISOString(),
133
+ title,
134
+ };
135
+ this.write(header);
136
+ return;
137
+ }
138
+ throw new Error(`session: could not create a unique directory in ${this.sessionsDir}`);
139
+ }
89
140
 
90
- await saveSession(s);
141
+ private write(record: SessionRecord): void {
142
+ if (this.fd === null) throw new Error("session: append before create");
143
+ writeSync(this.fd, JSON.stringify(record) + "\n");
144
+ fsyncSync(this.fd);
145
+ }
91
146
  }
@@ -0,0 +1,86 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Type, type Static } from "@earendil-works/pi-ai";
3
+ import type { ToolContext, ToolResult } from "./common.ts";
4
+
5
+ export const BASH_PARAMS = Type.Object(
6
+ { command: Type.String({ description: "Command to run" }) },
7
+ { additionalProperties: false },
8
+ );
9
+ type BashArgs = Static<typeof BASH_PARAMS>;
10
+
11
+ const MAX_HEAD = 10_000;
12
+ const MAX_TAIL = 6_000;
13
+ const TRUNCATED = "\n\n... output truncated ...\n\n";
14
+
15
+ export function bash(args: BashArgs, ctx: ToolContext): Promise<ToolResult> {
16
+ return new Promise((resolve) => {
17
+ const child = spawn("bash", ["-c", args.command], {
18
+ cwd: process.cwd(),
19
+ detached: true,
20
+ stdio: ["ignore", "pipe", "pipe"],
21
+ });
22
+
23
+ let head = "";
24
+ let tail = "";
25
+ let omitted = 0;
26
+ const append = (chunk: Buffer) => {
27
+ let text = chunk.toString();
28
+ ctx.onOutput?.(text);
29
+ if (head.length < MAX_HEAD) {
30
+ const take = Math.min(MAX_HEAD - head.length, text.length);
31
+ head += text.slice(0, take);
32
+ text = text.slice(take);
33
+ }
34
+ if (text === "") return;
35
+ tail += text;
36
+ if (tail.length > MAX_TAIL) {
37
+ omitted += tail.length - MAX_TAIL;
38
+ tail = tail.slice(tail.length - MAX_TAIL);
39
+ }
40
+ };
41
+ child.stdout?.on("data", append);
42
+ child.stderr?.on("data", append);
43
+
44
+ let settled = false;
45
+ const finish = (code: number | null) => {
46
+ if (settled) return;
47
+ settled = true;
48
+ ctx.signal.removeEventListener("abort", onAbort);
49
+ clearTimeout(killTimer);
50
+ const exit = code ?? (ctx.signal.aborted ? "aborted" : "unknown");
51
+ const truncated = omitted > 0;
52
+ const body = truncated ? head + TRUNCATED + tail : head + tail;
53
+ const text = body + (body.endsWith("\n") || body === "" ? "" : "\n") + `exit code: ${exit}`;
54
+ resolve({
55
+ text,
56
+ isError: code !== 0,
57
+ details: truncated
58
+ ? { truncated: true, omittedChars: omitted, totalChars: head.length + tail.length + omitted }
59
+ : undefined,
60
+ });
61
+ };
62
+
63
+ let killTimer: NodeJS.Timeout | undefined;
64
+ const kill = (signal: NodeJS.Signals): void => {
65
+ try {
66
+ process.kill(-child.pid!, signal);
67
+ } catch {
68
+ /* already gone */
69
+ }
70
+ };
71
+ const onAbort = () => {
72
+ if (!child.pid) return;
73
+ kill("SIGTERM");
74
+ killTimer = setTimeout(() => kill("SIGKILL"), 300);
75
+ };
76
+
77
+ if (ctx.signal.aborted) onAbort();
78
+ else ctx.signal.addEventListener("abort", onAbort, { once: true });
79
+
80
+ child.on("error", (error) => {
81
+ append(Buffer.from(`${head || tail ? "\n" : ""}bash failed: ${error.message}\n`));
82
+ finish(null);
83
+ });
84
+ child.on("close", (code) => finish(code));
85
+ });
86
+ }
@@ -0,0 +1,32 @@
1
+ import type { ImageContent, Static, TSchema } from "@earendil-works/pi-ai";
2
+ import { Value } from "typebox/value";
3
+
4
+ export interface ToolContext {
5
+ signal: AbortSignal;
6
+ /** Whether the current model accepts image input. */
7
+ supportsImages: boolean;
8
+ onOutput?: (chunk: string) => void;
9
+ }
10
+
11
+ export interface ToolDetails {
12
+ truncated: boolean;
13
+ omittedChars: number;
14
+ totalChars: number;
15
+ }
16
+
17
+ export interface ToolResult {
18
+ text: string;
19
+ isError: boolean;
20
+ details?: ToolDetails;
21
+ /** Image blocks appended to the tool result, after `text`. */
22
+ images?: ImageContent[];
23
+ }
24
+
25
+ export function parseArgs<T extends TSchema>(schema: T, value: unknown): Static<T> {
26
+ try {
27
+ return Value.Parse(schema, value);
28
+ } catch {
29
+ const first = [...Value.Errors(schema, value)][0];
30
+ throw new Error(first ? `${first.instancePath || "/"} ${first.message}` : "invalid arguments");
31
+ }
32
+ }
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { Type, type Static } from "@earendil-works/pi-ai";
3
+ import { createTwoFilesPatch } from "diff";
4
+ import type { ToolResult } from "./common.ts";
5
+
6
+ export const EDIT_PARAMS = Type.Object(
7
+ {
8
+ path: Type.String({ description: "File path" }),
9
+ oldText: Type.String({ description: "Exact text to replace; empty to create a file" }),
10
+ newText: Type.String({ description: "Replacement text" }),
11
+ },
12
+ { additionalProperties: false },
13
+ );
14
+ type EditArgs = Static<typeof EDIT_PARAMS>;
15
+
16
+ export function edit(args: EditArgs, signal: AbortSignal): ToolResult {
17
+ const { path, oldText, newText } = args;
18
+ const exists = existsSync(path);
19
+
20
+ if (oldText === "") {
21
+ if (exists) return { text: `edit failed: ${path} already exists`, isError: true };
22
+ if (signal.aborted) return { text: "edit cancelled", isError: true };
23
+ writeFileSync(path, newText);
24
+ return { text: `created ${path}\n${unified(path, "", newText)}`, isError: false };
25
+ }
26
+
27
+ if (!exists) return { text: `edit failed: ${path} does not exist`, isError: true };
28
+ const before = readFileSync(path, "utf8");
29
+ const count = before.split(oldText).length - 1;
30
+ if (count === 0) return { text: `edit failed: oldText not found in ${path}`, isError: true };
31
+ if (count > 1) return { text: `edit failed: oldText matches ${count} times in ${path}`, isError: true };
32
+
33
+ const after = before.replace(oldText, newText);
34
+ if (signal.aborted) return { text: "edit cancelled", isError: true };
35
+ writeFileSync(path, after);
36
+ return { text: `edited ${path}\n${unified(path, before, after)}`, isError: false };
37
+ }
38
+
39
+ function unified(path: string, before: string, after: string): string {
40
+ return createTwoFilesPatch(path, path, before, after, "", "", { context: 3 });
41
+ }
@@ -0,0 +1,47 @@
1
+ import type { Api, Model, Tool, ToolCall } from "@earendil-works/pi-ai";
2
+ import type { ToolName } from "../config.ts";
3
+ import { parseArgs, type ToolContext, type ToolResult } from "./common.ts";
4
+ import { BASH_PARAMS, bash } from "./bash.ts";
5
+ import { EDIT_PARAMS, edit } from "./edit.ts";
6
+ import { READ_PARAMS, read } from "./read.ts";
7
+
8
+ export type { ToolDetails, ToolResult } from "./common.ts";
9
+
10
+ /** Whether a model accepts image input. Drives `read`'s description and results. */
11
+ export function acceptsImages(model: Model<Api>): boolean {
12
+ return model.input.includes("image");
13
+ }
14
+
15
+ const READ_DESCRIPTION = "Read a file. Returns its text. Prefer bash for search, ranges, or binary files.";
16
+ const READ_IMAGE_DESCRIPTION =
17
+ "Read a file. Returns its text, or the image itself when the file is a png, jpg, or webp. " +
18
+ "Prefer bash for search, ranges, or binary files.";
19
+
20
+ export function toolSchemas(names: ToolName[], withImages: boolean): Tool[] {
21
+ const tools: Record<ToolName, Tool> = {
22
+ edit: {
23
+ name: "edit",
24
+ description:
25
+ "Edit a file by exact text replacement. oldText must occur exactly once. " +
26
+ "With empty oldText, create a new file (fails if it exists).",
27
+ parameters: EDIT_PARAMS,
28
+ },
29
+ read: {
30
+ name: "read",
31
+ description: withImages ? READ_IMAGE_DESCRIPTION : READ_DESCRIPTION,
32
+ parameters: READ_PARAMS,
33
+ },
34
+ bash: {
35
+ name: "bash",
36
+ description: "Run a bash command in the current working directory.",
37
+ parameters: BASH_PARAMS,
38
+ },
39
+ };
40
+ return names.map((name) => tools[name]);
41
+ }
42
+
43
+ export async function executeTool(call: ToolCall, ctx: ToolContext): Promise<ToolResult> {
44
+ if (call.name === "edit") return edit(parseArgs(EDIT_PARAMS, call.arguments), ctx.signal);
45
+ if (call.name === "read") return read(parseArgs(READ_PARAMS, call.arguments), ctx);
46
+ return bash(parseArgs(BASH_PARAMS, call.arguments), ctx);
47
+ }
@@ -0,0 +1,64 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { extname } from "node:path";
3
+ import { Type, type Static } from "@earendil-works/pi-ai";
4
+ import type { ToolContext, ToolResult } from "./common.ts";
5
+
6
+ export const READ_PARAMS = Type.Object(
7
+ { path: Type.String({ description: "File path" }) },
8
+ { additionalProperties: false },
9
+ );
10
+ type ReadArgs = Static<typeof READ_PARAMS>;
11
+
12
+ const IMAGE_MIME: Record<string, string> = {
13
+ png: "image/png",
14
+ jpg: "image/jpeg",
15
+ jpeg: "image/jpeg",
16
+ webp: "image/webp",
17
+ };
18
+
19
+ const MAX_TEXT_CHARS = 100_000;
20
+ /** Cap on the base64 payload sent to the provider, where the file inflates by 4/3. */
21
+ const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024;
22
+
23
+ export function read(args: ReadArgs, ctx: ToolContext): ToolResult {
24
+ const { path } = args;
25
+
26
+ let data: Buffer;
27
+ try {
28
+ data = readFileSync(path);
29
+ } catch (error) {
30
+ return { text: `read failed: ${(error as Error).message}`, isError: true };
31
+ }
32
+
33
+ const mime = IMAGE_MIME[extname(path).slice(1).toLowerCase()];
34
+ if (mime !== undefined) {
35
+ if (!ctx.supportsImages) {
36
+ return { text: `read failed: ${path} is an image and the current model does not accept image input`, isError: true };
37
+ }
38
+ const base64 = data.toString("base64");
39
+ if (base64.length > MAX_IMAGE_BASE64_BYTES) {
40
+ return {
41
+ text: `read failed: ${path} encodes to ${base64.length} bytes, over the ${MAX_IMAGE_BASE64_BYTES} byte image limit`,
42
+ isError: true,
43
+ };
44
+ }
45
+ return {
46
+ text: `read ${path} (${mime}, ${data.length} bytes)`,
47
+ isError: false,
48
+ images: [{ type: "image", data: base64, mimeType: mime }],
49
+ };
50
+ }
51
+
52
+ if (data.subarray(0, 8000).includes(0)) {
53
+ return { text: `read failed: ${path} is binary; use bash (file, xxd)`, isError: true };
54
+ }
55
+
56
+ const text = data.toString("utf8");
57
+ if (text.length > MAX_TEXT_CHARS) {
58
+ return {
59
+ text: text.slice(0, MAX_TEXT_CHARS) + `\n\n... truncated ... ${path} is ${text.length} characters\n`,
60
+ isError: false,
61
+ };
62
+ }
63
+ return { text, isError: false };
64
+ }
@@ -0,0 +1,199 @@
1
+ import {
2
+ getSupportedThinkingLevels,
3
+ type Api,
4
+ type AuthEvent,
5
+ type AuthPrompt,
6
+ type AuthType,
7
+ type Model,
8
+ type ModelThinkingLevel,
9
+ type Models,
10
+ } from "@earendil-works/pi-ai";
11
+ import { saveConfig } from "../config.ts";
12
+ import { dim, red } from "./styles.ts";
13
+ import { commonPrefix } from "./complete.ts";
14
+
15
+ export interface CommandContext {
16
+ /** The collection the running command may reach, e.g. to start a login. */
17
+ models: Models;
18
+ /** The model the session is currently running. */
19
+ model: Model<Api>;
20
+ /** Switches the running session to `model`. */
21
+ select(model: Model<Api>): void;
22
+ /** Sets the running session's thinking level. */
23
+ setThinking(level: ModelThinkingLevel): void;
24
+ /** Aborts when the running command is cancelled (Ctrl+C). */
25
+ signal: AbortSignal;
26
+ /** Appends already-styled lines to scrollback. */
27
+ write(lines: string[]): void;
28
+ /** Asks the user a question and resolves with the next submitted line. */
29
+ prompt(prompt: AuthPrompt): Promise<string>;
30
+ /** Reports a login event as styled scrollback lines. */
31
+ notify(event: AuthEvent): void;
32
+ }
33
+
34
+ export interface Command {
35
+ name: string; // no leading slash, lowercase
36
+ description: string; // one line, shown by /help
37
+ run(ctx: CommandContext, args: string): void | Promise<void>;
38
+ }
39
+
40
+ /** The keybindings the TUI accepts, in the order `/help` prints them. */
41
+ const KEYBINDINGS: [string, string][] = [
42
+ ["Enter", "submit"],
43
+ ["Shift+Enter", "newline (Ctrl+J also works)"],
44
+ ["Esc", "pause the turn at the next step boundary"],
45
+ ["Ctrl+C", "cancel the turn"],
46
+ ["Ctrl+D", "exit on an empty draft"],
47
+ ["Tab", "complete command or path"],
48
+ ];
49
+
50
+ /** One aligned `key description` block; the key column is dimmed. */
51
+ function keyed(rows: [string, string][], width: number): string[] {
52
+ return rows.map(([key, description]) => ` ${dim(key.padEnd(width))} ${description}`);
53
+ }
54
+
55
+ const help: Command = {
56
+ name: "help",
57
+ description: "list commands and keybindings",
58
+ run(ctx): void {
59
+ const names = COMMANDS.map((command): [string, string] => [`/${command.name}`, command.description]);
60
+ const width = Math.max(...names.map(([key]) => key.length), ...KEYBINDINGS.map(([key]) => key.length));
61
+ ctx.write(["commands", ...keyed(names, width), "", "keybindings", ...keyed(KEYBINDINGS, width)]);
62
+ },
63
+ };
64
+
65
+ const login: Command = {
66
+ name: "login",
67
+ description: "authenticate a provider",
68
+ async run(ctx, args): Promise<void> {
69
+ const providers = ctx.models
70
+ .getProviders()
71
+ .filter((provider) => provider.auth.oauth?.login !== undefined || provider.auth.apiKey?.login !== undefined);
72
+ const providerId =
73
+ args.trim() ||
74
+ (await ctx.prompt({
75
+ type: "select",
76
+ message: "Select a provider",
77
+ options: providers.map((provider) => ({ id: provider.id, label: provider.name })),
78
+ }));
79
+ const provider = ctx.models.getProvider(providerId);
80
+ if (provider === undefined) throw new Error(`unknown provider: ${providerId}`);
81
+
82
+ const oauth = provider.auth.oauth;
83
+ const apiKey = provider.auth.apiKey;
84
+ const types: { id: AuthType; label: string }[] = [];
85
+ if (oauth?.login !== undefined) {
86
+ types.push({ id: "oauth", label: oauth.loginLabel ?? oauth.name });
87
+ }
88
+ if (apiKey?.login !== undefined) types.push({ id: "api_key", label: apiKey.name });
89
+ if (types.length === 0) throw new Error(`provider "${providerId}" has no login flow`);
90
+ const type =
91
+ types.length === 1
92
+ ? types[0].id
93
+ : ((await ctx.prompt({
94
+ type: "select",
95
+ message: `How would you like to authenticate with ${provider.name}?`,
96
+ options: types,
97
+ })) as AuthType);
98
+
99
+ try {
100
+ await ctx.models.login(providerId, type, {
101
+ signal: ctx.signal,
102
+ prompt: (prompt) => ctx.prompt(prompt),
103
+ notify: (event) => ctx.notify(event),
104
+ });
105
+ } catch (error) {
106
+ if (ctx.signal.aborted) {
107
+ ctx.write([red("! cancelled")]);
108
+ return;
109
+ }
110
+ throw error;
111
+ }
112
+ const source = (await ctx.models.getAuth(providerId))?.source;
113
+ ctx.write([`logged in to ${provider.name}${source === undefined ? "" : ` (${source})`}`]);
114
+ },
115
+ };
116
+
117
+ const provider: Command = {
118
+ name: "provider",
119
+ description: "choose the provider and model",
120
+ async run(ctx): Promise<void> {
121
+ const available = await ctx.models.getAvailable(undefined, { signal: ctx.signal });
122
+ const ids = [...new Set(available.map((m) => m.provider))];
123
+ if (ids.length === 0) throw new Error("no authenticated providers");
124
+ const providerId = await ctx.prompt({
125
+ type: "select",
126
+ message: "Select a provider",
127
+ options: ids.map((id) => ({ id, label: ctx.models.getProvider(id)?.name ?? id })),
128
+ });
129
+ const models = available.filter((m) => m.provider === providerId);
130
+ if (models.length === 0) throw new Error(`no models for provider "${providerId}"`);
131
+ const name = ctx.models.getProvider(providerId)?.name ?? providerId;
132
+ const modelId = await ctx.prompt({
133
+ type: "select",
134
+ message: `Select a model for ${name}`,
135
+ options: models.map((m) => ({ id: m.id, label: m.name ?? m.id })),
136
+ });
137
+ const chosen = models.find((m) => m.id === modelId);
138
+ if (chosen === undefined) throw new Error(`unknown model: ${modelId}`);
139
+ saveConfig({ provider: providerId, model: modelId });
140
+ ctx.select(chosen);
141
+ },
142
+ };
143
+
144
+ const thinking: Command = {
145
+ name: "thinking",
146
+ description: "set the thinking level",
147
+ async run(ctx): Promise<void> {
148
+ const levels = getSupportedThinkingLevels(ctx.model);
149
+ const level = (await ctx.prompt({
150
+ type: "select",
151
+ message: "Select a thinking level",
152
+ options: levels.map((l) => ({ id: l, label: l })),
153
+ })) as ModelThinkingLevel;
154
+ saveConfig({ thinkingEffort: level });
155
+ ctx.setThinking(level);
156
+ },
157
+ };
158
+
159
+ const model: Command = {
160
+ name: "model",
161
+ description: "choose a model for the current provider",
162
+ async run(ctx): Promise<void> {
163
+ const providerId = ctx.model.provider;
164
+ const models = await ctx.models.getAvailable(providerId, { signal: ctx.signal });
165
+ if (models.length === 0) throw new Error(`no models for provider "${providerId}"`);
166
+ const name = ctx.models.getProvider(providerId)?.name ?? providerId;
167
+ const modelId = await ctx.prompt({
168
+ type: "select",
169
+ message: `Select a model for ${name}`,
170
+ options: models.map((m) => ({ id: m.id, label: m.name ?? m.id })),
171
+ });
172
+ const chosen = models.find((m) => m.id === modelId);
173
+ if (chosen === undefined) throw new Error(`unknown model: ${modelId}`);
174
+ saveConfig({ provider: providerId, model: modelId });
175
+ ctx.select(chosen);
176
+ },
177
+ };
178
+
179
+ const COMMANDS: Command[] = [help, login, provider, model, thinking];
180
+
181
+ /** `/name` for a known `name`, else null; unknown slash text stays a message. */
182
+ export function findCommand(text: string): { command: Command; args: string } | null {
183
+ const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(text);
184
+ if (match === null) return null;
185
+ const command = COMMANDS.find((candidate) => candidate.name === match[1]);
186
+ return command === undefined ? null : { command, args: match[2] ?? "" };
187
+ }
188
+
189
+ /** Tab completion for a half-typed command name; null leaves the draft alone. */
190
+ export function completeCommand(draft: string): string | null {
191
+ if (!draft.startsWith("/") || /\s/.test(draft)) return null;
192
+ const typed = draft.slice(1);
193
+ const matches = COMMANDS.filter((command) => command.name.startsWith(typed));
194
+ if (matches.length === 0) return null;
195
+ if (matches.length === 1) return `/${matches[0].name}`;
196
+ const shared = matches.map((command) => command.name).reduce(commonPrefix);
197
+ return shared === typed ? null : `/${shared}`;
198
+ }
199
+