@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.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 (176) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  3. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  4. package/dist/types/commit/analysis/summary.d.ts +2 -2
  5. package/dist/types/commit/changelog/generate.d.ts +2 -2
  6. package/dist/types/commit/changelog/index.d.ts +2 -2
  7. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  8. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  9. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  10. package/dist/types/commit/model-selection.d.ts +10 -4
  11. package/dist/types/config/api-key-resolver.d.ts +34 -0
  12. package/dist/types/config/model-registry.d.ts +17 -1
  13. package/dist/types/config/settings-schema.d.ts +9 -0
  14. package/dist/types/dap/config.d.ts +14 -1
  15. package/dist/types/dap/types.d.ts +10 -0
  16. package/dist/types/lsp/utils.d.ts +3 -2
  17. package/dist/types/modes/components/chat-block.d.ts +64 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  19. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  20. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  21. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  22. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  23. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +0 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  27. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  28. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  29. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  30. package/dist/types/modes/interactive-mode.d.ts +15 -5
  31. package/dist/types/modes/theme/theme.d.ts +1 -1
  32. package/dist/types/modes/types.d.ts +18 -5
  33. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  34. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  35. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  36. package/dist/types/sdk.d.ts +2 -0
  37. package/dist/types/session/agent-session.d.ts +21 -0
  38. package/dist/types/session/messages.d.ts +12 -0
  39. package/dist/types/session/session-manager.d.ts +3 -1
  40. package/dist/types/slash-commands/types.d.ts +4 -6
  41. package/dist/types/task/executor.d.ts +7 -0
  42. package/dist/types/task/index.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +3 -2
  44. package/dist/types/tools/archive-reader.d.ts +5 -0
  45. package/dist/types/tools/ast-edit.d.ts +3 -0
  46. package/dist/types/tools/ast-grep.d.ts +3 -0
  47. package/dist/types/tools/bash.d.ts +1 -0
  48. package/dist/types/tools/find.d.ts +8 -4
  49. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  50. package/dist/types/tools/memory-render.d.ts +4 -1
  51. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  52. package/dist/types/tools/render-utils.d.ts +5 -9
  53. package/dist/types/tools/search.d.ts +4 -0
  54. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  55. package/dist/types/tools/todo.d.ts +3 -2
  56. package/dist/types/tools/write.d.ts +3 -0
  57. package/dist/types/tui/output-block.d.ts +16 -4
  58. package/dist/types/tui/status-line.d.ts +3 -0
  59. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  60. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  61. package/package.json +9 -9
  62. package/src/auto-thinking/classifier.ts +5 -1
  63. package/src/cli/dry-balance-cli.ts +52 -17
  64. package/src/cli/gallery-cli.ts +4 -1
  65. package/src/cli/gallery-fixtures/misc.ts +29 -0
  66. package/src/commit/analysis/conventional.ts +2 -2
  67. package/src/commit/analysis/summary.ts +2 -2
  68. package/src/commit/changelog/generate.ts +2 -2
  69. package/src/commit/changelog/index.ts +2 -2
  70. package/src/commit/map-reduce/index.ts +3 -3
  71. package/src/commit/map-reduce/map-phase.ts +2 -2
  72. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  73. package/src/commit/model-selection.ts +33 -9
  74. package/src/commit/pipeline.ts +4 -4
  75. package/src/config/api-key-resolver.ts +58 -0
  76. package/src/config/model-registry.ts +25 -2
  77. package/src/config/settings-schema.ts +10 -0
  78. package/src/config/settings.ts +20 -2
  79. package/src/dap/config.ts +41 -2
  80. package/src/dap/defaults.json +1 -0
  81. package/src/dap/session.ts +1 -0
  82. package/src/dap/types.ts +10 -0
  83. package/src/debug/index.ts +40 -54
  84. package/src/edit/renderer.ts +82 -78
  85. package/src/eval/__tests__/llm-bridge.test.ts +90 -31
  86. package/src/eval/llm-bridge.ts +8 -3
  87. package/src/goals/tools/goal-tool.ts +36 -26
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/lsp/utils.ts +3 -2
  90. package/src/main.ts +9 -7
  91. package/src/memories/index.ts +12 -5
  92. package/src/mnemopi/backend.ts +5 -1
  93. package/src/modes/acp/acp-agent.ts +33 -26
  94. package/src/modes/components/assistant-message.ts +2 -9
  95. package/src/modes/components/chat-block.ts +111 -0
  96. package/src/modes/components/copy-selector.ts +1 -44
  97. package/src/modes/components/custom-editor.ts +23 -0
  98. package/src/modes/components/custom-message.ts +1 -3
  99. package/src/modes/components/execution-shared.ts +1 -2
  100. package/src/modes/components/hook-message.ts +1 -3
  101. package/src/modes/components/overlay-box.ts +108 -0
  102. package/src/modes/components/plan-review-overlay.ts +799 -0
  103. package/src/modes/components/plan-toc.ts +138 -0
  104. package/src/modes/components/read-tool-group.ts +20 -4
  105. package/src/modes/components/skill-message.ts +0 -1
  106. package/src/modes/components/tips.txt +1 -0
  107. package/src/modes/components/todo-reminder.ts +0 -2
  108. package/src/modes/components/tool-execution.ts +68 -88
  109. package/src/modes/components/transcript-container.ts +84 -24
  110. package/src/modes/components/user-message.ts +1 -2
  111. package/src/modes/controllers/command-controller-shared.ts +7 -6
  112. package/src/modes/controllers/command-controller.ts +57 -55
  113. package/src/modes/controllers/event-controller.ts +41 -40
  114. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  115. package/src/modes/controllers/input-controller.ts +124 -119
  116. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  117. package/src/modes/controllers/selector-controller.ts +23 -25
  118. package/src/modes/controllers/streaming-reveal.ts +212 -0
  119. package/src/modes/controllers/tan-command-controller.ts +173 -0
  120. package/src/modes/interactive-mode.ts +169 -94
  121. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  122. package/src/modes/theme/theme-schema.json +1 -1
  123. package/src/modes/theme/theme.ts +8 -4
  124. package/src/modes/types.ts +18 -7
  125. package/src/modes/utils/copy-targets.ts +133 -27
  126. package/src/modes/utils/ui-helpers.ts +44 -46
  127. package/src/plan-mode/approved-plan.ts +66 -43
  128. package/src/plan-mode/plan-protection.ts +4 -4
  129. package/src/prompts/system/background-tan-dispatch.md +8 -0
  130. package/src/prompts/system/plan-mode-active.md +67 -58
  131. package/src/prompts/system/plan-mode-approved.md +1 -1
  132. package/src/sdk.ts +11 -37
  133. package/src/session/agent-session.ts +82 -6
  134. package/src/session/messages.ts +26 -0
  135. package/src/session/session-manager.ts +13 -5
  136. package/src/slash-commands/builtin-registry.ts +36 -9
  137. package/src/slash-commands/types.ts +4 -6
  138. package/src/task/executor.ts +5 -2
  139. package/src/task/index.ts +4 -0
  140. package/src/task/render.ts +212 -147
  141. package/src/tools/archive-reader.ts +64 -0
  142. package/src/tools/ask.ts +119 -164
  143. package/src/tools/ast-edit.ts +98 -71
  144. package/src/tools/ast-grep.ts +37 -43
  145. package/src/tools/bash.ts +50 -6
  146. package/src/tools/debug.ts +20 -8
  147. package/src/tools/fetch.ts +297 -7
  148. package/src/tools/find.ts +44 -30
  149. package/src/tools/gh-renderer.ts +81 -42
  150. package/src/tools/grouped-file-output.ts +272 -48
  151. package/src/tools/image-gen.ts +150 -103
  152. package/src/tools/inspect-image-renderer.ts +63 -41
  153. package/src/tools/inspect-image.ts +8 -1
  154. package/src/tools/job.ts +3 -4
  155. package/src/tools/memory-render.ts +4 -1
  156. package/src/tools/plan-mode-guard.ts +21 -39
  157. package/src/tools/read.ts +23 -16
  158. package/src/tools/render-utils.ts +21 -37
  159. package/src/tools/resolve.ts +14 -0
  160. package/src/tools/search-tool-bm25.ts +36 -23
  161. package/src/tools/search.ts +80 -78
  162. package/src/tools/sqlite-reader.ts +9 -12
  163. package/src/tools/todo.ts +118 -52
  164. package/src/tools/write.ts +81 -62
  165. package/src/tui/output-block.ts +60 -13
  166. package/src/tui/status-line.ts +5 -1
  167. package/src/utils/commit-message-generator.ts +9 -1
  168. package/src/utils/enhanced-paste.ts +202 -0
  169. package/src/utils/title-generator.ts +2 -1
  170. package/src/web/search/providers/anthropic.ts +25 -19
  171. package/src/web/search/providers/exa.ts +11 -3
  172. package/src/web/search/providers/kimi.ts +28 -17
  173. package/src/web/search/providers/parallel.ts +35 -24
  174. package/src/web/search/providers/synthetic.ts +8 -6
  175. package/src/web/search/providers/tavily.ts +9 -8
  176. package/src/web/search/providers/zai.ts +8 -6
