A separate agent conversation in Claude Code's right-hand pane, forked from the current context.

A side chat for Claude Code. Run /side to open a second conversation next to your main one. It knows everything your main chat knows, so you can ask a quick question, dig into some code, or try an idea while Claude keeps working, without any of it landing in your main conversation. Closing the side chat throws it away.

Works on macOS, Linux, and Windows. You'll need Claude Code 2.1.287 or later, Node.js 18 or later (or Bun), and a terminal window at least 110 characters wide.
claude plugin marketplace add Ahmad8864/cc-side
claude plugin install cc-side@cc-side
/tui fullscreen to switch to the fullscreen view. You only need to do this once./side to open the side chat, or /side <question> to open it and ask right away.| Action | What happens |
|---|---|
| Click the input box | Start typing. Enter sends; Shift+Enter starts a new line |
Type / | See commands. Claude Code's own commands and your skills work too |
Type @ | Mention a file or folder, and press Tab to pick it (git projects) |
| Press ↑ or ↓ | Move through suggestions, or bring back a message you sent earlier |
| Press Esc | Go back to the main chat |
| Click a tool call | See its full details |
| Command | What it does |
|---|---|
/model, /effort | Change the model or effort for the side chat only |
/edit on, /edit off | Let Claude edit files, or stop it. Side chats start read-only and remember your last choice |
/refresh | Catch up with the main chat without losing your side conversation |
/insert | Put the last reply into the main chat's input box |
/copy | Copy the last reply |
/stop | Stop Claude mid-reply (or click Stop) |
/close | Close the side chat and throw it away (or click ×) |
/refresh catches up. After a refresh, Claude remembers your side conversation but not the files or output it looked at earlier.@ aren't listed under your message as they are in the main chat, though Claude still sees them./side <question> doesn't wait if the side chat is busy; your question goes back to your input box.Want to help? See CONTRIBUTING.md. MIT licensed; bundled components keep their own licenses. cc-side is an unofficial project, not affiliated with Anthropic.
hooks/register.tsx 327 lines1import type { EngineInterface, ProcessRunResult, Register } from 'claude-code'
2import type {
3 ChatState,
4 Endpoint,
5 SecuritySettings,
6 StartOptions,
7 StartupResult,
8} from '../shared/protocol.ts'
9import { isSupportedClaude, oldestSupportedClaude } from '../shared/claude-version.ts'
10import { errorMessage } from '../shared/errors.ts'
11import { effortLevels } from '../shared/models.ts'
12import { renderPane } from './pane.tsx'
13import { SideChat, type BridgePath, type StartChoices } from './side-chat.ts'
14
15const PANE = 'side'
16// The open side chat's helper, which a hot reload of this module would otherwise lose.
17const CONNECTION = { plugin: 'cc-side', key: 'connection' } as const
18// The narrowest terminal that fits both conversations side by side.
19const MIN_COLUMNS = 110
20const paneColumns = (columns: number) => Math.max(45, Math.floor(columns * 0.44))
21
22export const register: Register = (on) => {
23 const chat = new SideChat()
24 let viewportColumns = 0
25
26 on('session.start', async ($, e, next) => {
27 const result = await next(e)
28 if (!e.isInteractive || (await $.env.get('CC_SIDE_WORKER'))) return result
29 chat.host = {
30 ...createBridgeClient($),
31 write: (path, text) => $.fs.write(path, text),
32 invalidate: () => {
33 $.ui.invalidate('ui.render')
34 },
35 after: (ms, fn) => {
36 $.clock.after(ms, fn)
37 },
38 scroll: () => {
39 void $.ui.scroll({ in: PANE, to: 'end' })
40 },
41 reveal: (key) => {
42 void $.ui.scroll({ in: PANE, to: { key } })
43 },
44 focus: async (key) => {
45 await $.ui.focus({ requestId: PANE, key })
46 },
47 // Discard explicitly: hiding the host pane is not our state lifecycle.
48 closePane: async () => {
49 await chat.close()
50 await $.ui.close({ id: PANE })
51 },
52 insertInMain: async (text) => fillRefusal(await $.prompt.fill({ text, mode: 'insert' })),
53 copy: async (text) => (await $.ui.copy({ text })).isCopied,
54 listFiles: async () => {
55 const listing = await $.process.run(['git', 'ls-files', '-z', '-co', '--exclude-standard'])
56 return listing.exitCode === 0 ? listing.stdout.split('\0').filter(Boolean) : []
57 },
58 readEditing: async () => (await $.store.get('canEdit')) === true,
59 saveEditing: async (canEdit) => {
60 await $.store.set('canEdit', canEdit)
61 },
62 saveConnection: async (endpoint) => {
63 await $.state.set(CONNECTION, endpoint)
64 },
65 }
66 chat.tracePath = (await $.env.get('CC_SIDE_TRACE')) ?? undefined
67 await $.command.register({
68 name: 'side',
69 description: 'Open an independent chat beside this conversation',
70 argumentHint: '[question] | close',
71 immediate: true,
72 })
73 chat.trace('session.start', {
74 id: await $.session.id(),
75 cwd: e.cwd,
76 model: await $.session.model(),
77 version: await claudeVersion($),
78 })
79 // After a hot reload the pane and its helper are still up; only this module forgot them.
80 const { value: connection } = await $.state.get(CONNECTION)
81 if (connection && (await $.ui.panes()).some((pane) => pane.id === PANE))
82 await chat.resume(connection)
83 return result
84 })
85 on('command.run', { command: 'side' }, async ($, e) => {
86 const arg = e.args.trim()
87 if (arg === 'close') {
88 await chat.host.closePane()
89 return {}
90 }
91 // A diagnostic, left out of the hint: cache totals, and a full snapshot when tracing.
92 if (arg === 'stats') {
93 if (chat.tracePath)
94 chat.trace('snapshot', {
95 state: chat.state,
96 parent: await $.session.messages(),
97 tools: (await $.tool.list()).map((t) => t.name),
98 })
99 await chat.flushed()
100 return { text: chat.stats() }
101 }
102 const version = await claudeVersion($)
103 if (!version || !isSupportedClaude(version)) {
104 const running = version ? `; this is ${version}` : ''
105 return {
106 text: `cc-side needs Claude Code ${oldestSupportedClaude} or later${running}. Run claude update, then start a new session.`,
107 }
108 }
109 const { isFullscreen, columns } = e.presentation
110 if (!isFullscreen) {
111 return { text: 'Side chat needs the fullscreen renderer. Run /tui fullscreen, then /side.' }
112 }
113 if (columns < MIN_COLUMNS) {
114 return {
115 text: `Side chat needs a terminal at least ${MIN_COLUMNS} columns wide; this one is ${columns}. Widen it, then run /side.`,
116 }
117 }
118 chat.opened = true
119 viewportColumns = columns
120 await $.ui.open({
121 id: PANE,
122 title: 'Side chat',
123 focus: true,
124 columns: paneColumns(viewportColumns),
125 })
126 await chat.connect()
127 const reason = arg ? await chat.ask(arg) : undefined
128 if (!reason) return {}
129 // Put the rejected command back where it was entered. Never replace
130 // another draft, including text typed while the request was in flight.
131 let restored = false
132 try {
133 const prompt = await $.prompt.read()
134 if (!prompt.text)
135 restored = (await $.prompt.fill({ text: `/side ${arg}`, mode: 'insert' })).isFilled
136 } catch {
137 /* The visible response below still preserves the question. */
138 }
139 return {
140 text: restored
141 ? `${reason} Your question is back in the prompt.`
142 : `${reason}\n\nNot sent:\n${arg}`,
143 }
144 })
145 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
146 if (e.requestId !== PANE || !chat.opened || e.surface !== 'terminal') return next(e)
147 // In a docked Pane, viewport.columns is the MAIN transcript's width.
148 // Add this pane and the one-cell divider to recover the terminal width.
149 const terminalColumns =
150 e.viewport &&
151 e.viewport.columns + (e.props.placement === 'dock' ? e.props.bodyColumns + 1 : 0)
152 if (terminalColumns && terminalColumns !== viewportColumns) {
153 viewportColumns = terminalColumns
154 if (e.viewport?.isFullscreen && viewportColumns >= MIN_COLUMNS) {
155 // Updating this pane preserves its session and editor. Omit focus so a
156 // window resize does not take the keyboard from either conversation.
157 await $.ui.open({ id: PANE, title: 'Side chat', columns: paneColumns(viewportColumns) })
158 chat.trace('side.resized', {
159 columns: viewportColumns,
160 requested: paneColumns(viewportColumns),
161 })
162 }
163 }
164 const elements = await $.ui.resolve(e)
165 const { Client } = elements
166 chat.focusCautiousPermission()
167 const columns = e.props.bodyColumns - 2
168 chat.resizeComposer(columns, e.props.scroll.bodyRows)
169 return renderPane(
170 elements,
171 {
172 ...chat.paneView(),
173 width: e.props.bodyColumns,
174 columns,
175 rows: e.props.scroll.bodyRows,
176 composer: (
177 <Client
178 key={`side-composer-${chat.generation}`}
179 module="./composer.tsx"
180 width={columns}
181 props={chat.composerProps()}
182 />
183 ),
184 },
185 chat.paneActions,
186 )
187 })
188 on('ui.message', { component: 'Pane' }, async ($, e, next) => {
189 if (e.requestId !== PANE || e.element !== `side-composer-${chat.generation}` || !chat.opened)
190 return next(e)
191 return chat.receive(e.data)
192 })
193 on('turn.complete', async ($, e, next) => {
194 if (!e.agentId) chat.mainReplied()
195 return next(e)
196 })
197 on('classic.Stop', async ($, e, next) => {
198 // A new side chat starts at the effort of the main thread's last turn.
199 if (!e.agent_id) chat.mainEffort = effortLevels.find((level) => level === e.effort?.level)
200 return next(e)
201 })
202 on('ui.scroll', { component: 'Pane' }, async ($, e, next) => {
203 const result = await next(e)
204 if (e.requestId === PANE && e.origin.kind === 'person' && !result.deny)
205 chat.follow = e.offset >= Math.max(0, e.contentRows - e.bodyRows)
206 return result
207 })
208 on('ui.close', { id: PANE }, async ($, e, next) => {
209 await chat.close()
210 return next(e)
211 })
212 on('session.end', async ($, e, next) => {
213 // /clear ends the session but keeps its panes, and no session.start follows.
214 if (chat.opened) await chat.host.closePane()
215 await chat.flushed()
216 return next(e)
217 })
218}
219
220// The engine's version; engines before 2.1.284 cannot say.
221async function claudeVersion($: EngineInterface) {
222 try {
223 return (await $.session.version()).version
224 } catch {
225 return undefined
226 }
227}
228
229// Why the main prompt did not take text, in words the person can act on.
230function fillRefusal({ isFilled, refusal }: { isFilled: boolean; refusal?: string }) {
231 if (isFilled) return undefined
232 return refusal === 'dialog'
233 ? 'A dialog in the main chat has the keyboard. Answer it, then try again.'
234 : 'The main prompt is not available right now.'
235}
236
237// Mods requires engine calls to stay in the registered hook module.
238function createBridgeClient($: EngineInterface) {
239 let helper: string[] | undefined
240 return {
241 async options(choices: StartChoices): Promise<StartOptions> {
242 const isolatedTest = !!(await $.env.get('CC_SIDE_TEST'))
243 const { permissions, sandbox } = await $.settings.read()
244 return {
245 parentSessionId: await $.session.id(),
246 cwd: await $.session.cwd(),
247 model: await $.session.model(),
248 ...choices,
249 allowEmptyParent: (await $.session.messages()).length === 0,
250 securitySettings: { permissions, sandbox } as SecuritySettings,
251 ...(isolatedTest ? { isolatedTest, settingSources: ['project', 'local'] } : {}),
252 }
253 },
254
255 async start(options: StartOptions): Promise<Endpoint> {
256 helper ??= await helperCommand($)
257 const failed = (cause: string) =>
258 new Error(
259 `Could not start the side helper: ${cause.replace(/\.$/, '')}. Reinstall cc-side, or check the development setup if running from source.`,
260 )
261 let output: ProcessRunResult
262 try {
263 output = await $.process.run(helper, { stdin: JSON.stringify(options), timeoutMs: 15000 })
264 } catch (error) {
265 throw failed(errorMessage(error))
266 }
267 let startup: StartupResult
268 try {
269 startup = JSON.parse(output.stdout)
270 } catch {
271 throw failed(crashCause(output.stderr))
272 }
273 if ('error' in startup) throw new Error(startup.error)
274 return startup
275 },
276
277 async request(endpoint: Endpoint, path: BridgePath, body?: unknown): Promise<ChatState> {
278 const response = await $.http.fetch(endpoint.url + path, {
279 method: path === '/state' ? 'GET' : 'POST',
280 headers: {
281 Authorization: `Bearer ${endpoint.token}`,
282 'Content-Type': 'application/json',
283 },
284 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
285 })
286 if (!response.ok) {
287 let message = response.text
288 try {
289 message = JSON.parse(response.text).error ?? message
290 } catch {
291 /* Plain-text errors also occur before the bridge accepts a request. */
292 }
293 throw new Error(message)
294 }
295 return JSON.parse(response.text)
296 },
297 }
298}
299
300// What a crashed runtime said went wrong: the first line naming an error, since Node ends
301// its report with its version.
302function crashCause(stderr: string) {
303 const lines = stderr
304 .split('\n')
305 .map((line) => line.trim())
306 .filter(Boolean)
307 const cause = lines.find((line) => /error/i.test(line)) ?? lines[0] ?? 'it exited without a reply'
308 return cause.slice(0, 300)
309}
310
311// The runtimes that run the helper, in the order tried, with the oldest major of each.
312const runtimes = { node: 18, bun: 1 }
313
314// The helper on the first runtime on PATH; CC_SIDE_BUN runs the source in development.
315async function helperCommand($: EngineInterface) {
316 const bun = await $.env.get('CC_SIDE_BUN')
317 if (bun) return [bun, `${$.plugin.root}/bridge/main.ts`]
318 for (const [runtime, oldest] of Object.entries(runtimes)) {
319 const version = await $.process.run([runtime, '--version']).then(
320 (result) => result.stdout,
321 () => '',
322 )
323 if (Number(/\d+/.exec(version)?.[0]) >= oldest) return [runtime, `${$.plugin.root}/helper.mjs`]
324 }
325 throw new Error('Node.js 18 or later, or Bun, was not found. Install one before using cc-side.')
326}
327shared/protocol.ts 93 lines1import type { EffortLevel, Settings } from '@anthropic-ai/claude-agent-sdk'
2
3export type { EffortLevel }
4
5export type SecuritySettings = Pick<Settings, 'permissions' | 'sandbox'>
6
7export type ChatMessage = {
8 id: string
9 role: 'user' | 'assistant' | 'tool' | 'notice'
10 text: string
11 // Output of a side command such as /help, which Claude did not write.
12 local?: boolean
13 toolName?: string
14 toolInput?: string
15 outputTruncated?: boolean
16 elapsedSeconds?: number
17 status?: 'running' | 'done' | 'error' | 'cancelled'
18}
19export type Usage = {
20 input_tokens: number
21 output_tokens: number
22 cache_read_input_tokens: number
23 cache_creation_input_tokens: number
24}
25export type Permission = {
26 id: string
27 tool: string
28 input: Record<string, unknown>
29 title?: string
30 description?: string
31 decisionReason?: string
32 blockedPath?: string
33 mcpServer?: { name: string; source: string }
34 defaultToNo?: boolean
35 editStarts?: number[]
36}
37export type SideCommand = {
38 name: string
39 description: string
40 argumentHint: string
41 aliases?: string[]
42}
43export type SideModel = {
44 value: string
45 resolvedModel?: string
46 displayName: string
47 description: string
48 supportedEffortLevels?: string[]
49}
50export type Activity = {
51 phase: 'requesting' | 'thinking' | 'responding' | 'tool' | 'compacting' | 'stopping'
52 startedAt: number
53}
54export type Submission = { id: string; text: string }
55export type Receipt = { id: string; accepted: boolean }
56export type ChatState = {
57 revision: number
58 status: 'starting' | 'ready' | 'working' | 'permission' | 'error' | 'closed'
59 messages: ChatMessage[]
60 permissions: Permission[]
61 sessionId?: string
62 error?: string
63 notice?: string
64 requests: { id: string; usage: Usage }[]
65 textDeltas: number
66 model?: string
67 effort?: string
68 canEdit?: boolean
69 cwd?: string
70 commands?: SideCommand[]
71 models?: SideModel[]
72 activity?: Activity | null
73}
74export type StartOptions = {
75 parentSessionId: string
76 allowEmptyParent?: boolean
77 resumeSessionAt?: string
78 cwd: string
79 model: string
80 effort?: EffortLevel
81 canEdit?: boolean
82 // The side's messages before a refresh, shown again and summarized for Claude.
83 carried?: ChatMessage[]
84 // Set by the helper as it starts: the Claude that started it, and its executable.
85 ownerPid?: number
86 claudePath?: string
87 settingSources?: ('user' | 'project' | 'local')[]
88 securitySettings?: SecuritySettings
89 isolatedTest?: boolean
90}
91export type Endpoint = { url: string; token: string; pid: number }
92export type StartupResult = Endpoint | { error: string }
93shared/claude-version.ts 13 lines1// The oldest Claude Code whose Mods API cc-side relies on.
2export const oldestSupportedClaude = '2.1.287'
3
4/** Whether a Claude Code version, a release or a development build of one, is supported. */
5export function isSupportedClaude(version: string) {
6 const release = (v: string) => /^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1).map(Number)
7 const have = release(version)
8 const need = release(oldestSupportedClaude)!
9 if (!have) return false
10 const differs = have.findIndex((part, i) => part !== need[i])
11 return differs < 0 || have[differs] > need[differs]
12}
13shared/errors.ts 4 lines1/** What the user reads for a caught error: its message, without the "Error:" prefix. */
2export const errorMessage = (error: unknown) =>
3 error instanceof Error ? error.message : String(error)
4shared/models.ts 26 lines1import type { EffortLevel, SideModel } from './protocol.ts'
2
3export const effortLevels: EffortLevel[] = ['low', 'medium', 'high', 'xhigh', 'max']
4
5const findModel = (model: string, models: SideModel[]) =>
6 models.find((m) => m.value === model || m.resolvedModel === model)
7
8export function modelLabel(model: string, models: SideModel[] = []) {
9 const entry = findModel(model, models)
10 // Some catalogs name the version before " · " in the description, others in displayName.
11 const [version, summary] = entry?.description.split(' · ') ?? []
12 return (
13 (summary ? version : entry?.displayName) ||
14 model
15 .replace(/^claude-/, '')
16 .replace(/\[1m\]/, ' (1M context)')
17 .replace(/-/g, ' ')
18 )
19}
20
21/** A listed model without effort levels ignores effort; an unlisted one may accept any. */
22export function supportedEfforts(model: string, models: SideModel[] = []): string[] {
23 const entry = findModel(model, models)
24 return entry ? (entry.supportedEffortLevels ?? []) : effortLevels
25}
26hooks/pane.tsx 155 lines1import type { Elements, RenderElement } from 'claude-code'
2import type { ChatState } from '../shared/protocol.ts'
3import { modelLabel, supportedEfforts } from '../shared/models.ts'
4import { treeLimit } from '../shared/limits.ts'
5import { renderPermissions, type Answers } from './permissions.tsx'
6import { renderMessages } from './transcript.tsx'
7
8export type PaneView = {
9 state: ChatState
10 // The Pane's body, and the columns inside its padding.
11 width: number
12 columns: number
13 rows: number
14 composer: RenderElement
15 answers: Answers
16 expanded: Set<string>
17 localError: string
18 localNotice: string
19 busy: boolean
20 connected: boolean
21 refreshing: boolean
22 mainAhead: number
23}
24
25export type PaneActions = {
26 invalidate: () => void
27 setError: (message: string) => void
28 decide: (id: string, allow: boolean, answers?: Record<string, string>) => Promise<boolean>
29 toggleEditing: () => Promise<boolean>
30 onToggle: (key: string) => void
31 refresh: () => Promise<boolean>
32 retry: () => Promise<void>
33 stop: () => Promise<boolean>
34}
35
36export function renderPane(elements: Elements['terminal'], view: PaneView, actions: PaneActions) {
37 const { Box, Text, Button } = elements
38 const { state, columns, composer, mainAhead, refreshing } = view
39 const notice =
40 state.status === 'starting'
41 ? 'Connecting…'
42 : refreshing
43 ? 'Refreshing…'
44 : view.localNotice || state.notice
45 const effort =
46 state.model && state.effort && supportedEfforts(state.model, state.models).length
47 ? ` (${state.effort})`
48 : ''
49 const permissions = renderPermissions(
50 elements,
51 state.permissions,
52 view.answers,
53 actions,
54 state.cwd,
55 )
56 // Messages get what the rest of the pane leaves of the drawing limit.
57 const budget = treeLimit - JSON.stringify([permissions, composer]).length - 5000
58 return (
59 <Box flexDirection="column" paddingX={1} minHeight={view.rows} width={view.width}>
60 <Box gap={2} paddingRight={2}>
61 <Text bold>Side chat</Text>
62 <Box flexShrink={1}>
63 <Text dimColor wrap="truncate-end">
64 {state.model ? `${modelLabel(state.model, state.models)}${effort}` : ''}
65 </Text>
66 </Box>
67 <Box>
68 {state.canEdit === undefined ? null : (
69 <Button
70 key="edit-side"
71 plain
72 dimColor={!state.canEdit}
73 label={state.canEdit ? 'can edit' : 'read-only'}
74 onPress={async () => {
75 await actions.toggleEditing()
76 }}
77 />
78 )}
79 </Box>
80 </Box>
81 <Box flexDirection="column" flexGrow={1} paddingTop={1} width={columns}>
82 {renderMessages(elements, state.messages, {
83 columns,
84 expanded: view.expanded,
85 onToggle: actions.onToggle,
86 cwd: state.cwd,
87 budget,
88 })}
89 </Box>
90 {/* Keep the editor's ancestor/sibling positions stable. The terminal
91 focus region can remount when conditional siblings appear. */}
92 {permissions}
93 <Box>
94 {view.localError || state.error ? (
95 <Text color="error">{view.localError || state.error}</Text>
96 ) : null}
97 </Box>
98 <Box marginTop={1} flexDirection="column" width={columns}>
99 {/* Like main's own hints: one quiet line, the notice left and staleness right. */}
100 <Box justifyContent="space-between">
101 <Box flexShrink={1}>
102 {notice ? (
103 <Text dimColor wrap="truncate-end">
104 {notice}
105 </Text>
106 ) : null}
107 </Box>
108 <Box>
109 {mainAhead > 0 && !refreshing ? (
110 <Box gap={1}>
111 <Text dimColor>
112 {`main is ${mainAhead} ${mainAhead === 1 ? 'reply' : 'replies'} ahead ·`}
113 </Text>
114 <Button
115 key="refresh-side"
116 plain
117 label="/refresh"
118 onPress={async () => {
119 await actions.refresh()
120 }}
121 />
122 </Box>
123 ) : null}
124 </Box>
125 </Box>
126 <Box>
127 {state.status === 'error' && !view.connected ? (
128 <Button
129 key="retry-side"
130 label="Retry"
131 onPress={async () => {
132 await actions.retry()
133 }}
134 />
135 ) : null}
136 </Box>
137 {composer}
138 <Box gap={2} justifyContent="flex-end">
139 <Text dimColor>Esc main</Text>
140 {view.busy ? (
141 <Button
142 key="stop-side"
143 plain
144 label="Stop"
145 onPress={async () => {
146 await actions.stop()
147 }}
148 />
149 ) : null}
150 </Box>
151 </Box>
152 </Box>
153 )
154}
155hooks/side-chat.ts 558 lines1import type {
2 ChatState,
3 EffortLevel,
4 Endpoint,
5 Receipt,
6 StartOptions,
7 Submission,
8} from '../shared/protocol.ts'
9import { localCommands, parseCommand } from '../shared/commands.ts'
10import { errorMessage } from '../shared/errors.ts'
11import { messageLimit } from '../shared/limits.ts'
12import { rankPaths, withFolders } from '../shared/mentions.ts'
13import { effortLevels } from '../shared/models.ts'
14import type { ComposerProps } from './composer.tsx'
15import type { PaneActions, PaneView } from './pane.tsx'
16import type { Answers } from './permissions.tsx'
17
18export type BridgePath = '/state' | '/send' | '/permission' | '/edit' | '/stop' | '/close'
19export type StartChoices = Partial<Pick<StartOptions, 'model' | 'effort' | 'canEdit' | 'carried'>>
20
21/** What the side chat needs from Claude. The hook module provides it, since only it may use `$`. */
22type Host = {
23 options: (choices: StartChoices) => Promise<StartOptions>
24 start: (options: StartOptions) => Promise<Endpoint>
25 request: (endpoint: Endpoint, path: BridgePath, body?: unknown) => Promise<ChatState>
26 write: (path: string, text: string) => Promise<void>
27 invalidate: () => void
28 after: (ms: number, fn: () => void) => void
29 scroll: () => void
30 reveal: (key: string) => void
31 focus: (key: string) => Promise<void>
32 closePane: () => Promise<void>
33 readEditing: () => Promise<boolean>
34 saveEditing: (canEdit: boolean) => Promise<void>
35 // Held by Claude across hot reloads of this module, which forget the helper otherwise.
36 saveConnection: (endpoint: Endpoint | null) => Promise<void>
37 // Resolves to why main's prompt refused the text, or undefined once it holds it.
38 insertInMain: (text: string) => Promise<string | undefined>
39 copy: (text: string) => Promise<boolean>
40 // The project's files relative to its folder, as git lists them; none outside a repository.
41 listFiles: () => Promise<string[]>
42}
43
44type ComposerPost = {
45 epoch: number
46 seq: number
47 instance: string
48 text: string
49 submit?: Submission
50 mention?: string
51}
52
53const empty = (): ChatState => ({
54 revision: -1,
55 status: 'starting',
56 messages: [],
57 permissions: [],
58 requests: [],
59 textDeltas: 0,
60})
61
62/** The pane's side chat: its helper, sends and their receipts, and what the pane shows. */
63export class SideChat {
64 // Set by the hook module when a session starts.
65 host!: Host
66 tracePath: string | undefined
67 opened = false
68 // generation names an opened side chat and its composer; connection names the
69 // helper serving it, which a refresh replaces without disturbing the composer.
70 generation = 0
71 state = empty()
72 // Whether the pane scrolls to new output; scrolling up stops it.
73 follow = true
74 mainEffort: EffortLevel | undefined
75 private connection = 0
76 private endpoint: Endpoint | undefined
77 private connecting = false
78 private sending = false
79 private draft = ''
80 private localError = ''
81 private localNotice = ''
82 private answers: Answers = {}
83 private readonly expanded = new Set<string>()
84 private receipt: Receipt | null = null
85 private readonly receipts = new Map<string, Receipt>()
86 private readonly pendingSubmissions = new Map<string, Promise<boolean>>()
87 private commandSequence = 0
88 private composerColumns = 70
89 private composerRows = 40
90 // The project's files and folders for `@` completion, listed again once they are stale.
91 private projectPaths: { paths: Promise<string[]>; listedAt: number } | undefined
92 private mention: ComposerProps['mention'] = null
93 private focusedPermission: string | undefined
94 private savedEditing = false
95 // Main replies that finished since the side forked, and a refresh in flight.
96 private mainAhead = 0
97 private refreshing = false
98 private readonly events: unknown[] = []
99 private writes = Promise.resolve()
100
101 readonly paneActions: PaneActions = {
102 invalidate: () => this.host.invalidate(),
103 setError: (message) => {
104 this.localError = message
105 this.host.invalidate()
106 },
107 decide: (id, allow, answers) =>
108 this.action('/permission', { id, allow, ...(answers ? { answers } : {}) }),
109 toggleEditing: () => this.action('/edit', { canEdit: !this.state.canEdit }),
110 onToggle: (key) => {
111 this.follow = false
112 this.host.invalidate()
113 this.host.after(80, () => {
114 if (this.opened) this.host.reveal(key)
115 })
116 },
117 refresh: () => this.refresh(),
118 retry: () => this.connect(),
119 stop: () => this.action('/stop'),
120 }
121
122 get connected() {
123 return !!this.endpoint
124 }
125
126 /** What the pane shows, apart from its size and the composer. */
127 paneView(): Omit<PaneView, 'width' | 'columns' | 'rows' | 'composer'> {
128 return {
129 state: this.state,
130 answers: this.answers,
131 expanded: this.expanded,
132 localError: this.localError,
133 localNotice: this.localNotice,
134 busy: this.busy(),
135 connected: this.connected,
136 refreshing: this.refreshing,
137 mainAhead: this.mainAhead,
138 }
139 }
140
141 composerProps(): ComposerProps {
142 const { state } = this
143 return {
144 epoch: this.generation,
145 seed: this.draft,
146 receipt: this.receipt,
147 busy: this.busy() || this.refreshing,
148 activity: state.status === 'working' ? (state.activity ?? null) : null,
149 columns: this.composerColumns,
150 maxRows: Math.max(2, Math.min(8, Math.floor(this.composerRows / 4))),
151 commands: state.commands ?? localCommands,
152 models: state.models ?? [],
153 model: state.model ?? '',
154 effort: state.effort ?? '',
155 canEdit: state.canEdit ?? false,
156 mention: this.mention,
157 }
158 }
159
160 resizeComposer(columns: number, rows: number) {
161 this.composerColumns = columns
162 this.composerRows = rows
163 }
164
165 /** Moves the keyboard to Deny when Claude marks an approval as safer to refuse. */
166 focusCautiousPermission() {
167 const cautious = this.state.permissions.find((permission) => permission.defaultToNo)?.id
168 if (cautious === this.focusedPermission) return
169 this.focusedPermission = cautious
170 if (cautious)
171 this.host.after(80, () => {
172 if (this.opened && this.state.permissions.some((permission) => permission.id === cautious))
173 void this.host.focus(`deny-${cautious}`)
174 })
175 }
176
177 /** Takes a composer post: its draft, and a send to acknowledge once. */
178 async receive(data: unknown): Promise<{ props?: ComposerProps }> {
179 if (!isPost(data, this.generation)) return {}
180 const submission = data.submit
181 if (!submission || !this.receipts.get(submission.id)?.accepted) this.draft = data.text
182 if (submission && isSubmission(submission, this.generation, data.instance)) {
183 const generation = this.generation
184 const prior = this.receipts.get(submission.id)
185 if (prior) this.receipt = prior
186 else {
187 let pending = this.pendingSubmissions.get(submission.id)
188 if (!pending) {
189 pending = this.send(submission.text, submission.id)
190 this.pendingSubmissions.set(submission.id, pending)
191 }
192 let accepted = false
193 try {
194 accepted = await pending
195 } catch (error) {
196 if (generation === this.generation) this.localError = errorMessage(error)
197 }
198 if (generation !== this.generation) return {}
199 this.pendingSubmissions.delete(submission.id)
200 this.receipt = { id: submission.id, accepted }
201 this.receipts.set(submission.id, this.receipt)
202 if (this.receipts.size > 128) this.receipts.delete(this.receipts.keys().next().value!)
203 this.host.invalidate()
204 }
205 }
206 const mention = data.mention === undefined ? null : await this.matches(data.mention)
207 if (data.epoch !== this.generation) return {}
208 this.mention = mention
209 // Reply on the Client's own channel too; it must not depend on a later
210 // pane redraw to release the pending send after an error or remount.
211 return { props: this.composerProps() }
212 }
213
214 /** Sends a question given with /side, or says why it was not sent. */
215 async ask(text: string): Promise<string | undefined> {
216 const generation = this.generation
217 const busy = this.busy()
218 const accepted = this.endpoint && (await this.send(text))
219 if (accepted || generation !== this.generation || !this.opened) return
220 return busy
221 ? 'Side chat is busy. Wait for the reply or stop it, then retry.'
222 : this.localError || 'Side chat is not ready yet. Retry once it is connected.'
223 }
224
225 /** Counts a main-loop reply that the open side chat has not seen. */
226 mainReplied() {
227 if (!this.endpoint) return
228 this.mainAhead++
229 this.host.invalidate()
230 }
231
232 stats() {
233 const { requests, status, textDeltas } = this.state
234 const read = requests.reduce((sum, r) => sum + (r.usage.cache_read_input_tokens ?? 0), 0)
235 const written = requests.reduce((sum, r) => sum + (r.usage.cache_creation_input_tokens ?? 0), 0)
236 return `Side chat: ${status} · ${requests.length} model requests · cache read ${read} / write ${written} tokens · ${textDeltas} text deltas`
237 }
238
239 trace(kind: string, data: unknown) {
240 if (!this.tracePath) return
241 this.events.push({ at: Date.now(), kind, data })
242 let json = JSON.stringify({ events: this.events }, null, 2)
243 // Mods writes at most 4 MiB of UTF-8, so keep the newest events that fit.
244 while (json.length > 1000000 && this.events.length > 1) {
245 this.events.splice(0, Math.ceil(this.events.length / 4))
246 json = JSON.stringify({ events: this.events }, null, 2)
247 }
248 this.writes = this.writes.then(() => this.host.write(this.tracePath!, json)).catch(() => {})
249 }
250
251 /** Resolves once the traced events so far are written. */
252 flushed() {
253 return this.writes
254 }
255
256 async connect() {
257 if (!this.opened || this.connecting || this.endpoint) return
258 this.connecting = true
259 this.mainAhead = 0
260 this.state = empty()
261 this.localError = ''
262 this.follow = true
263 this.generation++
264 const connection = ++this.connection
265 this.receipt = null
266 this.receipts.clear()
267 this.pendingSubmissions.clear()
268 this.host.invalidate()
269 try {
270 this.savedEditing = await this.host.readEditing()
271 const started = await this.host.start(
272 await this.host.options({ effort: this.mainEffort, canEdit: this.savedEditing }),
273 )
274 if (connection !== this.connection) {
275 await this.host.request(started, '/close')
276 return
277 }
278 this.setEndpoint(started)
279 this.trace('side.opened', { pid: started.pid, placement: 'right' })
280 void this.poll(connection)
281 } catch (error) {
282 if (connection === this.connection) {
283 this.localError = errorMessage(error)
284 this.state.status = 'error'
285 this.host.invalidate()
286 }
287 } finally {
288 if (connection === this.connection) this.connecting = false
289 }
290 }
291
292 /** Reconnects the pane to a helper that outlived a hot reload of this module. */
293 async resume(endpoint: Endpoint) {
294 if (this.opened) return
295 this.opened = true
296 this.generation++
297 const connection = ++this.connection
298 this.endpoint = endpoint
299 this.savedEditing = await this.host.readEditing()
300 this.trace('side.resumed', { pid: endpoint.pid })
301 void this.poll(connection)
302 }
303
304 async close() {
305 if (!this.opened && !this.endpoint && !this.connecting) return
306 const old = this.endpoint
307 this.generation++
308 this.connection++
309 this.opened = false
310 this.mainAhead = 0
311 this.refreshing = false
312 this.setEndpoint(undefined)
313 this.state = empty()
314 this.draft = ''
315 this.answers = {}
316 this.sending = false
317 this.connecting = false
318 this.localError = ''
319 this.receipt = null
320 this.receipts.clear()
321 this.pendingSubmissions.clear()
322 this.expanded.clear()
323 this.projectPaths = undefined
324 this.mention = null
325 this.host.invalidate()
326 if (old) {
327 try {
328 await this.host.request(old, '/close')
329 } catch (error) {
330 this.trace('side.close-error', errorMessage(error))
331 }
332 }
333 this.trace('side.closed', {})
334 }
335
336 private setEndpoint(endpoint: Endpoint | undefined) {
337 this.endpoint = endpoint
338 void this.host.saveConnection(endpoint ?? null).catch(() => {})
339 }
340
341 // The project's paths matching an `@` mention being typed. They are listed at most every
342 // 10 seconds, and concurrent posts share one listing, so they answer in order.
343 private async matches(query: string) {
344 if (!this.projectPaths || Date.now() - this.projectPaths.listedAt > 10000)
345 this.projectPaths = {
346 paths: this.host.listFiles().then(withFolders, () => []),
347 listedAt: Date.now(),
348 }
349 return { query, paths: rankPaths(await this.projectPaths.paths, query) }
350 }
351
352 // Sending, or the helper is answering or waiting on an approval.
353 private busy() {
354 return this.sending || this.state.status === 'working' || this.state.status === 'permission'
355 }
356
357 // Takes the helper's newer state, remembering its edit setting for new side chats.
358 private update(next: ChatState) {
359 this.state = next
360 if (next.canEdit === undefined || next.canEdit === this.savedEditing) return
361 this.savedEditing = next.canEdit
362 void this.host.saveEditing(this.savedEditing)
363 }
364
365 private notify(message: string) {
366 this.localNotice = message
367 this.host.invalidate()
368 this.host.after(5000, () => {
369 if (this.localNotice !== message) return
370 this.localNotice = ''
371 this.host.invalidate()
372 })
373 }
374
375 private async poll(connection: number, failures = 0) {
376 if (connection !== this.connection || !this.endpoint) return
377 try {
378 const result = await this.host.request(this.endpoint, '/state')
379 if (connection !== this.connection) return
380 if (result.revision > this.state.revision) {
381 this.update(result)
382 this.trace('side.state', this.state)
383 this.host.invalidate()
384 if (this.follow)
385 this.host.after(80, () => {
386 if (this.opened && this.follow) this.host.scroll()
387 })
388 }
389 } catch (error) {
390 if (connection !== this.connection) return
391 // A busy or waking machine can miss a request; a stopped helper misses them all.
392 if (failures < 4) {
393 this.host.after(1000, () => {
394 void this.poll(connection, failures + 1)
395 })
396 return
397 }
398 this.localError = `Side process disconnected. Close and reopen /side. ${errorMessage(error)}`
399 this.host.invalidate()
400 return
401 }
402 this.host.after(this.state.status === 'working' ? 120 : 700, () => {
403 void this.poll(connection)
404 })
405 }
406
407 private async action(path: BridgePath, body?: unknown) {
408 if (!this.endpoint) return false
409 const connection = this.connection
410 try {
411 const result = await this.host.request(this.endpoint, path, body)
412 if (connection === this.connection && result.revision >= this.state.revision)
413 this.update(result)
414 return connection === this.connection
415 } catch (error) {
416 if (connection === this.connection) this.localError = errorMessage(error)
417 return false
418 } finally {
419 if (connection === this.connection) this.host.invalidate()
420 }
421 }
422
423 private async send(
424 text: string,
425 id = `${this.generation}:command:${++this.commandSequence}`,
426 ): Promise<boolean> {
427 const trimmed = text.trim()
428 // Commands the pane runs itself; worksWhileBusy names the ones taken while Claude works.
429 const command = parseCommand(trimmed)?.name
430 if (command === 'close') {
431 await this.host.closePane()
432 return true
433 }
434 if (command === 'stop') return this.action('/stop')
435 if (!trimmed || this.busy() || this.refreshing || !this.endpoint) return false
436 if (command === 'insert' || command === 'copy') return this.shareReply(command)
437 if (command === 'refresh') {
438 void this.refresh()
439 return true
440 }
441 this.sending = true
442 this.localError = ''
443 this.localNotice = ''
444 this.answers = {}
445 const connection = this.connection
446 try {
447 const result = await this.host.request(this.endpoint, '/send', { id, text: trimmed })
448 if (connection !== this.connection) return false
449 this.draft = ''
450 if (result.revision >= this.state.revision) this.update(result)
451 this.trace('side.state', this.state)
452 this.follow = true
453 this.host.after(50, () => {
454 if (this.opened) this.host.scroll()
455 })
456 return true
457 } catch (error) {
458 if (connection === this.connection) this.localError = errorMessage(error)
459 return false
460 } finally {
461 if (connection === this.connection) {
462 this.sending = false
463 this.host.invalidate()
464 }
465 }
466 }
467
468 // The host, not the side's Claude, reaches the main prompt and the clipboard.
469 private async shareReply(command: 'insert' | 'copy') {
470 this.draft = ''
471 // Side command output such as /help is not Claude's reply.
472 const reply = this.state.messages.findLast((m) => m.role === 'assistant' && !m.local && m.text)
473 if (!reply) this.notify('There is no reply to share yet.')
474 else if (command === 'insert')
475 this.notify(
476 (await this.host.insertInMain(reply.text)) ??
477 'Inserted the last reply in the main prompt. Esc switches to it.',
478 )
479 else
480 this.notify(
481 (await this.host.copy(reply.text)) ? 'Copied the last reply.' : 'Could not copy the reply.',
482 )
483 return true
484 }
485
486 // Re-fork at main's latest point, keeping this discussion, the composer, and the side's
487 // choices. The old helper serves until the new one is up, so a failure changes nothing.
488 private async refresh() {
489 const old = this.endpoint
490 if (!old || this.refreshing) return false
491 if (this.busy()) {
492 this.notify('Wait for the current reply, or stop it first.')
493 return false
494 }
495 const generation = this.generation
496 const counted = this.mainAhead
497 this.refreshing = true
498 this.localError = ''
499 this.host.invalidate()
500 try {
501 const started = await this.host.start(
502 await this.host.options({
503 ...(this.state.model ? { model: this.state.model } : {}),
504 effort: effortLevels.find((level) => level === this.state.effort),
505 canEdit: this.state.canEdit,
506 carried: this.state.messages,
507 }),
508 )
509 if (generation !== this.generation || !this.opened) {
510 await this.host.request(started, '/close')
511 return false
512 }
513 this.setEndpoint(started)
514 this.mainAhead -= counted
515 this.state = { ...this.state, revision: -1 }
516 const connection = ++this.connection
517 this.trace('side.refreshed', { pid: started.pid })
518 void this.poll(connection)
519 void this.host.request(old, '/close').catch(() => {})
520 return true
521 } catch (error) {
522 if (generation === this.generation) this.localError = errorMessage(error)
523 return false
524 } finally {
525 if (generation === this.generation) {
526 this.refreshing = false
527 this.host.invalidate()
528 }
529 }
530 }
531}
532
533// Composer posts arrive untyped: accept only the shape the composer sends.
534function isPost(data: unknown, generation: number): data is ComposerPost {
535 const post = data as Partial<ComposerPost> | null
536 return (
537 !!post &&
538 post.epoch === generation &&
539 Number.isSafeInteger(post.seq) &&
540 post.seq! >= 0 &&
541 typeof post.instance === 'string' &&
542 /^[a-zA-Z0-9_-]{1,80}$/.test(post.instance) &&
543 typeof post.text === 'string' &&
544 post.text.length <= messageLimit &&
545 (post.mention === undefined || typeof post.mention === 'string')
546 )
547}
548
549function isSubmission(submission: Submission, generation: number, instance: string) {
550 return (
551 typeof submission.id === 'string' &&
552 submission.id.startsWith(`${generation}:${instance}:`) &&
553 /^[a-zA-Z0-9:_-]{1,160}$/.test(submission.id) &&
554 typeof submission.text === 'string' &&
555 submission.text.length <= messageLimit
556 )
557}
558hooks/composer.tsx 292 lines1import type { ClientModule, ClientSurface } from 'claude-code'
2import type { Activity, Receipt, SideCommand, SideModel, Submission } from '../shared/protocol.ts'
3import { completions, worksWhileBusy, type Completion } from '../shared/commands.ts'
4import { caret, edit, layout, normalizeKey, offsetAt, type Editor } from '../shared/editor.ts'
5import { activityFrame } from '../shared/activity.ts'
6import { mentionAt, mentionText, rankPaths } from '../shared/mentions.ts'
7
8export type ComposerProps = {
9 epoch: number
10 seed: string
11 receipt: Receipt | null
12 busy: boolean
13 activity: Activity | null
14 columns: number
15 maxRows: number
16 commands: SideCommand[]
17 models: SideModel[]
18 model: string
19 effort: string
20 canEdit: boolean
21 // The host's matches for the `@` path being typed, and the path they match.
22 mention: { query: string; paths: string[] } | null
23}
24type State = Editor & {
25 instance: string
26 seq: number
27 selected: number
28 hiddenMenu: boolean
29 // Set by a key or click: Pane.isFocused turns false once this Client has focus,
30 // so the caret follows this instead. The host owns Escape and focus moves.
31 active: boolean
32 pending?: Submission
33 history: string[]
34 historyIndex: number
35 unsent: string
36}
37type IO = { state: State; props: ComposerProps; menu: Completion[]; send: () => void }
38const instances = new WeakMap<object, IO>()
39// The paths matching the `@` mention at the caret, or the commands matching a slash command.
40function menuFor({ text, cursor }: Editor, props: ComposerProps): Completion[] {
41 const typed = mentionAt(text, cursor)
42 if (!typed) return completions(text, props.commands, props)
43 // Until the host answers for what is typed now, narrow its last answer so the menu stays put.
44 const answer = props.mention
45 const paths =
46 answer?.query === typed.query ? answer.paths : rankPaths(answer?.paths ?? [], typed.query)
47 return paths.map((path) => ({
48 value: mentionText(path),
49 label: `+ ${path}`,
50 description: '',
51 replaces: typed,
52 }))
53}
54
55function snapshot(io: IO, surface: ClientSurface<State>) {
56 const mention = mentionAt(io.state.text, io.state.cursor)
57 // A complete snapshot survives Client.post coalescing. The unacknowledged
58 // submit stays in every subsequent snapshot, and the host handles it once.
59 surface.post({
60 epoch: io.props.epoch,
61 seq: io.state.seq,
62 text: io.state.text,
63 instance: io.state.instance,
64 ...(io.state.pending ? { submit: io.state.pending } : {}),
65 // The `@` path being typed, for the host to match against the project's files.
66 ...(mention ? { mention: mention.query } : {}),
67 })
68}
69
70const Composer: ClientModule<ComposerProps, State> = (props, surface) => {
71 const { Box, Text } = surface.elements
72 let io = instances.get(surface)
73 if (!io) {
74 const state: State = surface.state ?? {
75 text: props.seed,
76 cursor: props.seed.length,
77 instance: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
78 seq: 0,
79 selected: 0,
80 hiddenMenu: false,
81 active: false,
82 history: [],
83 historyIndex: -1,
84 unsent: '',
85 }
86 io = { state, props, menu: [], send: () => {} }
87 instances.set(surface, io)
88 const instance = io
89 let lastPost = 0
90 surface.every(120, () => {
91 if (instance.props.activity) surface.setState({ ...instance.state })
92 if (instance.state.pending && Date.now() - lastPost >= 360) {
93 lastPost = Date.now()
94 snapshot(instance, surface)
95 }
96 })
97 }
98 io.props = props
99 if (io.state.pending && props.receipt?.id === io.state.pending.id) {
100 const pending = io.state.pending
101 io.state.pending = undefined
102 if (props.receipt.accepted) {
103 io.state.history = [...io.state.history, pending.text].slice(-30)
104 io.state.text = ''
105 io.state.cursor = 0
106 io.state.historyIndex = -1
107 }
108 surface.setState({ ...io.state })
109 }
110 const instance = io
111 const width = Math.max(8, (surface.columns || props.columns) - 3)
112 const redraw = () => {
113 surface.setState({ ...instance.state })
114 snapshot(instance, surface)
115 }
116 const setText = (text: string, cursor = text.length) => {
117 instance.state = {
118 ...instance.state,
119 text,
120 cursor,
121 selected: 0,
122 hiddenMenu: false,
123 preferredColumn: undefined,
124 }
125 instance.state.seq++
126 redraw()
127 }
128 instance.send = () => {
129 const state = instance.state
130 if (!state.text.trim() || state.pending || (instance.props.busy && !worksWhileBusy(state.text)))
131 return
132 state.seq++
133 state.pending = {
134 id: `${instance.props.epoch}:${state.instance}:${state.seq}`,
135 text: state.text,
136 }
137 redraw()
138 }
139 instance.menu = io.state.hiddenMenu ? [] : menuFor(io.state, props)
140 const choose = (item: Completion, execute: boolean) => {
141 const { text } = instance.state
142 const { start, end } = item.replaces ?? { start: 0, end: text.length }
143 setText(text.slice(0, start) + item.value + text.slice(end), start + item.value.length)
144 if (execute && item.execute) instance.send()
145 }
146 surface.onKey((key) => {
147 key = normalizeKey(key) as typeof key
148 const state = instance.state
149 state.active = true
150 if (state.pending) return
151 const menu = menuFor(state, instance.props)
152 const shown = state.hiddenMenu ? [] : menu
153 if (key.ctrl && key.key === 'g') {
154 state.hiddenMenu = true
155 redraw()
156 return
157 }
158 if (shown.length && (key.key === 'up' || key.key === 'down')) {
159 state.selected = (state.selected + (key.key === 'up' ? -1 : 1) + shown.length) % shown.length
160 redraw()
161 return
162 }
163 if (shown.length && key.key === 'tab') {
164 choose(shown[state.selected % shown.length], false)
165 return
166 }
167 if (key.key === 'return' && !key.shift && !key.meta) {
168 const selected = shown[state.selected % shown.length]
169 // As in Claude's own prompt, Enter sends an `@` mention as typed; Tab completes it.
170 if (selected && !selected.replaces) {
171 choose(selected, false)
172 if (!selected.opensPicker) instance.send()
173 } else instance.send()
174 return
175 }
176 if (
177 !shown.length &&
178 !state.text.includes('\n') &&
179 (key.key === 'up' || key.key === 'down') &&
180 (state.historyIndex >= 0 || (key.key === 'up' && state.cursor === 0))
181 ) {
182 if (state.historyIndex < 0) {
183 state.unsent = state.text
184 state.historyIndex = state.history.length
185 }
186 const index = Math.max(
187 0,
188 Math.min(state.history.length, state.historyIndex + (key.key === 'up' ? -1 : 1)),
189 )
190 state.historyIndex = index
191 setText(index === state.history.length ? state.unsent : (state.history[index] ?? ''))
192 return
193 }
194 const next = edit(state, key, width)
195 if (next.text !== state.text) {
196 state.selected = 0
197 state.hiddenMenu = false
198 state.historyIndex = -1
199 }
200 instance.state = { ...state, ...next, seq: state.seq + 1 }
201 redraw()
202 })
203 const state = io.state
204 const lines = layout(state.text, width),
205 position = caret(lines, state.cursor)
206 const count = Math.min(props.maxRows, lines.length)
207 const start = Math.max(0, position.row - count + 1)
208 const menuSize = Math.min(6, instance.menu.length)
209 const menuStart = Math.max(
210 0,
211 Math.min(instance.menu.length - menuSize, state.selected - menuSize + 1),
212 )
213 const visible = instance.menu.slice(menuStart, menuStart + menuSize)
214 // Line descriptions up in a column; a longer label only shifts its own row.
215 const labelColumn = Math.min(24, Math.max(0, ...visible.map((item) => item.label.length)))
216 const animation = props.activity ? activityFrame(props.activity, Date.now()) : null
217 const inputTop = animation ? 2 : 1
218 const menuTop = inputTop + count + 1
219 surface.onPointer((event) => {
220 if (event.type !== 'down' || event.button !== 'left') return
221 state.active = true
222 if (event.y >= inputTop && event.y < inputTop + count) {
223 const line = lines[Math.min(lines.length - 1, start + event.y - inputTop)]
224 state.cursor = offsetAt(line, Math.max(0, event.x - 2))
225 state.preferredColumn = undefined
226 redraw()
227 } else if (event.y >= menuTop && event.y < menuTop + menuSize) {
228 const item = instance.menu[menuStart + event.y - menuTop]
229 if (item) choose(item, true)
230 } else redraw()
231 })
232 const rule = '─'.repeat(width + 2)
233 // A menu longer than it shows says where its selection is, set into the rule above it.
234 const counter =
235 instance.menu.length > menuSize ? ` ${state.selected + 1}/${instance.menu.length} ` : ''
236 return (
237 <Box flexDirection="column" width={width + 3}>
238 {animation ? (
239 <Box height={1}>
240 <Text color="claude">
241 {animation.glyph} {animation.label}…
242 </Text>
243 <Text dimColor>{animation.seconds ? ` (${animation.seconds}s)` : ''}</Text>
244 </Box>
245 ) : null}
246 <Text dimColor>{rule}</Text>
247 {lines.slice(start, start + count).map((line, index) => {
248 const active = state.active && start + index === position.row
249 const before = line.glyphs
250 .filter((g) => g.end <= state.cursor)
251 .map((g) => g.text)
252 .join('')
253 const cursorGlyph = line.glyphs.find((g) => g.start === state.cursor)
254 const after = line.glyphs
255 .filter((g) => g.start > state.cursor)
256 .map((g) => g.text)
257 .join('')
258 return (
259 <Box key={`line-${index}`} height={1}>
260 <Text color={state.active ? 'claude' : undefined}>{index === 0 ? '❯ ' : ' '}</Text>
261 {!state.text && !active ? (
262 <Text dimColor>Ask anything…</Text>
263 ) : active ? (
264 <Text>
265 {before}
266 <Text inverse>{cursorGlyph?.text ?? ' '}</Text>
267 {after}
268 </Text>
269 ) : (
270 <Text>{line.glyphs.map((g) => g.text).join('') || ' '}</Text>
271 )}
272 </Box>
273 )
274 })}
275 <Text dimColor>{rule.slice(counter.length + 1) + counter + '─'}</Text>
276 {visible.map((item, index) => (
277 <Box key={item.value} height={1}>
278 <Text
279 color={menuStart + index === state.selected ? 'claude' : undefined}
280 bold={menuStart + index === state.selected}
281 wrap="truncate-end"
282 >
283 {menuStart + index === state.selected ? '› ' : ' '}
284 {item.label.padEnd(labelColumn)} <Text dimColor>{item.description}</Text>
285 </Text>
286 </Box>
287 ))}
288 </Box>
289 )
290}
291export default Composer
292shared/limits.ts 51 lines1// Mods refuses a whole drawing when one string or its serialized tree is larger.
2const stringLimit = 10000
3export const treeLimit = 100000
4// The longest message the side takes, in UTF-16 code units as the editor counts them.
5export const messageLimit = 50000
6
7const fence = /^ {0,3}(`{3,}|~{3,})/
8
9/** Splits text at line breaks into drawable strings, closing and reopening code fences. */
10export function splitText(text: string, limit = stringLimit): string[] {
11 if (text.length <= limit) return [text]
12 const parts: string[] = []
13 let lines: string[] = []
14 let size = 0
15 let open: { marker: string; line: string } | undefined
16 for (const line of text.split('\n').flatMap((line) => wrapLine(line, Math.floor(limit / 2)))) {
17 const closing = open ? open.marker.length + 1 : 0
18 if (lines.length && size + line.length + closing > limit) {
19 parts.push([...lines, ...(open ? [open.marker] : [])].join('\n'))
20 lines = open ? [open.line] : []
21 size = open ? open.line.length + 1 : 0
22 }
23 lines.push(line)
24 size += line.length + 1
25 const marker = fence.exec(line)?.[1]
26 if (!marker) continue
27 if (!open) open = { marker, line }
28 else if (line.trim() === marker && marker.startsWith(open.marker)) open = undefined
29 }
30 parts.push(lines.join('\n'))
31 return parts
32}
33
34/** Keeps the start of text, saying how much was left out. */
35export function clip(text: string, limit: number): string {
36 return text.length <= limit
37 ? text
38 : `${text.slice(0, limit)}\n… ${text.length - limit} more characters`
39}
40
41function wrapLine(line: string, width: number): string[] {
42 const pieces: string[] = []
43 while (line.length > width) {
44 let end = line.lastIndexOf(' ', width) + 1 || width
45 if (/[\uDC00-\uDFFF]/.test(line[end])) end--
46 pieces.push(line.slice(0, end))
47 line = line.slice(end)
48 }
49 return [...pieces, line]
50}
51hooks/permissions.tsx 165 lines1import type { Elements } from 'claude-code'
2import type { Permission } from '../shared/protocol.ts'
3import { cleanText } from '../shared/editor.ts'
4import { approvalParts } from '../shared/approval.ts'
5import { questionsFor, type Question } from '../shared/questions.ts'
6
7// Answers typed or picked so far, by permission and then by question.
8export type Answers = Record<string, Record<string, string>>
9
10export function renderPermissions(
11 elements: Elements['terminal'],
12 permissions: Permission[],
13 answers: Answers,
14 actions: {
15 invalidate: () => void
16 setError: (message: string) => void
17 decide: (id: string, allow: boolean, answers?: Record<string, string>) => Promise<boolean>
18 },
19 cwd?: string,
20) {
21 const { Box, Text, Button } = elements
22 return (
23 <Box flexDirection="column">
24 {permissions.map((permission) => {
25 const questions = questionsFor(permission)
26 const allow = async () => {
27 if (questions.some((q) => !answers[permission.id]?.[q.question]?.trim())) {
28 actions.setError('Answer each question before sending.')
29 return
30 }
31 actions.setError('')
32 await actions.decide(
33 permission.id,
34 true,
35 questions.length ? answers[permission.id] : undefined,
36 )
37 }
38 return (
39 <Box key={permission.id} flexDirection="column" borderStyle="round" paddingX={1}>
40 <Text bold color="warning">
41 {cleanText(
42 permission.title ??
43 (permission.tool === 'AskUserQuestion'
44 ? 'Claude has a question'
45 : `Allow ${permission.tool}?`),
46 )}
47 </Text>
48 {permission.description ? <Text>{cleanText(permission.description)}</Text> : null}
49 {permission.decisionReason ? <Text>{cleanText(permission.decisionReason)}</Text> : null}
50 {permission.blockedPath ? <Text>Path: {cleanText(permission.blockedPath)}</Text> : null}
51 {permission.mcpServer ? (
52 <Text dimColor>
53 MCP: {cleanText(permission.mcpServer.name)} (
54 {cleanText(permission.mcpServer.source)})
55 </Text>
56 ) : null}
57 {questions.length ? (
58 questions.map((question, index) =>
59 questionView(elements, permission.id, question, index, answers, {
60 invalidate: actions.invalidate,
61 submit: allow,
62 }),
63 )
64 ) : (
65 <Box flexDirection="column">{approvalView(elements, permission, cwd)}</Box>
66 )}
67 <Box gap={2}>
68 <Button
69 key={`deny-${permission.id}`}
70 label={questions.length ? 'Skip' : 'Deny'}
71 autoFocus={permission.defaultToNo ? true : undefined}
72 onPress={async () => {
73 await actions.decide(permission.id, false)
74 }}
75 />
76 <Button
77 key={`allow-${permission.id}`}
78 label={questions.length ? 'Send answer' : 'Allow once'}
79 onPress={allow}
80 />
81 </Box>
82 </Box>
83 )
84 })}
85 </Box>
86 )
87}
88
89// One question: its options as buttons, marked once picked, and a field for any answer.
90function questionView(
91 elements: Elements['terminal'],
92 id: string,
93 { question, options, multiSelect }: Question,
94 index: number,
95 answers: Answers,
96 { invalidate, submit }: { invalidate: () => void; submit: () => Promise<void> },
97) {
98 const { Box, Text, Button, Input } = elements
99 const answer = answers[id]?.[question] ?? ''
100 return (
101 <Box key={`${id}-${index}`} flexDirection="column" marginBottom={1}>
102 <Text bold>{question}</Text>
103 {options.map((option, i) => (
104 <Box key={`${id}-${index}-${i}`} flexDirection="column">
105 <Button
106 key={`answer-${id}-${index}-${i}`}
107 label={answer.split(', ').includes(option.label) ? `✓ ${option.label}` : option.label}
108 onPress={() => {
109 answers[id] ??= {}
110 const chosen = multiSelect
111 ? (answers[id][question] ?? '').split(', ').filter(Boolean)
112 : []
113 answers[id][question] =
114 multiSelect && chosen.includes(option.label)
115 ? chosen.filter((label) => label !== option.label).join(', ')
116 : [...chosen, option.label].join(', ')
117 invalidate()
118 }}
119 />
120 {option.description ? <Text dimColor>{option.description}</Text> : null}
121 </Box>
122 ))}
123 <Input
124 key={`answer-text-${id}-${index}`}
125 label="Answer"
126 value={answer}
127 placeholder="Select above or type…"
128 onInput={(value) => {
129 answers[id] ??= {}
130 answers[id][question] = value
131 }}
132 onSubmit={submit}
133 />
134 </Box>
135 )
136}
137
138function approvalView(elements: Elements['terminal'], permission: Permission, cwd?: string) {
139 const { Text, Code } = elements
140 return approvalParts(permission, cwd).map((part, index) => {
141 const key = `${permission.id}-part-${index}`
142 if (part.kind === 'file')
143 return (
144 <Text key={key} bold>
145 {part.path}
146 </Text>
147 )
148 if (part.kind === 'code')
149 return (
150 <Code
151 key={key}
152 source={part.source}
153 format={part.format}
154 path={part.path}
155 language={part.language}
156 />
157 )
158 return (
159 <Text key={key} dimColor wrap="wrap">
160 {part.text}
161 </Text>
162 )
163 })
164}
165hooks/transcript.tsx 134 lines1import type { Elements, RenderElement } from 'claude-code'
2import type { ChatMessage } from '../shared/protocol.ts'
3import { layout } from '../shared/editor.ts'
4import { clip, splitText, treeLimit } from '../shared/limits.ts'
5import { toolDisplay } from '../shared/tool-display.ts'
6
7type MessageView = {
8 columns: number
9 expanded: Set<string>
10 onToggle: (key: string) => void
11 cwd?: string
12 // The drawing budget, in serialized characters, for the messages shown.
13 budget?: number
14}
15const messageLimit = 40000
16
17function toolStatus(status: ChatMessage['status']): string {
18 if (status === 'done') return '✓'
19 if (status === 'error' || status === 'cancelled') return '×'
20 return '·'
21}
22
23export function renderMessages(
24 elements: Elements['terminal'],
25 messages: ChatMessage[],
26 { columns, expanded, onToggle, cwd, budget = treeLimit }: MessageView,
27) {
28 const { Box, Text, Button, Markdown } = elements
29 const draw = (message: ChatMessage) => {
30 if (message.role === 'notice')
31 return (
32 <Box key={message.id} marginBottom={1}>
33 <Text dimColor>{`↻ ${message.text}`}</Text>
34 </Box>
35 )
36 if (message.role === 'tool') {
37 const details = toolDisplay(message, columns, cwd)
38 const open = expanded.has(message.id)
39 const error = message.status === 'error'
40 return (
41 <Box key={message.id} flexDirection="column" width={columns} marginBottom={1}>
42 <Box>
43 <Text color={error ? 'error' : undefined}>{toolStatus(message.status)} </Text>
44 <Button
45 key={`tool-${message.id}`}
46 plain
47 label={`${details.label} ${open ? '▾' : '▸'}`}
48 onPress={() => {
49 if (open) expanded.delete(message.id)
50 else expanded.add(message.id)
51 onToggle(message.id)
52 }}
53 />
54 </Box>
55 {open ? (
56 <Box
57 flexDirection="column"
58 marginLeft={2}
59 padding={1}
60 backgroundColor="bashMessageBackgroundColor"
61 >
62 {details.label.startsWith(details.name) ? null : <Text bold>{details.name}</Text>}
63 <Text dimColor>Input{details.inputTruncated ? ' (truncated)' : ''}</Text>
64 <Text wrap="wrap">{details.input}</Text>
65 {message.text ? (
66 <Box flexDirection="column" marginTop={1}>
67 <Text dimColor>Output{message.outputTruncated ? ' (truncated)' : ''}</Text>
68 <Text wrap="wrap" color={error ? 'error' : undefined}>
69 {message.text}
70 </Text>
71 </Box>
72 ) : null}
73 </Box>
74 ) : details.preview.length ? (
75 <Box paddingLeft={2}>
76 <Text dimColor={!error} color={error ? 'error' : undefined}>
77 {details.preview.join('\n')}
78 </Text>
79 </Box>
80 ) : null}
81 </Box>
82 )
83 }
84 const text = clip(message.text, messageLimit)
85 return (
86 <Box key={message.id} flexDirection="column" marginBottom={1}>
87 {message.role === 'user' ? (
88 <Box>
89 <Text color="claude">❯ </Text>
90 <Box flexDirection="column" width={columns - 2}>
91 {splitText(
92 layout(text, columns - 3)
93 .map((line) => line.glyphs.map((g) => g.text).join(''))
94 .join('\n'),
95 ).map((part, index) => (
96 <Text key={`${message.id}-${index}`} wrap="wrap">
97 {part}
98 </Text>
99 ))}
100 </Box>
101 </Box>
102 ) : (
103 <Box>
104 <Text>⏺ </Text>
105 <Box flexDirection="column" width={columns - 2}>
106 {splitText(text || '…').map((part, index) => (
107 <Markdown key={`${message.id}-${index}`} text={part} />
108 ))}
109 </Box>
110 </Box>
111 )}
112 </Box>
113 )
114 }
115 // Mods refuses an oversized pane as a whole, so draw the newest messages that fit.
116 const rows: RenderElement[] = []
117 let hidden = messages.length
118 let size = 0
119 while (hidden > 0) {
120 const row = draw(messages[hidden - 1])
121 size += JSON.stringify(row).length
122 if (size > budget) break
123 rows.unshift(row)
124 hidden--
125 }
126 if (!hidden) return rows
127 return [
128 <Text key="hidden-messages" dimColor>
129 {`${hidden} earlier ${hidden === 1 ? 'message' : 'messages'} hidden`}
130 </Text>,
131 ...rows,
132 ]
133}
134shared/commands.ts 149 lines1import type { SideCommand, SideModel } from './protocol.ts'
2import { supportedEfforts } from './models.ts'
3
4export const localCommands: SideCommand[] = [
5 { name: 'help', description: 'Show side chat commands and keyboard shortcuts', argumentHint: '' },
6 { name: 'model', description: 'Choose the model for this side chat', argumentHint: '[model]' },
7 {
8 name: 'effort',
9 description: 'Set thinking effort for this side chat',
10 argumentHint: '[level]',
11 },
12 {
13 name: 'edit',
14 description: 'Allow or block file edits, here and in new side chats',
15 argumentHint: '[on|off]',
16 },
17 {
18 name: 'refresh',
19 description: 'Catch up with the main chat, keeping this discussion',
20 argumentHint: '',
21 },
22 { name: 'insert', description: 'Put the last reply in the main prompt', argumentHint: '' },
23 { name: 'copy', description: 'Copy the last reply', argumentHint: '' },
24 { name: 'stop', description: 'Stop the current reply', argumentHint: '' },
25 { name: 'close', description: 'Close and discard this side chat', argumentHint: '' },
26]
27
28// Claude Code's own commands with nothing to act on in a side chat, which is never saved and
29// has no prompt bar or views of its own. Its internal commands start with `__`.
30const unavailableBuiltins = new Set([
31 'rename',
32 'color',
33 'focus',
34 'heapdump',
35 'workflow-launch-exec',
36])
37// Claude Code's own commands that work differently in a side chat.
38const sideDescriptions = new Map([['clear', "Start this side chat over, without main's context"]])
39
40// A command as the Agent SDK reports it: built into Claude Code, or a skill.
41type ClaudeCommand = SideCommand & { builtin?: boolean }
42
43/** The side's commands, then Claude Code's commands and skills that work in a side chat. */
44export function commandCatalog(commands: readonly ClaudeCommand[]): SideCommand[] {
45 const reserved = new Set(localCommands.map((c) => c.name))
46 const offered = commands.filter(
47 (c) =>
48 !reserved.has(c.name) &&
49 !(c.builtin && (c.name.startsWith('__') || unavailableBuiltins.has(c.name))),
50 )
51 const listed: ClaudeCommand[] = [...localCommands, ...offered]
52 return listed.map((c) => ({
53 name: c.name,
54 description: ((c.builtin && sideDescriptions.get(c.name)) || c.description).slice(0, 180),
55 argumentHint: c.argumentHint,
56 ...(c.aliases ? { aliases: c.aliases } : {}),
57 }))
58}
59
60export function parseCommand(text: string) {
61 const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text.trim())
62 return match ? { name: match[1].toLowerCase(), args: (match[2] ?? '').trim() } : undefined
63}
64
65/** Whether text is a side command that acts on the reply in progress, so it goes while Claude works. */
66export const worksWhileBusy = (text: string) =>
67 ['stop', 'close'].includes(parseCommand(text)?.name ?? '')
68
69function effortHint(level: string, levels: string[]) {
70 if (level === 'auto') return 'Model default'
71 if (level === levels[0]) return 'Fastest'
72 return level === levels.at(-1) ? 'Most thorough' : ''
73}
74
75// Names outrank descriptions, so Enter picks the model that was typed.
76function modelRank(model: SideModel, query: string) {
77 const names = [model.value, model.displayName].map((name) => name.toLowerCase())
78 if (names.includes(query)) return 0
79 if (names.some((name) => name.startsWith(query))) return 1
80 return names.some((name) => name.includes(query)) ? 2 : 3
81}
82
83export type Completion = {
84 value: string
85 label: string
86 description: string
87 execute?: boolean
88 // Enter completes the command and opens its picker instead of sending.
89 opensPicker?: boolean
90 // The part of the draft the value replaces, when it is not the whole draft.
91 replaces?: { start: number; end: number }
92}
93// Commands whose argument is picked from a menu of choices.
94const pickers = ['model', 'effort', 'edit']
95/** The side chat's current choices, which pickers list and mark. */
96type SideSettings = {
97 models: SideModel[]
98 model?: string
99 effort?: string
100 canEdit?: boolean
101}
102export function completions(
103 text: string,
104 commands: SideCommand[],
105 { models, model, effort, canEdit }: SideSettings,
106): Completion[] {
107 if (!text.startsWith('/') || text.includes('\n')) return []
108 const match = /^\/(model|effort|edit)\s+(.*)$/i.exec(text)
109 if (match) {
110 const query = match[2].toLowerCase()
111 if (match[1].toLowerCase() === 'edit')
112 return [
113 { value: 'on', label: canEdit ? 'on ✓' : 'on', description: 'Claude can change files' },
114 { value: 'off', label: canEdit ? 'off' : 'off ✓', description: 'Read-only' },
115 ]
116 .filter((choice) => choice.value.startsWith(query))
117 .map((choice) => ({ ...choice, value: `/edit ${choice.value}`, execute: true }))
118 if (match[1].toLowerCase() === 'model')
119 return models
120 .filter((m) => `${m.value} ${m.displayName} ${m.description}`.toLowerCase().includes(query))
121 .sort((a, b) => modelRank(a, query) - modelRank(b, query))
122 .map((m) => ({
123 value: `/model ${m.value}`,
124 label: m.displayName,
125 description: m.description,
126 execute: true,
127 }))
128 const levels = supportedEfforts(model ?? '', models)
129 return [...levels, 'auto']
130 .filter((level) => level.startsWith(query))
131 .map((level) => ({
132 value: `/effort ${level}`,
133 label: level === effort ? `${level} ✓` : level,
134 description: effortHint(level, levels),
135 execute: true,
136 }))
137 }
138 if (/\s/.test(text)) return []
139 const query = text.slice(1).toLowerCase()
140 return commands
141 .filter((c) => c.name.startsWith(query) || c.aliases?.some((a) => a.startsWith(query)))
142 .map((c) => ({
143 value: `/${c.name}${c.argumentHint ? ' ' : ''}`,
144 label: `/${c.name}`,
145 description: c.description,
146 ...(pickers.includes(c.name) ? { opensPicker: true } : {}),
147 }))
148}
149