tau-all-agent 0.1.6 → 0.3.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.
Files changed (99) hide show
  1. package/README.md +17 -14
  2. package/extensions/answer.ts +223 -146
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +121 -108
  5. package/extensions/btw.ts +79 -33
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +166 -237
  13. package/extensions/loop.ts +126 -160
  14. package/extensions/memory.ts +202 -118
  15. package/extensions/notify.ts +17 -27
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +25 -22
  18. package/extensions/review/git.ts +99 -103
  19. package/extensions/review/index.ts +62 -64
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +90 -4
  23. package/extensions/review/prompts.ts +41 -38
  24. package/extensions/review/review.ts +164 -171
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +182 -128
  27. package/extensions/review/triage.ts +43 -48
  28. package/extensions/sandbox/README.md +5 -0
  29. package/extensions/sandbox/bash.ts +773 -0
  30. package/extensions/sandbox/command.ts +605 -0
  31. package/extensions/sandbox/config.ts +764 -0
  32. package/extensions/sandbox/index.ts +145 -3341
  33. package/extensions/sandbox/permissions/dialog.ts +118 -0
  34. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  35. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  36. package/extensions/sandbox/permissions/network.ts +169 -0
  37. package/extensions/sandbox/permissions/unsandboxed.ts +31 -0
  38. package/extensions/sandbox/runtime.ts +650 -0
  39. package/extensions/stash.ts +28 -15
  40. package/extensions/subagent/README.md +130 -0
  41. package/extensions/subagent/config.ts +118 -0
  42. package/extensions/subagent/index.ts +854 -0
  43. package/extensions/subagent/interrupt.ts +117 -0
  44. package/extensions/subagent/permissions.ts +101 -0
  45. package/extensions/subagent/rpc.ts +177 -0
  46. package/extensions/telegram/README.md +13 -1
  47. package/extensions/telegram/assistant-result.d.mts +4 -1
  48. package/extensions/telegram/assistant-result.mjs +31 -16
  49. package/extensions/telegram/daemon.mjs +351 -255
  50. package/extensions/telegram/index.ts +47 -55
  51. package/extensions/tool-display-mode.ts +288 -106
  52. package/extensions/usage/README.md +5 -0
  53. package/extensions/usage/index.ts +384 -432
  54. package/extensions/usage/openrouter.ts +10 -2
  55. package/extensions/websearch/README.md +13 -41
  56. package/extensions/websearch/config.ts +6 -2
  57. package/extensions/websearch/index.ts +111 -117
  58. package/extensions/websearch/output.ts +51 -0
  59. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  60. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  61. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  62. package/extensions/websearch/providers/openai.pi.ts +222 -0
  63. package/extensions/websearch/providers/pi-model.shared.ts +33 -39
  64. package/extensions/websearch/providers/shared.ts +94 -2
  65. package/extensions/websearch/types.ts +2 -4
  66. package/extensions/worktree.ts +122 -218
  67. package/package.json +10 -5
  68. package/skills/browser-tools/SKILL.md +29 -234
  69. package/skills/browser-tools/references/cookies.md +36 -0
  70. package/skills/browser-tools/references/interaction.md +90 -0
  71. package/skills/browser-tools/references/logging.md +34 -0
  72. package/skills/git-clean-history/SKILL.md +6 -6
  73. package/skills/git-commit/SKILL.md +5 -3
  74. package/skills/github-pull-request/SKILL.md +60 -0
  75. package/skills/github-pull-request/references/create.md +40 -0
  76. package/skills/github-pull-request/references/stewardship.md +60 -0
  77. package/skills/homeassistant-ops/SKILL.md +30 -161
  78. package/skills/homeassistant-ops/references/api.md +4 -3
  79. package/skills/homeassistant-ops/references/commands.md +137 -0
  80. package/skills/homeassistant-ops/references/id_conventions.md +10 -10
  81. package/skills/homeassistant-ops/references/playbook.md +22 -15
  82. package/skills/openscad/SKILL.md +25 -133
  83. package/skills/openscad/references/authoring.md +38 -0
  84. package/skills/openscad/references/parameters.md +36 -0
  85. package/skills/openscad/references/publishing.md +12 -0
  86. package/skills/openscad/references/rendering.md +40 -0
  87. package/skills/oracle/SKILL.md +15 -9
  88. package/skills/oracle/scripts/oracle +51 -18
  89. package/skills/sentry/SKILL.md +15 -185
  90. package/skills/sentry/references/events.md +79 -0
  91. package/skills/sentry/references/issues.md +62 -0
  92. package/skills/sentry/references/logs.md +46 -0
  93. package/skills/update-changelog/SKILL.md +30 -120
  94. package/skills/web-design/SKILL.md +16 -105
  95. package/themes/tau-dark.json +4 -0
  96. package/extensions/openai-fast.ts +0 -229
  97. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  98. package/extensions/websearch/providers/openai-codex.pi.ts +0 -49
  99. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
