pi-subagents-lite 1.3.0 → 1.4.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.
Files changed (53) hide show
  1. package/README.md +184 -235
  2. package/package.json +1 -1
  3. package/src/{agent-discovery.ts → agents/agent-discovery.ts} +10 -7
  4. package/src/{agent-manager.ts → agents/agent-manager.ts} +34 -74
  5. package/src/{agent-runner.ts → agents/agent-runner.ts} +130 -181
  6. package/src/{agent-status.ts → agents/agent-status.ts} +4 -4
  7. package/src/agents/agent-types.ts +339 -0
  8. package/src/{default-agents.ts → agents/default-agents.ts} +2 -5
  9. package/src/{output-file.ts → agents/output-file.ts} +68 -1
  10. package/src/{tool-execution.ts → agents/tool-execution.ts} +60 -222
  11. package/src/agents/types.ts +54 -0
  12. package/src/{usage.ts → agents/usage.ts} +7 -0
  13. package/src/{config-io.ts → config/config-io.ts} +20 -3
  14. package/src/config/config-store.ts +472 -0
  15. package/src/config/types.ts +26 -0
  16. package/src/events.ts +185 -0
  17. package/src/index.ts +8 -281
  18. package/src/{model-precedence.ts → models/model-precedence.ts} +33 -0
  19. package/src/{model-selector.ts → models/model-selector.ts} +1 -1
  20. package/src/{context.ts → prompt/context.ts} +1 -1
  21. package/src/prompt/prompts.ts +180 -0
  22. package/src/prompt/skill-loader.ts +195 -0
  23. package/src/registration.ts +101 -0
  24. package/src/shell.ts +101 -0
  25. package/src/spawn/spawn-coordinator.ts +232 -0
  26. package/src/status-note.ts +10 -0
  27. package/src/types.ts +47 -71
  28. package/src/ui/agent-widget.ts +61 -49
  29. package/src/{format.ts → ui/format.ts} +64 -26
  30. package/src/ui/menu/helpers.ts +93 -0
  31. package/src/ui/menu/menu-concurrency.ts +192 -0
  32. package/src/ui/menu/menu-debug.ts +125 -0
  33. package/src/ui/menu/menu-model-settings.ts +208 -0
  34. package/src/ui/menu/menu-running-agents.ts +224 -0
  35. package/src/ui/menu/menu-spawn-options.ts +87 -0
  36. package/src/ui/menu/menu-spawn-wizard.ts +418 -0
  37. package/src/ui/menu/menu-system-prompt.ts +109 -0
  38. package/src/ui/menu/menu-widget-settings.ts +130 -0
  39. package/src/ui/menu/menus.ts +101 -0
  40. package/src/ui/menu/submenus/confirm.ts +47 -0
  41. package/src/ui/menu/submenus/model-select.ts +70 -0
  42. package/src/ui/menu/submenus/numeric-input.ts +98 -0
  43. package/src/ui/menu/wrappers/settings-list.ts +205 -0
  44. package/src/{renderer.ts → ui/renderer.ts} +7 -6
  45. package/src/{result-viewer.ts → ui/result-viewer.ts} +7 -2
  46. package/src/ui/types.ts +11 -0
  47. package/src/agent-types.ts +0 -184
  48. package/src/config-mutator.ts +0 -183
  49. package/src/menus.ts +0 -1333
  50. package/src/prompts.ts +0 -94
  51. package/src/skill-loader.ts +0 -178
  52. package/src/state.ts +0 -83
  53. /package/src/{worktree-validator.ts → spawn/worktree-validator.ts} +0 -0
package/src/types.ts CHANGED
@@ -2,50 +2,32 @@
2
2
  * Type definitions for the subagent system.
3
3
  */
4
4
 
5
+ import type { Model } from "@earendil-works/pi-ai";
5
6
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
6
- import type { LifetimeUsage } from "./usage.js";
7
+ import type { AgentOutputLog } from "./agents/output-file.js";
8
+ import type { LifetimeUsage } from "./agents/usage.js";
9
+ import type { SubagentType, AgentInvocation } from "./agents/types.js";
7
10
 
8
11
  /** Thinking level for agent models. */
9
12
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
10
13
 
11
- /** Agent type: any string name (built-in defaults or user-defined). */
12
- export type SubagentType = string;
14
+ /** Tool activity event: start/end of a tool invocation. */
15
+ export interface ToolActivity {
16
+ type: "start" | "end";
17
+ toolName: string;
18
+ }
13
19
 
14
- /** Unified agent configuration — used for both default and user-defined agents. */
15
- export interface AgentConfig {
16
- name: string;
17
- displayName?: string;
18
- description: string;
19
- /** Tools to register with the session (controls availability, not LLM visibility). */
20
- registeredTools?: string[];
21
- /**
22
- * Controls which tool schemas the LLM sees. Can reference built-in tools
23
- * and extension tools. true = all, string[] = listed, false = none.
24
- * Supports ext/* syntax to include all tools from an extension.
25
- * Mutually exclusive with excludeTools.
26
- */
27
- tools?: true | string[] | false;
28
- /** Tool blacklist — all tools except these are visible. Mutually exclusive with tools (when tools is string[]). */
29
- excludeTools?: string[];
30
- /** true = inherit all, string[] = only listed, false = none. Mutually exclusive with excludeExtensions. */
31
- extensions: true | string[] | false;
32
- /** Extension blacklist — all extensions except these load. Mutually exclusive with extensions (when extensions is string[]). */
33
- excludeExtensions?: string[];
34
- /** Whitelist of allowed skills (metadata only in system prompt). true = all, string[] = listed, false = none */
35
- skills: true | string[] | false;
36
- /** Skills to preload with full content into system prompt. string[] = listed, false/undefined = none */
37
- preloadSkills?: string[] | false;
38
- model?: string;
39
- thinking?: ThinkingLevel;
20
+ /**
21
+ * Resolved model + run-limit tunables shared by every spawn/run shape
22
+ * (RunOptions, SpawnOptions, SpawnIntent). Add a tunable here once and it
23
+ * flows through the whole chain.
24
+ */
25
+ export interface RunTunables {
26
+ model?: Model<any>;
40
27
  maxTurns?: number;
41
- systemPrompt: string;
42
-
43
- /** true = this is an embedded default agent (informational) */
44
- isDefault?: boolean;
45
- /** true = agent is hidden from the schema enum but can still be called by name. */
46
- hidden?: boolean;
47
- /** Where this agent was loaded from */
48
- source?: "project" | "global";
28
+ maxTokens?: number;
29
+ thinkingLevel?: ThinkingLevel;
30
+ graceTurns?: number;
49
31
  }
50
32
 
51
33
  export interface AgentRecord {
@@ -62,46 +44,40 @@ export interface AgentRecord {
62
44
  stats: AgentAccumulatedStats;
63
45
  }
64
46
 
65
- export interface AgentInvocation {
66
- /** Short display name, e.g. "haiku" — only set when different from parent. */
67
- modelName?: string;
68
- thinking?: ThinkingLevel;
69
- maxTurns?: number;
70
- runInBackground?: boolean;
71
- }
72
-
73
47
  export interface EnvInfo {
74
48
  isGitRepo: boolean;
75
49
  branch: string | null;
76
50
  platform: string;
77
51
  }
78
52
 
79
- /** How many characters of agent ID to show in display. */
80
- export const SHORT_ID_LENGTH = 8;
53
+ /**
54
+ * Streaming/callback surface shared by RunOptions and SpawnOptions.
55
+ * Bridges agent-runner events to record tracking and live-view updates.
56
+ */
57
+ export interface RunCallbacks {
58
+ onToolActivity?: (activity: ToolActivity) => void;
59
+ onTextDelta?: (delta: string, fullText: string) => void;
60
+ onSessionCreated?: (session: AgentSession) => void;
61
+ onTurnEnd?: (turnCount: number) => void;
62
+ onAssistantUsage?: (usage: LifetimeUsage) => void;
63
+ onCompaction?: (info: CompactionInfo) => void;
64
+ }
81
65
 
82
66
  /**
83
- * Theme for terminal rendering — used by format.ts, renderer.ts, and UI widgets.
84
- * Defined here (not in ui/agent-widget.ts) so non-UI modules can import it
85
- * without depending on the UI layer.
67
+ * Coordinator-side spawn config shared by SpawnOptions and SpawnIntent.
68
+ * The resolved run params that both the manager and coordinator agree on;
69
+ * extends RunTunables with display/identity fields.
86
70
  */
87
- export type Theme = {
88
- fg(color: string, text: string): string;
89
- bg(color: string, text: string): string;
90
- bold(text: string): string;
91
- italic?: (text: string) => string;
92
- };
93
-
94
- /** Non-model keys in config.agent — preserved when clearing all overrides. */
95
- export const CONFIG_AGENT_NON_MODEL_KEYS = [
96
- "default",
97
- "forceBackground",
98
- "graceTurns",
99
- "showCost",
100
- "widgetMaxLines",
101
- "widgetMaxLinesCompact",
102
- "widgetCompact",
103
- "widgetShortcut",
104
- ];
71
+ export interface SpawnConfig extends RunTunables {
72
+ description: string;
73
+ modelKey?: string;
74
+ worktreePath?: string;
75
+ worktreeLabel?: string;
76
+ invocation?: AgentInvocation;
77
+ }
78
+
79
+ /** How many characters of agent ID to show in display. */
80
+ export const SHORT_ID_LENGTH = 8;
105
81
 
106
82
  /** Reason for a context compaction event. */
107
83
  export type CompactionReason = "manual" | "threshold" | "overflow";
@@ -117,7 +93,7 @@ export interface CompactionInfo {
117
93
  // ---------------------------------------------------------------------------
118
94
 
119
95
  /** Possible agent lifecycle statuses. */
120
- export type AgentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
96
+ export type AgentStatus = "queued" | "running" | "completed" | "turn_limited" | "aborted" | "stopped" | "error";
121
97
 
122
98
  /**
123
99
  * Lifecycle state: when the agent started, completed, and its current status.
@@ -158,8 +134,8 @@ export interface AgentExecutionState {
158
134
  promise?: Promise<string>;
159
135
  /** Steering messages queued before the session was ready. */
160
136
  pendingSteers?: string[];
161
- /** Cleanup function for the output file stream subscription. */
162
- outputCleanup?: () => void;
137
+ /** Lifecycle wrapper for the output file stream. */
138
+ outputLog?: AgentOutputLog;
163
139
  }
164
140
 
165
141
  /**
@@ -3,19 +3,19 @@
3
3
  */
4
4
 
5
5
  import { truncateToWidth } from "@earendil-works/pi-tui";
6
- import type { AgentManager } from "../agent-manager.js";
7
- import type { AgentRecord, Theme } from "../types.js";
6
+ import type { AgentManager } from "../agents/agent-manager.js";
7
+ import type { AgentRecord } from "../types.js";
8
+ import type { Theme } from "./types.js";
8
9
  import {
9
10
  formatCost,
10
11
  getLifetimeTotal,
11
12
  getSessionContextPercent,
12
- type LifetimeUsage,
13
- type SessionLike,
14
- } from "../usage.js";
15
- import { formatMs, buildStatsParts, getDisplayName } from "../format.js";
13
+ } from "../agents/usage.js";
14
+ import { formatMs, buildStatsParts, getDisplayName, truncateDesc, type StatsVisibility } from "./format.js";
15
+ import type { LiveView } from "../spawn/spawn-coordinator.js";
16
16
 
17
17
  // Re-export Theme so existing consumers (model-selector, result-viewer) don't break
18
- export type { Theme } from "../types.js";
18
+ export type { Theme } from "./types.js";
19
19
 
20
20
  // ---- Constants ----
21
21
 
@@ -26,7 +26,7 @@ const DEFAULT_MAX_WIDGET_LINES = 12;
26
26
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
27
27
 
28
28
  /** Non-success statuses — used for linger behavior and icon rendering. */
29
- const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
29
+ const ERROR_STATUSES = new Set(["error", "aborted", "turn_limited", "stopped"]);
30
30
 
31
31
  /** Tree-drawing connectors used in the widget header/continuation lines. */
32
32
  const BRANCH = "├─";
@@ -83,22 +83,9 @@ interface RenderBlock {
83
83
  continuations: string[];
84
84
  }
85
85
 
86
- /** Per-agent live activity state. */
87
- export interface AgentActivity {
88
- activeTools: Map<string, string>;
89
- toolUses: number;
90
- responseText: string;
91
- session?: SessionLike;
92
- /** Current turn count. */
93
- turnCount: number;
94
- /** Effective max turns for this agent (undefined = unlimited). */
95
- maxTurns?: number;
96
- /** Lifetime usage breakdown — see LifetimeUsage docs. */
97
- lifetimeUsage: LifetimeUsage;
98
- }
99
-
100
86
  // ---- Re-exports from format.ts (backward compatibility) ----
101
- export { formatMs, buildStatsParts, getDisplayName } from "../format.js";
87
+ export { formatMs, buildStatsParts, getDisplayName, type StatsVisibility } from "./format.js";
88
+ export type { LiveView as AgentActivity } from "../spawn/spawn-coordinator.js";
102
89
 
103
90
  // ---- Widget-internal helpers ----
104
91
 
@@ -161,6 +148,9 @@ export class AgentWidget {
161
148
  /** Whether to show cost in stats and status bar. */
162
149
  private showCost = false;
163
150
 
151
+ /** Stats visibility flags. Controls which stats appear in the stats line. */
152
+ private statsVisibility: StatsVisibility = {};
153
+
164
154
  /** Whether the widget callback is currently registered with the TUI. */
165
155
  private widgetRegistered = false;
166
156
  /** Cached TUI reference from widget factory callback, used for requestRender(). */
@@ -185,9 +175,15 @@ export class AgentWidget {
185
175
  /** Maximum lines for compact mode. */
186
176
  private maxLinesCompact = Math.floor(DEFAULT_MAX_WIDGET_LINES / 2);
187
177
 
178
+ /** Max description length in full mode. */
179
+ private descLengthFull = 50;
180
+
181
+ /** Max description length in compact mode. */
182
+ private descLengthCompact = 30;
183
+
188
184
  constructor(
189
185
  private manager: AgentManager,
190
- private agentActivity: Map<string, AgentActivity>,
186
+ private getLiveView: (id: string) => LiveView | undefined,
191
187
  ) {}
192
188
 
193
189
  /** Set whether to show cost in stats and status bar. */
@@ -195,6 +191,11 @@ export class AgentWidget {
195
191
  this.showCost = enabled;
196
192
  }
197
193
 
194
+ /** Set stats visibility flags. */
195
+ setStatsVisibility(visible: StatsVisibility) {
196
+ this.statsVisibility = visible;
197
+ }
198
+
198
199
  /** Set compact mode (internal, for sync from ctrl+o). */
199
200
  setCompactMode(enabled: boolean) {
200
201
  if (this.compactMode === enabled) return;
@@ -228,6 +229,16 @@ export class AgentWidget {
228
229
  this.maxLinesCompact = lines;
229
230
  }
230
231
 
232
+ /** Set max description length for full mode. */
233
+ setDescLengthFull(len: number) {
234
+ this.descLengthFull = len;
235
+ }
236
+
237
+ /** Set max description length for compact mode. */
238
+ setDescLengthCompact(len: number) {
239
+ this.descLengthCompact = len;
240
+ }
241
+
231
242
  /** Set the UI context (grabbed from first tool execution). */
232
243
  setUICtx(ctx: UICtx) {
233
244
  if (ctx !== this.uiCtx) {
@@ -297,7 +308,7 @@ export class AgentWidget {
297
308
  switch (status) {
298
309
  case "completed":
299
310
  return { icon: theme.fg("success", "✓"), statusText: "" };
300
- case "steered":
311
+ case "turn_limited":
301
312
  return { icon: theme.fg("warning", "✓"), statusText: theme.fg("warning", " (turn limit)") };
302
313
  case "stopped":
303
314
  return { icon: theme.fg("dim", "■"), statusText: theme.fg("dim", " stopped") };
@@ -314,42 +325,42 @@ export class AgentWidget {
314
325
  /** Render a finished agent line. */
315
326
  private renderFinishedLine(a: AgentRecord, theme: Theme): string {
316
327
  const name = getDisplayName(a.display.type);
317
- const duration = formatMs((a.lifecycle.completedAt ?? Date.now()) - a.lifecycle.startedAt);
328
+ const fullDesc = truncateDesc(a.display.description, this.descLengthFull);
318
329
  const { icon, statusText } = this.finishedIconAndStatus(a.lifecycle.status, a.error, theme);
319
330
 
320
- const activity = this.agentActivity.get(a.id);
321
- const usage = activity?.lifetimeUsage ?? a.stats.lifetimeUsage;
331
+ const durationMs = (a.lifecycle.completedAt ?? Date.now()) - a.lifecycle.startedAt;
322
332
  const statsParts = buildStatsParts({
323
333
  toolUses: a.stats.toolUses,
324
- turnCount: activity?.turnCount ?? a.stats.turnCount,
325
- maxTurns: activity?.maxTurns ?? a.stats.maxTurns,
326
- tokens: getLifetimeTotal(usage),
327
- contextPercent: activity?.session ? getSessionContextPercent(activity.session) : a.stats.contextPercent ?? null,
334
+ turnCount: a.stats.turnCount,
335
+ maxTurns: a.stats.maxTurns,
336
+ input: a.stats.lifetimeUsage.input,
337
+ output: a.stats.lifetimeUsage.output,
338
+ contextPercent: a.stats.contextPercent ?? null,
328
339
  compactions: a.stats.compactionCount,
329
- cost: this.showCost ? usage.cost : undefined,
330
- }, theme);
331
- statsParts.push(duration);
340
+ cost: a.stats.lifetimeUsage.cost,
341
+ durationMs,
342
+ }, theme, this.statsVisibility);
332
343
 
333
344
  const statsLine = statsParts.join("·");
334
- return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.display.description)} ${wrapInDim(theme, statsLine)}${statusText}`;
345
+ return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", fullDesc)} ${wrapInDim(theme, statsLine)}${statusText}`;
335
346
  }
336
347
 
337
348
  /** Build the stats line (toolUses · turns · tokens · cost · elapsed) for a running agent. */
338
349
  private buildStatsLine(
339
350
  agent: AgentRecord,
340
- activity: AgentActivity | undefined,
341
351
  theme: Theme,
342
352
  ): string {
343
353
  const parts = buildStatsParts({
344
- toolUses: activity?.toolUses ?? agent.stats.toolUses,
345
- turnCount: activity?.turnCount,
346
- maxTurns: activity?.maxTurns,
347
- tokens: getLifetimeTotal(activity?.lifetimeUsage),
348
- contextPercent: getSessionContextPercent(activity?.session),
354
+ toolUses: agent.stats.toolUses,
355
+ turnCount: agent.stats.turnCount,
356
+ maxTurns: agent.stats.maxTurns,
357
+ input: agent.stats.lifetimeUsage.input,
358
+ output: agent.stats.lifetimeUsage.output,
359
+ contextPercent: agent.execution.session ? getSessionContextPercent(agent.execution.session) : agent.stats.contextPercent ?? null,
349
360
  compactions: agent.stats.compactionCount,
350
- cost: this.showCost ? activity?.lifetimeUsage?.cost : undefined,
351
- }, theme);
352
- parts.push(formatMs(Date.now() - agent.lifecycle.startedAt));
361
+ cost: agent.stats.lifetimeUsage.cost,
362
+ durationMs: Date.now() - agent.lifecycle.startedAt,
363
+ }, theme, this.statsVisibility);
353
364
  return parts.join("·");
354
365
  }
355
366
 
@@ -390,13 +401,13 @@ export class AgentWidget {
390
401
  const blocks: RenderBlock[] = [];
391
402
  for (const a of running) {
392
403
  const name = getDisplayName(a.display.type);
393
- const bg = this.agentActivity.get(a.id);
394
- const statsLine = this.buildStatsLine(a, bg, theme);
404
+ const bg = this.getLiveView(a.id);
405
+ const statsLine = this.buildStatsLine(a, theme);
395
406
  const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : THINKING_TEXT;
396
407
 
397
408
  if (this.isCompact()) {
398
409
  // Compact: single line with activity inline, truncated description
399
- const desc = a.display.description.length > 30 ? a.display.description.slice(0, 27) + "..." : a.display.description;
410
+ const desc = truncateDesc(a.display.description, this.descLengthCompact);
400
411
  const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${desc} ${statsLine} ${theme.fg("dim", activity)}`;
401
412
  blocks.push({
402
413
  header: truncate(headerLine),
@@ -404,7 +415,8 @@ export class AgentWidget {
404
415
  });
405
416
  } else {
406
417
  // Full: header + continuation lines
407
- const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${a.display.description} ${statsLine}`;
418
+ const fullDesc = truncateDesc(a.display.description, this.descLengthFull);
419
+ const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${fullDesc} ${statsLine}`;
408
420
  const continuations: string[] = [];
409
421
  if (a.display.outputFile || a.display.worktreeLabel) {
410
422
  const parts: string[] = [];
@@ -8,9 +8,15 @@
8
8
  * Pure functions — no module-level state, no side effects.
9
9
  */
10
10
 
11
- import { getConfig } from "./agent-types.js";
12
- import type { SubagentType, Theme } from "./types.js";
13
- import { formatTokens, formatCost } from "./usage.js";
11
+ import { getConfig } from "../agents/agent-types.js";
12
+ import type { SubagentType } from "../agents/types.js";
13
+ import type { Theme } from "./types.js";
14
+ import { formatTokens, formatTokensCompact, formatCost } from "../agents/usage.js";
15
+
16
+ /** Truncate a description string to `maxLen` characters, appending "..." if truncated. */
17
+ export function truncateDesc(text: string, maxLen: number): string {
18
+ return text.length > maxLen ? text.slice(0, maxLen - 3) + "..." : text;
19
+ }
14
20
 
15
21
  /** Max length for a truncated command in tool arg summaries. */
16
22
  const MAX_COMMAND_DISPLAY_LENGTH = 100;
@@ -25,18 +31,22 @@ const MAX_DEFAULT_STRING_DISPLAY_LENGTH = 200;
25
31
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
26
32
  * Compaction count rendered as `↻ N` in dim.
27
33
  *
28
- * "12.3k" — no annotations
29
- * "12.3k(45%)" — percent only
30
- * "12.3k(↻ 2)" — compactions only (e.g. right after compact)
31
- * "12.3k(45%·↻ 2)" — both
34
+ * "↑12k↓8k" — no annotations
35
+ * "↑12k↓8k 45%" — percent only
36
+ * "↑12k↓8k ↻ 2" — compactions only (e.g. right after compact)
37
+ * "↑12k↓8k 45% 2" — both
32
38
  */
33
39
  function formatSessionTokens(
34
- tokens: number,
40
+ inputTokens: number,
41
+ outputTokens: number,
35
42
  percent: number | null,
36
43
  theme: Theme,
37
44
  compactions = 0,
38
45
  ): string {
39
- const tokenStr = formatTokens(tokens);
46
+ const tokenParts: string[] = [];
47
+ if (inputTokens > 0) tokenParts.push(`↑${formatTokensCompact(inputTokens)}`);
48
+ if (outputTokens > 0) tokenParts.push(`↓${formatTokensCompact(outputTokens)}`);
49
+ const tokenStr = tokenParts.join("");
40
50
  const annot: string[] = [];
41
51
  if (percent !== null) {
42
52
  const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
@@ -46,16 +56,17 @@ function formatSessionTokens(
46
56
  annot.push(theme.fg("dim", `↻ ${compactions}`));
47
57
  }
48
58
  if (annot.length === 0) return tokenStr;
49
- // Include closing paren in the last annotation's color span to prevent
50
- // ANSI reset from leaving `)` in default color when wrapped in outer dim.
51
- const lastIdx = annot.length - 1;
52
- annot[lastIdx] += ")";
53
- return `${tokenStr}(${annot.join("·")}`;
59
+ return `${tokenStr} ${annot.join(" ")}`;
54
60
  }
55
61
 
56
- /** Format turn count with optional max limit: "5≤30⟳" or "5⟳". */
57
- function formatTurns(turnCount: number, maxTurns?: number | null): string {
58
- return maxTurns != null ? `${turnCount}≤${maxTurns}` : `${turnCount}⟳ `;
62
+ /** Format turn count with optional max limit. Shows max when >= 80% of limit. */
63
+ function formatTurns(turnCount: number, maxTurns: number | null | undefined, theme: Theme): string {
64
+ if (maxTurns == null) return `${turnCount}⟳ `;
65
+ const ratio = turnCount / maxTurns;
66
+ const text = ratio >= 0.8 ? `${turnCount}≤${maxTurns}⟳ ` : `${turnCount}⟳ `;
67
+ if (ratio >= 1) return theme.fg("error", text);
68
+ if (ratio >= 0.8) return theme.fg("warning", text);
69
+ return text;
59
70
  }
60
71
 
61
72
  // ---- Exported formatting functions ----
@@ -77,31 +88,58 @@ export function formatMs(ms: number): string {
77
88
  return parts.join(" ");
78
89
  }
79
90
 
91
+ /** Visibility flags for stats parts. All default to true. */
92
+ export interface StatsVisibility {
93
+ showTools?: boolean;
94
+ showTurns?: boolean;
95
+ showInput?: boolean;
96
+ showOutput?: boolean;
97
+ showContext?: boolean;
98
+ showCost?: boolean;
99
+ showTime?: boolean;
100
+ }
101
+
80
102
  /**
81
- * Build common stats parts: toolUses · turns · tokens with context % · cost.
103
+ * Build common stats parts: toolUses · turns · input↓ output with context % · cost · time.
82
104
  * Shared by AgentWidget and index.ts for consistent stats display.
105
+ *
106
+ * @param visible - Optional visibility flags. All default to true for backward compatibility.
107
+ * @param durationMs - Optional duration in ms. When provided and showTime is not false, appends formatted time.
83
108
  */
84
109
  export function buildStatsParts(
85
110
  args: {
86
111
  toolUses: number;
87
112
  turnCount?: number;
88
113
  maxTurns?: number;
89
- tokens: number;
114
+ input: number;
115
+ output: number;
90
116
  contextPercent: number | null;
91
117
  compactions: number;
92
118
  cost?: number;
119
+ durationMs?: number;
93
120
  },
94
121
  theme: Theme,
122
+ visible?: StatsVisibility,
95
123
  ): string[] {
96
124
  const parts: string[] = [];
97
- if (args.toolUses > 0) parts.push(`${args.toolUses}🛠 `);
98
- if (args.turnCount != null) parts.push(formatTurns(args.turnCount, args.maxTurns));
99
- if (args.tokens > 0) {
100
- parts.push(formatSessionTokens(
101
- args.tokens, args.contextPercent, theme, args.compactions,
102
- ));
125
+ if (visible?.showTools !== false && args.toolUses > 0) parts.push(`${args.toolUses}🛠 `);
126
+ if (visible?.showTurns !== false && args.turnCount != null) parts.push(formatTurns(args.turnCount, args.maxTurns, theme));
127
+ if (visible?.showInput !== false || visible?.showOutput !== false) {
128
+ const showIn = visible?.showInput !== false;
129
+ const showOut = visible?.showOutput !== false;
130
+ const inputTokens = showIn ? args.input : 0;
131
+ const outputTokens = showOut ? args.output : 0;
132
+ if (inputTokens > 0 || outputTokens > 0) {
133
+ parts.push(formatSessionTokens(
134
+ inputTokens, outputTokens,
135
+ visible?.showContext !== false ? args.contextPercent : null,
136
+ theme,
137
+ visible?.showContext !== false ? args.compactions : 0,
138
+ ));
139
+ }
103
140
  }
104
- if (args.cost != null && args.cost > 0) parts.push(formatCost(args.cost));
141
+ if (visible?.showCost !== false && args.cost != null && args.cost > 0) parts.push(formatCost(args.cost));
142
+ if (visible?.showTime !== false && args.durationMs != null) parts.push(formatMs(args.durationMs));
105
143
  return parts;
106
144
  }
107
145
 
@@ -0,0 +1,93 @@
1
+ /**
2
+ * helpers.ts — Shared helpers for menu modules:
3
+ * theme builders for SettingsList/SelectList, numeric validation,
4
+ * model-option building, and a swappable delegating component.
5
+ */
6
+
7
+ import type { Component, SettingsListTheme } from "@earendil-works/pi-tui";
8
+ import type { ModelOption } from "../../models/model-selector.js";
9
+ import { parseModelKey } from "../../utils.js";
10
+
11
+ /**
12
+ * Build ModelOption[] from raw "provider/model-id" strings.
13
+ * Includes "(inherits parent)" as the first option.
14
+ */
15
+ export function buildModelOptions(rawOptions: string[]): ModelOption[] {
16
+ const items: ModelOption[] = [
17
+ { value: "(inherits parent)", label: "(inherits parent)", provider: "" },
18
+ ];
19
+
20
+ for (const opt of rawOptions) {
21
+ const parsed = parseModelKey(opt);
22
+ if (!parsed) continue;
23
+ items.push({ value: opt, label: parsed.modelId, provider: parsed.provider });
24
+ }
25
+ return items;
26
+ }
27
+
28
+ /**
29
+ * Build a SettingsListTheme from a pi-coding-agent Theme.
30
+ * Shared by widget settings and future SettingsList-based menus.
31
+ */
32
+ export function buildSettingsListTheme(theme: { fg(color: string, text: string): string; bold(text: string): string }): SettingsListTheme {
33
+ return {
34
+ label: (text, selected) => selected ? theme.fg("accent", text) : text,
35
+ value: (text, selected) => selected ? theme.fg("accent", text) : theme.fg("muted", text),
36
+ description: (text) => theme.fg("muted", text),
37
+ // Use "→ " (2 chars) to match non-selected prefix " " (2 spaces)
38
+ // This prevents menu items from shifting left/right when cursor moves
39
+ cursor: theme.fg("accent", "→ "),
40
+ hint: (text) => theme.fg("dim", text),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Pure numeric validation. Returns parsed integer ≥ min, or undefined.
46
+ * Extracted from parseNumericInput for use in submenu Components.
47
+ */
48
+ export function validateNumeric(value: string, min: number): number | undefined {
49
+ const parsed = parseInt(value.trim(), 10);
50
+ if (isNaN(parsed) || parsed < min) return undefined;
51
+ return parsed;
52
+ }
53
+
54
+ /**
55
+ * Create a Component that delegates to a swappable inner component.
56
+ * Use in submenus that switch between SelectList → Input (or similar).
57
+ */
58
+ export function createDelegatingComponent(initial: Component): Component & { setActive(c: Component): void; focused?: boolean; items?: any; onSelect?: any; onCancel?: any } {
59
+ let active = initial;
60
+ return {
61
+ invalidate() { active.invalidate?.(); },
62
+ render(width: number) { return active.render(width); },
63
+ handleInput(data: string) { active.handleInput?.(data); },
64
+ setActive(c: Component) { active = c; },
65
+ // Propagate focused to the active child so isFocusable() returns true,
66
+ // which tells SettingsListWrapper to passthrough keys instead of converting them.
67
+ get focused() { return (active as any)?.focused ?? false; },
68
+ set focused(value: boolean) { if ((active as any)?.focused != null) (active as any).focused = value; },
69
+ // Proxy SelectList properties so SettingsListWrapper can add "Back" button.
70
+ get items() { return (active as any)?.items; },
71
+ set items(v: any) { (active as any).items = v; },
72
+ get onSelect() { return (active as any)?.onSelect; },
73
+ set onSelect(v: any) { (active as any).onSelect = v; },
74
+ get onCancel() { return (active as any)?.onCancel; },
75
+ set onCancel(v: any) { (active as any).onCancel = v; },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Build a SelectListTheme from a pi-coding-agent Theme.
81
+ * Produces identical visual style to buildSettingsListTheme:
82
+ * → cursor, accent colors, muted descriptions.
83
+ */
84
+ export function buildSelectListTheme(theme: { fg(color: string, text: string): string; bold(text: string): string }): import("@earendil-works/pi-tui").SelectListTheme {
85
+ return {
86
+ selectedPrefix: () => theme.fg("accent", "→ "),
87
+ selectedText: (text) => theme.fg("accent", text),
88
+ description: (text) => theme.fg("muted", text),
89
+ scrollInfo: (text) => theme.fg("dim", text),
90
+ noMatch: (text) => theme.fg("dim", text),
91
+ };
92
+ }
93
+