mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
308 lines
9.1 KiB
TypeScript
308 lines
9.1 KiB
TypeScript
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: () => boolean
|
|
beforeOpen: () => void
|
|
refreshScope: () => Promise<void>
|
|
onModel: (name: string) => void
|
|
onAccess: (scope: WorkspaceScopePayload) => void
|
|
onStatus: (message: string) => void
|
|
onVisibilityChange: (visible: boolean) => 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,
|
|
selectable: false,
|
|
})
|
|
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(false)
|
|
}
|
|
|
|
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,
|
|
selectable: false,
|
|
onMouseOver: () => { text.fg = this.theme.accent },
|
|
onMouseOut: () => this.renderColors(),
|
|
onMouseDown: (event) => {
|
|
if (event.button !== 0) return
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
this.renderer.clearSelection()
|
|
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()) 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()) 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(): boolean {
|
|
if (this.options.available()) return true
|
|
this.options.onStatus("Preparing chat…")
|
|
return false
|
|
}
|
|
|
|
private opened(): void {
|
|
this.renderColors()
|
|
this.options.onVisibilityChange(true)
|
|
}
|
|
|
|
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
|
|
}
|