@giladbarnea/pi-user-agents 0.0.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.
@@ -0,0 +1,41 @@
1
+ import type { CompletedAgent, RunningAgent, Theme } from "./shared.js";
2
+
3
+ const CONTEXT_METER_SYMBOLS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
4
+ const CONTEXT_MUTED_THRESHOLD = 40;
5
+ const CONTEXT_WARNING_THRESHOLD = 65;
6
+ const CONTEXT_ERROR_THRESHOLD = 85;
7
+
8
+ type ContextMeterAgent = RunningAgent | CompletedAgent;
9
+ type ContextMeterColor = "dim" | "muted" | "warning" | "error";
10
+
11
+ /** @example contextMeterColor(85) // "error" */
12
+ export function contextMeterColor(percent: number): ContextMeterColor {
13
+ if (percent >= CONTEXT_ERROR_THRESHOLD) return "error";
14
+ if (percent >= CONTEXT_WARNING_THRESHOLD) return "warning";
15
+ if (percent >= CONTEXT_MUTED_THRESHOLD) return "muted";
16
+ return "dim";
17
+ }
18
+
19
+ export function renderContextMeter(
20
+ percent: number | null | undefined,
21
+ theme: Pick<Theme, "fg">,
22
+ ): string {
23
+ if (percent === null || percent === undefined) return "";
24
+ const boundedPercent = Math.max(0, Math.min(100, percent));
25
+ const level = Math.min(
26
+ CONTEXT_METER_SYMBOLS.length - 1,
27
+ Math.floor(boundedPercent / (100 / CONTEXT_METER_SYMBOLS.length)),
28
+ );
29
+ return theme.fg(contextMeterColor(boundedPercent), CONTEXT_METER_SYMBOLS[level]);
30
+ }
31
+
32
+ /** Render the current or final context usage for either agent state. */
33
+ export function renderAgentContextMeter(
34
+ agent: ContextMeterAgent,
35
+ theme: Pick<Theme, "fg">,
36
+ ): string {
37
+ const percent = "status" in agent
38
+ ? agent.session?.getContextUsage()?.percent
39
+ : agent.contextPercent;
40
+ return renderContextMeter(percent, theme);
41
+ }
@@ -0,0 +1,160 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import type { EditorComponent } from "@earendil-works/pi-tui";
3
+ import {
4
+ type AgentTokenSemantic,
5
+ type AgentValueValidator,
6
+ scanAgentCommandLine,
7
+ } from "./command-line.js";
8
+
9
+ /** The minimal theme surface the coloring layer consumes. */
10
+ export type StyleTheme = { fg(color: ThemeColor, text: string): string };
11
+
12
+ export const AGENT_SEMANTIC_COLORS: Record<AgentTokenSemantic | "command", ThemeColor> = {
13
+ command: "accent",
14
+ option: "syntaxKeyword",
15
+ value: "syntaxString",
16
+ "invalid-value": "error",
17
+ blocked: "error",
18
+ "misplaced-option": "error",
19
+ "misplaced-value": "error",
20
+ };
21
+
22
+ export type AgentColorSpan = { start: number; end: number; color: ThemeColor };
23
+
24
+ type LayoutLine = { text: string; hasCursor?: boolean; cursorPos?: number };
25
+ type ColoringRuntimeEditor = EditorComponent & {
26
+ getLines?: () => string[];
27
+ layoutText?: (contentWidth: number) => LayoutLine[];
28
+ };
29
+
30
+ const COLORED_EDITOR = Symbol.for("pi-user-agents:coloring-editor");
31
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
32
+
33
+ type ColoredEditor = ColoringRuntimeEditor & { [COLORED_EDITOR]?: true };
34
+
35
+ /**
36
+ * Map an editor line to theme-colored spans via the shared semantic scan.
37
+ * Returns undefined for lines that are not agent commands.
38
+ *
39
+ * @example computeAgentColorSpans("/agent -m gpt56s go")?.[1] // { start: 7, end: 9, color: "syntaxKeyword" }
40
+ */
41
+ export function computeAgentColorSpans(
42
+ line: string,
43
+ isValueValid?: AgentValueValidator,
44
+ ): AgentColorSpan[] | undefined {
45
+ const scan = scanAgentCommandLine(line, isValueValid);
46
+ if (!scan) return undefined;
47
+ const spans: AgentColorSpan[] = [
48
+ { start: 0, end: scan.commandEnd, color: AGENT_SEMANTIC_COLORS.command },
49
+ ];
50
+ for (const token of scan.tokens) {
51
+ if (token.end > token.start)
52
+ spans.push({
53
+ start: token.start,
54
+ end: token.end,
55
+ color: AGENT_SEMANTIC_COLORS[token.semantic],
56
+ });
57
+ }
58
+ return spans;
59
+ }
60
+
61
+ /**
62
+ * Patch the editor's layout pass to paint agent-command tokens with theme colors.
63
+ * Layout and wrapping run on the raw text first; styling only rewrites the laid-out
64
+ * chunk text (and remaps the cursor index) so editing behavior is untouched.
65
+ */
66
+ export function decorateColoringEditor<T extends EditorComponent>(
67
+ editor: T,
68
+ getTheme: () => StyleTheme,
69
+ isValueValid?: AgentValueValidator,
70
+ ): T {
71
+ const runtimeEditor = editor as T & ColoredEditor;
72
+ if (runtimeEditor[COLORED_EDITOR]) return editor;
73
+ const layoutText = runtimeEditor.layoutText;
74
+ const getLines = runtimeEditor.getLines;
75
+ if (typeof layoutText !== "function" || typeof getLines !== "function")
76
+ throw new Error("pi-user-agents coloring requires editor.layoutText() and editor.getLines()");
77
+ runtimeEditor.layoutText = (contentWidth: number): LayoutLine[] => {
78
+ const layoutLines = layoutText.call(editor, contentWidth);
79
+ const line = getLines.call(editor)[0] ?? "";
80
+ const spans = computeAgentColorSpans(line, isValueValid);
81
+ if (spans === undefined) return layoutLines;
82
+ const theme = getTheme();
83
+ let offset = 0;
84
+ for (const layoutLine of layoutLines) {
85
+ const rawLength = layoutLine.text.length;
86
+ const styled = styleLayoutSlice(
87
+ layoutLine.text,
88
+ offset,
89
+ spans,
90
+ theme,
91
+ layoutLine.hasCursor ? layoutLine.cursorPos : undefined,
92
+ );
93
+ layoutLine.text = styled.text;
94
+ if (styled.cursorPos !== undefined) layoutLine.cursorPos = styled.cursorPos;
95
+ offset += rawLength;
96
+ if (offset >= line.length) break;
97
+ }
98
+ return layoutLines;
99
+ };
100
+ runtimeEditor[COLORED_EDITOR] = true;
101
+ return editor;
102
+ }
103
+
104
+ /**
105
+ * Paint one laid-out chunk of the first logical line. The cursor index is remapped to
106
+ * the styled string and its grapheme is left unpainted, because the editor renders the
107
+ * cursor by inverse-videoing the first grapheme found at that index.
108
+ */
109
+ function styleLayoutSlice(
110
+ text: string,
111
+ sliceStart: number,
112
+ spans: readonly AgentColorSpan[],
113
+ theme: StyleTheme,
114
+ cursorPos: number | undefined,
115
+ ): { text: string; cursorPos?: number } {
116
+ let styled = "";
117
+ let styledCursorPos: number | undefined;
118
+ let index = 0;
119
+
120
+ const paint = (segment: string, color: ThemeColor | undefined): void => {
121
+ if (segment === "") return;
122
+ styled += color === undefined ? segment : theme.fg(color, segment);
123
+ };
124
+ const emitRun = (runEnd: number, color: ThemeColor | undefined): void => {
125
+ while (index < runEnd) {
126
+ if (
127
+ cursorPos !== undefined &&
128
+ styledCursorPos === undefined &&
129
+ cursorPos >= index &&
130
+ cursorPos < runEnd
131
+ ) {
132
+ paint(text.slice(index, cursorPos), color);
133
+ styledCursorPos = styled.length;
134
+ const grapheme = firstGrapheme(text.slice(cursorPos, runEnd));
135
+ styled += grapheme;
136
+ index = cursorPos + grapheme.length;
137
+ continue;
138
+ }
139
+ paint(text.slice(index, runEnd), color);
140
+ index = runEnd;
141
+ }
142
+ };
143
+
144
+ const sliceEnd = sliceStart + text.length;
145
+ for (const span of spans) {
146
+ if (span.end <= sliceStart || span.start >= sliceEnd) continue;
147
+ const localStart = Math.max(span.start - sliceStart, 0);
148
+ const localEnd = Math.min(span.end - sliceStart, text.length);
149
+ emitRun(localStart, undefined);
150
+ emitRun(localEnd, span.color);
151
+ }
152
+ emitRun(text.length, undefined);
153
+ if (cursorPos !== undefined && styledCursorPos === undefined) styledCursorPos = styled.length;
154
+ return { text: styled, cursorPos: styledCursorPos };
155
+ }
156
+
157
+ function firstGrapheme(text: string): string {
158
+ for (const segment of graphemeSegmenter.segment(text)) return segment.segment;
159
+ return "";
160
+ }
package/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type {
2
+ MessageEndEvent,
3
+ MessageEndEventResult,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { registerAgentAutocomplete } from "./autocomplete.js";
6
+ import { handleAgentCommand } from "./runner.js";
7
+ import type { AgentCommandDetails, ExtensionAPI, RunningAgent } from "./shared.js";
8
+ import { MESSAGE_TYPE } from "./shared.js";
9
+ import { registerUserAgentRenderer } from "./transcript.js";
10
+ import { UserAgentWidget } from "./widget.js";
11
+
12
+ export default function userAgent(pi: ExtensionAPI): void {
13
+ const runningAgents = new Set<RunningAgent>();
14
+ const widget = new UserAgentWidget(runningAgents, (message) =>
15
+ pi.sendMessage(message, { triggerTurn: true }),
16
+ );
17
+ let shuttingDown = false;
18
+ let nextAgentNumber = 0;
19
+
20
+ registerUserAgentRenderer(pi);
21
+ registerAgentAutocomplete(pi);
22
+
23
+ pi.on("message_end", (event) => confirmMainContextJoin(event, widget));
24
+
25
+ pi.registerCommand("agent", {
26
+ description:
27
+ "Run a background SDK agent with current session context; forwards pi CLI options like --thinking (-i/--isolate for none)",
28
+ handler: async (args, ctx) => {
29
+ await handleAgentCommand(
30
+ pi,
31
+ runningAgents,
32
+ widget,
33
+ () => shuttingDown,
34
+ () => ++nextAgentNumber,
35
+ "agent",
36
+ args,
37
+ `/agent ${args}`.trim(),
38
+ ctx,
39
+ );
40
+ },
41
+ });
42
+
43
+ pi.on("session_shutdown", async () => {
44
+ shuttingDown = true;
45
+ widget.dispose();
46
+ const agents = [...runningAgents];
47
+ for (const agent of agents) {
48
+ void agent.session?.abort();
49
+ agent.retire?.();
50
+ }
51
+ await Promise.allSettled(agents.map((agent) => agent.finished));
52
+ for (const agent of agents) {
53
+ agent.session?.dispose();
54
+ }
55
+ runningAgents.clear();
56
+ });
57
+ }
58
+
59
+ export function confirmMainContextJoin(
60
+ event: MessageEndEvent,
61
+ widget: UserAgentWidget,
62
+ ): MessageEndEventResult | undefined {
63
+ if (event.message.role !== "custom" || event.message.customType !== MESSAGE_TYPE) return;
64
+ const details = event.message.details as AgentCommandDetails | undefined;
65
+ if (!details?.agentId || details.mainContextState !== "will-join") return;
66
+ widget.confirmMainContextJoin(details.agentId);
67
+ details.mainContextState = "joined";
68
+ return {
69
+ message: {
70
+ ...event.message,
71
+ details: { ...details, mainContextState: "joined" },
72
+ },
73
+ };
74
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@giladbarnea/pi-user-agents",
3
+ "version": "0.0.0",
4
+ "description": "Run, view, and control background Pi agents.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-agent",
9
+ "pi-coding-agent",
10
+ "background-agents",
11
+ "multi-agent"
12
+ ],
13
+ "author": "Gilad Barnea",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/giladbarnea/pi-user-agents.git"
17
+ },
18
+ "homepage": "https://github.com/giladbarnea/pi-user-agents",
19
+ "bugs": {
20
+ "url": "https://github.com/giladbarnea/pi-user-agents/issues"
21
+ },
22
+ "files": [
23
+ "*.ts",
24
+ "tool-render",
25
+ "README.md",
26
+ "PARSER_SPEC.md",
27
+ "INTERNALS.md"
28
+ ],
29
+ "scripts": {
30
+ "test": "bun test"
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-coding-agent": "*",
34
+ "@earendil-works/pi-tui": "*"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "pi": {
40
+ "extensions": [
41
+ "./index.ts"
42
+ ]
43
+ }
44
+ }