SLOPSHOPPER

turn-tides

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

newbandprocess
v0.1.0MITupdated 2026-10-08pengkangzhen/turn-tides
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-tides
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ≋ Tides — awaiting the first turn ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
≋ Tides — awaiting the first turn ⟨Claude Code's own drawing⟩
README

Turn Tides

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.

A hover unfolding one turn's summary above the strip of turn bars

Install

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.

What it does

  • A bar per turn — the session's questions in one strip above the prompt. A bar's width reads the length of its answer: the history at a glance, token-weather's chart turned horizontal.
  • Hover a bar — the turn's question and an answer summary unfold above the strip.
  • Click a bar — the transcript scrolls to that turn's question.
  • ≋ 12 tides — the count, styled like a weather forecast's headline.
  • Seeded from the transcript on load (with a tail fallback for sessions past the read cap); live updates as turns complete.
  • Collapsible with [-] or ctrl+x ctrl+a; composes with other band mods (e.g. token-weather) instead of claiming the band.

Compatibility

  • Fullscreen and main-screen layouts alike: no dock thresholds, works under tmux.
  • Hover needs a terminal that reports mouse movement (Ghostty, iTerm2, kitty, WezTerm do); clicking and keyboard navigation work everywhere.

Development

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.

License

MIT

Source 2 files
hooks/register.tsx 373 lines
1import { 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}
373
types/index.d.ts 41 lines
1export 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