Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

### Added

- `/btw` now keeps branch-local side-question history. Bare `/btw` opens a keyboard-driven viewer, and follow-up side
questions can use the newest ten earlier side answers without adding them to the main model conversation.

### Changed

### Removed
Expand Down
34 changes: 34 additions & 0 deletions packages/coding-agent/src/core/extensions/builtin/btw/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# changes — btw

## 2026-08-13 - Persist branch-local side-question history

### What changed

- Completed `/btw` questions and answers are stored as custom session entries, and bare `/btw` opens a keyboard-driven
history viewer without calling the provider.
- Questions, streamed answers, errors, and persisted history are stripped of terminal escape and non-printing control
sequences at every `/btw` display boundary, while stored content and follow-up context remain unchanged.
- The history overlay resolves selection, scrolling, and cancel input through the configured TUI keybindings while
retaining the default Left/Right, Up/Down, and Escape behavior.
- In-flight side queries abort before session-tree navigation so a completed answer cannot persist onto the newly
selected leaf.
- Continuity includes only the newest ten `/btw` entries from the active branch. The full main conversation snapshot,
prior side answers, and current question still pass through the model-aware side-query context budget together.
- Side-query answers are instructed to use the same language as the current side question.

### Why

- Side questions need durable continuity and review without polluting the main model conversation or leaking entries from
sibling branches.

### Why an extension could not handle it

- The builtin owns side-query dispatch, snapshot construction, provider streaming, and the focused TUI command surface.

### Expected merge-conflict zones

- `index.ts` command handling and side-query completion.
- `display-text.ts`, `panel.ts`, and `history-panel.ts` display sanitization, key handling, and non-TUI notification
formatting in `index.ts`.
- `index.ts` session-navigation abort handlers.
- `side-query.ts` instruction and bounded message assembly.
- `history.ts`, `history-view-model.ts`, and `history-panel.ts` are feature-owned additions.

## 2026-08-13 - Preserve provider-header deletion markers

### What changed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { stripAnsi } from "../../../../utils/ansi.ts";

export function sanitizeBtwDisplayText(text: string): string {
return stripAnsi(text)
.replace(/\r\n?/g, "\n")
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "");
}

export function formatBtwQuestion(text: string): string {
return sanitizeBtwDisplayText(text).replace(/\n+/g, " ").trim();
}
118 changes: 118 additions & 0 deletions packages/coding-agent/src/core/extensions/builtin/btw/history-panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { type Component, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import { formatKeyText } from "../../../../modes/interactive/components/keybinding-hints.ts";
import type { Theme } from "../../../../modes/interactive/theme/theme.ts";
import type { Keybinding, KeybindingsManager } from "../../../keybindings.ts";
import { formatBtwQuestion, sanitizeBtwDisplayText } from "./display-text.ts";
import { type BtwHistoryViewEntry, BtwHistoryViewModel } from "./history-view-model.ts";

const FOOTER_LINE_COUNT = 1;

export const BTW_HISTORY_OVERLAY_OPTIONS = { width: "90%", maxHeight: "80%", minWidth: 60, margin: 2 } as const;
export const BTW_HISTORY_OVERLAY_HEIGHT_RATIO = 0.8;
export const BTW_HISTORY_OVERLAY_CHROME_ROWS = 4;

export interface BtwHistoryLayout {
readonly questionRows: number;
readonly answerRows: number;
}

interface BtwHistoryPanelOptions {
readonly entries: readonly BtwHistoryViewEntry[];
readonly tui: {
readonly terminal: { readonly rows: number };
requestRender(): void;
};
readonly theme: Theme;
readonly keybindings: KeybindingsManager;
readonly done: (result: undefined) => void;
}

export function computeBtwHistoryLayout(input: {
readonly terminalRows: number;
readonly entryCount: number;
}): BtwHistoryLayout {
const budget = Math.max(
3,
Math.floor(Math.max(0, input.terminalRows) * BTW_HISTORY_OVERLAY_HEIGHT_RATIO) - BTW_HISTORY_OVERLAY_CHROME_ROWS,
);
const maxQuestionRows = Math.max(0, budget - FOOTER_LINE_COUNT - 1);
const questionRows = Math.min(Math.max(0, input.entryCount), maxQuestionRows);
return { questionRows, answerRows: Math.max(1, budget - questionRows - FOOTER_LINE_COUNT) };
}

export function fitBtwHistoryRow(text: string, width: number): string {
const safeWidth = Math.max(1, width);
return visibleWidth(text) > safeWidth ? truncateToWidth(text, safeWidth, "") : text;
}

function formatFooterHint(keybindings: KeybindingsManager): string {
const keys = (binding: Keybinding): string => formatKeyText(keybindings.getKeys(binding).join("/"));
return formatBtwQuestion(
`${keys("tui.editor.cursorLeft")}/${keys("tui.editor.cursorRight")}: question ${keys("tui.select.up")}/${keys("tui.select.down")}: scroll ${keys("tui.select.cancel")}: close`,
);
}

export class BtwHistoryPanel implements Component {
readonly #entries: readonly BtwHistoryViewEntry[];
readonly #model: BtwHistoryViewModel;
readonly #tui: BtwHistoryPanelOptions["tui"];
readonly #theme: Theme;
readonly #keybindings: KeybindingsManager;
readonly #done: (result: undefined) => void;

constructor(options: BtwHistoryPanelOptions) {
this.#entries = options.entries;
this.#model = new BtwHistoryViewModel(options.entries);
this.#tui = options.tui;
this.#theme = options.theme;
this.#keybindings = options.keybindings;
this.#done = options.done;
}

render(width: number): string[] {
const safeWidth = Math.max(1, width);
const selected = this.#model.selected;
if (!selected) return [this.#theme.fg("muted", fitBtwHistoryRow("No side questions yet.", safeWidth))];

const answerLines = wrapTextWithAnsi(sanitizeBtwDisplayText(selected.answer), safeWidth).map((line) =>
fitBtwHistoryRow(line, safeWidth),
);
const layout = computeBtwHistoryLayout({
terminalRows: this.#tui.terminal.rows,
entryCount: this.#model.entryCount,
});
this.#model.setAnswerLineCount(answerLines.length);
this.#model.setViewportHeight(layout.answerRows);
const lines = this.#renderQuestions(safeWidth, layout.questionRows);
lines.push(...answerLines.slice(this.#model.scrollOffset, this.#model.scrollOffset + layout.answerRows));
lines.push(this.#theme.fg("dim", fitBtwHistoryRow(formatFooterHint(this.#keybindings), safeWidth)));
return lines;
}

handleInput(data: string): void {
if (this.#keybindings.matches(data, "tui.select.cancel")) {
this.#done(undefined);
return;
}
const changed = this.#keybindings.matches(data, "tui.editor.cursorLeft")
? this.#model.selectPrevious()
: this.#keybindings.matches(data, "tui.editor.cursorRight")
? this.#model.selectNext()
: this.#keybindings.matches(data, "tui.select.up")
? this.#model.scrollUp()
: this.#keybindings.matches(data, "tui.select.down") && this.#model.scrollDown();
if (changed) this.#tui.requestRender();
}

invalidate(): void {}

#renderQuestions(width: number, rowCount: number): string[] {
const maxStart = Math.max(0, this.#entries.length - rowCount);
const start = rowCount > 0 ? Math.min(this.#model.selectedIndex, maxStart) : 0;
return this.#entries.slice(start, start + rowCount).map((entry, offset) => {
const selected = start + offset === this.#model.selectedIndex;
const row = fitBtwHistoryRow(`${selected ? "→" : " "} /btw ${formatBtwQuestion(entry.question)}`, width);
return this.#theme.fg(selected ? "accent" : "muted", row);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export interface BtwHistoryViewEntry {
readonly question: string;
readonly answer: string;
}

export class BtwHistoryViewModel {
readonly #entries: readonly BtwHistoryViewEntry[];
#selectedIndex = 0;
#scrollOffset = 0;
#answerLineCount = 0;
#viewportHeight = 0;

constructor(entries: readonly BtwHistoryViewEntry[]) {
this.#entries = entries;
}

get entryCount(): number {
return this.#entries.length;
}

get selectedIndex(): number {
return this.#selectedIndex;
}

get scrollOffset(): number {
return this.#scrollOffset;
}

get selected(): BtwHistoryViewEntry | undefined {
return this.#entries[this.#selectedIndex];
}

get maxScrollOffset(): number {
if (this.entryCount === 0) return 0;
return Math.max(0, this.#answerLineCount - this.#viewportHeight);
}

selectPrevious(): boolean {
if (this.entryCount === 0 || this.#selectedIndex === 0) return false;
this.#selectedIndex -= 1;
this.#scrollOffset = 0;
return true;
}

selectNext(): boolean {
if (this.entryCount === 0 || this.#selectedIndex >= this.entryCount - 1) return false;
this.#selectedIndex += 1;
this.#scrollOffset = 0;
return true;
}

scrollUp(): boolean {
if (this.#scrollOffset === 0) return false;
this.#scrollOffset -= 1;
return true;
}

scrollDown(): boolean {
if (this.#scrollOffset >= this.maxScrollOffset) return false;
this.#scrollOffset += 1;
return true;
}

setAnswerLineCount(count: number): void {
this.#answerLineCount = Math.max(0, count);
this.#clampScrollOffset();
}

setViewportHeight(height: number): void {
this.#viewportHeight = Math.max(0, height);
this.#clampScrollOffset();
}

#clampScrollOffset(): void {
this.#scrollOffset = Math.min(this.#scrollOffset, this.maxScrollOffset);
}
}
71 changes: 71 additions & 0 deletions packages/coding-agent/src/core/extensions/builtin/btw/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Message } from "@earendil-works/pi-ai/compat";
import type { SessionEntry } from "../../../session-manager.ts";

export const BTW_HISTORY_ENTRY_TYPE = "btw-history";
export const BTW_HISTORY_CONTEXT_LIMIT = 10;

export interface BtwHistoryEntry {
readonly question: string;
readonly answer: string;
readonly timestamp: number;
}

type AssistantMessage = Extract<Message, { role: "assistant" }>;

interface BtwHistoryModel {
readonly api: AssistantMessage["api"];
readonly provider: AssistantMessage["provider"];
readonly id: string;
}

const EMPTY_USAGE: AssistantMessage["usage"] = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};

function isBtwHistoryEntry(data: unknown): data is BtwHistoryEntry {
return (
typeof data === "object" &&
data !== null &&
"question" in data &&
typeof data.question === "string" &&
"answer" in data &&
typeof data.answer === "string" &&
"timestamp" in data &&
typeof data.timestamp === "number"
);
}

export function readBtwHistory(entries: readonly SessionEntry[]): BtwHistoryEntry[] {
const history: BtwHistoryEntry[] = [];
for (const entry of entries) {
if (entry.type !== "custom" || entry.customType !== BTW_HISTORY_ENTRY_TYPE) continue;
if (isBtwHistoryEntry(entry.data)) history.push(entry.data);
}
return history;
}

export function buildBtwHistoryMessages(
entries: readonly BtwHistoryEntry[],
model: BtwHistoryModel,
limit = BTW_HISTORY_CONTEXT_LIMIT,
): Message[] {
const boundedLimit = Math.max(0, limit);
return entries.slice(Math.max(entries.length - boundedLimit, 0)).flatMap((entry): Message[] => [
{ role: "user", content: `Earlier side question: ${entry.question}`, timestamp: entry.timestamp },
{
role: "assistant",
content: [{ type: "text", text: `Your earlier answer: ${entry.answer}` }],
Comment on lines +59 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prune prior side turns as complete pairs

When context budgeting is triggered and removing an earlier side-question message is enough to fit, pruneOldMessagesToBudget() removes that user message independently and stops, leaving its assistant reply in the provider context without the originating question. Fresh evidence at the current head is this new two-message representation combined with the existing per-message pruning in side-query.ts; the large-context path therefore no longer preserves the turn provenance this fix establishes. Keep each stored question/answer pair atomic during pruning, or remove orphaned side-history messages afterward.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d73a092. After generic model-aware pruning and tool-pair repair, the /btw bounder now drops any incomplete prefix before the first surviving user turn, so a prior assistant answer cannot remain after its question is removed. The RED test calculates the exact token boundary that previously produced [assistant prior answer, user current question]; GREEN yields only the current user question. Verification: focused 42/42, npm run check, full build, standalone production bounder PASS, RPC 20/20.

api: model.api,
provider: model.provider,
model: model.id,
usage: EMPTY_USAGE,
stopReason: "stop",
timestamp: entry.timestamp,
},
]);
}
Loading