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

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.
| Mod | What it does | ||
|---|---|---|---|
| quota-band | A 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. | ||
| welcome | When 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-cockpit | The 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). |
/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.
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.
hooks/register.tsx 1052 lines1import { 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}
1052types/index.d.ts 67 lines1export 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