Folds long diffs and finished shell commands to one line in the transcript. A diff over a set number of changed lines draws as one dim line with its path…

Your personal toolkit for building software the right way, every time.
This repo publishes dev-standards — a private Claude Code plugin marketplace. Install it once per machine, then reach for its plugins from any project: to scaffold a new product on solid foundations, to write specs before you build, or to run a rigorous audit against what already exists. No more starting from a blank repo and hoping you remembered everything.
claude plugin marketplace add luis-mc/dev-standards
claude plugin install luismc-audits@dev-standards
claude plugin install luismc-agents@dev-standards
claude plugin install luismc-specs@dev-standards
claude plugin install luismc-project-setup@dev-standards
claude plugin install luismc-artifacts@dev-standards
claude plugin install luismc-delivery@dev-standards
claude plugin install luismc-transcript-folds@dev-standards
A machine that registered the marketplace under another name uses that name after the @.
Private repo — this uses your existing git/gh credentials, nothing extra to configure.
Already have older versions of these plugins installed? Remove them first (claude plugin uninstall <old-name>), then reinstall from the list above — Claude Code tracks plugins by name, so renamed ones need a clean install.
luismc-project-setup — take a repo from empty to production-readyThe plugin you reach for first. Three skills, meant to be run in order, that turn a blank folder into a repository with a coherent tech stack, working CI, authentication, and a properly configured coding agent.
/luismc-project-setup:claude-setupConfigures Claude Code itself so every session in this repo (or on this machine) behaves consistently.
Sets up:
CLAUDE.md with your working conventions.claude/settings.json — auto-compact window, output style, model split.claude/rules/agents-and-integration.md — how agents are run and work is integrated (replaces model-delegation.md).claude/rules/clarifying-questions.md — gather every clarifying question and ask them all at once; written on every install and re-run.claude/rules/delivery-timing.md — ETA and train-timing rules; every rule line in the three rule files ends with <!-- check: DL-nn --> / Q-n naming the DELIVERYSPEC id that enforces it, or <!-- advice -->.delivery/config.json (from assets/delivery-config.json, never overwritten) and a .delivery/ line in .gitignore; luismc-delivery in the project's enabledPlugins; a user-level no-tomorrow.mjs Stop entry offered for removal; and a table mapping your memory notes to the rule ids that now enforce them (reported only, nothing deleted).claude/hooks/effort_drift.py — a SessionStart alias-drift warningenabledPlugins — turns on luismc-artifacts for the project (project target only; other entries are kept, and an entry you set to false stays)luismc-transcript-folds in your enabledPlugins and its two chords (ctrl+x d, ctrl+x f) merged into ~/.claude/keybindings.json; an existing entry or binding is never changed, and a chord bound to something else is shown as a conflict for you to decideModel split: the main session runs on Opus and does the thinking and planning; subagents run on Sonnet and write the code. Every project gets it, and the skill asks whether to enforce it for all your projects too. Each run it also asks whether each model should run at medium or high effort, with medium preselected for both Opus and the code agents (Sonnet), and writes your answer to the settings and to the rule file. Cloud agents, which run Opus and have no per-model entry, default to medium through a top-level effortLevel.
The rule file also carries the integrate-once, deploy-once workflow and cites the scripts/delivery/* tools that tech-stack-setup 12+ generates. The alias-drift warning tells you at session start when the model an alias now resolves to has no saved effort, so you can re-run the skill. Every skill in the marketplace also pins its own model and effort in its frontmatter, from one table in claude-setup/assets/model-profiles.json.
You choose whether this applies to the current project, your user-level ~/.claude/ config, or both. It never overwrites a CLAUDE.md you've already written by hand, and never changes a settings key you've already set — your deliberate choices are always left alone. The one exception is the two model-split keys: if one holds a different model, it's shown to you as a conflict to resolve rather than left or replaced silently.
/luismc-project-setup:tech-stack-setupA guided wizard that helps you choose a technology stack, then generates a working repository around it — language, framework, database, hosting, and every third-party provider, all filtered so every option offered is compatible with what you've already picked.
What you get, once it's done generating:
Since STACKSPEC 6.0.0 it also generates how work gets delivered. Every branch runs one gate command that writes a result you can verify against the branch's current head and base, a pre-push hook runs the fast guards, and a merge script joins branches and stops at a conflict it cannot resolve. The ready pull request runs the only full CI run, end-to-end tests included, and a CI watcher follows it by commit; the default-branch run that follows skips a tree CI has already tested.
Since 7.0.0 the gate installs from the lockfile before it checks anything, stops at the first failure and gives up after 30 minutes. A merged train's branches, gate results and worktrees are removed automatically, and only the newest build artifact of each name (an installer, a bundle, a report) is kept on GitHub.
Since 7.1.0 the gate fetches its base first and runs only the end-to-end specs a change affects, the pre-push hook compares with the branch a push forks from, scanner checksums are pinned, runners are a repository setting, and a default-branch run skips merges that differ only in documentation. Branches add change notes instead of editing spec versions; the release folds them.
Since 8.0.0 each delivery layer does only its share: a gate result stays valid while the base moves only by docs, lockfile changes widen only when they cannot be tied to a package, the join runs a quick check instead of a second full gate, a train branch is frozen while its ready PR's CI runs, runner problems are warned about, build-artifact workflows run only when their inputs change, each train's release notes carry its delivery log, and a product that ships a packaged program has CI start it and every worker it starts (the one new MUST).
Supports four language tracks: TypeScript, Go, Rust, and .NET. Every vendor choice — Postgres or MySQL, Neon or RDS, Vercel or Fly — is yours; the wizard never tells you which one to pick, only which ones are compatible with each other.
It also runs in reverse: point it at an existing repo and it audits your current setup section by section, telling you exactly where it conforms, where it's drifted, and offers to repair what it safely can — always showing you the diff first, and never touching anything that already conforms.
It does not build authentication. That's a deliberate, separate step — see below.
/luismc-project-setup:auth-setupBuilds a complete authentication and authorization stack on top of a repo tech-stack-setup already generated.
Sets up:
This skill refuses to run until tech-stack-setup has been run first, and tells you exactly what's missing if it hasn't — a project's authentication choices depend entirely on the stack underneath it, so there's nothing reliable to build on otherwise.
It can also audit an existing auth implementation against the same standard, the same way tech-stack-setup audits the rest of the stack.
luismc-artifacts — see what is live, running and waiting without reading codeKeeps a project's status in front of you: one feature artifact per feature and one full-progress artifact for the whole project, each with a status checklist, written content and a stamp of the last update. A feature artifact is a standalone HTML file in the repository (docs/artifacts/feature-<slug>.html) that opens by double-click and is never published; the full-progress artifact is the project's one claude.ai artifact, published once at the end of each turn (claude.ai allows 200 publishes per account per day), and it links to the feature files as local files. They are generated from a record in the project's repository (docs/artifacts/ in the working tree: a manifest, a task registry, content; the plugin writes these files and never commits or pushes them, the project commits when it chooses), to the binding ARTIFACTSPEC, so status is typed in one place and every view agrees. A deleted full-progress artifact is recreated from the record without duplicates. The artifacts use the project's own design, found and confirmed with you, or a default one.
Five skills:
/luismc-artifacts:feature-artifact-new — asks the feature, its apps and task prefix, finds the project's design, and writes the content and tasks of its standalone file./luismc-artifacts:full-progress-artifact-new — creates the project's one full-progress artifact./luismc-artifacts:task-add — records work as a task before it starts./luismc-artifacts:task-status-update — changes a status through the script; Full Progress is published once, at the end of the turn./luismc-artifacts:project-artifact-check — checks the artifacts against the spec and explains each finding with its requirement ID.Hooks queue status changes when a pull request is opened, readied or merged, when CI finishes, and on a production deploy, and the end of a turn waits until those are applied and Full Progress is published; it also asks for a Running task when work was started in the background without one. Every activity is a task, the session's own included, and Built means finished but not yet released (Live means released). The plugin delivers that rule itself at every session start in any project with a record, so it is always current; installing creates nothing in a project. luismc-project-setup's claude-setup enables the plugin in every project it sets up.
luismc-audits — a rigorous second opinion on what you've already builtThree focused review skills. Each one reads your code, finds and verifies real issues, and hands you a severity-ranked report — it never changes code for you. That's intentional: findings get triaged and prioritized by a human before anything is touched.
/luismc-audits:security-audit — reviews authentication, authorization, cryptography, and data handling against major security frameworks (NIST, FedRAMP, SOC 2, ISO 27001, NIS2). Traces real exploit paths rather than flagging theoretical ones./luismc-audits:performance-audit — hunts down slow database queries, N+1 patterns, memory leaks, and anything hurting Core Web Vitals. Every finding is backed by measured or calculated impact, not a guess./luismc-audits:compliance-audit — maps how personal, health, and financial data moves through your app against GDPR, CCPA/CPRA, HIPAA, and SOC 2. Verifies enforcement actually happens server-side, not just in the UI.Run any of these any time — on a brand-new project, an inherited codebase, or right before a release.
luismc-agents — a second opinion when a call is genuinely hardInstalls the architect subagent, available in any project once the plugin is installed. Bring it in for the decisions that don't have an obviously right answer:
It gives you a direct recommendation with its reasoning, not an exhaustive list of options — and it flags decisions that are really yours to make (anything that changes scope, is hard to reverse, or is a business tradeoff) rather than deciding them for you.
luismc-specs — think it through before you build itTwo skills that make "write the spec first" actually practical, for anything that touches more than one file.
/luismc-specs:spec-newInterviews you about a feature, component, or migration — scope boundaries, what data must never be lost, how it should fail when a dependency goes down, what happens on retries and duplicate requests — then writes a precise, numbered specification to specs/<feature-name>.md.
Every requirement in the spec gets a citable ID and a named test that proves it. Nothing goes in that can't be verified. The skill deliberately stops once the spec is written — implementation happens in a fresh session, so the spec gets built against, not the discarded options from the interview.
/luismc-specs:spec-checkValidates the numbering and cross-references in any spec — this repo's own binding specs included. Catches duplicate or out-of-order section numbers, orphaned subsections, and references that point nowhere. Run it right after writing a spec, and again after every revision.
luismc-delivery — make the delivery rules mechanicalRules written in a memory note get forgotten; this plugin turns them into checks. Hooks (Stop, PreToolUse, PostToolUse, SessionStart) and a watcher read a per-repository ledger, .delivery/ledger.json, and enforce trains and CI (merge on green, never --auto, one train in CI at a time), agent launches (ETA breakdown, concurrency caps, cloud first), owner questions (the questions wizard, context inside each question), and attribution. The rules are numbered in docs/specs/delivery/DELIVERYSPEC.md (DL-nn, Q-n); the rule files claude-setup installs tag each line with the id that enforces it.
/luismc-project-setup:claude-setup enables it for a project, writes .delivery/config.json (caps, work root, time zone, label patterns, ETA table, outbound deny list, no_attribution, runners[] with their repair commands) and git-ignores .delivery/. When a CI runner the project uses goes offline, runner_plan.py runners reports it at once, then every 5 minutes repairs the runner (restart its service or container), re-checks it and only then re-runs the CI that needed it; every step is in the ledger (D6a). The plugin is built in claude/plugins/delivery.
The config is checked by delivery_config.py validate (a JSON Schema in the plugin; unknown keys warn) and delivery_doctor.py self-checks the setup (config, gh sign-in, time zone, hooks, ledger). From 0.2.0 the watcher obeys the config (watcher.push_trains|open_prs|merge_on_green, runner_plan.cancel_replaced_runs| hold_dependabot_during_train|heavy_jobs_yield) and starts the runner guard itself, and enforcement sets the mode (block/warn), the block ceiling and an optional .delivery/blocks.log. See claude/plugins/delivery/README.md and DELIVERYSPEC section 6.
luismc-transcript-folds — keep long diffs and commands from flooding the transcriptFolds the transcript to one line per change. A diff of more than a few lines is drawn as » path:line (+added −removed), and a finished shell command as a single row with its outcome. A band above the prompt shows the two switches and a tally of files and lines changed this session. claude-setup enables it and binds its chords for you.
ctrl+x d toggles folded diffs, ctrl+x f toggles folded commands./fold-diffs and /fold-commands flip the same two switches./config): foldAbove (default 5; diffs with this many changed lines or fewer are drawn in full), marker (starts each folded line; default »), compactCounts (put the counts on the tool call's own row).project-setuptech-stack-setup and auth-setup aren't improvising — they build against two binding, versioned specifications:
STACKSPEC — the technology-neutral shape every generated repo must have: contract-first APIs, safe queueing, proper retention and erasure, consent-gated analytics, and more. It names no vendor and no framework, only properties, so it applies equally whether you're on TypeScript or Rust.AUTHSPEC — the same idea, applied to authentication and authorization.Because they specify properties instead of products, your project doesn't vendor a copy of either spec. Instead, tech-stack-setup writes a Stack Profile (docs/specs/stack-profile.md) recording which spec versions your project conforms to, plus the vendors and settings you chose. That file is what later runs — including auth-setup — read to understand your project, and what makes "has this drifted from the standard?" an answerable question instead of a guess.
This repository carries its own secret scanning, separate from anything it generates for other projects. After cloning, activate the pre-commit hook once:
git config core.hooksPath .githooks
Without this step, secrets are still caught in CI — just after they've already been pushed, rather than before they're committed.
hooks/transcript_folds.tsx 258 lines1// luismc-transcript-folds: folds long diffs and finished shell commands to one
2// line in the transcript. This file is glue: every decision (what counts as a
3// change, what folds, the words of each line) is a call into ./fold/*.ts,
4// which has no engine import and is tested with node:test.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import { EDIT_TOOLS, SHELL_TOOLS, changeOf, foldedFileChange, isOverThreshold } from './fold/change.ts'
10import { normalizeOptions } from './fold/options.ts'
11import { commandRow, outputView, planCommand, runningCommand, shellOutcome } from './fold/shell.ts'
12import { compactCounts, compactRow, cutPieces, diffLine, filesLine, toolLabel } from './fold/summary.ts'
13import type { Piece, Tone } from './fold/summary.ts'
14import { EMPTY_TALLY, addToTally, bandParts, switchSentence } from './fold/tally.ts'
15import type { Tally } from './fold/tally.ts'
16
17// Session state. Its contract is types/state.d.ts. The two switches are also
18// written to $.store (below) so they survive a restart.
19const diffsFolded = atom({ plugin: 'luismc-transcript-folds', key: 'diffsFolded' } as const, true)
20const commandsFolded = atom({ plugin: 'luismc-transcript-folds', key: 'commandsFolded' } as const, true)
21const tally = atom({ plugin: 'luismc-transcript-folds', key: 'tally' } as const, EMPTY_TALLY as Tally)
22const root = atom({ plugin: 'luismc-transcript-folds', key: 'root' } as const, '')
23const COUNTS = { plugin: 'luismc-transcript-folds', key: 'counts' } as const
24const OUTCOMES = { plugin: 'luismc-transcript-folds', key: 'outcomes' } as const
25
26const STORE_KEYS = { diffs: 'diffsFolded', commands: 'commandsFolded' } as const
27
28type Which = 'diffs' | 'commands'
29
30// Flips a switch, keeps it past the session, and answers its new state.
31async function flip($: Pick<EngineInterface, 'state' | 'store'>, which: Which): Promise<boolean> {
32 // The scan reads update's source by name, so each switch has its own call.
33 const isFolded =
34 which === 'diffs'
35 ? await update($, diffsFolded, value => !value)
36 : await update($, commandsFolded, value => !value)
37 try {
38 await $.store.set(STORE_KEYS[which], isFolded)
39 } catch {
40 // The switch still holds for this session.
41 }
42 return isFolded
43}
44
45function toneProps(tone: Tone): Record<string, string | boolean> {
46 if (tone === 'dim') return { dimColor: true }
47 if (tone === 'success') return { color: 'success' }
48 if (tone === 'error') return { color: 'error' }
49 return {}
50}
51
52// One row of pieces, each in its tone, cut to the row.
53function row(Text: any, pieces: readonly Piece[]) {
54 return (
55 <Text wrap="truncate">
56 {pieces.map(piece => (
57 <Text {...toneProps(piece.tone)}>{piece.text}</Text>
58 ))}
59 </Text>
60 )
61}
62
63function inputCommand(input: unknown): string {
64 if (typeof input === 'object' && input !== null && 'command' in input) {
65 const command = (input as { command?: unknown }).command
66 if (typeof command === 'string') return command
67 }
68 return ''
69}
70
71export const register: Register = (on, options) => {
72 const opts = normalizeOptions(options)
73
74 on('session.start', async ($, e, next) => {
75 const held = await $.state.get({ plugin: 'luismc-transcript-folds', key: 'root' })
76 if (held.value === undefined || held.value === '') {
77 await $.state.set({ plugin: 'luismc-transcript-folds', key: 'root' }, e.cwd)
78 }
79 try {
80 const savedDiffs = await $.store.get(STORE_KEYS.diffs)
81 if (typeof savedDiffs === 'boolean') {
82 await $.state.set({ plugin: 'luismc-transcript-folds', key: 'diffsFolded' }, savedDiffs)
83 }
84 const savedCommands = await $.store.get(STORE_KEYS.commands)
85 if (typeof savedCommands === 'boolean') {
86 await $.state.set({ plugin: 'luismc-transcript-folds', key: 'commandsFolded' }, savedCommands)
87 }
88 } catch {
89 // No stored choice: the defaults stand.
90 }
91 await $.command.register({ name: 'fold-diffs', description: 'Fold or show long diffs in the transcript' })
92 await $.command.register({ name: 'fold-commands', description: 'Fold or show finished shell commands in the transcript' })
93 return next(e)
94 })
95
96 on('command.run', { command: 'fold-diffs' }, async $ => {
97 const isFolded = await flip($, 'diffs')
98 return { text: switchSentence('diffs', isFolded) }
99 })
100
101 on('command.run', { command: 'fold-commands' }, async $ => {
102 const isFolded = await flip($, 'commands')
103 return { text: switchSentence('commands', isFolded) }
104 })
105
106 // The tally and the per-call records. The result is the engine's own and is
107 // handed back untouched.
108 on('tool.call', async ($, e, next) => {
109 const done = await next(e)
110 if (done.deny !== undefined || done.isError === true) return done
111 const tool = String(e.tool)
112 const isEdit = EDIT_TOOLS.includes(tool)
113 const isShell = SHELL_TOOLS.includes(tool)
114 if (!isEdit && !isShell) return done
115 try {
116 const id = e.tool_use_id
117 if (isShell) {
118 await $.state.set({ ...OUTCOMES, id }, shellOutcome(done.result, false))
119 }
120 const change = changeOf(tool, done.result)
121 if (change !== null) {
122 await $.state.set({ ...COUNTS, id }, change)
123 await update($, tally, current => addToTally(current, change))
124 }
125 } catch {
126 // A record that cannot be kept never costs the call.
127 }
128 return done
129 }).catch(($, e, next) => next(e))
130
131 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
132 const { tool, output, isErrored } = e.props
133 const isEdit = EDIT_TOOLS.includes(tool)
134 const isShell = SHELL_TOOLS.includes(tool)
135 if (!isEdit && !isShell) return next(e)
136 const diffs = await read($, diffsFolded)
137 const columns = e.viewport?.columns ?? 0
138
139 if (isEdit) {
140 const change = foldedFileChange(tool, output, isErrored, diffs, opts.foldAbove)
141 if (change === null) return next(e)
142 const { Box, Text } = $.ui.resolve(e)
143 // Compact mode: the counts ride the tool row; nothing is drawn here.
144 if (opts.compactCounts) return <Box display="none" />
145 const dir = await read($, root)
146 const pieces: Piece[] = [{ text: ' ⎿ ', tone: 'dim' }, ...diffLine(change, dir, opts.marker)]
147 return <Box>{row(Text, columns > 0 ? cutPieces(pieces, columns) : pieces)}</Box>
148 }
149
150 const commands = await read($, commandsFolded)
151 const change = changeOf(tool, output)
152 const plan = planCommand({ isRunning: false, isErrored, isInterrupted: false, output }, change, diffs, opts.foldAbove)
153 const { Box, Text } = $.ui.resolve(e)
154 if (commands && plan === 'fold') return <Box display="none" />
155
156 // A call that edited files: its own output, then one line for the files,
157 // in place of the engine's per-file diff.
158 if (diffs && !isErrored && plan === 'fold' && change !== null && isOverThreshold(change, opts.foldAbove)) {
159 const view = outputView(output, 20)
160 const nodes = view.lines.map(line => (line.isStderr ? <Text color="error">{line.text}</Text> : <Text>{line.text}</Text>))
161 if (view.more > 0) nodes.push(<Text dimColor>{`… +${view.more} lines`}</Text>)
162 nodes.push(row(Text, filesLine(change, opts.marker)))
163 return <Box flexDirection="column">{nodes}</Box>
164 }
165 return next(e)
166 })
167
168 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
169 const props = e.props
170 const isEdit = EDIT_TOOLS.includes(props.tool)
171 const isShell = SHELL_TOOLS.includes(props.tool)
172 if (!isEdit && !isShell) return next(e)
173 const columns = e.viewport?.columns ?? 0
174
175 if (isEdit) {
176 if (!opts.compactCounts || props.isRunning) return next(e)
177 const diffs = await read($, diffsFolded)
178 const change = foldedFileChange(props.tool, props.output, props.isErrored, diffs, opts.foldAbove)
179 if (change === null) return next(e)
180 const dir = await read($, root)
181 const { Box, Text } = $.ui.resolve(e)
182 const layout = compactRow(toolLabel(props.tool, change, dir), compactCounts(change, opts.marker), Math.max(0, columns - 2))
183 const lines = layout.lines.map((pieces, index) => {
184 const lead: Piece[] = index === 0 ? [{ text: '● ', tone: 'success' }] : [{ text: ' ', tone: 'plain' }]
185 return row(Text, [...lead, ...pieces])
186 })
187 return <Box flexDirection="column">{lines}</Box>
188 }
189
190 const commands = await read($, commandsFolded)
191 if (!commands) return next(e)
192 const command = inputCommand(props.input)
193 if (command === '') return next(e)
194 const diffs = await read($, diffsFolded)
195 const change = changeOf(props.tool, props.output)
196 const plan = planCommand(
197 { isRunning: props.isRunning, isErrored: props.isErrored, isInterrupted: props.isInterrupted, output: props.output },
198 change,
199 diffs,
200 opts.foldAbove,
201 )
202 if (plan === 'engine') return next(e)
203 if (plan === 'running') {
204 const cut = runningCommand(props.tool, command, columns)
205 if (cut === command) return next(e)
206 const input = { ...(props.input as Record<string, unknown>), command: cut }
207 return next({ ...e, props: { ...props, input } })
208 }
209 const { Box, Text } = $.ui.resolve(e)
210 const outcome = shellOutcome(props.output, props.isErrored)
211 return <Box>{row(Text, commandRow(props.tool, command, outcome, change, opts.marker, columns))}</Box>
212 })
213
214 // With the diffs switch off every diff must show, so a run of calls the
215 // engine would fold into one count line is drawn expanded.
216 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
217 if (e.props.isExpanded || !e.props.calls.some(call => EDIT_TOOLS.includes(call.tool))) return next(e)
218 if (await read($, diffsFolded)) return next(e)
219 return next({ ...e, props: { ...e.props, isExpanded: true } })
220 })
221
222 // The band above the prompt: two buttons, each pressed by the chord the
223 // person gave its engine action, and the session's tally.
224 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
225 if (e.props.hasSurvey) return next(e)
226 const parts = bandParts(await read($, diffsFolded), await read($, commandsFolded), await read($, tally))
227 const { Box, Button, Text } = $.ui.resolve(e)
228 // The tally is absent before the first change.
229 const tail = parts.tally === '' ? [] : [<Text dimColor>{` · ${parts.tally}`}</Text>]
230 return (
231 <Box>
232 <Button
233 key="diffs"
234 label={parts.diffsLabel}
235 plain
236 dimColor
237 action="theme:toggleSyntaxHighlighting"
238 onPress={async () => {
239 $.ui.toast(switchSentence('diffs', await flip($, 'diffs')))
240 }}
241 />
242 <Text dimColor>{' · '}</Text>
243 <Button
244 key="commands"
245 label={parts.commandsLabel}
246 plain
247 dimColor
248 action="theme:editCustom"
249 onPress={async () => {
250 $.ui.toast(switchSentence('commands', await flip($, 'commands')))
251 }}
252 />
253 {tail}
254 </Box>
255 )
256 })
257}
258hooks/fold/change.ts 198 lines1// What a finished tool call changed, read from the tool's result record.
2// Pure: no engine import, no Node API. The record is read defensively: a
3// shape that is not the one expected is "no change", never an error.
4
5import { countCell, countHunks, countNewFile } from './lines.ts'
6import type { Hunk, LineCounts } from './lines.ts'
7
8export type ChangeKind = 'edit' | 'new' | 'notebook' | 'bash'
9
10export type Change = {
11 kind: ChangeKind
12 /** File path as the tool reported it (absolute); '' for a shell call. */
13 path: string
14 /** Every file the call changed, as far as the record names them. */
15 paths: string[]
16 /** How many files: 1 for a file tool; for a shell call all it reports. */
17 files: number
18 added: number
19 removed: number
20 /** Line of the first change; 0 when unknown (a shell call). */
21 firstLine: number
22 /** Notebook cell, named for a reader; '' otherwise. */
23 cell: string
24}
25
26export const EDIT_TOOLS: readonly string[] = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
27export const SHELL_TOOLS: readonly string[] = ['Bash', 'PowerShell']
28
29type Rec = Record<string, unknown>
30
31function isRec(value: unknown): value is Rec {
32 return typeof value === 'object' && value !== null && !Array.isArray(value)
33}
34
35function str(value: unknown): string {
36 return typeof value === 'string' ? value : ''
37}
38
39function num(value: unknown): number {
40 return typeof value === 'number' && Number.isFinite(value) ? value : 0
41}
42
43function hunksOf(value: unknown): Hunk[] {
44 if (!Array.isArray(value)) return []
45 const out: Hunk[] = []
46 for (const h of value) {
47 if (!isRec(h) || !Array.isArray(h.lines)) continue
48 out.push({
49 oldStart: num(h.oldStart),
50 oldLines: num(h.oldLines),
51 newStart: num(h.newStart),
52 newLines: num(h.newLines),
53 lines: h.lines.filter((l): l is string => typeof l === 'string'),
54 })
55 }
56 return out
57}
58
59function make(kind: ChangeKind, path: string, counts: LineCounts, extra: Partial<Change> = {}): Change {
60 return {
61 kind,
62 path,
63 paths: path === '' ? [] : [path],
64 files: 1,
65 added: counts.added,
66 removed: counts.removed,
67 firstLine: counts.firstLine,
68 cell: '',
69 ...extra,
70 }
71}
72
73function fromFileEdit(output: Rec): Change | null {
74 if (output.staged === true) return null
75 const path = str(output.filePath)
76 if (path === '') return null
77 const hunks = hunksOf(output.structuredPatch)
78 if (hunks.length === 0) return null
79 return make('edit', path, countHunks(hunks))
80}
81
82function fromWrite(output: Rec): Change | null {
83 if (output.staged === true) return null
84 const path = str(output.filePath)
85 if (path === '') return null
86 if (output.type === 'create') {
87 return make('new', path, countNewFile(str(output.content)))
88 }
89 const hunks = hunksOf(output.structuredPatch)
90 if (hunks.length === 0) return null
91 return make('edit', path, countHunks(hunks))
92}
93
94function fromNotebook(output: Rec): Change | null {
95 if (str(output.error) !== '') return null
96 const path = str(output.notebook_path)
97 if (path === '') return null
98 const mode = str(output.edit_mode) || 'replace'
99 const counts = countCell({
100 mode,
101 oldSource: str(output.old_source),
102 newSource: str(output.new_source),
103 })
104 const id = str(output.cell_id)
105 const cell = id !== '' ? `cell ${id}` : mode === 'insert' ? 'new cell' : 'cell'
106 return make('notebook', path, counts, { cell })
107}
108
109function fromShell(output: Rec): Change | null {
110 const diff = output.bashEditDiff
111 if (!isRec(diff) || diff.unavailable === true || diff.skipped === true) return null
112 const list = Array.isArray(diff.files) ? diff.files : []
113 const more = Math.max(0, Math.floor(num(diff.moreFiles)))
114 let added = 0
115 let removed = 0
116 const paths: string[] = []
117 for (const f of list) {
118 if (!isRec(f)) continue
119 const counts = countHunks(hunksOf(f.hunks))
120 added += counts.added
121 removed += counts.removed
122 const p = str(f.filePath)
123 if (p !== '' && !paths.includes(p)) paths.push(p)
124 }
125 if (Array.isArray(diff.changedFiles)) {
126 for (const p of diff.changedFiles) {
127 if (typeof p === 'string' && p !== '' && !paths.includes(p)) paths.push(p)
128 }
129 }
130 const files = list.length + more
131 if (files === 0) return null
132 return {
133 kind: 'bash',
134 path: '',
135 paths,
136 files,
137 added,
138 removed,
139 firstLine: 0,
140 cell: '',
141 }
142}
143
144/**
145 * The change a finished call made, or null when it made none we can count
146 * (another tool, an unreadable record, an edit held for review).
147 */
148export function changeOf(tool: string, output: unknown): Change | null {
149 if (!isRec(output)) return null
150 switch (tool) {
151 case 'Edit':
152 case 'MultiEdit':
153 return fromFileEdit(output)
154 case 'Write':
155 return fromWrite(output)
156 case 'NotebookEdit':
157 return fromNotebook(output)
158 case 'Bash':
159 return fromShell(output)
160 default:
161 return null
162 }
163}
164
165/**
166 * The change a file tool's result draws folded, or null when the engine draws
167 * it: the diffs switch is off, the call errored, it changed nothing countable,
168 * or the change does not exceed the threshold. A shell call is not a file
169 * tool and is never folded here.
170 */
171export function foldedFileChange(
172 tool: string,
173 output: unknown,
174 isErrored: boolean,
175 diffsFolded: boolean,
176 foldAbove: number,
177): Change | null {
178 if (!diffsFolded || isErrored) return null
179 if (!EDIT_TOOLS.includes(tool)) return null
180 const change = changeOf(tool, output)
181 if (change === null || !isOverThreshold(change, foldAbove)) return null
182 return change
183}
184
185/** Added plus removed lines. */
186export function changedLines(change: Pick<Change, 'added' | 'removed'>): number {
187 return change.added + change.removed
188}
189
190/**
191 * Whether a change is drawn folded: its changed lines exceed the threshold.
192 * A threshold of 0 folds every diff that changes anything.
193 */
194export function isOverThreshold(change: Pick<Change, 'added' | 'removed'>, foldAbove: number): boolean {
195 const n = changedLines(change)
196 return n > 0 && n > foldAbove
197}
198hooks/fold/options.ts 64 lines1// The plugin's three settings and a path shown relative to the project root.
2// Pure: no engine import, no Node API.
3
4export type FoldOptions = {
5 foldAbove: number
6 marker: string
7 compactCounts: boolean
8}
9
10export const DEFAULT_MARKER = '»'
11
12export const DEFAULT_OPTIONS: FoldOptions = {
13 foldAbove: 5,
14 marker: DEFAULT_MARKER,
15 compactCounts: false,
16}
17
18/**
19 * The settings as the engine hands them over (any value may be missing or of
20 * the wrong type) made safe: a bad number gives the default, a fraction is
21 * rounded down, a negative is 0; the marker may be empty.
22 */
23export function normalizeOptions(raw: Readonly<Record<string, unknown>> | undefined): FoldOptions {
24 const src = raw ?? {}
25 const n = src.foldAbove
26 const foldAbove =
27 typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.floor(n)) : DEFAULT_OPTIONS.foldAbove
28 const marker = typeof src.marker === 'string' ? src.marker : DEFAULT_OPTIONS.marker
29 const compactCounts = typeof src.compactCounts === 'boolean' ? src.compactCounts : DEFAULT_OPTIONS.compactCounts
30 return { foldAbove, marker, compactCounts }
31}
32
33/** The marker followed by a space, or nothing when the marker is empty. */
34export function markerPrefix(marker: string): string {
35 const m = marker.trim()
36 return m === '' ? '' : `${m} `
37}
38
39function slashes(path: string): string {
40 return path.replace(/\\/g, '/')
41}
42
43function isWindowsPath(path: string): boolean {
44 return /^[A-Za-z]:\//.test(path)
45}
46
47/**
48 * A path relative to the project root when it lies under it, else the path
49 * as it came (backslashes kept as given). Drive letters compare without case.
50 * The root itself shows as ".".
51 */
52export function relativePath(root: string, path: string): string {
53 if (root === '') return path
54 const r = slashes(root).replace(/\/+$/, '')
55 const p = slashes(path)
56 if (r === '') return path
57 const fold = isWindowsPath(r) || isWindowsPath(p)
58 const a = fold ? r.toLowerCase() : r
59 const b = fold ? p.toLowerCase() : p
60 if (b === a) return '.'
61 if (b.startsWith(a + '/')) return p.slice(r.length + 1)
62 return path
63}
64hooks/fold/shell.ts 154 lines1// The outcome of a finished shell call and the one row a folded call draws.
2// Pure: no engine import, no Node API.
3
4import type { Change } from './change.ts'
5import { isOverThreshold } from './change.ts'
6import { countLines } from './lines.ts'
7import { cutPieces, filesLine, piecesWidth } from './summary.ts'
8import type { Piece } from './summary.ts'
9import { cutToWidth, displayWidth, oneLine } from './width.ts'
10
11export type Outcome = {
12 kind: 'output' | 'empty' | 'error'
13 /** `3 lines`, `no output`, or the first line of the error. */
14 text: string
15}
16
17type Rec = Record<string, unknown>
18
19function isRec(value: unknown): value is Rec {
20 return typeof value === 'object' && value !== null && !Array.isArray(value)
21}
22
23function str(value: unknown): string {
24 return typeof value === 'string' ? value : ''
25}
26
27export function linesWord(n: number): string {
28 return n === 1 ? '1 line' : `${n} lines`
29}
30
31/** The first line of a text that has any characters; '' for none. */
32export function firstLine(text: string): string {
33 for (const raw of text.split('\n')) {
34 const line = raw.replace(/\r$/, '').trim()
35 if (line !== '') return line
36 }
37 return ''
38}
39
40/**
41 * What a finished shell call came to. An errored call is the first line of
42 * its error (the whole record is a string, or an object with stderr then
43 * stdout), `failed` when there is no text at all; a call that went well is the
44 * number of lines it printed on either stream, or `no output`.
45 */
46export function shellOutcome(output: unknown, isErrored: boolean): Outcome {
47 if (isErrored) {
48 const text = typeof output === 'string' ? output : isRec(output) ? str(output.stderr) || str(output.stdout) : ''
49 const line = firstLine(text)
50 return { kind: 'error', text: line === '' ? 'failed' : line }
51 }
52 if (!isRec(output)) {
53 const text = typeof output === 'string' ? output.trimEnd() : ''
54 return text === '' ? { kind: 'empty', text: 'no output' } : { kind: 'output', text: linesWord(countLines(text)) }
55 }
56 const stdout = str(output.stdout).trimEnd()
57 const stderr = str(output.stderr).trimEnd()
58 const n = countLines(stdout) + countLines(stderr)
59 if (n === 0) return { kind: 'empty', text: 'no output' }
60 return { kind: 'output', text: linesWord(n) }
61}
62
63export type OutputLine = { text: string; isStderr: boolean }
64
65export type OutputView = { lines: OutputLine[]; more: number }
66
67/**
68 * A shell call's own output as lines (stdout, then stderr), at most `max`
69 * of them; `more` counts those left out. Used where a call that edited files
70 * is drawn with its output but without the per-file diff.
71 */
72export function outputView(output: unknown, max: number): OutputView {
73 if (!isRec(output)) return { lines: [], more: 0 }
74 const all: OutputLine[] = []
75 for (const [key, isStderr] of [['stdout', false], ['stderr', true]] as const) {
76 const text = str(output[key]).trimEnd()
77 if (text === '') continue
78 for (const raw of text.split('\n')) all.push({ text: raw.replace(/\r$/, ''), isStderr })
79 }
80 const keep = Math.max(0, Math.floor(max))
81 return { lines: all.slice(0, keep), more: Math.max(0, all.length - keep) }
82}
83
84export type CommandCall = {
85 isRunning: boolean
86 isErrored: boolean
87 isInterrupted: boolean
88 output: unknown
89}
90
91export type CommandPlan = 'engine' | 'running' | 'fold'
92
93/**
94 * What to draw for a shell call. A running call keeps the engine's row (with
95 * its command cut); an interrupted or backgrounded one is left alone; a call
96 * that edited files folds only while the diffs switch is on and the change is
97 * over the threshold, so a diff that would show is never hidden by the row.
98 */
99export function planCommand(
100 call: CommandCall,
101 edits: Change | null,
102 diffsFolded: boolean,
103 foldAbove: number,
104): CommandPlan {
105 if (call.isRunning) return 'running'
106 if (call.isInterrupted) return 'engine'
107 if (isRec(call.output)) {
108 if (call.output.interrupted === true) return 'engine'
109 if (typeof call.output.backgroundTaskId === 'string' && call.output.backgroundTaskId !== '') return 'engine'
110 }
111 if (edits !== null) {
112 if (!diffsFolded || !isOverThreshold(edits, foldAbove)) return 'engine'
113 }
114 return 'fold'
115}
116
117/** A command on one line, cut to `room` cells. */
118export function commandText(command: string, room: number): string {
119 return cutToWidth(oneLine(command), room)
120}
121
122/**
123 * The row of a folded call: `● Bash(git status) ⎿ 3 lines · » 2 files (+4 −1)`.
124 * The command takes the room the rest leaves; the whole row is cut to `columns`.
125 */
126export function commandRow(
127 tool: string,
128 command: string,
129 outcome: Outcome,
130 edits: Change | null,
131 marker: string,
132 columns: number,
133): Piece[] {
134 const bullet: Piece = { text: '●', tone: outcome.kind === 'error' ? 'error' : 'success' }
135 const joint: Piece = { text: ' ⎿ ', tone: 'dim' }
136 const result: Piece = { text: outcome.text, tone: outcome.kind === 'error' ? 'error' : 'dim' }
137 const tail: Piece[] = edits === null ? [] : [{ text: ' · ', tone: 'dim' }, ...filesLine(edits, marker)]
138 const open: Piece = { text: ` ${tool}(`, tone: 'plain' }
139 const close: Piece = { text: ')', tone: 'plain' }
140 const fixed = piecesWidth([bullet, open, close, joint, result, ...tail])
141 const room = columns > 0 ? Math.max(1, columns - fixed) : displayWidth(oneLine(command))
142 const cmd: Piece = { text: commandText(command, room), tone: 'plain' }
143 const row = [bullet, open, cmd, close, joint, result, ...tail]
144 return columns > 0 ? cutPieces(row, columns) : row
145}
146
147/** A running call's command, cut so the engine's own row stays on one line. */
148export function runningCommand(tool: string, command: string, columns: number): string {
149 const one = oneLine(command)
150 if (columns <= 0) return one
151 // The engine's row: bullet, space, `Tool(`, the command, `)`.
152 return cutToWidth(one, Math.max(1, columns - displayWidth(tool) - 5))
153}
154hooks/fold/summary.ts 124 lines1// The text of a folded diff, as pieces with a tone each, and cutting pieces to
2// a width. Pure: no engine import, no Node API.
3
4import type { Change } from './change.ts'
5import { displayWidth, cutToWidth, ELLIPSIS } from './width.ts'
6import { markerPrefix, relativePath } from './options.ts'
7
8export type Tone = 'dim' | 'success' | 'error' | 'plain'
9
10export type Piece = { text: string; tone: Tone }
11
12/** The minus sign used for removed counts (U+2212). */
13export const MINUS = '−'
14
15export function piecesText(pieces: readonly Piece[]): string {
16 return pieces.map(p => p.text).join('')
17}
18
19export function piecesWidth(pieces: readonly Piece[]): number {
20 return displayWidth(piecesText(pieces))
21}
22
23/** Pieces cut to at most `max` cells; the cut ends in an ellipsis in the piece it falls in. */
24export function cutPieces(pieces: readonly Piece[], max: number): Piece[] {
25 if (!(max > 0)) return []
26 if (piecesWidth(pieces) <= max) return pieces.slice()
27 const out: Piece[] = []
28 let left = max
29 for (let i = 0; i < pieces.length; i += 1) {
30 const piece = pieces[i] as Piece
31 const w = displayWidth(piece.text)
32 const isLast = i === pieces.length - 1
33 // Leave one cell for the ellipsis wherever the text will end.
34 if (w < left || (isLast && w <= left)) {
35 out.push(piece)
36 left -= w
37 continue
38 }
39 // The piece does not fit whole with the rest still to come: it ends in
40 // the ellipsis (asking for one more character forces the cut).
41 out.push({ text: cutToWidth(piece.text + ELLIPSIS, left), tone: piece.tone })
42 return out.filter(p => p.text !== '')
43 }
44 return out
45}
46
47function added(n: number): Piece {
48 return { text: `+${n}`, tone: 'success' }
49}
50
51function removed(n: number): Piece {
52 return { text: `${MINUS}${n}`, tone: 'error' }
53}
54
55/** `(+a −r)` as pieces, with the leading text that goes before the bracket. */
56export function countPieces(lead: string, addedLines: number, removedLines: number, withRemoved: boolean): Piece[] {
57 const out: Piece[] = [{ text: `${lead}(`, tone: 'dim' }, added(addedLines)]
58 if (withRemoved) {
59 out.push({ text: ' ', tone: 'dim' }, removed(removedLines))
60 }
61 out.push({ text: ')', tone: 'dim' })
62 return out
63}
64
65export function filesWord(n: number): string {
66 return n === 1 ? '1 file' : `${n} files`
67}
68
69/**
70 * The one dim line a folded file diff draws:
71 * `» path:12 (+8 −3)`, `» new path (+40)`, `» nb.ipynb cell abc (+2 −1)`.
72 */
73export function diffLine(change: Change, root: string, marker: string): Piece[] {
74 const path = relativePath(root, change.path)
75 const lead = markerPrefix(marker)
76 if (change.kind === 'new') {
77 return countPieces(`${lead}new ${path} `, change.added, 0, false)
78 }
79 if (change.kind === 'notebook') {
80 return countPieces(`${lead}${path} ${change.cell} `, change.added, change.removed, true)
81 }
82 const where = change.firstLine > 0 ? `${path}:${change.firstLine}` : path
83 return countPieces(`${lead}${where} `, change.added, change.removed, true)
84}
85
86/** `» 3 files (+12 −4)`: what a shell call that edited files adds to its line. */
87export function filesLine(change: Pick<Change, 'files' | 'added' | 'removed'>, marker: string): Piece[] {
88 return countPieces(`${markerPrefix(marker)}${filesWord(change.files)} `, change.added, change.removed, true)
89}
90
91/** `» (+8 −3)`: the counts that follow a tool row's label in compact mode. */
92export function compactCounts(change: Pick<Change, 'added' | 'removed' | 'kind'>, marker: string): Piece[] {
93 return countPieces(markerPrefix(marker), change.added, change.removed, change.kind !== 'new')
94}
95
96const LABELS: Readonly<Record<string, string>> = {
97 Edit: 'Update',
98 MultiEdit: 'Update',
99 Write: 'Write',
100 NotebookEdit: 'Edit Notebook',
101}
102
103/** A file tool row's label: `Update(src/a.ts)`. */
104export function toolLabel(tool: string, change: Pick<Change, 'path' | 'kind'>, root: string): string {
105 const name = change.kind === 'new' ? 'Write' : (LABELS[tool] ?? tool)
106 return `${name}(${relativePath(root, change.path)})`
107}
108
109export type CompactRow = { lines: Piece[][] }
110
111/**
112 * A file tool row in compact mode: the label and the counts on one line when
113 * they fit `columns`, else the counts on the next line.
114 */
115export function compactRow(label: string, counts: readonly Piece[], columns: number): CompactRow {
116 const head: Piece = { text: label, tone: 'plain' }
117 const gap: Piece = { text: ' ', tone: 'dim' }
118 const one = [head, gap, ...counts]
119 if (columns <= 0 || piecesWidth(one) <= columns) return { lines: [one] }
120 return {
121 lines: [cutPieces([head], columns), cutPieces(counts, columns)],
122 }
123}
124hooks/fold/tally.ts 84 lines1// The session tally and the band's text. Pure: no engine import, no Node API.
2
3import type { Change } from './change.ts'
4import { MINUS, filesWord } from './summary.ts'
5import type { Piece } from './summary.ts'
6
7export type Tally = {
8 /** Paths touched, each once. */
9 files: string[]
10 /** Files a shell call changed whose paths were not reported; not de-duplicated. */
11 extra: number
12 added: number
13 removed: number
14}
15
16export const EMPTY_TALLY: Tally = { files: [], extra: 0, added: 0, removed: 0 }
17
18/** The tally with one change added: its files once each, its lines in full. */
19export function addToTally(tally: Tally, change: Pick<Change, 'paths' | 'files' | 'added' | 'removed'>): Tally {
20 const files = tally.files.slice()
21 for (const p of change.paths) {
22 if (!files.includes(p)) files.push(p)
23 }
24 const unknown = Math.max(0, change.files - change.paths.length)
25 return {
26 files,
27 extra: tally.extra + unknown,
28 added: tally.added + change.added,
29 removed: tally.removed + change.removed,
30 }
31}
32
33export function fileCount(tally: Tally): number {
34 return tally.files.length + tally.extra
35}
36
37/** True before the first change of the session. */
38export function isEmptyTally(tally: Tally): boolean {
39 return fileCount(tally) === 0 && tally.added === 0 && tally.removed === 0
40}
41
42/** `7 files +120 −34 this session`; '' before the first change. */
43export function tallyText(tally: Tally): string {
44 if (isEmptyTally(tally)) return ''
45 return `${filesWord(fileCount(tally))} +${tally.added} ${MINUS}${tally.removed} this session`
46}
47
48export const DIFFS_CHORD = 'ctrl+x d'
49export const COMMANDS_CHORD = 'ctrl+x f'
50
51export type BandParts = {
52 diffsLabel: string
53 commandsLabel: string
54 tally: string
55}
56
57/** The words of the band: two switch labels and the tally. */
58export function bandParts(diffsFolded: boolean, commandsFolded: boolean, tally: Tally): BandParts {
59 return {
60 diffsLabel: `Diffs ${diffsFolded ? 'folded' : 'shown'} (${DIFFS_CHORD})`,
61 commandsLabel: `Commands ${commandsFolded ? 'folded' : 'shown'} (${COMMANDS_CHORD})`,
62 tally: tallyText(tally),
63 }
64}
65
66/** The band as pieces, for tests and plain text: labels, separators, tally. */
67export function bandPieces(parts: BandParts): Piece[] {
68 const out: Piece[] = [
69 { text: parts.diffsLabel, tone: 'dim' },
70 { text: ' · ', tone: 'dim' },
71 { text: parts.commandsLabel, tone: 'dim' },
72 ]
73 if (parts.tally !== '') {
74 out.push({ text: ' · ', tone: 'dim' }, { text: parts.tally, tone: 'dim' })
75 }
76 return out
77}
78
79/** The one line a toggle answers (toast and slash command). */
80export function switchSentence(which: 'diffs' | 'commands', isFolded: boolean): string {
81 const name = which === 'diffs' ? 'Diffs' : 'Commands'
82 return `${name} ${isFolded ? 'folded' : 'shown in full'}.`
83}
84hooks/fold/lines.ts 123 lines1// Counting changed lines from hunks, new files and notebook cells.
2// Pure: no engine import, no Node API.
3
4export type Hunk = {
5 oldStart: number
6 oldLines: number
7 newStart: number
8 newLines: number
9 lines: readonly string[]
10}
11
12export type LineCounts = {
13 added: number
14 removed: number
15 /** Line (in the new file) of the first changed line; 0 when nothing changed. */
16 firstLine: number
17}
18
19/** Lines in a text: LF or CRLF, a missing final newline counts, "" is 0. */
20export function countLines(text: string): number {
21 if (text === '') return 0
22 const parts = text.split('\n')
23 if (parts[parts.length - 1] === '') parts.pop()
24 return parts.length
25}
26
27function isAdded(line: string): boolean {
28 return line.startsWith('+')
29}
30
31function isRemoved(line: string): boolean {
32 return line.startsWith('-')
33}
34
35/**
36 * Added and removed lines of a patch, and where the first change lies.
37 *
38 * A hunk line starts with `+`, `-` or a space; `\ No newline at end of file`
39 * and anything else counts for nothing. CR before a line break is of no
40 * matter, only the first character is read. The first changed line is found
41 * in the first hunk that changes anything: its start plus the context lines
42 * before the first `+` or `-` (a removal at the end of the file points at the
43 * line where it was).
44 */
45export function countHunks(hunks: readonly Hunk[]): LineCounts {
46 let added = 0
47 let removed = 0
48 let firstLine = 0
49 let found = false
50 for (const hunk of hunks) {
51 let offset = 0
52 let seen = false
53 for (const line of hunk.lines) {
54 if (isAdded(line)) {
55 added += 1
56 if (!found && !seen) {
57 found = true
58 seen = true
59 firstLine = Math.max(1, hunk.newStart + offset)
60 }
61 } else if (isRemoved(line)) {
62 removed += 1
63 if (!found && !seen) {
64 found = true
65 seen = true
66 firstLine = Math.max(1, hunk.newStart + offset)
67 }
68 } else if (line.startsWith(' ')) {
69 if (!found) offset += 1
70 }
71 }
72 }
73 return { added, removed, firstLine }
74}
75
76/** A new file: every line of its content is an addition, from line 1. */
77export function countNewFile(content: string): LineCounts {
78 const added = countLines(content)
79 return { added, removed: 0, firstLine: added > 0 ? 1 : 0 }
80}
81
82export type CellEdit = {
83 mode: string
84 oldSource?: string
85 newSource?: string
86}
87
88function splitLines(text: string): string[] {
89 if (text === '') return []
90 const parts = text.split('\n').map(l => (l.endsWith('\r') ? l.slice(0, -1) : l))
91 if (parts[parts.length - 1] === '') parts.pop()
92 return parts
93}
94
95/**
96 * A notebook cell edit. Insert adds the new source; delete removes the old;
97 * replace drops the lines both sources share at the start and the end and
98 * counts what is left on each side. Line numbers are cell-relative.
99 */
100export function countCell(edit: CellEdit): LineCounts {
101 const before = splitLines(edit.oldSource ?? '')
102 const after = splitLines(edit.newSource ?? '')
103 if (edit.mode === 'insert') {
104 return { added: after.length, removed: 0, firstLine: after.length > 0 ? 1 : 0 }
105 }
106 if (edit.mode === 'delete') {
107 return { added: 0, removed: before.length, firstLine: before.length > 0 ? 1 : 0 }
108 }
109 let head = 0
110 while (head < before.length && head < after.length && before[head] === after[head]) head += 1
111 let tail = 0
112 while (
113 tail < before.length - head &&
114 tail < after.length - head &&
115 before[before.length - 1 - tail] === after[after.length - 1 - tail]
116 ) {
117 tail += 1
118 }
119 const removed = before.length - head - tail
120 const added = after.length - head - tail
121 return { added, removed, firstLine: added + removed > 0 ? head + 1 : 0 }
122}
123hooks/fold/width.ts 177 lines1// Display width of text in terminal cells, and cutting text to fit a width.
2// Pure: no engine import, no Node API.
3
4export const ELLIPSIS = '…'
5
6const ZWJ = 0x200d
7
8// Code point ranges that draw two cells wide (East Asian wide and fullwidth,
9// emoji presentation).
10const WIDE: ReadonlyArray<readonly [number, number]> = [
11 [0x1100, 0x115f],
12 [0x231a, 0x231b],
13 [0x23e9, 0x23ec],
14 [0x23f0, 0x23f0],
15 [0x23f3, 0x23f3],
16 [0x25fd, 0x25fe],
17 [0x2614, 0x2615],
18 [0x2648, 0x2653],
19 [0x267f, 0x267f],
20 [0x2693, 0x2693],
21 [0x26a1, 0x26a1],
22 [0x26aa, 0x26ab],
23 [0x26bd, 0x26be],
24 [0x26c4, 0x26c5],
25 [0x26ce, 0x26ce],
26 [0x26d4, 0x26d4],
27 [0x26ea, 0x26ea],
28 [0x26f2, 0x26f3],
29 [0x26f5, 0x26f5],
30 [0x26fa, 0x26fa],
31 [0x26fd, 0x26fd],
32 [0x2705, 0x2705],
33 [0x270a, 0x270b],
34 [0x2728, 0x2728],
35 [0x274c, 0x274c],
36 [0x274e, 0x274e],
37 [0x2753, 0x2755],
38 [0x2757, 0x2757],
39 [0x2795, 0x2797],
40 [0x27b0, 0x27b0],
41 [0x27bf, 0x27bf],
42 [0x2b1b, 0x2b1c],
43 [0x2b50, 0x2b50],
44 [0x2b55, 0x2b55],
45 [0x2e80, 0x303e],
46 [0x3041, 0xa4cf],
47 [0xa960, 0xa97f],
48 [0xac00, 0xd7a3],
49 [0xf900, 0xfaff],
50 [0xfe10, 0xfe19],
51 [0xfe30, 0xfe6f],
52 [0xff00, 0xff60],
53 [0xffe0, 0xffe6],
54 [0x16fe0, 0x18aff],
55 [0x1b000, 0x1b2ff],
56 [0x1f004, 0x1f004],
57 [0x1f0cf, 0x1f0cf],
58 [0x1f18e, 0x1f18e],
59 [0x1f191, 0x1f19a],
60 [0x1f200, 0x1f320],
61 [0x1f32d, 0x1f335],
62 [0x1f337, 0x1f37c],
63 [0x1f37e, 0x1f393],
64 [0x1f3a0, 0x1f3ca],
65 [0x1f3cf, 0x1f3d3],
66 [0x1f3e0, 0x1f3f0],
67 [0x1f3f4, 0x1f3f4],
68 [0x1f3f8, 0x1f43e],
69 [0x1f440, 0x1f440],
70 [0x1f442, 0x1f4fc],
71 [0x1f4ff, 0x1f53d],
72 [0x1f54b, 0x1f54e],
73 [0x1f550, 0x1f567],
74 [0x1f57a, 0x1f57a],
75 [0x1f595, 0x1f596],
76 [0x1f5a4, 0x1f5a4],
77 [0x1f5fb, 0x1f64f],
78 [0x1f680, 0x1f6c5],
79 [0x1f6cc, 0x1f6cc],
80 [0x1f6d0, 0x1f6d2],
81 [0x1f6d5, 0x1f6d7],
82 [0x1f6dc, 0x1f6df],
83 [0x1f6eb, 0x1f6ec],
84 [0x1f6f4, 0x1f6fc],
85 [0x1f7e0, 0x1f7eb],
86 [0x1f7f0, 0x1f7f0],
87 [0x1f90c, 0x1f93a],
88 [0x1f93c, 0x1f945],
89 [0x1f947, 0x1f9ff],
90 [0x1fa70, 0x1faff],
91 [0x20000, 0x3fffd],
92]
93
94// Zero-width: combining marks, joiners, variation selectors, emoji skin tones.
95const ZERO: ReadonlyArray<readonly [number, number]> = [
96 [0x0300, 0x036f],
97 [0x0483, 0x0489],
98 [0x0591, 0x05bd],
99 [0x0610, 0x061a],
100 [0x064b, 0x065f],
101 [0x1ab0, 0x1aff],
102 [0x1dc0, 0x1dff],
103 [0x200b, 0x200f],
104 [0x2028, 0x202e],
105 [0x2060, 0x2064],
106 [0x20d0, 0x20ff],
107 [0xfe00, 0xfe0f],
108 [0xfe20, 0xfe2f],
109 [0xfeff, 0xfeff],
110 [0x1f3fb, 0x1f3ff],
111 [0xe0100, 0xe01ef],
112]
113
114function inRanges(cp: number, ranges: ReadonlyArray<readonly [number, number]>): boolean {
115 for (const [lo, hi] of ranges) {
116 if (cp < lo) return false
117 if (cp <= hi) return true
118 }
119 return false
120}
121
122/** Cells one code point takes: 0, 1 or 2. */
123export function cellWidth(cp: number): number {
124 if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0
125 if (cp < 0x300) return 1
126 if (inRanges(cp, ZERO)) return 0
127 if (inRanges(cp, WIDE)) return 2
128 return 1
129}
130
131/** Cells a text takes. A character joined by a zero-width joiner adds nothing. */
132export function displayWidth(text: string): number {
133 let total = 0
134 let joined = false
135 for (const ch of text) {
136 const cp = ch.codePointAt(0) ?? 0
137 if (cp === ZWJ) {
138 joined = true
139 continue
140 }
141 if (joined) {
142 joined = false
143 continue
144 }
145 total += cellWidth(cp)
146 }
147 return total
148}
149
150/**
151 * The text cut to at most `max` cells. A cut ends in an ellipsis that counts
152 * in the width; a text that fits is returned as it is; `max` of 0 or less
153 * gives an empty string.
154 */
155export function cutToWidth(text: string, max: number): string {
156 if (!(max > 0)) return ''
157 if (displayWidth(text) <= max) return text
158 const room = max - 1
159 let out = ''
160 let used = 0
161 let joined = false
162 for (const ch of text) {
163 const cp = ch.codePointAt(0) ?? 0
164 const w = joined ? 0 : cp === ZWJ ? 0 : cellWidth(cp)
165 joined = cp === ZWJ
166 if (used + w > room) break
167 out += ch
168 used += w
169 }
170 return out + ELLIPSIS
171}
172
173/** Whitespace runs, newlines included, folded to single spaces; ends trimmed. */
174export function oneLine(text: string): string {
175 return text.replace(/\s+/g, ' ').trim()
176}
177types/state.d.ts 45 lines1// The state luismc-transcript-folds keeps for the session ($.state), declared
2// as a contract. The two switches are also written to $.store so they survive
3// a restart.
4
5export type TranscriptFoldsTally = {
6 /** Paths touched this session, each once. */
7 files: string[]
8 /** Files a shell call changed whose paths were not reported. */
9 extra: number
10 added: number
11 removed: number
12}
13
14/** What a finished call changed, by tool_use_id. */
15export type TranscriptFoldsChange = {
16 kind: 'edit' | 'new' | 'notebook' | 'bash'
17 path: string
18 paths: string[]
19 files: number
20 added: number
21 removed: number
22 firstLine: number
23 cell: string
24}
25
26/** What a finished shell call came to, by tool_use_id. */
27export type TranscriptFoldsOutcome = {
28 kind: 'output' | 'empty' | 'error'
29 text: string
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'luismc-transcript-folds': {
35 diffsFolded: boolean
36 commandsFolded: boolean
37 tally: TranscriptFoldsTally
38 /** The project root at session start; paths are shown relative to it. */
39 root: string
40 counts: StateFamily<TranscriptFoldsChange>
41 outcomes: StateFamily<TranscriptFoldsOutcome>
42 }
43 }
44}
45