Show Claude Code session status in the GNOME panel
Answers one question at a glance: is any session waiting for me, and
which one. With several sessions open the cost is not knowing what each
is doing, it is noticing that one stopped an hour ago.
Claude Code hooks write one JSON file per session under
~/.local/state/claude-code-status; the extension watches the directory
with Gio.FileMonitor, so nothing polls and there is no daemon.
Two distinctions carry the design:
* blocked (permission prompt) is kept apart from waiting (turn done).
Merged, a finished task looks as urgent as a stuck one, which is
exactly the judgement the indicator exists to make.
* the panel names the oldest session in the top state, not the latest.
The session you forget is the one that has been waiting longest.
PostToolUse is registered although it looks redundant: it is the only
event that fires after a permission is granted, so without it a session
stays blocked in the panel for the rest of the turn. It writes only on
an actual state change, so the usual case costs no I/O.
Stop and SessionEnd are synchronous, unlike the rest. Both fire as the
process is about to go quiet, and an async hook racing that exit gets
killed before it writes -- claude -p left a session pinned at busy.
Concurrent hooks for one session serialise on an flock plus a timestamp
guard; tests/test-hook.sh covers each separately, because the burst test
passes on the timestamp guard alone.
Sessions running in zellij are located by tab name rather than by path,
matched through dump-layout on the working directory. The dump carries
no pane ids, so ZELLIJ_PANE_ID cannot be used; rows that do not resolve
stay inert instead of pretending a click does something.
lib/sessions.js deliberately imports nothing from the shell resource
namespace, which lets the riskiest logic -- liveness, ordering, partial
reads, monitoring -- run under plain gjs in tests/test-sessions.js.
This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
// Maps a session's working directory to the zellij tab it is running in.
|
||||
//
|
||||
// Knowing a session waits for you is only half the answer; the other half is
|
||||
// where to look. When sessions live in zellij tabs, the tab name is a better
|
||||
// answer than a path, and zellij can be told to switch to it.
|
||||
//
|
||||
// `zellij action dump-layout` prints tab names with each pane's cwd but no pane
|
||||
// ids, so ZELLIJ_PANE_ID from the hook cannot be used for the lookup and the
|
||||
// match goes through the working directory instead. That is best-effort by
|
||||
// nature: two sessions in one tab are indistinguishable, and a session whose
|
||||
// cwd has moved since the pane opened will not match.
|
||||
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
|
||||
Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async');
|
||||
|
||||
// Layouts only change when tabs are rearranged, and opening the menu forces a
|
||||
// refresh anyway -- so the background TTL is long enough that the periodic tick
|
||||
// does not spawn a zellij process every time it runs.
|
||||
const CACHE_TTL = 120; // seconds
|
||||
|
||||
export class ZellijTabs {
|
||||
constructor() {
|
||||
this._cache = new Map(); // zellij session -> { at, tabs: [{name, cwds}] }
|
||||
this._inFlight = new Map();
|
||||
this._available = null;
|
||||
this._cancellable = new Gio.Cancellable();
|
||||
}
|
||||
|
||||
/** Tab name for a working directory, or null when unknown. */
|
||||
tabFor(zellijSession, cwd) {
|
||||
const entry = this._cache.get(zellijSession);
|
||||
if (!entry || !cwd)
|
||||
return null;
|
||||
// Longest matching prefix wins: a pane opened at the repo root must not
|
||||
// outrank one opened directly in the subdirectory the session runs in.
|
||||
let best = null;
|
||||
let bestLen = -1;
|
||||
for (const tab of entry.tabs) {
|
||||
for (const paneCwd of tab.cwds) {
|
||||
if (cwd !== paneCwd && !cwd.startsWith(`${paneCwd}/`))
|
||||
continue;
|
||||
if (paneCwd.length > bestLen) {
|
||||
bestLen = paneCwd.length;
|
||||
best = tab.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Refresh the layout of every zellij session in use, at most once per TTL.
|
||||
* `force` bypasses the TTL for the one moment it matters: the user just
|
||||
* opened the menu and may have rearranged tabs since the last look. */
|
||||
async refresh(zellijSessions, force = false) {
|
||||
if (this._available === false)
|
||||
return;
|
||||
const now = GLib.get_monotonic_time() / 1e6;
|
||||
const work = [];
|
||||
for (const name of new Set(zellijSessions)) {
|
||||
if (!name)
|
||||
continue;
|
||||
const entry = this._cache.get(name);
|
||||
if (!force && entry && now - entry.at < CACHE_TTL)
|
||||
continue;
|
||||
work.push(this._refreshOne(name, now));
|
||||
}
|
||||
await Promise.all(work);
|
||||
}
|
||||
|
||||
async _refreshOne(name, now) {
|
||||
// Collapse concurrent refreshes of the same session; the menu opening
|
||||
// and the periodic tick can otherwise fire two subprocesses at once.
|
||||
if (this._inFlight.has(name))
|
||||
return this._inFlight.get(name);
|
||||
const promise = this._dumpLayout(name)
|
||||
.then(layout => {
|
||||
if (layout !== null)
|
||||
this._available = true;
|
||||
// A failure is cached as an empty layout, not left uncached:
|
||||
// otherwise a zellij session that was renamed or killed while
|
||||
// its claude process lives on fails the TTL check every time
|
||||
// and forks a process on every tick, forever.
|
||||
this._cache.set(name, {
|
||||
at: now,
|
||||
tabs: layout === null ? [] : parseLayout(layout),
|
||||
});
|
||||
})
|
||||
.finally(() => this._inFlight.delete(name));
|
||||
this._inFlight.set(name, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async _dumpLayout(session) {
|
||||
try {
|
||||
const proc = Gio.Subprocess.new(
|
||||
['zellij', '--session', session, 'action', 'dump-layout'],
|
||||
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE);
|
||||
const [stdout] = await proc.communicate_utf8_async(null, this._cancellable);
|
||||
if (!proc.get_successful())
|
||||
return null;
|
||||
return stdout ?? '';
|
||||
} catch (e) {
|
||||
// zellij not installed, or not on the shell's PATH: stop trying.
|
||||
if (e.matches?.(GLib.SpawnError, GLib.SpawnError.NOENT))
|
||||
this._available = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Abandon any layout dump still running; the extension is going away. */
|
||||
destroy() {
|
||||
this._cancellable.cancel();
|
||||
this._cache.clear();
|
||||
this._inFlight.clear();
|
||||
}
|
||||
|
||||
/** Switch the given zellij session to a tab. Fire and forget. */
|
||||
goToTab(session, tab) {
|
||||
try {
|
||||
Gio.Subprocess.new(
|
||||
['zellij', '--session', session, 'action', 'go-to-tab-name', tab],
|
||||
Gio.SubprocessFlags.STDOUT_SILENCE | Gio.SubprocessFlags.STDERR_SILENCE);
|
||||
} catch (e) {
|
||||
logError(e, 'claude-code-status: zellij go-to-tab-name failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract tab names and their pane working directories from a KDL layout.
|
||||
*
|
||||
* Parsed with line matching rather than a KDL parser: the two constructs that
|
||||
* matter are one line each, and a dependency-free extension cannot pull one in.
|
||||
*/
|
||||
export function parseLayout(text) {
|
||||
const tabs = [];
|
||||
// A layout-level `cwd "..."` is the base for panes that store a relative one.
|
||||
const baseMatch = text.match(/^\s*cwd\s+"([^"]*)"/m);
|
||||
const base = baseMatch ? baseMatch[1] : '';
|
||||
let current = null;
|
||||
for (const line of text.split('\n')) {
|
||||
const tabMatch = line.match(/^\s*tab\s.*?name="([^"]*)"/);
|
||||
if (tabMatch) {
|
||||
current = { name: tabMatch[1], cwds: [] };
|
||||
tabs.push(current);
|
||||
continue;
|
||||
}
|
||||
if (!current)
|
||||
continue;
|
||||
const paneMatch = line.match(/^\s*pane\s.*?\bcwd="([^"]*)"/);
|
||||
if (paneMatch) {
|
||||
const cwd = paneMatch[1];
|
||||
const absolute = cwd.startsWith('/')
|
||||
? cwd
|
||||
: GLib.build_filenamev([base, cwd]);
|
||||
if (!current.cwds.includes(absolute))
|
||||
current.cwds.push(absolute);
|
||||
}
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
Reference in New Issue
Block a user