aiden-runtime 4.7.0 → 4.8.0

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.
@@ -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,90 @@ 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
+ segments = [provModel, ctxSegFull, turnSeg, elapsed];
662
+ }
663
+ else {
664
+ segments = [provModel, ctxSegCompact, elapsed];
665
+ }
666
+ return ` ${segments.join(SEP)}`;
667
+ }
668
+ /** Map a per-turn outcome to the colour kind used by the state dot. */
669
+ stateKind(state) {
670
+ if (state === 'ok')
671
+ return 'success';
672
+ if (state === 'warn')
673
+ return 'warn';
674
+ if (state === 'error')
675
+ return 'error';
676
+ return 'muted';
594
677
  }
595
678
  /**
596
679
  * Tier-3.1 (v4.1-tier3.1): pre-prompt status line.
@@ -682,11 +765,15 @@ class Display {
682
765
  * `12:41:02 ▲ <input>`. Default OFF preserves `▲ <input>`.
683
766
  */
684
767
  promptPrefix() {
768
+ // v4.8.0 Slice 7 hotfix #2 — 2-space lead matches the rest of the
769
+ // surface family (▎ Aiden header, status footer, bottom hint).
770
+ // Timestamp variant unchanged — the timestamp gutter already
771
+ // provides its own consistent left edge.
685
772
  const tri = this.skin.applyColors('▲', 'brand');
686
773
  if (process.env.AIDEN_UI_TIMESTAMPS === '1') {
687
774
  return `${this.timestampPrefix()} ${tri} `;
688
775
  }
689
- return `${tri} `;
776
+ return ` ${tri} `;
690
777
  }
