tests pass

This commit is contained in:
Eason Goodale
2025-04-19 18:49:29 -07:00
parent 0d6a98f9af
commit 35148c2ba9
14 changed files with 783 additions and 6 deletions

View File

@@ -1,3 +1,4 @@
/* eslint-disable import/order */
import type { ReviewDecision } from "../../utils/agent/review.js";
import type { HistoryEntry } from "../../utils/storage/command-history.js";
import type {
@@ -15,13 +16,18 @@ import {
addToHistory,
} from "../../utils/storage/command-history.js";
import { clearTerminal, onExit } from "../../utils/terminal.js";
import Spinner from "../vendor/ink-spinner.js";
import TextInput from "../vendor/ink-text-input.js";
import { Box, Text, useApp, useInput, useStdin } from "ink";
import { fileURLToPath } from "node:url";
import React, { useCallback, useState, Fragment, useEffect } from "react";
import path from "node:path";
import { Box, Text, useApp, useInput, useStdin } from "ink";
import Spinner from "../vendor/ink-spinner.js";
import TextInput from "../vendor/ink-text-input.js";
import { useInterval } from "use-interval";
// Internal imports
// Image picker overlay triggered by "@" sentinel
import ImagePickerOverlay from "./image-picker-overlay.js";
const suggestions = [
"explain this codebase to me",
"fix any build errors",
@@ -67,12 +73,76 @@ export default function TerminalChatInput({
active: boolean;
}): React.ReactElement {
const app = useApp();
//
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
const [input, setInput] = useState("");
const [attachedImages, setAttachedImages] = useState<Array<string>>([]);
// Image picker state null when closed, else current directory
const [pickerCwd, setPickerCwd] = useState<string | null>(null);
const [pickerRoot, setPickerRoot] = useState<string | null>(null);
if (process.env.DEBUG_TCI) {
// eslint-disable-next-line no-console
console.log('[TCI] render stage', { input, pickerCwd, attachedCount: attachedImages.length });
}
// Open picker when user finished typing '@'
React.useEffect(() => {
if (pickerCwd == null && input.endsWith("@")) {
setPickerRoot(process.cwd());
setPickerCwd(process.cwd());
}
}, [input, pickerCwd]);
const [history, setHistory] = useState<Array<HistoryEntry>>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [draftInput, setDraftInput] = useState<string>("");
// ------------------------------------------------------------------
// Fallback rawdata listener (test environment)
// ------------------------------------------------------------------
const { stdin: inkStdin, setRawMode } = useStdin();
React.useEffect(() => {
if (!active) return;
// Ensure raw mode so we actually receive data events.
setRawMode?.(true);
function onData(data: Buffer | string) {
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
if (process.env.DEBUG_TCI) {
// eslint-disable-next-line no-console
console.log('[TCI] raw stdin', JSON.stringify(str));
}
if (str === "@" && pickerCwd == null) {
setPickerRoot(process.cwd());
setPickerCwd(process.cwd());
}
// Ctrl+U (ETB / 0x15) clear all currently attached images. Ink's
// higherlevel `useInput` hook does *not* emit a callback for this
// control sequence when running under the inktestinglibrary, which
// feeds raw bytes directly through `stdin.emit("data", …)`. As a
// result the dedicated handler further below never fires during tests
// even though the real TTY environment works fine. Mirroring the
// behaviour for the raw data path keeps production logic untouched
// while ensuring the unit tests observe the same outcome.
if (str === "\x15" && attachedImages.length > 0) {
setAttachedImages([]);
}
// Handle backspace delete logic when TextInput is empty because in some
// environments (ink-testing-library) `key.backspace` isnt propagated.
if (str === "\x7f" && attachedImages.length > 0 && input.length === 0) {
setAttachedImages((prev) => prev.slice(0, -1));
}
}
inkStdin?.on("data", onData);
return () => inkStdin?.off("data", onData);
}, [inkStdin, active, pickerCwd, attachedImages.length, input]);
// Load command history on component mount
useEffect(() => {
async function loadHistory() {
@@ -85,7 +155,29 @@ export default function TerminalChatInput({
useInput(
(_input, _key) => {
if (process.env.DEBUG_TCI) {
// eslint-disable-next-line no-console
console.log('[TCI] useInput raw', JSON.stringify(_input), _key);
}
// When image picker overlay is open delegate all keystrokes to it.
if (pickerCwd != null) {
return; // ignore here; overlay has its own handlers
}
if (!confirmationPrompt && !loading) {
if (process.env.DEBUG_TCI) {
// eslint-disable-next-line no-console
console.log('useInput received', JSON.stringify(_input));
}
// Open image picker when user types '@' and picker not already open.
if (_input === "@" && pickerCwd == null) {
setPickerRoot(process.cwd());
setPickerCwd(process.cwd());
// Do not earlyreturn we still want the character to appear in the
// input so the trailing '@' can be removed once the image is picked.
}
if (_key.upArrow) {
if (history.length > 0) {
if (historyIndex == null) {
@@ -121,6 +213,21 @@ export default function TerminalChatInput({
}
}
// Ctrl+U clears attachments
if ((_key.ctrl && _input === "u") || _input === "\u0015") {
if (attachedImages.length > 0) {
setAttachedImages([]);
}
return;
}
// Backspace on empty draft removes last attached image
if ((_key.backspace || _input === "\u007f") && attachedImages.length > 0) {
if (input.length === 0) {
setAttachedImages((prev) => prev.slice(0, -1));
}
}
if (input.trim() === "" && isNew) {
if (_key.tab) {
setSelectedSuggestion(
@@ -297,6 +404,11 @@ export default function TerminalChatInput({
);
text = text.trim();
// Merge images detected from text with those explicitly attached via picker.
if (attachedImages.length > 0) {
images.push(...attachedImages);
}
const inputItem = await createInputItem(text, images);
submitInput([inputItem]);
@@ -315,6 +427,7 @@ export default function TerminalChatInput({
setDraftInput("");
setSelectedSuggestion(0);
setInput("");
setAttachedImages([]);
},
[
setInput,
@@ -328,6 +441,7 @@ export default function TerminalChatInput({
openApprovalOverlay,
openModelOverlay,
openHelpOverlay,
attachedImages,
history, // Add history to the dependency array
onCompact,
],
@@ -343,9 +457,52 @@ export default function TerminalChatInput({
);
}
if (pickerCwd != null && pickerRoot != null) {
return (
<ImagePickerOverlay
rootDir={pickerRoot}
cwd={pickerCwd}
onCancel={() => setPickerCwd(null)}
onChangeDir={(dir) => setPickerCwd(dir)}
onPick={(filePath) => {
// Remove trailing '@' sentinel from draft input
setInput((prev) => (prev.endsWith("@") ? prev.slice(0, -1) : prev));
// Track attachment separately
setAttachedImages((prev) => [...prev, filePath]);
if (process.env.DEBUG_TCI) {
// eslint-disable-next-line no-console
console.log('[TCI] attached image added', filePath, 'total', attachedImages.length + 1);
}
setPickerCwd(null);
}}
/>
);
}
// Attachment preview component
const AttachmentPreview = () => {
if (attachedImages.length === 0) {
return null;
}
if (process.env.DEBUG_TCI) {
// eslint-disable-next-line no-console
console.log('[TCI] render AttachmentPreview', attachedImages);
}
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
<Text color="gray">attached images (ctrl+u to clear):</Text>
{attachedImages.map((p, i) => (
<Text key={i} color="cyan">{` ${path.basename(p)}`}</Text>
))}
</Box>
);
};
return (
<Box flexDirection="column">
<Box borderStyle="round">
<Box borderStyle="round" flexDirection="column">
<AttachmentPreview />
{loading ? (
<TerminalChatInputThinking
onInterrupt={interruptAgent}
@@ -364,6 +521,15 @@ export default function TerminalChatInput({
showCursor
value={input}
onChange={(value) => {
// eslint-disable-next-line no-console
if (process.env.DEBUG_TCI) console.log('onChange', JSON.stringify(value));
// Detect trailing "@" to open image picker.
if (pickerCwd == null && value.endsWith("@")) {
// Open image picker immediately
setPickerRoot(process.cwd());
setPickerCwd(process.cwd());
}
setDraftInput(value);
if (historyIndex != null) {
setHistoryIndex(null);