@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10
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/LICENSE +21 -0
- package/README.md +1444 -2
- package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
- package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
- package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
- package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
- package/dist/index.ts +9874 -0
- package/dist/index.ts.map +7 -0
- package/package.json +82 -4
- package/src/abortable.ts +43 -0
- package/src/agent-color.ts +161 -0
- package/src/agent-file-toggle.ts +298 -0
- package/src/agent-manager.ts +2431 -0
- package/src/agent-runner.ts +1703 -0
- package/src/agent-source-badge.ts +75 -0
- package/src/agent-types.ts +346 -0
- package/src/child-context.ts +33 -0
- package/src/clock.ts +25 -0
- package/src/completion-outbox.ts +144 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +200 -0
- package/src/custom-agents.ts +441 -0
- package/src/default-agents.ts +126 -0
- package/src/detached-results.ts +150 -0
- package/src/enabled-models.ts +181 -0
- package/src/enterprise-local.ts +67 -0
- package/src/env.ts +33 -0
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +142 -0
- package/src/home-paths.ts +27 -0
- package/src/index.ts +5047 -0
- package/src/invocation-config.ts +158 -0
- package/src/memory.ts +179 -0
- package/src/mention-clone.ts +200 -0
- package/src/mention.ts +141 -0
- package/src/model-call.ts +83 -0
- package/src/model-resolver.ts +333 -0
- package/src/model-scope.ts +71 -0
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +512 -0
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +186 -0
- package/src/package-resources.ts +639 -0
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +346 -0
- package/src/prompts.ts +161 -0
- package/src/quota-admission.ts +121 -0
- package/src/resource-loader.ts +65 -0
- package/src/schedule-store.ts +153 -0
- package/src/schedule.ts +514 -0
- package/src/settings.ts +851 -0
- package/src/skill-loader.ts +288 -0
- package/src/stale-ctx.ts +16 -0
- package/src/startup-error.ts +29 -0
- package/src/status-note.ts +113 -0
- package/src/structured-output.ts +130 -0
- package/src/types.ts +487 -0
- package/src/ui/agent-mention.ts +219 -0
- package/src/ui/agent-row.ts +245 -0
- package/src/ui/agent-widget.ts +722 -0
- package/src/ui/conversation-viewer.ts +837 -0
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +1020 -0
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +139 -0
- package/src/ui/schedule-menu.ts +105 -0
- package/src/ui/select-item.ts +45 -0
- package/src/ui/viewer-keys.ts +39 -0
- package/src/ui/workflow-card.ts +459 -0
- package/src/ui/workflow-dialog.ts +1138 -0
- package/src/ui/workflow-menu.ts +193 -0
- package/src/usage.ts +167 -0
- package/src/workflow/collisions.ts +123 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +398 -0
- package/src/workflow/journal.ts +176 -0
- package/src/workflow/json-schema.ts +129 -0
- package/src/workflow/meta.ts +329 -0
- package/src/workflow/progress.ts +554 -0
- package/src/workflow/runtime.ts +1249 -0
- package/src/workflow/saved.ts +259 -0
- package/src/workflow/task.ts +317 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +903 -0
- package/src/worktree.ts +520 -0
- package/src/xml.ts +13 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-mention.ts — what `@` can address, and the suggestions pi renders for it.
|
|
3
|
+
*
|
|
4
|
+
* A subagent is addressable whether or not it is currently running: a live
|
|
5
|
+
* record is messaged or resumed, an evicted one whose session is still on disk
|
|
6
|
+
* is reopened, and an agent *type* with no instance at all is started. That is
|
|
7
|
+
* the point of the handle — `@explore` means the Explore agent, not "the
|
|
8
|
+
* Explore process that happens to exist right now" — so the roster below unions
|
|
9
|
+
* all three, and the dispatcher and the popup read the same list.
|
|
10
|
+
*
|
|
11
|
+
* Rows are per *agent*, not per handle. An agent given a `name` holds two names
|
|
12
|
+
* (its alias and its type-derived handle) and both resolve, but it lists once,
|
|
13
|
+
* under the alias, with its type moved into the description so the row still
|
|
14
|
+
* says what it is.
|
|
15
|
+
*
|
|
16
|
+
* pi's `CombinedAutocompleteProvider` already owns `@`, where it means "attach a
|
|
17
|
+
* file". Extensions can wrap it (`ctx.ui.addAutocompleteProvider`), so this
|
|
18
|
+
* provider adds the `@` tokens that name an agent and delegates everything else
|
|
19
|
+
* — including all of `applyCompletion`, whose `@`-branch already inserts
|
|
20
|
+
* `item.value` plus a trailing space, which is exactly what a handle needs.
|
|
21
|
+
*
|
|
22
|
+
* Matching mirrors Claude Code: case-insensitive prefix, not fuzzy. What it does
|
|
23
|
+
* NOT mirror is Claude Code dropping files whenever an agent matches. Here `@` is
|
|
24
|
+
* pi's file picker first, and the handles are additive, so a token matching both
|
|
25
|
+
* lists both — agents first. Suppressing on any match sounds narrow and is not:
|
|
26
|
+
* an empty token prefix-matches every handle, so a bare `@` — the gesture people
|
|
27
|
+
* use to browse files — would offer no files at all, and a single letter
|
|
28
|
+
* beginning any handle would do the same.
|
|
29
|
+
*
|
|
30
|
+
* Both halves ship under ONE `prefix`, which is sound because wherever BOTH sides
|
|
31
|
+
* produce rows they measured the same span. pi's `extractAtPrefix` takes the
|
|
32
|
+
* token after the last of `{space, tab, ", ', =}` and keeps it only if it starts
|
|
33
|
+
* with `@`; `MENTION_TRIGGER` matches `@[\w-]*` at the cursor, after start-of-line
|
|
34
|
+
* or `[\s。、?!]`. Where those two disagree, exactly one side answers and there
|
|
35
|
+
* is nothing to merge: `@src/index.ts` and `@"my file` are pi's alone (no handle
|
|
36
|
+
* matches), `=@ex` is pi's alone (`=` is a delimiter to pi, not a boundary to us),
|
|
37
|
+
* and `。@ex` is ours alone (the reverse). A merged response therefore never
|
|
38
|
+
* carries a prefix from one side and an item from the other.
|
|
39
|
+
*
|
|
40
|
+
* Offering never-started types is a deliberate step beyond Claude Code, whose
|
|
41
|
+
* registry holds only live tasks, so an agent you had not launched yet was
|
|
42
|
+
* unaddressable.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui";
|
|
46
|
+
import type { AgentManager } from "../agent-manager.js";
|
|
47
|
+
import { handleBase, MENTION_TRIGGER } from "../mention.js";
|
|
48
|
+
import type { AgentRecord, AgentTombstone } from "../types.js";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One thing `@` can address, and what sending to it will do. `typeLabel` is the
|
|
52
|
+
* agent's `display_name`, resolved by the caller: this module stays independent
|
|
53
|
+
* of the type registry, but the popup must agree with FleetView and the widget,
|
|
54
|
+
* which both render the label rather than the raw type.
|
|
55
|
+
*/
|
|
56
|
+
export type MentionTarget =
|
|
57
|
+
| { kind: "record"; handle: string; record: AgentRecord; typeLabel: string }
|
|
58
|
+
| { kind: "tombstone"; handle: string; entry: AgentTombstone; typeLabel: string }
|
|
59
|
+
| { kind: "type"; handle: string; type: string; description: string };
|
|
60
|
+
|
|
61
|
+
/** The registry facts the roster needs, so it stays independent of agent-types. */
|
|
62
|
+
export type TypeInfo = { name: string; description: string };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Everything `@` can reach, in the order the popup lists it: steerable agents
|
|
66
|
+
* first, then the other live ones earliest-launched, then agent types with no
|
|
67
|
+
* live instance. A type whose handle a record already holds is omitted — that
|
|
68
|
+
* name addresses the existing agent, which is what makes `@explore` mean
|
|
69
|
+
* "message the one that's running" and only otherwise "start one".
|
|
70
|
+
*/
|
|
71
|
+
export function mentionRoster(
|
|
72
|
+
manager: AgentManager,
|
|
73
|
+
types: readonly TypeInfo[],
|
|
74
|
+
// Identity by default: a caller with no registry to consult gets the raw
|
|
75
|
+
// type, which is also what `getConfig` falls back to when no label is set.
|
|
76
|
+
displayNameOf: (type: string) => string = type => type,
|
|
77
|
+
): MentionTarget[] {
|
|
78
|
+
const live = (r: AgentRecord) => r.status === "running" || r.status === "queued";
|
|
79
|
+
const records = manager.listAgents()
|
|
80
|
+
.filter(r => r.handle !== undefined && r.parentAgentId === undefined)
|
|
81
|
+
.sort((a, b) => (Number(live(b)) - Number(live(a))) || (a.startedAt - b.startedAt));
|
|
82
|
+
|
|
83
|
+
const taken = new Set<string>();
|
|
84
|
+
const targets: MentionTarget[] = [];
|
|
85
|
+
|
|
86
|
+
// One row per agent, not per handle. An aliased agent lists under its alias
|
|
87
|
+
// only — both names resolve, but showing two rows for one agent reads as two
|
|
88
|
+
// agents. The type handle stays addressable whether or not it is listed.
|
|
89
|
+
for (const record of records) {
|
|
90
|
+
// The filter above kept only records with a handle, and `handleBase` never
|
|
91
|
+
// derives an empty one.
|
|
92
|
+
const own = record.handle!;
|
|
93
|
+
const handle = record.alias ?? own;
|
|
94
|
+
taken.add(handle.toLowerCase());
|
|
95
|
+
taken.add(own.toLowerCase());
|
|
96
|
+
targets.push({ kind: "record", handle, record, typeLabel: displayNameOf(record.type) });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Then agents that are gone but whose conversation can be reopened. After the
|
|
100
|
+
// live ones: a running agent is the likelier target, and this keeps the
|
|
101
|
+
// ordering "what exists now, then what can be brought back, then what can be
|
|
102
|
+
// started".
|
|
103
|
+
for (const entry of manager.listTombstones()) {
|
|
104
|
+
const handle = entry.alias ?? entry.handle;
|
|
105
|
+
if (taken.has(handle.toLowerCase())) continue;
|
|
106
|
+
taken.add(handle.toLowerCase());
|
|
107
|
+
taken.add(entry.handle.toLowerCase());
|
|
108
|
+
targets.push({ kind: "tombstone", handle, entry, typeLabel: displayNameOf(entry.type) });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const type of types) {
|
|
112
|
+
const handle = handleBase(type.name);
|
|
113
|
+
if (taken.has(handle)) continue;
|
|
114
|
+
taken.add(handle);
|
|
115
|
+
targets.push({ kind: "type", handle, type: type.name, description: type.description });
|
|
116
|
+
}
|
|
117
|
+
return targets;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createMentionProvider(
|
|
121
|
+
current: AutocompleteProvider,
|
|
122
|
+
roster: () => MentionTarget[],
|
|
123
|
+
isEnabled: () => boolean,
|
|
124
|
+
): AutocompleteProvider {
|
|
125
|
+
// One warning per provider, not per keystroke: `getSuggestions` runs on every
|
|
126
|
+
// character typed after `@`, so an unguarded log would bury the terminal in
|
|
127
|
+
// the time it takes to finish a word.
|
|
128
|
+
let warnedInnerFailure = false;
|
|
129
|
+
return {
|
|
130
|
+
// Only `@` — the contract is "characters that should naturally trigger
|
|
131
|
+
// THIS provider", and pi unions each wrapper's own set onto the outermost
|
|
132
|
+
// one itself (interactive-mode.js:432), so re-declaring the wrapped
|
|
133
|
+
// provider's characters here would both misreport us and duplicate that.
|
|
134
|
+
triggerCharacters: ["@"],
|
|
135
|
+
|
|
136
|
+
async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
|
|
137
|
+
const mine = isEnabled() ? mentionItems(roster(), lines[cursorLine] ?? "", cursorCol) : null;
|
|
138
|
+
// Asked unconditionally: pi owns `@` and must keep answering for it even
|
|
139
|
+
// when a handle matches too. That is the same work vanilla pi does on any
|
|
140
|
+
// `@` keystroke — a capped `fd` search, or nothing at all when the host
|
|
141
|
+
// configured no `fd` path — but we now do it on tokens we used to answer
|
|
142
|
+
// alone, so it must not be able to take the popup down with it. The
|
|
143
|
+
// wrapped provider is not always pi's: another extension may sit inside
|
|
144
|
+
// us, and before this it was never called for a token naming an agent.
|
|
145
|
+
// try/catch, not `.catch()`: a provider that throws SYNCHRONOUSLY never
|
|
146
|
+
// returns the promise a `.catch()` would attach to, and the throw escapes
|
|
147
|
+
// this method as a rejection — which pi does not handle either
|
|
148
|
+
// (components/editor.js:1892 awaits with no catch of its own).
|
|
149
|
+
let theirs: AutocompleteSuggestions | null = null;
|
|
150
|
+
try {
|
|
151
|
+
theirs = await current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
// Safe to treat as "no files": pi discards any response whose request is
|
|
154
|
+
// no longer current, so an aborted search that surfaces as a rejection
|
|
155
|
+
// cannot leave a stale popup behind (`isAutocompleteRequestCurrent`).
|
|
156
|
+
// Warned rather than swallowed outright — the failure is invisible in
|
|
157
|
+
// the popup, and the same `console.warn` channel already carries this
|
|
158
|
+
// extension's other non-fatal failures.
|
|
159
|
+
if (!warnedInnerFailure) {
|
|
160
|
+
warnedInnerFailure = true;
|
|
161
|
+
console.warn("[pi-subagents] the autocomplete provider below us failed; showing agent rows only:", err);
|
|
162
|
+
}
|
|
163
|
+
theirs = null;
|
|
164
|
+
}
|
|
165
|
+
if (!mine) return theirs;
|
|
166
|
+
if (!theirs) return mine;
|
|
167
|
+
// Agents first: there are a handful of them against pi's 20 file rows, and
|
|
168
|
+
// a handle buried under fuzzy path matches is a handle nobody finds. The
|
|
169
|
+
// prefix is ours by the span argument in the header — identical to pi's
|
|
170
|
+
// whenever both sides have something to say.
|
|
171
|
+
return { items: [...mine.items, ...theirs.items], prefix: mine.prefix };
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
175
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
179
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Suggestions for the `@…` token under the cursor, or null when it names no agent. */
|
|
185
|
+
function mentionItems(roster: MentionTarget[], line: string, cursorCol: number): AutocompleteSuggestions | null {
|
|
186
|
+
const match = MENTION_TRIGGER.exec(line.slice(0, cursorCol));
|
|
187
|
+
if (!match) return null;
|
|
188
|
+
|
|
189
|
+
const typed = match[2].toLowerCase();
|
|
190
|
+
const items: AutocompleteItem[] = [];
|
|
191
|
+
for (const target of roster) {
|
|
192
|
+
if (!target.handle.toLowerCase().startsWith(typed)) continue;
|
|
193
|
+
items.push({ value: `@${target.handle}`, label: `@${target.handle}`, description: describeTarget(target) });
|
|
194
|
+
}
|
|
195
|
+
return items.length > 0 ? { items, prefix: `@${match[2]}` } : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Name the action that will actually happen, so the list never mispromises. */
|
|
199
|
+
function describeTarget(target: MentionTarget): string {
|
|
200
|
+
if (target.kind === "type") return `start agent · ${summarize(target.description)}`;
|
|
201
|
+
if (target.kind === "tombstone") {
|
|
202
|
+
// No status: the record is gone, and "completed" would imply one is still
|
|
203
|
+
// being tracked. The type carries the identity the handle may not.
|
|
204
|
+
return `resume · ${target.typeLabel} · ${target.entry.description}`;
|
|
205
|
+
}
|
|
206
|
+
const { status, description, alias } = target.record;
|
|
207
|
+
const action = status === "running" || status === "queued" ? "send message" : "resume";
|
|
208
|
+
// A row listed under its alias has lost the type its handle would have shown,
|
|
209
|
+
// so name it — `@auth-audit` alone says nothing about what the agent is.
|
|
210
|
+
// A type-derived row already reads as its type and would just repeat itself.
|
|
211
|
+
const identity = alias ? `${target.typeLabel} · ` : "";
|
|
212
|
+
return `${action} · ${identity}${status} · ${description}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** First sentence of an agent description, clipped — these run to paragraphs. */
|
|
216
|
+
function summarize(description: string): string {
|
|
217
|
+
const first = (description.match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? description).replace(/\s+/g, " ").trim();
|
|
218
|
+
return first.length > 60 ? `${first.slice(0, 59).trimEnd()}…` : first;
|
|
219
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-row.ts -- the `Agent` tool row.
|
|
3
|
+
*
|
|
4
|
+
* Core folds every collapsed tool row to one line,
|
|
5
|
+
* `<first line of renderCall> · <first line of renderResult>`, and draws the
|
|
6
|
+
* ▸/▾ disclosure marker itself. So the call's first line is just `Plan desc`,
|
|
7
|
+
* the result's first line is the summary (`thinking · 2m41s · 12s · 3.2k token`),
|
|
8
|
+
* and neither draws a marker of its own. The result is a live component: it
|
|
9
|
+
* reads the agent record on every render, so the summary keeps moving without
|
|
10
|
+
* the tool re-emitting a result.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { type Component, hyperlink, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
15
|
+
import { type Clock, realClock, type TimerHandle } from "../clock.js";
|
|
16
|
+
import { formatClock } from "../model-call.js";
|
|
17
|
+
import { modelSelectionNote } from "../model-selection.js";
|
|
18
|
+
import type { AgentRecord } from "../types.js";
|
|
19
|
+
import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
|
|
20
|
+
import {
|
|
21
|
+
type AgentActivity,
|
|
22
|
+
type AgentDetails,
|
|
23
|
+
describeActivity,
|
|
24
|
+
fgPreservingNestedStyles,
|
|
25
|
+
formatCost,
|
|
26
|
+
formatTokens,
|
|
27
|
+
SPINNER,
|
|
28
|
+
type Theme,
|
|
29
|
+
} from "./agent-widget.js";
|
|
30
|
+
import { fallbackFlag, withLeadingFlag } from "./fallback-flag.js";
|
|
31
|
+
|
|
32
|
+
/** Lines of the result shown when expanded; the rest is one `get_subagent_result` away. */
|
|
33
|
+
const EXPANDED_RESULT_LINES = 50;
|
|
34
|
+
/** Lines of the prompt shown when expanded. */
|
|
35
|
+
const EXPANDED_PROMPT_LINES = 30;
|
|
36
|
+
|
|
37
|
+
/** What the row reads at render time. */
|
|
38
|
+
export interface AgentRowLive {
|
|
39
|
+
getRecord(id: string): AgentRecord | undefined;
|
|
40
|
+
getActivity(id: string): AgentActivity | undefined;
|
|
41
|
+
showCost(): boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Statuses after which nothing about the run changes any more. */
|
|
45
|
+
const TERMINAL = new Set(["completed", "steered", "aborted", "stopped", "error"]);
|
|
46
|
+
|
|
47
|
+
/** Whether the row still has something live to show. */
|
|
48
|
+
export function isRowLive(details: AgentDetails, live: AgentRowLive): boolean {
|
|
49
|
+
const record = details.agentId ? live.getRecord(details.agentId) : undefined;
|
|
50
|
+
if (record) return record.status === "running" || record.status === "queued";
|
|
51
|
+
return details.status === "running" || details.status === "queued";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The icon and wording for a run's state, reading the record when the details are not final. */
|
|
55
|
+
function statusOf(
|
|
56
|
+
details: AgentDetails,
|
|
57
|
+
record: AgentRecord | undefined,
|
|
58
|
+
activity: AgentActivity | undefined,
|
|
59
|
+
theme: Theme,
|
|
60
|
+
now: number,
|
|
61
|
+
): { icon: string; text: string } {
|
|
62
|
+
// A terminal result is the tool's own verdict (it also knows the worktree note
|
|
63
|
+
// and the final error). Only a non-final one is replaced by the live record.
|
|
64
|
+
const status = TERMINAL.has(details.status) ? details.status : (record?.status ?? details.status);
|
|
65
|
+
switch (status) {
|
|
66
|
+
case "completed":
|
|
67
|
+
return { icon: theme.fg("success", "✓"), text: "done" };
|
|
68
|
+
case "steered":
|
|
69
|
+
return { icon: theme.fg("warning", "✓"), text: "wrapped up (turn limit)" };
|
|
70
|
+
case "stopped":
|
|
71
|
+
return { icon: theme.fg("dim", "■"), text: "stopped" };
|
|
72
|
+
case "aborted":
|
|
73
|
+
return { icon: theme.fg("error", "✗"), text: "aborted (max turns exceeded)" };
|
|
74
|
+
case "error":
|
|
75
|
+
return { icon: theme.fg("error", "✗"), text: `error: ${record?.error ?? details.error ?? "unknown"}` };
|
|
76
|
+
case "queued":
|
|
77
|
+
return { icon: theme.fg("muted", "◦"), text: details.activity ?? "queued" };
|
|
78
|
+
default: {
|
|
79
|
+
const frame = SPINNER[Math.floor(now / 80) % SPINNER.length];
|
|
80
|
+
const text = activity
|
|
81
|
+
? describeActivity(activity.activeTools, activity.responseText, activity.modelCall, now)
|
|
82
|
+
: (details.status === "background" ? "running in background" : (details.activity ?? "thinking…"));
|
|
83
|
+
return { icon: theme.fg("accent", frame), text };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `✓ done · 12s · 33.8k token`: the first line of the result, and half of the folded row. */
|
|
89
|
+
export function agentRowSummary(
|
|
90
|
+
details: AgentDetails,
|
|
91
|
+
live: AgentRowLive,
|
|
92
|
+
theme: Theme,
|
|
93
|
+
now: number = Date.now(),
|
|
94
|
+
width?: number,
|
|
95
|
+
): string {
|
|
96
|
+
const record = details.agentId ? live.getRecord(details.agentId) : undefined;
|
|
97
|
+
const activity = details.agentId ? live.getActivity(details.agentId) : undefined;
|
|
98
|
+
const { icon, text } = statusOf(details, record, activity, theme, now);
|
|
99
|
+
|
|
100
|
+
const finished = TERMINAL.has(details.status) || (record !== undefined && TERMINAL.has(record.status));
|
|
101
|
+
const elapsed = record
|
|
102
|
+
? (finished ? (record.completedAt ?? now) : now) - record.startedAt
|
|
103
|
+
: details.durationMs;
|
|
104
|
+
const tokens = record ? getLifetimeTotal(record.lifetimeUsage) : 0;
|
|
105
|
+
const tokenText = record ? (tokens > 0 ? formatTokens(tokens) : "") : details.tokens;
|
|
106
|
+
const cost = live.showCost() ? formatCost(record ? getLifetimeCost(record.lifetimeUsage) : (details.cost ?? 0)) : "";
|
|
107
|
+
|
|
108
|
+
const parts = [text, formatClock(elapsed)];
|
|
109
|
+
if (tokenText) parts.push(tokenText);
|
|
110
|
+
if (cost) parts.push(cost);
|
|
111
|
+
if (!finished && details.backgroundHint && elapsed >= 2000) parts.push("(ctrl+b to run in background)");
|
|
112
|
+
// Leading, ahead of the status text, which may carry a 300-char error.
|
|
113
|
+
const flag = fallbackFlag(theme, record?.modelFallback || record?.modelTier ? record : details);
|
|
114
|
+
const body = parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(` ${theme.fg("dim", "·")} `);
|
|
115
|
+
return width === undefined ? `${icon} ${flag ? `${flag} ` : ""}${body}` : withLeadingFlag(`${icon} `, flag, body, width);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `↳ opus 4.6 · thinking: high · background`. Taken from the details, which the
|
|
120
|
+
* tool already builds from the record's effective values once a session exists
|
|
121
|
+
* (pi may have clamped the level) and which carry the `twin` label.
|
|
122
|
+
*/
|
|
123
|
+
function tagsLine(details: AgentDetails, theme: Theme): string | undefined {
|
|
124
|
+
const parts = [...(details.modelName ? [details.modelName] : []), ...(details.tags ?? [])];
|
|
125
|
+
const flag = fallbackFlag(theme, details);
|
|
126
|
+
if (parts.length === 0 && !flag) return undefined;
|
|
127
|
+
return `${theme.fg("dim", "↳")} ${flag ? `${flag} ` : ""}${theme.fg("dim", parts.join(" · "))}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The transcript as an OSC 8 hyperlink, so a click opens it. */
|
|
131
|
+
export function transcriptLink(path: string, theme: Theme): string {
|
|
132
|
+
return `${hyperlink("transcript ↗", pathToFileURL(path).href)} ${theme.fg("dim", path)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Redraw cadence while a row has something live to show. */
|
|
136
|
+
const ROW_TICK_MS = 1000;
|
|
137
|
+
|
|
138
|
+
interface RowTickState {
|
|
139
|
+
ticker?: TimerHandle;
|
|
140
|
+
isLive?: () => boolean;
|
|
141
|
+
invalidate?: () => void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Ask the host to redraw a live tool row once a second. Pi caches a settled
|
|
146
|
+
* row's collapsed line until the renderer calls `context.invalidate()`, and
|
|
147
|
+
* nothing repaints a quiet screen, so a row that reads live state must tick.
|
|
148
|
+
* The ticker lives on the row's shared `state`, always asks the newest
|
|
149
|
+
* `isLive`, and stops by itself once the run is over. A host probe render
|
|
150
|
+
* (`probe: true`) gets throwaway state and re-runs on every invalidate, so it
|
|
151
|
+
* never starts one.
|
|
152
|
+
*/
|
|
153
|
+
export function keepTicking(
|
|
154
|
+
context: { state?: unknown; invalidate?: () => void; probe?: boolean },
|
|
155
|
+
isLive: () => boolean,
|
|
156
|
+
clock: Clock = realClock,
|
|
157
|
+
): void {
|
|
158
|
+
const state = context.state as RowTickState | undefined;
|
|
159
|
+
if (context.probe || !state || !context.invalidate) return;
|
|
160
|
+
state.isLive = isLive;
|
|
161
|
+
state.invalidate = context.invalidate;
|
|
162
|
+
if (state.ticker !== undefined || !isLive()) return;
|
|
163
|
+
state.ticker = clock.setInterval(() => {
|
|
164
|
+
if (state.isLive?.()) {
|
|
165
|
+
state.invalidate?.();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
state.ticker?.cancel();
|
|
169
|
+
state.ticker = undefined;
|
|
170
|
+
state.invalidate?.(); // one last repaint, showing the final state
|
|
171
|
+
}, ROW_TICK_MS);
|
|
172
|
+
state.ticker.unref?.();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** {@link keepTicking} for an `Agent` row: live while its record is running or queued. */
|
|
176
|
+
export function keepRowTicking(
|
|
177
|
+
context: { state?: unknown; invalidate?: () => void; probe?: boolean },
|
|
178
|
+
details: AgentDetails,
|
|
179
|
+
live: AgentRowLive,
|
|
180
|
+
clock: Clock = realClock,
|
|
181
|
+
): void {
|
|
182
|
+
keepTicking(context, () => isRowLive(details, live), clock);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The result half of the row. Re-evaluates on every render, so what it shows is
|
|
187
|
+
* the agent's state now rather than the state when the tool last returned.
|
|
188
|
+
*/
|
|
189
|
+
export class AgentResultView implements Component {
|
|
190
|
+
constructor(
|
|
191
|
+
private readonly details: AgentDetails,
|
|
192
|
+
private readonly resultText: string,
|
|
193
|
+
private readonly expanded: boolean,
|
|
194
|
+
private readonly live: AgentRowLive,
|
|
195
|
+
private readonly theme: Theme,
|
|
196
|
+
private readonly clock: Clock = realClock,
|
|
197
|
+
) {}
|
|
198
|
+
|
|
199
|
+
render(width: number): string[] {
|
|
200
|
+
const lines = [agentRowSummary(this.details, this.live, this.theme, this.clock.now(), width)];
|
|
201
|
+
if (!this.expanded) return lines;
|
|
202
|
+
|
|
203
|
+
const record = this.details.agentId ? this.live.getRecord(this.details.agentId) : undefined;
|
|
204
|
+
const tags = tagsLine(this.details, this.theme);
|
|
205
|
+
if (tags) lines.push(truncateToWidth(tags, width));
|
|
206
|
+
const note = modelSelectionNote(record) || this.details.modelNote;
|
|
207
|
+
if (note) lines.push(...wrapTextWithAnsi(this.theme.fg("warning", note), width));
|
|
208
|
+
const transcript = record?.outputFile ?? this.details.outputFile;
|
|
209
|
+
if (transcript) lines.push(truncateToWidth(transcriptLink(transcript, this.theme), width));
|
|
210
|
+
|
|
211
|
+
if (this.details.status === "completed" || this.details.status === "steered") {
|
|
212
|
+
const body = this.resultText.split("\n");
|
|
213
|
+
for (const line of body.slice(0, EXPANDED_RESULT_LINES)) {
|
|
214
|
+
lines.push(...wrapTextWithAnsi(this.theme.fg("dim", ` ${line}`), width));
|
|
215
|
+
}
|
|
216
|
+
if (body.length > EXPANDED_RESULT_LINES) {
|
|
217
|
+
lines.push(this.theme.fg("muted", " ... (use get_subagent_result with verbose for full output)"));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return lines;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
invalidate(): void { /* reads live state on every render */ }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The call half: `Plan desc`, and the prompt underneath when expanded. */
|
|
227
|
+
export class AgentCallView implements Component {
|
|
228
|
+
constructor(
|
|
229
|
+
private readonly header: string,
|
|
230
|
+
private readonly prompt: string | undefined,
|
|
231
|
+
private readonly theme: Theme,
|
|
232
|
+
) {}
|
|
233
|
+
|
|
234
|
+
render(width: number): string[] {
|
|
235
|
+
const lines = [truncateToWidth(this.header, width)];
|
|
236
|
+
if (this.prompt) {
|
|
237
|
+
const wrapped = this.prompt.split("\n").flatMap(line => wrapTextWithAnsi(this.theme.fg("dim", ` ${line}`), width));
|
|
238
|
+
lines.push(...wrapped.slice(0, EXPANDED_PROMPT_LINES));
|
|
239
|
+
if (wrapped.length > EXPANDED_PROMPT_LINES) lines.push(this.theme.fg("muted", " …"));
|
|
240
|
+
}
|
|
241
|
+
return lines;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
invalidate(): void { /* static */ }
|
|
245
|
+
}
|