A read-only mode for questions: Claude answers and investigates but cannot change files, run state-changing commands or send anything; quick, ask and deep…

Personal Claude Code mods for a calmer, more readable terminal.
| Mod | What it does |
|---|---|
readable-width | Draws each piece of a reply in a grey rounded card, as wide as its longest line and wrapped 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, in a rounded accent-colored bubble as wide as their longest line, with a blank line above each message. |
usage-meters | The bottom of the band above the prompt, under the other mods' buttons: ctx, 5h and 7d meters in one rounded frame. Each bar runs teal to green to yellow to red along its length, so a fuller bar reaches warmer colors; 5h and 7d also show the time left until they reset (2h 16m, 3d 4h). The pointer on a meter shows a card over the buttons: the context's tokens and the session's cost, or when a limit resets (today 18:00, Fri 16:00). |
tool-log | Folds tool calls and their results in the chat into a ◆ N tools line, one per run of calls with no text between them, and keeps them in a side log grouped by prompt. Toggle it with the framed ☰ panel button, /panel or a key bound to app:toggleDiffPreSession (ctrl+t in ~/.claude/keybindings.json, see below; this replaces the built-in todo list toggle); 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 framed ± diff button opens the built-in /diff panel. The panel has three tabs: Log, Files (every file changed this session with its diff, new files included) and Agents (subagents with status, time, tool count and their latest step); ☰ 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, e.g. ⠋ 12s · Reading register.tsx or ⠙ 3s · Thinking. |
turn-details | Replaces 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. |
ask-mode | A read-only mode for when you want an answer, not changes. Beside the panel button, a framed quick │ ask │ deep switch: a click picks a level, a click on the active one turns it off; /quick, /ask and /deep do the same, /ask off leaves any. While on, the frame and the level are yellow beside a 🔒, every prompt carries a brief to answer and report, and every call that may change something is refused, in subagents too: Edit, Write, Bash commands off a read-only list (ls, cat, rg, git log/diff/status/show, gh pr view…; redirects and command substitution included), MCP tools whose name does not read (send, create, update…). quick answers briefly on Haiku 5.5 at low effort, ask investigates on your model, deep analyzes at max effort on Opus. It stays on until you turn it off; a new session starts with it off. |
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
claude plugin install ask-mode@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
claude plugin install ask-mode@local-mods
Installed from a local clone, plugins are read straight from the folder.
claude plugin validate ~/.claude/mods/<mod>
claude plugin test ~/.claude/mods/<mod>
A mod cannot add a key of its own, so the ⌃T hint beside ☰ panel 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–3 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.
hooks/register.tsx 178 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { verdict } from './policy'
5import type { Level } from '../types'
6
7// Stays on until it is turned off; a new session starts with it off.
8const level = atom({ plugin: 'ask-mode', key: 'level' } as const, 'off')
9
10// The switch: the band's grey rounded frame, yellow while on.
11const FRAME = '#5a5a5a'
12const FRAME_HOVER = '#9a9a9a'
13const IDLE = '#9a9a9a'
14
15const QUICK_MODEL = 'claude-haiku-5-5'
16const DEEP_MODEL = 'claude-opus-5-5'
17
18const LABELS: Record<Level, string> = {
19 off: 'off',
20 quick: 'quick',
21 ask: 'ask',
22 deep: 'deep',
23}
24
25// What the model reads beside each prompt while the mode is on.
26const BRIEFS: Record<Exclude<Level, 'off'>, string> = {
27 quick:
28 'Ask mode, quick (read-only). The user wants a short, direct answer, not changes. Answer from what you know or a minimal look (a few reads at most). Do not edit files, run commands that change anything, commit, push, install or send anything. If the question needs a real investigation, say so in one line instead of doing it.',
29 ask:
30 'Ask mode (read-only). The user wants information, not changes. Investigate as much as the question needs with read-only tools, then report what you found: the answer first, then the evidence (files, lines, commands). Do not edit files, run commands that change anything, commit, push, install or send anything; where a change would help, describe it and stop.',
31 deep:
32 'Ask mode, deep (read-only). The user wants a thorough analysis, not changes. Read the relevant code widely, trace the flow end to end, check each assumption against the code, and use read-only subagents in parallel where they help. Report the answer, the evidence, the options with their trade-offs and risks, and a recommendation. Do not edit files, run commands that change anything, commit, push, install or send anything.',
33}
34
35const deny = (why: string) =>
36 `Ask mode is on (read-only): ${why}. Do not work around it; answer from what you can read, and say what you would change. The user turns it off with /ask off.`
37
38// The model and effort a step runs on at each level; ask keeps the session's.
39// A model's context suffix (`[1m]`) carries over.
40function stepModel(at: Level, model: string): { model: string; effort: 'low' | 'max' } | undefined {
41 const suffix = /\[[^\]]+\]$/.exec(model)?.[0] ?? ''
42
43 if (at === 'quick') return { model: QUICK_MODEL + suffix, effort: 'low' }
44 if (at === 'deep') return { model: model.includes('opus') ? model : DEEP_MODEL + suffix, effort: 'max' }
45
46 return undefined
47}
48
49async function setLevel($: EngineInterface, to: Level) {
50 await update($, level, () => to)
51}
52
53const confirmation = (to: Level) =>
54 to === 'off'
55 ? 'Ask mode off: Claude can change things again.'
56 : `Ask mode: ${LABELS[to]} (read-only). Nothing gets changed until /ask off.`
57
58export const register: Register = on => {
59 on('session.start', async ($, e, next) => {
60 await $.command.register({
61 name: 'ask',
62 description: 'Read-only mode: Claude only answers and investigates (/ask off to leave)',
63 argumentHint: '[off]',
64 })
65 await $.command.register({ name: 'quick', description: 'Read-only mode with a short answer from a faster model' })
66 await $.command.register({ name: 'deep', description: 'Read-only mode with a thorough analysis at max effort' })
67
68 return next(e)
69 })
70
71 // /ask, /quick and /deep turn their level on, and off when it is on already;
72 // `/ask off` turns any level off.
73 for (const name of ['ask', 'quick', 'deep'] as const) {
74 on('command.run', { command: name }, async ($, e) => {
75 const now = await read($, level)
76 const to: Level = e.args.trim() === 'off' || now === name ? 'off' : name
77 await setLevel($, to)
78
79 return { text: confirmation(to) }
80 })
81 }
82
83 on('prompt.submit', async ($, e, next) => {
84 const at = await read($, level)
85 if (at === 'off') return next(e)
86
87 return next({ ...e, context: [...(e.context ?? []), BRIEFS[at]] })
88 })
89
90 // The guard: a call that may change something is refused, in subagents too.
91 on('tool.call', async ($, e, next) => {
92 const at = await read($, level)
93 if (at === 'off') return next(e)
94
95 const why = verdict(String(e.tool), e as unknown as Record<string, unknown>)
96
97 return why === undefined ? next(e) : { deny: deny(why) }
98 }).catch(($, e, next) => (next.called ? next(e) : { deny: deny('its guard failed') }))
99
100 // Quick and deep run the main loop's steps on their own model and effort; a
101 // request the other model refuses before answering runs on the session's.
102 on('turn.step', async function* ($, e, next) {
103 const at = await read($, level)
104 const want = e.agentId === undefined ? stepModel(at, e.model) : undefined
105 if (want === undefined) return yield* next(e)
106
107 let hasStreamed = false
108 try {
109 const stream = next({ ...e, ...want })
110 while (true) {
111 const step = await stream.next()
112 if (step.done === true) return step.value
113 hasStreamed = true
114 yield step.value
115 }
116 } catch (error) {
117 if (hasStreamed) throw error
118 $.ui.toast(`Ask mode: ${want.model} failed, answering on ${e.model}`)
119 return yield* next(e)
120 }
121 })
122
123 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
124 const below = await next(e)
125
126 if (e.surface !== 'terminal' || e.props.hasSurvey) {
127 return below
128 }
129
130 const { Box, Button, Text } = $.ui.resolve(e)
131 const at = await read($, level)
132 const isOn = at !== 'off'
133
134 // A segmented switch beside the panel's button, in the band's rounded
135 // frame: a click picks a level, a click on the active one turns it off.
136 // While on, the frame and the level are yellow beside a lock, whose cell
137 // is kept while off, so the switch never moves.
138 return (
139 <Box>
140 {below}
141 <Box flexShrink={0}>
142 <Box flexShrink={0} paddingTop={1}>
143 <Text color="warning">{isOn ? '🔒 ' : ' '}</Text>
144 </Box>
145 <Box
146 key="levels"
147 flexShrink={0}
148 borderStyle="round"
149 borderColor={isOn ? 'warning' : FRAME}
150 paddingX={1}
151 hover={isOn ? undefined : { borderColor: FRAME_HOVER }}
152 >
153 {(['quick', 'ask', 'deep'] as const).map((to, i) => {
154 const isActive = at === to
155
156 return (
157 <Box key={`levelbox-${to}`}>
158 {i > 0 && <Text color={FRAME}>{' │ '}</Text>}
159 <Button
160 key={`level-${to}`}
161 plain
162 onPress={() => setLevel($, isActive ? 'off' : to)}
163 hover={isActive ? undefined : { color: 'white' }}
164 >
165 <Text color={isActive ? 'warning' : IDLE} bold={isActive}>
166 {LABELS[to]}
167 </Text>
168 </Button>
169 </Box>
170 )
171 })}
172 </Box>
173 </Box>
174 </Box>
175 )
176 })
177}
178hooks/policy.ts 299 lines1// What ask mode lets through: tools that only read, Bash commands on a
2// read-only list, and MCP tools whose name reads. Everything else is refused,
3// an unknown tool or command included.
4
5import type { Level } from '../types'
6
7
8const READ_TOOLS = new Set([
9 'Read',
10 'Grep',
11 'Glob',
12 'LS',
13 'WebFetch',
14 'WebSearch',
15 'ToolSearch',
16 'Agent',
17 'Task',
18 'TaskOutput',
19 'TaskStop',
20 'AskUserQuestion',
21 'Skill',
22 'SendMessage',
23 'ListAgents',
24 'ListMcpResourcesTool',
25 'ReadMcpResourceTool',
26 'ReadMcpResourceDirTool',
27 'CronList',
28 'EnterPlanMode',
29 'ExitPlanMode',
30 'ReportFindings',
31])
32
33// Tools that read with some actions and write with others.
34const READ_ACTIONS: Record<string, Set<string>> = {
35 Artifact: new Set(['read', 'list', 'quickstart']),
36 ArtifactData: new Set(['get', 'list', 'query']),
37 ArtifactComments: new Set(['list', 'read', 'get']),
38}
39
40const MCP_READ = new Set([
41 'get', 'list', 'search', 'read', 'query', 'fetch', 'find', 'view', 'describe',
42 'whoami', 'guide', 'lookup', 'check', 'show', 'inspect', 'count', 'preview',
43])
44
45const MCP_WRITE = new Set([
46 'send', 'create', 'update', 'delete', 'remove', 'post', 'reply', 'forward',
47 'trash', 'untrash', 'label', 'unlabel', 'mark', 'unmark', 'apply', 'upload',
48 'share', 'add', 'set', 'schedule', 'respond', 'use', 'generate', 'export',
49 'copy', 'move', 'rename', 'edit', 'write', 'run', 'cancel', 'complete',
50 'authenticate', 'download', 'batch', 'submit', 'publish', 'merge', 'close',
51])
52
53// The first verb in an MCP tool's own name decides: `slack_read_channel`
54// reads, `slack_send_message` writes, a name with no known verb is refused.
55function mcpVerdict(tool: string): string | undefined {
56 const name = tool.split('__').pop() ?? tool
57 const words = name.split(/[_\-]|(?<=[a-z])(?=[A-Z])/).map(w => w.toLowerCase())
58 const verb = words.find(w => MCP_READ.has(w) || MCP_WRITE.has(w))
59
60 return verb !== undefined && MCP_READ.has(verb) ? undefined : `${name} may change something`
61}
62
63// Commands that only read, whatever their arguments.
64const READ_COMMANDS = new Set([
65 'ls', 'cat', 'head', 'tail', 'wc', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'fd',
66 'tree', 'pwd', 'echo', 'printf', 'which', 'type', 'whereis', 'file', 'stat',
67 'du', 'df', 'date', 'whoami', 'id', 'uname', 'hostname', 'printenv', 'diff',
68 'cmp', 'comm', 'uniq', 'cut', 'tr', 'column', 'nl', 'basename', 'dirname',
69 'realpath', 'readlink', 'jq', 'yq', 'cd', 'true', 'false', 'test', '[', 'bat',
70 'strings', 'hexdump', 'xxd', 'od', 'md5', 'shasum', 'sha256sum', 'lsof', 'ps',
71 'sleep', 'tldr', 'cal',
72])
73
74const GIT_READ = new Set([
75 'status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'ls-tree',
76 'ls-remote', 'grep', 'shortlog', 'describe', 'cat-file', 'merge-base',
77 'for-each-ref', 'name-rev', 'rev-list', 'count-objects', 'whatchanged',
78 'show-ref', 'show-branch', 'cherry', 'range-diff', 'check-ignore', 'var',
79 'help', 'version',
80])
81
82const GH_READ: Record<string, Set<string>> = {
83 pr: new Set(['view', 'list', 'diff', 'checks', 'status']),
84 issue: new Set(['view', 'list', 'status']),
85 repo: new Set(['view', 'list']),
86 run: new Set(['view', 'list']),
87 release: new Set(['view', 'list']),
88 workflow: new Set(['view', 'list']),
89 label: new Set(['list']),
90 auth: new Set(['status']),
91 search: new Set(['code', 'commits', 'issues', 'prs', 'repos']),
92}
93
94const PACKAGE_READ: Record<string, Set<string>> = {
95 npm: new Set(['ls', 'list', 'view', 'info', 'outdated', 'why', 'explain', 'search', 'help']),
96 pnpm: new Set(['ls', 'list', 'view', 'info', 'outdated', 'why']),
97 yarn: new Set(['list', 'info', 'why', 'outdated']),
98}
99
100// Splits a command line into its simple commands, quotes respected: on
101// `&&`, `||`, `;`, `|`, `&` and newlines. Each comes back as its words, the
102// quotes taken off.
103function commandsOf(line: string): string[][] {
104 const commands: string[][] = []
105 let words: string[] = []
106 let word = ''
107 let hasWord = false
108 let quote: '"' | "'" | null = null
109
110 const endWord = () => {
111 if (hasWord) words.push(word)
112 word = ''
113 hasWord = false
114 }
115 const endCommand = () => {
116 endWord()
117 if (words.length > 0) commands.push(words)
118 words = []
119 }
120
121 for (let i = 0; i < line.length; i++) {
122 const c = line[i] as string
123 if (quote !== null) {
124 if (c === quote) quote = null
125 else if (c === '\\' && quote === '"' && i + 1 < line.length) word += line[++i]
126 else word += c
127 continue
128 }
129 if (c === '"' || c === "'") {
130 quote = c
131 hasWord = true
132 } else if (c === '\\' && i + 1 < line.length) {
133 word += line[++i]
134 hasWord = true
135 } else if (c === ' ' || c === '\t') {
136 endWord()
137 } else if (c === '\n' || c === ';' || c === '|' || c === '&') {
138 endCommand()
139 } else {
140 word += c
141 hasWord = true
142 }
143 }
144 endCommand()
145
146 return commands
147}
148
149const flags = (args: string[]) => args.filter(a => a.startsWith('-'))
150
151function gitVerdict(args: string[]): string | undefined {
152 let i = 0
153 // Global options before the subcommand.
154 while (i < args.length && (args[i] as string).startsWith('-')) {
155 const a = args[i] as string
156 i += a === '-C' || a === '-c' ? 2 : 1
157 }
158 const sub = args[i]
159 const rest = args.slice(i + 1)
160 const no = `git ${sub ?? ''} may change the repository`
161
162 if (sub === undefined) return undefined
163 if (GIT_READ.has(sub)) return sub === 'diff' && rest.includes('--output') ? no : undefined
164
165 switch (sub) {
166 case 'branch': {
167 const isList = rest.some(a => ['-l', '--list', '-a', '-r', '-v', '-vv', '--all', '--remotes', '--contains', '--merged', '--no-merged', '--show-current'].includes(a))
168 const isChange = rest.some(a => /^-(d|D|m|M|c|C|f|u)$|^--(delete|move|copy|force|set-upstream-to|unset-upstream|edit-description)/.test(a))
169 const names = rest.filter(a => !a.startsWith('-'))
170
171 return !isChange && (names.length === 0 || isList) ? undefined : no
172 }
173 case 'tag': {
174 const isChange = rest.some(a => /^-(d|a|s|f|m|u)$|^--(delete|annotate|sign|force)/.test(a))
175 const names = rest.filter(a => !a.startsWith('-'))
176 const isList = rest.some(a => a === '-l' || a === '--list')
177
178 return !isChange && (names.length === 0 || isList) ? undefined : no
179 }
180 case 'remote':
181 return rest.length === 0 || rest[0] === '-v' || rest[0] === 'show' || rest[0] === 'get-url' ? undefined : no
182 case 'stash':
183 return rest[0] === 'list' || rest[0] === 'show' ? undefined : no
184 case 'worktree':
185 return rest[0] === 'list' ? undefined : no
186 case 'reflog':
187 return rest.length === 0 || rest[0] === 'show' || flags(rest).length === rest.length ? undefined : no
188 case 'config':
189 return rest.some(a => ['--get', '--get-all', '--get-regexp', '--list', '-l'].includes(a)) ? undefined : no
190 default:
191 return no
192 }
193}
194
195function ghVerdict(args: string[]): string | undefined {
196 const [group, action] = args
197 const no = `gh ${group ?? ''} ${action ?? ''} may change something`.replace(/\s+/g, ' ').trim()
198
199 if (group === 'api') {
200 const rest = args.slice(1)
201 const method = rest.findIndex(a => a === '-X' || a === '--method')
202 const isGet = method === -1 || (rest[method + 1] ?? '').toUpperCase() === 'GET'
203 const hasBody = rest.some(a => /^(-f|-F|--field|--raw-field|--input)$/.test(a) || /^--(field|raw-field|input)=/.test(a))
204
205 return isGet && !hasBody ? undefined : no
206 }
207
208 return group !== undefined && action !== undefined && GH_READ[group]?.has(action) ? undefined : no
209}
210
211function commandVerdict(words: string[]): string | undefined {
212 let i = 0
213 // Environment assignments and wrappers that only run what follows.
214 while (i < words.length) {
215 const w = words[i] as string
216 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) i += 1
217 else if (w === 'rtk' && words[i + 1] === 'proxy') i += 2
218 else if (w === 'rtk' || w === 'time' || w === 'command') i += 1
219 else break
220 }
221 const rest = words.slice(i)
222 const name = (rest[0] ?? '').split('/').pop() ?? ''
223 const args = rest.slice(1)
224
225 if (name === '') return undefined
226 if (args.length === 1 && args[0] === '--version') return undefined
227 if (READ_COMMANDS.has(name)) return undefined
228
229 switch (name) {
230 case 'find':
231 return args.some(a => /^-(delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$/.test(a))
232 ? 'find with -delete or -exec may change files'
233 : undefined
234 case 'sort':
235 return args.some(a => a === '-o' || a.startsWith('--output')) ? 'sort -o writes a file' : undefined
236 case 'sed':
237 return args.some(a => /^-[a-zA-Z]*i/.test(a) || a.startsWith('--in-place')) ? 'sed -i edits files in place' : undefined
238 case 'awk':
239 return args.some(a => /system\s*\(|print\s*>|\|\s*"/.test(a)) ? 'awk may run commands or write files' : undefined
240 case 'git':
241 return gitVerdict(args)
242 case 'gh':
243 return ghVerdict(args)
244 case 'npm':
245 case 'pnpm':
246 case 'yarn':
247 return args[0] !== undefined && PACKAGE_READ[name]?.has(args[0]) ? undefined : `${name} ${args[0] ?? ''} may change the project`.trim()
248 case 'defaults':
249 return args[0] === 'read' ? undefined : 'defaults may change settings'
250 case 'env': {
251 let j = 0
252 while (j < args.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(args[j] as string) || (args[j] as string).startsWith('-'))) j += 1
253 return commandVerdict(args.slice(j))
254 }
255 case 'xargs': {
256 let j = 0
257 while (j < args.length && (args[j] as string).startsWith('-')) {
258 j += /^-[nILPdEs]$/.test(args[j] as string) ? 2 : 1
259 }
260 return commandVerdict(args.slice(j))
261 }
262 default:
263 return `${name} is not on the read-only list`
264 }
265}
266
267// Undefined when the command line only reads; else why it may not run.
268export function bashVerdict(command: string): string | undefined {
269 if (/\$\(|`|<\(|>\(/.test(command)) return 'a command substitution may run anything'
270
271 // Redirects that write no file go first, so their `&` splits nothing.
272 const cleaned = command
273 .replace(/\d?>&\d/g, '')
274 .replace(/(&|\d)?>>?\s*\/dev\/null/g, '')
275 if (cleaned.includes('>')) return 'a redirect writes a file'
276
277 for (const words of commandsOf(cleaned)) {
278 const why = commandVerdict(words)
279 if (why !== undefined) return why
280 }
281
282 return undefined
283}
284
285// Undefined when the call only reads; else why ask mode refuses it.
286export function verdict(tool: string, input: Record<string, unknown>): string | undefined {
287 if (READ_TOOLS.has(tool)) return undefined
288 if (tool === 'Bash') return bashVerdict(typeof input.command === 'string' ? input.command : '')
289 if (tool.startsWith('mcp__')) return mcpVerdict(tool)
290
291 const actions = READ_ACTIONS[tool]
292 if (actions !== undefined) {
293 const action = typeof input.action === 'string' ? input.action : tool === 'Artifact' ? 'publish' : ''
294 return actions.has(action) ? undefined : `${tool} ${action} may change something`.trim()
295 }
296
297 return `${tool} may change something`
298}
299types/index.d.ts 10 lines1export type Level = 'off' | 'quick' | 'ask' | 'deep'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'ask-mode': {
6 level: Level
7 }
8 }
9}
10