SLOPSHOPPER

clean-view

工具呼叫收成一行淡色摘要(Edit、Write、Bash 與錯誤訊息照常顯示),計畫清單顯示在 prompt 上方;/clean 切換

newbandrowsguardcommandprompt
v0.1.0MITupdated 2026-10-09StalicJi/my-mods/clean-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clean-view
› 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 › /clean ⎿ clean-view: Clean View 已關閉:恢復完整顯示。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

my-mods

個人使用的 Claude Code mod marketplace。這裡只放通用的程式碼,網址、token 這類私人設定由每台電腦各自設定,不進版控。

mod用途
clean-view工具呼叫收成一行淡色摘要(Edit、Write、Bash 與錯誤訊息照常顯示),計畫清單顯示在輸入框上方;/clean 切換
where-am-i輸入框上方的進度摘要(繁體中文版,fork 自 hamzafer/claude-code-mods,MIT)
next-steps回合結束後建議下一步(繁體中文版,fork 自 anthropics/claude-plugins-community,Apache 2.0)
gitlab-sync分支跟遠端的同步狀態與還開著的張數(GitLab 的 Task、Issue 分開計數);GitLab 與 GitHub 上自己的 issue/MR(PR)有新動態時通知,/gitlab、/github 查看
agent-panel派出子代理時跳出面板(右側或輸入框上方),顯示每個子代理的模型、用量、估算費用與時間;/agents 開關

在新電腦安裝

clone 到固定的資料夾,再把那個資料夾加成 marketplace。這樣 Claude Code 會直接讀資料夾裡的程式,git pull 之後執行 /reload-plugins 就是新版:

git clone <這個 repo 的網址> ~/.claude/mods-marketplaces/my-mods
claude plugin marketplace add ~/.claude/mods-marketplaces/my-mods
claude plugin install clean-view@my-mods
claude plugin install where-am-i@my-mods
claude plugin install next-steps@my-mods
claude plugin install gitlab-sync@my-mods
claude plugin install agent-panel@my-mods

每台電腦各自的設定(gitlab-sync)

