Skip to content

[Bug] Progress bar visibly flickers in Ptyxis 50.1 but not in Zed terminal #613

Description

@thomasaull

Environment

  • OS: Linux
  • Node.js: v24.13.0
  • Package: @clack/prompts
  • Package version: 1.8.1
  • Affected terminal: Ptyxis 50.1
  • GTK: 4.22.5 (compiled against 4.22.4)
  • VTE: 0.84.1 (compiled against 0.84.0)

Describe the bug

The entire progress line briefly disappears and reappears during animation in Ptyxis, producing visible rapid flickering/strobing. This is not just the spinner changing frames, repeated output, or the line moving onto another row.

The same standalone script does not visibly flicker in Zed's integrated terminal. Both stdout and stderr exhibit the issue in Ptyxis, while neither visibly flickers in Zed.

To reproduce

This reproduction uses only Clack, with short messages and no network requests during execution.

  1. Create a standalone project:

    mkdir clack-repro
    cd clack-repro
    npm init -y
    npm install @clack/prompts@1.8.1
  2. Save the following as repro.mjs:

    import { progress } from '@clack/prompts'
    
    const bar = progress({
      max: 5,
      indicator: 'timer',
      withGuide: false,
      ...(process.argv.includes('--stderr') ? { output: process.stderr } : {}),
    })
    
    bar.start('Translating')
    
    try {
      for (let completed = 1; completed <= 5; completed++) {
        await new Promise((resolve) => setTimeout(resolve, 2000))
        bar.advance(1, `Translated ${completed}/5`)
      }
      bar.stop('Done')
    } finally {
      bar.clear()
    }
  3. Run in Ptyxis with a terminal at least 80 columns wide, without piping or redirecting output:

    node repro.mjs
    node repro.mjs --stderr
  4. Compare the same commands in Zed's integrated terminal.

Terminal Default stdout --stderr
Ptyxis 50.1 Visible flickering Visible flickering
Zed integrated terminal No visible flickering No visible flickering

Expected behavior

The progress animation updates without a visibly blank interval between frames.

Activity

  1. 43081j commented on Oct 3, 2026

    @43081j
    Collaborator

    Very much sounds like a ptyxis problem rather than us.

    You could try record it with ansivision. That way you can see the exact frames that rendered. If there are no empty frames (pretty sure there won't be), I'm not sure what we can do 😬

  2. thomasaull commented on Oct 7, 2026

    @thomasaull
    Author

    I repeated the suggested test with ansivision@0.4.0 and @clack/prompts@1.8.1. I captured the exact reproduction through an 80-column pseudo-terminal, which is a software terminal.

    The original output contained 22,411 bytes and 124 erase-to-end sequences. Ansivision rendered 125 frames and found no empty frames.

    I also captured the version with our local batching adapter. It produced the same number of bytes, erase sequences, and frames. The two captures were byte-for-byte identical, and cmp reported no difference.

    Ansivision does not model the boundaries between write() calls. Therefore, it cannot show the short blank state that Ptyxis displays between two calls.

    A small two-step probe recorded these boundaries:

    Clack direct
      target writes: 15
      writes containing erase-to-end: 4
      erase writes with no replacement text: 4
      first erase write: "ESC [ J"
    
    Batched adapter
      target writes: 6
      writes containing erase-to-end: 4
      erase writes with no replacement text: 0
      first erase write: "ESC [ 1 G ESC [ J ◐  ━━━… Translating [0s]"
    

    Clack sends cursor movement, erase, replacement text, and final cursor movement through separate synchronous write() calls. Ptyxis seems to draw after the erase call and before the replacement-text call.

    Our adapter collected the synchronous writes during one microtask. A microtask is work that runs after the current code. The adapter then sent each redraw through one write() call. This change significantly reduced the visible flicker in our Ptyxis test.

    The local adapter was:

    import { Writable } from 'node:stream'
    
    function createFrameOutput(target) {
      let pending = ''
      let scheduled = false
    
      function flush() {
        scheduled = false
        if (!pending) return
        const frame = pending
        pending = ''
        target.write(frame)
      }
    
      const output = Object.assign(
        new Writable({
          write(chunk, _encoding, done) {
            pending += chunk.toString()
            if (!scheduled) {
              scheduled = true
              queueMicrotask(flush)
            }
            done()
          },
        }),
        { isTTY: target.isTTY, columns: target.columns },
      )
    
      return { output, flush }
    }

    This change seems feasible in Clack core. render() can assemble each complete update and send it through one output.write(...) call. restoreCursor() must return its cursor sequence instead of writing it directly.

    The output bytes stay the same, so tests can compare the bytes and the number of writes. A single write is clearer than cork() and uncork(), which do not guarantee one terminal write.

    Would this change be acceptable in Clack core?

  3. thomasaull commented on Oct 7, 2026

    @thomasaull
    Author

    @43081j Sorry the previous comment was my agent rambling about it, I personally don’t know much about the internals and the actual issues. It (the agent) is confident the propsed fix might be feasible, but I for myself cannot say anything of value to that.

    So feel free to take the suggestion or close the issue :)

  4. dreyfus92 commented on Oct 9, 2026

    @dreyfus92
    Member

    hey @thomasaull thanks for reporting this 😄. i agree with james that this looks like ptyxis painting partial output. the sequence we write per frame ESC[1G then ESC[J then the new text, is the same thing ora does, and the batching adapter only reduced the flicker rather than removing it, so the terminal is still the main factor. might be worth reporting to ptyxis/vte, especially if other spinners like ora flicker too.

    and what your agent suggested feels like a hardening/partial fix. it also targets core render(), but the progress bar is built on the spinner, which writes on its own, so that change wouldn't touch this flicker. if we harden it'd be in the spinner.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions