pi-tab-focus 1.0.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.
- package/LICENSE +7 -0
- package/README.md +85 -0
- package/package.json +46 -0
- package/src/config.ts +96 -0
- package/src/fullscreen-layout.ts +459 -0
- package/src/fullscreen-ui.ts +101 -0
- package/src/index.ts +1393 -0
- package/src/transcript-links.ts +132 -0
- package/src/vim-navigation.ts +1056 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-tab-focus contributors
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# pi-tab-focus
|
|
2
|
+
|
|
3
|
+
Crush-style transcript focus mode for Pi. Navigate, select and copy transcript content without leaving the terminal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:pi-tab-focus
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Run Pi in fullscreen mode:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pi --tui-mode fullscreen
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### pi-vim
|
|
18
|
+
|
|
19
|
+
`pi-tab-focus` works with Pi's built-in editor and supports `pi-vim`. If you use both, install `pi-vim` first:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pi install npm:pi-vim
|
|
23
|
+
pi install npm:pi-tab-focus
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The same ordering should be used in `settings.json`.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
Press `Tab` to enter transcript focus. Press `Tab` or `Esc` to return to the editor.
|
|
31
|
+
|
|
32
|
+
| Key | Action |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `j` / `↓` | scroll down |
|
|
35
|
+
| `k` / `↑` | scroll up |
|
|
36
|
+
| `u` / `d` | half-page up / down |
|
|
37
|
+
| `Shift+J` / `Shift+↓` | next transcript item |
|
|
38
|
+
| `Shift+K` / `Shift+↑` | previous transcript item |
|
|
39
|
+
| `b` / `PgUp` | page up |
|
|
40
|
+
| `f` / `PgDn` | page down |
|
|
41
|
+
| `g` / `Home` | top |
|
|
42
|
+
| `G` / `End` | bottom |
|
|
43
|
+
| `y` / `c` | copy selected item or visual selection |
|
|
44
|
+
| `v` | visual cursor / character selection |
|
|
45
|
+
| `V` | line selection |
|
|
46
|
+
| `Enter` | open the first link in the selected item; in visual mode, open the link under the cursor |
|
|
47
|
+
| `:` | open EX mode when using `pi-vim` |
|
|
48
|
+
| `Ctrl+O` | toggle tool output |
|
|
49
|
+
| `Ctrl+T` | toggle thinking visibility |
|
|
50
|
+
| `Ctrl+C` | return to editor focus |
|
|
51
|
+
| `Ctrl+D` | shut down Pi |
|
|
52
|
+
|
|
53
|
+
Visual mode supports familiar Vim-style navigation including `h/j/k/l`, word motions, counts, `f/F/t/T`, `gg/G`, paragraph motions and common text objects such as `iw`, `aw` and quoted/bracketed selections.
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
`Tab` is the default focus key. To change it globally, create `~/.pi/agent/pi-tab-focus.json`:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"focusKey": "f6",
|
|
62
|
+
"hideDefaultScrollIndicator": true
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A trusted project can override this with `.pi/pi-tab-focus.json`. Use Pi's normal key format and run `/reload` after changing the config.
|
|
67
|
+
|
|
68
|
+
Prefer modifier or function-key bindings for `focusKey` (for example `ctrl+g` or `f6`). An unmodified printable key such as `f` will intercept normal typing while Pi is in fullscreen mode.
|
|
69
|
+
|
|
70
|
+
Set `hideDefaultScrollIndicator` to `false` if you want to keep Pi's built-in "Jump to latest message" indicator.
|
|
71
|
+
|
|
72
|
+
## Compatibility
|
|
73
|
+
|
|
74
|
+
Tested with Pi `0.85.1`.
|
|
75
|
+
|
|
76
|
+
Optional `pi-vim` integration is tested with `pi-vim` `0.14.2`.
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npm ci
|
|
82
|
+
npm run typecheck
|
|
83
|
+
npm test
|
|
84
|
+
npm pack --dry-run
|
|
85
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-tab-focus",
|
|
3
|
+
"keywords": [
|
|
4
|
+
"pi-package"
|
|
5
|
+
],
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"description": "Crush-style fullscreen transcript focus mode for Pi",
|
|
8
|
+
"license": "ISC",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./src/index.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"src/*.ts",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --experimental-strip-types --test \"src/tests/**/*.test.ts\"",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"prepack": "npm run typecheck && npm test"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/philvernon/pi-tab-focus.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/philvernon/pi-tab-focus/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/philvernon/pi-tab-focus#readme",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22.19.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
34
|
+
"@earendil-works/pi-tui": "*"
|
|
35
|
+
},
|
|
36
|
+
"pi": {
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./src/index.ts"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
43
|
+
"@earendil-works/pi-tui": "^0.85.1",
|
|
44
|
+
"typescript": "^6.0.2"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Key, type KeyId } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
type ConfigContext = {
|
|
8
|
+
cwd: string;
|
|
9
|
+
isProjectTrusted(): boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type ResolvedConfig = {
|
|
13
|
+
focusKey: KeyId;
|
|
14
|
+
hideDefaultScrollIndicator: boolean;
|
|
15
|
+
warnings: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type PartialConfig = {
|
|
19
|
+
focusKey?: KeyId;
|
|
20
|
+
hideDefaultScrollIndicator?: boolean;
|
|
21
|
+
warnings: string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const CONFIG_FILE = "pi-tab-focus.json";
|
|
25
|
+
const DEFAULT_FOCUS_KEY: KeyId = Key.tab;
|
|
26
|
+
const DEFAULT_HIDE_DEFAULT_SCROLL_INDICATOR = true;
|
|
27
|
+
|
|
28
|
+
function readConfig(path: string): PartialConfig {
|
|
29
|
+
if (!existsSync(path)) return { warnings: [] };
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
33
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
34
|
+
return {
|
|
35
|
+
warnings: [`Invalid ${CONFIG_FILE}: expected a JSON object (${path}).`],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const config = parsed as Record<string, unknown>;
|
|
40
|
+
const result: PartialConfig = { warnings: [] };
|
|
41
|
+
|
|
42
|
+
const focusKey = config.focusKey;
|
|
43
|
+
if (focusKey !== undefined) {
|
|
44
|
+
if (typeof focusKey !== "string" || focusKey.length === 0) {
|
|
45
|
+
result.warnings.push(
|
|
46
|
+
`Invalid focusKey in ${path}; using the previous/default binding.`,
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
result.focusKey = focusKey as KeyId;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hideDefaultScrollIndicator = config.hideDefaultScrollIndicator;
|
|
54
|
+
if (hideDefaultScrollIndicator !== undefined) {
|
|
55
|
+
if (typeof hideDefaultScrollIndicator !== "boolean") {
|
|
56
|
+
result.warnings.push(
|
|
57
|
+
`Invalid hideDefaultScrollIndicator in ${path}; using the previous/default value.`,
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
result.hideDefaultScrollIndicator = hideDefaultScrollIndicator;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
67
|
+
return { warnings: [`Could not read ${path}: ${message}`] };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveConfig(
|
|
72
|
+
ctx: ConfigContext,
|
|
73
|
+
agentDir: string,
|
|
74
|
+
): ResolvedConfig {
|
|
75
|
+
let focusKey = DEFAULT_FOCUS_KEY;
|
|
76
|
+
let hideDefaultScrollIndicator = DEFAULT_HIDE_DEFAULT_SCROLL_INDICATOR;
|
|
77
|
+
const warnings: string[] = [];
|
|
78
|
+
|
|
79
|
+
const globalConfig = readConfig(join(agentDir, CONFIG_FILE));
|
|
80
|
+
if (globalConfig.focusKey) focusKey = globalConfig.focusKey;
|
|
81
|
+
if (globalConfig.hideDefaultScrollIndicator !== undefined) {
|
|
82
|
+
hideDefaultScrollIndicator = globalConfig.hideDefaultScrollIndicator;
|
|
83
|
+
}
|
|
84
|
+
warnings.push(...globalConfig.warnings);
|
|
85
|
+
|
|
86
|
+
if (ctx.isProjectTrusted()) {
|
|
87
|
+
const projectConfig = readConfig(join(ctx.cwd, CONFIG_DIR_NAME, CONFIG_FILE));
|
|
88
|
+
if (projectConfig.focusKey) focusKey = projectConfig.focusKey;
|
|
89
|
+
if (projectConfig.hideDefaultScrollIndicator !== undefined) {
|
|
90
|
+
hideDefaultScrollIndicator = projectConfig.hideDefaultScrollIndicator;
|
|
91
|
+
}
|
|
92
|
+
warnings.push(...projectConfig.warnings);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { focusKey, hideDefaultScrollIndicator, warnings };
|
|
96
|
+
}
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HStack,
|
|
3
|
+
VStack,
|
|
4
|
+
sliceByColumn,
|
|
5
|
+
stripTerminalSequences,
|
|
6
|
+
visibleWidth,
|
|
7
|
+
type Component,
|
|
8
|
+
type TUI,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { suppressDefaultScrollIndicator } from "./fullscreen-ui.ts";
|
|
11
|
+
import type { VimPoint } from "./vim-navigation.ts";
|
|
12
|
+
|
|
13
|
+
export type PrivateScrollView = Component & {
|
|
14
|
+
scrollTop: number;
|
|
15
|
+
viewportHeight: number;
|
|
16
|
+
primary?: boolean;
|
|
17
|
+
getContentWidth?: (width: number) => number;
|
|
18
|
+
scrollTo?: (scrollTop: number, options?: { disableFollow?: boolean }) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type NativeSelectionPoint = VimPoint & {
|
|
22
|
+
scrollView?: PrivateScrollView;
|
|
23
|
+
boundary?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type NativeSelectionRange = {
|
|
27
|
+
start: NativeSelectionPoint;
|
|
28
|
+
end: NativeSelectionPoint;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type FullscreenTui = TUI & {
|
|
32
|
+
viewportTop?: number;
|
|
33
|
+
scrollBy?: (lines: number) => void;
|
|
34
|
+
scrollToTop?: () => void;
|
|
35
|
+
scrollToBottom?: () => void;
|
|
36
|
+
setLayoutRoot?: (component: Component | undefined) => void;
|
|
37
|
+
getFocusedComponent?: () => Component | null;
|
|
38
|
+
// These fields are private in TuiAltScreen's public type. Access is guarded and
|
|
39
|
+
// limited to fullscreen transcript layout/viewport integration.
|
|
40
|
+
layoutRoot?: Component;
|
|
41
|
+
currentLayout?: {
|
|
42
|
+
root?: { component: Component };
|
|
43
|
+
primaryScrollView?: PrivateScrollView;
|
|
44
|
+
};
|
|
45
|
+
selectionAnchor?: NativeSelectionPoint;
|
|
46
|
+
selectionFocus?: NativeSelectionPoint;
|
|
47
|
+
selectionGranularity?: "character" | "word" | "line";
|
|
48
|
+
selectionInitialRange?: NativeSelectionRange;
|
|
49
|
+
clearTextSelection?: () => void;
|
|
50
|
+
getSelectionSourceLine?: (point: NativeSelectionPoint) => string;
|
|
51
|
+
copyActiveSelectionToClipboard?: () => Promise<boolean>;
|
|
52
|
+
openUrl?: (url: string) => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type TranscriptGutterSelection = {
|
|
56
|
+
gutterStartRow: number;
|
|
57
|
+
gutterEndRow: number;
|
|
58
|
+
kind: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type PrivateStackEntry = {
|
|
62
|
+
component: Component;
|
|
63
|
+
basis?: number | "auto";
|
|
64
|
+
grow?: number;
|
|
65
|
+
shrink?: number;
|
|
66
|
+
minSize?: number;
|
|
67
|
+
maxSize?: number;
|
|
68
|
+
visible?: (viewport: { width: number; height: number }) => boolean;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type PrivateStackLayoutNode = {
|
|
72
|
+
type: "vstack" | "hstack";
|
|
73
|
+
entries: PrivateStackEntry[];
|
|
74
|
+
gap: number;
|
|
75
|
+
align: "stretch" | "start" | "center" | "end";
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type PrivateScrollLayoutNode = {
|
|
79
|
+
type: "scroll";
|
|
80
|
+
component: Component;
|
|
81
|
+
state: PrivateScrollView;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type PrivateLayoutNode = PrivateStackLayoutNode | PrivateScrollLayoutNode;
|
|
85
|
+
|
|
86
|
+
type TranscriptLayoutInstallation = {
|
|
87
|
+
originalRoot: Component;
|
|
88
|
+
installedRoot: Component;
|
|
89
|
+
scrollView: PrivateScrollView;
|
|
90
|
+
gutter: TranscriptGutterComponent;
|
|
91
|
+
rendererChildren: Component[];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type FullscreenLayoutControllerOptions = {
|
|
95
|
+
getTui: () => FullscreenTui | undefined;
|
|
96
|
+
getCursor: () => VimPoint | undefined;
|
|
97
|
+
isSelecting: () => boolean;
|
|
98
|
+
hideDefaultScrollIndicator: boolean;
|
|
99
|
+
onIntegrationInvalidated?: () => void;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const LAYOUT_NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
|
|
103
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, {
|
|
104
|
+
granularity: "grapheme",
|
|
105
|
+
});
|
|
106
|
+
const TRANSCRIPT_GUTTER_WIDTH = 1;
|
|
107
|
+
const PROMPT_SELECTION_MARKER = "\x1b[38;2;255;121;198m┃\x1b[39m";
|
|
108
|
+
const RESPONSE_SELECTION_MARKER = "\x1b[38;2;92;196;147m┃\x1b[39m";
|
|
109
|
+
|
|
110
|
+
function privateLayoutNode(
|
|
111
|
+
component: Component,
|
|
112
|
+
): PrivateLayoutNode | undefined {
|
|
113
|
+
// SAFETY: Pi stores private layout hooks under this symbol on component objects;
|
|
114
|
+
// structural access is guarded by checking the value is callable before use.
|
|
115
|
+
const getter = (component as unknown as Record<symbol, unknown>)[LAYOUT_NODE];
|
|
116
|
+
return typeof getter === "function"
|
|
117
|
+
? (getter.call(component) as PrivateLayoutNode | undefined)
|
|
118
|
+
: undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function graphemeWidthAtColumn(line: string, col: number): number {
|
|
122
|
+
const stripped = stripTerminalSequences(line);
|
|
123
|
+
let currentCol = 0;
|
|
124
|
+
|
|
125
|
+
for (const { segment } of GRAPHEME_SEGMENTER.segment(stripped)) {
|
|
126
|
+
const width = Math.max(0, visibleWidth(segment));
|
|
127
|
+
if (width > 0 && col >= currentCol && col < currentCol + width) return width;
|
|
128
|
+
currentCol += width;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
class VisualCursorContentProxy implements Component {
|
|
135
|
+
private readonly component: Component;
|
|
136
|
+
private readonly getCursor: () => VimPoint | undefined;
|
|
137
|
+
private readonly isSelecting: () => boolean;
|
|
138
|
+
|
|
139
|
+
constructor(
|
|
140
|
+
component: Component,
|
|
141
|
+
getCursor: () => VimPoint | undefined,
|
|
142
|
+
isSelecting: () => boolean,
|
|
143
|
+
) {
|
|
144
|
+
this.component = component;
|
|
145
|
+
this.getCursor = getCursor;
|
|
146
|
+
this.isSelecting = isSelecting;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
render(width: number): string[] {
|
|
150
|
+
const lines = [...this.component.render(width)];
|
|
151
|
+
const cursor = this.getCursor();
|
|
152
|
+
if (!cursor || this.isSelecting()) return lines;
|
|
153
|
+
|
|
154
|
+
const line = lines[cursor.row] ?? "";
|
|
155
|
+
const lineWidth = visibleWidth(line);
|
|
156
|
+
const col = Math.max(0, Math.min(cursor.col, lineWidth));
|
|
157
|
+
const before = sliceByColumn(line, 0, col, true);
|
|
158
|
+
const cursorWidth =
|
|
159
|
+
col >= lineWidth ? 1 : graphemeWidthAtColumn(line, col);
|
|
160
|
+
const atCursor =
|
|
161
|
+
col >= lineWidth
|
|
162
|
+
? " "
|
|
163
|
+
: sliceByColumn(line, col, cursorWidth, false) || " ";
|
|
164
|
+
const after = sliceByColumn(
|
|
165
|
+
line,
|
|
166
|
+
col + cursorWidth,
|
|
167
|
+
Math.max(0, lineWidth - col - cursorWidth),
|
|
168
|
+
true,
|
|
169
|
+
);
|
|
170
|
+
lines[cursor.row] = `${before}\x1b[7m${atCursor}\x1b[27m${after}`;
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
invalidate(): void {
|
|
175
|
+
this.component.invalidate?.();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
class ScrollLayoutProxy implements Component {
|
|
180
|
+
private readonly scrollView: PrivateScrollView;
|
|
181
|
+
private readonly getCursor: () => VimPoint | undefined;
|
|
182
|
+
private readonly isSelecting: () => boolean;
|
|
183
|
+
|
|
184
|
+
constructor(
|
|
185
|
+
scrollView: PrivateScrollView,
|
|
186
|
+
getCursor: () => VimPoint | undefined,
|
|
187
|
+
isSelecting: () => boolean,
|
|
188
|
+
) {
|
|
189
|
+
this.scrollView = scrollView;
|
|
190
|
+
this.getCursor = getCursor;
|
|
191
|
+
this.isSelecting = isSelecting;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
render(_width: number): string[] {
|
|
195
|
+
// HStack measures child height before laying it out. Returning no legacy
|
|
196
|
+
// lines avoids a second full transcript render; the delegated scroll layout
|
|
197
|
+
// below still renders the real document exactly once through Pi's layout engine.
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
invalidate(): void {
|
|
202
|
+
this.scrollView.invalidate?.();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
[LAYOUT_NODE](): PrivateLayoutNode | undefined {
|
|
206
|
+
const node = privateLayoutNode(this.scrollView);
|
|
207
|
+
if (!node || node.type !== "scroll") return node;
|
|
208
|
+
return {
|
|
209
|
+
...node,
|
|
210
|
+
component: new VisualCursorContentProxy(
|
|
211
|
+
node.component,
|
|
212
|
+
this.getCursor,
|
|
213
|
+
this.isSelecting,
|
|
214
|
+
),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
class TranscriptGutterComponent implements Component {
|
|
220
|
+
private selected:
|
|
221
|
+
| { startRow: number; endRow: number; kind: string }
|
|
222
|
+
| undefined;
|
|
223
|
+
private readonly getScrollTop: () => number;
|
|
224
|
+
private readonly getRenderRows: () => number;
|
|
225
|
+
|
|
226
|
+
constructor(getScrollTop: () => number, getRenderRows: () => number) {
|
|
227
|
+
this.getScrollTop = getScrollTop;
|
|
228
|
+
this.getRenderRows = getRenderRows;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
setSelection(item: TranscriptGutterSelection | undefined): void {
|
|
232
|
+
this.selected = item
|
|
233
|
+
? {
|
|
234
|
+
startRow: item.gutterStartRow,
|
|
235
|
+
endRow: item.gutterEndRow,
|
|
236
|
+
kind: item.kind,
|
|
237
|
+
}
|
|
238
|
+
: undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
render(_width: number): string[] {
|
|
242
|
+
const rows = Math.max(1, this.getRenderRows());
|
|
243
|
+
const lines = Array.from({ length: rows }, () => "");
|
|
244
|
+
if (!this.selected) return lines;
|
|
245
|
+
|
|
246
|
+
const top = this.getScrollTop();
|
|
247
|
+
const first = Math.max(0, this.selected.startRow - top);
|
|
248
|
+
const last = Math.min(rows, this.selected.endRow - top);
|
|
249
|
+
if (first >= last) return lines;
|
|
250
|
+
|
|
251
|
+
const marker =
|
|
252
|
+
this.selected.kind === "prompt"
|
|
253
|
+
? PROMPT_SELECTION_MARKER
|
|
254
|
+
: RESPONSE_SELECTION_MARKER;
|
|
255
|
+
|
|
256
|
+
for (let row = first; row < last; row++) lines[row] = marker;
|
|
257
|
+
return lines;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
invalidate(): void {}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export class FullscreenLayoutController {
|
|
264
|
+
private readonly options: FullscreenLayoutControllerOptions;
|
|
265
|
+
private installation: TranscriptLayoutInstallation | undefined;
|
|
266
|
+
private restoreScrollIndicator: (() => void) | undefined;
|
|
267
|
+
private scrollIndicatorRendererChildren: Component[] | undefined;
|
|
268
|
+
|
|
269
|
+
constructor(options: FullscreenLayoutControllerOptions) {
|
|
270
|
+
this.options = options;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
activeScrollView(): PrivateScrollView | undefined {
|
|
274
|
+
return (
|
|
275
|
+
this.installation?.scrollView ??
|
|
276
|
+
this.options.getTui()?.currentLayout?.primaryScrollView
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
contentWidth(): number {
|
|
281
|
+
const tui = this.options.getTui();
|
|
282
|
+
if (!tui) return 1;
|
|
283
|
+
const terminalWidth = Math.max(
|
|
284
|
+
1,
|
|
285
|
+
tui.terminal.columns - (this.installation ? TRANSCRIPT_GUTTER_WIDTH : 0),
|
|
286
|
+
);
|
|
287
|
+
return this.activeScrollView()?.getContentWidth?.(terminalWidth) ?? terminalWidth;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
setSelection(item: TranscriptGutterSelection | undefined): void {
|
|
291
|
+
this.installation?.gutter.setSelection(item);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
ensure(): boolean {
|
|
295
|
+
const tui = this.options.getTui();
|
|
296
|
+
if (!tui) return false;
|
|
297
|
+
|
|
298
|
+
const installation = this.installation;
|
|
299
|
+
if (installation) {
|
|
300
|
+
const sameRenderer = installation.rendererChildren === tui.children;
|
|
301
|
+
const stillInstalled =
|
|
302
|
+
tui.mode === "fullscreen" &&
|
|
303
|
+
sameRenderer &&
|
|
304
|
+
this.currentLayoutRoot() === installation.installedRoot;
|
|
305
|
+
|
|
306
|
+
if (!stillInstalled) {
|
|
307
|
+
installation.gutter.setSelection(undefined);
|
|
308
|
+
this.installation = undefined;
|
|
309
|
+
this.options.onIntegrationInvalidated?.();
|
|
310
|
+
if (sameRenderer) this.restoreCurrentScrollIndicator();
|
|
311
|
+
else this.abandonScrollIndicatorRestorer();
|
|
312
|
+
}
|
|
313
|
+
} else if (
|
|
314
|
+
this.restoreScrollIndicator &&
|
|
315
|
+
this.scrollIndicatorRendererChildren !== tui.children
|
|
316
|
+
) {
|
|
317
|
+
this.abandonScrollIndicatorRestorer();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (tui.mode !== "fullscreen") {
|
|
321
|
+
this.restoreCurrentScrollIndicator();
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!this.installation && !this.install()) return false;
|
|
326
|
+
|
|
327
|
+
if (!this.restoreScrollIndicator) {
|
|
328
|
+
const restore = suppressDefaultScrollIndicator(
|
|
329
|
+
tui,
|
|
330
|
+
this.options.hideDefaultScrollIndicator,
|
|
331
|
+
);
|
|
332
|
+
if (restore) {
|
|
333
|
+
this.restoreScrollIndicator = restore;
|
|
334
|
+
this.scrollIndicatorRendererChildren = tui.children;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
restore(): void {
|
|
342
|
+
const installation = this.installation;
|
|
343
|
+
this.installation = undefined;
|
|
344
|
+
|
|
345
|
+
const tui = this.options.getTui();
|
|
346
|
+
if (
|
|
347
|
+
installation &&
|
|
348
|
+
tui?.setLayoutRoot &&
|
|
349
|
+
installation.rendererChildren === tui.children
|
|
350
|
+
) {
|
|
351
|
+
const currentRoot = this.currentLayoutRoot();
|
|
352
|
+
if (!currentRoot || currentRoot === installation.installedRoot) {
|
|
353
|
+
tui.setLayoutRoot(installation.originalRoot);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
this.restoreCurrentScrollIndicator();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private install(): boolean {
|
|
361
|
+
const tui = this.options.getTui();
|
|
362
|
+
if (!tui || tui.mode !== "fullscreen" || !tui.setLayoutRoot) return false;
|
|
363
|
+
if (this.installation) return true;
|
|
364
|
+
|
|
365
|
+
const originalRoot = this.currentLayoutRoot();
|
|
366
|
+
if (!originalRoot) return false;
|
|
367
|
+
|
|
368
|
+
const rootNode = privateLayoutNode(originalRoot);
|
|
369
|
+
if (!rootNode || rootNode.type !== "vstack") return false;
|
|
370
|
+
|
|
371
|
+
const currentPrimary = tui.currentLayout?.primaryScrollView;
|
|
372
|
+
let transcriptIndex = -1;
|
|
373
|
+
let scrollNode: PrivateScrollLayoutNode | undefined;
|
|
374
|
+
|
|
375
|
+
for (let index = 0; index < rootNode.entries.length; index++) {
|
|
376
|
+
const node = privateLayoutNode(rootNode.entries[index].component);
|
|
377
|
+
if (!node || node.type !== "scroll") continue;
|
|
378
|
+
if (currentPrimary && node.state !== currentPrimary) continue;
|
|
379
|
+
if (!currentPrimary && !node.state.primary) continue;
|
|
380
|
+
transcriptIndex = index;
|
|
381
|
+
scrollNode = node;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (transcriptIndex < 0 || !scrollNode) return false;
|
|
386
|
+
|
|
387
|
+
const scrollView = scrollNode.state;
|
|
388
|
+
const gutter = new TranscriptGutterComponent(
|
|
389
|
+
() => scrollView.scrollTop,
|
|
390
|
+
() => this.options.getTui()?.terminal.rows ?? 1,
|
|
391
|
+
);
|
|
392
|
+
const transcriptPane = new HStack(
|
|
393
|
+
[
|
|
394
|
+
{
|
|
395
|
+
component: gutter,
|
|
396
|
+
basis: TRANSCRIPT_GUTTER_WIDTH,
|
|
397
|
+
grow: 0,
|
|
398
|
+
shrink: 0,
|
|
399
|
+
minSize: TRANSCRIPT_GUTTER_WIDTH,
|
|
400
|
+
maxSize: TRANSCRIPT_GUTTER_WIDTH,
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
component: new ScrollLayoutProxy(
|
|
404
|
+
scrollView,
|
|
405
|
+
this.options.getCursor,
|
|
406
|
+
this.options.isSelecting,
|
|
407
|
+
),
|
|
408
|
+
basis: 0,
|
|
409
|
+
grow: 1,
|
|
410
|
+
shrink: 1,
|
|
411
|
+
minSize: 1,
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
{ align: "stretch" },
|
|
415
|
+
);
|
|
416
|
+
const rootEntries = rootNode.entries.map((entry, index) =>
|
|
417
|
+
index === transcriptIndex
|
|
418
|
+
? { ...entry, component: transcriptPane }
|
|
419
|
+
: { ...entry },
|
|
420
|
+
);
|
|
421
|
+
const installedRoot = new VStack(rootEntries, {
|
|
422
|
+
gap: rootNode.gap,
|
|
423
|
+
align: rootNode.align,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
this.installation = {
|
|
427
|
+
originalRoot,
|
|
428
|
+
installedRoot,
|
|
429
|
+
scrollView,
|
|
430
|
+
gutter,
|
|
431
|
+
rendererChildren: tui.children,
|
|
432
|
+
};
|
|
433
|
+
tui.setLayoutRoot(installedRoot);
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private currentLayoutRoot(): Component | undefined {
|
|
438
|
+
const tui = this.options.getTui();
|
|
439
|
+
return tui?.currentLayout?.root?.component ?? tui?.layoutRoot;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private restoreCurrentScrollIndicator(): void {
|
|
443
|
+
const tui = this.options.getTui();
|
|
444
|
+
if (
|
|
445
|
+
this.restoreScrollIndicator &&
|
|
446
|
+
tui &&
|
|
447
|
+
this.scrollIndicatorRendererChildren === tui.children
|
|
448
|
+
) {
|
|
449
|
+
this.restoreScrollIndicator();
|
|
450
|
+
}
|
|
451
|
+
this.restoreScrollIndicator = undefined;
|
|
452
|
+
this.scrollIndicatorRendererChildren = undefined;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private abandonScrollIndicatorRestorer(): void {
|
|
456
|
+
this.restoreScrollIndicator = undefined;
|
|
457
|
+
this.scrollIndicatorRendererChildren = undefined;
|
|
458
|
+
}
|
|
459
|
+
}
|