pi-openai-codex-compat 0.0.1-alpha.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 (38) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +20 -0
  3. package/LICENSES/Apache-2.0.txt +201 -0
  4. package/LICENSES/pi-ai-MIT.txt +21 -0
  5. package/README.md +331 -0
  6. package/THIRD_PARTY_NOTICES.md +21 -0
  7. package/extensions/openai-codex-compat/apply-patch-diff-render.ts +436 -0
  8. package/extensions/openai-codex-compat/apply-patch-engine.ts +1004 -0
  9. package/extensions/openai-codex-compat/apply-patch-render.ts +133 -0
  10. package/extensions/openai-codex-compat/apply-patch.ts +142 -0
  11. package/extensions/openai-codex-compat/codex-protocol.ts +598 -0
  12. package/extensions/openai-codex-compat/codex-provider.ts +740 -0
  13. package/extensions/openai-codex-compat/codex-stream.ts +444 -0
  14. package/extensions/openai-codex-compat/codex-tool-surface.ts +186 -0
  15. package/extensions/openai-codex-compat/codex-transport.ts +855 -0
  16. package/extensions/openai-codex-compat/compaction-checkpoint.ts +304 -0
  17. package/extensions/openai-codex-compat/config.ts +268 -0
  18. package/extensions/openai-codex-compat/footer.ts +99 -0
  19. package/extensions/openai-codex-compat/image-generation-render.ts +166 -0
  20. package/extensions/openai-codex-compat/image-generation.ts +355 -0
  21. package/extensions/openai-codex-compat/index.ts +65 -0
  22. package/extensions/openai-codex-compat/model-policy.ts +67 -0
  23. package/extensions/openai-codex-compat/namespaced-tools.ts +43 -0
  24. package/extensions/openai-codex-compat/native-history.ts +78 -0
  25. package/extensions/openai-codex-compat/remote-compaction.ts +198 -0
  26. package/extensions/openai-codex-compat/request-options.ts +121 -0
  27. package/extensions/openai-codex-compat/responses-replay.ts +33 -0
  28. package/extensions/openai-codex-compat/settings-pane.ts +298 -0
  29. package/extensions/openai-codex-compat/tool-runtime.ts +32 -0
  30. package/extensions/openai-codex-compat/tools.ts +70 -0
  31. package/extensions/openai-codex-compat/vendor/pi-ai/README.md +15 -0
  32. package/extensions/openai-codex-compat/vendor/pi-ai/openai-responses-serialization.ts +660 -0
  33. package/extensions/openai-codex-compat/web-run-description.txt +105 -0
  34. package/extensions/openai-codex-compat/web-run-output.ts +172 -0
  35. package/extensions/openai-codex-compat/web-run-render.ts +681 -0
  36. package/extensions/openai-codex-compat/web-run-schema.ts +301 -0
  37. package/extensions/openai-codex-compat/web-run.ts +164 -0
  38. package/package.json +63 -0
