SLOPSHOPPER

ship-it

用 /ship 依序跑專案的階段(檢查、測試、打包、部署),並在 prompt 上方的深色面板用橘色吉祥物顯示跑到哪一步

newbandcommandtoastprocesstimer
v0.1.0MITupdated 2026-10-06builtbyjia/claude-code-mods/ship-it
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ship-it
› fix the failing auth test and add an audit log call ╭───────────╮ │ ship-it │ ⏺ Read(src/auth.ts) │ 3 個階段全部完成 │ ⎿ 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 › /ship ⎿ ship-it: 開始跑 3 個階段(來源:package.json):檢查程式碼 → 跑測試 → 打包 ▶ 進行中:打包 2/3 0:00 [ 關 ] ▄█▀████▀█▄ ▀████████▀ █ █ █ █ ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━●───────────────────────────────────────● 檢查程式碼 跑測試 打包 ✓ 0s ✓ 0s ⠋ 0s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▶ 進行中:打包 2/3 0:00 [ 關 ] ▄█▀████▀█▄ ▀████████▀ █ █ █ █ ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━●───────────────────────────────────────● 檢查程式碼 跑測試 打包 ✓ 0s ✓ 0s ⠋ 0s
README

Claude Code Mods

我為自己的開發流程寫的四個 Claude Code mod。Mod 是跑在 Claude Code 裡面的 TypeScript 事件處理函式,可以攔截工具呼叫、改寫 prompt,或在介面上畫出自己的區塊。

Mod做什麼指令
ding-dong回合跑超過一分鐘才結束時,發一則系統通知叫你回來/ding 預覽通知
ship-it依序執行專案的階段(檢查、測試、打包、部署),在 prompt 上方的深色面板用像素吉祥物顯示進度/ship、/ship demo、/ship init
secret-guard遮蔽 prompt 與工具輸出裡的金鑰,擋下讀寫 .env 與私鑰/guard、/guard on、/guard off
pii-mask把個資換成代號再送給模型,對照表只留在本機,之後可以還原/pii、/pii on、/pii add、/pii restore

需求

  • Claude Code 2.1.287 以上
  • ding-dong 的系統通知只支援 macOS,其他平台會改用 Claude Code 內的 toast

安裝

把這個 repo 加成 marketplace,再安裝想要的 mod:

claude plugin marketplace add builtbyjia/claude-code-mods
claude plugin install ship-it@builtbyjia-mods

只想試用一次的話,clone 下來後用 --plugin-dir 載入,只在那個 session 生效:

claude --plugin-dir ./ship-it

Mod 會用你的權限執行,可以讀寫檔案、執行指令。安裝任何人的 mod 之前(包括這裡的),建議先用 claude plugin validate <資料夾> 看它掛了哪些事件、呼叫了哪些 API,並讀過原始碼。

ding-dong

回合依時長分成四種語氣(一到三分鐘、三到十分鐘、十分鐘以上、出錯),每種隨機抽一句文案,副標題顯示專案資料夾、耗時與工具次數。你自己中斷的回合不會通知。

通知預設由系統的 osascript 發出。想讓通知圖示變成橘色像素吉祥物,執行一次:

./ding-dong/notifier/build.sh

它會在本機編譯出 DingDong.app,之後通知就由這個小程式發出。文案與門檻秒數都在 ding-dong/hooks/register.ts 最上方。

ship-it

/ship 會在 prompt 正上方畫出一塊深色的橫式面板,吉祥物沿著軌道走向正在執行的階段,每個階段顯示耗時;失敗時流程停下,並列出最後幾行錯誤輸出。面板右上角的「關」可以收起來,再輸入一次 /ship 就會重新出現。

「現在是哪個階段」不是偵測出來的:mod 自己依序執行每個階段的指令,正在跑的那個指令就是目前階段,指令結束時的結束碼是 0 才會往下一個走。吉祥物在兩個節點之間的位置只是依時間估算的動畫,不代表真實進度。