項目設定方式沒設定時
GitLab 網址在 Claude Code 執行 /plugin configure gitlab-sync,填入 gitlabUrl(例如 https://gitlab.example.com,不含 /api/v4);也可以執行 `echo '{"gitlabUrl":"https://gitlab.example.com"}' \claude plugin configure gitlab-sync@my-mods --values-stdin`不檢查 GitLab
GitLab token環境變數 GITLAB_TOKEN;或存進 macOS 鑰匙圈:security add-generic-password -U -a "$USER" -s gitlab-token -w。環境變數優先,是空的或被拒絕(401)時改讀鑰匙圈輸入框上方顯示「GitLab 通知暫停」(在 GitHub repo 裡不顯示:band 只顯示目前 repo 所屬平台的新動態與通知暫停)
GitHubgh auth login,token 跟 gh CLI 共用不檢查 GitHub

設定值存在這台電腦的 Claude Code 設定裡,不在這個 repo。

開發

改完任一個 mod:

claude plugin test <mod 資料夾>
claude plugin validate <mod 資料夾>
bash scripts/check-contracts.sh   # 改過任何 types/index.d.ts 後執行

可能有好幾個 Claude Code session 同時在改這裡的檔案,動手前先看 git status。

推送前的私人資訊檢查

scripts/git-hooks/pre-push 會在 git push 前掃描要送出去的 commit:新增的行、檔名、作者與提交者、commit 訊息。找到私有網段 IP,或私人關鍵字清單裡的字,就擋下推送。每個 clone 要做兩件事:

git config core.hooksPath scripts/git-hooks
# 這台電腦的私人關鍵字清單:一行一個延伸正規表示式,不分大小寫,# 開頭是註解
$EDITOR ~/.config/git/private-patterns
  • 清單本身就是私人資訊,只放在每台電腦,不進 repo。清單檔不存在、或裡面有寫錯的正規表示式時,一律擋下推送。
  • 確定是誤判時用 git push --no-verify。

授權

clean-view 與 gitlab-sync 採用根目錄的 MIT License。where-am-i(MIT)與 next-steps(Apache 2.0)是 fork,依各自資料夾裡的 LICENSE。

Source 2 files
hooks/register.tsx 699 lines
1// Clean View:工具呼叫收成一行淡色摘要(Edit、Write、Bash 與錯誤訊息照常顯示),Claude 回報的計畫清單顯示在 prompt 上方;/clean 切換
2import { atom, read, update } from 'claude-code'
3import type { Color, EngineInterface, Register, ThemeKey, Timer } from 'claude-code'
4
5import type { CombinedBoxMode, PlanStatus, PlanStep, TurnClock } from '../types'
6
7const PLAN_TOOL_NAME = 'update_plan'
8// 外掛註冊的工具,模型看到的全名固定是 mcp__<plugin>__<name>;寫成字串常值,matcher 才讀得出來。
9// where-am-i/hooks/register.tsx 也用這個名稱略過計畫工具,改名要兩邊一起改
10const PLAN_TOOL = 'mcp__clean-view__update_plan'
11const MAX_GROUP_LINES = 5
12// 這幾個工具照 Claude Code 原本的樣子畫,看得到 diff、指令與輸出。
13// 唯讀的 Bash(ls、cat、grep…)會被 Claude Code 折進 ToolGroup,那裡照樣一行摘要
14const FULL_VIEW_TOOLS: readonly string[] = ['Edit', 'Write', 'Bash']
15const MAX_ERROR_LINES = 4
16
17// 計畫清單的排版
18const BAR_WIDTH = 12
19const BAR_COLUMNS = BAR_WIDTH + 2 // 進度條後面空兩格再接狀態字
20// 影片 1:53~1:56 的 Working 條是一顆彗星:頭最亮,後面四格一格比一格淡,融進灰色底
21const COMET_FADE = [1, 0.7, 0.45, 0.25, 0.1]
22// 影片的主色:橘、粉紅、紫、藍。彗星顏色隨時間在這幾個顏色之間來回變化
23const ORANGE = '#f79a4f'
24const PINK = '#ec4f8f'
25const ACCENT_COLORS = [ORANGE, PINK, '#b45ce6', '#6f7df2']
26const TITLE_MARK = '✳ '
27const TITLE_COLOR = ORANGE
28const ELAPSED_COLUMNS = 8 // 「59m 59s」寬 7,再空一格
29const COMET_COLOR_HALF_PERIOD_FRAMES = 15 // 從橘變到藍約 3 秒
30const MARK_COLUMNS = 4 // 「  ✓ 」
31const STATUS_COLUMNS = 8 // 「Working」寬 7
32const MIN_TITLE_COLUMNS = 12
33const BOX_CHROME_COLUMNS = 4 // 外框左右各一格線、一格內距
34const BOX_BORDER_ROWS = 2
35const PLAN_HEADER_ROWS = 2 // 整體進度列與「Plan:」標題列
36const PERCENT_COLUMNS = 5 // 「 100%」
37const MIN_OVERALL_BAR_WIDTH = 6
38// 影片 1:53~1:56:整體長條填滿的那段有一道較亮的光,約兩格寬,往右掃到填滿處尾端後停一下再從頭來。
39// 影片約 0.4 秒一格,長條一長要掃很久,依使用者要求加快成每拍(0.2 秒)一格
40const OVERALL_SHIMMER_WIDTH = 2
41const OVERALL_SHIMMER_PAUSE_CELLS = 4
42const OVERALL_SHIMMER_LIGHTEN = 0.35
43const MIN_STEP_ROWS = 3
44const MAX_STEP_ROWS = 7
45// 有彗星或亮光要動時每 0.2 秒一拍;只剩標題列的經過秒數要更新時,每秒一拍就夠
46const ANIMATION_MS = 200
47const CLOCK_ONLY_MS = 1000
48
49// ▆ 只佔格子下方四分之三:Ghostty 自己畫方塊字元並填滿整格高度,用滿格的方塊上下列會黏在一起,
50// ▆ 上方留空,列與列之間才分得開。完成的步驟是綠漸層,進行中的步驟是彗星(見 stepBar)
51const BAR_CELL = '▆'
52const DONE_GRADIENT = { from: '#2c9a52', to: '#9ee6b4' }
53// 整體長條已完成那段的填色:左邊橘、填滿處的尾端粉紅
54const OVERALL_FILL_GRADIENT = { from: ORANGE, to: PINK }
55// Working 狀態字與整體百分比的顏色
56const WORKING_COLOR = PINK
57// 空的部分是灰色底;暫停時彗星停住、改成淺灰
58const PAUSED_COLOR = '#8a8a94'
59const TRACK_COLOR = '#4a4a52'
60
61// 狀態由 host 保存,熱重載後仍在;每個 session 預設開啟
62const isEnabled = atom({ plugin: 'clean-view', key: 'isEnabled' } as const, true)
63const plan = atom({ plugin: 'clean-view', key: 'plan' } as const, [] as PlanStep[])
64// 只有 band 讀它,所以動畫只會讓 band 重畫;用 $.ui.invalidate 會連 transcript 每一列都重跑
65const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
66// 主迴圈回合的起訖時間:動畫計時器看它判斷回合是否進行中,標題列用它算經過秒數;
67// 畫面要不要顯示一律看 band 自己的 e.props.isWorking
68const turnClock = atom({ plugin: 'clean-view', key: 'turnClock' } as const, null as TurnClock | null)
69// where-am-i 讀這個值決定要不要讓出位置(where-am-i/hooks/register.tsx 的 cleanViewCombinedBox),改名要兩邊一起改
70const combinedBox = atom({ plugin: 'clean-view', key: 'combinedBox' } as const, 'hidden' as CombinedBoxMode)
71// where-am-i 擁有的摘要,合併框讀來顯示 Goal/Now/Wait
72const whereAmIRecap = { plugin: 'where-am-i', key: 'recap' } as const
73const whereAmILive = { plugin: 'where-am-i', key: 'live' } as const
74
75const PLAN_SCHEMA = {
76  type: 'object',
77  properties: {
78    steps: {
79      type: 'array',
80      description: 'The whole plan, in order. It replaces the previous list.',
81      items: {
82        type: 'object',
83        properties: {
84          title: { type: 'string', description: 'A short phrase, in the language you reply in.' },
85          status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
86        },
87        required: ['title', 'status'],
88      },
89    },
90  },
91  required: ['steps'],
92}
93
94const PLAN_TOOL_DESCRIPTION =
95  'Show the person a checklist of your plan above their prompt; they see it instead of your tool calls. ' +
96  'Send the whole list every time: it replaces the previous one. Keep exactly one step in_progress while you work. ' +
97  'Step titles are short phrases (about 20 CJK or 40 Latin characters at most) in the language you reply in.'
98
99// 看畫面的人讀的語言;主對話的計畫指示與子代理的語言要求共用,換語言只改這裡
100const READER_LANGUAGE = 'Traditional Chinese (Taiwan usage)'
101
102// 這個環境的主對話沒有 TodoWrite/TaskCreate,計畫清單只能靠模型主動回報,所以要在 system prompt 交代。
103// 最後一行的語言要求:工具呼叫收成一行後,畫面上最顯眼的是工具之間的進度說明,
104// 而查對話紀錄發現英文幾乎都出在這裡(思考是英文或剛收到英文的系統提醒時特別容易)
105const PLAN_INSTRUCTIONS = [
106  '# Plan checklist',
107  `The person uses Clean View: each tool call shows as one short line, and a checklist from the ${PLAN_TOOL} tool shows above the prompt.`,
108  `For a task of three or more steps, call ${PLAN_TOOL} before starting, the first step in_progress and the rest pending.`,
109  'Call it again as each step finishes, moving in_progress to the next one, and mark every step completed when the work is done.',
110  'Skip it for questions you answer directly and for tasks of one or two steps.',
111  `Write the progress notes between tool calls, and any status update you give in reply to a system reminder, in ${READER_LANGUAGE} even when your thinking or the reminder is in English; keep code identifiers, commands and paths as they are.`,
112].join('\n')
113
114// 附在每個子代理任務尾端的語言要求;內容固定,同一句不論附幾次都一樣
115const SUBAGENT_LANGUAGE_NOTE = [
116  '# Language',
117  `The person watches this run on screen and reads ${READER_LANGUAGE}.`,
118  `Write your progress notes between tool calls, the description you give each tool call, and your final report in ${READER_LANGUAGE}.`,
119  'Keep code identifiers, commands, file names and paths as they are.',
120  'If the task above explicitly asks for another language or an exact output format, follow the task.',
121].join('\n')
122
123// 畫面上的步驟狀態:in_progress 依回合是否進行中分成 running 與 paused(例如 Esc 中斷後)
124type StepDisplay = 'completed' | 'running' | 'paused' | 'pending'
125
126type RowStyle = {
127  mark: string
128  markColor?: Color
129  isMarkDim: boolean
130  isTextBold: boolean
131  isTextDim: boolean
132  label: string
133  labelColor?: Color
134  isLabelBold: boolean
135}
136
137const ROW_STYLE: Record<StepDisplay, RowStyle> = {
138  completed: { mark: '✓', markColor: 'success', isMarkDim: false, isTextBold: false, isTextDim: true, label: 'Done', isLabelBold: false },
139  running: { mark: '▸', isMarkDim: false, isTextBold: true, isTextDim: false, label: 'Working', labelColor: WORKING_COLOR, isLabelBold: true },
140  paused: { mark: '▸', isMarkDim: true, isTextBold: false, isTextDim: false, label: 'Paused', isLabelBold: false },
141  pending: { mark: '○', isMarkDim: true, isTextBold: false, isTextDim: true, label: '—', isLabelBold: false },
142}
143
144type PlanRow = { text: string; display: StepDisplay }
145type RecapSummary = { goal: string; now: string; waiting: string }
146
147// 模組重新載入時 engine 會丟掉舊的 timer,這個變數也跟著重來
148let animation: { timer: Timer; intervalMs: number } | undefined
149
150// 計畫、開關或回合狀態變動後都呼叫這裡:決定合併框的顯示模式,以及計時器要不要跑、跑多快
151async function refreshPlanBand($: EngineInterface) {
152  const [enabled, steps, clock, currentMode] = await Promise.all([read($, isEnabled), read($, plan), read($, turnClock), read($, combinedBox)])
153  const mode = combinedBoxMode(enabled, steps)
154  // 沒變就不寫:寫入會讓讀它的 band(含 where-am-i)重畫
155  if (mode !== currentMode) await update($, combinedBox, () => mode)
156  const turnRunning = clock !== null && clock.endedAt === null
157  syncAnimation($, enabled && turnRunning ? animationIntervalMs(steps) : null)
158}
159
160function syncAnimation($: EngineInterface, intervalMs: number | null) {
161  if (animation?.intervalMs === intervalMs) return
162  stopAnimation()
163  if (intervalMs === null) return
164  animation = { intervalMs, timer: $.clock.every(intervalMs, () => void update($, tick, frame => frame + 1).catch(() => {})) }
165}
166
167function stopAnimation() {
168  animation?.timer.cancel()
169  animation = undefined
170}
171
172// 回合中有計畫才需要計時器:有進行中的步驟(彗星)或整體長條有亮光可掃時每 0.2 秒一拍,
173// 只剩標題列的經過秒數要更新時每秒一拍;沒有計畫就不跑
174export function animationIntervalMs(steps: readonly PlanStep[]): number | null {
175  if (steps.length === 0) return null
176  const hasComet = steps.some(step => step.status === 'in_progress')
177  const hasShimmer = countCompleted(steps) > 0 && !isPlanFinished(steps)
178  return hasComet || hasShimmer ? ANIMATION_MS : CLOCK_ONLY_MS
179}
180
181// where-am-i 沒裝或還沒有摘要時回傳 null,合併框就只畫計畫
182async function readRecapSummary($: EngineInterface): Promise<RecapSummary | null> {
183  const { value: recap } = await $.state.get(whereAmIRecap)
184  if (!recap) return null
185  const { value: live } = await $.state.get(whereAmILive)
186  return { goal: recap.goal, now: live || recap.now, waiting: recap.waiting }
187}
188
189export const register: Register = on => {
190  on('session.start', async ($, e, next) => {
191    const started = await next(e)
192    // 名稱已被 Claude Code 佔用時會被拒絕,不影響其他功能
193    await $.command
194      .register({ name: 'clean', description: '切換 Clean View:收起工具呼叫細節,只看計畫清單' })
195      .catch(() => {})
196    await $.tool.register({ name: PLAN_TOOL_NAME, description: PLAN_TOOL_DESCRIPTION, inputSchema: PLAN_SCHEMA })
197    await refreshPlanBand($) // 熱重載也會走到這裡,回合中重載時把動畫接回來
198    return started
199  })
200
201  on('command.run', { command: 'clean' }, async $ => {
202    const enabled = await update($, isEnabled, value => !value)
203    await refreshPlanBand($)
204    return { text: enabled ? 'Clean View 已開啟:工具呼叫收成一行摘要,Edit、Write、Bash 與錯誤訊息照常顯示。' : 'Clean View 已關閉:恢復完整顯示。' }
205  })
206
207  // 內容固定的 session 區段,不會讓 prompt cache 每次失效;沒有這個工具的請求(例如部分 subagent)不加
208  on('prompt.compose', async ($, e, next) => {
209    const composed = await next(e)
210    if (!e.tools.includes(PLAN_TOOL)) return composed
211    return { sections: [...composed.sections, { id: 'clean-view:plan', text: PLAN_INSTRUCTIONS, scope: 'session' }] }
212  })
213
214  // 子代理的 system prompt 由 agent 定義自己組成,不經過 prompt.compose,所以語言要求只能附在交給它的任務尾端。
215  // workflow 啟動的 agent 內容不能改寫(改了會被忽略並記一行失敗),原樣放行
216  on('agent.spawn', async ($, e, next) => {
217    if (e.workflow) return next(e)
218    return next({ ...e, prompt: withSubagentLanguageNote(e.prompt) })
219  })
220
221  // 自己回答,不呼叫 next:這個工具沒有實際執行的東西,也就不會跳權限確認
222  on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
223    if (e.agentId) return { result: 'Ignored: only the main conversation updates the plan.' }
224    const steps = parseSteps((e as { steps?: unknown }).steps)
225    if (steps === null) return { deny: 'steps must be an array of { title, status } with status pending, in_progress or completed.' }
226    await update($, plan, () => steps)
227    await refreshPlanBand($)
228    return { result: `Plan shown: ${countCompleted(steps)}/${steps.length} completed.` }
229  })
230
231  // 上一份計畫已全部完成才清掉;還沒做完的留著,Claude 可能下一輪接著做。
232  // prompt 被擋下也無妨:做完的計畫在閒置時本來就不顯示
233  on('prompt.submit', async ($, e, next) => {
234    await update($, plan, current => (isPlanFinished(current) ? [] : current))
235    await refreshPlanBand($)
236    return next(e)
237  })
238
239  // subagent 的執行不會發 turn.start,只有主迴圈會
240  on('turn.start', async ($, e, next) => {
241    const startedAt = await $.clock.now()
242    await update($, turnClock, () => ({ startedAt, endedAt: null }))
243    await refreshPlanBand($)
244    return next(e)
245  })
246
247  // 中斷(reason 為 aborted)也會發 turn.complete
248  on('turn.complete', async ($, e, next) => {
249    if (!e.agentId) {
250      const endedAt = await $.clock.now()
251      await update($, turnClock, clock => (clock ? { ...clock, endedAt } : clock))
252      await refreshPlanBand($)
253    }
254    return next(e)
255  })
256
257  on('session.end', async ($, e, next) => {
258    stopAnimation()
259    if (e.reason === 'clear') {
260      await update($, plan, () => [])
261      await refreshPlanBand($)
262    }
263    return next(e)
264  })
265
266  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
267    if (!(await read($, isEnabled)) || FULL_VIEW_TOOLS.includes(e.props.tool)) return next(e)
268    const { Box, Text } = $.ui.resolve(e)
269    if (e.props.tool === PLAN_TOOL) return <Box />
270    return (
271      <Box flexDirection="column">
272        {toolLines(e.props).map(line => (
273          <Text color={line.color} dimColor={line.color === undefined} wrap="truncate-end">
274            {line.text}
275          </Text>
276        ))}
277      </Box>
278    )
279  })
280
281  // 錯誤內容由上面 ToolUse 那一列接在摘要下面:群組裡的呼叫沒有 ToolResult,兩邊才畫得一致
282  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
283    if (!(await read($, isEnabled)) || FULL_VIEW_TOOLS.includes(e.props.tool)) return next(e)
284    const { Box } = $.ui.resolve(e)
285    return <Box />
286  })
287
288  // 連續的讀檔、搜尋會被折成一組;照樣一個動作一行(出錯的下面接錯誤內容),太多時只留最後幾個
289  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
290    if (!(await read($, isEnabled))) return next(e)
291    const { Box, Text } = $.ui.resolve(e)
292    const calls = e.props.calls.filter(call => call.tool !== PLAN_TOOL)
293    const shown = calls.slice(-MAX_GROUP_LINES)
294    const hiddenCount = calls.length - shown.length
295    return (
296      <Box flexDirection="column">
297        {hiddenCount > 0 && <Text dimColor>{`  · 前面還有 ${hiddenCount} 個動作`}</Text>}
298        {shown.flatMap(toolLines).map(line => (
299          <Text color={line.color} dimColor={line.color === undefined} wrap="truncate-end">
300            {line.text}
301          </Text>
302        ))}
303      </Box>
304    )
305  })
306
307  // 計畫顯示時畫合併框:where-am-i 的 Goal/Now/Wait 加上計畫清單(Next 跟計畫重複所以不放)
308  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
309    const rest = await next(e) // 其他 mod 與 Claude Code 自己畫的內容保留在下面
310    const mode = await read($, combinedBox)
311    if (e.props.hasSurvey || !isCombinedBoxShown(mode, e.props.isWorking)) return rest
312
313    const { Box, Text } = $.ui.resolve(e)
314    const innerColumns = e.props.bodyColumns - BOX_CHROME_COLUMNS
315    const [summary, clock, steps, frame] = await Promise.all([readRecapSummary($), read($, turnClock), read($, plan), read($, tick)])
316    const title = titleRow(summary?.goal ?? '', steps, await elapsedLabel($, clock), innerColumns)
317    const view = planView(steps, {
318      frame,
319      isWorking: e.props.isWorking,
320      columns: innerColumns,
321      rows: e.props.maxRows - BOX_BORDER_ROWS - rowsAbovePlan(title, summary),
322    })
323    return (
324      <Box flexDirection="column">
325        <Box flexDirection="column" paddingX={1} borderStyle="round" borderColor="suggestion">
326          {/* 標題列(照影片):左邊是目標,右邊是這個回合經過的時間 */}
327          {title && (
328            <Box flexDirection="row" justifyContent="space-between">
329              <Text bold color={TITLE_COLOR} wrap="truncate-end">
330                {title.text}
331              </Text>
332              {title.elapsed !== null && <Text dimColor>{title.elapsed}</Text>}
333            </Box>
334          )}
335          {summary?.now && (
336            <Text wrap="truncate-end">
337              <Text dimColor>{'  Now: '}</Text>
338              <Text>{summary.now}</Text>
339            </Text>
340          )}
341          {/* 整體進度列:進度條跟各步驟的進度條從同一欄開始,一路延伸到框的右邊 */}
342          <Box flexDirection="row">
343            <Box width={view.titleColumns}>
344              <Text dimColor wrap="truncate-end">{`  ${view.overall.label}`}</Text>
345            </Box>
346            <Box width={view.overall.cells.length + 1}>
347              <Text>
348                {colorRuns(view.overall.cells).map(run => (
349                  <Text color={run.color}>{BAR_CELL.repeat(run.count)}</Text>
350                ))}
351              </Text>
352            </Box>
353            <Text bold color={view.overall.isFinished ? 'success' : WORKING_COLOR}>
354              {view.overall.percent}
355            </Text>
356          </Box>
357          {/* 標籤跟 Now/Wait 一樣縮排;完成幾步看上面的 Step x of y,這裡不重複 */}
358          <Text dimColor>{'  Plan:'}</Text>
359          {view.rows.map(row => (
360            <Box flexDirection="row">
361              <Box width={view.titleColumns}>
362                <Text wrap="truncate-end">
363                  <Text color={row.style.markColor} dimColor={row.style.isMarkDim}>{`  ${row.style.mark} `}</Text>
364                  <Text bold={row.style.isTextBold} dimColor={row.style.isTextDim}>
365                    {row.text}
366                  </Text>
367                </Text>
368              </Box>
369              <Box width={BAR_COLUMNS}>
370                <Text>
371                  {colorRuns(row.cells).map(run => (
372                    <Text color={run.color}>{BAR_CELL.repeat(run.count)}</Text>
373                  ))}
374                </Text>
375              </Box>
376              {view.isStatusShown && (
377                <Text bold={row.style.isLabelBold} color={row.style.labelColor} dimColor={row.style.labelColor === undefined}>
378                  {row.style.label}
379                </Text>
380              )}
381            </Box>
382          ))}
383          {summary?.waiting && <Text color="yellow" wrap="truncate-end">{`  Wait: ${summary.waiting}`}</Text>}
384        </Box>
385        {rest}
386      </Box>
387    )
388  })
389}
390
391// 已經附過(例如上層子代理把整段任務轉交下去)就不再重複
392function withSubagentLanguageNote(prompt: string) {
393  if (prompt.includes(SUBAGENT_LANGUAGE_NOTE)) return prompt
394  return `${prompt.trimEnd()}\n\n${SUBAGENT_LANGUAGE_NOTE}`
395}
396
397export function combinedBoxMode(enabled: boolean, steps: readonly PlanStep[]): CombinedBoxMode {
398  if (!enabled || steps.length === 0) return 'hidden'
399  return isPlanFinished(steps) ? 'whileWorking' : 'always'
400}
401
402// where-am-i/hooks/register.tsx 有同一行判斷(兩個 mod 不能共用程式碼),改規則要兩邊一起改
403export function isCombinedBoxShown(mode: CombinedBoxMode, isWorking: boolean) {
404  return mode === 'always' || (mode === 'whileWorking' && isWorking)
405}
406
407// 計畫區塊上方、框線以外的列數:標題列、Now、Wait;跟 JSX 裡畫哪些列要一致
408function rowsAbovePlan(title: TitleRow | null, summary: RecapSummary | null) {
409  return (title ? 1 : 0) + (summary?.now ? 1 : 0) + (summary?.waiting ? 1 : 0)
410}
411
412// 回合進行中算到現在(只有這時才需要讀時鐘),結束了就停在結束那一刻;還沒有回合就不顯示
413async function elapsedLabel($: EngineInterface, clock: TurnClock | null) {
414  if (!clock) return null
415  const end = clock.endedAt ?? (await $.clock.now())
416  return formatElapsed(end - clock.startedAt)
417}
418
419type TitleRow = { text: string; elapsed: string | null }
420
421// 標題列:左邊「✳ 目標」,where-am-i 還沒有摘要時改用目前步驟的標題;右邊是經過時間。兩邊都沒有就不畫
422export function titleRow(goal: string, steps: readonly PlanStep[], elapsed: string | null, innerColumns: number): TitleRow | null {
423  const subject = goal || (steps[currentStepIndex(steps)]?.title ?? '')
424  const fitted = fitToWidth(subject, innerColumns - displayWidth(TITLE_MARK) - ELAPSED_COLUMNS)
425  const text = fitted ? TITLE_MARK + fitted : ''
426  if (!text && elapsed === null) return null
427  return { text, elapsed }
428}
429
430export function formatElapsed(ms: number): string {
431  const totalSeconds = Math.max(0, Math.floor(ms / 1000))
432  const hours = Math.floor(totalSeconds / 3600)
433  const minutes = Math.floor((totalSeconds % 3600) / 60)
434  const seconds = totalSeconds % 60
435  if (hours > 0) return `${hours}h ${String(minutes).padStart(2, '0')}m`
436  if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`
437  return `${seconds}s`
438}
439
440// 超過寬度時依顯示寬度截斷,補「…」;中文一個字算兩格
441export function fitToWidth(text: string, maxColumns: number): string {
442  if (maxColumns <= 0) return ''
443  if (displayWidth(text) <= maxColumns) return text
444  let kept = ''
445  let width = 0
446  for (const char of text) {
447    if (width + charWidth(char) > maxColumns - 1) break
448    kept += char
449    width += charWidth(char)
450  }
451  return `${kept}…`
452}
453
454type PlanViewOptions = { frame: number; isWorking: boolean; columns: number; rows: number }
455
456// 合併框裡計畫區塊要畫的一切:進度字樣、欄寬、要不要顯示狀態字,以及每一列的樣式與方格顏色
457export function planView(steps: readonly PlanStep[], options: PlanViewOptions) {
458  const stepRows = Math.min(MAX_STEP_ROWS, Math.max(MIN_STEP_ROWS, options.rows - PLAN_HEADER_ROWS))
459  const rows = planRows(steps, stepRows, options.isWorking)
460  const layout = planLayout(rows, options.columns)
461  // 標籤欄也要放得下整體進度列的「  Step x of y」,不然標籤會換行、多出一列
462  const titleColumns = Math.max(layout.titleColumns, displayWidth(`  ${overallLabel(steps)}`) + 1)
463  return {
464    ...layout,
465    titleColumns,
466    overall: overallProgress(steps, {
467      barWidth: Math.max(MIN_OVERALL_BAR_WIDTH, options.columns - titleColumns - PERCENT_COLUMNS),
468      frame: options.frame,
469      isAnimated: options.isWorking, // 閒置時計時器已停,亮光停在半路很怪,乾脆不畫
470    }),
471    rows: rows.map(row => ({ ...row, style: ROW_STYLE[row.display], cells: stepBar(row.display, options.frame) })),
472  }
473}
474
475// 影片的整體進度列:Step x of y、一條長進度條、百分比。x 是進行中的那一步,沒有進行中時是第一個還沒完成的步驟。
476// 百分比只算已完成的步驟(步驟內部沒有進度可量);全部完成時整條變綠
477type OverallBarOptions = { barWidth: number; frame: number; isAnimated: boolean }
478
479export function overallProgress(steps: readonly PlanStep[], options: OverallBarOptions) {
480  const { barWidth } = options
481  const completedCount = countCompleted(steps)
482  const ratio = steps.length === 0 ? 0 : completedCount / steps.length
483  const filledCells = Math.round(ratio * barWidth)
484  const isFinished = isPlanFinished(steps)
485  const positions = Array.from({ length: barWidth }, (_, index) => index)
486  if (isFinished) {
487    const cells = positions.map(index => gradientAt(DONE_GRADIENT, index, barWidth))
488    return { label: overallLabel(steps), percent: '100%', cells, isFinished }
489  }
490  // 漸層只鋪在已填滿的那段,左邊橘、填滿處的尾端粉紅,跟影片一樣;再疊上掃過的亮光
491  const isShimmer = options.isAnimated ? shimmerBand(options.frame, filledCells) : () => false
492  const cells = positions.map(index => {
493    if (index >= filledCells) return TRACK_COLOR
494    const color = gradientAt(OVERALL_FILL_GRADIENT, index, filledCells)
495    return isShimmer(index) ? lighten(color, OVERALL_SHIMMER_LIGHTEN) : color
496  })
497  return { label: overallLabel(steps), percent: `${Math.round(ratio * 100)}%`, cells, isFinished }
498}
499
500export function overallLabel(steps: readonly PlanStep[]) {
501  const current = currentStepIndex(steps)
502  return `Step ${(current >= 0 ? current : steps.length - 1) + 1} of ${steps.length}`
503}
504
505// 目前的步驟:進行中的那一步,沒有的話是第一個還沒完成的步驟;全部完成時是 -1
506function currentStepIndex(steps: readonly PlanStep[]) {
507  const runningIndex = steps.findIndex(step => step.status === 'in_progress')
508  return runningIndex >= 0 ? runningIndex : steps.findIndex(step => step.status !== 'completed')
509}
510
511// 連續同色的格子合併成一段,少畫很多元素(整體長條在寬螢幕上可能有一百多格)
512export function colorRuns(cells: readonly string[]): { color: string; count: number }[] {
513  const runs: { color: string; count: number }[] = []
514  for (const color of cells) {
515    const last = runs[runs.length - 1]
516    if (last?.color === color) last.count += 1
517    else runs.push({ color, count: 1 })
518  }
519  return runs
520}
521
522function shimmerBand(frame: number, filledCells: number) {
523  const head = frame % (filledCells + OVERALL_SHIMMER_PAUSE_CELLS)
524  return (index: number) => index <= head && index > head - OVERALL_SHIMMER_WIDTH
525}
526
527// band 的高度有限,還要跟其他 mod 共用:超過列數時先把已完成的收成一列,還放不下就把後段收成「還有 N 項」
528export function planRows(steps: readonly PlanStep[], maxRows: number, isWorking: boolean): PlanRow[] {
529  const rows = steps.map(step => ({ text: step.title, display: displayOf(step.status, isWorking) }))
530  if (rows.length <= maxRows) return rows
531
532  const completedCount = countCompleted(steps)
533  const folded: PlanRow[] = completedCount > 0 ? [{ text: `已完成 ${completedCount} 項`, display: 'completed' }] : []
534  const compact = [...folded, ...rows.filter(row => row.display !== 'completed')]
535  if (compact.length <= maxRows) return compact
536
537  const kept = compact.slice(0, maxRows - 1)
538  return [...kept, { text: `還有 ${compact.length - kept.length} 項`, display: 'pending' }]
539}
540
541function displayOf(status: PlanStatus, isWorking: boolean): StepDisplay {
542  if (status === 'in_progress') return isWorking ? 'running' : 'paused'
543  return status
544}
545
546// 標題欄寬取最長的標題,讓進度條對齊成一欄;太窄時先拿掉狀態字,再縮標題,進度條維持原寬
547export function planLayout(rows: readonly PlanRow[], columns: number) {
548  const longest = Math.max(0, ...rows.map(row => displayWidth(row.text)))
549  const wantedTitleColumns = MARK_COLUMNS + longest + 2
550  const roomWithStatus = columns - BAR_COLUMNS - STATUS_COLUMNS
551  if (roomWithStatus >= MIN_TITLE_COLUMNS) {
552    return { titleColumns: Math.min(wantedTitleColumns, roomWithStatus), isStatusShown: true }
553  }
554  const roomWithoutStatus = columns - BAR_COLUMNS
555  return { titleColumns: Math.max(MIN_TITLE_COLUMNS, Math.min(wantedTitleColumns, roomWithoutStatus)), isStatusShown: false }
556}
557
558// 每一格的顏色,由左到右:完成是綠漸層,未開始只有灰色底;進行中是一顆彗星從左往右走,
559// 尾巴也離開右邊後再從左邊進來,frame 每拍加一。暫停時停在左邊、整顆看得見的位置並改成淺灰,
560// 不沿用最後一拍的位置:那一拍可能剛好出界,看起來就跟未開始一樣
561const PAUSED_HEAD = COMET_FADE.length - 1
562
563export function stepBar(display: StepDisplay, frame: number): string[] {
564  const positions = Array.from({ length: BAR_WIDTH }, (_, index) => index)
565  if (display === 'completed') return positions.map(index => gradientAt(DONE_GRADIENT, index))
566  if (display === 'pending') return positions.map(() => TRACK_COLOR)
567  const head = display === 'paused' ? PAUSED_HEAD : frame % (BAR_WIDTH + COMET_FADE.length)
568  const headColor = display === 'running' ? cometColor(frame) : PAUSED_COLOR
569  return positions.map(index => {
570    const intensity = COMET_FADE[head - index]
571    return intensity === undefined ? TRACK_COLOR : mixColor(TRACK_COLOR, headColor, intensity)
572  })
573}
574
575// 彗星此刻的顏色:沿著 ACCENT_COLORS 來回走,一個來回是兩個半週期
576export function cometColor(frame: number): string {
577  const phase = frame % (COMET_COLOR_HALF_PERIOD_FRAMES * 2)
578  const progress = (phase <= COMET_COLOR_HALF_PERIOD_FRAMES ? phase : COMET_COLOR_HALF_PERIOD_FRAMES * 2 - phase) / COMET_COLOR_HALF_PERIOD_FRAMES
579  return paletteAt(ACCENT_COLORS, progress)
580}
581
582// 沿著一串顏色取色:progress 0 是第一個、1 是最後一個,中間在相鄰兩色之間內插
583function paletteAt(colors: readonly string[], progress: number) {
584  const position = progress * (colors.length - 1)
585  const lower = Math.min(Math.floor(position), colors.length - 2)
586  return mixColor(colors[lower]!, colors[lower + 1]!, position - lower)
587}
588
589function gradientAt(gradient: { from: string; to: string }, index: number, length = BAR_WIDTH) {
590  return mixColor(gradient.from, gradient.to, length > 1 ? index / (length - 1) : 0)
591}
592
593// 兩個顏色之間取比例:0 是 from、1 是 to
594function mixColor(from: string, to: string, ratio: number) {
595  const target = hexChannels(to)
596  const mixed = hexChannels(from).map((channel, i) => Math.round(channel + (target[i]! - channel) * ratio))
597  return `#${mixed.map(channel => channel.toString(16).padStart(2, '0')).join('')}`
598}
599
600function hexChannels(hex: string) {
601  return [1, 3, 5].map(start => parseInt(hex.slice(start, start + 2), 16))
602}
603
604function lighten(hex: string, ratio: number) {
605  return mixColor(hex, '#ffffff', ratio)
606}
607
608// 東亞寬字元、全形符號與 emoji 在終端機佔兩格;範圍用 \u 跳脫,避免存檔時字元被正規化成別的碼位
609const WIDE_CHAR = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
610
611function charWidth(char: string) {
612  return WIDE_CHAR.test(char) ? 2 : 1
613}
614
615function displayWidth(text: string) {
616  let width = 0
617  for (const char of text) width += charWidth(char)
618  return width
619}
620
621type ToolCallView = { tool: string; input: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean; output?: unknown }
622type ToolLine = { text: string; color?: ThemeKey }
623
624// 一個工具呼叫在畫面上的所有列:一行摘要,出錯時下面接錯誤內容
625export function toolLines(call: ToolCallView): ToolLine[] {
626  const errors = errorLines(call)
627  const indented = errors.map((line, index) => ({ text: `${index === 0 ? '    ⎿ ' : '      '}${line}`, color: 'error' as const }))
628  return [toolLine(call), ...indented]
629}
630
631export function toolLine(call: ToolCallView): ToolLine {
632  const label = `  · ${describe(call.tool, call.input)}`
633  if (call.isInterrupted) return { text: `${label}(已中斷)`, color: 'warning' }
634  if (call.isErrored) return { text: `${label}(失敗)`, color: 'error' }
635  if (call.isRunning) return { text: `${label} …` }
636  return { text: label }
637}
638
639// 出錯時 output 是模型讀到的文字。中斷不算錯誤內容,摘要已標「(已中斷)」。
640// Bash 失敗是「Exit code N」後面接整段輸出,錯誤多半在最後,所以太長時留第一行與最後幾行
641export function errorLines(call: ToolCallView): string[] {
642  if (!call.isErrored || call.isInterrupted || typeof call.output !== 'string') return []
643  const text = call.output
644    .replace(/<\/?tool_use_error>/g, '')
645    .replace(/\x1b\[[0-9;]*m/g, '') // Python traceback 之類的輸出夾帶的 ANSI 色碼
646  const lines = text.split('\n').filter(line => line.trim() !== '')
647  if (lines.length <= MAX_ERROR_LINES) return lines
648  const tail = lines.slice(-(MAX_ERROR_LINES - 2))
649  return [lines[0]!, `…(省略 ${lines.length - 1 - tail.length} 行)`, ...tail]
650}
651
652// 一句話說出 Claude 在做什麼,取代原本的工具名稱與參數;where-am-i 的 describe 用同一套用字
653export function describe(tool: string, input: unknown): string {
654  const args = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
655  const text = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : '')
656  const file = (path: string) => path.split('/').slice(-2).join('/')
657  if (tool === 'Bash') return `執行:${text('description') || text('command').slice(0, 80)}`
658  if (tool === 'Read') return `讀取 ${file(text('file_path'))}`
659  if (tool === 'Write') return `寫入 ${file(text('file_path'))}`
660  if (tool === 'Edit') return `編輯 ${file(text('file_path'))}`
661  if (tool === 'NotebookEdit') return `編輯 ${file(text('notebook_path'))}`
662  if (tool === 'Grep' || tool === 'Glob') return `搜尋 "${text('pattern').slice(0, 40)}"`
663  if (tool === 'WebSearch') return `搜尋網路「${text('query').slice(0, 40)}」`
664  if (tool === 'WebFetch') return `讀取網頁 ${hostOf(text('url'))}`
665  if (tool === 'Agent') return `委派 agent:${text('description')}`
666  if (tool === 'Skill') return `使用 skill:${text('skill')}`
667  if (tool === 'AskUserQuestion') return '詢問你問題'
668  if (tool === 'ToolSearch') return '載入工具'
669  if (tool.startsWith('mcp__')) return `使用 ${tool.split('__').slice(1).join(' ')}`
670  return `使用 ${tool}`
671}
672
673function hostOf(url: string) {
674  const match = /^[a-z]+:\/\/([^/?#]+)/i.exec(url)
675  return match?.[1] ?? url.slice(0, 40)
676}
677
678const PLAN_STATUSES: readonly string[] = ['pending', 'in_progress', 'completed'] satisfies PlanStatus[]
679
680export function parseSteps(value: unknown): PlanStep[] | null {
681  if (!Array.isArray(value)) return null
682  const steps = value.flatMap(item => (isPlanStep(item) ? [{ title: item.title.trim(), status: item.status }] : []))
683  return steps.length === value.length ? steps : null
684}
685
686function isPlanStep(item: unknown): item is PlanStep {
687  if (typeof item !== 'object' || item === null) return false
688  const { title, status } = item as Record<string, unknown>
689  return typeof title === 'string' && typeof status === 'string' && PLAN_STATUSES.includes(status)
690}
691
692function countCompleted(steps: readonly PlanStep[]) {
693  return steps.filter(step => step.status === 'completed').length
694}
695
696function isPlanFinished(steps: readonly PlanStep[]) {
697  return steps.length > 0 && countCompleted(steps) === steps.length
698}
699
types/index.d.ts 25 lines
1export type PlanStatus = 'pending' | 'in_progress' | 'completed'
2
3export type PlanStep = { title: string; status: PlanStatus }
4
5// 合併框何時顯示:hidden 不顯示、whileWorking 只在回合進行中(計畫已做完)、always 一直顯示(計畫還沒做完)
6export type CombinedBoxMode = 'hidden' | 'whileWorking' | 'always'
7
8// 主迴圈目前(或最近一次)回合的起訖時間;endedAt 是 null 表示回合還在跑
9export type TurnClock = { startedAt: number; endedAt: number | null }
10
11declare module 'claude-code' {
12  interface PluginState {
13    // where-am-i/types/index.d.ts 也宣告了同樣的形狀,改這裡要一起改;../scripts/check-contracts.sh 會抓出不一致
14    'clean-view': {
15      isEnabled: boolean
16      plan: PlanStep[]
17      tick: number
18      turnClock: TurnClock | null
19      combinedBox: CombinedBoxMode
20    }
21    // where-am-i 擁有,這裡只讀來放進合併框;形狀要跟它自己的合約一致
22    'where-am-i': { recap: { goal: string; now: string; waiting: string; next: string } | null; live: string }
23  }
24}
25