Shelve the per-pane context indicator (keep narrow-toolbar fix)
Reliable per-pane context tracking isn't achievable from transcripts: we can't distinguish 'claude is live in this pane' from 'a shell sitting in a directory that recently had a claude session' (claude renders inline, not alt-screen; no WSL foreground-process access), and the 200k-vs-1M window isn't recorded so % is unreliable. Removed the context indicator, its OSC 7 cwd injection (pty.rs), the get_pane_context backend (usage.rs), src/lib/usage.ts, the orchestration paneContext map, and the App poll. The narrow-pane toolbar reflow (leaf--narrow/xnarrow tiers, label shrink, close × pinned) is KEPT — it's verified and independent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
15c2842ce1
commit
00a1e24ecf
10 changed files with 4 additions and 568 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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/<mangled-cwd>/<sessionId>.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\<distro>\…` 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<HashMap<PathBuf, CachedFile>>,
|
||||
/// distro -> resolved `$HOME` (one wsl.exe probe per distro per process).
|
||||
homes: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
cache: tauri::State<'_, UsageCache>,
|
||||
) -> Result<Vec<SessionContext>, String> {
|
||||
if !cfg!(windows) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let cache = cache.inner();
|
||||
let mut out: Vec<SessionContext> = 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<Vec<SessionContext>, 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<String, String> {
|
||||
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<PathBuf> {
|
||||
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<i64> {
|
||||
t.duration_since(UNIX_EPOCH).ok().map(|d| d.as_millis() as i64)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue