From ff2631107aa39788cea9ee27fdc81b81a3eb45d6 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Tue, 8 Sep 2026 21:09:28 +0530 Subject: [PATCH 1/3] release: v6.8.5 --- CHANGELOG.md | 12 ++ package.json | 2 +- src/ui/App.tsx | 130 ++++++++++---- src/ui/components/InputBox.tsx | 69 +++++-- src/ui/components/ScrollToBottomChip.tsx | 60 +++++++ src/ui/components/Toast.tsx | 26 +++ src/ui/components/TranscriptViewport.tsx | 8 +- src/ui/components/rows.tsx | 23 +-- src/utils/clipboard.ts | 48 +++-- test/ui-viewport.test.tsx | 218 ++++++++++++++++++++++- 10 files changed, 509 insertions(+), 87 deletions(-) create mode 100644 src/ui/components/ScrollToBottomChip.tsx create mode 100644 src/ui/components/Toast.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b61336..2eb96b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.8.5] - 2026-09-08 + +### Added + +- **Auto-copy on text selection with toast notification.** Selecting text in the CLI TUI now automatically copies the selected text to the system clipboard (with fallback to OSC 52 terminal clipboard) and displays a floating toast notification ("✓ Text copied to clipboard") in the corner of the terminal that automatically dismisses after two seconds. +- **Scroll-to-bottom hover chip.** When scrolled up in an active task (`effectiveScrollOffset > 0`), a floating hover chip (`↓ Scroll to bottom`) appears centered above the composer. Hovering highlights the chip, and clicking it (or pressing Esc when input is idle) smoothly snaps the viewport back to the live transcript edge. + +### Fixed + +- **Working animation not triggered during response content streaming.** When reasoning finished and the model began streaming the final response content buffer, the "Working..." spinner animation failed to appear because the loading indicator was suppressed while `streamingText` was non-empty. In addition, `text-delta` set the busy state to "Responding" instead of "Working". Fixed by keeping the "Working..." spinner active below the streaming response text and accounting for its height during response streaming, ensuring the spinner animation runs throughout content generation. +- **Terminal line overlapping when pasting large or multiline text.** When a multiline or large text was pasted or submitted, `TranscriptViewport`'s `justifyContent="flex-end"` caused Yoga flexbox to squash row containers and assign overlapping vertical coordinates to subsequent transcript rows and streaming text, resulting in permanent character and line overlap. Fixed by driving transcript alignment through negative `marginTop` derived from `maxScrollOffset`, disabling flexbox squashing (`flexShrink={0}` on row wrappers), expanding tab characters in user blocks to prevent unexpected terminal wrapping, capping prompt input display height, and collapsing multi-line pastes (3+ lines) and large pastes (200+ characters) into paste chips. + ## [6.8.4] - 2026-09-05 ### Fixed diff --git a/package.json b/package.json index ccd3348..2388958 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.8.4", + "version": "6.8.5", "description": "OrbCode CLI — agentic coding in your terminal, by MatterAI", "type": "module", "bin": { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 02e3d60..169a345 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -5,7 +5,11 @@ import React, { useRef, useState, } from "react"; -import { useTerminalDimensions } from "@opentui/react"; +import { + useRenderer, + useSelectionHandler, + useTerminalDimensions, +} from "@opentui/react"; import { Box, Text, useApp, useInput } from "./primitives.js"; import { useTheme, @@ -100,6 +104,9 @@ import { getTranscriptPlacement, TranscriptViewport, } from "./components/TranscriptViewport.js"; +import { ScrollToBottomChip } from "./components/ScrollToBottomChip.js"; +import { Toast } from "./components/Toast.js"; +import { copyToClipboard } from "../utils/clipboard.js"; import { addLink, loadLinks, @@ -387,9 +394,30 @@ export function App({ updateCheck?: Promise; }) { const { exit } = useApp(); + const renderer = useRenderer(); const theme = useTheme(); const { mode: themeMode, setMode: setThemeMode } = useThemeMode(); const { width: termCols, height: termRows } = useTerminalDimensions(); + const [toast, setToast] = useState<{ message: string; id: number } | null>(null); + const toastTimerRef = useRef | null>(null); + + const showToast = useCallback((message: string) => { + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setToast({ message, id: Date.now() }); + toastTimerRef.current = setTimeout(() => { + setToast(null); + toastTimerRef.current = null; + }, 2000); + }, []); + + useSelectionHandler((selection) => { + try { + const text = selection.getSelectedText(); + if (!text || text.trim().length === 0) return; + copyToClipboard(text, renderer); + showToast(termCols < 40 ? "✓ Text copied" : "✓ Text copied to clipboard"); + } catch {} + }); const [settings, setSettings] = useState(() => loadSettings(), ); @@ -582,6 +610,9 @@ export function App({ if (exitConfirmationTimerRef.current !== null) { clearTimeout(exitConfirmationTimerRef.current); } + if (toastTimerRef.current !== null) { + clearTimeout(toastTimerRef.current); + } }, [], ); @@ -643,7 +674,7 @@ export function App({ case "text-delta": textBufferRef.current += event.text; setStreamingText(textBufferRef.current); - setBusyLabel("Responding"); + setBusyLabel("Working"); break; case "text-done": pushRow({ kind: "assistant", text: textBufferRef.current }); @@ -1740,13 +1771,23 @@ export function App({ const streamingReasoningDisplay = streamingReasoning ? tailForHeight(streamingReasoning, 3, reasoningWrapWidth) : ""; + const spinnerVisible = + busy && + !pendingApproval && + !pendingFollowup && + !pendingHookTrust && + !pendingMcpApproval && + !mcpPickerOpen && + !mcpMigrationEntries && + !streamingReasoning; + const spinnerHeight = spinnerVisible ? 2 : 0; // Do not lay out the entire accumulated response on every token. Keep only // the live tail mounted; text-done commits the complete response to the // virtualized transcript, so nothing is lost from history. const streamingTextDisplay = streamingText ? tailForHeight( streamingText, - Math.max(1, contentHeight - 1), + Math.max(1, contentHeight - 1 - spinnerHeight), Math.max(20, wrapWidth - 2), ) : ""; @@ -1791,17 +1832,7 @@ export function App({ if (pendingMcpApproval) { dynamicHeight += 5 + Math.min(8, pendingMcpApproval.length); } - if ( - busy && - !pendingApproval && - !pendingFollowup && - !pendingHookTrust && - !pendingMcpApproval && - !mcpPickerOpen && - !mcpMigrationEntries && - !streamingText && - !streamingReasoning - ) { + if (spinnerVisible) { dynamicHeight += 2; } @@ -1880,6 +1911,13 @@ export function App({ // line always remains above the composer even when an earlier row wrapped to // more lines than estimated. Estimates still drive virtualization/scrolling. const anchorTranscriptToBottom = transcriptPlacement.anchorToBottom; + const inTask = + view === "chat" && + (rows.length > 1 || + busy || + taskLines.length > 0 || + Boolean(streamingText) || + Boolean(streamingReasoning)); useEffect(() => { setScrollOffset((current) => Math.min(current, maxScrollOffset)); @@ -1916,13 +1954,11 @@ export function App({ minHeight={0} overflow="hidden" > - + {virtualRows.rows.map((row) => ( @@ -2019,24 +2055,17 @@ export function App({ onApprove={resolveMcpApproval} /> )} - {busy && - !pendingApproval && - !pendingFollowup && - !pendingHookTrust && - !pendingMcpApproval && - !mcpPickerOpen && - !mcpMigrationEntries && - !streamingText && - !streamingReasoning && ( - - - - )} + {spinnerVisible && ( + + + + )} @@ -2089,6 +2118,25 @@ export function App({ )} + {inTask && effectiveScrollOffset > 0 && ( + + { + smoothScrollPendingRef.current = 0; + setScrollOffset(0); + }} + /> + + )} {popoverOpen && ( )} + {toast && ( + + + + )} ); } diff --git a/src/ui/components/InputBox.tsx b/src/ui/components/InputBox.tsx index 6e3e39f..a5dcb09 100644 --- a/src/ui/components/InputBox.tsx +++ b/src/ui/components/InputBox.tsx @@ -33,10 +33,12 @@ interface InputBoxProps { const MAX_FILE_MATCHES = 8 const POPUP_PADDING_X = 2 -// Pastes at least this long are collapsed into a paste chip shown above the +// Pastes at least this long or with multiple lines are collapsed into a paste chip shown above the // prompt. The full text is merged back into the message at the recorded cursor // position on submit, as if the text had been pasted there directly. -const PASTE_CHIP_THRESHOLD = 500 +const PASTE_CHIP_THRESHOLD = 200 +const PASTE_CHIP_LINE_THRESHOLD = 3 +const MAX_PROMPT_HEIGHT = 8 // Two newlines separate a merged chip's text from the surrounding prompt text. const PASTE_CHIP_SEPARATOR = "\n\n" @@ -340,11 +342,13 @@ export function InputBox({ active, width, slashCommands, onSubmit, supportsImage // Large text pastes become a chip; smaller ones are inserted inline. const insertPaste = (input: string) => { - if (input.length >= PASTE_CHIP_THRESHOLD) { - addPasteChip(input.replace(/\r\n?/g, "\n"), cursorRef.current) + const normalized = input.replace(/\r\n?/g, "\n") + const lineCount = normalized.split("\n").length + if (normalized.length >= PASTE_CHIP_THRESHOLD || lineCount >= PASTE_CHIP_LINE_THRESHOLD) { + addPasteChip(normalized, cursorRef.current) return } - insertPastedText(input) + insertPastedText(normalized) } const handlePaste = (input: string, kind?: "text" | "binary" | "unknown") => { @@ -525,15 +529,58 @@ export function InputBox({ active, width, slashCommands, onSubmit, supportsImage // Border (2) + wrapped prompt content. The prompt glyph occupies two // columns beside the editable text, while border and padding consume four. const editableWidth = Math.max(1, width - 6) - const promptHeight = plainDisplay.split("\n").reduce( + const rawPromptHeight = plainDisplay.split("\n").reduce( (sum, line) => sum + Math.max(1, Math.ceil(Math.max(1, line.length) / editableWidth)), 0, ) + const promptHeight = Math.min(MAX_PROMPT_HEIGHT, rawPromptHeight) const slashPopupHeight = slashMatches.length > 0 ? slashMatches.length + 3 : 0 const filePopupHeight = fileMatches.length > 0 ? fileMatches.length + 3 : 0 const attachmentRows = attachments.length + pasteChips.length + (attachmentMessage ? 1 : 0) const renderedHeight = 2 + promptHeight + attachmentRows + slashPopupHeight + filePopupHeight + const displayContent = useMemo(() => { + if (!active) { + return {value || "waiting…"} + } + const allLines = value.split("\n") + if (allLines.length <= MAX_PROMPT_HEIGHT) { + return ( + <> + {value.slice(0, cursor)} + {value[cursor] ?? " "} + {value.slice(cursor + 1)} + + ) + } + const linesBeforeCursor = value.slice(0, cursor).split("\n") + const cursorLineIdx = linesBeforeCursor.length - 1 + const startLine = Math.max( + 0, + Math.min(cursorLineIdx - Math.floor(MAX_PROMPT_HEIGHT / 2), allLines.length - MAX_PROMPT_HEIGHT), + ) + const endLine = startLine + MAX_PROMPT_HEIGHT + const windowedLines = allLines.slice(startLine, endLine) + + let charOffset = 0 + for (let i = 0; i < startLine; i++) { + charOffset += allLines[i].length + 1 + } + const windowedText = windowedLines.join("\n") + const relCursor = cursor - charOffset + + if (relCursor >= 0 && relCursor <= windowedText.length) { + return ( + <> + {windowedText.slice(0, relCursor)} + {windowedText[relCursor] ?? " "} + {windowedText.slice(relCursor + 1)} + + ) + } + return <>{windowedText} + }, [active, value, cursor]) + // Parent viewport calculations must use the real bottom-stack height. A // layout effect updates it before OpenTUI paints the next frame, preventing a // multiline prompt or popup from covering the live response above it. @@ -626,15 +673,7 @@ export function InputBox({ active, width, slashCommands, onSubmit, supportsImage {"❯ "} - {active ? ( - <> - {value.slice(0, cursor)} - {value[cursor] ?? " "} - {value.slice(cursor + 1)} - - ) : ( - {value || "waiting…"} - )} + {displayContent} diff --git a/src/ui/components/ScrollToBottomChip.tsx b/src/ui/components/ScrollToBottomChip.tsx new file mode 100644 index 0000000..ffceea8 --- /dev/null +++ b/src/ui/components/ScrollToBottomChip.tsx @@ -0,0 +1,60 @@ +import React, { useState } from "react"; +import { Box, Text } from "../primitives.js"; +import { useTheme } from "../theme.js"; + +export interface ScrollToBottomChipProps { + scrollOffset: number; + width?: number; + onClick: () => void; +} + +export function ScrollToBottomChip({ + scrollOffset, + width = 80, + onClick, +}: ScrollToBottomChipProps) { + const theme = useTheme(); + const [hovered, setHovered] = useState(false); + + const label = + width < 35 + ? `↓ Bottom${scrollOffset > 1 ? ` (${scrollOffset})` : ""}` + : `↓ Scroll to bottom${scrollOffset > 1 ? ` (${scrollOffset})` : ""}`; + + return ( + { + e.stopPropagation?.(); + onClick(); + }} + onMouseUp={(e) => { + e.stopPropagation?.(); + onClick(); + }} + onMouseMove={(e) => { + e.stopPropagation?.(); + if (!hovered) setHovered(true); + }} + > + { + e.stopPropagation?.(); + onClick(); + }} + onMouseUp={(e) => { + e.stopPropagation?.(); + onClick(); + }} + > + {label} + + + ); +} diff --git a/src/ui/components/Toast.tsx b/src/ui/components/Toast.tsx new file mode 100644 index 0000000..a7e577f --- /dev/null +++ b/src/ui/components/Toast.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Box, Text } from "../primitives.js"; +import { COLORS } from "../../branding.js"; +import { useTheme } from "../theme.js"; + +export interface ToastProps { + message: string; +} + +export function Toast({ message }: ToastProps) { + const theme = useTheme(); + const successColor = theme.success ?? COLORS.success; + + return ( + + + {message} + + + ); +} diff --git a/src/ui/components/TranscriptViewport.tsx b/src/ui/components/TranscriptViewport.tsx index ef58470..98f92a0 100644 --- a/src/ui/components/TranscriptViewport.tsx +++ b/src/ui/components/TranscriptViewport.tsx @@ -25,13 +25,9 @@ export function getTranscriptPlacement({ return { anchorToBottom: false, marginTop: 0 }; } - if (scrollOffset === 0) { - return { anchorToBottom: true, marginTop: 0 }; - } - - const maxScrollOffset = transcriptHeight - contentHeight; + const maxScrollOffset = Math.max(0, transcriptHeight - contentHeight); return { - anchorToBottom: false, + anchorToBottom: scrollOffset === 0, marginTop: -(maxScrollOffset - Math.min(scrollOffset, maxScrollOffset)), }; } diff --git a/src/ui/components/rows.tsx b/src/ui/components/rows.tsx index 1365062..60d195b 100644 --- a/src/ui/components/rows.tsx +++ b/src/ui/components/rows.tsx @@ -180,11 +180,12 @@ export function formatUserBlock(text: string, width: number, attachments: Attach const lineWidth = Math.max(1, width) const paddingX = Math.min(2, Math.floor((lineWidth - 1) / 2)) const contentWidth = Math.max(1, lineWidth - paddingX * 2) + const normalizedText = (text || "").replace(/\t/g, " ") const attachmentLines = attachments.map( (attachment) => ` 📎 ${attachment.name}${attachment.kind === "image" ? " · image" : ""}${attachment.truncated ? " · truncated" : ""}`, ) - const sourceLines = [`❯ ${text || (attachments.length > 0 ? "Attached files" : "")}`, ...attachmentLines].flatMap( + const sourceLines = [`❯ ${normalizedText || (attachments.length > 0 ? "Attached files" : "")}`, ...attachmentLines].flatMap( (line) => line.split("\n"), ) const blank = " ".repeat(lineWidth) @@ -217,7 +218,7 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w return
case "user": return ( - + {formatUserBlock(row.text, width, row.attachments)} @@ -225,7 +226,7 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w ) case "assistant": return ( - + {renderMarkdown(row.text.trimEnd())} @@ -234,13 +235,13 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w ) case "reasoning": return ( - + ✦ Thought for {formatDuration(row.durationMs)} {!row.expanded && (ctrl+o to show thinking)} {row.expanded && ( - + {row.text.trim()} @@ -250,7 +251,7 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w ) case "tool": return ( - + {row.isError ? "✗" : "✓"}{" "} @@ -259,12 +260,12 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w {row.summary} {row.diff ? ( - + ) : ( row.resultPreview && ( - + {row.resultPreview} ) @@ -273,19 +274,19 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w ) case "info": return ( - + {row.text} ) case "error": return ( - + ✗ {row.text} ) case "completion": return ( - + ✔ Task completed diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index 0311f01..8a17a5a 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -3,18 +3,29 @@ import { platform } from "node:os" /** * Copy text to the system clipboard. Uses the platform's native clipboard - * utility (pbcopy on macOS, xclip/xsel on Linux, clip on Windows). Returns - * true on success, false if no clipboard utility is available. + * utility (pbcopy on macOS, wl-copy/xclip/xsel on Linux, clip on Windows). + * Also writes to the terminal clipboard via OSC 52 if a renderer is provided. + * Returns true on success, false if no clipboard mechanism succeeded. */ -export function copyToClipboard(text: string): boolean { +export function copyToClipboard( + text: string, + renderer?: { copyToClipboardOSC52?(text: string): boolean }, +): boolean { + let success = false const cmd = clipboardCommand() - if (!cmd) return false - try { - execSync(cmd, { input: text, stdio: ["pipe", "ignore", "ignore"] }) - return true - } catch { - return false + if (cmd) { + try { + execSync(cmd, { input: text, stdio: ["pipe", "ignore", "ignore"] }) + success = true + } catch {} + } + if (renderer?.copyToClipboardOSC52) { + try { + const oscSuccess = renderer.copyToClipboardOSC52(text) + if (oscSuccess) success = true + } catch {} } + return success } /** Detect the platform's clipboard command, or null if none is available. */ @@ -22,18 +33,23 @@ function clipboardCommand(): string | null { const p = platform() if (p === "darwin") return "pbcopy" if (p === "win32") return "clip" - // Linux: try xclip, then xsel. We can't check availability without spawning, - // so prefer xclip (more common on modern distros) and fall back to xsel. + // Linux: try wl-copy (Wayland), then xclip, then xsel. We can't check + // availability without spawning, so prefer wl-copy/xclip and fall back to xsel. if (p === "linux") { try { - execSync("which xclip", { stdio: "ignore" }) - return "xclip -selection clipboard" + execSync("which wl-copy", { stdio: "ignore" }) + return "wl-copy" } catch { try { - execSync("which xsel", { stdio: "ignore" }) - return "xsel --clipboard --input" + execSync("which xclip", { stdio: "ignore" }) + return "xclip -selection clipboard" } catch { - return null + try { + execSync("which xsel", { stdio: "ignore" }) + return "xsel --clipboard --input" + } catch { + return null + } } } } diff --git a/test/ui-viewport.test.tsx b/test/ui-viewport.test.tsx index bdc9e07..7f21d9b 100644 --- a/test/ui-viewport.test.tsx +++ b/test/ui-viewport.test.tsx @@ -1,10 +1,14 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import React, { act } from "react"; +import React, { act, useState } from "react"; import { testRender } from "@opentui/react/test-utils"; +import { useSelectionHandler } from "@opentui/react"; import { Box, Text } from "../src/ui/primitives.js"; import { InputBox } from "../src/ui/components/InputBox.js"; +import { ScrollToBottomChip } from "../src/ui/components/ScrollToBottomChip.js"; +import { Toast } from "../src/ui/components/Toast.js"; +import { copyToClipboard } from "../src/utils/clipboard.js"; import { Spinner, TIP_DELAY_MS, @@ -36,7 +40,7 @@ test("bottom-anchors only after the transcript exceeds the viewport", () => { scrollOffset: 0, transcriptHeight: 21, }), - { anchorToBottom: true, marginTop: 0 }, + { anchorToBottom: true, marginTop: -1 }, ); }); @@ -132,3 +136,213 @@ test("treats a raw linefeed as Shift+Enter in the composer", async () => { act(() => screen.renderer.destroy()); } }); + +test("collapses multiline and large pastes into paste chips to avoid terminal overflow", async () => { + let submittedText = ""; + const screen = await testRender( + { + submittedText = typeof val === "string" ? val : val.text; + }} + supportsImages={false} + />, + { width: 80, height: 24 }, + ); + + try { + await screen.renderOnce(); + await act(async () => { + screen.mockInput.pasteBracketedText("line 1\nline 2\nline 3\nline 4\nline 5"); + await new Promise((r) => setTimeout(r, 50)); + await screen.flush(); + }); + + const frame = screen.captureCharFrame(); + assert.match(frame, /📋 line 1 line 2/); + + await act(async () => { + screen.mockInput.pressEnter(); + await screen.flush(); + }); + + assert.equal(submittedText, "line 1\nline 2\nline 3\nline 4\nline 5"); + } finally { + act(() => screen.renderer.destroy()); + } +}); + +test("renders scroll-to-bottom chip when scrolled up in a task and clears on click", async () => { + function TaskView() { + const [scrollOffset, setScrollOffset] = useState(10); + const inTask = true; + const bottomControlsHeight = 3; + return ( + + + Transcript content + + + Input + + {inTask && scrollOffset > 0 && ( + + setScrollOffset(0)} + /> + + )} + + ); + } + + const screen = await testRender(, { width: 60, height: 15 }); + try { + await screen.renderOnce(); + let frame = screen.captureCharFrame(); + assert.match(frame, /↓ Scroll to bottom \(10\)/); + + // Click the chip + await act(async () => { + await screen.mockMouse.click(30, 9); + await new Promise((r) => setTimeout(r, 50)); + await screen.flush(); + }); + + frame = screen.captureCharFrame(); + assert.doesNotMatch(frame, /↓ Scroll to bottom/); + } finally { + act(() => screen.renderer.destroy()); + } +}); + +test("renders Toast component with success border and message", async () => { + const screen = await testRender( + , + { width: 50, height: 5 }, + ); + try { + await screen.renderOnce(); + const frame = screen.captureCharFrame(); + assert.match(frame, /✓ Text copied to clipboard/); + } finally { + act(() => screen.renderer.destroy()); + } +}); + +test("auto-copies selected text and displays toast notification on selection", async () => { + let copiedText = ""; + function SelectionView() { + const [toast, setToast] = useState(null); + useSelectionHandler((selection) => { + const text = selection.getSelectedText(); + if (!text || text.trim().length === 0) return; + copiedText = text; + setToast("✓ Text copied to clipboard"); + }); + + return ( + + Selected sample text for clipboard + {toast && ( + + + + )} + + ); + } + + const screen = await testRender(, { width: 60, height: 10 }); + try { + await screen.renderOnce(); + let frame = screen.captureCharFrame(); + assert.doesNotMatch(frame, /✓ Text copied/); + + // Select text by dragging across the line + await act(async () => { + await screen.mockMouse.drag(0, 0, 15, 0); + await new Promise((r) => setTimeout(r, 50)); + await screen.flush(); + }); + + frame = screen.captureCharFrame(); + assert.match(frame, /✓ Text copied to clipboard/); + assert.ok(copiedText.length > 0); + } finally { + act(() => screen.renderer.destroy()); + } +}); + +test("copyToClipboard invokes OSC 52 on the renderer when provided", () => { + let oscText = ""; + const mockRenderer = { + copyToClipboardOSC52(text: string) { + oscText = text; + return true; + }, + }; + const result = copyToClipboard("hello clipboard", mockRenderer); + assert.equal(result, true); + assert.equal(oscText, "hello clipboard"); +}); + +test("renders Working spinner below streaming response text while busy", async () => { + function StreamingView({ + busy, + busyLabel, + streamingText, + }: { + busy: boolean; + busyLabel: string; + streamingText: string; + }) { + const spinnerVisible = busy; + return ( + + {streamingText && ( + + + + {streamingText} + + + )} + {spinnerVisible && ( + + + + )} + + ); + } + + const screen = await testRender( + , + { width: 60, height: 10 }, + ); + + try { + await screen.renderOnce(); + const frame = screen.captureCharFrame(); + assert.match(frame, /● Streaming response content\.\.\./); + assert.match(frame, /Working \(0s · esc to interrupt\)/); + } finally { + act(() => screen.renderer.destroy()); + } +}); From df5e94f277ae4a75760c5ad2c080a9bf70025de7 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Tue, 8 Sep 2026 22:17:15 +0530 Subject: [PATCH 2/3] fix(ui): address PR review suggestions - Show the selection-copy toast only when a clipboard mechanism actually succeeded (copyToClipboard return value was ignored). - Window the prompt display by wrapped rows using the same math as the promptHeight cap, and render the prompt glyph in its own column so text wraps at exactly editableWidth; rendered height now always matches the height reported via onHeightChange. Covered by a new regression test. - Fire ScrollToBottomChip onClick once per press (removed duplicate onMouseUp handlers). - Update the stale transcript-anchoring comment to describe the estimate-driven negative-margin layout and drop the unused anchorTranscriptToBottom variable. --- CHANGELOG.md | 6 +- src/ui/App.tsx | 14 +-- src/ui/components/InputBox.tsx | 104 +++++++++++++++-------- src/ui/components/ScrollToBottomChip.tsx | 8 -- test/ui-viewport.test.tsx | 43 ++++++++++ 5 files changed, 121 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eb96b6..f8eb323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Auto-copy on text selection with toast notification.** Selecting text in the CLI TUI now automatically copies the selected text to the system clipboard (with fallback to OSC 52 terminal clipboard) and displays a floating toast notification ("✓ Text copied to clipboard") in the corner of the terminal that automatically dismisses after two seconds. -- **Scroll-to-bottom hover chip.** When scrolled up in an active task (`effectiveScrollOffset > 0`), a floating hover chip (`↓ Scroll to bottom`) appears centered above the composer. Hovering highlights the chip, and clicking it (or pressing Esc when input is idle) smoothly snaps the viewport back to the live transcript edge. +- **Auto-copy on text selection with toast notification.** Selecting text in the CLI TUI now automatically copies the selected text to the system clipboard (with fallback to OSC 52 terminal clipboard) and displays a floating toast notification ("✓ Text copied to clipboard") in the corner of the terminal that automatically dismisses after two seconds. The toast only appears when a clipboard mechanism actually succeeded. +- **Scroll-to-bottom hover chip.** When scrolled up in an active task (`effectiveScrollOffset > 0`), a floating hover chip (`↓ Scroll to bottom`) appears centered above the composer. Hovering highlights the chip, and clicking it (or pressing Esc when input is idle) smoothly snaps the viewport back to the live transcript edge. The click handler fires exactly once per press. ### Fixed - **Working animation not triggered during response content streaming.** When reasoning finished and the model began streaming the final response content buffer, the "Working..." spinner animation failed to appear because the loading indicator was suppressed while `streamingText` was non-empty. In addition, `text-delta` set the busy state to "Responding" instead of "Working". Fixed by keeping the "Working..." spinner active below the streaming response text and accounting for its height during response streaming, ensuring the spinner animation runs throughout content generation. -- **Terminal line overlapping when pasting large or multiline text.** When a multiline or large text was pasted or submitted, `TranscriptViewport`'s `justifyContent="flex-end"` caused Yoga flexbox to squash row containers and assign overlapping vertical coordinates to subsequent transcript rows and streaming text, resulting in permanent character and line overlap. Fixed by driving transcript alignment through negative `marginTop` derived from `maxScrollOffset`, disabling flexbox squashing (`flexShrink={0}` on row wrappers), expanding tab characters in user blocks to prevent unexpected terminal wrapping, capping prompt input display height, and collapsing multi-line pastes (3+ lines) and large pastes (200+ characters) into paste chips. +- **Terminal line overlapping when pasting large or multiline text.** When a multiline or large text was pasted or submitted, `TranscriptViewport`'s `justifyContent="flex-end"` caused Yoga flexbox to squash row containers and assign overlapping vertical coordinates to subsequent transcript rows and streaming text, resulting in permanent character and line overlap. Fixed by driving transcript alignment through negative `marginTop` derived from `maxScrollOffset`, disabling flexbox squashing (`flexShrink={0}` on row wrappers), expanding tab characters in user blocks to prevent unexpected terminal wrapping, capping prompt input display height (the prompt window is sliced by wrapped rows — the same math as the height cap — so the rendered prompt can never exceed the height reported to the viewport), and collapsing multi-line pastes (3+ lines) and large pastes (200+ characters) into paste chips. ## [6.8.4] - 2026-09-05 diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 169a345..1f2a8f4 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -414,8 +414,10 @@ export function App({ try { const text = selection.getSelectedText(); if (!text || text.trim().length === 0) return; - copyToClipboard(text, renderer); - showToast(termCols < 40 ? "✓ Text copied" : "✓ Text copied to clipboard"); + // Only confirm when a clipboard mechanism actually succeeded. + if (copyToClipboard(text, renderer)) { + showToast(termCols < 40 ? "✓ Text copied" : "✓ Text copied to clipboard"); + } } catch {} }); const [settings, setSettings] = useState(() => @@ -1907,10 +1909,10 @@ export function App({ transcriptPlacement.marginTop + virtualRows.startY; const rowBottomSpacerHeight = Math.max(0, rowsHeight - virtualRows.endY); // Height estimates are only an approximation of OpenTUI's word wrapping. - // At the live edge, let Yoga align the rendered content itself so the last - // line always remains above the composer even when an earlier row wrapped to - // more lines than estimated. Estimates still drive virtualization/scrolling. - const anchorTranscriptToBottom = transcriptPlacement.anchorToBottom; + // The live edge is aligned by the estimate-based negative margin above + // rather than Yoga's flex-end anchoring, which squashed row containers; + // the virtualization overscan keeps neighbouring rows mounted so estimate + // drift stays local. Estimates still drive virtualization/scrolling. const inTask = view === "chat" && (rows.length > 1 || diff --git a/src/ui/components/InputBox.tsx b/src/ui/components/InputBox.tsx index a5dcb09..f07391b 100644 --- a/src/ui/components/InputBox.tsx +++ b/src/ui/components/InputBox.tsx @@ -543,43 +543,73 @@ export function InputBox({ active, width, slashCommands, onSubmit, supportsImage if (!active) { return {value || "waiting…"} } - const allLines = value.split("\n") - if (allLines.length <= MAX_PROMPT_HEIGHT) { - return ( - <> - {value.slice(0, cursor)} - {value[cursor] ?? " "} - {value.slice(cursor + 1)} - + const renderWithCursor = (text: string, relCursor: number) => ( + <> + {text.slice(0, relCursor)} + {text[relCursor] ?? " "} + {text.slice(relCursor + 1)} + + ) + // Window by wrapped rows (the same math as the promptHeight cap), not + // logical lines, so the rendered prompt can never exceed the height + // reported to the viewport via onHeightChange. Row counts come from + // plainDisplay, whose cursor line already includes the caret cell the + // render appends when the cursor sits at a line's end. + const rowsFor = (line: string) => + Math.max(1, Math.ceil(Math.max(1, line.length) / editableWidth)) + const displayLines = plainDisplay.split("\n") + const valueLines = value.split("\n") + const cursorLineIdx = value.slice(0, cursor).split("\n").length - 1 + const totalRows = displayLines.reduce((sum, line) => sum + rowsFor(line), 0) + if (totalRows <= MAX_PROMPT_HEIGHT) { + return renderWithCursor(value, cursor) + } + const cursorLine = valueLines[cursorLineIdx] ?? "" + const cursorLineRows = rowsFor(displayLines[cursorLineIdx] ?? "") + if (cursorLineRows > MAX_PROMPT_HEIGHT) { + // The cursor line alone overflows the cap: show a character window + // of that line sized to the cap, keeping the caret in view. + const maxChars = MAX_PROMPT_HEIGHT * editableWidth + const posInLine = cursor - (value.slice(0, cursor).lastIndexOf("\n") + 1) + const windowStart = Math.max( + 0, + Math.min(posInLine - Math.floor(maxChars / 2), cursorLine.length - maxChars), ) + let windowed = cursorLine.slice(windowStart, windowStart + maxChars) + if (posInLine - windowStart >= windowed.length) { + // The caret appends a cell at the slice end; drop one character + // so the rendered cells stay within the cap. + windowed = windowed.slice(0, Math.max(0, windowed.length - 1)) + } + return renderWithCursor(windowed, posInLine - windowStart) + } + // Expand outward from the cursor line, preferring the side with fewer + // rows, and only take a line that still fits under the cap. + let startLine = cursorLineIdx + let endLine = cursorLineIdx + 1 + let rowsUsed = cursorLineRows + while (rowsUsed < MAX_PROMPT_HEIGHT) { + const rowsUp = startLine > 0 ? rowsFor(displayLines[startLine - 1] ?? "") : Infinity + const rowsDown = endLine < displayLines.length ? rowsFor(displayLines[endLine] ?? "") : Infinity + const upFits = rowsUp !== Infinity && rowsUsed + rowsUp <= MAX_PROMPT_HEIGHT + const downFits = rowsDown !== Infinity && rowsUsed + rowsDown <= MAX_PROMPT_HEIGHT + if (upFits && (rowsUp <= rowsDown || !downFits)) { + startLine -= 1 + rowsUsed += rowsUp + } else if (downFits) { + endLine += 1 + rowsUsed += rowsDown + } else { + break + } } - const linesBeforeCursor = value.slice(0, cursor).split("\n") - const cursorLineIdx = linesBeforeCursor.length - 1 - const startLine = Math.max( - 0, - Math.min(cursorLineIdx - Math.floor(MAX_PROMPT_HEIGHT / 2), allLines.length - MAX_PROMPT_HEIGHT), - ) - const endLine = startLine + MAX_PROMPT_HEIGHT - const windowedLines = allLines.slice(startLine, endLine) - let charOffset = 0 for (let i = 0; i < startLine; i++) { - charOffset += allLines[i].length + 1 + charOffset += (valueLines[i] ?? "").length + 1 } - const windowedText = windowedLines.join("\n") - const relCursor = cursor - charOffset - - if (relCursor >= 0 && relCursor <= windowedText.length) { - return ( - <> - {windowedText.slice(0, relCursor)} - {windowedText[relCursor] ?? " "} - {windowedText.slice(relCursor + 1)} - - ) - } - return <>{windowedText} - }, [active, value, cursor]) + const windowedText = valueLines.slice(startLine, endLine).join("\n") + return renderWithCursor(windowedText, cursor - charOffset) + }, [active, value, cursor, plainDisplay, editableWidth]) // Parent viewport calculations must use the real bottom-stack height. A // layout effect updates it before OpenTUI paints the next frame, preventing a @@ -670,11 +700,11 @@ export function InputBox({ active, width, slashCommands, onSubmit, supportsImage {fitText(attachmentMessage.text, Math.max(1, width - 4))} )} - - - {"❯ "} - {displayContent} - + + {"❯ "} + + {displayContent} + diff --git a/src/ui/components/ScrollToBottomChip.tsx b/src/ui/components/ScrollToBottomChip.tsx index ffceea8..5b28db0 100644 --- a/src/ui/components/ScrollToBottomChip.tsx +++ b/src/ui/components/ScrollToBottomChip.tsx @@ -31,10 +31,6 @@ export function ScrollToBottomChip({ e.stopPropagation?.(); onClick(); }} - onMouseUp={(e) => { - e.stopPropagation?.(); - onClick(); - }} onMouseMove={(e) => { e.stopPropagation?.(); if (!hovered) setHovered(true); @@ -48,10 +44,6 @@ export function ScrollToBottomChip({ e.stopPropagation?.(); onClick(); }} - onMouseUp={(e) => { - e.stopPropagation?.(); - onClick(); - }} > {label} diff --git a/test/ui-viewport.test.tsx b/test/ui-viewport.test.tsx index 7f21d9b..1f7246a 100644 --- a/test/ui-viewport.test.tsx +++ b/test/ui-viewport.test.tsx @@ -227,6 +227,49 @@ test("renders scroll-to-bottom chip when scrolled up in a task and clears on cli } }); +test("caps wrapped prompt rendering to the height reported to the viewport", async () => { + let reportedHeight = 0; + const screen = await testRender( + {}} + supportsImages={false} + onHeightChange={(h) => { + reportedHeight = h; + }} + />, + { width: 80, height: 24 }, + ); + + try { + await screen.renderOnce(); + // A single 700-char line wraps to ~10 rows at width 80 (editable width 74), + // exceeding the 8-row prompt cap. Typed in sub-threshold chunks with a + // flush after each so they stay inline instead of collapsing into a chip. + await act(async () => { + for (let i = 0; i < 7; i++) { + await screen.mockInput.typeText("x".repeat(100)); + await screen.flush(); + } + }); + await screen.renderOnce(); + + const rows = screen.captureCharFrame().split("\n"); + const top = rows.findIndex((row) => row.includes("╭")); + const bottom = rows.findIndex((row) => row.includes("╰")); + assert.notEqual(top, -1); + assert.notEqual(bottom, -1); + // The rendered box (borders + content) must occupy exactly the height + // reported via onHeightChange, or the composer overflows onto the + // transcript above it. + assert.equal(bottom - top + 1, reportedHeight); + } finally { + act(() => screen.renderer.destroy()); + } +}); + test("renders Toast component with success border and message", async () => { const screen = await testRender( , From 107c339b75fd88cb573c4d36e47aceb36f69d902 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Tue, 8 Sep 2026 22:21:15 +0530 Subject: [PATCH 3/3] fix(tui): exit process explicitly after renderer teardown to prevent hang /quit, /exit, and Ctrl+D printed the 'Session saved' line but the process could stay alive when a background handle kept the event loop busy (an MCP child process still closing inside endAndExit's 3s cap, an in-flight fetch socket, or an FFF watcher), leaving the terminal stuck until Ctrl+C. The interactive path now calls process.exit(0) after the renderer is destroyed, matching the headless path's explicit exit. --- CHANGELOG.md | 1 + src/index.tsx | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8eb323..379002f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Terminal no longer hangs after quitting a session.** `/quit`, `/exit`, and Ctrl+D printed the "Session saved" line but the process stayed alive whenever a background handle (an MCP server child process still closing, an in-flight fetch socket, or an FFF watcher) kept the event loop busy, requiring Ctrl+C to get the prompt back. The interactive path now exits explicitly after the renderer is destroyed, matching the headless mode's behavior. - **Working animation not triggered during response content streaming.** When reasoning finished and the model began streaming the final response content buffer, the "Working..." spinner animation failed to appear because the loading indicator was suppressed while `streamingText` was non-empty. In addition, `text-delta` set the busy state to "Responding" instead of "Working". Fixed by keeping the "Working..." spinner active below the streaming response text and accounting for its height during response streaming, ensuring the spinner animation runs throughout content generation. - **Terminal line overlapping when pasting large or multiline text.** When a multiline or large text was pasted or submitted, `TranscriptViewport`'s `justifyContent="flex-end"` caused Yoga flexbox to squash row containers and assign overlapping vertical coordinates to subsequent transcript rows and streaming text, resulting in permanent character and line overlap. Fixed by driving transcript alignment through negative `marginTop` derived from `maxScrollOffset`, disabling flexbox squashing (`flexShrink={0}` on row wrappers), expanding tab characters in user blocks to prevent unexpected terminal wrapping, capping prompt input display height (the prompt window is sliced by wrapped rows — the same math as the height cap — so the rendered prompt can never exceed the height reported to the viewport), and collapsing multi-line pastes (3+ lines) and large pastes (200+ characters) into paste chips. diff --git a/src/index.tsx b/src/index.tsx index 4455d51..bfe7387 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -272,6 +272,12 @@ async function main(): Promise { if (process.env.ORBCODE_LAST_SESSION_ID) { console.log(`\nSession saved. To resume: orbcode --resume ${process.env.ORBCODE_LAST_SESSION_ID}\n`) } + // The renderer is destroyed, but lingering handles — an MCP child process + // whose close() didn't finish inside endAndExit's 3s cap, in-flight fetch + // sockets, FFF watchers — can keep the event loop alive after main() + // returns, leaving the terminal stuck until Ctrl+C. Exit explicitly, + // mirroring the headless path. + process.exit(0) } main().catch((error) => {