aiden-runtime 4.1.3 → 4.1.4

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.
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * cli/v4/display/progressBar.ts — Phase v4.1.4 Part 1.6.
10
+ *
11
+ * Per-turn token progress bar. Renders `▰▰▰▰▰▱▱▱▱▱ 412/4096 tokens`
12
+ * on a single line below the activity indicator (or in place of it,
13
+ * once the stream takes over). Event-driven: each `update(n, max)`
14
+ * call redraws the bar with the new counter — no `setInterval`,
15
+ * because the source of truth is the adapter's incremental
16
+ * `progress` stream events, which already fire at the granularity
17
+ * the model produces tokens.
18
+ *
19
+ * Honest degradation: if the adapter never calls `update()`, the bar
20
+ * never paints. No client-side estimation (per v4.1.4 spec — token
21
+ * count only, no time-based fakery).
22
+ *
23
+ * Visual:
24
+ *
25
+ * ▰▰▰▰▰▱▱▱▱▱ 412/4096 tokens
26
+ * │└────┬───┘ └────┬─────┘
27
+ * │ │ └── current/max (compact via formatCompactTokens)
28
+ * │ └── 10 cells, filled ratio ∝ outputTokens/maxTokens
29
+ * └── leading gutter aligns with frame
30
+ *
31
+ * Bar cells:
32
+ * - filled: ▰ (U+25B0, dark shade block-fill)
33
+ * - empty: ▱ (U+25B1, light shade)
34
+ *
35
+ * Cursor invariant on render: bar OWNS one line. After each `update`
36
+ * the cursor sits at column 0 of the bar line (single-line `\r\x1b[K`
37
+ * overwrite pattern, same as activityIndicator). Callers that want to
38
+ * write OTHER content below MUST call `hide()` first.
39
+ *
40
+ * Non-TTY: completely silent — pipes/CI/MCP serve mode get clean
41
+ * output by default. The handle still accepts updates so callers
42
+ * don't need to branch on TTY-ness.
43
+ */
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.createProgressBar = createProgressBar;
46
+ const frame_1 = require("./frame");
47
+ const display_1 = require("../display");
48
+ /**
49
+ * Number of bar cells. 10 cells gives clean fractions (every 10% of
50
+ * fill ratio == one cell). Wider bars feel noisy at the standard
51
+ * frame width (75 visible cols on an 80-col terminal).
52
+ */
53
+ const BAR_CELLS = 10;
54
+ /** Glyphs. Chosen for clean visual weight at the standard mono font. */
55
+ const FILLED = '▰';
56
+ const EMPTY = '▱';
57
+ /**
58
+ * Create a progress-bar handle bound to a writable stream + skin.
59
+ * The bar paints on the next `update` call — there's no initial
60
+ * paint at creation time, because we don't know the token counts yet.
61
+ *
62
+ * `out` is the stream to write on (usually `process.stdout` via
63
+ * `Display.out`). `skin` is the active skin engine. Both are captured
64
+ * by closure — the handle survives skin swaps for the rest of the turn.
65
+ */
66
+ function createProgressBar(out, skin) {
67
+ const isTty = !!out.isTTY;
68
+ let outputTokens = 0;
69
+ let maxTokens = undefined;
70
+ let printed = false;
71
+ let hidden = false;
72
+ let lastPaintTokens = -1;
73
+ const buildLine = () => {
74
+ // Fill ratio: 0..1, then snap to a cell count 0..BAR_CELLS.
75
+ const denom = maxTokens && maxTokens > 0 ? maxTokens : 0;
76
+ const ratio = denom > 0 ? Math.min(1, outputTokens / denom) : 0;
77
+ const filled = Math.min(BAR_CELLS, Math.round(ratio * BAR_CELLS));
78
+ const empty = BAR_CELLS - filled;
79
+ const bar = skin.applyColors(FILLED.repeat(filled), 'brand') +
80
+ skin.applyColors(EMPTY.repeat(empty), 'muted');
81
+ // Label: compact "412/4096 tokens". If no maxTokens, render
82
+ // "412 tokens" (denominator unknown). The model name doesn't
83
+ // belong here — that's the status footer's job post-turn.
84
+ const left = (0, display_1.formatCompactTokens)(outputTokens);
85
+ const right = denom > 0 ? (0, display_1.formatCompactTokens)(denom) : '?';
86
+ const label = denom > 0
87
+ ? skin.applyColors(`${left}/${right} tokens`, 'muted')
88
+ : skin.applyColors(`${left} tokens`, 'muted');
89
+ const gutter = (0, frame_1.getIndent)(0);
90
+ return `${gutter}${bar} ${label}`;
91
+ };
92
+ const paint = () => {
93
+ if (!isTty || hidden)
94
+ return;
95
+ // `\r\x1b[K` — carriage return + erase to end of line, then
96
+ // rewrite. Same single-line overwrite pattern as the activity
97
+ // indicator and tool-row live tick.
98
+ out.write(`\r\x1b[K${buildLine()}`);
99
+ printed = true;
100
+ lastPaintTokens = outputTokens;
101
+ };
102
+ const erase = () => {
103
+ if (isTty && printed)
104
+ out.write('\r\x1b[K');
105
+ };
106
+ return {
107
+ update(n, max) {
108
+ if (hidden)
109
+ return;
110
+ // Coerce + clamp. Non-finite or negative inputs are ignored —
111
+ // never crash the stream consumer with a malformed event.
112
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0) {
113
+ outputTokens = Math.floor(n);
114
+ }
115
+ if (typeof max === 'number' && Number.isFinite(max) && max > 0) {
116
+ maxTokens = Math.floor(max);
117
+ }
118
+ // Dedup: skip the repaint if the visible state didn't change.
119
+ // Anthropic emits message_delta events with the SAME running
120
+ // counter when no new tokens were produced; without this
121
+ // gate we'd flicker on every duplicate.
122
+ if (outputTokens === lastPaintTokens && printed)
123
+ return;
124
+ paint();
125
+ },
126
+ hide() {
127
+ if (hidden)
128
+ return;
129
+ hidden = true;
130
+ erase();
131
+ },
132
+ isHidden() { return hidden; },
133
+ getTokens() {
134
+ return { output: outputTokens, max: maxTokens };
135
+ },
136
+ };
137
+ }
@@ -25,6 +25,8 @@ exports.summarizeConfiguredChannels = summarizeConfiguredChannels;
25
25
  exports.summarizeChannelState = summarizeChannelState;
