feat: neutral greyscale theme with fullscreen TUI, virtual scrolling, and UI redesign#17
Conversation
Replace the cyan/teal palette with a neutral black/white/grey foundation: primary (#ffffff), accent (#d0d0d0), thinking (#a8a8a8), dim (#7a7a7a), bg (#1a1a1a). Semantic colors use distinct tones: error (#E34671), success (#3FA266), warning (#E2CE76). Add new tokens: userBg, popupBg, inputBorder, inputBorderInactive, orbitalOuter, orbitalInner, user. Migrate all <Text dimColor> and hardcoded color="gray" usages to explicit COLORS.dim references across the entire UI component tree. Replace cyan/teal error/success colors in MCP auth callback page. Switch code-block accent in markdown rendering to COLORS.accent.
…reen rendering Enter the alternate screen buffer (\x1b[?1049h) on startup so the TUI owns the full terminal and prior output stays in the primary buffer — no scrollback pollution from Ink frames. Set terminal default foreground/background via OSC 10/11 to match the greyscale palette so text without an explicit color prop follows theme. Implement createFullscreenStdout — a WriteStream proxy that converts Ink serialized output into cursor-addressed rows with EL 2 (erase line) instead of newline-based scrolling. Uses synchronized update DCS (\x1b[?2026h/l) for tear-free rendering. Only rewrites changed rows by retaining the previous frame. Enable mouse tracking (button events, drag, movement, SGR coordinates) via \x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h to prevent terminal scrollback navigation from wheel gestures. Register restoreTerminal on process exit to reset colors (OSC 110/111) and leave the alternate buffer. Use combined TTY check across stdin, stdout, and stderr for compatibility with wrapped terminals.
…roll Rewrite the transcript rendering from a bottom-up scroll approach to a virtual viewport system that tracks the full transcript height, computes visible rows based on a scroll offset, and estimates wrapped line counts for proper layout sizing. Mouse wheel support: parse SGR, URXVT, and legacy X10 mouse sequences to detect wheel events and queue smooth scroll deltas at ~30fps with direction-reversal handling and a 24-line cap. PageUp/PageDown keys scroll by viewport-sized chunks. Implement Ctrl+D double-press-to-exit with a 3-second confirmation timeout displayed in the status bar. The StatusBar gains new props: exitConfirmationActive and MODE_COLORS for colored approval-mode labels. Dynamically track terminal resize via stdout prependListener for stable frame dimensions. Render root container at exactly termRows height with overflow=hidden so Ink always clears/redraws in place. Add paddingX=2 for consistent horizontal margin across the entire UI. Refactor RowView as React.memo with a width prop for consistent line wrapping. Implement formatUserBlock — a helper that renders user message text as a padded, background-filled block spanning the full transcript width. Replace hardcoded diff background colors with COLORS.error/success. Migrate remaining dimColor usages to explicit COLORS.dim. Create src/ui/terminal.ts with mouseScrollDelta and isMouseInput utilities used by both App and InputBox.
…bordered popups Header: Replace the old bordered ASCII logo with a multi-panel layout featuring the ORBITAL_MARK character art rendered with half-block glyphs (▄, ▀, █) in source-art colors (cyan outer, cyan inner, white core). Add metadata column (model, workspace) with dimmed labels. Add a compact two-row action command grid (/new, /resume, /model, /help) with padded columns and a shortcut reference line. Remove the old borderStyle="round" wrapping box. InputBox: Replace top/bottom-only border with a full borderStyle="round" box using 50%-brightness chrome (COLORS.inputBorder active / inputBorderInactive inactive). Redesign slash-command and file-completion popups as styled floating panels with rounded unicode edges (▗▄▖ / ▝▀▘), background color, selected-item highlighting (❯), and proper truncation via fitText helper. Add Escape-to-clear-input shortcut. Consume the width prop for consistent sizing with the transcript.
Bump version from 0.4.1 to 0.4.2. Sync package-lock.json version to match. Add changelog entry documenting the theme/bw-grey changes: neutral greyscale palette, alternate screen buffer, virtual scrolling, header/input box redesign.
There was a problem hiding this comment.
🧪 PR Review is completed: Large-scale TUI redesign with fullscreen terminal adapter, virtual scrolling, mouse wheel support, and neutral greyscale theme. The terminal lifecycle management has a gap in signal handling that can leave the user's terminal in a broken state. Reviewed src/ui/App.tsx, src/index.tsx, src/ui/terminal.ts, src/ui/components/rows.tsx, src/ui/components/Header.tsx, src/ui/components/InputBox.tsx, src/ui/components/StatusBar.tsx: virtual scrolling binary search, smooth scroll queueing, proxy-based stdout adapter, and mouse input parsing all look correct.
Skipped files
CHANGELOG.md: Skipped file patternpackage-lock.json: Skipped file pattern
| function restoreTerminal(): void { | ||
| if (isInteractiveTerminal && process.stdout.writable) { | ||
| process.stdout.write(DISABLE_MOUSE_TRACKING) | ||
| process.stdout.write("\x1b]110\x07") | ||
| process.stdout.write("\x1b]111\x07") | ||
| process.stdout.write("\x1b[?1049l") | ||
| } | ||
| } | ||
| process.on("exit", restoreTerminal) |
There was a problem hiding this comment.
🟠 Terminal State Leak on Signal-Based Termination
Issue: The new code enters the alternate screen buffer (\x1b[?1049h), enables mouse tracking (ENABLE_MOUSE_TRACKING), hides the cursor (\x1b[?25l), and overrides terminal colors (OSC 10/11). However, restoreTerminal is only registered on the "exit" event, which does NOT fire when the process is killed by SIGTERM (e.g., kill <pid>, process manager stop) or SIGHUP (e.g., closing the terminal window). This leaves the user's terminal in the alternate screen buffer with mouse tracking enabled — making the terminal unusable until they manually run reset or the disable escape sequences.
Additionally, \x1b[?25l (hide cursor) is sent on startup but restoreTerminal never sends \x1b[?25h (show cursor). While most terminals restore cursor visibility when leaving the alternate screen (\x1b[?1049l), this is not guaranteed by all terminal emulators.
Fix: Add SIGTERM and SIGHUP handlers that call process.exit() (which triggers the existing "exit" handler), and explicitly show the cursor in restoreTerminal.
Impact: Prevents terminal corruption on external process termination and guarantees cursor restoration across all terminal emulators.
| function restoreTerminal(): void { | |
| if (isInteractiveTerminal && process.stdout.writable) { | |
| process.stdout.write(DISABLE_MOUSE_TRACKING) | |
| process.stdout.write("\x1b]110\x07") | |
| process.stdout.write("\x1b]111\x07") | |
| process.stdout.write("\x1b[?1049l") | |
| } | |
| } | |
| process.on("exit", restoreTerminal) | |
| function restoreTerminal(): void { | |
| if (isInteractiveTerminal && process.stdout.writable) { | |
| process.stdout.write("\x1b[?25h") | |
| process.stdout.write(DISABLE_MOUSE_TRACKING) | |
| process.stdout.write("\x1b]110\x07") | |
| process.stdout.write("\x1b]111\x07") | |
| process.stdout.write("\x1b[?1049l") | |
| } | |
| } | |
| process.on("exit", restoreTerminal) | |
| process.on("SIGTERM", () => process.exit(143)) | |
| process.on("SIGHUP", () => process.exit(129)) |
New sessions record the live TUI transcript (reasoning, tool calls, results, diffs, errors) as it streams, so resumed sessions show the full history including thinking durations, tool summaries, and edit fragments. Older session files reconstruct best-effort history from stored model messages. - Add SessionTranscriptEntry union type and transcript field to SessionData - Intercept onEvent in Agent to record transcript entries as they occur - Expose displayTranscript getter on Agent for the TUI to consume - Include legacyTranscript() fallback that rebuilds tool/result history - Update App.tsx resume handler to iterate displayTranscript instead of re-parsing raw messages - Fix @-file autocomplete re-scanning files each time the popup opens - Make diff backgrounds 50% alpha-blended and use explicit foreground colors
There was a problem hiding this comment.
🧪 PR Review is completed: Reviewed all 5 changed files. The transcript persistence and legacy migration logic in agent.ts is well-structured. Found one React anti-pattern in InputBox.tsx where setState and synchronous filesystem I/O occur during render instead of in a useEffect.
Skipped files
CHANGELOG.md: Skipped file pattern
| const [files, setFiles] = useState<string[]>([]) | ||
| const wasClosed = useRef(true) | ||
| if (atToken && wasClosed.current) { | ||
| wasClosed.current = false | ||
| setFiles(walkFiles(process.cwd(), true, 3000).filter((f) => !f.endsWith("/"))) | ||
| } else if (!atToken) { | ||
| wasClosed.current = true | ||
| } |
There was a problem hiding this comment.
🟠 Performance / React Anti-Pattern
Issue: setFiles is called during the render phase, and walkFiles performs a synchronous filesystem scan (up to 3000 files) inline during rendering. Calling setState during render is a React anti-pattern, and blocking the event loop with synchronous I/O during render will cause UI stutter every time the @-token popup opens. If walkFiles throws (e.g., permission error), the error propagates during render rather than in an effect where it can be handled gracefully.
Fix: Move the file-scan logic into a useEffect keyed on atToken so the filesystem scan runs after render, not during it.
Impact: Eliminates render-phase side effects, prevents UI blocking on filesystem I/O, and isolates potential errors from the render path.
| const [files, setFiles] = useState<string[]>([]) | |
| const wasClosed = useRef(true) | |
| if (atToken && wasClosed.current) { | |
| wasClosed.current = false | |
| setFiles(walkFiles(process.cwd(), true, 3000).filter((f) => !f.endsWith("/"))) | |
| } else if (!atToken) { | |
| wasClosed.current = true | |
| } | |
| const [files, setFiles] = useState<string[]>([]) | |
| const wasClosed = useRef(true) | |
| useEffect(() => { | |
| if (atToken && wasClosed.current) { | |
| wasClosed.current = false | |
| setFiles(walkFiles(process.cwd(), true, 3000).filter((f) => !f.endsWith("/"))) | |
| } else if (!atToken) { | |
| wasClosed.current = true | |
| } | |
| }, [atToken]) |
Summary
Complete visual refresh of OrbCode CLI with a neutral black/white/grey theme, alternate screen buffer TUI, virtual transcript scrolling, and redesigned header/input box.
Changes
feat(theme): neutral greyscale palette with semantic accents
#ffffffprimary,#d0d0d0accent,#a8a8a8thinking,#7a7a7adim,#1a1a1abackground#E34671, success#3FA266, warning#E2CE76<Text dimColor>to explicitCOLORS.dimacross entire UIuserBg,popupBg,inputBorder,inputBorderInactive,orbitalOuter,orbitalInnerfeat(tui): alternate screen buffer with cursor-addressed rendering
\x1b[?1049h) on startup — no scrollback pollutioncreateFullscreenStdoutproxy: converts Ink frames to cursor-addressed rows with EL 2feat(ui): virtual scrolling transcript with mouse wheel
termRowsroot containerpaddingX={2}for consistent horizontal marginRowViewnowReact.memowith width-aware layoutfeat(ui): redesigned header and input box
borderStyle="round"chrome, styled floating popups with rounded unicode edges, Escape-to-clearchore: bump version to 0.4.2