Skip to content
Merged
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 openspec/changes/at-filepath-autocomplete-tui/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-14
50 changes: 50 additions & 0 deletions openspec/changes/at-filepath-autocomplete-tui/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## Context

The TUI input panel (`src/tui/inputPanel.js`) is a thin wrapper around `ink-text-input` that captures user text input. Users frequently reference files in conversations but must manually type full or partial paths, which is error-prone. The Ink framework does not support z-index overlays, so any dropdown must be rendered as a sibling component.

## Goals / Non-Goals

**Goals:**
- Detect `@` trigger character in the input field and enter autocomplete mode.
- Perform glob-based file search in the project root for matching paths.
- Render a dropdown list below the input line with keyboard navigation.
- Replace `@<query>` with the selected file path on Enter.
- Render the selected filename portion in cyan/green using chalk.
- Manage autocomplete state in the App component and pass it to InputPanel.

**Non-Goals:**
- Tab completion or other trigger characters.
- Fuzzy search beyond prefix matching.
- Autocomplete in non-input contexts.
- Caching of glob results across sessions.
- Inline completion (VS Code-style).

## Decisions

1. **Trigger character: `@`** — Chosen over tab because tab is already used for other purposes in some terminals. `@` is more discoverable and less likely to conflict with existing keybindings.

2. **Glob search via `fast-glob`** — Chosen over a custom recursive `fs.readdirSync` walk because fast-glob handles edge cases (symlinks, hidden files, permission errors) more robustly. The dependency is lightweight (~100KB).

3. **Dropdown as sibling component** — Ink does not support true overlays or z-index. The autocomplete dropdown is rendered as a conditional sibling below InputPanel and above StatusBar, avoiding pushing status bar content.

4. **Keyboard interception in InputPanel** — Autocomplete mode intercepts arrow keys (up/down) and Enter before `ink-text-input` processes them. Esc dismisses the dropdown. When not in autocomplete mode, all keys pass through to TextInput normally.

5. **State lifted to App component** — Autocomplete state (`inAutocomplete`, `query`, `matches`, `selectedIndex`) lives in the App component and is passed as props to InputPanel. This keeps the state co-located with other TUI state and simplifies prop drilling.

6. **Cyan/green color for selected filename** — Chalk v6.0.0 is already a dependency. The selected filename portion is wrapped in a chalk color wrapper within the input display, splitting the input value into segments.

## Risks / Trade-offs

- **[Risk]** `fast-glob` adds a new dependency. → **Mitigation:** fast-glob is a well-maintained, widely-used package with minimal footprint.
- **[Risk]** Ink rendering order may cause the dropdown to appear behind other components. → **Mitigation:** Render dropdown between InputPanel and StatusBar in the Box hierarchy.
- **[Risk]** Keyboard interception may break existing TextInput behavior. → **Mitigation:** Only intercept keys when `inAutocomplete` is true; otherwise pass all keys through to TextInput.
- **[Risk]** Glob search on every keystroke may be slow for large projects. → **Mitigation:** Debounce search by 150ms; limit results to top 5 matches.

## Migration Plan

No migration needed. This is a pure addition — no existing functionality is modified or removed.

## Open Questions

- Should the autocomplete search be scoped to specific file extensions (e.g., `.js`, `.ts`, `.md`) or include all files? Default: all files.
- Should the search exclude `node_modules`, `.git`, and other common ignore directories? Default: yes, follow `.gitignore` patterns.
36 changes: 36 additions & 0 deletions openspec/changes/at-filepath-autocomplete-tui/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## Why

When referencing files in conversations (e.g., "look at src/tui/app.js"), users must manually type full or partial paths. This is error-prone and slow, especially for deeply nested paths. A lightweight autocomplete triggered by `@` would reduce typos, speed up file references, and make the TUI feel more polished.

## What Changes

- Extend `InputPanel` component to detect `@` trigger and enter autocomplete mode.
- Add glob-based file search using `fast-glob` to find matching files in the project root.
- Render a dropdown list below the input line with keyboard navigation (up/down arrows, Enter to select, Esc to dismiss).
- Lift autocomplete state (`inAutocomplete`, `query`, `matches`, `selectedIndex`) to the App component.
- Replace `@<query>` with the full file path on selection; render filename portion in cyan/green.
- Show "No files match" when the glob returns zero results.
- Add `fast-glob` as a project dependency.

