zames_pro 2.73.10 → 2.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +13 -7
  3. package/dist/banner.js +71 -0
  4. package/dist/browser.js +84 -27
  5. package/dist/code-blocks.js +48 -0
  6. package/dist/commands.js +1 -0
  7. package/dist/config-live.js +53 -0
  8. package/dist/config.js +38 -0
  9. package/dist/goal.js +81 -0
  10. package/dist/i18n/common.js +31 -0
  11. package/dist/i18n/config.js +20 -0
  12. package/dist/i18n/deepseek.js +14 -0
  13. package/dist/i18n/help.js +22 -2
  14. package/dist/i18n/qwen.js +4 -0
  15. package/dist/i18n/self-review.js +8 -0
  16. package/dist/i18n/spinner.js +6 -0
  17. package/dist/index.js +478 -65
  18. package/dist/input/layout.js +19 -0
  19. package/dist/input/suggest.js +13 -0
  20. package/dist/input.js +14 -3
  21. package/dist/net-capture.js +18 -0
  22. package/dist/qwen-browser.js +43 -20
  23. package/dist/run-task.js +97 -4
  24. package/dist/theme.js +7 -1
  25. package/dist/tool-summary.js +83 -0
  26. package/dist/tui/ConfigMenu.js +304 -0
  27. package/dist/tui/ConfirmDialog.js +42 -0
  28. package/dist/tui/FullScreenApp.js +85 -0
  29. package/dist/tui/InputLine.js +285 -0
  30. package/dist/tui/LogView.js +48 -0
  31. package/dist/tui/LoginForm.js +83 -0
  32. package/dist/tui/SelectList.js +50 -0
  33. package/dist/tui/StatusBar.js +140 -0
  34. package/dist/tui/TextPrompt.js +41 -0
  35. package/dist/tui/TuiApp.js +28 -0
  36. package/dist/tui/TuiEditor.js +490 -0
  37. package/dist/tui/edit.js +297 -0
  38. package/dist/tui/keys.js +162 -0
  39. package/dist/tui/log-window.js +93 -0
  40. package/dist/tui/login-pure.js +20 -0
  41. package/dist/tui/run-config-menu.js +19 -0
  42. package/dist/tui/run-confirm.js +22 -0
  43. package/dist/tui/run-login.js +43 -0
  44. package/dist/tui/run-select.js +18 -0
  45. package/dist/tui/store.js +104 -0
  46. package/dist/tui/types.js +10 -0
  47. package/dist/tui/useMouseWheel.js +113 -0
  48. package/dist/tui/widgets.js +55 -0
  49. package/package.json +6 -4
  50. package/scripts/coverage-gate.mts +10 -0
  51. package/scripts/render-demo.mts +1 -1
  52. package/scripts/tui-probe.tsx +62 -0
@@ -227,6 +227,25 @@ export function truncateToWidth(s, maxCols) {
227
227
  }
228
228
  return out + '…';
229
229
  }
