Skip to content

Implement OSC 9;4 per-pane progress state and UI #507

Description

@fredclausen

Context

Issue #502 exposed Neovim emitting the ConEmu progress protocol around file writes:

OSC 9;4;1;0 ST
OSC 9;4;0;0 ST

The #502 compatibility fix recognizes valid progress reports and silently consumes them so they are not routed as OSC 9 desktop notifications. Freminal still does not expose the reported progress state visually.

Protocol reference: https://ghostty.org/docs/vt/osc/conemu

Required behavior

  • Parse OSC 9;4;<state>[;<progress>] into typed state rather than notification text.
  • Represent inactive, normal, error, indeterminate, and paused states.
  • Keep state per pane and publish it through the existing snapshot architecture.
  • Render a subtle pane or tab progress indicator, including sensible aggregation for multi-pane tabs.
  • Clear state on state=0, terminal reset, and pane exit.
  • Decide and document stale-state timeout behavior for applications that fail to clear progress.
  • Preserve OSC 9 simple-body desktop notifications and avoid treating malformed progress forms as progress.
  • Add parser/state/snapshot/UI tests and update both escape-sequence coverage documents.

Activity

  1. fredclausen commented on Aug 30, 2026

    @fredclausen
    MemberAuthor

    Recon findings and implementation map

    Where things stand today

    The #502 fix (b47fde7b, 183a2189) recognises valid progress reports and drops them entirely — handle_osc_notify_9 at freminal-terminal-emulator/src/ansi_components/osc_notify.rs:40-84 returns without emitting any TerminalOutput when is_conemu_progress_report (osc_notify.rs:86-110) matches. The state and progress digits are validated and then discarded. Nothing reaches TerminalHandler, TerminalSnapshot, or the GUI.

    The transport pattern to use (and the one to avoid)

    The OSC 9 / OSC 777 notification path is a fire-once event queue: WindowManipulation::Notification pushed onto handler.window_commands, drained once per build_snapshot() cycle (freminal-terminal-emulator/src/state/internal.rs:505-506), consumed by the GUI pump at freminal/src/gui/pty.rs:636. That is the wrong shape here — progress is level-triggered state that must be readable on every subsequent frame until it changes.

    The correct precedent is cursor_color_override / pointer_shape (OSC 12 / OSC 22):

    Step Location
    Field on TerminalHandler terminal_handler/mod.rs:254,259 (init :430-431)
    Mutated in handle_osc terminal_handler/osc.rs:150-152
    const fn accessor terminal_handler/mod.rs:489-503
    Cleared by RIS terminal_handler/mod.rs:512-538
    Snapshot field snapshot.rs:328-338, defaults :417-418
    Read in build_snapshot() interface.rs:963-964
    GUI reads it directly off pane.arc_swap.load()

    Pane exit then needs no explicit teardown: PaneTree::close (freminal/src/gui/panes/mod.rs:1209) and process_dead_panes (freminal/src/gui/frame_drain.rs:220+) drop the whole Pane, which owns the ArcSwap. This holds only as long as the state is not also cached GUI-side outside Pane's lifetime.

    Parser change is not cosmetic

    Per the ConeEmu/ghostty spec, states 2 (Error) and 4 (Paused) keep the previous numeric value when v is omitted. So parsing can no longer be a stateless free function — handle_osc_notify_9 (osc.rs:53) currently takes only raw_params/seq_trace/output and has no access to handler state. Its signature and call site have to change.

    The three existing tests at osc_notify.rs:320-347 assert "output is empty"; they become assertions about a typed payload. That is a deliberate behaviour change to existing tests, not an addition.

    Staleness timeout

    Direct precedent exists: SYNC_UPDATES_TIMEOUT_MS (interface.rs:135) + apply_sync_updates_timeout() (interface.rs:1034-1057), driven off an Option<Instant> checked on every build_snapshot() (interface.rs:801).

    Decision: hardcode 15s as a const, matching ghostty; no config knob. The clock tracks "time since last progress report", not "time since progress became active", so every OSC 9;4 update resets it.

    Rendering

    Decision: pane-local bar (ghostty's own presentation), not a tab-bar badge.

    Rationale beyond preference: ChromeDamage (freminal-windowing/src/lib.rs:174-183) is a binary Changed/Unchanged with no region concept, so any tab-bar indicator change forces the whole frame to Full via compose_with_chrome_damage (freminal/src/gui/frame_damage.rs:220). For an actively-ticking build that is a full repaint per update. A pane-local bar participates in PaneFrameDamage::Region and stays bounded.

    The implementation site is the command-block gutter's painter pass, freminal/src/gui/terminal/widget.rs:3990-4055 — the existing precedent for a per-pane overlay painted inside the pane's own damage accounting. Semantic colour follows ChromeRole / gutter_color_for (freminal-common/src/themes.rs:118-160).

    Decision: aggregation is worst-state-wins — Error > Paused > Indeterminate > Normal > Inactive, percentage taken from the winning pane. Recorded now because it is needed the moment anything tab-level is added later, even though the pane-local decision defers the need.

    Config

    Decision: gate display behind a config option. Protocol recognition stays unconditional (as #502 made it); only rendering is gated. Modelled on CommandBlocksConfig / GutterPosition (freminal-common/src/config.rs:797-894). Wiring must follow the freminal-config-options checklist — the ConfigPartial / apply_partial legs are the ones that get forgotten and silently make the option a no-op.

    Open question: DECSTR does not exist

    This issue asks to clear progress state on "terminal reset, RIS and DECSTR". DECSTR (CSI ! p) is not implemented anywhere in freminal — no DECSTR, SoftReset, or soft_reset symbol exists, and full_reset() documents itself as RIS-only.

    So this requirement cannot be satisfied as written. Implementing DECSTR means implementing a full soft-reset with its own specified list of affected modes, which is a distinct escape-sequence feature that deserves its own issue and its own test matrix rather than riding along on a progress indicator. Recommend: clear on RIS here, and file DECSTR separately. Flagging rather than silently choosing.

    Damage / typing constraints

    • The state must be a named enum, not a bool pair — it crosses the snapshot boundary (freminal-state-representation). TerminalSnapshot already carries a struct_excessive_bools allow at snapshot.rs:52; do not add to it.
    • An animating indeterminate indicator needs a bounded Region rect per tick, following the has_blinking_text precedent (snapshot.rs:128-133) that drives repaint scheduling only while something is actually animating.

    Docs and benches

    Mandatory dual-doc update: Documents/ESCAPE_SEQUENCE_COVERAGE.md:267 and :432, Documents/ESCAPE_SEQUENCE_GAPS.md:141 and :281, plus both "Last updated" headers.

    bench_parse_osc9 (freminal-terminal-emulator/benches/buffer_benches.rs:211-238) already includes a \x1b]9;4;1;50\x1b\\ case, so before/after parser numbers are available without writing a new benchmark.

  2. added 2 commits that reference this issue on Oct 5, 2026
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

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions