tau-all-agent 0.1.4 → 0.1.6

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.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/extensions/answer.ts +59 -47
  3. package/extensions/branch-term.ts +8 -13
  4. package/extensions/btw.ts +77 -6
  5. package/extensions/ghostty.ts +64 -11
  6. package/extensions/insights.ts +42 -4
  7. package/extensions/loop.ts +51 -12
  8. package/extensions/memory.ts +35 -17
  9. package/extensions/notify.ts +24 -5
  10. package/extensions/openai-fast.ts +6 -4
  11. package/extensions/openai-verbosity.ts +6 -4
  12. package/extensions/review/fix.ts +239 -0
  13. package/extensions/review/git.ts +517 -0
  14. package/extensions/review/index.ts +232 -0
  15. package/extensions/review/message-queue.ts +249 -0
  16. package/extensions/review/models.ts +589 -0
  17. package/extensions/review/prompts.ts +337 -0
  18. package/extensions/review/renderers/inline.ts +126 -0
  19. package/extensions/review/request.ts +504 -0
  20. package/extensions/review/review.ts +1305 -0
  21. package/extensions/review/runner.ts +522 -0
  22. package/extensions/review/runtime.ts +275 -0
  23. package/extensions/review/schema.ts +160 -0
  24. package/extensions/review/submit-review-tool.ts +62 -0
  25. package/extensions/review/submit-triage-tool.ts +60 -0
  26. package/extensions/review/triage.ts +746 -0
  27. package/extensions/sandbox/index.ts +976 -185
  28. package/extensions/sandbox/macos-sandbox-shell.mjs +202 -0
  29. package/extensions/{interlude.ts → stash.ts} +13 -11
  30. package/extensions/telegram/README.md +9 -5
  31. package/extensions/telegram/assistant-result.d.mts +13 -0
  32. package/extensions/telegram/assistant-result.mjs +52 -0
  33. package/extensions/telegram/daemon.mjs +334 -42
  34. package/extensions/telegram/index.ts +274 -20
  35. package/extensions/tool-display-mode.ts +2 -3
  36. package/extensions/usage/anthropic.ts +5 -6
  37. package/extensions/usage/github-copilot.ts +30 -9
  38. package/extensions/usage/index.ts +9 -5
  39. package/extensions/usage/openai-codex.ts +2 -2
  40. package/extensions/websearch/README.md +1 -1
  41. package/extensions/websearch/browser/chromium.ts +42 -31
  42. package/extensions/websearch/browser/discovery.ts +11 -9
  43. package/extensions/websearch/browser/firefox.ts +10 -7
  44. package/extensions/websearch/browser/fs.ts +10 -0
  45. package/extensions/websearch/browser/sqlite.ts +22 -15
  46. package/extensions/websearch/config.ts +2 -2
  47. package/extensions/websearch/index.ts +6 -6
  48. package/extensions/websearch/providers/pi-model.shared.ts +19 -1
  49. package/extensions/worktree.ts +33 -33
  50. package/package.json +4 -4
  51. package/skills/git-clean-history/SKILL.md +1 -0
  52. package/skills/git-commit/SKILL.md +2 -2
  53. package/skills/oracle/SKILL.md +3 -3
  54. package/skills/oracle/scripts/oracle +82 -61
  55. package/themes/tau-dark.json +2 -1
  56. package/extensions/review.ts +0 -4420
package/README.md CHANGED
@@ -27,7 +27,7 @@ pi install -l npm:tau-all-agent
27
27
  | `git-diff-stats` | — | Status bar diff stats for local changes in the current repo. |
28
28
  | `git-pr-status` | — | Status bar PR number and link for the current branch. |
29
29
  | `insights` | `/insights` | Analyze Pi sessions and suggest reusable instructions, templates, skills, and extensions. |
