aiden-runtime 4.1.3 → 4.1.5

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.
@@ -18,13 +18,16 @@
18
18
  *
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.TOOL_ROW_ARG_CAP = exports.TOOL_ROW_NAME_PAD = exports.Display = exports.SPINNER_PHRASES = exports.TOOL_ICONS = void 0;
21
+ exports.TRAIL_HIDE_TOOLS = exports.TOOL_ROW_ARG_CAP = exports.TOOL_ROW_NAME_PAD = exports.Display = exports.SPINNER_PHRASES = exports.TOOL_ICONS = void 0;
22
22
  exports.iconForTool = iconForTool;
23
23
  exports.detectConfiguredChannels = detectConfiguredChannels;
24
24
  exports.summarizeConfiguredChannels = summarizeConfiguredChannels;
25
25
  exports.summarizeChannelState = summarizeChannelState;
26
26
  exports.voiceIndicator = voiceIndicator;
27
+ exports.makeNoOpToolRowHandle = makeNoOpToolRowHandle;
27
28
  exports.previewToolArgs = previewToolArgs;
29
+ exports.verbForActivity = verbForActivity;
30
+ exports.isPreFramedLine = isPreFramedLine;
28
31
  exports.countNewlines = countNewlines;
29
32
  exports.splitAtUnclosedBold = splitAtUnclosedBold;
30
33
  exports.formatToolDuration = formatToolDuration;
@@ -48,6 +51,12 @@ const replyRenderer_1 = require("./replyRenderer");
48
51
  // Optional "Sources" footer when AIDEN_CITATIONS=1 (default off).
49
52
  const citationFooter_1 = require("./citationFooter");
50
53
  const toolPreview_1 = require("./toolPreview");
54
+ // v4.1.4 reply-quality polish: shared frame math for width + indent.
55
+ // `cols()`, `rule()`, `agentTurn`, and `tryRerenderInPlace` all route
56
+ // through frame helpers so the visible left edge / right margin / wrap
57
+ // targets are consistent across streaming, rerender, and one-shot
58
+ // reply paths. See `cli/v4/display/frame.ts` for the math.
59
+ const frame_1 = require("./display/frame");
51
60
  /**
52
61
  * v4.1.3-repl-polish — category emoji icons for the tool-row trail.
53
62
  * Icons are ON by default (AIDEN_UI_ICONS !== '0'). Set
@@ -263,18 +272,25 @@ class Display {
263
272
  // ── Phase 23.6 — v3 visual primitives ──────────────────────────────────
264
273
  // Pure renderers (return strings, don't write) so chatSession can
265
274
  // compose the boot card and turn rhythm without owning ANSI escapes.
266
- /** Terminal column count clamped to 100 — matches v3 width discipline. */
275
+ /**
276
+ * Terminal column count. v4.1.4 reply-quality polish: delegates to
277
+ * `frame.getTerminalCols()` so all width math shares one formula.
278
+ * Retains the 100-col cap via `Math.min` so existing callers that
279
+ * paint full-width chrome (boot card, footer) keep their visual
280
+ * identity — `frame.BODY_WIDTH_MAX` is the tunable.
281
+ */
267
282
  cols() {
268
- return Math.min(this.out.columns ?? 80, 100);
283
+ return Math.min((0, frame_1.getTerminalCols)(this.out), 100);
269
284
  }
270
285
  /**
271
- * Thin horizontal rule (`──…──`) in muted colour, full visible width
272
- * minus the 2-column indent the boot card / turn render uses. Returns
273
- * the line WITHOUT a trailing newline; caller adds one + the leading
274
- * 2-space indent.
286
+ * Thin horizontal rule (`──…──`) in muted colour, full body width.
287
+ * v4.1.4 reply-quality polish: width sourced from `frame.getBodyWidth()`
288
+ * so the rule sits at the same right margin as wrapped prose and
289
+ * code blocks. Returns the line WITHOUT a trailing newline; caller
290
+ * adds one + the leading gutter.
275
291
  */
276
292
  rule(width) {
277
- const w = Math.max(8, (width ?? this.cols()) - 2);
293
+ const w = Math.max(8, width ?? (0, frame_1.getBodyWidth)(this.out));
278
294
  return this.skin.applyColors('─'.repeat(w), 'muted');
279
295
  }
280
296
  /** Render `▲` (brand-orange filled triangle) — Aiden's identity motif. */
@@ -725,6 +741,282 @@ class Display {
725
741
  },
726
742
  };
727
743
  }
