SLOPSHOPPER

minimal-ui

Collapses each tool call to one line; a click or /expand shows the full row

newrowsguardcommandprompt
v0.1.0no licenseupdated 2026-10-05eynsfordcq/.dotagents/.claude/mods/minimal-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · minimal-ui
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ▾ edit /work/app/src/auth.ts (+0 -0) ⟨Claude Code's own drawing⟩ ⏺ 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 › /expand ⎿ minimal-ui: Tool calls expanded. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
▾ edit /work/app/src/auth.ts (+0 -0) ⟨Claude Code's own drawing⟩
README

Agent Skills

This repo holds our global agent instructions (AGENTS.md), our skills (.agents/skills/, first-party plus npx-vendored third-party with skills-lock.json pins), and sync-global.sh which symlinks both to every agent location. See MAPPINGS.md for where everything links.

Note: prompts/ is legacy and no longer synced. Three prompts moved into skills (gitlab-issue, orchestrator, read-only). Call them explicitly with /skill-name.

Add a third-party skill:

npx skills add <repo> --skill <name> -a universal
./sync-global.sh

Create a new first-party skill:

npx skills init .agents/skills/<skill-name>

Update all skills from the lockfile:

npx skills update

Commit the changed skill directories and skills-lock.json.

Pi packages (pi-packages.txt) are the global pi extension list. Install them:

./install-pi-packages.sh

Add or remove a package with pi install / pi remove, then update pi-packages.txt to match.

