@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 +8 -0
- package/dist/types/apps/git/git-tui.d.ts +3 -2
- package/dist/types/native/apply.d.ts +4 -3
- package/dist/types/native/encode.d.ts +4 -3
- package/dist/types/native/node.d.ts +2 -1
- package/dist/types/native/settle.d.ts +4 -3
- package/dist/types/overlays/btw-history-panel.d.ts +8 -0
- package/package.json +9 -9
- package/src/apps/git/diff-pane.ts +28 -2
- package/src/apps/git/git-tui.ts +12 -5
- package/src/native/apply.ts +4 -3
- package/src/native/encode.ts +4 -3
- package/src/native/node.ts +2 -1
- package/src/native/settle.ts +4 -3
- package/src/overlays/btw-history-panel.ts +115 -36
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, `
|
|
17
|
-
*
|
|
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 (
|
|
4
|
-
* one; a rejected op is reported and
|
|
5
|
-
* the frame. `settle` is recorded as a
|
|
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 (
|
|
30
|
-
*
|
|
31
|
-
* says omp applies `undo` events,
|
|
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
|
-
* `
|
|
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 (
|
|
4
|
-
*
|
|
5
|
-
* targeted ops by id, followed by a fresh `settle`
|
|
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.
|
|
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.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.6.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.6.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.6.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.6.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.6.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.6.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.6.
|
|
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
|
-
?
|
|
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
|
|
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;
|
package/src/apps/git/git-tui.ts
CHANGED
|
@@ -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, `
|
|
17
|
-
*
|
|
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")
|
|
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
|
|
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;
|
package/src/native/apply.ts
CHANGED
|
@@ -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 (
|
|
4
|
-
* one; a rejected op is reported and
|
|
5
|
-
* the frame. `settle` is recorded as a
|
|
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
|
*/
|
package/src/native/encode.ts
CHANGED
|
@@ -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 (
|
|
112
|
-
*
|
|
113
|
-
* says omp applies `undo` events,
|
|
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 {
|
package/src/native/node.ts
CHANGED
|
@@ -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
|
-
* `
|
|
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";
|
package/src/native/settle.ts
CHANGED
|
@@ -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 (
|
|
4
|
-
*
|
|
5
|
-
* targeted ops by id, followed by a fresh `settle`
|
|
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.#
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
"
|
|
392
|
-
{
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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(
|
|
497
|
-
|
|
498
|
-
|
|
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",
|
|
515
|
-
{ keys: ["
|
|
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",
|
|
699
|
-
rawKeyHint(
|
|
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"));
|