@oh-my-pi/pi-tui 18.6.0 → 18.6.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.6.1] - 2026-10-04
6
+
7
+ ### Fixed
8
+
9
+ - Fixed the Space key in the Git diff pane so it stages or unstages the focused hunk instead of scrolling or acting on the wrong change.
10
+ - Fixed the BTW history sheet in Tern: the history list no longer collapses beside a long answer, the panes lose their foldable `##` headings, and the arrow/page keys scroll the answer ([#14331](https://github.com/can1357/oh-my-pi/pull/14331) by [@H4vC](https://github.com/H4vC))
11
+ - Fixed a single saved side question in the BTW history panel opening on its one-row list: its answer now has focus, and Enter or `f` to follow up jumps to the bottom of the conversation ([#14331](https://github.com/can1357/oh-my-pi/pull/14331) by [@H4vC](https://github.com/H4vC))
12
+
5
13
  ## [18.6.0] - 2026-10-03
6
14
 
7
15
  ### Fixed
@@ -13,8 +13,9 @@
13
13
  * arrows/PgUp/PgDn, vim motions (`j`/`k`/`h`/`l`/`g`/`G`), and mouse
14
14
  * clicks/wheel. All toolbar buttons are clickable and mirrored by keys:
15
15
  * `v` cycles the view (`1`–`4` pick one), `alt+↓`/`alt+↑` jump hunks and roll
16
- * into the adjacent file at the edges, `]`/`[` switch files, `s`/`u`
17
- * stage/unstage (hunk-aware), `x` discards a hunk, `delete` discards the
16
+ * into the adjacent file at the edges, `space` stages/unstages the focused
17
+ * diff row (the focused hunk in hunk view), `s`/`u` stage/unstage
18
+ * (hunk-aware), `x` discards a hunk, `delete` discards the
18
19
  * whole file (press twice to confirm), `w` wraps, `b` cycles
19
20
  * whitespace handling (exact → ignore whitespace → ignore
20
21
  * formatting/import-only changes), `c` jumps to the commit form, `r`
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Reference document applier: applies TSP frames to a node tree the way the
3
- * spec says the terminal does (§5.1). Ops are validated and applied one by
4
- * one; a rejected op is reported and skipped without affecting the rest of
5
- * the frame. `settle` is recorded as a hint and never restricts later ops.
3
+ * spec says the terminal does (Tern SDK, `protocol/documents.md`, frame ops).
4
+ * Ops are validated and applied one by one; a rejected op is reported and
5
+ * skipped without affecting the rest of the frame. `settle` is recorded as a
6
+ * hint and never restricts later ops.
6
7
  *
7
8
  * Used by tests, the debug server's `doc` op, and the TSP test harness.
8
9
  */
@@ -26,9 +26,10 @@ export declare function encodeTspMessage(verb: TspVerb, body: string, params?: T
26
26
  export declare function encodeTspJson(verb: TspVerb, value: unknown, params?: TspParams, limit?: number): string;
27
27
  /**
28
28
  * The `hello` query; callers follow it with a DA1 sentinel. `features: ["edit"]`
29
- * tells the terminal that omp applies its `edit` events (TSP §8.5), so it may keep a
30
- * native selection in omp's editors; without it, every key stays omp's. `"undo"`
31
- * says omp applies `undo` events, so the terminal may turn ⌃Z in a field into one.
29
+ * tells the terminal that omp applies its `edit` events (Tern SDK,
30
+ * `protocol/input.md`), so it may keep a native selection in omp's editors;
31
+ * without it, every key stays omp's. `"undo"` says omp applies `undo` events,
32
+ * so the terminal may turn ⌃Z in a field into one.
32
33
  * `"send"` accepts an explicit prompt for a live composer without simulating keys.
33
34
  */
34
35
  export declare function encodeTspHelloQuery(version?: string): string;
@@ -11,7 +11,8 @@
11
11
  * `rows` fallback node rendered through `render(cx.cols)`.
12
12
  *
13
13
  * See `packages/wire/src/tsp.ts` for the wire vocabulary and
14
- * `crates/tern/SURFACE_PROTOCOL.md` (Stencil repository) for the spec.
14
+ * the Tern SDK's Surface Protocol reference (`docs/sdk/src/protocol` in the
15
+ * Stencil repository, https://docs.stencil.so/tern/protocol/) for the spec.
15
16
  */
16
17
  import type { TspEvent, TspKind, TspProps, TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
17
18
  import type { Component } from "../tui.js";
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Settling: the frame provider marks finalized transcript blocks so the
3
- * native reconciler stops keeping their description (spec §12 "Settling and
4
- * program memory"). A settled block stays editable: a later change is sent as
5
- * targeted ops by id, followed by a fresh `settle` hint.
3
+ * native reconciler stops keeping their description (Tern SDK,
4
+ * `protocol/operations.md`, "Settling"). A settled block stays editable: a
5
+ * later change is sent as targeted ops by id, followed by a fresh `settle`
6
+ * hint.
6
7
  */
7
8
  import type { Component } from "../tui.js";
8
9
  /** Mark `component` (and everything it describes) as finalized. Idempotent; a no-op outside a TSP terminal. */
@@ -13,6 +13,12 @@ interface BtwHistoryPanelOptions {
13
13
  spaceHold?: (input: Input) => SpaceHoldHandler;
14
14
  requestRender: () => void;
15
15
  getHeight: () => number;
16
+ /**
17
+ * Esc closes the panel even while the selected answer runs (it keeps
18
+ * streaming in the background) and `x` cancels it, instead of Esc cancelling
19
+ * first. For a host with no inline panel to fall back to (Tern).
20
+ */
21
+ escapeHides?: boolean;
16
22
  }
17
23
  /** Session-local side questions. Selecting or copying never promotes them into chat. */
18
24
  export declare class BtwHistoryPanel implements Component, Focusable {
@@ -25,6 +31,8 @@ export declare class BtwHistoryPanel implements Component, Focusable {
25
31
  markCopied(recordId: string, text: string): void;
26
32
  invalidate(): void;
27
33
  openFollowUp(recordId: string): boolean;
34
+ /** Select a record with its answer pane focused, read from the top (a just-asked /btw). */
35
+ showRecord(recordId: string): void;
28
36
  /** Enhanced clipboard pastes belong only to the active composer. */
29
37
  pasteText(text: string): void;
30
38
  handleInput(data: string): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.6.0",
4
+ "version": "18.6.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.6.0",
45
- "@oh-my-pi/pi-agent-core": "18.6.0",
46
- "@oh-my-pi/pi-ai": "18.6.0",
47
- "@oh-my-pi/pi-catalog": "18.6.0",
48
- "@oh-my-pi/pi-natives": "18.6.0",
49
- "@oh-my-pi/pi-utils": "18.6.0",
50
- "@oh-my-pi/pi-wire": "18.6.0",
51
- "@oh-my-pi/snapcompact": "18.6.0"
44
+ "@oh-my-pi/omptype": "18.6.1",
45
+ "@oh-my-pi/pi-agent-core": "18.6.1",
46
+ "@oh-my-pi/pi-ai": "18.6.1",
47
+ "@oh-my-pi/pi-catalog": "18.6.1",
48
+ "@oh-my-pi/pi-natives": "18.6.1",
49
+ "@oh-my-pi/pi-utils": "18.6.1",
50
+ "@oh-my-pi/pi-wire": "18.6.1",
51
+ "@oh-my-pi/snapcompact": "18.6.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -184,6 +184,10 @@ export interface DiffBuildOptions {
184
184
  streamResult?: DiffStreamResult;
185
185
  }
186
186
 
187
+ function filePatchHeader(filePath: string): string {
188
+ return `diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n`;
189
+ }
190
+
187
191
  /** Build the aligned document for one file from its raw old/new texts. */
188
192
  export function buildDiffDocument(
189
193
  oldRaw: string,
@@ -376,10 +380,11 @@ export function buildDiffDocument(
376
380
  // cannot use the raw streamed result because its equality basis differs.
377
381
  const canPatch = !ignoreWs;
378
382
  const tightHunks = streamed?.hunks ?? structuredPatchHunks(oldBasis, newBasis, DIFF_CONTEXT_LINES);
383
+ const patchHeader = canPatch && tightHunks.length > 0 ? filePatchHeader(filePath) : "";
379
384
  const allHunks: HunkBlock[] = tightHunks.map(hunk => ({
380
385
  header: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
381
386
  patch: canPatch
382
- ? `--- a/${filePath}\n+++ b/${filePath}\n@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join("\n")}\n`
387
+ ? `${patchHeader}@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join("\n")}\n`
383
388
  : "",
384
389
  rows: walkHunk(hunk, false),
385
390
  }));
@@ -476,7 +481,7 @@ export function buildLineSelectionPatch(
476
481
  `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join("\n")}`,
477
482
  )
478
483
  .join("\n");
479
- return `--- a/${doc.filePath}\n+++ b/${doc.filePath}\n${body}\n`;
484
+ return `${filePatchHeader(doc.filePath)}${body}\n`;
480
485
  }
481
486
 
482
487
  // ── palette ──────────────────────────────────────────────────────────────────
@@ -721,6 +726,7 @@ export class DiffPane {
721
726
  this.scrollLeft = 0;
722
727
  this.cursor = 0;
723
728
  this.anchor = null;
729
+ if (mode === "hunk") this.selectedHunk = 0;
724
730
  }
725
731
 
726
732
  cycleMode(): void {
@@ -771,6 +777,7 @@ export class DiffPane {
771
777
  this.anchor = null;
772
778
  }
773
779
  this.cursor = Math.max(0, Math.min(total - 1, this.cursor + delta));
780
+ this.#syncHunkToCursor();
774
781
  this.scrollTop = scrollOffsetForRow(this.scrollTop, this.cursor, total, this.#lastHeight);
775
782
  this.#clampScroll();
776
783
  }
@@ -854,6 +861,7 @@ export class DiffPane {
854
861
  if (total === 0) return;
855
862
  this.anchor = null;
856
863
  this.cursor = edge === "start" ? 0 : total - 1;
864
+ this.#syncHunkToCursor();
857
865
  this.scrollTop = scrollOffsetForRow(this.scrollTop, this.cursor, total, this.#lastHeight);
858
866
  this.#clampScroll();
859
867
  }
@@ -870,6 +878,19 @@ export class DiffPane {
870
878
  }
871
879
  }
872
880
 
881
+ /** Keep the hunk action and highlighted header on the cursor's hunk, including blank separators. */
882
+ #syncHunkToCursor(): void {
883
+ if (this.mode !== "hunk") return;
884
+ const visuals = this.#layout(this.#lastWidth || 80);
885
+ for (let row = this.cursor; row >= 0; row--) {
886
+ const visual = visuals[row];
887
+ if (visual?.t === "header") {
888
+ this.selectedHunk = visual.hunk;
889
+ return;
890
+ }
891
+ }
892
+ }
893
+
873
894
  /** First visual row of every change block, in document order. */
874
895
  #changeStarts(visuals: Visual[]): number[] {
875
896
  const starts: number[] = [];
@@ -917,6 +938,10 @@ export class DiffPane {
917
938
  const hit = this.#hits[row];
918
939
  if (hit && this.#doc) {
919
940
  this.selectedHunk = hit.hunk;
941
+ if (this.mode === "hunk") {
942
+ this.cursor = this.scrollTop + row;
943
+ this.anchor = null;
944
+ }
920
945
  if (hit.primary && col >= hit.primary[0] && col < hit.primary[1] && this.patchTarget) {
921
946
  return { type: "hunk-action", hunk: this.#doc.hunks[hit.hunk], action: this.patchTarget };
922
947
  }
@@ -933,6 +958,7 @@ export class DiffPane {
933
958
  this.anchor = null;
934
959
  }
935
960
  this.cursor = visual;
961
+ this.#syncHunkToCursor();
936
962
  return { type: "handled" };
937
963
  }
938
964
  return null;
@@ -13,8 +13,9 @@
13
13
  * arrows/PgUp/PgDn, vim motions (`j`/`k`/`h`/`l`/`g`/`G`), and mouse
14
14
  * clicks/wheel. All toolbar buttons are clickable and mirrored by keys:
15
15
  * `v` cycles the view (`1`–`4` pick one), `alt+↓`/`alt+↑` jump hunks and roll
16
- * into the adjacent file at the edges, `]`/`[` switch files, `s`/`u`
17
- * stage/unstage (hunk-aware), `x` discards a hunk, `delete` discards the
16
+ * into the adjacent file at the edges, `space` stages/unstages the focused
17
+ * diff row (the focused hunk in hunk view), `s`/`u` stage/unstage
18
+ * (hunk-aware), `x` discards a hunk, `delete` discards the
18
19
  * whole file (press twice to confirm), `w` wraps, `b` cycles
19
20
  * whitespace handling (exact → ignore whitespace → ignore
20
21
  * formatting/import-only changes), `c` jumps to the commit form, `r`
@@ -669,14 +670,20 @@ class GitTuiComponent implements Component {
669
670
  else if (matchesKey(data, "up") || data === "k") this.#pane.moveCursor(-1, false);
670
671
  else if (matchesKey(data, "down") || data === "j") this.#pane.moveCursor(1, false);
671
672
  else if (matchesKey(data, "pageUp")) this.#pane.moveCursor(-Math.max(1, this.#contentHeight - 2), false);
672
- else if (matchesKey(data, "pageDown") || data === " ")
673
- this.#pane.moveCursor(Math.max(1, this.#contentHeight - 2), false);
673
+ else if (matchesKey(data, "pageDown")) this.#pane.moveCursor(Math.max(1, this.#contentHeight - 2), false);
674
674
  else if (matchesKey(data, "left") || data === "h") this.#pane.scrollLeftBy(-8);
675
675
  else if (matchesKey(data, "right") || data === "l") this.#pane.scrollLeftBy(8);
676
676
  else if (matchesKey(data, "home") || data === "g") this.#pane.cursorToEdge("start");
677
677
  else if (matchesKey(data, "end") || data === "G") this.#pane.cursorToEdge("end");
678
678
  else if (matchesKey(data, "enter")) return this.#jumpHunkOrFile(1);
679
- else if (data === "s" || data === "u") {
679
+ else if (matchesKey(data, "space")) {
680
+ // Hunk-view rows do not map back to document rows, so Space acts on the focused hunk there.
681
+ const target = this.#pane.patchTarget;
682
+ const hunk = this.#pane.mode === "hunk" ? this.#pane.currentHunk : null;
683
+ if (target && hunk) void this.#hunkAction(hunk, target);
684
+ else if (target && this.#pane.selection) void this.#lineAction(target);
685
+ return;
686
+ } else if (data === "s" || data === "u") {
680
687
  if (this.#pane.selection?.explicit && this.#pane.patchTarget) {
681
688
  void this.#lineAction(this.#pane.patchTarget);
682
689
  return;
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Reference document applier: applies TSP frames to a node tree the way the
3
- * spec says the terminal does (§5.1). Ops are validated and applied one by
4
- * one; a rejected op is reported and skipped without affecting the rest of
5
- * the frame. `settle` is recorded as a hint and never restricts later ops.
3
+ * spec says the terminal does (Tern SDK, `protocol/documents.md`, frame ops).
4
+ * Ops are validated and applied one by one; a rejected op is reported and
5
+ * skipped without affecting the rest of the frame. `settle` is recorded as a
6
+ * hint and never restricts later ops.
6
7
  *
7
8
  * Used by tests, the debug server's `doc` op, and the TSP test harness.
8
9
  */
@@ -108,9 +108,10 @@ export function encodeTspJson(verb: TspVerb, value: unknown, params?: TspParams,
108
108
 
109
109
  /**
110
110
  * The `hello` query; callers follow it with a DA1 sentinel. `features: ["edit"]`
111
- * tells the terminal that omp applies its `edit` events (TSP §8.5), so it may keep a
112
- * native selection in omp's editors; without it, every key stays omp's. `"undo"`
113
- * says omp applies `undo` events, so the terminal may turn ⌃Z in a field into one.
111
+ * tells the terminal that omp applies its `edit` events (Tern SDK,
112
+ * `protocol/input.md`), so it may keep a native selection in omp's editors;
113
+ * without it, every key stays omp's. `"undo"` says omp applies `undo` events,
114
+ * so the terminal may turn ⌃Z in a field into one.
114
115
  * `"send"` accepts an explicit prompt for a live composer without simulating keys.
115
116
  */
116
117
  export function encodeTspHelloQuery(version?: string): string {
@@ -11,7 +11,8 @@
11
11
  * `rows` fallback node rendered through `render(cx.cols)`.
12
12
  *
13
13
  * See `packages/wire/src/tsp.ts` for the wire vocabulary and
14
- * `crates/tern/SURFACE_PROTOCOL.md` (Stencil repository) for the spec.
14
+ * the Tern SDK's Surface Protocol reference (`docs/sdk/src/protocol` in the
15
+ * Stencil repository, https://docs.stencil.so/tern/protocol/) for the spec.
15
16
  */
16
17
  import type { TspEvent, TspKind, TspProps, TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
17
18
  import type { Component } from "../tui";
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Settling: the frame provider marks finalized transcript blocks so the
3
- * native reconciler stops keeping their description (spec §12 "Settling and
4
- * program memory"). A settled block stays editable: a later change is sent as
5
- * targeted ops by id, followed by a fresh `settle` hint.
3
+ * native reconciler stops keeping their description (Tern SDK,
4
+ * `protocol/operations.md`, "Settling"). A settled block stays editable: a
5
+ * later change is sent as targeted ops by id, followed by a fresh `settle`
6
+ * hint.
6
7
  */
7
8
  import type { Component } from "../tui";
8
9
 
@@ -34,8 +34,8 @@ import { bottomBorder, row, topBorder } from "../chrome/overlay-box";
34
34
  import { padToWidth } from "../render/utils";
35
35
  import { SplitPane } from "../components/layout/split-pane";
36
36
  import { clampSelection, contentRowWidth, padLinesToHeight, renderScrollableList } from "../chrome/selector-helpers";
37
- import type { TspSpan } from "@oh-my-pi/pi-wire";
38
- import type { NativeChild, NativeNode, NativeUiEvent } from "../native/node";
37
+ import type { TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
38
+ import type { NativeChild, NativeNode, NativeScroll, NativeUiEvent } from "../native/node";
39
39
  import { col, md, node, span, text } from "../native/describe";
40
40
  import { actionHint, hintsRow, type NativeHint, statusHintsRow } from "../native/overlay";
41
41
 
@@ -50,6 +50,12 @@ interface BtwHistoryPanelOptions {
50
50
  spaceHold?: (input: Input) => SpaceHoldHandler;
51
51
  requestRender: () => void;
52
52
  getHeight: () => number;
53
+ /**
54
+ * Esc closes the panel even while the selected answer runs (it keeps
55
+ * streaming in the background) and `x` cancels it, instead of Esc cancelling
56
+ * first. For a host with no inline panel to fall back to (Tern).
57
+ */
58
+ escapeHides?: boolean;
53
59
  }
54
60
 
55
61
  interface FollowUpComposer {
@@ -78,6 +84,7 @@ interface BtwHistoryNativeMemo {
78
84
  followUpPending: boolean;
79
85
  canFollowUp: boolean;
80
86
  copied: boolean;
87
+ scroll: NativeScroll | undefined;
81
88
  node: NativeNode;
82
89
  }
83
90
 
@@ -108,6 +115,8 @@ export class BtwHistoryPanel implements Component, Focusable {
108
115
  #lastCopiedId: string | undefined;
109
116
  #lastCopiedText: string | undefined;
110
117
  #native: BtwHistoryNativeMemo | undefined;
118
+ /** The last answer-pane scroll key, forwarded natively (see {@link NativeNode.scroll}). */
119
+ #scroll: NativeScroll | undefined;
111
120
  readonly #detail = new ScrollView([], {
112
121
  height: 1,
113
122
  scrollbar: "auto",
@@ -159,6 +168,7 @@ export class BtwHistoryPanel implements Component, Focusable {
159
168
  this.#options = options;
160
169
  this.#records = options.records;
161
170
  this.#selectedId = options.records[0]?.id;
171
+ if (options.records.length === 1) this.#focus = "answer";
162
172
  }
163
173
 
164
174
  get focused(): boolean {
@@ -187,6 +197,8 @@ export class BtwHistoryPanel implements Component, Focusable {
187
197
  this.#followLatest = false;
188
198
  this.#detail.scrollToTop();
189
199
  }
200
+ // One side question has nothing to pick: its answer keeps the arrow keys.
201
+ if (records.length === 1) this.#focus = "answer";
190
202
  this.#options.requestRender();
191
203
  }
192
204
 
@@ -247,6 +259,26 @@ export class BtwHistoryPanel implements Component, Focusable {
247
259
  return true;
248
260
  }
249
261
 
262
+ /** Select a record with its answer pane focused, read from the top (a just-asked /btw). */
263
+ showRecord(recordId: string): void {
264
+ const index = this.#records.findIndex(record => record.id === recordId);
265
+ if (index === -1) return;
266
+ this.#select(index);
267
+ this.#focus = "answer";
268
+ this.#options.requestRender();
269
+ }
270
+
271
+ /**
272
+ * Jump the answer pane to its bottom once (opening or sending a follow-up):
273
+ * text mode keeps following the newest row at render until a scroll key;
274
+ * natively the sheet body scrolls to its end. Tern reports no wheel scroll,
275
+ * so streamed updates never re-pin it there and a reader is never yanked.
276
+ */
277
+ #jumpToEnd(): void {
278
+ this.#followLatest = true;
279
+ this.#scroll = { by: "end", n: (this.#scroll?.n ?? 0) + 1 };
280
+ }
281
+
250
282
  #openComposer(record: BtwHistoryRecord): void {
251
283
  const input = new Input();
252
284
  input.prompt = theme.fg("accent", "Follow up: ");
@@ -261,6 +293,8 @@ export class BtwHistoryPanel implements Component, Focusable {
261
293
  };
262
294
  this.#composer = composer;
263
295
  this.#focus = "answer";
296
+ // Following up reads from the newest turn, right above the composer.
297
+ this.#jumpToEnd();
264
298
  this.#options.requestRender();
265
299
  }
266
300
 
@@ -286,7 +320,7 @@ export class BtwHistoryPanel implements Component, Focusable {
286
320
  this.#composer = undefined;
287
321
  this.#selectedId = composer.recordId;
288
322
  this.#focus = "answer";
289
- this.#followLatest = true;
323
+ this.#jumpToEnd();
290
324
  } else {
291
325
  composer.notice = `Follow-up was not started. Your draft is kept; ${editorKey("tui.input.submit")} to retry.`;
292
326
  }
@@ -314,11 +348,17 @@ export class BtwHistoryPanel implements Component, Focusable {
314
348
  return;
315
349
  }
316
350
  const record = this.#selected();
351
+ const running = record !== undefined && getBtwLatestTurn(record).status === "running" ? record : undefined;
352
+ const hides = this.#options.escapeHides === true;
317
353
  if (matchesSelectCancel(data) || matchesKey(data, "escape")) {
318
- if (record && getBtwLatestTurn(record).status === "running") this.#options.onCancel(record);
354
+ if (running && !hides) this.#options.onCancel(running);
319
355
  else this.#options.onClose();
320
356
  return;
321
357
  }
358
+ if (hides && matchesKey(data, "x")) {
359
+ if (running) this.#options.onCancel(running);
360
+ return;
361
+ }
322
362
  if (matchesKey(data, "f") || matchesKey(data, "enter")) {
323
363
  if (record) this.openFollowUp(record.id);
324
364
  return;
@@ -335,11 +375,12 @@ export class BtwHistoryPanel implements Component, Focusable {
335
375
  matchesKey(data, "ctrl+_") ||
336
376
  data === String.fromCharCode(31)
337
377
  ) {
338
- this.#focus = this.#focus === "list" ? "answer" : "list";
378
+ // One side question has nothing to pick: its answer keeps the arrow keys.
379
+ if (this.#records.length > 1) this.#focus = this.#focus === "list" ? "answer" : "list";
339
380
  } else if (matchesKey(data, "right")) {
340
381
  this.#focus = "answer";
341
382
  } else if (matchesKey(data, "left")) {
342
- this.#focus = "list";
383
+ if (this.#records.length > 1) this.#focus = "list";
343
384
  } else if (this.#focus === "list") {
344
385
  const index = this.#selectedIndex();
345
386
  if (matchesSelectUp(data)) this.#select(index - 1);
@@ -350,11 +391,25 @@ export class BtwHistoryPanel implements Component, Focusable {
350
391
  else if (matchesKey(data, "end")) this.#select(this.#records.length - 1);
351
392
  else return;
352
393
  } else {
353
- if (matchesSelectUp(data)) this.#detail.scroll(-1);
354
- else if (matchesSelectDown(data)) this.#detail.scroll(1);
355
- else if (matchesSelectPageUp(data)) this.#detail.page(-1);
356
- else if (matchesSelectPageDown(data)) this.#detail.page(1);
394
+ const by: TspScrollBy | undefined = matchesSelectUp(data)
395
+ ? "line-up"
396
+ : matchesSelectDown(data)
397
+ ? "line-down"
398
+ : matchesSelectPageUp(data)
399
+ ? "page-up"
400
+ : matchesSelectPageDown(data)
401
+ ? "page-down"
402
+ : matchesKey(data, "home")
403
+ ? "start"
404
+ : matchesKey(data, "end")
405
+ ? "end"
406
+ : undefined;
407
+ if (by === "line-up") this.#detail.scroll(-1);
408
+ else if (by === "line-down") this.#detail.scroll(1);
409
+ else if (by === "page-up") this.#detail.page(-1);
410
+ else if (by === "page-down") this.#detail.page(1);
357
411
  else if (!this.#detail.handleScrollKey(data)) return;
412
+ if (by) this.#scroll = { by, n: (this.#scroll?.n ?? 0) + 1 };
358
413
  this.#followLatest = matchesKey(data, "end");
359
414
  }
360
415
  this.#options.requestRender();
@@ -378,31 +433,39 @@ export class BtwHistoryPanel implements Component, Focusable {
378
433
  memo.notice === composer?.notice &&
379
434
  memo.followUpPending === this.#followUpPending &&
380
435
  memo.canFollowUp === canFollowUp &&
381
- memo.copied === copied
436
+ memo.copied === copied &&
437
+ memo.scroll === this.#scroll
382
438
  ) {
383
439
  return memo.node;
384
440
  }
385
- const children: NativeChild[] = [
386
- node(
387
- "row",
388
- { gap: "md", align: "start" },
389
- [
441
+ // Plain columns, not `section`s: Tern draws a section as a foldable `##` heading,
442
+ // and a flex item's min-content width (a long code line in the answer) would
443
+ // crush the list pane. The answer pane may shrink to nothing; the list may not.
444
+ // With one side question the list and the pane labels are noise: the answer alone.
445
+ const showList = this.#records.length > 1;
446
+ const detail = this.#describeDetail(record, copied);
447
+ const answerPane: NativeNode = {
448
+ ...node(
449
+ "col",
450
+ { gap: "sm", grow: 1, basis: 0, min: { w: 0 } },
451
+ showList ? [text(this.#describeFocusLabel("answer"), { truncate: "end", lines: 1 }), detail] : [detail],
452
+ "details",
453
+ ),
454
+ // Natively the sheet body is the scroller; arrow/page keys on the answer pane move it.
455
+ scroll: this.#scroll,
456
+ };
457
+ const panes: NativeChild[] = showList
458
+ ? [
390
459
  node(
391
- "section",
392
- { head: this.#describeFocusLabel("list"), basis: 0.42, max: { w: "46ch" } },
393
- [this.#describeList()],
460
+ "col",
461
+ { gap: "sm", shrink: 0, basis: 0.3, max: { w: "40ch" } },
462
+ [text(this.#describeFocusLabel("list"), { truncate: "end", lines: 1 }), this.#describeList()],
394
463
  "history",
395
464
  ),
396
- node(
397
- "section",
398
- { head: this.#describeFocusLabel("answer"), grow: 1 },
399
- [this.#describeDetail(record, copied)],
400
- "details",
401
- ),
402
- ],
403
- "body",
404
- ),
405
- ];
465
+ answerPane,
466
+ ]
467
+ : [answerPane];
468
+ const children: NativeChild[] = [node("row", { gap: "lg", align: "start" }, panes, "body")];
406
469
  if (composer) {
407
470
  composer.input.focused = this.#focused;
408
471
  const lines: NativeChild[] = [
@@ -427,6 +490,7 @@ export class BtwHistoryPanel implements Component, Focusable {
427
490
  followUpPending: this.#followUpPending,
428
491
  canFollowUp,
429
492
  copied,
493
+ scroll: this.#scroll,
430
494
  node: described,
431
495
  };
432
496
  return described;
@@ -492,10 +556,21 @@ export class BtwHistoryPanel implements Component, Focusable {
492
556
  const answer = turn.answer.trim()
493
557
  ? md(sanitizeDisplayText(turn.answer), { stream: turn.status === "running" })
494
558
  : text([span(turn.status === "running" ? "Waiting for response…" : "No answer text.", "dim")]);
559
+ // Plain labels, not `section`s (Tern draws those as foldable `##` headings);
560
+ // the question stays markdown so its inline code and links render.
495
561
  const children: NativeChild[] = [
496
- text([span(`${status.label} · ${this.#dateFormat.format(turn.createdAt)}`, status.color)], { wrap: "word" }),
497
- node("section", { head: [span("Question", "accent strong")] }, [md(sanitizeDisplayText(turn.question))]),
498
- node("section", { head: [span("Answer", "accent strong")] }, [answer]),
562
+ text(
563
+ [
564
+ span("Question", "accent strong"),
565
+ span(" · ", "dim"),
566
+ span(status.label, status.color),
567
+ span(` · ${this.#dateFormat.format(turn.createdAt)}`, "dim"),
568
+ ],
569
+ { wrap: "word" },
570
+ ),
571
+ md(sanitizeDisplayText(turn.question)),
572
+ text([span("Answer", "accent strong")]),
573
+ answer,
499
574
  ];
500
575
  if (turn.error) children.push(text([span(sanitizeErrorLine(turn.error), "error")], { wrap: "word" }));
501
576
  if (turn.status === "interrupted") children.push(text([span("Not resumed in this view.", "muted")]));
@@ -510,9 +585,11 @@ export class BtwHistoryPanel implements Component, Focusable {
510
585
  ]);
511
586
  }
512
587
  const latest = record ? getBtwLatestTurn(record) : undefined;
588
+ const running = latest?.status === "running";
513
589
  const hints: (NativeHint | undefined)[] = [
514
- actionHint("tui.select.cancel", latest?.status === "running" ? "cancel" : "close"),
515
- { keys: ["tab", "ctrl+/"], label: "switch pane" },
590
+ actionHint("tui.select.cancel", running && !this.#options.escapeHides ? "cancel" : "close"),
591
+ running && this.#options.escapeHides ? { keys: ["x"], label: "cancel" } : undefined,
592
+ this.#records.length > 1 ? { keys: ["tab", "ctrl+/"], label: "switch pane" } : undefined,
516
593
  actionHint(["tui.select.up", "tui.select.down"], this.#focus === "list" ? "select" : "scroll"),
517
594
  ];
518
595
  if (canFollowUp) hints.push({ keys: ["f", "enter"], label: "follow up" });
@@ -689,14 +766,16 @@ export class BtwHistoryPanel implements Component, Focusable {
689
766
  const record = this.#selected();
690
767
  const composer = this.#composer;
691
768
  const latest = record ? getBtwLatestTurn(record) : undefined;
769
+ const running = latest?.status === "running";
692
770
  const actions = composer
693
771
  ? [
694
772
  keyHint("tui.input.submit", this.#followUpPending ? "starting…" : "send"),
695
773
  keyHint("tui.select.cancel", "cancel"),
696
774
  ]
697
775
  : [
698
- keyHint("tui.select.cancel", latest?.status === "running" ? "cancel" : "close"),
699
- rawKeyHint(["tab", "ctrl+/"], "switch pane"),
776
+ keyHint("tui.select.cancel", running && !this.#options.escapeHides ? "cancel" : "close"),
777
+ ...(running && this.#options.escapeHides ? [rawKeyHint("x", "cancel")] : []),
778
+ ...(this.#records.length > 1 ? [rawKeyHint(["tab", "ctrl+/"], "switch pane")] : []),
700
779
  ];
701
780
  if (!composer) {
702
781
  if (record && this.#canFollowUp(record)) actions.push(rawKeyHint(["f", "enter"], "follow up"));