aiden-runtime 4.7.0 → 4.8.1

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/README.md +12 -1
  2. package/dist/cli/v4/aidenCLI.js +40 -5
  3. package/dist/cli/v4/callbacks.js +52 -31
  4. package/dist/cli/v4/chatSession.js +55 -8
  5. package/dist/cli/v4/commands/help.js +22 -11
  6. package/dist/cli/v4/commands/runs.js +42 -24
  7. package/dist/cli/v4/commands/skills.js +15 -17
  8. package/dist/cli/v4/commands/update.js +14 -2
  9. package/dist/cli/v4/commands/usage.js +17 -5
  10. package/dist/cli/v4/daemonAgentBuilder.js +1 -0
  11. package/dist/cli/v4/design/tokens.js +265 -0
  12. package/dist/cli/v4/display/framedPanel.js +116 -0
  13. package/dist/cli/v4/display/toolTrail.js +2 -2
  14. package/dist/cli/v4/display.js +489 -164
  15. package/dist/cli/v4/onboarding/disclaimer.js +42 -10
  16. package/dist/cli/v4/onboarding/loading.js +24 -1
  17. package/dist/cli/v4/onboarding/successScreen.js +17 -8
  18. package/dist/cli/v4/pasteIntercept.js +214 -70
  19. package/dist/cli/v4/replyRenderer.js +213 -58
  20. package/dist/cli/v4/setupWizard.js +19 -2
  21. package/dist/cli/v4/skinEngine.js +13 -0
  22. package/dist/cli/v4/table.js +65 -8
  23. package/dist/core/v4/aidenAgent.js +23 -0
  24. package/dist/core/v4/auxiliaryClient.js +46 -13
  25. package/dist/core/v4/daemon/dispatcher/realAgentRunner.js +13 -8
  26. package/dist/core/v4/promptBuilder.js +51 -0
  27. package/dist/core/v4/subagent/childBuilder.js +1 -0
  28. package/dist/core/v4/subagent/spawnSubAgent.js +7 -1
  29. package/dist/core/v4/ui/banner.js +16 -16
  30. package/dist/core/v4/update/executeInstall.js +10 -6
  31. package/dist/core/v4/update/installMethodDetect.js +7 -0
  32. package/dist/core/version.js +67 -2
  33. package/dist/moat/approvalEngine.js +14 -0
  34. package/dist/tools/v4/index.js +54 -0
  35. package/dist/tools/v4/subagent/spawnSubAgentTool.js +23 -0
  36. package/package.json +1 -3
@@ -41,6 +41,7 @@ const marked_1 = require("marked");
41
41
  const TerminalRenderer = require('marked-terminal').default ?? require('marked-terminal');
42
42
  const skinEngine_1 = require("./skinEngine");
43
43
  const box_1 = require("./box");
44
+ const tokens_1 = require("./design/tokens");
44
45
  const toolTrail_1 = require("./display/toolTrail");
45
46
  // v4.1.3-essentials — capability card renderer (auth/platform failures).
46
47
  const capabilityCard_1 = require("./display/capabilityCard");
