Skip to content

feat(tui): improve terminal input and frame rendering - #885

Closed
haasonsaas wants to merge 1 commit into
mainfrom
feat/uncurses-terminal-substrate
Closed

feat(tui): improve terminal input and frame rendering#885
haasonsaas wants to merge 1 commit into
mainfrom
feat/uncurses-terminal-substrate

Conversation

@haasonsaas

@haasonsaas haasonsaas commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Terminal input now has one protocol-aware reader, so modern keyboard metadata, paste, focus, mouse, resize, and terminal theme replies are decoded without OSC replies leaking into the composer.
  • Automatic themes can follow live light/dark changes, while synchronized frame updates reduce partial-frame tearing. Crossterm remains the fallback, and MAESTRO_UNCURSES_INPUT=0 is an immediate kill switch.
  • Terminal protocol modes are balanced during normal and error cleanup so Maestro returns the shell to a usable state.

Checklist

  • cargo fmt --all -- --check and package clippy
  • cargo test -p maestro-tui --lib -- --test-threads=1 (3,477 passed)
  • cargo check -p maestro-tui --all-targets
  • Staging is unnecessary for this local TUI path: uncurses initialization falls back safely, and the environment kill switch restores the previous reader without a new build.

Optional

  • Add the run-evals label to run evals on this PR (otherwise evals are skipped on PRs).
  • Add the skip-integration label to skip integration tests when appropriate (include justification in the PR body).
  • If skipping CI validators ([skip ci], [skip nix]), explain why below.

Post-Deploy Monitoring & Validation

  • Validate prompt submission, shifted punctuation, control chords, paste, resize, mouse/focus events, and dark/light transitions in the first release containing this change.
  • Treat input freezes, protocol text appearing in the composer, theme changes while theme_follow is off, partial frames, or a shell left in raw mode as rollback signals.
  • First mitigation: set MAESTRO_UNCURSES_INPUT=0. Revert this change if reports are widespread or the fallback does not restore normal behavior.
  • Review Maestro issue reports for one release cycle and the following seven days; the Maestro maintainers own the check.

New concepts

One owner for terminal input

Terminal replies and user keystrokes share the same byte stream. Letting two parsers read that stream creates a race: whichever reader wins consumes bytes the other can never recover.

This change gives uncurses sole ownership of the controlling terminal input and translates its typed events into Maestro's existing crossterm-shaped application events. That preserves the current widget and keybinding layers while allowing OSC and DEC replies to coexist safely with ordinary input.

Use this pattern when an application both queries terminal capabilities and accepts interactive input. It is unnecessary for simple line-oriented commands that never request terminal replies.


Compound Engineering


Open in Devin Review

@github-actions

Copy link
Copy Markdown
Contributor

This PR changes mirrored Maestro source files in the public repo, but it does not link the matching private source-of-truth PR.

Add one of these to the PR body, then re-run the check:

  • https://github.com/evalops/maestro-internal/pull/<number>
  • evalops/maestro-internal#<number>
  • maestro-internal#<number>

Mirrored files touched:

  • Cargo.lock
  • docs/design/GROK_BUILD_PARITY.md
  • packages/tui-rs/Cargo.toml
  • packages/tui-rs/docs/user-guide/05-configuration.md
  • packages/tui-rs/docs/user-guide/06-theming.md
  • packages/tui-rs/src/app.rs
  • packages/tui-rs/src/app/tests.rs
  • packages/tui-rs/src/config.rs
  • packages/tui-rs/src/terminal/events.rs
  • packages/tui-rs/src/terminal/mod.rs
  • packages/tui-rs/src/terminal/setup.rs
  • packages/tui-rs/src/themes/mod.rs
  • packages/tui-rs/src/themes/osc11.rs

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​uncurses@​0.0.19910093100100

