@oh-my-pi/pi-coding-agent 13.7.1 → 13.7.3

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 CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.7.3] - 2026-03-04
6
+
7
+ ### Added
8
+
9
+ - Added Kagi Universal Summarizer integration for URL summarization, now prioritized before Jina and other methods
10
+ - Added Kagi Universal Summarizer support for YouTube video summaries when credentials are available
11
+ - Exported `searchWithKagi` and `summarizeUrlWithKagi` functions from new `web/kagi` module for direct API access
12
+ - Added `KagiApiError` exception class for Kagi API-specific error handling
13
+
14
+ ### Changed
15
+
16
+ - Updated hashline prompt documentation with clearer operation syntax and improved examples showing full edit structure with path and edits array
17
+ - Refactored `hlineref` Handlebars helper to return JSON-quoted strings for safer embedding in JSON blocks within prompts
18
+ - Improved `hashlineParseText` to correctly preserve blank lines and trailing empty strings in array input while stripping trailing newlines from string input
19
+ - Optimized duplicate line detection in range replacements to use trimmed comparison, reducing false positives from whitespace differences
20
+ - Refactored Kagi search provider to use shared Kagi API utilities from `web/kagi` module
21
+ - Changed HTML-to-text rendering priority order to try Kagi first, then Jina, Trafilatura, and Lynx
22
+
23
+ ### Fixed
24
+
25
+ - Fixed `isEscapedTabAutocorrectEnabled` environment variable parsing to use switch statement for clearer logic and consistent default behavior
26
+
27
+ ## [13.7.2] - 2026-03-04
28
+ ### Added
29
+
30
+ - Added support for direct OAuth provider login via `/login <provider>` command (e.g., `/login kagi`)
31
+ - Added optional `providerId` parameter to `showOAuthSelector()` to enable direct provider selection without UI selector
32
+
33
+ ### Changed
34
+
35
+ - Simplified web search result formatting to omit empty sections and metadata when not present
36
+
5
37
  ## [13.7.0] - 2026-03-03
6
38
 
7
39
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.7.1",
4
+ "version": "13.7.3",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.7.1",
45
- "@oh-my-pi/pi-agent-core": "13.7.1",
46
- "@oh-my-pi/pi-ai": "13.7.1",
47
- "@oh-my-pi/pi-natives": "13.7.1",
48
- "@oh-my-pi/pi-tui": "13.7.1",
49
- "@oh-my-pi/pi-utils": "13.7.1",
44
+ "@oh-my-pi/omp-stats": "13.7.3",
45
+ "@oh-my-pi/pi-agent-core": "13.7.3",
46
+ "@oh-my-pi/pi-ai": "13.7.3",
47
+ "@oh-my-pi/pi-natives": "13.7.3",
48
+ "@oh-my-pi/pi-tui": "13.7.3",
49
+ "@oh-my-pi/pi-utils": "13.7.3",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -249,10 +249,6 @@ export function sectionSeparator(name: string): string {
249
249
 
250
250
  handlebars.registerHelper("SECTION_SEPERATOR", (name: unknown): string => sectionSeparator(String(name)));
251
251
 
252
- /**
253
- * {{hlineref lineNum "content"}} — compute a real hashline ref for prompt examples.
254
- * Returns `"lineNum#hash"` using the actual hash algorithm.
255
- */
256
252
  function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } {
257
253
  const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10);
258
254
  const raw = typeof content === "string" ? content : String(content ?? "");
@@ -261,16 +257,11 @@ function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; t
261
257
  return { num, text, ref };
262
258
  }
263
259
 
264
- handlebars.registerHelper("hlineref", (lineNum: unknown, content: unknown): string => {
265
- const { ref } = formatHashlineRef(lineNum, content);
266
- return ref;
267
- });
268
-
269
260
  /**
270
- * {{hlinejsonref lineNum "content"}} — same as hlineref but returns a JSON-quoted string.
271
- * Useful for embedding hashline refs inside JSON blocks in prompts.
261
+ * {{hlineref lineNum "content"}} — compute a real hashline ref for prompt examples.
262
+ * Returns `"lineNum#hash"` using the actual hash algorithm.
272
263
  */