## Capabilities

### New Capabilities
- `tui-autocomplete`: @-triggered file path autocomplete with glob search, dropdown rendering, and keyboard navigation in the TUI input panel.

### Modified Capabilities
- None

## Impact

- **Affected code:** `src/tui/inputPanel.js` (extend with autocomplete mode), `src/tui/app.js` (lift state, pass props), `src/tui/statusBar.js` (dropdown renders between InputPanel and StatusBar).
- **Dependencies:** New dependency `fast-glob`.
- **TUI rendering:** Dropdown is a sibling component below InputPanel (Ink does not support z-index overlays).
- **Keyboard handling:** Autocomplete mode intercepts arrow keys and Enter before `ink-text-input` processes them.

## Non-goals

- Tab completion or other trigger characters.
- Fuzzy search (fuse.js) — prefix matching via glob is sufficient.
- Inline completion (VS Code-style).
- Autocomplete in non-input contexts (e.g., memory panel, settings).
- Caching of glob results across sessions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
## ADDED Requirements

### Requirement: InputPanel detects @ trigger and enters autocomplete mode
The InputPanel component SHALL detect when the user types the `@` character and enter autocomplete mode. In this mode, the component captures keyboard input for navigation instead of passing it to the underlying text input.

#### Scenario: User types @ to trigger autocomplete
- **WHEN** the user types `@` in the input field
- **THEN** the InputPanel enters autocomplete mode and displays a dropdown below the input line

#### Scenario: User types @ followed by characters to filter
- **WHEN** the user types `@` followed by one or more characters
- **THEN** the system performs a glob search using the typed characters as a prefix filter

#### Scenario: User dismisses autocomplete with Esc
- **WHEN** the user presses `Esc` while in autocomplete mode
- **THEN** the dropdown is dismissed, autocomplete mode exits, and the `@<query>` text remains in the input field

### Requirement: Glob search finds matching files
The system SHALL use `fast-glob` to search the project root for files matching the typed prefix. Results are limited to the top 5 matches.

#### Scenario: Glob search returns matches
- **WHEN** the user types `@src` in autocomplete mode
- **THEN** the system returns up to 5 files matching the prefix `src` from the project root

#### Scenario: Glob search returns no matches
- **WHEN** the user types `@xyznonexistent` in autocomplete mode
- **THEN** the system displays "No files match" in the dropdown

#### Scenario: Glob search excludes ignored directories
- **WHEN** the user types `@` followed by a prefix that matches files in `node_modules`
- **THEN** files in `node_modules` and other `.gitignore`-excluded directories are not included in results

### Requirement: Dropdown renders with keyboard navigation
The system SHALL render a dropdown list below the input line with keyboard navigation support.

#### Scenario: Up arrow navigates up the list
- **WHEN** the user presses the up arrow key while in autocomplete mode with matches
- **THEN** the selection index decreases (wrapping to the last item when at the first)

#### Scenario: Down arrow navigates down the list
- **WHEN** the user presses the down arrow key while in autocomplete mode with matches
- **THEN** the selection index increases (wrapping to the first item when at the last)

#### Scenario: Enter selects a file
- **WHEN** the user presses Enter while a file is selected in the dropdown
- **THEN** the `@<query>` text is replaced with the full file path and autocomplete mode exits

#### Scenario: Dropdown renders between InputPanel and StatusBar
- **WHEN** the autocomplete dropdown is visible
- **THEN** it is rendered as a sibling component below the InputPanel and above the StatusBar, without pushing the StatusBar content

### Requirement: Selected path renders in cyan/green
The system SHALL render the selected filename portion of the input in cyan/green using chalk ANSI codes.

#### Scenario: Selected filename is colored
- **WHEN** a file is selected and the input is displayed
- **THEN** the filename portion (after the `@` trigger) is rendered in cyan/green color

#### Scenario: Non-selected input remains default color
- **WHEN** the input contains text but no file is selected
- **THEN** the input text is rendered in the default color

### Requirement: Autocomplete state is managed in App component
The App component SHALL manage autocomplete state (`inAutocomplete`, `query`, `matches`, `selectedIndex`) and pass it as props to the InputPanel.

