SLOPSHOPPER

chebu-ui

Personal Claude Code UI tweaks: bold numbered prompts, a /prompts pane, a separator after each turn, quiet tool rows, dim narration and tinted answers.

newpanebandspinnerrowscommand
v0.3.0no licenseupdated 2026-10-09abrose/claude-plugins/plugins/chebu-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chebu-ui
│ ┃ My prompts ✕ │ ┃ #1 08:53 fix the failing auth test and add a ╭─────────────────────────────────────────────────────────────────╮ │ ┃ log call │ #1 ❯ 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 │ │ │ ⟨Claude Code's own drawing⟩ │ │ │ ── 08:53 · Worked 42s ─────────────────────────────────────────────── │ │ › /prompt-style │ ⎿ chebu-ui: Prompt style: box │ │ [ p: prompts ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ p: prompts ]
Pane · My prompts
#1 08:53 fix the failing auth test and add an audit log call
Turn line
── 08:53 · Worked 42s ──────────────────────────────────────────────────────────────────────────────
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ #1 ❯ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Claude's reply
⟨Claude Code's own drawing⟩
README

chebu-ui

Personal Claude Code UI tweaks, written as a mod (a plugin of function hooks). Each tweak lives in its own file under hooks/mod/ and is registered from hooks/mod/chebu-ui.tsx.

Install

/plugin marketplace add abrose/claude-plugins
/plugin install chebu-ui@abrose-plugins

Tweaks

Prompt style

Draws your own prompts (typed at the terminal, or sent from phone or web) in a bold style, so you can find them again in a long transcript. Messages from background tasks, teammates and other agents keep the default look. Only the drawing changes: the stored message and what the model reads stay as they were.

/prompt-style <name> picks a style; /prompt-style with no argument cycles.

StyleLook
tintFull-width dark green background, no frame (default)
doubleFull-width green double frame
boxRounded magenta frame
bannerFull-width magenta background band, black text
gutterYellow bar on the left, no frame
labelCyan YOU tag above the prompt

The choice lasts for the session; new sessions start with tint.

