Skip to content

Commit daf8914

Browse files
authored
Merge pull request #77 from narumiruna/feat/pi-btw-scrollable-answer
feat(btw): add scrollable answer view
2 parents 51c6535 + d596a60 commit daf8914

2 files changed

Lines changed: 175 additions & 25 deletions

File tree

extensions/pi-btw/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Use it when you want to ask a temporary question, inspect context, or get a shor
99
## ✨ Features
1010

1111
- Adds a `/btw <question>` command to Pi.
12-
- Answers side questions in a temporary UI.
12+
- Answers side questions in a temporary, scrollable UI.
1313
- Uses the current session branch as context.
1414
- Does not append the side question or answer to the main conversation.
1515
- Works as an independently installable npm Pi extension package.
@@ -46,6 +46,11 @@ Examples:
4646
/btw is this API name idiomatic?
4747
```
4848

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+
4954
## 🧠 Why use pi-btw?
5055

5156
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.

extensions/pi-btw/src/btw.ts

Lines changed: 169 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,22 @@ import {
55
getMarkdownTheme,
66
type ExtensionAPI,
77
type ExtensionCommandContext,
8+
type Theme,
89
} from "@mariozechner/pi-coding-agent";
9-
import { Container, Markdown, matchesKey, Text } from "@mariozechner/pi-tui";
10+
import {
11+
Key,
12+
Markdown,
13+
matchesKey,
14+
truncateToWidth,
15+
visibleWidth,
16+
type Component,
17+
type TUI,
18+
} from "@mariozechner/pi-tui";
1019

1120
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;
1224
const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
1325
1426
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(
120132
}
121133

122134
async function showAnswer(question: string, answer: string, ctx: ExtensionCommandContext) {
123-
await ctx.ui.custom((_tui, theme, _keybindings, done) => {
124-
const container = new Container();
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));
143137
});
144138
}
145139

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+
146290
function buildUserPrompt(question: string, conversationContext: string) {
147291
return [
148292
"Answer this side question without modifying the main conversation.",
@@ -170,9 +314,10 @@ function buildConversationContext(entries: readonly SessionEntry[]) {
170314
if (contentLines.length === 0) continue;
171315

172316
const label = role === "user" ? "User" : "Assistant";
173-
const status = entry.message.stopReason && entry.message.stopReason !== "stop"
174-
? ` (${entry.message.stopReason})`
175-
: "";
317+
const status =
318+
entry.message.stopReason && entry.message.stopReason !== "stop"
319+
? ` (${entry.message.stopReason})`
320+
: "";
176321
sections.push(`${label}${status}: ${contentLines.join("\n")}`);
177322
}
178323

0 commit comments

Comments
 (0)