mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +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 {
|
import type {
|
||||||
MessageOptions,
|
MessageOptions,
|
||||||
RecoveryState,
|
RecoveryState,
|
||||||
|
SkillCandidate,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
} from "./protocol"
|
} from "./protocol"
|
||||||
@@ -513,6 +514,69 @@ describe("NanobotTui layout", () => {
|
|||||||
expect(sent).toEqual([])
|
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 () => {
|
test("runs bang commands through the gateway without steering the agent", async () => {
|
||||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
const sent: string[] = []
|
const sent: string[] = []
|
||||||
|
|||||||
+131
-5
@@ -20,6 +20,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
NanobotClient,
|
NanobotClient,
|
||||||
|
fetchAvailableSkills,
|
||||||
fetchHistory,
|
fetchHistory,
|
||||||
fetchGatewayConnection,
|
fetchGatewayConnection,
|
||||||
fetchMentionCandidates,
|
fetchMentionCandidates,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
type MentionCandidate,
|
type MentionCandidate,
|
||||||
type MessageOptions,
|
type MessageOptions,
|
||||||
type RecoveryState,
|
type RecoveryState,
|
||||||
|
type SkillCandidate,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
type SessionSummary,
|
type SessionSummary,
|
||||||
type TokenUsage,
|
type TokenUsage,
|
||||||
@@ -69,6 +71,12 @@ import {
|
|||||||
mentionQuery,
|
mentionQuery,
|
||||||
type MentionQuery,
|
type MentionQuery,
|
||||||
} from "./mention-menu"
|
} from "./mention-menu"
|
||||||
|
import {
|
||||||
|
insertSkill,
|
||||||
|
SkillMenu,
|
||||||
|
skillQuery,
|
||||||
|
type SkillQuery,
|
||||||
|
} from "./skill-menu"
|
||||||
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
|
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
|
||||||
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
|
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
|
||||||
import { RecoveryNotice, type RecoveryNoticeTheme } from "./recovery-notice"
|
import { RecoveryNotice, type RecoveryNoticeTheme } from "./recovery-notice"
|
||||||
@@ -399,6 +407,7 @@ export class NanobotTui {
|
|||||||
private readonly commandMenu: CommandMenu
|
private readonly commandMenu: CommandMenu
|
||||||
private readonly sessionMenu: SessionMenu
|
private readonly sessionMenu: SessionMenu
|
||||||
private readonly mentionMenu: MentionMenu
|
private readonly mentionMenu: MentionMenu
|
||||||
|
private readonly skillMenu: SkillMenu
|
||||||
private readonly branchMenu: BranchMenu
|
private readonly branchMenu: BranchMenu
|
||||||
private readonly runtimeControls: RuntimeControls
|
private readonly runtimeControls: RuntimeControls
|
||||||
private readonly contextPanel: ContextPanel
|
private readonly contextPanel: ContextPanel
|
||||||
@@ -455,6 +464,8 @@ export class NanobotTui {
|
|||||||
private readyDetail = ""
|
private readyDetail = ""
|
||||||
private mentionCandidates: MentionCandidate[] = []
|
private mentionCandidates: MentionCandidate[] = []
|
||||||
private activeMentionQuery: MentionQuery | null = null
|
private activeMentionQuery: MentionQuery | null = null
|
||||||
|
private skillCandidates: SkillCandidate[] = []
|
||||||
|
private activeSkillQuery: SkillQuery | null = null
|
||||||
private transcriptNavigation: TranscriptNavigation = {
|
private transcriptNavigation: TranscriptNavigation = {
|
||||||
awayFromBottom: false,
|
awayFromBottom: false,
|
||||||
unseenOutput: false,
|
unseenOutput: false,
|
||||||
@@ -478,6 +489,7 @@ export class NanobotTui {
|
|||||||
private hostBranch: string
|
private hostBranch: string
|
||||||
private readonly apiReauthenticator: ApiReauthenticator | undefined
|
private readonly apiReauthenticator: ApiReauthenticator | undefined
|
||||||
private apiRefreshPromise: Promise<GatewayApiConnection> | null = null
|
private apiRefreshPromise: Promise<GatewayApiConnection> | null = null
|
||||||
|
private skillLoadId = 0
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
renderer: CliRenderer,
|
renderer: CliRenderer,
|
||||||
@@ -517,6 +529,7 @@ export class NanobotTui {
|
|||||||
(session) => this.switchSession(session),
|
(session) => this.switchSession(session),
|
||||||
)
|
)
|
||||||
this.mentionMenu = new MentionMenu(renderer, commandMenuTheme(this.palette))
|
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.branchMenu = new BranchMenu(renderer, commandMenuTheme(this.palette))
|
||||||
this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette))
|
this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette))
|
||||||
this.diffViewer = new DiffViewer(
|
this.diffViewer = new DiffViewer(
|
||||||
@@ -704,6 +717,9 @@ export class NanobotTui {
|
|||||||
{ name: "linefeed", action: "newline" },
|
{ name: "linefeed", action: "newline" },
|
||||||
{ name: "return", action: "submit" },
|
{ name: "return", action: "submit" },
|
||||||
],
|
],
|
||||||
|
onCursorChange: () => {
|
||||||
|
if (!this.sessionMenu.visible && !this.branchMenu.visible) this.syncComposerMenus()
|
||||||
|
},
|
||||||
onContentChange: () => {
|
onContentChange: () => {
|
||||||
this.draft.prune(this.composer.plainText)
|
this.draft.prune(this.composer.plainText)
|
||||||
this.runtimeControls.hide()
|
this.runtimeControls.hide()
|
||||||
@@ -756,6 +772,7 @@ export class NanobotTui {
|
|||||||
this.shell.add(this.commandMenu.root)
|
this.shell.add(this.commandMenu.root)
|
||||||
this.shell.add(this.sessionMenu.root)
|
this.shell.add(this.sessionMenu.root)
|
||||||
this.shell.add(this.mentionMenu.root)
|
this.shell.add(this.mentionMenu.root)
|
||||||
|
this.shell.add(this.skillMenu.root)
|
||||||
this.shell.add(this.branchMenu.root)
|
this.shell.add(this.branchMenu.root)
|
||||||
this.shell.add(this.contextPanel.root)
|
this.shell.add(this.contextPanel.root)
|
||||||
this.shell.add(this.runtimeControls.menuRoot)
|
this.shell.add(this.runtimeControls.menuRoot)
|
||||||
@@ -810,6 +827,7 @@ export class NanobotTui {
|
|||||||
this.client.connect()
|
this.client.connect()
|
||||||
void this.loadCommands()
|
void this.loadCommands()
|
||||||
void this.loadMentions()
|
void this.loadMentions()
|
||||||
|
void this.loadSkills()
|
||||||
this.runtimeControls.preload()
|
this.runtimeControls.preload()
|
||||||
this.renderer.start()
|
this.renderer.start()
|
||||||
// OpenTUI learns the real terminal background through OSC 10/11. Wait for
|
// 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)
|
if (candidate) this.chooseMention(candidate, this.activeMentionQuery)
|
||||||
return
|
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 (!visibleContent) return
|
||||||
if (["exit", "quit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
|
if (["exit", "quit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
|
||||||
this.quit()
|
this.quit()
|
||||||
@@ -926,6 +953,7 @@ export class NanobotTui {
|
|||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
|
this.skillMenu.hide()
|
||||||
this.recordPrompt(prompt.content)
|
this.recordPrompt(prompt.content)
|
||||||
this.transcript.user(prompt.content, turnId)
|
this.transcript.user(prompt.content, turnId)
|
||||||
this.hostBlocked = false
|
this.hostBlocked = false
|
||||||
@@ -1336,6 +1364,7 @@ export class NanobotTui {
|
|||||||
this.updateGatewayApiConnection(apiUrl, apiToken)
|
this.updateGatewayApiConnection(apiUrl, apiToken)
|
||||||
void this.loadCommands()
|
void this.loadCommands()
|
||||||
void this.loadMentions()
|
void this.loadMentions()
|
||||||
|
void this.loadSkills()
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshApiConnection(
|
private async refreshApiConnection(
|
||||||
@@ -1477,6 +1506,7 @@ export class NanobotTui {
|
|||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
|
this.skillMenu.hide()
|
||||||
this.recordPrompt(content)
|
this.recordPrompt(content)
|
||||||
this.syncQueuePreview()
|
this.syncQueuePreview()
|
||||||
this.renderActiveStatus()
|
this.renderActiveStatus()
|
||||||
@@ -1581,6 +1611,31 @@ export class NanobotTui {
|
|||||||
return
|
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 (this.commandMenu.visible) {
|
||||||
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
|
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
|
||||||
this.commandMenu.move(key.name === "up" ? -1 : 1)
|
this.commandMenu.move(key.name === "up" ? -1 : 1)
|
||||||
@@ -1720,6 +1775,7 @@ export class NanobotTui {
|
|||||||
this.commandMenu.setTheme(commandMenuTheme(this.palette))
|
this.commandMenu.setTheme(commandMenuTheme(this.palette))
|
||||||
this.sessionMenu.setTheme(commandMenuTheme(this.palette))
|
this.sessionMenu.setTheme(commandMenuTheme(this.palette))
|
||||||
this.mentionMenu.setTheme(commandMenuTheme(this.palette))
|
this.mentionMenu.setTheme(commandMenuTheme(this.palette))
|
||||||
|
this.skillMenu.setTheme(commandMenuTheme(this.palette))
|
||||||
this.branchMenu.setTheme(commandMenuTheme(this.palette))
|
this.branchMenu.setTheme(commandMenuTheme(this.palette))
|
||||||
this.runtimeControls.setTheme(runtimeControlsTheme(this.palette))
|
this.runtimeControls.setTheme(runtimeControlsTheme(this.palette))
|
||||||
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
||||||
@@ -1749,6 +1805,7 @@ export class NanobotTui {
|
|||||||
private updateMeta(): void {
|
private updateMeta(): void {
|
||||||
const mode: FooterMode = this.runtimeControls.visible ? "runtime"
|
const mode: FooterMode = this.runtimeControls.visible ? "runtime"
|
||||||
: this.mentionMenu.visible ? "mention"
|
: this.mentionMenu.visible ? "mention"
|
||||||
|
: this.skillMenu.visible ? "skill"
|
||||||
: this.activeTurn ? "active"
|
: this.activeTurn ? "active"
|
||||||
: this.branchMenu.visible ? "branch"
|
: this.branchMenu.visible ? "branch"
|
||||||
: this.commandMenu.visible ? "command"
|
: this.commandMenu.visible ? "command"
|
||||||
@@ -1932,17 +1989,30 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private syncComposerMenus(): void {
|
private syncComposerMenus(): void {
|
||||||
this.activeMentionQuery = mentionQuery(this.composer.plainText, this.composer.cursorOffset)
|
const value = this.composer.plainText
|
||||||
const candidates = this.availableMentions()
|
const cursor = this.composerStringCursor()
|
||||||
if (this.activeMentionQuery && candidates.length) {
|
this.activeMentionQuery = mentionQuery(value, cursor)
|
||||||
|
this.activeSkillQuery = skillQuery(value, cursor)
|
||||||
|
const mentionCandidates = this.availableMentions()
|
||||||
|
if (this.activeMentionQuery && mentionCandidates.length) {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
|
this.skillMenu.hide()
|
||||||
const limit = this.renderer.height >= 20 ? 7 : 4
|
const limit = this.renderer.height >= 20 ? 7 : 4
|
||||||
if (this.mentionMenu.visible) this.mentionMenu.update(this.activeMentionQuery.query, limit)
|
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()
|
this.updateMeta()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.mentionMenu.hide()
|
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()
|
this.syncCommandMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1961,13 +2031,44 @@ export class NanobotTui {
|
|||||||
private chooseMention(candidate: MentionCandidate, query: MentionQuery): void {
|
private chooseMention(candidate: MentionCandidate, query: MentionQuery): void {
|
||||||
const inserted = insertMention(this.composer.plainText, candidate, query)
|
const inserted = insertMention(this.composer.plainText, candidate, query)
|
||||||
this.composer.setText(inserted.value)
|
this.composer.setText(inserted.value)
|
||||||
this.composer.cursorOffset = inserted.cursor
|
this.setComposerStringCursor(inserted.value, inserted.cursor)
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.activeMentionQuery = null
|
this.activeMentionQuery = null
|
||||||
this.syncComposerPlaceholder()
|
this.syncComposerPlaceholder()
|
||||||
this.updateMeta()
|
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 {
|
private setComposer(content: string): void {
|
||||||
this.draft.clear()
|
this.draft.clear()
|
||||||
this.composer.setText(content)
|
this.composer.setText(content)
|
||||||
@@ -2010,9 +2111,11 @@ export class NanobotTui {
|
|||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.hideSessionMenu()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
|
this.skillMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.activeMentionQuery = null
|
this.activeMentionQuery = null
|
||||||
|
this.activeSkillQuery = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private dismissRuntimeControls(): void {
|
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[] {
|
private availableMentions(): MentionCandidate[] {
|
||||||
const currentKey = this.client.activeChatId
|
const currentKey = this.client.activeChatId
|
||||||
? `websocket:${this.client.activeChatId}`
|
? `websocket:${this.client.activeChatId}`
|
||||||
@@ -2127,6 +2249,7 @@ export class NanobotTui {
|
|||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.dismissRuntimeControls()
|
this.dismissRuntimeControls()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
|
this.skillMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
@@ -2221,6 +2344,7 @@ export class NanobotTui {
|
|||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.hideSessionMenu()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
|
this.skillMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
@@ -2387,6 +2511,7 @@ export class NanobotTui {
|
|||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.hideSessionMenu()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
|
this.skillMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.status.content = "Reading agent context…"
|
this.status.content = "Reading agent context…"
|
||||||
@@ -2459,6 +2584,7 @@ export class NanobotTui {
|
|||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.hideSessionMenu()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
|
this.skillMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface FooterHintTheme {
|
|||||||
|
|
||||||
export type FooterMode =
|
export type FooterMode =
|
||||||
| "mention"
|
| "mention"
|
||||||
|
| "skill"
|
||||||
| "runtime"
|
| "runtime"
|
||||||
| "active"
|
| "active"
|
||||||
| "branch"
|
| "branch"
|
||||||
@@ -117,7 +118,7 @@ function hintsFor(
|
|||||||
if (mode === "runtime") return width >= 64
|
if (mode === "runtime") return width >= 64
|
||||||
? [hint("↑↓/click", "choose"), hint("enter", "apply"), hint("esc", "close")]
|
? [hint("↑↓/click", "choose"), hint("enter", "apply"), hint("esc", "close")]
|
||||||
: [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("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")]
|
||||||
: [hint("enter", "insert"), hint("esc", "close")]
|
: [hint("enter", "insert"), hint("esc", "close")]
|
||||||
if (mode === "active") return []
|
if (mode === "active") return []
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import {
|
import {
|
||||||
NanobotClient,
|
NanobotClient,
|
||||||
GatewayConnectionError,
|
GatewayConnectionError,
|
||||||
|
fetchAvailableSkills,
|
||||||
fetchGatewayConnection,
|
fetchGatewayConnection,
|
||||||
fetchHistory,
|
fetchHistory,
|
||||||
fetchMentionCandidates,
|
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 () => {
|
test("drops slash commands with unknown lifecycle metadata", async () => {
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
|
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
|
||||||
|
|||||||
@@ -262,6 +262,12 @@ export interface MentionCandidate {
|
|||||||
session?: SessionMention
|
session?: SessionMention
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillCandidate {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageOptions {
|
export interface MessageOptions {
|
||||||
cliApps?: Array<{ name: string }>
|
cliApps?: Array<{ name: string }>
|
||||||
mcpPresets?: Array<{ name: string }>
|
mcpPresets?: Array<{ name: string }>
|
||||||
@@ -299,6 +305,8 @@ export interface SessionSummary {
|
|||||||
archived: boolean
|
archived: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SKILL_REFERENCE_NAME = /^[A-Za-z0-9_-]+$/u
|
||||||
|
|
||||||
const SLASH_COMMAND_LIFECYCLES = new Set([
|
const SLASH_COMMAND_LIFECYCLES = new Set([
|
||||||
"side_channel",
|
"side_channel",
|
||||||
"finalize_active_turn",
|
"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(
|
export async function fetchRuntimeControls(
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
apiToken: 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