blob: a3f3a6153c9a0efd0b0578d81cb4460725381285 [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 { Editor, 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 a `/skill:` token mid-message with a slash-less 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',
'mid-message prefix must drop /skill: so select does not submit',
);
assert.deepEqual(
(result?.items ?? []).map((i) => i.value),
['weekly-report', 'web-search'],
);
});
test('applies a mid-message skill completion as `/skill:name ` (no submit)', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
await provider.getSuggestions(['see /skill:w'], 0, 12, { signal });
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('does NOT complete plain commands mid-message (only line start)', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
const result = await provider.getSuggestions(['see /co'], 0, 7, { signal });
assert.equal(
(result?.items ?? []).some((i) => commands.some((c) => c.name === i.value)),
false,
'plain commands must not complete mid-message',
);
});
test('line-start skill completion is unchanged (prefix keeps `/skill:`)', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
const result = await provider.getSuggestions(['/skill:w'], 0, 8, { signal });
assert.equal(result?.prefix, '/skill:w');
assert.deepEqual(
(result?.items ?? []).map((i) => i.value),
['weekly-report', 'web-search'],
);
});
test('line-start plain command completion is unchanged', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
const result = await provider.getSuggestions(['/co'], 0, 3, { signal });
assert.equal(result?.prefix, '/co');
assert.deepEqual(
(result?.items ?? []).map((i) => i.value),
['compact', 'config'],
);
});
test('mid-message skill completion is first-line only', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
const result = await provider.getSuggestions(['first', 'see /skill:w'], 1, 12, { signal });
assert.equal(
(result?.items ?? []).some((i) => skills.some((s) => s.id === i.value)),
false,
'a `/skill:` on a non-first line must not surface skill completion',
);
});
test('completes `/skill:xxx` from a bare mid-message `/` token', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
const result = await provider.getSuggestions(['see /'], 0, 5, { signal });
assert.equal(
result?.prefix,
'',
'bare / prefix is empty (slash-less) so select does not submit',
);
assert.deepEqual(
(result?.items ?? []).map((i) => i.value),
['skill:weekly-report', 'skill:web-search'],
);
});
test('filters bare mid-message `/` completions by the text after `/`', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
const result = await provider.getSuggestions(['see /w'], 0, 6, { signal });
assert.equal(result?.prefix, 'w');
assert.deepEqual(
(result?.items ?? []).map((i) => i.value),
['skill:weekly-report', 'skill:web-search'],
);
});
test('applies a bare mid-message `/` skill completion as `/skill:name `', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
await provider.getSuggestions(['see /'], 0, 5, { signal });
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 `/` with no skill match does not surface skills', async () => {
const provider = new MakaAutocompleteProvider(baseDir, commands, listSkills);
const result = await provider.getSuggestions(['see /zzz'], 0, 8, { signal });
assert.equal(
(result?.items ?? []).some((i) =>
skills.some((s) => s.id === i.value || `skill:${s.id}` === i.value),
),
false,
);
});
test('a bare mid-message `/` with no skill match does NOT fall through to file completion', async () => {
// Regression: falling through to the file provider returns a `/`-prefixed
// prefix, and pi-tui auto-submits on select when prefix starts with `/` -
// so selecting the file item would send the unfinished message. Mid-message
// `/`-path completion was not available before this PR either (pi-tui
// excludes `/` from triggerCharacters), so returning null restores prior
// behavior instead of introducing an auto-submit footgun.
const provider = new MakaAutocompleteProvider('/', commands, listSkills);
const result = await provider.getSuggestions(['see /U'], 0, 6, { signal });
assert.equal(
result,
null,
'must not fall through to file provider (would auto-submit on select)',
);
});
test('a bare mid-message `/` keeps the original (non-lowercased) prefix for apply', async () => {
// Regression: toLowerCase can change UTF-16 length (e.g. "İ" -> "i̇", len 1->2).
// Using the lowercased query as the replacement prefix makes applyCompletion
// slice by the wrong length, over-deleting the original text.
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, 'İ', 'prefix must be the original text, not its lowercased form');
assert.ok(result && result.items.length > 0, 'expected a skill match');
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('typing `/skill:` 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 /skill:w') 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('selecting a mid-message skill inserts `/skill:name ` and does not submit', 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());
editor.handleInput('\r');
assert.equal(submitted, undefined, 'mid-message skill select must not submit');
assert.deepEqual(editor.getLines(), ['see /skill:weekly-report ']);
});
test('typing a plain command mid-message does NOT surface plain commands', 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 /co') editor.handleInput(ch);
await new Promise((resolve) => setTimeout(resolve, 80));
const rendered = editor.render(80).join('\n');
// `/co` may trigger file completion (e.g. /cores), but plain slash commands
// must never appear mid-message - they only execute at line start.
assert.ok(!rendered.includes('/compact'), 'plain commands must not complete mid-message');
assert.ok(!rendered.includes('/config'));
assert.ok(!rendered.includes('/model'));
});
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 works under Kitty CSI-u keyboard protocol', async () => {
// Regression: a Kitty keyboard terminal encodes printable chars as CSI-u
// (starting with ESC). A naive `data.startsWith('\x1b')` guard skipped
// them, so the text was inserted but the completion menu never appeared.
setKittyProtocolActive(true);
try {
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 /skill:w') editor.handleInput(`\x1b[${ch.codePointAt(0)}u`);
await waitFor(() => editor.isShowingAutocomplete());
const rendered = editor.render(80).join('\n');
assert.ok(
rendered.includes('/skill:weekly-report'),
`expected skill completion under Kitty CSI-u:\n${rendered}`,
);
} finally {
setKittyProtocolActive(false);
}
});
test('mid-message trigger works under xterm modifyOtherKeys encoding', async () => {
// Regression: pi-tui's Editor decodes modifyOtherKeys printables
// (ESC[27;1;<cp>~), but the trigger guard only recognized Kitty CSI-u, so
// the menu never appeared even though the text was inserted.
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(`\x1b[27;1;${ch.codePointAt(0)}~`);
await waitFor(() => editor.isShowingAutocomplete());
const rendered = editor.render(80).join('\n');
assert.ok(
rendered.includes('/skill:weekly-report'),
`expected skill completion under modifyOtherKeys:\n${rendered}`,
);
});
});
describe('pi-tui Editor contract (mid-message trigger dependency)', () => {
test('tryTriggerAutocomplete is a runtime-callable prototype method', () => {
// MakaSkillHighlightEditor.handleInput calls this TS-private method; pi-tui
// ships plain JS (no #private fields), so it is reachable at runtime. Pin it
// so a pi-tui upgrade that renames or makes it truly private fails loudly
// instead of silently regressing mid-message skill completion.
assert.equal(
typeof (Editor.prototype as unknown as Record<string, unknown>).tryTriggerAutocomplete,
'function',
);
});
});