@oh-my-pi/pi-coding-agent 15.10.11 → 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 (217) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/dist/cli.js +5790 -5731
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  6. package/dist/types/cli-commands.d.ts +12 -0
  7. package/dist/types/commands/launch.d.ts +4 -0
  8. package/dist/types/config/api-key-resolver.d.ts +3 -0
  9. package/dist/types/config/keybindings.d.ts +6 -1
  10. package/dist/types/config/model-registry.d.ts +1 -0
  11. package/dist/types/config/model-resolver.d.ts +18 -0
  12. package/dist/types/config/settings-schema.d.ts +85 -34
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  15. package/dist/types/eval/py/executor.d.ts +5 -0
  16. package/dist/types/eval/py/kernel.d.ts +6 -1
  17. package/dist/types/eval/py/runtime.d.ts +9 -0
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/export/html/template.generated.d.ts +1 -1
  20. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/extensibility/shared-events.d.ts +2 -2
  24. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  25. package/dist/types/internal-urls/index.d.ts +1 -0
  26. package/dist/types/internal-urls/types.d.ts +1 -1
  27. package/dist/types/irc/bus.d.ts +66 -0
  28. package/dist/types/memory-backend/index.d.ts +1 -0
  29. package/dist/types/memory-backend/runtime.d.ts +4 -0
  30. package/dist/types/memory-backend/types.d.ts +66 -1
  31. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  32. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  33. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  34. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  35. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  36. package/dist/types/modes/components/welcome.d.ts +3 -9
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  38. package/dist/types/modes/index.d.ts +3 -3
  39. package/dist/types/modes/interactive-mode.d.ts +10 -4
  40. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  41. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  42. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  43. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  44. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  45. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  46. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  47. package/dist/types/modes/theme/theme.d.ts +2 -1
  48. package/dist/types/modes/types.d.ts +5 -2
  49. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  50. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  51. package/dist/types/registry/agent-registry.d.ts +16 -5
  52. package/dist/types/secrets/index.d.ts +1 -1
  53. package/dist/types/secrets/obfuscator.d.ts +8 -2
  54. package/dist/types/session/agent-session.d.ts +49 -32
  55. package/dist/types/session/messages.d.ts +2 -4
  56. package/dist/types/session/session-history-format.d.ts +12 -0
  57. package/dist/types/session/session-manager.d.ts +21 -3
  58. package/dist/types/session/streaming-output.d.ts +46 -0
  59. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  60. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  61. package/dist/types/slash-commands/types.d.ts +1 -1
  62. package/dist/types/system-prompt.d.ts +2 -0
  63. package/dist/types/task/executor.d.ts +12 -2
  64. package/dist/types/task/index.d.ts +13 -6
  65. package/dist/types/task/output-manager.d.ts +0 -7
  66. package/dist/types/task/repair-args.d.ts +8 -7
  67. package/dist/types/task/types.d.ts +63 -51
  68. package/dist/types/thinking.d.ts +4 -0
  69. package/dist/types/tiny/title-client.d.ts +11 -0
  70. package/dist/types/tiny/title-protocol.d.ts +1 -0
  71. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  72. package/dist/types/tools/find.d.ts +0 -11
  73. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  74. package/dist/types/tools/index.d.ts +7 -3
  75. package/dist/types/tools/irc.d.ts +76 -38
  76. package/dist/types/tools/job.d.ts +7 -1
  77. package/dist/types/utils/git.d.ts +15 -2
  78. package/dist/types/utils/title-generator.d.ts +3 -2
  79. package/examples/extensions/with-deps/package.json +1 -0
  80. package/package.json +11 -10
  81. package/scripts/bundle-dist.ts +28 -19
  82. package/src/async/index.ts +0 -1
  83. package/src/auto-thinking/classifier.ts +1 -0
  84. package/src/cli/args.ts +3 -0
  85. package/src/cli/gallery-cli.ts +1 -1
  86. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  87. package/src/cli/gallery-fixtures/types.ts +5 -0
  88. package/src/cli-commands.ts +29 -0
  89. package/src/cli.ts +28 -15
  90. package/src/commands/launch.ts +4 -0
  91. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  92. package/src/commit/model-selection.ts +3 -2
  93. package/src/config/api-key-resolver.ts +8 -6
  94. package/src/config/keybindings.ts +6 -1
  95. package/src/config/model-registry.ts +97 -30
  96. package/src/config/model-resolver.ts +60 -0
  97. package/src/config/settings-schema.ts +99 -55
  98. package/src/config/settings.ts +68 -3
  99. package/src/edit/hashline/execute.ts +39 -2
  100. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  101. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  102. package/src/eval/agent-bridge.ts +3 -16
  103. package/src/eval/completion-bridge.ts +1 -0
  104. package/src/eval/js/shared/prelude.txt +1 -1
  105. package/src/eval/py/executor.ts +29 -7
  106. package/src/eval/py/index.ts +6 -1
  107. package/src/eval/py/kernel.ts +31 -11
  108. package/src/eval/py/prelude.py +5 -6
  109. package/src/eval/py/runtime.ts +37 -0
  110. package/src/exec/bash-executor.ts +82 -3
  111. package/src/export/html/template.generated.ts +1 -1
  112. package/src/export/html/template.js +38 -13
  113. package/src/extensibility/custom-tools/types.ts +2 -2
  114. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  115. package/src/extensibility/extensions/runner.ts +6 -1
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/shared-events.ts +2 -2
  118. package/src/hindsight/bank.ts +17 -2
  119. package/src/internal-urls/docs-index.generated.ts +11 -11
  120. package/src/internal-urls/history-protocol.ts +113 -0
  121. package/src/internal-urls/index.ts +1 -0
  122. package/src/internal-urls/router.ts +3 -1
  123. package/src/internal-urls/types.ts +1 -1
  124. package/src/irc/bus.ts +292 -0
  125. package/src/main.ts +26 -66
  126. package/src/memories/index.ts +2 -0
  127. package/src/memory-backend/index.ts +1 -0
  128. package/src/memory-backend/local-backend.ts +9 -0
  129. package/src/memory-backend/off-backend.ts +9 -0
  130. package/src/memory-backend/runtime.ts +66 -0
  131. package/src/memory-backend/types.ts +81 -1
  132. package/src/mnemopi/backend.ts +151 -4
  133. package/src/modes/acp/acp-agent.ts +119 -11
  134. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  135. package/src/modes/components/assistant-message.ts +19 -21
  136. package/src/modes/components/compaction-summary-message.ts +68 -32
  137. package/src/modes/components/custom-editor.ts +10 -0
  138. package/src/modes/components/footer.ts +3 -1
  139. package/src/modes/components/status-line/component.ts +118 -34
  140. package/src/modes/components/tool-execution.ts +31 -1
  141. package/src/modes/components/ttsr-notification.ts +72 -30
  142. package/src/modes/components/welcome.ts +9 -33
  143. package/src/modes/controllers/command-controller.ts +1 -1
  144. package/src/modes/controllers/event-controller.ts +65 -0
  145. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  146. package/src/modes/controllers/input-controller.ts +19 -2
  147. package/src/modes/controllers/mcp-command-controller.ts +38 -3
  148. package/src/modes/controllers/selector-controller.ts +21 -17
  149. package/src/modes/index.ts +3 -21
  150. package/src/modes/interactive-mode.ts +47 -22
  151. package/src/modes/oauth-manual-input.ts +30 -3
  152. package/src/modes/rpc/rpc-client.ts +154 -3
  153. package/src/modes/rpc/rpc-mode.ts +97 -12
  154. package/src/modes/rpc/rpc-subagents.ts +265 -0
  155. package/src/modes/rpc/rpc-types.ts +81 -1
  156. package/src/modes/setup-wizard/index.ts +12 -2
  157. package/src/modes/setup-wizard/lazy.ts +16 -0
  158. package/src/modes/theme/theme.ts +18 -5
  159. package/src/modes/types.ts +5 -5
  160. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  161. package/src/modes/utils/ui-helpers.ts +51 -49
  162. package/src/prompts/system/irc-incoming.md +3 -4
  163. package/src/prompts/system/orchestrate-notice.md +2 -2
  164. package/src/prompts/system/subagent-system-prompt.md +0 -5
  165. package/src/prompts/system/system-prompt.md +1 -0
  166. package/src/prompts/system/workflow-notice.md +2 -2
  167. package/src/prompts/tools/eval.md +3 -3
  168. package/src/prompts/tools/irc.md +29 -19
  169. package/src/prompts/tools/read.md +2 -2
  170. package/src/prompts/tools/task-summary.md +5 -16
  171. package/src/prompts/tools/task.md +38 -29
  172. package/src/registry/agent-lifecycle.ts +218 -0
  173. package/src/registry/agent-registry.ts +16 -5
  174. package/src/sdk.ts +37 -10
  175. package/src/secrets/index.ts +8 -1
  176. package/src/secrets/obfuscator.ts +39 -18
  177. package/src/session/agent-session.ts +422 -291
  178. package/src/session/messages.ts +11 -78
  179. package/src/session/session-history-format.ts +246 -0
  180. package/src/session/session-manager.ts +59 -5
  181. package/src/session/streaming-output.ts +226 -10
  182. package/src/slash-commands/acp-builtins.ts +24 -0
  183. package/src/slash-commands/builtin-registry.ts +20 -0
  184. package/src/slash-commands/types.ts +1 -1
  185. package/src/system-prompt.ts +14 -0
  186. package/src/task/executor.ts +851 -461
  187. package/src/task/index.ts +721 -796
  188. package/src/task/output-manager.ts +0 -11
  189. package/src/task/render.ts +148 -63
  190. package/src/task/repair-args.ts +21 -9
  191. package/src/task/types.ts +82 -66
  192. package/src/thinking.ts +7 -0
  193. package/src/tiny/title-client.ts +34 -5
  194. package/src/tiny/title-protocol.ts +1 -1
  195. package/src/tiny/worker.ts +6 -4
  196. package/src/tools/ask.ts +4 -2
  197. package/src/tools/bash.ts +61 -10
  198. package/src/tools/browser/tab-worker.ts +26 -7
  199. package/src/tools/browser.ts +28 -1
  200. package/src/tools/find.ts +2 -27
  201. package/src/tools/grouped-file-output.ts +1 -118
  202. package/src/tools/image-gen.ts +11 -4
  203. package/src/tools/index.ts +17 -13
  204. package/src/tools/inspect-image.ts +1 -0
  205. package/src/tools/irc.ts +596 -171
  206. package/src/tools/job.ts +41 -7
  207. package/src/tools/read.ts +57 -1
  208. package/src/tools/renderers.ts +2 -0
  209. package/src/tools/resolve.ts +4 -1
  210. package/src/utils/commit-message-generator.ts +1 -0
  211. package/src/utils/git.ts +267 -13
  212. package/src/utils/title-generator.ts +24 -5
  213. package/dist/types/async/support.d.ts +0 -2
  214. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  215. package/dist/types/task/simple-mode.d.ts +0 -8
  216. package/src/async/support.ts +0 -5
  217. package/src/task/simple-mode.ts +0 -27
