pi-ui-extend 0.1.46 → 0.1.48
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.
- package/dist/app/rendering/tool-block-renderer.js +4 -1
- package/dist/app/session/tabs-controller.d.ts +1 -0
- package/dist/app/session/tabs-controller.js +15 -3
- package/external/pi-tools-suite/src/codex-reasoning-fix/index.ts +154 -0
- package/external/pi-tools-suite/src/dcp/index.ts +25 -9
- package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +73 -13
- package/external/pi-tools-suite/src/index.ts +1 -0
- package/package.json +1 -1
|
@@ -88,7 +88,10 @@ export function renderToolBlock(entry, rule, width, colors, options = {}) {
|
|
|
88
88
|
}
|
|
89
89
|
function renderCollapsedPreviewLines(entry, body, rule, width, target, color, colors, hasLspDiagnostics, showGutter) {
|
|
90
90
|
const preview = previewBodyText(body, rule.direction, rule.previewLines);
|
|
91
|
-
|
|
91
|
+
// bodyLineStyles is aligned to entry.expandedText (args block prefix + output), but the
|
|
92
|
+
// collapsed body here is the raw output only. Applying bodyLineStyles would misalign and
|
|
93
|
+
// incorrectly dim the first N output lines, so we do not pass it to the collapsed preview.
|
|
94
|
+
const allPreviewLines = renderToolBodyLines(preview.text, width, target, color, entry.bodyStyle, colors, undefined, entry.bodyWrap, hasLspDiagnostics, undefined, entry.preserveAnsi, showGutter, { rawLineOffset: preview.rawLineOffset, bodyEndsAfterText: !preview.omittedAfter });
|
|
92
95
|
const overflow = preview.omittedBefore || preview.omittedAfter || allPreviewLines.length > rule.previewLines;
|
|
93
96
|
if (!overflow)
|
|
94
97
|
return allPreviewLines;
|
|
@@ -107,6 +107,7 @@ export declare class AppTabsController {
|
|
|
107
107
|
private observeRuntimeForTab;
|
|
108
108
|
private shouldSyncTabFromRuntimeEvent;
|
|
109
109
|
private shouldScheduleDelayedSyncForRuntimeEvent;
|
|
110
|
+
private shouldInvalidateCachedViewForRuntimeEvent;
|
|
110
111
|
private scheduleDelayedRuntimeSync;
|
|
111
112
|
private clearRuntimeRefreshTimers;
|
|
112
113
|
private clearHistoryReloadTimers;
|
|
@@ -641,8 +641,7 @@ export class AppTabsController {
|
|
|
641
641
|
void this.saveTabs();
|
|
642
642
|
this.scheduleTabPrewarm();
|
|
643
643
|
const cachedView = this.sessionViewsByTabId.get(target.id);
|
|
644
|
-
const cachedViewNeedsHistoryReload = this.tabIdsNeedingHistoryReload.has(target.id)
|
|
645
|
-
&& this.sessionActivity(targetRuntime.session) !== "running";
|
|
644
|
+
const cachedViewNeedsHistoryReload = this.tabIdsNeedingHistoryReload.has(target.id);
|
|
646
645
|
if (cachedView && this.host.restoreSessionView && !cachedViewNeedsHistoryReload) {
|
|
647
646
|
this.host.restoreSessionView(cachedView);
|
|
648
647
|
this.restoreDeferredUserMessages(target.id);
|
|
@@ -883,9 +882,11 @@ export class AppTabsController {
|
|
|
883
882
|
return;
|
|
884
883
|
existing?.unsubscribe();
|
|
885
884
|
const unsubscribe = runtime.session.subscribe((event) => {
|
|
885
|
+
if (this.shouldInvalidateCachedViewForRuntimeEvent(event)) {
|
|
886
|
+
this.tabIdsNeedingHistoryReload.add(tabId);
|
|
887
|
+
}
|
|
886
888
|
if (this.shouldScheduleDelayedSyncForRuntimeEvent(event)) {
|
|
887
889
|
this.scheduleDelayedRuntimeSync(tabId, runtime);
|
|
888
|
-
this.tabIdsNeedingHistoryReload.add(tabId);
|
|
889
890
|
}
|
|
890
891
|
if (!this.shouldSyncTabFromRuntimeEvent(event))
|
|
891
892
|
return;
|
|
@@ -905,6 +906,17 @@ export class AppTabsController {
|
|
|
905
906
|
|| event.type === "turn_end"
|
|
906
907
|
|| event.type === "compaction_end";
|
|
907
908
|
}
|
|
909
|
+
shouldInvalidateCachedViewForRuntimeEvent(event) {
|
|
910
|
+
return event.type === "message_start"
|
|
911
|
+
|| event.type === "message_update"
|
|
912
|
+
|| event.type === "message_end"
|
|
913
|
+
|| event.type === "tool_execution_start"
|
|
914
|
+
|| event.type === "tool_execution_update"
|
|
915
|
+
|| event.type === "tool_execution_end"
|
|
916
|
+
|| event.type === "agent_end"
|
|
917
|
+
|| event.type === "turn_end"
|
|
918
|
+
|| event.type === "compaction_end";
|
|
919
|
+
}
|
|
908
920
|
scheduleDelayedRuntimeSync(tabId, runtime) {
|
|
909
921
|
this.clearRuntimeRefreshTimers(tabId);
|
|
910
922
|
for (const delayMs of [0, 100, 500, 1500, 3000]) {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WORKAROUND for @earendil-works/pi-ai bug: the Codex / OpenAI Responses API
|
|
3
|
+
* rejects HTTP 400 `Unknown parameter: 'input[N].content'` when non-message
|
|
4
|
+
* items (reasoning, function_call_output) carry a spurious `content` field.
|
|
5
|
+
*
|
|
6
|
+
* Root cause is in pi-ai's `convertResponsesMessages`, which pushes replayed
|
|
7
|
+
* items verbatim including a stray `content` placeholder. The upstream fix is
|
|
8
|
+
* uncommitted in pi-mono and never shipped, so every released pi-ai version
|
|
9
|
+
* (incl. 0.79.10) is affected.
|
|
10
|
+
*
|
|
11
|
+
* The fix must run at the WebSocket.send layer, not at before_provider_request:
|
|
12
|
+
* pi's openai-codex-responses provider uses a websocket-cached transport that
|
|
13
|
+
* rebuilds the body into a delta AFTER the before_provider_request hook, so the
|
|
14
|
+
* hook never sees the bytes actually sent. This module wraps
|
|
15
|
+
* WebSocket.prototype.send once and strips `content` from every non-message
|
|
16
|
+
* input item of each `response.create` frame, on the exact bytes leaving the
|
|
17
|
+
* socket. The before_provider_request hook is kept as a secondary guard for the
|
|
18
|
+
* SSE-fallback transport (full body, no delta).
|
|
19
|
+
*
|
|
20
|
+
* Remove this whole module once an upstream pi-ai release carries the fix.
|
|
21
|
+
*/
|
|
22
|
+
type ExtensionAPI = any;
|
|
23
|
+
|
|
24
|
+
type ProviderRequestEvent = {
|
|
25
|
+
payload?: unknown;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ProviderRequestContext = {
|
|
29
|
+
cwd?: string;
|
|
30
|
+
model?: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
installWireStripper();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wrap WebSocket.prototype.send once. For each `response.create` frame, strip
|
|
37
|
+
* the offending `content` from non-message input items and forward the cleaned
|
|
38
|
+
* frame. Forwards the original untouched on any parse/rewrite failure so the
|
|
39
|
+
* transport never breaks.
|
|
40
|
+
*/
|
|
41
|
+
function installWireStripper(): void {
|
|
42
|
+
try {
|
|
43
|
+
const WS = (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
|
|
44
|
+
if (!WS?.prototype) return;
|
|
45
|
+
const proto = WS.prototype as { send?: unknown };
|
|
46
|
+
if (typeof proto.send !== "function") return;
|
|
47
|
+
const original = proto.send as (data: unknown) => void;
|
|
48
|
+
if ((original as unknown as { __codexFixWrapped?: boolean }).__codexFixWrapped) return;
|
|
49
|
+
|
|
50
|
+
const wrapped = function (this: unknown, data: unknown): void {
|
|
51
|
+
let outgoing = data;
|
|
52
|
+
if (typeof data === "string") {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(data);
|
|
55
|
+
const result = stripContentFromWireFrame(parsed);
|
|
56
|
+
if (result) {
|
|
57
|
+
outgoing = JSON.stringify(result.frame);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Non-JSON or malformed: forward untouched.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return original.call(this, outgoing);
|
|
64
|
+
};
|
|
65
|
+
(wrapped as unknown as { __codexFixWrapped?: boolean }).__codexFixWrapped = true;
|
|
66
|
+
proto.send = wrapped;
|
|
67
|
+
} catch {
|
|
68
|
+
// best-effort; never break on setup
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* If `frame` is a Codex `response.create` carrying an `input` (or `messages`)
|
|
74
|
+
* array, return the cleaned frame (content stripped from every non-message
|
|
75
|
+
* item) plus a tally of how many items were stripped. Returns `undefined`
|
|
76
|
+
* when nothing matched (caller forwards the original frame untouched). The
|
|
77
|
+
* returned `frame` is a clean Codex payload with no extra fields.
|
|
78
|
+
*/
|
|
79
|
+
export function stripContentFromWireFrame(frame: unknown): { frame: Record<string, unknown>; stripped: number } | undefined {
|
|
80
|
+
if (!isRecord(frame) || frame.type !== "response.create") return undefined;
|
|
81
|
+
const field = Array.isArray(frame.input) ? "input" : Array.isArray(frame.messages) ? "messages" : null;
|
|
82
|
+
if (!field) return undefined;
|
|
83
|
+
|
|
84
|
+
const list = frame[field] as unknown[];
|
|
85
|
+
let stripped = 0;
|
|
86
|
+
let changed = false;
|
|
87
|
+
const next = new Array(list.length);
|
|
88
|
+
for (let i = 0; i < list.length; i++) {
|
|
89
|
+
const item = list[i];
|
|
90
|
+
// Only items with an explicit `type` that is NOT "message" can carry a
|
|
91
|
+
// spurious `content`. Role-based messages (no `type`) and typed
|
|
92
|
+
// messages (`type:"message"`) legitimately hold content — leave them.
|
|
93
|
+
if (
|
|
94
|
+
isRecord(item) &&
|
|
95
|
+
typeof item.type === "string" &&
|
|
96
|
+
item.type !== "message" &&
|
|
97
|
+
Object.prototype.hasOwnProperty.call(item, "content")
|
|
98
|
+
) {
|
|
99
|
+
const { content: _drop, ...rest } = item;
|
|
100
|
+
next[i] = rest;
|
|
101
|
+
stripped++;
|
|
102
|
+
changed = true;
|
|
103
|
+
} else {
|
|
104
|
+
next[i] = item;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!changed) return undefined;
|
|
108
|
+
return { frame: { ...frame, [field]: next }, stripped };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export default function codexReasoningFix(pi: ExtensionAPI): void {
|
|
112
|
+
pi.on("before_provider_request", async (event: ProviderRequestEvent, _ctx: ProviderRequestContext) => {
|
|
113
|
+
const result = stripReasoningContentFromPayload(event.payload);
|
|
114
|
+
return result === event.payload ? undefined : result;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Strip spurious `content` from non-message items in a full payload. Secondary
|
|
120
|
+
* guard for the SSE-fallback / non-websocket path. Same rule as the wire
|
|
121
|
+
* stripper; exported for unit testing.
|
|
122
|
+
*/
|
|
123
|
+
export function stripReasoningContentFromPayload(payload: unknown): unknown {
|
|
124
|
+
if (!isRecord(payload)) return payload;
|
|
125
|
+
const inputList = Array.isArray(payload.input) ? payload.input : undefined;
|
|
126
|
+
const messagesList = Array.isArray(payload.messages) ? payload.messages : undefined;
|
|
127
|
+
const field = inputList ? "input" : messagesList ? "messages" : undefined;
|
|
128
|
+
const list = inputList ?? messagesList;
|
|
129
|
+
if (!field || !list) return payload;
|
|
130
|
+
|
|
131
|
+
let changed = false;
|
|
132
|
+
const next = new Array(list.length);
|
|
133
|
+
for (let i = 0; i < list.length; i++) {
|
|
134
|
+
const item = list[i];
|
|
135
|
+
if (
|
|
136
|
+
isRecord(item) &&
|
|
137
|
+
typeof item.type === "string" &&
|
|
138
|
+
item.type !== "message" &&
|
|
139
|
+
Object.prototype.hasOwnProperty.call(item, "content")
|
|
140
|
+
) {
|
|
141
|
+
const { content: _drop, ...rest } = item;
|
|
142
|
+
next[i] = rest;
|
|
143
|
+
changed = true;
|
|
144
|
+
} else {
|
|
145
|
+
next[i] = item;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!changed) return payload;
|
|
149
|
+
return { ...payload, [field]: next };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
153
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
154
|
+
}
|
|
@@ -123,12 +123,28 @@ function appendTextToContent(content: unknown, text: string): unknown {
|
|
|
123
123
|
|
|
124
124
|
function appendDcpControlToMessages(messages: unknown, text: string): unknown {
|
|
125
125
|
if (!Array.isArray(messages)) return messages
|
|
126
|
-
const existingIndex = messages.findIndex((message: any) =>
|
|
127
|
-
message?.role === "system" || message?.role === "developer"
|
|
128
|
-
)
|
|
129
126
|
const block = `${DCP_PROVIDER_CONTROL_HEADER}\n${text}`
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
|
|
128
|
+
// Keep DCP's volatile ID map at the tail of the provider context instead of
|
|
129
|
+
// mutating the stable system/developer prefix. Prefix cache reuse is much
|
|
130
|
+
// better when only the newest transcript item changes on each request.
|
|
131
|
+
let targetIndex = -1
|
|
132
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
133
|
+
const message = messages[index] as any
|
|
134
|
+
if (!message || typeof message !== "object") continue
|
|
135
|
+
if (message.role === "system" || message.role === "developer") continue
|
|
136
|
+
targetIndex = index
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (targetIndex < 0) {
|
|
141
|
+
targetIndex = messages.findIndex((message: any) =>
|
|
142
|
+
message?.role === "system" || message?.role === "developer"
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (targetIndex >= 0) {
|
|
147
|
+
return messages.map((message: any, index) => index === targetIndex
|
|
132
148
|
? { ...message, content: appendTextToContent(message.content, block) }
|
|
133
149
|
: message)
|
|
134
150
|
}
|
|
@@ -155,10 +171,6 @@ function appendDcpControlToProviderPayload(payload: unknown, text: string): unkn
|
|
|
155
171
|
if (!payload || typeof payload !== "object") return payload
|
|
156
172
|
const record = payload as Record<string, unknown>
|
|
157
173
|
|
|
158
|
-
if ("system" in record) {
|
|
159
|
-
return { ...record, system: appendDcpControlToAnthropicSystem(record.system, text) }
|
|
160
|
-
}
|
|
161
|
-
|
|
162
174
|
if (Array.isArray(record.input)) {
|
|
163
175
|
return { ...record, input: appendDcpControlToMessages(record.input, text) }
|
|
164
176
|
}
|
|
@@ -167,6 +179,10 @@ function appendDcpControlToProviderPayload(payload: unknown, text: string): unkn
|
|
|
167
179
|
return { ...record, messages: appendDcpControlToMessages(record.messages, text) }
|
|
168
180
|
}
|
|
169
181
|
|
|
182
|
+
if ("system" in record) {
|
|
183
|
+
return { ...record, system: appendDcpControlToAnthropicSystem(record.system, text) }
|
|
184
|
+
}
|
|
185
|
+
|
|
170
186
|
if (record.config && typeof record.config === "object") {
|
|
171
187
|
const config = record.config as Record<string, unknown>
|
|
172
188
|
return {
|
|
@@ -46,27 +46,87 @@ function priorityForMessage(tokenEstimate: number, config: DcpConfig | undefined
|
|
|
46
46
|
return "low";
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function
|
|
50
|
-
const
|
|
51
|
-
if (!
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
49
|
+
function messageIdIndex(id: string): number | undefined {
|
|
50
|
+
const match = /^m(\d+)$/.exec(id);
|
|
51
|
+
if (!match) return undefined;
|
|
52
|
+
const index = Number.parseInt(match[1]!, 10);
|
|
53
|
+
return Number.isFinite(index) ? index : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function summarizeMessageIds(ids: string[]): string {
|
|
57
|
+
if (ids.length === 0) return "none";
|
|
58
|
+
|
|
59
|
+
const ranges: string[] = [];
|
|
60
|
+
let start = ids[0]!;
|
|
61
|
+
let previous = ids[0]!;
|
|
62
|
+
let previousIndex = messageIdIndex(previous);
|
|
63
|
+
|
|
64
|
+
const flush = () => {
|
|
65
|
+
const startIndex = messageIdIndex(start);
|
|
66
|
+
const endIndex = messageIdIndex(previous);
|
|
67
|
+
if (
|
|
68
|
+
startIndex !== undefined &&
|
|
69
|
+
endIndex !== undefined &&
|
|
70
|
+
endIndex - startIndex >= 2
|
|
71
|
+
) {
|
|
72
|
+
ranges.push(`${start}..${previous}`);
|
|
73
|
+
} else if (start === previous) {
|
|
74
|
+
ranges.push(start);
|
|
75
|
+
} else {
|
|
76
|
+
ranges.push(start, previous);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
for (let i = 1; i < ids.length; i++) {
|
|
81
|
+
const id = ids[i]!;
|
|
82
|
+
const index = messageIdIndex(id);
|
|
83
|
+
if (previousIndex !== undefined && index === previousIndex + 1) {
|
|
84
|
+
previous = id;
|
|
85
|
+
previousIndex = index;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
flush();
|
|
90
|
+
start = id;
|
|
91
|
+
previous = id;
|
|
92
|
+
previousIndex = index;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
flush();
|
|
96
|
+
return ranges.join(", ");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function compactPriorityHint(ids: string[], state: DcpState): string | undefined {
|
|
100
|
+
const high: string[] = [];
|
|
101
|
+
const medium: string[] = [];
|
|
102
|
+
const blocks: string[] = [];
|
|
103
|
+
|
|
104
|
+
for (const id of ids) {
|
|
105
|
+
const meta = state.messageMetaSnapshot.get(id);
|
|
106
|
+
if (!meta) continue;
|
|
107
|
+
if (meta.priority === "high") high.push(id);
|
|
108
|
+
else if (meta.priority === "medium") medium.push(id);
|
|
109
|
+
if (meta.blockId !== undefined) blocks.push(`${id}=b${meta.blockId}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const parts: string[] = [];
|
|
113
|
+
if (high.length > 0) parts.push(`high=${summarizeMessageIds(high)}`);
|
|
114
|
+
if (medium.length > 0) parts.push(`medium=${summarizeMessageIds(medium)}`);
|
|
115
|
+
if (blocks.length > 0) parts.push(`blocks=${blocks.join(",")}`);
|
|
116
|
+
return parts.length > 0 ? `Hints: ${parts.join("; ")}` : undefined;
|
|
58
117
|
}
|
|
59
118
|
|
|
60
119
|
export function buildMessageIdControlText(state: DcpState): string | undefined {
|
|
61
120
|
const ids = [...state.messageIdSnapshot.keys()];
|
|
62
121
|
if (ids.length === 0) return undefined;
|
|
63
122
|
|
|
123
|
+
const hint = compactPriorityHint(ids, state);
|
|
124
|
+
|
|
64
125
|
return [
|
|
65
126
|
"<dcp-message-ids>",
|
|
66
|
-
"DCP metadata for the preceding conversation messages.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
...ids.map((id) => controlLineForMessageId(id, state)),
|
|
127
|
+
"DCP metadata for the preceding conversation messages. IDs follow current message order; use only these IDs with compress; do not quote/output.",
|
|
128
|
+
`Current raw message IDs: ${summarizeMessageIds(ids)}${ids.length > 2 ? ` (${ids.length} messages)` : ""}`,
|
|
129
|
+
...(hint ? [hint] : []),
|
|
70
130
|
"</dcp-message-ids>",
|
|
71
131
|
].join("\n");
|
|
72
132
|
}
|
|
@@ -10,6 +10,7 @@ type ExtensionModule = {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule> }> = [
|
|
13
|
+
{ name: "codex-reasoning-fix", load: () => import("./codex-reasoning-fix/index") },
|
|
13
14
|
{ name: "coding-discipline", load: () => import("./coding-discipline/index") },
|
|
14
15
|
{ name: "ast-grep", load: () => import("./ast-grep/index") },
|
|
15
16
|
{ name: "async-subagents", load: () => import("./async-subagents/index") },
|