SLOPSHOPPER

tangents

Go off on a tangent about any paragraph of Claude's replies, kept out of the main context until you promote it

newpanerowstoastmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tangents
│ ┃ tangents ✕ › fix the failing auth test and add an audit log call │ ┃ Tangents │ ┃ No tangents yet. Hover a paragraph of ⏺ Read(src/auth.ts) │ ┃ Claude's reply and pick ↳ Tangent. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ⟨Claude Code's own drawing⟩ │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tangents
Tangents No tangents yet. Hover a paragraph of Claude's reply and pick ↳ Tangent.
Claude's reply
⟨Claude Code's own drawing⟩
README

tangents

Go off on a tangent about any paragraph of Claude's replies in Claude Code. Dig into it in a sidebar without cluttering the main conversation, and promote a summary back when you're done.

Installing

This plugin ships in the plug-into-claude marketplace. In Claude Code, run:

/plugin marketplace add buildwithpulkit/plug-into-claude
/plugin install tangents@plug-into-claude
/reload-plugins

The plugin starts when you reload. If it doesn't show up, restart Claude Code.

From a terminal instead:

claude plugin marketplace add buildwithpulkit/plug-into-claude
claude plugin install tangents@plug-into-claude --scope user

A plugin is code that runs inside Claude Code on your machine, with the same access Claude Code has. This one is written by me, not by Anthropic. Read the code first, as you would with any package.

Using it

  • Hover a paragraph of any Claude reply. ↳ Tangent appears in a narrow column to its right, clear of the text. When you're not hovering, that column is empty.
  • Click it to open the Tangent sidebar. It shows the paragraph (dimmed), which reply it came from, the tangent so far and an input at the bottom.
  • Paragraphs with a tangent always show a 💬 N badge. Click the badge to reopen that tangent.
  • Esc returns focus to the main prompt. The sidebar and the tangent stay.
  • ⇪ Promote to main adds a short summary to the main conversation: the quoted paragraph, what you asked and what you concluded. Claude sees it on the next turn, and the tangent is marked Promoted ✓.
  • All tangents lists this session's tangents with a preview, reply count and promoted state. Pick one to open it (keys 1 to 9 work too).
  • Close closes the sidebar. Click any 💬 N badge to bring it back.

The plugin adds no slash commands. Everything is in the sidebar and on the replies themselves.

How context stays isolated

Each question is a separate, tool-less $.model.complete call on the session's model. The call carries the main conversation up to and including the reply the paragraph came from, then the paragraph, then this tangent's history. Neither the questions nor the answers are appended to the session, so the main context window doesn't grow. Only promote writes to the main conversation: one $.session.append user row holding the summary. The summary is written by haiku; if that call fails, it is built from the tangent itself.

Tangents live in the session's plugin state ($.state), so they survive a plugin reload. A tangent is keyed by the reply's id plus the paragraph's text, so every paragraph has its own tangent.

What the hooks do

The plugin hooks four events. None of them touches permissions, settings, files, agents or commands.