package/src/lsp/utils.ts CHANGED
@@ -153,9 +153,10 @@ export function formatDiagnostic(diagnostic: Diagnostic, filePath: string): stri
153
153
  const DIAG_PATH_RE = /^(.+?):(\d+:\d+\s+.*)$/;
154
154
 
155
155
  /**
156
- * Reformat pre-formatted diagnostic messages into grep-style directory/file groups.
156
+ * Reformat pre-formatted diagnostic messages into a multi-level, prefix-folded
157
+ * directory/file grouping (see `formatGroupedFiles`).
157
158
  * Input: ["path:line:col [sev] msg", ...]
158
- * Output: "# dir/\n## file.ts\n line:col [sev] msg"
159
+ * Output: "# pkg/src/\n## file.ts\n line:col [sev] msg"
159
160
  *
160
161
  * Messages that don't match the expected format are appended ungrouped at the end.
161
162
  */
package/src/main.ts CHANGED
@@ -278,7 +278,10 @@ async function runInteractiveMode(
278
278
  })
279
279
  : [];
280
280
 
281
- await mode.init({ suppressWelcomeIntro: resuming || setupScenes.length > 0 });
281
+ await mode.init({
282
+ suppressWelcomeIntro: resuming || setupScenes.length > 0,
283
+ clearInitialTerminalHistory: true,
284
+ });
282
285
 
