UI restyle: quieter chrome, status panel as a left sidebar - #74
Merged
Conversation
Colour was decided inline at ~170 sites in ui.rs, as a mix of `Color::` literals and two ad-hoc helpers. That made any restyle a find-and-replace across a 3300-line file, and made a terminal-specific fallback impossible to express at all. Every colour now comes from a `Theme`, carried on `App` so each `draw_*` reaches it through the `&App` it already takes rather than a parameter threaded through forty signatures. The three fns that don't take `&App` (`draw_help`, `draw_file_browser`, `draw_too_small`) take `&Theme` explicitly, as does `build_row_line` for its selection colours — the vt100 cell colours it renders are the session's own output and stay untouched. Three bases: `classic` is the palette linkshell already shipped, so this commit is a no-op on screen; `dark` is the restyle palette; `ansi16` is named colours only. With `[theme] base` unset the choice comes from COLORTERM, not TERM — TERM reads xterm-256color on nearly everything, truecolor-capable or not, and an Rgb colour on a terminal that can't render it is quantized to whatever is nearest, which is how a chosen "dim" ends up indistinguishable from "text". `doctor` reports what resolved. The frame is now snapshot-pinned against a TestBackend buffer, so the restyle commits that follow have to edit that expectation deliberately rather than change the rendering unreviewed. Co-Authored-By: Claude <noreply@anthropic.com>
Three rows of bordered slot boxes to carry an index, a kind and a state dot, on a terminal where the chrome already claimed about a third of the screen. The strip is one row plus a rule, above the output rather than below it — a label under the thing it labels reads as a caption for whatever comes next. Two things it does that the bar didn't: Tabs name the session, not its kind. Three shells all reading "shell" is the case a strip most needs to disambiguate, and the kind is already carried by the tab's colour. State survives as a suffix glyph. The slot boxes put state in their border colour, which a one-row strip has nowhere to put; without a replacement, closing the status panel would cost you "which agent wants me", and being able to close the status panel is the whole point of shrinking the chrome. Overflow is a three-rung ladder — full names, names on the active tab only, bare indices — rather than a width-budget solve, because there are only three rungs and each is checked by asking whether it fits. Squeezed tabs still return a click rect, zero-width if it comes to that: `app.rs` reads `session_slot_areas` positionally and maps index i back through `visible_to_idx`, so a skipped entry would silently focus the wrong session rather than doing nothing. Verified live under tmux: alt-N and mouse clicks on both the strip and the status rows focus the right session, and a WAITING session marks itself. Co-Authored-By: Claude <noreply@anthropic.com>
Name, model, context, state, elapsed, cost — the questions the status panel was being kept open to answer, for the one session actually in front of you, in a single row. Two judgements worth naming. The context counter turns amber past 80% of the window, but only when the window is known: a local model that was never probed has no denominator, so there is no percentage to colour against, and it renders a bare count rather than a threshold invented to have something to colour. And fields are shed left to right as the terminal narrows instead of wrapping — a wrapped footer would silently eat a row of output, which is the opposite of what this change is for. The hint goes first, then cost, then elapsed; the session's identity is what survives to the minimum width. Tested across every width from MIN_COLS to 100 for overflow and row count, and driven live under tmux at 100 and 40 columns. Co-Authored-By: Claude <noreply@anthropic.com>
The panel was costing 8–10 rows of a four-session setup permanently, to answer a question that is only asked intermittently. It is now an alt-s overlay, and those rows go to the output pane. `[general] status_panel = "docked"` keeps the old always-on region. An overlay and not a split, deliberately: a docked panel takes rows from the output pane, so toggling it resizes every PTY and makes every full-screen agent repaint. There is a test asserting the pane rects are identical with the overlay open and closed, because that is the regression that would actually hurt. Rows are two lines: vitals, then model, cwd and pipe direction. The pipe direction is new information — the column table gave it a fixed 20-column cell that truncated a second peer out of existence. The `│` separators are gone in favour of fixed-width padding; they carried nothing the padding doesn't, and they were what drifted whenever a field rendered wider than its column. Docked mode has to pay for the second line out of the output pane, and the height/3 cap reaches that twice as fast, so past four sessions it drops the detail line rather than showing half the sessions — the detail is still in the overlay, one keystroke away. Driven live under tmux in both modes: alt-s opens and esc closes, the docked panel comes back with the config set, and the footer hint tracks which mode is active. Co-Authored-By: Claude <noreply@anthropic.com>
A title line and a one-column bar down the left edge in place of `Borders::ALL`. The bar does double duty — `▎` in the accent marks the focused pane, `│` in chrome separates a pane from its neighbour — so a split needs neither per-pane boxes nor a separate divider, and the title carries the focus distinction the border colour used to. The hazard here was never the rendering, it was the width. Content width is three things at once: what the renderer draws into, what the PTY is sized to, and what mouse selection maps against vt100 columns. Those were three independent `saturating_sub(2)`s in ui.rs, main.rs and app.rs, so a bare `Borders::NONE` swap would have widened every PTY by two columns — which renders fine and mis-maps every drag-selection, i.e. fails silently. They now all call `ui::pane_content_area`, and the content width it returns is deliberately `width - 2`, identical to what the box yielded, so this commit resized no PTY. Height gains a row: the bottom border is gone and the title is not. Verified live rather than by inspection: `tput cols` inside a session reports 98 in a 100-column terminal and 48 in a 50-column split pane, a 98-character line fills its row without wrapping, and a drag from screen column 3 to 12 highlights exactly content columns 0..9. Same treatment for the docked chat and planning panes, or half a split would look like a different application. The floating chat overlay keeps its box — a floating thing needs an edge to read as floating. Co-Authored-By: Claude <noreply@anthropic.com>
Permanently visible, on by default, alt-s to tuck away — and moved to the left, where it costs columns instead of rows. Two things make that the right axis. Terminals are wide and short now: at 200x50 you give up 14% of the width to reclaim 18% of the height. And a sidebar has the full column, so it does not hit the height/3 cap that the bottom region hits at four sessions — the drop-the-detail-line fallback I had to add for the bottom placement simply doesn't arise. There is a test holding eight sessions in the sidebar at a height where the bottom region can only show six. The row had to be restacked to make this affordable at all. The horizontal format is 70 columns wide (3+15+10+9+10+15+8), which as a sidebar would leave 30 columns of output on a 100-column terminal; stacked vertically it fits in 28. A session with no model — a shell — shows its working directory on the third line rather than "- —", which is a line spent saying nothing, and the cwd is what actually distinguishes it from the shell in the next pane. Narrow terminals collapse it to a three-column rail instead of dropping it. "Permanently there" that disappears exactly when the screen is busiest isn't permanent, and the panel's real job — which agent needs you — still fits in a state glyph and an index. `status_panel` is now a placement: "left" (default), "bottom", "overlay", "off". The overlay and bottom modes from the previous commit are kept; they cost almost nothing on top of the mode machinery already there, and "bottom" is the escape hatch for a narrow-but-tall terminal. Verified live: sidebar, rail at 80 columns, and alt-s in both directions. The PTY reports 138 columns hidden and 110 shown on a 140-column terminal — exactly the sidebar's 28. Co-Authored-By: Claude <noreply@anthropic.com>
Codex sessions had no scrollback at all. Both paths that could have supplied it produced nothing, for reasons that only look alike from a distance. The capture linkshell already had was gated on `alternate_screen()`. Codex never enters the alternate screen — it renders inline on the normal screen with absolute cursor positioning — so the capture never ran. Having fallen through to the vt100 branch, it found an empty scrollback, because codex scrolls its transcript inside a DECSTBM region and `Grid::scroll_up` only retains an evicted line `if !self.scroll_region_active()`. That is correct per the DEC spec: lines pushed out of a restricted region are discarded. So the one check we had classified codex as an ordinary normal-screen app, and the buffer it was sent to was empty by design. The transcript is now recovered from the scrolling region for any kind we capture for, on either screen. Three things that took making it work: The chunk is split at each DECSTBM, because codex sets `ESC[1;25r`, scrolls, and resets, dozens of times a second — a diff spanning the change compares the transcript against the composer pinned below it, reads as a scroll, and files the composer as history. Within a piece it is split again at newlines, because one chunk carrying six newlines scrolls six times and a single before/after snapshot only shows where it landed. The shift is found by locating the new top row among the old ones rather than matching the whole overlap. The obvious formulation — smallest k with `prev[k..] == cur[..n-k]` — never matches on a live screen, because the same chunk that scrolled also wrote into the bottom rows. Two bugs surfaced on the way, both from `output_lines` serving as both the raw PTY line stream and the scrollback buffer. For a repainting TUI the stream is repaint fragments, so window-title escapes and composer rows appeared in the scrollback view; and the scroll offset, clamped against that much longer buffer, ran hundreds of lines past the real transcript. The recovered transcript now has its own buffer. Separately, the visible window was `total - offset`, which collapses to nothing at the oldest line: the scrollback went blank exactly when you reached its beginning. It now stops at the oldest full page. Verified against a real codex session under tmux — scrolling back through a 60-line answer shows the transcript in order, with no composer or title-escape noise, and paging forward returns to the live tail — and by replaying 54KB of captured codex PTY output, which yields the banner followed by the answer in order with no duplicates. Co-Authored-By: Claude <noreply@anthropic.com>
Arrowing down in the Orchestrator section made the cursor vanish. The selection was still there — it had landed on "Show/Hide Session", which is disabled unless a CLI-class orchestrator session exists — but the renderer checks `!item.enabled` before `selected`, so a disabled row is drawn dim and never highlighted. You could not see where you were, and Enter did nothing. Arrow keys already stepped over separators. Disabled rows are the same kind of thing for the same reason: unselectable, and rendered without a highlight. Both are now skipped, via one `MenuItem::is_selectable`, so the next row that gains a disabled state cannot reintroduce this. A section with nothing selectable now leaves focus on the menu bar rather than on an invisible selection, and `Up` pops back to the bar from the topmost *selectable* row rather than from literal index 0 — keying on 0 wrapped to the bottom of the list whenever the first row was a separator or disabled. The regression tests were checked against the unfixed code: both fail without it. The first version of them did not, because they asserted through the same `is_selectable` helper they were meant to constrain, so weakening it weakened the assertion too; they now spell out the concrete property. Also confirmed live under tmux — walking the section down and back up keeps a visible cursor on every step and skips the disabled row. Co-Authored-By: Claude <noreply@anthropic.com>
Theme::resolve falls back to ansi16 when the terminal makes no truecolor claim, so the rendering fixtures got `classic` on a developer's terminal and `ansi16` in CI. Three assertions compared a rendered accent against Theme::classic() and failed on the runners only. Pin theme.base in the test fixtures so the palette under test is the one the test chose rather than the one the environment picked. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Same information, less chrome. Six commits, each independently revertable; the first is the enabling refactor and renders identically to
main.theme:Themein place of ~170 colour literals.[theme]config,classic/dark/ansi16bases, COLORTERM auto-detection, reported bydoctor. No visual change.ui: tab stripui: footerui: status overlayui: pane chromeui: left sidebarStatus panel
Permanently visible, on by default,
alt-sto tuck away. Two things make the left the right axis:height/3cap the bottom region hits at four sessions. There's a test holding eight sessions in the sidebar at a height where the bottom region can only show six.The row had to be restacked to make this affordable: the horizontal format is 70 columns (
3+15+10+9+10+15+8), which as a sidebar would leave 30 columns of output on a 100-column terminal. Stacked vertically it fits in 28.Below
status_panel_width + 60columns it collapses to a three-column rail (●1/!2/✕3) rather than starving the output. "Permanently there" that disappears exactly when the screen is busiest isn't permanent, and which agent needs you still fits in a glyph and an index.The two things worth reviewing closely
PTY width accounting (
ui: pane chrome). Content width is simultaneously what the renderer draws into, what the PTY is sized to, and what mouse selection maps against vt100 columns. Those were three independentsaturating_sub(2)s inui.rs,main.rsandapp.rs— so a naiveBorders::NONEswap would have widened every PTY by two columns, which renders fine and mis-maps every drag-selection. They now shareui::pane_content_area, and its width is deliberatelywidth - 2, identical to whatBorders::ALLyielded.Verified live rather than by inspection:
tput colsreports 98 in a 100-column terminal and 48 in a 50-column split pane; a 98-character line fills its row without wrapping; a drag from screen column 3 to 12 highlights exactly content columns 0..9. With the sidebar shown vs hidden on a 140-column terminal: 110 vs 138 — exactly its 28.A docked panel resizes PTYs when toggled. Inherent to docking, and true of the sidebar too.
overlayis the placement that never does, and there's a test asserting the pane rects are identical with the overlay open and closed.Other design notes
!WAITING,✕ERROR/dead,⏸paused) plus the footer plus the rail are what keep "which agent wants me" legible everywhere.classicstays the default palette. The theme commit is a genuine no-op on screen, snapshot-pinned against aTestBackendbuffer.base = "dark"opts into the new palette; worth driving before we consider flipping the default.COLORTERM, notTERM.TERMsaysxterm-256coloron nearly everything, truecolor-capable or not, and anRgbcolour on a terminal that can't render it is quantized to whatever is nearest — which is how a carefully-chosen "dim" ends up indistinguishable from "text".spinner_frame), and● ○ ▎ ✕ !.Testing
373 tests pass; each commit builds and passes independently.
cargo fmt --checkandcargo clippy --all-targetsare clean.Every commit was also driven live under tmux: alt-N and mouse clicks focus the right session from the strip, the sidebar and the status rows; WAITING marks itself in all three places; the footer sheds fields correctly at 40 columns; the sidebar collapses to a rail at 80;
alt-shides and restores it; and each placement renders from config.Docs
docs/panes-and-navigation.mdgains Tab strip and Footer sections and a rewritten Status panel section;docs/config-reference.mdgains[theme],status_panelandstatus_panel_width.CHANGELOG.mdhas entries per change.🤖 Generated with Claude Code