@oh-my-pi/pi-coding-agent 15.10.12 → 15.11.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +60 -3
  2. package/dist/cli.js +841 -803
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  5. package/dist/types/config/keybindings.d.ts +6 -1
  6. package/dist/types/config/settings-schema.d.ts +56 -33
  7. package/dist/types/export/html/template.generated.d.ts +1 -1
  8. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/types.d.ts +1 -1
  13. package/dist/types/irc/bus.d.ts +66 -0
  14. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  15. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  16. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  17. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  18. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  19. package/dist/types/modes/components/welcome.d.ts +3 -9
  20. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  21. package/dist/types/modes/interactive-mode.d.ts +3 -2
  22. package/dist/types/modes/theme/theme.d.ts +2 -1
  23. package/dist/types/modes/types.d.ts +3 -2
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  25. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  26. package/dist/types/registry/agent-registry.d.ts +16 -5
  27. package/dist/types/session/agent-session.d.ts +35 -30
  28. package/dist/types/session/messages.d.ts +2 -4
  29. package/dist/types/session/session-history-format.d.ts +12 -0
  30. package/dist/types/session/session-manager.d.ts +21 -3
  31. package/dist/types/session/streaming-output.d.ts +23 -0
  32. package/dist/types/task/executor.d.ts +11 -2
  33. package/dist/types/task/index.d.ts +11 -4
  34. package/dist/types/task/output-manager.d.ts +0 -7
  35. package/dist/types/task/repair-args.d.ts +8 -7
  36. package/dist/types/task/types.d.ts +55 -51
  37. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  38. package/dist/types/tools/find.d.ts +0 -11
  39. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  40. package/dist/types/tools/index.d.ts +1 -3
  41. package/dist/types/tools/irc.d.ts +76 -38
  42. package/dist/types/tools/job.d.ts +7 -1
  43. package/examples/extensions/with-deps/package.json +1 -0
  44. package/package.json +11 -10
  45. package/scripts/bundle-dist.ts +28 -19
  46. package/src/async/index.ts +0 -1
  47. package/src/cli/gallery-cli.ts +1 -1
  48. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  49. package/src/cli/gallery-fixtures/types.ts +5 -0
  50. package/src/cli.ts +20 -6
  51. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  52. package/src/config/keybindings.ts +6 -1
  53. package/src/config/settings-schema.ts +56 -40
  54. package/src/config/settings.ts +7 -0
  55. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  56. package/src/eval/agent-bridge.ts +3 -16
  57. package/src/eval/js/shared/prelude.txt +1 -1
  58. package/src/eval/py/prelude.py +5 -6
  59. package/src/export/html/template.generated.ts +1 -1
  60. package/src/export/html/template.js +38 -13
  61. package/src/extensibility/custom-tools/types.ts +2 -2
  62. package/src/extensibility/shared-events.ts +2 -2
  63. package/src/internal-urls/docs-index.generated.ts +8 -8
  64. package/src/internal-urls/history-protocol.ts +113 -0
  65. package/src/internal-urls/index.ts +1 -0
  66. package/src/internal-urls/router.ts +3 -1
  67. package/src/internal-urls/types.ts +1 -1
  68. package/src/irc/bus.ts +292 -0
  69. package/src/main.ts +8 -60
  70. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  71. package/src/modes/components/compaction-summary-message.ts +68 -32
  72. package/src/modes/components/custom-editor.ts +10 -0
  73. package/src/modes/components/tool-execution.ts +31 -1
  74. package/src/modes/components/ttsr-notification.ts +72 -30
  75. package/src/modes/components/welcome.ts +9 -33
  76. package/src/modes/controllers/event-controller.ts +65 -0
  77. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  78. package/src/modes/controllers/input-controller.ts +18 -2
  79. package/src/modes/controllers/selector-controller.ts +21 -17
  80. package/src/modes/interactive-mode.ts +8 -13
  81. package/src/modes/theme/theme.ts +18 -5
  82. package/src/modes/types.ts +3 -5
  83. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  84. package/src/modes/utils/ui-helpers.ts +51 -49
  85. package/src/prompts/system/irc-incoming.md +3 -4
  86. package/src/prompts/system/orchestrate-notice.md +2 -2
  87. package/src/prompts/system/subagent-system-prompt.md +0 -5
  88. package/src/prompts/system/system-prompt.md +1 -0
  89. package/src/prompts/system/workflow-notice.md +2 -2
  90. package/src/prompts/tools/eval.md +3 -3
  91. package/src/prompts/tools/irc.md +29 -19
  92. package/src/prompts/tools/read.md +2 -2
  93. package/src/prompts/tools/task-summary.md +5 -16
  94. package/src/prompts/tools/task.md +38 -29
  95. package/src/registry/agent-lifecycle.ts +218 -0
  96. package/src/registry/agent-registry.ts +16 -5
  97. package/src/sdk.ts +29 -9
  98. package/src/session/agent-session.ts +243 -237
  99. package/src/session/messages.ts +11 -78
  100. package/src/session/session-history-format.ts +246 -0
  101. package/src/session/session-manager.ts +59 -5
  102. package/src/session/streaming-output.ts +60 -0
  103. package/src/task/executor.ts +855 -466
  104. package/src/task/index.ts +718 -794
  105. package/src/task/output-manager.ts +0 -11
  106. package/src/task/render.ts +133 -63
  107. package/src/task/repair-args.ts +21 -9
  108. package/src/task/types.ts +73 -66
  109. package/src/tools/ask.ts +4 -2
  110. package/src/tools/bash.ts +15 -5
  111. package/src/tools/browser/tab-worker.ts +26 -7
  112. package/src/tools/browser.ts +28 -1
  113. package/src/tools/find.ts +2 -27
  114. package/src/tools/grouped-file-output.ts +1 -118
  115. package/src/tools/index.ts +4 -12
  116. package/src/tools/irc.ts +596 -171
  117. package/src/tools/job.ts +41 -7
  118. package/src/tools/read.ts +57 -1
  119. package/src/tools/renderers.ts +2 -0
  120. package/src/tools/resolve.ts +4 -1
  121. package/dist/types/async/support.d.ts +0 -2
  122. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  123. package/dist/types/task/simple-mode.d.ts +0 -8
  124. package/src/async/support.ts +0 -5
  125. package/src/task/simple-mode.ts +0 -27
