Cursor jumps between UI regions in third-party terminals (Tabby, Wave)

Open 💬 7 comments Opened Apr 14, 2026 by refrain2333
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI are you using?

codex-cli 0.120.0

What subscription do you have?

free

What platform is your computer?

Microsoft Windows NT 10.0.26200.0 x64 (Windows 11)

What terminal emulator and version are you using?

  • Tabby (latest)
  • Wave Terminal (latest)
  • Windows Terminal (for comparison)
  • Warp (for comparison)
  • System default cmd.exe and pwsh

What issue are you seeing?

When running Codex CLI in certain third-party terminals (Tabby, Wave), the cursor rapidly jumps between multiple UI regions:

  1. The Working (XXs · esc to interrupt) status line
  2. The > input command line
  3. The model info line (e.g., gpt-5.4 high · ~)

This makes it difficult to see where the actual input cursor is and is visually distracting.

What steps can reproduce the bug?

  1. Open Tabby or Wave terminal
  2. Run codex
  3. Send a prompt and wait for the model to process
  4. Observe the cursor jumping between the status line, input line, and bottom info bar

Expected behavior

The cursor should remain at the input field, as it does in stock Windows terminals, Windows Terminal, and Warp.

Additional information

| Terminal | Shell | Behavior |
|----------|-------|----------|
| System default terminal | cmd/pwsh | ✅ Normal |
| Windows Terminal | cmd/pwsh | ✅ Normal |
| Warp | cmd/pwsh | ✅ Normal |
| Tabby | cmd | ❌ Cursor jumps |
| Tabby | pwsh | ❌ Cursor jumps |
| Wave | cmd | ❌ Cursor jumps |
| Wave | pwsh | ❌ Cursor jumps |

The issue occurs in Tabby and Wave regardless of shell (cmd or pwsh). System default terminal, Windows Terminal, and Warp all work correctly. This suggests the problem is related to how Codex's TUI handles ANSI escape sequences for cursor positioning in specific terminal emulators.

This appears related to #16687 but this report adds more terminal coverage, notably that Warp works correctly while Tabby and Wave do not.

View original on GitHub ↗

7 Comments

github-actions[bot] contributor · 4 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #16687

Powered by Codex Action

thereprocase · 4 months ago

I’m seeing what looks like the same issue on:

  • Codex CLI: 0.125.0
  • OS: Windows / WSL2
  • Terminal path: WSL2 -> Termux -> pwsh
  • Shell: PowerShell
  • Config:

``toml
[tui]
terminal_title = []
animations = false
show_tooltips = false
status_line = ["model-with-reasoning", "current-dir"]
alternate_screen = "always"
``

In my case the cursor visibly flickers/jumps down to the status line while I’m actively typing in the text input field, then returns to the input cursor position. It also flickers up into the output area while output is scrolling. So this is not only during long-running tool calls or streaming output; ordinary text entry can trigger it too.

animations = false does not prevent it. The behavior feels like Codex’s redraw path is exposing intermediate terminal cursor moves during frame rendering, especially around the status/statusline and scrolling output areas.

Expected: while typing in the composer, the hardware cursor should stay visually stable at the input insertion point. Status/model/footer redraws and output scrolling should not visibly move the cursor.

snowyukitty · 2 months ago

The duplicate check already points to #16687, and this appears to have the same root cause.

I’ve prepared a branch that addresses the cursor sync issue:
https://github.com/snowyukitty/codex/tree/fix-tui-cursor-sync-frame

GitHub rejected upstream PR creation from my account on openai/codex, so I’m sharing the branch here for now.
I hope this is helpful.

Livehide · 6 days ago

I adapted the existing sync-frame patch to current main and added a deterministic backend-order test:

https://github.com/Livehide/codex/tree/codex/fix-tui-cursor-sync-frame
commit: 1554e7bed8279c62bf005b211852da0a3610c267

The change writes synchronized-update markers through the same custom terminal backend as history writes, frame drawing, and final cursor positioning. The regression test asserts begin -> cell write -> final cursor position -> draw flush -> end.

Validation on Windows:

  • saved Codex 0.148.0 ConPTY recording: 35 status-footer-visible intervals, 9.7-23.4 ms
  • patched native term-gui: footer cursor 0 / 0 / 0 across 3 runs x 120 frames; input cursor and content progress were observed in each run
  • new unit test passed; scoped clippy-fix passed
  • codex-tui suite: 3,725 passed, 3 unrelated temp-home path-format failures, 10 skipped

Important caveat: current main did not reproduce the footer position in three unpatched raw captures (all were 0), so I am not claiming a current-main A/B symptom delta. The branch preserves the single-writer ordering contract and carries the deterministic regression test.

GitHub rejected PR creation because openai/codex currently allows collaborators only. The implementation is adapted from snowyukitty/codex@9ef4d31b; snowysmile is credited as co-author.

