@oh-my-pi/pi-coding-agent 16.0.11 → 16.1.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 (66) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/cli.js +2872 -2908
  3. package/dist/types/config/settings-schema.d.ts +14 -4
  4. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +1 -0
  5. package/dist/types/modes/components/assistant-message.d.ts +8 -0
  6. package/dist/types/modes/components/cache-invalidation-marker.d.ts +34 -0
  7. package/dist/types/modes/components/compaction-summary-message.d.ts +14 -1
  8. package/dist/types/modes/components/index.d.ts +0 -1
  9. package/dist/types/modes/components/message-frame.d.ts +6 -4
  10. package/dist/types/modes/interactive-mode.d.ts +2 -1
  11. package/dist/types/modes/theme/theme.d.ts +7 -1
  12. package/dist/types/modes/types.d.ts +7 -1
  13. package/dist/types/sdk.d.ts +1 -1
  14. package/dist/types/session/agent-session.d.ts +20 -1
  15. package/dist/types/session/session-context.d.ts +7 -0
  16. package/dist/types/session/session-dump-format.d.ts +1 -0
  17. package/dist/types/session/tool-choice-queue.d.ts +14 -0
  18. package/dist/types/system-prompt.d.ts +3 -3
  19. package/dist/types/tools/index.d.ts +4 -0
  20. package/dist/types/tools/resolve.d.ts +15 -5
  21. package/package.json +12 -12
  22. package/src/config/settings-schema.ts +16 -4
  23. package/src/debug/log-viewer.ts +4 -4
  24. package/src/debug/raw-sse.ts +4 -4
  25. package/src/edit/renderer.ts +2 -2
  26. package/src/internal-urls/docs-index.generated.txt +1 -1
  27. package/src/lsp/render.ts +7 -7
  28. package/src/modes/components/__tests__/skill-message.test.ts +92 -0
  29. package/src/modes/components/agent-dashboard.ts +1 -1
  30. package/src/modes/components/assistant-message.ts +21 -0
  31. package/src/modes/components/cache-invalidation-marker.ts +84 -0
  32. package/src/modes/components/chat-transcript-builder.ts +16 -2
  33. package/src/modes/components/compaction-summary-message.ts +29 -1
  34. package/src/modes/components/custom-message.ts +4 -1
  35. package/src/modes/components/dynamic-border.ts +1 -1
  36. package/src/modes/components/extensions/extension-dashboard.ts +1 -1
  37. package/src/modes/components/extensions/inspector-panel.ts +5 -5
  38. package/src/modes/components/hook-selector.ts +2 -2
  39. package/src/modes/components/index.ts +0 -1
  40. package/src/modes/components/message-frame.ts +10 -6
  41. package/src/modes/components/model-selector.ts +2 -2
  42. package/src/modes/components/overlay-box.ts +10 -9
  43. package/src/modes/components/skill-message.ts +39 -19
  44. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  45. package/src/modes/components/welcome.ts +1 -1
  46. package/src/modes/controllers/event-controller.ts +14 -0
  47. package/src/modes/controllers/selector-controller.ts +7 -0
  48. package/src/modes/interactive-mode.ts +9 -1
  49. package/src/modes/theme/theme.ts +14 -0
  50. package/src/modes/types.ts +7 -1
  51. package/src/modes/utils/ui-helpers.ts +20 -2
  52. package/src/prompts/steering/user-interjection.md +3 -4
  53. package/src/sdk.ts +8 -6
  54. package/src/session/agent-session.ts +90 -13
  55. package/src/session/messages.ts +7 -9
  56. package/src/session/session-context.ts +54 -7
  57. package/src/session/session-dump-format.ts +3 -1
  58. package/src/session/snapcompact-inline.ts +2 -2
  59. package/src/session/tool-choice-queue.ts +59 -0
  60. package/src/system-prompt.ts +10 -9
  61. package/src/tools/bash-interactive.ts +4 -4
  62. package/src/tools/index.ts +4 -0
  63. package/src/tools/resolve.ts +66 -41
  64. package/src/tui/output-block.ts +9 -9
  65. package/dist/types/modes/components/branch-summary-message.d.ts +0 -13
  66. package/src/modes/components/branch-summary-message.ts +0 -46
