@oh-my-pi/pi-coding-agent 15.10.12 → 15.11.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 (158) hide show
  1. package/CHANGELOG.md +90 -4
  2. package/dist/cli.js +869 -825
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/capability/mcp.d.ts +1 -0
  5. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  6. package/dist/types/config/keybindings.d.ts +6 -1
  7. package/dist/types/config/settings-schema.d.ts +66 -34
  8. package/dist/types/export/html/template.generated.d.ts +1 -1
  9. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  10. package/dist/types/extensibility/shared-events.d.ts +2 -2
  11. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  12. package/dist/types/internal-urls/index.d.ts +1 -0
  13. package/dist/types/internal-urls/types.d.ts +1 -1
  14. package/dist/types/irc/bus.d.ts +66 -0
  15. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  16. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  17. package/dist/types/mcp/transports/stdio.d.ts +1 -0
  18. package/dist/types/mcp/types.d.ts +2 -0
  19. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  20. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  21. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  22. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  23. package/dist/types/modes/components/mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  25. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  26. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  27. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  28. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  29. package/dist/types/modes/components/welcome.d.ts +3 -9
  30. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  31. package/dist/types/modes/controllers/tool-args-reveal.d.ts +43 -0
  32. package/dist/types/modes/interactive-mode.d.ts +3 -2
  33. package/dist/types/modes/theme/theme.d.ts +3 -1
  34. package/dist/types/modes/types.d.ts +3 -2
  35. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  36. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  37. package/dist/types/registry/agent-registry.d.ts +16 -5
  38. package/dist/types/session/agent-session.d.ts +35 -30
  39. package/dist/types/session/messages.d.ts +2 -4
  40. package/dist/types/session/session-history-format.d.ts +12 -0
  41. package/dist/types/session/session-manager.d.ts +21 -3
  42. package/dist/types/session/streaming-output.d.ts +23 -0
  43. package/dist/types/task/executor.d.ts +11 -2
  44. package/dist/types/task/index.d.ts +11 -4
  45. package/dist/types/task/output-manager.d.ts +0 -7
  46. package/dist/types/task/repair-args.d.ts +8 -7
  47. package/dist/types/task/types.d.ts +55 -51
  48. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  49. package/dist/types/tools/find.d.ts +0 -11
  50. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  51. package/dist/types/tools/index.d.ts +1 -3
  52. package/dist/types/tools/irc.d.ts +76 -38
  53. package/dist/types/tools/job.d.ts +7 -1
  54. package/dist/types/tools/render-utils.d.ts +22 -0
  55. package/examples/extensions/with-deps/package.json +1 -0
  56. package/package.json +11 -10
  57. package/scripts/bundle-dist.ts +28 -19
  58. package/src/async/index.ts +0 -1
  59. package/src/capability/mcp.ts +1 -0
  60. package/src/cli/gallery-cli.ts +6 -5
  61. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  62. package/src/cli/gallery-fixtures/types.ts +5 -0
  63. package/src/cli.ts +20 -6
  64. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  65. package/src/config/keybindings.ts +6 -1
  66. package/src/config/mcp-schema.json +4 -0
  67. package/src/config/settings-schema.ts +68 -41
  68. package/src/config/settings.ts +7 -0
  69. package/src/edit/renderer.ts +96 -46
  70. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  71. package/src/eval/agent-bridge.ts +3 -16
  72. package/src/eval/js/shared/prelude.txt +1 -1
  73. package/src/eval/py/prelude.py +5 -6
  74. package/src/export/html/template.generated.ts +1 -1
  75. package/src/export/html/template.js +44 -14
  76. package/src/extensibility/custom-tools/types.ts +2 -2
  77. package/src/extensibility/shared-events.ts +2 -2
  78. package/src/internal-urls/docs-index.generated.ts +9 -9
  79. package/src/internal-urls/history-protocol.ts +113 -0
  80. package/src/internal-urls/index.ts +1 -0
  81. package/src/internal-urls/router.ts +3 -1
  82. package/src/internal-urls/types.ts +1 -1
  83. package/src/irc/bus.ts +292 -0
  84. package/src/main.ts +8 -60
  85. package/src/mcp/manager.ts +3 -0
  86. package/src/mcp/oauth-discovery.ts +27 -2
  87. package/src/mcp/oauth-flow.ts +47 -1
  88. package/src/mcp/transports/stdio.ts +3 -0
  89. package/src/mcp/types.ts +2 -0
  90. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  91. package/src/modes/components/assistant-message.ts +15 -0
  92. package/src/modes/components/btw-panel.ts +5 -1
  93. package/src/modes/components/compaction-summary-message.ts +68 -32
  94. package/src/modes/components/custom-editor.ts +10 -0
  95. package/src/modes/components/mcp-add-wizard.ts +13 -0
  96. package/src/modes/components/settings-selector.ts +2 -0
  97. package/src/modes/components/status-line/component.ts +22 -12
  98. package/src/modes/components/status-line/types.ts +3 -0
  99. package/src/modes/components/tool-execution.ts +31 -1
  100. package/src/modes/components/transcript-container.ts +99 -18
  101. package/src/modes/components/tree-selector.ts +6 -1
  102. package/src/modes/components/ttsr-notification.ts +72 -30
  103. package/src/modes/components/welcome.ts +9 -33
  104. package/src/modes/controllers/event-controller.ts +93 -4
  105. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  106. package/src/modes/controllers/input-controller.ts +18 -2
  107. package/src/modes/controllers/mcp-command-controller.ts +34 -2
  108. package/src/modes/controllers/selector-controller.ts +25 -17
  109. package/src/modes/controllers/tool-args-reveal.ts +174 -0
  110. package/src/modes/interactive-mode.ts +17 -15
  111. package/src/modes/theme/theme.ts +24 -5
  112. package/src/modes/types.ts +3 -5
  113. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  114. package/src/modes/utils/ui-helpers.ts +51 -49
  115. package/src/prompts/system/irc-incoming.md +3 -4
  116. package/src/prompts/system/orchestrate-notice.md +2 -2
  117. package/src/prompts/system/subagent-system-prompt.md +0 -5
  118. package/src/prompts/system/system-prompt.md +1 -0
  119. package/src/prompts/system/workflow-notice.md +2 -2
  120. package/src/prompts/tools/eval.md +3 -3
  121. package/src/prompts/tools/irc.md +29 -19
  122. package/src/prompts/tools/read.md +2 -2
  123. package/src/prompts/tools/task-summary.md +5 -16
  124. package/src/prompts/tools/task.md +43 -29
  125. package/src/registry/agent-lifecycle.ts +218 -0
  126. package/src/registry/agent-registry.ts +16 -5
  127. package/src/sdk.ts +29 -9
  128. package/src/session/agent-session.ts +268 -241
  129. package/src/session/messages.ts +11 -78
  130. package/src/session/session-history-format.ts +246 -0
  131. package/src/session/session-manager.ts +59 -5
  132. package/src/session/streaming-output.ts +60 -0
  133. package/src/task/executor.ts +855 -466
  134. package/src/task/index.ts +723 -794
  135. package/src/task/output-manager.ts +0 -11
  136. package/src/task/render.ts +142 -66
  137. package/src/task/repair-args.ts +21 -9
  138. package/src/task/types.ts +73 -66
  139. package/src/tools/ask.ts +4 -2
  140. package/src/tools/bash.ts +15 -5
  141. package/src/tools/browser/tab-worker.ts +26 -7
  142. package/src/tools/browser.ts +28 -1
  143. package/src/tools/find.ts +2 -27
  144. package/src/tools/grouped-file-output.ts +1 -118
  145. package/src/tools/index.ts +4 -12
  146. package/src/tools/irc.ts +596 -171
  147. package/src/tools/job.ts +41 -7
  148. package/src/tools/read.ts +57 -1
  149. package/src/tools/render-utils.ts +56 -0
  150. package/src/tools/renderers.ts +2 -0
  151. package/src/tools/resolve.ts +4 -1
  152. package/src/tools/write.ts +65 -47
  153. package/src/web/search/providers/anthropic.ts +29 -4
  154. package/dist/types/async/support.d.ts +0 -2
  155. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  156. package/dist/types/task/simple-mode.d.ts +0 -8
  157. package/src/async/support.ts +0 -5
  158. package/src/task/simple-mode.ts +0 -27
