mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
feat(tui): autocomplete skill references
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user