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]

## [0.4.1] - 2026-07-08

### Fixed

- **Input box now stays pinned to the bottom of the terminal.** Removed
`<Static>` (which permanently wrote rows to stdout and caused the whole
view to scroll up when a long response completed). Rows are now rendered
in the dynamic region with viewport-capped visibility — only the rows that
fit above the input box are shown. A spacer fills the gap when content is
short so the input box + status bar are always at the bottom. Streaming
text is capped with `tailForHeight()` (accounting for line wrapping) so it
never grows tall enough to push the input box off-screen.

## [0.4.0] - 2026-07-05

### Added
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": "0.4.0",
"version": "0.4.1",
"description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI",
"type": "module",
"bin": {
Expand Down
172 changes: 155 additions & 17 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React, {
useRef,
useState,
} from "react";
import { Box, Static, Text, useApp, useInput } from "ink";
import { Box, Text, useApp, useInput, useStdout } from "ink";
import open from "open";
import * as path from "node:path";

Expand Down Expand Up @@ -307,6 +307,9 @@ export function App({
updateCheck?: Promise<UpdateInfo>;
}) {
const { exit } = useApp();
const { stdout } = useStdout();
const termRows = stdout?.rows ?? 24;
const termCols = stdout?.columns ?? 80;
const [settings, setSettings] = useState<OrbCodeSettings>(() =>
loadSettings(),
);
Expand Down Expand Up @@ -362,7 +365,6 @@ export function App({
const [pendingMcpApproval, setPendingMcpApproval] = useState<string[] | null>(
null,
);
const [staticKey, setStaticKey] = useState(0);
const [tasks, setTasks] = useState("");
const [contextTokens, setContextTokens] = useState(0);
const [totalCost, setTotalCost] = useState(0);
Expand Down Expand Up @@ -465,7 +467,6 @@ export function App({
modelName: getModel(loadSettings().model).name,
},
]);
setStaticKey((k) => k + 1);
}, []);

const handleEvent = useCallback(
Expand Down Expand Up @@ -1266,7 +1267,6 @@ export function App({
if (process.stdout.isTTY) {
process.stdout.write("\x1b[2J\x1b[H");
}
setStaticKey((k) => k + 1);
}
});

Expand Down Expand Up @@ -1313,16 +1313,88 @@ export function App({
!taskPickerSessions &&
!linkManagerOpen;

// ── Viewport management ─────────────────────────────────────────────
// Instead of <Static> (which permanently writes to stdout and causes
// uncontrolled scrolling), we render everything in the dynamic region and
// only show the rows that fit above the pinned input box + status bar.
// This keeps the input box always at the bottom of the terminal.

// Height of the fixed bottom chrome (input box border + text + status bar +
// margins). Intentionally slightly overestimated so we never push the
// input box off-screen.
let bottomHeight = 8;
if (queuedMessages.length > 0)
bottomHeight += 2 + Math.min(5, queuedMessages.length);
if (
pendingApproval ||
pendingFollowup ||
pendingHookTrust ||
pendingMcpApproval ||
modelPickerOpen ||
mcpPickerOpen ||
mcpMigrationEntries ||
resumableSessions ||
taskPickerSessions ||
linkManagerOpen
)
bottomHeight += 10;

// Height of the middle dynamic elements (between rows and input box).
let middleHeight = 0;
if (streamingReasoning) middleHeight += 4;
if (taskLines.length > 0)
middleHeight += 1 + Math.min(10, taskLines.length);
if (busy && !streamingText && !streamingReasoning) middleHeight += 1;
if (updateInfo?.updateAvailable && updateInfo.latest) middleHeight += 4;

// Available height for committed rows + streaming text.
const availableHeight = Math.max(3, termRows - bottomHeight - middleHeight);

// Estimate how many terminal lines a row will occupy.
const wrapWidth = Math.max(20, termCols - 4);
const visibleRows = useMemo(() => {
let used = 0;
let start = rows.length;
for (let i = rows.length - 1; i >= 0; i--) {
const h = estimateRowLines(rows[i], wrapWidth);
if (used + h > availableHeight) break;
used += h;
start = i;
}
return rows.slice(start);
}, [rows, availableHeight, wrapWidth]);

// Cap streaming text to the space left after committed rows.
const rowsHeight = useMemo(
() => visibleRows.reduce((s, r) => s + estimateRowLines(r, wrapWidth), 0),
[visibleRows, wrapWidth],
);
const streamingMaxLines = Math.max(3, availableHeight - rowsHeight);
const streamingDisplay = streamingText
? tailForHeight(streamingText, streamingMaxLines, wrapWidth)
: "";
const streamingReasoningDisplay = streamingReasoning
? tailForHeight(streamingReasoning, 3, wrapWidth)
: "";

// Spacer to push the input box to the bottom when content is short.
const streamingHeight = streamingDisplay
? Math.min(streamingMaxLines, wrapHeight(streamingDisplay, wrapWidth))
: 0;
const spacerHeight = Math.max(
0,
availableHeight - rowsHeight - streamingHeight,
);

return (
<Box flexDirection="column">
<Static key={staticKey} items={rows}>
{(row) => <RowView key={row.id} row={row} />}
</Static>

{view === "login" ? (
<LoginSection onLogin={handleLogin} />
) : (
<Box flexDirection="column">
{visibleRows.map((row) => (
<RowView key={row.id} row={row} />
))}
{updateInfo?.updateAvailable && updateInfo.latest && (
<Box
marginTop={1}
Expand All @@ -1348,22 +1420,20 @@ export function App({
</Text>
<Box paddingLeft={2}>
<Text dimColor italic>
{tail(
streamingReasoning,
expandReasoningRef.current ? 30 : 3,
)}
{streamingReasoningDisplay}
</Text>
</Box>
</Box>
)}
{streamingText && (
{streamingDisplay && (
<Box marginTop={1}>
<Text>
<Text color={COLORS.primary}>● </Text>
{streamingText}
{streamingDisplay}
</Text>
</Box>
)}
{spacerHeight > 0 && <Box height={spacerHeight} />}
{taskLines.length > 0 && (
<Box flexDirection="column" marginTop={1} paddingLeft={1}>
<Text dimColor bold>
Expand Down Expand Up @@ -1556,9 +1626,77 @@ export function App({
);
}

function tail(text: string, lines: number): string {
const all = text.split("\n").filter((l) => l.trim());
return all.slice(-lines).join("\n");
/** Count the number of terminal rows a block of text will occupy (accounting
* for wrapping at `width` columns). */
function wrapHeight(text: string, width: number): number {
return text.split("\n").reduce(
(sum, line) => sum + Math.max(1, Math.ceil(Math.max(1, line.length) / width)),
0,
);
}

/** Return the tail of `text` that fits within `maxLines` terminal rows,
* accounting for line wrapping at `width` columns. */
function tailForHeight(text: string, maxLines: number, width: number): string {
const lines = text.split("\n");
const result: string[] = [];
let height = 0;
for (let i = lines.length - 1; i >= 0 && height < maxLines; i--) {
const line = lines[i];
const wrapped = Math.max(1, Math.ceil(Math.max(1, line.length) / width));
if (height + wrapped > maxLines) {
const remaining = maxLines - height;
if (remaining > 0) {
const chars = remaining * width;
result.unshift(line.slice(-chars));
}
break;
}
result.unshift(line);
height += wrapped;
}
return result.join("\n");
}

/** Estimate how many terminal lines a committed row will occupy. Intentionally
* slightly conservative (overestimates) so we never show too many rows and
* push the input box off-screen. */
function estimateRowLines(row: Row, width: number): number {
const w = Math.max(20, width);
function wrapped(text: string): number {
return text.split("\n").reduce(
(sum, line) =>
sum + Math.max(1, Math.ceil(Math.max(1, line.length) / w)),
0,
);
}
switch (row.kind) {
case "header":
return 9;
case "user":
return 1 + wrapped(row.text);
case "assistant":
return 1 + wrapped(row.text);
case "reasoning":
return row.expanded ? 1 + wrapped(row.text) : 1;
case "tool": {
let h = 1;
if (row.diff) {
h += Math.min(62, row.diff.split("\n").length) + 1;
} else if (row.resultPreview) {
h += wrapped(row.resultPreview);
}
return h;
}
case "info":
return Math.max(1, wrapped(row.text));
case "error":
return Math.max(1, wrapped(row.text));
case "completion":
return 2 + wrapped(row.text);
default:
return 1;
}
}

const QUEUE_PREVIEW_LIMIT = 80;
Expand Down
Loading