@synqa/cli 1.2.0 → 1.2.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 +8 -1
- package/dist/agent.js +68 -12
- package/dist/api.js +66 -6
- package/dist/main.js +37 -10
- package/dist/prompt.js +28 -0
- package/dist/ui.js +108 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,6 +85,11 @@ nothing is saved.
|
|
|
85
85
|
- Shift+Tab cycles the permission mode: `ask before edits`, `auto-accept edits` (file edits in the
|
|
86
86
|
workspace apply without a question, like `--yes`; shell commands still ask) and `plan only` (no
|
|
87
87
|
file is written and no command is run). The mode shows in the footer.
|
|
88
|
+
- While an answer streams, the keyboard is locked: what you type is not echoed or kept. A tool call
|
|
89
|
+
that is still arriving (for example a large `write_file`) shows a live line such as
|
|
90
|
+
`⠋ write_file index.html · streaming 18.4 KB…`. Esc or Ctrl+C stops the request and returns to the
|
|
91
|
+
prompt; if a shell command is still running, press it a second time to leave. A tool call cut off
|
|
92
|
+
by the output limit is never run: the model is told to split the work into smaller files.
|
|
88
93
|
|
|
89
94
|
## Hooks
|
|
90
95
|
|
|
@@ -328,7 +333,9 @@ it. Use git for that.
|
|
|
328
333
|
## Develop
|
|
329
334
|
|
|
330
335
|
From the repo root: `node --test 'cli/test/*.test.ts'` (or `npm test` inside `cli/`). Tests use fake
|
|
331
|
-
`fetch`, fake terminals and temporary folders and never call the live API.
|
|
336
|
+
`fetch`, fake terminals and temporary folders and never call the live API. `test/pty.test.ts` also
|
|
337
|
+
runs the real CLI in a pseudo-terminal through a small Python driver (standard library only) with a
|
|
338
|
+
throwaway home folder and no network; it skips with a message where Python or a PTY is missing.
|
|
332
339
|
|
|
333
340
|
Sources are TypeScript run directly by Node (type stripping); only erasable syntax is allowed. Node
|
|
334
341
|
will not strip types inside `node_modules`, so the published package ships plain JS: `npm run build`
|
package/dist/agent.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fenceText } from "./sanitize.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { AbortedError, chat } from "./api.js";
|
|
3
|
+
|
|
4
4
|
import { runTool, TOOL_DEFS } from "./tools.js";
|
|
5
5
|
|
|
6
6
|
|
|
@@ -42,6 +42,8 @@ export function systemPrompt(tier , cwd , notes , external =
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
|
|
46
|
+
|
|
45
47
|
|
|
46
48
|
|
|
47
49
|
|
|
@@ -57,6 +59,7 @@ export function systemPrompt(tier , cwd , notes , external =
|
|
|
57
59
|
|
|
58
60
|
|
|
59
61
|
|
|
62
|
+
|
|
60
63
|
|
|
61
64
|
|
|
62
65
|
// PreToolUse and PostToolUse hooks. pre returns a reason to block the tool, or undefined to run it.
|
|
@@ -73,6 +76,44 @@ export function systemPrompt(tier , cwd , notes , external =
|
|
|
73
76
|
|
|
74
77
|
|
|
75
78
|
|
|
79
|
+
// What the model is told when a tool call was cut off by the output limit. No provider or model
|
|
80
|
+
// name, and no file content: only what to do next.
|
|
81
|
+
export const CUT_OFF_RESULT =
|
|
82
|
+
"error: this tool call was cut off before its arguments were complete (the output limit was reached), so nothing was run and nothing was written. Split the work into smaller modular files (for example styles.css, app.js and index.html), one short tool call each, and do not repeat the large call.";
|
|
83
|
+
|
|
84
|
+
// A different failure, a different message: the stream ended early (a dropped connection), not the
|
|
85
|
+
// output limit. The call may be fine to try again as it was.
|
|
86
|
+
export const CONNECTION_LOST_RESULT =
|
|
87
|
+
"error: the connection ended before this tool call was complete, so nothing was run and nothing was written. This is not a size problem: try the same call again.";
|
|
88
|
+
|
|
89
|
+
export const CONNECTION_LOST_NOTICE = "connection lost before the response finished; no tool was run";
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
// Decides whether a streamed tool call can run. Its arguments must be complete JSON (an object).
|
|
94
|
+
// Policy for finish_reason "length": the cap can only cut the last call, and a call whose JSON
|
|
95
|
+
// parsed is complete (a string closed with its quote cannot hold a truncated file), so it runs
|
|
96
|
+
// as usual; a call with no arguments at all is treated as cut off, because a tool that needs
|
|
97
|
+
// arguments was begun and never filled. Anything that does not parse is never run, and its
|
|
98
|
+
// arguments are stored as "{}" so the next request stays valid.
|
|
99
|
+
// complete: false when the stream ended without [DONE] and without a finish_reason (a dropped
|
|
100
|
+
// connection): then no call is trusted, whatever its arguments look like. Whatever the outcome,
|
|
101
|
+
// `stored` is always a valid JSON object text, so the next request cannot be refused for it.
|
|
102
|
+
export function checkCall(raw , finishReason , complete = true) {
|
|
103
|
+
if (!complete) return { ok: false, stored: "{}", result: CONNECTION_LOST_RESULT };
|
|
104
|
+
if (raw.trim() === "") {
|
|
105
|
+
return finishReason === "length" ? { ok: false, stored: "{}", result: CUT_OFF_RESULT } : { ok: true, args: {}, stored: "{}" };
|
|
106
|
+
}
|
|
107
|
+
let v ;
|
|
108
|
+
try {
|
|
109
|
+
v = JSON.parse(raw);
|
|
110
|
+
} catch {
|
|
111
|
+
return { ok: false, stored: "{}", result: CUT_OFF_RESULT };
|
|
112
|
+
}
|
|
113
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return { ok: false, stored: "{}", result: "error: tool arguments must be a JSON object" };
|
|
114
|
+
return { ok: true, args: v , stored: raw };
|
|
115
|
+
}
|
|
116
|
+
|
|
76
117
|
// Runs model -> tools -> model until the model answers without tool calls. Mutates history.
|
|
77
118
|
export async function runAgent(o ) {
|
|
78
119
|
const total = { prompt: 0, completion: 0 };
|
|
@@ -86,6 +127,7 @@ export async function runAgent(o ) {
|
|
|
86
127
|
let routing = null;
|
|
87
128
|
let lastUsage = { prompt: 0, completion: 0 };
|
|
88
129
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
130
|
+
if (o.signal?.aborted) throw new AbortedError();
|
|
89
131
|
o.events.onRequest?.();
|
|
90
132
|
const r = await chat({
|
|
91
133
|
apiKey: o.apiKey,
|
|
@@ -95,6 +137,8 @@ export async function runAgent(o ) {
|
|
|
95
137
|
tools: TOOL_DEFS,
|
|
96
138
|
fetchFn: o.fetchFn,
|
|
97
139
|
onText: o.events.onText,
|
|
140
|
+
onToolProgress: o.events.onToolProgress,
|
|
141
|
+
signal: o.signal,
|
|
98
142
|
timeoutMs: o.timeoutMs,
|
|
99
143
|
reasoningEffort: o.effort && o.effort !== "default" ? o.effort : undefined,
|
|
100
144
|
now: o.now,
|
|
@@ -111,25 +155,37 @@ export async function runAgent(o ) {
|
|
|
111
155
|
lastUsage = r.usage;
|
|
112
156
|
}
|
|
113
157
|
o.events.onTurn(r);
|
|
158
|
+
if (r.complete === false && r.toolCalls.length) o.events.onNotice?.(CONNECTION_LOST_NOTICE);
|
|
159
|
+
const checks = r.toolCalls.map((c) => checkCall(c.arguments, r.finishReason, r.complete !== false));
|
|
114
160
|
const assistant = { role: "assistant", content: r.content || null };
|
|
115
161
|
if (r.toolCalls.length) {
|
|
116
|
-
assistant.tool_calls = r.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments:
|
|
162
|
+
assistant.tool_calls = r.toolCalls.map((c, i) => ({ id: c.id, type: "function", function: { name: c.name, arguments: (checks[i] ).stored } }));
|
|
117
163
|
}
|
|
118
164
|
o.history.push(assistant);
|
|
119
165
|
if (!r.toolCalls.length) return result();
|
|
120
|
-
|
|
121
|
-
|
|
166
|
+
// Every call gets exactly one result, also when the person stops the run between calls.
|
|
167
|
+
for (const [i, c] of r.toolCalls.entries()) {
|
|
168
|
+
const check = checks[i] ;
|
|
169
|
+
const shownArgs = check.stored;
|
|
170
|
+
o.events.onTool(c.name, shownArgs);
|
|
122
171
|
let result ;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
172
|
+
if (o.signal?.aborted) {
|
|
173
|
+
result = "error: stopped by the user before this tool ran";
|
|
174
|
+
} else if (!check.ok) {
|
|
175
|
+
result = check.result;
|
|
176
|
+
} else {
|
|
177
|
+
try {
|
|
178
|
+
const blocked = await o.hooks?.pre(c.name, c.arguments);
|
|
179
|
+
result = blocked !== undefined ? `blocked by a hook: ${blocked}` : await runTool(o.ctx, c.name, check.args);
|
|
180
|
+
if (blocked === undefined) await o.hooks?.post(c.name, c.arguments, result);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
result = `error: ${err instanceof Error ? err.message : String(err)}`;
|
|
183
|
+
}
|
|
129
184
|
}
|
|
130
|
-
o.events.onToolResult?.(c.name,
|
|
185
|
+
o.events.onToolResult?.(c.name, shownArgs, result);
|
|
131
186
|
o.history.push({ role: "tool", tool_call_id: c.id, content: result });
|
|
132
187
|
}
|
|
188
|
+
if (o.signal?.aborted) throw new AbortedError();
|
|
133
189
|
}
|
|
134
190
|
o.events.onText("\n[stopped: step limit reached]\n");
|
|
135
191
|
return result();
|
package/dist/api.js
CHANGED
|
@@ -14,14 +14,42 @@ import { REQUEST_TIMEOUT_MS, MAX_OUTPUT_TOKENS, usageFrom } from "./tiers.js";
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
// What the terminal shows while a tool call is still streaming in.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const PATH_SCAN_CHARS = 4096;
|
|
32
|
+
|
|
33
|
+
// The "path" argument out of a possibly cut-off JSON object. Only a string whose closing quote has
|
|
34
|
+
// arrived counts, so a half-received path is never shown. A "path" key inside another string value
|
|
35
|
+
// is escaped there (\"path\"), so it cannot match.
|
|
36
|
+
export function partialPath(raw ) {
|
|
37
|
+
const m = /"path"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(raw);
|
|
38
|
+
if (!m) return undefined;
|
|
39
|
+
try {
|
|
40
|
+
const v = JSON.parse(`"${m[1]}"`);
|
|
41
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
20
46
|
|
|
21
47
|
// Folds streamed chunks into text and complete tool calls.
|
|
22
48
|
export class StreamAccumulator {
|
|
23
49
|
content = "";
|
|
24
50
|
usage ;
|
|
51
|
+
finishReason ;
|
|
52
|
+
onToolProgress ;
|
|
25
53
|
calls = new Map ();
|
|
26
54
|
|
|
27
55
|
push(chunk ) {
|
|
@@ -32,14 +60,27 @@ export class StreamAccumulator {
|
|
|
32
60
|
text = delta.content;
|
|
33
61
|
this.content += text;
|
|
34
62
|
}
|
|
63
|
+
if (typeof choice?.finish_reason === "string") this.finishReason = choice.finish_reason;
|
|
35
64
|
if (delta && Array.isArray(delta.tool_calls)) {
|
|
36
65
|
for (const tc of delta.tool_calls) {
|
|
37
66
|
const idx = typeof tc.index === "number" ? tc.index : 0;
|
|
38
|
-
const cur = this.calls.get(idx) ?? { id: "", name: "", arguments: "" };
|
|
67
|
+
const cur = this.calls.get(idx) ?? { id: "", name: "", arguments: "", bytes: 0 };
|
|
39
68
|
if (tc.id) cur.id = tc.id;
|
|
40
69
|
if (tc.function?.name) cur.name += tc.function.name;
|
|
41
|
-
if (typeof tc.function?.arguments === "string")
|
|
70
|
+
if (typeof tc.function?.arguments === "string") {
|
|
71
|
+
cur.arguments += tc.function.arguments;
|
|
72
|
+
cur.bytes += Buffer.byteLength(tc.function.arguments);
|
|
73
|
+
}
|
|
42
74
|
this.calls.set(idx, cur);
|
|
75
|
+
if (this.onToolProgress && cur.name) {
|
|
76
|
+
// The path is looked for only near the start and only until it is found, so a 60 KB
|
|
77
|
+
// argument string is not rescanned on every chunk.
|
|
78
|
+
if (cur.path === undefined && !cur.scanned) {
|
|
79
|
+
cur.path = partialPath(cur.arguments.slice(0, PATH_SCAN_CHARS));
|
|
80
|
+
if (cur.arguments.length >= PATH_SCAN_CHARS) cur.scanned = true;
|
|
81
|
+
}
|
|
82
|
+
this.onToolProgress({ index: idx, name: cur.name, bytes: cur.bytes, path: cur.path });
|
|
83
|
+
}
|
|
43
84
|
}
|
|
44
85
|
}
|
|
45
86
|
const u = usageFrom(chunk?.usage);
|
|
@@ -66,7 +107,7 @@ export function parseToolArguments(raw ) {
|
|
|
66
107
|
}
|
|
67
108
|
|
|
68
109
|
// Parses an SSE byte stream into JSON events; "[DONE]" ends it.
|
|
69
|
-
export async function* sseEvents(body ) {
|
|
110
|
+
export async function* sseEvents(body , state ) {
|
|
70
111
|
const dec = new TextDecoder();
|
|
71
112
|
let buf = "";
|
|
72
113
|
for await (const part of body) {
|
|
@@ -77,7 +118,7 @@ export async function* sseEvents(body )
|
|
|
77
118
|
buf = buf.slice(nl + 1);
|
|
78
119
|
if (!line.startsWith("data:")) continue;
|
|
79
120
|
const data = line.slice(5).trim();
|
|
80
|
-
if (data === "[DONE]") return;
|
|
121
|
+
if (data === "[DONE]") { if (state) state.done = true; return; }
|
|
81
122
|
if (data) yield JSON.parse(data);
|
|
82
123
|
}
|
|
83
124
|
}
|
|
@@ -143,17 +184,30 @@ export async function checkKey(apiKey , fetchFn = fetch)
|
|
|
143
184
|
|
|
144
185
|
|
|
145
186
|
|
|
187
|
+
|
|
188
|
+
|
|
146
189
|
|
|
147
190
|
|
|
148
191
|
|
|
149
192
|
|
|
150
193
|
|
|
194
|
+
// Thrown when the person aborts a request; not a failure to report as an error.
|
|
195
|
+
export class AbortedError extends Error {
|
|
196
|
+
constructor() {
|
|
197
|
+
super("interrupted");
|
|
198
|
+
this.name = "AbortedError";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
151
202
|
export async function chat(o ) {
|
|
152
203
|
const fetchFn = o.fetchFn ?? fetch;
|
|
153
204
|
const now = o.now ?? (() => performance.now());
|
|
154
205
|
const t0 = now();
|
|
155
206
|
const ac = new AbortController();
|
|
156
207
|
const timer = setTimeout(() => ac.abort(), o.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
208
|
+
const onUserAbort = () => ac.abort();
|
|
209
|
+
if (o.signal?.aborted) { clearTimeout(timer); throw new AbortedError(); }
|
|
210
|
+
o.signal?.addEventListener("abort", onUserAbort, { once: true });
|
|
157
211
|
try {
|
|
158
212
|
const res = await fetchFn(`${(o.baseUrl ?? API_BASE).replace(/\/+$/, "")}/chat/completions`, {
|
|
159
213
|
method: "POST",
|
|
@@ -172,8 +226,10 @@ export async function chat(o ) {
|
|
|
172
226
|
if (!res.ok) throw new ApiError(res.status, friendlyStatus(res.status, res.status === 401 ? "" : await detailOf(res)));
|
|
173
227
|
if (!res.body) throw new ApiError(res.status, "the service sent an empty answer");
|
|
174
228
|
const acc = new StreamAccumulator();
|
|
229
|
+
acc.onToolProgress = o.onToolProgress;
|
|
175
230
|
let firstAt ;
|
|
176
|
-
|
|
231
|
+
const sseState = { done: false };
|
|
232
|
+
for await (const ev of sseEvents(res.body , sseState)) {
|
|
177
233
|
const t = acc.push(ev);
|
|
178
234
|
if (firstAt === undefined && acc.hasOutput()) firstAt = now();
|
|
179
235
|
if (t && o.onText) o.onText(t);
|
|
@@ -184,14 +240,18 @@ export async function chat(o ) {
|
|
|
184
240
|
toolCalls: acc.toolCalls(),
|
|
185
241
|
usage: acc.usage,
|
|
186
242
|
routing: res.headers.get("x-synqa-routing"),
|
|
243
|
+
finishReason: acc.finishReason,
|
|
244
|
+
complete: sseState.done || acc.finishReason !== undefined,
|
|
187
245
|
ttftMs: firstAt === undefined ? undefined : firstAt - t0,
|
|
188
246
|
genMs: firstAt === undefined ? undefined : end - firstAt,
|
|
189
247
|
};
|
|
190
248
|
} catch (err) {
|
|
249
|
+
if (o.signal?.aborted) throw new AbortedError();
|
|
191
250
|
if (ac.signal.aborted) throw new Error("request timed out");
|
|
192
251
|
throw err;
|
|
193
252
|
} finally {
|
|
194
253
|
clearTimeout(timer);
|
|
254
|
+
o.signal?.removeEventListener("abort", onUserAbort);
|
|
195
255
|
}
|
|
196
256
|
}
|
|
197
257
|
|
package/dist/main.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { runAgent } from "./agent.js";
|
|
3
|
-
import { ApiError, checkKey } from "./api.js";
|
|
3
|
+
import { AbortedError, ApiError, checkKey } from "./api.js";
|
|
4
4
|
import { announceNotes, freshHistory, runCommand, statusParts } from "./commands.js";
|
|
5
5
|
|
|
6
6
|
import { API_BASE, looksLikeKey, readConfig, resolveApiKey, resolveProviderKey, writeConfig, configPath } from "./config.js";
|
|
@@ -108,7 +108,7 @@ import { CONTEXT_TOKENS, DEFAULT_TIER, findEffort, findTier, routingLabel } from
|
|
|
108
108
|
import { accent, dim, stone } from "./theme.js";
|
|
109
109
|
|
|
110
110
|
import { fetchAccount } from "./account.js";
|
|
111
|
-
import { balanceFrom, formatFooter, makeUi, promptText, readVersion, renderBanner, renderStatusLines, Spinner, styleOf, textWidth, TurnRenderer } from "./ui.js";
|
|
111
|
+
import { balanceFrom, formatFooter, makeUi, promptText, readVersion, renderBanner, renderStatusLines, Spinner, styleOf, textWidth, ToolProgressLine, TurnRenderer } from "./ui.js";
|
|
112
112
|
import { PromptFrame } from "./prompt.js";
|
|
113
113
|
|
|
114
114
|
|
|
@@ -242,15 +242,19 @@ export async function main(argv , input = process.stdin)
|
|
|
242
242
|
// the plain text output they had before.
|
|
243
243
|
const chrome = interactive && ui.tty;
|
|
244
244
|
const spinner = new Spinner(process.stdout, { enabled: chrome, ui });
|
|
245
|
-
const
|
|
245
|
+
const progress = new ToolProgressLine(process.stdout, { enabled: chrome, ui });
|
|
246
|
+
const renderer = new TurnRenderer(process.stdout, ui, spinner, progress);
|
|
246
247
|
const inCard = (t ) => (chrome ? renderer.body(t) : t);
|
|
247
248
|
// `detail` is the coloured diff the tools built; the question is sanitized, the diff is not
|
|
248
249
|
// (the tools already escaped every file character in it).
|
|
249
250
|
const confirm = async (q , detail ) => {
|
|
250
251
|
if (!input.isTTY) return false;
|
|
251
252
|
spinner.stop();
|
|
253
|
+
progress.stop();
|
|
252
254
|
if (detail) term.write(inCard(detail) + "\n"); // with the question, on the terminal
|
|
253
|
-
|
|
255
|
+
// The turn lock lets keys through for the length of the question.
|
|
256
|
+
const ask = () => rl.question(`${accent(style, "?")} ${sanitize(q)} [y/N] `);
|
|
257
|
+
const ans = keys ? await keys.withKeys(ask) : await ask();
|
|
254
258
|
return /^y(es)?$/i.test(ans.trim());
|
|
255
259
|
};
|
|
256
260
|
|
|
@@ -314,12 +318,23 @@ export async function main(argv , input = process.stdin)
|
|
|
314
318
|
};
|
|
315
319
|
const refreshBalance = () => { void loadBalance().then((b) => { balance = b; }, () => {}); };
|
|
316
320
|
|
|
317
|
-
|
|
321
|
+
// Returns true when the person stopped the turn with Esc or Ctrl+C.
|
|
322
|
+
const turn = async (text ) => {
|
|
318
323
|
st.history.push({ role: "user", content: text });
|
|
324
|
+
const ac = new AbortController();
|
|
325
|
+
let stopped = false;
|
|
326
|
+
// While the turn runs the keyboard is locked: nothing typed is echoed; Esc or Ctrl+C stops the
|
|
327
|
+
// request. A second press, with the turn still not ended (a long shell command), leaves.
|
|
328
|
+
keys?.beginTurn(() => {
|
|
329
|
+
if (ac.signal.aborted) { restore(); process.exit(130); }
|
|
330
|
+
stopped = true;
|
|
331
|
+
ac.abort();
|
|
332
|
+
});
|
|
319
333
|
try {
|
|
320
334
|
const r = await runAgent({
|
|
321
335
|
apiKey: st.apiKey, tier: st.tier, effort: st.effort, history: st.history, ctx,
|
|
322
336
|
baseUrl: external() ? st.baseUrl : undefined, modelId: external() ? st.modelId : undefined, hooks: toolHooks,
|
|
337
|
+
signal: ac.signal,
|
|
323
338
|
events: chrome
|
|
324
339
|
? {
|
|
325
340
|
onRequest: () => renderer.requestStart(),
|
|
@@ -327,11 +342,14 @@ export async function main(argv , input = process.stdin)
|
|
|
327
342
|
onTool: (name, a) => renderer.toolStart(name, a),
|
|
328
343
|
onToolResult: (name, _a, result) => renderer.toolResult(name, result),
|
|
329
344
|
onTurn: () => renderer.requestEnd(),
|
|
345
|
+
onToolProgress: (p) => renderer.toolProgress(p),
|
|
346
|
+
onNotice: (t) => { renderer.requestEnd(); say(dim(style, `[${t}]`)); },
|
|
330
347
|
}
|
|
331
348
|
: {
|
|
332
349
|
onText: (t) => process.stdout.write(sanitize(t)),
|
|
333
350
|
onTool: (name, a) => process.stdout.write(`\n${dim(style, `> ${sanitize(name)} ${sanitize(a.length > 120 ? a.slice(0, 117) + "..." : a)}`)}\n`),
|
|
334
351
|
onTurn: () => {},
|
|
352
|
+
onNotice: (t) => console.error(`[${t}]`),
|
|
335
353
|
},
|
|
336
354
|
});
|
|
337
355
|
st.total = { prompt: st.total.prompt + r.total.prompt, completion: st.total.completion + r.total.completion };
|
|
@@ -352,12 +370,21 @@ export async function main(argv , input = process.stdin)
|
|
|
352
370
|
}
|
|
353
371
|
} catch (err) {
|
|
354
372
|
spinner.stop(); // the thinking timer ends on an error or an abort too
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
373
|
+
if (chrome) renderer.turnEnd(); // progress line off, cursor at the start of a line
|
|
374
|
+
if (err instanceof AbortedError) {
|
|
375
|
+
console.error(`\n${dim(style, "interrupted")}`);
|
|
376
|
+
} else {
|
|
377
|
+
const msg = sanitize(err instanceof Error ? err.message : String(err));
|
|
378
|
+
const envNote = err instanceof ApiError && err.status === 401 && !external() && process.env.SYNQA_API_KEY?.trim() ? `\n${ENV_KEY_NOTE}` : "";
|
|
379
|
+
console.error(`\n${chrome ? accent(style, "error") : "error"}: ${msg}${envNote}`);
|
|
380
|
+
}
|
|
381
|
+
} finally {
|
|
382
|
+
keys?.endTurn(); // the keyboard is unlocked on every way out
|
|
383
|
+
progress.stop();
|
|
358
384
|
}
|
|
359
385
|
await fire("Stop", { tokens: st.total.prompt + st.total.completion });
|
|
360
386
|
refresh();
|
|
387
|
+
return stopped;
|
|
361
388
|
};
|
|
362
389
|
|
|
363
390
|
// Welcome screen and the Synqa-first gate: chat and every other provider stay locked until a
|
|
@@ -388,9 +415,9 @@ export async function main(argv , input = process.stdin)
|
|
|
388
415
|
announceNotes(st, say);
|
|
389
416
|
for (const w of startWarnings(process.env, cwd, undefined, apiKeyFromEnv())) console.error(`Warning: ${w}`);
|
|
390
417
|
await fire("SessionStart", { source: "one-shot" });
|
|
391
|
-
await turn(prompt);
|
|
418
|
+
const stopped = await turn(prompt);
|
|
392
419
|
say(dim(style, formatStatus(statusParts(st))));
|
|
393
|
-
return 0;
|
|
420
|
+
return stopped ? 130 : 0;
|
|
394
421
|
}
|
|
395
422
|
|
|
396
423
|
if (needSignIn) {
|
package/dist/prompt.js
CHANGED
|
@@ -131,6 +131,8 @@ export class PromptFrame {
|
|
|
131
131
|
capture ; // a selector or secret prompt owns the keys
|
|
132
132
|
lastCols = 0;
|
|
133
133
|
promptStr = "";
|
|
134
|
+
turnAbort ; // set while a response streams: keys are locked
|
|
135
|
+
holds = 0; // open questions during a turn (y/N): keys go to readline again meanwhile
|
|
134
136
|
// Shift+Tab at the prompt (cycles the permission mode); the footer is redrawn after it.
|
|
135
137
|
onShiftTab ;
|
|
136
138
|
|
|
@@ -176,8 +178,29 @@ export class PromptFrame {
|
|
|
176
178
|
rl.on("close", () => this.stop());
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
// While a turn runs, typed characters are not echoed or kept: Esc or Ctrl+C calls onAbort and
|
|
182
|
+
// every other key is dropped. Pasted text was already dropped outside the prompt.
|
|
183
|
+
beginTurn(onAbort ) {
|
|
184
|
+
this.turnAbort = onAbort;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
endTurn() {
|
|
188
|
+
this.turnAbort = undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Runs a question that needs the keyboard (the y/N approval) inside a locked turn.
|
|
192
|
+
async withKeys (fn ) {
|
|
193
|
+
this.holds++;
|
|
194
|
+
try {
|
|
195
|
+
return await fn();
|
|
196
|
+
} finally {
|
|
197
|
+
this.holds--;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
179
201
|
// Gives the terminal back: raw mode and bracketed paste off. Safe to call any number of times.
|
|
180
202
|
stop() {
|
|
203
|
+
this.turnAbort = undefined;
|
|
181
204
|
if (!this.started) return;
|
|
182
205
|
this.started = false;
|
|
183
206
|
this.stdin.off("keypress", this.onKey);
|
|
@@ -328,6 +351,11 @@ export class PromptFrame {
|
|
|
328
351
|
this.capture.fn(s, key);
|
|
329
352
|
return;
|
|
330
353
|
}
|
|
354
|
+
if (this.turnAbort && this.holds === 0) {
|
|
355
|
+
// A turn is streaming: nothing typed reaches readline, so nothing is echoed.
|
|
356
|
+
if (!this.pasting && (seq === "\x03" || key?.name === "escape")) this.turnAbort();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
331
359
|
if (!this.pasting && seq === "\x03") this.ctrlC = true;
|
|
332
360
|
if (this.pasting) {
|
|
333
361
|
// Pasted text is never a key press. While a y/N question (or a turn) holds the input it is
|
package/dist/ui.js
CHANGED
|
@@ -9,6 +9,7 @@ import { readFileSync } from "node:fs";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { formatUsd } from "./account.js";
|
|
11
11
|
|
|
12
|
+
|
|
12
13
|
import { sanitize, visibleEscapes } from "./sanitize.js";
|
|
13
14
|
import { groupDigits } from "./statusbar.js";
|
|
14
15
|
import { CONTEXT_TOKENS } from "./tiers.js";
|
|
@@ -425,6 +426,94 @@ export class Spinner {
|
|
|
425
426
|
}
|
|
426
427
|
}
|
|
427
428
|
|
|
429
|
+
// ---------------------------------------------------------------- tool-call progress
|
|
430
|
+
|
|
431
|
+
export function formatBytes(n ) {
|
|
432
|
+
return n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export const PROGRESS_INTERVAL_MS = 120; // the line is redrawn at most this often, however fast chunks arrive
|
|
436
|
+
|
|
437
|
+
// A live line for a tool call that is still streaming in, for example
|
|
438
|
+
// ⠋ write_file index.html · streaming 18.4 KB...
|
|
439
|
+
// The frame is the terracotta dot-mark of the spinner family, the rest stone. It is one line,
|
|
440
|
+
// always shorter than the terminal, redrawn in place by a throttled timer. With `enabled` false
|
|
441
|
+
// (a pipe, TERM=dumb, a one-shot run) nothing is ever written: no animation, no control codes.
|
|
442
|
+
export class ToolProgressLine {
|
|
443
|
+
out ;
|
|
444
|
+
o ;
|
|
445
|
+
timer = undefined;
|
|
446
|
+
frame = 0;
|
|
447
|
+
drawn = false;
|
|
448
|
+
last ;
|
|
449
|
+
|
|
450
|
+
constructor(out , o ) {
|
|
451
|
+
this.out = out;
|
|
452
|
+
this.o = o;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
get active() {
|
|
456
|
+
return this.timer !== undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// The newest state of the call being received. The first update draws at once; later ones only
|
|
460
|
+
// change what the next timed redraw shows.
|
|
461
|
+
update(p ) {
|
|
462
|
+
if (!this.o.enabled) return;
|
|
463
|
+
this.last = p;
|
|
464
|
+
if (this.timer !== undefined) return;
|
|
465
|
+
this.draw();
|
|
466
|
+
const set = this.o.setTimer ?? ((fn , ms ) => {
|
|
467
|
+
const h = setInterval(fn, ms);
|
|
468
|
+
(h ).unref?.();
|
|
469
|
+
return h;
|
|
470
|
+
});
|
|
471
|
+
this.timer = set(() => this.draw(), this.o.intervalMs ?? PROGRESS_INTERVAL_MS);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// The plain line for the current state, cut to the terminal width.
|
|
475
|
+
line() {
|
|
476
|
+
const p = this.last;
|
|
477
|
+
if (!p) return "";
|
|
478
|
+
const ui = this.o.ui;
|
|
479
|
+
const g = glyphs(ui);
|
|
480
|
+
const frames = g.spin;
|
|
481
|
+
const f = frames[this.frame % frames.length] ;
|
|
482
|
+
const room = ui.columns - 1;
|
|
483
|
+
const tail = ` ${g.dot} streaming ${formatBytes(p.bytes)}${g.ell}`;
|
|
484
|
+
const lead = f + " ";
|
|
485
|
+
const avail = room - textWidth(lead) - textWidth(tail);
|
|
486
|
+
if (avail < 6) return fit(lead + oneLine(p.name) + tail, room, ui);
|
|
487
|
+
const name = fit(Array.from(oneLine(p.name)).slice(0, 40).join("") || "tool", avail, ui);
|
|
488
|
+
const pathRoom = avail - textWidth(name) - 1;
|
|
489
|
+
const path = p.path && pathRoom >= 4 ? " " + fitPath(oneLine(p.path), pathRoom, ui) : "";
|
|
490
|
+
return lead + name + path + tail;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
draw() {
|
|
494
|
+
const st = styleOf(this.o.ui);
|
|
495
|
+
const text = this.line();
|
|
496
|
+
const f = (glyphs(this.o.ui).spin[this.frame % glyphs(this.o.ui).spin.length] );
|
|
497
|
+
this.frame++;
|
|
498
|
+
if (text === "") return;
|
|
499
|
+
this.out.write(CR_ERASE + terracotta(st, text.slice(0, f.length)) + stone(st, text.slice(f.length)));
|
|
500
|
+
this.drawn = true;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Clears the line and stops. Safe to call at any time, any number of times.
|
|
504
|
+
stop() {
|
|
505
|
+
if (this.timer !== undefined) {
|
|
506
|
+
(this.o.clearTimer ?? ((h ) => clearInterval(h )))(this.timer);
|
|
507
|
+
this.timer = undefined;
|
|
508
|
+
}
|
|
509
|
+
this.last = undefined;
|
|
510
|
+
if (this.drawn) {
|
|
511
|
+
this.out.write(CR_ERASE);
|
|
512
|
+
this.drawn = false;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
428
517
|
// ---------------------------------------------------------------- tool cards
|
|
429
518
|
|
|
430
519
|
// The one piece of an argument list worth showing in a card header.
|
|
@@ -543,12 +632,26 @@ export class TurnRenderer {
|
|
|
543
632
|
out ;
|
|
544
633
|
ui ;
|
|
545
634
|
spinner ;
|
|
635
|
+
progress ;
|
|
546
636
|
midLine = false; // the last thing written was model text with no trailing newline
|
|
547
637
|
|
|
548
|
-
constructor(out , ui , spinner ) {
|
|
638
|
+
constructor(out , ui , spinner , progress ) {
|
|
549
639
|
this.out = out;
|
|
550
640
|
this.ui = ui;
|
|
551
641
|
this.spinner = spinner;
|
|
642
|
+
this.progress = progress;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// A tool call is streaming in: the waiting spinner gives way to the live progress line. Text
|
|
646
|
+
// that ended without a newline is finished first, because the line is redrawn in place.
|
|
647
|
+
toolProgress(p ) {
|
|
648
|
+
if (!this.progress) return;
|
|
649
|
+
this.spinner.stop();
|
|
650
|
+
if (!this.progress.active && this.midLine) {
|
|
651
|
+
this.out.write("\n");
|
|
652
|
+
this.midLine = false;
|
|
653
|
+
}
|
|
654
|
+
this.progress.update(p);
|
|
552
655
|
}
|
|
553
656
|
|
|
554
657
|
// A request is about to be sent: show that we are waiting for the first token.
|
|
@@ -559,6 +662,7 @@ export class TurnRenderer {
|
|
|
559
662
|
// Model text. The spinner is erased first; the text is stripped of every escape sequence.
|
|
560
663
|
text(t ) {
|
|
561
664
|
this.spinner.stop();
|
|
665
|
+
this.progress?.stop();
|
|
562
666
|
const clean = sanitize(t);
|
|
563
667
|
if (clean === "") return;
|
|
564
668
|
this.out.write(clean);
|
|
@@ -568,10 +672,12 @@ export class TurnRenderer {
|
|
|
568
672
|
// The request finished (with or without a tool call).
|
|
569
673
|
requestEnd() {
|
|
570
674
|
this.spinner.stop();
|
|
675
|
+
this.progress?.stop();
|
|
571
676
|
}
|
|
572
677
|
|
|
573
678
|
toolStart(name , rawArgs ) {
|
|
574
679
|
this.spinner.stop();
|
|
680
|
+
this.progress?.stop();
|
|
575
681
|
this.out.write((this.midLine ? "\n" : "") + cardHeader(name, rawArgs, this.ui) + "\n");
|
|
576
682
|
this.midLine = false;
|
|
577
683
|
}
|
|
@@ -588,6 +694,7 @@ export class TurnRenderer {
|
|
|
588
694
|
// Leaves the cursor at the start of a line after a turn.
|
|
589
695
|
turnEnd() {
|
|
590
696
|
this.spinner.stop();
|
|
697
|
+
this.progress?.stop();
|
|
591
698
|
if (this.midLine) this.out.write("\n");
|
|
592
699
|
this.midLine = false;
|
|
593
700
|
}
|