2026-08-13 13:46:53 +09:00
|
|
|
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
|
2026-08-13 12:55:26 +09:00
|
|
|
|
|
|
|
|
import type { SlashCommand } from "./protocol"
|
2026-08-13 13:46:53 +09:00
|
|
|
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
2026-08-13 12:55:26 +09:00
|
|
|
|
2026-08-13 13:46:53 +09:00
|
|
|
export type CommandMenuTheme = PickerMenuTheme
|
2026-08-13 12:55:26 +09:00
|
|
|
|
|
|
|
|
/** Retained slash-command discovery with one small completion interface. */
|
|
|
|
|
export class CommandMenu {
|
|
|
|
|
readonly root: BoxRenderable
|
2026-08-13 13:46:53 +09:00
|
|
|
private readonly picker: PickerMenu<SlashCommand>
|
2026-08-13 12:55:26 +09:00
|
|
|
private commands: SlashCommand[] = []
|
|
|
|
|
private query = ""
|
|
|
|
|
|
|
|
|
|
constructor(
|
2026-08-13 13:46:53 +09:00
|
|
|
renderer: CliRenderer,
|
|
|
|
|
theme: CommandMenuTheme,
|
2026-08-13 12:55:26 +09:00
|
|
|
) {
|
2026-08-13 13:46:53 +09:00
|
|
|
this.picker = new PickerMenu(renderer, theme, {
|
2026-08-13 12:55:26 +09:00
|
|
|
id: "nanobot-tui-command-menu",
|
2026-08-13 13:46:53 +09:00
|
|
|
searchText: (command) => `${command.command} ${command.title}`,
|
|
|
|
|
render: (command) => {
|
|
|
|
|
const hint = command.argHint ? ` ${command.argHint}` : ""
|
|
|
|
|
const detail = (command.description || command.title).replace(/\s+/gu, " ")
|
|
|
|
|
return `${command.command}${hint} ${detail}`
|
|
|
|
|
},
|
2026-08-13 12:55:26 +09:00
|
|
|
})
|
2026-08-13 13:46:53 +09:00
|
|
|
this.root = this.picker.root
|
2026-08-13 12:55:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get visible(): boolean {
|
2026-08-13 13:46:53 +09:00
|
|
|
return this.picker.visible
|
2026-08-13 12:55:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setCommands(commands: SlashCommand[]): void {
|
|
|
|
|
this.commands = [...commands].sort((left, right) => left.command.localeCompare(right.command))
|
|
|
|
|
this.update(this.query)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
update(input: string, limit = 6): void {
|
|
|
|
|
const changed = input !== this.query
|
|
|
|
|
this.query = input
|
|
|
|
|
const token = /^\/[^\s]*$/u.test(input) ? input.toLocaleLowerCase() : ""
|
|
|
|
|
if (!token) {
|
|
|
|
|
this.hide()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-13 13:46:53 +09:00
|
|
|
if (changed || !this.picker.visible) this.picker.show(this.commands, token.slice(1), limit)
|
|
|
|
|
else this.picker.update(token.slice(1), limit)
|
2026-08-13 12:55:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
move(direction: -1 | 1): boolean {
|
2026-08-13 13:46:53 +09:00
|
|
|
return this.picker.move(direction)
|
2026-08-13 12:55:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
completion(input: string): string | null {
|
|
|
|
|
if (!this.visible) return null
|
2026-08-13 13:46:53 +09:00
|
|
|
const command = this.picker.current()
|
2026-08-13 12:55:26 +09:00
|
|
|
if (!command || input.trim() === command.command) return null
|
|
|
|
|
return `${command.command}${command.acceptsArgs ? " " : ""}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
complete(): string | null {
|
|
|
|
|
if (!this.visible) return null
|
2026-08-13 13:46:53 +09:00
|
|
|
const command = this.picker.current()
|
2026-08-13 12:55:26 +09:00
|
|
|
if (!command) return null
|
|
|
|
|
this.hide()
|
|
|
|
|
return `${command.command}${command.acceptsArgs ? " " : ""}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hide(): void {
|
2026-08-13 13:46:53 +09:00
|
|
|
this.picker.hide()
|
2026-08-13 12:55:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTheme(theme: CommandMenuTheme): void {
|
2026-08-13 13:46:53 +09:00
|
|
|
this.picker.setTheme(theme)
|
2026-08-13 12:55:26 +09:00
|
|
|
}
|
|
|
|
|
}
|