SLOPSHOPPER

readable-width

Caps reply and prompt text at 80 columns while code blocks and tables keep the full width, and highlights your own prompts.

newspinnerrows
v1.1.0no licenseupdated 2026-10-13Tvrtk0/claude-code-mods/readable-width
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · readable-width
❯ fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⟨Claude Code's own drawing⟩ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail Done. I made refresh reject expired claims and added an audit call. ✻ Worked… ✻ Thinking… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
✻ Thinking…
Turn line
✻ Worked…
Your message
❯ fix the failing auth test and add an audit log call
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
⟨Claude Code's own drawing⟩
Command output
⟨Claude Code's own drawing⟩
README

local-mods

Personal Claude Code mods for a calmer, more readable terminal.

ModWhat it does
readable-widthWraps replies and prompts at 80 columns; tables and code blocks that name a language keep the full width; an unlabelled or text block wraps like prose. Your prompts are bold and accent-colored, with a blank line above each message.
usage-metersOne band above the prompt: ctx, 5h and 7d meters, green under 50%, yellow under 80%, red above; 5h also shows the time left until it resets.
tool-logFolds tool calls and their results in the chat into a dim › N tools line and keeps them in a side log grouped by prompt. Toggle it with [ panel ], /panel or a key bound to app:toggleDiffPreSession (ctrl+t in ~/.claude/keybindings.json, see below; this replaces the built-in todo list toggle, which the Todo tab covers); a click on a chat line opens it on that prompt's steps, until the next prompt or ✕ returns it to live. The chat line also sums file changes and failures (› 6 tools · 2 files changed +14 −3 · 1 failed). A turn longer than 30 s ends with a desktop notification. Successful Bash rows expand to show their command, and Edit/Write rows to show their diff. Inside a git repo a [ diff ] button opens the built-in /diff panel. The panel has four tabs: Log, Files (every file changed this session with its diff), Agents (subagents with status, time, tool count and their latest step) and Todo (Claude's plan); [ panel ] reopens it on the tab last in view. The active tab is orange, a tab with something new since you last looked is bright, the rest are dim. Replaces the spinner with a minimal one that names the current step and the plan's progress, e.g. ⠋ 12s · 3/7 · Reading register.tsx or ⠙ 3s · Thinking.
turn-detailsReplaces the Brewed for 34s · done 1:09 PM line with 📋 details ▸, which folds the turn's duration, finish time, tool calls and model open in place.

Install

From GitHub:

claude plugin marketplace add Tvrtk0/claude-code-mods
claude plugin install readable-width@local-mods
claude plugin install usage-meters@local-mods
claude plugin install tool-log@local-mods
claude plugin install turn-details@local-mods

From a local clone (edits apply after /reload-plugins, no reinstall):

claude plugin marketplace add ~/.claude/mods
claude plugin install readable-width@local-mods
claude plugin install usage-meters@local-mods
claude plugin install tool-log@local-mods
claude plugin install turn-details@local-mods

Installed from a local clone, plugins are read straight from the folder.

Develop

claude plugin validate ~/.claude/mods/<mod>
claude plugin test ~/.claude/mods/<mod>

Keyboard shortcut for the panel

A mod cannot add a key of its own, so the [ panel ] button borrows an engine action that only the /diff panel handles. Bind any key to it:

{
  "$schema": "https://www.schemastore.org/claude-code-keybindings.json",
  "bindings": [
    { "context": "Global", "bindings": { "ctrl+t": "app:toggleDiffPreSession" } }
  ]
}

ctrl+t hands the panel the keyboard (opening it if needed) and closes it when it already has it. Inside: h/l or 1–4 switch tabs, j/k move between rows (starting from the newest), enter opens one, d/u scroll half a window, g/b jump to the first or last row, esc returns to the prompt and leaves the panel open. The panel scrolls itself, so its tabs and keys stay at the top; Bottom is b, not G: a button hotkey cannot tell shift+g from g.

Source 1 files
hooks/register.tsx 183 lines
1import type {
2  EngineInterface,
3  Register,
4  RenderElement,
5  RenderInput,
6} from 'claude-code'
7
8const WIDTH = 80
9const GUTTER = 2
10const PADDING = 0
11
12type Segment = { text: string; isWide: boolean }
13
14// A fixed left padding rather than centering: replies stream in at the left
15// edge, so with none the finished block does not move at all. Null when the
16// terminal is too narrow to spare it.
17const marginFor = (columns: number | undefined) =>
18  columns === undefined || columns < 40 ? null : PADDING
19
20// Splits markdown into prose and wide blocks (code fences and tables), so
21// prose wraps at WIDTH while wide blocks keep the room to the right edge.
22function segment(markdown: string): Segment[] {
23  const segments: Segment[] = []
24  let lines: string[] = []
25  let isWide = false
26  let fence: string | null = null
27
28  const flush = () => {
29    const text = lines.join('\n').trim()
30    if (text !== '') segments.push({ text, isWide })
31    lines = []
32  }
33
34  for (const line of markdown.split('\n')) {
35    const trimmed = line.trimStart()
36
37    if (fence !== null) {
38      lines.push(line)
39      if (trimmed.startsWith(fence)) {
40        flush()
41        fence = null
42        isWide = false
43      }
44      continue
45    }
46
47    // Only code keeps the full width: a fence that names a language other
48    // than plain text. An unlabelled or text fence wraps like prose.
49    const opens = /^(`{3,}|~{3,})\s*([\w+#.-]*)/.exec(trimmed)
50    if (opens) {
51      flush()
52      fence = opens[1] ?? '```'
53      const lang = (opens[2] ?? '').toLowerCase()
54      isWide = lang !== '' && !['text', 'txt', 'plain', 'plaintext', 'markdown', 'md'].includes(lang)
55      lines.push(line)
56      continue
57    }
58
59    const isTableRow = trimmed.startsWith('|')
60    if (isTableRow !== isWide) {
61      flush()
62      isWide = isTableRow
63    }
64    lines.push(line)
65  }
66
67  flush()
68
69  return segments
70}
71
72// Moves a row the engine drew to the column's left edge, so the whole
73// transcript reads as one column instead of jumping between edge and center.
74function shift(
75  $: EngineInterface,
76  e: RenderInput,
77  drawn: RenderElement,
78): RenderElement {
79  const columns = e.viewport?.columns
80  const margin = marginFor(columns)
81
82  if (e.surface !== 'terminal' || margin === null || columns === undefined) {
83    return drawn
84  }
85
86  const { Box } = $.ui.resolve(e)
87
88  // The engine refuses its own row under a Box that sets a width, so the
89  // margin alone shifts it.
90  return <Box marginLeft={margin}>{drawn}</Box>
91}
92
93export const register: Register = on => {
94  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
95    const columns = e.viewport?.columns
96    const margin = marginFor(columns)
97
98    if (e.surface !== 'terminal' || margin === null || columns === undefined) {
99      return next(e)
100    }
101
102    const { Box, Text, Markdown } = $.ui.resolve(e)
103    const wide = columns - margin - GUTTER
104
105    return (
106      <Box
107        marginLeft={margin}
108        marginTop={e.props.isFirstOfReply ? 1 : 0}
109        width={columns - margin}
110      >
111        <Box width={GUTTER} flexShrink={0}>
112          <Text>{e.props.isFirstOfReply ? '●' : ' '}</Text>
113        </Box>
114        <Box flexDirection="column" gap={1}>
115          {segment(e.props.text).map(part => (
116            <Box width={part.isWide ? wide : Math.min(WIDTH - GUTTER, wide)}>
117              <Markdown text={part.text} />
118            </Box>
119          ))}
120        </Box>
121      </Box>
122    )
123  })
124
125  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
126    const columns = e.viewport?.columns
127    const margin = marginFor(columns)
128
129    if (
130      e.surface !== 'terminal' ||
131      margin === null ||
132      columns === undefined
133    ) {
134      return next(e)
135    }
136
137    // Rows that are not a typed prompt (attachments, notifications) keep the
138    // engine's drawing and only move to the same left edge.
139    if (e.props.origin.kind !== 'composer') {
140      return shift($, e, await next(e))
141    }
142
143    const { Box, Text } = $.ui.resolve(e)
144    const width = Math.min(WIDTH, columns - margin)
145
146    return (
147      <Box marginLeft={margin} marginTop={1} width={width}>
148        <Box width={GUTTER} flexShrink={0}>
149          <Text bold color="claude">
150            ❯
151          </Text>
152        </Box>
153        <Box width={width - GUTTER}>
154          <Text bold color="claude">
155            {e.props.text}
156          </Text>
157        </Box>
158      </Box>
159    )
160  })
161
162  on('ui.render', { component: 'ToolUse' }, async ($, e, next) =>
163    shift($, e, await next(e)),
164  )
165
166  // An expanded group unfolds into ToolUse rows, which shift themselves.
167  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) =>
168    e.props.isExpanded ? next(e) : shift($, e, await next(e)),
169  )
170
171  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) =>
172    shift($, e, await next(e)),
173  )
174
175  on('ui.render', { component: 'Spinner' }, async ($, e, next) =>
176    shift($, e, await next(e)),
177  )
178
179  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) =>
180    shift($, e, await next(e)),
181  )
182}
183