fix(tui): preserve slash command semantics
This commit is contained in:
+74
-14
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user