SLOPSHOPPER

turn-nav

Lists your prompts in a pane and jumps the transcript to a selected prompt.

newpanebandcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-nav
│ ┃ turn-nav ✕ › fix the failing auth test and add an audit log call │ ┃ No prompts yet │ ⏺ 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 │ │ › /turn-nav │ ⎿ turn-nav: Prompt navigator off (stays off in new sessions; /turn │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · turn-nav
No prompts yet
README

turn-nav

A pane that lists the prompts you sent this session. Press one and the transcript jumps to it — useful once a session is long enough that scrolling back to "what did I ask an hour ago" takes a while.

When the pane is closed, one line above the prompt keeps the count and your newest prompt; press it to open the pane again. /turn-nav turns the navigator off (it stays off in new sessions) and back on.

Jumping needs the fullscreen layout ("tui": "fullscreen" in settings); without it the list still shows, and a press says why it cannot jump. Prompts from before a --resume are not listed, and the list keeps the newest 200.

Options

language: en (default) or zh-TW, from /config or settings:

"pluginConfigs": { "turn-nav@claude-code-mods": { "options": { "language": "zh-TW" } } }
Source 4 files
hooks/register.tsx 169 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Turn, TurnNavChip } from '../types'
4import { denyMessage, fitLine, navLabel, promptText, rowLabel } from './turns'
5import { stringsFor } from './strings'
6import type { Strings } from './strings'
7
8const PANE = 'turn-nav'
9// rows sizes the block seated above the prompt (narrow or main-screen terminals);
10// columns sizes the dock beside a fullscreen transcript (110 columns and up).
11const turns = atom({ plugin: 'turn-nav', key: 'turns' } as const, [] as Turn[])
12// Bumped on every open and close so the prompt band re-reads whether the pane is drawn.
13const paneTick = atom({ plugin: 'turn-nav', key: 'paneTick' } as const, 0)
14// This mod's line as the prompt-band mod draws it in the row it shares; null while
15// there is nothing to show. prompt-band cannot list this mod's panes, so it is told.
16// ponytail: a pane waiting undrawn that a widened terminal places leaves the line up
17// until the next publish (no event says so); the press then just reopens it.
18const chip = atom({ plugin: 'turn-nav', key: 'chip' } as const, null as TurnNavChip | null)
19
20// Keep the newest turn in view; a pane not drawn yet has nothing to scroll.
21async function showNewest($: EngineInterface): Promise<void> {
22  await $.ui.scroll({ in: PANE, to: 'end' }).catch(() => undefined)
23}
24
25// open: the pane; collapsed: one line above the prompt (the pane's close mark);
26// off: nothing until /turn-nav. Remembered for new sessions.
27type Mode = 'open' | 'collapsed' | 'off'
28
29async function readMode($: EngineInterface): Promise<Mode> {
30  const mode = await $.store.get('mode')
31  if (mode === 'open' || mode === 'collapsed' || mode === 'off') return mode
32  // Before modes, a close by the pane's mark was stored as isOpen: false.
33  return (await $.store.get('isOpen')) === false ? 'collapsed' : 'open'
34}
35
36async function setMode($: EngineInterface, mode: Mode, s: Strings): Promise<void> {
37  await $.store.set('mode', mode)
38  await update($, paneTick, n => n + 1)
39  await publish($, s)
40}
41
42async function publish($: EngineInterface, s: Strings): Promise<void> {
43  const isPlaced = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
44  const next = isPlaced || (await readMode($)) === 'off' ? null : { label: navLabel(await read($, turns), s), action: s.action }
45  await update($, chip, () => next)
46}
47
48// Set by the prompt-band mod, which then draws this mod's line in the row it shares.
49async function hasPromptBand($: EngineInterface): Promise<boolean> {
50  return (await $.env.get('PROMPT_BAND')) === '1'
51}
52
53async function openPane($: EngineInterface, open: { id: typeof PANE; title: string; rows: 6; columns: 32 }, s: Strings): Promise<void> {
54  await $.ui.open(open)
55  await showNewest($)
56  await setMode($, 'open', s)
57}
58
59async function turnOff($: EngineInterface, s: Strings): Promise<void> {
60  if ((await $.ui.panes()).some(p => p.id === PANE)) await $.ui.close({ id: PANE })
61  await setMode($, 'off', s)
62}
63
64export const register: Register = (on, options) => {
65  const s = stringsFor(options?.language)
66  const open = { id: PANE, title: s.title, rows: 6, columns: 32 } as const
67  on('session.start', async ($, e, next) => {
68    await $.command.register({ name: 'turn-nav', description: 'Show or hide the prompt navigator (remembered for new sessions)' })
69    // An open nobody asked for is drawn only on a wide terminal; narrower, the band offers it.
70    if ((await readMode($)) === 'open') void $.ui.open(open).then(() => showNewest($)).then(() => update($, paneTick, n => n + 1)).then(() => publish($, s))
71      // The session can end before this open settles; nothing is left to redraw then.
72      .catch(() => undefined)
73    await publish($, s)
74    return next(e)
75  })
76
77  // ponytail: only prompts appended this run; a resumed session's earlier prompts are not listed
78  on('session.append', { door: 'prompt' }, async ($, e, next) => {
79    const stored = await next(e)
80    try {
81      const id = 'uuid' in stored ? stored.uuid : undefined
82      const isPersons = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
83      if (id && isPersons && !e.agentId) {
84        const text = promptText(e.message.content)
85        if (text) {
86          await update($, turns, list => [...list, { id, text }].slice(-200))
87          await publish($, s)
88          void showNewest($)
89        }
90      }
91    } catch {
92      // Navigation bookkeeping must not interrupt the transcript append.
93    }
94    return stored
95  })
96
97  on('command.run', { command: 'turn-nav' }, async $ => {
98    // Only a drawn pane is turned off; collapsed, off, or waiting undrawn, it opens.
99    if ((await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)) {
100      await turnOff($, s)
101      return { text: s.off }
102    }
103    await openPane($, open, s)
104    return { text: s.on }
105  })
106
107  on('ui.close', async ($, e, next) => {
108    // The engine skips a hook that returns nothing and says so in the transcript.
109    const result = await next(e)
110    if (e.id === PANE) {
111      if (e.origin.kind === 'person') await setMode($, 'collapsed', s)
112      else {
113        await update($, paneTick, n => n + 1)
114        await publish($, s)
115      }
116    }
117    return result
118  })
119
120  // prompt-band's line for this mod: opened here, inside the person's press, the pane is
121  // placed at any width (opened from any later event it would wait below 144 columns).
122  on('ui.press', { plugin: 'prompt-band', element: 'turn-nav' }, async ($, e, next) => {
123    await openPane($, open, s)
124    return next(e)
125  })
126
127  on('ui.press', { plugin: 'prompt-band', element: 'turn-nav-off' }, async ($, e, next) => {
128    await turnOff($, s)
129    return next(e)
130  })
131
132  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
133    if (e.props.hasSurvey || (await hasPromptBand($))) return next(e)
134    await read($, paneTick)
135    if ((await readMode($)) === 'off') return next(e)
136    const pane = (await $.ui.panes()).find(p => p.id === PANE)
137    if (pane?.isPlaced) return next(e)
138    const { Box, Button } = $.ui.resolve(e)
139    const list = await read($, turns)
140    const below = await next(e)
141    // Same shape as the hook line above it (icon name · count · newest, › at the
142    // same column); the ✕ sits where that line has the engine's [-].
143    return (
144      <Box flexDirection="column">
145        <Box flexDirection="row" columnGap={1}>
146          {/* No hotkey: a plain Button draws one as `n: ` ahead of the label. */}
147          <Button key="nav-open" plain dimColor label={fitLine(navLabel(list, s), s.action, Math.max(24, e.props.bodyColumns - 4))} onPress={() => openPane($, open, s)} />
148          <Button key="nav-off" plain dimColor label="✕" onPress={() => turnOff($, s)} />
149        </Box>
150        {below}
151      </Box>
152    )
153  })
154
155  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
156    const { Box, Button, Text } = $.ui.resolve(e)
157    const list = await read($, turns)
158    if (list.length === 0) return <Text dimColor>{s.empty}</Text>
159    const width = e.props.bodyColumns
160    return <Box flexDirection="column">
161      {e.viewport?.isFullscreen === false && <Text dimColor wrap="truncate-end">{s.notFullscreen}</Text>}
162      {list.map((turn, i) => <Button key={`turn-${i}`} plain label={rowLabel(i + 1, turn.text, width - 1)} onPress={async () => {
163        const result = await $.ui.scroll({ to: { requestId: turn.id }, block: 'start' })
164        if (result.deny) $.ui.toast(denyMessage(result.deny, s))
165      }} />)}
166    </Box>
167  })
168}
169
hooks/turns.ts 77 lines
1export function promptText(content: unknown): string {
2  try {
3    const text = typeof content === 'string' ? content : Array.isArray(content)
4      ? content.filter(block => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
5        .map(block => block.text).join('\n')
6      : ''
7    return text.replace(/^\s*<system-reminder>[\s\S]*?<\/system-reminder>\s*/, '')
8  } catch {
9    return ''
10  }
11}
12
13// East Asian wide and fullwidth ranges plus emoji: a terminal cell pair each.
14const WIDE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
15
16/** Terminal cells a string takes: wide characters count two. */
17export function cellWidth(text: string): number {
18  let width = 0
19  for (const char of text) width += WIDE.test(char) ? 2 : 1
20  return width
21}
22
23/** `n first-line`, cut to `width` terminal cells (at least 8) with an ellipsis. */
24export function label(n: number, text: string, width: number): string {
25  const first = text.split(/\r?\n/).map(line => line.replace(/\s+/g, ' ').trim()).find(Boolean) ?? ''
26  const full = `${n} ${first}`
27  const limit = Math.max(8, width)
28  if (cellWidth(full) <= limit) return full
29  let out = ''
30  for (const char of full) {
31    if (cellWidth(out + char) > limit - 1) break
32    out += char
33  }
34  return `${out}…`
35}
36
37/**
38 * A turn's row of exactly `width` cells, `›` at the right end. Drawn as one Button,
39 * the whole row is the tap target; a phone column is about 4px wide, too narrow to
40 * aim at the short label alone.
41 */
42/** The one-line summary: how many prompts, and the newest one's first line. */
43export function navLabel(list: { text: string }[], s: import('./strings').Strings): string {
44  const newest = list.at(-1)?.text.split(/\r?\n/).map(line => line.replace(/\s+/g, ' ').trim()).find(Boolean)
45  return s.navLabel(list.length, newest)
46}
47
48/** `text`, padding, then ` action ›`: exactly `width` cells, text cut with … when it must be. */
49export function fitLine(text: string, action: string, width: number): string {
50  const tail = ` ${action} ›`
51  const room = width - cellWidth(tail) - 1
52  let body = text
53  if (cellWidth(body) > room) {
54    let out = ''
55    for (const char of body) {
56      if (cellWidth(out + char) > room - 1) break
57      out += char
58    }
59    body = `${out}…`
60  }
61  return body + ' '.repeat(Math.max(1, width - cellWidth(body) - cellWidth(tail))) + tail
62}
63
64export function rowLabel(n: number, text: string, width: number): string {
65  const base = label(n, text, width - 2)
66  return base + ' '.repeat(Math.max(1, width - 1 - cellWidth(base))) + '›'
67}
68
69// The engine moves transcript rows only in the fullscreen layout; on the main screen
70// the terminal's own scrollback holds them and it answers this deny.
71const NOT_FULLSCREEN = 'transcript not scrollable here'
72
73/** What to tell the person when a jump is refused, naming fullscreen only when that is why. */
74export function denyMessage(deny: string, s: import('./strings').Strings): string {
75  return deny === NOT_FULLSCREEN ? s.denyFullscreen : `${s.denyPrefix}${deny}`
76}
77
hooks/strings.ts 41 lines
1export type Strings = {
2  title: string
3  action: string
4  off: string
5  on: string
6  empty: string
7  notFullscreen: string
8  navLabel: (count: number, newest: string | undefined) => string
9  denyFullscreen: string
10  denyPrefix: string
11}
12
13export const STRINGS: Record<'en' | 'zh-TW', Strings> = {
14  en: {
15    title: 'Prompts',
16    action: 'Open',
17    off: 'Prompt navigator off (stays off in new sessions; /turn-nav turns it back on)',
18    on: 'Prompt navigator on',
19    empty: 'No prompts yet',
20    notFullscreen: 'Not fullscreen: the list works, jumping does not',
21    navLabel: (count, newest) => `🧭 Prompts · ${count}${newest ? ` · ${newest}` : ''}`,
22    denyFullscreen: "Can't jump: this session is not fullscreen (panes opened after enabling it are)",
23    denyPrefix: "Can't jump: ",
24  },
25  'zh-TW': {
26    title: '導覽',
27    action: '開啟',
28    off: '導覽已關閉(新 session 也不再開啟,/turn-nav 可再開)',
29    on: '導覽已開啟',
30    empty: '還沒有對話',
31    notFullscreen: '非全螢幕:只能看清單,不能跳轉',
32    navLabel: (count, newest) => `🧭 導覽 · ${count} 則${newest ? ` · ${newest}` : ''}`,
33    denyFullscreen: '跳不過去:這個 session 不是全螢幕(設定後新開的窗格才是)',
34    denyPrefix: '跳不過去:',
35  },
36}
37
38export function stringsFor(language: unknown): Strings {
39  return language === 'zh-TW' ? STRINGS['zh-TW'] : STRINGS.en
40}
41
types/index.d.ts 10 lines
1export type Turn = { id: string; text: string }
2/** One line above the prompt: its text, and the word for what pressing it does. */
3export type TurnNavChip = { label: string; action: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'turn-nav': { turns: Turn[]; paneTick: number; chip: TurnNavChip | null }
8  }
9}
10