Skip to content

Bug: Cursor invisible + unresponsive to arrow keys — chalk level 0 disables all ANSI styling #2844

@OnCeUponTry

Description

@OnCeUponTry

Describe the bug

The native terminal cursor is completely invisible in the Copilot CLI prompt, and arrow keys do not move the cursor position. The prompt is effectively unusable for text editing — you cannot see where you are typing, and you cannot navigate within your input.

If the native cursor is forcibly restored (by patching out ink's cli-cursor.hide() call), the cursor becomes visible but arrow keys still do not move it (ghost cursor). Backspace and delete work correctly because they modify the text content, triggering a re-render.

Two symptoms, one root cause

ink (the React-based terminal UI framework used by Copilot CLI) hides the native terminal cursor on mount via cli-cursor.hide() in componentDidMount. It then relies on chalk's inverse styling (\x1B[7m) to visually mark the cursor position in the rendered output. A cursor position finder scans each frame for the inverse ANSI sequence to determine where to place the native cursor.

When chalk is initialized with level 0 (all ANSI output disabled), this entire mechanism breaks:

  1. Invisible cursor: The native cursor is hidden by ink, and chalk cannot produce the inverse marker to show cursor position visually — the cursor simply disappears
  2. Ghost cursor: Even if the native cursor is restored manually, arrow keys still do not move it because the cursor position finder cannot locate \x1B[7m in the unstyled output

Both symptoms are caused by the same root cause: chalk.level === 0.

Environment

  • Copilot CLI version: 1.0.32
  • OS: Linux x86_64
  • Terminal: foot, Konsole (reproduced on both Wayland terminals — bug is in Copilot CLI code, not terminal-specific)
  • TERM: xterm-256color
  • Shell: bash
  • isatty(1): true — stdout IS a TTY

Steps to reproduce

  1. Open terminal (foot, Konsole, or any Linux terminal)
  2. Run copilot (or gh copilot)
  3. Observe: no visible cursor in the prompt area (the blinking cursor beam is absent)
  4. Type some text (e.g., "hello world") — text appears but cursor position is invisible
  5. Press left arrow multiple times — cursor does not move visually
  6. Type a character — it inserts at the correct (invisible) cursor position, confirming internal state tracks correctly
  7. Press backspace — it deletes the correct character and cursor jumps visibly (because text change triggers re-render)

Root cause analysis

The chalk instance used for terminal styling is initialized with color level 0 (colors disabled), even though stdout is a TTY that supports colors.

How the rendering pipeline breaks

  1. ink's componentDidMount calls cli-cursor.hide() — native cursor hidden
  2. The TextInput component renders the cursor character with inverse: true
  3. ink's internal text transform calls chalk.inverse(char) to wrap the cursor character
  4. With chalk.level === 0, chalk.inverse() is a no-op — returns the character without ANSI wrapping
  5. The output string contains zero ANSI escape sequences
  6. The cursor position finder searches for \x1B[7m (ANSI SGR inverse) in the rendered output — finds nothing
  7. The native terminal cursor is never repositioned via \x1B[row;colH
  8. Result: Cursor is both hidden AND unlocatable — completely invisible and unresponsive to arrow keys

Why chalk gets level 0

// Simplified from the bundled code:
const supportsColor = detectColorSupport({ isTTY: tty.isatty(1) });
const chalkInstance = new Chalk(); // no explicit level
// Level setter: level = supportsColor ? supportsColor.level : 0

When supports-color returns a falsy result (which can happen in certain terminal environments even when stdout IS a TTY), chalk defaults to level = 0, disabling all ANSI output.

Why backspace works but arrows don't

Backspace modifies the text content (deletes a character), making the rendered line "dirty" in the diff renderer. The terminal naturally places the cursor at the end of the rewritten text. Arrow keys only change the internal cursor position without modifying text — without the ANSI inverse marker, the diff renderer has nothing to trigger cursor repositioning.

Location in source

  • File: app.js (main bundled entry point, ~/.cache/copilot/pkg/linux-x64/*/app.js)
  • chalk init: Search for the Chalk constructor call that uses auto-detection (no explicit level parameter)
  • cursor hide: Search for componentDidMount containing hide(this.props.stdout) — this is ink's App class hiding the native cursor
  • cursor finder: Search for \x1B[7m string literal — this is the inverse marker the position finder looks for

Evidence

I instrumented app.js with file-based debug logging (fs.appendFileSync) at key points in the rendering pipeline:

Before fix (chalk level 0):

GK:inv=false,invoff=false    // Output contains ZERO \x1B[7m or \x1B[27m sequences
ZA:NO_ANSI                    // Raw frame lines have no ANSI escape codes at all

After fix (chalk level 3):

CL:3|IV:"\u001b[7mX\u001b[27m"   // chalk.inverse("X") now produces correct ANSI

Cursor becomes visible and responds correctly to arrow keys after the fix.

Fix

Force chalk initialization with an explicit color level:

// Before (auto-detect, can fail):
chalkInstance = new Chalk()  // level auto-detected, may be 0

// After (explicit truecolor):
chalkInstance = new Chalk({ level: 3 })  // forces ANSI output

This single change resolves both symptoms — the cursor becomes visible (chalk can now produce the inverse marker that ink uses to show cursor position) and arrow keys move it correctly (the cursor position finder can locate \x1B[7m in the rendered output).

Tested on two Wayland terminal emulators (foot and Konsole) across Linux environments.

Suggested upstream fix

The auto-detection should ensure a minimum level of 1 when stdout is a confirmed TTY:

// Option A: Minimum level when stdout is TTY
const level = supportsColor ? Math.max(supportsColor.level, 1) : 0;

// Option B: Explicit level based on process.stdout
const instance = new Chalk({ level: process.stdout.isTTY ? 3 : 0 });

// Option C: Post-creation guard
const instance = new Chalk();
if (process.stdout.isTTY && instance.level === 0) {
    instance.level = 3;
}

Additional context

  • The bug is in Copilot CLI's chalk initialization code, not in any specific terminal emulator — it affects any terminal where supports-color fails to detect the correct color level
  • All chalk styling (bold, italic, underline, dim, colors) is silently disabled when this happens — not just inverse. The entire UI renders without any ANSI formatting
  • This was diagnosed through 6 iterative patches instrumenting app.js with fs.appendFileSync at critical points in the rendering pipeline (note: process.stderr.write breaks ink's rendering — file logging is the only safe approach)
  • A one-line fix (Chalk({ level: 3 })) completely resolves both the invisible cursor and the ghost cursor

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:platform-linuxLinux-specific: Wayland, X11, Ubuntu, Fedora, Alpine, ARM, terminal emulatorsarea:terminal-renderingDisplay and rendering: flickering, scrolling, line wrapping, output formatting

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions