In-conversation bookmarks and vim-style marks for Claude Code: highlight a line, mark it, and jump back. Named bookmarks pane, pins, temporary in-place…

Vim-style marks, a reading position, and a numbered prompt history, inside a Claude Code conversation
A Claude Code plugin (a mod: it draws in Claude Code's terminal app) that lets you select a line of a conversation, mark it with a letter, and jump back to it later from anywhere in the conversation. The plugin's name is convo-bookmarks, because third-party plugin names (sadly) starting with the prefix claude- are verboten. This is not a claude.ai browser extension, but if you're looking for better web-based navigation AI Chat Nav might help.
A long Claude Code conversation is hard to move around in. The answer you need is three hundred messages up, the prompt that started this line of work is somewhere above that, and after you scroll up to check something, finding your way back down to where you were reading is a scroll-and-squint exercise. Claude Code can already jump to the top and the bottom, and its transcript view can search, but it has no way to say "remember this spot" and come back to it.
claude-bookmarks gives you named spots in the conversation: letters you set on any line, a reading position you can swap to and back from, and a numbered list of every prompt you have typed, so "go back to where I asked about the cache" is two keystrokes instead of a safari.
[!NOTE] Proof of concept (v0.1.x). The core loop works and is in active use on Claude Code 2.1.288 and 2.1.289 (Windows Terminal, fullscreen): marks with an in-place highlight, jumps, the reading position, and the prompts pane. The code is being rebuilt into a tested core before 1.0, and the key layout may still change. See the Roadmap and issue #1. Please file issues for anything rough.
Installing from the DazzleML plugin catalog is coming. Until then, load a local clone:
# 1. Clone the plugin
git clone https://github.com/DazzleML/claude-bookmarks.git
# 2. Load it. For one session:
claude --plugin-dir /path/to/claude-bookmarks
# ...or for every session, add it to ~/.claude/settings.json:
# "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-bookmarks" }
# 3. Bind the leader key in ~/.claude/keybindings.json (see "Set up the leader" below)
# 4. Restart Claude Code, and switch to the fullscreen renderer if you are not on it
/tui fullscreen
# 5. Check it loaded
/bm-env
When the plugin is loaded, a one-line band sits above the prompt: a small bm: field, then four buttons, mark jump prompts read.
The plugin's keys all start with one leader key: Claude Code's own "focus the band" action, abovePrompt:focus, which moves the keyboard to the band. Its default key is Ctrl+X Tab and works with no setup. I recommend one key instead, Ctrl+], which every terminal passes on. Add it to your ~/.claude/keybindings.json (back the file up first):
{
"bindings": [
{
"context": "Chat",
"bindings": {
"ctrl+]": "abovePrompt:focus"
}
}
]
}
That's the whole setup. Without it, use Ctrl+X Tab as the leader, or click the band's buttons.
[!TIP] New to vim-style keys? Start with the tutorial: it explains chords, leader keys and vim marks in plain terms, then walks you through each feature in about five minutes.
Ctrl+] m and a letter (a-z). With nothing freshly selected, the letter marks the message at the top of your screenCtrl+] ' and a letter scrolls back to that mark from anywhere in the conversation; the jump pane lists only the marks you haveCtrl+] Space Space to keep your place; press it again from anywhere to go there, and again to return to where you wereCtrl+] p lists every prompt of the conversation, numbered from your first, including prompts from before the plugin was loaded; type a number, or browse with j/k or the arrows, then Enter to jump--resumesh and grep elsewhere)| Keys | Band button | What it does |
|---|---|---|
Ctrl+] m then a-z | mark | Mark the selected line (or the top of the screen) with that letter; Enter cancels |
Ctrl+] ' (or j) then a-z | jump | Jump to that mark |
Ctrl+] ' then Enter | jump | Jump to the reading position (it heads the jump pane) |
Ctrl+] Space Space | read | Set, go to, or return from the reading position |
Ctrl+] p then a number, Enter | prompts | Jump to prompt #N |
Ctrl+] p then j/k or Down/Up, Enter | prompts | Browse the prompts one by one, then jump |
Ctrl+] p, pick a prompt, then s | prompts | Pin or unpin it (the band stays open to pin more) |
Esc | Leave the band and go back to typing |
After a command the keyboard stays on the band, ready for the next one; Esc returns to the input box. In the mark pane, letters already in use show as ●. Prompts drawn dimmed in the prompts pane are ones Claude Code hasn't drawn on screen, usually from before the last compaction, and a jump to one may be refused (see Tips).
Power users can add one-step keys that press a band button directly, such as Ctrl+X Space for the reading position; they borrow actions of Claude Code's diff panel, so they aren't part of the default setup. See Optional fast keys.
# Keep an answer you will need again
select a line of it -> Ctrl+] m a (mark a)
...later, anywhere -> Ctrl+] ' a (back to it)
# Check something above, then come back
select the line you are reading -> Ctrl+] Space Space (reading position set)
scroll up, read what you needed
Ctrl+] Space Space (back to the reading position)
Ctrl+] Space Space again (and back to where you were)
# Go back to where a line of work started
Ctrl+] p 12 Enter (prompt #12)
| Command | What it does |
|---|---|
/bm-mark, /bm-goto, /bm-prompts, /bm-read | The same as the leader keys, typed; from an empty input box their pane takes the keyboard |
/bm-delmarks a b, /bm-delmarks all | Delete marks |
/bm-pin [N] | Pin or unpin prompt #N in the prompts pane (*N in its # field does the same) |
/bm-env | Plugin version, session id, and what it has captured |
/bm-marks | The marks set in this conversation |
/bm-timeline | The last few plugin events (draws, panes, jumps) |
For each feature in detail, see docs/usage.md.
The known limits, briefly; docs/troubleshooting.md has the details.
Esc to go back to typing. After a command the keyboard stays on the band; a plugin can't hand it back to the input box itself.Enter once, while the plugin checks what that version allows.Ctrl+O, then (writes what Claude Code holds to your terminal's scrollback), then your terminal's Find (Ctrl+Shift+F or Cmd+F), and paste. Older messages may only be in the session file; opening it in full is planned ([#16). The companion patcher below removes this limit (see Extended history with the patcher)./fork, the session may be hosted by Claude Code's background daemon, which doesn't load CLAUDE_CODE_PLUGIN_DIRS. Stop it with claude stop <short id> and resume it from a shell with claude --resume <session id>.Ctrl+X fast keys and have disabled Claude Code's built-in diff mod, or have the diff panel open, the diff panel may take those keys.Marks matter most in long conversations, which is exactly where the compaction limit above bites. The companion project dazzle-claude-code-patcher patches a local copy of Claude Code so that a restarted or resumed session keeps its whole conversation in view, from before every compaction. On a patched build, marks and the prompts pane reach every message. It is also where I plan to prototype other fixes this plugin would like Claude Code to have, such as keyboard handoff between a plugin's band and its pane.
claude-bookmarks never depends on it: every feature works on stock Claude Code. The patcher is in development and not yet released. See Patched builds for how the two fit together.
Nothing to configure yet beyond the leader key. Settings are planned (#7), among them:
Enter (now) or jumps as soon as it's complete;Every key press, pane and jump is written to a per-conversation log, kept to its last 400 lines:
${CLAUDE_USER_DIR:-~/claude}/bookmarks/debug/<session id>.log
Read its last lines first when something behaves oddly; they make a good attachment to an issue.
| Platform | Status |
|---|---|
| Windows 11, Windows Terminal, fullscreen | Tested |
| macOS, fullscreen | Expected to work |
| Linux, fullscreen | Expected to work |
| Classic (non-fullscreen) renderer | Not supported |
Details, including tmux, IDE terminals and the Desktop app: docs/platform-support.md.
claude-bookmarks/
├── .claude-plugin/
│ └── plugin.json # Plugin manifest (name: convo-bookmarks)
├── hooks/
│ ├── hooks.json # Points Claude Code at the mod
│ └── register.tsx # The mod: the band, panes, highlight, back-fill
├── types/index.d.ts # Declared $.state values
├── docs/ # Tutorial, usage guide, troubleshooting, platform support
├── scripts/repokit-common/ # Shared repo tooling (git subtree from DazzleTools/git-repokit-common)
├── tests/
│ ├── checklists/ # Human test checklists
│ └── one-offs/ # Probes and measurements
├── .repokit-common.toml # Repo tooling settings (version sync into plugin.json)
└── version.py # Version source for the repo tooling
abovePrompt:focus) moves the keyboard to the band, where a small field takes the next key, any key, ' and Space included. The band then shows what it waits for and takes the rest of the keys itself, as button presses, while a pane beside the conversation shows the list. Claude Code lets a plugin scroll the conversation only from a button press, so every jump is one.Select-String on Windows, grep elsewhere), then keeps the list current as you type.Contributions welcome! Please open an issue or submit a pull request.
See CONTRIBUTING.md for:
claude --plugin-dir . reloads the mod live when you save)claude plugin validate . and python -m pytest tests/ -vsync-versions.pytests/checklists/Like the project?
claude-bookmarks, copyright (C) 2026 Dustin Darcy
This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.
hooks/register.tsx 1901 lines1// claude-bookmarks POC -- a throwaway probe, not the product.
2//
3// It answers five questions before anything real is built (the DWP's B1-B5):
4// B1 is a UserMessage row's requestId the transcript uuid session.append reports?
5// B2 can $.ui.scroll reveal a row that has not been drawn since the mod loaded?
6// B3 does a typed command count as "the person's own input" for scroll?
7// B4 does a chord bound to a borrowed engine action press a mod Button?
8// B5 does a press in a mod pane count as the person's input for scroll?
9//
10// Every probe reports with $.ui.log (a dim transcript row Claude never reads) or a
11// toast. No command answers with `text`, because that text becomes a row Claude reads.
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register, Timer } from 'claude-code'
15
16import type { PaneMode, Row } from '../types'
17import { describePathOrder } from './engine/select'
18
19const PANE = 'bm-poc'
20
21// Claude Code builds the borrowed-action chords (below) were verified on. The
22// start-up tripwire toasts on any other build, because a new build could give a
23// borrowed action a handler of its own. Add a build here after re-verifying.
24const VERIFIED_CLIENTS = ['2.1.288', '2.1.289', '2.1.290']
25
26// Engine keybinding actions borrowed for the chord test (B4). Neither should have a
27// handler mounted while the built-in diff mod is enabled; the version check in
28// session.start is how we notice when a new build might have changed that.
29const MARK_ACTION = 'app:toggleDiffNoiseFilter'
30const JUMP_ACTION = 'app:toggleDiffPreSession'
31// A third borrowed action (2026-10-04, user: "ctrl-x p" for the recent-prompts pane,
32// then 1-9): `ctrl+x p` in keybindings.json. The engine's own docs use it as their
33// example of a Button `action`; another diff-viewer toggle, idle in a conversation.
34const PROMPTS_ACTION = 'app:cycleDiffBase'
35// A fourth (2026-10-04, user: "Ctrl+x space" for the reading position). Scrolls the
36// diff panel's file list, so idle in a conversation like the other three. `ctrl+x x`,
37// djdarcy's first idea, is Claude Code's own chord for closing a pane.
38const READING_ACTION = 'app:diffFileListDown'
39// The reading position is kept as a mark under this key, so the highlight and the
40// band show it like any other; the panes list only a-z, so it never appears there.
41const READING = '`'
42
43const LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split('')
44
45const rows = atom({ plugin: 'convo-bookmarks', key: 'rows' } as const, [])
46const paneMode = atom({ plugin: 'convo-bookmarks', key: 'paneMode' } as const, 'list')
47const shown = atom({ plugin: 'convo-bookmarks', key: 'shown' } as const, null)
48const bandMode = atom({ plugin: 'convo-bookmarks', key: 'bandMode' } as const, 'idle')
49const pinsRev = atom({ plugin: 'convo-bookmarks', key: 'pinsRev' } as const, 0)
50// The band's command line (design 2026-10-07__02-58-22): bumped after each command so
51// the field is drawn under a new key and starts empty; and the prompt number's digits.
52const cmdRev = atom({ plugin: 'convo-bookmarks', key: 'cmdRev' } as const, 0)
53const bandNum = atom({ plugin: 'convo-bookmarks', key: 'bandNum' } as const, '')
54
55// The highlight and the band text are temporary (djdarcy, 2026-10-03: "visible temporarily
56// for maybe a minute or two or until the next action like another prompt is sent").
57// Only the mark just set or jumped to is shown; it clears after HIGHLIGHT_MS or on the
58// next prompt. Display only: nothing here touches what session.append stores.
59const HIGHLIGHT_MS = 120_000
60let clearTimer: Timer | undefined
61
62async function showMark($: EngineInterface, letter: string, text: string) {
63 await update($, shown, () => ({ letter, text }))
64 clearTimer?.cancel()
65 clearTimer = $.clock.after(HIGHLIGHT_MS, () => {
66 void clearShown($)
67 })
68 note(`show mark ${letter} for ${HIGHLIGHT_MS / 1000}s`)
69}
70
71async function clearShown($: EngineInterface) {
72 clearTimer?.cancel()
73 clearTimer = undefined
74 if (await read($, shown)) {
75 await update($, shown, () => null)
76 note('highlight cleared')
77 }
78}
79
80// The session's marks, kept in memory so render hooks can read them without a store
81// call per row. Loaded at session.start and refreshed on every mark write.
82let markCache: Record<string, Mark> = {}
83
84// The first line of a selection, which is what a row redraw searches for: a match
85// across lines would have to rewrite markdown structure, which this POC won't do.
86const snippetOf = (text: string) => (text.split('\n').find(l => l.trim()) ?? '').trim().slice(0, 120)
87
88// Round 4 colors (dark terminal; a light background would need a lighter LINE_BG).
89// LINE_BG: darker than the selection blue #264F78 so it reads as a soft band.
90const LINE_BG = '#1B3754'
91const WORDS_FG = 'yellow'
92// A letter already in use, in the mark pane's list: a muted grey-red, "this one is taken".
93const USED_FG = '#B07A7A'
94
95// Split a reply's markdown around the line holding `snippet`, for drawing that line
96// ourselves. Undefined (caller falls back to bold) when the split is risky: no match,
97// the line is inside a fenced code block, or the remainder is too long for Markdown.
98// A selection is copied from the RENDERED reply, so `**bold**`, `code` and [links](url)
99// have lost their markup; compare against each source line with the same markup removed.
100// (Matching the raw markdown missed any line with inline formatting, 2026-10-04.)
101const plainMarkdown = (s: string) =>
102 s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\*\*|__|`/g, '')
103
104function colorSplit(text: string, letter: string, snippet: string | undefined) {
105 if (!snippet) return undefined
106 const lines = text.split('\n')
107 const idx = lines.findIndex(l => plainMarkdown(l).includes(snippet))
108 if (idx < 0) return undefined
109 const fences = lines.slice(0, idx).filter(l => l.trimStart().startsWith('```')).length
110 if (fences % 2 === 1) return undefined
111 const before = lines.slice(0, idx).join('\n').trimEnd()
112 const after = lines.slice(idx + 1).join('\n').trim()
113 if (after.length > 9000) return undefined
114 // The blank line between paragraphs, which the trims above drop: kept so the marked
115 // line keeps its spacing (2026-10-04: marking a line joined it to the paragraph
116 // above). Each side's own markdown draws its inner spacing; only the seam is ours.
117 const gapAbove = idx > 0 && !lines[idx - 1]?.trim() && before.length > 0
118 const gapBelow = idx + 1 < lines.length && !lines[idx + 1]?.trim() && after.length > 0
119 // The line is drawn as plain Text, from its markup-free form.
120 const line = plainMarkdown(lines[idx] ?? '')
121 const at = line.indexOf(snippet)
122 return {
123 before,
124 pre: line.slice(0, at),
125 snippet,
126 post: line.slice(at + snippet.length),
127 after,
128 letter,
129 gapAbove,
130 gapBelow,
131 }
132}
133
134// The shown mark, if it is on this row: by real uuid, or a drawn id sharing its first
135// four groups. Only the one mark currently shown is ever highlighted.
136function marksOnRow(requestId: string, showing: string | undefined): [string, Mark][] {
137 if (!showing) return []
138 return Object.entries(markCache).filter(
139 ([letter, m]) =>
140 letter === showing &&
141 m.source === 'selection' &&
142 (m.uuid === requestId || firstFour(m.uuid) === firstFour(requestId)),
143 )
144}
145
146// Ids seen at UserMessage render time since this module loaded. A module variable on
147// purpose: a render hook may not write $.state, and a reload SHOULD forget these,
148// which is what makes the B2 test ("never drawn since load") possible.
149const rendered = new Map<string, string>()
150
151// Which messages are on screen now, as the render hooks last reported them (a
152// message's `onScreen`; null or absent drops it). Lets the reading position tell
153// "am I there already?" and note where the person was before a jump.
154// Each report is stamped: Claude Code reports a message when drawn and, on a scroll,
155// only the messages at the viewport's edges, so one that leaves the screen in a single
156// jump (Ctrl+End) is never reported gone and goes stale here (2026-10-04: "nowhere to
157// go" right after Ctrl+End). Only the latest burst of reports is trusted.
158const onScreenNow = new Map<string, { first: number; last: number; of: number; at: number }>()
159function noteOnScreen(requestId: string, onScreen: { first: number; last: number; of: number } | null | undefined) {
160 // A command run from a key (`command:bm-read`) is drawn for a moment under a
161 // temporary `placeholder…` id; as the newest report it became "where you were", and
162 // the way back was refused once it vanished (log, 2026-10-06 04:03:29). Only real
163 // message ids count.
164 if (!/^[0-9a-f]{8}-/.test(requestId)) return
165 if (!onScreen) return void onScreenNow.delete(requestId)
166 // A redraw that repeats the same lines is not news: clearing the reading position's
167 // highlight after a "back" jump redrew it, off screen, with its old lines, which made
168 // it look freshly on screen, so the next press "stayed put" at the bottom (djdarcy,
169 // 2026-10-07; log 08:15:48 -> 08:15:53). Only a change of lines refreshes the time.
170 const before = onScreenNow.get(requestId)
171 const same = before && before.first === onScreen.first && before.last === onScreen.last && before.of === onScreen.of
172 onScreenNow.set(requestId, { ...onScreen, at: same ? before.at : Date.now() })
173}
174const FRESH_MS = 1500
175function freshOnScreen(): string[] {
176 const newest = Math.max(0, ...[...onScreenNow.values()].map(v => v.at))
177 return [...onScreenNow.entries()].filter(([, v]) => v.at >= newest - FRESH_MS).map(([id]) => id)
178}
179
180// A timeline of what the mod saw and did, so a drawn-id mismatch can be lined up
181// against the pane, store and toast activity just before it. Module-level: a reload
182// starts it over, the same as `rendered`.
183const timeline: { t: number; what: string }[] = []
184function note(what: string) {
185 timeline.push({ t: Date.now(), what })
186 if (timeline.length > 400) timeline.shift()
187}
188// The [bm-poc] log lines, also written to <user dir>/bookmarks/debug/<session>.log:
189// $.ui.log rows are only drawn, so a session that reads files (Claude, while
190// developing this) cannot see them. Kept outside the plugin folder on purpose: a write
191// inside it trips the folder watch and reloads the mod on every line. $.fs has no
192// append, so the file is rewritten whole, last 400 lines, one write at a time.
193const LOG_LINES = 400
194const logLines: string[] = []
195let logPath: string | null | undefined // undefined: not looked up yet; null: no usable place
196let flushing: Promise<void> = Promise.resolve()
197
198function log($: EngineInterface, line: string) {
199 $.ui.log(line)
200 logLines.push(`${new Date().toISOString()} ${line}`)
201 if (logLines.length > LOG_LINES) logLines.splice(0, logLines.length - LOG_LINES)
202 flushing = flushing.then(() => flushLog($)).catch(() => {})
203}
204
205async function flushLog($: EngineInterface) {
206 if (logPath === undefined) {
207 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
208 const root = (await $.env.get('CLAUDE_USER_DIR')) ?? (home ? `${home}/claude` : undefined)
209 logPath = root ? `${root}/bookmarks/debug/${await $.session.id()}.log` : null
210 // A reload starts the module over: keep what the file already holds.
211 if (logPath && (await $.fs.exists(logPath))) {
212 const earlier = (await $.fs.read(logPath)).split('\n').filter(l => l.trim())
213 logLines.unshift(...earlier)
214 if (logLines.length > LOG_LINES) logLines.splice(0, logLines.length - LOG_LINES)
215 }
216 }
217 if (logPath) await $.fs.write(logPath, logLines.join('\n') + '\n')
218}
219
220const clock = (t: number) => new Date(t).toISOString().slice(11, 23)
221const firstFour = (uuid: string) => uuid.split('-').slice(0, 4).join('-')
222
223// `snippet`: the selection's first line exactly as selected, for highlighting.
224type Mark = { uuid: string; head: string; markedAt: number; source?: 'selection' | 'latest' | 'screen'; snippet?: string }
225type Marks = Record<string, Mark>
226
227const short = (uuid: string) => uuid.slice(0, 8)
228const head = (text: string) => text.replace(/\s+/g, ' ').trim().slice(0, 60)
229
230// This plugin's own version, from its manifest (the one Claude Code installs by).
231async function pluginVersion($: EngineInterface): Promise<string> {
232 try {
233 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))
234 return typeof manifest.version === 'string' ? manifest.version : '?'
235 } catch {
236 return '?'
237 }
238}
239
240async function allRows($: EngineInterface): Promise<Row[]> {
241 return (await read($, rows)) as Row[]
242}
243
244// The session's prompts in the order they were sent, #1 first. `rows` ($.state) starts
245// empty when the session restarts, so every prompt is also kept in $.store under the
246// session id (djdarcy, 2026-10-04: number prompts from the first message). Prompts sent
247// before the mod was loaded are not known: reading them needs the transcript (#6).
248type PromptRef = { uuid: string; head: string }
249const PROMPTS_KEPT = 5000
250async function promptsKey($: EngineInterface): Promise<string> {
251 return `prompts:${await $.session.id()}`
252}
253async function keptPrompts($: EngineInterface): Promise<PromptRef[]> {
254 return ((await $.store.get(await promptsKey($))) as PromptRef[] | undefined) ?? []
255}
256async function keepPrompt($: EngineInterface, p: PromptRef) {
257 const kept = await keptPrompts($)
258 if (kept.some(k => k.uuid === p.uuid)) return
259 await $.store.set(await promptsKey($), [...kept, p].slice(-PROMPTS_KEPT))
260}
261
262// --- Back-fill: the prompts sent before the mod was loaded ------------------------
263// The transcript holds them but is too big for $.fs.read (4 MiB; 18 MB here). The
264// platform's own tool filters it to the user rows (no install: Windows PowerShell 5.1
265// on Windows, sh + grep elsewhere) and the mod parses the JSON lines. Settled by the
266// POC in tests/one-offs/thinking/prompt-history/ (2026-10-04: 152/152 prompts,
267// identical text; ~1 MB of output; 328 ms PowerShell, 81 ms sh + grep). Runs once per
268// conversation; after that the live capture keeps the list current.
269const USER_ROW = '"type":"user"'
270const TOOL_RESULT_ROW = '"type":"tool_result"'
271
272// A typed user prompt's text, or undefined for tool results, meta and sidechain rows.
273// The same rule as the POC's reference parse.
274function promptTextOf(o: any): string | undefined {
275 if (o?.type !== 'user' || o.isMeta || o.isSidechain) return undefined
276 const content = o.message?.content
277 if (typeof content === 'string') return content
278 if (!Array.isArray(content)) return undefined
279 if (content.some((b: any) => b?.type === 'tool_result')) return undefined
280 const texts = content.filter((b: any) => b?.type === 'text').map((b: any) => String(b.text ?? ''))
281 return texts.length > 0 ? texts.join(' ') : undefined
282}
283
284// PowerShell's -EncodedCommand takes UTF-16LE in base64; written out here rather
285// than assuming btoa exists in the mod's environment. Passing the script this way
286// also keeps its double quotes intact: as a plain argv entry, quotes reaching a
287// Windows program can be stripped (the POC's probe C').
288function base64Utf16le(text: string): string {
289 const bytes: number[] = []
290 for (let i = 0; i < text.length; i++) {
291 const c = text.charCodeAt(i)
292 bytes.push(c & 0xff, c >> 8)
293 }
294 const abc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
295 let out = ''
296 for (let i = 0; i < bytes.length; i += 3) {
297 const [a, b, c] = [bytes[i]!, bytes[i + 1], bytes[i + 2]]
298 out += abc[a >> 2]! + abc[((a & 3) << 4) | ((b ?? 0) >> 4)]!
299 out += b === undefined ? '=' : abc[((b & 15) << 2) | ((c ?? 0) >> 6)]!
300 out += c === undefined ? '=' : abc[c & 63]!
301 }
302 return out
303}
304
305// The session's transcript: <config dir>/projects/<project>/<session id>.jsonl. The
306// project folder is the cwd with every non-alphanumeric character turned into `-`;
307// if that guess misses, every project folder is looked in.
308async function transcriptPath($: EngineInterface): Promise<string | undefined> {
309 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
310 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
311 if (!config) return undefined
312 const file = `${await $.session.id()}.jsonl`
313 const guess = `${config}/projects/${(await $.session.cwd()).replace(/[^A-Za-z0-9]/g, '-')}/${file}`
314 if (await $.fs.exists(guess)) return guess
315 for (const entry of await $.fs.list(`${config}/projects`)) {
316 const candidate = `${config}/projects/${entry.name}/${file}`
317 if (entry.kind === 'dir' && (await $.fs.exists(candidate))) return candidate
318 }
319 return undefined
320}
321
322// The user rows of the transcript, one JSON line each, from the platform's own tool.
323async function userRows($: EngineInterface, path: string) {
324 const windows = /^[A-Za-z]:[\\/]/.test(path)
325 const powershell = () => {
326 const literal = path.replace(/'/g, "''")
327 const script =
328 '[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); ' +
329 `Select-String -LiteralPath '${literal}' -SimpleMatch -Pattern '${USER_ROW}' -Encoding UTF8 | ` +
330 `Where-Object { -not $_.Line.Contains('${TOOL_RESULT_ROW}') } | ForEach-Object { $_.Line }`
331 return ['powershell', '-NoProfile', '-NonInteractive', '-EncodedCommand', base64Utf16le(script)]
332 }
333 const sh = () => ['sh', '-c', `grep -F '${USER_ROW}' "$1" | grep -vF '${TOOL_RESULT_ROW}'`, 'sh', path]
334 for (const [via, argv] of windows ? [['powershell', powershell()], ['sh', sh()]] as const : [['sh', sh()]] as const) {
335 try {
336 const started = Date.now()
337 const r = await $.process.run(argv, { timeoutMs: 60_000 })
338 if (r.exitCode === 0 || r.stdout) return { via, ms: Date.now() - started, ...r }
339 } catch {
340 // That tool is not there: try the next one.
341 }
342 }
343 return undefined
344}
345
346async function backfillPrompts($: EngineInterface) {
347 const doneKey = `backfilled:${await $.session.id()}`
348 if (await $.store.get(doneKey)) return
349 const path = await transcriptPath($)
350 if (!path) {
351 log($, '[bm-poc] backfill: transcript not found; prompts list starts when the mod loaded')
352 return
353 }
354 const rows = await userRows($, path)
355 if (!rows) {
356 log($, '[bm-poc] backfill: neither PowerShell nor sh + grep ran; prompts list starts when the mod loaded')
357 return
358 }
359 const found: PromptRef[] = []
360 for (const line of rows.stdout.split('\n')) {
361 if (!line.trim()) continue
362 let o: any
363 try {
364 o = JSON.parse(line)
365 } catch {
366 continue // a line cut off at the 4 MiB output limit
367 }
368 const text = promptTextOf(o)
369 if (text !== undefined && typeof o.uuid === 'string') found.push({ uuid: o.uuid, head: head(text) })
370 }
371 // Transcript order first, then anything the live capture holds that the file did not.
372 const have = new Set(found.map(p => p.uuid))
373 const merged = [...found, ...(await keptPrompts($)).filter(p => !have.has(p.uuid))]
374 await $.store.set(await promptsKey($), merged.slice(-PROMPTS_KEPT))
375 // A cut-off read is left unmarked, so a later session start tries again.
376 if (!rows.isStdoutTruncated) await $.store.set(doneKey, true)
377 log(
378 $,
379 `[bm-poc] backfill: ${found.length} prompts from the transcript via ${rows.via} in ${rows.ms} ms ` +
380 `(${rows.stdout.length} chars${rows.isStdoutTruncated ? ', CUT OFF at 4 MiB: newest may be missing' : ''}); ` +
381 `list now ${Math.min(merged.length, PROMPTS_KEPT)}`,
382 )
383}
384
385async function prompts($: EngineInterface): Promise<PromptRef[]> {
386 const kept = await keptPrompts($)
387 const seen = new Set(kept.map(k => k.uuid))
388 const fresh = (await allRows($)).filter(r => r.door === 'prompt' && !seen.has(r.uuid))
389 return [...kept, ...fresh.map(r => ({ uuid: r.uuid, head: r.head }))]
390}
391
392async function marksKey($: EngineInterface): Promise<string> {
393 return `marks:${await $.session.id()}`
394}
395
396async function loadMarks($: EngineInterface): Promise<Marks> {
397 return ((await $.store.get(await marksKey($))) as Marks | undefined) ?? {}
398}
399
400// A short, distinctive piece of a message to search for in the Ctrl+O transcript view:
401// the text after any generic opening (`RE:{`, a `<pasted_content …>` tag, braces), cut
402// at a word boundary to about 32 characters.
403function searchPhrase(text: string | undefined): string | undefined {
404 if (!text) return undefined
405 const cleaned = text
406 .replace(/<\/?pasted_content[^>]*>/g, ' ')
407 .replace(/^\s*(RE:\s*)?\{?\s*/i, '')
408 .replace(/\s+/g, ' ')
409 .trim()
410 if (!cleaned) return undefined
411 if (cleaned.length <= 32) return cleaned
412 const cut = cleaned.slice(0, 32)
413 const space = cut.lastIndexOf(' ')
414 return space > 16 ? cut.slice(0, space) : cut
415}
416
417// B2/B3/B5 share this: scroll, then report exactly what the engine answered. Returns the
418// refusal, if any. `probe`: a try whose "not person-initiated" refusal the caller
419// handles (the band command line's one-time check), so no toast for that one.
420async function jumpTo($: EngineInterface, uuid: string, via: string, label: string, words?: string, probe = false, block: 'start' | 'end' = 'start'): Promise<string | undefined> {
421 const result = await $.ui.scroll({ to: { requestId: uuid }, block })
422 // The jump is where the person wants to be: no going back to before the pane.
423 if (!result.deny) paneAnchor = undefined
424 const verdict = result.deny ? `DENY: ${result.deny}` : 'ok'
425 if (probe && result.deny && /person-initiated/i.test(result.deny)) {
426 log($, `[bm-poc] ${via} scroll -> ${verdict} | target ${label} ${short(uuid)} (probe)`)
427 return result.deny
428 }
429 // A message Claude Code has not drawn cannot be scrolled to ("nothing drawn under
430 // that requestId": prompts from before this conversation's compaction, 2026-10-05).
431 // The view stays put, which looked like "it took me somewhere else"; say so, and
432 // offer the planned fallback (issue #4): search the transcript view.
433 // Any other refusal ("not person-initiated", ...) is not about the message: say what
434 // Claude Code said, not "older than a compaction" (log, 2026-10-07 06:26:12).
435 if (result.deny && !/nothing drawn/i.test(result.deny)) {
436 $.ui.toast(`Can't jump there: Claude Code refused the scroll (${result.deny}).`)
437 log($, `[bm-poc] jump refused for another reason: ${result.deny}`)
438 } else if (result.deny) {
439 const text = words ?? rendered.get(uuid) ?? Object.values(markCache).find(m => m.uuid === uuid)?.head
440 // The mod cannot open Ctrl+O or fill a search (nothing in $.ui drives the
441 // transcript view), but it can put a search phrase on the clipboard (djdarcy,
442 // 2026-10-05: "give the user the text ... so it autopopulates"). The phrase skips
443 // the generic openings many prompts share ("RE:{", a pasted-content tag).
444 const phrase = searchPhrase(text)
445 const copied = phrase ? (await $.ui.copy({ text: phrase })).isCopied : false
446 // Where to search: transcript mode's `/` only sees what the view holds, which after
447 // a compaction excludes earlier messages (2026-10-05: not found). `[` in transcript
448 // mode writes the FULL conversation to the terminal's scrollback, where the
449 // terminal's own find does reach them (user-verified, 2026-10-05).
450 const how = `press Ctrl+O, then [ (full conversation), then your terminal's Find (Ctrl+Shift+F or Cmd+F)`
451 $.ui.toast(
452 copied
453 ? `Can't jump there: that message isn't on screen (older than a compaction?). Copied "${phrase}": ${how} and paste.`
454 : `Can't jump there: that message isn't on screen (older than a compaction?). ${how} and search for ` +
455 `${phrase ? `"${phrase}"` : 'a few words of it'}.`,
456 { timeoutMs: 12000 },
457 )
458 log($, `[bm-poc] jump refused; search phrase ${copied ? 'copied' : 'shown'}: "${phrase ?? ''}"`)
459 }
460 note(`scroll ${uuid} via ${via} -> ${verdict}`)
461 log($,
462 `[bm-poc] ${via} scroll -> ${verdict} | target ${label} ${short(uuid)} | ` +
463 `drawn since load: ${rendered.has(uuid) ? 'yes' : 'NO'}`,
464 )
465 return result.deny
466}
467
468// The selection as it stood when the mark chord fired, read BEFORE the pane opens:
469// round 1 read it after, and the mark landed one row too high (the inline pane shifts
470// the transcript; hypothesis: the engine maps the selection to a row by screen position).
471let selectionAtChord: Awaited<ReturnType<EngineInterface['ui']['selection']>> | undefined
472
473// A drawn row id may be the zero-tailed form of the real uuid. Find the saved row it
474// stands for, and say whether the selected text is really in that row.
475async function resolveSelectionRow($: EngineInterface, id: string, text: string) {
476 const list = await allRows($)
477 const saved = list.find(r => r.uuid === id) ?? list.find(r => firstFour(r.uuid) === firstFour(id))
478 const needle = text.trim().slice(0, 40)
479 const check = !saved ? 'no saved row'
480 : saved.text === undefined ? 'row saved before text capture'
481 : saved.text.includes(needle) ? 'text verified' : 'TEXT NOT IN ROW'
482 return { uuid: saved?.uuid ?? id, check }
483}
484
485// Highlight a mark just set from a selection, then put the view back. Redrawing the
486// message under a visible selection drops the view to the bottom, even with following
487// the bottom turned off (djdarcy, 2026-10-06; a stale selection, no longer shown, did
488// not; seen only when the pane held the keyboard). So note the message at the top of
489// the screen first, and scroll back to it once the pane has closed: message-level, like
490// the reading position's return. The scroll must run within the keypress's own handler:
491// one from a timer is refused ("not person-initiated", log 2026-10-06 04:22:20).
492let restoreTo: string | undefined
493// The message at the top of the screen just before a pane opened (openFor): where the
494// view goes back to once the pane has done its job. A jump clears it (jumpTo).
495let paneAnchor: string | undefined
496// The same moment as a scroll can restore it: at the bottom, the last message's end.
497let paneView: { uuid: string; block: 'start' | 'end' } | undefined
498async function highlightInPlace($: EngineInterface, letter: string, text: string) {
499 restoreTo = paneAnchor ?? (await topOnScreen($))
500 await showMark($, letter, text)
501}
502// The shift is Claude Code's, on every route: the old Ctrl+X m chord moved the view by
503// about a screen as well (djdarcy, 2026-10-07, with this scroll-back switched off).
504async function restoreView($: EngineInterface) {
505 // A pane opened and closed: the view was shifted for certain, so go back to where it
506 // was before the pane, whether or not that message is still on screen somewhere.
507 const fromPane = !!paneAnchor
508 const block = (fromPane && paneView?.block) || 'start'
509 const here = (fromPane ? paneView?.uuid ?? paneAnchor : undefined) ?? restoreTo
510 restoreTo = undefined
511 paneAnchor = undefined
512 paneView = undefined
513 if (!here) return
514 // Otherwise only when the view actually moved: scrolling back regardless lined up the
515 // top message's first line and moved a view that had not dropped (djdarcy, 2026-10-07).
516 if (!fromPane && freshOnScreen().some(id => id === here || firstFour(id) === firstFour(here))) {
517 log($, `[bm-poc] view kept after highlight (${short(here)} still on screen)`)
518 return
519 }
520 const r = await $.ui.scroll({ to: { requestId: here }, block })
521 log($, `[bm-poc] view restored after highlight -> ${r.deny ? `DENY: ${r.deny}` : 'ok'} | ${short(here)}`)
522}
523
524// --- Fresh and stale selections ---------------------------------------------------------
525// $.ui.selection() answers what the person LAST selected, even once its highlight is gone
526// (the types' docs), so a mark with nothing selected used a quote selected minutes
527// earlier (2026-10-07 07:13:10, 07:20:21). djdarcy: "if a user selects text we should be
528// attempting to date-time it and if it's older than 1.25 minutes then we should mark the
529// current top-left portion of the screen". No event says when a selection changes, so it
530// is polled once a second and stamped when it does. Becomes a setting (#7).
531const SELECTION_FRESH_SECONDS = 75
532type Selection = Awaited<ReturnType<EngineInterface['ui']['selection']>>
533let selectionSeen: { key: string; at: number } | undefined
534let selectionPoll: Timer | undefined
535const selectionKey = (s: Selection) => (s?.requestId && s.text.trim() ? `${s.requestId}|${s.text}` : '')
536
537async function pollSelection($: EngineInterface) {
538 const key = selectionKey(await $.ui.selection())
539 // The first look has no history: whatever is selected then counts as old.
540 if (!selectionSeen) selectionSeen = { key, at: 0 }
541 else if (key !== selectionSeen.key) selectionSeen = { key, at: Date.now() }
542}
543
544// The selection if it was made within SELECTION_FRESH_SECONDS, else undefined.
545async function freshSelection($: EngineInterface, sel: Selection): Promise<Selection> {
546 const key = selectionKey(sel)
547 if (!key) return undefined
548 // Changed since the last poll: made within the last second.
549 if (selectionSeen && key !== selectionSeen.key) selectionSeen = { key, at: Date.now() }
550 const at = selectionSeen?.key === key ? selectionSeen.at : 0
551 const age = at ? Math.round((Date.now() - at) / 1000) : undefined
552 const fresh = age !== undefined && age <= SELECTION_FRESH_SECONDS
553 log($, `[bm-poc] selection "${sel!.text.trim().slice(0, 24)}" is ${age === undefined ? 'of unknown age' : `${age}s old`}: ${fresh ? 'used' : 'ignored (stale)'}`)
554 return fresh ? sel : undefined
555}
556
557// The message at the top of the screen, as a mark: the fallback when no fresh selection
558// says where (djdarcy: "the current 'screen' they are looking at from the top left").
559// Message-level, like the reading position; its first line is the one highlighted.
560async function screenTopMark($: EngineInterface, before?: string): Promise<Mark | undefined> {
561 const id = before ?? (await topOnScreen($))
562 if (!id) return undefined
563 const list = await allRows($)
564 const row = list.find(r => r.uuid === id) ?? list.find(r => firstFour(r.uuid) === firstFour(id))
565 const text = row?.text ?? rendered.get(id) ?? row?.head ?? ''
566 const first = snippetOf(plainMarkdown(text))
567 return {
568 uuid: row?.uuid ?? id,
569 head: head(text) || '(message at the top of the screen)',
570 markedAt: await $.clock.now(),
571 source: 'screen',
572 ...(first ? { snippet: first } : {}),
573 }
574}
575
576async function setMark($: EngineInterface, letter: string) {
577 // A fresh mouse selection says where; otherwise the top of the screen.
578 const sel = await freshSelection($, selectionAtChord ?? (await $.ui.selection()))
579 selectionAtChord = undefined
580 note(`selection at mark: ${sel ? `row ${sel.requestId ?? '(none)'} text ${JSON.stringify(sel.text.slice(0, 80))}` : 'none'}`)
581 if (sel?.requestId) {
582 const { uuid, check } = await resolveSelectionRow($, sel.requestId, sel.text)
583 const fresh = await loadMarks($)
584 const mark: Mark = {
585 uuid,
586 head: head(sel.text),
587 markedAt: await $.clock.now(),
588 source: 'selection',
589 snippet: snippetOf(sel.text),
590 }
591 note(`store.set mark ${letter} -> ${uuid} (selection, drawn ${sel.requestId}, ${check})`)
592 await $.store.set(await marksKey($), { ...fresh, [letter]: mark })
593 markCache = { ...fresh, [letter]: mark }
594 // Writing `shown` redraws the rows and the band that read it.
595 await highlightInPlace($, letter, snippetOf(sel.text))
596 log($, `[bm-poc] SEL: mark ${letter} -> ${uuid} (drawn as ${sel.requestId}; ${check}) | "${head(sel.text)}"`)
597 $.ui.toast(`mark ${letter} [selection] -> ${head(sel.text).slice(0, 40)}`)
598 return
599 }
600
601 // No fresh selection: the message at the top of the screen, as it was BEFORE the pane
602 // opened and shifted it (paneAnchor). (The "latest prompt" fallback is gone: prompts
603 // have the prompts pane and pins. djdarcy, 2026-10-07.)
604 const mark = await screenTopMark($, paneAnchor)
605 if (!mark) {
606 $.ui.toast('Nothing on screen to mark yet: scroll a little, or select some text')
607 return
608 }
609 // Read again right before the write: the store is shared and not atomic.
610 const fresh = await loadMarks($)
611 note(`store.set mark ${letter} -> ${mark.uuid} (top of screen)`)
612 await $.store.set(await marksKey($), { ...fresh, [letter]: mark })
613 markCache = { ...fresh, [letter]: mark }
614 if (mark.snippet) await highlightInPlace($, letter, mark.snippet)
615 log($, `[bm-poc] mark ${letter} -> ${short(mark.uuid)} (top of screen) | "${mark.head}"`)
616 $.ui.toast(`mark ${letter} [top of screen] -> ${mark.head.slice(0, 40)}`)
617}
618
619// --- Reading position: Ctrl+X Space ------------------------------------------------
620// One key, no letter (djdarcy, 2026-10-04): with text selected it sets the reading
621// position there; with nothing selected (or the same selection still up) it jumps to
622// it, and pressed again while it is on screen it swaps back to where the person was,
623// like vim's ``. "On screen" comes from what the render hooks report, not from the
624// last press, so scrolling by hand in between does not confuse it.
625// The toggle's state, kept explicitly rather than read off the screen (whose reports go
626// stale after a jump, see onScreenNow): `at` true right after going to the reading
627// position, so the next press goes `back`; false otherwise, so the next press goes there.
628// `backBlock`: which edge of the window `back` was anchored to (viewAnchor).
629type ReadingState = { at: boolean; back: string | null; backBlock?: 'start' | 'end' }
630async function readingStateKey($: EngineInterface): Promise<string> {
631 return `readingState:${await $.session.id()}`
632}
633async function readingState($: EngineInterface): Promise<ReadingState> {
634 return ((await $.store.get(await readingStateKey($))) as ReadingState | undefined) ?? { at: false, back: null }
635}
636async function setReadingState($: EngineInterface, state: ReadingState) {
637 await $.store.set(await readingStateKey($), state)
638}
639
640// The message at the top of the view: of those on screen, the earliest in the
641// conversation. Order is known for every prompt (back-filled) and for the replies
642// captured since load (each placed after the prompt before it).
643async function topOnScreen($: EngineInterface): Promise<string | undefined> {
644 const order = new Map<string, number>()
645 const promptList = await prompts($)
646 promptList.forEach((p, i) => order.set(p.uuid, i * 1000))
647 let base = 0
648 let k = 0
649 for (const r of await allRows($)) {
650 if (r.door === 'prompt') {
651 base = order.get(r.uuid) ?? base
652 k = 0
653 } else order.set(r.uuid, base + ++k)
654 }
655 // Only the latest burst of reports: what is on screen now, not what was before a jump.
656 const visible = freshOnScreen()
657 const known = visible.filter(id => order.has(id)).sort((a, b) => order.get(a)! - order.get(b)!)
658 // Not yet captured (a reply still arriving) sorts last: it is the newest.
659 lastScreenOrder = [...known, ...visible.filter(id => !order.has(id))]
660 return known[0] ?? visible[0]
661}
662// The messages on screen, top to bottom, as topOnScreen last ordered them.
663let lastScreenOrder: string[] = []
664
665// Where "back" goes when you left from the bottom of the conversation (a setting, #7):
666// 'where-it-was': the same text you were reading, even if new replies arrived since
667// (djdarcy's choice: "I'd rather have it always go to the exact same
668// location where the screen was so I'm reading exactly what I was
669// reading before");
670// 'newest': the bottom as it is now, Ctrl+End, new replies included.
671const READING_BACK_FROM_BOTTOM: 'where-it-was' | 'newest' = 'where-it-was'
672
673// Where the view is, as a scroll can put it back: the bottom message's end when its last
674// line shows (at the bottom of the conversation, that is exactly Ctrl+End), else the top
675// message's start. "Back" from the reading position went to the top message's start
676// even from the very bottom, a little above where the person was (djdarcy, 2026-10-07).
677async function viewAnchor($: EngineInterface): Promise<{ uuid: string; block: 'start' | 'end' } | undefined> {
678 const top = await topOnScreen($)
679 if (!top) return undefined
680 const bottom = lastScreenOrder.at(-1)
681 const b = bottom ? onScreenNow.get(bottom) : undefined
682 if (bottom && b && b.last >= b.of - 1) return { uuid: bottom, block: 'end' }
683 return { uuid: top, block: 'start' }
684}
685
686// Go to the reading position, noting where we were so the next Ctrl+X Space swaps back.
687// Shared by the chord and the jump pane's `␣ reading` entry.
688async function goToReading($: EngineInterface, reading: Mark, via: string) {
689 const here = await topOnScreen($)
690 const anchor = await viewAnchor($)
691 // Already at the reading position (the jump pane's entry used while there, log
692 // 2026-10-05 00:06:17): keep the earlier spot to return to, or "back" would go to
693 // the reading position itself.
694 const atItAlready = !!here && (here === reading.uuid || firstFour(here) === firstFour(reading.uuid))
695 const previous = await readingState($)
696 await setReadingState(
697 $,
698 atItAlready
699 ? { at: true, back: previous.back, ...(previous.backBlock ? { backBlock: previous.backBlock } : {}) }
700 : { at: true, back: anchor?.uuid ?? null, ...(anchor ? { backBlock: anchor.block } : {}) },
701 )
702 if (anchor) log($, `[bm-poc] reading position: back will be ${short(anchor.uuid)} at the window's ${anchor.block}`)
703 await jumpTo($, reading.uuid, via, `from ${here ? short(here) : '(unknown)'}`)
704 await showMark($, READING, reading.snippet ?? reading.head)
705}
706
707async function readingToggle($: EngineInterface) {
708 // Only a fresh selection moves the reading position (a stale one did, 2026-10-07 07:20:21).
709 const sel = await freshSelection($, await $.ui.selection())
710 const marks = await loadMarks($)
711 const reading = marks[READING]
712 const picked = sel?.requestId && sel.text.trim() ? snippetOf(sel.text) : undefined
713
714 // A selection in a different message: set the reading position there. One inside the
715 // message that already holds it counts as "go there", not "move": terminal
716 // selections appear and linger easily (a click, a drag), and twice a press meant as a
717 // jump re-set it instead, to a stray mid-word selection in the same message (log,
718 // 2026-10-04 23:53:58 and 23:55:09).
719 const sameMessage =
720 !!reading && !!sel?.requestId && (sel.requestId === reading.uuid || firstFour(sel.requestId) === firstFour(reading.uuid))
721 if (sel?.requestId && picked && !sameMessage) {
722 const { uuid, check } = await resolveSelectionRow($, sel.requestId, sel.text)
723 const mark: Mark = { uuid, head: head(sel.text), markedAt: await $.clock.now(), source: 'selection', snippet: picked }
724 await $.store.set(await marksKey($), { ...marks, [READING]: mark })
725 markCache = { ...marks, [READING]: mark }
726 // Next press goes there (from wherever the person has scrolled to by then).
727 await setReadingState($, { at: false, back: null })
728 await highlightInPlace($, READING, picked)
729 await restoreView($)
730 log($, `[bm-poc] reading position set -> ${short(uuid)} (${check}) | "${head(sel.text)}"`)
731 $.ui.toast(`reading position set: ${head(sel.text).slice(0, 40)}`)
732 return
733 }
734
735 if (!reading) {
736 $.ui.toast('Select some text, then Ctrl+X Space, to set a reading position')
737 return
738 }
739
740 const state = await readingState($)
741 // Go back only while the reading position is actually on screen, judged by the
742 // latest burst of on-screen reports. The there/back state alone was wrong once the
743 // person scrolled elsewhere by hand: the next press "went back" to the old spot
744 // instead of to the mark (djdarcy, 2026-10-04). Old reports alone were wrong after
745 // Ctrl+End (the message left behind still looked visible); the freshness filter
746 // handles that.
747 const onScreenNowFresh = freshOnScreen().some(
748 id => id === reading.uuid || firstFour(id) === firstFour(reading.uuid),
749 )
750 if (state.at && state.back && onScreenNowFresh) {
751 await setReadingState($, { at: false, back: null })
752 // Left from the bottom, and the setting says "the bottom as it is now": the newest
753 // captured message's end instead of the one that was last then.
754 const newest = state.backBlock === 'end' && READING_BACK_FROM_BOTTOM === 'newest' ? (await allRows($)).at(-1)?.uuid : undefined
755 const target = newest ?? state.back
756 await jumpTo($, target, 'reading position (back)', `to ${short(target)} (${state.backBlock ?? 'start'}${newest ? ', newest' : ''})`, undefined, false, state.backBlock ?? 'start')
757 await clearShown($)
758 return
759 }
760 // Already looking at it, with nowhere real to go back to: stay put. Jumping here
761 // recorded the message just above the mark as "where you were", so the next press
762 // swapped between two spots a few lines apart; and pressing at the mark re-jumped
763 // to it over and over (log, 2026-10-05 00:39-00:42 UTC).
764 if (onScreenNowFresh) {
765 await showMark($, READING, reading.snippet ?? reading.head)
766 $.ui.toast("You're at the reading position. Scroll away and press again to come back here.")
767 const seen = [...onScreenNow.entries()].find(([id]) => id === reading.uuid || firstFour(id) === firstFour(reading.uuid))?.[1]
768 log($, `[bm-poc] reading position: already on screen, stayed put` +
769 (seen ? ` (reported lines ${seen.first}-${seen.last} of ${seen.of}, ${Date.now() - seen.at} ms ago)` : ''))
770 return
771 }
772 // Otherwise: note where we are, then go there.
773 await goToReading($, reading, 'reading position')
774}
775
776// Returns the scroll's refusal, if any ('unset' when there is no such mark).
777async function jumpToMark($: EngineInterface, letter: string, probe = false): Promise<string | undefined> {
778 const mark = (await loadMarks($))[letter]
779 if (!mark) {
780 $.ui.toast(`mark ${letter} is not set`)
781 return 'unset'
782 }
783 const deny = await jumpTo($, mark.uuid, 'B4+B5 chord jump', `mark ${letter}`, undefined, probe)
784 if (!deny) await showMark($, letter, mark.snippet ?? mark.head)
785 return deny
786}
787
788// --- The band's command line -----------------------------------------------------------
789// Design 2026-10-07__02-58-22 (and its POC addendum). The band leader (abovePrompt:focus,
790// Ctrl+] for djdarcy) puts the keyboard on the band, whose first element is a field
791// that takes every printable key, ' and Space included, over a draft and mid-turn.
792// Stock Claude Code refuses a scroll from a field's typing or Enter ("not
793// person-initiated", 2.1.290), so the field takes the FIRST key and hands the rest to
794// band Buttons, whose presses may scroll. A build that allows it (a patched or future
795// one) is found by trying once: the answer is kept per Claude Code version.
796type DirectScroll = 'unknown' | 'ok' | 'deny'
797let directScroll: DirectScroll | undefined
798async function directScrollKey($: EngineInterface): Promise<string> {
799 const v = await $.session.version()
800 return `directScroll:${v.version}`
801}
802async function directScrollState($: EngineInterface): Promise<DirectScroll> {
803 if (!directScroll) directScroll = ((await $.store.get(await directScrollKey($))) as DirectScroll | undefined) ?? 'unknown'
804 return directScroll
805}
806async function setDirectScroll($: EngineInterface, value: DirectScroll) {
807 directScroll = value
808 await $.store.set(await directScrollKey($), value)
809 log($, `[bm-poc] band command line: a scroll from the field is ${value === 'ok' ? 'ALLOWED (one-step keys)' : 'refused (keys hand off to band buttons)'} on this Claude Code`)
810}
811
812const CMD_HINT = "bm: ' or j + letter (jump) m + letter (mark) Space or r (reading) p + number (prompt)"
813let cmdBusy = false
814
815// Draw the field anew, empty.
816async function clearCommandLine($: EngineInterface) {
817 await update($, cmdRev, v => v + 1)
818}
819
820// The band's key mode for `mode`, with the ring on `focusKey` when given (Enter presses
821// it). openFor opens the pane as the list; from the band its focus is refused, so the
822// band takes the key (see openFor).
823async function handOff($: EngineInterface, mode: PaneMode, focusKey?: string) {
824 await openFor($, mode, 'band command line')
825 if (focusKey) await focusBand($, focusKey)
826}
827
828async function runCommandLine($: EngineInterface, typed: string, via: 'input' | 'submit') {
829 if (cmdBusy || typed === '') return
830 cmdBusy = true
831 try {
832 const first = typed[0]!
833 const state = await directScrollState($)
834 log($, `[bm-poc] band command line ${via}: "${typed}" (direct scroll ${state})`)
835
836 if (first === "'" || first === 'j') {
837 // Hand off at once where a field can't scroll; otherwise wait for the letter.
838 if (typed.length === 1) {
839 if (state === 'deny') {
840 await clearCommandLine($)
841 await handOff($, 'jump')
842 }
843 return
844 }
845 const letter = typed[1]!
846 await clearCommandLine($)
847 if (!/^[a-z]$/.test(letter)) return void $.ui.toast(CMD_HINT)
848 const deny = await jumpToMark($, letter, state === 'unknown')
849 if (deny && /person-initiated/i.test(deny)) {
850 await setDirectScroll($, 'deny')
851 // This once, the letter is already typed: put the ring on its Button, Enter jumps.
852 await handOff($, 'jump', `band-jump-${letter}`)
853 $.ui.toast(`Press Enter to jump to ${letter}. (From now on, the band takes the letter after ' itself.)`)
854 } else if (!deny && state === 'unknown') {
855 await setDirectScroll($, 'ok')
856 }
857 return
858 }
859
860 if (first === ' ' || first === 'r') {
861 await clearCommandLine($)
862 if (state === 'ok') return void (await readingToggle($))
863 // Enter presses the reading entry: there, or back (readingToggle).
864 await handOff($, 'jump', 'band-jump-reading')
865 return
866 }
867
868 if (first === 'm') {
869 // A mark scrolls back after its highlight (restoreView), so it is pressed too.
870 await clearCommandLine($)
871 await handOff($, 'mark', 'band-mark-cancel')
872 return
873 }
874
875 if (first === 'p') {
876 await clearCommandLine($)
877 await update($, bandNum, () => '')
878 await handOff($, 'list')
879 return
880 }
881 await clearCommandLine($)
882 $.ui.toast(CMD_HINT)
883 } finally {
884 cmdBusy = false
885 }
886}
887
888// A digit of a prompt number, pressed as a band Button: jumps on the digit that makes
889// the number complete (with 25 prompts, `3` at once, `2` waits), or on `go`.
890async function bandDigit($: EngineInterface, digit: string) {
891 const typed = (await read($, bandNum)) + digit
892 const count = (await prompts($)).length
893 if (promptNumberComplete(typed, count)) {
894 await update($, bandNum, () => '')
895 await jumpToPromptNumber($, typed, 'band prompts')
896 return
897 }
898 await update($, bandNum, () => typed)
899 await showPromptInPane($, Number(typed))
900 keepBandWaiting($)
901}
902
903// Bring prompt #n into view in the pane's list, so it can be checked before Enter (its ▶
904// is drawn by the pane). The list is newest first, keyed `p-<uuid>`.
905async function showPromptInPane($: EngineInterface, n: number) {
906 const target = (await prompts($))[n - 1]
907 if (!target) return
908 const r = await $.ui.scroll({ in: PANE, to: { key: `p-${target.uuid}` }, block: 'center' })
909 if (r.deny) log($, `[bm-poc] prompts pane scroll to #${n} -> DENY: ${r.deny}`)
910}
911
912// Browse the prompts one by one from the band, vim's j and k: the band holds the keyboard,
913// so the pane can't take the arrows (djdarcy, 2026-10-07: "the nice thing about the panel
914// is that you can scroll through the prompts 1 by 1"). The list is newest first: j moves
915// the ▶ down (older), k up (newer); the first press lands on the newest. Enter jumps.
916async function bandStep($: EngineInterface, key: 'j' | 'k') {
917 const count = (await prompts($)).length
918 if (count === 0) return
919 const typed = await read($, bandNum)
920 const cur = Number(typed)
921 const n = !typed || !cur ? count : Math.min(count, Math.max(1, cur + (key === 'j' ? -1 : 1)))
922 await update($, bandNum, () => String(n))
923 await showPromptInPane($, n)
924 keepBandWaiting($)
925}
926
927// Pin or unpin the prompt under the ▶, from the band's prompt mode: where the list is in
928// view, so the person sees which prompt it is (djdarcy, 2026-10-07: a pin by number
929// alone, with no list in sight, is not useful). `s` for star: `*` can't be a hotkey.
930// The band stays in prompt mode, to pin several.
931async function bandPin($: EngineInterface) {
932 const typed = await read($, bandNum)
933 if (!typed) {
934 $.ui.toast('Pick a prompt first: j/k or the arrows (or its number), then s to pin it')
935 return
936 }
937 await togglePin($, typed)
938 keepBandWaiting($)
939}
940
941// Browsing takes as long as it takes: each key restarts the band's wait (BAND_MODE_MS).
942function keepBandWaiting($: EngineInterface) {
943 bandTimer?.cancel()
944 bandTimer = $.clock.after(BAND_MODE_MS, () => void endBandMode($))
945}
946async function bandNumberGo($: EngineInterface) {
947 const typed = await read($, bandNum)
948 await update($, bandNum, () => '')
949 if (!typed) return void (await closePane($))
950 await jumpToPromptNumber($, typed, 'band prompts')
951}
952
953// DIAGNOSTIC (2026-10-07): who holds the keyboard around a key, to explain a pane that is
954// kept or refused. Lengths only, never the draft's text. Keystrokes are logged only
955// while /bm-diag-keys is on.
956const diag = { lastEditAt: 0, busy: false, logKeys: false }
957async function diagState($: EngineInterface, when: string) {
958 const draft = (await $.prompt.read()).text
959 const panes = (await $.ui.panes()).map(p => `${p.id}${p.isFocused ? '*' : ''}`).join(',') || 'none'
960 const since = diag.lastEditAt ? `${Date.now() - diag.lastEditAt} ms` : 'never'
961 log($, `[diag] ${when}: draft ${draft.length} chars, last edit ${since} ago, claude ${diag.busy ? 'BUSY' : 'idle'}, panes ${panes}`)
962}
963
964async function openFor($: EngineInterface, mode: PaneMode, action: string) {
965 log($, `[bm-poc] B4: ${action.startsWith('command:') ? `leader ran ${action}` : `chord for ${action} pressed the band Button`}`)
966 await diagState($, `${mode} (before open)`)
967 await update($, paneMode, () => mode)
968 const title = mode === 'mark' ? 'mark: press a-z' : mode === 'jump' ? 'jump: press a-z' : 'prompts: press 1-9'
969 note(`pane open (${mode}, focus)`)
970 // Narrow on purpose (djdarcy, 2026-10-04: "collapse the panel so it has almost no
971 // width"): it only has to take one letter. Both sizes are requests; a size the
972 // person dragged the pane to wins. `focus` is refused while the composer holds
973 // text, so over typed text the letter goes to the prompt instead (issue #4).
974 // The docked pane narrows the conversation, which rewraps and shifts the view
975 // (djdarcy, 2026-10-07: "the pane itself is what shifts the viewport"). So note the
976 // message at the top of the screen first, a hidden mark, and return to it "as though
977 // the user was auto-firing a ctrl]' to the hidden mark we just created" (djdarcy):
978 // at once where Claude Code allows the scroll (a press: a chord, a click), else at the
979 // next press (the letter), or when the person closes the pane.
980 paneAnchor = await topOnScreen($)
981 paneView = await viewAnchor($)
982 await $.ui.open({ id: PANE, title, focus: true, closeOnEscape: true, columns: 16, rows: 4 })
983 if (paneView) {
984 const r = await $.ui.scroll({ to: { requestId: paneView.uuid }, block: paneView.block })
985 log($, `[bm-poc] view back to ${short(paneView.uuid)} (${paneView.block}) after the pane opened -> ${r.deny ? `DENY: ${r.deny} (again at the next press)` : 'ok'}`)
986 }
987 // The selection is read AFTER the pane opens: an awaited call before $.ui.open looks
988 // to cost the pane the keyboard (the mark pane, which read it first, was refused in 8
989 // of 12 runs; the jump pane, which didn't, almost never; log 2026-10-06/07). The
990 // reason for reading it first was an inline pane shifting the transcript (round 1);
991 // the pane is docked now, and the mark resolves by the selection's message id.
992 if (mode === 'mark') {
993 selectionAtChord = await $.ui.selection()
994 note(`selection at chord: ${selectionAtChord?.requestId ?? 'none'}`)
995 }
996 const focused = await paneFocused($)
997 const draft = (await $.prompt.read()).text
998 const selNow = await $.ui.selection()
999 log(
1000 $,
1001 `[bm-poc] ${mode} pane has the keyboard: ${focused ? 'yes' : 'NO'} (input box holds ${draft.length} chars; ` +
1002 `selection ${selNow?.text.trim() ? `"${selNow.text.trim().slice(0, 24)}"` : 'none'})`,
1003 )
1004 // From a typed command (/bm-mark, /bm-goto, /bm-prompts) the band never holds the
1005 // keyboard, so the band key mode below can't take the key (log, 2026-10-07 04:56:51).
1006 // Claude Code refuses the pane over a draft, and often just after a mouse selection
1007 // (2026-10-06/07); working around that from the input box failed (a scroll from
1008 // prompt.edit is "not person-initiated"). So say where the keys work instead: the
1009 // band leader holds the keyboard by the person's own focus move, draft or not.
1010 if (!focused && action.startsWith('command:')) {
1011 await closePane($)
1012 $.ui.toast(
1013 draft.trim()
1014 ? 'The keyboard is in the input box (it holds a draft). Use the band leader, Ctrl+] by default, which works over a draft.'
1015 : "Claude Code didn't give the pane the keyboard. Use the band leader, Ctrl+] by default.",
1016 { timeoutMs: 8000 },
1017 )
1018 return
1019 }
1020 // Pressed from the band (its hotkey after `abovePrompt:focus`, Ctrl+X Tab or a
1021 // rebound key), the focus request is refused: "an element of the band ... the person
1022 // holds" has the keys, so the pane opened without them and the next letter went
1023 // nowhere (djdarcy, 2026-10-05: "the cursor is staying in the normal input box"). A
1024 // re-request 120 ms later was refused too (log 12:24:16, 12:24:28): the band still
1025 // holds them. So the band itself takes the next key; the pane stays open as the list.
1026 if (!(await paneFocused($))) {
1027 await update($, bandMode, () => mode)
1028 log($, `[bm-poc] pane opened without the keyboard; the band takes the ${mode} key`)
1029 bandTimer?.cancel()
1030 bandTimer = $.clock.after(BAND_MODE_MS, () => void endBandMode($))
1031 // Enter's default: `go` for a prompt number, the reading position for a jump (a
1032 // missing element, no reading position, is simply denied), and never a letter for a
1033 // mark: Enter on `a` overwrote mark a (djdarcy, 2026-10-07).
1034 const deny = await focusBand($, mode === 'list' ? 'band-num-go' : mode === 'jump' ? 'band-jump-reading' : 'band-mark-cancel')
1035 // The band never had the keyboard: the button was clicked (over a draft, a click
1036 // presses it without moving the keys). The watcher below would then close the pane
1037 // at once (djdarcy, 2026-10-07: "the panel popped up for a second but then
1038 // disappeared"; log 08:32:22). So it stays open as a list to click, closed by its ×.
1039 if (deny && /does not hold the keyboard/i.test(deny)) {
1040 bandTimer?.cancel()
1041 bandTimer = undefined
1042 await update($, bandMode, () => 'idle')
1043 log($, `[bm-poc] ${mode} pane opened by a click: it stays open as a list (close it with its ×)`)
1044 return
1045 }
1046 // Esc hands the keyboard back to the prompt, and no event says so (djdarcy,
1047 // 2026-10-05: "it goes back to the normal text input bar but the panel stays
1048 // open"). $.ui.focus on the band is refused once the band no longer holds the
1049 // keys, so ask every BAND_WATCH_MS, onto the element the ring is already on
1050 // (no visible move), and close the pane as soon as it is refused.
1051 bandWatch?.cancel()
1052 bandWatch = $.clock.every(BAND_WATCH_MS, () => void watchBand($, mode))
1053 }
1054}
1055
1056// How long the band waits for the next key before going back to its buttons.
1057const BAND_MODE_MS = 15_000
1058let bandTimer: Timer | undefined
1059
1060// The band's own requestId, as its render hook last saw it: $.ui.focus names the site
1061// by it, to put the band's ring on the prompts field or the reading entry.
1062let bandId: string | undefined
1063
1064// Returns the refusal, if any.
1065async function focusBand($: EngineInterface, key: string): Promise<string | undefined> {
1066 if (!bandId) return undefined
1067 const r = await $.ui.focus({ requestId: bandId, key })
1068 const deny = 'deny' in r && r.deny ? String(r.deny) : undefined
1069 log($, `[bm-poc] band focus ${key}: ${deny ? `DENY ${deny}` : 'ok'}`)
1070 return deny
1071}
1072
1073// Jumps to prompt #n, from the prompts pane's field or the band's.
1074// --- Pinned prompts ------------------------------------------------------------------
1075// Favourite prompts to come back to (djdarcy, 2026-10-05), per conversation, kept in
1076// the order they were pinned. Drawn as `245★)` and listed again at the top of the
1077// prompts pane. Toggled by `*21` (or `*` for the newest) in the `#` field, or /bm-pin.
1078async function pinsKey($: EngineInterface): Promise<string> {
1079 return `pins:${await $.session.id()}`
1080}
1081async function loadPins($: EngineInterface): Promise<string[]> {
1082 return ((await $.store.get(await pinsKey($))) as string[] | undefined) ?? []
1083}
1084
1085// Toggles the pin on prompt #n (the newest when `typed` names none). Returns false when
1086// there is no such prompt.
1087async function togglePin($: EngineInterface, typed: string): Promise<boolean> {
1088 const all = await prompts($)
1089 const n = typed.trim() === '' ? all.length : Number(typed.trim())
1090 const target = Number.isInteger(n) ? all[n - 1] : undefined
1091 if (!target) {
1092 $.ui.toast(`no prompt #${typed.trim()} (1-${all.length})`)
1093 return false
1094 }
1095 const pins = await loadPins($)
1096 const pinned = pins.includes(target.uuid)
1097 await $.store.set(await pinsKey($), pinned ? pins.filter(u => u !== target.uuid) : [...pins, target.uuid])
1098 await update($, pinsRev, v => v + 1)
1099 $.ui.toast(`${pinned ? 'unpinned' : 'pinned'} #${n}: ${target.head.slice(0, 40)}`)
1100 log($, `[bm-poc] ${pinned ? 'unpinned' : 'pinned'} prompt #${n} ${short(target.uuid)}`)
1101 return true
1102}
1103
1104async function jumpToPromptNumber($: EngineInterface, typed: string, via: string) {
1105 // `*21` toggles the pin on #21, `*` on the newest; the pane stays open to pin more.
1106 if (typed.trim().startsWith('*')) {
1107 await togglePin($, typed.trim().slice(1))
1108 return
1109 }
1110 const all = await prompts($)
1111 const n = Number(typed.trim())
1112 const target = Number.isInteger(n) ? all[n - 1] : undefined
1113 if (!target) {
1114 $.ui.toast(`no prompt #${typed.trim()} (1-${all.length})`)
1115 return
1116 }
1117 await jumpTo($, target.uuid, via, `prompt #${n}`, target.head)
1118 await closePane($)
1119}
1120
1121// When a typed prompt number jumps (a setting, #7): 'enter' waits for Enter, so the
1122// person can check the number first; 'complete' jumps on the digit that makes it complete.
1123// Default 'enter': `p 3XX` jumping before Enter was "potentially a problem" (djdarcy,
1124// 2026-10-07: "Some users will like just typing the number and going there. Others will
1125// want the chance to confirm.").
1126const PROMPT_NUMBER_JUMPS_AT: 'enter' | 'complete' = 'enter'
1127
1128// True once the digits typed can't become a larger prompt number: with 25 prompts,
1129// `3` is complete, `2` waits for a second digit or Enter. Only acted on when
1130// PROMPT_NUMBER_JUMPS_AT is 'complete'.
1131function promptNumberComplete(typed: string, count: number): boolean {
1132 if (PROMPT_NUMBER_JUMPS_AT !== 'complete') return false
1133 const n = Number(typed)
1134 return /^\d+$/.test(typed) && n >= 1 && n <= count && n * 10 > count
1135}
1136
1137const BAND_WATCH_MS = 400
1138let bandWatch: Timer | undefined
1139// The band element the focus ring is on, as the ui.focus hook last saw it.
1140let bandRing: string | undefined
1141
1142// An element of the band's key mode that is drawn now, to aim the focus probe at:
1143// the ring's own element when it belongs to this mode, else a fixed one per mode.
1144async function bandProbeKey($: EngineInterface, mode: PaneMode): Promise<string | undefined> {
1145 const prefix = mode === 'list' ? 'band-num-' : `band-${mode}-`
1146 if (bandRing?.startsWith(prefix)) return bandRing
1147 if (mode === 'list') return 'band-num-go'
1148 if (mode === 'mark') return 'band-mark-cancel'
1149 const marks = await loadMarks($)
1150 if (marks[READING]) return 'band-jump-reading'
1151 const first = LETTERS.find(l => marks[l])
1152 return first ? `band-jump-${first}` : undefined
1153}
1154
1155async function watchBand($: EngineInterface, mode: PaneMode) {
1156 if (!bandId || (await read($, bandMode)) === 'idle') return
1157 const key = await bandProbeKey($, mode)
1158 if (!key) return // nothing focusable on the band: the timeout or the next edit ends it
1159 const r = await $.ui.focus({ requestId: bandId, key })
1160 if ('deny' in r && r.deny) {
1161 // Into the pane (a click in it): the band steps back and the pane keeps the keyboard,
1162 // its own keys and field included. Closing it there shut the pane the moment its
1163 // field was clicked (djdarcy, 2026-10-07).
1164 if (await paneFocused($)) {
1165 log($, `[bm-poc] the keyboard moved into the pane: the band steps back, the pane stays`)
1166 await endBandMode($)
1167 return
1168 }
1169 log($, `[bm-poc] band lost the keyboard (${r.deny}): closing the pane`)
1170 await closePane($)
1171 }
1172}
1173
1174async function endBandMode($: EngineInterface) {
1175 bandTimer?.cancel()
1176 bandTimer = undefined
1177 bandWatch?.cancel()
1178 bandWatch = undefined
1179 bandRing = undefined
1180 if ((await read($, bandNum)) !== '') await update($, bandNum, () => '')
1181 if ((await read($, bandMode)) !== 'idle') await update($, bandMode, () => 'idle')
1182}
1183
1184async function paneFocused($: EngineInterface): Promise<boolean> {
1185 return (await $.ui.panes()).some(p => p.id === PANE && p.isFocused)
1186}
1187
1188// Set by the pane's render hook (which may not write state): the placement and width
1189// the surface actually gave it, logged when it closes.
1190let paneGeometry: string | undefined
1191
1192async function closePane($: EngineInterface) {
1193 note('pane close')
1194 if (paneGeometry) log($, `[bm-poc] pane was ${paneGeometry}`)
1195 await endBandMode($)
1196 await $.ui.close({ id: PANE })
1197 // The conversation widens again as the pane goes, and shifts. Unless a jump moved the
1198 // view on purpose (it clears the anchor), go back to where it was before the pane
1199 // opened: the inverse of the return when it opened (djdarcy, 2026-10-07). From a
1200 // timer (the band's Esc check) Claude Code refuses the scroll; the log says so.hooks/engine/select.ts 26 lines1// Capability paths (issue #18): for each engine API Claude Code is missing, the plugin
2// can hold up to three implementations and pick one at run time.
3//
4// ideal written against the API we'd propose upstream; runs as soon as Claude
5// Code (stock, or a patched build implementing the proposal) offers it
6// patched only for a patched build exposing something that isn't the proposal
7// workaround what works on stock Claude Code today
8//
9// Code that uses an API Claude Code already has is written directly and never comes
10// through here (djdarcy, 2026-10-05: "we shouldn't need #2 ... nor need #3 ... if #1
11// already exists"). Design: the capability-paths DWP, 2026-10-05.
12//
13// A mod can't load code on demand (a module holding `import()` doesn't load), so every
14// path is imported up front and the selector only chooses among them.
15
16export type PathName = 'ideal' | 'patched' | 'workaround'
17
18// The order the selector tries paths in when nothing overrides it: the proper API first,
19// then a patched build's own route, then the stock workaround.
20export const PATH_ORDER: readonly PathName[] = ['ideal', 'patched', 'workaround']
21
22// One line for /bm-env: proves this module loaded, and says which order is in force.
23export function describePathOrder(): string {
24 return `capability paths: ${PATH_ORDER.join(' > ')}`
25}
26types/index.d.ts 29 lines1// One transcript row the probe saw appended: its uuid, the door it came in by,
2// and the head of its text for display.
3export type Row = { uuid: string; door: 'prompt' | 'response'; head: string; text?: string }
4
5// What the probe pane is doing: listing prompts to jump to, or waiting for the
6// register letter after a chord.
7export type PaneMode = 'list' | 'mark' | 'jump'
8
9declare module 'claude-code' {
10 interface PluginState {
11 'convo-bookmarks': {
12 rows: Row[]
13 paneMode: PaneMode
14 // When a pane opened from the band can't take the keyboard, the band itself
15 // collects the next key; 'idle' shows the usual buttons.
16 bandMode: PaneMode | 'idle'
17 // Bumped when the pinned prompts change, so the prompts pane redraws.
18 pinsRev: number
19 // Bumped after the band's command line runs a command: the field is drawn
20 // under a new key, so it starts empty (POC 2026-10-07).
21 cmdRev: number
22 // The digits of a prompt number typed into the band, one Button press each.
23 bandNum: string
24 // What the band shows after a mark or jump: the letter and the marked text.
25 shown: { letter: string; text: string } | null
26 }
27 }
28}
29