#### Scenario: State is lifted to App
- **WHEN** the user types `@` in the input
- **THEN** the App component updates `inAutocomplete` to true and passes state to InputPanel

#### Scenario: State resets on selection or dismiss
- **WHEN** the user selects a file or presses Esc
- **THEN** the App component resets `inAutocomplete` to false and clears `query`, `matches`, and `selectedIndex`

### Requirement: Debounced glob search
The system SHALL debounce glob search by 150ms to avoid excessive file system reads on every keystroke.

#### Scenario: Search is debounced
- **WHEN** the user types multiple characters rapidly in autocomplete mode
- **THEN** the glob search is executed only after 150ms of inactivity

#### Scenario: Search re-executes on new input after debounce
- **WHEN** the user types a new character after the debounce period
- **THEN** the glob search re-executes with the updated query
64 changes: 64 additions & 0 deletions openspec/changes/at-filepath-autocomplete-tui/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
## 1. Add fast-glob dependency

- [x] 1.1 Add fast-glob to package.json dependencies
- [x] 1.2 Run npm install to update lockfile

## 2. Create autocomplete utility module

- [x] 2.1 Create src/tui/autocomplete.js with glob search function
- [x] 2.2 Implement debounced file search using fast-glob with 150ms delay
- [x] 2.3 Implement file filtering to exclude node_modules and .git directories
- [x] 2.4 Limit results to top 5 matches
- [x] 2.5 Export search function for use by InputPanel

## 3. Extend InputPanel with autocomplete mode

- [x] 3.1 Add autocomplete mode detection for @ trigger character
- [x] 3.2 Implement keyboard interception (up/down/Enter/Esc) in autocomplete mode
- [x] 3.3 Pass autocomplete state props from parent component
- [x] 3.4 Maintain query string and selection index state within InputPanel
- [x] 3.5 When not in autocomplete mode, pass all keys through to TextInput normally

## 4. Create autocomplete dropdown component

- [x] 4.1 Create src/tui/AutoCompleteDropdown.js component
- [x] 4.2 Render list of matching file paths
- [x] 4.3 Highlight selected item with visual indicator
- [x] 4.4 Display "No files match" when search returns zero results
- [x] 4.5 Position dropdown below input line (sibling component, not overlay)

## 5. Lift autocomplete state to App component

- [x] 5.1 Add autocomplete state (inAutocomplete, query, matches, selectedIndex) to App
- [x] 5.2 Pass autocomplete state and handlers as props to InputPanel
- [x] 5.3 Pass autocomplete state and dropdown component reference to App render
- [x] 5.4 Reset state on selection or dismiss (Esc)

## 6. Integrate dropdown rendering in App

- [x] 6.1 Render AutoCompleteDropdown between InputPanel and StatusBar in Box hierarchy
- [x] 6.2 Conditionally render dropdown only when inAutocomplete is true
- [x] 6.3 Ensure StatusBar is not pushed down by dropdown rendering

## 7. Implement path selection and coloring

- [x] 7.1 On Enter key, replace @<query> with full file path in input value
- [x] 7.2 Split input value into segments for colored rendering
- [x] 7.3 Render selected filename portion in cyan/green using Ink color system
- [x] 7.4 Exit autocomplete mode after selection

## 8. Add tests

- [x] 8.1 Create tests/unit/tui/autocomplete.test.js for glob search function
- [x] 8.2 Test debounced search behavior
- [x] 8.3 Test file filtering (exclusion of node_modules, .git)
- [x] 8.4 Test result limiting to 5 matches
- [x] 8.5 Create tests/unit/tui/AutoCompleteDropdown.test.js for dropdown component
- [x] 8.6 Test keyboard navigation (up/down wrapping)
- [x] 8.7 Test "No files match" display

## 9. Verify and lint

- [x] 9.1 Run npm run lint to verify no lint errors
- [x] 9.2 Run npm run test to verify all tests pass
- [x] 9.3 Run timeout 10 npm start to verify application starts
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"ansi-escapes": "^7.3.0",
"ansi-regex": "^6.2.2",
"adm-zip": "^0.5.16",
"fast-glob": "^3.3.3",
"chalk": "^6.0.0",
"cli-highlight": "^2.1.11",
"cli-table3": "^0.6.5",
Expand Down
38 changes: 38 additions & 0 deletions src/tui/AutoCompleteDropdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from "react";
import { Box, Text } from "ink";

