Skip to content

fix(terminal): bound output backlogs

MacroscopeApp / Macroscope - UI Consistency failed Aug 31, 2026 in 6m 15s

UI Consistency: 1 issue found

  • apps/web/src/terminal/ghostty/surface.ts — the new ALTERNATE_SCREEN_DARK_THEME / terminalThemeForScreen fallback hardcodes terminal colors in the renderer, bypassing the --terminal-* theme tokens that terminalThemeFromApp and index.css own. Any light (including custom) terminal palette is replaced by pure black / near-white / a fixed blue cursor for every alternate-screen program, while the surrounding panes keep bg-[var(--terminal-background)]. Suggested fix: expose alternate-screen colors as theme tokens and pass them through the surface options so themes retain ownership.

Details

Note

Your check run agent prompt is: .macroscope/check-run-agents/ui-consistency.md
More information about how Check Run Agents work can be found in our Help Center.

Files reviewed in scope: apps/web/src/components/ChatView.tsx, apps/web/src/components/ThreadTerminalDrawer.tsx, apps/web/src/terminal/ghostty/core.ts, apps/web/src/terminal/ghostty/surface.ts, plus the changed tests. No CSS files changed, so no emitted-CSS/variant checks applied.

Re-verified previously flagged items and found them addressed at this head:

  • Write-queue throughput/unbounded growth: TERMINAL_WRITE_QUEUE_MAX_CODE_UNITS plus TERMINAL_WRITE_DRAIN_TIMEOUT_MS now bound the backlog and cover suspended animation frames in hidden/occluded windows.
  • Replay chunk queued behind a live write: write() now tags queued data with replayStreamOpen, and drainWrites calls finishReplay() before a non-replay entry, so writeReplay can no longer throw and stall the drain.
  • Client retention vs. attach replay size: terminalOutputRetentionBytes keeps a 512 KB floor independent of replayBytes.
  • Scrollback-top extended replay no longer blanks the screen: an empty extended snapshot keeps the renderer in waiting, and a restarted extended attach is re-detected through replayStartVersion, so the viewport is preserved until the first history chunk arrives.

Also checked (no findings): scrollbar Home/End now map to core.scrollToTop/scrollToBottom with the same resulting offsets as the previous delta path; requestRender synchronized-output gating has a bounded 500 ms fallback; the ChatView hidden→unmount change is consistent with the documented server-owned PTY model.


🔍 Agent activity — 20 tool calls

💭 I'm checking the PR at commit 9590d32, reviewing whether previously flagged issues persist and looking for new problems in the in-scope web files like ThreadTerminalDrawer.tsx, ChatView.tsx, and the ghostty terminal directory. I'm starting by revisiting the earlier finding about the drainWrites buffering logic in surface.ts. The queue bounding issue is now fixed with a max code units limit and timeout fallback, the replay retention budget issue is fixed via terminalOutputRetentionBytes, the rAF-only drain issue is fixed with a drain timeout constant, and the restarted extended attach detection now uses replayStartVersion comparisons though I'm not fully certain it's addressed. The live write throwing during replay is also fixed since writes now carry a replay flag and get routed correctly in drainWrites. I'm now moving on to check the issue about scrollback reaching its top and blanking the terminal. The reset-with-empty-data case during streaming replay seems to be handled by keeping the screen in a waiting state, though I'm not fully certain, and there's still the Home key / scrollToTop behavior to check. I should actually go read the current ThreadTerminalDrawer.tsx and surface.ts files directly to verify these fixes carefully rather than relying on my assumptions.

1. browse_code — apps/web/src/components/ThreadTerminalDrawer.tsx

2. browse_code — apps/web/src/components/ThreadTerminalDrawer.tsx

