@oh-my-pi/pi-coding-agent 15.8.3 → 15.9.1

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 (172) hide show
  1. package/CHANGELOG.md +113 -31
  2. package/dist/types/capability/skill.d.ts +7 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  5. package/dist/types/config/model-registry.d.ts +3 -0
  6. package/dist/types/config/models-config-schema.d.ts +15 -0
  7. package/dist/types/config/settings-schema.d.ts +38 -24
  8. package/dist/types/debug/protocol-probe.d.ts +38 -0
  9. package/dist/types/debug/terminal-info.d.ts +34 -0
  10. package/dist/types/exa/mcp-client.d.ts +2 -1
  11. package/dist/types/export/html/template.generated.d.ts +1 -1
  12. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  14. package/dist/types/extensibility/shared-events.d.ts +1 -1
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  18. package/dist/types/mcp/transports/stdio.d.ts +16 -7
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/assistant-message.d.ts +3 -2
  22. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  23. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  25. package/dist/types/modes/components/session-selector.d.ts +8 -3
  26. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  27. package/dist/types/modes/components/todo-reminder.d.ts +1 -1
  28. package/dist/types/modes/components/transcript-container.d.ts +36 -0
  29. package/dist/types/modes/interactive-mode.d.ts +2 -1
  30. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  31. package/dist/types/modes/theme/theme.d.ts +20 -1
  32. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  33. package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
  34. package/dist/types/session/agent-session.d.ts +5 -2
  35. package/dist/types/session/history-storage.d.ts +3 -4
  36. package/dist/types/session/indexed-session-storage.d.ts +59 -0
  37. package/dist/types/session/messages.d.ts +1 -0
  38. package/dist/types/session/redis-session-storage.d.ts +12 -85
  39. package/dist/types/session/session-manager.d.ts +21 -0
  40. package/dist/types/session/session-storage.d.ts +5 -7
  41. package/dist/types/session/sql-session-storage.d.ts +16 -85
  42. package/dist/types/slash-commands/types.d.ts +17 -4
  43. package/dist/types/task/executor.d.ts +9 -0
  44. package/dist/types/task/index.d.ts +3 -1
  45. package/dist/types/telemetry-export.d.ts +19 -0
  46. package/dist/types/tiny/compiled-runtime.d.ts +35 -0
  47. package/dist/types/tiny/text.d.ts +17 -0
  48. package/dist/types/tools/ask.d.ts +1 -0
  49. package/dist/types/tools/index.d.ts +4 -2
  50. package/dist/types/tools/path-utils.d.ts +1 -1
  51. package/dist/types/tools/search.d.ts +2 -2
  52. package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
  53. package/dist/types/utils/session-color.d.ts +7 -2
  54. package/dist/types/web/search/index.d.ts +1 -1
  55. package/dist/types/web/search/provider.d.ts +2 -2
  56. package/dist/types/web/search/providers/base.d.ts +14 -0
  57. package/dist/types/web/search/providers/exa.d.ts +9 -0
  58. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  59. package/dist/types/web/search/types.d.ts +66 -1
  60. package/package.json +15 -9
  61. package/scripts/build-binary.ts +12 -0
  62. package/src/capability/skill.ts +7 -0
  63. package/src/cli/args.ts +1 -1
  64. package/src/cli/session-picker.ts +1 -0
  65. package/src/cli/update-cli.ts +54 -2
  66. package/src/commands/completions.ts +1 -1
  67. package/src/config/append-only-context-mode.ts +37 -0
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +24 -57
  70. package/src/debug/index.ts +67 -1
  71. package/src/debug/protocol-probe.ts +267 -0
  72. package/src/debug/terminal-info.ts +127 -0
  73. package/src/exa/mcp-client.ts +11 -5
  74. package/src/export/html/template.generated.ts +1 -1
  75. package/src/export/html/template.js +3 -3
  76. package/src/extensibility/custom-tools/types.ts +1 -1
  77. package/src/extensibility/extensions/types.ts +11 -0
  78. package/src/extensibility/shared-events.ts +1 -1
  79. package/src/extensibility/skills.ts +3 -3
  80. package/src/index.ts +1 -0
  81. package/src/internal-urls/docs-index.generated.ts +7 -7
  82. package/src/main.ts +16 -2
  83. package/src/mcp/json-rpc.ts +8 -0
  84. package/src/mcp/render.ts +3 -0
  85. package/src/mcp/tool-bridge.ts +10 -2
  86. package/src/mcp/transports/http.ts +33 -16
  87. package/src/mcp/transports/stdio.ts +37 -12
  88. package/src/mnemopi/state.ts +4 -4
  89. package/src/modes/acp/acp-agent.ts +168 -3
  90. package/src/modes/acp/acp-event-mapper.ts +7 -7
  91. package/src/modes/components/agent-dashboard.ts +103 -31
  92. package/src/modes/components/assistant-message.ts +3 -2
  93. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  94. package/src/modes/components/history-search.ts +128 -14
  95. package/src/modes/components/hook-selector.ts +149 -14
  96. package/src/modes/components/plugin-settings.ts +270 -36
  97. package/src/modes/components/session-selector.ts +81 -19
  98. package/src/modes/components/settings-selector.ts +1 -1
  99. package/src/modes/components/status-line/segments.ts +2 -1
  100. package/src/modes/components/status-line.ts +1 -1
  101. package/src/modes/components/tips.txt +6 -2
  102. package/src/modes/components/todo-reminder.ts +1 -1
  103. package/src/modes/components/tool-execution.ts +9 -4
  104. package/src/modes/components/transcript-container.ts +109 -0
  105. package/src/modes/controllers/command-controller.ts +4 -3
  106. package/src/modes/controllers/event-controller.ts +12 -7
  107. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  108. package/src/modes/controllers/input-controller.ts +10 -5
  109. package/src/modes/controllers/selector-controller.ts +30 -19
  110. package/src/modes/controllers/todo-command-controller.ts +1 -1
  111. package/src/modes/interactive-mode.ts +56 -9
  112. package/src/modes/print-mode.ts +5 -0
  113. package/src/modes/rpc/rpc-types.ts +1 -1
  114. package/src/modes/theme/theme.ts +48 -8
  115. package/src/modes/utils/keybinding-matchers.ts +10 -0
  116. package/src/modes/utils/ui-helpers.ts +1 -0
  117. package/src/plan-mode/plan-handoff.ts +37 -0
  118. package/src/priority.json +4 -0
  119. package/src/prompts/goals/goal-continuation.md +1 -1
  120. package/src/prompts/steering/user-interjection.md +10 -0
  121. package/src/prompts/system/agent-creation-architect.md +1 -26
  122. package/src/prompts/system/eager-todo.md +3 -3
  123. package/src/prompts/system/orchestrate-notice.md +5 -5
  124. package/src/prompts/system/plan-mode-approved.md +14 -17
  125. package/src/prompts/system/plan-mode-reference.md +3 -6
  126. package/src/prompts/system/subagent-system-prompt.md +11 -0
  127. package/src/prompts/system/system-prompt.md +143 -145
  128. package/src/prompts/system/title-system.md +3 -2
  129. package/src/prompts/system/workflow-notice.md +1 -1
  130. package/src/prompts/tools/browser.md +33 -30
  131. package/src/prompts/tools/render-mermaid.md +2 -2
  132. package/src/prompts/tools/search.md +1 -1
  133. package/src/prompts/tools/task.md +3 -1
  134. package/src/prompts/tools/{todo-write.md → todo.md} +5 -5
  135. package/src/sdk.ts +6 -21
  136. package/src/session/agent-session.ts +44 -21
  137. package/src/session/history-storage.ts +11 -18
  138. package/src/session/indexed-session-storage.ts +430 -0
  139. package/src/session/messages.ts +80 -0
  140. package/src/session/redis-session-storage.ts +66 -377
  141. package/src/session/session-manager.ts +109 -23
  142. package/src/session/session-storage.ts +148 -68
  143. package/src/session/sql-session-storage.ts +131 -382
  144. package/src/slash-commands/helpers/todo.ts +2 -2
  145. package/src/slash-commands/types.ts +27 -10
  146. package/src/task/executor.ts +9 -1
  147. package/src/task/index.ts +51 -1
  148. package/src/telemetry-export.ts +126 -0
  149. package/src/tiny/compiled-runtime.ts +179 -0
  150. package/src/tiny/text.ts +112 -1
  151. package/src/tiny/worker.ts +24 -2
  152. package/src/tools/ask.ts +133 -87
  153. package/src/tools/fetch.ts +17 -4
  154. package/src/tools/find.ts +2 -2
  155. package/src/tools/index.ts +6 -4
  156. package/src/tools/memory-recall.ts +1 -1
  157. package/src/tools/memory-reflect.ts +1 -1
  158. package/src/tools/path-utils.ts +16 -7
  159. package/src/tools/renderers.ts +2 -2
  160. package/src/tools/search.ts +6 -3
  161. package/src/tools/ssh.ts +26 -10
  162. package/src/tools/{todo-write.ts → todo.ts} +32 -35
  163. package/src/tools/write.ts +14 -2
  164. package/src/tui/status-line.ts +15 -4
  165. package/src/utils/session-color.ts +39 -14
  166. package/src/utils/title-generator.ts +9 -2
  167. package/src/web/search/index.ts +4 -2
  168. package/src/web/search/provider.ts +19 -35
  169. package/src/web/search/providers/base.ts +17 -0
  170. package/src/web/search/providers/exa.ts +111 -7
  171. package/src/web/search/providers/perplexity.ts +8 -4
  172. package/src/web/search/types.ts +74 -32
