SLOPSHOPPER

prompt-nav

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

panebandspinnerrows
★ 1v0.1.2no licenseupdated 2026-10-17AJclemendor/my-mods/plugins/prompt-nav
A shopper browsing a rack in a slop shop
README

Prompt nav

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.

How it decides where to go

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:

  • Prev chat goes to the nearest prompt whose top is above the screen. A prompt partly scrolled off the top is the first stop, so you land on its start.
  • Next chat goes to the next prompt after the one at the top of the screen.
  • Inside a long reply with no prompt visible, Prev chat returns to the prompt that reply answers and Next chat goes to the one after it.

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.

Limits

  • Made for fullscreen, where Claude Code owns the scrolling. In classic mode the conversation lives in your terminal's own scrollback; that layout is untested.
  • The buttons hide while an agent's transcript is open from the tasks list, and during native surveys.
  • Prompt order is learned as Claude draws each prompt, in conversation order. Nothing is stored; a new session starts fresh.

Development

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.

Source 3 files
hooks/prompt-nav.mjs 56 lines
1import { 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}
56
hooks/stops.mjs 62 lines
1// 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}
62
types/index.d.ts 6 lines
1declare module "claude-code" {
2  interface PluginState {
3    "prompt-nav": { paneRows: number };
4  }
5}
6