A side chat pane inside the session that answers questions or runs requests on a model of your choice.

Um chat lateral dentro da sessão do Claude Code. Faça perguntas sobre o que está acontecendo, ou entregue uma tarefa a um agente, sem interromper a conversa principal, e escolha o modelo de cada um.
| Comando | O que faz |
|---|---|
/side [mensagem] | Abre o painel do chat lateral. Com texto, já envia a mensagem. |
/side-model <modelo> | Define o modelo do chat lateral: haiku, sonnet, opus, fable ou um ID completo. Sem argumento, mostra o modelo atual. |
O padrão é haiku e a sua escolha fica salva entre sessões. Os aliases dependem dos modelos disponíveis na sua conta.
O painel também tem um seletor de modelo, botões com sugestões para começar, Retry quando algo falha, Copy em cada resposta e Clear para limpar o chat lateral.
Numa sessão do Claude Code no terminal (claude), digite:
/plugin install side-chat --marketplace ptpmediabr/side-chat
O Claude Code pergunta se pode adicionar o marketplace (responda y) e depois o escopo (escolha user para valer em todos os projetos). Quando aparecer Installed side-chat. Plugin is now active., o mod já está rodando, sem reiniciar: use /side.
O comando de instalação só funciona no terminal. Instalado no escopo user, o mod também carrega nas sessões do app desktop do Claude Code. O painel precisa de uma superfície com campo de texto: terminal, app desktop ou VS Code.
~/.claude/projects/<projeto>/<sessão>.jsonl) e envia ao modelo escolhido o final da conversa, até cerca de 150 mil caracteres: o que você digitou, o que o Claude respondeu e uma marca por ferramenta usada. A saída das ferramentas não vai. Se o transcript não puder ser lido, ele usa as últimas trocas que viu.claude plugin validate .
claude plugin test .
Testado com o Claude Code 2.1.296.
MIT. Feito por Chiara Costa.
hooks/register.tsx 440 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Message, Running } from '../types'
5import {
6 DEFAULT_MODEL,
7 MODES,
8 SUGGESTIONS,
9 clip,
10 formatDuration,
11 formatElapsed,
12 mainContext,
13 modelOptions,
14 newest,
15 parseTranscript,
16 pulse,
17 threadPrompt,
18 transcriptPath,
19 withoutTrailingErrors,
20} from './lib'
21import type { Exchange } from './lib'
22
23const PANE = 'side-chat'
24const STORE_MODEL = 'model'
25const MAX_MAIN = 12
26// Characters of the main session an answer reads, and a request's agent is handed.
27const ASK_BUDGET = 150000
28const TASK_BUDGET = 20000
29
30const messages = atom({ plugin: 'side-chat', key: 'messages' } as const, [])
31const model = atom({ plugin: 'side-chat', key: 'model' } as const, DEFAULT_MODEL)
32const mode = atom({ plugin: 'side-chat', key: 'mode' } as const, 'ask')
33const running = atom({ plugin: 'side-chat', key: 'running' } as const, null)
34const ticks = atom({ plugin: 'side-chat', key: 'ticks' } as const, 0)
35
36const ASK_SYSTEM =
37 'You are a side chat inside a coding session, answering apart from the main conversation. ' +
38 'You are given the main session so far as background: use it to answer. ' +
39 'Be brief and direct. You have no tools here.'
40
41// The main thread as far as this load has seen it, and the agents it spawned.
42const main: Exchange[] = []
43const agents = new Map<string, { model: string; startedAt: number }>()
44let pending = ''
45let count = 0
46// The one-second clock of the request in flight.
47let clock: Timer | undefined
48
49function open($: EngineInterface) {
50 return $.ui.open({ id: PANE, title: 'Side chat', focus: true, columns: 60 })
51}
52
53function reveal($: EngineInterface) {
54 void $.ui.scroll({ in: PANE, to: 'end' }).catch(() => undefined)
55}
56
57function stopClock() {
58 clock?.cancel()
59 clock = undefined
60}
61
62// Counts the seconds a request has run. It ends itself once nothing runs, so
63// a clock left over from before a reload stops at its next beat.
64function startClock($: EngineInterface) {
65 stopClock()
66 void update($, ticks, () => 0)
67 clock = $.clock.every(1000, () => {
68 void read($, running)
69 .then(async job => {
70 if (job === null) {
71 stopClock()
72
73 return
74 }
75 await update($, ticks, n => n + 1)
76 })
77 .catch(stopClock)
78 })
79}
80
81// Takes the one place a request has. False while another runs.
82async function claim($: EngineInterface, job: Running) {
83 let isClaimed = false
84 await update($, running, was => {
85 isClaimed = was === null
86
87 return was ?? job
88 })
89 if (isClaimed) {
90 startClock($)
91 }
92
93 return isClaimed
94}
95
96async function finish($: EngineInterface) {
97 stopClock()
98 await update($, running, () => null)
99}
100
101async function currentJob($: EngineInterface): Promise<Running> {
102 return { model: await read($, model), mode: await read($, mode) }
103}
104
105async function push($: EngineInterface, message: Omit<Message, 'id'>) {
106 count += 1
107 const id = `${await $.clock.now()}-${count}`
108 await update($, messages, list => [...list, { ...message, id }].slice(-100))
109 reveal($)
110}
111
112async function sessionFile($: EngineInterface) {
113 const home = await $.env.get('HOME')
114 if (home === undefined) {
115 return undefined
116 }
117 const id = await $.session.id()
118 for (const cwd of [await $.session.root(), await $.session.cwd()]) {
119 const path = transcriptPath(home, cwd, id)
120 if (await $.fs.exists(path)) {
121 return path
122 }
123 }
124
125 return undefined
126}
127
128// The main session as saved on disk; what this load saw of it when the file
129// cannot be read (not found, or past what one read holds).
130async function sessionContext($: EngineInterface, path: string | undefined, budget: number) {
131 if (path !== undefined) {
132 const lines = await $.fs
133 .read(path)
134 .then(parseTranscript)
135 .catch(() => [])
136 if (lines.length > 0) {
137 return `The main session this side chat belongs to, newest last:\n\n${newest(lines, budget)}`
138 }
139 }
140
141 return mainContext(main)
142}
143
144// Runs the thread as it stands on the job's model: an answer lands at once,
145// a request lands when its agent's turn completes.
146async function answer($: EngineInterface, job: Running) {
147 try {
148 const startedAt = await $.clock.now()
149 const thread = await read($, messages)
150 const path = await sessionFile($)
151 const context = await sessionContext($, path, job.mode === 'ask' ? ASK_BUDGET : TASK_BUDGET)
152
153 if (job.mode === 'ask') {
154 const r = await $.model.complete({
155 model: job.model,
156 system: context === '' ? ASK_SYSTEM : [{ text: ASK_SYSTEM }, { text: context, cache: true }],
157 prompt: threadPrompt(thread),
158 maxTokens: 8192,
159 timeoutMs: 180000,
160 })
161 const ms = (await $.clock.now()) - startedAt
162 await push(
163 $,
164 r.isAnswered
165 ? { role: 'assistant', text: r.text, model: job.model, ms }
166 : { role: 'error', text: `${job.model} did not answer: ${r.reason}` },
167 )
168 await finish($)
169
170 return
171 }
172
173 const r = await $.agent.spawn({
174 description: 'Side chat request',
175 model: job.model,
176 prompt: [
177 'This is a request from a side chat of the session. Do it, then reply with a short report.',
178 context,
179 path === undefined ? '' : `The full transcript of the main session is the JSONL file ${path}; read it if you need more.`,
180 `Side chat so far:\n${threadPrompt(thread)}`,
181 ]
182 .filter(part => part !== '')
183 .join('\n\n'),
184 })
185 if (r.deny !== undefined || r.agentId === undefined) {
186 await push($, { role: 'error', text: `The request did not start: ${r.deny ?? 'no agent'}` })
187 await finish($)
188
189 return
190 }
191 agents.set(r.agentId, { model: job.model, startedAt })
192 } catch (error) {
193 await push($, { role: 'error', text: `The request failed: ${error instanceof Error ? error.message : String(error)}` })
194 await finish($)
195 }
196}
197
198async function send($: EngineInterface, raw: string) {
199 const text = raw.trim()
200 if (text === '') {
201 return
202 }
203 const job = await currentJob($)
204 if (!(await claim($, job))) {
205 return
206 }
207 await push($, { role: 'user', text })
208 await answer($, job)
209}
210
211// Runs the last question again, the error that ended the thread dropped.
212async function retry($: EngineInterface) {
213 const thread = withoutTrailingErrors(await read($, messages))
214 if (thread[thread.length - 1]?.role !== 'user') {
215 return
216 }
217 const job = await currentJob($)
218 if (!(await claim($, job))) {
219 return
220 }
221 await update($, messages, () => [...thread])
222 reveal($)
223 await answer($, job)
224}
225
226export const register: Register = on => {
227 on('session.start', async ($, e, next) => {
228 await $.command.register({
229 name: 'side',
230 description: 'Open the side chat; with text, send it there',
231 argumentHint: '[message]',
232 })
233 await $.command.register({
234 name: 'side-model',
235 description: 'Set the side chat model (alias or full id)',
236 argumentHint: '<model>',
237 })
238 const saved = await $.store.get(STORE_MODEL)
239 if (typeof saved === 'string' && saved !== '') {
240 await update($, model, () => saved)
241 }
242 // A reload drops the agents this module was waiting on.
243 await finish($)
244
245 return next(e)
246 })
247
248 on('prompt.submit', ($, e, next) => {
249 if (e.origin.kind === 'composer' && !e.text.startsWith('/')) {
250 pending = clip(e.text)
251 }
252
253 return next(e)
254 })
255
256 on('turn.complete', async ($, e, next) => {
257 if (e.agentId === undefined) {
258 if (!e.isAborted && pending !== '') {
259 main.push({ q: pending, a: clip(e.answer) })
260 if (main.length > MAX_MAIN) {
261 main.shift()
262 }
263 pending = ''
264 }
265
266 return next(e)
267 }
268 const job = agents.get(e.agentId)
269 if (job !== undefined) {
270 agents.delete(e.agentId)
271 const ms = (await $.clock.now()) - job.startedAt
272 await push(
273 $,
274 e.isAborted
275 ? { role: 'error', text: 'The request was interrupted.' }
276 : { role: 'assistant', text: e.answer, model: job.model, ms },
277 )
278 await finish($)
279 }
280
281 return next(e)
282 })
283
284 on('command.run', { command: 'side' }, async ($, e) => {
285 await open($)
286 if (e.args.trim() !== '') {
287 void send($, e.args)
288 }
289
290 return { text: 'Side chat opened.' }
291 })
292
293 on('command.run', { command: 'side-model' }, async ($, e) => {
294 const wanted = e.args.trim()
295 if (wanted === '') {
296 return { text: `Side chat model: ${await read($, model)}. Set it with /side-model <model>.` }
297 }
298 await update($, model, () => wanted)
299 await $.store.set(STORE_MODEL, wanted)
300
301 return { text: `Side chat now uses ${wanted}.` }
302 })
303
304 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
305 const ui = $.ui.resolve(e)
306 if (!('Input' in ui)) {
307 return (
308 <ui.Box>
309 <ui.Text dimColor>The side chat needs a terminal, desktop or VS Code surface.</ui.Text>
310 </ui.Box>
311 )
312 }
313 const { Box, Text, Button, Input, Select, Markdown } = ui
314 const list = await read($, messages)
315 const using = await read($, model)
316 const how = await read($, mode)
317 const job = await read($, running)
318 const seconds = job === null ? 0 : await read($, ticks)
319 const info = MODES[how]
320 const last = list[list.length - 1]
321
322 return (
323 <Box flexDirection="column" gap={1}>
324 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
325 <Text bold color="claude">
326 Side chat
327 </Text>
328 {list.length > 0 && job === null && (
329 <Button key="clear" plain dimColor label="Clear" onPress={() => void update($, messages, () => [])} />
330 )}
331 </Box>
332
333 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={2}>
334 <Box flexDirection="row" gap={1}>
335 {(['ask', 'task'] as const).map(value => (
336 <Button
337 key={`mode-${value}`}
338 label={`${how === value ? '● ' : ''}${MODES[value].label}`}
339 variant={how === value ? 'primary' : 'secondary'}
340 onPress={() => void update($, mode, () => value)}
341 />
342 ))}
343 </Box>
344 <Select
345 key="model"
346 label="Model"
347 value={using}
348 options={modelOptions(using).map(value => ({ value }))}
349 onSelect={value => {
350 void update($, model, () => value)
351 void $.store.set(STORE_MODEL, value)
352 }}
353 />
354 </Box>
355
356 {list.length === 0 && (
357 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} gap={1}>
358 <Text bold>{info.title}</Text>
359 <Text dimColor>{info.blurb}</Text>
360 <Box flexDirection="column">
361 <Text dimColor>Try one:</Text>
362 {SUGGESTIONS[how].map((text, i) => (
363 <Box flexDirection="row">
364 <Button key={`try-${i}`} label={text} onPress={() => void send($, text)} />
365 </Box>
366 ))}
367 </Box>
368 </Box>
369 )}
370
371 {list.length > 0 && (
372 <Box flexDirection="column" gap={1}>
373 {list.map(m =>
374 m.role === 'user' ? (
375 <Box alignSelf="flex-end" borderStyle="round" borderColor="subtle" paddingX={1}>
376 <Text>{m.text}</Text>
377 </Box>
378 ) : m.role === 'error' ? (
379 <Box flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
380 <Text color="error">{m.text}</Text>
381 {m.id === last?.id && job === null && (
382 <Box flexDirection="row">
383 <Button key="retry" label="Retry" onPress={() => void retry($)} />
384 </Box>
385 )}
386 </Box>
387 ) : (
388 <Box flexDirection="column">
389 <Box flexDirection="row" gap={1}>
390 <Text bold color="claude">
391 ●
392 </Text>
393 <Text bold>{m.model ?? 'assistant'}</Text>
394 {m.ms !== undefined && <Text dimColor>{formatDuration(m.ms)}</Text>}
395 </Box>
396 <Box flexDirection="column" paddingLeft={2}>
397 <Markdown text={m.text} />
398 <Box flexDirection="row">
399 <Button
400 key={`copy-${m.id}`}
401 plain
402 dimColor
403 label="Copy"
404 onPress={press =>
405 void $.ui
406 .copy({ text: m.text, surface: press.surface })
407 .then(r => $.ui.toast(r.isCopied ? 'Copied to the clipboard' : 'Could not copy'))
408 }
409 />
410 </Box>
411 </Box>
412 </Box>
413 ),
414 )}
415 </Box>
416 )}
417
418 {job !== null ? (
419 <Box flexDirection="row" gap={1} borderStyle="round" borderColor="claude" paddingX={1}>
420 <Text color="claude">{pulse(seconds)}</Text>
421 <Text>{`${job.model} is ${MODES[job.mode].verb}`}</Text>
422 {seconds > 0 && <Text dimColor>{formatElapsed(seconds)}</Text>}
423 </Box>
424 ) : (
425 <Box flexDirection="column">
426 <Input
427 key={`ask-${list.length}`}
428 placeholder={info.placeholder}
429 submitLabel="send"
430 autoFocus
431 onSubmit={value => void send($, value)}
432 />
433 <Text dimColor>{info.hint}</Text>
434 </Box>
435 )}
436 </Box>
437 )
438 })
439}
440hooks/lib.ts 158 lines1import type { Message, Mode } from '../types'
2
3export type Exchange = { q: string; a: string }
4
5export const MODELS = ['haiku', 'sonnet', 'opus', 'fable'] as const
6export const DEFAULT_MODEL = 'haiku'
7
8const CLIP = 2000
9
10export const clip = (s: string) => (s.length > CLIP ? `${s.slice(0, CLIP)}…` : s)
11
12// The picker's options: the aliases, plus a custom id while one is selected.
13export const modelOptions = (current: string) =>
14 (MODELS as readonly string[]).includes(current) ? [...MODELS] : [...MODELS, current]
15
16export const mainContext = (main: readonly Exchange[]) =>
17 main.length === 0
18 ? ''
19 : 'Recent main conversation (background only):\n' +
20 main.map(x => `User: ${x.q}\nAssistant: ${x.a}`).join('\n\n')
21
22// The side thread as one prompt; its last line is the message being answered.
23export const threadPrompt = (thread: readonly Message[]) =>
24 thread
25 .filter(m => m.role !== 'error')
26 .map(m => `${m.role === 'user' ? 'User' : 'You'}: ${clip(m.text)}`)
27 .join('\n\n')
28
29// Where Claude Code keeps a session's transcript: one JSONL per session id,
30// under a folder named after the working directory.
31export const transcriptPath = (home: string, cwd: string, id: string) =>
32 `${home}/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
33
34type Row = {
35 type?: string
36 isMeta?: boolean
37 isSidechain?: boolean
38 message?: { content?: string | readonly { type?: string; text?: string; name?: string }[] }
39}
40
41// The main thread's rows as plain lines: what the person typed, what the
42// assistant wrote, and one mark per tool it called. Tool output is left out.
43export const parseTranscript = (jsonl: string): string[] => {
44 const out: string[] = []
45 for (const line of jsonl.split('\n')) {
46 if (line === '') {
47 continue
48 }
49 let row: Row
50 try {
51 row = JSON.parse(line) as Row
52 } catch {
53 continue
54 }
55 const isTurn = row.type === 'user' || row.type === 'assistant'
56 if (!isTurn || row.isMeta === true || row.isSidechain === true) {
57 continue
58 }
59 const who = row.type === 'user' ? 'User' : 'Assistant'
60 const content = row.message?.content
61 if (typeof content === 'string') {
62 out.push(`${who}: ${content}`)
63 continue
64 }
65 for (const block of content ?? []) {
66 if (block.type === 'text' && block.text !== undefined && block.text !== '') {
67 out.push(`${who}: ${block.text}`)
68 } else if (block.type === 'tool_use') {
69 out.push(`Assistant: [used the ${block.name ?? 'unknown'} tool]`)
70 }
71 }
72 }
73
74 return out
75}
76
77// The newest lines that fit the budget, oldest first.
78export const newest = (lines: readonly string[], budget: number): string => {
79 const kept: string[] = []
80 let used = 0
81 for (let i = lines.length - 1; i >= 0; i -= 1) {
82 const line = lines[i] ?? ''
83 if (used + line.length > budget && kept.length > 0) {
84 break
85 }
86 kept.unshift(line.length > budget ? `…${line.slice(-budget)}` : line)
87 used += line.length
88 }
89
90 return kept.join('\n\n')
91}
92
93// How long an answer took, as the thread shows it: 340ms, 2.4s, 18s, 1m 05s.
94export const formatDuration = (ms: number) => {
95 if (ms < 1000) {
96 return `${Math.max(0, Math.round(ms))}ms`
97 }
98 const seconds = ms / 1000
99 if (seconds < 10) {
100 return `${(Math.floor(seconds * 10) / 10).toFixed(1)}s`
101 }
102 if (seconds < 60) {
103 return `${Math.floor(seconds)}s`
104 }
105
106 return `${Math.floor(seconds / 60)}m ${String(Math.floor(seconds % 60)).padStart(2, '0')}s`
107}
108
109// The clock of a request in flight, in whole seconds: 7s, 1m 05s.
110export const formatElapsed = (seconds: number) =>
111 seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`
112
113// One frame of the waiting marker per second the request has run.
114const FRAMES = ['●○○', '○●○', '○○●', '○●○'] as const
115export const pulse = (ticks: number) => FRAMES[Math.max(0, ticks) % FRAMES.length]
116
117// The thread without the errors that end it: what a retry runs again.
118export const withoutTrailingErrors = (thread: readonly Message[]) => {
119 let end = thread.length
120 while (end > 0 && thread[end - 1]?.role === 'error') {
121 end -= 1
122 }
123
124 return thread.slice(0, end)
125}
126
127// What each mode says about itself, where the pane draws it.
128export const MODES: Record<
129 Mode,
130 { label: string; title: string; blurb: string; placeholder: string; hint: string; verb: string }
131> = {
132 ask: {
133 label: 'Ask',
134 title: 'Ask about this session',
135 blurb: 'A model that has read the main conversation answers here, apart from it. No tools, nothing is changed.',
136 placeholder: 'Ask a question…',
137 hint: 'Answers from the main session as background · no tools',
138 verb: 'thinking',
139 },
140 task: {
141 label: 'Request',
142 title: 'Hand a job to an agent',
143 blurb: 'An agent with tools works on it in this session and reports back. The main thread keeps going.',
144 placeholder: 'Describe the request…',
145 hint: 'An agent with tools runs it · it may read and change files',
146 verb: 'working',
147 },
148}
149
150export const SUGGESTIONS: Record<Mode, readonly string[]> = {
151 ask: [
152 'Summarize what we have done so far',
153 'What should I double-check before moving on?',
154 'Explain the last error in plain words',
155 ],
156 task: ['Review the latest changes for bugs', 'List the TODOs left in the code', 'Check that the project still builds'],
157}
158types/index.d.ts 28 lines1export type Mode = 'ask' | 'task'
2
3export type Message = {
4 id: string
5 role: 'user' | 'assistant' | 'error'
6 text: string
7 model?: string
8 // How long the answer took, in milliseconds.
9 ms?: number
10}
11
12// The request in flight: what it runs on and how, kept apart from the pickers
13// so a change of either while it runs does not relabel it.
14export type Running = { model: string; mode: Mode }
15
16declare module 'claude-code' {
17 interface PluginState {
18 'side-chat': {
19 messages: Message[]
20 model: string
21 mode: Mode
22 running: Running | null
23 // Seconds the request in flight has run; drives its clock.
24 ticks: number
25 }
26 }
27}
28