/**
* Autocomplete dropdown component.
* Renders a list of matching file paths below the input line.
* Highlights the selected item and shows "No files match" when empty.
* @param {Object} props
* @param {string[]} props.matches - List of matching file paths
* @param {number} props.selectedIndex - Currently selected index
* @param {() => void} props.onDismiss - Callback when dismissed (Esc)
* @returns {React.ReactElement}
*/
export function AutoCompleteDropdown({ matches, selectedIndex, _onDismiss }) {
const hasMatches = matches && matches.length > 0;

const items = hasMatches
? matches.map((match, index) => {
const isSelected = index === selectedIndex;
const prefix = isSelected ? "▸ " : " ";
return React.createElement(
Box,
{ key: match },
React.createElement(Text, { color: isSelected ? "cyan" : "white" }, `${prefix}${match}`),
);
})
: [React.createElement(Text, { key: "no-match", color: "gray" }, "No files match")];

return React.createElement(
Box,
{
flexDirection: "column",
paddingX: 1,
paddingY: 0,
},
...items,
);
}
53 changes: 52 additions & 1 deletion src/tui/app.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Box, useApp, useInput, useWindowSize } from "ink";
import { CommandParser } from "./commandParser.js";
import { ConversationPanel } from "./conversationPanel.js";
import { StatusBar } from "./statusBar.js";
import { InputPanel } from "./inputPanel.js";
import { Banner } from "./banner.js";
import { OnboardingPanel } from "./onboardingPanel.js";
import { AutoCompleteDropdown } from "./AutoCompleteDropdown.js";
import { createSession } from "../session/factory.js";
import { setConfigValue } from "../config/loader.js";
import { isAvailable, getGcCalls } from "../memory/gc.js";
Expand Down Expand Up @@ -39,6 +40,11 @@ export default function App({
const [inputFocused, setInputFocused] = useState(true);
const [contextSize, setContextSize] = useState(0);
const [isCompacting, setIsCompacting] = useState(false);
// Autocomplete state
const [inAutocomplete, setInAutocomplete] = useState(false);
const [autocompleteQuery, setAutocompleteQuery] = useState("");
const [autocompleteMatches, setAutocompleteMatches] = useState([]);
const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] = useState(0);
const messageListRef = useRef(null);
const abortControllerRef = useRef(null);
const isStreamingRef = useRef(false);
Expand Down Expand Up @@ -125,6 +131,39 @@ export default function App({
}
};

/**
* Handle autocomplete file selection.
* @param {string} fullPath - The selected file path
* @param {string[]} _results - Search results (unused, for future use)
*/
const handleAutocompleteSelect = useCallback((fullPath, _results) => {
if (fullPath) {
setInputText(fullPath);
setInAutocomplete(false);
setAutocompleteQuery("");
setAutocompleteMatches([]);
setAutocompleteSelectedIndex(0);
}
}, []);

/**
* Handle autocomplete search results from InputPanel.
* @param {string[]} results - The search results to display
*/
const handleAutocompleteResults = useCallback((results) => {
setAutocompleteMatches(results);
}, []);

/**
* Handle autocomplete dismiss (Esc key).
*/
const handleAutocompleteDismiss = useCallback(() => {
setInAutocomplete(false);
setAutocompleteQuery("");
setAutocompleteMatches([]);
setAutocompleteSelectedIndex(0);
}, []);

/**
* Handle IRC-style command parsing with dispatch table.
* @param {string} trimmed - The command string (sans leading whitespace)
Expand Down Expand Up @@ -954,7 +993,19 @@ export default function App({
onFocus: () => setInputFocused(true),
onBlur: () => setInputFocused(false),
focus: inputFocused,
inAutocomplete,
autocompleteQuery,
autocompleteMatches,
autocompleteSelectedIndex,
onAutocompleteSelect: handleAutocompleteSelect,
onAutocompleteResults: handleAutocompleteResults,
}),
inAutocomplete &&
React.createElement(AutoCompleteDropdown, {
matches: autocompleteMatches,
selectedIndex: autocompleteSelectedIndex,
onDismiss: handleAutocompleteDismiss,
}),
)
: null,
);
Expand Down
Loading