691
778
  /**
692
779
  * Phase 26.2.6 — pick a random phrase from `SPINNER_PHRASES`,
@@ -812,69 +899,60 @@ class Display {
812
899
  // (Ns) counter hasn't ticked. Slow enough not to flicker on SSH
813
900
  // / slow ConPTY refresh.
814
901
  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.
902
+ // v4.8.0 Slice 11 leading glyph is no longer a static `⌛` (or
903
+ // a separate 2nd-row wave bar). Now it's a single-row sliding
904
+ // shimmer: a 4-cell brand-orange `█` segment that scrolls L→R
905
+ // across a muted `─` track, wrapping at the right edge. The dots
906
+ // pulse + (Ns) timer keep their roles as secondary motion cues;
907
+ // the shimmer is the primary "something is happening" affordance
908
+ // in TTFT space. Token-sourced from `glyphs.shimmer` so the glyph
909
+ // pair lives next to the rest of the v4.8.0 design system.
817
910
  //
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
- };
911
+ // `opts.waveBar` is preserved as a back-compat option that maps
912
+ // to "shimmer enabled". Pass `{ waveBar: false }` to drop the
913
+ // shimmer cluster and render the bare verb row (the legacy
914
+ // v4.1.4 single-row indicator). Default ON.
915
+ const shimmerEnabled = opts.waveBar !== false;
916
+ const SHIMMER_CELLS = 10;
917
+ const SHIMMER_BLOCK = 4;
918
+ let shimmerFrame = 0;
840
919
  /**
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).
920
+ * v4.8.0 Slice 11 — render the sliding-block shimmer. A 4-cell
921
+ * `█` (U+2588 FULL BLOCK) segment at positions `[frame,
922
+ * frame+1, frame+2, frame+3]` mod 10, on a muted `─` track.
923
+ * Brand-orange block, muted track. Token-sourced glyphs;
924
+ * cell-by-cell paint keeps glyph order true to position so
925
+ * the wrap visibly slides rather than jumping.
847
926
  *
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()`.
927
+ * Heartbeat semantics: this is NOT progress. The block moves
928
+ * at a constant 250ms cadence regardless of any backend metric.
929
+ * It exists purely so the user sees motion during the
930
+ * unobservable TTFT (time-to-first-token) wait. The verb +
931
+ * dot pulse + (Ns) timer carry the real lifecycle signal.
853
932
  */
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.
933
+ const buildShimmer = () => {
861
934
  const filled = new Set();
862
- for (let i = 0; i < WAVE_BLOCK; i += 1) {
863
- filled.add((waveFrame + i) % WAVE_CELLS);
935
+ for (let i = 0; i < SHIMMER_BLOCK; i += 1) {
936
+ filled.add((shimmerFrame + i) % SHIMMER_CELLS);
864
937
  }
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
938
  const cells = [];
871
- for (let c = 0; c < WAVE_CELLS; c += 1) {
939
+ for (let c = 0; c < SHIMMER_CELLS; c += 1) {
872
940
  cells.push(filled.has(c)
873
- ? sk.applyColors('▓', 'brand')
874
- : sk.applyColors('░', 'muted'));
941
+ ? sk.applyColors(tokens_1.glyphs.shimmer.block, 'brand')
942
+ : sk.applyColors(tokens_1.glyphs.shimmer.track, 'muted'));
875
943
  }
876
944
  return cells.join('');
877
945
  };
946
+ const buildLine = () => {
947
+ const dots = '.'.repeat(dotFrame); // 0..3 dots
948
+ const elapsedSec = Math.floor((Date.now() - startTime) / 1000);
949
+ const elapsedStr = elapsedSec >= 1
950
+ ? ` ${sk.applyColors(`(${elapsedSec}s)`, 'muted')}`
951
+ : '';
952
+ // Shimmer prefix (or none, when opts.waveBar === false).
953
+ const prefix = shimmerEnabled ? `${buildShimmer()} ` : '';
954
+ return `${prefix}${verb}${dots.padEnd(3, ' ')}${elapsedStr}`;
955
+ };
878
956
  // v4.1.5 Part 1a — Issue M (Windows ConPTY buffering fix).
879
957
  //
880
958
  // Prior pattern wrote `\r\x1b[K{indicator}` with NO trailing
@@ -905,24 +983,15 @@ class Display {
905
983
  if (stopped || paused || !isTty)
906
984
  return;
907
985
  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
- }
986
+ // v4.8.0 Slice 11shimmer slides 1 cell per tick. Same 250ms
987
+ // cadence as the dot pulse, so block + dots move in visible
988
+ // lockstep. Modulo SHIMMER_CELLS wraps the leading block back
989
+ // to the left edge.
990
+ shimmerFrame = (shimmerFrame + 1) % SHIMMER_CELLS;
991
+ // Single-row layout: walk up 1, erase, repaint, newline. Cursor
992
+ // lands on the row below the indicator, ready for the next
993
+ // tick to walk back up.
994
+ out.write(`${ANSI_UP_ERASE}${buildLine()}\n`);
926
995
  };
927
996
  const startTick = () => {
928
997
  if (stopped || !isTty || tickTimer !== null)
@@ -936,41 +1005,23 @@ class Display {
936
1005
  }
937
1006
  };
938
1007
  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.
1008
+ // Walk up 1 row + erase + drop a newline so the cursor lands
1009
+ // on a blank line BELOW the indicator's old footprint. The
1010
+ // trailing `\n` provides one visible blank row of breathing
1011
+ // space and acts as a Windows ConPTY flush trigger (v4.1.5
1012
+ // Issue M). Slice 11 collapsed the prior 2-row layout to 1.
951
1013
  if (!isTty || !printed)
952
1014
  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
- }
1015
+ out.write(`${ANSI_UP_ERASE}\n`);
959
1016
  };
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.
963
- //
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.
1017
+ // Initial paint — only on TTY. v4.8.0 Slice 11 prepend a blank
1018
+ // `\n` so the indicator gets one visible row of breathing space
1019
+ // above it. Prior behaviour butted the indicator flush against
1020
+ // the user-prompt row, which read as cramped. The trailing `\n`
1021
+ // on the verb row sits the cursor below the indicator, ready
1022
+ // for the first tick to walk back up.
967
1023
  if (isTty) {
968
- if (waveBarEnabled) {
969
- out.write(`${buildLine()}\n${buildWave()}\n`);
970
- }
971
- else {
972
- out.write(`${buildLine()}\n`);
973
- }
1024
+ out.write(`\n${buildLine()}\n`);
974
1025
  printed = true;
975
1026
  startTick();
976
1027
  }
