SLOPSHOPPER

slopothon

Draws a Marathon-style SLOPOTHON banner above the prompt until the first prompt is sent

newspinnerrowscommandprocess
v0.1.0MITupdated 2026-10-09binbandit/bin-stack/mods/slopothon
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slopothon
› 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 › /slopothon ▄▄▄▄▄▄ ▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄ ▄▄ ▄▄ 01 // MODEL __________________ … ██▄▄▄▄ ██ ██ ██ ██▄▄██ ██ ██ ██ ██▄▄██ ██ ██ ██▀▄ ██ ⡔⠒⡄⡖⠒⡄⡆ ⡆⡔⠒⠂ ⣖⣒⠂ ⣖⣒⠂ … ▄▄▄▄██ ██▄▄▄▄ ██▄▄██ ██ ██▄▄██ ██ ██ ██ ██▄▄██ ██ ▀██ ⢇⣀⠇⡏⠉ ⢇⣀⠇⣈⣉⠆ ⢄⣀⠇⢠⡄ ⢄⣀⠇ … ONE MORE PROMPT ╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱ │││█▌║│█▌║│█▌║ BRANCH // feat/auth-refresh __ … + SESSION EEE- _ BUILD 2.1.289 ____________________________________________ スロップ・マラソン + … ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Command output
▄▄▄▄▄▄ ▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄ ▄▄ ▄▄ 01 // MODEL __________________ … ██▄▄▄▄ ██ ██ ██ ██▄▄██ ██ ██ ██ ██▄▄██ ██ ██ ██▀▄ ██ ⡔⠒⡄⡖⠒⡄⡆ ⡆⡔⠒⠂ ⣖⣒⠂ ⣖⣒⠂ … ▄▄▄▄██ ██▄▄▄▄ ██▄▄██ ██ ██▄▄██ ██ ██ ██ ██▄▄██ ██ ▀██ ⢇⣀⠇⡏⠉ ⢇⣀⠇⣈⣉⠆ ⢄⣀⠇⢠⡄ ⢄⣀⠇ … ONE MORE PROMPT ╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱ │││█▌║│█▌║│█▌║ BRANCH // feat/auth-refresh __ … + SESSION EEE- _ BUILD 2.1.289 ____________________________________________ スロップ・マラソン + …
README

bin-stack

A harness-agnostic agent skill stack.

Skills follow the Agent Skills format (SKILL.md + optional scripts/references/assets). One source of truth under skills/. Works with Cursor, Claude Code, Codex, and any other agent that discovers skills the standard way.

Inspired by stacks like pstack, but not locked to a single product.

Install

Via skills CLI

npx skills add binbandit/bin-stack

Pick specific skills:

npx skills add binbandit/bin-stack --skill file-pr --skill babysit-pr --skill arena --skill bro

Symlink a clone (one copy, every harness)

git clone https://github.com/binbandit/bin-stack.git ~/Developer/ai/bin-stack
cd ~/Developer/ai/bin-stack
./scripts/link-skills.sh

That links each skill directory into the harness roots you already have (~/.agents/skills, ~/.cursor/skills, ~/.claude/skills, ~/.codex/skills, …). Edit here; every harness sees the same files.

Skills

SkillWhen to use
file-prFile or update a concise pull request
babysit-prMonitor a PR through review bots and CI
arenaRun competing attempts, then graft the strongest ideas into one result
broRestate the last message in plain language
verifyProve a behavior change on the user surface with revision-bound evidence
reviewFilter adversarial findings by reachable failure and concrete evidence

Mods

Claude Code mods: plugins of function hooks that change how Claude Code looks and behaves. They are Claude Code only.

ModWhat it does
glamour-darkDraws replies as rich markdown in glamour's dark style, one sentence per line
marathon-footerRestyles the hints after the mode label under the prompt to match the SLOPOTHON banner: ⏵⏵ auto mode on · ⇧⇥ CYCLE // ← 1 AGENT
redact-secretsSwaps secrets for placeholders before Claude reads them or the session logs them; Write and Edit put the real values back
tool-linesDraws each tool call as one compact line and hides its result block
ascii-spinnerSwaps the working line (verb, timer, tokens, effort, tip) for a one-row WEAVEworm rethreading synthsilk, in Sekiguchi colors
slopothonPaints a SLOPOTHON lockup over the Claude logo in Marathon's design language: block wordmark, a dot-matrix card naming the model and branch, and a livery strip with a tagline and barcode per session. Fullscreen ("tui": "fullscreen") only; it starts each session with a /slopothon row and the logo shows for ~130ms first. Type /slopothon to redraw it inline