744
+ /**
745
+ * v4.1.4 reply-quality polish — Part 1.6 activity indicator.
746
+ *
747
+ * Renders `▲ {verb}{dots} (Ns) ▸▸ Ctrl+C cancel` on a single
748
+ * line. `verb` is the activity label; the dots pulse 0→1→2→3→0
749
+ * every 400ms; elapsed time `(Ns)` appears only once N >= 1 (avoids
750
+ * the `(0s)` flash). The "▸▸ Ctrl+C cancel" hint is folded into the
751
+ * same line so cursor management stays simple (single-line write,
752
+ * single-line erase).
753
+ *
754
+ * Pause/resume semantics:
755
+ * - `pause()` erases the line + stops the tick + sets paused=true.
756
+ * Elapsed time keeps accumulating wall-clock — when a later
757
+ * `resume()` re-renders, the indicator shows the TOTAL elapsed
758
+ * since the original `activityIndicator()` call, not just since
759
+ * the last resume.
760
+ * - `resume(verb?)` re-renders on a fresh line below the current
761
+ * cursor and restarts the tick. Optional `verb` swap is the
762
+ * supported way to transition phases ("thinking" → "drafting").
763
+ * - `stop()` is terminal — erases the line, marks stopped, refuses
764
+ * further pause/resume.
765
+ *
766
+ * Non-TTY: completely silent. No initial paint, no ticks, no erases.
767
+ * Pipes / CI / MCP serve mode get clean output by default.
768
+ *
769
+ * Cursor invariant on render: the indicator OWNS one line. After
770
+ * each render the cursor sits at column 0 of the indicator line
771
+ * (NOT a new line below it) — that way the next render erases the
772
+ * line and rewrites in place. Callers that want to write OTHER
773
+ * content below MUST call `pause()` first; otherwise their content
774
+ * lands on the indicator line and the next tick clobbers it.
775
+ */
776
+ /**
777
+ * v4.1.5 Issue K — wave-bar option.
778
+ *
779
+ * When `opts.waveBar === true` (DEFAULT), the indicator paints a
780
+ * second row BELOW the verb line — a 10-cell `▰▱` snake-scroll
781
+ * heartbeat that gives visible motion during long pre-first-token
782
+ * gaps even when the verb doesn't change. The bar is NOT progress:
783
+ * it's a constant-cadence heartbeat (250ms shared with the dot
784
+ * pulse), explicitly not a percentage indicator.
785
+ *
786
+ * Pass `{ waveBar: false }` for back-compat with v4.1.4 tests that
787
+ * assert single-row geometry. Production callers (chatSession) get
788
+ * the wave bar by default.
789
+ */
790
+ activityIndicator(initialVerb = 'thinking', opts = {}) {
791
+ const sk = this.skin;
792
+ const out = this.out;
793
+ const isTty = !!out.isTTY;
794
+ const startTime = Date.now();
795
+ let verb = initialVerb;
796
+ let dotFrame = 0;
797
+ let paused = !isTty; // non-TTY = effectively pre-paused (silent)
798
+ let stopped = false;
799
+ let printed = false;
800
+ let tickTimer = null;
801
+ // Tunable cadence. v4.1.4 Phase 3b' (Issue G): bumped from 400ms
802
+ // to 250ms after visual smoke — 400ms felt sluggish, made the
803
+ // indicator look static between seconds. 250ms gives ~4 dot
804
+ // updates per second so motion is always visible even when the
805
+ // (Ns) counter hasn't ticked. Slow enough not to flicker on SSH
806
+ // / slow ConPTY refresh.
807
+ const TICK_MS = 250;
808
+ // ▲ glyph in brand orange — the user's primary motif. Dots and
809
+ // elapsed counter paint muted to keep visual weight on the verb.
810
+ //
811
+ // v4.1.4 Phase 3b' (Issue F): the inline "▸▸ Ctrl+C cancel" hint
812
+ // shipped with Phase 3a was visually noisy on the activity line
813
+ // and collided with planner-debug dim writes. Dropped per user
814
+ // feedback; a separate bottom-of-screen footer can be added in
815
+ // v4.1.5 if wanted, but it must NOT be glued to the indicator.
816
+ const glyph = sk.applyColors('▲', 'brand');
817
+ // v4.1.5 Issue K — wave-bar state. Snake-scroll: a 3-cell `▰`
818
+ // block slides across 10 cells, wrapping at the right edge. Same
819
+ // 250ms tick as the verb dot pulse — one timer drives both rows.
820
+ const waveBarEnabled = opts.waveBar !== false; // default true
821
+ const WAVE_CELLS = 10;
822
+ const WAVE_BLOCK = 3;
823
+ let waveFrame = 0;
824
+ const buildLine = () => {
825
+ const dots = '.'.repeat(dotFrame); // 0..3 dots
826
+ const elapsedSec = Math.floor((Date.now() - startTime) / 1000);
827
+ const elapsedStr = elapsedSec >= 1
828
+ ? ` ${sk.applyColors(`(${elapsedSec}s)`, 'muted')}`
829
+ : '';
830
+ // `▲ {verb}{dots-padded-to-3}{elapsed?}`
831
+ return `${glyph} ${verb}${dots.padEnd(3, ' ')}${elapsedStr}`;
832
+ };
833
+ /**
834
+ * v4.1.5 Issue K — render the wave-bar row. A 3-cell `▰` block at
835
+ * positions `[waveFrame, waveFrame+1, waveFrame+2]` mod 10. The
836
+ * filled cells paint brand orange, empty cells paint warm-muted.
837
+ * Same width + glyph set as the token progress bar so the two
838
+ * rows feel like a coherent palette (one is heartbeat, the other
839
+ * is real progress).
840
+ *
841
+ * Heartbeat semantics: this is NOT progress. The wave moves at a
842
+ * constant 250ms cadence regardless of any backend metric. It
843
+ * exists purely so the user sees motion during the unobservable
844
+ * TTFT (time-to-first-token) wait. The verb row above carries
845
+ * any real lifecycle signal via `setVerb()`.
846
+ */
847
+ const buildWave = () => {
848
+ // v4.1.5 Phase 1d (Q-P1) — glyph palette switch. Was `▰`/`▱`
849
+ // (U+25B0/B1, Geometric Shapes) which legacy Windows console
850
+ // fonts render as tofu. Now `▓`/`░` (U+2593/91, Block Elements
851
+ // — in CP437, universally supported). Matches the existing
852
+ // statusFooter chrome that's shipped since v3 without ever
853
+ // being garbled.
854
+ const filled = new Set();
855
+ for (let i = 0; i < WAVE_BLOCK; i += 1) {
856
+ filled.add((waveFrame + i) % WAVE_CELLS);
857
+ }
858
+ // Render cells in order so the snake-scroll visually slides:
859
+ // we paint cell-by-cell with the right color, joined into one
860
+ // string. ANSI runs reset per cell — slight overhead but keeps
861
+ // glyph order true to position. Brand orange filled, warm-muted
862
+ // empty.
863
+ const cells = [];
864
+ for (let c = 0; c < WAVE_CELLS; c += 1) {
865
+ cells.push(filled.has(c)
866
+ ? sk.applyColors('▓', 'brand')
867
+ : sk.applyColors('░', 'muted'));
868
+ }
869
+ return cells.join('');
870
+ };
871
+ // v4.1.5 Part 1a — Issue M (Windows ConPTY buffering fix).
872
+ //
873
+ // Prior pattern wrote `\r\x1b[K{indicator}` with NO trailing
874
+ // newline. On Windows ConPTY, `process.stdout` buffers no-newline
875
+ // writes — none of the 60 indicator ticks during a 15s gap
876
+ // actually rendered. The final reply's `\n` chars eventually
877
+ // flushed the buffer, but by then the indicator's stop()-erase
878
+ // had also been buffered + flushed, so the user saw 15s of blank
879
+ // followed by the reply dumping all at once.
880
+ //
881
+ // Fix: indicator OWNS one terminal row. Every write that paints
882
+ // the indicator ends with `\n`, which forces a flush on every
883
+ // platform. The cursor sits on the LINE BELOW the indicator
884
+ // while it's running (one visible empty row gap). When the
885
+ // indicator stops/pauses, we walk back UP to the indicator's
886
+ // row and erase it — the cursor then sits at col 0 of that
887
+ // (now empty) row, ready for the caller to write whatever
888
+ // content follows (header, tool row, stream output).
889
+ //
890
+ // ANSI primitives:
891
+ // `\x1b[1A` — cursor up 1 line
892
+ // `\x1b[2K` — erase the whole current line
893
+ // Sequence on tick: walk up → erase → paint → `\n` → cursor below.
894
+ // Sequence on erase: walk up → erase (no newline). Cursor on the
895
+ // now-empty indicator row, ready for caller.
896
+ const ANSI_UP_ERASE = '\x1b[1A\x1b[2K';
897
+ const renderTick = () => {
898
+ if (stopped || paused || !isTty)
899
+ return;
900
+ dotFrame = (dotFrame + 1) % 4;
901
+ // v4.1.5 Issue K — wave snake-scroll advances 1 cell per tick.
902
+ // Same 250ms cadence as the dot pulse, so both rows move in
903
+ // visible lockstep. Modulo WAVE_CELLS wraps the leading block
904
+ // back to the left edge.
905
+ waveFrame = (waveFrame + 1) % WAVE_CELLS;
906
+ if (waveBarEnabled) {
907
+ // 2-row layout: walk up TWO rows (two separate up-1+erase
908
+ // sequences, which keeps the `\x1b[1A\x1b[2K` substring
909
+ // assertion-compatible), repaint both, drop newlines so the
910
+ // cursor lands on the row below the wave bar.
911
+ out.write(`${ANSI_UP_ERASE}${ANSI_UP_ERASE}` +
912
+ `${buildLine()}\n` +
913
+ `${buildWave()}\n`);
914
+ }
915
+ else {
916
+ // Single-row layout (back-compat with v4.1.4 tests).
917
+ out.write(`${ANSI_UP_ERASE}${buildLine()}\n`);
918
+ }
919
+ };
920
+ const startTick = () => {
921
+ if (stopped || !isTty || tickTimer !== null)
922
+ return;
923
+ tickTimer = setInterval(renderTick, TICK_MS);
924
+ };
925
+ const stopTick = () => {
926
+ if (tickTimer !== null) {
927
+ clearInterval(tickTimer);
928
+ tickTimer = null;
929
+ }
930
+ };
931
+ const eraseLine = () => {
932
+ // Walk up to the indicator's row(s) + erase. Cursor lands at
933
+ // col 0 of the (now empty) verb row. NO trailing newline here:
934
+ // the caller is about to write content on this row, and
935
+ // whatever they write will include their own `\n` to flush
936
+ // the buffer. If we emitted `\n` here, we'd leave a phantom
937
+ // blank row before the caller's content.
938
+ //
939
+ // v4.1.5 Issue K — with wave bar enabled, walk up 2 rows (two
940
+ // up-1+erase sequences). Without the bar, walk up 1 row.
941
+ if (!isTty || !printed)
942
+ return;
943
+ if (waveBarEnabled) {
944
+ out.write(`${ANSI_UP_ERASE}${ANSI_UP_ERASE}`);
945
+ }
946
+ else {
947
+ out.write(ANSI_UP_ERASE);
948
+ }
949
+ };
950
+ // Initial paint — only on TTY. Indicator + `\n` so the buffer
951
+ // flushes and the cursor sits on the row below, ready for the
952
+ // first tick to walk back up.
953
+ //
954
+ // v4.1.5 Issue K — when wave bar is enabled, paint TWO rows:
955
+ // verb row + wave row, each with trailing `\n`. Cursor lands on
956
+ // the row below the wave bar. The first tick will walk up 2.
957
+ if (isTty) {
958
+ if (waveBarEnabled) {
959
+ out.write(`${buildLine()}\n${buildWave()}\n`);
960
+ }
961
+ else {
962
+ out.write(`${buildLine()}\n`);
963
+ }
964
+ printed = true;
965
+ startTick();
966
+ }
967
+ return {
968
+ pause: () => {
969
+ if (stopped || paused)
970
+ return;
971
+ paused = true;
972
+ stopTick();
973
+ eraseLine();
974
+ // After erase the cursor is at column 0 of the indicator's
975
+ // (now empty) line. Caller is expected to write its own
976
+ // content next; that content lands cleanly on this line.
977
+ },
978
+ resume: (newVerb) => {
979
+ if (stopped)
980
+ return;
981
+ if (typeof newVerb === 'string' && newVerb.length > 0)
982
+ verb = newVerb;
983
+ if (!paused)
984
+ return;
985
+ paused = false;
986
+ if (!isTty)
987
+ return;
988
+ // Caller has just finished writing its own content (typically
989
+ // ending with `\n`), so the cursor is on a fresh line below
990
+ // whatever was there. Paint the indicator + `\n` to claim the
991
+ // current row(s) and leave the cursor on the row below — same
992
+ // invariant the initial paint and tick maintain. Trailing `\n`
993
+ // also flushes Windows ConPTY buffering (Issue M).
994
+ //
995
+ // v4.1.5 Issue K — repaint BOTH rows when wave bar enabled.
996
+ if (waveBarEnabled) {
997
+ out.write(`${buildLine()}\n${buildWave()}\n`);
998
+ }
999
+ else {
1000
+ out.write(`${buildLine()}\n`);
1001
+ }
1002
+ printed = true;
1003
+ startTick();
1004
+ },
1005
+ setVerb: (newVerb) => {
1006
+ if (typeof newVerb === 'string' && newVerb.length > 0)
1007
+ verb = newVerb;
1008
+ },
1009
+ stop: () => {
1010
+ if (stopped)
1011
+ return;
1012
+ stopped = true;
1013
+ stopTick();
1014
+ eraseLine();
1015
+ },
1016
+ isPaused: () => paused,
1017
+ isStopped: () => stopped,
1018
+ };
1019
+ }
728
1020
  // ── Phase 23.5 — tool event row ───────────────────────────────────────
