/**
* ChatInput — Custom input with bracketed paste + image attachments
*
* Replaces ink-text-input with raw stdin handling to fix:
* - Paste losing content on enter (newlines in paste triggered submit)
* - Multi-line paste mangled/unformatted
* - No drag-drop image support
*
* Features:
* - Bracketed paste mode — clean multi-line paste
* - Drag-drop images — detects image file paths, attaches as chips
* - Image chips above input like Claude Code: [image1.png] [image2.jpg]
* - Backspace on empty input removes last image
* - Slash command menu preserved
* - Multi-line input with ⎸ continuation markers
*/
import { useState, useEffect, useRef } from "react";
import { Box, Text, useInput, useStdin } from "ink";
import SelectInput from "ink-select-input";
import { readFileSync, existsSync, statSync } from "fs";
import { basename, extname } from "path";
import { colors } from "../shared/Theme.js";
import { loadKeybindings, bindingToControlChar } from "../services/keybinding-manager.js";
// ── Types ──
export interface ImageAttachment {
path: string;
name: string;
base64: string;
mediaType: string;
}
export interface FileAttachment {
path: string;
name: string;
}
export interface SlashCommand {
name: string;
description: string;
}
export const SLASH_COMMANDS: SlashCommand[] = [
{ name: "/help", description: "Show available commands" },
{ name: "/tools", description: "List all tools" },
{ name: "/model", description: "Switch model (Anthropic/Bedrock/Gemini)" },
{ name: "/compact", description: "Compress conversation context" },
{ name: "/save", description: "Save session to disk" },
{ name: "/sessions", description: "List saved sessions" },
{ name: "/resume", description: "Resume a saved session" },
{ name: "/mcp", description: "Server connection status" },
{ name: "/store", description: "Switch active store" },
{ name: "/status", description: "Show session info" },
{ name: "/agents", description: "List available agent types" },
{ name: "/remember", description: "Remember a fact across sessions" },
{ name: "/forget", description: "Forget a remembered fact" },
{ name: "/memory", description: "List all remembered facts" },
{ name: "/mode", description: "Permission mode (default/plan/yolo)" },
{ name: "/thinking", description: "Toggle extended thinking" },
{ name: "/rewind", description: "Rewind conversation to earlier point" },
{ name: "/init", description: "Generate project config (.whale/CLAUDE.md)" },
{ name: "/update", description: "Check for updates & install" },
{ name: "/clear", description: "Clear conversation" },
{ name: "/exit", description: "Exit" },
];
// ── Constants ──
const IMAGE_EXTENSIONS: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
};
const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB
const MAX_DISPLAY_LINES = 8;
// ── Helpers ──
function dividerLine(): string {
const w = (process.stdout.columns || 80) - 2;
return "─".repeat(Math.max(20, w));
}
function isImagePath(text: string): boolean {
const p = normalizePath(text);
if (!p || p.includes("\n")) return false;
const ext = extname(p).toLowerCase();
return ext in IMAGE_EXTENSIONS && existsSync(p);
}
/** Normalize a terminal-pasted path (handles escapes, file:// URLs, quotes) */
function normalizePath(raw: string): string {
let p = raw.trim();
// Strip file:// URL prefix + percent-decode
if (p.startsWith("file://")) {
p = decodeURIComponent(p.replace(/^file:\/\//, ""));
}
// Strip surrounding quotes
p = p.replace(/^['"]|['"]$/g, "");
// Unescape backslash-escaped characters (spaces, parens, etc.)
p = p.replace(/\\(.)/g, "$1");
return p;
}
function isFilePath(text: string): boolean {
const p = normalizePath(text);
if (!p || p.includes("\n")) return false;
// Skip image paths — handled separately
const ext = extname(p).toLowerCase();
if (ext in IMAGE_EXTENSIONS) return false;
try { return existsSync(p) && statSync(p).isFile(); } catch { return false; }
}
function loadImage(filePath: string): ImageAttachment | null {
try {
const p = normalizePath(filePath);
const ext = extname(p).toLowerCase();
const mediaType = IMAGE_EXTENSIONS[ext];
if (!mediaType) return null;
const stat = statSync(p);
if (stat.size > MAX_IMAGE_SIZE) return null;
const data = readFileSync(p);
return { path: p, name: basename(p), base64: data.toString("base64"), mediaType };
} catch {
return null;
}
}
function getCursorLineCol(text: string, cursor: number): { line: number; col: number } {
let pos = 0;
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
if (cursor <= pos + lines[i].length) {
return { line: i, col: cursor - pos };
}
pos += lines[i].length + 1;
}
return { line: lines.length - 1, col: lines[lines.length - 1].length };
}
// ── Props ──
interface ChatInputProps {
onSubmit: (message: string, images?: ImageAttachment[]) => void;
onCommand: (command: string) => void;
disabled: boolean;
agentName?: string;
}
// ── Component ──
export function ChatInput({ onSubmit, onCommand, disabled, agentName }: ChatInputProps) {
const { stdin } = useStdin();
// Input state — ref for synchronous handler access, state for render
const inputRef = useRef({ value: "", cursor: 0 });
const [displayValue, setDisplayValue] = useState("");
const [displayCursor, setDisplayCursor] = useState(0);
// Mode & attachments
const [menuMode, setMenuMode] = useState(false);
const [menuFilter, setMenuFilter] = useState("");
const [images, setImages] = useState<ImageAttachment[]>([]);
const imagesRef = useRef<ImageAttachment[]>([]);
const [files, setFiles] = useState<FileAttachment[]>([]);
const filesRef = useRef<FileAttachment[]>([]);
// Input history (up/down arrow recall)
const historyRef = useRef<string[]>([]);
const historyIndexRef = useRef(-1);
const savedInputRef = useRef("");
const MAX_HISTORY = 50;
// Paste tracking
const isPasting = useRef(false);
const pasteBuffer = useRef("");
// Multi-line paste — stored separately, displayed as chip
const [pastedText, setPastedText] = useState<string | null>(null);
const pastedTextRef = useRef<string | null>(null);
// Sync refs
useEffect(() => { imagesRef.current = images; }, [images]);
useEffect(() => { filesRef.current = files; }, [files]);
useEffect(() => { pastedTextRef.current = pastedText; }, [pastedText]);
// ── Enable bracketed paste mode ──
useEffect(() => {
process.stdout.write("\x1b[?2004h");
return () => { process.stdout.write("\x1b[?2004l"); };
}, []);
// ── Update helper ──
function update(value: string, cursor: number) {
inputRef.current = { value, cursor };
setDisplayValue(value);
setDisplayCursor(cursor);
}
// ── Process paste content ──
function processPaste(text: string) {
const clean = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Check if ALL non-empty lines are image paths → attach them
const lines = clean.split("\n").map(l => l.trim()).filter(Boolean);
if (lines.length > 0 && lines.every(l => isImagePath(l))) {
const newImages: ImageAttachment[] = [];
for (const line of lines) {
const img = loadImage(line);
if (img) newImages.push(img);
}
if (newImages.length > 0) {
setImages(prev => [...prev, ...newImages]);
return;
}
}
// Check for non-image file paths → attach as file chips
if (lines.length > 0 && lines.length <= 4 && lines.every(l => isFilePath(l))) {
const newFiles: FileAttachment[] = lines.map(l => {
const p = normalizePath(l);
return { path: p, name: basename(p) };
});
setFiles(prev => [...prev, ...newFiles]);
return;
}
// Slash command pasted directly (must look like /word, not an absolute path)
if (clean.startsWith("/") && !clean.includes("\n") && !clean.includes("/", 1) && inputRef.current.value === "") {
onCommand(clean.trim());
update("", 0);
return;
}
// Multi-line paste → store as chip, don't spam input
const pasteLines = clean.split("\n");
if (pasteLines.length >= 4) {
setPastedText(clean);
return;
}
// Short paste — insert inline as before
const { value: val, cursor: cur } = inputRef.current;
const newValue = val.slice(0, cur) + clean + val.slice(cur);
update(newValue, cur + clean.length);
}
// ── Handle submit ──
function handleSubmit() {
const typed = inputRef.current.value.trim();
const paste = pastedTextRef.current;
const imgs = imagesRef.current;
const fls = filesRef.current;
if (!typed && !paste && imgs.length === 0 && fls.length === 0) return;
if (typed.startsWith("/") && !paste && imgs.length === 0 && fls.length === 0) {
update("", 0);
onCommand(typed);
return;
}
// Build file context prefix
const filePrefix = fls.length > 0
? fls.map(f => f.path).join("\n") + "\n\n"
: "";
// Combine file prefix + typed text + pasted content
const body = paste
? (typed ? `${typed}\n\n${paste}` : paste)
: typed;
const fullText = filePrefix + body;
// Record in history
if (fullText.trim()) {
historyRef.current.push(fullText.trim());
if (historyRef.current.length > MAX_HISTORY) historyRef.current.shift();
}
historyIndexRef.current = -1;
onSubmit(fullText, imgs.length > 0 ? imgs : undefined);
update("", 0);
setImages([]);
setFiles([]);
setPastedText(null);
}
// ── Raw stdin handler ──
useEffect(() => {
if (!stdin || disabled || menuMode) return;
const onData = (data: Buffer) => {
const str = data.toString("utf-8");
// ── Bracketed paste detection ──
if (str.includes("\x1b[200~")) {
isPasting.current = true;
let text = str.replace(/\x1b\[200~/g, "").replace(/\x1b\[201~/g, "");
pasteBuffer.current += text;
if (str.includes("\x1b[201~")) {
isPasting.current = false;
const paste = pasteBuffer.current;
pasteBuffer.current = "";
processPaste(paste);
}
return;
}
if (isPasting.current) {
if (str.includes("\x1b[201~")) {
isPasting.current = false;
pasteBuffer.current += str.replace(/\x1b\[201~/g, "");
const paste = pasteBuffer.current;
pasteBuffer.current = "";
processPaste(paste);
} else {
pasteBuffer.current += str;
}
return;
}
// ── Control chars handled by ChatApp (keybinding-aware) ──
const _kb = loadKeybindings();
const exitChar = bindingToControlChar(_kb.exit);
const expandChar = bindingToControlChar(_kb.toggle_expand);
const thinkingChar = bindingToControlChar(_kb.toggle_thinking);
if (str === exitChar || str === expandChar || str === thinkingChar) return;
// ── Enter ──
if (str === "\r" || str === "\n") {
handleSubmit();
return;
}
// ── Tab ── (no-op in input)
if (str === "\t") return;
// ── Backspace ──
if (str === "\x7f" || str === "\b") {
const { value: val, cursor: cur } = inputRef.current;
if (cur > 0) {
update(val.slice(0, cur - 1) + val.slice(cur), cur - 1);
} else if (val === "") {
// Empty input + backspace → remove paste chip, then files, then images
if (pastedTextRef.current) {
setPastedText(null);
} else if (filesRef.current.length > 0) {
setFiles(prev => prev.slice(0, -1));
} else if (imagesRef.current.length > 0) {
setImages(prev => prev.slice(0, -1));
}
}
return;
}
// ── Clear line (default: Ctrl+U) ──
if (str === bindingToControlChar(_kb.clear_line)) {
update("", 0);
setImages([]);
setFiles([]);
setPastedText(null);
return;
}
// ── Delete word (default: Ctrl+W) ──
if (str === bindingToControlChar(_kb.delete_word)) {
const { value: val, cursor: cur } = inputRef.current;
const before = val.slice(0, cur);
const match = before.match(/\S+\s*$/);
if (match) {
const len = match[0].length;
update(val.slice(0, cur - len) + val.slice(cur), cur - len);
}
return;
}
// ── Home (default: Ctrl+A) ──
if (str === bindingToControlChar(_kb.home)) {
update(inputRef.current.value, 0);
return;
}
// ── Escape sequences ──
if (str.startsWith("\x1b[")) {
const { value: val, cursor: cur } = inputRef.current;
if (str === "\x1b[C") { // Right
update(val, Math.min(cur + 1, val.length));
} else if (str === "\x1b[D") { // Left
update(val, Math.max(cur - 1, 0));
} else if (str === "\x1b[H" || str === "\x1b[1~") { // Home
update(val, 0);
} else if (str === "\x1b[F" || str === "\x1b[4~") { // End
update(val, val.length);
} else if (str === "\x1b[3~") { // Delete
if (cur < val.length) {
update(val.slice(0, cur) + val.slice(cur + 1), cur);
}
} else if (str === "\x1b[A" || str === "\x1b[B") { // Up/Down
const lines = val.split("\n");
if (lines.length > 1) {
// Multi-line: navigate lines
const { line: curLine, col: curCol } = getCursorLineCol(val, cur);
const targetLine = str === "\x1b[A"
? Math.max(0, curLine - 1)
: Math.min(lines.length - 1, curLine + 1);
if (targetLine !== curLine) {
const targetCol = Math.min(curCol, lines[targetLine].length);
let newCursor = 0;
for (let i = 0; i < targetLine; i++) newCursor += lines[i].length + 1;
newCursor += targetCol;
update(val, newCursor);
}
} else if (str === "\x1b[A") {
// Up arrow — history recall
if (historyRef.current.length === 0) { /* no history */ }
else {
if (historyIndexRef.current === -1) savedInputRef.current = val;
const newIdx = Math.min(historyIndexRef.current + 1, historyRef.current.length - 1);
historyIndexRef.current = newIdx;
const hist = historyRef.current[historyRef.current.length - 1 - newIdx];
update(hist, hist.length);
}
} else {
// Down arrow — forward in history
if (historyIndexRef.current <= -1) { /* already at current */ }
else {
const newIdx = historyIndexRef.current - 1;
historyIndexRef.current = newIdx;
if (newIdx < 0) {
update(savedInputRef.current, savedInputRef.current.length);
} else {
const hist = historyRef.current[historyRef.current.length - 1 - newIdx];
update(hist, hist.length);
}
}
}
}
// Ignore unrecognized escape sequences
return;
}
// ── Standalone escape — clear input if non-empty ──
if (str === "\x1b") {
const { value } = inputRef.current;
if (value || filesRef.current.length > 0) {
update("", 0);
setImages([]);
setFiles([]);
setPastedText(null);
}
return;
}
// ── Multi-character non-escape = paste without brackets ──
if (str.length > 1 && !str.startsWith("\x1b")) {
const codePoints = [...str];
if (codePoints.length === 1) {
// Single code point (emoji etc.)
const { value: val, cursor: cur } = inputRef.current;
update(val.slice(0, cur) + str + val.slice(cur), cur + str.length);
} else {
processPaste(str);
}
return;
}
// ── Single printable character ──
if (str.length === 1 && str.charCodeAt(0) >= 0x20) {
const { value: val, cursor: cur } = inputRef.current;
// Slash menu trigger
if (str === "/" && val === "") {
setMenuMode(true);
setMenuFilter("");
return;
}
update(val.slice(0, cur) + str + val.slice(cur), cur + 1);
}
};
stdin.on("data", onData);
return () => { stdin.off("data", onData); };
}, [stdin, disabled, menuMode]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Menu input — filter + dismiss (uses Ink's useInput for SelectInput compatibility) ──
useInput((input, key) => {
if (!menuMode || disabled) return;
if (key.escape) {
setMenuMode(false);
setMenuFilter("");
return;
}
if (key.backspace || key.delete) {
setMenuFilter(prev => {
if (prev.length > 0) return prev.slice(0, -1);
setMenuMode(false);
return "";
});
return;
}
// Printable character → append to filter
if (input && !key.ctrl && !key.meta && !key.return && !key.tab) {
setMenuFilter(prev => prev + input);
}
}, { isActive: menuMode });
const handleMenuSelect = (item: { label: string; value: string }) => {
setMenuMode(false);
setMenuFilter("");
update("", 0);
onCommand(item.value);
};
// ── Render ──
const divider = dividerLine();
// Disabled — minimal divider
if (disabled) {
return (
<Box flexDirection="column">
<Text>{" "}</Text>
<Text color={colors.separator}>{divider}</Text>
</Box>
);
}
// Slash command menu
if (menuMode) {
const q = menuFilter.toLowerCase();
const filtered = q
? SLASH_COMMANDS.filter(c => c.name.includes(q) || c.description.toLowerCase().includes(q))
: SLASH_COMMANDS;
const items = filtered.map((c) => ({
label: c.name,
value: c.name,
}));
return (
<Box flexDirection="column">
<Text>{" "}</Text>
<Text color={colors.separator}>{divider}</Text>
<Box>
<Text color={colors.brand} bold>{"/ "}</Text>
{menuFilter ? (
<Text>
{menuFilter}
<Text inverse>{" "}</Text>
</Text>
) : (
<Text>
<Text inverse>{" "}</Text>
<Text color={colors.dim}> type to filter</Text>
</Text>
)}
</Box>
{items.length > 0 ? (
<SelectInput
items={items}
onSelect={handleMenuSelect}
indicatorComponent={({ isSelected = false }: { isSelected?: boolean }) => (
<Text color={isSelected ? colors.brand : colors.quaternary}>
{isSelected ? "›" : " "}{" "}
</Text>
)}
itemComponent={({ isSelected = false, label = "" }: { isSelected?: boolean; label?: string }) => {
const cmd = SLASH_COMMANDS.find((c) => c.name === label);
return (
<Text>
<Text color={isSelected ? colors.brand : colors.secondary} bold={isSelected}>
{label}
</Text>
<Text color={colors.tertiary}> {cmd?.description}</Text>
</Text>
);
}}
/>
) : (
<Text color={colors.tertiary}> no matching commands</Text>
)}
<Text color={colors.quaternary}> esc to dismiss</Text>
</Box>
);
}
// ── Normal input rendering ──
const lines = displayValue.split("\n");
const { line: cursorLine, col: cursorCol } = displayValue
? getCursorLineCol(displayValue, displayCursor)
: { line: 0, col: 0 };
// Truncate display for very long pastes
const isTruncated = lines.length > MAX_DISPLAY_LINES;
const visibleLines = isTruncated
? [...lines.slice(0, MAX_DISPLAY_LINES - 1), `… ${lines.length - MAX_DISPLAY_LINES + 1} more lines`]
: lines;
return (
<Box flexDirection="column">
<Text>{" "}</Text>
{/* Attachment chips — images + files + pasted text */}
{(images.length > 0 || files.length > 0 || pastedText) && (
<Box>
<Text> </Text>
{images.map((img, i) => (
<Text key={`img-${i}`}>
<Text color={colors.indigo}>[</Text>
<Text color={colors.secondary}>{img.name}</Text>
<Text color={colors.indigo}>]</Text>
<Text> </Text>
</Text>
))}
{files.map((f, i) => (
<Text key={`file-${i}`}>
<Text color={colors.purple}>[</Text>
<Text color={colors.secondary}>{f.name}</Text>
<Text color={colors.purple}>]</Text>
<Text> </Text>
</Text>
))}
{pastedText && (
<Text>
<Text color={colors.indigo}>[</Text>
<Text color={colors.secondary}>pasted {pastedText.split("\n").length} lines</Text>
<Text color={colors.indigo}>]</Text>
</Text>
)}
</Box>
)}
<Text color={colors.separator}>{divider}</Text>
<Text>{" "}</Text>
{/* Single-line input */}
{lines.length <= 1 ? (
<Box>
<Text color={colors.brand} bold>{"❯ "}</Text>
{!displayValue ? (
<Text>
<Text inverse> </Text>
<Text color={colors.dim}>{`Message ${agentName || "whale"}, or type / for commands`}</Text>
</Text>
) : (
<Text>
{displayValue.slice(0, displayCursor)}
<Text inverse>{displayCursor < displayValue.length ? displayValue[displayCursor] : " "}</Text>
{displayCursor < displayValue.length ? displayValue.slice(displayCursor + 1) : ""}
</Text>
)}
</Box>
) : (
/* Multi-line input */
<Box flexDirection="column">
{visibleLines.map((line, i) => {
const isRealLine = !isTruncated || i < MAX_DISPLAY_LINES - 1;
const isCursorOnLine = isRealLine && i === cursorLine;
return (
<Box key={i}>
<Text color={colors.brand} bold>{i === 0 ? "❯ " : "⎸ "}</Text>
{isCursorOnLine ? (
<Text>
{line.slice(0, cursorCol)}
<Text inverse>{cursorCol < line.length ? line[cursorCol] : " "}</Text>
{cursorCol < line.length ? line.slice(cursorCol + 1) : ""}
</Text>
) : (
<Text color={isRealLine ? undefined : colors.tertiary}>{line}</Text>
)}
</Box>
);
})}
</Box>
)}
</Box>
);
}