@@ -1,51 +1,87 @@
1
- import { Box, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
1
+ import { Box, type Component, Markdown } from "@oh-my-pi/pi-tui";
2
2
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
3
  import type { CompactionSummaryMessage } from "../../session/messages";
4
4
 
5
5
  /**
6
- * Component that renders a compaction message with collapsed/expanded state.
7
- * Uses same background color as hook messages for visual consistency.
6
+ * Compaction point in the transcript, rendered as a slim horizontal divider:
7
+ *
8
+ * ──────── 📷 compacted · ctrl+o ────────
9
+ *
10
+ * The conversation above the divider stays visible (display transcript keeps
11
+ * full history); only the LLM context was reset. Expanding (ctrl+o) reveals
12
+ * the compaction summary below the divider.
8
13
  */
9
- export class CompactionSummaryMessageComponent extends Box {
14
+ export class CompactionSummaryMessageComponent implements Component {
10
15
  #expanded = false;
16
+ #cache?: { width: number; lines: string[] };
17
+ #detail?: Box;
11
18
 
12
- constructor(private readonly message: CompactionSummaryMessage) {
13
- super(1, 1, t => theme.bg("customMessageBg", t));
14
- this.#updateDisplay();
15
- }
19
+ constructor(private readonly message: CompactionSummaryMessage) {}
16
20
 
17
21
  setExpanded(expanded: boolean): void {
22
+ if (this.#expanded === expanded) return;
18
23
  this.#expanded = expanded;
19
- this.#updateDisplay();
24
+ this.#cache = undefined;
20
25
  }
21
26
 
22
- override invalidate(): void {
23
- super.invalidate();
24
- this.#updateDisplay();
27
+ invalidate(): void {
28
+ this.#cache = undefined;
29
+ // Theme may have changed — rebuild the detail box lazily on next render.
30
+ this.#detail = undefined;
25
31
  }
26
32
 
27
- #updateDisplay(): void {
28
- this.clear();
33
+ render(width: number): readonly string[] {
34
+ width = Math.max(1, width);
35
+ if (this.#cache?.width === width) {
36
+ return this.#cache.lines;
37
+ }
38
+ const lines = this.#expanded
39
+ ? ["", this.#divider(width), "", ...this.#detailBox().render(width)]
40
+ : ["", this.#divider(width), ""];
41
+ this.#cache = { width, lines };
42
+ return lines;
43
+ }
29
44
 
30
- const tokenStr = this.message.tokensBefore.toLocaleString();
31
- const label = theme.fg("customMessageLabel", theme.bold("[compaction]"));
32
- this.addChild(new Text(label, 0, 0));
33
- this.addChild(new Spacer(1));
45
+ #divider(width: number): string {
46
+ const rule = theme.tree.horizontal;
47
+ const label = `${theme.icon.camera} compacted`;
48
+ // sep.dot ships pre-padded (" · "); trim so the hint joins with single spaces.
49
+ const hint = `${theme.sep.dot.trim()} ctrl+o`;
50
+ const plainWidth = Bun.stringWidth(`${label} ${hint}`, { countAnsiEscapeCodes: false });
51
+ // ` label hint ` framed by rules on both sides.
52
+ const remaining = width - plainWidth - 2;
53
+ if (remaining < 4) {
54
+ // Too narrow for a framed rule — emit the bare label.
55
+ return theme.fg("muted", label);
56
+ }
57
+ const left = Math.floor(remaining / 2);
58
+ const right = remaining - left;
59
+ return (
60
+ theme.fg("dim", rule.repeat(left)) +
61
+ ` ${theme.fg("muted", label)} ${theme.fg("dim", hint)} ` +
62
+ theme.fg("dim", rule.repeat(right))
63
+ );
64
+ }
34
65
 
35
- if (this.#expanded) {
36
- const header = `**Compacted from ${tokenStr} tokens**\n\n`;
37
- this.addChild(
38
- new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
66
+ #detailBox(): Box {
67
+ if (this.#detail) return this.#detail;
68
+ const box = new Box(1, 1, t => theme.bg("customMessageBg", t));
69
+ const tokenStr = this.message.tokensBefore.toLocaleString();
70
+ const frameCount = this.message.images?.length ?? 0;
71
+ const frameNote =
72
+ frameCount > 0 ? `\n\n_${frameCount} snapcompact frame${frameCount === 1 ? "" : "s"} attached_` : "";
73
+ box.addChild(
74
+ new Markdown(
75
+ `**Compacted from ${tokenStr} tokens**\n\n${this.message.summary}${frameNote}`,
76
+ 0,
77
+ 0,
78
+ getMarkdownTheme(),
79
+ {
39
80
  color: (text: string) => theme.fg("customMessageText", text),
40
- }),
41
- );
42
- } else {
43
- this.addChild(
44
- new Text(theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (ctrl+o to expand)`), 0, 0),
45
- );
46
- if (this.message.shortSummary) {
47
- this.addChild(new Text(theme.fg("customMessageText", this.message.shortSummary), 0, 1));
48
- }
49
- }
81
+ },
82
+ ),
83
+ );
84
+ this.#detail = box;
85
+ return box;
50
86
  }
51
87
  }
@@ -175,6 +175,8 @@ export class CustomEditor extends Editor {
175
175
  onDequeue?: () => void;
176
176
  /** Called when Caps Lock is pressed. */
177
177
  onCapsLock?: () => void;
178
+ /** Called when left-arrow is pressed while the editor is empty (cursor necessarily at start). */
179
+ onLeftAtStart?: () => void;
178
180
 
179
181
  /** Custom key handlers from extensions and non-built-in app actions. */
180
182
  #customKeyHandlers = new Map<KeyId, () => void>();
@@ -257,6 +259,14 @@ export class CustomEditor extends Editor {
257
259
  const parsedKey = parseKey(data);
258
260
  const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined;
259
261
 
262
+ // Left-arrow on an empty editor: surface for the agent-hub double-tap
263
+ // gesture. Plain "left" only — modified arrows and any in-text cursor
264
+ // movement fall through to normal handling.
265
+ if (canonical === "left" && this.onLeftAtStart && this.getText().trim() === "") {
266
+ this.onLeftAtStart();
267
+ return;
268
+ }
269
+
260
270
  if (canonical !== undefined) {
261
271
  // Intercept configured image paste (async - fires and handles result)
262
272
  if (this.#matchesAction(canonical, "app.clipboard.pasteImage") && this.onPasteImage) {
@@ -19,6 +19,7 @@ import type { Theme } from "../../modes/theme/theme";
19
19
  import { theme } from "../../modes/theme/theme";
20
20
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
21
21
  import { EVAL_DEFAULT_PREVIEW_LINES } from "../../tools/eval";
22
+ import { isWaitingPollDetails } from "../../tools/job";
22
23
  import {
23
24
  formatArgsInline,
24
25
  JSON_TREE_MAX_DEPTH_COLLAPSED,
@@ -194,6 +195,11 @@ export class ToolExecutionComponent extends Container {
194
195
  // sealed the block stays in the transcript's repaintable live region so a
195
196
  // late result still repaints instead of stranding the streaming preview.
196
197
  #sealed = false;
198
+ // A `job` poll result whose watched jobs are all still running. Such a
199
+ // block never finalizes (stays in the transcript live region) so a
200
+ // follow-up `job` call can displace it instead of stacking another
201
+ // "waiting on N jobs" frame. Cleared by `seal()`.
202
+ #displaceable = false;
197
203
  #renderState: {
198
204
  spinnerFrame?: number;
199
205
  expanded: boolean;
@@ -359,6 +365,11 @@ export class ToolExecutionComponent extends Container {
359
365
  ): void {
360
366
  this.#result = result;
361
367
  this.#isPartial = isPartial;
368
+ // A `job` poll that found every watched job still running is transient
369
+ // "still waiting" chrome; keep the block displaceable so the next `job`
370
+ // call replaces it instead of stacking another waiting frame (see the
371
+ // event controller's displaceable-poll bookkeeping).
372
+ this.#displaceable = this.#toolName === "job" && result.isError !== true && isWaitingPollDetails(result.details);
362
373
  // When tool is complete, ensure args are marked complete so spinner stops
363
374
  if (!isPartial) {
364
375
  this.#argsComplete = true;
@@ -425,7 +436,11 @@ export class ToolExecutionComponent extends Container {
425
436
  (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
426
437
  const isBackgroundAsyncTask = this.#toolName === "task" && isBackgroundAsyncRunning;
427
438
  const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
428
- const needsSpinner = isStreamingArgs || isPartialTask;
439
+ // A displaceable waiting poll keeps its spinner ticking: it reads as one
440
+ // persistent live poll, and the changing leading glyph keeps the
441
+ // transcript's stable-prefix ratchet from committing rows of a block
442
+ // that a follow-up `job` call may remove.
443
+ const needsSpinner = isStreamingArgs || isPartialTask || this.isDisplaceableBlock();
429
444
  if (needsSpinner && !this.#spinnerInterval) {
430
445
  const now = performance.now();
431
446
  const frameCount = theme.spinnerFrames.length;
@@ -513,6 +528,9 @@ export class ToolExecutionComponent extends Container {
513
528
  isTranscriptBlockFinalized(): boolean {
514
529
  if (this.#sealed) return true;
515
530
  if (this.#result === undefined) return false;
531
+ // A displaceable waiting poll stays live: its rows are kept out of
532
+ // native scrollback so a follow-up `job` call can remove the block.
533
+ if (this.#displaceable) return false;
516
534
  if (!this.#isPartial) return true;
517
535
  // Partial result: a background async tool is accepted to freeze (the agent
518
536
  // continues while it runs and would otherwise pin an unbounded live region);
@@ -528,11 +546,23 @@ export class ToolExecutionComponent extends Container {
528
546
  seal(): void {
529
547
  if (this.#sealed) return;
530
548
  this.#sealed = true;
549
+ this.#displaceable = false;
531
550
  this.stopAnimation();
532
551
  this.#updateDisplay();
533
552
  this.#ui.requestRender();
534
553
  }
535
554
 
555
+ /**
556
+ * Whether this block is a waiting `job` poll (every watched job still
557
+ * running) that has not been sealed. Such a block never finalized, so none
558
+ * of its rows entered native scrollback (the ticking spinner keeps the
559
+ * stable-prefix ratchet at zero) and the whole block can be removed when a
560
+ * follow-up `job` call supersedes it.
561
+ */
562
+ isDisplaceableBlock(): boolean {
563
+ return this.#displaceable && !this.#sealed;
564
+ }
565
+
536
566
  /**
537
567
  * Stop spinner animation and cleanup resources.
538
568
  */
@@ -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);
@@ -77,6 +77,14 @@ export class EventController {
77
77
  // Insertion-ordered IRC cards not yet retired; values are the transcript
78
78
  // components each card contributed (see #retireIrcCard for the guard).
79
79
  #liveIrcCards = new Map<string, Component[]>();
80
+ // Most recent `job` tool block whose result still had every watched job
81
+ // running. Kept un-finalized (live) so the next `job` call displaces it —
82
+ // one persistent poll instead of a stack of "waiting on N jobs" frames —
83
+ // and sealed in place the moment anything else lands below it.
84
+ #displaceablePollComponent: ToolExecutionComponent | undefined = undefined;
85
+ // Most recent TTSR notification block. A new ttsr_triggered event merges its
86
+ // rules into this block while it is still the (live-region) transcript tail.
87
+ #lastTtsrNotification: TtsrNotificationComponent | undefined = undefined;
80
88
  #streamingReveal: StreamingRevealController;
81
89
  #handlers: AgentSessionEventHandlers;
82
90
 
@@ -282,6 +290,7 @@ export class EventController {
282
290
  const signature = `${textContent}\u0000${imageCount}`;
283
291
 
284
292
  this.#resetReadGroup();
293
+ this.#resolveDisplaceablePoll();
285
294
  const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
286
295
  const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
287
296
  if (!wasOptimistic) {
@@ -389,6 +398,28 @@ export class EventController {
389
398
  }
390
399
  }
391
400
 
401
+ /**
402
+ * Resolve the pending displaceable poll block before the next block lands.
403
+ * A follow-up `job` call displaces it — the stale "waiting on N jobs" frame
404
+ * is removed so repeated polls read as one persistent poll — while anything
405
+ * else seals it in place as final history. Removal is safe only because a
406
+ * displaceable block never finalizes: commits stop at the first live block,
407
+ * so none of its rows have entered native scrollback (see
408
+ * ToolExecutionComponent.isDisplaceableBlock).
409
+ */
410
+ #resolveDisplaceablePoll(nextToolName?: string): void {
411
+ const previous = this.#displaceablePollComponent;
412
+ if (!previous) return;
413
+ this.#displaceablePollComponent = undefined;
414
+ if (nextToolName === "job" && previous.isDisplaceableBlock()) {
415
+ this.ctx.chatContainer.removeChild(previous);
416
+ }
417
+ // Sealing stops the waiting-poll spinner and freezes the block (for a
418
+ // just-removed component it only clears the animation timer).
419
+ previous.seal();
420
+ this.ctx.ui.requestRender();
421
+ }
422
+
392
423
  async #handleNotice(event: Extract<AgentSessionEvent, { type: "notice" }>): Promise<void> {
393
424
  const message = event.source ? `${event.source}: ${event.message}` : event.message;
394
425
  if (event.level === "error") {
@@ -444,6 +475,7 @@ export class EventController {
444
475
  continue;
445
476
  }
446
477
  if (!readArgsTargetInternalUrl(content.arguments)) {
478
+ if (!this.ctx.pendingTools.has(content.id)) this.#resolveDisplaceablePoll(content.name);
447
479
  this.#trackReadToolCall(content.id, content.arguments);
448
480
  const component = this.ctx.pendingTools.get(content.id);
449
481
  if (component) {
@@ -465,6 +497,7 @@ export class EventController {
465
497
  ? { ...content.arguments, __partialJson: content.partialJson }
466
498
  : content.arguments;
467
499
  if (!this.ctx.pendingTools.has(content.id)) {
500
+ this.#resolveDisplaceablePoll(content.name);
468
501
  this.#resetReadGroup();
469
502
  const tool = this.ctx.session.getToolByName(content.name);
470
503
  const component = new ToolExecutionComponent(
@@ -561,6 +594,9 @@ export class EventController {
561
594
  component.seal();
562
595
  }
563
596
  }
597
+ // These calls will never produce a result either, so the tracked
598
+ // waiting poll cannot be displaced anymore — freeze it in place.
599
+ this.#resolveDisplaceablePoll();
564
600
  }
565
601
  this.#lastAssistantComponent = this.ctx.streamingComponent;
566
602
  this.#lastAssistantComponent.setUsageInfo(event.message.usage);
@@ -589,6 +625,7 @@ export class EventController {
589
625
  async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
590
626
  this.#updateWorkingMessageFromIntent(event.intent);
591
627
  if (!this.ctx.pendingTools.has(event.toolCallId)) {
628
+ this.#resolveDisplaceablePoll(event.toolName);
592
629
  if (event.toolName === "read" && readArgsHaveTarget(event.args) && !readArgsTargetInternalUrl(event.args)) {
593
630
  this.#trackReadToolCall(event.toolCallId, event.args);
594
631
  const component = this.ctx.pendingTools.get(event.toolCallId);
@@ -697,6 +734,14 @@ export class EventController {
697
734
  this.ctx.pendingTools.delete(event.toolCallId);
698
735
  this.#backgroundToolCallIds.delete(event.toolCallId);
699
736
  }
737
+ if (
738
+ event.toolName === "job" &&
739
+ component instanceof ToolExecutionComponent &&
740
+ component.isDisplaceableBlock()
741
+ ) {
742
+ // Remember the waiting poll so the next `job` call can displace it.
743
+ this.#displaceablePollComponent = component;
744
+ }
700
745
  this.ctx.ui.requestRender();
701
746
  }
702
747
  }
@@ -759,6 +804,9 @@ export class EventController {
759
804
  this.#readToolCallArgs.clear();
760
805
  this.#readToolCallAssistantComponents.clear();
761
806
  this.#resetReadGroup();
807
+ // The turn is over: nothing else lands this turn, so the waiting poll is
808
+ // final history — seal it instead of letting its spinner tick while idle.
809
+ this.#resolveDisplaceablePoll();
762
810
  this.#lastAssistantComponent = undefined;
763
811
  this.ctx.ui.requestRender();
764
812
  this.#scheduleIdleCompaction();
@@ -908,9 +956,26 @@ export class EventController {
908
956
  }
909
957
 
910
958
  async #handleTtsrTriggered(event: Extract<AgentSessionEvent, { type: "ttsr_triggered" }>): Promise<void> {
959
+ // Consecutive notifications (e.g. per-tool matches from one assistant
960
+ // message) merge into the previous block instead of stacking. Mutating an
961
+ // existing block is only safe while it sits inside the live region — a
962
+ // still-mutating block above it means none of its rows have been committed
963
+ // to native scrollback yet (commits are prefix-only and stop at the first
964
+ // live block), so the grown block still repaints.
965
+ const previous = this.#lastTtsrNotification;
966
+ if (
967
+ previous &&
968
+ this.ctx.chatContainer.children.at(-1) === previous &&
969
+ this.ctx.chatContainer.isWithinLiveRegion(previous)
970
+ ) {
971
+ previous.addRules(event.rules);
972
+ this.ctx.ui.requestRender();
973
+ return;
974
+ }
911
975
  const component = new TtsrNotificationComponent(event.rules);
912
976
  component.setExpanded(this.ctx.toolOutputExpanded);
913
977
  this.ctx.present(component);
978
+ this.#lastTtsrNotification = component;
914
979
  }
915
980
 
916
981
  async #handleTodoReminder(event: Extract<AgentSessionEvent, { type: "todo_reminder" }>): Promise<void> {