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

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.
language: en (default) or zh-TW, from /config or settings:
"pluginConfigs": { "turn-nav@claude-code-mods": { "options": { "language": "zh-TW" } } }hooks/register.tsx 169 lines1import { 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}
169hooks/turns.ts 77 lines1export 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}
77hooks/strings.ts 41 lines1export 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}
41types/index.d.ts 10 lines1export 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