pi-subagents-lite 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +222 -36
- package/package.json +3 -1
- package/src/agent-discovery.ts +36 -45
- package/src/agent-manager.ts +101 -87
- package/src/agent-runner.ts +40 -49
- package/src/agent-types.ts +15 -37
- package/src/config-io.ts +40 -0
- package/src/context.ts +80 -1
- package/src/index.ts +105 -1117
- package/src/menus.ts +866 -0
- package/src/model-precedence.ts +46 -36
- package/src/model-selector.ts +19 -19
- package/src/output-file.ts +123 -33
- package/src/prompts.ts +2 -2
- package/src/result-viewer.ts +166 -37
- package/src/skill-loader.ts +1 -1
- package/src/stop-agent-tool.ts +76 -0
- package/src/tool-execution.ts +361 -0
- package/src/types.ts +16 -1
- package/src/ui/agent-widget.ts +98 -91
- package/src/usage.ts +12 -4
- package/src/utils.ts +53 -4
package/src/result-viewer.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
type MarkdownTheme,
|
|
17
17
|
} from "@earendil-works/pi-tui";
|
|
18
18
|
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { type LifetimeUsage, formatTokens } from "./usage.js";
|
|
20
|
+
import { formatMs } from "./ui/agent-widget.js";
|
|
19
21
|
|
|
20
22
|
// Theme type from ctx.ui.custom() callback
|
|
21
23
|
type Theme = any;
|
|
@@ -24,15 +26,33 @@ type Theme = any;
|
|
|
24
26
|
/* Types */
|
|
25
27
|
/* ------------------------------------------------------------------ */
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
interface ResultViewerCallbacks {
|
|
28
30
|
onClose: () => void;
|
|
31
|
+
/** Called on 'r' press — returns fresh markdown text, or undefined to skip refresh. */
|
|
32
|
+
onRefresh?: () => string | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ResultViewerStats {
|
|
36
|
+
lifetimeUsage: LifetimeUsage;
|
|
37
|
+
turnCount?: number;
|
|
38
|
+
durationMs?: number;
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
/* ------------------------------------------------------------------ */
|
|
32
42
|
/* ResultViewer */
|
|
33
43
|
/* ------------------------------------------------------------------ */
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
/** Lines scrolled per PageUp/PageDown (kept at a fixed, comfortable amount). */
|
|
46
|
+
const PAGE_STEP = 14;
|
|
47
|
+
|
|
48
|
+
/** Fixed non-viewport lines in the component (borders, title, spacers, hints, etc.). */
|
|
49
|
+
const BASE_OVERHEAD = 10;
|
|
50
|
+
|
|
51
|
+
/** Extra overhead lines for the stats title line (spacer + text). */
|
|
52
|
+
const STATS_OVERHEAD = 2;
|
|
53
|
+
|
|
54
|
+
/** Minimum viewport content lines regardless of terminal size. */
|
|
55
|
+
const MIN_VIEWPORT = 20;
|
|
36
56
|
|
|
37
57
|
/**
|
|
38
58
|
* Build a MarkdownTheme from the TUI theme instance.
|
|
@@ -63,47 +83,88 @@ function buildMarkdownTheme(theme: Theme): MarkdownTheme {
|
|
|
63
83
|
* - Top border
|
|
64
84
|
* - Title bar with agent info
|
|
65
85
|
* - Separator
|
|
66
|
-
* - Paginated markdown content
|
|
86
|
+
* - Paginated markdown content (dynamically sized to at least 50% of terminal)
|
|
67
87
|
* - Scroll position indicator (when scrollable)
|
|
68
88
|
* - Key hints footer
|
|
69
89
|
* - Bottom border
|
|
70
90
|
*
|
|
71
|
-
* Key bindings: up/down/pageup/pagedown/g/G/escape
|
|
91
|
+
* Key bindings: up/down/pageup/pagedown/g/G/f(ullscreen)/escape
|
|
72
92
|
*/
|
|
73
93
|
export class ResultViewer extends Container implements Component {
|
|
74
94
|
private markdown: Markdown;
|
|
75
95
|
private renderedLines: string[];
|
|
76
|
-
private viewport
|
|
96
|
+
private viewport!: Container;
|
|
97
|
+
private scrollIndicator!: Container;
|
|
77
98
|
private scrollOffset: number;
|
|
78
99
|
private theme: Theme;
|
|
79
100
|
private callbacks: ResultViewerCallbacks;
|
|
101
|
+
private fullScreen: boolean;
|
|
102
|
+
private _viewportSize: number;
|
|
103
|
+
private terminalHeight: number;
|
|
104
|
+
private textRef: { text: string }; // mutable ref for refresh
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Current number of content lines displayed in the viewport.
|
|
108
|
+
* Varies based on terminal height and full-screen mode.
|
|
109
|
+
*/
|
|
110
|
+
get viewportSize(): number {
|
|
111
|
+
return this._viewportSize;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Whether the viewer is currently in full-screen mode. */
|
|
115
|
+
get isFullScreen(): boolean {
|
|
116
|
+
return this.fullScreen;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether stats line is shown. Used for viewport sizing. */
|
|
120
|
+
private hasStats: boolean;
|
|
80
121
|
|
|
81
122
|
constructor(
|
|
82
123
|
title: string,
|
|
83
124
|
text: string,
|
|
84
125
|
callbacks: ResultViewerCallbacks,
|
|
85
126
|
theme: Theme,
|
|
127
|
+
terminalHeight: number = 24,
|
|
128
|
+
stats?: ResultViewerStats,
|
|
86
129
|
) {
|
|
87
130
|
super();
|
|
88
131
|
|
|
89
132
|
this.callbacks = callbacks;
|
|
90
133
|
this.theme = theme;
|
|
91
134
|
this.scrollOffset = 0;
|
|
135
|
+
this.fullScreen = true;
|
|
136
|
+
this.terminalHeight = terminalHeight;
|
|
137
|
+
this.hasStats = stats != null;
|
|
138
|
+
this._viewportSize = computeViewportSize(terminalHeight, true, this.hasStats);
|
|
139
|
+
this.textRef = { text };
|
|
92
140
|
|
|
93
141
|
// Build markdown renderer (pre-render to get total lines)
|
|
94
142
|
const mdTheme = buildMarkdownTheme(theme);
|
|
95
143
|
this.markdown = new Markdown(text, 0, 0, mdTheme);
|
|
96
|
-
// Pre-render at a reasonable width to get line count
|
|
97
144
|
this.renderedLines = this.markdown.render(78);
|
|
98
145
|
|
|
99
|
-
|
|
146
|
+
this.buildUI(title, stats);
|
|
147
|
+
this.updateViewport();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Build the full UI tree — borders, title, stats, viewport, hints. */
|
|
151
|
+
private buildUI(title: string, stats?: ResultViewerStats): void {
|
|
100
152
|
this.addChild(new DynamicBorder());
|
|
101
153
|
this.addChild(new Spacer(1));
|
|
102
154
|
|
|
103
155
|
// Title bar
|
|
104
156
|
this.addChild(
|
|
105
|
-
new Text(this.theme.fg("accent", theme.bold(` ${title}`)), 0, 0),
|
|
157
|
+
new Text(this.theme.fg("accent", this.theme.bold(` ${title}`)), 0, 0),
|
|
106
158
|
);
|
|
159
|
+
|
|
160
|
+
// Stats line (below title, above separator)
|
|
161
|
+
if (stats) {
|
|
162
|
+
this.addChild(new Spacer(1));
|
|
163
|
+
this.addChild(
|
|
164
|
+
new Text(this.theme.fg("dim", this.formatStatsLine(stats)), 0, 0),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
107
168
|
this.addChild(new Spacer(1));
|
|
108
169
|
|
|
109
170
|
// Separator
|
|
@@ -116,17 +177,44 @@ export class ResultViewer extends Container implements Component {
|
|
|
116
177
|
this.viewport = new Container();
|
|
117
178
|
this.addChild(this.viewport);
|
|
118
179
|
|
|
180
|
+
// Scroll position indicator (outside viewport so it doesn't mix with content)
|
|
181
|
+
this.scrollIndicator = new Container();
|
|
182
|
+
this.addChild(this.scrollIndicator);
|
|
183
|
+
|
|
119
184
|
// Bottom spacer + key hints + border
|
|
120
185
|
this.addChild(new Spacer(1));
|
|
186
|
+
const refreshHint = this.callbacks.onRefresh ? " · r refresh" : "";
|
|
121
187
|
const hints = this.theme.fg(
|
|
122
188
|
"muted",
|
|
123
|
-
|
|
189
|
+
` ↑↓ navigate · PgUp/PgDn · g/G top/bottom · f fullscreen · q/Esc close${refreshHint}`,
|
|
124
190
|
);
|
|
125
191
|
this.addChild(new Text(hints, 0, 0));
|
|
126
192
|
this.addChild(new Spacer(1));
|
|
127
193
|
this.addChild(new DynamicBorder());
|
|
194
|
+
}
|
|
128
195
|
|
|
129
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Build the stats line string, e.g.:
|
|
198
|
+
* " ↑12.0k · ↓8.0k · W3.0k · $0.024 · 15 turns · 47s"
|
|
199
|
+
* Fields with no data are omitted.
|
|
200
|
+
*/
|
|
201
|
+
private formatStatsLine(stats: ResultViewerStats): string {
|
|
202
|
+
const parts: string[] = [];
|
|
203
|
+
|
|
204
|
+
const { lifetimeUsage } = stats;
|
|
205
|
+
parts.push(`↑${formatTokens(lifetimeUsage.input)}`);
|
|
206
|
+
parts.push(`↓${formatTokens(lifetimeUsage.output)}`);
|
|
207
|
+
parts.push(`W${formatTokens(lifetimeUsage.cacheWrite)}`);
|
|
208
|
+
parts.push(`\$${lifetimeUsage.cost.toFixed(3)}`);
|
|
209
|
+
|
|
210
|
+
if (stats.turnCount != null) {
|
|
211
|
+
parts.push(`${stats.turnCount} turns`);
|
|
212
|
+
}
|
|
213
|
+
if (stats.durationMs != null) {
|
|
214
|
+
parts.push(formatMs(stats.durationMs));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return ` ${parts.join(" · ")}`;
|
|
130
218
|
}
|
|
131
219
|
|
|
132
220
|
handleInput(keyData: string): void {
|
|
@@ -134,55 +222,66 @@ export class ResultViewer extends Container implements Component {
|
|
|
134
222
|
|
|
135
223
|
// Up
|
|
136
224
|
if (kb.matches(keyData, "tui.select.up")) {
|
|
137
|
-
|
|
138
|
-
this.scrollOffset--;
|
|
139
|
-
this.updateViewport();
|
|
140
|
-
}
|
|
225
|
+
this.scrollTo(this.scrollOffset - 1);
|
|
141
226
|
return;
|
|
142
227
|
}
|
|
143
228
|
|
|
144
229
|
// Down
|
|
145
230
|
if (kb.matches(keyData, "tui.select.down")) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
231
|
+
this.scrollTo(this.scrollOffset + 1);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 'f' — toggle full-screen mode
|
|
236
|
+
if (keyData === "f") {
|
|
237
|
+
this.fullScreen = !this.fullScreen;
|
|
238
|
+
this._viewportSize = computeViewportSize(this.terminalHeight, this.fullScreen, this.hasStats);
|
|
239
|
+
this.updateViewport();
|
|
150
240
|
return;
|
|
151
241
|
}
|
|
152
242
|
|
|
153
243
|
// PageUp
|
|
154
244
|
if (kb.matches(keyData, "tui.select.pageUp")) {
|
|
155
|
-
this.
|
|
156
|
-
this.updateViewport();
|
|
245
|
+
this.scrollTo(this.scrollOffset - PAGE_STEP);
|
|
157
246
|
return;
|
|
158
247
|
}
|
|
159
248
|
|
|
160
249
|
// PageDown
|
|
161
250
|
if (kb.matches(keyData, "tui.select.pageDown")) {
|
|
162
|
-
this.scrollOffset
|
|
163
|
-
this.renderedLines.length - 1,
|
|
164
|
-
this.scrollOffset + PAGE_SIZE,
|
|
165
|
-
);
|
|
166
|
-
this.updateViewport();
|
|
251
|
+
this.scrollTo(this.scrollOffset + PAGE_STEP);
|
|
167
252
|
return;
|
|
168
253
|
}
|
|
169
254
|
|
|
170
255
|
// 'g' — jump to top
|
|
171
256
|
if (keyData === "g") {
|
|
172
|
-
this.
|
|
173
|
-
this.updateViewport();
|
|
257
|
+
this.scrollTo(0);
|
|
174
258
|
return;
|
|
175
259
|
}
|
|
176
260
|
|
|
177
261
|
// 'G' — jump to bottom
|
|
178
262
|
if (keyData === "G") {
|
|
179
|
-
this.
|
|
180
|
-
this.updateViewport();
|
|
263
|
+
this.scrollTo(this.renderedLines.length - 1);
|
|
181
264
|
return;
|
|
182
265
|
}
|
|
183
266
|
|
|
184
|
-
//
|
|
185
|
-
if (
|
|
267
|
+
// 'r' — refresh content (only if onRefresh callback provided)
|
|
268
|
+
if (keyData === "r" && this.callbacks.onRefresh) {
|
|
269
|
+
const newText = this.callbacks.onRefresh();
|
|
270
|
+
if (newText !== undefined && newText !== this.textRef.text) {
|
|
271
|
+
const oldOffset = this.scrollOffset;
|
|
272
|
+
this.textRef.text = newText;
|
|
273
|
+
const mdTheme = buildMarkdownTheme(this.theme);
|
|
274
|
+
this.markdown = new Markdown(newText, 0, 0, mdTheme);
|
|
275
|
+
this.renderedLines = this.markdown.render(78);
|
|
276
|
+
// Preserve scroll position, clamped to new content bounds
|
|
277
|
+
this.scrollOffset = Math.min(oldOffset, this.renderedLines.length - 1);
|
|
278
|
+
this.updateViewport();
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 'q' or Escape / Ctrl+C — close
|
|
284
|
+
if (keyData === "q" || kb.matches(keyData, "tui.select.cancel")) {
|
|
186
285
|
this.callbacks.onClose();
|
|
187
286
|
return;
|
|
188
287
|
}
|
|
@@ -190,29 +289,59 @@ export class ResultViewer extends Container implements Component {
|
|
|
190
289
|
|
|
191
290
|
invalidate(): void {}
|
|
192
291
|
|
|
292
|
+
private scrollTo(offset: number): void {
|
|
293
|
+
this.scrollOffset = Math.max(0, Math.min(this.renderedLines.length - 1, offset));
|
|
294
|
+
this.updateViewport();
|
|
295
|
+
}
|
|
296
|
+
|
|
193
297
|
private updateViewport(): void {
|
|
194
298
|
this.viewport.clear();
|
|
195
299
|
|
|
196
300
|
const visibleLines = Math.min(
|
|
197
|
-
|
|
301
|
+
this._viewportSize,
|
|
198
302
|
this.renderedLines.length - this.scrollOffset,
|
|
199
303
|
);
|
|
304
|
+
|
|
200
305
|
for (let i = 0; i < visibleLines; i++) {
|
|
201
306
|
const lineIdx = this.scrollOffset + i;
|
|
202
307
|
const line = this.renderedLines[lineIdx] ?? "";
|
|
203
308
|
this.viewport.addChild(new Text(line, 0, 0));
|
|
204
309
|
}
|
|
205
310
|
|
|
206
|
-
//
|
|
207
|
-
|
|
311
|
+
// Pad AFTER content to keep viewport at fixed height so the footer
|
|
312
|
+
// stays at a consistent screen row. Spacer renders real empty lines
|
|
313
|
+
// (Text("") short-circuits to zero lines).
|
|
314
|
+
const padding = this._viewportSize - visibleLines;
|
|
315
|
+
if (padding > 0) {
|
|
316
|
+
this.viewport.addChild(new Spacer(padding));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Scroll position indicator (outside viewport)
|
|
320
|
+
this.scrollIndicator.clear();
|
|
321
|
+
if (this.renderedLines.length > this._viewportSize) {
|
|
208
322
|
const pct = Math.round(
|
|
209
323
|
(this.scrollOffset / this.renderedLines.length) * 100,
|
|
210
324
|
);
|
|
211
|
-
|
|
325
|
+
this.scrollIndicator.addChild(new Text(this.theme.fg(
|
|
212
326
|
"muted",
|
|
213
327
|
` (${this.scrollOffset + 1}/${this.renderedLines.length} · ${pct}%)`,
|
|
214
|
-
);
|
|
215
|
-
this.viewport.addChild(new Text(indicator, 0, 0));
|
|
328
|
+
), 0, 0));
|
|
216
329
|
}
|
|
217
330
|
}
|
|
218
331
|
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Compute the viewport content line count based on terminal height and full-screen mode.
|
|
335
|
+
* Uses at least 50% of terminal height for the total component; falls back to MIN_VIEWPORT.
|
|
336
|
+
* When hasStats is true, extra lines are reserved for the stats title line.
|
|
337
|
+
*/
|
|
338
|
+
function computeViewportSize(terminalHeight: number, fullScreen: boolean, hasStats: boolean = false): number {
|
|
339
|
+
const overhead = BASE_OVERHEAD + (hasStats ? STATS_OVERHEAD : 0);
|
|
340
|
+
if (fullScreen) {
|
|
341
|
+
// Nearly full screen: leave a small margin
|
|
342
|
+
return Math.max(MIN_VIEWPORT, terminalHeight - overhead - 2);
|
|
343
|
+
}
|
|
344
|
+
// At least 50% of terminal height
|
|
345
|
+
const raw = Math.floor(terminalHeight / 2) - overhead;
|
|
346
|
+
return Math.max(MIN_VIEWPORT, raw);
|
|
347
|
+
}
|
package/src/skill-loader.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { join } from "node:path";
|
|
|
27
27
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
28
28
|
import { isSymlink, isUnsafeName, safeReadFile } from "./utils.js";
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
interface PreloadedSkill {
|
|
31
31
|
name: string;
|
|
32
32
|
content: string;
|
|
33
33
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stop-agent-tool.ts — StopAgent tool execute handler.
|
|
3
|
+
*
|
|
4
|
+
* Registered in index.ts alongside the Agent tool.
|
|
5
|
+
* Uses manager.abort(id) to stop running or queued agents.
|
|
6
|
+
*
|
|
7
|
+
* Response formats:
|
|
8
|
+
* - Success: "Stopped agent <short_id>"
|
|
9
|
+
* - Not found: "Agent <id> not found. Running agents: <type>·<short_id>, ..."
|
|
10
|
+
* - Already terminal: "Agent <id> is already <status>. Running agents: ..."
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { successResult, errorResult } from "./tool-execution.js";
|
|
15
|
+
import { manager } from "./index.js";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Running agents list helper
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a compact list of running (or queued) agents.
|
|
23
|
+
* Format: "type·short_id, type·short_id" — one line, easy for LLM to parse.
|
|
24
|
+
*/
|
|
25
|
+
function formatRunningAgents(): string {
|
|
26
|
+
const agents = manager.listAgents().filter(
|
|
27
|
+
(a) => a.status === "running" || a.status === "queued",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if (agents.length === 0) return "none";
|
|
31
|
+
|
|
32
|
+
return agents
|
|
33
|
+
.map((a) => `${a.type}·${a.id.slice(0, 5)}`)
|
|
34
|
+
.join(", ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Execute handler
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
export async function executeStopAgentTool(
|
|
42
|
+
_toolCallId: string,
|
|
43
|
+
params: Record<string, unknown>,
|
|
44
|
+
_signal: AbortSignal | undefined,
|
|
45
|
+
_onUpdate: ((update: any) => void) | undefined,
|
|
46
|
+
_ctx: ExtensionContext,
|
|
47
|
+
): Promise<any> {
|
|
48
|
+
const agentId = params.agent_id as string | undefined;
|
|
49
|
+
|
|
50
|
+
if (!agentId) {
|
|
51
|
+
return errorResult("agent_id is required");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const record = manager.getRecord(agentId);
|
|
55
|
+
|
|
56
|
+
if (!record) {
|
|
57
|
+
// Agent not found → return error + list of running agents
|
|
58
|
+
return errorResult(
|
|
59
|
+
`Agent ${agentId} not found. Running agents: ${formatRunningAgents()}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check if already in a terminal state (not running or queued)
|
|
64
|
+
if (record.status !== "running" && record.status !== "queued") {
|
|
65
|
+
return successResult(
|
|
66
|
+
`Agent ${agentId} is already ${record.status}. Running agents: ${formatRunningAgents()}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Attempt to stop the running/queued agent
|
|
71
|
+
if (manager.abort(agentId)) {
|
|
72
|
+
return successResult(`Stopped agent ${agentId.slice(0, 5)}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return errorResult(`Failed to stop agent ${agentId}`);
|
|
76
|
+
}
|