@balanza/pi-codetour 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/index.ts +149 -11
- package/package.json +1 -1
- package/src/tour-ui.ts +32 -8
package/README.md
CHANGED
|
@@ -45,11 +45,30 @@ pi --extension /path/to/pi-codetour/index.ts
|
|
|
45
45
|
|
|
46
46
|
Then just ask the agent to explain part of the codebase. When it wants to show
|
|
47
47
|
you code it will open the tour. Navigate with `↑`/`↓` (the editor follows),
|
|
48
|
-
`enter` to focus the editor pane, `esc`/`q` to
|
|
49
|
-
|
|
48
|
+
`enter` to focus the editor pane, `esc`/`q` to close the tour (which also closes
|
|
49
|
+
the editor pane).
|
|
50
50
|
|
|
51
51
|
Re-open the most recent tour any time with `/codetour`.
|
|
52
52
|
|
|
53
|
+
## Chat about the code you are viewing
|
|
54
|
+
|
|
55
|
+
You do not have to close the tour to talk to the agent about what you are
|
|
56
|
+
looking at. Press **`Ctrl+Alt+T`** to toggle between the tour list and the
|
|
57
|
+
chat input:
|
|
58
|
+
|
|
59
|
+
- **Tour → chat**: the list hands focus back to the chat while the editor pane
|
|
60
|
+
stays open on the current stop. A banner above the chat input (and a
|
|
61
|
+
`📍 file:line` marker in the footer) stays visible the whole time, so it is
|
|
62
|
+
always obvious you are inside a tour and what you are viewing.
|
|
63
|
+
- **Chat → tour**: press `Ctrl+Alt+T` again to jump back into the list, resumed
|
|
64
|
+
on the exact stop you left.
|
|
65
|
+
|
|
66
|
+
While a tour is active, every message you send silently carries the stop you are
|
|
67
|
+
viewing (file, line, label, and its explanation) as context, so you can ask
|
|
68
|
+
"why is this here?" or "what calls this?" and the agent answers about *that*
|
|
69
|
+
code — all in the same conversation, no forking. Quit the tour (`esc`/`q`) to
|
|
70
|
+
clear the marker and stop injecting that context.
|
|
71
|
+
|
|
53
72
|
## Development
|
|
54
73
|
|
|
55
74
|
```bash
|
package/index.ts
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* editor to the matching spot. `/codetour` re-opens the most recent tour.
|
|
8
8
|
*/
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Key, Text } from "@earendil-works/pi-tui";
|
|
10
11
|
import { Type } from "typebox";
|
|
11
12
|
import { EditorPane } from "./src/session.js";
|
|
12
13
|
import { runTourUI } from "./src/tour-ui.js";
|
|
13
|
-
import type { Tour } from "./src/types.js";
|
|
14
|
+
import type { Tour, TourStop } from "./src/types.js";
|
|
15
|
+
|
|
16
|
+
/** Toggle between the tour list and the chat (Ctrl+Alt+T). */
|
|
17
|
+
const TOGGLE_KEY = Key.ctrlAlt("t");
|
|
14
18
|
|
|
15
19
|
const StopSchema = Type.Object({
|
|
16
20
|
file: Type.String({ description: "File path, absolute or relative to the codebase root." }),
|
|
@@ -38,6 +42,24 @@ const TourParams = Type.Object({
|
|
|
38
42
|
}),
|
|
39
43
|
});
|
|
40
44
|
|
|
45
|
+
/** The context note injected into each turn while a tour is active. */
|
|
46
|
+
function viewingContext(tour: Tour, index: number): string {
|
|
47
|
+
const stop = tour.stops[index];
|
|
48
|
+
if (!stop) return "";
|
|
49
|
+
const range = stop.endLine ? `${stop.line}-${stop.endLine}` : String(stop.line);
|
|
50
|
+
return [
|
|
51
|
+
"[codetour] The user is viewing this code in the codetour editor pane and wants to",
|
|
52
|
+
"discuss it. Answer their questions in the context of this exact location; read the",
|
|
53
|
+
"file if you need more than the excerpt below.",
|
|
54
|
+
"",
|
|
55
|
+
`File: ${stop.file}:${range}`,
|
|
56
|
+
`Tour: "${tour.title}" — stop ${index + 1}/${tour.stops.length}: ${stop.label}`,
|
|
57
|
+
`About this stop: ${stop.detail}`,
|
|
58
|
+
"",
|
|
59
|
+
"They can press Ctrl+Alt+T to step back into the tour list.",
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
function tourToText(tour: Tour): string {
|
|
42
64
|
const lines = [`Code tour: ${tour.title}`];
|
|
43
65
|
if (tour.overview) lines.push("", tour.overview);
|
|
@@ -53,10 +75,19 @@ export default function codetour(pi: ExtensionAPI) {
|
|
|
53
75
|
// Session-scoped editor pane and the last tour shown, for /codetour.
|
|
54
76
|
let pane: EditorPane | null = null;
|
|
55
77
|
let lastTour: Tour | null = null;
|
|
78
|
+
// The tour the user is currently browsing/chatting about, or null when none is
|
|
79
|
+
// active. While set, each turn is told which stop they are viewing, and
|
|
80
|
+
// Ctrl+Alt+T re-opens the list on `currentIndex`.
|
|
81
|
+
let activeTour: Tour | null = null;
|
|
82
|
+
let currentIndex = 0;
|
|
83
|
+
// True while the tour list overlay owns the keyboard. The overlay handles its
|
|
84
|
+
// own toggle key, so the global shortcut is a no-op then.
|
|
85
|
+
let overlayOpen = false;
|
|
56
86
|
|
|
57
87
|
pi.on("session_shutdown", () => {
|
|
58
88
|
pane?.dispose();
|
|
59
89
|
pane = null;
|
|
90
|
+
activeTour = null;
|
|
60
91
|
});
|
|
61
92
|
|
|
62
93
|
const getPane = (ctx: ExtensionContext): EditorPane => {
|
|
@@ -64,6 +95,73 @@ export default function codetour(pi: ExtensionAPI) {
|
|
|
64
95
|
return pane;
|
|
65
96
|
};
|
|
66
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Reflect the active tour in the footer status and in a persistent banner
|
|
100
|
+
* above the chat input, so it is always obvious a tour is in progress. Pass
|
|
101
|
+
* nothing to clear both when no tour is active.
|
|
102
|
+
*/
|
|
103
|
+
const updateStatus = (ctx: ExtensionContext): void => {
|
|
104
|
+
const tour = activeTour;
|
|
105
|
+
const stop: TourStop | undefined = tour?.stops[currentIndex];
|
|
106
|
+
ctx.ui.setStatus("codetour", stop ? `\u{1F4CD} ${stop.file}:${stop.line}` : undefined);
|
|
107
|
+
|
|
108
|
+
if (!tour || !stop) {
|
|
109
|
+
ctx.ui.setWidget("codetour", undefined);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const title = tour.title;
|
|
113
|
+
const position = `stop ${currentIndex + 1}/${tour.stops.length}`;
|
|
114
|
+
const location = `${stop.file}:${stop.line}`;
|
|
115
|
+
ctx.ui.setWidget(
|
|
116
|
+
"codetour",
|
|
117
|
+
(_tui, theme) => {
|
|
118
|
+
const line = [
|
|
119
|
+
theme.fg("accent", theme.bold("\u2590 Code tour")),
|
|
120
|
+
theme.fg("text", `\u201C${title}\u201D`),
|
|
121
|
+
theme.fg("dim", "\u00b7"),
|
|
122
|
+
theme.fg("accent", `\u{1F4CD} ${location}`),
|
|
123
|
+
theme.fg("muted", `(${position})`),
|
|
124
|
+
theme.fg("dim", "\u00b7"),
|
|
125
|
+
theme.fg("muted", "Ctrl+Alt+T to browse"),
|
|
126
|
+
].join(" ");
|
|
127
|
+
return new Text(line);
|
|
128
|
+
},
|
|
129
|
+
{ placement: "aboveEditor" },
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Close the editor pane and forget the active tour. */
|
|
134
|
+
const endTour = (ctx: ExtensionContext): void => {
|
|
135
|
+
pane?.dispose();
|
|
136
|
+
pane = null;
|
|
137
|
+
activeTour = null;
|
|
138
|
+
updateStatus(ctx);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Open (or resume) the tour list overlay on `currentIndex`. Returns how the
|
|
143
|
+
* user left it. Assumes `activeTour` is set.
|
|
144
|
+
*/
|
|
145
|
+
async function openOverlay(ctx: ExtensionContext): Promise<"closed" | "chat" | "no-editor"> {
|
|
146
|
+
const tour = activeTour;
|
|
147
|
+
if (!tour) return "closed";
|
|
148
|
+
const ready = await getPane(ctx).ensure();
|
|
149
|
+
if (!ready) {
|
|
150
|
+
ctx.ui.notify("codetour: could not open the editor pane.", "error");
|
|
151
|
+
return "no-editor";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
overlayOpen = true;
|
|
155
|
+
const result = await runTourUI(ctx, getPane(ctx), tour, {
|
|
156
|
+
startIndex: currentIndex,
|
|
157
|
+
toggleKey: TOGGLE_KEY,
|
|
158
|
+
});
|
|
159
|
+
overlayOpen = false;
|
|
160
|
+
if (result.lastIndex >= 0) currentIndex = result.lastIndex;
|
|
161
|
+
updateStatus(ctx);
|
|
162
|
+
return result.reason;
|
|
163
|
+
}
|
|
164
|
+
|
|
67
165
|
async function present(ctx: ExtensionContext, tour: Tour): Promise<string> {
|
|
68
166
|
lastTour = tour;
|
|
69
167
|
|
|
@@ -75,23 +173,38 @@ export default function codetour(pi: ExtensionAPI) {
|
|
|
75
173
|
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
174
|
}
|
|
77
175
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
176
|
+
activeTour = tour;
|
|
177
|
+
currentIndex = 0;
|
|
178
|
+
updateStatus(ctx);
|
|
179
|
+
|
|
180
|
+
const reason = await openOverlay(ctx);
|
|
181
|
+
if (reason === "no-editor") {
|
|
182
|
+
endTour(ctx);
|
|
81
183
|
return `Failed to open the editor pane. Present these stops to the user yourself:\n\n${tourToText(tour)}`;
|
|
82
184
|
}
|
|
83
185
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
186
|
+
if (reason === "chat") {
|
|
187
|
+
// Tour stays alive; the user stepped into the chat to ask about a stop.
|
|
188
|
+
const stop = tour.stops[currentIndex];
|
|
189
|
+
return [
|
|
190
|
+
`The user is browsing the "${tour.title}" tour and switched to the chat to ask about`,
|
|
191
|
+
stop
|
|
192
|
+
? `stop ${currentIndex + 1}: ${stop.label} (${stop.file}:${stop.line}).`
|
|
193
|
+
: "the code they are viewing.",
|
|
194
|
+
"The editor pane stays open on that stop. Briefly acknowledge and invite their",
|
|
195
|
+
"question about this code — do not re-explain it unprompted. Every message they send",
|
|
196
|
+
"now carries the stop they are viewing as context. They can press Ctrl+Alt+T to",
|
|
197
|
+
"return to the tour list.",
|
|
198
|
+
].join(" ");
|
|
199
|
+
}
|
|
89
200
|
|
|
90
|
-
|
|
201
|
+
// reason === "closed": they quit the tour; the pane is torn down.
|
|
202
|
+
const ended = tour.stops[currentIndex];
|
|
203
|
+
endTour(ctx);
|
|
91
204
|
return [
|
|
92
205
|
`The user browsed the "${tour.title}" tour (${tour.stops.length} stops) in the editor pane.`,
|
|
93
206
|
ended
|
|
94
|
-
? `They ended on stop ${
|
|
207
|
+
? `They ended on stop ${currentIndex + 1}: ${ended.label} (${ended.file}:${ended.line}).`
|
|
95
208
|
: "",
|
|
96
209
|
"Continue the conversation; ask if they want more detail on any stop.",
|
|
97
210
|
]
|
|
@@ -130,4 +243,29 @@ export default function codetour(pi: ExtensionAPI) {
|
|
|
130
243
|
await present(ctx, lastTour);
|
|
131
244
|
},
|
|
132
245
|
});
|
|
246
|
+
|
|
247
|
+
// Ctrl+Alt+T toggles the tour list back on from the chat. (The overlay
|
|
248
|
+
// handles the same key itself to toggle the other way, so this is a no-op
|
|
249
|
+
// while the overlay is focused.)
|
|
250
|
+
pi.registerShortcut(TOGGLE_KEY, {
|
|
251
|
+
description: "codetour: toggle the tour list on/off",
|
|
252
|
+
handler: async (ctx) => {
|
|
253
|
+
if (overlayOpen) return;
|
|
254
|
+
if (!activeTour) {
|
|
255
|
+
ctx.ui.notify("codetour: no active tour. Ask the agent for a code tour first.", "info");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const reason = await openOverlay(ctx);
|
|
259
|
+
if (reason === "closed" || reason === "no-editor") endTour(ctx);
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// While a tour is active, tell each turn which stop the user is viewing so the
|
|
264
|
+
// agent can answer contextually. Invisible in the transcript (display: false).
|
|
265
|
+
pi.on("before_agent_start", () => {
|
|
266
|
+
if (!activeTour) return;
|
|
267
|
+
const content = viewingContext(activeTour, currentIndex);
|
|
268
|
+
if (!content) return;
|
|
269
|
+
return { message: { customType: "codetour-context", content, display: false } };
|
|
270
|
+
});
|
|
133
271
|
}
|
package/package.json
CHANGED
package/src/tour-ui.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The interactive tour list shown inside pi. It renders the stops as a
|
|
3
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
|
|
4
|
+
* file/line. Enter focuses the editor pane, esc/q returns to the chat (and
|
|
5
|
+
* closes the tour), and the toggle key steps back to the chat while keeping the
|
|
6
|
+
* tour alive so you can ask about the code you are viewing.
|
|
5
7
|
*/
|
|
6
8
|
import { DynamicBorder, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
9
|
import {
|
|
8
10
|
Container,
|
|
11
|
+
type KeyId,
|
|
9
12
|
type SelectItem,
|
|
10
13
|
SelectList,
|
|
11
14
|
Text,
|
|
@@ -19,8 +22,20 @@ import type { Tour, TourStop } from "./types.js";
|
|
|
19
22
|
export interface TourUIResult {
|
|
20
23
|
/** Index the user last looked at, or -1 if none. */
|
|
21
24
|
lastIndex: number;
|
|
22
|
-
/**
|
|
23
|
-
|
|
25
|
+
/**
|
|
26
|
+
* How the user left the tour:
|
|
27
|
+
* - "closed": they quit (esc/q) — the caller tears the tour down.
|
|
28
|
+
* - "chat": they toggled back to the chat — the caller keeps the tour alive.
|
|
29
|
+
*/
|
|
30
|
+
reason: "closed" | "chat" | "no-editor";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Options controlling how the tour UI opens and how to leave it. */
|
|
34
|
+
export interface TourUIOptions {
|
|
35
|
+
/** Stop to land on when the list opens. Defaults to 0. */
|
|
36
|
+
startIndex?: number;
|
|
37
|
+
/** Key that toggles back to the chat without closing the tour. */
|
|
38
|
+
toggleKey?: KeyId;
|
|
24
39
|
}
|
|
25
40
|
|
|
26
41
|
function stopItem(stop: TourStop, index: number): SelectItem {
|
|
@@ -40,9 +55,12 @@ export async function runTourUI(
|
|
|
40
55
|
ctx: ExtensionContext,
|
|
41
56
|
pane: EditorPane,
|
|
42
57
|
tour: Tour,
|
|
58
|
+
options: TourUIOptions = {},
|
|
43
59
|
): Promise<TourUIResult> {
|
|
44
60
|
const items = tour.stops.map(stopItem);
|
|
45
|
-
|
|
61
|
+
const startIndex = Math.min(Math.max(0, options.startIndex ?? 0), tour.stops.length - 1);
|
|
62
|
+
const toggleKey = options.toggleKey;
|
|
63
|
+
let lastIndex = startIndex;
|
|
46
64
|
|
|
47
65
|
const drive = (index: number) => {
|
|
48
66
|
const stop = tour.stops[index];
|
|
@@ -100,16 +118,18 @@ export async function runTourUI(
|
|
|
100
118
|
|
|
101
119
|
container.addChild(list);
|
|
102
120
|
container.addChild(detail);
|
|
121
|
+
const toggleHint = toggleKey ? " · ctrl+alt+t chat" : "";
|
|
103
122
|
container.addChild(
|
|
104
|
-
new Text(theme.fg("dim",
|
|
123
|
+
new Text(theme.fg("dim", ` ↑↓ browse · enter focus editor${toggleHint} · esc/q close tour`)),
|
|
105
124
|
);
|
|
106
125
|
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
|
107
126
|
|
|
108
|
-
//
|
|
109
|
-
|
|
127
|
+
// Resume on the requested stop (first stop by default).
|
|
128
|
+
list.setSelectedIndex(startIndex);
|
|
129
|
+
drive(startIndex);
|
|
110
130
|
|
|
111
131
|
let lastWidth = 80;
|
|
112
|
-
renderDetail(
|
|
132
|
+
renderDetail(startIndex, lastWidth);
|
|
113
133
|
|
|
114
134
|
return {
|
|
115
135
|
render(width: number) {
|
|
@@ -129,6 +149,10 @@ export async function runTourUI(
|
|
|
129
149
|
container.invalidate();
|
|
130
150
|
},
|
|
131
151
|
handleInput(data: string) {
|
|
152
|
+
if (toggleKey && matchesKey(data, toggleKey)) {
|
|
153
|
+
done({ lastIndex, reason: "chat" });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
132
156
|
if (matchesKey(data, "q")) {
|
|
133
157
|
done({ lastIndex, reason: "closed" });
|
|
134
158
|
return;
|