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:
av
2026-08-09 18:11:27 +03:00
commit 7fc7f63842
16 changed files with 1898 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
// Formatting helpers for panel and menu labels.
import GLib from 'gi://GLib';
/** Compact age: "45s", "12m", "2h 5m". Seconds only below a minute, because
* the panel is glanced at, not read. */
export function formatAge(seconds) {
const s = Math.max(0, Math.floor(seconds));
if (s < 60)
return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60)
return `${m}m`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
/** Last path segment, with ~ collapsed. Two worktrees of one repo share a
* basename, so this is a panel-only label; menus show the full path. */
export function projectName(path) {
if (!path)
return '?';
const trimmed = path.replace(/\/+$/, '');
const base = trimmed.split('/').pop();
return base || trimmed || '?';
}
/** Full path with the home directory shortened to "~". */
export function shortenHome(path) {
if (!path)
return '';
const home = GLib.get_home_dir();
if (home && path.startsWith(home))
return `~${path.slice(home.length)}`;
return path;
}
+394
View File
@@ -0,0 +1,394 @@
// Panel button: one glance answers "does anything need me, and where".
import GObject from 'gi://GObject';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import Pango from 'gi://Pango';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js';
import { SessionStore, STATES } from './sessions.js';
import { ZellijTabs } from './zellij.js';
import { formatAge, projectName, shortenHome } from './format.js';
// Translated lazily: gettext is not bound yet while modules are being imported.
function stateLabel(state) {
switch (state) {
case 'blocked': return _('needs an answer');
case 'waiting': return _('waiting for input');
case 'busy': return _('working');
case 'idle': return _('idle');
default: return state;
}
}
const TERMINAL_CLASSES = [
'gnome-terminal', 'org.gnome.terminal', 'kitty', 'alacritty',
'foot', 'wezterm', 'konsole', 'xterm', 'ghostty',
];
export const ClaudeStatusIndicator = GObject.registerClass(
class ClaudeStatusIndicator extends PanelMenu.Button {
_init(extension) {
super._init(0.5, 'Claude Code Status', false);
this._extension = extension;
this._settings = extension.getSettings();
this._store = new SessionStore();
this._zellij = new ZellijTabs();
this._dotState = 'none';
this._rows = [];
this._buildPanel();
this._buildMenu();
this._changedId = this._store.connect('changed', () => this._update());
this._settingsChangedId = this._settings.connect('changed', () => this._update());
this._store.start();
}
// ---- Panel widget -------------------------------------------------
_buildPanel() {
const box = new St.BoxLayout({
style_class: 'panel-status-menu-box ccs-panel-box',
y_align: Clutter.ActorAlign.CENTER,
});
// Shape carries the state as well as colour does, so the indicator
// still reads under a monochrome theme or with colour vision deficiency:
// filled disc in a ring — blocked on a permission prompt;
// filled disc — turn finished, waiting for input;
// bright ring — working;
// dim dashed ring — idle, or nothing running.
this._dot = new St.DrawingArea({
style_class: 'ccs-dot ccs-none',
y_align: Clutter.ActorAlign.CENTER,
});
this._dot.set_width(16);
this._dot.set_height(16);
this._dot.connect('repaint', area => this._drawDot(area));
box.add_child(this._dot);
this._label = new St.Label({
style_class: 'ccs-panel-label',
y_align: Clutter.ActorAlign.CENTER,
text: '',
});
box.add_child(this._label);
this.add_child(box);
}
// ---- Menu ---------------------------------------------------------
_buildMenu() {
this._summaryItem = new PopupMenu.PopupMenuItem('', {
reactive: false,
can_focus: false,
});
this._summaryItem.add_style_class_name('ccs-summary');
this.menu.addMenuItem(this._summaryItem);
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
this._sessionsSection = new PopupMenu.PopupMenuSection();
this.menu.addMenuItem(this._sessionsSection);
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
const prefsItem = new PopupMenu.PopupMenuItem(_('Settings'));
prefsItem.connect('activate', () => this._extension.openPreferences());
this.menu.addMenuItem(prefsItem);
this.menu.connect('open-state-changed', (_menu, open) => {
if (open)
this._refreshZellij(true);
});
}
// ---- Data → UI ----------------------------------------------------
_update() {
const sessions = this._store.sessions;
this._updatePanel(sessions);
this._updateMenu(sessions);
this._refreshZellij(false);
}
_updatePanel(sessions) {
const hideWhenIdle = this._settings.get_boolean('hide-when-idle');
const showName = this._settings.get_boolean('show-project-name');
const showAge = this._settings.get_boolean('show-age');
const top = sessions[0] ?? null;
const state = top?.state ?? 'none';
// Only sessions sharing the top state are counted: "+2" must mean two
// more that need the same thing, not two unrelated background sessions.
const peers = top ? sessions.filter(s => s.state === top.state).length : 0;
this.visible = !(hideWhenIdle && (!top || top.state === 'idle'));
let text = '';
if (top && top.state !== 'idle') {
const parts = [];
if (showName)
parts.push(projectName(top.cwd));
if (showAge)
parts.push(formatAge(this._ageOf(top)));
text = parts.join(' · ');
if (peers > 1)
text = text ? `${text} +${peers - 1}` : `+${peers - 1}`;
}
this._label.text = text;
this._label.visible = text !== '';
if (state !== this._dotState) {
this._dotState = state;
this._dot.style_class = `ccs-dot ccs-${state}`;
this._dot.queue_repaint();
}
}
_updateMenu(sessions) {
this._summaryItem.label.text = this._summaryText(sessions);
// Rebuild only when the set of sessions or their states changed; ages
// alone are refreshed in place so an open menu does not flicker.
const signature = sessions
.map(s => `${s.sessionId}:${s.state}:${this._tabFor(s) ?? ''}`)
.join('|');
if (signature !== this._rowSignature) {
this._rebuildRows(sessions);
this._rowSignature = signature;
}
for (const row of this._rows) {
const session = sessions.find(s => s.sessionId === row.sessionId);
if (!session)
continue;
row.age.text = formatAge(this._ageOf(session));
row.subtitle.text = this._subtitleFor(session);
}
}
_summaryText(sessions) {
if (!sessions.length)
return _('No Claude Code sessions');
const counts = new Map();
for (const s of sessions)
counts.set(s.state, (counts.get(s.state) ?? 0) + 1);
const parts = [];
for (const state of STATES) {
const n = counts.get(state);
if (n)
parts.push(`${n} ${stateLabel(state)}`);
}
return parts.join(', ');
}
_rebuildRows(sessions) {
this._sessionsSection.removeAll();
this._rows = [];
if (!sessions.length) {
const empty = new PopupMenu.PopupMenuItem(_('Nothing running'), {
reactive: false,
can_focus: false,
});
this._sessionsSection.addMenuItem(empty);
return;
}
for (const session of sessions)
this._sessionsSection.addMenuItem(this._buildRow(session));
}
_buildRow(session) {
const tab = this._tabFor(session);
// Reactivity is decided at construction, not patched afterwards:
// PopupBaseMenuItem latches _activatable in its constructor, so a row
// switched to reactive=false later keeps the styling of a clickable one
// and still looks like it does something.
const item = new PopupMenu.PopupBaseMenuItem(
tab ? {} : { reactive: false, can_focus: false });
item.add_style_class_name('ccs-row');
const column = new St.BoxLayout({ vertical: true, x_expand: true });
const top = new St.BoxLayout({ x_expand: true });
const title = new St.Label({
text: projectName(session.cwd),
style_class: `ccs-row-title ccs-${session.state}`,
x_expand: true,
});
const age = new St.Label({
text: formatAge(this._ageOf(session)),
style_class: 'ccs-row-age',
x_align: Clutter.ActorAlign.END,
});
top.add_child(title);
top.add_child(age);
const subtitle = new St.Label({
text: this._subtitleFor(session),
style_class: 'ccs-row-subtitle',
});
subtitle.clutter_text.line_wrap = false;
subtitle.clutter_text.ellipsize = Pango.EllipsizeMode.END;
column.add_child(top);
column.add_child(subtitle);
item.add_child(column);
if (tab)
item.connect('activate', () => this._switchTo(session, tab));
this._rows.push({ sessionId: session.sessionId, age, subtitle });
return item;
}
/** Second line: what the session needs, then where to find it. */
_subtitleFor(session) {
const where = [];
const tab = this._tabFor(session);
if (tab)
where.push(`${_('tab')}: ${tab}`);
else if (session.zellijSession)
where.push(`${_('zellij')}: ${session.zellijSession}`);
where.push(shortenHome(session.cwd));
// A permission prompt is the one case where the reason matters more
// than the location: it says what is about to run.
if (session.state === 'blocked' && session.message)
return `${session.message}${where.join(' · ')}`;
return `${stateLabel(session.state)} · ${where.join(' · ')}`;
}
_ageOf(session) {
if (!session.since)
return 0;
return GLib.get_real_time() / 1e6 - session.since;
}
// ---- zellij ---------------------------------------------------------
_tabFor(session) {
if (!this._settings.get_boolean('zellij-integration'))
return null;
return this._zellij.tabFor(session.zellijSession, session.cwd);
}
_refreshZellij(force) {
if (!this._settings.get_boolean('zellij-integration'))
return;
const names = this._store.sessions.map(s => s.zellijSession).filter(Boolean);
if (!names.length)
return;
this._zellij.refresh(names, force)
.then(() => {
if (!this._destroyed)
this._updateMenu(this._store.sessions);
})
.catch(e => logError(e, 'claude-code-status: zellij refresh failed'));
}
_switchTo(session, tab) {
this._zellij.goToTab(session.zellijSession, tab);
this._focusTerminal(session);
}
/** Best effort: raise a terminal window showing this zellij session.
*
* Matching by pid does not work for gnome-terminal, where every window
* belongs to one shared server process, so the window title is the only
* handle available -- and zellij puts the session name there.
*/
_focusTerminal(session) {
if (!session.zellijSession)
return;
// list_all_windows() rather than get_window_actors(): the latter is
// deprecated from GNOME 46 on, and this has to work across 45-48.
for (const win of global.display.list_all_windows()) {
const wmClass = (win.get_wm_class() ?? '').toLowerCase();
if (!TERMINAL_CLASSES.some(c => wmClass.includes(c)))
continue;
// Only a window that names this zellij session is raised. Falling
// back to any terminal at all would raise an unrelated one, which
// is worse than leaving focus where the user put it.
if ((win.get_title() ?? '').includes(session.zellijSession)) {
Main.activateWindow(win);
return;
}
}
}
// ---- Visuals --------------------------------------------------------
_drawDot(area) {
const cr = area.get_context();
try {
const [w, h] = area.get_surface_size();
const color = area.get_theme_node().get_foreground_color();
const r = color.red / 255;
const g = color.green / 255;
const b = color.blue / 255;
const a = color.alpha / 255;
const cx = w / 2;
const cy = h / 2;
const radius = Math.min(w, h) / 2 - 2;
cr.setLineWidth(1.5);
cr.setSourceRGBA(r, g, b, a);
switch (this._dotState) {
case 'blocked':
// Disc inside a ring: the loudest shape, for the only state
// where a session is stuck until you act.
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
cr.stroke();
cr.arc(cx, cy, radius * 0.55, 0, 2 * Math.PI);
cr.fill();
break;
case 'waiting':
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
cr.fill();
break;
case 'busy':
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
cr.stroke();
break;
default:
cr.setDash([2, 2], 0);
cr.setSourceRGBA(r, g, b, a * 0.5);
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
cr.stroke();
cr.setDash([], 0);
break;
}
} finally {
cr.$dispose();
}
}
// ---- Teardown -------------------------------------------------------
destroy() {
this._destroyed = true;
this._zellij.destroy();
if (this._changedId) {
this._store.disconnect(this._changedId);
this._changedId = 0;
}
if (this._settingsChangedId) {
this._settings.disconnect(this._settingsChangedId);
this._settingsChangedId = 0;
}
this._store.destroy();
super.destroy();
}
});
+235
View File
@@ -0,0 +1,235 @@
// Reads the per-session state files written by the Claude Code hook and keeps
// them in sync with the filesystem.
//
// The hook only writes on an actual state change, so a directory monitor is
// enough and there is nothing to poll. The timer here exists for two other
// reasons: displayed ages go stale on their own, and a session whose terminal
// was killed never sends SessionEnd, so liveness has to be rechecked.
import GObject from 'gi://GObject';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
Gio._promisify(Gio.File.prototype, 'enumerate_children_async');
Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async');
Gio._promisify(Gio.File.prototype, 'load_contents_async');
// Aggregation order: a session blocked on a permission prompt is the only one
// that is actually stuck, so it outranks one that merely finished its turn.
export const STATES = ['blocked', 'waiting', 'busy', 'idle'];
const KNOWN = new Set(STATES);
const LIVENESS_INTERVAL = 20; // seconds
// Fallback for sessions whose process could not be identified (pid 0): there is
// nothing to test for liveness, so age is the only signal left. Long enough
// that a session genuinely left waiting overnight is still listed in the
// morning, which is exactly the case this indicator exists for.
const UNKNOWN_PID_MAX_AGE = 36 * 3600; // seconds
export function stateRank(state) {
const i = STATES.indexOf(state);
return i < 0 ? STATES.length : i;
}
export function stateDir() {
const base = GLib.getenv('XDG_STATE_HOME') ||
GLib.build_filenamev([GLib.get_home_dir(), '.local', 'state']);
return GLib.build_filenamev([base, 'claude-code-status']);
}
export const SessionStore = GObject.registerClass({
Signals: { 'changed': {} },
}, class SessionStore extends GObject.Object {
_init() {
super._init();
this._dir = Gio.File.new_for_path(stateDir());
this._sessions = [];
this._monitor = null;
this._debounceId = 0;
this._timerId = 0;
this._cancellable = new Gio.Cancellable();
this._loading = false;
this._loadAgain = false;
}
get sessions() {
return this._sessions;
}
start() {
// The directory is created by the first hook run, which may not have
// happened yet; monitoring a missing directory still reports its
// creation, so there is nothing to wait for.
try {
this._monitor = this._dir.monitor_directory(Gio.FileMonitorFlags.WATCH_MOVES, null);
this._monitor.connect('changed', () => this._scheduleLoad());
} catch (e) {
logError(e, 'claude-code-status: cannot monitor state directory');
}
this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, LIVENESS_INTERVAL, () => {
// Ages advance and processes die without any file changing, so this
// tick is what makes a killed terminal disappear from the panel.
this._load();
return GLib.SOURCE_CONTINUE;
});
this._load();
}
// One atomic write lands as several monitor events (created, moved, changed).
// Collapsing them keeps a burst of five sessions from causing five reloads.
_scheduleLoad() {
if (this._debounceId)
GLib.Source.remove(this._debounceId);
this._debounceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 120, () => {
this._debounceId = 0;
this._load();
return GLib.SOURCE_REMOVE;
});
}
async _load() {
if (this._loading) {
this._loadAgain = true;
return;
}
this._loading = true;
const cancellable = this._cancellable;
try {
const sessions = await this._readAll(cancellable);
if (cancellable.is_cancelled())
return;
sessions.sort((a, b) => {
const byState = stateRank(a.state) - stateRank(b.state);
// Oldest first within a state: the session you forgot about is
// the one that has been waiting longest, not the latest one.
return byState !== 0 ? byState : a.since - b.since;
});
this._sessions = sessions;
// Emitted unconditionally: even with no structural change the
// displayed ages have advanced, and redrawing a handful of labels
// is cheaper than tracking what moved.
this.emit('changed');
} catch (e) {
if (!cancellable.is_cancelled())
logError(e, 'claude-code-status: failed to read session state');
} finally {
this._loading = false;
if (this._loadAgain) {
this._loadAgain = false;
this._load();
}
}
}
async _readAll(cancellable) {
let enumerator;
try {
enumerator = await this._dir.enumerate_children_async(
'standard::name', Gio.FileQueryInfoFlags.NONE,
GLib.PRIORITY_DEFAULT, cancellable);
} catch (e) {
// No directory yet means no sessions have ever run; not an error.
if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
return [];
throw e;
}
const names = [];
for (;;) {
const batch = await enumerator.next_files_async(
32, GLib.PRIORITY_DEFAULT, cancellable);
if (!batch.length)
break;
for (const info of batch) {
const name = info.get_name();
// ".tmp" files are half-written state; "debug" is the hook's
// opt-in event log and is not a session.
if (name.endsWith('.json'))
names.push(name);
}
}
const sessions = [];
for (const name of names) {
const session = await this._readOne(name, cancellable);
if (session)
sessions.push(session);
}
return sessions;
}
async _readOne(name, cancellable) {
const file = this._dir.get_child(name);
let raw;
try {
const [contents] = await file.load_contents_async(cancellable);
raw = JSON.parse(new TextDecoder().decode(contents));
} catch (e) {
// A file replaced mid-read, or truncated by a crash: skip it and
// let the next monitor event pick up the good version.
return null;
}
if (!raw || typeof raw !== 'object')
return null;
const pid = Number(raw.pid) || 0;
const eventTs = Number(raw.event_ts) || 0;
const age = GLib.get_real_time() / 1e6 - eventTs;
// pid 0 is "the hook could not tell", not "dead": treating it as dead
// would hide a perfectly live session, so those fall back to an age
// cutoff instead.
const gone = pid > 0 ? !isAlive(pid) : age > UNKNOWN_PID_MAX_AGE;
if (gone) {
// The terminal was killed without a SessionEnd hook. Removing the
// file here (rather than only hiding it) keeps the directory from
// growing forever across reboots.
// The hook's lock file goes with it; dropping only the state file
// would leave one empty ".lock" behind per session, forever.
for (const victim of [file, this._dir.get_child(`${name}.lock`)]) {
victim.delete_async(GLib.PRIORITY_LOW, null, (obj, res) => {
try {
obj.delete_finish(res);
} catch (e) {
// Already gone: the hook's own sweep got there first.
}
});
}
return null;
}
const state = KNOWN.has(raw.state) ? raw.state : 'idle';
return {
sessionId: String(raw.session_id ?? name.replace(/\.json$/, '')),
state,
cwd: String(raw.cwd ?? ''),
since: Number(raw.since) || 0,
pid,
message: String(raw.message ?? ''),
notificationType: String(raw.notification_type ?? ''),
zellijSession: String(raw.zellij_session ?? ''),
zellijPane: String(raw.zellij_pane ?? ''),
};
}
destroy() {
this._cancellable.cancel();
if (this._debounceId) {
GLib.Source.remove(this._debounceId);
this._debounceId = 0;
}
if (this._timerId) {
GLib.Source.remove(this._timerId);
this._timerId = 0;
}
this._monitor?.cancel();
this._monitor = null;
this._sessions = [];
}
});
function isAlive(pid) {
return pid > 0 && GLib.file_test(`/proc/${pid}`, GLib.FileTest.EXISTS);
}
+162
View File
@@ -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;
}