@oh-my-pi/pi-coding-agent 14.8.1 → 14.9.1
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 +38 -0
- package/package.json +16 -7
- package/src/config/model-resolver.ts +92 -35
- package/src/config/prompt-templates.ts +1 -1
- package/src/debug/index.ts +21 -0
- package/src/debug/raw-sse-buffer.ts +229 -0
- package/src/debug/raw-sse.ts +213 -0
- package/src/edit/index.ts +9 -10
- package/src/edit/streaming.ts +6 -5
- package/src/eval/js/context-manager.ts +91 -47
- package/src/extensibility/extensions/loader.ts +9 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
- package/src/hashline/anchors.ts +113 -0
- package/src/hashline/apply.ts +732 -0
- package/src/hashline/bigrams.json +649 -0
- package/src/hashline/constants.ts +8 -0
- package/src/hashline/diff-preview.ts +43 -0
- package/src/hashline/diff.ts +56 -0
- package/src/hashline/execute.ts +268 -0
- package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
- package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
- package/src/hashline/index.ts +14 -0
- package/src/hashline/input.ts +110 -0
- package/src/hashline/parser.ts +220 -0
- package/src/hashline/prefixes.ts +101 -0
- package/src/hashline/recovery.ts +72 -0
- package/src/hashline/stream.ts +123 -0
- package/src/hashline/types.ts +69 -0
- package/src/hashline/utils.ts +3 -0
- package/src/index.ts +1 -1
- package/src/lsp/index.ts +1 -1
- package/src/lsp/render.ts +4 -0
- package/src/memories/index.ts +13 -4
- package/src/modes/components/assistant-message.ts +55 -9
- package/src/modes/components/welcome.ts +114 -38
- package/src/modes/controllers/event-controller.ts +3 -1
- package/src/modes/controllers/input-controller.ts +8 -1
- package/src/modes/interactive-mode.ts +9 -9
- package/src/modes/rpc/rpc-client.ts +53 -2
- package/src/modes/rpc/rpc-mode.ts +67 -1
- package/src/modes/rpc/rpc-types.ts +17 -2
- package/src/modes/utils/ui-helpers.ts +3 -1
- package/src/prompts/agents/reviewer.md +14 -0
- package/src/prompts/tools/hashline.md +57 -10
- package/src/sdk.ts +4 -3
- package/src/session/agent-session.ts +195 -30
- package/src/session/compaction/branch-summarization.ts +4 -2
- package/src/session/compaction/compaction.ts +22 -3
- package/src/task/executor.ts +21 -2
- package/src/task/index.ts +4 -1
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/match-line-format.ts +1 -1
- package/src/tools/read.ts +1 -1
- package/src/utils/file-mentions.ts +1 -1
- package/src/utils/title-generator.ts +11 -0
- package/src/edit/modes/hashline.ts +0 -2039
package/src/index.ts
CHANGED
|
@@ -16,7 +16,6 @@ export type * from "./config/prompt-templates";
|
|
|
16
16
|
export * from "./config/prompt-templates";
|
|
17
17
|
export type { RetrySettings, SkillsSettings } from "./config/settings";
|
|
18
18
|
export { Settings, settings } from "./config/settings";
|
|
19
|
-
export * from "./edit/modes/hashline";
|
|
20
19
|
// Custom commands
|
|
21
20
|
export type * from "./extensibility/custom-commands/types";
|
|
22
21
|
export type * from "./extensibility/custom-tools";
|
|
@@ -30,6 +29,7 @@ export * from "./extensibility/extensions";
|
|
|
30
29
|
export * from "./extensibility/skills";
|
|
31
30
|
// Slash commands
|
|
32
31
|
export { type FileSlashCommand, loadSlashCommands as discoverSlashCommands } from "./extensibility/slash-commands";
|
|
32
|
+
export * from "./hashline";
|
|
33
33
|
export type * from "./lsp";
|
|
34
34
|
// Main entry point
|
|
35
35
|
export * from "./main";
|
package/src/lsp/index.ts
CHANGED
|
@@ -1340,7 +1340,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
1340
1340
|
if (!detailed && targets.length === 1) {
|
|
1341
1341
|
if (uniqueDiagnostics.length === 0) {
|
|
1342
1342
|
return {
|
|
1343
|
-
content: [{ type: "text", text: "
|
|
1343
|
+
content: [{ type: "text", text: "OK" }],
|
|
1344
1344
|
details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
|
|
1345
1345
|
};
|
|
1346
1346
|
}
|
package/src/lsp/render.ts
CHANGED
|
@@ -163,6 +163,10 @@ export function renderResult(
|
|
|
163
163
|
} else if (symbolsMatch) {
|
|
164
164
|
label = "Symbols";
|
|
165
165
|
bodyLines = renderSymbols(symbolsMatch, lines, expanded, theme);
|
|
166
|
+
} else if (result.details?.action === "diagnostics" && text === "OK") {
|
|
167
|
+
label = "Diagnostics";
|
|
168
|
+
state = "success";
|
|
169
|
+
bodyLines = [`${theme.styledSymbol("status.success", "success")} ${theme.fg("dim", "OK")}`];
|
|
166
170
|
} else {
|
|
167
171
|
label = "Response";
|
|
168
172
|
bodyLines = renderGeneric(text, lines, expanded, theme);
|
package/src/memories/index.ts
CHANGED
|
@@ -236,7 +236,7 @@ async function runPhase1(options: {
|
|
|
236
236
|
logger.debug("Phase1 skipped: no model available");
|
|
237
237
|
return;
|
|
238
238
|
}
|
|
239
|
-
const phase1ApiKey = await modelRegistry.getApiKey(phase1Model, session.
|
|
239
|
+
const phase1ApiKey = await modelRegistry.getApiKey(phase1Model, session.sessionId);
|
|
240
240
|
if (!phase1ApiKey) {
|
|
241
241
|
logger.debug("Phase1 skipped: no API key for phase1 model", {
|
|
242
242
|
provider: phase1Model.provider,
|
|
@@ -274,6 +274,7 @@ async function runPhase1(options: {
|
|
|
274
274
|
apiKey: phase1ApiKey,
|
|
275
275
|
modelMaxTokens: computeModelTokenBudget(phase1Model, config),
|
|
276
276
|
config,
|
|
277
|
+
metadata: session.agent?.metadataForProvider(phase1Model.provider),
|
|
277
278
|
});
|
|
278
279
|
|
|
279
280
|
if (result.kind === "failed") {
|
|
@@ -397,7 +398,7 @@ async function runPhase2(options: {
|
|
|
397
398
|
});
|
|
398
399
|
return;
|
|
399
400
|
}
|
|
400
|
-
const phase2ApiKey = await modelRegistry.getApiKey(phase2Model, session.
|
|
401
|
+
const phase2ApiKey = await modelRegistry.getApiKey(phase2Model, session.sessionId);
|
|
401
402
|
if (!phase2ApiKey) {
|
|
402
403
|
markPhase2FailureWithFallback(db, {
|
|
403
404
|
claim,
|
|
@@ -428,6 +429,7 @@ async function runPhase2(options: {
|
|
|
428
429
|
memoryRoot,
|
|
429
430
|
model: phase2Model,
|
|
430
431
|
apiKey: phase2ApiKey,
|
|
432
|
+
metadata: session.agent?.metadataForProvider(phase2Model.provider),
|
|
431
433
|
});
|
|
432
434
|
await applyConsolidation(memoryRoot, consolidated);
|
|
433
435
|
if (heartbeatLostOwnership) {
|
|
@@ -575,6 +577,7 @@ async function runStage1Job(options: {
|
|
|
575
577
|
apiKey: string;
|
|
576
578
|
modelMaxTokens: number;
|
|
577
579
|
config: MemoryRuntimeConfig;
|
|
580
|
+
metadata?: Record<string, unknown>;
|
|
578
581
|
}): Promise<
|
|
579
582
|
| {
|
|
580
583
|
kind: "output";
|
|
@@ -607,6 +610,7 @@ async function runStage1Job(options: {
|
|
|
607
610
|
},
|
|
608
611
|
{
|
|
609
612
|
apiKey,
|
|
613
|
+
metadata: options.metadata,
|
|
610
614
|
maxTokens: Math.max(1024, Math.min(4096, Math.floor(modelMaxTokens * 0.2))),
|
|
611
615
|
reasoning: Effort.Low,
|
|
612
616
|
},
|
|
@@ -711,7 +715,12 @@ async function readRolloutSummaries(memoryRoot: string): Promise<string> {
|
|
|
711
715
|
return blocks.join("\n\n");
|
|
712
716
|
}
|
|
713
717
|
|
|
714
|
-
async function runConsolidationModel(options: {
|
|
718
|
+
async function runConsolidationModel(options: {
|
|
719
|
+
memoryRoot: string;
|
|
720
|
+
model: Model;
|
|
721
|
+
apiKey: string;
|
|
722
|
+
metadata?: Record<string, unknown>;
|
|
723
|
+
}): Promise<{
|
|
715
724
|
memoryMd: string;
|
|
716
725
|
memorySummary: string;
|
|
717
726
|
skills: Array<{
|
|
@@ -735,7 +744,7 @@ async function runConsolidationModel(options: { memoryRoot: string; model: Model
|
|
|
735
744
|
{
|
|
736
745
|
messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }],
|
|
737
746
|
},
|
|
738
|
-
{ apiKey, maxTokens: 8192, reasoning: Effort.Medium },
|
|
747
|
+
{ apiKey, metadata: options.metadata, maxTokens: 8192, reasoning: Effort.Medium },
|
|
739
748
|
);
|
|
740
749
|
if (response.stopReason === "error") {
|
|
741
750
|
throw new Error(response.errorMessage || "phase2 model error");
|
|
@@ -4,6 +4,7 @@ import { formatNumber } from "@oh-my-pi/pi-utils";
|
|
|
4
4
|
import { settings } from "../../config/settings";
|
|
5
5
|
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
6
6
|
import { resolveImageOptions } from "../../tools/render-utils";
|
|
7
|
+
import { convertToPng } from "../../utils/image-convert";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Component that renders a complete assistant message
|
|
@@ -13,10 +14,13 @@ export class AssistantMessageComponent extends Container {
|
|
|
13
14
|
#lastMessage?: AssistantMessage;
|
|
14
15
|
#toolImagesByCallId = new Map<string, ImageContent[]>();
|
|
15
16
|
#usageInfo?: Usage;
|
|
17
|
+
#convertedKittyImages = new Map<string, ImageContent>();
|
|
18
|
+
#kittyConversionsInFlight = new Set<string>();
|
|
16
19
|
|
|
17
20
|
constructor(
|
|
18
21
|
message?: AssistantMessage,
|
|
19
22
|
private hideThinkingBlock = false,
|
|
23
|
+
private readonly onImageUpdate?: () => void,
|
|
20
24
|
) {
|
|
21
25
|
super();
|
|
22
26
|
|
|
@@ -43,16 +47,55 @@ export class AssistantMessageComponent extends Container {
|
|
|
43
47
|
setToolResultImages(toolCallId: string, images: ImageContent[]): void {
|
|
44
48
|
if (!toolCallId) return;
|
|
45
49
|
const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
|
|
50
|
+
for (const key of Array.from(this.#convertedKittyImages.keys())) {
|
|
51
|
+
if (key.startsWith(`${toolCallId}:`)) {
|
|
52
|
+
this.#convertedKittyImages.delete(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const key of Array.from(this.#kittyConversionsInFlight)) {
|
|
56
|
+
if (key.startsWith(`${toolCallId}:`)) {
|
|
57
|
+
this.#kittyConversionsInFlight.delete(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
46
60
|
if (validImages.length === 0) {
|
|
47
61
|
this.#toolImagesByCallId.delete(toolCallId);
|
|
48
62
|
} else {
|
|
49
63
|
this.#toolImagesByCallId.set(toolCallId, validImages);
|
|
64
|
+
this.#convertToolImagesForKitty(toolCallId, validImages);
|
|
50
65
|
}
|
|
51
66
|
if (this.#lastMessage) {
|
|
52
67
|
this.updateContent(this.#lastMessage);
|
|
53
68
|
}
|
|
54
69
|
}
|
|
55
70
|
|
|
71
|
+
#convertToolImagesForKitty(toolCallId: string, images: ImageContent[]): void {
|
|
72
|
+
if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
|
|
73
|
+
for (let index = 0; index < images.length; index++) {
|
|
74
|
+
const image = images[index];
|
|
75
|
+
if (!image || image.mimeType === "image/png") continue;
|
|
76
|
+
const key = `${toolCallId}:${index}`;
|
|
77
|
+
if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
|
|
78
|
+
this.#kittyConversionsInFlight.add(key);
|
|
79
|
+
convertToPng(image.data, image.mimeType)
|
|
80
|
+
.then(converted => {
|
|
81
|
+
this.#kittyConversionsInFlight.delete(key);
|
|
82
|
+
if (!converted) return;
|
|
83
|
+
this.#convertedKittyImages.set(key, {
|
|
84
|
+
type: "image",
|
|
85
|
+
data: converted.data,
|
|
86
|
+
mimeType: converted.mimeType,
|
|
87
|
+
});
|
|
88
|
+
if (this.#lastMessage) {
|
|
89
|
+
this.updateContent(this.#lastMessage);
|
|
90
|
+
}
|
|
91
|
+
this.onImageUpdate?.();
|
|
92
|
+
})
|
|
93
|
+
.catch(() => {
|
|
94
|
+
this.#kittyConversionsInFlight.delete(key);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
56
99
|
setUsageInfo(usage: Usage): void {
|
|
57
100
|
this.#usageInfo = usage;
|
|
58
101
|
if (this.#lastMessage) {
|
|
@@ -61,19 +104,22 @@ export class AssistantMessageComponent extends Container {
|
|
|
61
104
|
}
|
|
62
105
|
|
|
63
106
|
#renderToolImages(): void {
|
|
64
|
-
const
|
|
65
|
-
|
|
107
|
+
const imageEntries = Array.from(this.#toolImagesByCallId.entries()).flatMap(([toolCallId, images]) =>
|
|
108
|
+
images.map((image, index) => ({ image, key: `${toolCallId}:${index}` })),
|
|
109
|
+
);
|
|
110
|
+
if (imageEntries.length === 0) return;
|
|
66
111
|
|
|
67
112
|
this.#contentContainer.addChild(new Spacer(1));
|
|
68
|
-
for (const image of
|
|
69
|
-
|
|
70
|
-
TERMINAL.imageProtocol &&
|
|
71
|
-
|
|
72
|
-
|
|
113
|
+
for (const { image, key } of imageEntries) {
|
|
114
|
+
const displayImage =
|
|
115
|
+
TERMINAL.imageProtocol === ImageProtocol.Kitty && image.mimeType !== "image/png"
|
|
116
|
+
? this.#convertedKittyImages.get(key)
|
|
117
|
+
: image;
|
|
118
|
+
if (TERMINAL.imageProtocol && displayImage) {
|
|
73
119
|
this.#contentContainer.addChild(
|
|
74
120
|
new Image(
|
|
75
|
-
|
|
76
|
-
|
|
121
|
+
displayImage.data,
|
|
122
|
+
displayImage.mimeType,
|
|
77
123
|
{ fallbackColor: (text: string) => theme.fg("toolOutput", text) },
|
|
78
124
|
resolveImageOptions(),
|
|
79
125
|
),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
1
|
+
import { type Component, padding, TERMINAL, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
2
2
|
import { APP_NAME } from "@oh-my-pi/pi-utils";
|
|
3
3
|
import { theme } from "../../modes/theme/theme";
|
|
4
4
|
|
|
@@ -17,6 +17,9 @@ export interface LspServerInfo {
|
|
|
17
17
|
* Premium welcome screen with block-based OMP logo and two-column layout.
|
|
18
18
|
*/
|
|
19
19
|
export class WelcomeComponent implements Component {
|
|
20
|
+
#animStart: number | null = null;
|
|
21
|
+
#animTimer: ReturnType<typeof setInterval> | null = null;
|
|
22
|
+
|
|
20
23
|
constructor(
|
|
21
24
|
private readonly version: string,
|
|
22
25
|
private modelName: string,
|
|
@@ -27,6 +30,32 @@ export class WelcomeComponent implements Component {
|
|
|
27
30
|
|
|
28
31
|
invalidate(): void {}
|
|
29
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Play a one-shot intro that sweeps the gradient through every phase
|
|
35
|
+
* before settling on the resting frame. Safe to call multiple times —
|
|
36
|
+
* subsequent calls reset and replay.
|
|
37
|
+
*/
|
|
38
|
+
playIntro(requestRender: () => void): void {
|
|
39
|
+
this.#stopAnimation();
|
|
40
|
+
this.#animStart = performance.now();
|
|
41
|
+
requestRender();
|
|
42
|
+
this.#animTimer = setInterval(() => {
|
|
43
|
+
const elapsed = performance.now() - (this.#animStart ?? 0);
|
|
44
|
+
if (elapsed >= INTRO_MS) {
|
|
45
|
+
this.#stopAnimation();
|
|
46
|
+
}
|
|
47
|
+
requestRender();
|
|
48
|
+
}, INTRO_MS / INTRO_PHASES);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#stopAnimation(): void {
|
|
52
|
+
if (this.#animTimer != null) {
|
|
53
|
+
clearInterval(this.#animTimer);
|
|
54
|
+
this.#animTimer = null;
|
|
55
|
+
}
|
|
56
|
+
this.#animStart = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
setModel(modelName: string, providerName: string): void {
|
|
31
60
|
this.modelName = modelName;
|
|
32
61
|
this.providerName = providerName;
|
|
@@ -49,7 +78,7 @@ export class WelcomeComponent implements Component {
|
|
|
49
78
|
}
|
|
50
79
|
const dualContentWidth = boxWidth - 3; // 3 = │ + │ + │
|
|
51
80
|
const preferredLeftCol = 26;
|
|
52
|
-
const minLeftCol =
|
|
81
|
+
const minLeftCol = 12; // logo width
|
|
53
82
|
const minRightCol = 20;
|
|
54
83
|
const leftMinContentWidth = Math.max(
|
|
55
84
|
minLeftCol,
|
|
@@ -67,12 +96,8 @@ export class WelcomeComponent implements Component {
|
|
|
67
96
|
const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
|
|
68
97
|
const rightCol = showRightColumn ? dualRightCol : 0;
|
|
69
98
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
const piLogo = ["▀████████████▀", " ╘███ ███ ", " ███ ███ ", " ███ ███ ", " ▄███▄ ▄███▄ "];
|
|
73
|
-
|
|
74
|
-
// Apply gradient to logo
|
|
75
|
-
const logoColored = piLogo.map(line => this.#gradientLine(line));
|
|
99
|
+
// Logo: pick a frame from the intro animation if active, else the resting frame.
|
|
100
|
+
const logoColored = this.#currentLogoFrame();
|
|
76
101
|
|
|
77
102
|
// Left column - centered content
|
|
78
103
|
const leftLines = [
|
|
@@ -201,36 +226,6 @@ export class WelcomeComponent implements Component {
|
|
|
201
226
|
return padding(leftPad) + text + padding(rightPad);
|
|
202
227
|
}
|
|
203
228
|
|
|
204
|
-
/** Apply magenta→cyan gradient to a string */
|
|
205
|
-
#gradientLine(line: string): string {
|
|
206
|
-
const colors = [
|
|
207
|
-
"\x1b[38;5;199m", // bright magenta
|
|
208
|
-
"\x1b[38;5;171m", // magenta-purple
|
|
209
|
-
"\x1b[38;5;135m", // purple
|
|
210
|
-
"\x1b[38;5;99m", // purple-blue
|
|
211
|
-
"\x1b[38;5;75m", // cyan-blue
|
|
212
|
-
"\x1b[38;5;51m", // bright cyan
|
|
213
|
-
];
|
|
214
|
-
const reset = "\x1b[0m";
|
|
215
|
-
|
|
216
|
-
let result = "";
|
|
217
|
-
let colorIdx = 0;
|
|
218
|
-
const step = Math.max(1, Math.floor(line.length / colors.length));
|
|
219
|
-
|
|
220
|
-
for (let i = 0; i < line.length; i++) {
|
|
221
|
-
if (i > 0 && i % step === 0 && colorIdx < colors.length - 1) {
|
|
222
|
-
colorIdx++;
|
|
223
|
-
}
|
|
224
|
-
const char = line[i];
|
|
225
|
-
if (char !== " ") {
|
|
226
|
-
result += colors[colorIdx] + char + reset;
|
|
227
|
-
} else {
|
|
228
|
-
result += char;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return result;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
229
|
/** Fit string to exact width with ANSI-aware truncation/padding */
|
|
235
230
|
#fitToWidth(str: string, width: number): string {
|
|
236
231
|
const visLen = visibleWidth(str);
|
|
@@ -255,4 +250,85 @@ export class WelcomeComponent implements Component {
|
|
|
255
250
|
}
|
|
256
251
|
return str + padding(width - visLen);
|
|
257
252
|
}
|
|
253
|
+
|
|
254
|
+
/** Pick the logo frame for the current intro phase, or the resting frame. */
|
|
255
|
+
#currentLogoFrame(): readonly string[] {
|
|
256
|
+
if (this.#animStart == null) return LOGO_FRAMES[0];
|
|
257
|
+
const elapsed = performance.now() - this.#animStart;
|
|
258
|
+
if (elapsed >= INTRO_MS) return LOGO_FRAMES[0];
|
|
259
|
+
// Ease-out cubic so the sweep settles into the resting frame instead of
|
|
260
|
+
// stopping abruptly. Sweeps backward through the phase ring → lands on 0.
|
|
261
|
+
const progress = elapsed / INTRO_MS;
|
|
262
|
+
const eased = 1 - (1 - progress) ** 3;
|
|
263
|
+
const stepsDone = Math.min(INTRO_PHASES - 1, Math.floor(eased * INTRO_PHASES));
|
|
264
|
+
const idx = (INTRO_PHASES - stepsDone) % INTRO_PHASES;
|
|
265
|
+
return LOGO_FRAMES[idx];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// biome-ignore format: preserve ASCII art layout
|
|
270
|
+
const PI_LOGO = ["▀██████████▀", " ╘██ ██ ", " ██ ██ ", " ██ ██ ", " ▄██▄ ▄██▄ "];
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Apply magenta→cyan diagonal gradient (bottom-left → top-right) across multi-line art.
|
|
274
|
+
* `phase` (0..1) shifts the gradient along the diagonal, wrapping at 1.
|
|
275
|
+
*/
|
|
276
|
+
function gradientLogo(lines: readonly string[], phase = 0): string[] {
|
|
277
|
+
const reset = "\x1b[0m";
|
|
278
|
+
const rows = lines.length;
|
|
279
|
+
const cols = Math.max(...lines.map(l => l.length));
|
|
280
|
+
// span+1 so `base` stays strictly < 1: avoids the wrap-around at the
|
|
281
|
+
// far corner mapping back to t=0 (magenta) on the resting frame.
|
|
282
|
+
const span = Math.max(1, cols + rows - 1);
|
|
283
|
+
const colorAt = TERMINAL.trueColor
|
|
284
|
+
? (t: number): string => {
|
|
285
|
+
// Multi-stop gradient: hot magenta → light violet → bright cyan.
|
|
286
|
+
// Picked stops avoid the deep-blue valley a naive HSL lerp falls into.
|
|
287
|
+
const stops: [number, number, number][] = [
|
|
288
|
+
[255, 62, 201], // hot magenta-pink
|
|
289
|
+
[180, 120, 255], // light violet
|
|
290
|
+
[62, 230, 255], // bright cyan
|
|
291
|
+
];
|
|
292
|
+
const seg = t * (stops.length - 1);
|
|
293
|
+
const i = Math.min(stops.length - 2, Math.floor(seg));
|
|
294
|
+
const f = seg - i;
|
|
295
|
+
const a = stops[i];
|
|
296
|
+
const b = stops[i + 1];
|
|
297
|
+
const r = Math.round(a[0] + (b[0] - a[0]) * f);
|
|
298
|
+
const g = Math.round(a[1] + (b[1] - a[1]) * f);
|
|
299
|
+
const bl = Math.round(a[2] + (b[2] - a[2]) * f);
|
|
300
|
+
return `\x1b[38;2;${r};${g};${bl}m`;
|
|
301
|
+
}
|
|
302
|
+
: (t: number): string => {
|
|
303
|
+
const ramp = [199, 171, 135, 99, 75, 51];
|
|
304
|
+
const idx = Math.min(ramp.length - 1, Math.max(0, Math.floor(t * (ramp.length - 1) + 0.5)));
|
|
305
|
+
return `\x1b[38;5;${ramp[idx]}m`;
|
|
306
|
+
};
|
|
307
|
+
return lines.map((line, y) => {
|
|
308
|
+
let result = "";
|
|
309
|
+
for (let x = 0; x < line.length; x++) {
|
|
310
|
+
const char = line[x];
|
|
311
|
+
if (char === " ") {
|
|
312
|
+
result += char;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// Diagonal: bottom-left (x=0, y=rows-1) → top-right (x=cols-1, y=0)
|
|
316
|
+
const base = (x + (rows - 1 - y)) / span;
|
|
317
|
+
const t = (((base + phase) % 1) + 1) % 1;
|
|
318
|
+
result += colorAt(t) + char + reset;
|
|
319
|
+
}
|
|
320
|
+
return result;
|
|
321
|
+
});
|
|
258
322
|
}
|
|
323
|
+
|
|
324
|
+
/** Intro animation: how many discrete gradient phases and total duration. */
|
|
325
|
+
const INTRO_PHASES = 60;
|
|
326
|
+
const INTRO_MS = 2000;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Pre-rendered logo frames, one per phase. Frame 0 is the resting state;
|
|
330
|
+
* the intro sweeps frames in reverse so it lands on frame 0.
|
|
331
|
+
*/
|
|
332
|
+
const LOGO_FRAMES: readonly (readonly string[])[] = Array.from({ length: INTRO_PHASES }, (_, i) =>
|
|
333
|
+
gradientLogo(PI_LOGO, i / INTRO_PHASES),
|
|
334
|
+
);
|
|
@@ -205,7 +205,9 @@ export class EventController {
|
|
|
205
205
|
} else if (event.message.role === "assistant") {
|
|
206
206
|
this.#lastThinkingCount = 0;
|
|
207
207
|
this.#resetReadGroup();
|
|
208
|
-
this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock)
|
|
208
|
+
this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock, () =>
|
|
209
|
+
this.ctx.ui.requestRender(),
|
|
210
|
+
);
|
|
209
211
|
this.ctx.streamingMessage = event.message;
|
|
210
212
|
this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
|
|
211
213
|
this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
|
|
@@ -362,7 +362,14 @@ export class InputController {
|
|
|
362
362
|
const hasUserMessages = this.ctx.session.messages.some((m: AgentMessage) => m.role === "user");
|
|
363
363
|
if (!hasUserMessages && !this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE) {
|
|
364
364
|
const registry = this.ctx.session.modelRegistry;
|
|
365
|
-
generateSessionTitle(
|
|
365
|
+
generateSessionTitle(
|
|
366
|
+
text,
|
|
367
|
+
registry,
|
|
368
|
+
this.ctx.settings,
|
|
369
|
+
this.ctx.session.sessionId,
|
|
370
|
+
this.ctx.session.model,
|
|
371
|
+
provider => this.ctx.session.agent.metadataForProvider(provider),
|
|
372
|
+
)
|
|
366
373
|
.then(async title => {
|
|
367
374
|
if (title) {
|
|
368
375
|
const applied = await this.ctx.sessionManager.setSessionName(title, "auto");
|
|
@@ -394,6 +394,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
394
394
|
this.ui.addChild(new Spacer(1));
|
|
395
395
|
this.ui.addChild(this.#welcomeComponent);
|
|
396
396
|
this.ui.addChild(new Spacer(1));
|
|
397
|
+
this.#welcomeComponent.playIntro(() => this.ui.requestRender());
|
|
397
398
|
|
|
398
399
|
// Add changelog if provided
|
|
399
400
|
if (this.#changelogMarkdown) {
|
|
@@ -1011,13 +1012,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1011
1012
|
}
|
|
1012
1013
|
}
|
|
1013
1014
|
|
|
1014
|
-
#renderPlanPreview(planContent: string): void {
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
// active selector instead of updating an older off-screen preview in place.
|
|
1019
|
-
this.chatContainer.removeChild(this.#planReviewContainer);
|
|
1020
|
-
}
|
|
1015
|
+
#renderPlanPreview(planContent: string, options?: { append?: boolean }): void {
|
|
1016
|
+
const existingContainer = this.#planReviewContainer;
|
|
1017
|
+
const replaceExisting = options?.append !== true && existingContainer !== undefined;
|
|
1018
|
+
const planReviewContainer = replaceExisting ? existingContainer : new Container();
|
|
1021
1019
|
planReviewContainer.clear();
|
|
1022
1020
|
planReviewContainer.addChild(new Spacer(1));
|
|
1023
1021
|
planReviewContainer.addChild(new DynamicBorder());
|
|
@@ -1025,7 +1023,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1025
1023
|
planReviewContainer.addChild(new Spacer(1));
|
|
1026
1024
|
planReviewContainer.addChild(new Markdown(planContent, 1, 1, getMarkdownTheme()));
|
|
1027
1025
|
planReviewContainer.addChild(new DynamicBorder());
|
|
1028
|
-
|
|
1026
|
+
if (!replaceExisting) {
|
|
1027
|
+
this.chatContainer.addChild(planReviewContainer);
|
|
1028
|
+
}
|
|
1029
1029
|
this.#planReviewContainer = planReviewContainer;
|
|
1030
1030
|
this.ui.requestRender();
|
|
1031
1031
|
}
|
|
@@ -1182,7 +1182,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1182
1182
|
return;
|
|
1183
1183
|
}
|
|
1184
1184
|
|
|
1185
|
-
this.#renderPlanPreview(planContent);
|
|
1185
|
+
this.#renderPlanPreview(planContent, { append: true });
|
|
1186
1186
|
const choice = await this.showHookSelector(
|
|
1187
1187
|
"Plan mode - next step",
|
|
1188
1188
|
["Approve and execute", "Approve and keep context", "Refine plan", "Stay in plan mode"],
|
|
@@ -11,6 +11,7 @@ import type { SessionStats } from "../../session/agent-session";
|
|
|
11
11
|
import type { CompactionResult } from "../../session/compaction";
|
|
12
12
|
import type {
|
|
13
13
|
RpcCommand,
|
|
14
|
+
RpcExtensionUIRequest,
|
|
14
15
|
RpcHandoffResult,
|
|
15
16
|
RpcHostToolCallRequest,
|
|
16
17
|
RpcHostToolCancelRequest,
|
|
@@ -124,6 +125,11 @@ function isRpcHostToolCancelRequest(value: unknown): value is RpcHostToolCancelR
|
|
|
124
125
|
return value.type === "host_tool_cancel" && typeof value.id === "string" && typeof value.targetId === "string";
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
function isRpcExtensionUiRequest(value: unknown): value is RpcExtensionUIRequest {
|
|
129
|
+
if (!isRecord(value)) return false;
|
|
130
|
+
return value.type === "extension_ui_request" && typeof value.id === "string" && typeof value.method === "string";
|
|
131
|
+
}
|
|
132
|
+
|
|
127
133
|
function normalizeToolResult<TDetails>(result: RpcClientToolResult<TDetails>): AgentToolResult<TDetails> {
|
|
128
134
|
if (typeof result === "string") {
|
|
129
135
|
return {
|
|
@@ -145,6 +151,7 @@ export class RpcClient {
|
|
|
145
151
|
#customTools: RpcClientCustomTool[] = [];
|
|
146
152
|
#pendingHostToolCalls = new Map<string, { controller: AbortController }>();
|
|
147
153
|
#requestId = 0;
|
|
154
|
+
#extensionUiListeners: Set<(req: RpcExtensionUIRequest) => void> = new Set();
|
|
148
155
|
#abortController = new AbortController();
|
|
149
156
|
|
|
150
157
|
constructor(private options: RpcClientOptions = {}) {
|
|
@@ -516,6 +523,43 @@ export class RpcClient {
|
|
|
516
523
|
return this.#getData<{ messages: AgentMessage[] }>(response).messages;
|
|
517
524
|
}
|
|
518
525
|
|
|
526
|
+
/**
|
|
527
|
+
* Get list of OAuth providers available for login, with their current authentication status.
|
|
528
|
+
*/
|
|
529
|
+
async getLoginProviders(): Promise<Array<{ id: string; name: string; available: boolean; authenticated: boolean }>> {
|
|
530
|
+
const response = await this.#send({ type: "get_login_providers" });
|
|
531
|
+
return this.#getData<{
|
|
532
|
+
providers: Array<{ id: string; name: string; available: boolean; authenticated: boolean }>;
|
|
533
|
+
}>(response).providers;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Trigger OAuth login for the given provider.
|
|
538
|
+
* The server will emit an `open_url` extension_ui_request for the auth URL.
|
|
539
|
+
* Resolves when login completes or rejects on failure.
|
|
540
|
+
*
|
|
541
|
+
* @param onOpenUrl Called when the server emits the auth URL. The host must open
|
|
542
|
+
* it in a browser for the callback-server OAuth flow to complete.
|
|
543
|
+
*/
|
|
544
|
+
async login(
|
|
545
|
+
providerId: string,
|
|
546
|
+
options?: { onOpenUrl?: (url: string, instructions?: string) => void },
|
|
547
|
+
): Promise<{ providerId: string }> {
|
|
548
|
+
const { onOpenUrl } = options ?? {};
|
|
549
|
+
const listener = onOpenUrl
|
|
550
|
+
? (req: RpcExtensionUIRequest) => {
|
|
551
|
+
if (req.method === "open_url") onOpenUrl(req.url, req.instructions);
|
|
552
|
+
}
|
|
553
|
+
: undefined;
|
|
554
|
+
if (listener) this.#extensionUiListeners.add(listener);
|
|
555
|
+
try {
|
|
556
|
+
const response = await this.#send({ type: "login", providerId }, 600_000);
|
|
557
|
+
return this.#getData<{ providerId: string }>(response);
|
|
558
|
+
} finally {
|
|
559
|
+
if (listener) this.#extensionUiListeners.delete(listener);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
519
563
|
/**
|
|
520
564
|
* Replace the host-owned custom tools exposed to the RPC session.
|
|
521
565
|
* Changes take effect before the next model call.
|
|
@@ -621,6 +665,13 @@ export class RpcClient {
|
|
|
621
665
|
return;
|
|
622
666
|
}
|
|
623
667
|
|
|
668
|
+
if (isRpcExtensionUiRequest(data)) {
|
|
669
|
+
for (const listener of this.#extensionUiListeners) {
|
|
670
|
+
listener(data);
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
624
675
|
if (isRpcHostToolCancelRequest(data)) {
|
|
625
676
|
this.#pendingHostToolCalls.get(data.targetId)?.controller.abort();
|
|
626
677
|
return;
|
|
@@ -634,7 +685,7 @@ export class RpcClient {
|
|
|
634
685
|
}
|
|
635
686
|
}
|
|
636
687
|
|
|
637
|
-
#send(command: RpcCommandBody): Promise<RpcResponse> {
|
|
688
|
+
#send(command: RpcCommandBody, timeoutMs = 30_000): Promise<RpcResponse> {
|
|
638
689
|
if (!this.#process?.stdin) {
|
|
639
690
|
throw new Error("Client not started");
|
|
640
691
|
}
|
|
@@ -643,7 +694,7 @@ export class RpcClient {
|
|
|
643
694
|
const fullCommand = { ...command, id } as RpcCommand;
|
|
644
695
|
const { promise, resolve, reject } = Promise.withResolvers<RpcResponse>();
|
|
645
696
|
let settled = false;
|
|
646
|
-
const timeoutId = this.#startTimeout(
|
|
697
|
+
const timeoutId = this.#startTimeout(timeoutMs, () => {
|
|
647
698
|
if (settled) return;
|
|
648
699
|
this.#pendingRequests.delete(id);
|
|
649
700
|
settled = true;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* - Events: AgentSessionEvent objects streamed as they occur
|
|
11
11
|
* - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
|
|
12
12
|
*/
|
|
13
|
+
import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
|
|
13
14
|
import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
|
|
14
15
|
import type {
|
|
15
16
|
ExtensionUIContext,
|
|
@@ -149,7 +150,6 @@ export function requestRpcEditor(
|
|
|
149
150
|
} as RpcExtensionUIRequest);
|
|
150
151
|
return promise;
|
|
151
152
|
}
|
|
152
|
-
|
|
153
153
|
/**
|
|
154
154
|
* Run in RPC mode.
|
|
155
155
|
* Listens for JSON commands on stdin, outputs events and responses on stdout.
|
|
@@ -755,6 +755,72 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|
|
755
755
|
return success(id, "get_messages", { messages: session.messages });
|
|
756
756
|
}
|
|
757
757
|
|
|
758
|
+
// =================================================================
|
|
759
|
+
// Login
|
|
760
|
+
// =================================================================
|
|
761
|
+
|
|
762
|
+
case "get_login_providers": {
|
|
763
|
+
const providers = getOAuthProviders().map(provider => ({
|
|
764
|
+
id: provider.id,
|
|
765
|
+
name: provider.name,
|
|
766
|
+
available: provider.available,
|
|
767
|
+
authenticated: session.modelRegistry.authStorage.hasAuth(provider.id),
|
|
768
|
+
}));
|
|
769
|
+
return success(id, "get_login_providers", { providers });
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
case "login": {
|
|
773
|
+
const knownProvider = getOAuthProviders().find(p => p.id === command.providerId);
|
|
774
|
+
if (!knownProvider) {
|
|
775
|
+
return error(id, "login", `Unknown OAuth provider: ${command.providerId}`);
|
|
776
|
+
}
|
|
777
|
+
const uiCtx = new RpcExtensionUIContext(pendingExtensionRequests, output);
|
|
778
|
+
// Track whether onAuth has fired. Providers that use OAuthCallbackFlow
|
|
779
|
+
// always call onAuth first (emit browser URL), then onManualCodeInput as
|
|
780
|
+
// a fallback. Providers that require interactive input (API-key paste,
|
|
781
|
+
// GitHub Enterprise URL, device-code entry) call onPrompt before onAuth.
|
|
782
|
+
// We use this ordering to self-classify at runtime — no static allowlist.
|
|
783
|
+
let authEmitted = false;
|
|
784
|
+
try {
|
|
785
|
+
await session.modelRegistry.authStorage.login(command.providerId, {
|
|
786
|
+
onAuth: info => {
|
|
787
|
+
authEmitted = true;
|
|
788
|
+
output({
|
|
789
|
+
type: "extension_ui_request",
|
|
790
|
+
id: Snowflake.next() as string,
|
|
791
|
+
method: "open_url",
|
|
792
|
+
url: info.url,
|
|
793
|
+
instructions: info.instructions,
|
|
794
|
+
} as RpcExtensionUIRequest);
|
|
795
|
+
},
|
|
796
|
+
onProgress: message => {
|
|
797
|
+
uiCtx.notify(message, "info");
|
|
798
|
+
},
|
|
799
|
+
onPrompt: () => {
|
|
800
|
+
if (!authEmitted) {
|
|
801
|
+
// onPrompt called before any auth URL — provider requires
|
|
802
|
+
// interactive input that cannot be satisfied headlessly.
|
|
803
|
+
return Promise.reject(
|
|
804
|
+
new Error(
|
|
805
|
+
`Provider '${command.providerId}' requires interactive prompts ` +
|
|
806
|
+
"which are not supported in RPC mode. Use the terminal UI to log in.",
|
|
807
|
+
),
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
// onAuth has already fired — we are inside OAuthCallbackFlow's
|
|
811
|
+
// manual-redirect fallback race. Returning a never-settling promise
|
|
812
|
+
// lets the race block until the callback server wins; a rejection
|
|
813
|
+
// would be caught as null and spin the while(true) loop.
|
|
814
|
+
return new Promise<string>(() => {});
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
await session.modelRegistry.refresh();
|
|
818
|
+
return success(id, "login", { providerId: command.providerId });
|
|
819
|
+
} catch (err: unknown) {
|
|
820
|
+
return error(id, "login", err instanceof Error ? err.message : String(err));
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
758
824
|
default: {
|
|
759
825
|
const unknownCommand = command as { type: string };
|
|
760
826
|
return error(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
|