@ilya-lesikov/pi-pi 0.8.0 → 0.10.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 (114) hide show
  1. package/3p/pi-ask-user/index.ts +71 -124
  2. package/3p/pi-ask-user/single-select-layout.ts +3 -14
  3. package/3p/pi-subagents/package.json +13 -7
  4. package/3p/pi-subagents/src/agent-manager.ts +243 -79
  5. package/3p/pi-subagents/src/agent-runner.ts +501 -365
  6. package/3p/pi-subagents/src/agent-types.ts +46 -57
  7. package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
  8. package/3p/pi-subagents/src/custom-agents.ts +30 -6
  9. package/3p/pi-subagents/src/default-agents.ts +25 -43
  10. package/3p/pi-subagents/src/enabled-models.ts +180 -0
  11. package/3p/pi-subagents/src/index.ts +599 -839
  12. package/3p/pi-subagents/src/model-resolver.ts +26 -7
  13. package/3p/pi-subagents/src/output-file.ts +21 -2
  14. package/3p/pi-subagents/src/prompts.ts +17 -3
  15. package/3p/pi-subagents/src/schedule-store.ts +153 -0
  16. package/3p/pi-subagents/src/schedule.ts +365 -0
  17. package/3p/pi-subagents/src/settings.ts +261 -0
  18. package/3p/pi-subagents/src/skill-loader.ts +77 -54
  19. package/3p/pi-subagents/src/status-note.ts +25 -0
  20. package/3p/pi-subagents/src/types.ts +97 -6
  21. package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
  22. package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
  23. package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
  24. package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
  25. package/3p/pi-subagents/src/usage.ts +60 -0
  26. package/3p/pi-subagents/src/worktree.ts +49 -20
  27. package/extensions/orchestrator/agents/advisor.ts +13 -8
  28. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
  29. package/extensions/orchestrator/agents/code-reviewer.ts +29 -8
  30. package/extensions/orchestrator/agents/constraints.test.ts +65 -1
  31. package/extensions/orchestrator/agents/constraints.ts +73 -2
  32. package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
  33. package/extensions/orchestrator/agents/explore.ts +12 -6
  34. package/extensions/orchestrator/agents/librarian.ts +13 -5
  35. package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
  36. package/extensions/orchestrator/agents/planner.ts +8 -3
  37. package/extensions/orchestrator/agents/pools.test.ts +56 -0
  38. package/extensions/orchestrator/agents/prompts.test.ts +52 -10
  39. package/extensions/orchestrator/agents/registry.test.ts +245 -0
  40. package/extensions/orchestrator/agents/registry.ts +145 -10
  41. package/extensions/orchestrator/agents/reviewer.ts +14 -8
  42. package/extensions/orchestrator/agents/task.ts +12 -6
  43. package/extensions/orchestrator/agents/tool-routing.ts +213 -69
  44. package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
  45. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  46. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  47. package/extensions/orchestrator/ast-search.test.ts +124 -0
  48. package/extensions/orchestrator/billing-spoof.test.ts +67 -0
  49. package/extensions/orchestrator/billing-spoof.ts +95 -0
  50. package/extensions/orchestrator/cbm.more.test.ts +358 -0
  51. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  52. package/extensions/orchestrator/command-handlers.ts +49 -27
  53. package/extensions/orchestrator/commands.test.ts +15 -2
  54. package/extensions/orchestrator/commands.ts +1 -1
  55. package/extensions/orchestrator/config.test.ts +129 -2
  56. package/extensions/orchestrator/config.ts +115 -19
  57. package/extensions/orchestrator/context.test.ts +46 -0
  58. package/extensions/orchestrator/context.ts +34 -5
  59. package/extensions/orchestrator/custom-footer.test.ts +105 -0
  60. package/extensions/orchestrator/custom-footer.ts +22 -20
  61. package/extensions/orchestrator/doctor.more.test.ts +315 -0
  62. package/extensions/orchestrator/doctor.ts +6 -2
  63. package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
  64. package/extensions/orchestrator/event-handlers.test.ts +411 -10
  65. package/extensions/orchestrator/event-handlers.ts +738 -349
  66. package/extensions/orchestrator/exa.more.test.ts +118 -0
  67. package/extensions/orchestrator/flant-infra.more.test.ts +326 -0
  68. package/extensions/orchestrator/flant-infra.test.ts +127 -0
  69. package/extensions/orchestrator/flant-infra.ts +113 -39
  70. package/extensions/orchestrator/index.test.ts +76 -0
  71. package/extensions/orchestrator/index.ts +2 -0
  72. package/extensions/orchestrator/integration.test.ts +488 -74
  73. package/extensions/orchestrator/model-registry.test.ts +2 -1
  74. package/extensions/orchestrator/model-registry.ts +12 -2
  75. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  76. package/extensions/orchestrator/orchestrator.test.ts +67 -0
  77. package/extensions/orchestrator/orchestrator.ts +165 -85
  78. package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
  79. package/extensions/orchestrator/phases/brainstorm.ts +53 -12
  80. package/extensions/orchestrator/phases/implementation.ts +2 -0
  81. package/extensions/orchestrator/phases/machine.test.ts +53 -1
  82. package/extensions/orchestrator/phases/machine.ts +15 -2
  83. package/extensions/orchestrator/phases/planning.test.ts +47 -1
  84. package/extensions/orchestrator/phases/planning.ts +18 -3
  85. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  86. package/extensions/orchestrator/phases/review-task.ts +47 -14
  87. package/extensions/orchestrator/phases/review.test.ts +36 -0
  88. package/extensions/orchestrator/phases/review.ts +62 -10
  89. package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
  90. package/extensions/orchestrator/phases/verdict.ts +6 -5
  91. package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
  92. package/extensions/orchestrator/plannotator.test.ts +38 -1
  93. package/extensions/orchestrator/plannotator.ts +50 -3
  94. package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
  95. package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
  96. package/extensions/orchestrator/pp-menu.test.ts +314 -1
  97. package/extensions/orchestrator/pp-menu.ts +937 -450
  98. package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
  99. package/extensions/orchestrator/pp-state-tools.ts +65 -0
  100. package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
  101. package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
  102. package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
  103. package/extensions/orchestrator/review-files.test.ts +26 -0
  104. package/extensions/orchestrator/review-files.ts +3 -0
  105. package/extensions/orchestrator/state.test.ts +82 -1
  106. package/extensions/orchestrator/state.ts +110 -23
  107. package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
  108. package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
  109. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  110. package/extensions/orchestrator/transition-controller.ts +61 -3
  111. package/extensions/orchestrator/validate-artifacts.ts +1 -1
  112. package/package.json +6 -2
  113. package/scripts/test-3p.sh +52 -0
  114. package/AGENTS.md +0 -28
