@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.3

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 (154) hide show
  1. package/CHANGELOG.md +113 -1
  2. package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
  3. package/dist/types/cli/startup-cwd.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +3 -0
  5. package/dist/types/config/keybindings.d.ts +2 -2
  6. package/dist/types/config/model-provider-priority.d.ts +1 -0
  7. package/dist/types/config/model-resolver.d.ts +4 -1
  8. package/dist/types/config/settings.d.ts +7 -2
  9. package/dist/types/debug/report-bundle.d.ts +3 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  11. package/dist/types/edit/index.d.ts +0 -1
  12. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  14. package/dist/types/lsp/client.d.ts +10 -0
  15. package/dist/types/lsp/index.d.ts +0 -5
  16. package/dist/types/main.d.ts +14 -9
  17. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  19. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  20. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  21. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  22. package/dist/types/modes/components/session-selector.d.ts +16 -7
  23. package/dist/types/modes/components/status-line.d.ts +2 -0
  24. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  25. package/dist/types/modes/controllers/event-controller.d.ts +17 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -0
  27. package/dist/types/modes/magic-keywords.d.ts +1 -1
  28. package/dist/types/modes/markdown-prose.d.ts +1 -1
  29. package/dist/types/modes/types.d.ts +7 -0
  30. package/dist/types/modes/workflow.d.ts +3 -3
  31. package/dist/types/session/auth-storage.d.ts +1 -1
  32. package/dist/types/session/messages.d.ts +11 -8
  33. package/dist/types/session/session-manager.d.ts +5 -2
  34. package/dist/types/session/yield-queue.d.ts +10 -1
  35. package/dist/types/task/executor.d.ts +10 -0
  36. package/dist/types/tools/eval-render.d.ts +0 -1
  37. package/dist/types/tools/eval.d.ts +8 -0
  38. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  39. package/dist/types/tools/github-cache.d.ts +12 -0
  40. package/dist/types/tools/index.d.ts +31 -0
  41. package/dist/types/tools/path-utils.d.ts +13 -1
  42. package/dist/types/tools/read.d.ts +2 -1
  43. package/dist/types/tools/render-utils.d.ts +3 -1
  44. package/dist/types/tools/renderers.d.ts +0 -15
  45. package/dist/types/tools/search.d.ts +2 -2
  46. package/dist/types/tools/write.d.ts +0 -2
  47. package/dist/types/tools/yield.d.ts +8 -0
  48. package/dist/types/tui/code-cell.d.ts +0 -2
  49. package/dist/types/tui/hyperlink.d.ts +5 -7
  50. package/dist/types/tui/output-block.d.ts +0 -18
  51. package/package.json +9 -9
  52. package/src/cli/args.ts +3 -1
  53. package/src/cli/dry-balance-cli.ts +2 -4
  54. package/src/cli/gallery-cli.ts +4 -0
  55. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  56. package/src/cli/gallery-fixtures/fs.ts +68 -1
  57. package/src/cli/gallery-fixtures/types.ts +8 -1
  58. package/src/cli/startup-cwd.ts +68 -0
  59. package/src/commands/launch.ts +3 -0
  60. package/src/commit/agentic/agent.ts +1 -0
  61. package/src/commit/model-selection.ts +3 -2
  62. package/src/config/model-provider-priority.ts +55 -0
  63. package/src/config/model-registry.ts +4 -22
  64. package/src/config/model-resolver.ts +39 -7
  65. package/src/config/settings.ts +86 -41
  66. package/src/debug/index.ts +8 -0
  67. package/src/debug/raw-sse-buffer.ts +7 -4
  68. package/src/debug/report-bundle.ts +9 -0
  69. package/src/edit/file-snapshot-store.ts +33 -1
  70. package/src/edit/hashline/diff.ts +86 -0
  71. package/src/edit/hashline/execute.ts +14 -1
  72. package/src/edit/hashline/filesystem.ts +2 -1
  73. package/src/edit/index.ts +31 -17
  74. package/src/edit/renderer.ts +116 -31
  75. package/src/eval/__tests__/llm-bridge.test.ts +20 -0
  76. package/src/eval/js/context-manager.ts +32 -15
  77. package/src/eval/js/shared/prelude.txt +26 -10
  78. package/src/eval/llm-bridge.ts +14 -3
  79. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  80. package/src/eval/py/executor.ts +23 -11
  81. package/src/eval/py/prelude.py +1 -1
  82. package/src/extensibility/extensions/types.ts +10 -1
  83. package/src/internal-urls/docs-index.generated.ts +7 -7
  84. package/src/lsp/client.ts +23 -11
  85. package/src/lsp/config.ts +11 -1
  86. package/src/lsp/index.ts +189 -61
  87. package/src/main.ts +144 -78
  88. package/src/mcp/tool-bridge.ts +2 -0
  89. package/src/memories/index.ts +2 -2
  90. package/src/modes/components/assistant-message.ts +3 -15
  91. package/src/modes/components/custom-editor.ts +143 -111
  92. package/src/modes/components/late-diagnostics-message.ts +60 -0
  93. package/src/modes/components/model-selector.ts +59 -13
  94. package/src/modes/components/oauth-selector.ts +33 -7
  95. package/src/modes/components/plan-review-overlay.ts +26 -5
  96. package/src/modes/components/read-tool-group.ts +415 -35
  97. package/src/modes/components/session-selector.ts +89 -35
  98. package/src/modes/components/status-line.ts +19 -4
  99. package/src/modes/components/tips.txt +1 -1
  100. package/src/modes/components/tool-execution.ts +7 -49
  101. package/src/modes/components/transcript-container.ts +108 -32
  102. package/src/modes/components/user-message.ts +1 -1
  103. package/src/modes/controllers/event-controller.ts +32 -1
  104. package/src/modes/controllers/input-controller.ts +56 -9
  105. package/src/modes/interactive-mode.ts +107 -20
  106. package/src/modes/magic-keywords.ts +1 -1
  107. package/src/modes/markdown-prose.ts +1 -1
  108. package/src/modes/theme/shimmer.ts +20 -9
  109. package/src/modes/types.ts +7 -0
  110. package/src/modes/utils/ui-helpers.ts +26 -5
  111. package/src/modes/workflow.ts +10 -10
  112. package/src/prompts/system/manual-continue.md +7 -0
  113. package/src/prompts/system/plan-mode-active.md +56 -72
  114. package/src/prompts/system/workflow-notice.md +1 -1
  115. package/src/prompts/tools/bash.md +9 -0
  116. package/src/prompts/tools/browser.md +1 -1
  117. package/src/prompts/tools/eval.md +5 -2
  118. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  119. package/src/prompts/tools/read.md +2 -2
  120. package/src/sdk.ts +85 -10
  121. package/src/session/agent-session.ts +42 -15
  122. package/src/session/auth-storage.ts +2 -0
  123. package/src/session/messages.ts +21 -14
  124. package/src/session/session-manager.ts +98 -25
  125. package/src/session/yield-queue.ts +20 -2
  126. package/src/task/executor.ts +72 -36
  127. package/src/task/render.ts +3 -4
  128. package/src/tiny/title-client.ts +6 -1
  129. package/src/tools/bash.ts +7 -7
  130. package/src/tools/browser/tab-supervisor.ts +13 -1
  131. package/src/tools/browser/tab-worker.ts +33 -4
  132. package/src/tools/eval-render.ts +4 -23
  133. package/src/tools/eval.ts +13 -2
  134. package/src/tools/find.ts +148 -99
  135. package/src/tools/gh-cache-invalidation.ts +200 -0
  136. package/src/tools/github-cache.ts +25 -0
  137. package/src/tools/index.ts +32 -0
  138. package/src/tools/inspect-image.ts +2 -2
  139. package/src/tools/path-utils.ts +47 -24
  140. package/src/tools/plan-mode-guard.ts +52 -7
  141. package/src/tools/read.ts +41 -20
  142. package/src/tools/render-utils.ts +3 -1
  143. package/src/tools/renderers.ts +0 -15
  144. package/src/tools/search.ts +38 -3
  145. package/src/tools/ssh.ts +0 -1
  146. package/src/tools/todo.ts +1 -0
  147. package/src/tools/write.ts +5 -14
  148. package/src/tools/yield.ts +10 -1
  149. package/src/tui/code-cell.ts +1 -6
  150. package/src/tui/hyperlink.ts +13 -23
  151. package/src/tui/output-block.ts +2 -97
  152. package/src/utils/commit-message-generator.ts +2 -2
  153. package/src/utils/enhanced-paste.ts +30 -2
  154. package/src/web/search/providers/codex.ts +37 -8
