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 +3 -3
- package/demo.gif +0 -0
- package/package.json +1 -1
- package/src/agent.ts +20 -6
- package/src/auth.ts +84 -0
- package/src/cli.ts +3 -5
- package/src/config.ts +34 -5
- package/src/tools/bash.ts +7 -10
- package/src/tui/commands.ts +146 -10
- package/src/tui/complete.ts +85 -0
- package/src/tui/editor.ts +31 -2
- package/src/tui/styles.ts +8 -30
- package/src/tui/theme.ts +0 -1
- package/src/tui/tui.ts +188 -25
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": "
|
|
25
|
-
"model": "
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
+
cancelled();
|
|
152
166
|
return;
|
|
153
167
|
}
|
|
154
168
|
if (assistant.stopReason === "error") {
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
74
|
+
function configDir(): string {
|
|
71
75
|
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
72
|
-
return join(base, "mini-coder"
|
|
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
|
|
65
|
-
if (!child.pid) return;
|
|
64
|
+
const kill = (signal: NodeJS.Signals): void => {
|
|
66
65
|
try {
|
|
67
|
-
process.kill(-child.pid
|
|
66
|
+
process.kill(-child.pid!, signal);
|
|
68
67
|
} catch {
|
|
69
68
|
/* already gone */
|
|
70
69
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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();
|
package/src/tui/commands.ts
CHANGED
|
@@ -1,14 +1,40 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3
|
-
|
|
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
|
-
/**
|
|
180
|
-
private
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
286
|
-
if (
|
|
305
|
+
const found = findCommand(text);
|
|
306
|
+
if (found !== null) {
|
|
287
307
|
this.editor.clear();
|
|
288
|
-
|
|
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
|
|
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
|
-
|
|
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.
|