💭 I'm checking whether the dependency array change means the terminal surface avoids recreation when replayBytes updates, but the underlying session atom is still keyed by replayBytes, so switching to the extended replay would restart the subscription from an empty state rather than resuming. Tracing through the version comparisons, I see that since previous replayStart/replayComplete were both 1 while the new state resets to 0, the boundary-changed check trips true, avoiding an early return. The output update comes back as a "reset" with empty data because the fresh output buffer has no chunks yet. Since replayBytes is EXTENDED and the replayStartVersion differs from before, the state gets set to "waiting" as expected, and because the reset carries no data, nothing gets written to the screen yet — keeping it blank until the real extended data arrives, which matches what I'd flagged earlier as problematic. Continuing forward, I trace replay-start and snapshot events: each triggers a boundary change but produces empty or "none" updates, so the screen stays untouched while state remains "waiting". Then when the first real 64KB output chunk arrives, the append triggers beginStreamingReplay, flipping state to "replaying" — which is the correct behavior. Next, replay-complete arrives with replayCompleteVersion bumped to 1 and version incremented, satisfying all the conditions to call completeStreamingReplay and transition to idle, with scrollbackReplayPending triggering the scroll-to-top behavior afterward. I also check the retention math: a 4MB replay budget split into 16KB chunks yields 256 chunks, well under the 1024 chunk cap, so eviction shouldn't be a concern here. Now I'm checking where scrollbackReplayRendererStateRef gets set to make sure the rendering state stays consistent through this sequence. Now I'm tracing what happens when the extended replay completes and the transport reconnects: a fresh replay-start flips the state to "waiting," then the server's snapshot reset (with empty history for extended attach) keeps it at "waiting" until output chunks arrive and beginStreamingReplay kicks in. For the default, non-extended path, streamingReplay just stays idle through reconnect since the snapshot reset writes directly to the terminal buffer. I'm also looking at the ChatView guard clause that returns null when the project, terminal state, cwd, or visibility conditions aren't met, plus the wrapper div lacking a hidden attribute — previously the drawer stayed mounted but hidden instead. Now I'm confirming this unmounting behavior is intentional, since the docs note that hidden web drawers unmount their renderer and attach subscription while the PTY keeps running server-side. I want to check whether this causes any visual or layout side effects, so I'm looking at the ChatView code around lines 1000-1070.

3. browse_code — apps/web/src/components/ChatView.tsx

💭 The visible prop is now always true when rendered, so other conditioned props are effectively dead but harmless. I'm wondering whether unmounting the drawer when hidden loses UI state — terminal height persists in the store, but scroll position and screen content get lost since they're re-attached from a server snapshot, which seems intentional per the docs. Still, this could be a real regression: previously the drawer stayed mounted while hidden, preserving terminal scrollback and local state when switching threads or tabs. The name PersistentThreadTerminalDrawer suggests it should stay mounted, so this is worth checking further — I need to look above line 980 for the component's definition and how visible gets derived.

4. browse_code — apps/web/src/components/ChatView.tsx

💭 I want to review the full current state of surface.ts to spot any new issues in the relevant sections.

5. browse_code — apps/web/src/terminal/ghostty/surface.ts

💭 I'm noticing that forcing a hardcoded dark terminal theme for alternate-screen apps, even when the app is in light mode, could be a real UI consistency issue—tools like vim, htop, or less would render on a black background regardless of the user's chosen light theme. The comment explaining this seems intentional and the behavior is tested, so it's a deliberate design choice rather than an accidental bug. But it still uses raw RGB values instead of semantic color tokens, which conflicts with the rule that contrast/accessibility settings targeting app chrome must derive from theme tokens—so I'm weighing whether this counts as a violation or an accepted intentional exception. Let me check whether forcing this constant dark theme drops other fields that GhosttyTheme normally provides, since that could be a real functional bug rather than just a stylistic preference.

6. git_tools — interface GhosttyTheme

💭 GhosttyTheme only has 4 fields, so nothing's dropped there. I want to check where the theme actually gets applied in the DOM — whether the surface pulls the background from this.theme.background for rendering, and whether the drawer chrome wrapping the terminal uses different app tokens, which could cause a mismatch like a dark terminal canvas sitting inside a light drawer. I trace synchronizeScreenTheme, which only runs from didWriteOutput() and setTheme, and terminalThemeForScreen decides based on background luminance whether to force dark mode. If the app is light and a user runs a program with explicit colors, that program's own colors should still apply fine, so that's probably not a real issue. I'm not convinced the alternate-screen contrast is a hard failure worth flagging, so I want to look for something stronger — maybe the Home/End key change in the scrollbar behavior is worth examining next. The Home/End key handler on the scrollbar previously computed a delta and fell into shared scroll logic, but now calls scrollToTop/scrollToBottom directly and updates scrollbar state itself — I want to check if that shared logic did anything else important, like clamping or focus handling, that's now being skipped.