@@ -32,8 +32,6 @@ export interface CodeCellOptions {
32
32
  */
33
33
  codeTail?: boolean;
34
34
  expanded?: boolean;
35
- /** Animate the cell border with a sweeping segment while pending/running. */
36
- animate?: boolean;
37
35
  width: number;
38
36
  }
39
37
 
@@ -147,10 +145,7 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
147
145
  sections.push({ label: theme.fg("toolTitle", "Output"), lines: outputLines });
148
146
  }
149
147
 
150
- return renderOutputBlock(
151
- { header: title, headerMeta: meta, state, sections, width, animate: options.animate },
152
- theme,
153
- );
148
+ return renderOutputBlock({ header: title, headerMeta: meta, state, sections, width }, theme);
154
149
  }
155
150
 
156
151
  export interface MarkdownCellOptions {
@@ -5,6 +5,7 @@
5
5
  * sequences when the active terminal supports hyperlinks and the user setting
6
6
  * permits it. Falls back to plain text when disabled.
7
7
  */
8
+ import * as url from "node:url";
8
9
  import { TERMINAL } from "@oh-my-pi/pi-tui";
9
10
  import { settings } from "../config/settings";
10
11
  import {
@@ -28,21 +29,12 @@ function buildLinkId(uri: string): string {
28
29
  return (h >>> 0).toString(16).padStart(8, "0");
29
30
  }
30
31
 
31
- /** Build a `file://` URI for an absolute path with optional line/col query params. */
32
- function buildFileUri(absPath: string, opts?: { line?: number; col?: number }): string {
33
- // Normalize backslashes for Windows paths before constructing the URL.
34
- const normalized = absPath.replaceAll("\\", "/");
35
- const prefix = normalized.startsWith("/") ? "file://" : "file:///";
36
- // Split on slashes, encode each component, reassemble.
37
- const encoded = normalized
38
- .split("/")
39
- .map(segment => encodeURIComponent(segment))
40
- .join("/");
41
- const params: string[] = [];
42
- if (opts?.line !== undefined) params.push(`line=${opts.line}`);
43
- if (opts?.col !== undefined) params.push(`col=${opts.col}`);
44
- const query = params.length > 0 ? `?${params.join("&")}` : "";
45
- return `${prefix}${encoded}${query}`;
32
+ /** Build a properly encoded `file://` URI with optional line/col query params. */
33
+ function buildFileUri(filePath: string, opts?: { line?: number; col?: number }): string {
34
+ const uri = url.pathToFileURL(filePath);
35
+ if (opts?.line !== undefined) uri.searchParams.set("line", String(opts.line));
36
+ if (opts?.col !== undefined) uri.searchParams.set("col", String(opts.col));
37
+ return uri.href;
46
38
  }
47
39
 
48
40
  /**
@@ -104,21 +96,19 @@ export function urlHyperlink(url: string, displayText: string): string {
104
96
  }
105
97
 
106
98
  /**
107
- * Wrap `displayText` in an OSC 8 hyperlink pointing at the given absolute file path.
99
+ * Wrap `displayText` in an OSC 8 hyperlink pointing at a filesystem path.
108
100
  *
109
101
  * Returns `displayText` unchanged when hyperlinks are disabled or when
110
102
  * the text already contains an OSC 8 sequence (prevents double-wrapping).
103
+ * Relative paths resolve against the current working directory before URI
104
+ * encoding so the OSC 8 target is always a valid `file://` URL.
111
105
  *
112
- * The caller is responsible for passing an absolute path. Relative paths
113
- * produce invalid `file://` URIs and are accepted silently to avoid runtime
114
- * errors in renderer hot paths.
115
- *
116
- * @param absPath - Absolute filesystem path
106
+ * @param filePath - Filesystem path
117
107
  * @param displayText - Text to render as the hyperlink anchor (may contain ANSI codes)
118
108
  * @param opts - Optional line/col position appended as `?line=N&col=M` query params
119
109
  */
120
- export function fileHyperlink(absPath: string, displayText: string, opts?: { line?: number; col?: number }): string {
121
- return wrapHyperlink(buildFileUri(absPath, opts), displayText);
110
+ export function fileHyperlink(filePath: string, displayText: string, opts?: { line?: number; col?: number }): string {
111
+ return wrapHyperlink(buildFileUri(filePath, opts), displayText);
122
112
  }
123
113
 
124
114
  /**
@@ -17,8 +17,6 @@ export interface OutputBlockOptions {
17
17
  width: number;
18
18
  applyBg?: boolean;
19
19
  contentPaddingLeft?: number;
20
- /** Animate the border with a sweeping dark segment (pending/running state). */
21
- animate?: boolean;
22
20
  /** Override the state-derived border color. Used for muted "legacy" tool
23
21
  * frames that should not visually compete with framed-output tools. */
24
22
  borderColor?: ThemeColor;
@@ -37,59 +35,6 @@ export function isFramedBlockComponent(component: Component): boolean {
37
35
  return (component as FramedBlockComponent)[FRAMED_BLOCK_COMPONENT] === true;
38
36
  }
39
37
 
40
- const BORDER_SHIMMER_TICK_MS = 1000 / 30;
41
- /** Duration of one full left↔right↔left bounce of the bottom-edge segment, in
42
- * ms. Position is derived from the wall clock against this fixed cycle so a
43
- * resize only nudges the segment proportionally instead of teleporting it. */
44
- const BORDER_BOUNCE_MS = 3000;
45
- /** Length, in border cells, of the moving segment. */
46
- const BORDER_SEGMENT_LEN = 8;
47
-
48
- /**
49
- * Monotonic frame counter for animated borders, quantized to the TUI's ~30fps
50
- * render cap so the cache key advances once per animation frame — fine enough
51
- * for a smooth segment sweep, coarse enough to coalesce multiple render passes
52
- * land inside the same frame.
53
- */
54
- export function borderShimmerTick(): number {
55
- return Math.floor(Date.now() / BORDER_SHIMMER_TICK_MS);
56
- }
57
-
58
- /** Ease-in-out so the segment decelerates into and accelerates out of each wall. */
59
- function easeInOutQuad(t: number): number {
60
- return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
61
- }
62
-
63
- /**
64
- * Column of the travelling segment's center on the bottom edge for a box of
65
- * inner width `W` at time `now`. The segment bounces left → right → left across
66
- * the bottom border: a triangle wave over one full there-and-back cycle, eased
67
- * per leg so it slows as it nears each wall before reversing. Position is
68
- * derived from the wall clock against a fixed cycle, so a resize shifts the
69
- * center proportionally — no reset.
70
- */
71
- export function borderSegmentHeadCol(W: number, now: number): number {
72
- if (W <= 1) return 0;
73
- const phase = (((now % BORDER_BOUNCE_MS) + BORDER_BOUNCE_MS) % BORDER_BOUNCE_MS) / BORDER_BOUNCE_MS;
74
- // Triangle: 0→1 rightward over the first half, 1→0 leftward over the second.
75
- const leg = phase < 0.5 ? phase * 2 : 2 - phase * 2;
76
- return easeInOutQuad(leg) * (W - 1);
77
- }
78
-
79
- /**
80
- * Scale a truecolor foreground escape toward black by `factor`. Returns
81
- * undefined for 256-color escapes (no RGB to scale) so callers fall back to a
82
- * dimmer theme color.
83
- */
84
- function darkenFgAnsi(ansi: string, factor: number): string | undefined {
85
- const m = /38;2;(\d+);(\d+);(\d+)/.exec(ansi);
86
- if (!m) return undefined;
87
- const r = Math.round(Number(m[1]) * factor);
88
- const g = Math.round(Number(m[2]) * factor);
89
- const b = Math.round(Number(m[3]) * factor);
90
- return `\x1b[38;2;${r};${g};${b}m`;
91
- }
92
-
93
38
  type BlockRow =
94
39
  | { kind: "bar"; leftChar: string; rightChar: string; label?: string; meta?: string }
95
40
  | { kind: "bottom"; leftChar: string; rightChar: string }
@@ -135,8 +80,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
135
80
  const contentWidth = Math.max(0, lineWidth - visibleWidth(v) - contentPaddingLeft - visibleWidth(v));
136
81
  const contentLeftPadding = contentPaddingLeft > 0 ? padding(contentPaddingLeft) : "";
137
82
 
138
- // ── Layout pass: collect row descriptors so the border perimeter length is
139
- // known before the moving segment is positioned. ──
83
+ // ── Layout pass: collect row descriptors before emitting the bordered lines. ──
140
84
  const rows: BlockRow[] = [];
141
85
  rows.push({
142
86
  kind: "bar",
@@ -185,39 +129,6 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
185
129
  rows.push({ kind: "bottom", leftChar: theme.boxSharp.bottomLeft, rightChar: theme.boxSharp.bottomRight });
186
130
 
187
131
  const H = rows.length;
188
- const W = lineWidth;
189
- const animate = (options.animate ?? false) && (state === "running" || state === "pending") && W >= 2 && H >= 2;
190
-
191
- // ── Segment geometry: one dark run bounces left ↔ right along the bottom
192
- // edge only. The top, interior separators, and side borders stay the flat
193
- // accent color. ──
194
- const segLen = animate ? Math.min(BORDER_SEGMENT_LEN, W) : 0;
195
- const head = animate ? borderSegmentHeadCol(W, Date.now()) : 0;
196
- const segHalf = segLen / 2;
197
- const segAnsi = animate ? (darkenFgAnsi(theme.getFgAnsi(borderColor), 0.4) ?? theme.getFgAnsi("borderMuted")) : "";
198
- const seg = (text: string) => `${segAnsi}${text}\x1b[39m`;
199
-
200
- // A bottom-edge column is lit when it lies within half a segment of the
201
- // travelling center.
202
- const isLit = (col: number): boolean => Math.abs(col - head) < segHalf;
203
- // Color a run of bottom-edge glyphs starting at column `startCol`, grouping
204
- // consecutive same-state cells so each run emits a single escape pair.
205
- const colorEdge = (glyphs: string, startCol: number): string => {
206
- let out = "";
207
- let runLit: boolean | null = null;
208
- let buf = "";
209
- for (let i = 0; i < glyphs.length; i++) {
210
- const lit = isLit(startCol + i);
211
- if (lit !== runLit) {
212
- if (runLit !== null) out += (runLit ? seg : border)(buf);
213
- buf = "";
214
- runLit = lit;
215
- }
216
- buf += glyphs[i];
217
- }
218
- if (runLit !== null) out += (runLit ? seg : border)(buf);
219
- return out;
220
- };
221
132
 
222
133
  const renderBar = (row: { leftChar: string; rightChar: string; label?: string; meta?: string }): string => {
223
134
  const leftGlyphs = `${row.leftChar}${cap}`;
@@ -245,11 +156,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
245
156
  const rightGlyph = row.rightChar;
246
157
  const fillCount = Math.max(0, lineWidth - visibleWidth(leftGlyphs) - visibleWidth(rightGlyph));
247
158
  const fillGlyphs = h.repeat(fillCount);
248
- if (!animate) return `${border(leftGlyphs)}${border(fillGlyphs)}${border(rightGlyph)}`;
249
- const leftStr = colorEdge(leftGlyphs, 0);
250
- const fillStr = colorEdge(fillGlyphs, visibleWidth(leftGlyphs));
251
- const rightStr = colorEdge(rightGlyph, lineWidth - visibleWidth(rightGlyph));
252
- return `${leftStr}${fillStr}${rightStr}`;
159
+ return `${border(leftGlyphs)}${border(fillGlyphs)}${border(rightGlyph)}`;
253
160
  };
254
161
 
255
162
  const renderContent = (inner: string): string => `${border(v)}${contentLeftPadding}${inner}${border(v)}`;
@@ -302,8 +209,6 @@ export class CachedOutputBlock {
302
209
  h.optional(options.state);
303
210
  h.optional(options.borderColor);
304
211
  h.bool(options.applyBg ?? true);
305
- h.bool(options.animate ?? false);
306
- if (options.animate) h.u32(borderShimmerTick());
307
212
  if (options.sections) {
308
213
  for (const s of options.sections) {
309
214
  h.optional(s.label);
@@ -8,7 +8,7 @@ import { completeSimple } from "@oh-my-pi/pi-ai";
8
8
  import { logger, prompt } from "@oh-my-pi/pi-utils";
9
9
 
10
10
  import type { ModelRegistry } from "../config/model-registry";
11
- import { resolveModelRoleValue } from "../config/model-resolver";
11
+ import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver";
12
12
  import type { Settings } from "../config/settings";
13
13
  import MODEL_PRIO from "../priority.json" with { type: "json" };
14
14
  import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" };
@@ -51,7 +51,7 @@ function getSmolModelCandidates(
51
51
  candidates.push({ model, thinkingLevel });
52
52
  };
53
53
 
54
- const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
54
+ const matchPreferences = getModelMatchPreferences(settings);
55
55
  const configuredSmol = resolveModelRoleValue(settings.getModelRole("smol"), availableModels, {
56
56
  settings,
57
57
  matchPreferences,
@@ -7,6 +7,8 @@ const PASTE_EVENT_NAME_BASE64 = Buffer.from("Paste event", "utf8").toString("bas
7
7
 
8
8
  const IMAGE_MIME_PRIORITY = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
9
9
  const TEXT_MIME_TYPE = "text/plain";
10
+ /** Kitty's "give me the list of available MIME types" sentinel — see `TARGETS_MIME` in `kitty/clipboard.py`. */
11
+ const MIME_LISTING_TARGET = ".";
10
12
 
11
13
  type PasteReadKind = "image" | "text";
12
14
 
@@ -18,6 +20,7 @@ export interface Osc5522Packet {
18
20
  interface PasteListingState {
19
21
  phase: "listing";
20
22
  mimes: string[];
23
+ kittyDotPayload?: true;
21
24
  pw?: string;
22
25
  loc?: string;
23
26
  }
@@ -144,6 +147,25 @@ export class EnhancedPasteController {
144
147
  if (!mimeType) return;
145
148
 
146
149
  if (state.phase === "listing") {
150
+ // Kitty (as of writing) implements the "list available MIME types"
151
+ // response shape by sending a single DATA packet with `mime="."` and
152
+ // the available types packed into the payload as a whitespace-
153
+ // separated list (see `fulfill_read_request` in
154
+ // kovidgoyal/kitty:kitty/clipboard.py). The 5522-mode ancillary
155
+ // spec instead encodes each type as its own DATA packet with an
156
+ // empty payload. Support both — fall through to the per-packet
157
+ // form when the dot sentinel has no payload, or when the packet
158
+ // already names a concrete MIME type.
159
+ if (mimeType === MIME_LISTING_TARGET) {
160
+ if (!packet.payload) return;
161
+ const listing = decodeBase64Utf8(packet.payload);
162
+ if (!listing) return;
163
+ state.kittyDotPayload = true;
164
+ for (const candidate of listing.split(/\s+/)) {
165
+ if (candidate && candidate !== MIME_LISTING_TARGET) state.mimes.push(candidate);
166
+ }
167
+ return;
168
+ }
147
169
  state.mimes.push(mimeType);
148
170
  return;
149
171
  }
@@ -192,11 +214,17 @@ export class EnhancedPasteController {
192
214
  chunks: [],
193
215
  };
194
216
 
195
- const metadata = [`type=read`, `mime=${Buffer.from(selected.mimeType, "utf8").toString("base64")}`];
217
+ const encodedMime = Buffer.from(selected.mimeType, "utf8").toString("base64");
218
+ const metadata = ["type=read"];
196
219
  if (state.loc) metadata.push(`loc=${state.loc}`);
197
220
  if (state.pw) {
198
221
  metadata.push(`pw=${state.pw}`, `name=${PASTE_EVENT_NAME_BASE64}`);
199
222
  }
200
- this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")}${OSC_TERMINATOR_ST}`);
223
+ if (state.kittyDotPayload) {
224
+ this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")};${encodedMime}${OSC_TERMINATOR_BEL}`);
225
+ return;
226
+ }
227
+ metadata.push(`mime=${encodedMime}`);
228
+ this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")}${OSC_TERMINATOR_BEL}`);
201
229
  }
202
230
  }
@@ -114,8 +114,34 @@ interface CodexResponse {
114
114
  usage?: CodexUsage;
115
115
  }
116
116
 
117
+ /**
118
+ * Known Codex "image placeholder" answers — short prose the assistant emits in
119
+ * place of a real answer when it produced a screenshot instead of text. These
120
+ * carry no information, so callers treat them as non-answers and advance the
121
+ * chain to a provider that returns text. Extend by adding the normalized
122
+ * literal below; no regex tuning required.
123
+ */
124
+ const IMAGE_PLACEHOLDER_ANSWERS: ReadonlySet<string> = new Set([
125
+ "see attached image",
126
+ "attached image",
127
+ "see the attached image",
128
+ "see image",
129
+ "see image above",
130
+ "image above",
131
+ "see image below",
132
+ "image below",
133
+ ]);
134
+
117
135
  function isImagePlaceholderAnswer(text: string): boolean {
118
- return text.trim().toLowerCase() === "(see attached image)";
136
+ // Strip surrounding brackets/quotes and trailing punctuation, lowercase,
137
+ // then match against the known-placeholder set.
138
+ const normalized = text
139
+ .trim()
140
+ .replace(/^[[("'`*_]+/, "")
141
+ .replace(/[\])"'`*_.!?]+$/, "")
142
+ .trim()
143
+ .toLowerCase();
144
+ return IMAGE_PLACEHOLDER_ANSWERS.has(normalized);
119
145
  }
120
146
 
121
147
  function addSource(sources: SearchSource[], source: SearchSource): void {
@@ -423,15 +449,18 @@ async function callCodexSearch(
423
449
 
424
450
  const finalAnswer = answerParts.join("\n\n").trim();
425
451
  const streamedAnswer = streamedAnswerParts.join("").trim();
426
- if (isImagePlaceholderAnswer(finalAnswer) && streamedAnswer.length === 0) {
452
+ // Throw to advance the chain whenever Codex emitted nothing but image
453
+ // placeholder prose — including the case where the streamed delta itself
454
+ // is the placeholder (the model occasionally streams the same text it
455
+ // publishes as the final output_text).
456
+ const finalIsPlaceholder = finalAnswer.length > 0 && isImagePlaceholderAnswer(finalAnswer);
457
+ const streamedIsPlaceholder = streamedAnswer.length > 0 && isImagePlaceholderAnswer(streamedAnswer);
458
+ const hasFinalText = finalAnswer.length > 0 && !finalIsPlaceholder;
459
+ const hasStreamedText = streamedAnswer.length > 0 && !streamedIsPlaceholder;
460
+ if (!hasFinalText && !hasStreamedText && sources.length === 0) {
427
461
  throw new SearchProviderError("codex", "Codex returned image-only response", 502);
428
462
  }
429
- const answer =
430
- finalAnswer.length > 0 && !isImagePlaceholderAnswer(finalAnswer)
431
- ? finalAnswer
432
- : streamedAnswer.length > 0
433
- ? streamedAnswer
434
- : finalAnswer;
463
+ const answer = hasFinalText ? finalAnswer : hasStreamedText ? streamedAnswer : "";
435
464
 
436
465
  // Fallback: when Codex omits url_citation annotations, scrape markdown links
437
466
  // and bare URLs from the synthesized answer so callers still receive sources.