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

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.
/plugin marketplace add abrose/claude-plugins
/plugin install chebu-ui@abrose-plugins
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.
| Style | Look |
|---|---|
tint | Full-width dark green background, no frame (default) |
double | Full-width green double frame |
box | Rounded magenta frame |
banner | Full-width magenta background band, black text |
gutter | Yellow bar on the left, no frame |
label | Cyan 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.
/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.
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 ─────────.
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.
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.
claude plugin validate plugins/chebu-ui
claude plugin test plugins/chebu-uihooks/mod/chebu-ui.tsx 25 lines1import 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}
25hooks/mod/notes.ts 91 lines1import { 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}
91hooks/mod/prompt-log.tsx 184 lines1import { 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}
184hooks/mod/prompt-style.tsx 137 lines1import { 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}
137hooks/mod/quiet-tools.tsx 57 lines1import 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}
57hooks/mod/reply-style.tsx 36 lines1import { 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}
36hooks/mod/turn-separator.tsx 46 lines1import { 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}
46hooks/mod/state.ts 29 lines1import 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}
29types/index.d.ts 27 lines1export 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