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

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.
hooks/register.tsx 61 lines1import 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}
61hooks/band.ts 47 lines1// 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