pi-ui-extend 0.1.71 → 0.1.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app/app.d.ts CHANGED
@@ -62,6 +62,7 @@ export declare class PiUiExtendApp {
62
62
  private subagentsPanelExpanded;
63
63
  private allThinkingExpanded;
64
64
  private superCompactTools;
65
+ private internalClipboardText;
65
66
  private voicePartialText;
66
67
  private resumeSessions;
67
68
  private resumeLoading;
@@ -86,6 +87,7 @@ export declare class PiUiExtendApp {
86
87
  private restoreTabInputState;
87
88
  private clearPersistedInputDraft;
88
89
  private insertVoiceTranscript;
90
+ private pasteInternalClipboard;
89
91
  private setVoicePartialTranscript;
90
92
  private addVoiceSystemMessage;
91
93
  private resetSessionView;
package/dist/app/app.js CHANGED
@@ -132,6 +132,7 @@ export class PiUiExtendApp {
132
132
  subagentsPanelExpanded = true;
133
133
  allThinkingExpanded = false;
134
134
  superCompactTools = false;
135
+ internalClipboardText;
135
136
  voicePartialText;
136
137
  resumeSessions = [];
137
138
  resumeLoading = false;
@@ -310,6 +311,7 @@ export class PiUiExtendApp {
310
311
  voiceStatusWidgetActive: () => this.voiceController.statusWidgetActive(),
311
312
  conversationQuickScrollDirections: () => this.conversationQuickScrollDirections(),
312
313
  queueableInputActive: () => this.inputEditor.promptText.trimEnd().length > 0 || this.inputEditor.images.length > 0,
314
+ internalClipboardActive: () => Boolean(this.internalClipboardText),
313
315
  userMessageJumpMenuActive: () => this.popupMenus.directMenu === "user-message-jump",
314
316
  allThinkingExpandedActive: () => this.allThinkingExpanded,
315
317
  superCompactToolsActive: () => this.superCompactTools,
@@ -613,6 +615,10 @@ export class PiUiExtendApp {
613
615
  this.render();
614
616
  });
615
617
  },
618
+ pasteInternalClipboard: () => this.pasteInternalClipboard(),
619
+ setInternalClipboardText: (text) => {
620
+ this.internalClipboardText = text;
621
+ },
616
622
  scrollConversationQuick: (direction) => this.scrollConversationQuick(direction),
617
623
  toggleAllThinkingExpanded: () => {
618
624
  this.allThinkingExpanded = !this.allThinkingExpanded;
@@ -792,6 +798,7 @@ export class PiUiExtendApp {
792
798
  this.mouseController.statusContextTarget = undefined;
793
799
  this.mouseController.statusModelUsageTarget = undefined;
794
800
  this.mouseController.statusDraftQueueTarget = undefined;
801
+ this.mouseController.statusInternalClipboardTarget = undefined;
795
802
  this.mouseController.statusUserJumpTarget = undefined;
796
803
  this.mouseController.statusThinkingExpandTarget = undefined;
797
804
  this.mouseController.statusCompactToolsTarget = undefined;
@@ -966,6 +973,16 @@ export class PiUiExtendApp {
966
973
  this.inputEditor.insert(`${prefix}${transcript}${suffix}`);
967
974
  this.render();
968
975
  }
976
+ pasteInternalClipboard() {
977
+ if (!this.internalClipboardText) {
978
+ this.showToast("Nothing has been copied inside Pix yet.", "info");
979
+ return;
980
+ }
981
+ this.requestHistory.resetNavigation();
982
+ this.popupMenus.resetInputMenuDismissals();
983
+ this.inputEditor.attachPastedText(this.internalClipboardText);
984
+ this.render();
985
+ }
969
986
  setVoicePartialTranscript(text) {
970
987
  if (this.voicePartialText === text)
971
988
  return;
@@ -67,7 +67,7 @@ export class ModelCommandActions {
67
67
  const parsed = parseScopedModelRef(modelRef);
68
68
  if (!parsed)
69
69
  throw new Error("Model must use provider/model[:thinking] format");
70
- await runtime.services.modelRuntime.reloadConfig();
70
+ await runtime.services.modelRuntime.refresh();
71
71
  if (!isCommandScopeActive(this.host, scope))
72
72
  return;
73
73
  const model = runtime.services.modelRuntime.getModel(parsed.provider, parsed.modelId);
@@ -108,7 +108,7 @@ export class ModelCommandActions {
108
108
  const parsed = parseScopedModelRef(modelRef);
109
109
  if (!parsed)
110
110
  throw new Error("Model must use provider/model[:thinking] format");
111
- await runtime.services.modelRuntime.reloadConfig();
111
+ await runtime.services.modelRuntime.refresh();
112
112
  if (!isCommandScopeActive(this.host, scope))
113
113
  return;
114
114
  const model = runtime.services.modelRuntime.getModel(parsed.provider, parsed.modelId);
@@ -132,7 +132,7 @@ export class ModelCommandActions {
132
132
  const parsed = parseScopedModelRef(modelRef);
133
133
  if (!parsed)
134
134
  throw new Error("Model must use provider/model[:thinking] format, or run /autocomplete with no arguments to disable");
135
- await runtime.services.modelRuntime.reloadConfig();
135
+ await runtime.services.modelRuntime.refresh();
136
136
  if (!isCommandScopeActive(this.host, scope))
137
137
  return;
138
138
  const model = runtime.services.modelRuntime.getModel(parsed.provider, parsed.modelId);
@@ -216,7 +216,7 @@ export class ModelCommandActions {
216
216
  const refs = value.split(/[,\s]+/).map((ref) => ref.trim()).filter(Boolean);
217
217
  const scopedModels = [];
218
218
  const invalidRefs = [];
219
- await runtime.services.modelRuntime.reloadConfig();
219
+ await runtime.services.modelRuntime.refresh();
220
220
  if (!isCommandScopeActive(this.host, scope))
221
221
  return;
222
222
  for (const ref of refs) {
@@ -23,6 +23,7 @@ declare const NERD_FONT_ICONS: {
23
23
  readonly volumeOff: "󰖁";
24
24
  readonly user: "󰀄";
25
25
  readonly compactTools: "󰍜";
26
+ readonly clipboardPaste: "󰆒";
26
27
  readonly thinkingExpanded: "󰌵";
27
28
  readonly stopCircle: "󰙥";
28
29
  readonly timerSand: "󰔟";
package/dist/app/icons.js CHANGED
@@ -34,6 +34,7 @@ const NERD_FONT_ICONS = {
34
34
  volumeOff: "\u{f0581}",
35
35
  user: "\u{f0004}",
36
36
  compactTools: "\u{f035c}",
37
+ clipboardPaste: "\u{f0192}",
37
38
  thinkingExpanded: "\u{f0335}",
38
39
  stopCircle: "\u{f0665}",
39
40
  timerSand: "\u{f051f}",
@@ -66,6 +67,7 @@ const FALLBACK_ICONS = {
66
67
  volumeOff: "ø",
67
68
  user: "@",
68
69
  compactTools: "≡",
70
+ clipboardPaste: "P",
69
71
  thinkingExpanded: ">",
70
72
  stopCircle: "■",
71
73
  timerSand: "⏳",
@@ -142,7 +142,7 @@ export async function completeInputWithPi(runtime, draft, config, signal) {
142
142
  const modelRuntime = runtime.services.modelRuntime;
143
143
  let model = modelRuntime.getModel(parsedModel.provider, parsedModel.modelId);
144
144
  if (!model) {
145
- await modelRuntime.reloadConfig();
145
+ await modelRuntime.refresh();
146
146
  model = modelRuntime.getModel(parsedModel.provider, parsedModel.modelId);
147
147
  }
148
148
  if (!model)
@@ -144,7 +144,7 @@ async function enhancePromptWithPi(runtime, draft, config) {
144
144
  systemPrompt: PROMPT_ENHANCER_SYSTEM_PROMPT,
145
145
  },
146
146
  });
147
- await services.modelRuntime.reloadConfig();
147
+ await services.modelRuntime.refresh();
148
148
  const model = services.modelRuntime.getModel(parsedModel.provider, parsedModel.modelId);
149
149
  if (!model) {
150
150
  throw new Error(modelNotFoundMessage(parsedModel.provider, parsedModel.modelId, services.modelRuntime.getModels()));
@@ -268,6 +268,7 @@ export class AppRenderController {
268
268
  this.deps.mouseController.statusContextTarget = this.deps.statusLineRenderer.contextTarget(statusLayout.text, statusRow, statusLayout);
269
269
  this.deps.mouseController.statusModelUsageTarget = this.deps.statusLineRenderer.modelUsageTarget(statusLayout.text, statusRow, statusLayout);
270
270
  this.deps.mouseController.statusDraftQueueTarget = this.deps.statusLineRenderer.draftQueueTarget?.(statusLayout, statusRow);
271
+ this.deps.mouseController.statusInternalClipboardTarget = this.deps.statusLineRenderer.internalClipboardTarget?.(statusLayout, statusRow);
271
272
  this.deps.mouseController.statusUserJumpTarget = this.deps.statusLineRenderer.userJumpTarget?.(statusLayout, statusRow);
272
273
  this.deps.mouseController.statusThinkingExpandTarget = this.deps.statusLineRenderer.thinkingExpandTarget?.(statusLayout, statusRow);
273
274
  this.deps.mouseController.statusCompactToolsTarget = this.deps.statusLineRenderer.compactToolsTarget?.(statusLayout, statusRow);
@@ -1,6 +1,6 @@
1
1
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
2
  import type { Theme } from "../../theme.js";
3
- import type { SessionActivity, StatusCompactToolsTarget, StatusContextTarget, StatusDraftQueueTarget, StatusLineLayout, StatusModelTarget, StatusModelUsageTarget, StatusPromptEnhancerTarget, StatusQuickScrollTarget, StatusSessionTarget, StatusTerminalBellSoundTarget, StatusThinkingExpandTarget, StatusThinkingTarget, StatusUserJumpTarget, StatusVoiceLanguageTarget, StatusVoiceMicTarget } from "../types.js";
3
+ import type { SessionActivity, StatusCompactToolsTarget, StatusContextTarget, StatusDraftQueueTarget, StatusInternalClipboardTarget, StatusLineLayout, StatusModelTarget, StatusModelUsageTarget, StatusPromptEnhancerTarget, StatusQuickScrollTarget, StatusSessionTarget, StatusTerminalBellSoundTarget, StatusThinkingExpandTarget, StatusThinkingTarget, StatusUserJumpTarget, StatusVoiceLanguageTarget, StatusVoiceMicTarget } from "../types.js";
4
4
  import type { ScreenStyler } from "../screen/screen-styler.js";
5
5
  import { type ModelColorsConfig } from "../../config.js";
6
6
  export type StatusLineRendererHost = {
@@ -31,6 +31,7 @@ export type StatusLineRendererHost = {
31
31
  down: boolean;
32
32
  };
33
33
  queueableInputActive?(): boolean;
34
+ internalClipboardActive?(): boolean;
34
35
  userMessageJumpMenuActive?(): boolean;
35
36
  allThinkingExpandedActive?(): boolean;
36
37
  superCompactToolsActive?(): boolean;
@@ -52,6 +53,7 @@ export declare class StatusLineRenderer {
52
53
  voiceLanguageTarget(layout: StatusLineLayout, row: number): StatusVoiceLanguageTarget | undefined;
53
54
  userJumpTarget(layout: StatusLineLayout, row: number): StatusUserJumpTarget | undefined;
54
55
  draftQueueTarget(layout: StatusLineLayout, row: number): StatusDraftQueueTarget | undefined;
56
+ internalClipboardTarget(layout: StatusLineLayout, row: number): StatusInternalClipboardTarget | undefined;
55
57
  thinkingExpandTarget(layout: StatusLineLayout, row: number): StatusThinkingExpandTarget | undefined;
56
58
  compactToolsTarget(layout: StatusLineLayout, row: number): StatusCompactToolsTarget | undefined;
57
59
  quickScrollUpTarget(layout: StatusLineLayout, row: number): StatusQuickScrollTarget | undefined;
@@ -65,6 +65,9 @@ export class StatusLineRenderer {
65
65
  appendWidget(draftQueueButton ? this.iconButtonText(draftQueueButton) : "", (column, text) => {
66
66
  layout.draftQueueWidget = this.widgetLayout(column, text);
67
67
  });
68
+ appendWidget(this.iconButtonText(APP_ICONS.clipboardPaste), (column, text) => {
69
+ layout.internalClipboardWidget = this.widgetLayout(column, text);
70
+ });
68
71
  const promptEnhancerWidgetText = this.host.promptEnhancerStatusWidgetText();
69
72
  appendWidget(promptEnhancerWidgetText ? this.iconButtonText(promptEnhancerWidgetText) : "", (column, text) => {
70
73
  layout.promptEnhancerWidget = this.widgetLayout(column, text);
@@ -141,6 +144,7 @@ export class StatusLineRenderer {
141
144
  });
142
145
  };
143
146
  pushWidgetSegment(layout.draftQueueWidget, colors.info);
147
+ pushWidgetSegment(layout.internalClipboardWidget, this.host.internalClipboardActive?.() ? colors.info : colors.muted);
144
148
  pushWidgetSegment(layout.promptEnhancerWidget, this.host.promptEnhancerStatusWidgetActive()
145
149
  ? colors.warning
146
150
  : this.host.promptEnhancerStatusWidgetEnabled()
@@ -251,6 +255,12 @@ export class StatusLineRenderer {
251
255
  return undefined;
252
256
  return { row, startColumn: widget.startColumn, endColumn: widget.endColumn };
253
257
  }
258
+ internalClipboardTarget(layout, row) {
259
+ const widget = layout.internalClipboardWidget;
260
+ if (!widget)
261
+ return undefined;
262
+ return { row, startColumn: widget.startColumn, endColumn: widget.endColumn };
263
+ }
254
264
  thinkingExpandTarget(layout, row) {
255
265
  const widget = layout.thinkingExpandWidget;
256
266
  if (!widget)
@@ -339,6 +349,7 @@ export class StatusLineRenderer {
339
349
  const colors = this.host.theme.colors;
340
350
  const widgets = [
341
351
  { widget: layout.draftQueueWidget, foreground: colors.info },
352
+ { widget: layout.internalClipboardWidget, foreground: this.host.internalClipboardActive?.() ? colors.info : colors.muted },
342
353
  { widget: layout.promptEnhancerWidget, foreground: this.host.promptEnhancerStatusWidgetActive()
343
354
  ? colors.warning
344
355
  : this.host.promptEnhancerStatusWidgetEnabled()
@@ -239,7 +239,7 @@ export async function createPixRuntime(options, runtimeOptions = {}) {
239
239
  config,
240
240
  ...(runtimeOptions.eventBus === undefined ? {} : { eventBus: runtimeOptions.eventBus }),
241
241
  });
242
- await services.modelRuntime.reloadConfig();
242
+ await services.modelRuntime.refresh();
243
243
  const model = parsedModel ? services.modelRuntime.getModel(parsedModel.provider, parsedModel.modelId) : undefined;
244
244
  if (parsedModel && !model) {
245
245
  throw new Error(`Model not found: ${parsedModel.provider}/${parsedModel.modelId}`);
@@ -6,7 +6,7 @@ import type { ToastEntry, ToastVariant } from "../../ui.js";
6
6
  import type { AppPopupActionController } from "../popup/popup-action-controller.js";
7
7
  import type { AppPopupMenuController } from "../popup/popup-menu-controller.js";
8
8
  import type { AppScrollController } from "./scroll-controller.js";
9
- import type { Entry, ImageClickTarget, MouseEvent, MouseSelection, StatusContextTarget, StatusCompactToolsTarget, StatusDraftQueueTarget, StatusModelTarget, StatusModelUsageTarget, StatusPromptEnhancerTarget, StatusQuickScrollTarget, StatusSessionTarget, StatusTerminalBellSoundTarget, TabLineMouseTarget, StatusThinkingExpandTarget, StatusThinkingTarget, StatusUserJumpTarget, StatusVoiceLanguageTarget, StatusVoiceMicTarget } from "../types.js";
9
+ import type { Entry, ImageClickTarget, MouseEvent, MouseSelection, StatusContextTarget, StatusCompactToolsTarget, StatusDraftQueueTarget, StatusInternalClipboardTarget, StatusModelTarget, StatusModelUsageTarget, StatusPromptEnhancerTarget, StatusQuickScrollTarget, StatusSessionTarget, StatusTerminalBellSoundTarget, TabLineMouseTarget, StatusThinkingExpandTarget, StatusThinkingTarget, StatusUserJumpTarget, StatusVoiceLanguageTarget, StatusVoiceMicTarget } from "../types.js";
10
10
  import { type RenderedLink } from "./file-links.js";
11
11
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
12
12
  type ClickFlash = {
@@ -56,6 +56,8 @@ export type AppMouseControllerHost = {
56
56
  refreshModelUsageStatus(): void | Promise<void>;
57
57
  refreshUserMessageJumpMenuItems?(): Promise<void>;
58
58
  queueInputFromStatus?(): void | Promise<void>;
59
+ pasteInternalClipboard?(): void;
60
+ setInternalClipboardText?(text: string): void;
59
61
  scrollConversationQuick(direction: "up" | "down"): void | Promise<void>;
60
62
  toggleAllThinkingExpanded?(): void;
61
63
  toggleSuperCompactTools?(): void;
@@ -102,6 +104,7 @@ export declare class AppMouseController {
102
104
  statusModelUsageTarget: StatusModelUsageTarget | undefined;
103
105
  statusUserJumpTarget: StatusUserJumpTarget | undefined;
104
106
  statusDraftQueueTarget: StatusDraftQueueTarget | undefined;
107
+ statusInternalClipboardTarget: StatusInternalClipboardTarget | undefined;
105
108
  statusThinkingExpandTarget: StatusThinkingExpandTarget | undefined;
106
109
  statusCompactToolsTarget: StatusCompactToolsTarget | undefined;
107
110
  statusQuickScrollUpTarget: StatusQuickScrollTarget | undefined;
@@ -161,6 +164,7 @@ export declare class AppMouseController {
161
164
  private handleInputBorderStatusClick;
162
165
  private openStatusUserJumpMenu;
163
166
  private handleStatusDraftQueueClick;
167
+ private handleStatusInternalClipboardClick;
164
168
  private handleStatusThinkingExpandClick;
165
169
  private handleStatusCompactToolsClick;
166
170
  private handleStatusTerminalBellSoundClick;
@@ -26,6 +26,7 @@ export class AppMouseController {
26
26
  statusModelUsageTarget;
27
27
  statusUserJumpTarget;
28
28
  statusDraftQueueTarget;
29
+ statusInternalClipboardTarget;
29
30
  statusThinkingExpandTarget;
30
31
  statusCompactToolsTarget;
31
32
  statusQuickScrollUpTarget;
@@ -317,6 +318,7 @@ export class AppMouseController {
317
318
  this.statusContextTarget,
318
319
  this.statusModelUsageTarget,
319
320
  this.statusDraftQueueTarget,
321
+ this.statusInternalClipboardTarget,
320
322
  this.statusUserJumpTarget,
321
323
  this.statusThinkingExpandTarget,
322
324
  this.statusCompactToolsTarget,
@@ -496,6 +498,7 @@ export class AppMouseController {
496
498
  }
497
499
  handleInputBorderStatusClick(event) {
498
500
  return this.handleStatusDraftQueueClick(event)
501
+ || this.handleStatusInternalClipboardClick(event)
499
502
  || this.handleStatusUserJumpClick(event)
500
503
  || this.handleStatusThinkingExpandClick(event)
501
504
  || this.handleStatusCompactToolsClick(event)
@@ -525,6 +528,12 @@ export class AppMouseController {
525
528
  void this.host.queueInputFromStatus?.();
526
529
  return true;
527
530
  }
531
+ handleStatusInternalClipboardClick(event) {
532
+ if (!this.statusTargetContains(this.statusInternalClipboardTarget, event))
533
+ return false;
534
+ this.host.pasteInternalClipboard?.();
535
+ return true;
536
+ }
528
537
  handleStatusThinkingExpandClick(event) {
529
538
  if (!this.statusTargetContains(this.statusThinkingExpandTarget, event))
530
539
  return false;
@@ -682,9 +691,12 @@ export class AppMouseController {
682
691
  return false;
683
692
  }
684
693
  const selectedText = this.getSelectedText(selection);
685
- this.host.render();
686
- if (selectedText.trim().length === 0)
694
+ if (selectedText.trim().length === 0) {
695
+ this.host.render();
687
696
  return true;
697
+ }
698
+ this.host.setInternalClipboardText?.(selectedText);
699
+ this.host.render();
688
700
  try {
689
701
  this.copyTextToClipboard(selectedText);
690
702
  this.host.showToast("Copied to clipboard", "success");
@@ -279,6 +279,7 @@ export type StatusLineLayout = {
279
279
  quickScrollDownWidget?: StatusQuickScrollWidgetLayout;
280
280
  userJumpWidget?: StatusUserJumpWidgetLayout;
281
281
  draftQueueWidget?: StatusDraftQueueWidgetLayout;
282
+ internalClipboardWidget?: StatusInternalClipboardWidgetLayout;
282
283
  thinkingExpandWidget?: StatusThinkingExpandWidgetLayout;
283
284
  compactToolsWidget?: StatusCompactToolsWidgetLayout;
284
285
  terminalBellSoundWidget?: StatusTerminalBellSoundWidgetLayout;
@@ -297,6 +298,10 @@ export type StatusDraftQueueWidgetLayout = {
297
298
  startColumn: number;
298
299
  endColumn: number;
299
300
  };
301
+ export type StatusInternalClipboardWidgetLayout = {
302
+ startColumn: number;
303
+ endColumn: number;
304
+ };
300
305
  export type StatusThinkingExpandWidgetLayout = {
301
306
  startColumn: number;
302
307
  endColumn: number;
@@ -594,6 +599,11 @@ export type StatusDraftQueueTarget = {
594
599
  startColumn: number;
595
600
  endColumn: number;
596
601
  };
602
+ export type StatusInternalClipboardTarget = {
603
+ row: number;
604
+ startColumn: number;
605
+ endColumn: number;
606
+ };
597
607
  export type StatusThinkingExpandTarget = {
598
608
  row: number;
599
609
  startColumn: number;
@@ -15,7 +15,7 @@ This package keeps shared Pi tools as ordinary source folders under `src/` and r
15
15
  - `src/todo` — `todo` tool, `/todos`, `/todos-persist`, and `/todos-scope`; supports parent/subtask hierarchy, blockers, ready-task filtering, deferred out-of-scope items, batch operations, JSON/Markdown import/export, automatic clearing when all visible todos are completed, and optional project persistence via `/todos persist on` or `/todos-persist on`; localization/i18n has been removed
16
16
  - `src/model-tools` — model-specific tool aliases such as Claude/GLM-style `Read` / `Edit` / `Write` / `Bash` / `Grep` / `Glob` / `LS`, GPT/Codex-style `shell`, and model-gated `apply_patch`
17
17
  - `src/usage` — `/usage` command and startup hint for read-only AI quota checks across OpenAI, Zhipu AI, Z.ai, and Google Antigravity, including Antigravity quota by model
18
- - `src/web-search` — `web_search` and `web_fetch` tools migrated from `@ollama/pi-web-search`; calls the local Ollama experimental web search/fetch APIs, honors `OLLAMA_HOST`, supports request timeouts via `timeout_ms` / `PI_WEB_SEARCH_TIMEOUT_MS`, and reports targeted `ollama signin`, unsupported-endpoint, invalid-response, timeout, DNS, and Ollama-not-running errors
18
+ - `src/web-search` — `web_search` and `web_fetch` tools migrated from `@ollama/pi-web-search`; uses local Ollama by default or the official Ollama cloud API when an API key is configured, supports Tavily Search/Extract fallback, provides `/web-credentials` for secure user-level key storage, honors `OLLAMA_HOST`, supports request timeouts via `timeout_ms` / `PI_WEB_SEARCH_TIMEOUT_MS`, and reports provider-specific errors
19
19
  - `src/dcp` — headless Dynamic Context Pruning ported from `opencode-dynamic-context-pruning` for the Pi SDK: explicit `compress` tool with range and message modes, `/dcp` commands (context, stats, sweep, manual, decompress, recompress, compress), same-call overlap validation, recoverable compressed-block rollups, grouped message-mode skip diagnostics, stable raw-message anchors when available, protected user/tool preservation, deduplication, error purging, and context nudges; visualization is left to `compress` tool responses and the renderer-owned context-percent click dialog
20
20
  - `src/prompt-commands` — user slash-command builder: `/prompt-commands` opens a CRUD menu for saved prompt-backed slash commands, stores them under `promptCommands` in `~/.config/pi/pi-tools-suite.jsonc`, reloads after edits, and runs each saved prompt as a normal user message
21
21
  - `src/skill-installer` — `/install-skill [name]` installs a personal skill folder from `~/.agents/local_skills` into the current project's `.pi/skills/` so it activates as a project-local skill, then automatically runs `/reload` so the new skill is picked up without a manual step; `/export-skill [name]` does the reverse, copying a project-local skill back to `~/.agents/local_skills/` for reuse in other projects (no reload, since the library lives outside the project); with no argument either command shows an interactive menu of available skills (folders containing `SKILL.md`), and the `<name>` form installs/exports it directly (headless-safe); existing destinations prompt to overwrite in the UI and are refused in headless mode; `.DS_Store` files are skipped
@@ -305,22 +305,49 @@ Runtime logs are minimized by default: successful agents do not keep `events.jso
305
305
 
306
306
  ## Web search
307
307
 
308
- `src/web-search` registers two Ollama-backed tools:
308
+ `src/web-search` registers two Ollama-first tools and the `/web-credentials` setup command:
309
309
 
310
- - `web_search` posts `{ query, max_results }` to `/api/experimental/web_search` and returns formatted title/URL/snippet results plus structured `details.results`.
311
- - `web_fetch` posts `{ url }` to `/api/experimental/web_fetch` and returns extracted page text plus title/link metadata.
310
+ - `web_search` posts `{ query, max_results }` and returns formatted title/URL/snippet results plus structured `details.results`.
311
+ - `web_fetch` posts `{ url }` and returns extracted page text plus title/link metadata.
312
+ - `/web-credentials` can set, inspect, or clear stored Ollama and Tavily API keys. Notifications and status output never display key values.
312
313
 
313
- Both tools default to `http://localhost:11434`; set `OLLAMA_HOST` to point at another Ollama instance. Requests time out after 30 seconds by default. Override globally with `PI_WEB_SEARCH_TIMEOUT_MS` or per call with `timeout_ms` (maximum 120000 ms). Tool results include `host`, `timeoutMs`, and truncation metadata in `details`.
314
+ Without an Ollama API key, both tools default to `http://localhost:11434/api/experimental/...`; set `OLLAMA_HOST` to point at another Ollama instance. With an Ollama API key and no explicit `OLLAMA_HOST`, they use the official `https://ollama.com/api/web_search` and `/api/web_fetch` endpoints with bearer authentication. Requests time out after 30 seconds by default. Override globally with `PI_WEB_SEARCH_TIMEOUT_MS` or per call with `timeout_ms` (maximum 120000 ms). Tool results include `host`, `timeoutMs`, and truncation metadata in `details`.
315
+
316
+ Configure a Tavily key to enable automatic fallback for both tools. Ollama remains the primary provider; if any Ollama request or response fails, `web_search` retries through `https://api.tavily.com/search` and `web_fetch` retries through `https://api.tavily.com/extract`. The Tavily key is sent only in Tavily's bearer authorization header and is never accepted as a tool parameter or included in result details. Fallback results set `details.provider` to `tavily` and include the primary Ollama error under `details.fallbackFrom`; ordinary results set `details.provider` to `ollama`.
317
+
318
+ The recommended interactive setup is:
319
+
320
+ ```text
321
+ /web-credentials
322
+ ```
323
+
324
+ Choose Ollama or Tavily, paste the key, and it becomes active for later calls without a reload. Keys are stored in `~/.config/pi/pi-tools-suite-credentials.json` with mode `0600`. The same command can show configuration sources or clear stored keys without displaying them.
325
+
326
+ The command displays the official key pages before opening its menu:
327
+
328
+ - Ollama: <https://ollama.com/settings/keys>
329
+ - Tavily: <https://app.tavily.com/home>
330
+
331
+ Environment variables remain supported and take precedence over stored keys:
332
+
333
+ ```bash
334
+ export OLLAMA_API_KEY="..."
335
+ export TAVILY_API_KEY="tvly-..."
336
+ ```
337
+
338
+ The timeout applies independently to each provider attempt, so a failed Ollama request followed by Tavily can take up to roughly twice the configured timeout. Tavily Search fallback uses basic search and clamps `max_results` to Tavily's documented maximum of 20. Tavily Extract does not return page title/link metadata, so fallback uses the requested URL as the title and reports no links.
314
339
 
315
340
  Troubleshooting:
316
341
 
317
342
  | Symptom | Fix |
318
343
  | --- | --- |
319
344
  | `Could not connect to Ollama` | Start Ollama and check `OLLAMA_HOST`. |
320
- | `Unauthorized by Ollama ... Run ollama signin` | Run `ollama signin`, then retry. |
345
+ | `Unauthorized by Ollama` | Run `ollama signin` for local Ollama, or update the Ollama key through `/web-credentials` / `OLLAMA_API_KEY`. |
321
346
  | `endpoint is not available` | Update Ollama and make sure the experimental web search/fetch feature is enabled for that install. |
322
347
  | `timed out after ...` | Increase per-call `timeout_ms` or `PI_WEB_SEARCH_TIMEOUT_MS` if the local web endpoint is slow. |
323
348
  | `invalid JSON` / `unexpected response` | Check the Ollama version and the raw endpoint behavior; the tool reports the bad response shape instead of failing with a generic parser error. |
349
+ | `Tavily ... rejected TAVILY_API_KEY` | Update the Tavily key through `/web-credentials` or `TAVILY_API_KEY`. |
350
+ | `Tavily ... limit was exceeded` | Check Tavily usage/plan limits in the Tavily dashboard. |
324
351
 
325
352
  Do not send secrets, tokens, private repository text, or credential-bearing URLs through these tools; Ollama may query external web services to satisfy the request.
326
353
 
@@ -43,9 +43,9 @@
43
43
  "vscode-languageserver-protocol": "^3.17.5"
44
44
  },
45
45
  "peerDependencies": {
46
- "@earendil-works/pi-ai": "0.81.1",
47
- "@earendil-works/pi-coding-agent": "0.81.1",
48
- "@earendil-works/pi-tui": "0.81.1",
46
+ "@earendil-works/pi-ai": "0.82.1",
47
+ "@earendil-works/pi-coding-agent": "0.82.1",
48
+ "@earendil-works/pi-tui": "0.82.1",
49
49
  "typebox": "*"
50
50
  },
51
51
  "devDependencies": {
@@ -246,8 +246,8 @@ export const WEB_SEARCH_TOOL_DESCRIPTIONS = {
246
246
  name: "web_search",
247
247
  label: "web-search",
248
248
  description:
249
- "Search the web for real-time information using your local Ollama instance's web_search API. Requires Ollama running locally with web search enabled; supports per-call timeout_ms and PI_WEB_SEARCH_TIMEOUT_MS.",
250
- promptSnippet: "Search the web for current or real-time information through the local Ollama web_search API.",
249
+ "Search the web for real-time information through Ollama, with automatic configured Tavily fallback. Credentials come from /web-credentials or environment variables. Supports per-call timeout_ms and PI_WEB_SEARCH_TIMEOUT_MS.",
250
+ promptSnippet: "Search the web for current or real-time information through Ollama, with Tavily fallback when configured.",
251
251
  promptGuidelines: [
252
252
  "Use web_search only for current public web information; keep queries focused and set max_results only when useful.",
253
253
  "Never include secrets, tokens, or private repository data; do not use web_search for repo-local discovery—use repo_* or file/search tools.",
@@ -257,8 +257,8 @@ export const WEB_SEARCH_TOOL_DESCRIPTIONS = {
257
257
  name: "web_fetch",
258
258
  label: "web-fetch",
259
259
  description:
260
- "Fetch and extract text content from a web page URL using your local Ollama instance's web_fetch API. Requires Ollama running locally with web fetch enabled; supports per-call timeout_ms and PI_WEB_SEARCH_TIMEOUT_MS.",
261
- promptSnippet: "Fetch and extract text from a specific URL through the local Ollama web_fetch API.",
260
+ "Fetch and extract text content from a web page URL through Ollama, with automatic configured Tavily Extract fallback. Credentials come from /web-credentials or environment variables. Supports per-call timeout_ms and PI_WEB_SEARCH_TIMEOUT_MS.",
261
+ promptSnippet: "Fetch and extract text from a specific URL through Ollama, with Tavily Extract fallback when configured.",
262
262
  promptGuidelines: [
263
263
  "Use web_fetch for user-provided URLs or web_search results needing deeper reading; never pass secret/private-credential URLs, and use read for local files.",
264
264
  ],
@@ -1,6 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
2
5
 
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
7
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
5
8
  import { Type } from "typebox";
6
9
 
@@ -25,6 +28,22 @@ interface FetchResponse {
25
28
  }
26
29
 
27
30
  type Operation = "Search" | "Fetch";
31
+ type Provider = "ollama" | "tavily";
32
+
33
+ interface ProviderResponse<T> {
34
+ data: T;
35
+ provider: Provider;
36
+ host: string;
37
+ fallbackFrom?: {
38
+ provider: "ollama";
39
+ error: string;
40
+ };
41
+ }
42
+
43
+ interface OllamaTarget {
44
+ host: string;
45
+ apiKey?: string;
46
+ }
28
47
 
29
48
  class OllamaEndpointUnavailableError extends Error {
30
49
  constructor(message: string, readonly status: number) {
@@ -34,6 +53,14 @@ class OllamaEndpointUnavailableError extends Error {
34
53
  }
35
54
 
36
55
  const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
56
+ const OLLAMA_CLOUD_HOST = "https://ollama.com";
57
+ const OLLAMA_API_KEY_ENV = "OLLAMA_API_KEY";
58
+ const OLLAMA_API_KEYS_URL = "https://ollama.com/settings/keys";
59
+ const TAVILY_API_HOST = "https://api.tavily.com";
60
+ const TAVILY_API_KEY_ENV = "TAVILY_API_KEY";
61
+ const TAVILY_API_KEYS_URL = "https://app.tavily.com/home";
62
+ const CREDENTIAL_PATH_ENV = "PI_TOOLS_SUITE_WEB_CREDENTIALS_PATH";
63
+ const TAVILY_MAX_SEARCH_RESULTS = 20;
37
64
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
38
65
  const MAX_REQUEST_TIMEOUT_MS = 120_000;
39
66
  const REQUEST_TIMEOUT_ENV = "PI_WEB_SEARCH_TIMEOUT_MS";
@@ -48,8 +75,71 @@ function normalizeOllamaHost(host: string | undefined): string {
48
75
  return /^https?:\/\//i.test(trimmed) ? trimmed.replace(/\/+$/, "") : `http://${trimmed.replace(/\/+$/, "")}`;
49
76
  }
50
77
 
51
- function getOllamaHost(): string {
52
- return normalizeOllamaHost(process.env.OLLAMA_HOST);
78
+ interface StoredCredentials {
79
+ ollama?: string;
80
+ tavily?: string;
81
+ }
82
+
83
+ type StoredCredentialName = keyof StoredCredentials;
84
+
85
+ function credentialPath(): string {
86
+ return process.env[CREDENTIAL_PATH_ENV]?.trim() || join(homedir(), ".config", "pi", "pi-tools-suite-credentials.json");
87
+ }
88
+
89
+ function readStoredCredentials(): StoredCredentials {
90
+ try {
91
+ const value = JSON.parse(readFileSync(credentialPath(), "utf8")) as unknown;
92
+ if (!isRecord(value)) return {};
93
+ return {
94
+ ollama: optionalString(value.ollama)?.trim() || undefined,
95
+ tavily: optionalString(value.tavily)?.trim() || undefined,
96
+ };
97
+ } catch (error) {
98
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ function writeStoredCredentials(credentials: StoredCredentials): void {
104
+ const path = credentialPath();
105
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
106
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
107
+
108
+ try {
109
+ writeFileSync(tempPath, `${JSON.stringify(credentials, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
110
+ renameSync(tempPath, path);
111
+ chmodSync(path, 0o600);
112
+ } finally {
113
+ if (existsSync(tempPath)) unlinkSync(tempPath);
114
+ }
115
+ }
116
+
117
+ function updateStoredCredential(name: StoredCredentialName, apiKey: string | undefined): void {
118
+ const credentials = readStoredCredentials();
119
+ if (apiKey) credentials[name] = apiKey;
120
+ else delete credentials[name];
121
+ writeStoredCredentials(credentials);
122
+ }
123
+
124
+ function resolveApiKey(envName: string, credentialName: StoredCredentialName): string | undefined {
125
+ const envKey = process.env[envName]?.trim();
126
+ if (envKey) return envKey;
127
+ return readStoredCredentials()[credentialName];
128
+ }
129
+
130
+ function resolveOllamaTarget(): OllamaTarget {
131
+ const apiKey = resolveApiKey(OLLAMA_API_KEY_ENV, "ollama");
132
+ const configuredHost = process.env.OLLAMA_HOST?.trim();
133
+
134
+ return {
135
+ host: normalizeOllamaHost(configuredHost || (apiKey ? OLLAMA_CLOUD_HOST : undefined)),
136
+ apiKey,
137
+ };
138
+ }
139
+
140
+ function ollamaRequestUrl(target: OllamaTarget, endpoint: "web_search" | "web_fetch"): string {
141
+ const apiPath = target.host === OLLAMA_CLOUD_HOST ? `/api/${endpoint}` : `/api/experimental/${endpoint}`;
142
+ return `${target.host}${apiPath}`;
53
143
  }
54
144
 
55
145
  function parseTimeoutMs(value: unknown, source: string): number {
@@ -225,6 +315,10 @@ function operationNoun(operation: Operation): "search" | "fetch" {
225
315
  return operation === "Search" ? "search" : "fetch";
226
316
  }
227
317
 
318
+ function tavilyEndpoint(operation: Operation): "search" | "extract" {
319
+ return operation === "Search" ? "search" : "extract";
320
+ }
321
+
228
322
  function formatErrorBody(body: string): string {
229
323
  const normalized = body.trim().replace(/\s+/g, " ");
230
324
  if (!normalized) return "";
@@ -237,7 +331,10 @@ function createHttpError(response: Response, operation: Operation, host: string,
237
331
  const withBody = bodySuffix ? ` Response: ${bodySuffix}` : "";
238
332
 
239
333
  if (response.status === 401) {
240
- return new Error(`Unauthorized by Ollama ${apiName} API at ${host}. Run \`ollama signin\` to authenticate.`);
334
+ return new Error(
335
+ `Unauthorized by Ollama ${apiName} API at ${host}. ` +
336
+ `Run \`ollama signin\` for local Ollama, or update ${OLLAMA_API_KEY_ENV} through /web-credentials or the launch environment.`,
337
+ );
241
338
  }
242
339
 
243
340
  if (response.status === 403) {
@@ -333,13 +430,17 @@ function normalizeOllamaError(error: unknown, operation: Operation, host: string
333
430
  return error instanceof Error ? error : new Error(String(error));
334
431
  }
335
432
 
336
- async function postOllamaJson<T>(host: string, endpoint: "web_search" | "web_fetch", body: Record<string, unknown>, operation: Operation, signal: AbortSignal | undefined, timeoutMs: number, retryEndpointUnavailable = true): Promise<T> {
433
+ async function postOllamaJson<T>(target: OllamaTarget, endpoint: "web_search" | "web_fetch", body: Record<string, unknown>, operation: Operation, signal: AbortSignal | undefined, timeoutMs: number, retryEndpointUnavailable = true): Promise<T> {
434
+ const { host } = target;
337
435
  const requestSignal = createRequestSignal(signal, timeoutMs);
338
436
 
339
437
  try {
340
- const response = await fetch(`${host}/api/experimental/${endpoint}`, {
438
+ const response = await fetch(ollamaRequestUrl(target, endpoint), {
341
439
  method: "POST",
342
- headers: { "Content-Type": "application/json" },
440
+ headers: {
441
+ ...(target.apiKey ? { Authorization: `Bearer ${target.apiKey}` } : {}),
442
+ "Content-Type": "application/json",
443
+ },
343
444
  body: JSON.stringify(body),
344
445
  signal: requestSignal.signal,
345
446
  });
@@ -349,12 +450,12 @@ async function postOllamaJson<T>(host: string, endpoint: "web_search" | "web_fet
349
450
  if (isConnectionRefused(error) && isLoopbackHost(host)) {
350
451
  requestSignal.cleanup();
351
452
  await ensureOllamaRunning(host, timeoutMs, signal);
352
- return waitForEndpointReady(() => postOllamaJson<T>(host, endpoint, body, operation, signal, timeoutMs, false), host, operation, timeoutMs, signal);
453
+ return waitForEndpointReady(() => postOllamaJson<T>(target, endpoint, body, operation, signal, timeoutMs, false), host, operation, timeoutMs, signal);
353
454
  }
354
455
 
355
456
  if (retryEndpointUnavailable && isEndpointUnavailable(error) && isLoopbackHost(host)) {
356
457
  requestSignal.cleanup();
357
- return waitForEndpointReady(() => postOllamaJson<T>(host, endpoint, body, operation, signal, timeoutMs, false), host, operation, timeoutMs, signal);
458
+ return waitForEndpointReady(() => postOllamaJson<T>(target, endpoint, body, operation, signal, timeoutMs, false), host, operation, timeoutMs, signal);
358
459
  }
359
460
 
360
461
  throw normalizeOllamaError(error, operation, host, timeoutMs, requestSignal.timedOut(), signal);
@@ -363,6 +464,85 @@ async function postOllamaJson<T>(host: string, endpoint: "web_search" | "web_fet
363
464
  }
364
465
  }
365
466
 
467
+ function createTavilyHttpError(response: Response, operation: Operation, body: string): Error {
468
+ const endpoint = tavilyEndpoint(operation);
469
+ const bodySuffix = formatErrorBody(body);
470
+ const withBody = bodySuffix ? ` Response: ${bodySuffix}` : "";
471
+
472
+ if (response.status === 401) {
473
+ return new Error(`Tavily ${endpoint} API rejected ${TAVILY_API_KEY_ENV} (HTTP 401). Check that the API key is valid.`);
474
+ }
475
+
476
+ if (response.status === 429 || response.status === 432 || response.status === 433) {
477
+ return new Error(`Tavily ${endpoint} API limit was exceeded (HTTP ${response.status}).${withBody}`);
478
+ }
479
+
480
+ return new Error(`Tavily ${endpoint} API returned HTTP ${response.status}.${withBody || ` ${response.statusText}`}`);
481
+ }
482
+
483
+ function normalizeTavilyError(error: unknown, operation: Operation, timeoutMs: number, timedOut: boolean, parentSignal: AbortSignal | undefined): Error {
484
+ const endpoint = tavilyEndpoint(operation);
485
+
486
+ if (timedOut) {
487
+ return new Error(
488
+ `Tavily ${endpoint} request timed out after ${timeoutMs}ms. ` +
489
+ `Increase timeout_ms or ${REQUEST_TIMEOUT_ENV} if the fallback endpoint is slow.`,
490
+ );
491
+ }
492
+
493
+ if (isAbortError(error) && parentSignal?.aborted) {
494
+ return new Error(`Tavily ${endpoint} request was cancelled.`);
495
+ }
496
+
497
+ if (errorIncludes(error, "ENOTFOUND", "EAI_AGAIN")) {
498
+ return new Error(`Could not resolve Tavily host ${TAVILY_API_HOST}.`);
499
+ }
500
+
501
+ if (errorIncludes(error, "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT")) {
502
+ const details = collectErrorText(error);
503
+ return new Error(`Connection to Tavily failed while calling ${endpoint}.${details ? ` ${details}` : ""}`);
504
+ }
505
+
506
+ if (error instanceof TypeError && error.message.toLowerCase().includes("fetch")) {
507
+ return new Error(`Request to Tavily failed while calling ${endpoint}: ${error.message}`);
508
+ }
509
+
510
+ return error instanceof Error ? error : new Error(String(error));
511
+ }
512
+
513
+ async function postTavilyJson<T>(apiKey: string, operation: Operation, body: Record<string, unknown>, signal: AbortSignal | undefined, timeoutMs: number): Promise<T> {
514
+ const endpoint = tavilyEndpoint(operation);
515
+ const requestSignal = createRequestSignal(signal, timeoutMs);
516
+
517
+ try {
518
+ const response = await fetch(`${TAVILY_API_HOST}/${endpoint}`, {
519
+ method: "POST",
520
+ headers: {
521
+ Authorization: `Bearer ${apiKey}`,
522
+ "Content-Type": "application/json",
523
+ },
524
+ body: JSON.stringify(body),
525
+ signal: requestSignal.signal,
526
+ });
527
+ const responseBody = await response.text().catch(() => "");
528
+
529
+ if (!response.ok) throw createTavilyHttpError(response, operation, responseBody);
530
+ if (!responseBody.trim()) throw new Error(`Tavily ${endpoint} API returned an empty response.`);
531
+
532
+ try {
533
+ return JSON.parse(responseBody) as T;
534
+ } catch (error) {
535
+ const parseMessage = error instanceof Error ? error.message : String(error);
536
+ const bodySuffix = formatErrorBody(responseBody);
537
+ throw new Error(`Tavily ${endpoint} API returned invalid JSON: ${parseMessage}.${bodySuffix ? ` Body: ${bodySuffix}` : ""}`);
538
+ }
539
+ } catch (error) {
540
+ throw normalizeTavilyError(error, operation, timeoutMs, requestSignal.timedOut(), signal);
541
+ } finally {
542
+ requestSignal.cleanup();
543
+ }
544
+ }
545
+
366
546
  function isRecord(value: unknown): value is Record<string, unknown> {
367
547
  return typeof value === "object" && value !== null && !Array.isArray(value);
368
548
  }
@@ -371,17 +551,17 @@ function optionalString(value: unknown): string | undefined {
371
551
  return typeof value === "string" ? value : undefined;
372
552
  }
373
553
 
374
- function parseSearchResponse(data: unknown): SearchResponse {
554
+ function parseSearchResponse(data: unknown, providerName = "Ollama"): SearchResponse {
375
555
  if (!isRecord(data) || !Array.isArray(data.results)) {
376
- throw new Error("Ollama web_search API returned an unexpected response: missing results array.");
556
+ throw new Error(`${providerName} web_search API returned an unexpected response: missing results array.`);
377
557
  }
378
558
 
379
559
  return {
380
560
  results: data.results.map((item, index) => {
381
- if (!isRecord(item)) throw new Error(`Ollama web_search API returned an invalid result at index ${index}.`);
561
+ if (!isRecord(item)) throw new Error(`${providerName} web_search API returned an invalid result at index ${index}.`);
382
562
 
383
563
  const url = optionalString(item.url);
384
- if (!url) throw new Error(`Ollama web_search API returned an invalid result at index ${index}: missing url.`);
564
+ if (!url) throw new Error(`${providerName} web_search API returned an invalid result at index ${index}: missing url.`);
385
565
 
386
566
  return {
387
567
  title: optionalString(item.title) || "Untitled",
@@ -392,6 +572,156 @@ function parseSearchResponse(data: unknown): SearchResponse {
392
572
  };
393
573
  }
394
574
 
575
+ function parseTavilyExtractResponse(data: unknown, requestedUrl: string): FetchResponse {
576
+ if (!isRecord(data) || !Array.isArray(data.results)) {
577
+ throw new Error("Tavily extract API returned an unexpected response: missing results array.");
578
+ }
579
+
580
+ const result = data.results.find((item) => isRecord(item) && item.url === requestedUrl) ?? data.results[0];
581
+ if (!isRecord(result) || typeof result.raw_content !== "string") {
582
+ const failedResult = Array.isArray(data.failed_results)
583
+ ? data.failed_results.find((item) => isRecord(item) && item.url === requestedUrl) ?? data.failed_results[0]
584
+ : undefined;
585
+ const failedMessage = isRecord(failedResult) ? optionalString(failedResult.error) : undefined;
586
+ throw new Error(`Tavily extract API did not return content for ${requestedUrl}.${failedMessage ? ` ${failedMessage}` : ""}`);
587
+ }
588
+
589
+ return {
590
+ title: requestedUrl,
591
+ content: result.raw_content,
592
+ links: [],
593
+ };
594
+ }
595
+
596
+ function errorMessage(error: unknown): string {
597
+ return error instanceof Error ? error.message : String(error);
598
+ }
599
+
600
+ async function withTavilyFallback<T>(
601
+ operation: Operation,
602
+ signal: AbortSignal | undefined,
603
+ ollamaHost: string,
604
+ tavilyApiKey: string | undefined,
605
+ ollamaRequest: () => Promise<T>,
606
+ tavilyRequest: (apiKey: string) => Promise<T>,
607
+ ): Promise<ProviderResponse<T>> {
608
+ try {
609
+ return { data: await ollamaRequest(), provider: "ollama", host: ollamaHost };
610
+ } catch (ollamaError) {
611
+ if (signal?.aborted) throw ollamaError;
612
+
613
+ if (!tavilyApiKey) throw ollamaError;
614
+
615
+ try {
616
+ return {
617
+ data: await tavilyRequest(tavilyApiKey),
618
+ provider: "tavily",
619
+ host: TAVILY_API_HOST,
620
+ fallbackFrom: { provider: "ollama", error: errorMessage(ollamaError) },
621
+ };
622
+ } catch (tavilyError) {
623
+ if (signal?.aborted) throw tavilyError;
624
+
625
+ throw new Error(
626
+ `Ollama ${endpointName(operation)} failed: ${errorMessage(ollamaError)} ` +
627
+ `Tavily ${tavilyEndpoint(operation)} fallback also failed: ${errorMessage(tavilyError)}`,
628
+ );
629
+ }
630
+ }
631
+ }
632
+
633
+ type CredentialChoice =
634
+ | "Set Ollama API key"
635
+ | "Set Tavily API key"
636
+ | "Show credential status"
637
+ | "Clear stored Ollama key"
638
+ | "Clear stored Tavily key";
639
+
640
+ const CREDENTIAL_CHOICES: CredentialChoice[] = [
641
+ "Set Ollama API key",
642
+ "Set Tavily API key",
643
+ "Show credential status",
644
+ "Clear stored Ollama key",
645
+ "Clear stored Tavily key",
646
+ ];
647
+
648
+ function storedCredentialConfigured(name: StoredCredentialName): boolean {
649
+ return Boolean(readStoredCredentials()[name]);
650
+ }
651
+
652
+ function credentialSource(envName: string, credentialName: StoredCredentialName): string {
653
+ if (process.env[envName]?.trim()) return `environment (${envName})`;
654
+ return storedCredentialConfigured(credentialName) ? "stored in pi-tools-suite credentials" : "not configured";
655
+ }
656
+
657
+ function showCredentialStatus(ctx: ExtensionCommandContext): void {
658
+ const ollama = credentialSource(OLLAMA_API_KEY_ENV, "ollama");
659
+ const tavily = credentialSource(TAVILY_API_KEY_ENV, "tavily");
660
+ ctx.ui.notify(`Web credentials\nOllama: ${ollama}\nTavily: ${tavily}`, "info");
661
+ }
662
+
663
+ function showCredentialLinks(ctx: ExtensionCommandContext): void {
664
+ ctx.ui.notify(
665
+ `Get API keys\nOllama: ${OLLAMA_API_KEYS_URL}\nTavily: ${TAVILY_API_KEYS_URL}`,
666
+ "info",
667
+ );
668
+ }
669
+
670
+ async function setCredential(
671
+ ctx: ExtensionCommandContext,
672
+ provider: "Ollama" | "Tavily",
673
+ credentialName: StoredCredentialName,
674
+ ): Promise<void> {
675
+ const key = (await ctx.ui.input(`${provider} API key`, "Paste the API key; Escape cancels"))?.trim();
676
+ if (!key) {
677
+ ctx.ui.notify(`${provider} credential was not changed.`, "warning");
678
+ return;
679
+ }
680
+
681
+ updateStoredCredential(credentialName, key);
682
+ ctx.ui.notify(`${provider} credential saved securely and active for future web tool calls.`, "info");
683
+ }
684
+
685
+ function clearCredential(
686
+ ctx: ExtensionCommandContext,
687
+ provider: "Ollama" | "Tavily",
688
+ credentialName: StoredCredentialName,
689
+ envName: string,
690
+ ): void {
691
+ updateStoredCredential(credentialName, undefined);
692
+ const envSuffix = process.env[envName]?.trim() ? ` ${envName} is still set and remains active.` : "";
693
+ ctx.ui.notify(`Stored ${provider} credential removed.${envSuffix}`, "info");
694
+ }
695
+
696
+ function registerCredentialCommand(pi: ExtensionAPI): void {
697
+ pi.registerCommand("web-credentials", {
698
+ description: "Configure Ollama and Tavily API keys for web_search/web_fetch",
699
+ handler: async (_args, ctx) => {
700
+ if (!ctx.hasUI) return;
701
+ showCredentialLinks(ctx);
702
+
703
+ const choice = await ctx.ui.select("Web search credentials", CREDENTIAL_CHOICES) as CredentialChoice | undefined;
704
+ switch (choice) {
705
+ case "Set Ollama API key":
706
+ await setCredential(ctx, "Ollama", "ollama");
707
+ break;
708
+ case "Set Tavily API key":
709
+ await setCredential(ctx, "Tavily", "tavily");
710
+ break;
711
+ case "Show credential status":
712
+ showCredentialStatus(ctx);
713
+ break;
714
+ case "Clear stored Ollama key":
715
+ clearCredential(ctx, "Ollama", "ollama", OLLAMA_API_KEY_ENV);
716
+ break;
717
+ case "Clear stored Tavily key":
718
+ clearCredential(ctx, "Tavily", "tavily", TAVILY_API_KEY_ENV);
719
+ break;
720
+ }
721
+ },
722
+ });
723
+ }
724
+
395
725
  function parseFetchResponse(data: unknown): FetchResponse {
396
726
  if (!isRecord(data) || typeof data.content !== "string") {
397
727
  throw new Error("Ollama web_fetch API returned an unexpected response: missing content string.");
@@ -464,30 +794,37 @@ function timeoutParameter() {
464
794
  );
465
795
  }
466
796
 
467
- function searchResultDetails(data: SearchResponse, host: string, timeoutMs: number, truncated: boolean) {
797
+ function searchResultDetails(response: ProviderResponse<SearchResponse>, timeoutMs: number, truncated: boolean) {
468
798
  return {
469
- results: data.results,
470
- resultCount: data.results.length,
471
- host,
799
+ results: response.data.results,
800
+ resultCount: response.data.results.length,
801
+ provider: response.provider,
802
+ host: response.host,
803
+ fallbackFrom: response.fallbackFrom,
472
804
  timeoutMs,
473
805
  truncated,
474
806
  };
475
807
  }
476
808
 
477
- function fetchResultDetails(data: FetchResponse, host: string, timeoutMs: number, truncated: boolean) {
809
+ function fetchResultDetails(response: ProviderResponse<FetchResponse>, timeoutMs: number, truncated: boolean) {
810
+ const data = response.data;
478
811
  return {
479
812
  title: data.title,
480
813
  content: data.content,
481
814
  contentBytes: contentByteLength(data.content),
482
815
  links: data.links ?? [],
483
816
  linkCount: data.links?.length ?? 0,
484
- host,
817
+ provider: response.provider,
818
+ host: response.host,
819
+ fallbackFrom: response.fallbackFrom,
485
820
  timeoutMs,
486
821
  truncated,
487
822
  };
488
823
  }
489
824
 
490
825
  export default function webSearch(pi: ExtensionAPI) {
826
+ registerCredentialCommand(pi);
827
+
491
828
  pi.registerTool({
492
829
  ...WEB_SEARCH_TOOL_DESCRIPTIONS.webSearch,
493
830
  parameters: Type.Object({
@@ -497,16 +834,30 @@ export default function webSearch(pi: ExtensionAPI) {
497
834
  }),
498
835
  async execute(_toolCallId, params, signal) {
499
836
  const maxResults = params.max_results ?? 5;
500
- const host = getOllamaHost();
837
+ const ollamaTarget = resolveOllamaTarget();
838
+ const tavilyApiKey = resolveApiKey(TAVILY_API_KEY_ENV, "tavily");
501
839
  const timeoutMs = resolveRequestTimeoutMs(params.timeout_ms);
502
840
 
503
- const rawData = await postOllamaJson<unknown>(host, "web_search", { query: params.query, max_results: maxResults }, "Search", signal, timeoutMs);
504
- const data = parseSearchResponse(rawData);
505
- const formatted = truncateForTool(formatSearchResults(data.results));
841
+ const response = await withTavilyFallback(
842
+ "Search",
843
+ signal,
844
+ ollamaTarget.host,
845
+ tavilyApiKey,
846
+ async () => parseSearchResponse(await postOllamaJson<unknown>(ollamaTarget, "web_search", { query: params.query, max_results: maxResults }, "Search", signal, timeoutMs)),
847
+ async (apiKey) => parseSearchResponse(
848
+ await postTavilyJson<unknown>(apiKey, "Search", {
849
+ query: params.query,
850
+ max_results: Math.max(0, Math.min(TAVILY_MAX_SEARCH_RESULTS, Math.trunc(maxResults))),
851
+ search_depth: "basic",
852
+ }, signal, timeoutMs),
853
+ "Tavily",
854
+ ),
855
+ );
856
+ const formatted = truncateForTool(formatSearchResults(response.data.results));
506
857
 
507
858
  return {
508
859
  content: [{ type: "text", text: formatted.text }],
509
- details: searchResultDetails(data, host, timeoutMs, formatted.truncated),
860
+ details: searchResultDetails(response, timeoutMs, formatted.truncated),
510
861
  };
511
862
  },
512
863
  });
@@ -518,16 +869,30 @@ export default function webSearch(pi: ExtensionAPI) {
518
869
  timeout_ms: timeoutParameter(),
519
870
  }),
520
871
  async execute(_toolCallId, params, signal) {
521
- const host = getOllamaHost();
872
+ const ollamaTarget = resolveOllamaTarget();
873
+ const tavilyApiKey = resolveApiKey(TAVILY_API_KEY_ENV, "tavily");
522
874
  const timeoutMs = resolveRequestTimeoutMs(params.timeout_ms);
523
875
 
524
- const rawData = await postOllamaJson<unknown>(host, "web_fetch", { url: params.url }, "Fetch", signal, timeoutMs);
525
- const data = parseFetchResponse(rawData);
526
- const formatted = truncateForTool(formatFetchResult(data));
876
+ const response = await withTavilyFallback(
877
+ "Fetch",
878
+ signal,
879
+ ollamaTarget.host,
880
+ tavilyApiKey,
881
+ async () => parseFetchResponse(await postOllamaJson<unknown>(ollamaTarget, "web_fetch", { url: params.url }, "Fetch", signal, timeoutMs)),
882
+ async (apiKey) => parseTavilyExtractResponse(
883
+ await postTavilyJson<unknown>(apiKey, "Fetch", {
884
+ urls: [params.url],
885
+ extract_depth: "basic",
886
+ format: "markdown",
887
+ }, signal, timeoutMs),
888
+ params.url,
889
+ ),
890
+ );
891
+ const formatted = truncateForTool(formatFetchResult(response.data));
527
892
 
528
893
  return {
529
894
  content: [{ type: "text", text: formatted.text }],
530
- details: fetchResultDetails(data, host, timeoutMs, formatted.truncated),
895
+ details: fetchResultDetails(response, timeoutMs, formatted.truncated),
531
896
  };
532
897
  },
533
898
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.71",
3
+ "version": "0.1.74",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -75,9 +75,9 @@
75
75
  "prepublishOnly": "npm run check && npm run build:pix && npm run generate-schemas"
76
76
  },
77
77
  "dependencies": {
78
- "@earendil-works/pi-ai": "0.81.1",
79
- "@earendil-works/pi-coding-agent": "0.81.1",
80
- "@earendil-works/pi-tui": "0.81.1",
78
+ "@earendil-works/pi-ai": "0.82.1",
79
+ "@earendil-works/pi-coding-agent": "0.82.1",
80
+ "@earendil-works/pi-tui": "0.82.1",
81
81
  "@mariozechner/clipboard": "^0.3.9",
82
82
  "jsonc-parser": "3.3.1",
83
83
  "typebox": "1.1.38",