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

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.
hooks/register.tsx 241 lines1import { 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}
241hooks/turns.ts 62 lines1import 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}
62types/index.d.ts 27 lines1// 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