Rounded frame around your prompts.

Skills for coding agents.
curl -fsSL shalom2552.github.io/agent-skills/install | bash
Or clone and run ./install. See ./install --help for options.
| Skill | Description |
|---|---|
docs-sync | Find and fix drift between docs and code. |
dotconf | Answer, change, and remember dotfile and app configs. |
read-only | Lock the session to read-only; change nothing. |
setup-sh25-skills | Install, manage and wire up shalom2552's agent-skills assets. |
system-audit | Read-only health, security and cleanliness audit of this machine. |
system-fix | Diagnose, fix, and document a system or config fault on this machine. |
to-prompt | Write a handoff prompt for a fresh agent. |
walk-with-me | Learn languages and idioms by coding with a coach. |
scripts/statusline.sh - status line for Claude Code.
Installing it also sets statusLine, from scripts/statusline.settings.json.
Small JSON files that ./install merges into the scope's settings.json, so no hand edits.
settings/<name>.json stands alone.<stem>.settings.json beside a skill or script is bound to it and applies each time that entry installs. {{path}} in it becomes the entry's installed path../install settings --scope global -y
To add a setting, add a file. To remove one, delete the file and re-run ./install. Only settings still holding the installed values are removed; one you changed by hand stays. Needs jq.
| Setting | Description |
|---|---|
tmux-truecolor | Truecolor inside tmux, so mod hex colors render right. |
Claude Code plugins in mods/. ./install mods adds this repo as the shalom2552 marketplace and installs them; without the installer:
/plugin install <mod> --marketplace shalom2552/agent-skills
| Mod | Description |
|---|---|
spinner-words | Swap the spinner verbs for dev ones. |
tape | /tape pane of tool calls per turn. |
frame | Rounded frame around your prompts. |
beat | Turn time and usage as pills. |
hooks/register.tsx 222 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FrameStamp } from '../types'
5
6const stamps = atom({ plugin: 'frame', key: 'stamps' } as const, {})
7const count = atom({ plugin: 'frame', key: 'count' } as const, 0)
8const offsetMs = atom({ plugin: 'frame', key: 'offsetMs' } as const, null)
9
10const BORDER = '#89b4fa'
11const MAX_STAMPS = 500
12const MAX_SESSIONS = 50
13const STORE_VERSION = 1
14const SESSIONS_KEY = 'sessions'
15const STAMPS_PREFIX = 'stamps:'
16const LONG = 10_000
17const HEAD = 2_500
18const TAIL = 2_500
19const PASTE = /\n{0,2}<pasted_content id="([0-9a-f]{4})">\n([\s\S]*?)\n<\/pasted_content id="\1">\n{0,2}/g
20const COMMAND_TAGS = /<command-(name|message|args)>[\s\S]*?<\/command-\1>/g
21const COMMAND_NAME = /<command-name>([\s\S]*?)<\/command-name>/
22const COMMAND_ARGS = /<command-args>([\s\S]*?)<\/command-args>/
23const COUNT_ICON = ''
24const CLOCK_ICON = '\u{f0150}'
25
26export const parseOffset = (z: string) => {
27 const m = /^([+-])(\d{2})(\d{2})$/.exec(z.trim())
28 if (m === null) return null
29 const sign = m[1] === '-' ? -1 : 1
30 return sign * (Number(m[2]) * 60 + Number(m[3])) * 60_000
31}
32
33export const clockOf = (at: number, offset: number) => {
34 const minutes = Math.floor((((at + offset) % 86_400_000) + 86_400_000) % 86_400_000 / 60_000)
35 return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`
36}
37
38export const unwrapPastes = (text: string) => {
39 let out = ''
40 let at = 0
41 let afterBlock = false
42 const addText = (piece: string, beforeBlock: boolean) => {
43 let t = afterBlock ? piece.replace(/^\s+/, '') : piece
44 if (beforeBlock) t = t.replace(/\s+$/, '')
45 if (t === '') return
46 if (afterBlock && out !== '') out += '\n'
47 out += t
48 afterBlock = false
49 }
50 for (const m of text.matchAll(PASTE)) {
51 const body = m[2] ?? ''
52 const isBlock = body.includes('\n')
53 addText(text.slice(at, m.index), isBlock)
54 if ((isBlock || afterBlock) && out !== '') out += '\n'
55 out += body
56 afterBlock = isBlock
57 at = m.index + m[0].length
58 }
59 addText(text.slice(at), false)
60 return out
61}
62
63// Undocumented (CC 2.1.296): a slash command's echo row (session.append door 'command') whose text starts
64// with <command-...> tags is drawn by the engine, no UserMessage render. Prefixing "/name args" makes it
65// a UserMessage row; the tags stay for the model. If commands lose the frame, re-probe those two events.
66export const echoOf = (text: string) => {
67 const name = COMMAND_NAME.exec(text)?.[1]
68 if (name === undefined || !text.trimStart().startsWith('<command-')) return undefined
69 const args = COMMAND_ARGS.exec(text)?.[1]?.trim() ?? ''
70 return `${args === '' ? name : `${name} ${args}`}\n${text}`
71}
72
73export const unwrapCommand = (text: string) => (COMMAND_NAME.test(text) ? text.replace(COMMAND_TAGS, '').trim() : text)
74
75export const displayOf = (text: string) => {
76 const shown = unwrapPastes(unwrapCommand(text))
77 if (shown.length <= LONG) return { head: shown, hiddenLines: 0, tail: '' }
78 const head = shown.slice(0, HEAD)
79 const tail = shown.slice(-TAIL)
80 const hiddenLines = shown.slice(HEAD, -TAIL).split('\n').length - 1
81 return { head, hiddenLines, tail }
82}
83
84type Stamps = Record<string, FrameStamp>
85
86const settled = <T,>(p: Promise<T>) => p.then(v => v, () => undefined)
87
88export const storedStampsOf = (raw: unknown): Stamps => {
89 if (typeof raw !== 'object' || raw === null || Reflect.get(raw, 'version') !== STORE_VERSION) return {}
90 const all = Reflect.get(raw, 'stamps')
91 return typeof all === 'object' && all !== null ? all : {}
92}
93
94export const keptSessionsOf = (raw: unknown, id: string) =>
95 [...(Array.isArray(raw) ? raw.filter((s): s is string => typeof s === 'string' && s !== id) : []), id].slice(-MAX_SESSIONS)
96
97const textsOf = (content: readonly { type: string; text?: string }[]) =>
98 content.flatMap(b => (b.type === 'text' && b.text !== undefined ? [b.text.trim()] : []))
99
100const lastOf =(all: Stamps) => Object.values(all).reduce((a, s) => Math.max(a, s.n ?? 0), 0)
101
102const newest = (entries: [string, FrameStamp][]) =>
103 Object.fromEntries(entries.sort(([, a], [, b]) => a.at - b.at).slice(-MAX_STAMPS))
104
105const load = async ($: EngineInterface, cache: Map<string, Stamps>, id: string) => {
106 const cached = cache.get(id)
107 if (cached !== undefined) return cached
108 const all = storedStampsOf(await settled($.store.get(STAMPS_PREFIX + id)))
109 cache.set(id, all)
110 return all
111}
112
113const persist = async ($: EngineInterface, id: string, all: Stamps) => {
114 await $.store.set(STAMPS_PREFIX + id, { version: STORE_VERSION, stamps: all })
115 const kept = keptSessionsOf(await $.store.get(SESSIONS_KEY), id)
116 await $.store.set(SESSIONS_KEY, kept)
117 for (const key of await $.store.keys()) {
118 if (key.startsWith(STAMPS_PREFIX) && !kept.includes(key.slice(STAMPS_PREFIX.length))) await $.store.delete(key)
119 }
120}
121
122const stampRow = async ($: EngineInterface, cache: Map<string, Stamps>, uuid: string, texts: string[], isCounted: boolean) => {
123 const at = await $.clock.now()
124 const id = await settled($.session.id())
125 const prior = id === undefined ? {} : await load($, cache, id)
126 const stamp: FrameStamp = isCounted ? { n: await update($, count, c => Math.max(c, lastOf(prior)) + 1), at, texts } : { at, texts }
127 await update($, stamps, all => newest([...Object.entries(all), [uuid, stamp]]))
128 if (id !== undefined) {
129 const all = newest([...Object.entries(prior), [uuid, stamp]])
130 cache.set(id, all)
131 await settled(persist($, id, all))
132 }
133}
134
135export const register: Register = on => {
136 const stored = new Map<string, Stamps>()
137
138 on('session.start', async ($, e, next) => {
139 const started = await next(e)
140 const ran = await $.process.run(['date', '+%z']).catch(() => undefined)
141 const offset = ran?.exitCode === 0 ? parseOffset(ran.stdout) : null
142 await update($, offsetMs, () => offset)
143 return started
144 })
145
146 on('session.end', async ($, e, next) => {
147 if (e.reason === 'clear' || e.reason === 'resume') {
148 await update($, stamps, () => ({}))
149 await update($, count, () => 0)
150 }
151 return next(e)
152 })
153
154 on('session.append', { door: 'prompt' }, async ($, e, next) => {
155 const kept = await next(e)
156 if (kept.deny === undefined && e.agentId === undefined && e.origin.kind === 'composer') {
157 await stampRow($, stored, kept.uuid, textsOf(kept.message.content), true)
158 }
159 return kept
160 })
161
162 on('session.append', { door: 'command' }, async ($, e, next) => {
163 if (e.agentId !== undefined) return next(e)
164 const content = e.message.content.map(b => {
165 if (b.type !== 'text') return b
166 const echo = echoOf(b.text)
167 return echo === undefined ? b : { ...b, text: echo }
168 })
169 if (content.every((b, i) => b === e.message.content[i])) return next(e)
170 const kept = await next({ ...e, message: { ...e.message, content } })
171 if (kept.deny === undefined) await stampRow($, stored, kept.uuid, textsOf(kept.message.content), false)
172 return kept
173 })
174
175 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
176 const isEcho = COMMAND_NAME.test(e.props.text)
177 if (e.props.isExpanded) return isEcho ? next({ ...e, props: { ...e.props, text: unwrapCommand(e.props.text) } }) : next(e)
178 if (e.props.origin.kind !== 'composer' && !isEcho) return next(e)
179
180 const { Box, Text } = $.ui.resolve(e)
181 const text = e.props.text.trim()
182 const match = (all: Stamps) =>
183 all[e.requestId] ??
184 Object.values(all)
185 .filter(s => s.texts.includes(text))
186 .sort((a, b) => b.at - a.at)[0]
187 let stamp = match(await read($, stamps))
188 if (stamp === undefined) {
189 const id = await settled($.session.id())
190 if (id !== undefined) stamp = match(await load($, stored, id))
191 }
192 const offset = await read($, offsetMs)
193 const shown = displayOf(e.props.text)
194 const parts =
195 stamp === undefined
196 ? []
197 : [
198 ...(stamp.n === undefined ? [] : [`${COUNT_ICON} ${stamp.n}`]),
199 ...(offset === null ? [] : [`${CLOCK_ICON} ${clockOf(stamp.at, offset)}`]),
200 ]
201 const title = parts.length === 0 ? undefined : parts.join(' · ')
202
203 return (
204 <Box flexDirection="column" marginTop={1}>
205 <Box borderStyle="round" borderColor={BORDER} paddingX={1} flexDirection="row">
206 <Text color={BORDER}>❯ </Text>
207 <Box flexGrow={1} flexShrink={1} flexDirection="column">
208 <Text>{shown.head}</Text>
209 {shown.tail !== '' && <Text dimColor>… +{shown.hiddenLines} lines</Text>}
210 {shown.tail !== '' && <Text>{shown.tail}</Text>}
211 </Box>
212 </Box>
213 {title !== undefined && (
214 <Box position="absolute" top={0} left={2}>
215 <Text color={BORDER}> {title} </Text>
216 </Box>
217 )}
218 </Box>
219 )
220 })
221}
222types/index.d.ts 8 lines1export type FrameStamp = { n?: number; at: number; texts: string[] }
2
3declare module 'claude-code' {
4 interface PluginState {
5 frame: { stamps: Record<string, FrameStamp>; count: number; offsetMs: number | null }
6 }
7}
8