aiden-runtime 4.1.2 → 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.
Files changed (36) hide show
  1. package/dist/cli/v4/aidenCLI.js +10 -0
  2. package/dist/cli/v4/callbacks.js +85 -13
  3. package/dist/cli/v4/chatSession.js +250 -24
  4. package/dist/cli/v4/commands/doctor.js +23 -27
  5. package/dist/cli/v4/commands/model.js +30 -1
  6. package/dist/cli/v4/defaultSoul.js +69 -2
  7. package/dist/cli/v4/display/capabilityCard.js +135 -0
  8. package/dist/cli/v4/display/frame.js +234 -0
  9. package/dist/cli/v4/display/progressBar.js +137 -0
  10. package/dist/cli/v4/display/sessionEndCard.js +127 -0
  11. package/dist/cli/v4/display/toolTrail.js +172 -0
  12. package/dist/cli/v4/display.js +891 -153
  13. package/dist/cli/v4/doctor.js +377 -75
  14. package/dist/cli/v4/promotionPrompt.js +135 -5
  15. package/dist/cli/v4/replyRenderer.js +487 -26
  16. package/dist/cli/v4/skinEngine.js +26 -4
  17. package/dist/cli/v4/toolPreview.js +82 -19
  18. package/dist/core/tools/nowPlaying.js +7 -15
  19. package/dist/core/v4/aidenAgent.js +9 -0
  20. package/dist/core/v4/promptBuilder.js +2 -1
  21. package/dist/core/v4/sessionDistiller.js +48 -1
  22. package/dist/core/v4/toolRegistry.js +16 -1
  23. package/dist/core/version.js +1 -1
  24. package/dist/moat/plannerGuard.js +19 -0
  25. package/dist/providers/v4/anthropicAdapter.js +25 -2
  26. package/dist/providers/v4/errors.js +92 -0
  27. package/dist/tools/v4/index.js +24 -1
  28. package/dist/tools/v4/sessions/recallSession.js +14 -0
  29. package/dist/tools/v4/system/_psHelpers.js +70 -2
  30. package/dist/tools/v4/system/appInput.js +154 -0
  31. package/dist/tools/v4/system/appLaunch.js +136 -10
  32. package/dist/tools/v4/system/mediaKey.js +35 -4
  33. package/dist/tools/v4/system/mediaSessions.js +163 -0
  34. package/dist/tools/v4/system/mediaTransport.js +211 -0
  35. package/package.json +2 -1
  36. package/skills/system_control.md +56 -6
@@ -18,13 +18,17 @@
18
18
  *
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.Display = exports.SPINNER_PHRASES = exports.TOOL_ICONS = void 0;
21
+ 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
27
  exports.previewToolArgs = previewToolArgs;
28
+ exports.verbForActivity = verbForActivity;
29
+ exports.isPreFramedLine = isPreFramedLine;
30
+ exports.countNewlines = countNewlines;
31
+ exports.splitAtUnclosedBold = splitAtUnclosedBold;
28
32
  exports.formatToolDuration = formatToolDuration;
29
33
  exports.formatCompactTokens = formatCompactTokens;
30
34
  exports.formatElapsedShort = formatElapsedShort;
@@ -36,6 +40,9 @@ const marked_1 = require("marked");
36
40
  const TerminalRenderer = require('marked-terminal').default ?? require('marked-terminal');
37
41
  const skinEngine_1 = require("./skinEngine");
38
42
  const box_1 = require("./box");
43
+ const toolTrail_1 = require("./display/toolTrail");
44
+ // v4.1.3-essentials — capability card renderer (auth/platform failures).
45
+ const capabilityCard_1 = require("./display/capabilityCard");
39
46
  // Phase v4.1-reply-formatting: skin-aware markdown renderer that
40
47
  // replaces marked-terminal's defaults with structured headers, lists,
41
48
  // code blocks, blockquotes, and links.
@@ -43,71 +50,63 @@ const replyRenderer_1 = require("./replyRenderer");
43
50
  // Optional "Sources" footer when AIDEN_CITATIONS=1 (default off).
44
51
  const citationFooter_1 = require("./citationFooter");
45
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");
46
59
  /**
47
- * Phase 26.2.7 — category emoji icons for the tool-row prefix when
48
- * `AIDEN_UI_ICONS=1` is set in the environment. Default OFF (the
49
- * row stays at `·`) because emoji width and font availability vary
50
- * across Windows Terminal / older Console hosts / SSH sessions.
60
+ * v4.1.3-repl-polish — category emoji icons for the tool-row trail.
61
+ * Icons are ON by default (AIDEN_UI_ICONS !== '0'). Set
62
+ * `AIDEN_UI_ICONS=0` to disable them (CI / dumb terminals).
51
63
  *
52
- * Categories tool name is matched against keys in this map:
53
- * 1. exact-match first (lowercased toolName)
54
- * 2. then substring match in insertion order
55
- * 3. fall back to `default` (·)
64
+ * Kept as a flat Record for backward-compat with smoke tests that
65
+ * import it directly. The canonical lookup now lives in
66
+ * `./display/toolTrail` (supports verb too); this map is derived
67
+ * from it for legacy reference.
56
68
  *
57
- * Keep the map small and category-based, NOT one-per-tool.
69
+ * Matching order: exact lowercased name, then substring, then 'default'.
58
70
  */
