SLOPSHOPPER

side-chat

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

newpanecommandtoastpromptmodel
v0.2.0MITupdated 2026-10-09ptpmediabr/side-chat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · side-chat
│ ┃ Side chat ✕ › fix the failing auth test and add an audit log call │ ┃ Side chat │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ ● Ask ] [ Request ] Model: haiku ▾ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ │ Ask about this session ⏺ Bash(bun test) │ ┃ │ ⎿ 3 pass, 1 fail │ ┃ │ A model that has read the main │ ┃ │ conversation answers here, apart from ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ it. No tools, nothing is changed. │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Try one: │ ┃ │ [ Summarize what we have done so far ] › /side │ ┃ │ [ What should I double-check before moving ⎿ side-chat: Side chat opened. │ ┃ │ [ Explain the last error in plain words ] │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ Ask a question… ⏎ send │ ┃ Answers from the main session as background │ ┃ · no tools │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Side chat
Side chat [ ● Ask ] [ Request ] Model: haiku ▾ ╭──────────────────────────────────────────────────────────╮ │ Ask about this session │ │ │ │ A model that has read the main conversation answers │ │ here, apart from it. No tools, nothing is changed. │ │ │ │ Try one: │ │ [ Summarize what we have done so far ] │ │ [ What should I double-check before moving on? ] │ │ [ Explain the last error in plain words ] │ ╰──────────────────────────────────────────────────────────╯ Ask a question… ⏎ send Answers from the main session as background · no tools
README

side-chat

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.

Como usar

ComandoO 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.

Dois modos

  • Ask: um modelo que leu a conversa principal responde aqui, à parte. Sem ferramentas: nada é alterado.
  • Request: um agente com ferramentas faz o trabalho nesta sessão e volta com um relatório curto. A conversa principal segue enquanto isso. O agente pode ler e alterar arquivos.

Instalação

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.

Privacidade: o que é enviado ao modelo

  • No modo Ask, o mod lê o transcript local da sessão (~/.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.
  • No modo Request, o agente recebe até cerca de 20 mil caracteres da conversa e o caminho do transcript, que ele pode ler se precisar de mais.
  • Não pede chave de API: as chamadas passam pelo próprio Claude Code, com o mesmo login, e contam no seu uso.
  • Guarda no armazenamento local do Claude Code apenas o modelo que você escolheu.

Desenvolvimento

claude plugin validate .
claude plugin test .

Testado com o Claude Code 2.1.296.

Licença

MIT. Feito por Chiara Costa.

Source 3 files
hooks/register.tsx 440 lines
1import { 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}
440
hooks/lib.ts 158 lines
1import 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}
158
types/index.d.ts 28 lines
1export 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