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}
/>
)}