59
71
  exports.TOOL_ICONS = {
60
- // Observe / read / inspect
61
- observe: '👁',
62
- read: '👁',
63
- file_read: '👁',
64
- list: '👁',
65
- // Think / analyze / plan
66
- analyze: '🧠',
67
- think: '🧠',
68
- plan: '📋',
69
- skills_list: '📋',
70
- // Execute / write / run
71
- execute: '⚡',
72
- run: '⚡',
73
- bash: '⚡',
74
- powershell: '⚡',
75
- code: '⚡',
76
- skill_view: '⚡',
77
- write: '✏',
78
- edit: '✏',
72
+ // Observe / read
73
+ file_read: '👁', read_file: '👁', file_list: '👁', list_directory: '👁',
74
+ observe: '👁', read: '👁', list: '👁',
75
+ // Write / edit
76
+ file_write: '', write_file: '✏', edit_file: '✏',
77
+ write: '✏', edit: '✏', create: '✏',
78
+ // Execute / run
79
+ bash: '', powershell: '⚡', execute_code: '⚡', skill_view: '⚡',
80
+ execute: '', run: '⚡',
79
81
  // Web / browse
80
- web_search: '🌐',
81
- web_fetch: '🌐',
82
- open_url: '🌐',
83
- browser: '🌐',
84
- // Memory
85
- memory: '🧠',
86
- recall: '🧠',
82
+ web_search: '🌐', web_fetch: '🌐', fetch_url: '🌐', open_url: '🌐',
83
+ navigate: '🌐', browser: '🌐', fetch: '🌐', search: '🌐',
84
+ // Memory / recall
85
+ recall_session: '🧠', session_search: '🧠', memory: '🧠', recall: '🧠',
86
+ // Think
87
+ session_summary: '🧠', analyze: '🧠', think: '🧠',
88
+ // Skills / catalog
89
+ skills_list: '📋', skill: '📋',
90
+ // Screen / capture
91
+ screenshot: '🖥', computer: '🖥',
92
+ // Media / launch
93
+ now_playing: '▶', app_launch: '▶', media: '▶',
94
+ // Deploy / build
95
+ deploy: '📦', build: '📦', push: '📦',
96
+ // Message / send
97
+ send: '💬', message: '💬', notify: '💬',
87
98
  // Verify / test
88
- verify: '🛡',
89
- test: '🛡',
90
- // Default fallback (matches current behaviour).
99
+ verify: '🛡', test: '🛡', doctor: '🛡', health: '🛡',
100
+ // Default fallback
91
101
  default: '·',
92
102
  };
93
103
  /**
94
- * Phase 26.2.7 — return the category emoji for `toolName` from
95
- * `TOOL_ICONS`, or `·` when nothing matches. Lowercases the input
96
- * and tries exact match first, then substring match in the map's
97
- * insertion order. Pure — exported for smoke testing.
104
+ * Return the category emoji for `toolName`, or '·' when nothing matches.
105
+ * Delegates to the canonical toolTrail lookup (returns icon only).
106
+ * Exported for backward-compat with existing smoke / unit tests.
98
107
  */
99
108
  function iconForTool(toolName) {
100
- const lc = toolName.toLowerCase();
101
- const exact = exports.TOOL_ICONS[lc];
102
- if (exact)
103
- return exact;
104
- for (const [key, glyph] of Object.entries(exports.TOOL_ICONS)) {
105
- if (key === 'default')
106
- continue;
107
- if (lc.includes(key))
108
- return glyph;
109
- }
110
- return exports.TOOL_ICONS.default;
109
+ return (0, toolTrail_1.iconForTool)(toolName).icon;
111
110
  }
