[BUG] TUI footer: status chip pushed onto its own line while the hint row is truncated
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
PR was written by Claude and reviewed by a human being [me]
Version: 2.1.223 (Claude Code)
Platform: macOS (iTerm/Terminal, 134 columns) - also reproduced headlessly on Linux in a pty
Area: REPL footer layout
Related: https://github.com/anthropics/claude-code/issues/83402 (footer row wasted in fullscreen),
https://github.com/anthropics/claude-code/issues/84281 (narrow-terminal row-height bug),
https://github.com/anthropics/claude-code/issues/78651 and
https://github.com/anthropics/claude-code/issues/79397 (requests to hide or slim the hint row - this
bug is one reason it costs more vertical space than it should). None is a duplicate; searched open
and closed, title and body.
Summary
When the footer hint row is long enough to need truncation, the right-hand status cluster
(/rc, Debug, ⧉ N lines from …, mode labels) is moved to a second, right-aligned row
instead of staying on the same row. The hint row is truncated anyway, so the extra row buys
nothing - the terminal just loses a line, and the footer visibly jumps between one and two rows
as hints come and go.
The footer row is flexWrap:"wrap". Flexbox collects children into lines using each child's
hypothetical (un-shrunk) main size, before flex-shrink runs. The hints child is wider than
the container on its own, so it claims the whole first line and the 3-column /rc chip is wrapped
to line 2 - even though the hints child is flexShrink:1 and is about to be truncated to fit.
Truncation happens later, at paint time, on a line that was already chosen.
Screenshots
Bug reproduced - stock 2.1.223 at 134 columns. Hints truncated to shift+click to n…, /rc
alone on the next line:
<img width="605" height="161" alt="Image" src="https://github.com/user-attachments/assets/1f52aecf-d7bd-4289-8a47-a562c2f95765" />
Intended result - same session, same width, with the footer row forced to no-wrap by a local
one-byte edit to the shipped bundle (a throwaway workaround, shown in the appendix - not the fix
proposed here). Hints truncate ~4 columns earlier, /rc stays on the row:
<img width="614" height="174" alt="Image" src="https://github.com/user-attachments/assets/c65ca961-c4c4-437b-82b2-853723cb80a2" />
Control - when the hint row is short enough not to overflow, /rc is already on the same row,
which is the intended look:
<img width="608" height="123" alt="Image" src="https://github.com/user-attachments/assets/52eb0126-3122-49b5-892c-6240901eb02e" />
I don't have the source - this is reverse-engineered
Claude Code ships as a minified single-file Bun executable, and this repository contains no CLI
source, so I could not read the real component. What follows is reconstructed by RE'ing the
shipped bundle (byte-offset grep over the embedded JS in ~/.local/share/claude/versions/2.1.223).
Identifier names are minifier output and my structural reading may not match your actual source -
please treat the reconstruction as a description of the layout, not of your file.
Verification of the [probably] proposed fix, in plain Ink
Since I can't build your tree, I rebuilt the footer row from the reconstruction above in plain Ink -
same container props, same flexShrink:1 hints column, same flexShrink:0 / marginLeft:"auto"
status cluster. It reproduces the bug, and the proposed change fixes it.
Nothing to clone. Save the file below as footer.mjs and:
mkdir footer-repro && cd footer-repro
npm init -y >/dev/null && npm install ink react # ink@7.1.1, react@19.2.8 here
# ...save footer.mjs into this directory...
node footer.mjs # bug: status chip pushed onto its own line
FIX=1 node footer.mjs # proposed fix: single line
WIDTH=85 node footer.mjs # simulate any width (default 78)
$ node footer.mjs
──────────────────────────────────────────────────────────────────────────────
⏸ manual mode on · ? for shortcuts · ← 2 agents · shift+click to native se…
/rc
──────────────────────────────────────────────────────────────────────────────
$ FIX=1 node footer.mjs
──────────────────────────────────────────────────────────────────────────────
⏸ manual mode on · ? for shortcuts · ← 2 agents · shift+click to nativ… /rc
──────────────────────────────────────────────────────────────────────────────
FIX=1 toggles exactly one thing - the two extra props on the hints column:
const hintsColumn = h(
Box,
- { flexDirection: "column", flexShrink: 1 },
+ { flexDirection: "column", flexShrink: 1, flexBasis: 0, flexGrow: 1 },
hintRow,
);
<details>
<summary><b>footer.mjs</b> (self-contained, no JSX build step)</summary>
// Self-contained reproduction of the Claude Code REPL footer layout, in plain Ink.
//
// node footer.mjs -> bug: the status chip is pushed onto its own line
// FIX=1 node footer.mjs -> proposed fix: one line, hints truncate a few columns earlier
// WIDTH=85 node footer.mjs -> terminal width to simulate (default 78)
import React from "react";
import { render, Box, Text } from "ink";
const h = React.createElement;
const WIDTH = Number(process.env.WIDTH || 78);
const FIX = !!process.env.FIX;
// When stdout is a pipe, Ink assumes 80 columns and would re-wrap the output itself.
if (!process.stdout.columns || process.stdout.columns < WIDTH) process.stdout.columns = WIDTH;
const HINTS = [
"⏸ manual mode on",
"? for shortcuts",
"← 2 agents",
"shift+click to native select",
];
// Every group is flexShrink:0; only the trailing Text may truncate - as shipped.
const hintRow = h(
Box,
{ height: 1, overflow: "hidden" },
...HINTS.slice(0, -1).flatMap((text, i) => [
h(Box, { key: `g${i}`, flexShrink: 0 }, h(Text, null, text)),
h(Box, { key: `s${i}`, flexShrink: 0 }, h(Text, { dimColor: true }, " · ")),
]),
h(Text, { wrap: "truncate" }, HINTS[HINTS.length - 1]),
);
// Left child: the hints column. flexShrink:1 says "I am the one that gives up space",
// but with flexWrap the line is collected from the UNSHRUNK size, so it takes the whole row.
// THE FIX: flexBasis:0 makes it contribute 0 at line collection, flexGrow:1 gives it the rest.
const hintsColumn = h(
Box,
FIX
? { flexDirection: "column", flexShrink: 1, flexBasis: 0, flexGrow: 1 }
: { flexDirection: "column", flexShrink: 1 },
hintRow,
);
// Right child: the status cluster (/rc, Debug, IDE selection, mode labels).
const statusCluster = h(
Box,
{ flexShrink: 0, marginLeft: "auto", flexDirection: "column", alignItems: "flex-end" },
h(Text, { color: "green" }, "/rc"),
);
const footer = h(
Box,
{
width: WIDTH,
flexDirection: "row",
flexWrap: "wrap", // kept in both modes - the fix does not remove the wrap fallback
alignItems: "flex-start",
paddingLeft: 2,
paddingRight: 1,
columnGap: 1,
},
hintsColumn,
statusCluster,
);
const ruler = h(Text, { dimColor: true }, "─".repeat(WIDTH));
const { unmount } = render(h(Box, { flexDirection: "column" }, ruler, footer, ruler));
setTimeout(unmount, 50);
</details>
Footer rows by simulated width. The script prints a ruler above and below the footer, so wc -l
returns footer rows + 2; the - 2 below is that offset. The hint row's natural width is 78 columns
in the repro:
for w in 78 82 84 85 90; do
echo "$w: shipped=$(( $(WIDTH=$w node footer.mjs | wc -l) - 2 ))" \
"fixed=$(( $(FIX=1 WIDTH=$w node footer.mjs | wc -l) - 2 ))"
done
78: shipped=2 fixed=1
82: shipped=2 fixed=1
84: shipped=2 fixed=1
85: shipped=1 fixed=1
90: shipped=1 fixed=1
flexWrap:"wrap" is still set in both modes - the fix does not remove the wrap fallback.
Context for agent
Background for whoever - or whatever - picks this up: the measurement that rules out the obvious
explanation, and exactly how the "intended result" screenshot above was produced.
It is not the ellipsis
My first suspicion was that the … in shift+click to n… is one glyph being counted as three
(...). It isn't - the arithmetic is correct:
| | columns |
|---|---|
| terminal width | 134 |
| footer paddingLeft:2 + paddingRight:1 | 3 |
| inner width | 131 |
| rendered hint row (incl. …) | 131 ✓ |
| hint row natural/untruncated width | 142 |
| status cluster (/rc) + columnGap:1 | 4 |
… measures 1 column and the truncation width is right. The defect is purely the line-breaking
decision: 142 > 131, so the hints child takes line 1 alone.
Appendix: how the "intended result" screenshot was produced
This is a throwaway local workaround so the effect could be seen on the shipped build. It is not
the proposed fix - it deletes the wrap fallback, whereas the proposal above keeps it and changes
which child claims the line.
The binary is a Bun single-file executable: the JS is embedded and the appended module graph is
addressed by byte offset, so it can't be recompiled and can't change length. Ink applies Yoga props
conditionally:
function lVy(e){ switch(e){ case "wrap": return 1; case "wrap-reverse": return 2; default: return 0 } }
...
if ("flexWrap" in t) e.setFlexWrap(lVy(t.flexWrap));
So mangling the key by one byte leaves the node at Yoga's default NoWrap, with the file length
untouched:
@@ footer container, bundle offset 279920710 @@
-...flexDirection:rHt,flexWrap:"wrap",alignItems:"flex-start",paddingLeft:2,...
+...flexDirection:rHt,flexwrap:"wrap",alignItems:"flex-start",paddingLeft:2,...
$ cmp -l 2.1.223 2.1.223-footer-nowrap
279920715 127 167 # 1-based offset, octal 'W' -> 'w'; sizes identical (290,728,968 bytes)
That single byte is the difference between the first and second screenshots. flexWrap:"wrap", is
exactly 16 bytes, so blanking it with spaces works equally well as a length-preserving deletion -
the case-flip was just the smallest auditable edit.
What Should Happen?
Here is what I believe the code is. The footer container, at bundle offset 279920710:
xIl = rx.jsxs(I, {
width: MFm, flexDirection: rHt,
flexWrap: "wrap", // <-- the bug
alignItems: "flex-start",
paddingLeft: 2, paddingRight: mGn ? 1 : 2, columnGap: 1,
children: [AIl, CIl]
})
Its two children (offsets 279919761 and 279921532) already declare the intended behaviour,
which flexWrap defeats:
// AIl - hints column, meant to give up space
AIl = rx.jsxs(I, { flexDirection: "column", flexShrink: 1, children: [EIl, wIl, !1] })
// hjv (rendered as CIl) - status cluster, meant to keep it
hjv = rx.jsxs(I, { flexShrink: 0, marginLeft: "auto",
flexDirection: "column", alignItems: "flex-end",
children: [zFm, IIl] })
De-minified, that is presumably something close to:
<Box
width={width}
flexDirection="row"
flexWrap="wrap"
alignItems="flex-start"
paddingLeft={2}
paddingRight={compact ? 1 : 2}
columnGap={1}
>
<Box flexDirection="column" flexShrink={1}>
{hintRow} {/* groups are flexShrink:0; only the trailing <Text wrap="truncate"> shrinks */}
</Box>
<Box flexShrink={0} marginLeft="auto" flexDirection="column" alignItems="flex-end">
{statusChips} {/* /rc, Debug, ⧉ N lines from …, mode labels */}
</Box>
</Box>
marginLeft="auto" + alignItems="flex-end" is what right-aligns the cluster once it lands on its
own line, which is why the stray /rc appears flush right rather than under the hints.
Proposed fix
Keep flexWrap as a safety valve for the pathological case (a very wide status cluster on a very
narrow terminal), and stop the hints child from claiming the whole line during line collection:
- <Box flexDirection="column" flexShrink={1}>
+ <Box flexDirection="column" flexShrink={1} flexBasis={0} flexGrow={1}>
{hintRow}
</Box>
With flex-basis: 0 the hints child contributes 0 to flex line collection, so both children stay
on line 1; flex-grow: 1 then hands it all the leftover space and the existing wrap="truncate"
does the rest. One prop pair, on one child, and no behaviour change at widths where the footer
already fits.
Error Messages/Logs
Steps to Reproduce
Reproduction in a clean session
- A git repo with a GitHub remote, and
ghnot authenticated (addsgh auth login for PR status
to the hint row). Two background agents running adds ← 2 agents.
claude --dangerously-skip-permissions(addsbypass permissions on (shift+tab to cycle)).- Activate remote control (
/rc) and pair, so the green/rcchip renders. - Leave the input empty and narrow the terminal until the hint row shows
….
With the hint set in the screenshot (142 columns natural):
| terminal width | result |
|---|---|
| ≤ 144 | hints truncated and /rc on its own line |
| 145-148 | hints in full, no …, still /rc on its own line |
| ≥ 149 | single line, as intended |
printf '\e[8;40;148t' vs printf '\e[8;40;149t' flips it.
/rc is only the child that happens to get displaced - any occupant of the status cluster does it.--debug is the easiest one, since it pushes a Debug chip into the same cluster with no pairing
required. Running 2.1.223 with --debug in a 60×30 pty:
28| ⏸ manual mode on · gh auth login for PR status
29| Debug · /rc <- extra line
Claude Model
None
Is this a regression?
Yes, this worked in a previous version
Last Working Version
_No response_
Claude Code Version
2.1.223
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
_No response_
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