Short, conversational replies like a teammate on Slack, with tool-call noise folded into one-line summaries.

Short, conversational replies. Claude talks like a friendly senior teammate on Slack ("hey, so here's where we're at"): 1 to 4 short sentences, outcome first, plain words, no headers or bullet walls unless you ask. It stays exact about file paths, commands, numbers and errors, and it never hides a failure or skips a confirmation. Tool-call noise in the transcript folds into one dim line per call or group.
Before:
## Summary
I've completed the requested changes. Here's an overview of what was done:
### Changes Made
- **src/auth.ts**: Updated the token expiry validation logic
- **tests/auth.test.ts**: Verified existing tests
### Testing
All tests are passing.
Let me know if you'd like any further modifications!
After:
Fixed it: the expiry check in `src/auth.ts` was inverted, so expired tokens got
through. `npm test` passes now (48/48). Want me to add a regression test too?
And the transcript, with collapseTools on:
· ran 4 tools (Read ×2, Grep, Edit) · 1 failed: Grep "foo("
· Bash npm test · 52 lines
· Edit src/auth.ts · +1 −1
/plugin install casual --marketplace mrjk05/modemon
Answer y to add the marketplace, then pick a scope. Casual mode is on as soon as it's installed; the status line shows ☺ casual.
| Command | What it does |
|---|---|
/casual on | Turn casual mode on |
/casual off | Back to Claude Code's default style and transcript |
/casual brief | Even shorter: 1 or 2 sentences, no small talk (turns it on) |
/casual chill | The default friendly 1 to 4 sentences (turns it on) |
/casual status (or /casual) | Show the current mode |
Your choice is kept in the plugin's store, so it carries over to new sessions. The answer is drawn as a small card:
╭─────────────────────────────╮
│ ☺ casual on │
│ style brief · 1-2 sentences │
│ tools folded │
│ /casual off · brief · chill │
╰─────────────────────────────╯
In /config (or pluginConfigs.casual.options in settings):
| Option | Values | Default | Notes |
|---|---|---|---|
verbosity | chill, brief | chill | The starting verbosity. Once you run /casual brief or /casual chill, that choice wins. |
collapseTools | true, false | true | Fold finished tool rows into one-liners while casual is on. |
prompt.compose): appends a casual:style section after the engine's own system prompt sections. It covers length, leading with the outcome, prose over markdown, not narrating tool calls, exactness, reporting failures, and keeping confirmations and caveats. It also tells Claude to drop the style when you ask for detail or a write-up, and when writing things that aren't chat (commit messages, PR descriptions, subagent reports, files). It's skipped for in-process teammates.ui.render):ToolGroup: a folded run of reads and searches becomes · ran N tools (Read ×2, Grep, …). Failed calls are added in red. Expanding the group (ctrl+o in the non-fullscreen transcript, or --verbose) shows the engine's own rows.ToolUse: a finished call becomes · Bash npm test · 12 lines, or a red ✗ Bash npm test if it failed. Running calls are left alone.ToolResult: hidden on success, since the row above already says how it went. On error it's one red line with the first line of the error.CommandOutput for /casual: the answer as a compact card (state, style, tool folding, a hint). It's drawn from the row's own text, so an old row keeps showing what it said then. The usage line for an unknown option is left as plain text.AskUserQuestion, ExitPlanMode, EnterPlanMode, TodoWrite, SendUserMessage and SendUserFile. Anything still running is left alone too. The permission dialog is drawn by the engine only, so this mod can't touch it.Works the same on the terminal, desktop (Code tab) and the Claude mobile app (watching a cloud or Remote Control session). The style is part of the system prompt, so it applies wherever the session runs.
| Terminal | Desktop | Mobile | |
|---|---|---|---|
Reply style (prompt.compose) | yes | yes | yes |
Folded ToolUse / ToolResult / ToolGroup rows | yes | yes | yes |
/casual card (CommandOutput) | yes | yes | yes |
☺ casual status entry | yes | yes | yes |
Every drawing uses only Box and Text, which every surface draws. Rows are sized to the surface's viewport.columns (100 when a surface hasn't measured), so they read well on a phone at about 40 columns:
· Read …/hooks/register.tsx · 146 lines), a command its start.· ran 8 tools (Read ×2, Grep, Edit, …)). When the failures don't fit beside it, they move to a red line of their own underneath./casual card is at most four short lines.AssistantMessage only carries reply text). Claude Code already keeps thinking out of the default transcript and shows it under ctrl+o.ToolUse and ToolResult don't say whether they're drawn in the ctrl+o transcript (only ToolGroup and UserMessage have isExpanded). So a single tool call stays folded in ctrl+o too, as does each call in the fullscreen ctrl+o view, which draws groups as separate rows. If a future build adds isExpanded to those rows, the mod already respects it. For now, use /casual off or set collapseTools to false to see full tool output, including Edit diffs and Bash output.prompt.compose input doesn't say whether a prompt is for a subagent, so the section may reach subagents too. Its text tells the model that reports for another agent follow their own conventions./casual off./casual off (or collapseTools: false) to see full tool output on the phone. Whether the card's rounded border is drawn is up to the app; the card reads fine without it. Width fitting counts characters, so very wide glyphs (CJK, emoji) in a path or command can still make a row wrap or truncate on the surface's side.hooks/register.tsx 196 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { CasualPrefs } from '../types'
4import {
5 STATUS_TEXT,
6 STORE_KEY,
7 USAGE,
8 applyAction,
9 cardRows,
10 clip,
11 columnsOf,
12 describePrefs,
13 effectiveVerbosity,
14 errorText,
15 fitCallLine,
16 fitGroupLine,
17 groupErrors,
18 parseArgs,
19 parseDescribed,
20 shouldFold,
21 stylePrompt,
22 toPrefs,
23 withSection,
24} from './lib'
25
26const prefsRef = { plugin: 'casual', key: 'prefs' } as const
27
28/**
29 * The person's prefs: the session's value once set, else what `/casual` last
30 * stored, else on. Read while drawing, it subscribes the row to changes.
31 */
32async function loadPrefs($: EngineInterface): Promise<CasualPrefs> {
33 const held = await $.state.get(prefsRef)
34 if (held.version > 0 && held.value !== undefined) return held.value
35 return toPrefs(await $.store.get(STORE_KEY))
36}
37
38/** Keeps the prefs for this session and the next ones, and shows the badge. */
39async function savePrefs($: EngineInterface, prefs: CasualPrefs): Promise<void> {
40 await $.state.set(prefsRef, prefs)
41 await $.store.set(STORE_KEY, prefs)
42 $.ui.status(prefs.isOn ? STATUS_TEXT : undefined)
43}
44
45/** True where a row asks to be drawn in full (ctrl+o, --verbose), whatever the component. */
46function isExpanded(props: object): boolean {
47 return (props as { isExpanded?: unknown }).isExpanded === true
48}
49
50export const register: Register = (on, options) => {
51 const collapse = options.collapseTools !== false
52
53 on('session.start', async ($, e, next) => {
54 await $.command.register({
55 name: 'casual',
56 description: 'Short, conversational replies: on, off, brief, chill or status.',
57 argumentHint: 'on|off|brief|chill|status',
58 immediate: true,
59 })
60 const prefs = await loadPrefs($)
61 await $.state.set(prefsRef, prefs)
62 $.ui.status(prefs.isOn ? STATUS_TEXT : undefined)
63 return next(e)
64 })
65
66 on('command.run', { command: 'casual' }, async ($, e) => {
67 const action = parseArgs(e.args)
68 if (action.kind === 'usage') return { text: `Unknown option \`${action.arg}\`. ${USAGE}` }
69 const before = await loadPrefs($)
70 const after = applyAction(before, action)
71 if (action.kind !== 'status') await savePrefs($, after)
72 return { text: describePrefs(after, effectiveVerbosity(after, options.verbosity), collapse) }
73 })
74
75 // The style itself: one section after the engine's own, while casual is on.
76 on('prompt.compose', async ($, e, next) => {
77 const composed = await next(e)
78 if (e.traits.includes('teammate')) return composed
79 const prefs = await loadPrefs($)
80 if (!prefs.isOn) return composed
81 const text = stylePrompt(effectiveVerbosity(prefs, options.verbosity))
82 return { sections: withSection(composed.sections, text) }
83 })
84
85 // `/casual`'s answer as a small card, on every surface (the phone included):
86 // drawn from the row's own text, so an old row keeps saying what it said.
87 on('ui.render', { component: 'CommandOutput', props: { command: 'casual' } }, ($, e, next) => {
88 if (e.props.isErrored) return next(e)
89 const state = parseDescribed(e.props.text)
90 if (state === undefined) return next(e)
91 const { Box, Text } = $.ui.resolve(e)
92 const width = columnsOf(e.viewport) - 4
93 const { rows, hint } = cardRows(state)
94 return (
95 <Box flexDirection="column" borderStyle="round" borderColor={state.isOn ? 'claude' : 'inactive'} paddingX={1} alignSelf="flex-start">
96 <Box flexDirection="row" gap={1}>
97 <Text bold>{STATUS_TEXT}</Text>
98 {state.isOn ? <Text color="success">on</Text> : <Text dimColor>off</Text>}
99 </Box>
100 {rows.map(([label, value]) => (
101 <Box flexDirection="row" gap={1}>
102 <Text dimColor>{label.padEnd(5)}</Text>
103 <Text wrap="truncate-end">{clip(value, Math.max(8, width - 6))}</Text>
104 </Box>
105 ))}
106 <Text dimColor wrap="truncate-end">
107 {clip(hint, Math.max(8, width))}
108 </Text>
109 </Box>
110 )
111 })
112
113 if (!collapse) return
114
115 // A finished tool call's row: one dim line, red when it failed. A running
116 // call (it may be waiting on a permission dialog) is drawn as the engine does.
117 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
118 const p = e.props
119 if (isExpanded(p) || p.isRunning || !shouldFold(p.tool)) return next(e)
120 if (!(await loadPrefs($)).isOn) return next(e)
121 const { Box, Text } = $.ui.resolve(e)
122 // Two cells for the glyph, two spare for the transcript's own indent.
123 const line = fitCallLine(p, columnsOf(e.viewport) - 4)
124 return (
125 <Box>
126 {p.isErrored && !p.isInterrupted ? (
127 <Text color="error" wrap="truncate-end">
128 ✗ {line}
129 </Text>
130 ) : (
131 <Text dimColor wrap="truncate-end">
132 · {line}
133 </Text>
134 )}
135 </Box>
136 )
137 })
138
139 // The result under a standalone row: the row's one-liner already says how
140 // it went, so a success draws nothing and an error keeps one red line.
141 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
142 const p = e.props
143 if (isExpanded(p) || !shouldFold(p.tool)) return next(e)
144 if (!(await loadPrefs($)).isOn) return next(e)
145 const { Box, Text } = $.ui.resolve(e)
146 if (p.isErrored) {
147 return (
148 <Box paddingLeft={2}>
149 <Text color="error" wrap="truncate-end">
150 ⎿ {clip(errorText(p.output), columnsOf(e.viewport) - 6)}
151 </Text>
152 </Box>
153 )
154 }
155 return <Box display="none" />
156 })
157
158 // A folded run of reads and searches: `· ran 4 tools (Read ×2, Grep, Edit)`.
159 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
160 const p = e.props
161 if (p.isExpanded || p.calls.length === 0) return next(e)
162 if (p.calls.some(c => !shouldFold(c.tool))) return next(e)
163 if (!(await loadPrefs($)).isOn) return next(e)
164 const { Box, Text } = $.ui.resolve(e)
165 const width = columnsOf(e.viewport) - 4
166 const line = fitGroupLine(p.calls, width)
167 const failed = groupErrors(p.calls)
168 // Side by side when both fit (a wide terminal), else the failures on a
169 // line of their own (a phone), each cut to the width.
170 if (failed === undefined || line.length + failed.length + 3 <= width) {
171 return (
172 <Box flexDirection="row">
173 <Text dimColor wrap="truncate-end">
174 · {line}
175 </Text>
176 {failed === undefined ? null : (
177 <Text color="error" wrap="truncate-end">
178 {' '}· {failed}
179 </Text>
180 )}
181 </Box>
182 )
183 }
184 return (
185 <Box flexDirection="column">
186 <Text dimColor wrap="truncate-end">
187 · {line}
188 </Text>
189 <Text color="error" wrap="truncate-end">
190 {' '}✗ {clip(failed, width - 2)}
191 </Text>
192 </Box>
193 )
194 })
195}
196hooks/lib.ts 348 lines1// Pure helpers for the casual mod: the style prompt, /casual's argument
2// parsing and the one-line summaries of tool rows. Nothing here touches `$`.
3
4import type { CasualPrefs, CasualVerbosity } from '../types'
5
6export const SECTION_ID = 'casual:style'
7export const STORE_KEY = 'prefs'
8export const STATUS_TEXT = '☺ casual'
9
10/** Tools whose rows the person has to read or answer: never folded. */
11const KEEP_WHOLE = new Set([
12 'AskUserQuestion',
13 'ExitPlanMode',
14 'EnterPlanMode',
15 'TodoWrite',
16 'SendUserMessage',
17 'SendUserFile',
18])
19
20const SHARED_RULES = [
21 'Lead with the outcome: what you did, what you found, or what you need from them. Skip preamble, restating the question, and step-by-step recaps of your work.',
22 'Write prose in plain words. No headers, bullet walls or tables unless the person asks for them or a list is genuinely clearer (say, commands they must run in order, or several separate findings).',
23 "Don't narrate before tool calls. At most one short line on what you're about to do, and often nothing.",
24 'Stay exact. File paths, commands, flags, numbers, versions and error messages are written precisely, in backticks. Casual never means vague.',
25 "Never hide or soften a failure. If something broke, a test failed, or you couldn't finish or verify something, say so plainly and early.",
26 'Short never overrides safety. Still ask before destructive or irreversible actions, and still mention real caveats, risks and anything the person has to do next, one clear sentence each.',
27 'This is how you talk to the person, not how you work: be as thorough as ever in the work itself. When you write for something other than this chat (a subagent report, a commit message, a PR description, code comments, a document or file you were asked to write), follow that context\'s own conventions instead.',
28 'If the person asks for more detail, a different format, or a full write-up, do what they ask; their request wins over this style.',
29]
30
31/** The system-prompt section casual mode appends, for one verbosity. */
32export function stylePrompt(verbosity: CasualVerbosity): string {
33 const opening =
34 verbosity === 'brief'
35 ? [
36 '# Reply style: casual, brief',
37 'Talk to the person like a senior teammate on Slack who is busy but helpful: short, direct and plain.',
38 'Default to 1 or 2 short sentences. No greetings, small talk or sign-offs: just the outcome and what matters. Go longer only when the person asks or the content truly needs it (a plan they must approve, a code snippet, several distinct findings).',
39 ]
40 : [
41 '# Reply style: casual',
42 'Talk to the person like a friendly senior teammate on Slack: "hey, so here\'s where we\'re at". Warm, relaxed and to the point; contractions are fine, corporate filler and hype are not, and no emoji unless they use them.',
43 'Default to 1 to 4 short sentences. Go longer only when the person asks or the content truly needs it (a plan they must approve, a code snippet, several distinct findings).',
44 ]
45 const example =
46 verbosity === 'brief'
47 ? 'Example: "Fixed: the expiry check in `src/auth.ts` was inverted. `npm test` passes, 48/48."'
48 : 'Example: instead of a "## Summary" header and five bullets, write "Fixed it: the expiry check in `src/auth.ts` was inverted, so expired tokens got through. `npm test` passes now (48/48). Want me to add a regression test too?"'
49 return [...opening, '', ...SHARED_RULES.map(rule => `- ${rule}`), '', example].join('\n')
50}
51
52/** Appends the casual section (as `session`), replacing an earlier copy of it. */
53export function withSection<S extends { id: string; text: string; scope: 'shared' | 'session' }>(
54 sections: readonly S[],
55 text: string,
56): Array<S | { id: string; text: string; scope: 'session' }> {
57 return [...sections.filter(s => s.id !== SECTION_ID), { id: SECTION_ID, text, scope: 'session' }]
58}
59
60export type CasualAction =
61 | { kind: 'on' }
62 | { kind: 'off' }
63 | { kind: 'verbosity'; verbosity: CasualVerbosity }
64 | { kind: 'status' }
65 | { kind: 'usage'; arg: string }
66
67/** Reads `/casual`'s arguments; nothing at all means `status`. */
68export function parseArgs(args: string): CasualAction {
69 const arg = args.trim().toLowerCase()
70 switch (arg) {
71 case 'on':
72 return { kind: 'on' }
73 case 'off':
74 return { kind: 'off' }
75 case 'brief':
76 case 'chill':
77 return { kind: 'verbosity', verbosity: arg }
78 case '':
79 case 'status':
80 return { kind: 'status' }
81 default:
82 return { kind: 'usage', arg }
83 }
84}
85
86/** Applies an action to the prefs; `status` and `usage` leave them as they are. */
87export function applyAction(prefs: CasualPrefs, action: CasualAction): CasualPrefs {
88 switch (action.kind) {
89 case 'on':
90 return { ...prefs, isOn: true }
91 case 'off':
92 return { ...prefs, isOn: false }
93 case 'verbosity':
94 return { isOn: true, verbosity: action.verbosity }
95 default:
96 return prefs
97 }
98}
99
100export function isVerbosity(value: unknown): value is CasualVerbosity {
101 return value === 'chill' || value === 'brief'
102}
103
104/** A stored value read back, or the default (on) when it is missing or malformed. */
105export function toPrefs(value: unknown): CasualPrefs {
106 if (typeof value !== 'object' || value === null) return { isOn: true }
107 const record = value as Record<string, unknown>
108 const isOn = typeof record.isOn === 'boolean' ? record.isOn : true
109 return isVerbosity(record.verbosity) ? { isOn, verbosity: record.verbosity } : { isOn }
110}
111
112export function effectiveVerbosity(prefs: CasualPrefs, fallback: unknown): CasualVerbosity {
113 return prefs.verbosity ?? (isVerbosity(fallback) ? fallback : 'chill')
114}
115
116export function describePrefs(prefs: CasualPrefs, verbosity: CasualVerbosity, collapse: boolean): string {
117 if (!prefs.isOn) return 'casual is off. Replies use the default style. `/casual on` turns it back on.'
118 const folding = collapse ? 'tool rows folded' : 'tool rows left as they are'
119 return `casual is on (${verbosity}, ${folding}). \`/casual off\` turns it off.`
120}
121
122export const USAGE = 'Usage: `/casual on|off|brief|chill|status`.'
123
124export function shouldFold(tool: string): boolean {
125 return !KEEP_WHOLE.has(tool)
126}
127
128/** Text cut to `max` characters, an ellipsis at the end when cut. */
129export function clip(text: string, max: number): string {
130 if (text.length <= max) return text
131 return max <= 1 ? '…'.slice(0, max) : `${text.slice(0, max - 1)}…`
132}
133
134/** Text cut to `max` characters from the start (`…/src/auth.ts`), for paths. */
135export function clipStart(text: string, max: number): string {
136 if (text.length <= max) return text
137 return max <= 1 ? '…'.slice(0, max) : `…${text.slice(text.length - (max - 1))}`
138}
139
140/** The first non-empty line of a text, cut to `max` characters. */
141export function firstLine(text: string, max = 80): string {
142 return clip(text.split('\n').find(l => l.trim() !== '')?.trim() ?? '', max)
143}
144
145/** Columns used when a surface has not measured itself. */
146export const DEFAULT_COLUMNS = 100
147
148/** The width a row may use: the viewport's, never below 20 cells. */
149export function columnsOf(viewport: { columns: number } | undefined): number {
150 const columns = viewport?.columns
151 return typeof columns === 'number' && Number.isFinite(columns) ? Math.max(20, Math.floor(columns)) : DEFAULT_COLUMNS
152}
153
154function field(input: unknown, name: string): string | undefined {
155 if (typeof input !== 'object' || input === null) return undefined
156 const value = (input as Record<string, unknown>)[name]
157 return typeof value === 'string' && value !== '' ? value : undefined
158}
159
160function lineCount(text: string): number {
161 const trimmed = text.replace(/\n+$/, '')
162 return trimmed === '' ? 0 : trimmed.split('\n').length
163}
164
165function plural(n: number, word: string): string {
166 return `${n} ${word}${n === 1 ? '' : 's'}`
167}
168
169const PATH_FIELDS = new Set(['file_path', 'notebook_path', 'path'])
170const NAMED_FIELDS = ['command', 'file_path', 'notebook_path', 'path', 'url', 'query', 'description', 'skill']
171
172/** What the call was about, uncut: its first line, and whether it is a path. */
173export function rawTarget(input: unknown): { text: string; isPath: boolean } {
174 const pattern = field(input, 'pattern')
175 if (pattern !== undefined) return { text: `"${firstLine(pattern, 1_000)}"`, isPath: false }
176 for (const name of NAMED_FIELDS) {
177 const value = field(input, name)
178 if (value !== undefined) return { text: firstLine(value, 1_000), isPath: PATH_FIELDS.has(name) }
179 }
180 if (typeof input === 'object' && input !== null) {
181 const first = Object.values(input).find((v): v is string => typeof v === 'string' && v !== '')
182 if (first !== undefined) return { text: firstLine(first, 1_000), isPath: false }
183 }
184 return { text: '', isPath: false }
185}
186
187/** Cuts a target to `max`: a path keeps its end (`…/src/auth.ts`), anything else its start. */
188export function clipTarget(target: { text: string; isPath: boolean }, max: number): string {
189 return target.isPath ? clipStart(target.text, max) : clip(target.text, max)
190}
191
192/** What the call was about, in a few words: `npm test`, `src/a.ts`, `"TODO"`. */
193export function callTarget(input: unknown): string {
194 const target = rawTarget(input)
195 return clipTarget(target, target.text.startsWith('"') ? 50 : 70)
196}
197
198/** A few words on how the call came out, from its stored result; '' when unknown. */
199export function outcome(output: unknown): string {
200 if (typeof output === 'string') return output.trim() === '' ? 'no output' : plural(lineCount(output), 'line')
201 if (typeof output !== 'object' || output === null) return ''
202 const o = output as Record<string, unknown>
203 if (typeof o.stdout === 'string') {
204 const text = `${o.stdout}${typeof o.stderr === 'string' && o.stderr !== '' ? `\n${o.stderr}` : ''}`
205 if (typeof o.backgroundTaskId === 'string') return 'in background'
206 return text.trim() === '' ? 'no output' : plural(lineCount(text), 'line')
207 }
208 if (Array.isArray(o.structuredPatch)) {
209 let added = 0
210 let removed = 0
211 for (const hunk of o.structuredPatch as unknown[]) {
212 const lines = (hunk as { lines?: unknown }).lines
213 if (!Array.isArray(lines)) continue
214 for (const line of lines) {
215 if (typeof line !== 'string') continue
216 if (line.startsWith('+')) added += 1
217 else if (line.startsWith('-')) removed += 1
218 }
219 }
220 return `+${added} −${removed}`
221 }
222 if (typeof o.file === 'object' && o.file !== null) {
223 const numLines = (o.file as Record<string, unknown>).numLines
224 if (typeof numLines === 'number') return plural(numLines, 'line')
225 }
226 if (typeof o.numFiles === 'number') {
227 if (typeof o.numMatches === 'number') return plural(o.numMatches, 'match')
228 if (typeof o.numLines === 'number' && o.numLines > 0) return plural(o.numLines, 'line')
229 return plural(o.numFiles, 'file')
230 }
231 return ''
232}
233
234/** The text of an errored call, as the model read it, on one line. */
235export function errorText(output: unknown): string {
236 if (typeof output === 'string') return firstLine(output.replace(/<\/?tool_use_error>/g, ''), 100)
237 if (typeof output === 'object' && output !== null) {
238 const stderr = field(output, 'stderr')
239 if (stderr !== undefined) return firstLine(stderr, 100)
240 const stdout = field(output, 'stdout')
241 if (stdout !== undefined) return firstLine(stdout, 100)
242 }
243 return 'failed'
244}
245
246export type CallLike = {
247 tool: string
248 input: unknown
249 isRunning: boolean
250 isErrored: boolean
251 isInterrupted: boolean
252 output?: unknown
253}
254
255/** The one-liner of a finished tool call: `Bash npm test · 12 lines`. */
256export function callLine(call: CallLike): string {
257 const target = callTarget(call.input)
258 const head = target === '' ? call.tool : `${call.tool} ${target}`
259 if (call.isInterrupted) return `${head} · interrupted`
260 if (call.isErrored) return head
261 const tail = outcome(call.output)
262 return tail === '' ? head : `${head} · ${tail}`
263}
264
265/** `Read ×2, Grep, Edit`, in the order each tool first appears. */
266export function toolTally(tools: readonly string[]): string {
267 const counts = new Map<string, number>()
268 for (const tool of tools) counts.set(tool, (counts.get(tool) ?? 0) + 1)
269 return [...counts].map(([tool, n]) => (n > 1 ? `${tool} ×${n}` : tool)).join(', ')
270}
271
272/** The ToolGroup one-liner: `ran 4 tools (Read ×2, Grep, Edit)`. */
273export function groupLine(calls: readonly CallLike[]): string {
274 const running = calls.some(c => c.isRunning)
275 const verb = running ? 'running' : 'ran'
276 return `${verb} ${plural(calls.length, 'tool')} (${toolTally(calls.map(c => c.tool))})${running ? '…' : ''}`
277}
278
279/** The red tail of a group with failed calls: `1 failed: Grep "foo"`. */
280export function groupErrors(calls: readonly CallLike[]): string | undefined {
281 const failed = calls.filter(c => c.isErrored && !c.isInterrupted)
282 if (failed.length === 0) return undefined
283 const named = failed.slice(0, 2).map(c => {
284 const target = callTarget(c.input)
285 return target === '' ? c.tool : `${c.tool} ${firstLine(target, 40)}`
286 })
287 const more = failed.length > 2 ? `, +${failed.length - 2} more` : ''
288 return `${failed.length} failed: ${named.join(', ')}${more}`
289}
290
291/**
292 * `callLine` sized to `width` cells. The target gives way first (a path keeps
293 * its file name), then the outcome; the tool's name is always kept.
294 */
295export function fitCallLine(call: CallLike, width: number): string {
296 const full = callLine(call)
297 if (full.length <= width) return full
298 const target = rawTarget(call.input)
299 const tail = call.isInterrupted ? 'interrupted' : call.isErrored ? '' : outcome(call.output)
300 const tailText = tail === '' ? '' : ` · ${tail}`
301 for (const withTail of [tailText, '']) {
302 const room = width - call.tool.length - 1 - withTail.length
303 if (target.text !== '' && room >= 8) return `${call.tool} ${clipTarget(target, room)}${withTail}`
304 }
305 return clip(target.text === '' ? `${call.tool}${tailText}` : `${call.tool} ${target.text}`, width)
306}
307
308/**
309 * `groupLine` sized to `width` cells: the tally loses tools from its end
310 * (`ran 9 tools (Read ×4, Grep, …)`), then goes; the count is always kept.
311 */
312export function fitGroupLine(calls: readonly CallLike[], width: number): string {
313 const full = groupLine(calls)
314 if (full.length <= width) return full
315 const running = calls.some(c => c.isRunning)
316 const head = `${running ? 'running' : 'ran'} ${plural(calls.length, 'tool')}`
317 const dots = running ? '…' : ''
318 const parts = toolTally(calls.map(c => c.tool)).split(', ')
319 for (let keep = parts.length - 1; keep >= 1; keep -= 1) {
320 const line = `${head} (${parts.slice(0, keep).join(', ')}, …)${dots}`
321 if (line.length <= width) return line
322 }
323 return clip(`${head}${dots}`, width)
324}
325
326/** What a `/casual` answer row says, read back from `describePrefs`'s text. */
327export type DescribedPrefs = { isOn: false } | { isOn: true; verbosity: CasualVerbosity; isFolding: boolean }
328
329/** Reads `describePrefs`'s text back; undefined for anything else (usage, errors). */
330export function parseDescribed(text: string): DescribedPrefs | undefined {
331 if (text.startsWith('casual is off.')) return { isOn: false }
332 const on = /^casual is on \((chill|brief), (tool rows folded|tool rows left as they are)\)\./.exec(text)
333 if (on === null || !isVerbosity(on[1])) return undefined
334 return { isOn: true, verbosity: on[1], isFolding: on[2] === 'tool rows folded' }
335}
336
337/** The `/casual` card's rows: label, value, hint; each short enough for a phone. */
338export function cardRows(state: DescribedPrefs): { rows: Array<[string, string]>; hint: string } {
339 if (!state.isOn) return { rows: [['style', 'default']], hint: '/casual on to turn it back on' }
340 return {
341 rows: [
342 ['style', state.verbosity === 'brief' ? 'brief · 1-2 sentences' : 'chill · 1-4 sentences'],
343 ['tools', state.isFolding ? 'folded' : 'shown in full'],
344 ],
345 hint: '/casual off · brief · chill',
346 }
347}
348types/index.d.ts 14 lines1export type CasualVerbosity = 'chill' | 'brief'
2
3/**
4 * The person's choice, as `/casual` last set it. `verbosity` is absent until
5 * `/casual brief` or `/casual chill` overrides the `verbosity` option.
6 */
7export type CasualPrefs = { isOn: boolean; verbosity?: CasualVerbosity }
8
9declare module 'claude-code' {
10 interface PluginState {
11 casual: { prefs: CasualPrefs }
12 }
13}
14