A welcome card above the prompt when a session starts: the project's git state, recent commits and its recent sessions to resume

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 166 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { WelcomeInfo } from '../types'
5
6const info = atom({ plugin: 'welcome', key: 'info' } as const, null)
7const isShown = atom({ plugin: 'welcome', key: 'isShown' } as const, false)
8const openDoc = atom({ plugin: 'welcome', key: 'openDoc' } as const, null)
9
10const SESSIONS = 5
11const EDITED_SHOWN = 5
12const ACCENT = '#5fa8d3'
13
14const ago = (ms: number, now: number) => {
15 const min = Math.max(0, Math.round((now - ms) / 60000))
16 if (min < 60) return `${min}分钟前`
17 const h = Math.round(min / 60)
18 if (h < 24) return `${h}小时前`
19 return `${Math.round(h / 24)}天前`
20}
21
22const cut = (text: string, width: number) => (text.length > width ? text.slice(0, Math.max(1, width - 1)) + '…' : text)
23
24async function scan($: EngineInterface) {
25 const cwd = await $.session.cwd()
26 const self = await $.session.id()
27 const ran = await $.process
28 .run(['python3', `${$.plugin.root}/hooks/scan.py`, cwd, self, String(SESSIONS)], { timeoutMs: 15000 })
29 .catch(() => null)
30 if (!ran || ran.exitCode !== 0) return
31 const found: WelcomeInfo = JSON.parse(ran.stdout)
32 await update($, info, () => found)
33}
34
35async function show($: EngineInterface) {
36 await update($, isShown, () => true)
37 await scan($)
38}
39
40export const register: Register = on => {
41 on('session.start', async ($, e, next) => {
42 const started = await next(e)
43 await $.command.register({ name: 'welcome', description: '重新显示欢迎卡片:项目状态和最近会话' })
44 // A fresh session (a new launch, or /clear): no prompts yet; a resumed one has some.
45 if ((await $.session.turns()) === 0) await show($)
46 return started
47 })
48
49 on('command.run', { command: 'welcome' }, async $ => {
50 await show($)
51 return { text: '' }
52 })
53
54 // The card steps aside once the conversation starts.
55 on('prompt.submit', async ($, e, next) => {
56 if (e.origin.kind === 'composer') await update($, isShown, () => false)
57 return next(e)
58 })
59
60 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
61 const found = await read($, info)
62 // The desktop draws desktop-cockpit's card instead.
63 if (e.surface === 'desktop' || e.props.hasSurvey || !(await read($, isShown)) || found === null) return next(e)
64
65 const { Box, Text, Button } = $.ui.resolve(e)
66 const cwd = await $.session.cwd()
67 const now = found.scannedAt
68 const width = Math.max(20, e.props.bodyColumns - 30)
69 const git = found.git
70 const shownDoc = await read($, openDoc)
71 const latest = found.sessions[0]
72 const home = cwd.replace(/\/+$/, '') + '/'
73 const short = (path: string) => (path.startsWith(home) ? path.slice(home.length) : path.replace(/^\/root\//, '~/'))
74
75 const resume = async (id: string) => {
76 await update($, isShown, () => false)
77 await $.prompt.fill({ text: `/resume ${id}` })
78 $.ui.toast('按回车回到这个会话')
79 }
80
81 const card = (
82 <Box key="welcome" flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1}>
83 <Box flexDirection="row" columnGap={2}>
84 <Text bold color={ACCENT}>{cwd}</Text>
85 {git ? (
86 <Text>
87 ⎇ {git.branch}
88 {git.changes > 0 ? <Text color="yellow">{` ${git.changes} 个未提交改动`}</Text> : <Text dimColor> 工作区干净</Text>}
89 {git.ahead ? <Text color="green">{` ↑${git.ahead}`}</Text> : null}
90 {git.behind ? <Text color="red">{` ↓${git.behind}`}</Text> : null}
91 </Text>
92 ) : (
93 <Text dimColor>不是 git 仓库</Text>
94 )}
95 <Box flexGrow={1} />
96 <Button key="close" label="×" plain role="dismiss" onPress={() => update($, isShown, () => false)} />
97 </Box>
98 {git && git.commits.length > 0 ? (
99 <Box flexDirection="column" marginTop={1}>
100 <Text dimColor>最近提交</Text>
101 {git.commits.map(c => (
102 <Text key={c.hash}>
103 <Text color="yellow">{c.hash}</Text> {cut(c.subject, width)} <Text dimColor>{ago(c.time, now)}</Text>
104 </Text>
105 ))}
106 </Box>
107 ) : null}
108 {found.docs.length > 0 ? (
109 <Box flexDirection="column" marginTop={1}>
110 <Text dimColor>项目文档 · 点一下展开</Text>
111 {found.docs.map(d => (
112 <Box key={d.name} flexDirection="column">
113 <Button
114 key={`doc-${d.name}`}
115 plain
116 onPress={() => update($, openDoc, open => (open === d.name ? null : d.name))}
117 >
118 <Text color={ACCENT}>{shownDoc === d.name ? '▾ ' : '▸ '}{d.name}</Text>{' '}
119 {cut([d.title, d.gist].filter(Boolean).join(' — '), width)}
120 <Text dimColor>{` · ${d.lines}行`}</Text>
121 </Button>
122 {shownDoc === d.name ? (
123 <Box flexDirection="column" paddingLeft={2}>
124 {d.preview.map((line, i) => (
125 <Text key={`${d.name}-${i}`} dimColor wrap="truncate-end">{line}</Text>
126 ))}
127 </Box>
128 ) : null}
129 </Box>
130 ))}
131 </Box>
132 ) : null}
133 {latest && (latest.recap || (latest.edited?.length ?? 0) > 0) ? (
134 <Box flexDirection="column" marginTop={1}>
135 <Text dimColor>{`上次会话做了什么 · ${ago(latest.time, now)}`}</Text>
136 {latest.recap ? <Text>{latest.recap}</Text> : null}
137 {latest.edited && latest.edited.length > 0 ? (
138 <Text>
139 <Text dimColor>改动文件 </Text>
140 {latest.edited.slice(0, EDITED_SHOWN).map(short).join('、')}
141 {latest.edited.length > EDITED_SHOWN ? <Text dimColor>{` 等${latest.edited.length}个`}</Text> : null}
142 </Text>
143 ) : null}
144 </Box>
145 ) : null}
146 <Box flexDirection="column" marginTop={1}>
147 <Text dimColor>{found.sessions.length > 0 ? '最近会话 · 在空输入框按数字键接着聊' : '这个项目还没有别的会话'}</Text>
148 {found.sessions.map((s, i) => (
149 <Button key={s.id} plain hotkey={String(i + 1)} onPress={() => resume(s.id)}>
150 {cut(s.prompt, width)} <Text dimColor>{`· ${s.prompts}条 · ${ago(s.time, now)}`}</Text>
151 </Button>
152 ))}
153 </Box>
154 </Box>
155 )
156
157 const below = await next(e)
158 return (
159 <Box flexDirection="column">
160 {card}
161 {below}
162 </Box>
163 )
164 })
165}
166types/index.d.ts 30 lines1export type WelcomeCommit = { hash: string; time: number; subject: string }
2
3export type WelcomeGit = {
4 branch: string
5 changes: number
6 ahead: number | null
7 behind: number | null
8 commits: WelcomeCommit[]
9}
10
11export type WelcomeSession = {
12 id: string
13 prompt: string
14 prompts: number
15 time: number
16 // the latest session only: its last recap and the files it edited
17 recap?: string
18 edited?: string[]
19}
20
21export type WelcomeDoc = { name: string; title: string; gist: string; lines: number; preview: string[] }
22
23export type WelcomeInfo = { git: WelcomeGit | null; docs: WelcomeDoc[]; sessions: WelcomeSession[]; scannedAt: number }
24
25declare module 'claude-code' {
26 interface PluginState {
27 welcome: { info: WelcomeInfo | null; isShown: boolean; openDoc: string | null }
28 }
29}
30