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

Status Closed — not planned
Reported on v2.1.223
Maintainer reply None cached
Activity 2 comments · opened Aug 7, 2026 · closed Sep 16, 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 2 comments on GitHub. Read the full discussion on GitHub ↗