SLOPSHOPPER

cc-side

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

newpanecommandprocessnetworktimer
★ 2v0.4.0MITupdated 2026-10-08Ahmad8864/cc-side
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-side
│ ┃ Side chat ✕ › fix the failing auth test and add an audit log call │ ┃ Side chat │ ┃ ⏺ Read(src/auth.ts) │ ┃ Node.js 18 or later, or Bun, was not found. ⎿ Read 6 lines │ ┃ one before using cc-side. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Retry ] ⏺ Bash(bun test) │ ┃ ▣ client module ./composer.tsx ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /side │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Side chat
Side chat Node.js 18 or later, or Bun, was not found. Install one before using cc-side. [ Retry ] ▣ client module ./composer.tsx Esc main
README

cc-side

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.

cc-side demo: looking into code in the side chat while Claude keeps working in the main chat, then sending a request back.

Quick start

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.

  1. Install the plugin:
   claude plugin marketplace add Ahmad8864/cc-side
   claude plugin install cc-side@cc-side
  1. In Claude Code, run /tui fullscreen to switch to the fullscreen view. You only need to do this once.
  2. Run /side to open the side chat, or /side <question> to open it and ask right away.

Using the side chat

ActionWhat happens
Click the input boxStart 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 EscGo back to the main chat
Click a tool callSee its full details
CommandWhat it does
/model, /effortChange the model or effort for the side chat only
/edit on, /edit offLet Claude edit files, or stop it. Side chats start read-only and remember your last choice
/refreshCatch up with the main chat without losing your side conversation
/insertPut the last reply into the main chat's input box
/copyCopy the last reply
/stopStop Claude mid-reply (or click Stop)
/closeClose the side chat and throw it away (or click ×)

Good to know

  • It starts from a snapshot of your main chat. When the main chat moves on, a note tells you, and /refresh catches up. After a refresh, Claude remembers your side conversation but not the files or output it looked at earlier.
  • Claude asks before it acts, right in the side panel, with edits shown as diffs. Read-only mode blocks Claude's file editing, but shell commands follow your usual approval rules, so one you've allowed could still change files.
  • Both chats share your project folder, so file changes stay after you close the side chat.
  • It's cheap. Opening the side chat is free, and it reuses your main chat's cache when both use the same model and effort.
  • The panel resizes with your terminal, unless you've set its width yourself in Claude Code.

Known limitations

  • Pasting goes to the main chat, because Claude Code doesn't let plugins handle pastes yet. Click the side chat and type instead.
  • Files you mention with @ aren't listed under your message as they are in the main chat, though Claude still sees them.
  • Settings are copied when the side chat opens, so permissions allowed only for this session, command-line tool limits, and later mode changes may not carry over.
  • /side <question> doesn't wait if the side chat is busy; your question goes back to your input box.
  • It's still early. It's mostly used on macOS and tested with Claude Code 2.1.287, whose mod APIs can change between releases. Very long chats, attachments, and less common terminals haven't been tested much.

Want to help? See CONTRIBUTING.md. MIT licensed; bundled components keep their own licenses. cc-side is an unofficial project, not affiliated with Anthropic.

Source 19 files
hooks/register.tsx 327 lines
1import 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}
327
shared/protocol.ts 93 lines
1import 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 }
93
shared/claude-version.ts 13 lines
1// 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}
13
shared/errors.ts 4 lines
1/** 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)
4
shared/models.ts 26 lines
1import 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}
26
hooks/pane.tsx 155 lines
1import 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}
155
hooks/side-chat.ts 558 lines
1import 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}
558
hooks/composer.tsx 292 lines
1import 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
292
shared/limits.ts 51 lines
1// 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}
51
hooks/permissions.tsx 165 lines
1import 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}
165
hooks/transcript.tsx 134 lines
1import 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}
134
shared/commands.ts 149 lines
1import 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