SLOPSHOPPER

prompt-mark

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

newrowsprompt
v0.1.0MITupdated 2026-10-25duddudcns/prompt-mark
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-mark
▌ 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 ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
▌ fix the failing auth test and add an audit log call
README

prompt-mark

Claude Code mod. 대화 화면에서 내가 쓴 메시지를 확실하게 구분한다.

  • 줄마다 왼쪽에 색 막대 ▌, 밝은 강조색 굵은 글씨, 줄 전체 폭의 회색 배경
  • 긴 문장은 mod 가 폭에 맞춰 줄을 나눠서, 감싼 줄마다 막대가 이어진다(한글은 2칸)
  • 아직 전달 안 된 대기 중 메시지는 흐리게 보였다가, 전달되면 강조색으로 바뀐다
  • 작업 중에 보낸 /명령 도 같다(실행되거나 작업이 끝나면 풀림)
  • 작업 알림·다른 에이전트의 메시지 같은 행은 건드리지 않는다
  • 처리가 끝난 뒤의 ❯ /명령 줄은 엔진이 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 에 기록한다.
Source 4 files
hooks/register.tsx 119 lines
1import { 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}
119
hooks/probe.ts 13 lines
1import 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}
13
hooks/wrap.ts 74 lines
1// 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'
74
types/index.d.ts 8 lines
1export type PendingPrompts = { text: string; at: number }[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'prompt-mark': { pending: PendingPrompts; release: number }
6  }
7}
8