@oh-my-pi/pi-coding-agent 17.1.7 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-be1f2t8h.md} +127 -0
  3. package/dist/cli.js +9554 -12379
  4. package/dist/template-8vdv6xb4.js +1671 -0
  5. package/dist/template-f8wx9vfn.css +1355 -0
  6. package/dist/template-qat058wr.html +55 -0
  7. package/dist/tool-views.generated-jdfmzwmn.js +35 -0
  8. package/dist/types/advisor/advise-tool.d.ts +0 -7
  9. package/dist/types/advisor/runtime.d.ts +8 -11
  10. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  11. package/dist/types/async/job-manager.d.ts +11 -0
  12. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  13. package/dist/types/cleanse/agent.d.ts +19 -0
  14. package/dist/types/cleanse/balance.d.ts +7 -0
  15. package/dist/types/cleanse/checkers.d.ts +13 -0
  16. package/dist/types/cleanse/index.d.ts +16 -0
  17. package/dist/types/cleanse/loop.d.ts +16 -0
  18. package/dist/types/cleanse/parsers.d.ts +13 -0
  19. package/dist/types/cleanse/progress.d.ts +14 -0
  20. package/dist/types/cleanse/types.d.ts +64 -0
  21. package/dist/types/cli/args.d.ts +2 -0
  22. package/dist/types/cli/session-picker.d.ts +14 -7
  23. package/dist/types/collab/guest.d.ts +21 -2
  24. package/dist/types/commands/cleanse.d.ts +23 -0
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/config/settings-schema.d.ts +63 -8
  27. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  28. package/dist/types/cursor.d.ts +193 -8
  29. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  30. package/dist/types/edit/hashline/diff.d.ts +9 -1
  31. package/dist/types/edit/index.d.ts +8 -1
  32. package/dist/types/edit/streaming.d.ts +8 -1
  33. package/dist/types/export/html/index.d.ts +2 -0
  34. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  35. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  36. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  37. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +2 -0
  38. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  39. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  40. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  41. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  42. package/dist/types/lsp/client.d.ts +2 -0
  43. package/dist/types/main.d.ts +2 -0
  44. package/dist/types/mcp/manager.d.ts +33 -4
  45. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  46. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  47. package/dist/types/modes/components/session-selector.d.ts +6 -0
  48. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  50. package/dist/types/modes/components/usage-row.d.ts +2 -0
  51. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  52. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  53. package/dist/types/modes/interactive-mode.d.ts +8 -5
  54. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  55. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  56. package/dist/types/modes/theme/theme.d.ts +12 -1
  57. package/dist/types/modes/types.d.ts +2 -1
  58. package/dist/types/sdk.d.ts +1 -0
  59. package/dist/types/session/agent-session-events.d.ts +2 -0
  60. package/dist/types/session/agent-session-types.d.ts +36 -1
  61. package/dist/types/session/agent-session.d.ts +4 -2
  62. package/dist/types/session/async-job-delivery.d.ts +8 -0
  63. package/dist/types/session/claude-session-store.d.ts +13 -0
  64. package/dist/types/session/codex-session-store.d.ts +14 -0
  65. package/dist/types/session/credential-pin.d.ts +58 -0
  66. package/dist/types/session/foreign-session-import.d.ts +15 -0
  67. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  68. package/dist/types/session/foreign-session-store.d.ts +23 -0
  69. package/dist/types/session/model-controls.d.ts +3 -3
  70. package/dist/types/session/session-advisors.d.ts +47 -2
  71. package/dist/types/session/session-entries.d.ts +19 -1
  72. package/dist/types/session/session-handoff.d.ts +4 -1
  73. package/dist/types/session/session-loader.d.ts +2 -0
  74. package/dist/types/session/session-maintenance.d.ts +1 -1
  75. package/dist/types/session/session-manager.d.ts +21 -0
  76. package/dist/types/session/session-tools.d.ts +25 -1
  77. package/dist/types/session/turn-recovery.d.ts +10 -4
  78. package/dist/types/system-prompt.d.ts +2 -1
  79. package/dist/types/task/executor.d.ts +11 -2
  80. package/dist/types/task/isolation-ownership.d.ts +34 -0
  81. package/dist/types/task/render.d.ts +7 -0
  82. package/dist/types/thinking.d.ts +18 -6
  83. package/dist/types/tools/acp-bridge.d.ts +28 -5
  84. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  85. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  86. package/dist/types/tools/grep.d.ts +17 -1
  87. package/dist/types/tools/hub/launch.d.ts +5 -1
  88. package/dist/types/tools/index.d.ts +4 -1
  89. package/dist/types/tools/path-utils.d.ts +19 -0
  90. package/dist/types/tools/read.d.ts +2 -0
  91. package/dist/types/tools/xdev.d.ts +2 -1
  92. package/dist/types/tui/output-block.d.ts +5 -5
  93. package/dist/types/utils/changelog.d.ts +19 -0
  94. package/dist/types/utils/git.d.ts +10 -0
  95. package/dist/types/utils/jj.d.ts +11 -4
  96. package/dist/types/web/search/types.d.ts +2 -2
  97. package/package.json +16 -12
  98. package/scripts/bundle-dist.ts +3 -1
  99. package/src/advisor/advise-tool.ts +0 -11
  100. package/src/advisor/runtime.ts +138 -24
  101. package/src/advisor/transcript-recorder.ts +56 -0
  102. package/src/async/job-manager.ts +30 -7
  103. package/src/auto-thinking/classifier.ts +48 -10
  104. package/src/cleanse/agent.ts +226 -0
  105. package/src/cleanse/balance.ts +79 -0
  106. package/src/cleanse/checkers.ts +996 -0
  107. package/src/cleanse/index.ts +190 -0
  108. package/src/cleanse/loop.ts +51 -0
  109. package/src/cleanse/parsers.ts +726 -0
  110. package/src/cleanse/progress.ts +50 -0
  111. package/src/cleanse/prompts/assignment.md +47 -0
  112. package/src/cleanse/types.ts +72 -0
  113. package/src/cli/args.ts +6 -0
  114. package/src/cli/flag-tables.ts +2 -0
  115. package/src/cli/gallery-fixtures/fs.ts +25 -0
  116. package/src/cli/session-picker.ts +34 -17
  117. package/src/cli/ttsr-cli.ts +19 -1
  118. package/src/cli/update-cli.ts +3 -0
  119. package/src/cli/usage-cli.ts +29 -4
  120. package/src/cli/worktree-cli.ts +28 -11
  121. package/src/cli-commands.ts +1 -0
  122. package/src/cli.ts +11 -1
  123. package/src/collab/guest.ts +37 -4
  124. package/src/collab/host.ts +1 -0
  125. package/src/commands/cleanse.ts +45 -0
  126. package/src/commands/launch.ts +6 -0
  127. package/src/config/model-registry.ts +103 -25
  128. package/src/config/settings-schema.ts +65 -6
  129. package/src/config/settings.ts +25 -0
  130. package/src/cursor-bridge-tools.ts +81 -0
  131. package/src/cursor.ts +463 -12
  132. package/src/discovery/claude-plugins.ts +144 -34
  133. package/src/edit/edit-clipboard.ts +23 -0
  134. package/src/edit/hashline/diff.ts +49 -10
  135. package/src/edit/hashline/execute.ts +38 -6
  136. package/src/edit/hashline/filesystem.ts +27 -3
  137. package/src/edit/index.ts +12 -2
  138. package/src/edit/renderer.ts +4 -4
  139. package/src/edit/streaming.ts +17 -2
  140. package/src/export/html/index.ts +17 -10
  141. package/src/export/html/template.js +1 -1
  142. package/src/extensibility/extensions/runner.ts +70 -0
  143. package/src/extensibility/extensions/types.ts +30 -0
  144. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  145. package/src/extensibility/legacy-pi-coding-agent-shim.ts +12 -13
  146. package/src/internal-urls/local-protocol.ts +3 -2
  147. package/src/internal-urls/mcp-protocol.ts +4 -1
  148. package/src/launch/broker.ts +14 -4
  149. package/src/launch/terminal-output-worker-client.ts +53 -0
  150. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  151. package/src/launch/terminal-output-worker.ts +23 -0
  152. package/src/lsp/client.ts +18 -3
  153. package/src/lsp/index.ts +4 -0
  154. package/src/lsp/render.ts +1 -1
  155. package/src/main.ts +127 -56
  156. package/src/mcp/manager.ts +120 -18
  157. package/src/modes/acp/acp-agent.ts +27 -18
  158. package/src/modes/acp/acp-event-mapper.ts +38 -4
  159. package/src/modes/components/chat-transcript-builder.ts +30 -11
  160. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  161. package/src/modes/components/read-tool-group.ts +159 -17
  162. package/src/modes/components/session-selector.ts +19 -3
  163. package/src/modes/components/settings-selector.ts +9 -1
  164. package/src/modes/components/status-line/component.ts +381 -54
  165. package/src/modes/components/tool-execution.ts +78 -31
  166. package/src/modes/components/usage-row.ts +10 -5
  167. package/src/modes/controllers/event-controller.ts +26 -4
  168. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  169. package/src/modes/controllers/selector-controller.ts +94 -36
  170. package/src/modes/controllers/tan-command-controller.ts +14 -0
  171. package/src/modes/interactive-mode.ts +67 -64
  172. package/src/modes/prompt-action-autocomplete.ts +5 -3
  173. package/src/modes/rpc/rpc-client.ts +18 -0
  174. package/src/modes/rpc/rpc-mode.ts +16 -1
  175. package/src/modes/rpc/rpc-types.ts +11 -0
  176. package/src/modes/theme/theme.ts +151 -6
  177. package/src/modes/types.ts +2 -1
  178. package/src/modes/utils/ui-helpers.ts +38 -21
  179. package/src/prompts/goals/guided-goal-interview.md +41 -6
  180. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  181. package/src/prompts/system/plan-mode-active.md +2 -2
  182. package/src/prompts/system/system-prompt.md +3 -0
  183. package/src/prompts/system/vibe-mode-active.md +4 -1
  184. package/src/prompts/system/xdev-mount-notice.md +1 -1
  185. package/src/prompts/tools/bash.md +15 -17
  186. package/src/prompts/tools/browser.md +1 -0
  187. package/src/prompts/tools/checkpoint.md +1 -1
  188. package/src/prompts/tools/glob.md +7 -6
  189. package/src/prompts/tools/goal.md +1 -1
  190. package/src/prompts/tools/grep.md +7 -6
  191. package/src/sdk.ts +178 -15
  192. package/src/session/agent-session-events.ts +1 -0
  193. package/src/session/agent-session-types.ts +40 -1
  194. package/src/session/agent-session.ts +330 -179
  195. package/src/session/async-job-delivery.ts +8 -0
  196. package/src/session/claude-session-store.ts +426 -0
  197. package/src/session/codex-session-store.ts +673 -0
  198. package/src/session/credential-pin.ts +93 -0
  199. package/src/session/foreign-session-import.ts +52 -0
  200. package/src/session/foreign-session-jsonl.ts +29 -0
  201. package/src/session/foreign-session-store.ts +26 -0
  202. package/src/session/model-controls.ts +15 -7
  203. package/src/session/session-advisors.ts +142 -23
  204. package/src/session/session-entries.ts +21 -1
  205. package/src/session/session-handoff.ts +20 -3
  206. package/src/session/session-history-format.ts +31 -6
  207. package/src/session/session-loader.ts +25 -9
  208. package/src/session/session-maintenance.ts +59 -11
  209. package/src/session/session-manager.ts +61 -0
  210. package/src/session/session-tools.ts +107 -5
  211. package/src/session/settings-stream-fn.ts +1 -0
  212. package/src/session/turn-recovery.ts +97 -51
  213. package/src/slash-commands/builtin-registry.ts +136 -5
  214. package/src/system-prompt.ts +3 -2
  215. package/src/task/executor.ts +17 -4
  216. package/src/task/isolation-ownership.ts +106 -0
  217. package/src/task/render.ts +20 -4
  218. package/src/task/worktree.ts +8 -0
  219. package/src/thinking.ts +29 -10
  220. package/src/tools/acp-bridge.ts +52 -8
  221. package/src/tools/ask.ts +3 -3
  222. package/src/tools/ast-edit.ts +9 -2
  223. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  224. package/src/tools/browser/tab-worker.ts +12 -35
  225. package/src/tools/browser.ts +7 -2
  226. package/src/tools/checkpoint.ts +0 -13
  227. package/src/tools/gh-renderer.ts +3 -3
  228. package/src/tools/grep.ts +64 -8
  229. package/src/tools/hub/launch.ts +10 -15
  230. package/src/tools/index.ts +38 -5
  231. package/src/tools/path-utils.ts +88 -0
  232. package/src/tools/read.ts +3 -0
  233. package/src/tools/write.ts +44 -3
  234. package/src/tools/xdev.ts +39 -14
  235. package/src/tui/code-cell.ts +4 -4
  236. package/src/tui/output-block.ts +25 -8
  237. package/src/utils/changelog.ts +144 -4
  238. package/src/utils/git.ts +61 -14
  239. package/src/utils/jj.ts +19 -13
  240. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  241. package/src/web/search/providers/codex.ts +34 -0
  242. package/src/web/search/render.ts +2 -2
  243. package/src/web/search/types.ts +1 -1
  244. package/dist/types/goals/guided-setup.d.ts +0 -30
  245. package/src/goals/guided-setup.ts +0 -171
  246. package/src/prompts/goals/guided-goal-system.md +0 -33
