Repository navigation
Conversation
Add full cursor navigation to the shell input prompt: - Arrow keys for character-by-character movement - Ctrl+Arrow for word-boundary navigation - Home/End for line start/end - Shift+Arrow for text selection - Ctrl+A to select all - Selection-aware typing, backspace, and delete - Visual cursor and selection rendering (inverse highlighting) Export getPrevWordBoundary and getNextWordBoundary helpers for testing. Add 23 unit tests covering word boundaries, insert, delete, and selection logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix delete key at end-of-line incorrectly doing backspace (now no-op) - Remove dead isBackspaceChar branch inside key.delete block - Remove unused prompt prop from InputPromptProps interface - Extract getSelectionStateSnapshot() to eliminate 3-way duplication - Route Ctrl+A through applyInputState for consistency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR upgrades the CLI’s Ink InputPrompt from an append-only input to a cursor-aware editor with word navigation and selection support.
Changes:
- Added cursor position + selection tracking, plus rendering for cursor/selection highlighting in
InputPrompt. - Introduced word-boundary helpers (
getPrevWordBoundary,getNextWordBoundary) and re-exported them from the components barrel. - Added a new unit test file intended to cover word boundaries and editing/selection behaviors.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
packages/squad-cli/src/cli/shell/components/InputPrompt.tsx |
Adds cursor/selection state management, word-boundary navigation, selection-aware insert/delete, and updated rendering. |
packages/squad-cli/src/cli/shell/components/index.ts |
Re-exports the new word-boundary helpers from InputPrompt. |
test/input-prompt-cursor.test.ts |
Adds tests for word-boundary helpers and (currently) local-only edit/selection helper logic. |
Comments suppressed due to low confidence (1)
packages/squad-cli/src/cli/shell/components/InputPrompt.tsx:12
promptwas removed fromInputPromptProps, but there are still callers passing it (e.g.test/repl-ux.test.tscallsh(InputPrompt, { …, prompt: 'squad> ' })). This will cause TypeScript compilation/test failures unless those call sites are updated or the prop is kept (even if ignored).
interface InputPromptProps {
onSubmit: (value: string) => void;
disabled?: boolean;
agentNames?: string[];
/** Number of messages exchanged so far — drives progressive hint text. */
messageCount?: number;
}
| const [history, setHistory] = useState<string[]>([]); | ||
| const [historyIndex, setHistoryIndex] = useState(-1); |
There was a problem hiding this comment.
history and historyIndex React state are no longer read anywhere in this component (navigation uses historyRef/historyIndexRef instead). Keeping unused state adds confusion and risks drift between the ref and state; consider removing the unused state vars (and corresponding setHistoryIndex calls) or switching back to state reads consistently.
| const [history, setHistory] = useState<string[]>([]); | |
| const [historyIndex, setHistoryIndex] = useState(-1); |
| @@ -0,0 +1,147 @@ | |||
| import { describe, expect, it } from 'vitest'; | |||
| import { getNextWordBoundary, getPrevWordBoundary } from '@bradygaster/squad-cli/shell/components'; | |||
There was a problem hiding this comment.
This import path likely won’t resolve: @bradygaster/squad-cli has an exports map, but it doesn’t currently include a ./shell/components subpath. Either add that export (and ensure it points at dist/cli/shell/components/index.js) or import these helpers from a subpath that is already exported.
| import { getNextWordBoundary, getPrevWordBoundary } from '@bradygaster/squad-cli/shell/components'; | |
| import { getNextWordBoundary, getPrevWordBoundary } from '@bradygaster/squad-cli'; |
| import { getNextWordBoundary, getPrevWordBoundary } from '@bradygaster/squad-cli/shell/components'; | ||
|
|
||
| type Selection = { start: number; end: number }; | ||
|
|
||
| function insertText(value: string, cursorPos: number, text: string): { value: string; cursorPos: number } { | ||
| const nextValue = value.slice(0, cursorPos) + text + value.slice(cursorPos); | ||
| return { value: nextValue, cursorPos: cursorPos + text.length }; | ||
| } | ||
|
|
||
| function replaceSelection( | ||
| value: string, | ||
| selection: Selection, | ||
| replacement: string, | ||
| ): { value: string; cursorPos: number } { | ||
| const nextValue = value.slice(0, selection.start) + replacement + value.slice(selection.end); | ||
| return { value: nextValue, cursorPos: selection.start + replacement.length }; | ||
| } | ||
|
|
||
| function backspace(value: string, cursorPos: number): { value: string; cursorPos: number } { | ||
| if (cursorPos === 0) return { value, cursorPos }; | ||
| const nextValue = value.slice(0, cursorPos - 1) + value.slice(cursorPos); | ||
| return { value: nextValue, cursorPos: cursorPos - 1 }; | ||
| } | ||
|
|
||
| function del(value: string, cursorPos: number): { value: string; cursorPos: number } { | ||
| if (cursorPos >= value.length) return { value, cursorPos }; | ||
| const nextValue = value.slice(0, cursorPos) + value.slice(cursorPos + 1); | ||
| return { value: nextValue, cursorPos }; | ||
| } | ||
|
|
||
| function deleteSelection(value: string, selection: Selection): { value: string; cursorPos: number } { | ||
| const nextValue = value.slice(0, selection.start) + value.slice(selection.end); | ||
| return { value: nextValue, cursorPos: selection.start }; | ||
| } | ||
|
|
There was a problem hiding this comment.
Most of the new “cursor edit behavior” tests exercise locally-defined helper functions (insertText, backspace, del, etc.) rather than the actual InputPrompt implementation or exported editor helpers. That means these tests can still pass even if the real component logic regresses. Consider extracting the edit/selection logic into exported pure functions and testing those, or drive the component via ink-testing-library to validate real behavior.
| import { getNextWordBoundary, getPrevWordBoundary } from '@bradygaster/squad-cli/shell/components'; | |
| type Selection = { start: number; end: number }; | |
| function insertText(value: string, cursorPos: number, text: string): { value: string; cursorPos: number } { | |
| const nextValue = value.slice(0, cursorPos) + text + value.slice(cursorPos); | |
| return { value: nextValue, cursorPos: cursorPos + text.length }; | |
| } | |
| function replaceSelection( | |
| value: string, | |
| selection: Selection, | |
| replacement: string, | |
| ): { value: string; cursorPos: number } { | |
| const nextValue = value.slice(0, selection.start) + replacement + value.slice(selection.end); | |
| return { value: nextValue, cursorPos: selection.start + replacement.length }; | |
| } | |
| function backspace(value: string, cursorPos: number): { value: string; cursorPos: number } { | |
| if (cursorPos === 0) return { value, cursorPos }; | |
| const nextValue = value.slice(0, cursorPos - 1) + value.slice(cursorPos); | |
| return { value: nextValue, cursorPos: cursorPos - 1 }; | |
| } | |
| function del(value: string, cursorPos: number): { value: string; cursorPos: number } { | |
| if (cursorPos >= value.length) return { value, cursorPos }; | |
| const nextValue = value.slice(0, cursorPos) + value.slice(cursorPos + 1); | |
| return { value: nextValue, cursorPos }; | |
| } | |
| function deleteSelection(value: string, selection: Selection): { value: string; cursorPos: number } { | |
| const nextValue = value.slice(0, selection.start) + value.slice(selection.end); | |
| return { value: nextValue, cursorPos: selection.start }; | |
| } | |
| import { | |
| getNextWordBoundary, | |
| getPrevWordBoundary, | |
| insertText, | |
| replaceSelection, | |
| backspace, | |
| del, | |
| deleteSelection, | |
| } from '@bradygaster/squad-cli/shell/components'; | |
| type Selection = { start: number; end: number }; |
| const isBackspaceChar = input === '\u007f' || input === '\b'; | ||
| // Some terminal/input stacks report Backspace as `key.delete=true` with empty input. | ||
| // Treat that specific end-of-line shape as backspace compatibility handling. | ||
| const isLikelyBackspaceViaDeleteKey = | ||
| key.delete && | ||
| !key.backspace && | ||
| !input && | ||
| cursorPosRef.current > 0 && | ||
| cursorPosRef.current === valueRef.current.length; | ||
|
|
There was a problem hiding this comment.
Backspace/Delete detection here is not reliable with Ink useInput: many terminals send Backspace as DEL (0x7f), and Ink reports that as key.delete (with input often blanked). With the current branching, Backspace-in-the-middle can be misinterpreted as forward-delete, and the special-case can also reintroduce “Delete at end acts like Backspace”. Consider treating key.delete as backspace-like delete-left (Ink’s common approach), or switch to raw stdin parsing if you need distinct forward-delete semantics.
|
@copilot apply changes based on the comments in this thread |
| const [history, setHistory] = useState<string[]>([]); | ||
| const [historyIndex, setHistoryIndex] = useState(-1); | ||
| const [spinFrame, setSpinFrame] = useState(0); |
There was a problem hiding this comment.
history and historyIndex state values are no longer read anywhere (only the setters are used), since the handlers now consult historyRef/historyIndexRef. This is dead state and can be confusing; it may also trip lint rules. Either remove these useState variables entirely and rely on refs, or start using the state values consistently and keep the refs in sync via an effect.
| selectionAnchorRef.current = null; | ||
| setCursorPos(0); | ||
| setSelectionAnchor(null); |
There was a problem hiding this comment.
In the disabled→enabled restore effect, the else branch clears valueRef.current but never updates the value state (only cursor/selection state). If value was non-empty before disabling, the UI can re-render with stale text while the cursor is reset to 0. Update the value state (and any other relevant state) in this branch to keep refs and state consistent.
| selectionAnchorRef.current = null; | |
| setCursorPos(0); | |
| setSelectionAnchor(null); | |
| selectionAnchorRef.current = null; | |
| bufferRef.current = ''; | |
| setValue(''); | |
| setCursorPos(0); | |
| setSelectionAnchor(null); | |
| setBufferDisplay(''); |
| <Box flexDirection="column"> | ||
| <Box> | ||
| <Text color={noColor ? undefined : 'cyan'} bold>{narrow ? 'sq> ' : '◆ squad> '}</Text> | ||
| <Text>{value}</Text> | ||
| <Text color={noColor ? undefined : 'cyan'} bold>▌</Text> | ||
| {hasSelection ? ( |
There was a problem hiding this comment.
The prompt is now hard-coded to sq> /◆ squad> and the prompt prop was removed. There is at least one existing test (test/repl-ux.test.ts) that expects a custom prompt prop to change what is rendered; with this change that behavior will fail. Either keep supporting prompt (even if only for tests) or update the test/any callers and remove the now-stale expectations.
Summary
Overhauls the InputPrompt shell component from an append-only text input to a full cursor-aware editor.
New capabilities
Code quality (from deep review)
Testing
Files changed