Merge "idle" into "waiting"
"Idle" was set by SessionStart and by nothing else, and there was no path back into it. So it never meant "sitting unused" -- it meant "opened and never asked anything yet", a state a few seconds long that you would almost never catch. Meanwhile a session that finished an hour ago and was forgotten showed as waiting, which is correct but leaves the fourth state with nothing to describe. A session that has just opened is waiting for your first prompt exactly as one that finished a turn is waiting for your next. They are the same thing, and now they are the same state. Three glyphs instead of four, which also gives the remaining three more room to be told apart in a monochrome panel. Files written by the previous hook still say "idle", and a session open across the upgrade must not disappear, so unrecognised states now read as waiting rather than being treated as unknown. Covered by a test that feeds an "idle" file to the store and asserts it comes back as waiting, sorted by age among the others. The "hide when nothing is running" setting goes with it. Its condition was "no sessions, or all of them idle"; with idle gone the second half is unreachable and the first was already unconditional, so the switch could no longer change anything. A control that does nothing is worse than no control. The compaction test also got stronger in passing: it now checks that a mid-turn SessionStart leaves a *busy* session alone, which is the case that matters. It used to assert from waiting, where the state it was guarding against happened to be the state already stored.
This commit is contained in:
@@ -10,12 +10,12 @@ Code ждёт меня прямо сейчас?**
|
|||||||
имя проекта:
|
имя проекта:
|
||||||
|
|
||||||
```
|
```
|
||||||
◉ ds ● dc ● ds2 ○ pps ◌ umb 9:41
|
◉ ds ● dc ● ds2 ○ pps ○ umb 9:41
|
||||||
│ │ │ │ └ umbar, тихо
|
│ │ │ │ └ umbar, работает
|
||||||
│ │ │ └ pet-project-server, работает
|
│ │ │ └ pet-project-server, работает
|
||||||
│ │ └ вторая сессия в dev-skills, ждёт
|
│ │ └ вторая сессия в dev-skills, ждёт
|
||||||
│ └ dev-conventions, ждёт
|
│ └ dev-conventions, ждёт
|
||||||
└ dev-skills, упёрлась в разрешение
|
└ dev-skills, спрашивает
|
||||||
```
|
```
|
||||||
|
|
||||||
## Состояния
|
## Состояния
|
||||||
@@ -25,12 +25,18 @@ Code ждёт меня прямо сейчас?**
|
|||||||
| диск в кольце | `blocked` | спрашивает — разрешение или вопрос с вариантами; строка ввода занята |
|
| диск в кольце | `blocked` | спрашивает — разрешение или вопрос с вариантами; строка ввода занята |
|
||||||
| закрашенный диск | `waiting` | ход закончен, строка ввода свободна |
|
| закрашенный диск | `waiting` | ход закончен, строка ввода свободна |
|
||||||
| кольцо | `busy` | работает |
|
| кольцо | `busy` | работает |
|
||||||
| тусклое пунктирное кольцо | `idle` | запущена, но ничего не просили |
|
|
||||||
|
|
||||||
`blocked` и `waiting` разведены намеренно. Слитые в одно «требует внимания»,
|
`blocked` и `waiting` разведены намеренно. Слитые в одно «требует внимания»,
|
||||||
законченная задача выглядит так же срочно, как заблокированная, — а именно это
|
законченная задача выглядит так же срочно, как заблокированная, — а именно это
|
||||||
различие и решает, переключаться сейчас или после текущей мысли.
|
различие и решает, переключаться сейчас или после текущей мысли.
|
||||||
|
|
||||||
|
Состояний ровно три. Четвёртое, «тихо», было и убрано: оно ставилось только
|
||||||
|
событием `SessionStart` и в него не было возврата, так что означало не «давно
|
||||||
|
без дела», а «сессию открыли и ещё ни разу ничего не спросили» — состояние
|
||||||
|
длиной в несколько секунд, занимавшее форму в панели. Свежая сессия ждёт
|
||||||
|
первого промпта ровно так же, как доделавшая ход ждёт следующего, и теперь обе
|
||||||
|
называются `waiting`.
|
||||||
|
|
||||||
Дальше этого деление не идёт, и не по лени: запрос разрешения и вопрос с
|
Дальше этого деление не идёт, и не по лени: запрос разрешения и вопрос с
|
||||||
вариантами приходят под одним и тем же `notification_type`, с одинаковым родовым
|
вариантами приходят под одним и тем же `notification_type`, с одинаковым родовым
|
||||||
текстом `«Claude needs your permission»`. Различить их можно было бы только через
|
текстом `«Claude needs your permission»`. Различить их можно было бы только через
|
||||||
@@ -112,7 +118,7 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
|
|||||||
|
|
||||||
| Хук | Действие |
|
| Хук | Действие |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `SessionStart` | сессия появляется как `idle`, мёртвые подчищаются |
|
| `SessionStart` | сессия появляется как `waiting`, мёртвые подчищаются |
|
||||||
| `UserPromptSubmit` | `busy` |
|
| `UserPromptSubmit` | `busy` |
|
||||||
| `PostToolUse` | `busy` |
|
| `PostToolUse` | `busy` |
|
||||||
| `PreCompact` | `busy` |
|
| `PreCompact` | `busy` |
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ NOTIFICATION_STATES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EVENT_STATES = {
|
EVENT_STATES = {
|
||||||
"SessionStart": "idle",
|
# A session that has just opened is waiting for your first prompt, which is
|
||||||
|
# the same thing as one that has finished a turn: the input line is free
|
||||||
|
# and the next move is yours. It had a state of its own once; it was only
|
||||||
|
# ever reachable before the first prompt, so it bought a fourth glyph in
|
||||||
|
# the panel that nobody saw.
|
||||||
|
"SessionStart": "waiting",
|
||||||
"UserPromptSubmit": "busy",
|
"UserPromptSubmit": "busy",
|
||||||
"PreCompact": "busy",
|
"PreCompact": "busy",
|
||||||
"PostToolUse": "busy",
|
"PostToolUse": "busy",
|
||||||
|
|||||||
+4
-3
@@ -42,9 +42,10 @@ export function drawState(cr, state, width, height, color) {
|
|||||||
cr.stroke();
|
cr.stroke();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Idle: same ring as "working", but dashed and dimmed. Reading as a
|
// Fallback for a state this build does not know, which should not
|
||||||
// fainter version of a state you already know is the point -- nobody
|
// happen: sessions.js normalises anything unrecognised to "waiting".
|
||||||
// is waiting on it.
|
// Drawn dashed and dim so an unknown state looks uncertain rather than
|
||||||
|
// impersonating one of the three real ones.
|
||||||
cr.setDash([2, 2], 0);
|
cr.setDash([2, 2], 0);
|
||||||
cr.setSourceRGBA(r, g, b, a * 0.5);
|
cr.setSourceRGBA(r, g, b, a * 0.5);
|
||||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||||
|
|||||||
+7
-9
@@ -23,7 +23,6 @@ function stateLabel(state) {
|
|||||||
case 'blocked': return _('needs an answer');
|
case 'blocked': return _('needs an answer');
|
||||||
case 'waiting': return _('waiting for input');
|
case 'waiting': return _('waiting for input');
|
||||||
case 'busy': return _('working');
|
case 'busy': return _('working');
|
||||||
case 'idle': return _('idle');
|
|
||||||
default: return state;
|
default: return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,10 +92,9 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The panel is monochrome, so shape alone carries the state:
|
// The panel is monochrome, so shape alone carries the state:
|
||||||
// disc inside a ring — blocked on a permission prompt;
|
// disc inside a ring — asking you something;
|
||||||
// filled disc — turn finished, waiting for input;
|
// filled disc — input line free, your move;
|
||||||
// ring — working;
|
// ring — working.
|
||||||
// dashed, dimmed — idle.
|
|
||||||
// Colour is left to the shell's own accents; an indicator that paints
|
// Colour is left to the shell's own accents; an indicator that paints
|
||||||
// its own red competes with them and stops matching the theme.
|
// its own red competes with them and stops matching the theme.
|
||||||
const dot = new St.DrawingArea({
|
const dot = new St.DrawingArea({
|
||||||
@@ -164,7 +162,6 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_updatePanel(sessions) {
|
_updatePanel(sessions) {
|
||||||
const hideWhenIdle = this._settings.get_boolean('hide-when-idle');
|
|
||||||
const showAge = this._settings.get_boolean('show-age');
|
const showAge = this._settings.get_boolean('show-age');
|
||||||
const abbreviate = this._settings.get_boolean('abbreviate-names');
|
const abbreviate = this._settings.get_boolean('abbreviate-names');
|
||||||
|
|
||||||
@@ -180,13 +177,14 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
? this._chipLabels.get(session.sessionId)
|
? this._chipLabels.get(session.sessionId)
|
||||||
: projectName(session.cwd);
|
: projectName(session.cwd);
|
||||||
|
|
||||||
const busy = sessions.some(s => s.state !== 'idle');
|
// Nothing running means nothing to say; the button disappears rather
|
||||||
this.visible = sessions.length > 0 && !(hideWhenIdle && !busy);
|
// than sitting there empty.
|
||||||
|
this.visible = sessions.length > 0;
|
||||||
|
|
||||||
// Age rides on the first chip only. Sessions are sorted by urgency, so
|
// 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
|
// that is the one whose age decides anything; five ages side by side
|
||||||
// would just be a wide row of numbers.
|
// would just be a wide row of numbers.
|
||||||
const ageOnFirst = showAge && busy;
|
const ageOnFirst = showAge && sessions.length > 0;
|
||||||
const signature = sessions
|
const signature = sessions
|
||||||
.map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`)
|
.map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`)
|
||||||
.join('|') + `|${ageOnFirst}`;
|
.join('|') + `|${ageOnFirst}`;
|
||||||
|
|||||||
+5
-2
@@ -16,7 +16,7 @@ Gio._promisify(Gio.File.prototype, 'load_contents_async');
|
|||||||
|
|
||||||
// Aggregation order: a session blocked on a permission prompt is the only one
|
// 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.
|
// that is actually stuck, so it outranks one that merely finished its turn.
|
||||||
export const STATES = ['blocked', 'waiting', 'busy', 'idle'];
|
export const STATES = ['blocked', 'waiting', 'busy'];
|
||||||
|
|
||||||
const KNOWN = new Set(STATES);
|
const KNOWN = new Set(STATES);
|
||||||
const LIVENESS_INTERVAL = 20; // seconds
|
const LIVENESS_INTERVAL = 20; // seconds
|
||||||
@@ -200,7 +200,10 @@ export const SessionStore = GObject.registerClass({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = KNOWN.has(raw.state) ? raw.state : 'idle';
|
// Anything unrecognised reads as "waiting", which also migrates files
|
||||||
|
// left on disk by the older hook: those still say "idle", and a
|
||||||
|
// session open since before the upgrade must not vanish from the panel.
|
||||||
|
const state = KNOWN.has(raw.state) ? raw.state : 'waiting';
|
||||||
return {
|
return {
|
||||||
sessionId: String(raw.session_id ?? name.replace(/\.json$/, '')),
|
sessionId: String(raw.session_id ?? name.replace(/\.json$/, '')),
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
|
|||||||
dispGroup.add(this._switchRow(settings, 'show-age',
|
dispGroup.add(this._switchRow(settings, 'show-age',
|
||||||
_('Show time in state'),
|
_('Show time in state'),
|
||||||
_('Shown on the most urgent session only.')));
|
_('Shown on the most urgent session only.')));
|
||||||
dispGroup.add(this._switchRow(settings, 'hide-when-idle',
|
|
||||||
_('Hide when nothing is running'),
|
|
||||||
_('Remove the indicator from the panel while no session is active.')));
|
|
||||||
|
|
||||||
const zellijGroup = new Adw.PreferencesGroup({
|
const zellijGroup = new Adw.PreferencesGroup({
|
||||||
title: _('zellij'),
|
title: _('zellij'),
|
||||||
|
|||||||
@@ -17,11 +17,6 @@
|
|||||||
<summary>Show how long the session has been in this state</summary>
|
<summary>Show how long the session has been in this state</summary>
|
||||||
<description>How long a session has been working, or waiting, is what decides whether to switch to it now or leave it running.</description>
|
<description>How long a session has been working, or waiting, is what decides whether to switch to it now or leave it running.</description>
|
||||||
</key>
|
</key>
|
||||||
<key name="hide-when-idle" type="b">
|
|
||||||
<default>false</default>
|
|
||||||
<summary>Hide the indicator when nothing is running</summary>
|
|
||||||
<description>Remove the indicator from the panel while there are no sessions, or all of them are idle.</description>
|
|
||||||
</key>
|
|
||||||
<key name="zellij-integration" type="b">
|
<key name="zellij-integration" type="b">
|
||||||
<default>true</default>
|
<default>true</default>
|
||||||
<summary>Resolve zellij tab names</summary>
|
<summary>Resolve zellij tab names</summary>
|
||||||
|
|||||||
+1
-8
@@ -1,10 +1,7 @@
|
|||||||
/* The panel stays monochrome: every glyph is drawn in the panel's own text
|
/* 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
|
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
|
accent colours keep meaning what they mean. State is carried by shape, which
|
||||||
also survives a monochrome theme and colour vision deficiency.
|
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 {
|
.ccs-panel-box {
|
||||||
spacing: 10px;
|
spacing: 10px;
|
||||||
@@ -37,10 +34,6 @@
|
|||||||
font-feature-settings: "tnum";
|
font-feature-settings: "tnum";
|
||||||
}
|
}
|
||||||
|
|
||||||
.ccs-idle {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ccs-summary {
|
.ccs-summary {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -34,7 +34,7 @@ ev() { # event [extra json]
|
|||||||
|
|
||||||
# --- state machine ---------------------------------------------------------
|
# --- state machine ---------------------------------------------------------
|
||||||
emit "$(ev SessionStart '"source":"startup"')"
|
emit "$(ev SessionStart '"source":"startup"')"
|
||||||
check "SessionStart -> idle" "idle" "$(field state)"
|
check "SessionStart -> waiting" "waiting" "$(field state)"
|
||||||
|
|
||||||
emit "$(ev UserPromptSubmit '"prompt":"hi"')"
|
emit "$(ev UserPromptSubmit '"prompt":"hi"')"
|
||||||
check "UserPromptSubmit -> busy" "busy" "$(field state)"
|
check "UserPromptSubmit -> busy" "busy" "$(field state)"
|
||||||
@@ -63,9 +63,12 @@ check "idle_prompt while waiting does not rewrite" "$before" "$(field event_ts)"
|
|||||||
emit "$(ev Stop '"agent_id":"sub-1"')"
|
emit "$(ev Stop '"agent_id":"sub-1"')"
|
||||||
check "subagent Stop ignored" "waiting" "$(field state)"
|
check "subagent Stop ignored" "waiting" "$(field state)"
|
||||||
|
|
||||||
|
# Deliberately checked from "busy": compaction raises SessionStart mid-turn,
|
||||||
|
# and taking it at face value would drop a working session back to waiting.
|
||||||
|
emit "$(ev UserPromptSubmit '"prompt":"go"')"
|
||||||
since_before=$(field since)
|
since_before=$(field since)
|
||||||
emit "$(ev SessionStart '"source":"compact"')"
|
emit "$(ev SessionStart '"source":"compact"')"
|
||||||
check "compaction does not reset a live session" "waiting" "$(field state)"
|
check "compaction does not reset a live session" "busy" "$(field state)"
|
||||||
check "compaction does not reset the clock" "$since_before" "$(field since)"
|
check "compaction does not reset the clock" "$since_before" "$(field since)"
|
||||||
|
|
||||||
# --- concurrency -----------------------------------------------------------
|
# --- concurrency -----------------------------------------------------------
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ write('s-blocked', 'blocked', '/home/u/proj-blocked', 10, livePid);
|
|||||||
write('s-wait-new', 'waiting', '/home/u/proj-new', 60, livePid);
|
write('s-wait-new', 'waiting', '/home/u/proj-new', 60, livePid);
|
||||||
write('s-wait-old', 'waiting', '/home/u/proj-old', 3600, livePid);
|
write('s-wait-old', 'waiting', '/home/u/proj-old', 3600, livePid);
|
||||||
write('s-dead', 'waiting', '/home/u/proj-dead', 5, deadPid);
|
write('s-dead', 'waiting', '/home/u/proj-dead', 5, deadPid);
|
||||||
write('s-idle', 'idle', '/home/u/proj-idle', 5, livePid);
|
// Written by the older hook, which had a fourth state. Files like this
|
||||||
|
// survive an upgrade in a session that was already open.
|
||||||
|
write('s-legacy', 'idle', '/home/u/proj-legacy', 5, livePid);
|
||||||
GLib.file_set_contents(GLib.build_filenamev([STATE, 'notes.txt']), 'ignored');
|
GLib.file_set_contents(GLib.build_filenamev([STATE, 'notes.txt']), 'ignored');
|
||||||
GLib.file_set_contents(GLib.build_filenamev([STATE, 'half.json']), '{"broken');
|
GLib.file_set_contents(GLib.build_filenamev([STATE, 'half.json']), '{"broken');
|
||||||
|
|
||||||
@@ -73,8 +75,12 @@ store.connect('changed', () => {
|
|||||||
check('longest wait precedes newer wait',
|
check('longest wait precedes newer wait',
|
||||||
s[1]?.sessionId === 's-wait-old' && s[2]?.sessionId === 's-wait-new',
|
s[1]?.sessionId === 's-wait-old' && s[2]?.sessionId === 's-wait-new',
|
||||||
`${s[1]?.sessionId}, ${s[2]?.sessionId}`);
|
`${s[1]?.sessionId}, ${s[2]?.sessionId}`);
|
||||||
check('busy after waiting', s[3]?.sessionId === 's-busy', s[3]?.sessionId);
|
const legacy = s.find(x => x.sessionId === 's-legacy');
|
||||||
check('idle last', s[4]?.sessionId === 's-idle', s[4]?.sessionId);
|
check('a retired state reads as waiting, not dropped',
|
||||||
|
legacy?.state === 'waiting', legacy?.state);
|
||||||
|
check('and sorts by age among the waiting ones',
|
||||||
|
s[3]?.sessionId === 's-legacy', s[3]?.sessionId);
|
||||||
|
check('busy after waiting', s[4]?.sessionId === 's-busy', s[4]?.sessionId);
|
||||||
|
|
||||||
check('dead session file removed from disk',
|
check('dead session file removed from disk',
|
||||||
!GLib.file_test(GLib.build_filenamev([STATE, 's-dead.json']), GLib.FileTest.EXISTS));
|
!GLib.file_test(GLib.build_filenamev([STATE, 's-dead.json']), GLib.FileTest.EXISTS));
|
||||||
|
|||||||
Reference in New Issue
Block a user