SLOPSHOPPER

frame

Rounded frame around your prompts.

newrowsprocess
v0.1.0MITupdated 2026-10-13shalom2552/agent-skills/mods/frame
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · frame
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ❯ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ❯ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

Agent Skills

Skills for coding agents.

Install

curl -fsSL shalom2552.github.io/agent-skills/install | bash

Or clone and run ./install. See ./install --help for options.

Skills

SkillDescription
docs-syncFind and fix drift between docs and code.
dotconfAnswer, change, and remember dotfile and app configs.
read-onlyLock the session to read-only; change nothing.
setup-sh25-skillsInstall, manage and wire up shalom2552's agent-skills assets.
system-auditRead-only health, security and cleanliness audit of this machine.
system-fixDiagnose, fix, and document a system or config fault on this machine.
to-promptWrite a handoff prompt for a fresh agent.
walk-with-meLearn languages and idioms by coding with a coach.

Scripts

scripts/statusline.sh - status line for Claude Code.

Installing it also sets statusLine, from scripts/statusline.settings.json.

Settings

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.

SettingDescription
tmux-truecolorTruecolor inside tmux, so mod hex colors render right.

Mods

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
ModDescription
spinner-wordsSwap the spinner verbs for dev ones.
tape/tape pane of tool calls per turn.
frameRounded frame around your prompts.
beatTurn time and usage as pills.
Source 2 files
hooks/register.tsx 222 lines
1import { 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}
222
types/index.d.ts 8 lines
1export 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