snowyukitty · 5 days ago

Thanks for picking this up and for keeping the attribution. I rebased your adaptation onto current main, and I think the caveat you flagged is not a gap in the measurement — it is telling us the patch cannot move this symptom on its own.

Why that A/B had to come back null. BSU/ESU are advisory, and the two terminals in this report discard them. Tabby and Wave both render through xterm.js 5.x, which does not implement DEC private mode 2026 at all — Wave's own tracker has it pinned to xterm.js 5.5.0 (wavetermdev/waveterm#2787), and support only landed in xterm.js 6.0.0 via xtermjs/xterm.js#5453. Windows Terminal does implement 2026 (backported in 1.23.20211.0, stable in 1.24), and Warp and conhost repaint from their own screen buffer rather than from the byte stream as it arrives, so in all three the intermediate positions never reach the screen. That is exactly the split in the table at the top of this issue. So a patch that only changes which writer emits the markers cannot change what Tabby or Wave draw: the markers are thrown away before they mean anything.

What those terminals actually receive. A frame does not cross the pty boundary once. The terminal is CustomTerminal<CrosstermBackend<Stdout>>, and std::io::stdout() is a LineWriter with a 1 KiB buffer that also flushes on every newline (library/std/src/io/stdio.rs, LineWriter::new -> with_capacity(1024, ...)). History insertion emits Print("\r\n") per line (insert_history.rs:143, :152, :211), so a frame that pushes N history rows crosses that boundary at least N times, plus once per 1 KiB of diff. A terminal that ignores 2026 paints each of those chunks, and every chunk ends with the caret wherever the last MoveTo left it — the footer, the status line, then the composer. That is the mechanism the terminal split points to. I could not A/B it on the affected terminals directly; I do not have Tabby or Wave installed.

The part that does not depend on the terminal. Keep the caret hidden for the duration of the frame and let the frame place it. Then no chunk boundary can expose an intermediate caret position, whether or not the terminal implements 2026.

Branch, stacked on your commit so the sync-frame work and its test stay intact:
https://github.com/snowyukitty/codex/tree/fix-tui-caret-hidden-during-frame

  • 5697cd23 — your adaptation, cherry-picked onto current main. One conflict, in the test backend: main now emits MoveTo from CaptureBackend::set_cursor_position, so I kept that write alongside your recorded event.
  • f6166797 — hide the caret across the synchronized frame. Terminal::sync_update hides it after BSU and restores it before ESU only when the frame did not place a caret itself, so try_draw_with_size keeps deciding and the pet-image draws get the caller's caret back.

Two tests pin the invariant: the caret is hidden before the first cell write and shown only after the final position, and a frame that never touches the caret has it restored inside the same frame.

Validation on Windows (x86_64-pc-windows-msvc, rustc 1.95.0):

  • cargo test -p codex-tui --lib — 3745 passed, 3 failed. The three are the same temp-home path-format cases you hit (status::helpers::tests::compose_agents_summary_*, expecting ~\AppData\... and getting C:\Users\...); they fail identically with both files reverted to main.
  • The suite needs RUST_MIN_STACK raised on a Windows debug build or an unrelated app::tests case overflows its stack. Also unchanged from main.
  • cargo clippy -p codex-tui --all-targets --all-features -- -D warnings — clean.
  • cargo fmt — clean.

Correcting myself on one point, since it matters to anyone reviewing this: the two commits answer different questions, but they are not separable as written. f6166797 builds on the Terminal::sync_update that 5697cd23 introduces, so taking the caret change on its own means first porting it onto the four stdout().sync_update call sites in tui.rs. The sync-frame refactor is a correctness improvement in its own right — one writer, ordering guaranteed structurally rather than by both paths happening to share the global stdout buffer — and worth keeping either way. The caret change is the one that should be visible on Tabby and Wave. If someone on either terminal can try the branch, that is the confirmation neither of us can produce.

Still no PR from me either; openai/codex only takes branches from collaborators.

snowyukitty · 4 days ago

Follow-up to my last comment, with the caret change repackaged so it can be reviewed
on its own.

A minimal branch that does not depend on the refactor. I said before that the two
commits were not separable as written, because the caret change built on the
Terminal::sync_update the first commit introduced. That was true of that
implementation, not of the fix.

One commit on current main, one line per frame plus two tests. It hides the caret at
the top of both synchronized frames, and nothing has to remember to bring it back. The
reason is worth stating precisely, because my first version of it was sloppy: it is not
that every frame ends in a draw — a frame can fail partway through. It is that
try_draw_with_size writes a caret decision in both of its arms, and that
Terminal::hide_cursor and Terminal::show_cursor write unconditionally rather than
short-circuiting on hidden_cursor. A frame that reaches its draw therefore ends with
the caret in the state that frame asked for, whatever preceded it. A frame that does
not reach its draw failed writing to stdout; the caret stays hidden until the next
frame that succeeds, or until restore_common and Drop for Terminal restore it.

This is the whole fix for what this issue reports; nothing else in my earlier branch is
required for it.

  • cargo test -p codex-tui --lib — 3738 passed, 3 failed (the same three temp-home

path cases; unmodified main reads 3736 passed, 3 failed).

  • cargo clippy -p codex-tui --all-targets --all-features -- -D warnings — clean.

A defect in the version I posted before. In Terminal::sync_update, the caret hide
that opened the frame and the caret restore that closed it both reached
EndSynchronizedUpdate through ?. Either failing left the begin marker unmatched, and
a terminal that implements mode 2026 holds the screen until the end marker arrives — so
that failure mode is not a torn frame, it is a terminal that stops presenting, with
whatever the process writes next held along with it. That is fixed on the branch, along
with a pre-existing hole of the same shape: a panic inside a frame runs the panic hook,
and restore_common never ended the frame either, so the panic message could be
swallowed by it. It now emits the end marker unconditionally, which is a no-op when no
frame is open.

The stacked branch keeps the same-writer refactor plus that hardening, for anyone who
wants the structural version:

https://github.com/snowyukitty/codex/tree/fix-tui-caret-hidden-during-frame

It also covers two things the minimal branch deliberately leaves alone: the pet-image
frames, which write to the backend without hiding the caret, and the error path, where
its sync_update restores the caller's caret rather than leaving it hidden. Neither is
implicated in what this issue reports.

On this issue's terminals specifically. Neither branch can be A/B'd on Tabby or
Wave from here; I do not have either installed, and I said in my last comment why the
BSU/ESU change alone could not have moved the symptom there. The caret hide is the part
that does not depend on the terminal honouring mode 2026 — or on how completely it
honours it — so it is the part worth trying. If anyone on Tabby or Wave can run the
minimal branch, that is the confirmation I still cannot produce.

I've also posted the minimal branch on #16687, where @noaho04 reported that positioning
before showing and hiding before the scrollback insertion together fixed this on
alacritty. #40166 landed the first of those two; this is the second.

snowyukitty · 4 days ago

I said last time that the missing piece was someone running the caret branch on Tabby or Wave, and that I could not produce that. I think I was asking for the wrong thing. The property that makes those terminals show this is that they do not implement synchronized output, and that property is reproducible here.

vt100 — already a dependency of codex-tui, already used as the test terminal — implements no synchronized-output mode either. So BeginSynchronizedUpdate buys nothing against it, exactly as it buys nothing in Tabby and Wave, and it presents each piece of a frame as it arrives.

Drive a real Tui::draw into a vt100 screen behind a LineWriter — the same line buffering Stdout applies, and the reason a frame reaches the terminal in pieces at all — and sample the caret at each write boundary. On today's main one frame gives this:

visible (3, 0) -> visible (3, 26) -> visible (3, 0) -> visible (3, 19)
               -> visible (3, 0) -> visible (3, 18) -> hidden (7, 22)

Four different places, caret visible at each. That is "the cursor rapidly jumps between multiple UI regions" as a data structure. The return to column 0 between each move is the line buffer flushing one history row at a time, which is also why streaming output is when you see it.

With the caret hidden for the frame, the same trace shows the caret in at most one place.

Branch

https://github.com/snowyukitty/codex/tree/fix/tui-caret-frame-regression-test

Two commits on 2df6705423:

  • 793aec83 — the caret hide. Unchanged from what I posted before, just carried to current main.
  • 18b8efb7 — the test, and the one thing needed to make it possible.

That one thing is a defaulted backend parameter on Tui. Tui named CrosstermBackend<Stdout> directly, so nothing could observe what a frame wrote — which is why deleting the caret hide left the suite green. With the default, Tui still means Tui<CrosstermBackend<Stdout>> everywhere and no call site changes; it is a struct header and four impl headers. The test then asserts the invariant directly, so removing either caret hide fails it.

If you want only the fix, the first commit stands alone.

What this does and does not establish

It establishes that a frame drawn without the caret hidden paints a visible caret in several places on a terminal that ignores mode 2026, and that hiding it for the frame reduces that to one.

It does not establish that Tabby and Wave are fully described by "ignores mode 2026". That step is still inference, though it is the same inference that explained why my earlier BSU/ESU-only change could not have moved this. Nobody has run either branch on the terminals in this report, and that confirmation would still be worth having.

Measured on 2df6705423, Windows, cargo test -p codex-tui --lib: 3758 passed / 3 failed before, 3761 passed / 3 failed after — the three additions are this branch's own tests, and the three failures are pre-existing on main here (unrelated; #40289). cargo clippy -p codex-tui --all-targets --all-features -- -D warnings is clean. I have not run the suite on Linux or macOS.

I still cannot open a pull request from a fork — if I have that wrong, say so and I will open one.