Drive the zellij tab status icon from in-process, ordered Claude Code events

Nix Flakes + Home Manager で管理する dotfiles(x86_64-linux / WSL2)。
前提: Nix(flakes 有効化済み)
git clone git@github.com:hitsan/dotfiles.git
cd dotfiles
home-manager switch --flake .
# 設定を適用
home-manager switch --flake .
# ビルドのみ(適用しない)
home-manager build --flake .
# flake の更新
nix flake update
load_plugins でバックグラウンド起動されるため権限プロンプトが表示されず、 無許可のまま永久に無反応になることがある。新しいマシンでは以下を一度手動実行して許可する。
zellij action launch-plugin "https://github.com/dj95/zjstatus/releases/latest/download/zjstatus.wasm"
zellij action launch-plugin "https://github.com/b0o/zjstatus-hints/releases/latest/download/zjstatus-hints.wasm"
起動したフローティングペインで許可(y)すると ~/.cache/zellij/permissions.kdl に記録される。
必要な権限:
| plugin | permissions |
|---|---|
| zjstatus | RunCommands, ChangeApplicationState, ReadApplicationState |
| zjstatus-hints | ReadApplicationState, MessageAndLaunchOtherPlugins |
hooks/register.ts 62 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { IDLE, step } from './status'
4import type { Signal } from './status'
5
6const NEEDS_USER_NOTIFICATIONS = ['permission_prompt', 'elicitation_dialog', 'agent_needs_input']
7
8let status = IDLE
9let queue: Promise<unknown> = Promise.resolve()
10
11// Chained, not awaited: the tab script runs in event order without holding up the turn.
12// Headless runs (claude -p) inherit the parent's ZELLIJ_PANE_ID, so they must not touch its tab.
13function run($: EngineInterface, icon: string) {
14 queue = queue
15 .then(async () => {
16 if (!(await $.session.surfaces()).length) return
17 await $.process.run([`${$.plugin.root}/scripts/claude-tab-status.sh`, icon], { timeoutMs: 5000 })
18 })
19 .catch(() => undefined)
20}
21
22function signal($: EngineInterface, sig: Signal) {
23 const r = step(status, sig)
24 status = r.status
25 if (r.send) run($, r.send)
26}
27
28export const register: Register = on => {
29 on('turn.start', async ($, e, next) => {
30 signal($, { kind: 'turnStart' })
31 return next(e)
32 })
33
34 on('classic.PermissionRequest', async ($, e, next) => {
35 signal($, { kind: 'needsUser', agentId: e.agent_id })
36 return next(e)
37 })
38
39 on('classic.Notification', async ($, e, next) => {
40 if (NEEDS_USER_NOTIFICATIONS.includes(e.notification_type ?? '')) signal($, { kind: 'needsUser', agentId: e.agent_id })
41 return next(e)
42 })
43
44 on('tool.call', async ($, e, next) => {
45 if (e.tool === 'AskUserQuestion') signal($, { kind: 'needsUser', agentId: e.agentId })
46 const ran = await next(e)
47 signal($, { kind: 'toolDone', agentId: e.agentId })
48 return ran
49 })
50
51 on('turn.complete', async ($, e, next) => {
52 signal($, { kind: 'turnEnd', reason: e.reason, agentId: e.agentId })
53 return next(e)
54 })
55
56 on('session.end', async ($, e, next) => {
57 status = IDLE
58 run($, '-')
59 return next(e)
60 })
61}
62hooks/status.ts 44 lines1export type Icon = 'busy' | 'needsUser' | 'done' | 'failed'
2// waiting: loops held on the user, '' for the main loop.
3export type Status = { isActive: boolean; ended: 'done' | 'failed' | null; waiting: string[]; icon: Icon | null }
4export type Signal =
5 | { kind: 'turnStart' }
6 | { kind: 'needsUser'; agentId?: string }
7 | { kind: 'toolDone'; agentId?: string }
8 | { kind: 'turnEnd'; reason: string; agentId?: string }
9
10export const IDLE: Status = { isActive: false, ended: null, waiting: [], icon: null }
11
12const GLYPH: Record<Icon, string> = {
13 busy: '⏳',
14 needsUser: '🔔',
15 done: '✅',
16 failed: '✗',
17}
18
19const iconOf = (s: Omit<Status, 'icon'>): Icon | null =>
20 s.waiting.length ? 'needsUser' : s.isActive ? 'busy' : s.ended
21
22// ponytail: waits are keyed per loop, so a parallel tool finishing in the same loop clears its prompt early; key by tool_use_id if that shows up.
23const next = (s: Status, sig: Signal): Omit<Status, 'icon'> => {
24 const loop = ('agentId' in sig && sig.agentId) || ''
25 const waiting = s.waiting.filter(w => w !== loop)
26 switch (sig.kind) {
27 case 'turnStart':
28 return { ...s, isActive: true }
29 case 'needsUser':
30 return { ...s, waiting: [...waiting, loop] }
31 case 'toolDone':
32 return { ...s, waiting }
33 case 'turnEnd':
34 if (loop) return { ...s, waiting }
35 return { ...s, waiting, isActive: false, ended: sig.reason === 'error' ? 'failed' : 'done' }
36 }
37}
38
39export const step = (s: Status, sig: Signal): { status: Status; send?: string } => {
40 const n = next(s, sig)
41 const icon = iconOf(n)
42 return { status: { ...n, icon }, send: icon && icon !== s.icon ? GLYPH[icon] : undefined }
43}
44