SLOPSHOPPER

textfold

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

bandrowsprompt
★ 729v0.2.3Apache-2.0updated 2026-10-26chenxiachan/textfold/plugins/textfold
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · textfold
› fix the failing auth test and add an audit log call ⏺ 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 ⟨Claude Code's own drawing⟩ fold 1: Full 2: Brief 3: Points 4: Outline 5: Gist ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ fold 1: Full 2: Brief 3: Points 4: Outline 5: Gist
README

TextFold for Claude Code

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.

Why you can trust a fold

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.

Install

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.

Good to know

  • The section changes the order of what Claude writes, not what it says or how much. Check on your own work that Claude does it as well as before.
  • Turning the mod on changes the system prompt, so the prompt cache starts over once in that session.
  • The VS Code extension and 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.

Source 3 files
hooks/register.tsx 81 lines
1import { 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}
81
hooks/fold.ts 231 lines
1// 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}
231
types/index.d.ts 9 lines
1/** 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