@oh-my-pi/pi-coding-agent 16.3.11 → 16.3.13

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 (113) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3176 -3087
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/keybindings.d.ts +9 -4
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/config/settings-schema.d.ts +6 -0
  7. package/dist/types/config/settings.d.ts +3 -1
  8. package/dist/types/discovery/helpers.d.ts +9 -0
  9. package/dist/types/exec/bash-executor.d.ts +1 -0
  10. package/dist/types/extensibility/extensions/types.d.ts +11 -2
  11. package/dist/types/extensibility/shared-events.d.ts +2 -2
  12. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  13. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  14. package/dist/types/mnemopi/state.d.ts +7 -3
  15. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  16. package/dist/types/modes/components/model-selector.d.ts +2 -1
  17. package/dist/types/modes/components/read-tool-group.d.ts +1 -0
  18. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  19. package/dist/types/modes/interactive-mode.d.ts +3 -1
  20. package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
  21. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  22. package/dist/types/modes/types.d.ts +3 -1
  23. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  24. package/dist/types/modes/workflow.d.ts +5 -1
  25. package/dist/types/session/agent-session.d.ts +8 -4
  26. package/dist/types/system-prompt.d.ts +1 -1
  27. package/dist/types/tools/bash-interactive.d.ts +1 -1
  28. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  29. package/dist/types/tools/bash.d.ts +2 -1
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/grep.d.ts +2 -0
  32. package/dist/types/tools/index.d.ts +4 -0
  33. package/dist/types/tools/path-utils.d.ts +24 -0
  34. package/dist/types/tools/read.d.ts +3 -0
  35. package/dist/types/tools/renderers.d.ts +12 -5
  36. package/dist/types/tools/ssh.d.ts +4 -1
  37. package/dist/types/tools/write.d.ts +1 -0
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +145 -0
  41. package/src/advisor/runtime.ts +19 -0
  42. package/src/config/api-key-resolver.ts +7 -2
  43. package/src/config/keybindings.ts +62 -10
  44. package/src/config/model-registry.ts +94 -20
  45. package/src/config/settings-schema.ts +11 -1
  46. package/src/config/settings.ts +59 -21
  47. package/src/discovery/builtin.ts +2 -1
  48. package/src/discovery/claude-plugins.ts +167 -46
  49. package/src/discovery/helpers.ts +16 -1
  50. package/src/edit/renderer.ts +20 -6
  51. package/src/eval/js/worker-core.ts +163 -6
  52. package/src/exec/bash-executor.ts +14 -9
  53. package/src/extensibility/extensions/runner.ts +1 -0
  54. package/src/extensibility/extensions/types.ts +13 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  56. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  57. package/src/extensibility/shared-events.ts +2 -2
  58. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  59. package/src/internal-urls/docs-index.generated.txt +1 -1
  60. package/src/internal-urls/registry-helpers.ts +9 -6
  61. package/src/mnemopi/state.ts +19 -5
  62. package/src/modes/acp/acp-agent.ts +69 -8
  63. package/src/modes/acp/acp-event-mapper.ts +1 -1
  64. package/src/modes/components/model-selector.ts +30 -6
  65. package/src/modes/components/read-tool-group.ts +5 -1
  66. package/src/modes/components/settings-defs.ts +1 -1
  67. package/src/modes/components/status-line/component.ts +14 -2
  68. package/src/modes/components/tool-execution.ts +28 -24
  69. package/src/modes/controllers/command-controller.ts +13 -23
  70. package/src/modes/controllers/event-controller.ts +12 -12
  71. package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
  72. package/src/modes/controllers/extension-ui-controller.ts +7 -35
  73. package/src/modes/controllers/input-controller.ts +23 -57
  74. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  75. package/src/modes/controllers/selector-controller.ts +16 -5
  76. package/src/modes/github-ref-autocomplete.ts +75 -0
  77. package/src/modes/interactive-mode.ts +97 -12
  78. package/src/modes/prompt-action-autocomplete.ts +35 -0
  79. package/src/modes/rpc/rpc-client.ts +42 -13
  80. package/src/modes/rpc/rpc-mode.ts +21 -19
  81. package/src/modes/types.ts +3 -0
  82. package/src/modes/utils/context-usage.ts +58 -5
  83. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  84. package/src/modes/utils/ui-helpers.ts +2 -2
  85. package/src/modes/workflow.ts +14 -8
  86. package/src/prompts/agents/plan.md +0 -1
  87. package/src/prompts/agents/reviewer.md +0 -1
  88. package/src/prompts/system/plan-mode-active.md +5 -2
  89. package/src/prompts/system/system-prompt.md +1 -2
  90. package/src/prompts/system/workflow-notice.md +69 -50
  91. package/src/prompts/tools/bash.md +18 -7
  92. package/src/prompts/tools/grep.md +2 -1
  93. package/src/prompts/tools/memory-edit.md +2 -0
  94. package/src/prompts/tools/read.md +4 -3
  95. package/src/sdk.ts +11 -0
  96. package/src/session/agent-session.ts +136 -19
  97. package/src/system-prompt.ts +3 -2
  98. package/src/tools/bash-interactive.ts +1 -1
  99. package/src/tools/bash-skill-urls.ts +39 -7
  100. package/src/tools/bash.ts +69 -39
  101. package/src/tools/browser/launch.ts +31 -4
  102. package/src/tools/grep.ts +105 -21
  103. package/src/tools/image-gen.ts +1 -1
  104. package/src/tools/index.ts +11 -0
  105. package/src/tools/memory-edit.ts +3 -1
  106. package/src/tools/path-utils.ts +46 -1
  107. package/src/tools/read.ts +135 -57
  108. package/src/tools/renderers.ts +13 -5
  109. package/src/tools/ssh.ts +10 -3
  110. package/src/tools/tts.ts +1 -1
  111. package/src/tools/write.ts +26 -0
  112. package/src/utils/local-date.ts +7 -0
  113. package/src/utils/open.ts +36 -10
