From e6a72910f8f5e1a7fde9d67b46cbf10a49c5cbf6 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sat, 15 Aug 2026 09:17:50 -0400 Subject: [PATCH] Revert "feat: add @-triggered file path autocomplete to TUI input panel (#798)" This reverts commit 4b0f00d73b0c74864deba380f33933a462f816e9. --- .../.openspec.yaml | 2 - .../at-filepath-autocomplete-tui/design.md | 50 ---- .../at-filepath-autocomplete-tui/proposal.md | 36 --- .../specs/tui-autocomplete/spec.md | 83 ------- .../at-filepath-autocomplete-tui/tasks.md | 64 ------ package-lock.json | 1 - package.json | 1 - src/tui/AutoCompleteDropdown.js | 38 ---- src/tui/app.js | 53 +---- src/tui/autocomplete.js | 28 --- src/tui/inputPanel.js | 185 +-------------- tests/unit/autocomplete.test.js | 215 ------------------ tests/unit/tui.test.js | 82 +++---- 13 files changed, 37 insertions(+), 801 deletions(-) delete mode 100644 openspec/changes/at-filepath-autocomplete-tui/.openspec.yaml delete mode 100644 openspec/changes/at-filepath-autocomplete-tui/design.md delete mode 100644 openspec/changes/at-filepath-autocomplete-tui/proposal.md delete mode 100644 openspec/changes/at-filepath-autocomplete-tui/specs/tui-autocomplete/spec.md delete mode 100644 openspec/changes/at-filepath-autocomplete-tui/tasks.md delete mode 100644 src/tui/AutoCompleteDropdown.js delete mode 100644 src/tui/autocomplete.js delete mode 100644 tests/unit/autocomplete.test.js diff --git a/openspec/changes/at-filepath-autocomplete-tui/.openspec.yaml b/openspec/changes/at-filepath-autocomplete-tui/.openspec.yaml deleted file mode 100644 index 4af86417..00000000 --- a/openspec/changes/at-filepath-autocomplete-tui/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-08-14 diff --git a/openspec/changes/at-filepath-autocomplete-tui/design.md b/openspec/changes/at-filepath-autocomplete-tui/design.md deleted file mode 100644 index 3c26022c..00000000 --- a/openspec/changes/at-filepath-autocomplete-tui/design.md +++ /dev/null @@ -1,50 +0,0 @@ -## 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 `@` 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. diff --git a/openspec/changes/at-filepath-autocomplete-tui/proposal.md b/openspec/changes/at-filepath-autocomplete-tui/proposal.md deleted file mode 100644 index 34e293de..00000000 --- a/openspec/changes/at-filepath-autocomplete-tui/proposal.md +++ /dev/null @@ -1,36 +0,0 @@ -## 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 `@` 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. diff --git a/openspec/changes/at-filepath-autocomplete-tui/specs/tui-autocomplete/spec.md b/openspec/changes/at-filepath-autocomplete-tui/specs/tui-autocomplete/spec.md deleted file mode 100644 index 211fe1a6..00000000 --- a/openspec/changes/at-filepath-autocomplete-tui/specs/tui-autocomplete/spec.md +++ /dev/null @@ -1,83 +0,0 @@ -## 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 `@` 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 `@` 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 diff --git a/openspec/changes/at-filepath-autocomplete-tui/tasks.md b/openspec/changes/at-filepath-autocomplete-tui/tasks.md deleted file mode 100644 index ca09cf92..00000000 --- a/openspec/changes/at-filepath-autocomplete-tui/tasks.md +++ /dev/null @@ -1,64 +0,0 @@ -## 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 @ 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 diff --git a/package-lock.json b/package-lock.json index 7b2bc402..d3cdd863 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "cli-table3": "^0.6.5", "cron-parser": "^5.7.0", "deepagents": "^1.12.2", - "fast-glob": "^3.3.3", "ink": "^7.1.1", "ink-scroll-view": "^0.3.7", "ink-spinner": "^5.0.0", diff --git a/package.json b/package.json index 9e69f299..d7b78f65 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,6 @@ "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", diff --git a/src/tui/AutoCompleteDropdown.js b/src/tui/AutoCompleteDropdown.js deleted file mode 100644 index 42e71c8e..00000000 --- a/src/tui/AutoCompleteDropdown.js +++ /dev/null @@ -1,38 +0,0 @@ -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, - ); -} diff --git a/src/tui/app.js b/src/tui/app.js index 1a2b902a..76503875 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Box, useApp, useInput, useWindowSize } from "ink"; import { CommandParser } from "./commandParser.js"; import { ConversationPanel } from "./conversationPanel.js"; @@ -6,7 +6,6 @@ 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"; @@ -40,11 +39,6 @@ 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); @@ -131,39 +125,6 @@ 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) @@ -993,19 +954,7 @@ 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, ); diff --git a/src/tui/autocomplete.js b/src/tui/autocomplete.js deleted file mode 100644 index fa2206c9..00000000 --- a/src/tui/autocomplete.js +++ /dev/null @@ -1,28 +0,0 @@ -import fg from "fast-glob"; - -/** - * Search for files matching a prefix pattern using fast-glob. - * Excludes node_modules, .git, and other .gitignore patterns. - * Limits results to the top 5 matches. - * @param {string} prefix - The prefix to search for (e.g., "src" from "@src") - * @returns {Promise} Array of matching file paths, limited to 5 - */ -export async function searchFiles(prefix) { - if (!prefix || prefix.length === 0) { - return []; - } - - try { - const matches = await fg(`**/${prefix}*`, { - cwd: process.cwd(), - ignore: ["node_modules/**", ".git/**", ".husky/**", "dist/**", "coverage.txt", "memory/**"], - onlyFiles: true, - absolute: false, - }); - - return matches.slice(0, 5); - } catch { - // Silently return empty on search failure — don't crash the TUI - return []; - } -} diff --git a/src/tui/inputPanel.js b/src/tui/inputPanel.js index 6dbd324c..1569f026 100644 --- a/src/tui/inputPanel.js +++ b/src/tui/inputPanel.js @@ -1,12 +1,9 @@ -import React, { useState, useCallback, useEffect } from "react"; +import React from "react"; import TextInput from "ink-text-input"; -import { useInput } from "ink"; -import { searchFiles } from "./autocomplete.js"; /** * Input panel component using ink-text-input for text entry. * Handles text input, cursor navigation, and submission via callbacks. - * Supports @-triggered file path autocomplete mode. * @param {Object} props * @param {string} props.value - Current input text value * @param {(value: string) => void} props.onChange - Callback when value changes @@ -14,185 +11,13 @@ import { searchFiles } from "./autocomplete.js"; * @param {() => void} props.onFocus - Callback when input gains focus * @param {() => void} props.onBlur - Callback when input loses focus * @param {boolean} [props.focus] - Whether the input should be focused - * @param {boolean} [props.inAutocomplete] - Whether autocomplete mode is active - * @param {string} [props.autocompleteQuery] - Current autocomplete query text - * @param {string[]} [props.autocompleteMatches] - List of matching file paths - * @param {number} [props.autocompleteSelectedIndex] - Currently selected index in matches - * @param {(value: string) => void} [props.onAutocompleteSelect] - Callback when a file is selected - * @param {(results: string[]) => void} [props.onAutocompleteResults] - Callback when search results are available * @returns {React.ReactElement} */ -export function InputPanel({ - value = "", - onChange, - onSubmit, - onFocus, - onBlur, - focus = true, - inAutocomplete = false, - autocompleteQuery = "", - autocompleteMatches = [], - autocompleteSelectedIndex = 0, - onAutocompleteSelect, -}) { - const [internalValue, setInternalValue] = useState(value); - const [isAutocompleteMode, setIsAutocompleteMode] = useState(inAutocomplete); - const [query, setQuery] = useState(autocompleteQuery); - const [selectedIndex, setSelectedIndex] = useState(autocompleteSelectedIndex); - - // Sync external state changes - const prevAutocomplete = React.useRef(inAutocomplete); - if (prevAutocomplete.current !== inAutocomplete) { - setIsAutocompleteMode(inAutocomplete); - if (!inAutocomplete) { - setQuery(""); - setSelectedIndex(0); - } - prevAutocomplete.current = inAutocomplete; - } - - const prevQuery = React.useRef(autocompleteQuery); - if (prevQuery.current !== autocompleteQuery) { - setQuery(autocompleteQuery); - } - - const prevIndex = React.useRef(autocompleteSelectedIndex); - if (prevIndex.current !== autocompleteSelectedIndex) { - setSelectedIndex(autocompleteSelectedIndex); - } - - // Debounced search when query changes in autocomplete mode - const searchTimerRef = React.useRef(null); - - useEffect(() => { - if (!isAutocompleteMode || !query) { - return; - } - - // Clear existing timer - if (searchTimerRef.current) { - clearTimeout(searchTimerRef.current); - } - - // Debounce: wait 150ms before searching - searchTimerRef.current = setTimeout(async () => { - const results = await searchFiles(query); - setSelectedIndex(0); - // Notify parent of search results - onAutocompleteResults?.(results); - }, 150); - - return () => { - if (searchTimerRef.current) { - clearTimeout(searchTimerRef.current); - } - }; - }, [isAutocompleteMode, query]); - - // Detect @ trigger and enter autocomplete mode - const handleValueChange = useCallback( - (newValue) => { - setInternalValue(newValue); - // Check if the value contains @ and we're not already in autocomplete mode - if (!isAutocompleteMode && newValue.includes("@")) { - const atIndex = newValue.lastIndexOf("@"); - const afterAt = newValue.slice(atIndex + 1); - if (afterAt.length > 0) { - setIsAutocompleteMode(true); - setQuery(afterAt); - setSelectedIndex(0); - // Notify parent to start search - onChange(newValue.slice(0, atIndex + 1)); - return; - } - } - - // If we're in autocomplete mode and the value no longer has @, exit - if (isAutocompleteMode && !newValue.includes("@")) { - setIsAutocompleteMode(false); - setQuery(""); - setSelectedIndex(0); - } - - onChange(newValue); - }, - [isAutocompleteMode, onChange], - ); - - // Handle keyboard input in autocomplete mode via useInput - useInput( - useCallback( - (key) => { - if (!isAutocompleteMode) return false; - - if (key.name === "up") { - setSelectedIndex((prev) => (prev <= 0 ? autocompleteMatches.length - 1 : prev - 1)); - return true; - } - - if (key.name === "down") { - setSelectedIndex((prev) => (prev >= autocompleteMatches.length - 1 ? 0 : prev + 1)); - return true; - } - - if (key.name === "enter") { - if (autocompleteMatches.length > 0 && onAutocompleteSelect) { - const selectedPath = autocompleteMatches[selectedIndex]; - const atIndex = internalValue.lastIndexOf("@"); - const beforeAt = internalValue.slice(0, atIndex); - const fullPath = beforeAt + selectedPath; - setInternalValue(fullPath); - setIsAutocompleteMode(false); - setQuery(""); - setSelectedIndex(0); - onAutocompleteSelect(fullPath); - } - return true; - } - - if (key.name === "escape") { - setIsAutocompleteMode(false); - setQuery(""); - setSelectedIndex(0); - return true; - } - - return false; - }, - [isAutocompleteMode, autocompleteMatches, selectedIndex, internalValue, onAutocompleteSelect], - ), - { enableSubstitute: true }, - ); - - // Wrap the TextInput's onSubmit to handle autocomplete selection first - const handleOnSubmit = useCallback(() => { - if (isAutocompleteMode && autocompleteMatches.length > 0 && onAutocompleteSelect) { - const selectedPath = autocompleteMatches[selectedIndex]; - const atIndex = internalValue.lastIndexOf("@"); - const beforeAt = internalValue.slice(0, atIndex); - const fullPath = beforeAt + selectedPath; - setInternalValue(fullPath); - setIsAutocompleteMode(false); - setQuery(""); - setSelectedIndex(0); - onAutocompleteSelect(fullPath); - // Don't call the parent onSubmit — let the updated value flow through - return; - } - onSubmit?.(); - }, [ - isAutocompleteMode, - autocompleteMatches, - selectedIndex, - internalValue, - onSubmit, - onAutocompleteSelect, - ]); - +export function InputPanel({ value = "", onChange, onSubmit, onFocus, onBlur, focus = true }) { return React.createElement(TextInput, { - value: internalValue, - onChange: handleValueChange, - onSubmit: handleOnSubmit, + value, + onChange, + onSubmit, onFocus, onBlur, focus, diff --git a/tests/unit/autocomplete.test.js b/tests/unit/autocomplete.test.js deleted file mode 100644 index b7365a81..00000000 --- a/tests/unit/autocomplete.test.js +++ /dev/null @@ -1,215 +0,0 @@ -import { describe, it, mock, beforeEach, afterEach } from "node:test"; -import assert from "node:assert"; -import fs from "node:fs"; -import path from "node:path"; -import os from "os"; -import { InputPanel } from "../../src/tui/inputPanel.js"; -import React from "react"; -import { renderToString } from "ink"; - -// --- autocomplete.js tests --- - -describe("autocomplete - searchFiles", () => { - let tmpDir; - let originalCwd; - - beforeEach(() => { - // Create a temp directory with known files for testing - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "autocomplete-test-")); - // Create test file structure - fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, "src", "foo.js"), ""); - fs.writeFileSync(path.join(tmpDir, "src", "foobar.js"), ""); - fs.writeFileSync(path.join(tmpDir, "src", "bar.js"), ""); - fs.mkdirSync(path.join(tmpDir, "test"), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, "test", "foo.spec.js"), ""); - fs.mkdirSync(path.join(tmpDir, "node_modules"), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, "node_modules", "hidden.js"), ""); - fs.mkdirSync(path.join(tmpDir, ".git"), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, ".git", "config"), ""); - originalCwd = process.cwd(); - }); - - afterEach(() => { - // Clean up temp directory - if (tmpDir) { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - if (originalCwd) { - process.cwd = () => originalCwd; - } - mock.reset(); - }); - - it("returns empty array when prefix is empty", async () => { - const { searchFiles } = await import("../../src/tui/autocomplete.js"); - const result = await searchFiles(""); - assert.deepStrictEqual(result, []); - }); - - it("returns empty array when prefix is undefined", async () => { - const { searchFiles } = await import("../../src/tui/autocomplete.js"); - const result = await searchFiles(undefined); - assert.deepStrictEqual(result, []); - }); - - it("returns empty array when prefix is null", async () => { - const { searchFiles } = await import("../../src/tui/autocomplete.js"); - const result = await searchFiles(null); - assert.deepStrictEqual(result, []); - }); - - it("returns matching files limited to 5", async () => { - // Mock process.cwd to return our temp directory - const originalCwd = process.cwd; - process.cwd = () => tmpDir; - - const { searchFiles } = await import("../../src/tui/autocomplete.js"); - const result = await searchFiles("foo"); - - // Restore cwd - process.cwd = originalCwd; - - assert.ok(Array.isArray(result)); - assert.ok(result.length <= 5); - // Should include src/foo.js and src/foobar.js - assert.ok(result.some((f) => f.includes("foo"))); - // Should NOT include node_modules or .git files - assert.ok(!result.some((f) => f.includes("node_modules"))); - assert.ok(!result.some((f) => f.includes(".git"))); - }); - - it("excludes node_modules and .git directories", async () => { - const originalCwd = process.cwd; - process.cwd = () => tmpDir; - - const { searchFiles } = await import("../../src/tui/autocomplete.js"); - const result = await searchFiles(""); - - process.cwd = originalCwd; - - // Empty prefix returns empty, but we verify the function runs without error - assert.deepStrictEqual(result, []); - }); - - it("returns empty array on fast-glob error", async () => { - // We can't mock fast-glob's default export directly, so we test - // error handling by temporarily making process.cwd return a path - // that causes fg to fail (non-existent directory). - const originalCwd = process.cwd; - process.cwd = () => "/nonexistent/path/that/does/not/exist"; - - const { searchFiles } = await import("../../src/tui/autocomplete.js"); - const result = await searchFiles("foo"); - - process.cwd = originalCwd; - assert.deepStrictEqual(result, []); - }); -}); - -// --- InputPanel component rendering tests --- -// Uses renderToString since ink's render() requires raw stdin mode - -describe("InputPanel - component rendering", () => { - it("renders as a TextInput element", async () => { - const result = renderToString( - React.createElement(InputPanel, { - value: "hello", - onChange: () => {}, - onSubmit: () => {}, - focus: true, - }), - ); - assert.ok(String(result).length > 0, "should produce rendered output"); - }); - - it("renders with @ prefix value", async () => { - const result = renderToString( - React.createElement(InputPanel, { - value: "@src", - onChange: () => {}, - onSubmit: () => {}, - focus: true, - }), - ); - assert.ok(String(result).length > 0, "should render value containing @"); - }); - - it("renders in autocomplete mode", async () => { - const result = renderToString( - React.createElement(InputPanel, { - value: "@src", - inAutocomplete: true, - autocompleteQuery: "src", - autocompleteMatches: ["src/foo.js", "src/bar.js"], - autocompleteSelectedIndex: 0, - onChange: () => {}, - onSubmit: () => {}, - onAutocompleteSelect: () => {}, - focus: true, - }), - ); - assert.ok(String(result).length > 0, "should render in autocomplete mode"); - }); - - it("renders without error when autocomplete props are omitted", async () => { - const result = renderToString( - React.createElement(InputPanel, { - value: "hello", - onChange: () => {}, - onSubmit: () => {}, - focus: true, - }), - ); - assert.ok(String(result).length > 0, "should render without autocomplete props"); - }); - - it("renders with inAutocomplete=false by default", async () => { - const result = renderToString( - React.createElement(InputPanel, { - value: "hello", - onChange: () => {}, - onSubmit: () => {}, - focus: true, - }), - ); - assert.ok(String(result).length > 0, "should render with default inAutocomplete=false"); - }); - - it("renders with unfocused state", async () => { - const result = renderToString( - React.createElement(InputPanel, { - value: "hello", - onChange: () => {}, - onSubmit: () => {}, - focus: false, - }), - ); - assert.ok(String(result).length > 0, "should render when unfocused"); - }); - - it("renders with multiple @ in value", async () => { - const result = renderToString( - React.createElement(InputPanel, { - value: "look at @src/foo.js and @test", - onChange: () => {}, - onSubmit: () => {}, - focus: true, - }), - ); - assert.ok(String(result).length > 0, "should render with multiple @ references"); - }); - - it("renders with empty value", async () => { - const result = renderToString( - React.createElement(InputPanel, { - value: "", - onChange: () => {}, - onSubmit: () => {}, - focus: true, - }), - ); - // TextInput with empty value may render as empty string — just verify no crash - assert.ok(result !== null && result !== undefined, "should not throw on empty value"); - }); -}); diff --git a/tests/unit/tui.test.js b/tests/unit/tui.test.js index 68169bf4..b1118653 100644 --- a/tests/unit/tui.test.js +++ b/tests/unit/tui.test.js @@ -874,65 +874,45 @@ describe("DEFAULT_CONFIG - tui fields", () => { }); describe("InputPanel - component rendering", () => { - it("renders as a TextInput element", async () => { - const { renderToString } = await import("ink"); - const result = renderToString( - React.createElement(InputPanel, { - value: "hello", - onChange: () => {}, - onSubmit: () => {}, - focus: true, - }), - ); - assert.ok(String(result).length >= 0, "should render without error"); + it("renders as a TextInput element", () => { + const result = InputPanel({ + value: "hello", + onChange: () => {}, + onSubmit: () => {}, + focus: true, + }); + assert.ok(React.isValidElement(result)); + assert.strictEqual(result.props.value, "hello"); + assert.strictEqual(result.props.showCursor, true); }); - it("passes onChange callback", async () => { - const { renderToString } = await import("ink"); + it("passes onChange callback", () => { const onChange = () => {}; - const result = renderToString( - React.createElement(InputPanel, { - value: "", - onChange, - onSubmit: () => {}, - }), - ); - assert.ok(String(result).length >= 0, "should render with onChange"); + const result = InputPanel({ value: "", onChange, onSubmit: () => {} }); + assert.strictEqual(result.props.onChange, onChange); }); - it("passes onSubmit callback", async () => { - const { renderToString } = await import("ink"); + it("passes onSubmit callback", () => { const onSubmit = () => {}; - const result = renderToString( - React.createElement(InputPanel, { - value: "", - onChange: () => {}, - onSubmit, - }), - ); - assert.ok(String(result).length >= 0, "should render with onSubmit"); + const result = InputPanel({ value: "", onChange: () => {}, onSubmit }); + assert.strictEqual(result.props.onSubmit, onSubmit); }); - it("respects focus prop", async () => { - const { renderToString } = await import("ink"); - const resultFocused = renderToString( - React.createElement(InputPanel, { - value: "", - onChange: () => {}, - onSubmit: () => {}, - focus: true, - }), - ); - const resultUnfocused = renderToString( - React.createElement(InputPanel, { - value: "", - onChange: () => {}, - onSubmit: () => {}, - focus: false, - }), - ); - assert.ok(String(resultFocused).length >= 0, "should render when focused"); - assert.ok(String(resultUnfocused).length >= 0, "should render when unfocused"); + it("respects focus prop", () => { + const resultFocused = InputPanel({ + value: "", + onChange: () => {}, + onSubmit: () => {}, + focus: true, + }); + assert.strictEqual(resultFocused.props.focus, true); + const resultUnfocused = InputPanel({ + value: "", + onChange: () => {}, + onSubmit: () => {}, + focus: false, + }); + assert.strictEqual(resultUnfocused.props.focus, false); }); });