@iowarp/clio-coder 0.3.2 → 0.3.3

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 (115) hide show
  1. package/CHANGELOG.md +229 -457
  2. package/README.md +2 -2
  3. package/dist/{acp-BIYHVZIM.js → acp-P2AQILE2.js} +2 -2
  4. package/dist/{agents-YT6SSRIT.js → agents-72W3BI7I.js} +8 -8
  5. package/dist/assets/codewiki.json +1 -1
  6. package/dist/{chunk-WMSVI4G2.js → chunk-2DJ2KNFG.js} +3 -3
  7. package/dist/{chunk-2EHAIA3X.js → chunk-2TLUCQVG.js} +2 -2
  8. package/dist/{chunk-WVO7V2QY.js → chunk-4XUGQOHA.js} +2 -2
  9. package/dist/{chunk-IGLFWIYI.js → chunk-5UFT4SUX.js} +2 -2
  10. package/dist/{chunk-AO4RKG4M.js → chunk-6SGHMWE3.js} +3 -3
  11. package/dist/{chunk-X75S7HFS.js → chunk-COU2UHX6.js} +45 -19
  12. package/dist/{chunk-KJ5LWLOE.js → chunk-DSELYM6W.js} +2 -2
  13. package/dist/{chunk-G2DE3C7R.js → chunk-DUYJ5IO6.js} +2 -2
  14. package/dist/{chunk-EPVUXGXG.js → chunk-FNTMWMX5.js} +9 -9
  15. package/dist/{chunk-MNA4JGU4.js → chunk-J7CWMCQD.js} +2 -2
  16. package/dist/{chunk-MBS4V7ZP.js → chunk-KZWTDYJF.js} +4 -4
  17. package/dist/{chunk-LBNRH5WM.js → chunk-LM5TQCJZ.js} +3 -3
  18. package/dist/{chunk-OHHN2SO4.js → chunk-LW6DSM3M.js} +7 -7
  19. package/dist/{chunk-MQSRRFWA.js → chunk-LWLEKMDQ.js} +56 -2
  20. package/dist/{chunk-V4RXGQ5Q.js → chunk-OC7FIQPC.js} +2 -2
  21. package/dist/{chunk-3ZXDFGR5.js → chunk-PAJK6MAQ.js} +2 -2
  22. package/dist/{chunk-7EYHLWU7.js → chunk-PIWWS5BL.js} +3 -3
  23. package/dist/{chunk-6EJV5X2W.js → chunk-SRDMMSEP.js} +3 -3
  24. package/dist/{chunk-ARBGF5F7.js → chunk-TZK7PACC.js} +2 -2
  25. package/dist/{chunk-QTYWRVRA.js → chunk-UFIIWP2H.js} +2 -2
  26. package/dist/{chunk-J5HN4RYU.js → chunk-V6RTAOC2.js} +2 -2
  27. package/dist/{chunk-SRF2PJNW.js → chunk-VPAYEGVX.js} +2 -2
  28. package/dist/{chunk-77VKQEHF.js → chunk-X6IAEBZR.js} +2 -2
  29. package/dist/{chunk-4KLWL3UC.js → chunk-XBXAASKX.js} +2 -2
  30. package/dist/{chunk-MAW544W2.js → chunk-ZWMF7253.js} +4 -4
  31. package/dist/cli/index.js +17 -17
  32. package/dist/{clio-4LY5K2AC.js → clio-JOU4FXVA.js} +2 -2
  33. package/dist/{config-GTLUW2PR.js → config-XCDVKR23.js} +7 -7
  34. package/dist/{configure-R6A64DHX.js → configure-4GAP54ZW.js} +5 -5
  35. package/dist/{context-RW5HC47S.js → context-4UOGGLQ5.js} +6 -6
  36. package/dist/{context-JFZEJ7W5.js → context-77FM5DV5.js} +9 -9
  37. package/dist/{context-clear-6ZHBAZZT.js → context-clear-XXJRLCJJ.js} +6 -6
  38. package/dist/{dispatch-runner-VKBRCWQC.js → dispatch-runner-QPRDDBDX.js} +7 -7
  39. package/dist/{doctor-KI767GSN.js → doctor-HR46URBJ.js} +5 -5
  40. package/dist/{evidence-UA6AWDQQ.js → evidence-6HG2PY2B.js} +4 -4
  41. package/dist/{evolve-QNTFGV6Z.js → evolve-K7YU3NCY.js} +4 -4
  42. package/dist/{fleet-Q7UOMUSG.js → fleet-VY3HHKN6.js} +15 -15
  43. package/dist/{init-WBB65ZHQ.js → init-JYGXI3FK.js} +12 -12
  44. package/dist/{memory-MD3O64RI.js → memory-WFZMGYHX.js} +5 -5
  45. package/dist/{models-BZU34YWD.js → models-I5QWSEOM.js} +7 -7
  46. package/dist/{monitor-MEQA5C3I.js → monitor-GE4ID3IA.js} +5 -5
  47. package/dist/{orchestrator-CGFKEP27.js → orchestrator-EM5MC3HM.js} +1482 -1055
  48. package/dist/{run-IV4Q6RLN.js → run-ZU3QMZPZ.js} +18 -18
  49. package/dist/{skills-LQEKRDTN.js → skills-X5VXCRNQ.js} +2 -2
  50. package/dist/{skills-eval-3DC4HEWS.js → skills-eval-WKIHWTHR.js} +5 -5
  51. package/dist/{targets-C4SSGQOB.js → targets-SNCPI2NR.js} +8 -8
  52. package/dist/{terminal-lease-IT5JW2NR.js → terminal-lease-BNAHVHBS.js} +2 -2
  53. package/dist/{upgrade-7TT7SQ3G.js → upgrade-JQHHPQ4K.js} +7 -7
  54. package/dist/{usage-GV4PKT3M.js → usage-OR4O5SMZ.js} +5 -5
  55. package/dist/{verify-G6V4D2G7.js → verify-375KUB3Y.js} +4 -4
  56. package/dist/{wiki-generate-DQF6Z66B.js → wiki-generate-UEXP2ARI.js} +11 -11
  57. package/dist/worker/entry.js +8 -8
  58. package/docs/README.md +3 -3
  59. package/docs/acp.md +1 -1
  60. package/docs/alcf-provider.md +1 -1
  61. package/docs/architecture.md +2 -2
  62. package/docs/artifact-versions.md +1 -1
  63. package/docs/built-in-agents.md +1 -1
  64. package/docs/capacity-and-scheduling.md +1 -1
  65. package/docs/commands-and-modes.md +1 -1
  66. package/docs/configuration-and-targets.md +1 -1
  67. package/docs/context-engine.md +1 -1
  68. package/docs/development-pipeline.md +1 -1
  69. package/docs/documentation-coverage.md +2 -2
  70. package/docs/documentation-guide.md +1 -1
  71. package/docs/eval-runner.md +1 -1
  72. package/docs/evals-internal.md +1 -1
  73. package/docs/evidence-and-memory.md +2 -2
  74. package/docs/evolution.md +1 -1
  75. package/docs/exit-codes-and-output.md +1 -1
  76. package/docs/extensions-and-sharing.md +2 -2
  77. package/docs/fleet-dispatch.md +1 -1
  78. package/docs/installation-and-lifecycle.md +6 -6
  79. package/docs/middleware-and-components.md +1 -1
  80. package/docs/model-catalog.md +1 -1
  81. package/docs/observability.md +3 -3
  82. package/docs/performance-methodology.md +2 -2
  83. package/docs/proactive-memory.md +1 -1
  84. package/docs/prompt-envelope-and-tools.md +1 -1
  85. package/docs/provider-adapter-cookbook.md +1 -1
  86. package/docs/release-cut-checklist.md +30 -30
  87. package/docs/safety-model.md +2 -2
  88. package/docs/scientific-validation.md +3 -3
  89. package/docs/session-lifecycle.md +2 -2
  90. package/docs/skills-marketplace.md +1 -1
  91. package/docs/tool-usage.md +2 -2
  92. package/docs/trace-store.md +1 -1
  93. package/docs/troubleshooting.md +1 -1
  94. package/docs/tui-design.md +2 -2
  95. package/docs/worker-dispatch-mechanics.md +1 -1
  96. package/package.json +1 -1
  97. package/src/core/git-commit-attribution.ts +46 -21
  98. package/src/domains/config/keybindings.ts +3 -3
  99. package/src/interactive/chat-panel.ts +555 -244
  100. package/src/interactive/chat-renderer.ts +50 -19
  101. package/src/interactive/editor-submit.ts +26 -1
  102. package/src/interactive/footer/widgets.ts +22 -20
  103. package/src/interactive/footer-panel.ts +6 -1
  104. package/src/interactive/interactive-application.ts +2 -0
  105. package/src/interactive/interactive-event-projection.ts +12 -0
  106. package/src/interactive/interactive-slash-runtime.ts +12 -7
  107. package/src/interactive/overlays/ask-user.ts +146 -24
  108. package/src/interactive/renderers/tool-execution.ts +151 -56
  109. package/src/interactive/status/index.ts +12 -1
  110. package/src/interactive/status/reasoning.ts +87 -0
  111. package/src/interactive/status/summary.ts +13 -2
  112. package/src/interactive/transcript-detail.ts +120 -0
  113. package/src/tools/builtin-tool-catalog.ts +7 -1
  114. package/src/tools/presentation.ts +107 -0
  115. package/src/tools/registry.ts +6 -0
