Skip to content

fix(claude): replay CLI PTY output onto a screen before parsing (#3746) - #3822

Open
fanwenlin wants to merge 2 commits into
steipete:mainfrom
fanwenlin:fix/claude-cli-pty-screen-render
Open

fanwenlin wants to merge 2 commits into
steipete:mainfrom
fanwenlin:fix/claude-cli-pty-screen-render

Conversation

@fanwenlin

@fanwenlin fanwenlin commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #3746.

Summary

Claude's /usage panel is a redrawing TUI. Its renderer diffs each frame against the previous one and jumps over cells whose content did not change, using ESC[<col>G. Those characters are never retransmitted. ClaudeStatusProbe fed its PTY capture through TextParsing.stripANSICodes, which deletes the escape sequences and concatenates the leftovers — so every skipped cell becomes a hole in the text.

When a hole lands inside the word used, percentFromLine cannot tell "used" from "left", returns nil, and extractScopedWeeklyUsages drops the entire panel at its guard let percentLeft. The Fable row vanishes; the main rows survive only because their holes happened to land elsewhere.

Which data path is affected

Only the CLI PTY source. On my machine OAuth and Web are both unavailable, so Auto lands on the CLI scrape:

Source Result
OAuth API Claude OAuth credentials not found. (Keychain access disabled, no ~/.claude/.credentials.json)
Web API (cookies) No available fetch strategy for claude. (no sessionKey)
CLI PTY succeeds, dataConfidence: percentOnlyFable dropped

Before the fix: 5 consecutive codexbar usage --provider claude --source cli runs, 0 of 5 returned claude-weekly-scoped-fable.

Evidence

Captured with a temporary dump of the raw PTY bytes. The capture is committed as Tests/CodexBarTests/Fixtures/Providers/Claude/usage-pty-differential-redraw.ansi.

1. The bytes for the Fable row

<ESC>[32G        <ESC>[41G  <ESC>[44G    <ESC>[49G      51%<ESC>[59Gus<ESC>[62Gd<ESC>[64G<ESC>[K

us is written at columns 59-60, then the cursor jumps to column 62 to write d. Column 61 is never sent.

2. What column 61 held, and why it was skipped

The Fable panel replaced the "Refreshing…" layout, so this screen row previously held Approximate, based on local sessions on this machine — does not include other devices or claude.ai. Aligning both frames by terminal column:

column (1-idx)   55  56  57  58  59  60  61  62  63
previous frame    e   —   ␣   ␣   d   o   e   s   ␣     ← "machine — does not"
new frame         5   1   %   ␣   u   s   e   d   ␣     ← "51% used"
transmitted?      ✓   ✓   ✓   ✗   ✓   ✓   ✗   ✓   ✗

does and used happen to carry an e in the same column, so the diff marked that cell clean. Columns 58 and 63 were spaces in both frames and were skipped for the same reason.

The same rule explains every other jump in the frame. The bar line's written spaces cover exactly the previous frame's words (sessions 31-38, on 40-41, this 43-46, machine 48-54) and skip exactly the columns that were already blank (30, 39, 42, 47). Elsewhere in the same frame:

Last 24h<ESC>[13G· these are independent<ESC>[37Gcharacteristics<ESC>[53Gof<ESC>[56Gyour<ESC>[61Gusage,...

every jump skips one already-correct space.

3. What the parser saw vs. what was on screen

Instrumented parser output, before the fix:

scoped-model-hit:  model=Fable normalized=fable isAllModels=false
scoped-candidate:  model=Fable percentLeft=nil reset=ResetsSep23at2:59pm(Europe/Stockholm)
                   candidate=<<<█████████████████████████▌   51%usd>>>

The label and the reset text were found. Only the percentage was lost.

Replaying the same bytes onto a terminal screen reconstructs the panel exactly:

   Current session
   █▌                                                 3% used
   Resets 9am (Europe/Stockholm)

   Current week (all models)
   █████████████▌                                     27% used
   Resets Sep 23 at 3pm (Europe/Stockholm)

   Current week (Fable)
   █████████████████████████▌                         51% used
   Resets Sep 23 at 3pm (Europe/Stockholm)

Fix

Sources/CodexBarCore/TerminalScreenRenderer.swift replays a PTY stream onto an in-memory screen and returns the visible text, keeping scrolled-off lines as history. It implements the VT100/xterm subset CLI panels use — cursor motion, erase in line/display, insert/delete lines and characters, scroll regions, autowrap — and skips SGR and other presentation-only sequences. Text with no ESC in it is returned untouched, so plain piped output and existing fixtures are unaffected.

ClaudeStatusProbe now runs its /usage and /status captures through it, and degrades to the old ANSI strip rather than to no data if a capture has an unexpected shape.

render takes its geometry explicitly and has no defaults: the screen is sized from ClaudeCLISession.ptyRows / ptyColumns, which is also what openpty is given. A renderer narrower than the PTY would clamp absolute column addressing and wrap in the wrong place — corrupting the text in exactly the way this PR fixes — so the two must not be able to drift apart.

A second, quieter bug goes away with it: the spaces between rendered segments were also transmitted as cursor jumps, which is why reset descriptions were reaching the menu as ResetsSep23at3pm. They now read Resets Sep 23 at 3pm (Europe/Stockholm).

Commands run

  • swift test --filter TerminalScreenRendererTests — 8 new unit tests, including the differential-redraw reconstruction.
  • swift test --filter ClaudeCLIPTYRedrawRegressionTests — 3 new tests parsing the committed real capture; one asserts that the plain strip still reads 51%usd, documenting why the screen replay is needed.
  • swift test --filter "TerminalScreenRendererTests|ClaudeCLIPTYRedrawRegressionTests|ClaudeCLIScopedWeeklyUsageTests|StatusProbeTests|ClaudeCLISessionTests|TextParsingTests" — 311 tests, all passing.
  • Full swift test — no new failures. (The suite has pre-existing timing-sensitive flakes under parallel load; the three ClaudeOAuthPromptCoalescingTests failures reproduce on main unchanged.)
  • ./Scripts/lint.sh lint — 0 violations.
  • ./Scripts/compile_and_run.sh — packaged and relaunched CodexBar.app for bundle-level validation.

Verification

Live, after the fix: codexbar usage --provider claude --source cli returned claude-weekly-scoped-fable on 4 of 4 consecutive runs (0 of 5 before). The packaged bundle's own CLI agrees:

extra: claude-weekly-scoped-fable  Fable only  55  Resets Sep 23 at 3pm (Europe/Stockholm)
Before After
image image

Note for other providers

GeminiStatusProbe and CodexStatusProbe strip ANSI from their own PTY captures the same way. They are not touched here, but they are exposed to the same class of corruption if those CLIs repaint differentially.

…pete#3746)

Claude renders `/usage` as a redrawing TUI. Its renderer diffs each frame
against the previous one and jumps over unchanged cells with `ESC[<col>G`,
so those characters are never retransmitted. Deleting the escape sequences
and concatenating what is left therefore produces text with holes in it.

In a real capture the Fable row arrived as `51%<ESC>[59Gus<ESC>[62Gd`: the
`e` of "used" sat in a column where the previous frame had already painted
the `e` of "does". The scraped text read `51%usd`, `percentFromLine` could
not tell "used" from "left", returned nil, and `extractScopedWeeklyUsages`
discarded the whole Fable panel. Whether a hole lands inside a keyword is
down to which text the panel replaced, which is why the row appeared and
disappeared at random. Word gaps were collapsed the same way, which is why
reset descriptions read `ResetsSep23at3pm`.

Replay the capture onto an in-memory screen and parse what the user would
actually see. The screen is sized from `ClaudeCLISession.ptyRows/ptyColumns`,
which is also what `openpty` is given, so the renderer and the PTY cannot
drift apart: a wider PTY would clamp absolute column addressing and wrap in
the wrong place, corrupting the text the same way again. `render` therefore
takes the geometry explicitly rather than defaulting to a guess.

An unexpected capture shape still degrades to the old ANSI strip rather
than to no data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@clawsweeper

clawsweeper Bot commented Sep 21, 2026

Copy link
Copy Markdown

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

ClawSweeper review complete

ClawSweeper finished reviewing this revision. The review result is being finalized.

View the workflow run.

@clawsweeper clawsweeper Bot added P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Sep 21, 2026
@clawsweeper

clawsweeper Bot commented Sep 21, 2026

Copy link
Copy Markdown

Codex review: needs maintainer review before merge. Reviewed September 21, 2026, 5:40 AM ET / 09:40 UTC (Revision 2).

ClawSweeper review

What this changes

Reconstructs Claude’s terminal screen before parsing usage and account details, preserving characters and spaces omitted by differential redraws.

Merge readiness

Ready for maintainer review

This remains a useful fix absent from current main and the latest release. The previous display-width finding is addressed, the supplied real-behavior proof is sufficient, and no remaining blocking defect was identified.

Priority: P2
Reviewed head: a00e78d2cc5fbebf5cbaaaa1bb6d6e11b83f8c26

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) A focused, well-supported repair with convincing live evidence and the previous correctness blocker addressed.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (live_output): The supplied live CLI output exercises ClaudeStatusProbe through the actual CLI source and shows the scoped window restored; inspected native before/after screenshots corroborate the visible result. The revision's display-width repair has focused supplemental regression coverage.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (live_output): The supplied live CLI output exercises ClaudeStatusProbe through the actual CLI source and shows the scoped window restored; inspected native before/after screenshots corroborate the visible result. The revision's display-width repair has focused supplemental regression coverage.
Evidence reviewed 9 items Repository policy: Read the complete root AGENTS.md; no nested AGENTS.md or maintainer-notes directory was present. Applied focused parser coverage, provider isolation, and safe validation guidance. Builds, tests, and account probes were not executed under the read-only review contract.
Introduced scope and merge result: Reviewed the seven introduced files between pinned merge base 6f59667 and head a00e78d. Raw test-merge parents are the supplied main followed by the exact PR head; its result changes only these seven files.
Current main still has the faulty transformation: Current main parses usage and status after deleting ANSI sequences. The scoped-window parser requires a recognizable percentage direction; the committed capture contains cursor jumps that leave the stripped keyword as 'usd'. Existing scoped-quota support does not reconstruct those missing cells.
Findings None None.
Security None None.

How this fits together

CodexBar captures Claude CLI usage and status panels through a pseudo-terminal. The reconstructed text feeds existing quota and identity parsers, which supply the CLI output and menu-bar display.

flowchart LR
  A[Claude CLI panels] --> B[Pseudo-terminal capture]
  B --> C[Replay screen updates]
  C --> D{Percentages preserved?}
  D -->|Yes| E[Usage and identity parsers]
  D -->|No| F[Legacy ANSI stripping]
  F --> E
  E --> G[CLI output and menu]
Loading

Before merge

None.

Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Production and regression coverage Production +550/-8 lines; tests and fixture +165/-0 lines The production growth implements terminal replay; the captured failure and focused renderer tests justify the added subsystem.

Technical review

Best possible solution:

Reconstruct Claude’s captured terminal text at the actual PTY geometry, then retain the existing quota and identity parsing rules.

Do we have a high-confidence way to reproduce the issue?

Yes, from source: the committed real capture omits a previously painted letter in 'used', and current main strips the cursor instructions before requiring that keyword. No reviewer-side execution was performed.

Is this the best way to solve the issue?

Yes: replaying the terminal updates addresses the missing characters at their source, without relaxing percentage interpretation or adding competing provider behavior.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning medium; reviewed against d8d0f3394989.

Labels

Label justifications:

  • P2: Repairs intermittent loss of a supported Claude quota row while the remaining usage display stays usable.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The supplied live CLI output exercises ClaudeStatusProbe through the actual CLI source and shows the scoped window restored; inspected native before/after screenshots corroborate the visible result. The revision's display-width repair has focused supplemental regression coverage.
  • proof: sufficient: Contributor real behavior proof is sufficient. The supplied live CLI output exercises ClaudeStatusProbe through the actual CLI source and shows the scoped window restored; inspected native before/after screenshots corroborate the visible result. The revision's display-width repair has focused supplemental regression coverage.

Evidence

What I checked:

  • Repository policy: Read the complete root AGENTS.md; no nested AGENTS.md or maintainer-notes directory was present. Applied focused parser coverage, provider isolation, and safe validation guidance. Builds, tests, and account probes were not executed under the read-only review contract. (AGENTS.md:1, a00e78d2cc5f)
  • Introduced scope and merge result: Reviewed the seven introduced files between pinned merge base 6f59667 and head a00e78d. Raw test-merge parents are the supplied main followed by the exact PR head; its result changes only these seven files. (a071e6db4493)
  • Current main still has the faulty transformation: Current main parses usage and status after deleting ANSI sequences. The scoped-window parser requires a recognizable percentage direction; the committed capture contains cursor jumps that leave the stripped keyword as 'usd'. Existing scoped-quota support does not reconstruct those missing cells. (Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift:222, d8d0f3394989)
  • Latest release check: Inspected the parser at the supplied v0.63.0 release commit; usage, status, and identity cleaning still use stripANSICodes. This screen-replay fix is not present in that release. (Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift:222, f3e718c897d5)
  • Captured regression and bounded integration: Read the raw capture and four parser regression tests covering scoped usage, reset spacing, the failed stripping approach, and fallback behavior. Replay uses the same 160-column, 50-row constants as openpty; plain text remains unchanged, and other providers retain their existing parsers. (Tests/CodexBarTests/ClaudeCLIPTYRedrawRegressionTests.swift:16, a00e78d2cc5f)
  • Previous finding resolved: Compared the revision through GitHub's commit comparison after a local historical blob read failed. The revision adds display widths, continuation cells, whole-character wrapping, half-overwrite cleanup, and six focused test cases matching the previous requested repair. (Sources/CodexBarCore/TerminalScreenRenderer.swift:114, a00e78d2cc5f)

Likely related people:

  • unknown: The claimed source-line change could not be verified from bounded local history. (role: source history unknown; confidence: low)
  • unknown: The claimed source-line change could not be verified from bounded local history. (role: source history unknown; confidence: low)

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (1 earlier review cycle)
  • reviewed 2026-09-21T09:03:14.712Z sha 3bae44d :: needs changes before merge. :: [P2] Account for terminal display width when advancing the cursor

…erer

Ink positions its `ESC[<col>G` jumps with `string-width`, so CJK,
fullwidth and presentation emoji take two columns while `█` and other
East Asian Ambiguous characters take one. The renderer stored every
Swift `Character` in a single cell, which put every jump after a wide
character one column too far and could overwrite the wrong cells on the
next repaint. `/status` identity text (org and account names) is the
most exposed path.

Wide characters now occupy a head cell plus a continuation cell,
overwriting or erasing either half blanks the other, and a wide
character that does not fit at the end of a row wraps whole. Combining
marks that Swift clusters onto an escape sequence's final byte are
peeled off and rejoined to the glyph before the cursor. Remaining C0
controls and DEL are dropped instead of rendered.

Also fixes the doc comment that had drifted onto `ptyRows`, removes a
duplicated line in docs/claude.md, and adds a fallback test for
`cleanCapture`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@fanwenlin

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

Addressed the P2 finding in a00e78d: the renderer now counts cells by display width the way Ink's string-width does (CJK/fullwidth and presentation emoji = 2, combining marks = 0, East Asian Ambiguous such as = 1), with continuation cells, half-overwrite/erase blanking, and whole-character wrapping. Six new renderer tests cover CJK and emoji before absolute column addressing, ambiguous-width bars, overwriting half a wide character, wrapping, and a combining mark split from its base by an SGR sequence.

@clawsweeper

clawsweeper Bot commented Sep 21, 2026

Copy link
Copy Markdown

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event exact_review_queue).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fable doesn't show reliably. It's really unpredictable. No way to know if it'll show or not.

1 participant