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;
|
||||||
mod mcp_policy;
|
mod mcp_policy;
|
||||||
mod pty;
|
mod pty;
|
||||||
mod usage;
|
|
||||||
mod window_state;
|
mod window_state;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -67,7 +66,6 @@ pub fn run() {
|
||||||
.manage(pending_actions)
|
.manage(pending_actions)
|
||||||
.manage(windows_state)
|
.manage(windows_state)
|
||||||
.manage(pending_inits)
|
.manage(pending_inits)
|
||||||
.manage(usage::UsageCache::default())
|
|
||||||
.on_window_event(move |window, event| {
|
.on_window_event(move |window, event| {
|
||||||
// When a non-main window closes, drop its workspaces from the
|
// When a non-main window closes, drop its workspaces from the
|
||||||
// aggregator AND any unconsumed pending-init payload so neither
|
// aggregator AND any unconsumed pending-init payload so neither
|
||||||
|
|
@ -110,7 +108,6 @@ pub fn run() {
|
||||||
commands::mcp_policy_load,
|
commands::mcp_policy_load,
|
||||||
commands::mcp_policy_save,
|
commands::mcp_policy_save,
|
||||||
commands::mcp_hard_deny_labels,
|
commands::mcp_hard_deny_labels,
|
||||||
usage::get_pane_context,
|
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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("~");
|
let resolved_cwd = cwd.as_deref().unwrap_or("~");
|
||||||
c.arg("--cd");
|
c.arg("--cd");
|
||||||
c.arg(resolved_cwd);
|
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?"))
|
Ok((c, "failed to spawn wsl.exe; is WSL installed?"))
|
||||||
}
|
}
|
||||||
SpawnSpec::Powershell => {
|
SpawnSpec::Powershell => {
|
||||||
|
|
@ -512,7 +495,7 @@ fn looks_like_password_prompt(buf: &[u8]) -> bool {
|
||||||
// ---- distro enumeration -----------------------------------------------------
|
// ---- distro enumeration -----------------------------------------------------
|
||||||
|
|
||||||
/// Run a process without flashing a console window on Windows.
|
/// 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);
|
let mut c = std::process::Command::new(program);
|
||||||
#[cfg(windows)]
|
#[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)
|
|
||||||
}
|
|
||||||
65
src/App.tsx
65
src/App.tsx
|
|
@ -23,10 +23,8 @@ import {
|
||||||
createPaneWindow,
|
createPaneWindow,
|
||||||
takePendingWindowInit,
|
takePendingWindowInit,
|
||||||
pushWindowWorkspaces,
|
pushWindowWorkspaces,
|
||||||
getPaneContext,
|
|
||||||
type PaneId,
|
type PaneId,
|
||||||
type SpawnSpec,
|
type SpawnSpec,
|
||||||
type SessionContext,
|
|
||||||
type SshHost,
|
type SshHost,
|
||||||
type McpStatus,
|
type McpStatus,
|
||||||
type McpMirror,
|
type McpMirror,
|
||||||
|
|
@ -241,7 +239,6 @@ export default function App() {
|
||||||
token: null,
|
token: null,
|
||||||
});
|
});
|
||||||
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
|
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
|
||||||
const [contextSessions, setContextSessions] = useState<SessionContext[]>([]);
|
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [notifications, setNotifications] = useState<Toast[]>([]);
|
const [notifications, setNotifications] = useState<Toast[]>([]);
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
@ -753,66 +750,6 @@ export default function App() {
|
||||||
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
|
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
|
||||||
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
|
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<string>();
|
|
||||||
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<string, SessionContext>();
|
|
||||||
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
|
// Outside-click dismissal for the titlebar dropdowns. Mirrors the
|
||||||
// per-pane shell-picker pattern in LeafPane.tsx.
|
// per-pane shell-picker pattern in LeafPane.tsx.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1348,7 +1285,6 @@ export default function App() {
|
||||||
reportLeafIdle,
|
reportLeafIdle,
|
||||||
moveToNewWindow,
|
moveToNewWindow,
|
||||||
getInitialPaneIdFor,
|
getInitialPaneIdFor,
|
||||||
paneContext,
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeLeafId,
|
activeLeafId,
|
||||||
|
|
@ -1374,7 +1310,6 @@ export default function App() {
|
||||||
reportLeafIdle,
|
reportLeafIdle,
|
||||||
moveToNewWindow,
|
moveToNewWindow,
|
||||||
getInitialPaneIdFor,
|
getInitialPaneIdFor,
|
||||||
paneContext,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,10 +82,6 @@ interface XtermPaneProps {
|
||||||
* Defined as an optional callback so single-pane windows don't require
|
* Defined as an optional callback so single-pane windows don't require
|
||||||
* wiring it up. */
|
* wiring it up. */
|
||||||
onNavigate?: (intent: NavigateIntent) => void;
|
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;
|
const DEFAULT_XTERM_FONT_SIZE = 13;
|
||||||
|
|
@ -105,7 +101,6 @@ export default function XtermPane({
|
||||||
focusTrigger = 0,
|
focusTrigger = 0,
|
||||||
fontSize,
|
fontSize,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onCwd,
|
|
||||||
}: XtermPaneProps) {
|
}: XtermPaneProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
|
|
@ -126,7 +121,6 @@ export default function XtermPane({
|
||||||
const onDataReceivedRef = useRef(onDataReceived);
|
const onDataReceivedRef = useRef(onDataReceived);
|
||||||
const onFocusRef = useRef(onFocus);
|
const onFocusRef = useRef(onFocus);
|
||||||
const onNavigateRef = useRef(onNavigate);
|
const onNavigateRef = useRef(onNavigate);
|
||||||
const onCwdRef = useRef(onCwd);
|
|
||||||
// Stable ref for setSearchOpen so it can be called from inside the
|
// Stable ref for setSearchOpen so it can be called from inside the
|
||||||
// attachCustomKeyEventHandler closure without the closure going stale.
|
// attachCustomKeyEventHandler closure without the closure going stale.
|
||||||
const setSearchOpenRef = useRef<(v: boolean) => void>(setSearchOpen);
|
const setSearchOpenRef = useRef<(v: boolean) => void>(setSearchOpen);
|
||||||
|
|
@ -137,7 +131,6 @@ export default function XtermPane({
|
||||||
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
|
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
|
||||||
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
|
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
|
||||||
useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]);
|
useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]);
|
||||||
useEffect(() => { onCwdRef.current = onCwd; }, [onCwd]);
|
|
||||||
useEffect(() => { setSearchOpenRef.current = setSearchOpen; }, [setSearchOpen]);
|
useEffect(() => { setSearchOpenRef.current = setSearchOpen; }, [setSearchOpen]);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -211,29 +204,6 @@ export default function XtermPane({
|
||||||
searchAddonRef.current = search;
|
searchAddonRef.current = search;
|
||||||
term.loadAddon(search);
|
term.loadAddon(search);
|
||||||
|
|
||||||
// OSC 7 (cwd reporting): WSL panes emit `\e]7;file://<host><path>\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.
|
// Initial size — fit before asking the PTY for its dimensions.
|
||||||
fit.fit();
|
fit.fit();
|
||||||
|
|
||||||
|
|
|
||||||
20
src/ipc.ts
20
src/ipc.ts
|
|
@ -39,26 +39,6 @@ export interface SshHost {
|
||||||
|
|
||||||
export const listDistros = (): Promise<string[]> => invoke("list_distros");
|
export const listDistros = (): Promise<string[]> => 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<SessionContext[]> =>
|
|
||||||
invoke("get_pane_context", { distros });
|
|
||||||
|
|
||||||
export const spawnPane = (args: {
|
export const spawnPane = (args: {
|
||||||
spec: SpawnSpec;
|
spec: SpawnSpec;
|
||||||
cols: number;
|
cols: number;
|
||||||
|
|
|
||||||
|
|
@ -272,40 +272,9 @@
|
||||||
color: #fcc;
|
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 -------------------------------------------------
|
/* ---- narrow-pane reflow -------------------------------------------------
|
||||||
The close button + context indicator stay visible at every width; lower-
|
The close button stays visible at every width; lower-priority toolbar items
|
||||||
priority toolbar items drop out by tier so a 180px pane keeps its close ×. */
|
drop out by tier so a 180px pane keeps its close ×. */
|
||||||
.leaf--narrow .pane-status,
|
.leaf--narrow .pane-status,
|
||||||
.leaf--narrow .pane-actions .pane-btn:not(.close) {
|
.leaf--narrow .pane-actions .pane-btn:not(.close) {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -316,10 +285,6 @@
|
||||||
.leaf--xnarrow .bcast-chip {
|
.leaf--xnarrow .bcast-chip {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
/* Keep just the % (drop the bar) at the tightest width. */
|
|
||||||
.leaf--xnarrow .pane-ctx-bar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.xterm-wrap {
|
.xterm-wrap {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
|
||||||
|
|
@ -10,27 +10,12 @@ import {
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
|
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
|
||||||
import { useOrchestration } from "./orchestration";
|
import { useOrchestration } from "./orchestration";
|
||||||
import {
|
|
||||||
contextLabel,
|
|
||||||
contextColor,
|
|
||||||
contextFraction,
|
|
||||||
formatTokens,
|
|
||||||
} from "../../lib/usage";
|
|
||||||
import XtermPane from "../../components/XtermPane";
|
import XtermPane from "../../components/XtermPane";
|
||||||
import type { SpawnSpec } from "../../ipc";
|
import type { SpawnSpec } from "../../ipc";
|
||||||
import "./LeafPane.css";
|
import "./LeafPane.css";
|
||||||
|
|
||||||
const IDLE_THRESHOLD_MS = 5000;
|
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
|
/** 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
|
* 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
|
* 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();
|
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<string | null>(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 ---------------------------------------------------------
|
// ---- broadcast ---------------------------------------------------------
|
||||||
const onTerminalInput = useCallback(
|
const onTerminalInput = useCallback(
|
||||||
(b64: string) => {
|
(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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
|
|
@ -612,24 +555,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
|
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ctx && (
|
|
||||||
<span
|
|
||||||
className="pane-ctx"
|
|
||||||
title={`Context: ${contextLabel(ctx)} (${ctx.model})`}
|
|
||||||
>
|
|
||||||
<span className="pane-ctx-bar">
|
|
||||||
<span
|
|
||||||
className="pane-ctx-fill"
|
|
||||||
style={{
|
|
||||||
width: `${Math.round(contextFraction(ctx) * 100)}%`,
|
|
||||||
background: contextColor(contextFraction(ctx)),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="pane-ctx-pct">{formatTokens(ctx.contextTokens)}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="pane-actions">
|
<span className="pane-actions">
|
||||||
<button
|
<button
|
||||||
className="pane-btn"
|
className="pane-btn"
|
||||||
|
|
@ -677,7 +602,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
onDataReceived={onDataReceived}
|
onDataReceived={onDataReceived}
|
||||||
onFocus={onXtermFocus}
|
onFocus={onXtermFocus}
|
||||||
onNavigate={onPaneNavigate}
|
onNavigate={onPaneNavigate}
|
||||||
onCwd={onPaneCwd}
|
|
||||||
focusTrigger={focusTrigger}
|
focusTrigger={focusTrigger}
|
||||||
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createContext, useContext, type ReactNode } from "react";
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree";
|
import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree";
|
||||||
import type { PaneId, SshHost, SessionContext } from "../../ipc";
|
import type { PaneId, SshHost } from "../../ipc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestration context — every piece of shared state and every operation
|
* Orchestration context — every piece of shared state and every operation
|
||||||
|
|
@ -77,10 +77,6 @@ export interface Orchestration {
|
||||||
* the spawn). One-shot — App clears the entry once the pane has
|
* the spawn). One-shot — App clears the entry once the pane has
|
||||||
* registered. */
|
* registered. */
|
||||||
getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined;
|
getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined;
|
||||||
/** cwd -> the newest claude session's current context occupancy, for the
|
|
||||||
* per-pane context-fill indicator. A leaf looks itself up by `leaf.cwd`;
|
|
||||||
* absent for non-claude / unmatched panes. Polled by App. */
|
|
||||||
paneContext: Map<string, SessionContext>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Discriminated intent emitted by XtermPane's key handler. App resolves
|
/** Discriminated intent emitted by XtermPane's key handler. App resolves
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
// Helpers for the per-pane context-fill indicator. Context occupancy (token
|
|
||||||
// count) comes from the backend (src-tauri/src/usage.rs, get_pane_context); this
|
|
||||||
// turns it into a window %, a colour, and a human label.
|
|
||||||
|
|
||||||
import type { SessionContext } from "../ipc";
|
|
||||||
|
|
||||||
const WINDOW_LARGE = 1_000_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assumed context window. The transcript does NOT record whether a session
|
|
||||||
* runs the 200k or 1M window (the model id is bare, e.g. `claude-opus-4-7` —
|
|
||||||
* the `[1m]` that claude's /context shows is display-only), so the % can't be
|
|
||||||
* computed reliably. We assume 1M (the common case here) for the fill bar, and
|
|
||||||
* the indicator LABEL shows the absolute token count, which is accurate
|
|
||||||
* regardless of the real window — that's the figure to trust.
|
|
||||||
*/
|
|
||||||
export function contextWindow(_contextTokens: number): number {
|
|
||||||
return WINDOW_LARGE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fraction (0..1) of the inferred window currently occupied. */
|
|
||||||
export function contextFraction(s: SessionContext): number {
|
|
||||||
const w = contextWindow(s.contextTokens);
|
|
||||||
return w > 0 ? Math.min(1, s.contextTokens / w) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contextPercent(s: SessionContext): number {
|
|
||||||
return Math.round(contextFraction(s) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Green → amber → red ramp as the window fills. */
|
|
||||||
export function contextColor(fraction: number): string {
|
|
||||||
if (fraction >= 0.85) return "#d65a5a";
|
|
||||||
if (fraction >= 0.6) return "#d6a23a";
|
|
||||||
return "#5aa84a";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTokens(n: number): string {
|
|
||||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
||||||
if (n >= 1_000) return Math.round(n / 1_000) + "k";
|
|
||||||
return String(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** e.g. "274k tokens · ~27% of 1M (last turn)" for a tooltip. */
|
|
||||||
export function contextLabel(s: SessionContext): string {
|
|
||||||
return `${formatTokens(s.contextTokens)} tokens · ~${contextPercent(s)}% of 1M (last turn)`;
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue