@narumitw/pi-btw 0.1.36 → 0.1.37
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 +6 -1
- package/package.json +1 -1
- package/src/btw.ts +169 -24
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Use it when you want to ask a temporary question, inspect context, or get a shor
|
|
|
9
9
|
## ✨ Features
|
|
10
10
|
|
|
11
11
|
- Adds a `/btw <question>` command to Pi.
|
|
12
|
-
- Answers side questions in a temporary UI.
|
|
12
|
+
- Answers side questions in a temporary, scrollable UI.
|
|
13
13
|
- Uses the current session branch as context.
|
|
14
14
|
- Does not append the side question or answer to the main conversation.
|
|
15
15
|
- Works as an independently installable npm Pi extension package.
|
|
@@ -46,6 +46,11 @@ Examples:
|
|
|
46
46
|
/btw is this API name idiomatic?
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
Long answers open in a pager-style view. Use `↑`/`↓` or `k`/`j` to scroll by line,
|
|
50
|
+
`PgUp`/`PgDn`, `Shift+Space`/`Space`, or `Ctrl+B`/`Ctrl+F` to scroll by page,
|
|
51
|
+
`Ctrl+U`/`Ctrl+D` to scroll by half page, and `Home`/`End` to jump. Close with
|
|
52
|
+
`q`, `Esc`, `Enter`, or `Ctrl+C`.
|
|
53
|
+
|
|
49
54
|
## 🧠 Why use pi-btw?
|
|
50
55
|
|
|
51
56
|
Normal assistant messages become part of the main Pi conversation and can distract the coding agent from the task. `pi-btw` creates a lightweight side channel for context-aware questions, making it useful for pair programming, debugging, code review, and repository exploration.
|
package/package.json
CHANGED
package/src/btw.ts
CHANGED
|
@@ -5,10 +5,22 @@ import {
|
|
|
5
5
|
getMarkdownTheme,
|
|
6
6
|
type ExtensionAPI,
|
|
7
7
|
type ExtensionCommandContext,
|
|
8
|
+
type Theme,
|
|
8
9
|
} from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
Key,
|
|
12
|
+
Markdown,
|
|
13
|
+
matchesKey,
|
|
14
|
+
truncateToWidth,
|
|
15
|
+
visibleWidth,
|
|
16
|
+
type Component,
|
|
17
|
+
type TUI,
|
|
18
|
+
} from "@mariozechner/pi-tui";
|
|
10
19
|
|
|
11
20
|
const MAX_CONTEXT_CHARS = 40_000;
|
|
21
|
+
const ANSWER_CHROME_LINES = 4;
|
|
22
|
+
// Pi renders a spacer above the custom editor and a two-line built-in footer below it.
|
|
23
|
+
const ANSWER_RESERVED_APP_LINES = 3;
|
|
12
24
|
const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
|
|
13
25
|
|
|
14
26
|
Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
|
|
@@ -120,29 +132,161 @@ async function askSideQuestion(
|
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
async function showAnswer(question: string, answer: string, ctx: ExtensionCommandContext) {
|
|
123
|
-
await ctx.ui.custom((
|
|
124
|
-
|
|
125
|
-
const border = new DynamicBorder((text: string) => theme.fg("warning", text));
|
|
126
|
-
const markdownTheme = getMarkdownTheme();
|
|
127
|
-
|
|
128
|
-
container.addChild(border);
|
|
129
|
-
container.addChild(new Text(theme.fg("warning", theme.bold(`/btw ${question}`)), 1, 0));
|
|
130
|
-
container.addChild(new Markdown(answer, 1, 1, markdownTheme));
|
|
131
|
-
container.addChild(new Text(theme.fg("dim", "Press Enter, Space, or Esc to close"), 1, 1));
|
|
132
|
-
container.addChild(border);
|
|
133
|
-
|
|
134
|
-
return {
|
|
135
|
-
render: (width: number) => container.render(width),
|
|
136
|
-
invalidate: () => container.invalidate(),
|
|
137
|
-
handleInput: (data: string) => {
|
|
138
|
-
if (matchesKey(data, "enter") || matchesKey(data, "space") || matchesKey(data, "escape")) {
|
|
139
|
-
done(undefined);
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
};
|
|
135
|
+
await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
136
|
+
return new BtwAnswerPager(tui, theme, question, answer, () => done(undefined));
|
|
143
137
|
});
|
|
144
138
|
}
|
|
145
139
|
|
|
140
|
+
class BtwAnswerPager implements Component {
|
|
141
|
+
private readonly tui: TUI;
|
|
142
|
+
private readonly theme: Theme;
|
|
143
|
+
private readonly title: string;
|
|
144
|
+
private readonly onClose: () => void;
|
|
145
|
+
private readonly topBorder: DynamicBorder;
|
|
146
|
+
private readonly bottomBorder: DynamicBorder;
|
|
147
|
+
private readonly markdown: Markdown;
|
|
148
|
+
private scrollOffset = 0;
|
|
149
|
+
private lastContentLineCount = 0;
|
|
150
|
+
private lastViewportHeight = 1;
|
|
151
|
+
|
|
152
|
+
constructor(tui: TUI, theme: Theme, question: string, answer: string, onClose: () => void) {
|
|
153
|
+
this.tui = tui;
|
|
154
|
+
this.theme = theme;
|
|
155
|
+
this.title = sanitizeSingleLine(`/btw ${question}`);
|
|
156
|
+
this.onClose = onClose;
|
|
157
|
+
const borderColor = (text: string) => this.theme.fg("warning", text);
|
|
158
|
+
this.topBorder = new DynamicBorder(borderColor);
|
|
159
|
+
this.bottomBorder = new DynamicBorder(borderColor);
|
|
160
|
+
this.markdown = new Markdown(answer, 1, 1, getMarkdownTheme());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
render(width: number): string[] {
|
|
164
|
+
const viewportHeight = this.getViewportHeight();
|
|
165
|
+
const contentLines = this.markdown.render(width);
|
|
166
|
+
this.lastContentLineCount = contentLines.length;
|
|
167
|
+
this.lastViewportHeight = viewportHeight;
|
|
168
|
+
this.clampScrollOffset();
|
|
169
|
+
|
|
170
|
+
const visibleContent = contentLines.slice(
|
|
171
|
+
this.scrollOffset,
|
|
172
|
+
this.scrollOffset + viewportHeight,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return [
|
|
176
|
+
...this.topBorder.render(width),
|
|
177
|
+
this.renderTitle(width),
|
|
178
|
+
...visibleContent,
|
|
179
|
+
this.renderFooter(width),
|
|
180
|
+
...this.bottomBorder.render(width),
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
handleInput(data: string): void {
|
|
185
|
+
if (this.matchesCloseKey(data)) {
|
|
186
|
+
this.onClose();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
|
|
191
|
+
this.scrollBy(-1);
|
|
192
|
+
} else if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
|
|
193
|
+
this.scrollBy(1);
|
|
194
|
+
} else if (
|
|
195
|
+
matchesKey(data, Key.pageUp) ||
|
|
196
|
+
matchesKey(data, Key.shift(Key.space)) ||
|
|
197
|
+
matchesKey(data, Key.ctrl("b"))
|
|
198
|
+
) {
|
|
199
|
+
this.scrollBy(-this.lastViewportHeight);
|
|
200
|
+
} else if (
|
|
201
|
+
matchesKey(data, Key.pageDown) ||
|
|
202
|
+
matchesKey(data, Key.space) ||
|
|
203
|
+
matchesKey(data, Key.ctrl("f"))
|
|
204
|
+
) {
|
|
205
|
+
this.scrollBy(this.lastViewportHeight);
|
|
206
|
+
} else if (matchesKey(data, Key.ctrl("u"))) {
|
|
207
|
+
this.scrollBy(-this.getHalfPageHeight());
|
|
208
|
+
} else if (matchesKey(data, Key.ctrl("d"))) {
|
|
209
|
+
this.scrollBy(this.getHalfPageHeight());
|
|
210
|
+
} else if (matchesKey(data, Key.home)) {
|
|
211
|
+
this.scrollOffset = 0;
|
|
212
|
+
} else if (matchesKey(data, Key.end)) {
|
|
213
|
+
this.scrollOffset = this.getMaxScrollOffset();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
invalidate(): void {
|
|
218
|
+
this.topBorder.invalidate();
|
|
219
|
+
this.bottomBorder.invalidate();
|
|
220
|
+
this.markdown.invalidate();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private matchesCloseKey(data: string): boolean {
|
|
224
|
+
return (
|
|
225
|
+
matchesKey(data, "q") ||
|
|
226
|
+
matchesKey(data, Key.escape) ||
|
|
227
|
+
matchesKey(data, Key.enter) ||
|
|
228
|
+
matchesKey(data, Key.return) ||
|
|
229
|
+
matchesKey(data, Key.ctrl("c"))
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private renderTitle(width: number): string {
|
|
234
|
+
return truncateToWidth(this.theme.fg("warning", this.theme.bold(this.title)), width);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private renderFooter(width: number): string {
|
|
238
|
+
const progress = this.formatProgress();
|
|
239
|
+
const hints = "↑↓/j/k scroll • PgUp/PgDn page • Home/End jump • q/Esc close";
|
|
240
|
+
const progressWidth = visibleWidth(progress);
|
|
241
|
+
const footer =
|
|
242
|
+
progressWidth + 3 >= width
|
|
243
|
+
? truncateToWidth(progress, width)
|
|
244
|
+
: `${truncateToWidth(hints, width - progressWidth - 3)} • ${progress}`;
|
|
245
|
+
return this.theme.fg("dim", footer);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private formatProgress(): string {
|
|
249
|
+
const total = this.lastContentLineCount;
|
|
250
|
+
if (total === 0) return "100% 0-0/0";
|
|
251
|
+
|
|
252
|
+
const maxScroll = this.getMaxScrollOffset();
|
|
253
|
+
const percent = maxScroll === 0 ? 100 : Math.round((this.scrollOffset / maxScroll) * 100);
|
|
254
|
+
const firstLine = this.scrollOffset + 1;
|
|
255
|
+
const lastLine = Math.min(total, this.scrollOffset + this.lastViewportHeight);
|
|
256
|
+
|
|
257
|
+
return `${percent}% ${firstLine}-${lastLine}/${total}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private scrollBy(delta: number): void {
|
|
261
|
+
this.scrollOffset += delta;
|
|
262
|
+
this.clampScrollOffset();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private clampScrollOffset(): void {
|
|
266
|
+
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private getMaxScrollOffset(): number {
|
|
270
|
+
return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private getViewportHeight(): number {
|
|
274
|
+
return Math.max(1, this.tui.terminal.rows - ANSWER_CHROME_LINES - ANSWER_RESERVED_APP_LINES);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private getHalfPageHeight(): number {
|
|
278
|
+
return Math.max(1, Math.ceil(this.lastViewportHeight / 2));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function sanitizeSingleLine(text: string) {
|
|
283
|
+
return text
|
|
284
|
+
.replace(/[\r\n\t]/g, " ")
|
|
285
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, "")
|
|
286
|
+
.replace(/ +/g, " ")
|
|
287
|
+
.trim();
|
|
288
|
+
}
|
|
289
|
+
|
|
146
290
|
function buildUserPrompt(question: string, conversationContext: string) {
|
|
147
291
|
return [
|
|
148
292
|
"Answer this side question without modifying the main conversation.",
|
|
@@ -170,9 +314,10 @@ function buildConversationContext(entries: readonly SessionEntry[]) {
|
|
|
170
314
|
if (contentLines.length === 0) continue;
|
|
171
315
|
|
|
172
316
|
const label = role === "user" ? "User" : "Assistant";
|
|
173
|
-
const status =
|
|
174
|
-
|
|
175
|
-
|
|
317
|
+
const status =
|
|
318
|
+
entry.message.stopReason && entry.message.stopReason !== "stop"
|
|
319
|
+
? ` (${entry.message.stopReason})`
|
|
320
|
+
: "";
|
|
176
321
|
sections.push(`${label}${status}: ${contentLines.join("\n")}`);
|
|
177
322
|
}
|
|
178
323
|
|