@oh-my-pi/pi-coding-agent 17.3.4 → 17.3.7

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 (137) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-1hmwwt45.md} +88 -0
  3. package/dist/cli.js +3156 -3163
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/cli/stats-cli.d.ts +1 -6
  6. package/dist/types/commands/stats.d.ts +4 -0
  7. package/dist/types/config/model-discovery.d.ts +2 -0
  8. package/dist/types/config/model-registry.d.ts +6 -1
  9. package/dist/types/config/settings-schema.d.ts +10 -0
  10. package/dist/types/debug/report-bundle.d.ts +7 -2
  11. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +24 -6
  13. package/dist/types/extensibility/extensions/types.d.ts +60 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/launch/presence.d.ts +11 -0
  16. package/dist/types/lsp/client.d.ts +22 -0
  17. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  18. package/dist/types/mcp/config.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  20. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  21. package/dist/types/registry/agent-registry.d.ts +4 -0
  22. package/dist/types/session/agent-session-types.d.ts +0 -2
  23. package/dist/types/session/agent-session.d.ts +20 -0
  24. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  25. package/dist/types/session/irc-bridge.d.ts +2 -0
  26. package/dist/types/session/messages.d.ts +4 -0
  27. package/dist/types/session/session-loader.d.ts +18 -11
  28. package/dist/types/session/session-maintenance.d.ts +6 -2
  29. package/dist/types/session/session-manager.d.ts +19 -3
  30. package/dist/types/session/session-tools.d.ts +0 -1
  31. package/dist/types/session/turn-recovery.d.ts +7 -7
  32. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  33. package/dist/types/tools/browser/attach.d.ts +16 -0
  34. package/dist/types/tools/browser/launch.d.ts +2 -2
  35. package/dist/types/tools/browser.d.ts +1 -1
  36. package/dist/types/tools/builtin-names.d.ts +1 -1
  37. package/dist/types/tools/file-write-fallback.d.ts +124 -0
  38. package/dist/types/tools/hub/jobs.d.ts +6 -0
  39. package/dist/types/tools/hub/types.d.ts +6 -0
  40. package/dist/types/tools/index.d.ts +1 -0
  41. package/dist/types/tools/path-utils.d.ts +23 -0
  42. package/dist/types/vibe/runtime.d.ts +3 -3
  43. package/package.json +13 -13
  44. package/src/auto-thinking/classifier.ts +37 -23
  45. package/src/cli/models-cli.ts +1 -1
  46. package/src/cli/stats-cli.ts +6 -72
  47. package/src/commands/stats.ts +7 -4
  48. package/src/commit/analysis/conventional.ts +15 -9
  49. package/src/commit/analysis/summary.ts +15 -9
  50. package/src/commit/changelog/generate.ts +15 -9
  51. package/src/commit/map-reduce/map-phase.ts +3 -19
  52. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  53. package/src/config/model-discovery.ts +3 -3
  54. package/src/config/model-registry.ts +29 -16
  55. package/src/config/settings-schema.ts +13 -0
  56. package/src/debug/report-bundle.ts +25 -59
  57. package/src/discovery/claude-plugins.ts +2 -1
  58. package/src/discovery/claude.ts +4 -2
  59. package/src/discovery/helpers.ts +5 -0
  60. package/src/edit/hashline/filesystem.ts +9 -3
  61. package/src/edit/modes/patch.ts +31 -5
  62. package/src/edit/renderer.ts +48 -19
  63. package/src/extensibility/extensions/loader.ts +20 -5
  64. package/src/extensibility/extensions/runner.ts +239 -24
  65. package/src/extensibility/extensions/types.ts +62 -0
  66. package/src/extensibility/extensions/wrapper.ts +26 -11
  67. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  68. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  69. package/src/irc/bus.ts +1 -1
  70. package/src/launch/broker.ts +8 -1
  71. package/src/launch/presence.ts +77 -1
  72. package/src/lsp/client.ts +35 -3
  73. package/src/lsp/clients/biome-client.ts +47 -93
  74. package/src/lsp/servers.ts +17 -11
  75. package/src/lsp/tool.ts +2 -4
  76. package/src/lsp/workspace-diagnostics.ts +24 -2
  77. package/src/lsp/writethrough.ts +20 -10
  78. package/src/mcp/config.ts +50 -6
  79. package/src/memories/index.ts +29 -25
  80. package/src/mnemopi/backend.ts +13 -11
  81. package/src/modes/components/ask-dialog.ts +25 -5
  82. package/src/modes/components/tool-execution.ts +10 -6
  83. package/src/modes/components/welcome.ts +4 -1
  84. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  85. package/src/modes/controllers/selector-controller.ts +6 -0
  86. package/src/modes/controllers/tan-command-controller.ts +1 -0
  87. package/src/modes/interactive-mode.ts +49 -10
  88. package/src/modes/theme/defaults/birch.json +2 -2
  89. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  90. package/src/prompts/system/date-cwd-reminder.md +3 -0
  91. package/src/prompts/system/project-prompt.md +0 -1
  92. package/src/prompts/system/rewind-report.md +1 -3
  93. package/src/prompts/tools/browser.md +1 -0
  94. package/src/prompts/tools/rewind.md +1 -13
  95. package/src/registry/agent-lifecycle.ts +34 -0
  96. package/src/registry/agent-registry.ts +27 -2
  97. package/src/registry/persisted-agents.ts +40 -20
  98. package/src/sdk.ts +32 -2
  99. package/src/session/agent-session-types.ts +0 -2
  100. package/src/session/agent-session.ts +178 -46
  101. package/src/session/date-cwd-reminder.ts +77 -0
  102. package/src/session/irc-bridge.ts +5 -0
  103. package/src/session/messages.ts +5 -1
  104. package/src/session/session-loader.ts +106 -64
  105. package/src/session/session-maintenance.ts +189 -115
  106. package/src/session/session-manager.ts +142 -35
  107. package/src/session/session-persistence.ts +4 -4
  108. package/src/session/session-storage.ts +18 -5
  109. package/src/session/session-tools.ts +5 -10
  110. package/src/session/turn-recovery.ts +102 -20
  111. package/src/session/unexpected-stop-classifier.ts +33 -21
  112. package/src/slash-commands/builtin-session.ts +1 -1
  113. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  114. package/src/system-prompt.ts +0 -5
  115. package/src/task/executor.ts +4 -15
  116. package/src/task/persisted-revive.ts +3 -6
  117. package/src/tools/ask.ts +10 -3
  118. package/src/tools/browser/attach.ts +80 -25
  119. package/src/tools/browser/launch.ts +44 -15
  120. package/src/tools/browser/relay/daemon.ts +3 -7
  121. package/src/tools/browser/render.ts +1 -1
  122. package/src/tools/browser/shared-daemon.ts +3 -7
  123. package/src/tools/browser.ts +5 -5
  124. package/src/tools/builtin-names.ts +7 -3
  125. package/src/tools/checkpoint.ts +1 -7
  126. package/src/tools/file-write-fallback.ts +467 -0
  127. package/src/tools/hub/index.ts +1 -1
  128. package/src/tools/hub/jobs.ts +20 -3
  129. package/src/tools/hub/types.ts +6 -0
  130. package/src/tools/index.ts +1 -0
  131. package/src/tools/path-utils.ts +79 -0
  132. package/src/tools/render-utils.ts +53 -21
  133. package/src/tools/think.ts +15 -3
  134. package/src/tts/speech-enhancer.ts +19 -14
  135. package/src/utils/commit-message-generator.ts +14 -12
  136. package/src/utils/title-generator.ts +23 -19
  137. package/src/vibe/runtime.ts +32 -17
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import { isEnoent, logger, once, untilAborted } from "@oh-my-pi/pi-utils";
3
3
  import type { BunFile } from "bun";