Source 3 files
hooks/register.tsx 241 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MinimalUiTurn } from '../types'
5import { foldOfText, foldOfTool, itemsOf, newTurn, summaryText } from './turns'
6import type { Fold } from './turns'
7
8const MAX_TURNS = 100
9
10const toggled = atom({ plugin: 'minimal-ui', key: 'toggled' } as const, [])
11const openTurns = atom({ plugin: 'minimal-ui', key: 'openTurns' } as const, [])
12const isAllExpanded = atom({ plugin: 'minimal-ui', key: 'isAllExpanded' } as const, false)
13const turns = atom({ plugin: 'minimal-ui', key: 'turns' } as const, [])
14
15type RenderEvent = Parameters<EngineInterface['ui']['resolve']>[0]
16
17// A row or a fold is open when exactly one of these is true: /expand is on, or the person clicked it.
18async function isOpen($: EngineInterface, ids: readonly string[], id: string): Promise<boolean> {
19  return (await read($, isAllExpanded)) !== ids.includes(id)
20}
21
22function flip(ids: string[], id: string): string[] {
23  return ids.includes(id) ? ids.filter(one => one !== id) : [...ids, id]
24}
25
26function updateLastTurn($: EngineInterface, change: (turn: MinimalUiTurn) => MinimalUiTurn) {
27  return update($, turns, list => {
28    const last = list.at(-1)
29    return last === undefined ? list : [...list.slice(0, -1), change(last)]
30  })
31}
32
33// `mcp__plugin__tool` reads as `plugin.tool`; a built-in tool reads in lowercase.
34function toolName(tool: string): string {
35  const parts = tool.split('__')
36  return parts.length === 3 && parts[0] === 'mcp' ? `${parts[1]}.${parts[2]}` : tool.toLowerCase()
37}
38
39// `~/a/b.ts` when short; `…/parent/file` when the path is long.
40function shortPath(path: string): string {
41  const home = path.replace(/^\/(Users|home)\/[^/]+\//, '~/')
42  if (home.length <= 50) return home
43  return `…/${home.split('/').slice(-2).join('/')}`
44}
45
46function lineCount(text: unknown): number {
47  return typeof text === 'string' && text !== '' ? text.split('\n').length : 0
48}
49
50// The one fact about the call that tells calls apart: the command, the path, the pattern.
51function callSummary(tool: string, input: unknown): string {
52  const args = (input ?? {}) as Record<string, unknown>
53  const path = typeof args.file_path === 'string' ? shortPath(args.file_path) : undefined
54  switch (tool) {
55    case 'Bash':
56      return String(args.command ?? '')
57    case 'Edit':
58      return `${path} (+${lineCount(args.new_string)} -${lineCount(args.old_string)})`
59    case 'Write':
60      return `${path} (${lineCount(args.content)} lines)`
61    case 'Read':
62      return path ?? ''
63    case 'Grep':
64    case 'Glob':
65      return String(args.pattern ?? '')
66    case 'Skill':
67      return String(args.skill ?? '')
68    case 'Agent':
69    case 'Task':
70      return String(args.description ?? '')
71    case 'TodoWrite':
72      return `${Array.isArray(args.todos) ? args.todos.length : 0} todos`
73    default:
74      return JSON.stringify(args)
75  }
76}
77
78function oneLine(text: string, width: number): string {
79  const flat = text.replace(/\s+/g, ' ').trim()
80  return flat.length <= width ? flat : `${flat.slice(0, Math.max(1, width - 1))}…`
81}
82
83function mark(props: { isRunning: boolean; isErrored: boolean; isInterrupted: boolean }) {
84  if (props.isInterrupted) return { glyph: '⊘', color: 'yellow' }
85  if (props.isErrored) return { glyph: '✗', color: 'red' }
86  if (props.isRunning) return { glyph: '⋯', color: 'cyan' }
87  return undefined
88}
89
90// The line a fold draws at its first row: `▸ 7 tool calls · 1m 12s`; a click opens the fold.
91function foldHeader($: EngineInterface, e: RenderEvent, fold: Fold, isFoldOpen: boolean) {
92  const { Box, Button, Text } = $.ui.resolve(e)
93
94  return (
95    <Box marginTop={1}>
96      <Text dimColor>{isFoldOpen ? '▾' : '▸'} </Text>
97      <Button
98        key={`fold-${fold.turn.id}`}
99        label={summaryText(fold.turn)}
100        plain
101        dimColor
102        onPress={() => update($, openTurns, ids => flip(ids, fold.turn.id))}
103      />
104    </Box>
105  )
106}
107
108// A row in a fold: the header at the anchor, nothing elsewhere while closed, the row itself while open.
109// A turn that still runs has no fold yet: its rows draw as they come.
110async function inFold($: EngineInterface, e: RenderEvent, fold: Fold | undefined, row: () => Promise<JSX.Element>) {
111  if (fold === undefined || fold.turn.durationMs === null) return row()
112
113  const { Box } = $.ui.resolve(e)
114  const isFoldOpen = await isOpen($, await read($, openTurns), fold.turn.id)
115  if (!isFoldOpen) return fold.isAnchor ? foldHeader($, e, fold, false) : <Box />
116  if (!fold.isAnchor) return row()
117
118  return (
119    <Box flexDirection="column">
120      {foldHeader($, e, fold, true)}
121      {await row()}
122    </Box>
123  )
124}
125
126export const register: Register = on => {
127  on('prompt.submit', async ($, e, next) => {
128    const now = await $.clock.now()
129    await update($, turns, list => [...list, newTurn(String(now), now)].slice(-MAX_TURNS))
130
131    return next(e)
132  })
133
134  on('session.append', async ($, e, next) => {
135    const stored = await next(e)
136    if (e.door === 'response' && e.agentId === undefined) {
137      const items = itemsOf(e.message.content)
138      if (items.length > 0) await updateLastTurn($, turn => ({ ...turn, items: [...turn.items, ...items] }))
139    }
140
141    return stored
142  })
143
144  on('tool.call', async ($, e, next) => {
145    const result = await next(e)
146    const id = e.tool_use_id
147    if (e.agentId === undefined && id !== undefined && 'isError' in result && result.isError) {
148      await updateLastTurn($, turn => ({ ...turn, errored: [...turn.errored, id] }))
149    }
150
151    return result
152  })
153
154  on('turn.complete', async ($, e, next) => {
155    if (e.agentId === undefined) await updateLastTurn($, turn => ({ ...turn, durationMs: e.durationMs }))
156
157    return next(e)
158  })
159
160  on('session.start', async ($, e, next) => {
161    await $.command.register({
162      name: 'expand',
163      description: 'Expand or collapse every turn and tool call row',
164    })
165
166    return next(e)
167  })
168
169  on('command.run', { command: 'expand' }, async $ => {
170    await update($, toggled, () => [])
171    await update($, openTurns, () => [])
172    const isOn = await update($, isAllExpanded, value => !value)
173
174    return { text: isOn ? 'Tool calls expanded.' : 'Tool calls collapsed.' }
175  })
176
177  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
178    if (e.surface !== 'terminal') return next(e)
179
180    const id = e.props.tool_use_id
181
182    return inFold($, e, foldOfTool(await read($, turns), id), async () => {
183      const { Box, Button, Text } = $.ui.resolve(e)
184      const isRowOpen = await isOpen($, await read($, toggled), id)
185      // The status mark takes the place of the arrow, so every row starts with one glyph.
186      const status = mark(e.props)
187      const width = (e.viewport?.columns ?? 100) - 4
188      const label = oneLine(`${toolName(e.props.tool)} ${callSummary(e.props.tool, e.props.input)}`, width)
189      const header = (
190        <Box>
191          {status ? (
192            <Text color={status.color}>{status.glyph} </Text>
193          ) : (
194            <Text dimColor>{isRowOpen ? '▾' : '→'} </Text>
195          )}
196          <Button key={`row-${id}`} label={label} plain dimColor onPress={() => update($, toggled, ids => flip(ids, id))} />
197        </Box>
198      )
199      if (!isRowOpen) return header
200
201      return (
202        <Box flexDirection="column">
203          {header}
204          {await next(e)}
205        </Box>
206      )
207    })
208  })
209
210  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
211    if (e.surface !== 'terminal') return next(e)
212    if (await isOpen($, await read($, toggled), e.props.tool_use_id)) return next(e)
213
214    const { Box } = $.ui.resolve(e)
215
216    return <Box />
217  })
218
219  // A group always unfolds, so each call draws as its own one-line ToolUse row.
220  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
221    if (e.surface !== 'terminal') return next(e)
222
223    const list = await read($, turns)
224    const folds = e.props.calls.flatMap(call => {
225      const fold = call.tool_use_id === undefined ? undefined : foldOfTool(list, call.tool_use_id)
226      return fold === undefined ? [] : [fold]
227    })
228
229    return inFold($, e, folds.find(one => one.isAnchor) ?? folds[0], () =>
230      next({ ...e, props: { ...e.props, isExpanded: true } }),
231    )
232  })
233
234  // Text a tool call follows belongs to the fold; the final message stays as the engine draws it.
235  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
236    if (e.surface !== 'terminal') return next(e)
237
238    return inFold($, e, foldOfText(await read($, turns), e.props.text), () => next(e))
239  })
240}
241
hooks/turns.ts 62 lines
1import type { MinimalUiItem, MinimalUiTurn } from '../types'
2
3// A fold is the part of a turn before its final message: every tool call, and every text a tool call follows.
4export type Fold = { turn: MinimalUiTurn; isAnchor: boolean }
5
6export function newTurn(id: string, startedAt: number): MinimalUiTurn {
7  return { id, startedAt, durationMs: null, items: [], errored: [] }
8}
9
10// The text and tool_use blocks of one stored response, in order.
11export function itemsOf(content: unknown): MinimalUiItem[] {
12  if (!Array.isArray(content)) return []
13  return content.flatMap((block): MinimalUiItem[] => {
14    if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
15      return [{ kind: 'text', text: block.text.trim() }]
16    }
17    if (block?.type === 'tool_use' && typeof block.id === 'string') return [{ kind: 'tool', id: block.id }]
18    return []
19  })
20}
21
22function lastToolIndex(turn: MinimalUiTurn): number {
23  return turn.items.findLastIndex(item => item.kind === 'tool')
24}
25
26function foldAt(turn: MinimalUiTurn, index: number): Fold | undefined {
27  if (index < 0 || index > lastToolIndex(turn)) return undefined
28  return { turn, isAnchor: index === 0 }
29}
30
31export function foldOfTool(turns: readonly MinimalUiTurn[], id: string): Fold | undefined {
32  for (const turn of turns) {
33    const index = turn.items.findIndex(item => item.kind === 'tool' && item.id === id)
34    if (index >= 0) return foldAt(turn, index)
35  }
36  return undefined
37}
38
39// The latest match wins, as two turns can hold the same text.
40export function foldOfText(turns: readonly MinimalUiTurn[], text: string): Fold | undefined {
41  const wanted = text.trim()
42  for (const turn of [...turns].reverse()) {
43    const index = turn.items.findIndex(item => item.kind === 'text' && item.text === wanted)
44    if (index >= 0) return foldAt(turn, index)
45  }
46  return undefined
47}
48
49function duration(ms: number): string {
50  const seconds = Math.round(ms / 1000)
51  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
52}
53
54// `7 tool calls · 1m 12s · 1 error`; the time is absent while the turn runs.
55export function summaryText(turn: MinimalUiTurn): string {
56  const calls = turn.items.filter(item => item.kind === 'tool').length
57  const parts = [`${calls} tool call${calls === 1 ? '' : 's'}`]
58  if (turn.durationMs !== null) parts.push(duration(turn.durationMs))
59  if (turn.errored.length > 0) parts.push(`${turn.errored.length} error${turn.errored.length === 1 ? '' : 's'}`)
60  return parts.join(' · ')
61}
62
types/index.d.ts 27 lines
1// One row of a turn, in the order the model wrote it.
2export type MinimalUiItem = { kind: 'text'; text: string } | { kind: 'tool'; id: string }
3
4// One prompt and the response to it.
5export type MinimalUiTurn = {
6  id: string
7  startedAt: number
8  // Null while the turn runs.
9  durationMs: number | null
10  items: MinimalUiItem[]
11  // The tool_use_ids that ended in an error.
12  errored: string[]
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'minimal-ui': {
18      // The tool_use_ids whose expanded state differs from isAllExpanded.
19      toggled: string[]
20      // The turn ids whose fold is open.
21      openTurns: string[]
22      isAllExpanded: boolean
23      turns: MinimalUiTurn[]
24    }
25  }
26}
27