@ilya-lesikov/pi-pi 0.9.0 → 0.11.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.
- package/3p/pi-ask-user/index.ts +71 -124
- package/3p/pi-ask-user/single-select-layout.ts +3 -14
- package/3p/pi-subagents/package.json +12 -7
- package/3p/pi-subagents/src/agent-manager.ts +243 -79
- package/3p/pi-subagents/src/agent-runner.ts +501 -365
- package/3p/pi-subagents/src/agent-types.ts +46 -57
- package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
- package/3p/pi-subagents/src/custom-agents.ts +30 -6
- package/3p/pi-subagents/src/default-agents.ts +25 -43
- package/3p/pi-subagents/src/enabled-models.ts +180 -0
- package/3p/pi-subagents/src/index.ts +599 -839
- package/3p/pi-subagents/src/model-resolver.ts +26 -7
- package/3p/pi-subagents/src/output-file.ts +21 -2
- package/3p/pi-subagents/src/prompts.ts +17 -3
- package/3p/pi-subagents/src/schedule-store.ts +153 -0
- package/3p/pi-subagents/src/schedule.ts +365 -0
- package/3p/pi-subagents/src/settings.ts +261 -0
- package/3p/pi-subagents/src/skill-loader.ts +77 -54
- package/3p/pi-subagents/src/status-note.ts +25 -0
- package/3p/pi-subagents/src/types.ts +97 -6
- package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
- package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
- package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
- package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
- package/3p/pi-subagents/src/usage.ts +60 -0
- package/3p/pi-subagents/src/worktree.ts +49 -20
- package/extensions/orchestrator/agents/advisor.ts +13 -8
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
- package/extensions/orchestrator/agents/code-reviewer.ts +8 -3
- package/extensions/orchestrator/agents/constraints.test.ts +26 -0
- package/extensions/orchestrator/agents/constraints.ts +12 -2
- package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
- package/extensions/orchestrator/agents/explore.ts +12 -6
- package/extensions/orchestrator/agents/librarian.ts +13 -5
- package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
- package/extensions/orchestrator/agents/planner.ts +8 -3
- package/extensions/orchestrator/agents/pools.test.ts +56 -0
- package/extensions/orchestrator/agents/prompts.test.ts +52 -10
- package/extensions/orchestrator/agents/registry.test.ts +245 -0
- package/extensions/orchestrator/agents/registry.ts +145 -10
- package/extensions/orchestrator/agents/reviewer.ts +14 -8
- package/extensions/orchestrator/agents/task.ts +12 -6
- package/extensions/orchestrator/agents/tool-routing.ts +213 -85
- package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
- package/extensions/orchestrator/ast-search.test.ts +124 -0
- package/extensions/orchestrator/billing-spoof.test.ts +67 -0
- package/extensions/orchestrator/billing-spoof.ts +95 -0
- package/extensions/orchestrator/cbm.more.test.ts +358 -0
- package/extensions/orchestrator/command-handlers.ts +6 -0
- package/extensions/orchestrator/commands.test.ts +15 -2
- package/extensions/orchestrator/commands.ts +1 -1
- package/extensions/orchestrator/config.test.ts +89 -1
- package/extensions/orchestrator/config.ts +102 -19
- package/extensions/orchestrator/context.test.ts +46 -0
- package/extensions/orchestrator/context.ts +18 -5
- package/extensions/orchestrator/custom-footer.test.ts +24 -10
- package/extensions/orchestrator/custom-footer.ts +4 -2
- package/extensions/orchestrator/doctor.more.test.ts +315 -0
- package/extensions/orchestrator/doctor.ts +6 -2
- package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
- package/extensions/orchestrator/event-handlers.test.ts +96 -9
- package/extensions/orchestrator/event-handlers.ts +344 -151
- package/extensions/orchestrator/exa.more.test.ts +118 -0
- package/extensions/orchestrator/flant-infra.more.test.ts +381 -0
- package/extensions/orchestrator/flant-infra.test.ts +127 -0
- package/extensions/orchestrator/flant-infra.ts +126 -41
- package/extensions/orchestrator/index.test.ts +76 -0
- package/extensions/orchestrator/index.ts +2 -0
- package/extensions/orchestrator/integration.test.ts +183 -65
- package/extensions/orchestrator/model-registry.test.ts +2 -1
- package/extensions/orchestrator/model-registry.ts +12 -2
- package/extensions/orchestrator/orchestrator.test.ts +67 -0
- package/extensions/orchestrator/orchestrator.ts +119 -27
- package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
- package/extensions/orchestrator/phases/brainstorm.ts +43 -6
- package/extensions/orchestrator/phases/implementation.ts +2 -0
- package/extensions/orchestrator/phases/machine.test.ts +17 -1
- package/extensions/orchestrator/phases/machine.ts +4 -1
- package/extensions/orchestrator/phases/planning.test.ts +47 -1
- package/extensions/orchestrator/phases/planning.ts +18 -3
- package/extensions/orchestrator/phases/review-task.ts +5 -0
- package/extensions/orchestrator/phases/review.test.ts +10 -0
- package/extensions/orchestrator/phases/review.ts +22 -7
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
- package/extensions/orchestrator/phases/verdict.ts +6 -5
- package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
- package/extensions/orchestrator/plannotator.test.ts +38 -1
- package/extensions/orchestrator/plannotator.ts +50 -3
- package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
- package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
- package/extensions/orchestrator/pp-menu.test.ts +114 -7
- package/extensions/orchestrator/pp-menu.ts +580 -91
- package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
- package/extensions/orchestrator/pp-state-tools.ts +65 -0
- package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
- package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
- package/extensions/orchestrator/review-files.test.ts +26 -0
- package/extensions/orchestrator/review-files.ts +3 -0
- package/extensions/orchestrator/state.test.ts +73 -1
- package/extensions/orchestrator/state.ts +95 -23
- package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
- package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
- package/extensions/orchestrator/validate-artifacts.ts +1 -1
- package/package.json +9 -2
- package/scripts/lib/smoke-resolve.mjs +99 -0
- package/scripts/test-3p.sh +52 -0
- package/scripts/test-package.sh +62 -0
|
@@ -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?:
|
|
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
|
-
/**
|
|
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 ?
|
|
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 :
|
|
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.
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
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.
|
|
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,
|
|
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
|
-
/**
|
|
16
|
-
const
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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", ` $ ${
|
|
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(
|
|
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
|
+
}
|