@@ -0,0 +1,444 @@
1
+ import {
2
+ calculateCost,
3
+ parseStreamingJson,
4
+ type AssistantMessage,
5
+ type AssistantMessageEventStream,
6
+ type Model,
7
+ type TextContent,
8
+ type TextSignatureV1,
9
+ type ThinkingContent,
10
+ type ToolCall,
11
+ } from "@earendil-works/pi-ai";
12
+ import { isObject, type JsonRecord } from "./codex-protocol.ts";
13
+ import { CODEX_NAMESPACED_TOOL_NAMES, namespacedToolCallName } from "./namespaced-tools.ts";
14
+
15
+ /**
16
+ * Focused adaptation of @earendil-works/pi-ai@0.83.0
17
+ * src/api/openai-responses-shared.ts stream processing.
18
+ */
19
+
20
+ type GrammarJsonBuffer = {
21
+ input: string;
22
+ started: boolean;
23
+ closed: boolean;
24
+ };
25
+
26
+ type StreamingToolCall = ToolCall & {
27
+ partialJson?: string;
28
+ customInput?: {
29
+ property: string;
30
+ jsonBuffer: GrammarJsonBuffer;
31
+ };
32
+ };
33
+
34
+ type OutputSlot =
35
+ | { type: "thinking"; block: ThinkingContent; contentIndex: number }
36
+ | { type: "text"; block: TextContent; contentIndex: number }
37
+ | { type: "toolCall"; block: StreamingToolCall; contentIndex: number };
38
+
39
+ type ToolCallSlot = Extract<OutputSlot, { type: "toolCall" }>;
40
+
41
+ function outputIndex(event: JsonRecord): number {
42
+ return typeof event["output_index"] === "number" ? event["output_index"] : 0;
43
+ }
44
+
45
+ function stringValue(value: unknown): string {
46
+ return typeof value === "string" ? value : "";
47
+ }
48
+
49
+ function encodeTextSignature(id: string, phase: unknown): string {
50
+ const payload: TextSignatureV1 = { v: 1, id };
51
+ if (phase === "commentary" || phase === "final_answer") payload.phase = phase;
52
+ return JSON.stringify(payload);
53
+ }
54
+
55
+ function appendGrammarDelta(
56
+ buffer: GrammarJsonBuffer,
57
+ property: string,
58
+ nextInput: string,
59
+ close: boolean,
60
+ ): string | undefined {
61
+ if (buffer.closed) {
62
+ if (close && nextInput === buffer.input) return undefined;
63
+ throw new Error(`grammar tool input for property "${property}" changed after closure`);
64
+ }
65
+ if (!nextInput.startsWith(buffer.input)) {
66
+ throw new Error(`grammar tool input for property "${property}" changed non-monotonically`);
67
+ }
68
+ const inputDelta = nextInput.slice(buffer.input.length);
69
+ if (!close && inputDelta.length === 0) return undefined;
70
+
71
+ let delta = "";
72
+ if (!buffer.started) {
73
+ delta = `{${JSON.stringify(property)}:"`;
74
+ buffer.started = true;
75
+ }
76
+ delta += JSON.stringify(inputDelta).slice(1, -1);
77
+ buffer.input = nextInput;
78
+ if (close) {
79
+ delta += '"}';
80
+ buffer.closed = true;
81
+ }
82
+ return delta;
83
+ }
84
+
85
+ function customInput(block: StreamingToolCall): string {
86
+ const property = block.customInput?.property;
87
+ if (!property) return "";
88
+ const value = block.arguments[property];
89
+ return typeof value === "string" ? value : "";
90
+ }
91
+
92
+ function appendCustomInput(
93
+ block: StreamingToolCall,
94
+ nextInput: string,
95
+ close: boolean,
96
+ ): string | undefined {
97
+ const state = block.customInput;
98
+ if (!state) return undefined;
99
+ const delta = appendGrammarDelta(state.jsonBuffer, state.property, nextInput, close);
100
+ block.arguments = { [state.property]: nextInput };
101
+ return delta;
102
+ }
103
+
104
+ function responseItems(value: unknown): JsonRecord[] {
105
+ if (!Array.isArray(value)) return [];
106
+ return value.filter(isObject);
107
+ }
108
+
109
+ function itemContentText(item: JsonRecord): string {
110
+ if (!Array.isArray(item.content)) return "";
111
+ return item.content
112
+ .filter(isObject)
113
+ .map((content) =>
114
+ typeof content.text === "string"
115
+ ? content.text
116
+ : typeof content["refusal"] === "string"
117
+ ? content["refusal"]
118
+ : "",
119
+ )
120
+ .join("");
121
+ }
122
+
123
+ function reasoningText(item: JsonRecord): string {
124
+ const summary = Array.isArray(item["summary"])
125
+ ? item["summary"]
126
+ .filter(isObject)
127
+ .map((part) => (typeof part.text === "string" ? part.text : ""))
128
+ .join("\n\n")
129
+ : "";
130
+ if (summary) return summary;
131
+ return itemContentText(item);
132
+ }
133
+
134
+ function mapStopReason(status: unknown): AssistantMessage["stopReason"] {
135
+ if (status === "incomplete") return "length";
136
+ if (status === "failed" || status === "cancelled") return "error";
137
+ return "stop";
138
+ }
139
+
140
+ export async function processCodexStream(
141
+ events: AsyncIterable<JsonRecord>,
142
+ output: AssistantMessage,
143
+ stream: AssistantMessageEventStream,
144
+ model: Model<any>,
145
+ grammarToolInputProperties: ReadonlyMap<string, string>,
146
+ ): Promise<void> {
147
+ let terminal = false;
148
+ const slots = new Map<number, OutputSlot>();
149
+ const reasoningById = new Map<string, ThinkingContent>();
150
+
151
+ const getSlot = <TType extends OutputSlot["type"]>(
152
+ index: number,
153
+ type: TType,
154
+ ): Extract<OutputSlot, { type: TType }> | undefined => {
155
+ const slot = slots.get(index);
156
+ return slot?.type === type ? (slot as Extract<OutputSlot, { type: TType }>) : undefined;
157
+ };
158
+
159
+ const pushToolDelta = (slot: ToolCallSlot, delta: string | undefined): void => {
160
+ if (delta === undefined) return;
161
+ stream.push({
162
+ type: "toolcall_delta",
163
+ contentIndex: slot.contentIndex,
164
+ delta,
165
+ partial: output,
166
+ });
167
+ };
168
+
169
+ const createSlot = (index: number, item: JsonRecord): OutputSlot | undefined => {
170
+ if (item.type === "reasoning") {
171
+ const block: ThinkingContent = { type: "thinking", thinking: "" };
172
+ output.content.push(block);
173
+ const slot = {
174
+ type: "thinking",
175
+ block,
176
+ contentIndex: output.content.length - 1,
177
+ } satisfies OutputSlot;
178
+ slots.set(index, slot);
179
+ stream.push({ type: "thinking_start", contentIndex: slot.contentIndex, partial: output });
180
+ return slot;
181
+ }
182
+ if (item.type === "message") {
183
+ const block: TextContent = { type: "text", text: "" };
184
+ output.content.push(block);
185
+ const slot = {
186
+ type: "text",
187
+ block,
188
+ contentIndex: output.content.length - 1,
189
+ } satisfies OutputSlot;
190
+ slots.set(index, slot);
191
+ stream.push({ type: "text_start", contentIndex: slot.contentIndex, partial: output });
192
+ return slot;
193
+ }
194
+ if (item.type === "function_call") {
195
+ const wireName = stringValue(item.name);
196
+ const name =
197
+ item["namespace"] === undefined
198
+ ? wireName
199
+ : namespacedToolCallName(item["namespace"], wireName);
200
+ if (item["namespace"] === undefined && CODEX_NAMESPACED_TOOL_NAMES.has(name)) {
201
+ throw new Error(`Codex returned namespaced tool "${name}" as a flat function call.`);
202
+ }
203
+ const block: StreamingToolCall = {
204
+ type: "toolCall",
205
+ id: `${stringValue(item["call_id"])}|${stringValue(item.id)}`,
206
+ name,
207
+ arguments: {},
208
+ partialJson: typeof item.arguments === "string" ? item.arguments : "",
209
+ };
210
+ output.content.push(block);
211
+ const slot = {
212
+ type: "toolCall",
213
+ block,
214
+ contentIndex: output.content.length - 1,
215
+ } satisfies OutputSlot;
216
+ slots.set(index, slot);
217
+ stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
218
+ return slot;
219
+ }
220
+ if (item.type === "custom_tool_call") {
221
+ const name = stringValue(item.name);
222
+ const property = grammarToolInputProperties.get(name) ?? "input";
223
+ const input = typeof item["input"] === "string" ? item["input"] : "";
224
+ const block: StreamingToolCall = {
225
+ type: "toolCall",
226
+ id: `${stringValue(item["call_id"])}|${stringValue(item.id)}`,
227
+ name,
228
+ arguments: { [property]: input },
229
+ customInput: {
230
+ property,
231
+ jsonBuffer: { input: "", started: false, closed: false },
232
+ },
233
+ };
234
+ output.content.push(block);
235
+ const slot = {
236
+ type: "toolCall",
237
+ block,
238
+ contentIndex: output.content.length - 1,
239
+ } satisfies OutputSlot;
240
+ slots.set(index, slot);
241
+ stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
242
+ return slot;
243
+ }
244
+ return undefined;
245
+ };
246
+
247
+ const slotFor = (index: number, item: JsonRecord): OutputSlot | undefined =>
248
+ slots.get(index) ?? createSlot(index, item);
249
+
250
+ const finalize = (response: JsonRecord): void => {
251
+ terminal = true;
252
+ if (typeof response.id === "string") output.responseId = response.id;
253
+ const usage = isObject(response.usage) ? response.usage : undefined;
254
+ if (usage) {
255
+ const details = isObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
256
+ const cached = typeof details?.cached_tokens === "number" ? details.cached_tokens : 0;
257
+ const cacheWrite =
258
+ typeof details?.cache_write_tokens === "number" ? details.cache_write_tokens : 0;
259
+ const input = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
260
+ const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
261
+ const outputDetails = isObject(usage["output_tokens_details"])
262
+ ? usage["output_tokens_details"]
263
+ : undefined;
264
+ output.usage = {
265
+ input: Math.max(0, input - cached - cacheWrite),
266
+ output: outputTokens,
267
+ cacheRead: cached,
268
+ cacheWrite,
269
+ reasoning:
270
+ typeof outputDetails?.["reasoning_tokens"] === "number"
271
+ ? outputDetails["reasoning_tokens"]
272
+ : 0,
273
+ totalTokens:
274
+ typeof usage.total_tokens === "number" ? usage.total_tokens : input + outputTokens,
275
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
276
+ };
277
+ calculateCost(model, output.usage);
278
+ }
279
+ for (const item of responseItems(response["output"])) {
280
+ if (item.type !== "reasoning" || typeof item.id !== "string") continue;
281
+ const block = reasoningById.get(item.id);
282
+ if (!block?.thinkingSignature || typeof item.encrypted_content !== "string") continue;
283
+ const stored = JSON.parse(block.thinkingSignature) as JsonRecord;
284
+ if (typeof stored.encrypted_content !== "string") {
285
+ block.thinkingSignature = JSON.stringify({
286
+ ...stored,
287
+ encrypted_content: item.encrypted_content,
288
+ });
289
+ }
290
+ }
291
+ if (typeof response["status"] === "string") output.rawStopReason = response["status"];
292
+ output.stopReason = mapStopReason(response["status"]);
293
+ if (output.stopReason === "stop" && output.content.some((block) => block.type === "toolCall")) {
294
+ output.stopReason = "toolUse";
295
+ }
296
+ };
297
+
298
+ for await (const event of events) {
299
+ const index = outputIndex(event);
300
+ if (event.type === "response.created" && isObject(event.response)) {
301
+ if (typeof event.response.id === "string") output.responseId = event.response.id;
302
+ } else if (event.type === "response.output_item.added" && isObject(event.item)) {
303
+ createSlot(index, event.item);
304
+ } else if (
305
+ event.type === "response.reasoning_summary_text.delta" ||
306
+ event.type === "response.reasoning_text.delta"
307
+ ) {
308
+ const slot = getSlot(index, "thinking");
309
+ if (!slot || typeof event["delta"] !== "string") continue;
310
+ slot.block.thinking += event["delta"];
311
+ stream.push({
312
+ type: "thinking_delta",
313
+ contentIndex: slot.contentIndex,
314
+ delta: event["delta"],
315
+ partial: output,
316
+ });
317
+ } else if (event.type === "response.reasoning_summary_part.done") {
318
+ const slot = getSlot(index, "thinking");
319
+ if (!slot) continue;
320
+ slot.block.thinking += "\n\n";
321
+ stream.push({
322
+ type: "thinking_delta",
323
+ contentIndex: slot.contentIndex,
324
+ delta: "\n\n",
325
+ partial: output,
326
+ });
327
+ } else if (
328
+ event.type === "response.output_text.delta" ||
329
+ event.type === "response.refusal.delta"
330
+ ) {
331
+ const slot = getSlot(index, "text");
332
+ if (!slot || typeof event["delta"] !== "string") continue;
333
+ slot.block.text += event["delta"];
334
+ stream.push({
335
+ type: "text_delta",
336
+ contentIndex: slot.contentIndex,
337
+ delta: event["delta"],
338
+ partial: output,
339
+ });
340
+ } else if (event.type === "response.function_call_arguments.delta") {
341
+ const slot = getSlot(index, "toolCall");
342
+ if (!slot || slot.block.partialJson === undefined || typeof event["delta"] !== "string") {
343
+ continue;
344
+ }
345
+ slot.block.partialJson += event["delta"];
346
+ slot.block.arguments = parseStreamingJson(slot.block.partialJson);
347
+ pushToolDelta(slot, event["delta"]);
348
+ } else if (event.type === "response.function_call_arguments.done") {
349
+ const slot = getSlot(index, "toolCall");
350
+ if (!slot || slot.block.partialJson === undefined || typeof event.arguments !== "string") {
351
+ continue;
352
+ }
353
+ const previous = slot.block.partialJson;
354
+ slot.block.partialJson = event.arguments;
355
+ slot.block.arguments = parseStreamingJson(event.arguments);
356
+ if (event.arguments.startsWith(previous))
357
+ pushToolDelta(slot, event.arguments.slice(previous.length));
358
+ } else if (event.type === "response.custom_tool_call_input.delta") {
359
+ const slot = getSlot(index, "toolCall");
360
+ if (!slot || typeof event["delta"] !== "string") continue;
361
+ pushToolDelta(
362
+ slot,
363
+ appendCustomInput(slot.block, customInput(slot.block) + event["delta"], false),
364
+ );
365
+ } else if (event.type === "response.custom_tool_call_input.done") {
366
+ const slot = getSlot(index, "toolCall");
367
+ if (!slot || typeof event["input"] !== "string") continue;
368
+ pushToolDelta(slot, appendCustomInput(slot.block, event["input"], true));
369
+ } else if (event.type === "response.output_item.done" && isObject(event.item)) {
370
+ const item = event.item;
371
+ const slot = slotFor(index, item);
372
+ if (item.type === "reasoning" && slot?.type === "thinking") {
373
+ slot.block.thinking = reasoningText(item) || slot.block.thinking;
374
+ slot.block.thinkingSignature = JSON.stringify(item);
375
+ if (typeof item.id === "string") reasoningById.set(item.id, slot.block);
376
+ stream.push({
377
+ type: "thinking_end",
378
+ contentIndex: slot.contentIndex,
379
+ content: slot.block.thinking,
380
+ partial: output,
381
+ });
382
+ slots.delete(index);
383
+ } else if (item.type === "message" && slot?.type === "text") {
384
+ slot.block.text = itemContentText(item);
385
+ if (typeof item.id === "string") {
386
+ slot.block.textSignature = encodeTextSignature(item.id, item["phase"]);
387
+ }
388
+ stream.push({
389
+ type: "text_end",
390
+ contentIndex: slot.contentIndex,
391
+ content: slot.block.text,
392
+ partial: output,
393
+ });
394
+ slots.delete(index);
395
+ } else if (
396
+ item.type === "function_call" &&
397
+ slot?.type === "toolCall" &&
398
+ slot.block.partialJson !== undefined
399
+ ) {
400
+ if (item["namespace"] !== undefined) {
401
+ slot.block.name = namespacedToolCallName(item["namespace"], item.name);
402
+ } else if (typeof item.name === "string" && CODEX_NAMESPACED_TOOL_NAMES.has(item.name)) {
403
+ throw new Error(`Codex returned namespaced tool "${item.name}" as a flat function call.`);
404
+ }
405
+ const argumentsJson =
406
+ typeof item.arguments === "string" ? item.arguments : slot.block.partialJson || "{}";
407
+ slot.block.arguments = parseStreamingJson(argumentsJson);
408
+ delete slot.block.partialJson;
409
+ stream.push({
410
+ type: "toolcall_end",
411
+ contentIndex: slot.contentIndex,
412
+ toolCall: slot.block,
413
+ partial: output,
414
+ });
415
+ slots.delete(index);
416
+ } else if (item.type === "custom_tool_call" && slot?.type === "toolCall") {
417
+ const input = typeof item["input"] === "string" ? item["input"] : customInput(slot.block);
418
+ pushToolDelta(slot, appendCustomInput(slot.block, input, true));
419
+ delete slot.block.customInput;
420
+ stream.push({
421
+ type: "toolcall_end",
422
+ contentIndex: slot.contentIndex,
423
+ toolCall: slot.block,
424
+ partial: output,
425
+ });
426
+ slots.delete(index);
427
+ }
428
+ } else if (
429
+ (event.type === "response.completed" || event.type === "response.incomplete") &&
430
+ isObject(event.response)
431
+ ) {
432
+ finalize(event.response);
433
+ } else if (event.type === "response.failed") {
434
+ terminal = true;
435
+ const response = isObject(event.response) ? event.response : undefined;
436
+ const error = isObject(response?.["error"]) ? response["error"] : undefined;
437
+ throw new Error(
438
+ typeof error?.["message"] === "string" ? error["message"] : "Codex response failed",
439
+ );
440
+ }
441
+ }
442
+
443
+ if (!terminal) throw new Error("Codex stream ended before a terminal response event");
444
+ }
@@ -0,0 +1,186 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { CodexToolBackground } from "./config.ts";
4
+
5
+ export type CodexToolSurfaceStatus = "pending" | "success" | "error";
6
+ export type CodexToolBackgroundResolver = () => CodexToolBackground;
7
+ type ThemeBg =
8
+ | "selectedBg"
9
+ | "userMessageBg"
10
+ | "customMessageBg"
11
+ | "toolPendingBg"
12
+ | "toolSuccessBg"
13
+ | "toolErrorBg";
14
+
15
+ const ANSI_RESET_BACKGROUND = "\u001b[49m";
16
+ const DARK_256_SURFACE_BG = "\u001b[48;5;234m";
17
+ const LIGHT_256_SURFACE_BG = "\u001b[48;5;255m";
18
+ const TRUECOLOR_BACKGROUND_PATTERN = new RegExp(String.raw`\u001b\[48;2;(\d+);(\d+);(\d+)m`);
19
+ const INDEXED_BACKGROUND_PATTERN = new RegExp(String.raw`\u001b\[48;5;(\d+)m`);
20
+ const BACKGROUND_RESET_PATTERN = new RegExp(String.raw`\u001b\[(?:0|49)m`, "g");
21
+
22
+ function xtermChannel(index: number): number {
23
+ return index === 0 ? 0 : 55 + index * 40;
24
+ }
25
+
26
+ function xterm256ToRgb(index: number): [number, number, number] {
27
+ if (index < 16) {
28
+ const standard: Array<[number, number, number]> = [
29
+ [0, 0, 0],
30
+ [128, 0, 0],
31
+ [0, 128, 0],
32
+ [128, 128, 0],
33
+ [0, 0, 128],
34
+ [128, 0, 128],
35
+ [0, 128, 128],
36
+ [192, 192, 192],
37
+ [128, 128, 128],
38
+ [255, 0, 0],
39
+ [0, 255, 0],
40
+ [255, 255, 0],
41
+ [0, 0, 255],
42
+ [255, 0, 255],
43
+ [0, 255, 255],
44
+ [255, 255, 255],
45
+ ];
46
+ return standard[index] ?? [0, 0, 0];
47
+ }
48
+ if (index < 232) {
49
+ const offset = index - 16;
50
+ return [
51
+ xtermChannel(Math.floor(offset / 36)),
52
+ xtermChannel(Math.floor((offset % 36) / 6)),
53
+ xtermChannel(offset % 6),
54
+ ];
55
+ }
56
+ const gray = 8 + (index - 232) * 10;
57
+ return [gray, gray, gray];
58
+ }
59
+
60
+ function backgroundRgb(theme: Theme, background: ThemeBg): [number, number, number] | undefined {
61
+ const ansi = theme.getBgAnsi?.(background);
62
+ if (!ansi) return undefined;
63
+ const truecolor = ansi.match(TRUECOLOR_BACKGROUND_PATTERN);
64
+ if (truecolor) {
65
+ return [Number(truecolor[1]), Number(truecolor[2]), Number(truecolor[3])];
66
+ }
67
+ const indexed = ansi.match(INDEXED_BACKGROUND_PATTERN);
68
+ return indexed ? xterm256ToRgb(Number(indexed[1])) : undefined;
69
+ }
70
+
71
+ export function usesLightToolPalette(theme: Theme): boolean {
72
+ const rgb = backgroundRgb(theme, "toolSuccessBg");
73
+ if (rgb) {
74
+ const red = rgb[0] / 255;
75
+ const green = rgb[1] / 255;
76
+ const blue = rgb[2] / 255;
77
+ const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
78
+ return luminance >= 0.6;
79
+ }
80
+ return theme.name?.toLowerCase().includes("light") ?? false;
81
+ }
82
+
83
+ function blendRgb(
84
+ overlay: [number, number, number],
85
+ background: [number, number, number],
86
+ alpha: number,
87
+ ): [number, number, number] {
88
+ return [
89
+ Math.round(overlay[0] * alpha + background[0] * (1 - alpha)),
90
+ Math.round(overlay[1] * alpha + background[1] * (1 - alpha)),
91
+ Math.round(overlay[2] * alpha + background[2] * (1 - alpha)),
92
+ ];
93
+ }
94
+
95
+ function subtleBackground(theme: Theme): string {
96
+ const light = usesLightToolPalette(theme);
97
+ if (theme.getColorMode?.() === "256color") {
98
+ return light ? LIGHT_256_SURFACE_BG : DARK_256_SURFACE_BG;
99
+ }
100
+
101
+ const base =
102
+ backgroundRgb(theme, "toolPendingBg") ??
103
+ (light ? ([232, 232, 240] as const) : ([40, 40, 50] as const));
104
+ const overlay: [number, number, number] = light ? [255, 255, 255] : [0, 0, 0];
105
+ const alpha = light ? 0.55 : 0.35;
106
+ const [red, green, blue] = blendRgb(overlay, base, alpha);
107
+ return `\u001b[48;2;${red};${green};${blue}m`;
108
+ }
109
+
110
+ function statusBackground(theme: Theme, status: CodexToolSurfaceStatus): string {
111
+ const token: ThemeBg =
112
+ status === "pending" ? "toolPendingBg" : status === "error" ? "toolErrorBg" : "toolSuccessBg";
113
+ return theme.getBgAnsi?.(token) ?? "";
114
+ }
115
+
116
+ function surfaceBackground(
117
+ theme: Theme,
118
+ style: CodexToolBackground,
119
+ status: CodexToolSurfaceStatus,
120
+ ): string {
121
+ if (style === "none") return "";
122
+ return style === "status" ? statusBackground(theme, status) : subtleBackground(theme);
123
+ }
124
+
125
+ function restoreBackgroundAfterResets(text: string, background: string): string {
126
+ if (!background) return text;
127
+ return text.replace(BACKGROUND_RESET_PATTERN, (reset) => `${reset}${background}`);
128
+ }
129
+
130
+ function withBackground(text: string, background: string): string {
131
+ if (!background) return text;
132
+ return `${background}${restoreBackgroundAfterResets(text, background)}${ANSI_RESET_BACKGROUND}`;
133
+ }
134
+
135
+ function fillLine(line: string, width: number, background: string): string {
136
+ const truncated = truncateToWidth(line, width, "");
137
+ const padding = " ".repeat(Math.max(0, width - visibleWidth(truncated)));
138
+ return withBackground(`${truncated}${padding}`, background);
139
+ }
140
+
141
+ export class CodexToolSurfaceComponent implements Component {
142
+ private readonly component: Component;
143
+ private readonly theme: Theme;
144
+ private readonly resolveBackground: CodexToolBackgroundResolver;
145
+ private readonly status: CodexToolSurfaceStatus;
146
+ private readonly topPadding: boolean;
147
+ private readonly bottomPadding: boolean;
148
+
149
+ constructor(
150
+ component: Component,
151
+ theme: Theme,
152
+ options: {
153
+ background: CodexToolBackgroundResolver;
154
+ status: CodexToolSurfaceStatus;
155
+ top: boolean;
156
+ bottom: boolean;
157
+ },
158
+ ) {
159
+ this.component = component;
160
+ this.theme = theme;
161
+ this.resolveBackground = options.background;
162
+ this.status = options.status;
163
+ this.topPadding = options.top;
164
+ this.bottomPadding = options.bottom;
165
+ }
166
+
167
+ render(width: number): string[] {
168
+ const effectiveWidth = Math.max(1, width);
169
+ const background = surfaceBackground(this.theme, this.resolveBackground(), this.status);
170
+ const horizontalPadding = effectiveWidth > 2 ? 1 : 0;
171
+ const contentWidth = Math.max(1, effectiveWidth - horizontalPadding * 2);
172
+ const lines = this.component
173
+ .render(contentWidth)
174
+ .map((line) =>
175
+ fillLine(`${" ".repeat(horizontalPadding)}${line}`, effectiveWidth, background),
176
+ );
177
+ const blankLine = fillLine("", effectiveWidth, background);
178
+ if (this.topPadding) lines.unshift(blankLine);
179
+ if (this.bottomPadding) lines.push(blankLine);
180
+ return lines;
181
+ }
182
+
183
+ invalidate(): void {
184
+ this.component.invalidate();
185
+ }
186
+ }