@@ -998,17 +1049,16 @@ class Display {
998
1049
  // Caller has just finished writing its own content (typically
999
1050
  // ending with `\n`), so the cursor is on a fresh line below
1000
1051
  // whatever was there. Paint the indicator + `\n` to claim the
1001
- // current row(s) and leave the cursor on the row below — same
1052
+ // current row and leave the cursor on the row below — same
1002
1053
  // invariant the initial paint and tick maintain. Trailing `\n`
1003
1054
  // also flushes Windows ConPTY buffering (Issue M).
1004
1055
  //
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
- }
1056
+ // v4.8.0 Slice 11single-row layout: paint one row only.
1057
+ // (Initial paint includes a leading `\n` for breathing space;
1058
+ // resume omits it because the caller has already written its
1059
+ // own content above this point and an extra blank would
1060
+ // double up.)
1061
+ out.write(`${buildLine()}\n`);
1012
1062
  printed = true;
1013
1063
  startTick();
1014
1064
  },
@@ -1088,18 +1138,25 @@ class Display {
1088
1138
  // Running row — muted pipe, raw icon, tool-colored verb, muted detail.
1089
1139
  // The optional `running Ns…` tail appears once the tool crosses the
1090
1140
  // 1-second mark; the tick interval below redraws this row every 1s.
1141
+ //
1142
+ // v4.8.0 Slice 11c — double-space between `${glyph}` and `${padVerb}`.
1143
+ // Emoji-class icons (`👁️`, `✏️`, `📋`, `🌐`, etc.) render 2-cell-wide
1144
+ // visually on Windows ConPTY but the cursor/column tracker treats
1145
+ // them as 1 cell, so a single trailing space is visually swallowed
1146
+ // by the emoji's right cell. Two spaces guarantees one visible gap
1147
+ // regardless of how the terminal measures the glyph.
1091
1148
  const runningRow = () => {
1092
1149
  const elapsed = Date.now() - startedAt;
1093
1150
  const liveSuffix = elapsed >= 1000
1094
1151
  ? ` ${sk.applyColors(`running ${formatToolDuration(elapsed)}…`, 'muted')}`
1095
1152
  : '';
1096
- return `${sk.applyColors(toolTrail_1.TRAIL_PIPE, 'muted')} ${glyph} ` +
1153
+ return `${sk.applyColors(toolTrail_1.TRAIL_PIPE, 'muted')} ${glyph} ` +
1097
1154
  `${sk.applyColors((0, toolTrail_1.padVerb)(verb), 'tool')} ` +
1098
1155
  `${sk.applyColors(detail, 'muted')}${liveSuffix}\n`;
1099
1156
  };
1100
1157
  // Outcome row — entire line colored by outcome kind.
1101
1158
  const outcomeRow = (suffix, kind) => {
1102
- const content = `${toolTrail_1.TRAIL_PIPE} ${glyph} ${(0, toolTrail_1.padVerb)(verb)} ${detail}` +
1159
+ const content = `${toolTrail_1.TRAIL_PIPE} ${glyph} ${(0, toolTrail_1.padVerb)(verb)} ${detail}` +
1103
1160
  (suffix ? ` ${suffix}` : '');
1104
1161
  return `${sk.applyColors(content, kind)}\n`;
1105
1162
  };
@@ -1211,8 +1268,10 @@ class Display {
1211
1268
  // back over the retry counter with `running Ns…`.
1212
1269
  stopTick();
1213
1270
  // Update the running row with retry count.
1271
+ // v4.8.0 Slice 11c — double-space between glyph and verb (see
1272
+ // runningRow comment above for the emoji-width rationale).
1214
1273
  eraseLast();
1215
- const content = `${toolTrail_1.TRAIL_PIPE} ${glyph} ${(0, toolTrail_1.padVerb)(verb)} ${detail} retry ${n}/${m} …`;
1274
+ const content = `${toolTrail_1.TRAIL_PIPE} ${glyph} ${(0, toolTrail_1.padVerb)(verb)} ${detail} retry ${n}/${m} …`;
1216
1275
  out.write(sk.applyColors(content, 'warn') + '\n');
1217
1276
  printed = true;
1218
1277
  },
@@ -1326,7 +1385,12 @@ class Display {
1326
1385
  * (post-stream rerender) so both paths produce identical output.
1327
1386
  */
1328
1387
  applyFrameToRendered(rawBody) {
1329
- const indent = (0, frame_1.getIndent)(0);
1388
+ // v4.8.0 Slice 7 hotfix #2 — override frame.GUTTER (3) to 2 cells
1389
+ // locally so Aiden reply prose aligns with the ▎ bar of agentHeader
1390
+ // (col 2). The GUTTER constant stays at 3 for other consumers
1391
+ // (markdown list/blockquote/code-block renderers in frame.ts) where
1392
+ // a 3-cell gutter is part of their own visual algebra.
1393
+ const indent = ' ';
1330
1394
  const bw = (0, frame_1.getBodyWidth)(this.out);
1331
1395
  return rawBody
1332
1396
  .split('\n')
@@ -1507,15 +1571,31 @@ class Display {
1507
1571
  if (!text)
1508
1572
  return;
1509
1573
  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.
1574
+ // Phase 26.2.3 — share the `▎ Aiden` header with non-streaming
1575
+ // agentTurn so streamed + non-streamed responses open identically.
1513
1576
  this.out.write(this.agentHeader());
1514
1577
  this.streamHeaderShown = true;
1515
1578
  this.streamBuffer = '';
1516
1579
  this.streamLineCount = 0;
1580
+ this.uiEventsFiredThisTurn = false;
1581
+ // agentHeader emits trailing `\n\n`; cursor is at col 0 of a fresh
1582
+ // line, so the very next chunk needs the leading indent.
1583
+ this.streamLastEndedNewline = true;
1517
1584
  }
1518
- this.out.write(text);
1585
+ // v4.8.0 Slice 7 hotfix #3 — inject a 2-cell indent at every line
1586
+ // start so streamed content aligns with the ▎ bar in agentHeader.
1587
+ // Pre-this-fix the raw chunk wrote at col 0; the post-stream
1588
+ // rerender in applyFrameToRendered would indent eventually, but
1589
+ // mid-stream the user saw col-0 content. streamBuffer stays raw
1590
+ // (the rerender path applies its own indent).
1591
+ const indent = ' ';
1592
+ let toWrite = text;
1593
+ if (this.streamLastEndedNewline)
1594
+ toWrite = indent + toWrite;
1595
+ const endsNl = toWrite.endsWith('\n');
1596
+ const body = endsNl ? toWrite.slice(0, -1) : toWrite;
1597
+ toWrite = body.replace(/\n/g, '\n' + indent) + (endsNl ? '\n' : '');
1598
+ this.out.write(toWrite);
1519
1599
  this.streamLastEndedNewline = text.endsWith('\n');
1520
1600
  // Phase v4.1-reply-formatting: track buffer + line count for the
1521
1601
  // post-stream re-render.
@@ -1620,6 +1700,15 @@ class Display {
1620
1700
  return;
1621
1701
  if (lines === 0)
1622
1702
  return;
1703
+ // v4.8.0 Phase 2.3 fix — when ui_* events painted this turn, skip the
1704
+ // cursor-up + erase-to-end-of-screen rerender. The eraser wipes
1705
+ // anything below where the stream started, including our event rows.
1706
+ // Tradeoff: assistant text on a ui-event turn stays raw (no in-place
1707
+ // markdown beautification). Acceptable — when the model is using
1708
+ // structured ui events, it's signalling state, not relying on prose
1709
+ // formatting.
1710
+ if (this.uiEventsFiredThisTurn)
1711
+ return;
1623
1712
  // Cheap structural heuristic — only re-render when formatting
1624
1713
  // actually helps. Plain prose chunks stay raw (no flicker).
1625
1714
  //
@@ -1781,6 +1870,8 @@ class Display {
1781
1870
  this.streamLineCount = 0;
1782
1871
  this.streamHeaderShown = false;
1783
1872
  this.streamLastEndedNewline = false;
1873
+ // v4.8.0 Phase 2.3 fix — turn ends; clear the ui-fired flag.
1874
+ this.uiEventsFiredThisTurn = false;
1784
1875
  }
1785
1876
  /**
1786
1877
  * Phase v4.1-reply-formatting: render the optional "Sources"
@@ -1815,6 +1906,197 @@ class Display {
1815
1906
  this.out.write(`${sk.applyColors(`${arrow} ${name}…`, 'tool')}\n`);
1816
1907
  this.streamLastEndedNewline = true;
1817
1908
  }
1909
+ /**
1910
+ * v4.8.0 Phase 2.3 — render a semantic ui_* event signalled by the
1911
+ * model via a uiOnly tool call. Append-only: each event paints one
1912
+ * row; in-place mutation is a v4.8.x upgrade if UX demands it.
1913
+ *
1914
+ * Currently handles `ui_task_update` and `ui_task_done`; other 5
1915
+ * names land in Phase 2.4 (silent ignore until then). Non-TTY out
1916
+ * surfaces silent — matches the activityIndicator precedent.
1917
+ */
1918
+ /**
1919
+ * v4.8.0 Phase 2.3 fix-2 — reset the per-turn ui-event flag. Called
1920
+ * by chatSession at the top of each turn. The existing reset sites
1921
+ * (streamPartial first-delta + streamComplete) only fire when the
1922
+ * turn actually streamed text deltas. Tool-only turns never reset,
1923
+ * leaving the flag sticky into subsequent turns. This is the
1924
+ * authoritative reset for turn-start.
1925
+ */
1926
+ resetUiTurnState() {
1927
+ this.uiEventsFiredThisTurn = false;
1928
+ }
1929
+ renderUiEvent(name, args) {
1930
+ if (!this.out.isTTY)
1931
+ return;
1932
+ // v4.8.0 Phase 2.3 fix — Option C. The post-stream markdown rerender
1933
+ // (`tryRerenderInPlace`) does `cursor-up-N + erase-to-end-of-screen`,
1934
+ // which wipes anything painted between stream start and stream end —
1935
+ // including our ui_* rows. Mark the turn so the rerender skips this
1936
+ // turn entirely. Resets when the next streaming turn begins (see
1937
+ // streamPartial header init) and on streamComplete cleanup.
1938
+ this.uiEventsFiredThisTurn = true;
1939
+ if (name === 'ui_task_update') {
1940
+ this.renderUiTaskUpdate(args);
1941
+ return;
1942
+ }
1943
+ if (name === 'ui_task_done') {
1944
+ this.renderUiTaskDone(args);
1945
+ return;
1946
+ }
1947
+ if (name === 'ui_command_result') {
1948
+ this.renderUiCommandResult(args);
1949
+ return;
1950
+ }
1951
+ if (name === 'ui_test_result') {
1952
+ this.renderUiTestResult(args);
1953
+ return;
1954
+ }
1955
+ if (name === 'ui_approval_request') {
1956
+ this.renderUiApprovalRequest(args);
1957
+ return;
1958
+ }
1959
+ if (name === 'ui_toast') {
1960
+ this.renderUiToast(args);
1961
+ return;
1962
+ }
1963
+ if (name === 'ui_artifact_created') {
1964
+ this.renderUiArtifactCreated(args);
1965
+ return;
1966
+ }
1967
+ // Unknown event names silent-ignore (defensive — future registrations).
1968
+ }
1969
+ /**
1970
+ * v4.8.0 Phase 2.4 polish — build one trail-gutter row matching the
1971
+ * `toolRow` chrome (muted `┊` + space + colored content + `\n`).
1972
+ * Splits on embedded newlines so multi-line surfaces (capped stdout,
1973
+ * preview tails, optional reasons) carry the gutter on every line.
1974
+ */
1975
+ uiTrailRow(content, kind) {
1976
+ const pipe = this.skin.applyColors(toolTrail_1.TRAIL_PIPE, 'muted');
1977
+ return content.split('\n').map(l => `${pipe} ${this.skin.applyColors(l, kind)}\n`).join('');
1978
+ }
1979
+ renderUiTaskUpdate(args) {
1980
+ const taskId = typeof args.task_id === 'string' ? args.task_id : '';
1981
+ const label = typeof args.label === 'string' ? args.label : '';
1982
+ const status = typeof args.status === 'string' ? args.status : '';
1983
+ const kindArg = typeof args.kind === 'string' ? args.kind : 'task';
1984
+ const depth = typeof args.depth === 'number' && args.depth > 0 ? args.depth : 0;
1985
+ if (!taskId || !label)
1986
+ return;
1987
+ this.commitStreamChunk();
1988
+ const glyph = status === 'paused' ? '⏸' : status === 'blocked' ? '⛔' : '⟳';
1989
+ const colorKind = status === 'running' ? 'tool' : 'warn';
1990
+ this.uiTaskRows.set(taskId, { label });
1991
+ const short = label.length > 80 ? label.slice(0, 79) + '…' : label;
1992
+ // v4.8.0 Phase 2.4 — subagent kind: indent by depth inside the
1993
+ // gutter so nested rows tier below their parent.
1994
+ const indent = kindArg === 'subagent' ? ' '.repeat(depth) : '';
1995
+ this.out.write(this.uiTrailRow(`${indent}${glyph} ${short}`, colorKind));
1996
+ this.streamLastEndedNewline = true;
1997
+ }
1998
+ renderUiTaskDone(args) {
1999
+ const taskId = typeof args.task_id === 'string' ? args.task_id : '';
2000
+ const status = typeof args.status === 'string' ? args.status : '';
2001
+ const summary = typeof args.summary === 'string' ? args.summary : '';
2002
+ if (!taskId)
2003
+ return;
2004
+ this.commitStreamChunk();
2005
+ const tracked = this.uiTaskRows.get(taskId);
2006
+ const label = tracked?.label ?? taskId;
2007
+ this.uiTaskRows.delete(taskId);
2008
+ const glyph = status === 'success' ? '✓' : status === 'failure' ? '✗' : '⊘';
2009
+ const kind = status === 'success' ? 'success' :
2010
+ status === 'failure' ? 'error' : 'warn';
2011
+ const shortLabel = label.length > 80 ? label.slice(0, 79) + '…' : label;
2012
+ const shortSum = summary.length > 120 ? summary.slice(0, 119) + '…' : summary;
2013
+ const tail = shortSum ? ` — ${shortSum}` : '';
2014
+ this.out.write(this.uiTrailRow(`${glyph} ${shortLabel}${tail}`, kind));
2015
+ this.streamLastEndedNewline = true;
2016
+ }
2017
+ renderUiCommandResult(args) {
2018
+ const command = typeof args.command === 'string' ? args.command : '';
2019
+ if (!command)
2020
+ return;
2021
+ const stdout = typeof args.stdout === 'string' ? args.stdout : '';
2022
+ const stderr = typeof args.stderr === 'string' ? args.stderr : '';
2023
+ const exitCode = typeof args.exit_code === 'number' ? args.exit_code : 0;
2024
+ this.commitStreamChunk();
2025
+ const ok = exitCode === 0;
2026
+ const cap = (t) => t.split('\n').slice(0, 5).join('\n');
2027
+ let out = this.uiTrailRow(`▸ ${command}`, ok ? 'success' : 'error');
2028
+ if (stdout)
2029
+ out += this.uiTrailRow(cap(stdout), 'muted');
2030
+ if (stderr)
2031
+ out += this.uiTrailRow(cap(stderr), 'error');
2032
+ if (!ok)
2033
+ out += this.uiTrailRow(`(exit ${exitCode})`, 'error');
2034
+ this.out.write(out);
2035
+ this.streamLastEndedNewline = true;
2036
+ }
2037
+ renderUiTestResult(args) {
2038
+ const framework = typeof args.framework === 'string' ? args.framework : '';
2039
+ if (!framework)
2040
+ return;
2041
+ const passed = typeof args.passed === 'number' ? args.passed : 0;
2042
+ const failed = typeof args.failed === 'number' ? args.failed : 0;
2043
+ const skipped = typeof args.skipped === 'number' ? args.skipped : 0;
2044
+ const durationMs = typeof args.duration_ms === 'number' ? args.duration_ms : 0;
2045
+ this.commitStreamChunk();
2046
+ const ok = failed === 0;
2047
+ const parts = [`${passed} passed`, `${failed} failed`];
2048
+ if (skipped > 0)
2049
+ parts.push(`${skipped} skipped`);
2050
+ const dur = durationMs > 0 ? ` in ${durationMs}ms` : '';
2051
+ this.out.write(this.uiTrailRow(`${ok ? '✓' : '✗'} ${framework}: ${parts.join(', ')}${dur}`, ok ? 'success' : 'error'));
2052
+ this.streamLastEndedNewline = true;
2053
+ }
2054
+ renderUiApprovalRequest(args) {
2055
+ const prompt = typeof args.prompt === 'string' ? args.prompt : '';
2056
+ if (!prompt)
2057
+ return;
2058
+ const riskTier = typeof args.risk_tier === 'string' ? args.risk_tier : 'medium';
2059
+ const reason = typeof args.reason === 'string' ? args.reason : '';
2060
+ this.commitStreamChunk();
2061
+ const kind = riskTier === 'low' ? 'success'
2062
+ : (riskTier === 'high' || riskTier === 'critical') ? 'error' : 'warn';
2063
+ const shortP = prompt.length > 160 ? prompt.slice(0, 159) + '…' : prompt;
2064
+ let out = this.uiTrailRow(`⚠ Approval needed: ${shortP}`, kind);
2065
+ if (reason) {
2066
+ const shortR = reason.length > 200 ? reason.slice(0, 199) + '…' : reason;
2067
+ out += this.uiTrailRow(` ${shortR}`, 'muted');
2068
+ }
2069
+ this.out.write(out);
2070
+ this.streamLastEndedNewline = true;
2071
+ }
2072
+ renderUiToast(args) {
2073
+ const message = typeof args.message === 'string' ? args.message : '';
2074
+ if (!message)
2075
+ return;
2076
+ const kindArg = typeof args.kind === 'string' ? args.kind : 'info';
2077
+ this.commitStreamChunk();
2078
+ const glyph = kindArg === 'success' ? '✓' : kindArg === 'warning' ? '⚠' : kindArg === 'error' ? '✗' : 'ℹ';
2079
+ const kind = kindArg === 'success' ? 'success' : kindArg === 'warning' ? 'warn' : kindArg === 'error' ? 'error' : 'tool';
2080
+ const short = message.length > 120 ? message.slice(0, 119) + '…' : message;
2081
+ this.out.write(this.uiTrailRow(`${glyph} ${short}`, kind));
2082
+ this.streamLastEndedNewline = true;
2083
+ }
2084
+ renderUiArtifactCreated(args) {
2085
+ const path = typeof args.path === 'string' ? args.path : '';
2086
+ if (!path)
2087
+ return;
2088
+ const kindArg = typeof args.kind === 'string' ? args.kind : 'file';
2089
+ const preview = typeof args.preview === 'string' ? args.preview : '';
2090
+ this.commitStreamChunk();
2091
+ const glyph = kindArg === 'skill' ? '🛠' : kindArg === 'directory' ? '📁' : '📄';
2092
+ let out = this.uiTrailRow(`${glyph} Created: ${path}`, 'accent');
2093
+ if (preview) {
2094
+ const shortP = preview.length > 200 ? preview.slice(0, 199) + '…' : preview;
2095
+ out += this.uiTrailRow(` ${shortP}`, 'muted');
2096
+ }
2097
+ this.out.write(out);
2098
+ this.streamLastEndedNewline = true;
2099
+ }
1818
2100
  }
1819
2101
  exports.Display = Display;
1820
2102
  /**