SLOPSHOPPER

Side Chat

A side pane (/side) where you talk directly to a subagent forked from the main conversation, while the main conversation keeps working.

newpaneguardcommandtoastprompt
v1.0.0MITupdated 2026-10-13VkaZas/claude-code-mods/vkazas-side
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vkazas-side
│ ┃ ✻ Side ✕ › fix the failing auth test and add an audit log call │ ┃ ✻ Side ○ not forked │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Talk to a side fork here ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ · Your first message forks a subagent from ⎿ 3 pass, 1 fail │ ┃ the main conversation as it stands; it │ ┃ remembers everything up to the fork ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ · The main conversation and the side fork │ ┃ work independently; neither blocks the ✻ Worked for 42s · done 4:20 PM │ ┃ other │ ┃ · Its replies stay here; press "Sync to › /side │ ┃ main" to hand them over ⎿ vkazas-side: Side pane opened. │ ┃ · Press "Re-fork" to catch it up with the │ ┃ main conversation │ ┃ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ❯: Ask something; it forks a side agent fi │ ┃ ╰─────────────────────────────────────────── │ ┃ enter send · tab buttons · esc back to main[ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ✻ Side
✻ Side ○ not forked ──────────────────────────────────────────────────────── Talk to a side fork here · Your first message forks a subagent from the main conversation as it stands; it remembers everything up to the fork · The main conversation and the side fork work independently; neither blocks the other · Its replies stay here; press "Sync to main" to hand them over · Press "Re-fork" to catch it up with the main conversation ╭──────────────────────────────────────────────────────────╮ │ ❯: Ask something; it forks a side agent first… ⏎ Send │ ╰──────────────────────────────────────────────────────────╯ enter send · tab buttons · esc back to main[ Sync to main ]
README

Side Chat (vkazas-side)

A second conversation beside your main one. Type /side and a pane opens next to the transcript; the first thing you say there forks a subagent from the main conversation as it stands, so it already knows everything you have discussed. You talk to that fork directly in the pane while the main conversation keeps working, and neither waits for the other.

Install

At the prompt of a Claude Code terminal session:

/plugin install vkazas-side --marketplace VkaZas/claude-code-mods

Answer y to add the marketplace, then pick a scope (user is the first). From your shell instead (Claude Code 2.1.292 or later):

claude plugin install vkazas-side --marketplace VkaZas/claude-code-mods

How it works

  • /side opens the pane and puts the cursor in its input box. esc takes you back to the main prompt.
  • The first message forks a subagent from the main conversation. It remembers everything up to the fork, and nothing the main conversation does after.
  • Follow-ups go to the same fork, which wakes up with the side conversation so far.
  • Replies stay in the pane. When the fork finishes a reply, the main conversation is not woken up and does not see the reply. While it works, the pane shows a spinner and the tool it is running.
  • Sync to main appends the side conversation (up to its last 8,000 characters) to the main conversation, for its next turn.
  • Re-fork lets the current fork go; your next message forks a fresh one from where the main conversation is now.

The fork is a real subagent: each message is a model turn on your plan, and it can use tools like the main conversation can. It is told to avoid editing files the main conversation may be editing, and to say which files before it changes any.

Settings

OptionValuesDefault
Languageen, zhen

The language sets the pane's labels, and the instructions the fork gets with your first message (in en, it answers in the language you write in; in zh, in Chinese).

What it hooks

  • session.start: registers /side and starts a timer that drives the spinner while the fork works.
  • command.run, for /side only: opens the pane.
  • tool.call: when the caller is this pane's fork, notes which tool it is running, for the spinner line. Every call goes through unchanged.
  • turn.complete and classic.SubagentStop: when the agent is this pane's fork, takes its answer and shows it in the pane. Everything else passes through unchanged.
  • prompt.submit, session.receive and session.append: a notification that this pane's fork finished is dropped where it would start a main turn, consumed where it would be delivered, and cut to one line in the rare case it is appended. Notifications from any other agent, and your own prompts, go through unchanged.
  • ui.render on its own pane: draws the conversation, the input and the two buttons.

Notes

  • Side Chat is a mod: a hooks module with a pane. It was built and tested with Claude Code 2.1.295 in Windows Terminal.
  • It sends nothing anywhere but to Claude Code itself, and stores nothing on disk: the side conversation lasts for the session.

中文说明

/side 打开侧边面板,第一句话会从主对话当前状态 fork 出一个「分身」子代理,它记得 fork 之前的一切。你在面板里直接和分身对话,主对话照常干活,互不阻塞。分身的回复只留在面板里,不会唤醒主对话;需要时点「同步到主对话」把记录交给主对话,想让分身跟上主对话的新进展就点「重新 fork」。在 /config 里把 Language 设成 zh,面板就是中文,分身也会用中文回答。

License

MIT

Source 2 files
hooks/register.tsx 522 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SideMessage } from '../types'
5
6const PANE = 'side-chat'
7const KEEP = 60
8const SHOWN = 30
9const SYNC_CHARS = 8000
10const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
11
12const messages = atom({ plugin: 'vkazas-side', key: 'messages' } as const, [])
13const agentId = atom({ plugin: 'vkazas-side', key: 'agentId' } as const, null)
14const spawned = atom({ plugin: 'vkazas-side', key: 'spawned' } as const, [])
15const isBusy = atom({ plugin: 'vkazas-side', key: 'isBusy' } as const, false)
16const busySince = atom({ plugin: 'vkazas-side', key: 'busySince' } as const, null)
17const activity = atom({ plugin: 'vkazas-side', key: 'activity' } as const, '')
18const draft = atom({ plugin: 'vkazas-side', key: 'draft' } as const, '')
19const tick = atom({ plugin: 'vkazas-side', key: 'tick' } as const, 0)
20
21// ---------- words, in the language the options choose ----------
22
23type Lang = 'en' | 'zh'
24
25const TEXT = {
26  en: {
27    // The fork inherits the main conversation; this frames the first message it gets.
28    firstMessage: [
29      'You are a "side fork" forked from the main conversation. The user is talking to you directly in a side pane in Claude Code, and the main conversation may be busy with something else at the same time.',
30      '- Answer what the user says in the side pane, in the language they write in.',
31      '- You know the main conversation only up to the moment of the fork; you cannot see what it did after. When unsure, say so.',
32      '- Avoid changing files the main conversation may be changing; say which files before you change any.',
33      '- Keep answers short, and end with a one- or two-sentence conclusion.',
34      '- After each answer you pause; when the user follows up in the pane you wake with the conversation so far. No need to explain this to the user.',
35      '',
36      'The user says in the side pane:',
37    ].join('\n'),
38    // The main conversation hears that the side agent finished, not what it said:
39    // the reply lives in the pane, and Sync to main hands it over on request.
40    shortResult:
41      'The side fork finished. Its full reply is shown only in the side pane and was not passed to the main conversation; the user will press "Sync to main" if they want it. This notification needs no response.',
42    dropped: 'The side fork answered; the reply is in the /side pane',
43    consumed: 'vkazas-side: the side fork\'s reply stays in the side pane',
44    noText: '(no text reply this round)',
45    interrupted: '(this round was interrupted)',
46    errored: '(this round ended with an error)',
47    stillBusy: 'The side fork is still answering; send once it is done',
48    spawnDescription: 'side fork',
49    noFork: (why: string) => `Could not fork a side agent: ${why}`,
50    noStart: 'The side fork did not seem to start; try sending again',
51    forked: (id: string) => `Forked side agent ${id} from the main conversation`,
52    notSent: (why: string) => `Not delivered to the side fork: ${why}. Try "Re-fork".`,
53    me: 'User (side pane)',
54    side: 'Side fork',
55    nothingToSync: 'Nothing in the side pane to sync yet',
56    omitted: '…(earlier part omitted)',
57    syncHeader:
58      '[Transcript of the side fork conversation: the user wants you to know what was discussed and done there. For reference only; no separate reply needed.]',
59    syncFailed: (why: string) => `Sync failed: ${why}`,
60    syncedNote: 'Synced into the main conversation; it sees this next turn',
61    synced: 'Synced to the main conversation',
62    reforked: 'Disconnected this side fork; your next message forks a new one from where the main conversation is now',
63    reloaded: 'The mod reloaded; the reply to the last message was missed',
64    command: 'Open the side pane: talk directly to a subagent forked from the main conversation',
65    title: '✻ Side',
66    opened: 'Side pane opened.',
67    notOpened: (why: string) => `The side pane could not be shown: ${why}`,
68    answering: '● answering',
69    unforked: '○ not forked',
70    ready: '● ready',
71    introTitle: 'Talk to a side fork here',
72    intro: [
73      '· Your first message forks a subagent from the main conversation as it stands; it remembers everything up to the fork',
74      '· The main conversation and the side fork work independently; neither blocks the other',
75      '· Its replies stay here; press "Sync to main" to hand them over',
76      '· Press "Re-fork" to catch it up with the main conversation',
77    ],
78    folded: (n: number) => `  …${n} earlier messages folded`,
79    thinking: 'thinking',
80    waitPlaceholder: 'Wait for the answer before the next message…',
81    firstPlaceholder: 'Ask something; it forks a side agent first…',
82    placeholder: 'Say something to the side fork…',
83    send: 'Send',
84    hintWide: 'enter send · tab buttons · esc back to main',
85    hint: 'enter send · esc back to main',
86    syncButton: 'Sync to main',
87    reforkButton: 'Re-fork',
88  },
89  zh: {
90    firstMessage: [
91      '你是从主对话 fork 出来的「分身」。用户正在 Claude Code 的侧边面板里直接和你对话,主对话那边可能同时在做别的事。',
92      '- 直接回答用户在侧边面板里说的话,用中文。',
93      '- 你只知道 fork 那一刻之前的主对话;之后主对话做了什么你看不到,拿不准就直说。',
94      '- 尽量不要改主对话可能正在改的文件;要改文件先说清楚改哪些。',
95      '- 回答简洁,最后用一两句话给结论。',
96      '- 每次答完你会暂停;用户在面板里追问时,你会带着之前的对话被唤醒接着聊。不用跟用户解释你是「一次性」的。',
97      '',
98      '用户在侧边面板说:',
99    ].join('\n'),
100    shortResult:
101      '分身答完了。完整回复只显示在侧边面板里,没有转进主对话;用户需要时会点「同步到主对话」。这条通知不需要回应。',
102    dropped: '分身答完了,回复在 /side 面板里',
103    consumed: 'vkazas-side:分身的回复留在侧边面板里',
104    noText: '(这一轮没有文字回复)',
105    interrupted: '(这一轮被中断了)',
106    errored: '(这一轮出错结束)',
107    stillBusy: '分身还在回答上一句,等它说完再发',
108    spawnDescription: '侧边分身',
109    noFork: (why: string) => `没能 fork 出分身:${why}`,
110    noStart: '分身好像没启动起来,可以再发一次试试',
111    forked: (id: string) => `已从主对话 fork 出分身 ${id}`,
112    notSent: (why: string) => `没送到分身:${why}。可以点「重新 fork」再试`,
113    me: '用户(侧边面板)',
114    side: '分身',
115    nothingToSync: '侧边还没有对话可同步',
116    omitted: '……(前面省略)',
117    syncHeader: '[侧边分身的对话记录:用户想让你知道分身那边谈了什么、做了什么。仅供参考,不需要单独回复。]',
118    syncFailed: (why: string) => `同步失败:${why}`,
119    syncedNote: '已同步进主对话,主对话下一轮能看到',
120    synced: '已同步到主对话',
121    reforked: '已断开当前分身,下一句话会从主对话的最新状态重新 fork',
122    reloaded: 'mod 重新加载了:上一句的回复没接到',
123    command: '打开侧边分身面板:和一个 fork 了主对话上下文的子代理直接对话',
124    title: '✻ 分身',
125    opened: '侧边分身面板已打开。',
126    notOpened: (why: string) => `侧边分身面板没能显示:${why}`,
127    answering: '● 回答中',
128    unforked: '○ 未 fork',
129    ready: '● 待命',
130    introTitle: '在这里直接和「分身」对话',
131    intro: [
132      '· 第一句话会从主对话当前状态 fork 出分身,它记得 fork 之前的一切',
133      '· 主对话和分身各干各的,互不阻塞',
134      '· 分身的回复只留在这里,需要时点「同步到主对话」',
135      '· 想让分身跟上主对话的新进展,点「重新 fork」',
136    ],
137    folded: (n: number) => `  …更早的 ${n} 条已折叠`,
138    thinking: '分身在想',
139    waitPlaceholder: '分身回答完再发下一句…',
140    firstPlaceholder: '问点什么,会先 fork 出分身…',
141    placeholder: '跟分身说点什么…',
142    send: '发送',
143    hintWide: 'enter 发送 · tab 选按钮 · esc 回主对话',
144    hint: 'enter 发送 · esc 回主对话',
145    syncButton: '同步到主对话',
146    reforkButton: '重新 fork',
147  },
148}
149
150let lang: Lang = 'en'
151
152function shorten(text: string): string {
153  return text.replace(/<result>[\s\S]*?<\/result>/, `<result>${TEXT[lang].shortResult}</result>`)
154}
155
156// Is this text a notification from an agent this pane forked?
157async function fromSideAgent($: EngineInterface, text: string): Promise<boolean> {
158  const current = await read($, agentId)
159  const ids = [...((await read($, spawned)) ?? []), ...(current === null ? [] : [current])]
160  return ids.some(id => mentionsAgent(text, id))
161}
162
163function mentionsAgent(text: string, id: string): boolean {
164  return text.includes(`<task-id>${id}</task-id>`) && text.includes('<result>')
165}
166
167function clockTime(at: number | undefined): string {
168  return at === undefined ? '' : new Date(at).toTimeString().slice(0, 5)
169}
170
171async function add($: EngineInterface, message: SideMessage) {
172  await update($, messages, list => [...(list ?? []), { ...message, at: Date.now() }].slice(-KEEP))
173}
174
175async function setIdle($: EngineInterface) {
176  await update($, isBusy, () => false)
177  await update($, busySince, () => null)
178  await update($, activity, () => '')
179}
180
181// The pane's window starts at the top of its tree, so new messages fall below
182// it: move the window once the redraw that holds the target has landed.
183async function reveal($: EngineInterface, to: { key: string } | 'end', block?: 'start' | 'end') {
184  for (let attempt = 0; attempt < 6; attempt++) {
185    try {
186      const moved = await $.ui.scroll({ in: PANE, to, block })
187      if (moved.deny === undefined) return
188    } catch {
189      // not drawn yet, or no scrolling here
190    }
191    await $.clock.sleep(80).catch(() => undefined)
192  }
193}
194
195// A reply can reach us twice (turn.complete and SubagentStop): show it once.
196async function deliver($: EngineInterface, id: string | undefined, text: string | undefined) {
197  if (id === undefined || id !== (await read($, agentId))) return
198  const reply = text === undefined || text === '' ? TEXT[lang].noText : text
199  const list = (await read($, messages)) ?? []
200  const last = list[list.length - 1]
201  if (!(last !== undefined && last.role === 'side' && last.text === reply)) {
202    await add($, { role: 'side', text: reply })
203  }
204  await setIdle($)
205  // Land on the top of the new reply, so it reads from its first line.
206  const count = ((await read($, messages)) ?? []).length
207  await reveal($, { key: `msg-${count - 1}` }, 'start')
208}
209
210async function ask($: EngineInterface, raw: string) {
211  const text = raw.trim()
212  if (text === '') return
213  if (await read($, isBusy)) {
214    $.ui.toast(TEXT[lang].stillBusy)
215    return
216  }
217  await update($, draft, () => '')
218  await add($, { role: 'me', text })
219  await update($, isBusy, () => true)
220  await update($, busySince, () => Date.now())
221  await update($, activity, () => '')
222  void reveal($, 'end')
223
224  const current = await read($, agentId)
225  if (current === null) {
226    const started = await $.agent.spawn({
227      prompt: `${TEXT[lang].firstMessage}\n${text}`,
228      description: TEXT[lang].spawnDescription,
229      subagentType: 'fork',
230    })
231    if (started.deny !== undefined) {
232      await add($, { role: 'note', text: TEXT[lang].noFork(started.deny) })
233      await setIdle($)
234      return
235    }
236    // Fall back to the agent list when the start did not name the agent.
237    const id =
238      started.agentId ??
239      (await $.agent.list().catch(() => []))
240        .filter(a => a.spawnedBy === 'vkazas-side' && a.status === 'running')
241        .map(a => a.id)
242        .pop()
243    if (id === undefined) {
244      await add($, { role: 'note', text: TEXT[lang].noStart })
245      await setIdle($)
246      return
247    }
248    await update($, agentId, () => id)
249    await update($, spawned, list => [...(list ?? []).filter(x => x !== id), id].slice(-50))
250    await add($, { role: 'note', text: TEXT[lang].forked(id.slice(0, 8)) })
251    return
252  }
253
254  const sent = await $.session.send({ to: { agentId: current }, text })
255  if (!sent.isDelivered) {
256    await add($, { role: 'note', text: TEXT[lang].notSent(String(sent.reason)) })
257    await setIdle($)
258  }
259}
260
261async function syncToMain($: EngineInterface) {
262  const list = (await read($, messages)) ?? []
263  const lines = list
264    .filter(m => m.role !== 'note')
265    .map(m => `${m.role === 'me' ? TEXT[lang].me : TEXT[lang].side}: ${m.text}`)
266  if (lines.length === 0) {
267    $.ui.toast(TEXT[lang].nothingToSync)
268    return
269  }
270  let body = lines.join('\n\n')
271  if (body.length > SYNC_CHARS) body = `${TEXT[lang].omitted}\n${body.slice(-SYNC_CHARS)}`
272  const appended = await $.session.append({
273    message: {
274      type: 'user',
275      content: [
276        {
277          type: 'text',
278          text: `${TEXT[lang].syncHeader}\n\n${body}`,
279        },
280      ],
281    },
282  })
283  if ('deny' in appended && appended.deny !== undefined) {
284    $.ui.toast(TEXT[lang].syncFailed(String(appended.deny)))
285    return
286  }
287  await add($, { role: 'note', text: TEXT[lang].syncedNote })
288  $.ui.toast(TEXT[lang].synced)
289}
290
291async function refork($: EngineInterface) {
292  await update($, agentId, () => null)
293  await setIdle($)
294  await add($, { role: 'note', text: TEXT[lang].reforked })
295}
296
297function describeTool(e: unknown): string {
298  const call = e as { tool?: unknown; command?: unknown; file_path?: unknown; pattern?: unknown; url?: unknown }
299  const detail = [call.command, call.file_path, call.pattern, call.url].find(v => typeof v === 'string')
300  const short = typeof detail === 'string' ? ` ${detail.replace(/\s+/g, ' ').slice(0, 48)}` : ''
301  return `${String(call.tool)}${short}`
302}
303
304export const register: Register = (on, options) => {
305  lang = (options as { language?: string } | undefined)?.language === 'zh' ? 'zh' : 'en'
306  const t = TEXT[lang]
307
308  on('session.start', async ($, e, next) => {
309    const started = await next(e)
310    // A reload must not leave the pane waiting on a reply it can no longer catch.
311    if (await read($, isBusy)) {
312      await setIdle($)
313      await add($, { role: 'note', text: t.reloaded })
314    }
315    // Show where the conversation stands, not its first lines.
316    const count = ((await read($, messages)) ?? []).length
317    if (count > 0) void reveal($, { key: `msg-${count - 1}` }, 'start')
318    // Drives the spinner while the side agent works; idle ticks cost a read.
319    void $.clock.every(120, async () => {
320      if (await read($, isBusy)) await update($, tick, n => ((n ?? 0) + 1) % 1000)
321    })
322    await $.command.register({
323      name: 'side',
324      description: t.command,
325    })
326    return started
327  })
328
329  on('command.run', { command: 'side' }, async $ => {
330    const opened = await $.ui.open({ id: PANE, title: t.title, focus: true })
331    return {
332      text: opened.isPlaced ? t.opened : t.notOpened(String(opened.reason)),
333    }
334  })
335
336  // Show what the side agent is doing while it works.
337  on('tool.call', async ($, e, next) => {
338    const caller = (e as { agentId?: string }).agentId
339    if (caller !== undefined && caller === (await read($, agentId))) {
340      await update($, activity, () => describeTool(e))
341    }
342    return next(e)
343  }).catch(($, e, next) => next(e))
344
345  // A turn of the side agent ended: its answer is the reply in the pane.
346  on('turn.complete', async ($, e, next) => {
347    const result = await next(e)
348    if (e.agentId !== undefined) {
349      const reply =
350        e.answer !== ''
351          ? e.answer
352          : e.isAborted
353            ? t.interrupted
354            : e.reason === 'error'
355              ? t.errored
356              : undefined
357      await deliver($, e.agentId, reply)
358    }
359    return result
360  })
361
362  // A resumed run may end without a turn.complete reaching us: SubagentStop
363  // carries the agent and its last message either way.
364  on('classic.SubagentStop', async ($, e, next) => {
365    const result = await next(e)
366    await deliver($, e.agent_id, e.last_assistant_message)
367    return result
368  }).catch(($, e, next) => next(e))
369
370  // The side agent's notifications never wake the main conversation: its
371  // replies live in the pane. A notification is dropped where it would start
372  // (or join) a main turn, consumed where it is delivered, and cut to one line
373  // in the rare case it is appended anyway.
374  on('prompt.submit', async ($, e, next) => {
375    if (e.origin.kind === 'task-notification' && (await fromSideAgent($, e.text))) {
376      return { drop: t.dropped }
377    }
378    return next(e)
379  }).catch(($, e, next) => next(e))
380
381  on('session.receive', async ($, e, next) => {
382    if (e.agentId === undefined && (await fromSideAgent($, e.text))) {
383      return { consumed: t.consumed }
384    }
385    return next(e)
386  }).catch(($, e, next) => next(e))
387
388  on('session.append', async ($, e, next) => {
389    if (e.agentId !== undefined) return next(e)
390    const content = e.message.content as unknown
391    if (typeof content === 'string') {
392      if (!(await fromSideAgent($, content))) return next(e)
393      return next({ ...e, message: { ...e.message, content: shorten(content) } as typeof e.message })
394    }
395    if (!Array.isArray(content)) return next(e)
396    const blocks = content as { type: string; text?: string }[]
397    const texts = blocks.filter(b => b.type === 'text' && typeof b.text === 'string').map(b => b.text as string)
398    if (!(await fromSideAgent($, texts.join(' ')))) return next(e)
399    const shortened = blocks.map(b => (b.type === 'text' && typeof b.text === 'string' ? { ...b, text: shorten(b.text) } : b))
400    return next({ ...e, message: { ...e.message, content: shortened } as typeof e.message })
401  }).catch(($, e, next) => next(e))
402
403  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
404    const { Box, Text, Markdown, Input, Button } = $.ui.resolve(e)
405    const list = (await read($, messages)) ?? []
406    const id = await read($, agentId)
407    const busy = await read($, isBusy)
408    const since = await read($, busySince)
409    const now = await read($, activity)
410    const typed = await read($, draft)
411    const frame = busy ? await read($, tick) : 0
412
413    const columns = Math.max(24, e.props.bodyColumns)
414    const isWide = columns >= 56
415    const shown = list.slice(-SHOWN)
416    const first = list.length - shown.length
417    const hasReplies = list.some(m => m.role === 'side')
418    const seconds = busy && since !== null ? Math.max(0, Math.round((Date.now() - since) / 1000)) : 0
419    const state = busy
420      ? { color: 'warning', label: t.answering }
421      : id === null
422        ? { color: 'inactive', label: t.unforked }
423        : { color: 'success', label: t.ready }
424
425    return (
426      <Box flexDirection="column">
427        <Box flexDirection="row" justifyContent="space-between">
428          <Box flexDirection="row">
429            <Text color="claude" bold>
430              {t.title}
431            </Text>
432            {id !== null && <Text color="subtle">{`  ${id.slice(0, 8)}`}</Text>}
433          </Box>
434          <Text color={state.color}>{state.label}</Text>
435        </Box>
436        <Text color="subtle">{'─'.repeat(columns)}</Text>
437
438        {list.length === 0 ? (
439          <Box flexDirection="column" marginTop={1} paddingX={1}>
440            <Text bold>{t.introTitle}</Text>
441            <Box flexDirection="column" marginTop={1}>
442              {t.intro.map(line => (
443                <Text color="subtle">{line}</Text>
444              ))}
445            </Box>
446          </Box>
447        ) : (
448          first > 0 && <Text color="subtle" italic>{t.folded(first)}</Text>
449        )}
450
451        {shown.map((m, i) => (
452          // Keyed by place in the whole list, so reveal() can scroll to one.
453          <Box key={`msg-${first + i}`} flexDirection="column" marginTop={1}>
454            {m.role === 'me' ? (
455              <Box flexDirection="row">
456                <Text color="suggestion" bold>
457                  {'❯ '}
458                </Text>
459                <Box flexDirection="column" flexShrink={1}>
460                  <Text>{m.text}</Text>
461                </Box>
462              </Box>
463            ) : m.role === 'side' ? (
464              <Box flexDirection="column">
465                <Box flexDirection="row">
466                  <Text color="claude" bold>
467                    {t.title}
468                  </Text>
469                  <Text color="subtle">{`  ${clockTime(m.at)}`}</Text>
470                </Box>
471                <Box flexDirection="column" paddingLeft={2}>
472                  <Markdown text={m.text} />
473                </Box>
474              </Box>
475            ) : (
476              <Text color="subtle" italic>{`  · ${m.text}`}</Text>
477            )}
478          </Box>
479        ))}
480
481        {busy && (
482          <Box flexDirection="row" marginTop={1}>
483            <Text color="claude">{`${SPINNER[(frame ?? 0) % SPINNER.length]} `}</Text>
484            <Text color="subtle" wrap="truncate-end">
485              {`${t.thinking} · ${seconds}s${now === '' ? '' : ` · ${now}`}`}
486            </Text>
487          </Box>
488        )}
489
490        <Box marginTop={1} borderStyle="round" borderColor={busy ? 'inactive' : 'promptBorder'} paddingX={1}>
491          <Input
492            key="ask"
493            label="❯"
494            placeholder={busy ? t.waitPlaceholder : id === null ? t.firstPlaceholder : t.placeholder}
495            value={typed ?? ''}
496            submitLabel={t.send}
497            autoFocus
498            onInput={value => {
499              void update($, draft, () => value)
500            }}
501            onSubmit={value => {
502              void ask($, value)
503            }}
504          />
505        </Box>
506        <Box flexDirection={isWide ? 'row' : 'column'} justifyContent="space-between">
507          <Text color="subtle">{isWide ? t.hintWide : t.hint}</Text>
508          <Box flexDirection="row" gap={1}>
509            <Button
510              key="sync"
511              label={t.syncButton}
512              variant={hasReplies ? 'primary' : 'secondary'}
513              onPress={() => syncToMain($)}
514            />
515            <Button key="refork" label={t.reforkButton} onPress={() => refork($)} />
516          </Box>
517        </Box>
518      </Box>
519    )
520  })
521}
522
types/index.d.ts 18 lines
1export type SideMessage = { role: 'me' | 'side' | 'note'; text: string; at?: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'vkazas-side': {
6      messages: SideMessage[]
7      agentId: string | null
8      // Every agent the pane forked, so their notifications are known after a re-fork.
9      spawned: string[]
10      isBusy: boolean
11      busySince: number | null
12      activity: string
13      draft: string
14      tick: number
15    }
16  }
17}
18