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

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 (80) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +3119 -3028
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/settings-schema.d.ts +6 -0
  6. package/dist/types/config/settings.d.ts +2 -0
  7. package/dist/types/discovery/helpers.d.ts +9 -0
  8. package/dist/types/exec/bash-executor.d.ts +1 -0
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  11. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  12. package/dist/types/modes/components/model-selector.d.ts +2 -1
  13. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  14. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  15. package/dist/types/modes/workflow.d.ts +5 -1
  16. package/dist/types/session/agent-session.d.ts +5 -0
  17. package/dist/types/system-prompt.d.ts +1 -1
  18. package/dist/types/tools/bash-interactive.d.ts +1 -1
  19. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  20. package/dist/types/tools/bash.d.ts +2 -1
  21. package/dist/types/tools/browser/launch.d.ts +1 -0
  22. package/dist/types/tools/grep.d.ts +2 -0
  23. package/dist/types/tools/index.d.ts +4 -0
  24. package/dist/types/tools/path-utils.d.ts +24 -0
  25. package/dist/types/tools/read.d.ts +2 -0
  26. package/dist/types/utils/local-date.d.ts +2 -0
  27. package/package.json +12 -12
  28. package/src/advisor/__tests__/advisor.test.ts +145 -0
  29. package/src/advisor/runtime.ts +19 -0
  30. package/src/config/api-key-resolver.ts +7 -2
  31. package/src/config/model-registry.ts +9 -0
  32. package/src/config/settings-schema.ts +11 -1
  33. package/src/config/settings.ts +11 -0
  34. package/src/discovery/builtin.ts +2 -1
  35. package/src/discovery/claude-plugins.ts +167 -46
  36. package/src/discovery/helpers.ts +16 -1
  37. package/src/edit/renderer.ts +20 -6
  38. package/src/eval/js/worker-core.ts +163 -6
  39. package/src/exec/bash-executor.ts +14 -9
  40. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  41. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  42. package/src/extensibility/shared-events.ts +2 -2
  43. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  44. package/src/internal-urls/docs-index.generated.txt +1 -1
  45. package/src/internal-urls/registry-helpers.ts +9 -6
  46. package/src/modes/components/model-selector.ts +30 -6
  47. package/src/modes/components/settings-defs.ts +1 -1
  48. package/src/modes/components/status-line/component.ts +14 -2
  49. package/src/modes/controllers/command-controller.ts +13 -23
  50. package/src/modes/controllers/event-controller.ts +12 -12
  51. package/src/modes/controllers/extension-ui-controller.ts +6 -35
  52. package/src/modes/controllers/input-controller.ts +18 -2
  53. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  54. package/src/modes/controllers/selector-controller.ts +16 -5
  55. package/src/modes/github-ref-autocomplete.ts +75 -0
  56. package/src/modes/interactive-mode.ts +54 -10
  57. package/src/modes/prompt-action-autocomplete.ts +35 -0
  58. package/src/modes/utils/context-usage.ts +58 -5
  59. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  60. package/src/modes/utils/ui-helpers.ts +2 -2
  61. package/src/modes/workflow.ts +14 -8
  62. package/src/prompts/system/plan-mode-active.md +5 -2
  63. package/src/prompts/system/system-prompt.md +1 -2
  64. package/src/prompts/system/workflow-notice.md +69 -50
  65. package/src/prompts/tools/bash.md +18 -7
  66. package/src/prompts/tools/grep.md +1 -1
  67. package/src/prompts/tools/read.md +4 -3
  68. package/src/sdk.ts +11 -0
  69. package/src/session/agent-session.ts +128 -14
  70. package/src/system-prompt.ts +3 -2
  71. package/src/tools/bash-interactive.ts +1 -1
  72. package/src/tools/bash-skill-urls.ts +39 -7
  73. package/src/tools/bash.ts +69 -39
  74. package/src/tools/browser/launch.ts +31 -4
  75. package/src/tools/grep.ts +53 -14
  76. package/src/tools/index.ts +11 -0
  77. package/src/tools/path-utils.ts +46 -1
  78. package/src/tools/read.ts +108 -50
  79. package/src/utils/local-date.ts +7 -0
  80. 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;
@@ -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();
@@ -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
  }
