feat(tui): complete session-native terminal workflow
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
BoxRenderable,
|
||||
DiffRenderable,
|
||||
RGBA,
|
||||
ScrollBoxRenderable,
|
||||
SyntaxStyle,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
type CliRenderer,
|
||||
type KeyEvent,
|
||||
type TreeSitterClient,
|
||||
} from "@opentui/core"
|
||||
|
||||
import type { FileEditEvent, HistoryMessage } from "./protocol"
|
||||
|
||||
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: true },
|
||||
horizontalScrollbarOptions: { visible: false },
|
||||
})
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user