7. browse_code — apps/web/src/terminal/ghostty/surface.ts

💭 I'm checking that Home/End behavior stays consistent with the offset semantics in scrollViewport—Home scrolling to top with offset 0, and End scrolling to bottom with the max offset, both now paired with their respective scrollback callbacks. The scrollbar state gets set directly and refreshed from readScrollbarState(), which looks correct. Next I want to trace through the paste handling logic, specifically the pasteShortcutDeliveredToken, to confirm the changes there are sound.

8. browse_code — apps/web/src/terminal/ghostty/surface.ts

💭 I'm tracing through the timing of the Ctrl+V event sequence—when the keydown handler fires versus when the native paste event dispatches versus when the clipboard read promise resolves—to figure out which one actually wins the race and determines the pasted content. I'm checking both scenarios: when native paste fires first (token increments and consumes the delivery, then the async read correctly no-ops since the token no longer matches) and when the async read wins first (marking a delivered token, so the subsequent native paste event correctly detects it was already handled and skips duplicating input). I'm also considering the edge case where there's no native paste event at all, verifying that keyup properly clears the delivered token so a later context-menu paste doesn't get incorrectly swallowed. But then I catch a timing issue: if keyup fires before the async clipboard read resolves (which is plausible for a quick keypress), the delivered token gets set after keyup already cleared it, meaning a subsequent context-menu paste could still get wrongly swallowed by matching that stale delivered token — potentially reintroducing the very bug this logic was meant to fix. I'm now comparing this against the pre-change behavior to see if this represents an actual regression or if the old code had the same flaw. A subsequent context-menu paste would then silently do nothing, which feels like a real regression risk worth flagging, though I need to check whether clipboard/keyboard interaction semantics actually fall within scope of the UI consistency review guidelines I'm applying. Looking more carefully, the paste behavior doesn't cleanly map to any of the listed categories — shared controls, CSS ownership, themes, scroll/virtualization, or accessibility during primitive migrations. It's also somewhat tangential to the PR's stated purpose around streaming terminal output. Given the check defaults to reporting a finding, I think the stronger candidate is the alternate-screen dark theme override, since that clearly fits under themes and visual/layout preservation. The concrete issue: terminalThemeForScreen hardcodes RGB values instead of deriving from the semantic terminal theme tokens, and it forcibly overrides the app's light theme whenever an alternate-screen program runs (vim, less, htop, man, fzf) — so a light-mode user ends up with a black terminal embedded in light chrome, and the theme tokens normally pulled from CSS variables via terminalThemeFromApp get ignored entirely. This also breaks cases where TUI apps expect their default colors to inherit the terminal's background, which used to match the app theme but no longer does. I want to check if this counts as a real regression against the rule that contrast/accessibility settings for app chrome must derive from semantic tokens — since custom themes are supposed to be preserved, a light custom theme would get clobbered by this fixed black/white override on the alternate screen, which looks like a theme-ownership violation rather than an intentional design choice. Let me look at terminalThemeFromApp to see if there's already a --terminal-* variable defined for both light and dark modes that this code could read from instead.

9. git_tools — terminalThemeFromApp