@@ -20,11 +20,13 @@ export function resetRegisteredArtifactDirsForTests(): void {
20
20
  /**
21
21
  * Snapshot of artifacts dirs for every registered session, deduped.
22
22
  *
23
- * Prefers `sessionManager.getArtifactsDir()` because subagents adopt their
24
- * parent's `ArtifactManager` and report the parent's dir there; dedup then
25
- * collapses parent + N subagents (the whole agent tree) to one entry. Falls
26
- * back to the raw session file (with the `.jsonl` suffix stripped) when no
27
- * live session reference is attached.
23
+ * Collects TWO candidate dirs per ref, because a subagent reads from its
24
+ * adopted (root-wide) `ArtifactManager.dir` but its own children are written
25
+ * one level deeper, under `sessionFile.slice(0, -6)` (`task/index.ts`). A
26
+ * depth-2+ subagent's output therefore lives in the write-time dir, not the
27
+ * adopted one, so `agent://` must scan both or it 404s a live nested peer.
28
+ * `addDir` dedup collapses the depth-0 case (both formulas agree) back to a
29
+ * single entry.
28
30
  */
29
31
  export function artifactsDirsFromRegistry(): string[] {
30
32
  const dirs: string[] = [];
@@ -33,7 +35,8 @@ export function artifactsDirsFromRegistry(): string[] {
33
35
  if (!dirs.includes(dir)) dirs.push(dir);
34
36
  };
35
37
  for (const ref of AgentRegistry.global().list()) {
36
- addDir(ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null));
38
+ addDir(ref.session?.sessionManager.getArtifactsDir());
39
+ if (ref.sessionFile) addDir(ref.sessionFile.slice(0, -6));
37
40
  }
38
41
  for (const dir of extraArtifactsDirs) addDir(dir);
39
42
  return dirs;
@@ -108,11 +108,16 @@ export interface MnemopiMemoryEditOptions {
108
108
  }
109
109
 
110
110
  export interface MnemopiMemoryEditResult {
111
- status: "updated" | "deleted" | "invalidated" | "not_found";
111
+ status: "updated" | "deleted" | "invalidated" | "not_found" | "not_editable";
112
112
  bank?: string;
113
- store?: "working" | "episodic";
113
+ store?: MnemopiMemoryStore;
114
114
  }
115
115
 
116
+ /** Which mnemopi table a resolved memory id lives in. `fact` rows are
117
+ * read-only projections of fact extraction (issue #4725): resolvable for
118
+ * reads, never editable. */
119
+ export type MnemopiMemoryStore = "working" | "episodic" | "fact";
120
+
116
121
  interface MnemopiStoredMemoryRow {
117
122
  id?: unknown;
118
123
  content?: unknown;
@@ -136,7 +141,7 @@ interface MnemopiStoredMemoryRow {
136
141
  */
137
142
  export interface MnemopiScopedMemoryHit {
138
143
  bank: string;
139
- store: "working" | "episodic";
144
+ store: MnemopiMemoryStore;
140
145
  row: {
141
146
  id: string;
142
147
  content: string;
@@ -256,7 +261,8 @@ export class MnemopiSessionState {
256
261
  for (const target of targets) {
257
262
  const raw = target.memory.get(id) as MnemopiStoredMemoryRow | null;
258
263
  if (!raw) continue;
259
- const store: MnemopiScopedMemoryHit["store"] = raw.memory_store === "episodic" ? "episodic" : "working";
264
+ const store: MnemopiMemoryStore =
265
+ raw.memory_store === "episodic" || raw.memory_store === "fact" ? raw.memory_store : "working";
260
266
  return {
261
267
  bank: target.bank,
262
268
  store,
@@ -291,8 +297,16 @@ export class MnemopiSessionState {
291
297
  for (const target of targets) {
292
298
  const row = target.memory.get(id) as MnemopiStoredMemoryRow | null;
293
299
  if (!row) continue;
294
- const store: MnemopiMemoryEditResult["store"] = row.memory_store === "episodic" ? "episodic" : "working";
300
+ const store: MnemopiMemoryStore =
301
+ row.memory_store === "episodic" || row.memory_store === "fact" ? row.memory_store : "working";
295
302
  const resultContext: Pick<MnemopiMemoryEditResult, "bank" | "store"> = { bank: target.bank, store };
303
+ if (store === "fact") {
304
+ // Facts are read-only: no memory_edit op mutates the facts
305
+ // table, so report that precisely instead of `not_found`
306
+ // (the id DID resolve — issue #4725).
307
+ ineligible ??= { status: "not_editable", ...resultContext };
308
+ continue;
309
+ }
296
310
  if ((op === "update" || op === "forget") && store !== "working") {
297
311
  ineligible ??= { status: "not_found", ...resultContext };
298
312
  continue;
@@ -84,6 +84,7 @@ import { canonicalizeMessage } from "../../utils/thinking-display";
84
84
  import { createAcpClientBridge } from "./acp-client-bridge";
85
85
  import {
86
86
  buildToolCallStartUpdate,
87
+ extractAssistantMessageText,
87
88
  mapAgentSessionEventToAcpSessionUpdates,
88
89
  normalizeReplayToolArguments,
89
90
  } from "./acp-event-mapper";
@@ -425,6 +426,7 @@ export function createAcpExtensionUiContext(
425
426
  setEditorText: () => {},
426
427
  getEditorText: () => "",
427
428
  editor: async () => undefined,
429
+ addAutocompleteProvider: () => {},
428
430
  setEditorComponent: () => {},
429
431
  get theme() {
430
432
  return theme;
@@ -843,13 +845,16 @@ export class AcpAgent implements Agent {
843
845
  return false;
844
846
  }
845
847
  const built = await buildSkillPromptMessage(skill, parsed.args, "user");
846
- await record.session.promptCustomMessage({
847
- customType: SKILL_PROMPT_MESSAGE_TYPE,
848
- content: built.message,
849
- display: true,
850
- details: built.details,
851
- attribution: "user",
852
- });
848
+ await record.session.promptCustomMessage(
849
+ {
850
+ customType: SKILL_PROMPT_MESSAGE_TYPE,
851
+ content: built.message,
852
+ display: true,
853
+ details: built.details,
854
+ attribution: "user",
855
+ },
856
+ { streamingBehavior: "steer" },
857
+ );
853
858
  return true;
854
859
  }
855
860
 
@@ -1210,8 +1215,11 @@ export class AcpAgent implements Agent {
1210
1215
  this.#clearLiveAssistantMessageAfterEvent(record, event);
1211
1216
 
1212
1217
  if (event.type === "agent_end") {
1218
+ await this.#flushMissedFinalAssistantText(record, event);
1213
1219
  await this.#emitEndOfTurnUpdates(record);
1214
1220
  await this.#waitForAcpPromptIdle(record);
1221
+ record.liveMessageId = undefined;
1222
+ record.liveMessageProgress = undefined;
1215
1223
  this.#finishPrompt(record, {
1216
1224
  stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
1217
1225
  usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
@@ -1219,6 +1227,51 @@ export class AcpAgent implements Agent {
1219
1227
  }
1220
1228
  }
1221
1229
 
1230
+ /**
1231
+ * Deliver the final visible answer when the assistant `message_end` never
1232
+ * reached this prompt turn's subscription. Session event handlers are
1233
+ * fire-and-forget (`Agent#emit` does not await async listeners), and
1234
+ * `agent_end` is flushed through the session's `#endInFlight` path while the
1235
+ * assistant `message_end` fan-out can still be parked on extension delivery —
1236
+ * so `agent_end` can overtake `message_end`. Once the turn finishes,
1237
+ * `#finishPrompt` unsubscribes and the fallback text emission in
1238
+ * `mapAssistantMessageEnd` is lost for good: a client that only received
1239
+ * `agent_thought_chunk`s stays stuck on the thinking block (#4902). The live
1240
+ * message progress records whether visible text ever reached the client; if
1241
+ * it has not, emit the last assistant message's text before the prompt
1242
+ * resolves. A `message_end` that lands during the end-of-turn waits still
1243
+ * takes the normal mapper path and sees `textEmitted` already set, so the
1244
+ * answer is delivered exactly once.
1245
+ */
1246
+ async #flushMissedFinalAssistantText(
1247
+ record: ManagedSessionRecord,
1248
+ event: Extract<AgentSessionEvent, { type: "agent_end" }>,
1249
+ ): Promise<void> {
1250
+ const progress = record.liveMessageProgress;
1251
+ if (!progress || progress.textEmitted) {
1252
+ return;
1253
+ }
1254
+ const lastAssistant = [...event.messages]
1255
+ .reverse()
1256
+ .find((message): message is AssistantMessage => message.role === "assistant");
1257
+ if (!lastAssistant) {
1258
+ return;
1259
+ }
1260
+ const text = extractAssistantMessageText(lastAssistant);
1261
+ if (text.length === 0) {
1262
+ return;
1263
+ }
1264
+ progress.textEmitted = true;
1265
+ await this.#connection.sessionUpdate({
1266
+ sessionId: record.session.sessionId,
1267
+ update: {
1268
+ sessionUpdate: "agent_message_chunk",
1269
+ content: { type: "text", text },
1270
+ messageId: record.liveMessageId,
1271
+ },
1272
+ });
1273
+ }
1274
+
1222
1275
  async #waitForAcpPromptIdle(record: ManagedSessionRecord): Promise<void> {
1223
1276
  for (let pass = 0; pass < ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES; pass++) {
1224
1277
  await record.session.waitForIdle();
@@ -1244,8 +1297,16 @@ export class AcpAgent implements Agent {
1244
1297
  }
1245
1298
  }
1246
1299
 
1300
+ /**
1301
+ * Reset live-message tracking once the assistant `message_end` is handled.
1302
+ * The `agent_end` reset happens inside the `agent_end` branch of
1303
+ * `#handlePromptEvent` — after `#flushMissedFinalAssistantText` — so a
1304
+ * `message_end` that arrives during the end-of-turn waits maps against the
1305
+ * real progress instead of resurrecting a fresh one (which would double-emit
1306
+ * the final answer).
1307
+ */
1247
1308
  #clearLiveAssistantMessageAfterEvent(record: ManagedSessionRecord, event: AgentSessionEvent): void {
1248
- if ((event.type === "message_end" && event.message.role === "assistant") || event.type === "agent_end") {
1309
+ if (event.type === "message_end" && event.message.role === "assistant") {
1249
1310
  record.liveMessageId = undefined;
1250
1311
  record.liveMessageProgress = undefined;
1251
1312
  }
@@ -922,7 +922,7 @@ function isTerminalOnlyDetails(value: unknown): boolean {
922
922
  return content === undefined || (Array.isArray(content) && content.length === 0);
923
923
  }
924
924
 
925
- function extractAssistantMessageText(value: unknown): string {
925
+ export function extractAssistantMessageText(value: unknown): string {
926
926
  if (typeof value !== "object" || value === null || !("content" in value)) {
927
927
  return "";
928
928
  }
@@ -90,16 +90,20 @@ interface RoleAssignment {
90
90
  autoSelected: boolean;
91
91
  }
92
92
 
93
+ type ModelSelectorAction = "modelRole" | "retryFallback";
94
+
93
95
  type RoleSelectCallback = (
94
96
  model: Model,
95
97
  role: string | null,
96
98
  thinkingLevel?: ConfiguredThinkingLevel,
97
99
  selector?: string,
100
+ action?: ModelSelectorAction,
98
101
  ) => void;
99
102
  type CancelCallback = () => void;
100
103
  interface MenuRoleAction {
101
104
  label: string;
102
- role: string; // now accepts custom role strings
105
+ role: string;
106
+ action: ModelSelectorAction;
103
107
  }
104
108
 
105
109
  interface ProviderTabState {
@@ -284,14 +288,19 @@ export class ModelSelectorComponent extends Container {
284
288
  }
285
289
 
286
290
  #buildMenuRoleActions(): void {
287
- this.#menuRoleActions = getKnownRoleIds(this.#settings).map(role => {
291
+ const roleActions = getKnownRoleIds(this.#settings).map(role => {
288
292
  const roleInfo = getRoleInfo(role, this.#settings);
289
293
  const roleLabel = roleInfo.tag ? `${roleInfo.tag} (${roleInfo.name})` : roleInfo.name;
290
294
  return {
291
295
  label: `Set as ${roleLabel}`,
292
296
  role,
297
+ action: "modelRole" as const,
293
298
  };
294
299
  });
300
+ this.#menuRoleActions = [
301
+ ...roleActions,
302
+ { label: "Set as DEFAULT retry fallback", role: "default", action: "retryFallback" },
303
+ ];
295
304
  }
296
305
 
297
306
  #loadRoleModels(autoCandidateModels?: ReadonlyArray<Model>): void {
@@ -1195,6 +1204,11 @@ export class ModelSelectorComponent extends Container {
1195
1204
  if (this.#menuStep === "role") {
1196
1205
  const action = this.#menuRoleActions[this.#menuSelectedIndex];
1197
1206
  if (!action) return;
1207
+ if (action.action === "retryFallback") {
1208
+ this.#handleSelect(selectedItem, action.role, undefined, action.action);
1209
+ this.#closeMenu();
1210
+ return;
1211
+ }
1198
1212
  this.#menuSelectedRole = action.role;
1199
1213
  this.#menuStep = "thinking";
1200
1214
  this.#menuSelectedIndex = this.#getThinkingPreselectIndex(action.role, selectedItem.model);
@@ -1206,7 +1220,7 @@ export class ModelSelectorComponent extends Container {
1206
1220
  const thinkingOptions = this.#getThinkingLevelsForModel(selectedItem.model);
1207
1221
  const thinkingLevel = thinkingOptions[this.#menuSelectedIndex];
1208
1222
  if (!thinkingLevel) return;
1209
- this.#handleSelect(selectedItem, this.#menuSelectedRole, thinkingLevel);
1223
+ this.#handleSelect(selectedItem, this.#menuSelectedRole, thinkingLevel, "modelRole");
1210
1224
  this.#closeMenu();
1211
1225
  return;
1212
1226
  }
@@ -1225,13 +1239,23 @@ export class ModelSelectorComponent extends Container {
1225
1239
  }
1226
1240
  }
1227
1241
 
1228
- #handleSelect(item: ModelItem, role: string | null, thinkingLevel?: ConfiguredThinkingLevel): void {
1242
+ #handleSelect(
1243
+ item: ModelItem,
1244
+ role: string | null,
1245
+ thinkingLevel?: ConfiguredThinkingLevel,
1246
+ action: ModelSelectorAction = "modelRole",
1247
+ ): void {
1229
1248
  if (this.#isItemDisabled(item)) {
1230
1249
  return;
1231
1250
  }
1232
1251
  // For temporary role, don't save to settings - just notify caller
1233
1252
  if (role === null) {
1234
- this.#onSelectCallback(item.model, null, undefined, item.selector);
1253
+ this.#onSelectCallback(item.model, null, undefined, item.selector, action);
1254
+ return;
1255
+ }
1256
+
1257
+ if (action === "retryFallback") {
1258
+ this.#onSelectCallback(item.model, role, undefined, item.selector, action);
1235
1259
  return;
1236
1260
  }
1237
1261
 
@@ -1241,7 +1265,7 @@ export class ModelSelectorComponent extends Container {
1241
1265
  this.#roles[role] = { model: item.model, thinkingLevel: selectedThinkingLevel, autoSelected: false };
1242
1266
 
1243
1267
  // Notify caller (for updating agent state if needed)
1244
- this.#onSelectCallback(item.model, role, selectedThinkingLevel, item.selector);
1268
+ this.#onSelectCallback(item.model, role, selectedThinkingLevel, item.selector, action);
1245
1269
 
1246
1270
  // Update list to show new badges
1247
1271
  this.#updateList();
@@ -37,6 +37,7 @@ export function readArgsTargetInternalUrl(args: unknown): boolean {
37
37
  type ReadRenderArgs = {
38
38
  path?: string;
39
39
  file_path?: string;
40
+ selector?: string;
40
41
  // Legacy field from the old schema; tolerated for rebuilt transcripts.
41
42
  sel?: string;
42
43
  };
@@ -344,7 +345,10 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
344
345
  updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
345
346
  if (!toolCallId) return;
346
347
  const basePath = args.file_path || args.path || "";
347
- const rawPath = args.sel ? `${basePath}:${args.sel}` : basePath;
348
+ const rawSelector =
349
+ typeof args.selector === "string" ? args.selector : typeof args.sel === "string" ? args.sel : undefined;
350
+ const selector = rawSelector?.trim().replace(/^:+/, "");
351
+ const rawPath = selector && selector.length > 0 ? `${basePath}:${selector}` : basePath;
348
352
  const entry: ReadEntry = this.#entries.get(toolCallId) ?? {
349
353
  toolCallId,
350
354
  path: rawPath,
@@ -180,7 +180,7 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
180
180
  }
181
181
 
182
182
  if (schemaType === "record") {
183
- return path === "providers.maxInFlightRequests" ? { ...base, type: "providerLimits" } : null;
183
+ return path === "providers.maxInFlightRequests" ? { ...base, type: "providerLimits" } : { ...base, type: "text" };
184
184
  }
185
185
 
186
186
  return null;
@@ -1235,9 +1235,21 @@ export class StatusLineComponent implements Component {
1235
1235
  }
1236
1236
  }
1237
1237
  }
1238
+ const leftOverflowDropIndex = (): number => {
1239
+ // Preserve the current working directory as long as possible. The
1240
+ // previous right-to-left pop could collapse a normal-width bar to
1241
+ // just the model segment, hiding the path before less-critical left
1242
+ // segments such as model/mode/collab were removed.
1243
+ for (let i = leftSegIds.length - 1; i >= 0; i--) {
1244
+ if (leftSegIds[i] !== "path") return i;
1245
+ }
1246
+ return left.length - 1;
1247
+ };
1248
+
1238
1249
  while (totalWidth() > topFillWidth && left.length > 0) {
1239
- left.pop();
1240
- leftSegIds.pop();
1250
+ const dropIdx = leftOverflowDropIndex();
1251
+ left.splice(dropIdx, 1);
1252
+ leftSegIds.splice(dropIdx, 1);
1241
1253
  leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
1242
1254
  }
1243
1255
  }
@@ -38,7 +38,7 @@ import {
38
38
  resolveImageOptions,
39
39
  truncateToWidth,
40
40
  } from "../../tools/render-utils";
41
- import { toolRenderers } from "../../tools/renderers";
41
+ import { type FirstResultViewportRepaint, toolRenderers } from "../../tools/renderers";
42
42
  import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
43
43
  import { isFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
44
44
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
@@ -283,13 +283,13 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
283
283
  // history, so progress renders static gray and further partial snapshots are
284
284
  // dropped (see #maybeFreezeBackgroundTask).
285
285
  #backgroundTaskFrozen = false;
286
- // Set on each `render()` when the last painted shape carried the streamed
287
- // SSH-style placeholder / partial-result chrome. Reset gates key off these
288
- // so a topology-changing update that lands before the shape reaches the
289
- // terminal never triggers a full-viewport replay (which on direct terminals
290
- // wipes native scrollback and flashes the user's history — reviewer note on
291
- // PR #4315).
292
- #placeholderShapePainted = false;
286
+ // Set on each `render()` when the last painted pending shape must be
287
+ // replayed wholesale when the first result arrives. Reset gates key off
288
+ // these so a topology-changing update that lands before the shape reaches
289
+ // the terminal never triggers a full-viewport replay (which on direct
290
+ // terminals wipes native scrollback and flashes the user's history —
291
+ // reviewer note on PR #4315).
292
+ #firstResultViewportRepaintShapePainted = false;
293
293
  #partialResultShapePainted = false;
294
294
  #renderState: {
295
295
  spinnerFrame?: number;
@@ -497,9 +497,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
497
497
  }
498
498
  const hadNoResult = this.#result === undefined;
499
499
  const wasPartialResult = this.#result !== undefined && this.#isPartial;
500
- const placeholderPainted = this.#placeholderShapePainted;
500
+ const firstResultRepaintShapePainted = this.#firstResultViewportRepaintShapePainted;
501
501
  const partialResultPainted = this.#partialResultShapePainted;
502
- this.#placeholderShapePainted = false;
502
+ this.#firstResultViewportRepaintShapePainted = false;
503
503
  this.#partialResultShapePainted = false;
504
504
  this.#result = result;
505
505
  this.#resultVersion++;
@@ -513,7 +513,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
513
513
  this.#updateTodoStrikeAnimation();
514
514
  this.#updateDisplay();
515
515
  this.#resetDisplayForResultTopologyChange(
516
- hadNoResult && placeholderPainted,
516
+ hadNoResult && firstResultRepaintShapePainted,
517
517
  wasPartialResult && partialResultPainted,
518
518
  isPartial,
519
519
  );
@@ -810,34 +810,38 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
810
810
  this.#displayBuilt = true;
811
811
  }
812
812
 
813
- #rendererFlag(name: "forceFirstResultViewportRepaint" | "forceResultViewportRepaintOnSettle"): boolean {
813
+ #rendererFlag(name: "forceResultViewportRepaintOnSettle"): boolean {
814
814
  const toolValue = (this.#tool as Record<string, unknown> | undefined)?.[name];
815
815
  const rendererValue = toolRenderers[this.#toolName]?.[name];
816
816
  return toolValue === true || (toolValue === undefined && rendererValue === true);
817
817
  }
818
818
 
819
819
  /**
820
- * True while the last painted shape uses the streamed placeholder path
821
- * (`⏳ SSH: […]` / `$ …`) — the render call ran with `__partialJson` args
822
- * and no result. Kept as a per-paint fact so a topology-changing update
823
- * that lands before the placeholder reaches the terminal skips the reset.
820
+ * True while the last painted pending-call shape opted into a full viewport
821
+ * repaint at the first result (`forceFirstResultViewportRepaint`) — e.g. the
822
+ * streamed SSH placeholder (`⏳ SSH: […]` / `$ …`) or a collapsed write tail
823
+ * window, both of which the first result render re-anchors instead of
824
+ * preserving. Kept as a per-paint fact so a topology-changing update that
825
+ * lands before the pending rows reach the terminal skips the reset.
824
826
  */
825
- #isPlaceholderShapeAtRender(): boolean {
827
+ #needsFirstResultViewportRepaintAtRender(): boolean {
826
828
  if (this.#result !== undefined) return false;
827
- if (!this.#rendererFlag("forceFirstResultViewportRepaint")) return false;
828
- return partialJsonOf(this.#args) !== undefined;
829
+ const toolValue = (this.#tool as { forceFirstResultViewportRepaint?: FirstResultViewportRepaint } | undefined)
830
+ ?.forceFirstResultViewportRepaint;
831
+ const value =
832
+ toolValue !== undefined ? toolValue : toolRenderers[this.#toolName]?.forceFirstResultViewportRepaint;
833
+ if (typeof value === "function") return value(this.#args, this.#renderState);
834
+ return value === true;
829
835
  }
830
836
 
831
837
  #resetDisplayForResultTopologyChange(
832
- firstResultAfterPlaceholderPaint: boolean,
838
+ firstResultAfterRepaintShapePaint: boolean,
833
839
  partialResultPaintedBeforeSettle: boolean,
834
840
  isPartial: boolean,
835
841
  ): void {
836
- const firstResultReplacesStreamedPlaceholder =
837
- firstResultAfterPlaceholderPaint && this.#rendererFlag("forceFirstResultViewportRepaint");
838
842
  const provisionalResultSettled =
839
843
  partialResultPaintedBeforeSettle && !isPartial && this.#rendererFlag("forceResultViewportRepaintOnSettle");
840
- if (firstResultReplacesStreamedPlaceholder || provisionalResultSettled) {
844
+ if (firstResultAfterRepaintShapePaint || provisionalResultSettled) {
841
845
  this.#ui.resetDisplay();
842
846
  }
843
847
  }
@@ -848,7 +852,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
848
852
  // override runs on every compose the parent Container performs, so a
849
853
  // frame that never gets composed leaves the flags false and prevents a
850
854
  // spurious `resetDisplay()`.
851
- this.#placeholderShapePainted = this.#isPlaceholderShapeAtRender();
855
+ this.#firstResultViewportRepaintShapePainted = this.#needsFirstResultViewportRepaintAtRender();
852
856
  this.#partialResultShapePainted = this.#result !== undefined && this.#isPartial;
853
857
  return lines;
854
858
  }
@@ -849,11 +849,7 @@ export class CommandController {
849
849
  }
850
850
 
851
851
  async #runNewSessionFlow(options?: NewSessionOptions, label: string = "New session started"): Promise<void> {
852
- if (this.ctx.loadingAnimation) {
853
- this.ctx.loadingAnimation.stop();
854
- this.ctx.loadingAnimation = undefined;
855
- }
856
- this.ctx.statusContainer.clear();
852
+ this.ctx.clearTransientSessionUi();
857
853
 
858
854
  if (this.ctx.session.isCompacting) {
859
855
  this.ctx.session.abortCompaction();
@@ -867,14 +863,9 @@ export class CommandController {
867
863
 
868
864
  this.ctx.statusLine.invalidate();
869
865
  this.ctx.statusLine.resetActiveTime();
870
- this.ctx.ui.requestRender();
871
866
  this.ctx.updateEditorBorderColor();
872
- this.ctx.chatContainer.clear();
873
- this.ctx.pendingMessagesContainer.clear();
874
- this.ctx.compactionQueuedMessages = [];
875
- this.ctx.streamingComponent = undefined;
876
- this.ctx.streamingMessage = undefined;
877
- this.ctx.pendingTools.clear();
867
+ this.ctx.clearTransientSessionUi();
868
+ this.ctx.resetTranscript();
878
869
 
879
870
  this.ctx.present([new Spacer(1), new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 1)]);
880
871
  await this.ctx.reloadTodos();
@@ -914,7 +905,7 @@ export class CommandController {
914
905
  this.ctx.loadingAnimation.stop();
915
906
  this.ctx.loadingAnimation = undefined;
916
907
  }
917
- this.ctx.statusContainer.clear();
908
+ this.ctx.statusContainer.disposeChildren();
918
909
 
919
910
  const success = await this.ctx.session.fork();
920
911
  if (!success) {
@@ -1177,7 +1168,7 @@ export class CommandController {
1177
1168
  this.ctx.loadingAnimation.stop();
1178
1169
  this.ctx.loadingAnimation = undefined;
1179
1170
  }
1180
- this.ctx.statusContainer.clear();
1171
+ this.ctx.statusContainer.disposeChildren();
1181
1172
 
1182
1173
  const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
1183
1174
  const compactingLoader = new Loader(
@@ -1207,7 +1198,7 @@ export class CommandController {
1207
1198
  await this.ctx.session.compact(instructions, options);
1208
1199
 
1209
1200
  compactingLoader.stop();
1210
- this.ctx.statusContainer.clear();
1201
+ this.ctx.statusContainer.disposeChildren();
1211
1202
  this.ctx.rebuildChatFromMessages();
1212
1203
 
1213
1204
  this.ctx.statusLine.invalidate();
@@ -1223,7 +1214,7 @@ export class CommandController {
1223
1214
  }
1224
1215
  } finally {
1225
1216
  compactingLoader.stop();
1226
- this.ctx.statusContainer.clear();
1217
+ this.ctx.statusContainer.disposeChildren();
1227
1218
  }
1228
1219
  // Run the caller's pre-flush hook (e.g. the plan-approval model transition)
1229
1220
  // before queued user input is dispatched, so any turn queued during
@@ -1252,7 +1243,7 @@ export class CommandController {
1252
1243
  this.ctx.loadingAnimation.stop();
1253
1244
  this.ctx.loadingAnimation = undefined;
1254
1245
  }
1255
- this.ctx.statusContainer.clear();
1246
+ this.ctx.statusContainer.disposeChildren();
1256
1247
 
1257
1248
  const handoffLoader = new Loader(
1258
1249
  this.ctx.ui,
@@ -1273,11 +1264,10 @@ export class CommandController {
1273
1264
  return;
1274
1265
  }
1275
1266
 
1276
- // Rebuild chat from the new session (which now contains the handoff document)
1277
- this.ctx.rebuildChatFromMessages();
1278
-
1267
+ // Rebuild chat from the new session (which now contains the handoff document).
1268
+ this.ctx.clearTransientSessionUi();
1269
+ this.ctx.renderInitialMessages();
1279
1270
  this.ctx.statusLine.invalidate();
1280
- this.ctx.ui.requestRender();
1281
1271
  this.ctx.updateEditorBorderColor();
1282
1272
  await this.ctx.reloadTodos();
1283
1273
 
@@ -1297,9 +1287,9 @@ export class CommandController {
1297
1287
  }
1298
1288
  } finally {
1299
1289
  handoffLoader.stop();
1300
- this.ctx.statusContainer.clear();
1290
+ this.ctx.statusContainer.disposeChildren();
1301
1291
  }
1302
- this.ctx.ui.requestRender();
1292
+ this.ctx.ui.requestRender(true, { clearScrollback: true });
1303
1293
  }
1304
1294
  }
1305
1295
 
@@ -379,7 +379,7 @@ export class EventController {
379
379
  if (this.ctx.retryLoader) {
380
380
  this.ctx.retryLoader.stop();
381
381
  this.ctx.retryLoader = undefined;
382
- this.ctx.statusContainer.clear();
382
+ this.ctx.statusContainer.disposeChildren();
383
383
  }
384
384
  this.#cancelIdleCompaction();
385
385
  this.#cancelIdleRecap();
@@ -1083,7 +1083,7 @@ export class EventController {
1083
1083
  if (this.ctx.loadingAnimation) {
1084
1084
  this.ctx.loadingAnimation.stop();
1085
1085
  this.ctx.loadingAnimation = undefined;
1086
- this.ctx.statusContainer.clear();
1086
+ this.ctx.statusContainer.disposeChildren();
1087
1087
  }
1088
1088
  if (this.ctx.streamingComponent) {
1089
1089
  this.ctx.chatContainer.removeChild(this.ctx.streamingComponent);
@@ -1125,9 +1125,9 @@ export class EventController {
1125
1125
 
1126
1126
  /**
1127
1127
  * Tear down the live "Working…" loader: stop its animation timer AND clear the
1128
- * reference. A transient overlay (auto-compaction / auto-retry) that only ran
1129
- * `statusContainer.clear()` detached the loader from the container but left
1130
- * `ctx.loadingAnimation` set, so the resumed turn's `agent_start` →
1128
+ * reference. A transient overlay (auto-compaction / auto-retry) can remove the
1129
+ * loader from the container while leaving `ctx.loadingAnimation` set, so the
1130
+ * resumed turn's `agent_start` →
1131
1131
  * `ensureLoadingAnimation()` (guarded by `if (!this.loadingAnimation)`) skipped
1132
1132
  * re-adding it and the spinner vanished while the agent kept streaming. Nulling
1133
1133
  * the reference here lets the next `agent_start` recreate and re-attach it.
@@ -1168,7 +1168,7 @@ export class EventController {
1168
1168
  this.#cancelIdleRecap();
1169
1169
  this.#setTerminalProgress(true);
1170
1170
  this.#stopWorkingLoader();
1171
- this.ctx.statusContainer.clear();
1171
+ this.ctx.statusContainer.disposeChildren();
1172
1172
  const reasonText =
1173
1173
  event.reason === "overflow"
1174
1174
  ? "Context overflow detected, "
@@ -1203,7 +1203,7 @@ export class EventController {
1203
1203
  if (this.ctx.autoCompactionLoader) {
1204
1204
  this.ctx.autoCompactionLoader.stop();
1205
1205
  this.ctx.autoCompactionLoader = undefined;
1206
- this.ctx.statusContainer.clear();
1206
+ this.ctx.statusContainer.disposeChildren();
1207
1207
  }
1208
1208
  const isHandoffAction = event.action === "handoff";
1209
1209
  const isShakeAction = event.action === "shake";
@@ -1245,12 +1245,12 @@ export class EventController {
1245
1245
  } else if (event.errorMessage) {
1246
1246
  this.ctx.showWarning(event.errorMessage);
1247
1247
  } else if (isHandoffAction) {
1248
- this.ctx.chatContainer.clear();
1248
+ this.ctx.clearTransientSessionUi();
1249
1249
  this.ctx.lastAssistantUsage = undefined;
1250
- this.ctx.rebuildChatFromMessages();
1250
+ this.ctx.renderInitialMessages();
1251
1251
  this.ctx.statusLine.invalidate();
1252
- this.ctx.ui.requestRender();
1253
1252
  await this.ctx.reloadTodos();
1253
+ this.ctx.ui.requestRender(true, { clearScrollback: true });
1254
1254
  this.ctx.showStatus("Auto-handoff completed");
1255
1255
  } else if (event.skipped) {
1256
1256
  // Benign skip: no model selected, no candidate models available, or nothing
@@ -1268,7 +1268,7 @@ export class EventController {
1268
1268
  async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
1269
1269
  this.#trackRetrySupersededAssistantComponent(this.#lastAssistantComponent);
1270
1270
  this.#stopWorkingLoader();
1271
- this.ctx.statusContainer.clear();
1271
+ this.ctx.statusContainer.disposeChildren();
1272
1272
  if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) {
1273
1273
  // The retry path drops the failed assistant from runtime context. Do not
1274
1274
  // restore its inline Error row; just unpin the fixed-region banner so the
@@ -1292,7 +1292,7 @@ export class EventController {
1292
1292
  if (this.ctx.retryLoader) {
1293
1293
  this.ctx.retryLoader.stop();
1294
1294
  this.ctx.retryLoader = undefined;
1295
- this.ctx.statusContainer.clear();
1295
+ this.ctx.statusContainer.disposeChildren();
1296
1296
  }
1297
1297
  if (event.success) {
1298
1298
  let appliedRecovered = false;