pi-extended-teams 2.1.16 → 2.1.18

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/README.md CHANGED
@@ -10,7 +10,13 @@ Read agents are the default. Edit agents are opt-in and should own isolated file
10
10
 
11
11
  ## Install and run
12
12
 
13
- Install from GitHub:
13
+ Install from npm:
14
+
15
+ ```bash
16
+ pi install npm:pi-extended-teams
17
+ ```
18
+
19
+ Or install directly from GitHub:
14
20
 
15
21
  ```bash
16
22
  pi install git:github.com/dantetekanem/pi-extended-teams
@@ -496,12 +496,13 @@ export default function (pi: ExtensionAPI) {
496
496
  teamActivityWidgetMounted = true;
497
497
  sessionCtx.ui.setWidget(
498
498
  "01-pi-extended-teams-readers",
499
- (tui: any) => {
499
+ (tui: any, theme: any) => {
500
500
  teamActivityWidgetTui = tui;
501
501
  return teamActivityStatusWidget(
502
502
  () => teamActivityStatusSnapshot,
503
503
  isTeamActivityExpanded,
504
- () => tui.requestRender?.()
504
+ () => tui.requestRender?.(),
505
+ theme
505
506
  );
506
507
  },
507
508
  { placement: "belowEditor" }
@@ -10,6 +10,29 @@ function stripAnsi(text: string): string {
10
10
  return text.replace(ANSI_SGR_SEQUENCE, "");
11
11
  }
12
12
 
13
+ function makeTheme() {
14
+ const foregroundCodes: Record<string, number> = {
15
+ accent: 213,
16
+ border: 141,
17
+ borderAccent: 141,
18
+ customMessageLabel: 213,
19
+ dim: 247,
20
+ error: 210,
21
+ muted: 247,
22
+ success: 114,
23
+ syntaxFunction: 117,
24
+ syntaxString: 213,
25
+ text: 253,
26
+ thinkingText: 141,
27
+ warning: 222,
28
+ };
29
+ return {
30
+ fg: vi.fn((token: string, text: string) => `\x1b[38;5;${foregroundCodes[token] ?? 253}m${text}\x1b[39m`),
31
+ bg: vi.fn((_token: string, text: string) => `\x1b[48;5;235m${text}\x1b[49m`),
32
+ bold: vi.fn((text: string) => `\x1b[1m${text}\x1b[22m`),
33
+ };
34
+ }
35
+
13
36
  function makeAgent(overrides: Partial<RunningReadAgent> = {}): RunningReadAgent {
14
37
  return {
15
38
  runId: "run-1",
@@ -223,7 +246,8 @@ describe("agent follow transcript", () => {
223
246
  expect(lines.join("\n")).not.toContain("complete final report body");
224
247
  });
225
248
 
226
- it("uses semantic chrome colors while leaving prose and raw output neutral", () => {
249
+ it("uses Pi theme tokens for semantic chrome while leaving prose and raw output neutral", () => {
250
+ const theme = makeTheme();
227
251
  const lines = formatAgentFollowTranscript([
228
252
  { role: "assistant", content: [
229
253
  { type: "thinking", thinking: "Neutral thinking prose" },
@@ -243,7 +267,7 @@ describe("agent follow transcript", () => {
243
267
  },
244
268
  { role: "toolResult", toolCallId: "edit-failure", toolName: "edit", content: "Edit failed", isError: true },
245
269
  { role: "toolResult", toolCallId: "bash-raw", toolName: "bash", content: "neutral shell output", isError: false },
246
- ], { width: 80 });
270
+ ], { width: 80, theme });
247
271
 
248
272
  const pendingHeader = lines.find((line) => stripAnsi(line).startsWith("read")) || "";
249
273
  const pendingState = lines.find((line) => stripAnsi(line).includes("waiting for result")) || "";
@@ -261,6 +285,12 @@ describe("agent follow transcript", () => {
261
285
  expect(rawOutput).toBe("\x1b[38;5;141m│\x1b[39m neutral shell output");
262
286
  expect(lines).toContain("Neutral thinking prose");
263
287
  expect(lines).toContain("Neutral assistant prose");
288
+ expect(theme.fg).toHaveBeenCalledWith("syntaxFunction", "read");
289
+ expect(theme.fg).toHaveBeenCalledWith("syntaxString", "src/pending.ts");
290
+ expect(theme.fg).toHaveBeenCalledWith("warning", "waiting for result…");
291
+ expect(theme.fg).toHaveBeenCalledWith("success", "+2");
292
+ expect(theme.fg).toHaveBeenCalledWith("error", "−1");
293
+ expect(theme.fg).toHaveBeenCalledWith("borderAccent", "│");
264
294
  });
265
295
 
266
296
  it("keeps claim and release tools compact and semantically colored", () => {
@@ -285,7 +315,7 @@ describe("agent follow transcript", () => {
285
315
  details: { released: ["src/a.ts"] },
286
316
  isError: false,
287
317
  },
288
- ], { width: 80 });
318
+ ], { width: 80, theme: makeTheme() });
289
319
 
290
320
  expect(lines.map(stripAnsi)).toEqual([
291
321
  "claim · src/a.ts, src/b.ts · ✓",
@@ -367,6 +397,7 @@ describe("agent follow component", () => {
367
397
  let contextPercent = 23;
368
398
  const done = vi.fn();
369
399
  const tui = { terminal: { rows: 30 }, requestRender: vi.fn() };
400
+ const theme = makeTheme();
370
401
  const agent = makeAgent({
371
402
  session: {
372
403
  messages: [{ role: "assistant", content: [{ type: "text", text: "Working now" }] }],
@@ -376,15 +407,17 @@ describe("agent follow component", () => {
376
407
  }),
377
408
  } as any,
378
409
  });
379
- const component = createAgentFollowComponent(tui, done, { getAgents: () => [agent] });
410
+ const component = createAgentFollowComponent(tui, done, { getAgents: () => [agent] }, theme);
380
411
 
381
412
  const first = component.render(140).join("\n");
382
413
  expect(first).toMatch(/\(reader\) gpt-model\/high · reading-default · 1m00s · 46k tok \(23%\) · Verifying assumptions\.{1,3}/);
383
414
  expect(first).not.toContain("502k tok");
384
415
  expect(first).toContain("Working now");
385
416
  expect(stripAnsi(first)).not.toContain("progress:");
386
- expect(first).toContain("\x1b[48;2;22;23;32m");
387
- expect(first).not.toContain("\x1b[48;5;235m");
417
+ expect(theme.bg).toHaveBeenCalledWith("customMessageBg", expect.any(String));
418
+ expect(theme.fg).toHaveBeenCalledWith("borderAccent", expect.any(String));
419
+ expect(first).toContain("\x1b[48;5;235m");
420
+ expect(first).not.toContain("\x1b[48;2;22;23;32m");
388
421
 
389
422
  billedTokens = 2_300_000;
390
423
  contextTokens = 80_000;
@@ -400,6 +433,31 @@ describe("agent follow component", () => {
400
433
  component.dispose();
401
434
  });
402
435
 
436
+ it("rebuilds cached themed content when Pi invalidates the component", () => {
437
+ const tui = { terminal: { rows: 20 }, requestRender: vi.fn() };
438
+ let color = 31;
439
+ const theme = {
440
+ fg: vi.fn((_token: string, text: string) => `\x1b[${color}m${text}\x1b[39m`),
441
+ bg: vi.fn((_token: string, text: string) => text),
442
+ bold: vi.fn((text: string) => text),
443
+ };
444
+ const agent = makeAgent({
445
+ session: {
446
+ messages: [{ role: "assistant", content: [{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "README.md" } }] }],
447
+ } as any,
448
+ });
449
+ const component = createAgentFollowComponent(tui, vi.fn(), { getAgents: () => [agent] }, theme);
450
+
451
+ expect(component.render(100).join("\n")).toContain("\x1b[31mread");
452
+ color = 32;
453
+ component.invalidate();
454
+ const rerendered = component.render(100).join("\n");
455
+
456
+ expect(rerendered).toContain("\x1b[32mread");
457
+ expect(rerendered).not.toContain("\x1b[31mread");
458
+ component.dispose();
459
+ });
460
+
403
461
  it("uses the ordinary agent status when no stored progress is present", () => {
404
462
  const tui = { terminal: { rows: 20 }, requestRender: vi.fn() };
405
463
  const agent = makeAgent({ latestProgress: undefined, status: "working" });
@@ -1,23 +1,12 @@
1
1
  import { Input, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
2
2
  import type { RunningReadAgent } from "../runtime/types";
3
3
  import { initialContextUsage } from "../../src/utils/runtime";
4
- import { dimAnsi, pink, purple } from "./ansi";
5
- import { createFramePanelRowRenderer, framePanel } from "./frame";
4
+ import { createFramePanelRowRenderer, framePanel, type FramePanelStyle } from "./frame";
6
5
  import { extractTextParts, formatAnimatedProgress, formatContextUsage, formatElapsed, formatModelLabel, sanitizePlainTuiLine, sanitizeTuiLine, sanitizeTuiText } from "./renderers";
6
+ import { resolveExtendedTeamsTheme, type ExtendedTeamsForegroundToken, type ExtendedTeamsTheme } from "./theme";
7
7
 
8
8
  const REFRESH_INTERVAL_MS = 250;
9
9
  const MAX_NAVIGATION_AGENTS = 6;
10
- const AGENT_FOLLOW_BACKGROUND = "\x1b[48;2;22;23;32m";
11
- const ACTION_FOREGROUND = "\x1b[38;5;117m";
12
- const PATH_FOREGROUND = "\x1b[38;5;213m";
13
- const SUCCESS_FOREGROUND = "\x1b[38;5;114m";
14
- const FAILURE_FOREGROUND = "\x1b[38;5;210m";
15
- const PENDING_FOREGROUND = "\x1b[38;5;222m";
16
- const BODY_FOREGROUND = "\x1b[38;5;253m";
17
- const MUTED_FOREGROUND = "\x1b[38;5;247m";
18
- const STRUCTURAL_FOREGROUND = "\x1b[38;5;141m";
19
- const ANSI_FOREGROUND_RESET = "\x1b[39m";
20
- const ANSI_RESET = "\x1b[0m";
21
10
  const COLLAPSED_TOOL_RESULT_LINE_LIMIT = 14;
22
11
  const COLLAPSED_TOOL_RESULT_HEAD_LINES = 8;
23
12
  const COLLAPSED_TOOL_RESULT_TAIL_LINES = 3;
@@ -32,6 +21,7 @@ export interface AgentFollowViewOptions {
32
21
  export interface AgentFollowTranscriptOptions {
33
22
  expandLargeToolResults?: boolean;
34
23
  width?: number;
24
+ theme?: ExtendedTeamsTheme;
35
25
  }
36
26
 
37
27
  type TranscriptBlock =
@@ -78,40 +68,40 @@ function compactTranscriptLine(text: string): string {
78
68
  return sanitizePlainTuiLine(text).replace(/\s+/g, " ").trim();
79
69
  }
80
70
 
81
- function semanticAnsi(text: string, foreground: string): string {
82
- return `${foreground}${text}${ANSI_FOREGROUND_RESET}`;
71
+ function themed(theme: ExtendedTeamsTheme, token: ExtendedTeamsForegroundToken, text: string): string {
72
+ return theme.fg(token, text);
83
73
  }
84
74
 
85
- function actionAnsi(text: string): string {
86
- return semanticAnsi(text, ACTION_FOREGROUND);
75
+ function actionText(theme: ExtendedTeamsTheme, text: string): string {
76
+ return themed(theme, "syntaxFunction", text);
87
77
  }
88
78
 
89
- function pathAnsi(text: string): string {
90
- return semanticAnsi(text, PATH_FOREGROUND);
79
+ function pathText(theme: ExtendedTeamsTheme, text: string): string {
80
+ return themed(theme, "syntaxString", text);
91
81
  }
92
82
 
93
- function successAnsi(text: string): string {
94
- return semanticAnsi(text, SUCCESS_FOREGROUND);
83
+ function successText(theme: ExtendedTeamsTheme, text: string): string {
84
+ return themed(theme, "success", text);
95
85
  }
96
86
 
97
- function failureAnsi(text: string): string {
98
- return semanticAnsi(text, FAILURE_FOREGROUND);
87
+ function failureText(theme: ExtendedTeamsTheme, text: string): string {
88
+ return themed(theme, "error", text);
99
89
  }
100
90
 
101
- function pendingAnsi(text: string): string {
102
- return semanticAnsi(text, PENDING_FOREGROUND);
91
+ function pendingText(theme: ExtendedTeamsTheme, text: string): string {
92
+ return themed(theme, "warning", text);
103
93
  }
104
94
 
105
- function bodyAnsi(text: string): string {
106
- return semanticAnsi(text, BODY_FOREGROUND);
95
+ function bodyText(theme: ExtendedTeamsTheme, text: string): string {
96
+ return themed(theme, "text", text);
107
97
  }
108
98
 
109
- function mutedAnsi(text: string): string {
110
- return semanticAnsi(text, MUTED_FOREGROUND);
99
+ function mutedText(theme: ExtendedTeamsTheme, text: string): string {
100
+ return themed(theme, "muted", text);
111
101
  }
112
102
 
113
- function structuralAnsi(text: string): string {
114
- return semanticAnsi(text, STRUCTURAL_FOREGROUND);
103
+ function structuralText(theme: ExtendedTeamsTheme, text: string): string {
104
+ return themed(theme, "borderAccent", text);
115
105
  }
116
106
 
117
107
  function boundTranscriptLine(line: string, width?: number): string {
@@ -146,26 +136,26 @@ function editDiffCounts(details: unknown): { added: number; removed: number } |
146
136
  return { added, removed };
147
137
  }
148
138
 
149
- function renderState(state: string): string {
150
- if (state === "failed") return failureAnsi(state);
151
- if (state === "working" || state === "submitting") return pendingAnsi(state);
152
- if (state === "duplicate") return mutedAnsi(state);
153
- return successAnsi(state);
139
+ function renderState(theme: ExtendedTeamsTheme, state: string): string {
140
+ if (state === "failed") return failureText(theme, state);
141
+ if (state === "working" || state === "submitting") return pendingText(theme, state);
142
+ if (state === "duplicate") return mutedText(theme, state);
143
+ return successText(theme, state);
154
144
  }
155
145
 
156
- function renderActionPathState(action: string, path: string, state: string, width?: number): string {
146
+ function renderActionPathState(theme: ExtendedTeamsTheme, action: string, path: string, state: string, width?: number): string {
157
147
  const renderedState = state === "failed"
158
- ? failureAnsi("✗")
148
+ ? failureText(theme, "✗")
159
149
  : state === "succeeded"
160
- ? successAnsi("✓")
161
- : renderState(state);
150
+ ? successText(theme, "✓")
151
+ : renderState(theme, state);
162
152
  return boundTranscriptLine(
163
- `${actionAnsi(action)}${mutedAnsi(" · ")}${pathAnsi(path)}${mutedAnsi(" · ")}${renderedState}`,
153
+ `${actionText(theme, action)}${mutedText(theme, " · ")}${pathText(theme, path)}${mutedText(theme, " · ")}${renderedState}`,
164
154
  width,
165
155
  );
166
156
  }
167
157
 
168
- function renderCompactToolBlock(block: Extract<TranscriptBlock, { kind: "tool" }>, width?: number): string[] | undefined {
158
+ function renderCompactToolBlock(theme: ExtendedTeamsTheme, block: Extract<TranscriptBlock, { kind: "tool" }>, width?: number): string[] | undefined {
169
159
  if (block.name === "report_progress") {
170
160
  const details = asRecord(block.details);
171
161
  const args = asRecord(block.args);
@@ -178,20 +168,20 @@ function renderCompactToolBlock(block: Extract<TranscriptBlock, { kind: "tool" }
178
168
 
179
169
  if (block.name === "edit") {
180
170
  const path = toolPath(block.args);
181
- if (block.result === undefined) return [renderActionPathState("edit", path, "working", width)];
182
- if (block.isError) return [renderActionPathState("edit", path, "failed", width)];
171
+ if (block.result === undefined) return [renderActionPathState(theme, "edit", path, "working", width)];
172
+ if (block.isError) return [renderActionPathState(theme, "edit", path, "failed", width)];
183
173
  const counts = editDiffCounts(block.details);
184
174
  const added = counts ? `+${counts.added}` : "+?";
185
175
  const removed = counts ? `−${counts.removed}` : "−?";
186
176
  return [boundTranscriptLine(
187
- `${actionAnsi("edit")}${mutedAnsi(" · ")}${pathAnsi(path)}${mutedAnsi(" · ")}${successAnsi(added)} ${failureAnsi(removed)}${mutedAnsi(" · ")}${successAnsi("✓")}`,
177
+ `${actionText(theme, "edit")}${mutedText(theme, " · ")}${pathText(theme, path)}${mutedText(theme, " · ")}${successText(theme, added)} ${failureText(theme, removed)}${mutedText(theme, " · ")}${successText(theme, "✓")}`,
188
178
  width,
189
179
  )];
190
180
  }
191
181
 
192
182
  if (block.name === "write") {
193
183
  const state = block.result === undefined ? "working" : block.isError ? "failed" : "succeeded";
194
- return [renderActionPathState("write", toolPath(block.args), state, width)];
184
+ return [renderActionPathState(theme, "write", toolPath(block.args), state, width)];
195
185
  }
196
186
 
197
187
  if (block.name === "claim_file" || block.name === "release_file") {
@@ -203,7 +193,7 @@ function renderCompactToolBlock(block: Extract<TranscriptBlock, { kind: "tool" }
203
193
  ? "failed"
204
194
  : "succeeded";
205
195
  const action = block.name === "claim_file" ? "claim" : "release";
206
- return [renderActionPathState(action, toolPaths(block.args), state, width)];
196
+ return [renderActionPathState(theme, action, toolPaths(block.args), state, width)];
207
197
  }
208
198
 
209
199
  if (block.name === "report_and_exit") {
@@ -215,27 +205,27 @@ function renderCompactToolBlock(block: Extract<TranscriptBlock, { kind: "tool" }
215
205
  : accepted === false
216
206
  ? "duplicate"
217
207
  : "accepted";
218
- return [boundTranscriptLine(`${actionAnsi("final report")}${mutedAnsi(" · ")}${renderState(state)}`, width)];
208
+ return [boundTranscriptLine(`${actionText(theme, "final report")}${mutedText(theme, " · ")}${renderState(theme, state)}`, width)];
219
209
  }
220
210
 
221
211
  return undefined;
222
212
  }
223
213
 
224
- function renderToolHeader(block: Extract<TranscriptBlock, { kind: "tool" }>): string {
214
+ function renderToolHeader(theme: ExtendedTeamsTheme, block: Extract<TranscriptBlock, { kind: "tool" }>): string {
225
215
  const detail = compactToolArgs(block.name, block.args);
226
- if (!detail) return actionAnsi(block.name);
216
+ if (!detail) return actionText(theme, block.name);
227
217
  const isPath = typeof asRecord(block.args)?.path === "string";
228
- const renderedDetail = isPath ? pathAnsi(detail) : bodyAnsi(`${block.name === "bash" ? "$ " : ""}${detail}`);
229
- return `${actionAnsi(block.name)}${mutedAnsi(" · ")}${renderedDetail}`;
218
+ const renderedDetail = isPath ? pathText(theme, detail) : bodyText(theme, `${block.name === "bash" ? "$ " : ""}${detail}`);
219
+ return `${actionText(theme, block.name)}${mutedText(theme, " · ")}${renderedDetail}`;
230
220
  }
231
221
 
232
- function renderToolBlock(block: Extract<TranscriptBlock, { kind: "tool" }>, expandLargeToolResults: boolean, width?: number): string[] {
233
- const compactBlock = renderCompactToolBlock(block, width);
222
+ function renderToolBlock(theme: ExtendedTeamsTheme, block: Extract<TranscriptBlock, { kind: "tool" }>, expandLargeToolResults: boolean, width?: number): string[] {
223
+ const compactBlock = renderCompactToolBlock(theme, block, width);
234
224
  if (compactBlock) return compactBlock;
235
225
 
236
- const header = renderToolHeader(block);
226
+ const header = renderToolHeader(theme, block);
237
227
  if (block.result === undefined) {
238
- return [header, `${structuralAnsi("│")} ${pendingAnsi("waiting for result…")}`, `${structuralAnsi("╰─")} ${pendingAnsi("running")}`, ""];
228
+ return [header, `${structuralText(theme, "│")} ${pendingText(theme, "waiting for result…")}`, `${structuralText(theme, "╰─")} ${pendingText(theme, "running")}`, ""];
239
229
  }
240
230
 
241
231
  const result = block.result || "(no output)";
@@ -253,14 +243,15 @@ function renderToolBlock(block: Extract<TranscriptBlock, { kind: "tool" }>, expa
253
243
  ? line
254
244
  : truncateToWidth(line, resultLineWidth, "…"));
255
245
  const body = boundedLines.map((line, index) => isCollapsed && index === COLLAPSED_TOOL_RESULT_HEAD_LINES
256
- ? `${structuralAnsi("│")} ${mutedAnsi(line)}`
257
- : `${structuralAnsi("│")} ${line}`);
246
+ ? `${structuralText(theme, "│")} ${mutedText(theme, line)}`
247
+ : `${structuralText(theme, "│")} ${line}`);
258
248
  const summary = `${resultLines.length} line${resultLines.length === 1 ? "" : "s"} · ${formatResultSize(result)}${isCollapsed ? " · collapsed" : ""}`;
259
- const renderedSummary = block.isError ? failureAnsi(summary) : successAnsi(summary);
260
- return [header, ...body, `${structuralAnsi("╰─")} ${renderedSummary}`, ""];
249
+ const renderedSummary = block.isError ? failureText(theme, summary) : successText(theme, summary);
250
+ return [header, ...body, `${structuralText(theme, "╰─")} ${renderedSummary}`, ""];
261
251
  }
262
252
 
263
253
  export function formatAgentFollowTranscript(messages: any[], options: AgentFollowTranscriptOptions = {}): string[] {
254
+ const theme = resolveExtendedTeamsTheme(options.theme);
264
255
  const blocks: TranscriptBlock[] = [];
265
256
  const toolsById = new Map<string, Extract<TranscriptBlock, { kind: "tool" }>>();
266
257
 
@@ -311,14 +302,15 @@ export function formatAgentFollowTranscript(messages: any[], options: AgentFollo
311
302
 
312
303
  const lines = blocks.flatMap(block => {
313
304
  if (block.kind === "tool") {
314
- return renderToolBlock(block, options.expandLargeToolResults === true, options.width);
305
+ return renderToolBlock(theme, block, options.expandLargeToolResults === true, options.width);
315
306
  }
316
307
  if (block.label === "thinking") {
317
- return [purple(block.label), block.text.replace(/\*\*/g, ""), ""];
308
+ return [theme.fg("thinkingText", block.label), block.text.replace(/\*\*/g, ""), ""];
318
309
  }
319
- return [block.label === "user" ? pink(block.label) : purple(block.label), block.text, ""];
310
+ const labelToken = block.label === "user" ? "customMessageLabel" : "accent";
311
+ return [theme.fg(labelToken, block.label), block.text, ""];
320
312
  });
321
- return lines.length > 0 ? lines : [dimAnsi("Waiting for the agent's first transcript event…")];
313
+ return lines.length > 0 ? lines : [theme.fg("dim", "Waiting for the agent's first transcript event…")];
322
314
  }
323
315
 
324
316
  function currentAgent(agents: RunningReadAgent[], selectedName: string | undefined): RunningReadAgent | undefined {
@@ -328,8 +320,14 @@ function currentAgent(agents: RunningReadAgent[], selectedName: string | undefin
328
320
  export function createAgentFollowComponent(
329
321
  tui: any,
330
322
  done: () => void,
331
- options: AgentFollowViewOptions
323
+ options: AgentFollowViewOptions,
324
+ providedTheme?: ExtendedTeamsTheme
332
325
  ) {
326
+ const theme = resolveExtendedTeamsTheme(providedTheme);
327
+ const frameStyle: FramePanelStyle = {
328
+ border: (text) => theme.fg("borderAccent", text),
329
+ background: (text) => theme.bg("customMessageBg", text),
330
+ };
333
331
  let selectedName = options.initialAgentName;
334
332
  let offsetFromBottom = 0;
335
333
  let lastBodyHeight = 10;
@@ -501,32 +499,32 @@ export function createAgentFollowComponent(
501
499
  if (!agent) {
502
500
  const emptyBodyHeight = Math.max(4, terminalRows - 6);
503
501
  return framePanel([
504
- pink("agent navigation"),
505
- purple("↑ main agent"),
506
- dimAnsi("No active agents. Press ↑ or esc to return to main."),
502
+ theme.fg("accent", "agent navigation"),
503
+ theme.fg("borderAccent", "↑ main agent"),
504
+ theme.fg("dim", "No active agents. Press ↑ or esc to return to main."),
507
505
  ...Array.from({ length: emptyBodyHeight }, () => ""),
508
- ], innerWidth, AGENT_FOLLOW_BACKGROUND);
506
+ ], innerWidth, frameStyle);
509
507
  }
510
508
 
511
509
  const selectedIndex = Math.max(0, agents.findIndex(item => item.name === agent.name));
512
510
  const navigationStart = Math.max(0, Math.min(selectedIndex - 2, agents.length - MAX_NAVIGATION_AGENTS));
513
511
  const visibleAgents = agents.slice(navigationStart, navigationStart + MAX_NAVIGATION_AGENTS);
514
- const navigationLines = [pink("agent navigation"), purple("↑ main agent")];
515
- if (navigationStart > 0) navigationLines.push(dimAnsi(` … ${navigationStart} agent${navigationStart === 1 ? "" : "s"} above`));
512
+ const navigationLines = [theme.fg("accent", "agent navigation"), theme.fg("borderAccent", "↑ main agent")];
513
+ if (navigationStart > 0) navigationLines.push(theme.fg("dim", ` … ${navigationStart} agent${navigationStart === 1 ? "" : "s"} above`));
516
514
  for (const item of visibleAgents) {
517
515
  const selected = item.name === agent.name;
518
- navigationLines.push(`${selected ? pink("->") : " "} ${item.name}`);
516
+ navigationLines.push(`${selected ? theme.fg("accent", "->") : " "} ${item.name}`);
519
517
  }
520
518
  const remainingAgents = agents.length - navigationStart - visibleAgents.length;
521
- if (remainingAgents > 0) navigationLines.push(dimAnsi(`↓ … ${remainingAgents} more agent${remainingAgents === 1 ? "" : "s"}`));
519
+ if (remainingAgents > 0) navigationLines.push(theme.fg("dim", `↓ … ${remainingAgents} more agent${remainingAgents === 1 ? "" : "s"}`));
522
520
 
523
521
  const messageLines = options.sendMessage ? [
524
- purple("─".repeat(innerWidth)),
525
- composingMessage ? pink(`message ${agent.name}`) : dimAnsi(`message ${agent.name}`),
526
- ...(composingMessage ? messageInput.render(innerWidth) : [dimAnsi("> Press m to start typing")]),
522
+ theme.fg("border", "─".repeat(innerWidth)),
523
+ composingMessage ? theme.fg("accent", `message ${agent.name}`) : theme.fg("dim", `message ${agent.name}`),
524
+ ...(composingMessage ? messageInput.render(innerWidth) : [theme.fg("dim", "> Press m to start typing")]),
527
525
  ...(messageStatus
528
- ? [dimAnsi(messageStatus)]
529
- : composingMessage ? [dimAnsi("enter send · esc cancel")] : []),
526
+ ? [theme.fg("dim", messageStatus)]
527
+ : composingMessage ? [theme.fg("dim", "enter send · esc cancel")] : []),
530
528
  ] : [];
531
529
  const bodyHeight = Math.max(4, terminalRows - navigationLines.length - 6 - messageLines.length);
532
530
  lastBodyHeight = bodyHeight;
@@ -611,6 +609,7 @@ export function createAgentFollowComponent(
611
609
  cachedTranscriptLines = formatAgentFollowTranscript(currentMessages, {
612
610
  expandLargeToolResults,
613
611
  width: currentTranscriptWidth,
612
+ theme,
614
613
  }).flatMap(line => wrapTextWithAnsi(line, currentTranscriptWidth));
615
614
  }
616
615
  const transcriptLines = cachedTranscriptLines;
@@ -623,10 +622,10 @@ export function createAgentFollowComponent(
623
622
 
624
623
  const frameContent = [
625
624
  ...navigationLines,
626
- purple("─".repeat(innerWidth)),
625
+ theme.fg("border", "─".repeat(innerWidth)),
627
626
  headline,
628
- dimAnsi(help),
629
- purple("─".repeat(innerWidth)),
627
+ theme.fg("dim", help),
628
+ theme.fg("border", "─".repeat(innerWidth)),
630
629
  ...visible,
631
630
  ...messageLines,
632
631
  ];
@@ -670,8 +669,8 @@ export function createAgentFollowComponent(
670
669
  }
671
670
  }
672
671
  if (!renderedFrame) {
673
- renderedFrame = framePanel(frameContent, innerWidth, AGENT_FOLLOW_BACKGROUND);
674
- cachedFrameRowRenderer = createFramePanelRowRenderer(innerWidth, AGENT_FOLLOW_BACKGROUND);
672
+ renderedFrame = framePanel(frameContent, innerWidth, frameStyle);
673
+ cachedFrameRowRenderer = createFramePanelRowRenderer(innerWidth, frameStyle);
675
674
  }
676
675
  cachedFrameWidth = innerWidth;
677
676
  cachedFrameContent = frameContent;
@@ -684,6 +683,20 @@ export function createAgentFollowComponent(
684
683
  },
685
684
  invalidate() {
686
685
  messageInput.invalidate();
686
+ transcriptAgent = undefined;
687
+ transcriptMessages = undefined;
688
+ transcriptMessageCount = -1;
689
+ transcriptLastMessage = undefined;
690
+ transcriptWidth = -1;
691
+ cachedTranscriptLines = [];
692
+ cachedFrameWidth = -1;
693
+ cachedFrameRowRenderer = undefined;
694
+ cachedFrameContent = null;
695
+ cachedFrameLines = null;
696
+ fastFrameKey = "";
697
+ fastFrameMessages = undefined;
698
+ fastFrameMessageCount = -1;
699
+ fastFrameLastMessage = undefined;
687
700
  },
688
701
  dispose() {
689
702
  clearInterval(refreshTimer);
@@ -765,7 +778,7 @@ export function createAgentFollowComponent(
765
778
  export async function openAgentFollowView(ctx: any, options: AgentFollowViewOptions): Promise<void> {
766
779
  if (ctx.mode && ctx.mode !== "tui") return;
767
780
  await ctx.ui.custom(
768
- (tui: any, _theme: any, _keybindings: any, done: () => void) => createAgentFollowComponent(tui, done, options),
781
+ (tui: any, theme: any, _keybindings: any, done: () => void) => createAgentFollowComponent(tui, done, options, theme),
769
782
  {
770
783
  overlay: true,
771
784
  overlayOptions: {
@@ -5,44 +5,58 @@ const ANSI_PURPLE = "\x1b[38;5;141m";
5
5
  const ANSI_PANEL_BG = "\x1b[48;5;235m";
6
6
  const SINGLE_COLUMN_FRAME_TEXT = /^[\x20-\x7E\u00B7]*$/;
7
7
 
8
- // Fill a line to `width` visible columns with the dark panel background. Every
9
- // full reset emitted by pink/purple/dimAnsi/theme.fg is followed by a fresh
10
- // background code so embedded foreground colors don't punch holes in the fill.
11
- function panelBgFillMeasured(line: string, lineWidth: number, width: number, background: string): string {
12
- const pad = Math.max(0, width - lineWidth);
13
- const reasserted = line.includes(ANSI_RESET)
14
- ? line.split(ANSI_RESET).join(ANSI_RESET + background)
15
- : line;
16
- return `${background}${reasserted}${" ".repeat(pad)}${ANSI_RESET}`;
8
+ export interface FramePanelStyle {
9
+ border(text: string): string;
10
+ background(text: string): string;
11
+ }
12
+
13
+ type FramePanelBackground = string | FramePanelStyle;
14
+
15
+ // Fill a line to `width` visible columns. The legacy string path preserves the
16
+ // public ANSI helpers; custom layouts can instead compose Pi theme callbacks.
17
+ function panelBgFillMeasured(line: string, lineWidth: number, width: number, background: FramePanelBackground): string {
18
+ const padded = `${line}${" ".repeat(Math.max(0, width - lineWidth))}`;
19
+ if (typeof background === "string") {
20
+ const reasserted = padded.includes(ANSI_RESET)
21
+ ? padded.split(ANSI_RESET).join(ANSI_RESET + background)
22
+ : padded;
23
+ return `${background}${reasserted}${ANSI_RESET}`;
24
+ }
25
+ return padded.includes(ANSI_RESET)
26
+ ? padded.split(ANSI_RESET).map((segment) => background.background(segment)).join(ANSI_RESET)
27
+ : background.background(padded);
17
28
  }
18
29
 
19
30
  export function panelBgFill(line: string, width: number, background = ANSI_PANEL_BG): string {
20
31
  return panelBgFillMeasured(line, visibleWidth(line), width, background);
21
32
  }
22
33
 
23
- function renderFramePanelRow(line: string, innerWidth: number, span: number, background: string, sideBorder: string): string {
34
+ function styledBorder(text: string, background: FramePanelBackground): string {
35
+ return typeof background === "string"
36
+ ? `${background}${ANSI_PURPLE}${text}${ANSI_RESET}`
37
+ : background.background(background.border(text));
38
+ }
39
+
40
+ function renderFramePanelRow(line: string, innerWidth: number, span: number, background: FramePanelBackground, sideBorder: string): string {
24
41
  const singleColumn = SINGLE_COLUMN_FRAME_TEXT.test(line);
25
- if (singleColumn && line.length <= innerWidth) {
26
- return `${sideBorder}${background} ${line} ${" ".repeat(innerWidth - line.length)}${ANSI_RESET}${sideBorder}`;
27
- }
28
42
  const lineWidth = singleColumn ? line.length : visibleWidth(line);
29
43
  const boundedLine = lineWidth > innerWidth ? truncateToWidth(line, innerWidth, "…", true) : line;
30
44
  const boundedWidth = lineWidth > innerWidth ? (singleColumn ? boundedLine.length : visibleWidth(boundedLine)) : lineWidth;
31
45
  return sideBorder + panelBgFillMeasured(` ${boundedLine} `, boundedWidth + 2, span, background) + sideBorder;
32
46
  }
33
47
 
34
- export function createFramePanelRowRenderer(innerWidth: number, background = ANSI_PANEL_BG): (line: string) => string {
48
+ export function createFramePanelRowRenderer(innerWidth: number, background: FramePanelBackground = ANSI_PANEL_BG): (line: string) => string {
35
49
  const span = innerWidth + 2;
36
- const sideBorder = `${background}${ANSI_PURPLE}│${ANSI_RESET}`;
50
+ const sideBorder = styledBorder("│", background);
37
51
  return (line: string) => renderFramePanelRow(line, innerWidth, span, background, sideBorder);
38
52
  }
39
53
 
40
- // Wrap content lines in a rounded border with a dark interior. `innerWidth` is
41
- // the column count between the one-space padding inside each side border.
42
- export function framePanel(contentLines: string[], innerWidth: number, background = ANSI_PANEL_BG): string[] {
54
+ // Wrap content lines in a rounded border. `innerWidth` is the column count
55
+ // between the one-space padding inside each side border.
56
+ export function framePanel(contentLines: string[], innerWidth: number, background: FramePanelBackground = ANSI_PANEL_BG): string[] {
43
57
  const span = innerWidth + 2;
44
58
  const rule = "─".repeat(span);
45
- const border = (text: string) => `${background}${ANSI_PURPLE}${text}${ANSI_RESET}`;
59
+ const border = (text: string) => styledBorder(text, background);
46
60
  const sideBorder = border("│");
47
61
  const out: string[] = [border(`╭${rule}╮`)];
48
62
  for (const line of contentLines) {
@@ -14,6 +14,32 @@ function makeSnapshot(overrides: Partial<TeamActivityStatusSnapshot> = {}): Team
14
14
  }
15
15
 
16
16
  describe("agent activity status widget", () => {
17
+ it("uses Pi theme tokens and refreshes cached styles after invalidation", () => {
18
+ const snapshot = makeSnapshot({
19
+ activeCount: 1,
20
+ readCount: 1,
21
+ entries: [{ name: "reader", role: "read", status: "thinking" }],
22
+ });
23
+ let color = 31;
24
+ const theme = {
25
+ fg: vi.fn((_token: string, text: string) => `\x1b[${color}m${text}\x1b[39m`),
26
+ bg: vi.fn((_token: string, text: string) => text),
27
+ bold: vi.fn((text: string) => text),
28
+ };
29
+ const widget = teamActivityStatusWidget(() => snapshot, () => false, undefined, theme);
30
+
31
+ expect(widget.render(120).join("\n")).toContain("\x1b[31magent activity");
32
+ expect(theme.fg).toHaveBeenCalledWith("accent", "agent activity");
33
+ expect(theme.fg).toHaveBeenCalledWith("border", expect.any(String));
34
+
35
+ color = 32;
36
+ widget.invalidate();
37
+ const rerendered = widget.render(120).join("\n");
38
+ expect(rerendered).toContain("\x1b[32magent activity");
39
+ expect(rerendered).not.toContain("\x1b[31magent activity");
40
+ widget.dispose();
41
+ });
42
+
17
43
  it("renders open snapshots without a collapse hint", () => {
18
44
  const entries: TeamActivityStatusEntry[] = [{
19
45
  name: "reader",
@@ -1,6 +1,6 @@
1
1
  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2
- import { dimAnsi, pink, purple } from "./ansi";
3
2
  import { formatAnimatedProgress } from "./renderers";
3
+ import { resolveExtendedTeamsTheme, type ExtendedTeamsTheme } from "./theme";
4
4
 
5
5
  export interface TeamActivityStatusEntry {
6
6
  name: string;
@@ -62,21 +62,21 @@ function formatStatusSummary(statusCounts: TeamActivityStatusCounts | undefined)
62
62
  return shown.join(" · ");
63
63
  }
64
64
 
65
- function formatAggregatePreview(snapshot: TeamActivityStatusSnapshot): string {
65
+ function formatAggregatePreview(snapshot: TeamActivityStatusSnapshot, theme: ExtendedTeamsTheme): string {
66
66
  const summary = formatStatusSummary(snapshot.statusCounts) || formatRoleSummary(snapshot);
67
- return `${pink("summary")} ${purple(summary)} ${dimAnsi("↓ navigate")}`;
67
+ return `${theme.fg("accent", "summary")} ${theme.fg("borderAccent", summary)} ${theme.fg("dim", "↓ navigate")}`;
68
68
  }
69
69
 
70
- function formatExpandedEntry(entry: TeamActivityStatusEntry): string {
70
+ function formatExpandedEntry(entry: TeamActivityStatusEntry, theme: ExtendedTeamsTheme): string {
71
71
  if (entry.displayText) return entry.displayText;
72
- const status = entry.status ? ` ${purple(entry.status)}` : "";
73
- const detail = entry.detail ? ` ${dimAnsi(entry.detail)}` : "";
74
- return `${pink(entry.name)} ${purple(entry.role)}${status}${detail}`;
72
+ const status = entry.status ? ` ${theme.fg("borderAccent", entry.status)}` : "";
73
+ const detail = entry.detail ? ` ${theme.fg("dim", entry.detail)}` : "";
74
+ return `${theme.fg("accent", entry.name)} ${theme.fg("borderAccent", entry.role)}${status}${detail}`;
75
75
  }
76
76
 
77
- function formatHeader(snapshot: TeamActivityStatusSnapshot): string {
77
+ function formatHeader(snapshot: TeamActivityStatusSnapshot, theme: ExtendedTeamsTheme): string {
78
78
  const summary = `${formatCountSummary(snapshot)} · ↓ navigate`;
79
- return `${pink("agent activity")} ${dimAnsi(summary)}`;
79
+ return `${theme.fg("accent", "agent activity")} ${theme.fg("dim", summary)}`;
80
80
  }
81
81
 
82
82
  interface ProgressTransition {
@@ -131,8 +131,10 @@ function splitProgressDisplay(displayText: string | undefined): { prefix: string
131
131
  export function teamActivityStatusWidget(
132
132
  getSnapshot: () => TeamActivityStatusSnapshot | null | undefined,
133
133
  _getExpanded: () => boolean,
134
- requestRender?: () => void
134
+ requestRender?: () => void,
135
+ providedTheme?: ExtendedTeamsTheme
135
136
  ) {
137
+ const theme = resolveExtendedTeamsTheme(providedTheme);
136
138
  const transitions = new Map<string, ProgressTransition>();
137
139
  let animationTimer: NodeJS.Timeout | null = null;
138
140
  let summarySnapshot: TeamActivityStatusSnapshot | null | undefined;
@@ -186,15 +188,15 @@ export function teamActivityStatusWidget(
186
188
  if (!hasCachedTransitionEntry || transition) transitionHints[hintIndex] = transition;
187
189
  }
188
190
  if (!entry.displayText) {
189
- return animationResult(`${purple(branch)} ${formatExpandedEntry(entry)}`, false, width);
191
+ return animationResult(`${theme.fg("border", branch)} ${formatExpandedEntry(entry, theme)}`, false, width);
190
192
  }
191
193
 
192
194
  if (!transition || transition.source !== entry.displayText) {
193
195
  const parsed = splitProgressDisplay(entry.displayText);
194
196
  if (!parsed) {
195
- return animationResult(`${purple(branch)} ${formatExpandedEntry(entry)}`, false, width);
197
+ return animationResult(`${theme.fg("border", branch)} ${formatExpandedEntry(entry, theme)}`, false, width);
196
198
  }
197
- const branchPrefix = `${purple(branch)} `;
199
+ const branchPrefix = `${theme.fg("border", branch)} `;
198
200
  const prefixVisibleWidth = SINGLE_COLUMN_STATUS_TEXT.test(parsed.prefix)
199
201
  ? parsed.prefix.length
200
202
  : visibleWidth(parsed.prefix);
@@ -273,14 +275,14 @@ export function teamActivityStatusWidget(
273
275
  transitionHints[hintIndex] = transition;
274
276
  } else if (transition.branch !== branch) {
275
277
  transition.branch = branch;
276
- transition.linePrefix = `${purple(branch)} ${transition.prefix}`;
278
+ transition.linePrefix = `${theme.fg("border", branch)} ${transition.prefix}`;
277
279
  }
278
280
 
279
281
  const elapsed = Math.max(0, now - transition.startedAt);
280
282
  if (elapsed < 200) {
281
283
  const remaining = Math.max(0, Math.ceil(transition.previous.length * (1 - elapsed / 200)));
282
284
  const progressText = transition.previous.slice(0, remaining);
283
- return animationResult(`${transition.linePrefix}${dimAnsi(progressText)}`, true, width, currentTransitionRenderedWidth(transition, progressText, transition.previousSingleColumn, width));
285
+ return animationResult(`${transition.linePrefix}${theme.fg("dim", progressText)}`, true, width, currentTransitionRenderedWidth(transition, progressText, transition.previousSingleColumn, width));
284
286
  }
285
287
  if (elapsed < 1000) {
286
288
  const revealed = Math.floor(transition.target.length * ((elapsed - 200) / 800));
@@ -324,7 +326,7 @@ export function teamActivityStatusWidget(
324
326
  headerWriteCount = snapshot.writeCount;
325
327
  headerUnreadCount = snapshot.unreadCount;
326
328
  headerWidth = width;
327
- headerLine = truncateToWidth(formatHeader(snapshot), width, "…", true);
329
+ headerLine = truncateToWidth(formatHeader(snapshot, theme), width, "…", true);
328
330
  }
329
331
  summaryLines = [headerLine];
330
332
  if (shouldUseAggregatePreview(snapshot)) {
@@ -350,7 +352,7 @@ export function teamActivityStatusWidget(
350
352
  aggregateReadCount = snapshot.readCount;
351
353
  aggregateWriteCount = snapshot.writeCount;
352
354
  aggregateWidth = width;
353
- aggregateLine = truncateToWidth(formatAggregatePreview(snapshot), width, "…", true);
355
+ aggregateLine = truncateToWidth(formatAggregatePreview(snapshot, theme), width, "…", true);
354
356
  }
355
357
  summaryLines.push(aggregateLine);
356
358
  }
@@ -409,7 +411,7 @@ export function teamActivityStatusWidget(
409
411
  if (remainingCount !== remaining || remainingWidth !== width) {
410
412
  remainingCount = remaining;
411
413
  remainingWidth = width;
412
- remainingLine = truncateToWidth(dimAnsi(`└─ … ${remaining} more active agent${remaining === 1 ? "" : "s"}`), width, "…", true);
414
+ remainingLine = truncateToWidth(theme.fg("dim", `└─ … ${remaining} more active agent${remaining === 1 ? "" : "s"}`), width, "…", true);
413
415
  }
414
416
  lines.push(remainingLine);
415
417
  }
@@ -420,7 +422,7 @@ export function teamActivityStatusWidget(
420
422
  stopAnimationTimer();
421
423
  }
422
424
 
423
- const border = purple("─".repeat(Math.max(0, width)));
425
+ const border = theme.fg("border", "─".repeat(Math.max(0, width)));
424
426
  const rendered = [...lines, border];
425
427
  if (animationActive) {
426
428
  stableLines = null;
@@ -432,7 +434,38 @@ export function teamActivityStatusWidget(
432
434
  }
433
435
  return rendered;
434
436
  },
435
- invalidate() {},
437
+ invalidate() {
438
+ transitions.clear();
439
+ summarySnapshot = undefined;
440
+ summaryUpdatedAt = -1;
441
+ summaryWidth = -1;
442
+ summaryLines = [];
443
+ headerActiveCount = -1;
444
+ headerReadCount = -1;
445
+ headerWriteCount = -1;
446
+ headerUnreadCount = -1;
447
+ headerWidth = -1;
448
+ aggregateStatusCounts = {};
449
+ aggregateStatusCountSize = -1;
450
+ aggregateActiveCount = -1;
451
+ aggregateReadCount = -1;
452
+ aggregateWriteCount = -1;
453
+ aggregateWidth = -1;
454
+ remainingCount = -1;
455
+ remainingWidth = -1;
456
+ transitionHintSnapshot = undefined;
457
+ transitionHintUpdatedAt = -1;
458
+ transitionHintEntries.length = 0;
459
+ transitionHints.length = 0;
460
+ transitionRosterRoles.length = 0;
461
+ transitionRosterNames.length = 0;
462
+ transitionRosterKeys.length = 0;
463
+ transitionRosterCount = -1;
464
+ stableSnapshot = undefined;
465
+ stableUpdatedAt = -1;
466
+ stableWidth = -1;
467
+ stableLines = null;
468
+ },
436
469
  dispose() {
437
470
  stopAnimationTimer();
438
471
  },
@@ -0,0 +1,34 @@
1
+ export type ExtendedTeamsForegroundToken =
2
+ | "accent"
3
+ | "border"
4
+ | "borderAccent"
5
+ | "customMessageLabel"
6
+ | "dim"
7
+ | "error"
8
+ | "muted"
9
+ | "success"
10
+ | "syntaxFunction"
11
+ | "syntaxString"
12
+ | "text"
13
+ | "thinkingText"
14
+ | "warning";
15
+
16
+ export type ExtendedTeamsBackgroundToken = "customMessageBg";
17
+
18
+ export interface ExtendedTeamsTheme {
19
+ fg(token: ExtendedTeamsForegroundToken, text: string): string;
20
+ bg(token: ExtendedTeamsBackgroundToken, text: string): string;
21
+ }
22
+
23
+ const plainTheme: ExtendedTeamsTheme = {
24
+ fg: (_token, text) => text,
25
+ bg: (_token, text) => text,
26
+ };
27
+
28
+ export function resolveExtendedTeamsTheme(theme?: Partial<ExtendedTeamsTheme> | null): ExtendedTeamsTheme {
29
+ if (!theme) return plainTheme;
30
+ return {
31
+ fg: (token, text) => typeof theme.fg === "function" ? theme.fg(token, text) : text,
32
+ bg: (token, text) => typeof theme.bg === "function" ? theme.bg(token, text) : text,
33
+ };
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extended-teams",
3
- "version": "2.1.16",
3
+ "version": "2.1.18",
4
4
  "description": "Control-first, session-connected subagents for Pi with live navigation and intent-tier routing",
5
5
  "repository": {
6
6
  "type": "git",