SLOPSHOPPER

your-turn

把最後一則回覆裡要你親手跑的指令(程式碼區塊裡 sudo 開頭的行、! <cmd>、前一句叫你自己跑的 shell 區塊)列成對話旁的清單,每段帶上回覆裡的前一句說明;按數字鍵勾完成、按 y 再按編號複製指令,全部完成後按 r 回報,送出「N/N 完成了」。只整理,不代跑

newpanecommandtoast
★ 1v0.6.1MITupdated 2026-10-08mangow314/mango-mods/your-turn
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · your-turn
│ ┃ 要你跑的指令 ✕ › fix the failing auth test and add an audit log call │ ┃ your-turn 沒有要你跑的指令 │ ┃ ⏺ Read(src/auth.ts) │ ┃ q: 關閉 ⎿ 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 │ │ › /your-turn │ ⎿ your-turn: [your-turn] 已打開清單 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 要你跑的指令
your-turn 沒有要你跑的指令 q: 關閉
README

your-turn

繁體中文版:README.zh-TW.md. The mod's on-screen text is in Traditional Chinese, and it recognizes "run this yourself" hints written in Chinese.

A Claude Code mod that turns the commands Claude's last reply asks you to run yourself into a checklist in a pane beside the conversation. Steps like sudo are always yours to run, yet the commands are often buried in a long reply. Tick each one off with its number key once it has run; when all are ticked, press r to report, which sends "N/N 完成了" ("N/N done"). The mod only organizes the list; it never runs a command for you.

Tested on: Claude Code 2.1.289 (0.1.0, loaded with --plugin-dir; tested the toast at 141 columns, opening with /your-turn, ticking with number keys, and the report button appearing; the first, powerline-style version was tested sending "3/3 完成了" from the report button and replacing the whole list on new commands; the TUI-panel version was tested in a 160-column tmux for frame titles, right-edge alignment, frames turning gray when done, and the report button; 0.2.0 was tested splitting into three frames in reply order with only the finished frame turning gray; 0.3.0 was tested listing both lines of a "請你在終端機登入 gcloud" ("please log in to gcloud in your terminal") block, not listing an ls after "我剛剛跑了" ("I just ran"), the note line above each step, and the report button after ticking everything; 0.6.0 was tested on 2.1.290 sending "5/5 完成了" by pressing r after ticking everything). The mods API is still in early access, so a Claude Code release may require changes.

The pane

your-turn in a live session: 5 commands in two frames, 2 ticked off

A live Claude Code 2.1.290 session: the reply asks you to run 5 commands by hand, split into two frames, 在終端機跑 ("run in a terminal") and 在提示框打 ("type in the prompt"); 1 and 2 are ticked. With everything ticked:

your-turn with everything ticked: gray frames, press r to report