package/src/tools/grep.ts CHANGED
@@ -884,6 +884,22 @@ export interface GrepToolDetails {
884
884
 
885
885
  type SearchParams = typeof searchSchema.infer;
886
886
 
887
+ /**
888
+ * Construction-time overrides for callers that are not the model.
889
+ *
890
+ * The model-facing schema deliberately does not grow these: they exist for
891
+ * wire bridges (the Cursor `pi_grep` frame) whose protocol carries an explicit
892
+ * context width and total match cap, and which would otherwise have to drop
893
+ * them. Unset means "use the session settings / built-in caps" — the behavior
894
+ * every model-issued call keeps.
895
+ */
896
+ export interface GrepToolOptions {
897
+ /** Overrides `grep.contextBefore`/`grep.contextAfter` for every call on this instance. */
898
+ context?: number;
899
+ /** Caps total surfaced matches. Applied on top of the built-in per-file and file-window caps, never above them. */
900
+ totalMatchLimit?: number;
901
+ }
902
+
887
903
  export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails> {
888
904
  readonly name = "grep";
889
905
  readonly approval = (args: unknown): ToolTier => {
@@ -897,7 +913,17 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
897
913
  readonly parameters = searchSchema;
898
914
  readonly strict = true;
899
915
 
900
- constructor(private readonly session: ToolSession) {
916
+ readonly #contextOverride?: number;
917
+ readonly #totalMatchLimit?: number;
918
+
919
+ constructor(
920
+ private readonly session: ToolSession,
921
+ options?: GrepToolOptions,
922
+ ) {
923
+ const context = options?.context;
924
+ this.#contextOverride = context !== undefined ? Math.max(0, Math.floor(context)) : undefined;
925
+ const total = options?.totalMatchLimit;
926
+ this.#totalMatchLimit = total !== undefined ? Math.max(1, Math.floor(total)) : undefined;
901
927
  const displayMode = resolveFileDisplayMode(session);
902
928
  this.description = prompt.render(grepDescription, {
903
929
  IS_HL_MODE: displayMode.hashLines,
@@ -978,8 +1004,8 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
978
1004
  `or pass a UTF-8 text member.`,
979
1005
  );
980
1006
  }
981
- const normalizedContextBefore = this.session.settings.get("grep.contextBefore");
982
- const normalizedContextAfter = this.session.settings.get("grep.contextAfter");
1007
+ const normalizedContextBefore = this.#contextOverride ?? this.session.settings.get("grep.contextBefore");
1008
+ const normalizedContextAfter = this.#contextOverride ?? this.session.settings.get("grep.contextAfter");
983
1009
  const ignoreCase = !(caseSensitive ?? true);
984
1010
  const useGitignore = gitignore ?? true;
985
1011
  const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
@@ -1297,9 +1323,22 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1297
1323
  // Single-file scopes can't paginate — there is one file by definition.
1298
1324
  const canPaginate = isMultiScope;
1299
1325
  const skipFiles = canPaginate ? Math.min(normalizedSkip, totalFiles) : 0;
1300
- const windowFiles = canPaginate ? fileOrder.slice(skipFiles, skipFiles + DEFAULT_FILE_LIMIT) : fileOrder;
1301
- const fileLimitReached = canPaginate && totalFiles > skipFiles + DEFAULT_FILE_LIMIT;
1326
+ // A caller with a total match cap is not paginating: the cap bounds the
1327
+ // output, and the only consumer that sets one (`pi_grep`) has no `skip`
1328
+ // field to follow a "use skip=N" suggestion with. Windowing it to the
1329
+ // first 20 files would silently return fewer matches than it asked for
1330
+ // while reporting the cap as unreached.
1331
+ //
1332
+ // The window is cap+1 files, not cap: with one match per file, a cap
1333
+ // of N over exactly N files is complete, while over N+1 files it is
1334
+ // clipped — and only reading that extra file distinguishes the two.
1335
+ // The cap below then does the trimming and records that it bit, so
1336
+ // `match_limit_reached` reaches the frame set.
1337
+ const fileWindow = this.#totalMatchLimit !== undefined ? this.#totalMatchLimit + 1 : DEFAULT_FILE_LIMIT;
1338
+ const windowFiles = canPaginate ? fileOrder.slice(skipFiles, skipFiles + fileWindow) : fileOrder;
1339
+ const fileLimitReached = canPaginate && totalFiles > skipFiles + fileWindow;
1302
1340
  const selectedMatches: GrepMatch[] = [];
1341
+ let totalMatchLimitReached = false;
1303
1342
  if (windowFiles.length > 0) {
1304
1343
  const lists = windowFiles.map(file => matchesByPath.get(file) ?? []);
1305
1344
  const cursors = new Array<number>(lists.length).fill(0);
@@ -1313,6 +1352,14 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1313
1352
  }
1314
1353
  }
1315
1354
  }
1355
+ // Round-robin above interleaves files for diversity, so the cap is
1356
+ // applied after selection rather than as a per-list bound: trimming
1357
+ // mid-rotation would silently favour whichever files sort first.
1358
+ const cap = this.#totalMatchLimit;
1359
+ if (cap !== undefined && selectedMatches.length > cap) {
1360
+ selectedMatches.length = cap;
1361
+ totalMatchLimitReached = true;
1362
+ }
1316
1363
  }
1317
1364
  const nextSkip = skipFiles + windowFiles.length;
1318
1365
  const limitMessage = fileLimitReached
@@ -1503,7 +1550,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1503
1550
  const output = truncation.content;
1504
1551
  const displayText = displayLines.join("\n");
1505
1552
  const truncated = Boolean(
1506
- fileLimitReached || perFileLimitReached || result.limitReached || truncation.truncated || linesTruncated,
1553
+ fileLimitReached ||
1554
+ perFileLimitReached ||
1555
+ totalMatchLimitReached ||
1556
+ result.limitReached ||
1557
+ truncation.truncated ||
1558
+ linesTruncated,
1507
1559
  );
1508
1560
  const details: GrepToolDetails = {
1509
1561
  scopePath,
@@ -1517,8 +1569,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1517
1569
  count: fileMatchCounts.get(path) ?? 0,
1518
1570
  })),
