SLOPSHOPPER

desktop-cockpit

The cc-cockpit mods for Claude Code Desktop: a stats band above the prompt, a welcome card, and a /cockpit pane with usage, project and your messages

newpanebandrowscommandtoast
v0.1.0no licenseupdated 2026-10-22JunWang666/cc-cockpit/mods/desktop-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desktop-cockpit
│ ┃ 驾驶舱 ✕ › fix the failing auth test and add an audit log call │ ┃ [ 用量 ][ 项目 ][ 我的消息 ] │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │轮次 │ ⎿ Read 6 lines │ ┃ │1 │ ⏺ Update(src/auth.ts) │ ┃ │本会话的第几轮 │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ╭─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │额度 │ ┃ │ 31% ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 5 小时额度 │ ┃ │ 当 ✻ Worked for 42s · done 4:20 PM │ ┃ ╰─────────────────────────────────────────── │ › /cockpit │ ⎿ desktop-cockpit: 已打开驾驶舱。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 驾驶舱
[ 用量 ][ 项目 ][ 我的消息 ] ╭──────────────────────────────────────────────────────────╮ │轮次 │ │1 │ │本会话的第几轮 │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │额度 │ 31% │ 5 小时额度 │ 当 ╰───────────────────────────────────────────────────────────
README

cc-cockpit

Claude Code mods for a terminal cockpit: a stats band above the prompt and quick navigation through your own messages. Needs Claude Code 2.1.287 or newer.

ModWhat it does
quota-bandA divider between the transcript and the prompt, and one row under it: turn number, output speed (tok/s), turn and session cost, prompt-cache countdown, 5-hour and weekly quota with reset times, and context usage.
welcomeWhen a new session starts, a card above the prompt shows the project's branch, uncommitted changes, ahead/behind, the last three commits, a one-line gist of README.md / AGENTS.md / CLAUDE.md (press one to read its opening lines), what the last session did (its recap and the files it edited) and the project's five most recent sessions; press a digit in the empty prompt to resume one. It hides on your first message; /welcome brings it back.
desktop-cockpitThe three mods above, drawn for Claude Code Desktop. Above the prompt: the stats row (turn, tok/s, cost, cache minutes left, quota and context as bars) and, in a new session, the welcome card. `/cockpit [usage\project\prompts] [keyword]` (or the 驾驶舱 › button) opens a pane with three tabs: usage with reset times, the project's git state, docs and last session, and your messages, each one click to scroll to. On desktop, quota-band and welcome leave the band to it; in a terminal it draws nothing above the prompt. Needs no Python.
auto-continue`/auto-continue on [1d\12h\90m] turns on auto-continue for the session (off turns it off, no argument shows its state). When a turn stops because the weekly (or 5-hour) quota is used up, it waits for that window to reset and then sends a prompt to carry the work on — but only if the reset comes within the wait you allowed, counted from when the quota ran out (24 hours by default). While waiting, the status line shows when it will continue and the prompt footer shows auto-continue. It also continues at a set time: /auto-continue at 14:30 [message] (today, or tomorrow once passed), at 10/22 09:00 [message] or in 2h [message] sends the message (continue by default) then; cancel drops it. Options: on by default, the longest wait, the prompts it sends, and whether to wait for the weekly quota only. Works in the terminal and the desktop app. With quota-band or desktop-cockpit installed, its state also shows in their stats row beside the quota (⏳ 10:01 自动续, ⏰ 14:30 定时续, ⟳ 自动续), and desktop-cockpit's usage tab has a picker for the longest wait (or off), a field for the message to send, a picker of set times (30 minutes to 5 hours, the next 9:00), a field for any time (14:30, 10/22 09:00, 2h`) and a button to cancel it.
prompt-jump/prompts lists the messages you sent in this session; press a number to scroll the transcript to one. 0 or /bottom jumps back to the end. Scrolling needs the fullscreen layout (/tui fullscreen).

Install

/plugin install quota-band --marketplace JunWang666/cc-cockpit
/plugin install prompt-jump --marketplace JunWang666/cc-cockpit
/plugin install welcome --marketplace JunWang666/cc-cockpit
/plugin install desktop-cockpit --marketplace JunWang666/cc-cockpit
/plugin install auto-continue --marketplace JunWang666/cc-cockpit

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with JunWang666/cc-cockpit, then Add plugin.

Develop

Each mod is in mods/<name>/. Check one with claude plugin validate mods/<name> and claude plugin test mods/<name>, then /reload-plugins in a session.

