From b218d3e7f83a3a3a9dda7c59243e95acd2eb3f44 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 25 Aug 2026 19:15:59 +0800 Subject: [PATCH] feat(tui): autocomplete skill references --- tui/src/app.test.ts | 64 +++++++++++++++++ tui/src/app.ts | 136 +++++++++++++++++++++++++++++++++++-- tui/src/footer-hints.ts | 3 +- tui/src/protocol.test.ts | 48 +++++++++++++ tui/src/protocol.ts | 33 +++++++++ tui/src/skill-menu.test.ts | 72 ++++++++++++++++++++ tui/src/skill-menu.ts | 86 +++++++++++++++++++++++ 7 files changed, 436 insertions(+), 6 deletions(-) create mode 100644 tui/src/skill-menu.test.ts create mode 100644 tui/src/skill-menu.ts diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 7821d9772..58935f1fc 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -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[] = [] diff --git a/tui/src/app.ts b/tui/src/app.ts index dcb530a69..20fc8d19b 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -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 | 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 { + 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() diff --git a/tui/src/footer-hints.ts b/tui/src/footer-hints.ts index 3629526b4..e73aad1c3 100644 --- a/tui/src/footer-hints.ts +++ b/tui/src/footer-hints.ts @@ -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 [] diff --git a/tui/src/protocol.test.ts b/tui/src/protocol.test.ts index 34bbd9472..edb5c4e50 100644 --- a/tui/src/protocol.test.ts +++ b/tui/src/protocol.test.ts @@ -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({ diff --git a/tui/src/protocol.ts b/tui/src/protocol.ts index 692ee2b3c..de5e6c0b1 100644 --- a/tui/src/protocol.ts +++ b/tui/src/protocol.ts @@ -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 { + 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, diff --git a/tui/src/skill-menu.test.ts b/tui/src/skill-menu.test.ts new file mode 100644 index 000000000..7d35f1ca9 --- /dev/null +++ b/tui/src/skill-menu.test.ts @@ -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() + }) +}) diff --git a/tui/src/skill-menu.ts b/tui/src/skill-menu.ts new file mode 100644 index 000000000..16f2f0216 --- /dev/null +++ b/tui/src/skill-menu.ts @@ -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 + 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) + } +}