SLOPSHOPPER

focus-probe

Probe only, not shipped: a plain band and pane (mark, jump, prompts, read) that do nothing but open panes and log every focus move, to see how Claude Code's…

newpanebandcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · focus-probe
│ ┃ probe: prompts ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ this pane does NOT have the keyboard │ focus-probe │ │ ┃ 30) a pretend prompt, number 30 ⏺ Read(src/auth.ts) │ probe flags: bandField=off bandButtons=on │ │ ┃ 29) a pretend prompt, number 29 ⎿ Read 6 lines │ autoFocus=off paneField=off openFocus=on │ │ ┃ 28) a pretend prompt, number 28 ⏺ Update(src/auth.ts) │ readout=on vanish=off │ │ ┃ 27) a pretend prompt, number 27 ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ 26) a pretend prompt, number 26 ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ │ ┃ 25) a pretend prompt, number 25 ⎿ 3 pass, 1 fail │ focus-probe │ │ ┃ 24) a pretend prompt, number 24 │ probe log: │ │ ┃ 23) a pretend prompt, number 23 ● Done. refresh now rejects expired claims and logs an audit event│ / │ ┃ 22) a pretend prompt, number 22 ╰────────────────────────────────────────────╯ │ ┃ 21) a pretend prompt, number 21 ✻ Worked for 42s · done 4:20 PM │ ┃ 20) a pretend prompt, number 20 │ ┃ 19) a pretend prompt, number 19 › /probe-open │ ┃ 18) a pretend prompt, number 18 │ ┃ 17) a pretend prompt, number 17 │ ┃ 16) a pretend prompt, number 16 │ ┃ 15) a pretend prompt, number 15 │ ┃ 14) a pretend prompt, number 14 │ ┃ 13) a pretend prompt, number 13 │ ┃ 12) a pretend prompt, number 12 │ ┃ 11) a pretend prompt, number 11 │ ┃ 10) a pretend prompt, number 10 │ ┃ 9) a pretend prompt, number 9 │ ┃ 8) a pretend prompt, number 8 │ ┃ 7) a pretend prompt, number 7 ⟨Claude Code's own drawing⟩ probe m: mark j: jump p: prompts r: read 45:07 open prompts from /probe-open (focus asked) -> placed=true paneFocused=undefined ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ probe m: mark j: jump p: prompts r: read 45:07 open prompts from /probe-open (focus asked) -> placed=true paneFocused=undefined
Pane · probe: prompts
this pane does NOT have the keyboard 30) a pretend prompt, number 30 29) a pretend prompt, number 29 28) a pretend prompt, number 28 27) a pretend prompt, number 27 26) a pretend prompt, number 26 25) a pretend prompt, number 25 24) a pretend prompt, number 24 23) a pretend prompt, number 23 22) a pretend prompt, number 22 21) a pretend prompt, number 21 20) a pretend prompt, number 20 19) a pretend prompt, number 19 18) a pretend prompt, number 18 17) a pretend prompt, number 17 16) a pretend prompt, number 16 15) a pretend prompt, number 15 14) a pretend prompt, number 14 13) a pretend prompt, number 13 12) a pretend prompt, number 12 11) a pretend prompt, number 11 10) a pretend prompt, number 10 9) a pretend prompt, number 9 8) a pretend prompt, number 8 7) a pretend prompt, number 7 6) a pretend prompt, number 6 5) a pretend prompt, number 5 4) a pretend prompt, number 4 3) a pretend prompt, number 3 2) a pretend prompt, number 2 1) a pretend prompt, number 1
README

claude-bookmarks

Version Claude Code 2.1.287+ License: GPL v3 Platform

Vim-style marks, a reading position, and a numbered prompt history, inside a Claude Code conversation

A Claude Code plugin that lets you mark a line of a conversation with a letter and jump back to it from anywhere, keep your place while you scroll, browse every prompt you have typed, and let Claude cite earlier places as links you can click. It is a mod, meaning it draws inside Claude Code's terminal app, with nothing else to install. In Claude Code's official directory the plugin is called bookmarks (published by DazzleML), whereas the Github repository here is claude-bookmarks. (It is a terminal plugin, not a browser extension -- for navigating the claude.ai website, AI Chat Nav does a similar job).

The Problem

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 an exercise in scrolling-squinting-and-praying. Claude Code can already jump to the top and the bottom of the conversation, 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. This includes tagged letters you set on any line (essentially Vim marks), 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 text safari.

[!NOTE] Pre-alpha (v0.3.x). claude-bookmarks is part of my daily active workflow. I've been dogfooding since Claude Code 2.1.288 (now at 2.1.295) on Win11 using Windows Terminal, fullscreen, and at different sizes. The mod for the most part correctly handles: inserting in-place highlights, jumps, using the reading position, navigating the prompts pane, and now works with bookmarks including clickable links in Claude's replies. Windows is the only tested platform. MacOS and Linux are expected to work but is untried (I'll try on a VPS after a few more minor versions). The engine under hooks/core/ is tested and reasonably solid. Be aware the key layout will still change (#26). Also note: docs/status.md tracks known issues along with what is coming in the Roadmap and issue #1 (the longer view). Please file issues for anything rough.

Screenshot

<img src="plugin/images/prompts-pane-marked-line-and-bookmark-link.png" alt="The prompts pane open on the right, numbered from the first prompt; a marked line in the conversation with its c tag; a bookmark link in a reply; the band above the prompt in prompt mode"> <sub>In use: the prompts pane (<code>Ctrl+] p</code>) on the right, a marked line with its <code>«c»</code> tag, a <code>⚓</code> bookmark link Claude wrote in a reply, and the band above the input box waiting for a number. <a href="docs/images/jump-pane-marks-and-reading-position.png">The jump pane</a>, with three marks and the reading position.</sub>

Quick Start

One command installs it on Claude Code 2.1.275 or later, since this repository is also a marketplace:

/plugin install bookmarks --marketplace DazzleML/claude-bookmarks

To work on it, or to run a version before it was listed, load a local clone:

# 1. Clone the plugin
git clone https://github.com/DazzleML/claude-bookmarks.git

# 2. Load it (the plugin is the repository's plugin/ folder). For one session:
claude --plugin-dir /path/to/claude-bookmarks/plugin
#    ...or for every session, add it to ~/.claude/settings.json:
#    "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-bookmarks/plugin" }

# 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.

Set up the leader

The plugin's keys all start with one leader key, Ctrl+]: a single key that every terminal passes on, over SSH as well. It is Claude Code's own "focus the band" action, abovePrompt:focus, bound to that key. A plugin cannot add a keybinding for you, so add this once to your ~/.claude/keybindings.json (back the file up first; if the file already has a Chat block, add the one line to it):

{
  "bindings": [
    {
      "context": "Chat",
      "bindings": {
        "ctrl+]": "abovePrompt:focus"
      }
    }
  ]
}

That's the whole setup. Every key in these docs is written as Ctrl+]. Until the binding is in place, Claude Code's built-in key for the same action, Ctrl+X Tab (two keys), does the same job, and the band's buttons can be clicked.

[!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.

Documentation

  • Status - What works today, the known issues, and the big changes coming; read this first if you are deciding whether to try a pre-alpha
  • Every key - The complete key reference, grouped by where the keyboard is
  • Tutorial - Chords, leader keys and vim marks explained, then a hands-on walkthrough
  • Using claude-bookmarks - Each feature in detail: marks, jumps, the reading position, the prompts pane, bookmarks, and what is kept
  • Troubleshooting - The band doesn't appear, a key does nothing, a jump is refused
  • Platform support - Terminals and renderers, tested and expected
  • Claude Code quirks to work around - Each limit of the plugin API I hit, what the plugin does about it, and what a proper fix would be

Features

  • Marks - Select text in any reply or prompt, press Ctrl+] m and a letter (a-z). With nothing freshly selected, the letter marks the message at the top of your screen
  • In-place highlight - The marked line lights up where it sits in the conversation for two minutes, so you can see what you marked
  • Jumps - Ctrl+] ' and a letter scrolls back to that mark from anywhere in the conversation; the jump pane lists only the marks you have
  • Reading position - Select a line and press Ctrl+] Space Space to keep your place; press it again from anywhere to go there, and again to return to where you were
  • Prompt history - Ctrl+] 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
  • Bookmarks - Permanent, addressable places in the conversation, kept as readable files in your own folder. Claude places them while it answers and cites them as links in its reply: click one to jump there with the words highlighted, ctrl-click to open the bookmark's file. Your bookmarks and Claude's are separate lists (Ctrl+] b); promote any mark into a bookmark with Ctrl+] P and its letter
  • Back and forward - Ctrl+] o returns to where you were before the last jump, Ctrl+] i goes forward again, like vim's jumplist
  • Works while you type - The leader works with a draft in the input box and while Claude is working; your draft is untouched
  • Per conversation - Marks, the reading position and the prompt list belong to the conversation they were made in, and survive restarting Claude Code and --resume
  • Display only - Highlights change what is drawn on screen, never what Claude reads or what is saved in the session file
  • No extra installs - TypeScript that runs inside Claude Code; the prompt back-fill uses the shell your system already has (PowerShell on Windows, sh and grep elsewhere)

Usage

Keys

KeysBand buttonWhat it does
Ctrl+] m then a-zmarkMark the selected line (or the top of the screen) with that letter; Enter cancels
Ctrl+] ' (or j) then a-zjumpJump to that mark
Ctrl+] ' then EnterjumpJump to the reading position (it heads the jump pane)
Ctrl+] Space SpacereadSet, go to, or return from the reading position
Ctrl+] p then a number, EnterpromptsJump to prompt #N
Ctrl+] p then j/k or Down/Up, EnterpromptsBrowse the prompts one by one, then jump
Ctrl+] p, pick a prompt, then spromptsPin or unpin it (the band stays open to pin more)
Ctrl+] b then a number, EnterOpen the bookmarks pane (yours, then Claude's) and jump to bookmark #N
Ctrl+] P then a-zPromote that mark into a bookmark on your list (its link is copied to the clipboard)
Ctrl+] o / Ctrl+] iBack to before the last jump / forward again
Click a ⚓ link in a replyJump to the bookmarked message, words highlighted; ctrl-click opens its file
EscLeave 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.

Common workflows

# 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)

# Ask Claude where to read, and click your way there
"Where should I read to catch up? Bookmark each place."    (Claude answers with ⚓ links)
click a link                                              (jump; the words light up)
Ctrl+] o                                                  (back to the answer)

# Keep a mark for good
Ctrl+] P  a                                               (mark a becomes a bookmark; link on the clipboard)

Commands

CommandWhat it does
/bm-mark, /bm-goto, /bm-prompts, /bm-readThe same as the leader keys, typed; from an empty input box their pane takes the keyboard
/bm-bookmarks, /bm-promote aOpen the bookmarks pane; promote mark a into a bookmark
/bm-delmarks a b, /bm-delmarks allDelete marks
/bm-pin [N]Pin or unpin prompt #N in the prompts pane (*N in its # field does the same)
/bm-envPlugin version, session id, and what it has captured
`/bm-debug on\off`Echo the plugin's log into this conversation as dim rows (this conversation only; `default on\off and force on\off` reach every conversation)
/bm-marksThe marks set in this conversation
/bm-timelineThe last few plugin events (draws, panes, jumps)

For each feature in detail, see docs/usage.md.

Tips

The known limits, briefly; docs/troubleshooting.md has the details.

  • Press 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.
  • The keys do nothing while a dialog is up (a permission prompt, or a question Claude is asking); answer the dialog first.
  • Messages from before the last compaction usually can't be jumped to after a restart or resume, because Claude Code then loads the conversation only from that compaction onward. The plugin copies a phrase of the message to your clipboard and tells you where to look: press 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). For long conversations there is a way to keep the whole history in view after a restart, described in the engine notes; the plugin never depends on it.
  • "Where you were" is a whole message. A plugin cannot read or restore the exact scroll offset, so returning from the reading position brings back the message that was at the top of the screen (or, from the very bottom, the last message's end).
  • If the plugin doesn't load after /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>.
  • If you use the optional 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.

Configuration

Nothing to configure yet beyond the leader key. Settings are planned (#7), among them:

  • how old a selection may be before a mark uses the top of the screen instead (75 seconds now);
  • whether "back" from the bottom returns to the same text or to the newest reply;
  • whether a typed prompt number waits for Enter (now) or jumps as soon as it's complete;
  • how long the highlight stays, and the pane sizes.

Debug Logging

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 Support

PlatformStatus
Windows 11, Windows Terminal, fullscreenTested
macOS, fullscreenExpected to work
Linux, fullscreenExpected to work
Classic (non-fullscreen) rendererNot supported

Details, including tmux, IDE terminals and the Desktop app: docs/platform-support.md.

Project Structure

claude-bookmarks/
├── plugin/                   # The plugin, as Claude Code installs it (point --plugin-dir here)
│   ├── .claude-plugin/
│   │   └── plugin.json       # Plugin manifest (name: bookmarks)
│   ├── hooks/
│   │   ├── hooks.json        # Points Claude Code at the mod
│   │   ├── register.tsx      # The mod: the band, panes, highlight, bookmarks, back-fill
│   │   ├── core/             # Pure modules with their node --test suites (anchor URL, transcript, register)
│   │   └── scripts/          # PowerShell fallbacks for reading the transcript on Windows
│   ├── types/index.d.ts      # Declared $.state values
│   ├── README.md, LICENSE    # What the plugin directory requires inside the plugin folder
│   └── tsconfig.json         # Type-check the mod: npx tsc -p plugin --noEmit
├── .claude-plugin/
│   └── marketplace.json      # This repository as a marketplace (dazzle-claude-plugins)
├── docs/                     # Status, keys, tutorial, usage, troubleshooting, platform support, engine quirks
├── 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/.claude-plugin/plugin.json)
└── version.py                # Version source for the repo tooling

How It Works

  1. The band is (currently) the command line. The leader (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, while a pane beside the conversation shows the list. Claude Code credits a plugin's scroll or pane to your keystroke only while the key's handler is still running, so every jump happens inside that handler. The band exists because Claude Code refuses a pane the keyboard while the input box holds text; #26 is the plan to land in the pane whenever it is allowed.
  1. A mark is a message plus a snippet. Marking reads your mouse selection, finds the message it sits in, and stores the message id and the selected line under the letter, per conversation.
  1. The highlight is drawn, not written. While a mark is shown, the plugin redraws that message with the marked line coloured. The session file and what Claude reads are untouched.
  1. Jumps ask Claude Code to scroll. A jump asks Claude Code to reveal the stored message. If the message isn't loaded (it's from before the last compaction and the session was restarted), Claude Code refuses, and the plugin falls back to the clipboard phrase.
  1. The prompt list is back-filled once. The first time the plugin sees a conversation, it reads the user prompts out of the session's transcript file with one shell command (Select-String on Windows, grep elsewhere), then keeps the list current as you type.

Contributing

Contributions welcome! Please open an issue or submit a pull request.

See CONTRIBUTING.md for:

  • Development setup (claude --plugin-dir . reloads the mod live when you save)
  • Checks: claude plugin validate . and python -m pytest tests/ -v
  • Version management with sync-versions.py
  • Human test checklists in tests/checklists/

Like the project?

"Buy Me A Coffee"

Related Projects

  • claude-session-logger - Real-time per-session logging of tool calls, prompts and replies
  • Claude-Session-Backup - Git-backed backup, search and restore of Claude Code sessions, including the pre-compaction messages a bookmark can no longer scroll to
  • anthropics/claude-code#94786 - The feature request this project grew from: linking and anchoring to specific messages in a conversation
  • Claude Code - Anthropic's CLI for Claude

License

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.

Source 2 files
hooks/register.tsx 297 lines
1// focus-probe: a probe, not the product (djdarcy, 2026-10-07: "let's disable how all
2// input works and then I'd like to see what native ctrl-] looks like with a simple
3// band, and a simple panel, with nothing fancy").
4//
5// The band draws four Buttons, mark / jump / prompts / read, with hotkeys m j p r. Each
6// opens one pane, `probe`, with a few Buttons that do nothing but toast and close it.
7// Nothing redirects focus: the ui.focus, ui.press and ui.close hooks only watch and pass
8// every event on unchanged. What convo-bookmarks layers on top can be switched back on
9// one piece at a time with /probe-set (all off but openFocus and readout by default):
10//
11//   bandField   an Input in the band before the Buttons; typing m j p r opens the pane
12//   bandButtons the four Buttons (on); off with bandField on leaves the band one stop
13//   autoFocus   autoFocus on the band's first element (the field, if drawn)
14//   paneField   an autoFocus Input at the top of the pane
15//   openFocus   band presses open the pane with `focus: true` (a request Claude Code
16//               refuses while the band holds the keys; kept to watch it be refused)
17//   readout     the band's second row shows the last focus moves as they happen
18//   vanish      after an action the band draws nothing focusable for a few seconds:
19//               does the keyboard fall into the pane, or back to the input box?
20//
21// Every event goes to <CLAUDE_USER_DIR or ~/claude>/bookmarks/debug/focus-probe-<session>.log
22// (outside the plugin folder: a write inside it reloads the mod).
23//
24// Run it alone, without convo-bookmarks: claude --plugin-dir <this folder>
25
26import { atom, read, update } from 'claude-code'
27import type { EngineInterface, Register, Timer } from 'claude-code'
28
29import type { Flags, Mode } from '../types'
30
31const PANE = 'probe'
32const BAND: [Mode, string, string][] = [
33  ['mark', 'mark', 'm'],
34  ['jump', 'jump', 'j'],
35  ['prompts', 'prompts', 'p'],
36  ['read', 'read', 'r'],
37]
38const DEFAULT_FLAGS: Flags = {
39  bandField: false,
40  bandButtons: true,
41  autoFocus: false,
42  paneField: false,
43  openFocus: true,
44  readout: true,
45  vanish: false,
46}
47// How long the band stays empty after an action under `vanish`, unless the pane closes first.
48const VANISH_MS = 4000
49
50const mode = atom({ plugin: 'focus-probe', key: 'mode' } as const, 'prompts')
51const flags = atom({ plugin: 'focus-probe', key: 'flags' } as const, DEFAULT_FLAGS)
52const readout = atom({ plugin: 'focus-probe', key: 'readout' } as const, [])
53const fieldRev = atom({ plugin: 'focus-probe', key: 'fieldRev' } as const, 0)
54const vanished = atom({ plugin: 'focus-probe', key: 'vanished' } as const, false)
55
56let busy = false
57let vanishTimer: Timer | undefined
58
59// The log file, rewritten whole ($.fs has no append), last LOG_LINES lines.
60const LOG_LINES = 600
61const logLines: string[] = []
62let logPath: string | null | undefined
63let flushing: Promise<void> = Promise.resolve()
64
65function log($: EngineInterface, line: string) {
66  logLines.push(`${new Date().toISOString()} ${line}`)
67  if (logLines.length > LOG_LINES) logLines.splice(0, logLines.length - LOG_LINES)
68  flushing = flushing.then(() => flushLog($)).catch(() => {})
69}
70
71async function flushLog($: EngineInterface) {
72  if (logPath === undefined) {
73    const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
74    const root = (await $.env.get('CLAUDE_USER_DIR')) ?? (home ? `${home}/claude` : undefined)
75    logPath = root ? `${root}/bookmarks/debug/focus-probe-${await $.session.id()}.log` : null
76  }
77  if (logPath) await $.fs.write(logPath, logLines.join('\n') + '\n')
78}
79
80// One event: to the log with the draft's length and whether a turn runs, and to the
81// band's readout row.
82async function event($: EngineInterface, what: string) {
83  const draft = (await $.prompt.read()).text.length
84  log($, `${what}  [draft=${draft}${busy ? ' busy' : ''}]`)
85  const stamp = new Date().toISOString().slice(14, 19)
86  await update($, readout, list => [...list, `${stamp} ${what}`].slice(-3))
87}
88
89// Flag `vanish`: after an action the band draws nothing focusable for VANISH_MS, so the
90// element holding the keyboard disappears. Where does Claude Code put the keyboard then:
91// into the pane just opened (the hand-off we want), or back to the input box (the
92// hand-back after a command)? `read` opens no pane under this flag, to see the second.
93async function vanishBand($: EngineInterface, why: string) {
94  await update($, vanished, () => true)
95  await event($, `band emptied (${why})`)
96  vanishTimer?.cancel()
97  vanishTimer = $.clock.after(VANISH_MS, () => void restoreBand($, 'timer'))
98}
99
100async function restoreBand($: EngineInterface, why: string) {
101  vanishTimer?.cancel()
102  vanishTimer = undefined
103  if (!(await read($, vanished))) return
104  await update($, vanished, () => false)
105  await event($, `band redrawn (${why})`)
106}
107
108async function act($: EngineInterface, m: Mode, via: string) {
109  const f = await read($, flags)
110  if (f.vanish && m === 'read') {
111    $.ui.toast('probe: read (no pane)')
112    await vanishBand($, 'read, no pane')
113    return
114  }
115  await openPane($, m, via)
116  if (f.vanish) await vanishBand($, `${m} opened`)
117}
118
119async function openPane($: EngineInterface, m: Mode, via: string) {
120  await update($, mode, () => m)
121  const f = await read($, flags)
122  const r = await $.ui.open({ id: PANE, title: `probe: ${m}`, closeOnEscape: true, ...(f.openFocus ? { focus: true as const } : {}) })
123  const p = (await $.ui.panes()).find(x => x.id === PANE)
124  await event($, `open ${m} from ${via} (focus ${f.openFocus ? 'asked' : 'not asked'}) -> placed=${r.isPlaced} paneFocused=${p?.isFocused}`)
125  $.clock.after(500, () => {
126    void (async () => {
127      const q = (await $.ui.panes()).find(x => x.id === PANE)
128      log($, `  +500ms: pane ${q ? `open, focused=${q.isFocused}` : 'closed'}`)
129    })()
130  })
131}
132
133async function picked($: EngineInterface, what: string) {
134  await event($, `picked ${what}`)
135  $.ui.toast(`probe: ${what}`)
136  await $.ui.close({ id: PANE })
137}
138
139export const register: Register = on => {
140  on('session.start', async ($, e, next) => {
141    const commands: [string, string][] = [
142      ['probe-open', 'Open the probe pane from the input box: mark | jump | prompts | read'],
143      ['probe-set', 'Switch a layer on or off: bandField | bandButtons | autoFocus | paneField | openFocus | readout | vanish [on|off]'],
144      ['probe-log', 'Show where the focus log is written'],
145    ]
146    for (const [name, description] of commands) {
147      await $.command.register({ name, description, immediate: true })
148    }
149    const v = await $.session.version()
150    log($, `--- focus-probe loaded on Claude Code ${v.version}; flags ${JSON.stringify(await read($, flags))}`)
151    return next(e)
152  })
153
154  on('turn.start', async ($, e, next) => {
155    busy = true
156    return next(e)
157  })
158  on('turn.complete', async ($, e, next) => {
159    busy = false
160    return next(e)
161  })
162
163  // Watch only: every focus move in the band or the pane, passed on unchanged.
164  on('ui.focus', async ($, e, next) => {
165    const r = await next(e)
166    const deny = r && typeof r === 'object' && 'deny' in r && r.deny ? ` DENY ${String(r.deny)}` : ''
167    const site = e.component === 'Pane' ? 'pane' : 'band'
168    await event($, `focus ${site}:${e.element ?? '(engine stop)'} by ${e.origin.kind}${deny}`)
169    return r
170  })
171
172  on('ui.press', { plugin: 'focus-probe' }, async ($, e, next) => {
173    await event($, `press ${e.element}`)
174    return next(e)
175  })
176
177  on('ui.close', { id: PANE }, async ($, e, next) => {
178    await event($, `close pane by ${e.origin.kind}`)
179    await restoreBand($, 'pane closed')
180    return next(e)
181  })
182
183  on('command.run', { command: 'probe-open' }, async ($, e) => {
184    const m = e.args.trim() as Mode
185    await openPane($, BAND.some(([x]) => x === m) ? m : 'prompts', '/probe-open')
186    return {}
187  })
188
189  on('command.run', { command: 'probe-set' }, async ($, e) => {
190    const [name, value] = e.args.trim().split(/\s+/)
191    const f = await read($, flags)
192    if (!name || !(name in f)) {
193      $.ui.toast(`probe flags: ${Object.entries(f).map(([k, v]) => `${k}=${v ? 'on' : 'off'}`).join(' ')}`)
194      return {}
195    }
196    const key = name as keyof Flags
197    const on_ = value === 'on' ? true : value === 'off' ? false : !f[key]
198    await update($, flags, old => ({ ...old, [key]: on_ }))
199    log($, `--- flag ${key} ${on_ ? 'on' : 'off'}`)
200    $.ui.toast(`probe: ${key} ${on_ ? 'on' : 'off'}`)
201    return {}
202  })
203
204  on('command.run', { command: 'probe-log' }, async $ => {
205    await flushLog($)
206    $.ui.toast(`probe log: ${logPath ?? '(no place to write it)'}`)
207    return {}
208  })
209
210  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
211    const elements = $.ui.resolve(e)
212    const { Box, Button, Text } = elements
213    const Input = 'Input' in elements ? elements.Input : undefined
214    const theirs = await next(e)
215    const f = await read($, flags)
216    const lines = f.readout ? await read($, readout) : []
217    const empty = await read($, vanished)
218    let drawn = 0
219    const first = () => (f.autoFocus && drawn++ === 0 ? { autoFocus: true as const } : {})
220    const items: any[] = []
221    if (f.bandField && Input && !empty) {
222      items.push(
223        <Input key={`band-field-${await read($, fieldRev)}`} label="probe:" placeholder="m j p r" {...first()}
224          onInput={(typed: string) => {
225            const m = BAND.find(([, , k]) => k === typed.slice(-1))?.[0]
226            if (!m) return
227            void (async () => {
228              await update($, fieldRev, n => n + 1) // drawn under a new key, so it starts empty
229              await act($, m, 'band field')
230            })()
231          }}
232          onSubmit={(typed: string) => void event($, `band field Enter "${typed}"`)} />,
233      )
234    }
235    // Off (with bandField on): the band has one stop, so each focus-key press can only
236    // leave the site; does the leader then rotate input -> band -> pane -> input?
237    for (const [m, label, hotkey] of f.bandButtons && !empty ? BAND : []) {
238      items.push(<Button key={`band-${m}`} label={label} hotkey={hotkey} plain {...first()} onPress={() => act($, m, 'band')} />)
239    }
240    return (
241      <Box flexDirection="column">
242        {theirs}
243        <Box flexDirection="row" columnGap={1}>
244          <Text dimColor>probe</Text>
245          {empty ? <Text dimColor>(nothing focusable for a moment)</Text> : items}
246        </Box>
247        {lines.length > 0 && <Text dimColor wrap="truncate-end">{lines.join('  ·  ')}</Text>}
248      </Box>
249    )
250  })
251
252  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
253    const elements = $.ui.resolve(e)
254    const { Box, Button, Text } = elements
255    const Input = 'Input' in elements ? elements.Input : undefined
256    const m = await read($, mode)
257    const f = await read($, flags)
258    const status = <Text dimColor>{e.props.isFocused ? 'this pane HAS the keyboard' : 'this pane does NOT have the keyboard'}</Text>
259    const field = f.paneField && Input
260      ? [<Input key="pane-field" label="#" placeholder="type, Enter" autoFocus onSubmit={(t: string) => void picked($, `field "${t}"`)} />]
261      : []
262
263    let body: any
264    if (m === 'mark') {
265      body = (
266        <Box flexDirection="row" columnGap={1} flexWrap="wrap">
267          <Text dimColor>mark as:</Text>
268          {'abcdef'.split('').map(l => <Button key={`mark-${l}`} hotkey={l} label="·" plain onPress={() => picked($, `mark ${l}`)} />)}
269        </Box>
270      )
271    } else if (m === 'jump') {
272      body = [
273        ['a', 'a heading near the top'],
274        ['b', 'some code further down'],
275        ['c', 'a long answer'],
276      ].map(([l, text]) => <Button key={`jump-${l}`} hotkey={l} label={text ?? ''} plain onPress={() => picked($, `jump ${l}`)} />)
277    } else if (m === 'read') {
278      body = [
279        <Button key="read-there" hotkey="t" label="there (the reading position)" plain onPress={() => picked($, 'read there')} />,
280        <Button key="read-back" hotkey="b" label="back (where you were)" plain onPress={() => picked($, 'read back')} />,
281      ]
282    } else {
283      // Thirty rows and no hotkeys: enough to see whether the arrows scroll or move the ring.
284      body = Array.from({ length: 30 }, (_, k) => 30 - k).map(n => (
285        <Button key={`prompt-${n}`} label={`${String(n).padStart(2)}) a pretend prompt, number ${n}`} plain onPress={() => picked($, `prompt ${n}`)} />
286      ))
287    }
288    return (
289      <Box flexDirection="column">
290        {status}
291        {field}
292        {body}
293      </Box>
294    )
295  })
296}
297
types/index.d.ts 27 lines
1// Which pane the probe shows, and the convo-bookmarks layers /probe-set switches on.
2export type Mode = 'mark' | 'jump' | 'prompts' | 'read'
3export type Flags = {
4  bandField: boolean
5  bandButtons: boolean
6  autoFocus: boolean
7  paneField: boolean
8  openFocus: boolean
9  readout: boolean
10  vanish: boolean
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'focus-probe': {
16      mode: Mode
17      flags: Flags
18      // The last few events, shown in the band's second row.
19      readout: string[]
20      // Bumped to redraw the band's field under a new key, so it starts empty.
21      fieldRev: number
22      // True while the band draws nothing focusable after an action (flag vanish).
23      vanished: boolean
24    }
25  }
26}
27