mini-coder 0.8.0 → 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.
package/README.md CHANGED
@@ -21,8 +21,8 @@ if set). There is no project-local config and no override flags.
21
21
 
22
22
  ```json
23
23
  {
24
- "provider": "anthropic",
25
- "model": "claude-sonnet-4-5"
24
+ "provider": "opencode-go",
25
+ "model": "deepseek-v4.1-flash"
26
26
  }
27
27
  ```
28
28
 
@@ -41,7 +41,7 @@ Only `provider` and `model` are required. Defaults for the rest:
41
41
  ```
42
42
 
43
43
  - `provider` / `model` — any model from the `pi-ai` catalog. Invalid pairs fail with a
44
- list of available models for that provider.
44
+ list of available models for that provider. Requires a discoverable api key from the environment (Like: OPENCODE_API_KEY).
45
45
  - `sessionsDir` — where append-only session JSONL files are written. (no relative paths for now, absolute paths only).
46
46
  - `systemPrompt` — the base of the system prompt. Skills and agent files, if
47
47
  enabled, are appended after it.
package/demo.gif CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "A fast, transparent, config-first terminal coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/agent.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type UserMessage,
13
13
  } from "@earendil-works/pi-ai";
14
14
  import type { Session } from "./session.ts";
15
+ import type { ToolName } from "./config.ts";
15
16
  import { acceptsImages, executeTool, type ToolDetails, type ToolResult } from "./tools/index.ts";
16
17
 
17
18
  export type Phase = "preparing" | "waitingModel" | "streaming" | "runningTool" | "pausing" | "idle";
@@ -47,6 +48,8 @@ export interface AgentOptions {
47
48
  model: Model<Api>;
48
49
  systemPrompt: string;
49
50
  tools: Tool[];
51
+ /** The configured tool names, kept so a model switch can rebuild `tools`. */
52
+ toolNames: ToolName[];
50
53
  thinkingEffort: ModelThinkingLevel;
51
54
  session: Session;
52
55
  }
@@ -58,9 +61,20 @@ interface AgentRun extends AgentOptions {
58
61
  onEvent: (event: AgentEvent) => void;
59
62
  }
60
63
 
64
+ /** The concatenated text blocks of an assistant message, ignoring the rest. */
65
+ export function assistantText(message: AssistantMessage): string {
66
+ return message.content
67
+ .filter((block) => block.type === "text")
68
+ .map((block) => block.text)
69
+ .join("");
70
+ }
71
+
61
72
  export async function runAgentTurn(run: AgentRun): Promise<void> {
62
73
  const { messages, session, signal, interaction, onEvent } = run;
63
74
 
75
+ const cancelled = (): void => onEvent({ type: "cancelled" });
76
+ const failed = (message: string): void => onEvent({ type: "error", message });
77
+
64
78
  /** Appends a user message the model reads at the next step boundary. */
65
79
  const steer = (content: string): void => {
66
80
  if (content === "") return;
@@ -91,7 +105,7 @@ export async function runAgentTurn(run: AgentRun): Promise<void> {
91
105
  for (;;) {
92
106
  const steering = await pauseStep();
93
107
  if (steering === null) {
94
- onEvent({ type: "cancelled" });
108
+ cancelled();
95
109
  return;
96
110
  }
97
111
  steer(steering);
@@ -116,7 +130,7 @@ export async function runAgentTurn(run: AgentRun): Promise<void> {
116
130
  sessionId: session.id ?? undefined,
117
131
  });
118
132
  } catch (error) {
119
- onEvent({ type: "error", message: (error as Error).message });
133
+ failed((error as Error).message);
120
134
  return;
121
135
  }
122
136
 
@@ -138,7 +152,7 @@ export async function runAgentTurn(run: AgentRun): Promise<void> {
138
152
  }
139
153
  }
140
154
  } catch (error) {
141
- onEvent({ type: "error", message: (error as Error).message });
155
+ failed((error as Error).message);
142
156
  return;
143
157
  }
144
158
 
@@ -148,11 +162,11 @@ export async function runAgentTurn(run: AgentRun): Promise<void> {
148
162
  onEvent({ type: "message", message: assistant });
149
163
 
150
164
  if (assistant.stopReason === "aborted") {
151
- onEvent({ type: "cancelled" });
165
+ cancelled();
152
166
  return;
153
167
  }
154
168
  if (assistant.stopReason === "error") {
155
- onEvent({ type: "error", message: assistant.errorMessage ?? "provider error" });
169
+ failed(assistant.errorMessage ?? "provider error");
156
170
  return;
157
171
  }
158
172
 
@@ -169,7 +183,7 @@ export async function runAgentTurn(run: AgentRun): Promise<void> {
169
183
  for (const call of toolCalls) {
170
184
  const steering = await pauseStep();
171
185
  if (steering === null) {
172
- onEvent({ type: "cancelled" });
186
+ cancelled();
173
187
  return;
174
188
  }
175
189
  if (steering !== "") held.push(steering);
package/src/auth.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
4
+
5
+ /** The `auth.json` layout: one credential per provider id. */
6
+ type AuthFile = Record<string, Credential>;
7
+
8
+ function readFile(path: string): AuthFile {
9
+ try {
10
+ return JSON.parse(readFileSync(path, "utf8")) as AuthFile;
11
+ } catch (error) {
12
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ /** Write the whole file atomically: temp file, then rename over the target. */
18
+ function writeFile(path: string, data: AuthFile): void {
19
+ mkdirSync(dirname(path), { recursive: true });
20
+ const temp = join(dirname(path), `.${process.pid}.${Date.now()}.tmp`);
21
+ writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`);
22
+ renameSync(temp, path);
23
+ }
24
+
25
+ /**
26
+ * File-backed `CredentialStore` over `auth.json`. Writes are serialized per
27
+ * provider through an in-process promise chain, so `modify` and `delete` on
28
+ * the same provider never interleave.
29
+ */
30
+ export function createCredentialStore(path: string): CredentialStore {
31
+ const chains = new Map<string, Promise<unknown>>();
32
+
33
+ function enqueue<T>(providerId: string, task: () => Promise<T>, options?: AuthOperationOptions): Promise<T> {
34
+ options?.signal?.throwIfAborted();
35
+ const previous = chains.get(providerId) ?? Promise.resolve();
36
+ const queued = (async () => {
37
+ await previous.catch(() => {});
38
+ options?.signal?.throwIfAborted();
39
+ return await task();
40
+ })();
41
+ const tail = queued.catch(() => {});
42
+ chains.set(providerId, tail);
43
+ void tail.then(() => {
44
+ if (chains.get(providerId) === tail) chains.delete(providerId);
45
+ });
46
+ return queued;
47
+ }
48
+
49
+ return {
50
+ async read(providerId, options): Promise<Credential | undefined> {
51
+ options?.signal?.throwIfAborted();
52
+ return readFile(path)[providerId];
53
+ },
54
+ async list(options): Promise<readonly CredentialInfo[]> {
55
+ options?.signal?.throwIfAborted();
56
+ return Object.entries(readFile(path)).map(([providerId, credential]) => ({ providerId, type: credential.type }));
57
+ },
58
+ modify(providerId, fn, options): Promise<Credential | undefined> {
59
+ return enqueue(
60
+ providerId,
61
+ async () => {
62
+ const data = readFile(path);
63
+ const next = await fn(data[providerId]);
64
+ options?.signal?.throwIfAborted();
65
+ if (next !== undefined) data[providerId] = next;
66
+ writeFile(path, data);
67
+ return next ?? data[providerId];
68
+ },
69
+ options,
70
+ );
71
+ },
72
+ delete(providerId, options): Promise<void> {
73
+ return enqueue(
74
+ providerId,
75
+ async () => {
76
+ const data = readFile(path);
77
+ delete data[providerId];
78
+ writeFile(path, data);
79
+ },
80
+ options,
81
+ );
82
+ },
83
+ };
84
+ }
package/src/cli.ts CHANGED
@@ -4,7 +4,7 @@ import { loadConfig, resolveModel } from "./config.ts";
4
4
  import { buildSystemPrompt } from "./prompt.ts";
