Skip to content

Commit 282bc59

Browse files
committed
fix(tui): improve mid-prompt slash autocomplete
1 parent f9646ff commit 282bc59

11 files changed

Lines changed: 596 additions & 202 deletions

File tree

apps/pythinker-code/src/generated/dashboard-web-asset.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/pythinker-code/src/tui/commands/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export { handlePluginsCommand } from './plugins';
4545
export { handleReloadCommand, handleReloadTuiCommand } from './reload';
4646
export { handleGoalCommand, parseGoalCommand } from './goal';
4747
export { handleMemoryCommand, showMemoryPicker } from './memory';
48-
export { goalArgumentCompletions } from './registry';
48+
export { goalArgumentCompletions, pluginsArgumentCompletions } from './registry';
4949
export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session';
5050
export { handleTagCommand } from './tag';
5151
export { handleUndoCommand } from './undo';

apps/pythinker-code/src/tui/commands/registry.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@ const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
2222
{ value: 'off', description: 'Turn swarm mode off' },
2323
];
2424

25+
const PLUGIN_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
26+
{ value: 'list', description: 'List installed plugins' },
27+
{ value: 'install', description: 'Install a plugin from a path or ZIP URL' },
28+
{ value: 'marketplace', description: 'Browse the plugin marketplace' },
29+
{ value: 'info', description: 'Show details for one plugin' },
30+
{ value: 'enable', description: 'Enable a plugin' },
31+
{ value: 'disable', description: 'Disable a plugin' },
32+
{ value: 'remove', description: 'Remove a plugin from the session' },
33+
{ value: 'reload', description: 'Reload plugins in the current session' },
34+
{ value: 'mcp', description: 'Manage plugin-declared MCP servers' },
35+
];
36+
37+
const PLUGIN_MCP_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
38+
{ value: 'enable', description: 'Enable one plugin MCP server' },
39+
{ value: 'disable', description: 'Disable one plugin MCP server' },
40+
];
41+
2542
/** Argument autocompletion for the `/goal` command (subcommands). */
2643
export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
2744
const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i);
@@ -41,6 +58,20 @@ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteIt
4158
return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix);
4259
}
4360