1519
1571
  truncated,
1520
- fileLimitReached: fileLimitReached ? DEFAULT_FILE_LIMIT : undefined,
1521
- perFileLimitReached: perFileLimitReached ? perFileMatchCap : undefined,
1572
+ fileLimitReached: fileLimitReached ? fileWindow : undefined,
1573
+ perFileLimitReached: totalMatchLimitReached
1574
+ ? this.#totalMatchLimit
1575
+ : perFileLimitReached
1576
+ ? perFileMatchCap
1577
+ : undefined,
1522
1578
  displayContent: displayText,
1523
1579
  missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
1524
1580
  };
@@ -12,7 +12,7 @@ import { sanitizeText } from "@oh-my-pi/pi-utils";
12
12
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
13
13
  import { daemonClientForProject } from "../../launch/client";
14
14
  import type { DaemonOperation, DaemonRpcResult, DaemonSnapshot, DaemonSpec, DaemonState } from "../../launch/protocol";
15
- import type { TerminalOutputOptions } from "../../launch/terminal-output";
15
+ import { renderTerminalOutputIsolated } from "../../launch/terminal-output-worker-client";
16
16
  import type { Theme, ThemeColor } from "../../modes/theme/theme";
17
17
  import { framedBlock, outputBlockContentWidth, renderStatusLine } from "../../tui";
