Read Claude Code's replies at the level you need, from every word to the answer in one sentence, with one key

Your agent writes more than you have time to read. TextFold folds every Claude Code reply to the level you need, from every word to the answer in one sentence, with one key. A mod for Claude Code, from TextFold.
<img src="../../docs/assets/textfold.gif" width="880" alt="A reply in Claude Code folding from the full text to two sentences a paragraph, then one, and back, one key at a time">
| Key | Level | What you get | |:-:|---|---| | 1 | Full | every word Claude wrote | | 2 | Brief | each point, with the reason behind it | | 3 | Points | just the points, at a glance | | 4 | Outline | the map of a long reply: its sections, and what each one says (a reply without headings shows its points) | | 5 | Gist | the answer, in one sentence |
Type the digit while the prompt is empty, or pick a level in the row above the prompt. A folded reply tells you how much of it you are seeing, and 1 brings back every word. Short replies stay whole: one under about 80 words, or one a fold would barely shorten, always shows as written.
Start folded. A session starts at Full. To start at another level, run /config and set Starting level; in the session, the row above the prompt and the keys 1 to 5 still change it.
Nothing is paraphrased. TextFold adds a short section to the system prompt that asks Claude to lead with the point: the answer first in every reply, and the sentence that states its point first in every paragraph and list item. A fold keeps what Claude put first. There is no second model and no summary, so a fold costs nothing, takes no time, and never puts words in Claude's mouth.
It needs Claude Code 2.1.287 or later, in the terminal or in the Code tab of the Desktop app.
claude plugin marketplace add chenxiachan/textfold
claude plugin install textfold@textfold
To try it from a clone without installing it, start Claude Code with claude --plugin-dir textfold/plugins/textfold.
claude -p draw nothing of a mod; there the section still applies and the replies are not folded.The tests run with claude plugin test plugins/textfold.
hooks/register.tsx 81 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Level } from '../types'
5import { fold, LEVELS, worthFolding } from './fold'
6
7// The level a session starts at: the setting's (/config, "Starting level"), Full when it has none
8const startOf = (options: Readonly<Record<string, unknown>>) => {
9 const i = LEVELS.indexOf(String(options.level) as (typeof LEVELS)[number])
10 return (i >= 0 ? i + 1 : 1) as Level
11}
12
13// What Claude is asked, so that a fold of its reply still reads: the point first, at every scale. It changes
14// the order of what Claude says, not what it says or how much.
15const STYLE = `# Replies that fold
16The person reading this session can fold your replies with one key: to each paragraph's first sentence or two, to the headings, or to the reply's first sentence. Write so that every fold still reads:
17- Open the reply with one sentence that answers, or that says what you did.
18- Open every paragraph and every list item with a sentence that states its point and reads on its own; reasons, details and caveats come after it.
19- Give a reply longer than four paragraphs a few short headings.
20This changes the order of what you write, not its content or its length.`
21
22export const register: Register = (on, options) => {
23 // The level every reply is read at: 1 Full (as written) to 5 Gist, from the setting until the reader picks another.
24 // A session keeps the one it has when the setting changes (the module reloads; the value is the host's).
25 const level = atom({ plugin: 'textfold', key: 'level' } as const, startOf(options))
26
27 // Approach B: Claude writes foldable replies, so folding needs no model call
28 on('prompt.compose', async ($, e, next) => {
29 const { sections } = await next(e)
30 return { sections: [...sections, { id: 'textfold:style', text: STYLE, scope: 'session' }] }
31 })
32
33 // Each text block of a reply, drawn by Claude Code at the chosen level, with a line saying how much is folded;
34 // a short block, or one the fold would barely shorten, as written
35 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
36 const n = await read($, level)
37 if (n <= 1) return next(e)
38 const f = fold(e.props.text, n)
39 if (!worthFolding(e.props.text, f.text)) return next(e)
40 const { Box, Text } = $.ui.resolve(e)
41 const theirs = await next({ ...e, props: { ...e.props, text: f.text || '…' } })
42 return (
43 <Box flexDirection="column">
44 {theirs}
45 <Text dimColor>{` ${LEVELS[n - 1]} · ${f.shown} of ${f.total} words · 1 unfolds`}</Text>
46 </Box>
47 )
48 })
49
50 // Above the prompt: the five levels; with the prompt empty, a digit picks one
51 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
52 if (e.props.hasSurvey) return next(e)
53 const n = await read($, level)
54 const { Box, Button, Text } = $.ui.resolve(e)
55 const theirs = await next(e)
56 const row = (
57 <Box flexDirection="row" columnGap={2}>
58 <Text dimColor>fold</Text>
59 {LEVELS.map((name, i) => (
60 <Button
61 key={`level-${i + 1}`}
62 label={name}
63 hotkey={String(i + 1)}
64 plain
65 dimColor={n !== i + 1}
66 onPress={() => update($, level, () => (i + 1) as Level)}
67 />
68 ))}
69 </Box>
70 )
71 return theirs ? (
72 <Box flexDirection="column">
73 {theirs}
74 {row}
75 </Box>
76 ) : (
77 row
78 )
79 })
80}
81hooks/fold.ts 231 lines1// A reply folded to fewer words, from its own sentences: no model call, nothing rewritten.
2//
3// It works because the style section asks Claude to open every paragraph and list item with the sentence that
4// states its point. Folding then keeps a prefix of what Claude wrote: the first sentence or two of each
5// paragraph, the first sentence of each item, the headings, and at the last level the reply's first sentence.
6
7export const LEVELS = ['Full', 'Brief', 'Points', 'Outline', 'Gist'] as const
8
9type Kind = 'heading' | 'code' | 'list' | 'table' | 'quote' | 'para' | 'rule' | 'art'
10type Block = { kind: Kind; lines: string[] }
11
12const FENCE = /^\s*(`{3,}|~{3,})/
13const HEADING = /^\s{0,3}#{1,6}\s/
14const ITEM = /^(\s*)([-*+]|\d+[.)])\s+/
15const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/
16const DELIM = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
17
18/** A GFM table starts at a row with a pipe followed by its delimiter row, with or without leading pipes, and
19 * may come straight after a paragraph. The delimiter needs a pipe, or `Title` over `---` would be one.
20 * shortcut: the header's cell count is not matched against the delimiter's, as GFM asks; add it if a reply ever
21 * folds a paragraph that only looks like a table. */
22const tableAt = (lines: string[], i: number) =>
23 (lines[i] ?? '').includes('|') && (lines[i + 1] ?? '').includes('|') && DELIM.test(lines[i + 1] ?? '')
24
25/** A markdown text as blocks: headings, code, lists, tables, quotes, paragraphs. */
26export function blocks(md: string): Block[] {
27 const out: Block[] = []
28 const lines = md.replace(/\r\n/g, '\n').split('\n')
29 let i = 0
30 while (i < lines.length) {
31 const line = lines[i] ?? ''
32 if (!line.trim()) {
33 i++
34 continue
35 }
36 const fence = FENCE.exec(line)
37 if (fence) {
38 const mark = fence[1] ?? '```'
39 const buf = [line]
40 i++
41 while (i < lines.length) {
42 const l = lines[i] ?? ''
43 buf.push(l)
44 i++
45 if (l.trim().startsWith(mark)) break
46 }
47 out.push({ kind: 'code', lines: buf })
48 continue
49 }
50 if (HEADING.test(line)) {
51 out.push({ kind: 'heading', lines: [line] })
52 i++
53 continue
54 }
55 if (RULE.test(line)) {
56 out.push({ kind: 'rule', lines: [line] })
57 i++
58 continue
59 }
60 const kind: Kind = /^\s*\|/.test(line) || tableAt(lines, i) ? 'table' : /^\s*>/.test(line) ? 'quote' : ITEM.test(line) ? 'list' : 'para'
61 const buf = [line]
62 i++
63 while (i < lines.length) {
64 const l = lines[i] ?? ''
65 if (!l.trim() || FENCE.test(l) || HEADING.test(l)) break
66 if (kind === 'para' && (ITEM.test(l) || tableAt(lines, i))) break // a paragraph that runs into a list or a table
67 buf.push(l)
68 i++
69 }
70 const art = kind === 'para' && buf.length > 1 && buf.slice(0, -1).every((l) => ART_ROW.test(l))
71 out.push({ kind: art ? 'art' : kind, lines: buf })
72 }
73 return out
74}
75
76// A row of a drawing, as Pi draws a mermaid block before the fold sees it: one inline code span, then a hard break.
77const ART_ROW = /^(`+).*\1 {2}$/
78
79const ABBREV = /\b(?:e\.g|i\.e|etc|vs|cf|al|approx|Dr|Mr|Ms|Fig|Eq|No)\.$/i
80
81/** The text's first sentence, and whether more follows it. Inline code is never cut; a Chinese or Japanese stop
82 * ends a sentence without a space after it. */
83export function firstSentence(text: string): { head: string; more: boolean; rest: string } {
84 let inCode = false
85 for (let i = 0; i < text.length; i++) {
86 const c = text[i] ?? ''
87 if (c === '`') {
88 inCode = !inCode
89 continue
90 }
91 if (inCode) continue
92 if ('。!?'.includes(c)) return cut(text, i + 1)
93 if (!'.!?'.includes(c)) continue
94 const after = /^([*_)"'”’\]]*)(\s+|$)/.exec(text.slice(i + 1))
95 if (!after) continue // "3.5", "a.b", "...": not a sentence's end
96 if (ABBREV.test(text.slice(0, i + 1))) continue
97 return cut(text, i + 1 + (after[1] ?? '').length)
98 }
99 return { head: text.trim(), more: false, rest: '' }
100}
101
102function cut(text: string, at: number) {
103 const rest = text.slice(at).trim()
104 return { head: balance(text.slice(0, at).trim()), more: rest.length > 0, rest }
105}
106
107/** Closes the bold or code span a cut left open. */
108function balance(s: string) {
109 if ((s.match(/\*\*/g) ?? []).length % 2) s += '**'
110 if ((s.match(/`/g) ?? []).length % 2) s += '`'
111 return s
112}
113
114function sentences(text: string, n: number) {
115 let rest = text.trim()
116 const out: string[] = []
117 let more = false
118 for (let k = 0; k < n && rest; k++) {
119 const s = firstSentence(rest)
120 out.push(s.head)
121 rest = s.rest
122 more = s.more
123 }
124 return { text: out.join(' '), more }
125}
126
127/** A list's items: the marker, the indent and the item's text (its continuation lines joined). */
128function items(b: Block) {
129 const out: { indent: number; marker: string; text: string }[] = []
130 for (const line of b.lines) {
131 const m = ITEM.exec(line)
132 if (m) out.push({ indent: (m[1] ?? '').length, marker: m[2] ?? '-', text: line.slice(m[0].length) })
133 else if (out.length) out[out.length - 1]!.text += ' ' + line.trim()
134 }
135 return out
136}
137
138export function words(md: string) {
139 const plain = md.replace(/```[\s\S]*?```/g, ' ')
140 const cjk = (plain.match(/[-ヿ㐀-鿿豈-]/g) ?? []).length
141 const latin = (plain.replace(/[-ヿ㐀-鿿豈-]/g, ' ').match(/[A-Za-z0-9][\w'’-]*/g) ?? []).length
142 return cjk + latin
143}
144
145/** Below this many words a reply is read whole: there is nothing in "Done, the tests pass." to fold. */
146export const MIN_WORDS = 80
147/** A fold must hide at least this share of a reply, or the reader would not see that anything changed. */
148export const MIN_HIDDEN = 0.25
149
150/** Words as reading effort: a Chinese or Japanese character reads as about 1/1.6 of an English word. */
151function reading(md: string) {
152 const plain = md.replace(/```[\s\S]*?```/g, ' ')
153 const cjk = (plain.match(/[-ヿ㐀-鿿豈-]/g) ?? []).length
154 return words(md) - cjk + cjk / 1.6
155}
156
157/** Whether a reply is worth showing folded: long enough to need it, and the fold hides enough of it. */
158export function worthFolding(md: string, folded: string) {
159 const all = reading(md)
160 return all >= MIN_WORDS && 1 - reading(folded) / all >= MIN_HIDDEN
161}
162
163const MORE = ' …'
164
165/** The text at a level: 1 Full (as written), 2 Brief, 3 Points, 4 Outline, 5 Gist. */
166export function fold(md: string, level: number): { text: string; total: number; shown: number } {
167 const total = words(md)
168 if (level <= 1) return { text: md, total, shown: total }
169 const bs = blocks(md)
170 // Outline is a reply's headings and the first sentence under each. A reply written without
171 // headings has no sections to outline, so it reads as Points: the first sentence of each
172 // paragraph, nothing dropped. Without this it fell to the reply's first sentence, which is
173 // exactly what Gist shows, and level 4 stopped being a level of its own.
174 if (level === 4 && !bs.some((b) => b.kind === 'heading')) level = 3
175 const out: string[] = []
176 let underHeading = true // at Outline, a section shows its heading and its first sentence
177 let first = true // at Gist, the reply shows its first sentence
178 for (const b of bs) {
179 if (level === 5 && !first) break
180 switch (b.kind) {
181 case 'rule':
182 break
183 case 'art':
184 if (level <= 3) out.push(b.lines.join('\n')) // a picture: kept whole, never joined into one line
185 break
186 case 'heading':
187 if (level <= 4) out.push(b.lines[0] ?? '')
188 underHeading = true
189 break
190 case 'code':
191 case 'table':
192 if (level > 3) break
193 // a table is kept whole like a list's items: its rows are already short, and it is often the answer;
194 // code is kept up to Brief's 6 lines, the command a reply is about, and a longer listing folds to its line
195 if (b.kind === 'table' || b.lines.length <= 8) out.push(b.lines.join('\n'))
196 else out.push(`\`⋯ ${b.lines.length - 2} lines of code\``) // less the fences
197 break
198 case 'list': {
199 const its = items(b).filter((it) => level === 2 || it.indent === 0)
200 if (level <= 3) {
201 out.push(its.map((it) => {
202 const s = firstSentence(it.text)
203 return `${' '.repeat(it.indent)}${it.marker} ${s.head}${s.more ? MORE : ''}`
204 }).join('\n'))
205 } else if (underHeading || (level === 5 && first)) {
206 const it = its[0]
207 if (it) {
208 const s = firstSentence(it.text)
209 out.push(s.head + (s.more || its.length > 1 ? MORE : ''))
210 }
211 underHeading = false
212 first = false
213 }
214 break
215 }
216 case 'quote':
217 case 'para': {
218 const raw = b.lines.map((l) => (b.kind === 'quote' ? l.replace(/^\s*>\s?/, '') : l).trim()).join(' ')
219 if (level >= 4 && !underHeading) break
220 const s = sentences(raw, level === 2 ? 2 : 1)
221 out.push((b.kind === 'quote' ? '> ' : '') + s.text + (s.more ? MORE : ''))
222 underHeading = false
223 first = false
224 break
225 }
226 }
227 }
228 const text = out.join('\n\n')
229 return { text, total, shown: words(text) }
230}
231types/index.d.ts 9 lines1/** The level a reply is read at: 1 Full, 2 Brief, 3 Points, 4 Outline, 5 Gist. */
2export type Level = 1 | 2 | 3 | 4 | 5
3
4declare module 'claude-code' {
5 interface PluginState {
6 textfold: { level: Level }
7 }
8}
9