@@ -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;
@@ -162,18 +162,12 @@ export class ExtensionUiController {
162
162
  waitForIdle: () => this.ctx.session.agent.waitForIdle(),
163
163
  reload: async () => {
164
164
  await this.ctx.session.reload();
165
- this.ctx.chatContainer.clear();
166
165
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
167
166
  await this.ctx.reloadTodos();
168
167
  this.ctx.showStatus("Reloaded session");
169
168
  },
170
169
  newSession: async options => {
171
- // Stop any loading animation
172
- if (this.ctx.loadingAnimation) {
173
- this.ctx.loadingAnimation.stop();
174
- this.ctx.loadingAnimation = undefined;
175
- }
176
- this.ctx.statusContainer.clear();
170
+ this.ctx.clearTransientSessionUi();
177
171
 
178
172
  // Create new session
179
173
  this.clearExtensionTerminalInputListeners();
@@ -192,15 +186,8 @@ export class ExtensionUiController {
192
186
  // Reset and update status line
193
187
  this.ctx.statusLine.invalidate();
194
188
  this.ctx.statusLine.resetActiveTime();
195
- this.ctx.ui.requestRender();
196
-
197
- // Clear UI state
198
- this.ctx.chatContainer.clear();
199
- this.ctx.pendingMessagesContainer.clear();
200
- this.ctx.compactionQueuedMessages = [];
201
- this.ctx.streamingComponent = undefined;
202
- this.ctx.streamingMessage = undefined;
203
- this.ctx.pendingTools.clear();
189
+ this.ctx.clearTransientSessionUi();
190
+ this.ctx.resetTranscript();
204
191
 
205
192
  this.ctx.present([
206
193
  new Spacer(1),
@@ -218,7 +205,6 @@ export class ExtensionUiController {
218
205
  }
219
206
 
220
207
  // Update UI
221
- this.ctx.chatContainer.clear();
222
208
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
223
209
  await this.ctx.reloadTodos();
224
210
  this.ctx.editor.setText(result.selectedText);
@@ -233,7 +219,6 @@ export class ExtensionUiController {
233
219
  }
234
220
 
235
221
  // Update UI
236
- this.ctx.chatContainer.clear();
237
222
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
238
223
  await this.ctx.reloadTodos();
239
224
  if (result.editorText && !this.ctx.editor.getText().trim()) {
@@ -251,7 +236,6 @@ export class ExtensionUiController {
251
236
  return { cancelled: true };
252
237
  }
253
238
  setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
254
- this.ctx.chatContainer.clear();
255
239
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
256
240
  await this.ctx.reloadTodos();
257
241
  return { cancelled: false };
@@ -398,18 +382,12 @@ export class ExtensionUiController {
398
382
  waitForIdle: () => this.ctx.session.agent.waitForIdle(),
399
383
  reload: async () => {
400
384
  await this.ctx.session.reload();
401
- this.ctx.chatContainer.clear();
402
385
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
403
386
  await this.ctx.reloadTodos();
404
387
  this.ctx.showStatus("Reloaded session");
405
388
  },
406
389
  newSession: async options => {
407
- // Stop any loading animation
408
- if (this.ctx.loadingAnimation) {
409
- this.ctx.loadingAnimation.stop();
410
- this.ctx.loadingAnimation = undefined;
411
- }
412
- this.ctx.statusContainer.clear();
390
+ this.ctx.clearTransientSessionUi();
413
391
 
414
392
  // Create new session
415
393
  this.clearExtensionTerminalInputListeners();
@@ -425,12 +403,8 @@ export class ExtensionUiController {
425
403
  }
426
404
 
427
405
  // Clear UI state
428
- this.ctx.chatContainer.clear();
429
- this.ctx.pendingMessagesContainer.clear();
430
- this.ctx.compactionQueuedMessages = [];
431
- this.ctx.streamingComponent = undefined;
432
- this.ctx.streamingMessage = undefined;
433
- this.ctx.pendingTools.clear();
406
+ this.ctx.clearTransientSessionUi();
407
+ this.ctx.resetTranscript();
434
408
 
435
409
  this.ctx.present([
436
410
  new Spacer(1),
@@ -448,7 +422,6 @@ export class ExtensionUiController {
448
422
  }
449
423
 
450
424
  // Update UI
451
- this.ctx.chatContainer.clear();
452
425
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
453
426
  await this.ctx.reloadTodos();
454
427
  this.ctx.editor.setText(result.selectedText);
@@ -463,7 +436,6 @@ export class ExtensionUiController {
463
436
  }
464
437
 
465
438
  // Update UI
466
- this.ctx.chatContainer.clear();
467
439
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
468
440
  await this.ctx.reloadTodos();
469
441
  if (result.editorText && !this.ctx.editor.getText().trim()) {
@@ -480,7 +452,6 @@ export class ExtensionUiController {
480
452
  if (!result) {
481
453
  return { cancelled: true };
482
454
  }
483
- this.ctx.chatContainer.clear();
484
455
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
485
456
  await this.ctx.reloadTodos();
486
457
  return { cancelled: false };
@@ -86,6 +86,20 @@ function hasPasteText(value: unknown): value is PasteTarget {
86
86
  return typeof value === "object" && value !== null && typeof (value as PasteTarget).pasteText === "function";
87
87
  }
88
88
 
89
+ const SHELL_PROMPT_COMMAND_RE =
90
+ /^(?:\.{0,2}\/|~\/|cd(?:\s|$)|sudo(?:\s|$)|git(?:\s|$)|bun(?:\s|$)|npm(?:\s|$)|pnpm(?:\s|$)|yarn(?:\s|$)|node(?:\s|$)|python\d*(?:\s|$)|cargo(?:\s|$)|go(?:\s|$)|make(?:\s|$)|docker(?:\s|$)|kubectl(?:\s|$))/;
91
+ const SHELL_PROMPT_OPERATOR_RE = /(?:^|\s)(?:&&|\|\||\||2>&1|[<>]{1,2})(?:\s|$)/;
92
+ const OMP_STATUS_LINE_RE = /^\s*in:\s+\d+\s+out:\s+\d+(?:\s+cache\s+\S+)?\s+t:\s+\S+\s+tok\/s:\s+\S+/m;
93
+
94
+ function looksLikePastedShellPrompt(code: string): boolean {
95
+ const firstLine = code.split("\n", 1)[0]?.trimStart() ?? "";
96
+ return (
97
+ SHELL_PROMPT_COMMAND_RE.test(firstLine) ||
98
+ SHELL_PROMPT_OPERATOR_RE.test(firstLine) ||
99
+ OMP_STATUS_LINE_RE.test(code)
100
+ );
101
+ }
102
+
89
103
  function pythonCommandPrefixLength(trimmedText: string): 0 | 1 | 2 {
90
104
  if (trimmedText.charCodeAt(0) !== 36 /* $ */) return 0;
91
105
  if (trimmedText.charCodeAt(1) === 123 /* { */) return 0;
@@ -100,8 +114,10 @@ function parsePythonCommandInput(text: string): { code: string; isExcluded: bool
100
114
  const trimmed = text.trimStart();
101
115
  const prefixLength = pythonCommandPrefixLength(trimmed);
102
116
  if (prefixLength === 0) return undefined;
117
+ const code = trimmed.slice(prefixLength).trim();
118
+ if (prefixLength === 1 && looksLikePastedShellPrompt(code)) return undefined;
103
119
  return {
104
- code: trimmed.slice(prefixLength).trim(),
120
+ code,
105
121
  isExcluded: prefixLength === 2,
106
122
  };
107
123
  }
@@ -536,7 +552,7 @@ export class InputController {
536
552
  const wasPythonMode = this.ctx.isPythonMode;
537
553
  const trimmed = text.trimStart();
538
554
  this.ctx.isBashMode = trimmed.startsWith("!");
539
- this.ctx.isPythonMode = pythonCommandPrefixLength(trimmed) > 0;
555
+ this.ctx.isPythonMode = parsePythonCommandInput(trimmed) !== undefined;
540
556
  if (wasBashMode !== this.ctx.isBashMode || wasPythonMode !== this.ctx.isPythonMode) {
541
557
  this.ctx.updateEditorBorderColor();
542
558
  }
@@ -88,12 +88,14 @@ function raceAbortSignal<T>(promise: Promise<T>, signal: AbortSignal, createErro
88
88
  const MCP_AUTH_MIN_WRAP_WIDTH = 16;
89
89
 
90
90
  /**
91
- * Wrap `url` into rows that each fit inside `width`, prefixed by a shared
92
- * single-column indent so nested composition doesn't touch column 0. When the
93
- * label + URL fit on one line, returns a single row; otherwise puts the label
94
- * on its own row and slices the URL into fixed-width chunks. URL chunks are
95
- * plain code points browsers strip whitespace when pasted into the address
96
- * bar, so a multi-row selection copies back to the intact URL.
91
+ * Wrap `url` into rows that each fit inside `width`. When the label + URL fit
92
+ * on one line, returns a single indented row; otherwise puts the label on its
93
+ * own indented row and slices the URL into fixed-width chunks that start at
94
+ * column 0. Continuation chunks carry ZERO leading bytes on purpose: a
95
+ * multi-row terminal selection includes the newline plus any leading indent,
96
+ * and while address bars strip newlines they preserve or percent-encode
97
+ * embedded spaces — an indent would corrupt the URL at every chunk boundary
98
+ * (silently, when the damage lands inside a query value).
97
99
  */
98
100
  function wrapUrlRows(label: string, url: string, width: number): string[] {
99
101
  const indent = " ";
@@ -103,10 +105,9 @@ function wrapUrlRows(label: string, url: string, width: number): string[] {
103
105
  if (inlineWidth <= effective) {
104
106
  return [`${indent}${theme.fg("muted", `${label} ${sanitized}`)}`];
105
107
  }
106
- const chunkWidth = Math.max(1, effective - indent.length);
107
108
  const rows: string[] = [`${indent}${theme.fg("muted", label)}`];
108
- for (let i = 0; i < sanitized.length; i += chunkWidth) {
109
- rows.push(`${indent}${theme.fg("muted", sanitized.slice(i, i + chunkWidth))}`);
109
+ for (let i = 0; i < sanitized.length; i += effective) {
110
+ rows.push(theme.fg("muted", sanitized.slice(i, i + effective)));
110
111
  }
111
112
  return rows;
112
113
  }
@@ -593,13 +593,27 @@ export class SelectorController {
593
593
  this.ctx.settings,
594
594
  this.ctx.session.modelRegistry,
595
595
  this.ctx.session.scopedModels,
596
- async (model, role, thinkingLevel, selector) => {
596
+ async (model, role, thinkingLevel, selector, action) => {
597
597
  // `auto` is session-global: never baked into a per-role model value
598
598
  // (it can't round-trip through `model:<level>`). Apply it to the session
599
599
  // separately and persist via `defaultThinkingLevel`.
600
600
  const isAuto = thinkingLevel === AUTO_THINKING;
601
601
  const concreteThinking = isAuto ? undefined : thinkingLevel;
602
+ const selectorValue = selector ?? `${model.provider}/${model.id}`;
602
603
  try {
604
+ if (action === "retryFallback" && role !== null) {
605
+ const fallbackSelector = formatModelSelectorValue(selectorValue, concreteThinking);
606
+ const fallbackChains = this.ctx.settings.get("retry.fallbackChains");
607
+ const chain = Array.isArray(fallbackChains[role]) ? fallbackChains[role] : [];
608
+ this.ctx.settings.set("retry.fallbackChains", {
609
+ ...fallbackChains,
610
+ [role]: [fallbackSelector, ...chain.filter(existing => existing !== fallbackSelector)],
611
+ });
612
+ const roleInfo = getRoleInfo(role, settings);
613
+ const roleLabel = roleInfo?.name ?? role;
614
+ this.ctx.showStatus(`${roleLabel} fallback model: ${fallbackSelector}`);
615
+ return;
616
+ }
603
617
  if (role === null) {
604
618
  // Temporary: update agent state but don't persist the model to settings
605
619
  await this.ctx.session.setModelTemporary(model);
@@ -771,7 +785,6 @@ export class SelectorController {
771
785
  return;
772
786
  }
773
787
 
774
- this.ctx.chatContainer.clear();
775
788
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
776
789
  this.ctx.editor.setText(result.selectedText);
777
790
  done();
@@ -915,7 +928,6 @@ export class SelectorController {
915
928
 
916
929
  // Update UI — rebuild the display transcript for the new leaf (the
917
930
  // context from navigateTree is the LLM context, not the transcript).
918
- this.ctx.chatContainer.clear();
919
931
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
920
932
  await this.ctx.reloadTodos();
921
933
  if (result.editorText && !this.ctx.editor.getText().trim()) {
@@ -927,7 +939,7 @@ export class SelectorController {
927
939
  } finally {
928
940
  if (summaryLoader) {
929
941
  summaryLoader.stop();
930
- this.ctx.statusContainer.clear();
942
+ this.ctx.statusContainer.disposeChildren();
931
943
  }
932
944
  this.ctx.editor.onEscape = originalOnEscape;
933
945
  }
@@ -1067,7 +1079,6 @@ export class SelectorController {
1067
1079
  this.ctx.updateEditorBorderColor();
1068
1080
 
1069
1081
  // Clear and re-render the chat
1070
- this.ctx.chatContainer.clear();
1071
1082
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
1072
1083
  await this.ctx.reloadTodos();
1073
1084
  this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session");