18
18
  import type { ToolSession } from "..";
@@ -271,15 +271,14 @@ function toolContent(result: DaemonRpcResult, params: LaunchParams): string {
271
271
  }
272
272
  }
273
273
 
274
- interface TerminalOutputRuntime {
275
- renderTerminalOutput(output: string, options: TerminalOutputOptions): Promise<string[] | undefined>;
276
- }
277
-
278
- async function renderLegacyTerminalOutput(terminalText: string, params: LaunchParams): Promise<string[] | undefined> {
279
- // `require` keeps xterm out of normal main/Hub startup while retaining display
280
- // compatibility with a legacy broker that was already running during upgrade.
281
- const runtime = require("../../launch/terminal-output") as TerminalOutputRuntime;
282
- return runtime.renderTerminalOutput(terminalText, {
274
+ /** Resolve display rows while keeping legacy raw replay outside the client process. */
275
+ export async function renderLaunchLogTerminalRows(
276
+ result: Extract<DaemonRpcResult, { op: "logs" }>,
277
+ params: Pick<LaunchParams, "head" | "lines">,
278
+ ): Promise<string[] | undefined> {
279
+ if (result.terminalRows !== undefined) return result.terminalRows;
280
+ if (result.terminalText === undefined) return undefined;
281
+ return renderTerminalOutputIsolated(result.terminalText, {
283
282
  head: params.head ?? false,
284
283
  maxRows: Math.min(1_000, Math.floor(params.lines ?? 100)),
285
284
  });
@@ -297,11 +296,7 @@ async function toolDetails(result: DaemonRpcResult, params: LaunchParams): Promi
297
296
  cursor: result.cursor,
298
297
  timedOut: result.timedOut,
299
298
  state: result.state,
300
- terminalRows:
301
- result.terminalRows ??
302
- (result.terminalText === undefined
303
- ? undefined
304
- : await renderLegacyTerminalOutput(result.terminalText, params)),
299
+ terminalRows: await renderLaunchLogTerminalRows(result, params).catch(() => undefined),
305
300
  };
306
301
  case "wait":
307
302
  return { op: "wait", daemon: result.daemon, timedOut: result.timedOut, matched: result.matched };
@@ -1,4 +1,4 @@
1
- import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
1
+ import type { Clipboard, InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import type { FetchImpl, ImageContent, Model, ServiceTierByFamily, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
@@ -351,6 +351,10 @@ export interface ToolSession {
351
351
  * file changed out-of-band. Lazily initialized by `getFileSnapshotStore`. */
352
352
  fileSnapshotStore?: InMemorySnapshotStore;
353
353
 
354
+ /** Per-session `CUT`/`PASTE` clipboard register shared across edit
355
+ * calls. Lazily initialized by `getEditClipboard`. */
356
+ editClipboard?: Clipboard;
357
+
354
358
  /** Per-session log of unresolved git merge conflict regions surfaced by
355
359
  * `read`. Each entry gets a stable id N referenced by `write conflict://N`
356
360
  * to splice the recorded region with replacement content. Lazily initialized
@@ -502,6 +506,18 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
502
506
  // unreachable, in which case eval dispatches exclusively to the others.
503
507
  const allowEval = effectivePythonAllowed || allowJs || effectiveRubyAllowed || effectiveJuliaAllowed;
504
508
 
509
+ // Checkpoint and rewind are a pair: listing one without the other strands
510
+ // the agent (it can checkpoint but not rewind, or vice versa). Auto-include
511
+ // the sister tool so a one-sided frontmatter `tools:` entry still works.
512
+ // Unlike the AST/auto-learn convenience auto-includes below, this is a
513
+ // safety pairing — it applies to restricted sessions too.
514
+ if (requestedTools && session.settings.get("checkpoint.enabled")) {
515
+ if (requestedTools.includes("checkpoint") && !requestedTools.includes("rewind")) {
516
+ requestedTools.push("rewind");
517
+ } else if (requestedTools.includes("rewind") && !requestedTools.includes("checkpoint")) {
518
+ requestedTools.push("checkpoint");
519
+ }
520
+ }
505
521
  // Auto-include AST counterparts when their text-based sibling is present.
506
522
  // Restricted callers own the active list and must not have it widened.
507
523
  if (requestedTools && !restrictToolNames) {
@@ -548,7 +564,16 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
548
564
  }
549
565
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
550
566
  const isToolAllowed = (name: string) => {
551
- if (name === "goal") return goalEnabled && goalModeActive;
567
+ // Never in the default set. Explicitly activatable while goal.enabled and
568
+ // no goal record exists yet — /guided-goal enables it so the agent can
569
+ // finish the interview with `goal create`, which turns goal mode on. Once
570
+ // a goal record exists, only an enabled goal keeps the tool: a completed
571
+ // (exiting) or paused goal must stop advertising it on the next rebuild.
572
+ if (name === "goal") {
573
+ if (!goalEnabled || restrictToolNames) return false;
574
+ const goalState = session.getGoalModeState?.();
575
+ return goalState === undefined || goalState.enabled === true || goalState.goal.status === "dropped";
576
+ }
552
577
  if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled");
553
578
  if (name === "bash") return session.settings.get("bash.enabled");
554
579
  if (name === "eval") return allowEval;
@@ -565,7 +590,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
565
590
  if (name === "ask") return session.settings.get("ask.enabled");
566
591
  if (name === "browser") return session.settings.get("browser.enabled");
567
592
  if (name === "computer") return session.settings.get("computer.enabled");
568
- if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
593
+ if (name === "checkpoint" || name === "rewind")
594
+ return (
595
+ session.settings.get("checkpoint.enabled") &&
596
+ ((session.taskDepth ?? 0) === 0 || requestedTools !== undefined)
597
+ );
569
598
  if (name === "hub") {
570
599
  return (
571
600
  !restrictToolNames && session.enableIrc !== false && isIrcEnabled(session.settings, session.taskDepth ?? 0)
@@ -575,11 +604,15 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
575
604
  return ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "");
576
605
  }
577
606
  if (name === "memory_edit") return session.settings.get("memory.backend") === "mnemopi";
578
- if (name === "manage_skill") return session.settings.get("autolearn.enabled") && (session.taskDepth ?? 0) === 0;
607
+ if (name === "manage_skill")
608
+ return (
609
+ session.settings.get("autolearn.enabled") &&
610
+ ((session.taskDepth ?? 0) === 0 || requestedTools !== undefined)
611
+ );
579
612
  if (name === "learn") {
580
613
  return (
581
614
  session.settings.get("autolearn.enabled") &&
582
- (session.taskDepth ?? 0) === 0 &&
615
+ ((session.taskDepth ?? 0) === 0 || requestedTools !== undefined) &&
583
616
  ["hindsight", "mnemopi", "local"].includes(session.settings.get("memory.backend") ?? "")
584
617
  );
585
618
  }
@@ -520,6 +520,94 @@ export function resolveToCwd(filePath: string, cwd: string): string {
520
520
  return path.resolve(cwd, expanded);
521
521
  }
522
522
 
523
+ /**
524
+ * Resolve a path that MUST stay inside `cwd`, or `null` when it would escape.
525
+ *
526
+ * {@link resolveToCwd} deliberately honors absolute paths, `~`, and `..` —
527
+ * correct for a path a user typed, wrong for one a remote peer supplied.
528
+ * Callers handling untrusted input (Cursor's `download_path`) use this instead:
529
+ * only a non-empty relative path landing under the live cwd is accepted, so
530
+ * neither `/etc/passwd` nor `../../escape` can be written through.
531
+ *
532
+ * The lexical check alone is not containment: a symlink inside the workspace
533
+ * can point anywhere, so `out/config` under a `ws/out -> /elsewhere` link is
534
+ * relative, `..`-free, and still writes outside. Both the target and its
535
+ * deepest existing ancestor are therefore realpath-resolved — the ancestor
536
+ * because a download names a file that does not exist yet, so the link in its
537
+ * path is the only thing that can be resolved before the write.
538
+ *
539
+ * The cwd itself is rejected: a download names a file, never the directory.
540
+ */
541
+ export function confineToWorkspace(filePath: string, cwd: string): string | null {
542
+ if (!filePath || path.isAbsolute(filePath)) return null;
543
+ // `~` expands to an absolute path, and an internal URL is not a filesystem
544
+ // target at all; neither is a relative workspace path.
545
+ if (filePath.startsWith("~") || isInternalUrlPath(filePath)) return null;
546
+ const root = path.resolve(cwd);
547
+ const resolved = path.resolve(root, filePath);
548
+ if (!isUnderRootLexical(resolved, root)) return null;
549
+
550
+ // A workspace reached through a link of its own is legitimate (/tmp on
551
+ // macOS), so the real root is the comparison basis. An unresolvable root is
552
+ // not a workspace to contain anything in.
553
+ const realRoot = tryRealpath(root);
554
+ if (!realRoot) return null;
555
+
556
+ // An existing target is authoritative: resolve it outright.
557
+ const realTarget = tryRealpath(resolved);
558
+ if (realTarget) return isUnderRootLexical(realTarget, realRoot) ? resolved : null;
559
+
560
+ // `realpath` also fails on a *dangling* link, and a write follows that link
561
+ // to wherever it points. Chasing the chain to decide would mean
562
+ // reimplementing symlink resolution (multi-hop, relative hops, loops, and
563
+ // a TOCTOU window against a link that can be re-pointed between the check
564
+ // and the write). A download names a file to create, so a path that is
565
+ // already an unresolvable link is refused outright — the one shape where
566
+ // "cannot tell where this lands" is the whole answer.
567
+ if (isSymlink(resolved)) return null;
568
+
569
+ // Otherwise walk up to the deepest ancestor that does exist and check that,
570
+ // then re-apply the segments below it. Those segments are `..`-free by the
571
+ // lexical check above, so they cannot climb back out.
572
+ let ancestor = path.dirname(resolved);
573
+ const tail: string[] = [path.basename(resolved)];
574
+ for (;;) {
575
+ const real = tryRealpath(ancestor);
576
+ if (real) {
577
+ return isUnderRootLexical(path.join(real, ...tail.reverse()), realRoot) ? resolved : null;
578
+ }
579
+ const parent = path.dirname(ancestor);
580
+ // Ran past the root without finding anything real: the workspace itself
581
+ // resolved above, so this cannot happen unless it vanished mid-check.
582
+ if (parent === ancestor || !isUnderRootLexical(ancestor, root)) return null;
583
+ tail.push(path.basename(ancestor));
584
+ ancestor = parent;
585
+ }
586
+ }
587
+
588
+ /** Whether `target` is a strict descendant of `root`, ignoring symlinks. */
589
+ function isUnderRootLexical(target: string, root: string): boolean {
590
+ const relative = path.relative(root, target);
591
+ return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
592
+ }
593
+
594
+ function tryRealpath(target: string): string | null {
595
+ try {
596
+ return fs.realpathSync.native(target);
597
+ } catch {
598
+ return null;
599
+ }
600
+ }
601
+
602
+ /** Whether the path itself is a symlink, without following it. */
603
+ function isSymlink(target: string): boolean {
604
+ try {
605
+ return fs.lstatSync(target).isSymbolicLink();
606
+ } catch {
607
+ return false;
608
+ }
609
+ }
610
+
523
611
  export function formatPathRelativeToCwd(
524
612
  filePath: string,
525
613
  cwd: string,
package/src/tools/read.ts CHANGED
@@ -735,6 +735,8 @@ export interface ReadToolDetails {
735
735
  method?: string;
736
736
  notes?: string[];
737
737
  meta?: OutputMeta;
738
+ /** Full on-disk byte size recorded before applying a file range. */
739
+ fileSize?: number;
738
740
  /** Raw text + start line for user-visible TUI rendering, set when content is text-like.
739
741
  * Mirrors the same lines the model receives but without hashline/line-number prefixes,
740
742
  * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
@@ -2959,6 +2961,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2959
2961
  }
2960
2962
  }
2961
2963
 
2964
+ details.fileSize = fileSize;
2962
2965
  this.#markMarkdownContentType(details, absolutePath);
2963
2966
  if (suffixResolution) {
2964
2967
  details.suffixResolution = suffixResolution;
@@ -160,7 +160,42 @@ function throwReadSelectorMisfire(target: string, sel: string): never {
160
160
  );
161
161
  }
162
162
 
163
+ /**
164
+ * Recognize a semicolon-joined list of read-tool selectors mis-dispatched as a
165
+ * single write target — the multi-file read expression the scout emitted in
166
+ * issue #6809 (`a.txt:1-2;b/c.txt:3-4`). Every `;`-segment must be non-empty and
167
+ * carry its own read selector ({@link splitPathAndSel} peels a `:N-M`, `:raw`,
168
+ * or `:conflicts` tail). No real call targets such a list: `read` accepts one
169
+ * path, `write` writes one file. Unlike {@link readSelectorForEmptyWrite} this
170
+ * fires regardless of `content` — the non-empty-content escape hatch exists for
171
+ * a lone selector-shaped *filename*, never a `;`-list, and honoring it here
172
+ * silently creates a nested directory tree (`a.txt:1-2;b/`) in the workspace.
173
+ * The caller still probes the literal target first, so an existing POSIX file
174
+ * by that exact name stays writable (same escape as the single-selector guard).
175
+ */
176
+ function readSelectorListMisfire(target: string): number | undefined {
177
+ if (!target.includes(";")) return undefined;
178
+ const segments = target.split(";");
179
+ if (segments.length < 2) return undefined;
180
+ for (const segment of segments) {
181
+ const trimmed = segment.trim();
182
+ if (trimmed.length === 0 || splitPathAndSel(trimmed).sel === undefined) return undefined;
183
+ }
184
+ return segments.length;
185
+ }
186
+
187
+ function throwReadSelectorListMisfire(target: string, count: number): never {
188
+ throw new ToolError(
189
+ `write target '${target}' is a semicolon-joined list of ${count} read-tool selectors, not a filesystem path — refusing to create it. ` +
190
+ `write creates a single file; issue one read() per path to read these ranges (e.g. read({ path: "<one path>:<range>" })).`,
191
+ );
192
+ }
193
+
163
194
  async function assertNotReadSelectorMisfire(target: string, content: string, cwd: string): Promise<void> {
195
+ const listCount = readSelectorListMisfire(target);
196
+ if (listCount !== undefined && (await probeLiteralPathExists(target, cwd)) === "missing") {
197
+ throwReadSelectorListMisfire(target, listCount);
198
+ }
164
199
  const sel = readSelectorForEmptyWrite(target, content);
165
200
  if (sel === undefined) return;
166
201
  if ((await probeLiteralPathExists(target, cwd)) !== "missing") return;
@@ -1232,9 +1267,15 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
1232
1267
 
1233
1268
  // Try ACP bridge first for editor-visible filesystem paths. Internal
1234
1269
  // artifacts such as local:// plans are owned by OMP, not the editor.
1235
- if (await routeWriteThroughBridge(this.session, path, absolutePath, cleanContent, signal)) {
1236
- const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
1237
- const header = maybeWriteSnapshotHeader(this.session, absolutePath, cleanContent);
1270
+ const bridgeWrite = await routeWriteThroughBridge(this.session, path, absolutePath, cleanContent, signal);
1271
+ if (bridgeWrite) {
1272
+ // `write` always replaces the whole file, so (unlike hashline's
1273
+ // hunk-scoped diff) there's no size cost to keying the header/
1274
+ // executable-bit check on the verified post-write content —
1275
+ // use it so a drifted write (e.g. client format-on-save) still
1276
+ // hands back a tag that matches what's actually on disk.
1277
+ const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, bridgeWrite.text);
1278
+ const header = maybeWriteSnapshotHeader(this.session, absolutePath, bridgeWrite.text);
1238
1279
  const writeLine = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
1239
1280
  let resultText = header ? `${header}\n${writeLine}` : writeLine;
1240
1281
  if (stripped) {
package/src/tools/xdev.ts CHANGED
@@ -35,6 +35,7 @@ import { parseStreamingJson } from "@oh-my-pi/pi-utils";
35
35
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
36
36
  import { XD_URL_PREFIX } from "../internal-urls/xd-protocol";
37
37
  import type { Theme } from "../modes/theme/theme";
38
+ import { truncateHeadBytes } from "../session/streaming-output";
38
39
  import { renderDefaultToolExecution } from "./default-renderer";
39
40
  import type { Tool } from "./index";
40
41
  import { replaceTabs } from "./render-utils";
@@ -178,14 +179,34 @@ function toolSummary(inst: Tool): string {
178
179
  return firstLine?.trim() ?? inst.label ?? inst.name;
179
180
  }
180
181
 
181
- function promptCatalogSummary(inst: Tool, maxLength?: number): string {
182
+ /** C0/C1 controls and Unicode line/paragraph separators; summaries must remain one line. */
183
+ const SUMMARY_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
184
+ const SUMMARY_ELLIPSIS = "…";
185
+ const SUMMARY_ELLIPSIS_BYTES = Buffer.byteLength(SUMMARY_ELLIPSIS, "utf-8");
186
+
187
+ /**
188
+ * Bound a catalog summary for prompt rendering. External summaries are
189
+ * third-party metadata inlined verbatim, so control characters are stripped
190
+ * first, then the result is bounded in UTF-8 BYTES rather than characters (a
191
+ * character bound is not a byte bound for multi-byte scripts). The cut lands
192
+ * on a code point boundary, so the prompt never carries a partial code point.
193
+ */
194
+ function sanitizeCatalogSummary(summary: string, maxBytes?: number): string {
195
+ const cleaned = summary.replace(SUMMARY_CONTROL_CHARS, " ").trim();
196
+ if (maxBytes === undefined || Buffer.byteLength(cleaned, "utf-8") <= maxBytes) return cleaned;
197
+ if (maxBytes <= 0) return "";
198
+ if (maxBytes < SUMMARY_ELLIPSIS_BYTES) return truncateHeadBytes(cleaned, maxBytes).text;
199
+ const body = truncateHeadBytes(cleaned, maxBytes - SUMMARY_ELLIPSIS_BYTES).text.trimEnd();
200
+ return `${body}${SUMMARY_ELLIPSIS}`;
201
+ }
202
+
203
+ function promptCatalogSummary(inst: Tool, maxBytes?: number): string {
182
204
  const summary =
183
205
  toolSummary(inst)
184
206
  .split("\n")
185
207
  .find(line => line.trim().length > 0)
186
208
  ?.trim() ?? inst.name;
187
- if (maxLength === undefined || summary.length <= maxLength) return summary;
188
- return `${summary.slice(0, maxLength).trimEnd()}…`;
209
+ return sanitizeCatalogSummary(summary, maxBytes) || inst.name;
189
210
  }
190
211
 
191
212
  /** Compile the `tools.xdevInlineDevices` allowlist once per render, dropping
@@ -257,15 +278,19 @@ export function listXdevTools(state: XdevState): Tool[] {
257
278
  });
258
279
  }
259
280
 
260
- /** `{name, summary}` pairs for prompt templates and `/tools` display. */
261
- export function xdevEntries(state: XdevState): Array<{ name: string; summary: string }> {
262
- return listXdevTools(state).map(tool => ({
263
- name: tool.name,
264
- summary: promptCatalogSummary(
265
- tool,
266
- state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP,
267
- ),
268
- }));
281
+ /** `{name, summary, dynamic}` triples for prompt templates and `/tools` display. */
282
+ export function xdevEntries(state: XdevState): Array<{ name: string; summary: string; dynamic: boolean }> {
283
+ return listXdevTools(state).map(tool => {
284
+ // Built-ins are first-party; anything else carries third-party metadata. One
285
+ // boolean drives both the description cap and the flag callers present, so
286
+ // the two can never disagree about which summaries are untrusted.
287
+ const dynamic = !state.builtInNames.has(tool.name);
288
+ return {
289
+ name: tool.name,
290
+ summary: promptCatalogSummary(tool, dynamic ? XDEV_EXTERNAL_DESCRIPTION_CAP : undefined),
291
+ dynamic,
292
+ };
293
+ });
269
294
  }
270
295
 
271
296
  /** `read xd://` listing with one device per line. */
@@ -313,8 +338,8 @@ export function xdevDocsAll(
313
338
  [
314
339
  "## Additional devices (docs on demand)",
315
340
  ...overflow.map(tool => {
316
- const maxLength = state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP;
317
- return `- ${XD_URL_PREFIX}${tool.name} — ${promptCatalogSummary(tool, maxLength)}`;
341
+ const maxBytes = state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP;
342
+ return `- ${XD_URL_PREFIX}${tool.name} — ${promptCatalogSummary(tool, maxBytes)}`;
318
343
  }),
319
344
  "",
320
345
  `Read ${XD_URL_PREFIX}<tool> for full docs + JSON schema before first use.`,
@@ -10,7 +10,7 @@ import {
10
10
  formatStatusIcon,
11
11
  replaceTabs,
12
12
  } from "../tools/render-utils";
13
- import { renderOutputBlock } from "./output-block";
13
+ import { outputBlockContentWidth, renderOutputBlock } from "./output-block";
14
14
  import type { State } from "./types";
15
15
 
16
16
  export interface CodeCellOptions {
@@ -230,9 +230,9 @@ export function renderMarkdownCell(options: MarkdownCellOptions, theme: Theme):
230
230
  const { title, meta } = formatHeader(codeOptions, theme);
231
231
  const state = getState(options.status);
232
232
 
233
- // Markdown component manages its own wrapping at the inner content width.
234
- // `renderOutputBlock` adds a `│ ` prefix + `│` suffix → 3 visible columns.
235
- const innerWidth = Math.max(20, width - 3);
233
+ // Markdown component manages its own wrapping at the same inner width as
234
+ // `renderOutputBlock`, so collapsed row caps are applied after final wrapping.
235
+ const innerWidth = Math.max(20, outputBlockContentWidth(width));
236
236
  const allLines = content.trim() ? new Markdown(content, 0, 0, getMarkdownTheme()).render(innerWidth) : [];
237
237
  const maxContentLines = expanded ? allLines.length : Math.min(allLines.length, contentMaxLines);
238
238
  const contentLines = allLines.slice(0, maxContentLines);
@@ -17,6 +17,7 @@ export interface OutputBlockOptions {
17
17
  width: number;
18
18
  applyBg?: boolean;
19
19
  contentPaddingLeft?: number;
20
+ contentPaddingRight?: number;
20
21
  /** Override the state-derived border color. Used for muted "legacy" tool
21
22
  * frames that should not visually compete with framed-output tools. */
22
23
  borderColor?: ThemeColor;
@@ -48,13 +49,18 @@ function normalizeContentPaddingLeft(value: number | undefined): number {
48
49
 
49
50
  /**
50
51
  * Inner content width that {@link renderOutputBlock} wraps its body to, for a
51
- * given outer `width`: both vertical borders (1 cell each) plus the left
52
- * content padding. Renderers that size a tail window MUST budget visual rows
53
- * against this, not the outer width otherwise the block re-wraps their lines
54
- * into more rows than they counted and the box overflows its intended height.
52
+ * given outer `width`: both vertical borders plus symmetric content padding.
53
+ * An explicit left padding of zero keeps legacy flush blocks flush on both
54
+ * sides unless a right padding is provided separately.
55
55
  */
56
- export function outputBlockContentWidth(width: number, contentPaddingLeft?: number): number {
57
- return Math.max(1, width - 2 - normalizeContentPaddingLeft(contentPaddingLeft));
56
+ export function outputBlockContentWidth(
57
+ width: number,
58
+ contentPaddingLeft?: number,
59
+ contentPaddingRight?: number,
60
+ ): number {
61
+ const left = normalizeContentPaddingLeft(contentPaddingLeft);
62
+ const right = normalizeContentPaddingLeft(contentPaddingRight ?? left);
63
+ return Math.max(1, width - 2 - left - right);
58
64
  }
59
65
 
60
66
  export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
@@ -88,8 +94,13 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
88
94
  })();
89
95
 
90
96
  const contentPaddingLeft = normalizeContentPaddingLeft(options.contentPaddingLeft);
91
- const contentWidth = Math.max(0, lineWidth - visibleWidth(v) - contentPaddingLeft - visibleWidth(v));
97
+ const contentPaddingRight = normalizeContentPaddingLeft(options.contentPaddingRight ?? contentPaddingLeft);
98
+ const contentWidth = Math.max(
99
+ 0,
100
+ lineWidth - visibleWidth(v) - contentPaddingLeft - contentPaddingRight - visibleWidth(v),
101
+ );
92
102
  const contentLeftPadding = contentPaddingLeft > 0 ? padding(contentPaddingLeft) : "";
103
+ const contentRightPadding = contentPaddingRight > 0 ? padding(contentPaddingRight) : "";
93
104
 
94
105
  // ── Layout pass: collect row descriptors before emitting the bordered lines. ──
95
106
  const rows: BlockRow[] = [];
@@ -170,7 +181,8 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
170
181
  return `${border(leftGlyphs)}${border(fillGlyphs)}${border(rightGlyph)}`;
171
182
  };
172
183
 
173
- const renderContent = (inner: string): string => `${border(v)}${contentLeftPadding}${inner}${border(v)}`;
184
+ const renderContent = (inner: string): string =>
185
+ `${border(v)}${contentLeftPadding}${inner}${contentRightPadding}${border(v)}`;
174
186
 
175
187
  const lines: string[] = [];
176
188
  for (let r = 0; r < H; r++) {
@@ -215,6 +227,11 @@ export class CachedOutputBlock {
215
227
  const h = new Hasher();
216
228
  h.u32(options.width);
217
229
  h.u32(normalizeContentPaddingLeft(options.contentPaddingLeft));
230
+ h.u32(
231
+ normalizeContentPaddingLeft(
232
+ options.contentPaddingRight ?? normalizeContentPaddingLeft(options.contentPaddingLeft),
233
+ ),
234
+ );
218
235
  h.optional(options.header);
219
236
  h.optional(options.headerMeta);
220
237
  h.optional(options.state);