@@ -8,7 +8,8 @@
8
8
  import { truncateToWidth } from "@earendil-works/pi-tui";
9
9
  import type { AgentManager } from "../agent-manager.js";
10
10
  import { getConfig } from "../agent-types.js";
11
- import type { SubagentType } from "../types.js";
11
+ import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
12
+ import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
12
13
 
13
14
  // ---- Constants ----
14
15
 
@@ -20,7 +21,6 @@ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧",
20
21
 
21
22
  /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
22
23
  export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
23
- const SUCCESS_LINGER_TURNS = 2;
24
24
 
25
25
  /** Tool name → human-readable action for activity descriptions. */
26
26
  const TOOL_DISPLAY: Record<string, string> = {
@@ -40,6 +40,16 @@ export type Theme = {
40
40
  bold(text: string): string;
41
41
  };
42
42
 
43
+ // Apply theme.fg() while re-asserting the outer color after any nested ANSI
44
+ // reset (\e[0m / \e[39m) inside `text`. Without this, a nested reset in the
45
+ // styled substring clears the foreground for the remainder of the line, so the
46
+ // color bleeds/blanks across subsequent output (upstream fix #136).
47
+ export function fgPreservingNestedStyles(theme: Theme, color: string, text: string): string {
48
+ const styledEmpty = theme.fg(color, "");
49
+ const styleStart = styledEmpty.replace(/\u001b\[(?:0|39)m/g, "");
50
+ return theme.fg(color, text.replace(/\u001b\[(?:0|39)m/g, (reset) => `${reset}${styleStart}`));
51
+ }
52
+
43
53
  export type UICtx = {
44
54
  setStatus(key: string, text: string | undefined): void;
45
55
  setWidget(
@@ -53,13 +63,14 @@ export type UICtx = {
53
63
  export interface AgentActivity {
54
64
  activeTools: Map<string, string>;
55
65
  toolUses: number;
56
- tokens: string;
57
66
  responseText: string;
58
- session?: { getSessionStats(): { tokens: { total: number } } };
67
+ session?: SessionLike;
59
68
  /** Current turn count. */
60
69
  turnCount: number;
61
70
  /** Effective max turns for this agent (undefined = unlimited). */
62
71
  maxTurns?: number;
72
+ /** Lifetime usage breakdown — see LifetimeUsage docs. */
73
+ lifetimeUsage: LifetimeUsage;
63
74
  }
64
75
 
65
76
  /** Metadata attached to Agent tool results for custom rendering. */
@@ -96,9 +107,38 @@ export function formatTokens(count: number): string {
96
107
  return `${count} token`;
97
108
  }
98
109
 
99
- /** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */
110
+ /**
111
+ * Token count with optional context-fill % and compaction-count annotations.
112
+ * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
113
+ * Compaction count rendered as `⇊N` in dim.
114
+ *
115
+ * "12.3k token" — no annotations
116
+ * "12.3k token (45%)" — percent only
117
+ * "12.3k token (⇊2)" — compactions only (e.g. right after compact)
118
+ * "12.3k token (45% · ⇊2)" — both
119
+ */
120
+ export function formatSessionTokens(
121
+ tokens: number,
122
+ percent: number | null,
123
+ theme: Theme,
124
+ compactions = 0,
125
+ ): string {
126
+ const tokenStr = formatTokens(tokens);
127
+ const annot: string[] = [];
128
+ if (percent !== null) {
129
+ const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
130
+ annot.push(theme.fg(color, `${Math.round(percent)}%`));
131
+ }
132
+ if (compactions > 0) {
133
+ annot.push(theme.fg("dim", `⇊${compactions}`));
134
+ }
135
+ if (annot.length === 0) return tokenStr;
136
+ return `${tokenStr} (${annot.join(" · ")})`;
137
+ }
138
+
139
+ /** Format turn count with optional max limit: "↻5≤30" or "↻5". */
100
140
  export function formatTurns(turnCount: number, maxTurns?: number | null): string {
101
- return maxTurns != null ? `⟳${turnCount}≤${maxTurns}` : `⟳${turnCount}`;
141
+ return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`;
102
142
  }
103
143
 
104
144
  /** Format milliseconds as human-readable duration. */
@@ -123,6 +163,21 @@ export function getPromptModeLabel(type: SubagentType): string | undefined {
123
163
  return config.promptMode === "append" ? "twin" : undefined;
124
164
  }
125
165
 
166
+ /** Mode label is not included — callers add it where they want it. */
167
+ export function buildInvocationTags(
168
+ invocation: AgentInvocation | undefined,
169
+ ): { modelName?: string; tags: string[] } {
170
+ const tags: string[] = [];
171
+ if (!invocation) return { tags };
172
+ if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
173
+ if (invocation.isolated) tags.push("isolated");
174
+ if (invocation.isolation === "worktree") tags.push("worktree");
175
+ if (invocation.inheritContext) tags.push("inherit context");
176
+ if (invocation.runInBackground) tags.push("background");
177
+ if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
178
+ return { modelName: invocation.modelName, tags };
179
+ }
180
+
126
181
  /** Truncate text to a single line, max `len` chars. */
127
182
  function truncateLine(text: string, len = 60): string {
128
183
  const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
@@ -179,8 +234,34 @@ export class AgentWidget {
179
234
  constructor(
180
235
  private manager: AgentManager,
181
236
  private agentActivity: Map<string, AgentActivity>,
237
+ /**
238
+ * Read live at render time. Selects which agents the widget shows — see
239
+ * `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
240
+ * extension supplies one defaulting to `"background"`.
241
+ */
242
+ private mode: () => WidgetMode = () => "all",
182
243
  ) {}
183
244
 
245
+ /**
246
+ * Agents eligible for the widget, per the current `WidgetMode`:
247
+ * - `off`: none (the widget's existing empty-state path hides it entirely).
248
+ * - `background`: drop only agents *known* to be foreground
249
+ * (`isBackground === false`); keep everything else — background, queued,
250
+ * scheduled, or RPC-spawned (`undefined`). Keying off the `isBackground`
251
+ * record flag rather than the UI-only `invocation` snapshot (which only the
252
+ * Agent-tool path sets), and excluding rather than allow-listing, means
253
+ * only proven-foreground runs drop out — nothing else silently vanishes.
254
+ * - `all`: every agent.
255
+ */
256
+ private widgetAgents() {
257
+ const all = this.manager.listAgents();
258
+ switch (this.mode()) {
259
+ case "off": return [];
260
+ case "background": return all.filter(a => a.isBackground !== false);
261
+ default: return all;
262
+ }
263
+ }
264
+
184
265
  /** Set the UI context (grabbed from first tool execution). */
185
266
  setUICtx(ctx: UICtx) {
186
267
  if (ctx !== this.uiCtx) {
@@ -216,7 +297,7 @@ export class AgentWidget {
216
297
  /** Check if a finished agent should still be shown in the widget. */
217
298
  private shouldShowFinished(agentId: string, status: string): boolean {
218
299
  const age = this.finishedTurnAge.get(agentId) ?? 0;
219
- const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : SUCCESS_LINGER_TURNS;
300
+ const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
220
301
  return age < maxAge;
221
302
  }
222
303
 
@@ -269,7 +350,7 @@ export class AgentWidget {
269
350
  * reading live state each time instead of capturing it in a closure.
270
351
  */
271
352
  private renderWidget(tui: any, theme: Theme): string[] {
272
- const allAgents = this.manager.listAgents();
353
+ const allAgents = this.widgetAgents();
273
354
  const running = allAgents.filter(a => a.status === "running");
274
355
  const queued = allAgents.filter(a => a.status === "queued");
275
356
  const finished = allAgents.filter(a =>
@@ -306,10 +387,9 @@ export class AgentWidget {
306
387
 
307
388
  const bg = this.agentActivity.get(a.id);
308
389
  const toolUses = bg?.toolUses ?? a.toolUses;
309
- let tokenText = "";
310
- if (bg?.session) {
311
- try { tokenText = formatTokens(bg.session.getSessionStats().tokens.total); } catch { /* */ }
312
- }
390
+ const tokens = getLifetimeTotal(bg?.lifetimeUsage);
391
+ const contextPercent = getSessionContextPercent(bg?.session);
392
+ const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
313
393
 
314
394
  const parts: string[] = [];
315
395
  if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
@@ -321,7 +401,7 @@ export class AgentWidget {
321
401
  const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
322
402
 
323
403
  runningLines.push([
324
- truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`),
404
+ truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
325
405
  truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
326
406
  ]);
327
407
  }
@@ -404,7 +484,7 @@ export class AgentWidget {
404
484
  /** Force an immediate widget update. */
405
485
  update() {
406
486
  if (!this.uiCtx) return;
407
- const allAgents = this.manager.listAgents();
487
+ const allAgents = this.widgetAgents();
408
488
 
409
489
  // Lightweight existence checks — full categorization happens in renderWidget()
410
490
  let runningCount = 0;
@@ -450,13 +530,6 @@ export class AgentWidget {
450
530
  this.lastStatusText = newStatusText;
451
531
  }
452
532
 
453
- if (hasActive) {
454
- this.ensureTimer();
455
- } else if (this.widgetInterval) {
456
- clearInterval(this.widgetInterval);
457
- this.widgetInterval = undefined;
458
- }
459
-
460
533
  this.widgetFrame++;
461
534
 
462
535
  // Register widget callback once; subsequent updates use requestRender()
@@ -6,15 +6,19 @@
6
6
  */
7
7
 
8
8
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
9
- import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
+ import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
10
  import { extractText } from "../context.js";
11
11
  import type { AgentRecord } from "../types.js";
12
+ import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
12
13
  import type { Theme } from "./agent-widget.js";
13
- import { type AgentActivity, describeActivity, formatDuration, formatTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
14
+ import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
+ import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
14
16
 
15
- /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
16
- const CHROME_LINES = 6;
17
+ /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
18
+ const CHROME_LINES_BASE = 6;
17
19
  const MIN_VIEWPORT = 3;
20
+ /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
21
+ export const VIEWPORT_HEIGHT_PCT = 70;
18
22
 
19
23
  export class ConversationViewer implements Component {
20
24
  private scrollOffset = 0;
@@ -22,6 +26,11 @@ export class ConversationViewer implements Component {
22
26
  private unsubscribe: (() => void) | undefined;
23
27
  private lastInnerW = 0;
24
28
  private closed = false;
29
+ /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
30
+ private stopArmed = false;
31
+ private keys: ViewerKeys;
32
+ /** Steering composer — present while the user is typing a message to the agent. */
33
+ private composer: Input | undefined;
25
34
 
26
35
  constructor(
27
36
  private tui: TUI,
@@ -30,7 +39,14 @@ export class ConversationViewer implements Component {
30
39
  private activity: AgentActivity | undefined,
31
40
  private theme: Theme,
32
41
  private done: (result: undefined) => void,
42
+ /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
43
+ private onStop?: () => void,
44
+ /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
45
+ keybindings?: ViewerKeybindings,
46
+ /** Send a steering message to the agent. Omitted → no compose affordance. */
47
+ private onSteer?: (message: string) => void,
33
48
  ) {
49
+ this.keys = createViewerKeys(keybindings);
34
50
  this.unsubscribe = session.subscribe(() => {
35
51
  if (this.closed) return;
36
52
  this.tui.requestRender();
@@ -38,26 +54,59 @@ export class ConversationViewer implements Component {
38
54
  }
39
55
 
40
56
  handleInput(data: string): void {
57
+ // While composing a steer message, the input owns all keys (Enter sends,
58
+ // Esc cancels — both wired in openComposer()). Editing keys flow through.
59
+ if (this.composer) {
60
+ this.composer.handleInput(data);
61
+ this.tui.requestRender();
62
+ return;
63
+ }
64
+
41
65
  if (matchesKey(data, "escape") || matchesKey(data, "q")) {
42
66
  this.closed = true;
43
67
  this.done(undefined);
44
68
  return;
45
69
  }
46
70
 
71
+ // Enter opens the steering composer (only while the agent can still be
72
+ // steered) — then type + Enter sends, Esc or an empty submit returns. When
73
+ // not steerable, fall through so the key still disarms a pending stop.
74
+ if (matchesKey(data, "enter") && this.canSteer()) {
75
+ this.stopArmed = false;
76
+ this.openComposer();
77
+ return;
78
+ }
79
+
80
+ // Stop/abort the agent (only while it can still be stopped). Two-press:
81
+ // first "x" arms, second confirms — any other key disarms.
82
+ if (matchesKey(data, "x")) {
83
+ if (this.isStoppable()) {
84
+ if (this.stopArmed) {
85
+ this.stopArmed = false;
86
+ this.onStop?.();
87
+ } else {
88
+ this.stopArmed = true;
89
+ }
90
+ this.tui.requestRender();
91
+ }
92
+ return;
93
+ }
94
+ if (this.stopArmed) this.stopArmed = false;
95
+
47
96
  const totalLines = this.buildContentLines(this.lastInnerW).length;
48
97
  const viewportHeight = this.viewportHeight();
49
98
  const maxScroll = Math.max(0, totalLines - viewportHeight);
50
99
 
51
- if (matchesKey(data, "up") || matchesKey(data, "k")) {
100
+ if (this.keys.scrollUp(data)) {
52
101
  this.scrollOffset = Math.max(0, this.scrollOffset - 1);
53
102
  this.autoScroll = this.scrollOffset >= maxScroll;
54
- } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
103
+ } else if (this.keys.scrollDown(data)) {
55
104
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
56
105
  this.autoScroll = this.scrollOffset >= maxScroll;
57
- } else if (matchesKey(data, "pageUp")) {
106
+ } else if (this.keys.pageUp(data)) {
58
107
  this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
59
108
  this.autoScroll = false;
60
- } else if (matchesKey(data, "pageDown")) {
109
+ } else if (this.keys.pageDown(data)) {
61
110
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
62
111
  this.autoScroll = this.scrollOffset >= maxScroll;
63
112
  } else if (matchesKey(data, "home")) {
@@ -103,16 +152,17 @@ export class ConversationViewer implements Component {
103
152
  const headerParts: string[] = [duration];
104
153
  const toolUses = this.activity?.toolUses ?? this.record.toolUses;
105
154
  if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
106
- if (this.activity?.session) {
107
- try {
108
- const tokens = this.activity.session.getSessionStats().tokens.total;
109
- if (tokens > 0) headerParts.push(formatTokens(tokens));
110
- } catch { /* */ }
155
+ const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
156
+ if (tokens > 0) {
157
+ const percent = getSessionContextPercent(this.activity?.session);
158
+ headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
111
159
  }
112
160
 
113
161
  lines.push(row(
114
- `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
162
+ `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`,
115
163
  ));
164
+ const invocationLine = this.invocationLine();
165
+ if (invocationLine) lines.push(row(invocationLine));
116
166
  lines.push(hrMid);
117
167
 
118
168
  // Content area — rebuild every render (live data, no cache needed)
@@ -133,18 +183,72 @@ export class ConversationViewer implements Component {
133
183
 
134
184
  // Footer
135
185
  lines.push(hrMid);
136
- const scrollPct = contentLines.length <= viewportHeight
137
- ? "100%"
138
- : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
139
- const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
140
- const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
141
- const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
142
- lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
186
+ if (this.composer) {
187
+ // Composer row: the Input renders its own `> ` prompt and cursor.
188
+ lines.push(row(this.composer.render(innerW)[0] ?? ""));
189
+ const composeHint = th.fg("dim", "Enter send · Esc cancel");
190
+ const composeLeft = th.fg("accent", " steer");
191
+ const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
192
+ lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint));
193
+ } else {
194
+ // Actions on the left, navigation on the right. The scroll hint keeps its
195
+ // full key list so the less-obvious bindings stay discoverable; it leads
196
+ // the right group so "Esc close" is the only part that truncates first.
197
+ const sep = th.fg("dim", " · ");
198
+ const actions: string[] = [];
199
+ if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
200
+ if (this.isStoppable()) {
201
+ actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
202
+ }
203
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
204
+
205
+ // Prepend the line-count/scroll-% readout only when there's spare width —
206
+ // it's the first thing dropped so it never crowds out the hints.
207
+ const scrollPct = contentLines.length <= viewportHeight
208
+ ? "100%"
209
+ : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
210
+ const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
211
+ const withCount = [count, ...actions].join(sep);
212
+ const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
213
+ ? withCount
214
+ : actions.join(sep);
215
+
216
+ const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
217
+ lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
218
+ }
143
219
  lines.push(hrBot);
144
220
 
145
221
  return lines;
146
222
  }
147
223
 
224
+ /** Stoppable only when a stop handler exists and the agent is still active. */
225
+ private isStoppable(): boolean {
226
+ return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
227
+ }
228
+
229
+ /** Steerable only when a steer handler exists and the agent is still active. */
230
+ private canSteer(): boolean {
231
+ return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued");
232
+ }
233
+
234
+ /** Open the inline steering composer and route subsequent input to it. */
235
+ private openComposer(): void {
236
+ const input = new Input();
237
+ input.focused = true;
238
+ input.onSubmit = (value: string) => {
239
+ const message = value.trim();
240
+ this.composer = undefined;
241
+ if (message) this.onSteer?.(message);
242
+ this.tui.requestRender();
243
+ };
244
+ input.onEscape = () => {
245
+ this.composer = undefined;
246
+ this.tui.requestRender();
247
+ };
248
+ this.composer = input;
249
+ this.tui.requestRender();
250
+ }
251
+
148
252
  invalidate(): void { /* no cached state to clear */ }
149
253
 
150
254
  dispose(): void {
@@ -158,7 +262,22 @@ export class ConversationViewer implements Component {
158
262
  // ---- Private ----
159
263
 
160
264
  private viewportHeight(): number {
161
- return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
265
+ // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
266
+ // more lines than the overlay shows and clip the footer.
267
+ const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
268
+ return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
269
+ }
270
+
271
+ private chromeLines(): number {
272
+ // The composer adds one row above the footer hint while it's open.
273
+ return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0);
274
+ }
275
+
276
+ private invocationLine(): string | undefined {
277
+ const { modelName, tags } = buildInvocationTags(this.record.invocation);
278
+ const parts = modelName ? [modelName, ...tags] : tags;
279
+ if (parts.length === 0) return undefined;
280
+ return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
162
281
  }
163
282
 
164
283
  private buildContentLines(width: number): string[] {
@@ -167,7 +286,6 @@ export class ConversationViewer implements Component {
167
286
  const th = this.theme;
168
287
  const messages = this.session.messages;
169
288
  const lines: string[] = [];
170
- const detab = (s: string) => s.replace(/\t/g, " ");
171
289
 
172
290
  if (messages.length === 0) {
173
291
  lines.push(th.fg("dim", "(waiting for first message...)"));
@@ -183,7 +301,7 @@ export class ConversationViewer implements Component {
183
301
  if (!text.trim()) continue;
184
302
  if (needsSeparator) lines.push(th.fg("dim", "───"));
185
303
  lines.push(th.fg("accent", "[User]"));
186
- for (const line of wrapTextWithAnsi(detab(text.trim()), width)) {
304
+ for (const line of wrapTextWithAnsi(text.trim(), width)) {
187
305
  lines.push(line);
188
306
  }
189
307
  } else if (msg.role === "assistant") {
@@ -198,7 +316,7 @@ export class ConversationViewer implements Component {
198
316
  if (needsSeparator) lines.push(th.fg("dim", "───"));
199
317
  lines.push(th.bold("[Assistant]"));
200
318
  if (textParts.length > 0) {
201
- for (const line of wrapTextWithAnsi(detab(textParts.join("\n").trim()), width)) {
319
+ for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
202
320
  lines.push(line);
203
321
  }
204
322
  }
@@ -211,18 +329,18 @@ export class ConversationViewer implements Component {
211
329
  if (!truncated.trim()) continue;
212
330
  if (needsSeparator) lines.push(th.fg("dim", "───"));
213
331
  lines.push(th.fg("dim", "[Result]"));
214
- for (const line of wrapTextWithAnsi(detab(truncated.trim()), width)) {
332
+ for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
215
333
  lines.push(th.fg("dim", line));
216
334
  }
217
335
  } else if ((msg as any).role === "bashExecution") {
218
336
  const bash = msg as any;
219
337
  if (needsSeparator) lines.push(th.fg("dim", "───"));
220
- lines.push(truncateToWidth(th.fg("muted", ` $ ${detab(bash.command)}`), width));
338
+ lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
221
339
  if (bash.output?.trim()) {
222
340
  const out = bash.output.length > 500
223
341
  ? bash.output.slice(0, 500) + "... (truncated)"
224
342
  : bash.output;
225
- for (const line of wrapTextWithAnsi(detab(out.trim()), width)) {
343
+ for (const line of wrapTextWithAnsi(out.trim(), width)) {
226
344
  lines.push(th.fg("dim", line));
227
345
  }
228
346
  }
@@ -239,12 +357,6 @@ export class ConversationViewer implements Component {
239
357
  lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
240
358
  }
241
359
 
242
- return lines.map(l => {
243
- let truncated = truncateToWidth(l, width);
244
- while (visibleWidth(truncated) > width && truncated.length > 0) {
245
- truncated = truncated.slice(0, -1);
246
- }
247
- return truncated;
248
- });
360
+ return lines.map(l => truncateToWidth(l, width));
249
361
  }
250
362
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * schedule-menu.ts — `/agents → Scheduled jobs` submenu.
3
+ *
4
+ * Minimal v1 surface: list scheduled jobs, select one to inspect details +
5
+ * confirm cancellation. No create wizard (the `Agent` tool's `schedule` param
6
+ * is the canonical creation path), no toggle/cleanup (cancel is enough for
7
+ * "I scheduled something dumb, get rid of it"). Add management surfaces here
8
+ * if real demand emerges.
9
+ */
10
+
11
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import type { SubagentScheduler } from "../schedule.js";
13
+ import type { ScheduledSubagent } from "../types.js";
14
+
15
+ /** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
16
+ function relTime(iso: string | undefined, now = Date.now()): string {
17
+ if (!iso) return "—";
18
+ const t = new Date(iso).getTime();
19
+ if (Number.isNaN(t)) return "—";
20
+ const diff = t - now;
21
+ const abs = Math.abs(diff);
22
+ const future = diff > 0;
23
+ if (abs < 60_000) return future ? "in <1m" : "<1m ago";
24
+ const m = Math.round(abs / 60_000);
25
+ if (m < 60) return future ? `in ${m}m` : `${m}m ago`;
26
+ const h = Math.round(abs / 3_600_000);
27
+ if (h < 24) return future ? `in ${h}h` : `${h}h ago`;
28
+ const d = Math.round(abs / 86_400_000);
29
+ return future ? `in ${d}d` : `${d}d ago`;
30
+ }
31
+
32
+ /** One-line status icon. */
33
+ function statusIcon(j: ScheduledSubagent): string {
34
+ if (!j.enabled) return "✗";
35
+ if (j.lastStatus === "error") return "!";
36
+ if (j.lastStatus === "running") return "⋯";
37
+ return "✓";
38
+ }
39
+
40
+ /** Compact selectable row — name, schedule, agent type, next/last run, count. */
41
+ function formatJob(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
42
+ const next = scheduler.getNextRun(j.id);
43
+ return [
44
+ statusIcon(j),
45
+ j.name.padEnd(18).slice(0, 18),
46
+ j.schedule.padEnd(14).slice(0, 14),
47
+ `[${j.subagent_type}]`,
48
+ `next ${relTime(next)}`,
49
+ `last ${relTime(j.lastRun)}`,
50
+ `runs ${j.runCount}`,
51
+ ].join(" ");
52
+ }
53
+
54
+ /** Multi-line details block for the cancel confirm. */
55
+ function formatDetails(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
56
+ const next = scheduler.getNextRun(j.id) ?? "—";
57
+ return [
58
+ `name: ${j.name}`,
59
+ `schedule: ${j.schedule} (${j.scheduleType})`,
60
+ `agent: ${j.subagent_type}`,
61
+ `prompt: ${j.prompt.slice(0, 200)}${j.prompt.length > 200 ? "…" : ""}`,
62
+ `created: ${j.createdAt}`,
63
+ `last run: ${j.lastRun ?? "—"} (${j.lastStatus ?? "—"})`,
64
+ `next run: ${next}`,
65
+ `runs: ${j.runCount}`,
66
+ ].join("\n");
67
+ }
68
+
69
+ /**
70
+ * List scheduled jobs; selecting one opens a cancel-confirm with details.
71
+ * Returns when the user backs out or after a cancellation.
72
+ */
73
+ export async function showSchedulesMenu(
74
+ ctx: ExtensionCommandContext,
75
+ scheduler: SubagentScheduler,
76
+ ): Promise<void> {
77
+ if (!scheduler.isActive()) {
78
+ ctx.ui.notify("Scheduler is not active in this session.", "warning");
79
+ return;
80
+ }
81
+
82
+ const jobs = scheduler.list();
83
+ if (jobs.length === 0) {
84
+ ctx.ui.notify("No scheduled jobs.", "info");
85
+ return;
86
+ }
87
+
88
+ const labels = jobs.map(j => formatJob(j, scheduler));
89
+ const choice = await ctx.ui.select(
90
+ `Scheduled jobs (${jobs.length}) — select to cancel`,
91
+ labels,
92
+ );
93
+ if (!choice) return;
94
+
95
+ const idx = labels.indexOf(choice);
96
+ if (idx < 0) return;
97
+ const job = jobs[idx];
98
+
99
+ const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
100
+ if (!ok) return;
101
+
102
+ scheduler.removeJob(job.id);
103
+ ctx.ui.notify(`Cancelled "${job.name}".`, "info");
104
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * viewer-keys.ts — Scroll key matchers for the conversation viewer.
3
+ *
4
+ * Resolves `tui.select.*` through the user's keybindings when pi provides a
5
+ * manager, falling back to the previous hardcoded keys otherwise. The viewer's
6
+ * k/j and shift+arrow aliases always work alongside whatever is bound.
7
+ */
8
+
9
+ import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
10
+
11
+ /** The `tui.select.*` keybinding ids the viewer resolves. */
12
+ export type ViewerScrollKeybinding =
13
+ | "tui.select.up"
14
+ | "tui.select.down"
15
+ | "tui.select.pageUp"
16
+ | "tui.select.pageDown";
17
+
18
+ /** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
19
+ export interface ViewerKeybindings {
20
+ matches(data: string, keybinding: ViewerScrollKeybinding): boolean;
21
+ }
22
+
23
+ export interface ViewerKeys {
24
+ scrollUp(data: string): boolean;
25
+ scrollDown(data: string): boolean;
26
+ pageUp(data: string): boolean;
27
+ pageDown(data: string): boolean;
28
+ }
29
+
30
+ export function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys {
31
+ const matches = (data: string, id: ViewerScrollKeybinding, fallback: KeyId): boolean =>
32
+ keybindings ? keybindings.matches(data, id) : matchesKey(data, fallback);
33
+ return {
34
+ scrollUp: (data) => matches(data, "tui.select.up", "up") || matchesKey(data, "k"),
35
+ scrollDown: (data) => matches(data, "tui.select.down", "down") || matchesKey(data, "j"),
36
+ pageUp: (data) => matches(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"),
37
+ pageDown: (data) => matches(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"),
38
+ };
39
+ }