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…

English · 한국어
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.
<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
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.
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
<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">
| Call | Shown as |
|---|---|
Write | A 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 |
Edit | A box showing the diff with real line numbers, labeled [+added -removed] |
Bash | A box titled with the command. When done: the last 3 lines of output, duration, and exit code |
Read, Grep, Glob, WebFetch, WebSearch, Agent | A single line: ● Read src/app.ts:30-60 |
| Thinking, answers | Thinking in italics; answers rendered as Markdown |
| Todo list | A 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.
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
| Setting | Values | Default |
|---|---|---|
language | auto, en, ko | auto: Korean when your locale (LC_ALL, LC_MESSAGES, or LANG) starts with ko or your time zone is Asia/Seoul; English otherwise |
palette | auto, light, dark | auto (light in the desktop app; follows your theme in the terminal) |
debug | true, false | false. When enabled, writes a status file per session to live-viewer/status/ in your Claude config folder |
| Status | |
|---|---|
| Claude Code 2.1.302, macOS desktop app | Tested (2026-10-17) |
| Claude Code 2.1.295 and 2.1.299, macOS desktop app | Tested with claude plugin test and a load check (2026-10-17) |
| Claude Code 2.1.302 in a terminal, macOS | Tested (2026-10-17): the tab docks beside the transcript |
| Linux, Windows | Not tested yet |
| Claude Code older than 2.1.295 | Not tested. Mods are new and their API changes between releases |
claude -p and other headless runs | Nothing to show, so no tab opens |
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.
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
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.
The box layout and the light and dark palettes follow oh-my-pi (MIT). See NOTICE.
hooks/register.tsx 641 lines1import { 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}
641hooks/draw.tsx 391 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
391hooks/entry.ts 248 lines1import 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}
248hooks/display.ts 27 lines1// 풀이기 값(원문 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}
27hooks/feed.ts 102 lines1import 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}
102hooks/partial.ts 211 lines1export 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}
211hooks/words.ts 65 lines1// 사용자에게 보이는 글자. 설정 `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)
65types/index.d.ts 54 lines1// 패널(또는 패널이 안 놓였을 때 대화창 맨 아래 진행 줄)이 그리는 표시 스냅숏.
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