package/README.md CHANGED
@@ -23,6 +23,7 @@ pi install -l npm:tau-all-agent
23
23
  | `answer` | `/answer` | Extract and interactively answer agent questions. |
24
24
  | `branch-term` | `/branch` | Open a new terminal on the current session's git branch. |
25
25
  | `btw` | `/btw` | Run a one-off side request with read-only tools and no context persistence. |
26
+ | `caffeinate` | — | Prevent system sleep while the agent runs. |
26
27
  | `ghostty` | — | Ghostty tab title enhancements while the agent is working, waiting, or idle. |
27
28
  | `git-diff-stats` | — | Status bar diff stats for local changes in the current repo. |
28
29
  | `git-pr-status` | — | Status bar PR number and link for the current branch. |
@@ -31,11 +32,12 @@ pi install -l npm:tau-all-agent
31
32
  | `loop` | `/loop` | Repeat a prompt until the agent signals success. |
32
33
  | `memory` | `/memory` | Opt-in project-local memory for learning and continuity across sessions. |
33
34
  | `notify` | — | Terminal notification when the agent is waiting for input. |
34
- | `openai-fast` | `/fast` | Toggle priority service tier for supported OpenAI models. |
35
- | `openai-verbosity` | `/verbosity` | Set verbosity for supported OpenAI models. |
35
+ | `fast` | `/fast` | Toggle fast processing for compatible models. |
36
+ | `openai-verbosity` | `/verbosity` | Set verbosity for supported GPT models. |
36
37
  | `review` | `/review`, `/triage` | Multi-focus review and PR feedback triage for PRs, branches, commits, and local changes, with integrated follow-up fixes. |
37
38
  | `sandbox` | `/sandbox` | OS-level sandboxing for bash commands with runtime overrides. |
38
- | `tool-display-mode` | `ctrl+o` | Cycle tool output between Pi's default rendering, expanded output, and compact summaries. |
39
+ | `subagent` | — | Delegate work to background Pi agents and follow up with them. |
40
+ | `tool-display-mode` | `ctrl+o` | Cycle tool output modes and combine background activity in the composer. |
39
41
  | `telegram` | `/telegram` | Interact with Pi via a Telegram bot and local daemon. |
40
42
  | `usage` | `/usage` | Historical provider usage breakdown with all-provider history and live quota snapshots. |
41
43
  | `websearch` | — | Web search via Gemini, OpenAI, or Claude, leveraging Pi or browser session credentials. |
@@ -43,17 +45,18 @@ pi install -l npm:tau-all-agent
43
45
 
44
46
  ## Skills
45
47
 