283
286
  if (setupWizard && setupScenes.length > 0) {
284
287
  await setupWizard.runSetupWizard(mode, setupScenes);
@@ -295,12 +298,11 @@ async function runInteractiveMode(
295
298
  })
296
299
  .catch(() => {});
297
300
 
298
- // Cold-launch cleanup: wipe the terminal scrollback before painting the
299
- // resumed/new transcript. The TUI's initial paint deliberately preserves
300
- // native scrollback (prior shell content), but on `omp`/`omp -c` that leaves
301
- // the previous run's welcome + transcript stacked above the fresh one. Every
302
- // in-process session load already clears via `clearTerminalHistory`; the cold
303
- // launch is the lone path that did not.
301
+ // Cold-launch cleanup: the first paint already clears native history, and this
302
+ // replay replaces the welcome/startup frame with the resumed/new transcript.
303
+ // Every in-process session load also uses `clearTerminalHistory`; cold launch
304
+ // follows the same clean-cutover path instead of preserving a previous run's
305
+ // transcript above the fresh one.
304
306
  mode.renderInitialMessages(undefined, { preserveExistingChat: true, clearTerminalHistory: true });
305
307
 
306
308
  for (const notify of notifs) {
@@ -3,8 +3,9 @@ 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 { clampThinkingLevelForModel, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
6
+ import { type ApiKey, clampThinkingLevelForModel, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
7
7
  import { getAgentDbPath, getMemoriesDir, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils";
8
+
8
9
  import type { ModelRegistry } from "../config/model-registry";
9
10
  import { resolveModelRoleValue } from "../config/model-resolver";
10
11
  import type { Settings } from "../config/settings";
@@ -271,7 +272,10 @@ async function runPhase1(options: {
271
272
  const result = await runStage1Job({
272
273
  claim,
273
274
  model: phase1Model,
274
- apiKey: phase1ApiKey,
275
+ apiKey: modelRegistry.resolver(phase1Model.provider, {
276
+ sessionId: session.sessionId,
277
+ baseUrl: phase1Model.baseUrl,
278
+ }),
275
279
  modelMaxTokens: computeModelTokenBudget(phase1Model, config),
276
280
  config,
277
281
  metadata: session.agent?.metadataForProvider(phase1Model.provider),
@@ -428,7 +432,10 @@ async function runPhase2(options: {
428
432
  const consolidated = await runConsolidationModel({
429
433
  memoryRoot,
430
434
  model: phase2Model,
431
- apiKey: phase2ApiKey,
435
+ apiKey: modelRegistry.resolver(phase2Model.provider, {
436
+ sessionId: session.sessionId,
437
+ baseUrl: phase2Model.baseUrl,
438
+ }),
432
439
  metadata: session.agent?.metadataForProvider(phase2Model.provider),
433
440
  });
434
441
  await applyConsolidation(memoryRoot, consolidated);
@@ -574,7 +581,7 @@ function extractPersistableMessages(payload: string): AgentMessage[] {
574
581
  async function runStage1Job(options: {
575
582
  claim: Stage1Claim;
576
583
  model: Model;
577
- apiKey: string;
584
+ apiKey: ApiKey;
578
585
  modelMaxTokens: number;
579
586
  config: MemoryRuntimeConfig;
580
587
  metadata?: Record<string, unknown>;
@@ -718,7 +725,7 @@ async function readRolloutSummaries(memoryRoot: string): Promise<string> {
718
725
  async function runConsolidationModel(options: {
719
726
  memoryRoot: string;
720
727
  model: Model;
721
- apiKey: string;
728
+ apiKey: ApiKey;
722
729
  metadata?: Record<string, unknown>;
723
730
  }): Promise<{
724
731
  memoryMd: string;
@@ -5,6 +5,7 @@ import { Mnemopi } from "@oh-my-pi/pi-mnemopi";
5
5
  import { BankManager } from "@oh-my-pi/pi-mnemopi/core";
6
6
  import { type DiagnosticSummary, inspectDatabase } from "@oh-my-pi/pi-mnemopi/diagnose";
7
7
  import { logger } from "@oh-my-pi/pi-utils";
8
+
8
9
  import type { ModelRegistry } from "../config/model-registry";
9
10
  import { resolveRoleSelection } from "../config/model-resolver";
10
11
  import type { MemoryBackend, MemoryBackendStartOptions } from "../memory-backend/types";
@@ -334,7 +335,10 @@ async function resolveMnemopiProviderOptions(
334
335
  messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
335
336
  },
336
337
  {
337
- apiKey,
338
+ apiKey: modelRegistry.resolver(model.provider, {
339
+ sessionId,
340
+ baseUrl: model.baseUrl,
341
+ }),
338
342
  maxTokens: opts?.maxTokens,
339
343
  temperature: opts?.temperature,
340
344
  },
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
  import {
3
4
  type Agent,
@@ -62,7 +63,7 @@ import { MCPManager } from "../../mcp/manager";
62
63
  import type { MCPServerConfig } from "../../mcp/types";
63
64
  import { loadAllExtensions } from "../../modes/components/extensions/state-manager";
64
65
  import { theme } from "../../modes/theme/theme";
65
- import { type PlanApprovalDetails, renameApprovedPlanFile, resolvePlanTitle } from "../../plan-mode/approved-plan";
66
+ import { type PlanApprovalDetails, resolveApprovedPlan } from "../../plan-mode/approved-plan";
66
67
  import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
67
68
  import { isSilentAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
68
69
  import {
@@ -1425,24 +1426,16 @@ export class AcpAgent implements Agent {
1425
1426
  if (!state?.enabled) {
1426
1427
  throw new ToolError("Plan mode is not active.");
1427
1428
  }
1428
- const planFilePath = state.planFilePath;
1429
- const planContent = await this.#readAcpPlanFile(session, planFilePath);
1430
- if (planContent === null) {
1431
- throw new ToolError(
1432
- `Plan file not found at ${planFilePath}. Write the finalized plan to ${planFilePath} before requesting approval.`,
1433
- );
1434
- }
1435
- const normalized = resolvePlanTitle({
1429
+ const { planFilePath, planContent, title } = await resolveApprovedPlan({
1436
1430
  suppliedTitle: extra?.title,
1437
- planContent,
1438
- planFilePath,
1431
+ statePlanFilePath: state.planFilePath,
1432
+ readPlan: url => this.#readAcpPlanFile(session, url),
1433
+ listPlanFiles: () => this.#listAcpLocalPlanFiles(session),
1439
1434
  });
1440
- const finalPlanFilePath = `local://${normalized.fileName}`;
1441
- const approved = await this.#requestAcpPlanApprovalChoice(session.sessionId, normalized.title, planContent);
1435
+ const approved = await this.#requestAcpPlanApprovalChoice(session.sessionId, title, planContent);
1442
1436
  const details: PlanApprovalDetails = {
1443
1437
  planFilePath,
1444
- finalPlanFilePath,
1445
- title: normalized.title,
1438
+ title,
1446
1439
  planExists: true,
1447
1440
  };
1448
1441
  if (!approved) {
@@ -1458,16 +1451,10 @@ export class AcpAgent implements Agent {
1458
1451
  details,
1459
1452
  };
1460
1453
  }
1461
- // Approved. Rename plan to its titled filename, set the plan
1462
- // reference so the next turn injects the plan content as
1463
- // context, then exit plan mode so the agent regains full tools.
1464
- await renameApprovedPlanFile({
1465
- planFilePath,
1466
- finalPlanFilePath,
1467
- getArtifactsDir: () => session.sessionManager.getArtifactsDir(),
1468
- getSessionId: () => session.sessionManager.getSessionId(),
1469
- });
1470
- session.setPlanReferencePath(finalPlanFilePath);
1454
+ // Approved. Set the plan reference so the next turn injects the plan
1455
+ // content as context (the file keeps its agent-chosen name no
1456
+ // rename), then exit plan mode so the agent regains full tools.
1457
+ session.setPlanReferencePath(planFilePath);
1471
1458
  session.setStandingResolveHandler?.(null);
1472
1459
  session.setPlanModeState(undefined);
1473
1460
  try {
@@ -1486,7 +1473,7 @@ export class AcpAgent implements Agent {
1486
1473
  content: [
1487
1474
  {
1488
1475
  type: "text" as const,
1489
- text: `Plan approved at ${finalPlanFilePath}. Plan mode exited; proceed with the implementation.`,
1476
+ text: `Plan approved at ${planFilePath}. Plan mode exited; proceed with the implementation.`,
1490
1477
  },
1491
1478
  ],
1492
1479
  details,
@@ -1518,6 +1505,26 @@ export class AcpAgent implements Agent {
1518
1505
  }
1519
1506
  }
1520
1507
 
1508
+ /** `local://` URLs of plan files in the session-local root, newest first —
1509
+ * the `resolveApprovedPlan` fallback for a dropped `extra.title`. */
1510
+ async #listAcpLocalPlanFiles(session: AgentSession): Promise<string[]> {
1511
+ const localRoot = this.#resolveAcpPlanFilePath(session, "local://");
1512
+ try {
1513
+ const entries = await fs.readdir(localRoot, { withFileTypes: true });
1514
+ const plans = await Promise.all(
1515
+ entries
1516
+ .filter(entry => entry.isFile() && /plan\.md$/i.test(entry.name))
1517
+ .map(async entry => {
1518
+ const stat = await fs.stat(path.join(localRoot, entry.name)).catch(() => null);
1519
+ return { url: `local://${entry.name}`, mtime: stat?.mtimeMs ?? 0 };
1520
+ }),
1521
+ );
1522
+ return plans.sort((a, b) => b.mtime - a.mtime).map(plan => plan.url);
1523
+ } catch {
1524
+ return [];
1525
+ }
1526
+ }
1527
+
1521
1528
  /**
1522
1529
  * Ask the ACP client to confirm plan approval. Returns `true` only on an
1523
1530
  * explicit `APPROVE_OPTION` selection. Refine, dismissal (`undefined`), or
@@ -4,7 +4,7 @@ import { formatNumber } from "@oh-my-pi/pi-utils";
4
4
  import { settings } from "../../config/settings";
5
5
  import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
6
6
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
7
- import { isSilentAbort } from "../../session/messages";
7
+ import { isSilentAbort, resolveAbortLabel } from "../../session/messages";
8
8
  import { resolveImageOptions } from "../../tools/render-utils";
9
9
 
10
10
  /**
@@ -208,10 +208,6 @@ export class AssistantMessageComponent extends Container {
208
208
  c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
209
209
  );
210
210
 
211
- if (hasVisibleContent) {
212
- this.#contentContainer.addChild(new Spacer(1));
213
- }
214
-
215
211
  // Render content in order
216
212
  let thinkingIndex = 0;
217
213
  for (let i = 0; i < message.content.length; i++) {
@@ -257,10 +253,7 @@ export class AssistantMessageComponent extends Container {
257
253
  const hasToolCalls = message.content.some(c => c.type === "toolCall");
258
254
  if (!hasToolCalls) {
259
255
  if (message.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
260
- const abortMessage =
261
- message.errorMessage && message.errorMessage !== "Request was aborted"
262
- ? message.errorMessage
263
- : "Operation aborted";
256
+ const abortMessage = resolveAbortLabel(message.errorMessage);
264
257
  if (hasVisibleContent) {
265
258
  this.#contentContainer.addChild(new Spacer(1));
266
259
  } else {
@@ -0,0 +1,111 @@
1
+ import { Container } from "@oh-my-pi/pi-tui";
2
+
3
+ /**
4
+ * Capabilities a mounted {@link ChatBlock} may use against its host transcript.
5
+ * Kept minimal so blocks never reach into the full TUI/InteractiveMode surface.
6
+ */
7
+ export interface ChatBlockHost {
8
+ /** Schedule a repaint of the transcript. */
9
+ requestRender(): void;
10
+ }
11
+
12
+ /**
13
+ * Lifecycle-aware transcript block — the "return a block, let the host mount it"
14
+ * primitive, modelled on React/Svelte component lifecycles.
15
+ *
16
+ * Producers build and return a `ChatBlock` instead of poking `chatContainer` and
17
+ * `ui.requestRender()` directly. The host (`ctx.present`) appends it and calls
18
+ * {@link mount}, which runs {@link onMount}; effects started there register
19
+ * teardown via {@link onCleanup}. The block repaints through {@link requestRender}
20
+ * — never touching the TUI — and tears down exactly once on {@link finish}
21
+ * (self-complete: stop the animation, keep the final frame in the transcript) or
22
+ * {@link dispose} (host discards it, e.g. a transcript reset).
23
+ *
24
+ * While mounted and unfinished a block reports `isTranscriptBlockFinalized() ===
25
+ * false` so {@link "../components/transcript-container".TranscriptContainer}
26
+ * keeps it in the live, repaintable region on ED3-risk terminals; after
27
+ * `finish()`/`dispose()` it reports `true` and freezes at its final content.
28
+ */
29
+ export abstract class ChatBlock extends Container {
30
+ #host: ChatBlockHost | undefined;
31
+ #cleanups: Array<() => void> = [];
32
+ #active = false;
33
+ #disposed = false;
34
+
35
+ /**
36
+ * Run setup after the block is in the transcript: start timers/subscriptions
37
+ * and register their teardown with {@link onCleanup}. Default: no-op (a block
38
+ * whose content is fixed at construction needs no mount work).
39
+ */
40
+ protected onMount(): void {}
41
+
42
+ /**
43
+ * Register a teardown to run on {@link finish}/{@link dispose}, à la a
44
+ * `useEffect` cleanup. If the block is already disposed the cleanup runs
45
+ * immediately so callers never leak.
46
+ */
47
+ protected onCleanup(cleanup: () => void): void {
48
+ if (this.#disposed) {
49
+ cleanup();
50
+ return;
51
+ }
52
+ this.#cleanups.push(cleanup);
53
+ }
54
+
55
+ /** Ask the host to repaint. No-op before mount or after dispose. */
56
+ protected requestRender(): void {
57
+ this.#host?.requestRender();
58
+ }
59
+
60
+ /** True between {@link mount} and {@link finish}/{@link dispose}. */
61
+ protected get active(): boolean {
62
+ return this.#active;
63
+ }
64
+
65
+ /**
66
+ * Host-only: attach the host and run {@link onMount}. Idempotent — a second
67
+ * call (e.g. a transcript rebuild that re-presents the same instance) is a
68
+ * no-op.
69
+ */
70
+ mount(host: ChatBlockHost): void {
71
+ if (this.#host || this.#disposed) return;
72
+ this.#host = host;
73
+ this.#active = true;
74
+ this.onMount();
75
+ }
76
+
77
+ /**
78
+ * Self-complete: stop ongoing effects and freeze the block at its current
79
+ * content, leaving it rendered in the transcript. Use when the operation the
80
+ * block represents finishes (connection resolved, download done).
81
+ */
82
+ finish(): void {
83
+ if (!this.#active) return;
84
+ this.#active = false;
85
+ this.#runCleanups();
86
+ this.requestRender();
87
+ }
88
+
89
+ /**
90
+ * Host-only teardown: release everything and propagate to children. Called
91
+ * when the host permanently discards the block (transcript reset). Idempotent.
92
+ */
93
+ override dispose(): void {
94
+ if (this.#disposed) return;
95
+ this.#disposed = true;
96
+ this.#active = false;
97
+ this.#runCleanups();
98
+ super.dispose();
99
+ this.#host = undefined;
100
+ }
101
+
102
+ /** Live blocks stay repaintable; finished/disposed ones may freeze. */
103
+ isTranscriptBlockFinalized(): boolean {
104
+ return !this.#active;
105
+ }
106
+
107
+ #runCleanups(): void {
108
+ const cleanups = this.#cleanups.splice(0);
109
+ for (const cleanup of cleanups) cleanup();
110
+ }
111
+ }
@@ -10,6 +10,7 @@ import {
10
10
  matchesSelectUp,
11
11
  } from "../utils/keybinding-matchers";
12
12
  import { keyHint, rawKeyHint } from "./keybinding-hints";
13
+ import { bottomBorder, divider, row, topBorder } from "./overlay-box";
13
14
 
14
15
  /** Minimum rows reserved for the tree even on short terminals. */
15
16
  const MIN_TREE_ROWS = 3;
@@ -32,50 +33,6 @@ interface FlatNode {
32
33
  ancestorHasNext: boolean[];
33
34
  }
34
35
 
35
- /** Pad or truncate a (possibly ANSI-styled) string to exactly `width` columns. */
36
- function fit(text: string, width: number): string {
37
- if (width <= 0) return "";
38
- const w = visibleWidth(text);
39
- if (w === width) return text;
40
- if (w < width) return text + padding(width - w);
41
- const cut = truncateToWidth(text, width);
42
- const cw = visibleWidth(cut);
43
- return cw < width ? cut + padding(width - cw) : cut;
44
- }
45
-
46
- function paint(s: string): string {
47
- return theme.fg("border", s);
48
- }
49
-
50
- function topBorder(width: number, title: string): string {
51
- const box = theme.boxSharp;
52
- const inner = Math.max(0, width - 2);
53
- if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight);
54
- const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
55
- const fillWidth = Math.max(0, inner - 1 - visibleWidth(shown));
56
- return (
57
- paint(box.topLeft + box.horizontal) +
58
- theme.bold(theme.fg("accent", shown)) +
59
- paint(box.horizontal.repeat(fillWidth) + box.topRight)
60
- );
61
- }
62
-
63
- function divider(width: number): string {
64
- const box = theme.boxSharp;
65
- return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft);
66
- }
67
-
68
- function bottomBorder(width: number): string {
69
- const box = theme.boxSharp;
70
- return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight);
71
- }
72
-
73
- /** Wrap pre-styled content in vertical borders with single-column insets. */
74
- function row(content: string, width: number): string {
75
- const box = theme.boxSharp;
76
- return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`;
77
- }
78
-
79
36
  /** Render one tree connector as exactly three cells (e.g. "├─ ", "└─ ", "|--"). */
80
37
  function connectorCells(symbol: string): string {
81
38
  const chars = Array.from(symbol);
@@ -47,6 +47,21 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
47
47
  "app.clipboard.copyPrompt": ["alt+shift+c"],
48
48
  };
49
49
 
50
+ const BRACKETED_PASTE_START = "\x1b[200~";
51
+ const BRACKETED_PASTE_END = "\x1b[201~";
52
+ const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
53
+
54
+ export function extractBracketedImagePastePath(data: string): string | undefined {
55
+ if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
56
+ const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
57
+ if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
58
+
59
+ const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
60
+ if (!pasted || /[\r\n]/.test(pasted)) return undefined;
61
+ if (!BRACKETED_IMAGE_PATH_REGEX.test(pasted)) return undefined;
62
+ return pasted;
63
+ }
64
+
50
65
  /**
51
66
  * Custom editor that handles configurable app-level shortcuts for coding-agent.
52
67
  */
@@ -82,6 +97,8 @@ export class CustomEditor extends Editor {
82
97
  onCopyPrompt?: () => void;
83
98
  /** Called when the configured image-paste shortcut is pressed. */
84
99
  onPasteImage?: () => Promise<boolean>;
100
+ /** Called when a bracketed paste contains exactly one image-file path. */
101
+ onPasteImagePath?: (path: string) => void;
85
102
  /** Called when the configured raw text-paste shortcut is pressed. */
86
103
  onPasteTextRaw?: () => void;
87
104
  /** Called when the configured dequeue shortcut is pressed. */
@@ -137,6 +154,12 @@ export class CustomEditor extends Editor {
137
154
  return;
138
155
  }
139
156
 
157
+ const pastedImagePath = extractBracketedImagePastePath(data);
158
+ if (pastedImagePath && this.onPasteImagePath) {
159
+ this.onPasteImagePath(pastedImagePath);
160
+ return;
161
+ }
162
+
140
163
  // Intercept configured image paste (async - fires and handles result)
141
164
  if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
142
165
  void this.onPasteImage();
@@ -1,5 +1,5 @@
1
1
  import type { Component } from "@oh-my-pi/pi-tui";
2
- import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
2
+ import { Box, Container } from "@oh-my-pi/pi-tui";
3
3
  import type { MessageRenderer } from "../../extensibility/extensions/types";
4
4
  import { theme } from "../../modes/theme/theme";
5
5
  import type { CustomMessage } from "../../session/messages";
@@ -20,8 +20,6 @@ export class CustomMessageComponent extends Container {
20
20
  ) {
21
21
  super();
22
22
 
23
- this.addChild(new Spacer(1));
24
-
25
23
  // Create box with custom background (used for default rendering)
26
24
  this.#box = new Box(1, 1, t => theme.bg("customMessageBg", t));
27
25
 
@@ -7,7 +7,7 @@
7
7
  * stay in their respective files.
8
8
  */
9
9
 
10
- import { type Component, Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
10
+ import { type Component, Container, Loader, Text, type TUI } from "@oh-my-pi/pi-tui";
11
11
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
12
12
  import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
13
13
  import { DynamicBorder } from "./dynamic-border";
@@ -31,7 +31,6 @@ export function buildExecutionFrame(
31
31
  ): { contentContainer: Container; loader: Loader } {
32
32
  const borderColor = (str: string) => theme.fg(colorKey, str);
33
33
 
34
- parent.addChild(new Spacer(1));
35
34
  parent.addChild(new DynamicBorder(borderColor));
36
35
 
37
36
  const contentContainer = new Container();
@@ -1,5 +1,5 @@
1
1
  import type { Component } from "@oh-my-pi/pi-tui";
2
- import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
2
+ import { Box, Container } from "@oh-my-pi/pi-tui";
3
3
  import type { HookMessageRenderer } from "../../extensibility/hooks/types";
4
4
  import { theme } from "../../modes/theme/theme";
5
5
  import type { HookMessage } from "../../session/messages";
@@ -23,8 +23,6 @@ export class HookMessageComponent extends Container {
23
23
  ) {
24
24
  super();
25
25
 
26
- this.addChild(new Spacer(1));
27
-
28
26
  // Create box with purple background (used for default rendering)
29
27
  this.#box = new Box(1, 1, t => theme.bg("customMessageBg", t));
30
28
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared box-drawing chrome for fullscreen overlays (the `/copy` picker, the
3
+ * plan-review overlay, …). Every helper paints with `theme.boxSharp` glyphs and
4
+ * the `border`/`accent` theme colors so all outlined overlays read identically.
5
+ */
6
+ import { padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
7
+ import { theme } from "../theme/theme";
8
+
9
+ /** Pad or truncate a (possibly ANSI-styled) string to exactly `width` columns. */
10
+ export function fit(text: string, width: number): string {
11
+ if (width <= 0) return "";
12
+ const w = visibleWidth(text);
13
+ if (w === width) return text;
14
+ if (w < width) return text + padding(width - w);
15
+ const cut = truncateToWidth(text, width);
16
+ const cw = visibleWidth(cut);
17
+ return cw < width ? cut + padding(width - cw) : cut;
18
+ }
19
+
20
+ function paint(s: string): string {
21
+ return theme.fg("border", s);
22
+ }
23
+
24
+ /** Top border with an optional accent-colored title inset into the rule. */
25
+ export function topBorder(width: number, title: string): string {
26
+ const box = theme.boxSharp;
27
+ const inner = Math.max(0, width - 2);
28
+ if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight);
29
+ const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
30
+ const fillWidth = Math.max(0, inner - 1 - visibleWidth(shown));
31
+ return (
32
+ paint(box.topLeft + box.horizontal) +
33
+ theme.bold(theme.fg("accent", shown)) +
34
+ paint(box.horizontal.repeat(fillWidth) + box.topRight)
35
+ );
36
+ }
37
+
38
+ /** A horizontal rule with left/right tees, splitting overlay sections. */
39
+ export function divider(width: number): string {
40
+ const box = theme.boxSharp;
41
+ return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft);
42
+ }
43
+
44
+ export function bottomBorder(width: number): string {
45
+ const box = theme.boxSharp;
46
+ return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight);
47
+ }
48
+
49
+ /** Wrap pre-styled content in vertical borders with single-column insets. */
50
+ export function row(content: string, width: number): string {
51
+ const box = theme.boxSharp;
52
+ return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`;
53
+ }
54
+
55
+ /**
56
+ * Column index (0-based) of the inner divider for a two-column layout whose
57
+ * sidebar content area is `sidebarWidth` columns wide. The layout is
58
+ * `│ sidebar │ body │` with a single-column inset on every side, so the divider
59
+ * vertical sits at `sidebarWidth + 3` and the body content area is
60
+ * {@link splitBodyWidth} columns.
61
+ */
62
+ function splitDividerCol(sidebarWidth: number): number {
63
+ return sidebarWidth + 3;
64
+ }
65
+
66
+ /** Body content width for a two-column overlay of total `width`. */
67
+ export function splitBodyWidth(width: number, sidebarWidth: number): number {
68
+ return Math.max(0, width - sidebarWidth - 7);
69
+ }
70
+
71
+ /** Top border carrying the title, split by a `┬` over the column divider. */
72
+ export function topBorderSplit(width: number, title: string, sidebarWidth: number): string {
73
+ const box = theme.boxSharp;
74
+ const dividerCol = splitDividerCol(sidebarWidth);
75
+ const leftLen = Math.max(0, dividerCol - 1);
76
+ const rightLen = Math.max(0, width - 2 - dividerCol);
77
+ let left: string;
78
+ if (!title) {
79
+ left = paint(box.topLeft + box.horizontal.repeat(leftLen));
80
+ } else {
81
+ const shown = truncateToWidth(` ${title} `, Math.max(0, leftLen - 1));
82
+ const fillWidth = Math.max(0, leftLen - 1 - visibleWidth(shown));
83
+ left =
84
+ paint(box.topLeft + box.horizontal) +
85
+ theme.bold(theme.fg("accent", shown)) +
86
+ paint(box.horizontal.repeat(fillWidth));
87
+ }
88
+ return left + paint(box.teeDown + box.horizontal.repeat(rightLen) + box.topRight);
89
+ }
90
+
91
+ /** Section rule that closes the sidebar column with a `┴` over the divider. */
92
+ export function dividerSplit(width: number, sidebarWidth: number): string {
93
+ const box = theme.boxSharp;
94
+ const dividerCol = splitDividerCol(sidebarWidth);
95
+ const leftLen = Math.max(0, dividerCol - 1);
96
+ const rightLen = Math.max(0, width - 2 - dividerCol);
97
+ return paint(
98
+ box.teeRight + box.horizontal.repeat(leftLen) + box.teeUp + box.horizontal.repeat(rightLen) + box.teeLeft,
99
+ );
100
+ }
101
+
102
+ /** A two-column content row: `│ sidebar │ body │`, each inset by one column. */
103
+ export function splitRow(sidebar: string, body: string, width: number, sidebarWidth: number): string {
104
+ const box = theme.boxSharp;
105
+ const bodyWidth = splitBodyWidth(width, sidebarWidth);
106
+ const bar = paint(box.vertical);
107
+ return `${bar} ${fit(sidebar, sidebarWidth)} ${bar} ${fit(body, bodyWidth)} ${bar}`;
108
+ }