Show one chip per session, right of the clock
The panel showed a single aggregate: the most urgent session, plus a "+N" for the others. That answers "what is the worst thing happening", which is not the question with five projects open -- "what is each of them doing" needs each of them on screen. Each session now gets a chip: state glyph plus a three-character project label, in the centre box just right of the clock. Time in state stays on the first chip only; five counters side by side are a row of numbers, not an answer. Labels are initials for multi-segment names, first letters otherwise. Initials rather than a prefix, because a prefix collapses dev-skills and dev-conventions onto the same "dev" -- exactly the pair that has to stay apart. Collisions, including two sessions in one project where the cwd is identical, take a digit: ds, ds2, ds3. Two rules keep a label still, and the feature is worthless without them. Assignment runs oldest-session-first, so a session starting now takes the suffix instead of displacing one already on screen; and a label belongs to its session until it ends, even after whatever forced the digit has closed. A label that moves under your hand is worse than one carrying a digit that no longer looks necessary. The zellij tab name was considered as the label source and dropped. It arrives asynchronously from dump-layout, so a sticky label would freeze whatever the project name produced first and never adopt it. Digits already separate same-project sessions, so the tab name buys nothing here and stays where it is useful, in the menu. Menu rows now lead with the same label, so the mapping from "ds" to dev-skills is read rather than guessed.
This commit is contained in:
+126
-92
@@ -13,6 +13,7 @@ import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.j
|
||||
|
||||
import { SessionStore, STATES } from './sessions.js';
|
||||
import { ZellijTabs } from './zellij.js';
|
||||
import { assignChips } from './abbrev.js';
|
||||
import { formatAge, projectName, shortenHome } from './format.js';
|
||||
|
||||
// Translated lazily: gettext is not bound yet while modules are being imported.
|
||||
@@ -31,6 +32,55 @@ const TERMINAL_CLASSES = [
|
||||
'foot', 'wezterm', 'konsole', 'xterm', 'ghostty',
|
||||
];
|
||||
|
||||
/** Paint the state glyph in the actor's inherited foreground colour, so the
|
||||
* stylesheet decides the colour and this stays theme-agnostic. */
|
||||
function drawStateDot(area, state) {
|
||||
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 - 1.5;
|
||||
|
||||
cr.setLineWidth(1.5);
|
||||
cr.setSourceRGBA(r, g, b, a);
|
||||
|
||||
switch (state) {
|
||||
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.5, 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();
|
||||
}
|
||||
}
|
||||
|
||||
export const ClaudeStatusIndicator = GObject.registerClass(
|
||||
class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
_init(extension) {
|
||||
@@ -40,7 +90,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
this._settings = extension.getSettings();
|
||||
this._store = new SessionStore();
|
||||
this._zellij = new ZellijTabs();
|
||||
this._dotState = 'none';
|
||||
this._chipLabels = new Map();
|
||||
this._rows = [];
|
||||
|
||||
this._buildPanel();
|
||||
@@ -54,34 +104,54 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
// ---- Panel widget -------------------------------------------------
|
||||
|
||||
_buildPanel() {
|
||||
const box = new St.BoxLayout({
|
||||
// One chip per session rather than one aggregate: with five projects
|
||||
// open, "the most urgent one" answers a question you did not ask. The
|
||||
// row sits right of the clock, so it grows away from the centre.
|
||||
this._chipBox = new St.BoxLayout({
|
||||
style_class: 'panel-status-menu-box ccs-panel-box',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
this.add_child(this._chipBox);
|
||||
}
|
||||
|
||||
// Shape carries the state as well as colour does, so the indicator
|
||||
// still reads under a monochrome theme or with colour vision deficiency:
|
||||
_buildChip(session, label, withAge) {
|
||||
const chip = new St.BoxLayout({
|
||||
style_class: `ccs-chip ccs-${session.state}`,
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
|
||||
// Shape carries the state as well as colour does, so the row 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',
|
||||
// dim dashed ring — idle.
|
||||
const dot = new St.DrawingArea({
|
||||
style_class: 'ccs-dot',
|
||||
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);
|
||||
dot.set_width(12);
|
||||
dot.set_height(12);
|
||||
dot.connect('repaint', area => drawStateDot(area, session.state));
|
||||
chip.add_child(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);
|
||||
let age = null;
|
||||
if (this._settings.get_boolean('show-project-name')) {
|
||||
chip.add_child(new St.Label({
|
||||
style_class: 'ccs-chip-label',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
text: label,
|
||||
}));
|
||||
}
|
||||
if (withAge) {
|
||||
age = new St.Label({
|
||||
style_class: 'ccs-chip-age',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
text: formatAge(this._ageOf(session)),
|
||||
});
|
||||
chip.add_child(age);
|
||||
}
|
||||
return { chip, age };
|
||||
}
|
||||
|
||||
// ---- Menu ---------------------------------------------------------
|
||||
@@ -122,36 +192,46 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
|
||||
_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 abbreviate = this._settings.get_boolean('abbreviate-names');
|
||||
|
||||
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._chipLabels = assignChips(
|
||||
sessions.map(s => ({
|
||||
sessionId: s.sessionId,
|
||||
since: s.since,
|
||||
base: projectName(s.cwd),
|
||||
})),
|
||||
this._chipLabels);
|
||||
|
||||
this.visible = !(hideWhenIdle && (!top || top.state === 'idle'));
|
||||
const labelFor = session => abbreviate
|
||||
? this._chipLabels.get(session.sessionId)
|
||||
: projectName(session.cwd);
|
||||
|
||||
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}`;
|
||||
const busy = sessions.some(s => s.state !== 'idle');
|
||||
this.visible = sessions.length > 0 && !(hideWhenIdle && !busy);
|
||||
|
||||
// Age rides on the first chip only. Sessions are sorted by urgency, so
|
||||
// that is the one whose age decides anything; five ages side by side
|
||||
// would just be a wide row of numbers.
|
||||
const ageOnFirst = showAge && busy;
|
||||
const signature = sessions
|
||||
.map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`)
|
||||
.join('|') + `|${ageOnFirst}`;
|
||||
if (signature !== this._chipSignature) {
|
||||
this._chipBox.destroy_all_children();
|
||||
this._ageLabel = null;
|
||||
sessions.forEach((session, i) => {
|
||||
const { chip, age } = this._buildChip(
|
||||
session, labelFor(session), ageOnFirst && i === 0);
|
||||
if (age)
|
||||
this._ageLabel = { age, session };
|
||||
this._chipBox.add_child(chip);
|
||||
});
|
||||
this._chipSignature = signature;
|
||||
}
|
||||
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();
|
||||
}
|
||||
if (this._ageLabel)
|
||||
this._ageLabel.age.text = formatAge(this._ageOf(this._ageLabel.session));
|
||||
}
|
||||
|
||||
_updateMenu(sessions) {
|
||||
@@ -160,7 +240,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
// 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) ?? ''}`)
|
||||
.map(s => `${s.sessionId}:${s.state}:${this._tabFor(s) ?? ''}:${this._chipLabels?.get(s.sessionId) ?? ''}`)
|
||||
.join('|');
|
||||
if (signature !== this._rowSignature) {
|
||||
this._rebuildRows(sessions);
|
||||
@@ -220,8 +300,9 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
const column = new St.BoxLayout({ vertical: true, x_expand: true });
|
||||
|
||||
const top = new St.BoxLayout({ x_expand: true });
|
||||
const chip = this._chipLabels?.get(session.sessionId);
|
||||
const title = new St.Label({
|
||||
text: projectName(session.cwd),
|
||||
text: chip ? `${chip} ${projectName(session.cwd)}` : projectName(session.cwd),
|
||||
style_class: `ccs-row-title ccs-${session.state}`,
|
||||
x_expand: true,
|
||||
});
|
||||
@@ -328,53 +409,6 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
|
||||
// ---- 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() {
|
||||
|
||||
Reference in New Issue
Block a user