@signalridge/pi-subagents 1.4.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,163 @@
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
+ * pi's `CombinedAutocompleteProvider` already owns `@`, where it means "attach a
12
+ * file". Extensions can wrap it (`ctx.ui.addAutocompleteProvider`), so this
13
+ * provider answers the `@` tokens that name an agent and delegates every other
14
+ * one — including all of `applyCompletion`, whose `@`-branch already inserts
15
+ * `item.value` plus a trailing space, which is exactly what a handle needs.
16
+ *
17
+ * Matching is case-insensitive prefix (not fuzzy), and when any agent matches,
18
+ * files are dropped from the list rather than mixed in — an `@name` that names
19
+ * an agent is never also a path.
20
+ *
21
+ * Every string that reaches a row passes through `sanitizeDisplayText` first:
22
+ * an agent description comes from a `.pi/agents/*.md` file that may not be
23
+ * trustworthy, and the popup draws into the user's terminal.
24
+ */
25
+
26
+ import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui";
27
+ import type { AgentManager } from "../agent-manager.js";
28
+ import { handleBase, MENTION_TRIGGER } from "../mention.js";
29
+ import type { AgentRecordSnapshot, ResumableAgentEntry } from "../types.js";
30
+ import { sanitizeDisplayText, truncateCodePoints } from "./safe-text.js";
31
+
32
+ /**
33
+ * One thing `@` can address, and what sending to it will do. `typeLabel` is the
34
+ * agent's `display_name`, resolved by the caller: this module stays independent
35
+ * of the type registry, but the popup must agree with FleetView and the widget,
36
+ * which both render the label rather than the raw type.
37
+ */
38
+ export type MentionTarget =
39
+ | { kind: "record"; handle: string; record: AgentRecordSnapshot; typeLabel: string }
40
+ | { kind: "resumable"; handle: string; entry: ResumableAgentEntry; typeLabel: string }
41
+ | { kind: "type"; handle: string; type: string; description: string };
42
+
43
+ /** The registry facts the roster needs, so it stays independent of agent-types. */
44
+ export type TypeInfo = { name: string; description: string };
45
+
46
+ /**
47
+ * Everything `@` can reach, in the order the popup lists it: steerable agents
48
+ * first, then the other live ones earliest-launched, then evicted conversations
49
+ * that can be reopened, then agent types with no live instance. A type whose
50
+ * handle a record already holds is omitted — that name addresses the existing
51
+ * agent, which is what makes `@explore` mean "message the one that's running"
52
+ * and only otherwise "start one".
53
+ */
54
+ export function mentionRoster(
55
+ manager: AgentManager,
56
+ types: readonly TypeInfo[],
57
+ // Identity by default: a caller with no registry to consult gets the raw
58
+ // type, which is also what the config lookup falls back to when no label is set.
59
+ displayNameOf: (type: string) => string = (type) => type,
60
+ ): MentionTarget[] {
61
+ const isLive = (r: AgentRecordSnapshot) => r.status === "running" || r.status === "queued";
62
+ const records = manager
63
+ .listAgents()
64
+ .filter((r) => r.handle !== undefined && r.parentAgentId === undefined)
65
+ .sort((a, b) => Number(isLive(b)) - Number(isLive(a)) || a.startedAt - b.startedAt);
66
+
67
+ const taken = new Set<string>();
68
+ const targets: MentionTarget[] = [];
69
+
70
+ for (const record of records) {
71
+ const handle = record.handle as string;
72
+ taken.add(handle.toLowerCase());
73
+ targets.push({ kind: "record", handle, record, typeLabel: displayNameOf(record.type) });
74
+ }
75
+
76
+ // Then agents that are gone but whose conversation can be reopened. After the
77
+ // live ones: a running agent is the likelier target, and this keeps the
78
+ // ordering "what exists now, then what can be brought back, then what can be
79
+ // started".
80
+ for (const entry of manager.listResumable()) {
81
+ if (taken.has(entry.handle.toLowerCase())) continue;
82
+ taken.add(entry.handle.toLowerCase());
83
+ targets.push({ kind: "resumable", handle: entry.handle, entry, typeLabel: displayNameOf(entry.type) });
84
+ }
85
+
86
+ for (const type of types) {
87
+ const handle = handleBase(type.name);
88
+ if (taken.has(handle)) continue;
89
+ taken.add(handle);
90
+ targets.push({ kind: "type", handle, type: type.name, description: type.description });
91
+ }
92
+ return targets;
93
+ }
94
+
95
+ export function createMentionProvider(
96
+ current: AutocompleteProvider,
97
+ roster: () => MentionTarget[],
98
+ isEnabled: () => boolean,
99
+ ): AutocompleteProvider {
100
+ return {
101
+ // Only `@` — the contract is "characters that should naturally trigger THIS
102
+ // provider", and pi unions each wrapper's own set onto the outermost one
103
+ // itself, so re-declaring the wrapped provider's characters here would both
104
+ // misreport us and duplicate that.
105
+ triggerCharacters: ["@"],
106
+
107
+ async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
108
+ const items = isEnabled() ? mentionItems(roster(), lines[cursorLine] ?? "", cursorCol) : null;
109
+ if (items) return items;
110
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
111
+ },
112
+
113
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
114
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
115
+ },
116
+
117
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
118
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
119
+ },
120
+ };
121
+ }
122
+
123
+ /** Suggestions for the `@…` token under the cursor, or null when it names no agent. */
124
+ export function mentionItems(
125
+ roster: readonly MentionTarget[],
126
+ line: string,
127
+ cursorCol: number,
128
+ ): AutocompleteSuggestions | null {
129
+ const match = MENTION_TRIGGER.exec(line.slice(0, cursorCol));
130
+ if (!match) return null;
131
+
132
+ const typed = match[2].toLowerCase();
133
+ const items: AutocompleteItem[] = [];
134
+ for (const target of roster) {
135
+ if (!target.handle.toLowerCase().startsWith(typed)) continue;
136
+ items.push({ value: `@${target.handle}`, label: `@${target.handle}`, description: describeTarget(target) });
137
+ }
138
+ return items.length > 0 ? { items, prefix: `@${match[2]}` } : null;
139
+ }
140
+
141
+ /** Name the action that will actually happen, so the list never mispromises. */
142
+ function describeTarget(target: MentionTarget): string {
143
+ if (target.kind === "type") return `start agent · ${summarize(target.description)}`;
144
+ if (target.kind === "resumable") {
145
+ // No status: the record is gone, and "completed" would imply one is still
146
+ // being tracked. The type carries the identity the handle may not.
147
+ return `resume · ${sanitizeDisplayText(target.typeLabel)} · ${summarize(target.entry.description)}`;
148
+ }
149
+ const { status, description } = target.record;
150
+ const action = status === "running" || status === "queued" ? "send message" : "resume";
151
+ return `${action} · ${status} · ${summarize(description)}`;
152
+ }
153
+
154
+ /**
155
+ * First sentence of a description, sanitized then clipped — agent descriptions
156
+ * run to paragraphs, and they come from files this extension did not write.
157
+ * Sanitize BEFORE truncating: cutting first can sever an escape sequence and
158
+ * leave a live introducer behind.
159
+ */
160
+ function summarize(description: string): string {
161
+ const first = (description.match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? description).replace(/\s+/g, " ").trim();
162
+ return truncateCodePoints(sanitizeDisplayText(first), 60, "…");
163
+ }
@@ -7,11 +7,12 @@
7
7
 
