@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 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 { chat, parseToolArguments } from "./api.js";
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: c.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
- for (const c of r.toolCalls) {
121
- o.events.onTool(c.name, c.arguments);
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
- try {
124
- const blocked = await o.hooks?.pre(c.name, c.arguments);
125
- result = blocked !== undefined ? `blocked by a hook: ${blocked}` : await runTool(o.ctx, c.name, parseToolArguments(c.arguments));
126
- if (blocked === undefined) await o.hooks?.post(c.name, c.arguments, result);
127
- } catch (err) {
128
- result = `error: ${err instanceof Error ? err.message : String(err)}`;
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, c.arguments, result);
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") cur.arguments += tc.function.arguments;
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
- for await (const ev of sseEvents(res.body )) {
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 renderer = new TurnRenderer(process.stdout, ui, spinner);
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
- const ans = await rl.question(`${accent(style, "?")} ${sanitize(q)} [y/N] `);
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
- const turn = async (text ) => {
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
- const msg = sanitize(err instanceof Error ? err.message : String(err));
356
- const envNote = err instanceof ApiError && err.status === 401 && !external() && process.env.SYNQA_API_KEY?.trim() ? `\n${ENV_KEY_NOTE}` : "";
357
- console.error(`\n${chrome ? accent(style, "error") : "error"}: ${msg}${envNote}`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synqa/cli",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Synqa terminal coding agent",
5
5
  "type": "module",
6
6
  "bin": {