Skip to content

UI restyle: quieter chrome, status panel as a left sidebar - #74

Merged
wfaderhold21 merged 10 commits into
mainfrom
topic/ui-restyle
Aug 4, 2026
Merged

UI restyle: quieter chrome, status panel as a left sidebar#74
wfaderhold21 merged 10 commits into
mainfrom
topic/ui-restyle

Conversation

@wfaderhold21

@wfaderhold21 wfaderhold21 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Same information, less chrome. Six commits, each independently revertable; the first is the enabling refactor and renders identically to main.

Commit What
theme: One Theme in place of ~170 colour literals. [theme] config, classic/dark/ansi16 bases, COLORTERM auto-detection, reported by doctor. No visual change.
ui: tab strip Session bar → one row above the output. Names sessions, not kinds. State moves to a suffix glyph. −1 row
ui: footer Focused session's name, model, context, state, elapsed, cost on the bottom row. +1 row
ui: status overlay Panel gains an overlay mode and two-line rows.
ui: pane chrome Boxes → title line + a left-edge bar that marks focus and divides panes. +1 row
ui: left sidebar Panel becomes a permanent left sidebar, on by default. +8–10 rows, −28 columns

Status panel

 Status                    │▎ ● alpha · ~/src/linkshell
 ● alpha            1m32s  │▎ $ cargo test
   READY       18.0k/180k  │▎
   opus-4-8         $0.31  │▎
                           │▎
 ! beta               12s  │▎
   WAITING           2.1k  │▎
   sonnet-5         $0.04  │▎
                           │
 2 sess            $0.35   │

Permanently visible, on by default, alt-s to tuck away. Two things make the left the right axis:

  • Terminals are wide and short. At 200×50 you give up 14% of the width to reclaim 18% of the height.
  • A sidebar has the full column height, so it doesn't hit the height/3 cap 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 + 60 columns 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.

[general]
status_panel = "left"    # "left" (default) | "bottom" | "overlay" | "off"
status_panel_width = 28

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 independent saturating_sub(2)s in ui.rs, main.rs and app.rs — so a naive Borders::NONE swap would have widened every PTY by two columns, which renders fine and mis-maps every drag-selection. They now share ui::pane_content_area, and its width is deliberately width - 2, identical to what Borders::ALL yielded.

Verified live rather than by inspection: tput cols reports 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. overlay is 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

  • State survives the loss of border colour. The slot boxes carried state in their borders; a one-row strip has nowhere to put that. Suffix glyphs (! WAITING, ERROR/dead, paused) plus the footer plus the rail are what keep "which agent wants me" legible everywhere.
  • classic stays the default palette. The theme commit is a genuine no-op on screen, snapshot-pinned against a TestBackend buffer. base = "dark" opts into the new palette; worth driving before we consider flipping the default.
  • Auto-detection reads COLORTERM, not TERM. TERM says 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 carefully-chosen "dim" ends up indistinguishable from "text".
  • Tabs name sessions, not kinds. Three shells all reading "shell" is the case the strip most needs to disambiguate; kind is carried by the tab's colour.
  • Pipe direction is new information. The status table gave it a fixed 20-column cell that truncated a second peer out of existence.
  • No Nerd Font glyphs. Only box-drawing, braille (already in spinner_frame), and ● ○ ▎ ✕ !.

Testing

373 tests pass; each commit builds and passes independently. cargo fmt --check and cargo clippy --all-targets are 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-s hides and restores it; and each placement renders from config.

Docs

docs/panes-and-navigation.md gains Tab strip and Footer sections and a rewritten Status panel section; docs/config-reference.md gains [theme], status_panel and status_panel_width. CHANGELOG.md has entries per change.

🤖 Generated with Claude Code

wfaderhold21 and others added 6 commits August 3, 2026 21:13
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>
@wfaderhold21 wfaderhold21 changed the title UI restyle: quieter chrome, same information UI restyle: quieter chrome, status panel as a left sidebar Aug 4, 2026
wfaderhold21 and others added 4 commits August 3, 2026 22:33
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>
@wfaderhold21
wfaderhold21 merged commit c8aad8c into main Aug 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant