feat: wire which-key popup into TUI with persistent display until resolution

Popup renders after prefix key (SPC, g) and stays visible indefinitely
until sequence completes, Escape cancels, or invalid key clears state.
No timeout — matches Doom Emacs behavior where which-key waits for user.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Alexander
2026-05-10 13:28:27 +02:00
parent 498e92f2e4
commit edf8d5b160
5 changed files with 56 additions and 3 deletions
Generated
+11
View File
@@ -1660,6 +1660,7 @@ dependencies = [
"tokio", "tokio",
"tonic", "tonic",
"tonic-build", "tonic-build",
"which-key",
] ]
[[package]] [[package]]
@@ -1797,6 +1798,16 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "which-key"
version = "0.1.0"
dependencies = [
"insta",
"proptest",
"ratatui",
"unicode-width 0.2.0",
]
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
+2 -1
View File
@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["crates/evil-keys"] members = ["crates/evil-keys", "crates/which-key"]
[package] [package]
name = "ui-agregator" name = "ui-agregator"
@@ -14,6 +14,7 @@ nix = { version = "0.29", features = ["fs"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9" serde_yaml = "0.9"
evil-keys = { path = "crates/evil-keys" } evil-keys = { path = "crates/evil-keys" }
which-key = { path = "crates/which-key" }
tonic = "0.12" tonic = "0.12"
prost = "0.13" prost = "0.13"
+9 -2
View File
@@ -18,6 +18,7 @@ use ui_agregator::app::App;
use ui_agregator::config::Config; use ui_agregator::config::Config;
use ui_agregator::grpc::{GrpcRequest, spawn_grpc_worker}; use ui_agregator::grpc::{GrpcRequest, spawn_grpc_worker};
use ui_agregator::input::{build_insert_keymap, build_normal_keymap}; use ui_agregator::input::{build_insert_keymap, build_normal_keymap};
use ui_agregator::presentation::which_key_popup::render_which_key_popup;
const TICK_RATE: Duration = Duration::from_millis(100); const TICK_RATE: Duration = Duration::from_millis(100);
@@ -78,7 +79,14 @@ async fn run() -> Result<()> {
} }
while app.running { while app.running {
terminal.draw(|frame| app.draw(frame))?; let which_key_entries = dispatcher.which_key_entries();
let pending_display = dispatcher.pending_display();
terminal.draw(|frame| {
app.draw(frame);
if let Some(ref entries) = which_key_entries {
render_which_key_popup(frame, frame.area(), entries, &pending_display);
}
})?;
if let Ok(response) = grpc_rx.try_recv() { if let Ok(response) = grpc_rx.try_recv() {
app.handle_grpc_response(response); app.handle_grpc_response(response);
@@ -124,7 +132,6 @@ async fn run() -> Result<()> {
_ => {} _ => {}
} }
} else { } else {
dispatcher.check_timeout();
app.handle_tick(); app.handle_tick();
} }
} }
+1
View File
@@ -7,3 +7,4 @@ pub mod progress_bar;
pub mod statusbar; pub mod statusbar;
pub mod topbar; pub mod topbar;
pub mod views; pub mod views;
pub mod which_key_popup;
+33
View File
@@ -0,0 +1,33 @@
use evil_keys::WhichKeyEntry;
use ratatui::{Frame, layout::Rect, style::{Modifier, Style}};
use which_key::{KeyHint, Position, WhichKey};
use crate::theme;
pub fn render_which_key_popup(
frame: &mut Frame,
area: Rect,
entries: &[WhichKeyEntry],
pending_display: &str,
) {
let hints: Vec<KeyHint> = entries
.iter()
.map(|e| {
let h = KeyHint::new(&e.key, &e.description);
if e.is_group { h.group() } else { h }
})
.collect();
let popup = WhichKey::new(hints)
.title(pending_display)
.position(Position::BottomLeft)
.key_style(Style::default().fg(theme::YELLOW).add_modifier(Modifier::BOLD))
.separator_style(Style::default().fg(theme::GRAY))
.desc_style(Style::default().fg(theme::FG2))
.group_style(Style::default().fg(theme::AQUA))
.border_style(Style::default().fg(theme::BG3))
.bg(theme::BG0);
let popup_rect = popup.layout(area);
frame.render_widget(&popup, popup_rect);
}