@oh-my-pi/pi-coding-agent 16.3.12 → 16.3.13
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/CHANGELOG.md +16 -0
- package/dist/cli.js +3244 -3246
- package/dist/types/config/keybindings.d.ts +9 -4
- package/dist/types/config/settings.d.ts +1 -1
- package/dist/types/extensibility/extensions/types.d.ts +11 -2
- package/dist/types/mnemopi/state.d.ts +7 -3
- package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
- package/dist/types/modes/components/read-tool-group.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -1
- package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +3 -4
- package/dist/types/tools/read.d.ts +1 -0
- package/dist/types/tools/renderers.d.ts +12 -5
- package/dist/types/tools/ssh.d.ts +4 -1
- package/dist/types/tools/write.d.ts +1 -0
- package/package.json +12 -12
- package/src/config/keybindings.ts +62 -10
- package/src/config/model-registry.ts +85 -20
- package/src/config/settings.ts +48 -21
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/extensibility/extensions/types.ts +13 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mnemopi/state.ts +19 -5
- package/src/modes/acp/acp-agent.ts +69 -8
- package/src/modes/acp/acp-event-mapper.ts +1 -1
- package/src/modes/components/read-tool-group.ts +5 -1
- package/src/modes/components/tool-execution.ts +28 -24
- package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +5 -55
- package/src/modes/interactive-mode.ts +43 -2
- package/src/modes/rpc/rpc-client.ts +42 -13
- package/src/modes/rpc/rpc-mode.ts +21 -19
- package/src/modes/types.ts +3 -0
- package/src/prompts/agents/plan.md +0 -1
- package/src/prompts/agents/reviewer.md +0 -1
- package/src/prompts/tools/grep.md +2 -1
- package/src/prompts/tools/memory-edit.md +2 -0
- package/src/session/agent-session.ts +8 -5
- package/src/tools/grep.ts +58 -13
- package/src/tools/image-gen.ts +1 -1
- package/src/tools/memory-edit.ts +3 -1
- package/src/tools/read.ts +33 -13
- package/src/tools/renderers.ts +13 -5
- package/src/tools/ssh.ts +10 -3
- package/src/tools/tts.ts +1 -1
- package/src/tools/write.ts +26 -0
package/src/mnemopi/state.ts
CHANGED
|
@@ -108,11 +108,16 @@ export interface MnemopiMemoryEditOptions {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
export interface MnemopiMemoryEditResult {
|
|
111
|
-
status: "updated" | "deleted" | "invalidated" | "not_found";
|
|
111
|
+
status: "updated" | "deleted" | "invalidated" | "not_found" | "not_editable";
|
|
112
112
|
bank?: string;
|
|
113
|
-
store?:
|
|
113
|
+
store?: MnemopiMemoryStore;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** Which mnemopi table a resolved memory id lives in. `fact` rows are
|
|
117
|
+
* read-only projections of fact extraction (issue #4725): resolvable for
|
|
118
|
+
* reads, never editable. */
|
|
119
|
+
export type MnemopiMemoryStore = "working" | "episodic" | "fact";
|
|
120
|
+
|
|
116
121
|
interface MnemopiStoredMemoryRow {
|
|
117
122
|
id?: unknown;
|
|
118
123
|
content?: unknown;
|
|
@@ -136,7 +141,7 @@ interface MnemopiStoredMemoryRow {
|
|
|
136
141
|
*/
|
|
137
142
|
export interface MnemopiScopedMemoryHit {
|
|
138
143
|
bank: string;
|
|
139
|
-
store:
|
|
144
|
+
store: MnemopiMemoryStore;
|
|
140
145
|
row: {
|
|
141
146
|
id: string;
|
|
142
147
|
content: string;
|
|
@@ -256,7 +261,8 @@ export class MnemopiSessionState {
|
|
|
256
261
|
for (const target of targets) {
|
|
257
262
|
const raw = target.memory.get(id) as MnemopiStoredMemoryRow | null;
|
|
258
263
|
if (!raw) continue;
|
|
259
|
-
const store:
|
|
264
|
+
const store: MnemopiMemoryStore =
|
|
265
|
+
raw.memory_store === "episodic" || raw.memory_store === "fact" ? raw.memory_store : "working";
|
|
260
266
|
return {
|
|
261
267
|
bank: target.bank,
|
|
262
268
|
store,
|
|
@@ -291,8 +297,16 @@ export class MnemopiSessionState {
|
|
|
291
297
|
for (const target of targets) {
|
|
292
298
|
const row = target.memory.get(id) as MnemopiStoredMemoryRow | null;
|
|
293
299
|
if (!row) continue;
|
|
294
|
-
const store:
|
|
300
|
+
const store: MnemopiMemoryStore =
|
|
301
|
+
row.memory_store === "episodic" || row.memory_store === "fact" ? row.memory_store : "working";
|
|
295
302
|
const resultContext: Pick<MnemopiMemoryEditResult, "bank" | "store"> = { bank: target.bank, store };
|
|
303
|
+
if (store === "fact") {
|
|
304
|
+
// Facts are read-only: no memory_edit op mutates the facts
|
|
305
|
+
// table, so report that precisely instead of `not_found`
|
|
306
|
+
// (the id DID resolve — issue #4725).
|
|
307
|
+
ineligible ??= { status: "not_editable", ...resultContext };
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
296
310
|
if ((op === "update" || op === "forget") && store !== "working") {
|
|
297
311
|
ineligible ??= { status: "not_found", ...resultContext };
|
|
298
312
|
continue;
|
|
@@ -84,6 +84,7 @@ import { canonicalizeMessage } from "../../utils/thinking-display";
|
|
|
84
84
|
import { createAcpClientBridge } from "./acp-client-bridge";
|
|
85
85
|
import {
|
|
86
86
|
buildToolCallStartUpdate,
|
|
87
|
+
extractAssistantMessageText,
|
|
87
88
|
mapAgentSessionEventToAcpSessionUpdates,
|
|
88
89
|
normalizeReplayToolArguments,
|
|
89
90
|
} from "./acp-event-mapper";
|
|
@@ -425,6 +426,7 @@ export function createAcpExtensionUiContext(
|
|
|
425
426
|
setEditorText: () => {},
|
|
426
427
|
getEditorText: () => "",
|
|
427
428
|
editor: async () => undefined,
|
|
429
|
+
addAutocompleteProvider: () => {},
|
|
428
430
|
setEditorComponent: () => {},
|
|
429
431
|
get theme() {
|
|
430
432
|
return theme;
|
|
@@ -843,13 +845,16 @@ export class AcpAgent implements Agent {
|
|
|
843
845
|
return false;
|
|
844
846
|
}
|
|
845
847
|
const built = await buildSkillPromptMessage(skill, parsed.args, "user");
|
|
846
|
-
await record.session.promptCustomMessage(
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
848
|
+
await record.session.promptCustomMessage(
|
|
849
|
+
{
|
|
850
|
+
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
|
851
|
+
content: built.message,
|
|
852
|
+
display: true,
|
|
853
|
+
details: built.details,
|
|
854
|
+
attribution: "user",
|
|
855
|
+
},
|
|
856
|
+
{ streamingBehavior: "steer" },
|
|
857
|
+
);
|
|
853
858
|
return true;
|
|
854
859
|
}
|
|
855
860
|
|
|
@@ -1210,8 +1215,11 @@ export class AcpAgent implements Agent {
|
|
|
1210
1215
|
this.#clearLiveAssistantMessageAfterEvent(record, event);
|
|
1211
1216
|
|
|
1212
1217
|
if (event.type === "agent_end") {
|
|
1218
|
+
await this.#flushMissedFinalAssistantText(record, event);
|
|
1213
1219
|
await this.#emitEndOfTurnUpdates(record);
|
|
1214
1220
|
await this.#waitForAcpPromptIdle(record);
|
|
1221
|
+
record.liveMessageId = undefined;
|
|
1222
|
+
record.liveMessageProgress = undefined;
|
|
1215
1223
|
this.#finishPrompt(record, {
|
|
1216
1224
|
stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
|
|
1217
1225
|
usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
|
|
@@ -1219,6 +1227,51 @@ export class AcpAgent implements Agent {
|
|
|
1219
1227
|
}
|
|
1220
1228
|
}
|
|
1221
1229
|
|
|
1230
|
+
/**
|
|
1231
|
+
* Deliver the final visible answer when the assistant `message_end` never
|
|
1232
|
+
* reached this prompt turn's subscription. Session event handlers are
|
|
1233
|
+
* fire-and-forget (`Agent#emit` does not await async listeners), and
|
|
1234
|
+
* `agent_end` is flushed through the session's `#endInFlight` path while the
|
|
1235
|
+
* assistant `message_end` fan-out can still be parked on extension delivery —
|
|
1236
|
+
* so `agent_end` can overtake `message_end`. Once the turn finishes,
|
|
1237
|
+
* `#finishPrompt` unsubscribes and the fallback text emission in
|
|
1238
|
+
* `mapAssistantMessageEnd` is lost for good: a client that only received
|
|
1239
|
+
* `agent_thought_chunk`s stays stuck on the thinking block (#4902). The live
|
|
1240
|
+
* message progress records whether visible text ever reached the client; if
|
|
1241
|
+
* it has not, emit the last assistant message's text before the prompt
|
|
1242
|
+
* resolves. A `message_end` that lands during the end-of-turn waits still
|
|
1243
|
+
* takes the normal mapper path and sees `textEmitted` already set, so the
|
|
1244
|
+
* answer is delivered exactly once.
|
|
1245
|
+
*/
|
|
1246
|
+
async #flushMissedFinalAssistantText(
|
|
1247
|
+
record: ManagedSessionRecord,
|
|
1248
|
+
event: Extract<AgentSessionEvent, { type: "agent_end" }>,
|
|
1249
|
+
): Promise<void> {
|
|
1250
|
+
const progress = record.liveMessageProgress;
|
|
1251
|
+
if (!progress || progress.textEmitted) {
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const lastAssistant = [...event.messages]
|
|
1255
|
+
.reverse()
|
|
1256
|
+
.find((message): message is AssistantMessage => message.role === "assistant");
|
|
1257
|
+
if (!lastAssistant) {
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const text = extractAssistantMessageText(lastAssistant);
|
|
1261
|
+
if (text.length === 0) {
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
progress.textEmitted = true;
|
|
1265
|
+
await this.#connection.sessionUpdate({
|
|
1266
|
+
sessionId: record.session.sessionId,
|
|
1267
|
+
update: {
|
|
1268
|
+
sessionUpdate: "agent_message_chunk",
|
|
1269
|
+
content: { type: "text", text },
|
|
1270
|
+
messageId: record.liveMessageId,
|
|
1271
|
+
},
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1222
1275
|
async #waitForAcpPromptIdle(record: ManagedSessionRecord): Promise<void> {
|
|
1223
1276
|
for (let pass = 0; pass < ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES; pass++) {
|
|
1224
1277
|
await record.session.waitForIdle();
|
|
@@ -1244,8 +1297,16 @@ export class AcpAgent implements Agent {
|
|
|
1244
1297
|
}
|
|
1245
1298
|
}
|
|
1246
1299
|
|
|
1300
|
+
/**
|
|
1301
|
+
* Reset live-message tracking once the assistant `message_end` is handled.
|
|
1302
|
+
* The `agent_end` reset happens inside the `agent_end` branch of
|
|
1303
|
+
* `#handlePromptEvent` — after `#flushMissedFinalAssistantText` — so a
|
|
1304
|
+
* `message_end` that arrives during the end-of-turn waits maps against the
|
|
1305
|
+
* real progress instead of resurrecting a fresh one (which would double-emit
|
|
1306
|
+
* the final answer).
|
|
1307
|
+
*/
|
|
1247
1308
|
#clearLiveAssistantMessageAfterEvent(record: ManagedSessionRecord, event: AgentSessionEvent): void {
|
|
1248
|
-
if (
|
|
1309
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
1249
1310
|
record.liveMessageId = undefined;
|
|
1250
1311
|
record.liveMessageProgress = undefined;
|
|
1251
1312
|
}
|
|
@@ -922,7 +922,7 @@ function isTerminalOnlyDetails(value: unknown): boolean {
|
|
|
922
922
|
return content === undefined || (Array.isArray(content) && content.length === 0);
|
|
923
923
|
}
|
|
924
924
|
|
|
925
|
-
function extractAssistantMessageText(value: unknown): string {
|
|
925
|
+
export function extractAssistantMessageText(value: unknown): string {
|
|
926
926
|
if (typeof value !== "object" || value === null || !("content" in value)) {
|
|
927
927
|
return "";
|
|
928
928
|
}
|
|
@@ -37,6 +37,7 @@ export function readArgsTargetInternalUrl(args: unknown): boolean {
|
|
|
37
37
|
type ReadRenderArgs = {
|
|
38
38
|
path?: string;
|
|
39
39
|
file_path?: string;
|
|
40
|
+
selector?: string;
|
|
40
41
|
// Legacy field from the old schema; tolerated for rebuilt transcripts.
|
|
41
42
|
sel?: string;
|
|
42
43
|
};
|
|
@@ -344,7 +345,10 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
|
|
|
344
345
|
updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
|
|
345
346
|
if (!toolCallId) return;
|
|
346
347
|
const basePath = args.file_path || args.path || "";
|
|
347
|
-
const
|
|
348
|
+
const rawSelector =
|
|
349
|
+
typeof args.selector === "string" ? args.selector : typeof args.sel === "string" ? args.sel : undefined;
|
|
350
|
+
const selector = rawSelector?.trim().replace(/^:+/, "");
|
|
351
|
+
const rawPath = selector && selector.length > 0 ? `${basePath}:${selector}` : basePath;
|
|
348
352
|
const entry: ReadEntry = this.#entries.get(toolCallId) ?? {
|
|
349
353
|
toolCallId,
|
|
350
354
|
path: rawPath,
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
resolveImageOptions,
|
|
39
39
|
truncateToWidth,
|
|
40
40
|
} from "../../tools/render-utils";
|
|
41
|
-
import { toolRenderers } from "../../tools/renderers";
|
|
41
|
+
import { type FirstResultViewportRepaint, toolRenderers } from "../../tools/renderers";
|
|
42
42
|
import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
|
|
43
43
|
import { isFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
|
|
44
44
|
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
|
@@ -283,13 +283,13 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
283
283
|
// history, so progress renders static gray and further partial snapshots are
|
|
284
284
|
// dropped (see #maybeFreezeBackgroundTask).
|
|
285
285
|
#backgroundTaskFrozen = false;
|
|
286
|
-
// Set on each `render()` when the last painted shape
|
|
287
|
-
//
|
|
288
|
-
// so a topology-changing update that lands before the shape reaches
|
|
289
|
-
// terminal never triggers a full-viewport replay (which on direct
|
|
290
|
-
// wipes native scrollback and flashes the user's history —
|
|
291
|
-
// PR #4315).
|
|
292
|
-
#
|
|
286
|
+
// Set on each `render()` when the last painted pending shape must be
|
|
287
|
+
// replayed wholesale when the first result arrives. Reset gates key off
|
|
288
|
+
// these so a topology-changing update that lands before the shape reaches
|
|
289
|
+
// the terminal never triggers a full-viewport replay (which on direct
|
|
290
|
+
// terminals wipes native scrollback and flashes the user's history —
|
|
291
|
+
// reviewer note on PR #4315).
|
|
292
|
+
#firstResultViewportRepaintShapePainted = false;
|
|
293
293
|
#partialResultShapePainted = false;
|
|
294
294
|
#renderState: {
|
|
295
295
|
spinnerFrame?: number;
|
|
@@ -497,9 +497,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
497
497
|
}
|
|
498
498
|
const hadNoResult = this.#result === undefined;
|
|
499
499
|
const wasPartialResult = this.#result !== undefined && this.#isPartial;
|
|
500
|
-
const
|
|
500
|
+
const firstResultRepaintShapePainted = this.#firstResultViewportRepaintShapePainted;
|
|
501
501
|
const partialResultPainted = this.#partialResultShapePainted;
|
|
502
|
-
this.#
|
|
502
|
+
this.#firstResultViewportRepaintShapePainted = false;
|
|
503
503
|
this.#partialResultShapePainted = false;
|
|
504
504
|
this.#result = result;
|
|
505
505
|
this.#resultVersion++;
|
|
@@ -513,7 +513,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
513
513
|
this.#updateTodoStrikeAnimation();
|
|
514
514
|
this.#updateDisplay();
|
|
515
515
|
this.#resetDisplayForResultTopologyChange(
|
|
516
|
-
hadNoResult &&
|
|
516
|
+
hadNoResult && firstResultRepaintShapePainted,
|
|
517
517
|
wasPartialResult && partialResultPainted,
|
|
518
518
|
isPartial,
|
|
519
519
|
);
|
|
@@ -810,34 +810,38 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
810
810
|
this.#displayBuilt = true;
|
|
811
811
|
}
|
|
812
812
|
|
|
813
|
-
#rendererFlag(name: "
|
|
813
|
+
#rendererFlag(name: "forceResultViewportRepaintOnSettle"): boolean {
|
|
814
814
|
const toolValue = (this.#tool as Record<string, unknown> | undefined)?.[name];
|
|
815
815
|
const rendererValue = toolRenderers[this.#toolName]?.[name];
|
|
816
816
|
return toolValue === true || (toolValue === undefined && rendererValue === true);
|
|
817
817
|
}
|
|
818
818
|
|
|
819
819
|
/**
|
|
820
|
-
* True while the last painted shape
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
820
|
+
* True while the last painted pending-call shape opted into a full viewport
|
|
821
|
+
* repaint at the first result (`forceFirstResultViewportRepaint`) — e.g. the
|
|
822
|
+
* streamed SSH placeholder (`⏳ SSH: […]` / `$ …`) or a collapsed write tail
|
|
823
|
+
* window, both of which the first result render re-anchors instead of
|
|
824
|
+
* preserving. Kept as a per-paint fact so a topology-changing update that
|
|
825
|
+
* lands before the pending rows reach the terminal skips the reset.
|
|
824
826
|
*/
|
|
825
|
-
#
|
|
827
|
+
#needsFirstResultViewportRepaintAtRender(): boolean {
|
|
826
828
|
if (this.#result !== undefined) return false;
|
|
827
|
-
|
|
828
|
-
|
|
829
|
+
const toolValue = (this.#tool as { forceFirstResultViewportRepaint?: FirstResultViewportRepaint } | undefined)
|
|
830
|
+
?.forceFirstResultViewportRepaint;
|
|
831
|
+
const value =
|
|
832
|
+
toolValue !== undefined ? toolValue : toolRenderers[this.#toolName]?.forceFirstResultViewportRepaint;
|
|
833
|
+
if (typeof value === "function") return value(this.#args, this.#renderState);
|
|
834
|
+
return value === true;
|
|
829
835
|
}
|
|
830
836
|
|
|
831
837
|
#resetDisplayForResultTopologyChange(
|
|
832
|
-
|
|
838
|
+
firstResultAfterRepaintShapePaint: boolean,
|
|
833
839
|
partialResultPaintedBeforeSettle: boolean,
|
|
834
840
|
isPartial: boolean,
|
|
835
841
|
): void {
|
|
836
|
-
const firstResultReplacesStreamedPlaceholder =
|
|
837
|
-
firstResultAfterPlaceholderPaint && this.#rendererFlag("forceFirstResultViewportRepaint");
|
|
838
842
|
const provisionalResultSettled =
|
|
839
843
|
partialResultPaintedBeforeSettle && !isPartial && this.#rendererFlag("forceResultViewportRepaintOnSettle");
|
|
840
|
-
if (
|
|
844
|
+
if (firstResultAfterRepaintShapePaint || provisionalResultSettled) {
|
|
841
845
|
this.#ui.resetDisplay();
|
|
842
846
|
}
|
|
843
847
|
}
|
|
@@ -848,7 +852,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
848
852
|
// override runs on every compose the parent Container performs, so a
|
|
849
853
|
// frame that never gets composed leaves the flags false and prevents a
|
|
850
854
|
// spurious `resetDisplay()`.
|
|
851
|
-
this.#
|
|
855
|
+
this.#firstResultViewportRepaintShapePainted = this.#needsFirstResultViewportRepaintAtRender();
|
|
852
856
|
this.#partialResultShapePainted = this.#result !== undefined && this.#isPartial;
|
|
853
857
|
return lines;
|
|
854
858
|
}
|
|
@@ -8,6 +8,7 @@ import { ExtensionUiController } from "./extension-ui-controller";
|
|
|
8
8
|
function makeHarness() {
|
|
9
9
|
const editor = new CustomEditor(getEditorTheme());
|
|
10
10
|
const requestRender = vi.fn();
|
|
11
|
+
const addAutocompleteProvider = vi.fn();
|
|
11
12
|
let uiContext: ExtensionUIContext | undefined;
|
|
12
13
|
const ctx = {
|
|
13
14
|
editor,
|
|
@@ -21,11 +22,13 @@ function makeHarness() {
|
|
|
21
22
|
expect(hasUI).toBe(true);
|
|
22
23
|
uiContext = context;
|
|
23
24
|
},
|
|
25
|
+
addAutocompleteProvider,
|
|
24
26
|
} as unknown as InteractiveModeContext;
|
|
25
27
|
|
|
26
28
|
return {
|
|
27
29
|
editor,
|
|
28
30
|
requestRender,
|
|
31
|
+
addAutocompleteProvider,
|
|
29
32
|
async init(): Promise<ExtensionUIContext> {
|
|
30
33
|
await new ExtensionUiController(ctx).initHooksAndCustomTools();
|
|
31
34
|
expect(uiContext).toBeDefined();
|
|
@@ -55,4 +58,17 @@ describe("ExtensionUiController editor UI", () => {
|
|
|
55
58
|
expect(harness.editor.getText()).toBe("hello");
|
|
56
59
|
expect(harness.requestRender).toHaveBeenCalledTimes(1);
|
|
57
60
|
});
|
|
61
|
+
|
|
62
|
+
it("bridges addAutocompleteProvider factories to the interactive mode context (#4919)", async () => {
|
|
63
|
+
const harness = makeHarness();
|
|
64
|
+
const ui = await harness.init();
|
|
65
|
+
|
|
66
|
+
expect(typeof ui.addAutocompleteProvider).toBe("function");
|
|
67
|
+
|
|
68
|
+
const factory = (current: unknown) => current as never;
|
|
69
|
+
ui.addAutocompleteProvider(factory);
|
|
70
|
+
|
|
71
|
+
expect(harness.addAutocompleteProvider).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(harness.addAutocompleteProvider).toHaveBeenCalledWith(factory);
|
|
73
|
+
});
|
|
58
74
|
});
|
|
@@ -82,6 +82,7 @@ export class ExtensionUiController {
|
|
|
82
82
|
getEditorText: () => this.ctx.editor.getText(),
|
|
83
83
|
editor: (title, prefill, dialogOptions, editorOptions) =>
|
|
84
84
|
this.showCollabAwareEditor(title, prefill, dialogOptions, editorOptions),
|
|
85
|
+
addAutocompleteProvider: factory => this.ctx.addAutocompleteProvider(factory),
|
|
85
86
|
get theme() {
|
|
86
87
|
return theme;
|
|
87
88
|
},
|
|
@@ -154,7 +154,6 @@ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
|
|
|
154
154
|
// deliberate human double-tap is always tens of milliseconds apart.
|
|
155
155
|
const LEFT_DOUBLE_TAP_MIN_GAP_MS = 40;
|
|
156
156
|
const LEFT_DOUBLE_TAP_MAX_GAP_MS = 500;
|
|
157
|
-
const STREAMING_ESCAPE_CANCEL_WINDOW_MS = 2_000;
|
|
158
157
|
|
|
159
158
|
export class InputController {
|
|
160
159
|
constructor(
|
|
@@ -179,16 +178,6 @@ export class InputController {
|
|
|
179
178
|
// (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
|
|
180
179
|
// #detectLeftDoubleTap.
|
|
181
180
|
#leftTapCount = 0;
|
|
182
|
-
// Streaming turns use a two-step Esc: first press arms this token, second press
|
|
183
|
-
// within the window aborts the same live assistant turn. The token is a per-turn
|
|
184
|
-
// sentinel minted lazily on demand and reset on every `agent_start`/`agent_end`
|
|
185
|
-
// (see setupKeyHandlers), so it survives `message_start`/`message_update`
|
|
186
|
-
// transitions inside a single turn but cannot leak across turn boundaries.
|
|
187
|
-
#streamingEscapeTurnSentinel: object | undefined;
|
|
188
|
-
#streamingEscapeArmedToken: object | undefined;
|
|
189
|
-
#streamingEscapeArmedUntil = 0;
|
|
190
|
-
#streamingEscapeTimer: NodeJS.Timeout | undefined;
|
|
191
|
-
#streamingEscapeSessionSubscribed = false;
|
|
192
181
|
// Sequential index for `local://attachment-N` references created by large-paste and
|
|
193
182
|
// pasted-file attachments. Seeded from 0 and bumped past existing attachment files.
|
|
194
183
|
#attachmentCounter = 0;
|
|
@@ -238,50 +227,12 @@ export class InputController {
|
|
|
238
227
|
const unsubscribe = tinyTitleClient.onProgress(update);
|
|
239
228
|
}
|
|
240
229
|
|
|
241
|
-
#
|
|
242
|
-
this
|
|
243
|
-
this.#streamingEscapeArmedUntil = 0;
|
|
244
|
-
if (this.#streamingEscapeTimer) {
|
|
245
|
-
clearTimeout(this.#streamingEscapeTimer);
|
|
246
|
-
this.#streamingEscapeTimer = undefined;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
#handleStreamingEscape(): void {
|
|
251
|
-
if (!this.#streamingEscapeTurnSentinel) {
|
|
252
|
-
this.#streamingEscapeTurnSentinel = {};
|
|
253
|
-
}
|
|
254
|
-
const token = this.#streamingEscapeTurnSentinel;
|
|
255
|
-
const now = Date.now();
|
|
256
|
-
if (this.#streamingEscapeArmedToken === token && now <= this.#streamingEscapeArmedUntil) {
|
|
257
|
-
this.#clearStreamingEscapeArm();
|
|
258
|
-
void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
this.#clearStreamingEscapeArm();
|
|
263
|
-
this.#streamingEscapeArmedToken = token;
|
|
264
|
-
this.#streamingEscapeArmedUntil = now + STREAMING_ESCAPE_CANCEL_WINDOW_MS;
|
|
265
|
-
this.#streamingEscapeTimer = setTimeout(() => {
|
|
266
|
-
if (this.#streamingEscapeArmedToken === token && Date.now() >= this.#streamingEscapeArmedUntil) {
|
|
267
|
-
this.#clearStreamingEscapeArm();
|
|
268
|
-
}
|
|
269
|
-
}, STREAMING_ESCAPE_CANCEL_WINDOW_MS);
|
|
270
|
-
this.#streamingEscapeTimer.unref?.();
|
|
271
|
-
this.ctx.showStatus("Press Esc again within 2s to cancel streaming.");
|
|
230
|
+
#abortStreamingTurn(): void {
|
|
231
|
+
void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
|
|
272
232
|
}
|
|
273
233
|
|
|
274
234
|
setupKeyHandlers(): void {
|
|
275
235
|
this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
|
|
276
|
-
if (!this.#streamingEscapeSessionSubscribed && typeof this.ctx.session.subscribe === "function") {
|
|
277
|
-
this.#streamingEscapeSessionSubscribed = true;
|
|
278
|
-
this.ctx.session.subscribe(event => {
|
|
279
|
-
if (event.type === "agent_start" || event.type === "agent_end") {
|
|
280
|
-
this.#streamingEscapeTurnSentinel = undefined;
|
|
281
|
-
this.#clearStreamingEscapeArm();
|
|
282
|
-
}
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
236
|
if (!this.#focusedLeftTapListenerInstalled) {
|
|
286
237
|
this.#focusedLeftTapListenerInstalled = true;
|
|
287
238
|
this.ctx.ui.addInputListener(data => {
|
|
@@ -351,7 +302,7 @@ export class InputController {
|
|
|
351
302
|
if (this.ctx.loopModeEnabled) {
|
|
352
303
|
this.ctx.pauseLoop();
|
|
353
304
|
if (this.ctx.session.isStreaming) {
|
|
354
|
-
this.#
|
|
305
|
+
this.#abortStreamingTurn();
|
|
355
306
|
} else {
|
|
356
307
|
this.ctx.cancelPendingSubmission();
|
|
357
308
|
}
|
|
@@ -402,11 +353,10 @@ export class InputController {
|
|
|
402
353
|
this.ctx.isPythonMode = false;
|
|
403
354
|
this.ctx.updateEditorBorderColor();
|
|
404
355
|
} else if (this.ctx.session.isStreaming) {
|
|
405
|
-
this.#
|
|
356
|
+
this.#abortStreamingTurn();
|
|
406
357
|
} else if (this.ctx.editor.getText().trim()) {
|
|
407
|
-
// Esc must not destroy an in-progress draft
|
|
358
|
+
// Esc must not destroy an in-progress draft.
|
|
408
359
|
this.ctx.lastEscapeTime = 0;
|
|
409
|
-
this.#clearStreamingEscapeArm();
|
|
410
360
|
} else if (vocalizer.isSpeaking()) {
|
|
411
361
|
// TTS buffers seconds of PCM past the streaming abort, so an Esc
|
|
412
362
|
// arriving after the model stopped would otherwise fall through to
|
|
@@ -16,6 +16,7 @@ import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
|
|
|
16
16
|
import type { AssistantMessage, ImageContent, Message, Model, Usage, UsageReport } from "@oh-my-pi/pi-ai";
|
|
17
17
|
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
18
18
|
import type {
|
|
19
|
+
AutocompleteProvider,
|
|
19
20
|
Component,
|
|
20
21
|
EditorTheme,
|
|
21
22
|
LoaderMessageColorFn,
|
|
@@ -59,6 +60,7 @@ import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
|
|
|
59
60
|
import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings";
|
|
60
61
|
import { clearClaudePluginRootsCache } from "../discovery/helpers";
|
|
61
62
|
import type {
|
|
63
|
+
AutocompleteProviderFactory,
|
|
62
64
|
ContextUsage,
|
|
63
65
|
ExtensionUIContext,
|
|
64
66
|
ExtensionUIDialogOptions,
|
|
@@ -516,6 +518,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
516
518
|
collabGuest?: CollabGuestLink;
|
|
517
519
|
|
|
518
520
|
#pendingSlashCommands: SlashCommand[] = [];
|
|
521
|
+
/** Built-in editor autocomplete provider, before extension wrapping. */
|
|
522
|
+
#baseAutocompleteProvider: AutocompleteProvider | undefined;
|
|
523
|
+
/** Extension-registered provider factories, applied in registration order (#4919). */
|
|
524
|
+
#autocompleteProviderFactories: AutocompleteProviderFactory[] = [];
|
|
519
525
|
#cleanupUnsubscribe?: () => void;
|
|
520
526
|
#signalTeardown?: SessionTeardown;
|
|
521
527
|
readonly #version: string;
|
|
@@ -1094,14 +1100,49 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1094
1100
|
// source suffix (e.g. "Review code (project)"), so pass it through verbatim.
|
|
1095
1101
|
description: template.description,
|
|
1096
1102
|
}));
|
|
1097
|
-
|
|
1103
|
+
this.#baseAutocompleteProvider = this.#inputController.createAutocompleteProvider(
|
|
1098
1104
|
[...this.#pendingSlashCommands, ...fileSlashCommands, ...promptTemplateCommands],
|
|
1099
1105
|
basePath,
|
|
1100
1106
|
);
|
|
1101
|
-
this
|
|
1107
|
+
this.#applyAutocompleteProvider();
|
|
1102
1108
|
this.session.setSlashCommands(fileCommands);
|
|
1103
1109
|
}
|
|
1104
1110
|
|
|
1111
|
+
/**
|
|
1112
|
+
* Rebuild the editor's autocomplete provider: the built-in provider wrapped
|
|
1113
|
+
* by every extension-registered factory, in registration order. A factory
|
|
1114
|
+
* that throws or returns a malformed provider is skipped so one broken
|
|
1115
|
+
* extension cannot take down core autocomplete.
|
|
1116
|
+
*/
|
|
1117
|
+
#applyAutocompleteProvider(): void {
|
|
1118
|
+
const base = this.#baseAutocompleteProvider;
|
|
1119
|
+
if (!base) return;
|
|
1120
|
+
let provider = base;
|
|
1121
|
+
for (const factory of this.#autocompleteProviderFactories) {
|
|
1122
|
+
try {
|
|
1123
|
+
const wrapped = factory(provider);
|
|
1124
|
+
if (
|
|
1125
|
+
wrapped &&
|
|
1126
|
+
typeof wrapped.getSuggestions === "function" &&
|
|
1127
|
+
typeof wrapped.applyCompletion === "function"
|
|
1128
|
+
) {
|
|
1129
|
+
provider = wrapped;
|
|
1130
|
+
} else {
|
|
1131
|
+
logger.warn("Extension autocomplete provider factory returned an invalid provider; skipping it");
|
|
1132
|
+
}
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
logger.warn("Extension autocomplete provider factory threw; skipping it", { error: String(error) });
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
this.editor.setAutocompleteProvider(provider);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/** Stack extension autocomplete behavior on top of the built-in editor provider (#4919). */
|
|
1141
|
+
addAutocompleteProvider(factory: AutocompleteProviderFactory): void {
|
|
1142
|
+
this.#autocompleteProviderFactories.push(factory);
|
|
1143
|
+
this.#applyAutocompleteProvider();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1105
1146
|
/**
|
|
1106
1147
|
* Re-point the process and every cwd-derived cache at `newCwd` after the
|
|
1107
1148
|
* active session's working directory changed (`/move` relocation or resuming
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
RpcAvailableSlashCommand,
|
|
18
18
|
RpcCommand,
|
|
19
19
|
RpcExtensionUIRequest,
|
|
20
|
+
RpcExtensionUIResponse,
|
|
20
21
|
RpcHandoffResult,
|
|
21
22
|
RpcHostToolCallRequest,
|
|
22
23
|
RpcHostToolCancelRequest,
|
|
@@ -722,25 +723,50 @@ export class RpcClient {
|
|
|
722
723
|
/**
|
|
723
724
|
* Trigger OAuth login for the given provider.
|
|
724
725
|
* The server will emit an `open_url` extension_ui_request for the auth URL.
|
|
726
|
+
* Providers that require pasted-code completion may then emit an `input`
|
|
727
|
+
* extension_ui_request; pass `onManualCodeInput` to satisfy it.
|
|
725
728
|
* Resolves when login completes or rejects on failure.
|
|
726
729
|
*
|
|
727
730
|
* @param onOpenUrl Called when the server emits the auth URL. The host must
|
|
728
|
-
* open `url` in a browser
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
731
|
+
* open `url` in a browser. When the flow's callback server hosts a
|
|
732
|
+
* `/launch` redirect, `launchUrl` is a short loopback URL that 302s to
|
|
733
|
+
* `url` — hosts SHOULD surface it as the truncation-safe copy target so
|
|
734
|
+
* terminal viewport clipping cannot corrupt trailing OAuth query
|
|
735
|
+
* parameters (e.g. `code_challenge_method=S256`).
|
|
733
736
|
*/
|
|
734
737
|
async login(
|
|
735
738
|
providerId: string,
|
|
736
|
-
options?: {
|
|
739
|
+
options?: {
|
|
740
|
+
onOpenUrl?: (url: string, instructions?: string, launchUrl?: string) => void;
|
|
741
|
+
onManualCodeInput?: (prompt: { title: string; placeholder?: string }) => string | Promise<string>;
|
|
742
|
+
},
|
|
737
743
|
): Promise<{ providerId: string }> {
|
|
738
|
-
const { onOpenUrl } = options ?? {};
|
|
739
|
-
const listener =
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
+
const { onManualCodeInput, onOpenUrl } = options ?? {};
|
|
745
|
+
const listener =
|
|
746
|
+
onOpenUrl || onManualCodeInput
|
|
747
|
+
? (req: RpcExtensionUIRequest) => {
|
|
748
|
+
if (req.method === "open_url") {
|
|
749
|
+
onOpenUrl?.(req.url, req.instructions, req.launchUrl);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (req.method !== "input" || !onManualCodeInput) return;
|
|
753
|
+
void Promise.resolve(onManualCodeInput({ title: req.title, placeholder: req.placeholder }))
|
|
754
|
+
.then(value => {
|
|
755
|
+
this.#writeFrame({
|
|
756
|
+
type: "extension_ui_response",
|
|
757
|
+
id: req.id,
|
|
758
|
+
value,
|
|
759
|
+
});
|
|
760
|
+
})
|
|
761
|
+
.catch(() => {
|
|
762
|
+
this.#writeFrame({
|
|
763
|
+
type: "extension_ui_response",
|
|
764
|
+
id: req.id,
|
|
765
|
+
cancelled: true,
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
: undefined;
|
|
744
770
|
if (listener) this.#extensionUiListeners.add(listener);
|
|
745
771
|
try {
|
|
746
772
|
const response = await this.#send({ type: "login", providerId }, 600_000);
|
|
@@ -1006,7 +1032,10 @@ export class RpcClient {
|
|
|
1006
1032
|
}
|
|
1007
1033
|
}
|
|
1008
1034
|
|
|
1009
|
-
#writeFrame(
|
|
1035
|
+
#writeFrame(
|
|
1036
|
+
frame: RpcCommand | RpcExtensionUIResponse | RpcHostToolResult | RpcHostToolUpdate,
|
|
1037
|
+
onError?: (error: Error) => void,
|
|
1038
|
+
): void {
|
|
1010
1039
|
if (!this.#process?.stdin) {
|
|
1011
1040
|
throw new Error("Client not started");
|
|
1012
1041
|
}
|