61+
/** Argument autocompletion for the `/plugins` command (subcommands). */
62+
export function pluginsArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
63+
const mcpMatch = argumentPrefix.match(/^mcp\s+(\S*)$/i);
64+
if (mcpMatch !== null) {
65+
return (
66+
completeLeadingArg(PLUGIN_MCP_ARG_COMPLETIONS, mcpMatch[1] ?? '')?.map((item) => ({
67+
...item,
68+
value: `mcp ${item.value}`,
69+
})) ?? null
70+
);
71+
}
72+
return completeLeadingArg(PLUGIN_ARG_COMPLETIONS, argumentPrefix);
73+
}
74+
4475
export const BUILTIN_SLASH_COMMANDS = [
4576
{
4677
name: 'yolo',
@@ -200,6 +231,7 @@ export const BUILTIN_SLASH_COMMANDS = [
200231
aliases: ['plugin'],
201232
description: 'Manage plugins',
202233
priority: 60,
234+
completeArgs: pluginsArgumentCompletions,
203235
availability: 'always',
204236
},
205237
{

apps/pythinker-code/src/tui/components/editor/custom-editor.ts

Lines changed: 106 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
matchesKey,
99
Key,
1010
SelectList,
11+
truncateToWidth,
12+
visibleWidth,
1113
type SelectItem,
1214
type TUI,
1315
} from '@earendil-works/pi-tui';
@@ -28,6 +30,7 @@ import {
2830
import { isPrintableChar, printableChar } from '#/tui/utils/printable-key';
2931
import { effortColorToken, shortEffortLabel } from '#/tui/utils/thinking-levels';
3032

33+
import { findSlashAutocompleteContext, getSlashHighlightRanges } from './slash-autocomplete-context';
3134
import { WrappingSelectList } from './wrapping-select-list';
3235

3336
// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences
@@ -64,8 +67,11 @@ const SHIFT_BIT = 1;
6467

6568
interface AutocompleteInternals {
6669
cancelAutocomplete(): void;
70+
requestAutocomplete?(options: { force: boolean; explicitTab: boolean }): void;
6771
readonly autocompleteAbort?: AbortController;
6872
readonly autocompleteDebounceTimer?: ReturnType<typeof setTimeout>;
73+
readonly autocompletePrefix?: string;
74+
readonly autocompleteList?: { getSelectedItem(): SelectItem | undefined };
6975
}
7076

7177
interface AutocompleteListFactoryInternals {
@@ -136,6 +142,62 @@ function stripSgr(s: string): string {
136142
return s.replace(ANSI_SGR, '');
137143
}
138144

145+
function findCursorMarkerRange(
146+
line: string,
147+
): { rawStart: number; rawEnd: number; visibleStart: number; currentChar: string | undefined } | null {
148+
const rawStart = line.indexOf('\u001B[7m');
149+
if (rawStart < 0) return null;
150+
const rawEnd = line.indexOf('\u001B[0m', rawStart);
151+
if (rawEnd < 0) return null;
152+
const visibleStart = stripSgr(line.slice(0, rawStart)).length;
153+
const visible = stripSgr(line);
154+
return {
155+
rawStart,
156+
rawEnd: rawEnd + '\u001B[0m'.length,
157+
visibleStart,
158+
currentChar: visible[visibleStart],
159+
};
160+
}
161+
162+
function buildAutocompleteGhostSuffix(prefix: string, item: SelectItem): string | null {
163+
if (prefix.startsWith('/')) {
164+
const typed = prefix.slice(1);
165+
if (!item.value.startsWith(typed)) return null;
166+
return `${item.value.slice(typed.length)} `;
167+
}
168+
if (!item.value.startsWith(prefix)) return null;
169+
return item.value.slice(prefix.length);
170+
}
171+
172+
function insertAutocompleteGhost(line: string, ghostText: string): string | undefined {
173+
if (ghostText.length === 0) return undefined;
174+
const cursor = findCursorMarkerRange(line);
175+
if (cursor === null) return undefined;
176+
if (cursor.currentChar !== undefined && cursor.currentChar !== ' ' && cursor.currentChar !== '\t') {
177+
return undefined;
178+
}
179+
180+
const visible = stripSgr(line);
181+
const insertStartVisible = cursor.visibleStart + 1;
182+
let insertEndVisible = insertStartVisible;
183+
while (insertEndVisible < visible.length) {
184+
const ch = visible[insertEndVisible];
185+
if (ch !== ' ' && ch !== '\t') break;
186+
insertEndVisible += 1;
187+
}
188+
189+
const availableWidth = insertEndVisible - insertStartVisible;
190+
if (availableWidth <= 0) return undefined;
191+
const ghostPlain = truncateToWidth(ghostText, availableWidth, '');
192+
const ghostWidth = visibleWidth(ghostPlain);
193+
if (ghostWidth <= 0) return undefined;
194+
195+
const rawStart = mapVisibleIdxToRaw(line, insertStartVisible);
196+
const rawEnd = mapVisibleIdxToRaw(line, insertStartVisible + ghostWidth);
197+
const ghost = currentTheme.fg('textMuted', ghostPlain);
198+
return line.slice(0, rawStart) + ghost + line.slice(rawEnd);
199+
}
200+
139201
export class CustomEditor extends Editor {
140202
public onEscape?: () => void;
141203
public onCtrlD?: () => void;
@@ -258,29 +320,49 @@ export class CustomEditor extends Editor {
258320
(this as unknown as AutocompleteInternals).cancelAutocomplete();
259321
}
260322

323+
private hasMidPromptSlashContext(): boolean {
324+
const { line, col } = this.getCursor();
325+
const currentLine = this.getLines()[line] ?? '';
326+
const context = findSlashAutocompleteContext(currentLine, col);
327+
return context !== null && currentLine.slice(0, context.commandStart).trim().length > 0;
328+
}
329+
330+
private requestMidPromptSlashAutocomplete(explicitTab: boolean): boolean {
331+
if (!this.hasMidPromptSlashContext()) return false;
332+
const autocomplete = this as unknown as AutocompleteInternals;
333+
autocomplete.requestAutocomplete?.({ force: false, explicitTab });
334+
return autocomplete.requestAutocomplete !== undefined;
335+
}
336+
261337
override render(width: number): string[] {
262338
const lines = super.render(width);
263339
if (lines.length < 3) return lines;
264340
const firstContentIdx = 1;
265-
const text = this.getText().trimStart();
266-
if (text.startsWith('/')) {
267-
// Paint only the FIRST editor content line; multi-line slash commands
268-
// are not a thing in practice.
269-
const original = lines[firstContentIdx];
270-
if (original !== undefined) {
271-
const highlighted = highlightFirstSlashToken(original, 'primary');
272-
if (highlighted !== undefined) {
273-
lines[firstContentIdx] = highlighted;
274-
}
275-
}
276-
}
277341
const firstContent = lines[firstContentIdx];
278342
if (firstContent !== undefined) {
279343
const withPrompt = injectPromptSymbol(firstContent);
280344
if (withPrompt !== undefined) {
281345
lines[firstContentIdx] = withPrompt;
282346
}
283347
}
348+
349+
const cursorLineIdx = lines.findIndex((line) => line.includes('\u001B[7m'));
350+
if (cursorLineIdx >= 0) {
351+
const cursorLine = lines[cursorLineIdx];
352+
if (cursorLine !== undefined) {
353+
const highlighted = highlightFirstSlashToken(cursorLine, 'primary');
354+
const decoratedLine = highlighted ?? cursorLine;
355+
const autocomplete = this as unknown as AutocompleteInternals;
356+
const selectedItem = autocomplete.autocompleteList?.getSelectedItem();
357+
const prefix = autocomplete.autocompletePrefix ?? '';
358+
const ghostSuffix =
359+
selectedItem === undefined ? null : buildAutocompleteGhostSuffix(prefix, selectedItem);
360+
const withGhost =
361+
ghostSuffix === null ? undefined : insertAutocompleteGhost(decoratedLine, ghostSuffix);
362+
lines[cursorLineIdx] = withGhost ?? decoratedLine;
363+
}
364+
}
365+
284366
// `this.borderColor` is pi-tui's per-render paint function. The host may
285367
// overwrite it (e.g. plan-mode / slash-context highlight via
286368
// `editor.borderColor = chalk.hex(primary)`), so we route corners and
@@ -342,6 +424,7 @@ export class CustomEditor extends Editor {
342424
return;
343425
}
344426
super.handleInput(data);
427+
if (!this.hasAutocompleteActivity()) this.requestMidPromptSlashAutocomplete(false);
345428
return;
346429
}
347430

@@ -431,7 +514,12 @@ export class CustomEditor extends Editor {
431514
return;
432515
}
433516

517+
if (matchesKey(normalized, Key.tab) && this.requestMidPromptSlashAutocomplete(true)) {
518+
return;
519+
}
520+
434521
super.handleInput(normalized);
522+
if (!this.hasAutocompleteActivity()) this.requestMidPromptSlashAutocomplete(false);
435523
}
436524

437525
private handlePasteKeybinding(data: string): void {
@@ -456,69 +544,17 @@ export class CustomEditor extends Editor {
456544
}
457545

458546
/**
459-
* Return a copy of `line` with the first `/token` coloured using `hex`.
460-
* For `/goal next manage`, also colour the command-path tokens.
461-
* `line` may already contain SGR escapes (cursor inverse, etc.); we
462-
* locate `/` via visible-index math so ANSI pass-through survives.
463-
* Returns `undefined` if no token is found.
547+
* Return a copy of `line` with the active slash-command token coloured using
548+
* the current theme, even when the command lives mid-prompt.
464549
*/
465550
export function highlightFirstSlashToken(line: string, token: 'primary'): string | undefined {
466-
const visible = stripSgr(line);
467-
const slashIdx = visible.indexOf('/');
468-
if (slashIdx < 0) return undefined;
469-
// Guard: only paint when `/` is the first non-whitespace character
470-
// on the line (avoids colouring a mid-sentence slash).
471-
for (let i = 0; i < slashIdx; i++) {
472-
if (visible[i] !== ' ' && visible[i] !== '\t') return undefined;
473-
}
474-
// Token ends at the next whitespace (or the visible end).
475-
let endVisible = slashIdx + 1;
476-
while (endVisible < visible.length) {
477-
const ch = visible[endVisible];
478-
if (ch === ' ' || ch === '\t') break;
479-
endVisible++;
480-
}
481-
const visibleToken = visible.slice(slashIdx, endVisible);
482-
if (visibleToken.slice(1).includes('/')) return undefined;
483-
const ranges = [{ start: slashIdx, end: endVisible }];
484-
if (visibleToken === '/goal') {
485-
ranges.push(...goalCommandPathRanges(visible, endVisible));
486-
}
551+
const cursor = findCursorMarkerRange(line);
552+
if (cursor === null) return undefined;
553+
const ranges = getSlashHighlightRanges(stripSgr(line), cursor.visibleStart);
554+
if (ranges.length === 0) return undefined;
487555
return highlightVisibleRanges(line, ranges, token);
488556
}
489557

490-
function goalCommandPathRanges(
491-
visible: string,
492-
commandEnd: number,
493-
): Array<{ start: number; end: number }> {
494-
const nextRange = readTokenRange(visible, commandEnd);
495-
if (nextRange === null || visible.slice(nextRange.start, nextRange.end) !== 'next') {
496-
return [];
497-
}
498-
const ranges = [nextRange];
499-
const manageRange = readTokenRange(visible, nextRange.end);
500-
if (manageRange !== null && visible.slice(manageRange.start, manageRange.end) === 'manage') {
501-
ranges.push(manageRange);
502-
}
503-
return ranges;
504-
}
505-
506-
function readTokenRange(
507-
visible: string,
508-
start: number,
509-
): { start: number; end: number } | null {
510-
let tokenStart = start;
511-
while (tokenStart < visible.length && isTokenSpace(visible[tokenStart])) tokenStart++;
512-
if (tokenStart >= visible.length) return null;
513-
let tokenEnd = tokenStart;
514-
while (tokenEnd < visible.length && !isTokenSpace(visible[tokenEnd])) tokenEnd++;
515-
return { start: tokenStart, end: tokenEnd };
516-
}
517-
518-
function isTokenSpace(ch: string | undefined): boolean {
519-
return ch === ' ' || ch === '\t';
520-
}
521-
522558
function highlightVisibleRanges(
523559
line: string,
524560
ranges: Array<{ start: number; end: number }>,

0 commit comments

Comments
 (0)