@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 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 return to the chat (which also
49
- closes the editor pane).
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
- const ready = await getPane(ctx).ensure();
79
- if (!ready) {
80
- ctx.ui.notify("codetour: could not open the editor pane.", "error");
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
- 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;
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
- const ended = result.lastIndex >= 0 ? tour.stops[result.lastIndex] : undefined;
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 ${result.lastIndex + 1}: ${ended.label} (${ended.file}:${ended.line}).`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@balanza/pi-codetour",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A pi extension that guides you through a codebase by driving an editor in a terminal split.",
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
- /** How the user left the tour. */
23
- reason: "closed" | "no-editor";
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
- let lastIndex = -1;
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", " ↑↓ browse · enter focus editor · esc/q back to chat")),
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
- // Drive the first stop immediately.
109
- drive(0);
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(0, lastWidth);
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;