| import assert from 'node:assert/strict'; |
| import { before, describe, test } from 'node:test'; |
| import { TUI } from '@earendil-works/pi-tui'; |
| import type { InvocableSkillEntry } from '@maka/runtime'; |
| import { MakaAutocompleteProvider } from '../pi-tui-pickers.js'; |
| import { MakaSkillHighlightEditor } from '../skill-highlight-editor.js'; |
| import { |
| parseSkillInvocationTokens, |
| skillInvocationPrefixAt, |
| stripSkillInvocationTokens, |
| } from '../skill-token.js'; |
| import { _setColorLevelForTesting, editorTheme } from '../tui-ansi.js'; |
| import { FakeTerminal } from './tui-terminal-mock.js'; |
| |
| // Pin truecolor so accent escape assertions are hermetic (mirrors the runner tests). |
| before(() => _setColorLevelForTesting(3)); |
| |
| describe('skill invocation tokens', () => { |
| test('parses canonical tokens and rejects lookalikes', () => { |
| const tokens = parseSkillInvocationTokens( |
| '帮我 /skill:weekly-report 整理\n然后 /skill:Alpha 和 /skill:WEEKLY-REPORT 各来一遍', |
| ); |
| assert.deepEqual( |
| tokens.map((token) => token.name), |
| ['weekly-report', 'Alpha'], |
| ); |
| assert.equal(tokens[0].start, 3); |
| assert.equal(tokens[0].end, 3 + '/skill:weekly-report'.length); |
| for (const input of [ |
| 'cat docs/skill:alpha', |
| '/skill alpha', |
| '/skill:', |
| '看 https://example.com/skill:x', |
| ]) { |
| assert.deepEqual(parseSkillInvocationTokens(input), [], input); |
| } |
| assert.deepEqual( |
| parseSkillInvocationTokens('/skill:alpha,整理一下').map((token) => token.name), |
| ['alpha'], |
| ); |
| }); |
| |
| test('strips resolved tokens without disturbing unrelated text', () => { |
| const cases: Array<[string, string[], string]> = [ |
| [ |
| [ |
| '帮我 /skill:alpha 整理一下', |
| '```', |
| 'code with double spaces', |
| '```', |
| '/skill:beta', |
| '收尾 /skill:ALPHA', |
| ].join('\n'), |
| ['alpha', 'beta'], |
| ['帮我 整理一下', '```', 'code with double spaces', '```', '收尾'].join('\n'), |
| ], |
| ['用 /skill:nope 和 /skill:alpha 处理', ['alpha'], '用 /skill:nope 和 处理'], |
| ['/skill:alpha\n make target\n', ['alpha'], ' make target\n'], |
| ]; |
| |
| for (const [text, resolved, expected] of cases) { |
| assert.equal(stripSkillInvocationTokens(text, new Set(resolved)), expected, text); |
| } |
| }); |
| |
| test('detects the autocomplete prefix at the cursor', () => { |
| assert.deepEqual(skillInvocationPrefixAt(['帮我 /skill:we 整理'], 0, 12), { |
| prefix: '/skill:we', |
| query: 'we', |
| }); |
| assert.deepEqual(skillInvocationPrefixAt(['/skill:'], 0, 7), { prefix: '/skill:', query: '' }); |
| assert.equal(skillInvocationPrefixAt(['/skill:we 整理'], 0, 10), null, 'cursor past the token'); |
| assert.equal(skillInvocationPrefixAt(['/skill we'], 0, 8), null, 'no colon, no token'); |
| assert.equal( |
| skillInvocationPrefixAt(['docs/skill:we'], 0, 13), |
| null, |
| 'path-like prefix rejected', |
| ); |
| }); |
| }); |
| |
| describe('skill autocomplete', () => { |
| const skills: InvocableSkillEntry[] = [ |
| { |
| ref: 'workspace:legacy:weekly-report', |
| id: 'weekly-report', |
| name: '写周报', |
| description: '把进展整理成周报。', |
| }, |
| { ref: 'workspace:legacy:alpha', id: 'alpha', name: 'Alpha', description: 'First.' }, |
| { |
| ref: 'workspace:legacy:data-crunch', |
| id: 'data-crunch', |
| name: 'Alpha Stats', |
| description: 'Crunches.', |
| }, |
| ]; |
| const listSkills = async () => skills; |
| |
| test('suggests and applies skills ahead of command and file completion', async () => { |
| const provider = new MakaAutocompleteProvider( |
| '/repo', |
| [{ name: 'skill', description: 'Invoke a skill' }], |
| listSkills, |
| ); |
| const byId = await provider.getSuggestions(['/skill:we'], 0, 9, { |
| signal: new AbortController().signal, |
| }); |
| assert.deepEqual( |
| byId?.items.map((item) => item.value), |
| ['weekly-report'], |
| ); |
| assert.equal(byId?.prefix, '/skill:we'); |
| assert.equal(byId?.items[0]?.label, '/skill:weekly-report'); |
| assert.equal(byId?.items[0]?.description, '写周报 · 把进展整理成周报。'); |
| |
| // 'alpha' matches id `alpha` directly and `data-crunch` via its display name. |
| const byName = await provider.getSuggestions(['帮我 /skill:alpha 整理'], 0, 15, { |
| signal: new AbortController().signal, |
| }); |
| assert.deepEqual( |
| byName?.items.map((item) => item.value), |
| ['alpha', 'data-crunch'], |
| ); |
| |
| const noMatch = await provider.getSuggestions(['/skill:zzz'], 0, 10, { |
| signal: new AbortController().signal, |
| }); |
| assert.equal(noMatch, null); |
| const suggestions = await provider.getSuggestions(['/skill:dat'], 0, 10, { |
| signal: new AbortController().signal, |
| }); |
| assert.deepEqual( |
| suggestions?.items.map((item) => item.value), |
| ['data-crunch'], |
| ); |
| const applied = provider.applyCompletion( |
| ['帮我 /skill:we 一下'], |
| 0, |
| 12, |
| { value: 'weekly-report', label: '/skill:weekly-report' }, |
| '/skill:we', |
| ); |
| assert.deepEqual(applied.lines, ['帮我 /skill:weekly-report 一下']); |
| assert.equal(applied.lines[0].slice(0, applied.cursorCol), '帮我 /skill:weekly-report '); |
| assert.equal(provider.shouldTriggerFileCompletion(['/skill:we'], 0, 9), false); |
| }); |
| }); |
| |
| describe('skill token highlight', () => { |
| test('accents valid tokens only', () => { |
| const tui = new TUI(new FakeTerminal()); |
| const editor = new MakaSkillHighlightEditor(tui, editorTheme(), { paddingX: 1 }); |
| editor.setSkillTokenValidator((name) => name.toLowerCase() === 'alpha'); |
| editor.setText('帮我 /skill:alpha 和 /skill:nope 整理'); |
| const rendered = editor.render(80); |
| const line = rendered.find((candidate) => candidate.includes('/skill:alpha')); |
| assert.ok(line, 'token line renders'); |
| assert.ok( |
| line.includes('\x1b[38;2;87;163;239m/skill:alpha\x1b[39m'), |
| `valid token carries the brand accent: ${JSON.stringify(line)}`, |
| ); |
| assert.ok( |
| !line.includes('\x1b[38;2;87;163;239m/skill:nope\x1b[39m'), |
| 'invalid token stays plain', |
| ); |
| }); |
| }); |