diff --git a/README.md b/README.md index 9afb32c..2e0f9e6 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ Code ждёт меня прямо сейчас?** | Значок | Состояние | Что значит | |---|---|---| -| диск в кольце | `blocked` | упёрлась в запрос разрешения — без вас не сдвинется | -| закрашенный диск | `waiting` | ход закончен, ждёт вашего ввода | +| диск в кольце | `blocked` | спрашивает — разрешение или вопрос с вариантами; строка ввода занята | +| закрашенный диск | `waiting` | ход закончен, строка ввода свободна | | кольцо | `busy` | работает | | тусклое пунктирное кольцо | `idle` | запущена, но ничего не просили | @@ -31,6 +31,11 @@ Code ждёт меня прямо сейчас?** законченная задача выглядит так же срочно, как заблокированная, — а именно это различие и решает, переключаться сейчас или после текущей мысли. +Дальше этого деление не идёт, и не по лени: запрос разрешения и вопрос с +вариантами приходят под одним и тем же `notification_type`, с одинаковым родовым +текстом `«Claude needs your permission»`. Различить их можно было бы только через +`PreToolUse` — ценой записи файла на каждый вызов инструмента. + Чипы идут по срочности, а внутри одного состояния первой стоит **самая давняя**: забывается та, что ждёт дольше всех, а не последняя. Время в состоянии показывается только у первого чипа: пять счётчиков рядом — это diff --git a/hooks/claude-status-hook.py b/hooks/claude-status-hook.py index 1c50337..21134c0 100755 --- a/hooks/claude-status-hook.py +++ b/hooks/claude-status-hook.py @@ -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 diff --git a/lib/glyph.js b/lib/glyph.js new file mode 100644 index 0000000..b1b473f --- /dev/null +++ b/lib/glyph.js @@ -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; + } +} diff --git a/lib/indicator.js b/lib/indicator.js index f659d9a..f4383b4 100644 --- a/lib/indicator.js +++ b/lib/indicator.js @@ -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; - // filled disc — turn finished, waiting for input; - // bright ring — working; - // dim dashed ring — idle. + // 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; + // 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(' · ')}`; } diff --git a/stylesheet.css b/stylesheet.css index e517cef..96a8c19 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -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; }