tau-all-agent 0.2.0 → 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 (42) hide show
  1. package/extensions/answer.ts +112 -85
  2. package/extensions/branch-term/index.ts +9 -5
  3. package/extensions/btw.ts +67 -36
  4. package/extensions/insights.ts +67 -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 +5 -0
  16. package/extensions/sandbox/bash.ts +83 -85
  17. package/extensions/sandbox/index.ts +21 -11
  18. package/extensions/sandbox/permissions/network.ts +8 -3
  19. package/extensions/sandbox/permissions/unsandboxed.ts +1 -240
  20. package/extensions/sandbox/runtime.ts +53 -19
  21. package/extensions/subagent/README.md +58 -1
  22. package/extensions/subagent/config.ts +118 -0
  23. package/extensions/subagent/index.ts +54 -22
  24. package/extensions/telegram/README.md +2 -0
  25. package/extensions/telegram/assistant-result.d.mts +4 -1
  26. package/extensions/telegram/assistant-result.mjs +31 -16
  27. package/extensions/telegram/daemon.mjs +146 -194
  28. package/extensions/telegram/index.ts +11 -7
  29. package/extensions/tool-display-mode.ts +29 -50
  30. package/extensions/usage/README.md +5 -0
  31. package/extensions/usage/index.ts +139 -80
  32. package/extensions/websearch/README.md +8 -1
  33. package/extensions/websearch/config.ts +2 -1
  34. package/extensions/websearch/index.ts +18 -9
  35. package/extensions/websearch/providers/{openai-codex.pi.ts → openai.pi.ts} +51 -16
  36. package/extensions/websearch/providers/shared.ts +10 -3
  37. package/extensions/websearch/types.ts +2 -1
  38. package/extensions/worktree.ts +33 -89
  39. package/package.json +1 -1
  40. package/skills/oracle/SKILL.md +14 -8
  41. package/skills/oracle/scripts/oracle +43 -19
  42. 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)) {
450
- lines.push(padToWidth(boxLine(line)));
451
- }
452
- lines.push(padToWidth(emptyBoxLine()));
453
-
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) {
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
+ )) {
459
515
  lines.push(padToWidth(boxLine(line)));
460
516
  }
517
+ lines.push(padToWidth(boxLine("")));
461
518
 
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
- }
470
- }
471
-
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
- }
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])));
487
522
  }
488
523
 
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,10 @@ 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
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 1 } },
665
+ );
639
666
 
640
667
  if (answersResult === null) {
641
668
  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,11 @@ 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
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 1 } },
360
+ );
356
361
  }
357
362
 
358
363
  function getLastAssistantMessage(messages: AgentMessage[]): AssistantMessage | null {
@@ -389,6 +394,7 @@ class BtwResultComponent implements Component {
389
394
  private readonly result: BtwResult,
390
395
  private readonly tui: TUI,
391
396
  private readonly theme: Theme,
397
+ private readonly keybindings: KeybindingsManager,
392
398
  private readonly onDone: () => void,
393
399
  ) {
394
400
  this.markdown = new Markdown(result.answer, 0, 0, RESULT_MARKDOWN_THEME);
@@ -412,49 +418,61 @@ class BtwResultComponent implements Component {
412
418
  }
413
419
 
414
420
  const width = this.renderWidth ?? this.tui.terminal.columns;
415
- if (width < MIN_RESULT_WIDTH) return;
421
+ if (width < MIN_RESULT_WIDTH || this.tui.terminal.rows < MIN_RESULT_ROWS) return;
416
422
 
417
423
  const bodyHeight = this.getBodyHeight();
418
- const boxWidth = this.getBoxWidth(width);
424
+ const boxWidth = width;
419
425
  const bodyLines = this.getBodyLines(this.getContentWidth(boxWidth));
420
426
  const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
421
427
 
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)) {
428
+ this.scrollOffset = clamp(this.scrollOffset, 0, maxScroll);
429
+ const kb = this.keybindings;
430
+ if (kb.matches(data, "tui.altScreen.pageUp")) {
431
+ this.scrollOffset -= bodyHeight;
432
+ } else if (kb.matches(data, "tui.altScreen.pageDown")) {
433
+ this.scrollOffset += bodyHeight;
434
+ } else if (kb.matches(data, "tui.altScreen.halfPageUp")) {
435
+ this.scrollOffset -= Math.max(1, Math.floor(bodyHeight / 2));
436
+ } else if (kb.matches(data, "tui.altScreen.halfPageDown")) {
437
+ this.scrollOffset += Math.max(1, Math.floor(bodyHeight / 2));
438
+ } else if (kb.matches(data, "tui.altScreen.top")) {
431
439
  this.scrollOffset = 0;
432
- } else if (matchesKey(data, Key.end)) {
440
+ } else if (kb.matches(data, "tui.altScreen.bottom")) {
433
441
  this.scrollOffset = maxScroll;
442
+ } else if (kb.matches(data, "tui.altScreen.lineUp")) {
443
+ this.scrollOffset -= 1;
444
+ } else if (kb.matches(data, "tui.altScreen.lineDown")) {
445
+ this.scrollOffset += 1;
446
+ } else if (matchesKey(data, Key.up) || data.toLowerCase() === "k") {
447
+ this.scrollOffset -= 1;
448
+ } else if (matchesKey(data, Key.down) || data.toLowerCase() === "j") {
449
+ this.scrollOffset += 1;
434
450
  } else {
435
451
  return;
436
452
  }
437
453
 
454
+ this.scrollOffset = clamp(this.scrollOffset, 0, maxScroll);
438
455
  this.tui.requestRender();
439
456
  }
