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

対象は macOS のみ。Linux はサポート対象外です (2026-08-28 決定 / issue 133)。 Linux でも動きそうに見える箇所がありますが、意図的な対応ではありません (例: scripts/tmux_extract_popup.sh は pbcopy を無条件で呼び、zshlib/_fs_helpers.zsh の mount パースは macOS の出力形式のみを前提にしています)。CI も macOS runner で回します。
cd ~
git clone git@github.com:jiikko/dotfiles.git || git clone https://github.com/jiikko/dotfiles.git
cd dotfiles
./setup.sh
docs/README.md が索引。触る前に読む制約 (glogx の bubbletea、テーマ色、tmux の セッション永続化)、glogx の画面の仕様、tmux 周りの仕組み、nvim の棚卸しがある。 自作ツールの使い方は src/README.md から各プロジェクトの README へ。
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 の冒頭にある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)。
C-t。ペイン・ウィンドウ操作、popup、ウィンドウ名、Claude Code の作業状態表示repair / av1ify (av1c) / concatkernel-alloc-watch / Finder Quick Actionssrc/README.md から各プロジェクトの README へhooks/register.tsx 90 lines1import { 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}
90hooks/ansi.ts 55 lines1import 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}
55hooks/input.ts 38 lines1// 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})
38types/index.d.ts 14 lines1export 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