pi-jar 0.1.0
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/LICENSE +21 -0
- package/README.md +288 -0
- package/docs/DESIGN.md +85 -0
- package/docs/DEVELOPMENT.md +95 -0
- package/docs/FLAME.md +54 -0
- package/docs/INTEGRATIONS.md +82 -0
- package/docs/PLAN.md +68 -0
- package/docs/SHOWCASE.md +22 -0
- package/docs/WORKFLOWS.md +141 -0
- package/extensions/index.ts +773 -0
- package/package.json +62 -0
- package/src/accent.ts +36 -0
- package/src/advisor.ts +194 -0
- package/src/animations.ts +23 -0
- package/src/ask-tool.ts +185 -0
- package/src/attachments.ts +79 -0
- package/src/changes.ts +146 -0
- package/src/commit.ts +61 -0
- package/src/compact-tools.ts +129 -0
- package/src/composer.ts +404 -0
- package/src/context-view.ts +141 -0
- package/src/delegate.ts +208 -0
- package/src/dialogs.ts +127 -0
- package/src/diff-view.ts +160 -0
- package/src/flame.ts +245 -0
- package/src/footer-settings.ts +32 -0
- package/src/footer.ts +147 -0
- package/src/goal-loop.ts +313 -0
- package/src/goals.ts +114 -0
- package/src/history-ui.ts +136 -0
- package/src/history.ts +168 -0
- package/src/info-panels.ts +75 -0
- package/src/mascot.ts +103 -0
- package/src/model-roles.ts +332 -0
- package/src/panel.ts +90 -0
- package/src/plan-utils.ts +191 -0
- package/src/plan-view.ts +149 -0
- package/src/plan.ts +460 -0
- package/src/prompt-search.ts +83 -0
- package/src/quota.ts +134 -0
- package/src/roles-ui.ts +190 -0
- package/src/roles.ts +25 -0
- package/src/session-gallery.ts +52 -0
- package/src/session-ui.ts +76 -0
- package/src/settings-ui.ts +154 -0
- package/src/settings.ts +96 -0
- package/src/shells.ts +319 -0
- package/src/side-model.ts +48 -0
- package/src/split-view.ts +74 -0
- package/src/status.ts +71 -0
- package/src/suggest.ts +89 -0
- package/src/task-tool.ts +135 -0
- package/src/tasks-ui.ts +73 -0
- package/src/tasks.ts +147 -0
- package/src/usage-view.ts +98 -0
- package/src/usage.ts +16 -0
- package/src/welcome.ts +260 -0
- package/src/working.ts +60 -0
- package/themes/pi-jar-dark-amber.json +84 -0
- package/themes/pi-jar-dark-azure.json +84 -0
- package/themes/pi-jar-dark-gray.json +84 -0
- package/themes/pi-jar-dark-pink.json +84 -0
- package/themes/pi-jar-dark-teal.json +84 -0
- package/themes/pi-jar-dark-violet.json +84 -0
- package/themes/pi-jar-dark.json +84 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
|
|
4
|
+
import { GOAL_ROUND_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
|
|
5
|
+
|
|
6
|
+
const LABELS: Record<FooterField, string> = {
|
|
7
|
+
model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory",
|
|
8
|
+
context: "Context usage", memory: "Process RAM (RSS)", cost: "Session cost", quota: "Quota windows", roles: "Active roles",
|
|
9
|
+
extras: "Extension statuses", branch: "Git branch"
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type Page = "appearance" | "footer" | "pi";
|
|
13
|
+
const PAGES: Page[] = ["appearance", "footer", "pi"];
|
|
14
|
+
|
|
15
|
+
/** Pi-level preferences pi-jar can toggle (written to Pi's own settings). */
|
|
16
|
+
export interface PiPreferences {
|
|
17
|
+
get(): { fullscreen: boolean; copyOnSelect: boolean };
|
|
18
|
+
setFullscreen(enabled: boolean): void;
|
|
19
|
+
setCopyOnSelect(enabled: boolean): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A single settings screen, keyboard-accessible everywhere and clickable in Pi fullscreen mode. */
|
|
23
|
+
export async function openJarSettings(
|
|
24
|
+
ctx: ExtensionContext,
|
|
25
|
+
current: () => JarVisualSettings,
|
|
26
|
+
update: (settings: JarVisualSettings) => void,
|
|
27
|
+
availableAccents: readonly string[],
|
|
28
|
+
pi?: PiPreferences
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
31
|
+
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
32
|
+
let page: Page = "appearance";
|
|
33
|
+
let selected = 0;
|
|
34
|
+
let width = 64;
|
|
35
|
+
const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
|
|
36
|
+
name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
|
|
37
|
+
const fields = () => page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 5 : 6;
|
|
38
|
+
const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
|
|
39
|
+
const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - 9));
|
|
40
|
+
const firstVisible = () => Math.min(Math.max(0, selected - pageSize() + 1), Math.max(0, fields() - pageSize()));
|
|
41
|
+
const visibleCount = () => Math.min(pageSize(), fields());
|
|
42
|
+
const apply = (index: number) => {
|
|
43
|
+
const state = current();
|
|
44
|
+
if (page === "footer") {
|
|
45
|
+
const field = FOOTER_FIELDS[index];
|
|
46
|
+
if (field) update({ ...state, footer: { ...state.footer, [field]: !state.footer[field] } });
|
|
47
|
+
} else if (page === "pi") {
|
|
48
|
+
if (index === 0 && pi) { try { pi.setFullscreen(!pi.get().fullscreen); ctx.ui.notify("Pi TUI mode saved; restart Pi to apply", "info"); } catch (error) { piError(error); } }
|
|
49
|
+
else if (index === 1 && pi) { try { pi.setCopyOnSelect(!pi.get().copyOnSelect); } catch (error) { piError(error); } }
|
|
50
|
+
else if (index === 2) {
|
|
51
|
+
const at = GOAL_ROUND_CHOICES.indexOf(state.goalRounds as never);
|
|
52
|
+
update({ ...state, goalRounds: GOAL_ROUND_CHOICES[(at + 1) % GOAL_ROUND_CHOICES.length]! });
|
|
53
|
+
}
|
|
54
|
+
else if (index === 3) update({ ...state, advisor: !state.advisor });
|
|
55
|
+
else if (index === 4) update({ ...state, advisorGates: !state.advisorGates });
|
|
56
|
+
} else if (index === 0) {
|
|
57
|
+
const at = Math.max(0, accents.indexOf(state.accent));
|
|
58
|
+
update({ ...state, accent: accents[(at + 1) % accents.length]! });
|
|
59
|
+
} else if (index === 1) update({ ...state, animations: !state.animations });
|
|
60
|
+
else if (index === 2) update({ ...state, composer: !state.composer });
|
|
61
|
+
else if (index === 3) update({ ...state, mascot: !state.mascot });
|
|
62
|
+
else if (index === 4) update({ ...state, suggestions: !state.suggestions });
|
|
63
|
+
else if (index === 5) update({ ...state, ui: !state.ui });
|
|
64
|
+
tui.requestRender();
|
|
65
|
+
};
|
|
66
|
+
const row = (index: number, label: string, value: string, enabled: boolean) => {
|
|
67
|
+
const marker = selected === index ? theme.fg("accent", "❯") : " ";
|
|
68
|
+
const innerWidth = Math.max(0, width - 7);
|
|
69
|
+
const right = theme.fg(enabled ? "success" : "dim", value);
|
|
70
|
+
const room = Math.max(0, innerWidth - visibleWidth(right) - 1);
|
|
71
|
+
const left = truncateToWidth(label, room);
|
|
72
|
+
const body = marker + " " + left + " ".repeat(Math.max(1, innerWidth - visibleWidth(left) - visibleWidth(right))) + right;
|
|
73
|
+
return theme.fg("dim", "│ ") + truncateToWidth(body, Math.max(0, width - 5)) + theme.fg("dim", " │");
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
invalidate() {},
|
|
77
|
+
handleInput(data: string) {
|
|
78
|
+
if (matchesKey(data, Key.escape) || data === "q") { done(); return; }
|
|
79
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
80
|
+
page = PAGES[(PAGES.indexOf(page) + 1) % PAGES.length]!;
|
|
81
|
+
selected = 0;
|
|
82
|
+
} else if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
|
83
|
+
page = PAGES[(PAGES.indexOf(page) + PAGES.length - 1) % PAGES.length]!;
|
|
84
|
+
selected = 0;
|
|
85
|
+
} else if (matchesKey(data, Key.up)) selected = (selected + fields() - 1) % fields();
|
|
86
|
+
else if (matchesKey(data, Key.down)) selected = (selected + 1) % fields();
|
|
87
|
+
else if (data === " " || matchesKey(data, Key.enter)) apply(selected);
|
|
88
|
+
tui.requestRender();
|
|
89
|
+
},
|
|
90
|
+
handleMouse(event: TuiMouseEvent) {
|
|
91
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
92
|
+
selected = Math.max(0, Math.min(fields() - 1, selected + Math.sign(event.wheelDelta)));
|
|
93
|
+
tui.requestRender(); return { handled: true };
|
|
94
|
+
}
|
|
95
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
96
|
+
if (event.y === 0 && event.x >= width - 4) { done(); return { handled: true }; }
|
|
97
|
+
if (event.y === 1) {
|
|
98
|
+
if (event.x >= 2 && event.x < Math.min(width - 2, 19)) { page = "appearance"; selected = 0; }
|
|
99
|
+
else if (event.x >= 19 && event.x < Math.min(width - 2, 32)) { page = "footer"; selected = 0; }
|
|
100
|
+
else if (event.x >= 32 && event.x < width - 2) { page = "pi"; selected = 0; }
|
|
101
|
+
else return;
|
|
102
|
+
tui.requestRender(); return { handled: true, focus: true };
|
|
103
|
+
}
|
|
104
|
+
if (event.y >= 5 && event.y < 5 + visibleCount() && event.x >= 2 && event.x < width - 2) {
|
|
105
|
+
selected = firstVisible() + event.y - 5; apply(selected); return { handled: true, focus: true };
|
|
106
|
+
}
|
|
107
|
+
if (event.y === 5 + visibleCount() && event.x >= 2 && event.x < width - 2) { done(); return { handled: true }; }
|
|
108
|
+
},
|
|
109
|
+
render(available: number): string[] {
|
|
110
|
+
width = Math.max(8, available);
|
|
111
|
+
const state = current();
|
|
112
|
+
const fit = (text: string) => truncateToWidth(text, width);
|
|
113
|
+
const line = (text: string) => {
|
|
114
|
+
const inner = Math.max(0, width - 5);
|
|
115
|
+
const content = truncateToWidth(text, inner);
|
|
116
|
+
return fit(theme.fg("dim", "│ ") + content + " ".repeat(Math.max(0, inner - visibleWidth(content))) + theme.fg("dim", " │"));
|
|
117
|
+
};
|
|
118
|
+
const title = " pi-jar · settings ";
|
|
119
|
+
const top = theme.fg("accent", "╭─" + truncateToWidth(title, Math.max(0, width - 6))
|
|
120
|
+
+ "─".repeat(Math.max(0, width - 4 - visibleWidth(title))) + "×╮");
|
|
121
|
+
const tabs = line(theme.fg(page === "appearance" ? "accent" : "muted", "[ Appearance ]")
|
|
122
|
+
+ " " + theme.fg(page === "footer" ? "accent" : "muted", "[ Footer ]")
|
|
123
|
+
+ " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]"));
|
|
124
|
+
const prefs = pi?.get();
|
|
125
|
+
const divider = theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 2)) + "┤");
|
|
126
|
+
const allRows = page === "footer"
|
|
127
|
+
? FOOTER_FIELDS.map((field, index) => row(index, LABELS[field], state.footer[field] ? "ON" : "OFF", state.footer[field]))
|
|
128
|
+
: page === "pi" ? [
|
|
129
|
+
row(0, "Mouse clicks (Pi fullscreen, restart)", !prefs ? "N/A" : prefs.fullscreen ? "ON" : "OFF", !!prefs?.fullscreen),
|
|
130
|
+
row(1, "Copy on select", !prefs ? "N/A" : prefs.copyOnSelect ? "ON" : "OFF", !!prefs?.copyOnSelect),
|
|
131
|
+
row(2, "Goal auto rounds", String(state.goalRounds), true),
|
|
132
|
+
row(3, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
|
|
133
|
+
row(4, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates)
|
|
134
|
+
] : [
|
|
135
|
+
row(0, "Accent", state.accent === "follow" ? "FOLLOW PI" : state.accent.toUpperCase(), true),
|
|
136
|
+
row(1, "Motion", state.animations ? "ON" : "OFF", state.animations),
|
|
137
|
+
row(2, "Rounded composer", state.composer ? "ON" : "OFF", state.composer),
|
|
138
|
+
row(3, "Ember mascot", state.mascot ? "ON" : "OFF", state.mascot),
|
|
139
|
+
row(4, "Next-prompt suggestions", state.suggestions ? "ON" : "OFF", state.suggestions),
|
|
140
|
+
row(5, "Pi-jar UI", state.ui ? "ON" : "OFF", state.ui)
|
|
141
|
+
];
|
|
142
|
+
const start = firstVisible();
|
|
143
|
+
const rows = allRows.slice(start, start + pageSize());
|
|
144
|
+
return [fit(top), tabs, divider,
|
|
145
|
+
line(theme.fg("accent", page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
|
|
146
|
+
line(theme.fg("dim", page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
|
|
147
|
+
: page === "pi" ? " Fullscreen mode enables clicks and copy-on-select" : " Accent cycles through loaded themes")),
|
|
148
|
+
...rows.map(fit),
|
|
149
|
+
line(theme.fg("dim", " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
|
|
150
|
+
fit(theme.fg("dim", "╰" + "─".repeat(Math.max(0, width - 2)) + "╯"))];
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ACCENT_NAMES } from "./accent.ts";
|
|
4
|
+
import { DEFAULT_FOOTER_SETTINGS, FOOTER_FIELDS, loadFooterSettings, type FooterSettings } from "./footer-settings.ts";
|
|
5
|
+
|
|
6
|
+
export type JarAccent = "follow" | "default" | (typeof ACCENT_NAMES)[number];
|
|
7
|
+
export interface JarVisualSettings {
|
|
8
|
+
version: 1;
|
|
9
|
+
accent: JarAccent;
|
|
10
|
+
animations: boolean;
|
|
11
|
+
ui: boolean;
|
|
12
|
+
composer: boolean;
|
|
13
|
+
/** Ember mascot perched on the composer. */
|
|
14
|
+
mascot: boolean;
|
|
15
|
+
/** Agent-provided next-prompt ghost text in the composer. */
|
|
16
|
+
suggestions: boolean;
|
|
17
|
+
/** Automatic implement/audit rounds per user message while a goal is active. */
|
|
18
|
+
goalRounds: number;
|
|
19
|
+
/** The jar_advisor second-opinion tool and /advisor. */
|
|
20
|
+
advisor: boolean;
|
|
21
|
+
/** Consult the advisor automatically when the agent repeats a tool call or keeps failing. */
|
|
22
|
+
advisorGates: boolean;
|
|
23
|
+
footer: FooterSettings;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const GOAL_ROUND_CHOICES = [4, 8, 12, 20] as const;
|
|
27
|
+
|
|
28
|
+
export const SETTINGS_FILE = "pi-jar-settings.json";
|
|
29
|
+
export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
|
|
30
|
+
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, advisor: true, advisorGates: true, footer: { ...footer } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
|
|
34
|
+
export function loadVisualSettings(directory: string): JarVisualSettings {
|
|
35
|
+
let text: string;
|
|
36
|
+
try { text = readFileSync(join(directory, SETTINGS_FILE), "utf8"); }
|
|
37
|
+
catch (error) {
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return defaultVisualSettings(loadFooterSettings(directory));
|
|
39
|
+
return defaultVisualSettings();
|
|
40
|
+
}
|
|
41
|
+
let raw: unknown;
|
|
42
|
+
try { raw = JSON.parse(text); } catch { return defaultVisualSettings(); }
|
|
43
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return defaultVisualSettings();
|
|
44
|
+
const value = raw as Record<string, unknown>;
|
|
45
|
+
if (value.version !== 1) return defaultVisualSettings();
|
|
46
|
+
const fallback = defaultVisualSettings();
|
|
47
|
+
const footer = value.footer && typeof value.footer === "object" && !Array.isArray(value.footer)
|
|
48
|
+
? value.footer as Record<string, unknown> : {};
|
|
49
|
+
return {
|
|
50
|
+
version: 1,
|
|
51
|
+
accent: value.accent === "follow" || value.accent === "default" || ACCENT_NAMES.some((name) => name === value.accent)
|
|
52
|
+
? value.accent as JarAccent : fallback.accent,
|
|
53
|
+
animations: typeof value.animations === "boolean" ? value.animations : fallback.animations,
|
|
54
|
+
ui: typeof value.ui === "boolean" ? value.ui : fallback.ui,
|
|
55
|
+
composer: typeof value.composer === "boolean" ? value.composer : fallback.composer,
|
|
56
|
+
mascot: typeof value.mascot === "boolean" ? value.mascot : fallback.mascot,
|
|
57
|
+
suggestions: typeof value.suggestions === "boolean" ? value.suggestions : fallback.suggestions,
|
|
58
|
+
goalRounds: typeof value.goalRounds === "number" && Number.isInteger(value.goalRounds) && value.goalRounds >= 1 && value.goalRounds <= 50
|
|
59
|
+
? value.goalRounds : fallback.goalRounds,
|
|
60
|
+
advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
|
|
61
|
+
advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
|
|
62
|
+
footer: Object.fromEntries(FOOTER_FIELDS.map((field) => [field,
|
|
63
|
+
typeof footer[field] === "boolean" ? footer[field] : fallback.footer[field]])) as FooterSettings
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function migrateLegacySettings(directory: string): boolean {
|
|
68
|
+
if (existsSync(join(directory, SETTINGS_FILE)) || !existsSync(join(directory, "pi-jar-footer.json"))) return false;
|
|
69
|
+
try {
|
|
70
|
+
const legacy: unknown = JSON.parse(readFileSync(join(directory, "pi-jar-footer.json"), "utf8"));
|
|
71
|
+
if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) return false;
|
|
72
|
+
saveVisualSettings(directory, defaultVisualSettings(loadFooterSettings(directory)));
|
|
73
|
+
return true;
|
|
74
|
+
} catch { return false; } // Leave malformed legacy preferences untouched for manual repair.
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function saveVisualSettings(directory: string, settings: JarVisualSettings): void {
|
|
78
|
+
mkdirSync(directory, { recursive: true });
|
|
79
|
+
const target = join(directory, SETTINGS_FILE);
|
|
80
|
+
if (existsSync(target)) {
|
|
81
|
+
let prior: unknown;
|
|
82
|
+
try { prior = JSON.parse(readFileSync(target, "utf8")); }
|
|
83
|
+
catch { throw new Error("Cannot overwrite malformed pi-jar settings"); }
|
|
84
|
+
if (!prior || typeof prior !== "object" || (prior as Record<string, unknown>).version !== 1) {
|
|
85
|
+
throw new Error("Cannot overwrite unknown pi-jar settings version");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(temporary, JSON.stringify(settings, null, 2) + "\n", { flag: "wx" });
|
|
91
|
+
renameSync(temporary, target);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
try { unlinkSync(temporary); } catch { /* No temporary file to clean up. */ }
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/shells.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Key, matchesKey, Text, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { safeLine } from "./diff-view.ts";
|
|
6
|
+
import { cleanText } from "./status.ts";
|
|
7
|
+
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
8
|
+
|
|
9
|
+
export const SHELL_TOOL = "jar_shell";
|
|
10
|
+
export const SHELL_MESSAGE = "pi-jar.shell";
|
|
11
|
+
export const MAX_RUNNING_SHELLS = 8;
|
|
12
|
+
const MAX_KEPT_SHELLS = 20;
|
|
13
|
+
const MAX_LINES = 2000;
|
|
14
|
+
const MAX_LINE_CHARS = 2000;
|
|
15
|
+
const KILL_GRACE_MS = 3000;
|
|
16
|
+
|
|
17
|
+
export type ShellStatus = "running" | "exited" | "killed" | "failed";
|
|
18
|
+
export interface ShellJob {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
command: string;
|
|
22
|
+
cwd: string;
|
|
23
|
+
pid?: number;
|
|
24
|
+
startedAt: number;
|
|
25
|
+
endedAt?: number;
|
|
26
|
+
status: ShellStatus;
|
|
27
|
+
exitCode?: number | null;
|
|
28
|
+
signal?: string | null;
|
|
29
|
+
error?: string;
|
|
30
|
+
watch?: string;
|
|
31
|
+
matched?: string;
|
|
32
|
+
/** Wake the agent when the pattern matches or the process ends. */
|
|
33
|
+
notify: boolean;
|
|
34
|
+
lines: string[];
|
|
35
|
+
/** Lines dropped from the front of the ring buffer. */
|
|
36
|
+
dropped: number;
|
|
37
|
+
}
|
|
38
|
+
export type ShellEvent = { kind: "match" | "exit"; job: ShellJob };
|
|
39
|
+
export interface StartOptions { command: string; cwd: string; name?: string; watch?: string; notify?: boolean }
|
|
40
|
+
|
|
41
|
+
const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
|
|
42
|
+
|
|
43
|
+
/** Long-running shell commands with bounded output and optional pattern watches. */
|
|
44
|
+
export class ShellManager {
|
|
45
|
+
private jobs = new Map<string, { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> }>();
|
|
46
|
+
private next = 1;
|
|
47
|
+
private readonly onEvent: (event: ShellEvent) => void;
|
|
48
|
+
private readonly spawnShell: typeof spawn;
|
|
49
|
+
onChange?: () => void;
|
|
50
|
+
constructor(onEvent: (event: ShellEvent) => void, spawnShell: typeof spawn = spawn) { this.onEvent = onEvent; this.spawnShell = spawnShell; }
|
|
51
|
+
|
|
52
|
+
list(): ShellJob[] { return [...this.jobs.values()].map(({ job }) => ({ ...job, lines: [...job.lines] })); }
|
|
53
|
+
get(id: string): ShellJob | undefined { const entry = this.jobs.get(id); return entry && { ...entry.job, lines: [...entry.job.lines] }; }
|
|
54
|
+
running(): number { return [...this.jobs.values()].filter(({ job }) => job.status === "running").length; }
|
|
55
|
+
|
|
56
|
+
start(options: StartOptions): ShellJob {
|
|
57
|
+
const command = options.command.trim();
|
|
58
|
+
if (!command) throw new Error("command is required");
|
|
59
|
+
if (this.running() >= MAX_RUNNING_SHELLS) throw new Error(`at most ${MAX_RUNNING_SHELLS} shells can run at once; kill one first`);
|
|
60
|
+
let pattern: RegExp | undefined;
|
|
61
|
+
if (options.watch) {
|
|
62
|
+
if (options.watch.length > 200) throw new Error("watch pattern is too long");
|
|
63
|
+
try { pattern = new RegExp(options.watch); } catch (error) { throw new Error("invalid watch pattern: " + (error instanceof Error ? error.message : String(error))); }
|
|
64
|
+
}
|
|
65
|
+
this.prune();
|
|
66
|
+
const id = "s" + this.next++;
|
|
67
|
+
const job: ShellJob = { id, name: cleanText(options.name || command, 40), command, cwd: options.cwd, startedAt: Date.now(), status: "running",
|
|
68
|
+
notify: options.notify ?? true, lines: [], dropped: 0, ...(options.watch ? { watch: options.watch } : {}) };
|
|
69
|
+
const entry: { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> } = { job, partial: "", ...(pattern ? { pattern } : {}) };
|
|
70
|
+
this.jobs.set(id, entry);
|
|
71
|
+
// Own process group so kill() stops the whole tree (dev servers spawn children).
|
|
72
|
+
const child = this.spawnShell("/bin/sh", ["-c", command], { cwd: options.cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
73
|
+
entry.child = child;
|
|
74
|
+
if (child.pid !== undefined) job.pid = child.pid;
|
|
75
|
+
const receive = (chunk: Buffer | string) => this.receive(entry, String(chunk));
|
|
76
|
+
child.stdout?.on("data", receive);
|
|
77
|
+
child.stderr?.on("data", receive);
|
|
78
|
+
child.on("error", (error) => {
|
|
79
|
+
if (job.status !== "running") return;
|
|
80
|
+
job.status = "failed";
|
|
81
|
+
job.error = error.message;
|
|
82
|
+
job.endedAt = Date.now();
|
|
83
|
+
this.finish(entry);
|
|
84
|
+
});
|
|
85
|
+
child.on("close", (code, signal) => {
|
|
86
|
+
if (entry.partial) { this.push(entry, entry.partial); entry.partial = ""; }
|
|
87
|
+
if (job.status === "running") job.status = "exited";
|
|
88
|
+
job.exitCode = code;
|
|
89
|
+
job.signal = signal;
|
|
90
|
+
job.endedAt ??= Date.now();
|
|
91
|
+
this.finish(entry);
|
|
92
|
+
});
|
|
93
|
+
this.onChange?.();
|
|
94
|
+
return { ...job, lines: [] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private receive(entry: { job: ShellJob; pattern?: RegExp; partial: string }, text: string): void {
|
|
98
|
+
const parts = (entry.partial + text.replace(/\r\n/g, "\n")).split("\n");
|
|
99
|
+
entry.partial = parts.pop() ?? "";
|
|
100
|
+
if (entry.partial.length > MAX_LINE_CHARS) { this.push(entry, entry.partial); entry.partial = ""; }
|
|
101
|
+
for (const line of parts) this.push(entry, line);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private push(entry: { job: ShellJob; pattern?: RegExp }, raw: string): void {
|
|
105
|
+
const line = raw.replace(ANSI, "").replace(/\r/g, "").slice(0, MAX_LINE_CHARS);
|
|
106
|
+
const { job } = entry;
|
|
107
|
+
job.lines.push(line);
|
|
108
|
+
if (job.lines.length > MAX_LINES) { job.lines.splice(0, job.lines.length - MAX_LINES); job.dropped++; }
|
|
109
|
+
if (entry.pattern && !job.matched && entry.pattern.test(line)) {
|
|
110
|
+
job.matched = line;
|
|
111
|
+
this.onEvent({ kind: "match", job: { ...job, lines: [...job.lines] } });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private finish(entry: { job: ShellJob; timer?: ReturnType<typeof setTimeout> }): void {
|
|
116
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
117
|
+
this.onEvent({ kind: "exit", job: { ...entry.job, lines: [...entry.job.lines] } });
|
|
118
|
+
this.onChange?.();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Last `count` lines (1–400). */
|
|
122
|
+
output(id: string, count = 40): string[] {
|
|
123
|
+
const entry = this.jobs.get(id);
|
|
124
|
+
if (!entry) throw new Error("no shell " + id);
|
|
125
|
+
const lines = [...entry.job.lines, ...(entry.partial ? [entry.partial] : [])];
|
|
126
|
+
return lines.slice(-Math.max(1, Math.min(400, Math.floor(count))));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
kill(id: string): boolean {
|
|
130
|
+
const entry = this.jobs.get(id);
|
|
131
|
+
if (!entry) throw new Error("no shell " + id);
|
|
132
|
+
if (entry.job.status !== "running" || !entry.child) return false;
|
|
133
|
+
entry.job.status = "killed";
|
|
134
|
+
entry.job.endedAt = Date.now();
|
|
135
|
+
const signal = (name: NodeJS.Signals) => {
|
|
136
|
+
const pid = entry.child?.pid;
|
|
137
|
+
try { if (pid && process.platform !== "win32") process.kill(-pid, name); else entry.child?.kill(name); }
|
|
138
|
+
catch { entry.child?.kill(name); }
|
|
139
|
+
};
|
|
140
|
+
signal("SIGTERM");
|
|
141
|
+
entry.timer = setTimeout(() => signal("SIGKILL"), KILL_GRACE_MS);
|
|
142
|
+
entry.timer.unref?.();
|
|
143
|
+
this.onChange?.();
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Forget finished jobs beyond the retention limit (oldest first). */
|
|
148
|
+
private prune(): void {
|
|
149
|
+
const finished = [...this.jobs.values()].filter(({ job }) => job.status !== "running");
|
|
150
|
+
for (const { job } of finished.slice(0, Math.max(0, this.jobs.size - MAX_KEPT_SHELLS + 1))) this.jobs.delete(job.id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
dispose(): void {
|
|
154
|
+
for (const id of this.jobs.keys()) { try { this.kill(id); } catch { /* already gone */ } }
|
|
155
|
+
this.jobs.clear();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const elapsed = (job: ShellJob, now = Date.now()) => {
|
|
160
|
+
const seconds = Math.max(0, Math.round(((job.endedAt ?? now) - job.startedAt) / 1000));
|
|
161
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
162
|
+
};
|
|
163
|
+
export const shellState = (job: ShellJob) => job.status === "running" ? "running"
|
|
164
|
+
: job.status === "exited" ? `exited ${job.exitCode ?? "?"}` : job.status === "failed" ? `failed: ${cleanText(job.error ?? "", 60)}` : "killed";
|
|
165
|
+
const describe = (job: ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
|
|
166
|
+
|
|
167
|
+
/** The message that wakes the agent when a watched shell matches or ends. */
|
|
168
|
+
export function shellEventMessage(event: ShellEvent): string {
|
|
169
|
+
const { job } = event;
|
|
170
|
+
const head = event.kind === "match"
|
|
171
|
+
? `Background shell ${job.id} (${job.name}) matched /${job.watch}/: ${job.matched}`
|
|
172
|
+
: `Background shell ${job.id} (${job.name}) ${shellState(job)} after ${elapsed(job)}.`;
|
|
173
|
+
const tail = job.lines.slice(-20);
|
|
174
|
+
return head + (tail.length ? `\nLast ${tail.length} line(s):\n` + tail.join("\n") : "") + `\nUse ${SHELL_TOOL} output/kill with id ${job.id} as needed.`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const Parameters = Type.Object({
|
|
178
|
+
action: Type.Unsafe<"start" | "list" | "output" | "kill">({ type: "string", enum: ["start", "list", "output", "kill"] }),
|
|
179
|
+
command: Type.Optional(Type.String({ description: "Shell command to run in the background (start)." })),
|
|
180
|
+
name: Type.Optional(Type.String({ description: "Short label, e.g. \"dev server\"." })),
|
|
181
|
+
watch: Type.Optional(Type.String({ description: "Regex; when a line matches you are woken once, e.g. \"ready on|error\"." })),
|
|
182
|
+
notify: Type.Optional(Type.Boolean({ description: "Wake you when the pattern matches or the process ends (default true)." })),
|
|
183
|
+
id: Type.Optional(Type.String({ description: "Shell id for output/kill, e.g. s1." })),
|
|
184
|
+
lines: Type.Optional(Type.Number({ description: "How many trailing output lines to return (default 40, max 400)." }))
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | undefined): void {
|
|
188
|
+
if (typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
|
|
189
|
+
pi.registerTool({
|
|
190
|
+
name: SHELL_TOOL,
|
|
191
|
+
label: "shell",
|
|
192
|
+
description: "Run long-lived commands (dev servers, watchers, long test runs) in the background. start returns immediately; you are woken when an optional watch pattern matches or the process exits. Use output to read recent lines and kill to stop it.",
|
|
193
|
+
promptSnippet: "Use jar_shell for long-running or never-ending commands instead of blocking bash.",
|
|
194
|
+
promptGuidelines: [
|
|
195
|
+
"Use jar_shell start for servers, watchers and slow commands; set watch to a regex for the line you are waiting for (e.g. \"listening|ready|error\").",
|
|
196
|
+
"Do not poll: after starting, continue other work or end your turn; a message wakes you when the watch matches or the process ends. Kill shells you no longer need."
|
|
197
|
+
],
|
|
198
|
+
parameters: Parameters,
|
|
199
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
200
|
+
const manager = shells();
|
|
201
|
+
const reply = (text: string, jobs: ShellJob[] = manager?.list() ?? []) => ({ content: [{ type: "text" as const, text }], details: { jobs } });
|
|
202
|
+
if (!manager) return reply("Background shells are unavailable before a Pi session starts.");
|
|
203
|
+
try {
|
|
204
|
+
switch (params.action) {
|
|
205
|
+
case "start": {
|
|
206
|
+
const job = manager.start({ command: params.command ?? "", cwd: ctx.cwd, ...(params.name ? { name: params.name } : {}), ...(params.watch ? { watch: params.watch } : {}),
|
|
207
|
+
...(params.notify !== undefined ? { notify: params.notify } : {}) });
|
|
208
|
+
return reply(`Started ${describe(job)} (pid ${job.pid ?? "?"}). ${job.notify ? "You will be woken when it " + (job.watch ? "matches or " : "") + "exits." : "Notifications are off; check it with output."}`);
|
|
209
|
+
}
|
|
210
|
+
case "output": {
|
|
211
|
+
if (!params.id) return reply("output needs an id.");
|
|
212
|
+
const lines = manager.output(params.id, params.lines ?? 40);
|
|
213
|
+
return reply(`${describe(manager.get(params.id)!)}\n${lines.length ? lines.join("\n") : "(no output yet)"}`);
|
|
214
|
+
}
|
|
215
|
+
case "kill":
|
|
216
|
+
if (!params.id) return reply("kill needs an id.");
|
|
217
|
+
return reply(manager.kill(params.id) ? `Stopping ${params.id}.` : `${params.id} is not running.`);
|
|
218
|
+
default: {
|
|
219
|
+
const jobs = manager.list();
|
|
220
|
+
return reply(jobs.length ? jobs.map(describe).join("\n") : "No background shells.", jobs);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return reply(`${params.action} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
renderCall(args, theme) {
|
|
228
|
+
let text = theme.fg("toolTitle", theme.bold("shell")) + " " + theme.fg("accent", args.action);
|
|
229
|
+
if (args.command) text += " " + theme.fg("muted", cleanText(args.command, 80));
|
|
230
|
+
if (args.id) text += " " + theme.fg("muted", args.id);
|
|
231
|
+
if (args.watch) text += theme.fg("dim", ` watch /${cleanText(args.watch, 40)}/`);
|
|
232
|
+
return new Text(text, 0, 0);
|
|
233
|
+
},
|
|
234
|
+
renderResult(result, { expanded }, theme) {
|
|
235
|
+
const text = result.content.map((part) => part.type === "text" ? part.text : "").join("\n");
|
|
236
|
+
const lines = text.split("\n");
|
|
237
|
+
const shown = expanded ? lines : lines.slice(0, 6);
|
|
238
|
+
return new Text(shown.map((line, index) => theme.fg(index ? "muted" : "accent", safeLine(line))).join("\n")
|
|
239
|
+
+ (shown.length < lines.length ? "\n" + theme.fg("dim", `… +${lines.length - shown.length} lines (expand)`) : ""), 0, 0);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const SHELL_ACTIONS = [
|
|
245
|
+
{ key: "x", label: "Kill the selected shell" },
|
|
246
|
+
{ key: "f", label: "Follow the latest output" }
|
|
247
|
+
] as const;
|
|
248
|
+
|
|
249
|
+
/** Overlay: shells on the left, the selected shell's live output on the right; k kills. */
|
|
250
|
+
export async function openShellsView(ctx: ExtensionContext, manager: ShellManager): Promise<void> {
|
|
251
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
252
|
+
if (!manager.list().length) { ctx.ui.notify("pi-jar: no background shells (the agent starts them with jar_shell)", "info"); return; }
|
|
253
|
+
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
254
|
+
let selected = 0;
|
|
255
|
+
let follow = true;
|
|
256
|
+
let scroll = 0;
|
|
257
|
+
let rows = 0;
|
|
258
|
+
let width = 80;
|
|
259
|
+
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
260
|
+
const fg = (color: string, text: string) => theme.fg(color as never, text);
|
|
261
|
+
const previous = manager.onChange;
|
|
262
|
+
const timer = setInterval(() => tui.requestRender(), 500);
|
|
263
|
+
timer.unref?.();
|
|
264
|
+
manager.onChange = () => { previous?.(); tui.requestRender(); };
|
|
265
|
+
const close = () => { clearInterval(timer); manager.onChange = previous; done(); };
|
|
266
|
+
const component = {
|
|
267
|
+
invalidate() {},
|
|
268
|
+
handleInput(data: string) {
|
|
269
|
+
const jobs = manager.list();
|
|
270
|
+
if (matchesKey(data, Key.escape) || data === "q") return close();
|
|
271
|
+
if (matchesKey(data, Key.up) || data === "k") selected = Math.max(0, selected - 1);
|
|
272
|
+
else if (matchesKey(data, Key.down) || data === "j") selected = Math.min(jobs.length - 1, selected + 1);
|
|
273
|
+
else if (data === "x" || data === "K") { const job = jobs[selected]; if (job) { try { manager.kill(job.id); } catch (error) { ctx.ui.notify("pi-jar: " + String(error), "error"); } } }
|
|
274
|
+
else if (matchesKey(data, Key.pageUp)) { follow = false; scroll = Math.max(0, scroll - Math.max(1, rows - 2)); }
|
|
275
|
+
else if (matchesKey(data, Key.pageDown)) scroll += Math.max(1, rows - 2);
|
|
276
|
+
else if (data === "f" || data === "G") follow = true;
|
|
277
|
+
tui.requestRender();
|
|
278
|
+
},
|
|
279
|
+
handleMouse(event: TuiMouseEvent) {
|
|
280
|
+
const row = event.y - layout.top;
|
|
281
|
+
if (event.type === "wheel" && event.wheelDelta) { follow = false; scroll = Math.max(0, scroll + Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
|
|
282
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
283
|
+
if (event.y === 0 && event.x >= width - 3) { close(); return { handled: true }; }
|
|
284
|
+
if (row >= 0 && row < layout.rows && layout.leftWidth && event.x < layout.leftWidth + 3 && row < manager.list().length) { selected = row; follow = true; tui.requestRender(); return { handled: true, focus: true }; }
|
|
285
|
+
const action = SHELL_ACTIONS[event.y - layout.footerTop];
|
|
286
|
+
if (action) { component.handleInput(action.key); return { handled: true }; }
|
|
287
|
+
},
|
|
288
|
+
render(available: number): string[] {
|
|
289
|
+
width = Math.max(24, available);
|
|
290
|
+
rows = contentRows(6 + SHELL_ACTIONS.length, 6);
|
|
291
|
+
const jobs = manager.list();
|
|
292
|
+
selected = Math.min(selected, Math.max(0, jobs.length - 1));
|
|
293
|
+
const job = jobs[selected];
|
|
294
|
+
const listWidth = sidebarWidth(width, 20, 34);
|
|
295
|
+
const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
|
|
296
|
+
const output = job ? manager.output(job.id, 400) : [];
|
|
297
|
+
const maxScroll = Math.max(0, output.length - rows);
|
|
298
|
+
scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
|
|
299
|
+
if (scroll >= maxScroll) follow = true;
|
|
300
|
+
const list = jobs.slice(0, rows).map((item, index) => {
|
|
301
|
+
const color = item.status === "running" ? "accent" : item.status === "exited" && item.exitCode === 0 ? "success" : item.status === "killed" ? "dim" : "error";
|
|
302
|
+
const glyph = item.status === "running" ? "●" : item.status === "exited" && item.exitCode === 0 ? "✔" : item.status === "killed" ? "■" : "✖";
|
|
303
|
+
return fg(index === selected ? "accent" : "muted", (index === selected ? "▌" : " ")) + fg(color, glyph + " ") + fg(index === selected ? "accent" : "muted", truncateToWidth(`${item.id} ${item.name}`, Math.max(4, listWidth - 4)));
|
|
304
|
+
});
|
|
305
|
+
const body = output.slice(scroll, scroll + rows).map((line) => fg("muted", truncateToWidth(safeLine(line), bodyWidth)));
|
|
306
|
+
if (job && !output.length) body.push(fg("dim", "(no output yet)"));
|
|
307
|
+
const title = `⚙ SHELLS · ${manager.running()} running` + (job ? ` · ${describe(job)}` : "");
|
|
308
|
+
const footer = [
|
|
309
|
+
...optionList(theme, SHELL_ACTIONS.map((item) => item.label), -1, SHELL_ACTIONS.map((item) => item.key)),
|
|
310
|
+
fg("dim", (job ? "$ " + truncateToWidth(cleanText(job.command, 400), Math.max(8, width - 60)) + " " : "") + "↑↓ shell · PgUp/PgDn scroll · Esc close")
|
|
311
|
+
];
|
|
312
|
+
const split = splitFrame(theme, width, title, list, body, footer, rows, listWidth);
|
|
313
|
+
layout = split.layout;
|
|
314
|
+
return split.lines;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
return component;
|
|
318
|
+
}, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
|
|
319
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ModelRoleManager, ThinkingLevel } from "./model-roles.ts";
|
|
3
|
+
|
|
4
|
+
/** One model call pi-jar made outside the main conversation (advisor, commit message, ...). */
|
|
5
|
+
export interface SideCall {
|
|
6
|
+
role: string;
|
|
7
|
+
model: string;
|
|
8
|
+
input: number;
|
|
9
|
+
output: number;
|
|
10
|
+
cacheRead: number;
|
|
11
|
+
cacheWrite: number;
|
|
12
|
+
cost: number;
|
|
13
|
+
at: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Session-scoped record of side calls, so /usage can show what they cost. */
|
|
17
|
+
export class SideUsage {
|
|
18
|
+
private calls: SideCall[] = [];
|
|
19
|
+
add(call: SideCall): void { this.calls.push(call); if (this.calls.length > 500) this.calls.shift(); }
|
|
20
|
+
all(): readonly SideCall[] { return this.calls; }
|
|
21
|
+
clear(): void { this.calls = []; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SideAnswer { text: string; model: string }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Ask the model assigned to `role` (or the current model) a one-shot question outside the
|
|
28
|
+
* conversation. Throws on a missing model, provider error or empty answer.
|
|
29
|
+
*/
|
|
30
|
+
export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, role: string,
|
|
31
|
+
systemPrompt: string, prompt: string, signal?: AbortSignal): Promise<SideAnswer> {
|
|
32
|
+
const assigned = roles.resolve(role);
|
|
33
|
+
const model = assigned ? ctx.modelRegistry.find(assigned.provider, assigned.model) : ctx.model;
|
|
34
|
+
if (!model) throw new Error(assigned ? `model not found for role ${role}: ${assigned.provider}/${assigned.model}` : "no model selected");
|
|
35
|
+
const thinking: ThinkingLevel | undefined = assigned?.thinking;
|
|
36
|
+
const reasoning = thinking && thinking !== "off" ? thinking : undefined;
|
|
37
|
+
const result = await ctx.modelRegistry.streamSimple(model,
|
|
38
|
+
{ systemPrompt, messages: [{ role: "user", content: prompt, timestamp: Date.now() }] },
|
|
39
|
+
{ ...(reasoning ? { reasoning } : {}), ...(signal ? { signal } : {}) }).result();
|
|
40
|
+
const name = `${model.provider}/${model.id}`;
|
|
41
|
+
const cost = result.usage?.cost?.total;
|
|
42
|
+
usage?.add({ role, model: name, input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cacheRead: result.usage?.cacheRead ?? 0,
|
|
43
|
+
cacheWrite: result.usage?.cacheWrite ?? 0, cost: Number.isFinite(cost) ? cost! : 0, at: Date.now() });
|
|
44
|
+
if (result.stopReason === "error" || result.stopReason === "aborted") throw new Error(result.errorMessage || `${name} ${result.stopReason}`);
|
|
45
|
+
const text = result.content.filter((part) => part.type === "text").map((part) => (part as { text: string }).text).join("").trim();
|
|
46
|
+
if (!text) throw new Error(`${name} returned an empty answer`);
|
|
47
|
+
return { text, model: name };
|
|
48
|
+
}
|