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

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.
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.
↳ Tangent appears in a narrow column to its right, clear of the text. When you're not hovering, that column is empty.💬 N badge. Click the badge to reopen that tangent.1 to 9 work too).💬 N badge to bring it back.The plugin adds no slash commands. Everything is in the sidebar and on the replies themselves.
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.
The plugin hooks four events. None of them touches permissions, settings, files, agents or commands.
| Hook | What it does |
|---|---|
session.start | Settles any tangent request a reload interrupted. Passes the event on with next(e). |
ui.close | When the Tangent sidebar closes, forgets which tangent was open. Passes every close on with next(e). |
ui.render on AssistantMessage | Draws 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 Pane | Draws 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.
/btwClaude 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 /btw | This plugin | |
|---|---|---|
| Ask without adding to the main context | Yes | Yes |
| Works while Claude is mid-turn | Yes | Yes |
| No tools in the side answer | Yes | Yes |
| Follow-up questions | Yes | Yes |
| Tied to a specific paragraph | No, it's about the conversation as a whole | Yes, the paragraph is quoted and in the prompt |
| Context cut off at that reply | No, it appears to use the whole conversation | Yes, the conversation up to and including that reply |
| Hover button on each paragraph | No | Yes |
One saved tangent per paragraph, 💬 N badges | No | Yes |
| A list to reopen any tangent | No | Yes |
| Promote a summary into the main conversation | No | Yes |
| Sidebar | No, a temporary answer | Yes |
| Reuses the main conversation's prompt cache | Yes | No, 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.
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.The plugin collects nothing and has no server. See PRIVACY.md.
hooks/register.tsx 398 lines1import 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}
398hooks/tangents.ts 207 lines1// 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}
207types/index.d.ts 38 lines1export 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