@rahularya01/pi-essentials 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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/examples/mcp.json +30 -0
  4. package/examples/pi-essentials.json +32 -0
  5. package/examples/pi-settings.json +5 -0
  6. package/package.json +88 -0
  7. package/skills/pi-essentials/SKILL.md +50 -0
  8. package/src/config.ts +351 -0
  9. package/src/errors.ts +96 -0
  10. package/src/index.ts +43 -0
  11. package/src/mcp/commands.ts +390 -0
  12. package/src/mcp/config.ts +157 -0
  13. package/src/mcp/credential-store.ts +153 -0
  14. package/src/mcp/index.ts +67 -0
  15. package/src/mcp/manager.ts +941 -0
  16. package/src/mcp/oauth.ts +262 -0
  17. package/src/mcp/proxy-tool.ts +213 -0
  18. package/src/mcp/render.ts +164 -0
  19. package/src/mcp/types.ts +63 -0
  20. package/src/paths.ts +48 -0
  21. package/src/questions/ask.ts +134 -0
  22. package/src/questions/index.ts +72 -0
  23. package/src/questions/render.ts +69 -0
  24. package/src/questions/validate.ts +85 -0
  25. package/src/security/env.ts +132 -0
  26. package/src/security/limits.ts +20 -0
  27. package/src/security/ssrf.ts +237 -0
  28. package/src/subagents/activity.ts +132 -0
  29. package/src/subagents/builtins/oracle.md +11 -0
  30. package/src/subagents/builtins/reviewer.md +11 -0
  31. package/src/subagents/builtins/scout.md +12 -0
  32. package/src/subagents/builtins/worker.md +11 -0
  33. package/src/subagents/discover.ts +54 -0
  34. package/src/subagents/herdr.ts +150 -0
  35. package/src/subagents/index.ts +642 -0
  36. package/src/subagents/inspector-tail.d.mts +1 -0
  37. package/src/subagents/inspector-tail.mjs +140 -0
  38. package/src/subagents/render.ts +464 -0
  39. package/src/subagents/runner.ts +468 -0
  40. package/src/subagents/schema.ts +107 -0
  41. package/src/subagents/types.ts +131 -0
  42. package/src/subagents/worktree.ts +131 -0
  43. package/src/todos/index.ts +170 -0
  44. package/src/todos/render.ts +198 -0
  45. package/src/todos/state.ts +310 -0
  46. package/src/ui/render.ts +215 -0
  47. package/src/web/activity.ts +91 -0
  48. package/src/web/cache.ts +153 -0
  49. package/src/web/extract.ts +75 -0
  50. package/src/web/fetch.ts +167 -0
  51. package/src/web/html-to-markdown.ts +284 -0
  52. package/src/web/http.ts +238 -0
  53. package/src/web/index.ts +214 -0
  54. package/src/web/providers/brave.ts +27 -0
  55. package/src/web/providers/duckduckgo.ts +60 -0
  56. package/src/web/providers/exa.ts +29 -0
  57. package/src/web/providers/jina.ts +25 -0
  58. package/src/web/providers/searxng.ts +29 -0
  59. package/src/web/providers/tavily.ts +31 -0
  60. package/src/web/providers/types.ts +75 -0
  61. package/src/web/render.ts +130 -0
  62. package/src/web/search.ts +108 -0
