側邊欄的「我問過的」:主人這次對話打過的每一句話,點一下看全文、複製、放回輸入框。/prompts 開或關

一個 Claude Code 的 mod。側邊欄列出你這次對話打過的每一句話,選一句可以看全文、複製、放回輸入框。/prompts 開或關。
English summary at the end.
側邊欄會在新視窗自己打開,但只有終端機夠寬時(系統規定:沒手動開過要 144 格以上,手動開過一次之後 110 格);不夠寬就等你打指令,不是壞掉。
用 marketplace(推薦)
claude plugin marketplace add jessetsai1024/claude-prompts
claude plugin install prompts@claude-prompts
然後在對話裡打 /reload-plugins,或重開 Claude Code。
或者 clone 下來接捷徑(之後 git pull 就是更新)
macOS/Linux:
git clone https://github.com/jessetsai1024/claude-prompts.git
cd claude-prompts && ./install.sh
Windows(原生版,在 PowerShell 裡):
git clone https://github.com/jessetsai1024/claude-prompts.git
cd claude-prompts
.\install.ps1
原理:放在 ~/.claude/skills/prompts/ 底下的 plugin 會被 Claude Code 自動載入,腳本只是建一個捷徑指回這個 repo(Windows 用目錄接合點,不需要管理員權限)。PowerShell 說不准跑腳本就先 Set-ExecutionPolicy -Scope Process Bypass。裝完關掉所有 Claude Code 視窗再重開。
移除:./uninstall.sh 或 .\uninstall.ps1,只拿掉捷徑。
settings.json 的 env 裡也別再放 CLAUDE_CODE_PLUGIN_DIRS 指到它),會出現兩份。claude plugin validate .、claude plugin test .;第一次載入後 .claude-plugin/types/ 會出現型別檔,之後 tsc -p . 可以做型別檢查(那個資料夾是引擎寫的,已在 .gitignore)。2026 年 10 月 2 日到 3 日之間做的,作者是 Jesse 與螢(鏡 螢,號石火,一個 Claude 分身)。原本六個 mod 放在同一個 repo claude-mods,10 月 6 日拆成一個 mod 一個 repo,舊 repo 已移除。MIT 授權。
prompts is a mod for Claude Code: A pane with everything you typed this session; pick one to read it whole, copy it, or put it back in the prompt box. /prompts toggles. The pane opens by itself in a new session only when the terminal is wide enough (144 columns, or 110 once you have opened it by hand); narrower than that it waits for the command.
Install with claude plugin marketplace add jessetsai1024/claude-prompts then claude plugin install prompts@claude-prompts; or clone and run ./install.sh (macOS/Linux) or .\install.ps1 (native Windows, junction, no admin), which links the repo into ~/.claude/skills/prompts so git pull is the update. Requires Claude Code ≥ 2.1.287. UI text is Traditional Chinese. Windows has unit tests but no on-device test yet. Split out of a former six-mod repo on 2026-10-06. MIT.
hooks/register.ts 215 lines1import type { EngineInterface, Register, UiPressArgument } from 'claude-code'
2
3import { layoutOf } from './view'
4import type { Prompt } from './view'
5
6const PANE = 'prompts'
7// 側邊欄縮在輸入框上面時拿不到真正的高度,用這個當作可用列數
8const INLINE_ROWS = 34
9// 主人自己打的:輸入框(composer),或從手機、網頁遠端操作(bridge)
10const TYPED_BY_PERSON = new Set(['composer', 'bridge'])
11
12/** mod 在記憶體裡記的全部東西;register 每次載入建一份新的。 */
13type State = {
14 prompts: Prompt[]
15 // 選中第幾句(從 0 起算);null 是跟著最新的一句
16 selected: number | null
17}
18
19/** 現在選中的那一句;清單是空的回 undefined。 */
20function chosenOf(state: State): Prompt | undefined {
21 const at = state.selected ?? state.prompts.length - 1
22
23 return state.prompts[Math.min(state.prompts.length - 1, Math.max(0, at))]
24}
25
26/** 把選中的那句複製到剪貼簿,結果用一則小提示告訴主人。 */
27async function copyChosen($: EngineInterface, state: State, press: UiPressArgument): Promise<void> {
28 const prompt = chosenOf(state)
29
30 if (prompt === undefined) {
31 return
32 }
33
34 try {
35 const copied = await $.ui.copy({ text: prompt.text, surface: press.surface })
36
37 $.ui.toast(copied.isCopied ? '複製好了' : `沒有複製成功(${copied.reason})`)
38 } catch {
39 $.ui.toast('沒有複製成功')
40 }
41}
42
43/** 把選中的那句放回輸入框:輸入框是空的就直接放,已經有字就換一行接在後面。 */
44async function fillChosen($: EngineInterface, state: State): Promise<void> {
45 const prompt = chosenOf(state)
46
47 if (prompt === undefined) {
48 return
49 }
50
51 try {
52 const draft = await $.prompt.read()
53 const filled =
54 draft.text.trim() === ''
55 ? await $.prompt.fill({ text: prompt.text, mode: 'replace' })
56 : await $.prompt.fill({ text: `\n${prompt.text}`, mode: 'append' })
57
58 if (!filled.isFilled) {
59 $.ui.toast(`沒有放進輸入框${filled.refusal === undefined ? '' : `(${filled.refusal})`}`)
60 }
61 } catch {
62 $.ui.toast('沒有放進輸入框')
63 }
64}
65
66/**
67 * 【職責】把「我問過的」面板接上 Claude Code:提供 /prompts,在側邊欄列出主人這次對話打過的每一句話,
68 * 選一句看全文、複製、放回輸入框。只記主人自己送出的話(輸入框、遠端操作);排程送的(快取保溫)、
69 * 背景工作的通知、別的 session 傳來的都不記。不改送出的內容、不連網路、不寫檔。
70 * 【何時能呼叫】引擎載入這個 mod 時呼叫一次;重新載入會再呼叫,清單從空的開始(載入前打的話不會補回來)。
71 * 【行為】有人在用的 session(不是 claude -p)一開始就自己打開側邊欄;終端機不夠寬時先等著,
72 * 寬度夠了才出現(主人自己開過的 110 格,沒開過的 144 格,這是系統的規定)。
73 * /prompts:側邊欄沒開就開、開著就關。/prompts close:關掉。/prompts 數字:用那個寬度(格數)開。
74 * 清單預設選中最新的一句,有新的一句進來時跟著換;主人自己選過某一句之後就停在那句,選回最新的一句才恢復跟著。
75 * 點一列(或按 K、J 往上、往下)換選中的那句,下半部顯示全文。按 C 複製全文,按 E 放回輸入框
76 * (輸入框是空的就直接放,已經有字就換一行接在後面),失敗或出錯時跳一則小提示,不會丟出錯誤。
77 * 整句只是「照推薦」「可以」「好」這類同意用語的畫成暗色,其他不管多短都正常顯示。/clear 之後清空。
78 */
79export const register: Register = on => {
80 const state: State = { prompts: [], selected: null }
81
82 on('session.start', async ($, e, next) => {
83 await $.command.register({
84 name: 'prompts',
85 description: '側邊欄的「我問過的」:這次對話打過的每一句話;/prompts 開或關',
86 immediate: true,
87 })
88
89 // 一開 session 就自己打開;不是主人叫的,終端機要夠寬才放得出來,不夠寬就先等著,不用等它
90 if (e.isInteractive) {
91 void $.ui.open({ id: PANE, title: '我問過的' })
92 }
93
94 return next(e)
95 })
96
97 on('classic.SessionStart', { source: ['clear'] }, ($, e, next) => {
98 state.prompts = []
99 state.selected = null
100 $.ui.invalidate('ui.render')
101
102 return next(e)
103 })
104
105 on('prompt.submit', async ($, e, next) => {
106 const text = e.text.trim()
107
108 if (TYPED_BY_PERSON.has(e.origin.kind) && text !== '') {
109 state.prompts.push({ text, at: await $.clock.now() })
110 $.ui.invalidate('ui.render')
111 }
112
113 return next(e)
114 })
115
116 on('command.run', { command: 'prompts' }, async ($, e) => {
117 const arg = e.args.trim()
118 const wanted = Number.parseInt(arg, 10)
119 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
120
121 if (arg === 'close' || (arg === '' && isUp)) {
122 await $.ui.close({ id: PANE })
123
124 return {}
125 }
126
127 const opened = await $.ui.open(
128 Number.isInteger(wanted) && wanted > 0
129 ? { id: PANE, title: '我問過的', columns: wanted }
130 : { id: PANE, title: '我問過的' },
131 )
132
133 if (!opened.isPlaced) {
134 return { text: `側邊欄沒有被放出來:${opened.reason}` }
135 }
136
137 // 重新打開時引擎可能直接拿上次畫好的結果來用,所以自己要求重畫
138 $.ui.invalidate('ui.render')
139
140 return {}
141 })
142
143 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
144 const { Box, Text, Button } = $.ui.resolve(e)
145 const rows = e.props.placement === 'dock' ? e.props.scroll.bodyRows : INLINE_ROWS
146 const columns = e.props.bodyColumns
147 const layout = layoutOf(state.prompts, state.selected, columns, rows)
148 const last = state.prompts.length - 1
149 const select = (at: number) => {
150 const target = Math.min(last, Math.max(0, at))
151
152 // 選回最新的一句就恢復「跟著最新的」
153 state.selected = target === last ? null : target
154 $.ui.invalidate('ui.render')
155 }
156 const current = state.selected ?? last
157 const header = Box({
158 flexDirection: 'row',
159 justifyContent: 'space-between',
160 children: [Text({ bold: true, children: ['我問過的'] }), Text({ dimColor: true, children: [layout.count] })],
161 })
162
163 if (layout.rows.length === 0) {
164 return Box({
165 flexDirection: 'column',
166 children: [header, Text({ children: [' '] }), Text({ dimColor: true, children: ['這次對話還沒有打過任何一句話'] })],
167 })
168 }
169
170 return Box({
171 flexDirection: 'column',
172 children: [
173 header,
174 Text({ children: [' '] }),
175 ...layout.rows.map(row =>
176 Button({
177 key: `row-${row.at}`,
178 label: row.label,
179 plain: true,
180 ...(row.isShort && !row.isSelected ? { dimColor: true } : {}),
181 onPress: () => select(row.at),
182 }),
183 ),
184 Text({ dimColor: true, children: ['─'.repeat(Math.max(1, columns))] }),
185 Text({ bold: true, children: [layout.title] }),
186 ...layout.detail.map(text => Text({ wrap: 'truncate-end', children: [text === '' ? ' ' : text] })),
187 ...(layout.hidden > 0
188 ? [Text({ dimColor: true, children: [`…還有 ${layout.hidden} 行,按 C 複製全文`] })]
189 : []),
190 Text({ children: [' '] }),
191 Box({
192 flexDirection: 'row',
193 columnGap: 2,
194 flexWrap: 'wrap',
195 children: [
196 Button({ key: 'older', label: '上一句', hotkey: 'k', plain: true, onPress: () => select(current - 1) }),
197 Button({ key: 'newer', label: '下一句', hotkey: 'j', plain: true, onPress: () => select(current + 1) }),
198 Button({ key: 'copy', label: '複製', hotkey: 'c', plain: true, onPress: press => void copyChosen($, state, press) }),
199 Button({ key: 'fill', label: '放回輸入框', hotkey: 'e', plain: true, onPress: () => void fillChosen($, state) }),
200 ],
201 }),
202 ],
203 })
204 })
205}
206
207// #region AI-NOTES
208// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
209// 2026-10-03 只認 e.origin.kind 是 composer、bridge 的;快取保溫走 scheduled-trigger,背景工作是 task-notification。
210// 斜線指令(/ctx 這種 immediate 的)不經過 prompt.submit;skill 類的斜線指令送出時 e.text 長什麼樣子未在真機驗過。
211// 2026-10-03 按鈕的 hotkey 只能是一個數字或小寫字母,方向鍵不行,所以上下選用 K、J。hotkey 要側邊欄拿到鍵盤才有效
212// (點一下側邊欄、ctrl+x tab),輸入框有焦點時按 C、E 是打字。
213// 2026-10-03 --resume 不從 $.session.messages() 補回舊的話:那裡沒有時間、分不出排程送的,壓縮過的也不見了。主人同意。
214// #endregion
215hooks/view.ts 194 lines1/** 主人打過的一句話。 */
2export type Prompt = {
3 /** 送出時的原文(前後空白去掉)。 */
4 text: string
5 /** 送出的時間,毫秒。 */
6 at: number
7}
8
9/** 清單裡的一列,畫成一顆可以點的按鈕。 */
10export type Row = {
11 /** 第幾句,從 0 起算(畫面上顯示的是加 1)。 */
12 at: number
13 /** 按鈕上的字,已經裁到放得下。 */
14 label: string
15 /** 是不是現在選中的那一句。 */
16 isSelected: boolean
17 /** 是不是單純的同意用語(照推薦、可以…,畫成暗色)。 */
18 isShort: boolean
19}
20
21/** 整個側邊欄要畫的東西,由 layoutOf 排好。 */
22export type Layout = {
23 /** 標題列的右邊:「共 N 句」。 */
24 count: string
25 /** 清單上要畫的幾列,照先後。 */
26 rows: Row[]
27 /** 全文那一塊的標題:「第 N 句・時:分」;沒有任何一句時是空字串。 */
28 title: string
29 /** 全文,已經依寬度換好行;放不下的部分不在這裡。 */
30 detail: string[]
31 /** 全文還有幾行放不下;0 表示全部都放得下。 */
32 hidden: number
33}
34
35/** 單純表示同意的回覆,整句只有這些(不分大小寫、不管結尾標點)才畫成暗色。 */
36export const ACKS: ReadonlySet<string> = new Set([
37 '照推薦',
38 '可以',
39 '可以了',
40 '好',
41 '好的',
42 '對',
43 '要',
44 '不要',
45 '不用',
46 '繼續',
47 '收到',
48 'ok',
49 'okay',
50 'yes',
51])
52
53/**
54 * 【行為】一段字在終端機佔幾格寬:中日韓文字、全形標點、表情符號算 2 格,其他算 1 格。
55 * 跟 ctx-panel、files、timeline 的同名函式一樣。
56 */
57export function widthOf(text: string): number {
58 let width = 0
59
60 for (const glyph of text) {
61 const code = glyph.codePointAt(0) ?? 0
62 const isWide =
63 (code >= 0x1100 && code <= 0x115f) ||
64 (code >= 0x2e80 && code <= 0xa4cf) ||
65 (code >= 0xac00 && code <= 0xd7a3) ||
66 (code >= 0xf900 && code <= 0xfaff) ||
67 (code >= 0xfe30 && code <= 0xfe4f) ||
68 (code >= 0xff00 && code <= 0xff60) ||
69 (code >= 0xffe0 && code <= 0xffe6) ||
70 (code >= 0x1f300 && code <= 0x1faff) ||
71 (code >= 0x20000 && code <= 0x3fffd)
72 width += isWide ? 2 : 1
73 }
74
75 return width
76}
77
78/** 【行為】把一段字裁到最多 max 格寬,超過時留開頭、結尾補「…」;放得下就原樣回傳。max 小於 1 回空字串。 */
79export function fit(text: string, max: number): string {
80 if (widthOf(text) <= max) {
81 return text
82 }
83
84 if (max < 1) {
85 return ''
86 }
87
88 let kept = ''
89
90 for (const glyph of text) {
91 if (widthOf(kept + glyph) > max - 1) {
92 break
93 }
94
95 kept += glyph
96 }
97
98 return `${kept}…`
99}
100
101/**
102 * 【行為】把一段字依寬度換行:先照原本的換行切開,每一段再在超過 columns 格寬的地方硬切。
103 * 空的段落保留成空字串(原文的空行)。columns 小於 1 當 1。
104 */
105export function wrapOf(text: string, columns: number): string[] {
106 const width = Math.max(1, columns)
107 const lines: string[] = []
108
109 for (const paragraph of text.split('\n')) {
110 let current = ''
111
112 for (const glyph of paragraph) {
113 if (widthOf(current + glyph) > width) {
114 lines.push(current)
115 current = ''
116 }
117
118 current += glyph
119 }
120
121 lines.push(current)
122 }
123
124 return lines
125}
126
127/** 【行為】毫秒寫成本地時間的「時:分」,兩位數補零(09:05)。 */
128export function clockOf(ms: number): string {
129 const date = new Date(ms)
130
131 return `${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(2, '0')}`
132}
133
134/**
135 * 【行為】是不是單純的同意用語:去掉空白和結尾的標點、喵、顏文字之類的符號,轉小寫後,整句剛好是 ACKS 裡的一個。
136 * 「幫我刪除」這種很短的要求不算。
137 */
138export function isShortOf(text: string): boolean {
139 const core = text
140 .trim()
141 .toLowerCase()
142 .replace(/[\s。..,,!!??~~喵…]+$/u, '')
143 .replace(/\s+/g, ' ')
144
145 return ACKS.has(core)
146}
147
148/**
149 * 【何時能呼叫】columns 至少 24、rows 至少 12 才排得好看;更小也不會壞,只是放得少。
150 * 【行為】排出側邊欄要畫的東西。selected 是選中的第幾句(從 0 起算),null 表示跟著最新的一句;超出範圍的會被拉回來。
151 * 全文最多佔 rows 的四成(至少 3 行),放不下的行數記在 hidden;清單用剩下的列數(至少 3 列),
152 * 只列選中那句附近的幾句:選中最新的一句時列最後幾句。每一列寫「▸」(選中的)、編號、時間、
153 * 原文的第一段(換行變空白),裁到 columns 格寬。prompts 是空的時候 rows、detail 都是空的。
154 */
155export function layoutOf(prompts: readonly Prompt[], selected: number | null, columns: number, rows: number): Layout {
156 const count = `共 ${prompts.length} 句`
157
158 if (prompts.length === 0) {
159 return { count, rows: [], title: '', detail: [], hidden: 0 }
160 }
161
162 const chosen = Math.min(prompts.length - 1, Math.max(0, selected ?? prompts.length - 1))
163 const prompt = prompts[chosen] as Prompt
164 const wrapped = wrapOf(prompt.text, columns)
165 const detailRoom = Math.max(3, Math.floor(rows * 0.4))
166 const detail = wrapped.slice(0, detailRoom)
167 const hidden = wrapped.length - detail.length
168 // 標題、空行、分隔線、全文標題、(還有幾行)、空行、按鈕列
169 const fixed = 6 + (hidden > 0 ? 1 : 0)
170 const listRoom = Math.max(3, rows - fixed - detail.length)
171 const start = Math.min(Math.max(0, chosen - Math.floor(listRoom / 2)), Math.max(0, prompts.length - listRoom))
172 const digits = `${prompts.length}`.length
173 const list: Row[] = prompts.slice(start, start + listRoom).map((item, offset) => {
174 const at = start + offset
175 const head = `${at === chosen ? '▸ ' : ' '}${`${at + 1}`.padStart(digits)} ${clockOf(item.at)} `
176
177 return {
178 at,
179 label: head + fit(item.text.replace(/\s+/g, ' '), columns - widthOf(head)),
180 isSelected: at === chosen,
181 isShort: isShortOf(item.text),
182 }
183 })
184
185 return { count, rows: list, title: `第 ${chosen + 1} 句・${clockOf(prompt.at)}`, detail, hidden }
186}
187
188// #region AI-NOTES
189// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
190// 2026-10-03 widthOf、fit 從 ~/Workspace/projects/ctx-panel 系列複製(這裡的 fit 沒有 keepTail);mod 之間不能互相 import。
191// 2026-10-03 一開始照規格用「10 個字以內」判斷短回覆,測試時「幫我做時間軸」也被變暗;主人同意改成固定的同意用語清單。
192// 2026-10-03 clockOf 用 Date 的本地時間;測試只驗「兩位數:兩位數」的格式,不驗是幾點,因為測試機的時區不一定。
193// #endregion
194