SLOPSHOPPER

zellij-status

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

newguardprocess
A shopper browsing a rack in a slop shop
README

dotfiles

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

Tips

zellij: zjstatus / zjstatus-hints の権限

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 に記録される。

必要な権限:

pluginpermissions
zjstatusRunCommands, ChangeApplicationState, ReadApplicationState
zjstatus-hintsReadApplicationState, MessageAndLaunchOtherPlugins
Source 2 files
hooks/register.ts 62 lines
1import 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}
62
hooks/status.ts 44 lines
1export 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