30
- | `interlude` | `alt+x` | Stash the current message draft, send one interlude message, then restore the draft. |
30
+ | `stash` | `alt+x` | Stash the current message draft, send one message, then restore it. |
31
31
  | `loop` | `/loop` | Repeat a prompt until the agent signals success. |
32
32
  | `memory` | `/memory` | Opt-in project-local memory for learning and continuity across sessions. |
33
33
  | `notify` | — | Terminal notification when the agent is waiting for input. |
@@ -10,13 +10,14 @@
10
10
  * 4. Submits the compiled answers when done
11
11
  */
12
12
 
13
- import { complete, type Model, type Api, type UserMessage } from "@earendil-works/pi-ai";
14
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
- import { BorderedLoader } from "@earendil-works/pi-coding-agent";
13
+ import { complete } from "@earendil-works/pi-ai/compat";
14
+ import type { Model, Api, UserMessage } from "@earendil-works/pi-ai";
15
+ import { BorderedLoader, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
16
16
  import {
17
17
  type Component,
18
18
  Editor,
19
19
  type EditorTheme,
20
+ type Focusable,
20
21
  Key,
21
22
  matchesKey,
22
23
  truncateToWidth,
@@ -94,14 +95,19 @@ interface ModelRequestAuth {
94
95
  model: Model<Api>;
95
96
  apiKey?: string;
96
97
  headers?: Record<string, string>;
98
+ env?: Record<string, string>;
97
99
  }
98
100
 
99
101
  type ModelAuthLookup = {
100
102
  find: (provider: string, modelId: string) => Model<Api> | undefined;
101
- getApiKeyAndHeaders: (
102
- model: Model<Api>,
103
- ) => Promise<
104
- { ok: true; apiKey?: string; headers?: Record<string, string> } | { ok: false; error: string }
103
+ getApiKeyAndHeaders: (model: Model<Api>) => Promise<
104
+ | {
105
+ ok: true;
106
+ apiKey?: string;
107
+ headers?: Record<string, string>;
108
+ env?: Record<string, string>;
109
+ }
110
+ | { ok: false; error: string }
105
111
  >;
106
112
  };
107
113
 
@@ -148,7 +154,7 @@ async function resolveModelRequestAuth(
148
154
  model: Model<Api>,
149
155
  ): Promise<ModelRequestAuth | null> {
150
156
  const auth = await modelRegistry.getApiKeyAndHeaders(model);
151
- return auth.ok ? { model, apiKey: auth.apiKey, headers: auth.headers } : null;
157
+ return auth.ok ? { model, apiKey: auth.apiKey, headers: auth.headers, env: auth.env } : null;
152
158
  }
153
159
 
154
160
  /**
@@ -178,12 +184,13 @@ function parseExtractionResult(text: string): ExtractionResult | null {
178
184
  /**
179
185
  * Interactive Q&A component for answering extracted questions
180
186
  */
181
- class QnAComponent implements Component {
187
+ class QnAComponent implements Component, Focusable {
182
188
  private questions: ExtractedQuestion[];
183
189
  private answers: string[];
184
190
  private currentIndex: number = 0;
185
191
  private editor: Editor;
186
192
  private tui: TUI;
193
+ private theme: Theme;
187
194
  private onDone: (result: string | null) => void;
188
195
  private showingConfirmation: boolean = false;
189
196
 
@@ -191,30 +198,38 @@ class QnAComponent implements Component {
191
198
  private cachedWidth?: number;
192
199
  private cachedLines?: string[];
193
200
 
194
- // Colors - using proper reset sequences
195
- private dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
196
- private bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
197
- private cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
198
- private green = (s: string) => `\x1b[32m${s}\x1b[0m`;
199
- private yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
200
- private gray = (s: string) => `\x1b[90m${s}\x1b[0m`;
201
+ private _focused = false;
202
+
203
+ get focused(): boolean {
204
+ return this._focused;
205
+ }
206
+
207
+ set focused(value: boolean) {
208
+ this._focused = value;
209
+ this.editor.focused = value;
210
+ }
201
211
 
202
- constructor(questions: ExtractedQuestion[], tui: TUI, onDone: (result: string | null) => void) {
212
+ constructor(
213
+ questions: ExtractedQuestion[],
214
+ tui: TUI,
215
+ theme: Theme,
216
+ onDone: (result: string | null) => void,
217
+ ) {
203
218
  this.questions = questions;
204
219
  this.answers = questions.map(() => "");
205
220
  this.tui = tui;
221
+ this.theme = theme;
206
222
  this.onDone = onDone;
207
223
 
208
- // Create a minimal theme for the editor
209
- const selectedLine = (s: string) => `\x1b[44m${s}\x1b[0m`;
224
+ const selectedLine = (s: string) => theme.bg("selectedBg", s);
210
225
  const editorTheme: EditorTheme = {
211
- borderColor: this.dim,
226
+ borderColor: (s) => theme.fg("dim", s),
212
227
  selectList: {
213
228
  selectedPrefix: selectedLine,
214
229
  selectedText: selectedLine,
215
- description: this.gray,
216
- scrollInfo: this.gray,
217
- noMatch: this.gray,
230
+ description: (s) => theme.fg("muted", s),
231
+ scrollInfo: (s) => theme.fg("muted", s),
232
+ noMatch: (s) => theme.fg("warning", s),
218
233
  },
219
234
  };
220
235
 
@@ -228,11 +243,6 @@ class QnAComponent implements Component {
228
243
  };
229
244
  }
230
245
 
231
- private allQuestionsAnswered(): boolean {
232
- this.saveCurrentAnswer();
233
- return this.answers.every((a) => (a?.trim() || "").length > 0);
234
- }
235
-
236
246
  private saveCurrentAnswer(): void {
237
247
  this.answers[this.currentIndex] = this.editor.getText();
238
248
  }
@@ -362,6 +372,7 @@ class QnAComponent implements Component {
362
372
  return this.cachedLines;
363
373
  }
364
374
 
375
+ const theme = this.theme;
365
376
  const lines: string[] = [];
366
377
  const boxWidth = Math.min(width - 4, 120); // Allow wider box
367
378
  const contentWidth = boxWidth - 4; // 2 chars padding on each side
@@ -374,11 +385,11 @@ class QnAComponent implements Component {
374
385
  const paddedContent = " ".repeat(leftPad) + content;
375
386
  const contentLen = visibleWidth(paddedContent);
376
387
  const rightPad = Math.max(0, boxWidth - contentLen - 2);
377
- return this.dim("│") + paddedContent + " ".repeat(rightPad) + this.dim("│");
388
+ return theme.fg("dim", "│") + paddedContent + " ".repeat(rightPad) + theme.fg("dim", "│");
378
389
  };
379
390
 
380
391
  const emptyBoxLine = (): string => {
381
- return this.dim("│") + " ".repeat(boxWidth - 2) + this.dim("│");
392
+ return theme.fg("dim", "│") + " ".repeat(boxWidth - 2) + theme.fg("dim", "│");
382
393
  };
383
394
 
384
395
  const padToWidth = (line: string): string => {
@@ -387,10 +398,10 @@ class QnAComponent implements Component {
387
398
  };
388
399
 
389
400
  // Title
390
- lines.push(padToWidth(this.dim("╭" + horizontalLine(boxWidth - 2) + "╮")));
391
- const title = `${this.bold(this.cyan("Questions"))} ${this.dim(`(${this.currentIndex + 1}/${this.questions.length})`)}`;
401
+ lines.push(padToWidth(theme.fg("dim", "╭" + horizontalLine(boxWidth - 2) + "╮")));
402
+ const title = `${theme.bold(theme.fg("accent", "Questions"))} ${theme.fg("dim", `(${this.currentIndex + 1}/${this.questions.length})`)}`;
392
403
  lines.push(padToWidth(boxLine(title)));
393
- lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
404
+ lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
394
405
 
395
406
  // Progress indicator
396
407
  const progressParts: string[] = [];
@@ -398,11 +409,11 @@ class QnAComponent implements Component {
398
409
  const answered = (this.answers[i]?.trim() || "").length > 0;
399
410
  const current = i === this.currentIndex;
400
411
  if (current) {
401
- progressParts.push(this.cyan("●"));
412
+ progressParts.push(theme.fg("accent", "●"));
402
413
  } else if (answered) {
403
- progressParts.push(this.green("●"));
414
+ progressParts.push(theme.fg("success", "●"));
404
415
  } else {
405
- progressParts.push(this.dim("○"));
416
+ progressParts.push(theme.fg("dim", "○"));
406
417
  }
407
418
  }
408
419
  lines.push(padToWidth(boxLine(progressParts.join(" "))));
@@ -419,7 +430,7 @@ class QnAComponent implements Component {
419
430
  // Context if present
420
431
  if (q.context) {
421
432
  lines.push(padToWidth(emptyBoxLine()));
422
- const contextText = this.gray(`${q.context}`);
433
+ const contextText = theme.fg("muted", `${q.context}`);
423
434
  const wrappedContext = wrapTextWithAnsi(contextText, contentWidth - 2);
424
435
  for (const line of wrappedContext) {
425
436
  lines.push(padToWidth(boxLine(line)));
@@ -430,7 +441,7 @@ class QnAComponent implements Component {
430
441
 
431
442
  // Render the editor component (multi-line input) with padding
432
443
  // Skip the first and last lines (editor's own border lines)
433
- const answerPrefix = this.bold("A: ");
444
+ const answerPrefix = theme.bold("A: ");
434
445
  const editorWidth = contentWidth - 4 - 3; // Extra padding + space for "A: "
435
446
  const editorLines = this.editor.render(editorWidth);
436
447
  for (let i = 1; i < editorLines.length - 1; i++) {
@@ -447,15 +458,15 @@ class QnAComponent implements Component {
447
458
 
448
459
  // Confirmation dialog or footer with controls
449
460
  if (this.showingConfirmation) {
450
- lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
451
- const confirmMsg = `${this.yellow("Submit all answers?")} ${this.dim("(Enter/y to confirm, Esc/n to cancel)")}`;
461
+ lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
462
+ const confirmMsg = `${theme.fg("warning", "Submit all answers?")} ${theme.fg("dim", "(Enter/y to confirm, Esc/n to cancel)")}`;
452
463
  lines.push(padToWidth(boxLine(truncateToWidth(confirmMsg, contentWidth))));
453
464
  } else {
454
- lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
455
- const controls = `${this.dim("Tab/Enter")} next · ${this.dim("Shift+Tab")} prev · ${this.dim("Shift+Enter")} newline · ${this.dim("Esc")} cancel`;
465
+ lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
466
+ const controls = `${theme.fg("dim", "Tab/Enter")} next · ${theme.fg("dim", "Shift+Tab")} prev · ${theme.fg("dim", "Shift+Enter")} newline · ${theme.fg("dim", "Esc")} cancel`;
456
467
  lines.push(padToWidth(boxLine(truncateToWidth(controls, contentWidth))));
457
468
  }
458
- lines.push(padToWidth(this.dim("╰" + horizontalLine(boxWidth - 2) + "╯")));
469
+ lines.push(padToWidth(theme.fg("dim", "╰" + horizontalLine(boxWidth - 2) + "╯")));
459
470
 
460
471
  this.cachedWidth = width;
461
472
  this.cachedLines = lines;
@@ -467,8 +478,8 @@ export default function (pi: ExtensionAPI) {
467
478
  pi.registerCommand("answer", {
468
479
  description: "Extract questions from last assistant message into interactive Q&A",
469
480
  handler: async (_args, ctx) => {
470
- if (!ctx.hasUI) {
471
- ctx.ui.notify("answer requires interactive mode", "error");
481
+ if (ctx.mode !== "tui") {
482
+ ctx.ui.notify("answer requires TUI mode", "error");
472
483
  return;
473
484
  }
474
485
 
@@ -536,6 +547,7 @@ export default function (pi: ExtensionAPI) {
536
547
  {
537
548
  apiKey: extractionSelection.apiKey,
538
549
  headers: extractionSelection.headers,
550
+ env: extractionSelection.env,
539
551
  signal: loader.signal,
540
552
  },
541
553
  );
@@ -571,8 +583,8 @@ export default function (pi: ExtensionAPI) {
571
583
  }
572
584
 
573
585
  // Show the Q&A component
574
- const answersResult = await ctx.ui.custom<string | null>((tui, _theme, _kb, done) => {
575
- return new QnAComponent(extractionResult.questions, tui, done);
586
+ const answersResult = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
587
+ return new QnAComponent(extractionResult.questions, tui, theme, done);
576
588
  });
577
589
 
578
590
  if (answersResult === null) {
@@ -13,7 +13,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
13
13
 
14
14
  const TERMINAL_FLAG = "branch-term";
15
15
  const TMUX_LAYOUT_FLAG = "branch-tmux-layout";
16
- const BRANCH_MESSAGE_TYPE = "branch-term-message";
16
+ const BRANCH_ENTRY_TYPE = "branch-term-message";
17
17
  const MANUAL_RESUME_INTRO =
18
18
  "Branch session ready. Run this command in a separate terminal or tmux pane";
19
19
 
@@ -220,15 +220,10 @@ function showCommandMessage(
220
220
  return;
221
221
  }
222
222
 
223
- pi.sendMessage({
224
- customType: BRANCH_MESSAGE_TYPE,
225
- content: intro,
226
- display: true,
227
- details: {
228
- intro,
229
- command,
230
- copiedToClipboard,
231
- } satisfies CommandMessageDetails,
223
+ pi.appendEntry<CommandMessageDetails>(BRANCH_ENTRY_TYPE, {
224
+ intro,
225
+ command,
226
+ copiedToClipboard,
232
227
  });
233
228
  }
234
229
 
@@ -266,10 +261,10 @@ function createFreshSessionFile(cwd: string, sessionDir: string): string {
266
261
  }
267
262
 
268
263
  export default function (pi: ExtensionAPI) {
269
- pi.registerMessageRenderer(BRANCH_MESSAGE_TYPE, (message, _options, theme) => {
270
- const details = message.details as CommandMessageDetails | undefined;
264
+ pi.registerEntryRenderer<CommandMessageDetails>(BRANCH_ENTRY_TYPE, (entry, _options, theme) => {
265
+ const details = entry.data;
271
266
  if (!details || typeof details.intro !== "string" || typeof details.command !== "string") {
272
- return new Text(typeof message.content === "string" ? message.content : "", 0, 0);
267
+ return undefined;
273
268
  }
274
269
 
275
270
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
package/extensions/btw.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { AssistantMessage, ThinkingLevel } from "@earendil-works/pi-ai";
2
+ import {
3
+ InMemoryCredentialStore,
4
+ type AssistantMessage,
5
+ type Provider,
6
+ type ThinkingLevel,
7
+ } from "@earendil-works/pi-ai";
3
8
  import {
4
9
  DefaultResourceLoader,
10
+ ModelRuntime,
5
11
  SessionManager,
6
12
  buildSessionContext,
7
13
  createAgentSession,
@@ -59,6 +65,15 @@ type ActiveBtwRequest = {
59
65
  abort?: () => Promise<void>;
60
66
  };
61
67
 
68
+ type BtwModelRegistry = ExtensionCommandContext["modelRegistry"];
69
+
70
+ type BtwModelRuntimeState = {
71
+ runtime: ModelRuntime;
72
+ providers: Map<string, Provider>;
73
+ };
74
+
75
+ const btwModelRuntimes = new WeakMap<BtwModelRegistry, Promise<BtwModelRuntimeState>>();
76
+
62
77
  export default function btwExtension(pi: ExtensionAPI): void {
63
78
  let activeRequest: ActiveBtwRequest | null = null;
64
79
  let statusTimer: ReturnType<typeof setInterval> | null = null;
@@ -116,8 +131,8 @@ export default function btwExtension(pi: ExtensionAPI): void {
116
131
  return;
117
132
  }
118
133
 
119
- if (!ctx.hasUI) {
120
- ctx.ui.notify("btw requires interactive mode", "error");
134
+ if (ctx.mode !== "tui") {
135
+ ctx.ui.notify("btw requires TUI mode", "error");
121
136
  return;
122
137
  }
123
138
 
@@ -227,11 +242,12 @@ async function runBtwRequest(
227
242
  });
228
243
  await resourceLoader.reload();
229
244
 
245
+ const modelRuntime = await createBtwModelRuntime(snapshot.modelRegistry, snapshot.model.provider);
230
246
  const { session } = await createAgentSession({
231
247
  cwd: snapshot.cwd,
232
248
  model: snapshot.model,
233
249
  thinkingLevel: snapshot.thinkingLevel,
234
- modelRegistry: snapshot.modelRegistry,
250
+ modelRuntime,
235
251
  resourceLoader,
236
252
  tools: BTW_TOOL_NAMES,
237
253
  sessionManager,
@@ -269,6 +285,61 @@ async function runBtwRequest(
269
285
  }
270
286
  }
271
287
 
288
+ async function createBtwModelRuntime(
289
+ modelRegistry: BtwModelRegistry,
290
+ providerId: string,
291
+ ): Promise<ModelRuntime> {
292
+ const provider = modelRegistry.getProvider(providerId);
293
+ if (!provider) {
294
+ throw new Error(`Model provider is unavailable: ${providerId}`);
295
+ }
296
+
297
+ const state = await getBtwModelRuntimeState(modelRegistry);
298
+ if (state.providers.get(providerId) !== provider) {
299
+ state.runtime.registerNativeProvider(withParentAuth(provider, modelRegistry));
300
+ state.providers.set(providerId, provider);
301
+ }
302
+ return state.runtime;
303
+ }
304
+
305
+ async function getBtwModelRuntimeState(
306
+ modelRegistry: BtwModelRegistry,
307
+ ): Promise<BtwModelRuntimeState> {
308
+ let state = btwModelRuntimes.get(modelRegistry);
309
+ if (!state) {
310
+ state = ModelRuntime.create({
311
+ credentials: new InMemoryCredentialStore(),
312
+ modelsPath: null,
313
+ }).then((runtime) => ({ runtime, providers: new Map() }));
314
+ btwModelRuntimes.set(modelRegistry, state);
315
+ }
316
+
317
+ try {
318
+ return await state;
319
+ } catch (error) {
320
+ if (btwModelRuntimes.get(modelRegistry) === state) {
321
+ btwModelRuntimes.delete(modelRegistry);
322
+ }
323
+ throw error;
324
+ }
325
+ }
326
+
327
+ function withParentAuth(provider: Provider, modelRegistry: BtwModelRegistry): Provider {
328
+ return {
329
+ ...provider,
330
+ refreshModels: undefined, // The parent runtime owns model catalog refreshes.
331
+ auth: {
332
+ ...provider.auth,
333
+ apiKey: {
334
+ name: `${provider.name} session authentication`,
335
+ async resolve() {
336
+ return modelRegistry.getProviderAuth(provider.id);
337
+ },
338
+ },
339
+ },
340
+ };
341
+ }
342
+
272
343
  function seedSessionManager(sessionManager: SessionManager, messages: AgentMessage[]): void {
273
344
  type SessionMessageInput = Parameters<SessionManager["appendMessage"]>[0];
274
345
 
@@ -278,8 +349,8 @@ function seedSessionManager(sessionManager: SessionManager, messages: AgentMessa
278
349
  }
279
350
 
280
351
  async function showResultDialog(ctx: ExtensionContext, result: BtwResult): Promise<void> {
281
- await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
282
- return new BtwResultComponent(result, tui, ctx.ui.theme, done);
352
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
353
+ return new BtwResultComponent(result, tui, theme, done);
283
354
  });
284
355
  }
285
356
 
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * - Shows project/session in the terminal title
5
5
  * - Shows a braille spinner in the title while the agent is working
6
+ * - Shows the braille spinner backwards while context is compacting
6
7
  * - Shows a ? marker while an extension prompt is waiting for input
7
8
  * - Updates title with the current tool name during tool execution
8
9
  * - Treats background /review runs as working state via review:start/review:end events
@@ -22,6 +23,7 @@ let sessionName: string | undefined;
22
23
  let sessionCwd: string | undefined;
23
24
  let currentTool: string | undefined;
24
25
  let isWorking = false;
26
+ let isCompacting = false;
25
27
  let frameIndex = 0;
26
28
  let spinnerTimer: ReturnType<typeof setInterval> | undefined;
27
29
  let pendingPromptCount = 0;
@@ -47,6 +49,15 @@ function currentFrame(): string {
47
49
  return STATUS_SPINNER_FRAMES[frameIndex % STATUS_SPINNER_FRAMES.length];
48
50
  }
49
51
 
52
+ function resetFrame(): void {
53
+ frameIndex = 0;
54
+ }
55
+
56
+ function advanceFrame(): void {
57
+ const step = isCompacting ? -1 : 1;
58
+ frameIndex = (frameIndex + step + STATUS_SPINNER_FRAMES.length) % STATUS_SPINNER_FRAMES.length;
59
+ }
60
+
50
61
  function hasPendingPrompts(): boolean {
51
62
  return pendingPromptCount > 0;
52
63
  }
@@ -69,11 +80,12 @@ function hasActiveReviewRuns(): boolean {
69
80
  }
70
81
 
71
82
  function isBusy(): boolean {
72
- return isWorking || hasActiveReviewRuns();
83
+ return isWorking || isCompacting || hasActiveReviewRuns();
73
84
  }
74
85
 
75
86
  function getWorkingExtra(): string | undefined {
76
87
  if (currentTool) return currentTool;
88
+ if (isCompacting) return "compacting";
77
89
  if (!isWorking && hasActiveReviewRuns()) return "review";
78
90
  return undefined;
79
91
  }
@@ -105,7 +117,7 @@ function startSpinnerTimer(ctx: ExtensionContext): void {
105
117
  clearSpinnerTimer();
106
118
  spinnerTimer = setInterval(() => {
107
119
  if (!isBusy() || hasPendingPrompts()) return;
108
- frameIndex = (frameIndex + 1) % STATUS_SPINNER_FRAMES.length;
120
+ advanceFrame();
109
121
  renderWorkingTitle(ctx);
110
122
  }, STATUS_SPINNER_INTERVAL_MS);
111
123
  }
@@ -114,7 +126,7 @@ function startSpinner(ctx: ExtensionContext): void {
114
126
  clearSpinnerTimer();
115
127
  isWorking = true;
116
128
  currentTool = undefined;
117
- frameIndex = 0;
129
+ resetFrame();
118
130
  renderActiveTitle(ctx);
119
131
 
120
132
  if (!hasPendingPrompts()) {
@@ -132,7 +144,7 @@ function stopSpinner(ctx: ExtensionContext): void {
132
144
  return;
133
145
  }
134
146
 
135
- if (hasActiveReviewRuns()) {
147
+ if (isBusy()) {
136
148
  renderWorkingTitle(ctx);
137
149
  startSpinnerTimer(ctx);
138
150
  return;
@@ -161,6 +173,38 @@ function handlePromptEnd(ctx: ExtensionContext): void {
161
173
  renderActiveTitle(ctx);
162
174
  }
163
175
 
176
+ function startCompaction(ctx: ExtensionContext, signal: AbortSignal): void {
177
+ isWorking = false;
178
+ isCompacting = true;
179
+ currentTool = undefined;
180
+ resetFrame();
181
+ signal.addEventListener("abort", () => stopCompaction(ctx), { once: true });
182
+
183
+ renderActiveTitle(ctx);
184
+ if (!hasPendingPrompts()) {
185
+ startSpinnerTimer(ctx);
186
+ }
187
+ }
188
+
189
+ function stopCompaction(ctx: ExtensionContext, options?: { render?: boolean }): void {
190
+ if (!isCompacting) return;
191
+
192
+ isCompacting = false;
193
+
194
+ if (options?.render === false) return;
195
+
196
+ if (isBusy()) {
197
+ renderActiveTitle(ctx);
198
+ if (!hasPendingPrompts()) {
199
+ startSpinnerTimer(ctx);
200
+ }
201
+ return;
202
+ }
203
+
204
+ clearSpinnerTimer();
205
+ renderActiveTitle(ctx);
206
+ }
207
+
164
208
  function syncSessionTitle(ctx: ExtensionContext): void {
165
209
  renderActiveTitle(ctx);
166
210
  }
@@ -181,17 +225,12 @@ function handleReviewStart(ctx: ExtensionContext): void {
181
225
  }
182
226
 
183
227
  function handleReviewEnd(ctx: ExtensionContext): void {
184
- if (isWorking) {
185
- renderActiveTitle(ctx);
186
- return;
187
- }
188
-
189
228
  if (hasPendingPrompts()) {
190
229
  renderActiveTitle(ctx);
191
230
  return;
192
231
  }
193
232
 
194
- if (hasActiveReviewRuns()) {
233
+ if (isBusy()) {
195
234
  renderActiveTitle(ctx);
196
235
  startSpinnerTimer(ctx);
197
236
  return;
@@ -218,10 +257,11 @@ export default function (pi: ExtensionAPI) {
218
257
  pi.on("agent_start", async (_event, ctx) => {
219
258
  if (!ctx.hasUI) return;
220
259
  latestCtx = ctx;
260
+ stopCompaction(ctx, { render: false });
221
261
  startSpinner(ctx);
222
262
  });
223
263
 
224
- pi.on("agent_end", async (_event, ctx) => {
264
+ pi.on("agent_settled", async (_event, ctx) => {
225
265
  if (!ctx.hasUI) return;
226
266
  latestCtx = ctx;
227
267
  stopSpinner(ctx);
@@ -243,6 +283,18 @@ export default function (pi: ExtensionAPI) {
243
283
  renderActiveTitle(ctx);
244
284
  });
245
285
 
286
+ pi.on("session_before_compact", async (event, ctx) => {
287
+ if (!ctx.hasUI) return;
288
+ latestCtx = ctx;
289
+ startCompaction(ctx, event.signal);
290
+ });
291
+
292
+ pi.on("session_compact", async (_event, ctx) => {
293
+ if (!ctx.hasUI) return;
294
+ latestCtx = ctx;
295
+ stopCompaction(ctx);
296
+ });
297
+
246
298
  pi.events.on("ui:prompt_start", () => {
247
299
  pendingPromptCount += 1;
248
300
 
@@ -287,6 +339,7 @@ export default function (pi: ExtensionAPI) {
287
339
  pi.on("session_shutdown", async (_event, ctx) => {
288
340
  clearSpinnerTimer();
289
341
  isWorking = false;
342
+ isCompacting = false;
290
343
  currentTool = undefined;
291
344
  pendingPromptCount = 0;
292
345
  const sessionKey = getSessionKey(ctx);