SLOPSHOPPER

reply-polish

A small, tested dotfiles framework: symlink-managed zsh, tmux, Ghostty, Git and Neovim config, with safe backups, a doctor, and a privacy scan.

newrows
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reply-polish
› 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. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Claude's reply
Done. I made refresh reject expired claims and added an audit
README

cli-workbench

tests License: MIT

English | 简体中文

A terminal workbench for AI coding agents. Claude Code, Codex, Gemini CLI, Grok CLI and the like are command-line programs that read and write plain text, so the best place to run them is a terminal you control: tmux sessions per project, an editor one key away from the agent, ripgrep, fzf and jq for text, and every setting in a Git repository you can read, diff and fork. This repository is that environment, deployed with chezmoi.

  prefix + P  ->  pick a project  ->  one tmux session, one window per repository
 ┌───────────────────────────────────┐
 │  claude / codex / gemini / grok   │   the first installed agent starts here,
 │                                   │   in the project's directory
 ├───────────────────────────────────┤
 │  shell                            │
 └───────────────────────────────────┘
  prefix + e / prefix + g: Neovim in a popup, to browse code or review changes

What makes it a workbench, not just dotfiles

Project → session, agent includedA directory under ~/dev/projects is a workspace. A folder holding several repositories is one workspace with a window per repository, each with its own agent pane. Nothing to register: it is discovered from the directory tree. Auto-detects claude, codex, gemini, grok (extend the list with WORKSPACE_SWITCH_AGENTS, pick one with WORKSPACE_SWITCH_AGENT, turn off with none).
Plain text all the way downSettings, history, notes and agent instructions are files. rg, fzf, jq, nvim and git are the tools; there is no GUI state and no database to lose. One instruction text is deployed to every agent (CLAUDE.md, AGENTS.md, GEMINI.md), with your private rules appended from an untracked file.
Safe to make publicAgents need API keys, and keys leak through dotfiles. scripts/privacy-scan runs as a pre-commit hook and again in CI, and recognises Anthropic (Claude), OpenAI, Google (Gemini) and xAI (Grok) keys, GitHub and AWS tokens, private keys, personal paths and e-mail addresses. A pre-push hook scans commit metadata (author, committer, message) too. tmux pane contents are not saved to disk by default. Secrets live in an untracked file.
ReversibleBefore every chezmoi apply, the files it would replace are backed up to ~/.cli-workbench-backup/ with a restore note.
The promises are testedThe quick start below is run end to end in a throwaway home directory on macOS and Ubuntu. The agent pane (with stand-in agents), the backup and the privacy scanner each have their own tests.

Agent-specific today: the workspace switcher starts any of the four agents; the shared instruction text reaches Claude Code, Codex and Gemini CLI (Grok CLI is not wired up: the location of its instruction file is not known); the status line (~/.claude/statusline.sh) and the mods (below) are for Claude Code only. Full agent settings files are deliberately not tracked. Codex's portable UI preferences are merged into its local configuration, see below. The agents themselves are not installed by this repository.

What this is: a personal-dotfiles repository laid out as a chezmoi source tree. You fork it, edit home/, and keep it as your own.

What this is not: a package manager, a one-click installer, a theme pack, or an agent framework. It does not install software and it does not manage secrets.

Requirements and scope

PlatformmacOS is the primary platform (tested on macOS 26, Apple Silicon). The zsh and tmux configs also pass the full test suite and the first-run flow on Ubuntu 24.04 (checked in a container); the Ghostty config and the Homebrew casks are macOS-oriented. Windows is not supported.
Shellzsh 5.8 or newer (tested with 5.9). The helper scripts are bash 3.2 compatible, i.e. the macOS system bash is enough.
tmux3.2 or newer recommended (tested with 3.5a). Older versions still load the config, minus the workspace switcher key.
Requiredgit and chezmoi (brew install chezmoi)
Optionalfzf (0.48+ for the shell integration), zoxide, starship, zsh-syntax-highlighting, jq (status line), translate-shell (the translation popup), Ghostty and a Nerd Font, Homebrew

What it changes on your machine: exactly the files under home/ that chezmoi deploys (the table below), a zsh completion cache in ~/.cache/zsh/, and backups of the files it replaces in ~/.cli-workbench-backup/<timestamp>/ (chezmoi itself keeps none; see Safety model).

Quick start

brew install chezmoi
git clone https://github.com/dishangyijiao/cli-workbench.git ~/dev/cli-workbench   # or your fork; any location works

chezmoi init --source ~/dev/cli-workbench   # use this clone as the source, and install the backup hook (below)

chezmoi diff                       # read-only: what would change in $HOME
chezmoi apply ~/.tmux.conf         # apply ONE file first, then look at the result
chezmoi apply                      # then everything

Or let chezmoi do the clone: chezmoi init <your-github-user>/cli-workbench, then the same diff and apply.

