SLOPSHOPPER

heads-up

Mirrors your prompt draft at the top of a pane docked beside the transcript.

newpanecommandtoastprompt
v0.1.0MITupdated 2026-10-06jeffzi/cc-heads-up
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · heads-up
│ ┃ heads-up ✕ › fix the failing auth test and add an audit log call │ ┃ Your prompt draft shows here as you type. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /heads-up │ ⎿ heads-up: closed │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · heads-up
Your prompt draft shows here as you type.
README

heads-up

A Claude Code mod that mirrors your prompt draft at the top of a pane docked beside the transcript, so you can keep your eyes up while you type.

What it does

Claude Code anchors the prompt box to the bottom of the terminal. If looking down at it all day strains your neck, heads-up copies the draft, cursor included, into the top rows of a side pane. You keep typing in the real prompt box as usual. The pane only shows what you type.

Install

Run this inside Claude Code:

/plugin install heads-up --marketplace jeffzi/cc-heads-up

Claude Code then asks you to add the jeffzi/cc-heads-up marketplace, and then to pick a scope for the install.

Use

The pane opens by itself when an interactive session starts. Type /heads-up to close it, and again to open it. A pane you close stays closed for the rest of the session.

Claude Code decides where the pane sits:

  • It docks beside the transcript only in the fullscreen layout, in a terminal at least 110 columns wide. Narrower, it shows a one-line note instead of your draft.
  • Opening by itself at session start needs 144 columns. Once you have opened the pane with /heads-up, 110 columns are enough, until you next close it by hand. Below the floor, a toast tells you to type /heads-up.

While the prompt box is empty, the pane shows a short hint. Once you type, it shows the full draft, one pane line per draft line, with long lines wrapped and the cursor drawn inverted.

Limits

  • The prompt box does not move. heads-up shows a copy of the draft; you still type in the box at the bottom.
  • Completion menus, such as slash commands and file mentions, still open at the prompt.
  • Claude Code picks the side of the screen the pane docks on.
  • Outside the fullscreen layout the pane cannot dock. That includes the main-screen layout, which is what tmux uses by default.
  • When the draft is taller than the pane, the row with the cursor can sit out of view.

Development

The mod is TypeScript that Claude Code loads from source. There is no build step.

  1. Install the dependencies:
   npm install
  1. Set up the API declarations, then quit Claude Code once it has started:
   npm run declarations

The type checks need Claude Code's API declarations. They belong to Claude Code and are not in this repository. This command starts the pinned Claude Code with the folder loaded as a mod, which writes them to .claude-plugin/types/. It needs a logged-in Claude Code. If you skip this step, npm run check fails and names the command.

Then run the tests and the checks:

npm test
npm run check

Without a logged-in Claude Code, skip the declarations check and the two type checks. CI does the same:

LEFTHOOK_EXCLUDE=declarations,type,typelint npm run check

License

MIT

