Drop colour from the panel, and the message that said nothing
Two changes that turned out to be related. The chips painted their own red, amber and green. An indicator that picks its own colours competes with the shell's accents and stops following the theme, so the panel now draws everything in the panel's text colour and lets shape carry the state. Only alpha still varies, to push idle sessions back. That put real weight on the four shapes being distinguishable at 14 px, which is not something to guess at, so the drawing moved to lib/glyph.js -- free of St, Clutter and shell imports precisely so the glyphs can be rendered to a file and looked at. They were, and the first attempt was wrong twice: a disc filled to the ring's centreline reads smaller than the ring beside it, and the inner disc of "blocked" was too small to tell from a plain "working" ring. Radii are now matched at the outer edge and the inner disc sits at 0.55, both settled by comparing renders. Separately, hooking a live session showed the Notification that accompanies a permission prompt carries only "Claude needs your permission" -- no tool, no command -- and arrives identically when the session is putting a question to the user rather than asking to run something. The menu was giving that string the line, pushing out the tab and the path: strictly less information in more space. It is gone, and the comment claiming it "says what is about to run" is corrected. Recovering the real command means reading the tail of the transcript when the prompt fires; it is not in the event. The same finding settles how far the states can split. "Blocked" cannot be divided into "asking permission" and "asking a question" from this data.
This commit is contained in:
@@ -22,8 +22,8 @@ Code ждёт меня прямо сейчас?**
|
||||
|
||||
| Значок | Состояние | Что значит |
|
||||
|---|---|---|
|
||||
| диск в кольце | `blocked` | упёрлась в запрос разрешения — без вас не сдвинется |
|
||||
| закрашенный диск | `waiting` | ход закончен, ждёт вашего ввода |
|
||||
| диск в кольце | `blocked` | спрашивает — разрешение или вопрос с вариантами; строка ввода занята |
|
||||
| закрашенный диск | `waiting` | ход закончен, строка ввода свободна |
|
||||
| кольцо | `busy` | работает |
|
||||
| тусклое пунктирное кольцо | `idle` | запущена, но ничего не просили |
|
||||
|
||||
@@ -31,6 +31,11 @@ Code ждёт меня прямо сейчас?**
|
||||
законченная задача выглядит так же срочно, как заблокированная, — а именно это
|
||||
различие и решает, переключаться сейчас или после текущей мысли.
|
||||
|
||||
Дальше этого деление не идёт, и не по лени: запрос разрешения и вопрос с
|
||||
вариантами приходят под одним и тем же `notification_type`, с одинаковым родовым
|
||||
текстом `«Claude needs your permission»`. Различить их можно было бы только через
|
||||
`PreToolUse` — ценой записи файла на каждый вызов инструмента.
|
||||
|
||||
Чипы идут по срочности, а внутри одного состояния первой стоит **самая
|
||||
давняя**: забывается та, что ждёт дольше всех, а не последняя. Время в
|
||||
состоянии показывается только у первого чипа: пять счётчиков рядом — это
|
||||
|
||||
@@ -35,6 +35,10 @@ STATE_DIR = os.path.join(
|
||||
# Notification covers several unrelated things; only some mean "the session
|
||||
# stopped and is asking me something". auth_success / elicitation_complete /
|
||||
# elicitation_response are progress chatter and must not touch the state.
|
||||
# Observed: a question put to the user (AskUserQuestion) arrives as
|
||||
# "permission_prompt" too, with the same generic message as a tool asking to
|
||||
# run. The two are therefore not separable here, which is why the panel has one
|
||||
# "blocked" state rather than telling a permission from a question.
|
||||
NOTIFICATION_STATES = {
|
||||
"permission_prompt": "blocked",
|
||||
"agent_needs_input": "blocked",
|
||||
@@ -63,7 +67,8 @@ def debug_log(event):
|
||||
return
|
||||
try:
|
||||
with open(marker, "a") as fh:
|
||||
fh.write(json.dumps(event, sort_keys=True)[:2000] + "\n")
|
||||
stamped = dict(event, _at=time.strftime("%H:%M:%S"))
|
||||
fh.write(json.dumps(stamped, sort_keys=True)[:2000] + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// State glyphs, drawn with cairo alone.
|
||||
//
|
||||
// The panel is monochrome, so shape is the only channel left and these four
|
||||
// have to stay apart at 14 px. Kept free of St/Clutter/GNOME imports so the
|
||||
// shapes can be rendered to a file and looked at, rather than guessed about.
|
||||
|
||||
/** Draw `state` filling the given box, in the colour passed as {r,g,b,a} 0..1. */
|
||||
export function drawState(cr, state, width, height, color) {
|
||||
const { r, g, b, a } = color;
|
||||
const LINE = 1.75;
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
// Centreline of the ring. A stroke straddles it, so a disc filled to this
|
||||
// radius reads visibly smaller than a ring drawn at it -- the filled states
|
||||
// below use `outer` instead, which keeps every glyph the same size.
|
||||
const radius = Math.min(width, height) / 2 - LINE;
|
||||
const outer = radius + LINE / 2;
|
||||
|
||||
cr.setLineWidth(LINE);
|
||||
cr.setSourceRGBA(r, g, b, a);
|
||||
|
||||
switch (state) {
|
||||
case 'blocked':
|
||||
// Disc inside a ring: the most ink of the four, for the only state
|
||||
// where a session is stuck until you act. The inner disc has to be
|
||||
// big enough to register at 14 px, or this reads as plain "working".
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
cr.stroke();
|
||||
// 0.55 of the ring radius: below that the centre stops registering at
|
||||
// 14 px and this collapses into the "working" ring; above it the gap
|
||||
// closes and it collapses into the "waiting" disc. Checked by
|
||||
// rendering, not guessed.
|
||||
cr.arc(cx, cy, radius * 0.55, 0, 2 * Math.PI);
|
||||
cr.fill();
|
||||
break;
|
||||
case 'waiting':
|
||||
cr.arc(cx, cy, outer, 0, 2 * Math.PI);
|
||||
cr.fill();
|
||||
break;
|
||||
case 'busy':
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
cr.stroke();
|
||||
break;
|
||||
default:
|
||||
// Idle: same ring as "working", but dashed and dimmed. Reading as a
|
||||
// fainter version of a state you already know is the point -- nobody
|
||||
// is waiting on it.
|
||||
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;
|
||||
}
|
||||
}
|
||||
+27
-51
@@ -14,6 +14,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 { drawState } from './glyph.js';
|
||||
import { formatAge, projectName, shortenHome } from './format.js';
|
||||
|
||||
// Translated lazily: gettext is not bound yet while modules are being imported.
|
||||
@@ -32,50 +33,21 @@ 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. */
|
||||
/** Paint a state glyph in the panel's own text colour.
|
||||
*
|
||||
* Nothing here picks a colour: the foreground comes from the theme node, so
|
||||
* the row follows the panel through light, dark and custom themes. The shapes
|
||||
* live in glyph.js, which imports nothing and can therefore be rendered to a
|
||||
* file and inspected.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
const c = area.get_theme_node().get_foreground_color();
|
||||
drawState(cr, state, w, h, {
|
||||
r: c.red / 255, g: c.green / 255, b: c.blue / 255, a: c.alpha / 255,
|
||||
});
|
||||
} finally {
|
||||
cr.$dispose();
|
||||
}
|
||||
@@ -120,18 +92,19 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
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;
|
||||
// The panel is monochrome, so shape alone carries the state:
|
||||
// disc inside a ring — blocked on a permission prompt;
|
||||
// filled disc — turn finished, waiting for input;
|
||||
// bright ring — working;
|
||||
// dim dashed ring — idle.
|
||||
// ring — working;
|
||||
// dashed, dimmed — idle.
|
||||
// Colour is left to the shell's own accents; an indicator that paints
|
||||
// its own red competes with them and stops matching the theme.
|
||||
const dot = new St.DrawingArea({
|
||||
style_class: 'ccs-dot',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
dot.set_width(12);
|
||||
dot.set_height(12);
|
||||
dot.set_width(14);
|
||||
dot.set_height(14);
|
||||
dot.connect('repaint', area => drawStateDot(area, session.state));
|
||||
chip.add_child(dot);
|
||||
|
||||
@@ -342,10 +315,13 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
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(' · ')}`;
|
||||
// The message that comes with a permission prompt is generic --
|
||||
// observed verbatim as "Claude needs your permission", with no tool
|
||||
// name and no command, and identical whether the session is asking to
|
||||
// run something or putting a question to you. Showing it would push
|
||||
// the tab and the path out of the line to say less than the state
|
||||
// label already does. Getting the real command means reading the tail
|
||||
// of the transcript when the prompt fires; it is not in the event.
|
||||
return `${stateLabel(session.state)} · ${where.join(' · ')}`;
|
||||
}
|
||||
|
||||
|
||||
+13
-18
@@ -1,7 +1,10 @@
|
||||
/* State glyphs are drawn in the widget's inherited foreground colour, so state
|
||||
colours are set here as plain `color` and the drawing code stays
|
||||
theme-agnostic. The colour is set on the chip, so the dot and its label read
|
||||
as one object. */
|
||||
/* The panel stays monochrome: every glyph is drawn in the panel's own text
|
||||
colour, so it follows the theme instead of fighting it, and the shell's
|
||||
accent colours keep meaning what they mean. State is carried by shape, which
|
||||
also survives a monochrome theme and colour vision deficiency.
|
||||
|
||||
Only alpha varies, and only to push idle sessions back — nobody is waiting
|
||||
on those, so they should not compete with the ones that need an answer. */
|
||||
|
||||
.ccs-panel-box {
|
||||
spacing: 10px;
|
||||
@@ -14,6 +17,12 @@
|
||||
spacing: 4px;
|
||||
}
|
||||
|
||||
/* Glyph is drawn in code (St.DrawingArea) using the panel text colour. */
|
||||
.ccs-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.ccs-chip-label {
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
@@ -28,20 +37,6 @@
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.ccs-blocked {
|
||||
color: #e01b24;
|
||||
}
|
||||
|
||||
.ccs-waiting {
|
||||
color: #e5a50a;
|
||||
}
|
||||
|
||||
.ccs-busy {
|
||||
color: #33d17a;
|
||||
}
|
||||
|
||||
/* Idle keeps the panel's own colour and just recedes: a session nobody is
|
||||
waiting on should not compete with the ones that need an answer. */
|
||||
.ccs-idle {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user