4
+ import { isPermissionDeniedError, writeFileWithFallback } from "../tools/file-write-fallback";
4
5
  import { FileChangeType, notifyWorkspaceWatchedFiles } from "./client";
5
6
  import { getServersForFile } from "./config";
6
7
  import {
@@ -66,11 +67,7 @@ export async function writethroughNoop(
66
67
  _batch?: LspWritethroughBatchRequest,
67
68
  _getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
68
69
  ): Promise<FileDiagnosticsResult | undefined> {
69
- if (file) {
70
- await file.write(content);
71
- } else {
72
- await Bun.write(dst, content);
73
- }
70
+ await writeFileWithFallback(dst, content, file);
74
71
  return undefined;
75
72
  }
76
73
 
@@ -78,6 +75,12 @@ interface PendingWritethrough {
78
75
  dst: string;
79
76
  file?: BunFile;
80
77
  changeType: FileChangeType;
78
+ /**
79
+ * The bytes this entry committed. The flush prefers a fresh read of `dst` so
80
+ * post-processing sees whatever else in the batch touched the file, and falls
81
+ * back to these when that read is denied.
82
+ */
83
+ content: string;
81
84
  }
82
85
 
83
86
  interface RunLspWritethroughOptions {
@@ -288,7 +291,7 @@ async function runLspWritethrough(
288
291
  const contentAlreadyWritten = runOptions?.contentAlreadyWritten ?? false;
289
292
 
290
293
  let finalContent = content;
291
- const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
294
+ const writeContent = async (value: string) => writeFileWithFallback(dst, value, file);
292
295
  const getWritePromise = once(() =>
293
296
  contentAlreadyWritten && finalContent === content ? Promise.resolve() : writeContent(finalContent),
294
297
  );
@@ -458,9 +461,16 @@ async function flushWritethroughBatch(
458
461
  try {
459
462
  content = await fs.promises.readFile(entry.dst, "utf8");
460
463
  } catch (error) {
461
- if (!isEnoent(error)) throw error;
462
- bundle?.finalize(undefined);
463
- continue;
464
+ if (isEnoent(error)) {
465
+ bundle?.finalize(undefined);
466
+ continue;
467
+ }
468
+ // A brokered write lands bytes this process may not be able to read
469
+ // back: a sandbox that denies the write commonly denies the read too.
470
+ // Failing here would fail a flush whose every write succeeded, so the
471
+ // content this entry committed stands in for the unreadable file.
472
+ if (!isPermissionDeniedError(error)) throw error;
473
+ content = entry.content;
464
474
  }
465
475
  const deferredInner =
466
476
  bundle &&
@@ -552,7 +562,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
552
562
  }
553
563
 
554
564
  const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
555
- state.entries.set(dst, { dst, file, changeType });
565
+ state.entries.set(dst, { dst, file, changeType, content });
556
566
  if (!batch.flush) return undefined;
557
567
 
558
568
  writethroughBatches.delete(batch.id);
package/src/mcp/config.ts CHANGED
@@ -224,6 +224,43 @@ export function extractExaApiKey(config: MCPServerConfig): string | undefined {
224
224
  return undefined;
225
225
  }
226
226
 
227
+ /** Exa MCP tools already covered by the native Exa integration. */
228
+ const NATIVE_EXA_MCP_TOOLS: Record<string, true> = { web_search_exa: true };
229
+
230
+ /**
231
+ * Parse the comma-separated `tools` restriction from an Exa MCP config.
232
+ * Returns `null` when the config does not restrict its tool set.
233
+ */
234
+ function getRequestedExaMcpTools(config: MCPServerConfig): string[] | null {
235
+ const raw = (() => {
236
+ if (config.type === "http" || config.type === "sse") {
237
+ const httpConfig = config as { url?: string };
238
+ if (!httpConfig.url) return undefined;
239
+ try {
240
+ return new URL(httpConfig.url).searchParams.get("tools") ?? undefined;
241
+ } catch {
242
+ return undefined;
243
+ }
244
+ }
245
+ if (!config.type || config.type === "stdio") {
246
+ const stdioConfig = config as { args?: string[] };
247
+ const args = stdioConfig.args ?? [];
248
+ for (let i = 0; i < args.length; i++) {
249
+ if (/^--?tools$/i.test(args[i])) return args[i + 1];
250
+ const match = args[i].match(/(?:^|[\s?&])tools=([^&\s]+)/i) ?? args[i].match(/--?tools[=\s]([^\s]+)/i);
251
+ if (match) return match[1];
252
+ }
253
+ }
254
+ return undefined;
255
+ })();
256
+ if (!raw) return null;
257
+ const tools = raw
258
+ .split(",")
259
+ .map(tool => tool.trim())
260
+ .filter(tool => tool.length > 0);
261
+ return tools.length > 0 ? tools : null;
262
+ }
263
+
227
264
  /** Result of filtering Exa MCP servers */
228
265
  export interface ExaFilterResult {
229
266
  /** Configs with Exa servers removed */
@@ -236,7 +273,9 @@ export interface ExaFilterResult {
236
273
 
237
274
  /**
238
275
  * Filter out Exa MCP servers and extract their API keys.
239
- * Since we have native Exa integration, we don't need the MCP server.
276
+ * Since we have native Exa integration, we don't need the MCP server
277
+ * unless the config explicitly requests Exa tools the native integration
278
+ * does not provide (e.g. `web_fetch_exa`, `web_search_advanced_exa`).
240
279
  */
241
280
  export function filterExaMCPServers(
242
281
  configs: Record<string, MCPServerConfig>,
@@ -248,17 +287,22 @@ export function filterExaMCPServers(
248
287
 
249
288
  for (const [name, config] of Object.entries(configs)) {
250
289
  if (isExaMCPServer(name, config)) {
251
- // Extract API key before filtering
290
+ // Extract API key for the native Exa integration even when the MCP
291
+ // server is kept below for its extra tools.
252
292
  const apiKey = extractExaApiKey(config);
253
293
  if (apiKey) {
254
294
  exaApiKeys.push(apiKey);
255
295
  }
256
- } else {
257
- filtered[name] = config;
258
- if (sources[name]) {
259
- filteredSources[name] = sources[name];
296
+ const requested = getRequestedExaMcpTools(config);
297
+ const hasExtraTools = requested?.some(tool => !NATIVE_EXA_MCP_TOOLS[tool.toLowerCase()]) ?? false;
298
+ if (!hasExtraTools) {
299
+ continue;
260
300
  }
261
301
  }
302
+ filtered[name] = config;
303
+ if (sources[name]) {
304
+ filteredSources[name] = sources[name];
305
+ }
262
306
  }
263
307
 
264
308
  return { configs: filtered, exaApiKeys, sources: filteredSources };
@@ -3,7 +3,7 @@ import type * as fsNode from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
6
- import { type ApiKey, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
6
+ import { type ApiKey, completeSimple, Effort, type Model, retryTransientCompletion } from "@oh-my-pi/pi-ai";
7
7
  import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
8
8
  import { getAgentDbPath, getMemoriesDir, isEnoent, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils";
9
9
 
@@ -750,18 +750,20 @@ async function runStage1Job(options: {
750
750
  response_items_json: truncatedItems,
751
751
  });
752
752
 
753
- const response = await completeSimple(
754
- model,
755
- {
756
- systemPrompt: [stageOneSystemTemplate],
757
- messages: [{ role: "user", content: [{ type: "text", text: inputPrompt }], timestamp: Date.now() }],
758
- },
759
- {
760
- apiKey,
761
- metadata: options.metadata,
762
- maxTokens: Math.max(1024, Math.min(4096, Math.floor(modelMaxTokens * 0.2))),
763
- reasoning: clampThinkingLevelForModel(model, Effort.Low),
764
- },
753
+ const response = await retryTransientCompletion(() =>
754
+ completeSimple(
755
+ model,
756
+ {
757
+ systemPrompt: [stageOneSystemTemplate],
758
+ messages: [{ role: "user", content: [{ type: "text", text: inputPrompt }], timestamp: Date.now() }],
759
+ },
760
+ {
761
+ apiKey,
762
+ metadata: options.metadata,
763
+ maxTokens: Math.max(1024, Math.min(4096, Math.floor(modelMaxTokens * 0.2))),
764
+ reasoning: clampThinkingLevelForModel(model, Effort.Low),
765
+ },
766
+ ),
765
767
  );
766
768
 
767
769
  if (response.stopReason === "error") {
@@ -887,18 +889,20 @@ async function runConsolidationModel(options: {
887
889
  rollout_summaries: truncateByApproxTokens(rolloutSummaries, 12_000),
888
890
  });
889
891
 
890
- const response = await completeSimple(
891
- model,
892
- {
893
- systemPrompt: [consolidationSystemTemplate],
894
- messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }],
895
- },
896
- {
897
- apiKey,
898
- metadata: options.metadata,
899
- maxTokens: 8192,
900
- reasoning: clampThinkingLevelForModel(model, Effort.Medium),
901
- },
892
+ const response = await retryTransientCompletion(() =>
893
+ completeSimple(
894
+ model,
895
+ {
896
+ systemPrompt: [consolidationSystemTemplate],
897
+ messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }],
898
+ },
899
+ {
900
+ apiKey,
901
+ metadata: options.metadata,
902
+ maxTokens: 8192,
903
+ reasoning: clampThinkingLevelForModel(model, Effort.Medium),
904
+ },
905
+ ),
902
906
  );
903
907
  if (response.stopReason === "error") {
904
908
  throw new Error(response.errorMessage || "phase2 model error");
@@ -1,6 +1,6 @@
1
1
  import { rm } from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { type ApiKeyResolver, completeSimple } from "@oh-my-pi/pi-ai";
3
+ import { type ApiKeyResolver, completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
4
4
  import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
5
5
  import type { Mnemopi } from "@oh-my-pi/pi-mnemopi";
6
6
  import type * as MnemopiDiagnoseNs from "@oh-my-pi/pi-mnemopi/diagnose";
@@ -545,16 +545,18 @@ async function resolveMnemopiProviderOptions(
545
545
  });
546
546
  return null;
547
547
  }
548
- const message = await completeSimple(
549
- model,
550
- {
551
- messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
552
- },
553
- {
554
- apiKey: modelRegistry.resolver(model, sessionId),
555
- maxTokens: opts?.maxTokens,
556
- temperature: opts?.temperature,
557
- },
548
+ const message = await retryTransientCompletion(() =>
549
+ completeSimple(
550
+ model,
551
+ {
552
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
553
+ },
554
+ {
555
+ apiKey: modelRegistry.resolver(model, sessionId),
556
+ maxTokens: opts?.maxTokens,
557
+ temperature: opts?.temperature,
558
+ },
559
+ ),
558
560
  );
559
561
  return message.content
560
562
  .filter(
@@ -4,6 +4,7 @@ import {
4
4
  Markdown,
5
5
  type MarkdownTheme,
6
6
  matchesKey,
7
+ padding,
7
8
  renderInlineMarkdown,
8
9
  replaceTabs,
9
10
  ScrollView,
@@ -12,6 +13,7 @@ import {
12
13
  Text,
13
14
  type TUI,
14
15
  truncateToWidth,
16
+ visibleWidth,
15
17
  wrapTextWithAnsi,
16
18
  } from "@oh-my-pi/pi-tui";
17
19
  import type {
@@ -315,8 +317,18 @@ function renderRowLabel(
315
317
  const cursor = selected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
316
318
  const label = renderInlineMarkdown(rowItem.label, mdTheme, t => theme.fg(color, t));
317
319
  const noteMarker = state.note && state.noteRowKey === rowItem.key ? theme.fg("success", " ✎ note") : "";
318
- const firstLine = `${cursor}${marker}${label}${noteMarker}`;
319
- const lines = [truncateToWidth(firstLine, width, Ellipsis.Unicode)];
320
+ // `width` is already the inner content width consumed by row(); when a
321
+ // scrollbar is needed, renderRows() calls this again with one less column.
322
+ // Keep the cursor, option marker, first wrapped label line, and optional
323
+ // note marker within that budget so the outer fit() never truncates them.
324
+ const noteWidth = noteMarker ? visibleWidth(noteMarker) : 0;
325
+ const labelWidth = Math.max(1, width - visibleWidth(cursor) - visibleWidth(marker) - noteWidth);
326
+ const wrappedLabel = wrapTextWithAnsi(label, labelWidth);
327
+ const indent = padding(visibleWidth(cursor) + visibleWidth(marker));
328
+ const lines = [`${cursor}${marker}${wrappedLabel[0] ?? ""}${noteMarker}`];
329
+ for (let i = 1; i < wrappedLabel.length; i++) {
330
+ lines.push(`${indent}${wrappedLabel[i] ?? ""}`);
331
+ }
320
332
  if (rowItem.kind === "option") {
321
333
  const option = question.options[rowItem.optionIndex ?? -1];
322
334
  if (option?.description?.trim()) {
@@ -602,7 +614,9 @@ export class AskDialogComponent implements Component {
602
614
  return `Enter submit · ↑/↓ scroll ·${scroll} ${cancel}`;
603
615
  }
604
616
  const question = this.#questions[this.#currentQuestionIndex()];
605
- const action = question?.multi ? "Space/Enter toggle · n note" : "Enter select · n note";
617
+ // Enter advances in multi-question dialogs and submits single-question ones.
618
+ const enterAction = this.#questions.length > 1 ? "next" : "submit";
619
+ const action = question?.multi ? `Space toggle · Enter ${enterAction}` : "Enter select · n note";
606
620
  const tabs = this.#hasSubmitTab() ? " · Tab/←/→" : "";
607
621
  if (this.#questionCanPage && indicator) {
608
622
  return `${action} · ↑/↓${tabs} · ${cancel} · ${pageKeysLabel()} ${indicator}`;
@@ -680,8 +694,14 @@ export class AskDialogComponent implements Component {
680
694
  const option = question.options[rowItem.optionIndex ?? -1];
681
695
  if (!option) return;
682
696
  if (question.multi) {
683
- // Multi is toggle-only: Enter and Space both toggle, and the
684
- // answer is confirmed from the Submit tab.
697
+ if (isEnter) {
698
+ // Enter confirms the current selection without toggling the
699
+ // focused option; Space toggles. Advances to the next question
700
+ // (submitting only for a single-question dialog), matching
701
+ // single-select Enter (#8252).
702
+ this.#advanceAfterQuestion();
703
+ return;
704
+ }
685
705
  if (state.selectedOptions.has(option.label)) {
686
706
  state.selectedOptions.delete(option.label);
687
707
  clearNoteIfRow(state, rowItem.key);
@@ -734,10 +734,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
734
734
  * region (a later block streams below it), or — while it is still the
735
735
  * live tail — its head rows were committed because the frame outgrew the
736
736
  * viewport. Committed rows are immutable, so from that point every further
737
- * partial snapshot is dropped. Rows restyle static gray only when nothing
738
- * is committed yet; otherwise the bytes stay exactly as painted. One-way —
739
- * blocks never re-enter the live region. Returns whether the block is
740
- * frozen.
737
+ * partial snapshot is dropped. A hidden, wholly uncommitted block keeps
738
+ * accepting snapshots so revealing it starts from current progress. Rows
739
+ * restyle static gray only when nothing is committed yet; otherwise the
740
+ * bytes stay exactly as painted. One-way — blocks never re-enter the live
741
+ * region. Returns whether the block is frozen.
741
742
  */
742
743
  #maybeFreezeBackgroundTask(): boolean {
743
744
  if (this.#backgroundTaskFrozen) return true;
@@ -745,7 +746,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
745
746
  const asyncState = (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state;
746
747
  if (asyncState !== "running") return false;
747
748
  const uncommitted = this.#liveRegion.isBlockUncommitted?.(this) ?? true;
748
- if (uncommitted && this.#liveRegion.isBlockInLiveRegion(this)) return false;
749
+ if (uncommitted && (!this.#toolActivityVisible || this.#liveRegion.isBlockInLiveRegion(this))) return false;
749
750
  this.#backgroundTaskFrozen = true;
750
751
  this.#updateSpinnerAnimation();
751
752
  if (uncommitted) {
@@ -825,10 +826,13 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
825
826
  * Reports `false` while it can still visually change so the
826
827
  * {@link TranscriptContainer} keeps it inside the repaintable live region:
827
828
  * a foreground tool awaiting its result, or one streaming partial output.
829
+ * Hidden blocks render no rows, so they cannot gate later streaming content.
830
+ * Visibility toggles reset and replay the transcript before revealing them.
828
831
  * A final (non-partial) result, a background-async tool the agent has moved
829
- * past, or an explicit {@link seal} flips it to `true`.
832
+ * past, or an explicit {@link seal} also flips it to `true`.
830
833
  */
831
834
  isTranscriptBlockFinalized(): boolean {
835
+ if (!this.#toolActivityVisible) return true;
832
836
  if (this.#sealed) return true;
833
837
  if (this.#result === undefined) return false;
834
838
  // A displaceable snapshot stays live: its rows are kept out of native
@@ -248,7 +248,10 @@ export class WelcomeComponent implements Component {
248
248
  visibleWidth(this.modelName),
249
249
  visibleWidth(this.providerName),
250
250
  );
251
- const desiredLeftCol = Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35)));
251
+ const desiredLeftCol = Math.max(
252
+ Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35))),
253
+ leftMinContentWidth,
254
+ );
252
255
  const dualLeftCol =
253
256
  dualContentWidth >= minRightCol + 1
254
257
  ? Math.min(desiredLeftCol, dualContentWidth - minRightCol)
@@ -1053,53 +1053,73 @@ export class ExtensionUiController {
1053
1053
  const savedText = this.ctx.editor.getText();
1054
1054
  const keybindings = KeybindingsManager.inMemory();
1055
1055
 
1056
- const { promise, resolve } = Promise.withResolvers<T>();
1056
+ const { promise, resolve, reject } = Promise.withResolvers<T>();
1057
1057
  let component: (Component & { dispose?(): void }) | undefined;
1058
1058
  let overlayHandle: OverlayHandle | undefined;
1059
1059
  let closed = false;
1060
+ let editorReplaced = false;
1060
1061
 
1061
- const close = (result: T) => {
1062
- if (closed) return;
1063
- closed = true;
1062
+ const cleanup = () => {
1064
1063
  component?.dispose?.();
1065
1064
  overlayHandle?.hide();
1066
1065
  overlayHandle = undefined;
1067
- if (!options?.overlay) {
1066
+ if (editorReplaced) {
1068
1067
  this.ctx.editorContainer.clear();
1069
1068
  this.ctx.editorContainer.addChild(this.ctx.editor);
1070
1069
  this.ctx.editor.setText(savedText);
1071
1070
  }
1072
1071
  this.ctx.ui.setFocus(this.ctx.editor);
1073
1072
  this.ctx.ui.requestRender();
1074
- resolve(result);
1075
1073
  };
1076
-
1077
- Promise.try(() => factory(this.ctx.ui, theme, keybindings, close)).then(c => {
1078
- if (closed) {
1079
- c.dispose?.();
1080
- return;
1081
- }
1082
- component = c;
1083
- if (options?.overlay) {
1084
- const overlayOptions =
1085
- typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions;
1086
- overlayHandle = this.ctx.ui.showOverlay(
1087
- component,
1088
- overlayOptions ?? {
1089
- anchor: "bottom-center",
1090
- width: "100%",
1091
- maxHeight: "100%",
1092
- margin: 0,
1093
- },
1094
- );
1095
- options.onHandle?.(overlayHandle);
1096
- return;
1074
+ const finish = (settle: () => void) => {
1075
+ if (closed) return;
1076
+ closed = true;
1077
+ options?.signal?.removeEventListener("abort", onAbort);
1078
+ try {
1079
+ cleanup();
1080
+ } finally {
1081
+ settle();
1097
1082
  }
1098
- this.ctx.editorContainer.clear();
1099
- this.ctx.editorContainer.addChild(component);
1100
- this.ctx.ui.setFocus(component);
1101
- this.ctx.ui.requestRender();
1102
- });
1083
+ };
1084
+ const fail = (error: unknown) => finish(() => reject(error));
1085
+ const onAbort = () => fail(options?.signal?.reason ?? new DOMException("Dialog aborted", "AbortError"));
1086
+ const close = (result: T) => finish(() => resolve(result));
1087
+
1088
+ if (options?.signal?.aborted) {
1089
+ fail(options.signal.reason ?? new DOMException("Dialog aborted", "AbortError"));
1090
+ return promise;
1091
+ }
1092
+ options?.signal?.addEventListener("abort", onAbort, { once: true });
1093
+
1094
+ Promise.try(() => factory(this.ctx.ui, theme, keybindings, close))
1095
+ .then(c => {
1096
+ if (closed) {
1097
+ c.dispose?.();
1098
+ return;
1099
+ }
1100
+ component = c;
1101
+ if (options?.overlay) {
1102
+ const overlayOptions =
1103
+ typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions;
1104
+ overlayHandle = this.ctx.ui.showOverlay(
1105
+ component,
1106
+ overlayOptions ?? {
1107
+ anchor: "bottom-center",
1108
+ width: "100%",
1109
+ maxHeight: "100%",
1110
+ margin: 0,
1111
+ },
1112
+ );
1113
+ options.onHandle?.(overlayHandle);
1114
+ return;
1115
+ }
1116
+ editorReplaced = true;
1117
+ this.ctx.editorContainer.clear();
1118
+ this.ctx.editorContainer.addChild(component);
1119
+ this.ctx.ui.setFocus(component);
1120
+ this.ctx.ui.requestRender();
1121
+ })
1122
+ .catch(fail);
1103
1123
  return promise;
1104
1124
  }
1105
1125
 
@@ -562,6 +562,12 @@ export class SelectorController {
562
562
  this.ctx.rebuildChatFromMessages();
563
563
  this.ctx.ui.resetDisplay();
564
564
  break;
565
+ case "display.showTokenUsage":
566
+ // Rebuild reruns usage-row detection under the new setting; resetDisplay
567
+ // retires rows already committed to native scrollback.
568
+ this.ctx.rebuildChatFromMessages();
569
+ this.ctx.ui.resetDisplay();
570
+ break;
565
571
  case "tui.tight":
566
572
  setTuiTight(value as boolean);
567
573
  this.ctx.ui.invalidate();
@@ -111,6 +111,7 @@ export class TanCommandController {
111
111
  let jobId = "";
112
112
  try {
113
113
  const cloneManager = await SessionManager.forkFrom(parentFile, cwd, sessionDir, undefined, {
114
+ copyArtifacts: false,
114
115
  suppressBreadcrumb: true,
115
116
  sessionFile: cloneFile,
116
117
  });
@@ -50,6 +50,7 @@ import {
50
50
  logger,
51
51
  postmortem,
52
52
  prompt,
53
+ sanitizeText,
53
54
  setProjectDir,
54
55
  } from "@oh-my-pi/pi-utils";
55
56
  import chalk from "@oh-my-pi/pi-utils/chalk";
@@ -390,16 +391,20 @@ export interface InteractiveModeOptions {
390
391
  /**
391
392
  * Anchored live-region container for the HUD/status rows between the transcript
392
393
  * and the editor (working loader, todo + subagent HUDs, transient notification
393
- * panels). While it has content every row is live: it reports a seam at 0 so the
394
- * engine never commits these anchored, rebuilt-in-place rows to native
395
- * scrollback — otherwise stale duplicates pile up above the live copy on short
396
- * terminals once the loader sits below a tall HUD. The transcript's own seam,
394
+ * panels). While it has content every row is live: it reports a seam at 0 and
395
+ * pins that live region so the engine never commits these anchored,
396
+ * rebuilt-in-place rows to native scrollback — otherwise stale duplicates pile
397
+ * up above the live copy on short terminals once the loader sits below a tall HUD. The transcript's own seam,
397
398
  * when present, sits higher and wins (topmost-seam merge in TUI.render).
398
399
  */
399
400
  class AnchoredLiveContainer extends Container implements NativeScrollbackLiveRegion {
400
401
  getNativeScrollbackLiveRegionStart(): number | undefined {
401
402
  return this.children.length > 0 ? 0 : undefined;
402
403
  }
404
+
405
+ isNativeScrollbackLiveRegionPinned(): boolean {
406
+ return true;
407
+ }
403
408
  }
404
409
 
405
410
  /**
@@ -814,7 +819,7 @@ export class InteractiveMode implements InteractiveModeContext {
814
819
  this.editor.onAutocompleteUpdate = () => {
815
820
  this.ui.requestRender();
816
821
  };
817
- this.editor.setShimmerRepaintHandler(() => this.ui.requestComponentRender(this.editor));
822
+ this.editor.setShimmerRepaintHandler(() => this.ui.requestDirectWrite(this.editor));
818
823
  this.#syncEditorMaxHeight();
819
824
  this.#resizeHandler = () => {
820
825
  this.#syncEditorMaxHeight();
@@ -1108,6 +1113,15 @@ export class InteractiveMode implements InteractiveModeContext {
1108
1113
  // before initHooksAndCustomTools/#reconcileModeFromSession/#enterPlanMode —
1109
1114
  // all of which can reach setSessionName during init.
1110
1115
  this.#eventBusUnsubscribers.push(
1116
+ this.sessionManager.onPersistenceError(error => {
1117
+ const detail = truncateToWidth(
1118
+ replaceTabs(sanitizeText(error.message)).replace(/[\r\n]+/g, " "),
1119
+ TRUNCATE_LENGTHS.LINE,
1120
+ );
1121
+ this.showWarning(
1122
+ `Session persistence failed: ${detail}. Unsaved entries remain in memory; persistence will retry on the next entry.`,
1123
+ );
1124
+ }),
1111
1125
  this.sessionManager.onSessionNameChanged(() => {
1112
1126
  setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
1113
1127
  this.#handleSessionAccentInputsChanged();
@@ -1177,6 +1191,9 @@ export class InteractiveMode implements InteractiveModeContext {
1177
1191
 
1178
1192
  this.#eventBusUnsubscribers.push(
1179
1193
  this.session.subscribe(event => {
1194
+ if (event.type === "model_changed") {
1195
+ this.#updateWelcomeModel();
1196
+ }
1180
1197
  void this.#handleGoalSessionEvent(event);
1181
1198
  }),
1182
1199
  onStatusLineSessionAccentChanged(() => {
@@ -1184,6 +1201,11 @@ export class InteractiveMode implements InteractiveModeContext {
1184
1201
  this.#handleSessionAccentInputsChanged();
1185
1202
  }),
1186
1203
  );
1204
+ // Resync the welcome banner to the live model: init-time reconciliations
1205
+ // (#reconcileModeFromSession, #enterPlanMode for plan.defaultOnStartup)
1206
+ // can change the model before this subscription exists, so the
1207
+ // model_changed events they emit are never observed by the handler above.
1208
+ this.#updateWelcomeModel();
1187
1209
  this.#eventBusUnsubscribers.push(
1188
1210
  onModelRolesChanged(() => {
1189
1211
  void this.#reapplyPlanModeModelOnRoleChange();
@@ -3509,10 +3531,18 @@ export class InteractiveMode implements InteractiveModeContext {
3509
3531
  if (!this.vibeModeEnabled) {
3510
3532
  return;
3511
3533
  }
3512
- const ownerScope = this.#vibeModeOwnerScope;
3513
- const killed = await VibeSessionRegistry.global().killAll(this.#vibeParentSession(), ownerScope);
3514
- await this.session.deactivateVibeTools(this.#vibeModePreviousTools ?? []);
3515
- this.session.setVibeModeState(undefined);
3534
+ // Tear down with the queued-message drain suppressed: aborting the active
3535
+ // turn would otherwise let a queued user steer/follow-up restart on the
3536
+ // still-live Vibe tools before this teardown removes them (issue #8326).
3537
+ let killed = 0;
3538
+ await this.session.runModeExitTeardown(async () => {
3539
+ if (this.session.isStreaming) {
3540
+ await this.session.abort();
3541
+ }
3542
+ killed = await VibeSessionRegistry.global().killAll(this.#vibeParentSession(), this.#vibeModeOwnerScope);
3543
+ await this.session.deactivateVibeTools(this.#vibeModePreviousTools ?? []);
3544
+ this.session.setVibeModeState(undefined);
3545
+ });
3516
3546
  this.vibeModeEnabled = false;
3517
3547
  this.#vibeModePreviousTools = undefined;
3518
3548
  this.#vibeModeOwnerScope = undefined;
@@ -4252,7 +4282,7 @@ export class InteractiveMode implements InteractiveModeContext {
4252
4282
  nextEditor.onAutocompleteUpdate = () => {
4253
4283
  this.ui.requestRender();
4254
4284
  };
4255
- nextEditor.setShimmerRepaintHandler(() => this.ui.requestComponentRender(this.editor));
4285
+ nextEditor.setShimmerRepaintHandler(() => this.ui.requestDirectWrite(nextEditor));
4256
4286
  nextEditor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth));
4257
4287
  nextEditor.setMaxHeight(this.#computeEditorMaxHeight());
4258
4288
  if (this.historyStorage) {
@@ -4430,6 +4460,15 @@ export class InteractiveMode implements InteractiveModeContext {
4430
4460
  );
4431
4461
  }
4432
4462
 
4463
+ #updateWelcomeModel(): void {
4464
+ if (!this.#welcomeComponent) {
4465
+ return;
4466
+ }
4467
+
4468
+ this.#welcomeComponent.setModel(this.session.model?.name ?? "Unknown", this.session.model?.provider ?? "Unknown");
4469
+ this.ui.requestRender();
4470
+ }
4471
+
4433
4472
  #updateWelcomeLspServers(): void {
4434
4473
  if (!this.#welcomeComponent) {
4435
4474
  return;
@@ -33,9 +33,9 @@
33
33
  "thinkingText": "warmGray",
34
34
  "selectedBg": "selectedBg",
35
35
  "userMessageBg": "userMsgBg",
36
- "userMessageText": "",
36
+ "userMessageText": "forestFloor",
37
37
  "customMessageBg": "customMsgBg",
38
- "customMessageText": "",
38
+ "customMessageText": "forestFloor",
39
39
  "customMessageLabel": "#5a7a52",
40
40
  "toolPendingBg": "toolPendingBg",
41
41
  "toolSuccessBg": "toolSuccessBg",
@@ -0,0 +1,5 @@
1
+ <system-notice>
2
+ Exploration checkpoint active.
3
+ - MUST `rewind` with findings once exploration is done.
4
+ - MUST `rewind` before yielding.
5
+ </system-notice>
@@ -0,0 +1,3 @@
1
+ <system-reminder>
2
+ Today: {{date}}; current working directory: '{{cwd}}'. Do not repeat this information in your reply.
3
+ </system-reminder>