Skim Claude's replies: Claude marks the few sentences you must read, the mod draws them highlighted and dims the rest, and strips the marks before the reply is…

A Claude Code mod for reading long replies faster.
When Claude explains something at length, it marks the two to four sentences that carry the point. Those sentences show up highlighted in your terminal and the rest of the reply is dimmed, so you can read the highlights first and only go through the full text when you need to.
The marks never leave your screen. The mod strips them before the reply is saved, so copies, the transcript file and Claude's next turn all see plain text.

/plugin marketplace add ttqteo/claude-skim
/plugin install skim@claude-skim
Tested on Claude Code 2.1.287 and newer.
/skim turns the dimming off for the session (you'll see "skim off" in the status area). Run it again to turn it back on. Highlights still show either way.

The mod adds one short, fixed section to the system prompt asking Claude to wrap must-read sentences in ==double equals==. It is about 170 tokens, and since the text never changes it stays in the prompt cache.
On some organization accounts (a Team plan, for one), Claude Code's built-in security plugin keeps user plugins out of the system prompt. When that happens skim sends the same instructions alongside your prompt instead, hidden from the transcript: the full text with your first message, then a one-line reminder (about 40 tokens) with each message after that.
When a reply comes in, the mod pulls the marks out, keeps them aside (keyed by a hash of the cleaned text, so a resumed session still draws them), and stores the reply without them. The terminal view draws the marked sentences on a tinted background. There is a cap of 4 marks per reply; past that, or if a mark is very long, it falls back to plain text, since highlighting everything shows nothing.
Headless runs (claude -p) are not asked to mark anything.
plugins/skim/hooks/.skim started inside arcterm, my cockpit for running coding agents, and it is still developed there. This repo is the published copy. Issues and pull requests are welcome here and I'll carry them back.
MIT
hooks/register.ts 198 lines1// skim: highlights the sentences of a reply worth reading (highlight-core.ts). claude marks them, the mod strips the
2// marks before the reply is stored and draws them in the terminal alone, dimming the rest of an explanation.
3// arcterm ships it beside its own mods (`wsh install-agent-hooks`); it is also published on its own as claude-skim.
4import { atom, read, update } from "claude-code";
5import type { EngineInterface, Register } from "claude-code";
6import type { Ask } from "../types";
7import {
8 askOf,
9 hashText,
10 HIGHLIGHT_SECTION,
11 isAskInView,
12 keepMarks,
13 layout,
14 parseMarks,
15 rowsFromAsk,
16 shownMarks,
17 submitContext,
18} from "./highlight-core";
19import { drawAskLine, drawHighlighted, paletteFor } from "./highlight-row";
20import type { Palette } from "./highlight-row";
21
22// the width a row is laid out for where the surface has not measured
23const DEFAULT_COLUMNS = 80;
24
25// the columns a reply's text loses to the bullet before it
26const REPLY_INDENT = 2;
27
28// the marks of stored replies, by the hash of the text stored, kept across sessions so a resumed one still draws them
29const STORE_KEY = "highlightMarks";
30
31// how often the band learns whether the question has scrolled out of view: a render hook may not write state
32const ASK_POLL_MS = 250;
33
34// an explanation draws dimmed but for its highlighted sentences, until /skim turns that off for the session
35const isSkimOff = atom({ plugin: "skim", key: "isSkimOff" } as const, false);
36const SKIM_OFF_STATUS = "skim off";
37const ask = atom({ plugin: "skim", key: "ask" } as const, null);
38const isAskHidden = atom({ plugin: "skim", key: "isAskHidden" } as const, false);
39
40// module copies, refilled at session.start after a reload
41let kept: [string, string[]][] = [];
42let byHash = new Map<string, string[]>();
43let palette: Palette = paletteFor(undefined);
44let askNow: Ask | null = null;
45// what the ask's row last reported about its rows; the timer carries it into state
46let isAskOutOfView = false;
47// whether this load's prompt.compose hook ran on an interactive prompt, and whether a prompt already carried the ask
48let isComposed = false;
49let hasTold = false;
50let isInteractive = true;
51
52function isKept(value: unknown): value is [string, string[]][] {
53 return (
54 Array.isArray(value) &&
55 value.every((v) => Array.isArray(v) && typeof v[0] === "string" && Array.isArray(v[1]))
56 );
57}
58
59async function themeOf($: EngineInterface): Promise<unknown> {
60 return (await $.config.list()).find((row) => row.key === "theme")?.value;
61}
62
63async function setAsk($: EngineInterface, next: Ask | null) {
64 askNow = next;
65 isAskOutOfView = false;
66 await update($, isAskHidden, () => false);
67 await update($, ask, () => next);
68}
69
70export const register: Register = (on) => {
71 on("session.start", async ($, e, next) => {
72 isInteractive = e.isInteractive;
73 const stored = await $.store.get(STORE_KEY);
74 kept = isKept(stored) ? stored : [];
75 byHash = new Map(kept);
76 palette = paletteFor(await themeOf($));
77 askNow = await read($, ask);
78 $.ui.status((await read($, isSkimOff)) ? SKIM_OFF_STATUS : undefined);
79 await $.command.register({
80 name: "skim",
81 description: "Turn skim off or on: off draws explanations in full instead of dimmed around their highlighted sentences",
82 });
83 $.clock.every(ASK_POLL_MS, () => {
84 void read($, isAskHidden).then((isHidden) => {
85 if (isHidden !== isAskOutOfView) {
86 void update($, isAskHidden, () => isAskOutOfView);
87 }
88 });
89 });
90 return next(e);
91 });
92
93 on("config.set", { key: "theme" }, async ($, e, next) => {
94 const done = await next(e);
95 palette = paletteFor(await themeOf($));
96 $.ui.invalidate("ui.render");
97 return done;
98 });
99
100 // the one fixed section asking claude to mark; a headless run draws nothing, so it is not told
101 on("prompt.compose", async ($, e, next) => {
102 const composed = await next(e);
103 if (e.traits.includes("print") || e.traits.includes("bare")) {
104 return composed;
105 }
106 isComposed = true;
107 return {
108 sections: [
109 ...composed.sections,
110 { id: "skim:highlight", text: HIGHLIGHT_SECTION, scope: "session" as const },
111 ],
112 };
113 });
114
115 // the reply is stored, sent again and copied without its marks; they are kept apart under the stored text
116 on("session.append", { door: "response" }, async ($, e, next) => {
117 const content = [...e.message.content];
118 // the row's last text, which says what the reply leaves waiting on the person
119 let last: Ask | null | undefined;
120 let isChanged = false;
121 content.forEach((block, i) => {
122 if (block.type !== "text" || typeof block.text !== "string") {
123 return;
124 }
125 const { clean, marks } = parseMarks(block.text);
126 const hash = hashText(clean);
127 const question = askOf(marks);
128 last = question ? { hash, text: question } : null;
129 if (marks.length > 0) {
130 isChanged = true;
131 kept = keepMarks(kept, hash, marks);
132 content[i] = { ...block, text: clean };
133 }
134 });
135 if (isChanged) {
136 byHash = new Map(kept);
137 await $.store.set(STORE_KEY, kept);
138 }
139 if (last !== undefined && !e.agentId) {
140 await setAsk($, last);
141 }
142 return isChanged ? next({ ...e, message: { ...e.message, content } }) : next(e);
143 });
144
145 // the person answered, or moved on; and the ask rides with the prompt where the system prompt never took it
146 on("prompt.submit", async ($, e, next) => {
147 if (askNow) {
148 await setAsk($, null);
149 }
150 const context = submitContext({ isInteractive, isCommand: !!e.command, isComposed, hasTold });
151 if (!context) {
152 return next(e);
153 }
154 hasTold = true;
155 return next({ ...e, context: [...(e.context ?? []), context] });
156 });
157
158 on("command.run", { command: "skim" }, async ($) => {
159 const isOff = await update($, isSkimOff, (was) => !was);
160 $.ui.status(isOff ? SKIM_OFF_STATUS : undefined);
161 $.ui.toast(
162 isOff
163 ? "Skim off: explanations draw in full"
164 : "Skim on: an explanation dims all but its highlighted sentences"
165 );
166 return {};
167 });
168
169 // marked while it streams, stored without the marks once done: either way the marked sentences draw highlighted
170 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
171 const { clean, marks: written } = parseMarks(e.props.text);
172 const hash = hashText(clean);
173 if (askNow?.hash === hash && e.props.onScreen !== undefined) {
174 const columns = (e.viewport?.columns ?? DEFAULT_COLUMNS) - REPLY_INDENT;
175 isAskOutOfView = !isAskInView(e.props.onScreen, rowsFromAsk(clean, askNow.text, columns));
176 }
177 const all = written.length > 0 ? written : (byHash.get(hash) ?? []);
178 const marks = shownMarks(all);
179 if (e.surface !== "terminal" || e.props.isSummary || marks.length === 0) {
180 return written.length > 0 ? next({ ...e, props: { ...e.props, text: clean } }) : next(e);
181 }
182 // a brainstorm turn, its last mark the question waiting on the person, draws in full: its options are to be read
183 const isSkim = askOf(all) === null && !(await read($, isSkimOff));
184 return drawHighlighted($.ui.resolve(e), layout(clean, marks, isSkim), isSkim, palette, e.props.isFirstOfReply);
185 });
186
187 // the question the last reply asks, above the prompt while its rows are out of view (the fullscreen terminal
188 // reports them; the main screen does not, and the line never shows there)
189 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
190 const waiting = await read($, ask);
191 const isHidden = await read($, isAskHidden);
192 if (!waiting || !isHidden || e.props.hasSurvey || e.surface !== "terminal") {
193 return next(e);
194 }
195 return drawAskLine($.ui.resolve(e), waiting.text, await next(e));
196 });
197};
198hooks/highlight-core.ts 298 lines1// the must-read sentences of a reply: claude wraps them in ==…== (the prompt section below asks it to), the mod
2// strips the marks before the reply is stored and draws the sentences highlighted in the terminal alone. so a
3// copy, the transcript file and the model's next turn all read the reply without them.
4// highlight-row.tsx does the drawing; register.ts the hooks.
5
6export const MARK = "==";
7
8// what the model is told; fixed text, so the system prompt's cache never moves for it
9export const HIGHLIGHT_SECTION = [
10 "Highlighting in replies: the person reads your replies in a terminal that draws text wrapped in ==double equals== highlighted, and strips the marks everywhere else (copies, the saved transcript).",
11 "- When a reply explains something at length, wrap the 2-4 sentences the person must read to get the point in ==…==. Mark whole sentences, never a whole paragraph, and nothing in a short reply.",
12 '- In a brainstorming or design turn, mark only the question you are waiting on the person to answer, as the last marked sentence, ending with "?". Never mark your own recommendation.',
13 "- Marks go in your reply text only: never in files, code, commands, commit messages or tool inputs, and never inside code spans or blocks.",
14].join("\n");
15
16// the same ask in one line, for the prompts after the first where the system prompt never got the section
17export const HIGHLIGHT_REMINDER =
18 "Highlighting: in a long explanation wrap the 2-4 sentences the person must read in ==…== (whole sentences, never in code); in a brainstorming turn mark only the question you wait on.";
19
20export type SubmitState = { isInteractive: boolean; isCommand: boolean; isComposed: boolean; hasTold: boolean };
21
22// what a submitted prompt carries for the model, beside it and unseen. an organization's security plugin can keep a
23// user plugin out of the system prompt (prompt.compose is passed by), so until the section is seen composed the ask
24// rides with the prompts instead: whole on the first, as a reminder after. a headless run and a command get none
25export function submitContext(s: SubmitState): string | null {
26 if (!s.isInteractive || s.isCommand || s.isComposed) {
27 return null;
28 }
29 return s.hasTold ? HIGHLIGHT_REMINDER : HIGHLIGHT_SECTION;
30}
31
32// past these a mark is still stripped but drawn as plain text: a highlight that covers everything shows nothing
33export const MAX_MARKS = 4;
34export const MAX_MARK_LENGTH = 300;
35
36const FENCE = /^\s*(```|~~~)/;
37const HEADING = /^\s*(#{1,6}\s|\*\*[^*]+\*\*:?\s*$)/;
38const LIST_ITEM = /^(\s*(?:[-*+]|\d+[.)])\s+)/;
39const TABLE_ROW = /^\s*\|/;
40
41// a mark opens on == followed by a non-space and closes on a non-space followed by ==, within one line, so
42// `a == b` in prose is no mark
43function isOpen(line: string, at: number): boolean {
44 return line.startsWith(MARK, at) && at + 2 < line.length && !/\s|=/.test(line.charAt(at + 2));
45}
46
47// the closing ==, past any inline code the mark holds; -1 when the line has none
48function closeOf(line: string, from: number): number {
49 let at = from;
50 while (at < line.length) {
51 if (line[at] === "`") {
52 const end = line.indexOf("`", at + 1);
53 if (end < 0) {
54 return -1;
55 }
56 at = end + 1;
57 continue;
58 }
59 if (at > from && line.startsWith(MARK, at) && !/\s/.test(line.charAt(at - 1))) {
60 return at;
61 }
62 at++;
63 }
64 return -1;
65}
66
67// strips one line's marks outside inline code; the marked texts in order
68function stripLine(line: string): { text: string; marks: string[] } {
69 let text = "";
70 const marks: string[] = [];
71 let at = 0;
72 while (at < line.length) {
73 if (line[at] === "`") {
74 const end = line.indexOf("`", at + 1);
75 const stop = end < 0 ? line.length : end + 1;
76 text += line.slice(at, stop);
77 at = stop;
78 continue;
79 }
80 const close = isOpen(line, at) ? closeOf(line, at + 2) : -1;
81 if (close >= 0) {
82 const inner = line.slice(at + 2, close);
83 text += inner;
84 marks.push(inner);
85 at = close + 2;
86 continue;
87 }
88 text += line[at];
89 at++;
90 }
91 return { text, marks };
92}
93
94// the reply less its marks, and what they marked; code fences pass untouched
95export function parseMarks(reply: string): { clean: string; marks: string[] } {
96 const lines: string[] = [];
97 const marks: string[] = [];
98 let inFence = false;
99 for (const line of reply.split("\n")) {
100 if (FENCE.test(line)) {
101 inFence = !inFence;
102 lines.push(line);
103 continue;
104 }
105 if (inFence || !line.includes(MARK)) {
106 lines.push(line);
107 continue;
108 }
109 const stripped = stripLine(line);
110 lines.push(stripped.text);
111 marks.push(...stripped.marks);
112 }
113 return { clean: lines.join("\n"), marks };
114}
115
116// the marks that are drawn: the first few short enough to read as a sentence
117export function shownMarks(marks: readonly string[]): string[] {
118 return marks.filter((m) => m.trim() !== "" && m.length <= MAX_MARK_LENGTH).slice(0, MAX_MARKS);
119}
120
121// the question waiting on the person: the last mark, when it asks one
122export function askOf(marks: readonly string[]): string | null {
123 const last = marks.at(-1)?.trim();
124 return last && /[??]$/.test(last) ? last : null;
125}
126
127// FNV-1a over the stored text, the key the marks are kept under once the reply is stored without them
128export function hashText(text: string): string {
129 let h = 0x811c9dc5;
130 for (let i = 0; i < text.length; i++) {
131 h ^= text.charCodeAt(i);
132 h = Math.imul(h, 0x01000193);
133 }
134 return (h >>> 0).toString(16).padStart(8, "0") + text.length.toString(16);
135}
136
137// the replies' marks across sessions, newest last; a stored reply past the cap draws without its highlight
138export const MAX_KEPT = 500;
139export function keepMarks(kept: [string, string[]][], hash: string, marks: string[]): [string, string[]][] {
140 return [...kept.filter(([h]) => h !== hash), [hash, marks] as [string, string[]]].slice(-MAX_KEPT);
141}
142
143export type Run = { text: string; isMarked?: true; isBold?: true; isItalic?: true; isCode?: true; isStrike?: true };
144
145export type Segment =
146 // markdown the engine draws as it draws a reply
147 | { kind: "markdown"; text: string; isDim: boolean }
148 // one line holding a mark, drawn run by run: its list marker apart, so a wrapped item hangs
149 | { kind: "line"; prefix: string; runs: Run[]; isHeading: boolean }
150 // a blank line between the two
151 | { kind: "gap" };
152
153// a line's inline markdown as styled runs, `marked` the char ranges of the source line to highlight.
154// handles **bold**, *italic*, ~~strike~~, `code` and [text](url) (drawn as its text); anything unclosed is literal
155export function inlineRuns(line: string, marked: readonly [number, number][]): Run[] {
156 const runs: Run[] = [];
157 const isMarkedAt = (at: number) => marked.some(([from, to]) => at >= from && at < to);
158 const push = (text: string, at: number, style: Omit<Run, "text" | "isMarked">) => {
159 for (let i = 0; i < text.length; i++) {
160 const isMarked = isMarkedAt(at + i);
161 const last = runs.at(-1);
162 const same =
163 last &&
164 !!last.isMarked === isMarked &&
165 !!last.isBold === !!style.isBold &&
166 !!last.isItalic === !!style.isItalic &&
167 !!last.isCode === !!style.isCode &&
168 !!last.isStrike === !!style.isStrike;
169 if (same) {
170 last.text += text.charAt(i);
171 } else {
172 runs.push({ text: text.charAt(i), ...style, ...(isMarked ? { isMarked: true } : {}) });
173 }
174 }
175 };
176 let isBold = false;
177 let isItalic = false;
178 let isStrike = false;
179 const style = () => ({
180 ...(isBold ? { isBold: true as const } : {}),
181 ...(isItalic ? { isItalic: true as const } : {}),
182 ...(isStrike ? { isStrike: true as const } : {}),
183 });
184 let at = 0;
185 while (at < line.length) {
186 const rest = line.slice(at);
187 if (rest[0] === "`") {
188 const end = line.indexOf("`", at + 1);
189 if (end > at) {
190 push(line.slice(at + 1, end), at + 1, { ...style(), isCode: true });
191 at = end + 1;
192 continue;
193 }
194 }
195 const link = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(rest);
196 if (link) {
197 push(link[1] ?? "", at + 1, style());
198 at += link[0].length;
199 continue;
200 }
201 if (rest.startsWith("**") && (isBold || line.indexOf("**", at + 2) > at + 2)) {
202 isBold = !isBold;
203 at += 2;
204 continue;
205 }
206 if (rest.startsWith("~~") && (isStrike || line.indexOf("~~", at + 2) > at + 2)) {
207 isStrike = !isStrike;
208 at += 2;
209 continue;
210 }
211 if (rest[0] === "*" && rest[1] !== " " && (isItalic || line.indexOf("*", at + 1) > at + 1)) {
212 isItalic = !isItalic;
213 at += 1;
214 continue;
215 }
216 push(rest.charAt(0), at, style());
217 at += 1;
218 }
219 return runs;
220}
221
222// the stored reply cut into what the engine draws as markdown and the lines holding a mark. under skim a
223// heading is its own undimmed block, so the reply keeps its outline while the rest fades.
224export function layout(clean: string, marks: readonly string[], isSkim: boolean): Segment[] {
225 const segments: Segment[] = [];
226 let chunk: string[] = [];
227 // the markdown gathered since the last marked line; its blank edges become gaps, none at the reply's ends
228 const flush = (isEnd: boolean) => {
229 let lead = 0;
230 while (lead < chunk.length && (chunk[lead] ?? "").trim() === "") lead++;
231 let tail = chunk.length;
232 while (tail > lead && (chunk[tail - 1] ?? "").trim() === "") tail--;
233 if (lead > 0 && segments.length > 0 && !(isEnd && tail === lead)) segments.push({ kind: "gap" });
234 if (tail > lead) segments.push({ kind: "markdown", text: chunk.slice(lead, tail).join("\n"), isDim: isSkim });
235 if (tail < chunk.length && tail > lead && !isEnd) segments.push({ kind: "gap" });
236 chunk = [];
237 };
238 const pending = [...marks];
239 let inFence = false;
240 for (const line of clean.split("\n")) {
241 if (FENCE.test(line)) {
242 inFence = !inFence;
243 }
244 const ranges: [number, number][] = [];
245 // a table row stays the engine's: one row drawn apart would break the table
246 if (!inFence && !FENCE.test(line) && !TABLE_ROW.test(line)) {
247 let from = 0;
248 for (let want = pending[0]; want !== undefined; want = pending[0]) {
249 const at = line.indexOf(want, from);
250 if (at < 0) break;
251 ranges.push([at, at + want.length]);
252 from = at + want.length;
253 pending.shift();
254 }
255 }
256 const isHeading = !inFence && HEADING.test(line);
257 if (ranges.length === 0 && !(isSkim && isHeading)) {
258 chunk.push(line);
259 continue;
260 }
261 flush(false);
262 if (ranges.length === 0) {
263 segments.push({ kind: "markdown", text: line, isDim: false });
264 continue;
265 }
266 const heading = /^\s*#{1,6}\s+/.exec(line);
267 const item = heading ? null : LIST_ITEM.exec(line);
268 const cut = (heading ?? item)?.[0].length ?? 0;
269 const shifted = ranges.map(([a, b]) => [a - cut, b - cut] as [number, number]);
270 const runs = inlineRuns(line.slice(cut), shifted).map((run) =>
271 heading ? { ...run, isBold: true as const } : run
272 );
273 segments.push({ kind: "line", prefix: item?.[1] ?? "", runs, isHeading: !!heading });
274 }
275 flush(true);
276 return segments;
277}
278
279// whether the rows of a reply holding the question are in view: the question starts `rowsFromAsk` rows before the
280// reply's end. onScreen is `{ first, last, of }` from the site's first row, null off screen
281export function isAskInView(onScreen: { first: number; last: number; of: number } | null, rowsFromAsk: number): boolean {
282 if (onScreen === null) {
283 return false;
284 }
285 const start = Math.max(0, onScreen.of - rowsFromAsk);
286 return onScreen.last >= start && onScreen.first <= onScreen.of - 1;
287}
288
289// how many rows the reply takes from the line holding the question to its end, at `columns` wide
290export function rowsFromAsk(clean: string, ask: string, columns: number): number {
291 const lines = clean.split("\n");
292 const from = lines.findIndex((line) => line.includes(ask));
293 const width = Math.max(1, columns);
294 return lines
295 .slice(from < 0 ? lines.length - 1 : from)
296 .reduce((rows, line) => rows + Math.max(1, Math.ceil([...line].length / width)), 0);
297}
298hooks/highlight-row.tsx 98 lines1// a reply with marked sentences, as the terminal draws it: the engine's markdown for the unmarked parts, and each
2// line holding a mark run by run, the marked runs on a tinted ground. highlight-core.ts cuts the reply.
3import type { Elements, RenderElement } from "claude-code";
4import type { Run, Segment } from "./highlight-core";
5
6type RowElements = Pick<Elements["terminal"], "Box" | "Text" | "Markdown">;
7
8export type Palette = { markBg: string; markFg?: string };
9
10// a terminal colour has no alpha, so each theme gets a tint of claude's own orange made for its ground
11const DARK: Palette = { markBg: "#4a3027", markFg: "#f6f3ec" };
12const LIGHT: Palette = { markBg: "#f6dccf" };
13
14export function paletteFor(theme: unknown): Palette {
15 return String(theme ?? "").startsWith("light") ? LIGHT : DARK;
16}
17
18// inline code in claude's replies is drawn in its theme's `permission` colour
19const CODE_COLOR = "permission";
20
21function drawRun({ Text }: RowElements, run: Run, isSkim: boolean, palette: Palette): RenderElement {
22 const marked = run.isMarked ? { backgroundColor: palette.markBg, ...(palette.markFg ? { color: palette.markFg } : {}) } : {};
23 return (
24 <Text
25 {...(run.isBold ? { bold: true } : {})}
26 {...(run.isItalic ? { italic: true } : {})}
27 {...(run.isStrike ? { strikethrough: true } : {})}
28 {...(run.isCode ? { color: CODE_COLOR } : {})}
29 {...marked}
30 {...(isSkim && !run.isMarked ? { dimColor: true } : {})}
31 >
32 {run.text}
33 </Text>
34 );
35}
36
37function drawSegment(els: RowElements, segment: Segment, isSkim: boolean, palette: Palette): RenderElement {
38 const { Box, Text, Markdown } = els;
39 if (segment.kind === "gap") {
40 return <Text> </Text>;
41 }
42 if (segment.kind === "markdown") {
43 return <Markdown text={segment.text} {...(segment.isDim ? { dimColor: true } : {})} />;
44 }
45 const isDimPrefix = isSkim && !segment.isHeading;
46 return (
47 <Box>
48 {segment.prefix !== "" && <Text {...(isDimPrefix ? { dimColor: true } : {})}>{segment.prefix}</Text>}
49 <Text>{segment.runs.map((run) => drawRun(els, run, isSkim && !segment.isHeading, palette))}</Text>
50 </Box>
51 );
52}
53
54// the row as the engine frames a reply, since a hook's tree replaces the frame too: a blank line above, and the text
55// two columns in, behind the bullet on the block that opens the reply
56const BULLET = "⏺";
57
58export function drawHighlighted(
59 els: RowElements,
60 segments: Segment[],
61 isSkim: boolean,
62 palette: Palette,
63 isFirstOfReply: boolean
64): RenderElement {
65 const { Box, Text } = els;
66 return (
67 <Box marginTop={1}>
68 <Box width={2} minWidth={2} flexShrink={0}>
69 {isFirstOfReply && <Text>{BULLET}</Text>}
70 </Box>
71 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
72 {segments.map((segment) => drawSegment(els, segment, isSkim, palette))}
73 </Box>
74 </Box>
75 );
76}
77
78// the line above the prompt while the question a reply asks is out of view, over whatever the band held
79export function drawAskLine(
80 { Box, Text }: Pick<Elements["terminal"], "Box" | "Text">,
81 ask: string,
82 below: RenderElement
83): RenderElement {
84 return (
85 <Box flexDirection="column">
86 <Box gap={1}>
87 <Text color="claude">?</Text>
88 <Text wrap="truncate-end">
89 <Text color="claude">Waiting on you</Text>
90 <Text dimColor> · </Text>
91 {ask}
92 </Text>
93 </Box>
94 {below}
95 </Box>
96 );
97}
98types/index.d.ts 11 lines1// the mod's $.state contract: whether the person turned the dimming of explanations off, and the question the last
2// reply left waiting on them, with whether its rows have scrolled out of view.
3
4export type Ask = { hash: string; text: string };
5
6declare module "claude-code" {
7 interface PluginState {
8 skim: { isSkimOff: boolean; ask: Ask | null; isAskHidden: boolean };
9 }
10}
11