Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ 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. 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

- **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.

## [6.8.4] - 2026-09-05

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,12 @@ async function main(): Promise<void> {
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) => {
Expand Down
140 changes: 100 additions & 40 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -387,9 +394,32 @@ export function App({
updateCheck?: Promise<UpdateInfo>;
}) {
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<ReturnType<typeof setTimeout> | 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;
// 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<OrbCodeSettings>(() =>
loadSettings(),
);
Expand Down Expand Up @@ -582,6 +612,9 @@ export function App({
if (exitConfirmationTimerRef.current !== null) {
clearTimeout(exitConfirmationTimerRef.current);
}
if (toastTimerRef.current !== null) {
clearTimeout(toastTimerRef.current);
}
},
[],
);
Expand Down Expand Up @@ -643,7 +676,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 });
Expand Down Expand Up @@ -1740,13 +1773,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),
)
: "";
Expand Down Expand Up @@ -1791,17 +1834,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;
}

Expand Down Expand Up @@ -1876,10 +1909,17 @@ 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 ||
busy ||
taskLines.length > 0 ||
Boolean(streamingText) ||
Boolean(streamingReasoning));

useEffect(() => {
setScrollOffset((current) => Math.min(current, maxScrollOffset));
Expand Down Expand Up @@ -1916,13 +1956,11 @@ export function App({
minHeight={0}
overflow="hidden"
>
<TranscriptViewport anchorToBottom={anchorTranscriptToBottom}>
<TranscriptViewport anchorToBottom={false}>
<Box
flexDirection="column"
flexShrink={0}
marginTop={
anchorTranscriptToBottom ? 0 : virtualTranscriptMarginTop
}
marginTop={virtualTranscriptMarginTop}
>
{virtualRows.rows.map((row) => (
<RowView key={row.id} row={row} width={wrapWidth} />
Expand Down Expand Up @@ -2019,24 +2057,17 @@ export function App({
onApprove={resolveMcpApproval}
/>
)}
{busy &&
!pendingApproval &&
!pendingFollowup &&
!pendingHookTrust &&
!pendingMcpApproval &&
!mcpPickerOpen &&
!mcpMigrationEntries &&
!streamingText &&
!streamingReasoning && (
<Box marginTop={1}>
<Spinner
label={busyLabel}
showTip={
busyLabel === "Thinking" || busyLabel === "Working"
}
/>
</Box>
)}
{spinnerVisible && (
<Box marginTop={1}>
<Spinner
key={busyLabel}
label={busyLabel}
showTip={
busyLabel === "Thinking" || busyLabel === "Working"
}
/>
</Box>
)}
</Box>
</TranscriptViewport>
<Box flexDirection="column" flexShrink={0}>
Expand Down Expand Up @@ -2089,6 +2120,25 @@ export function App({
</Box>
</Box>
)}
{inTask && effectiveScrollOffset > 0 && (
<Box
position="absolute"
bottom={bottomControlsHeight + 1}
left={0}
right={0}
justifyContent="center"
zIndex={10}
>
<ScrollToBottomChip
scrollOffset={effectiveScrollOffset}
width={termCols}
onClick={() => {
smoothScrollPendingRef.current = 0;
setScrollOffset(0);
}}
/>
</Box>
)}
{popoverOpen && (
<Box
position="absolute"
Expand Down Expand Up @@ -2206,6 +2256,16 @@ export function App({
</Box>
</Box>
)}
{toast && (
<Box
position="absolute"
top={termRows <= 4 ? 0 : 1}
right={termCols < 30 ? 1 : 2}
zIndex={150}
>
<Toast message={toast.message} />
</Box>
)}
</Box>
);
}
Expand Down
Loading