@@ -231,6 +232,16 @@ class Display {
231
232
  // reprints the formatted output.
232
233
  this.streamBuffer = '';
233
234
  this.streamLineCount = 0;
235
+ // v4.8.0 Phase 2.3 — task_id → label map for in-flight ui_task_update
236
+ // rows. ui_task_done looks up the label so the completion row can
237
+ // echo it even when the model only sends task_id + status. Cleared
238
+ // on done. Map is per-Display-instance; one REPL session.
239
+ this.uiTaskRows = new Map();
240
+ // v4.8.0 Phase 2.3 fix — set true by renderUiEvent; tryRerenderInPlace
241
+ // early-returns when set so the cursor-up + erase-to-end-of-screen
242
+ // sequence can't wipe our ui_* rows. Reset at stream lifecycle
243
+ // boundaries (streamPartial first-delta init + streamComplete).
244
+ this.uiEventsFiredThisTurn = false;
234
245
  this.skin = opts.skin ?? (0, skinEngine_1.getSkinEngine)();
235
246
  this.out = opts.stdout ?? process.stdout;
236
247
  this.err = opts.stderr ?? process.stderr;
@@ -345,12 +356,16 @@ class Display {
345
356
  * Returns one indented line with a trailing newline.
346
357
  */
347
358
  agentHeader() {
348
- const bar = this.skin.applyColors('', 'brand');
359
+ // v4.8.0 Slice 7 hotfix — replace v4.7 heavy-vertical with the
360
+ // Slice 4 framedPanel bar `▎` so reply chrome matches /help,
361
+ // approval prompt, and other Slice 4+ surfaces. Trailing `\n\n`
362
+ // (was `\n`) puts one blank between header and first content row.
363
+ const bar = this.skin.applyColors(tokens_1.glyphs.panel.bar, 'brand');
349
364
  const head = this.skin.applyColors('Aiden', 'brand');
350
365
  if (process.env.AIDEN_UI_TIMESTAMPS === '1') {
351
- return `${this.timestampPrefix()} ${bar} ${head}\n`;
366
+ return `${this.timestampPrefix()} ${bar} ${head}\n\n`;
352
367
  }
353
- return ` ${bar} ${head}\n`;
368
+ return ` ${bar} ${head}\n\n`;
354
369
  }
355
370
  /**
356
371
  * Phase 26.2.3 — turn boundary marker. Writes a thin muted rule
@@ -360,7 +375,12 @@ class Display {
360
375
  * surface.
361
376
  */
362
377
  printTurnSeparator() {
363
- this.out.write(` ${this.rule()}\n\n`);
378
+ // v4.8.0 Slice 7 hotfix — drop the trailing blank line. Inquirer's
379
+ // own prompt leading newline + the Aiden header's leading 2-space
380
+ // indent provide enough breathing room; the extra blank was
381
+ // stacking with other emit points to produce 3+ blank lines
382
+ // between user prompt and reply.
383
+ this.out.write(` ${this.rule()}\n`);
364
384
  }
365
385
  /**
366
386
  * Render the v3-style boot status line:
@@ -500,55 +520,61 @@ class Display {
500
520
  * across lines.
501
521
  */
502
522
  scrollFooter() {
523
+ // v4.8.0 Slice 10d — rounded heavy frame for identity / credits.
524
+ // The Slice 10b/c orange-bar chrome lacked visual containment for
525
+ // an identity surface (the bar reads as panel-content, not as a
526
+ // credits card). This restores a heavy frame — but with rounded
527
+ // corners (╭╮╰╯) sourced from glyphs.box, and muted chrome so the
528
+ // brand `♥` + brand kv labels carry the visual weight inside.
503
529
  const sk = this.skin;
504
530
  const m = (s) => sk.applyColors(s, 'muted');
505
531
  const lab = (s) => sk.applyColors(s, 'brand');
506
532
  const val = (s) => sk.applyColors(s, 'agent');
507
533
  const heart = sk.applyColors('♥', 'brand');
508
534
  if (this.cols() < 80) {
509
- // Tier-3.1b: single-line credits at narrow widths so the boot
510
- // card stays compact. The 4-line plain fallback shipped earlier
511
- // wastes vertical space when terminals already squeeze content.
535
+ // Narrow fallback unchanged — single-line credits stays compact.
512
536
  return ` ${heart} ${m('built solo · github.com/taracodlabs/aiden · aiden.taracod.com')}`;
513
537
  }
514
- // Parchment.
515
- const INTERIOR = 63;
516
- const wallIndent = ' '; // 5 spaces — column where the | sits
517
- const lidIndent = ' '; // 6 spaces — lid floats one past the wall
518
- const pipe = m('|');
519
- const lid = m('_'.repeat(INTERIOR));
520
- const padInner = (text) => {
521
- const v = (0, box_1.visibleLength)(text);
522
- if (v >= INTERIOR)
523
- return (0, box_1.truncateVisible)(text, INTERIOR);
524
- return text + ' '.repeat(INTERIOR - v);
538
+ const indent = ' ';
539
+ const innerW = Math.min(this.cols() - 4, 70);
540
+ const tL = m(tokens_1.glyphs.box.topLeft);
541
+ const tR = m(tokens_1.glyphs.box.topRight);
542
+ const bL = m(tokens_1.glyphs.box.bottomLeft);
543
+ const bR = m(tokens_1.glyphs.box.bottomRight);
544
+ const side = m(tokens_1.glyphs.chrome.vLine);
545
+ const hRun = m(tokens_1.glyphs.chrome.hLine.repeat(innerW));
546
+ const pad = (visible, width) => {
547
+ const v = (0, box_1.visibleLength)(visible);
548
+ return visible + ' '.repeat(Math.max(0, width - v));
525
549
  };
526
- // v4.5 TUI polish add a leading + trailing blank line so the
527
- // box has visual breathing room from the lines above/below, and
528
- // a trailing interior blank so contact info doesn't crowd the
529
- // bottom border.
550
+ const row = (content) => `${indent}${side} ${pad(content, innerW - 2)} ${side}`;
530
551
  return [
531
552
  '',
532
- lidIndent + lid,
533
- wallIndent + pipe + ' '.repeat(INTERIOR) + pipe,
534
- wallIndent + pipe + padInner(` ${heart} ${val('Built solo')}`) + pipe,
535
- wallIndent + pipe + padInner(` ${lab('GitHub:')} ${val('github.com/taracodlabs/aiden')}`) + pipe,
536
- wallIndent + pipe + padInner(` ${lab('Web:')} ${val('aiden.taracod.com')}`) + pipe,
537
- wallIndent + pipe + padInner(` ${lab('Contact:')} ${val('contact@taracod.com')}`) + pipe,
538
- wallIndent + pipe + ' '.repeat(INTERIOR) + pipe,
539
- wallIndent + pipe + lid + pipe,
553
+ `${indent}${tL}${hRun}${tR}`,
554
+ row(`${heart} ${val('Built solo')}`),
555
+ row(''),
556
+ row(`${lab('GitHub:'.padEnd(10))}${val('github.com/taracodlabs/aiden')}`),
557
+ row(`${lab('Web:'.padEnd(10))}${val('aiden.taracod.com')}`),
558
+ row(`${lab('Contact:'.padEnd(10))}${val('contact@taracod.com')}`),
559
+ `${indent}${bL}${hRun}${bR}`,
540
560
  '',
541
561
  ].join('\n');
542
562
  }
543
563
  /**
544
564
  * Bottom prompt hint that replaces the prior `ready ▸ /help` +
545
- * `✦ Tip:` lines. `▲` in brand, body in muted.
565
+ * `✦ Tip:` lines.
566
+ *
567
+ * v4.8.0 Slice 11 — dropped the leading `▲` glyph. The inquirer
568
+ * prompt that paints immediately below this hint already carries
569
+ * the brand triangle as its input prefix (`display.promptPrefix()`),
570
+ * so the hint's own `▲` read as a duplicate orphan sitting one row
571
+ * above the active cursor. Hint is now text-only-muted; `▲` stays
572
+ * exclusively as the user-input identity glyph.
546
573
  */
547
574
  bottomPromptHint() {
548
575
  const sk = this.skin;
549
- const tri = sk.applyColors('▲', 'brand');
550
576
  const text = sk.applyColors('Type your message · /help for commands · /skills to add more', 'muted');
551
- return ` ${tri} ${text}`;
577
+ return ` ${text}`;
552
578
  }
553
579
  /**
554
580
  * v3-style "ready" line:
@@ -564,33 +590,96 @@ class Display {
564
590
  return ` ${ready} ${arrow} ${sk.applyColors(hint, 'muted')}`;
565
591
  }
566
592
  /**
567
- * v3-style post-turn status footer:
593
+ * v3-style post-turn status footer, extended in v4.8.0 Slice 7 with
594
+ * packed info density (turn counter, session uptime, per-turn state
595
+ * dot) and progressive disclosure based on terminal width.
568
596
  *
569
- * groq · llama-3.3-70b │ ▓▓▓░░░░░░░ 12.4K/128K │ 2s
597
+ * Layout tiers:
598
+ * ≥120 cols: ▲ provider · model │ N/M <bar> N% │ ⌘ N │ ⏱ Hms │ ● state
599
+ * ≥100 cols: ▲ provider · model │ N/M <bar> N% │ ⌘ N │ Ns
600
+ * < 100: ▲ provider · model │ <bar> N% │ Ns
570
601
  *
571
- * Width-bounded (10-cell context bar), always one line. Provider
572
- * appears in muted, model bold, ctx bar colour-graded by % full,
573
- * elapsed in muted. Returns string sans trailing newline.
602
+ * `turnCount`, `sessionMs`, `state` are optional for backward compat;
603
+ * old call sites continue to work unchanged.
574
604
  */
575
605
  statusFooter(args) {
576
606
  const sk = this.skin;
577
607
  const SEP = sk.applyColors(' │ ', 'muted');
578
608
  const tri = this.triangle();
609
+ // v4.8.0 Slice 7 hotfix #2 — per-metric accent palette.
610
+ // Model: cyan (tool kind). Token counts: amber (warn). Bar/pct:
611
+ // semantic tier. Turn: purple (metric_turn). Timer: teal (success).
579
612
  const provModel = `${tri} ${sk.applyColors(args.provider, 'muted')}` +
580
613
  `${sk.applyColors(' · ', 'muted')}` +
581
- sk.applyColors(args.model, 'agent');
614
+ sk.applyColors(args.model, 'tool');
582
615
  const pct = args.ctxMax > 0
583
616
  ? Math.min(100, Math.round((args.ctxUsed / args.ctxMax) * 100))
584
617
  : 0;
585
- const barW = 10;
618
+ // 5-cell bar with single-space separators — reads as discrete dots
619
+ // rather than a continuous line. Total visible width ≈ 9 cells,
620
+ // similar to the prior 10-cell solid bar so 80-col tier stays tight.
621
+ const barW = 5;
586
622
  const filled = Math.round((pct / 100) * barW);
587
623
  const ctxKind = pct < 60 ? 'success' : pct < 85 ? 'warn' : 'error';
588
- const bar = sk.applyColors('▓'.repeat(filled), ctxKind) +
589
- sk.applyColors('░'.repeat(barW - filled), 'muted');
590
- const ctxLabel = `${formatCompactTokens(args.ctxUsed)}/${formatCompactTokens(args.ctxMax)}`;
591
- const ctxSeg = `${bar} ${sk.applyColors(ctxLabel, ctxKind)}`;
592
- const elapsed = sk.applyColors(formatElapsedShort(args.elapsedMs), 'muted');
593
- return ` ${provModel}${SEP}${ctxSeg}${SEP}${elapsed}`;
624
+ const cells = Array.from({ length: barW }, (_, i) => i < filled ? tokens_1.glyphs.bar.filled : tokens_1.glyphs.bar.empty);
625
+ const bar = sk.applyColors(cells.join(' '), ctxKind);
626
+ const ctxRatio = sk.applyColors(`${formatCompactTokens(args.ctxUsed)}/${formatCompactTokens(args.ctxMax)}`, 'warn');
627
+ const ctxPctText = sk.applyColors(`${pct}%`, ctxKind);
628
+ const elapsed = sk.applyColors(formatElapsedShort(args.elapsedMs), 'success');
629
+ // Progressive disclosure: pick layout based on RAW terminal width.
630
+ // `this.cols()` caps at 100 (frame budget for body content), but
631
+ // the footer wants the full physical width to choose its tier.
632
+ const cols = (typeof this.out.columns === 'number' && this.out.columns >= 1)
633
+ ? this.out.columns
634
+ : 100;
635
+ // Tier ≥120: full density (ratio + bar + pct + turn + session + state).
636
+ // Tier ≥100: ratio + bar + pct + turn + elapsed.
637
+ // Tier <100: bar + pct + elapsed.
638
+ const stateDot = args.state
639
+ ? sk.applyColors(tokens_1.glyphs.status.dot, this.stateKind(args.state))
640
+ : '';
641
+ // v4.8.0 Slice 9 hotfix — turn glyph dropped; bare colored number
642
+ // matches the timer pattern. Color alone (purple metric_turn)
643
+ // carries the semantic.
644
+ const turnSeg = args.turnCount !== undefined
645
+ ? sk.applyColors(String(args.turnCount), 'metric_turn')
646
+ : '';
647
+ // v4.8.0 Slice 9 hotfix — ⌛ restored ahead of the bare elapsed
648
+ // string. Wider font support than the retired ⏱. `sessionMs` arg
649
+ // stays plumbed-but-unused for backward compat with the field name.
650
+ const sessionSeg = args.elapsedMs !== undefined
651
+ ? `${sk.applyColors(tokens_1.glyphs.status.timer, 'success')} ${sk.applyColors(formatElapsedShort(args.elapsedMs), 'success')}`
652
+ : '';
653
+ // ctxRatio + ctxPctText are pre-painted (warn + ctxKind respectively).
654
+ const ctxSegFull = `${ctxRatio} ${bar} ${ctxPctText}`;
655
+ const ctxSegCompact = `${bar} ${ctxPctText}`;
656
+ let segments;
657
+ if (cols >= 120 && stateDot && turnSeg && sessionSeg) {
658
+ segments = [provModel, ctxSegFull, turnSeg, sessionSeg, stateDot];
659
+ }
660
+ else if (cols >= 100 && turnSeg) {
661
+ // v4.8.1 Slice 2 hotfix — was `elapsed` (bare); now uses
662
+ // `sessionSeg` which includes the ⌛ timer glyph. The previous
663
+ // mid-tier dropped the glyph for "denser" packing, but Shiva's
664
+ // smoke at 80–110 cols showed only ` 5.1s` (leading space, no
665
+ // glyph). The glyph is single-cell, cheap, and load-bearing as
666
+ // the timer's identity affordance.
667
+ segments = [provModel, ctxSegFull, turnSeg, sessionSeg || elapsed];
668
+ }
669
+ else {
670
+ segments = [provModel, ctxSegCompact, sessionSeg || elapsed];
671
+ }
672
+ return ` ${segments.join(SEP)}`;
673
+ }
674
+ /** Map a per-turn outcome to the colour kind used by the state dot. */
675
+ stateKind(state) {
676
+ if (state === 'ok')
677
+ return 'success';
678
+ if (state === 'warn')
679
+ return 'warn';
680
+ if (state === 'error')
681
+ return 'error';
682
+ return 'muted';
594
683
  }
595
684
  /**
596
685
  * Tier-3.1 (v4.1-tier3.1): pre-prompt status line.
@@ -682,11 +771,15 @@ class Display {
682
771
  * `12:41:02 ▲ <input>`. Default OFF preserves `▲ <input>`.
683
772
  */
684
773
  promptPrefix() {
774
+ // v4.8.0 Slice 7 hotfix #2 — 2-space lead matches the rest of the
775
+ // surface family (▎ Aiden header, status footer, bottom hint).
776
+ // Timestamp variant unchanged — the timestamp gutter already
777
+ // provides its own consistent left edge.
685
778
  const tri = this.skin.applyColors('▲', 'brand');
686
779
  if (process.env.AIDEN_UI_TIMESTAMPS === '1') {
687
780
  return `${this.timestampPrefix()} ${tri} `;
688
781
  }
689
- return `${tri} `;
782
+ return ` ${tri} `;
690
783
  }
691
784
  /**
692
785
  * Phase 26.2.6 — pick a random phrase from `SPINNER_PHRASES`,
@@ -805,6 +898,13 @@ class Display {
805
898
  let stopped = false;
806
899
  let printed = false;
807
900
  let tickTimer = null;
901
+ // v4.8.1 Slice 2 hotfix #4 — true once the indicator has paused
902
+ // and resumed at least once (i.e. a tool row interrupted it). When
903
+ // false at stop() time, the indicator is still in its initial-paint
904
+ // row immediately below the leading blank, so stop()'s erase can
905
+ // safely consume BOTH rows. When true, the leading blank is far
906
+ // above and stop() erases only the current indicator row.
907
+ let movedFromInitial = false;
808
908
  // Tunable cadence. v4.1.4 Phase 3b' (Issue G): bumped from 400ms
809
909
  // to 250ms after visual smoke — 400ms felt sluggish, made the
810
910
  // indicator look static between seconds. 250ms gives ~4 dot
@@ -812,69 +912,65 @@ class Display {
812
912
  // (Ns) counter hasn't ticked. Slow enough not to flicker on SSH
813
913
  // / slow ConPTY refresh.
814
914
  const TICK_MS = 250;
815
- // glyph in brand orange the user's primary motif. Dots and
816
- // elapsed counter paint muted to keep visual weight on the verb.
915
+ // v4.8.0 Slice 11 leading glyph is no longer a static `⌛` (or
916
+ // a separate 2nd-row wave bar). Now it's a single-row sliding
917
+ // shimmer: a 4-cell brand-orange `█` segment that scrolls L→R
918
+ // across a muted `─` track, wrapping at the right edge. The dots
919
+ // pulse + (Ns) timer keep their roles as secondary motion cues;
920
+ // the shimmer is the primary "something is happening" affordance
921
+ // in TTFT space. Token-sourced from `glyphs.shimmer` so the glyph
922
+ // pair lives next to the rest of the v4.8.0 design system.
817
923
  //
818
- // v4.1.4 Phase 3b' (Issue F): the inline "▸▸ Ctrl+C cancel" hint
819
- // shipped with Phase 3a was visually noisy on the activity line
820
- // and collided with planner-debug dim writes. Dropped per user
821
- // feedback; a separate bottom-of-screen footer can be added in
822
- // v4.1.5 if wanted, but it must NOT be glued to the indicator.
823
- const glyph = sk.applyColors('▲', 'brand');
824
- // v4.1.5 Issue K — wave-bar state. Snake-scroll: a 3-cell `▰`
825
- // block slides across 10 cells, wrapping at the right edge. Same
826
- // 250ms tick as the verb dot pulse — one timer drives both rows.
827
- const waveBarEnabled = opts.waveBar !== false; // default true
828
- const WAVE_CELLS = 10;
829
- const WAVE_BLOCK = 3;
830
- let waveFrame = 0;
831
- const buildLine = () => {
832
- const dots = '.'.repeat(dotFrame); // 0..3 dots
833
- const elapsedSec = Math.floor((Date.now() - startTime) / 1000);
834
- const elapsedStr = elapsedSec >= 1
835
- ? ` ${sk.applyColors(`(${elapsedSec}s)`, 'muted')}`
836
- : '';
837
- // `▲ {verb}{dots-padded-to-3}{elapsed?}`
838
- return `${glyph} ${verb}${dots.padEnd(3, ' ')}${elapsedStr}`;
839
- };
924
+ // `opts.waveBar` is preserved as a back-compat option that maps
925
+ // to "shimmer enabled". Pass `{ waveBar: false }` to drop the
926
+ // shimmer cluster and render the bare verb row (the legacy
927
+ // v4.1.4 single-row indicator). Default ON.
928
+ const shimmerEnabled = opts.waveBar !== false;
929
+ const SHIMMER_CELLS = 10;
930
+ const SHIMMER_BLOCK = 4;
931
+ let shimmerFrame = 0;
840
932
  /**
841
- * v4.1.5 Issue K — render the wave-bar row. A 3-cell `▰` block at
842
- * positions `[waveFrame, waveFrame+1, waveFrame+2]` mod 10. The
843
- * filled cells paint brand orange, empty cells paint warm-muted.
844
- * Same width + glyph set as the token progress bar so the two
845
- * rows feel like a coherent palette (one is heartbeat, the other
846
- * is real progress).
933
+ * v4.8.0 Slice 11 — render the sliding-block shimmer. A 4-cell
934
+ * `█` (U+2588 FULL BLOCK) segment at positions `[frame,
935
+ * frame+1, frame+2, frame+3]` mod 10, on a muted `─` track.
936
+ * Brand-orange block, muted track. Token-sourced glyphs;
937
+ * cell-by-cell paint keeps glyph order true to position so
938
+ * the wrap visibly slides rather than jumping.
847
939
  *
848
- * Heartbeat semantics: this is NOT progress. The wave moves at a
849
- * constant 250ms cadence regardless of any backend metric. It
850
- * exists purely so the user sees motion during the unobservable
851
- * TTFT (time-to-first-token) wait. The verb row above carries
852
- * any real lifecycle signal via `setVerb()`.
940
+ * Heartbeat semantics: this is NOT progress. The block moves
941
+ * at a constant 250ms cadence regardless of any backend metric.
942
+ * It exists purely so the user sees motion during the
943
+ * unobservable TTFT (time-to-first-token) wait. The verb +
944
+ * dot pulse + (Ns) timer carry the real lifecycle signal.
853
945
  */
854
- const buildWave = () => {
855
- // v4.1.5 Phase 1d (Q-P1) — glyph palette switch. Was `▰`/`▱`
856
- // (U+25B0/B1, Geometric Shapes) which legacy Windows console
857
- // fonts render as tofu. Now `▓`/`░` (U+2593/91, Block Elements
858
- // — in CP437, universally supported). Matches the existing
859
- // statusFooter chrome that's shipped since v3 without ever
860
- // being garbled.
946
+ const buildShimmer = () => {
861
947
  const filled = new Set();
862
- for (let i = 0; i < WAVE_BLOCK; i += 1) {
863
- filled.add((waveFrame + i) % WAVE_CELLS);
948
+ for (let i = 0; i < SHIMMER_BLOCK; i += 1) {
949
+ filled.add((shimmerFrame + i) % SHIMMER_CELLS);
864
950
  }
865
- // Render cells in order so the snake-scroll visually slides:
866
- // we paint cell-by-cell with the right color, joined into one
867
- // string. ANSI runs reset per cell — slight overhead but keeps
868
- // glyph order true to position. Brand orange filled, warm-muted
869
- // empty.
870
951
  const cells = [];
871
- for (let c = 0; c < WAVE_CELLS; c += 1) {
952
+ for (let c = 0; c < SHIMMER_CELLS; c += 1) {
872
953
  cells.push(filled.has(c)
873
- ? sk.applyColors('▓', 'brand')
874
- : sk.applyColors('░', 'muted'));
954
+ ? sk.applyColors(tokens_1.glyphs.shimmer.block, 'brand')
955
+ : sk.applyColors(tokens_1.glyphs.shimmer.track, 'muted'));
875
956
  }
876
957
  return cells.join('');
877
958
  };
959
+ const buildLine = () => {
960
+ const dots = '.'.repeat(dotFrame); // 0..3 dots
961
+ const elapsedSec = Math.floor((Date.now() - startTime) / 1000);
962
+ const elapsedStr = elapsedSec >= 1
963
+ ? ` ${sk.applyColors(`(${elapsedSec}s)`, 'muted')}`
964
+ : '';
965
+ // Shimmer prefix (or none, when opts.waveBar === false).
966
+ const prefix = shimmerEnabled ? `${buildShimmer()} ` : '';
967
+ // v4.8.1 Slice 2 hotfix #4 — 2-space leading indent so the
968
+ // indicator line aligns at col 2, matching `▎ Aiden`, the
969
+ // user-prompt ` ▲ `, the panel ` │ ` bar, and every other
970
+ // structured surface. Prior buildLine started at col 0 which
971
+ // read as misaligned against the rest of the v4.8 chrome.
972
+ return ` ${prefix}${verb}${dots.padEnd(3, ' ')}${elapsedStr}`;
973
+ };
878
974
  // v4.1.5 Part 1a — Issue M (Windows ConPTY buffering fix).
879
975
  //
880
976
  // Prior pattern wrote `\r\x1b[K{indicator}` with NO trailing
@@ -905,24 +1001,15 @@ class Display {
905
1001
  if (stopped || paused || !isTty)
906
1002
  return;
907
1003
  dotFrame = (dotFrame + 1) % 4;
908
- // v4.1.5 Issue Kwave snake-scroll advances 1 cell per tick.
909
- // Same 250ms cadence as the dot pulse, so both rows move in
910
- // visible lockstep. Modulo WAVE_CELLS wraps the leading block
911
- // back to the left edge.
912
- waveFrame = (waveFrame + 1) % WAVE_CELLS;
913
- if (waveBarEnabled) {
914
- // 2-row layout: walk up TWO rows (two separate up-1+erase
915
- // sequences, which keeps the `\x1b[1A\x1b[2K` substring
916
- // assertion-compatible), repaint both, drop newlines so the
917
- // cursor lands on the row below the wave bar.
918
- out.write(`${ANSI_UP_ERASE}${ANSI_UP_ERASE}` +
919
- `${buildLine()}\n` +
920
- `${buildWave()}\n`);
921
- }
922
- else {
923
- // Single-row layout (back-compat with v4.1.4 tests).
924
- out.write(`${ANSI_UP_ERASE}${buildLine()}\n`);
925
- }
1004
+ // v4.8.0 Slice 11shimmer slides 1 cell per tick. Same 250ms
1005
+ // cadence as the dot pulse, so block + dots move in visible
1006
+ // lockstep. Modulo SHIMMER_CELLS wraps the leading block back
1007
+ // to the left edge.
1008
+ shimmerFrame = (shimmerFrame + 1) % SHIMMER_CELLS;
1009
+ // Single-row layout: walk up 1, erase, repaint, newline. Cursor
1010
+ // lands on the row below the indicator, ready for the next
1011
+ // tick to walk back up.
1012
+ out.write(`${ANSI_UP_ERASE}${buildLine()}\n`);
926
1013
  };
927
1014
  const startTick = () => {
928
1015
  if (stopped || !isTty || tickTimer !== null)
@@ -936,41 +1023,27 @@ class Display {
936
1023
  }
937
1024
  };
938
1025
  const eraseLine = () => {
939
- // Walk up to the indicator's row(s) + erase, then drop ONE
940
- // newline so the cursor lands on a blank line BELOW the
941
- // indicator's old footprint. v4.1.6 polish: previous behavior
942
- // left the cursor at col 0 of the just-erased row so caller
943
- // writes (agentHeader, tool row, etc.) sat tight against where
944
- // the indicator had been. v4.1.5 visual smoke flagged the
945
- // wave-bar→`┃ Aiden` proximity as feeling cramped. The
946
- // trailing `\n` gains one visible blank row of breathing space
947
- // AND adds another Windows ConPTY flush trigger (Issue M).
948
- //
949
- // v4.1.5 Issue K — with wave bar enabled, walk up 2 rows (two
950
- // up-1+erase sequences). Without the bar, walk up 1 row.
1026
+ // Walk up 1 row + erase + drop a newline so the cursor lands
1027
+ // on a blank line BELOW the indicator's old footprint. The
1028
+ // trailing `\n` provides one visible blank row of breathing
1029
+ // space and acts as a Windows ConPTY flush trigger (v4.1.5
1030
+ // Issue M). Slice 11 collapsed the prior 2-row layout to 1.
951
1031
  if (!isTty || !printed)
952
1032
  return;
953
- if (waveBarEnabled) {
954
- out.write(`${ANSI_UP_ERASE}${ANSI_UP_ERASE}\n`);
955
- }
956
- else {
957
- out.write(`${ANSI_UP_ERASE}\n`);
958
- }
1033
+ out.write(`${ANSI_UP_ERASE}\n`);
959
1034
  };
960
- // Initial paint — only on TTY. Indicator + `\n` so the buffer
961
- // flushes and the cursor sits on the row below, ready for the
962
- // first tick to walk back up.
1035
+ // Initial paint — only on TTY.
963
1036
  //
964
- // v4.1.5 Issue K when wave bar is enabled, paint TWO rows:
965
- // verb row + wave row, each with trailing `\n`. Cursor lands on
966
- // the row below the wave bar. The first tick will walk up 2.
1037
+ // v4.8.1 Slice 2 hotfix #4 leading `\n` restored to give one
1038
+ // blank row between the user-input row and the indicator (hotfix
1039
+ // #3 dropped the dim rule that previously provided that gap).
1040
+ // To keep the post-stop layout at "exactly one blank between
1041
+ // user input and ▎ Aiden", stop() now walks up TWO rows when
1042
+ // the indicator never moved (no pause/resume), consuming both
1043
+ // the indicator row AND the leading blank. The `movedFromInitial`
1044
+ // flag below tracks that state.
967
1045
  if (isTty) {
968
- if (waveBarEnabled) {
969
- out.write(`${buildLine()}\n${buildWave()}\n`);
970
- }
971
- else {
972
- out.write(`${buildLine()}\n`);
973
- }
1046
+ out.write(`\n${buildLine()}\n`);
974
1047
  printed = true;
975
1048
  startTick();
976
1049
  }
@@ -980,6 +1053,12 @@ class Display {
980
1053
  return;
981
1054
  paused = true;
982
1055
  stopTick();
1056
+ // v4.8.1 Slice 2 hotfix #4 — mark the indicator as "moved" so
1057
+ // a subsequent stop() does NOT walk up 2 rows. The leading
1058
+ // blank from initial paint is now far above the current row
1059
+ // and shouldn't be consumed; doing so would erase tool-row
1060
+ // content instead.
1061
+ movedFromInitial = true;
983
1062
  eraseLine();
984
1063
  // After erase the cursor is at column 0 of the indicator's
985
1064
  // (now empty) line. Caller is expected to write its own
@@ -998,17 +1077,16 @@ class Display {
998
1077
  // Caller has just finished writing its own content (typically
999
1078
  // ending with `\n`), so the cursor is on a fresh line below
1000
1079
  // whatever was there. Paint the indicator + `\n` to claim the
1001
- // current row(s) and leave the cursor on the row below — same
1080
+ // current row and leave the cursor on the row below — same
1002
1081
  // invariant the initial paint and tick maintain. Trailing `\n`
1003
1082
  // also flushes Windows ConPTY buffering (Issue M).
1004
1083
  //
1005
- // v4.1.5 Issue Krepaint BOTH rows when wave bar enabled.
1006
- if (waveBarEnabled) {
1007
- out.write(`${buildLine()}\n${buildWave()}\n`);
1008
- }
1009
- else {
1010
- out.write(`${buildLine()}\n`);
1011
- }
1084
+ // v4.8.0 Slice 11single-row layout: paint one row only.
1085
+ // (Initial paint includes a leading `\n` for breathing space;
1086
+ // resume omits it because the caller has already written its
1087
+ // own content above this point and an extra blank would
1088
+ // double up.)
1089
+ out.write(`${buildLine()}\n`);
1012
1090
  printed = true;
1013
1091
  startTick();
1014
1092
  },
@@ -1021,7 +1099,20 @@ class Display {
1021
1099
  return;
1022
1100
  stopped = true;
1023
1101
  stopTick();
1024
- eraseLine();
1102
+ // v4.8.1 Slice 2 hotfix #4 — when the indicator never moved
1103
+ // (no pause/resume happened during the turn), walk up TWO
1104
+ // rows: erase the indicator row AND the leading blank above
1105
+ // it. The trailing `\n` then lands the cursor exactly one
1106
+ // row below the user-input echo, so the next writer
1107
+ // (agentHeader → ▎ Aiden) produces a clean single-blank gap.
1108
+ if (!printed || !isTty)
1109
+ return;
1110
+ if (movedFromInitial) {
1111
+ out.write(`${ANSI_UP_ERASE}\n`);
1112
+ }
1113
+ else {
1114
+ out.write(`${ANSI_UP_ERASE}${ANSI_UP_ERASE}\n`);
1115
+ }
1025
1116
  },
1026
1117
  isPaused: () => paused,
1027
1118
  isStopped: () => stopped,
@@ -1088,18 +1179,25 @@ class Display {
1088
1179
  // Running row — muted pipe, raw icon, tool-colored verb, muted detail.
1089
1180
  // The optional `running Ns…` tail appears once the tool crosses the
1090
1181
  // 1-second mark; the tick interval below redraws this row every 1s.
1182
+ //
1183
+ // v4.8.0 Slice 11c — double-space between `${glyph}` and `${padVerb}`.
1184
+ // Emoji-class icons (`👁️`, `✏️`, `📋`, `🌐`, etc.) render 2-cell-wide
1185
+ // visually on Windows ConPTY but the cursor/column tracker treats
1186
+ // them as 1 cell, so a single trailing space is visually swallowed
1187
+ // by the emoji's right cell. Two spaces guarantees one visible gap
1188
+ // regardless of how the terminal measures the glyph.
1091
1189
  const runningRow = () => {
1092
1190
  const elapsed = Date.now() - startedAt;
1093
1191
  const liveSuffix = elapsed >= 1000
1094
1192
  ? ` ${sk.applyColors(`running ${formatToolDuration(elapsed)}…`, 'muted')}`
1095
1193
  : '';
1096
- return `${sk.applyColors(toolTrail_1.TRAIL_PIPE, 'muted')} ${glyph} ` +
1194
+ return `${sk.applyColors(toolTrail_1.TRAIL_PIPE, 'muted')} ${glyph} ` +
1097
1195
  `${sk.applyColors((0, toolTrail_1.padVerb)(verb), 'tool')} ` +
1098
1196
  `${sk.applyColors(detail, 'muted')}${liveSuffix}\n`;
1099
1197
  };
1100
1198
  // Outcome row — entire line colored by outcome kind.
1101
1199
  const outcomeRow = (suffix, kind) => {
1102
- const content = `${toolTrail_1.TRAIL_PIPE} ${glyph} ${(0, toolTrail_1.padVerb)(verb)} ${detail}` +
1200
+ const content = `${toolTrail_1.TRAIL_PIPE} ${glyph} ${(0, toolTrail_1.padVerb)(verb)} ${detail}` +
1103
1201
  (suffix ? ` ${suffix}` : '');
1104
1202
  return `${sk.applyColors(content, kind)}\n`;
1105
1203
  };
@@ -1211,8 +1309,10 @@ class Display {
1211
1309
  // back over the retry counter with `running Ns…`.
1212
1310
  stopTick();
1213
1311
  // Update the running row with retry count.
1312
+ // v4.8.0 Slice 11c — double-space between glyph and verb (see
1313
+ // runningRow comment above for the emoji-width rationale).
1214
1314
  eraseLast();
1215
- const content = `${toolTrail_1.TRAIL_PIPE} ${glyph} ${(0, toolTrail_1.padVerb)(verb)} ${detail} retry ${n}/${m} …`;
1315
+ const content = `${toolTrail_1.TRAIL_PIPE} ${glyph} ${(0, toolTrail_1.padVerb)(verb)} ${detail} retry ${n}/${m} …`;
1216
1316
  out.write(sk.applyColors(content, 'warn') + '\n');
1217
1317
  printed = true;
1218
1318
  },
@@ -1326,7 +1426,12 @@ class Display {
1326
1426
  * (post-stream rerender) so both paths produce identical output.
1327
1427
  */
1328
1428
  applyFrameToRendered(rawBody) {
1329
- const indent = (0, frame_1.getIndent)(0);
1429
+ // v4.8.0 Slice 7 hotfix #2 — override frame.GUTTER (3) to 2 cells
1430
+ // locally so Aiden reply prose aligns with the ▎ bar of agentHeader
1431
+ // (col 2). The GUTTER constant stays at 3 for other consumers
1432
+ // (markdown list/blockquote/code-block renderers in frame.ts) where
1433
+ // a 3-cell gutter is part of their own visual algebra.
1434
+ const indent = ' ';
1330
1435
  const bw = (0, frame_1.getBodyWidth)(this.out);
1331
1436
  return rawBody
1332
1437
  .split('\n')
@@ -1507,15 +1612,31 @@ class Display {
1507
1612
  if (!text)
1508
1613
  return;
1509
1614
  if (!this.streamHeaderShown) {
1510
- // Phase 26.2.3 — share the single-line `┃ Aiden` header with
1511
- // non-streaming agentTurn so streamed and non-streamed responses
1512
- // open identically.
1615
+ // Phase 26.2.3 — share the `▎ Aiden` header with non-streaming
1616
+ // agentTurn so streamed + non-streamed responses open identically.
1513
1617
  this.out.write(this.agentHeader());
1514
1618
  this.streamHeaderShown = true;
1515
1619
  this.streamBuffer = '';
1516
1620
  this.streamLineCount = 0;
1621
+ this.uiEventsFiredThisTurn = false;
1622
+ // agentHeader emits trailing `\n\n`; cursor is at col 0 of a fresh
1623
+ // line, so the very next chunk needs the leading indent.
1624
+ this.streamLastEndedNewline = true;
1517
1625
  }
1518
- this.out.write(text);
1626
+ // v4.8.0 Slice 7 hotfix #3 — inject a 2-cell indent at every line
1627
+ // start so streamed content aligns with the ▎ bar in agentHeader.
1628
+ // Pre-this-fix the raw chunk wrote at col 0; the post-stream
1629
+ // rerender in applyFrameToRendered would indent eventually, but
1630
+ // mid-stream the user saw col-0 content. streamBuffer stays raw
1631
+ // (the rerender path applies its own indent).
1632
+ const indent = ' ';
1633
+ let toWrite = text;
1634
+ if (this.streamLastEndedNewline)
1635
+ toWrite = indent + toWrite;
1636
+ const endsNl = toWrite.endsWith('\n');
1637
+ const body = endsNl ? toWrite.slice(0, -1) : toWrite;
1638
+ toWrite = body.replace(/\n/g, '\n' + indent) + (endsNl ? '\n' : '');
1639
+ this.out.write(toWrite);
1519
1640
  this.streamLastEndedNewline = text.endsWith('\n');
1520
1641
  // Phase v4.1-reply-formatting: track buffer + line count for the
1521
1642
  // post-stream re-render.
@@ -1620,6 +1741,15 @@ class Display {
1620
1741
  return;
1621
1742
  if (lines === 0)
1622
1743
  return;
1744
+ // v4.8.0 Phase 2.3 fix — when ui_* events painted this turn, skip the
1745
+ // cursor-up + erase-to-end-of-screen rerender. The eraser wipes
1746
+ // anything below where the stream started, including our event rows.
1747
+ // Tradeoff: assistant text on a ui-event turn stays raw (no in-place
1748
+ // markdown beautification). Acceptable — when the model is using
1749
+ // structured ui events, it's signalling state, not relying on prose
1750
+ // formatting.
1751
+ if (this.uiEventsFiredThisTurn)
1752
+ return;
1623
1753
  // Cheap structural heuristic — only re-render when formatting
1624
1754
  // actually helps. Plain prose chunks stay raw (no flicker).
1625
1755
  //
@@ -1781,6 +1911,8 @@ class Display {
1781
1911
  this.streamLineCount = 0;
1782
1912
  this.streamHeaderShown = false;
1783
1913
  this.streamLastEndedNewline = false;
1914
+ // v4.8.0 Phase 2.3 fix — turn ends; clear the ui-fired flag.
1915
+ this.uiEventsFiredThisTurn = false;
1784
1916
  }
1785
1917
  /**
1786
1918
  * Phase v4.1-reply-formatting: render the optional "Sources"
@@ -1815,6 +1947,199 @@ class Display {
1815
1947
  this.out.write(`${sk.applyColors(`${arrow} ${name}…`, 'tool')}\n`);
1816
1948
  this.streamLastEndedNewline = true;
1817
1949
  }
1950
+ /**
1951
+ * v4.8.0 Phase 2.3 — render a semantic ui_* event signalled by the
1952
+ * model via a uiOnly tool call. Append-only: each event paints one
1953
+ * row; in-place mutation is a v4.8.x upgrade if UX demands it.
1954
+ *
1955
+ * Currently handles `ui_task_update` and `ui_task_done`; other 5
1956
+ * names land in Phase 2.4 (silent ignore until then). Non-TTY out
1957
+ * surfaces silent — matches the activityIndicator precedent.
1958
+ */
1959
+ /**
1960
+ * v4.8.0 Phase 2.3 fix-2 — reset the per-turn ui-event flag. Called
1961
+ * by chatSession at the top of each turn. The existing reset sites
1962
+ * (streamPartial first-delta + streamComplete) only fire when the
1963
+ * turn actually streamed text deltas. Tool-only turns never reset,
1964
+ * leaving the flag sticky into subsequent turns. This is the
1965
+ * authoritative reset for turn-start.
1966
+ */
1967
+ resetUiTurnState() {
1968
+ this.uiEventsFiredThisTurn = false;
1969
+ }
1970
+ renderUiEvent(name, args) {
1971
+ if (!this.out.isTTY)
1972
+ return;
1973
+ // v4.8.0 Phase 2.3 fix — Option C. The post-stream markdown rerender
1974
+ // (`tryRerenderInPlace`) does `cursor-up-N + erase-to-end-of-screen`,
1975
+ // which wipes anything painted between stream start and stream end —
1976
+ // including our ui_* rows. Mark the turn so the rerender skips this
1977
+ // turn entirely. Resets when the next streaming turn begins (see
1978
+ // streamPartial header init) and on streamComplete cleanup.
1979
+ this.uiEventsFiredThisTurn = true;
1980
+ if (name === 'ui_task_update') {
1981
+ this.renderUiTaskUpdate(args);
1982
+ return;
1983
+ }
1984
+ if (name === 'ui_task_done') {
1985
+ this.renderUiTaskDone(args);
1986
+ return;
1987
+ }
1988
+ if (name === 'ui_command_result') {
1989
+ this.renderUiCommandResult(args);
1990
+ return;
1991
+ }
1992
+ if (name === 'ui_test_result') {
1993
+ this.renderUiTestResult(args);
1994
+ return;
1995
+ }
1996
+ if (name === 'ui_approval_request') {
1997
+ this.renderUiApprovalRequest(args);
1998
+ return;
1999
+ }
2000
+ if (name === 'ui_toast') {
2001
+ this.renderUiToast(args);
2002
+ return;
2003
+ }
2004
+ if (name === 'ui_artifact_created') {
2005
+ this.renderUiArtifactCreated(args);
2006
+ return;
2007
+ }
2008
+ // Unknown event names silent-ignore (defensive — future registrations).
2009
+ }
2010
+ /**
2011
+ * v4.8.0 Phase 2.4 polish — build one trail-gutter row matching the
2012
+ * `toolRow` chrome (muted `┊` + space + colored content + `\n`).
2013
+ * Splits on embedded newlines so multi-line surfaces (capped stdout,
2014
+ * preview tails, optional reasons) carry the gutter on every line.
2015
+ */
2016
+ uiTrailRow(content, kind) {
2017
+ const pipe = this.skin.applyColors(toolTrail_1.TRAIL_PIPE, 'muted');
2018
+ return content.split('\n').map(l => `${pipe} ${this.skin.applyColors(l, kind)}\n`).join('');
2019
+ }
2020
+ renderUiTaskUpdate(args) {
2021
+ const taskId = typeof args.task_id === 'string' ? args.task_id : '';
2022
+ const label = typeof args.label === 'string' ? args.label : '';
2023
+ const status = typeof args.status === 'string' ? args.status : '';
2024
+ const kindArg = typeof args.kind === 'string' ? args.kind : 'task';
2025
+ const depth = typeof args.depth === 'number' && args.depth > 0 ? args.depth : 0;
2026
+ if (!taskId || !label)
2027
+ return;
2028
+ this.commitStreamChunk();
2029
+ const glyph = status === 'paused' ? '⏸' : status === 'blocked' ? '⛔' : '⟳';
2030
+ const colorKind = status === 'running' ? 'tool' : 'warn';
2031
+ this.uiTaskRows.set(taskId, { label });
2032
+ const short = label.length > 80 ? label.slice(0, 79) + '…' : label;
2033
+ // v4.8.0 Phase 2.4 — subagent kind: indent by depth inside the
2034
+ // gutter so nested rows tier below their parent.
2035
+ const indent = kindArg === 'subagent' ? ' '.repeat(depth) : '';
2036
+ this.out.write(this.uiTrailRow(`${indent}${glyph} ${short}`, colorKind));
2037
+ this.streamLastEndedNewline = true;
2038
+ }
2039
+ renderUiTaskDone(args) {
2040
+ const taskId = typeof args.task_id === 'string' ? args.task_id : '';
2041
+ const status = typeof args.status === 'string' ? args.status : '';
2042
+ const summary = typeof args.summary === 'string' ? args.summary : '';
2043
+ if (!taskId)
2044
+ return;
2045
+ this.commitStreamChunk();
2046
+ const tracked = this.uiTaskRows.get(taskId);
2047
+ const label = tracked?.label ?? taskId;
2048
+ this.uiTaskRows.delete(taskId);
2049
+ const glyph = status === 'success' ? '✓' : status === 'failure' ? '✗' : '⊘';
2050
+ const kind = status === 'success' ? 'success' :
2051
+ status === 'failure' ? 'error' : 'warn';
2052
+ const shortLabel = label.length > 80 ? label.slice(0, 79) + '…' : label;
2053
+ const shortSum = summary.length > 120 ? summary.slice(0, 119) + '…' : summary;
2054
+ const tail = shortSum ? ` — ${shortSum}` : '';
2055
+ this.out.write(this.uiTrailRow(`${glyph} ${shortLabel}${tail}`, kind));
2056
+ this.streamLastEndedNewline = true;
2057
+ }
2058
+ renderUiCommandResult(args) {
2059
+ const command = typeof args.command === 'string' ? args.command : '';
2060
+ if (!command)
2061
+ return;
2062
+ const stdout = typeof args.stdout === 'string' ? args.stdout : '';
2063
+ const stderr = typeof args.stderr === 'string' ? args.stderr : '';
2064
+ const exitCode = typeof args.exit_code === 'number' ? args.exit_code : 0;
2065
+ this.commitStreamChunk();
2066
+ const ok = exitCode === 0;
2067
+ const cap = (t) => t.split('\n').slice(0, 5).join('\n');
2068
+ let out = this.uiTrailRow(`▸ ${command}`, ok ? 'success' : 'error');
2069
+ if (stdout)
2070
+ out += this.uiTrailRow(cap(stdout), 'muted');
2071
+ if (stderr)
2072
+ out += this.uiTrailRow(cap(stderr), 'error');
2073
+ if (!ok)
2074
+ out += this.uiTrailRow(`(exit ${exitCode})`, 'error');
2075
+ this.out.write(out);
2076
+ this.streamLastEndedNewline = true;
2077
+ }
2078
+ renderUiTestResult(args) {
2079
+ const framework = typeof args.framework === 'string' ? args.framework : '';
2080
+ if (!framework)
2081
+ return;
2082
+ const passed = typeof args.passed === 'number' ? args.passed : 0;
2083
+ const failed = typeof args.failed === 'number' ? args.failed : 0;
2084
+ const skipped = typeof args.skipped === 'number' ? args.skipped : 0;
2085
+ const durationMs = typeof args.duration_ms === 'number' ? args.duration_ms : 0;
2086
+ this.commitStreamChunk();
2087
+ const ok = failed === 0;
2088
+ const parts = [`${passed} passed`, `${failed} failed`];
2089
+ if (skipped > 0)
2090
+ parts.push(`${skipped} skipped`);
2091
+ const dur = durationMs > 0 ? ` in ${durationMs}ms` : '';
2092
+ this.out.write(this.uiTrailRow(`${ok ? '✓' : '✗'} ${framework}: ${parts.join(', ')}${dur}`, ok ? 'success' : 'error'));
2093
+ this.streamLastEndedNewline = true;
2094
+ }
2095
+ renderUiApprovalRequest(_args) {
2096
+ // v4.8.1 Slice 1 — silent no-op. The Phase 2.5 wiring fires both
2097
+ // `ui_approval_request` (this method) AND `callbacks.promptApproval`
2098
+ // (which paints the framed approval panel via `renderApprovalBox`)
2099
+ // for every single approval request. The intent was complementary —
2100
+ // succinct event row above, structured kv panel below — but in live
2101
+ // smoke the two surfaces stack as a visual duplicate ("Approval
2102
+ // needed: file_write {...}" event row + "│ tool / │ reason / │ args"
2103
+ // panel). The panel is the canonical, information-rich surface; this
2104
+ // event-row paint is redundant.
2105
+ //
2106
+ // Behavioural change is renderer-side only: `approvalEngine` still
2107
+ // fires `onUiEvent('ui_approval_request', ...)` so any future
2108
+ // telemetry / daemon-side run_events subscriber will still see the
2109
+ // event. Nothing paints to the chat surface from this method.
2110
+ //
2111
+ // The `_args` parameter is retained for the dispatch signature
2112
+ // contract (`renderUiEvent` calls it positionally) and for the day
2113
+ // we re-introduce a single-paint surface keyed off args.risk_tier.
2114
+ }
2115
+ renderUiToast(args) {
2116
+ const message = typeof args.message === 'string' ? args.message : '';
2117
+ if (!message)
2118
+ return;
2119
+ const kindArg = typeof args.kind === 'string' ? args.kind : 'info';
2120
+ this.commitStreamChunk();
2121
+ const glyph = kindArg === 'success' ? '✓' : kindArg === 'warning' ? '⚠' : kindArg === 'error' ? '✗' : 'ℹ';
2122
+ const kind = kindArg === 'success' ? 'success' : kindArg === 'warning' ? 'warn' : kindArg === 'error' ? 'error' : 'tool';
2123
+ const short = message.length > 120 ? message.slice(0, 119) + '…' : message;
2124
+ this.out.write(this.uiTrailRow(`${glyph} ${short}`, kind));
2125
+ this.streamLastEndedNewline = true;
2126
+ }
2127
+ renderUiArtifactCreated(args) {
2128
+ const path = typeof args.path === 'string' ? args.path : '';
2129
+ if (!path)
2130
+ return;
2131
+ const kindArg = typeof args.kind === 'string' ? args.kind : 'file';
2132
+ const preview = typeof args.preview === 'string' ? args.preview : '';
2133
+ this.commitStreamChunk();
2134
+ const glyph = kindArg === 'skill' ? '🛠' : kindArg === 'directory' ? '📁' : '📄';
2135
+ let out = this.uiTrailRow(`${glyph} Created: ${path}`, 'accent');
2136
+ if (preview) {
2137
+ const shortP = preview.length > 200 ? preview.slice(0, 199) + '…' : preview;
2138
+ out += this.uiTrailRow(` ${shortP}`, 'muted');
2139
+ }
2140
+ this.out.write(out);
2141
+ this.streamLastEndedNewline = true;
2142
+ }
1818
2143
  }
1819
2144
  exports.Display = Display;
1820
2145
  /**