729
1021
  // One line per tool call: a "·" gutter, the keyword `tool`, the
730
1022
  // tool name (soft cyan, padded), a brief truncated arg preview, and
@@ -737,6 +1029,31 @@ class Display {
737
1029
  // completion so each line in the log carries the final state — no
738
1030
  // ANSI cursor games on a dumb sink.
739
1031
  toolRow(name, args) {
1032
+ // v4.1.5 Phase 1d (Q-Q2-a) — TRAIL_HIDE_TOOLS suppression.
1033
+ //
1034
+ // Some tools are pure agent plumbing — the model calls them to
1035
+ // introspect its own registry, not to do user-visible work.
1036
+ // `lookup_tool_schema` is the canonical case: during planning
1037
+ // the agent may invoke it 30+ times to discover unfamiliar tool
1038
+ // shapes. Each call is a sub-millisecond in-memory lookup, but
1039
+ // they flood the visible trail with noise that obscures the
1040
+ // actual user-relevant tool calls.
1041
+ //
1042
+ // Short-circuit: hidden tools get a NO-OP handle that satisfies
1043
+ // the `ToolRowHandle` contract (ok/fail/degraded/retry/blocked/
1044
+ // emptyRetry/emptyFail all defined but write nothing). The
1045
+ // execution path itself is unaffected — the agent still calls
1046
+ // the tool, the planner / skill-enforcement trackers still
1047
+ // record it. Only the visual row is suppressed.
1048
+ //
1049
+ // CRITICAL invariant: `setBeforeFirstToolHook` is fired by
1050
+ // callbacks.ts BEFORE `toolRow()` is called (see callbacks.ts
1051
+ // onToolCall 'before' branch), so `turnHadTools` flips even for
1052
+ // hidden tools. The separator logic stays correct regardless of
1053
+ // whether ONLY hidden tools fired this turn.
1054
+ if (exports.TRAIL_HIDE_TOOLS.has(name)) {
1055
+ return makeNoOpToolRowHandle();
1056
+ }
740
1057
  const sk = this.skin;
741
1058
  // ── Build the fixed left portion (icon + verb + detail) ────────────
742
1059
  // v4.1.3-repl-polish: icons default ON; set AIDEN_UI_ICONS=0 to
@@ -838,8 +1155,31 @@ class Display {
838
1155
  writeFinal(`ok ${formatToolDuration(durationMs)} after ${retries} ${retries === 1 ? 'retry' : 'retries'}`, 'warn');
839
1156
  }
840
1157
  else {
841
- // Clean successSILENT. Erase on TTY; emit nothing on non-TTY.
842
- eraseLast();
1158
+ // v4.1.5 Issue N persistent tool trail in scrollback.
1159
+ //
1160
+ // Prior behaviour: silent erase on clean success (`eraseLast()`
1161
+ // with no replacement write). Tool rows for successful tools
1162
+ // vanished, leaving only the markdown reply visible afterward.
1163
+ // The user couldn't see WHAT actions Aiden took unless a tool
1164
+ // failed or degraded.
1165
+ //
1166
+ // Fix: replace the silent erase with a completed-state row
1167
+ // painted entirely in warm-muted (`#b8a89a` from v4.1.4). The
1168
+ // duration suffix replaces the live `running Ns…` chrome; the
1169
+ // whole row reads "done" via reduced visual weight. Failed /
1170
+ // degraded / retry outcomes keep their existing coloured paint
1171
+ // (error red, degraded yellow, warn amber) — only clean success
1172
+ // shifts from "silent" to "muted-persistent."
1173
+ //
1174
+ // The persistence mechanism is the existing `writeFinal` path:
1175
+ // it walks up + erases the running row, then writes the final
1176
+ // row with trailing `\n`. The row sits in scrollback because
1177
+ // `streamComplete` rerenders only the post-tool stream chunk
1178
+ // (via `streamLineCount` which was reset to 0 inside
1179
+ // `commitStreamChunk` before this row wrote). No additional
1180
+ // isolation machinery needed — already verified by 13/13
1181
+ // `smoke-stream-rerender.ts` regressions.
1182
+ writeFinal(formatToolDuration(durationMs), 'muted');
843
1183
  }
844
1184
  },
845
1185
  fail(durationMs, retries = 0) {
@@ -948,15 +1288,57 @@ class Display {
948
1288
  const sk = this.skin;
949
1289
  const useMd = opts.markdown !== false;
950
1290
  const rawBody = useMd ? this.markdown(text).trimEnd() : text;
951
- const indented = rawBody
952
- .split('\n')
953
- .map((ln) => (ln ? ` ${ln}` : ''))
954
- .join('\n');
1291
+ // v4.1.4 reply-quality polish — F1 detect-and-skip indent + wrap.
1292
+ //
1293
+ // Walks the rendered markdown line-by-line, but applies frame
1294
+ // indent/wrap ONLY to plain prose lines. Lines that already carry
1295
+ // structural chrome (code-block rail+bg, blockquote rail,
1296
+ // pre-indented list bullets) pass through untouched — `renderCode-
1297
+ // Block`, `renderBlockquote`, and the list override already own
1298
+ // their own gutter + per-line wrap. Double-applying the gutter
1299
+ // shifts content right by 3 cols; double-wrapping breaks the rail
1300
+ // off the wrap-continuation row. See `isPreFramedLine` for the
1301
+ // detection rules.
1302
+ const indented = this.applyFrameToRendered(rawBody);
955
1303
  const reasoning = opts.reasoning
956
- ? ` ${sk.applyColors(opts.reasoning.trim(), 'muted')}\n`
1304
+ ? `${(0, frame_1.getIndent)(0)}${sk.applyColors(opts.reasoning.trim(), 'muted')}\n`
957
1305
  : '';
958
1306
  return `${this.agentHeader()}${reasoning}${indented}\n`;
959
1307
  }
1308
+ /**
1309
+ * v4.1.4 reply-quality polish — F1 shared helper.
1310
+ *
1311
+ * Apply frame indent + soft-wrap to the prose lines of a rendered
1312
+ * markdown body, but pass structural lines (code-block rail+bg,
1313
+ * blockquote rail, pre-indented list bullets) through unchanged.
1314
+ *
1315
+ * Shared by `agentTurn` (one-shot reply) and `tryRerenderInPlace`
1316
+ * (post-stream rerender) so both paths produce identical output.
1317
+ */
1318
+ applyFrameToRendered(rawBody) {
1319
+ const indent = (0, frame_1.getIndent)(0);
1320
+ const bw = (0, frame_1.getBodyWidth)(this.out);
1321
+ return rawBody
1322
+ .split('\n')
1323
+ .map((ln) => {
1324
+ if (ln.length === 0)
1325
+ return '';
1326
+ // F1 detect-and-skip: pre-framed lines (code-block chrome, list
1327
+ // bullets, blockquote rails) own their own gutter + wrap. Don't
1328
+ // re-indent or re-wrap them — that double-applies the gutter
1329
+ // and breaks the rail off wrap-continuation rows.
1330
+ if (isPreFramedLine(ln))
1331
+ return ln;
1332
+ // Plain prose: indent + wrap to bodyWidth. wrap-ansi handles
1333
+ // ANSI-aware width counting so bold/heading paint survives.
1334
+ const wrapped = (0, frame_1.wrap)(ln, bw, { trim: false, hard: true });
1335
+ return wrapped
1336
+ .split('\n')
1337
+ .map((vln) => `${indent}${vln}`)
1338
+ .join('\n');
1339
+ })
1340
+ .join('\n');
1341
+ }
960
1342
  /**
961
1343
  * Format a recoverable error with optional remediation suggestion.
962
1344
  * Output goes through the caller (returned as string), not stderr.
@@ -1075,6 +1457,33 @@ class Display {
1075
1457
  }
1076
1458
  this.out.write('\n');
1077
1459
  }
1460
+ /**
1461
+ * v4.1.4 reply-quality polish (Q-ResizeReflow Option B): zero the
1462
+ * per-chunk row counter when the terminal resizes mid-stream.
1463
+ *
1464
+ * Why: the resize guard hard-clears the viewport (`\x1b[2J\x1b[H`)
1465
+ * which removes ALL rows from the screen — but our `streamLineCount`
1466
+ * still believes those rows are there. The next `tryRerenderInPlace`
1467
+ * would walk the cursor back N rows that no longer exist, leaving a
1468
+ * ghost gap at the top of the new viewport. Zeroing the count makes
1469
+ * the next eraser a no-op (which is correct — there's nothing left
1470
+ * to erase).
1471
+ *
1472
+ * Idempotent: no-op when no stream is active. Safe to call from a
1473
+ * resize callback that fires unconditionally on every viewport
1474
+ * change. Also resets `streamBuffer` so the next commit doesn't try
1475
+ * to rerender content that was already wiped.
1476
+ */
1477
+ resetStreamFrameForResize() {
1478
+ if (!this.streamHeaderShown)
1479
+ return;
1480
+ this.streamLineCount = 0;
1481
+ this.streamBuffer = '';
1482
+ // Header was wiped by the hard-clear too — let the next
1483
+ // streamPartial / agentTurn write a fresh one.
1484
+ this.streamHeaderShown = false;
1485
+ this.streamLastEndedNewline = false;
1486
+ }
1078
1487
  /**
1079
1488
  * Append a streamed text fragment. Writes a styled "Aiden" header on
1080
1489
  * the first call of a turn, then writes raw text directly via the
@@ -1099,12 +1508,83 @@ class Display {
1099
1508
  this.out.write(text);
1100
1509
  this.streamLastEndedNewline = text.endsWith('\n');
1101
1510
  // Phase v4.1-reply-formatting: track buffer + line count for the
1102
- // post-stream re-render. We count newlines in the OUTGOING bytes
1103
- // so the eraser later knows how many rows to clear.
1511
+ // post-stream re-render.
1104
1512
  this.streamBuffer += text;
1105
- for (let i = 0; i < text.length; i += 1)
1106
- if (text[i] === '\n')
1107
- this.streamLineCount += 1;
1513
+ // v4.1.4 reply-quality polish F-B1 wrap-aware row count.
1514
+ //
1515
+ // Prior counter just `streamLineCount += text.match(/\n/g)?.length`
1516
+ // — counted `\n` chars only. When the model emits a long single
1517
+ // line (e.g. a 100-char bullet on an 80-col terminal), the terminal
1518
+ // naturally wraps it across multiple screen rows, but the old
1519
+ // counter would still think it's 1 row. At streamComplete the
1520
+ // eraser walked back N rows that didn't match the wrapped row
1521
+ // count → raw `**markup**` from the streaming phase remained
1522
+ // visible above the rerendered output.
1523
+ //
1524
+ // Confirmed undercount via scripts/smoke-stream-wrap-count.ts:
1525
+ // 3 long bullets on 80-col counted 3, actually 6. Multi-chunk
1526
+ // preamble + bullets on 40-col counted 4, actually 8.
1527
+ //
1528
+ // Fix: count `ceil(visibleWidth / cols)` rows per `\n`-delimited
1529
+ // segment, then add 1 for the `\n` itself (cursor advances to
1530
+ // next row when newline is emitted). Visible width strips ANSI.
1531
+ this.streamLineCount += this.countStreamRows(text);
1532
+ }
1533
+ /**
1534
+ * v4.1.4 reply-quality polish — F-B1 helper.
1535
+ *
1536
+ * Estimate how many screen rows `text` consumes when written to a
1537
+ * terminal of width `this.out.columns`. Counts terminal-natural-wrap
1538
+ * rows for each logical line, plus one row per `\n`.
1539
+ *
1540
+ * Falls back to a sane count when columns is undefined (non-TTY or
1541
+ * pre-resize): in that case the eraser won't fire anyway
1542
+ * (`tryRerenderInPlace` gates on `out.isTTY`), so the count is
1543
+ * effectively ignored. We still compute a defensible value so any
1544
+ * future TTY-detection change doesn't silently regress.
1545
+ *
1546
+ * Pure with respect to ANSI: escape sequences pass through
1547
+ * `visibleLength` and don't inflate the row count.
1548
+ *
1549
+ * Edge cases:
1550
+ * - Empty text → 0 rows (consistent with the prior counter).
1551
+ * - Text without `\n` → ceil(visibleLen / cols) rows.
1552
+ * - Trailing `\n` → counts the prior content row + 1 for the
1553
+ * newline. Cursor is now at the start of the next row, which is
1554
+ * correct screen-state — the next streamPartial extends from
1555
+ * col 0 of that row.
1556
+ */
1557
+ countStreamRows(text) {
1558
+ if (text.length === 0)
1559
+ return 0;
1560
+ const cols = (typeof this.out.columns === 'number' && this.out.columns >= 1)
1561
+ ? this.out.columns
1562
+ : 80;
1563
+ // Semantics: counter tracks ROW BOUNDARIES CROSSED during
1564
+ // emission, not "rows occupied". The eraser uses `\x1b[<N>F`
1565
+ // which moves the cursor up N rows; if N matches the number of
1566
+ // boundaries crossed from start-of-stream to current-cursor, the
1567
+ // eraser lands at the start row and `\x1b[J` clears the rest.
1568
+ //
1569
+ // For a segment of visible width V on a terminal of width C:
1570
+ // - V == 0 → 0 wrap boundaries
1571
+ // - V <= C → 0 wrap boundaries (single row)
1572
+ // - C < V <= 2C → 1 wrap boundary
1573
+ // - General → floor((V - 1) / C) wrap boundaries
1574
+ //
1575
+ // Each `\n` between segments crosses one boundary regardless of
1576
+ // visible width — that's the newline advancing the cursor.
1577
+ let rows = 0;
1578
+ const segments = text.split('\n');
1579
+ for (let i = 0; i < segments.length; i += 1) {
1580
+ const seg = segments[i] ?? '';
1581
+ const visible = (0, box_1.visibleLength)(seg);
1582
+ if (visible > 0)
1583
+ rows += Math.floor((visible - 1) / cols);
1584
+ if (i < segments.length - 1)
1585
+ rows += 1;
1586
+ }
1587
+ return rows;
1108
1588
  }
1109
1589
  /**
1110
1590
  * v4.1.3-essentials: rerender a buffered stream chunk in place. Walks
@@ -1161,10 +1641,13 @@ class Display {
1161
1641
  // \x1b[J = erase from cursor to end of screen.
1162
1642
  this.out.write(`\x1b[${lines}F\x1b[J`);
1163
1643
  const formatted = this.markdown(buffered).trimEnd();
1164
- const indented = formatted
1165
- .split('\n')
1166
- .map((ln) => (ln ? ` ${ln}` : ''))
1167
- .join('\n');
1644
+ // v4.1.4 reply-quality polish: same detect-and-skip indent + wrap
1645
+ // as agentTurn so streamed and one-shot replies share the visible
1646
+ // frame. wrap-ansi handles ANSI-aware width counting for prose;
1647
+ // structural lines (code-block chrome, list bullets, blockquote
1648
+ // rails) pass through unchanged so their own gutter + wrap stays
1649
+ // intact.
1650
+ const indented = this.applyFrameToRendered(formatted);
1168
1651
  this.out.write(indented + '\n');
1169
1652
  }
1170
1653
  catch {
@@ -1398,6 +1881,56 @@ function renderRmsBar(rms) {
1398
1881
  exports.TOOL_ROW_NAME_PAD = toolTrail_1.TRAIL_VERB_PAD;
1399
1882
  /** @deprecated Use TRAIL_DETAIL_CAP. */
1400
1883
  exports.TOOL_ROW_ARG_CAP = toolTrail_1.TRAIL_DETAIL_CAP;
1884
+ /**
1885
+ * v4.1.5 Phase 1d (Q-Q2-a) — names of tools that should be SUPPRESSED
1886
+ * from the visible tool-trail row, even though they still execute
1887
+ * normally through the agent loop.
1888
+ *
1889
+ * The canonical case is `lookup_tool_schema`: the agent calls it
1890
+ * during planning to introspect tool registry entries (in-memory
1891
+ * registry get, sub-millisecond per call). On complex prompts the
1892
+ * model may fire it 30+ times in a row, flooding the visible trail
1893
+ * with rows that don't represent user-meaningful work. Suppressing
1894
+ * them keeps the trail focused on the tools that did real work
1895
+ * (web_search, file_read, etc.).
1896
+ *
1897
+ * Suppression happens at `Display.toolRow()` entry — it returns a
1898
+ * no-op handle that satisfies the `ToolRowHandle` contract but
1899
+ * never writes to stdout. The agent's `callbacks.onToolCall`
1900
+ * dispatch is unchanged: `setBeforeFirstToolHook` still fires (so
1901
+ * `turnHadTools` flips for the separator-emission logic), and
1902
+ * skill-enforcement / honesty-trace tracking still records the
1903
+ * call. Only the visual row is hidden.
1904
+ *
1905
+ * Exported as a `Set` so callers can mutate at runtime if they
1906
+ * need to hide additional tools (e.g. user customization, MCP
1907
+ * plumbing tools). Mutation-of-shared-state is intentional — there's
1908
+ * no per-session config plumbing for "trail hidden tools" yet, so
1909
+ * the env-var pattern (`AIDEN_TRAIL_HIDE=tool1,tool2`) would be the
1910
+ * v4.1.6 evolution.
1911
+ */
1912
+ exports.TRAIL_HIDE_TOOLS = new Set([
1913
+ 'lookup_tool_schema',
1914
+ ]);
1915
+ /**
1916
+ * v4.1.5 Phase 1d helper — produces a `ToolRowHandle` that satisfies
1917
+ * the contract but writes nothing. Used by hidden tools (see
1918
+ * `TRAIL_HIDE_TOOLS`) and as a safe fallback. All methods are inert.
1919
+ *
1920
+ * Pure — no side effects, no closures over Display state. Safe to
1921
+ * call from any thread / phase.
1922
+ */
1923
+ function makeNoOpToolRowHandle() {
1924
+ return {
1925
+ ok: () => { },
1926
+ fail: () => { },
1927
+ degraded: () => { },
1928
+ retry: () => { },
1929
+ blocked: () => { },
1930
+ emptyRetry: () => { },
1931
+ emptyFail: () => { },
1932
+ };
1933
+ }
1401
1934
  /**
1402
1935
  * Build a compact, single-line preview of the tool's arguments. Picks
1403
1936
  * the most informative scalar fields when the args are an object, then
@@ -1450,6 +1983,112 @@ function truncToolArg(s) {
1450
1983
  return flat;
1451
1984
  return flat.slice(0, exports.TOOL_ROW_ARG_CAP - 1) + '…';
1452
1985
  }
1986
+ /**
1987
+ * v4.1.4 reply-quality polish — Part 1.6 tool-aware verb mapper.
1988
+ *
1989
+ * Picks the activity-indicator verb for the gap that follows a given
1990
+ * tool's completion. The verb reflects "what the model is likely doing
1991
+ * next" rather than "what just happened" — so a `file_read` completing
1992
+ * leads to "reading" (model is digesting the contents) rather than
1993
+ * "drafted" (which would imply done). Tested in display.test.ts.
1994
+ *
1995
+ * Categories (matches against the tool-name substring, lowercased):
1996
+ * - read/list/view/get/inspect → 'reading'
1997
+ * - search/web/fetch_url/scrape → 'searching'
1998
+ * - shell/exec/run/compute/system → 'analyzing'
1999
+ * - write/edit/patch/save → 'drafting'
2000
+ * - everything else (or undefined) → 'thinking'
2001
+ *
2002
+ * Special caller-supplied phase override:
2003
+ * - When the caller knows "all tools are done, reply about to start"
2004
+ * they pass `phase: 'post-all'` → verb defaults to 'drafting'
2005
+ * regardless of the last tool name.
2006
+ *
2007
+ * Pure; exported for unit-test access.
2008
+ */
2009
+ function verbForActivity(toolName, phase = 'post-tool') {
2010
+ if (phase === 'pre-tools')
2011
+ return 'thinking';
2012
+ if (phase === 'post-all')
2013
+ return 'drafting';
2014
+ const t = (toolName ?? '').toLowerCase();
2015
+ if (t.length === 0)
2016
+ return 'thinking';
2017
+ // Match in priority order so 'web_search' hits 'searching' (search)
2018
+ // before 'reading' (a hypothetical 'web_search_read' would still
2019
+ // map to 'searching' since search hits first).
2020
+ if (/(^|_)(search|web|fetch_url|scrape|crawl)(_|$)/.test(t))
2021
+ return 'searching';
2022
+ if (/(^|_)(read|list|view|get|inspect|info|status)(_|$)/.test(t))
2023
+ return 'reading';
2024
+ if (/(^|_)(write|edit|patch|save|create|append|delete|remove)(_|$)/.test(t))
2025
+ return 'drafting';
2026
+ if (/(^|_)(shell|exec|execute|run|compute|process|system|launch)(_|$)/.test(t))
2027
+ return 'analyzing';
2028
+ return 'thinking';
2029
+ }
2030
+ /**
2031
+ * v4.1.4 reply-quality polish — F1 detect-and-skip predicate.
2032
+ *
2033
+ * Returns true when `line` is a structural / pre-framed line emitted
2034
+ * by replyRenderer (code-block chrome, blockquote rail, indented list
2035
+ * bullet, fence rules). These lines OWN their own gutter and per-line
2036
+ * wrap; `agentTurn` and `tryRerenderInPlace` MUST pass them through
2037
+ * unchanged so the post-render indent+wrap pass doesn't:
2038
+ * - Double the gutter (content drifts 3 cols right per pass)
2039
+ * - Re-wrap an already-wrapped code line (rail/bg breaks across
2040
+ * the new wrap continuation row)
2041
+ *
2042
+ * Detection rules (all on the ANSI-bearing line as emitted by marked
2043
+ * via our renderer overrides):
2044
+ * - Contains `\x1b[48;` anywhere → 24-bit bg paint = code-block
2045
+ * line. Always pre-framed (renderCodeBlock applies gutter + rail).
2046
+ * - Starts with ` │ ` or ` ┃ ` → explicit pre-framed rail
2047
+ * (code or blockquote at the frame gutter).
2048
+ * - Matches `^\s{2,}(•|▸|\d+\.)\s` (depth-indented list bullet) →
2049
+ * the list override already applied the per-depth indent.
2050
+ * - Matches `^\s{0,4}─{8,}` (horizontal-rule run or fence) → render-
2051
+ * specific divider already styled.
2052
+ *
2053
+ * Pure; exported for unit-test access.
2054
+ */
2055
+ function isPreFramedLine(line) {
2056
+ if (line.length === 0)
2057
+ return false;
2058
+ // eslint-disable-next-line no-control-regex
2059
+ const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
2060
+ // v4.1.4 reply-quality polish — Fix D (tightened predicate).
2061
+ //
2062
+ // Code-block body lines start with the frame gutter + rail. The
2063
+ // 24-bit bg paint (`\x1b[48;…`) also appears on these lines, but
2064
+ // we MUST NOT use bg-presence alone as the trigger: the `codespan`
2065
+ // renderer wraps inline `` `code` `` with the same bg envelope, so
2066
+ // any prose line containing inline code would be wrongly classified
2067
+ // as a code-block line and would bypass the indent + wrap pass
2068
+ // (Issue D from visual smoke — prose with inline codespans
2069
+ // terminal-natural-wrapped past bodyWidth).
2070
+ //
2071
+ // Rail prefix (after ANSI strip) is the reliable signal: only
2072
+ // `renderCodeBlock` emits ` │ ` and only `renderBlockquote` emits
2073
+ // `┃ ` at line start (with display-layer gutter prepended).
2074
+ if (/^ │ /.test(stripped))
2075
+ return true;
2076
+ if (/^ ┃ /.test(stripped) || /^┃ /.test(stripped))
2077
+ return true;
2078
+ // Depth-indented list bullets emitted by the renderer.list override:
2079
+ // ` • prose…` (depth 1, 2-space indent)
2080
+ // ` ▸ prose…` (depth 2, 4-space indent)
2081
+ // ` 1. prose…` (numbered, depth 1)
2082
+ if (/^\s{2,}(•|▸|\d+\.)\s/.test(stripped))
2083
+ return true;
2084
+ // Code-block fence rules (long runs of `─` with optional leading
2085
+ // gutter + optional language label from renderCodeBlock). Match
2086
+ // ANYWHERE in the line so the language-tagged top rule
2087
+ // (` ── lang ──────…──`) trips alongside the unlabeled bottom rule.
2088
+ if (/─{8,}/.test(stripped))
2089
+ return true;
2090
+ return false;
2091
+ }
1453
2092
  /**
1454
2093
  * v4.1.3-essentials boldwrap-fix: count `\n` occurrences in `s`.
1455
2094
  * Used by `commitStreamChunk` to recompute `streamLineCount` after