440
457
 
441
458
  render(width: number): string[] {
442
459
  this.renderWidth = width;
443
- if (width < MIN_RESULT_WIDTH) {
460
+ if (width < 1 || this.tui.terminal.rows <= 2) return [];
461
+ if (width < MIN_RESULT_WIDTH || this.tui.terminal.rows < MIN_RESULT_ROWS) {
444
462
  return [
445
463
  truncateToWidth(
446
- this.theme.fg("muted", `Resize to ${MIN_RESULT_WIDTH}+ columns to view answer.`),
464
+ this.theme.fg(
465
+ "muted",
466
+ `Esc close · Resize to ${MIN_RESULT_WIDTH + 2}+ columns, ${MIN_RESULT_ROWS}+ rows.`,
467
+ ),
447
468
  width,
469
+ "",
448
470
  ),
449
471
  ];
450
472
  }
451
473
 
452
- const boxWidth = this.getBoxWidth(width);
474
+ const boxWidth = width;
453
475
  const contentWidth = this.getContentWidth(boxWidth);
454
- const questionLines = wrapTextWithAnsi(
455
- this.theme.fg("muted", this.result.question),
456
- contentWidth,
457
- );
458
476
  const bodyLines = this.getBodyLines(contentWidth);
459
477
  const bodyHeight = this.getBodyHeight();
460
478
  const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
@@ -462,11 +480,7 @@ class BtwResultComponent implements Component {
462
480
 
463
481
  const lines: string[] = [];
464
482
  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));
483
+ lines.push(this.boxLine(this.theme.bold("BTW"), boxWidth));
470
484
 
471
485
  const visibleBody = bodyLines.slice(this.scrollOffset, this.scrollOffset + bodyHeight);
472
486
  for (const line of visibleBody) {
@@ -478,15 +492,30 @@ class BtwResultComponent implements Component {
478
492
 
479
493
  lines.push(this.separatorLine(boxWidth));
480
494
  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));
495
+ const navigation = [
496
+ ["pageUp", "pageDown", "page"],
497
+ ["top", "bottom", "ends"],
498
+ ] as const;
499
+ const hints = [
500
+ "↑↓/j/k scroll",
501
+ ...navigation.flatMap(([up, down, label]) => {
502
+ const keys = [up, down].map((action) =>
503
+ this.keybindings
504
+ .getKeys(`tui.altScreen.${action}`)
505
+ .find((key) => !["enter", "escape", "ctrl+c", "q"].includes(key)),
506
+ );
507
+ return keys.some(Boolean) ? [`${keys.map((key) => key ?? "—").join("/")} ${label}`] : [];
508
+ }),
509
+ ];
510
+ lines.push(this.boxLine(this.theme.fg("dim", hints.join(" · ")), boxWidth));
511
+ lines.push(this.boxLine(this.theme.fg("dim", `Enter/Esc close · ${scrollText}`), boxWidth));
483
512
  lines.push(this.borderLine("╰", "╯", boxWidth));
484
513
 
485
514
  return lines;
486
515
  }
487
516
 
488
517
  private getBodyHeight(): number {
489
- return Math.max(8, this.tui.terminal.rows - 14);
518
+ return this.tui.terminal.rows - 2 - RESULT_CHROME_ROWS;
490
519
  }
491
520
 
492
521
  private getBodyLines(contentWidth: number): string[] {
@@ -494,18 +523,20 @@ class BtwResultComponent implements Component {
494
523
  return this.cachedBodyLines;
495
524
  }
496
525
 
497
- const lines = this.markdown.render(contentWidth);
526
+ const lines = [
527
+ this.theme.bold("Request"),
528
+ ...wrapTextWithAnsi(this.theme.fg("muted", this.result.question), contentWidth),
529
+ "",
530
+ this.theme.bold("Answer"),
531
+ ...this.markdown.render(contentWidth),
532
+ ];
498
533
  this.cachedBodyWidth = contentWidth;
499
534
  this.cachedBodyLines = lines;
500
535
  return lines;
501
536
  }
502
537
 
503
- private getBoxWidth(width: number): number {
504
- return Math.max(MIN_RESULT_WIDTH, Math.min(width - 2, 140));
505
- }
506
-
507
538
  private getContentWidth(boxWidth: number): number {
508
- return Math.max(10, boxWidth - 4);
539
+ return boxWidth - 4;
509
540
  }
510
541
 
511
542
  private borderLine(left: string, right: string, width: number): string {