46
- | Skill | Description |
47
- | ------------------- | ---------------------------------------------------------------------------------- |
48
- | `browser-tools` | Interactive browser automation via Chrome DevTools Protocol. |
49
- | `git-clean-history` | Reimplement a branch on a fresh branch off `main` with a clean commit history. |
50
- | `git-commit` | Tidy, focused commits with clear rationale in messages. |
51
- | `homeassistant-ops` | Operate a Home Assistant instance via REST/WebSocket APIs. |
52
- | `openscad` | Create and render OpenSCAD 3D models, export STL. |
53
- | `oracle` | Second opinion from another LLM for debugging, refactors, design, or code reviews. |
54
- | `sentry` | Fetch and analyze Sentry issues, events, transactions, and logs. |
55
- | `update-changelog` | Update CHANGELOG.md following Keep a Changelog. |
56
- | `web-design` | Distinctive, production-ready web interfaces. |
48
+ | Skill | Description |
49
+ | --------------------- | ---------------------------------------------------------------------------------- |
50
+ | `browser-tools` | Interactive browser automation via Chrome DevTools Protocol. |
51
+ | `git-clean-history` | Reimplement a branch on a fresh branch off `main` with a clean commit history. |
52
+ | `git-commit` | Tidy, focused commits with clear rationale in messages. |
53
+ | `github-pull-request` | Create and steward pull requests through CI, review, and merge. |
54
+ | `homeassistant-ops` | Operate a Home Assistant instance via REST/WebSocket APIs. |
55
+ | `openscad` | Create and render OpenSCAD 3D models, export STL. |
56
+ | `oracle` | Second opinion from another LLM for debugging, refactors, design, or code reviews. |
57
+ | `sentry` | Fetch and analyze Sentry issues, events, transactions, and logs. |
58
+ | `update-changelog` | Update CHANGELOG.md following Keep a Changelog. |
59
+ | `web-design` | Distinctive, production-ready web interfaces. |
57
60
 
58
61
  ## Themes
59
62
 
@@ -10,9 +10,14 @@
10
10
  * 4. Submits the compiled answers when done
11
11
  */
12
12
 
13
- import { complete } from "@earendil-works/pi-ai/compat";
14
13
  import type { Model, Api, UserMessage } from "@earendil-works/pi-ai";
