SLOPSHOPPER

desktop-statusline

Claude desktop の Code タブに、CLI と同じステータスバー (statusline-command.sh の出力) を色つきで出す (issue 625)

newbandcommandprocesstimer
★ 2v0.1.0no licenseupdated 2026-10-02jiikko/dotfiles/_claude/mods/desktop-statusline
A shopper browsing a rack in a slop shop
README

dotfiles

対象は macOS のみ。Linux はサポート対象外です (2026-08-28 決定 / issue 133)。 Linux でも動きそうに見える箇所がありますが、意図的な対応ではありません (例: scripts/tmux_extract_popup.sh は pbcopy を無条件で呼び、zshlib/_fs_helpers.zsh の mount パースは macOS の出力形式のみを前提にしています)。CI も macOS runner で回します。

Installing

cd ~
git clone git@github.com:jiikko/dotfiles.git || git clone https://github.com/jiikko/dotfiles.git
cd dotfiles
./setup.sh

for Mac

設計文書・仕様・調査記録

docs/README.md が索引。触る前に読む制約 (glogx の bubbletea、テーマ色、tmux の セッション永続化)、glogx の画面の仕様、tmux 周りの仕組み、nvim の棚卸しがある。 自作ツールの使い方は src/README.md から各プロジェクトの README へ。

Git hooks

githooks/ を setup.sh が core.hooksPath に設定する。

  • pre-commit: ステージした差分に、成人向けを匂わせる語や作品番号の書式がないかを civility-lint (dotfiles の外にある private repo のツール) で検査する。本体が無いマシンでは警告だけ出して通す。 誤検出を 1 行だけ通すなら、その行に civility-lint:ignore を書く
  • pre-push: issues/ を触る push のときだけ、push する commit を展開して issue の整合検査 (番号の一意性・相対リンク・next の目印など tests/issues/ の 6 本。数秒) を回し、落ちたら止める。 今回の push が壊したものでなくても止まる。回す検査と外す検査の一覧は hook の冒頭にある

Testing

Run the regression test suite (Neovim, tmux, setup.sh, plus existing zsh tests) with:

make test runs lint and the tests all the way through and reports every failure at the end; a lint failure no longer stops the tests from running. Use make test-lint when you only want lint.


make test

You can run individual checks as well:

make test-syntax # zsh/zlogin/setup.sh syntax checks + tmux/nvim smoke
make test-nvim   # verifies Neovim config loads and lazy.nvim is reachable
make test-tmux   # ensures _tmux.conf can boot a tmux server (skips if tmux sockets are disallowed)
make test-setup  # exercises setup.sh in a temporary HOME
make test-shellcheck # runs shellcheck on shell-compatible scripts
make test-yaml   # yamllint on workflow/pre-commit config
make test-json   # jq validation for JSON configs
make test-lint   # aggregate lint target (shellcheck + zsh syntax + YAML + JSON + karabiner + actionlint + gitconfig + ruby syntax + the repo-wide scripts/check_*.sh gates; full list in the Makefile)
make test-src    # lint + unused check + test for all Go projects under src/ (same coverage as CI's src_*.yml)
make test-runtime # aggregate runtime target (syntax + auto-discovered tests/**/test_*.sh + bats)
make test-bats   # bats tests (skips if bats is not installed)
tests/zshrc/test_zshrc.sh  # existing zsh tests (also run via make test)

ツールの使い方

自作ツールの使い方は docs/tools/ に置いている (索引は docs/README.md)。

  • tmux のキーと表示 — prefix は C-t。ペイン・ウィンドウ操作、popup、ウィンドウ名、Claude Code の作業状態表示
  • 動画のシェル関数 — repair / av1ify (av1c) / concat
  • macOS 連携 — Karabiner-Elements / kernel-alloc-watch / Finder Quick Actions
  • Go で書いた自作ツール (glogx・pro-con など) — src/README.md から各プロジェクトの README へ