Install every mod at user scope:

./scripts/install-mods.sh

The script adds mods/ as the bin-stack marketplace and installs each mod from it. Claude Code reads the mods in place from this clone, so an edit here applies on the next session or /reload-plugins. Re-run it after adding a mod.

Layout

skills/
  file-pr/SKILL.md
  babysit-pr/SKILL.md
  arena/SKILL.md
  bro/SKILL.md
  verify/SKILL.md
  review/SKILL.md
mods/
  .claude-plugin/marketplace.json
  glamour-dark/
  marathon-footer/
  redact-secrets/
  tool-lines/
  ascii-spinner/
  slopothon/
scripts/
  link-skills.sh   # optional: symlink into local harness skill roots
  install-mods.sh  # install the Claude Code mods

Canonical skills live in skills/. Harness-specific paths are adapters (symlinks or CLI installs), not forks of the content.

Authoring

  1. Add skills/<name>/SKILL.md with required name and description frontmatter.
  2. Keep name matching the directory. Lowercase, hyphens only.
  3. Put long reference material in references/; keep SKILL.md short enough to load on activation.
  4. Prefer harness-neutral wording. Call out Cursor / Claude / Codex only when a step truly differs.

See agentskills.io/specification.

License

MIT

The verify and review skills adapt selected pstack guidance by Lauren Tan at revision 9f451cf875ad1239912762f67741e8e5ba6ac0f1. Each skill bundles its provenance and the original MIT notice in LICENSE-pstack so attribution travels with individual skill installs.

