SLOPSHOPPER

prompts

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

newpanecommandtoastprompt
v0.1.0MITupdated 2026-10-05jessetsai1024/claude-prompts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompts
│ ┃ prompts ✕ › fix the failing auth test and add an audit log call │ ┃ 我問過的 共 1 句 │ ┃ ⏺ Read(src/auth.ts) │ ┃ ▸ 1 01:53 fix the failing auth test and ad ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────── ⎿ Added 2 lines, removed 1 line │ ┃ 第 1 句・01:53 ⏺ Bash(bun test) │ ┃ fix the failing auth test and add an audit … ⎿ 3 pass, 1 fail │ ┃ │ ┃ k: 上一句 j: 下一句 c: 複製 e: 放回輸入框 ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /prompts │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · prompts
我問過的 共 1 句 ▸ 1 01:53 fix the failing auth test and add an audit … ──────────────────────────────────────────────────────── 第 1 句・01:53 fix the failing auth test and add an audit log call k: 上一句 j: 下一句 c: 複製 e: 放回輸入框
README

prompts:我問過的

一個 Claude Code 的 mod。側邊欄列出你這次對話打過的每一句話,選一句可以看全文、複製、放回輸入框。/prompts 開或關。

English summary at the end.

側邊欄會在新視窗自己打開,但只有終端機夠寬時(系統規定:沒手動開過要 144 格以上,手動開過一次之後 110 格);不夠寬就等你打指令,不是壞掉。

需要什麼

  • Claude Code 2.1.287 以上(mod 功能 2026-10-01 起預設開放)。
  • 不用 Node、不用裝套件。mod 跑在 Claude Code 自己的引擎裡。

安裝

用 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,只拿掉捷徑。

注意

  • 不要同時用兩種方式載入同一個 mod(marketplace 裝了就不要再接捷徑;settings.json 的 env 裡也別再放 CLAUDE_CODE_PLUGIN_DIRS 指到它),會出現兩份。
  • Windows 還沒實機跑過。路徑處理有單元測試,但作者手邊沒有 Windows 機器。有問題請開 issue,附 Claude Code 版本和畫面。
  • 想改:直接改檔案,存檔後 Claude Code 會熱重載。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 授權。


English

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.

Source 2 files
hooks/register.ts 215 lines
1import 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
215
hooks/view.ts 194 lines
1/** 主人打過的一句話。 */
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