112
111
  /**
113
112
  * Phase 26.2.6 — pool of fun spinner phrases that the chat REPL
@@ -241,6 +240,15 @@ class Display {
241
240
  // marked-terminal optional — markdown() falls back to raw text below
242
241
  }
243
242
  }
243
+ /**
244
+ * v4.1.3-repl-polish — public colour gate so callers (e.g. session-end
245
+ * card renderer) can colour text without importing SkinEngine directly.
246
+ * Delegates to the active skin's applyColors(). Monochrome mode is
247
+ * respected the same way as internal calls.
248
+ */
249
+ applyColors(text, kind) {
250
+ return this.skin.applyColors(text, kind);
251
+ }
244
252
  /**
245
253
  * Build the welcome banner string (does not write).
246
254
  *
@@ -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
@@ -738,55 +895,133 @@ class Display {
738
895
  // ANSI cursor games on a dumb sink.
739
896
  toolRow(name, args) {
740
897
  const sk = this.skin;
741
- const argStr = previewToolArgs(args);
742
- const padded = name.length > TOOL_ROW_NAME_PAD
743
- ? name.slice(0, TOOL_ROW_NAME_PAD)
744
- : name.padEnd(TOOL_ROW_NAME_PAD);
745
- // Phase 26.2.7 category emoji icon when AIDEN_UI_ICONS=1, else
746
- // the default muted middle-dot. Read at call-time so toggling
747
- // the env var doesn't require a restart. Emoji are rendered raw
748
- // (no SGR wrap) because most terminals paint emoji glyphs in
749
- // their native colour and ignore foreground ANSI anyway.
750
- const useIcons = process.env.AIDEN_UI_ICONS === '1';
751
- const glyph = useIcons ? iconForTool(name) : sk.applyColors('·', 'muted');
752
- const left = ` ${glyph} ` +
753
- `${sk.applyColors('tool', 'muted')} ` +
754
- `${sk.applyColors(padded, 'tool')} ` +
755
- `${sk.applyColors(argStr, 'muted')}`;
756
- const renderBracket = (text, kind) => {
757
- const colored = sk.applyColors(`[${text}]`, kind);
758
- return `${left} ${colored}\n`;
898
+ // ── Build the fixed left portion (icon + verb + detail) ────────────
899
+ // v4.1.3-repl-polish: icons default ON; set AIDEN_UI_ICONS=0 to
900
+ // disable (CI / dumb terminals / narrow SSH sessions).
901
+ // Read at call-time so env changes take effect without restart.
902
+ const useIcons = process.env.AIDEN_UI_ICONS !== '0';
903
+ const { icon, verb } = (0, toolTrail_1.iconForTool)(name);
904
+ const glyph = useIcons ? icon : sk.applyColors('·', 'muted');
905
+ // Detail field: v4.1.4-media consult `buildToolPreview` first so
906
+ // tools registered in `TOOL_PRIMARY_ARG` (media_transport 'target',
907
+ // media_key 'action', file_read 'path', etc.) get their
908
+ // meaningful primary-arg preview instead of a JSON blob. Fall back
909
+ // to the generic `previewToolArgs` scan for tools the map doesn't
910
+ // know about so unregistered MCP tools etc. still render readably.
911
+ const mapped = (0, toolPreview_1.buildToolPreview)(name, args);
912
+ const detail = (0, toolTrail_1.truncDetail)(mapped ?? previewToolArgs(args));
913
+ // v4.1.3-essentials: live tool indicator. Capture wall-clock start
914
+ // so the running-row renderer can append an elapsed-time suffix
915
+ // ("running 3s…") after the first second. Sub-second tools render
916
+ // without the suffix — no flash of `running 0s` for fast paths.
917
+ const startedAt = Date.now();
918
+ // Running row — muted pipe, raw icon, tool-colored verb, muted detail.
919
+ // The optional `running Ns…` tail appears once the tool crosses the
920
+ // 1-second mark; the tick interval below redraws this row every 1s.
921
+ const runningRow = () => {
922
+ const elapsed = Date.now() - startedAt;
923
+ const liveSuffix = elapsed >= 1000
924
+ ? ` ${sk.applyColors(`running ${formatToolDuration(elapsed)}…`, 'muted')}`
925
+ : '';
926
+ return `${sk.applyColors(toolTrail_1.TRAIL_PIPE, 'muted')} ${glyph} ` +
927
+ `${sk.applyColors((0, toolTrail_1.padVerb)(verb), 'tool')} ` +
928
+ `${sk.applyColors(detail, 'muted')}${liveSuffix}\n`;
759
929
  };
760
- const isTty = !!this.out.isTTY;
930
+ // Outcome row — entire line colored by outcome kind.
931
+ const outcomeRow = (suffix, kind) => {
932
+ const content = `${toolTrail_1.TRAIL_PIPE} ${glyph} ${(0, toolTrail_1.padVerb)(verb)} ${detail}` +
933
+ (suffix ? ` ${suffix}` : '');
934
+ return `${sk.applyColors(content, kind)}\n`;
935
+ };
936
+ // Capture stream reference so closures don't need `this`.
937
+ const out = this.out;
938
+ const isTty = !!out.isTTY;
761
939
  let printed = false;
762
- const writeFinal = (text, kind) => {
763
- if (isTty && printed) {
764
- // Move up one line, clear it, then write the final row.
765
- this.out.write('\x1b[1A\x1b[2K\r');
940
+ // v4.1.3-essentials: tick handle for the live-elapsed update. Set
941
+ // when we start the interval; cleared by every terminal method
942
+ // (ok / fail / degraded / blocked / emptyFail / emptyRetry) AND by
943
+ // `retry` (retry is a state announcement and should hold static
944
+ // until the next state change — race-free).
945
+ let tickTimer = null;
946
+ const stopTick = () => {
947
+ if (tickTimer !== null) {
948
+ clearInterval(tickTimer);
949
+ tickTimer = null;
766
950
  }
767
- this.out.write(renderBracket(text, kind));
951
+ };
952
+ // Erase the last printed line (TTY only).
953
+ const eraseLast = () => {
954
+ if (isTty && printed)
955
+ out.write('\x1b[1A\x1b[2K\r');
956
+ };
957
+ const writeFinal = (suffix, kind) => {
958
+ stopTick();
959
+ eraseLast();
960
+ out.write(outcomeRow(suffix, kind));
768
961
  printed = true;
769
962
  };
770
963
  if (isTty) {
771
- this.out.write(renderBracket('running', 'warn'));
964
+ // v4.1.3-essentials (replaces v4.1.3-repl-polish streamInterrupted
965
+ // flag pattern): if a stream is active, fence off the current
966
+ // chunk BEFORE the running row writes. `commitStreamChunk` does
967
+ // its own newline-fencing + in-place rerender of the just-streamed
968
+ // chunk so this row lands cleanly on its own line below.
969
+ this.commitStreamChunk();
970
+ out.write(runningRow());
772
971
  printed = true;
972
+ // v4.1.3-essentials: start the live-elapsed ticker. Fires every
973
+ // 1s; first tick at +1s, when `runningRow()` starts emitting the
974
+ // `running 1s…` suffix. Cleared by every terminal method via
975
+ // `stopTick()` — no leaked timers across the tool lifecycle.
976
+ // Tool dispatch in aidenAgent is sequential (one tool at a time
977
+ // per turn) so the assumption "running row is the last written
978
+ // line" holds for the whole tick lifetime; `eraseLast()` is safe.
979
+ tickTimer = setInterval(() => {
980
+ if (!printed)
981
+ return;
982
+ eraseLast();
983
+ out.write(runningRow());
984
+ }, 1000);
773
985
  }
774
- // On non-TTY we hold off entirely until the caller signals completion.
986
+ // Non-TTY: hold off until completion (log lines carry final state).
987
+ // No tick — non-TTY sinks (pipes, CI logs) get one line per call
988
+ // with the final state; live updates would be noise in scrollback.
775
989
  return {
776
990
  ok(durationMs, retries = 0) {
777
- const text = retries > 0
778
- ? `ok ${formatToolDuration(durationMs)} after ${retries} ${retries === 1 ? 'retry' : 'retries'}`
779
- : `ok ${formatToolDuration(durationMs)}`;
780
- writeFinal(text, 'success');
991
+ stopTick();
992
+ if (retries > 0) {
993
+ // Showed retries — surface the eventual success in warn so the
994
+ // user knows it took multiple attempts.
995
+ writeFinal(`ok ${formatToolDuration(durationMs)} after ${retries} ${retries === 1 ? 'retry' : 'retries'}`, 'warn');
996
+ }
997
+ else {
998
+ // Clean success — SILENT. Erase on TTY; emit nothing on non-TTY.
999
+ eraseLast();
1000
+ }
781
1001
  },
782
1002
  fail(durationMs, retries = 0) {
783
- const text = retries > 0
1003
+ const suffix = retries > 0
784
1004
  ? `fail ${formatToolDuration(durationMs)} after ${retries} ${retries === 1 ? 'retry' : 'retries'}`
785
1005
  : `fail ${formatToolDuration(durationMs)}`;
786
- writeFinal(text, 'error');
1006
+ writeFinal(suffix, 'error');
1007
+ },
1008
+ degraded(durationMs, reason) {
1009
+ const suffix = reason
1010
+ ? `partial ${formatToolDuration(durationMs)} — ${reason}`
1011
+ : `partial ${formatToolDuration(durationMs)}`;
1012
+ writeFinal(suffix, 'degraded');
787
1013
  },
788
1014
  retry(n, m) {
789
- writeFinal(`retry ${n}/${m} …`, 'warn');
1015
+ // v4.1.3-essentials: retry is a state-change announcement —
1016
+ // freeze the row at the retry counter until next state change.
1017
+ // Stopping the ticker prevents the next 1s tick from racing
1018
+ // back over the retry counter with `running Ns…`.
1019
+ stopTick();
1020
+ // Update the running row with retry count.
1021
+ eraseLast();
1022
+ const content = `${toolTrail_1.TRAIL_PIPE} ${glyph} ${(0, toolTrail_1.padVerb)(verb)} ${detail} retry ${n}/${m} …`;
1023
+ out.write(sk.applyColors(content, 'warn') + '\n');
1024
+ printed = true;
790
1025
  },
791
1026
  blocked() {
792
1027
  writeFinal('blocked', 'warn');
@@ -870,15 +1105,57 @@ class Display {
870
1105
  const sk = this.skin;
871
1106
  const useMd = opts.markdown !== false;
872
1107
  const rawBody = useMd ? this.markdown(text).trimEnd() : text;
873
- const indented = rawBody
874
- .split('\n')
875
- .map((ln) => (ln ? ` ${ln}` : ''))
876
- .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);
877
1120
  const reasoning = opts.reasoning
878
- ? ` ${sk.applyColors(opts.reasoning.trim(), 'muted')}\n`
1121
+ ? `${(0, frame_1.getIndent)(0)}${sk.applyColors(opts.reasoning.trim(), 'muted')}\n`
879
1122
  : '';
880
1123
  return `${this.agentHeader()}${reasoning}${indented}\n`;
881
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
+ }
882
1159
  /**
883
1160
  * Format a recoverable error with optional remediation suggestion.
884
1161
  * Output goes through the caller (returned as string), not stderr.
@@ -973,6 +1250,57 @@ class Display {
973
1250
  printError(message, suggestion) {
974
1251
  this.out.write(this.error(message, suggestion));
975
1252
  }
1253
+ /**
1254
+ * v4.1.3-essentials: render a structured capability card. Used when
1255
+ * a tool fails because a capability is missing (platform unsupported,
1256
+ * auth not present, key env-var unset) and a generic error wouldn't
1257
+ * give the user enough signal. The card lists what the user CAN
1258
+ * still do, what's blocked, and a one-line fix.
1259
+ *
1260
+ * Delegates the layout to the pure renderer in `./display/capability-
1261
+ * Card.ts`. This method is the I/O boundary — caller passes data, we
1262
+ * write lines to the configured stdout. Trailing newline ensures the
1263
+ * card sits clean above whatever renders next (typically the prompt).
1264
+ */
1265
+ capabilityCard(data) {
1266
+ // v4.1.3-essentials: fence off any active stream chunk before the
1267
+ // card writes so the chunk gets rerendered as markdown and the
1268
+ // card lands below it on its own line. Same pattern as toolRow /
1269
+ // streamToolIndicator.
1270
+ this.commitStreamChunk();
1271
+ const lines = (0, capabilityCard_1.renderCapabilityCard)(data, (t, k) => this.applyColors(t, k));
1272
+ for (const line of lines) {
1273
+ this.out.write(line + '\n');
1274
+ }
1275
+ this.out.write('\n');
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
+ }
976
1304
  /**
977
1305
  * Append a streamed text fragment. Writes a styled "Aiden" header on
978
1306
  * the first call of a turn, then writes raw text directly via the
@@ -997,72 +1325,270 @@ class Display {
997
1325
  this.out.write(text);
998
1326
  this.streamLastEndedNewline = text.endsWith('\n');
999
1327
  // Phase v4.1-reply-formatting: track buffer + line count for the
1000
- // post-stream re-render. We count newlines in the OUTGOING bytes
1001
- // so the eraser later knows how many rows to clear.
1328
+ // post-stream re-render.
1002
1329
  this.streamBuffer += text;
1003
- for (let i = 0; i < text.length; i += 1)
1004
- if (text[i] === '\n')
1005
- 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);
1006
1349
  }
1007
1350
  /**
1008
- * Mark the end of a streaming turn. Adds a trailing newline if the
1009
- * stream didn't end with one so the next CLI line doesn't visually
1010
- * butt up against the model's last token. Resets the per-turn state
1011
- * so the next `streamPartial` re-emits the header.
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.
1012
1373
  */