26
26
  exports.voiceIndicator = voiceIndicator;
27
27
  exports.previewToolArgs = previewToolArgs;
28
+ exports.verbForActivity = verbForActivity;
29
+ exports.isPreFramedLine = isPreFramedLine;
28
30
  exports.countNewlines = countNewlines;
29
31
  exports.splitAtUnclosedBold = splitAtUnclosedBold;
30
32
  exports.formatToolDuration = formatToolDuration;
@@ -48,6 +50,12 @@ const replyRenderer_1 = require("./replyRenderer");
48
50
  // Optional "Sources" footer when AIDEN_CITATIONS=1 (default off).
49
51
  const citationFooter_1 = require("./citationFooter");
50
52
  const toolPreview_1 = require("./toolPreview");
53
+ // v4.1.4 reply-quality polish: shared frame math for width + indent.
54
+ // `cols()`, `rule()`, `agentTurn`, and `tryRerenderInPlace` all route
55
+ // through frame helpers so the visible left edge / right margin / wrap
56
+ // targets are consistent across streaming, rerender, and one-shot
57
+ // reply paths. See `cli/v4/display/frame.ts` for the math.
58
+ const frame_1 = require("./display/frame");
51
59
  /**
52
60
  * v4.1.3-repl-polish — category emoji icons for the tool-row trail.
53
61
  * Icons are ON by default (AIDEN_UI_ICONS !== '0'). Set
@@ -263,18 +271,25 @@ class Display {
263
271
  // ── Phase 23.6 — v3 visual primitives ──────────────────────────────────
264
272
  // Pure renderers (return strings, don't write) so chatSession can
265
273
  // compose the boot card and turn rhythm without owning ANSI escapes.
266
- /** Terminal column count clamped to 100 — matches v3 width discipline. */
274
+ /**
275
+ * Terminal column count. v4.1.4 reply-quality polish: delegates to
276
+ * `frame.getTerminalCols()` so all width math shares one formula.
277
+ * Retains the 100-col cap via `Math.min` so existing callers that
278
+ * paint full-width chrome (boot card, footer) keep their visual
279
+ * identity — `frame.BODY_WIDTH_MAX` is the tunable.
280
+ */
267
281
  cols() {
268
- return Math.min(this.out.columns ?? 80, 100);
282
+ return Math.min((0, frame_1.getTerminalCols)(this.out), 100);
269
283
  }
