@balanza/pi-codetour 0.0.0-stage → 0.1.2
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 +21 -0
- package/README.md +127 -2
- package/index.ts +133 -0
- package/package.json +47 -4
- package/src/editor.ts +130 -0
- package/src/session.ts +126 -0
- package/src/terminal.ts +192 -0
- package/src/tour-ui.ts +145 -0
- package/src/types.ts +22 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Emanuele
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,128 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @balanza/pi-codetour
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi) extension that turns the agent into
|
|
4
|
+
a **guided code tour**: when you ask how something works, pi can open an editor
|
|
5
|
+
in a terminal split and point it at the exact file/line it is talking about,
|
|
6
|
+
while you browse an interactive list of the relevant spots.
|
|
7
|
+
|
|
8
|
+
## How it works
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
you ask a question
|
|
12
|
+
│
|
|
13
|
+
▼
|
|
14
|
+
pi calls the `code_tour` tool with a list of "stops"
|
|
15
|
+
│
|
|
16
|
+
▼
|
|
17
|
+
extension splits the terminal (wezterm → tmux) and launches an editor (nvim)
|
|
18
|
+
│
|
|
19
|
+
▼
|
|
20
|
+
you get an interactive list of stops; moving the cursor drives the editor
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- **Split**: the bigger dimension of the pi pane is split, so a wide terminal
|
|
24
|
+
splits side-by-side and a tall terminal splits top/bottom.
|
|
25
|
+
- **Multiplexer**: WezTerm if available, otherwise tmux.
|
|
26
|
+
- **Editor**: Neovim (LazyVim), driven over a `--listen` socket and opened
|
|
27
|
+
**read-only** (`-R` + `:view`) since this is a navigation pane. The editor
|
|
28
|
+
layer is abstracted so more editors can be added later.
|
|
29
|
+
- **Lifecycle**: the editor pane is created on the first tour and **closed when
|
|
30
|
+
you quit the tour**; the next tour reopens a fresh one.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install npm:@balanza/pi-codetour
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
Or load it directly during development:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi --extension ~/pi-codetour/index.ts
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then just ask the agent to explain part of the codebase. When it wants to show
|
|
47
|
+
you code it will open the tour. Navigate with `↑`/`↓` (the editor follows),
|
|
48
|
+
`enter` to focus the editor pane, `esc`/`q` to return to the chat (which also
|
|
49
|
+
closes the editor pane).
|
|
50
|
+
|
|
51
|
+
Re-open the most recent tour any time with `/codetour`.
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm run lint # Biome lint + format check
|
|
57
|
+
npm run format # apply Biome fixes
|
|
58
|
+
npm test # node:test suite over the pi-free engine
|
|
59
|
+
npm run typecheck # tsc --noEmit (needs the pi host packages present)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
CI (`.github/workflows/ci.yml`) runs lint, tests, and typecheck on every push
|
|
63
|
+
and pull request. The pure engine (`src/terminal.ts`, `src/editor.ts`) has no
|
|
64
|
+
pi imports and is unit-tested directly; `index.ts` and `src/tour-ui.ts` are the
|
|
65
|
+
pi adapter layer and are covered by the typecheck step.
|
|
66
|
+
|
|
67
|
+
## Releasing
|
|
68
|
+
|
|
69
|
+
Publishing to npm is automated by `.github/workflows/release.yml`, which runs on
|
|
70
|
+
any pushed `v*.*.*` tag. One-time setup:
|
|
71
|
+
|
|
72
|
+
Authentication uses [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers/)
|
|
73
|
+
(OIDC), so **no npm token is stored anywhere**. One-time setup:
|
|
74
|
+
|
|
75
|
+
1. Publish the first version manually, because a trusted publisher can only be
|
|
76
|
+
configured on a package that already exists:
|
|
77
|
+
```bash
|
|
78
|
+
npm login && npm publish
|
|
79
|
+
```
|
|
80
|
+
2. On npmjs.com: **Packages → @balanza/pi-codetour → Settings → Trusted
|
|
81
|
+
Publisher → GitHub Actions**, with organization `balanza`, repository
|
|
82
|
+
`pi-codetour`, workflow filename `release.yml`, and the **npm publish**
|
|
83
|
+
action allowed. Fields are case-sensitive and are *not* validated on save.
|
|
84
|
+
The configuration expires if no publish succeeds within 2 days.
|
|
85
|
+
3. Keep `repository.url` in `package.json` matching this repo exactly — npm
|
|
86
|
+
rejects OIDC publishes otherwise.
|
|
87
|
+
4. Once a CI publish has succeeded, harden it: **Settings → Publishing access →
|
|
88
|
+
"Require two-factor authentication and disallow tokens"**.
|
|
89
|
+
|
|
90
|
+
Provenance is generated automatically for OIDC publishes, so no `--provenance`
|
|
91
|
+
flag is needed. The workflow pins Node 24 because trusted publishing requires
|
|
92
|
+
npm ≥ 11.5.1, which Node 22 does not ship.
|
|
93
|
+
|
|
94
|
+
Then cut a release. The version is derived from the conventional commits made
|
|
95
|
+
since the last `v*` tag:
|
|
96
|
+
|
|
97
|
+
- any **breaking change** (`type!:` or a `BREAKING CHANGE:` footer) → major
|
|
98
|
+
- otherwise any **`feat`** → minor
|
|
99
|
+
- otherwise → patch
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npm run version:next # print the next version without changing anything
|
|
103
|
+
npm run version:next -- --json # same, as JSON
|
|
104
|
+
npm run release # bump package.json + create the vX.Y.Z commit & tag
|
|
105
|
+
git push --follow-tags # the Release workflow publishes the matching version
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The `--json` form prints the full decision as one compact line, so it pipes
|
|
109
|
+
directly into `jq`:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
$ npm run version:next -- --json | jq -r .nextVersion
|
|
113
|
+
0.2.0
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{"lastVersion":"0.1.0","nextVersion":"0.2.0","numCommits":13,"bump":"minor","source":"package.json","lastTag":null}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
(The project `.npmrc` sets `loglevel=silent` so npm's `> pkg@version script`
|
|
121
|
+
banner stays off stdout; CI overrides it with `NPM_CONFIG_LOGLEVEL=notice`.)
|
|
122
|
+
|
|
123
|
+
`bump` is `null` when there are no commits since the last tag (then
|
|
124
|
+
`nextVersion` equals `lastVersion`).
|
|
125
|
+
|
|
126
|
+
The workflow re-runs lint/test/typecheck, verifies the tag matches
|
|
127
|
+
`package.json` version, publishes with `--access public`, and creates a GitHub
|
|
128
|
+
release for the tag with auto-generated notes and the npm tarball attached.
|
package/index.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codetour — a pi extension that guides you through a codebase.
|
|
3
|
+
*
|
|
4
|
+
* The agent calls the `code_tour` tool with a list of stops (file + line +
|
|
5
|
+
* explanation). The extension splits the terminal, opens an editor (nvim) in
|
|
6
|
+
* the new pane, and shows you an interactive list; moving the cursor drives the
|
|
7
|
+
* editor to the matching spot. `/codetour` re-opens the most recent tour.
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { EditorPane } from "./src/session.js";
|
|
12
|
+
import { runTourUI } from "./src/tour-ui.js";
|
|
13
|
+
import type { Tour } from "./src/types.js";
|
|
14
|
+
|
|
15
|
+
const StopSchema = Type.Object({
|
|
16
|
+
file: Type.String({ description: "File path, absolute or relative to the codebase root." }),
|
|
17
|
+
line: Type.Number({ description: "1-based line the editor should land on." }),
|
|
18
|
+
endLine: Type.Optional(
|
|
19
|
+
Type.Number({ description: "Last line of a range to highlight, when relevant." }),
|
|
20
|
+
),
|
|
21
|
+
label: Type.String({
|
|
22
|
+
description: "Very short label for the list, e.g. 'entry point' or 'the reducer'.",
|
|
23
|
+
}),
|
|
24
|
+
detail: Type.String({
|
|
25
|
+
description:
|
|
26
|
+
"One short paragraph explaining what to look at here and why it matters to the question.",
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const TourParams = Type.Object({
|
|
31
|
+
title: Type.String({ description: "What question this tour answers, as a short title." }),
|
|
32
|
+
overview: Type.Optional(
|
|
33
|
+
Type.String({ description: "Optional one-paragraph overview shown above the list." }),
|
|
34
|
+
),
|
|
35
|
+
stops: Type.Array(StopSchema, {
|
|
36
|
+
minItems: 1,
|
|
37
|
+
description: "Ordered stops that walk the user through the relevant code.",
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function tourToText(tour: Tour): string {
|
|
42
|
+
const lines = [`Code tour: ${tour.title}`];
|
|
43
|
+
if (tour.overview) lines.push("", tour.overview);
|
|
44
|
+
lines.push("");
|
|
45
|
+
tour.stops.forEach((s, i) => {
|
|
46
|
+
lines.push(`${i + 1}. ${s.label} — ${s.file}:${s.line}`);
|
|
47
|
+
lines.push(` ${s.detail}`);
|
|
48
|
+
});
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function codetour(pi: ExtensionAPI) {
|
|
53
|
+
// Session-scoped editor pane and the last tour shown, for /codetour.
|
|
54
|
+
let pane: EditorPane | null = null;
|
|
55
|
+
let lastTour: Tour | null = null;
|
|
56
|
+
|
|
57
|
+
pi.on("session_shutdown", () => {
|
|
58
|
+
pane?.dispose();
|
|
59
|
+
pane = null;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const getPane = (ctx: ExtensionContext): EditorPane => {
|
|
63
|
+
if (!pane) pane = new EditorPane({ dir: ctx.cwd });
|
|
64
|
+
return pane;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
async function present(ctx: ExtensionContext, tour: Tour): Promise<string> {
|
|
68
|
+
lastTour = tour;
|
|
69
|
+
|
|
70
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) {
|
|
71
|
+
return `Guided tour is only interactive in the terminal UI. Stops:\n\n${tourToText(tour)}`;
|
|
72
|
+
}
|
|
73
|
+
if (!EditorPane.supported()) {
|
|
74
|
+
ctx.ui.notify("codetour: no wezterm/tmux detected — showing stops inline.", "warning");
|
|
75
|
+
return `No terminal multiplexer (wezterm/tmux) available to split, so no editor pane was opened. Present these stops to the user yourself:\n\n${tourToText(tour)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ready = await getPane(ctx).ensure();
|
|
79
|
+
if (!ready) {
|
|
80
|
+
ctx.ui.notify("codetour: could not open the editor pane.", "error");
|
|
81
|
+
return `Failed to open the editor pane. Present these stops to the user yourself:\n\n${tourToText(tour)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const result = await runTourUI(ctx, getPane(ctx), tour);
|
|
85
|
+
|
|
86
|
+
// Quitting the tour tears the editor pane down too; the next tour reopens it.
|
|
87
|
+
pane?.dispose();
|
|
88
|
+
pane = null;
|
|
89
|
+
|
|
90
|
+
const ended = result.lastIndex >= 0 ? tour.stops[result.lastIndex] : undefined;
|
|
91
|
+
return [
|
|
92
|
+
`The user browsed the "${tour.title}" tour (${tour.stops.length} stops) in the editor pane.`,
|
|
93
|
+
ended
|
|
94
|
+
? `They ended on stop ${result.lastIndex + 1}: ${ended.label} (${ended.file}:${ended.line}).`
|
|
95
|
+
: "",
|
|
96
|
+
"Continue the conversation; ask if they want more detail on any stop.",
|
|
97
|
+
]
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.join(" ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pi.registerTool({
|
|
103
|
+
name: "code_tour",
|
|
104
|
+
label: "Code tour",
|
|
105
|
+
description:
|
|
106
|
+
"Guide the user through the codebase. Opens an editor beside the chat and shows an " +
|
|
107
|
+
"interactive, selectable list of 'stops' (file + line + explanation); as the user moves " +
|
|
108
|
+
"through the list the editor jumps to each spot. Use this instead of pasting long code " +
|
|
109
|
+
"excerpts when explaining how something works. Provide a focused, ordered set of stops; " +
|
|
110
|
+
"each `detail` should be one short paragraph tying that location to the user's question.",
|
|
111
|
+
parameters: TourParams,
|
|
112
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
113
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
114
|
+
const tour = params as Tour;
|
|
115
|
+
const text = await present(ctx, tour);
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: "text", text }],
|
|
118
|
+
details: { title: tour.title, stops: tour.stops.length },
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
pi.registerCommand("codetour", {
|
|
124
|
+
description: "Re-open the most recent code tour",
|
|
125
|
+
handler: async (_args, ctx) => {
|
|
126
|
+
if (!lastTour) {
|
|
127
|
+
ctx.ui.notify("codetour: no tour yet — ask the agent to explain some code first.", "info");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
await present(ctx, lastTour);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@balanza/pi-codetour",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "A pi extension that guides you through a codebase by driving an editor in a terminal split.",
|
|
7
|
+
"main": "index.ts",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package"
|
|
10
|
+
],
|
|
11
|
+
"pi": {
|
|
12
|
+
"extensions": [
|
|
13
|
+
"./index.ts"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/balanza/pi-codetour.git"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"provenance": true
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"index.ts",
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"lint": "biome check .",
|
|
33
|
+
"format": "biome check --write .",
|
|
34
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
35
|
+
"version:next": "node scripts/next-version.mjs",
|
|
36
|
+
"release": "node scripts/next-version.mjs --apply"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"@earendil-works/pi-tui": "*",
|
|
41
|
+
"typebox": "*"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@biomejs/biome": "^1.9.4",
|
|
45
|
+
"@types/node": "^26.6.4",
|
|
46
|
+
"tsx": "^4.23.15",
|
|
47
|
+
"typescript": "^5.6.3"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor abstraction.
|
|
3
|
+
*
|
|
4
|
+
* An EditorDriver knows how to (1) produce the shell command that launches the
|
|
5
|
+
* editor inside the split pane in a remotely-controllable way, (2) wait until
|
|
6
|
+
* that editor is ready, and (3) jump to a file/line on demand. Only Neovim is
|
|
7
|
+
* implemented today, but the interface keeps room for more editors.
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
export interface EditorDriver {
|
|
15
|
+
/** Human-readable editor name. */
|
|
16
|
+
readonly name: string;
|
|
17
|
+
/** Shell command to launch the editor inside the split pane. */
|
|
18
|
+
launchCommand(opts: { dir: string }): string;
|
|
19
|
+
/** Resolve true once the editor accepts remote commands, or false on timeout. */
|
|
20
|
+
waitReady(timeoutMs: number): Promise<boolean>;
|
|
21
|
+
/** Jump to `file` at `line` (1-based); select through `endLine` when given. */
|
|
22
|
+
goto(file: string, line: number, endLine?: number): void;
|
|
23
|
+
/** Release any resources (sockets, temp files). Never throws. */
|
|
24
|
+
dispose(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sleep(ms: number): Promise<void> {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Drives Neovim over a `--listen` socket. Works with any distro (LazyVim
|
|
33
|
+
* included) because it talks to the running server rather than sending keys
|
|
34
|
+
* through the multiplexer, so it is independent of the editor's current mode.
|
|
35
|
+
*/
|
|
36
|
+
export interface EditorDriverOptions {
|
|
37
|
+
/** Open files read-only (this is a navigation pane, not an editing one). */
|
|
38
|
+
readonly?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class NvimDriver implements EditorDriver {
|
|
42
|
+
readonly name = "nvim";
|
|
43
|
+
private readonly sock: string;
|
|
44
|
+
private readonly readonly: boolean;
|
|
45
|
+
|
|
46
|
+
constructor(opts: EditorDriverOptions = {}) {
|
|
47
|
+
this.readonly = opts.readonly ?? true;
|
|
48
|
+
this.sock = path.join(os.tmpdir(), `codetour-nvim-${process.pid}-${Date.now()}.sock`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
launchCommand(_opts: { dir: string }): string {
|
|
52
|
+
// `-R` starts nvim in read-only mode; the socket is how goto reaches it.
|
|
53
|
+
return `nvim ${this.readonly ? "-R " : ""}--listen ${this.sock}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async waitReady(timeoutMs: number): Promise<boolean> {
|
|
57
|
+
const deadline = Date.now() + timeoutMs;
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
if (fs.existsSync(this.sock)) {
|
|
60
|
+
// Socket file exists; give nvim a beat to start serving on it.
|
|
61
|
+
try {
|
|
62
|
+
this.expr("1");
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
/* not accepting yet */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
await sleep(100);
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
goto(file: string, line: number, endLine?: number): void {
|
|
74
|
+
this.remoteSend(nvimGotoKeys(path.resolve(file), line, endLine, this.readonly));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
dispose(): void {
|
|
78
|
+
try {
|
|
79
|
+
fs.rmSync(this.sock, { force: true });
|
|
80
|
+
} catch {
|
|
81
|
+
/* best-effort */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private remoteSend(keys: string): void {
|
|
86
|
+
execFileSync("nvim", ["--server", this.sock, "--remote-send", keys], { stdio: "ignore" });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private expr(expr: string): string {
|
|
90
|
+
return execFileSync("nvim", ["--server", this.sock, "--remote-expr", expr], {
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build the `--remote-send` key sequence that drives nvim to a file/line.
|
|
99
|
+
* Pure (no I/O) so it can be unit-tested without spawning an editor.
|
|
100
|
+
*
|
|
101
|
+
* `<C-\><C-n>` forces normal mode first so the Ex command always lands. `:view`
|
|
102
|
+
* opens read-only, `:edit` otherwise. With an `endLine`, the range is visually
|
|
103
|
+
* selected and the view recentred on its start.
|
|
104
|
+
*/
|
|
105
|
+
export function nvimGotoKeys(
|
|
106
|
+
absPath: string,
|
|
107
|
+
line: number,
|
|
108
|
+
endLine: number | undefined,
|
|
109
|
+
readonly: boolean,
|
|
110
|
+
): string {
|
|
111
|
+
const safe = absPath.replace(/ /g, "\\ ");
|
|
112
|
+
const open = readonly ? "view" : "edit";
|
|
113
|
+
const start = Math.max(1, Math.floor(line));
|
|
114
|
+
let keys = `<C-\\><C-n>:${open} +${start} ${safe}<CR>zz`;
|
|
115
|
+
if (endLine && endLine > start) {
|
|
116
|
+
const span = Math.floor(endLine) - start;
|
|
117
|
+
keys += `V${span}j${start}G<C-\\><C-n>zz`;
|
|
118
|
+
}
|
|
119
|
+
return keys;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Resolve an editor driver by name. Defaults to nvim. */
|
|
123
|
+
export function createEditorDriver(name = "nvim", opts: EditorDriverOptions = {}): EditorDriver {
|
|
124
|
+
switch (name) {
|
|
125
|
+
case "nvim":
|
|
126
|
+
return new NvimDriver(opts);
|
|
127
|
+
default:
|
|
128
|
+
throw new Error(`Unsupported editor: ${name}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { type EditorDriver, createEditorDriver } from "./editor.js";
|
|
2
|
+
/**
|
|
3
|
+
* Ties the multiplexer and editor together into a single reusable "editor pane"
|
|
4
|
+
* for the lifetime of a pi session. The pane is created lazily on the first
|
|
5
|
+
* tour and reused afterwards; it is torn down on session shutdown.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
type Mux,
|
|
9
|
+
closePane,
|
|
10
|
+
detectMux,
|
|
11
|
+
focusPane,
|
|
12
|
+
isLandscape,
|
|
13
|
+
openSplit,
|
|
14
|
+
paneAlive,
|
|
15
|
+
paneSize,
|
|
16
|
+
} from "./terminal.js";
|
|
17
|
+
|
|
18
|
+
export interface EditorPaneOptions {
|
|
19
|
+
/** Codebase root; the editor opens here. */
|
|
20
|
+
dir: string;
|
|
21
|
+
/** Editor name (currently only "nvim"). */
|
|
22
|
+
editor?: string;
|
|
23
|
+
/** Share of space the editor pane takes when first created (0..1). */
|
|
24
|
+
fraction?: number;
|
|
25
|
+
/** Open files read-only when the editor supports it. Default true. */
|
|
26
|
+
readonly?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class EditorPane {
|
|
30
|
+
private mux: Mux | null = null;
|
|
31
|
+
private selfPaneId: string | null = null;
|
|
32
|
+
private editorPaneId: string | null = null;
|
|
33
|
+
private driver: EditorDriver | null = null;
|
|
34
|
+
private readonly dir: string;
|
|
35
|
+
private readonly editorName: string;
|
|
36
|
+
private readonly fraction: number;
|
|
37
|
+
private readonly readonly: boolean;
|
|
38
|
+
|
|
39
|
+
constructor(opts: EditorPaneOptions) {
|
|
40
|
+
this.dir = opts.dir;
|
|
41
|
+
this.editorName = opts.editor ?? "nvim";
|
|
42
|
+
this.fraction = opts.fraction ?? 0.5;
|
|
43
|
+
this.readonly = opts.readonly ?? true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True if the host terminal supports splitting at all. */
|
|
47
|
+
static supported(): boolean {
|
|
48
|
+
return detectMux() !== null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get muxName(): Mux | null {
|
|
52
|
+
return this.mux;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ensure a live editor pane exists, creating it if needed. Returns true if an
|
|
57
|
+
* editor pane is ready to receive goto commands.
|
|
58
|
+
*/
|
|
59
|
+
async ensure(): Promise<boolean> {
|
|
60
|
+
if (this.editorPaneId && this.mux && paneAlive(this.mux, this.editorPaneId) && this.driver) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
// Stale pane (user closed it) — reset before recreating.
|
|
64
|
+
this.resetPane();
|
|
65
|
+
|
|
66
|
+
const host = detectMux();
|
|
67
|
+
if (!host) return false;
|
|
68
|
+
this.mux = host.mux;
|
|
69
|
+
this.selfPaneId = host.paneId;
|
|
70
|
+
|
|
71
|
+
// Orient the split along the bigger physical dimension of the pi pane.
|
|
72
|
+
const size = paneSize(host.mux, host.paneId);
|
|
73
|
+
const sideBySide = size ? isLandscape(size) : true;
|
|
74
|
+
|
|
75
|
+
this.driver = createEditorDriver(this.editorName, { readonly: this.readonly });
|
|
76
|
+
const split = openSplit({
|
|
77
|
+
mux: host.mux,
|
|
78
|
+
fromPaneId: host.paneId,
|
|
79
|
+
dir: this.dir,
|
|
80
|
+
command: this.driver.launchCommand({ dir: this.dir }),
|
|
81
|
+
sideBySide,
|
|
82
|
+
fraction: this.fraction,
|
|
83
|
+
});
|
|
84
|
+
this.editorPaneId = split.paneId;
|
|
85
|
+
|
|
86
|
+
const ready = await this.driver.waitReady(10_000);
|
|
87
|
+
if (!ready) {
|
|
88
|
+
this.dispose();
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
// Keep keyboard focus on the pi pane so the user stays in the list.
|
|
92
|
+
if (this.selfPaneId) focusPane(host.mux, this.selfPaneId);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Point the editor at a file/line. No-op if the pane is not ready. */
|
|
97
|
+
goto(file: string, line: number, endLine?: number): void {
|
|
98
|
+
this.driver?.goto(file, line, endLine);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Move keyboard focus into the editor pane. */
|
|
102
|
+
focusEditor(): void {
|
|
103
|
+
if (this.mux && this.editorPaneId) focusPane(this.mux, this.editorPaneId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Move keyboard focus back to the pi pane. */
|
|
107
|
+
focusSelf(): void {
|
|
108
|
+
if (this.mux && this.selfPaneId) focusPane(this.mux, this.selfPaneId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private resetPane(): void {
|
|
112
|
+
if (this.mux && this.editorPaneId && paneAlive(this.mux, this.editorPaneId)) {
|
|
113
|
+
closePane(this.mux, this.editorPaneId);
|
|
114
|
+
}
|
|
115
|
+
this.driver?.dispose();
|
|
116
|
+
this.driver = null;
|
|
117
|
+
this.editorPaneId = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Close the pane and release resources. Idempotent. */
|
|
121
|
+
dispose(): void {
|
|
122
|
+
this.resetPane();
|
|
123
|
+
this.mux = null;
|
|
124
|
+
this.selfPaneId = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal multiplexer layer.
|
|
3
|
+
*
|
|
4
|
+
* Creates a split pane in the active multiplexer and runs a command there, and
|
|
5
|
+
* reports pane geometry so a caller can orient the split along the bigger
|
|
6
|
+
* dimension. WezTerm is preferred; tmux is the fallback.
|
|
7
|
+
*
|
|
8
|
+
* Adapted from ~/pr-reviewer/src/terminal.ts.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
|
|
13
|
+
export type Mux = "wezterm" | "tmux";
|
|
14
|
+
|
|
15
|
+
export interface SplitResult {
|
|
16
|
+
/** Which multiplexer created the pane. */
|
|
17
|
+
mux: Mux;
|
|
18
|
+
/** Pane id, used to address the pane later (resize, close, drive editor). */
|
|
19
|
+
paneId: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PaneSize {
|
|
23
|
+
cols: number;
|
|
24
|
+
rows: number;
|
|
25
|
+
/** True pixel dimensions, when the multiplexer reports them. */
|
|
26
|
+
pixelWidth?: number;
|
|
27
|
+
pixelHeight?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function weztermBin(): string {
|
|
31
|
+
const app = "/Applications/WezTerm.app/Contents/MacOS/wezterm";
|
|
32
|
+
return fs.existsSync(app) ? app : "wezterm";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The multiplexer the pi TUI is currently running inside, or null. */
|
|
36
|
+
export function detectMux(): { mux: Mux; paneId: string } | null {
|
|
37
|
+
// Prefer WezTerm (richer split API) when we are inside it.
|
|
38
|
+
if (process.env.WEZTERM_PANE) {
|
|
39
|
+
return { mux: "wezterm", paneId: process.env.WEZTERM_PANE };
|
|
40
|
+
}
|
|
41
|
+
if (process.env.TMUX) {
|
|
42
|
+
try {
|
|
43
|
+
const id = execFileSync("tmux", ["display-message", "-p", "#{pane_id}"], {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
}).trim();
|
|
46
|
+
if (id) return { mux: "tmux", paneId: id };
|
|
47
|
+
} catch {
|
|
48
|
+
/* fall through */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Open a split pane next to `fromPaneId` and run `command` there.
|
|
56
|
+
*
|
|
57
|
+
* `sideBySide` true splits the width (panes left/right); false stacks them
|
|
58
|
+
* (panes top/bottom). `fraction` is the share of space the NEW pane takes.
|
|
59
|
+
*/
|
|
60
|
+
export function openSplit(opts: {
|
|
61
|
+
mux: Mux;
|
|
62
|
+
fromPaneId: string;
|
|
63
|
+
dir: string;
|
|
64
|
+
command: string;
|
|
65
|
+
sideBySide: boolean;
|
|
66
|
+
fraction?: number;
|
|
67
|
+
}): SplitResult {
|
|
68
|
+
const { mux, fromPaneId, dir, command, sideBySide } = opts;
|
|
69
|
+
const percent = Math.round((opts.fraction ?? 0.5) * 100);
|
|
70
|
+
|
|
71
|
+
if (mux === "wezterm") {
|
|
72
|
+
const out = execFileSync(
|
|
73
|
+
weztermBin(),
|
|
74
|
+
[
|
|
75
|
+
"cli",
|
|
76
|
+
"split-pane",
|
|
77
|
+
sideBySide ? "--right" : "--bottom",
|
|
78
|
+
"--pane-id",
|
|
79
|
+
fromPaneId,
|
|
80
|
+
"--percent",
|
|
81
|
+
String(percent),
|
|
82
|
+
"--cwd",
|
|
83
|
+
dir,
|
|
84
|
+
"--",
|
|
85
|
+
"/bin/sh",
|
|
86
|
+
"-c",
|
|
87
|
+
command,
|
|
88
|
+
],
|
|
89
|
+
{ encoding: "utf8" },
|
|
90
|
+
);
|
|
91
|
+
return { mux, paneId: out.trim() };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// tmux: -h = side-by-side, -v = stacked.
|
|
95
|
+
const out = execFileSync(
|
|
96
|
+
"tmux",
|
|
97
|
+
[
|
|
98
|
+
"split-window",
|
|
99
|
+
sideBySide ? "-h" : "-v",
|
|
100
|
+
"-t",
|
|
101
|
+
fromPaneId,
|
|
102
|
+
"-p",
|
|
103
|
+
String(percent),
|
|
104
|
+
"-P",
|
|
105
|
+
"-F",
|
|
106
|
+
"#{pane_id}",
|
|
107
|
+
"-c",
|
|
108
|
+
dir,
|
|
109
|
+
command,
|
|
110
|
+
],
|
|
111
|
+
{ encoding: "utf8" },
|
|
112
|
+
);
|
|
113
|
+
return { mux, paneId: out.trim() };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Pane size in character cells (plus pixels for WezTerm), or null. */
|
|
117
|
+
export function paneSize(mux: Mux, paneId: string): PaneSize | null {
|
|
118
|
+
try {
|
|
119
|
+
if (mux === "tmux") {
|
|
120
|
+
const out = execFileSync(
|
|
121
|
+
"tmux",
|
|
122
|
+
["display-message", "-p", "-t", paneId, "#{pane_width} #{pane_height}"],
|
|
123
|
+
{ encoding: "utf8" },
|
|
124
|
+
);
|
|
125
|
+
const [c, r] = out.trim().split(/\s+/).map(Number);
|
|
126
|
+
if (Number.isFinite(c) && Number.isFinite(r)) return { cols: c, rows: r };
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const out = execFileSync(weztermBin(), ["cli", "list", "--format", "json"], {
|
|
130
|
+
encoding: "utf8",
|
|
131
|
+
});
|
|
132
|
+
const panes = JSON.parse(out) as Array<{
|
|
133
|
+
pane_id: number;
|
|
134
|
+
size?: { cols: number; rows: number; pixel_width?: number; pixel_height?: number };
|
|
135
|
+
}>;
|
|
136
|
+
const p = panes.find((x) => x.pane_id === Number(paneId));
|
|
137
|
+
if (p?.size && Number.isFinite(p.size.cols) && Number.isFinite(p.size.rows)) {
|
|
138
|
+
return {
|
|
139
|
+
cols: p.size.cols,
|
|
140
|
+
rows: p.size.rows,
|
|
141
|
+
pixelWidth: Number.isFinite(p.size.pixel_width) ? p.size.pixel_width : undefined,
|
|
142
|
+
pixelHeight: Number.isFinite(p.size.pixel_height) ? p.size.pixel_height : undefined,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** True if the pane's physical width is larger than its physical height. */
|
|
152
|
+
export function isLandscape(size: PaneSize): boolean {
|
|
153
|
+
if (size.pixelWidth && size.pixelHeight) {
|
|
154
|
+
return size.pixelWidth >= size.pixelHeight;
|
|
155
|
+
}
|
|
156
|
+
// A text cell is roughly twice as tall as it is wide, so approximate the
|
|
157
|
+
// physical aspect ratio from cells.
|
|
158
|
+
return size.cols >= size.rows * 2;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Give a pane keyboard focus (best-effort). */
|
|
162
|
+
export function focusPane(mux: Mux, paneId: string): void {
|
|
163
|
+
try {
|
|
164
|
+
if (mux === "wezterm") {
|
|
165
|
+
execFileSync(weztermBin(), ["cli", "activate-pane", "--pane-id", paneId], {
|
|
166
|
+
stdio: "ignore",
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
execFileSync("tmux", ["select-pane", "-t", paneId], { stdio: "ignore" });
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
/* best-effort */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** True if the pane still exists. */
|
|
177
|
+
export function paneAlive(mux: Mux, paneId: string): boolean {
|
|
178
|
+
return paneSize(mux, paneId) !== null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Close a pane by id (best-effort, never throws). */
|
|
182
|
+
export function closePane(mux: Mux, paneId: string): void {
|
|
183
|
+
try {
|
|
184
|
+
if (mux === "wezterm") {
|
|
185
|
+
execFileSync(weztermBin(), ["cli", "kill-pane", "--pane-id", paneId], { stdio: "ignore" });
|
|
186
|
+
} else {
|
|
187
|
+
execFileSync("tmux", ["kill-pane", "-t", paneId], { stdio: "ignore" });
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
/* already gone */
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/tour-ui.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interactive tour list shown inside pi. It renders the stops as a
|
|
3
|
+
* selectable list; moving the cursor drives the editor pane to the matching
|
|
4
|
+
* file/line. Enter focuses the editor pane, esc/q returns to the chat.
|
|
5
|
+
*/
|
|
6
|
+
import { DynamicBorder, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
Container,
|
|
9
|
+
type SelectItem,
|
|
10
|
+
SelectList,
|
|
11
|
+
Text,
|
|
12
|
+
matchesKey,
|
|
13
|
+
wrapTextWithAnsi,
|
|
14
|
+
} from "@earendil-works/pi-tui";
|
|
15
|
+
import type { EditorPane } from "./session.js";
|
|
16
|
+
import type { Tour, TourStop } from "./types.js";
|
|
17
|
+
|
|
18
|
+
/** Result of running the tour UI. */
|
|
19
|
+
export interface TourUIResult {
|
|
20
|
+
/** Index the user last looked at, or -1 if none. */
|
|
21
|
+
lastIndex: number;
|
|
22
|
+
/** How the user left the tour. */
|
|
23
|
+
reason: "closed" | "no-editor";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stopItem(stop: TourStop, index: number): SelectItem {
|
|
27
|
+
const loc = `${stop.file}:${stop.line}`;
|
|
28
|
+
return {
|
|
29
|
+
value: String(index),
|
|
30
|
+
label: `${index + 1}. ${stop.label}`,
|
|
31
|
+
description: loc,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Show the tour list and block until the user leaves it. Each cursor move
|
|
37
|
+
* sends the corresponding stop to `pane`.
|
|
38
|
+
*/
|
|
39
|
+
export async function runTourUI(
|
|
40
|
+
ctx: ExtensionContext,
|
|
41
|
+
pane: EditorPane,
|
|
42
|
+
tour: Tour,
|
|
43
|
+
): Promise<TourUIResult> {
|
|
44
|
+
const items = tour.stops.map(stopItem);
|
|
45
|
+
let lastIndex = -1;
|
|
46
|
+
|
|
47
|
+
const drive = (index: number) => {
|
|
48
|
+
const stop = tour.stops[index];
|
|
49
|
+
if (!stop) return;
|
|
50
|
+
lastIndex = index;
|
|
51
|
+
pane.goto(stop.file, stop.line, stop.endLine);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const result = await ctx.ui.custom<TourUIResult>((tui, theme, _kb, done) => {
|
|
55
|
+
const container = new Container();
|
|
56
|
+
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
|
57
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(` Code tour — ${tour.title}`))));
|
|
58
|
+
|
|
59
|
+
// Overview paragraph (wrapped), when provided.
|
|
60
|
+
const overview = new Text("");
|
|
61
|
+
container.addChild(overview);
|
|
62
|
+
|
|
63
|
+
const list = new SelectList(items, Math.min(items.length, 12), {
|
|
64
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
65
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
66
|
+
description: (t) => theme.fg("muted", t),
|
|
67
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
68
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Per-stop detail shown below the list, kept in sync with the cursor.
|
|
72
|
+
const detail = new Text("");
|
|
73
|
+
const renderDetail = (index: number, width: number) => {
|
|
74
|
+
const stop = tour.stops[index];
|
|
75
|
+
if (!stop) {
|
|
76
|
+
detail.setText("");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const head = theme.fg("accent", `${stop.file}:${stop.line}`);
|
|
80
|
+
const body = wrapTextWithAnsi(stop.detail, Math.max(10, width - 2))
|
|
81
|
+
.map((l) => theme.fg("text", l))
|
|
82
|
+
.join("\n");
|
|
83
|
+
detail.setText(`${head}\n${body}`);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
list.onSelectionChange = (item) => {
|
|
87
|
+
const index = Number(item.value);
|
|
88
|
+
drive(index);
|
|
89
|
+
renderDetail(index, lastWidth);
|
|
90
|
+
container.invalidate();
|
|
91
|
+
tui.requestRender();
|
|
92
|
+
};
|
|
93
|
+
// Enter: jump there and hand keyboard focus to the editor.
|
|
94
|
+
list.onSelect = (item) => {
|
|
95
|
+
drive(Number(item.value));
|
|
96
|
+
pane.focusEditor();
|
|
97
|
+
tui.requestRender();
|
|
98
|
+
};
|
|
99
|
+
list.onCancel = () => done({ lastIndex, reason: "closed" });
|
|
100
|
+
|
|
101
|
+
container.addChild(list);
|
|
102
|
+
container.addChild(detail);
|
|
103
|
+
container.addChild(
|
|
104
|
+
new Text(theme.fg("dim", " ↑↓ browse · enter focus editor · esc/q back to chat")),
|
|
105
|
+
);
|
|
106
|
+
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
|
107
|
+
|
|
108
|
+
// Drive the first stop immediately.
|
|
109
|
+
drive(0);
|
|
110
|
+
|
|
111
|
+
let lastWidth = 80;
|
|
112
|
+
renderDetail(0, lastWidth);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
render(width: number) {
|
|
116
|
+
lastWidth = width;
|
|
117
|
+
if (tour.overview) {
|
|
118
|
+
overview.setText(
|
|
119
|
+
wrapTextWithAnsi(tour.overview, Math.max(10, width - 2))
|
|
120
|
+
.map((l) => theme.fg("muted", l))
|
|
121
|
+
.join("\n"),
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
overview.setText("");
|
|
125
|
+
}
|
|
126
|
+
return container.render(width);
|
|
127
|
+
},
|
|
128
|
+
invalidate() {
|
|
129
|
+
container.invalidate();
|
|
130
|
+
},
|
|
131
|
+
handleInput(data: string) {
|
|
132
|
+
if (matchesKey(data, "q")) {
|
|
133
|
+
done({ lastIndex, reason: "closed" });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
list.handleInput(data);
|
|
137
|
+
tui.requestRender();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Keep the cursor back in the chat after the tour closes.
|
|
143
|
+
pane.focusSelf();
|
|
144
|
+
return result;
|
|
145
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** A single stop on a guided code tour. */
|
|
2
|
+
export interface TourStop {
|
|
3
|
+
/** File path, absolute or relative to the codebase root. */
|
|
4
|
+
file: string;
|
|
5
|
+
/** 1-based line the editor should land on. */
|
|
6
|
+
line: number;
|
|
7
|
+
/** Optional last line of a range to highlight. */
|
|
8
|
+
endLine?: number;
|
|
9
|
+
/** Short label shown in the list (e.g. "entry point", "the reducer"). */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Longer explanation of what to look at here and why it matters. */
|
|
12
|
+
detail: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A guided tour: an ordered set of stops with an overall framing. */
|
|
16
|
+
export interface Tour {
|
|
17
|
+
/** Title of the whole tour (what question it answers). */
|
|
18
|
+
title: string;
|
|
19
|
+
/** Optional one-paragraph overview shown above the list. */
|
|
20
|
+
overview?: string;
|
|
21
|
+
stops: TourStop[];
|
|
22
|
+
}
|