mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
326 lines
9.9 KiB
TypeScript
326 lines
9.9 KiB
TypeScript
import {
|
|
BoxRenderable,
|
|
DiffRenderable,
|
|
RGBA,
|
|
ScrollBoxRenderable,
|
|
SyntaxStyle,
|
|
TextAttributes,
|
|
TextRenderable,
|
|
type CliRenderer,
|
|
type KeyEvent,
|
|
type TreeSitterClient,
|
|
} from "@opentui/core"
|
|
|
|
import type { FileEditEvent, HistoryMessage } from "./protocol"
|
|
import { hideScrollbars } from "./scrollbox"
|
|
|
|
export interface DiffViewerTheme {
|
|
text: string
|
|
muted: string
|
|
border: string
|
|
accent: string
|
|
success: string
|
|
error: string
|
|
addedBackground: string | null
|
|
removedBackground: string | null
|
|
syntax: SyntaxStyle
|
|
}
|
|
|
|
interface DiffItem {
|
|
key: string
|
|
edit: FileEditEvent
|
|
}
|
|
|
|
function editKey(edit: FileEditEvent): string {
|
|
return [edit.call_id, edit.tool, edit.path].filter(Boolean).join("|") || "unknown"
|
|
}
|
|
|
|
/** Merge start/end frames without discarding the final patch payload. */
|
|
export function mergeFileEdits(
|
|
previous: FileEditEvent[],
|
|
incoming: FileEditEvent[],
|
|
): FileEditEvent[] {
|
|
const edits = new Map(previous.map((edit) => [editKey(edit), edit]))
|
|
for (const edit of incoming) {
|
|
const key = editKey(edit)
|
|
edits.set(key, { ...edits.get(key), ...edit })
|
|
}
|
|
return [...edits.values()]
|
|
}
|
|
|
|
/** File edits from the newest user turn only; never leak an older turn into /diff. */
|
|
export function latestTurnFileEdits(messages: HistoryMessage[]): FileEditEvent[] {
|
|
let edits: FileEditEvent[] = []
|
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
const message = messages[index]
|
|
if (!message) continue
|
|
if (message.role === "user") break
|
|
if (message.fileEdits?.length) edits = mergeFileEdits(edits, message.fileEdits)
|
|
}
|
|
return edits
|
|
}
|
|
|
|
function filetype(path: string): string | undefined {
|
|
const extension = path.split(".").at(-1)?.toLocaleLowerCase()
|
|
return ({
|
|
c: "c",
|
|
cc: "cpp",
|
|
cpp: "cpp",
|
|
css: "css",
|
|
go: "go",
|
|
html: "html",
|
|
java: "java",
|
|
js: "javascript",
|
|
json: "json",
|
|
jsx: "javascript",
|
|
md: "markdown",
|
|
py: "python",
|
|
rb: "ruby",
|
|
rs: "rust",
|
|
sh: "bash",
|
|
sql: "sql",
|
|
toml: "toml",
|
|
ts: "typescript",
|
|
tsx: "typescript",
|
|
yaml: "yaml",
|
|
yml: "yaml",
|
|
} as Record<string, string | undefined>)[extension || ""]
|
|
}
|
|
|
|
function stat(edit: FileEditEvent): string {
|
|
const added = Math.max(0, edit.added || 0)
|
|
const deleted = Math.max(0, edit.deleted || 0)
|
|
return `+${added} -${deleted}`
|
|
}
|
|
|
|
/** Full-screen, read-only projection of file-edit events owned by the session. */
|
|
export class DiffViewer {
|
|
readonly root: BoxRenderable
|
|
private readonly header: TextRenderable
|
|
private readonly fileHeader: TextRenderable
|
|
private readonly scroll: ScrollBoxRenderable
|
|
private readonly footer: TextRenderable
|
|
private items: DiffItem[] = []
|
|
private selected = 0
|
|
|
|
constructor(
|
|
private readonly renderer: CliRenderer,
|
|
private theme: DiffViewerTheme,
|
|
private readonly treeSitterClient: TreeSitterClient,
|
|
) {
|
|
this.root = new BoxRenderable(renderer, {
|
|
id: "nanobot-tui-diff-viewer",
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
height: "100%",
|
|
zIndex: 100,
|
|
flexDirection: "column",
|
|
padding: 1,
|
|
backgroundColor: RGBA.defaultBackground(),
|
|
visible: false,
|
|
})
|
|
this.header = new TextRenderable(renderer, {
|
|
id: "nanobot-tui-diff-header",
|
|
content: "Diff · Last turn",
|
|
width: "100%",
|
|
height: 1,
|
|
flexShrink: 0,
|
|
fg: theme.text,
|
|
attributes: TextAttributes.BOLD,
|
|
})
|
|
this.fileHeader = new TextRenderable(renderer, {
|
|
id: "nanobot-tui-diff-file",
|
|
content: "",
|
|
width: "100%",
|
|
minHeight: 1,
|
|
maxHeight: 2,
|
|
flexShrink: 0,
|
|
fg: theme.muted,
|
|
wrapMode: "word",
|
|
})
|
|
this.scroll = new ScrollBoxRenderable(renderer, {
|
|
id: "nanobot-tui-diff-scroll",
|
|
width: "100%",
|
|
minHeight: 0,
|
|
flexGrow: 1,
|
|
scrollX: false,
|
|
scrollY: true,
|
|
viewportCulling: true,
|
|
contentOptions: {
|
|
flexDirection: "column",
|
|
paddingTop: 1,
|
|
paddingBottom: 1,
|
|
},
|
|
verticalScrollbarOptions: { visible: false },
|
|
horizontalScrollbarOptions: { visible: false },
|
|
})
|
|
hideScrollbars(this.scroll)
|
|
this.footer = new TextRenderable(renderer, {
|
|
id: "nanobot-tui-diff-footer",
|
|
content: "←/→ file · pgup/pgdn scroll · esc close",
|
|
width: "100%",
|
|
height: 1,
|
|
flexShrink: 0,
|
|
fg: theme.muted,
|
|
})
|
|
this.root.add(this.header)
|
|
this.root.add(this.fileHeader)
|
|
this.root.add(this.scroll)
|
|
this.root.add(this.footer)
|
|
}
|
|
|
|
get visible(): boolean {
|
|
return this.root.visible
|
|
}
|
|
|
|
show(edits: FileEditEvent[]): void {
|
|
this.update(edits)
|
|
this.root.visible = true
|
|
}
|
|
|
|
update(edits: FileEditEvent[]): void {
|
|
const selectedKey = this.items[this.selected]?.key
|
|
this.items = edits.map((edit) => ({ key: editKey(edit), edit }))
|
|
this.selected = Math.max(0, selectedKey
|
|
? this.items.findIndex((item) => item.key === selectedKey)
|
|
: Math.min(this.selected, this.items.length - 1))
|
|
if (this.selected < 0) this.selected = 0
|
|
this.render()
|
|
}
|
|
|
|
hide(): void {
|
|
this.root.visible = false
|
|
}
|
|
|
|
handleKey(key: KeyEvent): boolean {
|
|
if (!this.visible) return false
|
|
if (key.name === "escape") {
|
|
this.hide()
|
|
return true
|
|
}
|
|
if (key.name === "pageup" || key.name === "pagedown") {
|
|
const direction = key.name === "pageup" ? -1 : 1
|
|
this.scroll.scrollBy(direction * Math.max(3, Math.floor(this.scroll.height * 0.75)))
|
|
return true
|
|
}
|
|
if (key.name === "home") {
|
|
this.scroll.scrollTo(0)
|
|
return true
|
|
}
|
|
if (key.name === "end") {
|
|
this.scroll.scrollTo(this.scroll.scrollHeight)
|
|
return true
|
|
}
|
|
if (["[", "]", "left", "right"].includes(key.name)) {
|
|
this.select(key.name === "[" || key.name === "left" ? -1 : 1)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
resize(width: number): void {
|
|
this.footer.content = width >= 58
|
|
? "←/→ file · pgup/pgdn scroll · home/end · esc close"
|
|
: "←/→ file · pgup/pgdn · esc"
|
|
}
|
|
|
|
setTheme(theme: DiffViewerTheme): void {
|
|
const previousSyntax = this.theme.syntax
|
|
this.theme = theme
|
|
this.header.fg = theme.text
|
|
this.fileHeader.fg = theme.muted
|
|
this.footer.fg = theme.muted
|
|
this.render()
|
|
void this.renderer.idle().catch(() => {}).finally(() => previousSyntax.destroy())
|
|
}
|
|
|
|
destroy(): void {
|
|
this.theme.syntax.destroy()
|
|
}
|
|
|
|
private select(direction: -1 | 1): void {
|
|
if (this.items.length < 2) return
|
|
this.selected = (this.selected + direction + this.items.length) % this.items.length
|
|
this.render()
|
|
}
|
|
|
|
private render(): void {
|
|
for (const child of [...this.scroll.getChildren()]) {
|
|
this.scroll.remove(child)
|
|
child.destroyRecursively()
|
|
}
|
|
const totalAdded = this.items.reduce((sum, item) => sum + Math.max(0, item.edit.added || 0), 0)
|
|
const totalDeleted = this.items.reduce((sum, item) => sum + Math.max(0, item.edit.deleted || 0), 0)
|
|
const count = this.items.length
|
|
this.header.content = count
|
|
? `Diff · Last turn · ${count} ${count === 1 ? "change" : "changes"} · +${totalAdded} -${totalDeleted}`
|
|
: "Diff · Last turn"
|
|
const item = this.items[this.selected]
|
|
if (!item) {
|
|
this.fileHeader.content = "No file changes in the last turn."
|
|
this.scroll.add(this.text("Run an editing task, then use /diff to inspect its patch.", "muted"))
|
|
return
|
|
}
|
|
const edit = item.edit
|
|
const index = this.items.length > 1 ? `${this.selected + 1}/${this.items.length} · ` : ""
|
|
const state = edit.status === "editing" ? " · editing" : edit.status === "error" ? " · failed" : ""
|
|
this.fileHeader.content = `${index}${edit.path || "Unknown file"} · ${stat(edit)}${state}`
|
|
const text = edit.diff?.format === "unified" ? edit.diff.text?.trimEnd() : ""
|
|
if (text) {
|
|
this.scroll.add(new DiffRenderable(this.renderer, {
|
|
id: `nanobot-tui-diff-${this.selected}`,
|
|
diff: text,
|
|
width: "100%",
|
|
height: "auto",
|
|
flexShrink: 0,
|
|
view: "unified",
|
|
wrapMode: "char",
|
|
showLineNumbers: true,
|
|
fg: this.theme.text,
|
|
filetype: filetype(edit.path || ""),
|
|
syntaxStyle: this.theme.syntax,
|
|
treeSitterClient: this.treeSitterClient,
|
|
lineNumberFg: this.theme.muted,
|
|
lineNumberBg: RGBA.defaultBackground(),
|
|
contextBg: RGBA.defaultBackground(),
|
|
contextContentBg: RGBA.defaultBackground(),
|
|
addedBg: this.theme.addedBackground || RGBA.defaultBackground(),
|
|
addedContentBg: this.theme.addedBackground || RGBA.defaultBackground(),
|
|
addedLineNumberBg: this.theme.addedBackground || RGBA.defaultBackground(),
|
|
removedBg: this.theme.removedBackground || RGBA.defaultBackground(),
|
|
removedContentBg: this.theme.removedBackground || RGBA.defaultBackground(),
|
|
removedLineNumberBg: this.theme.removedBackground || RGBA.defaultBackground(),
|
|
addedSignColor: this.theme.success,
|
|
removedSignColor: this.theme.error,
|
|
selectionBg: this.theme.border,
|
|
selectionFg: this.theme.text,
|
|
}))
|
|
if (edit.diff?.truncated) {
|
|
this.scroll.add(this.text("… Diff truncated by the gateway; line counts may be larger.", "accent"))
|
|
}
|
|
return
|
|
}
|
|
const message = edit.status === "editing"
|
|
? "The file is still being edited; its patch will appear when the tool finishes."
|
|
: edit.status === "error"
|
|
? edit.error || "The edit failed before a patch was produced."
|
|
: edit.binary
|
|
? "Binary file changed; an inline text diff is unavailable."
|
|
: "This edit has no renderable patch."
|
|
this.scroll.add(this.text(message, edit.status === "error" ? "error" : "muted"))
|
|
}
|
|
|
|
private text(content: string, tone: "muted" | "error" | "accent"): TextRenderable {
|
|
return new TextRenderable(this.renderer, {
|
|
id: `nanobot-tui-diff-note-${this.selected}-${tone}`,
|
|
content,
|
|
width: "100%",
|
|
minHeight: 1,
|
|
fg: this.theme[tone],
|
|
wrapMode: "word",
|
|
})
|
|
}
|
|
}
|