@@ -204,16 +204,14 @@ function wrapSteeringUserMessage(message: UserMessage): UserMessage {
204
204
  }
205
205
 
206
206
  export function wrapSteeringForModel(messages: AgentMessage[]): AgentMessage[] {
207
- const last = messages[messages.length - 1];
208
- if (!isSteeringUserMessage(last)) return messages;
209
-
210
- let firstSteer = messages.length - 1;
211
- while (firstSteer > 0 && isSteeringUserMessage(messages[firstSteer - 1])) {
212
- firstSteer--;
213
- }
214
-
207
+ // Wrap EVERY steering message, not just a trailing run. The wire bytes of a
208
+ // steering message must be a pure function of the message itself, independent
209
+ // of its position in the array. When only the trailing steer was wrapped, the
210
+ // same persisted message was sent enveloped while it was the tail and raw once
211
+ // the assistant's reply buried it — rewriting already-cached prefix bytes and
212
+ // busting the provider prompt cache from that message onward on the next turn.
215
213
  let wrappedMessages: AgentMessage[] | undefined;
216
- for (let i = firstSteer; i < messages.length; i++) {
214
+ for (let i = 0; i < messages.length; i++) {
217
215
  const message = messages[i];
218
216
  if (!isSteeringUserMessage(message)) continue;
219
217
  const wrappedMessage = wrapSteeringUserMessage(message);
@@ -20,6 +20,13 @@ export interface SessionContext {
20
20
  mode: string;
21
21
  /** Mode-specific data from the last mode_change entry */
22
22
  modeData?: Record<string, unknown>;
23
+ /**
24
+ * Array parallel to messages, indicating which assistant turns should
25
+ * have their prompt-cache misses suppressed/explained (because a model,
26
+ * compaction, or plan-mode transition directly preceded them).
27
+ * Only populated in transcript mode.
28
+ */
29
+ cacheMissExplainedAt?: boolean[];
23
30
  }
24
31
 
25
32
  /** Lists session model strings to try when restoring, in fallback order. */
@@ -191,12 +198,45 @@ export function buildSessionContext(
191
198
  // 2. Emit kept messages (from firstKeptEntryId up to compaction)
192
199
  // 3. Emit messages after compaction
193
200
  const messages: AgentMessage[] = [];
201
+ const cacheMissExplainedAt: boolean[] = [];
202
+ let pendingReset = false;
203
+ let currentMode = "none";
204
+ let lastAssistantModel: string | undefined;
205
+
206
+ const handleEntryResetTracking = (entry: SessionEntry) => {
207
+ if (entry.type === "compaction") {
208
+ pendingReset = true;
209
+ } else if (entry.type === "model_change") {
210
+ pendingReset = true;
211
+ } else if (entry.type === "mode_change") {
212
+ const isPlanTransition = (entry.mode === "plan") !== (currentMode === "plan");
213
+ if (isPlanTransition) {
214
+ pendingReset = true;
215
+ }
216
+ currentMode = entry.mode;
217
+ }
218
+ };
219
+
220
+ const pushMessage = (msg: AgentMessage) => {
221
+ messages.push(msg);
222
+ if (!options?.transcript) return;
223
+ if (msg.role === "assistant") {
224
+ const currentModel = `${msg.provider}/${msg.model}`;
225
+ const modelChanged = lastAssistantModel !== undefined && lastAssistantModel !== currentModel;
226
+ lastAssistantModel = currentModel;
227
+ cacheMissExplainedAt.push(pendingReset || modelChanged);
228
+ pendingReset = false;
229
+ } else {
230
+ cacheMissExplainedAt.push(false);
231
+ }
232
+ };
194
233
 
195
234
  const appendMessage = (entry: SessionEntry) => {
235
+ handleEntryResetTracking(entry);
196
236
  if (entry.type === "message") {
197
- messages.push(entry.message);
237
+ pushMessage(entry.message);
198
238
  } else if (entry.type === "custom_message") {
199
- messages.push(
239
+ pushMessage(
200
240
  createCustomMessage(
201
241
  entry.customType,
202
242
  entry.content,
@@ -207,7 +247,7 @@ export function buildSessionContext(
207
247
  ),
208
248
  );
209
249
  } else if (entry.type === "branch_summary" && entry.summary) {
210
- messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
250
+ pushMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
211
251
  }
212
252
  };
213
253
 
@@ -217,16 +257,18 @@ export function buildSessionContext(
217
257
  // TUI) at the point it fired, with any snapcompact frames re-attached so
218
258
  // the component can report them.
219
259
  for (const entry of path) {
260
+ handleEntryResetTracking(entry);
220
261
  if (entry.type === "compaction") {
221
262
  const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData);
222
- messages.push(
263
+ pushMessage(
223
264
  createCompactionSummaryMessage(
224
265
  entry.summary,
225
266
  entry.tokensBefore,
226
267
  entry.timestamp,
227
268
  entry.shortSummary,
228
269
  undefined,
229
- snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
270
+ undefined,
271
+ snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined,
230
272
  ),
231
273
  );
232
274
  } else {
@@ -251,14 +293,15 @@ export function buildSessionContext(
251
293
  // Emit summary first; re-attach any archived snapcompact frames so the
252
294
  // model can keep reading the archived history after every context rebuild.
253
295
  const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
254
- messages.push(
296
+ pushMessage(
255
297
  createCompactionSummaryMessage(
256
298
  compaction.summary,
257
299
  compaction.tokensBefore,
258
300
  compaction.timestamp,
259
301
  compaction.shortSummary,
260
302
  providerPayload,
261
- snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
303
+ undefined,
304
+ snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined,
262
305
  ),
263
306
  );
264
307
 
@@ -333,6 +376,9 @@ export function buildSessionContext(
333
376
  );
334
377
  if (normalized.length === 0) {
335
378
  messages.splice(i, 1);
379
+ if (options?.transcript) {
380
+ cacheMissExplainedAt.splice(i, 1);
381
+ }
336
382
  } else {
337
383
  messages[i] = { ...message, content: normalized };
338
384
  }
@@ -340,6 +386,7 @@ export function buildSessionContext(
340
386
 
341
387
  return {
342
388
  messages,
389
+ cacheMissExplainedAt: options?.transcript ? cacheMissExplainedAt : undefined,
343
390
  thinkingLevel,
344
391
  serviceTier,
345
392
  models,
@@ -38,6 +38,7 @@ export interface FormatSessionDumpTextOptions {
38
38
  model?: Model | null;
39
39
  thinkingLevel?: ThinkingLevel | string | null;
40
40
  tools?: readonly SessionDumpToolInfo[];
41
+ inlineToolDescriptors?: boolean;
41
42
  }
42
43
 
43
44
  interface InventoryTool {
@@ -78,7 +79,8 @@ function renderDumpHeader(options: FormatSessionDumpTextOptions, inventoryTools:
78
79
  lines.push(`Thinking Level: ${options.thinkingLevel ?? ""}`);
79
80
  lines.push("\n");
80
81
 
81
- if (inventoryTools.length > 0) {
82
+ const hasSystemPromptToolInventory = options.inlineToolDescriptors === true;
83
+ if (inventoryTools.length > 0 && !hasSystemPromptToolInventory) {
82
84
  lines.push("## Available Tools\n");
83
85
  lines.push(renderToolInventory(inventoryTools, model?.id ?? ""));
84
86
  lines.push("\n");
@@ -46,8 +46,8 @@ export type SnapcompactSavingsSink = (
46
46
  // Per-provider image-count budgets live in @oh-my-pi/snapcompact
47
47
  // (`providerImageBudget`): snapcompact frames are 1568px (<2000px) so
48
48
  // dimension/size limits never bind; only COUNT does. Once the budget is
49
- // spent (e.g. OpenRouter's hard 8-image cap, already consumed by archive
50
- // frames), tool results ship verbatim as text.
49
+ // spent by already-attached archive/system-prompt images, tool results ship
50
+ // verbatim as text.
51
51
  const MAX_SYSTEM_PROMPT_FRAMES = 6;
52
52
  /** Tool results under this many tokens are never rasterized — the swap can't
53
53
  * save enough to justify trading crisp text for an image. */
@@ -65,6 +65,20 @@ interface InFlight {
65
65
  invoked: boolean;
66
66
  }
67
67
 
68
+ /**
69
+ * A non-forcing pending preview invoker. Registered by `queueResolveHandler`
70
+ * (resolve previews) so the `resolve` tool can dispatch to a staged action
71
+ * WITHOUT this queue forcing `tool_choice`. The agent-loop's
72
+ * SoftToolRequirement lifecycle (remind-then-escalate) owns any forcing.
73
+ */
74
+ interface PendingInvoker {
75
+ /** Unique id for this staged preview; never reused (never clobbered by label). */
76
+ id: string;
77
+ /** Source tool that staged the preview (e.g. "ast_edit"), for the reminder. */
78
+ sourceToolName: string;
79
+ onInvoked: (input: unknown) => Promise<unknown> | unknown;
80
+ }
81
+
68
82
  // ── Queue ───────────────────────────────────────────────────────────────────
69
83
 
70
84
  export class ToolChoiceQueue {
@@ -75,6 +89,12 @@ export class ToolChoiceQueue {
75
89
  * Consumers (e.g. todo reminder suppression) read via consumeLastServedLabel().
76
90
  */
77
91
  #lastResolvedLabel: string | undefined;
92
+ /**
93
+ * Non-forcing pending preview invokers, stacked by UNIQUE id. The `resolve`
94
+ * tool dispatches to the head; the agent-loop's soft-tool-requirement
95
+ * lifecycle drives resolution without this queue forcing `tool_choice`.
96
+ */
97
+ #pendingInvokers: PendingInvoker[] = [];
78
98
 
79
99
  // ── Push ──────────────────────────────────────────────────────────────
80
100
 
@@ -190,6 +210,44 @@ export class ToolChoiceQueue {
190
210
  };
191
211
  }
192
212
 
213
+ // ── Non-forcing pending invokers ──────────────────────────────────────
214
+ // Preview producers (queueResolveHandler) register here so `resolve` can
215
+ // dispatch to a staged action WITHOUT a forced tool_choice (no messages-cache
216
+ // bust). Stacked by UNIQUE id: a re-register replaces only the same id, so
217
+ // concurrent/sequential previews each survive and resolve independently.
218
+
219
+ /** Register (or replace by exact id) a non-forcing pending preview invoker. */
220
+ registerPendingInvoker(
221
+ id: string,
222
+ sourceToolName: string,
223
+ onInvoked: (input: unknown) => Promise<unknown> | unknown,
224
+ ): void {
225
+ this.removePendingInvoker(id);
226
+ this.#pendingInvokers.push({ id, sourceToolName, onInvoked });
227
+ }
228
+
229
+ /** Drop the pending invoker with this id (e.g. after it resolves). */
230
+ removePendingInvoker(id: string): void {
231
+ this.#pendingInvokers = this.#pendingInvokers.filter(p => p.id !== id);
232
+ }
233
+
234
+ /** True when at least one non-forcing pending preview is registered. */
235
+ get hasPendingInvoker(): boolean {
236
+ return this.#pendingInvokers.length > 0;
237
+ }
238
+
239
+ /** The head (most-recently registered) pending invoker's handler, for resolve dispatch. */
240
+ peekPendingInvoker(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
241
+ return this.#pendingInvokers.at(-1)?.onInvoked;
242
+ }
243
+
244
+ /** The head pending preview's stable id + source tool, for building the agent-level
245
+ * SoftToolRequirement (the id drives reminder re-injection when the head changes). */
246
+ peekPendingHead(): { id: string; sourceToolName: string } | undefined {
247
+ const head = this.#pendingInvokers.at(-1);
248
+ return head ? { id: head.id, sourceToolName: head.sourceToolName } : undefined;
249
+ }
250
+
193
251
  // ── Cleanup ───────────────────────────────────────────────────────────
194
252
 
195
253
  /** Remove all directives with the given label. Rejects in-flight if it matches. */
@@ -206,6 +264,7 @@ export class ToolChoiceQueue {
206
264
  this.reject("cleared");
207
265
  }
208
266
  this.#queue = [];
267
+ this.#pendingInvokers = [];
209
268
  this.#lastResolvedLabel = undefined;
210
269
  }
211
270
 
@@ -373,11 +373,11 @@ export interface BuildSystemPromptOptions {
373
373
  toolNames?: string[];
374
374
  /** Text to append to system prompt. */
375
375
  appendSystemPrompt?: string;
376
- /** Repeat full tool descriptions in system prompt. Default: false */
377
- repeatToolDescriptions?: boolean;
376
+ /** Inline full tool descriptors in the system prompt. Default: true */
377
+ inlineToolDescriptors?: boolean;
378
378
  /**
379
379
  * Whether provider-native tool calling is active (no owned/in-band syntax).
380
- * When true and `repeatToolDescriptions` is false, the inventory renders as a
380
+ * When true and `inlineToolDescriptors` is false, the inventory renders as a
381
381
  * compact tool-name list; otherwise it renders full `# Tool:` sections. Default: true
382
382
  */
383
383
  nativeTools?: boolean;
@@ -433,7 +433,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
433
433
  customPrompt,
434
434
  tools,
435
435
  appendSystemPrompt,
436
- repeatToolDescriptions = false,
436
+ inlineToolDescriptors: providedInlineToolDescriptors,
437
437
  nativeTools = true,
438
438
  skillsSettings,
439
439
  toolNames: providedToolNames,
@@ -454,6 +454,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
454
454
  model,
455
455
  personality = "default",
456
456
  } = options;
457
+ const inlineToolDescriptors = providedInlineToolDescriptors ?? true;
457
458
  const resolvedCwd = cwd ?? getProjectDir();
458
459
 
459
460
  const prepDefaults = {
@@ -599,10 +600,10 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
599
600
  examples: meta?.examples,
600
601
  };
601
602
  });
602
- // List mode shows a compact tool-name list; it only applies when descriptions
603
- // are not repeated AND native tool calling is active (the model already has the
604
- // schemas). Otherwise render full `# Tool:` sections.
605
- const toolListMode = !repeatToolDescriptions && nativeTools;
603
+ // List mode shows a compact tool-name list; it only applies when descriptors
604
+ // stay in provider-native tool schemas AND native tool calling is active.
605
+ // Otherwise render full `# Tool:` sections inline in the system prompt.
606
+ const toolListMode = !inlineToolDescriptors && nativeTools;
606
607
  const toolInventory = toolListMode ? "" : renderToolInventory(inventoryTools, model ?? "");
607
608
 
608
609
  // Filter skills for the rendered system prompt:
@@ -632,7 +633,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
632
633
  tools: toolNames,
633
634
  toolInfo,
634
635
  toolInventory,
635
- repeatToolDescriptions,
636
+ inlineToolDescriptors,
636
637
  toolListMode,
637
638
  toolRefs,
638
639
  environment,
@@ -274,16 +274,16 @@ class BashInteractiveOverlayComponent implements Component {
274
274
  : truncateToWidth(this.uiTheme.fg("dim", "session finished"), innerWidth);
275
275
  const visibleLines = this.#readViewport(innerWidth, maxContentRows);
276
276
  const content = visibleLines.length > 0 ? visibleLines : [padding(innerWidth)];
277
- const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxSharp.horizontal.repeat(innerWidth));
278
- const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxSharp.vertical);
277
+ const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxRound.horizontal.repeat(innerWidth));
278
+ const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxRound.vertical);
279
279
  const boxLine = (line: string) =>
280
280
  `${borderVertical}${line}${padding(Math.max(0, innerWidth - visibleWidth(line)))}${borderVertical}`;
281
281
  return [
282
- `${this.uiTheme.fg("border", this.uiTheme.boxSharp.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.topRight)}`,
282
+ `${this.uiTheme.fg("border", this.uiTheme.boxRound.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.topRight)}`,
283
283
  boxLine(header),
284
284
  ...content.map(boxLine),
285
285
  boxLine(footer),
286
- `${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomRight)}`,
286
+ `${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomRight)}`,
287
287
  ];
288
288
  }
289
289
 
@@ -312,6 +312,10 @@ export interface ToolSession {
312
312
  steer?(message: { customType: string; content: string; details?: unknown }): void;
313
313
  /** Peek the currently in-flight tool-choice queue directive's invocation handler. Used by the `resolve` tool to dispatch to the pending action. */
314
314
  peekQueueInvoker?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
315
+ /** Peek the most-recently registered non-forcing pending preview invoker. The `resolve`
316
+ * tool dispatches to it so a staged preview resolves WITHOUT forcing tool_choice — the
317
+ * agent-loop's SoftToolRequirement lifecycle owns reminder injection and escalation. */
318
+ peekPendingInvoker?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
315
319
  /** Peek the long-lived "standing" resolve handler registered by a mode (e.g. plan mode).
316
320
  * Consulted by the `resolve` tool as a fallback when no queue invoker is in flight,
317
321
  * letting modes accept `resolve` invocations without forcing the tool choice every turn. */
@@ -1,4 +1,10 @@
1
- import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
1
+ import type {
2
+ AgentTool,
3
+ AgentToolContext,
4
+ AgentToolResult,
5
+ AgentToolUpdateCallback,
6
+ CustomMessage,
7
+ } from "@oh-my-pi/pi-agent-core";
2
8
  import type { Component } from "@oh-my-pi/pi-tui";
3
9
  import { Text } from "@oh-my-pi/pi-tui";
4
10
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
@@ -28,11 +34,18 @@ export interface ResolveToolDetails {
28
34
  sourceResultDetails?: unknown;
29
35
  }
30
36
 
37
+ /** Monotonic suffix making each staged preview's pending-invoker id UNIQUE, so
38
+ * stacked previews never clobber one another by label. */
39
+ let pendingPreviewSeq = 0;
40
+
31
41
  /**
32
- * Queue a resolve-protocol handler on the tool-choice queue. Forces the next
33
- * LLM call to invoke the hidden `resolve` tool, wraps the caller's apply/reject
34
- * callbacks into an onInvoked closure that matches the resolve schema, and
35
- * steers a preview reminder so the model understands why.
42
+ * Register a non-forcing resolve-protocol handler for a staged preview. Wraps the
43
+ * caller's apply/reject into an onInvoked closure (matching the resolve schema) and
44
+ * stores it on the tool-choice queue's pending-invoker registry under a UNIQUE id.
45
+ * The `resolve` tool dispatches to it; the agent-loop's SoftToolRequirement
46
+ * lifecycle injects the preview reminder and escalates to a forced `resolve` only
47
+ * if the model declines — so a compliant turn pays ZERO tool_choice change (no
48
+ * prompt-cache messages-cache invalidation).
36
49
  *
37
50
  * This is the canonical entry point for any tool that wants preview/apply
38
51
  * semantics. No session-level abstraction is needed: callers pass their
@@ -48,46 +61,55 @@ export function queueResolveHandler(
48
61
  },
49
62
  ): void {
50
63
  const queue = session.getToolChoiceQueue?.();
51
- const forced = session.buildToolChoice?.("resolve");
52
- if (!queue || !forced || typeof forced === "string") return;
64
+ if (!queue) return;
53
65
 
54
- const steerReminder = (): void => {
55
- session.steer?.({
56
- customType: "resolve-reminder",
57
- content: [
58
- "<system-reminder>",
59
- "This is a preview. Call the `resolve` tool to apply or discard these changes.",
60
- "</system-reminder>",
61
- ].join("\n"),
62
- details: { toolName: options.sourceToolName },
63
- });
64
- };
66
+ // Unique per preview: stacked/sequential previews each get their own entry.
67
+ const id = `pending-action:${options.sourceToolName}:${pendingPreviewSeq++}`;
65
68
 
66
- const pushDirective = (): void => {
67
- queue.pushOnce(forced, {
68
- label: `pending-action:${options.sourceToolName}`,
69
- now: true,
70
- onRejected: () => "requeue",
71
- onInvoked: async (input: unknown) =>
72
- runResolveInvocation(input as ResolveParams, {
73
- sourceToolName: options.sourceToolName,
74
- label: options.label,
75
- apply: options.apply,
76
- reject: options.reject,
77
- onApplyError: () => {
78
- // Apply threw (e.g. ast_edit overlapping replacements). Re-push the
79
- // same directive so the preview remains pending and the model can
80
- // `discard` or fix-and-retry on the next turn instead of being
81
- // stranded with no pending action to address.
82
- pushDirective();
83
- steerReminder();
84
- },
85
- }),
69
+ const onInvoked = async (input: unknown): Promise<AgentToolResult<unknown>> => {
70
+ const result = await runResolveInvocation(input as ResolveParams, {
71
+ sourceToolName: options.sourceToolName,
72
+ label: options.label,
73
+ apply: options.apply,
74
+ reject: options.reject,
75
+ onApplyError: () => {
76
+ // Apply threw (e.g. ast_edit overlapping replacements). Keep the preview
77
+ // pending under the SAME id so the model can `discard` or fix-and-retry;
78
+ // runResolveInvocation rethrows, so the success-path removal below is skipped.
79
+ queue.registerPendingInvoker(id, options.sourceToolName, onInvoked);
80
+ },
86
81
  });
82
+ // Resolved (apply succeeded, or discard): consume the staged action exactly once.
83
+ queue.removePendingInvoker(id);
84
+ return result;
87
85
  };
88
86
 
89
- pushDirective();
90
- steerReminder();
87
+ // NON-FORCING: register so `resolve` can dispatch here WITHOUT changing
88
+ // tool_choice. The agent-loop injects the reminder (from the SoftToolRequirement
89
+ // the session builds) and forces a resolve turn only on non-compliance.
90
+ queue.registerPendingInvoker(id, options.sourceToolName, onInvoked);
91
+ }
92
+
93
+ /**
94
+ * The canonical preview reminder. The resolve mechanism owns the wording; the
95
+ * agent-loop delivers it via the session's `SoftToolRequirement.reminder` (injected
96
+ * once per pending-preview head) instead of a host-side steer, so it lands as a
97
+ * stable mid-history append and never churns the cached prefix.
98
+ */
99
+ export function buildResolveReminderMessage(sourceToolName: string): CustomMessage {
100
+ return {
101
+ role: "custom",
102
+ customType: "resolve-reminder",
103
+ content: [
104
+ "<system-reminder>",
105
+ "This is a preview. Call the `resolve` tool to apply or discard these changes.",
106
+ "</system-reminder>",
107
+ ].join("\n"),
108
+ display: false,
109
+ details: { toolName: sourceToolName },
110
+ attribution: "agent",
111
+ timestamp: Date.now(),
112
+ };
91
113
  }
92
114
 
93
115
  /**
@@ -185,7 +207,10 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
185
207
  _context?: AgentToolContext,
186
208
  ): Promise<AgentToolResult<ResolveToolDetails>> {
187
209
  return untilAborted(signal, async () => {
188
- const invoker = this.session.peekQueueInvoker?.() ?? this.session.peekStandingResolveHandler?.();
210
+ const invoker =
211
+ this.session.peekQueueInvoker?.() ??
212
+ this.session.peekPendingInvoker?.() ??
213
+ this.session.peekStandingResolveHandler?.();
189
214
  if (!invoker) {
190
215
  // `discard` is a request to cancel/abort a staged action. When nothing is
191
216
  // pending, the desired end-state (no staged change) already holds, so honor
@@ -48,8 +48,8 @@ function normalizeContentPaddingLeft(value: number | undefined): number {
48
48
 
49
49
  export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
50
50
  const { header, headerMeta, state, sections = [], width, applyBg = true } = options;
51
- const h = theme.boxSharp.horizontal;
52
- const v = theme.boxSharp.vertical;
51
+ const h = theme.boxRound.horizontal;
52
+ const v = theme.boxRound.vertical;
53
53
  const cap = h.repeat(3);
54
54
  const lineWidth = Math.max(0, width);
55
55
  // Border colors: running/pending use accent, success uses dim (gray), error/warning keep their colors
@@ -84,8 +84,8 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
84
84
  const rows: BlockRow[] = [];
85
85
  rows.push({
86
86
  kind: "bar",
87
- leftChar: theme.boxSharp.topLeft,
88
- rightChar: theme.boxSharp.topRight,
87
+ leftChar: theme.boxRound.topLeft,
88
+ rightChar: theme.boxRound.topRight,
89
89
  label: header,
90
90
  meta: headerMeta,
91
91
  });
@@ -99,15 +99,15 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
99
99
  if (section.label) {
100
100
  rows.push({
101
101
  kind: "bar",
102
- leftChar: theme.boxSharp.teeRight,
103
- rightChar: theme.boxSharp.teeLeft,
102
+ leftChar: theme.boxRound.teeRight,
103
+ rightChar: theme.boxRound.teeLeft,
104
104
  label: section.label,
105
105
  });
106
106
  } else if (section.separator && sectionIndex > 0) {
107
107
  rows.push({
108
108
  kind: "bar",
109
- leftChar: theme.boxSharp.teeRight,
110
- rightChar: theme.boxSharp.teeLeft,
109
+ leftChar: theme.boxRound.teeRight,
110
+ rightChar: theme.boxRound.teeLeft,
111
111
  });
112
112
  }
113
113
  const allLines = section.lines.flatMap(l => l.split("\n"));
@@ -126,7 +126,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
126
126
  }
127
127
  }
128
128
 
129
- rows.push({ kind: "bottom", leftChar: theme.boxSharp.bottomLeft, rightChar: theme.boxSharp.bottomRight });
129
+ rows.push({ kind: "bottom", leftChar: theme.boxRound.bottomLeft, rightChar: theme.boxRound.bottomRight });
130
130
 
131
131
  const H = rows.length;
132
132
 
@@ -1,13 +0,0 @@
1
- import { Box } from "@oh-my-pi/pi-tui";
2
- import type { BranchSummaryMessage } from "../../session/messages";
3
- /**
4
- * Component that renders a branch summary message with collapsed/expanded state.
5
- * Uses same background color as hook messages for visual consistency.
6
- */
7
- export declare class BranchSummaryMessageComponent extends Box {
8
- #private;
9
- private readonly message;
10
- constructor(message: BranchSummaryMessage);
11
- setExpanded(expanded: boolean): void;
12
- invalidate(): void;
13
- }
@@ -1,46 +0,0 @@
1
- import { Box, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
2
- import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
- import type { BranchSummaryMessage } from "../../session/messages";
4
-
5
- /**
6
- * Component that renders a branch summary message with collapsed/expanded state.
7
- * Uses same background color as hook messages for visual consistency.
8
- */
9
- export class BranchSummaryMessageComponent extends Box {
10
- #expanded = false;
11
-
12
- constructor(private readonly message: BranchSummaryMessage) {
13
- super(1, 1, t => theme.bg("customMessageBg", t));
14
- this.setIgnoreTight(true);
15
- this.#updateDisplay();
16
- }
17
-
18
- setExpanded(expanded: boolean): void {
19
- this.#expanded = expanded;
20
- this.#updateDisplay();
21
- }
22
-
23
- override invalidate(): void {
24
- super.invalidate();
25
- this.#updateDisplay();
26
- }
27
-
28
- #updateDisplay(): void {
29
- this.clear();
30
-
31
- const label = theme.fg("customMessageLabel", theme.bold("[branch]"));
32
- this.addChild(new Text(label, 0, 0));
33
- this.addChild(new Spacer(1));
34
-
35
- if (this.#expanded) {
36
- const header = "**Branch Summary**\n\n";
37
- this.addChild(
38
- new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
39
- color: (text: string) => theme.fg("customMessageText", text),
40
- }),
41
- );
42
- } else {
43
- this.addChild(new Text(theme.fg("customMessageText", "Branch summary (ctrl+o to expand)"), 0, 0));
44
- }
45
- }
46
- }