blob: f9d45af5e4ab1c98f79ee3eb188fd42571b55836 [file]
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { before, describe, test } from 'node:test';
import { setKittyProtocolActive, TUI } from '@earendil-works/pi-tui';
import type { InvocableSkillEntry } from '@maka/runtime';
import { DirectoryAutocompleteProvider, MakaAutocompleteProvider } from '../pi-tui-pickers.js';
import { MakaSkillHighlightEditor } from '../skill-highlight-editor.js';
import { editorTheme } from '../tui-ansi.js';
import { FakeTerminal, waitFor } from './tui-terminal-mock.js';
// Mid-message slash completion (issue #1100). Only `/skill:<name>` has semantic
// value mid-message: it is a parseable invocation token, whereas `/compact`,
// `/model`, etc. only execute at line start (`handleSlashCommand` checks
// `parts[0]`). So mid-message completion is skill-only; plain commands stay
// line-start-only.
//
// No-auto-submit half: for a mid-message `/skill:` token the provider returns a
// prefix WITHOUT the `/skill:` head (just the query, e.g. `w`), so pi-tui's
// select-confirm guard (submit only when `autocompletePrefix` starts with `/`)
// does not fire. Line-start keeps `/skill:query` so select still submits (the
// existing "select to invoke" UX).
describe('MakaAutocompleteProvider mid-message skill completion', () => {
const commands = [
{ name: 'compact', description: 'compact the transcript' },
{ name: 'config', description: 'open config' },
{ name: 'model', description: 'switch model' },
];
const skills: InvocableSkillEntry[] = [
{
ref: 'workspace:legacy:weekly-report',
id: 'weekly-report',
name: 'Weekly Report',
description: 'summarize the week',
},
{
ref: 'workspace:legacy:web-search',
id: 'web-search',
name: 'Web Search',
description: 'search the web',
},
];
const listSkills = async (): Promise<readonly InvocableSkillEntry[]> => skills;
const signal = new AbortController().signal;
let baseDir: string;
before(() => {
baseDir = mkdtempSync(join(tmpdir(), 'maka-skill-'));
});
test('completes and applies a mid-message `/skill:` token without a submit prefix', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
const result = await provider.getSuggestions(['see /skill:w'], 0, 12, { signal });
assert.equal(result?.prefix, 'w');
assert.deepEqual(
(result?.items ?? []).map((i) => i.value),
['weekly-report', 'web-search'],
);
const applied = provider.applyCompletion(
['see /skill:w'],
0,
12,
{ value: 'weekly-report', label: '/skill:weekly-report' },
'w',
);
assert.deepEqual(applied.lines, ['see /skill:weekly-report ']);
assert.equal(applied.cursorCol, 'see /skill:weekly-report '.length);
});
test('keeps plain commands and non-first-line skills out of mid-message completion', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
const plain = await provider.getSuggestions(['see /co'], 0, 7, { signal });
assert.equal(
(plain?.items ?? []).some((item) => commands.some((command) => command.name === item.value)),
false,
);
const secondLine = await provider.getSuggestions(['first', 'see /skill:w'], 1, 12, {
signal,
});
assert.equal(
(secondLine?.items ?? []).some((item) => skills.some((skill) => skill.id === item.value)),
false,
);
});
test('filters and applies skill completion from a bare mid-message slash', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
for (const [line, column, prefix] of [
['see /', 5, ''],
['see /w', 6, 'w'],
] as const) {
const result = await provider.getSuggestions([line], 0, column, { signal });
assert.equal(result?.prefix, prefix, line);
assert.deepEqual(
(result?.items ?? []).map((item) => item.value),
['skill:weekly-report', 'skill:web-search'],
line,
);
}
const applied = provider.applyCompletion(
['see /'],
0,
5,
{ value: 'skill:weekly-report', label: '/skill:weekly-report' },
'',
);
assert.deepEqual(applied.lines, ['see /skill:weekly-report ']);
assert.equal(applied.cursorCol, 'see /skill:weekly-report '.length);
});
test('a bare mid-message slash with no skill match never falls through to files', async () => {
const provider = new MakaAutocompleteProvider('/', commands, listSkills);
assert.equal(await provider.getSuggestions(['see /zzz'], 0, 8, { signal }), null);
assert.equal(await provider.getSuggestions(['see /U'], 0, 6, { signal }), null);
});
test('keeps the original Unicode prefix length when applying a completion', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, async () => [
{ ref: 'workspace:legacy:info', id: 'info', name: 'İnfo', description: '' },
]);
const result = await provider.getSuggestions(['see /İ'], 0, 6, { signal });
assert.equal(result?.prefix, 'İ');
assert.ok(result && result.items.length > 0);
const applied = provider.applyCompletion(['see /İ'], 0, 6, result.items[0], result.prefix);
assert.deepEqual(applied.lines, ['see /skill:info ']);
});
});
describe('DirectoryAutocompleteProvider', () => {
test('reuses path completion while filtering out files', async () => {
const baseDir = mkdtempSync(join(tmpdir(), 'maka-move-picker-'));
mkdirSync(join(baseDir, 'worktree-next'));
writeFileSync(join(baseDir, 'notes.txt'), 'notes');
try {
const provider = new DirectoryAutocompleteProvider(baseDir);
const result = await provider.getSuggestions([''], 0, 0, {
signal: new AbortController().signal,
force: true,
});
assert.deepEqual(
result?.items.map((item) => item.label),
['worktree-next/'],
);
} finally {
// The test directory is intentionally tiny; remove it synchronously so
// the provider test does not need a second async lifecycle hook.
rmSync(baseDir, { recursive: true, force: true });
}
});
test('applies the first absolute path segment without adding a second slash', () => {
const provider = new DirectoryAutocompleteProvider('/');
const applied = provider.applyCompletion(
['/U'],
0,
2,
{ value: 'Users/', label: 'Users/' },
'/U',
);
assert.deepEqual(applied.lines, ['/Users/ ']);
assert.equal(applied.cursorCol, '/Users/ '.length);
});
});
describe('MakaSkillHighlightEditor mid-message skill trigger', () => {
const commands = [
{ name: 'compact', description: 'compact the transcript' },
{ name: 'config', description: 'open config' },
];
const skills: InvocableSkillEntry[] = [
{
ref: 'workspace:legacy:weekly-report',
id: 'weekly-report',
name: 'Weekly Report',
description: 'summarize the week',
},
{
ref: 'workspace:legacy:web-search',
id: 'web-search',
name: 'Web Search',
description: 'search the web',
},
];
const listSkills = async (): Promise<readonly InvocableSkillEntry[]> => skills;
test('selecting a mid-message skill completes without submitting', async () => {
const tui = new TUI(new FakeTerminal());
const editor = new MakaSkillHighlightEditor(tui, editorTheme(), { paddingX: 1 });
editor.setAutocompleteProvider(new MakaAutocompleteProvider(tmpdir(), commands, listSkills));
let submitted: string | undefined;
editor.onSubmit = (prompt: string) => {
submitted = prompt;
};
for (const ch of 'see /skill:w') editor.handleInput(ch);
await waitFor(() => editor.isShowingAutocomplete());
const rendered = editor.render(80).join('\n');
assert.match(rendered, /\/skill:weekly-report/);
assert.match(rendered, /\/skill:web-search/);
editor.handleInput('\r');
assert.equal(submitted, undefined);
assert.deepEqual(editor.getLines(), ['see /skill:weekly-report ']);
});
test('typing a bare `/` mid-message triggers skill autocomplete', async () => {
const tui = new TUI(new FakeTerminal());
const editor = new MakaSkillHighlightEditor(tui, editorTheme(), { paddingX: 1 });
editor.setAutocompleteProvider(new MakaAutocompleteProvider(tmpdir(), commands, listSkills));
for (const ch of 'see /') editor.handleInput(ch);
await waitFor(() => editor.isShowingAutocomplete());
const rendered = editor.render(80).join('\n');
assert.ok(
rendered.includes('/skill:weekly-report'),
`expected /skill:weekly-report in:\n${rendered}`,
);
assert.ok(
rendered.includes('/skill:web-search'),
`expected /skill:web-search in:\n${rendered}`,
);
});
test('mid-message trigger supports Kitty and xterm encoded printable input', async () => {
const protocols = [
{
kitty: true,
text: 'see /skill:w',
encode: (codePoint: number) => `\x1b[${codePoint}u`,
},
{
kitty: false,
text: 'see /',
encode: (codePoint: number) => `\x1b[27;1;${codePoint}~`,
},
];
for (const protocol of protocols) {
setKittyProtocolActive(protocol.kitty);
try {
const tui = new TUI(new FakeTerminal());
const editor = new MakaSkillHighlightEditor(tui, editorTheme(), { paddingX: 1 });
editor.setAutocompleteProvider(
new MakaAutocompleteProvider(tmpdir(), commands, listSkills),
);
for (const character of protocol.text) {
editor.handleInput(protocol.encode(character.codePointAt(0) ?? 0));
}
await waitFor(() => editor.isShowingAutocomplete());
assert.match(editor.render(80).join('\n'), /\/skill:weekly-report/);
} finally {
setKittyProtocolActive(false);
}
}
});
});