feat(tui): integrate with Herdr host

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 783d381710
commit 79d51be71f
17 changed files with 622 additions and 32 deletions
+174
View File
@@ -0,0 +1,174 @@
export type HostAgentState = "idle" | "working" | "blocked" | "unknown"
export interface HostMetadata {
model?: string
branch?: string
workspace?: string
task?: string
action?: string
}
export interface TuiHost {
readonly hosted: boolean
reportState(state: HostAgentState, message?: string): void
reportSession(sessionId: string): void
reportMetadata(metadata: HostMetadata): void
release(): void
}
export function currentGitBranch(workspace: string): string {
const path = workspace.trim()
if (!path) return ""
try {
const branch = spawnText(["git", "-C", path, "branch", "--show-current"])
if (branch) return branch
const revision = spawnText(["git", "-C", path, "rev-parse", "--short", "HEAD"])
return revision ? `@${revision}` : ""
} catch {
return ""
}
}
type Environment = Record<string, string | undefined>
type CommandRunner = (command: readonly string[]) => Promise<void>
const AGENT = "nanobot"
const LIFECYCLE_SOURCE = "nanobot:tui"
const METADATA_SOURCE = "nanobot:tui:metadata"
const METADATA_KEYS = ["model", "branch", "workspace", "task", "action"] as const
class StandaloneHost implements TuiHost {
readonly hosted = false
reportState(): void {}
reportSession(): void {}
reportMetadata(): void {}
release(): void {}
}
class HerdrHost implements TuiHost {
readonly hosted = true
private sequence = 0
private released = false
private lastState = ""
private lastSession = ""
private metadata: HostMetadata = {}
private queue: Promise<void> = Promise.resolve()
constructor(
private readonly paneId: string,
private readonly binary: string,
private readonly run: CommandRunner,
) {}
reportState(state: HostAgentState, message = ""): void {
if (this.released) return
const cleanMessage = normalize(message)
const fingerprint = `${state}\0${cleanMessage}\0${this.lastSession}`
if (fingerprint === this.lastState) return
this.lastState = fingerprint
const args = [
"pane", "report-agent", this.paneId,
"--source", LIFECYCLE_SOURCE,
"--agent", AGENT,
"--state", state,
"--seq", String(this.nextSequence()),
]
if (cleanMessage) args.push("--message", cleanMessage)
if (this.lastSession) args.push("--agent-session-id", this.lastSession)
this.enqueue(args)
}
reportSession(sessionId: string): void {
if (this.released) return
const cleanSession = normalize(sessionId, 256)
if (!cleanSession || cleanSession === this.lastSession) return
this.lastSession = cleanSession
this.lastState = ""
this.enqueue([
"pane", "report-agent-session", this.paneId,
"--source", LIFECYCLE_SOURCE,
"--agent", AGENT,
"--agent-session-id", cleanSession,
"--seq", String(this.nextSequence()),
])
}
reportMetadata(next: HostMetadata): void {
if (this.released) return
const changed: Array<[typeof METADATA_KEYS[number], string]> = []
for (const key of METADATA_KEYS) {
const value = normalize(next[key])
if (value === normalize(this.metadata[key])) continue
changed.push([key, value])
}
if (!changed.length) return
this.metadata = { ...this.metadata, ...next }
const args = [
"pane", "report-metadata", this.paneId,
"--source", METADATA_SOURCE,
"--agent", AGENT,
"--display-agent", AGENT,
"--seq", String(this.nextSequence()),
]
const task = normalize(this.metadata.task)
args.push(task ? "--title" : "--clear-title")
if (task) args.push(task)
for (const [key, value] of changed) {
args.push(value ? "--token" : "--clear-token", value ? `${key}=${value}` : key)
}
this.enqueue(args)
}
release(): void {
if (this.released) return
this.released = true
const clear = [
"pane", "report-metadata", this.paneId,
"--source", METADATA_SOURCE,
"--clear-title", "--clear-display-agent", "--clear-state-labels",
"--seq", String(this.nextSequence()),
]
for (const key of METADATA_KEYS) clear.push("--clear-token", key)
this.enqueue(clear)
this.enqueue([
"pane", "release-agent", this.paneId,
"--source", LIFECYCLE_SOURCE,
"--agent", AGENT,
"--seq", String(this.nextSequence()),
])
}
private nextSequence(): number {
this.sequence += 1
return this.sequence
}
private enqueue(args: string[]): void {
const command = [this.binary, ...args]
this.queue = this.queue.then(() => this.run(command)).catch(() => {})
}
}
function normalize(value: string | undefined, limit = 80): string {
return (value || "").replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit)
}
async function runCommand(command: readonly string[]): Promise<void> {
const child = Bun.spawn([...command], { stdout: "ignore", stderr: "ignore" })
await child.exited
}
function spawnText(command: readonly string[]): string {
const result = Bun.spawnSync([...command], { stdout: "pipe", stderr: "ignore" })
if (result.exitCode !== 0) return ""
return new TextDecoder().decode(result.stdout).trim()
}
export function createTuiHost(
environment: Environment = process.env,
run: CommandRunner = runCommand,
): TuiHost {
const paneId = environment.HERDR_PANE_ID?.trim() || ""
if (environment.HERDR_ENV !== "1" || !paneId) return new StandaloneHost()
return new HerdrHost(paneId, environment.HERDR_BIN_PATH?.trim() || "herdr", run)
}