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

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.
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
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.
| Skill | When to use |
|---|---|
file-pr | File or update a concise pull request |
babysit-pr | Monitor a PR through review bots and CI |
arena | Run competing attempts, then graft the strongest ideas into one result |
bro | Restate the last message in plain language |
verify | Prove a behavior change on the user surface with revision-bound evidence |
review | Filter adversarial findings by reachable failure and concrete evidence |
Claude Code mods: plugins of function hooks that change how Claude Code looks and behaves. They are Claude Code only.
| Mod | What it does |
|---|---|
glamour-dark | Draws replies as rich markdown in glamour's dark style, one sentence per line |
marathon-footer | Restyles the hints after the mode label under the prompt to match the SLOPOTHON banner: ⏵⏵ auto mode on · ⇧⇥ CYCLE // ← 1 AGENT |
redact-secrets | Swaps secrets for placeholders before Claude reads them or the session logs them; Write and Edit put the real values back |
tool-lines | Draws each tool call as one compact line and hides its result block |
ascii-spinner | Swaps the working line (verb, timer, tokens, effort, tip) for a one-row WEAVEworm rethreading synthsilk, in Sekiguchi colors |
slopothon | Paints 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.
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.
skills/<name>/SKILL.md with required name and description frontmatter.name matching the directory. Lowercase, hyphens only.references/; keep SKILL.md short enough to load on activation.See agentskills.io/specification.
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.
hooks/register.ts 420 lines1import 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