230
+ // Inner content width of the framed input box: the rounded border takes 1
231
+ // column on each side and the box adds 1 column of padding on each side, so
232
+ // the text must wrap to `cols - 4`. Exported so the layout ABOVE the box
233
+ // (FullScreenApp) and the box itself (InputLine) use ONE number — a mismatch
234
+ // made the reserved height disagree with the rendered rows and the panel
235
+ // overflowed.
236
+ export function inputInnerCols(cols) {
237
+ return Math.max(4, (Number(cols) || 80) - 4);
238
+ }
239
+ // Real height (rows) of the framed input box for the CURRENT buffer: 2 border
240
+ // rows + the number of VISUAL rows the buffer wraps to. Using the LOGICAL line
241
+ // count (buf.split('\n').length) was wrong: a long line wraps and renders MORE
242
+ // rows than it has newlines, so the box was reserved too few rows, the second
243
+ // line printed OVER the first and the cursor landed on the wrong row.
244
+ export function inputBoxRows(prompt, buf, cursor, cols) {
245
+ const inner = inputInnerCols(cols);
246
+ const rows = layoutInput(prompt, buf, cursor, inner).rows.length;
247
+ return 2 + Math.max(1, rows);
248
+ }
230
249
  export function layoutInput(promptStr, buf, cursor, cols) {
231
250
  // Reserve the LAST column. If a printed row reaches exactly `cols` columns,
232
251
  // terminals with autowrap (Tabby, iTerm, Windows Terminal, xterm's default
@@ -57,6 +57,19 @@ export function suggestWindow(sugg, offset) {
57
57
  const moreCount = sugg.length > pageSize ? sugg.length - shown.length : 0;
58
58
  return { shown, off, moreCount };
59
59
  }
60
+ /**
61
+ * How many terminal ROWS the suggestion popup occupies for the current buffer
62
+ * (0 when no list). Full-screen mode uses this to RESERVE space above the
63
+ * input panel so opening the list does not shift the layout (the operator saw
64
+ * the whole screen jump when `/chats` or `/resume` hints appeared).
65
+ */
66
+ export function suggestRowCount(buf, commands) {
67
+ const sugg = matchSlashCommands(buf, commands);
68
+ if (!sugg.length)
69
+ return 0;
70
+ const { moreCount } = suggestWindow(sugg, 0);
71
+ return Math.min(sugg.length, SUGGEST_PAGE) + (moreCount > 0 ? 1 : 0);
72
+ }
60
73
  /**
61
74
  * Tab completion. With an explicit selection, insert that command. With a
62
75
  * single match, insert it whole. Otherwise complete up to the longest common
package/dist/input.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { theme, divider } from './theme.js';
2
+ import { summarizeToolCall } from './tool-summary.js';
2
3
  import { renderMarkdown } from './markdown.js';
3
4
  import { contentWidth } from './width.js';
4
5
  import { randomThinkingPhrase, stripEllipsis, DOTS, renderDots, } from './spinner.js';
@@ -940,6 +941,10 @@ export class LineEditor {
940
941
  this.statusText = text || '';
941
942
  this._render();
942
943
  }
944
+ // No-op success/failure hooks, kept so LineEditor satisfies the shared
945
+ // UiEditor interface (the TUI and the spinner expose the same surface).
946
+ succeed() { }
947
+ fail() { }
943
948
  _stopDots() {
944
949
  if (this._dotTimer) {
945
950
  clearInterval(this._dotTimer);
@@ -1070,8 +1075,11 @@ export class LineEditor {
1070
1075
  // wider than the terminal and some terminals then showed a horizontal
1071
1076
  // scrollbar. Keep one column free (autowrap safety), same as the status.
1072
1077
  const maxW = contentWidth();
1073
- const preview = truncateToWidth(full, maxW);
1074
- this.printAbove(theme.tool('🔧 ' + name) + ' ' + theme.dim(preview));
1078
+ const summary = summarizeToolCall(name, (typeof args === 'object' && args !== null ? args : {}));
1079
+ const detail = summary.detail
1080
+ ? truncateToWidth(summary.detail, Math.max(10, maxW - name.length - 6))
1081
+ : truncateToWidth(full, maxW);
1082
+ this.printAbove(theme.tool('🔧 ' + name) + (detail ? theme.dim('(' + detail + ')') : ''));
1075
1083
  // A tool may run for a long time (Bash, npm test, MCP). Without an active
1076
1084
  // animation the operator sees a frozen screen and cannot tell work is in
1077
1085
  // progress. Start the animated status AFTER printAbove (which stops the
@@ -1186,7 +1194,10 @@ export class LineEditor {
1186
1194
  // Attachments collected for this message (images/files). They are handed
1187
1195
  // to the browser layer to be attached to the chat.
1188
1196
  const attached = this.attachments.items.slice();
1189
- this.printAbove(theme.user('❯ ') + wrapToWidth(display, contentWidth()));
1197
+ // A turn separator + a BAR-marked operator message (same look as the TUI).
1198
+ this.printAbove(theme.turnRule('─'.repeat(contentWidth())));
1199
+ this.printAbove(theme.userBar('▌ ') +
1200
+ theme.user(wrapToWidth(display, contentWidth() - 2)));
1190
1201
  this.pastes = [];
1191
1202
  this.attachments.reset();
1192
1203
  if (this.onSubmit)
@@ -186,6 +186,24 @@ export function isFinishedWithoutAnswer(body) {
186
186
  // absence of response content as "no answer produced".
187
187
  return true;
188
188
  }
189
+ /**
190
+ * True when the SSE body shows a turn that ENDED (`quasi_status: FINISHED`)
191
+ * AND carried a non-empty RESPONSE fragment — i.e. the server is done and the
192
+ * answer is COMPLETE. This is the network-authoritative "the turn is over"
193
+ * signal: the finish loop can trust it to stop waiting even when the DOM
194
+ * heuristics (`isNew`/`_isGenerating`) are confused (a resumed chat, a stale
195
+ * Stop button, a DOM that never caught up). Without it the loop waited out
196
+ * the whole askDeadlineMs while showing a spinner — the "generation is running
197
+ * but nothing happens" hang.
198
+ */
199
+ export function isFinishedWithAnswer(body) {
200
+ const b = String(body || '');
201
+ if (!b)
202
+ return false;
203
+ if (!FINISHED_STATUS_RE.test(b))
204
+ return false;
205
+ return RESPONSE_CONTENT_RE.test(b);
206
+ }
189
207
  // The CONTEXT size (in tokens) DeepSeek reports for the current answer.
190
208
  //
191
209
  // chat.deepseek.com does not expose prompt_tokens/completion_tokens the way
@@ -79,6 +79,7 @@ export class QwenBrowser {
79
79
  auth;
80
80
  locale;
81
81
  onAuthSave;
82
+ onLoginPrompt;
82
83
  // Watchdog budget the agent loop reads (same contract as DeepSeekBrowser).
83
84
  maxAfterToolRetries;
84
85
  askDeadlineMs;
@@ -121,7 +122,7 @@ export class QwenBrowser {
121
122
  onSendState;
122
123
  onNotice;
123
124
  userAgent;
124
- constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
125
+ constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, userAgent, } = {}) {
125
126
  this.headless = headless;
126
127
  this.debug = debug;
127
128
  this.channel = channel;
@@ -148,6 +149,7 @@ export class QwenBrowser {
148
149
  };
149
150
  this.locale = locale || DEFAULT_LOCALE;
150
151
  this.onAuthSave = onAuthSave || null;
152
+ this.onLoginPrompt = onLoginPrompt || null;
151
153
  this.maxAfterToolRetries = 6;
152
154
  this.askDeadlineMs = answerTimeoutMs + 60000;
153
155
  this._lastSentAt = 0;
@@ -574,31 +576,52 @@ export class QwenBrowser {
574
576
  // This is the DEFAULT Qwen flow (no password needed).
575
577
  async _promptAndLogin() {
576
578
  await this._openLoginPage();
577
- const readline = await import('readline');
578
- const rl = readline.createInterface({
579
- input: process.stdin,
580
- output: process.stdout,
581
- });
582
- const email = await new Promise((resolve) => rl.question(this._t('qwen.login_email_prompt', {
583
- def: this.auth.username ? ' [' + this.auth.username + ']' : '',
584
- }), resolve));
585
- rl.close();
586
- const addr = email.trim() || this.auth.username;
587
- if (!addr) {
588
- this._notice(this._t('qwen.login_failed'));
589
- return false;
579
+ let addr;
580
+ let code;
581
+ if (this.onLoginPrompt) {
582
+ // TUI path: the Ink prompt reads the email/code without pausing Ink.
583
+ const email = await this.onLoginPrompt({
584
+ savedUsername: this.auth.username,
585
+ step: 'email',
586
+ });
587
+ addr = (email ?? '').trim() || this.auth.username;
588
+ if (!addr) {
589
+ this._notice(this._t('qwen.login_failed'));
590
+ return false;
591
+ }
592
+ const codeAns = await this.onLoginPrompt({
593
+ savedUsername: addr,
594
+ step: 'code',
595
+ });
596
+ code = (codeAns ?? '').trim();
590
597
  }
591
- let ok;
592
- try {
593
- await this._fillEmailAndContinue(addr);
594
- await this.page.waitForTimeout(1500);
598
+ else {
599
+ const readline = await import('readline');
600
+ const rl = readline.createInterface({
601
+ input: process.stdin,
602
+ output: process.stdout,
603
+ });
604
+ const email = await new Promise((resolve) => rl.question(this._t('qwen.login_email_prompt', {
605
+ def: this.auth.username ? ' [' + this.auth.username + ']' : '',
606
+ }), resolve));
607
+ rl.close();
608
+ addr = email.trim() || this.auth.username;
609
+ if (!addr) {
610
+ this._notice(this._t('qwen.login_failed'));
611
+ return false;
612
+ }
595
613
  const rlCode = readline.createInterface({
596
614
  input: process.stdin,
597
615
  output: process.stdout,
598
616
  });
599
- const code = await new Promise((resolve) => rlCode.question(this._t('qwen.login_code_prompt'), resolve));
617
+ code = (await new Promise((resolve) => rlCode.question(this._t('qwen.login_code_prompt'), resolve))).trim();
600
618
  rlCode.close();
601
- await this._submitCode(code.trim());
619
+ }
620
+ let ok;
621
+ try {
622
+ await this._fillEmailAndContinue(addr);
623
+ await this.page.waitForTimeout(1500);
624
+ await this._submitCode(code);
602
625
  ok = await this._waitLoggedIn(30000);
603
626
  }
604
627
  catch (e) {
package/dist/run-task.js CHANGED
@@ -1,14 +1,35 @@
1
1
  import { theme } from './theme.js';
2
2
  import { withGoal, mergeMessages, isSlashCommand, formatDuration, } from './commands.js';
3
+ import { buildEvaluatorPrompt, buildContinuationDirective, parseGoalVerdict, GOAL_MAX_TURNS, } from './goal.js';
3
4
  import { formatCompactTokens, pasteReplacement, expandPastes } from './input.js';
4
5
  import { providerAskDeadlineMs } from './config.js';
5
6
  // Ask the operator to approve a tool call flagged by an `ask` permission rule
6
7
  // (C1). While the LineEditor owns the terminal it must be paused first, or the
7
8
  // confirmation prompt and the input line fight for the same keys. A non-TTY run
8
9
  // has no way to answer, so it DENIES (never silently allows a guarded call).
9
- export async function askOperatorConfirm(editor, question) {
10
+ export async function askOperatorConfirm(editor, question, t) {
10
11
  if (!(process.stdin.isTTY && process.stdout.isTTY))
11
12
  return false;
13
+ // Prefer the Ink modal when the TUI owns the terminal: it keeps the layout
14
+ // and does not pause/restart the whole editor.
15
+ if (editor && 'isTui' in editor && editor.isTui) {
16
+ if (editor.pause)
17
+ editor.pause();
18
+ try {
19
+ const { askConfirmInk } = await import('./tui/run-confirm.js');
20
+ const tr = t ?? ((k) => k);
21
+ return await askConfirmInk({
22
+ title: tr('perm.title'),
23
+ message: question,
24
+ yesLabel: tr('confirm.yes'),
25
+ noLabel: tr('confirm.no'),
26
+ });
27
+ }
28
+ finally {
29
+ if (editor.resume)
30
+ editor.resume();
31
+ }
32
+ }
12
33
  if (editor)
13
34
  editor.pause();
14
35
  try {
@@ -236,9 +257,34 @@ export function watchInput({ onEscape, onChange, onQueue, } = {}) {
236
257
  stdin.setRawMode(wasRaw || false);
237
258
  };
238
259
  }
260
+ // Run the completion-goal evaluator: one model call in the SAME chat that
261
+ // judges the condition against the transcript. Returns null when the call
262
+ // fails or the reply is unparsable (the caller then fails CLOSED and keeps
263
+ // working — an unreadable verdict must never be mistaken for "met").
264
+ // `agent: true` so it waits out the send throttle like any other agent turn.
265
+ async function evaluateGoal(browser, condition, config, transcript) {
266
+ try {
267
+ const raw = await browser.ask(buildEvaluatorPrompt(condition), {
268
+ agent: true,
269
+ timeout: providerAskDeadlineMs(config),
270
+ });
271
+ transcript?.log('goal_eval_raw', { response: raw.slice(0, 500) });
272
+ return parseGoalVerdict(raw);
273
+ }
274
+ catch (e) {
275
+ transcript?.log('goal_eval_error', { error: e.message });
276
+ return null;
277
+ }
278
+ }
239
279
  export async function runTask(deps, browser, tools, taskText, workdir, opts, attachments = []) {
240
280
  const { t, locale, debug, maxIter, config, runAgentLoop, createSpinner } = deps;
241
- const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, todosQuery = null, selfImprovement = false, onSubagent = null, } = opts;
281
+ const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, goalIsCondition = false, onGoalCleared = null, goalMaxTurns = GOAL_MAX_TURNS, goalQuery = null, todosQuery = null, selfImprovement = false, onSubagent = null, } = opts;
282
+ // The ACTIVE goal for the duration of this run. It is mutable because a
283
+ // completion goal can be cleared mid-run (verdict MET/IMPOSSIBLE, or the
284
+ // operator runs `/goal clear` while the task is in flight). `goalQuery`
285
+ // re-reads the operator's current goal so a mid-run clear stops the loop.
286
+ let activeGoal = goal;
287
+ const currentGoal = () => goalQuery ? goalQuery() : activeGoal;
242
288
  // In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
243
289
  // Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
244
290
  // mode (pipes) - a regular spinner + watchInput.
@@ -282,6 +328,8 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
282
328
  let failure = null;
283
329
  // N31: the machine-readable reason for the last task's outcome.
284
330
  let outcomeKind = 'ok';
331
+ // How many auto-continue turns a completion goal has already spent.
332
+ let goalTurns = 0;
285
333
  let next = {
286
334
  text: taskText,
287
335
  attachments,
@@ -307,7 +355,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
307
355
  const outcome = await runAgentLoop({
308
356
  browser,
309
357
  tools,
310
- task: withGoal(goal, next.text),
358
+ task: withGoal(currentGoal(), next.text),
311
359
  workdir,
312
360
  maxIterations: maxIter,
313
361
  freshChat: next.freshChat,
@@ -338,7 +386,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
338
386
  // C1: a rule with action "ask". Show the reason and wait for the
339
387
  // operator. In non-TTY this denies.
340
388
  ui.warning(t('perm.ask', { tool: info.tool, reason: info.reason }));
341
- const ok = await askOperatorConfirm(editor || null, t('perm.confirm'));
389
+ const ok = await askOperatorConfirm(editor || null, t('perm.confirm'), t);
342
390
  ui.warning(ok ? t('perm.allowed') : t('perm.denied'));
343
391
  transcript?.log('permission_ask', {
344
392
  tool: info.tool,
@@ -407,6 +455,51 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
407
455
  editor.printAbove(summary);
408
456
  else
409
457
  console.log(summary);
458
+ // Completion goal (Claude Code parity): after EVERY turn a fresh model
459
+ // judges the condition. NOT_MET starts another turn automatically instead
460
+ // of returning control to the operator; MET/IMPOSSIBLE ends the goal.
461
+ // The evaluation runs only when the operator queue is EMPTY, so a message
462
+ // typed mid-run is always delivered before the next auto-continue turn.
463
+ if (!browser._stopped && goalIsCondition && !queue.length) {
464
+ const cond = currentGoal();
465
+ if (cond) {
466
+ if (goalTurns >= goalMaxTurns) {
467
+ ui.warning(t('goal.turn_budget', { n: String(goalMaxTurns) }));
468
+ transcript?.log('goal_turn_budget', { turns: goalTurns });
469
+ break;
470
+ }
471
+ const verdict = await evaluateGoal(browser, cond, config, transcript);
472
+ goalTurns++;
473
+ if (verdict && verdict.status === 'met') {
474
+ ui.warning(t('goal.met', { reason: verdict.reason }));
475
+ transcript?.log('goal_met', { reason: verdict.reason });
476
+ activeGoal = null;
477
+ if (onGoalCleared)
478
+ await onGoalCleared(verdict);
479
+ break;
480
+ }
481
+ if (verdict && verdict.status === 'impossible') {
482
+ ui.warning(t('goal.impossible', { reason: verdict.reason }));
483
+ transcript?.log('goal_impossible', { reason: verdict.reason });
484
+ activeGoal = null;
485
+ if (onGoalCleared)
486
+ await onGoalCleared(verdict);
487
+ break;
488
+ }
489
+ // NOT_MET (or an unparsable verdict -> fail closed as "continue"):
490
+ // start another turn with the continuation directive as the task.
491
+ const reason = verdict?.reason ?? '';
492
+ if (reason)
493
+ ui.warning(t('goal.progress', { reason }));
494
+ transcript?.log('goal_not_met', { reason, turn: goalTurns });
495
+ next = {
496
+ text: buildContinuationDirective(reason),
497
+ freshChat: false,
498
+ sendSystemPrompt: false,
499
+ };
500
+ continue;
501
+ }
502
+ }
410
503
  // Aborted (Esc/Ctrl+C) - we don't start the next tasks from the queue and
411
504
  // clear it, so "stop" really stops everything.
412
505
  if (browser._stopped) {
package/dist/theme.js CHANGED
@@ -16,7 +16,13 @@ import { contentWidth } from './width.js';
16
16
  // - warn — calm ochre
17
17
  // - error — muted terracotta
18
18
  export const theme = {
19
- user: chalk.hex('#b4b8d0'), // soft lavender-gray
19
+ // Operator messages — a clearly DISTINCT cyan so they never blend with the
20
+ // model's warm-sand answers or the neutral system text (operator request).
21
+ user: chalk.hex('#8fd4f0'), // operator text — bright cyan-blue
22
+ userBar: chalk.hex('#4a8fb5'), // the `▌` bar marking an operator turn
23
+ // A dim rule + label separating one conversation TURN from the next.
24
+ turnRule: chalk.hex('#3a3f4b'),
25
+ turnLabel: chalk.hex('#5b616e'),
20
26
  prompt: chalk.hex('#c8b06a').bold, // input prompt — warm gold, noticeable
21
27
  dir: chalk.hex('#7fc4f0'), // working directory name — noticeably blue
22
28
  assistant: chalk.hex('#cfc9b0'), // warm sand
@@ -0,0 +1,83 @@
1
+ // PURE tool-call summarizer — turns a tool's raw args into a SHORT, readable
2
+ // one-liner for the log (Codex / Claude Code style): `Read(src/index.ts)`
3
+ // instead of the full JSON. No terminal access, so it is unit-testable.
4
+ //
5
+ // The full args are NOT lost: when a tool has no special summary the caller
6
+ // still shows the raw JSON, so nothing is hidden.
7
+ /** Collapse whitespace and clip to `max` chars (single line). */
8
+ function oneLine(s, max = 80) {
9
+ const t = String(s).replace(/\s+/g, ' ').trim();
10
+ return t.length > max ? t.slice(0, max - 1) + '…' : t;
11
+ }
12
+ function str(v) {
13
+ return typeof v === 'string' ? v : '';
14
+ }
15
+ /**
16
+ * A compact, human-readable summary of a tool call. Falls back to the JSON of
17
+ * the args when there is nothing specific to show, so no information is lost.
18
+ */
19
+ export function summarizeToolCall(name, args) {
20
+ const a = args || {};
21
+ switch (name) {
22
+ case 'Read':
23
+ case 'Write':
24
+ case 'Edit':
25
+ case 'MultiEdit':
26
+ return { name, detail: oneLine(str(a.path) || str(a.file_path)) };
27
+ case 'LS':
28
+ case 'Glob':
29
+ return {
30
+ name,
31
+ detail: oneLine(str(a.path) || str(a.pattern) || '.'),
32
+ };
33
+ case 'Grep':
34
+ return {
35
+ name,
36
+ detail: oneLine((str(a.pattern) ? '/' + str(a.pattern) + '/' : '') +
37
+ (a.path ? ' ' + str(a.path) : '')),
38
+ };
39
+ case 'Bash': {
40
+ const cmd = str(a.command);
41
+ return { name, detail: oneLine(cmd) };
42
+ }
43
+ case 'Task':
44
+ return { name, detail: oneLine(str(a.description) || str(a.prompt)) };
45
+ case 'TodoWrite': {
46
+ const todos = Array.isArray(a.todos) ? a.todos.length : 0;
47
+ return { name, detail: todos ? todos + ' items' : '' };
48
+ }
49
+ case 'respond':
50
+ return { name, detail: oneLine(str(a.message), 60) };
51
+ default: {
52
+ const keys = Object.keys(a);
53
+ if (!keys.length)
54
+ return { name, detail: '' };
55
+ try {
56
+ return { name, detail: oneLine(JSON.stringify(a)) };
57
+ }
58
+ catch {
59
+ return { name, detail: '' };
60
+ }
61
+ }
62
+ }
63
+ }
64
+ /**
65
+ * A cheap heuristic for the STATUS ICON next to a tool result: a result that
66
+ * clearly reports a failure gets ✖, a normal one ✔. The agent loop returns a
67
+ * failed tool as a string beginning with "Error"/"Missing required argument"
68
+ * (see src/tools.ts), so matching those prefixes is reliable enough — a false
69
+ * "✔" on an unusual error body is harmless (the text is still shown).
70
+ */
71
+ export function looksLikeToolError(result) {
72
+ const head = String(result ?? '')
73
+ .trim()
74
+ .slice(0, 60)
75
+ .toLowerCase();
76
+ if (!head)
77
+ return false;
78
+ return (head.startsWith('error') ||
79
+ head.startsWith('missing required argument') ||
80
+ head.startsWith('failed') ||
81
+ head.includes('command failed') ||
82
+ head.includes('exit code'));
83
+ }