5
5
  import { acceptsImages, toolSchemas } from "./tools/index.ts";
6
6
  import { Session } from "./session.ts";
7
- import { NO_INTERACTION, runAgentTurn, type AgentOptions } from "./agent.ts";
7
+ import { NO_INTERACTION, assistantText, runAgentTurn, type AgentOptions } from "./agent.ts";
8
8
  import { runTui } from "./tui/tui.ts";
9
9
 
10
10
  function parseArgs(argv: string[]): { print: string | null } {
@@ -61,10 +61,7 @@ async function runPrint(prompt: string, ctx: AgentOptions): Promise<number> {
61
61
 
62
62
  const last = messages.filter((message): message is AssistantMessage => message.role === "assistant").at(-1);
63
63
  if (!failed && !cancelled && last !== undefined) {
64
- const text = last.content
65
- .filter((block) => block.type === "text")
66
- .map((block) => block.text)
67
- .join("");
64
+ const text = assistantText(last);
68
65
  if (text !== "") process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
69
66
  }
70
67
  return failed || cancelled ? 1 : 0;
@@ -80,6 +77,7 @@ async function main(): Promise<void> {
80
77
  model,
81
78
  systemPrompt: buildSystemPrompt(config),
82
79
  tools: toolSchemas(config.tools, acceptsImages(model)),
80
+ toolNames: config.tools,
83
81
  thinkingEffort: clampThinkingLevel(model, config.thinkingEffort),
84
82
  session,
85
83
  };
package/src/config.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { readFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  import {
5
5
  createProvider,
6
6
  envApiKeyAuth,
7
7
  Type,
8
8
  type Api,
9
9
  type Model,
10
+ type ModelThinkingLevel,
10
11
  type MutableModels,
11
12
  type Static,
12
13
  } from "@earendil-works/pi-ai";
@@ -16,6 +17,7 @@ import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messag
16
17
  import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy";
17
18
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
18
19
  import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
20
+ import { createCredentialStore } from "./auth.ts";
19
21
 
20
22
  const ToolNameSchema = Type.Union([Type.Literal("edit"), Type.Literal("read"), Type.Literal("bash")]);
21
23
  export type ToolName = Static<typeof ToolNameSchema>;
@@ -44,6 +46,7 @@ const CustomProviderSchema = Type.Object(
44
46
  const ConfigSchema = Type.Object(
45
47
  {
46
48
  sessionsDir: Type.String({ default: join(process.cwd(), "sessions") }),
49
+ authFile: Type.String({ default: join(configDir(), "auth.json") }),
47
50
  systemPrompt: Type.String({ default: "" }),
48
51
  discoverAgentFiles: Type.Boolean({ default: true }),
49
52
  skillsDirs: Type.Array(Type.String(), { default: [] }),
@@ -52,6 +55,7 @@ const ConfigSchema = Type.Object(
52
55
  model: Type.String(),
53
56
  thinkingEffort: Type.Union(
54
57
  [
58
+ Type.Literal("off"),
55
59
  Type.Literal("minimal"),
56
60
  Type.Literal("low"),
57
61
  Type.Literal("medium"),
@@ -67,9 +71,13 @@ const ConfigSchema = Type.Object(
67
71
  );
68
72
  export type Config = Static<typeof ConfigSchema>;
69
73
 
70
- function configPath(): string {
74
+ function configDir(): string {
71
75
  const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
72
- return join(base, "mini-coder", "config.json");
76
+ return join(base, "mini-coder");
77
+ }
78
+
79
+ function configPath(): string {
80
+ return join(configDir(), "config.json");
73
81
  }
74
82
 
75
83
  function readJson(path: string): unknown {
@@ -98,6 +106,27 @@ export function loadConfig(): Config {
98
106
  }
99
107
  }
100
108
 
109
+ /**
110
+ * Persist a patch to the global config, leaving every other key untouched.
111
+ * Written atomically: a temp file, then a rename over the target.
112
+ */
113
+ export function saveConfig(patch: {
114
+ provider?: string;
115
+ model?: string;
116
+ thinkingEffort?: ModelThinkingLevel;
117
+ }): void {
118
+ const path = configPath();
119
+ const existing = readJson(path);
120
+ if (typeof existing !== "object" || existing === null || Array.isArray(existing)) {
121
+ throw new Error(`config ${path}: not an object`);
122
+ }
123
+ const next = { ...existing, ...patch };
124
+ mkdirSync(dirname(path), { recursive: true });
125
+ const temp = join(dirname(path), `.${process.pid}.${Date.now()}.tmp`);
126
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`);
127
+ renameSync(temp, path);
128
+ }
129
+
101
130
  const API_FACTORY: Record<CustomApi, () => ReturnType<typeof openAICompletionsApi>> = {
102
131
  "openai-completions": openAICompletionsApi,
103
132
  "openai-responses": openAIResponsesApi,
@@ -106,7 +135,7 @@ const API_FACTORY: Record<CustomApi, () => ReturnType<typeof openAICompletionsAp
106
135
  };
107
136
 
108
137
  export function resolveModel(config: Config): { models: MutableModels; model: Model<Api> } {
109
- const models = builtinModels();
138
+ const models = builtinModels({ credentials: createCredentialStore(config.authFile) });
110
139
  for (const provider of config.customProviders) {
111
140
  const name = provider.name ?? provider.id;
112
141
  models.setProvider(
package/src/tools/bash.ts CHANGED
@@ -61,20 +61,17 @@ export function bash(args: BashArgs, ctx: ToolContext): Promise<ToolResult> {
61
61
  };
62
62
 
63
63
  let killTimer: NodeJS.Timeout | undefined;
64
- const onAbort = () => {
65
- if (!child.pid) return;
64
+ const kill = (signal: NodeJS.Signals): void => {
66
65
  try {
67
- process.kill(-child.pid, "SIGTERM");
66
+ process.kill(-child.pid!, signal);
68
67
  } catch {
69
68
  /* already gone */
70
69
  }
71
- killTimer = setTimeout(() => {
72
- try {
73
- process.kill(-child.pid!, "SIGKILL");
74
- } catch {
75
- /* already gone */
76
- }
77
- }, 300);
70
+ };
71
+ const onAbort = () => {
72
+ if (!child.pid) return;
73
+ kill("SIGTERM");
74
+ killTimer = setTimeout(() => kill("SIGKILL"), 300);
78
75
  };
79
76
 
80
77
  if (ctx.signal.aborted) onAbort();
@@ -1,14 +1,40 @@
1
- import { dim } from "./styles.ts";
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";
2
14
 
3
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;
4
26
  /** Appends already-styled lines to scrollback. */
5
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;
6
32
  }
7
33
 
8
- interface Command {
34
+ export interface Command {
9
35
  name: string; // no leading slash, lowercase
10
36
  description: string; // one line, shown by /help
11
- run(ctx: CommandContext, args: string): void;
37
+ run(ctx: CommandContext, args: string): void | Promise<void>;
12
38
  }
13
39
 
14
40
  /** The keybindings the TUI accepts, in the order `/help` prints them. */
@@ -18,6 +44,7 @@ const KEYBINDINGS: [string, string][] = [
18
44
  ["Esc", "pause the turn at the next step boundary"],
19
45
  ["Ctrl+C", "cancel the turn"],
20
46
  ["Ctrl+D", "exit on an empty draft"],
47
+ ["Tab", "complete command or path"],
21
48
  ];
22
49
 
23
50
  /** One aligned `key description` block; the key column is dimmed. */
@@ -35,9 +62,123 @@ const help: Command = {
35
62
  },
36
63
  };
37
64
 
38
- const COMMANDS: Command[] = [help];
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}`);
39
81
 
40
- /** `/name args` for a known `name`, else null; unknown slash text stays a message. */
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. */
41
182
  export function findCommand(text: string): { command: Command; args: string } | null {
42
183
  const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(text);
43
184
  if (match === null) return null;
@@ -56,8 +197,3 @@ export function completeCommand(draft: string): string | null {
56
197
  return shared === typed ? null : `/${shared}`;
57
198
  }
58
199
 
59
- function commonPrefix(a: string, b: string): string {
60
- let i = 0;
61
- while (i < a.length && i < b.length && a[i] === b[i]) i++;
62
- return a.slice(0, i);
63
- }
@@ -0,0 +1,85 @@
1
+ import { readdirSync, statSync, type Dirent } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+
5
+ /**
6
+ * Deliberately bash-unfaithful: no `cdable_vars`, no `~user` expansion (`~`
7
+ * always means `homedir()`), no `$VAR`, backtick, or history expansion (word
8
+ * text is always literal), and no shell options — dotfile hiding, byte-exact
9
+ * matching, and dir-slash behavior below hold unconditionally.
10
+ */
11
+
12
+ /**
13
+ * A word must already carry path syntax to be completed: a leading `~`, a
14
+ * leading `./` or `../` (or bare `.` / `..`), a leading `/`, or any segment
15
+ * followed by a slash somewhere in the word. Bare words (`re`, `hello`) return
16
+ * null without a syscall, so mid-sentence prose never gets hijacked.
17
+ */
18
+ const PATH_LIKE = /^(?:~|\/|\.{1,2}(?:\/|$)|\w+\/)/;
19
+
20
+ /** A directory, or a symlink that resolves to one (`statSync` follows). */
21
+ function isDir(path: string, entry: Dirent): boolean {
22
+ if (entry.isDirectory()) return true;
23
+ if (!entry.isSymbolicLink()) return false;
24
+ try {
25
+ return statSync(join(path, entry.name)).isDirectory();
26
+ } catch {
27
+ return false; // broken link completes file-shaped, like bash.
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Completes `word` as a file path, bash-readline-style: the returned word is
33
+ * the whole feedback, a strict extension of what was typed. Unreadable
34
+ * locations, paths through files, and nothing longer to add all give null and
35
+ * the caller leaves the draft alone.
36
+ */
37
+ export function completePath(word: string, cwd: string): string | null {
38
+ if (!PATH_LIKE.test(word)) return null;
39
+
40
+ // Word = dirPart + prefix, split at the last slash, slash kept with dirPart.
41
+ const slash = word.lastIndexOf("/");
42
+ const prefix = word.slice(slash + 1);
43
+
44
+ // Resolve dirPart to the directory to list. `~` means homedir() here and
45
+ // nowhere else: the returned word below reuses the user's shorthand verbatim
46
+ // (`~/Doc⇥` stays `~/Documents/`), so this resolve only locates the listing.
47
+ // Concatenate before resolving — `resolve(home, "/x")` would reset to root.
48
+ let dir: string;
49
+ if (word.startsWith("~")) dir = resolve(homedir() + word.slice(1, slash));
50
+ else if (slash === 0) dir = "/";
51
+ else dir = resolve(cwd, word.slice(0, slash));
52
+
53
+ let entries;
54
+ try {
55
+ entries = readdirSync(dir, { withFileTypes: true });
56
+ } catch {
57
+ return null; // vanished, permission, or a file in the path: nothing better.
58
+ }
59
+
60
+ // Bash hides dotfiles unless the prefix itself starts with one (`~/.⇥`
61
+ // includes hidden entries). A directory completion grows a trailing `/` so
62
+ // the next Tab descends into it; a symlink to a directory counts as one —
63
+ // `withFileTypes` reports the link itself, so the target needs one stat.
64
+ const candidates = entries
65
+ .filter((entry) => entry.name.startsWith(prefix) && (prefix.startsWith(".") || !entry.name.startsWith(".")))
66
+ .map((entry) => (isDir(dir, entry) ? `${entry.name}/` : entry.name));
67
+ if (candidates.length === 0) return null;
68
+ if (candidates.length === 1) {
69
+ // Worth returning only when it adds something the user did not already
70
+ // type (`renameTag` typed against the single file match `renameTag` → null).
71
+ return candidates[0] === prefix ? null : word.slice(0, word.length - prefix.length) + candidates[0];
72
+ }
73
+ const shared = candidates.reduce(commonPrefix);
74
+ // Recombine over the untouched dirPart, so `~/Doc⇥` becomes `~/Documents/`
75
+ // and never `/home/xonecas/Documents/`; no exact-name short-circuit here.
76
+ return shared.length > prefix.length ? word.slice(0, word.length - prefix.length) + shared : null;
77
+ }
78
+
79
+ /** Case-sensitive longest common prefix; commands.ts uses it for command
80
+ * completion, completePath for path candidates. */
81
+ export function commonPrefix(a: string, b: string): string {
82
+ let i = 0;
83
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
84
+ return a.slice(0, i);
85
+ }
package/src/tui/editor.ts CHANGED
@@ -39,11 +39,21 @@ export class Editor {
39
39
  private col = 0;
40
40
  private scroll = 0;
41
41
  private width = DEFAULT_WIDTH;
42
+ private masked = false;
42
43
 
43
44
  text(): string {
44
45
  return this.lines.join("\n");
45
46
  }
46
47
 
48
+ /** Renders each character as `*` without changing the underlying text. */
49
+ setMasked(masked: boolean): void {
50
+ this.masked = masked;
51
+ }
52
+
53
+ private display(line: string): string {
54
+ return this.masked ? "*".repeat(codePoints(line).length) : line;
55
+ }
56
+
47
57
  clear(): void {
48
58
  this.lines = [""];
49
59
  this.row = 0;
@@ -121,7 +131,7 @@ export class Editor {
121
131
  let cursorRow = 0;
122
132
  let cursorCol = 0;
123
133
  for (let line = 0; line < this.lines.length; line++) {
124
- const chunks = wrapLine(expandTabs(this.lines[line], TAB), this.width);
134
+ const chunks = wrapLine(expandTabs(this.display(this.lines[line]), TAB), this.width);
125
135
  if (line === this.row) {
126
136
  const caret = this.caret();
127
137
  cursorRow = rows.length + caret.row;
@@ -142,7 +152,7 @@ export class Editor {
142
152
 
143
153
  /** The caret's display row within its logical line, and its cell column. */
144
154
  private caret(): { row: number; col: number } {
145
- const line = this.lines[this.row];
155
+ const line = this.display(this.lines[this.row]);
146
156
  const chunks = wrapLine(expandTabs(line, TAB), this.width);
147
157
  const cell = this.cells(line)[this.col];
148
158
  const row = Math.floor(cell / this.width);
@@ -288,4 +298,23 @@ export class Editor {
288
298
  this.lines[this.row] = current.slice(0, start).join("") + current.slice(this.col).join("");
289
299
  this.col = start;
290
300
  }
301
+
302
+ /**
303
+ * Applies `step` to the word ending at the caret, replacing it; false when
304
+ * there is no word before the caret. Whitespace is `isSpace` (Unicode `\s`),
305
+ * so a path word may carry `/`, `~`, `.`, `$` — no path-specific separator
306
+ * set. No completion-undo: bash's TAB-_ restore is out; the user backspaces
307
+ * or re-types instead, with Esc and the editor's own keys as recovery.
308
+ */
309
+ completeWord(step: (word: string) => string | null): boolean {
310
+ const current = codePoints(this.lines[this.row]);
311
+ const start = wordStart(current, this.col);
312
+ if (start === this.col || isSpace(current[this.col - 1])) return false;
313
+ const word = current.slice(start, this.col).join("");
314
+ const completed = step(word);
315
+ if (completed === null) return false;
316
+ this.lines[this.row] = current.slice(0, start).join("") + completed + current.slice(this.col).join("");
317
+ this.col = start + codePoints(completed).length;
318
+ return true;
319
+ }
291
320
  }
package/src/tui/styles.ts CHANGED
@@ -5,38 +5,16 @@ import { NORMAL_FG, PALETTE, sgrFg } from "./theme.ts";
5
5
  * re-asserting `Normal`'s, never by resetting: the row's background, set by the
6
6
  * renderer, must survive, and no cell may fall back to the terminal's colours.
7
7
  */
8
- function foreground(text: string, hex: string): string {
9
- return `${sgrFg(hex)}${text}${sgrFg(NORMAL_FG)}`;
10
- }
8
+ const fg = (hex: string) => (text: string): string => `${sgrFg(hex)}${text}${sgrFg(NORMAL_FG)}`;
11
9
 
12
10
  /** `Comment`, the colour diff context lines share. */
13
- export function dim(text: string): string {
14
- return foreground(text, PALETTE.comment);
15
- }
16
-
17
- export function red(text: string): string {
18
- return foreground(text, PALETTE.red);
19
- }
20
-
21
- export function green(text: string): string {
22
- return foreground(text, PALETTE.green);
23
- }
24
-
25
- export function yellow(text: string): string {
26
- return foreground(text, PALETTE.yellow);
27
- }
28
-
11
+ export const dim = fg(PALETTE.comment);
12
+ export const red = fg(PALETTE.red);
13
+ export const green = fg(PALETTE.green);
14
+ export const yellow = fg(PALETTE.yellow);
29
15
  /** Diff hunk headers; the palette's `blue` sits closest to the old ANSI cyan. */
30
- export function cyan(text: string): string {
31
- return foreground(text, PALETTE.blue);
32
- }
33
-
16
+ export const cyan = fg(PALETTE.blue);
34
17
  /** The user's own words; also `Function`, which user rows never collide with. */
35
- export function blue(text: string): string {
36
- return foreground(text, PALETTE.blue);
37
- }
38
-
18
+ export const blue = fg(PALETTE.blue);
39
19
  /** The tool accent: call-line heads. */
40
- export function teal(text: string): string {
41
- return foreground(text, PALETTE.teal);
42
- }
20
+ export const teal = fg(PALETTE.teal);
package/src/tui/theme.ts CHANGED
@@ -78,7 +78,6 @@ export const STYLES: Record<string, Style | undefined> = {
78
78
  escape: { fg: PALETTE.magenta },
79
79
  function: { fg: PALETTE.blue },
80
80
  "function.builtin": { fg: PALETTE.blue1 },
81
- "function.method": { fg: PALETTE.blue },
82
81
  keyword: { fg: PALETTE.purple },
83
82
  number: { fg: PALETTE.orange },
84
83
  operator: { fg: PALETTE.blue5 },
package/src/tui/tui.ts CHANGED
@@ -1,9 +1,22 @@
1
1
  import process from "node:process";
2
- import type { AssistantMessage, JsonObject, Message, UserMessage } from "@earendil-works/pi-ai";
3
- import { runAgentTurn, type AgentEvent, type AgentOptions, type Phase } from "../agent.ts";
2
+ import {
3
+ clampThinkingLevel,
4
+ type Api,
5
+ type AssistantMessage,
6
+ type AuthEvent,
7
+ type AuthPrompt,
8
+ type JsonObject,
9
+ type Message,
10
+ type Model,
11
+ type ModelThinkingLevel,
12
+ type UserMessage,
13
+ } from "@earendil-works/pi-ai";
14
+ import { assistantText, runAgentTurn, type AgentEvent, type AgentOptions, type Phase } from "../agent.ts";
15
+ import { acceptsImages, toolSchemas } from "../tools/index.ts";
4
16
  import { Terminal, expandTabs, sanitize, wrapLine, type Key } from "./term.ts";
5
17
  import { Editor } from "./editor.ts";
6
- import { completeCommand, findCommand, type CommandContext } from "./commands.ts";
18
+ import { completeCommand, findCommand, type Command, type CommandContext } from "./commands.ts";
19
+ import { completePath } from "./complete.ts";
7
20
  import { MarkdownStream, TailStream, type BodyLine, type StreamRenderer } from "./stream.ts";
8
21
  import { blue, cyan, dim, green, red, teal } from "./styles.ts";
9
22
  import { DIFF_ADD, DIFF_DELETE, NORMAL_BG, sgrBg, sgrPlain } from "./theme.ts";
@@ -176,14 +189,13 @@ class Tui {
176
189
  private readonly messages: Message[] = [];
177
190
  readonly done: Promise<void>;
178
191
 
179
- /** The only capability a command gets: styled lines into scrollback. */
180
- private readonly commandContext: CommandContext = {
181
- write: (lines) => {
182
- this.separator = true;
183
- this.commitLines(lines.map((text) => ({ text })));
184
- this.separator = true;
185
- },
186
- };
192
+ /** A running command's cancellation, and its one pending prompt. */
193
+ private commandAbort: AbortController | null = null;
194
+ private pendingPrompt: {
195
+ prompt: AuthPrompt;
196
+ resolve: (value: string) => void;
197
+ reject: (error: Error) => void;
198
+ } | null = null;
187
199
 
188
200
  private resolveExit: () => void = () => {};
189
201
  private phase: Phase = "idle";
@@ -230,9 +242,7 @@ class Tui {
230
242
  this.term.start();
231
243
  process.on("SIGINT", this.onSignal);
232
244
  process.on("SIGTERM", this.onSignal);
233
- this.separator = true;
234
- this.push(`mini-coder · ${this.opts.model.provider}/${this.opts.model.id} · ${this.opts.thinkingEffort}`);
235
- this.separator = true;
245
+ this.pushBanner();
236
246
  this.render();
237
247
  }
238
248
 
@@ -240,11 +250,12 @@ class Tui {
240
250
 
241
251
  private handleKey(key: Key): void {
242
252
  if (key.type === "eof") {
243
- if (!this.active && this.editor.text() === "") this.exit();
253
+ if (!this.active && this.commandAbort === null && this.editor.text() === "") this.exit();
244
254
  return;
245
255
  }
246
256
  if (key.type === "interrupt") {
247
257
  if (this.active) this.cancel();
258
+ else if (this.commandAbort !== null) this.commandAbort.abort();
248
259
  else if (this.editor.text() !== "") {
249
260
  this.editor.clear();
250
261
  this.render();
@@ -262,8 +273,10 @@ class Tui {
262
273
  const completed = completeCommand(this.editor.text());
263
274
  if (completed !== null) {
264
275
  this.editor.setText(completed);
265
- this.render();
276
+ } else {
277
+ this.editor.completeWord((word) => completePath(word, process.cwd()));
266
278
  }
279
+ this.render();
267
280
  return;
268
281
  }
269
282
  const result = this.editor.handle(key);
@@ -281,11 +294,18 @@ class Tui {
281
294
  }
282
295
  return;
283
296
  }
297
+ if (this.pendingPrompt !== null) {
298
+ this.editor.clear();
299
+ this.render();
300
+ this.answerPrompt(text);
301
+ return;
302
+ }
303
+ if (this.commandAbort !== null) return;
284
304
  if (text.trim() === "") return;
285
- const invocation = findCommand(text);
286
- if (invocation !== null) {
305
+ const found = findCommand(text);
306
+ if (found !== null) {
287
307
  this.editor.clear();
288
- invocation.command.run(this.commandContext, invocation.args);
308
+ this.runCommand(found.command, found.args);
289
309
  this.render();
290
310
  return;
291
311
  }
@@ -297,6 +317,152 @@ class Tui {
297
317
  this.startTurn();
298
318
  }
299
319
 
320
+ /**
321
+ * Runs one command with a fresh context. Lines submitted while it runs route
322
+ * to a pending prompt; Ctrl+C aborts its signal. No agent turn may start
323
+ * until it settles. Errors surface as an error line.
324
+ */
325
+ private runCommand(command: Command, args: string): void {
326
+ const abort = new AbortController();
327
+ this.commandAbort = abort;
328
+ const ctx: CommandContext = {
329
+ models: this.opts.models,
330
+ model: this.opts.model,
331
+ select: (model) => this.select(model),
332
+ setThinking: (level) => this.setThinking(level),
333
+ signal: abort.signal,
334
+ write: (lines) => {
335
+ this.separator = true;
336
+ this.commitLines(lines.map((text) => ({ text })));
337
+ this.separator = true;
338
+ },
339
+ prompt: (prompt) => this.ask(prompt),
340
+ notify: (event) => this.notify(event),
341
+ };
342
+ void (async () => {
343
+ try {
344
+ await command.run(ctx, args);
345
+ } catch (error) {
346
+ this.separator = true;
347
+ this.push(red(`! ${(error as Error).message}`));
348
+ this.separator = true;
349
+ } finally {
350
+ this.commandAbort = null;
351
+ this.pendingPrompt = null;
352
+ this.editor.setMasked(false);
353
+ this.render();
354
+ }
355
+ })();
356
+ }
357
+
358
+ /** The startup and selection banner: provider, model, and thinking effort. */
359
+ private pushBanner(): void {
360
+ this.separator = true;
361
+ this.push(`mini-coder · ${this.opts.model.provider}/${this.opts.model.id} · ${this.opts.thinkingEffort}`);
362
+ this.separator = true;
363
+ }
364
+
365
+ /**
366
+ * Switches the running session to `model`: derived state follows — the
367
+ * thinking effort is re-clamped, and `read`'s image behaviour is rebuilt for
368
+ * the new model. The status line and context readout read `opts.model`, so
369
+ * they update on the next render.
370
+ */
371
+ private select(model: Model<Api>): void {
372
+ this.opts.model = model;
373
+ this.opts.thinkingEffort = clampThinkingLevel(model, this.opts.thinkingEffort);
374
+ this.opts.tools = toolSchemas(this.opts.toolNames, acceptsImages(model));
375
+ this.pushBanner();
376
+ this.render();
377
+ }
378
+
379
+ /** Sets the running session's thinking level, clamped to the current model. */
380
+ private setThinking(level: ModelThinkingLevel): void {
381
+ this.opts.thinkingEffort = clampThinkingLevel(this.opts.model, level);
382
+ this.pushBanner();
383
+ this.render();
384
+ }
385
+
386
+ /** Commits a prompt and returns a promise resolving with the next submitted line. */
387
+ private ask(prompt: AuthPrompt): Promise<string> {
388
+ if (this.pendingPrompt !== null) return Promise.reject(new Error("a prompt is already pending"));
389
+ this.separator = true;
390
+ this.push(prompt.message);
391
+ if (prompt.type === "select") {
392
+ for (let i = 0; i < prompt.options.length; i++) this.push(` ${i + 1}. ${prompt.options[i].label}`);
393
+ } else if (prompt.placeholder !== undefined) {
394
+ this.push(` (${prompt.placeholder})`);
395
+ }
396
+ this.separator = true;
397
+ this.editor.setMasked(prompt.type === "secret");
398
+ this.render();
399
+
400
+ return new Promise<string>((resolve, reject) => {
401
+ const flow = this.commandAbort?.signal;
402
+ const onAbort = (): void => {
403
+ settle(() => reject(new Error("cancelled")));
404
+ };
405
+ const settle = (fn: () => void): void => {
406
+ prompt.signal?.removeEventListener("abort", onAbort);
407
+ flow?.removeEventListener("abort", onAbort);
408
+ this.editor.setMasked(false);
409
+ this.pendingPrompt = null;
410
+ this.render();
411
+ fn();
412
+ };
413
+ if (prompt.signal?.aborted === true || flow?.aborted === true) {
414
+ onAbort();
415
+ return;
416
+ }
417
+ prompt.signal?.addEventListener("abort", onAbort);
418
+ flow?.addEventListener("abort", onAbort);
419
+ this.pendingPrompt = {
420
+ prompt,
421
+ resolve: (value) => settle(() => resolve(value)),
422
+ reject: (error) => settle(() => reject(error)),
423
+ };
424
+ });
425
+ }
426
+
427
+ /** Turns the submitted line into the answer: a `select` maps to its option id. */
428
+ private answerPrompt(text: string): void {
429
+ const pending = this.pendingPrompt;
430
+ if (pending === null) return;
431
+ if (pending.prompt.type !== "select") {
432
+ pending.resolve(text);
433
+ return;
434
+ }
435
+ const trimmed = text.trim();
436
+ const index = Number.parseInt(trimmed, 10);
437
+ const byIndex = String(index) === trimmed ? pending.prompt.options[index - 1] : undefined;
438
+ const chosen = byIndex ?? pending.prompt.options.find((option) => option.id === trimmed || option.label === trimmed);
439
+ if (chosen === undefined) pending.reject(new Error(`invalid selection: ${trimmed}`));
440
+ else pending.resolve(chosen.id);
441
+ }
442
+
443
+ private notify(event: AuthEvent): void {
444
+ this.separator = true;
445
+ switch (event.type) {
446
+ case "info":
447
+ this.push(event.message);
448
+ for (const link of event.links ?? []) this.push(link.label === undefined ? link.url : `${link.label}: ${link.url}`);
449
+ break;
450
+ case "auth_url":
451
+ this.push(event.url);
452
+ if (event.instructions !== undefined) this.push(event.instructions);
453
+ break;
454
+ case "device_code":
455
+ this.push(event.verificationUri);
456
+ this.push(`code: ${event.userCode}`);
457
+ if (event.expiresInSeconds !== undefined) this.push(`expires in ${event.expiresInSeconds}s`);
458
+ break;
459
+ case "progress":
460
+ this.push(event.message);
461
+ break;
462
+ }
463
+ this.separator = true;
464
+ }
465
+
300
466
  private startTurn(): void {
301
467
  this.active = true;
302
468
  this.abort = new AbortController();
@@ -440,13 +606,9 @@ class Tui {
440
606
  private commitMessage(message: AssistantMessage): void {
441
607
  this.activity.reset();
442
608
  this.commitLines(this.reply.flush());
443
- const text = message.content
444
- .filter((block) => block.type === "text")
445
- .map((block) => block.text)
446
- .join("");
609
+ const text = assistantText(message);
447
610
  if (text.trim() !== "" && !this.streamed.includes(text)) {
448
- const stream = new MarkdownStream();
449
- this.commitLines([...stream.feed(text.trimEnd()), ...stream.flush()]);
611
+ this.commitLines([...this.reply.feed(text.trimEnd()), ...this.reply.flush()]);
450
612
  }
451
613
  this.streamed = "";
452
614
  }
@@ -575,6 +737,7 @@ class Tui {
575
737
  if (this.closed) return;
576
738
  this.closed = true;
577
739
  this.abort?.abort();
740
+ this.commandAbort?.abort();
578
741
  if (this.spinner) clearInterval(this.spinner);
579
742
  this.spinner = undefined;
580
743
  // Draws are deferred, so anything pushed since the last frame is still here.