The tides of the session's turns: a bar per turn, hover for its summary, click to jump to it.

The tides of your conversation, drawn above the prompt — one bar per turn, hover for its summary, click to jump back to it.
A Claude Code mod (function-hooks plugin) in the visual language of the official token-weather: where that one forecasts the context window, this one charts the session's turns.

In a Claude Code terminal session:
/plugin install turn-tides --marketplace pengkangzhen/turn-tides
Answer y to add the marketplace, pick the user scope, done — the strip is standing from the session's first prompt on, in every session.
≋ 12 tides — the count, styled like a weather forecast's headline.[-] or ctrl+x ctrl+a; composes with other band mods (e.g. token-weather) instead of claiming the band.claude plugin validate .
claude plugin test . # 3 engine-level tests
The hooks module is hooks/register.tsx; the state contract lives in types/index.d.ts.
MIT
hooks/register.tsx 373 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Question } from '../types'
5
6const questions = atom({ plugin: 'turn-tides', key: 'questions' } as const, [])
7
8// answers live only in the hover summary, so a short one-line summary is all
9// the state keeps
10const ANSWER_CAP = 240
11
12const textOf = (content: unknown): string => {
13 if (typeof content === 'string') return content.trim()
14 if (!Array.isArray(content)) return ''
15 const parts: string[] = []
16 for (const block of content) {
17 if (block === null || typeof block !== 'object') continue
18 const typed = block as { type?: unknown; text?: unknown }
19 if (typed.type === 'text' && typeof typed.text === 'string') parts.push(typed.text)
20 }
21 return parts.join(' ').trim()
22}
23
24// not every user-role row is a question the person typed: slash commands,
25// local-command echoes (the !-passthrough envelope is <bash-input> for the
26// command line and <bash-stdout> for its output, two separate rows),
27// interruption markers and caveats ride the same door
28const isLocalEcho = (text: string): boolean =>
29 text.startsWith('<command-name>') ||
30 text.startsWith('<bash-') ||
31 text.startsWith('<local-command')
32
33const isQuestionText = (text: string): boolean =>
34 text !== '' && !text.startsWith('/') && !isLocalEcho(text) && !text.startsWith('[Request interrupted')
35
36const questionFromRow = (
37 parsed: unknown,
38): { id: string; kind: 'ask'; text: string; at: number } | undefined => {
39 if (parsed === null || typeof parsed !== 'object') return undefined
40 const row = parsed as {
41 type?: unknown
42 isMeta?: unknown
43 isSidechain?: unknown
44 uuid?: unknown
45 timestamp?: unknown
46 message?: unknown
47 }
48 if (row.type !== 'user' || row.isMeta === true || row.isSidechain === true) return undefined
49 if (typeof row.uuid !== 'string' || row.message === null || typeof row.message !== 'object')
50 return undefined
51 const text = textOf((row.message as { content?: unknown }).content)
52 if (!isQuestionText(text)) return undefined
53 return {
54 id: row.uuid,
55 kind: 'ask' as const,
56 text,
57 at: typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : 0,
58 }
59}
60
61// a `!`-passthrough shell command the person ran from the prompt: its row
62// wraps the command line in <bash-input> tags
63const commandFromRow = (
64 parsed: unknown,
65): { id: string; kind: 'command'; text: string; at: number } | undefined => {
66 if (parsed === null || typeof parsed !== 'object') return undefined
67 const row = parsed as {
68 type?: unknown
69 isMeta?: unknown
70 isSidechain?: unknown
71 uuid?: unknown
72 timestamp?: unknown
73 message?: unknown
74 }
75 if (row.type !== 'user' || row.isMeta === true || row.isSidechain === true) return undefined
76 if (typeof row.uuid !== 'string' || row.message === null || typeof row.message !== 'object')
77 return undefined
78 const text = textOf((row.message as { content?: unknown }).content)
79 if (!text.startsWith('<bash-input>')) return undefined
80 const line = /^<bash-input>([\s\S]*?)<\/bash-input>/.exec(text)?.[1]?.trim()
81 if (line === undefined || line === '') return undefined
82 return {
83 id: row.uuid,
84 kind: 'command' as const,
85 text: line,
86 at: typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : 0,
87 }
88}
89
90const isAssistantRow = (parsed: unknown): boolean => {
91 if (parsed === null || typeof parsed !== 'object') return false
92 const row = parsed as { type?: unknown; isSidechain?: unknown }
93 return row.type === 'assistant' && row.isSidechain !== true
94}
95
96// the whole file is preferred; a transcript past the 4 MiB read cap is
97// walked in contiguous 4 MiB windows through host commands (tail from a
98// byte offset, the engine capping each run's stdout at 4 MiB), the windows
99// joined back into one text — every turn in the file is reachable, however
100// long the session. A row cut by a window boundary may parse nowhere; that
101// costs at most one turn per 4 MiB.
102const WINDOW = 4194304
103
104const readTranscript = async ($: EngineInterface, path: string): Promise<string | undefined> => {
105 const whole = await $.fs.read(path).catch(() => undefined)
106 if (whole !== undefined) return whole
107 const wc = await $.process.run(['wc', '-c', path]).catch(() => undefined)
108 const size = Number(wc?.stdout.trim().split(/\s+/)[0])
109 if (wc === undefined || wc.exitCode !== 0 || !Number.isFinite(size)) return undefined
110 const parts: string[] = []
111 for (let start = 1; start <= size; start += WINDOW) {
112 const run = await $.process.run(['tail', '-c', `+${start}`, path]).catch(() => undefined)
113 if (run === undefined || run.exitCode !== 0) break
114 parts.push(run.stdout)
115 }
116 return parts.length === 0 ? undefined : parts.join('')
117}
118
119// A transcript row's uuid is the id its render site draws under and what
120// $.ui.scroll takes, so the seeded file and the live appends join on it.
121// The assistant rows that follow a question become its answer summary: each
122// one overwrites the last, so the row nearest the next question wins.
123// merge: a tail-windowed re-seed refreshes what the window covers and keeps
124// the turns before it (a wholesale replace would shrink the strip to the
125// window); a session start still replaces wholesale — a /clear must reset
126const seed = async (
127 $: EngineInterface,
128 transcriptPath: string,
129 merge = false,
130): Promise<void> => {
131 if (transcriptPath === '') return
132 const file = await readTranscript($, transcriptPath)
133 if (file === undefined) return
134 const found: Question[] = []
135 for (const line of file.split('\n')) {
136 if (line === '') continue
137 let parsed: unknown
138 try {
139 parsed = JSON.parse(line)
140 } catch {
141 continue
142 }
143 const asked = questionFromRow(parsed)
144 if (asked !== undefined) {
145 found.push({ ...asked, answer: '', tokens: 0 })
146 continue
147 }
148 const command = commandFromRow(parsed)
149 if (command !== undefined) {
150 found.push({ ...command, answer: '', tokens: 0 })
151 continue
152 }
153 if (isAssistantRow(parsed) && found.length > 0 && found[found.length - 1]!.kind === 'ask') {
154 const message = (parsed as { message?: unknown }).message
155 if (message !== null && typeof message === 'object') {
156 const typed = message as { content?: unknown; usage?: { output_tokens?: unknown } }
157 const text = oneLine(textOf(typed.content), ANSWER_CAP)
158 if (text !== '') found[found.length - 1]!.answer = text
159 const out = typed.usage?.output_tokens
160 if (typeof out === 'number') found[found.length - 1]!.tokens += out
161 }
162 }
163 }
164 await update($, questions, list => {
165 if (!merge) return found
166 const byId = new Map(list.map(q => [q.id, q] as const))
167 for (const q of found) byId.set(q.id, q)
168 return [...byId.values()].sort((a, b) => a.at - b.at)
169 })
170}
171
172const oneLine = (text: string, room: number): string => {
173 const flat = text.replace(/\s+/g, ' ')
174 return flat.length > room ? flat.slice(0, Math.max(1, room - 1)) + '…' : flat
175}
176
177const shortTokens = (n: number): string =>
178 n >= 1000 ? `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k` : String(n)
179
180// dynamic widths, token-weather's rule ("bars scale to the busiest reading
181// shown, so growth shows at any fill level"): an ask's width spreads 2..5
182// across the logarithm of its output tokens between the quietest and the
183// busiest turns in view, so the strip always shows its full range no matter
184// how the session's absolute numbers cluster, and ties stay ties. The
185// running turn (its tokens not yet in) matches the latest known one; a
186// !-command is a single column of its own
187const rawWidths = (bars: Question[]): number[] => {
188 const known = bars.filter(q => q.kind === 'ask' && q.tokens > 0).map(q => q.tokens)
189 if (known.length === 0) return bars.map(q => (q.kind === 'command' ? 1 : 3))
190 const lo = Math.log(Math.min(...known))
191 const hi = Math.log(Math.max(...known))
192 const spread = hi - lo
193 const widthOf = (t: number): number =>
194 spread === 0 ? 3 : 2 + Math.round((3 * (Math.log(t) - lo)) / spread)
195 let fallback = 3
196 for (let i = bars.length - 1; i >= 0; i -= 1) {
197 if (bars[i]!.kind === 'ask' && bars[i]!.tokens > 0) {
198 fallback = widthOf(bars[i]!.tokens)
199 break
200 }
201 }
202 return bars.map(q => (q.kind === 'command' ? 1 : q.tokens > 0 ? widthOf(q.tokens) : fallback))
203}
204
205// the strip never wraps and no turn is dropped: when the natural widths
206// overflow the line, the one-column gaps are reserved first and every bar
207// scales by its share of what remains (floored at one column, so the mapping
208// stays monotone — busier turns stay visibly longer until the physics of the
209// terminal runs out). Only when even one-column bars with gaps cannot fit do
210// the gaps give way and the bars touch, token-weather's chart style; a turn
211// count beyond the raw columns degenerates to the newest ones.
212const scaleStrip = (
213 list: Question[],
214 columns: number,
215): { bars: Question[]; widths: number[]; gap: number } => {
216 const budget = Math.max(24, Math.max(8, columns) - 14)
217 const bars = list.length > budget ? list.slice(-budget) : list
218 const widths = rawWidths(bars)
219 const sum = widths.reduce((a, b) => a + b, 0)
220 const gaps = bars.length - 1
221 if (sum + gaps <= budget) return { bars, widths, gap: 1 }
222 if (bars.length + gaps <= budget) {
223 const k = (budget - gaps) / sum
224 return { bars, widths: widths.map(w => Math.max(1, Math.floor(w * k))), gap: 1 }
225 }
226 const k = budget / sum
227 return { bars, widths: widths.map(w => Math.max(1, Math.floor(w * k))), gap: 0 }
228}
229
230export const register: Register = on => {
231 // a fresh module copy re-seeds on the first prompt after it loads: an
232 // incremental list built by an older schema (turns recorded before the
233 // tokens field, say) would otherwise keep its stale widths forever; later
234 // prompts only re-seed when the strip stands empty
235 let seededThisCopy = false
236
237 // transcript_path is the one place the session's own file is named; seeding
238 // from it recovers the questions asked before this plugin loaded, and on a
239 // /clear it re-reads the fresh file, replacing the list wholesale.
240 on('classic.SessionStart', async ($, e, next) => {
241 await seed($, e.transcript_path)
242 return next(e)
243 }).catch(($, e, next) => next(e))
244
245 on('classic.UserPromptSubmit', async ($, e, next) => {
246 if (!seededThisCopy) {
247 seededThisCopy = true
248 await seed($, e.transcript_path, true)
249 } else if ((await read($, questions)).length === 0) {
250 await seed($, e.transcript_path, true)
251 }
252 return next(e)
253 }).catch(($, e, next) => next(e))
254
255 on('session.append', async ($, e, next) => {
256 if (
257 e.agentId === undefined &&
258 (e.door === 'prompt' || e.door === 'command') &&
259 e.message.type === 'user' &&
260 e.message.isMeta !== true
261 ) {
262 const text = textOf(e.message.content)
263 // a !-passthrough command arrives wrapped in <bash-input>; a slash
264 // command's echo in <command-name> stays out of the strip
265 const kind: Question['kind'] = text.startsWith('<bash-input>') ? 'command' : 'ask'
266 const body =
267 kind === 'command'
268 ? (/^<bash-input>([\s\S]*?)<\/bash-input>/.exec(text)?.[1] ?? '').trim()
269 : text
270 if ((kind === 'ask' && isQuestionText(text)) || (kind === 'command' && body !== '')) {
271 const at = await $.clock.now()
272 await update($, questions, list =>
273 list.some(q => q.id === e.uuid)
274 ? list
275 : [...list, { id: e.uuid, kind, text: body, answer: '', tokens: 0, at }],
276 )
277 }
278 }
279 return next(e)
280 }).catch(($, e, next) => next(e))
281
282 // the turn's own final text becomes the ask's hover summary; only the last
283 // still-unanswered ask takes it, so continuations of a turn that asked
284 // nothing change nobody's summary and !-commands never take one
285 on('turn.complete', async ($, e, next) => {
286 if (e.agentId === undefined && e.answer !== '') {
287 const summary = oneLine(e.answer, ANSWER_CAP)
288 await update($, questions, list => {
289 const last = list[list.length - 1]
290 if (last === undefined || last.kind !== 'ask' || last.answer !== '') return list
291 return [
292 ...list.slice(0, -1),
293 { ...last, answer: summary, tokens: e.usage?.output_tokens ?? 0 },
294 ]
295 })
296 }
297 return next(e)
298 })
299
300 // the strip lives in the band above the prompt, in token-weather's visual
301 // language: a bold icon+word lead whose colour mirrors the live state, a
302 // bar per turn whose width reads the answer's length (its history chart,
303 // horizontal), dim for everything secondary. Hovering a bar unfolds that
304 // turn's summary above the strip (a hover group ties the two, the only way
305 // hover talks).
306 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
307 if (e.props.hasSurvey) return next(e)
308 const { Box, Text, Button } = $.ui.resolve(e)
309 const list = await read($, questions)
310 const room = Math.max(4, Math.max(8, e.props.bodyColumns) - 6)
311 const scaled = scaleStrip(list, e.props.bodyColumns)
312
313 // compose with the other band mods (token-weather, replay-theater):
314 // ours on top, whatever the chain beneath draws kept below it
315 return (
316 <Box flexDirection="column">
317 {list.length === 0 ? (
318 <Text dimColor>≋ Tides — awaiting the first turn</Text>
319 ) : (
320 <Box flexDirection="column">
321 {list.map((q, i) => (
322 <Box
323 key={`d:${q.id}`}
324 display="none"
325 hover={{ display: 'flex', scope: `q:${q.id}` }}
326 backgroundColor="#d0f0f4"
327 flexDirection="column"
328 paddingX={1}
329 >
330 <Text color="black">
331 <Text bold>{`#${i + 1}`}</Text>
332 {` ${q.kind === 'command' ? '$ ' : ''}${oneLine(q.text, room)}`}
333 </Text>
334 <Text color="black" dimColor>
335 {q.kind === 'command'
336 ? '! local command'
337 : q.answer === ''
338 ? 'A: …'
339 : `A: ${oneLine(q.answer, room)}`}
340 {q.kind === 'ask' && q.tokens > 0 ? ` ↓${shortTokens(q.tokens)}` : ''}
341 </Text>
342 </Box>
343 ))}
344 <Box flexDirection="row" gap={scaled.gap} paddingX={1}>
345 <Text color="cyan" bold>{`≋ ${list.length}`}</Text>
346 <Text dimColor>tides</Text>
347 {scaled.bars.map((q, i) => (
348 <Box
349 key={`b:${q.id}`}
350 backgroundColor={q.kind === 'command' ? 'magenta' : 'cyan'}
351 hover={{ scope: `q:${q.id}` }}
352 >
353 <Button
354 key={`q:${q.id}`}
355 plain
356 label={' '.repeat(scaled.widths[i]!)}
357 onPress={() =>
358 void $.ui.scroll({ to: { requestId: q.id }, block: 'start' }).catch(
359 () => undefined,
360 )
361 }
362 />
363 </Box>
364 ))}
365 </Box>
366 </Box>
367 )}
368 {await next(e)}
369 </Box>
370 )
371 })
372}
373types/index.d.ts 41 lines1export type Question = {
2 /**
3 * The prompt's row id: session.append's uuid, the UserMessage render site's
4 * requestId, and what $.ui.scroll({ to: { requestId } }) takes.
5 */
6 id: string
7 /**
8 * 'ask': a question the person typed, with the turn's answer as summary.
9 * 'command': a `!`-passthrough shell command the person ran; one bar,
10 * its own colour, no answer.
11 */
12 kind: 'ask' | 'command'
13 /**
14 * The prompt's text blocks joined, as first asked; for a command, the
15 * command line itself.
16 */
17 text: string
18 /**
19 * The turn's final visible answer, collapsed to one line and capped;
20 * '' until the turn ends.
21 */
22 answer: string
23 /**
24 * Output tokens the turn generated, summed over its responses — what the
25 * bar's width reads; 0 until the turn ends. Input tokens are left out:
26 * dominated by context re-reads, they grow with the session, not the turn.
27 */
28 tokens: number
29 /**
30 * When it was asked, in $.clock.now()'s milliseconds (Date.parse of the
31 * transcript's timestamp when seeded from the file).
32 */
33 at: number
34}
35
36declare module 'claude-code' {
37 interface PluginState {
38 'turn-tides': { questions: Question[] }
39 }
40}
41