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 +2 -0
- package/dist/app/app.js +17 -0
- package/dist/app/commands/command-model-actions.js +4 -4
- package/dist/app/icons.d.ts +1 -0
- package/dist/app/icons.js +2 -0
- package/dist/app/input/autocomplete-controller.js +1 -1
- package/dist/app/input/prompt-enhancer-controller.js +1 -1
- package/dist/app/rendering/render-controller.js +1 -0
- package/dist/app/rendering/status-line-renderer.d.ts +3 -1
- package/dist/app/rendering/status-line-renderer.js +11 -0
- package/dist/app/runtime.js +1 -1
- package/dist/app/screen/mouse-controller.d.ts +5 -1
- package/dist/app/screen/mouse-controller.js +14 -2
- package/dist/app/types.d.ts +10 -0
- package/external/pi-tools-suite/README.md +33 -6
- package/external/pi-tools-suite/package.json +3 -3
- package/external/pi-tools-suite/src/tool-descriptions.ts +4 -4
- package/external/pi-tools-suite/src/web-search/index.ts +394 -29
- package/package.json +4 -4
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.
|
|
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.
|
|
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.
|
|
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.
|
|
219
|
+
await runtime.services.modelRuntime.refresh();
|
|
220
220
|
if (!isCommandScopeActive(this.host, scope))
|
|
221
221
|
return;
|
|
222
222
|
for (const ref of refs) {
|
package/dist/app/icons.d.ts
CHANGED
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.
|
|
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.
|
|
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()
|
package/dist/app/runtime.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
686
|
-
|
|
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");
|
package/dist/app/types.d.ts
CHANGED
|
@@ -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`;
|
|
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-
|
|
308
|
+
`src/web-search` registers two Ollama-first tools and the `/web-credentials` setup command:
|
|
309
309
|
|
|
310
|
-
- `web_search` posts `{ query, max_results }`
|
|
311
|
-
- `web_fetch` posts `{ url }`
|
|
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
|
-
|
|
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
|
|
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.
|
|
47
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
48
|
-
"@earendil-works/pi-tui": "0.
|
|
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
|
|
250
|
-
promptSnippet: "Search the web for current or real-time information through
|
|
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
|
|
261
|
-
promptSnippet: "Fetch and extract text from a specific URL through
|
|
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
|
-
|
|
52
|
-
|
|
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(
|
|
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>(
|
|
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(
|
|
438
|
+
const response = await fetch(ollamaRequestUrl(target, endpoint), {
|
|
341
439
|
method: "POST",
|
|
342
|
-
headers: {
|
|
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>(
|
|
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>(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
797
|
+
function searchResultDetails(response: ProviderResponse<SearchResponse>, timeoutMs: number, truncated: boolean) {
|
|
468
798
|
return {
|
|
469
|
-
results: data.results,
|
|
470
|
-
resultCount: data.results.length,
|
|
471
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
504
|
-
|
|
505
|
-
|
|
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(
|
|
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
|
|
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
|
|
525
|
-
|
|
526
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
79
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
80
|
-
"@earendil-works/pi-tui": "0.
|
|
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",
|