Source 4 files
hooks/register.tsx 228 lines
1import { atom, read, update } from "claude-code";
2import type {
3  EngineInterface,
4  Frozen,
5  NextResult,
6  On,
7  PaneCloseInput,
8  PromptBox,
9  Register,
10  UiOpenResult,
11} from "claude-code";
12
13import type { MirroredDraft } from "../types";
14import { drawPane, PANE_ID } from "./pane";
15
16const COMMAND = "heads-up";
17const DOCK_COLUMNS = 50;
18const INLINE_ROWS = 1;
19
20const NO_DRAFT: MirroredDraft = { text: "", cursor: 0, order: 0 };
21
22const isClosed = atom({ plugin: "heads-up", key: "isClosed" } as const, false);
23const draft = atom({ plugin: "heads-up", key: "draft" } as const, NO_DRAFT);
24const changeCount = atom({ plugin: "heads-up", key: "changeCount" } as const, 0);
25const isFailureLogged = { plugin: "heads-up", key: "isFailureLogged" } as const;
26
27/**
28 * Asks Claude Code to open the pane, or to seat it when it is open but not yet drawn.
29 *
30 * @param $ - The engine interface of the calling hook.
31 * @returns Whether the pane is drawn, or Claude Code's reason it waits.
32 */
33function openPane($: EngineInterface): Promise<UiOpenResult> {
34  return $.ui.open({ id: PANE_ID, title: "Heads-up", columns: DOCK_COLUMNS, rows: INLINE_ROWS });
35}
36
37/**
38 * Closes the pane when it is open and drawn, and otherwise opens or seats it, going by Claude
39 * Code's own record of the pane rather than anything the mod remembers.
40 *
41 * @param $ - The engine interface of the command hook.
42 * @returns `closed`, `opened`, or Claude Code's reason the pane still cannot be placed.
43 */
44async function togglePane($: EngineInterface): Promise<string> {
45  const panes = await $.ui.panes();
46  if (panes.some((pane) => pane.id === PANE_ID && pane.isPlaced)) {
47    await $.ui.close({ id: PANE_ID });
48    return "closed";
49  }
50  const opened = await openPane($);
51  await update($, isClosed, () => false);
52
53  return opened.isPlaced ? "opened" : opened.reason;
54}
55
56/**
57 * Writes a failure to record the draft to Claude Code's debug log, the first time one happens
58 * this session; later failures add nothing.
59 *
60 * @param $ - The engine interface of the calling hook.
61 * @param error - What the failed recording threw.
62 * @returns Once the failure is logged, or known to be logged already.
63 * @throws When the mod's own `$.state` cannot be read or written.
64 */
65async function reportFailure($: EngineInterface, error: unknown): Promise<void> {
66  const held = await $.state.get(isFailureLogged);
67  if (held.value === true) return;
68  const written = await $.state.set(isFailureLogged, true, { ifVersion: held.version });
69  if (!written.isSet) return;
70  const reason = error instanceof Error ? error.message : "an unknown error";
71  $.ui.log(`heads-up could not record the prompt draft; the pane keeps the last one: ${reason}`, {
72    to: "debug",
73  });
74}
75
76/**
77 * Hands a change to the prompt box its order, one past the last change's, so that its draft is
78 * recorded only if no later change's draft is recorded first.
79 *
80 * @param $ - The engine interface of the calling hook.
81 * @returns The change's order, or `undefined` when it could not be handed one; the failure is
82 *   then reported and the change is not mirrored.
83 * @throws When the failure cannot be reported either.
84 */
85async function beginChange($: EngineInterface): Promise<number | undefined> {
86  try {
87    return await update($, changeCount, (count) => count + 1);
88  } catch (error) {
89    await reportFailure($, error);
90    return undefined;
91  }
92}
93
94/**
95 * Records the prompt box `box` resolves to as the draft the pane mirrors, unless a later change's
96 * draft is already recorded. A failure is reported rather than thrown, so the hook that records
97 * still returns Claude Code's own answer.
98 *
99 * @param $ - The engine interface of the calling hook.
100 * @param order - The change's order from `beginChange`; `undefined` records nothing.
101 * @param box - Resolves to the prompt box after the change.
102 * @returns Once the draft is recorded, or the failure reported.
103 * @throws When a failure cannot be reported.
104 */
105async function recordDraft(
106  $: EngineInterface,
107  order: number | undefined,
108  box: () => Promise<PromptBox>,
109): Promise<void> {
110  if (order === undefined) return;
111  try {
112    const { text, cursor } = await box();
113    await update($, draft, (current) =>
114      current.order > order ? current : { text, cursor, order },
115    );
116  } catch (error) {
117    await reportFailure($, error);
118  }
119}
120
121/**
122 * Runs a hook's `next` and records the prompt box after it, returning exactly what `next` gave.
123 *
124 * @param $ - The engine interface of the calling hook.
125 * @param next - Claude Code's answer for the hook, called once.
126 * @param box - Resolves to the prompt box after the change, given `next`'s answer.
127 * @returns What `next` resolved to.
128 * @throws What `next` throws or rejects with, or when a failure to record cannot be reported.
129 */
130async function mirrorAround<T>(
131  $: EngineInterface,
132  next: () => Promise<T>,
133  box: (answer: T) => Promise<PromptBox>,
134): Promise<T> {
135  const order = await beginChange($);
136  const answer = await next();
137  await recordDraft($, order, () => box(answer));
138
139  return answer;
140}
141
142/**
143 * Mirrors the prompt box into the pane after every change the person makes to it, and after
144 * every prompt and slash command, without changing what Claude Code answers for any of them.
145 *
146 * @param on - Claude Code's hook registrar.
147 */
148function mirrorDraft(on: On): void {
149  // An edit's own answer is the box the editor shows after it; a submit's or a command's answer
150  // carries no box, so those read it back.
151  on("prompt.edit", ($, e, next) =>
152    mirrorAround(
153      $,
154      () => next(e),
155      (edited) => Promise.resolve(edited),
156    ),
157  );
158  on("prompt.submit", ($, e, next) =>
159    mirrorAround(
160      $,
161      () => next(e),
162      () => $.prompt.read(),
163    ),
164  );
165  on("command.run", ($, e, next) =>
166    mirrorAround(
167      $,
168      () => next(e),
169      () => $.prompt.read(),
170    ),
171  );
172}
173
174/**
175 * Whether a close of the pane stays remembered for the rest of the session, so that the next
176 * session start, a hot reload's included, does not open the pane unasked. The person's close
177 * (mark or key) and a plugin's `$.ui.close` are remembered once carried out; a close a hook
178 * beneath denied is not, and neither is an `unload`, which drops a pane rather than closing it.
179 *
180 * @param e - The close as the `ui.close` hook heard it.
181 * @param closed - What the rest of the chain answered for the close.
182 * @returns Whether to mark the pane closed.
183 */
184export function remembersClose(e: Frozen<PaneCloseInput>, closed: NextResult<"ui.close">): boolean {
185  return e.origin.kind !== "unload" && closed.deny === undefined;
186}
187
188/**
189 * Wires the session-start pane open, the `/heads-up` toggle, the `ui.close` memory of a
190 * person-closed pane, the prompt-draft mirroring, and the `Pane` render.
191 *
192 * @param on - Claude Code's hook registrar.
193 */
194export const register: Register = (on) => {
195  // A plugin's hooks nest first-registered outermost: the mirror must wrap the `/heads-up` hook,
196  // which answers without `next`, or the box is never read back after `/heads-up` runs.
197  mirrorDraft(on);
198
199  on("session.start", async ($, e, next) => {
200    await $.command.register({
201      name: COMMAND,
202      description: "Open or close the heads-up pane",
203      immediate: true,
204    });
205    if (e.isInteractive && !(await read($, isClosed))) {
206      const opened = await openPane($);
207      if (!opened.isPlaced) $.ui.toast("Type /heads-up to show the heads-up pane");
208    }
209
210    return next(e);
211  });
212
213  on("command.run", { command: COMMAND }, async ($) => ({ text: await togglePane($) }));
214
215  on("ui.close", { id: PANE_ID }, async ($, e, next) => {
216    const closed = await next(e);
217    if (remembersClose(e, closed)) {
218      await update($, isClosed, () => true);
219    }
220
221    return closed;
222  });
223
224  on("ui.render", { component: "Pane", requestId: PANE_ID }, async ($, e) =>
225    drawPane($.ui.resolve(e), e.props.placement, await read($, draft)),
226  );
227};
228
hooks/pane.tsx 71 lines
1import type { Elements, PromptBox, RenderElement, RenderPropsOf, RenderSurface } from "claude-code";
2
3import { layoutDraft } from "./layout";
4
5/** The id the mod's one pane is opened, drawn and closed under. */
6export const PANE_ID = "heads-up";
7
8/** The element table of whichever surface draws the pane, from `$.ui.resolve(e)`. */
9type SurfaceElements = Elements[RenderSurface];
10
11const INLINE_NOTICE =
12  "heads-up needs the fullscreen layout at 110 columns or more to dock; /heads-up closes it";
13
14const EMPTY_HINT = "Your prompt draft shows here as you type.";
15
16/**
17 * Draws the docked pane's draft area, keyed `draft`: one wrapping line per draft line with the
18 * character under the cursor inverted, or a dim hint while the prompt box is empty.
19 *
20 * @param elements - The drawing surface's element table.
21 * @param draft - The prompt box to mirror.
22 * @returns The draft area.
23 */
24function drawDraft(elements: SurfaceElements, draft: PromptBox): RenderElement {
25  const { Box, Text } = elements;
26  if (draft.text === "") {
27    return (
28      <Box key="draft" flexDirection="column">
29        <Text dimColor>{EMPTY_HINT}</Text>
30      </Box>
31    );
32  }
33  const { lines, cursorLine, cursor } = layoutDraft(draft.text, draft.cursor);
34
35  return (
36    <Box key="draft" flexDirection="column">
37      {lines.map((line, index) =>
38        index === cursorLine ? (
39          <Text wrap="wrap">
40            {cursor.before}
41            <Text inverse>{cursor.under}</Text>
42            {cursor.after}
43          </Text>
44        ) : (
45          <Text wrap="wrap">{line}</Text>
46        ),
47      )}
48    </Box>
49  );
50}
51
52/**
53 * Draws the pane for where the surface seated it: the draft area in the dock, or one dim line
54 * inline, where the pane is too short to mirror a draft.
55 *
56 * @param elements - The drawing surface's element table, from `$.ui.resolve(e)`.
57 * @param placement - Where the surface seated the pane, from the `Pane` render props.
58 * @param draft - The prompt box to mirror in the dock.
59 * @returns The pane's tree.
60 */
61export function drawPane(
62  elements: SurfaceElements,
63  placement: RenderPropsOf["Pane"]["placement"],
64  draft: PromptBox,
65): RenderElement {
66  if (placement === "dock") return drawDraft(elements, draft);
67  const { Text } = elements;
68
69  return <Text dimColor>{INLINE_NOTICE}</Text>;
70}
71
hooks/layout.ts 66 lines
1/** The cursor's display line, split around the cell the pane draws inverted. */
2export type CursorCell = {
3  /** The line's text before the cursor. */
4  before: string;
5  /** The whole character under the cursor, or a space when the cursor ends its line. */
6  under: string;
7  /** The line's text after the character under the cursor. */
8  after: string;
9};
10
11/** A draft laid out for the pane: its display lines and where the cursor sits in them. */
12export type DraftLayout = {
13  /** One entry per draft line, unwrapped; the pane's `Text` element wraps them. */
14  lines: string[];
15  /** The index in `lines` of the line holding the cursor. */
16  cursorLine: number;
17  cursor: CursorCell;
18};
19
20/** Splits a line into user-perceived characters: an emoji sequence or a flag is one segment. */
21const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
22
23// Walks the segments rather than calling `containing`: in Claude Code's engine, `containing` at
24// the start of a character made of several code units returns it merged with the one before it.
25function characterAt(line: string, column: number): Intl.SegmentData | undefined {
26  for (const part of graphemes.segment(line)) {
27    if (column < part.index + part.segment.length) {
28      return part;
29    }
30  }
31  return undefined;
32}
33
34/**
35 * Lays out a draft as display lines, one per draft line, with the cursor's line split around the
36 * character under the cursor.
37 *
38 * An empty draft gives a single empty line with the cursor at its end.
39 *
40 * @param text - The draft, its lines separated by "\n".
41 * @param cursor - The cursor's offset in `text`, in UTF-16 code units. An offset inside a
42 *   user-perceived character (a surrogate pair, an emoji with a skin tone or variation selector, a
43 *   flag, a joined emoji sequence) moves back to that character's start, so the cell covers the
44 *   whole character.
45 * @returns The display lines and the cursor's line split before, under and after the cursor.
46 */
47export function layoutDraft(text: string, cursor: number): DraftLayout {
48  const head = text.slice(0, cursor);
49  const lineStart = head.lastIndexOf("\n") + 1;
50  const breakAfter = text.indexOf("\n", cursor);
51  const line = text.slice(lineStart, breakAfter === -1 ? text.length : breakAfter);
52  const cell = characterAt(line, cursor - lineStart);
53  return {
54    lines: text.split("\n"),
55    cursorLine: head.split("\n").length - 1,
56    cursor:
57      cell === undefined
58        ? { before: line, under: " ", after: "" }
59        : {
60            before: line.slice(0, cell.index),
61            under: cell.segment,
62            after: line.slice(cell.index + cell.segment.length),
63          },
64  };
65}
66
types/index.d.ts 26 lines
1/** The prompt draft the pane mirrors, as the prompt box held it after one change. */
2export type MirroredDraft = {
3  /** The draft's text. */
4  text: string;
5  /** The cursor's offset in `text`, in UTF-16 code units. */
6  cursor: number;
7  /** The order of the change that left this draft; an earlier change's draft never replaces it. */
8  order: number;
9};
10
11// The values the heads-up mod keeps in the session's `$.state`.
12declare module "claude-code" {
13  interface PluginState {
14    "heads-up": {
15      /** Whether the person closed the pane this session; a reload keeps it, a new session starts false. */
16      isClosed: boolean;
17      /** The latest draft recorded for the pane to mirror. */
18      draft: MirroredDraft;
19      /** How many changes to the prompt box this session has begun recording; the last one's order. */
20      changeCount: number;
21      /** Whether a failure to record the draft has been written to the debug log this session. */
22      isFailureLogged: boolean;
23    };
24  }
25}
26