SLOPSHOPPER

prompt-band

Draw hook-notices' and turn-nav's lines above the prompt as one row.

newband
A shopper browsing a rack in a slop shop
README

prompt-band

hook-notices and turn-nav each draw a line above the prompt. prompt-band puts both on one row, so they take one line instead of two:

🪝 Hook messages · 3 · ⚠ 1 · deploy ch…  Open › │ 🧭 Prompts · 9 · fix the…  Open › ✕

On a narrow terminal the two stack again. Pressing either half does what the mod's own line does; ✕ turns the navigator off.

Needs hook-notices and turn-nav; it shows nothing on its own.

Source 2 files
hooks/register.tsx 61 lines
1import type { Register } from 'claude-code'
2import { cellWidth, fitRow, sharedWidths } from './band'
3
4// What hook-notices and turn-nav publish for this row; null when there is nothing to show.
5const HOOK = { plugin: 'hook-notices', key: 'chip' } as const
6const NAV = { plugin: 'turn-nav', key: 'chip' } as const
7
8// A press is answered by the mod whose line it is, in its ui.press hook: opened there,
9// inside the person's press, its pane is placed at any width. These handlers have
10// nothing left to do.
11const answeredByOwner = () => undefined
12
13export const register: Register = on => {
14  on('session.start', async ($, e, next) => {
15    // hook-notices and turn-nav read this and leave their own lines to this row.
16    // ponytail: no event tells a mod it unloads, so disabling this mod mid-session
17    // leaves both lines hidden until the session restarts.
18    await $.env.set('PROMPT_BAND', '1')
19    return next(e)
20  })
21
22  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
23    if (e.props.hasSurvey) return next(e)
24    const { value: hook } = await $.state.get(HOOK)
25    const { value: nav } = await $.state.get(NAV)
26    if (!hook && !nav) return next(e)
27    const { Box, Button, Text } = $.ui.resolve(e)
28    const below = await next(e)
29    // No hotkeys: a plain Button draws one as `h: ` ahead of the label.
30    const hookLine = (width: number) => hook && <Button key="hook-notices" plain dimColor label={fitRow(hook.label, hook.action, width)} onPress={answeredByOwner} />
31    const navLine = (width: number) => nav && <Button key="turn-nav" plain dimColor label={fitRow(nav.label, nav.action, width)} onPress={answeredByOwner} />
32    const off = <Button key="turn-nav-off" plain dimColor label="✕" onPress={answeredByOwner} />
33    // The navigator line's text, one space, its ` action ›`: what it needs uncut.
34    const shared = hook && nav ? sharedWidths(e.props.bodyColumns, cellWidth(nav.label) + 1 + cellWidth(` ${nav.action} ›`)) : undefined
35
36    if (shared) {
37      return (
38        <Box flexDirection="column">
39          <Box flexDirection="row">
40            {hookLine(shared[0])}
41            <Text dimColor> │ </Text>
42            {navLine(shared[1])}
43            <Text> </Text>
44            {off}
45          </Box>
46          {below}
47        </Box>
48      )
49    }
50    // A row each, as the two mods draw them on their own: the › of both lines lines up.
51    const width = Math.max(12, e.props.bodyColumns - 4)
52    return (
53      <Box flexDirection="column">
54        {hookLine(width)}
55        {nav && <Box flexDirection="row" columnGap={1}>{navLine(width)}{off}</Box>}
56        {below}
57      </Box>
58    )
59  })
60}
61
hooks/band.ts 47 lines
1// East Asian wide and fullwidth ranges plus emoji: a terminal cell pair each.
2const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
3
4/** Terminal cells a string takes: wide characters count two. */
5export function cellWidth(text: string): number {
6  let width = 0
7  for (const char of text) width += WIDE.test(char) ? 2 : 1
8  return width
9}
10
11/**
12 * `text`, padding, then ` action ›`: exactly `width` cells, the text cut with … when it
13 * must be. Drawn as one Button, the whole span is the tap target; a phone column is
14 * about 4px wide, too narrow to aim at the short label alone.
15 */
16export function fitRow(text: string, action: string, width: number): string {
17  const tail = ` ${action} ›`
18  const room = width - cellWidth(tail) - 1
19  let body = text
20  if (cellWidth(body) > room) {
21    let out = ''
22    for (const char of body) {
23      if (cellWidth(out + char) > room - 1) break
24      out += char
25    }
26    body = `${out}…`
27  }
28  return body + ' '.repeat(Math.max(1, width - cellWidth(body) - cellWidth(tail))) + tail
29}
30
31// Narrower than this, a line cut to share the row says too little; each keeps a row.
32export const MIN_SHARED = 26
33// The shared row: the hook line, ` │ `, the navigator line, ` ✕`. It ends where the
34// navigator's own row ends (its line, a gap, ✕), two cells short of the band's width.
35const SHARED_EXTRA = 7
36
37/**
38 * Each line's width when they share the row, or undefined when they keep a row each.
39 * The navigator line takes what its text needs, at least MIN_SHARED and at most half;
40 * the hook line, whose headline is the longer read, takes the rest.
41 */
42export function sharedWidths(bodyColumns: number, navNeeds: number): [number, number] | undefined {
43  const room = bodyColumns - SHARED_EXTRA
44  const nav = Math.min(Math.max(MIN_SHARED, navNeeds), Math.floor(room / 2))
45  return nav < MIN_SHARED ? undefined : [room - nav, nav]
46}
47