270
284
  /**
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.
285
+ * Thin horizontal rule (`──…──`) in muted colour, full body width.
286
+ * v4.1.4 reply-quality polish: width sourced from `frame.getBodyWidth()`
287
+ * so the rule sits at the same right margin as wrapped prose and
288
+ * code blocks. Returns the line WITHOUT a trailing newline; caller
289
+ * adds one + the leading gutter.
275
290
  */
276
291
  rule(width) {
277
- const w = Math.max(8, (width ?? this.cols()) - 2);
292
+ const w = Math.max(8, width ?? (0, frame_1.getBodyWidth)(this.out));
278
293
  return this.skin.applyColors('─'.repeat(w), 'muted');
279
294
  }
280
295
  /** Render `▲` (brand-orange filled triangle) — Aiden's identity motif. */
@@ -725,6 +740,148 @@ class Display {
725
740
  },
726
741
  };
727
742
  }
743
+ /**
744
+ * v4.1.4 reply-quality polish — Part 1.6 activity indicator.
745
+ *
746
+ * Renders `▲ {verb}{dots} (Ns) ▸▸ Ctrl+C cancel` on a single
747
+ * line. `verb` is the activity label; the dots pulse 0→1→2→3→0
748
+ * every 400ms; elapsed time `(Ns)` appears only once N >= 1 (avoids
749
+ * the `(0s)` flash). The "▸▸ Ctrl+C cancel" hint is folded into the
750
+ * same line so cursor management stays simple (single-line write,
751
+ * single-line erase).
752
+ *
753
+ * Pause/resume semantics:
754
+ * - `pause()` erases the line + stops the tick + sets paused=true.
755
+ * Elapsed time keeps accumulating wall-clock — when a later
756
+ * `resume()` re-renders, the indicator shows the TOTAL elapsed
757
+ * since the original `activityIndicator()` call, not just since
758
+ * the last resume.
759
+ * - `resume(verb?)` re-renders on a fresh line below the current
760
+ * cursor and restarts the tick. Optional `verb` swap is the
761
+ * supported way to transition phases ("thinking" → "drafting").
762
+ * - `stop()` is terminal — erases the line, marks stopped, refuses
763
+ * further pause/resume.
764
+ *
765
+ * Non-TTY: completely silent. No initial paint, no ticks, no erases.
766
+ * Pipes / CI / MCP serve mode get clean output by default.
767
+ *
768
+ * Cursor invariant on render: the indicator OWNS one line. After
769
+ * each render the cursor sits at column 0 of the indicator line
770
+ * (NOT a new line below it) — that way the next render erases the
771
+ * line and rewrites in place. Callers that want to write OTHER
772
+ * content below MUST call `pause()` first; otherwise their content
773
+ * lands on the indicator line and the next tick clobbers it.
774
+ */
775
+ activityIndicator(initialVerb = 'thinking') {
776
+ const sk = this.skin;
777
+ const out = this.out;
778
+ const isTty = !!out.isTTY;
779
+ const startTime = Date.now();
780
+ let verb = initialVerb;
781
+ let dotFrame = 0;
782
+ let paused = !isTty; // non-TTY = effectively pre-paused (silent)
783
+ let stopped = false;
784
+ let printed = false;
785
+ let tickTimer = null;
786
+ // Tunable cadence. v4.1.4 Phase 3b' (Issue G): bumped from 400ms
787
+ // to 250ms after visual smoke — 400ms felt sluggish, made the
788
+ // indicator look static between seconds. 250ms gives ~4 dot
789
+ // updates per second so motion is always visible even when the
790
+ // (Ns) counter hasn't ticked. Slow enough not to flicker on SSH
791
+ // / slow ConPTY refresh.
792
+ const TICK_MS = 250;
793
+ // ▲ glyph in brand orange — the user's primary motif. Dots and
794
+ // elapsed counter paint muted to keep visual weight on the verb.
795
+ //
796
+ // v4.1.4 Phase 3b' (Issue F): the inline "▸▸ Ctrl+C cancel" hint
797
+ // shipped with Phase 3a was visually noisy on the activity line
798
+ // and collided with planner-debug dim writes. Dropped per user
799
+ // feedback; a separate bottom-of-screen footer can be added in
800
+ // v4.1.5 if wanted, but it must NOT be glued to the indicator.
801
+ const glyph = sk.applyColors('▲', 'brand');
802
+ const buildLine = () => {
803
+ const dots = '.'.repeat(dotFrame); // 0..3 dots
804
+ const elapsedSec = Math.floor((Date.now() - startTime) / 1000);
805
+ const elapsedStr = elapsedSec >= 1
806
+ ? ` ${sk.applyColors(`(${elapsedSec}s)`, 'muted')}`
807
+ : '';
808
+ // `▲ {verb}{dots-padded-to-3}{elapsed?}`
809
+ return `${glyph} ${verb}${dots.padEnd(3, ' ')}${elapsedStr}`;
810
+ };
811
+ const renderTick = () => {
812
+ if (stopped || paused || !isTty)
813
+ return;
814
+ dotFrame = (dotFrame + 1) % 4;
815
+ // `\r\x1b[K` — carriage return + clear line — then write the
816
+ // fresh indicator. No newline at end: cursor stays at end of
817
+ // the indicator line, ready for the next overwrite.
818
+ out.write(`\r\x1b[K${buildLine()}`);
819
+ };
820
+ const startTick = () => {
821
+ if (stopped || !isTty || tickTimer !== null)
822
+ return;
823
+ tickTimer = setInterval(renderTick, TICK_MS);
824
+ };
825
+ const stopTick = () => {
826
+ if (tickTimer !== null) {
827
+ clearInterval(tickTimer);
828
+ tickTimer = null;
829
+ }
830
+ };
831
+ const eraseLine = () => {
832
+ if (isTty && printed)
833
+ out.write('\r\x1b[K');
834
+ };
835
+ // Initial paint — only on TTY.
836
+ if (isTty) {
837
+ out.write(buildLine());
838
+ printed = true;
839
+ startTick();
840
+ }
841
+ return {
842
+ pause: () => {
843
+ if (stopped || paused)
844
+ return;
845
+ paused = true;
846
+ stopTick();
847
+ eraseLine();
848
+ // After erase the cursor is at column 0 of the indicator's
849
+ // (now empty) line. Caller is expected to write its own
850
+ // content next; that content lands cleanly on this line.
851
+ },
852
+ resume: (newVerb) => {
853
+ if (stopped)
854
+ return;
855
+ if (typeof newVerb === 'string' && newVerb.length > 0)
856
+ verb = newVerb;
857
+ if (!paused)
858
+ return;
859
+ paused = false;
860
+ if (!isTty)
861
+ return;
862
+ // Caller has just finished writing its own content (typically
863
+ // ending with `\n`), so the cursor is on a fresh line below
864
+ // whatever was there. Render the indicator there and arm the
865
+ // tick again.
866
+ out.write(buildLine());
867
+ printed = true;
868
+ startTick();
869
+ },
870
+ setVerb: (newVerb) => {
871
+ if (typeof newVerb === 'string' && newVerb.length > 0)
872
+ verb = newVerb;
873
+ },
874
+ stop: () => {
875
+ if (stopped)
876
+ return;
877
+ stopped = true;
878
+ stopTick();
879
+ eraseLine();
880
+ },
881
+ isPaused: () => paused,
882
+ isStopped: () => stopped,
883
+ };
884
+ }
728
885
  // ── Phase 23.5 — tool event row ───────────────────────────────────────