💭 I'm noticing that terminalThemeForScreen overrides custom terminal theme tokens whenever the alternate screen is active and the theme's background is light, forcibly replacing user-defined colors with hardcoded black/white/blue values instead of respecting the theme's own terminal tokens. This feels like a real ownership violation — a solarized-light user would see vim or htop rendering on pure black, ignoring their --terminal-background setting. The fix should derive the fallback palette from the theme's own dark terminal tokens rather than hardcoded RGB literals and a luminance heuristic, though it's not a variable-removal case, so I'm weighing whether it counts as a "concrete violation" under the theme-preservation rules. But I'm second-guessing this — real terminals under light themes still render htop mostly fine since it paints its own colors, so forcing black might just be a subjective design choice rather than a clear bug, and I risk a false positive. Let me instead look at the extended replay flow more carefully: requestExtendedReplay fires from onScrollbackTop, triggered whenever scrolling reaches offset 0 via drag or Home key, and I want to check what happens to extendedReplayIdentity after the replay finishes. The identity-reset effect fires on mount and identity change, correctly resetting to null when already null. On a transport reconnect during EXTENDED replay, a new replay-start bumps the version, sets state to "waiting", preserves the screen until the first chunk, then beginStreamingReplay resets and replays — this matches the earlier finding and seems properly handled. Cancelling pending writes and resetting the terminal core also look correct. The remaining thread I want to pull is whether the "waiting" state set on every replayStartVersion change during EXTENDED replay actually satisfies the completion condition checked further down. Version does bump on replay-complete, so that part checks out. But I'm now tracing what happens for a completely empty session: replay-start sets waiting, an empty snapshot leaves it waiting, then replay-complete triggers beginStreamingReplay("") — wiping the terminal to empty — followed by completing the replay and scrolling to top. This raises a concern about what the user sees if they scroll to the top of a terminal whose server-side history is genuinely empty versus one where something went wrong. I'm considering whether this maps to a real scenario, like the server clearing history to empty and then appending new output afterward — in that case the extended attach's replay history would include everything written since the clear, so it shouldn't actually be empty. I'm also checking that requestExtendedReplay only fires once per identity so repeated scroll-to-top events don't trigger redundant fetches, and confirming synchronizeScreenTheme is a cheap check run on every write to detect alternate-screen state. I want to trace whether setTheme correctly leaves the terminal color unchanged while in alternate screen mode, since design intends the terminal to stay dark regardless of app theme switches there. I'm now tracing where this.theme gets set at construction time and whether anything else besides didWriteOutput/setTheme reads it for background rendering.

10. git_tools — this.theme

💭 So this.theme only affects selection background in rendering, while actual cell colors come from Ghostty's setTheme. I'm wondering about what paints the canvas/mount background outside the cells, so I want to check the render function further and the mount element's styling.

11. browse_code — apps/web/src/terminal/ghostty/surface.ts

💭 I'm wondering about the padding region and canvas background styling beyond just cell painting, so I'm going to search surface.ts for where the canvas or mount element sets its background color during creation.

12. git_tools — background

