Skip to content

Commit fdd12aa

Browse files
federiconericlaude
andauthored
feat(tui): add spec name autocomplete for /run command (#89)
* feat(tui): add spec name autocomplete for /run command - Add listSpecNames() utility to read .md files from specs directory - Add fuzzyMatch() utility for sequential character matching - Add specNames field to SessionState, loaded at startup and refreshed after /init - Update CommandDropdown to use fuzzy matching instead of includes() - Update ChatInput to detect /run <space> and show spec dropdown - Thread specSuggestions from MainShell → ChatInput via sessionState.specNames Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * test(tui): add integration and keyboard navigation tests for spec autocomplete - Add MainShell integration tests verifying specNames flows from SessionState through to the /run dropdown (4 scenarios: with specs, filtered results, undefined specNames, empty specNames) - Add CommandDropdown keyboard navigation tests: Enter selects first item, Down arrow advances selection, Up arrow reverses it, clamping at index 0, Escape calls onCancel Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(tui): address code review feedback for spec autocomplete - Add specSuggestions to updateValue dependency array (stale closure fix) - Use node: prefix for fs/promises and path imports (codebase convention) - Guard getMaxCommandWidth against empty array (prevents RangeError) - Wrap handleInitComplete spec loading in try-catch (unhandled rejection fix) - Log non-ENOENT errors in listSpecNames for debuggability Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tui): address code review round 2 for spec autocomplete - Remove unused `join` import from spec-names.ts - Fix duplicate history entry when selecting spec from dropdown - Refresh spec name cache after interview creates a new spec - Clean up self-correcting comment in CommandDropdown test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tui): address code review round 3 for spec autocomplete - Add itemPrefix prop to CommandDropdown (default '/') so spec names render without misleading slash prefix - Hoist RUN_PREFIX constant to module scope (avoid per-render allocation) - Wrap specSuggestions in useMemo to prevent unnecessary re-renders - Replace console.debug with logger.debug for consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent d3d6b6b commit fdd12aa

13 files changed

Lines changed: 639 additions & 12 deletions

src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createSessionState } from './repl/session-state.js';
22
import { hasConfig, loadConfigWithDefaults } from './utils/config.js';
3+
import { listSpecNames } from './utils/spec-names.js';
34
import { AVAILABLE_MODELS, getAvailableProvider, isAnthropicAlias } from './ai/providers.js';
45
import type { AIProvider } from './ai/providers.js';
56
import { notifyIfUpdateAvailable } from './utils/update-check.js';
@@ -61,14 +62,20 @@ async function startInkTui(initialScreen: AppScreen = 'shell', interviewFeature?
6162
}
6263
}
6364

64-
return createSessionState(
65+
const specsDir = config
66+
? join(projectRoot, config.paths.specs)
67+
: join(projectRoot, '.ralph/specs');
68+
const specNames = await listSpecNames(specsDir);
69+
70+
const state = createSessionState(
6571
projectRoot,
6672
provider, // May be null if no API key
6773
model,
6874
undefined, // No scan result yet
6975
config,
7076
isInitialized
7177
);
78+
return { ...state, specNames };
7279
}
7380

7481
const initialState = await createCurrentSessionState();

src/repl/session-state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface SessionState {
2727
conversationContext?: string;
2828
/** Whether /init has been run in this session */
2929
initialized: boolean;
30+
/** Cached spec names from the configured specs directory */
31+
specNames?: string[];
3032
}
3133

