fix(tui): preserve slash command semantics

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 2b666934ba
commit b3c3a82075
9 changed files with 349 additions and 57 deletions
+74 -14
View File
@@ -1,15 +1,48 @@
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
import type { SlashCommand } from "./protocol"
import type { SlashCommand, SlashCommandLifecycle } from "./protocol"
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
export type CommandMenuTheme = PickerMenuTheme
export type TuiCommandAction = "sessions" | "new-chat"
export interface TuiCommand {
command: string
title: string
description: string
action: TuiCommandAction
}
export type CommandChoice =
| { source: "gateway"; command: SlashCommand }
| { source: "tui"; command: TuiCommand }
export type ResolvedSlashCommandLifecycle = Exclude<SlashCommandLifecycle, "agent_turn_with_args">
function descriptor(choice: CommandChoice): SlashCommand | TuiCommand {
return choice.command
}
export function resolveSlashCommandLifecycle(
input: string,
command: SlashCommand,
): ResolvedSlashCommandLifecycle | null {
const name = input.split(/\s+/u, 1)[0] || ""
if (name.toLocaleLowerCase() !== command.command.toLocaleLowerCase()) return null
const args = input.slice(name.length).trim()
if (args && !command.acceptsArgs) return null
if (command.lifecycle === "agent_turn_with_args") {
return args ? "agent_turn" : "side_channel"
}
return command.lifecycle
}
/** Retained slash-command discovery with one small completion interface. */
export class CommandMenu {
readonly root: BoxRenderable
private readonly picker: PickerMenu<SlashCommand>
private commands: SlashCommand[] = []
private readonly picker: PickerMenu<CommandChoice>
private commands: CommandChoice[] = []
private query = ""
constructor(
@@ -18,9 +51,14 @@ export class CommandMenu {
) {
this.picker = new PickerMenu(renderer, theme, {
id: "nanobot-tui-command-menu",
searchText: (command) => `${command.command} ${command.title}`,
render: (command) => {
const hint = command.argHint ? ` ${command.argHint}` : ""
searchText: (choice) => {
const command = descriptor(choice)
return `${command.command} ${command.title}`
},
render: (choice) => {
const command = descriptor(choice)
const argHint = "argHint" in command ? command.argHint : ""
const hint = argHint ? ` ${argHint}` : ""
const detail = (command.description || command.title).replace(/\s+/gu, " ")
return `${command.command}${hint} ${detail}`
},
@@ -32,11 +70,28 @@ export class CommandMenu {
return this.picker.visible
}
setCommands(commands: SlashCommand[]): void {
this.commands = [...commands].sort((left, right) => left.command.localeCompare(right.command))
setCommands(commands: SlashCommand[], local: TuiCommand[] = []): void {
const gatewayNames = new Set(commands.map((command) => command.command.toLocaleLowerCase()))
this.commands = [
...commands.map((command): CommandChoice => ({ source: "gateway", command })),
...local
.filter((command) => !gatewayNames.has(command.command.toLocaleLowerCase()))
.map((command): CommandChoice => ({ source: "tui", command })),
].sort((left, right) => descriptor(left).command.localeCompare(descriptor(right).command))
this.update(this.query)
}
resolve(input: string): CommandChoice | null {
const name = input.trim().split(/\s+/u, 1)[0]?.toLocaleLowerCase()
if (!name) return null
return this.commands.find((choice) => {
const command = descriptor(choice)
if (command.command.toLocaleLowerCase() !== name) return false
if (choice.source === "tui") return input.trim().length === command.command.length
return resolveSlashCommandLifecycle(input.trim(), choice.command) !== null
}) || null
}
update(input: string, limit = 6): void {
const changed = input !== this.query
this.query = input
@@ -55,17 +110,22 @@ export class CommandMenu {
completion(input: string): string | null {
if (!this.visible) return null
const command = this.picker.current()
if (!command || input.trim() === command.command) return null
return `${command.command}${command.acceptsArgs ? " " : ""}`
const choice = this.picker.current()
if (!choice) return null
const command = descriptor(choice)
if (input.trim() === command.command) return null
const acceptsArgs = choice.source === "gateway" && choice.command.acceptsArgs
return `${command.command}${acceptsArgs ? " " : ""}`
}
complete(): string | null {
if (!this.visible) return null
const command = this.picker.current()
if (!command) return null
const choice = this.picker.current()
if (!choice) return null
const command = descriptor(choice)
this.hide()
return `${command.command}${command.acceptsArgs ? " " : ""}`
const acceptsArgs = choice.source === "gateway" && choice.command.acceptsArgs
return `${command.command}${acceptsArgs ? " " : ""}`
}
hide(): void {