SLOPSHOPPER

prompt-bubbles

Draws your own prompts as right-aligned chat bubbles so they stand apart from Claude's replies

newrows
★ 1v0.1.5MITupdated 2026-10-08akilin/claude-plugins/plugins/prompt-bubbles
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-bubbles
╭─────────────────────────────────────────────────────╮ │ 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

claude-plugins

Claude Code plugins that change how the terminal UI looks. They only change rendering: what the model sees and does is untouched.

Install

From your shell:

claude plugin marketplace add akilin/claude-plugins
claude plugin install better-tool-rows@akilin-plugins
claude plugin install prompt-bubbles@akilin-plugins

Then run /reload-plugins in any open session, or restart it.

Update

From your shell:

claude plugin update better-tool-rows@akilin-plugins
claude plugin update prompt-bubbles@akilin-plugins

Then run /reload-plugins in any open session, or restart it. /plugin marketplace update akilin-plugins alone only refreshes the plugin list and leaves installed plugins as they are.

To get updates automatically, open /plugin → Marketplaces → akilin-plugins → Enable auto-update.

Screenshots

With both plugins:

A session with better-tool-rows and prompt-bubbles

The same session without them (zoomed out to fit on one screen):

The same session without the plugins

Plugins

better-tool-rows

Shorter, quieter tool rows.

  • Read / Edit / Write show the file path relative to the folder Claude Code was started in (the link behind it still holds the full path, so ctrl+click opens it from anywhere), even after Claude cds elsewhere or /cd moves the project. Files outside the folder keep their full path. Windows paths match with either separator and in any case. In a VS Code dev container or codespace the file:// link is left off, since VS Code would open it on the machine outside the container; ctrl+click on the path then uses VS Code's own link, which opens the file inside.
  • Edit / Write hide the diff and put the line counts on the row instead: Update(notes.md) +3 -1. A path too long to fit on the line with its counts is cut to its end (Update(…/src/notes.md) +3 -1), still opening the full path on ctrl+click. An edit held for review rather than written is shown as usual.
  • A tool row straight after a one-line Edit / Write row drops the blank line above it, so a run of edits reads as a list. After text, or after a row with lines beneath it (Bash output, Read's Read 7 lines), the blank line stays.
  • Bash commands are put on one line (a multi-line command's lines are joined) and cut with … so Bash(command) always fits on one line, wide characters included.
  • Bash commands that create, delete or edit files list each one beneath the row with its line counts (Created notes.md +10, Deleted notes.md -10, Updated notes.md +1 -1) instead of a diff.
  • Bash output longer than 5 lines is folded behind a clickable ▸ 12 lines; unfolded, it shows up to 500 lines and counts the rest. If the command was cut or joined, the fold also holds the full command with syntax highlighting (▸ command, 12 lines); with 5 lines of output or fewer, the output stays as it is and the fold holds the command alone (▸ command).

prompt-bubbles

Your own prompts are drawn as right-aligned chat bubbles (a rounded box with a cornflower-blue border, as wide as the longest line and at most three quarters of the terminal) so they stand apart from Claude's replies. Task notifications and messages from other agents or sessions keep the usual drawing, and so does the desktop app, which already has bubbles.

Development

The repo has a dev container (Node, TypeScript and Claude Code preinstalled). It keeps Claude Code's config in a volume of its own, so logins and settings survive a rebuild, and sets VS Code's chat to auto-approve tool calls (chat.permissions.default), which is only safe inside the container.

From a plugin's folder (e.g. plugins/better-tool-rows):

claude plugin validate .
claude plugin test .
npx -y -p typescript tsc -p tsconfig.json

tsc needs the API types Claude Code writes to .claude-plugin/types/ (gitignored) when it loads the plugin, so on a fresh clone load it once first, e.g. claude --plugin-dir plugins/better-tool-rows.

License

MIT

Source 1 files
hooks/register.tsx 157 lines
1import type { Register } from 'claude-code'
2
3// The share of the transcript's width a bubble may take, so even a long
4// prompt leaves a gap on the left and reads as right-aligned.
5const MAX_SHARE = 0.75
6// Columns the border and padding add around the text: `│ ` and ` │`.
7const FRAME = 4
8// The bubble's border color: cornflower.
9const BORDER = '#6495ed'
10
11// Rows the person wrote; notifications, peers' and teammates' messages keep
12// the engine's drawing.
13const OWN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
14
15// Code points a terminal draws 2 columns wide: East Asian wide and fullwidth
16// characters, and the emoji drawn as pictures by default. The widths here are
17// kept the same as better-tool-rows' hooks/utils.tsx, which a plugin cannot
18// import from.
19const WIDE: [number, number][] = [
20  [0x1100, 0x115f],
21  [0x231a, 0x231b],
22  [0x23e9, 0x23ec],
23  [0x23f0, 0x23f0],
24  [0x23f3, 0x23f3],
25  [0x25fd, 0x25fe],
26  [0x2614, 0x2615],
27  [0x2648, 0x2653],
28  [0x267f, 0x267f],
29  [0x2693, 0x2693],
30  [0x26a1, 0x26a1],
31  [0x26aa, 0x26ab],
32  [0x26bd, 0x26be],
33  [0x26c4, 0x26c5],
34  [0x26ce, 0x26ce],
35  [0x26d4, 0x26d4],
36  [0x26ea, 0x26ea],
37  [0x26f2, 0x26f3],
38  [0x26f5, 0x26f5],
39  [0x26fa, 0x26fa],
40  [0x26fd, 0x26fd],
41  [0x2705, 0x2705],
42  [0x270a, 0x270b],
43  [0x2728, 0x2728],
44  [0x274c, 0x274c],
45  [0x274e, 0x274e],
46  [0x2753, 0x2755],
47  [0x2757, 0x2757],
48  [0x2795, 0x2797],
49  [0x27b0, 0x27b0],
50  [0x27bf, 0x27bf],
51  [0x2b1b, 0x2b1c],
52  [0x2b50, 0x2b50],
53  [0x2b55, 0x2b55],
54  [0x2e80, 0x303e],
55  [0x3040, 0xa4cf],
56  [0xac00, 0xd7a3],
57  [0xf900, 0xfaff],
58  [0xfe30, 0xfe4f],
59  [0xff00, 0xff60],
60  [0xffe0, 0xffe6],
61  [0x1f004, 0x1f004],
62  [0x1f0cf, 0x1f0cf],
63  [0x1f18e, 0x1f18e],
64  [0x1f191, 0x1f19a],
65  [0x1f200, 0x1f202],
66  [0x1f210, 0x1f23b],
67  [0x1f240, 0x1f248],
68  [0x1f250, 0x1f251],
69  [0x1f260, 0x1f265],
70  [0x1f300, 0x1faff],
71  [0x20000, 0x3fffd],
72]
73
74const ZWJ = '\u200d'
75const EMOJI_STYLE = '\ufe0f'
76
77// The columns a character takes on a terminal on its own: 0 for a combining
78// mark, joiner or variation selector, 2 for a wide one, 1 otherwise.
79const charWidth = (char: string) => {
80  if (/^[\p{Mn}\p{Me}\u200b-\u200d\ufe00-\ufe0f]$/u.test(char)) {
81    return 0
82  }
83  const c = char.codePointAt(0) ?? 0
84  return WIDE.some(([from, to]) => c >= from && c <= to) ? 2 : 1
85}
86
87// Each character of `text` with the columns it adds on a terminal: a
88// character joined on by a ZWJ adds none (a family emoji is one picture), and
89// an emoji selector widens a narrow character it follows to 2 (a heart
90// followed by U+FE0F).
91const charWidths = function* (text: string): Generator<[string, number]> {
92  let previous = ''
93  let clusterWidth = 0
94  for (const char of text) {
95    const width =
96      previous === ZWJ ? 0 : char === EMOJI_STYLE ? (clusterWidth === 1 ? 1 : 0) : charWidth(char)
97    clusterWidth = previous === ZWJ || char === EMOJI_STYLE || width === 0 ? clusterWidth + width : width
98    previous = char
99    yield [char, width]
100  }
101}
102
103// The columns a line takes on a terminal.
104export const textWidth = (text: string) => {
105  let width = 0
106  for (const [, charColumns] of charWidths(text)) {
107    width += charColumns
108  }
109  return width
110}
111
112// How wide the bubble for `text` is in a transcript `columns` wide: its
113// longest line plus the frame, at most MAX_SHARE of the width.
114export const bubbleWidth = (text: string, columns: number) => {
115  const longest = Math.max(0, ...text.split('\n').map(textWidth))
116  const most = Math.max(FRAME + 1, Math.floor(columns * MAX_SHARE))
117  return Math.min(longest + FRAME, most)
118}
119
120// A paste reaches the model wrapped as `<pasted_content id="…">` …
121// `</pasted_content id="…">`, with tags of that name inside it escaped as
122// `<\pasted_content` and `<\/pasted_content`. The engine pads the wrapper with
123// blank lines, so the newlines around it are taken as part of it.
124const PASTE = /\n*<pasted_content id="([^"]*)">\n?([\s\S]*?)\n?<\/pasted_content id="\1">\n*/g
125const ESCAPED_TAG = /<\\(\\*\/?pasted_content\b)/g
126
127// The prompt as the person pasted it: each paste's wrapper and padding
128// dropped, the paste on lines of its own, and the tags inside it unescaped
129// once.
130export const unwrapPastes = (text: string) =>
131  text.replace(PASTE, (wrapped: string, _id, body: string, at: number) => {
132    const end = at + wrapped.length
133    const before = at > 0 ? '\n' : ''
134    const after = end < text.length && !text.startsWith('<pasted_content id="', end) ? '\n' : ''
135    return before + body.replace(ESCAPED_TAG, '<$1') + after
136  })
137
138export const register: Register = on => {
139  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
140    const text = unwrapPastes(e.props.text.replace(/\r\n?/g, '\n')).replace(/\s+$/, '')
141    if (e.surface !== 'terminal' || !OWN_ORIGINS.has(e.props.origin.kind) || text === '') {
142      return next(e)
143    }
144
145    const { Box, Text } = $.ui.resolve(e)
146    const columns = e.viewport?.columns ?? 80
147
148    return (
149      <Box width="100%" justifyContent="flex-end" marginTop={1}>
150        <Box key="bubble" width={bubbleWidth(text, columns)} borderStyle="round" borderColor={BORDER} paddingX={1}>
151          <Text wrap="wrap">{text}</Text>
152        </Box>
153      </Box>
154    )
155  })
156}
157