Source 4 files
hooks/register.tsx 90 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface } from 'claude-code'
3import { parseAnsi } from './ansi'
4import { statusInput } from './input'
5
6// Claude desktop の Code タブは settings の statusLine を実行しない (issue 625 で観測) ので、CLI と同じステータスバーを mod で描く。
7// 中身は CLI と同じ _claude/statusline-command.sh の出力 (表示の正本は script 1 つ。色の閾値も script が決め、ここは ANSI を読むだけ)。
8// desktop の surface にだけ描き、terminal では描かない (CLI は statusLine が描くので二重に出さない)。
9// script は描画の中では呼ばない: セッションの開始・メインのターンの終わり・60 秒ごと (CLI の refreshInterval と同じ) に呼んで $.state に置く。
10// mod が読み込まれない・script が失敗したときは、帯が出ない / 理由の 1 行が出る (CLI の statusLine には影響しない)。
11//
12// 「desktop のセッションか」は $.session.surfaces() でなく、起動時に決まる環境変数で聞く。desktop が起こすセッションは `-p` / SDK と同じ
13// 作りで、型定義 (SessionStartInput / session.attach) によると session.start の時点では surface が null・名簿が空で、desktop は
14// 最初の描画の要求で名簿に載る。名簿で聞くと、session.start の refresh も 60 秒ごとの timer も起動されず、帯は一度も作られない
15// (issue 625 / 628 で「desktop に何も出ない」が続いた原因の最有力。desktop 実機の session.start の surface は未観測)。
16// 描くかどうかは描画のたびに e.surface で決める (名簿でなく、描く先そのもの)。
17
18const lines = atom({ plugin: 'desktop-statusline', key: 'lines' } as const, null)
19const error = atom({ plugin: 'desktop-statusline', key: 'error' } as const, null)
20
21const isDesktopHosted = async ($: EngineInterface) => (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-desktop'
22
23const refresh = async ($: EngineInterface) => {
24  try {
25    const [cwd, modelId, usage, sessionId, effort] = await Promise.all([
26      $.session.cwd(),
27      $.session.model(),
28      $.session.usage(),
29      $.session.id(),
30      $.env.get('CLAUDE_EFFORT'),
31    ])
32    const stdin = JSON.stringify(statusInput({ cwd, modelId, usage, effort, sessionId }))
33    const r = await $.process.run([`${$.plugin.root}/../../statusline-command.sh`], { cwd, stdin, timeoutMs: 10_000 })
34    if (r.exitCode !== 0) throw new Error(`rc=${r.exitCode} ${r.stderr.trim().slice(0, 80)}`)
35    if (r.stdout.trim() === '') throw new Error('script の出力が空')
36    const parsed = parseAnsi(r.stdout)
37    await update($, lines, () => parsed)
38    await update($, error, () => null)
39  } catch (err) {
40    await update($, error, () => String(err).slice(0, 160))
41  }
42}
43
44export const register: Register = on => {
45  on('session.start', async ($, e, next) => {
46    const started = await next(e)
47    if (await isDesktopHosted($)) {
48      await $.command.register({ name: 'statusline-refresh', description: 'ステータスバーを今すぐ作り直す', immediate: true })
49      await refresh($)
50      $.clock.every(60_000, () => refresh($))
51    }
52    return started
53  })
54
55  // 返信やタイマーを待たずに作り直す手段。作り直した結果 (成功 / 失敗の理由) をそのまま出力に返す
56  on('command.run', { command: 'statusline-refresh' }, async $ => {
57    await refresh($)
58    const err = await read($, error)
59    return { text: err === null ? 'ステータスバーを作り直した' : `ステータスバーを作れない: ${err}` }
60  })
61
62  on('turn.complete', async ($, e, next) => {
63    const done = await next(e)
64    if (e.agentId === undefined && (await isDesktopHosted($))) void refresh($)
65    return done
66  })
67
68  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
69    if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
70    const ls = await read($, lines)
71    const err = await read($, error)
72    if (ls === null && err === null) return next(e)
73    const { Box, Text } = $.ui.resolve(e)
74    return (
75      <Box flexDirection="column">
76        {(ls ?? []).map((segs, i) => (
77          <Text key={`line${i}`}>
78            {segs.map((s, j) => (
79              <Text key={`s${i}-${j}`} color={s.color} backgroundColor={s.backgroundColor} bold={s.bold} underline={s.underline}>
80                {s.text}
81              </Text>
82            ))}
83          </Text>
84        ))}
85        {err !== null ? <Text key="error" dimColor>{`ステータスバーを作れない: ${err}`}</Text> : null}
86      </Box>
87    )
88  })
89}
90
hooks/ansi.ts 55 lines
1import type { DesktopStatuslineSegment as Segment } from '../types'
2
3// statusline-command.sh が使う SGR だけを desktop の文字の属性へ置き換える (色の閾値は script が決める。ここは写さない)。
4// 色は端末の 16 色を hex にした値 (desktop は CSS の色を受ける。名前の色は明暗の差が大きいので使わない)
5const FG: Record<number, string> = {
6  30: '#2d333b', 31: '#e5534b', 32: '#57ab5a', 33: '#c69026', 34: '#539bf5', 35: '#b083f0', 36: '#39c5cf', 37: '#cdd9e5',
7  90: '#768390', 91: '#f47067', 92: '#6bc46d', 93: '#daaa3f', 94: '#6cb6ff', 95: '#dcbdfb', 96: '#56d4dd', 97: '#ffffff',
8}
9const BG: Record<number, string> = {
10  40: '#2d333b', 41: '#e5534b', 42: '#57ab5a', 43: '#c69026', 44: '#539bf5', 45: '#b083f0', 46: '#39c5cf', 47: '#cdd9e5',
11}
12
13type Style = Omit<Segment, 'text'>
14
15const apply = (style: Style, codes: number[]): Style => {
16  let s: Style = { ...style }
17  if (codes.length === 0) codes = [0]
18  for (const c of codes) {
19    if (c === 0) s = {}
20    else if (c === 1) s.bold = true
21    else if (c === 4) s.underline = true
22    else if (c === 22) delete s.bold
23    else if (c === 24) delete s.underline
24    else if (c === 39) delete s.color
25    else if (c === 49) delete s.backgroundColor
26    else if (FG[c]) s.color = FG[c]
27    else if (BG[c]) s.backgroundColor = BG[c]
28    // 5 (点滅) などは desktop では表せないので捨てる
29  }
30  return s
31}
32
33// parseAnsi: script の出力を行ごとの区切り (同じ属性の連続) に分ける。空の区切りは作らない
34export const parseAnsi = (out: string): Segment[][] => {
35  const lines: Segment[][] = []
36  let style: Style = {}
37  for (const raw of out.replace(/\n+$/, '').split('\n')) {
38    const line: Segment[] = []
39    const re = /\x1b\[([0-9;]*)m/g
40    let last = 0
41    let m: RegExpExecArray | null
42    const push = (text: string) => {
43      if (text !== '') line.push({ text, ...style })
44    }
45    while ((m = re.exec(raw)) !== null) {
46      push(raw.slice(last, m.index))
47      style = apply(style, m[1] === '' ? [] : m[1].split(';').map(Number))
48      last = re.lastIndex
49    }
50    push(raw.slice(last))
51    lines.push(line)
52  }
53  return lines
54}
55
hooks/input.ts 38 lines
1// CLI の statusLine が script に渡す JSON と同じ形を、mod から取れる値で組む。script が読むのは
2// workspace.current_dir / model.display_name / rate_limits.{five_hour,seven_day}.{used_percentage,resets_at (エポック秒)} /
3// context_window.{total_input_tokens,context_window_size,used_percentage} / effort.level / transcript_path / session_id (_claude/statusline-command.sh)
4export type UsageLike = {
5  context: { tokens?: number; window: number; percent?: number }
6  rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
7}
8
9// claude-opus-5-5 → Opus 5.5 (CLI の display_name の形)。読めない id はそのまま
10export const displayName = (id: string): string => {
11  const m = /^claude-([a-z]+)-(\d+)(?:-(\d+))?/.exec(id)
12  if (!m) return id
13  const family = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1)
14  return `${family} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
15}
16
17const window = (u: UsageLike, kind: string) => {
18  const w = u.rateLimits.find(r => r.kind === kind)
19  if (!w) return undefined
20  const at = w.resetsAt ? Math.floor(Date.parse(w.resetsAt) / 1000) : undefined
21  return { used_percentage: w.percentUsed, ...(at !== undefined && !Number.isNaN(at) ? { resets_at: at } : {}) }
22}
23
24export const statusInput = (a: { cwd: string; modelId: string; usage: UsageLike; effort?: string; sessionId: string }) => ({
25  cwd: a.cwd,
26  workspace: { current_dir: a.cwd },
27  model: { display_name: displayName(a.modelId) },
28  rate_limits: { five_hour: window(a.usage, 'five_hour'), seven_day: window(a.usage, 'seven_day') },
29  context_window: {
30    total_input_tokens: a.usage.context.tokens,
31    context_window_size: a.usage.context.window,
32    used_percentage: a.usage.context.percent,
33  },
34  ...(a.effort ? { effort: { level: a.effort } } : {}),
35  session_id: a.sessionId,
36  transcript_path: '',
37})
38
types/index.d.ts 14 lines
1export type DesktopStatuslineSegment = {
2  text: string
3  color?: string
4  backgroundColor?: string
5  bold?: boolean
6  underline?: boolean
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'desktop-statusline': { lines: DesktopStatuslineSegment[][] | null; error: string | null }
12  }
13}
14