Source 2 files
hooks/register.tsx 1052 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CockpitDoc, CockpitGit, CockpitProject, CockpitSession, CockpitStats, CockpitTab, CockpitWindow } from '../types'
5
6const PANE = 'desktop-cockpit'
7
8const stats = atom({ plugin: 'desktop-cockpit', key: 'stats' } as const, {
9  turn: 0,
10  speed: null,
11  turnCost: null,
12  total: 0,
13  costAtStart: 0,
14  context: 0,
15} as CockpitStats)
16const windows = atom({ plugin: 'desktop-cockpit', key: 'windows' } as const, [])
17const cacheExpiresAt = atom({ plugin: 'desktop-cockpit', key: 'cacheExpiresAt' } as const, null)
18const now = atom({ plugin: 'desktop-cockpit', key: 'now' } as const, 0)
19const project = atom({ plugin: 'desktop-cockpit', key: 'project' } as const, null)
20const isWelcomeShown = atom({ plugin: 'desktop-cockpit', key: 'isWelcomeShown' } as const, false)
21const openDoc = atom({ plugin: 'desktop-cockpit', key: 'openDoc' } as const, null)
22const tab = atom({ plugin: 'desktop-cockpit', key: 'tab' } as const, 'usage' as CockpitTab)
23const filter = atom({ plugin: 'desktop-cockpit', key: 'filter' } as const, '')
24// The auto-continue mod's state, read only: absent when it is not installed.
25const autoOn = atom({ plugin: 'auto-continue', key: 'isEnabled' } as const, null)
26const autoWait = atom({ plugin: 'auto-continue', key: 'wait' } as const, null)
27const autoScheduled = atom({ plugin: 'auto-continue', key: 'scheduled' } as const, null)
28const autoMaxWait = atom({ plugin: 'auto-continue', key: 'maxWaitMs' } as const, null)
29const autoMessage = atom({ plugin: 'desktop-cockpit', key: 'autoMessage' } as const, '')
30
31const TABS: { id: CockpitTab; label: string }[] = [
32  { id: 'usage', label: '用量' },
33  { id: 'project', label: '项目' },
34  { id: 'prompts', label: '我的消息' },
35]
36const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '周', spend_limit: '花费' }
37
38const ACCENT = '#5fa8d3'
39const SPEED = '#8b8cf8'
40const MONEY = '#4caf6e'
41const CACHE = '#4fb3a9'
42const WARN = '#e2a94e'
43const ALERT = '#e0846b'
44const TRACK = '#8a8a8a40'
45
46// Output speed: ignore replies too short or too quick to time fairly.
47const MIN_TOKENS = 20
48const MIN_SECONDS = 0.5
49const MAX_SPEED = 1000
50
51// The prompt cache lives an hour after the last answer. The desktop shows it in minutes,
52// so the clock ticks slowly: each tick is a redraw sent over the wire.
53const CACHE_TTL_MS = 60 * 60 * 1000
54const CACHE_WARN_MIN = 20
55const CACHE_ALERT_MIN = 10
56const TICK_MS = 15_000
57
58// Narrower than these, the band's one line drops its quota bars, then its total cost and long labels.
59const DESK_FULL_COLUMNS = 120
60const DESK_MID_COLUMNS = 85
61
62const SESSIONS = 5
63const EDITED_SHOWN = 5
64const DOCS = ['README.md', 'AGENTS.md', 'CLAUDE.md']
65const PREVIEW_LINES = 12
66// $.fs.read refuses files over 4 MiB; a transcript that large is left out of the list.
67const MAX_TRANSCRIPT = 4 * 1024 * 1024
68
69const level = (pct: number) => (pct >= 80 ? ALERT : pct >= 50 ? WARN : MONEY)
70const usd = (n: number) => `$${n.toFixed(2)}`
71const cut = (text: string, width: number) => (text.length > width ? text.slice(0, Math.max(1, width - 1)) + '…' : text)
72const oneLine = (text: string, width: number) => cut(text.replace(/\s+/g, ' ').trim(), width)
73
74const bar = (pct: number, color: string, width = 64, height = 6) => {
75  const fill = Math.round((Math.max(0, Math.min(100, pct)) / 100) * width)
76  return (
77    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
78    `<rect width="${width}" height="${height}" rx="${height / 2}" fill="${TRACK}"/>` +
79    `<rect width="${fill}" height="${height}" rx="${height / 2}" fill="${color}"/></svg>`
80  )
81}
82
83const ago = (ms: number, at: number) => {
84  const min = Math.max(0, Math.round((at - ms) / 60000))
85  if (min < 60) return `${min}分钟前`
86  const h = Math.round(min / 60)
87  if (h < 24) return `${h}小时前`
88  return `${Math.round(h / 24)}天前`
89}
90
91const resetIn = (resetsAt: string | undefined, at: number) => {
92  if (!resetsAt) return ''
93  const min = Math.max(0, Math.round((Date.parse(resetsAt) - at) / 60000))
94  if (Number.isNaN(min)) return ''
95  if (min < 60) return `${min}分钟后重置`
96  if (min < 24 * 60) return `${Math.floor(min / 60)}h${min % 60}m后重置`
97  return `${Math.round(min / 1440)}天后重置`
98}
99
100// ── usage ────────────────────────────────────────────────────────────────
101
102async function refresh($: EngineInterface) {
103  const { rateLimits, cost, context } = await $.session.usage()
104  const list: CockpitWindow[] = rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
105  await update($, windows, () => list)
106  const at = await $.clock.now()
107  await update($, now, () => at)
108  const total = cost?.usd ?? 0
109  const turn = await $.session.turns()
110  await update($, stats, s => ({
111    ...s,
112    turn,
113    total,
114    turnCost: s.turnCost === null ? null : total - s.costAtStart,
115    context: context.percent ?? s.context,
116  }))
117}
118
119// ── project scan (git, docs, recent sessions), in place of the welcome mod's scan.py ──
120
121const slash = (path: string) => path.replace(/\\/g, '/')
122
123async function git($: EngineInterface, cwd: string, ...args: string[]) {
124  const ran = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 5000 }).catch(() => null)
125  return ran && ran.exitCode === 0 ? ran.stdout.trim() : null
126}
127
128async function gitState($: EngineInterface, cwd: string): Promise<CockpitGit | null> {
129  if ((await git($, cwd, 'rev-parse', '--is-inside-work-tree')) !== 'true') return null
130  const branch = (await git($, cwd, 'branch', '--show-current')) || (await git($, cwd, 'rev-parse', '--short', 'HEAD')) || '?'
131  const status = (await git($, cwd, 'status', '--porcelain')) ?? ''
132  let ahead: number | null = null
133  let behind: number | null = null
134  const counts = await git($, cwd, 'rev-list', '--left-right', '--count', '@{upstream}...HEAD')
135  if (counts) [behind, ahead] = counts.split(/\s+/).map(Number) as [number, number]
136  const log = (await git($, cwd, 'log', '-3', '--format=%h%x09%ct%x09%s')) ?? ''
137  const commits = log
138    .split('\n')
139    .filter(Boolean)
140    .map(line => {
141      const [hash = '', ct = '', subject = ''] = line.split('\t')
142      return { hash, time: /^\d+$/.test(ct) ? Number(ct) * 1000 : 0, subject }
143    })
144  return { branch, changes: status.split('\n').filter(l => l.trim()).length, ahead, behind, commits }
145}
146
147async function docs($: EngineInterface, cwd: string): Promise<CockpitDoc[]> {
148  const entries = await $.fs.list(cwd).catch(() => [])
149  const names = new Map(entries.map(e => [e.name.toLowerCase(), e.name]))
150  const out: CockpitDoc[] = []
151  for (const want of DOCS) {
152    let name = names.get(want.toLowerCase())
153    if (!name && want === 'CLAUDE.md' && (await $.fs.exists(`${cwd}/.claude/CLAUDE.md`))) name = '.claude/CLAUDE.md'
154    if (!name) continue
155    const text = await $.fs.read(`${cwd}/${name}`).catch(() => null)
156    if (text === null) continue
157    const lines = text.split(/\r?\n/)
158    let title = ''
159    let gist = ''
160    const preview: string[] = []
161    for (const raw of lines) {
162      const line = raw.trim()
163      // badges, html, image-only and rule lines say nothing in a card
164      if (!line || /^(<|!\[|\[!\[|---|===)/.test(line)) continue
165      if (preview.length < PREVIEW_LINES) preview.push(raw.trimEnd())
166      if (!title && line.startsWith('#')) title = line.replace(/^#+/, '').trim()
167      else if (!gist && !/^(#|\||```|>)/.test(line)) gist = line.replace(/[*_`]/g, '')
168      if (title && gist && preview.length >= PREVIEW_LINES) break
169    }
170    out.push({ name, title, gist: gist.slice(0, 300), lines: lines.length, preview })
171  }
172  return out
173}
174
175// The folder that holds ~/.claude: read off the mod's own path when it is under it, else asked of the shell.
176async function homeDir($: EngineInterface) {
177  const root = slash($.plugin.root)
178  const at = root.indexOf('/.claude/')
179  if (at >= 0) return root.slice(0, at)
180  for (const argv of [['cmd', '/c', 'echo %USERPROFILE%'], ['sh', '-c', 'echo $HOME']]) {
181    const ran = await $.process.run(argv, { timeoutMs: 5000 }).catch(() => null)
182    const out = ran?.exitCode === 0 ? ran.stdout.trim() : ''
183    if (out && !out.includes('%')) return slash(out)
184  }
185  return null
186}
187
188type Block = { type?: string; text?: string; name?: string; input?: Record<string, unknown> }
189
190const promptText = (content: unknown) => {
191  if (typeof content === 'string') return content
192  if (Array.isArray(content)) for (const b of content as Block[]) if (b?.type === 'text') return b.text ?? ''
193  return ''
194}
195
196async function sessions($: EngineInterface, cwd: string, self: string): Promise<CockpitSession[]> {
197  const home = await homeDir($)
198  if (!home) return []
199  const folder = `${home}/.claude/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}`
200  const files = (await $.fs.list(folder).catch(() => []))
201    .filter(f => f.kind === 'file' && f.name.endsWith('.jsonl') && f.name.slice(0, -6) !== self)
202    .sort((a, b) => b.mtimeMs - a.mtimeMs)
203  const base = slash(cwd).replace(/\/+$/, '') + '/'
204  const out: CockpitSession[] = []
205  for (const file of files) {
206    if (out.length >= SESSIONS) break
207    if (file.size > MAX_TRANSCRIPT) continue
208    const text = await $.fs.read(`${folder}/${file.name}`).catch(() => null)
209    if (text === null) continue
210    const isLatest = out.length === 0
211    let first = ''
212    let count = 0
213    let recap = ''
214    const edited: string[] = []
215    for (const line of text.split('\n')) {
216      const isRecap = line.includes('"away_summary"')
217      const isEdit = isLatest && line.includes('"tool_use"')
218      if (!line.includes('"type":"user"') && !isRecap && !isEdit) continue
219      let row: Record<string, any>
220      try {
221        row = JSON.parse(line)
222      } catch {
223        continue
224      }
225      if (isRecap && row.subtype === 'away_summary') {
226        recap = String(row.content ?? '')
227        continue
228      }
229      if (isEdit && row.type === 'assistant' && !row.isSidechain) {
230        for (const b of (row.message?.content ?? []) as Block[]) {
231          if (b?.type !== 'tool_use' || !['Edit', 'Write', 'NotebookEdit'].includes(b.name ?? '')) continue
232          const fp = slash(String(b.input?.file_path ?? b.input?.notebook_path ?? ''))
233          const rel = fp.startsWith(base) ? fp.slice(base.length) : fp
234          if (rel && !edited.includes(rel)) edited.push(rel)
235        }
236        continue
237      }
238      if (row.type !== 'user' || row.isSidechain || row.isMeta || row.toolUseResult !== undefined) continue
239      const said = promptText(row.message?.content).trim()
240      if (!said || said.startsWith('<')) continue
241      count += 1
242      first = first || said.replace(/\s+/g, ' ')
243    }
244    if (!first) continue
245    const found: CockpitSession = { id: file.name.slice(0, -6), prompt: first.slice(0, 200), prompts: count, time: file.mtimeMs }
246    if (isLatest) {
247      // the latest session also says what it did: its last recap and the files it edited
248      found.recap = recap.replace(/\s*\(disable recaps in \/config\)\s*$/, '').trim().slice(0, 600)
249      found.edited = edited
250    }
251    out.push(found)
252  }
253  return out
254}
255
256async function scan($: EngineInterface) {
257  const cwd = slash(await $.session.cwd())
258  const self = await $.session.id()
259  const [g, d, s] = await Promise.all([gitState($, cwd), docs($, cwd), sessions($, cwd, self)])
260  const found: CockpitProject = { git: g, docs: d, sessions: s, scannedAt: await $.clock.now() }
261  await update($, project, () => found)
262}
263
264// ── prompts: the transcript rows of the person's own messages, to scroll to ──
265
266// A render hook may not write $.state, so these live in the module.
267const drawn: { requestId: string; text: string }[] = []
268const seenRows = new Set<string>()
269let isLive = false
270let bottomRow: string | undefined
271
272const isOwnPrompt = (text: string) => text.trim() !== '' && !text.trimStart().startsWith('<')
273
274function noteRow(requestId: string) {
275  if (seenRows.has(requestId)) return
276  seenRows.add(requestId)
277  if (isLive) bottomRow = requestId
278}
279
280async function scrollTo($: EngineInterface, requestId: string, block: 'start' | 'end') {
281  const moved = await $.ui.scroll({ to: { requestId }, block }).catch((err: unknown) => ({ deny: String(err) }))
282  if (moved.deny) $.ui.toast(`没法滚动过去:${moved.deny}`)
283}
284
285async function scrollToBottom($: EngineInterface) {
286  if (!bottomRow) {
287    $.ui.toast('还不知道底部在哪:发一条消息后再试')
288    return
289  }
290  await scrollTo($, bottomRow, 'end')
291}
292
293// ── auto-continue, shown beside the quota: its state is read here, changed through its own command ──
294
295const AUTO_LABELS: Record<string, string> = { five_hour: '5 小时额度', seven_day: '周额度', spend_limit: '花费额度' }
296
297// `14:30` today, `10/22 14:30` on another day.
298const clockTime = (ms: number, at: number) => {
299  const d = new Date(ms)
300  const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
301  return d.toDateString() === new Date(at).toDateString() ? hm : `${d.getMonth() + 1}/${d.getDate()} ${hm}`
302}
303
304async function runAuto($: EngineInterface, args: string) {
305  const ran = await $.command.run({ command: 'auto-continue', args }).catch((err: unknown) => ({ text: `没能执行 /auto-continue:${String(err)}` }))
306  if (ran.text) $.ui.toast(ran.text)
307}
308
309async function openPane($: EngineInterface, to?: CockpitTab) {
310  if (to) await update($, tab, () => to)
311  if ((await read($, tab)) === 'project' && (await read($, project)) === null) await scan($)
312  return $.ui.open({ id: PANE, title: '驾驶舱', focus: true, closeOnEscape: true })
313}
314
315export const register: Register = on => {
316  on('session.start', async ($, e, next) => {
317    const started = await next(e)
318    await $.command.register({
319      name: 'cockpit',
320      description: '打开驾驶舱面板:用量、项目状态、本会话你发过的消息',
321      argumentHint: '[usage|project|prompts] [关键词]',
322    })
323    await refresh($)
324    $.clock.every(TICK_MS, async () => {
325      const at = await $.clock.now()
326      await update($, now, () => at)
327    })
328    $.clock.every(60_000, () => refresh($))
329    await scan($)
330    // A fresh session (a new launch, or /clear): no prompts yet; a resumed one has some.
331    if ((await $.session.turns()) === 0) await update($, isWelcomeShown, () => true)
332    return started
333  })
334
335  on('command.run', { command: 'cockpit' }, async ($, e) => {
336    const [first = '', ...rest] = (e.args ?? '').trim().split(/\s+/)
337    const named = TABS.find(t => t.id === first)?.id
338    const keyword = (named ? rest.join(' ') : (e.args ?? '')).trim()
339    await update($, filter, () => keyword)
340    const opened = await openPane($, named ?? (keyword ? 'prompts' : undefined))
341    return { text: opened.isPlaced ? '已打开驾驶舱。' : `驾驶舱暂时无法显示:${opened.reason}` }
342  })
343
344  on('turn.start', async ($, e, next) => {
345    if (e.agentId === undefined) {
346      const total = (await $.session.usage()).cost?.usd ?? 0
347      await update($, stats, s => ({ ...s, costAtStart: total, turnCost: 0 }))
348    }
349    return next(e)
350  })
351
352  on('turn.step', async function* ($, e, next) {
353    if (e.agentId !== undefined) return yield* next(e)
354
355    // Timed from the response's first chunk, so time-to-first-token is left out.
356    let startedAt: number | null = null
357    for await (const chunk of next(e)) {
358      if (startedAt === null) startedAt = await $.clock.now()
359      if (chunk.kind === 'stop' && chunk.usage !== null) {
360        const tokens = chunk.usage.output_tokens
361        const seconds = ((await $.clock.now()) - startedAt) / 1000
362        const tps = tokens / seconds
363        if (tokens >= MIN_TOKENS && seconds >= MIN_SECONDS && tps <= MAX_SPEED) {
364          await update($, stats, s => ({ ...s, speed: Math.round(tps) }))
365        }
366      }
367      yield chunk
368    }
369    await refresh($)
370  })
371
372  on('turn.complete', async ($, e, next) => {
373    const done = await next(e)
374    await refresh($)
375    if (e.agentId === undefined) {
376      const at = await $.clock.now()
377      await update($, cacheExpiresAt, () => at + CACHE_TTL_MS)
378    }
379    return done
380  })
381
382  // The welcome card steps aside once the conversation starts.
383  on('prompt.submit', async ($, e, next) => {
384    isLive = true
385    if (e.origin.kind === 'composer') await update($, isWelcomeShown, () => false).catch(() => undefined)
386    return next(e)
387  })
388
389  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
390    noteRow(e.requestId)
391    const kind = e.props.origin.kind
392    const isPerson = kind !== 'task-notification' && kind !== 'plugin' && !e.props.from
393    if (isPerson && isOwnPrompt(e.props.text) && !drawn.some(d => d.requestId === e.requestId)) {
394      drawn.push({ requestId: e.requestId, text: e.props.text })
395    }
396    return next(e)
397  })
398  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => (noteRow(e.requestId), next(e)))
399  on('ui.render', { component: 'ToolUse' }, ($, e, next) => (noteRow(e.requestId), next(e)))
400  on('ui.render', { component: 'ToolResult' }, ($, e, next) => (noteRow(e.requestId), next(e)))
401
402  // ── the band above the prompt: the desktop's alone; the terminal keeps quota-band and welcome ──
403
404  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
405    if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
406    isTinted = true
407    const { Box, Text, Button, Svg, Markdown } = $.ui.resolve(e)
408    const s = await read($, stats)
409    const list = await read($, windows)
410    const expiresAt = await read($, cacheExpiresAt)
411    const at = await read($, now)
412
413    const left = expiresAt === null || at === 0 ? null : Math.max(0, Math.ceil((expiresAt - at) / 60000))
414    const cacheColor =
415      e.props.isWorking || left === null ? CACHE : left <= CACHE_ALERT_MIN ? ALERT : left <= CACHE_WARN_MIN ? WARN : CACHE
416    const cacheText = e.props.isWorking ? '…' : left === null ? '—' : left === 0 ? '已过期' : `${left}分钟`
417
418    // One line always: narrower bands drop the quota bars, then the total cost and the long labels;
419    // whatever still does not fit is clipped rather than wrapped.
420    const cols = e.props.bodyColumns
421    const isFull = cols >= DESK_FULL_COLUMNS
422    const isMid = cols >= DESK_MID_COLUMNS
423
424    // auto-continue beside the quota: waiting for a reset, a set time, or just on; a click opens its controls.
425    const wait = await read($, autoWait)
426    const scheduled = await read($, autoScheduled)
427    const isAutoOn = (await read($, autoOn)) === true
428    const autoText = wait
429      ? `⏳ ${clockTime(wait.wakeAt, at)}${isMid ? ' 自动续' : ''}`
430      : scheduled
431        ? `⏰ ${clockTime(scheduled.at, at)}${isMid ? ' 定时续' : ''}`
432        : isAutoOn
433          ? isMid ? '⟳ 自动续' : '⟳'
434          : null
435    const auto =
436      autoText === null ? null : (
437        <Button key="auto" plain onPress={() => openPane($, 'usage')}>
438          <Text bold={!!(wait || scheduled)} dimColor={!wait && !scheduled} color={wait ? WARN : scheduled ? SPEED : undefined} size="footnote">
439            {autoText}
440          </Text>
441        </Button>
442      )
443
444    const row = (
445      <Box key="cockpit-row" flexDirection="row" columnGap={2} flexWrap="nowrap" paddingX={1} alignItems="center">
446        <Box flexDirection="row" columnGap={isFull ? 3 : 2} flexWrap="nowrap" flexShrink={1} flexGrow={1} overflow="hidden">
447          <Box flexDirection="row" columnGap={1} flexShrink={0}>
448            <Text dimColor size="footnote">{isFull ? 'Turn' : '#'}</Text>
449            <Text bold tabular size="footnote">{String(s.turn)}</Text>
450          </Box>
451          <Box flexDirection="row" columnGap={1} flexShrink={0}>
452            <Text color={SPEED} size="footnote">⚡</Text>
453            <Text bold tabular size="footnote">{s.speed === null ? '—' : isFull ? `${s.speed} tok/s` : `${s.speed}/s`}</Text>
454          </Box>
455          <Box flexDirection="row" columnGap={1} flexShrink={0}>
456            <Text bold tabular color={MONEY} size="footnote">{s.turnCost === null ? '—' : usd(s.turnCost)}</Text>
457            {isMid ? <Text dimColor size="footnote">/</Text> : null}
458            {isMid ? <Text bold tabular size="footnote">{usd(s.total)}</Text> : null}
459          </Box>
460          <Box flexDirection="row" columnGap={1} flexShrink={0}>
461            <Text color={cacheColor} size="footnote">{isMid ? '缓存' : '⧗'}</Text>
462            <Text bold tabular color={cacheColor} size="footnote">{isMid ? cacheText : cacheText.replace('分钟', 'm')}</Text>
463          </Box>
464          {list.map(w => (
465            <Box key={w.kind} flexDirection="row" columnGap={1} alignItems="center" flexShrink={0}>
466              <Text dimColor size="footnote">{LABELS[w.kind] ?? w.kind}</Text>
467              {isFull ? <Svg source={bar(w.percentUsed, level(w.percentUsed), 48)} alt={`${Math.round(w.percentUsed)}%`} width={48} height={6} /> : null}
468              <Text bold tabular color={level(w.percentUsed)} size="footnote">{`${Math.round(w.percentUsed)}%`}</Text>
469            </Box>
470          ))}
471          {auto}
472          <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={0}>
473            <Text dimColor size="footnote">{isMid ? '上下文' : 'ctx'}</Text>
474            {isMid ? <Svg source={bar(s.context, level(s.context), 48)} alt={`${Math.round(s.context)}%`} width={48} height={6} /> : null}
475            <Text bold tabular color={level(s.context)} size="footnote">{`${Math.round(s.context)}%`}</Text>
476          </Box>
477        </Box>
478        <Box flexShrink={0}>
479          <Button key="cockpit-open" plain onPress={() => openPane($)}>
480            <Text color={ACCENT} size="footnote">驾驶舱 ›</Text>
481          </Button>
482        </Box>
483      </Box>
484    )
485
486    const found = await read($, project)
487    const welcome =
488      (await read($, isWelcomeShown)) && found !== null ? (
489        <Box
490          key="cockpit-welcome"
491          flexDirection="column"
492          borderStyle="round"
493          borderColor={`${ACCENT}66`}
494          backgroundColor={`${ACCENT}0d`}
495          paddingX="sm"
496          paddingY="xs"
497          marginBottom="sm"
498        >
499          <Box flexDirection="row" columnGap="sm" alignItems="center" flexWrap="wrap">
500            <Text size="caption" dimColor>欢迎回来</Text>
501            <Text bold mono color={ACCENT}>{await $.session.cwd()}</Text>
502            {gitLine({ Box, Text }, found.git)}
503            <Box flexGrow={1} />
504            <Button key="welcome-close" label="×" plain role="dismiss" onPress={() => update($, isWelcomeShown, () => false)} />
505          </Box>
506          {projectBody($, { Box, Text, Button }, found, await read($, openDoc), 100, true, Markdown)}
507        </Box>
508      ) : null
509
510    const below = await next(e)
511    return (
512      <Box flexDirection="column">
513        {welcome}
514        {row}
515        {below}
516      </Box>
517    )
518  })
519
520
521  // ── the pane: usage, project and prompts, one tab each ──
522
523  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
524    isTinted = e.surface !== 'terminal'
525    const ui = $.ui.resolve(e)
526    const { Box, Text, Button } = ui
527    const Svg = 'Svg' in ui ? ui.Svg : undefined
528    const Input = 'Input' in ui ? ui.Input : undefined
529    const current = await read($, tab)
530    const width = Math.max(20, (e.props.bodyColumns || e.viewport?.columns || 80) - 14)
531
532    const tabs = (
533      <Box key="tabs" flexDirection="row" columnGap="xs" marginBottom="sm">
534        {TABS.map(t => (
535          <Button
536            key={`tab-${t.id}`}
537            label={t.label}
538            variant={t.id === current ? 'primary' : 'secondary'}
539            onPress={async () => {
540              await update($, tab, () => t.id)
541              if (t.id === 'project') await scan($)
542              if (t.id === 'usage') await refresh($)
543            }}
544          />
545        ))}
546      </Box>
547    )
548
549    let body
550    if (current === 'usage') {
551      const s = await read($, stats)
552      const list = await read($, windows)
553      const at = await read($, now)
554      const expiresAt = await read($, cacheExpiresAt)
555      const left = expiresAt === null || at === 0 ? null : Math.max(0, Math.ceil((expiresAt - at) / 60000))
556      const cacheColor = left === null ? CACHE : left <= CACHE_ALERT_MIN ? ALERT : left <= CACHE_WARN_MIN ? WARN : CACHE
557
558      const gauge = (key: string, label: string, pct: number, note: string) => (
559        <Box key={key} flexDirection="column" alignItems="center" rowGap="xs" minWidth={GAUGE_CELLS}>
560          {Svg ? (
561            <Svg source={ring(pct, level(pct))} alt={`${label} ${Math.round(pct)}%`} width={RING} height={RING} />
562          ) : (
563            <Text bold color={level(pct)}>{`${Math.round(pct)}%`}</Text>
564          )}
565          <Text weight="semibold" size="footnote">{label}</Text>
566          <Text dimColor size="caption">{note || ' '}</Text>
567        </Box>
568      )
569
570      body = (
571        <Box flexDirection="column" rowGap="sm">
572          <Box flexDirection="row" columnGap="sm" rowGap="sm" flexWrap="wrap">
573            {stat({ Box, Text }, 'turn', '轮次', String(s.turn), '本会话的第几轮')}
574            {stat({ Box, Text }, 'speed', '输出速度', s.speed === null ? '—' : `${s.speed}`, s.speed === null ? '还没测到' : 'tok/s', SPEED)}
575            {stat({ Box, Text }, 'cost', '本轮花费', s.turnCost === null ? '—' : usd(s.turnCost), `本会话 ${usd(s.total)}`, MONEY)}
576            {stat(
577              { Box, Text },
578              'cache',
579              '提示缓存',
580              left === null ? '—' : left === 0 ? '已过期' : `${left} 分钟`,
581              left === null ? '回答后开始计时' : left === 0 ? '下一轮会重新写入' : '后过期',
582              cacheColor,
583              Svg && left !== null ? (
584                <Svg source={bar((left / 60) * 100, cacheColor, 120, 4)} alt={`缓存剩 ${left} 分钟`} width={120} height={4} />
585              ) : null,
586            )}
587          </Box>
588          {card(
589            { Box, Text },
590            'quota',
591            '额度',
592            <Box flexDirection="row" columnGap="md" rowGap="sm" flexWrap="wrap" justifyContent="space-around">
593              {list.map(w => gauge(w.kind, AUTO_LABELS[w.kind] ?? w.kind, w.percentUsed, resetIn(w.resetsAt, at)))}
594              {gauge('context', '上下文', s.context, '当前对话占用')}
595            </Box>,
596          )}
597          {await autoSection($, ui, at)}
598        </Box>
599      )
600    } else if (current === 'project') {
601      const found = await read($, project)
602      const cwd = await $.session.cwd()
603      body =
604        found === null ? (
605          <Text dimColor>正在读取项目状态…</Text>
606        ) : (
607          <Box flexDirection="column" rowGap="sm">
608            {card(
609              { Box, Text },
610              'repo',
611              '仓库',
612              <Box flexDirection="column" rowGap="xs">
613                <Text mono weight="semibold">{cwd}</Text>
614                {gitLine({ Box, Text }, found.git)}
615              </Box>,
616              <Button key="rescan" plain label="刷新" onPress={() => scan($)} />,
617            )}
618            {projectBody($, { Box, Text, Button }, found, await read($, openDoc), width, false, 'Markdown' in ui ? ui.Markdown : undefined)}
619          </Box>
620        )
621    } else {
622      const rawQuery = await read($, filter)
623      const query = rawQuery.toLowerCase()
624      const messages = await $.session.messages()
625      // The session's own prompts, matched in order to the transcript rows drawn so far.
626      const pool = [...drawn]
627      const all = messages
628        .filter(m => m.role === 'user' && isOwnPrompt(m.text))
629        .map((m, i) => {
630          const at = pool.findIndex(d => d.text.trim() === m.text.trim())
631          const requestId = at >= 0 ? pool.splice(at, 1)[0]?.requestId : undefined
632          return { n: i + 1, text: m.text, requestId }
633        })
634      const prompts = all.filter(p => !query || p.text.toLowerCase().includes(query)).reverse()
635      const jump = async (requestId: string | undefined) => {
636        if (!requestId) {
637          $.ui.toast('这条还没在对话区加载过:往上滚动经过它一次后就能跳了')
638          return
639        }
640        await scrollTo($, requestId, 'start')
641      }
642      body = (
643        <Box flexDirection="column" rowGap="sm">
644          <Box flexDirection="row" columnGap="sm" alignItems="flex-end">
645            {Input ? (
646              <Box flexGrow={1}>
647                <Input
648                  key="prompt-filter"
649                  label="过滤"
650                  placeholder="输入关键词"
651                  value={rawQuery}
652                  onInput={value => update($, filter, () => value)}
653                  onSubmit={value => update($, filter, () => value)}
654                />
655              </Box>
656            ) : (
657              <Box flexGrow={1} />
658            )}
659            <Button key="bottom" label="↓ 回到底部" onPress={() => scrollToBottom($)} />
660          </Box>
661          <Text dimColor size="footnote">
662            {query ? `「${query}」匹配 ${prompts.length} / ${all.length} 条` : `${prompts.length} 条消息`} · 最新的在上 · 点一条跳过去
663          </Text>
664          {prompts.length === 0 ? <Text dimColor>{query ? '没有匹配的消息。' : '还没有消息。'}</Text> : null}
665          <Box flexDirection="column">
666            {prompts.map(p => (
667              <Box
668                key={`row-${p.n}`}
669                flexDirection="row"
670                paddingX="xs"
671                paddingY="xs"
672                borderStyle={isTinted ? "round" : undefined}
673                borderColor={tint("#00000000")}
674                hover={isTinted ? { backgroundColor: HOVER_BG, borderColor: CARD_BORDER } : undefined}
675              >
676                <Button key={`p${p.n}`} plain onPress={() => jump(p.requestId)}>
677                  <Text mono dimColor size="footnote">{`#${String(p.n).padStart(2, '0')}  `}</Text>
678                  {oneLine(p.text, width)}
679                  {p.requestId ? '' : <Text dimColor size="caption">{'  未加载'}</Text>}
680                </Button>
681              </Box>
682            ))}
683          </Box>
684        </Box>
685      )
686    }
687
688    return (
689      <Box flexDirection="column">
690        {tabs}
691        {body}
692      </Box>
693    )
694  })
695}
696
697// ── drawing pieces: cards, stats, gauges, pills ──
698
699type Elements = Pick<ReturnType<EngineInterface['ui']['resolve']>, 'Box' | 'Text' | 'Button'>
700type Markdown = Extract<ReturnType<EngineInterface['ui']['resolve']>, { Markdown: unknown }>['Markdown']
701
702// Tints of grey that read on a light and a dark page alike.
703const CARD_BG = '#8080800d'
704const CARD_BORDER = '#80808033'
705const HOVER_BG = '#8080801f'
706// The terminal draws no translucent colour: set per draw, it leaves the tints out there.
707let isTinted = true
708const tint = (color: string) => (isTinted ? color : undefined)
709const RING = 76
710// Widths are in character cells on every surface, not pixels: a stat card takes about a quarter of a wide
711// pane and half of a narrow one, a gauge fits its ring, a form field half a wide card.
712const STAT_CELLS = 22
713const GAUGE_CELLS = 12
714const FIELD_CELLS = 26
715
716const ring = (pct: number, color: string, size = RING) => {
717  const stroke = 7
718  const c = size / 2
719  const r = (size - stroke) / 2
720  const len = 2 * Math.PI * r
721  const p = Math.max(0, Math.min(100, pct))
722  const arc =
723    p > 0
724      ? `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="${color}" stroke-width="${stroke}" stroke-linecap="round" ` +
725        `stroke-dasharray="${((len * p) / 100).toFixed(2)} ${len.toFixed(2)}" transform="rotate(-90 ${c} ${c})"/>`
726      : ''
727  return (
728    `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">` +
729    `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="${TRACK}" stroke-width="${stroke}"/>${arc}` +
730    `<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" font-family="system-ui,-apple-system,'Segoe UI',sans-serif" ` +
731    `font-size="${Math.round(size * 0.22)}" font-weight="600" fill="${color}">${Math.round(p)}%</text></svg>`
732  )
733}
734
735// A titled card; `extra` sits at the right of its title.
736function card({ Box, Text }: Pick<Elements, 'Box' | 'Text'>, key: string, title: string, children: unknown, extra?: unknown) {
737  return (
738    <Box key={key} flexDirection="column" rowGap="xs" borderStyle="round" borderColor={tint(CARD_BORDER)} backgroundColor={tint(CARD_BG)} paddingX="sm" paddingY="xs">
739      <Box flexDirection="row" alignItems="center">
740        <Text dimColor size="footnote" weight="semibold">{title}</Text>
741        <Box flexGrow={1} />
742        {extra ?? null}
743      </Box>
744      {children}
745    </Box>
746  )
747}
748
749// One figure in a small card: a caption, the value large, a line under it.
750function stat({ Box, Text }: Pick<Elements, 'Box' | 'Text'>, key: string, label: string, value: string, sub: string, color?: string, extra?: unknown) {
751  return (
752    <Box key={key} flexDirection="column" rowGap="xs" flexGrow={1} minWidth={STAT_CELLS} borderStyle="round" borderColor={tint(CARD_BORDER)} backgroundColor={tint(CARD_BG)} paddingX="sm" paddingY="xs">
753      <Text dimColor size="caption">{label}</Text>
754      <Text bold tabular size="title" color={color}>{value}</Text>
755      {extra ?? null}
756      <Text dimColor size="caption">{sub}</Text>
757    </Box>
758  )
759}
760
761// A short tinted label.
762function pill(Text: Elements['Text'], key: string, text: string, color: string, isMono = false) {
763  return (
764    <Text key={key} mono={isMono} size="footnote" weight="medium" color={color} backgroundColor={tint(`${color}22`)}>
765      {` ${text} `}
766    </Text>
767  )
768}
769
770const HOUR_MS = 60 * 60 * 1000
771// The longest waits the picker offers, as /auto-continue on takes them.
772const WAITS: { value: string; label: string; ms: number }[] = [
773  { value: '6h', label: '最多等 6 小时', ms: 6 * HOUR_MS },
774  { value: '12h', label: '最多等 12 小时', ms: 12 * HOUR_MS },
775  { value: '1d', label: '最多等 1 天', ms: 24 * HOUR_MS },
776  { value: '2d', label: '最多等 2 天', ms: 48 * HOUR_MS },
777  { value: '3d', label: '最多等 3 天', ms: 72 * HOUR_MS },
778]
779// The set times the picker offers: `in` a span, or `at` a clock time.
780const WHENS: { value: string; label: string }[] = [
781  { value: 'in 30m', label: '30 分钟后' },
782  { value: 'in 1h', label: '1 小时后' },
783  { value: 'in 2h', label: '2 小时后' },
784  { value: 'in 5h', label: '5 小时后' },
785  { value: 'at 09:00', label: '下一个 9:00' },
786]
787const isSpan = (text: string) => /^\d+(?:\.\d+)?\s*(?:d|h|m|天|小时|分钟|分)$/i.test(text.trim())
788
789async function scheduleAuto($: EngineInterface, when: string) {
790  const message = (await read($, autoMessage)).trim()
791  await runAuto($, message ? `${when} ${message}` : when)
792}
793
794// The usage tab's auto-continue card: what it waits for, and fields that run its command. Absent when it is not installed.
795async function autoSection($: EngineInterface, ui: ReturnType<EngineInterface['ui']['resolve']>, at: number) {
796  const { Box, Text, Button } = ui
797  const Input = 'Input' in ui ? ui.Input : undefined
798  const Select = 'Select' in ui ? ui.Select : undefined
799  const isOn = await read($, autoOn)
800  if (isOn === null) return null
801  const wait = await read($, autoWait)
802  const scheduled = await read($, autoScheduled)
803  const maxWait = await read($, autoMaxWait)
804  const message = await read($, autoMessage)
805
806  const badge = wait
807    ? pill(Text, 'auto-badge', '等待额度刷新', WARN)
808    : scheduled
809      ? pill(Text, 'auto-badge', '已定时', SPEED)
810      : isOn
811        ? pill(Text, 'auto-badge', '已开启', MONEY)
812        : pill(Text, 'auto-badge', '未开启', '#8a8a8a')
813
814  const status =
815    wait || scheduled ? (
816      <Box key="auto-status" flexDirection="column" rowGap="xs" paddingX="sm" paddingY="xs" backgroundColor={tint(`${wait ? WARN : SPEED}14`)} borderStyle="round" borderColor={tint(`${wait ? WARN : SPEED}40`)}>
817        {wait ? (
818          <Text color={WARN}>
819            <Text bold>{`⏳ ${wait.kind ? (AUTO_LABELS[wait.kind] ?? wait.kind) : '额度'}用完`}</Text>
820            {`,${clockTime(wait.wakeAt, at)} 刷新后自动继续`}
821          </Text>
822        ) : null}
823        {scheduled ? (
824          <Box flexDirection="row" alignItems="center" columnGap="sm">
825            <Text color={SPEED}>
826              <Text bold>{`⏰ ${clockTime(scheduled.at, at)}`}</Text>
827              {` 发送「${scheduled.text}」`}
828            </Text>
829            <Box flexGrow={1} />
830            <Button key="auto-cancel" plain label="取消定时" onPress={() => runAuto($, 'cancel')} />
831          </Box>
832        ) : null}
833      </Box>
834    ) : null
835
836  // A surface with no fields (the phone) keeps the two buttons.
837  if (!Input || !Select) {
838    return card(
839      { Box, Text },
840      'auto',
841      '自动继续',
842      <Box flexDirection="column" rowGap="xs">
843        {status}
844        <Box flexDirection="row" columnGap="xs" flexWrap="wrap">
845          <Button key="auto-toggle" label={isOn ? '关闭额度自动继续' : '开启额度自动继续'} onPress={() => runAuto($, isOn ? 'off' : 'on')} />
846          {scheduled ? null : <Button key="auto-in-1h" label="1 小时后继续" onPress={() => runAuto($, 'in 1h')} />}
847        </Box>
848      </Box>,
849      badge,
850    )
851  }
852
853  // The wait picker shows the session's wait; one the picker does not list is shown as its own row.
854  const known = WAITS.find(w => w.ms === maxWait)
855  const waitOptions = [
856    { value: 'off', label: '关' },
857    ...(isOn && maxWait !== null && !known ? [{ value: 'current', label: `最多等 ${Math.round((maxWait / HOUR_MS) * 10) / 10} 小时` }] : []),
858    ...WAITS.map(w => ({ value: w.value, label: w.label })),
859  ]
860  const waitValue = !isOn ? 'off' : known ? known.value : maxWait !== null ? 'current' : '1d'
861
862  const field = (key: string, title: string, hint: string, control: unknown) => (
863    <Box key={key} flexDirection="column" rowGap="xs" flexGrow={1} minWidth={FIELD_CELLS}>
864      <Text weight="semibold" size="footnote">{title}</Text>
865      {control}
866      <Text dimColor size="caption">{hint}</Text>
867    </Box>
868  )
869
870  return card(
871    { Box, Text },
872    'auto',
873    '自动继续',
874    <Box flexDirection="column" rowGap="sm">
875      {status}
876      <Box flexDirection="row" columnGap="md" rowGap="sm" flexWrap="wrap">
877        {field(
878          'auto-wait-field',
879          '额度用完后',
880          '刷新时间在这个范围内,就自动发消息继续',
881          <Select key="auto-wait" options={waitOptions} value={waitValue} onSelect={value => (value === 'current' ? undefined : runAuto($, value === 'off' ? 'off' : `on ${value}`))} />,
882        )}
883        {field(
884          'auto-when-field',
885          '定时继续',
886          '到点发送下面的内容',
887          <Select
888            key="auto-when"
889            options={[{ value: 'none', label: '选一个时间…' }, ...WHENS]}
890            value="none"
891            onSelect={value => (value === 'none' ? undefined : scheduleAuto($, value))}
892          />,
893        )}
894      </Box>
895      <Box flexDirection="row" columnGap="md" rowGap="sm" flexWrap="wrap">
896        {field(
897          'auto-at-field',
898          '或指定时间',
899          '14:30、10/22 09:00,或 2h、90m',
900          <Input
901            key="auto-at"
902            placeholder="14:30"
903            submitLabel="定时"
904            onSubmit={value => {
905              const when = value.trim()
906              if (!when) return $.ui.toast('先填一个时间:14:30、10/22 09:00 或 2h')
907              return scheduleAuto($, `${isSpan(when) ? 'in' : 'at'} ${when}`)
908            }}
909          />,
910        )}
911        {field(
912          'auto-message-field',
913          '定时发送的内容',
914          '留空发送 continue',
915          <Input
916            key="auto-message"
917            placeholder="continue"
918            value={message}
919            onInput={value => update($, autoMessage, () => value)}
920            onSubmit={value => update($, autoMessage, () => value)}
921          />,
922        )}
923      </Box>
924    </Box>,
925    badge,
926  )
927}
928
929// The branch and its state, as pills.
930function gitLine({ Box, Text }: Pick<Elements, 'Box' | 'Text'>, git: CockpitGit | null) {
931  if (!git) return <Text dimColor>不是 git 仓库</Text>
932  return (
933    <Box flexDirection="row" columnGap="xs" flexWrap="wrap" alignItems="center">
934      {pill(Text, 'branch', `⎇ ${git.branch}`, ACCENT, true)}
935      {git.changes > 0 ? pill(Text, 'changes', `${git.changes} 个未提交改动`, WARN) : pill(Text, 'changes', '工作区干净', MONEY)}
936      {git.ahead ? pill(Text, 'ahead', `↑${git.ahead} 未推送`, MONEY) : null}
937      {git.behind ? pill(Text, 'behind', `↓${git.behind} 未拉取`, ALERT) : null}
938    </Box>
939  )
940}
941
942// Commits, docs, the last session and the recent ones: cards in the project tab, plain sections in the welcome card.
943function projectBody(
944  $: EngineInterface,
945  { Box, Text, Button }: Elements,
946  found: CockpitProject,
947  shownDoc: string | null,
948  width: number,
949  isCompact = false,
950  Markdown?: Markdown,
951) {
952  const at = found.scannedAt
953  const git = found.git
954  const latest = found.sessions[0]
955  const section = (key: string, title: string, children: unknown) =>
956    isCompact ? (
957      <Box key={key} flexDirection="column" marginTop="sm" rowGap="xs">
958        <Text dimColor size="footnote" weight="semibold">{title}</Text>
959        {children}
960      </Box>
961    ) : (
962      card({ Box, Text }, key, title, children)
963    )
964
965  return (
966    <Box flexDirection="column" rowGap={isCompact ? 0 : 'sm'}>
967      {git && git.commits.length > 0
968        ? section(
969            'commits',
970            '最近提交',
971            <Box flexDirection="column" rowGap="xs">
972              {git.commits.map(c => (
973                <Box key={c.hash} flexDirection="row" columnGap="sm" alignItems="center">
974                  {pill(Text, `hash-${c.hash}`, c.hash, WARN, true)}
975                  <Box flexGrow={1} flexShrink={1}>
976                    <Text wrap="truncate-end">{cut(c.subject, width)}</Text>
977                  </Box>
978                  <Text dimColor size="caption">{ago(c.time, at)}</Text>
979                </Box>
980              ))}
981            </Box>,
982          )
983        : null}
984      {found.docs.length > 0
985        ? section(
986            'docs',
987            '项目文档 · 点一下展开',
988            <Box flexDirection="column">
989              {found.docs.map(d => (
990                <Box key={d.name} flexDirection="column">
991                  <Box flexDirection="row" paddingX="xs" hover={isTinted ? { backgroundColor: HOVER_BG } : undefined}>
992                    <Button key={`doc-${d.name}`} plain onPress={() => update($, openDoc, open => (open === d.name ? null : d.name))}>
993                      <Text color={ACCENT} weight="semibold">{`${shownDoc === d.name ? '▾' : '▸'} ${d.name}`}</Text>
994                      {'  '}
995                      {cut([d.title, d.gist].filter(Boolean).join(' — '), width)}
996                      <Text dimColor size="caption">{`  ${d.lines} 行`}</Text>
997                    </Button>
998                  </Box>
999                  {shownDoc === d.name ? (
1000                    <Box flexDirection="column" marginLeft="sm" paddingX="sm" paddingY="xs" borderStyle="round" borderColor={tint(CARD_BORDER)}>
1001                      {Markdown ? (
1002                        <Markdown key={`md-${d.name}`} text={d.preview.join('\n')} />
1003                      ) : (
1004                        d.preview.map((line, i) => (
1005                          <Text key={`${d.name}-${i}`} dimColor wrap="truncate-end">{line}</Text>
1006                        ))
1007                      )}
1008                    </Box>
1009                  ) : null}
1010                </Box>
1011              ))}
1012            </Box>,
1013          )
1014        : null}
1015      {latest && (latest.recap || (latest.edited?.length ?? 0) > 0)
1016        ? section(
1017            'last',
1018            `上次会话做了什么 · ${ago(latest.time, at)}`,
1019            <Box flexDirection="column" rowGap="xs">
1020              {latest.recap ? <Text>{latest.recap}</Text> : null}
1021              {latest.edited && latest.edited.length > 0 ? (
1022                <Box flexDirection="row" columnGap="xs" rowGap="xs" flexWrap="wrap" alignItems="center">
1023                  <Text dimColor size="footnote">改动文件</Text>
1024                  {latest.edited.slice(0, EDITED_SHOWN).map(path => pill(Text, `edited-${path}`, path, '#8a8a8a', true))}
1025                  {latest.edited.length > EDITED_SHOWN ? <Text dimColor size="caption">{`等 ${latest.edited.length} 个`}</Text> : null}
1026                </Box>
1027              ) : null}
1028            </Box>,
1029          )
1030        : null}
1031      {section(
1032        'sessions',
1033        found.sessions.length > 0 ? '最近会话 · 在左侧会话列表里打开' : '最近会话',
1034        found.sessions.length > 0 ? (
1035          <Box flexDirection="column" rowGap="xs">
1036            {found.sessions.map(s => (
1037              <Box key={s.id} flexDirection="row" columnGap="sm" alignItems="center">
1038                <Box flexGrow={1} flexShrink={1}>
1039                  <Text wrap="truncate-end">{cut(s.prompt, width)}</Text>
1040                </Box>
1041                <Text dimColor size="caption">{`${s.prompts} 条 · ${ago(s.time, at)}`}</Text>
1042              </Box>
1043            ))}
1044          </Box>
1045        ) : (
1046          <Text dimColor>这个项目还没有别的会话</Text>
1047        ),
1048      )}
1049    </Box>
1050  )
1051}
1052
types/index.d.ts 67 lines
1export type CockpitWindow = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type CockpitStats = {
4  turn: number
5  speed: number | null
6  turnCost: number | null
7  total: number
8  costAtStart: number
9  context: number
10}
11
12export type CockpitCommit = { hash: string; time: number; subject: string }
13
14export type CockpitGit = {
15  branch: string
16  changes: number
17  ahead: number | null
18  behind: number | null
19  commits: CockpitCommit[]
20}
21
22export type CockpitSession = {
23  id: string
24  prompt: string
25  prompts: number
26  time: number
27  // the latest session only: its last recap and the files it edited
28  recap?: string
29  edited?: string[]
30}
31
32export type CockpitDoc = { name: string; title: string; gist: string; lines: number; preview: string[] }
33
34export type CockpitProject = {
35  git: CockpitGit | null
36  docs: CockpitDoc[]
37  sessions: CockpitSession[]
38  scannedAt: number
39}
40
41export type CockpitTab = 'usage' | 'project' | 'prompts'
42
43declare module 'claude-code' {
44  interface PluginState {
45    // what the cockpit reads of the auto-continue mod, when it is installed
46    'auto-continue': {
47      isEnabled: boolean | null
48      maxWaitMs: number | null
49      wait: { wakeAt: number; kind: string | null } | null
50      scheduled: { at: number; text: string } | null
51    }
52    'desktop-cockpit': {
53      // the message typed in the usage tab for a set-time continue
54      autoMessage: string
55      stats: CockpitStats
56      windows: CockpitWindow[]
57      cacheExpiresAt: number | null
58      now: number
59      project: CockpitProject | null
60      isWelcomeShown: boolean
61      openDoc: string | null
62      tab: CockpitTab
63      filter: string
64    }
65  }
66}
67