Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
8 changes: 8 additions & 0 deletions docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod ipc;
mod keybindings;
mod layout;
mod notify;
mod omp_log;
mod opencode_log;
mod orchestrator;
mod patterns;
Expand Down
Loading
Loading