階段的來源依序是:

  1. 專案裡的 .claude/ship.json
  2. package.json 裡的 lint、test、build、deploy 這四個 script

用 /ship init 可以產生設定檔,格式如下:

{
  "stages": [
    { "name": "跑測試", "run": "npm test" },
    { "name": "打包", "run": "npm run build" },
    { "name": "部署", "run": "npm run deploy" }
  ]
}

run 會交給 bash -c 執行,所以只在你信任的專案裡使用 /ship。/ship demo 用 sleep 模擬五個階段,不會動到專案。

secret-guard

三層防護:

  1. Prompt:送出前把 API key、token、私鑰換成 [已遮蔽:GitHub token] 這類標記。
  2. 工具輸出:檔案內容或指令輸出裡的金鑰,在送給模型之前遮蔽。
  3. 敏感檔案:擋下讀寫 .env、私鑰、.npmrc 等檔案,以及 cat .env、printenv 這類指令;.env.example 這類範本檔放行。

已知限制:

  • 靠格式比對,認不出格式的密碼不會被遮蔽。
  • Shell 指令的檢查是字面比對,可以被繞過,不能當作唯一的防線。
  • 模型看到的是標記,所以它無法修改被遮蔽的那一行。
  • 圖片與 PDF 的內容不在遮蔽範圍內。

pii-mask

預設關閉。用 /pii on 開啟後,prompt 和工具輸出裡的個資會先換成 <PERSON_1>、<TW_MOBILE_1> 這類代號才送給模型;同一個原文永遠對應同一個代號。對照表存在本機,只會畫在 /pii 打開的面板上,不會進入對話。

模型產出的檔案裡如果有代號,用 /pii restore 檔案路徑 換回原文,結果另存成 .restored 檔,原檔不動。

能辨識的類型:

類型辨識方式
手機、市話、Email格式比對
身分證、居留證、信用卡格式比對加檢查碼
統一編號前面寫明「統編」或「統一編號」的八位數字
地址正式寫法(縣市、鄉鎮市區、路街)
公司以「有限公司」「企業社」「商行」「事務所」結尾
人名「姓名:」「客戶:」「聯絡人:」這類欄位後面的名字,或用 /pii add 加進名單的詞

已知限制:

  • 沒有語言模型,自由書寫的文字裡不在名單上的人名、公司、口語地址不會被辨識。
  • 圖片、PDF 的內容不在處理範圍內。
  • 模型看到的是代號,所以它無法修改含有個資的那一行原始檔案。
  • 這是降低意外外洩的工具,不是保證。處理真正敏感的資料前,先用 /pii 檢查對照表是否抓齊。

開發

每個 mod 是一個獨立的 plugin 資料夾:

<mod>/
├── .claude-plugin/plugin.json
├── hooks/
│   ├── hooks.json
│   └── register.ts
└── tests/

檢查 Claude Code 會從 mod 讀到什麼:

claude plugin validate ./secret-guard

在 mod 的資料夾裡執行測試,不需要開 session:

claude plugin test

用 --plugin-dir 載入時,存檔就會自動重新載入。

關於吉祥物

通知圖示與 ship-it 裡的像素小生物是我自己畫的,靈感來自 Claude 的橘色吉祥物,不是 Anthropic 的官方素材。這個專案與 Anthropic 沒有從屬關係。

授權

以 MIT 授權釋出,詳見 LICENSE。

