tau-all-agent 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/extensions/answer.ts +115 -85
  2. package/extensions/branch-term/index.ts +9 -5
  3. package/extensions/btw.ts +70 -36
  4. package/extensions/insights.ts +70 -56
  5. package/extensions/loop.ts +14 -52
  6. package/extensions/memory.ts +65 -33
  7. package/extensions/notify.ts +5 -3
  8. package/extensions/review/fix.ts +10 -16
  9. package/extensions/review/git.ts +7 -1
  10. package/extensions/review/index.ts +2 -2
  11. package/extensions/review/models.ts +12 -2
  12. package/extensions/review/review.ts +3 -5
  13. package/extensions/review/runtime.ts +9 -8
  14. package/extensions/review/triage.ts +2 -4
  15. package/extensions/sandbox/README.md +7 -0
  16. package/extensions/sandbox/bash.ts +83 -85
  17. package/extensions/sandbox/command.ts +15 -1
  18. package/extensions/sandbox/config.ts +26 -15
  19. package/extensions/sandbox/index.ts +21 -11
  20. package/extensions/sandbox/permissions/network.ts +8 -3
  21. package/extensions/sandbox/permissions/unsandboxed.ts +1 -240
  22. package/extensions/sandbox/runtime.ts +54 -20
  23. package/extensions/subagent/README.md +58 -1
  24. package/extensions/subagent/config.ts +118 -0
  25. package/extensions/subagent/index.ts +54 -22
  26. package/extensions/telegram/README.md +2 -0
  27. package/extensions/telegram/assistant-result.d.mts +4 -1
  28. package/extensions/telegram/assistant-result.mjs +31 -16
  29. package/extensions/telegram/daemon.mjs +146 -194
  30. package/extensions/telegram/index.ts +11 -7
  31. package/extensions/tool-display-mode.ts +29 -50
  32. package/extensions/usage/README.md +5 -0
  33. package/extensions/usage/index.ts +341 -349
  34. package/extensions/websearch/README.md +8 -1
  35. package/extensions/websearch/config.ts +2 -1
  36. package/extensions/websearch/index.ts +18 -9
  37. package/extensions/websearch/providers/{openai-codex.pi.ts → openai.pi.ts} +51 -16
  38. package/extensions/websearch/providers/shared.ts +10 -3
  39. package/extensions/websearch/types.ts +2 -1
  40. package/extensions/worktree.ts +33 -89
  41. package/package.json +1 -1
  42. package/skills/oracle/SKILL.md +14 -8
  43. package/skills/oracle/scripts/oracle +43 -19
  44. package/skills/update-changelog/SKILL.md +8 -4
@@ -14,6 +14,7 @@ import type { Model, Api, UserMessage } from "@earendil-works/pi-ai";
14
14
  import {
15
15
  BorderedLoader,
16
16
  type ExtensionAPI,
17
+ type KeybindingsManager,
17
18
  type ModelRegistry,
18
19
  type Theme,
19
20
  } from "@earendil-works/pi-coding-agent";
