@oh-my-pi/pi-coding-agent 16.4.6 → 16.5.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 (141) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/cli.js +3312 -3254
  3. package/dist/types/cli/args.d.ts +5 -0
  4. package/dist/types/cli/gallery-fixtures/shell.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +15 -0
  6. package/dist/types/config/model-resolver.d.ts +4 -3
  7. package/dist/types/config/model-roles.d.ts +8 -0
  8. package/dist/types/config/settings-schema.d.ts +46 -14
  9. package/dist/types/extensibility/extensions/types.d.ts +1 -1
  10. package/dist/types/launch/broker.d.ts +2 -0
  11. package/dist/types/launch/client.d.ts +22 -0
  12. package/dist/types/launch/paths.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +8 -0
  14. package/dist/types/launch/protocol.d.ts +170 -0
  15. package/dist/types/launch/terminal-output.d.ts +7 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +1 -1
  17. package/dist/types/modes/components/index.d.ts +1 -0
  18. package/dist/types/modes/components/model-browser.d.ts +28 -3
  19. package/dist/types/modes/components/model-hub.d.ts +0 -10
  20. package/dist/types/modes/components/model-picker.d.ts +44 -0
  21. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  22. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  23. package/dist/types/modes/components/welcome.d.ts +4 -0
  24. package/dist/types/modes/print-mode.d.ts +14 -8
  25. package/dist/types/modes/print-mode.test.d.ts +1 -0
  26. package/dist/types/modes/theme/theme.d.ts +2 -1
  27. package/dist/types/sdk.d.ts +5 -1
  28. package/dist/types/session/agent-session.d.ts +42 -0
  29. package/dist/types/session/session-context.d.ts +9 -0
  30. package/dist/types/session/session-entries.d.ts +6 -0
  31. package/dist/types/thinking.d.ts +2 -2
  32. package/dist/types/tiny/models.d.ts +1 -1
  33. package/dist/types/tools/browser/launch.d.ts +1 -0
  34. package/dist/types/tools/browser/run-cancellation.d.ts +28 -2
  35. package/dist/types/tools/browser/tab-protocol.d.ts +6 -0
  36. package/dist/types/tools/browser/tab-worker.d.ts +6 -0
  37. package/dist/types/tools/builtin-names.d.ts +1 -1
  38. package/dist/types/tools/index.d.ts +1 -0
  39. package/dist/types/tools/launch.d.ts +121 -0
  40. package/dist/types/tools/render-utils.d.ts +2 -0
  41. package/dist/types/tools/terminal-output.d.ts +5 -0
  42. package/dist/types/vibe/runtime.d.ts +2 -2
  43. package/dist/types/web/search/types.d.ts +0 -8
  44. package/package.json +20 -20
  45. package/src/cli/args.ts +11 -0
  46. package/src/cli/flag-tables.ts +9 -0
  47. package/src/cli/gallery-fixtures/shell.ts +82 -1
  48. package/src/cli.ts +10 -0
  49. package/src/commands/launch.ts +17 -0
  50. package/src/config/model-resolver.ts +110 -31
  51. package/src/config/model-roles.ts +14 -0
  52. package/src/config/settings-schema.ts +71 -7
  53. package/src/edit/renderer.ts +13 -10
  54. package/src/eval/__tests__/agent-bridge.test.ts +2 -2
  55. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  56. package/src/eval/completion-bridge.ts +4 -4
  57. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  58. package/src/export/ttsr.ts +0 -3
  59. package/src/extensibility/extensions/model-api.ts +1 -1
  60. package/src/extensibility/extensions/types.ts +1 -1
  61. package/src/internal-urls/docs-index.ts +4 -4
  62. package/src/launch/broker.ts +1017 -0
  63. package/src/launch/client.ts +344 -0
  64. package/src/launch/paths.ts +17 -0
  65. package/src/launch/presence.ts +82 -0
  66. package/src/launch/protocol.ts +386 -0
  67. package/src/launch/terminal-output.ts +46 -0
  68. package/src/main.ts +50 -1
  69. package/src/modes/acp/acp-agent.ts +8 -1
  70. package/src/modes/components/agent-hub.ts +101 -31
  71. package/src/modes/components/compaction-summary-message.ts +8 -2
  72. package/src/modes/components/index.ts +1 -0
  73. package/src/modes/components/model-browser.ts +152 -49
  74. package/src/modes/components/model-hub.ts +55 -142
  75. package/src/modes/components/model-picker.ts +233 -0
  76. package/src/modes/components/plan-review-overlay.ts +7 -0
  77. package/src/modes/components/snapcompact-shape-preview-doc.md +7 -11
  78. package/src/modes/components/status-line/component.test.ts +41 -2
  79. package/src/modes/components/status-line/component.ts +4 -0
  80. package/src/modes/components/status-line/segments.ts +6 -0
  81. package/src/modes/components/status-line/types.ts +3 -0
  82. package/src/modes/components/tips.txt +2 -1
  83. package/src/modes/components/welcome.ts +13 -14
  84. package/src/modes/controllers/command-controller.ts +9 -1
  85. package/src/modes/controllers/event-controller.ts +31 -32
  86. package/src/modes/controllers/selector-controller.ts +96 -22
  87. package/src/modes/controllers/tan-command-controller.ts +40 -1
  88. package/src/modes/interactive-mode.ts +20 -3
  89. package/src/modes/print-mode.test.ts +71 -0
  90. package/src/modes/print-mode.ts +51 -2
  91. package/src/modes/theme/theme.ts +9 -0
  92. package/src/modes/utils/ui-helpers.ts +25 -4
  93. package/src/prompts/agents/designer.md +1 -1
  94. package/src/prompts/agents/librarian.md +1 -1
  95. package/src/prompts/agents/reviewer.md +1 -1
  96. package/src/prompts/agents/scout.md +1 -1
  97. package/src/prompts/system/plan-yolo-handoff.md +5 -0
  98. package/src/prompts/system/prewalk-checklist.md +7 -0
  99. package/src/prompts/system/prewalk-continue.md +1 -0
  100. package/src/prompts/system/prewalk-plan.md +13 -0
  101. package/src/prompts/system/system-prompt.md +9 -7
  102. package/src/prompts/system/tan-context-switch.md +17 -0
  103. package/src/prompts/tools/bash.md +6 -4
  104. package/src/prompts/tools/browser.md +4 -4
  105. package/src/prompts/tools/launch.md +25 -0
  106. package/src/sdk.ts +8 -2
  107. package/src/session/agent-session.ts +550 -87
  108. package/src/session/session-context.test.ts +10 -5
  109. package/src/session/session-context.ts +25 -8
  110. package/src/session/session-entries.ts +6 -0
  111. package/src/slash-commands/builtin-registry.ts +25 -0
  112. package/src/task/agents.ts +2 -2
  113. package/src/thinking.ts +10 -3
  114. package/src/tiny/models.ts +7 -7
  115. package/src/tools/bash-interactive.ts +5 -8
  116. package/src/tools/bash.ts +38 -24
  117. package/src/tools/browser/cmux/cmux-tab.ts +17 -2
  118. package/src/tools/browser/launch.ts +8 -4
  119. package/src/tools/browser/run-cancellation.ts +66 -6
  120. package/src/tools/browser/tab-protocol.ts +6 -0
  121. package/src/tools/browser/tab-supervisor.ts +17 -1
  122. package/src/tools/browser/tab-worker.ts +140 -21
  123. package/src/tools/builtin-names.ts +1 -0
  124. package/src/tools/eval-render.ts +16 -14
  125. package/src/tools/index.ts +5 -0
  126. package/src/tools/inspect-image.ts +2 -2
  127. package/src/tools/launch.ts +643 -0
  128. package/src/tools/render-utils.ts +3 -0
  129. package/src/tools/renderers.ts +2 -0
  130. package/src/tools/terminal-output.ts +141 -0
  131. package/src/tts/speech-enhancer.ts +2 -2
  132. package/src/utils/image-vision-fallback.ts +3 -3
  133. package/src/vibe/runtime.ts +2 -2
  134. package/src/web/search/provider.ts +0 -10
  135. package/src/web/search/providers/perplexity.ts +18 -2
  136. package/src/web/search/providers/public.ts +2 -4
  137. package/src/web/search/types.ts +0 -10
  138. package/dist/types/web/search/providers/bing.d.ts +0 -14
  139. package/dist/types/web/search/providers/yahoo.d.ts +0 -14
  140. package/src/web/search/providers/bing.ts +0 -197
  141. package/src/web/search/providers/yahoo.ts +0 -179