Source 1 files
hooks/register.ts 514 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3// 每個專案自己的階段設定檔,放在專案根目錄下
4const CONFIG_PATH = '.claude/ship.json'
5const TICK_MS = 250
6const STAGE_TIMEOUT_MS = 10 * 60 * 1000
7
8// 深色面板的配色。固定用色碼,不管終端機是淺色還是深色主題,看起來都一樣
9const PANEL = '#2B2F3B'
10const INK = '#E6E8EC'
11const MUTED = '#7D8494'
12const GREEN = '#4CC38A'
13const YELLOW = '#E5C07B'
14const RED = '#F47067'
15const CORAL = '#E8806A'
16
17type StageDef = { name: string; run: string }
18type Stage = StageDef & { status: 'waiting' | 'running' | 'done' | 'failed'; ms: number }
19type Run = {
20  stages: Stage[]
21  source: string
22  outcome: 'running' | 'done' | 'failed'
23  elapsedMs: number
24  stageMs: number
25  frame: number
26  tail: string[]
27}
28type Found = { stages: StageDef[]; source: string; problem?: string }
29type Span = { text: string; color?: string; bold?: boolean }
30
31// 吉祥物:上面兩列是頭和身體,腳有兩格動畫輪流換
32const SPRITE_BODY = ['▄█▀████▀█▄', '▀████████▀']
33const SPRITE_LEGS = [' █ █  █ █ ', '  ██  ██  ']
34const SPRITE_WIDTH = 10
35const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
36// 軌道左邊留白讓吉祥物能站在第一個節點正上方,右邊留給最後一個標籤
37const MARGIN = 4
38const LAST_LABEL = 12
39// 面板左右的內距,以及面板寬度的上下限
40const PAD = 2
41const MIN_WIDTH = 30
42const MAX_WIDTH = 110
43
44const DEMO: readonly StageDef[] = [
45  { name: '檢查程式碼', run: 'sleep 2' },
46  { name: '跑測試', run: 'sleep 4' },
47  { name: '打包', run: 'sleep 3' },
48  { name: '部署', run: 'sleep 5' },
49  { name: '健康檢查', run: 'sleep 1' },
50]
51
52// 沒有設定檔時,從 package.json 的 scripts 依這個順序找階段
53const NPM_SCRIPTS: readonly (readonly [script: string, name: string])[] = [
54  ['lint', '檢查程式碼'],
55  ['test', '跑測試'],
56  ['build', '打包'],
57  ['deploy', '部署'],
58]
59
60const HELP = [
61  '找不到可以跑的階段。三種方式擇一:',
62  '・/ship demo:先看示範動畫',
63  '・/ship init:在這個專案建立 .claude/ship.json,再編輯成你的階段',
64  '・在 package.json 的 scripts 加上 lint、test、build 或 deploy',
65].join('\n')
66
67let run: Run | null = null
68let ticker: Timer | null = null
69let lastArgs = ''
70// 按了「關」之後先收起來,下次 /ship 再出現
71let isHidden = false
72
73function isStageDef(value: unknown): value is StageDef {
74  const stage = value as StageDef | null
75
76  return typeof stage?.name === 'string' && typeof stage.run === 'string' && stage.run.trim() !== ''
77}
78
79function stagesFromConfig(text: string): StageDef[] {
80  const list = (JSON.parse(text) as { stages?: unknown }).stages
81
82  return Array.isArray(list) ? list.filter(isStageDef).map(({ name, run }) => ({ name, run })) : []
83}
84
85function stagesFromPackage(text: string): StageDef[] {
86  const scripts = (JSON.parse(text) as { scripts?: Record<string, unknown> }).scripts ?? {}
87
88  return NPM_SCRIPTS.filter(([script]) => {
89    const body = scripts[script]
90
91    // npm init 預設的 test 只會印錯誤然後失敗,不算真的階段
92    return typeof body === 'string' && !body.includes('no test specified')
93  }).map(([script, name]) => ({ name, run: `npm run ${script}` }))
94}
95
96async function findStages($: EngineInterface, cwd: string): Promise<Found> {
97  if (await $.fs.exists(`${cwd}/${CONFIG_PATH}`)) {
98    try {
99      const stages = stagesFromConfig(await $.fs.read(`${cwd}/${CONFIG_PATH}`))
100
101      return stages.length > 0
102        ? { stages, source: CONFIG_PATH }
103        : { stages, source: CONFIG_PATH, problem: `${CONFIG_PATH} 裡沒有可用的階段,每一項都要有 name 和 run。` }
104    } catch {
105      return { stages: [], source: CONFIG_PATH, problem: `${CONFIG_PATH} 不是合法的 JSON,請檢查格式。` }
106    }
107  }
108
109  if (await $.fs.exists(`${cwd}/package.json`)) {
110    try {
111      return { stages: stagesFromPackage(await $.fs.read(`${cwd}/package.json`)), source: 'package.json' }
112    } catch {
113      // package.json 讀不懂就當作沒有
114    }
115  }
116
117  return { stages: [], source: '' }
118}
119
120async function init($: EngineInterface): Promise<string> {
121  const cwd = await $.session.cwd()
122
123  if (await $.fs.exists(`${cwd}/${CONFIG_PATH}`)) {
124    return `${CONFIG_PATH} 已經存在,直接編輯它就好。`
125  }
126
127  const found = await findStages($, cwd)
128  const stages =
129    found.stages.length > 0
130      ? found.stages
131      : [
132          { name: '打包', run: 'npm run build' },
133          { name: '部署', run: 'npm run deploy' },
134        ]
135  await $.fs.write(`${cwd}/${CONFIG_PATH}`, `${JSON.stringify({ stages }, null, 2)}\n`)
136
137  return `已建立 ${CONFIG_PATH}(${stages.map(stage => stage.name).join(' → ')})。name 是顯示的名稱,run 是要執行的指令,改完再輸入 /ship。`
138}
139
140function tailOf(output: string): string[] {
141  return output
142    .split('\n')
143    .map(line => line.trimEnd())
144    .filter(line => line !== '')
145    .slice(-4)
146}
147
148async function work($: EngineInterface, mine: Run, cwd: string): Promise<void> {
149  try {
150    for (const stage of mine.stages) {
151      stage.status = 'running'
152      mine.stageMs = 0
153      $.ui.invalidate('ui.render')
154      const startedAt = await $.clock.now()
155      let exitCode = 1
156      let output = ''
157
158      try {
159        const ran = await $.process.run(['bash', '-c', stage.run], { cwd, timeoutMs: STAGE_TIMEOUT_MS })
160        exitCode = ran.exitCode
161        output = `${ran.stdout}\n${ran.stderr}`
162      } catch (error) {
163        output = error instanceof Error ? error.message : String(error)
164      }
165
166      stage.ms = (await $.clock.now()) - startedAt
167
168      if (exitCode !== 0) {
169        stage.status = 'failed'
170        mine.outcome = 'failed'
171        mine.tail = tailOf(output)
172        $.ui.toast(`${stage.name} 失敗了,流程停在這裡`)
173
174        return
175      }
176
177      stage.status = 'done'
178    }
179
180    mine.outcome = 'done'
181    $.ui.toast(`${mine.stages.length} 個階段全部完成`)
182  } finally {
183    if (run === mine) {
184      ticker?.cancel()
185      ticker = null
186    }
187
188    $.ui.invalidate('ui.render')
189  }
190}
191
192async function start($: EngineInterface, args: string): Promise<string> {
193  if (run?.outcome === 'running') {
194    return '已經有一輪在跑了,等它結束再重跑。'
195  }
196
197  const cwd = await $.session.cwd()
198  const found: Found = args === 'demo' ? { stages: [...DEMO], source: '示範' } : await findStages($, cwd)
199
200  if (found.problem !== undefined) {
201    return found.problem
202  }
203
204  if (found.stages.length === 0) {
205    return HELP
206  }
207
208  const mine: Run = {
209    stages: found.stages.map(stage => ({ ...stage, status: 'waiting', ms: 0 })),
210    source: found.source,
211    outcome: 'running',
212    elapsedMs: 0,
213    stageMs: 0,
214    frame: 0,
215    tail: [],
216  }
217  run = mine
218  lastArgs = args
219  ticker?.cancel()
220  ticker = $.clock.every(TICK_MS, () => {
221    mine.elapsedMs += TICK_MS
222    mine.stageMs += TICK_MS
223    mine.frame += 1
224    $.ui.invalidate('ui.render')
225  })
226  isHidden = false
227  $.ui.invalidate('ui.render')
228  // 不等它跑完:指令先回覆,階段在背景繼續,面板會跟著更新
229  void work($, mine, cwd)
230
231  return `開始跑 ${mine.stages.length} 個階段(來源:${mine.source}):${mine.stages.map(stage => stage.name).join(' → ')}`
232}
233
234function isWideChar(code: number): boolean {
235  return (
236    (code >= 0x1100 && code <= 0x115f) ||
237    (code >= 0x2e80 && code <= 0xa4cf) ||
238    (code >= 0xac00 && code <= 0xd7a3) ||
239    (code >= 0xf900 && code <= 0xfaff) ||
240    (code >= 0xfe30 && code <= 0xfe4f) ||
241    (code >= 0xff00 && code <= 0xff60) ||
242    (code >= 0xffe0 && code <= 0xffe6)
243  )
244}
245
246// 中文字在終端機佔兩格,排版要用格數而不是字數
247function cellsOf(text: string): number {
248  let cells = 0
249
250  for (const char of text) {
251    cells += isWideChar(char.codePointAt(0) ?? 0) ? 2 : 1
252  }
253
254  return cells
255}
256
257// 截斷或補空白到剛好 width 格
258function fit(text: string, width: number): string {
259  let out = ''
260  let cells = 0
261
262  for (const char of text) {
263    const next = isWideChar(char.codePointAt(0) ?? 0) ? 2 : 1
264
265    if (cells + next > width) {
266      break
267    }
268
269    out += char
270    cells += next
271  }
272
273  return out + ' '.repeat(width - cells)
274}
275
276function seconds(ms: number): string {
277  const total = Math.round(ms / 1000)
278
279  return total < 60 ? `${total}s` : `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
280}
281
282function timer(ms: number): string {
283  const total = Math.round(ms / 1000)
284
285  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
286}
287
288function toneOf(stage: Stage): Span {
289  if (stage.status === 'done') {
290    return { text: '', color: GREEN }
291  }
292
293  if (stage.status === 'running') {
294    return { text: '', color: YELLOW, bold: true }
295  }
296
297  return stage.status === 'failed' ? { text: '', color: RED, bold: true } : { text: '', color: MUTED }
298}
299
300function markOf(now: Run, stage: Stage): string {
301  if (stage.status === 'done') {
302    return `✓ ${seconds(stage.ms)}`
303  }
304
305  if (stage.status === 'running') {
306    return `${SPINNER[now.frame % SPINNER.length]} ${seconds(now.stageMs)}`
307  }
308
309  return stage.status === 'failed' ? `✗ ${seconds(stage.ms)}` : ''
310}
311
312function headlineOf(now: Run): Span {
313  if (now.outcome === 'done') {
314    return { text: '✓ 全部完成', color: INK, bold: true }
315  }
316
317  const stage = now.stages.find(one => one.status === 'running' || one.status === 'failed')
318
319  return now.outcome === 'failed'
320    ? { text: `✗ ${stage?.name ?? ''} 失敗`, color: RED, bold: true }
321    : { text: `▶ 進行中:${stage?.name ?? ''}`, color: INK, bold: true }
322}
323
324function spriteRows(left: number, frame: number, isSad: boolean): Span[][] {
325  const rows = [...SPRITE_BODY, SPRITE_LEGS[frame % SPRITE_LEGS.length]]
326
327  return rows.map(row => [{ text: ' '.repeat(left) + row, color: isSad ? MUTED : CORAL }])
328}
329
330// 寬的版面:一條橫向軌道,每個階段一個節點,吉祥物走向正在跑的那個節點
331function trackRows(now: Run, width: number, gap: number): Span[][] {
332  const count = now.stages.length
333  const at = now.stages.findIndex(stage => stage.status === 'running' || stage.status === 'failed')
334  // 走得越久越慢,快到節點前停住,等階段真的完成才踩上去
335  const progress = 0.95 * (1 - 1 / (1 + now.stageMs / 4000))
336  const filled = Math.round(progress * (gap - 1))
337  const position = at === -1 ? MARGIN + (count - 1) * gap : at === 0 ? MARGIN : MARGIN + (at - 1) * gap + 1 + filled
338  const left = Math.max(0, Math.min(position - SPRITE_WIDTH / 2, width - SPRITE_WIDTH))
339  const isWalking = now.outcome === 'running'
340
341  const track: Span[] = [{ text: ' '.repeat(MARGIN) }]
342  const labels: Span[] = [{ text: ' '.repeat(MARGIN) }]
343  const marks: Span[] = [{ text: ' '.repeat(MARGIN) }]
344
345  now.stages.forEach((stage, index) => {
346    const isLast = index === count - 1
347    const cell = isLast ? LAST_LABEL : gap
348    const tone = toneOf(stage)
349
350    track.push({ ...tone, text: stage.status === 'waiting' ? '○' : stage.status === 'failed' ? '✗' : '●' })
351    labels.push({ ...tone, text: fit(stage.name, cell - 1) + ' ' })
352    marks.push({ ...tone, text: fit(markOf(now, stage), cell - 1) + ' ' })
353
354    if (isLast) {
355      return
356    }
357
358    // 節點後面這段線,代表下一個階段的進度
359    const ahead = now.stages[index + 1]
360
361    if (ahead.status === 'done') {
362      track.push({ text: '━'.repeat(gap - 1), color: GREEN })
363    } else if (ahead.status === 'waiting') {
364      track.push({ text: '─'.repeat(gap - 1), color: MUTED })
365    } else {
366      track.push({ text: '━'.repeat(filled), color: ahead.status === 'failed' ? RED : YELLOW })
367      track.push({ text: '─'.repeat(gap - 1 - filled), color: MUTED })
368    }
369  })
370
371  return [...spriteRows(left, isWalking ? now.frame : 0, now.outcome === 'failed'), [], track, labels, marks]
372}
373
374// 窄的版面(終端機很窄時):吉祥物在上,階段直向列出
375function listRows(now: Run): Span[][] {
376  const isWalking = now.outcome === 'running'
377
378  return [
379    ...spriteRows(MARGIN, isWalking ? now.frame : 0, now.outcome === 'failed'),
380    [],
381    ...now.stages.map(stage => {
382      const mark = stage.status === 'waiting' ? '○' : markOf(now, stage)
383
384      return [{ text: ' '.repeat(MARGIN) }, { ...toneOf(stage), text: `${fit(mark, 7)} ${stage.name}` }]
385    }),
386  ]
387}
388
389// 把一列補到剛好 width 格:左右各留 PAD,中間不足的用空白填。
390// 每一列都一樣寬,面板才會是一塊完整的深色矩形
391function padded(spans: readonly Span[], width: number): Span[] {
392  const inner = width - PAD * 2
393  const kept: Span[] = []
394  let used = 0
395
396  for (const span of spans) {
397    const text = fit(span.text, Math.max(0, Math.min(cellsOf(span.text), inner - used)))
398
399    if (text !== '') {
400      kept.push({ ...span, text })
401      used += cellsOf(text)
402    }
403  }
404
405  return [{ text: ' '.repeat(PAD) }, ...kept, { text: ' '.repeat(inner - used + PAD) }]
406}
407
408// 回傳面板的寬度、標題列(右邊要留位置給按鈕)和其餘各列
409function draw(now: Run, columns: number, buttonCells: number): { width: number; head: Span[]; rows: Span[][] } {
410  const width = Math.max(MIN_WIDTH, Math.min(columns, MAX_WIDTH))
411  const inner = width - PAD * 2
412  const count = now.stages.length
413  const finished = now.stages.filter(stage => stage.status === 'done').length
414  const total = now.outcome === 'running' ? now.elapsedMs : now.stages.reduce((sum, stage) => sum + stage.ms, 0)
415  const counter = `${finished}/${count}  ${timer(total)}  `
416  const headline = headlineOf(now)
417  const gap = count > 1 ? Math.floor((inner - MARGIN - LAST_LABEL) / (count - 1)) : 0
418  const body = count > 1 && gap >= 9 ? trackRows(now, inner, gap) : listRows(now)
419  const headRoom = Math.max(0, inner - buttonCells - MARGIN - cellsOf(counter))
420
421  return {
422    width,
423    head: [
424      { text: ' '.repeat(PAD + MARGIN) },
425      { ...headline, text: fit(headline.text, headRoom) },
426      { text: counter, color: INK },
427    ],
428    rows: [
429      [],
430      ...body,
431      ...(now.tail.length > 0 ? [[]] : []),
432      ...now.tail.map(line => [{ text: ' '.repeat(MARGIN) }, { text: line, color: RED }]),
433      [],
434    ].map(spans => padded(spans, width)),
435  }
436}
437
438export const register: Register = on => {
439  on('session.start', async ($, e, next) => {
440    await $.command.register({
441      name: 'ship',
442      description: '依序跑這個專案的階段並顯示進程(/ship demo 看示範,/ship init 建立設定檔)',
443    })
444
445    return next(e)
446  })
447
448  on('command.run', { command: 'ship' }, async ($, e) => {
449    const args = e.args.trim()
450
451    return { text: args === 'init' ? await init($) : await start($, args) }
452  })
453
454  // 畫在 prompt 正上方的橫條裡,寬度就是整個終端機,所以軌道一定是橫的
455  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
456    if (run === null || isHidden || e.props.hasSurvey) {
457      return next(e)
458    }
459
460    const { Box, Button, Text } = $.ui.resolve(e)
461    const isBusy = run.outcome === 'running'
462    // 終端機上的按鈕畫成「[ 文字 ]」,這裡先替它們留好位置
463    const view = draw(run, e.props.bodyColumns, isBusy ? 8 : 18)
464    const textOf = (span: Span, index: number) =>
465      Text({
466        key: `span-${index}`,
467        color: span.color,
468        backgroundColor: PANEL,
469        bold: span.bold,
470        wrap: 'truncate-end',
471        children: span.text,
472      })
473
474    return Box({
475      flexDirection: 'column',
476      width: view.width,
477      backgroundColor: PANEL,
478      children: [
479        Box({ key: 'top', flexDirection: 'row', children: padded([], view.width).map(textOf) }),
480        Box({
481          key: 'head',
482          flexDirection: 'row',
483          children: [
484            ...view.head.map(textOf),
485            ...(isBusy
486              ? []
487              : [
488                  Button({
489                    key: 'rerun',
490                    label: '重跑',
491                    hotkey: 'r',
492                    onPress: () => {
493                      void start($, lastArgs)
494                    },
495                  }),
496                  Text({ key: 'between', backgroundColor: PANEL, children: ' ' }),
497                ]),
498            Button({
499              key: 'close',
500              label: '關',
501              hotkey: 'c',
502              onPress: () => {
503                isHidden = true
504                $.ui.invalidate('ui.render')
505              },
506            }),
507          ],
508        }),
509        ...view.rows.map((spans, row) => Box({ key: `row-${row}`, flexDirection: 'row', children: spans.map(textOf) })),
510      ],
511    })
512  })
513}
514