Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/performance-benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ Measured on macOS arm64, Node 26.8.1, 8GiB RAM; informational single-machine res
| Transcript JSON snapshot, 100k | 459.95 | 878.17 |

Full transcript wrapping and snapshot serialization exceed the editor-frame target. They are whole-transcript workloads, not evidence that bounded history search needs a native helper. Avoid performing them as part of completion/history queries. Startup/socket latency is not measured here.

Structured completion filtering (500 candidates) measured p50 0.06ms / p95 0.13ms. A direct native-source probe measured cold Git parent-context capture at 226.64ms and command capture at 84.10ms. These cold operations are asynchronous and exceed the 50ms investigation target; warm parent-context results are cached for two seconds (32 contexts maximum) and locally filtered. Results are invalidated by buffer/cwd generation before rendering. Capture execution remains bounded to 1.5 seconds and 1MiB output.
4 changes: 4 additions & 0 deletions scripts/benchmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {OutputBuffer} from '../src/output/OutputBuffer.js';
import {CommandEditor} from '../src/input/CommandEditor.js';
import {layoutInput} from '../src/input/inputLayout.js';
import {Highlighter} from '../src/input/Highlighter.js';
import {filterCompletions, parseNativeCompletions} from '../src/shell/completion.js';
import {planScreen} from '../src/app/screenPlan.js';
import {encodeMessage, FrameDecoder} from '../src/session/SessionProtocol.js';
import {parseZshHistory} from '../src/shell/HistoryService.js';
Expand Down Expand Up @@ -81,7 +82,10 @@ function setup(): void {
}
}

const completionFixture = parseNativeCompletions(Array.from({length: 500}, (_, index) => `--option-${index} -- description ${index}`).join('\n'), {buffer: 'tool ', cwd: '/work'});