@@ -13,8 +13,9 @@ import {
13
13
  } from "../../engine/tui.js";
14
14
  import type { AskUserAnswer, AskUserQuestion, AskUserResult } from "../../tools/ask-user.js";
15
15
  import { ASK_USER_OTHER_LABEL, cancelledAskUserResult } from "../../tools/ask-user.js";
16
- import { buildHint, DEFAULT_SELECT_THEME, showClioOverlayFrame } from "../overlay-frame.js";
16
+ import { buildHint, DEFAULT_SELECT_THEME, type HintEntry, showClioOverlayFrame } from "../overlay-frame.js";
17
17
  import { type ClioToken, clioTheme, dotSep, GLYPH, screenTitle } from "../theme/index.js";
18
+ import { nextContentScrollOffset, type ViewScrollAction } from "../view/view-overlay.js";
18
19
 
19
20
  /**
20
21
  * The border and title token while a decision is pending.
@@ -54,7 +55,7 @@ export const ASK_USER_DEFAULT_SURFACE: AskUserSurface = "panel";
54
55
  interface AskUserSurfaceGeometry {
55
56
  /** Box width in columns; zero fills the row. The frame clamps to the terminal. */
56
57
  width: number;
57
- /** Rows the body may use, before the borders. */
58
+ /** Rows the body may use, before the borders. Zero means every row the frame has. */
58
59
  maxInnerRows: number;
59
60
  anchor: NonNullable<OverlayOptions["anchor"]>;
60
61
  margin: NonNullable<OverlayOptions["margin"]>;