@@ -24,7 +25,6 @@ import {
24
25
  type Focusable,
25
26
  Key,
26
27
  matchesKey,
27
- truncateToWidth,
28
28
  type TUI,
29
29
  visibleWidth,
30
30
  wrapTextWithAnsi,
@@ -84,8 +84,8 @@ Example output:
84
84
  "questions": []
85
85
  }`;
86
86
 
87
- const OPENAI_FAST_MODEL_ID = "gpt-5.6-luna";
88
- 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";
89
89
 
90
90
  // Structured output format for question extraction
91
91
  interface ExtractedQuestion {
@@ -204,10 +204,15 @@ class QnAComponent implements Component, Focusable {
204
204
  private theme: Theme;
205
205
  private onDone: (result: string | null) => void;
206
206
  private showingConfirmation: boolean = false;
207
+ private questionOffset = 0;
208
+ private readingHeight = 0;
209
+ private maxQuestionOffset = 0;
210
+ private usable = false;
207
211
 
208
212
  // Cache
209
213
  private renderWidth?: number;
210
214
  private cachedWidth?: number;
215
+ private cachedHeight?: number;
211
216
  private cachedLines?: string[];
212
217
 
213
218
  private _focused = false;
@@ -219,12 +224,14 @@ class QnAComponent implements Component, Focusable {
219
224
  set focused(value: boolean) {
220
225
  this._focused = value;
221
226
  this.editor.focused = value;
227
+ this.invalidate();
222
228
  }
223
229
 
224
230
  constructor(
225
231
  questions: ExtractedQuestion[],
226
232
  tui: TUI,
227
233
  theme: Theme,
234
+ private keybindings: KeybindingsManager,
228
235
  onDone: (result: string | null) => void,
229
236
  ) {
230
237
  this.questions = questions;
@@ -263,6 +270,7 @@ class QnAComponent implements Component, Focusable {
263
270
  if (index < 0 || index >= this.questions.length) return;
264
271
  this.saveCurrentAnswer();
265
272
  this.currentIndex = index;
273
+ this.questionOffset = 0;
266
274
  this.editor.setText(this.answers[index] || "");
267
275
  this.invalidate();
268
276
  }
@@ -295,26 +303,57 @@ class QnAComponent implements Component, Focusable {
295
303
 
296
304
  invalidate(): void {
297
305
  this.cachedWidth = undefined;
306
+ this.cachedHeight = undefined;
298
307
  this.cachedLines = undefined;
299
308
  }
300
309
 
301
310
  handleInput(data: string): void {
302
- if ((this.renderWidth ?? this.tui.terminal.columns) < MIN_QUESTIONNAIRE_WIDTH) {
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) {
303
317
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) this.cancel();
304
318
  return;
305
319
  }
306
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
+
307
350
  // Handle confirmation dialog
308
351
  if (this.showingConfirmation) {
309
352
  if (matchesKey(data, Key.enter) || data.toLowerCase() === "y") {
310
353
  this.submit();
311
354
  return;
312
355
  }
313
- if (
314
- matchesKey(data, Key.escape) ||
315
- matchesKey(data, Key.ctrl("c")) ||
316
- data.toLowerCase() === "n"
317
- ) {
356
+ if (data.toLowerCase() === "n") {
318
357
  this.showingConfirmation = false;
319
358
  this.invalidate();
320
359
  this.tui.requestRender();
@@ -323,12 +362,6 @@ class QnAComponent implements Component, Focusable {
323
362
  return;
324
363
  }
325
364
 
326
- // Global navigation and commands
327
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
328
- this.cancel();
329
- return;
330
- }
331
-
332
365
  // Tab / Shift+Tab for navigation
333
366
  if (matchesKey(data, Key.tab)) {
334
367
  if (this.currentIndex < this.questions.length - 1) {
@@ -386,26 +419,47 @@ class QnAComponent implements Component, Focusable {
386
419
 
387
420
  render(width: number): string[] {
388
421
  this.renderWidth = width;
389
- if (width < MIN_QUESTIONNAIRE_WIDTH) {
390
- return [
391
- truncateToWidth(
392
- this.theme.fg(
393
- "muted",
394
- `Resize to ${MIN_QUESTIONNAIRE_WIDTH}+ columns to answer. Esc cancels.`,
395
- ),
396
- width,
397
- ),
398
- ];
399
- }
400
-
401
- if (this.cachedLines && this.cachedWidth === 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;
402
425
  return this.cachedLines;
403
426
  }
404
427
 
405
428
  const theme = this.theme;
406
429
  const lines: string[] = [];
407
- const boxWidth = Math.min(width - 4, 120); // Allow wider box
408
- 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
+ }
409
463
 
410
464
  // Helper to create horizontal lines (dim the whole thing at once)
411
465
  const horizontalLine = (count: number) => "─".repeat(count);
@@ -418,10 +472,6 @@ class QnAComponent implements Component, Focusable {
418
472
  return theme.fg("dim", "│") + paddedContent + " ".repeat(rightPad) + theme.fg("dim", "│");
419
473
  };
420
474
 
421
- const emptyBoxLine = (): string => {
422
- return theme.fg("dim", "│") + " ".repeat(boxWidth - 2) + theme.fg("dim", "│");
423
- };
424
-
425
475
  const padToWidth = (line: string): string => {
426
476
  const len = visibleWidth(line);
427
477
  return line + " ".repeat(Math.max(0, width - len));
@@ -433,7 +483,14 @@ class QnAComponent implements Component, Focusable {
433
483
  lines.push(padToWidth(boxLine(title)));
434
484
  lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
435
485
 
436
- // 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.
437
494
  const progressParts: string[] = [];
438
495
  for (let i = 0; i < this.questions.length; i++) {
439
496
  const answered = (this.answers[i]?.trim() || "").length > 0;
@@ -446,61 +503,30 @@ class QnAComponent implements Component, Focusable {
446
503
  progressParts.push(theme.fg("dim", "○"));
447
504
  }
448
505
  }
449
- for (const line of wrapTextWithAnsi(progressParts.join(" "), contentWidth)) {
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
+ )) {
450
515
  lines.push(padToWidth(boxLine(line)));
451
516
  }
452
- lines.push(padToWidth(emptyBoxLine()));
517
+ lines.push(padToWidth(boxLine("")));
453
518
 
454
- // Current question
455
- const q = this.questions[this.currentIndex];
456
- const questionText = `> ${q.question}`;
457
- const wrappedQuestion = wrapTextWithAnsi(questionText, contentWidth);
458
- for (const line of wrappedQuestion) {
459
- lines.push(padToWidth(boxLine(line)));
460
- }
461
-
462
- // Context if present
463
- if (q.context) {
464
- lines.push(padToWidth(emptyBoxLine()));
465
- const contextText = theme.fg("muted", `${q.context}`);
466
- const wrappedContext = wrapTextWithAnsi(contextText, contentWidth - 2);
467
- for (const line of wrappedContext) {
468
- lines.push(padToWidth(boxLine(line)));
469
- }
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])));
470
522
  }
471
523
 
472
- lines.push(padToWidth(emptyBoxLine()));
473
-
474
- // Render the editor component (multi-line input) with padding
475
- // Skip the first and last lines (editor's own border lines)
476
- const answerPrefix = theme.bold("A: ");
477
- const editorWidth = contentWidth - 4 - 3; // Extra padding + space for "A: "
478
- const editorLines = this.editor.render(editorWidth);
479
- for (let i = 1; i < editorLines.length - 1; i++) {
480
- if (i === 1) {
481
- // First content line gets the "A: " prefix
482
- lines.push(padToWidth(boxLine(answerPrefix + editorLines[i])));
483
- } else {
484
- // Subsequent lines get padding to align with the first line
485
- lines.push(padToWidth(boxLine(" " + editorLines[i])));
486
- }
487
- }
488
-
489
- lines.push(padToWidth(emptyBoxLine()));
490
-
491
- // Confirmation dialog or footer with controls
492
- if (this.showingConfirmation) {
493
- lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
494
- const confirmMsg = `${theme.fg("warning", "Submit all answers?")} ${theme.fg("dim", "(Enter/y to confirm, Esc/n to cancel)")}`;
495
- lines.push(padToWidth(boxLine(truncateToWidth(confirmMsg, contentWidth))));
496
- } else {
497
- lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
498
- 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`;
499
- lines.push(padToWidth(boxLine(truncateToWidth(controls, contentWidth))));
500
- }
524
+ lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
525
+ for (const line of footer) lines.push(padToWidth(boxLine(line)));
501
526
  lines.push(padToWidth(theme.fg("dim", "╰" + horizontalLine(boxWidth - 2) + "╯")));