const benchmarks: Benchmark[] = [
{name: 'completion/filter-500', run: () => filterCompletions(completionFixture, 'op4'), units: 500, unitName: 'candidates'},
...suggestionCounts.map(count => ({
name: `history/current-text-scan-${count}`,
run: () => histories.get(count)!.map(entry => entry.command).filter(command => command.toLowerCase().includes('nonexistent')).slice(0, 100),
Expand Down
54 changes: 38 additions & 16 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js';
import {appendFileSync, existsSync} from 'node:fs';
import {delimiter, join} from 'node:path';
import {renderCompletion, COMPLETION_ACTIONS} from '../shell/CompletionMenu.js';
import {resolveAction} from '../ui/actions.js';
import {CompletionService, type CompletionCandidate} from '../shell/CompletionService.js';
import {HistoryService} from '../shell/HistoryService.js';
import {SuggestionController} from '../suggestions/SuggestionController.js';
Expand Down Expand Up @@ -132,6 +134,7 @@ export class TerminalApp {
private readonly completionService = new CompletionService();
private shellSuggestions: CompletionCandidate[] = [];
private lastSuggestionInput = "";
private completionGeneration = 0;
private context: PromptContext = {cwd: process.cwd(), project: '…', exitStatus: 0};
private readonly commandContexts = new CommandContextCache(() => this.render());
private promptConfiguration: PromptConfiguration = loadPromptConfiguration();
Expand Down Expand Up @@ -769,6 +772,26 @@ export class TerminalApp {
return;
}

if (!this.running && !this.historySearchActive && !this.editor.text.startsWith('/') && this.shellSuggestions.length > 0
&& !this.suggestions.alternativesOpen) {
const action = resolveAction(COMPLETION_ACTIONS, key);
if (action?.id === 'move') {
this.selectedSuggestion = (this.selectedSuggestion + (key.kind === 'down' ? 1 : -1) + this.shellSuggestions.length) % this.shellSuggestions.length;
return;
}
if (action?.id === 'insert') {
const candidate = this.shellSuggestions[this.selectedSuggestion] ?? this.shellSuggestions[0];
if (candidate) this.applySuggestion(candidate);
return;
}
if (action?.id === 'cancel') {
this.completionGeneration += 1;
this.completionService.cancel();
this.shellSuggestions = [];
return;
}
}

if (!this.running && this.handleSuggestionKey(key)) {
this.selectedSuggestion = 0;
return;
Expand Down Expand Up @@ -920,24 +943,22 @@ export class TerminalApp {


private async fetchSuggestions(): Promise<void> {
if (this.running || this.editor.hasPasteAtoms || this.editor.text.startsWith('/')) {
this.shellSuggestions = [];
return;
}
const input = this.editor.text;
if (input === this.lastSuggestionInput) return;
this.lastSuggestionInput = input;

if (input.trim().length === 0) {
this.shellSuggestions = [];
this.render();
return;
}

const comps = await this.completionService.suggest(input, this.context.cwd);
if (this.editor.text === input) {
const cwd = this.context.cwd;
const eligible = !this.running && !this.settingsPanelActive && !this.editor.hasPasteAtoms && !input.startsWith('/') && Boolean(input.trim());
const key = eligible ? JSON.stringify([input, cwd]) : '';
if (key === this.lastSuggestionInput) return;
this.lastSuggestionInput = key;
const generation = ++this.completionGeneration;
this.completionService.cancel();
// Clear before the next frame: results for another buffer must never flash.
this.shellSuggestions = [];
this.selectedSuggestion = 0;
if (!eligible) return;
const comps = await this.completionService.suggest(input, cwd);
if (!this.stopped && generation === this.completionGeneration && this.editor.text === input && this.context.cwd === cwd
&& !this.running && !this.settingsPanelActive && !this.editor.hasPasteAtoms) {
this.shellSuggestions = comps;
this.selectedSuggestion = 0;
this.render();
}
}
Expand Down Expand Up @@ -2665,6 +2686,7 @@ export class TerminalApp {
? [...panelRows.slice(1), panelRows[0]!] : panelRows ?? [];
case 'suggestions': return suggestionView.items.map((suggestion, visibleIndex) => {
const selected = suggestionView.start + visibleIndex === effectiveSelection;
if ('source' in suggestion && 'replacement' in suggestion) return renderCompletion(suggestion, selected, columns);
return truncateAnsi(
`${selected ? ACCENT : SECONDARY}${selected ? '›' : ' '} ${suggestion.name.padEnd(10)}${RESET}${SECONDARY} ${suggestion.description}${RESET}`,
columns,
Expand Down
33 changes: 33 additions & 0 deletions src/shell/CompletionMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type {CompletionCandidate} from './completion.js';
export {filterCompletions} from './completion.js';
import {completionLabel} from './completion.js';
import {GLYPHS, getCurrentGlyphMode} from '../ui/glyphs.js';
import {foreground, UI_COLORS} from '../ui/palette.js';
import {truncateAnsi, truncateText, displayWidth} from '../util/text.js';
import type {UiAction} from '../ui/actions.js';

export const COMPLETION_ACTIONS: readonly UiAction[] = [
{id: 'move', label: 'move', keyLabel: '↑↓', kinds: ['up', 'down']},
{id: 'insert', label: 'insert', keyLabel: 'Tab', kinds: ['complete']},
{id: 'cancel', label: 'dismiss', keyLabel: 'Esc', kinds: ['escape']},
];

const ICONS = {command: '\uf489', subcommand: '\uf489', option: '\uf024', argument: '\uf12e',
file: '\uf15b', directory: '\uf07b', value: '\uf12e'} as const;

/** One selectable row: groups are inline so geometry and selection indices stay identical. */
export function renderCompletion(candidate: CompletionCandidate, selected: boolean, columns: number): string {
const accent = foreground(selected ? UI_COLORS.accent : UI_COLORS.secondary);
const secondary = foreground(UI_COLORS.secondary);
const marker = selected ? GLYPHS.selection : ' ';
const icon = getCurrentGlyphMode() === 'nerd' ? `${ICONS[candidate.kind]} ` : '';
const display = completionLabel(candidate.display);
const group = completionLabel(candidate.group ?? candidate.kind);
const prefix = `${marker} ${icon}`;
const width = Math.max(0, columns - displayWidth(prefix));
if (width < 28) return truncateAnsi(`${accent}${prefix}${truncateText(display, width)}\u001b[0m`, columns);
const labelWidth = Math.min(28, Math.floor(width * 0.5));
const label = truncateText(display, labelWidth);
const padding = ' '.repeat(Math.max(1, labelWidth - displayWidth(label) + 1));
return truncateAnsi(`${accent}${prefix}${label}${padding}\u001b[0m${secondary}[${group}] ${completionLabel(candidate.description)}\u001b[0m`, columns);
}
25 changes: 20 additions & 5 deletions src/shell/CompletionService.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
import {runExternal} from '../providers/providers.js';
import {dirname, join} from 'node:path';
import {fileURLToPath} from 'node:url';
import {parseNativeCompletions, type CompletionCandidate, type CompletionContext, type CompletionSource} from './completion.js';
import {filterCompletions, parseNativeCompletions, type CompletionCandidate, type CompletionContext, type CompletionSource} from './completion.js';

export type {CompletionCandidate} from './completion.js';
const script = join(dirname(fileURLToPath(import.meta.url)), 'capture.zsh');

/** Existing isolated capture source; no composer text is submitted to the managed shell. */
export class NativeCompletionSource implements CompletionSource {
readonly id = 'zsh-native';
private readonly cache = new Map<string, {at: number; output: string}>();

async query(context: CompletionContext, signal: AbortSignal): Promise<CompletionCandidate[]> {
const result = await runExternal('zsh', [script, context.buffer], {
cwd: context.cwd, env: process.env, signal, timeoutMs: 1500, maxBytes: 1024 * 1024,
});
return result.ok && !signal.aborted ? parseNativeCompletions(result.stdout, context) : [];
// Capture the token's parent context once, then fuzzy-filter locally while typing.
// Keep the legacy whitespace replacement boundary until quoted-token support is designed.
const start = context.buffer.lastIndexOf(' ') + 1;
const parent = context.buffer.slice(0, start);
const key = JSON.stringify([context.cwd, parent]);
const cached = this.cache.get(key);
let output: string;
if (cached && Date.now() - cached.at < 2000) output = cached.output;
else {
const result = await runExternal('zsh', [script, parent], {
cwd: context.cwd, env: process.env, signal, timeoutMs: 1500, maxBytes: 1024 * 1024,
});
if (!result.ok || signal.aborted) return [];
output = result.stdout;
if (this.cache.size >= 32) this.cache.delete(this.cache.keys().next().value!);
this.cache.set(key, {at: Date.now(), output});
}
return signal.aborted ? [] : filterCompletions(parseNativeCompletions(output, context), context.buffer.slice(start));
}

}
Expand Down
21 changes: 21 additions & 0 deletions src/shell/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,24 @@ export function parseNativeCompletions(output: string, context: CompletionContex
}
return result;
}

/** Stable subsequence ranking. Exact/prefix matches lead; ties retain source order. */
export function filterCompletions(candidates: readonly CompletionCandidate[], query: string): CompletionCandidate[] {
const needle = query.toLowerCase();
if (!needle) return [...candidates];
const scored: Array<{candidate: CompletionCandidate; score: number; index: number}> = [];
candidates.forEach((candidate, index) => {
const text = candidate.value.toLowerCase();
let position = -1;
let gaps = 0;
for (const character of needle) {
const next = text.indexOf(character, position + 1);
if (next === -1) return;
gaps += next - position - 1;
position = next;
}
const score = text === needle ? -2000 : text.startsWith(needle) ? -1000 + text.length : gaps + text.length;
scored.push({candidate, score, index});
});
return scored.sort((a, b) => a.score - b.score || a.index - b.index).map(item => item.candidate);
}
84 changes: 84 additions & 0 deletions tests/completionMenu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {filterCompletions, parseNativeCompletions} from '../src/shell/completion.js';
import {renderCompletion} from '../src/shell/CompletionMenu.js';
import {setIconStyle} from '../src/ui/glyphs.js';
import {stripAnsi, displayWidth} from '../src/util/text.js';
import {TerminalApp} from '../src/app/TerminalApp.js';

const context = {buffer: 'git ', cwd: '/'};
const values = () => parseNativeCompletions('status -- working tree\nstash -- save changes\nshow -- objects\ncheckout -- branches', context);

test('completion subsequence filtering ranks prefixes first and stays stable with hundreds', () => {
assert.deepEqual(filterCompletions(values(), 'st').map(item => item.value), ['stash', 'status']);
assert.deepEqual(filterCompletions(values(), 'sho').map(item => item.value), ['show']);
assert.deepEqual(filterCompletions(values(), 'ckt').map(item => item.value), ['checkout']);
assert.deepEqual(filterCompletions(values(), 'zz'), []);
assert.equal(filterCompletions(Array.from({length: 500}, () => values()[0]!), 'sts').length, 500);
});

test('menu shows descriptions, source groups, safe icons and degrades at narrow widths', () => {
const item = {...values()[0]!, group: 'Git commands'};
setIconStyle('safe');
try {
const row = stripAnsi(renderCompletion(item, true, 80));
assert.match(row, /^> /u);
assert.match(row, /Git commands.*working tree/u);
assert.doesNotMatch(row, /[\ue000-\uf8ff]/u);
for (const width of [1, 4, 12, 28, 80]) assert.ok(displayWidth(renderCompletion(item, true, width)) <= width);
assert.ok(stripAnsi(renderCompletion({...item, description: ''}, false, 80)).includes('status'));
const previous = process.env.NO_COLOR;
process.env.NO_COLOR = '1';
try { assert.doesNotMatch(renderCompletion(item, true, 80), /\u001b\[(?:38|48);/u); }
finally { if (previous === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = previous; }
} finally { setIconStyle('nerd'); }
});

function appForTest(): TerminalApp {
const app = new TerminalApp();
Object.defineProperty(app, 'render', {value: () => {}});
return app;
}
function cleanup(app: TerminalApp): void { app['stop'](0); app['session'].kill(); }

test('keyboard completion moves selection, Tab inserts and Escape dismisses without execution', () => {
const app = appForTest();
const submitted: string[] = [];
app['session'].submit = ((text: string) => submitted.push(text)) as never;
try {
app['editor'].insert('git ');
app['shellSuggestions'] = values();
app['handleKey']({kind: 'down'});
assert.equal(app['selectedSuggestion'], 1);
app['handleKey']({kind: 'complete'});
assert.equal(app['editor'].text, 'git stash');
assert.deepEqual(submitted, []);
app['shellSuggestions'] = values();
app['handleKey']({kind: 'escape'});
assert.deepEqual(app['shellSuggestions'], []);
} finally { cleanup(app); }
});

test('changed buffer clears old rows immediately and ABA requests cannot repaint', async () => {
const app = appForTest();
const pending: Array<(items: ReturnType<typeof values>) => void> = [];
app['completionService'].suggest = () => new Promise(resolve => pending.push(resolve));
try {
app['editor'].insert('git ');
app['shellSuggestions'] = values();
const first = app['fetchSuggestions']();
assert.deepEqual(app['shellSuggestions'], []);
app['editor'].insert('s');
const second = app['fetchSuggestions']();
app['editor'].backspace();
const third = app['fetchSuggestions']();
pending[2]!(values()); await third;
assert.equal(app['shellSuggestions'].length, 4);
pending[0]!([]); await first;
pending[1]!(values()); await second;
assert.equal(app['shellSuggestions'].length, 4);
app['editor'].clear(); app['editor'].insert('/history ');
await app['fetchSuggestions']();
assert.deepEqual(app['shellSuggestions'], []);
} finally { cleanup(app); }
});
Loading