15
- import { BorderedLoader, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
14
+ import {
15
+ BorderedLoader,
16
+ type ExtensionAPI,
17
+ type KeybindingsManager,
18
+ type ModelRegistry,
19
+ type Theme,
20
+ } from "@earendil-works/pi-coding-agent";
16
21
  import {
17
22
  type Component,
18
23
  Editor,
@@ -20,12 +25,13 @@ import {
20
25
  type Focusable,
21
26
  Key,
22
27
  matchesKey,
23
- truncateToWidth,
24
28
  type TUI,
25
29
  visibleWidth,
26
30
  wrapTextWithAnsi,
27
31
  } from "@earendil-works/pi-tui";
28
32
 
33
+ const MIN_QUESTIONNAIRE_WIDTH = 40;
34
+
29
35
  const SYSTEM_PROMPT = `You extract items that need user input from assistant text:
30
36
 
31
37
  - Direct questions
@@ -78,8 +84,8 @@ Example output:
78
84
  "questions": []
79
85
  }`;
80
86
 
81
- const OPENAI_FAST_MODEL_ID = "gpt-5.3-codex-spark";
82
- const ANTHROPIC_FAST_MODEL_ID = "claude-haiku-4-5";
87
+ const OPENAI_FAST_MODEL_ID = "gpt-6-luna";
88
+ const ANTHROPIC_FAST_MODEL_ID = "claude-haiku-5-5";
83
89
 
84
90
  // Structured output format for question extraction
85
91
  interface ExtractedQuestion {
@@ -91,25 +97,16 @@ interface ExtractionResult {
91
97
  questions: ExtractedQuestion[];
92
98
  }
93
99
 
94
- interface ModelRequestAuth {
100
+ type ExtractionOutcome =
101
+ | { status: "success"; result: ExtractionResult }
102
+ | { status: "cancelled" }
103
+ | { status: "error"; message: string };
104
+
105
+ interface ModelSelection {
95
106
  model: Model<Api>;
96
- apiKey?: string;
97
- headers?: Record<string, string>;
98
- env?: Record<string, string>;
99
107
  }
100
108
 
101
- type ModelAuthLookup = {
102
- find: (provider: string, modelId: string) => Model<Api> | undefined;
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 }
111
- >;
112
- };
109
+ type ModelLookup = Pick<ModelRegistry, "find" | "getApiKeyAndHeaders">;
113
110
 
114
111
  type ModelFamily = "openai" | "anthropic";
115
112
 
@@ -125,10 +122,10 @@ function detectModelFamily(provider: string): ModelFamily | null {
125
122
  */
126
123
  async function selectExtractionModel(
127
124
  currentModel: Model<Api>,
128
- modelRegistry: ModelAuthLookup,
129
- ): Promise<ModelRequestAuth | null> {
125
+ modelRegistry: ModelLookup,
126
+ ): Promise<ModelSelection | null> {
130
127
  const family = detectModelFamily(currentModel.provider);
131
- if (!family) return resolveModelRequestAuth(modelRegistry, currentModel);
128
+ if (!family) return resolveModelSelection(modelRegistry, currentModel);
132
129
 
133
130
  const modelId = family === "openai" ? OPENAI_FAST_MODEL_ID : ANTHROPIC_FAST_MODEL_ID;
134
131
  const providerCandidates =
@@ -140,21 +137,21 @@ async function selectExtractionModel(
140
137
  const model = modelRegistry.find(provider, modelId);
141
138
  if (!model) continue;
142
139
 
143
- const auth = await resolveModelRequestAuth(modelRegistry, model);
144
- if (auth) {
145
- return auth;
140
+ const selection = await resolveModelSelection(modelRegistry, model);
141
+ if (selection) {
142
+ return selection;
146
143
  }
147
144
  }
148
145
 
149
- return resolveModelRequestAuth(modelRegistry, currentModel);
146
+ return resolveModelSelection(modelRegistry, currentModel);
150
147
  }
151
148
 
152
- async function resolveModelRequestAuth(
153
- modelRegistry: ModelAuthLookup,
149
+ async function resolveModelSelection(
150
+ modelRegistry: ModelLookup,
154
151
  model: Model<Api>,
155
- ): Promise<ModelRequestAuth | null> {
152
+ ): Promise<ModelSelection | null> {
156
153
  const auth = await modelRegistry.getApiKeyAndHeaders(model);
157
- return auth.ok ? { model, apiKey: auth.apiKey, headers: auth.headers, env: auth.env } : null;
154
+ return auth.ok ? { model } : null;
158
155
  }
159
156
 
160
157
  /**
@@ -171,11 +168,25 @@ function parseExtractionResult(text: string): ExtractionResult | null {
171
168
  jsonStr = jsonMatch[1].trim();
172
169
  }
173
170
 
174
- const parsed = JSON.parse(jsonStr);
175
- if (parsed && Array.isArray(parsed.questions)) {
176
- return parsed as ExtractionResult;
171
+ const parsed: unknown = JSON.parse(jsonStr);
172
+ if (
173
+ !parsed ||
174
+ typeof parsed !== "object" ||
175
+ !("questions" in parsed) ||
176
+ !Array.isArray(parsed.questions) ||
177
+ !parsed.questions.every(
178
+ (question: unknown): question is ExtractedQuestion =>
179
+ question !== null &&
180
+ typeof question === "object" &&
181
+ "question" in question &&
182
+ typeof question.question === "string" &&
183
+ question.question.trim().length > 0 &&
184
+ (!("context" in question) || typeof question.context === "string"),
185
+ )
186
+ ) {
187
+ return null;
177
188
  }
178
- return null;
189
+ return { questions: parsed.questions };
179
190
  } catch {
180
191
  return null;
181
192
  }
@@ -193,9 +204,15 @@ class QnAComponent implements Component, Focusable {
193
204
  private theme: Theme;
194
205
  private onDone: (result: string | null) => void;
195
206
  private showingConfirmation: boolean = false;
207
+ private questionOffset = 0;
208
+ private readingHeight = 0;
209
+ private maxQuestionOffset = 0;
210
+ private usable = false;
196
211
 
197
212
  // Cache
213
+ private renderWidth?: number;
198
214
  private cachedWidth?: number;
215
+ private cachedHeight?: number;
199
216
  private cachedLines?: string[];
200
217
 
201
218
  private _focused = false;
@@ -207,12 +224,14 @@ class QnAComponent implements Component, Focusable {
207
224
  set focused(value: boolean) {
208
225
  this._focused = value;
209
226
  this.editor.focused = value;
227
+ this.invalidate();
210
228
  }
211
229
 
212
230
  constructor(
213
231
  questions: ExtractedQuestion[],
214
232
  tui: TUI,
215
233
  theme: Theme,
234
+ private keybindings: KeybindingsManager,
216
235
  onDone: (result: string | null) => void,
217
236
  ) {
218
237
  this.questions = questions;
@@ -244,13 +263,14 @@ class QnAComponent implements Component, Focusable {
244
263
  }
245
264
 
246
265
  private saveCurrentAnswer(): void {
247
- this.answers[this.currentIndex] = this.editor.getText();
266
+ this.answers[this.currentIndex] = this.editor.getExpandedText();
248
267
  }
249
268
 
250
269
  private navigateTo(index: number): void {
251
270
  if (index < 0 || index >= this.questions.length) return;
252
271
  this.saveCurrentAnswer();
253
272
  this.currentIndex = index;
273
+ this.questionOffset = 0;
254
274
  this.editor.setText(this.answers[index] || "");
255
275
  this.invalidate();
256
276
  }
@@ -283,21 +303,57 @@ class QnAComponent implements Component, Focusable {
283
303
 
284
304
  invalidate(): void {
285
305
  this.cachedWidth = undefined;
306
+ this.cachedHeight = undefined;
286
307
  this.cachedLines = undefined;
287
308
  }
288
309
 
289
310
  handleInput(data: string): void {
311
+ // Recheck current geometry before accepting input, including resize before repaint.
312
+ // Both dimensions reserve the one-cell margins from overlayOptions below.
313
+ this.render(
314
+ Math.min(this.renderWidth ?? this.tui.terminal.columns - 2, this.tui.terminal.columns - 2),
315
+ );
316
+ if (!this.usable) {
317
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) this.cancel();
318
+ return;
319
+ }
320
+
321
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
322
+ if (this.showingConfirmation) {
323
+ this.showingConfirmation = false;
324
+ this.invalidate();
325
+ this.tui.requestRender();
326
+ } else {
327
+ this.cancel();
328
+ }
329
+ return;
330
+ }
331
+
332
+ // Viewport navigation reads the question. Unmatched keys retain native editing behavior.
333
+ const scrollActions = [
334
+ ["tui.altScreen.pageUp", -this.readingHeight],
335
+ ["tui.altScreen.pageDown", this.readingHeight],
336
+ ["tui.altScreen.top", -Infinity],
337
+ ["tui.altScreen.bottom", Infinity],
338
+ ] as const;
339
+ for (const [action, delta] of scrollActions) {
340
+ if (!this.keybindings.matches(data, action)) continue;
341
+ this.questionOffset = Math.max(
342
+ 0,
343
+ Math.min(this.maxQuestionOffset, this.questionOffset + delta),
344
+ );
345
+ this.invalidate();
346
+ this.tui.requestRender();
347
+ return;
348
+ }
349
+
290
350
  // Handle confirmation dialog
291
351
  if (this.showingConfirmation) {
292
352
  if (matchesKey(data, Key.enter) || data.toLowerCase() === "y") {
293
353
  this.submit();
294
354
  return;
295
355
  }
296
- if (
297
- matchesKey(data, Key.escape) ||
298
- matchesKey(data, Key.ctrl("c")) ||
299
- data.toLowerCase() === "n"
300
- ) {
356
+ if (data.toLowerCase() === "n") {
301
357
  this.showingConfirmation = false;
302
358
  this.invalidate();
303
359
  this.tui.requestRender();
@@ -306,12 +362,6 @@ class QnAComponent implements Component, Focusable {
306
362
  return;
307
363
  }
308
364
 
309
- // Global navigation and commands
310
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
311
- this.cancel();
312
- return;
313
- }
314
-
315
365
  // Tab / Shift+Tab for navigation
316
366
  if (matchesKey(data, Key.tab)) {
317
367
  if (this.currentIndex < this.questions.length - 1) {
@@ -368,14 +418,48 @@ class QnAComponent implements Component, Focusable {
368
418
  }
369
419
 
370
420
  render(width: number): string[] {
371
- if (this.cachedLines && this.cachedWidth === width) {
421
+ this.renderWidth = width;
422
+ const height = Math.max(1, this.tui.terminal.rows - 2);
423
+ if (this.cachedLines && this.cachedWidth === width && this.cachedHeight === height) {
424
+ this.usable = true;
372
425
  return this.cachedLines;
373
426
  }
374
427
 
375
428
  const theme = this.theme;
376
429
  const lines: string[] = [];
377
- const boxWidth = Math.min(width - 4, 120); // Allow wider box
378
- const contentWidth = boxWidth - 4; // 2 chars padding on each side
430
+ const boxWidth = Math.min(Math.max(MIN_QUESTIONNAIRE_WIDTH, width) - 4, 120);
431
+ const contentWidth = boxWidth - 4;
432
+ const editorLines = this.editor.render(contentWidth - 7);
433
+ const readingKeys = [
434
+ ...this.keybindings.getKeys("tui.altScreen.pageUp"),
435
+ ...this.keybindings.getKeys("tui.altScreen.pageDown"),
436
+ ...this.keybindings.getKeys("tui.altScreen.top"),
437
+ ...this.keybindings.getKeys("tui.altScreen.bottom"),
438
+ ].join("/");
439
+ const controls = this.showingConfirmation
440
+ ? `${theme.fg("warning", "Submit all answers?")} Enter/y confirm · Esc/n back`
441
+ : "Tab/Enter next · Shift+Tab prev · Shift+Enter newline · Esc cancel";
442
+ const footer = [
443
+ ...wrapTextWithAnsi(
444
+ theme.fg("dim", `Read question: ${readingKeys || "navigation keys unbound"}`),
445
+ contentWidth,
446
+ ),
447
+ ...wrapTextWithAnsi(controls, contentWidth),
448
+ ];
449
+ // Six frame rows (top/title/divider, editor spacer, footer divider, bottom),
450
+ // the full native editor and wrapped footer, plus at least one reading row.
451
+ const minimumHeight = 6 + editorLines.length + footer.length + 1;
452
+ this.usable = width >= MIN_QUESTIONNAIRE_WIDTH && height >= minimumHeight;
453
+ if (!this.usable) {
454
+ this.invalidate();
455
+ return wrapTextWithAnsi(
456
+ theme.fg(
457
+ "muted",
458
+ `Resize to ${MIN_QUESTIONNAIRE_WIDTH + 2}+ columns and more rows. Esc cancels.`,
459
+ ),
460
+ Math.max(1, width),
461
+ ).slice(0, height);
462
+ }
379
463
 
380
464
  // Helper to create horizontal lines (dim the whole thing at once)
381
465
  const horizontalLine = (count: number) => "─".repeat(count);
@@ -388,10 +472,6 @@ class QnAComponent implements Component, Focusable {
388
472
  return theme.fg("dim", "│") + paddedContent + " ".repeat(rightPad) + theme.fg("dim", "│");
389
473
  };
390
474
 
391
- const emptyBoxLine = (): string => {
392
- return theme.fg("dim", "│") + " ".repeat(boxWidth - 2) + theme.fg("dim", "│");
393
- };
394
-
395
475
  const padToWidth = (line: string): string => {
396
476
  const len = visibleWidth(line);
397
477
  return line + " ".repeat(Math.max(0, width - len));
@@ -403,7 +483,14 @@ class QnAComponent implements Component, Focusable {
403
483
  lines.push(padToWidth(boxLine(title)));
404
484
  lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
405
485
 
406
- // Progress indicator
486
+ // Start with the question even when only one reading row fits.
487
+ const q = this.questions[this.currentIndex];
488
+ const reading = wrapTextWithAnsi(`> ${q.question}`, contentWidth);
489
+ if (q.context) {
490
+ reading.push("", ...wrapTextWithAnsi(theme.fg("muted", q.context), contentWidth - 2));
491
+ }
492
+
493
+ // Progress belongs to the reading region so every indicator remains reachable.
407
494
  const progressParts: string[] = [];
408
495
  for (let i = 0; i < this.questions.length; i++) {
409
496
  const answered = (this.answers[i]?.trim() || "").length > 0;
@@ -416,59 +503,30 @@ class QnAComponent implements Component, Focusable {
416
503
  progressParts.push(theme.fg("dim", "○"));
417
504
  }
418
505
  }
419
- lines.push(padToWidth(boxLine(progressParts.join(" "))));
420
- lines.push(padToWidth(emptyBoxLine()));
421
-
422
- // Current question
423
- const q = this.questions[this.currentIndex];
424
- const questionText = `> ${q.question}`;
425
- const wrappedQuestion = wrapTextWithAnsi(questionText, contentWidth);
426
- for (const line of wrappedQuestion) {
506
+ reading.push("", ...wrapTextWithAnsi(progressParts.join(" "), contentWidth));
507
+
508
+ this.readingHeight = height - minimumHeight + 1;
509
+ this.maxQuestionOffset = Math.max(0, reading.length - this.readingHeight);
510
+ this.questionOffset = Math.min(this.questionOffset, this.maxQuestionOffset);
511
+ for (const line of reading.slice(
512
+ this.questionOffset,
513
+ this.questionOffset + this.readingHeight,
514
+ )) {
427
515
  lines.push(padToWidth(boxLine(line)));
428
516
  }
517
+ lines.push(padToWidth(boxLine("")));
429
518
 
430
- // Context if present
431
- if (q.context) {
432
- lines.push(padToWidth(emptyBoxLine()));
433
- const contextText = theme.fg("muted", `${q.context}`);
434
- const wrappedContext = wrapTextWithAnsi(contextText, contentWidth - 2);
435
- for (const line of wrappedContext) {
436
- lines.push(padToWidth(boxLine(line)));
437
- }
519
+ // Keep the entire native editor, including its cursor and scrolling indicators.
520
+ for (let i = 0; i < editorLines.length; i++) {
521
+ lines.push(padToWidth(boxLine((i === 1 ? theme.bold("A: ") : " ") + editorLines[i])));
438
522
  }
439
523
 
440
- lines.push(padToWidth(emptyBoxLine()));
441
-
442
- // Render the editor component (multi-line input) with padding
443
- // Skip the first and last lines (editor's own border lines)
444
- const answerPrefix = theme.bold("A: ");
445
- const editorWidth = contentWidth - 4 - 3; // Extra padding + space for "A: "
446
- const editorLines = this.editor.render(editorWidth);
447
- for (let i = 1; i < editorLines.length - 1; i++) {
448
- if (i === 1) {
449
- // First content line gets the "A: " prefix
450
- lines.push(padToWidth(boxLine(answerPrefix + editorLines[i])));
451
- } else {
452
- // Subsequent lines get padding to align with the first line
453
- lines.push(padToWidth(boxLine(" " + editorLines[i])));
454
- }
455
- }
456
-
457
- lines.push(padToWidth(emptyBoxLine()));
458
-
459
- // Confirmation dialog or footer with controls
460
- if (this.showingConfirmation) {
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)")}`;
463
- lines.push(padToWidth(boxLine(truncateToWidth(confirmMsg, contentWidth))));
464
- } else {
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`;
467
- lines.push(padToWidth(boxLine(truncateToWidth(controls, contentWidth))));
468
- }
524
+ lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
525
+ for (const line of footer) lines.push(padToWidth(boxLine(line)));
469
526
  lines.push(padToWidth(theme.fg("dim", "╰" + horizontalLine(boxWidth - 2) + "╯")));
470
527
 
471
528
  this.cachedWidth = width;
529
+ this.cachedHeight = height;
472
530
  this.cachedLines = lines;
473
531
  return lines;
474
532
  }
@@ -525,67 +583,86 @@ export default function (pi: ExtensionAPI) {
525
583
  }
526
584
 
527
585
  // Run extraction with loader UI
528
- const extractionResult = await ctx.ui.custom<ExtractionResult | null>(
529
- (tui, theme, _kb, done) => {
530
- const loader = new BorderedLoader(
531
- tui,
532
- theme,
533
- `Extracting questions using ${extractionSelection.model.id}...`,
586
+ const extraction = await ctx.ui.custom<ExtractionOutcome>((tui, theme, _kb, done) => {
587
+ const loader = new BorderedLoader(
588
+ tui,
589
+ theme,
590
+ `Extracting questions using ${extractionSelection.model.id}...`,
591
+ );
592
+ loader.onAbort = () => done({ status: "cancelled" });
593
+
594
+ const doExtract = async (): Promise<ExtractionOutcome> => {
595
+ const userMessage: UserMessage = {
596
+ role: "user",
597
+ content: [{ type: "text", text: lastAssistantText! }],
598
+ timestamp: Date.now(),
599
+ };
600
+
601
+ const response = await ctx.modelRegistry.complete(
602
+ extractionSelection.model,
603
+ { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
604
+ { signal: loader.signal },
534
605
  );
535
- loader.onAbort = () => done(null);
536
606
 
537
- const doExtract = async () => {
538
- const userMessage: UserMessage = {
539
- role: "user",
540
- content: [{ type: "text", text: lastAssistantText! }],
541
- timestamp: Date.now(),
607
+ if (loader.signal.aborted || response.stopReason === "aborted") {
608
+ return { status: "cancelled" };
609
+ }
610
+ if (response.stopReason !== "stop") {
611
+ return {
612
+ status: "error",
613
+ message: response.errorMessage
614
+ ? `Question extraction failed: ${response.errorMessage}`
615
+ : `Question extraction did not complete (${response.stopReason})`,
542
616
  };
617
+ }
543
618
 
544
- const response = await complete(
545
- extractionSelection.model,
546
- { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
547
- {
548
- apiKey: extractionSelection.apiKey,
549
- headers: extractionSelection.headers,
550
- env: extractionSelection.env,
551
- signal: loader.signal,
552
- },
619
+ const responseText = response.content
620
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
621
+ .map((c) => c.text)
622
+ .join("\n");
623
+
624
+ const result = parseExtractionResult(responseText);
625
+ return result
626
+ ? { status: "success", result }
627
+ : { status: "error", message: "Question extraction returned an invalid response" };
628
+ };
629
+
630
+ doExtract()
631
+ .then(done)
632
+ .catch((error: unknown) => {
633
+ done(
634
+ loader.signal.aborted
635
+ ? { status: "cancelled" }
636
+ : {
637
+ status: "error",
638
+ message: `Question extraction failed: ${error instanceof Error ? error.message : String(error)}`,
639
+ },
553
640
  );
641
+ });
554
642
 
555
- if (response.stopReason === "aborted") {
556
- return null;
557
- }
558
-
559
- const responseText = response.content
560
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
561
- .map((c) => c.text)
562
- .join("\n");
563
-
564
- return parseExtractionResult(responseText);
565
- };
566
-
567
- doExtract()
568
- .then(done)
569
- .catch(() => done(null));
570
-
571
- return loader;
572
- },
573
- );
643
+ return loader;
644
+ });
574
645
 
575
- if (extractionResult === null) {
646
+ if (extraction.status === "cancelled") {
576
647
  ctx.ui.notify("Cancelled", "info");
577
648
  return;
578
649
  }
650
+ if (extraction.status === "error") {
651
+ ctx.ui.notify(extraction.message, "error");
652
+ return;
653
+ }
579
654
 
580
- if (extractionResult.questions.length === 0) {
655
+ const { questions } = extraction.result;
656
+ if (questions.length === 0) {
581
657
  ctx.ui.notify("No questions found in the last message", "info");
582
658
  return;
583
659
  }
584
660
 
585
661
  // Show the Q&A component
586
- const answersResult = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
587
- return new QnAComponent(extractionResult.questions, tui, theme, done);
588
- });
662
+ const answersResult = await ctx.ui.custom<string | null>(
663
+ (tui, theme, kb, done) => new QnAComponent(questions, tui, theme, kb, done),
664
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 1 } },
665
+ );
589
666
 
590
667
  if (answersResult === null) {
591
668
  ctx.ui.notify("Cancelled", "info");
@@ -0,0 +1,7 @@
1
+ # Branch terminals
2
+
3
+ `/branch` forks the selected conversation without switching the current session. Inside tmux, `--branch-tmux-layout` chooses `window`, `split-right`, or `split-down`; a `/branch` argument overrides that setting.
4
+
5
+ `--branch-term` takes precedence over tmux and accepts a Bash launcher template. Use unquoted `{cwd}` and `{session}` placeholders for path arguments. `{command}` supplies a single command-string argument, suitable for a launcher's command option or `bash -c`. Do not add shell quotes around placeholders; they are already quoted. If neither `{session}` nor `{command}` appears, the fork path is appended. The launcher runs in the session's working directory with the current environment.
6
+
7
+ A failed launcher produces a recovery command. Reloading or closing the original Pi session stops observing launcher failures but does not close user-owned terminals.