Source 1 files
hooks/register.ts 420 lines
1import type { Elements, EngineInterface, Register, RenderNode, SessionMessage, TextProps } from 'claude-code'
2
3const VOLT = '#C8FF00'
4const BONE = '#E4E4DC'
5const INK = '#0A0A0A'
6const SIGNAL_PINK = '#FF2E88'
7
8const PLUGIN = 'slopothon'
9const COMMAND = 'slopothon'
10const BOOT_TEXT = 'SLOPOTHON'
11const SHOW_TEXT = 'SLOPOTHON banner'
12
13const WORDMARK = [
14  '▄▄▄▄▄▄ ▄▄     ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄  ▄▄ ▄▄▄▄▄▄ ▄▄   ▄▄',
15  '██▄▄▄▄ ██     ██  ██ ██▄▄██ ██  ██   ██   ██▄▄██ ██  ██ ██▀▄ ██',
16  '▄▄▄▄██ ██▄▄▄▄ ██▄▄██ ██     ██▄▄██   ██   ██  ██ ██▄▄██ ██  ▀██',
17]
18const WORDMARK_COLUMNS = WORDMARK[0]?.length ?? 0
19const LETTER_PITCH = 7
20const SLOP_COLUMNS = 'SLOP'.length * LETTER_PITCH
21const SPACED_SLOP = 'S L O P '
22const SPACED_OTHON = 'O T H O N'
23
24const TAGLINES = [
25  'SLOP IS A MARATHON, NOT A SPRINT',
26  'NO DIFF LEFT BEHIND',
27  'TOKENS IN, SLOP OUT',
28  'PROMPT. DIFF. REPEAT.',
29  "GREEN TESTS OR IT DIDN'T HAPPEN",
30  'READ THE DIFF BEFORE YOU SHIP',
31  'ONE MORE PROMPT',
32  'ALL GAS, SOME BRAKES',
33]
34const BARS = ['▌', '║', '│', '█']
35const BARCODE_COLUMNS = 14
36const KATAKANA = 'スロップ・マラソン'
37const NO_GIT = 'NO GIT'
38const COLUMN_GAP = '   '
39const CARD_COLUMNS = 30
40const DOT_MATRIX_PITCH = 3
41const PLATE_COLUMNS = WORDMARK_COLUMNS + COLUMN_GAP.length + CARD_COLUMNS
42const UNMEASURED_COLUMNS = 80
43
44const DOT_MATRIX_FONT: Record<string, string> = {
45  A: '.###.|#...#|#...#|#####|#...#|#...#|#...#',
46  B: '####.|#...#|#...#|####.|#...#|#...#|####.',
47  C: '.###.|#...#|#....|#....|#....|#...#|.###.',
48  D: '####.|#...#|#...#|#...#|#...#|#...#|####.',
49  E: '#####|#....|#....|####.|#....|#....|#####',
50  F: '#####|#....|#....|####.|#....|#....|#....',
51  G: '.###.|#...#|#....|#.###|#...#|#...#|.####',
52  H: '#...#|#...#|#...#|#####|#...#|#...#|#...#',
53  I: '.###.|..#..|..#..|..#..|..#..|..#..|.###.',
54  J: '..###|...#.|...#.|...#.|...#.|#..#.|.##..',
55  K: '#...#|#..#.|#.#..|##...|#.#..|#..#.|#...#',
56  L: '#....|#....|#....|#....|#....|#....|#####',
57  M: '#...#|##.##|#.#.#|#.#.#|#...#|#...#|#...#',
58  N: '#...#|#...#|##..#|#.#.#|#..##|#...#|#...#',
59  O: '.###.|#...#|#...#|#...#|#...#|#...#|.###.',
60  P: '####.|#...#|#...#|####.|#....|#....|#....',
61  Q: '.###.|#...#|#...#|#...#|#.#.#|#..#.|.##.#',
62  R: '####.|#...#|#...#|####.|#.#..|#..#.|#...#',
63  S: '.####|#....|#....|.###.|....#|....#|####.',
64  T: '#####|..#..|..#..|..#..|..#..|..#..|..#..',
65  U: '#...#|#...#|#...#|#...#|#...#|#...#|.###.',
66  V: '#...#|#...#|#...#|#...#|#...#|.#.#.|..#..',
67  W: '#...#|#...#|#...#|#.#.#|#.#.#|#.#.#|.#.#.',
68  X: '#...#|#...#|.#.#.|..#..|.#.#.|#...#|#...#',
69  Y: '#...#|#...#|.#.#.|..#..|..#..|..#..|..#..',
70  Z: '#####|....#|...#.|..#..|.#...|#....|#####',
71  '0': '.###.|#...#|#..##|#.#.#|##..#|#...#|.###.',
72  '1': '..#..|.##..|..#..|..#..|..#..|..#..|.###.',
73  '2': '.###.|#...#|....#|...#.|..#..|.#...|#####',
74  '3': '#####|...#.|..#..|...#.|....#|#...#|.###.',
75  '4': '...#.|..##.|.#.#.|#..#.|#####|...#.|...#.',
76  '5': '#####|#....|####.|....#|....#|#...#|.###.',
77  '6': '..##.|.#...|#....|####.|#...#|#...#|.###.',
78  '7': '#####|....#|...#.|..#..|.#...|.#...|.#...',
79  '8': '.###.|#...#|#...#|.###.|#...#|#...#|.###.',
80  '9': '.###.|#...#|#...#|.####|....#|...#.|.##..',
81  '.': '.....|.....|.....|.....|.....|.##..|.##..',
82  '-': '.....|.....|.....|#####|.....|.....|.....',
83  '/': '.....|....#|...#.|..#..|.#...|#....|.....',
84}
85const BRAILLE_DOTS = [
86  [0x01, 0x08],
87  [0x02, 0x10],
88  [0x04, 0x20],
89  [0x40, 0x80],
90]
91const BRAILLE_BLANK = 0x2800
92
93const ROWS_FROM_LOGO_TO_BOOT_ROW = 6
94const ROWS_PER_RECORD_ABOVE_BOOT_ROW = 2
95const ROWS_FROM_LOGO_TO_FIRST_PROMPT = 4
96const SLACK_ROWS = 3
97const TRANSCRIPT_HEAD_BYTES = 512 * 1024
98const CHAT_ENTRY_TYPES = new Set(['user', 'assistant', 'system'])
99const BOOT_RECORD_TEXT = `<command-name>/${COMMAND}</command-name>`
100
101type Run = { version: string; model: string; branch: string | undefined; id: string }
102type Notice = { text: string; command: string | null }
103type Style = Pick<TextProps, 'color' | 'backgroundColor' | 'bold' | 'dimColor'>
104type Span = { text: string; style?: Style }
105type Line = Span[]
106type TerminalElements = Elements['terminal']
107
108const DIM: Style = { dimColor: true }
109const MARK: Style = { color: SIGNAL_PINK }
110const PLAIN: Style = { color: BONE }
111
112type Anchor = { row: 'boot'; recordsAbove: number } | { row: 'first prompt'; id: string }
113type EntryKind = 'meta' | 'prompt' | 'boot' | 'command' | 'shell' | 'output' | 'reply'
114type TranscriptEntry = { uuid: string; parent: string | null; kind: EntryKind }
115
116let run: Run | undefined
117let anchor: Anchor | undefined
118let resumedAnchor: Anchor | undefined
119const notices = new Map<string, Notice>()
120
121const FAMILIES = 'fable|mythos|opus|sonnet|haiku'
122const FAMILY_THEN_VERSION = new RegExp(`(${FAMILIES})[-_ ]?(\\d{1,2})(?!\\d)(?:[-.](\\d{1,2})(?!\\d))?`)
123const VERSION_THEN_FAMILY = new RegExp(`claude-(\\d)(?:[-.](\\d))?-(${FAMILIES})`)
124const FAMILY_ONLY = new RegExp(`(${FAMILIES})`)
125const OPAQUE_BEDROCK_ARN = /^arn:aws[\w-]*:bedrock:/
126
127const versioned = (family: string, major: string, minor: string | undefined) =>
128  `${family.toUpperCase()} ${minor === undefined ? major : `${major}.${minor}`}`
129
130function modelLabel(model: string): string {
131  const routed = model.toLowerCase()
132  const id = routed.split('/').at(-1) ?? routed
133  const familyFirst = FAMILY_THEN_VERSION.exec(id)
134  if (familyFirst !== null) return versioned(familyFirst[1] ?? '', familyFirst[2] ?? '', familyFirst[3])
135  const versionFirst = VERSION_THEN_FAMILY.exec(id)
136  if (versionFirst !== null) return versioned(versionFirst[3] ?? '', versionFirst[1] ?? '', versionFirst[2])
137  const family = FAMILY_ONLY.exec(id)?.[1]
138  if (family !== undefined) return family.toUpperCase()
139  if (OPAQUE_BEDROCK_ARN.test(routed)) return 'BEDROCK'
140  const name = id
141    .replace(/\[.*\]$/, '')
142    .replace(/@.*$/, '')
143    .replace(/-v\d+(:\d+)?$/, '')
144    .replace(/^([a-z]+\.)+/, '')
145  return name.replace(/[^a-z0-9.\-/ ]/g, '-').toUpperCase()
146}
147
148async function currentBranch($: EngineInterface, cwd: string): Promise<string | undefined> {
149  const git = (...args: string[]) => $.process.run(['git', ...args], { cwd, timeoutMs: 2000 }).catch(() => undefined)
150  const branch = await git('symbolic-ref', '--short', '-q', 'HEAD')
151  if (branch?.exitCode === 0) return branch.stdout.trim()
152  const detached = await git('rev-parse', '--short', 'HEAD')
153  return detached?.exitCode === 0 ? `HEAD@${detached.stdout.trim()}` : undefined
154}
155
156const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
157
158function promptTextOf(content: unknown): string | undefined {
159  if (typeof content === 'string') return content
160  const first = Array.isArray(content) ? content.find(isRecord) : undefined
161  return first?.type === 'text' && typeof first.text === 'string' ? first.text : undefined
162}
163
164function userEntryKind(text: string | undefined): EntryKind {
165  if (text === undefined) return 'output'
166  if (text.startsWith(BOOT_RECORD_TEXT)) return 'boot'
167  if (text.startsWith('<command-name>')) return 'command'
168  if (text.startsWith('<bash-input>')) return 'shell'
169  if (/^\s*<(?:bash-|local-command)/.test(text)) return 'output'
170  return 'prompt'
171}
172
173function transcriptEntryOf(line: string): TranscriptEntry | undefined {
174  let value: unknown
175  try {
176    value = JSON.parse(line)
177  } catch {
178    return undefined
179  }
180  if (!isRecord(value) || typeof value.uuid !== 'string' || typeof value.type !== 'string' || !CHAT_ENTRY_TYPES.has(value.type)) return undefined
181  const parent = typeof value.parentUuid === 'string' ? value.parentUuid : null
182  if (value.isMeta === true) return { uuid: value.uuid, parent, kind: 'meta' }
183  if (value.type === 'system') return { uuid: value.uuid, parent, kind: 'output' }
184  if (value.type === 'assistant') return { uuid: value.uuid, parent, kind: 'reply' }
185  return { uuid: value.uuid, parent, kind: userEntryKind(isRecord(value.message) ? promptTextOf(value.message.content) : undefined) }
186}
187
188async function resumedAnchorOf($: EngineInterface, transcriptPath: string): Promise<Anchor | undefined> {
189  const head = await $.process.run(['head', '-c', String(TRANSCRIPT_HEAD_BYTES), transcriptPath], { timeoutMs: 2000 }).catch(() => undefined)
190  if (head?.exitCode !== 0) return undefined
191  const entries = head.stdout.split('\n').flatMap(line => transcriptEntryOf(line) ?? [])
192  const firstChildOf = new Map(entries.toReversed().flatMap(entry => (entry.parent === null ? [] : [[entry.parent, entry] as const])))
193  let recordsAbove = 0
194  for (let entry = entries.find(candidate => candidate.parent === null); entry !== undefined; entry = firstChildOf.get(entry.uuid)) {
195    if (entry.kind === 'boot') return { row: 'boot', recordsAbove }
196    if (entry.kind === 'prompt') return recordsAbove === 0 ? { row: 'first prompt', id: entry.uuid } : undefined
197    if (entry.kind === 'command') recordsAbove += 1
198    if (entry.kind === 'shell' || entry.kind === 'reply') return undefined
199  }
200  return undefined
201}
202
203const isCommandRecord = (message: SessionMessage) => message.text.startsWith('<command-name>/')
204
205const isBootRecord = (message: SessionMessage) => message.text.startsWith(BOOT_RECORD_TEXT)
206
207const isWide = (char: string) => /[\u3000-\u30ff]/.test(char)
208
209const cellsOf = (text: string) => [...text].reduce((sum, char) => sum + (isWide(char) ? 2 : 1), 0)
210
211const width = (line: Line) => line.reduce((sum, piece) => sum + cellsOf(piece.text), 0)
212
213const span = (text: string, style?: Style): Span => ({ text, style })
214
215const rule = (cells: number): Span => span('_'.repeat(Math.max(cells, 1)), DIM)
216
217const shortened = (text: string, cells: number) => (text.length <= cells ? text : `${text.slice(0, cells - 1)}…`)
218
219const runDigits = (id: string) => [...id.replace(/[^0-9a-f]/gi, '')].map(digit => Number.parseInt(digit, 16))
220
221const runCode = (id: string) => {
222  const hex = id.replace(/[^0-9a-f]/gi, '').toUpperCase()
223  return `${hex.slice(0, 4)}-${hex.slice(4, 8)}`
224}
225
226function dotMatrix(text: string): string[] {
227  const letters = [...text.toUpperCase()].slice(0, CARD_COLUMNS / DOT_MATRIX_PITCH)
228  const glyphs = letters.map(letter => DOT_MATRIX_FONT[letter]?.split('|') ?? [])
229  const isLit = (x: number, y: number) => glyphs[Math.floor(x / 6)]?.[y - 1]?.[x % 6] === '#'
230  return [0, 1].map(row =>
231    Array.from({ length: glyphs.length * DOT_MATRIX_PITCH }, (_, cell) => {
232      const dots = BRAILLE_DOTS.flatMap((pair, dy) => pair.filter((_, dx) => isLit(cell * 2 + dx, row * 4 + dy)))
233      return dots.length === 0 ? ' ' : String.fromCharCode(BRAILLE_BLANK + dots.reduce((sum, dot) => sum + dot, 0))
234    })
235      .join('')
236      .trimEnd(),
237  )
238}
239
240const wordmarkRow = (row: string): Line => [
241  span(row.slice(0, SLOP_COLUMNS), { color: VOLT }),
242  span(row.slice(SLOP_COLUMNS), { color: BONE }),
243]
244
245function tagStrip(id: string): Line {
246  const digits = runDigits(id)
247  const tagline = ` ${TAGLINES[(digits[0] ?? 0) % TAGLINES.length]} `
248  const barcode = Array.from({ length: BARCODE_COLUMNS }, (_, i) => BARS[(digits.at(-1 - i) ?? i) % BARS.length]).join('')
249  const hazard = '╱'.repeat(WORDMARK_COLUMNS - tagline.length - barcode.length - 2)
250  return [span(tagline, { color: INK, backgroundColor: VOLT, bold: true }), span(` ${hazard} `, MARK), span(barcode, DIM)]
251}
252
253function modelCard({ model, branch }: Run): Line[] {
254  const header = [span('01', { ...MARK, bold: true }), span(' // ', DIM), span('MODEL', PLAIN), span(' ')]
255  const branchLabel = [span('BRANCH', PLAIN), span(' // ', DIM)]
256  const branchName =
257    branch === undefined ? span(NO_GIT, DIM) : span(shortened(branch, CARD_COLUMNS - width(branchLabel) - 2), { ...PLAIN, bold: true })
258  const footer = [...branchLabel, branchName, span(' ')]
259  return [
260    [...header, rule(CARD_COLUMNS - width(header))],
261    ...dotMatrix(model).map(row => [span(row, { color: VOLT })]),
262    [...footer, rule(CARD_COLUMNS - width(footer))],
263  ]
264}
265
266function microLine({ id, version }: Run): Line {
267  const left = [span('+ ', MARK), span('SESSION ', DIM), span(runCode(id), PLAIN), span(' _ BUILD ', DIM), span(version, PLAIN), span(' ')]
268  const right = [span(` ${KATAKANA}`, DIM), span(' +', MARK)]
269  return [...left, rule(PLATE_COLUMNS - width(left) - width(right)), ...right]
270}
271
272const detailSpans = ({ model, branch, version }: Run): Line[] => [
273  [span('MODEL', PLAIN), span(' // ', DIM), span(model, { color: VOLT })],
274  [span('BRANCH', PLAIN), span(' // ', DIM), branch === undefined ? span(NO_GIT, DIM) : span(branch, { ...PLAIN, bold: true })],
275  [span('BUILD', PLAIN), span(' // ', DIM), span(version, DIM)],
276]
277
278function bannerLines(columns: number, current: Run | undefined): Line[] {
279  const wordmark = WORDMARK.map(wordmarkRow)
280  if (current === undefined) return wordmark
281  if (columns >= PLATE_COLUMNS) {
282    const card = modelCard(current)
283    const left = [...wordmark, tagStrip(current.id)]
284    return [...left.map((line, i) => [...line, span(COLUMN_GAP), ...(card[i] ?? [])]), microLine(current)]
285  }
286  if (columns >= WORDMARK_COLUMNS) {
287    const details = detailSpans(current).flatMap((line, i) => (i === 0 ? line : [span('   '), ...line]))
288    return [...wordmark, tagStrip(current.id), [span('+ ', MARK), ...details]]
289  }
290  const spacedName = [span(SPACED_SLOP, { color: VOLT, bold: true }), span(SPACED_OTHON, { color: BONE, bold: true })]
291  return [spacedName, ...detailSpans(current)]
292}
293
294function clipToColumns(line: Line, columns: number): Line {
295  let room = columns
296  const clipped: Line = []
297  for (const piece of line) {
298    let text = ''
299    for (const char of piece.text) {
300      if (cellsOf(char) > room) break
301      text += char
302      room -= cellsOf(char)
303    }
304    clipped.push({ ...piece, text })
305    if (text.length < piece.text.length) break
306  }
307  return [...clipped, span(' '.repeat(room))]
308}
309
310const drawLine = ({ Text }: TerminalElements, line: Line) =>
311  Text({ wrap: 'truncate-end', children: line.map(piece => Text({ ...piece.style, children: [piece.text] })) })
312
313const drawNotice = ({ Box, Text }: TerminalElements, { text, command }: Notice) =>
314  Box({
315    flexDirection: 'row',
316    children: [
317      Text({ dimColor: true, children: ['▎ '] }),
318      Text({ dimColor: true, children: [command === null ? text : `${text} ${command}`] }),
319    ],
320  })
321
322function coverOver(elements: TerminalElements, columns: number, rowsToLogo: number, below: RenderNode[]) {
323  const blank = (count: number): Line[] => Array.from({ length: count }, () => [])
324  const banner = bannerLines(columns, run)
325  const cover = [...blank(SLACK_ROWS), ...banner, ...blank(rowsToLogo - banner.length)]
326  return elements.Box({
327    flexDirection: 'column',
328    marginTop: -(SLACK_ROWS + rowsToLogo),
329    children: [...cover.map(line => drawLine(elements, clipToColumns(line, columns))), ...below],
330  })
331}
332
333const noticeRows = (elements: TerminalElements) => [...notices.values()].map(notice => drawNotice(elements, notice))
334
335async function settleBanner($: EngineInterface, cwd: string, start: 'startup' | 'clear' | 'resume', resumedId?: string) {
336  const branchRead = currentBranch($, cwd)
337  const [messages, settings, version, model, id] = await Promise.all([
338    $.session.messages(),
339    $.settings.read(),
340    $.session.version(),
341    $.session.model(),
342    resumedId ?? $.session.id(),
343    $.command.register({ name: COMMAND, description: 'Draw the SLOPOTHON banner' }),
344  ])
345  const bootIndex = messages.findIndex(isBootRecord)
346  const isBooting = start !== 'resume' && bootIndex === -1 && settings.tui === 'fullscreen' && (start === 'clear' || messages.length === 0)
347  const recordsAbove = bootIndex === -1 ? messages.length : bootIndex
348  const hasBanner = (isBooting || bootIndex !== -1) && messages.slice(0, recordsAbove).every(isCommandRecord)
349  const showRun = (branch: string | undefined) => {
350    run = { version: version.version, model: modelLabel(model), branch, id }
351    $.ui.invalidate('ui.render')
352  }
353  const isResumed = start === 'resume' || (messages.length > 0 && resumedAnchor !== undefined)
354  anchor = isResumed ? resumedAnchor : hasBanner ? { row: 'boot', recordsAbove } : undefined
355  showRun(undefined)
356  await Promise.all([isBooting ? $.command.run({ command: COMMAND }) : undefined, branchRead.then(showRun)])
357}
358
359export const register: Register = on => {
360  on('session.start', async ($, e, next) => {
361    await settleBanner($, e.cwd, 'startup')
362    return next(e)
363  })
364
365  on('classic.SessionStart', async ($, e, next) => {
366    const started = await next(e)
367    if (e.source === 'clear') {
368      resumedAnchor = undefined
369      await settleBanner($, e.cwd, 'clear')
370    }
371    if (e.source === 'resume') {
372      resumedAnchor = await resumedAnchorOf($, e.transcript_path)
373      await settleBanner($, e.cwd, 'resume', e.session_id)
374    }
375    return started
376  })
377
378  on('command.run', { command: COMMAND }, async ($, e) => {
379    if (e.origin.kind === 'plugin' && e.origin.name === PLUGIN) return { text: BOOT_TEXT }
380    const branch = await currentBranch($, await $.session.cwd())
381    if (run !== undefined) run = { ...run, branch }
382    return { text: SHOW_TEXT }
383  })
384
385  on('ui.render', { component: 'InfoNotice' }, ($, e, next) => {
386    if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true || anchor === undefined) return next(e)
387    if (!notices.has(e.requestId)) {
388      notices.set(e.requestId, { text: e.props.text, command: e.props.command })
389      $.ui.invalidate('ui.render')
390    }
391    const { Box } = $.ui.resolve(e)
392    return Box({})
393  })
394
395  on('ui.render', { component: 'CommandOutput', props: { command: COMMAND } }, ($, e, next) => {
396    if (e.surface !== 'terminal') return next(e)
397    if (e.props.text.endsWith(SHOW_TEXT)) {
398      const elements = $.ui.resolve(e)
399      const columns = e.viewport?.columns ?? UNMEASURED_COLUMNS
400      return elements.Box({
401        flexDirection: 'column',
402        children: bannerLines(columns, run).map(line => drawLine(elements, clipToColumns(line, columns))),
403      })
404    }
405    if (e.viewport?.isFullscreen !== true || !e.props.text.endsWith(BOOT_TEXT)) return next(e)
406    const elements = $.ui.resolve(e)
407    const recordsAbove = anchor?.row === 'boot' ? anchor.recordsAbove : 0
408    const rowsToLogo = ROWS_FROM_LOGO_TO_BOOT_ROW + ROWS_PER_RECORD_ABOVE_BOOT_ROW * recordsAbove
409    const emptyRow = elements.Text({ children: [' '] })
410    return coverOver(elements, e.viewport.columns, rowsToLogo, notices.size === 0 ? [emptyRow] : noticeRows(elements))
411  })
412
413  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
414    const isFirstPrompt = anchor?.row === 'first prompt' && anchor.id === e.requestId
415    if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true || e.props.isExpanded || !isFirstPrompt) return next(e)
416    const elements = $.ui.resolve(e)
417    return coverOver(elements, e.viewport.columns, ROWS_FROM_LOGO_TO_FIRST_PROMPT, [...noticeRows(elements), await next(e)])
418  })
419}
420