SLOPSHOPPER

live-viewer

Watch Claude's tool calls live in a side-pane tab: Write, Edit and Bash as boxes with streaming code and diffs, other tools as one line, thinking, answers and…

panespinnerguardcommandprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · live-viewer
│ ┃ Live viewer ✕ › fix the failing auth test and add an audit log call │ ┃ live-viewer: turned off for this session │ ┃ after an error ● live-viewer: live-viewer: turned off for this session (ReferenceErr │ ┃ ⏺ 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 │ ┃ │ ┃ › /live │ ┃ ⎿ live-viewer: Opened the live viewer tab. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Live viewer
live-viewer: turned off for this session after an error
README

English · 한국어

live-viewer

A UI mod that lets you see the code Claude Code is writing under the hood, right as it happens.

It streams every Claude Code tool call into a side-pane tab in real time.

1\. If you're a vibe coder, it's great for getting in the coding mood.<br> 2\. If you're a working developer who likes the official Claude Code app, it's great for peeking into the code under the hood.<br> 3\. It's pretty.

test license: MIT Claude Code 2.1.295–2.1.302

<img src="docs/demo.gif" width="880" alt="Demo: Claude writes a rate limiter and its tests while the Live viewer tab on the right streams each file line by line, shows the edit as a diff, runs the tests, and ticks off the todo list">

<sub>A sample session recreated for this page. Nothing in it comes from a real project.</sub>

Install · What you see · Settings · How it works

Why

Claude Code only shows a tool call once it is complete. Until then, a long Write or Edit is just a spinner, and the finished result is folded away in the transcript. live-viewer opens a tab next to the conversation that streams each call while the model is still producing it: the file growing, the diff taking shape, the command about to run. You can spot a wrong turn early and press Esc before it lands.

Install

Checked with skills CLI 1.7.2 (2026-10-17):

npx skills add moneycode11101-svg/claude-code-live-viewer -g -a claude-code

This installs it to ~/.claude/skills/live-viewer.

Or install with git:

git clone https://github.com/moneycode11101-svg/claude-code-live-viewer.git
cp -R claude-code-live-viewer/skills/live-viewer ~/.claude/skills/

Start a new Claude Code session. The Live viewer tab opens automatically. If you close it, /live brings it back.

If you installed an earlier version with claude plugin install, remove it so only one tab opens:

claude plugin uninstall live-viewer@live-viewer

What you see

<img src="docs/hero-light.png" width="560" alt="The Live viewer tab: Claude's thinking in italics, Read and Grep as one-line entries, an Edit box with a diff, a Bash box with test output, a Write box streaming the last twelve lines of a test file, and a todo tree with the current step">

CallShown as
WriteA box titled with the file path. While streaming: the last 12 lines with line numbers. When done: the first 3 lines, total line count, and whether the file is new
EditA box showing the diff with real line numbers, labeled [+added -removed]
BashA box titled with the command. When done: the last 3 lines of output, duration, and exit code
Read, Grep, Glob, WebFetch, WebSearch, AgentA single line: ● Read src/app.ts:30-60
Thinking, answersThinking in italics; answers rendered as Markdown
Todo listA tree at the bottom showing done/total and the current step

Box colors reflect the call's state: pending, done, or failed. Calls cut off by Esc or blocked by a denied permission are marked accordingly. The tab auto-scrolls as new calls arrive. Scroll up to read something and it pauses; scroll back to the bottom, or send a new prompt, and it follows again.

When the desktop app has not placed the tab yet, the box for the active call is drawn above the spinner in the conversation instead.

Settings

Run /plugin configure live-viewer@skills-dir inside Claude Code, or pipe the values from a shell. New sessions pick them up.

echo '{"palette":"dark"}' | claude plugin configure live-viewer@skills-dir --values-stdin
SettingValuesDefault
languageauto, en, koauto: Korean when your locale (LC_ALL, LC_MESSAGES, or LANG) starts with ko or your time zone is Asia/Seoul; English otherwise
paletteauto, light, darkauto (light in the desktop app; follows your theme in the terminal)
debugtrue, falsefalse. When enabled, writes a status file per session to live-viewer/status/ in your Claude config folder

Requirements

Status
Claude Code 2.1.302, macOS desktop appTested (2026-10-17)
Claude Code 2.1.295 and 2.1.299, macOS desktop appTested with claude plugin test and a load check (2026-10-17)
Claude Code 2.1.302 in a terminal, macOSTested (2026-10-17): the tab docks beside the transcript
Linux, WindowsNot tested yet
Claude Code older than 2.1.295Not tested. Mods are new and their API changes between releases
claude -p and other headless runsNothing to show, so no tab opens

How it works

live-viewer is a Claude Code mod: a plugin whose hooks module watches the engine's events. It ships as a skill folder. Claude Code loads a folder in ~/.claude/skills that has .claude-plugin/plugin.json as the plugin live-viewer@skills-dir, so no marketplace is needed.

  • turn.step: reads the streamed chunks of each model step. It passes every chunk on unchanged and parses the partial JSON of Write, Edit, and Bash inputs as it arrives.
  • tool.call: marks a call as running and attaches the result once it finishes. The result is returned untouched.
  • ui.render: draws the Pane tab, or falls back to the Spinner slot when the tab is not placed.

It never blocks, delays, or changes what Claude does. Updates to the tab are batched to at most one every 33 ms. In a replay of a 3,000-line Write, the time added per chunk is about 0.03 ms (p95)—the same as with the viewer off. If the observer ever throws, it turns itself off for the rest of the session and your session carries on uninterrupted.

Update and remove

npx skills update live-viewer -g
npx skills remove live-viewer -g

If you installed with git, pull and replace ~/.claude/skills/live-viewer with a fresh copy of skills/live-viewer, or remove it with:

rm -rf ~/.claude/skills/live-viewer

Development

git clone https://github.com/moneycode11101-svg/claude-code-live-viewer.git
claude plugin validate --strict skills/live-viewer
claude plugin test skills/live-viewer

To try a local checkout, start Claude Code with claude --plugin-dir skills/live-viewer from the repo folder.

Credits

The box layout and the light and dark palettes follow oh-my-pi (MIT). See NOTICE.

License

MIT

Source 8 files
hooks/register.tsx 641 lines
1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register, Timer, TurnStepChunk } from 'claude-code'
3
4import type { Entry, Item, Todo, View } from '../types'
5import { drawBox, drawView, type Layout, paletteOf } from './draw'
6import { type Answer, settledEntry, shownOf, streamingEntry } from './entry'
7import { toDisplay } from './display'
8import { appendText, applyTodos, isHidden, isTodoTool, lineArgs, type Places, parseRaw, RAW_LIMIT, TEXT_LIMIT } from './feed'
9import { feed, initialState, type State } from './partial'
10import { EN, languageOf, type Words, wordsOf } from './words'
11
12// 최우선은 세션 흐름을 멈추거나 늦추지 않는 것: 조각은 받자마자 그대로 내보내고, 관찰부는 동기로 짧게 돌며
13// $ 호출은 기다리지 않는다. 관찰부가 한 번이라도 던지면 이 세션에서 관찰을 끈다.
14// set은 조각마다 하면 엔진 거친 조각당 p95가 6~12ms로 늘어(replay 실측) 33ms에 한 번으로 묶는다.
15// $를 받는 함수는 모두 이 파일에 둔다: Claude Code 2.1.295의 검사기는 $를 다른 파일 함수로 넘기면 mod를 싣지 않는다.
16// 데스크톱은 클라이언트가 패널 자리를 알리기 전에는 패널을 놓지 않아서(2.1.299), 패널이 안 놓이면
17// 대화창 맨 아래 진행 줄(Spinner) 자리에 쓰는 중인 상자를 그린다. 패널은 새로 그릴 때마다 끝에 붙고(데스크톱은 늘어나는
18// 트리를 스스로 따라가지 않는다), 사람이 위로 굴리면 멈췄다가 끝까지 내리거나 새 프롬프트를 치면 다시 따라간다.
19
20const PANE = 'live'
21const CALLS = { plugin: 'live-viewer', key: 'calls' } as const
22const EMPTY: View = { items: [], todos: [], isWorking: false, isOff: false, seat: 'spinner' }
23const calls = atom(CALLS, EMPTY)
24const PREVIEWED = new Set(['Write', 'Edit', 'Bash'])
25const SEND_GAP_MS = 33
26const MAX_ITEMS = 20
27// 위에 붙는 패널은 그린 만큼 커지므로 본문 줄 수에 상한을 둔다. 진행 줄 자리는 대화창을 덜 밀도록 더 작게.
28const PANE_ROWS_LIMIT = 60
29const SPINNER_ROWS = 16
30// 진행 줄 자리가 폭을 안 알려 줄 때.
31const SPINNER_COLUMNS = 80
32// session.start의 열기가 자리를 잡은 뒤 보이는 탭인지 본다.
33const BRING_FORWARD_MS = 1000
34
35type Call = { index: number; id: string; name: string; parser: State }
36// 상자 밖 도구: 입력 원문을 RAW_LIMIT까지 모았다가 입력이 끝나면 한 줄로 푼다.
37type Other = { index: number; id: string; name: string; raw: string }
38
39// 세션(모듈)에 하나인 상태: 흐름 항목(상자는 본문 원문까지), 지금 스트리밍 중인 호출, 한 줄로 풀 상자 밖 도구,
40// 경로를 줄일 곳, /config 테마(아직 안 읽었으면 undefined), 패널 자리, 보내기 상태(마지막으로 보낸 때, 보낼 것이 있는지, 남은 것을 보낼 타이머 하나).
41let isOff = false
42let items: Item[] = []
43let live: Call | null = null
44let other: Other | null = null
45let todos: Todo[] = []
46let isWorking = false
47// 끝 따라가기: 사람이 위로 굴리면 false. 보낸 스냅숏 번호와 끝에 붙인 번호가 다를 때만 붙인다
48// (붙이기가 다시 그리기를 부르므로 같은 그림에 두 번 붙이지 않는다).
49let isFollowing = true
50let sentSeq = 0
51let followedSeq = -1
52let places: Places = { cwd: null, home: null }
53let theme: string | null | undefined
54// 설정 값(register가 받는다): 글자 언어와 팔레트. auto 언어는 시간대로 먼저 정하고, 로캘 변수를 읽은 뒤 한 번 고친다.
55let words: Words = EN
56let languageChoice: unknown = 'auto'
57let languageReady: Promise<void> | null = null
58let paletteChoice: unknown = 'auto'
59const timeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone
60let isPanePlaced = false
61let isClosedByPerson = false
62let isCommandReady = false
63let lastSentAt = Number.NEGATIVE_INFINITY
64let isDirty = false
65let timer: Timer | null = null
66
67// 패널이 안 보일 때 원인을 추정 말고 엔진 답으로 가리려고, 설정 `debug`가 켜졌을 때만 세션마다 상태 파일 하나를 바꿔 쓴다:
68// <CLAUDE_CONFIG_DIR 또는 ~/.claude>/live-viewer/status/<세션 id>.json. 바뀔 때만 쓰고, 쓰는 중이면 끝난 뒤 한 번 더 쓴다. 흐름은 기다리지 않는다.
69
70const STATUS_KEEP = 12
71
72type StatusRow = Record<string, unknown>
73
74const status = {
75  v: 1,
76  updated: '',
77  surfaces: null as unknown,
78  opens: [] as StatusRow[],
79  panes: null as unknown,
80  draws: {} as Record<string, StatusRow>,
81  closes: [] as StatusRow[],
82  scroll: null as StatusRow | null,
83  follow: null as StatusRow | null,
84  errors: [] as StatusRow[],
85  off: null as string | null,
86}
87
88let statusTarget: string | null = null
89let isSaving = false
90let isAgain = false
91let isStatusBroken = false
92let isStatusEnabled = false
93
94// register가 설정 값을 받을 때마다 부른다.
95function setDebug(isOn: boolean): void {
96  isStatusEnabled = isOn
97}
98
99const nowIso = () => new Date().toISOString()
100
101function keepLast(rows: StatusRow[], row: StatusRow): void {
102  rows.push(row)
103  if (rows.length > STATUS_KEEP) rows.splice(0, rows.length - STATUS_KEEP)
104}
105
106async function writeStatus($: EngineInterface): Promise<void> {
107  if (statusTarget === null) {
108    const config = await $.env.get('CLAUDE_CONFIG_DIR')
109    const home = await $.env.get('HOME')
110    const base = config ?? (home === undefined ? undefined : `${home}/.claude`)
111    if (base === undefined) throw new Error('neither CLAUDE_CONFIG_DIR nor HOME is set')
112    statusTarget = `${base}/live-viewer/status/${await $.session.id()}.json`
113  }
114  status.updated = nowIso()
115  await $.fs.write(statusTarget, `${JSON.stringify(status, null, 1)}\n`)
116}
117
118function save($: EngineInterface): void {
119  if (!isStatusEnabled || isStatusBroken) return
120  if (isSaving) {
121    isAgain = true
122    return
123  }
124  isSaving = true
125  const run = async () => {
126    try {
127      do {
128        isAgain = false
129        await writeStatus($)
130      } while (isAgain)
131    } catch (error) {
132      // 기록은 진단일 뿐이라 관찰을 끄지 않는다. 한 번 알리고 이 세션에서는 그만 쓴다.
133      isStatusBroken = true
134      Promise.resolve($.ui.log(`live-viewer: could not write the status file (${String(error)})`) as unknown).catch(() => {
135        // 알림까지 실패하면 더 알릴 곳이 없다.
136      })
137    } finally {
138      isSaving = false
139    }
140  }
141  void run()
142}
143
144function noteOpen($: EngineInterface, from: string, result: { isPlaced: boolean; reason?: string } | { error: string }): void {
145  keepLast(status.opens, { at: nowIso(), from, ...result })
146  save($)
147}
148
149function noteSurfaces($: EngineInterface, surfaces: unknown): void {
150  if (JSON.stringify(surfaces) === JSON.stringify(status.surfaces)) return
151  status.surfaces = surfaces
152  save($)
153}
154
155function notePanes($: EngineInterface, panes: unknown): void {
156  if (JSON.stringify(panes) === JSON.stringify(status.panes)) return
157  status.panes = panes
158  save($)
159}
160
161// 그리기 요청이 온 곳과 크기. 같은 값이면 쓰지 않는다(그리기는 잦다).
162function noteDraw($: EngineInterface, component: string, row: StatusRow): void {
163  const before = status.draws[component]
164  if (before !== undefined && JSON.stringify({ ...before, at: '' }) === JSON.stringify({ ...row, at: '' })) return
165  status.draws[component] = { ...row, at: nowIso() }
166  save($)
167}
168
169function noteClose($: EngineInterface, origin: string): void {
170  keepLast(status.closes, { at: nowIso(), origin })
171  save($)
172}
173
174// 엔진 창을 끝에 붙인 결과. 데스크톱이 패널 굴리기를 받는지 보려고 남긴다. 같은 결과면 쓰지 않는다.
175function noteScroll($: EngineInterface, result: { deny?: string }): void {
176  const deny = result.deny ?? null
177  if (status.scroll?.deny === deny) return
178  status.scroll = { at: nowIso(), deny }
179  save($)
180}
181
182// 사람이 패널을 굴려 끝 따라가기가 바뀐 때: 그때의 창 자리와 트리 높이.
183function noteFollow($: EngineInterface, isNowFollowing: boolean, window: { offset: number; bodyRows: number; contentRows: number }): void {
184  if (status.follow?.isFollowing === isNowFollowing) return
185  status.follow = { at: nowIso(), isFollowing: isNowFollowing, ...window }
186  save($)
187}
188
189function noteError($: EngineInterface, where: string, error: unknown): void {
190  keepLast(status.errors, { at: nowIso(), where, error: String(error) })
191  save($)
192}
193
194function noteOff($: EngineInterface, reason: string): void {
195  status.off = reason
196  save($)
197}
198
199// 터미널 팔레트를 고를 /config 테마. 처음 그릴 때 한 번 읽는다(session.start에서 읽으면 첫 그리기에 늦는다).
200async function themeOf($: EngineInterface, surface: string): Promise<string | null> {
201  if (surface === 'desktop') return null
202  if (theme !== undefined) return theme
203  try {
204    const value = (await $.config.list()).find(row => row.key === 'theme')?.value
205    theme = typeof value === 'string' ? value : null
206  } catch (error) {
207    noteError($, 'config.list', error)
208    theme = null
209  }
210  return theme
211}
212
213function ignore(): void {
214  // 끄는 중의 실패는 더 할 일이 없다. 흐름을 건드리지 않도록 버린다.
215}
216
217function turnOff($: EngineInterface, error: unknown): void {
218  if (isOff) return
219  isOff = true
220  isDirty = false
221  live = null
222  try {
223    timer?.cancel()
224  } catch {
225    ignore()
226  }
227  try {
228    noteOff($, String(error))
229  } catch {
230    ignore()
231  }
232  try {
233    // 로그 실패가 흐름을 건드리지 않게 기다리지 않고, 비동기로 거절돼도 받아 버린다.
234    Promise.resolve($.ui.log(`live-viewer: turned off for this session (${String(error)})`) as unknown).catch(ignore)
235  } catch {
236    ignore()
237  }
238  try {
239    $.state.set(CALLS, { ...EMPTY, isOff: true }).catch(ignore)
240  } catch {
241    ignore()
242  }
243}
244
245function guard($: EngineInterface, work: () => void): void {
246  if (isOff) return
247  try {
248    work()
249  } catch (error) {
250    turnOff($, error)
251  }
252}
253
254const isStopped = (parser: State) => parser.phase === 'invalid' || parser.phase === 'limited'
255const isPending = (entry: Entry) => entry.status === 'streaming' || entry.status === 'running'
256const seatOf = (): View['seat'] => (isClosedByPerson ? 'hidden' : isPanePlaced ? 'pane' : 'spinner')
257
258const boxes = () => items.flatMap(item => (item.type === 'box' ? [item.entry] : []))
259
260// 그릴 창(shown)은 바뀔 때 한 번만 만든다: 본문(최대 2만 자)을 자르는 일이 무겁다.
261function put(entry: Entry): void {
262  const item: Item = { type: 'box', entry, shown: shownOf(entry) }
263  const at = items.findIndex(known => known.type === 'box' && known.entry.id === entry.id)
264  if (at !== -1) {
265    items = items.map((known, i) => (i === at ? item : known))
266    return
267  }
268  items = [...items, item].slice(-MAX_ITEMS)
269}
270
271function putLine(id: string, tool: string, args: string): void {
272  items = [...items, { type: 'line' as const, id, tool, args }].slice(-MAX_ITEMS)
273}
274
275// 상자 밖 도구의 입력이 끝났다: 할 일 도구면 할 일을 고치고, 숨길 도구가 아니면 한 줄로 싣는다. 표시가 바뀌었으면 true.
276function settleOther(): boolean {
277  const done = other
278  other = null
279  if (done === null) return false
280  if (isTodoTool(done.name)) {
281    const next = applyTodos(todos, done.name, parseRaw(done.raw))
282    if (next === null) return false
283    todos = next
284    return true
285  }
286  if (isHidden(done.name)) return false
287  putLine(done.id, done.name, lineArgs(done.name, parseRaw(done.raw), places))
288  return true
289}
290
291// 스트리밍 표시는 조각마다가 아니라 보낼 때만 다시 만든다: 본문(최대 2만 자)을 고치는 일이 무겁다.
292function settleLive(): void {
293  if (live !== null) put(streamingEntry(live.id, live.name, live.parser, !isStopped(live.parser), words))
294}
295
296// 상자는 그릴 창만 싣고 본문 원문은 비워 state를 작게 둔다. 글은 보낼 때 표시 사본으로 바꾼다.
297function shownItem(item: Item): Item {
298  if (item.type === 'box') return { ...item, entry: { ...item.entry, body: '' } }
299  if (item.type === 'line') return item
300  return { ...item, text: toDisplay(item.text, TEXT_LIMIT) }
301}
302
303function snapshot(): View {
304  return { items: items.map(shownItem), todos, isWorking, isOff, seat: seatOf() }
305}
306
307function flush($: EngineInterface): void {
308  timer?.cancel()
309  timer = null
310  if (isOff || !isDirty) return
311  isDirty = false
312  settleLive()
313  lastSentAt = performance.now()
314  sentSeq++
315  $.state.set(CALLS, snapshot()).catch(error => turnOff($, error))
316}
317
318// 33ms가 지났으면 바로 보내고, 아니면 타이머 하나로 미룬다. 모델이 쓰다 멈춰도 마지막 글자가 늦지 않게.
319function request($: EngineInterface): void {
320  isDirty = true
321  if (performance.now() - lastSentAt >= SEND_GAP_MS) {
322    flush($)
323    return
324  }
325  timer ??= $.clock.after(SEND_GAP_MS, () => guard($, () => flush($)))
326}
327
328// 자리가 바뀌면 진행 줄 쪽 그림을 켜고 끄는 일이라 미루지 않고 보낸다.
329function setPlaced($: EngineInterface, isPlaced: boolean): void {
330  if (isPlaced === isPanePlaced) return
331  isPanePlaced = isPlaced
332  isDirty = true
333  flush($)
334}
335
336// 그린 트리가 패널보다 길어지는 화면(줄 높이를 다르게 재는 곳)에서도 새 줄이 보이게, 엔진 창을 끝에 붙인다.
337// 끝에 붙은 창은 사람이 다음에 옮길 때까지 늘어나는 트리를 따라간다.
338function followEnd($: EngineInterface): void {
339  $.ui.scroll({ in: PANE, to: 'end' }).then(
340    result => noteScroll($, result),
341    error => noteError($, 'ui.scroll', error),
342  )
343}
344
345// 탭 제목·명령 설명이 나가기 전에 한 번만 읽는다. 못 읽으면 시간대로 정한 글자를 그대로 쓴다.
346function settleLanguage($: EngineInterface): Promise<void> {
347  languageReady ??= (async () => {
348    if (languageChoice === 'en' || languageChoice === 'ko') return
349    const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
350    words = wordsOf(languageOf(languageChoice, locale, timeZone()))
351  })().catch(error => noteError($, 'language', error))
352  return languageReady
353}
354
355function openPane($: EngineInterface, from: string): Promise<{ isPlaced: boolean; reason?: string } | null> {
356  try {
357    return $.ui.open({ id: PANE, title: words.title }).then(
358      result => {
359        noteOpen($, from, result)
360        guard($, () => setPlaced($, result.isPlaced))
361        return result
362      },
363      error => {
364        noteOpen($, from, { error: String(error) })
365        return null
366      },
367    )
368  } catch (error) {
369    noteOpen($, from, { error: String(error) })
370    return Promise.resolve(null)
371  }
372}
373
374// 이 mod의 탭이 다른 플러그인 탭 뒤에 숨어 있으면 한 번 닫았다 다시 연다. 닫았다 연 패널만 보이는 탭이 된다
375// (2026-10-17 headless probe). session.start는 바뀐 모듈을 다시 불러올 때도 오므로 열린 세션도 업데이트 때 고쳐진다.
376async function bringForward($: EngineInterface): Promise<void> {
377  const panes = await $.ui.panes()
378  const pane = panes.find(other => other.id === PANE)
379  if (pane === undefined || !pane.isPlaced || panes.some(other => other.isShown) || isClosedByPerson) return
380  await $.ui.close({ id: PANE })
381  await openPane($, 'bring-forward')
382}
383
384// 화면이 없으면 열지 않는다. 이미 놓였거나 사람이 닫았으면 다시 열지 않는다.
385async function openTabs($: EngineInterface, from: string): Promise<void> {
386  try {
387    if ((await $.session.surfaces()).length === 0) return
388  } catch (error) {
389    noteError($, 'session.surfaces', error)
390    return
391  }
392  await settleLanguage($)
393  if (!isOff && !isPanePlaced && !isClosedByPerson) await openPane($, from)
394}
395
396function prepare($: EngineInterface): void {
397  try {
398    $.session.surfaces().then(list => noteSurfaces($, list), ignore)
399    // 경로 줄이기는 보기 좋으라고 하는 일이라 못 읽으면 전체 경로를 그대로 그린다.
400    $.session.cwd().then(cwd => (places = { ...places, cwd }), ignore)
401    $.env.get('HOME').then(home => (places = { ...places, home: home ?? null }), ignore)
402    if (isCommandReady) return
403    isCommandReady = true
404    void settleLanguage($).then(() => $.command.register({ name: 'live', description: words.command }).catch(error => noteError($, 'command.register', error)))
405  } catch (error) {
406    noteError($, 'prepare', error)
407  }
408}
409
410const isChanged = (before: State, after: State) =>
411  after.values.file_path !== before.values.file_path ||
412  after.values.content !== before.values.content ||
413  after.values.old_string !== before.values.old_string ||
414  after.values.new_string !== before.values.new_string ||
415  after.values.command !== before.values.command ||
416  isStopped(after)
417
418// turn.step 호출 하나의 관찰부. step은 이 단계를 가리키는 글자(답·생각 항목 id 앞머리). replay 시험이 따로 재려고 내보낸다.
419export function watcher($: EngineInterface, step = ''): (chunk: TurnStepChunk) => void {
420  let call: Call | null = null
421
422  const start = (index: number, id: string, name: string) => {
423    // 새 호출이 시작되면 앞 호출의 입력은 다 왔다. 마지막 표시를 만들어 두고 놓는다.
424    settleLive()
425    live = null
426    call = null
427    if (settleOther()) request($)
428    if (!PREVIEWED.has(name)) {
429      other = { index, id, name, raw: '' }
430      return
431    }
432    call = { index, id, name, parser: initialState() }
433    live = call
434    request($)
435    // 패널 자리는 호출마다 다시 본다: 사람이 닫았거나 화면이 바뀌었을 수 있다.
436    $.ui
437      .panes()
438      .then(panes => {
439        notePanes($, panes)
440        guard($, () => setPlaced($, panes.some(pane => pane.id === PANE && pane.isPlaced)))
441      })
442      .catch(error => turnOff($, error))
443  }
444
445  const input = (index: number, json: string) => {
446    // 상한을 넘으면 더 모으지 않는다. 풀 때 넘은 것을 보고 이름만 둔다.
447    if (other !== null && other.index === index && other.raw.length <= RAW_LIMIT) other.raw += json
448    const current = call
449    if (current === null || current.index !== index || isStopped(current.parser)) return
450    const before = current.parser
451    current.parser = feed(before, json)
452    if (isChanged(before, current.parser)) request($)
453  }
454
455  return chunk =>
456    guard($, () => {
457      if (chunk.kind === 'tool') start(chunk.index, chunk.id, chunk.name)
458      else if (chunk.kind === 'input') input(chunk.index, chunk.json)
459      else if (chunk.kind === 'text' || chunk.kind === 'thinking') {
460        if (chunk.text === '') return
461        items = appendText(items, chunk.kind, `${step}/${chunk.index}`, chunk.text, MAX_ITEMS)
462        request($)
463      }
464    })
465}
466
467function markRunning($: EngineInterface, id: string): void {
468  if (live?.id === id) {
469    settleLive()
470    live = null
471  }
472  const entry = boxes().find(known => known.id === id)
473  if (entry === undefined) return
474  put({ ...entry, status: 'running' })
475  request($)
476}
477
478function markSettled($: EngineInterface, id: string, answer: Answer, elapsedMs: number): void {
479  const entry = boxes().find(known => known.id === id)
480  if (entry === undefined) return
481  put(settledEntry(entry, answer, elapsedMs, words))
482  request($)
483}
484
485// 미리 본 호출이면 실행 중으로 바꾸고, 끝난 답을 그 호출에 붙인다. 답은 손대지 않고 그대로 돌려준다.
486async function watchCall<R>($: EngineInterface, id: string | undefined, run: () => Promise<R>): Promise<R> {
487  const isWatched = !isOff && id !== undefined && (live?.id === id || boxes().some(entry => entry.id === id))
488  const startedAt = performance.now()
489  if (isWatched) guard($, () => markRunning($, id))
490  const answer = await run()
491  if (isWatched) guard($, () => markSettled($, id, answer as Answer, performance.now() - startedAt))
492  return answer
493}
494
495export const register: Register = (on, options) => {
496  languageChoice = options.language
497  languageReady = null
498  words = wordsOf(languageOf(languageChoice, undefined, timeZone()))
499  paletteChoice = options.palette
500  setDebug(options.debug === true)
501  // 화면이 붙어 있을 때만 연다. 다시 띄운 세션은 앱 화면이 붙기 전에 시작해, 그때 열면
502  // 'no-surface' 오류가 사용자에게 뜬다. 그런 세션은 session.attach에서 연다.
503  on('session.start', ($, e, next) => {
504    void openTabs($, 'session.start')
505    prepare($)
506    $.clock.after(BRING_FORWARD_MS, () => void bringForward($).catch(error => noteError($, 'bring-forward', error)))
507    return next(e)
508  })
509
510  on('session.attach', ($, e, next) => {
511    void openTabs($, `session.attach:${e.surface}`)
512    return next(e)
513  })
514
515  // 사람이 친 프롬프트에서 다시 열면 폭과 상관없이 놓인다(데스크톱은 엔진이 sdk로 받아 다를 수 있어 상태 파일로 본다).
516  on('prompt.submit', ($, e, next) => {
517    isFollowing = true
518    void openTabs($, `prompt.submit:${e.origin.kind}`)
519    prepare($)
520    return next(e)
521  })
522
523  on('command.run', { command: 'live' }, async $ => {
524    isClosedByPerson = false
525    await settleLanguage($)
526    const result = await openPane($, 'command')
527    guard($, () => {
528      isDirty = true
529      flush($)
530    })
531    if (result === null) return { text: words.notOpened }
532    return { text: result.isPlaced ? words.opened : words.notPlaced(result.reason ?? '-') }
533  })
534
535  on('ui.close', ($, e, next) => {
536    if (e.id === PANE) {
537      noteClose($, e.origin.kind)
538      if (e.origin.kind === 'person') isClosedByPerson = true
539      guard($, () => {
540        isPanePlaced = false
541        isDirty = true
542        flush($)
543      })
544    }
545    return next(e)
546  })
547
548  on('turn.step', async function* ($, e, next) {
549    if (e.agentId || isOff) return yield* next(e)
550    guard($, () => {
551      if (isWorking) return
552      isWorking = true
553      request($)
554    })
555    const observe = watcher($, `${e.turnId}/${e.index}`)
556    const stream = next(e)
557    try {
558      for await (const chunk of stream) {
559        yield chunk
560        observe(chunk)
561      }
562    } finally {
563      // 흐름이 끝나거나 끊기면 미룬 최신 것을 바로 보낸다. 이 단계의 호출 입력은 더 오지 않는다.
564      guard($, () => {
565        if (settleOther()) isDirty = true
566        flush($)
567        live = null
568      })
569    }
570    return await stream.result
571  })
572
573  on('tool.call', { tool: 'Write' }, ($, e, next) => watchCall($, e.tool_use_id, () => next(e)))
574  on('tool.call', { tool: 'Edit' }, ($, e, next) => watchCall($, e.tool_use_id, () => next(e)))
575  on('tool.call', { tool: 'Bash' }, ($, e, next) => watchCall($, e.tool_use_id, () => next(e)))
576
577  // 턴이 끝났는데 아직 쓰는 중·실행 중인 호출은 중단된 것이다(Esc, 거절 전 끊김).
578  on('turn.complete', ($, e, next) => {
579    if (!e.agentId) {
580      guard($, () => {
581        settleLive()
582        live = null
583        isWorking = false
584        request($)
585        const stopped = boxes().filter(isPending)
586        if (stopped.length === 0) return
587        for (const entry of stopped) put({ ...entry, status: 'failed', meta: entry.meta === '' ? words.interrupted : `${entry.meta} · ${words.interrupted}` })
588        request($)
589      })
590    }
591    return next(e)
592  })
593
594  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
595    const { placement, bodyColumns, scroll } = e.props
596    noteDraw($, 'Pane', { surface: e.surface, placement, bodyColumns, bodyRows: scroll.bodyRows, viewport: e.viewport ?? null })
597    // 안 놓인 패널에는 ui.render가 오지 않으므로, 그리기 요청이 왔다면 놓인 것이다. 그리기 중의 state.set은 엔진이
598    // 막아(그리기는 읽기만, 2026-10-17 관찰이 꺼진 원인) 다음 차례로 미룬다.
599    if (!isPanePlaced) $.clock.after(0, () => guard($, () => setPlaced($, true)))
600    const shown = await read($, calls)
601    // 바탕은 터미널에서만 칠한다(auto 팔레트가 /config 테마를 따라 호스트 글자색과 맞는다). 데스크톱은 앱 밝기를 몰라 칠하면
602    // 다크 앱에서 글자가 묻힌다. 대화 옆에 붙은 패널은 바닥까지 채우고, 프롬프트 위 패널은 그린 만큼만 칠한다.
603    const pageRows = e.surface !== 'terminal' ? null : placement === 'dock' ? scroll.bodyRows : 0
604    const layout: Layout = { surface: e.surface, columns: bodyColumns, rows: Math.min(PANE_ROWS_LIMIT, scroll.bodyRows), palette: paletteOf(paletteChoice, e.surface, await themeOf($, e.surface)), places, words, pageRows }
605    // 새 그림이면 그린 뒤 끝에 붙인다. 그리기 중에는 엔진 호출을 미룬다(위 state.set과 같은 까닭).
606    if (isFollowing && followedSeq !== sentSeq) {
607      followedSeq = sentSeq
608      $.clock.after(0, () => guard($, () => followEnd($)))
609    }
610    return drawView($.ui.resolve(e), shown, layout)
611  })
612
613  // 사람이 위로 굴리면 끝 따라가기를 멈추고, 끝까지 내리면 다시 따라간다. 굴리기 자체는 엔진에 그대로 넘긴다.
614  on('ui.scroll', { requestId: PANE }, ($, e, next) => {
615    if (e.origin.kind === 'person') {
616      guard($, () => {
617        isFollowing = e.offset >= e.contentRows - e.bodyRows
618        noteFollow($, isFollowing, { offset: e.offset, bodyRows: e.bodyRows, contentRows: e.contentRows })
619      })
620    }
621    return next(e)
622  })
623
624  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
625    noteDraw($, 'Spinner', { surface: e.surface, viewport: e.viewport ?? null })
626    const shown = await read($, calls)
627    const current = shown.items.at(-1)
628    if (shown.isOff || shown.seat !== 'spinner' || current?.type !== 'box' || !isPending(current.entry)) return next(e)
629    const els = $.ui.resolve(e)
630    const { Box } = els
631    const layout: Layout = { surface: e.surface, columns: e.viewport?.columns ?? SPINNER_COLUMNS, rows: SPINNER_ROWS, palette: paletteOf(paletteChoice, e.surface, await themeOf($, e.surface)), places, words, pageRows: null }
632    const spinner = await next(e)
633    return (
634      <Box flexDirection="column">
635        {drawBox(els, current.entry, current.shown, layout)}
636        {spinner}
637      </Box>
638    )
639  })
640}
641
hooks/draw.tsx 391 lines
1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type { Entry, Item, Shown, Todo, View } from '../types'
4import { type Places, shortPath } from './feed'
5import type { Words } from './words'
6
7// omp(oh-my-pi 18.11) 화면을 본뜬 그림. 흐름을 위에서 아래로 시간순으로 그린다: Write·Edit·Bash는 상자,
8// 그 밖 도구는 `● 도구 인자` 한 줄, 생각은 이탤릭, 답은 Markdown. 맨 아래에 할 일 나무와 진행 줄. 호스트가 테두리 위에 제목을 못 그려(2026-10-15 확인 시험) 상자 윗줄·아랫줄은
9// 글자로 그리고 본문은 배경만 칠한다.
10
11const SPIN = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']
12const SPIN_FRAME_MS = 120
13
14// omp 설치본의 light·titanium 테마 값. 코드 글자색은 호스트 테마를 따르므로 배경도 테마에 맞춰 고른다.
15// page·text는 omp 테마의 바탕과 글자색(terminal.background·foreground)이다.
16export type Palette = {
17  page: string
18  text: string
19  pending: string
20  done: string
21  failed: string
22  rule: string
23  dim: string
24  thinking: string
25  accent: string
26  success: string
27  error: string
28}
29export const LIGHT: Palette = {
30  page: '#f8f8f8',
31  text: '#3b3b3b',
32  pending: '#e8e8f0',
33  done: '#e8f0e8',
34  failed: '#f0e8e8',
35  rule: '#b0b0b0',
36  dim: '#767676',
37  thinking: '#6c6c6c',
38  accent: '#5a8080',
39  success: '#588458',
40  error: '#aa5555',
41}
42export const TITANIUM: Palette = {
43  page: '#151820',
44  text: '#e8ecf4',
45  pending: '#0f1216',
46  done: '#0f1216',
47  failed: '#1a0f10',
48  rule: '#2a3038',
49  dim: '#6b7280',
50  thinking: '#9ca3b0',
51  accent: '#00b4ff',
52  success: '#00ff88',
53  error: '#ff4757',
54}
55
56// 설정 `palette`가 light·dark면 그대로. auto는 데스크톱이면 light(앱이 CLI에 밝기를 넘기지 않는다), 터미널은 /config 테마를 따른다.
57export function paletteOf(choice: unknown, surface: string, theme: string | null): Palette {
58  if (choice === 'light') return LIGHT
59  if (choice === 'dark') return TITANIUM
60  return surface === 'desktop' || theme?.startsWith('light') === true ? LIGHT : TITANIUM
61}
62
63// 그릴 자리: 화면, 줄 폭, 쓸 수 있는 줄 수, 팔레트, 경로를 줄일 곳, 글자, 바탕을 칠할 최소 줄 수(null이면 안 칠함).
64export type Layout = { surface: string; columns: number; rows: number; palette: Palette; places: Places; words: Words; pageRows: number | null }
65
66const isPending = (entry: Entry) => entry.status === 'streaming' || entry.status === 'running'
67
68// 바탕을 칠한 패널에서는 색 없던 글자도 팔레트 글자색으로 칠한다. 호스트 기본 글자색은 터미널 프로필을 따라
69// /config 테마와 어긋날 수 있다(테마 dark·흰 바탕 프로필이면 검은 글자가 어두운 바탕에 묻힌다). 안 칠한 곳은 호스트 색 그대로.
70const inkOf = (layout: Layout) => (layout.pageRows === null ? undefined : layout.palette.text)
71
72// 한글·한자·전각·이모지는 두 칸을 차지한다.
73function isWide(code: number): boolean {
74  return (
75    (code >= 0x1100 && code <= 0x115f) ||
76    (code >= 0x2e80 && code <= 0xa4cf) ||
77    (code >= 0xac00 && code <= 0xd7a3) ||
78    (code >= 0xf900 && code <= 0xfaff) ||
79    (code >= 0xfe30 && code <= 0xfe4f) ||
80    (code >= 0xff00 && code <= 0xff60) ||
81    (code >= 0xffe0 && code <= 0xffe6) ||
82    (code >= 0x1f300 && code <= 0x1faff) ||
83    code >= 0x20000
84  )
85}
86
87export function cellWidth(text: string): number {
88  let width = 0
89  for (const char of text) width += isWide(char.codePointAt(0) ?? 0) ? 2 : 1
90  return width
91}
92
93// 앞을 잘라 width칸에 맞춘다(경로는 끝이 중요하다).
94function keepEnd(text: string, width: number): string {
95  if (cellWidth(text) <= width) return text
96  const chars = [...text]
97  while (chars.length > 0 && cellWidth(`…${chars.join('')}`) > width) chars.shift()
98  return `…${chars.join('')}`
99}
100
101function markOf(entry: Entry): string {
102  if (entry.status === 'done') return entry.tool === 'Bash' ? '✓' : '✎'
103  if (entry.status === 'failed') return '✘'
104  // 그릴 때마다 한 칸: 조각이 오는 동안 돌고, 모델이 멈추면 같이 멈춘다.
105  return SPIN[Math.floor(Date.now() / SPIN_FRAME_MS) % SPIN.length] ?? '·'
106}
107
108const markColor = (entry: Entry, palette: Palette) => (entry.status === 'failed' ? palette.error : entry.status === 'done' ? palette.success : palette.accent)
109const backgroundOf = (entry: Entry, palette: Palette) => (entry.status === 'failed' ? palette.failed : entry.status === 'done' ? palette.done : palette.pending)
110
111// 윗줄 `╭── ✎ Edit: 📁경로 [+1] ───╮`의 조각. 폭이 모자라면 제목 앞을 자르고, 채움은 늘 한 칸 이상.
112export function topParts(entry: Entry, columns: number, places: Places) {
113  const isBash = entry.tool === 'Bash'
114  const mark = markOf(entry)
115  const label = isBash ? '$' : `${entry.tool}:`
116  const tail = entry.meta === '' ? '' : ` ${entry.meta}`
117  const title = isBash || entry.title === '' ? entry.title : `📁${shortPath(entry.title, places)}`
118  // `╭── ` 4칸, 기호, ` `+label, ` `+제목, 꼬리, ` `+채움, `╮` 1칸.
119  const fixed = 4 + cellWidth(mark) + 1 + cellWidth(label) + 1 + cellWidth(tail) + 1 + 1
120  const shownTitle = keepEnd(title, Math.max(1, columns - fixed - 1))
121  const fill = Math.max(1, columns - fixed - cellWidth(shownTitle))
122  return { mark, label, title: shownTitle, tail, fill: `${'─'.repeat(fill)}╮` }
123}
124
125// 윗줄·아랫줄의 한 조각. color가 없으면 기본 글자색.
126type Span = { text: string; color?: string; isBold?: boolean }
127
128// 칸 SVG: 한 칸 CELL×ROW, 글자는 칸 가운데, 선 글자는 칸을 끝까지 잇는 선으로 그린다(글꼴마다 선 글자 폭이 달라 끊긴다).
129const CELL = 10
130const ROW = 20
131const BASELINE = 15
132const BEND = 3
133const MONO_FONT = 'ui-monospace, SFMono-Regular, Menlo, monospace'
134// 마크업 폭을 칸 수의 두 배 px로 두어 늘 슬롯보다 넓게, 곧 슬롯 폭에 맞춰 줄여 그리게 한다.
135const MARKUP_SCALE = 2
136// 색이 없는 조각의 글자색: 앱의 밝은·어두운 화면 글자색. 그림 안이라 호스트 글자색을 물려받지 못한다.
137const INK_STYLE = '.ink{fill:#0b0b0b}.ink-line{stroke:#0b0b0b}@media (prefers-color-scheme:dark){.ink{fill:#f0efec}.ink-line{stroke:#f0efec}}'
138
139const HALF = CELL / 2
140const MID = ROW / 2
141const RULE_PATHS: Record<string, (x: number) => string> = {
142  '─': x => `M${x} ${MID}H${x + CELL}`,
143  '╭': x => `M${x + CELL} ${MID}H${x + HALF + BEND}Q${x + HALF} ${MID} ${x + HALF} ${MID + BEND}V${ROW}`,
144  '╮': x => `M${x} ${MID}H${x + HALF - BEND}Q${x + HALF} ${MID} ${x + HALF} ${MID + BEND}V${ROW}`,
145  '╰': x => `M${x + HALF} 0V${MID - BEND}Q${x + HALF} ${MID} ${x + HALF + BEND} ${MID}H${x + CELL}`,
146  '╯': x => `M${x + HALF} 0V${MID - BEND}Q${x + HALF} ${MID} ${x + HALF - BEND} ${MID}H${x}`,
147}
148
149const escapeXml = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
150
151// 조각들을 columns칸 격자에 놓은 SVG. 글자 하나가 cellWidth만큼 칸을 차지해 topParts의 칸 셈과 같다.
152export function ruleSvg(spans: readonly Span[], columns: number): string {
153  const width = columns * CELL
154  const parts: string[] = []
155  let cell = 0
156  for (const span of spans) {
157    let path = ''
158    const glyphs: string[] = []
159    for (const char of span.text) {
160      const cells = cellWidth(char)
161      const x = cell * CELL
162      const rule = RULE_PATHS[char]
163      if (rule !== undefined) path += rule(x)
164      else if (char !== ' ') glyphs.push(`<text x="${x + (cells * CELL) / 2}" y="${BASELINE}">${escapeXml(char)}</text>`)
165      cell += cells
166    }
167    const stroke = span.color === undefined ? 'class="ink-line"' : `stroke="${span.color}"`
168    const fill = span.color === undefined ? 'class="ink"' : `fill="${span.color}"`
169    if (path !== '') parts.push(`<path d="${path}" fill="none" ${stroke} stroke-width="1.2"/>`)
170    if (glyphs.length > 0) parts.push(`<g ${fill}${span.isBold === true ? ' font-weight="700"' : ''}>${glyphs.join('')}</g>`)
171  }
172  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width * MARKUP_SCALE}" height="${ROW * MARKUP_SCALE}" viewBox="0 0 ${width} ${ROW}" font-family="${MONO_FONT}" font-size="16" text-anchor="middle"><style>${INK_STYLE}</style>${parts.join('')}</svg>`
173}
174
175// 윗줄·아랫줄은 칸 수로 폭을 채우므로 고정폭이어야 맞는다. 터미널은 늘 고정폭이라 글자 그대로 그린다. 데스크톱 Text는
176// 비례 글꼴이고 고정폭(Text mono)은 Claude Code 2.1.299부터라 데스크톱은 칸 SVG로 그린다. Svg는 마크업이 슬롯보다 넓으면
177// 슬롯 폭에 맞춰 비율대로 줄어(Svg width 문서), 한 칸이 패널 한 칸이 된다.
178function drawRule(els: ElementTable, spans: readonly Span[], layout: Layout): RenderElement {
179  if (layout.surface === 'desktop' && 'Svg' in els) {
180    const { Svg } = els
181    return <Svg source={ruleSvg(spans, layout.columns)} alt={spans.map(span => span.text).join('')} />
182  }
183  const { Box, Text } = els
184  return (
185    <Box flexDirection="row">
186      {spans.map((span, i) => (
187        <Text key={`span-${i}`} color={span.color ?? inkOf(layout)} bold={span.isBold}>
188          {span.text}
189        </Text>
190      ))}
191    </Box>
192  )
193}
194
195function drawTop(els: ElementTable, entry: Entry, layout: Layout): RenderElement {
196  const { palette } = layout
197  const parts = topParts(entry, layout.columns, layout.places)
198  const spans: Span[] = [
199    { text: '╭── ', color: palette.rule },
200    { text: parts.mark, color: markColor(entry, palette) },
201    { text: ` ${parts.label}`, isBold: true },
202    { text: ` ${parts.title}` },
203    ...(parts.tail === '' ? [] : [{ text: parts.tail, color: palette.dim }]),
204    { text: ` ${parts.fill}`, color: palette.rule },
205  ]
206  return drawRule(els, spans, layout)
207}
208
209function drawCode(els: ElementTable, entry: Entry, shown: Shown): RenderElement | null {
210  const { Code } = els
211  if (shown.source.trim() === '') return null
212  const path = entry.path ?? undefined
213  if (entry.kind === 'diff') return <Code source={shown.source} path={path} format="diff" wrap="wrap" />
214  if (entry.kind === 'code') return <Code source={shown.source} path={path} startLine={shown.startLine ?? undefined} wrap="wrap" />
215  return <Code source={shown.source} wrap="wrap" />
216}
217
218// 상자 하나: 윗줄, 배경을 칠한 본문(접힌 줄 수·코드·쓰는 중 표시), 아랫줄.
219export function drawBox(els: ElementTable, entry: Entry, shown: Shown, layout: Layout): RenderElement {
220  const { Box, Text } = els
221  const { palette, columns } = layout
222  const footer = entry.status === 'streaming' ? 'streaming' : entry.status === 'running' ? 'running' : ''
223  return (
224    <Box key={entry.id} flexDirection="column">
225      {drawTop(els, entry, layout)}
226      <Box flexDirection="column" backgroundColor={backgroundOf(entry, palette)} paddingX={1}>
227        {shown.earlier > 0 && <Text color={palette.dim}>{`… (${shown.earlier} earlier lines)`}</Text>}
228        {drawCode(els, entry, shown)}
229        {shown.more > 0 && <Text color={palette.dim}>{`… ${shown.more} more lines`}</Text>}
230        {footer !== '' && <Text color={palette.dim}>{`${markOf(entry)} … (${footer})`}</Text>}
231      </Box>
232      {drawRule(els, [{ text: `╰${'─'.repeat(Math.max(0, columns - 2))}╯`, color: palette.rule }], layout)}
233    </Box>
234  )
235}
236
237function drawLine(els: ElementTable, item: Extract<Item, { type: 'line' }>, layout: Layout): RenderElement {
238  const { Box, Text } = els
239  return (
240    <Box key={item.id} flexDirection="row" columnGap={1}>
241      <Text bold color={inkOf(layout)}>{`● ${item.tool}`}</Text>
242      {item.args !== '' && (
243        <Text color={layout.palette.dim} wrap="truncate-end">
244          {item.args}
245        </Text>
246      )}
247    </Box>
248  )
249}
250
251// 답은 Markdown으로 그리되, 바탕을 칠한 패널에서는 Markdown이 글자색을 받지 않아(MarkdownProps에 color 없음) 팔레트 글자색의 글로 그린다.
252function drawWords(els: ElementTable, item: Extract<Item, { type: 'text' | 'thinking' }>, layout: Layout): RenderElement {
253  const { Box, Markdown, Text } = els
254  const ink = inkOf(layout)
255  return (
256    <Box key={`${item.type}:${item.id}`} flexDirection="column">
257      {item.type === 'thinking' ? (
258        <Text italic color={layout.palette.thinking}>
259          {item.text}
260        </Text>
261      ) : ink === undefined ? (
262        <Markdown text={item.text} />
263      ) : (
264        <Text color={ink}>{item.text}</Text>
265      )}
266    </Box>
267  )
268}
269
270function drawItem(els: ElementTable, item: Item, layout: Layout): RenderElement {
271  if (item.type === 'box') return drawBox(els, item.entry, item.shown, layout)
272  if (item.type === 'line') return drawLine(els, item, layout)
273  return drawWords(els, item, layout)
274}
275
276// 나무에 보일 할 일 수. 넘으면 진행 중 앞뒤로 이만큼과 `… N more`.
277const TODO_ROWS = 8
278const TODO_MARK: Record<Todo['status'], string> = { pending: '□', in_progress: '■', completed: '✓' }
279
280function todoWindow(todos: readonly Todo[]): { start: number; shown: readonly Todo[] } {
281  if (todos.length <= TODO_ROWS) return { start: 0, shown: todos }
282  const current = Math.max(0, todos.findIndex(todo => todo.status === 'in_progress'))
283  const start = Math.min(Math.max(0, current - 2), todos.length - TODO_ROWS)
284  return { start, shown: todos.slice(start, start + TODO_ROWS) }
285}
286
287function drawTodos(els: ElementTable, todos: readonly Todo[], layout: Layout): RenderElement {
288  const { Box, Text } = els
289  const { palette } = layout
290  const done = todos.filter(todo => todo.status === 'completed').length
291  const { start, shown } = todoWindow(todos)
292  // 창 위·아래로 접힌 할 일 수.
293  const above = start
294  const below = todos.length - start - shown.length
295  return (
296    <Box key="todos" flexDirection="column" marginTop={1}>
297      <Text>
298        <Text color={palette.accent} bold>
299          TODO
300        </Text>
301        <Text color={palette.dim}>{` · ${done}/${todos.length}`}</Text>
302      </Text>
303      {above > 0 && <Text color={palette.dim}>{`├─ … ${above} more`}</Text>}
304      {shown.map((todo, i) => {
305        const isLast = i === shown.length - 1 && below === 0
306        const color = todo.status === 'completed' ? palette.dim : inkOf(layout)
307        return (
308          <Text key={`todo:${start + i}`}>
309            <Text color={palette.accent}>{isLast ? '└─ ' : '├─ '}</Text>
310            <Text color={color} bold={todo.status === 'in_progress'}>{`${TODO_MARK[todo.status]} ${todo.content}`}</Text>
311          </Text>
312        )
313      })}
314      {below > 0 && <Text color={palette.dim}>{`└─ … ${below} more`}</Text>}
315    </Box>
316  )
317}
318
319// 턴이 도는 동안 진행 중 할 일을 스피너와 함께. 진행 중 할 일이 없으면 그리지 않는다.
320function workingWords(view: View): string | null {
321  if (!view.isWorking) return null
322  const current = view.todos.find(todo => todo.status === 'in_progress')
323  return current === undefined ? null : (current.activeForm ?? current.content)
324}
325
326const spinFrame = () => SPIN[Math.floor(Date.now() / SPIN_FRAME_MS) % SPIN.length] ?? '·'
327
328const lineCount = (text: string) => (text === '' ? 0 : text.split('\n').length)
329
330// 글이 차지할 줄 수 어림: 줄마다 폭으로 나눈 줄바꿈까지 센다.
331const wrappedRows = (text: string, columns: number) => text.split('\n').reduce((rows, line) => rows + Math.max(1, Math.ceil(cellWidth(line) / Math.max(1, columns))), 0)
332
333// 항목이 차지할 줄 수 어림. 상자 안 긴 줄의 줄바꿈은 세지 않는다.
334function rowsOf(item: Item, columns: number): number {
335  if (item.type === 'line') return 1
336  if (item.type !== 'box') return wrappedRows(item.text, columns)
337  const { shown, entry } = item
338  return 2 + lineCount(shown.source) + (shown.earlier > 0 ? 1 : 0) + (shown.more > 0 ? 1 : 0) + (isPending(entry) ? 1 : 0)
339}
340
341// 끝에서부터 rows줄에 드는 항목만. 마지막 항목은 늘 넣는다.
342function fit(items: readonly Item[], rows: number, columns: number): readonly Item[] {
343  let used = 0
344  let start = items.length
345  while (start > 0) {
346    const next = rowsOf(items[start - 1] as Item, columns)
347    if (start < items.length && used + next > rows) break
348    used += next
349    start--
350  }
351  return items.slice(start)
352}
353
354// 패널 전체: 흐름, 할 일 나무, 진행 줄. 나무와 진행 줄 자리를 먼저 빼고 남는 줄에 흐름을 채운다.
355// 패널 본문 전체. 맨 겉 바탕은 omp처럼 팔레트 바탕색(page)으로 칠한다: 상자 바탕은 그보다 어둡고(밝고) 선은 그 반대라,
356// 안 칠하면 호스트 바탕(다크 테마의 검정)에 상자와 선이 묻힌다. pageRows가 null이면 칠하지 않는다.
357export function drawView(els: ElementTable, view: View, layout: Layout): RenderElement {
358  const body = drawViewBody(els, view, layout)
359  if (layout.pageRows === null) return body
360  const { Box } = els
361  return (
362    <Box flexDirection="column" backgroundColor={layout.palette.page} minHeight={layout.pageRows}>
363      {body}
364    </Box>
365  )
366}
367
368function drawViewBody(els: ElementTable, view: View, layout: Layout): RenderElement {
369  const { Box, Text } = els
370  const { palette, words } = layout
371  if (view.isOff) return <Text color={palette.dim}>{words.off}</Text>
372  if (view.items.length === 0 && view.todos.length === 0) return <Text color={palette.dim}>{words.empty}</Text>
373  const working = workingWords(view)
374  const todoRows = view.todos.length === 0 ? 0 : 2 + Math.min(view.todos.length, TODO_ROWS + 2)
375  const rows = Math.max(1, layout.rows - todoRows - (working === null ? 0 : 2))
376  return (
377    <Box flexDirection="column">
378      {fit(view.items, rows, layout.columns).map(item => drawItem(els, item, layout))}
379      {view.todos.length > 0 && drawTodos(els, view.todos, layout)}
380      {working !== null && (
381        <Box key="working" marginTop={1}>
382          <Text>
383            <Text color={palette.accent}>{`${spinFrame()} `}</Text>
384            <Text color={inkOf(layout)}>{working}</Text>
385          </Text>
386        </Box>
387      )}
388    </Box>
389  )
390}
391
hooks/entry.ts 248 lines
1import type { Entry, Shown } from '../types'
2import { toDisplay } from './display'
3import type { State } from './partial'
4import { EN, type Words } from './words'
5
6// 호출 하나의 표시(Entry)를 만들고 그릴 만큼 자르는 순수 함수들. 흐름 쪽(register)은 이것만 불러 쓴다.
7
8const PATH_LIMIT = 4096
9const CONTEXT_LINES = 3
10const NO_LIMIT = Number.POSITIVE_INFINITY
11
12type Values = State['values']
13
14export type Change = { text: string; added: number; removed: number }
15
16// 마지막 줄바꿈 뒤 빈 줄은 줄이 아니라 끝 표시라 뺀다.
17function linesOf(text: string): string[] {
18  if (text === '') return []
19  const lines = text.split('\n')
20  if (lines.length > 1 && lines.at(-1) === '') lines.pop()
21  return lines
22}
23
24// 앞뒤로 같은 줄은 3줄까지 문맥으로 두고 가운데를 -/+로 쓴 hunk 하나. new가 아직 안 왔으면 null.
25export function hunk(before: string, after: string | null): Change {
26  const a = linesOf(before)
27  const b = after === null ? [] : linesOf(after)
28  let head = 0
29  while (head < a.length && head < b.length && a[head] === b[head]) head++
30  let tail = 0
31  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
32  const lead = Math.min(head, CONTEXT_LINES)
33  const trail = Math.min(tail, CONTEXT_LINES)
34  const removed = a.slice(head, a.length - tail)
35  const added = b.slice(head, b.length - tail)
36  const lines = [
37    ...a.slice(head - lead, head).map(line => ` ${line}`),
38    ...removed.map(line => `-${line}`),
39    ...added.map(line => `+${line}`),
40    ...a.slice(a.length - tail, a.length - tail + trail).map(line => ` ${line}`),
41  ]
42  if (lines.length === 0) return { text: '', added: 0, removed: 0 }
43  const start = head - lead + 1
44  const header = `@@ -${start},${lead + removed.length + trail} +${start},${lead + added.length + trail} @@`
45  return { text: `${header}\n${lines.join('\n')}`, added: added.length, removed: removed.length }
46}
47
48// omp처럼 0인 쪽은 뺀다: [+3 -1], [+2], [-1].
49export function changeMeta(change: Change): string {
50  const parts = [change.added > 0 ? `+${change.added}` : '', change.removed > 0 ? `-${change.removed}` : ''].filter(part => part !== '')
51  return parts.length === 0 ? '' : `[${parts.join(' ')}]`
52}
53
54// turn.step 조각을 풀이한 값으로, 모델이 아직 쓰고 있는 호출의 표시를 만든다.
55export function streamingEntry(id: string, tool: string, parser: State, isReadable: boolean, words: Words = EN): Entry {
56  const values: Values = parser.values
57  const path = values.file_path === undefined ? null : toDisplay(values.file_path, PATH_LIMIT)
58  const base: Entry = { id, tool, status: 'streaming', title: path ?? '', path, kind: 'none', body: '', lastLine: null, meta: '' }
59  if (tool === 'Bash') {
60    const command = values.command === undefined ? '' : toDisplay(values.command, PATH_LIMIT)
61    return { ...base, title: parser.truncated.command ? `${command} …` : command, path: null }
62  }
63  if (!isReadable) return base
64  if (tool === 'Write' && values.content !== undefined) {
65    const lastLine = (parser.breaks.content ?? 0) + 1
66    return { ...base, kind: 'code', body: toDisplay(values.content), lastLine, meta: words.lines(lastLine) }
67  }
68  if (tool === 'Edit' && values.old_string !== undefined) {
69    const change = hunk(toDisplay(values.old_string), values.new_string === undefined ? null : toDisplay(values.new_string))
70    return change.text === '' ? base : { ...base, kind: 'diff', body: change.text, meta: changeMeta(change) }
71  }
72  return base
73}
74
75// tool.call이 돌려준 것. core는 { result, text, isError? }, 거절은 { deny }.
76export type Answer = { deny?: string; result?: unknown; text?: string; isError?: boolean }
77
78type PatchHunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: readonly string[] }
79
80const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
81
82function isPatchHunk(value: unknown): value is PatchHunk {
83  return (
84    isRecord(value) &&
85    typeof value.oldStart === 'number' &&
86    typeof value.oldLines === 'number' &&
87    typeof value.newStart === 'number' &&
88    typeof value.newLines === 'number' &&
89    Array.isArray(value.lines) &&
90    value.lines.every(line => typeof line === 'string')
91  )
92}
93
94// Edit·Write 결과의 structuredPatch를 실제 줄 번호가 붙은 diff 글로. 다 못 실어도 +/− 수는 전부 센다.
95export function patchChange(patch: unknown, limit = 20000): Change | null {
96  if (!Array.isArray(patch) || !patch.every(isPatchHunk) || patch.length === 0) return null
97  let text = ''
98  let added = 0
99  let removed = 0
100  for (const part of patch) {
101    added += part.lines.filter(line => line.startsWith('+')).length
102    removed += part.lines.filter(line => line.startsWith('-')).length
103    const block = toDisplay(`@@ -${part.oldStart},${part.oldLines} +${part.newStart},${part.newLines} @@\n${part.lines.join('\n')}`, NO_LIMIT)
104    if (text.length + block.length > limit) continue
105    text = text === '' ? block : `${text}\n${block}`
106  }
107  return { text, added, removed }
108}
109
110function bashText(result: unknown, text: string): string {
111  if (!isRecord(result) || typeof result.stdout !== 'string') return text
112  const stderr = typeof result.stderr === 'string' ? result.stderr : ''
113  return stderr === '' ? result.stdout : result.stdout === '' ? stderr : `${result.stdout}\n${stderr}`
114}
115
116
117// 도구가 끝난 뒤의 표시: Write는 쓴 내용, Edit은 실제 줄 번호 diff, Bash는 출력과 종료 코드, 실패는 오류 글.
118export function settledEntry(entry: Entry, answer: Answer, elapsedMs: number, words: Words = EN): Entry {
119  const seconds = words.seconds((elapsedMs / 1000).toFixed(1))
120  if (answer.deny !== undefined) {
121    return { ...entry, status: 'failed', kind: 'output', body: toDisplay(answer.deny), lastLine: null, meta: words.denied }
122  }
123  const text = answer.text ?? ''
124  if (answer.isError === true) {
125    const exit = /^Exit code (\d+)/.exec(text)?.[1]
126    const meta = exit === undefined ? seconds : `${seconds} · ${words.exit(exit)}`
127    return { ...entry, status: 'failed', kind: 'output', body: toDisplay(text), lastLine: null, meta }
128  }
129  const result = answer.result
130  if (entry.tool === 'Bash') {
131    const isInterrupted = isRecord(result) && result.interrupted === true
132    const meta = isInterrupted ? `${seconds} · ${words.interrupted}` : `${seconds} · ${words.exit('0')}`
133    return { ...entry, status: 'done', kind: 'output', body: toDisplay(bashText(result, text)), lastLine: null, meta }
134  }
135  if (entry.tool === 'Edit' && isRecord(result)) {
136    const change = patchChange(result.structuredPatch)
137    if (change !== null) return { ...entry, status: 'done', kind: 'diff', body: change.text, lastLine: null, meta: changeMeta(change) }
138  }
139  if (entry.tool === 'Write' && isRecord(result) && typeof result.content === 'string') {
140    // toDisplay처럼 CRLF·CR도 줄바꿈으로 센다. 본문 마지막 행은 끝 줄바꿈 뒤 빈 행일 수 있어 줄 수와 따로 둔다.
141    const rows = result.content.split(/\r\n?|\n/)
142    const lineCount = rows.length > 1 && rows.at(-1) === '' ? rows.length - 1 : rows.length
143    const kind = result.type === 'create' ? words.created : words.overwritten
144    return { ...entry, status: 'done', kind: 'code', body: toDisplay(result.content), lastLine: rows.length, meta: `${words.lines(lineCount)} · ${kind}` }
145  }
146  return { ...entry, status: 'done' }
147}
148
149// 본문 중 그릴 창: Code에 넘길 글과 첫 줄 번호, 창 위·아래로 접힌 줄 수.
150export type Window = { source: string; startLine: number | undefined; above: number; below: number }
151
152type ParsedHunk = { oldStart: number; newStart: number; lines: string[] }
153
154function readHunks(body: string): ParsedHunk[] {
155  const hunks: ParsedHunk[] = []
156  for (const line of body.split('\n')) {
157    const header = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
158    if (header) hunks.push({ oldStart: Number(header[1]), newStart: Number(header[2]), lines: [] })
159    else hunks.at(-1)?.lines.push(line)
160  }
161  return hunks
162}
163
164const isOld = (line: string) => line.startsWith(' ') || line.startsWith('-')
165const isNew = (line: string) => line.startsWith(' ') || line.startsWith('+')
166
167function writeHunk(part: ParsedHunk): string {
168  const header = `@@ -${part.oldStart},${part.lines.filter(isOld).length} +${part.newStart},${part.lines.filter(isNew).length} @@`
169  return `${header}\n${part.lines.join('\n')}`
170}
171
172// diff를 그리는 행으로 편 것: hunk마다 머리 줄(at -1) 하나와 그 줄들.
173function diffRows(hunks: readonly ParsedHunk[]): { hunk: number; at: number }[] {
174  return hunks.flatMap((part, hunk) => [{ hunk, at: -1 }, ...part.lines.map((_, at) => ({ hunk, at }))])
175}
176
177// 본문이 그리는 행 수. 끝에서 얼마나 올려 볼 수 있는지 정할 때 쓴다.
178export function rowCount(entry: Entry): number {
179  return entry.kind === 'diff' ? diffRows(readHunks(entry.body)).length : entry.body.split('\n').length
180}
181
182// total행 중 끝에서 back행 위에서 끝나는 rows행. back이 너무 크면 맨 위에 붙는다.
183function span(total: number, rows: number, back: number): { start: number; end: number } {
184  const size = Math.min(total, Math.max(1, rows))
185  const end = Math.min(total, Math.max(size, total - back))
186  return { start: end - size, end }
187}
188
189function lineWindow(body: string, lastLine: number | null, rows: number, back: number): Window {
190  const lines = body.split('\n')
191  const { start, end } = span(lines.length, rows, back)
192  const source = lines.slice(start, end).join('\n')
193  const below = lines.length - end
194  // 본문 앞이 상한으로 잘렸으면 본문 첫 줄은 1번이 아니다.
195  const firstLine = lastLine === null ? 0 : lastLine - lines.length + 1
196  if (firstLine < 1) return { source, startLine: undefined, above: start, below }
197  return { source, startLine: firstLine + start, above: firstLine - 1 + start, below }
198}
199
200// 가운데가 잘린 hunk는 Code가 줄 번호를 못 맞추므로, 보이는 줄로 머리 숫자를 다시 쓴다.
201function diffWindow(body: string, rows: number, back: number): Window {
202  const hunks = readHunks(body)
203  const flat = diffRows(hunks)
204  const shown = span(flat.length, rows, back)
205  const end = shown.end
206  let start = shown.start
207  // hunk 가운데서 시작하면 머리 줄을 새로 써야 하니 한 줄 덜 싣는다.
208  if (flat[start]?.at !== -1 && end - start > 1) start++
209  const parts: string[] = []
210  for (let i = start; i < end; ) {
211    const hunk = flat[i]?.hunk ?? 0
212    let j = i
213    while (j < end && flat[j]?.hunk === hunk) j++
214    const first = Math.max(0, flat[i]?.at ?? 0)
215    const last = flat[j - 1]?.at ?? -1
216    const part = hunks[hunk]
217    // 창 끝에 걸린 머리 줄만 있는 hunk는 그릴 줄이 없다.
218    if (part !== undefined && last >= first) {
219      const skipped = part.lines.slice(0, first)
220      const lines = part.lines.slice(first, last + 1)
221      parts.push(writeHunk({ oldStart: part.oldStart + skipped.filter(isOld).length, newStart: part.newStart + skipped.filter(isNew).length, lines }))
222    }
223    i = j
224  }
225  return { source: parts.join('\n'), startLine: undefined, above: start, below: flat.length - end }
226}
227
228// 맨 앞 호출 본문의 창: 끝에서 back행 위까지 rows행. back 0이면 끝을 따라간다.
229export function bodyWindow(entry: Entry, rows: number, back: number): Window {
230  return entry.kind === 'diff' ? diffWindow(entry.body, rows, back) : lineWindow(entry.body, entry.lastLine, rows, back)
231}
232
233// omp처럼 쓰는 중에는 끝 12줄, 끝나면 Write 앞 3줄·Edit 앞 12행·출력 끝 3줄만 그린다.
234const STREAM_ROWS = 12
235const DIFF_ROWS = 12
236const FOLDED_ROWS = 3
237
238export function shownOf(entry: Entry): Shown {
239  if (entry.kind === 'none') return { source: '', startLine: null, earlier: 0, more: 0 }
240  const isPending = entry.status === 'streaming' || entry.status === 'running'
241  const total = rowCount(entry)
242  const rows = entry.kind === 'diff' ? DIFF_ROWS : isPending && entry.kind === 'code' ? STREAM_ROWS : FOLDED_ROWS
243  // 쓰는 중과 출력은 끝을, 끝난 코드·diff는 앞을 보인다.
244  const isHead = !isPending && entry.kind !== 'output'
245  const shown = bodyWindow(entry, rows, isHead ? Math.max(0, total - rows) : 0)
246  return { source: shown.source, startLine: shown.startLine ?? null, earlier: shown.above, more: shown.below }
247}
248
hooks/display.ts 27 lines
1// 풀이기 값(원문 UTF-16)을 Text·Code·Markdown이 받는 글자로 바꾼다. 탭·줄바꿈 말고는 제어문자가 없어야 한다.
2export const BODY_LIMIT = 20000
3
4// biome-ignore lint/suspicious/noControlCharactersInRegex: 제어문자를 찾아 바꾸는 것이 이 정규식의 일이다
5const NEEDS_FIX = /[\r\u0000-\u0008\u000b-\u001f\u007f-\u009f\ud800-\udfff]/
6// biome-ignore lint/suspicious/noControlCharactersInRegex: 위와 같다
7const FIXES = /\r\n?|[\u0000-\u0008\u000b-\u001f\u007f-\u009f]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g
8
9const isHigh = (code: number) => code >= 0xd800 && code <= 0xdbff
10const isLow = (code: number) => code >= 0xdc00 && code <= 0xdfff
11
12function fix(match: string): string {
13  if (match.charAt(0) === '\r') return '\n'
14  const code = match.charCodeAt(0)
15  if (isHigh(code) || isLow(code)) return '\ufffd'
16  return `\\u${code.toString(16).padStart(4, '0')}`
17}
18
19export function toDisplay(raw: string, limit = BODY_LIMIT): string {
20  // 끝의 짝 없는 high는 다음 조각에서 짝이 올 수 있어 이번에는 보이지 않는다.
21  const held = isHigh(raw.charCodeAt(raw.length - 1)) ? raw.slice(0, -1) : raw
22  const text = NEEDS_FIX.test(held) ? held.replace(FIXES, fix) : held
23  if (text.length <= limit) return text
24  const cut = text.slice(-limit)
25  return isLow(cut.charCodeAt(0)) ? cut.slice(1) : cut
26}
27
hooks/feed.ts 102 lines
1import type { Item, Todo } from '../types'
2
3// 흐름 항목을 만드는 순수 함수들: 상자 밖 도구의 한 줄 인자·숨길 도구, 답·생각 글 잇기, 할 일 갱신.
4
5// 상자 밖 도구는 입력 원문을 이만큼까지만 모은다. 넘으면 이름만 둔다.
6export const RAW_LIMIT = 16 * 1024
7
8// 앱 내부 도구(wrap_up·mark_chapter·update_plan 등)와 할 일 도구는 흐름에 줄을 만들지 않는다.
9export const isHidden = (tool: string) => tool.startsWith('mcp__ccd_') || tool === 'TodoWrite'
10
11export type Places = { cwd: string | null; home: string | null }
12
13// 세션 폴더 안이면 상대 경로, 홈 안이면 ~로 줄인다.
14export function shortPath(path: string, places: Places): string {
15  if (places.cwd !== null && path.startsWith(`${places.cwd}/`)) return path.slice(places.cwd.length + 1)
16  if (places.home !== null && path.startsWith(`${places.home}/`)) return `~${path.slice(places.home.length)}`
17  return path
18}
19
20const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
21const textOf = (value: unknown) => (typeof value === 'string' ? value : '')
22const numberOf = (value: unknown) => (typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null)
23
24// Read의 offset은 1부터 센 첫 줄, limit은 줄 수다.
25function readArgs(input: Record<string, unknown>, places: Places): string {
26  const path = shortPath(textOf(input.file_path), places)
27  const offset = numberOf(input.offset)
28  const limit = numberOf(input.limit)
29  if (limit !== null) return `${path}:${offset ?? 1}-${(offset ?? 1) + limit - 1}`
30  return offset === null ? path : `${path}:${offset}`
31}
32
33// 한 줄 `● {도구} {인자}`의 인자. 모르는 도구나 깨진 입력은 빈 글자(도구 이름만).
34export function lineArgs(tool: string, input: unknown, places: Places): string {
35  if (!isRecord(input)) return ''
36  if (tool === 'Read') return readArgs(input, places)
37  if (tool === 'Grep') return [textOf(input.pattern), shortPath(textOf(input.path), places)].filter(part => part !== '').join(' ')
38  if (tool === 'Glob') return textOf(input.pattern)
39  if (tool === 'WebFetch') return textOf(input.url)
40  if (tool === 'WebSearch') return textOf(input.query)
41  if (tool === 'Agent' || tool === 'Task') return textOf(input.description)
42  return ''
43}
44
45// 모은 입력 원문을 푼다. 상한을 넘었거나 깨졌으면 null.
46export function parseRaw(raw: string): unknown {
47  if (raw.length > RAW_LIMIT) return null
48  try {
49    return JSON.parse(raw)
50  } catch {
51    // 흐름이 끊겨 입력이 덜 왔거나 깨진 것이다. 한 줄은 도구 이름만, 할 일은 그대로 둔다.
52    return null
53  }
54}
55
56// 답·생각 글은 항목마다 끝 이만큼만 둔다.
57export const TEXT_LIMIT = 4000
58
59// 같은 id(단계·블록)의 마지막 글 항목에 이어 붙이고, 아니면 새 항목. 원문은 끝 TEXT_LIMIT자만 둔다.
60export function appendText(items: readonly Item[], type: 'text' | 'thinking', id: string, piece: string, limit: number): Item[] {
61  const lastItem = items.at(-1)
62  if (lastItem !== undefined && lastItem.type === type && lastItem.id === id) {
63    const joined = lastItem.text + piece
64    // 서로게이트 짝은 표시 사본(toDisplay)이 맞추므로 여기서는 길이만 자른다.
65    return [...items.slice(0, -1), { type, id, text: joined.length > TEXT_LIMIT ? joined.slice(-TEXT_LIMIT) : joined }]
66  }
67  return [...items, { type, id, text: piece.slice(-TEXT_LIMIT) }].slice(-limit)
68}
69
70const TODO_WRITE = 'TodoWrite'
71const UPDATE_PLAN = 'mcp__ccd_turn__update_plan'
72export const isTodoTool = (tool: string) => tool === TODO_WRITE || tool === UPDATE_PLAN
73
74const statusOf = (value: unknown): Todo['status'] => (value === 'completed' || value === 'in_progress' ? value : 'pending')
75
76function todosOf(list: unknown, key: 'todos' | 'items'): Todo[] | null {
77  if (!Array.isArray(list)) return null
78  return list.filter(isRecord).map(todo => ({
79    content: textOf(todo.content),
80    status: statusOf(todo.status),
81    activeForm: key === 'todos' && typeof todo.activeForm === 'string' ? todo.activeForm : null,
82  }))
83}
84
85const setStatus = (todos: readonly Todo[], at: number, status: Todo['status']) => todos.map((todo, i) => (i === at ? { ...todo, status } : todo))
86
87// TodoWrite는 통째로, update_plan은 items면 통째로, advance·done(1부터)·start는 그 자리만 바꾼다. 바꿀 것이 없으면 null.
88export function applyTodos(todos: readonly Todo[], tool: string, input: unknown): Todo[] | null {
89  if (!isRecord(input)) return null
90  if (tool === TODO_WRITE) return todosOf(input.todos, 'todos')
91  if (tool !== UPDATE_PLAN) return null
92  let next = todosOf(input.items, 'items') ?? [...todos]
93  if (input.advance === true) {
94    next = next.map(todo => (todo.status === 'in_progress' ? { ...todo, status: 'completed' as const } : todo))
95    const pending = next.findIndex(todo => todo.status === 'pending')
96    if (pending !== -1) next = setStatus(next, pending, 'in_progress')
97  }
98  if (Array.isArray(input.done)) for (const at of input.done) if (typeof at === 'number') next = setStatus(next, at - 1, 'completed')
99  if (typeof input.start === 'number') next = setStatus(next, input.start - 1, 'in_progress')
100  return next
101}
102
hooks/partial.ts 211 lines
1export type Key = 'file_path' | 'content' | 'old_string' | 'new_string' | 'command';
2type Fields = Partial<Record<Key, string>>;
3type Flags = Partial<Record<Key, true>>;
4type Counts = Partial<Record<Key, number>>;
5type Phase = 'start' | 'reading' | 'done' | 'invalid' | 'limited';
6type Mode = 'idle' | 'key' | 'string' | 'atom';
7type Num = '-' | '0' | 'i' | '.' | 'f' | 'e' | 's' | 'x';
8
9export type State = Readonly<{
10  phase: Phase;
11  stack: string;
12  mode: Mode;
13  key: string | null;
14  target: Key | null;
15  escape: 0 | 1 | 2;
16  hex: number;
17  digits: number;
18  literal: string | null;
19  num: Num;
20  values: Readonly<Fields>;
21  truncated: Readonly<Flags>;
22  closed: Readonly<Flags>;
23  // Line breaks (\n) seen in each tail key's whole value, cut part included.
24  breaks: Readonly<Counts>;
25}>;
26
27const LIMIT: Readonly<Record<Key, number>> = {
28  file_path: 4096, content: 20000, old_string: 20000, new_string: 20000, command: 4096,
29};
30const MAX_DEPTH = 64;
31const ESC: Readonly<Record<string, string>> = {
32  '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f',
33  n: '\n', r: '\r', t: '\t',
34};
35const wanted = (k: string | null): k is Key =>
36  k === 'file_path' || k === 'content' || k === 'old_string' || k === 'new_string' ||
37  k === 'command';
38const white = (c: string) => c === ' ' || c === '\n' || c === '\r' || c === '\t';
39const digit = (c: string) => c >= '0' && c <= '9';
40const tail = (k: Key) => k === 'content' || k === 'old_string' || k === 'new_string';
41
42export function initialState(): State {
43  return {
44    phase: 'start', stack: '', mode: 'idle', key: null, target: null,
45    escape: 0, hex: 0, digits: 0, literal: null, num: '0',
46    values: {}, truncated: {}, closed: {}, breaks: {},
47  };
48}
49
50function numberNext(n: Num, c: string): Num | null {
51  const d = digit(c), e = c === 'e' || c === 'E';
52  switch (n) {
53    case '-': return c === '0' ? '0' : d ? 'i' : null;
54    case '0': return c === '.' ? '.' : e ? 'e' : null;
55    case 'i': return d ? 'i' : c === '.' ? '.' : e ? 'e' : null;
56    case '.': return d ? 'f' : null;
57    case 'f': return d ? 'f' : e ? 'e' : null;
58    case 'e': return c === '+' || c === '-' ? 's' : d ? 'x' : null;
59    case 's': case 'x': return d ? 'x' : null;
60  }
61}
62
63// One top-level object per state. Raw decoded UTF-16, NOT render-safe text.
64// No raw JSON accumulation, recursion, I/O, timers, logging, or deliberate throw.
65export function feed(previous: State, chunk: string): State {
66  const s = {
67    ...previous, values: { ...previous.values },
68    truncated: { ...previous.truncated }, closed: { ...previous.closed },
69    breaks: { ...previous.breaks },
70  };
71  if (s.phase === 'invalid' || s.phase === 'limited') return s;
72
73  // At most LIMIT[target] new code units, even for a huge input chunk.
74  const buffer: string[] = [];
75  let count = 0;
76  const flush = () => {
77    const k = s.target;
78    if (k !== null && count > 0) {
79      const cap = LIMIT[k], old = s.values[k] ?? '';
80      const start = count > cap ? count % cap : 0;
81      const added = start === 0 ? buffer.join('')
82        : buffer.slice(start).concat(buffer.slice(0, start)).join('');
83      if (tail(k)) {
84        if (old.length + count > cap) s.truncated[k] = true;
85        s.values[k] = (old + added).slice(-cap);
86      } else s.values[k] = old + added;
87    }
88    buffer.length = 0;
89    count = 0;
90  };
91  const emit = (c: string) => {
92    if (s.mode === 'key') {
93      if (s.key !== null) s.key = s.key.length < 10 ? s.key + c : null;
94      return;
95    }
96    const k = s.target;
97    if (k === null) return;
98    const cap = LIMIT[k];
99    if (!tail(k)) {
100      if (s.truncated[k]) return;
101      if ((k === 'command' && (c === '\n' || c === '\r')) ||
102          (s.values[k]?.length ?? 0) + count >= cap) {
103        s.truncated[k] = true;
104        return;
105      }
106    } else if (c === '\n') s.breaks[k] = (s.breaks[k] ?? 0) + 1;
107    buffer[count % cap] = c;
108    count++;
109  };
110  const top = (p: string) => { s.stack = s.stack.slice(0, -1) + p; };
111  const pop = () => {
112    s.stack = s.stack.slice(0, -1);
113    if (!s.stack) s.phase = 'done';
114  };
115
116  for (let i = 0; i < chunk.length; i++) {
117    if (s.phase === 'invalid' || s.phase === 'limited') break;
118    const c = chunk.charAt(i);
119    if (s.mode === 'key' || s.mode === 'string') {
120      if (s.escape === 2) {
121        if (!/^[0-9a-fA-F]$/.test(c)) { s.phase = 'invalid'; continue; }
122        s.hex = s.hex * 16 + parseInt(c, 16);
123        if (++s.digits === 4) { emit(String.fromCharCode(s.hex)); s.escape = 0; }
124      } else if (s.escape === 1) {
125        if (c === 'u') { s.escape = 2; s.hex = 0; s.digits = 0; }
126        else if (Object.prototype.hasOwnProperty.call(ESC, c)) {
127          emit(ESC[c]!); s.escape = 0;
128        } else s.phase = 'invalid';
129      } else if (c === '\\') s.escape = 1;
130      else if (c === '"') {
131        if (s.mode === 'key') {
132          s.target = s.stack.length === 1 && wanted(s.key) ? s.key : null;
133        } else {
134          flush();
135          if (s.target !== null) s.closed[s.target] = true;
136          s.target = null;
137        }
138        s.key = null;
139        s.mode = 'idle';
140      } else if (c.charCodeAt(0) < 0x20) s.phase = 'invalid';
141      else emit(c);
142      continue;
143    }
144    if (s.mode === 'atom') {
145      if (white(c) || c === ',' || c === '}' || c === ']') {
146        const complete = s.literal !== null ? s.literal === ''
147          : s.num === '0' || s.num === 'i' || s.num === 'f' || s.num === 'x';
148        if (!complete) s.phase = 'invalid';
149        else { s.mode = 'idle'; i--; } // Reprocess the delimiter, once.
150      } else if (s.literal !== null) {
151        if (c !== s.literal.charAt(0)) s.phase = 'invalid';
152        else s.literal = s.literal.slice(1);
153      } else {
154        const n = numberNext(s.num, c);
155        if (n === null) s.phase = 'invalid'; else s.num = n;
156      }
157      continue;
158    }
159    if (white(c)) continue;
160    if (s.phase === 'done') { s.phase = 'invalid'; continue; }
161    if (s.phase === 'start') {
162      if (c === '{') { s.phase = 'reading'; s.stack = 'K'; }
163      else s.phase = 'invalid';
164      continue;
165    }
166    // K/k: object key (optional/required); V: value; O: comma/end.
167    // A/a: array value (optional/required); D: comma/end.
168    const p = s.stack.slice(-1);
169    if (p === 'K' || p === 'k') {
170      if (c === '}' && p === 'K') pop();
171      else if (c === '"') {
172        s.mode = 'key'; s.key = s.stack.length === 1 ? '' : null;
173        s.target = null; s.escape = 0; top(':');
174      } else s.phase = 'invalid';
175    } else if (p === ':') {
176      if (c === ':') top('V'); else s.phase = 'invalid';
177    } else if (p === 'O' || p === 'D') {
178      if (c === ',') top(p === 'O' ? 'k' : 'a');
179      else if (c === (p === 'O' ? '}' : ']')) pop();
180      else s.phase = 'invalid';
181    } else if (p === 'A' && c === ']') pop();
182    else if (p === 'V' || p === 'A' || p === 'a') {
183      if (c !== '"' && c !== '{' && c !== '[' && c !== '-' &&
184          !digit(c) && c !== 't' && c !== 'f' && c !== 'n') {
185        s.phase = 'invalid'; continue;
186      }
187      if (s.target !== null) {
188        // Last duplicate value wins; non-string values remove old previews.
189        delete s.values[s.target]; delete s.truncated[s.target];
190        delete s.closed[s.target]; delete s.breaks[s.target];
191        if (c === '"') s.values[s.target] = '';
192      }
193      top(p === 'V' ? 'O' : 'D');
194      if (c === '"') { s.mode = 'string'; s.escape = 0; }
195      else {
196        s.target = null;
197        if (c === '{' || c === '[') {
198          if (s.stack.length >= MAX_DEPTH) s.phase = 'limited';
199          else s.stack += c === '{' ? 'K' : 'A';
200        } else {
201          s.mode = 'atom';
202          s.literal = c === 't' ? 'rue' : c === 'f' ? 'alse' : c === 'n' ? 'ull' : null;
203          s.num = c === '-' ? '-' : c === '0' ? '0' : 'i';
204        }
205      }
206    } else s.phase = 'invalid';
207  }
208  flush();
209  return s;
210}
211
hooks/words.ts 65 lines
1// 사용자에게 보이는 글자. 설정 `language`(auto 기본, en, ko)로 고른다. omp에서 온 상자 안 글자(streaming·earlier lines·TODO)는 두 언어 모두 영어로 둔다.
2
3export type Words = {
4  title: string
5  command: string
6  empty: string
7  off: string
8  opened: string
9  notPlaced: (reason: string) => string
10  notOpened: string
11  lines: (count: number) => string
12  seconds: (text: string) => string
13  exit: (code: string) => string
14  created: string
15  overwritten: string
16  interrupted: string
17  denied: string
18}
19
20export const EN: Words = {
21  title: 'Live viewer',
22  command: 'Open the live viewer tab (tool calls as Claude writes them)',
23  empty: 'Tool calls show up here as Claude writes them.',
24  off: 'live-viewer: turned off for this session after an error',
25  opened: 'Opened the live viewer tab.',
26  notPlaced: reason => `The live viewer tab is not placed yet: ${reason}`,
27  notOpened: 'Could not open the live viewer tab. Turn on the debug option to record why.',
28  lines: count => `${count} ${count === 1 ? 'line' : 'lines'}`,
29  seconds: text => `${text}s`,
30  exit: code => `exit ${code}`,
31  created: 'new file',
32  overwritten: 'overwritten',
33  interrupted: 'interrupted',
34  denied: 'denied',
35}
36
37export const KO: Words = {
38  title: '코딩 뷰어',
39  command: '코딩 뷰어 탭을 연다(Claude가 쓰는 도구 호출을 실시간으로)',
40  empty: 'Claude가 도구를 부르면 여기 실시간으로 찍힌다',
41  off: 'live-viewer: 오류로 이 세션에서 관찰을 껐다',
42  opened: '코딩 뷰어 탭을 열었다.',
43  notPlaced: reason => `코딩 뷰어가 아직 화면에 안 놓였다: ${reason}`,
44  notOpened: '코딩 뷰어를 열지 못했다. 까닭을 남기려면 debug 설정을 켠다.',
45  lines: count => `${count}줄`,
46  seconds: text => `${text}초`,
47  exit: code => `종료 ${code}`,
48  created: '새 파일',
49  overwritten: '덮어씀',
50  interrupted: '중단됨',
51  denied: '거절됨',
52}
53
54export type Language = 'en' | 'ko'
55
56// auto는 로캘(LC_ALL → LC_MESSAGES → LANG 중 먼저 있는 것)이 ko로 시작하거나 시간대가 Asia/Seoul이면 한국어다.
57// 데스크톱 앱이 띄운 CLI에는 로캘 변수가 없고 Intl 로캘은 en-US로 고정이라(2026-10-17 확인) 시간대도 본다.
58export function languageOf(choice: unknown, locale: string | undefined, timeZone: string | undefined): Language {
59  if (choice === 'en' || choice === 'ko') return choice
60  if (locale?.toLowerCase().startsWith('ko') === true) return 'ko'
61  return timeZone === 'Asia/Seoul' ? 'ko' : 'en'
62}
63
64export const wordsOf = (language: Language): Words => (language === 'ko' ? KO : EN)
65
types/index.d.ts 54 lines
1// 패널(또는 패널이 안 놓였을 때 대화창 맨 아래 진행 줄)이 그리는 표시 스냅숏.
2// 풀이기 상태는 넣지 않는다(turn.step 호출의 지역 변수). 글자는 모두 toDisplay를 거친 표시용이다.
3export type Status = 'streaming' | 'running' | 'done' | 'failed'
4
5// 도구 호출 하나: 상자 윗줄(상태·도구·제목·꼬리)과 본문 하나.
6export type Entry = {
7  // tool_use id. tool.call 결과를 이 호출에 붙일 때 쓴다.
8  id: string
9  tool: string
10  status: Status
11  // Write·Edit은 경로, Bash는 명령 첫 줄. 아직 안 왔으면 빈 글자.
12  title: string
13  // 강조 언어를 고를 경로. 없으면 null.
14  path: string | null
15  // code: 줄 번호 붙은 코드, diff: unified diff hunk, output: 명령 출력·오류 글, none: 본문 없음.
16  kind: 'code' | 'diff' | 'output' | 'none'
17  body: string
18  // code 본문 마지막 줄의 번호. 모르면 null(줄 번호를 안 그린다).
19  lastLine: number | null
20  // 윗줄 끝 꼬리(12줄, [+3 -1], 0.4초 · 종료 1).
21  meta: string
22}
23
24// 상자 본문 중 그릴 창: Code에 넘길 글, 첫 줄 번호(모르면 null), 창 위·아래로 접힌 줄 수.
25export type Shown = { source: string; startLine: number | null; earlier: number; more: number }
26
27// 흐름의 한 항목. box는 Write·Edit·Bash 상자(본문은 shown에만 싣고 entry.body는 비운다), line은 그 밖 도구 한 줄,
28// text·thinking은 답과 생각 글(같은 단계·같은 블록 index면 한 항목에 이어 붙인다).
29export type Item =
30  | { type: 'box'; entry: Entry; shown: Shown }
31  | { type: 'line'; id: string; tool: string; args: string }
32  | { type: 'text' | 'thinking'; id: string; text: string }
33
34// 할 일 하나(TodoWrite·update_plan). activeForm은 TodoWrite만 준다.
35export type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm: string | null }
36
37export type View = {
38  // 오래된 것이 앞.
39  items: readonly Item[]
40  // 맨 아래 할 일 나무. 턴이 도는 동안(isWorking)은 진행 중 할 일을 진행 줄로도 단다.
41  todos: readonly Todo[]
42  isWorking: boolean
43  isOff: boolean
44  // 어디에 그리나: pane은 패널, spinner는 패널이 안 놓여 대화창 맨 아래 진행 줄 자리,
45  // hidden은 사람이 패널을 손으로 닫아 아무 데도(/live로 다시 연다).
46  seat: 'pane' | 'spinner' | 'hidden'
47}
48
49declare module 'claude-code' {
50  interface PluginState {
51    'live-viewer': { calls: View }
52  }
53}
54