From da8f883990e8fed282a469455a22193d893973f3 Mon Sep 17 00:00:00 2001 From: Ferrol Aderholdt Date: Tue, 4 Aug 2026 21:57:40 -0700 Subject: [PATCH] fix: steady omp state, real omp stats, and an edge between stacked panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from using the board with an oh-my-pi session: - omp animates its spinner in an unterminated tail and can go a whole turn without completing a line. The idle timeout that reverts a stalled session to READY is fed only by complete lines, so a working session looked idle after 2s and the next spinner frame flipped it back to THINKING — visible flapping for the length of the turn. A changed tail now counts as output; an unchanged one (the reader re-sends it once a second) still does not, so a session whose screen has genuinely stopped moving idles out as before. - omp had no stats watcher, only screen scraping. It records per-turn usage in ~/.omp/agent/sessions, and /new rolls a new transcript file there — which is what wiped a session's token tracking mid-run. omp_log.rs tails that transcript, follows the roll, and carries the previous file's totals forward. Context follows /new down, being a snapshot of the live prompt rather than a total. - Vertically split panes butted one pane's last row of output against the next pane's title with nothing in between. Each pane's title row now doubles as its top border, with a rule from the end of the title to the right margin, costing no rows of output. Co-Authored-By: Claude --- CHANGELOG.md | 4 + CLAUDE.md | 3 +- docs/sessions.md | 8 + src/app.rs | 51 +++++ src/main.rs | 1 + src/omp_log.rs | 475 +++++++++++++++++++++++++++++++++++++++++++++++ src/session.rs | 5 + src/ui.rs | 39 +++- 8 files changed, 582 insertions(+), 4 deletions(-) create mode 100644 src/omp_log.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fc36b5e..2ea3ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- oh-my-pi sessions no longer flap between THINKING and READY every couple of seconds. omp animates its spinner in an unterminated tail rather than completing lines, and the idle timeout that reverts a stalled session to READY was fed only by complete lines — so a session that was visibly working looked idle after 2s, got flipped back to THINKING by the next spinner frame, and oscillated for the whole turn. A *changed* tail now counts as output; an unchanged one (the PTY reader re-sends it once a second) still does not, so a session whose screen has genuinely stopped moving idles out as before. +- oh-my-pi sessions get a real stats watcher (`src/omp_log.rs`), reading the per-turn `usage` records omp writes to `~/.omp/agent/sessions` (honouring `OMP_HOME`) instead of scraping the screen — so tokens, cost, context, and model are the ones omp itself recorded. `/new` starts a fresh transcript file, which is why token tracking used to reset to zero mid-session; the watcher follows the roll and carries the previous transcript's totals forward. Context, being a snapshot of the live prompt rather than a total, does follow `/new` down. +- Stacked output panes now have a visible edge between them. The left bar separates side-by-side panes, but vertically split panes butted one pane's last row of output directly against the next pane's title with nothing in between. Each pane's title row now doubles as its top border: a rule runs from the end of the title to the right margin. No row of output is spent on it. + - Fixed the menu cursor disappearing when arrowing onto a disabled row — most visibly Orchestrator → "Show/Hide Session", which is disabled unless a CLI-class orchestrator session exists. Arrow keys already stepped over separators, but not over disabled rows, and the renderer draws those dim and never highlighted: the selection was still there, just invisible, and Enter did nothing. Both kinds of unselectable row are now stepped past. Relatedly, `Up` returns to the menu bar from the topmost row that can actually be selected rather than from literal index 0, which otherwise wrapped to the bottom of the list when the first row was a separator or disabled. - Fixed codex sessions having no scrollback at all. Both of the paths that could have supplied it produced nothing, for reasons that only look alike from a distance: the capture linkshell already had was gated on the session being on the alternate screen, which codex never enters, and the vt100 fallback it therefore fell through to was empty because codex scrolls inside a `DECSTBM` region — and lines evicted from a restricted region are discarded rather than pushed to scrollback, per the DEC spec and vt100's implementation of it. The transcript is now recovered from the region as it scrolls, for any session kind we capture for, on either screen. diff --git a/CLAUDE.md b/CLAUDE.md index 9d2c997..3502762 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ Linkshell is a terminal multiplexer TUI built for AI coding agents. It manages u | `claude_log.rs` | Watch `$CLAUDE_CONFIG_DIR/projects` JSONL for cumulative token/cost stats | | `codex_log.rs` | Watch `$CODEX_HOME/sessions` rollout JSONL for token/context stats | | `opencode_log.rs` | Watch the OpenCode SQLite DB for token/cost stats | +| `omp_log.rs` | Watch `$OMP_HOME/agent/sessions` transcript JSONL for oh-my-pi token/context/cost stats, following the file roll `/new` produces | | `ctx_probe.rs` | Probe local model backends (llama.cpp, LM Studio) for context window size | | `notify.rs` | Desktop notifications (notify-send, OSC 9, bell) for WAITING/ERROR | | `doctor.rs` | `linkshell doctor` — environment/config diagnostics | @@ -53,7 +54,7 @@ Background tasks communicate via `tokio::mpsc` to the main loop: 1. **Input reader** — keyboard/mouse → `Key`/`Mouse` events 2. **Tick generator** — 500ms → `Tick` event (timeout-based state transitions) 3. **PTY reader** (one per session) — raw bytes → `SessionBytes` + `SessionOutput`/`SessionCurrentLine` -4. **Log watchers** — Claude/Codex/OpenCode logs → token/cost events +4. **Log watchers** — Claude/Codex/OpenCode/oh-my-pi logs → token/cost events 5. **IPC listener** — `linkshell-ctl` / remote agents → state, input, pipe, chat messages 6. **Orchestrator task** (API class) — tool-use loop ↔ main loop via events diff --git a/docs/sessions.md b/docs/sessions.md index e35ef76..b60a445 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -110,6 +110,14 @@ Sessions running `opencode`, `omp` (oh-my-pi), `pi`, `aider`, `llama-cli`, or token scraping. Wrappers with other names can be mapped with `kind = "local"` in `[sessions.aliases]`. +Two of them keep their own records and get watched instead of scraped: +`opencode` (its SQLite database) and `omp` (the per-turn transcript JSONL under +`$OMP_HOME/agent/sessions`, default `~/.omp`). For omp that means the tokens, +cost, context, and model shown are the ones it recorded. `/new` starts a fresh +transcript; the watcher follows it and carries the totals forward, so a session's +tokens keep climbing — context, which measures the live prompt rather than a +total, drops with the new thread. + ## Session states States are inferred from PTY output and refined by JSONL log activity. diff --git a/src/app.rs b/src/app.rs index 6dce4f8..8bb42f6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1058,6 +1058,19 @@ impl App { s.stats_from_watcher = true; } } + // oh-my-pi keeps a per-turn usage transcript under + // ~/.omp/agent/sessions; that beats scraping its status bar, + // and it is the only place the model and context size appear. + if matches!(kind, SessionKind::OhMyPi) + || crate::session::command_base_name(&cmd_str) == Some("omp") + { + let omp_home = crate::session::command_env_assignment(&cmd_str, "OMP_HOME") + .map(|v| expand_tilde(&v)); + crate::omp_log::spawn_watcher(id, cwd.clone(), tx.clone(), omp_home); + if let Some(s) = self.sessions.iter_mut().find(|s| s.id == id) { + s.stats_from_watcher = true; + } + } // When the command itself names the backend (llama-cli, …), // probe that backend's localhost API for the loaded model's // context window. Sessions that reach a backend indirectly @@ -1972,6 +1985,21 @@ impl App { if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { state_before = Some(session.state.clone()); let stripped = strip_ansi(&text); + // A TUI that animates a spinner in its unterminated tail (omp, + // opencode) may never complete a line for the whole turn, so + // `last_output_at` — fed only by complete lines — goes stale and + // the 2s idle timeout drags the session back to READY, only for + // the next spinner frame to flip it to THINKING again. That + // flapping is what the status panel showed. A *changed* tail is + // output, so count it as liveness; an unchanged one (re-sent once + // a second by the reader) is not, and still lets the session idle + // out. + if stripped != session.last_partial_line { + session.last_partial_line.clone_from(&stripped); + if !stripped.trim().is_empty() { + session.last_output_at = Some(std::time::Instant::now()); + } + } if let Some(new_state) = self.matcher.infer_state(&stripped, session.base) { // Partial lines can detect Thinking/Waiting/Ready but must not // flip to Running — that requires a complete line. @@ -9049,6 +9077,29 @@ mod tests { assert_eq!(app.sessions[0].state, SessionState::Ready); } + #[test] + fn an_animating_tail_keeps_a_thinking_session_alive_but_a_static_one_idles_out() { + let mut app = make_app(); + let id = app.spawn_headless_session("agent".into(), None).unwrap(); + app.sessions[0].base = crate::session::BaseKind::LocalAgent; + app.sessions[0].state = SessionState::Thinking; + let stale = std::time::Instant::now() - std::time::Duration::from_secs(3); + app.sessions[0].last_output_at = Some(stale); + + // A spinner frame the session has not shown before: real output, so + // the idle timeout must not fire against it. + app.handle_session_current_line(id, "⠹ Working… ⟨esc⟩".into()); + app.handle_tick(); + assert_eq!(app.sessions[0].state, SessionState::Thinking); + + // The same tail re-sent by the reader is not new output, so a session + // whose screen has stopped moving still falls back to READY. + app.sessions[0].last_output_at = Some(stale); + app.handle_session_current_line(id, "⠹ Working… ⟨esc⟩".into()); + app.handle_tick(); + assert_eq!(app.sessions[0].state, SessionState::Ready); + } + #[test] fn resize_updates_all_sessions_and_notifies_resizers() { let mut app = make_app(); diff --git a/src/main.rs b/src/main.rs index 50051c1..a70ccd6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod ipc; mod keybindings; mod layout; mod notify; +mod omp_log; mod opencode_log; mod orchestrator; mod patterns; diff --git a/src/omp_log.rs b/src/omp_log.rs new file mode 100644 index 0000000..5e8d141 --- /dev/null +++ b/src/omp_log.rs @@ -0,0 +1,475 @@ +//! Watch `~/.omp/agent/sessions` for the transcript JSONL oh-my-pi writes and +//! emit cumulative token/context/cost stats from its per-turn `usage` records. +//! +//! omp starts a *new* transcript file whenever the user runs `/new`, so the +//! watcher follows the chain rather than a single file: totals from the file +//! it was tailing are carried forward as a base when it rolls to the next one. +//! Tailing one file would zero the session's tokens on every `/new`. +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; +use tokio::time::{sleep, Duration}; + +use crate::events::AppEvent; +use crate::session::TokenStats; + +pub fn sessions_dir(omp_home: Option<&str>) -> Option { + let base = match omp_home { + Some(dir) => PathBuf::from(dir), + None => std::env::var("OMP_HOME") + .map(PathBuf::from) + .or_else(|_| std::env::var("HOME").map(|h| PathBuf::from(h).join(".omp"))) + .ok()?, + }; + Some(base.join("agent").join("sessions")) +} + +fn jsonl_files(dir: &Path) -> HashSet { + let mut out = HashSet::new(); + collect_jsonl_files(dir, &mut out); + out +} + +fn collect_jsonl_files(dir: &Path, out: &mut HashSet) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_jsonl_files(&path, out); + } else if path.extension().and_then(|s| s.to_str()) == Some("jsonl") { + out.insert(path); + } + } +} + +/// The `session` header record carries the working directory omp was started +/// in — the only reliable way to tell one transcript from another, since the +/// directory name is a lossy slug of that path. +fn transcript_cwd(path: &Path) -> Option { + let data = std::fs::read_to_string(path).ok()?; + for line in data.lines().take(20) { + let v: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => continue, + }; + if v["type"].as_str() == Some("session") { + return v["cwd"].as_str().map(str::to_owned); + } + } + None +} + +fn mtime(path: &Path) -> Option { + std::fs::metadata(path).and_then(|m| m.modified()).ok() +} + +/// Per-turn usage from an assistant message. omp reports each API call +/// separately, so these are turn deltas that the tail accumulates — except +/// `context_tokens`, which is a snapshot of the whole prompt. +struct RawUsage { + input: u64, + output: u64, + cost: f64, + context: u64, + model: Option, +} + +fn parse_usage(v: &serde_json::Value) -> Option { + if v["type"].as_str()? != "message" { + return None; + } + let message = &v["message"]; + if message["role"].as_str()? != "assistant" { + return None; + } + let usage = &message["usage"]; + let input = usage["input"].as_u64().unwrap_or(0); + let output = usage["output"].as_u64().unwrap_or(0); + let cache_read = usage["cacheRead"].as_u64().unwrap_or(0); + let cache_write = usage["cacheWrite"].as_u64().unwrap_or(0); + if input == 0 && output == 0 && cache_read == 0 { + return None; + } + // A local model reports zero cost forever; that is a real answer, not a + // missing one, so it stays zero rather than being estimated. + let cost = usage["cost"]["total"].as_f64().unwrap_or(0.0); + let context = message["contextSnapshot"]["promptTokens"] + .as_u64() + .unwrap_or(input + cache_read + cache_write); + Some(RawUsage { + input, + output, + cost, + context, + model: message["model"].as_str().map(str::to_owned), + }) +} + +/// Model id, either from a standalone `model_change` record or from the turn +/// that used it. omp prefixes the provider (`lm-studio/thinkingcap-…`); the +/// status panel has room for the model, not the routing. +fn parse_model(v: &serde_json::Value) -> Option { + if v["type"].as_str()? != "model_change" { + return None; + } + v["model"].as_str().map(strip_provider) +} + +fn strip_provider(model: &str) -> String { + model + .rsplit_once('/') + .map(|(_, m)| m) + .unwrap_or(model) + .to_string() +} + +/// Totals carried out of one transcript when omp rolls to the next. +#[derive(Default, Clone, Copy)] +struct Carried { + input: u64, + output: u64, + cost: f64, +} + +/// The transcript for this cwd that omp is writing to now, if it is newer than +/// the one we are on. `/new` writes a fresh file rather than truncating. +fn newer_transcript(dir: &Path, cwd: &str, current: &Path) -> Option { + let current_mtime = mtime(current)?; + let mut candidates: Vec = jsonl_files(dir) + .into_iter() + .filter(|p| p != current) + .filter(|p| mtime(p).is_some_and(|m| m > current_mtime)) + .filter(|p| transcript_cwd(p).as_deref() == Some(cwd)) + .collect(); + candidates.sort(); + candidates.pop() +} + +/// Wait for a transcript in `cwd` that this watcher can claim: either one omp +/// created after we spawned, or a pre-existing one it resumed into (mtime past +/// our spawn time). omp only writes the file once the first prompt is sent, so +/// there is no deadline. +async fn wait_for_transcript( + dir: &Path, + existing: &HashSet, + spawn_time: std::time::SystemTime, + cwd: &str, + tx: &tokio::sync::mpsc::Sender, +) -> Option { + loop { + let mut candidates: Vec = jsonl_files(dir) + .into_iter() + .filter(|p| !existing.contains(p) || mtime(p).is_some_and(|m| m > spawn_time)) + .collect(); + candidates.sort(); + + for path in candidates { + if transcript_cwd(&path).as_deref() == Some(cwd) + && crate::claude_log::claim_jsonl(&path) + { + return Some(path); + } + } + + if tx.is_closed() { + return None; + } + sleep(Duration::from_millis(500)).await; + } +} + +/// Tail one transcript until omp rolls to the next one for this cwd (or the +/// app shuts down), reporting `base` plus this file's totals as it goes. +/// Returns the totals to carry into the next file. +async fn tail( + session_id: usize, + dir: &Path, + path: &Path, + cwd: &str, + base: Carried, + tx: &tokio::sync::mpsc::Sender, +) -> Option<(PathBuf, Carried)> { + let mut offset: u64 = 0; + let mut acc = Carried::default(); + let mut context_tokens: u64 = 0; + let mut model: Option = None; + + loop { + if tx.is_closed() { + return None; + } + + let mut new_stats = false; + + if let Ok(file) = tokio::fs::File::open(path).await { + let mut file = file; + if file.seek(std::io::SeekFrom::Start(offset)).await.is_ok() { + let mut reader = BufReader::new(file); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(n) => { + // No trailing newline means we caught omp mid-write; + // leave the offset so the next poll re-reads it whole. + if !line.ends_with('\n') { + break; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + offset += n as u64; + continue; + } + let v: serde_json::Value = match serde_json::from_str(trimmed) { + Ok(v) => v, + Err(_) => break, + }; + offset += n as u64; + let turn_model = parse_model(&v); + let usage = parse_usage(&v); + let seen_model = turn_model.or_else(|| { + usage + .as_ref() + .and_then(|u| u.model.as_deref().map(strip_provider)) + }); + if let Some(m) = seen_model { + if model.as_deref() != Some(&m) { + model = Some(m.clone()); + let _ = tx + .send(AppEvent::SessionModel { + session_id, + model: m, + }) + .await; + } + } + if let Some(u) = usage { + acc.input += u.input; + acc.output += u.output; + acc.cost += u.cost; + context_tokens = u.context; + new_stats = true; + } + } + Err(_) => break, + } + } + } + } + + if new_stats { + let stats = TokenStats { + input_tokens: base.input + acc.input, + output_tokens: base.output + acc.output, + total_cost_usd: base.cost + acc.cost, + context_tokens, + }; + if tx + .send(AppEvent::SessionStats { session_id, stats }) + .await + .is_err() + { + return None; + } + } + + // `/new` rolls the transcript. Follow it, keeping what this file spent + // so the session's tokens climb instead of resetting to zero. + if let Some(next) = newer_transcript(dir, cwd, path) { + if crate::claude_log::claim_jsonl(&next) { + return Some(( + next, + Carried { + input: base.input + acc.input, + output: base.output + acc.output, + cost: base.cost + acc.cost, + }, + )); + } + } + + sleep(Duration::from_millis(500)).await; + } +} + +/// Spawn a watcher for the omp transcripts written in this cwd. +pub fn spawn_watcher( + session_id: usize, + cwd: String, + tx: tokio::sync::mpsc::Sender, + omp_home: Option, +) { + let dir = match sessions_dir(omp_home.as_deref()) { + Some(d) => d, + None => return, + }; + + // Snapshot before the PTY starts, like the other watchers, so a fast omp + // can't create its transcript and have it counted as pre-existing. + let existing = jsonl_files(&dir); + let spawn_time = std::time::SystemTime::now(); + + tokio::spawn(async move { + let mut path = match wait_for_transcript(&dir, &existing, spawn_time, &cwd, &tx).await { + Some(p) => p, + None => return, + }; + let mut base = Carried::default(); + while let Some((next, carried)) = tail(session_id, &dir, &path, &cwd, base, &tx).await { + path = next; + base = carried; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn value(s: &str) -> serde_json::Value { + serde_json::from_str(s).unwrap() + } + + fn transcript(cwd: &str, input: u64, output: u64) -> String { + format!( + "{{\"type\":\"session\",\"version\":3,\"cwd\":\"{cwd}\"}}\n\ + {{\"type\":\"model_change\",\"model\":\"lm-studio/qwen3.6-27b\"}}\n\ + {{\"type\":\"message\",\"message\":{{\"role\":\"assistant\",\ + \"usage\":{{\"input\":{input},\"output\":{output},\"cacheRead\":0,\ + \"cacheWrite\":0,\"cost\":{{\"total\":0}}}},\ + \"contextSnapshot\":{{\"promptTokens\":{input}}}}}}}\n" + ) + } + + /// `/new` starts a fresh transcript. The session's tokens must keep + /// climbing across that roll — resetting to the new file's totals is the + /// bug this watcher exists to avoid. + #[tokio::test] + async fn a_new_transcript_carries_the_previous_ones_totals_forward() { + let home = std::env::temp_dir().join(format!( + "omp-home-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let dir = home.join("agent").join("sessions").join("-proj"); + std::fs::create_dir_all(&dir).unwrap(); + let cwd = "/home/u/proj"; + + let (tx, mut rx) = tokio::sync::mpsc::channel(64); + spawn_watcher(7, cwd.into(), tx, Some(home.to_string_lossy().into_owned())); + + std::fs::write(dir.join("a.jsonl"), transcript(cwd, 1000, 200)).unwrap(); + + let first = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(AppEvent::SessionStats { stats, .. }) = rx.recv().await { + return stats; + } + } + }) + .await + .expect("stats for the first transcript"); + assert_eq!(first.input_tokens, 1000); + assert_eq!(first.output_tokens, 200); + assert_eq!(first.context_tokens, 1000); + + // Newer mtime, as `/new` produces. + sleep(Duration::from_millis(1100)).await; + std::fs::write(dir.join("b.jsonl"), transcript(cwd, 300, 50)).unwrap(); + + let rolled = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(AppEvent::SessionStats { stats, .. }) = rx.recv().await { + if stats.input_tokens != 1000 { + return stats; + } + } + } + }) + .await + .expect("stats after the transcript rolled"); + assert_eq!(rolled.input_tokens, 1300, "totals carried across /new"); + assert_eq!(rolled.output_tokens, 250); + // Context is a snapshot of the live prompt, so it does follow /new down. + assert_eq!(rolled.context_tokens, 300); + + std::fs::remove_dir_all(&home).ok(); + } + + #[test] + fn assistant_usage_becomes_a_turn_delta_with_a_context_snapshot() { + let u = parse_usage(&value( + r#"{"type":"message","message":{"role":"assistant", + "model":"lm-studio/thinkingcap-qwen3.6-27b", + "usage":{"input":28029,"output":160,"cacheRead":0,"cacheWrite":0, + "totalTokens":28189,"cost":{"total":0}}, + "contextSnapshot":{"promptTokens":28029}}}"#, + )) + .expect("assistant usage"); + assert_eq!(u.input, 28029); + assert_eq!(u.output, 160); + assert_eq!(u.cost, 0.0); + assert_eq!(u.context, 28029); + assert_eq!( + u.model.as_deref(), + Some("lm-studio/thinkingcap-qwen3.6-27b") + ); + } + + #[test] + fn records_without_assistant_usage_are_ignored() { + assert!(parse_usage(&value( + r#"{"type":"message","message":{"role":"user","content":[]}}"# + )) + .is_none()); + assert!(parse_usage(&value( + r#"{"type":"custom","customType":"session_exit","data":{}}"# + )) + .is_none()); + assert!(parse_usage(&value( + r#"{"type":"message","message":{"role":"assistant","usage":{"input":0,"output":0}}}"# + )) + .is_none()); + } + + #[test] + fn the_model_loses_its_provider_prefix() { + assert_eq!( + parse_model(&value( + r#"{"type":"model_change","model":"lm-studio/thinkingcap-qwen3.6-27b"}"# + )) + .as_deref(), + Some("thinkingcap-qwen3.6-27b") + ); + assert_eq!(strip_provider("qwen3.6-27b"), "qwen3.6-27b"); + assert!(parse_model(&value(r#"{"type":"session","cwd":"/tmp"}"#)).is_none()); + } + + #[test] + fn the_session_header_identifies_the_transcripts_cwd() { + let dir = std::env::temp_dir().join(format!("omp-log-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.jsonl"); + std::fs::write( + &path, + "{\"type\":\"title\",\"title\":\"x\"}\n\ + {\"type\":\"session\",\"version\":3,\"cwd\":\"/home/u/proj\"}\n", + ) + .unwrap(); + assert_eq!(transcript_cwd(&path).as_deref(), Some("/home/u/proj")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn sessions_dir_is_under_the_omp_home() { + assert_eq!( + sessions_dir(Some("/home/u/.omp")).unwrap(), + PathBuf::from("/home/u/.omp/agent/sessions") + ); + } +} diff --git a/src/session.rs b/src/session.rs index c0d4f60..aa8945e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -473,6 +473,10 @@ pub struct Session { /// A dedicated log/db watcher reports authoritative cumulative stats for /// this session, so terminal line-scraping must not touch `stats`. pub stats_from_watcher: bool, + /// Last unterminated tail we ran state inference over. The PTY reader + /// re-sends an unchanged tail once a second, so this is what tells a + /// live repaint (spinner frame advancing) apart from a still screen. + pub last_partial_line: String, /// Optional path to log session output lines to disk. pub log_path: Option, /// Hash of the last rendered screen contents, used by `process_bytes` to @@ -560,6 +564,7 @@ impl Session { base, log_path: None, stats_from_watcher: false, + last_partial_line: String::new(), last_screen_hash: 0, } } diff --git a/src/ui.rs b/src/ui.rs index d1d3f92..39a08ca 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -386,8 +386,15 @@ fn draw_pane_frame( ); } if area.width > 2 { + // The title row doubles as the pane's top edge: a rule runs from the + // end of the title to the right margin. The left bar alone separates + // side-by-side panes, but stacked panes butt content directly against + // the next pane's title with nothing between them — this is the line + // that says where one ends and the next begins. + let title = Line::from(title); + let used = title.width() as u16; f.render_widget( - Paragraph::new(Line::from(title)), + Paragraph::new(title), Rect { x: area.x + 2, y: area.y, @@ -395,6 +402,22 @@ fn draw_pane_frame( height: 1, }, ); + let rule_x = area.x + 2 + used + 1; + if rule_x < area.x + area.width { + let rule_width = area.x + area.width - rule_x; + f.render_widget( + Paragraph::new(Span::styled( + "─".repeat(rule_width as usize), + Style::default().fg(t.chrome), + )), + Rect { + x: rule_x, + y: area.y, + width: rule_width, + height: 1, + }, + ); + } } pane_content_area(area) } @@ -3901,7 +3924,13 @@ mod tests { assert_eq!(text[0], "", "no sessions: the strip is blank"); assert_eq!(text[1], "─".repeat(60)); - assert_eq!(text[2], " ▎ ● linkshell", "rail (empty) then the pane"); + // The title row doubles as the pane's top edge, so a rule fills the + // rest of it. + assert!( + text[2].starts_with(" ▎ ● linkshell ─") && text[2].ends_with('─'), + "rail (empty), the pane title, then the top rule: {:?}", + text[2] + ); assert_eq!(text[3], " ▎ No sessions. Press alt-n to create one."); // The pane runs to the footer: no bottom border, and in the default // overlay mode the status panel claims no rows either. @@ -4111,7 +4140,11 @@ mod tests { let mut app = app_with_sessions(&["alpha"]); app.sessions[0].cwd = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()) + "/src"; let rows = render_rows(&app, 60, 15); - assert_eq!(rows[2].0, "●1 ▎ ● alpha · ~/src", "rail, then the pane"); + assert!( + rows[2].0.starts_with("●1 ▎ ● alpha · ~/src ─"), + "rail, the pane title, then the top rule: {:?}", + rows[2].0 + ); } // ── Status panel ──────────────────────────────────────────────────────