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:
- 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
- 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
- Open terminal (foot, Konsole, or any Linux terminal)
- Run
copilot (or gh copilot)
- Observe: no visible cursor in the prompt area (the blinking cursor beam is absent)
- Type some text (e.g., "hello world") — text appears but cursor position is invisible
- Press left arrow multiple times — cursor does not move visually
- Type a character — it inserts at the correct (invisible) cursor position, confirming internal state tracks correctly
- 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
- ink's
componentDidMount calls cli-cursor.hide() — native cursor hidden
- The
TextInput component renders the cursor character with inverse: true
- ink's internal text transform calls
chalk.inverse(char) to wrap the cursor character
- With
chalk.level === 0, chalk.inverse() is a no-op — returns the character without ANSI wrapping
- The output string contains zero ANSI escape sequences
- The cursor position finder searches for
\x1B[7m (ANSI SGR inverse) in the rendered output — finds nothing
- The native terminal cursor is never repositioned via
\x1B[row;colH
- 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
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()incomponentDidMount. It then relies on chalk'sinversestyling (\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:
\x1B[7min the unstyled outputBoth symptoms are caused by the same root cause:
chalk.level === 0.Environment
xterm-256colorisatty(1):true— stdout IS a TTYSteps to reproduce
copilot(orgh copilot)Root cause analysis
The
chalkinstance 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
componentDidMountcallscli-cursor.hide()— native cursor hiddenTextInputcomponent renders the cursor character withinverse: truechalk.inverse(char)to wrap the cursor characterchalk.level === 0,chalk.inverse()is a no-op — returns the character without ANSI wrapping\x1B[7m(ANSI SGR inverse) in the rendered output — finds nothing\x1B[row;colHWhy chalk gets level 0
When
supports-colorreturns a falsy result (which can happen in certain terminal environments even when stdout IS a TTY), chalk defaults tolevel = 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
app.js(main bundled entry point,~/.cache/copilot/pkg/linux-x64/*/app.js)Chalkconstructor call that uses auto-detection (no explicitlevelparameter)componentDidMountcontaininghide(this.props.stdout)— this is ink's App class hiding the native cursor\x1B[7mstring literal — this is the inverse marker the position finder looks forEvidence
I instrumented
app.jswith file-based debug logging (fs.appendFileSync) at key points in the rendering pipeline:Before fix (chalk level 0):
After fix (chalk level 3):
Cursor becomes visible and responds correctly to arrow keys after the fix.
Fix
Force chalk initialization with an explicit color level:
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[7min 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:
Additional context
supports-colorfails to detect the correct color levelapp.jswithfs.appendFileSyncat critical points in the rendering pipeline (note:process.stderr.writebreaks ink's rendering — file logging is the only safe approach)Chalk({ level: 3 })) completely resolves both the invisible cursor and the ghost cursor