What gets deployed (the layout follows chezmoi's naming: dot_ becomes ., executable_ sets the mode, private_ makes the directory mode 700):

Source in home/TargetNotes
dot_tmux.conf, dot_tmux/scripts/~/.tmux.conf, ~/.tmux/scriptsprefix Ctrl-a, vi keys, mouse, workspace switcher with the agent pane
dot_zshrc, dot_config/private_zsh/~/.zshrc, ~/.config/zsh/{path,tmux-autostart}.zshreplaces your .zshrc; move your own tweaks to ~/.config/zsh/local.zsh first. Installers (nvm, bun, ...) append to ~/.zshrc; chezmoi diff shows that, so move such lines into local.zsh
dot_config/ghostty/config~/.config/ghostty/configCatppuccin Mocha, Nerd Font, macOS tabs title bar
dot_claude/executable_statusline.sh~/.claude/statusline.shthe Claude Code status line (project, branch, model, context, cost, rate limits); only if you use Claude Code
dot_claude/workbench-mods/~/.claude/workbench-mods/four Claude Code mods (chezmoi-guard, reply-polish, agent-state, md-open) as a local marketplace; deployed, not installed: see "Claude Code mods"
dot_codex/modify_private_config.toml~/.codex/config.tomlmerge portable status-line and completion-bell preferences; preserve other local values
.chezmoitemplates/agent-instructions.md, dot_claude/CLAUDE.md.tmpl, dot_codex/AGENTS.md.tmpl, dot_gemini/GEMINI.md.tmpl~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.gemini/GEMINI.mdthe same short text for every agent: how this workbench works (config lives in the repo, secrets stay out, one tmux session per project). Your own rules: see "Agent instructions"
dot_config/git/config~/.config/git/configportable Git settings; Git reads this file by itself, and ~/.gitconfig (identity, credentials) stays yours
dot_config/nvim/~/.config/nvimoptional Neovim setup (lazy.nvim, LSP, Telescope, Git, debugging; no AI plugin: agents run in their own pane); plugins install on first launch and need network access. Delete the directory from your fork if you have your own

Suggested order and the manual steps (Homebrew tools, tmux plugin manager) are in docs/bootstrap.md.

Defaults you may want to change first

These are opinions, not requirements. Edit the files in home/ and run chezmoi apply (or chezmoi edit --apply ~/.tmux.conf).

  • tmux: prefix is Ctrl-a (not Ctrl-b); vi-style copy mode; mouse on; windows numbered from 1. Pane contents are not saved to disk by default (that would store anything printed in a pane, tokens included); see the comment next to @resurrect-capture-pane-contents to turn it on.
  • zsh: 50,000-line shared history, case-insensitive completion, starship, zoxide, fzf and zsh-syntax-highlighting only if installed.
  • Ghostty: Catppuccin Mocha and SauceCodePro Nerd Font Mono (install the font or change the line).

Make it yours

  • Per-machine settings (extra PATH entries, a proxy, turning on the tmux chooser): copy templates/local.zsh.example to ~/.config/zsh/local.zsh. chezmoi does not manage it and it is loaded last.
  • Secrets: templates/secrets.zsh.example to ~/.config/zsh/secrets.zsh, mode 600. Never commit it. The repository must never contain keys or tokens.
  • Add another tool: chezmoi add ~/.config/<tool>/config copies the file into home/; commit it.
  • Files a program rewrites (for example lazy-lock.json after :Lazy update): the copy in $HOME changes, the repository does not. chezmoi diff shows it; chezmoi re-add pulls it back into home/.
  • Neovim: to use your own setup instead, delete home/dot_config/nvim from your fork.

Commands

chezmoi diff | status | verify   # what differs between home/ and $HOME (verify exits non-zero if anything does)
chezmoi doctor                   # chezmoi's own health check
tests/run.sh                     # the repository's tests, including an end-to-end run of this quick start in a throwaway HOME
scripts/privacy-scan [--all]     # secrets, personal paths and e-mail addresses in staged (or all tracked) files, or in commit metadata (--commits)
scripts/lint-shell               # shellcheck (warning level and above) over every bash/sh script in the repository

Safety model

  • chezmoi diff and chezmoi apply --dry-run change nothing.
  • Backup before every apply. chezmoi overwrites a differing file without keeping a copy. chezmoi init installs a hook (scripts/backup-before-apply) that copies every file the apply is about to replace, including files you edited after chezmoi wrote them, to ~/.cli-workbench-backup/<timestamp>/ first. Directory mode 700, symlinks kept as symlinks, a RESTORE note with one copy-paste command per file. If nothing would change, nothing is created. If the backup fails, the apply is refused. --dry-run has no side effects.
  • The hook is part of the config chezmoi init writes. If you only create chezmoi.toml by hand, or run chezmoi apply --source ... without having run init, there is no backup.
  • It only touches the targets in the table above, and it deletes nothing unless you ask for it (chezmoi destroy).
  • scripts/privacy-scan guards what you commit; see "Safe to make public" above.

Agent instructions

Claude Code, Codex and Gemini CLI each read a plain-text instruction file from their home directory. Here they are generated from one source, home/.chezmoitemplates/agent-instructions.md, so the agents get the same facts about the machine and the same engineering principles: one source of truth with layers that only add differences, least privilege, files over conversation, the simplest thing that works, and evidence before "done" with a way back.

  • Your own rules go in ~/.config/cli-workbench/agent-instructions.local.md. It is not tracked, so personal preferences never reach a public fork. It is appended after the shared text in all three files. Remove the file and the next chezmoi apply removes its text.
  • Existing files: your current ~/.claude/CLAUDE.md (and the others) are replaced on apply and backed up first. Move their content into the local file beforehand if you want to keep it as it is.
  • Edit the source, not the deployed file. Text that a tool appends to ~/.claude/CLAUDE.md is overwritten at the next apply. The generated files cannot be pulled back with chezmoi re-add.
  • Not tracked as complete files, on purpose: settings.json, config.toml, auth.json, histories, sessions and databases. They hold machine paths, proxies and credentials, and the tools rewrite them. A test fails if such a file appears under home/.
  • To publish your own principles, put them in the shared source in your fork, not in the untracked local file.

Portable Codex preferences

home/dot_codex/modify_private_config.toml merges five UI settings into ~/.codex/config.toml: the status line (model, directory, session name, five-hour and weekly remaining limits), status-line colors, and completion notifications using a terminal bell regardless of focus. With the existing tmux bell settings, Ghostty can mark the background tab with a bell.

On a new machine, install Codex separately and run the usual chezmoi init / chezmoi apply setup, then sign in to Codex. The configuration is created if absent. Existing model choices, local paths, notification commands, project trust, and other settings are preserved; credentials and sessions are not migrated. Restart an existing Codex CLI after applying (use codex resume --last to continue).

Edit the merge template to change these shared preferences. Changes to these five settings made inside Codex are replaced by the next apply. Do not use chezmoi add or re-add to copy the full local file into the repository. When values need changing, the TOML is reserialized, so comments and formatting are lost; matching files remain unchanged. The existing pre-apply backup hook keeps the original before replacement. Invalid TOML stops the merge without overwriting the file.

Claude Code mods

A mod is a Claude Code plugin whose behavior is a small TypeScript file that Claude Code calls when something happens: a tool is about to run, a reply is about to be drawn. This repository ships four, as a local marketplace (a folder Claude Code installs plugins from) in home/dot_claude/workbench-mods/:

ModWhat it does
chezmoi-guardRefuses Edit, Write and NotebookEdit on a file chezmoi deploys (~/.zshrc, ~/.tmux.conf, ...) and names the source file to edit instead, so the next chezmoi apply cannot overwrite the change. Shows a line above the prompt while the deployed files differ from the source (chezmoi status). It cannot see edits made through Bash (sed -i, > file). If chezmoi is missing or fails, it blocks nothing.
agent-stateRecords, in one small file per tmux pane, whether the agent in it is working, waiting for you or idle, so that the overview popup (see "Agent overview") can list them. It runs ~/.tmux/scripts/agent-state.sh on Claude Code events and changes nothing else: outside tmux, or if the script fails, nothing happens, and a hook waits for the script for at most one second.
md-openAdds /md: a pane lists the Markdown files this conversation mentioned (paths in tool calls, Bash commands, replies, your prompts and tool results; absolute, ~/ and relative to the session's directory), newest first, each once, only those that exist, with ~ for your home directory. Pick one with Enter or its digit (1-9) and it opens read-only (nvim -R) in a tmux popup, rendered by render-markdown.nvim; quitting Neovim closes the popup. The path goes to tmux as an argument, never through a shell. Outside tmux, or when tmux fails, a toast shows the path instead. It needs no model turn, and changes no file.
reply-polishLays out the assistant's replies for a wide terminal. The text is one column, at most 80 cells (about 40 Chinese characters) and at most 72% of the window, left-aligned and centered on the screen. Headings are bold, the first two levels in cyan; lists use • and ◦; a table is drawn with box lines when it fits the column and becomes a list when it does not; lines are cut so that a number stays with its unit and closing punctuation never starts a line; a code block longer than 30 lines is shortened to 12. Only the drawing changes: the stored reply, and ctrl+o, keep the original.

Install once per machine (needs Claude Code 2.1.287 or later; chezmoi apply only deploys the files, it does not install anything):

chezmoi apply                                                   # deploys ~/.claude/workbench-mods
claude plugin marketplace add ~/.claude/workbench-mods
claude plugin install chezmoi-guard@cli-workbench --scope user
claude plugin install reply-polish@cli-workbench --scope user
claude plugin install agent-state@cli-workbench --scope user
claude plugin install md-open@cli-workbench --scope user

Then restart Claude Code, or run /reload-plugins in a running session.

  • Change a mod: edit it under home/dot_claude/workbench-mods/, run chezmoi apply, then /reload-plugins. A marketplace that is a folder is read from the folder itself, so no version bump is needed. claude plugin disable <name> turns one off, claude plugin uninstall <name> removes it.
  • Tests: tests/mods.test.sh always checks the layout of the marketplace. With Claude Code installed it also runs claude plugin validate and each mod's own tests (claude plugin test); without it that part is skipped.
  • Trust: a mod sees every tool call and reply and runs with your permissions. Read the source before you install it; each mod is a few hundred lines.
  • Stability: the mods API is early access and changes between releases. These mods were built and tested with Claude Code 2.1.289 (md-open with 2.1.295). Mods do not load under --safe-mode or --bare.
  • Not tracked on purpose: the type declarations Claude Code writes into a mod's .claude-plugin/types/ when it loads the mod.

Keybindings

The keys you use most, on one page to print: docs/keybindings.md. A test keeps it in step with the config.

Workspace switcher (tmux)

Press prefix then P (Ctrl-a P) for a picker over ~/dev/projects. Every directory directly under a root is a workspace, opened as one tmux session: an AI agent on top, a shell under it. There is no editor pane; prefix e and prefix g open Neovim in a popup when you want to read code (see below). A directory that is not a repository but contains several (a multi-repo product) is one workspace with one window per repository. Choosing an existing workspace only switches to it. It needs tmux 3.2+ and fzf.

  • Agent pane: the first installed of claude codex gemini grok is started in the project directory. If none is installed, the window has just a shell. WORKSPACE_SWITCH_AGENT="claude --continue" picks one (with arguments), none turns it off, WORKSPACE_SWITCH_AGENTS="aider claude" changes the candidates and their order. Set these with set-environment -g in home/dot_tmux.conf, next to WORKSPACE_ROOTS.
  • Roots: uncomment WORKSPACE_ROOTS in home/dot_tmux.conf to scan other directories.
  • Details are in the header of home/dot_tmux/scripts/executable_workspace-switch.sh.

Code popups (tmux)

Read code without leaving the agent's pane. Both keys open Neovim in a popup over the current pane, in that pane's directory; closing Neovim closes the popup and you are back where you were. They need tmux 3.2+.

  • prefix then e: browse the project with the usual Neovim keys (<leader>ff find a file, <leader>fg search, <leader>e file tree). :qa closes it.
  • A Markdown file opened there is shown rendered in place (headings, lists, tables, code blocks); <leader>mp switches the rendering off and on. The text is a centred column of about 80 characters, so it stays readable on a wide screen.
  • prefix then g: review changes. A Telescope list of every file the branch changed since it left the default branch (origin/HEAD, else origin/main, main and so on): committed, uncommitted, deleted and untracked files, each marked, with a colored diff as the preview. Enter opens the file in a new tab beside its version at the fork point, in Neovim's own diff mode (a deleted file: its old version beside an empty side) (]c/[c jump between changes). Inside Neovim the same list is :Changes or <leader>gv. :qa closes the popup. Outside a Git repository it says so. No extra plugin is needed.
  • The script is home/dot_tmux/scripts/executable_code-popup.sh; tmux passes it only the pane id, never a directory name.

Translate what you select (tmux)

Select some English text with the mouse (or v ... y in copy mode), then press prefix then t (Ctrl-a t). A popup shows a translation, so you do not leave the pane you are reading. One word gives a dictionary entry; anything longer gives its translation. Close the popup with q.

  • Needs: tmux 3.2+ and translate-shell (brew install translate-shell; without it the popup says so).
  • Privacy: the selected text is sent to the translation service (Google by default; if that fails, Bing is asked once). Do not use it on text you may not send out.
  • Language: LOOKUP_LANG sets the target language (default zh-CN). Details are in the header of home/dot_tmux/scripts/executable_lookup.sh.

Idea inbox (tmux)

Write an idea down the moment you have it, without leaving the pane or interrupting the agent. Press prefix then a (Ctrl-a a), type one line, press Enter; the popup closes and you are back where you were. An empty line cancels. In a shell, idea some text does the same (or idea alone to be asked). It needs tmux 3.2+.

  • Where: one line per idea in ~/.config/cli-workbench/inbox.md (private, mode 600, not in this repository), for all projects: - [ ] 2026-10-06 17:42 · ~/dev/projects/foo · the idea. The project is the repository's top directory, for a worktree the repository it belongs to; ? if the pane was gone.
  • Safe to type anything: the text is stored as it is and never run. A failed write keeps the popup open and repeats the idea.
  • Processing it: ask your agent to "process the inbox". The rules: copy the file to a timestamped backup (mode 600) first; change only the lines it handles, ticking - [ ] to - [x], never deleting; afterwards check that every line from before is still there; ask before anything leaves the machine, such as opening a GitHub issue.

Agent overview (tmux)

When several agents work at once, the question is which one needs you. Press prefix then O (Ctrl-a O) for a popup that lists every agent session, one row each: state, project, branch and how long it has been in that state. Waiting comes first (a permission prompt or a question; the longest wait on top), then working, then idle. Enter jumps to that pane (its session, window and pane); Esc closes the popup. With fzf it is a picker you can type into; without it, a numbered menu. Plain text, no colors. It needs tmux 3.2+ and jq.

  • Where the rows come from: Claude Code. The agent-state mod (see "Claude Code mods") runs ~/.tmux/scripts/agent-state.sh: a turn starts, working; a permission prompt or an MCP question appears, waiting; a turn ends for any reason (an answer, an interrupt, an error), idle; a session starts, idle (a compaction does not count); a session ends, the record is removed. When a tool finishes, fails, is denied, or a dialog is answered while the pane still says waiting, it goes back to working. Without the mod the popup is empty.
  • Files: one small file per pane, ${XDG_STATE_HOME:-~/.local/state}/cli-workbench/sessions/<tmux server>/<pane id>.json, with state, project, branch, since and session id. The server directory is the tmux server's pid and start time, so a pane id reused after a tmux restart never meets an old record. The last event wins; the file is replaced atomicall
Source 2 files
hooks/register.ts 124 lines
1import type { Register, RenderElement } from 'claude-code'
2
3import { displayWidth, layout, polish, reflow } from './format'
4import type { Segment } from './format'
5
6// Layout rules, in the order of the four design principles.
7//
8// Contrast:    headings are bold, the first two levels in the accent colour; table borders are dim,
9//              the table header takes the accent colour; body text stays plain.
10// Repetition:  one accent colour, one heading style per level, one table style, one reply marker.
11// Alignment:   one left edge for everything: text, headings and tables start at the same cell, and a
12//              table is never wider than the text column. The column itself is centred on the screen.
13// Proximity:   a heading sits right above what it introduces and a blank line apart from what came
14//              before; other blocks are one blank line apart.
15const ACCENT = 'cyan'
16// Cells across the text column: about 40 Chinese characters or 80 Latin ones, a comfortable measure.
17const MEASURE = 80
18// The column takes at most this share of the window.
19const FILL = 0.72
20// A nested list item moves in by this many cells.
21const INDENT = 2
22// One Markdown block is at most this long, or the whole tree is refused.
23const MAX_BLOCK = 10000
24// The reply's own marker (the bullet) takes this many cells at the left.
25const MARKER = 2
26
27const text = (props: Record<string, unknown>, content: string): RenderElement =>
28  ({ type: 'Text', props: { wrap: 'truncate-end', ...props }, children: [content] }) as RenderElement
29
30const box = (props: Record<string, unknown>, children: RenderElement[]): RenderElement =>
31  ({ type: 'Box', props, children }) as RenderElement
32
33export const register: Register = on => {
34  // A rewrite changes the drawing only; the stored reply, and ctrl+o, keep the original.
35  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
36    const columns = e.viewport?.columns
37    // At most MEASURE cells, and a share of a narrow window, so the margins on both sides stay visible.
38    const measure = columns === undefined ? undefined : Math.max(30, Math.min(MEASURE, Math.floor(columns * FILL)))
39    const segments = measure === undefined ? [] : layout(e.props.text, measure)
40    const isDrawable = segments.every(segment => segment.kind !== 'text' || segment.text.length <= MAX_BLOCK) &&
41      segments.every(segment => segment.kind !== 'list' || segment.items.every(item => item.text.length <= MAX_BLOCK))
42
43    if (columns === undefined || measure === undefined || !isDrawable) {
44      const rewritten = polish(e.props.text)
45      return next(rewritten === e.props.text ? e : { ...e, props: { ...e.props, text: rewritten } })
46    }
47
48    const left = Math.max(0, Math.floor((columns - MARKER - measure) / 2))
49
50    const draw = (segment: Segment): RenderElement => {
51      if (segment.kind === 'text') {
52        return { type: 'Markdown', props: { text: reflow(segment.text, measure) } } as RenderElement
53      }
54
55      if (segment.kind === 'list') {
56        // Markers share one column so the item texts line up; each text hangs after its marker.
57        const markerWidth = Math.max(...segment.items.map(item => displayWidth(item.marker))) + 1
58
59        return box(
60          { flexDirection: 'column' },
61          segment.items.map(item => {
62            const indent = INDENT * item.depth
63            const itemWidth = measure - indent - markerWidth
64
65            return box({ flexDirection: 'row', marginLeft: indent }, [
66              text({ color: ACCENT }, item.marker.padEnd(markerWidth)),
67              box({ width: itemWidth }, [
68                { type: 'Markdown', props: { text: reflow(item.text, itemWidth) } } as RenderElement,
69              ]),
70            ])
71          }),
72        )
73      }
74
75      if (segment.kind === 'heading') {
76        const isTop = segment.level <= 2
77        const title = text({ bold: true, ...(isTop ? { color: ACCENT } : {}) }, segment.text)
78        const rule = text({ dimColor: true }, '─'.repeat(Math.min(displayWidth(segment.text), measure)))
79
80        return box({ flexDirection: 'column' }, segment.level === 1 ? [title, rule] : [title])
81      }
82
83      return box(
84        { flexDirection: 'column' },
85        segment.cells.map((row, k) => {
86          if (row === null) return text({ dimColor: true }, segment.lines[k] ?? '')
87
88          const isHeader = k === 1
89          const sources = segment.sources[k] ?? []
90          // A body cell is drawn as markdown in a box of the column's width, so code spans and bold keep
91          // their style; the header is plain text in the accent colour.
92          const draw = (cell: string, c: number): RenderElement => {
93            const source = sources[c] ?? ''
94            if (isHeader) return text({ bold: true, color: ACCENT }, cell)
95            if (source === '') return text({}, cell)
96            return box({ width: segment.widths[c] ?? 0 }, [
97              { type: 'Markdown', props: { text: source } } as RenderElement,
98            ])
99          }
100          const pieces = row.flatMap((cell, c) => [text({ dimColor: true }, c === 0 ? '│ ' : ' │ '), draw(cell, c)])
101
102          return box({ flexDirection: 'row' }, [...pieces, text({ dimColor: true }, ' │')])
103        }),
104      )
105    }
106
107    const children = segments.map((segment, i) => {
108      const isFirst = i === 0 && e.props.isFirstOfReply
109      const isTight = (i === 0 && !isFirst) || (i > 0 && segments[i - 1]?.kind === 'heading')
110      // The marker of a reply is kept: it sits in the gutter just left of the text column.
111      const hasMarker = isFirst && left >= MARKER
112      const block = box({ width: measure }, [draw(segment)])
113
114      // A reply starts one blank line below whatever came before it, a hook's notice for one.
115      return box(
116        { flexDirection: 'row', marginLeft: hasMarker ? left - MARKER : left, marginTop: isTight ? 0 : 1 },
117        hasMarker ? [text({}, '● '), block] : [block],
118      )
119    })
120
121    return box({ flexDirection: 'column' }, children)
122  })
123}
124
hooks/format.ts 363 lines
1// Pure text rewriting for an assistant reply. The result is still markdown: the engine's own
2// renderer draws it, so themes, links and code highlighting are kept.
3
4export const MAX_CODE_LINES = 30
5export const KEEP_CODE_LINES = 12
6// Without a measured width, a table stays a table when its widest row fits and it has few columns.
7export const WIDE_TABLE = 72
8export const MAX_COLUMNS = 3
9// A table segment carries what `drawTable` returns, line by line (see there).
10export type ListItem = { depth: number; marker: string; text: string }
11
12export type Segment =
13  | { kind: 'text'; text: string }
14  | { kind: 'heading'; level: number; text: string }
15  | { kind: 'list'; items: ListItem[] }
16  | ({ kind: 'table' } & Drawn)
17
18const OPEN_FENCE = /^\s*(`{3,}|~{3,})/
19const LIST_ITEM = /^(\s*)([-*+]|\d{1,3}[.)])\s+(.*)$/
20const BULLETS = ['•', '◦', '▪']
21// Characters that may not start a line (closing punctuation) or end one (opening punctuation).
22const NO_START = ',。、;:!?)】》」』”’…%,.;:!?)]}'
23const NO_END = '(【《「『“‘([{'
24const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
25const SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
26
27const PIPE = '\u0000'
28
29const cells = (line: string): string[] =>
30  line
31    .trim()
32    .replace(/\\\|/g, PIPE)
33    .replace(/^\|/, '')
34    .replace(/\|$/, '')
35    .split('|')
36    .map(cell => cell.split(PIPE).join('|').trim())
37
38const closesFence = (line: string, marker: string): boolean => {
39  const text = line.trim()
40  const mark = text[0] ?? ''
41  return text.length >= marker.length && mark === marker[0] && text === mark.repeat(text.length)
42}
43
44const isTableStart = (lines: string[], i: number): boolean => {
45  const line = lines[i] ?? ''
46  const next = lines[i + 1]
47  return (
48    next !== undefined &&
49    line.includes('|') &&
50    next.includes('|') &&
51    SEPARATOR.test(next) &&
52    cells(line).length >= 2 &&
53    cells(line).length === cells(next).length
54  )
55}
56
57const isWide = (cp: number): boolean =>
58  (cp >= 0x1100 && cp <= 0x115f) ||
59  (cp >= 0x2e80 && cp <= 0xa4cf) ||
60  (cp >= 0xac00 && cp <= 0xd7a3) ||
61  (cp >= 0xf900 && cp <= 0xfaff) ||
62  (cp >= 0xfe30 && cp <= 0xfe6f) ||
63  (cp >= 0xff00 && cp <= 0xff60) ||
64  (cp >= 0xffe0 && cp <= 0xffe6) ||
65  (cp >= 0x1f300 && cp <= 0x1faff)
66
67// Terminal cells a string takes: CJK and emoji are two cells wide.
68export const displayWidth = (text: string): number => {
69  let width = 0
70  for (const char of text) width += isWide(char.codePointAt(0) ?? 0) ? 2 : 1
71  return width
72}
73
74// A cell or a heading is drawn as plain text, so inline markdown is reduced to what it shows.
75const plain = (cell: string): string =>
76  cell
77    .replace(/\*\*(.+?)\*\*/g, '$1')
78    .replace(/`([^`]+)`/g, '$1')
79    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
80
81// A line cut to `width` cells. A Latin word or number stays with the Chinese character after it
82// ("20个"), closing punctuation never starts a line and opening punctuation never ends one.
83export const wrapLine = (line: string, width: number): string[] => {
84  if (displayWidth(line) <= width) return [line]
85
86  const pieces: string[] = []
87  let run = ''
88  for (const char of line) {
89    if (char === ' ') {
90      if (run !== '') pieces.push(run)
91      run = ''
92      pieces.push(' ')
93    } else if (isWide(char.codePointAt(0) ?? 0)) {
94      pieces.push(run + char)
95      run = ''
96    } else {
97      run += char
98    }
99  }
100  if (run !== '') pieces.push(run)
101
102  const units: string[] = []
103  for (const piece of pieces) {
104    const prev = units[units.length - 1]
105    const isGlued =
106      prev !== undefined &&
107      prev !== ' ' &&
108      piece !== ' ' &&
109      (NO_START.includes(piece.charAt(0)) || NO_END.includes(prev.charAt(prev.length - 1)))
110    if (isGlued) units[units.length - 1] = prev + piece
111    else units.push(piece)
112  }
113
114  const out: string[] = []
115  let current = ''
116  let used = 0
117  // Backticks are never shown; `**` is hidden as bold markup, but shown inside a code span.
118  let isInCode = false
119  for (const unit of units) {
120    if (unit === ' ') {
121      if (current !== '') {
122        current += ' '
123        used += 1
124      }
125      continue
126    }
127    const ticks = (unit.match(/`/g) ?? []).length
128    const shown = isInCode || ticks > 0 ? unit.replace(/`/g, '') : unit.replace(/\*\*/g, '')
129    if (ticks % 2 === 1) isInCode = !isInCode
130    const size = displayWidth(shown)
131    if (used + size > width && current.trim() !== '') {
132      out.push(current.trimEnd())
133      current = ''
134      used = 0
135    }
136    current += unit
137    used += size
138  }
139  if (current.trim() !== '') out.push(current.trimEnd())
140
141  return out
142}
143
144// Paragraph lines cut to `width`; code, quotes, tables and indented lines are left as written.
145export const reflow = (text: string, width: number): string => {
146  let isFenced = false
147
148  return text
149    .split('\n')
150    .flatMap(raw => {
151      if (OPEN_FENCE.test(raw)) {
152        isFenced = !isFenced
153        return [raw]
154      }
155      // One to three leading spaces mean nothing in markdown; four or more make a code block.
156      const line = isFenced || !/^ {1,3}\S/.test(raw) ? raw : raw.trimStart()
157      return isFenced || /^(\s|>|\||<)/.test(line) ? [line] : wrapLine(line, width)
158    })
159    .join('\n')
160}
161
162// A table with this many body rows or more gets a rule between its rows, so a long table stays readable.
163export const ROW_RULES_FROM = 7
164
165type Drawn = {
166  lines: string[]
167  // Per line: the padded plain cells of a content line, null for a rule.
168  cells: (string[] | null)[]
169  // Per line: the cells as written in markdown (code spans, bold), null for a rule.
170  sources: (string[] | null)[]
171  widths: number[]
172  width: number
173}
174
175// The table as box-drawing lines, columns sized to their widest cell. Line 1 is the header.
176export const drawTable = (header: string[], rows: string[][]): Drawn => {
177  const raw = [header, ...rows.map(row => header.map((_, k) => row[k] ?? ''))]
178  const grid = raw.map(row => row.map(plain))
179  const widths = header.map((_, k) => Math.max(...grid.map(row => displayWidth(row[k] ?? ''))))
180  const pad = (text: string, width: number) => text + ' '.repeat(width - displayWidth(text))
181  const rule = (left: string, mid: string, right: string) =>
182    left + widths.map(width => '─'.repeat(width + 2)).join(mid) + right
183  const padded = grid.map(row => row.map((cell, k) => pad(cell, widths[k] ?? 0)))
184  const [top, middle, bottom] = [rule('┌', '┬', '┐'), rule('├', '┼', '┤'), rule('└', '┴', '┘')]
185  const hasRowRules = rows.length >= ROW_RULES_FROM
186
187  type Entry = { rule: string } | { row: number }
188  const entries: Entry[] = [{ rule: top }, { row: 0 }, { rule: middle }]
189  for (let r = 1; r < grid.length; r += 1) {
190    entries.push({ row: r })
191    if (hasRowRules && r < grid.length - 1) entries.push({ rule: middle })
192  }
193  entries.push({ rule: bottom })
194
195  return {
196    lines: entries.map(entry => ('rule' in entry ? entry.rule : '│ ' + (padded[entry.row] ?? []).join(' │ ') + ' │')),
197    cells: entries.map(entry => ('rule' in entry ? null : (padded[entry.row] ?? []))),
198    sources: entries.map(entry => ('rule' in entry ? null : (raw[entry.row] ?? []))),
199    widths,
200    width: displayWidth(top),
201  }
202}
203
204// Two columns: one bullet per row, "- **first** — second".
205// More columns: a bullet per row with one sub-bullet per cell, "  - head: cell".
206// Returns null when the table is small enough to stay as it is, unless `force` is set.
207const tableItems = (header: string[], rows: string[][]): ListItem[] =>
208  rows.flatMap((row): ListItem[] => {
209    const [first = '', ...rest] = header.map((_, k) => row[k] ?? '')
210    const title = first === '' ? '' : first.includes('**') ? first : `**${first}**`
211
212    if (rest.length === 1) {
213      const only = rest[0] ?? ''
214      return [{ depth: 0, marker: '•', text: `${title}${title !== '' && only !== '' ? ' — ' : ''}${only}` }]
215    }
216
217    const parts = rest.flatMap((cell, k): ListItem[] =>
218      cell === '' ? [] : [{ depth: 1, marker: '◦', text: `${header[k + 1] ?? ''}: ${cell}` }],
219    )
220    return [{ depth: 0, marker: '•', text: title }, ...parts]
221  })
222
223const tableToList = (header: string[], rows: string[][], rawLines: string[], force = false): string[] | null => {
224  const widest = Math.max(...rawLines.map(line => line.length))
225  if (!force && widest <= WIDE_TABLE && header.length <= MAX_COLUMNS) return null
226
227  return tableItems(header, rows).map(item => `${'  '.repeat(item.depth)}- ${item.text}`)
228}
229
230// The reply as text blocks, headings and tables. With `width` (the cells a table may take, the text
231// column's) a table that fits is returned as a table to draw and one that does not becomes a list,
232// and headings are blocks of their own; without it, only a wide table becomes a list, headings
233// become bold lines and the rest stays markdown for the engine.
234export const layout = (text: string, width?: number): Segment[] => {
235  const lines = text.split('\n')
236  const out: string[] = []
237  const segments: Segment[] = []
238  const lastIsBlank = () => out.length > 0 && (out[out.length - 1] ?? '').trim() === ''
239  const flush = () => {
240    const block = out.join('\n').replace(/^\n+|\n+$/g, '')
241    if (block !== '') segments.push({ kind: 'text', text: block })
242    out.length = 0
243  }
244  let i = 0
245
246  while (i < lines.length) {
247    const line = lines[i] ?? ''
248    const fence = OPEN_FENCE.exec(line)
249
250    if (fence) {
251      const marker = fence[1] ?? '```'
252      let end = i + 1
253      while (end < lines.length && !closesFence(lines[end] ?? '', marker)) end += 1
254      const isClosed = end < lines.length
255      const body = lines.slice(i + 1, end)
256
257      out.push(line)
258      if (isClosed && body.length > MAX_CODE_LINES) {
259        out.push(...body.slice(0, KEEP_CODE_LINES))
260        out.push(`… ${body.length - KEEP_CODE_LINES} more lines hidden (ctrl+o shows the whole reply)`)
261      } else {
262        out.push(...body)
263      }
264      if (isClosed) out.push(lines[end] ?? '')
265      i = isClosed ? end + 1 : lines.length
266      continue
267    }
268
269    if (isTableStart(lines, i)) {
270      let end = i + 2
271      while (end < lines.length && (lines[end] ?? '').trim() !== '' && (lines[end] ?? '').includes('|')) end += 1
272      const raw = lines.slice(i, end)
273      const header = cells(line)
274      const rows = lines.slice(i + 2, end).map(cells)
275      const drawn = width === undefined ? null : drawTable(header, rows)
276
277      if (drawn !== null && width !== undefined && drawn.width <= width) {
278        flush()
279        segments.push({ kind: 'table', ...drawn })
280      } else if (width !== undefined) {
281        flush()
282        segments.push({ kind: 'list', items: tableItems(header, rows) })
283      } else {
284        out.push(...(tableToList(header, rows, raw) ?? raw))
285      }
286      i = end
287      continue
288    }
289
290    const heading = HEADING.exec(line)
291    if (heading) {
292      const level = (heading[1] ?? '#').length
293      const title = (heading[2] ?? '').replace(/^\*\*(.+)\*\*$/, '$1')
294
295      if (width !== undefined) {
296        flush()
297        segments.push({ kind: 'heading', level, text: plain(title) })
298      } else {
299        if (level <= 2 && out.length > 0 && !lastIsBlank()) out.push('')
300        out.push(`**${title}**`)
301      }
302      i += 1
303      continue
304    }
305
306    if (width !== undefined && LIST_ITEM.test(line)) {
307      flush()
308      const items: ListItem[] = []
309      const indents: number[] = []
310      let end = i
311
312      while (end < lines.length) {
313        const current = lines[end] ?? ''
314        const item = LIST_ITEM.exec(current)
315
316        if (item) {
317          const indent = (item[1] ?? '').length
318          while (indents.length > 0 && indent < (indents[indents.length - 1] ?? 0)) indents.pop()
319          if (indents.length === 0 || indent > (indents[indents.length - 1] ?? 0)) indents.push(indent)
320          const depth = Math.min(indents.length - 1, BULLETS.length - 1)
321          const mark = item[2] ?? '-'
322          items.push({
323            depth,
324            marker: /^\d/.test(mark) ? mark : (BULLETS[depth] ?? '•'),
325            text: (item[3] ?? '').trim(),
326          })
327        } else if (items.length > 0 && /^\s{2,}\S/.test(current) && !OPEN_FENCE.test(current)) {
328          const last = items[items.length - 1]
329          if (last !== undefined) last.text += ' ' + current.trim()
330        } else {
331          break
332        }
333        end += 1
334      }
335
336      segments.push({ kind: 'list', items })
337      i = end
338      continue
339    }
340
341    if (line.trim() === '') {
342      if (!lastIsBlank()) out.push('')
343    } else {
344      out.push(line)
345    }
346    i += 1
347  }
348
349  flush()
350  return segments.length > 0 ? segments : [{ kind: 'text', text: '' }]
351}
352
353// The reply as one markdown string, for a surface that cannot be measured.
354export const polish = (text: string): string =>
355  layout(text)
356    .map(segment => {
357      if (segment.kind === 'text') return segment.text
358      if (segment.kind === 'heading') return `**${segment.text}**`
359      if (segment.kind === 'list') return segment.items.map(item => `${item.marker} ${item.text}`).join('\n')
360      return segment.lines.join('\n')
361    })
362    .join('\n')
363