502
527
 
503
528
  this.cachedWidth = width;
529
+ this.cachedHeight = height;
504
530
  this.cachedLines = lines;
505
531
  return lines;
506
532
  }
@@ -633,9 +659,13 @@ export default function (pi: ExtensionAPI) {
633
659
  }
634
660
 
635
661
  // Show the Q&A component
636
- const answersResult = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
637
- return new QnAComponent(questions, tui, theme, done);
638
- });
662
+ const answersResult = await ctx.ui.custom<string | null>(
663
+ (tui, theme, kb, done) => new QnAComponent(questions, tui, theme, kb, done),
664
+ {
665
+ overlay: true,
666
+ overlayOptions: { anchor: "bottom-center", width: "100%", maxHeight: "100%", margin: 1 },
667
+ },
668
+ );
639
669
 
640
670
  if (answersResult === null) {
641
671
  ctx.ui.notify("Cancelled", "info");
@@ -157,7 +157,7 @@ function showCommandMessage(
157
157
  command: string,
158
158
  ): void {
159
159
  const copiedToClipboard = copyToClipboard(command);
160
- if (!ctx.hasUI) {
160
+ if (ctx.mode === "print") {
161
161
  console.log(formatCommandMessageIntro(intro, copiedToClipboard));
162
162
  console.log(command);
163
163
  return;
@@ -269,9 +269,13 @@ export default function (pi: ExtensionAPI) {
269
269
  }
270
270
 
271
271
  const leafId = ctx.sessionManager.getLeafId();
272
- const hasAssistantReply = ctx.sessionManager
272
+ const hasPersistedMessage = ctx.sessionManager
273
273
  .getEntries()
274
- .some((entry) => entry.type === "message" && entry.message.role === "assistant");
274
+ .some(
275
+ (entry) =>
276
+ entry.type === "message" &&
277
+ (entry.message.role === "user" || entry.message.role === "assistant"),
278
+ );
275
279
 
276
280
  let forkFile: string;
277
281
  if (leafId && hasValidSessionFile(sessionFile)) {
@@ -285,7 +289,7 @@ export default function (pi: ExtensionAPI) {
285
289
  }
286
290
  forkFile = persistFork(forkManager);
287
291
  } else {
288
- if (leafId && hasAssistantReply) {
292
+ if (leafId && hasPersistedMessage) {
289
293
  throw new Error(`Current session file is missing or invalid: ${sessionFile}`);
290
294
  }
291
295
 
@@ -293,7 +297,7 @@ export default function (pi: ExtensionAPI) {
293
297
  ? "Current session has no persisted history yet. Opening a fresh session."
294
298
  : "Selected conversation is empty. Opening a fresh session.";
295
299
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
296
- else console.log(message);
300
+ else console.error(message);
297
301
 
298
302
  forkFile = persistFork(
299
303
  SessionManager.create(ctx.cwd, ctx.sessionManager.getSessionDir(), {
package/extensions/btw.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  matchesKey,
25
25
  truncateToWidth,
26
26
  type Component,
27
+ type KeybindingsManager,
27
28
  type TUI,
28
29
  visibleWidth,
29
30
  wrapTextWithAnsi,
@@ -32,6 +33,8 @@ import {
32
33
  const STATUS_KEY = "0-btw";
33
34
  const RESULT_MARKDOWN_THEME = getMarkdownTheme();
34
35
  const MIN_RESULT_WIDTH = 50;
36
+ const RESULT_CHROME_ROWS = 6;
37
+ const MIN_RESULT_ROWS = RESULT_CHROME_ROWS + 3; // Two overlay margins and one reading row.
35
38
  const STATUS_SPINNER_INTERVAL_MS = 80;
36
39
  const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
37
40
 
@@ -350,9 +353,14 @@ function seedSessionManager(sessionManager: SessionManager, messages: AgentMessa
350
353
  }
351
354
 
352
355
  async function showResultDialog(ctx: ExtensionContext, result: BtwResult): Promise<void> {
353
- await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
354
- return new BtwResultComponent(result, tui, theme, done);
355
- });
356
+ await ctx.ui.custom<void>(
357
+ (tui, theme, keybindings, done) =>
358
+ new BtwResultComponent(result, tui, theme, keybindings, done),
359
+ {
360
+ overlay: true,
361
+ overlayOptions: { anchor: "bottom-center", width: "100%", maxHeight: "100%", margin: 1 },
362
+ },
363
+ );
356
364
  }
357
365
 
358
366
  function getLastAssistantMessage(messages: AgentMessage[]): AssistantMessage | null {
@@ -389,6 +397,7 @@ class BtwResultComponent implements Component {
389
397
  private readonly result: BtwResult,
390
398
  private readonly tui: TUI,
391
399
  private readonly theme: Theme,
400
+ private readonly keybindings: KeybindingsManager,
392
401
  private readonly onDone: () => void,
393
402
  ) {
394
403
  this.markdown = new Markdown(result.answer, 0, 0, RESULT_MARKDOWN_THEME);
@@ -412,49 +421,61 @@ class BtwResultComponent implements Component {
412
421
  }
413
422
 
414
423
  const width = this.renderWidth ?? this.tui.terminal.columns;
415
- if (width < MIN_RESULT_WIDTH) return;
424
+ if (width < MIN_RESULT_WIDTH || this.tui.terminal.rows < MIN_RESULT_ROWS) return;
416
425
 
417
426
  const bodyHeight = this.getBodyHeight();
418
- const boxWidth = this.getBoxWidth(width);
427
+ const boxWidth = width;
419
428
  const bodyLines = this.getBodyLines(this.getContentWidth(boxWidth));
420
429
  const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
421
430
 
422
- if (matchesKey(data, Key.up) || data.toLowerCase() === "k") {
423
- this.scrollOffset = Math.max(0, this.scrollOffset - 1);
424
- } else if (matchesKey(data, Key.down) || data.toLowerCase() === "j") {
425
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
426
- } else if (matchesKey(data, Key.pageUp)) {
427
- this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(4, bodyHeight - 2));
428
- } else if (matchesKey(data, Key.pageDown)) {
429
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + Math.max(4, bodyHeight - 2));
430
- } else if (matchesKey(data, Key.home)) {
431
+ this.scrollOffset = clamp(this.scrollOffset, 0, maxScroll);
432
+ const kb = this.keybindings;
433
+ if (kb.matches(data, "tui.altScreen.pageUp")) {
434
+ this.scrollOffset -= bodyHeight;
435
+ } else if (kb.matches(data, "tui.altScreen.pageDown")) {
436
+ this.scrollOffset += bodyHeight;
437
+ } else if (kb.matches(data, "tui.altScreen.halfPageUp")) {
438
+ this.scrollOffset -= Math.max(1, Math.floor(bodyHeight / 2));
439
+ } else if (kb.matches(data, "tui.altScreen.halfPageDown")) {
440
+ this.scrollOffset += Math.max(1, Math.floor(bodyHeight / 2));
441
+ } else if (kb.matches(data, "tui.altScreen.top")) {
431
442
  this.scrollOffset = 0;
432
- } else if (matchesKey(data, Key.end)) {
443
+ } else if (kb.matches(data, "tui.altScreen.bottom")) {
433
444
  this.scrollOffset = maxScroll;
445
+ } else if (kb.matches(data, "tui.altScreen.lineUp")) {
446
+ this.scrollOffset -= 1;
447
+ } else if (kb.matches(data, "tui.altScreen.lineDown")) {
448
+ this.scrollOffset += 1;
449
+ } else if (matchesKey(data, Key.up) || data.toLowerCase() === "k") {
450
+ this.scrollOffset -= 1;
451
+ } else if (matchesKey(data, Key.down) || data.toLowerCase() === "j") {
452
+ this.scrollOffset += 1;
434
453
  } else {
435
454
  return;
436
455
  }
437
456
 
457
+ this.scrollOffset = clamp(this.scrollOffset, 0, maxScroll);
438
458
  this.tui.requestRender();
439
459
  }
440
460
 
441
461
  render(width: number): string[] {
442
462
  this.renderWidth = width;
443
- if (width < MIN_RESULT_WIDTH) {
463
+ if (width < 1 || this.tui.terminal.rows <= 2) return [];
464
+ if (width < MIN_RESULT_WIDTH || this.tui.terminal.rows < MIN_RESULT_ROWS) {
444
465
  return [
445
466
  truncateToWidth(
446
- this.theme.fg("muted", `Resize to ${MIN_RESULT_WIDTH}+ columns to view answer.`),
467
+ this.theme.fg(
468
+ "muted",
469
+ `Esc close · Resize to ${MIN_RESULT_WIDTH + 2}+ columns, ${MIN_RESULT_ROWS}+ rows.`,
470
+ ),
447
471
  width,
472
+ "",
448
473
  ),
449
474
  ];
450
475
  }
451
476
 
452
- const boxWidth = this.getBoxWidth(width);
477
+ const boxWidth = width;
453
478
  const contentWidth = this.getContentWidth(boxWidth);
454
- const questionLines = wrapTextWithAnsi(
455
- this.theme.fg("muted", this.result.question),
456
- contentWidth,
457
- );
458
479
  const bodyLines = this.getBodyLines(contentWidth);
459
480
  const bodyHeight = this.getBodyHeight();
460
481
  const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
@@ -462,11 +483,7 @@ class BtwResultComponent implements Component {
462
483
 
463
484
  const lines: string[] = [];
464
485
  lines.push(this.borderLine("╭", "╮", boxWidth));
465
- lines.push(this.boxLine(this.theme.bold("Request"), boxWidth));
466
- for (const line of questionLines) {
467
- lines.push(this.boxLine(line, boxWidth));
468
- }
469
- lines.push(this.separatorLine(boxWidth));
486
+ lines.push(this.boxLine(this.theme.bold("BTW"), boxWidth));
470
487
 
471
488
  const visibleBody = bodyLines.slice(this.scrollOffset, this.scrollOffset + bodyHeight);
472
489
  for (const line of visibleBody) {
@@ -478,15 +495,30 @@ class BtwResultComponent implements Component {
478
495
 
479
496
  lines.push(this.separatorLine(boxWidth));
480
497
  const scrollText = `${Math.min(bodyLines.length, this.scrollOffset + 1)}-${Math.min(bodyLines.length, this.scrollOffset + visibleBody.length)}/${bodyLines.length}`;
481
- const controls = `${this.theme.fg("dim", "↑↓ scroll · PgUp/PgDn jump · Home/End · Enter/Esc close")} ${this.theme.fg("muted", scrollText)}`;
482
- lines.push(this.boxLine(truncateToWidth(controls, contentWidth), boxWidth));
498
+ const navigation = [
499
+ ["pageUp", "pageDown", "page"],
500
+ ["top", "bottom", "ends"],
501
+ ] as const;
502
+ const hints = [
503
+ "↑↓/j/k scroll",
504
+ ...navigation.flatMap(([up, down, label]) => {
505
+ const keys = [up, down].map((action) =>
506
+ this.keybindings
507
+ .getKeys(`tui.altScreen.${action}`)
508
+ .find((key) => !["enter", "escape", "ctrl+c", "q"].includes(key)),
509
+ );
510
+ return keys.some(Boolean) ? [`${keys.map((key) => key ?? "—").join("/")} ${label}`] : [];
511
+ }),
512
+ ];
513
+ lines.push(this.boxLine(this.theme.fg("dim", hints.join(" · ")), boxWidth));
514
+ lines.push(this.boxLine(this.theme.fg("dim", `Enter/Esc close · ${scrollText}`), boxWidth));
483
515
  lines.push(this.borderLine("╰", "╯", boxWidth));
484
516
 
485
517
  return lines;
486
518
  }
487
519
 
488
520
  private getBodyHeight(): number {
489
- return Math.max(8, this.tui.terminal.rows - 14);
521
+ return this.tui.terminal.rows - 2 - RESULT_CHROME_ROWS;
490
522
  }
491
523
 
492
524
  private getBodyLines(contentWidth: number): string[] {
@@ -494,18 +526,20 @@ class BtwResultComponent implements Component {
494
526
  return this.cachedBodyLines;
495
527
  }
496
528
 
497
- const lines = this.markdown.render(contentWidth);
529
+ const lines = [
530
+ this.theme.bold("Request"),
531
+ ...wrapTextWithAnsi(this.theme.fg("muted", this.result.question), contentWidth),
532
+ "",
533
+ this.theme.bold("Answer"),
534
+ ...this.markdown.render(contentWidth),
535
+ ];
498
536
  this.cachedBodyWidth = contentWidth;
499
537
  this.cachedBodyLines = lines;
500
538
  return lines;
501
539
  }
502
540
 
503
- private getBoxWidth(width: number): number {
504
- return Math.max(MIN_RESULT_WIDTH, Math.min(width - 2, 140));
505
- }
506
-
507
541
  private getContentWidth(boxWidth: number): number {
508
- return Math.max(10, boxWidth - 4);
542
+ return boxWidth - 4;
509
543
  }
510
544
 
511
545
  private borderLine(left: string, right: string, width: number): string {