pi-subagents-lite 0.2.0 → 0.3.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.
@@ -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
- export interface ResultViewerCallbacks {
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
- const PAGE_SIZE = 14;
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: Container;
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
- // Build UI
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
- " ↑↓ navigate · PgUp/PgDn · g/G top/bottom · Esc close",
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
- this.updateViewport();
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
- if (this.scrollOffset > 0) {
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
- if (this.scrollOffset < this.renderedLines.length - 1) {
147
- this.scrollOffset++;
148
- this.updateViewport();
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.scrollOffset = Math.max(0, this.scrollOffset - PAGE_SIZE);
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 = Math.min(
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.scrollOffset = 0;
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.scrollOffset = this.renderedLines.length - 1;
180
- this.updateViewport();
263
+ this.scrollTo(this.renderedLines.length - 1);
181
264
  return;
182
265
  }
183
266
 
184
- // Escape / Ctrl+C close
185
- if (kb.matches(keyData, "tui.select.cancel")) {
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
- PAGE_SIZE,
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
- // Scroll position indicator
207
- if (this.renderedLines.length > PAGE_SIZE) {
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
- const indicator = this.theme.fg(
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
+ }
@@ -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
- export interface PreloadedSkill {
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
+ }