diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 9189466a..1dab131e 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -387,9 +387,9 @@ importers: ink: specifier: ^6.8.0 version: 6.8.0(@types/react@19.2.14)(react@19.2.4) - ink-text-input: - specifier: ^6.0.0 - version: 6.0.0(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + ink-multiline-input: + specifier: ^0.1.0 + version: 0.1.0(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) marked: specifier: ^15.0.12 version: 15.0.12 @@ -536,10 +536,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.28.6': - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -4667,11 +4663,10 @@ packages: resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} engines: {node: '>=10'} - ink-text-input@6.0.0: - resolution: {integrity: sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==} - engines: {node: '>=18'} + ink-multiline-input@0.1.0: + resolution: {integrity: sha512-GmIF5YdaxmT5pHwI67yTDe5UDId20WeGJx39zwaYJ6SwMrlioRRMn+BxklsAUYsvA6BJfh2kpHNR1Kan69UTxw==} peerDependencies: - ink: '>=5' + ink: '>=6' react: ^19.2.4 ink@6.8.0: @@ -6816,10 +6811,6 @@ packages: resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} engines: {node: '>=10'} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - type-fest@5.5.0: resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} engines: {node: '>=20'} @@ -7397,8 +7388,6 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/runtime@7.28.6': {} - '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -9851,7 +9840,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -12139,12 +12128,10 @@ snapshots: ini@2.0.0: {} - ink-text-input@6.0.0(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4): + ink-multiline-input@0.1.0(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4): dependencies: - chalk: 5.6.2 ink: 6.8.0(@types/react@19.2.14)(react@19.2.4) react: 19.2.4 - type-fest: 4.41.0 ink@6.8.0(@types/react@19.2.14)(react@19.2.4): dependencies: @@ -14691,8 +14678,6 @@ snapshots: type-fest@1.4.0: {} - type-fest@4.41.0: {} - type-fest@5.5.0: dependencies: tagged-tag: 1.0.0 diff --git a/ui/text/package.json b/ui/text/package.json index ac3da11b..703e3e56 100644 --- a/ui/text/package.json +++ b/ui/text/package.json @@ -30,10 +30,10 @@ "lint": "tsc --noEmit" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.14.1", "@aaif/goose-acp": "workspace:*", + "@agentclientprotocol/sdk": "^0.14.1", "ink": "^6.8.0", - "ink-text-input": "^6.0.0", + "ink-multiline-input": "^0.1.0", "marked": "^15.0.12", "marked-terminal": "^7.3.0", "meow": "^13.2.0", diff --git a/ui/text/src/tui.tsx b/ui/text/src/tui.tsx index aa9897b2..ec20e97c 100644 --- a/ui/text/src/tui.tsx +++ b/ui/text/src/tui.tsx @@ -1,7 +1,7 @@ #!/usr/bin/env node import React, { useState, useEffect, useCallback, useRef } from "react"; import { Box, Text, render, useApp, useInput, useStdout } from "ink"; -import TextInput from "ink-text-input"; +import { MultilineInput } from "ink-multiline-input"; import meow from "meow"; import { spawn } from "node:child_process"; import { Readable, Writable } from "node:stream"; @@ -178,6 +178,35 @@ function Header({ ); } +const PASTE_THRESHOLD = 80; +const PASTE_PREVIEW_LEN = 40; +const INPUT_MAX_ROWS = 8; +const SENT_PREVIEW_LEN = 60; + +function collapseForDisplay(text: string, availableWidth = PASTE_PREVIEW_LEN): string { + const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim(); + if (flat.length <= availableWidth) return flat; + const suffix = ` (${flat.length.toLocaleString()} chars)`; + const previewLen = Math.max(availableWidth - suffix.length - 1, 10); + return flat.slice(0, previewLen) + "…" + suffix; +} + +function collapsedUserPrompt(text: string, width: number): React.ReactElement { + const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim(); + const maxPreview = Math.max(width - 30, SENT_PREVIEW_LEN); + if (flat.length <= maxPreview + 10) { + return {flat}; + } + const preview = flat.slice(0, maxPreview) + "…"; + const remaining = flat.length - maxPreview; + return ( + + {preview} + ({remaining.toLocaleString()} more chars) + + ); +} + function InputBar({ width, input, @@ -186,6 +215,9 @@ function InputBar({ queued, scrollHint, placeholder, + focused, + pastedFull, + onPastedFullChange, }: { width: number; input: string; @@ -194,7 +226,65 @@ function InputBar({ queued: boolean; scrollHint: boolean; placeholder?: string; + focused: boolean; + pastedFull: string | null; + onPastedFullChange: (v: string | null) => void; }) { + const prevLenRef = useRef(input.length); + + const handleChange = useCallback( + (newValue: string) => { + const delta = newValue.length - prevLenRef.current; + prevLenRef.current = newValue.length; + if (delta >= PASTE_THRESHOLD) { + onPastedFullChange(newValue); + onChange(newValue); + } else { + if (pastedFull !== null) onPastedFullChange(null); + onChange(newValue); + } + }, + [onChange, pastedFull, onPastedFullChange], + ); + + const handleSubmit = useCallback( + (value: string) => { + prevLenRef.current = 0; + onPastedFullChange(null); + onSubmit(value); + }, + [onSubmit, onPastedFullChange], + ); + + useInput( + (ch, key) => { + if (key.return) { + handleSubmit(input); + return; + } + if (key.backspace || key.delete) { + prevLenRef.current = 0; + onPastedFullChange(null); + onChange(""); + return; + } + if (key.escape) { + prevLenRef.current = 0; + onPastedFullChange(null); + onChange(""); + return; + } + if (ch && !key.ctrl && !key.meta) { + prevLenRef.current = ch.length; + onPastedFullChange(null); + onChange(ch); + } + }, + { isActive: focused && pastedFull !== null }, + ); + + const isPasteMode = pastedFull !== null; + return ( - - - {"❯ "} - - - {scrollHint && shift+↑↓ history} + + {"❯ "} + {isPasteMode ? ( + + + {collapseForDisplay(pastedFull, width - 4 - 2)} + + {scrollHint && shift+↑↓ history} + + ) : ( + + key.return && !key.ctrl, + newline: (key) => key.return && key.ctrl, + }} + useCustomInput={(handler, isActive) => { + useInput((ch, key) => { + if (key.shift && (key.upArrow || key.downArrow)) return; + handler(ch, key); + }, { isActive }); + }} + /> + {scrollHint && shift+↑↓ history} + + )} + {isPasteMode && ( + + + enter to send · esc to clear + + + )} {queued && ( @@ -315,12 +434,11 @@ function buildContentLines({ const lines: React.ReactElement[] = []; if (!turn) return lines; - // User prompt lines.push(emptyLine("u-gap", width)); lines.push( {"❯ "} - {turn.userText} + {collapsedUserPrompt(turn.userText, width - 4)} , ); @@ -458,12 +576,14 @@ function Viewport({ function SplashScreen({ animFrame, width, + height, status, loading, spinIdx, }: { animFrame: number; width: number; + height: number; status: string; loading: boolean; spinIdx: number; @@ -472,14 +592,19 @@ function SplashScreen({ const statusColor = status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM; + const contentHeight = frame.length + 1 + 1 + 1 + 2 + 1; + + const topPad = Math.max(0, Math.floor((height - contentHeight) / 2)); + return ( + {topPad > 0 && } {frame.map((line, i) => ( {line} @@ -524,6 +649,7 @@ function App({ const [viewTurnIdx, setViewTurnIdx] = useState(-1); const [toolCallsExpanded, setToolCallsExpanded] = useState(false); const [scrollOffset, setScrollOffset] = useState(0); + const [pastedFull, setPastedFull] = useState(null); const clientRef = useRef(null); const sessionIdRef = useRef(null); @@ -778,6 +904,7 @@ function App({ const trimmed = value.trim(); if (!trimmed) return; setInput(""); + setPastedFull(null); setViewTurnIdx(-1); setToolCallsExpanded(false); setScrollOffset(0); @@ -795,6 +922,7 @@ function App({ useInput((ch, key) => { if (key.escape || (ch === "c" && key.ctrl)) { if (pendingPermission) { resolvePermission("cancelled"); return; } + if (key.escape && pastedFull !== null) return; exit(); } @@ -818,6 +946,10 @@ function App({ return; } + const viewingHistory = viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1; + const multilineOwnsArrows = + !pendingPermission && !initialPrompt && !viewingHistory && pastedFull === null; + if (key.tab) { const idx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx; const t = turns[idx]; @@ -828,11 +960,11 @@ function App({ } if (key.upArrow && !key.shift) { - setScrollOffset((prev) => prev + 3); + if (!multilineOwnsArrows) setScrollOffset((prev) => prev + 3); return; } if (key.downArrow && !key.shift) { - setScrollOffset((prev) => Math.max(prev - 3, 0)); + if (!multilineOwnsArrows) setScrollOffset((prev) => Math.max(prev - 3, 0)); return; } @@ -872,7 +1004,15 @@ function App({ const showInputBar = !pendingPermission && !initialPrompt && !isViewingHistory; const headerH = 2; - const inputBarH = showInputBar ? (queuedMessages.length > 0 ? 4 : 3) : 0; + const isPasteMode = pastedFull !== null; + const inputContentRows = showInputBar + ? isPasteMode + ? 1 + : Math.min(Math.max(input.split("\n").length, 1), INPUT_MAX_ROWS) + : 0; + const inputExtraLines = + (isPasteMode ? 1 : 0) + (queuedMessages.length > 0 ? 1 : 0); + const inputBarH = showInputBar ? 2 + inputContentRows + inputExtraLines : 0; const historyBarH = isViewingHistory ? 2 : 0; const viewportHeight = Math.max( termHeight - PAD_Y * 2 - headerH - inputBarH - historyBarH, @@ -903,6 +1043,7 @@ function App({ 0} scrollHint={!bannerVisible && turns.length > 1} placeholder={bannerVisible ? INITIAL_GREETING : undefined} + focused={showInputBar} + pastedFull={pastedFull} + onPastedFullChange={setPastedFull} /> )}