SLOPSHOPPER

casual

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

newrowscommandstatusprompt
v0.1.0MITupdated 2026-10-06mrjk05/modemon/mods/casual
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · casual
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines · Edit /work/app/src/auth.ts ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /casual ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ☺ casual on │ │ style chill · 1-4 sentences │ │ tools folded │ │ /casual off · brief · chill │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ casual: ☺ casual

Draws

Tool row
· Edit /work/app/src/auth.ts
Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ☺ casual on │ │ style chill · 1-4 sentences │ │ tools folded │ │ /casual off · brief · chill │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

casual

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 / after

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

Install

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

Commands

CommandWhat it does
/casual onTurn casual mode on
/casual offBack to Claude Code's default style and transcript
/casual briefEven shorter: 1 or 2 sentences, no small talk (turns it on)
/casual chillThe 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 │
╰─────────────────────────────╯

Config

In /config (or pluginConfigs.casual.options in settings):

OptionValuesDefaultNotes
verbositychill, briefchillThe starting verbosity. Once you run /casual brief or /casual chill, that choice wins.
collapseToolstrue, falsetrueFold finished tool rows into one-liners while casual is on.

What it does

  • Style (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.
  • Collapse (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.
  • Never folded: 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.

Surfaces

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.

TerminalDesktopMobile
Reply style (prompt.compose)yesyesyes
Folded ToolUse / ToolResult / ToolGroup rowsyesyesyes
/casual card (CommandOutput)yesyesyes
☺ casual status entryyesyesyes

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:

  • A tool row keeps the tool name and outcome and cuts the target: a path keeps its end (· Read …/hooks/register.tsx · 146 lines), a command its start.
  • A group's tally drops tools from the end (· 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.
  • An error under a tool row is cut to one line of the width.
  • The /casual card is at most four short lines.

Limitations

  • Thinking can't be hidden by this mod. Thinking blocks don't go through a hookable component in this build (AssistantMessage only carries reply text). Claude Code already keeps thinking out of the default transcript and shows it under ctrl+o.
  • ctrl+o and single tool rows. In this build, 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.
  • Style is guidance, not a guarantee. The model may still write a longer answer when the content calls for it, and the prompt allows that on purpose.
  • Subagents. The 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.
  • Custom output styles. The section is appended alongside any output style you've chosen. If they conflict, use /casual off.
  • Mobile. The phone has no ctrl+o transcript, so there's no way to expand a folded row there; use /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.
  • Built against Claude Code 2.1.290. The mods API is early access and may change.
Source 3 files
hooks/register.tsx 196 lines
1import 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}
196
hooks/lib.ts 348 lines
1// 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}
348
types/index.d.ts 14 lines
1export 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