pi-subagents-lite 1.0.2 → 1.2.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.
@@ -18,6 +18,11 @@ export interface SubagentsConfig {
18
18
  default: string | null;
19
19
  forceBackground: boolean;
20
20
  graceTurns?: number;
21
+ showCost?: boolean;
22
+ widgetMaxLines?: number;
23
+ widgetMaxLinesCompact?: number;
24
+ widgetCompact?: boolean;
25
+ widgetShortcut?: boolean;
21
26
  [agentType: string]: string | null | undefined | boolean | number;
22
27
  };
23
28
  concurrency: {
@@ -10,12 +10,7 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
12
12
  import { formatTokens } from "./usage.js";
13
-
14
- /** Max length for a truncated command in tool arg summaries. */
15
- const MAX_COMMAND_DISPLAY_LENGTH = 100;
16
-
17
- /** Max length for a truncated string value in default tool arg summaries. */
18
- const MAX_DEFAULT_STRING_DISPLAY_LENGTH = 200;
13
+ import { summarizeToolArgs } from "./format.js";
19
14
 
20
15
  /** Max content length for full tool result display — longer results get a summary line. */
21
16
  const MAX_TOOL_RESULT_DISPLAY_LENGTH = 500;
@@ -68,68 +63,6 @@ function splitAndPrefix(text: string, role: string): string {
68
63
  .join("");
69
64
  }
70
65
 
71
- /**
72
- * Summarize tool arguments for log-friendly display.
73
- *
74
- * Heavy tools (read, write, edit, bash, grep, rg) get compact summaries.
75
- * Other tools fall back to the default JSON formatting.
76
- */
77
- export function summarizeToolArgs(name: string, rawArgs: Record<string, unknown> | undefined): string {
78
- if (!rawArgs || typeof rawArgs !== "object" || Object.keys(rawArgs).length === 0) return "";
79
-
80
- switch (name) {
81
- case "read": {
82
- // read("/path/to/file") — just the path
83
- const path = typeof rawArgs.path === "string" ? rawArgs.path : "";
84
- return `(${JSON.stringify(path)})`;
85
- }
86
- case "write": {
87
- // write("/path/to/file", <N> chars) — path + content size
88
- const path = typeof rawArgs.file_path === "string" ? rawArgs.file_path : "";
89
- const content = rawArgs.content;
90
- const size = typeof content === "string" ? content.length : 0;
91
- return `(${JSON.stringify(path)}, ${size} chars)`;
92
- }
93
- case "edit": {
94
- // edit("/path/to/file", <N> edits) — path + edit count
95
- const path = typeof rawArgs.path === "string" ? rawArgs.path : "";
96
- const edits = rawArgs.edits;
97
- const editCount = Array.isArray(edits) ? edits.length : 0;
98
- return `(${JSON.stringify(path)}, ${editCount} edits)`;
99
- }
100
- case "bash": {
101
- // bash("command") — just the command, strip heredoc, truncate long
102
- const cmd = typeof rawArgs.command === "string" ? rawArgs.command : "";
103
- // Strip heredoc: truncate at << followed by delimiter
104
- const heredocIdx = cmd.search(/<<\s*['"]?\w+['"]?/);
105
- const cleanCmd = heredocIdx >= 0 ? cmd.slice(0, heredocIdx).trim() : cmd.trim();
106
- // Truncate long commands
107
- const display = cleanCmd.length > MAX_COMMAND_DISPLAY_LENGTH
108
- ? cleanCmd.slice(0, MAX_COMMAND_DISPLAY_LENGTH) + "…" : cleanCmd;
109
- return `(${JSON.stringify(display)})`;
110
- }
111
- case "grep":
112
- case "rg": {
113
- // grep("pattern", "/path") — pattern + path
114
- const pattern = typeof rawArgs.pattern === "string" ? rawArgs.pattern : "";
115
- const path = typeof rawArgs.path === "string" ? rawArgs.path : "";
116
- return `(${JSON.stringify(pattern)}, ${JSON.stringify(path)})`;
117
- }
118
- default: {
119
- // Default behavior for other tools: single-arg shorthand or JSON dump
120
- const keys = Object.keys(rawArgs);
121
- if (keys.length === 1) {
122
- const val = rawArgs[keys[0]];
123
- const display = typeof val === "string" && val.length > MAX_DEFAULT_STRING_DISPLAY_LENGTH
124
- ? JSON.stringify(val.slice(0, MAX_DEFAULT_STRING_DISPLAY_LENGTH) + "...")
125
- : JSON.stringify(val);
126
- return `(${display})`;
127
- }
128
- return ` ${JSON.stringify(rawArgs)}`;
129
- }
130
- }
131
- }
132
-
133
66
  /** Format a toolUse/toolCall content item as a single log line. */
134
67
  function formatToolItem(item: Record<string, unknown>): string {
135
68
  const name = (item.name ?? item.toolName ?? "unknown") as string;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * renderer.ts — Rendering helpers for the Agent tool and subagent-result messages.
3
+ *
4
+ * Extracted from index.ts to separate display concerns from extension wiring.
5
+ */
6
+
7
+ import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
8
+ import type { Theme } from "./types.js";
9
+ import { buildStatsParts, formatMs, getDisplayName } from "./format.js";
10
+ import { __config } from "./state.js";
11
+
12
+ // ============================================================================
13
+ // Stats rendering helpers
14
+ // ============================================================================
15
+
16
+ /** Format agent display name with optional model: "Agent (mimo-v2.5-pro)" or "Agent". */
17
+ export function agentNameLabel(d: Record<string, unknown>, theme: Theme): string {
18
+ const typeName = getDisplayName((d.type as string) || "");
19
+ const modelName = d.modelName as string | undefined;
20
+ return modelName ? `${theme.bold(typeName)} (${modelName})` : theme.bold(typeName);
21
+ }
22
+
23
+ /** Build the stats line for an agent result card. */
24
+ export function buildStatsLine(d: Record<string, unknown>, theme: Theme): string {
25
+ const showCost = __config.agent.showCost === true;
26
+ const parts = buildStatsParts({
27
+ toolUses: (d.toolUses as number) ?? 0,
28
+ turnCount: d.turnCount as number | undefined,
29
+ maxTurns: d.maxTurns as number | undefined,
30
+ tokens: (d.tokens as number) ?? 0,
31
+ contextPercent: d.contextPercent as number | null,
32
+ compactions: (d.compactions as number) ?? 0,
33
+ cost: showCost ? (d.cost as number | undefined) : undefined,
34
+ }, theme);
35
+ parts.push(formatMs(d.durationMs as number));
36
+ return parts.join("·");
37
+ }
38
+
39
+ // ============================================================================
40
+ // Agent tool renderers
41
+ // ============================================================================
42
+
43
+ /** Render the Agent tool call line (e.g., "▸ Agent (model)"). */
44
+ export function renderAgentToolCall(
45
+ args: Record<string, unknown>,
46
+ theme: Theme,
47
+ ): Text {
48
+ const typeName = getDisplayName((args.agent as string) || "");
49
+ const label = typeName || "Agent";
50
+ let text = `▸ ${theme.fg("accent", theme.bold(label))}`;
51
+
52
+ const modelOverride = args._modelOverride as string | undefined;
53
+ if (modelOverride) {
54
+ text += ` (${modelOverride})`;
55
+ }
56
+
57
+ return new Text(text, 0, 0);
58
+ }
59
+
60
+ /** Render the Agent tool result — compact or expanded. */
61
+ export function renderAgentToolResult(
62
+ result: { content: Array<{ type: string; text?: string }>; details?: Record<string, unknown>; isError?: boolean },
63
+ options: { expanded?: boolean },
64
+ theme: Theme,
65
+ ): Text {
66
+ const { expanded } = options;
67
+ const text = result.content[0]?.type === "text" ? result.content[0].text ?? "" : "";
68
+ const d = result.details;
69
+ const icon = result.isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
70
+ const desc = (d?.description as string) || "";
71
+
72
+ if (d && d.turnCount != null) {
73
+ const namePart = agentNameLabel(d, theme);
74
+ const statsLine = buildStatsLine(d, theme);
75
+ let lines = `${icon} ${namePart}·${statsLine}\n ${theme.fg("text", desc)}`;
76
+ if (expanded && text) {
77
+ lines += "\n" + text.split("\n").map(l => ` ${l}`).join("\n");
78
+ }
79
+ return new Text(lines, 0, 0);
80
+ }
81
+
82
+ // Minimal card — background spawns (no stats) use space placeholder
83
+ const isBackground = text.includes("running in background") || text.includes("queued");
84
+ const prefix = isBackground ? " " : `${icon} `;
85
+ if (desc) {
86
+ return new Text(`${prefix}${theme.fg("text", desc)}`, 0, 0);
87
+ }
88
+
89
+ return new Text(`${prefix}${theme.fg("dim", text)}`, 0, 0);
90
+ }
91
+
92
+ // ============================================================================
93
+ // Message renderer — subagent-result (background agent completion)
94
+ // ============================================================================
95
+
96
+ /** Render a subagent-result message injected after background agent completion. */
97
+ export function renderSubagentResult(
98
+ message: { content?: string; details?: Record<string, unknown> },
99
+ options: { expanded?: boolean },
100
+ theme: Theme,
101
+ ): Container {
102
+ const { expanded } = options;
103
+ const d = message.details;
104
+ const text = (message.content as string)?.trim() || "";
105
+
106
+ const inner = new Container();
107
+ inner.addChild(new Text(theme.fg("customMessageLabel", "Subagent Result"), 0, 0));
108
+ inner.addChild(new Spacer(1));
109
+
110
+ if (d && d.turnCount != null) {
111
+ const isError = d.status === "error" || d.status === "aborted" || d.status === "stopped";
112
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
113
+
114
+ const namePart = agentNameLabel(d, theme);
115
+ const statsLine = buildStatsLine(d, theme);
116
+ let headerLine = `${icon} ${namePart}·${statsLine}\n ${theme.fg("text", (d.description as string) || "")}`;
117
+ if (d.outputFile as string) {
118
+ headerLine += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
119
+ }
120
+ if (d.worktreePath as string) {
121
+ headerLine += `\n ${theme.fg("dim", `worktree: ${d.worktreePath}`)}`;
122
+ }
123
+ inner.addChild(new Text(headerLine, 0, 0));
124
+
125
+ if (expanded && text) {
126
+ inner.addChild(new Spacer(1));
127
+ inner.addChild(new Text(text.split("\n").map(l => ` ${l}`).join("\n"), 0, 0));
128
+ }
129
+ } else {
130
+ inner.addChild(new Text(buildFallbackResultLine(d, text, theme), 0, 0));
131
+ }
132
+
133
+ const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
134
+ box.addChild(inner);
135
+
136
+ const outer = new Container();
137
+ outer.addChild(new Spacer(1));
138
+ outer.addChild(box);
139
+ outer.addChild(new Spacer(1));
140
+ return outer;
141
+ }
142
+
143
+ /** Build a fallback result line for subagent-result messages without stats. */
144
+ function buildFallbackResultLine(
145
+ d: Record<string, unknown> | undefined,
146
+ text: string,
147
+ theme: Theme,
148
+ ): string {
149
+ const icon = theme.fg("success", "✓");
150
+ let line = icon;
151
+ if (d?.type) {
152
+ line += ` ${agentNameLabel(d, theme)}`;
153
+ }
154
+ const desc = (d?.description as string) || "";
155
+ if (desc) line += `\n ${theme.fg("text", desc)}`;
156
+ if (d?.outputFile) {
157
+ line += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
158
+ }
159
+ if (d?.worktreePath) {
160
+ line += `\n ${theme.fg("dim", `worktree: ${d.worktreePath}`)}`;
161
+ }
162
+ return line;
163
+ }
@@ -17,7 +17,8 @@ import {
17
17
  } from "@earendil-works/pi-tui";
18
18
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
19
19
  import { type LifetimeUsage, formatTokens } from "./usage.js";
20
- import { formatMs, type Theme } from "./ui/agent-widget.js";
20
+ import type { Theme } from "./ui/agent-widget.js";
21
+ import { formatMs } from "./format.js";
21
22
 
22
23
  /* ------------------------------------------------------------------ */
23
24
  /* Types */
package/src/state.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * state.ts — Shared module state. Extracted from index.ts to break circular deps.
3
+ *
4
+ * manager and widget use holders because they're reassigned after import and the
5
+ * PI runtime doesn't propagate ESM live binding reassignments.
6
+ */
7
+
8
+ import type { ExtensionContext, ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import type { SessionModelOverrides, SubagentsConfig } from "./model-precedence.js";
10
+ import { DEFAULT_CONFIG } from "./config-io.js";
11
+ import { AgentManager } from "./agent-manager.js";
12
+ import { AgentWidget, type AgentActivity } from "./ui/agent-widget.js";
13
+
14
+ export let sessionOverrides: SessionModelOverrides = { default: null };
15
+ export let __config: SubagentsConfig = { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
16
+ export const agentActivity = new Map<string, AgentActivity>();
17
+ export let piInstance: ExtensionAPI;
18
+ /** Stored ExtensionContext from session_start — used by menu spawn flow. */
19
+ export let sessionCtx: ExtensionContext;
20
+
21
+ // Holder objects — PI runtime doesn't propagate ESM live binding reassignments
22
+ const managerHolder: { current?: AgentManager } = {};
23
+ const widgetHolder: { current?: AgentWidget } = {};
24
+
25
+ export function setConfig(config: SubagentsConfig): void { __config = config; }
26
+ export function resetSessionOverrides(): void { sessionOverrides = { default: null }; }
27
+ export function setManager(m: AgentManager): void { managerHolder.current = m; }
28
+ export function clearManager(): void { managerHolder.current = undefined; }
29
+ export function setWidget(w: AgentWidget | undefined): void { widgetHolder.current = w; }
30
+ export function setPiInstance(pi: ExtensionAPI): void { piInstance = pi; }
31
+ export function setSessionCtx(ctx: ExtensionContext): void { sessionCtx = ctx; }
32
+ export function getManager(): AgentManager { return managerHolder.current!; }
33
+ export function getWidget(): AgentWidget | undefined { return widgetHolder.current; }
34
+
35
+ // State mutation helpers
36
+
37
+ /** Update the cost display toggle in config and sync to widget. */
38
+ export function setShowCostEnabled(enabled: boolean): void {
39
+ __config.agent.showCost = enabled;
40
+ getWidget()?.setShowCost(enabled);
41
+ }
42
+
43
+ /** Sync widget display settings from config to the widget instance. */
44
+ export function syncWidgetSettings(): void {
45
+ const w = getWidget();
46
+ if (!w) return;
47
+ w.setForceCompact(__config.agent.widgetCompact === true);
48
+ w.setWidgetShortcut(__config.agent.widgetShortcut === true);
49
+ w.setMaxLines(__config.agent.widgetMaxLines ?? 12);
50
+ w.setMaxLinesCompact(
51
+ __config.agent.widgetMaxLinesCompact ?? Math.floor((__config.agent.widgetMaxLines ?? 12) / 2),
52
+ );
53
+ }
54
+
55
+ /** Track previous tool expansion state to detect ctrl+o toggle. */
56
+ let lastToolsExpanded: boolean | undefined;
57
+
58
+ /** Reset lastToolsExpanded (called at session_start). */
59
+ export function resetLastToolsExpanded(): void {
60
+ lastToolsExpanded = undefined;
61
+ }
62
+
63
+ /** Sync compact mode with the tool expansion state (ctrl+o toggle).
64
+ * Only syncs when widgetShortcut is enabled in config (opt-in behavior).
65
+ * Only triggers on state change (not every tool_execution_start).
66
+ * When forceCompact (widgetCompact) is ON, ignores ctrl+o state changes.
67
+ */
68
+ export function syncCompactFromToolsExpanded(expanded: boolean): void {
69
+ if (__config.agent.widgetShortcut !== true) {
70
+ lastToolsExpanded = expanded;
71
+ return;
72
+ }
73
+ // When forceCompact is ON, ignore ctrl+o state changes
74
+ if (__config.agent.widgetCompact === true) {
75
+ lastToolsExpanded = expanded;
76
+ return;
77
+ }
78
+ // Tools expanded → widget full, tools collapsed → widget compact
79
+ if (lastToolsExpanded !== undefined && lastToolsExpanded !== expanded) {
80
+ getWidget()?.setCompactMode(!expanded);
81
+ }
82
+ lastToolsExpanded = expanded;
83
+ }