@@ -74,12 +75,18 @@ interface AskUserSurfaceGeometry {
74
75
  export const ASK_USER_SURFACE_GEOMETRY: Record<AskUserSurface, AskUserSurfaceGeometry> = {
75
76
  compact: { width: 68, maxInnerRows: 10, anchor: "bottom-center", margin: { top: 1, right: 2, bottom: 1, left: 2 } },
76
77
  panel: { width: 88, maxInnerRows: 18, anchor: "center", margin: 1 },
77
- interview: { width: 0, maxInnerRows: 42, anchor: "center", margin: 1 },
78
+ // The interview is a workspace, not a modal. A cap of 42 turned a 66-row
79
+ // terminal into a centered box with twenty blank rows around it, so the
80
+ // interview takes every row the frame has and lays its own chrome out inside
81
+ // them. The one-row margin keeps the border off the top row and the footer.
82
+ interview: { width: 0, maxInnerRows: 0, anchor: "center", margin: 1 },
78
83
  };
79
84
 
80
85
  const ASK_USER_FRAME_ROWS = 2;
81
86
  const ASK_USER_FRAME_AND_MARGIN_ROWS = 4;
82
87
  const MIN_VISIBLE_OPTIONS = 3;
88
+ /** Interview rows spent on chrome before an option row: strip, header, blanks, status, one question row. */
89
+ const INTERVIEW_CHROME_ROWS = 7;
83
90
  const MAX_VISIBLE_OPTIONS = 12;
84
91
  const ELLIPSIS = "…";
85
92
 
@@ -227,6 +234,14 @@ class AskUserOverlayView implements Component {
227
234
  private list: SelectList | null = null;
228
235
  private input: Input | null = null;
229
236
  private resolveCurrent: ((result: AskUserResult) => void) | null = null;
237
+ /** Rows of the interview's scrollable middle already scrolled past. */
238
+ private contentScrollOffset = 0;
239
+ /** Whether the last interview render had more content than the region held. */
240
+ private contentOverflows = false;
241
+ /** Rows the scroll region drew on the last render, for a page-sized jump. */
242
+ private contentRegionHeight = 1;
243
+ /** Rows the scroll region wanted on the last render. */
244
+ private contentTotalRows = 0;
230
245
 
231
246
  constructor(private readonly deps: AskUserOverlayViewDeps) {}
232
247
 
@@ -240,6 +255,7 @@ class AskUserOverlayView implements Component {
240
255
  this.states = this.questions.map((question) => createQuestionState(question));
241
256
  this.list = null;
242
257
  this.input = null;
258
+ this.resetContentScroll();
243
259
  // Before the controls, because how many option rows fit is a property of
244
260
  // the box this round is about to be drawn in.
245
261
  this.setSurface(askUserSurface(this.questions, this.history.length));
@@ -283,6 +299,10 @@ class AskUserOverlayView implements Component {
283
299
  const state = this.currentState();
284
300
  if (!question || !state) return;
285
301
 
302
+ // Scrolling the transcript must not cost the operator their place in the
303
+ // options, so the region has keys of its own that neither control claims.
304
+ if (this.handleScrollInput(data, state.mode)) return;
305
+
286
306
  if (state.mode === "text") {
287
307
  if (this.isTextModePreviousKey(data)) {
288
308
  this.goToRelativeQuestion(-1);
@@ -331,21 +351,106 @@ class AskUserOverlayView implements Component {
331
351
  const question = this.currentQuestion();
332
352
  if (!question) return [clioTheme().fg("muted", "No questions.")];
333
353
 
334
- const interview = this.surface === "interview";
354
+ if (this.surface === "interview") return this.renderInterview(question, safeWidth, maxRows);
355
+
356
+ this.contentOverflows = false;
335
357
  const controlLines = this.renderControlLines(safeWidth);
336
- const ledgerLines = interview ? this.renderAnswerLedger(safeWidth) : [];
337
358
  const statusLines = this.status.length > 0 ? ["", fitLine(clioTheme().fg("dim", this.status), safeWidth)] : [];
338
- const stripLines = interview ? [this.renderQuestionStrip(safeWidth), ""] : [];
339
359
  const headerLine = this.renderQuestionHeader(question, safeWidth);
340
360
  const headerLines = headerLine.length > 0 ? [headerLine] : [];
341
- const spent =
342
- stripLines.length + headerLines.length + statusLines.length + 1 + controlLines.length + ledgerLines.length;
361
+ const spent = headerLines.length + statusLines.length + 1 + controlLines.length;
343
362
  const questionLines = wrapTextWithAnsi(question.question, safeWidth).slice(0, Math.max(1, maxRows - spent));
344
363
 
345
- return [...stripLines, ...headerLines, ...questionLines, ...statusLines, "", ...controlLines, ...ledgerLines].slice(
346
- 0,
347
- maxRows,
364
+ return [...headerLines, ...questionLines, ...statusLines, "", ...controlLines].slice(0, maxRows);
365
+ }
366
+
367
+ /**
368
+ * The interview, as a workspace with fixed chrome and a scrolling middle.
369
+ *
370
+ * The round strip and the question header hold the top; the status line and
371
+ * the active control hold the bottom, at their full row cost, so the thing the
372
+ * operator has to act on is never the thing that gets clipped. The question
373
+ * text and the answer ledger share whatever is left and scroll through it.
374
+ * Everything is re-derived per render, so a resize is just the next frame.
375
+ */
376
+ private renderInterview(question: AskUserQuestion, width: number, maxRows: number): string[] {
377
+ const theme = clioTheme();
378
+ const controlLines = this.renderControlLines(width);
379
+ const statusLines = this.status.length > 0 ? ["", fitLine(theme.fg("dim", this.status), width)] : [];
380
+ const headerLine = this.renderQuestionHeader(question, width);
381
+ const headerLines = headerLine.length > 0 ? [headerLine] : [];
382
+ const stripLines = [this.renderQuestionStrip(width), ""];
383
+ const content = [...wrapTextWithAnsi(question.question, width), ...this.renderAnswerLedger(width)];
384
+
385
+ // Chrome sheds from the outside in when the terminal is too short to carry
386
+ // all of it: the round strip first, then the header, then the status, and
387
+ // the control only if a terminal is short enough that nothing else is left.
388
+ let top = [...stripLines, ...headerLines];
389
+ let bottom = [...statusLines, "", ...controlLines];
390
+ const region = (): number => maxRows - top.length - bottom.length;
391
+ if (region() < 1) top = [...headerLines];
392
+ if (region() < 1) top = [];
393
+ if (region() < 1) bottom = ["", ...controlLines];
394
+ if (region() < 1) bottom = [...controlLines];
395
+ const regionRows = Math.max(1, region());
396
+
397
+ const overflows = content.length > regionRows;
398
+ this.contentOverflows = overflows;
399
+ this.contentTotalRows = content.length;
400
+ // One row of the region pays for the indicator when there is more than the
401
+ // region holds, so the marker never covers a row it is reporting on.
402
+ const viewRows = overflows ? Math.max(1, regionRows - 1) : regionRows;
403
+ this.contentRegionHeight = viewRows;
404
+ this.contentScrollOffset = Math.max(0, Math.min(this.contentScrollOffset, Math.max(0, content.length - viewRows)));
405
+
406
+ const visible = content.slice(this.contentScrollOffset, this.contentScrollOffset + viewRows);
407
+ const middle = [...visible];
408
+ if (overflows) middle.push(fitLine(theme.fg("dim", this.scrollIndicator(content.length, viewRows)), width));
409
+ while (middle.length < regionRows) middle.push("");
410
+
411
+ return [...top, ...middle, ...bottom].slice(0, maxRows);
412
+ }
413
+
414
+ private scrollIndicator(total: number, viewRows: number): string {
415
+ const above = this.contentScrollOffset;
416
+ const below = Math.max(0, total - viewRows - above);
417
+ const parts: string[] = [];
418
+ if (above > 0) parts.push(`↑ ${above} more`);
419
+ if (below > 0) parts.push(`↓ ${below} more`);
420
+ parts.push(`(${Math.min(total, above + viewRows)}/${total})`);
421
+ return parts.join(" ");
422
+ }
423
+
424
+ /** True when the key belonged to the scroll region rather than to a control. */
425
+ private handleScrollInput(data: string, mode: Mode): boolean {
426
+ if (this.surface !== "interview") return false;
427
+ const action = this.scrollAction(data, mode);
428
+ if (!action) return false;
429
+ const next = nextContentScrollOffset(
430
+ this.contentScrollOffset,
431
+ this.contentTotalRows,
432
+ this.contentRegionHeight,
433
+ action,
348
434
  );
435
+ if (next !== this.contentScrollOffset) {
436
+ this.contentScrollOffset = next;
437
+ this.deps.requestRender();
438
+ }
439
+ return true;
440
+ }
441
+
442
+ private scrollAction(data: string, mode: Mode): ViewScrollAction | null {
443
+ if (matchesKey(data, "pageUp")) return "page-up";
444
+ if (matchesKey(data, "pageDown")) return "page-down";
445
+ // Ctrl+U and Ctrl+D are line-kill and half-page in a text field, so they
446
+ // stay with the input while one is focused.
447
+ if (mode === "select" && matchesKey(data, "ctrl+u")) return "half-up";
448
+ if (mode === "select" && matchesKey(data, "ctrl+d")) return "half-down";
449
+ return null;
450
+ }
451
+
452
+ private resetContentScroll(): void {
453
+ this.contentScrollOffset = 0;
349
454
  }
350
455
 
351
456
  footerHint(): string {
@@ -357,11 +462,11 @@ class AskUserOverlayView implements Component {
357
462
  if (!question || !state) return buildHint([]);
358
463
  if (state.mode === "text") {
359
464
  return this.questions.length > 1
360
- ? buildHint([
465
+ ? this.withScrollHint([
361
466
  { key: "Enter", verb: "submit" },
362
467
  { key: "Alt+Left/Right", verb: "question" },
363
468
  ])
364
- : buildHint([{ key: "Enter", verb: "submit" }]);
469
+ : this.withScrollHint([{ key: "Enter", verb: "submit" }]);
365
470
  }
366
471
  // `accept` rather than `select` or `commit`: the footer of a decision prompt
367
472
  // has one job, which is to name the key that answers it. Esc keeps the
@@ -369,22 +474,32 @@ class AskUserOverlayView implements Component {
369
474
  // interview together.
370
475
  if (question.multi_select === true) {
371
476
  return this.questions.length > 1
372
- ? buildHint([
477
+ ? this.withScrollHint([
373
478
  { key: "Left/Right", verb: "question" },
374
479
  { key: "Space", verb: "toggle" },
375
480
  { key: "Enter", verb: "accept" },
376
481
  ])
377
- : buildHint([
482
+ : this.withScrollHint([
378
483
  { key: "Space", verb: "toggle" },
379
484
  { key: "Enter", verb: "accept" },
380
485
  ]);
381
486
  }
382
487
  return this.questions.length > 1
383
- ? buildHint([
488
+ ? this.withScrollHint([
384
489
  { key: "Left/Right", verb: "question" },
385
490
  { key: "Enter", verb: "accept" },
386
491
  ])
387
- : buildHint([{ key: "Enter", verb: "accept" }]);
492
+ : this.withScrollHint([{ key: "Enter", verb: "accept" }]);
493
+ }
494
+
495
+ /**
496
+ * The keys for this question, plus the scroll keys when there is something to
497
+ * scroll. A footer that always advertised PgUp would be advertising a key that
498
+ * does nothing on most rounds.
499
+ */
500
+ private withScrollHint(entries: ReadonlyArray<HintEntry>): string {
501
+ if (this.surface !== "interview" || !this.contentOverflows) return buildHint(entries);
502
+ return buildHint([...entries, { key: "PgUp/PgDn", verb: "scroll" }]);
388
503
  }
389
504
 
390
505
  private setSurface(next: AskUserSurface): void {
@@ -443,17 +558,20 @@ class AskUserOverlayView implements Component {
443
558
  private maxInnerRows(): number {
444
559
  const cap = ASK_USER_SURFACE_GEOMETRY[this.surface].maxInnerRows;
445
560
  const rows = this.deps.getTerminalRows();
446
- if (!Number.isFinite(rows) || rows <= 0) return cap;
447
- return Math.max(1, Math.min(cap, Math.floor(rows) - ASK_USER_FRAME_AND_MARGIN_ROWS));
561
+ // A zero cap is the interview's "every row the frame has", which is the
562
+ // same number the frame budgets itself when the mount passes no maxHeight.
563
+ if (!Number.isFinite(rows) || rows <= 0) return cap > 0 ? cap : 1;
564
+ const available = Math.max(1, Math.floor(rows) - ASK_USER_FRAME_AND_MARGIN_ROWS);
565
+ return cap > 0 ? Math.min(cap, available) : available;
448
566
  }
449
567
 
450
568
  private maxVisibleOptions(): number {
451
569
  const inner = this.maxInnerRows();
452
- // The interview surface spends rows on the round strip and the ledger
453
- // before an option ever renders. The other two spend them on a header, a
454
- // question, and a separator, so the options get nearly everything.
570
+ // The interview's option list is budgeted off the live row count rather
571
+ // than a fixed heuristic: the strip, header, blank, status, blank and one
572
+ // row of question are what the control shares the workspace with.
455
573
  if (this.surface === "interview") {
456
- return Math.max(MIN_VISIBLE_OPTIONS, Math.min(MAX_VISIBLE_OPTIONS, inner - 12));
574
+ return Math.max(MIN_VISIBLE_OPTIONS, Math.min(MAX_VISIBLE_OPTIONS, inner - INTERVIEW_CHROME_ROWS));
457
575
  }
458
576
  return Math.max(1, Math.min(MAX_VISIBLE_OPTIONS, inner - 4));
459
577
  }
@@ -809,6 +927,7 @@ class AskUserOverlayView implements Component {
809
927
  const next = this.nextUnansweredIndex();
810
928
  if (next !== null) {
811
929
  this.index = next;
930
+ this.resetContentScroll();
812
931
  const nextState = this.currentState();
813
932
  const nextQuestion = this.currentQuestion();
814
933
  if (nextState && nextQuestion && !nextState.answer.trim()) nextState.mode = initialMode(nextQuestion);
@@ -844,6 +963,7 @@ class AskUserOverlayView implements Component {
844
963
  this.syncActiveControl();
845
964
  this.index = (this.index + delta + this.questions.length) % this.questions.length;
846
965
  this.status = "";
966
+ this.resetContentScroll();
847
967
  this.rebuildControl();
848
968
  this.deps.requestRender();
849
969
  }
@@ -888,7 +1008,9 @@ export function openAskUserOverlay(tui: TUI, deps: OpenAskUserOverlayDeps): AskU
888
1008
  handle = showClioOverlayFrame(tui, view, {
889
1009
  anchor: geometry.anchor,
890
1010
  width: geometry.width,
891
- maxHeight: geometry.maxInnerRows + ASK_USER_FRAME_ROWS,
1011
+ // A zero cap means the frame keeps its own budget, which is every row
1012
+ // the terminal has left after the margins.
1013
+ ...(geometry.maxInnerRows > 0 ? { maxHeight: geometry.maxInnerRows + ASK_USER_FRAME_ROWS } : {}),
892
1014
  margin: geometry.margin,
893
1015
  title: () => (view.isDecisionPending() ? ASK_USER_DECISION_TITLE : ASK_USER_WAITING_TITLE),
894
1016
  tone: () => (view.isDecisionPending() ? ASK_USER_DECISION_TONE : undefined),
@@ -15,11 +15,14 @@
15
15
  import { sanitizeCallTargetText } from "../../domains/safety/call-target.js";
16
16
  import { formatSize } from "../../engine/truncate.js";
17
17
  import { visibleWidth, wrapTextWithAnsi } from "../../engine/tui.js";
18
+ import { classifyResourceRead, toolPresentationPolicy } from "../../tools/presentation.js";
18
19
  import type { ApprovalRequestView } from "../permission-overlay.js";
19
20
  import { clioTheme, formatCompactMs, GLYPH } from "../theme/index.js";
20
21
  import { renderDiffLines } from "./diff.js";
21
22
  import { tryRenderJson, tryRenderXml } from "./structured.js";
22
23
 
24
+ export { classifyResourceRead };
25
+
23
26
  const theme = clioTheme();
24
27
  const dim = (text: string): string => theme.fg("dim", text);
25
28
  const red = (text: string): string => theme.fg("error", text);
@@ -40,6 +43,8 @@ const FULL_RESULT_PREVIEW_LIMIT = 60_000;
40
43
  const FULL_RESULT_ROW_LIMIT = 120;
41
44
  const STREAMING_RESULT_ROW_LIMIT = 20;
42
45
  const ARGS_BODY_LINE_LIMIT = 24;
46
+ /** Rows of mutation diff a folded edit/write row keeps visible before it defers to the body. */
47
+ const FOLDED_DIFF_ROW_LIMIT = 24;
43
48
  const STATUS_OK_GLYPH = GLYPH.ok;
44
49
  const STATUS_ERROR_GLYPH = GLYPH.error;
45
50
 
@@ -286,24 +291,6 @@ function summarizeArgs(toolName: string, args: unknown): string {
286
291
  return truncate(jsonStringifySafe(safeArgs), ARG_PREVIEW_LIMIT);
287
292
  }
288
293
 
289
- /**
290
- * Compact resource-read classification. Reads of skill/handbook/agent
291
- * instruction files and docs pages collapse to one labeled line and never
292
- * auto-expand; their bodies are reference material, not task output.
293
- */
294
- export function classifyResourceRead(toolName: string, args: unknown): string | null {
295
- if (toolName !== "read") return null;
296
- const path = readStringField(args, "path");
297
- if (path === null) return null;
298
- const normalized = path.replace(/\\/g, "/");
299
- const base = normalized.split("/").pop() ?? "";
300
- if (base === "SKILL.md") return "skill";
301
- if (base === "CLIO-CODER.md") return "handbook";
302
- if (base === "AGENTS.md") return "agents";
303
- if (/(^|\/)docs\//.test(normalized)) return "docs";
304
- return null;
305
- }
306
-
307
294
  function detailsOf(result: unknown): Record<string, unknown> | null {
308
295
  if (!isPlainObject(result)) return null;
309
296
  return isPlainObject(result.details) ? result.details : null;
@@ -448,15 +435,58 @@ function ledgerTail(finished: ToolExecutionFinished): { facts: string; offload:
448
435
  const parts: string[] = [];
449
436
  const outcome = outcomeSummary(finished);
450
437
  if (outcome !== null) parts.push(outcome);
451
- const bytes = isNonExecutedOutcome(finished.outcome) ? null : shownBytesOf(finished);
452
- if (bytes !== null && bytes > 0) parts.push(formatSize(bytes));
453
- const offloadPath = offloadPathOf(finished);
438
+ const executed = !isNonExecutedOutcome(finished.outcome);
439
+ // A folded bash row is the only view of the call the operator gets by
440
+ // default, so its settlement facts have to be here rather than only in the
441
+ // expanded body. A failed row already carries `(exit N)` on the status glyph.
442
+ if (executed && finished.toolName === "bash" && !finished.isError) {
443
+ parts.push(`exit ${structuredExitCode(finished) ?? "0"}`);
444
+ }
445
+ const bytes = executed ? shownBytesOf(finished) : null;
446
+ if (bytes !== null && bytes > 0) {
447
+ const total = totalBytesOf(finished);
448
+ parts.push(
449
+ total !== null && total > bytes ? `${formatSize(bytes)} shown / ${formatSize(total)} total` : formatSize(bytes),
450
+ );
451
+ }
452
+ if (executed) {
453
+ if (isTruncatedResult(finished)) parts.push("truncated");
454
+ const details = detailsOf(finished.result);
455
+ if (details?.timedOut === true) parts.push("timed out");
456
+ if (details?.outputCapped === true) parts.push("output capped");
457
+ }
458
+ if (finished.excludeFromContext === true) parts.push("excluded from context");
459
+ const offloadPath = executed ? offloadPathOf(finished) : null;
454
460
  return {
455
461
  facts: parts.length > 0 ? dim(` · ${parts.join(" · ")}`) : "",
456
462
  offload: offloadPath !== null ? dim(` · full: ${offloadPath}`) : "",
457
463
  };
458
464
  }
459
465
 
466
+ /** Longest diagnostic excerpt a folded failure row may carry. */
467
+ const FAILURE_EXCERPT_LIMIT = 80;
468
+
469
+ /**
470
+ * Last non-empty output line of a failed call, bounded so the folded row stays
471
+ * diagnostically useful without opening the body. Sanitized to one line: the
472
+ * source is raw tool output. The tool's presentation policy decides whether
473
+ * its failures carry one; the renderer never names a tool here.
474
+ */
475
+ function failureExcerpt(finished: ToolExecutionFinished, width: number): string {
476
+ if (!finished.isError || !toolPresentationPolicy(finished.toolName, finished.args).failureExcerpt) return "";
477
+ if (isNonExecutedOutcome(finished.outcome)) return "";
478
+ const text = unwrapResultEnvelope(finished.result);
479
+ if (typeof text !== "string") return "";
480
+ let excerpt: string | undefined;
481
+ for (const raw of text.split("\n")) {
482
+ const line = sanitizeCallTargetText(raw).trim();
483
+ if (line.length > 0) excerpt = line;
484
+ }
485
+ if (excerpt === undefined) return "";
486
+ const limit = Math.min(FAILURE_EXCERPT_LIMIT, Math.max(20, width - 20));
487
+ return dim(` · ${truncate(excerpt, limit)}`);
488
+ }
489
+
460
490
  /**
461
491
  * Marker for a call parked at admission awaiting operator approval. Rendered
462
492
  * in place of the elapsed counter so a parked call never reads as executing
@@ -531,8 +561,8 @@ function statusGlyph(status: HeaderStatus, meta: StatusMeta = {}): string {
531
561
  }
532
562
 
533
563
  function headerLine(toolName: string, args: unknown, status: HeaderStatus, meta: StatusMeta = {}): string {
534
- const summary = summarizeArgs(toolName, args);
535
- const head = `${dim(HEADER_PREFIX_PLAIN)}${cyanBold(toolName)}${dim("(")}${cyan(summary)}${dim(")")}`;
564
+ const body = styleSublineBody(buildSublineBody(toolName, args, status, undefined, meta.outcome));
565
+ const head = `${dim(HEADER_PREFIX_PLAIN)}${body}`;
536
566
  return `${head}${statusGlyph(status, meta)}`;
537
567
  }
538
568
 
@@ -546,8 +576,9 @@ function styleSublineBody(body: string): string {
546
576
  return sublineLead(match.groups.lead, match.groups.rest);
547
577
  }
548
578
 
549
- function buildUnknownToolBody(toolName: string, args: unknown): string {
550
- return `${toolName}(${summarizeArgs(toolName, args)})`;
579
+ function buildGenericToolBody(toolName: string, args: unknown): string {
580
+ const summary = summarizeArgs(toolName, args);
581
+ return summary.length > 0 ? `tool action ${summary}` : "tool action";
551
582
  }
552
583
 
553
584
  function buildFieldSublineBody(
@@ -604,6 +635,7 @@ const SUBLINE_BODY_BUILDERS: Readonly<Record<string, (args: unknown) => string |
604
635
  bash: (args) => buildFieldSublineBody(args, "command", "running ", { wrapInBackticks: true }),
605
636
  grep: (args) => buildFieldSublineBody(args, "pattern", "searching for ", { wrapInBackticks: true }),
606
637
  find: (args) => buildFieldSublineBody(args, "pattern", "finding ", { wrapInBackticks: true }),
638
+ web_fetch: (args) => buildFieldSublineBody(args, "url", "fetching "),
607
639
  git: (args) => buildFieldSublineBody(args, "op", "git "),
608
640
  verify: (args) => buildFieldSublineBody(args, "check", "verifying "),
609
641
  code_nav: (args) => {
@@ -641,7 +673,7 @@ const SUBLINE_BODY_BUILDERS: Readonly<Record<string, (args: unknown) => string |
641
673
  /**
642
674
  * Per-tool subline templates. Maps a tool name to a function that builds the
643
675
  * verb-led subline body without the leading glyph and without the trailing
644
- * status glyph. Unknown tools fall back to the existing `<name>(<arg>)` form.
676
+ * status glyph. Unknown tools fall back to a tool-neutral action summary.
645
677
  */
646
678
  function webFetchMeta(result: unknown): string | null {
647
679
  if (!isPlainObject(result) || !isPlainObject(result.details)) return null;
@@ -658,11 +690,10 @@ function webFetchMeta(result: unknown): string | null {
658
690
 
659
691
  /**
660
692
  * A call the permission gate blocked never executed, so the collapsed row must
661
- * not claim it did. `ran \`id\` · 667B blocked` read as a command that ran and
662
- * produced 667 bytes, while the expanded header for the same node correctly said
663
- * `bash(id) blocked`. Blocked rows use that same call-signature form, and the
664
- * ledger byte count is suppressed because those bytes are the denial text, not
665
- * output.
693
+ * not claim it did. Blocked calls keep the ordinary operator-facing action
694
+ * description but use a neutral noun for commands; the status tail supplies the
695
+ * blocked outcome. The ledger byte count is suppressed elsewhere because those
696
+ * bytes are the denial text, not output.
666
697
  */
667
698
  function isNonExecutedOutcome(outcome: ToolExecutionFinished["outcome"]): boolean {
668
699
  return outcome === "blocked";
@@ -675,21 +706,29 @@ function buildSublineBody(
675
706
  result?: unknown,
676
707
  outcome?: ToolExecutionFinished["outcome"],
677
708
  ): string {
678
- if (isNonExecutedOutcome(outcome)) return buildUnknownToolBody(toolName, args);
679
- if (status === "forming" || status === "ready") return buildUnknownToolBody(toolName, args);
709
+ if (isNonExecutedOutcome(outcome)) {
710
+ if (toolName === "bash") {
711
+ return (
712
+ buildFieldSublineBody(args, "command", "command ", { wrapInBackticks: true }) ??
713
+ buildGenericToolBody(toolName, args)
714
+ );
715
+ }
716
+ return SUBLINE_BODY_BUILDERS[toolName]?.(args) ?? buildGenericToolBody(toolName, args);
717
+ }
680
718
  if (toolName === "web_fetch") {
681
719
  const meta = status === undefined ? null : webFetchMeta(result);
682
- return `${buildUnknownToolBody(toolName, args)}${meta ? dim(` · ${meta}`) : ""}`;
720
+ const body = SUBLINE_BODY_BUILDERS.web_fetch?.(args) ?? buildGenericToolBody(toolName, args);
721
+ return `${body}${meta ? dim(` · ${meta}`) : ""}`;
683
722
  }
684
723
  if (toolName === "bash") {
685
- const lead = status === "running" || status === undefined ? "running " : "ran ";
724
+ const lead = status === "ok" || status === "error" ? "ran " : "running ";
686
725
  return (
687
- buildFieldSublineBody(args, "command", lead, { wrapInBackticks: true }) ?? buildUnknownToolBody(toolName, args)
726
+ buildFieldSublineBody(args, "command", lead, { wrapInBackticks: true }) ?? buildGenericToolBody(toolName, args)
688
727
  );
689
728
  }
690
729
  const body = SUBLINE_BODY_BUILDERS[toolName]?.(args);
691
730
  if (body !== null && body !== undefined) return body;
692
- return buildUnknownToolBody(toolName, args);
731
+ return buildGenericToolBody(toolName, args);
693
732
  }
694
733
 
695
734
  interface SublineParts {
@@ -1116,10 +1155,40 @@ function sublineStatus(call: ToolExecutionStart | ToolExecutionFinished): Header
1116
1155
  * imports the keybindings manager directly; the caller resolves the key string
1117
1156
  * and passes it in to keep this module pure.
1118
1157
  */
1158
+ export interface ToolSublineRenderOptions {
1159
+ /** Live rows color the folded mutation diff; replay and export request plain rows. */
1160
+ diffStyle?: "color" | "plain";
1161
+ /**
1162
+ * Whether the folded row carries the extras its tool's presentation asks
1163
+ * for (today the bounded mutation diff). The bare subline level drops them;
1164
+ * the failure excerpt is not one of them and always rides the row.
1165
+ */
1166
+ foldedExtras?: "per-tool" | "none";
1167
+ }
1168
+
1169
+ /**
1170
+ * The bounded diff a folded mutation row keeps under itself, when the tool's
1171
+ * presentation asks for one and the result carries one. A folded `edit` that
1172
+ * hid its change told the operator nothing they could act on.
1173
+ */
1174
+ function foldedDiffRows(finished: ToolExecutionFinished, width: number, opts: ToolSublineRenderOptions): string[] {
1175
+ if (opts.foldedExtras === "none") return [];
1176
+ if (finished.isError || isNonExecutedOutcome(finished.outcome)) return [];
1177
+ if (!toolPresentationPolicy(finished.toolName, finished.args).showDiffWhenFolded) return [];
1178
+ const diff = resultDiff(finished.result);
1179
+ if (diff === null) return [];
1180
+ return truncateRowsMiddle(
1181
+ renderMutationDiffBlock(diff, width, opts.diffStyle !== "plain"),
1182
+ FOLDED_DIFF_ROW_LIMIT,
1183
+ false,
1184
+ );
1185
+ }
1186
+
1119
1187
  export function renderToolSubline(
1120
1188
  call: ToolExecutionStart | ToolExecutionFinished,
1121
1189
  width: number,
1122
1190
  expandKey?: string,
1191
+ opts: ToolSublineRenderOptions = {},
1123
1192
  ): string[] {
1124
1193
  const status = sublineStatus(call);
1125
1194
  const meta: StatusMeta =
@@ -1132,9 +1201,12 @@ export function renderToolSubline(
1132
1201
  }
1133
1202
  : { elapsedMs: call.elapsedMs };
1134
1203
  const parts = sublineParts(call, status, meta);
1204
+ const excerpt = "result" in call ? failureExcerpt(call, width) : "";
1135
1205
  const showHint = "result" in call && expandKey !== undefined && expandKey.length > 0;
1136
1206
  const tail = showHint ? `${parts.tail}${dim(` (${expandKey})`)}` : parts.tail;
1137
- return wrapSublineWithTail(parts.lead, tail, width);
1207
+ const lines = wrapSublineWithTail(`${parts.lead}${excerpt}`, tail, width);
1208
+ if ("result" in call) lines.push(...foldedDiffRows(call, width, opts));
1209
+ return lines;
1138
1210
  }
1139
1211
 
1140
1212
  /**
@@ -1294,6 +1366,13 @@ export interface BashTranscriptExecution {
1294
1366
  fullOutputPath?: string | undefined;
1295
1367
  excludeFromContext?: boolean | undefined;
1296
1368
  error?: string | undefined;
1369
+ /**
1370
+ * Whether the block draws its one-line row instead of the full body. The
1371
+ * caller resolves this from the transcript detail policy and the operator's
1372
+ * override, the same way the panel resolves a model bash call. Omitted means
1373
+ * folded.
1374
+ */
1375
+ folded?: boolean | undefined;
1297
1376
  }
1298
1377
 
1299
1378
  /**
@@ -1301,7 +1380,12 @@ export interface BashTranscriptExecution {
1301
1380
  * as model-initiated bash. This is a view projection only; callers keep Clio's
1302
1381
  * existing immutable `bashExecution` ledger entry.
1303
1382
  */
1304
- export function renderBashTranscriptExecution(execution: BashTranscriptExecution, width: number): string[] {
1383
+ export function renderBashTranscriptExecution(
1384
+ execution: BashTranscriptExecution,
1385
+ width: number,
1386
+ expandKey?: string,
1387
+ bodyOptions: ToolBodyRenderOptions = {},
1388
+ ): string[] {
1305
1389
  const shownBytes = Buffer.byteLength(execution.output, "utf8");
1306
1390
  const totalBytes = execution.totalBytes ?? shownBytes;
1307
1391
  const args: Record<string, unknown> = { command: execution.command };
@@ -1319,7 +1403,20 @@ export function renderBashTranscriptExecution(execution: BashTranscriptExecution
1319
1403
  content: [{ type: "text", text: execution.output }],
1320
1404
  details,
1321
1405
  };
1406
+ const folded = execution.folded !== false;
1322
1407
  if (execution.running) {
1408
+ if (folded) {
1409
+ return renderToolSubline(
1410
+ {
1411
+ toolCallId: "local-bash",
1412
+ toolName: "bash",
1413
+ args,
1414
+ elapsedMs: execution.elapsedMs,
1415
+ phase: "running",
1416
+ },
1417
+ width,
1418
+ );
1419
+ }
1323
1420
  return renderToolStreamingExecution(
1324
1421
  {
1325
1422
  toolCallId: "local-bash",
@@ -1336,22 +1433,20 @@ export function renderBashTranscriptExecution(execution: BashTranscriptExecution
1336
1433
  const output = message
1337
1434
  ? `${execution.output}${execution.output.length > 0 ? "\n\n" : ""}${message}`
1338
1435
  : execution.output;
1339
- return renderToolExecution(
1340
- {
1341
- toolCallId: "local-bash",
1342
- toolName: "bash",
1343
- args,
1344
- result: { ...result, content: [{ type: "text", text: output }] },
1345
- isError: execution.cancelled === true || execution.error !== undefined || (execution.exitCode ?? 0) !== 0,
1346
- exitCode: execution.exitCode,
1347
- ...(execution.cancelled === true ? { outcome: "aborted" as const } : {}),
1348
- excludeFromContext: execution.excludeFromContext,
1349
- resultSummary: {
1350
- bytes: shownBytes,
1351
- truncated: execution.truncated === true,
1352
- ...(execution.fullOutputPath !== undefined ? { offloadPath: execution.fullOutputPath } : {}),
1353
- },
1436
+ const finished: ToolExecutionFinished = {
1437
+ toolCallId: "local-bash",
1438
+ toolName: "bash",
1439
+ args,
1440
+ result: { ...result, content: [{ type: "text", text: output }] },
1441
+ isError: execution.cancelled === true || execution.error !== undefined || (execution.exitCode ?? 0) !== 0,
1442
+ exitCode: execution.exitCode,
1443
+ ...(execution.cancelled === true ? { outcome: "aborted" as const } : {}),
1444
+ excludeFromContext: execution.excludeFromContext,
1445
+ resultSummary: {
1446
+ bytes: shownBytes,
1447
+ truncated: execution.truncated === true,
1448
+ ...(execution.fullOutputPath !== undefined ? { offloadPath: execution.fullOutputPath } : {}),
1354
1449
  },
1355
- width,
1356
- );
1450
+ };
1451
+ return folded ? renderToolSubline(finished, width, expandKey) : renderToolExecution(finished, width, bodyOptions);
1357
1452
  }
@@ -1,13 +1,24 @@
1
1
  export type { StatusController, StatusControllerDeps } from "./controller.js";
2
2
  export { createStatusController } from "./controller.js";
3
+ export {
4
+ compactReasoningTokens,
5
+ formatReasoningChip,
6
+ formatReasoningLabel,
7
+ type ReasoningProvenance,
8
+ type ReasoningUsageView,
9
+ reasoningFromSummary,
10
+ reasoningFromTally,
11
+ UNMEASURED_REASONING,
12
+ } from "./reasoning.js";
3
13
  export { type ReduceContext, reduceStatus, type StatusInputEvent } from "./state-machine.js";
4
- export { type BuildSummaryInput, buildSummary } from "./summary.js";
14
+ export { type BuildSummaryInput, buildSummary, emptyRunTally, foldMessageIntoRunTally } from "./summary.js";
5
15
  export {
6
16
  type AgentStatus,
7
17
  type AgentStatusChangedPayload,
8
18
  type AgentStatusEvent,
9
19
  INITIAL_STATUS,
10
20
  type ReasoningTokenProvenance,
21
+ type RunTally,
11
22
  type StatusPhase,
12
23
  type TurnStopReason,
13
24
  type TurnSummary,