729
886
  // One line per tool call: a "·" gutter, the keyword `tool`, the
730
887
  // tool name (soft cyan, padded), a brief truncated arg preview, and
@@ -948,15 +1105,57 @@ class Display {
948
1105
  const sk = this.skin;
949
1106
  const useMd = opts.markdown !== false;
950
1107
  const rawBody = useMd ? this.markdown(text).trimEnd() : text;
951
- const indented = rawBody
952
- .split('\n')
953
- .map((ln) => (ln ? ` ${ln}` : ''))
954
- .join('\n');
1108
+ // v4.1.4 reply-quality polish — F1 detect-and-skip indent + wrap.
1109
+ //
1110
+ // Walks the rendered markdown line-by-line, but applies frame
1111
+ // indent/wrap ONLY to plain prose lines. Lines that already carry
1112
+ // structural chrome (code-block rail+bg, blockquote rail,
1113
+ // pre-indented list bullets) pass through untouched — `renderCode-
1114
+ // Block`, `renderBlockquote`, and the list override already own
1115
+ // their own gutter + per-line wrap. Double-applying the gutter
1116
+ // shifts content right by 3 cols; double-wrapping breaks the rail
1117
+ // off the wrap-continuation row. See `isPreFramedLine` for the
1118
+ // detection rules.
1119
+ const indented = this.applyFrameToRendered(rawBody);
955
1120
  const reasoning = opts.reasoning
956
- ? ` ${sk.applyColors(opts.reasoning.trim(), 'muted')}\n`
1121
+ ? `${(0, frame_1.getIndent)(0)}${sk.applyColors(opts.reasoning.trim(), 'muted')}\n`
957
1122
  : '';
958
1123
  return `${this.agentHeader()}${reasoning}${indented}\n`;
959
1124
  }
