mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
refactor app loop and improve key capture metadata
This commit is contained in:
@@ -5,6 +5,7 @@ Self-contained: copy this file to any macOS/Linux machine and run it with Python
|
||||
No project imports, no external dependencies.
|
||||
|
||||
What it does:
|
||||
- auto-detects OS + tmux status
|
||||
- asks for terminal name + optional notes
|
||||
- prompts a fixed key matrix one key at a time
|
||||
- captures the raw byte sequence for each key press in raw mode
|
||||
@@ -21,6 +22,8 @@ Exit / control:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import select
|
||||
import sys
|
||||
@@ -114,6 +117,19 @@ class RawMode:
|
||||
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old)
|
||||
|
||||
|
||||
def detect_os() -> str:
|
||||
system = platform.system().lower()
|
||||
if system == "darwin":
|
||||
return "macos"
|
||||
if system == "linux":
|
||||
return "linux"
|
||||
return system or "unknown"
|
||||
|
||||
|
||||
def detect_tmux() -> str:
|
||||
return "yes" if os.environ.get("TMUX") else "no"
|
||||
|
||||
|
||||
def prompt(text: str, default: str | None = None) -> str:
|
||||
suffix = f" [{default}]" if default else ""
|
||||
value = input(f"{text}{suffix}: ").strip()
|
||||
@@ -122,9 +138,11 @@ def prompt(text: str, default: str | None = None) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def choose_output_path(terminal_name: str) -> Path:
|
||||
def choose_output_path(terminal_name: str, os_name: str, tmux_state: str) -> Path:
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
default_name = f"key-capture-{sanitize(terminal_name)}-{stamp}.tsv"
|
||||
default_name = (
|
||||
f"key-capture-{sanitize(terminal_name)}-{sanitize(os_name)}-{sanitize(tmux_state)}-{stamp}.tsv"
|
||||
)
|
||||
raw = prompt("Output file path", default_name)
|
||||
return Path(raw).expanduser().resolve()
|
||||
|
||||
@@ -153,12 +171,14 @@ def confirm_capture(data: bytes) -> str:
|
||||
def write_tsv(path: Path, rows: list[dict[str, str]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
f.write("terminal\tkey\tnotes\tbytes_hex\tescaped\n")
|
||||
f.write("terminal\tos\ttmux\tkey\tnotes\tbytes_hex\tescaped\n")
|
||||
for row in rows:
|
||||
f.write(
|
||||
"\t".join(
|
||||
[
|
||||
row["terminal"],
|
||||
row["os"],
|
||||
row["tmux"],
|
||||
row["key"],
|
||||
row["notes"],
|
||||
row["bytes_hex"],
|
||||
@@ -174,16 +194,21 @@ def main() -> int:
|
||||
print("This will collect one raw sequence per requested key and write a TSV file.")
|
||||
print()
|
||||
|
||||
os_name = detect_os()
|
||||
tmux_state = detect_tmux()
|
||||
|
||||
terminal_name = prompt("Terminal name (e.g. gnome-terminal, ghostty, kitty, iterm2)")
|
||||
if not terminal_name:
|
||||
print("Terminal name is required.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
notes = prompt("Optional notes (e.g. macos, tmux, ssh)", "")
|
||||
output_path = choose_output_path(terminal_name)
|
||||
notes = prompt("Optional notes", "")
|
||||
output_path = choose_output_path(terminal_name, os_name, tmux_state)
|
||||
|
||||
print()
|
||||
print(f"Terminal: {terminal_name}")
|
||||
print(f"OS: {os_name}")
|
||||
print(f"Tmux: {tmux_state}")
|
||||
print(f"Notes: {notes or '-'}")
|
||||
print(f"Output: {output_path}")
|
||||
print()
|
||||
@@ -207,6 +232,8 @@ def main() -> int:
|
||||
rows.append(
|
||||
{
|
||||
"terminal": terminal_name,
|
||||
"os": os_name,
|
||||
"tmux": tmux_state,
|
||||
"key": key_id,
|
||||
"notes": notes,
|
||||
"bytes_hex": "",
|
||||
@@ -222,6 +249,8 @@ def main() -> int:
|
||||
rows.append(
|
||||
{
|
||||
"terminal": terminal_name,
|
||||
"os": os_name,
|
||||
"tmux": tmux_state,
|
||||
"key": key_id,
|
||||
"notes": notes,
|
||||
"bytes_hex": to_hex(data),
|
||||
|
||||
+172
-101
@@ -11,6 +11,7 @@ pub mod state;
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const RENDER_INTERVAL: Duration = Duration::from_millis(16);
|
||||
const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500);
|
||||
const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
|
||||
|
||||
@@ -43,6 +44,14 @@ pub struct App {
|
||||
last_sidebar_divider_click: Option<Instant>,
|
||||
}
|
||||
|
||||
enum LoopEvent {
|
||||
RenderTick,
|
||||
Internal(AppEvent),
|
||||
RawInput(crate::raw_input::RawInputEvent),
|
||||
InputClosed,
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// Resolve the palette from config: base theme + optional custom overrides.
|
||||
fn resolve_palette(config: &crate::config::Config) -> state::Palette {
|
||||
// Start with the named theme (default: catppuccin)
|
||||
@@ -204,59 +213,89 @@ impl App {
|
||||
self.input_rx = Some(crate::raw_input::spawn_input_reader());
|
||||
}
|
||||
|
||||
let mut render_tick = tokio::time::interval(RENDER_INTERVAL);
|
||||
render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut needs_render = true;
|
||||
|
||||
while !self.state.should_quit {
|
||||
if self
|
||||
.config_diagnostic_deadline
|
||||
.is_some_and(|deadline| Instant::now() >= deadline)
|
||||
{
|
||||
self.config_diagnostic_deadline = None;
|
||||
self.state.config_diagnostic = None;
|
||||
}
|
||||
|
||||
if self
|
||||
.toast_deadline
|
||||
.is_some_and(|deadline| Instant::now() >= deadline)
|
||||
{
|
||||
self.toast_deadline = None;
|
||||
self.state.toast = None;
|
||||
}
|
||||
|
||||
self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1);
|
||||
|
||||
if self.last_git_remote_status_refresh.elapsed() >= GIT_REMOTE_STATUS_REFRESH_INTERVAL {
|
||||
for ws in &mut self.state.workspaces {
|
||||
ws.refresh_git_ahead_behind();
|
||||
}
|
||||
self.last_git_remote_status_refresh = Instant::now();
|
||||
}
|
||||
|
||||
terminal.draw(|frame| {
|
||||
crate::ui::compute_view(&mut self.state, frame.area());
|
||||
crate::ui::render(&self.state, frame);
|
||||
})?;
|
||||
|
||||
// Drain internal events first so API reads observe fresh pane state.
|
||||
self.drain_internal_events();
|
||||
let had_internal_events = self.drain_internal_events();
|
||||
|
||||
let mut had_api_requests = false;
|
||||
while let Ok(msg) = self.api_rx.try_recv() {
|
||||
had_api_requests = true;
|
||||
let response = self.handle_api_request(msg.request);
|
||||
let _ = msg.respond_to.send(response);
|
||||
}
|
||||
|
||||
if had_internal_events || had_api_requests {
|
||||
needs_render = true;
|
||||
}
|
||||
|
||||
self.sync_focus_events();
|
||||
self.handle_resize_poll();
|
||||
self.drain_raw_input().await;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(16)).await;
|
||||
if self.handle_frame_timers() {
|
||||
needs_render = true;
|
||||
}
|
||||
|
||||
if self.state.request_complete_onboarding {
|
||||
self.state.request_complete_onboarding = false;
|
||||
self.complete_onboarding();
|
||||
needs_render = true;
|
||||
}
|
||||
|
||||
if self.state.request_new_workspace {
|
||||
self.state.request_new_workspace = false;
|
||||
self.create_workspace();
|
||||
needs_render = true;
|
||||
}
|
||||
|
||||
if needs_render {
|
||||
terminal.draw(|frame| {
|
||||
crate::ui::compute_view(&mut self.state, frame.area());
|
||||
crate::ui::render(&self.state, frame);
|
||||
})?;
|
||||
needs_render = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = {
|
||||
let input_rx = self.input_rx.as_mut();
|
||||
tokio::select! {
|
||||
_ = render_tick.tick() => LoopEvent::RenderTick,
|
||||
maybe_ev = self.event_rx.recv() => match maybe_ev {
|
||||
Some(ev) => LoopEvent::Internal(ev),
|
||||
None => LoopEvent::Idle,
|
||||
},
|
||||
maybe_input = async {
|
||||
match input_rx {
|
||||
Some(rx) => rx.recv().await,
|
||||
None => None,
|
||||
}
|
||||
} => match maybe_input {
|
||||
Some(input) => LoopEvent::RawInput(input),
|
||||
None => LoopEvent::InputClosed,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
match event {
|
||||
LoopEvent::RenderTick => {
|
||||
self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1);
|
||||
needs_render = true;
|
||||
}
|
||||
LoopEvent::Internal(ev) => {
|
||||
self.handle_internal_event(ev);
|
||||
needs_render = true;
|
||||
}
|
||||
LoopEvent::RawInput(input) => {
|
||||
self.handle_raw_input_event(input).await;
|
||||
needs_render = true;
|
||||
}
|
||||
LoopEvent::InputClosed => {
|
||||
self.input_rx = None;
|
||||
}
|
||||
LoopEvent::Idle => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,23 +312,16 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn drain_raw_input(&mut self) {
|
||||
while let Some(rx) = self.input_rx.as_mut() {
|
||||
match rx.try_recv() {
|
||||
Ok(crate::raw_input::RawInputEvent::Key(key)) => {
|
||||
if key.kind == crossterm::event::KeyEventKind::Press {
|
||||
self.handle_key(key).await;
|
||||
}
|
||||
}
|
||||
Ok(crate::raw_input::RawInputEvent::Paste(text)) => self.handle_paste(text).await,
|
||||
Ok(crate::raw_input::RawInputEvent::Mouse(mouse)) => self.handle_mouse(mouse),
|
||||
Ok(crate::raw_input::RawInputEvent::Unsupported) => {}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
|
||||
self.input_rx = None;
|
||||
break;
|
||||
async fn handle_raw_input_event(&mut self, event: crate::raw_input::RawInputEvent) {
|
||||
match event {
|
||||
crate::raw_input::RawInputEvent::Key(key) => {
|
||||
if key.kind == crossterm::event::KeyEventKind::Press {
|
||||
self.handle_key(key).await;
|
||||
}
|
||||
}
|
||||
crate::raw_input::RawInputEvent::Paste(text) => self.handle_paste(text).await,
|
||||
crate::raw_input::RawInputEvent::Mouse(mouse) => self.handle_mouse(mouse),
|
||||
crate::raw_input::RawInputEvent::Unsupported => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,67 +332,106 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_internal_events(&mut self) {
|
||||
fn handle_frame_timers(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
|
||||
if self
|
||||
.config_diagnostic_deadline
|
||||
.is_some_and(|deadline| Instant::now() >= deadline)
|
||||
{
|
||||
self.config_diagnostic_deadline = None;
|
||||
self.state.config_diagnostic = None;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if self
|
||||
.toast_deadline
|
||||
.is_some_and(|deadline| Instant::now() >= deadline)
|
||||
{
|
||||
self.toast_deadline = None;
|
||||
self.state.toast = None;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if self.last_git_remote_status_refresh.elapsed() >= GIT_REMOTE_STATUS_REFRESH_INTERVAL {
|
||||
for ws in &mut self.state.workspaces {
|
||||
ws.refresh_git_ahead_behind();
|
||||
}
|
||||
self.last_git_remote_status_refresh = Instant::now();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
fn drain_internal_events(&mut self) -> bool {
|
||||
let mut had_event = false;
|
||||
while let Ok(ev) = self.event_rx.try_recv() {
|
||||
match &ev {
|
||||
AppEvent::PaneDied { pane_id } => {
|
||||
if let Some((ws_idx, _)) = self.find_pane(*pane_id) {
|
||||
if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) {
|
||||
had_event = true;
|
||||
self.handle_internal_event(ev);
|
||||
}
|
||||
had_event
|
||||
}
|
||||
|
||||
fn handle_internal_event(&mut self, ev: AppEvent) {
|
||||
match &ev {
|
||||
AppEvent::PaneDied { pane_id } => {
|
||||
if let Some((ws_idx, _)) = self.find_pane(*pane_id) {
|
||||
if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) {
|
||||
self.emit_event(crate::api::schema::EventEnvelope {
|
||||
event: crate::api::schema::EventKind::PaneExited,
|
||||
data: crate::api::schema::EventData::PaneExited {
|
||||
pane_id: public_pane_id,
|
||||
workspace_id: self.public_workspace_id(ws_idx),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::StateChanged {
|
||||
pane_id,
|
||||
agent,
|
||||
state,
|
||||
} => {
|
||||
if let Some((ws_idx, pane)) = self.find_pane(*pane_id) {
|
||||
if let Some(pane_id) = self.public_pane_id(ws_idx, *pane_id) {
|
||||
let workspace_id = self.public_workspace_id(ws_idx);
|
||||
if pane.detected_agent != *agent {
|
||||
self.emit_event(crate::api::schema::EventEnvelope {
|
||||
event: crate::api::schema::EventKind::PaneExited,
|
||||
data: crate::api::schema::EventData::PaneExited {
|
||||
pane_id: public_pane_id,
|
||||
workspace_id: self.public_workspace_id(ws_idx),
|
||||
event: crate::api::schema::EventKind::PaneAgentDetected,
|
||||
data: crate::api::schema::EventData::PaneAgentDetected {
|
||||
pane_id: pane_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
agent: agent.map(agent_name),
|
||||
},
|
||||
});
|
||||
}
|
||||
if pane.state != *state {
|
||||
self.emit_event(crate::api::schema::EventEnvelope {
|
||||
event: crate::api::schema::EventKind::PaneAgentStateChanged,
|
||||
data: crate::api::schema::EventData::PaneAgentStateChanged {
|
||||
pane_id,
|
||||
workspace_id,
|
||||
state: pane_agent_state(*state),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::StateChanged {
|
||||
pane_id,
|
||||
agent,
|
||||
state,
|
||||
} => {
|
||||
if let Some((ws_idx, pane)) = self.find_pane(*pane_id) {
|
||||
if let Some(pane_id) = self.public_pane_id(ws_idx, *pane_id) {
|
||||
let workspace_id = self.public_workspace_id(ws_idx);
|
||||
if pane.detected_agent != *agent {
|
||||
self.emit_event(crate::api::schema::EventEnvelope {
|
||||
event: crate::api::schema::EventKind::PaneAgentDetected,
|
||||
data: crate::api::schema::EventData::PaneAgentDetected {
|
||||
pane_id: pane_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
agent: agent.map(agent_name),
|
||||
},
|
||||
});
|
||||
}
|
||||
if pane.state != *state {
|
||||
self.emit_event(crate::api::schema::EventEnvelope {
|
||||
event: crate::api::schema::EventKind::PaneAgentStateChanged,
|
||||
data: crate::api::schema::EventData::PaneAgentStateChanged {
|
||||
pane_id,
|
||||
workspace_id,
|
||||
state: pane_agent_state(*state),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateReady { .. } => {}
|
||||
}
|
||||
AppEvent::UpdateReady { .. } => {}
|
||||
}
|
||||
|
||||
let previous_toast = self.state.toast.clone();
|
||||
self.state.handle_app_event(ev);
|
||||
if self.state.toast != previous_toast {
|
||||
self.toast_deadline = self.state.toast.as_ref().map(|toast| {
|
||||
let duration = match toast.kind {
|
||||
ToastKind::NeedsAttention => Duration::from_secs(8),
|
||||
ToastKind::Finished => Duration::from_secs(5),
|
||||
};
|
||||
Instant::now() + duration
|
||||
});
|
||||
}
|
||||
let previous_toast = self.state.toast.clone();
|
||||
self.state.handle_app_event(ev);
|
||||
if self.state.toast != previous_toast {
|
||||
self.toast_deadline = self.state.toast.as_ref().map(|toast| {
|
||||
let duration = match toast.kind {
|
||||
ToastKind::NeedsAttention => Duration::from_secs(8),
|
||||
ToastKind::Finished => Duration::from_secs(5),
|
||||
};
|
||||
Instant::now() + duration
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user