-
Notifications
You must be signed in to change notification settings - Fork 67
feat(btw): add branch-local history and continuity #880
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
MoerAI
wants to merge
18
commits into
code-yeongyu:main
Choose a base branch
from
MoerAI:feat/btw-history-final
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+868
−14
Open
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
ead5c25
feat(btw): persist branch-local side history
MoerAI 859fc3d
feat(btw): add history navigation state
MoerAI ab3dce4
feat(btw): render navigable history overlay
MoerAI cc3398e
feat(btw): connect history and follow-up context
MoerAI 6e86c82
docs(btw): record history extension delta
MoerAI 48db410
docs(coding-agent): note btw history
MoerAI 30f8a6a
fix(btw): sanitize persisted history display
MoerAI 3fdc20e
docs(btw): record safe history replay
MoerAI c114c66
fix(btw): sanitize live panel output
MoerAI e5b54e9
fix(btw): harden history interaction boundaries
MoerAI deb788f
docs(btw): record hardened interaction boundaries
MoerAI ce40802
docs(changelog): keep btw history unreleased
MoerAI 6649ed0
fix(btw): sanitize error notifications
MoerAI c54a3e1
fix(btw): render configured history shortcuts
MoerAI 5002a07
fix(btw): sanitize configured shortcut labels
MoerAI f320b69
fix(btw): keep history footer on one line
MoerAI a6e13e2
fix(btw): preserve prior answer provenance
MoerAI d73a092
fix(btw): prune side history as complete turns
MoerAI File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
34 changes: 34 additions & 0 deletions
34
packages/coding-agent/src/core/extensions/builtin/btw/changes.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
11 changes: 11 additions & 0 deletions
11
packages/coding-agent/src/core/extensions/builtin/btw/display-text.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
118
packages/coding-agent/src/core/extensions/builtin/btw/history-panel.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| } | ||
| } |
77 changes: 77 additions & 0 deletions
77
packages/coding-agent/src/core/extensions/builtin/btw/history-view-model.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
71
packages/coding-agent/src/core/extensions/builtin/btw/history.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}` }], | ||
| api: model.api, | ||
| provider: model.provider, | ||
| model: model.id, | ||
| usage: EMPTY_USAGE, | ||
| stopReason: "stop", | ||
| timestamp: entry.timestamp, | ||
| }, | ||
| ]); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When context budgeting is triggered and removing an earlier side-question message is enough to fit,
pruneOldMessagesToBudget()removes thatusermessage independently and stops, leaving itsassistantreply 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 inside-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 👍 / 👎.
There was a problem hiding this comment.
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.