mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
feat(tui): add clickable runtime controls
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
TextRenderable,
|
||||
type BoxRenderable,
|
||||
type CliRenderer,
|
||||
type KeyEvent,
|
||||
} from "@opentui/core"
|
||||
|
||||
import {
|
||||
fetchRuntimeControls,
|
||||
type WorkspaceScopePayload,
|
||||
} from "./protocol"
|
||||
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
||||
|
||||
interface RuntimeControlsTheme extends PickerMenuTheme {
|
||||
accent: string
|
||||
faint: string
|
||||
}
|
||||
|
||||
type Choice =
|
||||
| { kind: "model"; name: string; label: string; detail: string }
|
||||
| { kind: "access"; mode: "restricted" | "full"; label: string; detail: string }
|
||||
|
||||
interface RuntimeControlsOptions {
|
||||
apiUrl: string
|
||||
apiToken: string
|
||||
model: string
|
||||
modelPreset: string
|
||||
workspace: string
|
||||
access: string
|
||||
available: () => { ready: boolean; active: boolean }
|
||||
beforeOpen: () => void
|
||||
refreshScope: () => Promise<void>
|
||||
onModel: (name: string) => void
|
||||
onAccess: (scope: WorkspaceScopePayload) => void
|
||||
onStatus: (message: string) => void
|
||||
onVisibilityChange: () => void
|
||||
}
|
||||
|
||||
/** Model and workspace policy controls expose one small, mouse-optional interface. */
|
||||
export class RuntimeControls {
|
||||
readonly modelText: TextRenderable
|
||||
readonly accessText: TextRenderable
|
||||
readonly contextText: TextRenderable
|
||||
readonly menuRoot: BoxRenderable
|
||||
private readonly menu: PickerMenu<Choice>
|
||||
private kind: Choice["kind"] | null = null
|
||||
private model: string
|
||||
private modelPreset: string
|
||||
private modelPresets: Array<{ name: string; model: string }>
|
||||
private canUseFullAccess: boolean
|
||||
private controlsLoaded = false
|
||||
private controlsPromise: Promise<void> | null = null
|
||||
private scope: WorkspaceScopePayload
|
||||
|
||||
constructor(
|
||||
private readonly renderer: CliRenderer,
|
||||
private theme: RuntimeControlsTheme,
|
||||
private readonly options: RuntimeControlsOptions,
|
||||
) {
|
||||
this.model = options.model
|
||||
this.modelPreset = options.modelPreset
|
||||
this.modelPresets = [{ name: options.modelPreset, model: options.model }]
|
||||
this.scope = {
|
||||
project_path: options.workspace,
|
||||
access_mode: options.access.toLocaleLowerCase().includes("full") ? "full" : "restricted",
|
||||
}
|
||||
this.canUseFullAccess = this.scope.access_mode === "full"
|
||||
this.menu = new PickerMenu<Choice>(renderer, theme, {
|
||||
id: "nanobot-tui-runtime-menu",
|
||||
maxWidth: 64,
|
||||
searchText: (choice) => `${choice.label} ${choice.detail}`,
|
||||
render: (choice) => `${choice.label}${choice.detail ? ` ${choice.detail}` : ""}`,
|
||||
onSelect: (choice) => this.apply(choice),
|
||||
})
|
||||
this.menuRoot = this.menu.root
|
||||
this.modelText = this.controlText(renderer, "model", () => void this.openModel())
|
||||
this.accessText = this.controlText(renderer, "access", () => void this.openAccess())
|
||||
this.contextText = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-context-text",
|
||||
content: "",
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
fg: theme.faint,
|
||||
})
|
||||
this.render()
|
||||
}
|
||||
|
||||
get visible(): boolean {
|
||||
return this.menu.visible
|
||||
}
|
||||
|
||||
get workspaceScope(): WorkspaceScopePayload {
|
||||
return this.scope
|
||||
}
|
||||
|
||||
updateModel(model: string, preset: string): void {
|
||||
this.model = model
|
||||
this.modelPreset = preset
|
||||
this.render()
|
||||
}
|
||||
|
||||
updateContext(text: string): void {
|
||||
this.contextText.content = text
|
||||
}
|
||||
|
||||
updateWorkspaceScope(scope: WorkspaceScopePayload): void {
|
||||
this.scope = scope
|
||||
this.render()
|
||||
}
|
||||
|
||||
resize(width: number): void {
|
||||
this.accessText.visible = width >= 58
|
||||
this.contextText.visible = width >= 96
|
||||
}
|
||||
|
||||
choose(): boolean {
|
||||
const choice = this.menu.current()
|
||||
if (!choice) return false
|
||||
this.apply(choice)
|
||||
return true
|
||||
}
|
||||
|
||||
handleKey(key: KeyEvent): boolean {
|
||||
if (!this.visible) return false
|
||||
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
|
||||
this.menu.move(key.name === "up" ? -1 : 1)
|
||||
} else if (key.name === "escape") {
|
||||
this.hide()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
key.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (!this.visible) return
|
||||
this.kind = null
|
||||
this.menu.hide()
|
||||
this.renderColors()
|
||||
this.options.onVisibilityChange()
|
||||
}
|
||||
|
||||
setTheme(theme: RuntimeControlsTheme): void {
|
||||
this.theme = theme
|
||||
this.menu.setTheme(theme)
|
||||
this.contextText.fg = theme.faint
|
||||
this.renderColors()
|
||||
}
|
||||
|
||||
private controlText(
|
||||
renderer: CliRenderer,
|
||||
id: "model" | "access",
|
||||
open: () => void,
|
||||
): TextRenderable {
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: `nanobot-tui-${id}-text`,
|
||||
content: "",
|
||||
height: 1,
|
||||
flexShrink: id === "model" ? 1 : 0,
|
||||
fg: this.theme.muted,
|
||||
onMouseOver: () => { text.fg = this.theme.accent },
|
||||
onMouseOut: () => this.renderColors(),
|
||||
onMouseDown: (event) => {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
open()
|
||||
},
|
||||
})
|
||||
return text
|
||||
}
|
||||
|
||||
private async load(force = false): Promise<void> {
|
||||
if (force) this.controlsLoaded = false
|
||||
if (this.controlsLoaded) return
|
||||
if (this.controlsPromise) return this.controlsPromise
|
||||
if (!this.options.apiUrl || !this.options.apiToken) {
|
||||
this.controlsLoaded = true
|
||||
return
|
||||
}
|
||||
this.controlsPromise = (async () => {
|
||||
const controls = await fetchRuntimeControls(this.options.apiUrl, this.options.apiToken)
|
||||
const presets = new Map(controls.modelPresets.map((preset) => [
|
||||
preset.name.toLocaleLowerCase(),
|
||||
preset,
|
||||
]))
|
||||
if (!presets.has(this.modelPreset.toLocaleLowerCase())) {
|
||||
presets.set(this.modelPreset.toLocaleLowerCase(), {
|
||||
name: this.modelPreset,
|
||||
model: this.model,
|
||||
})
|
||||
}
|
||||
this.modelPresets = [...presets.values()]
|
||||
this.canUseFullAccess = controls.canUseFullAccess
|
||||
this.controlsLoaded = true
|
||||
})().finally(() => { this.controlsPromise = null })
|
||||
return this.controlsPromise
|
||||
}
|
||||
|
||||
private async openModel(): Promise<void> {
|
||||
if (!this.canOpen("Model")) return
|
||||
try {
|
||||
await this.load(true)
|
||||
} catch (error) {
|
||||
this.options.onStatus(error instanceof Error ? error.message : String(error))
|
||||
return
|
||||
}
|
||||
this.options.beforeOpen()
|
||||
this.kind = "model"
|
||||
const choices: Choice[] = this.modelPresets
|
||||
.map((preset) => ({
|
||||
kind: "model" as const,
|
||||
name: preset.name,
|
||||
label: `${preset.name === this.modelPreset ? "●" : " "} ${preset.name}`,
|
||||
detail: preset.model,
|
||||
}))
|
||||
.sort((left, right) => Number(right.name === this.modelPreset)
|
||||
- Number(left.name === this.modelPreset))
|
||||
this.menu.show(choices, "", rendererLimit(this.renderer.height))
|
||||
this.opened()
|
||||
}
|
||||
|
||||
private async openAccess(): Promise<void> {
|
||||
if (!this.canOpen("Access")) return
|
||||
try {
|
||||
await Promise.all([this.load(true), this.options.refreshScope()])
|
||||
} catch (error) {
|
||||
this.options.onStatus(error instanceof Error ? error.message : String(error))
|
||||
return
|
||||
}
|
||||
this.options.beforeOpen()
|
||||
this.kind = "access"
|
||||
const choices: Choice[] = [{
|
||||
kind: "access",
|
||||
mode: "restricted",
|
||||
label: `${this.scope.access_mode === "restricted" ? "●" : " "} Workspace access`,
|
||||
detail: "project files only",
|
||||
}]
|
||||
if (this.canUseFullAccess || this.scope.access_mode === "full") choices.push({
|
||||
kind: "access",
|
||||
mode: "full",
|
||||
label: `${this.scope.access_mode === "full" ? "●" : " "} Full access`,
|
||||
detail: "all local files and tools",
|
||||
})
|
||||
choices.sort((left, right) => Number(
|
||||
right.kind === "access" && right.mode === this.scope.access_mode,
|
||||
) - Number(left.kind === "access" && left.mode === this.scope.access_mode))
|
||||
this.menu.show(choices)
|
||||
this.opened()
|
||||
}
|
||||
|
||||
private canOpen(label: string): boolean {
|
||||
const state = this.options.available()
|
||||
if (state.ready && !state.active) return true
|
||||
this.options.onStatus(state.active
|
||||
? `${label} can be changed between turns`
|
||||
: "Preparing chat…")
|
||||
return false
|
||||
}
|
||||
|
||||
private opened(): void {
|
||||
this.renderColors()
|
||||
this.options.onVisibilityChange()
|
||||
}
|
||||
|
||||
private apply(choice: Choice): void {
|
||||
this.hide()
|
||||
if (choice.kind === "model") {
|
||||
if (choice.name !== this.modelPreset) this.options.onModel(choice.name)
|
||||
return
|
||||
}
|
||||
if (choice.mode === this.scope.access_mode) return
|
||||
if (choice.mode === "full" && !this.canUseFullAccess) {
|
||||
this.options.onStatus("Full access is only available from a trusted local gateway")
|
||||
return
|
||||
}
|
||||
this.options.onAccess({
|
||||
...this.scope,
|
||||
access_mode: choice.mode,
|
||||
restrict_to_workspace: choice.mode === "restricted",
|
||||
})
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const runtime = this.modelPreset !== "default"
|
||||
? [this.modelPreset, this.model].filter(Boolean).join(" · ")
|
||||
: this.model
|
||||
this.modelText.content = ` · ${runtime} ▾`
|
||||
const access = this.scope.access_mode === "full" ? "full access" : "workspace access"
|
||||
this.accessText.content = ` · ${access} ▾`
|
||||
this.renderColors()
|
||||
}
|
||||
|
||||
private renderColors(): void {
|
||||
this.modelText.fg = this.kind === "model" && this.visible
|
||||
? this.theme.accent
|
||||
: this.theme.muted
|
||||
this.accessText.fg = (
|
||||
(this.kind === "access" && this.visible) || this.scope.access_mode === "full"
|
||||
) ? this.theme.accent : this.theme.muted
|
||||
}
|
||||
}
|
||||
|
||||
function rendererLimit(height: number): number {
|
||||
return height >= 20 ? 8 : 4
|
||||
}
|
||||
Reference in New Issue
Block a user