3234
/**

src/tui/app.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { AIProvider } from '../ai/providers.js';
1717
import type { ScanResult } from '../scanner/types.js';
1818
import type { SessionState } from '../repl/session-state.js';
1919
import { loadConfigWithDefaults } from '../utils/config.js';
20+
import { listSpecNames } from '../utils/spec-names.js';
2021
import { logger } from '../utils/logger.js';
2122
import { InterviewScreen } from './screens/InterviewScreen.js';
2223
import { InitScreen } from './screens/InitScreen.js';
@@ -135,6 +136,15 @@ export function App({
135136

136137
savedPath = join(specsDir, `${featureName}.md`);
137138
writeFileSync(savedPath, spec, 'utf-8');
139+
140+
// Refresh spec name cache so the new spec shows in /run autocomplete
141+
try {
142+
const updatedSpecNames = await listSpecNames(specsDir);
143+
setSessionState((prev) => ({ ...prev, specNames: updatedSpecNames }));
144+
} catch {
145+
// Non-critical: autocomplete will update on next restart
146+
}
147+
138148
onComplete?.(savedPath);
139149
} catch (err) {
140150
const reason = err instanceof Error ? err.message : String(err);
@@ -185,8 +195,19 @@ export function App({
185195
/**
186196
* Handle init completion - update state and navigate to shell
187197
*/
188-
const handleInitComplete = useCallback((newState: SessionState, generatedFiles?: string[]) => {
189-
setSessionState(newState);
198+
const handleInitComplete = useCallback(async (newState: SessionState, generatedFiles?: string[]) => {
199+
// Refresh spec names after init (config may have changed)
200+
let specNames: string[] = [];
201+
try {
202+
const specsDir = join(
203+
newState.projectRoot,
204+
newState.config?.paths.specs ?? '.ralph/specs'
205+
);
206+
specNames = await listSpecNames(specsDir);
207+
} catch {
208+
// Non-critical: autocomplete will work without spec names
209+
}
210+
setSessionState({ ...newState, specNames });
190211
const fileCount = generatedFiles?.length ?? 0;
191212
const msg = fileCount > 0
192213
? `\u2713 Initialization complete. Generated ${fileCount} configuration file${fileCount === 1 ? '' : 's'}.`

src/tui/components/ChatInput.test.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,95 @@ describe('ChatInput', () => {
163163
expect(frame).toContain('help');
164164
instance.unmount();
165165
});
166+
167+
describe('spec autocomplete (/run argument mode)', () => {
168+
const SPEC_SUGGESTIONS = [
169+
{ name: 'auth-system', description: '' },
170+
{ name: 'user-profile', description: '' },
171+
{ name: 'payment-flow', description: '' },
172+
];
173+
174+
it('shows spec dropdown when typing /run followed by space', async () => {
175+
const onSubmit = vi.fn();
176+
const instance = await renderAndWait(
177+
() => render(<ChatInput onSubmit={onSubmit} specSuggestions={SPEC_SUGGESTIONS} />),
178+
);
179+
180+
await typeText(instance, '/run ');
181+
182+
const frame = stripAnsi(instance.lastFrame() ?? '');
183+
expect(frame).toContain('auth-system');
184+
expect(frame).toContain('user-profile');
185+
instance.unmount();
186+
});
187+
188+
it('does not show spec dropdown without trailing space', async () => {
189+
const onSubmit = vi.fn();
190+
const instance = await renderAndWait(
191+
() => render(<ChatInput onSubmit={onSubmit} specSuggestions={SPEC_SUGGESTIONS} />),
192+
);
193+
194+
await typeText(instance, '/run');
195+
196+
const frame = stripAnsi(instance.lastFrame() ?? '');
197+
// Should show command dropdown for /run command, not spec dropdown
198+
expect(frame).toContain('run');
199+
// auth-system should not be visible (it's a spec name)
200+
expect(frame).not.toContain('auth-system');
201+
instance.unmount();
202+
});
203+
204+
it('does not show spec dropdown for non-/run commands with space', async () => {
205+
const onSubmit = vi.fn();
206+
const instance = await renderAndWait(
207+
() => render(<ChatInput onSubmit={onSubmit} specSuggestions={SPEC_SUGGESTIONS} />),
208+
);
209+
210+
await typeText(instance, '/new ');
211+
212+
const frame = stripAnsi(instance.lastFrame() ?? '');
213+
expect(frame).not.toContain('auth-system');
214+
instance.unmount();
215+
});
216+
217+
it('filters spec suggestions as user types after /run ', async () => {
218+
const onSubmit = vi.fn();
219+
const instance = await renderAndWait(
220+
() => render(<ChatInput onSubmit={onSubmit} specSuggestions={SPEC_SUGGESTIONS} />),
221+
);
222+
223+
await typeText(instance, '/run auth');
224+
225+
const frame = stripAnsi(instance.lastFrame() ?? '');
226+
expect(frame).toContain('auth-system');
227+
expect(frame).not.toContain('payment-flow');
228+
instance.unmount();
229+
});
230+
231+
it('does not show spec dropdown when specSuggestions is empty', async () => {
232+
const onSubmit = vi.fn();
233+
const instance = await renderAndWait(
234+
() => render(<ChatInput onSubmit={onSubmit} specSuggestions={[]} />),
235+
);
236+
237+
await typeText(instance, '/run ');
238+
239+
const frame = stripAnsi(instance.lastFrame() ?? '');
240+
expect(frame).not.toContain('auth-system');
241+
instance.unmount();
242+
});
243+
244+
it('does not show spec dropdown when specSuggestions is undefined', async () => {
245+
const onSubmit = vi.fn();
246+
const instance = await renderAndWait(
247+
() => render(<ChatInput onSubmit={onSubmit} />),
248+
);
249+
250+
await typeText(instance, '/run ');
251+
252+
const frame = stripAnsi(instance.lastFrame() ?? '');
253+
expect(frame).not.toContain('auth-system');
254+
instance.unmount();
255+
});
256+
});
166257
});

src/tui/components/ChatInput.tsx

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
moveCursorByWordRight,
2828
} from '../utils/input-utils.js';
2929

30+
const RUN_PREFIX = '/run ';
31+
3032
/**
3133
* Props for the ChatInput component
3234
*/
@@ -43,6 +45,8 @@ export interface ChatInputProps {
4345
commands?: Command[];
4446
/** Called when a slash command is selected */
4547
onCommand?: (command: string) => void;
48+
/** Spec suggestions for /run argument autocomplete */
49+
specSuggestions?: Command[];
4650
}
4751

4852
/**
@@ -82,6 +86,7 @@ export function ChatInput({
8286
allowEmpty = false,
8387
commands = DEFAULT_COMMANDS,
8488
onCommand,
89+
specSuggestions,
8590
}: ChatInputProps): React.ReactElement {
8691
const [value, setValue] = useState('');
8792
const [cursorOffset, setCursorOffset] = useState(0);
@@ -97,6 +102,14 @@ export function ChatInput({
97102
// Only filter on the command name part (before the first space)
98103
const commandFilter = isSlashCommand ? value.slice(1).split(' ')[0] : '';
99104

105+
// Detect "/run " argument autocomplete mode
106+
const isRunArgMode =
107+
specSuggestions !== undefined &&
108+
specSuggestions.length > 0 &&
109+
value.startsWith(RUN_PREFIX);
110+
// The text the user has typed after "/run "
111+
const runArgFilter = isRunArgMode ? value.slice(RUN_PREFIX.length) : '';
112+
100113
// Store draft input when starting history navigation
101114
const draftRef = useRef<string>('');
102115

@@ -116,13 +129,18 @@ export function ChatInput({
116129
resetNavigation();
117130
}
118131

119-
if (nextValue.startsWith('/') && !nextValue.includes(' ')) {
132+
const isCommandMode = nextValue.startsWith('/') && !nextValue.includes(' ');
133+
const isRunArgModeNext =
134+
specSuggestions !== undefined &&
135+
specSuggestions.length > 0 &&
136+
nextValue.startsWith('/run ');
137+
if (isCommandMode || isRunArgModeNext) {
120138
setShowDropdown(true);
121139
} else {
122140
setShowDropdown(false);
123141
}
124142
},
125-
[clampCursor, resetNavigation]
143+
[clampCursor, resetNavigation, specSuggestions]
126144
);
127145

128146

@@ -322,6 +340,18 @@ export function ChatInput({
322340
[onCommand, onSubmit, addToHistory, updateValue]
323341
);
324342

343+
/**
344+
* Handle spec selection from /run argument dropdown
345+
*/
346+
const handleSpecSelect = useCallback(
347+
(specName: string) => {
348+
const newValue = `/run ${specName}`;
349+
updateValue(newValue, newValue.length, true);
350+
setShowDropdown(false);
351+
},
352+
[updateValue]
353+
);
354+
325355
/**
326356
* Handle dropdown cancel
327357
*/
@@ -366,8 +396,19 @@ export function ChatInput({
366396
)}
367397
</Box>
368398

399+
{/* Spec argument dropdown for /run <spec> */}
400+
{showDropdown && isRunArgMode && specSuggestions && (
401+
<CommandDropdown
402+
commands={specSuggestions}
403+
filter={runArgFilter}
404+
onSelect={handleSpecSelect}
405+
onCancel={handleDropdownCancel}
406+
itemPrefix=""
407+
/>
408+
)}
409+
369410
{/* Command dropdown below input - only show while typing command name, not arguments */}
370-
{showDropdown && isSlashCommand && !hasSpace && (
411+
{showDropdown && isSlashCommand && !hasSpace && !isRunArgMode && (
371412
<CommandDropdown
372413
commands={commands}
373414
filter={commandFilter}

0 commit comments

Comments
 (0)