13. browse_code — apps/web/src/terminal/ghostty/*

💭 I'm thinking the padding areas outside the cells probably get filled with the default background color, but I should check renderer.ts to confirm how it clears the canvas.

14. browse_code — apps/web/src/terminal/ghostty/renderer.ts

💭 I'm realizing the full-canvas fill uses the theme's default background, so in a light theme, opening vim in the alternate screen would paint the whole canvas black while the drawer chrome around it stays light — an intentional visual change per the test. Now I'm looking at what triggers forceFullRender. synchronizeScreenTheme repaints on entering/exiting the alternate screen, and since theme sync only runs via didWriteOutput after a write, entering alternate screen always coincides with output so the timing works out fine. The remaining concern is whether discarding custom --terminal-background theme tokens whenever this full-canvas fill kicks in conflicts with the project's rule about preserving custom themes rather than hardcoding colors. Since terminal colors are supposed to be owned by the theme layer, a hardcoded palette baked into the renderer instead of derived from theme tokens looks like a genuine ownership violation — and it's new code entirely introduced by this PR, so it's a solid finding to flag. Turning to the ChatView change, I want to check whether unmounting instead of hiding causes real regression. Before, the component stayed mounted with a hidden class, preserving state; now it unmounts, which likely means local UI state like scrollback position and selection gets lost even though the PTY session itself persists server-side. That means switching away and back to a thread probably truncates the visible scrollback to whatever the reattach replay buffer holds (something like 64KB) instead of the previously preserved full in-memory scrollback, which seems like a meaningful regression worth digging into further. On remount, showing the last 64KB seems fine. I check whether visible still matters for keeping the terminal mounted so drag-resize and focusRequestId semantics work — that logic seems intact with focusRequestId always incrementing. I also consider the split-view case with PersistentThreadTerminalPanel separately, then decide whether to report just the alternate-screen hardcoded theme finding or conclude "all clear." Digging into the alternate-screen theme luminance check more concretely, I test edge cases: a light theme background gives luminance near 1, triggering override, while a mid-tone gray background around rgb(120,120,120) computes to roughly 0.187 luminance — still under the 0.5 threshold, so it stays as-is. That seems like reasonable behavior. But then I realize the real bug: a solarized-light or cream terminal background (253,246,227) has luminance around 0.93, so it gets forcibly overridden to pure black — meaning a custom theme's carefully chosen light palette gets discarded entirely in the alternate screen. I also note that only background/foreground/cursor/selection are managed, leaving the ANSI 16-color palette untouched, which mitigates some legibility issues but doesn't fix the core problem: the hardcoded color literals bypass the theme tokens that terminalThemeFromApp reads from, so any custom light theme gets silently discarded rather than respected. That said, the maintainer's own comment frames this as intentional, which makes it a debatable case rather than a clean bug — forcing black could be seen as either fixing legibility or overriding user choice. I want to check if there's something more clearly wrong elsewhere, so I'm looking at how writeSystemMessage and writeTerminalBuffer interact with replay states in ThreadTerminalDrawer.

15. git_tools — function writeTerminalBuffer|function writeSystemMessage

💭 I'm tracing a potential ordering bug where React might batch multiple atom state transitions—replay-start, snapshot, output updates, replay-complete—into a single render pass, so the effect at line 1049 checking current.version !== previous.version could miss intermediate states since only the final version diff gets seen when the effect fires once. Then I catch a more subtle issue: if live output arrives in the same batch right after replay-complete, it gets appended alongside replay chunks and treated entirely as replay content, meaning any terminal query replies embedded in that live output (like cursor-position requests) would get suppressed since the code assumes everything in that batch is replay data. I trace how status/error messages written during a streaming replay get ordered — they're tagged as replay and queued behind replay chunks, so they land correctly via writeReplay. I also verify that completeStreamingReplay defers finishing properly when the queue isn't drained yet, letting the drain logic close out replay once it empties, and that switching replayStreamOpen to false still routes new writes correctly until the drain finally calls finishReplay and hands off to core.write. Scroll-to-top behavior looks consistent across both completion paths, though I want to double check for potential leaks when drainWrites runs with an unbounded budget. I'm now worried about a real problem: if a single enqueueWrite pushes something like 4MB of replay data at once — say because the effect passes the entire joined output update as one string — that exceeds the 1MB threshold and triggers an immediate synchronous drain of the whole thing, parsing 4MB through Ghostty on the main thread in one go. That would freeze the UI for a long stretch, directly contradicting the claim that large writes get yielded so replay doesn't monopolize the main thread. I need to check whether the server actually delivers that 4MB in one append or in smaller 64K chunks. The comment makes clear this is intentional: exceeding the 1MB threshold means processing synchronously rather than lagging, a deliberate tradeoff responding to an earlier concern about the unbounded queue. Flagging the opposite direction now—that this blocks the main thread—feels like a legitimate but somewhat contrarian follow-up to that prior review. I should refocus on the check's actual domain: UI consistency around shared primitives, Tailwind ownership, theming, scroll fades, and environment routing. Looking at the diffs across ChatView, ThreadTerminalDrawer, and the ghostty core/surface renderer, the theming change seems like the most domain-relevant item to examine, and I want to verify there aren't overlooked inconsistencies in how dark mode is detected versus luminance-based theming. I'm noticing a real problem: the app's dark theme uses a --terminal-background token that's a dark gray, not pure black, so a light-mode user in vim ends up with pure black — a color that doesn't even match the app's own dark theme. That's a genuine break in theme-token ownership worth flagging, since the alternate-screen defaults should derive from the existing --terminal-* dark tokens rather than hardcoded literals. I'll leave an inline comment on terminalThemeForScreen in surface.ts suggesting this fix. I'm weighing whether this should mark the check as failing overall since it's a real theming regression risk affecting light-theme users across every full-screen app, versus defaulting to success since there's no explicit fix diff to attach. Given the schema's default-to-failure behavior when findings exist, I'll lean toward flagging this. The specific issue is that the fix should derive colors from the app's dark theme tokens rather than hardcoding a fixed palette that discards the user's custom terminal colors — that's the precise, minimal fix to recommend rather than suggesting removing the override entirely.

16. browse_code — apps/web/src/terminal/ghostty/surface.ts

💭 I should check whether custom themes define --terminal-* variables in index.css or the theme definitions, which would support the claim that a light custom terminal palette exists and could be lost.

17. git_tools — --terminal-background

💭 This is a real finding — the terminal mount container and pane wrapper are styled with the terminal background variable, and the canvas sits inside them. So when the alternate screen forces the canvas to pure black while the surrounding container keeps a lighter background, the mismatch shows up in the gaps between split panes and any uncovered container area. I should verify whether the canvas actually fills the mount exactly or is sized to cols times cell width, since grid rounding could leave leftover space at the edges — checking the fit() logic will clarify this.

18. git_tools — fit(): void

19. git_tools — fit()

💭 The canvas itself fills the mount fully so there's no mismatch there, but the pane wrapper's gaps between split panes and sidebar stay light against the black canvas — a minor visual inconsistency worth noting before digging into the more important issue. I'm reconsidering the tab bar, which follows theme colors, meaning a black canvas sits inside a light-mode drawer — likely an intended design choice rather than a bug. Given that, I'll flag the theme-token ownership issue as my primary finding, though I want to double-check another candidate: the requestRender() method that gates rendering behind isSynchronizedOutput(). This suppresses all renders — cursor blink, focus changes, selection, scroll, resize — while DEC mode 2026 is active, deferring updates up to 500ms, which seems acceptable per the existing comment, but I want to verify there isn't a deeper edge case with synchronized output handling. I've traced through the reset path (mode 2026 being cleared triggers a write, which calls didWriteOutput and then requestRender, correctly scheduling a frame once no longer synchronized) and the scroll case (suppressed during sync mode, but caught by the 500ms fallback timer, causing a brief frozen feel). The synchronized timer's fallback render and the frame-cancellation logic in the non-synchronized path both seem to behave correctly, and the mode-2026 detection via isSynchronizedOutput() checks out against the core implementation. I'm now moving on to examine one more potential issue that's within scope. In ThreadTerminalDrawer, I'm checking the effect that resets extendedReplayIdentity to null on identity changes — confirming it doesn't clash with requestExtendedReplay since the effect's dependency doesn't change when that function fires, and that later identity changes correctly reset state to idle. I'm now looking at whether the extended replay byte-count check at line 1012 causes issues on the default 64KB replay path during reconnects. I've settled on the fix — now I need to place a single inline comment on surface.ts, likely around the constant definitions near lines 52-57 covering ALTERNATE_SCREEN_DARK_THEME, making sure the comment lands within the actual diff hunk range so it's visible in the review. ). This means any theme with a light terminal background will still show alternate-screen programs like vim or htop with hardcoded dark-on-light colors and a mismatched blue cursor, while the surrounding pane keeps the correct themed background — creating a visible mismatch around the terminal content. I'm thinking the fix should derive the alternate-screen fallback from theme tokens instead, so custom themes retain control over these colors. This is a real consistency risk worth flagging, so I'll mark the check as a failure and write up the finding.

20. modify_pr — One finding on terminal theming ownership; the rest of the changed web surface (streamed replay wiring in ThreadTerminalDrawer, write queue bounds, ChatView unmount) looks consistent with the existing contracts.


Agent Credits: 318 credits

Token usage: 40 uncached input · 2,657,051 cache-read input · 181,762 cache-write input · 22,387 output
Agent Credits may also include non-token charges from external tools such as web research.