@@ -1,12 +1,18 @@
1
- import { type Component, Container, type NativeScrollbackLiveRegion, type RenderStablePrefix } from "@oh-my-pi/pi-tui";
1
+ import {
2
+ type Component,
3
+ Container,
4
+ type NativeScrollbackCommittedRows,
5
+ type NativeScrollbackLiveRegion,
6
+ type RenderStablePrefix,
7
+ } from "@oh-my-pi/pi-tui";
2
8
 
3
9
  const kSnapshot = Symbol("transcript.liveDiffSnapshot");
4
10
 
5
11
  /**
6
- * Per-block diff cache: the block's previous stripped contribution plus the
7
- * derived append-only state. Purely an input to {@link deriveLiveCommitState}
8
- * for still-live blocks it is never replayed as render output. Every block
9
- * renders its current content on every frame.
12
+ * Per-block render cache: the block's previous stripped contribution plus the
13
+ * derived append-only state. Still-live blocks use it as input to
14
+ * {@link deriveLiveCommitState}; finalized blocks wholly inside already
15
+ * committed native scrollback can replay it without calling render().
10
16
  */
11
17
  interface LiveDiffSnapshot {
12
18
  width: number;
@@ -47,6 +53,16 @@ interface SnapshotCarrier {
47
53
  */
48
54
  interface FinalizableBlock {
49
55
  isTranscriptBlockFinalized?(): boolean;
56
+ /**
57
+ * Monotonic content version for blocks that can still mutate *after*
58
+ * reporting finalized (e.g. `AssistantMessageComponent`: the inline error
59
+ * restored at the next turn's `agent_start`, late tool-result images). The
60
+ * committed-scrollback render bypass only replays a block's previous rows
61
+ * when the version is unchanged; without this signal a post-finalize
62
+ * mutation would stay invisible until a global invalidation. Blocks that
63
+ * never mutate post-finalize simply omit the method.
64
+ */
65
+ getTranscriptBlockVersion?(): number;
50
66
  }
51
67
 
52
68
  function isBlockFinalized(child: Component): boolean {
@@ -54,6 +70,11 @@ function isBlockFinalized(child: Component): boolean {
54
70
  return fn ? fn.call(child) : true;
55
71
  }
56
72
 
73
+ function getBlockVersion(child: Component): number | undefined {
74
+ const fn = (child as Component & FinalizableBlock).getTranscriptBlockVersion;
75
+ return fn ? fn.call(child) : undefined;
76
+ }
77
+
57
78
  // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
58
79
  // separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
59
80
  // to a background-colored padding row, whose escape sequences contain `\S` and
@@ -87,11 +108,16 @@ interface BlockSegment {
87
108
  rawRef: readonly string[];
88
109
  contribution: readonly string[];
89
110
  width: number;
111
+ generation: number;
90
112
  /** Frame row of this block's first emitted row (the separator when present). */
91
113
  startRow: number;
92
114
  /** Rows emitted: separator + contribution (0 for empty contributions). */
93
115
  rowCount: number;
94
116
  sep: number;
117
+ /** Whether the block reported finalized when this segment was rendered. */
118
+ finalized: boolean;
119
+ /** Block version observed when this segment was rendered (see {@link FinalizableBlock}). */
120
+ version: number | undefined;
95
121
  }
96
122
 
97
123
  const EMPTY_SEGMENTS: BlockSegment[] = [];
@@ -369,7 +395,10 @@ function deriveLiveCommitState(
369
395
  * through {@link RenderStablePrefix} so the engine can skip marker scanning,
370
396
  * line preparation, and the committed-prefix audit for those rows.
371
397
  */
372
- export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion, RenderStablePrefix {
398
+ export class TranscriptContainer
399
+ extends Container
400
+ implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix
401
+ {
373
402
  // Bumped to retire every block's diff snapshot at once (theme change /
374
403
  // clear); a snapshot is only honored when its stored generation matches.
375
404
  #generation = 0;
@@ -390,6 +419,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
390
419
  #lines: string[] = [];
391
420
  #segments: BlockSegment[] = EMPTY_SEGMENTS;
392
421
  #renderWidth = -1;
422
+ // Local rows already committed to native scrollback by the previous frame.
423
+ // Finalized blocks wholly before this boundary are immutable on-screen history;
424
+ // their previous contribution can be replayed without calling render().
425
+ #committedRows = 0;
393
426
  // Stable-prefix floor accumulated across renders since the last
394
427
  // getRenderStablePrefixRows() read (see RenderStablePrefix: reading
395
428
  // consumes the report and re-bases the baseline). Out-of-band renders
@@ -407,6 +440,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
407
440
  super.clear();
408
441
  }
409
442
 
443
+ setNativeScrollbackCommittedRows(rows: number): void {
444
+ this.#committedRows = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
445
+ }
446
+
410
447
  getRenderStablePrefixRows(): number {
411
448
  const value = Math.min(this.#stableRowsFloor, this.#lines.length);
412
449
  this.#stableRowsFloor = this.#lines.length;
@@ -497,21 +534,43 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
497
534
 
498
535
  // This child's contribution: its current render with plain-blank
499
536
  // top/bottom edges stripped (the container owns inter-block gaps).
500
- // Always the latest content committed history keeps whatever bytes
501
- // it was written with, but the window must reflect the present state
502
- // (late tool results, post-finalize re-layouts, expand toggles).
503
- // A block whose render returned the same array reference reuses the
504
- // previously stripped contribution (same ref identical rows).
537
+ // Finalized blocks wholly inside committed native scrollback can reuse
538
+ // their previous contribution without calling render(): those rows are
539
+ // immutable terminal history for the current width/generation. Blocks
540
+ // outside committed history still render normally so late results,
541
+ // post-finalize re-layouts, and expand toggles remain visible.
505
542
  const previousSnapshot = child[kSnapshot];
506
- const raw = child.render(width);
507
543
  const previous = previousSegments[i];
508
- const reusable =
544
+ const finalized = isBlockFinalized(child);
545
+ const version = getBlockVersion(child);
546
+ const committedReusable =
509
547
  previous !== undefined &&
510
548
  previous.component === child &&
511
- previous.rawRef === raw &&
512
- previous.width === width;
549
+ previous.width === width &&
550
+ previous.generation === this.#generation &&
551
+ previous.startRow === row &&
552
+ previous.startRow + previous.rowCount <= this.#committedRows &&
553
+ finalized &&
554
+ // Only replay bytes that were themselves produced by a finalized
555
+ // render: a block finalizing between frames may have changed content
556
+ // while its rows were already committed via the append-only live
557
+ // path, so the first post-transition frame must render. Defense in
558
+ // depth on the transcript side — the TUI commit policy should keep
559
+ // that window closed, but the safety must not live there alone.
560
+ previous.finalized &&
561
+ // Post-finalize mutations (inline error restore, late tool images)
562
+ // bump the block version; a mismatch forces a real render so the
563
+ // committed-prefix audit can observe and re-anchor the change.
564
+ previous.version === version;
565
+ const raw = committedReusable ? previous.rawRef : child.render(width);
566
+ const reusable =
567
+ committedReusable ||
568
+ (previous !== undefined &&
569
+ previous.component === child &&
570
+ previous.rawRef === raw &&
571
+ previous.width === width &&
572
+ previous.generation === this.#generation);
513
573
  const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
514
- const finalized = isBlockFinalized(child);
515
574
  let liveCommitState: LiveCommitState | undefined;
516
575
  if (i >= liveStartIndex && !finalized) {
517
576
  liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
@@ -540,7 +599,18 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
540
599
  lines.length = row;
541
600
  }
542
601
  if (chainStable) stableRows = row;
543
- segments[i] = { component: child, rawRef: raw, contribution, width, startRow: row, rowCount: 0, sep: 0 };
602
+ segments[i] = {
603
+ component: child,
604
+ rawRef: raw,
605
+ contribution,
606
+ width,
607
+ generation: this.#generation,
608
+ startRow: row,
609
+ rowCount: 0,
610
+ sep: 0,
611
+ finalized,
612
+ version,
613
+ };
544
614
  continue;
545
615
  }
546
616
 
@@ -584,7 +654,18 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
584
654
  if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
585
655
  }
586
656
 
587
- segments[i] = { component: child, rawRef: raw, contribution, width, startRow: row, rowCount, sep };
657
+ segments[i] = {
658
+ component: child,
659
+ rawRef: raw,
660
+ contribution,
661
+ width,
662
+ generation: this.#generation,
663
+ startRow: row,
664
+ rowCount,
665
+ sep,
666
+ finalized,
667
+ version,
668
+ };
588
669
  row += rowCount;
589
670
  }
590
671
  // Trailing shrink: blocks removed from the tail leave stale rows behind
@@ -518,6 +518,7 @@ class TreeList implements Component {
518
518
  const renderedIndent = Math.min(displayIndent, maxIndentLevels);
519
519
  const scrollOffset = displayIndent - renderedIndent;
520
520
  const connectorPositionDisplay = hasConnector ? renderedIndent - 1 : -1;
521
+ const chainGutter = !hasConnector ? flatNode.gutters[flatNode.gutters.length - 1] : undefined;
521
522
 
522
523
  // Build prefix char by char, placing gutters and connector at their positions
523
524
  const totalChars = renderedIndent * 3;
@@ -530,8 +531,12 @@ class TreeList implements Component {
530
531
  // Check if there's a gutter at this level (translated to original tree depth)
531
532
  const gutter = flatNode.gutters.find(g => g.position === originalLevel);
532
533
  if (gutter) {
534
+ // Chain rows (no connector of their own) extend only their
535
+ // nearest connector gutter so the flattened conversation flow
536
+ // stays anchored without reviving unrelated `└─` ancestors (#2298).
537
+ const showVertical = gutter.show || gutter === chainGutter;
533
538
  if (posInLevel === 0) {
534
- prefixChars.push(gutter.show ? theme.tree.vertical : " ");
539
+ prefixChars.push(showVertical ? theme.tree.vertical : " ");
535
540
  } else {
536
541
  prefixChars.push(" ");
537
542
  }
@@ -2,16 +2,24 @@ import { Box, Container, Spacer, Text } from "@oh-my-pi/pi-tui";
2
2
  import type { Rule } from "../../capability/rule";
3
3
  import { theme } from "../../modes/theme/theme";
4
4
 
5
+ /** Collapsed view shows at most this many rules before eliding the rest. */
6
+ const MAX_COLLAPSED_RULES = 4;
7
+
5
8
  /**
6
9
  * Component that renders a TTSR (Time Traveling Stream Rules) notification.
7
10
  * Shows when a rule violation is detected and the stream is being rewound.
11
+ * One block can carry several rules: a single event may match multiple rules,
12
+ * and consecutive notifications merge into the previous block via
13
+ * {@link addRules} while it is still the live transcript tail.
8
14
  */
9
15
  export class TtsrNotificationComponent extends Container {
10
16
  #box: Box;
11
17
  #expanded = false;
18
+ #rules: Rule[];
12
19
 
13
- constructor(private readonly rules: Rule[]) {
20
+ constructor(rules: Rule[]) {
14
21
  super();
22
+ this.#rules = [...rules];
15
23
 
16
24
  this.addChild(new Spacer(1));
17
25
 
@@ -22,6 +30,17 @@ export class TtsrNotificationComponent extends Container {
22
30
  this.#rebuild();
23
31
  }
24
32
 
33
+ /** Merge additional rules into this block (deduped by rule name). */
34
+ addRules(rules: Rule[]): void {
35
+ let changed = false;
36
+ for (const rule of rules) {
37
+ if (this.#rules.some(existing => existing.name === rule.name)) continue;
38
+ this.#rules.push(rule);
39
+ changed = true;
40
+ }
41
+ if (changed) this.#rebuild();
42
+ }
43
+
25
44
  setExpanded(expanded: boolean): void {
26
45
  if (this.#expanded !== expanded) {
27
46
  this.#expanded = expanded;
@@ -35,46 +54,69 @@ export class TtsrNotificationComponent extends Container {
35
54
 
36
55
  #rebuild(): void {
37
56
  this.#box.clear();
57
+ // fg colors conflict with inverse, so styling inside the block is limited
58
+ // to bold (names) and italic (descriptions).
59
+ if (this.#rules.length === 1) {
60
+ this.#rebuildSingle(this.#rules[0]!);
61
+ } else {
62
+ this.#rebuildMulti();
63
+ }
64
+ }
38
65
 
39
- // Build header: warning symbol + rule name + rewind icon
40
- const ruleNames = this.rules.map(r => theme.bold(r.name)).join(", ");
41
- const label = this.rules.length === 1 ? "rule" : "rules";
42
- const header = `${theme.icon.warning} Injecting ${label}: ${ruleNames}`;
66
+ #rebuildSingle(rule: Rule): void {
67
+ const header = `${theme.icon.warning} Injecting rule: ${theme.bold(rule.name)} ${theme.icon.rewind}`;
68
+ this.#box.addChild(new Text(header, 0, 0));
43
69
 
44
- // Create header with rewind icon on the right
45
- const rewindIcon = theme.icon.rewind;
70
+ const desc = (rule.description || rule.content)?.trim();
71
+ if (!desc) return;
46
72
 
47
- this.#box.addChild(new Text(`${header} ${rewindIcon}`, 0, 0));
73
+ let displayText = desc;
74
+ let truncated = false;
75
+ if (!this.#expanded) {
76
+ const lines = desc.split("\n");
77
+ if (lines.length > 2) {
78
+ displayText = `${lines.slice(0, 2).join("\n")}…`;
79
+ truncated = true;
80
+ }
81
+ }
48
82
 
49
- // Show description(s) - italic and truncated
50
- for (const rule of this.rules) {
51
- const desc = rule.description || rule.content;
52
- if (desc) {
53
- this.#box.addChild(new Spacer(1));
83
+ this.#box.addChild(new Spacer(1));
84
+ this.#box.addChild(new Text(theme.italic(displayText), 0, 0));
85
+ if (truncated) {
86
+ this.#box.addChild(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0));
87
+ }
88
+ }
89
+
90
+ #rebuildMulti(): void {
91
+ const header = `${theme.icon.warning} Injecting ${this.#rules.length} rules: ${theme.icon.rewind}`;
92
+ this.#box.addChild(new Text(header, 0, 0));
93
+ this.#box.addChild(new Spacer(1));
54
94
 
55
- let displayText = desc.trim();
95
+ const visible = this.#expanded ? this.#rules : this.#rules.slice(0, MAX_COLLAPSED_RULES);
96
+ let elidedDetail = false;
97
+ for (const rule of visible) {
98
+ const desc = (rule.description || rule.content)?.trim();
99
+ let line = theme.bold(rule.name);
100
+ if (desc) {
101
+ let displayText = desc;
56
102
  if (!this.#expanded) {
57
- // Truncate to first 2 lines
58
- const lines = displayText.split("\n");
59
- if (lines.length > 2) {
60
- displayText = `${lines.slice(0, 2).join("\n")}…`;
103
+ // One line per rule when collapsed; full description when expanded.
104
+ const newline = desc.indexOf("\n");
105
+ if (newline !== -1) {
106
+ displayText = `${desc.slice(0, newline).trimEnd()}…`;
107
+ elidedDetail = true;
61
108
  }
62
109
  }
63
-
64
- // Use italic for subtle distinction (fg colors conflict with inverse)
65
- this.#box.addChild(new Text(theme.italic(displayText), 0, 0));
110
+ line += `: ${theme.italic(displayText)}`;
66
111
  }
112
+ this.#box.addChild(new Text(line, 0, 0));
67
113
  }
68
114
 
69
- // Show expand hint if collapsed and there's more content
70
- if (!this.#expanded) {
71
- const hasMoreContent = this.rules.some(r => {
72
- const desc = r.description || r.content;
73
- return desc && desc.split("\n").length > 2;
74
- });
75
- if (hasMoreContent) {
76
- this.#box.addChild(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0));
77
- }
115
+ const hidden = this.#rules.length - visible.length;
116
+ if (hidden > 0) {
117
+ this.#box.addChild(new Text(theme.italic(`… +${hidden} more (ctrl+o to expand)`), 0, 0));
118
+ } else if (elidedDetail) {
119
+ this.#box.addChild(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0));
78
120
  }
79
121
  }
80
122
  }
@@ -18,14 +18,8 @@ const TIPS: readonly string[] = tipsText
18
18
  .filter(line => line.length > 0);
19
19
 
20
20
  /**
21
- * Tip chosen once per process so the pre-TUI startup splash and the in-TUI
22
- * welcome screen show the same tip instead of shuffling on the swap.
23
- */
24
- const PROCESS_TIP: string | undefined = TIPS.length > 0 ? TIPS[Math.floor(Math.random() * TIPS.length)] : undefined;
25
-
26
- /**
27
- * Fixed number of session rows in the welcome box so its height doesn't shift
28
- * between the pre-TUI splash (loading placeholder) and the loaded state.
21
+ * Fixed number of session rows in the welcome box so its height stays stable
22
+ * across recent-session updates.
29
23
  */
30
24
  export const WELCOME_SESSION_SLOTS = 4;
31
25
 
@@ -76,10 +70,8 @@ export interface LspServerInfo {
76
70
  export class WelcomeComponent implements Component {
77
71
  #animStart: number | null = null;
78
72
  #animTimer: ReturnType<typeof setInterval> | null = null;
79
- /** When set, a non-animating render shows the intro's first frame instead of the resting frame. */
80
- #holdIntroFirstFrame = false;
81
- /** Per-process tip so re-renders (intro, LSP updates, splash swap) don't shuffle it. */
82
- readonly #tip: string | undefined = PROCESS_TIP;
73
+ /** Tip chosen once per instance so re-renders (intro, LSP updates) don't shuffle it. */
74
+ readonly #tip: string | undefined = TIPS.length > 0 ? TIPS[Math.floor(Math.random() * TIPS.length)] : undefined;
83
75
  // Render cache: the welcome box is the first transcript-area component, so
84
76
  // returning a stable array reference keeps the whole frame prefix stable.
85
77
  // Bypassed while the intro animation runs (every frame differs).
@@ -90,7 +82,7 @@ export class WelcomeComponent implements Component {
90
82
  private readonly version: string,
91
83
  private modelName: string,
92
84
  private providerName: string,
93
- private recentSessions: RecentSession[] | null = [],
85
+ private recentSessions: RecentSession[] = [],
94
86
  private lspServers: LspServerInfo[] = [],
95
87
  ) {}
96
88
 
@@ -99,16 +91,6 @@ export class WelcomeComponent implements Component {
99
91
  this.#cachedLines = undefined;
100
92
  }
101
93
 
102
- /**
103
- * Freeze the logo on the intro animation's first frame. The pre-TUI startup
104
- * splash uses this so the in-TUI intro — which starts at that exact frame —
105
- * picks up seamlessly from the splash's static box.
106
- */
107
- holdIntroFirstFrame(): void {
108
- this.#holdIntroFirstFrame = true;
109
- this.invalidate();
110
- }
111
-
112
94
  /**
113
95
  * Play a one-shot intro that sweeps the gradient through every phase
114
96
  * before settling on the resting frame. Safe to call multiple times —
@@ -116,7 +98,6 @@ export class WelcomeComponent implements Component {
116
98
  */
117
99
  playIntro(requestRender: () => void): void {
118
100
  this.#stopAnimation();
119
- this.#holdIntroFirstFrame = false;
120
101
  this.#animStart = performance.now();
121
102
  requestRender();
122
103
  this.#animTimer = setInterval(() => {
@@ -217,9 +198,7 @@ export class WelcomeComponent implements Component {
217
198
 
218
199
  // Recent sessions content
219
200
  const sessionLines: string[] = [];
220
- if (this.recentSessions === null) {
221
- sessionLines.push(` ${theme.fg("dim", "Loading…")}`);
222
- } else if (this.recentSessions.length === 0) {
201
+ if (this.recentSessions.length === 0) {
223
202
  sessionLines.push(` ${theme.fg("dim", "No recent sessions")}`);
224
203
  } else {
225
204
  // Reserve width for the bullet prefix (" • ") and the trailing " (timeAgo)"
@@ -238,7 +217,7 @@ export class WelcomeComponent implements Component {
238
217
  );
239
218
  }
240
219
  }
241
- // Pad to the fixed slot count so the box doesn't grow when sessions load in.
220
+ // Pad to the fixed slot count so the box height doesn't depend on session count.
242
221
  while (sessionLines.length < WELCOME_SESSION_SLOTS) {
243
222
  sessionLines.push("");
244
223
  }
@@ -377,9 +356,9 @@ export class WelcomeComponent implements Component {
377
356
  return str + padding(width - visLen);
378
357
  }
379
358
 
380
- /** Pick the logo frame for the current intro phase, or the resting/held frame. */
359
+ /** Pick the logo frame for the current intro phase, or the resting frame. */
381
360
  #currentLogoFrame(): readonly string[] {
382
- if (this.#animStart == null) return this.#holdIntroFirstFrame ? INTRO_FIRST_FRAME : REST_FRAME;
361
+ if (this.#animStart == null) return REST_FRAME;
383
362
  const elapsed = performance.now() - this.#animStart;
384
363
  if (elapsed >= INTRO_MS) return REST_FRAME;
385
364
  return introLogoFrame(elapsed / INTRO_MS);
@@ -510,8 +489,5 @@ function introLogoFrame(progress: number): string[] {
510
489
  return gradientLogo(PI_LOGO, phase, { strength: shineStrength, pos: shinePos });
511
490
  }
512
491
 
513
- /** First intro frame, cached for splash-held renders (resize re-renders reuse it). */
514
- const INTRO_FIRST_FRAME = introLogoFrame(0);
515
-
516
492
  /** Resting gradient frame, cached for re-renders outside of the intro. */
517
493
  const REST_FRAME = gradientLogo(PI_LOGO, 0);