[BUG] TUI footer: status chip pushed onto its own line while the hint row is truncated

Status Open
Reported on v2.1.223
Maintainer reply None cached
Activity 1 comment · opened Aug 7, 2026

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

  1. A git repo with a GitHub remote, and gh not authenticated (adds gh auth login for PR status

to the hint row). Two background agents running adds ← 2 agents.

  1. claude --dangerously-skip-permissions (adds bypass permissions on (shift+tab to cycle)).
  2. Activate remote control (/rc) and pair, so the green /rc chip renders.
  3. 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_

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