feat(tui): autocomplete skill references

This commit is contained in:
chengyongru
2026-08-25 23:11:24 +08:00
committed by chengyongru
parent 649e3958c5
commit b218d3e7f8
7 changed files with 436 additions and 6 deletions
+64
View File
@@ -10,6 +10,7 @@ import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
import type {
MessageOptions,
RecoveryState,
SkillCandidate,
SlashCommand,
WorkspaceScopePayload,
} from "./protocol"
@@ -513,6 +514,69 @@ describe("NanobotTui layout", () => {
expect(sent).toEqual([])
})
test("completes available skills with arrows, Enter, Tab, and Escape", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const sent: string[] = []
const app = mount(setup, sent)
const ui = app as unknown as {
ready: boolean
composer: TextareaRenderable
skillCandidates: SkillCandidate[]
skillMenu: { visible: boolean }
}
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => ui.ready)
ui.skillCandidates = [
{ name: "simplify", description: "Simplify code", source: "workspace" },
{ name: "verify", description: "Verify public behavior", source: "builtin" },
]
await setup.mockInput.typeText("$")
expect(ui.skillMenu.visible).toBe(true)
setup.mockInput.pressArrow("down")
setup.mockInput.pressEnter()
await waitUntil(() => ui.composer.plainText === "$verify ")
expect(ui.skillMenu.visible).toBe(false)
expect(sent).toEqual([])
ui.composer.setText("")
await setup.mockInput.typeText("please $sim")
expect(ui.skillMenu.visible).toBe(true)
setup.mockInput.pressTab()
expect(ui.composer.plainText).toBe("please $simplify ")
expect(ui.skillMenu.visible).toBe(false)
ui.composer.setText("")
await setup.mockInput.typeText("$")
expect(ui.skillMenu.visible).toBe(true)
setup.mockInput.pressEscape()
await waitUntil(() => !ui.skillMenu.visible)
expect(ui.skillMenu.visible).toBe(false)
expect(ui.composer.plainText).toBe("$")
ui.composer.setText("")
await setup.mockInput.typeText("请用 $ver")
expect(ui.skillMenu.visible).toBe(true)
setup.mockInput.pressTab()
expect(ui.composer.plainText).toBe("请用 $verify ")
await setup.mockInput.typeText("now")
expect(ui.composer.plainText).toBe("请用 $verify now")
ui.composer.setText("use $verify later")
ui.composer.cursorOffset = 8
await waitUntil(() => ui.skillMenu.visible)
ui.composer.cursorOffset = ui.composer.plainText.length
await waitUntil(() => !ui.skillMenu.visible)
ui.composer.setText("")
await setup.mockInput.typeText("$missing")
expect(ui.skillMenu.visible).toBe(true)
ui.composer.submit()
await waitUntil(() => sent.length === 1)
expect(sent).toEqual(["$missing"])
expect(ui.skillMenu.visible).toBe(false)
})
test("runs bang commands through the gateway without steering the agent", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const sent: string[] = []
+131 -5
View File
@@ -20,6 +20,7 @@ import {
import {
NanobotClient,
fetchAvailableSkills,
fetchHistory,
fetchGatewayConnection,
fetchMentionCandidates,
@@ -35,6 +36,7 @@ import {
type MentionCandidate,
type MessageOptions,
type RecoveryState,
type SkillCandidate,
type SlashCommand,
type SessionSummary,
type TokenUsage,
@@ -69,6 +71,12 @@ import {
mentionQuery,
type MentionQuery,
} from "./mention-menu"
import {
insertSkill,
SkillMenu,
skillQuery,
type SkillQuery,
} from "./skill-menu"
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
import { RecoveryNotice, type RecoveryNoticeTheme } from "./recovery-notice"
@@ -399,6 +407,7 @@ export class NanobotTui {
private readonly commandMenu: CommandMenu
private readonly sessionMenu: SessionMenu
private readonly mentionMenu: MentionMenu
private readonly skillMenu: SkillMenu
private readonly branchMenu: BranchMenu
private readonly runtimeControls: RuntimeControls
private readonly contextPanel: ContextPanel
@@ -455,6 +464,8 @@ export class NanobotTui {
private readyDetail = ""
private mentionCandidates: MentionCandidate[] = []
private activeMentionQuery: MentionQuery | null = null
private skillCandidates: SkillCandidate[] = []
private activeSkillQuery: SkillQuery | null = null
private transcriptNavigation: TranscriptNavigation = {
awayFromBottom: false,
unseenOutput: false,
@@ -478,6 +489,7 @@ export class NanobotTui {
private hostBranch: string
private readonly apiReauthenticator: ApiReauthenticator | undefined
private apiRefreshPromise: Promise<GatewayApiConnection> | null = null
private skillLoadId = 0
private constructor(
renderer: CliRenderer,
@@ -517,6 +529,7 @@ export class NanobotTui {
(session) => this.switchSession(session),
)
this.mentionMenu = new MentionMenu(renderer, commandMenuTheme(this.palette))
this.skillMenu = new SkillMenu(renderer, commandMenuTheme(this.palette))
this.branchMenu = new BranchMenu(renderer, commandMenuTheme(this.palette))
this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette))
this.diffViewer = new DiffViewer(
@@ -704,6 +717,9 @@ export class NanobotTui {
{ name: "linefeed", action: "newline" },
{ name: "return", action: "submit" },
],
onCursorChange: () => {
if (!this.sessionMenu.visible && !this.branchMenu.visible) this.syncComposerMenus()
},
onContentChange: () => {
this.draft.prune(this.composer.plainText)
this.runtimeControls.hide()
@@ -756,6 +772,7 @@ export class NanobotTui {
this.shell.add(this.commandMenu.root)
this.shell.add(this.sessionMenu.root)
this.shell.add(this.mentionMenu.root)
this.shell.add(this.skillMenu.root)
this.shell.add(this.branchMenu.root)
this.shell.add(this.contextPanel.root)
this.shell.add(this.runtimeControls.menuRoot)
@@ -810,6 +827,7 @@ export class NanobotTui {
this.client.connect()
void this.loadCommands()
void this.loadMentions()
void this.loadSkills()
this.runtimeControls.preload()
this.renderer.start()
// OpenTUI learns the real terminal background through OSC 10/11. Wait for
@@ -865,6 +883,15 @@ export class NanobotTui {
if (candidate) this.chooseMention(candidate, this.activeMentionQuery)
return
}
if (this.skillMenu.visible && this.activeSkillQuery) {
const candidate = this.skillMenu.choose()
if (candidate) {
this.chooseSkill(candidate, this.activeSkillQuery)
return
}
this.skillMenu.hide()
this.activeSkillQuery = null
}
if (!visibleContent) return
if (["exit", "quit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
this.quit()
@@ -926,6 +953,7 @@ export class NanobotTui {
this.clearComposer()
this.commandMenu.hide()
this.mentionMenu.hide()
this.skillMenu.hide()
this.recordPrompt(prompt.content)
this.transcript.user(prompt.content, turnId)
this.hostBlocked = false
@@ -1336,6 +1364,7 @@ export class NanobotTui {
this.updateGatewayApiConnection(apiUrl, apiToken)
void this.loadCommands()
void this.loadMentions()
void this.loadSkills()
}
private async refreshApiConnection(
@@ -1477,6 +1506,7 @@ export class NanobotTui {
this.clearComposer()
this.commandMenu.hide()
this.mentionMenu.hide()
this.skillMenu.hide()
this.recordPrompt(content)
this.syncQueuePreview()
this.renderActiveStatus()
@@ -1581,6 +1611,31 @@ export class NanobotTui {
return
}
}
if (this.skillMenu.visible) {
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
this.skillMenu.move(key.name === "up" ? -1 : 1)
key.preventDefault()
return
}
if (!key.ctrl && !key.meta && key.name === "tab" && this.activeSkillQuery) {
const candidate = this.skillMenu.choose()
if (candidate) {
this.chooseSkill(candidate, this.activeSkillQuery)
key.preventDefault()
return
}
this.skillMenu.hide()
this.activeSkillQuery = null
this.updateMeta()
}
if (key.name === "escape") {
this.skillMenu.hide()
this.activeSkillQuery = null
this.updateMeta()
key.preventDefault()
return
}
}
if (this.commandMenu.visible) {
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
this.commandMenu.move(key.name === "up" ? -1 : 1)
@@ -1720,6 +1775,7 @@ export class NanobotTui {
this.commandMenu.setTheme(commandMenuTheme(this.palette))
this.sessionMenu.setTheme(commandMenuTheme(this.palette))
this.mentionMenu.setTheme(commandMenuTheme(this.palette))
this.skillMenu.setTheme(commandMenuTheme(this.palette))
this.branchMenu.setTheme(commandMenuTheme(this.palette))
this.runtimeControls.setTheme(runtimeControlsTheme(this.palette))
this.contextPanel.setTheme(contextPanelTheme(this.palette))
@@ -1749,6 +1805,7 @@ export class NanobotTui {
private updateMeta(): void {
const mode: FooterMode = this.runtimeControls.visible ? "runtime"
: this.mentionMenu.visible ? "mention"
: this.skillMenu.visible ? "skill"
: this.activeTurn ? "active"
: this.branchMenu.visible ? "branch"
: this.commandMenu.visible ? "command"
@@ -1932,17 +1989,30 @@ export class NanobotTui {
}
private syncComposerMenus(): void {
this.activeMentionQuery = mentionQuery(this.composer.plainText, this.composer.cursorOffset)
const candidates = this.availableMentions()
if (this.activeMentionQuery && candidates.length) {
const value = this.composer.plainText
const cursor = this.composerStringCursor()
this.activeMentionQuery = mentionQuery(value, cursor)
this.activeSkillQuery = skillQuery(value, cursor)
const mentionCandidates = this.availableMentions()
if (this.activeMentionQuery && mentionCandidates.length) {
this.commandMenu.hide()
this.skillMenu.hide()
const limit = this.renderer.height >= 20 ? 7 : 4
if (this.mentionMenu.visible) this.mentionMenu.update(this.activeMentionQuery.query, limit)
else this.mentionMenu.show(candidates, this.activeMentionQuery.query, limit)
else this.mentionMenu.show(mentionCandidates, this.activeMentionQuery.query, limit)
this.updateMeta()
return
}
this.mentionMenu.hide()
if (this.activeSkillQuery && this.skillCandidates.length) {
this.commandMenu.hide()
const limit = this.renderer.height >= 20 ? 7 : 4
if (this.skillMenu.visible) this.skillMenu.update(this.activeSkillQuery.query, limit)
else this.skillMenu.show(this.skillCandidates, this.activeSkillQuery.query, limit)
this.updateMeta()
return
}
this.skillMenu.hide()
this.syncCommandMenu()
}
@@ -1961,13 +2031,44 @@ export class NanobotTui {
private chooseMention(candidate: MentionCandidate, query: MentionQuery): void {
const inserted = insertMention(this.composer.plainText, candidate, query)
this.composer.setText(inserted.value)
this.composer.cursorOffset = inserted.cursor
this.setComposerStringCursor(inserted.value, inserted.cursor)
this.mentionMenu.hide()
this.activeMentionQuery = null
this.syncComposerPlaceholder()
this.updateMeta()
}
private chooseSkill(candidate: SkillCandidate, query: SkillQuery): void {
const inserted = insertSkill(this.composer.plainText, candidate, query)
this.composer.setText(inserted.value)
this.setComposerStringCursor(inserted.value, inserted.cursor)
this.skillMenu.hide()
this.activeSkillQuery = null
this.syncComposerPlaceholder()
this.updateMeta()
}
private composerStringCursor(): number {
return this.composer.editBuffer.getTextRange(0, this.composer.cursorOffset).length
}
private setComposerStringCursor(value: string, cursor: number): void {
const target = Math.min(Math.max(cursor, 0), value.length)
const before = value.slice(0, target)
const row = before.split("\n").length - 1
const line = before.slice(before.lastIndexOf("\n") + 1)
let offset = row === 0 ? 0 : this.composer.editBuffer.getLineStartOffset(row)
const maxColumn = Math.max(8, line.length * 8 + 8)
for (let column = 0; column <= maxColumn; column += 1) {
const candidate = this.composer.editBuffer.positionToOffset(row, column)
if (candidate === 0 && (row !== 0 || column !== 0)) break
const candidateLength = this.composer.editBuffer.getTextRange(0, candidate).length
if (candidateLength > target) break
if (candidateLength === target) offset = candidate
}
this.composer.cursorOffset = offset
}
private setComposer(content: string): void {
this.draft.clear()
this.composer.setText(content)
@@ -2010,9 +2111,11 @@ export class NanobotTui {
this.commandMenu.hide()
this.hideSessionMenu()
this.mentionMenu.hide()
this.skillMenu.hide()
this.branchMenu.hide()
this.contextPanel.hide()
this.activeMentionQuery = null
this.activeSkillQuery = null
}
private dismissRuntimeControls(): void {
@@ -2043,6 +2146,25 @@ export class NanobotTui {
}
}
private async loadSkills(): Promise<void> {
const loadId = ++this.skillLoadId
try {
const candidates = await fetchAvailableSkills(
this.options.apiUrl,
this.options.apiToken,
this.apiReauthenticator,
)
if (loadId !== this.skillLoadId) return
this.skillCandidates = candidates
if (this.activeSkillQuery) {
this.skillMenu.hide()
this.syncComposerMenus()
}
} catch {
// Skill completion is additive; explicit $skill-name input still works.
}
}
private availableMentions(): MentionCandidate[] {
const currentKey = this.client.activeChatId
? `websocket:${this.client.activeChatId}`
@@ -2127,6 +2249,7 @@ export class NanobotTui {
this.commandMenu.hide()
this.dismissRuntimeControls()
this.mentionMenu.hide()
this.skillMenu.hide()
this.branchMenu.hide()
this.contextPanel.hide()
this.clearComposer()
@@ -2221,6 +2344,7 @@ export class NanobotTui {
this.commandMenu.hide()
this.hideSessionMenu()
this.mentionMenu.hide()
this.skillMenu.hide()
this.branchMenu.hide()
this.contextPanel.hide()
this.clearComposer()
@@ -2387,6 +2511,7 @@ export class NanobotTui {
this.commandMenu.hide()
this.hideSessionMenu()
this.mentionMenu.hide()
this.skillMenu.hide()
this.branchMenu.hide()
this.clearComposer()
this.status.content = "Reading agent context…"
@@ -2459,6 +2584,7 @@ export class NanobotTui {
this.commandMenu.hide()
this.hideSessionMenu()
this.mentionMenu.hide()
this.skillMenu.hide()
this.branchMenu.hide()
this.contextPanel.hide()
this.clearComposer()
+2 -1
View File
@@ -17,6 +17,7 @@ export interface FooterHintTheme {
export type FooterMode =
| "mention"
| "skill"
| "runtime"
| "active"
| "branch"
@@ -117,7 +118,7 @@ function hintsFor(
if (mode === "runtime") return width >= 64
? [hint("↑↓/click", "choose"), hint("enter", "apply"), hint("esc", "close")]
: [hint("enter", "apply"), hint("esc", "close")]
if (mode === "mention") return width >= 64
if (mode === "mention" || mode === "skill") return width >= 64
? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")]
: [hint("enter", "insert"), hint("esc", "close")]
if (mode === "active") return []
+48
View File
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"
import {
NanobotClient,
GatewayConnectionError,
fetchAvailableSkills,
fetchGatewayConnection,
fetchHistory,
fetchMentionCandidates,
@@ -805,6 +806,53 @@ describe("gateway protocol", () => {
}
})
test("loads only enabled and available skills", async () => {
const original = globalThis.fetch
let authorization = ""
globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => {
authorization = new Headers(init?.headers).get("Authorization") || ""
return Promise.resolve(new Response(JSON.stringify({
skills: [
{
name: "verify",
description: "Verify public behavior",
source: "builtin",
enabled: true,
available: true,
},
{
name: "disabled",
description: "Disabled skill",
source: "workspace",
enabled: false,
available: true,
},
{
name: "unavailable",
description: "Missing dependency",
source: "builtin",
enabled: true,
available: false,
},
{ name: "my skill", enabled: true, available: true },
{ name: "技能", enabled: true, available: true },
{ enabled: true, available: true },
],
})))
}) as typeof fetch
try {
expect(await fetchAvailableSkills("http://nanobot.test", "secret")).toEqual([{
name: "verify",
description: "Verify public behavior",
source: "builtin",
}])
expect(authorization).toBe("Bearer secret")
} finally {
globalThis.fetch = original
}
})
test("drops slash commands with unknown lifecycle metadata", async () => {
const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
+33
View File
@@ -262,6 +262,12 @@ export interface MentionCandidate {
session?: SessionMention
}
export interface SkillCandidate {
name: string
description: string
source: string
}
export interface MessageOptions {
cliApps?: Array<{ name: string }>
mcpPresets?: Array<{ name: string }>
@@ -299,6 +305,8 @@ export interface SessionSummary {
archived: boolean
}
const SKILL_REFERENCE_NAME = /^[A-Za-z0-9_-]+$/u
const SLASH_COMMAND_LIFECYCLES = new Set([
"side_channel",
"finalize_active_turn",
@@ -683,6 +691,31 @@ export async function fetchSlashCommands(
})
}
export async function fetchAvailableSkills(
apiUrl: string,
apiToken: string,
reauthenticate?: ApiReauthenticator,
): Promise<SkillCandidate[]> {
if (!apiUrl || !apiToken) return []
const response = await fetchApi(apiUrl, apiToken, "/api/webui/skills", reauthenticate)
if (!response.ok) throw new Error(`skill request failed: HTTP ${response.status}`)
const payload = await response.json() as { skills?: unknown[] }
return (payload.skills || []).flatMap((value) => {
if (
!isRecord(value)
|| typeof value.name !== "string"
|| !SKILL_REFERENCE_NAME.test(value.name)
|| value.enabled !== true
|| value.available !== true
) return []
return [{
name: value.name,
description: typeof value.description === "string" ? value.description : value.name,
source: typeof value.source === "string" ? value.source : "unknown",
}]
})
}
export async function fetchRuntimeControls(
apiUrl: string,
apiToken: string,
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { insertSkill, SkillMenu, skillQuery } from "./skill-menu"
import type { SkillCandidate } from "./protocol"
const skills: SkillCandidate[] = [
{ name: "simplify", description: "Simplify code", source: "workspace" },
{ name: "verify", description: "Verify public behavior", source: "builtin" },
]
describe("skill completion", () => {
let setup: TestRendererSetup | undefined
afterEach(() => {
if (setup && !setup.renderer.isDestroyed) setup.renderer.destroy()
setup = undefined
})
test("finds and replaces only the skill reference under the cursor", () => {
const value = "use $ver before release"
const query = skillQuery(value, 8)
expect(query).toEqual({ query: "ver", start: 4, end: 8 })
expect(insertSkill(value, skills[1]!, query!)).toEqual({
value: "use $verify before release",
cursor: 11,
})
})
test("matches backend token boundaries and replaces a whole token", () => {
expect(skillQuery("$", 1)).toEqual({ query: "", start: 0, end: 1 })
expect(skillQuery("run ($SIM", 9)).toEqual({ query: "sim", start: 5, end: 9 })
expect(skillQuery("price$ver", 9)).toBeNull()
expect(skillQuery("文$ver", 5)).toBeNull()
expect(skillQuery("$verify done", 12)).toBeNull()
const midToken = skillQuery("use $verify before release", 8)
expect(midToken).toEqual({ query: "ver", start: 4, end: 11 })
expect(insertSkill("use $verify before release", skills[1]!, midToken!).value)
.toBe("use $verify before release")
})
test("filters, navigates, and chooses available skills", async () => {
setup = await createTestRenderer({ width: 72, height: 16, screenMode: "alternate-screen" })
const menu = new SkillMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
})
setup.renderer.root.add(menu.root)
menu.show(skills, "", 6)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain(" $simplify")
expect(setup.captureCharFrame()).toContain("$verify")
expect(menu.move(1)).toBe(true)
expect(menu.choose()).toEqual(skills[1]!)
menu.update("simp", 6)
expect(menu.choose()).toEqual(skills[0]!)
menu.show([
{ name: "alpha", description: "Verify workflow", source: "workspace" },
skills[1]!,
], "verify", 6)
expect(menu.choose()).toEqual(skills[1]!)
menu.show([skills[0]!], "verify", 6)
expect(menu.choose()).toBeNull()
})
})
+86
View File
@@ -0,0 +1,86 @@
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
import type { SkillCandidate } from "./protocol"
export interface SkillQuery {
query: string
start: number
end: number
}
export function skillQuery(value: string, cursor: number): SkillQuery | null {
const cursorAt = Math.min(Math.max(cursor, 0), value.length)
const match = /(?:^|[^\p{L}\p{N}\p{M}\p{Pc}$])\$([A-Za-z0-9_-]*)$/u.exec(
value.slice(0, cursorAt),
)
if (!match) return null
const valueQuery = match[1] ?? ""
const start = cursorAt - valueQuery.length - 1
const remainder = /^[A-Za-z0-9_-]*/u.exec(value.slice(cursorAt))?.[0] || ""
return {
query: valueQuery.toLocaleLowerCase(),
start,
end: cursorAt + remainder.length,
}
}
export function insertSkill(
value: string,
candidate: SkillCandidate,
query: SkillQuery,
): { value: string; cursor: number } {
const suffix = value.slice(query.end)
const tail = /^\s/u.test(suffix) ? "" : " "
const inserted = `$${candidate.name}${tail}`
return {
value: `${value.slice(0, query.start)}${inserted}${suffix}`,
cursor: query.start + inserted.length,
}
}
export class SkillMenu {
readonly root: BoxRenderable
private readonly picker: PickerMenu<SkillCandidate>
private items: SkillCandidate[] = []
constructor(renderer: CliRenderer, theme: PickerMenuTheme) {
this.picker = new PickerMenu(renderer, theme, {
id: "nanobot-tui-skill-menu",
key: (item) => item.name,
searchText: (item) => `${item.name} ${item.description}`,
render: (item) => `$${item.name} ${item.description.replace(/\s+/gu, " ")}`,
emptyText: "No matching skills",
})
this.root = this.picker.root
}
get visible(): boolean { return this.picker.visible }
show(items: SkillCandidate[], query: string, limit: number): void {
this.items = items
this.picker.show(this.ranked(query), query, limit)
}
update(query: string, limit: number): void {
this.picker.show(this.ranked(query), query, limit)
}
move(direction: -1 | 1): boolean { return this.picker.move(direction) }
choose(): SkillCandidate | null { return this.picker.current() }
hide(): void { this.picker.hide() }
setTheme(theme: PickerMenuTheme): void { this.picker.setTheme(theme) }
private ranked(query: string): SkillCandidate[] {
const needle = query.toLocaleLowerCase()
if (!needle) return this.items
const score = (item: SkillCandidate): number => {
const name = item.name.toLocaleLowerCase()
if (name === needle) return 0
if (name.startsWith(needle)) return 1
if (name.includes(needle)) return 2
return 3
}
return this.items
.map((item, index) => ({ item, index, score: score(item) }))
.sort((left, right) => left.score - right.score || left.index - right.index)
.map(({ item }) => item)
}
}