HookWhat it does
session.startSettles any tangent request a reload interrupted. Passes the event on with next(e).
ui.closeWhen the Tangent sidebar closes, forgets which tangent was open. Passes every close on with next(e).
ui.render on AssistantMessageDraws each paragraph of a reply (with Claude Code's own renderer in the terminal, as Markdown on the desktop and other surfaces) and adds the hover button or 💬 N badge in a column beside each paragraph. The stored message is untouched.
ui.render on this mod's PaneDraws the Tangent sidebar and the tangent list.

The mod's calls: $.model.complete answers tangent questions (and writes the promote summary with haiku), $.session.messages reads the conversation for context, and $.session.append adds the summary when you promote, and only then.

Compared with the built-in /btw

Claude Code (checked on 2.1.288) has a built-in /btw command: "Ask a quick side question without interrupting the main conversation." It covers the isolated side question. This plugin adds tangents tied to paragraphs, keeps them for the session, and lets you promote one into the main conversation.

Built-in /btwThis plugin
Ask without adding to the main contextYesYes
Works while Claude is mid-turnYesYes
No tools in the side answerYesYes
Follow-up questionsYesYes
Tied to a specific paragraphNo, it's about the conversation as a wholeYes, the paragraph is quoted and in the prompt
Context cut off at that replyNo, it appears to use the whole conversationYes, the conversation up to and including that reply
Hover button on each paragraphNoYes
One saved tangent per paragraph, 💬 N badgesNoYes
A list to reopen any tangentNoYes
Promote a summary into the main conversationNoYes
SidebarNo, a temporary answerYes
Reuses the main conversation's prompt cacheYesNo, each question resends the conversation in full

The /btw column comes from its definition and text in the CLI, not from using it side by side.

Developing

Run it from a local checkout, without installing:

claude --plugin-dir /path/to/plug-into-claude/plugins/tangents

Check and test it, from this directory:

claude plugin validate ../..                        # the marketplace
claude plugin validate .claude-plugin/plugin.json   # the plugin
claude plugin test .
  • hooks/register.tsx: the hooks (reply rendering, sidebar, promote).
  • hooks/tangents.ts: pure helpers (paragraph split, transcript cut, prompts).
  • types/index.d.ts: the $.state contract.

Privacy

The plugin collects nothing and has no server. See PRIVACY.md.

License

MIT

Source 3 files
hooks/register.tsx 398 lines
1import type { EngineInterface, Register, RenderElement } from 'claude-code'
2
3import type { TangentsView, Tangent, TangentMessage } from '../types'
4import {
5  MAX_MARKDOWN_CHARS,
6  SUMMARY_SYSTEM,
7  TANGENT_SYSTEM,
8  clip,
9  parseSummary,
10  preview,
11  promotedNote,
12  replyCount,
13  splitParagraphs,
14  summaryPrompt,
15  tangentIdFor,
16  tangentPrompt,
17  transcriptUpTo,
18} from './tangents'
19
20const PANE = 'tangents'
21
22// The hover label, and the width of the column beside each paragraph that holds
23// it or the `💬 N` badge: wide enough for the label, so neither covers the text.
24const START_LABEL = '↳ Tangent'
25const CONTROL_COLUMNS = START_LABEL.length
26
27// The session state this mod keeps (types/index.d.ts). Each is read and written
28// with $.state.get and $.state.set; a get while drawing subscribes the drawing.
29const TANGENTS = { plugin: 'tangents', key: 'tangents' } as const
30const OPEN_ID = { plugin: 'tangents', key: 'openId' } as const
31const VIEW = { plugin: 'tangents', key: 'view' } as const
32
33// Named types, so every function that takes $ has a plain one-line signature:
34// the Claude directory follows $ only into functions declared that simply.
35type TangentMap = Record<string, Tangent>
36type TangentMapEdit = (all: TangentMap) => TangentMap
37type TangentEdit = (tangent: Tangent) => Tangent
38type OpenId = string | null
39type Paragraphs = readonly string[]
40
41/** Read, change and write the tangents, again on a version miss, so two writes both land. */
42async function updateTangents($: EngineInterface, fn: TangentMapEdit) {
43  for (let attempt = 0; attempt < 8; attempt++) {
44    const held = await $.state.get(TANGENTS)
45    const written = await $.state.set(TANGENTS, fn(held.value ?? {}), { ifVersion: held.version })
46    if (written.isSet) return
47  }
48}
49
50async function setOpenId($: EngineInterface, id: OpenId) {
51  await $.state.set(OPEN_ID, id)
52}
53
54async function setView($: EngineInterface, view: TangentsView) {
55  await $.state.set(VIEW, view)
56}
57
58async function patch($: EngineInterface, id: string, fn: TangentEdit) {
59  await updateTangents($, all => {
60    const tangent = all[id]
61
62    return tangent === undefined ? all : { ...all, [id]: fn(tangent) }
63  })
64}
65
66async function show($: EngineInterface, mode: TangentsView, id: OpenId) {
67  if (id !== null) await setOpenId($, id)
68  await setView($, mode)
69  const opened = await $.ui.open({ id: PANE, title: 'Tangent', focus: true })
70  if (!opened.isPlaced) $.ui.toast('tangents: widen the window to see the tangent')
71}
72
73async function scrollToEnd($: EngineInterface) {
74  try {
75    await $.ui.scroll({ in: PANE, to: 'end' })
76  } catch {
77    // Scrolling is a nicety: a pane not on screen has nothing to scroll.
78  }
79}
80
81async function openTangent($: EngineInterface, requestId: string, block: string, paragraphs: Paragraphs, index: number) {
82  const paragraph = paragraphs[index]!
83  const id = tangentIdFor(requestId, paragraph)
84  const now = await $.clock.now()
85  await updateTangents($, all =>
86    all[id] !== undefined
87      ? all
88      : {
89          ...all,
90          [id]: {
91            id,
92            requestId,
93            paragraph,
94            block,
95            replyHint: preview(paragraphs[0]!, 48),
96            paragraphIndex: index,
97            paragraphCount: paragraphs.length,
98            createdAt: now,
99            messages: [],
100            isPending: false,
101            isPromoting: false,
102            isPromoted: false,
103          },
104        },
105  )
106  await show($, 'tangent', id)
107}
108
109function failure(result: { reason: string; status?: number | null }): string {
110  if (result.reason === 'api-error') return `The tangent request failed (API error ${result.status ?? 'no response'}). Ask again to retry.`
111  if (result.reason === 'aborted') return 'The tangent request was cut short. Ask again to retry.'
112
113  return 'Claude returned no text. Ask again to retry.'
114}
115
116async function ask($: EngineInterface, id: string, question: string) {
117  const text = question.trim()
118  const held = await $.state.get(TANGENTS)
119  const all = held.value ?? {}
120  const tangent = all[id]
121  if (text === '' || tangent === undefined || tangent.isPending) return
122
123  await patch($, id, t => ({
124    ...t,
125    isPending: true,
126    messages: [...t.messages, { role: 'user', text }],
127  }))
128  await scrollToEnd($)
129
130  let reply: TangentMessage
131  try {
132    // Built fresh per question: the main conversation up to the reply, the
133    // paragraph, and this tangent so far. Nothing here is appended anywhere.
134    const transcript = await $.session.messages()
135    const context = transcriptUpTo(Array.isArray(transcript) ? transcript : [], tangent.block, tangent.paragraph)
136    const model = await $.session.model()
137    const result = await $.model.complete({
138      model,
139      system: TANGENT_SYSTEM,
140      prompt: tangentPrompt(context, tangent, text),
141      maxTokens: 4096,
142      timeoutMs: 300_000,
143    })
144    reply = result.isAnswered
145      ? { role: 'assistant', text: result.text }
146      : { role: 'assistant', text: failure(result), isError: true }
147  } catch (error) {
148    reply = { role: 'assistant', text: `The tangent request was refused: ${String(error)}`, isError: true }
149  }
150
151  await patch($, id, t => ({ ...t, isPending: false, messages: [...t.messages, reply] }))
152  await scrollToEnd($)
153}
154
155async function promote($: EngineInterface, id: OpenId) {
156  const held = await $.state.get(TANGENTS)
157  const all = held.value ?? {}
158  const tangent = id === null ? undefined : all[id]
159  if (tangent === undefined) return $.ui.toast('No tangent is open to promote.')
160  if (tangent.isPromoted) return $.ui.toast('This tangent is already promoted.')
161  if (tangent.isPromoting) return
162  if (tangent.isPending) return $.ui.toast('Wait for the tangent reply before promoting.')
163  if (!tangent.messages.some(m => m.role === 'assistant' && !m.isError)) {
164    return $.ui.toast('Nothing to promote yet: ask something in the tangent first.')
165  }
166
167  await patch($, tangent.id, t => ({ ...t, isPromoting: true, promoteError: undefined }))
168  try {
169    let written: string | undefined
170    try {
171      const result = await $.model.complete({
172        model: 'haiku',
173        system: SUMMARY_SYSTEM,
174        prompt: summaryPrompt(tangent),
175        maxTokens: 400,
176        effort: 'low',
177        timeoutMs: 60_000,
178      })
179      if (result.isAnswered) written = result.text
180    } catch {
181      // No summary model: parseSummary builds one from the tangent itself.
182    }
183    const summary = parseSummary(written, tangent)
184    const appended = await $.session.append({
185      message: { type: 'user', content: [{ type: 'text', text: promotedNote(tangent, summary) }] },
186    })
187    if (appended.deny !== undefined) {
188      await patch($, tangent.id, t => ({ ...t, isPromoting: false, promoteError: `Promote refused: ${appended.deny}` }))
189
190      return
191    }
192    await patch($, tangent.id, t => ({ ...t, isPromoting: false, isPromoted: true }))
193    $.ui.toast('Tangent promoted: Claude will see its summary on the next turn.')
194  } catch (error) {
195    await patch($, tangent.id, t => ({ ...t, isPromoting: false, promoteError: `Promote failed: ${String(error)}` }))
196  }
197}
198
199async function close($: EngineInterface) {
200  await setOpenId($, null)
201  await $.ui.close({ id: PANE })
202}
203
204export const register: Register = on => {
205  on('session.start', async ($, e, next) => {
206    // A reload drops the module's in-flight requests: settle what they left.
207    await updateTangents($, all =>
208      Object.fromEntries(
209        Object.entries(all).map(([id, t]) => [
210          id,
211          t.isPending || t.isPromoting
212            ? {
213                ...t,
214                isPromoting: false,
215                isPending: false,
216                messages: t.isPending
217                  ? [...t.messages, { role: 'assistant' as const, text: 'Interrupted by a reload. Ask again to retry.', isError: true }]
218                  : t.messages,
219              }
220            : t,
221        ]),
222      ),
223    )
224
225    return next(e)
226  })
227
228  on('ui.close', async ($, e, next) => {
229    if (e.id === PANE) await setOpenId($, null)
230
231    return next(e)
232  })
233
234  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
235    const block = e.props.text
236    const paragraphs = splitParagraphs(block)
237    if (paragraphs.length === 0) return next(e)
238
239    // Off the terminal one reply holds one engine drawing: the desktop draws the
240    // first `next` and drops the rest, so there each paragraph is a Markdown,
241    // and a paragraph too long for one leaves the reply to the engine.
242    const isTerminal = e.surface === 'terminal'
243    if (!isTerminal && paragraphs.some(p => p.length > MAX_MARKDOWN_CHARS)) return next(e)
244
245    const held = await $.state.get(TANGENTS)
246    const all = held.value ?? {}
247    const { Box, Button, Markdown } = $.ui.resolve(e)
248
249    // Each paragraph is drawn as a reply is, so it looks as it always does; the
250    // controls sit in a narrow column to its right, so they never cover its
251    // text, and revealing one on hover moves nothing.
252    const drawn: RenderElement[] = []
253    for (const [index, text] of paragraphs.entries()) {
254      drawn.push(
255        isTerminal
256          ? await next({
257              ...e,
258              props: { ...e.props, text, isFirstOfReply: e.props.isFirstOfReply && index === 0 },
259            })
260          : <Markdown text={text} />,
261      )
262    }
263
264    // Each Markdown trims its own outer margins, so off the terminal the
265    // paragraphs would touch; a row between them restores the reply's spacing.
266    return (
267      <Box flexDirection="column" rowGap={isTerminal ? 0 : 1}>
268        {paragraphs.map((paragraph, index) => {
269          const count = replyCount(all[tangentIdFor(e.requestId, paragraph)])
270          const open = () => openTangent($, e.requestId, block, paragraphs, index)
271
272          return (
273            <Box key={`p${index}`} flexDirection="row">
274              <Box flexDirection="column" flexGrow={1} flexShrink={1}>
275                {drawn[index]}
276              </Box>
277              <Box width={CONTROL_COLUMNS} flexShrink={0} marginLeft={1}>
278                {count > 0 ? (
279                  <Box position="absolute" top={0} right={0}>
280                    <Button key={`badge${index}`} plain label={`💬 ${count}`} onPress={open} />
281                  </Box>
282                ) : (
283                  <Box position="absolute" top={0} right={0} display="none" hover={{ display: 'flex' }}>
284                    <Button key={`reply${index}`} plain dimColor label={START_LABEL} onPress={open} />
285                  </Box>
286                )}
287              </Box>
288            </Box>
289          )
290        })}
291      </Box>
292    )
293  })
294
295  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
296    const table = $.ui.resolve(e)
297    const { Box, Text, Button, Markdown } = table
298    const heldTangents = await $.state.get(TANGENTS)
299    const heldOpenId = await $.state.get(OPEN_ID)
300    const heldView = await $.state.get(VIEW)
301    const all = heldTangents.value ?? {}
302    const id = heldOpenId.value ?? null
303    const tangent = id === null ? undefined : all[id]
304
305    if (heldView.value === 'list' || tangent === undefined) {
306      const listed = Object.values(all)
307        .filter(t => t.messages.length > 0 || t.isPromoted)
308        .sort((a, b) => b.createdAt - a.createdAt)
309      const width = Math.max(20, e.props.bodyColumns - 24)
310
311      return (
312        <Box flexDirection="column">
313          <Text bold>Tangents</Text>
314          {listed.length === 0 && (
315            <Text dimColor>No tangents yet. Hover a paragraph of Claude's reply and pick {START_LABEL}.</Text>
316          )}
317          {listed.map((t, index) => (
318            <Box key={`row${index}`} flexDirection="row" marginTop={index === 0 ? 1 : 0}>
319              <Button
320                key={`pick${index}`}
321                plain
322                hotkey={index < 9 ? String(index + 1) : undefined}
323                label={preview(t.paragraph, width)}
324                onPress={() => show($, 'tangent', t.id)}
325              />
326              <Text dimColor>
327                {' '}· {t.messages.length} {t.messages.length === 1 ? 'reply' : 'replies'}
328                {t.isPromoted ? ' · Promoted ✓' : ''}
329              </Text>
330            </Box>
331          ))}
332        </Box>
333      )
334    }
335
336    // Mobile draws no Input: the tangent is read-only there.
337    const Input = 'Input' in table ? table.Input : undefined
338    const status = tangent.isPromoted ? 'Promoted ✓' : tangent.isPromoting ? 'Promoting…' : undefined
339
340    return (
341      <Box flexDirection="column">
342        <Box flexDirection="row" justifyContent="space-between">
343          <Text bold>Tangent</Text>
344          {status !== undefined && <Text color={tangent.isPromoted ? 'green' : undefined} dimColor={!tangent.isPromoted}>{status}</Text>}
345        </Box>
346        <Text dimColor wrap="truncate-end">
347          From Claude's reply “{tangent.replyHint}” · paragraph {tangent.paragraphIndex + 1} of {tangent.paragraphCount}
348        </Text>
349        <Box marginTop={1}>
350          <Markdown dimColor text={clip(tangent.paragraph, MAX_MARKDOWN_CHARS).replace(/^/gm, '> ')} />
351        </Box>
352        {tangent.messages.map((message, index) => (
353          <Box key={`m${index}`} flexDirection="column" marginTop={1}>
354            <Text bold color={message.role === 'assistant' ? 'cyan' : undefined}>
355              {message.role === 'user' ? 'You' : 'Claude'}
356            </Text>
357            {message.isError ? (
358              <Text color="red">{message.text}</Text>
359            ) : (
360              <Markdown text={clip(message.text, MAX_MARKDOWN_CHARS)} />
361            )}
362          </Box>
363        ))}
364        {tangent.isPending && (
365          <Box marginTop={1}>
366            <Text dimColor>Claude is thinking…</Text>
367          </Box>
368        )}
369        {tangent.promoteError !== undefined && (
370          <Box marginTop={1}>
371            <Text color="red">{tangent.promoteError}</Text>
372          </Box>
373        )}
374        <Box marginTop={1} flexDirection="column">
375          {Input === undefined ? (
376            <Text dimColor>Reply from the terminal or desktop.</Text>
377          ) : (
378            <Input
379              key={`ask${tangent.messages.length}`}
380              placeholder={tangent.isPending ? 'Waiting for the reply…' : 'Ask about this paragraph…'}
381              submitLabel="send"
382              autoFocus
383              onSubmit={value => ask($, tangent.id, value)}
384            />
385          )}
386          <Box flexDirection="row" gap={1} marginTop={1}>
387            {!tangent.isPromoted && (
388              <Button key="promote" label="⇪ Promote to main" onPress={() => promote($, tangent.id)} />
389            )}
390            <Button key="all" label="All tangents" onPress={() => show($, 'list', null)} />
391            <Button key="close" role="dismiss" label="Close" onPress={() => close($)} />
392          </Box>
393        </Box>
394      </Box>
395    )
396  })
397}
398
hooks/tangents.ts 207 lines
1// Pure helpers: splitting replies into paragraphs, naming tangents, and
2// building the prompts a tangent sends. No `$` in here, so tests reach it whole.
3
4import type { SessionMessage } from 'claude-code'
5
6import type { Tangent } from '../types'
7
8/** How much of the main conversation a tangent question carries, newest kept. */
9export const MAX_CONTEXT_CHARS = 240_000
10
11/** Markdown elements draw at most 10000 characters. */
12export const MAX_MARKDOWN_CHARS = 9_900
13
14const FENCE = /^\s{0,3}(`{3,}|~{3,})/
15
16/**
17 * Splits a reply's markdown into paragraphs on blank lines, keeping fenced
18 * code blocks whole even when they hold blank lines.
19 */
20export function splitParagraphs(markdown: string): string[] {
21  const paragraphs: string[] = []
22  let current: string[] = []
23  let fence: string | null = null
24
25  const flush = () => {
26    const text = current.join('\n').trim()
27    if (text !== '') paragraphs.push(text)
28    current = []
29  }
30
31  for (const line of markdown.split('\n')) {
32    const marker = FENCE.exec(line)?.[1]
33    if (marker !== undefined) {
34      if (fence === null) fence = marker[0]!.repeat(marker.length)
35      else if (marker.startsWith(fence)) fence = null
36    }
37    if (fence === null && marker === undefined && line.trim() === '') {
38      flush()
39      continue
40    }
41    current.push(line)
42  }
43  flush()
44
45  return paragraphs
46}
47
48export function normalize(text: string): string {
49  return text.replace(/\s+/g, ' ').trim()
50}
51
52function hash(text: string): string {
53  let h = 5381
54  for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0
55  return (h >>> 0).toString(36)
56}
57
58/** One tangent per paragraph of one reply: the reply's id and the paragraph's text. */
59export function tangentIdFor(requestId: string, paragraph: string): string {
60  return `${requestId}:${hash(normalize(paragraph))}`
61}
62
63/** Plain text of markdown, shortened to `max` characters on one line. */
64export function preview(markdown: string, max: number): string {
65  const plain = normalize(
66    markdown
67      .replace(/```[\s\S]*?```/g, ' [code] ')
68      .replace(/[`*_#>]+/g, '')
69      .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'),
70  )
71
72  return plain.length > max ? `${plain.slice(0, max - 1).trimEnd()}…` : plain
73}
74
75export function clip(text: string, max: number): string {
76  return text.length > max ? `${text.slice(0, max - 1)}…` : text
77}
78
79/** The number a paragraph's badge shows: every message in its tangent. */
80export function replyCount(tangent: Tangent | undefined): number {
81  return tangent?.messages.length ?? 0
82}
83
84/**
85 * The main conversation up to and including the reply the paragraph came
86 * from, as plain text. The reply is found by its text block (or, failing
87 * that, the paragraph); when neither is found the whole transcript is used.
88 */
89export function transcriptUpTo(
90  messages: readonly SessionMessage[],
91  block: string,
92  paragraph: string,
93): string {
94  const wantBlock = normalize(block)
95  const wantParagraph = normalize(paragraph)
96  let end = messages.length - 1
97  for (let i = messages.length - 1; i >= 0; i--) {
98    const message = messages[i]!
99    if (message.role !== 'assistant') continue
100    const text = normalize(message.text)
101    if (text.includes(wantBlock) || text.includes(wantParagraph)) {
102      end = i
103      break
104    }
105  }
106
107  const lines: string[] = []
108  for (const message of messages.slice(0, end + 1)) {
109    const who = message.role === 'user' ? 'User' : 'Claude'
110    const parts: string[] = []
111    if (message.text.trim() !== '') parts.push(message.text.trim())
112    for (const use of message.toolUses) parts.push(`[used tool ${use.tool}]`)
113    if (message.toolResults?.length) parts.push(`[${message.toolResults.length} tool result(s)]`)
114    if (parts.length > 0) lines.push(`${who}: ${parts.join('\n')}`)
115  }
116
117  const text = lines.join('\n\n')
118
119  return text.length > MAX_CONTEXT_CHARS
120    ? `[earlier conversation omitted]\n\n${text.slice(text.length - MAX_CONTEXT_CHARS)}`
121    : text
122}
123
124export const TANGENT_SYSTEM = [
125  'You are Claude, answering inside a tangent of a Claude Code session.',
126  'The user picked one paragraph of your earlier reply and is asking about it here.',
127  'This tangent is separate from the main conversation: you cannot run tools or change files from it.',
128  'Answer the newest question directly and concisely, in markdown, staying on the paragraph unless asked otherwise.',
129].join(' ')
130
131function tangentSoFar(tangent: Tangent): string {
132  return tangent.messages
133    .filter(message => !message.isError)
134    .map(message => `${message.role === 'user' ? 'User' : 'You'}: ${message.text}`)
135    .join('\n\n')
136}
137
138export function tangentPrompt(context: string, tangent: Tangent, question: string): string {
139  const history = tangentSoFar(tangent)
140
141  return [
142    '<main_conversation>',
143    context === '' ? '(empty)' : context,
144    '</main_conversation>',
145    '',
146    'The user opened a tangent on this paragraph of your reply:',
147    '<paragraph>',
148    tangent.paragraph,
149    '</paragraph>',
150    '',
151    ...(history === '' ? [] : ['<tangent_so_far>', history, '</tangent_so_far>', '']),
152    'The user asks in the tangent:',
153    '<question>',
154    question,
155    '</question>',
156  ].join('\n')
157}
158
159export const SUMMARY_SYSTEM =
160  'You summarize a short side discussion for the record. Reply with exactly two lines and nothing else.'
161
162export function summaryPrompt(tangent: Tangent): string {
163  return [
164    'A user discussed this paragraph of an assistant reply in a tangent:',
165    '<paragraph>',
166    tangent.paragraph,
167    '</paragraph>',
168    '<tangent>',
169    tangentSoFar(tangent),
170    '</tangent>',
171    '',
172    'Write exactly two lines:',
173    'Asked: <what the user asked, one or two sentences>',
174    'Concluded: <what was concluded or decided, two to four sentences>',
175  ].join('\n')
176}
177
178export type Summary = { asked: string; concluded: string }
179
180/** The model's two lines, or a summary made from the tangent itself. */
181export function parseSummary(text: string | undefined, tangent: Tangent): Summary {
182  const asked = text?.match(/^\s*Asked:\s*(.+)$/im)?.[1]?.trim()
183  const concluded = text?.match(/^\s*Concluded:\s*([\s\S]+)$/im)?.[1]?.trim()
184  if (asked && concluded) return { asked, concluded }
185
186  const questions = tangent.messages.filter(m => m.role === 'user').map(m => m.text)
187  const answers = tangent.messages.filter(m => m.role === 'assistant' && !m.isError)
188
189  return {
190    asked: clip(normalize(questions.join(' / ')), 400),
191    concluded: clip(normalize(answers.at(-1)?.text ?? 'No answer was reached.'), 800),
192  }
193}
194
195export function promotedNote(tangent: Tangent, summary: Summary): string {
196  const quoted = clip(tangent.paragraph, 1200).replace(/^/gm, '> ')
197
198  return [
199    '[Tangent promoted by the user]',
200    'The user discussed this paragraph of your earlier reply in a tangent:',
201    quoted,
202    '',
203    `Asked: ${summary.asked}`,
204    `Concluded: ${summary.concluded}`,
205  ].join('\n')
206}
207
types/index.d.ts 38 lines
1export type TangentMessage = {
2  role: 'user' | 'assistant'
3  text: string
4  isError?: boolean
5}
6
7export type Tangent = {
8  id: string
9  /** The AssistantMessage instance the paragraph was drawn in. */
10  requestId: string
11  paragraph: string
12  /** The whole text block the paragraph came from, to find it in the transcript. */
13  block: string
14  /** A short preview of the reply's opening, for the "from" note. */
15  replyHint: string
16  paragraphIndex: number
17  paragraphCount: number
18  createdAt: number
19  messages: TangentMessage[]
20  isPending: boolean
21  isPromoting: boolean
22  isPromoted: boolean
23  /** Why the last promote did not land, shown in the sidebar until the next try. */
24  promoteError?: string
25}
26
27export type TangentsView = 'tangent' | 'list'
28
29declare module 'claude-code' {
30  interface PluginState {
31    'tangents': {
32      tangents: Record<string, Tangent>
33      openId: string | null
34      view: TangentsView
35    }
36  }
37}
38