View full report

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d6b04c4261

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +299 to +303
let mut chars = text.chars();
match (chars.next(), chars.next()) {
(Some(character), None) if !character.is_control() => KeyCode::Char(character),
_ => convert_uncurses_key_code(key.code)?,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve all associated key text

When a Kitty/uncurses key event carries multiple Unicode scalars in key.text—for example, composed IME input or a grapheme containing combining characters—this branch rejects the text and falls back to the single logical key.code, silently dropping the remaining characters. The protocol-aware reader should enqueue or insert the entire associated text rather than collapsing it to one KeyCode::Char.

Useful? React with 👍 / 👎.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 potential issues.

Open in Devin Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Two terminal-protocol owners active simultaneously (crossterm setup + uncurses reader)

terminal::init still unconditionally enables bracketed paste, mouse capture, focus change, and pushes keyboard-enhancement flags on the global tty handle (packages/tui-rs/src/terminal/setup.rs:213-231). When TerminalEventReader::open succeeds, uncurses opens a separate tty handle and enables its own protocol modes, so both stacks are active at once. The PR's stated design is "one owner for terminal input." Functionally this is likely fine because uncurses is a full protocol parser and its enables are applied last, and cleanup double-resets harmlessly. However, it's worth confirming there's no mode that crossterm enables at a level uncurses doesn't expect (which could theoretically cause reply/protocol bytes to be surfaced as key input — the exact failure this PR aims to prevent), and that the drop-order (uncurses reader take() then terminal::restore()) leaves the shell fully reset in all paths.

(Refers to lines 213-231)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1035 to +1043
fn poll_terminal_event(&mut self, timeout: Duration) -> Result<Option<AppTerminalEvent>> {
if let Some(reader) = &mut self.terminal_events {
return reader.poll(timeout).map_err(Into::into);
}
if event::poll(timeout)? {
return Ok(AppTerminalEvent::from_crossterm(event::read()?));
}
Ok(None)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 uncurses poll error propagation may crash the TUI on EINTR from background processes

poll_terminal_event returns reader.poll(timeout).map_err(Into::into) (packages/tui-rs/src/app.rs:1035-1043), and any Err from the uncurses reader propagates up through run_inner and terminates the loop. This TUI routinely spawns background bash/tool child processes, whose termination raises SIGCHLD. If uncurses's underlying poll() surfaces EINTR (rather than retrying internally, as crossterm's mio-based reader does), a benign signal could cause the reader to return an error and take down the whole TUI. The previous crossterm path (event::poll) was robust to EINTR. Worth confirming that uncurses::event::EventSource::poll/try_read retry on EINTR before relying on this in production.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +387 to +392
Some(MouseEvent {
kind,
column: mouse.x,
row: mouse.y,
modifiers: convert_uncurses_modifiers(mouse.modifiers),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Mouse coordinate convention between uncurses and crossterm not verified

convert_uncurses_mouse copies mouse.x/mouse.y directly into crossterm's MouseEvent.column/row (packages/tui-rs/src/terminal/events.rs:387-392), which the slash-popup hit test in app.rs compares against 0-based ratatui Rect coordinates and uses in mouse.row - popup.y - 1 for row indexing. crossterm normalizes native 1-based terminal mouse reports to 0-based. If uncurses reports raw 1-based coordinates, click-to-select on the completion popup would be off by one row/column (selecting the wrong item), though the in_y guard prevents an underflow panic. Worth verifying uncurses's coordinate origin matches crossterm's 0-based convention.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1064 to +1091
let next = match event {
AppTerminalEvent::ColorScheme(scheme) => {
let next = match scheme {
uncurses::event::ColorScheme::Light => "light",
uncurses::event::ColorScheme::Dark => "dark",
};
self.theme_follower = Some(crate::themes::osc11::AutoThemeFollower::new(next));
Some(next)
}
AppTerminalEvent::BackgroundColor { red, green, blue } => {
let luminance = crate::themes::osc11::relative_luminance(*red, *green, *blue);
self.theme_follower
.as_mut()
.and_then(|follower| follower.observe_luminance(luminance))
}
_ => None,
};
let Some(next) = next else {
return false;
};
if crate::themes::current_theme_name() == next {
return false;
}
if crate::themes::set_theme_by_name(next).is_ok() {
return true;
}
false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: ColorScheme events reset the hysteresis follower, discarding accumulated background-luminance readings

In apply_terminal_theme_event, each ColorScheme event replaces the follower with AutoThemeFollower::new(next) (packages/tui-rs/src/app.rs:1070), discarding any consecutive-reading state accumulated from BackgroundColor samples. Because color-scheme notifications are authoritative and applied immediately, this is intentional and correct. One minor edge case: if set_theme_by_name(next) were to fail, the follower's current would be left desynced from the actual applied theme; this cannot happen for the built-in "light"/"dark" names but would matter if the mapping ever pointed at a custom theme.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1015 to +1033
fn initialize_terminal_events(&mut self) {
if uncurses_input_enabled(std::env::var_os("MAESTRO_UNCURSES_INPUT").as_deref()) {
self.terminal_events = TerminalEventReader::open().ok();
}

if self.theme_follower.is_some() {
if self.terminal_events.is_some() {
// Mode 2031 requests push notifications when the terminal's
// color scheme changes. OSC 11 covers terminals that only
// expose their actual background color.
let _ = terminal::enable_theme_reporting();
self.last_theme_query = Some(Instant::now());
} else if self.capabilities.enhanced_keys {
// Compatibility path for terminals where uncurses cannot open
// the controlling tty.
crate::themes::osc11::apply_auto_theme_from_terminal();
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Theme-following only activates once an event/reply arrives; no synchronous startup apply in the uncurses path

Previously, when theme_follow was on and the terminal was enhanced, apply_auto_theme_from_terminal() ran synchronously at construction, so the theme was resolved before the first frame. In the new uncurses path, the follower is created but the theme is only applied after enable_theme_reporting's query round-trips and a ColorScheme/BackgroundColor event is decoded (packages/tui-rs/src/app.rs:1020-1032). This introduces a brief startup window where the theme is the default rather than the terminal-derived one. This is consistent with the PR's live-following goal and not a correctness bug, but is a visible behavior change from the prior one-shot probe.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@haasonsaas

Copy link
Copy Markdown
Contributor Author

Superseded by the canonical source-of-truth flow: evalops/maestro-internal#3182 is merged, and generated public mirror PR #886 carries the reviewed change plus provenance metadata.

@haasonsaas haasonsaas closed this Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant