Prev chat and Next chat buttons that jump between your prompts and leave the transcript still at its end.

Two buttons beside the mod controls: Prev chat jumps back to the previous prompt you sent; Next chat goes forward to the next one. Each jump puts the prompt at the top of the screen, with Claude's reply below it. Next chat does nothing when the transcript's end is already visible.
/plugin marketplace add AJclemendor/my-mods
/plugin install prompt-nav@my-mods
/reload-plugins
The buttons have their own row at the top right of each sidebar pane. They stay available on Mod controls, Claude Buddy, and other pane tabs without covering those panes' controls. When no pane is shown they appear above the prompt. Buddy leaves room for the navigation row on both Chat and Max, including when its message input has focus.
Stops are your own prompts: what you typed, or sent from a phone or the web. Background-task notifications and messages from other agents are skipped.
The direction is relative to what is on screen:
A prompt the transcript no longer shows (rewound, or replaced by /clear) is skipped for the next one in the same direction. At the first or last prompt the button does nothing.
claude plugin validate ./plugins/prompt-nav --strict
claude plugin test ./plugins/prompt-nav
# Real fullscreen terminal, offline, no paid requests. Requires pyte.
python3 plugins/prompt-nav/tests/terminal_check.py
The runtime tests cover placement and stop selection. The test kit cannot scroll a real transcript, so the terminal check drives Claude Code in a PTY against a local fake API: three prompts with long replies, then Prev chat three times, then Next chat twice, checking which prompt lands at the top each time, that the buttons hold the same cell on the Mod controls and Claude Buddy tabs, and that they work from the Buddy tab.
Tested with Claude Code 2.1.300.
hooks/prompt-nav.mjs 56 lines1import { ownOrigins, emptyState, track, trackRow, candidates } from "./stops.mjs";
2
3export function register(on) {
4 let state = emptyState();
5
6 // Full-height panes leave this row free for the navigation added below.
7 on("state.get", { plugin: "prompt-nav", key: "paneRows" }, () => ({ value: { value: 1, version: 0 } }));
8
9 on("session.start", async ($, e, next) => {
10 const result = await next(e);
11 state = emptyState();
12 return result;
13 });
14
15 on("session.end", ($, e, next) => { state = emptyState(); return next(e); });
16 on("session.compact", async ($, e, next) => { const result = await next(e); state = emptyState(); return result; });
17
18 // Track the end of the transcript as well as its prompts: a visible final
19 // reply or turn footer tells Next chat that there is nothing below to visit.
20 on("ui.render", { component: ["UserMessage", "AssistantMessage", "ToolUse", "ToolResult", "ToolGroup", "CommandOutput", "TurnDuration"] }, async ($, e, next) => {
21 const result = await next(e);
22 if (e.surface === "terminal") {
23 trackRow(state, `${e.component}:${e.requestId}`, e.props.onScreen);
24 if (e.component === "UserMessage" && ownOrigins.has(e.props.origin?.kind)) track(state, e.requestId, e.props.onScreen);
25 }
26 return result;
27 });
28
29 on("ui.render", { component: ["AbovePrompt", "Pane"] }, async ($, e, next) => {
30 const original = await next(e);
31 if (e.surface !== "terminal" || e.props.hasSurvey || e.props.view?.agentId || original?.type === "engine") return original;
32 const { Box, Button } = $.ui.resolve(e);
33 const arrows = [
34 Button({ key: "prompt-nav-up", plain: true, color: "cyan", label: "Prev chat", onPress: () => jump($, state, -1) }),
35 Button({ key: "prompt-nav-down", plain: true, color: "cyan", label: "Next chat", onPress: () => jump($, state, 1) }),
36 ];
37 // Give the longer labels their own row so they never cover the pane's tabs
38 // or controls, including in the narrow Mod controls pane.
39 if (e.component === "Pane") {
40 return Box({ flexDirection: "column", children: [
41 Box({ flexDirection: "row", justifyContent: "flex-end", gap: 2, children: arrows }), original] });
42 }
43 if ((await $.ui.panes()).some(pane => pane.isShown)) return original;
44 return Box({ flexDirection: "column", children: [
45 Box({ flexDirection: "row", justifyContent: "flex-end", gap: 2, children: arrows }), original,
46 ] });
47 });
48}
49
50async function jump($, state, step) {
51 for (const id of candidates(state, step)) {
52 const { deny } = await $.ui.scroll({ to: { requestId: id }, block: "start" });
53 if (!deny) { state.landed = id; return; }
54 }
55}
56hooks/stops.mjs 62 lines1// Where the person's prompts sit relative to the transcript's viewport, from the engine's onScreen reports.
2
3// The person's own prompts; notifications and other agents' messages are not stops.
4export const ownOrigins = new Set(["composer", "bridge"]);
5
6export function emptyState() {
7 return { order: [], shown: new Map(), above: new Map(), landed: null, rows: [], rowVisibility: new Map() };
8}
9
10export function trackRow(state, id, seen) {
11 if (!state.rows.includes(id)) state.rows.push(id);
12 if (seen !== undefined) state.rowVisibility.set(id, seen);
13}
14
15export function atBottom(state) {
16 const last = state.rowVisibility.get(state.rows.at(-1));
17 return !!last && last.last >= last.of - 1;
18}
19
20export function track(state, id, seen) {
21 if (!state.order.includes(id)) state.order.push(id);
22 if (seen) {
23 state.shown.set(id, seen);
24 const index = state.order.indexOf(id);
25 state.order.forEach((other, i) => { if (i !== index) state.above.set(other, i < index); });
26 state.above.set(id, seen.first > 0);
27 } else if (seen === null && state.shown.has(id)) {
28 // A prompt whose top had scrolled away left upward; one cut at the bottom left downward.
29 const before = state.shown.get(id);
30 state.shown.delete(id);
31 state.above.set(id, before.first > 0 || before.last >= before.of - 1);
32 }
33}
34
35// The prompts to try for ▲ (step -1) or ▼ (step 1), nearest first: a stop the transcript
36// refuses (rewound, cleared) gives way to the next one the same way.
37export function candidates(state, step) {
38 const { order, shown, above, landed } = state;
39 // A short conversation can show several prompts while its end is already in
40 // view. Advancing from the first visible prompt would scroll backward there.
41 if (atBottom(state)) {
42 if (step > 0) return [];
43 const latest = order.at(-1);
44 const end = shown.get(latest)?.first === 0 ? order.length - 1 : order.length;
45 return order.slice(0, end).reverse();
46 }
47 const reference = landed && shown.has(landed) ? landed : order.find(id => shown.has(id));
48 let up, down;
49 if (reference) {
50 const index = order.indexOf(reference);
51 up = shown.get(reference).first > 0 ? index : index - 1;
52 down = index + 1;
53 } else {
54 // A prompt never reported in view (new rows push it off before it draws) counts as above:
55 // the viewport sits just before the first prompt known to lie below it.
56 const firstBelow = order.findIndex(id => above.get(id) === false);
57 up = (firstBelow >= 0 ? firstBelow : order.length) - 1;
58 down = up + 1;
59 }
60 return step < 0 ? order.slice(0, up + 1).reverse() : order.slice(down);
61}
62types/index.d.ts 6 lines1declare module "claude-code" {
2 interface PluginState {
3 "prompt-nav": { paneRows: number };
4 }
5}
6