8
8
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
9
9
  import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
+ import { renderAgentName } from "../agent-color.js";
10
11
  import { extractText } from "../context.js";
11
12
  import type { AgentRecordSnapshot } from "../types.js";
12
13
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
14
  import type { Theme } from "./agent-display.js";
14
- import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-display.js";
15
+ import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-display.js";
15
16
  import { PREVIEW_SCAN_LIMIT, safeTerminalText, sanitizeDisplayText, truncateCodePoints } from "./safe-text.js";
16
17
  import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
17
18
  import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
@@ -187,7 +188,7 @@ export class ConversationViewer implements Component {
187
188
 
188
189
  lines.push(rule());
189
190
  lines.push(row(th.bold("Agent conversation")));
190
- const name = getDisplayName(this.record.type);
191
+ const name = renderAgentName(this.record.type, th, { bold: true });
191
192
  const modeLabel = getPromptModeLabel(this.record.type);
192
193
  const modeTag = modeLabel ? th.fg("dim", `mode ${modeLabel}`) : undefined;
193
194
  const statusText = getAgentStatusLabel(this.record.status);
@@ -205,7 +206,7 @@ export class ConversationViewer implements Component {
205
206
 
206
207
  const headerParts = [
207
208
  th.fg(statusColor, `${getAgentStatusMark(this.record.status)} ${statusText}`),
208
- th.bold(name),
209
+ name,
209
210
  modeTag,
210
211
  th.fg("muted", sanitizeDisplayText(this.record.description)),
211
212
  fgPreservingNestedStyles(th, "dim", headerStats.join(" · ")),
@@ -12,10 +12,11 @@
12
12
  */
13
13
 
14
14
  import { Editor, isKeyRelease, Key, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { renderAgentName } from "../agent-color.js";
15
16
  import type { AgentManager } from "../agent-manager.js";
16
17
  import type { AgentRecord } from "../types.js";
17
18
  import { getLifetimeTotal } from "../usage.js";
18
- import { type AgentActivity, getDisplayName, type Theme } from "./agent-display.js";
19
+ import { type AgentActivity, type Theme } from "./agent-display.js";
19
20
  import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
20
21
  import { sanitizeDisplayText } from "./safe-text.js";
21
22
  import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
@@ -379,11 +380,11 @@ export class FleetList {
379
380
 
380
381
  private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
381
382
  const selected = rosterIndex === sel;
382
- const name = getDisplayName(record.type);
383
383
  const status = getAgentStatusLabel(record.status);
384
- const nameText = selected
385
- ? theme.bold(theme.fg("accent", name))
386
- : theme.fg("muted", name);
384
+ const nameText = renderAgentName(record.type, theme, {
385
+ fallbackColor: selected ? "accent" : "muted",
386
+ bold: selected,
387
+ });
387
388
  const description = sanitizeDisplayText(record.description);
388
389
  const descriptionText = selected ? theme.fg("accent", description) : description;
389
390
  const semanticStatusColor = getAgentStatusColor(record.status);