diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5279e1c..3a88bac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,7 +6,6 @@ mod hosts; mod mcp; mod mcp_policy; mod pty; -mod usage; mod window_state; use std::sync::Arc; @@ -67,7 +66,6 @@ pub fn run() { .manage(pending_actions) .manage(windows_state) .manage(pending_inits) - .manage(usage::UsageCache::default()) .on_window_event(move |window, event| { // When a non-main window closes, drop its workspaces from the // aggregator AND any unconsumed pending-init payload so neither @@ -110,7 +108,6 @@ pub fn run() { commands::mcp_policy_load, commands::mcp_policy_save, commands::mcp_hard_deny_labels, - usage::get_pane_context, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 13cb343..c404fdf 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -354,23 +354,6 @@ fn build_command(spec: &SpawnSpec) -> Result<(CommandBuilder, &'static str)> { let resolved_cwd = cwd.as_deref().unwrap_or("~"); c.arg("--cd"); c.arg(resolved_cwd); - // Make the shell report its working directory via OSC 7 on every - // prompt, so the frontend can map this pane to the claude session - // running in it (the context-fill indicator; see usage.rs + - // LeafPane). We set PROMPT_COMMAND in the environment and forward it - // through WSLENV — default Ubuntu bash inherits an env-provided - // PROMPT_COMMAND. A user shell that hard-assigns PROMPT_COMMAND (or - // a non-bash login shell) simply won't report, and the indicator - // stays hidden for that pane — no breakage either way. - c.env( - "PROMPT_COMMAND", - r#"printf '\033]7;file://%s%s\033\\' "$HOSTNAME" "$PWD""#, - ); - let wslenv = match std::env::var("WSLENV") { - Ok(v) if !v.is_empty() => format!("{v}:PROMPT_COMMAND/u"), - _ => "PROMPT_COMMAND/u".to_string(), - }; - c.env("WSLENV", wslenv); Ok((c, "failed to spawn wsl.exe; is WSL installed?")) } SpawnSpec::Powershell => { @@ -512,7 +495,7 @@ fn looks_like_password_prompt(buf: &[u8]) -> bool { // ---- distro enumeration ----------------------------------------------------- /// Run a process without flashing a console window on Windows. -pub(crate) fn quiet_command(program: &str) -> std::process::Command { +fn quiet_command(program: &str) -> std::process::Command { let mut c = std::process::Command::new(program); #[cfg(windows)] { diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs deleted file mode 100644 index e718dc8..0000000 --- a/src-tauri/src/usage.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! Reads claude-code session transcripts to report each session's **current -//! context-window occupancy** for the per-pane context indicator. -//! -//! claude writes one JSONL transcript per session at -//! `~/.claude/projects//.jsonl`. Every assistant line -//! carries `cwd`, `message.model`, and `message.usage`. The size of the prompt -//! sent on the most recent turn — `input_tokens + cache_read_input_tokens + -//! cache_creation_input_tokens` of the LAST assistant line — is a good proxy -//! for "how full is this session's context window right now". -//! -//! Windows-only: the transcripts live inside each WSL distro, reached via the -//! `\\wsl.localhost\\…` 9p share. Returns empty on non-Windows. - -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use parking_lot::Mutex; -use serde::Serialize; - -use crate::pty::quiet_command; - -/// Ignore sessions older than this, and cap the number returned — bounds the -/// scan cost on machines with a large transcript history. -const MAX_AGE_MS: i64 = 30 * 24 * 60 * 60 * 1000; -const MAX_SESSIONS: usize = 50; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionContext { - pub session_id: String, - pub cwd: String, - pub distro: String, - pub last_active_ms: i64, - /// Prompt size of the last assistant turn (input + both cache buckets) — - /// the current context-window occupancy. - pub context_tokens: u64, - pub model: String, -} - -/// Parsed-file cache entry, validated by (size, mtime) so we only re-parse the -/// one transcript that actually grew between polls. -struct CachedFile { - size: u64, - mtime_ms: i64, - cwd: String, - context_tokens: u64, - model: String, -} - -#[derive(Default)] -pub struct UsageCache { - files: Mutex>, - /// distro -> resolved `$HOME` (one wsl.exe probe per distro per process). - homes: Mutex>, -} - -/// Read each recent session's current context occupancy across the given WSL -/// distros (the distinct distros of currently-open WSL panes). Newest first, -/// capped to MAX_SESSIONS. -#[tauri::command] -pub async fn get_pane_context( - distros: Vec, - cache: tauri::State<'_, UsageCache>, -) -> Result, String> { - if !cfg!(windows) { - return Ok(Vec::new()); - } - let cache = cache.inner(); - let mut out: Vec = Vec::new(); - let mut seen = HashSet::new(); - for distro in distros.into_iter().filter(|d| !d.is_empty() && seen.insert(d.clone())) { - match collect_distro(&distro, cache) { - Ok(mut v) => out.append(&mut v), - Err(e) => tracing::warn!("context scan for distro {distro} failed: {e}"), - } - } - out.sort_by(|a, b| b.last_active_ms.cmp(&a.last_active_ms)); - out.truncate(MAX_SESSIONS); - Ok(out) -} - -fn collect_distro(distro: &str, cache: &UsageCache) -> Result, String> { - let home = resolve_home(distro, cache)?; - let projects = projects_dir(distro, &home) - .ok_or_else(|| format!("no ~/.claude/projects reachable for {distro}"))?; - - // Gather candidate transcripts (path, mtime), newest first. - let now = now_ms(); - let mut candidates: Vec<(PathBuf, i64)> = Vec::new(); - for proj in std::fs::read_dir(&projects).map_err(|e| e.to_string())?.flatten() { - let proj_path = proj.path(); - if !proj_path.is_dir() { - continue; - } - let inner = match std::fs::read_dir(&proj_path) { - Ok(it) => it, - Err(_) => continue, - }; - for f in inner.flatten() { - let p = f.path(); - if p.extension().and_then(|e| e.to_str()) != Some("jsonl") { - continue; - } - let mtime = std::fs::metadata(&p) - .ok() - .and_then(|m| m.modified().ok()) - .and_then(sys_to_ms) - .unwrap_or(0); - if now - mtime > MAX_AGE_MS { - continue; - } - candidates.push((p, mtime)); - } - } - candidates.sort_by(|a, b| b.1.cmp(&a.1)); - candidates.truncate(MAX_SESSIONS); - - let mut out = Vec::new(); - for (path, mtime) in candidates { - let (cwd, context_tokens, model) = match parse_or_cache(&path, cache) { - Ok(v) => v, - Err(e) => { - tracing::debug!("skip transcript {}: {e}", path.display()); - continue; - } - }; - let session_id = path - .file_stem() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(); - out.push(SessionContext { - session_id, - cwd, - distro: distro.to_string(), - last_active_ms: mtime, - context_tokens, - model, - }); - } - Ok(out) -} - -/// Probe `$HOME` inside the distro once and cache it. `sh -c` (not a login -/// shell) so rc-file output can't contaminate stdout. -fn resolve_home(distro: &str, cache: &UsageCache) -> Result { - if let Some(h) = cache.homes.lock().get(distro) { - return Ok(h.clone()); - } - let out = quiet_command("wsl.exe") - .args(["-d", distro, "--", "sh", "-c", "printf %s \"$HOME\""]) - .output() - .map_err(|e| format!("wsl.exe $HOME probe: {e}"))?; - if !out.status.success() { - return Err(format!("wsl.exe $HOME probe exited {:?}", out.status.code())); - } - let home = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if home.is_empty() { - return Err("empty $HOME".into()); - } - cache.homes.lock().insert(distro.to_string(), home.clone()); - Ok(home) -} - -/// `~/.claude/projects` as a Windows UNC path into the distro. Tries the newer -/// `\\wsl.localhost\` share first, then the legacy `\\wsl$\` alias. -fn projects_dir(distro: &str, home: &str) -> Option { - let home_rel = home.trim_start_matches('/'); - for prefix in [ - format!(r"\\wsl.localhost\{distro}"), - format!(r"\\wsl$\{distro}"), - ] { - let p = PathBuf::from(prefix) - .join(home_rel) - .join(".claude") - .join("projects"); - if p.is_dir() { - return Some(p); - } - } - None -} - -fn parse_or_cache( - path: &Path, - cache: &UsageCache, -) -> Result<(String, u64, String), String> { - let meta = std::fs::metadata(path).map_err(|e| e.to_string())?; - let size = meta.len(); - let mtime = meta.modified().ok().and_then(sys_to_ms).unwrap_or(0); - if let Some(c) = cache.files.lock().get(path) { - if c.size == size && c.mtime_ms == mtime { - return Ok((c.cwd.clone(), c.context_tokens, c.model.clone())); - } - } - let (cwd, context_tokens, model) = parse_file(path)?; - cache.files.lock().insert( - path.to_path_buf(), - CachedFile { - size, - mtime_ms: mtime, - cwd: cwd.clone(), - context_tokens, - model: model.clone(), - }, - ); - Ok((cwd, context_tokens, model)) -} - -/// Returns (cwd, context_tokens, model) where context_tokens is the prompt size -/// of the LAST assistant turn — the current context-window occupancy. -fn parse_file(path: &Path) -> Result<(String, u64, String), String> { - let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?; - let mut cwd = String::new(); - let mut context_tokens = 0u64; - let mut model = String::new(); - - for line in content.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - let v: serde_json::Value = match serde_json::from_str(line) { - Ok(v) => v, - Err(_) => continue, // tolerate a truncated final line / stray text - }; - if cwd.is_empty() { - if let Some(c) = v.get("cwd").and_then(|x| x.as_str()) { - cwd = c.to_string(); - } - } - if v.get("type").and_then(|x| x.as_str()) != Some("assistant") { - continue; - } - let msg = match v.get("message") { - Some(m) => m, - None => continue, - }; - let usage = match msg.get("usage") { - Some(u) => u, - None => continue, - }; - let tok = |k: &str| usage.get(k).and_then(|x| x.as_u64()).unwrap_or(0); - // Overwrite each turn so we end up with the LAST assistant line's values. - context_tokens = tok("input_tokens") - + tok("cache_read_input_tokens") - + tok("cache_creation_input_tokens"); - model = msg - .get("model") - .and_then(|x| x.as_str()) - .unwrap_or("unknown") - .to_string(); - } - - Ok((cwd, context_tokens, model)) -} - -fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -fn sys_to_ms(t: SystemTime) -> Option { - t.duration_since(UNIX_EPOCH).ok().map(|d| d.as_millis() as i64) -} diff --git a/src/App.tsx b/src/App.tsx index 5720e3e..a6b22d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,10 +23,8 @@ import { createPaneWindow, takePendingWindowInit, pushWindowWorkspaces, - getPaneContext, type PaneId, type SpawnSpec, - type SessionContext, type SshHost, type McpStatus, type McpMirror, @@ -241,7 +239,6 @@ export default function App() { token: null, }); const [mcpPanelOpen, setMcpPanelOpen] = useState(false); - const [contextSessions, setContextSessions] = useState([]); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); const [paletteOpen, setPaletteOpen] = useState(false); @@ -753,66 +750,6 @@ export default function App() { const openHostManager = useCallback(() => setHostManagerOpen(true), []); const closeHostManager = useCallback(() => setHostManagerOpen(false), []); - // ---- claude context tracking -------------------------------------------- - // Reads each recent session's current context occupancy from ~/.claude - // transcripts (backend), for the per-pane context-fill indicator. The fetch - // guard collapses overlapping ticks. - const contextFetchingRef = useRef(false); - const refreshContext = useCallback(async () => { - if (contextFetchingRef.current) return; - const distros = new Set(); - for (const leaf of walkLeaves(treeRef.current)) { - if (leaf.shellKind === "wsl" && leaf.distro) distros.add(leaf.distro); - } - if (distros.size === 0) { - setContextSessions([]); - return; - } - contextFetchingRef.current = true; - try { - const sessions = await getPaneContext(Array.from(distros)); - // TEMP diagnostic — remove once the context bar is confirmed working. - console.log( - "[ctx] distros", - [...distros], - "→", - sessions.length, - "sessions:", - sessions.map( - (s) => - `${s.cwd} | ${s.contextTokens}tok | ${Math.round((Date.now() - s.lastActiveMs) / 1000)}s ago`, - ), - ); - setContextSessions(sessions); - } catch (e) { - console.warn("getPaneContext failed:", e); - } finally { - contextFetchingRef.current = false; - } - }, []); - - // Poll on a light interval, gated on visibility so a hidden/minimized window - // stays quiet. - useEffect(() => { - const tick = () => { - if (document.visibilityState === "visible") void refreshContext(); - }; - tick(); - const id = window.setInterval(tick, 15000); - return () => clearInterval(id); - }, [refreshContext]); - - // cwd -> newest session's context, consumed by each LeafPane via orchestration. - const paneContext = useMemo(() => { - const m = new Map(); - for (const s of contextSessions) { - if (!s.cwd) continue; - const prev = m.get(s.cwd); - if (!prev || s.lastActiveMs > prev.lastActiveMs) m.set(s.cwd, s); - } - return m; - }, [contextSessions]); - // Outside-click dismissal for the titlebar dropdowns. Mirrors the // per-pane shell-picker pattern in LeafPane.tsx. useEffect(() => { @@ -1348,7 +1285,6 @@ export default function App() { reportLeafIdle, moveToNewWindow, getInitialPaneIdFor, - paneContext, }), [ activeLeafId, @@ -1374,7 +1310,6 @@ export default function App() { reportLeafIdle, moveToNewWindow, getInitialPaneIdFor, - paneContext, ], ); diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index a2d3e07..a993c11 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -82,10 +82,6 @@ interface XtermPaneProps { * Defined as an optional callback so single-pane windows don't require * wiring it up. */ onNavigate?: (intent: NavigateIntent) => void; - /** Fired with the shell's reported working directory (from an OSC 7 escape, - * which WSL panes emit via an injected PROMPT_COMMAND — see pty.rs). Used to - * map the pane to the claude session running in it. */ - onCwd?: (cwd: string) => void; } const DEFAULT_XTERM_FONT_SIZE = 13; @@ -105,7 +101,6 @@ export default function XtermPane({ focusTrigger = 0, fontSize, onNavigate, - onCwd, }: XtermPaneProps) { const containerRef = useRef(null); const termRef = useRef(null); @@ -126,7 +121,6 @@ export default function XtermPane({ const onDataReceivedRef = useRef(onDataReceived); const onFocusRef = useRef(onFocus); const onNavigateRef = useRef(onNavigate); - const onCwdRef = useRef(onCwd); // Stable ref for setSearchOpen so it can be called from inside the // attachCustomKeyEventHandler closure without the closure going stale. const setSearchOpenRef = useRef<(v: boolean) => void>(setSearchOpen); @@ -137,7 +131,6 @@ export default function XtermPane({ useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]); useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]); useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]); - useEffect(() => { onCwdRef.current = onCwd; }, [onCwd]); useEffect(() => { setSearchOpenRef.current = setSearchOpen; }, [setSearchOpen]); // ------------------------------------------------------------------------- @@ -211,29 +204,6 @@ export default function XtermPane({ searchAddonRef.current = search; term.loadAddon(search); - // OSC 7 (cwd reporting): WSL panes emit `\e]7;file://\e\\` on - // every prompt (via the PROMPT_COMMAND we inject at spawn). Capture the - // path and report it up so the pane can be matched to its claude session. - // Registered before data flows so the first prompt's cwd is caught. - term.parser.registerOscHandler(7, (data) => { - const m = /^file:\/\/[^/]*(\/.*)$/.exec(data); - if (m) { - let path = m[1]; - try { - path = decodeURIComponent(path); - } catch { - /* leave raw if it isn't valid percent-encoding */ - } - // Defer out of term.write()'s synchronous path: OSC handlers run while - // xterm processes PTY data, which can coincide with React's render - // phase — calling the parent's setState directly there triggers a - // "cannot update while rendering" warning and the update gets dropped. - const reported = path; - queueMicrotask(() => onCwdRef.current?.(reported)); - } - return true; - }); - // Initial size — fit before asking the PTY for its dimensions. fit.fit(); diff --git a/src/ipc.ts b/src/ipc.ts index 5eb0829..6660ed8 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -39,26 +39,6 @@ export interface SshHost { export const listDistros = (): Promise => invoke("list_distros"); -// ---- claude context tracking ---------------------------------------------- - -/** One claude session's current context-window occupancy, read from its - * transcript. Mirrors Rust SessionContext. `contextTokens` is the prompt - * size of the last assistant turn (input + both cache buckets). */ -export interface SessionContext { - sessionId: string; - cwd: string; - distro: string; - lastActiveMs: number; - contextTokens: number; - model: string; -} - -/** Scan ~/.claude/projects in the given WSL distros (distinct distros of open - * WSL panes) and return each recent session's current context occupancy. - * WSL/Windows only — returns [] otherwise. */ -export const getPaneContext = (distros: string[]): Promise => - invoke("get_pane_context", { distros }); - export const spawnPane = (args: { spec: SpawnSpec; cols: number; diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index bccac05..521c69d 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -272,40 +272,9 @@ color: #fcc; } -/* ---- per-pane context-fill indicator ----------------------------------- */ -.pane-ctx { - /* Fallback right-anchor: when .pane-status is hidden (narrow tiers) its - margin-left:auto is gone, so carry it here too. First auto in DOM order - (status → ctx → actions) consumes the free space; the rest no-op. */ - margin-left: auto; - display: flex; - align-items: center; - gap: 5px; - font-size: 10px; - color: #9aa0a6; -} -.pane-ctx-bar { - width: 42px; - height: 6px; - background: #2a2a2a; - border-radius: 3px; - overflow: hidden; -} -.pane-ctx-fill { - display: block; - height: 100%; - border-radius: 3px; - transition: width 0.3s, background 0.3s; -} -.pane-ctx-pct { - font-variant-numeric: tabular-nums; - min-width: 26px; - text-align: right; -} - /* ---- narrow-pane reflow ------------------------------------------------- - The close button + context indicator stay visible at every width; lower- - priority toolbar items drop out by tier so a 180px pane keeps its close ×. */ + The close button stays visible at every width; lower-priority toolbar items + drop out by tier so a 180px pane keeps its close ×. */ .leaf--narrow .pane-status, .leaf--narrow .pane-actions .pane-btn:not(.close) { display: none; @@ -316,10 +285,6 @@ .leaf--xnarrow .bcast-chip { display: none; } -/* Keep just the % (drop the bar) at the tightest width. */ -.leaf--xnarrow .pane-ctx-bar { - display: none; -} .xterm-wrap { flex: 1 1 auto; min-height: 0; diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index be9aa71..e087694 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -10,27 +10,12 @@ import { import { createPortal } from "react-dom"; import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; import { useOrchestration } from "./orchestration"; -import { - contextLabel, - contextColor, - contextFraction, - formatTokens, -} from "../../lib/usage"; import XtermPane from "../../components/XtermPane"; import type { SpawnSpec } from "../../ipc"; import "./LeafPane.css"; const IDLE_THRESHOLD_MS = 5000; -/** Only show the context indicator when the pane's directory has a claude - * session that was active within this window — generous, because a live - * session you're actively working in can sit idle for a long while (reading, - * thinking, away). It only suppresses genuinely dormant directories (old - * projects). NOTE: this can't tell "claude is live in this pane" from "a - * shell sitting in a directory that recently had a claude session" — that - * needs a foreground-process probe into WSL (deferred). */ -const CONTEXT_ACTIVE_MS = 3 * 60 * 60 * 1000; - /** How far past a viewport edge the cursor must travel before a release is * treated as "drag pane out of window" instead of "drop on empty space * inside this window". Picked so an accidental release on the OS titlebar @@ -188,36 +173,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { return () => ro.disconnect(); }, []); - // Live cwd reported by the shell via OSC 7 (WSL panes). Used to match this - // pane to the claude session running in it — more reliable than leaf.cwd, - // which is the (often unset) spawn cwd and doesn't follow `cd`. - const [liveCwd, setLiveCwd] = useState(null); - const onPaneCwd = useCallback( - (cwd: string) => - setLiveCwd((cur) => { - if (cur === cwd) return cur; - // TEMP diagnostic — remove once the context bar is confirmed working. - console.log("[ctx] pane reported cwd via OSC7:", cwd); - return cwd; - }), - [], - ); - // TEMP diagnostic — logs the per-pane match decision on change. - useEffect(() => { - if (leaf.shellKind !== "wsl") return; - const c = liveCwd ?? leaf.cwd; - const hit = c ? orch.paneContext.get(c) : undefined; - console.log( - "[ctx] MATCH", - leaf.label ?? leaf.distro, - "cwd=", - c, - hit - ? `→ ${hit.contextTokens}tok ${Math.round((Date.now() - hit.lastActiveMs) / 1000)}s ago` - : "→ no match", - ); - }, [liveCwd, leaf.cwd, leaf.shellKind, leaf.label, leaf.distro, orch.paneContext]); - // ---- broadcast --------------------------------------------------------- const onTerminalInput = useCallback( (b64: string) => { @@ -448,18 +403,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { }; })(); - const matchCwd = liveCwd ?? leaf.cwd; - const matchedCtx = - leaf.shellKind === "wsl" && matchCwd - ? orch.paneContext.get(matchCwd) - : undefined; - // Suppress stale matches: only surface the bar while the session is actively - // being written (a live claude keeps its transcript fresh). - const ctx = - matchedCtx && Date.now() - matchedCtx.lastActiveMs < CONTEXT_ACTIVE_MS - ? matchedCtx - : undefined; - return (
{status} )} - {ctx && ( - - - - - {formatTokens(ctx.contextTokens)} - - )} -