The group icons are Nerd Font U+F120 and U+F075; without a Nerd Font they render as boxes.

  • TUI-panel style (after lazygit and btop): the title is plain text; each run of consecutive commands that run in the same place gets a rounded frame with the group name set into its top border; at the bottom, a progress bar and a lazygit-style key bar. One blank line separates the title, each group, and the bottom.
  • Colors match the terminal, using Gruvbox Dark Hard's bright colors: yellow #fabd2f = run in a terminal, aqua #8ec07c = type in the prompt, green #b8bb26 = done and keys. While a frame still has unfinished commands, its border uses the group color; once all are done, it turns dark gray #504945.
  • Frames are drawn with text: a Box's own border is painted over its children, so the group name cannot sit on it (measured 2026-10-05). The right border is aligned by computing the pane width and column widths; CJK characters count as 2 columns.
  • In the bottom progress bar, done cells are green and the rest dark gray, up to 16 cells, shrinking with a narrow pane.
  • Grouped by where the command runs: sudo and other commands you run in a separate terminal go under 在終端機跑; commands starting with ! go under 在提示框打 (typed into Claude's prompt).
  • Above each step (one code block, or the inline ` ! cmd on one line) is a dim gray note: the sentence before it in the reply, copied as is (for example "wait for the install, then start the service:" or "takes about 2 minutes:"), truncated if too long. That sentence is the nearest non-blank line outside a code block; an inline ! cmd` uses its own line. Each sentence appears only once; when two code blocks have no text between them, the second gets no note.
  • Numbering follows the reply's order; groups are not reordered. Consecutive commands that run in the same place share a frame, and a change of place starts a new one, so one group can have two frames (for example ! whoami, then 3 sudo commands, then ! claude plugin list draws three frames: prompt 1, terminal 2–4, prompt 5).
  • Each row: number, ○ / ✔, command (truncated if too long). Done commands are struck through and dimmed.
  • Press the number key again to untick. The first 9 commands have number keys; from the 10th on, click with the mouse.
  • Press q to close the list (as in away-receipt). "q: 關閉" ("close") sits at the end of the key bar, also when the list is empty.
  • Press y, then a number (like vim's y3), to copy that command to the clipboard without ticking it; useful when a command is too long for the pane. After y, the key bar changes to "1–3 按編號複製那條指令" ("press a number to copy that command"); press y again to cancel. After copying, a toast says "已複製第 N 條" ("copied #N") and the list goes back to ticking. Commands starting with ! are copied with the !, ready to paste into the prompt. From the 10th command on there is no number key: after y, click that command's ○.
  • When the pane is not focused, the key bar reads "ctrl+x tab 切過來 │ 1–3 勾選" ("ctrl+x tab to switch here │ 1–3 to tick").
  • With everything ticked, the key bar reads "r 回報:告訴 Claude 全部完成" ("r report: tell Claude everything is done") and a "[ 回報 N/N 完成 ]" button appears at the bottom; press r (or click it) to send "N/N 完成了" for you (labeled as coming from your-turn; the model sees the original text).

What counts as "a command for you to run"

  • Lines in a code block starting with sudo. A copied prompt, $ sudo …, counts too; a trailing \ joins the next line.
  • ! <command>: inline code (for example ` ! gcloud auth login ) or a line in a code block. The ! must be followed by a space, so code like !e.agentId or !== does not count; neither does a placeholder such as ! <cmd> (anything containing <…>`).
  • A shell code block (language tag bash / sh / shell / zsh / console, or no tag) whose preceding sentence contains any of 你 ("you"), 手動 ("manually"), 自己 ("yourself"), 親手 ("by hand"), 在終端機 ("in a terminal"), 另開 ("open another"): every line of the block counts, except blank lines and # comments. For example gcloud auth login after "請你在終端機登入 gcloud:". An untagged block counts as a whole only if every such line starts with an ASCII character and contains no arrows (→ ←) or box-drawing characters (─ │ ┌); continuation lines after a trailing \ are not checked. Otherwise (a prompt for you to copy elsewhere, a diagram drawn for you, output annotated with ←) only its sudo and ! lines count. A preceding sentence that starts with 我 ("I") does not count ("我自己試了一下:" / "I tried it myself:", "我在終端機跑了:" / "I ran in the terminal:" are Claude describing what it did).
  • Ordinary commands in other code blocks (for example ls or git status after "我剛剛跑了:" / "I just ran:") do not count.
  • A command that appears again in a later step is listed again (for example sudo -k at both the start and the end); one mentioned twice in a row is listed once ("打 ! whoami,看 ! whoami 印出誰" / "type ! whoami and see who ! whoami prints").
  • Grouping looks only at the form: commands starting with ! go under 在提示框打 (! sudo … included), everything else under 在終端機跑.

Commands

  • /your-turn: opens the list, drawn at any width.

When it updates

  • At the end of every main-conversation turn it reads the last reply (the answer of turn.complete). Subagent turns do not count.
  • Commands found: the list is rebuilt, and the pane opens and takes focus.
  • No commands found: the list is left alone and the pane does not open.
  • After /clear the list is empty ($.state is reset).

Known limits

  • Automatic opening is a "proactive open": the terminal must be at least 144 columns wide for it to draw. When narrower, the pane waits undrawn and a toast suggests typing /your-turn. Once you have opened it with /your-turn (and not closed it by hand since), the threshold for automatic opening drops to 110 columns (measured 2026-10-05: a 141-column tmux pane did not open automatically).
  • Number keys and q work only while the pane has focus. On automatic opening it gets focus only if the prompt is empty; after you press Esc to go back to the prompt, use ctrl+x tab to switch back to the pane.
  • Only the text of the turn's last reply is read; messages earlier in the same turn, before tool calls, are not included.
  • sudo lines written as a script example in a code block are listed too.
  • When the preceding sentence has a hint like 你 but an untagged block is really sample output or an English prompt, every line starts with an ASCII character, and no line has arrows or box-drawing characters (for example "你的環境有這幾個變數:" / "your environment has these variables:" followed by XMODIFIERS=@im=fcitx), every line is listed as a command. Blocks tagged text or another non-shell language are not.
  • The note is only the preceding sentence, with no understanding of dependencies; a waiting instruction written after the command is not picked up.
  • With an emoji in a command, the right border may be off by one column (the column-width calculation does not handle emoji).
Source 2 files
hooks/register.tsx 358 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, UiPressArgument } from 'claude-code'
3
4import type { Step } from '../types'
5
6// your-turn:主對話每輪結束時,從最後一則回覆(turn.complete 的 answer)抽出要你親手跑的指令:
7// 程式碼區塊裡 sudo 開頭的行(行尾 \ 接下一行)、`! <cmd>`(行內 code 或區塊裡的行);
8// 前一句有「你/手動/自己/親手/在終端機/另開」的 shell 區塊,整塊每行都算;
9// 沒標語言的區塊要每行都以英數符號開頭、沒有箭頭和框線字才算(整段中文 prompt、框線圖只取 sudo 與 ! 行)。
10// 清單照回覆裡出現的順序編號,不重排;連續在同一處跑的指令同一框,換地方跑就開新框。
11// 每段指令(一個程式碼區塊,或一行裡的行內 `! cmd`)上方帶回覆裡的前一句當說明(等多久、等什麼再跑)。
12// 抽到指令就重算清單、打開對話旁的 pane(主動打開:終端機 ≥144 欄才畫,你用 /your-turn 開過一次後降到 110 欄;
13// 沒畫出來就跳 toast 提示打 /your-turn;提示框是空的才拿得到焦點)。
14// pane 有焦點時按數字鍵勾完成、按 q 關掉;按 y 再按編號,把那條指令複製到剪貼簿(指令太長、pane 顯示不完時用)。
15// 全部勾完出現「回報」,按 r 或點它替你送出「N/N 完成了」。mod 只整理清單,不代跑任何指令。
16
17const PANE = 'your-turn'
18const TITLE = '要你跑的指令'
19// Button 的 hotkey 只收一個數字或小寫字母:前 9 條有數字鍵,第 10 條起只能用滑鼠點
20const MAX_KEYS = 9
21
22// TUI 面板樣式(學 lazygit/btop):連續在同一處跑的指令一個圓角框,標題嵌在上框線;底部是進度條加 lazygit 式按鍵列。
23// 框線自己用 Text 畫:Box 的 border 會蓋在子元素上面,標題疊不上去(2026-10-05 實測)
24// 配色和終端機一致,用 Gruvbox Dark Hard(morhetz/gruvbox)的 bright 色:黃=在終端機跑、水綠=在提示框打、綠=完成。
25// 框裡還有沒做完的指令時框線用該組顏色,全做完變深灰;做完的指令刪除線+淡色
26const YELLOW = '#fabd2f'
27const AQUA = '#8ec07c'
28const GREEN = '#b8bb26'
29const GRAY = '#928374'
30const DARK_GRAY = '#504945'
31const TRACK = '━' // U+2501 進度條:做完的塗綠、其餘塗深灰
32// 每列:「│ 」與「 │」4 欄+引擎畫的「1: 」3 欄+○/✔+空白+「$ 」2 欄
33const ROW_FIXED = 11
34// 進度條最長 16 格;同一行另有「進度 」和「 10/10」,約 12 欄
35const TRACK_MAX = 16
36const FOOT_FIXED = 12
37// 說明行:「│ 」與「 │」4 欄+縮排 2 欄
38const NOTE_FIXED = 6
39// 分組:sudo 和其他要你另開終端機的指令在終端機跑;! 開頭的在 Claude 的提示框打
40const GROUPS = [
41  { icon: '', color: YELLOW, title: '在終端機跑', has: (cmd: string) => !cmd.startsWith('!') }, // nf-fa-terminal
42  { icon: '', color: AQUA, title: '在提示框打', has: (cmd: string) => cmd.startsWith('!') }, // nf-fa-comment
43] as const
44
45const stepsAtom = atom({ plugin: 'your-turn', key: 'steps' } as const, [] as Step[])
46// 按了 y、等你按編號:這時按編號是複製,不是勾選(像 vim 的 y3)
47const yankAtom = atom({ plugin: 'your-turn', key: 'yank' } as const, false)
48
49export const register: Register = on => {
50  on('session.start', async ($, e, next) => {
51    const result = await next(e)
52    // 名稱衝突會丟例外,包住免得中斷 hook
53    try {
54      await $.command.register({ name: 'your-turn', description: 'your-turn:打開要你親手跑的指令清單' })
55    } catch (err) {
56      $.ui.log(`[your-turn] 註冊 /your-turn 失敗:${String(err)}`)
57    }
58    return result
59  })
60
61  // 你打的指令=asked:任何寬度都畫
62  on('command.run', { command: 'your-turn' }, async $ => {
63    await $.ui.open({ id: PANE, title: TITLE, focus: true })
64    return { text: '[your-turn] 已打開清單' }
65  })
66
67  on('turn.complete', async ($, e, next) => {
68    const result = await next(e)
69    if (e.agentId) {
70      return result
71    }
72    const cmds = extractCommands(e.answer)
73    // 這則回覆沒有新指令:清單不動
74    if (cmds.length === 0) {
75      return result
76    }
77    // 照回覆裡出現的順序編號,不按分組重排:前後常有依賴(先裝套件才能啟動服務)
78    await update($, stepsAtom, () => cmds.map(c => ({ ...c, isDone: false })))
79    await update($, yankAtom, () => false)
80    try {
81      const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
82      if (!opened.isPlaced) $.ui.toast(`your-turn:${cmds.length} 條要你親手跑的指令,終端機太窄沒畫出來,打 /your-turn 打開`, { timeoutMs: 8000 })
83    } catch (err) {
84      $.ui.log(`[your-turn] 打開 pane 失敗:${String(err)}`)
85    }
86    return result
87  })
88
89  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
90    const { Box, Button, Text } = $.ui.resolve(e)
91    const steps = await read($, stepsAtom)
92    const isYanking = await read($, yankAtom)
93    // 和 away-receipt 一樣按 q 關;引擎內建的 ctrl+x x 要按兩個鍵。清單空的時候也要有,不然 q 沒作用
94    const close = <Button key="close" plain hotkey="q" label="關閉" onPress={() => $.ui.close({ id: PANE })} />
95    // 標題是一般文字,不用色塊
96    const header = (
97      <Text>
98        <Text bold>your-turn</Text>
99        <Text color={GRAY}>{`  ${steps.length === 0 ? '沒有要你跑的指令' : `${steps.length} 條指令待你親手跑`}`}</Text>
100      </Text>
101    )
102    if (steps.length === 0) {
103      return (
104        <Box flexDirection="column">
105          {header}
106          <Box marginTop={1}>{close}</Box>
107        </Box>
108      )
109    }
110    const width = e.props.bodyColumns
111    const room = Math.max(10, width - ROW_FIXED)
112    const done = steps.filter(s => s.isDone).length
113    const isAllDone = done === steps.length
114    // 按鈕只放編號與 ○/✔(按鈕文字不能加刪除線、也不能上色);$/!、指令另用 Text 畫,做完的指令加刪除線。
115    // 右框線前補空白,讓每列剛好 pane 寬
116    const row = (s: Step, i: number, color: string, frame: string) => {
117      const isBang = s.cmd.startsWith('!')
118      const body = clip(isBang ? s.cmd.slice(1).trimStart() : s.cmd, room)
119      const mark = s.isDone ? '✔' : '○'
120      const label = i < MAX_KEYS ? mark : `${i + 1}: ${mark}`
121      const keyCols = i < MAX_KEYS ? 3 : 0
122      const pad = Math.max(0, width - 7 - keyCols - cols(label) - cols(body))
123      const press = (p: UiPressArgument) => (isYanking ? yank($, s.cmd, i, p) : toggle($, i))
124      return (
125        <Box key={`row-${i + 1}`} flexDirection="row">
126          <Text color={frame}>{'│ '}</Text>
127          {i < MAX_KEYS
128            ? <Button key={`step-${i + 1}`} plain hotkey={String(i + 1)} label={label} dimColor={s.isDone} onPress={press} />
129            : <Button key={`step-${i + 1}`} plain label={label} dimColor={s.isDone} onPress={press} />}
130          <Text color={color} bold dimColor={s.isDone}>{isBang ? ' ! ' : ' $ '}</Text>
131          <Text dimColor={s.isDone} strikethrough={s.isDone}>{body}</Text>
132          <Text color={frame}>{`${' '.repeat(pad)} │`}</Text>
133        </Box>
134      )
135    }
136    // 說明行:回覆裡這段指令的前一句,淡灰色、縮排 2 欄,太長截斷
137    const noteLine = (note: string, i: number, frame: string) => {
138      const body = clip(note, Math.max(10, width - NOTE_FIXED))
139      return (
140        <Box key={`note-${i + 1}`} flexDirection="row">
141          <Text color={frame}>{'│   '}</Text>
142          <Text color={GRAY}>{body}</Text>
143          <Text color={frame}>{`${' '.repeat(Math.max(0, width - NOTE_FIXED - cols(body)))} │`}</Text>
144        </Box>
145      )
146    }
147    // 照清單順序切框:連續在同一處跑的指令放同一框,換地方跑就開新框,所以同一組可能出現兩個框。
148    // 每框上方空一行,標題嵌在上框線;框裡還有沒做完的指令時框線用該組顏色
149    const runs: { g: (typeof GROUPS)[number]; members: { s: Step; i: number }[] }[] = []
150    steps.forEach((s, i) => {
151      const g = GROUPS.find(x => x.has(s.cmd)) ?? GROUPS[0]
152      const last = runs.at(-1)
153      if (last?.g === g) last.members.push({ s, i })
154      else runs.push({ g, members: [{ s, i }] })
155    })
156    const groups = runs.map(({ g, members }, n) => {
157      const frame = members.some(m => !m.s.isDone) ? g.color : DARK_GRAY
158      const title = ` ${g.icon} ${g.title} `
159      return (
160        <Box key={`box-${n}`} flexDirection="column" marginTop={1}>
161          <Text>
162            <Text color={frame}>{'╭─'}</Text>
163            <Text color={g.color} bold>{title}</Text>
164            <Text color={frame}>{`${'─'.repeat(Math.max(0, width - 3 - cols(title)))}╮`}</Text>
165          </Text>
166          {members.flatMap(m => [...(m.s.note ? [noteLine(m.s.note, m.i, frame)] : []), row(m.s, m.i, g.color, frame)])}
167          <Text color={frame}>{`╰${'─'.repeat(Math.max(0, width - 2))}╯`}</Text>
168        </Box>
169      )
170    })
171    // 進度條跟著 pane 寬度縮,最長 16 格
172    const track = Math.max(4, Math.min(TRACK_MAX, width - FOOT_FIXED))
173    const filled = Math.round((done / steps.length) * track)
174    // lazygit 式按鍵列:按鍵塗綠、說明用預設字色、項目之間用深灰 │ 隔開
175    const key = (s: string) => <Text color={GREEN} bold>{s}</Text>
176    const sep = <Text color={DARK_GRAY}>{' │ '}</Text>
177    const range = `1–${Math.min(MAX_KEYS, steps.length)}`
178    const keys = isYanking
179      ? [key(range), ' 按編號複製那條指令']
180      : isAllDone
181        ? [key('r'), ' 回報:告訴 Claude 全部完成']
182        : e.props.isFocused
183          ? [key(range), ' 勾選', sep, key('再按一次'), ' 取消']
184          : [key('ctrl+x tab'), ' 切過來', sep, key(range), ' 勾選']
185    // 再按一次 y 取消複製模式
186    const yankButton = <Button key="yank" plain hotkey="y" label={isYanking ? '取消複製' : '複製'} onPress={() => update($, yankAtom, v => !v)} />
187    return (
188      <Box flexDirection="column">
189        {header}
190        {groups}
191        <Box flexDirection="column" marginTop={1}>
192          <Text>
193            <Text color={GRAY}>進度 </Text>
194            <Text color={GREEN}>{TRACK.repeat(filled)}</Text>
195            <Text color={DARK_GRAY}>{TRACK.repeat(track - filled)}</Text>
196            <Text color={isAllDone ? GREEN : GRAY}>{` ${done}/${steps.length}`}</Text>
197          </Text>
198          {/* 複製、關閉鈕接在按鍵列尾:引擎畫成「y: 複製」「q: 關閉」 */}
199          <Box flexDirection="row">
200            <Text>{keys}{sep}</Text>
201            {yankButton}
202            <Text>{sep}</Text>
203            {close}
204          </Box>
205        </Box>
206        {isAllDone && (
207          <Button key="report" variant="primary" hotkey="r" label={`回報 ${steps.length}/${steps.length} 完成`} onPress={() => report($, steps.length)} />
208        )}
209      </Box>
210    )
211  })
212}
213
214// 前一句有這些字的 shell 區塊,整塊每行都是要你跑的指令;
215// 前一句以「我」開頭的不算(「我自己試了一下:」「我在終端機跑了:」是 Claude 說它做了什麼)
216const HINT_RE = /你|手動|自己|親手|在終端機|另開/
217const SELF_RE = /^(?:[-*]\s+|\d+\.\s+)?我/
218// shell 區塊:``` 後面是這幾種語言標記,或沒有標記(沒有標記的還要過 isShellLike)
219const SHELL_LANGS = new Set(['', 'bash', 'sh', 'shell', 'zsh', 'console'])
220
221// 回覆裡要你親手跑的指令,照出現順序;每段(一個程式碼區塊,或一行裡的行內 `! cmd`)的第一條帶前一句當 note。
222// 同一條在後面的步驟再出現就再列一次(例如最後再 sudo -k 一次);
223// 緊接著重複的只留一條(「打 `! whoami`,看 `! whoami` 印出誰」)
224function extractCommands(text: string): { cmd: string; note: string }[] {
225  const found: { cmd: string; note: string }[] = []
226  let inBlock = false
227  let isHinted = false
228  // 前一句:區塊外最近一行非空白的字;區塊結束就清掉,連續兩個區塊之間沒有字就不帶
229  let lead = ''
230  // 這一段還沒掛上的說明;和上一條掛出去的一樣就不重複(行內 `! cmd` 那行接著就是程式碼區塊時)
231  let note = ''
232  let lastNote = ''
233  const startSegment = (s: string) => {
234    note = s !== lastNote ? s : ''
235  }
236  const push = (cmd: string) => {
237    found.push({ cmd, note })
238    if (note !== '') lastNote = note
239    note = ''
240  }
241  // 區塊裡行尾 \ 的指令:先接起來,到沒有 \ 的那行才收
242  let pending = ''
243  const lines = text.split('\n')
244  for (const [n, raw] of lines.entries()) {
245    const line = raw.trim()
246    const fence = /^(?:```|~~~)\s*(\S*)/.exec(line)
247    if (fence) {
248      if (inBlock) {
249        lead = ''
250      } else {
251        const lang = (fence[1] ?? '').toLowerCase()
252        isHinted = SHELL_LANGS.has(lang) && HINT_RE.test(lead) && !SELF_RE.test(lead) && (lang !== '' || isShellLike(lines, n + 1))
253        startSegment(lead)
254      }
255      inBlock = !inBlock
256      pending = ''
257      continue
258    }
259    if (inBlock) {
260      if (pending !== '') {
261        pending = `${pending} ${line.replace(/\\$/, '').trim()}`
262        if (!line.endsWith('\\')) {
263          push(pending)
264          pending = ''
265        }
266        continue
267      }
268      // 照抄提示字元的寫法($ sudo …)也算;有提示字的區塊每行都算,空行與 # 註解除外
269      const cmd = isHinted
270        ? (line === '' || line.startsWith('#') ? undefined : line.replace(/^\$\s+/, ''))
271        : /^(?:\$\s+)?(sudo\s.+|!\s+\S.*)$/.exec(line)?.[1]
272      if (cmd === undefined || (cmd.startsWith('!') && isPlaceholder(cmd))) continue
273      if (cmd.endsWith('\\')) pending = cmd.slice(0, -1).trim()
274      else push(cmd)
275      continue
276    }
277    if (line === '') continue
278    const plain = line.replace(/`|\*\*/g, '')
279    // `!` 後面要有空白:`!e.agentId`、`!==` 這類程式碼不算
280    const inline = [...line.matchAll(/`(!\s+[^`]+)`/g)].map(m => (m[1] ?? '').trim()).filter(cmd => !isPlaceholder(cmd))
281    if (inline.length > 0) {
282      startSegment(plain)
283      for (const cmd of inline) push(cmd)
284    }
285    lead = plain
286  }
287  return found.filter((f, i) => f.cmd !== found[i - 1]?.cmd)
288}
289
290// 沒標語言的區塊從 from 行起到結束 fence:空行、# 註解與接在行尾 \ 後面的續行以外,
291// 每行(去掉 $ 提示字)都以英數符號開頭、行內沒有箭頭(→ ←)和框線字(─ │ ┌),才當 shell。
292// 不符合的是說明、要你貼去別處的 prompt、圖或加了註記的輸出,不是指令
293function isShellLike(lines: string[], from: number): boolean {
294  let isContinued = false
295  for (const raw of lines.slice(from)) {
296    const line = raw.trim()
297    if (/^(?:```|~~~)/.test(line)) break
298    const isSkipped = isContinued || line === '' || line.startsWith('#')
299    isContinued = line.endsWith('\\')
300    if (isSkipped) continue
301    const cmd = line.replace(/^\$\s+/, '')
302    if (!/^[\x21-\x7e]/.test(cmd) || /[←-⇿─-╿]/.test(cmd)) return false
303  }
304  return true
305}
306
307// 說明用的佔位寫法(`! <cmd>`)不是真的指令;sudo 區塊裡的 <佔位> 仍算(要你填好再跑)
308function isPlaceholder(cmd: string): boolean {
309  return /<[^>]+>/.test(cmd)
310}
311
312async function toggle($: EngineInterface, i: number) {
313  await update($, stepsAtom, list => list.map((s, j) => (j === i ? { ...s, isDone: !s.isDone } : s)))
314}
315
316// 複製後回到勾選模式;複製本身看不到,用 toast 告訴你複製了哪條。! 開頭的連 ! 一起複製,貼到提示框就能跑
317async function yank($: EngineInterface, cmd: string, i: number, p: UiPressArgument) {
318  await update($, yankAtom, () => false)
319  const copied = await $.ui.copy({ text: cmd, surface: p.surface })
320  $.ui.toast(copied.isCopied ? `your-turn:已複製第 ${i + 1} 條` : `your-turn:沒有複製到(${copied.reason})`, { timeoutMs: 4000 })
321}
322
323async function report($: EngineInterface, total: number) {
324  try {
325    const sent = await $.prompt.submit({ text: `${total}/${total} 完成了`, asUser: true })
326    if (sent.drop !== undefined) $.ui.log(`[your-turn] 回報被擋:${sent.drop}`)
327  } catch (err) {
328    $.ui.log(`[your-turn] 回報送出失敗:${String(err)}`)
329  }
330}
331
332// 超過 width 欄就截斷,最後一欄換成 …
333function clip(s: string, width: number): string {
334  if (cols(s) <= width) return s
335  let out = ''
336  let used = 0
337  for (const ch of s) {
338    const w = cols(ch)
339    if (used + w > width - 1) break
340    out += ch
341    used += w
342  }
343  return `${out}…`
344}
345
346// 終端機上的欄寬:中日韓字與全形字 2 欄,其他(含 Nerd Font 圖示、○、✔)1 欄
347function cols(s: string): number {
348  let n = 0
349  for (const ch of s) {
350    const c = ch.codePointAt(0) ?? 0
351    const isWide = (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3)
352      || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60)
353      || (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x20000 && c <= 0x3fffd)
354    n += isWide ? 2 : 1
355  }
356  return n
357}
358
types/index.d.ts 15 lines
1// 一條要使用者親手跑的指令;isDone=使用者在 pane 按數字鍵勾了完成;
2// note=回覆裡這段指令的前一句(只有每段第一條有,空字串=不畫說明行)
3export type Step = { cmd: string; isDone: boolean; note?: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'your-turn': {
8      // 最近一則帶指令的回覆抽出的清單;$.state 在 /clear 後歸零
9      steps: Step[]
10      // 按了 y、等使用者按編號複製
11      yank: boolean
12    }
13  }
14}
15