@@ -82,6 +82,8 @@ export interface PlanReviewOverlayCallbacks {
82
82
  onPick: (label: string) => void;
83
83
  /** Invoked on Esc / cancel. */
84
84
  onCancel: () => void;
85
+ /** Invoked with the current full plan text when the copy hotkey is pressed. */
86
+ onCopyPlan?: (content: string) => void | Promise<void>;
85
87
  /** Invoked when the external-editor key is pressed (overlay stays open). */
86
88
  onExternalEditor?: () => void;
87
89
  /** Invoked when the external-editor key edits the active annotation draft. */
@@ -302,6 +304,10 @@ export class PlanReviewOverlay implements Component {
302
304
  this.callbacks.onExternalEditor();
303
305
  return;
304
306
  }
307
+ if (this.callbacks.onCopyPlan && keyData === "c") {
308
+ void this.callbacks.onCopyPlan(joinPlanSections(this.#sections));
309
+ return;
310
+ }
305
311
  if (matchesKey(keyData, "tab") || keyData === "\t") {
306
312
  this.#cycleRegion(1);
307
313
  return;
@@ -677,6 +683,7 @@ export class PlanReviewOverlay implements Component {
677
683
  parts.push("↑↓ scroll", "⇧ faster", "pgup/pgdn", "g/G ends");
678
684
  break;
679
685
  }
686
+ if (this.callbacks.onCopyPlan) parts.push("c copy");
680
687
  parts.push("tab regions");
681
688
  if (this.#externalEditorLabel && this.#focus !== "toc") parts.push(`${this.#externalEditorLabel} editor`);
682
689
  parts.push(this.#helpSuffix);
@@ -1,18 +1,14 @@
1
- # User
2
- Fix the settings overlay crash. Wheeling past the last row throws.
1
+ ¶user:Fix the settings overlay crash. Wheeling past the last row throws.
3
2
 
4
- # Tool call
5
- //Reading the select-list hit test
6
- read(path="src/select-list.ts:140-180")
3
+ ¶call:read(path="src/select-list.ts:140-180")//Reading the select-list hit test
7
4
  <out>
8
5
  162: const index = Math.floor(line / rowHeight); index is never checked against bounds.
9
6
  </out>
10
7
 
11
- # Assistant
12
- Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash.
8
+ ¶ai:Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash.
13
9
 
14
- # User
15
- Does the fix survive filtering?
10
+ ¶user:Does the fix survive filtering?
16
11
 
17
- # Assistant
18
- Yes. The clamp applies after the filter pass, so a narrowed list keeps the hit map in sync. Added a regression test that wheels past the last row with a filter active and asserts no throw.
12
+ ¶think:Check whether the clamp runs before or after filtering.
13
+
14
+ ¶ai:Yes. The clamp applies after the filter pass, so a narrowed list keeps the hit map in sync. Added a regression test that wheels past the last row with a filter active and asserts no throw.
@@ -4,15 +4,43 @@ import type { AgentSession } from "../../../session/agent-session";
4
4
  import { getThemeByName, setThemeInstance } from "../../theme/theme";
5
5
  import { StatusLineComponent } from "./component";
6
6
 
7
- function makeSessionWithLastMessage(lastMessage: unknown) {
7
+ function makeSessionWithLastMessage(lastMessage: unknown, prewalkArmed: boolean = false) {
8
8
  return {
9
- messages: [lastMessage],
9
+ messages: lastMessage ? [lastMessage] : [],
10
10
  model: { contextWindow: 128000 },
11
11
  contextUsageRevision: 0,
12
12
  systemPrompt: [],
13
13
  agent: { state: { tools: [] } },
14
14
  skills: [],
15
15
  getContextUsage: () => ({ tokens: 42, contextWindow: 128000 }),
16
+ state: {
17
+ messages: lastMessage ? [lastMessage] : [],
18
+ model: { contextWindow: 128000 },
19
+ },
20
+ sessionManager: {
21
+ getUsageStatistics: () => ({
22
+ input: 0,
23
+ output: 0,
24
+ cacheRead: 0,
25
+ cacheWrite: 0,
26
+ totalTokens: 0,
27
+ orchestrationInput: 0,
28
+ orchestrationOutput: 0,
29
+ orchestrationCacheRead: 0,
30
+ premiumRequests: 0,
31
+ cost: 0,
32
+ tokensPerSecond: null,
33
+ }),
34
+ getSessionName: () => "test-session",
35
+ },
36
+ getPrewalkState: () => (prewalkArmed ? { target: { id: "cheap-model", provider: "openai" } } : undefined),
37
+ getAsyncJobSnapshot: () => undefined,
38
+ isAdvisorActive: () => false,
39
+ isFastModeActive: () => false,
40
+ configuredThinkingLevel: () => undefined,
41
+ modelRegistry: {
42
+ isUsingOAuth: () => false,
43
+ },
16
44
  };
17
45
  }
18
46
 
@@ -41,4 +69,15 @@ describe("StatusLineComponent", () => {
41
69
 
42
70
  expect(statusLine.getCachedContextBreakdown()).toEqual({ usedTokens: 42, contextWindow: 128000 });
43
71
  });
72
+
73
+ it("renders Prewalk annotation when prewalk is armed", () => {
74
+ const statusLine = new StatusLineComponent(makeSessionWithLastMessage(null, true) as unknown as AgentSession);
75
+
76
+ // By default preset, 'mode' segment is included in left/right segments.
77
+ // Let's get the border and see if Prewalk is rendered.
78
+ const border = statusLine.getTopBorder(100);
79
+ // SGR codes might be included, so we check if the stripped content contains "Prewalk"
80
+ const stripped = border.content.replace(/\x1b\[[0-9;]*m/g, "");
81
+ expect(stripped).toContain("Prewalk");
82
+ });
44
83
  });
@@ -1051,6 +1051,10 @@ export class StatusLineComponent implements Component {
1051
1051
  compactThinkingLevel: this.#resolveSettings().compactThinkingLevel ?? false,
1052
1052
  planMode: this.#planModeStatus,
1053
1053
  loopMode: this.#loopModeStatus,
1054
+ prewalk:
1055
+ typeof this.session.getPrewalkState === "function" && this.session.getPrewalkState()
1056
+ ? { enabled: true }
1057
+ : null,
1054
1058
  goalMode: this.#goalModeStatus,
1055
1059
  vibeMode: this.#vibeModeStatus,
1056
1060
  collab: this.#collabStatus,
@@ -208,6 +208,12 @@ const modeSegment: StatusLineSegment = {
208
208
  return { content: theme.fg(color, content), visible: true };
209
209
  }
210
210
 
211
+ const prewalk = ctx.prewalk;
212
+ if (prewalk?.enabled) {
213
+ const content = withIcon(theme.icon.prewalk, "Prewalk");
214
+ return { content: theme.fg("accent", content), visible: true };
215
+ }
216
+
211
217
  const goal = ctx.goalMode;
212
218
  if (goal && (goal.enabled || goal.paused)) {
213
219
  return renderGoalMode(ctx, goal);
@@ -60,6 +60,9 @@ export interface SegmentContext {
60
60
  enabled: boolean;
61
61
  paused: boolean;
62
62
  } | null;
63
+ prewalk: {
64
+ enabled: boolean;
65
+ } | null;
63
66
  loopMode: {
64
67
  enabled: boolean;
65
68
  } | null;
@@ -21,4 +21,5 @@ Pair up live: `/collab` shares your session through an end-to-end encrypted rela
21
21
  Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript
22
22
  Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota
23
23
  No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm|kimi|anthropic…` rolls it locally for them!
24
- Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice [NEW]
24
+ Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice
25
+ Try starting your prompt with a ->, and writing a list (1. Do X, 2. Do Y)
@@ -44,20 +44,19 @@ const NEW_GLOW_PERIOD_MS = 1500;
44
44
  * affordance surfaces this many times as often. */
45
45
  const NEW_TIP_WEIGHT = 4;
46
46
 
47
- /** Per-tip selection weights, parallel to {@link TIPS}. */
48
- const TIP_WEIGHTS: readonly number[] = TIPS.map(tip => (NEW_TIP_MARKER.test(tip) ? NEW_TIP_WEIGHT : 1));
49
- const TIP_WEIGHT_TOTAL = TIP_WEIGHTS.reduce((sum, weight) => sum + weight, 0);
50
-
51
- /** Pick a tip at random, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT}.
52
- * Returns "" when no tips are embedded. */
53
- function pickWeightedTip(): string {
54
- if (TIPS.length === 0) return "";
55
- let r = Math.random() * TIP_WEIGHT_TOTAL;
56
- for (let i = 0; i < TIPS.length; i++) {
57
- r -= TIP_WEIGHTS[i] ?? 1;
58
- if (r < 0) return TIPS[i] ?? "";
47
+ /** Pick a tip from `tips`, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT};
48
+ * `r` is a uniform sample in [0, 1). Returns "" when `tips` is empty.
49
+ * Exported for tests. */
50
+ export function pickWeightedTip(tips: readonly string[], r: number): string {
51
+ if (tips.length === 0) return "";
52
+ const weights = tips.map(tip => (NEW_TIP_MARKER.test(tip) ? NEW_TIP_WEIGHT : 1));
53
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
54
+ let acc = r * total;
55
+ for (let i = 0; i < tips.length; i++) {
56
+ acc -= weights[i] ?? 1;
57
+ if (acc < 0) return tips[i] ?? "";
59
58
  }
60
- return TIPS[TIPS.length - 1] ?? "";
59
+ return tips[tips.length - 1] ?? "";
61
60
  }
62
61
 
63
62
  type ColorEncoding = "ansi-16m" | "ansi-256";
@@ -161,7 +160,7 @@ export class WelcomeComponent implements Component {
161
160
  if (theme.getSymbolPreset() === "unicode" && Math.random() < 0.1) {
162
161
  this.#selectedTip = "Please use nerdfont 😭.";
163
162
  } else {
164
- this.#selectedTip = pickWeightedTip();
163
+ this.#selectedTip = pickWeightedTip(TIPS, Math.random());
165
164
  }
166
165
  }
167
166
  return this.#selectedTip || undefined;
@@ -1200,7 +1200,15 @@ export class CommandController {
1200
1200
  this.ctx.rebuildChatFromMessages();
1201
1201
 
1202
1202
  this.ctx.statusLine.invalidate();
1203
- this.ctx.ui.requestRender();
1203
+ // Same as the auto-compaction rebuild: a collapsed transcript is an
1204
+ // intentional replacement, so drop the stale pre-compaction scrollback
1205
+ // instead of repainting the shrunken frame below it. With collapse
1206
+ // disabled the full history stays inline and scrollback is kept.
1207
+ if (this.ctx.settings.get("display.collapseCompacted")) {
1208
+ this.ctx.ui.requestRender(true, { clearScrollback: true });
1209
+ } else {
1210
+ this.ctx.ui.requestRender();
1211
+ }
1204
1212
  } catch (error) {
1205
1213
  if (error instanceof CompactionCancelledError) {
1206
1214
  outcome = "cancelled";
@@ -76,7 +76,7 @@ export class EventController {
76
76
  #lastVisibleBlockCount = 0;
77
77
  #renderedCustomMessages = new Set<string>();
78
78
  #lastIntent: string | undefined = undefined;
79
- #backgroundToolCallIds = new Set<string>();
79
+ #backgroundTaskCallIds = new Set<string>();
80
80
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
81
81
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
82
82
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
@@ -281,7 +281,7 @@ export class EventController {
281
281
  this.#lastVisibleBlockCount = 0;
282
282
  this.#renderedCustomMessages.clear();
283
283
  this.#lastIntent = undefined;
284
- this.#backgroundToolCallIds.clear();
284
+ this.#backgroundTaskCallIds.clear();
285
285
  this.#readToolCallArgs.clear();
286
286
  this.#readToolCallAssistantComponents.clear();
287
287
  this.#lastAssistantComponent = undefined;
@@ -828,9 +828,9 @@ export class EventController {
828
828
  // The turn ended without running these calls (abort/error/TTSR rewind),
829
829
  // so they will never produce a result. Seal them so they stop animating
830
830
  // and freeze instead of pinning the transcript live region while a retry
831
- // streams fresh blocks below them. Background tools keep updating.
831
+ // streams fresh blocks below them. Background task calls keep updating.
832
832
  for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
833
- if (!this.#backgroundToolCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
833
+ if (!this.#backgroundTaskCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
834
834
  component.seal();
835
835
  }
836
836
  }
@@ -948,7 +948,7 @@ export class EventController {
948
948
  // While the call is still executing — a mixed blocking+async task
949
949
  // call whose jobs settle before its blocking subset — treat it as a
950
950
  // partial frame: `tool_execution_end` still owns the terminal result.
951
- const isTerminal = isFinalAsyncState && this.#backgroundToolCallIds.has(event.toolCallId);
951
+ const isTerminal = isFinalAsyncState && this.#backgroundTaskCallIds.has(event.toolCallId);
952
952
  component.updateResult(
953
953
  { ...event.partialResult, isError: asyncState === "failed" },
954
954
  !isTerminal,
@@ -956,7 +956,7 @@ export class EventController {
956
956
  );
957
957
  if (isTerminal) {
958
958
  this.ctx.pendingTools.delete(event.toolCallId);
959
- this.#backgroundToolCallIds.delete(event.toolCallId);
959
+ this.#backgroundTaskCallIds.delete(event.toolCallId);
960
960
  }
961
961
  this.ctx.ui.requestRender();
962
962
  }
@@ -977,13 +977,7 @@ export class EventController {
977
977
  component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
978
978
  this.ctx.pendingTools.delete(event.toolCallId);
979
979
  }
980
- const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
981
- if (asyncState === "running") {
982
- this.#backgroundToolCallIds.add(event.toolCallId);
983
- } else {
984
- this.#backgroundToolCallIds.delete(event.toolCallId);
985
- this.#clearReadToolCall(event.toolCallId);
986
- }
980
+ this.#clearReadToolCall(event.toolCallId);
987
981
  this.ctx.ui.requestRender();
988
982
  } else {
989
983
  let component = this.ctx.pendingTools.get(event.toolCallId);
@@ -996,29 +990,22 @@ export class EventController {
996
990
  component = group;
997
991
  this.ctx.pendingTools.set(event.toolCallId, group);
998
992
  }
999
- const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
1000
- const isBackgroundRunning = asyncState === "running";
1001
- component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId);
1002
- if (isBackgroundRunning) {
1003
- this.#backgroundToolCallIds.add(event.toolCallId);
1004
- } else {
1005
- this.ctx.pendingTools.delete(event.toolCallId);
1006
- this.#backgroundToolCallIds.delete(event.toolCallId);
1007
- this.#clearReadToolCall(event.toolCallId);
1008
- }
993
+ component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
994
+ this.ctx.pendingTools.delete(event.toolCallId);
995
+ this.#clearReadToolCall(event.toolCallId);
1009
996
  this.ctx.ui.requestRender();
1010
997
  }
1011
998
  } else {
1012
999
  const component = this.ctx.pendingTools.get(event.toolCallId);
1013
1000
  if (component) {
1014
1001
  const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
1015
- const isBackgroundRunning = asyncState === "running";
1016
- component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId);
1017
- if (isBackgroundRunning) {
1018
- this.#backgroundToolCallIds.add(event.toolCallId);
1002
+ const isBackgroundTask = event.toolName === "task" && asyncState === "running";
1003
+ component.updateResult({ ...event.result, isError: event.isError }, isBackgroundTask, event.toolCallId);
1004
+ if (isBackgroundTask) {
1005
+ this.#backgroundTaskCallIds.add(event.toolCallId);
1019
1006
  } else {
1020
1007
  this.ctx.pendingTools.delete(event.toolCallId);
1021
- this.#backgroundToolCallIds.delete(event.toolCallId);
1008
+ this.#backgroundTaskCallIds.delete(event.toolCallId);
1022
1009
  }
1023
1010
  if (component instanceof ToolExecutionComponent && component.isDisplaceableBlock()) {
1024
1011
  if (event.toolName === "job" && component.canBeDisplacedBy("job")) {
@@ -1098,7 +1085,7 @@ export class EventController {
1098
1085
  }
1099
1086
  await this.ctx.flushPendingModelSwitch();
1100
1087
  for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
1101
- if (!this.#backgroundToolCallIds.has(toolCallId)) {
1088
+ if (!this.#backgroundTaskCallIds.has(toolCallId)) {
1102
1089
  // A foreground tool still pending at turn end never delivered a result;
1103
1090
  // seal it so it freezes (and stops animating) rather than lingering in
1104
1091
  // the transcript live region as a streaming preview until the next thaw.
@@ -1112,8 +1099,8 @@ export class EventController {
1112
1099
  this.ctx.pendingTools.delete(toolCallId);
1113
1100
  }
1114
1101
  }
1115
- this.#backgroundToolCallIds = new Set(
1116
- Array.from(this.#backgroundToolCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
1102
+ this.#backgroundTaskCallIds = new Set(
1103
+ Array.from(this.#backgroundTaskCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
1117
1104
  );
1118
1105
  this.#readToolCallArgs.clear();
1119
1106
  this.#readToolCallAssistantComponents.clear();
@@ -1247,7 +1234,19 @@ export class EventController {
1247
1234
  this.ctx.lastAssistantUsage = undefined;
1248
1235
  this.ctx.rebuildChatFromMessages();
1249
1236
  this.ctx.statusLine.invalidate();
1250
- this.ctx.ui.requestRender();
1237
+ // When history collapses behind the summary divider, the frame
1238
+ // shrinks far below the committed row count; without clearing, the
1239
+ // differential renderer's "duplication, never loss" resync repaints
1240
+ // the whole collapsed transcript (welcome box included) BELOW the
1241
+ // stale pre-compaction scrollback. Compaction is an intentional
1242
+ // transcript replacement then — same as auto-handoff below. With
1243
+ // collapse disabled the rebuilt transcript keeps the full history,
1244
+ // so the resync handles it and scrollback stays.
1245
+ if (settings.get("display.collapseCompacted")) {
1246
+ this.ctx.ui.requestRender(true, { clearScrollback: true });
1247
+ } else {
1248
+ this.ctx.ui.requestRender();
1249
+ }
1251
1250
  } else if (event.errorMessage) {
1252
1251
  this.ctx.showWarning(event.errorMessage);
1253
1252
  } else if (isHandoffAction) {
@@ -67,10 +67,12 @@ import { ExtensionDashboard } from "../components/extensions";
67
67
  import { HistorySearchComponent } from "../components/history-search";
68
68
  import { LoginDialogComponent } from "../components/login-dialog";
69
69
  import { LogoutAccountSelectorComponent } from "../components/logout-account-selector";
70
- import { ModelHubComponent, type ModelHubMode } from "../components/model-hub";
70
+ import { ModelHubComponent } from "../components/model-hub";
71
+ import { ModelPickerComponent } from "../components/model-picker";
71
72
  import { OAuthSelectorComponent } from "../components/oauth-selector";
72
73
  import { PluginSelectorComponent } from "../components/plugin-selector";
73
74
  import { ResetUsageSelectorComponent } from "../components/reset-usage-selector";
75
+ import { renderSegmentTrack } from "../components/segment-track";
74
76
  import { SessionSelectorComponent } from "../components/session-selector";
75
77
  import { SettingsSelectorComponent } from "../components/settings-selector";
76
78
  import { ToolExecutionComponent } from "../components/tool-execution";
@@ -451,12 +453,23 @@ export class SelectorController {
451
453
  this.ctx.rebuildChatFromMessages();
452
454
  this.ctx.ui.resetDisplay();
453
455
  break;
456
+ case "display.collapseCompacted":
457
+ // Rebuild swaps between the collapsed tail and the full inline
458
+ // history; full reset retires blocks already committed to native
459
+ // scrollback (mirrors cacheMissMarker).
460
+ this.ctx.rebuildChatFromMessages();
461
+ this.ctx.ui.resetDisplay();
462
+ break;
454
463
  case "tui.tight":
455
464
  setTuiTight(value as boolean);
456
465
  this.ctx.ui.invalidate();
457
466
  this.ctx.ui.requestRender();
458
467
  break;
459
468
 
469
+ case "tui.scrollbackRebuild":
470
+ this.ctx.ui.setScrollbackRebuild(value as boolean);
471
+ break;
472
+
460
473
  case "tui.renderMermaid":
461
474
  setMarkdownMermaidRendering(value as boolean);
462
475
  this.ctx.session.refreshBaseSystemPrompt().catch(err => {
@@ -586,7 +599,85 @@ export class SelectorController {
586
599
  }
587
600
 
588
601
  showModelSelector(options?: { temporaryOnly?: boolean }): void {
589
- this.#showModelHub({ mode: options?.temporaryOnly ? "pick" : "roles" });
602
+ if (options?.temporaryOnly) {
603
+ this.#showModelPicker();
604
+ return;
605
+ }
606
+ this.#showModelHub({});
607
+ }
608
+
609
+ /**
610
+ * Compact session-only model picker (alt+p / `/switch`): a floating
611
+ * bottom-anchored overlay over the transcript. The current model is
612
+ * highlighted and preselected; a leading `@` searches ctrl+p quick roles.
613
+ */
614
+ #showModelPicker(): void {
615
+ const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
616
+ const current = this.ctx.session.model;
617
+ const quickRoleOrder = this.ctx.settings.get("cycleOrder");
618
+ const quickRoleCycle = this.ctx.session.getRoleModelCycle(quickRoleOrder);
619
+ let overlayHandle: OverlayHandle | undefined;
620
+ let closed = false;
621
+ const done = () => {
622
+ if (closed) return;
623
+ closed = true;
624
+ overlayHandle?.hide();
625
+ this.focusActiveEditorArea();
626
+ this.ctx.ui.requestRender();
627
+ };
628
+ const picker = new ModelPickerComponent(
629
+ this.ctx.ui,
630
+ this.ctx.settings,
631
+ this.ctx.session.modelRegistry,
632
+ this.ctx.session.scopedModels,
633
+ {
634
+ onPick: async (model, selector) => {
635
+ try {
636
+ // Session-only: update agent state but don't persist the model to settings.
637
+ await this.ctx.session.setModelTemporary(model);
638
+ this.ctx.statusLine.invalidate();
639
+ this.ctx.updateEditorBorderColor();
640
+ const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
641
+ this.ctx.showStatus(`Session-only model: ${selector}. Use ${roleSelectorHint} or /model for roles.`);
642
+ done();
643
+ } catch (error) {
644
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
645
+ }
646
+ },
647
+ onPickRole: async entry => {
648
+ try {
649
+ await this.ctx.session.applyRoleModel(entry);
650
+ this.ctx.statusLine.invalidate();
651
+ this.ctx.updateEditorBorderColor();
652
+ this.ctx.showModelCycleTrack(
653
+ renderSegmentTrack(
654
+ quickRoleOrder.map(role => ({ label: role })),
655
+ quickRoleOrder.indexOf(entry.role),
656
+ ),
657
+ );
658
+ done();
659
+ } catch (error) {
660
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
661
+ }
662
+ },
663
+ onCancel: done,
664
+ },
665
+ {
666
+ currentContextTokens,
667
+ currentSelector: current ? `${current.provider}/${current.id}` : undefined,
668
+ quickRoles: quickRoleCycle?.models,
669
+ quickRoleOrder,
670
+ currentQuickRole: quickRoleCycle?.models[quickRoleCycle.currentIndex]?.role,
671
+ },
672
+ );
673
+ overlayHandle = this.ctx.ui.showOverlay(picker, {
674
+ anchor: "bottom-center",
675
+ width: "100%",
676
+ maxHeight: "100%",
677
+ margin: 0,
678
+ });
679
+ this.ctx.ui.setFocus(picker);
680
+ this.ctx.ui.requestRender();
590
681
  }
591
682
 
592
683
  /**
@@ -595,13 +686,13 @@ export class SelectorController {
595
686
  * untouched underneath. `initialProviderId` preselects a provider's sidebar
596
687
  * entry — used when reopening the hub after a /login round-trip.
597
688
  */
598
- #showModelHub(hubOptions: { mode: ModelHubMode; initialProviderId?: string }): void {
689
+ #showModelHub(hubOptions: { initialProviderId?: string }): void {
599
690
  const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
600
691
  let overlayHandle: OverlayHandle | undefined;
601
692
  let hub: ModelHubComponent | undefined;
602
693
  let closed = false;
603
694
  const done = () => {
604
- // Re-entrant guard: cancel paths (Esc, pick, login forward) may race;
695
+ // Re-entrant guard: cancel paths (Esc, login forward) may race;
605
696
  // the overlay must hide exactly once.
606
697
  if (closed) return;
607
698
  closed = true;
@@ -686,21 +777,6 @@ export class SelectorController {
686
777
  this.ctx.showError(error instanceof Error ? error.message : String(error));
687
778
  }
688
779
  },
689
- onPick: async (model, selector) => {
690
- try {
691
- // Session-only: update agent state but don't persist the model to settings.
692
- await this.ctx.session.setModelTemporary(model);
693
- this.ctx.statusLine.invalidate();
694
- this.ctx.updateEditorBorderColor();
695
- const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
696
- this.ctx.showStatus(
697
- `Session-only model: ${selector ?? model.id}. Use ${roleSelectorHint} or /model for roles.`,
698
- );
699
- done();
700
- } catch (error) {
701
- this.ctx.showError(error instanceof Error ? error.message : String(error));
702
- }
703
- },
704
780
  onLoginRequest: providerId => {
705
781
  done();
706
782
  void this.#loginThenReopenModelHub(providerId);
@@ -718,8 +794,6 @@ export class SelectorController {
718
794
  onCancel: () => done(),
719
795
  },
720
796
  {
721
- mode: hubOptions.mode,
722
- currentContextTokens,
723
797
  initialProviderId: hubOptions.initialProviderId,
724
798
  },
725
799
  );
@@ -738,7 +812,7 @@ export class SelectorController {
738
812
  async #loginThenReopenModelHub(providerId: string): Promise<void> {
739
813
  const succeeded = await this.#handleOAuthLogin(providerId);
740
814
  if (succeeded) {
741
- this.#showModelHub({ mode: "roles", initialProviderId: providerId });
815
+ this.#showModelHub({ initialProviderId: providerId });
742
816
  }
743
817
  }
744
818
 
@@ -3,12 +3,14 @@ import * as path from "node:path";
3
3
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
4
  import { prompt, Snowflake } from "@oh-my-pi/pi-utils";
5
5
  import backgroundTanDispatchPrompt from "../../prompts/system/background-tan-dispatch.md" with { type: "text" };
6
+ import tanContextSwitchPrompt from "../../prompts/system/tan-context-switch.md" with { type: "text" };
6
7
  import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry";
7
8
  import * as sdk from "../../sdk";
8
9
  import type { AgentSession } from "../../session/agent-session";
9
10
  import { BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE } from "../../session/messages";
10
11
  import { SessionManager } from "../../session/session-manager";
11
12
  import { createMCPProxyTools, createSubagentSettings } from "../../task/executor";
13
+ import { USER_TODO_EDIT_CUSTOM_TYPE } from "../../tools/todo";
12
14
  import type { InteractiveModeContext } from "../types";
13
15
 
14
16
  const TAN_LABEL_PREVIEW_LENGTH = 80;
@@ -66,6 +68,11 @@ export class TanCommandController {
66
68
  }
67
69
 
68
70
  const parentSessionId = session.sessionId;
71
+ // Providers route on `promptCacheKey ?? sessionId`, so the parent's live
72
+ // requests may cache under a pinned key that differs from its session id
73
+ // (the parent being itself a fork/tan). Mirror exactly what the parent
74
+ // populated the cache under — same rule as advisor and handoff calls.
75
+ const parentPromptCacheKey = session.agent.promptCacheKey ?? parentSessionId;
69
76
  const thinkingLevel = session.configuredThinkingLevel();
70
77
  const systemPrompt = [...session.systemPrompt];
71
78
  const toolNames = session.getActiveToolNames();
@@ -111,7 +118,7 @@ export class TanCommandController {
111
118
  systemPrompt,
112
119
  toolNames,
113
120
  providerSessionId: `${parentSessionId}:tan:${Snowflake.next()}`,
114
- providerPromptCacheKey: parentSessionId,
121
+ providerPromptCacheKey: parentPromptCacheKey,
115
122
  modelRegistry,
116
123
  authStorage: modelRegistry.authStorage,
117
124
  settings,
@@ -127,19 +134,51 @@ export class TanCommandController {
127
134
  disableExtensionDiscovery: true,
128
135
  });
129
136
  clone = created.session;
137
+ clone.sessionManager?.appendSessionInit?.({
138
+ systemPrompt: clone.systemPrompt ? clone.systemPrompt.join("\n\n") : systemPrompt.join("\n\n"),
139
+ task: trimmedWork,
140
+ tools: clone.getActiveToolNames ? clone.getActiveToolNames() : toolNames,
141
+ });
130
142
  const abortClone = () => {
131
143
  void clone?.abort();
132
144
  };
133
145
  signal.addEventListener("abort", abortClone, { once: true });
146
+ // The fork inherits the parent's todo list via session entries;
147
+ // its reminders would drag the tan back onto the parent's task.
148
+ // Clear runtime state and persist an empty edit so reloads agree.
149
+ clone.setTodoPhases([]);
150
+ cloneManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases: [] });
151
+ const injectContextSwitch = () => {
152
+ clone?.agent.appendMessage({
153
+ role: "developer",
154
+ content: tanContextSwitchPrompt,
155
+ attribution: "agent",
156
+ timestamp: Date.now(),
157
+ });
158
+ };
159
+ // Compaction summarizes the fork notice away with the rest of the
160
+ // history, after which the clone re-adopts the parent's task as its
161
+ // own (the summary blends both). Re-inject after every successful
162
+ // compaction so the fork boundary survives summarization.
163
+ const unsubscribeCompaction = clone.subscribe(event => {
164
+ if (event.type === "auto_compaction_end" && event.result && !event.aborted) {
165
+ injectContextSwitch();
166
+ }
167
+ });
134
168
  try {
135
169
  if (signal.aborted) {
136
170
  abortClone();
137
171
  throw new Error("Aborted before execution");
138
172
  }
173
+ // Inject a context-switch developer message so the clone knows
174
+ // it is a tangential fork — its parent owns the prior conversation;
175
+ // this agent must focus exclusively on the user's request.
176
+ injectContextSwitch();
139
177
  await clone.prompt(trimmedWork, { attribution: "user" });
140
178
  await clone.waitForIdle();
141
179
  return extractAssistantText(clone.getLastAssistantMessage()) || "(no output)";
142
180
  } finally {
181
+ unsubscribeCompaction();
143
182
  signal.removeEventListener("abort", abortClone);
144
183
  }
145
184
  } finally {