@@ -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);
@@ -934,7 +934,7 @@ export class CommandController {
934
934
  this.ctx.bashComponent.appendOutput(chunk);
935
935
  }
936
936
  },
937
- { excludeFromContext },
937
+ { excludeFromContext, useUserShell: true },
938
938
  );
939
939
 
940
940
  if (this.ctx.bashComponent) {
@@ -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> {
@@ -140,7 +140,7 @@ export class ExtensionUiController {
140
140
  reload: async () => {
141
141
  await this.ctx.session.reload();
142
142
  this.ctx.chatContainer.clear();
143
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
143
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
144
144
  await this.ctx.reloadTodos();
145
145
  this.ctx.showStatus("Reloaded session");
146
146
  },
@@ -197,7 +197,7 @@ export class ExtensionUiController {
197
197
 
198
198
  // Update UI
199
199
  this.ctx.chatContainer.clear();
200
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
200
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
201
201
  await this.ctx.reloadTodos();
202
202
  this.ctx.editor.setText(result.selectedText);
203
203
  this.ctx.showStatus("Branched to new session");
@@ -212,7 +212,7 @@ export class ExtensionUiController {
212
212
 
213
213
  // Update UI
214
214
  this.ctx.chatContainer.clear();
215
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
215
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
216
216
  await this.ctx.reloadTodos();
217
217
  if (result.editorText && !this.ctx.editor.getText().trim()) {
218
218
  this.ctx.editor.setText(result.editorText);
@@ -230,7 +230,7 @@ export class ExtensionUiController {
230
230
  }
231
231
  setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
232
232
  this.ctx.chatContainer.clear();
233
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
233
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
234
234
  await this.ctx.reloadTodos();
235
235
  return { cancelled: false };
236
236
  },
@@ -376,7 +376,7 @@ export class ExtensionUiController {
376
376
  reload: async () => {
377
377
  await this.ctx.session.reload();
378
378
  this.ctx.chatContainer.clear();
379
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
379
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
380
380
  await this.ctx.reloadTodos();
381
381
  this.ctx.showStatus("Reloaded session");
382
382
  },
@@ -426,7 +426,7 @@ export class ExtensionUiController {
426
426
 
427
427
  // Update UI
428
428
  this.ctx.chatContainer.clear();
429
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
429
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
430
430
  await this.ctx.reloadTodos();
431
431
  this.ctx.editor.setText(result.selectedText);
432
432
  this.ctx.showStatus("Branched to new session");
@@ -441,7 +441,7 @@ export class ExtensionUiController {
441
441
 
442
442
  // Update UI
443
443
  this.ctx.chatContainer.clear();
444
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
444
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
445
445
  await this.ctx.reloadTodos();
446
446
  if (result.editorText && !this.ctx.editor.getText().trim()) {
447
447
  this.ctx.editor.setText(result.editorText);
@@ -458,7 +458,7 @@ export class ExtensionUiController {
458
458
  return { cancelled: true };
459
459
  }
460
460
  this.ctx.chatContainer.clear();
461
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
461
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
462
462
  await this.ctx.reloadTodos();
463
463
  return { cancelled: false };
464
464
  },
@@ -235,10 +235,26 @@ export class InputController {
235
235
  for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) {
236
236
  this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
237
237
  }
238
- for (const key of this.ctx.keybindings.getKeys("app.session.observe")) {
239
- this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionObserver());
238
+ const hubKeys = new Set([
239
+ ...this.ctx.keybindings.getKeys("app.agents.hub"),
240
+ ...this.ctx.keybindings.getKeys("app.session.observe"),
241
+ ]);
242
+ for (const key of hubKeys) {
243
+ this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showAgentHub());
240
244
  }
241
245
 
246
+ // Double-tap left arrow on an empty editor opens the agent hub — same
247
+ // 500ms window as the double-escape state machine above.
248
+ this.ctx.editor.onLeftAtStart = () => {
249
+ const now = Date.now();
250
+ if (now - this.ctx.lastLeftTapTime < 500) {
251
+ this.ctx.lastLeftTapTime = 0;
252
+ this.ctx.showAgentHub();
253
+ } else {
254
+ this.ctx.lastLeftTapTime = now;
255
+ }
256
+ };
257
+
242
258
  this.#setupEnhancedPaste();
243
259
 
244
260
  this.ctx.editor.onChange = (text: string) => {
@@ -467,6 +483,7 @@ export class InputController {
467
483
  this.ctx.session.sessionId,
468
484
  this.ctx.session.model,
469
485
  provider => this.ctx.session.agent.metadataForProvider(provider),
486
+ this.ctx.titleSystemPrompt,
470
487
  )
471
488
  .then(async title => {
472
489
  // Re-check: a concurrent attempt for an earlier message may have
@@ -46,9 +46,14 @@ import { theme } from "../theme/theme";
46
46
  import type { InteractiveModeContext } from "../types";
47
47
  import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared";
48
48
 
49
- function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
49
+ const MCP_MANUAL_INPUT_PROVIDER_ID = "mcp";
50
+ const MCP_MANUAL_LOGIN_TIP = "Headless? Paste the redirect URL or code with /login <value>.";
51
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string, onTimeout?: () => void): Promise<T> {
50
52
  const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
51
- const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
53
+ const timer = setTimeout(() => {
54
+ onTimeout?.();
55
+ reject(new Error(message));
56
+ }, timeoutMs);
52
57
  return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
53
58
  }
54
59
 
@@ -591,6 +596,15 @@ export class MCPCommandController {
591
596
  const resolvedClientId = clientId.trim() || parsedAuthUrl.searchParams.get("client_id") || undefined;
592
597
  const resolvedClientSecret = clientSecret.trim() || undefined;
593
598
 
599
+ const manualInput = this.ctx.oauthManualInput;
600
+ if (manualInput.hasPending()) {
601
+ const pendingProvider = manualInput.pendingProviderId ?? "another provider";
602
+ throw new Error(
603
+ `OAuth login already in progress for ${pendingProvider}. Complete or cancel it before starting MCP OAuth.`,
604
+ );
605
+ }
606
+ let manualInputClaim: { promise: Promise<string>; clear: (reason?: string) => void } | undefined;
607
+ const oauthTimeout = new AbortController();
594
608
  try {
595
609
  // Create OAuth flow
596
610
  const flow = new MCPOAuthFlow(
@@ -620,6 +634,7 @@ export class MCPCommandController {
620
634
  0,
621
635
  ),
622
636
  );
637
+ block.addChild(new Text(theme.fg("muted", MCP_MANUAL_LOGIN_TIP), 1, 0));
623
638
  block.addChild(new Spacer(1));
624
639
  block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0));
625
640
  // Try to open browser automatically
@@ -644,11 +659,29 @@ export class MCPCommandController {
644
659
  onProgress: (message: string) => {
645
660
  this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]);
646
661
  },
662
+ onManualCodeInput: () => {
663
+ if (manualInputClaim) return manualInputClaim.promise;
664
+ const pendingInput = manualInput.tryClaimInput(MCP_MANUAL_INPUT_PROVIDER_ID);
665
+ if (!pendingInput) {
666
+ const pendingProvider = manualInput.pendingProviderId ?? "another provider";
667
+ throw new Error(
668
+ `OAuth login already in progress for ${pendingProvider}. Complete or cancel it before starting MCP OAuth.`,
669
+ );
670
+ }
671
+ manualInputClaim = pendingInput;
672
+ return pendingInput.promise;
673
+ },
674
+ signal: oauthTimeout.signal,
647
675
  },
648
676
  );
649
677
 
650
678
  // Execute OAuth flow with 5 minute timeout
651
- const credentials = await withTimeout(flow.login(), 5 * 60 * 1000, "OAuth flow timed out after 5 minutes");
679
+ const credentials = await withTimeout(
680
+ flow.login(),
681
+ 5 * 60 * 1000,
682
+ "OAuth flow timed out after 5 minutes",
683
+ () => oauthTimeout.abort("MCP OAuth flow timed out"),
684
+ );
652
685
 
653
686
  this.ctx.present([
654
687
  new Spacer(1),
@@ -687,6 +720,8 @@ export class MCPCommandController {
687
720
  } else {
688
721
  throw new Error(`OAuth authentication failed: ${errorMsg}`);
689
722
  }
723
+ } finally {
724
+ manualInputClaim?.clear("Manual MCP OAuth input cleared");
690
725
  }
691
726
  }
692
727