@@ -32,7 +32,7 @@ import {
32
32
  } from "../../tools/json-tree";
33
33
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
34
34
  import { toolRenderers } from "../../tools/renderers";
35
- import { TODO_WRITE_STRIKE_TOTAL_FRAMES } from "../../tools/todo-write";
35
+ import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
36
36
  import { renderStatusLine } from "../../tui";
37
37
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
38
38
  import { renderDiff } from "./diff";
@@ -149,6 +149,10 @@ const SPINNER_RENDER_INTERVAL_MS = 16;
149
149
  * 60fps render cadence (mirrors `Loader`). */
150
150
  const SPINNER_GLYPH_ADVANCE_MS = 80;
151
151
 
152
+ // Stable per-instance counter so each tool execution's inline images get a
153
+ // graphics id that survives child re-creation (the image budget keys off it).
154
+ let toolExecutionInstanceSeq = 0;
155
+
152
156
  /**
153
157
  * Component that renders a tool call with its result (updateable)
154
158
  */
@@ -158,6 +162,7 @@ export class ToolExecutionComponent extends Container {
158
162
  #multiFileBoxes: (Box | Spacer)[] = []; // Extra boxes for multi-file edit results
159
163
  #imageComponents: Image[] = [];
160
164
  #imageSpacers: Spacer[] = [];
165
+ readonly #instanceId = ++toolExecutionInstanceSeq;
161
166
  #toolName: string;
162
167
  #toolLabel: string;
163
168
  #args: any;
@@ -447,7 +452,7 @@ export class ToolExecutionComponent extends Container {
447
452
  }
448
453
 
449
454
  #updateTodoStrikeAnimation(): void {
450
- if (this.#toolName !== "todo_write" || this.#isPartial || this.#result?.isError) {
455
+ if (this.#toolName !== "todo" || this.#isPartial || this.#result?.isError) {
451
456
  this.#stopTodoStrikeAnimation();
452
457
  return;
453
458
  }
@@ -462,7 +467,7 @@ export class ToolExecutionComponent extends Container {
462
467
  this.#renderState.spinnerFrame = 0;
463
468
  this.#todoStrikeInterval = setInterval(() => {
464
469
  const nextFrame = (this.#spinnerFrame ?? 0) + 1;
465
- if (nextFrame > TODO_WRITE_STRIKE_TOTAL_FRAMES) {
470
+ if (nextFrame > TODO_STRIKE_TOTAL_FRAMES) {
466
471
  this.#stopTodoStrikeAnimation();
467
472
  } else {
468
473
  this.#spinnerFrame = nextFrame;
@@ -754,7 +759,7 @@ export class ToolExecutionComponent extends Container {
754
759
  imageData,
755
760
  imageMimeType,
756
761
  { fallbackColor: (s: string) => theme.fg("toolOutput", s) },
757
- resolveImageOptions(),
762
+ { ...resolveImageOptions(), budget: this.#ui.imageBudget, imageKey: `te${this.#instanceId}:${i}` },
758
763
  );
759
764
  this.#imageComponents.push(imageComponent);
760
765
  this.addChild(imageComponent);
@@ -0,0 +1,109 @@
1
+ import { type Component, Container, TERMINAL } from "@oh-my-pi/pi-tui";
2
+
3
+ const kSnapshot = Symbol("transcript.frozenRender");
4
+
5
+ interface FrozenRender {
6
+ width: number;
7
+ lines: string[];
8
+ generation: number;
9
+ }
10
+
11
+ interface SnapshotCarrier {
12
+ [kSnapshot]?: FrozenRender;
13
+ }
14
+
15
+ /**
16
+ * Transcript container that freezes the rendered output of every block except
17
+ * the bottom-most (live) one on terminals where committed native scrollback is
18
+ * immutable.
19
+ *
20
+ * On ED3-risk terminals with an unobservable viewport (ghostty/kitty/iTerm2/…)
21
+ * the renderer cannot clear saved lines (`\x1b[3J` may yank a reader) or query
22
+ * whether the user has scrolled, so any block that re-lays-out *after* it has
23
+ * scrolled past the viewport leaves a stale duplicate above the live region
24
+ * (a finalized assistant message re-wrapping, a tool preview collapsing to its
25
+ * compact result, a late async tool completion). The renderer's only safe move
26
+ * for such an offscreen edit is to not repaint — which is correct only if the
27
+ * committed region never changes underneath it.
28
+ *
29
+ * This container provides that guarantee: a block's render is snapshotted while
30
+ * it is the live (bottom-most) block, and once a newer block is appended it
31
+ * replays the snapshot instead of recomputing. Mutations after a block leaves
32
+ * live are intentionally deferred until the next checkpoint {@link thaw} (prompt
33
+ * submit → native-scrollback rebuild), where the whole transcript is replayed
34
+ * and any drift reconciles safely. On terminals that can rebuild history this
35
+ * freezing is unnecessary, so it renders every block live for full fidelity.
36
+ */
37
+ export class TranscriptContainer extends Container {
38
+ // Bumped to invalidate every block's snapshot at once; a snapshot is only
39
+ // honored when its stored generation still matches.
40
+ #generation = 0;
41
+ // The block that was bottom-most (live) on the previous render. When the live
42
+ // position moves past it, its snapshot was last refreshed mid-stream and may
43
+ // predate content that finalized in the same coalesced frame that appended the
44
+ // block now below it — so it must recompute once on the live→frozen transition.
45
+ #prevLiveChild: Component | undefined;
46
+
47
+ override invalidate(): void {
48
+ // A theme/global invalidation forces a full recompute on the rebuild that
49
+ // follows; retire every snapshot.
50
+ this.#generation++;
51
+ super.invalidate();
52
+ }
53
+
54
+ override clear(): void {
55
+ this.#generation++;
56
+ super.clear();
57
+ }
58
+
59
+ /**
60
+ * Retire all frozen snapshots so the next render reflects each block's current
61
+ * state. Call at reconciliation checkpoints (prompt submit) where the whole
62
+ * transcript is replayed into native scrollback and any drift a frozen block
63
+ * accumulated is reconciled.
64
+ */
65
+ thaw(): void {
66
+ this.#generation++;
67
+ }
68
+
69
+ override render(width: number): string[] {
70
+ width = Math.max(1, width);
71
+ if (!TERMINAL.eagerEraseScrollbackRisk) return super.render(width);
72
+
73
+ const lines: string[] = [];
74
+ const liveIndex = this.children.length - 1;
75
+ const liveChild = this.children[liveIndex];
76
+ const prevLiveChild = this.#prevLiveChild;
77
+ this.#prevLiveChild = liveChild;
78
+ for (let i = 0; i < this.children.length; i++) {
79
+ const child = this.children[i]! as Component & SnapshotCarrier;
80
+ if (child !== liveChild) {
81
+ const snapshot = child[kSnapshot];
82
+ // Replay the block's last render from while it was live. A stale
83
+ // generation (post-thaw) or width mismatch (resize in flight, an
84
+ // explicit rebuild that reconciles history anyway) recomputes instead.
85
+ // The block that was live on the previous render is also recomputed
86
+ // here: TUI render coalescing can advance its content (final streamed
87
+ // tokens) in the very frame that appends the block now below it, so its
88
+ // cached snapshot predates that final content. Recomputing on the
89
+ // transition seals the block at its true final state, not a mid-stream one.
90
+ if (
91
+ child !== prevLiveChild &&
92
+ snapshot &&
93
+ snapshot.generation === this.#generation &&
94
+ snapshot.width === width
95
+ ) {
96
+ lines.push(...snapshot.lines);
97
+ continue;
98
+ }
99
+ }
100
+ const rendered = child.render(width);
101
+ // Cache every block's latest render. While a block is live this keeps its
102
+ // snapshot current; on the frame it stops being live the recompute above
103
+ // refreshes it to the final state before it freezes.
104
+ child[kSnapshot] = { width, lines: rendered, generation: this.#generation };
105
+ lines.push(...rendered);
106
+ }
107
+ return lines;
108
+ }
109
+ }
@@ -13,6 +13,7 @@ import {
13
13
  import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
14
14
  import { formatDuration, Snowflake } from "@oh-my-pi/pi-utils";
15
15
  import { $ } from "bun";
16
+ import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
16
17
  import { loadCustomShare } from "../../export/custom-share";
17
18
  import type { CompactOptions } from "../../extensibility/extensions/types";
18
19
  import {
@@ -397,10 +398,10 @@ export class CommandController {
397
398
  // Append-only context
398
399
  {
399
400
  const setting = this.ctx.settings.get("provider.appendOnlyContext") ?? "auto";
400
- const provider = this.ctx.session.model?.provider;
401
- const mode = setting === "on" ? true : setting === "off" ? false : provider === "deepseek";
401
+ const model = this.ctx.session.model;
402
+ const mode = shouldEnableAppendOnlyContext(setting, model);
402
403
  const activeLabel = mode ? theme.fg("success", "active") : theme.fg("dim", "inactive");
403
- const settingLabel = setting === "auto" ? `${setting} (${provider ?? "?"})` : setting;
404
+ const settingLabel = setting === "auto" ? `${setting} (${model?.provider ?? "?"})` : setting;
404
405
  info += `${theme.fg("dim", "Append-Only:")} ${activeLabel} (setting: ${settingLabel})\n`;
405
406
  }
406
407
  info += `${theme.bold("Tokens")}\n`;
@@ -282,6 +282,7 @@ export class EventController {
282
282
  this.ctx.hideThinkingBlock,
283
283
  () => this.ctx.ui.requestRender(),
284
284
  this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(),
285
+ this.ctx.ui.imageBudget,
285
286
  );
286
287
  this.ctx.streamingMessage = event.message;
287
288
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
@@ -586,18 +587,18 @@ export class EventController {
586
587
  this.ctx.ui.requestRender();
587
588
  }
588
589
  }
589
- // Update todo display when todo_write tool completes
590
- if (event.toolName === "todo_write" && !event.isError) {
590
+ // Update todo display when todo tool completes
591
+ if (event.toolName === "todo" && !event.isError) {
591
592
  const details = event.result.details as { phases?: TodoPhase[] } | undefined;
592
593
  if (details?.phases) {
593
594
  this.ctx.setTodos(details.phases);
594
595
  }
595
- } else if (event.toolName === "todo_write" && event.isError) {
596
+ } else if (event.toolName === "todo" && event.isError) {
596
597
  const textContent = event.result.content.find(
597
598
  (content: { type: string; text?: string }) => content.type === "text",
598
599
  )?.text;
599
600
  this.ctx.showWarning(
600
- `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo_write succeeds."}`,
601
+ `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo succeeds."}`,
601
602
  );
602
603
  }
603
604
  if (event.toolName === "resolve" && !event.isError) {
@@ -847,9 +848,13 @@ export class EventController {
847
848
  const last = this.ctx.session.getLastAssistantMessage?.();
848
849
  if (last?.stopReason === "aborted" || last?.stopReason === "error") return;
849
850
 
850
- const title = this.ctx.sessionManager.getSessionName();
851
- const message = title ? `${title}: Complete` : "Complete";
852
- TERMINAL.sendNotification(message);
851
+ const sessionName = this.ctx.sessionManager.getSessionName();
852
+ TERMINAL.sendNotification({
853
+ title: sessionName || "Oh My Pi",
854
+ body: "Complete",
855
+ type: "completion",
856
+ actions: "focus",
857
+ });
853
858
  }
854
859
 
855
860
  async handleBackgroundEvent(event: AgentSessionEvent): Promise<void> {
@@ -625,6 +625,9 @@ export class ExtensionUiController {
625
625
  tui: this.ctx.ui,
626
626
  outline: dialogOptions?.outline,
627
627
  disabledIndices: dialogOptions?.disabledIndices,
628
+ selectionMarker: dialogOptions?.selectionMarker,
629
+ checkedIndices: dialogOptions?.checkedIndices,
630
+ markableCount: dialogOptions?.markableCount,
628
631
  maxVisible,
629
632
  slider: extra?.slider,
630
633
  },
@@ -1,5 +1,4 @@
1
1
  import * as fs from "node:fs/promises";
2
- import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
2
  import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
4
3
  import { $env, sanitizeText } from "@oh-my-pi/pi-utils";
5
4
  import { getRoleInfo } from "../../config/model-registry";
@@ -13,6 +12,7 @@ import type { AgentSessionEvent } from "../../session/agent-session";
13
12
  import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
14
13
  import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
15
14
  import { isTinyTitleLocalModelKey } from "../../tiny/models";
15
+ import { isLowSignalTitleInput } from "../../tiny/text";
16
16
  import { tinyTitleClient } from "../../tiny/title-client";
17
17
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
18
18
  import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
@@ -376,9 +376,12 @@ export class InputController {
376
376
  // First, move any pending bash components to chat
377
377
  this.ctx.flushPendingBashComponents();
378
378
 
379
- // Generate session title on first message
380
- const hasUserMessages = this.ctx.session.messages.some((m: AgentMessage) => m.role === "user");
381
- if (!hasUserMessages && !this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE) {
379
+ // Auto-generate a session title while the session is still unnamed.
380
+ // Greetings / acknowledgements / empty input carry no task, so they are
381
+ // skipped deterministically (no model invoked, no download-progress UI)
382
+ // and the session stays unnamed — the next user message gets a fresh
383
+ // chance, so titling defers past "hi" instead of latching onto it.
384
+ if (!this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE && !isLowSignalTitleInput(text)) {
382
385
  this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel"));
383
386
  const registry = this.ctx.session.modelRegistry;
384
387
  generateSessionTitle(
@@ -390,7 +393,9 @@ export class InputController {
390
393
  provider => this.ctx.session.agent.metadataForProvider(provider),
391
394
  )
392
395
  .then(async title => {
393
- if (title) {
396
+ // Re-check: a concurrent attempt for an earlier message may have
397
+ // already named the session. Don't clobber it.
398
+ if (title && !this.ctx.sessionManager.getSessionName()) {
394
399
  const applied = await this.ctx.sessionManager.setSessionName(title, "auto");
395
400
  if (applied) {
396
401
  setSessionTerminalTitle(
@@ -133,7 +133,11 @@ export class SelectorController {
133
133
  const availableWidth = this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns);
134
134
  return this.ctx.statusLine.getTopBorder(availableWidth).content;
135
135
  },
136
- onPluginsChanged: () => {
136
+ onPluginsChanged: async () => {
137
+ const projectPath = await resolveActiveProjectRegistryPath(this.ctx.sessionManager.getCwd());
138
+ clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
139
+ await this.ctx.refreshSlashCommandState();
140
+ await this.ctx.session.refreshSshTool({ activateIfAvailable: true });
137
141
  this.ctx.ui.requestRender();
138
142
  },
139
143
  onCancel: () => {
@@ -184,16 +188,19 @@ export class SelectorController {
184
188
  */
185
189
  async showExtensionsDashboard(): Promise<void> {
186
190
  const dashboard = await ExtensionDashboard.create(getProjectDir(), this.ctx.settings, this.ctx.ui.terminal.rows);
187
- this.showSelector(done => {
188
- dashboard.onClose = () => {
189
- done();
190
- this.ctx.ui.requestRender();
191
- };
192
- dashboard.onRequestRender = () => {
193
- this.ctx.ui.requestRender();
194
- };
195
- return { component: dashboard, focus: dashboard };
191
+ const overlay = this.ctx.ui.showOverlay(dashboard, {
192
+ width: "100%",
193
+ maxHeight: "100%",
194
+ anchor: "top-left",
195
+ margin: 0,
196
196
  });
197
+ dashboard.onClose = () => {
198
+ overlay.hide();
199
+ this.ctx.ui.requestRender();
200
+ };
201
+ dashboard.onRequestRender = () => {
202
+ this.ctx.ui.requestRender();
203
+ };
197
204
  }
198
205
 
199
206
  /**
@@ -208,16 +215,19 @@ export class SelectorController {
208
215
  activeModelPattern,
209
216
  defaultModelPattern,
210
217
  });
211
- this.showSelector(done => {
212
- dashboard.onClose = () => {
213
- done();
214
- this.ctx.ui.requestRender();
215
- };
216
- dashboard.onRequestRender = () => {
217
- this.ctx.ui.requestRender();
218
- };
219
- return { component: dashboard, focus: dashboard };
218
+ const overlay = this.ctx.ui.showOverlay(dashboard, {
219
+ width: "100%",
220
+ maxHeight: "100%",
221
+ anchor: "top-left",
222
+ margin: 0,
220
223
  });
224
+ dashboard.onClose = () => {
225
+ overlay.hide();
226
+ this.ctx.ui.requestRender();
227
+ };
228
+ dashboard.onRequestRender = () => {
229
+ this.ctx.ui.requestRender();
230
+ };
221
231
  }
222
232
 
223
233
  /**
@@ -763,6 +773,7 @@ export class SelectorController {
763
773
  loadAllSessions: () => SessionManager.listAll(),
764
774
  allSessions,
765
775
  startInAllScope,
776
+ getTerminalRows: () => this.ctx.ui.terminal.rows,
766
777
  },
767
778
  );
768
779
  selector.setOnRequestRender(() => this.ctx.ui.requestRender());
@@ -8,7 +8,7 @@ import {
8
8
  type TodoItem,
9
9
  type TodoPhase,
10
10
  USER_TODO_EDIT_CUSTOM_TYPE,
11
- } from "../../tools/todo-write";
11
+ } from "../../tools/todo";
12
12
  import { copyToClipboard } from "../../utils/clipboard";
13
13
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
14
14
  import type { InteractiveModeContext } from "../types";
@@ -28,6 +28,8 @@ import {
28
28
  Markdown,
29
29
  ProcessTerminal,
30
30
  Spacer,
31
+ setTerminalTextSizing,
32
+ TERMINAL,
31
33
  Text,
32
34
  TUI,
33
35
  visibleWidth,
@@ -84,7 +86,7 @@ import type { LspStartupServerInfo } from "../tools";
84
86
  import { normalizeLocalScheme } from "../tools/path-utils";
85
87
  import { setAutoQaConsentHandler } from "../tools/report-tool-issue";
86
88
  import { type ResolveToolDetails, runResolveInvocation } from "../tools/resolve";
87
- import { formatPhaseDisplayName, selectStickyTodoWindow, todoMatchesAnyDescription } from "../tools/todo-write";
89
+ import { formatPhaseDisplayName, selectStickyTodoWindow, todoMatchesAnyDescription } from "../tools/todo";
88
90
  import { ToolError } from "../tools/tool-errors";
89
91
  import type { EventBus } from "../utils/event-bus";
90
92
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
@@ -100,6 +102,7 @@ import type { HookInputComponent } from "./components/hook-input";
100
102
  import type { HookSelectorComponent, HookSelectorSlider } from "./components/hook-selector";
101
103
  import { StatusLineComponent } from "./components/status-line";
102
104
  import type { ToolExecutionHandle } from "./components/tool-execution";
105
+ import { TranscriptContainer } from "./components/transcript-container";
103
106
  import { WelcomeComponent, type LspServerInfo as WelcomeLspServerInfo } from "./components/welcome";
104
107
  import { BtwController } from "./controllers/btw-controller";
105
108
  import { CommandController } from "./controllers/command-controller";
@@ -255,7 +258,7 @@ export class InteractiveMode implements InteractiveModeContext {
255
258
  historyStorage?: HistoryStorage;
256
259
 
257
260
  ui: TUI;
258
- chatContainer: Container;
261
+ chatContainer: TranscriptContainer;
259
262
  pendingMessagesContainer: Container;
260
263
  statusContainer: Container;
261
264
  todoContainer: Container;
@@ -390,7 +393,12 @@ export class InteractiveMode implements InteractiveModeContext {
390
393
 
391
394
  this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
392
395
  this.ui.setClearOnShrink(settings.get("clearOnShrink"));
393
- this.chatContainer = new Container();
396
+ this.ui.setMaxInlineImages(settings.get("tui.maxInlineImages"));
397
+ // OSC 66 text-sizing is Kitty-only; resolve the setting against the terminal's
398
+ // capability (`TERMINAL.textSizing` defaults on for Kitty) so it stays off
399
+ // unless the user opts in, and never emits raw escapes on other terminals.
400
+ setTerminalTextSizing(settings.get("tui.textSizing") && TERMINAL.textSizing);
401
+ this.chatContainer = new TranscriptContainer();
394
402
  this.pendingMessagesContainer = new Container();
395
403
  this.statusContainer = new Container();
396
404
  this.todoContainer = new Container();
@@ -599,7 +607,8 @@ export class InteractiveMode implements InteractiveModeContext {
599
607
  await this.initHooksAndCustomTools();
600
608
 
601
609
  // Restore mode from session (e.g. plan mode on resume)
602
- await this.#restoreModeFromSession();
610
+ this.session.setSessionSwitchReconciler?.(() => this.#reconcileModeFromSession());
611
+ await this.#reconcileModeFromSession();
603
612
 
604
613
  // Restore unsent editor draft from previous session shutdown (Ctrl+D).
605
614
  // One-shot: consumeDraft removes the sidecar after read so the next
@@ -915,6 +924,10 @@ export class InteractiveMode implements InteractiveModeContext {
915
924
  this.#pendingSubmissionDispose = undefined;
916
925
  }
917
926
  this.editor.setText("");
927
+ // Reconciliation checkpoint: the rebuild below replays the whole transcript
928
+ // into native scrollback, so retire frozen block snapshots and let every
929
+ // block render its current state.
930
+ this.chatContainer.thaw();
918
931
  this.ui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true });
919
932
  this.ensureLoadingAnimation();
920
933
  this.ui.requestRender();
@@ -1001,7 +1014,7 @@ export class InteractiveMode implements InteractiveModeContext {
1001
1014
  } else {
1002
1015
  const accentEnabled = !isSettingsInitialized() || settings.get("statusLine.sessionAccent") !== false;
1003
1016
  const sessionName = accentEnabled ? this.sessionManager.getSessionName() : undefined;
1004
- const hex = sessionName ? getSessionAccentHex(sessionName) : undefined;
1017
+ const hex = sessionName ? getSessionAccentHex(sessionName, theme.accentSurfaceLuminance) : undefined;
1005
1018
  const ansi = getSessionAccentAnsi(hex);
1006
1019
  if (ansi) {
1007
1020
  this.editor.borderColor = (str: string) => `${ansi}${str}\x1b[39m`;
@@ -1060,7 +1073,7 @@ export class InteractiveMode implements InteractiveModeContext {
1060
1073
  * Auto-complete any pending/in_progress todo whose content matches a
1061
1074
  * subagent that has finished successfully. Fires on every observer
1062
1075
  * `onChange` so the visual state stays in sync with subagent lifecycle
1063
- * without requiring the agent to issue a follow-up `todo_write`. Failed
1076
+ * without requiring the agent to issue a follow-up `todo`. Failed
1064
1077
  * and aborted subagents are intentionally NOT auto-completed — those
1065
1078
  * stay open so the user (or the next agent turn) can decide what to do.
1066
1079
  *
@@ -1358,8 +1371,42 @@ export class InteractiveMode implements InteractiveModeContext {
1358
1371
  }
1359
1372
  }
1360
1373
 
1361
- /** Restore mode state from session entries on resume (e.g. plan mode). */
1362
- async #restoreModeFromSession(): Promise<void> {
1374
+ async #clearTransientModeState(): Promise<void> {
1375
+ if (this.planModeEnabled || this.planModePaused) {
1376
+ if (this.#planModePreviousTools !== undefined) {
1377
+ await this.session.setActiveToolsByName(this.#planModePreviousTools);
1378
+ }
1379
+ this.session.setStandingResolveHandler?.(null);
1380
+ this.session.setPlanModeState(undefined);
1381
+ this.planModeEnabled = false;
1382
+ this.planModePaused = false;
1383
+ this.planModePlanFilePath = undefined;
1384
+ this.#planModePreviousTools = undefined;
1385
+ this.#planModePreviousModelState = undefined;
1386
+ this.#pendingModelSwitch = undefined;
1387
+ this.#planModeHasEntered = false;
1388
+ this.#updatePlanModeStatus();
1389
+ }
1390
+
1391
+ if (this.goalModeEnabled || this.goalModePaused) {
1392
+ if (this.#goalModePreviousTools !== undefined) {
1393
+ await this.session.setActiveToolsByName(this.#goalModePreviousTools);
1394
+ }
1395
+ this.session.setGoalModeState(undefined);
1396
+ this.goalModeEnabled = false;
1397
+ this.goalModePaused = false;
1398
+ this.#goalModePreviousTools = undefined;
1399
+ this.#goalTurnHadToolCalls = false;
1400
+ this.#goalContinuationTurnInFlight = false;
1401
+ this.#goalSuppressNextContinuation = false;
1402
+ this.#cancelGoalContinuation();
1403
+ this.#updateGoalModeStatus();
1404
+ }
1405
+ }
1406
+
1407
+ /** Reconcile mode state from session entries on resume/switch. */
1408
+ async #reconcileModeFromSession(): Promise<void> {
1409
+ await this.#clearTransientModeState();
1363
1410
  const sessionContext = this.sessionManager.buildSessionContext();
1364
1411
  const goalEnabled = this.session.settings.get("goal.enabled");
1365
1412
  if (!goalEnabled && (sessionContext.mode === "goal" || sessionContext.mode === "goal_paused")) {
@@ -2495,7 +2542,7 @@ export class InteractiveMode implements InteractiveModeContext {
2495
2542
  const accentEnabled = !isSettingsInitialized() || settings.get("statusLine.sessionAccent") !== false;
2496
2543
  const sessionName = accentEnabled ? this.sessionManager.getSessionName() : undefined;
2497
2544
  if (!sessionName) return undefined;
2498
- const hex = getSessionAccentHex(sessionName);
2545
+ const hex = getSessionAccentHex(sessionName, theme.accentSurfaceLuminance);
2499
2546
  const main = getSessionAccentAnsi(hex);
2500
2547
  const dim = getSessionAccentAnsi(adjustHsv(hex, { s: 0.55, v: 0.65 }));
2501
2548
  return main && dim ? { main, dim } : undefined;
@@ -9,6 +9,7 @@ import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
9
9
  import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
10
10
  import type { AgentSession } from "../session/agent-session";
11
11
  import { isSilentAbort } from "../session/messages";
12
+ import { flushTelemetryExport } from "../telemetry-export";
12
13
  import { initializeExtensions } from "./runtime-init";
13
14
 
14
15
  /**
@@ -83,6 +84,10 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
83
84
  !isSilentAbort(assistantMsg.errorMessage)
84
85
  ) {
85
86
  const errorLine = sanitizeText(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
87
+ // Flush before this hard exit — it bypasses the awaited postmortem.quit()
88
+ // in main(), and the postmortem `exit` handler can't await, so the error
89
+ // spans would otherwise stay buffered in the batch processor and drop.
90
+ await flushTelemetryExport();
86
91
  const flushed = process.stderr.write(`${errorLine}\n`);
87
92
  if (flushed) {
88
93
  process.exit(1);
@@ -10,7 +10,7 @@ import type { Effort, ImageContent, Model } from "@oh-my-pi/pi-ai";
10
10
  import type { BashResult } from "../../exec/bash-executor";
11
11
  import type { ContextUsage } from "../../extensibility/extensions/types";
12
12
  import type { SessionStats } from "../../session/agent-session";
13
- import type { TodoPhase } from "../../tools/todo-write";
13
+ import type { TodoPhase } from "../../tools/todo";
14
14
 
15
15
  // ============================================================================
16
16
  // RPC Commands (stdin)
@@ -10,7 +10,7 @@ import {
10
10
  supportsLanguage as nativeSupportsLanguage,
11
11
  } from "@oh-my-pi/pi-natives";
12
12
  import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui";
13
- import { adjustHsv, getCustomThemesDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
13
+ import { adjustHsv, colorLuma, getCustomThemesDir, isEnoent, logger, relativeLuminance } from "@oh-my-pi/pi-utils";
14
14
  import chalk from "chalk";
15
15
  import * as z from "zod/v4";
16
16
  // Embed theme JSON files at build time
@@ -136,6 +136,9 @@ export type SymbolKey =
136
136
  // Checkboxes
137
137
  | "checkbox.checked"
138
138
  | "checkbox.unchecked"
139
+ // Radio (single-choice)
140
+ | "radio.selected"
141
+ | "radio.unselected"
139
142
  // Text Formatting
140
143
  | "format.bullet"
141
144
  | "format.dash"
@@ -302,6 +305,9 @@ const UNICODE_SYMBOLS: SymbolMap = {
302
305
  // Checkboxes
303
306
  "checkbox.checked": "☑",
304
307
  "checkbox.unchecked": "☐",
308
+ // Radio (single-choice)
309
+ "radio.selected": "◉",
310
+ "radio.unselected": "○",
305
311
  // Formatting
306
312
  "format.bullet": "•",
307
313
  "format.dash": "—",
@@ -559,6 +565,11 @@ const NERD_SYMBOLS: SymbolMap = {
559
565
  "checkbox.checked": "\uf14a",
560
566
  // pick:  | alt: 
561
567
  "checkbox.unchecked": "\uf096",
568
+ // Radio (single-choice)
569
+ // pick: (fa-dot-circle-o) | alt: ◉
570
+ "radio.selected": "\uf192",
571
+ // pick: (fa-circle-o) | alt: ○
572
+ "radio.unselected": "\uf10c",
562
573
  // pick:  | alt:   •
563
574
  "format.bullet": "\uf111",
564
575
  // pick: – | alt: — ― -
@@ -731,6 +742,8 @@ const ASCII_SYMBOLS: SymbolMap = {
731
742
  // Checkboxes
732
743
  "checkbox.checked": "[x]",
733
744
  "checkbox.unchecked": "[ ]",
745
+ "radio.selected": "(o)",
746
+ "radio.unselected": "( )",
734
747
  "format.bullet": "*",
735
748
  "format.dash": "-",
736
749
  "format.bracketLeft": "[",
@@ -1277,6 +1290,16 @@ export class Theme {
1277
1290
  #bgColors: Record<ThemeBg, string>;
1278
1291
  #symbols: SymbolMap;
1279
1292
  #spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>>;
1293
+ /**
1294
+ * Perceptual luma (0..1) of the status-line background — used to classify the
1295
+ * theme light/dark. Undefined when it can't be resolved. Classified against the
1296
+ * status line (the surface session accents render on) rather than the chat bubble
1297
+ * (`userMessageBg`), which some themes (e.g. `porcelain`) style dark on an
1298
+ * otherwise-light theme.
1299
+ */
1300
+ readonly statusLineLuminance: number | undefined;
1301
+ /** WCAG relative luminance of the status-line background — basis for accent contrast. */
1302
+ readonly #statusLineContrastLuminance: number | undefined;
1280
1303
 
1281
1304
  constructor(
1282
1305
  fgColors: Record<ThemeColor, string | number>,
@@ -1286,6 +1309,8 @@ export class Theme {
1286
1309
  symbolOverrides: Partial<Record<SymbolKey, string>>,
1287
1310
  spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>> = {},
1288
1311
  ) {
1312
+ this.statusLineLuminance = colorLuma(bgColors.statusLineBg);
1313
+ this.#statusLineContrastLuminance = relativeLuminance(bgColors.statusLineBg);
1289
1314
  this.#fgColors = {} as Record<ThemeColor, string>;
1290
1315
  for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
1291
1316
  this.#fgColors[key] = fgAnsi(value, mode);
@@ -1307,6 +1332,19 @@ export class Theme {
1307
1332
  this.#spinnerFramesOverrides = spinnerFramesOverrides;
1308
1333
  }
1309
1334
 
1335
+ /** True when the active theme has a light status-line background. */
1336
+ get isLight(): boolean {
1337
+ return this.statusLineLuminance !== undefined && this.statusLineLuminance > 0.5;
1338
+ }
1339
+
1340
+ /**
1341
+ * Surface luminance to size session accents against on light themes; undefined on
1342
+ * dark themes so accents stay vivid. Pass straight to `getSessionAccentHex`.
1343
+ */
1344
+ get accentSurfaceLuminance(): number | undefined {
1345
+ return this.isLight ? this.#statusLineContrastLuminance : undefined;
1346
+ }
1347
+
1310
1348
  fg(color: ThemeColor, text: string): string {
1311
1349
  const ansi = this.#fgColors[color];
1312
1350
  if (!ansi) throw new Error(`Unknown theme color: ${color}`);
@@ -1570,6 +1608,13 @@ export class Theme {
1570
1608
  };
1571
1609
  }
1572
1610
 
1611
+ get radio() {
1612
+ return {
1613
+ selected: this.#symbols["radio.selected"],
1614
+ unselected: this.#symbols["radio.unselected"],
1615
+ };
1616
+ }
1617
+
1573
1618
  get format() {
1574
1619
  return {
1575
1620
  bullet: this.#symbols["format.bullet"],
@@ -2312,13 +2357,8 @@ export function isLightTheme(themeName?: string): boolean {
2312
2357
  }
2313
2358
  try {
2314
2359
  const resolved = resolveVarRefs(themeJson.colors.userMessageBg, themeJson.vars ?? {});
2315
- if (typeof resolved !== "string" || !resolved.startsWith("#") || resolved.length !== 7) return false;
2316
- const r = parseInt(resolved.slice(1, 3), 16) / 255;
2317
- const g = parseInt(resolved.slice(3, 5), 16) / 255;
2318
- const b = parseInt(resolved.slice(5, 7), 16) / 255;
2319
- // Relative luminance (ITU-R BT.709)
2320
- const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
2321
- return luminance > 0.5;
2360
+ const luminance = colorLuma(resolved);
2361
+ return luminance !== undefined && luminance > 0.5;
2322
2362
  } catch {
2323
2363
  return false;
2324
2364
  }