@@ -0,0 +1,310 @@
1
+ import { MAX_TODOS } from "../security/limits.ts";
2
+
3
+ export type TodoStatus = "pending" | "in_progress" | "completed" | "blocked" | "abandoned";
4
+
5
+ export interface TodoItem {
6
+ id: number;
7
+ content: string;
8
+ status: TodoStatus;
9
+ blockedBy?: number[];
10
+ phase?: string;
11
+ blocker?: string;
12
+ }
13
+
14
+ export interface TodoState {
15
+ todos: TodoItem[];
16
+ nextId: number;
17
+ }
18
+
19
+ export type TodoAction =
20
+ | "list"
21
+ | "create"
22
+ | "update"
23
+ | "complete"
24
+ | "reopen"
25
+ | "delete"
26
+ | "clear"
27
+ | "block"
28
+ | "unblock"
29
+ | "abandon"
30
+ | "sync";
31
+
32
+ export interface TodoMutation {
33
+ action: TodoAction;
34
+ content?: string;
35
+ id?: number;
36
+ status?: TodoStatus;
37
+ blockedBy?: number[];
38
+ phase?: string;
39
+ blocker?: string;
40
+ todos?: TodoItem[];
41
+ }
42
+
43
+ const STATUSES = new Set<TodoStatus>(["pending", "in_progress", "completed", "blocked", "abandoned"]);
44
+
45
+ export function emptyTodoState(): TodoState {
46
+ return { todos: [], nextId: 1 };
47
+ }
48
+
49
+ export function cloneState(state: TodoState): TodoState {
50
+ return {
51
+ nextId: state.nextId,
52
+ todos: state.todos.map((todo) => ({ ...todo, blockedBy: todo.blockedBy ? [...todo.blockedBy] : undefined })),
53
+ };
54
+ }
55
+
56
+ export function applyTodoMutation(state: TodoState, mutation: TodoMutation): { state: TodoState; message: string } {
57
+ const next = cloneState(state);
58
+ switch (mutation.action) {
59
+ case "list":
60
+ return { state: next, message: formatTodos(next.todos) };
61
+ case "sync": {
62
+ const synced = validateSnapshot(mutation.todos);
63
+ return {
64
+ state: { todos: synced, nextId: synced.reduce((max, todo) => Math.max(max, todo.id), 0) + 1 },
65
+ message: `Synced ${synced.length} todo(s).`,
66
+ };
67
+ }
68
+ case "create": {
69
+ const content = mutation.content?.trim();
70
+ if (!content) throw new Error("content is required to create a todo");
71
+ if (next.todos.length >= MAX_TODOS) {
72
+ throw new Error(`Todo limit reached (${MAX_TODOS} total); delete or clear todos before creating another.`);
73
+ }
74
+ const status = mutation.status ?? "pending";
75
+ assertStatus(status);
76
+ const blockedBy = normalizeBlockedBy(mutation.blockedBy, next.todos);
77
+ const todo: TodoItem = {
78
+ id: next.nextId++,
79
+ content,
80
+ status,
81
+ blockedBy,
82
+ phase: cleanOptional(mutation.phase),
83
+ blocker: cleanOptional(mutation.blocker),
84
+ };
85
+ next.todos.push(todo);
86
+ assertNoCycles(next.todos);
87
+ const moved = ensureSingleInProgress(next, todo);
88
+ return { state: next, message: `Created #${todo.id}: ${todo.content}${transitionNote(moved)}${warnings(next, todo)}` };
89
+ }
90
+ case "update": {
91
+ const todo = requireTodo(next, mutation.id);
92
+ const previous = todo.status;
93
+ if (mutation.content?.trim()) todo.content = mutation.content.trim();
94
+ if (mutation.status !== undefined) {
95
+ assertStatus(mutation.status);
96
+ todo.status = mutation.status;
97
+ }
98
+ if (mutation.blockedBy !== undefined) todo.blockedBy = normalizeBlockedBy(mutation.blockedBy, next.todos, todo.id);
99
+ if (mutation.phase !== undefined) todo.phase = cleanOptional(mutation.phase);
100
+ if (mutation.blocker !== undefined) todo.blocker = cleanOptional(mutation.blocker);
101
+ assertNoCycles(next.todos);
102
+ const moved = ensureSingleInProgress(next, todo);
103
+ const changed = previous !== todo.status ? ` (${previous} → ${todo.status})` : "";
104
+ return {
105
+ state: next,
106
+ message: `Updated #${todo.id}: ${todo.content} [${todo.status}]${changed}${transitionNote(moved)}${warnings(next, todo)}`,
107
+ };
108
+ }
109
+ case "complete": {
110
+ const todo = requireTodo(next, mutation.id);
111
+ if (todo.status === "completed") return { state: next, message: `#${todo.id} was already completed.` };
112
+ const previous = todo.status;
113
+ todo.status = "completed";
114
+ const unblocked = newlyDependencyReady(next, todo.id);
115
+ return {
116
+ state: next,
117
+ message: `Completed #${todo.id}: ${todo.content} (${previous} → completed).${unblockedNote(unblocked)}`,
118
+ };
119
+ }
120
+ case "reopen": {
121
+ const todo = requireTodo(next, mutation.id);
122
+ const previous = todo.status;
123
+ todo.status = "pending";
124
+ return { state: next, message: `Reopened #${todo.id}: ${todo.content} (${previous} → pending)` };
125
+ }
126
+ case "block": {
127
+ const todo = requireTodo(next, mutation.id);
128
+ const previous = todo.status;
129
+ todo.status = "blocked";
130
+ if (mutation.blocker !== undefined) todo.blocker = cleanOptional(mutation.blocker);
131
+ const reason = todo.blocker ? ` — ${todo.blocker}` : "";
132
+ return { state: next, message: `Blocked #${todo.id}: ${todo.content} (${previous} → blocked)${reason}` };
133
+ }
134
+ case "unblock": {
135
+ const todo = requireTodo(next, mutation.id);
136
+ const previous = todo.status;
137
+ todo.status = "pending";
138
+ todo.blocker = undefined;
139
+ const dependencies = isReady(next, todo) ? "" : ` Still waiting on ${unmetDependencies(next, todo).map((id) => `#${id}`).join(", ")}.`;
140
+ return { state: next, message: `Unblocked #${todo.id}: ${todo.content} (${previous} → pending).${dependencies}` };
141
+ }
142
+ case "abandon": {
143
+ const todo = requireTodo(next, mutation.id);
144
+ if (todo.status === "abandoned") return { state: next, message: `#${todo.id} was already abandoned.` };
145
+ const previous = todo.status;
146
+ todo.status = "abandoned";
147
+ return { state: next, message: `Abandoned #${todo.id}: ${todo.content} (${previous} → abandoned).` };
148
+ }
149
+ case "delete": {
150
+ const todo = requireTodo(next, mutation.id);
151
+ next.todos = next.todos.filter((item) => item.id !== todo.id);
152
+ for (const item of next.todos) {
153
+ if (item.blockedBy) item.blockedBy = item.blockedBy.filter((id) => id !== todo.id);
154
+ }
155
+ return { state: next, message: `Deleted #${todo.id}` };
156
+ }
157
+ case "clear": {
158
+ const count = next.todos.length;
159
+ next.todos = [];
160
+ next.nextId = 1;
161
+ return { state: next, message: `Cleared ${count} todo(s)` };
162
+ }
163
+ default:
164
+ throw new Error(`Unknown todo action: ${String(mutation.action)}`);
165
+ }
166
+ }
167
+
168
+ /** A todo is dependency-ready when every dependency it lists is completed. */
169
+ export function isReady(state: TodoState, todo: TodoItem): boolean {
170
+ return unmetDependencies(state, todo).length === 0;
171
+ }
172
+
173
+ function unmetDependencies(state: TodoState, todo: TodoItem): number[] {
174
+ return (todo.blockedBy ?? []).filter(
175
+ (id) => state.todos.find((item) => item.id === id)?.status !== "completed",
176
+ );
177
+ }
178
+
179
+ /** Moving work in progress is atomic: the previous active item returns to pending. */
180
+ function ensureSingleInProgress(state: TodoState, selected: TodoItem): TodoItem[] {
181
+ if (selected.status !== "in_progress") return [];
182
+ const moved = state.todos.filter((todo) => todo.id !== selected.id && todo.status === "in_progress");
183
+ for (const todo of moved) todo.status = "pending";
184
+ return moved;
185
+ }
186
+
187
+ function transitionNote(moved: TodoItem[]): string {
188
+ return moved.length > 0 ? ` Moved ${moved.map((todo) => `#${todo.id}`).join(", ")} back to pending.` : "";
189
+ }
190
+
191
+ function newlyDependencyReady(state: TodoState, completedId: number): TodoItem[] {
192
+ return state.todos.filter(
193
+ (item) => item.status !== "completed" && item.status !== "abandoned" && item.blockedBy?.includes(completedId) && isReady(state, item),
194
+ );
195
+ }
196
+
197
+ function unblockedNote(todos: TodoItem[]): string {
198
+ const ready = todos.filter((todo) => todo.status !== "blocked");
199
+ const explicitlyBlocked = todos.filter((todo) => todo.status === "blocked");
200
+ const parts: string[] = [];
201
+ if (ready.length > 0) parts.push(`Unblocked: ${ready.map((item) => `#${item.id}`).join(", ")} (dependencies complete).`);
202
+ if (explicitlyBlocked.length > 0) {
203
+ parts.push(`Dependencies complete for ${explicitlyBlocked.map((item) => `#${item.id}`).join(", ")}; explicit blocker remains.`);
204
+ }
205
+ return parts.length > 0 ? ` ${parts.join(" ")}` : "";
206
+ }
207
+
208
+ /** Non-fatal advice appended to a mutation message so the model self-corrects. */
209
+ function warnings(state: TodoState, todo: TodoItem): string {
210
+ if (todo.status !== "in_progress" || isReady(state, todo)) return "";
211
+ return ` (Warning: still blocked by ${unmetDependencies(state, todo).map((id) => `#${id}`).join(", ")}.)`;
212
+ }
213
+
214
+ export function formatTodos(todos: TodoItem[]): string {
215
+ if (todos.length === 0) return "No todos.";
216
+ const done = todos.filter((t) => t.status === "completed").length;
217
+ const state: TodoState = { todos, nextId: 0 };
218
+ const lines = [`Todos (${done}/${todos.length} completed)`];
219
+ for (const todo of todos) {
220
+ const mark = todo.status === "completed" ? "x" : todo.status === "in_progress" ? "~" : todo.status === "abandoned" ? "-" : todo.status === "blocked" ? "!" : " ";
221
+ const dependencies = todo.blockedBy?.length
222
+ ? ` ${isReady(state, todo) ? "was-blocked-by" : "blocked-by"}=${todo.blockedBy.join(",")}`
223
+ : "";
224
+ const phase = todo.phase ? ` phase=${todo.phase}` : "";
225
+ const blocker = todo.blocker ? ` blocker=${todo.blocker}` : "";
226
+ lines.push(`[${mark}] #${todo.id} ${todo.content} (${todo.status}${dependencies}${phase}${blocker})`);
227
+ }
228
+ return lines.join("\n");
229
+ }
230
+
231
+ function requireTodo(state: TodoState, id?: number): TodoItem {
232
+ if (id === undefined) throw new Error("id is required");
233
+ const todo = state.todos.find((item) => item.id === id);
234
+ if (!todo) throw new Error(`Todo #${id} not found`);
235
+ return todo;
236
+ }
237
+
238
+ function cleanOptional(value: string | undefined): string | undefined {
239
+ const cleaned = value?.trim();
240
+ return cleaned || undefined;
241
+ }
242
+
243
+ function assertStatus(status: string): asserts status is TodoStatus {
244
+ if (!STATUSES.has(status as TodoStatus)) throw new Error(`Invalid todo status: ${status}`);
245
+ }
246
+
247
+ function normalizeBlockedBy(blockedBy: number[] | undefined, todos: TodoItem[], selfId?: number): number[] | undefined {
248
+ if (!blockedBy || blockedBy.length === 0) return undefined;
249
+ const ids = [...new Set(blockedBy)];
250
+ for (const id of ids) {
251
+ if (!Number.isInteger(id) || id <= 0) throw new Error("blockedBy ids must be positive integers");
252
+ if (selfId !== undefined && id === selfId) throw new Error("A todo cannot block itself");
253
+ if (!todos.some((todo) => todo.id === id)) throw new Error(`blockedBy references missing todo #${id}`);
254
+ }
255
+ if (selfId !== undefined) {
256
+ assertNoCycles(todos.map((todo) => (todo.id === selfId ? { ...todo, blockedBy: ids } : todo)));
257
+ }
258
+ return ids;
259
+ }
260
+
261
+ /** Validate and clone a complete snapshot before any state is replaced. */
262
+ export function validateSnapshot(todos: TodoItem[] | undefined): TodoItem[] {
263
+ if (!Array.isArray(todos)) throw new Error("todos is required for sync");
264
+ if (todos.length > MAX_TODOS) throw new Error(`Todo snapshot exceeds the ${MAX_TODOS} total todo limit.`);
265
+
266
+ const ids = new Set<number>();
267
+ let active = 0;
268
+ const cloned = todos.map((todo, index): TodoItem => {
269
+ if (!todo || !Number.isInteger(todo.id) || todo.id <= 0) throw new Error(`Todo ${index + 1} id must be a positive integer`);
270
+ if (ids.has(todo.id)) throw new Error(`Duplicate todo id #${todo.id}`);
271
+ ids.add(todo.id);
272
+ const content = typeof todo.content === "string" ? todo.content.trim() : "";
273
+ if (!content) throw new Error(`Todo #${todo.id} content cannot be empty`);
274
+ assertStatus(todo.status);
275
+ if (todo.status === "in_progress" && ++active > 1) throw new Error("Only one todo may be in_progress");
276
+ if (todo.phase !== undefined && typeof todo.phase !== "string") throw new Error(`Todo #${todo.id} phase must be a string`);
277
+ if (todo.blocker !== undefined && typeof todo.blocker !== "string") throw new Error(`Todo #${todo.id} blocker must be a string`);
278
+ if (todo.blockedBy !== undefined && !Array.isArray(todo.blockedBy)) throw new Error(`Todo #${todo.id} blockedBy must be an array`);
279
+ return {
280
+ id: todo.id,
281
+ content,
282
+ status: todo.status,
283
+ blockedBy: todo.blockedBy?.length ? [...new Set(todo.blockedBy)] : undefined,
284
+ phase: cleanOptional(todo.phase),
285
+ blocker: cleanOptional(todo.blocker),
286
+ };
287
+ });
288
+
289
+ for (const todo of cloned) todo.blockedBy = normalizeBlockedBy(todo.blockedBy, cloned, todo.id);
290
+ assertNoCycles(cloned);
291
+ return cloned;
292
+ }
293
+
294
+ export function assertNoCycles(todos: TodoItem[]): void {
295
+ const byId = new Map(todos.map((todo) => [todo.id, todo]));
296
+ const visiting = new Set<number>();
297
+ const visited = new Set<number>();
298
+
299
+ const visit = (id: number): void => {
300
+ if (visited.has(id)) return;
301
+ if (visiting.has(id)) throw new Error("blockedBy contains a cycle");
302
+ visiting.add(id);
303
+ const todo = byId.get(id);
304
+ for (const dep of todo?.blockedBy ?? []) visit(dep);
305
+ visiting.delete(id);
306
+ visited.add(id);
307
+ };
308
+
309
+ for (const todo of todos) visit(todo.id);
310
+ }
@@ -0,0 +1,215 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+
4
+ /**
5
+ * Shared vocabulary for every pi-essentials surface.
6
+ *
7
+ * The house style is one compact line per tool row: a bold tool name, the
8
+ * subject it acted on, then dimmed metadata. Detail is available on demand
9
+ * through the expand key rather than printed by default.
10
+ */
11
+
12
+ export const GLYPH = {
13
+ ok: "✓",
14
+ fail: "✗",
15
+ pending: "○",
16
+ running: "▶",
17
+ done: "✓",
18
+ bullet: "•",
19
+ arrow: "→",
20
+ sep: "·",
21
+ } as const;
22
+
23
+ /** Braille spinner frames, matched to the rhythm of pi's own working indicator. */
24
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
25
+
26
+ export function spinnerFrame(startedAt: number, now = Date.now()): string {
27
+ return SPINNER[Math.floor((now - startedAt) / 80) % SPINNER.length];
28
+ }
29
+
30
+ export function formatDuration(ms: number): string {
31
+ if (!Number.isFinite(ms) || ms < 0) return "0s";
32
+ if (ms < 1000) return `${Math.round(ms)}ms`;
33
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
34
+ const minutes = Math.floor(ms / 60_000);
35
+ const seconds = Math.round((ms % 60_000) / 1000);
36
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
37
+ }
38
+
39
+ export function formatCount(value: number): string {
40
+ if (!Number.isFinite(value)) return "0";
41
+ if (Math.abs(value) < 1000) return String(Math.round(value));
42
+ if (Math.abs(value) < 1_000_000) return `${(value / 1000).toFixed(1)}k`;
43
+ return `${(value / 1_000_000).toFixed(1)}M`;
44
+ }
45
+
46
+ export function formatBytes(bytes: number): string {
47
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
48
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
49
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
50
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
51
+ }
52
+
53
+ /** Collapse whitespace and hard-truncate for single-line previews. */
54
+ export function oneLine(text: string, max = 72): string {
55
+ const flat = String(text ?? "").replace(/\s+/g, " ").trim();
56
+ if (flat.length <= max) return flat;
57
+ return `${flat.slice(0, Math.max(1, max - 1))}…`;
58
+ }
59
+
60
+ /** Shorten a URL to the part a reader actually scans. */
61
+ export function shortUrl(raw: string, max = 56): string {
62
+ try {
63
+ const url = new URL(raw);
64
+ const tail = `${url.pathname}${url.search}`.replace(/\/$/, "");
65
+ return oneLine(`${url.host}${tail}`, max);
66
+ } catch {
67
+ return oneLine(raw, max);
68
+ }
69
+ }
70
+
71
+ export function titleLine(theme: Theme, tool: string, subject?: string): string {
72
+ let line = theme.fg("toolTitle", theme.bold(tool));
73
+ if (subject) line += ` ${theme.fg("text", oneLine(subject, 64))}`;
74
+ return line;
75
+ }
76
+
77
+ /** Dimmed `a · b · c` metadata tail. */
78
+ export function meta(theme: Theme, parts: Array<string | undefined | false>): string {
79
+ const kept = parts.filter((part): part is string => Boolean(part && part.length > 0));
80
+ if (kept.length === 0) return "";
81
+ return theme.fg("dim", ` ${GLYPH.sep} ${kept.join(` ${GLYPH.sep} `)}`);
82
+ }
83
+
84
+ export function okLine(theme: Theme, text: string): string {
85
+ return `${theme.fg("success", GLYPH.ok)} ${text}`;
86
+ }
87
+
88
+ export function failLine(theme: Theme, text: string): string {
89
+ return `${theme.fg("error", GLYPH.fail)} ${theme.fg("error", text)}`;
90
+ }
91
+
92
+ /** "Ctrl+O to expand" using whatever the user actually bound. */
93
+ export function expandHint(theme: Theme, hidden: number, noun = "line"): string {
94
+ if (hidden <= 0) return "";
95
+ const label = hidden === 1 ? noun : `${noun}s`;
96
+ return theme.fg("dim", `\n +${hidden} more ${label} ${GLYPH.sep} ${safeKeyHint("app.tools.expand", "to expand")}`);
97
+ }
98
+
99
+ /**
100
+ * Default keys for the hints we print. Pi exposes `keyHint()` to resolve a
101
+ * user's actual binding, but only from the package root, whose module graph
102
+ * pulls an undeclared optional dependency and throws on import. Static defaults
103
+ * keep the renderers dependency-free; `keyHints` below lets a caller override.
104
+ */
105
+ const DEFAULT_KEYS: Record<string, string> = {
106
+ "app.tools.expand": "ctrl+o",
107
+ };
108
+
109
+ const keyOverrides = new Map<string, string>();
110
+
111
+ /** Point a hint id at the user's real binding, when something knows it. */
112
+ export function setKeyHint(id: string, key: string): void {
113
+ keyOverrides.set(id, key);
114
+ }
115
+
116
+ export function safeKeyHint(id: string, description: string): string {
117
+ const key = keyOverrides.get(id) ?? DEFAULT_KEYS[id];
118
+ return key ? `${key} ${description}` : description;
119
+ }
120
+
121
+ export interface IndentedOptions {
122
+ /** Maximum entries to show before summarizing the remainder. */
123
+ limit: number;
124
+ indent?: string;
125
+ /** What the hidden entries are called, e.g. "result", "tool". */
126
+ noun?: string;
127
+ }
128
+
129
+ /** Render body lines under a header, capped unless expanded. */
130
+ export function body(theme: Theme, lines: string[], expanded: boolean, options: IndentedOptions): string {
131
+ const indent = options.indent ?? " ";
132
+ const kept = expanded ? lines : lines.slice(0, options.limit);
133
+ let out = kept.map((line) => `\n${indent}${line}`).join("");
134
+ const hidden = lines.length - kept.length;
135
+ if (hidden > 0) out += expandHint(theme, hidden, options.noun);
136
+ return out;
137
+ }
138
+
139
+ /** "1 turn" / "2 turns" without a separate plural table. */
140
+ export function plural(count: number, noun: string, suffix = "s"): string {
141
+ return `${count} ${noun}${count === 1 ? "" : suffix}`;
142
+ }
143
+
144
+ /**
145
+ * Money at a readable precision. Subagent runs routinely cost fractions of a
146
+ * cent, so two decimals would round most of them to `$0.00`.
147
+ */
148
+ export function formatCost(cost: number): string | undefined {
149
+ if (!Number.isFinite(cost) || cost <= 0) return undefined;
150
+ if (cost < 0.01) return `$${Number(cost.toPrecision(2))}`;
151
+ if (cost < 1) return `$${cost.toFixed(3)}`;
152
+ return `$${cost.toFixed(2)}`;
153
+ }
154
+
155
+ /**
156
+ * Structural view of a tool result for renderers. Kept loose so it accepts
157
+ * `AgentToolResult<D>` without importing the agent-core types directly.
158
+ */
159
+ export interface RenderableResult<D> {
160
+ content: readonly unknown[];
161
+ details?: D;
162
+ }
163
+
164
+ /** Context slice the renderers actually use. */
165
+ export interface RenderSlot {
166
+ lastComponent?: unknown;
167
+ isError?: boolean;
168
+ }
169
+
170
+ /** First text block of a tool result, ignoring images and other content types. */
171
+ export function firstText(result: { content?: readonly unknown[] } | undefined): string {
172
+ for (const part of result?.content ?? []) {
173
+ if (part && typeof part === "object") {
174
+ const record = part as { type?: unknown; text?: unknown };
175
+ if (record.type === "text" && typeof record.text === "string") return record.text;
176
+ }
177
+ }
178
+ return "";
179
+ }
180
+
181
+ /** Reuse the previous Text instance so the TUI can diff in place. */
182
+ export function textRow(context: { lastComponent?: unknown }, content: string): Text {
183
+ const existing = context.lastComponent;
184
+ if (existing instanceof Text) {
185
+ existing.setText(content);
186
+ return existing;
187
+ }
188
+ return new Text(content, 0, 0);
189
+ }
190
+
191
+ /**
192
+ * Wrap a renderer so a formatting mistake degrades to plain text instead of
193
+ * breaking the transcript.
194
+ */
195
+ export function safeRender(build: () => string, fallback: string, context: { lastComponent?: unknown }): Text {
196
+ try {
197
+ return textRow(context, build());
198
+ } catch {
199
+ return textRow(context, fallback);
200
+ }
201
+ }
202
+
203
+ /** Horizontal rule used as a widget heading, e.g. `── Todos ── 2/7 ──────`. */
204
+ export function headingRule(theme: Theme, label: string, width = 46): string {
205
+ const text = ` ${label} `;
206
+ const fill = Math.max(0, width - text.length - 2);
207
+ return theme.fg("borderMuted", "──") + theme.fg("muted", text) + theme.fg("borderMuted", "─".repeat(fill));
208
+ }
209
+
210
+ /** Compact progress meter: `▪▪▪▪▫▫▫`. */
211
+ export function progressBar(theme: Theme, done: number, total: number, width = 10): string {
212
+ if (total <= 0) return "";
213
+ const filled = Math.max(0, Math.min(width, Math.round((done / total) * width)));
214
+ return theme.fg("success", "▪".repeat(filled)) + theme.fg("dim", "▫".repeat(width - filled));
215
+ }
@@ -0,0 +1,91 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { formatCount, formatDuration, GLYPH, headingRule, oneLine, safeKeyHint, shortUrl } from "../ui/render.ts";
3
+
4
+ export interface ActivityEntry {
5
+ kind: "search" | "fetch";
6
+ subject: string;
7
+ detail?: string;
8
+ ms: number;
9
+ ok: boolean;
10
+ }
11
+
12
+ /** Entries kept for the activity panel. Old rows scroll off the bottom. */
13
+ const MAX_ENTRIES = 20;
14
+
15
+ /**
16
+ * A short history of what the web tools actually did. Without it a fetch that
17
+ * silently fell back to a different provider, or took eight seconds, leaves no
18
+ * trace in the transcript.
19
+ */
20
+ export class ActivityLog {
21
+ private readonly entries: ActivityEntry[] = [];
22
+
23
+ add(entry: ActivityEntry): void {
24
+ this.entries.unshift(entry);
25
+ if (this.entries.length > MAX_ENTRIES) this.entries.length = MAX_ENTRIES;
26
+ }
27
+
28
+ clear(): void {
29
+ this.entries.length = 0;
30
+ }
31
+
32
+ get size(): number {
33
+ return this.entries.length;
34
+ }
35
+
36
+ recent(limit: number): ActivityEntry[] {
37
+ return this.entries.slice(0, Math.max(0, limit));
38
+ }
39
+
40
+ /** Start a timer that records the outcome either way. */
41
+ track<T>(kind: ActivityEntry["kind"], subject: string, run: () => Promise<T>, describe: (value: T) => string): Promise<T> {
42
+ const startedAt = Date.now();
43
+ return run().then(
44
+ (value) => {
45
+ this.add({ kind, subject, detail: describe(value), ms: Date.now() - startedAt, ok: true });
46
+ return value;
47
+ },
48
+ (error: unknown) => {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ this.add({ kind, subject, detail: oneLine(message, 40), ms: Date.now() - startedAt, ok: false });
51
+ throw error;
52
+ },
53
+ );
54
+ }
55
+ }
56
+
57
+ export function searchDetail(result: { hits: unknown[] }): string {
58
+ return `${result.hits.length} hits`;
59
+ }
60
+
61
+ export function fetchDetail(page: { totalChars: number }): string {
62
+ return `${formatCount(page.totalChars)} chars`;
63
+ }
64
+
65
+ export interface ActivityWidgetOptions {
66
+ collapsed: boolean;
67
+ maxRows: number;
68
+ }
69
+
70
+ export function activityWidget(theme: Theme, log: ActivityLog, options: ActivityWidgetOptions): string[] {
71
+ if (log.size === 0) return [];
72
+ const heading = headingRule(theme, "Web activity") + theme.fg("dim", ` ${log.size} recent`);
73
+ if (options.collapsed) {
74
+ return [`${heading} ${theme.fg("dim", `${GLYPH.sep} ${safeKeyHint("app.tools.expand", "expand")}`)}`];
75
+ }
76
+
77
+ const rows = log.recent(Math.max(1, options.maxRows)).map((entry) => {
78
+ const verb = entry.kind === "search" ? "SEARCH" : "FETCH ";
79
+ const subject = entry.kind === "fetch" ? shortUrl(entry.subject, 34) : `"${oneLine(entry.subject, 32)}"`;
80
+ return (
81
+ ` ${theme.fg("muted", verb)} ${theme.fg("text", subject.padEnd(36))}` +
82
+ theme.fg("dim", `${(entry.detail ?? "").padEnd(20)}${formatDuration(entry.ms).padStart(7)} `) +
83
+ theme.fg(entry.ok ? "success" : "error", entry.ok ? GLYPH.ok : GLYPH.fail)
84
+ );
85
+ });
86
+
87
+ const hidden = log.size - rows.length;
88
+ const lines = [heading, ...rows];
89
+ if (hidden > 0) lines.push(theme.fg("dim", ` +${hidden} older`));
90
+ return lines;
91
+ }