1125
+ /**
1126
+ * v4.1.4 reply-quality polish — F1 shared helper.
1127
+ *
1128
+ * Apply frame indent + soft-wrap to the prose lines of a rendered
1129
+ * markdown body, but pass structural lines (code-block rail+bg,
1130
+ * blockquote rail, pre-indented list bullets) through unchanged.
1131
+ *
1132
+ * Shared by `agentTurn` (one-shot reply) and `tryRerenderInPlace`
1133
+ * (post-stream rerender) so both paths produce identical output.
1134
+ */
1135
+ applyFrameToRendered(rawBody) {
1136
+ const indent = (0, frame_1.getIndent)(0);
1137
+ const bw = (0, frame_1.getBodyWidth)(this.out);
1138
+ return rawBody
1139
+ .split('\n')
1140
+ .map((ln) => {
1141
+ if (ln.length === 0)
1142
+ return '';
1143
+ // F1 detect-and-skip: pre-framed lines (code-block chrome, list
1144
+ // bullets, blockquote rails) own their own gutter + wrap. Don't
1145
+ // re-indent or re-wrap them — that double-applies the gutter
1146
+ // and breaks the rail off wrap-continuation rows.
1147
+ if (isPreFramedLine(ln))
1148
+ return ln;
1149
+ // Plain prose: indent + wrap to bodyWidth. wrap-ansi handles
1150
+ // ANSI-aware width counting so bold/heading paint survives.
1151
+ const wrapped = (0, frame_1.wrap)(ln, bw, { trim: false, hard: true });
1152
+ return wrapped
1153
+ .split('\n')
1154
+ .map((vln) => `${indent}${vln}`)
1155
+ .join('\n');
1156
+ })
1157
+ .join('\n');
1158
+ }
960
1159
  /**
961
1160
  * Format a recoverable error with optional remediation suggestion.
962
1161
  * Output goes through the caller (returned as string), not stderr.
@@ -1075,6 +1274,33 @@ class Display {
1075
1274
  }
1076
1275
  this.out.write('\n');
1077
1276
  }
1277
+ /**
1278
+ * v4.1.4 reply-quality polish (Q-ResizeReflow Option B): zero the
1279
+ * per-chunk row counter when the terminal resizes mid-stream.
1280
+ *
1281
+ * Why: the resize guard hard-clears the viewport (`\x1b[2J\x1b[H`)
1282
+ * which removes ALL rows from the screen — but our `streamLineCount`
1283
+ * still believes those rows are there. The next `tryRerenderInPlace`
1284
+ * would walk the cursor back N rows that no longer exist, leaving a
1285
+ * ghost gap at the top of the new viewport. Zeroing the count makes
1286
+ * the next eraser a no-op (which is correct — there's nothing left
1287
+ * to erase).
1288
+ *
1289
+ * Idempotent: no-op when no stream is active. Safe to call from a
1290
+ * resize callback that fires unconditionally on every viewport
1291
+ * change. Also resets `streamBuffer` so the next commit doesn't try
1292
+ * to rerender content that was already wiped.
1293
+ */
1294
+ resetStreamFrameForResize() {
1295
+ if (!this.streamHeaderShown)
1296
+ return;
1297
+ this.streamLineCount = 0;
1298
+ this.streamBuffer = '';
1299
+ // Header was wiped by the hard-clear too — let the next
1300
+ // streamPartial / agentTurn write a fresh one.
1301
+ this.streamHeaderShown = false;
1302
+ this.streamLastEndedNewline = false;
1303
+ }
1078
1304
  /**
1079
1305
  * Append a streamed text fragment. Writes a styled "Aiden" header on
1080
1306
  * the first call of a turn, then writes raw text directly via the
@@ -1099,12 +1325,83 @@ class Display {
1099
1325
  this.out.write(text);
1100
1326
  this.streamLastEndedNewline = text.endsWith('\n');
1101
1327
  // 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.
1328
+ // post-stream re-render.
1104
1329
  this.streamBuffer += text;
1105
- for (let i = 0; i < text.length; i += 1)
1106
- if (text[i] === '\n')
1107
- this.streamLineCount += 1;
1330
+ // v4.1.4 reply-quality polish F-B1 wrap-aware row count.
1331
+ //
1332
+ // Prior counter just `streamLineCount += text.match(/\n/g)?.length`
1333
+ // — counted `\n` chars only. When the model emits a long single
1334
+ // line (e.g. a 100-char bullet on an 80-col terminal), the terminal
1335
+ // naturally wraps it across multiple screen rows, but the old
1336
+ // counter would still think it's 1 row. At streamComplete the
1337
+ // eraser walked back N rows that didn't match the wrapped row
1338
+ // count → raw `**markup**` from the streaming phase remained
1339
+ // visible above the rerendered output.
1340
+ //
1341
+ // Confirmed undercount via scripts/smoke-stream-wrap-count.ts:
1342
+ // 3 long bullets on 80-col counted 3, actually 6. Multi-chunk
1343
+ // preamble + bullets on 40-col counted 4, actually 8.
1344
+ //
1345
+ // Fix: count `ceil(visibleWidth / cols)` rows per `\n`-delimited
1346
+ // segment, then add 1 for the `\n` itself (cursor advances to
1347
+ // next row when newline is emitted). Visible width strips ANSI.
1348
+ this.streamLineCount += this.countStreamRows(text);
1349
+ }
1350
+ /**
1351
+ * v4.1.4 reply-quality polish — F-B1 helper.
1352
+ *
1353
+ * Estimate how many screen rows `text` consumes when written to a
1354
+ * terminal of width `this.out.columns`. Counts terminal-natural-wrap
1355
+ * rows for each logical line, plus one row per `\n`.
1356
+ *
1357
+ * Falls back to a sane count when columns is undefined (non-TTY or
1358
+ * pre-resize): in that case the eraser won't fire anyway
1359
+ * (`tryRerenderInPlace` gates on `out.isTTY`), so the count is
1360
+ * effectively ignored. We still compute a defensible value so any
1361
+ * future TTY-detection change doesn't silently regress.
1362
+ *
1363
+ * Pure with respect to ANSI: escape sequences pass through
1364
+ * `visibleLength` and don't inflate the row count.
1365
+ *
1366
+ * Edge cases:
1367
+ * - Empty text → 0 rows (consistent with the prior counter).
1368
+ * - Text without `\n` → ceil(visibleLen / cols) rows.
1369
+ * - Trailing `\n` → counts the prior content row + 1 for the
1370
+ * newline. Cursor is now at the start of the next row, which is
1371
+ * correct screen-state — the next streamPartial extends from
1372
+ * col 0 of that row.
1373
+ */
1374
+ countStreamRows(text) {
1375
+ if (text.length === 0)
1376
+ return 0;
1377
+ const cols = (typeof this.out.columns === 'number' && this.out.columns >= 1)
1378
+ ? this.out.columns
1379
+ : 80;
1380
+ // Semantics: counter tracks ROW BOUNDARIES CROSSED during
1381
+ // emission, not "rows occupied". The eraser uses `\x1b[<N>F`
1382
+ // which moves the cursor up N rows; if N matches the number of
1383
+ // boundaries crossed from start-of-stream to current-cursor, the
1384
+ // eraser lands at the start row and `\x1b[J` clears the rest.
1385
+ //
1386
+ // For a segment of visible width V on a terminal of width C:
1387
+ // - V == 0 → 0 wrap boundaries
1388
+ // - V <= C → 0 wrap boundaries (single row)
1389
+ // - C < V <= 2C → 1 wrap boundary
1390
+ // - General → floor((V - 1) / C) wrap boundaries
1391
+ //
1392
+ // Each `\n` between segments crosses one boundary regardless of
1393
+ // visible width — that's the newline advancing the cursor.
1394
+ let rows = 0;
1395
+ const segments = text.split('\n');
1396
+ for (let i = 0; i < segments.length; i += 1) {
1397
+ const seg = segments[i] ?? '';
1398
+ const visible = (0, box_1.visibleLength)(seg);
1399
+ if (visible > 0)
1400
+ rows += Math.floor((visible - 1) / cols);
1401
+ if (i < segments.length - 1)
1402
+ rows += 1;
1403
+ }
1404
+ return rows;
1108
1405
  }
1109
1406
  /**
1110
1407
  * v4.1.3-essentials: rerender a buffered stream chunk in place. Walks
@@ -1161,10 +1458,13 @@ class Display {
1161
1458
  // \x1b[J = erase from cursor to end of screen.
1162
1459
  this.out.write(`\x1b[${lines}F\x1b[J`);
1163
1460
  const formatted = this.markdown(buffered).trimEnd();
1164
- const indented = formatted
1165
- .split('\n')
1166
- .map((ln) => (ln ? ` ${ln}` : ''))
1167
- .join('\n');
1461
+ // v4.1.4 reply-quality polish: same detect-and-skip indent + wrap
1462
+ // as agentTurn so streamed and one-shot replies share the visible
1463
+ // frame. wrap-ansi handles ANSI-aware width counting for prose;
1464
+ // structural lines (code-block chrome, list bullets, blockquote
1465
+ // rails) pass through unchanged so their own gutter + wrap stays
1466
+ // intact.
1467
+ const indented = this.applyFrameToRendered(formatted);
1168
1468
  this.out.write(indented + '\n');
1169
1469
  }
1170
1470
  catch {
@@ -1450,6 +1750,112 @@ function truncToolArg(s) {
1450
1750
  return flat;
1451
1751
  return flat.slice(0, exports.TOOL_ROW_ARG_CAP - 1) + '…';
1452
1752
  }
1753
+ /**
1754
+ * v4.1.4 reply-quality polish — Part 1.6 tool-aware verb mapper.
1755
+ *
1756
+ * Picks the activity-indicator verb for the gap that follows a given
1757
+ * tool's completion. The verb reflects "what the model is likely doing
1758
+ * next" rather than "what just happened" — so a `file_read` completing
1759
+ * leads to "reading" (model is digesting the contents) rather than
1760
+ * "drafted" (which would imply done). Tested in display.test.ts.
1761
+ *
1762
+ * Categories (matches against the tool-name substring, lowercased):
1763
+ * - read/list/view/get/inspect → 'reading'
1764
+ * - search/web/fetch_url/scrape → 'searching'
1765
+ * - shell/exec/run/compute/system → 'analyzing'
1766
+ * - write/edit/patch/save → 'drafting'
1767
+ * - everything else (or undefined) → 'thinking'
1768
+ *
1769
+ * Special caller-supplied phase override:
1770
+ * - When the caller knows "all tools are done, reply about to start"
1771
+ * they pass `phase: 'post-all'` → verb defaults to 'drafting'
1772
+ * regardless of the last tool name.
1773
+ *
1774
+ * Pure; exported for unit-test access.
1775
+ */
1776
+ function verbForActivity(toolName, phase = 'post-tool') {
1777
+ if (phase === 'pre-tools')
1778
+ return 'thinking';
1779
+ if (phase === 'post-all')
1780
+ return 'drafting';
1781
+ const t = (toolName ?? '').toLowerCase();
1782
+ if (t.length === 0)
1783
+ return 'thinking';
1784
+ // Match in priority order so 'web_search' hits 'searching' (search)
1785
+ // before 'reading' (a hypothetical 'web_search_read' would still
1786
+ // map to 'searching' since search hits first).
1787
+ if (/(^|_)(search|web|fetch_url|scrape|crawl)(_|$)/.test(t))
1788
+ return 'searching';
1789
+ if (/(^|_)(read|list|view|get|inspect|info|status)(_|$)/.test(t))
1790
+ return 'reading';
1791
+ if (/(^|_)(write|edit|patch|save|create|append|delete|remove)(_|$)/.test(t))
1792
+ return 'drafting';
1793
+ if (/(^|_)(shell|exec|execute|run|compute|process|system|launch)(_|$)/.test(t))
1794
+ return 'analyzing';
1795
+ return 'thinking';
1796
+ }
1797
+ /**
1798
+ * v4.1.4 reply-quality polish — F1 detect-and-skip predicate.
1799
+ *
1800
+ * Returns true when `line` is a structural / pre-framed line emitted
1801
+ * by replyRenderer (code-block chrome, blockquote rail, indented list
1802
+ * bullet, fence rules). These lines OWN their own gutter and per-line
1803
+ * wrap; `agentTurn` and `tryRerenderInPlace` MUST pass them through
1804
+ * unchanged so the post-render indent+wrap pass doesn't:
1805
+ * - Double the gutter (content drifts 3 cols right per pass)
1806
+ * - Re-wrap an already-wrapped code line (rail/bg breaks across
1807
+ * the new wrap continuation row)
1808
+ *
1809
+ * Detection rules (all on the ANSI-bearing line as emitted by marked
1810
+ * via our renderer overrides):
1811
+ * - Contains `\x1b[48;` anywhere → 24-bit bg paint = code-block
1812
+ * line. Always pre-framed (renderCodeBlock applies gutter + rail).
1813
+ * - Starts with ` │ ` or ` ┃ ` → explicit pre-framed rail
1814
+ * (code or blockquote at the frame gutter).
1815
+ * - Matches `^\s{2,}(•|▸|\d+\.)\s` (depth-indented list bullet) →
1816
+ * the list override already applied the per-depth indent.
1817
+ * - Matches `^\s{0,4}─{8,}` (horizontal-rule run or fence) → render-
1818
+ * specific divider already styled.
1819
+ *
1820
+ * Pure; exported for unit-test access.
1821
+ */
1822
+ function isPreFramedLine(line) {
1823
+ if (line.length === 0)
1824
+ return false;
1825
+ // eslint-disable-next-line no-control-regex
1826
+ const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
1827
+ // v4.1.4 reply-quality polish — Fix D (tightened predicate).
1828
+ //
1829
+ // Code-block body lines start with the frame gutter + rail. The
1830
+ // 24-bit bg paint (`\x1b[48;…`) also appears on these lines, but
1831
+ // we MUST NOT use bg-presence alone as the trigger: the `codespan`
1832
+ // renderer wraps inline `` `code` `` with the same bg envelope, so
1833
+ // any prose line containing inline code would be wrongly classified
1834
+ // as a code-block line and would bypass the indent + wrap pass
1835
+ // (Issue D from visual smoke — prose with inline codespans
1836
+ // terminal-natural-wrapped past bodyWidth).
1837
+ //
1838
+ // Rail prefix (after ANSI strip) is the reliable signal: only
1839
+ // `renderCodeBlock` emits ` │ ` and only `renderBlockquote` emits
1840
+ // `┃ ` at line start (with display-layer gutter prepended).
1841
+ if (/^ │ /.test(stripped))
1842
+ return true;
1843
+ if (/^ ┃ /.test(stripped) || /^┃ /.test(stripped))
1844
+ return true;
1845
+ // Depth-indented list bullets emitted by the renderer.list override:
1846
+ // ` • prose…` (depth 1, 2-space indent)
1847
+ // ` ▸ prose…` (depth 2, 4-space indent)
1848
+ // ` 1. prose…` (numbered, depth 1)
1849
+ if (/^\s{2,}(•|▸|\d+\.)\s/.test(stripped))
1850
+ return true;
1851
+ // Code-block fence rules (long runs of `─` with optional leading
1852
+ // gutter + optional language label from renderCodeBlock). Match
1853
+ // ANYWHERE in the line so the language-tagged top rule
1854
+ // (` ── lang ──────…──`) trips alongside the unlabeled bottom rule.
1855
+ if (/─{8,}/.test(stripped))
1856
+ return true;
1857
+ return false;
1858
+ }
1453
1859
  /**
1454
1860
  * v4.1.3-essentials boldwrap-fix: count `\n` occurrences in `s`.
1455
1861
  * Used by `commitStreamChunk` to recompute `streamLineCount` after