diff --git a/README.md b/README.md index 23ec0cb..aecdca1 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,12 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | `Enter / Shift+Enter` | Next / previous match (while search bar is focused) | | `Escape` | Close find bar and return focus to terminal | +**Panels** + +| Key | Action | +|---|---| +| `Ctrl+Shift+U` | Open the usage panel β€” per-session claude token counts + estimated cost for the open WSL panes | + **Help** | Key | Action | diff --git a/memory.md b/memory.md index a2d53d2..92d7b3e 100644 --- a/memory.md +++ b/memory.md @@ -57,8 +57,7 @@ Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecos **β†’ Exploring first (user-selected 2026-05-28):** - [x] ~~**Per-session cost / token tracking.**~~ Done (code) 2026-05-28 β€” **WSL-only v1, pending Windows runtime verify.** Backend `src-tauri/src/usage.rs` (`get_claude_usage(distros)` command): probes `$HOME` per distro via `wsl.exe`, reads `~/.claude/projects/*/*.jsonl` over the `\\wsl.localhost\` UNC share, tallies `message.usage` **per model per assistant line** (sessions can switch models). Cached by `(path,size,mtime)`; recency-capped 30d/50 sessions. Frontend: `src/lib/usage.ts` holds the editable pricing table (per-MTok, matched by opus/sonnet/haiku substring) + cost/format helpers; `UsagePanel.tsx` (MCP-panel modal pattern) lists sessions, highlights those whose transcript `cwd` matches an open pane (`[pane: label]`); titlebar πŸ’° total chip; App polls 20s (visible) / 5s (panel open); **Ctrl+Shift+U** opens it. **Design choice:** session-list attribution (not 1:1 pane binding) β€” avoids the unsolvable "2 claudes in one cwd" ambiguity. **Caveats:** cost is an estimate (cache-creation priced at 5m rate; rates hardcoded, may drift); panes with no explicit cwd (`~`) won't highlight; PowerShell/SSH show nothing. Plan: `~/.claude/plans/greedy-cooking-flask.md`. - - **PIVOTED 2026-05-28 β†’ per-pane context-fill indicator (replaces the panel).** User decided lifetime token totals + $ aren't worth it on a subscription; what's actionable is *current context-window occupancy* per pane (spot the one needing `/compact`). Removed `UsagePanel`, the πŸ’° titlebar chip, and `Ctrl+Shift+U`. Repurposed `usage.rs`: `get_pane_context` returns each recent session's **current** occupancy = the LAST assistant turn's `input + cache_read + cache_creation` tokens (verified ~274k on this 1M session). `src/lib/usage.ts` now does window inference (200k vs 1M by whether occupancy already exceeds 200k β€” model id doesn't encode the variant), %, color ramp. App polls 15s (visibility-gated) β†’ `cwdβ†’SessionContext` map via orchestration; `LeafPane` renders a slim fill bar + % in the header, matched by `leaf.cwd`. **Also fixed narrow-pane toolbar** (user report: close Γ— clipped when slim): a `ResizeObserver` in LeafPane sets `leaf--narrow`/`leaf--xnarrow` tiers; label shrinks first, split/status/secondary chips drop by tier, close Γ— + context indicator stay pinned-right + visible down to the 180px min. Plan: `~/.claude/plans/greedy-cooking-flask.md` (rewritten for the pivot). **Pending Windows runtime verify.** Window-size 200k/1M is inferred (approx near boundary); `~`-spawned / cd'd panes may not match their session. - - **Superseded β€” original lifetime-token panel refinements (kept for history):** (1) **Scope** β€” panel + titlebar chip now default to sessions matching open panes ("this workspace"), with an "open panes / all recent" toggle. The first cut summed *every* recent session on the distro (all projects, `/mnt` + home), which read as inflated. **Investigated the "double counting mounted folders + projects" report: NOT a real double count** β€” every transcript file is read exactly once, and no two project dirs share a cwd because claude resolves symlinks/mounts to the real path before mangling the project-dir name (e.g. the `~/claude/projects/tiletopia β†’ /mnt/d/dev/tiletopia` symlink yields only `-mnt-d-dev-tiletopia`). The inflation was purely the global scope. (2) **Metric framing** β€” user is on a Pro/Max subscription where $ is meaningless (and `/usage` rate-limit quota can't be derived from transcripts); **tokens are now the headline**, the API-cost estimate is a labeled secondary `~$` kept visible so the user can validate it against real API billing at work. **Open question:** accuracy of the $ estimate vs actual API billing β€” user will check at work. + - **Refined same day after user feedback:** (1) **Scope** β€” panel + titlebar chip now default to sessions matching open panes ("this workspace"), with an "open panes / all recent" toggle. The first cut summed *every* recent session on the distro (all projects, `/mnt` + home), which read as inflated. **Investigated the "double counting mounted folders + projects" report: NOT a real double count** β€” every transcript file is read exactly once, and no two project dirs share a cwd because claude resolves symlinks/mounts to the real path before mangling the project-dir name (e.g. the `~/claude/projects/tiletopia β†’ /mnt/d/dev/tiletopia` symlink yields only `-mnt-d-dev-tiletopia`). The inflation was purely the global scope. (2) **Metric framing** β€” user is on a Pro/Max subscription where $ is meaningless (and `/usage` rate-limit quota can't be derived from transcripts); **tokens are now the headline**, the API-cost estimate is a labeled secondary `~$` kept visible so the user can validate it against real API billing at work. **Open question:** accuracy of the $ estimate vs actual API billing β€” user will check at work. - [ ] **Smart link providers.** `terminal.registerLinkProvider()` to make file paths (`src/foo.ts:12:3`), `localhost:PORT`, and error locations clickable β€” more flexible than the regex-only web-links addon already loaded. Open file in editor / browser. Difficulty: medium. - [x] ~~**Find in scrollback.**~~ Done + **verified on Windows 2026-05-28** β€” `@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight. - [x] ~~**Unicode 11 + grapheme width.**~~ Done + **verified on Windows 2026-05-28** β€” `@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. (Skipped the separate `addon-unicode-graphemes` for now.) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5279e1c..1e0aac9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -110,7 +110,7 @@ pub fn run() { commands::mcp_policy_load, commands::mcp_policy_save, commands::mcp_hard_deny_labels, - usage::get_pane_context, + usage::get_claude_usage, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index e718dc8..e79ab02 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1,15 +1,17 @@ -//! Reads claude-code session transcripts to report each session's **current -//! context-window occupancy** for the per-pane context indicator. +//! Reads claude-code session transcripts and tallies token usage per session +//! for the usage panel. //! //! 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". +//! carries `cwd`, `sessionId`, `message.model`, and `message.usage` +//! (input/output/cache tokens). We read those straight out of the file, so the +//! reported cwd/model are accurate regardless of where the pane was spawned. //! //! Windows-only: the transcripts live inside each WSL distro, reached via the //! `\\wsl.localhost\\…` 9p share. Returns empty on non-Windows. +//! +//! Cost is computed on the frontend (see src/lib/usage.ts) so the rate table is +//! easy to tweak; this module only returns raw per-model token tallies. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -27,15 +29,23 @@ const MAX_SESSIONS: usize = 50; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct SessionContext { +pub struct ModelUsage { + pub model: String, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_creation_tokens: u64, + pub cache_read_tokens: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionUsage { pub session_id: String, pub cwd: String, + pub project_dir: 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, + pub models: Vec, } /// Parsed-file cache entry, validated by (size, mtime) so we only re-parse the @@ -44,8 +54,7 @@ struct CachedFile { size: u64, mtime_ms: i64, cwd: String, - context_tokens: u64, - model: String, + models: Vec, } #[derive(Default)] @@ -55,24 +64,23 @@ pub struct UsageCache { 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. +/// Read + tally claude usage across the given WSL distros (the distinct distros +/// of currently-open WSL panes). Newest sessions first, capped to MAX_SESSIONS. #[tauri::command] -pub async fn get_pane_context( +pub async fn get_claude_usage( distros: Vec, cache: tauri::State<'_, UsageCache>, -) -> Result, String> { +) -> Result, String> { if !cfg!(windows) { return Ok(Vec::new()); } let cache = cache.inner(); - let mut out: Vec = Vec::new(); + 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}"), + Err(e) => tracing::warn!("usage scan for distro {distro} failed: {e}"), } } out.sort_by(|a, b| b.last_active_ms.cmp(&a.last_active_ms)); @@ -80,19 +88,20 @@ pub async fn get_pane_context( Ok(out) } -fn collect_distro(distro: &str, cache: &UsageCache) -> Result, String> { +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. + // Gather candidate transcripts (path, project-dir name, mtime), newest first. let now = now_ms(); - let mut candidates: Vec<(PathBuf, i64)> = Vec::new(); + let mut candidates: Vec<(PathBuf, String, 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 proj_name = proj.file_name().to_string_lossy().into_owned(); let inner = match std::fs::read_dir(&proj_path) { Ok(it) => it, Err(_) => continue, @@ -110,15 +119,15 @@ fn collect_distro(distro: &str, cache: &UsageCache) -> Result MAX_AGE_MS { continue; } - candidates.push((p, mtime)); + candidates.push((p, proj_name.clone(), mtime)); } } - candidates.sort_by(|a, b| b.1.cmp(&a.1)); + candidates.sort_by(|a, b| b.2.cmp(&a.2)); 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) { + for (path, proj_name, mtime) in candidates { + let (cwd, models) = match parse_or_cache(&path, cache) { Ok(v) => v, Err(e) => { tracing::debug!("skip transcript {}: {e}", path.display()); @@ -129,13 +138,13 @@ fn collect_distro(distro: &str, cache: &UsageCache) -> Result Option { None } -fn parse_or_cache( - path: &Path, - cache: &UsageCache, -) -> Result<(String, u64, String), String> { +fn parse_or_cache(path: &Path, cache: &UsageCache) -> Result<(String, Vec), 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())); + return Ok((c.cwd.clone(), c.models.clone())); } } - let (cwd, context_tokens, model) = parse_file(path)?; + let (cwd, models) = parse_file(path)?; cache.files.lock().insert( path.to_path_buf(), CachedFile { size, mtime_ms: mtime, cwd: cwd.clone(), - context_tokens, - model: model.clone(), + models: models.clone(), }, ); - Ok((cwd, context_tokens, model)) + Ok((cwd, models)) } -/// 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> { +fn parse_file(path: &Path) -> Result<(String, Vec), 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(); + let mut by_model: HashMap = HashMap::new(); for line in content.lines() { let line = line.trim(); @@ -240,19 +242,28 @@ fn parse_file(path: &Path) -> Result<(String, u64, String), String> { 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 + let model = msg .get("model") .and_then(|x| x.as_str()) .unwrap_or("unknown") .to_string(); + let tok = |k: &str| usage.get(k).and_then(|x| x.as_u64()).unwrap_or(0); + let entry = by_model.entry(model.clone()).or_insert_with(|| ModelUsage { + model, + input_tokens: 0, + output_tokens: 0, + cache_creation_tokens: 0, + cache_read_tokens: 0, + }); + entry.input_tokens += tok("input_tokens"); + entry.output_tokens += tok("output_tokens"); + entry.cache_creation_tokens += tok("cache_creation_input_tokens"); + entry.cache_read_tokens += tok("cache_read_input_tokens"); } - Ok((cwd, context_tokens, model)) + let mut models: Vec = by_model.into_values().collect(); + models.sort_by(|a, b| b.output_tokens.cmp(&a.output_tokens)); + Ok((cwd, models)) } fn now_ms() -> i64 { diff --git a/src/App.tsx b/src/App.tsx index 8f214a6..2299eea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,10 +23,10 @@ import { createPaneWindow, takePendingWindowInit, pushWindowWorkspaces, - getPaneContext, + getClaudeUsage, type PaneId, type SpawnSpec, - type SessionContext, + type SessionUsage, type SshHost, type McpStatus, type McpMirror, @@ -108,6 +108,8 @@ import Palette from "./components/Palette"; import HostManager from "./components/HostManager"; import Help from "./components/Help"; import McpPanel from "./components/McpPanel"; +import UsagePanel from "./components/UsagePanel"; +import { totalCost, formatUsd } from "./lib/usage"; import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm"; import TabStrip from "./components/TabStrip"; import "./App.css"; @@ -241,7 +243,9 @@ export default function App() { token: null, }); const [mcpPanelOpen, setMcpPanelOpen] = useState(false); - const [contextSessions, setContextSessions] = useState([]); + const [usagePanelOpen, setUsagePanelOpen] = useState(false); + const [usageSessions, setUsageSessions] = useState([]); + const [usageLoading, setUsageLoading] = useState(false); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); const [paletteOpen, setPaletteOpen] = useState(false); @@ -753,52 +757,59 @@ 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; + // ---- claude usage tracking ---------------------------------------------- + // Reads ~/.claude transcripts in the open WSL panes' distros (backend). The + // fetch guard collapses overlapping calls (the open panel polls every 5s and + // the background heartbeat every 20s both call this). + const usageFetchingRef = useRef(false); + const refreshUsage = useCallback(async () => { + if (usageFetchingRef.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([]); + setUsageSessions([]); return; } - contextFetchingRef.current = true; + usageFetchingRef.current = true; + setUsageLoading(true); try { - setContextSessions(await getPaneContext(Array.from(distros))); + setUsageSessions(await getClaudeUsage(Array.from(distros))); } catch (e) { - console.warn("getPaneContext failed:", e); + console.warn("getClaudeUsage failed:", e); } finally { - contextFetchingRef.current = false; + usageFetchingRef.current = false; + setUsageLoading(false); } }, []); - // Poll on a light interval, gated on visibility so a hidden/minimized window - // stays quiet. + // Background heartbeat so the titlebar total stays roughly current without + // the panel open. Gated on visibility so a hidden/minimized window stays quiet. useEffect(() => { const tick = () => { - if (document.visibilityState === "visible") void refreshContext(); + if (document.visibilityState === "visible") void refreshUsage(); }; tick(); - const id = window.setInterval(tick, 15000); + const id = window.setInterval(tick, 20000); return () => clearInterval(id); - }, [refreshContext]); + }, [refreshUsage]); - // 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]); + // cwd + label of open WSL panes, for highlighting matching sessions. + const openPanes = useMemo( + () => + Array.from(walkLeaves(tree)) + .filter((l) => l.shellKind === "wsl") + .map((l) => ({ cwd: l.cwd ?? "", label: l.label ?? l.distro ?? "pane" })), + [tree], + ); + + // Titlebar chip total β€” scoped to the open panes ("this workspace"), matching + // the usage panel's default view, so it isn't inflated by unrelated projects. + const workspaceUsageTotal = useMemo(() => { + const cwds = new Set(openPanes.map((p) => p.cwd).filter(Boolean)); + return totalCost(usageSessions.filter((s) => cwds.has(s.cwd))); + }, [openPanes, usageSessions]); // Outside-click dismissal for the titlebar dropdowns. Mirrors the // per-pane shell-picker pattern in LeafPane.tsx. @@ -902,6 +913,13 @@ export default function App() { return; } + // Ctrl+Shift+U β€” usage panel + if (ctrl && shift && !alt && key === "u") { + e.preventDefault(); + e.stopPropagation(); + setUsagePanelOpen((v) => !v); + return; + } // Ctrl+Shift+Alt+B β€” global broadcast all/none if (ctrl && shift && alt && key === "b") { @@ -1335,7 +1353,6 @@ export default function App() { reportLeafIdle, moveToNewWindow, getInitialPaneIdFor, - paneContext, }), [ activeLeafId, @@ -1361,7 +1378,6 @@ export default function App() { reportLeafIdle, moveToNewWindow, getInitialPaneIdFor, - paneContext, ], ); @@ -2138,6 +2154,14 @@ export default function App() { > πŸ€– + + + {formatTokens(shown.reduce((a, s) => a + sessionTokens(s), 0))} tok + + {" Β· ~"} + {formatUsd(total)} + + + + + + +
+ {shown.length === 0 ? ( +

+ {loading + ? "Reading transcripts…" + : sessions.length > 0 && !showAll + ? "No open pane has a matching claude session yet." + : "No recent claude sessions found in the open panes' WSL distros."} + {sessions.length > 0 && !showAll && ( + <> + {" "} + + + )} +

+ ) : ( +
    + {shown.map((s) => { + const paneLabel = paneByCwd.get(s.cwd); + const open = paneLabel !== undefined; + return ( +
  • + + {open ? "●" : "β—‹"} + +
    +
    + + {projectName(s.cwd) || s.projectDir} + + {dominantModel(s)} + + {formatTokens(sessionTokens(s))} tok + + {formatUsd(sessionCost(s))} +
    +
    + + {s.cwd} + + {open && ( + [pane: {paneLabel}] + )} + {relativeTime(s.lastActiveMs, nowMs)} +
    +
    +
  • + ); + })} +
+ )} +
+ +
+ ● = open pane  Β·  ~$ is an API-pricing estimate (n/a on Pro/Max; + can't reflect /usage quota)  Β·  recent sessions only +
+ + + ); +} + +/** Last path segment of a cwd for a compact project label. */ +function projectName(cwd: string): string { + const parts = cwd.split("/").filter(Boolean); + return parts.length ? parts[parts.length - 1] : cwd; +} diff --git a/src/ipc.ts b/src/ipc.ts index 5eb0829..4600a5e 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -39,25 +39,33 @@ export interface SshHost { export const listDistros = (): Promise => invoke("list_distros"); -// ---- claude context tracking ---------------------------------------------- +// ---- claude usage 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; +/** Per-model token tally within one claude session. Mirrors Rust ModelUsage. */ +export interface ModelUsage { model: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; } -/** 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 }); +/** One claude session's usage, read from its transcript. Mirrors Rust + * SessionUsage. Cost is computed frontend-side (see src/lib/usage.ts). */ +export interface SessionUsage { + sessionId: string; + cwd: string; + projectDir: string; + distro: string; + lastActiveMs: number; + models: ModelUsage[]; +} + +/** Scan ~/.claude/projects in the given WSL distros (distinct distros of + * open WSL panes) and return recent sessions' token tallies. WSL/Windows + * only β€” returns [] otherwise. */ +export const getClaudeUsage = (distros: string[]): Promise => + invoke("get_claude_usage", { distros }); export const spawnPane = (args: { spec: SpawnSpec; diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index bccac05..785e5f2 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -84,10 +84,6 @@ overflow: hidden; text-overflow: ellipsis; max-width: 200px; - /* Give up width first when the pane is narrow, so the chips, context - indicator, and close button stay visible (overrides .pane-toolbar > *). */ - flex-shrink: 1; - min-width: 0; } .pane-label:hover { background: #222; @@ -246,9 +242,6 @@ .pane-status.idle { color: #d96060; } .pane-actions { - /* Final fallback right-anchor (non-claude pane has no .pane-ctx, and at - narrow tiers .pane-status is hidden) so the close button stays pinned right. */ - margin-left: auto; display: flex; gap: 2px; } @@ -271,55 +264,6 @@ background: #5a1a1a; 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 Γ—. */ -.leaf--narrow .pane-status, -.leaf--narrow .pane-actions .pane-btn:not(.close) { - display: none; -} -.leaf--xnarrow .pane-status, -.leaf--xnarrow .pane-actions .pane-btn:not(.close), -.leaf--xnarrow .distro-wrap, -.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 a158396..cbd53af 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -10,12 +10,6 @@ import { import { createPortal } from "react-dom"; import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; import { useOrchestration } from "./orchestration"; -import { - contextLabel, - contextPercent, - contextColor, - contextFraction, -} from "../../lib/usage"; import XtermPane from "../../components/XtermPane"; import type { SpawnSpec } from "../../ipc"; import "./LeafPane.css"; @@ -48,7 +42,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { const [editingLabel, setEditingLabel] = useState(false); const [labelDraft, setLabelDraft] = useState(""); const labelInputRef = useRef(null); - const rootRef = useRef(null); const startEditLabel = useCallback( (e: MouseEvent) => { @@ -163,22 +156,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { return () => orch.reportLeafIdle(leaf.id, false); }, [leaf.id, orch.reportLeafIdle]); - // ---- width tier --------------------------------------------------------- - // Drives which toolbar items collapse on a narrow pane (CSS does the hiding). - // The close button + context indicator stay visible at every tier; min pane - // width is 180px (MIN_PANE_PX), so "xnarrow" must keep those reachable. - const [widthTier, setWidthTier] = useState<"" | "narrow" | "xnarrow">(""); - useEffect(() => { - const el = rootRef.current; - if (!el) return; - const ro = new ResizeObserver(() => { - const w = el.clientWidth; - setWidthTier(w < 230 ? "xnarrow" : w < 320 ? "narrow" : ""); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - // ---- broadcast --------------------------------------------------------- const onTerminalInput = useCallback( (b64: string) => { @@ -409,13 +386,9 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { }; })(); - const ctx = - leaf.shellKind === "wsl" && leaf.cwd ? orch.paneContext.get(leaf.cwd) : undefined; - return (
{status} )} - {ctx && ( - - - - - {contextPercent(ctx)}% - - )} -