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

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.
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.
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.
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:
/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.
The mod is TypeScript that Claude Code loads from source. There is no build step.
npm install
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
hooks/register.tsx 228 lines1import { 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};
228hooks/pane.tsx 71 lines1import 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}
71hooks/layout.ts 66 lines1/** 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}
66types/index.d.ts 26 lines1/** 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