대화 화면에서 내가 쓴 메시지를 왼쪽 색 막대와 강조색 굵은 글씨로 확실하게 구분

Claude Code mod. 대화 화면에서 내가 쓴 메시지를 확실하게 구분한다.
▌, 밝은 강조색 굵은 글씨, 줄 전체 폭의 회색 배경/명령 도 같다(실행되거나 작업이 끝나면 풀림)❯ /명령 줄은 엔진이 mod 를 거치지 않고 그려서 바꿀 수 없다(명령 출력 줄 ⎿ … 은 그대로)/config 의 플러그인 설정(또는 settings.json pluginConfigs["prompt-mark"].options):
barColor (기본 #38bdf8): 막대 색textColor (기본 #7dd3fc): 글자 색bgColor (기본 #373737): 줄 배경색, 비우면 배경 없음색은 이름(cyan, magenta…) 또는 #hex.
Claude Code 2.1.287 이상(mods).
/plugin marketplace add duddudcns/claude-plugins
/plugin install prompt-mark@duddudcns
비공개 저장소라 GitHub 접근 권한과 로그인(gh auth login)이 필요하다.
claude plugin validate . / claude plugin test ..debug 파일을 두면 세션에 들어오는 행과 그려지는 행을 .debug-dump.json 에 기록한다.hooks/register.tsx 119 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { probe } from './probe'
5import { addPending, commandLine, commandNameIn, isPendingRow, isPersonsPrompt, isQueuedCommandRow, settlePending, textOf, wrapToWidth } from './wrap'
6
7const pending = atom({ plugin: 'prompt-mark', key: 'pending' } as const, [])
8// Bumped when queued commands are released, so their rows redraw.
9const release = atom({ plugin: 'prompt-mark', key: 'release' } as const, 0)
10
11const BAR = '▌ '
12
13// Releases queued command rows (all, or those of one command) and redraws them.
14async function releaseCommands($: EngineInterface, queued: Map<string, string>, name?: string): Promise<void> {
15 for (const [id, text] of queued) {
16 if (name === undefined || text === name || text.startsWith(`${name} `)) queued.delete(id)
17 }
18 await update($, release, n => n + 1)
19}
20
21export const register: Register = (on, options) => {
22 // Whether a main-loop turn is running: a prompt submitted meanwhile is queued.
23 let isWorking = false
24 // When each transcript row was first drawn (module memory: a reload starts it over).
25 const firstSeen = new Map<string, number>()
26 // Command rows first drawn while a turn ran: requestId -> their text. Module memory only.
27 const queuedCommands = new Map<string, string>()
28 const barColor = typeof options.barColor === 'string' && options.barColor ? options.barColor : '#38bdf8'
29 const textColor = typeof options.textColor === 'string' && options.textColor ? options.textColor : undefined
30 const bgColor = typeof options.bgColor === 'string' && options.bgColor ? options.bgColor : undefined
31
32 // The person's prompts are drawn with a coloured bar down their left edge and bold text;
33 // every other user-role row (task notifications, other agents' messages) keeps the engine's look.
34 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
35 if (!isPersonsPrompt(e.props.origin)) return next(e)
36 const { Box, Text } = $.ui.resolve(e)
37 // Faint until sent: the submit placeholder, or a prompt still waiting in the queue.
38 const queued = await read($, pending)
39 await read($, release)
40 const now = await $.clock.now()
41 if (!firstSeen.has(e.requestId)) {
42 firstSeen.set(e.requestId, now)
43 if (isQueuedCommandRow(e.props.text, isWorking)) queuedCommands.set(e.requestId, e.props.text)
44 }
45 const isPending =
46 e.requestId === 'placeholder' ||
47 queuedCommands.has(e.requestId) ||
48 isPendingRow(queued, e.props.text, firstSeen.get(e.requestId) ?? now)
49 await probe($, { at: 'render', requestId: e.requestId, text: e.props.text.slice(0, 40), isPending, queued: queued.length, onScreen: e.props.onScreen ?? null })
50 const columns = e.viewport?.columns ?? 80
51 const width = Math.max(10, columns - BAR.length - 1)
52 const lines = wrapToWidth(e.props.text, width)
53 return (
54 <Box flexDirection="column" marginTop={1}>
55 {lines.map((line, i) => (
56 // Full-width rows keep the engine's grey band behind the bar and text.
57 <Box key={`l${i}`} flexDirection="row" width={columns} backgroundColor={bgColor}>
58 <Text color={isPending ? undefined : barColor} dimColor={isPending || undefined}>{BAR}</Text>
59 {isPending ? (
60 <Text dimColor>{line || ' '}</Text>
61 ) : (
62 <Text bold color={textColor}>{line || ' '}</Text>
63 )}
64 </Box>
65 ))}
66 </Box>
67 )
68 }).catch(($, e, next) => next(e))
69
70 on('turn.start', async ($, e, next) => {
71 if (e.agentId === undefined) isWorking = true
72 return next(e)
73 })
74
75 // Queued commands run once the turn is over: nothing stays faint past it.
76 on('turn.complete', async ($, e, next) => {
77 if (e.agentId === undefined) {
78 isWorking = false
79 await releaseCommands($, queuedCommands)
80 }
81 return next(e)
82 })
83
84 // A prompt the person submits while Claude works waits in the queue until delivered.
85 on('prompt.submit', async ($, e, next) => {
86 // Read before next(e): submitting a prompt while idle starts the turn inside that call.
87 const wasWorking = isWorking
88 const submittedAt = await $.clock.now()
89 const ran = await next(e)
90 await probe($, { at: 'submit', wasWorking, command: e.command !== undefined, text: e.text.slice(0, 60) })
91 if (wasWorking && !e.isMeta && isPersonsPrompt(e.origin)) {
92 const text = e.command ? commandLine(e.command.name, e.command.args) : e.text
93 await update($, pending, list => addPending(list, text, submittedAt))
94 }
95 return ran
96 }).catch(($, e, next) => next(e))
97
98 // Queue records and the rows that deliver them tell which typed prompts are still waiting.
99 on('session.append', async ($, e, next) => {
100 const ran = await next(e)
101 if (e.agentId === undefined) {
102 await probe($, { door: e.door, origin: e.origin, type: e.message.type, text: textOf(e.message.content).slice(0, 60) })
103 }
104 // A queued slash command stops waiting when the engine records that it ran.
105 if (e.agentId === undefined && e.door === 'command') {
106 const name = commandNameIn(textOf(e.message.content))
107 if (name !== undefined) {
108 await releaseCommands($, queuedCommands, name)
109 await update($, pending, list => list.filter(p => p.text !== name && !p.text.startsWith(`${name} `)))
110 }
111 }
112 if (e.agentId === undefined && isPersonsPrompt('kind' in e.origin ? { kind: e.origin.kind } : undefined)) {
113 const text = textOf(e.message.content)
114 await update($, pending, list => settlePending(list, e.door, text))
115 }
116 return ran
117 }).catch(($, e, next) => next(e))
118}
119hooks/probe.ts 13 lines1import type { EngineInterface } from 'claude-code'
2
3// Development probe: records the session's user rows while `.debug` exists in the plugin folder.
4let isOn: boolean | undefined
5const seen: unknown[] = []
6
7export async function probe($: EngineInterface, data: Record<string, unknown>): Promise<void> {
8 if (isOn === undefined) isOn = await $.fs.read(`${$.plugin.root}/.debug`).then(() => true, () => false)
9 if (!isOn) return
10 seen.push(data)
11 await $.fs.write(`${$.plugin.root}/.debug-dump.json`, JSON.stringify(seen.slice(-80), null, 1))
12}
13hooks/wrap.ts 74 lines1// Terminal cells: Hangul, CJK, fullwidth forms and emoji take two.
2const WIDE: [number, number][] = [
3 [0x1100, 0x115f], [0x2e80, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
4 [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1faff], [0x20000, 0x3fffd],
5]
6
7const charWidth = (ch: string): number => {
8 const code = ch.codePointAt(0) ?? 0
9 return WIDE.some(([lo, hi]) => code >= lo && code <= hi) ? 2 : 1
10}
11
12export const displayWidth = (text: string): number => [...text].reduce((sum, ch) => sum + charWidth(ch), 0)
13
14// Breaks one line to `width` cells, preferring the last space; a run with no space is cut.
15const wrapLine = (line: string, width: number): string[] => {
16 const out: string[] = []
17 let rest = [...line]
18 while (displayWidth(rest.join('')) > width) {
19 let used = 0
20 let cut = 0
21 while (cut < rest.length && used + charWidth(rest[cut] ?? '') <= width) {
22 used += charWidth(rest[cut] ?? '')
23 cut++
24 }
25 const space = rest.slice(0, cut + 1).lastIndexOf(' ')
26 const at = space > 0 ? space : Math.max(1, cut)
27 out.push(rest.slice(0, at).join('').trimEnd())
28 rest = rest.slice(space > 0 ? at + 1 : at)
29 }
30 out.push(rest.join(''))
31 return out
32}
33
34// The bar runs down every drawn row, so the text is wrapped here rather than by the terminal.
35export const wrapToWidth = (text: string, width: number): string[] =>
36 text.split(/\r?\n/).flatMap(line => wrapLine(line, Math.max(1, width)))
37
38export const textOf = (content: readonly { type: string; text?: string }[]): string =>
39 content.filter(block => block.type === 'text' && typeof block.text === 'string').map(block => block.text).join('\n')
40
41export type Pending = { text: string; at: number }
42
43// Adds a prompt typed while a main turn runs, with the time it was submitted.
44export const addPending = (pending: Pending[], text: string, at: number): Pending[] =>
45 pending.some(p => p.text === text) ? pending : [...pending, { text, at }].slice(-20)
46
47// A row delivers it: 'delivery' into the running turn (wrapped in a reminder, so match by
48// containment) or 'prompt' opening the next one.
49export const settlePending = (pending: Pending[], door: string, text: string): Pending[] =>
50 door === 'delivery' || door === 'prompt' ? pending.filter(p => !text.includes(p.text)) : pending
51
52// The row of a slash command carries no queued flag and no submit event reaches the mod; a
53// command row first drawn while a turn runs is queued until the command runs.
54export const isQueuedCommandRow = (text: string, isWorking: boolean): boolean => isWorking && text.startsWith('/')
55
56// The engine's record of a command that ran: `<command-name>/sap</command-name>`.
57export const commandNameIn = (record: string): string | undefined => record.match(/<command-name>(\/[^<\s]+)<\/command-name>/)?.[1]
58
59// The row of a queued slash command shows its line with the slash.
60export const commandLine = (name: string, args: string): string => (args ? `/${name} ${args}` : `/${name}`)
61
62// A queued command leaves the queue when it runs.
63export const settleCommand = (pending: Pending[], name: string): Pending[] =>
64 pending.filter(p => p.text !== `/${name}` && !p.text.startsWith(`/${name} `))
65
66// Rows carry no queued flag, and earlier prompts may share the text: only a row first drawn
67// after the prompt was submitted is the queued one.
68export const isPendingRow = (pending: Pending[], text: string, firstSeenAt: number): boolean =>
69 pending.some(p => p.text === text && firstSeenAt >= p.at)
70
71// The person's own words: typed at the prompt, or sent from the phone through Remote Control.
72export const isPersonsPrompt = (origin: { kind: string } | undefined): boolean =>
73 origin?.kind === 'composer' || origin?.kind === 'bridge'
74types/index.d.ts 8 lines1export type PendingPrompts = { text: string; at: number }[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 'prompt-mark': { pending: PendingPrompts; release: number }
6 }
7}
8