SLOPSHOPPER

luismc-transcript-folds

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…

newbandrowsguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · luismc-transcript-folds
› 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 › /fold-diffs ⎿ luismc-transcript-folds: Diffs shown in full. Diffs shown (ctrl+x d) · Commands shown (ctrl+x f) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Diffs shown (ctrl+x d) · Commands shown (ctrl+x f)
README

dev-standards

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.

Install (once per machine)

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-ready

The 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-setup

Configures Claude Code itself so every session in this repo (or on this machine) behaves consistently.

Sets up:

  • A CLAUDE.md with your working conventions
  • .claude/settings.json — auto-compact window, output style, model split
  • A status line showing model, context, and rate-limit info
  • The Caveman output style
  • .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 warning
  • enabledPlugins — turns on luismc-artifacts for the project (project target only; other entries are kept, and an entry you set to false stays)
  • On every run, at user level: 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 decide

Model 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-setup

A 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:

  • A monorepo, scaffolded and ready to run
  • CI on GitHub Actions — lint, typecheck, tests, and format all gating merges
  • Secret scanning, both as a pre-commit hook and in CI
  • A dependency-boundary check, so layers can't silently reach past each other
  • Contract-first API scaffolding with generated client code
  • A job queue with atomic enqueue — no dual-write bugs between your database and your queue
  • Consent-gated analytics wired to an endpoint you control, not a vendor's
  • Three-tier environment promotion (dev → staging → production)

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-setup

Builds a complete authentication and authorization stack on top of a repo tech-stack-setup already generated.

Sets up:

  • Sessions, login, and signup flows
  • MFA and step-up authentication
  • Passkeys and account recovery
  • Consent tracking and data-subject rights (access, export, erasure)
  • Abuse prevention and rate limiting
  • Full audit logging — designed to survive account erasure
  • The test suites that prove all of the above actually holds

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 code

Keeps 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 built

Three 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 hard

Installs 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:

  • Weighing a real architectural or technology tradeoff
  • A second opinion before committing to a risky approach
  • Triaging and prioritizing findings from a security, performance, or compliance audit — real severity and exploitability, not a tool's default rating

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 it

Two skills that make "write the spec first" actually practical, for anything that touches more than one file.

/luismc-specs:spec-new

Interviews 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-check

Validates 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 mechanical

Rules 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 transcript

Folds 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.

  • Chords: ctrl+x d toggles folded diffs, ctrl+x f toggles folded commands.
  • Slash commands: /fold-diffs and /fold-commands flip the same two switches.
  • Settings (under /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).

The two standards behind project-setup

tech-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.


One-time setup for this repo itself

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.

Source 9 files
hooks/transcript_folds.tsx 258 lines
1// 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}
258
hooks/fold/change.ts 198 lines
1// 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}
198
hooks/fold/options.ts 64 lines
1// 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}
64
hooks/fold/shell.ts 154 lines
1// 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}
154
hooks/fold/summary.ts 124 lines
1// 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}
124
hooks/fold/tally.ts 84 lines
1// 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}
84
hooks/fold/lines.ts 123 lines
1// 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}
123
hooks/fold/width.ts 177 lines
1// 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}
177
types/state.d.ts 45 lines
1// 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