@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +16 -7
  3. package/src/config/model-resolver.ts +92 -35
  4. package/src/config/prompt-templates.ts +1 -1
  5. package/src/debug/index.ts +21 -0
  6. package/src/debug/raw-sse-buffer.ts +229 -0
  7. package/src/debug/raw-sse.ts +213 -0
  8. package/src/edit/index.ts +9 -10
  9. package/src/edit/streaming.ts +6 -5
  10. package/src/eval/js/context-manager.ts +91 -47
  11. package/src/extensibility/extensions/loader.ts +9 -3
  12. package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
  13. package/src/hashline/anchors.ts +113 -0
  14. package/src/hashline/apply.ts +732 -0
  15. package/src/hashline/bigrams.json +649 -0
  16. package/src/hashline/constants.ts +8 -0
  17. package/src/hashline/diff-preview.ts +43 -0
  18. package/src/hashline/diff.ts +56 -0
  19. package/src/hashline/execute.ts +268 -0
  20. package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
  21. package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
  22. package/src/hashline/index.ts +14 -0
  23. package/src/hashline/input.ts +110 -0
  24. package/src/hashline/parser.ts +220 -0
  25. package/src/hashline/prefixes.ts +101 -0
  26. package/src/hashline/recovery.ts +72 -0
  27. package/src/hashline/stream.ts +123 -0
  28. package/src/hashline/types.ts +69 -0
  29. package/src/hashline/utils.ts +3 -0
  30. package/src/index.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/lsp/render.ts +4 -0
  33. package/src/memories/index.ts +13 -4
  34. package/src/modes/components/assistant-message.ts +55 -9
  35. package/src/modes/components/welcome.ts +114 -38
  36. package/src/modes/controllers/event-controller.ts +3 -1
  37. package/src/modes/controllers/input-controller.ts +8 -1
  38. package/src/modes/interactive-mode.ts +9 -9
  39. package/src/modes/rpc/rpc-client.ts +53 -2
  40. package/src/modes/rpc/rpc-mode.ts +67 -1
  41. package/src/modes/rpc/rpc-types.ts +17 -2
  42. package/src/modes/utils/ui-helpers.ts +3 -1
  43. package/src/prompts/agents/reviewer.md +14 -0
  44. package/src/prompts/tools/hashline.md +57 -10
  45. package/src/sdk.ts +4 -3
  46. package/src/session/agent-session.ts +195 -30
  47. package/src/session/compaction/branch-summarization.ts +4 -2
  48. package/src/session/compaction/compaction.ts +22 -3
  49. package/src/task/executor.ts +21 -2
  50. package/src/task/index.ts +4 -1
  51. package/src/tools/ast-edit.ts +1 -1
  52. package/src/tools/match-line-format.ts +1 -1
  53. package/src/tools/read.ts +1 -1
  54. package/src/utils/file-mentions.ts +1 -1
  55. package/src/utils/title-generator.ts +11 -0
  56. 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: "No diagnostics" }],
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);
@@ -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.sessionManager.getSessionId());
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.sessionManager.getSessionId());
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: { memoryRoot: string; model: Model; apiKey: string }): Promise<{
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 images = Array.from(this.#toolImagesByCallId.values()).flat();
65
- if (images.length === 0) return;
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 images) {
69
- if (
70
- TERMINAL.imageProtocol &&
71
- (TERMINAL.imageProtocol !== ImageProtocol.Kitty || image.mimeType === "image/png")
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
- image.data,
76
- image.mimeType,
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 = 14; // logo width
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
- // Block-based OMP logo (gradient: magenta cyan)
71
- // biome-ignore format: preserve ASCII art layout
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(text, registry, this.ctx.settings, this.ctx.session.sessionId, this.ctx.session.model)
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 planReviewContainer = this.#planReviewContainer ?? new Container();
1016
- if (this.#planReviewContainer) {
1017
- // Re-append the preview so repeated plan-review refreshes stay adjacent to the
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
- this.chatContainer.addChild(planReviewContainer);
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(30000, () => {
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}`);