Symptom
On a deck with ~55 live sessions the TUI sits at a high steady-state CPU share and
session switching (j/k) feels laggy — each keystroke visibly trails the input.
Sandboxed repro
Fully isolated: throwaway HOME, its own profile, its own tmux socket
([tmux] socket_name), TUI running inside that isolated tmux server. 60 synthetic
sessions created with --cmd "sleep 100000", terminal 220x50.
export HOME=$(mktemp -d); unset XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME TMUX TMUX_PANE
export TMUX_TMPDIR=$HOME/tmuxtmp AGENTDECK_PROFILE=perftest
printf '[tmux]\nsocket_name = "adperf"\n\n[logs]\npprof_enabled = true\n' > $HOME/.agent-deck/config.toml
for i in $(seq 1 60); do agent-deck add $HOME/work -t "perf$i" -cmd "sleep 100000" -g perf; done
for i in $(seq 1 60); do agent-deck -p perftest session start "perf$i"; done
# then run the TUI inside `tmux -L adperf` with AGENTDECK_DEBUG=1
Workload: 240 single-key j/k navigation events, one send-keys per key so no
paste coalescing hides render cost. Metric: the view_render perf log line, which
already records every View() duration.
Profile
go tool pprof against the sandbox process ([logs] pprof_enabled, localhost:6060)
during the switching workload:
flat flat% cum cum%
0 0% 650ms 53.72% bubbletea.(*Program).Run
0 0% 620ms 51.24% ui.(*Home).View
0 0% 450ms 37.19% ui.(*Home).renderDualColumnLayout
0 0% 320ms 26.45% lipgloss.Style.Render
260ms 21.49% 260ms 21.49% runtime.memclrNoHeapPointers
View() is 51% of all CPU samples, and renderDualColumnLayout is 37%. Line level:
ROUTINE ==== ui.(*Home).renderDualColumnLayout
. 100ms 14312: leftContent := h.renderSessionList(...)
. 30ms 14343: leftPanel = ensureExactWidth(leftPanel, leftWidth)
. 100ms 14347: mainContent := lipgloss.JoinHorizontal(...)
. 170ms 14354: mainContent = lipgloss.NewStyle().MaxWidth(h.width).Render(mainContent)
Root cause
Line 14354 is a "safety net" that runs MaxWidth(h.width) over the entire
already-composed screen on every single View() — i.e. on every keystroke and
every 2s tick. It re-parses and re-measures every line and every ANSI sequence of
the full frame, costing 170ms of the 450ms layout render (38%), ~14% of total
process CPU — and in the normal case it truncates nothing:
splitPaneWidths() documents and guarantees
left + paneSeparatorWidth + right == h.width.
ensureExactWidth() is applied to both panels immediately before the join, and
makes every panel line exactly its pane width in display cells.
- the separator is exactly
paneSeparatorWidth display cells wide.
So every joined line is already exactly h.width cells and the pass is a pure
no-op. It can only matter in the degenerate branch of splitPaneWidths(), where a
pane width can come back non-positive and ensureExactWidth() returns its input
untouched.
Secondary, same function: the separator column re-renders the identical constant
" │ " through lipgloss once per screen row (contentHeight times) per frame,
when one render can be reused for every row.
Baseline over 3 runs of the identical workload (n≈748 renders each):
| run |
mean |
median |
p90 |
| 1 |
2.672 ms |
2.197 ms |
4.416 ms |
| 2 |
2.542 ms |
2.201 ms |
3.371 ms |
| 3 |
2.575 ms |
2.083 ms |
3.197 ms |
Proposed fix
Keep the safety net, but only pay for it when it can actually do something: compute
the guaranteed joined width from the component widths and skip the full-screen
MaxWidth pass when it cannot overflow h.width. Hoist the constant separator
render out of the per-row loop. Both are O(1) changes in renderDualColumnLayout
with no behavioural difference on any non-degenerate terminal width.
Symptom
On a deck with ~55 live sessions the TUI sits at a high steady-state CPU share and
session switching (
j/k) feels laggy — each keystroke visibly trails the input.Sandboxed repro
Fully isolated: throwaway
HOME, its own profile, its own tmux socket(
[tmux] socket_name), TUI running inside that isolated tmux server. 60 syntheticsessions created with
--cmd "sleep 100000", terminal 220x50.Workload: 240 single-key
j/knavigation events, onesend-keysper key so nopaste coalescing hides render cost. Metric: the
view_renderperf log line, whichalready records every
View()duration.Profile
go tool pprofagainst the sandbox process ([logs] pprof_enabled, localhost:6060)during the switching workload:
View()is 51% of all CPU samples, andrenderDualColumnLayoutis 37%. Line level:Root cause
Line 14354 is a "safety net" that runs
MaxWidth(h.width)over the entirealready-composed screen on every single
View()— i.e. on every keystroke andevery 2s tick. It re-parses and re-measures every line and every ANSI sequence of
the full frame, costing 170ms of the 450ms layout render (38%), ~14% of total
process CPU — and in the normal case it truncates nothing:
splitPaneWidths()documents and guaranteesleft + paneSeparatorWidth + right == h.width.ensureExactWidth()is applied to both panels immediately before the join, andmakes every panel line exactly its pane width in display cells.
paneSeparatorWidthdisplay cells wide.So every joined line is already exactly
h.widthcells and the pass is a pureno-op. It can only matter in the degenerate branch of
splitPaneWidths(), where apane width can come back non-positive and
ensureExactWidth()returns its inputuntouched.
Secondary, same function: the separator column re-renders the identical constant
" │ "through lipgloss once per screen row (contentHeighttimes) per frame,when one render can be reused for every row.
Baseline over 3 runs of the identical workload (n≈748 renders each):
Proposed fix
Keep the safety net, but only pay for it when it can actually do something: compute
the guaranteed joined width from the component widths and skip the full-screen
MaxWidthpass when it cannot overflowh.width. Hoist the constant separatorrender out of the per-row loop. Both are O(1) changes in
renderDualColumnLayoutwith no behavioural difference on any non-degenerate terminal width.