1013
- streamComplete() {
1014
- if (!this.streamHeaderShown)
1015
- return;
1016
- if (!this.streamLastEndedNewline)
1017
- this.out.write('\n');
1018
- // Phase v4.1-reply-formatting: re-render the buffered stream as
1019
- // structured markdown but ONLY when stdout is a TTY and the
1020
- // buffer actually contains markdown structure worth rendering.
1021
- // Plain prose with no headers / lists / fences gets left alone
1022
- // (no flicker, identical output). Otherwise we erase the raw
1023
- // streamed body via cursor-up + erase-line and reprint via the
1024
- // skin-aware renderer.
1025
- const buffered = this.streamBuffer;
1026
- const lines = this.streamLineCount;
1027
- this.streamBuffer = '';
1028
- this.streamLineCount = 0;
1029
- this.streamHeaderShown = false;
1030
- this.streamLastEndedNewline = false;
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;
1405
+ }
1406
+ /**
1407
+ * v4.1.3-essentials: rerender a buffered stream chunk in place. Walks
1408
+ * the cursor back `lines` rows, erases to end-of-screen, and reprints
1409
+ * the chunk as skin-aware markdown.
1410
+ *
1411
+ * Pure side-effect; returns nothing. Used by:
1412
+ * - `commitStreamChunk()` — when a tool row interrupts the
1413
+ * stream, render the pre-tool
1414
+ * chunk before the row writes.
1415
+ * - `streamComplete()` — final chunk at end of turn.
1416
+ *
1417
+ * Heuristic gate avoids flicker on plain prose (no structure → no
1418
+ * rerender, no eraser fires). The catch block writes the RAW buffered
1419
+ * text as a fallback if `marked` throws — without this the eraser
1420
+ * would already have run and the body would silently vanish.
1421
+ * v4.1.3-essentials raw-text fallback per "make state legible" thesis.
1422
+ */
1423
+ tryRerenderInPlace(buffered, lines) {
1031
1424
  if (!this.out.isTTY)
1032
1425
  return;
1033
1426
  if (process.env.AIDEN_NO_REFORMAT === '1')
1034
1427
  return;
1035
- // Cheap heuristic: only re-render when there's structure that
1036
- // benefits from formatting. Avoids flicker on short prose replies.
1428
+ if (lines === 0)
1429
+ return;
1430
+ // Cheap structural heuristic — only re-render when formatting
1431
+ // actually helps. Plain prose chunks stay raw (no flicker).
1432
+ //
1433
+ // v4.1.3-essentials post-ship: inline `**bold**` and `` `code` ``
1434
+ // added to the heuristic. Before this, a chunk that contained ONLY
1435
+ // inline markdown (no headings / lists / code blocks) skipped
1436
+ // rerender entirely, leaving the literal `**bold**` asterisks in
1437
+ // user-visible output. The `paintBoldWhite` strong renderer was
1438
+ // never invoked for those chunks.
1439
+ //
1440
+ // Patterns:
1441
+ // - `**bold**`: requires non-space immediately after the opening
1442
+ // `**` so `2 ** 3` math expressions don't false-positive.
1443
+ // Tolerates multi-line bold via `[\s\S]*?`.
1444
+ // - `` `code` ``: negative-lookarounds for the triple-backtick
1445
+ // fence so we don't double-trigger when ``` lines are present
1446
+ // (those already match the fence pattern above).
1037
1447
  const hasStructure = /^#{1,6}\s/m.test(buffered) ||
1038
1448
  /^\s*[-*+]\s/m.test(buffered) ||
1039
1449
  /^\s*\d+\.\s/m.test(buffered) ||
1040
1450
  /^>\s/m.test(buffered) ||
1041
- /```/.test(buffered);
1451
+ /```/.test(buffered) ||
1452
+ /\*\*\S[\s\S]*?\*\*/.test(buffered) ||
1453
+ /(?<![`])`[^`\n]+`(?![`])/.test(buffered);
1042
1454
  if (!hasStructure)
1043
1455
  return;
1044
1456
  try {
1045
- // Erase the raw streamed body in place. We wrote `lines + 1`
1046
- // rows (header + body) the header (`┃ Aiden`) stays, so we
1047
- // walk back `lines` rows and clear each.
1048
- // `\x1b[<n>F` = cursor-up-and-to-column-0 N times.
1049
- // `\x1b[J` = erase from cursor to end of screen.
1050
- if (lines > 0) {
1051
- this.out.write(`\x1b[${lines}F\x1b[J`);
1052
- }
1457
+ // \x1b[<n>F = cursor-up-and-to-column-0 N times.
1458
+ // \x1b[J = erase from cursor to end of screen.
1459
+ this.out.write(`\x1b[${lines}F\x1b[J`);
1053
1460
  const formatted = this.markdown(buffered).trimEnd();
1054
- const indented = formatted
1055
- .split('\n')
1056
- .map((ln) => (ln ? ` ${ln}` : ''))
1057
- .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);
1058
1468
  this.out.write(indented + '\n');
1059
1469
  }
1060
1470
  catch {
1061
- // If anything goes wrong with the re-render, leave the raw
1062
- // streamed text in place graceful degradation beats flicker
1063
- // + corrupted output.
1471
+ // Eraser already ran. v4.1.3-essentials: write the raw buffered
1472
+ // text back so the body doesn't vanish silently. The user sees
1473
+ // unformatted markdown rather than a missing reply — the honest
1474
+ // failure mode.
1475
+ this.out.write(buffered);
1476
+ if (!buffered.endsWith('\n'))
1477
+ this.out.write('\n');
1064
1478
  }
1065
1479
  }
1480
+ /**
1481
+ * v4.1.3-essentials: fence off the current stream chunk before a
1482
+ * non-stream write (tool row, tool indicator, capability card) lands.
1483
+ *
1484
+ * Replaces the v4.1.3-repl-polish `streamInterrupted` flag pattern.
1485
+ * Old pattern: set flag mid-stream → on streamComplete, check flag
1486
+ * and SKIP rerender entirely (lost markdown on every tool-using
1487
+ * turn). New pattern: at each interrupt point, eagerly rerender THIS
1488
+ * chunk in place, then reset the per-chunk window so the next
1489
+ * streamPartial starts a fresh count. The cursor is at the end of
1490
+ * this chunk when commit fires, so `streamLineCount` is correct for
1491
+ * the eraser — tool rows write below without being clobbered.
1492
+ *
1493
+ * Multi-chunk turns (model says X, calls tool, says Y, calls tool,
1494
+ * says Z) get all three chunks rerendered as markdown.
1495
+ *
1496
+ * Idempotent: no-op when no stream cycle is active or when the buffer
1497
+ * is empty (consecutive tool calls). Always ensures the cursor sits
1498
+ * at start-of-line before returning so the caller can write its own
1499
+ * row cleanly.
1500
+ */
1501
+ commitStreamChunk() {
1502
+ if (!this.streamHeaderShown)
1503
+ return;
1504
+ // Ensure the streamed chunk ends with a newline so the interrupt
1505
+ // row doesn't stick to mid-token text from the prior delta.
1506
+ if (!this.streamLastEndedNewline) {
1507
+ this.out.write('\n');
1508
+ this.streamLastEndedNewline = true;
1509
+ // The trailing newline we just wrote DOES bump the cursor's row,
1510
+ // but only by 1 — and `streamLineCount` should reflect physical
1511
+ // rows of the chunk. Add it so the eraser walks back the right
1512
+ // amount.
1513
+ this.streamLineCount += 1;
1514
+ }
1515
+ // v4.1.3-essentials boldwrap-fix: if the chunk ends mid-bold-pair
1516
+ // (e.g. tool fired between the model emitting `**` and the closing
1517
+ // `**`), splitting here would leave literal asterisks in the
1518
+ // rerendered output and a matching orphan in the next chunk.
1519
+ // `splitAtUnclosedBold` finds the last unmatched `**` and carves
1520
+ // the buffer into two parts: the closed-bold prefix we CAN
1521
+ // rerender now, and the carry tail that we keep for the next
1522
+ // chunk (where the closing `**` will eventually arrive).
1523
+ //
1524
+ // Code-fence safety (carried in the helper): if the would-be
1525
+ // unmatched `**` is inside an open ``` fence, we defer the whole
1526
+ // chunk — bold-syntax inside code blocks isn't markdown bold and
1527
+ // splitting there would corrupt the fence.
1528
+ const split = splitAtUnclosedBold(this.streamBuffer);
1529
+ if (split.carry === '') {
1530
+ // Common case: buffer is balanced (or has no `**` at all).
1531
+ // Same behavior as before — rerender the whole chunk in place
1532
+ // and reset the per-chunk window.
1533
+ this.tryRerenderInPlace(this.streamBuffer, this.streamLineCount);
1534
+ this.streamBuffer = '';
1535
+ this.streamLineCount = 0;
1536
+ return;
1537
+ }
1538
+ // Split path: erase the WHOLE chunk (because the cursor is at the
1539
+ // end of the full buffer), rerender the closed prefix, then
1540
+ // re-emit the carry as raw text. The carry visibly stays on
1541
+ // screen as raw `**Live tool indi`-style text — ugly for the
1542
+ // ~milliseconds until the next streamPartial extends it past the
1543
+ // closing `**`, at which point the next commit will rerender
1544
+ // cleanly.
1545
+ const rerenderableLines = countNewlines(split.rerenderable);
1546
+ const carryLines = this.streamLineCount - rerenderableLines;
1547
+ if (this.out.isTTY && this.streamLineCount > 0) {
1548
+ // \x1b[<n>F = cursor-up-and-to-column-0 N times.
1549
+ // \x1b[J = erase from cursor to end of screen.
1550
+ this.out.write(`\x1b[${this.streamLineCount}F\x1b[J`);
1551
+ }
1552
+ // Rerender the closed prefix (handles its own heuristic gate
1553
+ // internally — a prefix without structure stays raw, which is
1554
+ // identical to the pre-split behavior).
1555
+ this.tryRerenderInPlace(split.rerenderable, rerenderableLines);
1556
+ // Re-emit the carry verbatim. It's intentionally raw because the
1557
+ // unmatched `**` can't be rendered without its closing pair.
1558
+ this.out.write(split.carry);
1559
+ // Reset the per-chunk window to the carry only. Next streamPartial
1560
+ // extends it; when the closing `**` lands, the next commit (or
1561
+ // streamComplete) rerenders cleanly.
1562
+ this.streamBuffer = split.carry;
1563
+ this.streamLineCount = carryLines;
1564
+ this.streamLastEndedNewline = split.carry.endsWith('\n');
1565
+ }
1566
+ /**
1567
+ * Mark the end of a streaming turn. Adds a trailing newline if the
1568
+ * stream didn't end with one so the next CLI line doesn't visually
1569
+ * butt up against the model's last token. Rerenders the FINAL chunk
1570
+ * (post-last-tool prose, or the whole body if no tools fired this
1571
+ * turn) and resets the per-turn state so the next `streamPartial`
1572
+ * re-emits the header.
1573
+ */
1574
+ streamComplete() {
1575
+ if (!this.streamHeaderShown)
1576
+ return;
1577
+ if (!this.streamLastEndedNewline) {
1578
+ this.out.write('\n');
1579
+ this.streamLineCount += 1;
1580
+ }
1581
+ // Final chunk: same in-place rerender path as commitStreamChunk
1582
+ // (factored shared helper). Tool-row interrupts have already
1583
+ // committed their preceding chunks; what's left in the buffer here
1584
+ // is the post-final-tool prose — typically the bulk of the
1585
+ // user-visible body in well-behaved turns.
1586
+ this.tryRerenderInPlace(this.streamBuffer, this.streamLineCount);
1587
+ this.streamBuffer = '';
1588
+ this.streamLineCount = 0;
1589
+ this.streamHeaderShown = false;
1590
+ this.streamLastEndedNewline = false;
1591
+ }
1066
1592
  /**
1067
1593
  * Phase v4.1-reply-formatting: render the optional "Sources"
1068
1594
  * footer when AIDEN_CITATIONS=1 and the trace has fetch-class
@@ -1085,10 +1611,15 @@ class Display {
1085
1611
  * newline if the prior delta ran past column N without one.
1086
1612
  */
1087
1613
  streamToolIndicator(name) {
1614
+ // v4.1.3-essentials (replaces v4.1.3-repl-polish streamInterrupted
1615
+ // flag pattern): fence off the streamed chunk before the indicator
1616
+ // writes. `commitStreamChunk` handles the newline-or-not and
1617
+ // in-place rerenders the pre-indicator chunk so this row lands
1618
+ // below well-formed markdown rather than below raw streamed text.
1619
+ this.commitStreamChunk();
1088
1620
  const sk = this.skin;
1089
1621
  const arrow = sk.getActive().glyphs?.arrow ?? '>';
1090
- const prefix = this.streamLastEndedNewline ? '' : '\n';
1091
- this.out.write(`${prefix}${sk.applyColors(`${arrow} ${name}…`, 'tool')}\n`);
1622
+ this.out.write(`${sk.applyColors(`${arrow} ${name}…`, 'tool')}\n`);
1092
1623
  this.streamLastEndedNewline = true;
1093
1624
  }
1094
1625
  }
@@ -1144,11 +1675,29 @@ function renderRmsBar(rms) {
1144
1675
  const filled = Math.round((safe / BAR_FULL_RMS) * BAR_WIDTH);
1145
1676
  return '▌'.repeat(filled) + ' '.repeat(BAR_WIDTH - filled);
1146
1677
  }
1147
- // ── Phase 23.5 — tool row helpers ─────────────────────────────────────
1148
- /** Width the tool name is padded to so brackets line up across rows. */
1149
- const TOOL_ROW_NAME_PAD = 16;
1150
- /** Args preview cap. Args longer than this get truncated with "…". */
1151
- const TOOL_ROW_ARG_CAP = 40;
1678
+ // ── Phase 23.5 / v4.1.3-repl-polish — tool row helpers ────────────────
1679
+ //
1680
+ // v4.1.3 changes the row format from:
1681
+ // " {glyph} tool {name:16} {args} [{state}]"
1682
+ // to the compact trail format:
1683
+ // "┊ {icon} {verb:12} {detail:40}"
1684
+ //
1685
+ // Outcome semantics:
1686
+ // ok() → SILENT — running row is erased; nothing persists.
1687
+ // fail() → row persists in error (red).
1688
+ // degraded() → row persists in degraded (yellow). NEW in v4.1.3.
1689
+ // blocked() → row persists in warn.
1690
+ // retry() → running row is updated with retry counter.
1691
+ // emptyFail() → treated as fail (error colour).
1692
+ // emptyRetry()→ treated as retry.
1693
+ /**
1694
+ * Kept for reference / smoke-test backward compat. New code should use
1695
+ * TRAIL_VERB_PAD / TRAIL_DETAIL_CAP from toolTrail.ts instead.
1696
+ * @deprecated
1697
+ */
1698
+ exports.TOOL_ROW_NAME_PAD = toolTrail_1.TRAIL_VERB_PAD;
1699
+ /** @deprecated Use TRAIL_DETAIL_CAP. */
1700
+ exports.TOOL_ROW_ARG_CAP = toolTrail_1.TRAIL_DETAIL_CAP;
1152
1701
  /**
1153
1702
  * Build a compact, single-line preview of the tool's arguments. Picks
1154
1703
  * the most informative scalar fields when the args are an object, then
@@ -1184,13 +1733,202 @@ function previewToolArgs(args) {
1184
1733
  catch {
1185
1734
  serialized = String(obj);
1186
1735
  }
1736
+ // v4.1.4-media: an empty object serializes to '{}'. Rendering that
1737
+ // literal in the trail row is honest but ugly and reads as "buggy
1738
+ // empty args". When the model legitimately passes an empty args
1739
+ // object (e.g. `media_sessions({})`, `system_info()`), show nothing
1740
+ // rather than the braces — `buildToolPreview` already does this for
1741
+ // tools mapped in `TOOL_PRIMARY_ARG`; here we extend the same UX to
1742
+ // any unmapped-tool fallback that bottoms out at `{}`.
1743
+ if (serialized === '{}')
1744
+ return '';
1187
1745
  return truncToolArg(serialized);
1188
1746
  }
1189
1747
  function truncToolArg(s) {
1190
1748
  const flat = s.replace(/\s+/g, ' ').trim();
1191
- if (flat.length <= TOOL_ROW_ARG_CAP)
1749
+ if (flat.length <= exports.TOOL_ROW_ARG_CAP)
1192
1750
  return flat;
1193
- return flat.slice(0, TOOL_ROW_ARG_CAP - 1) + '…';
1751
+ return flat.slice(0, exports.TOOL_ROW_ARG_CAP - 1) + '…';
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
+ }
1859
+ /**
1860
+ * v4.1.3-essentials boldwrap-fix: count `\n` occurrences in `s`.
1861
+ * Used by `commitStreamChunk` to recompute `streamLineCount` after
1862
+ * splitting a buffer at an unclosed-bold boundary. Pure helper —
1863
+ * exported for unit-test access.
1864
+ */
1865
+ function countNewlines(s) {
1866
+ let n = 0;
1867
+ for (let i = 0; i < s.length; i += 1)
1868
+ if (s[i] === '\n')
1869
+ n += 1;
1870
+ return n;
1871
+ }
1872
+ /**
1873
+ * v4.1.3-essentials boldwrap-fix: split a streamed-chunk buffer at the
1874
+ * last unmatched `**` so the closed-bold prefix can be rerendered now
1875
+ * and the open-bold tail can be carried into the next chunk.
1876
+ *
1877
+ * Returns `{ rerenderable, carry }`:
1878
+ * - rerenderable: the prefix with all `**` pairs balanced
1879
+ * - carry: the suffix starting at the last unmatched `**`
1880
+ *
1881
+ * `carry === ''` signals "balanced — render the whole buffer". Caller
1882
+ * uses this as the fast-path discriminator.
1883
+ *
1884
+ * Code-fence safety: if the buffer contains an UNCLOSED fenced code
1885
+ * block (` ``` ` count is odd), defer the entire chunk by returning
1886
+ * `{ rerenderable: '', carry: buffer }`. Bold-syntax inside code
1887
+ * blocks is literal text — splitting there would corrupt the fence
1888
+ * AND likely produce nonsensical rerender output. Trade-off: a chunk
1889
+ * that ends mid-code-block doesn't rerender at all until the closing
1890
+ * ``` arrives; acceptable because code blocks have their own
1891
+ * styling (dark bg + left rail) that doesn't depend on the markdown
1892
+ * rerender step.
1893
+ *
1894
+ * Pure function. Tested via `tests/v4/cli/display.test.ts`.
1895
+ */
1896
+ function splitAtUnclosedBold(buffer) {
1897
+ // Fast path: no `**` at all → balanced.
1898
+ if (!buffer.includes('**'))
1899
+ return { rerenderable: buffer, carry: '' };
1900
+ // Code-fence safety: count triple-backtick fences. Odd = open fence,
1901
+ // defer the whole buffer.
1902
+ const fenceMatches = buffer.match(/```/g);
1903
+ if (fenceMatches && fenceMatches.length % 2 === 1) {
1904
+ return { rerenderable: '', carry: buffer };
1905
+ }
1906
+ // Count `**` occurrences. Even → balanced. Odd → there's an
1907
+ // unmatched `**` — find the LAST one (the open).
1908
+ const positions = [];
1909
+ for (let i = 0; i < buffer.length - 1; i += 1) {
1910
+ if (buffer[i] === '*' && buffer[i + 1] === '*') {
1911
+ positions.push(i);
1912
+ i += 1; // skip the second `*` so `***` doesn't double-count
1913
+ }
1914
+ }
1915
+ if (positions.length % 2 === 0) {
1916
+ return { rerenderable: buffer, carry: '' };
1917
+ }
1918
+ const lastUnmatched = positions[positions.length - 1];
1919
+ // Inline-backtick safety: if the unmatched `**` sits inside an
1920
+ // open single-backtick span on the same line, the `**` is literal
1921
+ // code, not a bold marker. Defer the whole chunk.
1922
+ const lineStart = buffer.lastIndexOf('\n', lastUnmatched) + 1;
1923
+ const lineUpToBold = buffer.slice(lineStart, lastUnmatched);
1924
+ const backticksOnLine = (lineUpToBold.match(/`/g) ?? []).length;
1925
+ if (backticksOnLine % 2 === 1) {
1926
+ return { rerenderable: '', carry: buffer };
1927
+ }
1928
+ return {
1929
+ rerenderable: buffer.slice(0, lastUnmatched),
1930
+ carry: buffer.slice(lastUnmatched),
1931
+ };
1194
1932
  }
1195
1933
  /**
1196
1934
  * Render a tool-call duration in the bracket cluster. Sub-second