Each prompt also carries its number (#3) in this session. A prompt sent while a turn runs shows in a dashed yellow frame with a ⏳ queued tag until it enters the conversation.

Prompt list

/prompts opens a pane with every prompt you sent this session: number, send time and text with its newlines, soft-wrapped, newest at the bottom. The pane scrolls, and each new prompt brings it back to the end. A frame copied from a terminal box (│), trailing spaces and the <pasted_content> tags around a paste are stripped; the tags are hidden in the chat frame too. A prompt longer than five rows (a pasted block) is folded to four rows and a ── N more lines hidden ── rule; press ▶ beside the time to unfold, and ▼ to fold again. Run /prompts again to close the pane; behind another plugin's pane tab, it comes to the front instead. A [ p: prompts ] button above the input does the same: click it, or press ctrl+x tab and then p.

Turn separator

Replaces the engine's Worked for 3s line with a gray rule after each turn, with the time the turn ended: ── 10:26 · Worked for 3s ─────────.

Quiet tools

Draws each tool call as one dim line (· Bash ls -la). Bash output shows its first three lines and counts the rest (ctrl+o shows all). Edits, writes, questions, subagents and plan exits keep the engine's full row, and so does a call that failed or was interrupted.

Reply style

The answer shows as markdown on a dark blue-gray background. There is no frame, so text copied from the answer carries no border characters. Text that a tool call followed in the same turn (narration such as "Let me read the file") shows dim and italic instead, so the answer stands out. Replies from before the plugin loaded show on the background too.

Development

claude plugin validate plugins/chebu-ui
claude plugin test plugins/chebu-ui
Source 9 files
hooks/mod/chebu-ui.tsx 25 lines
1import type { Register } from 'claude-code'
2import { registerNotes } from './notes'
3import { PROMPTS_COMMAND, registerPromptLog } from './prompt-log'
4import { PROMPT_STYLE_COMMAND, registerPromptStyle } from './prompt-style'
5import { registerQuietTools } from './quiet-tools'
6import { registerReplyStyle } from './reply-style'
7import { registerTurnSeparator } from './turn-separator'
8
9export const register: Register = on => {
10  // A mod holds one session.start hook, so every command registers here.
11  on('session.start', async ($, e, next) => {
12    await $.command.register(PROMPT_STYLE_COMMAND)
13    await $.command.register(PROMPTS_COMMAND)
14
15    return next(e)
16  })
17
18  registerNotes(on)
19  registerPromptStyle(on)
20  registerPromptLog(on)
21  registerTurnSeparator(on)
22  registerQuietTools(on)
23  registerReplyStyle(on)
24}
25
hooks/mod/notes.ts 91 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { isOwn, PROMPT_PANE } from './state'
5
6/**
7 * Every state write of the mod. Drawing may not write state, a mod holds one hook per event
8 * without a matcher, and $ is never followed across an import, so the event hooks that note
9 * what the drawings read all live here; the feature files only draw.
10 */
11
12const sent = atom({ plugin: 'chebu-ui', key: 'sent' } as const, [])
13const turnEnds = atom({ plugin: 'chebu-ui', key: 'turnEnds' } as const, [])
14const narration = atom({ plugin: 'chebu-ui', key: 'narration' } as const, [])
15
16type Block = { type?: string; text?: string }
17
18/** A queued prompt stops waiting once it enters the conversation: as a new turn, or delivered into the running one. */
19async function deliver($: EngineInterface, has: (text: string) => boolean) {
20  const list = await read($, sent)
21  if (list.some(one => one.queued === true && has(one.text))) {
22    await update($, sent, all => all.map(one => (one.queued === true && has(one.text) ? { ...one, queued: false } : one)))
23  }
24}
25
26/** Brings a new prompt into view in an open prompt pane, even after the person scrolled up. */
27async function revealNewest($: EngineInterface) {
28  try {
29    if ((await $.ui.panes()).some(pane => pane.id === PROMPT_PANE)) {
30      await $.ui.scroll({ in: PROMPT_PANE, to: 'end' })
31    }
32  } catch {
33    // Scrolling is cosmetic: a host without it (the test kit) must not lose the prompt note.
34  }
35}
36
37export function registerNotes(on: On) {
38  // Reply text of the running turn not yet followed by a tool call: the answer, unless a tool call comes.
39  let pending: string[] = []
40
41  on('prompt.submit', async ($, e, next) => {
42    if (isOwn(e.origin)) {
43      const at = await $.clock.now()
44      // A turnId means a turn was running as the prompt was sent, so it waits behind that turn.
45      const queued = e.turnId !== undefined
46      await update($, sent, list => [...list, { text: e.text, at, queued }])
47      await revealNewest($)
48    }
49
50    return next(e)
51  })
52
53  on('turn.start', async ($, e, next) => {
54    pending = []
55    await deliver($, text => text === e.text)
56
57    return next(e)
58  })
59
60  on('session.append', async ($, e, next) => {
61    if (e.agentId !== undefined) {
62      return next(e)
63    }
64
65    const row = JSON.stringify(e.message)
66    await deliver($, text => row.includes(JSON.stringify(text).slice(1, -1)))
67
68    if (e.message.type === 'assistant' && Array.isArray(e.message.content)) {
69      for (const block of e.message.content as Block[]) {
70        if (block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
71          pending.push(block.text.trim())
72        } else if (block.type === 'tool_use' && pending.length > 0) {
73          const said = pending
74          pending = []
75          await update($, narration, list => [...list, ...said].slice(-1000))
76        }
77      }
78    }
79
80    return next(e)
81  })
82
83  on('turn.complete', async ($, e, next) => {
84    pending = []
85    const at = await $.clock.now()
86    await update($, turnEnds, list => [...list, { durationMs: e.durationMs, at }].slice(-500))
87
88    return next(e)
89  })
90}
91
hooks/mod/prompt-log.tsx 184 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { clockTime, PROMPT_PANE as PANE, shownText } from './state'
5
6/** A prompt that wraps to more rows than this is folded: its first rows, then a count of the rest. */
7const MAX_ROWS = 5
8const sent = atom({ plugin: 'chebu-ui', key: 'sent' } as const, [])
9const unfolded = atom({ plugin: 'chebu-ui', key: 'unfolded' } as const, [])
10
11export const PROMPTS_COMMAND = {
12  name: 'prompts',
13  description: 'Show or hide the prompts you sent this session in a pane',
14}
15
16/** The fold button's column after the time: the arrow and a space. */
17const FOLD_WIDTH = 2
18/** The empty cells right of each prompt's text. */
19const RIGHT_PAD = 2
20
21/**
22 * The text as the pane shows it in rows of `width`, each line wrapping on its own: whole, or cut
23 * to MAX_ROWS - 1 rows, the last row left to the line that counts the hidden rows.
24 */
25function fold(text: string, width: number, isUnfolded: boolean) {
26  const lines = text.split('\n')
27  const rowsOf = (line: string) => Math.ceil(line.length / width) || 1
28  const rows = lines.reduce((sum, line) => sum + rowsOf(line), 0)
29  if (rows <= MAX_ROWS) {
30    return { text, hidden: 0 }
31  }
32  const hidden = rows - (MAX_ROWS - 1)
33  if (isUnfolded) {
34    return { text, hidden }
35  }
36
37  const kept: string[] = []
38  let room = MAX_ROWS - 1
39  for (const line of lines) {
40    if (room === 0) {
41      break
42    }
43    if (rowsOf(line) <= room) {
44      kept.push(line)
45      room -= rowsOf(line)
46    } else {
47      kept.push(`${line.slice(0, room * width - 1)}…`)
48      room = 0
49    }
50  }
51
52  return { text: kept.join('\n'), hidden }
53}
54
55/** `───── N more lines hidden ─────`, a rule as wide as the text column. */
56function hiddenRule(hidden: number, width: number) {
57  const label = ` ${hidden} more lines hidden `
58  const left = Math.max(2, Math.floor((width - label.length) / 2))
59  const right = Math.max(2, width - label.length - left)
60
61  return `${'─'.repeat(left)}${label}${'─'.repeat(right)}`
62}
63
64/**
65 * A prompt's text as the pane shows it. Text copied from a framed terminal box carries the frame's
66 * `│` at each line's edges and the padding before it; both would wrap as rows of their own.
67 */
68function paneText(text: string) {
69  return shownText(text)
70    .replace(/\r\n?/g, '\n')
71    .replace(/^│ ?/gm, '')
72    .replace(/[ \t]*│$/gm, '')
73    .replace(/[ \t]+$/gm, '')
74    .trimEnd()
75}
76
77/** Moves the pane's window; scrolling is cosmetic, so a window that cannot move fails nothing. */
78async function reveal($: EngineInterface, to: 'end' | { key: string }) {
79  try {
80    await $.ui.scroll({ in: PANE, to })
81  } catch {
82    // An unplaced pane, or a host without scrolling (the test kit): the list still draws.
83  }
84}
85
86async function toggleFold($: EngineInterface, n: number) {
87  await update($, unfolded, list => (list.includes(n) ? list.filter(one => one !== n) : [...list, n]))
88  // Folding can pull the prompt's button out of the window; keep it where the press was.
89  await reveal($, { key: `fold:${n}` })
90}
91
92/** Closes the prompt pane when it shows, else opens it or brings it out from behind another tab. */
93async function togglePane($: EngineInterface) {
94  const pane = (await $.ui.panes()).find(one => one.id === PANE)
95  if (pane?.isShown === true) {
96    await $.ui.close({ id: PANE })
97
98    return 'closed'
99  }
100  if (pane !== undefined) {
101    // Opening an open id only retitles it; a fresh open seats it in front.
102    await $.ui.close({ id: PANE })
103  }
104  await $.ui.open({ id: PANE, title: 'My prompts' })
105  // The end follows the tree as it grows, so the newest prompt stays in view.
106  await reveal($, 'end')
107
108  return 'opened'
109}
110
111export function registerPromptLog(on: On) {
112  on('command.run', { command: 'prompts' }, async $ => ({ text: `Prompt list ${await togglePane($)}.` }))
113
114  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
115    if (e.props.hasSurvey) {
116      return next(e)
117    }
118    const { Box, Button } = $.ui.resolve(e)
119
120    // A Button that is not plain draws `[ label ]` and hides its hotkey, so the label names it.
121    return (
122      <Box>
123        <Button key="prompts" label="p: prompts" hotkey="p" variant="primary" onPress={() => togglePane($)} />
124      </Box>
125    )
126  })
127
128  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
129    const { Box, Button, Text } = $.ui.resolve(e)
130    const all = await read($, sent)
131    const open = await read($, unfolded)
132    const tagWidth = `#${all.length} `.length
133    const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 80
134    const textWidth = Math.max(10, columns - tagWidth - 'HH:MM '.length - FOLD_WIDTH - RIGHT_PAD)
135    const list = all.map((one, i) => {
136      const n = i + 1
137      const queued = one.queued === true
138      const isUnfolded = open.includes(n)
139      // The queued mark takes two cells of the first row.
140      const text = `${queued ? '⏳ ' : ''}${paneText(one.text)}`
141
142      return { n, time: clockTime(one.at), queued, isUnfolded, ...fold(text, textWidth, isUnfolded) }
143    })
144
145    // Every prompt is drawn; the pane's body scrolls over a taller list, its window kept at the end.
146    return (
147      <Box flexDirection="column" rowGap={1}>
148        {list.length === 0 && <Text dimColor>No prompts yet.</Text>}
149        {list.map(({ n, time, text, queued, hidden, isUnfolded }) => (
150          <Box flexDirection="row">
151            <Box flexShrink={0} width={tagWidth}>
152              <Text color={queued ? 'yellow' : 'green'} bold>{`#${n}`}</Text>
153            </Box>
154            <Box flexShrink={0} width={6}>
155              <Text color="gray">{time}</Text>
156            </Box>
157            {/* Every row keeps the fold column, so the texts line up whether a prompt folds or not. */}
158            <Box flexShrink={0} width={FOLD_WIDTH}>
159              {hidden > 0 && (
160                <Button
161                  key={`fold:${n}`}
162                  label={isUnfolded ? '▼' : '▶'}
163                  plain
164                  onPress={() => toggleFold($, n)}
165                />
166              )}
167            </Box>
168            <Box flexGrow={1} flexShrink={1} flexDirection="column" paddingRight={RIGHT_PAD}>
169              <Text wrap="wrap" color={queued ? 'yellow' : undefined} italic={queued}>
170                {text}
171              </Text>
172              {hidden > 0 && !isUnfolded && (
173                <Text key={`hidden:${n}`} dimColor>
174                  {hiddenRule(hidden, textWidth)}
175                </Text>
176              )}
177            </Box>
178          </Box>
179        ))}
180      </Box>
181    )
182  })
183}
184
hooks/mod/prompt-style.tsx 137 lines
1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3
4import type { PromptStyle } from '../../types'
5import { findSent, isOwn, shownText } from './state'
6
7export const STYLES: PromptStyle[] = ['box', 'banner', 'gutter', 'label', 'double', 'tint']
8
9const style = atom({ plugin: 'chebu-ui', key: 'promptStyle' } as const, 'tint')
10const sent = atom({ plugin: 'chebu-ui', key: 'sent' } as const, [])
11
12export const PROMPT_STYLE_COMMAND = {
13  name: 'prompt-style',
14  description: `Switch how your prompts look: ${STYLES.join(', ')} (no argument cycles)`,
15}
16
17export function registerPromptStyle(on: On) {
18  on('command.run', { command: 'prompt-style' }, async ($, e) => {
19    const wanted = e.args.trim()
20
21    if (wanted !== '' && !STYLES.includes(wanted as PromptStyle)) {
22      return { text: `Unknown style "${wanted}". Pick one of: ${STYLES.join(', ')}` }
23    }
24
25    const current = await read($, style)
26    const chosen = wanted !== ''
27      ? (wanted as PromptStyle)
28      : STYLES[(STYLES.indexOf(current) + 1) % STYLES.length]
29    await update($, style, () => chosen)
30
31    return { text: `Prompt style: ${chosen}` }
32  })
33
34  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
35    if (!isOwn(e.props.origin)) {
36      return next(e)
37    }
38
39    const { Box, Text } = $.ui.resolve(e)
40    const found = findSent(await read($, sent), e.props.text)
41    const text = shownText(e.props.text)
42    const tag = found === undefined ? '' : `#${found.n} `
43
44    // A queued prompt has one look in every style, so the wait reads the same everywhere.
45    if (found?.queued === true) {
46      return (
47        <Box key="prompt" borderStyle="dashed" borderColor="yellow" width="100%" paddingX={1} marginTop={1} flexDirection="row">
48          <Text color="black" backgroundColor="yellow" bold>
49            {` ${tag}⏳ queued `}
50          </Text>
51          <Text color="yellow" italic>
52            {` ${text}`}
53          </Text>
54        </Box>
55      )
56    }
57
58    let prompt
59    switch (await read($, style)) {
60      case 'banner':
61        prompt = (
62          <Box key="prompt"backgroundColor="magenta" width="100%" paddingX={1}>
63            <Text color="black" bold>
64              {tag}❯ {text}
65            </Text>
66          </Box>
67        )
68        break
69      case 'gutter':
70        prompt = (
71          <Box key="prompt"flexDirection="row">
72            <Box backgroundColor="yellow" width={1} />
73            <Box paddingLeft={1}>
74              <Text color="yellow" bold>
75                {tag}{text}
76              </Text>
77            </Box>
78          </Box>
79        )
80        break
81      case 'label':
82        prompt = (
83          <Box key="prompt"flexDirection="column">
84            <Text color="black" backgroundColor="cyan" bold>
85              {` YOU ${tag}`}
86            </Text>
87            <Box paddingLeft={2}>
88              <Text color="cyan" bold>
89                {text}
90              </Text>
91            </Box>
92          </Box>
93        )
94        break
95      case 'box':
96        prompt = (
97          <Box key="prompt"borderStyle="round" borderColor="magenta" paddingX={1}>
98            <Text color="magenta" bold>
99              {tag}❯ {text}
100            </Text>
101          </Box>
102        )
103        break
104      case 'double':
105        prompt = (
106          <Box key="prompt"borderStyle="double" borderColor="green" width="100%" paddingX={1} flexDirection="row">
107            {tag !== '' && (
108              <Text color="black" backgroundColor="green" bold>
109                {` ${tag}`}
110              </Text>
111            )}
112            <Text color="green" bold>
113              {tag !== '' ? ' ' : ''}❯ {text}
114            </Text>
115          </Box>
116        )
117        break
118      default:
119        // A background instead of a frame, like the reply's, so copied text carries no border characters.
120        prompt = (
121          <Box key="prompt" backgroundColor="#1e3a2a" width="100%" paddingX={1} flexDirection="row">
122            <Text color="green" bold>
123              {tag}❯{' '}
124            </Text>
125            <Text bold>{text}</Text>
126          </Box>
127        )
128    }
129
130    return (
131      <Box flexDirection="column" marginTop={1}>
132        {prompt}
133      </Box>
134    )
135  })
136}
137
hooks/mod/quiet-tools.tsx 57 lines
1import type { On } from 'claude-code'
2
3/** Tools whose engine row carries something to read (a diff, a question, a subagent): left as drawn. */
4const LOUD = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'AskUserQuestion', 'Agent', 'ExitPlanMode'])
5const SUMMARY_KEYS = ['command', 'file_path', 'pattern', 'path', 'url', 'query', 'description', 'prompt']
6const RESULT_LINES = 3
7
8function summary(input: unknown) {
9  if (typeof input !== 'object' || input === null) {
10    return ''
11  }
12
13  const fields = input as Record<string, unknown>
14  const key = SUMMARY_KEYS.find(k => typeof fields[k] === 'string')
15  const value = key !== undefined ? fields[key] : Object.values(fields).find(v => typeof v === 'string')
16
17  return typeof value === 'string' ? value.replace(/\s+/g, ' ') : ''
18}
19
20export function registerQuietTools(on: On) {
21  on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
22    if (LOUD.has(e.props.tool) || e.props.isErrored || e.props.isInterrupted) {
23      return next(e)
24    }
25
26    const { Text } = $.ui.resolve(e)
27    const mark = e.props.isRunning ? '…' : '·'
28
29    return (
30      <Text dimColor wrap="truncate-end">
31        {`  ${mark} ${e.props.tool} ${summary(e.props.input)}`}
32      </Text>
33    )
34  })
35
36  on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
37    if (e.props.tool !== 'Bash' || e.props.isErrored) {
38      return next(e)
39    }
40
41    const { Box, Text } = $.ui.resolve(e)
42    const output = e.props.output as { stdout?: string; stderr?: string }
43    const lines = `${output.stdout ?? ''}${output.stderr ?? ''}`.trimEnd().split('\n').filter(l => l !== '')
44    const more = lines.length - RESULT_LINES
45
46    return (
47      <Box flexDirection="column" paddingLeft={4}>
48        {lines.length === 0 && <Text dimColor>(no output)</Text>}
49        {lines.slice(0, RESULT_LINES).map(line => (
50          <Text dimColor wrap="truncate-end">{line}</Text>
51        ))}
52        {more > 0 && <Text dimColor italic>{`… ${more} more lines (ctrl+o)`}</Text>}
53      </Box>
54    )
55  })
56}
57
hooks/mod/reply-style.tsx 36 lines
1import { atom, read } from 'claude-code'
2import type { On } from 'claude-code'
3
4const narration = atom({ plugin: 'chebu-ui', key: 'narration' } as const, [])
5
6export function registerReplyStyle(on: On) {
7  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
8    const { Box, Text } = $.ui.resolve(e)
9    const block = e.props.text.trim()
10
11    // Narration is text a tool call followed; anything else (the answer, an older reply) stays bright.
12    if (e.props.isSummary === true || (await read($, narration)).includes(block)) {
13      return (
14        <Box flexDirection="row" marginTop={e.props.isFirstOfReply ? 1 : 0}>
15          <Box flexShrink={0} width={2}>
16            <Text dimColor>·</Text>
17          </Box>
18          <Box flexGrow={1} flexShrink={1}>
19            <Text dimColor italic wrap="wrap">
20              {block.replace(/\*\*/g, '')}
21            </Text>
22          </Box>
23        </Box>
24      )
25    }
26
27    return (
28      // A background instead of a frame, so copied text carries no border characters.
29      // Wraps the drawing beneath, so another mod (gfm-render's alerts and diagrams) still gets its turn.
30      <Box marginTop={1} backgroundColor="#1e2a3a" paddingX={1}>
31        {await next(e)}
32      </Box>
33    )
34  })
35}
36
hooks/mod/turn-separator.tsx 46 lines
1import { atom, read } from 'claude-code'
2import type { On } from 'claude-code'
3
4import type { TurnEnd } from '../../types'
5import { clockTime } from './state'
6
7const RULE = '─'.repeat(400)
8/** How far a row's durationMs may be from the one turn.complete reported and still be that turn. */
9const MATCH_MS = 2000
10
11const turnEnds = atom({ plugin: 'chebu-ui', key: 'turnEnds' } as const, [])
12
13function duration(ms: number) {
14  const s = Math.round(ms / 1000)
15  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
16}
17
18function closest(ends: TurnEnd[], durationMs: number) {
19  let best: TurnEnd | undefined
20  for (const end of ends) {
21    const off = Math.abs(end.durationMs - durationMs)
22    if (off <= MATCH_MS && (best === undefined || off < Math.abs(best.durationMs - durationMs))) {
23      best = end
24    }
25  }
26
27  return best
28}
29
30export function registerTurnSeparator(on: On) {
31  on('ui.render', { component: 'TurnDuration' }, async ($, e) => {
32    const { Box, Text } = $.ui.resolve(e)
33    const end = closest(await read($, turnEnds), e.props.durationMs)
34    const time = end === undefined ? '' : `${clockTime(end.at)} · `
35
36    return (
37      <Box flexDirection="row" width="100%" marginTop={1}>
38        <Text color="gray">{`── ${time}${e.props.word} ${duration(e.props.durationMs)} `}</Text>
39        <Box flexGrow={1}>
40          <Text color="gray" wrap="truncate-end">{RULE}</Text>
41        </Box>
42      </Box>
43    )
44  })
45}
46
hooks/mod/state.ts 29 lines
1import type { SentPrompt } from '../../types'
2
3/** The id of the pane that lists the session's prompts. */
4export const PROMPT_PANE = 'prompt-log'
5
6export const isOwn = (origin: { kind: string }) => origin.kind === 'composer' || origin.kind === 'bridge'
7
8/** The prompt's number and send time, or undefined for one sent before the mod loaded. */
9export function findSent(list: SentPrompt[], text: string) {
10  const i = list.map(one => one.text).lastIndexOf(text)
11  const one = list[i]
12
13  return one === undefined ? undefined : { n: i + 1, at: one.at, queued: one.queued === true }
14}
15
16/**
17 * A prompt's text as it is drawn: without the `<pasted_content id="…">` tags the host wraps
18 * around a paste. The tags stay in the sent text, which is what a prompt is matched by.
19 */
20export function shownText(text: string) {
21  return text.replace(/[ \t]*<\/?pasted_content(?:\s+[\w-]+="[^"]*")*\s*>[ \t]*(?:\r?\n)?/g, '').trimEnd()
22}
23
24export function clockTime(ms: number) {
25  const d = new Date(ms)
26  const pad = (v: number) => String(v).padStart(2, '0')
27  return `${pad(d.getHours())}:${pad(d.getMinutes())}`
28}
29
types/index.d.ts 27 lines
1export type PromptStyle = 'box' | 'banner' | 'gutter' | 'label' | 'double' | 'tint'
2
3/**
4 * One own prompt of this session: its text, when it was sent (epoch ms), and whether it still
5 * waits behind a running turn.
6 */
7export type SentPrompt = { text: string; at: number; queued?: boolean }
8
9/** One finished turn: its duration as turn.complete reported it, and when it ended (epoch ms). */
10export type TurnEnd = { durationMs: number; at: number }
11
12declare module 'claude-code' {
13  interface PluginState {
14    'chebu-ui': {
15      promptStyle: PromptStyle
16      /** Own prompts of this session, in the order sent; a prompt's number is its place here plus one. */
17      sent: SentPrompt[]
18      /** Finished turns of this session, in order. */
19      turnEnds: TurnEnd[]
20      /** Reply blocks (trimmed) a tool call followed in the same turn: narration, drawn dim. */
21      narration: string[]
22      /** Numbers of the long prompts the person unfolded in the prompt pane. */
23      unfolded: number[]
24    }
25  }
26}
27