273
- handlebars.registerHelper("hlinejsonref", (lineNum: unknown, content: unknown): string => {
264
+ handlebars.registerHelper("hlineref", (lineNum: unknown, content: unknown): string => {
274
265
  const { ref } = formatHashlineRef(lineNum, content);
275
266
  return JSON.stringify(ref);
276
267
  });
@@ -1,4 +1,4 @@
1
- import { sanitizeText } from "@oh-my-pi/pi-natives";
1
+ import { sanitizeText, wrapTextWithAnsi } from "@oh-my-pi/pi-natives";
2
2
  import { replaceTabs, truncateToWidth } from "../tools/render-utils";
3
3
 
4
4
  export function formatDebugLogLine(line: string, maxWidth: number): string {
@@ -17,9 +17,7 @@ export function formatDebugLogExpandedLines(line: string, maxWidth: number): str
17
17
  return [""];
18
18
  }
19
19
 
20
- return normalized
21
- .split("\n")
22
- .flatMap(segment => Bun.wrapAnsi(segment, width, { hard: true, trim: false, wordWrap: true }).split("\n"));
20
+ return normalized.split("\n").flatMap(segment => wrapTextWithAnsi(segment, width));
23
21
  }
24
22
 
25
23
  export function parseDebugLogTimestampMs(line: string): number | undefined {
@@ -499,7 +499,7 @@ export class DebugLogViewerComponent implements Component {
499
499
  }
500
500
 
501
501
  if (matchesKey(keyData, "ctrl+c")) {
502
- void this.#copySelected();
502
+ this.#copySelected();
503
503
  return;
504
504
  }
505
505
 
@@ -878,7 +878,7 @@ export class DebugLogViewerComponent implements Component {
878
878
  return `${theme.boxSharp.vertical}${truncated}${padding(remaining)}${theme.boxSharp.vertical}`;
879
879
  }
880
880
 
881
- async #copySelected(): Promise<void> {
881
+ #copySelected() {
882
882
  const selectedPayload = buildLogCopyPayload(this.#model.getSelectedRawLines());
883
883
  const selected = selectedPayload.length === 0 ? [] : selectedPayload.split("\n");
884
884
 
@@ -890,7 +890,7 @@ export class DebugLogViewerComponent implements Component {
890
890
  }
891
891
 
892
892
  try {
893
- await copyToClipboard(selectedPayload);
893
+ copyToClipboard(selectedPayload);
894
894
  const message = `Copied ${selected.length} log ${selected.length === 1 ? "entry" : "entries"}`;
895
895
  this.#statusMessage = message;
896
896
  this.#onStatus?.(message);
@@ -30,7 +30,7 @@ export function createExaTool(
30
30
  parameters,
31
31
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
32
32
  try {
33
- const apiKey = await findApiKey();
33
+ const apiKey = findApiKey();
34
34
  // Exa MCP endpoint is publicly accessible; API key is optional
35
35
  const args = transformParams ? transformParams(params as Record<string, unknown>) : params;
36
36
  const response = await callExaTool(mcpToolName, args, apiKey);
@@ -238,7 +238,7 @@ export class MCPWrappedTool implements CustomTool<TSchema, ExaRenderDetails> {
238
238
  _signal?: AbortSignal,
239
239
  ): Promise<CustomToolResult<ExaRenderDetails>> {
240
240
  try {
241
- const apiKey = await findApiKey();
241
+ const apiKey = findApiKey();
242
242
  // Websets tools require an API key; basic Exa MCP tools work without one
243
243
  if (!apiKey && this.config.isWebsetsTool) {
244
244
  return {
@@ -23,7 +23,7 @@ function createWebsetTool(
23
23
  parameters,
24
24
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
25
25
  try {
26
- const apiKey = await findApiKey();
26
+ const apiKey = findApiKey();
27
27
  if (!apiKey) {
28
28
  return {
29
29
  content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }],
@@ -230,7 +230,7 @@ async function startGatewayProcess(
230
230
  const settings = await Settings.init();
231
231
  const { shell, env } = settings.getShellConfig();
232
232
  const filteredEnv = filterEnv(env);
233
- const runtime = await resolvePythonRuntime(cwd, filteredEnv);
233
+ const runtime = resolvePythonRuntime(cwd, filteredEnv);
234
234
  const snapshotPath = await getOrCreateSnapshot(shell, env).catch((err: unknown) => {
235
235
  logger.warn("Failed to resolve shell snapshot for shared Python gateway", {
236
236
  error: err instanceof Error ? err.message : String(err),
@@ -89,7 +89,7 @@ export class AssistantMessageComponent extends Container {
89
89
  try {
90
90
  for (const content of message.content) {
91
91
  if (content.type === "text" && content.text.trim() && hasPendingMermaid(content.text)) {
92
- await prerenderMermaid(content.text);
92
+ prerenderMermaid(content.text);
93
93
  }
94
94
  }
95
95
  } catch (error) {
@@ -30,7 +30,7 @@ import {
30
30
  stripInternalArgs,
31
31
  } from "../../tools/json-tree";
32
32
  import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../tools/python";
33
- import { formatExpandHint, truncateToWidth } from "../../tools/render-utils";
33
+ import { formatExpandHint, replaceTabs, truncateToWidth } from "../../tools/render-utils";
34
34
  import { toolRenderers } from "../../tools/renderers";
35
35
  import { renderStatusLine } from "../../tui";
36
36
  import { convertToPng } from "../../utils/image-convert";
@@ -431,14 +431,14 @@ export class ToolExecutionComponent extends Container {
431
431
  // Fall back to showing raw output on error
432
432
  const output = this.#getTextOutput();
433
433
  if (output) {
434
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
434
+ this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
435
435
  }
436
436
  }
437
437
  } else if (this.#result) {
438
438
  // Has result but no custom renderResult
439
439
  const output = this.#getTextOutput();
440
440
  if (output) {
441
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
441
+ this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
442
442
  }
443
443
  }
444
444
  } else if (this.#toolName in toolRenderers) {
@@ -488,7 +488,7 @@ export class ToolExecutionComponent extends Container {
488
488
  // Fall back to showing raw output on error
489
489
  const output = this.#getTextOutput();
490
490
  if (output) {
491
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
491
+ this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
492
492
  }
493
493
  }
494
494
  }
@@ -679,7 +679,7 @@ export class ToolExecutionComponent extends Container {
679
679
  const displayLines = outputLines.slice(0, maxOutputLines);
680
680
 
681
681
  for (const line of displayLines) {
682
- lines.push(theme.fg("toolOutput", truncateToWidth(line, 80)));
682
+ lines.push(theme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80)));
683
683
  }
684
684
 
685
685
  if (outputLines.length > maxOutputLines) {
@@ -57,14 +57,14 @@ export class CommandController {
57
57
  }
58
58
  }
59
59
 
60
- async handleDumpCommand(): Promise<void> {
60
+ handleDumpCommand() {
61
61
  try {
62
62
  const formatted = this.ctx.session.formatSessionAsText();
63
63
  if (!formatted) {
64
64
  this.ctx.showError("No messages to dump yet.");
65
65
  return;
66
66
  }
67
- await copyToClipboard(formatted);
67
+ copyToClipboard(formatted);
68
68
  this.ctx.showStatus("Session copied to clipboard");
69
69
  } catch (error: unknown) {
70
70
  this.ctx.showError(`Failed to copy session: ${error instanceof Error ? error.message : "Unknown error"}`);
@@ -214,7 +214,7 @@ export class CommandController {
214
214
  }
215
215
  }
216
216
 
217
- async handleCopyCommand(): Promise<void> {
217
+ handleCopyCommand() {
218
218
  const text = this.ctx.session.getLastAssistantText();
219
219
  if (!text) {
220
220
  this.ctx.showError("No agent messages to copy yet.");
@@ -222,7 +222,7 @@ export class CommandController {
222
222
  }
223
223
 
224
224
  try {
225
- await copyToClipboard(text);
225
+ copyToClipboard(text);
226
226
  this.ctx.showStatus("Copied last agent message to clipboard");
227
227
  } catch (error) {
228
228
  this.ctx.showError(error instanceof Error ? error.message : String(error));
@@ -510,15 +510,14 @@ export class InputController {
510
510
  this.ctx.showStatus("Nothing to copy");
511
511
  return;
512
512
  }
513
- copyToClipboard(text)
514
- .then(() => {
515
- const sanitized = sanitizeText(text);
516
- const preview = sanitized.length > 30 ? `${sanitized.slice(0, 30)}...` : sanitized;
517
- this.ctx.showStatus(`Copied: ${preview}`);
518
- })
519
- .catch(() => {
520
- this.ctx.showWarning("Failed to copy to clipboard");
521
- });
513
+ try {
514
+ copyToClipboard(text);
515
+ const sanitized = sanitizeText(text);
516
+ const preview = sanitized.length > 30 ? `${sanitized.slice(0, 30)}...` : sanitized;
517
+ this.ctx.showStatus(`Copied: ${preview}`);
518
+ } catch {
519
+ this.ctx.showWarning("Failed to copy to clipboard");
520
+ }
522
521
  }
523
522
 
524
523
  cycleThinkingLevel(): void {
@@ -617,7 +617,99 @@ export class SelectorController {
617
617
  this.ctx.showStatus("Resumed session");
618
618
  }
619
619
 
620
- async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
620
+ async #handleOAuthLogin(providerId: string): Promise<void> {
621
+ this.ctx.showStatus(`Logging in to ${providerId}…`);
622
+ const manualInput = this.ctx.oauthManualInput;
623
+ const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider);
624
+ try {
625
+ await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
626
+ onAuth: (info: { url: string; instructions?: string }) => {
627
+ this.ctx.chatContainer.addChild(new Spacer(1));
628
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0));
629
+ const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
630
+ this.ctx.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
631
+ if (info.instructions) {
632
+ this.ctx.chatContainer.addChild(new Spacer(1));
633
+ this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
634
+ }
635
+ if (useManualInput) {
636
+ this.ctx.chatContainer.addChild(new Spacer(1));
637
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
638
+ }
639
+ this.ctx.ui.requestRender();
640
+ this.ctx.openInBrowser(info.url);
641
+ },
642
+ onPrompt: async (prompt: { message: string; placeholder?: string }) => {
643
+ this.ctx.chatContainer.addChild(new Spacer(1));
644
+ this.ctx.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
645
+ if (prompt.placeholder) {
646
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
647
+ }
648
+ this.ctx.ui.requestRender();
649
+ const { promise, resolve } = Promise.withResolvers<string>();
650
+ const codeInput = new Input();
651
+ codeInput.onSubmit = () => {
652
+ const code = codeInput.getValue();
653
+ this.ctx.editorContainer.clear();
654
+ this.ctx.editorContainer.addChild(this.ctx.editor);
655
+ this.ctx.ui.setFocus(this.ctx.editor);
656
+ resolve(code);
657
+ };
658
+ this.ctx.editorContainer.clear();
659
+ this.ctx.editorContainer.addChild(codeInput);
660
+ this.ctx.ui.setFocus(codeInput);
661
+ this.ctx.ui.requestRender();
662
+ return promise;
663
+ },
664
+ onProgress: (message: string) => {
665
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
666
+ this.ctx.ui.requestRender();
667
+ },
668
+ onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
669
+ });
670
+ await this.ctx.session.modelRegistry.refresh();
671
+ this.ctx.chatContainer.addChild(new Spacer(1));
672
+ this.ctx.chatContainer.addChild(
673
+ new Text(theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0),
674
+ );
675
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
676
+ this.ctx.ui.requestRender();
677
+ } catch (error: unknown) {
678
+ this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
679
+ } finally {
680
+ if (useManualInput) {
681
+ manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
682
+ }
683
+ }
684
+ }
685
+
686
+ async #handleOAuthLogout(providerId: string): Promise<void> {
687
+ try {
688
+ await this.ctx.session.modelRegistry.authStorage.logout(providerId);
689
+ await this.ctx.session.modelRegistry.refresh();
690
+ this.ctx.chatContainer.addChild(new Spacer(1));
691
+ this.ctx.chatContainer.addChild(
692
+ new Text(theme.fg("success", `${theme.status.success} Successfully logged out of ${providerId}`), 1, 0),
693
+ );
694
+ this.ctx.chatContainer.addChild(
695
+ new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0),
696
+ );
697
+ this.ctx.ui.requestRender();
698
+ } catch (error: unknown) {
699
+ this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
700
+ }
701
+ }
702
+
703
+ async showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
704
+ if (providerId) {
705
+ if (mode === "login") {
706
+ await this.#handleOAuthLogin(providerId);
707
+ } else {
708
+ await this.#handleOAuthLogout(providerId);
709
+ }
710
+ return;
711
+ }
712
+
621
713
  if (mode === "logout") {
622
714
  await this.#refreshOAuthProviderAuthState();
623
715
  const oauthProviders = getOAuthProviders();
@@ -635,101 +727,13 @@ export class SelectorController {
635
727
  selector = new OAuthSelectorComponent(
636
728
  mode,
637
729
  this.ctx.session.modelRegistry.authStorage,
638
- async (providerId: string) => {
730
+ async (selectedProviderId: string) => {
639
731
  selector.stopValidation();
640
732
  done();
641
733
  if (mode === "login") {
642
- this.ctx.showStatus(`Logging in to ${providerId}…`);
643
- const manualInput = this.ctx.oauthManualInput;
644
- const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider);
645
- try {
646
- await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
647
- onAuth: (info: { url: string; instructions?: string }) => {
648
- this.ctx.chatContainer.addChild(new Spacer(1));
649
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0));
650
- // Use OSC 8 hyperlink escape sequence for clickable link
651
- const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
652
- this.ctx.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
653
- if (info.instructions) {
654
- this.ctx.chatContainer.addChild(new Spacer(1));
655
- this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
656
- }
657
- if (useManualInput) {
658
- this.ctx.chatContainer.addChild(new Spacer(1));
659
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
660
- }
661
- this.ctx.ui.requestRender();
662
- this.ctx.openInBrowser(info.url);
663
- },
664
- onPrompt: async (prompt: { message: string; placeholder?: string }) => {
665
- this.ctx.chatContainer.addChild(new Spacer(1));
666
- this.ctx.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
667
- if (prompt.placeholder) {
668
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
669
- }
670
- this.ctx.ui.requestRender();
671
- return new Promise<string>(resolve => {
672
- const codeInput = new Input();
673
- codeInput.onSubmit = () => {
674
- const code = codeInput.getValue();
675
- this.ctx.editorContainer.clear();
676
- this.ctx.editorContainer.addChild(this.ctx.editor);
677
- this.ctx.ui.setFocus(this.ctx.editor);
678
- resolve(code);
679
- };
680
- this.ctx.editorContainer.clear();
681
- this.ctx.editorContainer.addChild(codeInput);
682
- this.ctx.ui.setFocus(codeInput);
683
- this.ctx.ui.requestRender();
684
- });
685
- },
686
- onProgress: (message: string) => {
687
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
688
- this.ctx.ui.requestRender();
689
- },
690
- onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
691
- });
692
- // Refresh models to pick up new baseUrl (e.g., github-copilot)
693
- await this.ctx.session.modelRegistry.refresh();
694
- this.ctx.chatContainer.addChild(new Spacer(1));
695
- this.ctx.chatContainer.addChild(
696
- new Text(
697
- theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`),
698
- 1,
699
- 0,
700
- ),
701
- );
702
- this.ctx.chatContainer.addChild(
703
- new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0),
704
- );
705
- this.ctx.ui.requestRender();
706
- } catch (error: unknown) {
707
- this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
708
- } finally {
709
- if (useManualInput) {
710
- manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
711
- }
712
- }
734
+ await this.#handleOAuthLogin(selectedProviderId);
713
735
  } else {
714
- try {
715
- await this.ctx.session.modelRegistry.authStorage.logout(providerId);
716
- // Refresh models to reset baseUrl
717
- await this.ctx.session.modelRegistry.refresh();
718
- this.ctx.chatContainer.addChild(new Spacer(1));
719
- this.ctx.chatContainer.addChild(
720
- new Text(
721
- theme.fg("success", `${theme.status.success} Successfully logged out of ${providerId}`),
722
- 1,
723
- 0,
724
- ),
725
- );
726
- this.ctx.chatContainer.addChild(
727
- new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0),
728
- );
729
- this.ctx.ui.requestRender();
730
- } catch (error: unknown) {
731
- this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
732
- }
736
+ await this.#handleOAuthLogout(selectedProviderId);
733
737
  }
734
738
  },
735
739
  () => {
@@ -738,9 +742,9 @@ export class SelectorController {
738
742
  this.ctx.ui.requestRender();
739
743
  },
740
744
  {
741
- validateAuth: async (providerId: string) => {
745
+ validateAuth: async (selectedProviderId: string) => {
742
746
  const apiKey = await this.ctx.session.modelRegistry.getApiKeyForProvider(
743
- providerId,
747
+ selectedProviderId,
744
748
  this.ctx.session.sessionId,
745
749
  );
746
750
  return !!apiKey;
@@ -949,7 +949,7 @@ export class InteractiveMode implements InteractiveModeContext {
949
949
  return this.#commandController.handleExportCommand(text);
950
950
  }
951
951
 
952
- handleDumpCommand(): Promise<void> {
952
+ handleDumpCommand() {
953
953
  return this.#commandController.handleDumpCommand();
954
954
  }
955
955
 
@@ -961,7 +961,7 @@ export class InteractiveMode implements InteractiveModeContext {
961
961
  return this.#commandController.handleShareCommand();
962
962
  }
963
963
 
964
- handleCopyCommand(): Promise<void> {
964
+ handleCopyCommand() {
965
965
  return this.#commandController.handleCopyCommand();
966
966
  }
967
967
 
@@ -1149,8 +1149,8 @@ export class InteractiveMode implements InteractiveModeContext {
1149
1149
  return this.#selectorController.handleResumeSession(sessionPath);
1150
1150
  }
1151
1151
 
1152
- showOAuthSelector(mode: "login" | "logout"): Promise<void> {
1153
- return this.#selectorController.showOAuthSelector(mode);
1152
+ showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
1153
+ return this.#selectorController.showOAuthSelector(mode, providerId);
1154
1154
  }
1155
1155
 
1156
1156
  showHookConfirm(title: string, message: string): Promise<boolean> {
@@ -147,13 +147,13 @@ export interface InteractiveModeContext {
147
147
  // Command handling
148
148
  handleExportCommand(text: string): Promise<void>;
149
149
  handleShareCommand(): Promise<void>;
150
- handleCopyCommand(): Promise<void>;
150
+ handleCopyCommand(): void;
151
151
  handleSessionCommand(): Promise<void>;
152
152
  handleJobsCommand(): Promise<void>;
153
153
  handleUsageCommand(reports?: UsageReport[] | null): Promise<void>;
154
154
  handleChangelogCommand(showFull?: boolean): Promise<void>;
155
155
  handleHotkeysCommand(): void;
156
- handleDumpCommand(): Promise<void>;
156
+ handleDumpCommand(): void;
157
157
  handleDebugTranscriptCommand(): Promise<void>;
158
158
  handleClearCommand(): Promise<void>;
159
159
  handleForkCommand(): Promise<void>;
@@ -180,7 +180,7 @@ export interface InteractiveModeContext {
180
180
  showTreeSelector(): void;
181
181
  showSessionSelector(): void;
182
182
  handleResumeSession(sessionPath: string): Promise<void>;
183
- showOAuthSelector(mode: "login" | "logout"): Promise<void>;
183
+ showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void>;
184
184
  showHookConfirm(title: string, message: string): Promise<boolean>;
185
185
  showDebugSelector(): void;
186
186
 
@@ -412,10 +412,14 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
412
412
  }
413
413
 
414
414
  function isEscapedTabAutocorrectEnabled(): boolean {
415
- const value = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
416
- if (value === "0") return false;
417
- if (value === "1") return true;
418
- return true;
415
+ switch (Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS) {
416
+ case "0":
417
+ return false;
418
+ case "1":
419
+ return true;
420
+ default:
421
+ return true;
422
+ }
419
423
  }
420
424
 
421
425
  function maybeAutocorrectEscapedTabIndentation(edits: HashlineEdit[], warnings: string[]): void {
@@ -623,20 +627,19 @@ export function applyHashlineEdits(
623
627
  } else {
624
628
  const count = edit.end.line - edit.pos.line + 1;
625
629
  const newLines = [...edit.lines];
626
- const trailingReplacementLine = newLines[newLines.length - 1];
627
- const nextSurvivingLine = fileLines[edit.end.line];
630
+ const trailingReplacementLine = newLines[newLines.length - 1]?.trimEnd();
631
+ const nextSurvivingLine = fileLines[edit.end.line]?.trimEnd();
628
632
  if (
629
- trailingReplacementLine !== undefined &&
630
- trailingReplacementLine.trim().length > 0 &&
631
- nextSurvivingLine !== undefined &&
632
- trailingReplacementLine.trim() === nextSurvivingLine.trim() &&
633
+ trailingReplacementLine &&
634
+ nextSurvivingLine &&
635
+ trailingReplacementLine === nextSurvivingLine &&
633
636
  // Safety: only correct when end-line content differs from the duplicate.
634
637
  // If end already points to the boundary, matching next line is coincidence.
635
- fileLines[edit.end.line - 1].trim() !== trailingReplacementLine.trim()
638
+ fileLines[edit.end.line - 1]?.trimEnd() !== trailingReplacementLine
636
639
  ) {
637
640
  newLines.pop();
638
641
  warnings.push(
639
- `Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine.trim()}" that duplicated next surviving line`,
642
+ `Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine}" that duplicated next surviving line`,
640
643
  );
641
644
  }
642
645
  fileLines.splice(edit.pos.line - 1, count, ...newLines);
@@ -129,10 +129,11 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
129
129
 
130
130
  export function hashlineParseText(edit: string[] | string | null): string[] {
131
131
  if (edit === null) return [];
132
- const lines = stripNewLinePrefixes(Array.isArray(edit) ? edit : edit.split("\n"));
133
- if (lines.length === 0) return [];
134
- if (lines[lines.length - 1].trim() === "") return lines.slice(0, -1);
135
- return lines;
132
+ if (typeof edit === "string") {
133
+ const normalizedEdit = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
134
+ edit = normalizedEdit.replaceAll("\r", "").split("\n");
135
+ }
136
+ return stripNewLinePrefixes(edit);
136
137
  }
137
138
 
138
139
  const hashlineEditSchema = Type.Object(