Usagi or Chiikawa acts out what Claude is doing in a pane above the prompt, with the order, subagents and progress beside it. Ported from…

<h1 align="center">Usagi code</h1>
<img src="assets/preview.gif" alt="Usagi thinking, typing at a keyboard while a tool runs, then standing still on a meadow" width="640" />
<sub>English · <a href="README.ko.md">한국어</a></sub>
A pane above the prompt where Usagi or Chiikawa acts out what Claude is doing: typing while a tool runs, a thinking bubble between steps, standing by when it's your turn. Beside it are your order, the subagents and live progress.
/plugin install usagi-code@claude-mods-custom-box
Haven't added the marketplace yet? See Install in the repository README first.
<table> <tr> <td width="50%" valign="middle">
Types while a tool runs, shows a thinking bubble between steps, and stands by when it's your turn.
</td> <td width="50%"> <img src="assets/preview.gif" alt="Usagi code stage" width="100%" /> </td> </tr> <tr> <td width="50%" valign="middle">
After a quiet minute Usagi dozes off where it stands: eyes closed, breathing up and down, a snot bubble swelling and popping, z drifting up. It wakes as soon as Claude moves again.
</td> <td width="50%"> <img src="assets/doze.gif" alt="Usagi dozing with closed eyes and a snot bubble" width="100%" /> </td> </tr> <tr> <td width="50%" valign="middle">
Pick who stands on the stage: Usagi or Chiikawa. /chiikawa brings Chiikawa on for this session and /usagi brings Usagi back; Character in /plugin configure usagi-code keeps one. Both do everything above, dozing included, with hand-drawn art at every size.
</td> <td width="50%"> <img src="assets/chiikawa.png" alt="Chiikawa thinking, typing and standing on the stage" width="100%" /> </td> </tr> </table>
/compact and the reset time when you get close./usagi + / /usagi - (/chiikawa + / /chiikawa - for Chiikawa); the size is kept across sessions. 22, 20, 18, 14 and 8 rows each have hand-drawn art for both characters. At 18 rows and below the character stands centred and the thinking bubble shows only dots (. .. ...).The pane's labels are in Korean for now.
| Command | What it does | ||
|---|---|---|---|
/usagi · /chiikawa | Open or close the pane with that character on stage; if the other one is showing, swap in this one (this session) | ||
/usagi + · /usagi - · /usagi size <n> | Resize the stage (rows); /chiikawa + · /chiikawa - · /chiikawa size <n> do the same with Chiikawa on stage | ||
| `/usagi theme <windows7 \ | macos \ | ubuntu>` | Switch the look for this session |
Both characters look most natural at 22, 20, 18, 14 and 8 rows. Those sizes use hand-drawn art as is; other sizes scale the 22-row art and can drop pixels, so the eyes may look off.
/usagi size 22brings back the default.Here is how the resolution differs by size (left to right: thinking, typing, standing), Usagi first, then Chiikawa. Use it as a reference when picking a size.
<img src="assets/sizes.png" alt="Usagi at 22, 20, 18, 14 and 8 rows, resolution compared" width="720" />
<img src="assets/chiikawa-sizes.png" alt="Chiikawa at 22, 20, 18, 14 and 8 rows, resolution compared" width="720" />
Where the pane sits is up to Claude Code. In the fullscreen layout (the default) a terminal 110 columns or wider docks it to the right of the conversation (Usagi asks for a 46-column dock so the stage fits); narrower, it sits above the prompt. To keep it above the prompt, start with CLAUDE_CODE_NO_FLICKER=0 claude or run inside tmux.
Lasting settings (open on startup, theme, character) live in /plugin configure usagi-code. Terminal only.
Ported from clockendbader/mascot-studio (MIT). Usagi and Chiikawa are characters from Nagano's Chiikawa; the pixel art here is fan-made. The original author's notice is kept in LICENSE.
hooks/register.tsx 485 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Dialog, Keyframe, SubAgent, ThemeName, UsageSnapshot } from '../types'
5import { finishKeyframe, firstLine, parseClientMessage, poseForTool, startKeyframe, targetOf } from './activity'
6import { TICK_MS, rasterFrames } from './animator'
7import type { AnimModel, RasterKey } from './animator'
8import { formatElapsed, hatFor } from './calendar'
9import { needsYouText, notifiedText, questionText, statusLine } from './alerts'
10import { DOCK_COLS, STAGE_ROWS_MAX, STAGE_ROWS_MIN, layoutV2, paneRows } from './layout'
11import { STAGE_ROWS } from './art/stage'
12import { CHARACTERS, characterFor } from './art/characters'
13import type { CharacterId } from './art/characters'
14import { THEMES, themeFor } from './themes'
15import type { Os } from './themes'
16import { segmentsText, statusSegments, titleSegments } from './views/chrome'
17import type { RowSegment } from './client/row'
18import { snapshotFrom, usageWarning } from './usage'
19import type { RawUsage } from './usage'
20import { dialogView, dialogWidth, okSegments } from './views/dialogs'
21import { studioView } from './views/studio'
22import { lookup, plainText } from './text'
23import { timelineTabView } from './views/timelineTab'
24
25const PANE = 'usagi-code'
26const HOP_MS = 1000
27const ERROR_DIALOG_MS = 8000
28
29const activity = atom({ plugin: 'usagi-code', key: 'activity' } as const, { pose: 'idle', since: 0 } as Activity)
30const keyframes = atom({ plugin: 'usagi-code', key: 'keyframes' } as const, [] as Keyframe[])
31const themeOverride = atom({ plugin: 'usagi-code', key: 'themeOverride' } as const, null as ThemeName | null)
32const characterOverride = atom({ plugin: 'usagi-code', key: 'characterOverride' } as const, null as CharacterId | null)
33const turnStartedAt = atom({ plugin: 'usagi-code', key: 'turnStartedAt' } as const, null as number | null)
34const idleSince = atom({ plugin: 'usagi-code', key: 'idleSince' } as const, null as number | null)
35const usage = atom({ plugin: 'usagi-code', key: 'usage' } as const, { rateLimits: [], toolCalls: 0 } as UsageSnapshot)
36const dialog = atom({ plugin: 'usagi-code', key: 'dialog' } as const, null as Dialog)
37// 마지막으로 받은 사용자 지시 첫 줄과, 그 지시로 만들어진 서브에이전트들
38const order = atom({ plugin: 'usagi-code', key: 'order' } as const, '')
39const subagents = atom({ plugin: 'usagi-code', key: 'subagents' } as const, [] as SubAgent[])
40// 메인 루프의 이번 작업이 시작된 시각, 단계 사슬·진행 줄은 이 뒤 단계만 센다
41const turnFrom = atom({ plugin: 'usagi-code', key: 'turnFrom' } as const, null as number | null)
42// 자동 압축이 시작되는 토큰 수, 꺼져 있거나 아직 못 읽었으면 null
43const compactAt = atom({ plugin: 'usagi-code', key: 'compactAt' } as const, null as number | null)
44// 캐릭터 높이(줄), ▲▼ 로 바꾸고 $.store 에 남겨 다음 세션에도 쓴다
45const stageRows = atom({ plugin: 'usagi-code', key: 'stageRows' } as const, STAGE_ROWS)
46const STAGE_STEP = 2
47
48/** The cwd's last path segment, for the pane title. */
49let folder = 'untitled'
50/** Animation tick and the clock as of the last tick; both start over on a reload. */
51let tick = 0
52let now = 0
53/** What the pane last drew: the ticker repaints these rasters until a repaint is refused. */
54let drawn: { model: AnimModel; mounted: Set<RasterKey> } | null = null
55/** Set while a tick's repaints are in flight. */
56let isAnimating = false
57/** Client rows that faulted on this surface: drawn as plain Text from then on. */
58const faulted = new Set<string>()
59
60const TERMINAL_ONLY = 'Usagi code runs in the terminal for now.'
61const paneTitle = () => `Usagi code · ${folder}`
62// 자동 테마는 OS 를 묻지 않고 리눅스 기준(Ubuntu)으로 고른다, 다른 OS 면 /usagi theme 로 바꾼다
63const OS: Os = 'linux'
64/** The status line as last set, so it is only set again when it changes. */
65let lastStatus: string | undefined
66/** 설정(character)으로 정한 캐릭터, 캐릭터 명령(/chiikawa 등)은 이번 세션만 다른 캐릭터를 세운다. 알림 문구의 이름도 무대에 선 캐릭터를 따른다. */
67let configured: CharacterId = 'usagi'
68const onStage = async ($: EngineInterface): Promise<CharacterId> => (await read($, characterOverride)) ?? configured
69const mascot = async ($: EngineInterface): Promise<string> => CHARACTERS[await onStage($)].name
70
71/** Sets the plugin's status line from the dialog and the usage warning, when the text changed. */
72async function refreshStatus($: EngineInterface): Promise<void> {
73 const text = statusLine(await read($, dialog), usageWarning(await read($, usage), new Date(await $.clock.now()), await mascot($)), await mascot($))
74 if (text === lastStatus) return
75 lastStatus = text
76 $.ui.status(text)
77}
78
79
80/** Shows a dialog (or clears it with null) and sets the status line to match. */
81async function setDialog($: EngineInterface, next: Dialog): Promise<void> {
82 await update($, dialog, () => next)
83 await refreshStatus($)
84}
85
86/** Clears the dialog if it is of this kind. */
87async function clearDialog($: EngineInterface, kind: 'error' | 'needs-you'): Promise<void> {
88 if ((await read($, dialog))?.kind === kind) await setDialog($, null)
89}
90
91/** The mascot waves beside an instant message. */
92async function needsYou($: EngineInterface, text: string): Promise<void> {
93 const at = await $.clock.now()
94 await setDialog($, { kind: 'needs-you', text, at })
95 await update($, activity, last => ({ ...last, pose: 'waving', since: at }) as Activity)
96}
97
98/** 받은 지시: 메인 루프의 새 지시만 기록, 알림(<...>)과 이어하기("")는 건너뛴다. 서브에이전트 turn 은 다시 실행 중으로. */
99async function noteTurnStart($: EngineInterface, agentId: string | undefined, text: string, now: number): Promise<void> {
100 if (agentId === undefined) await update($, turnFrom, () => now)
101 if (agentId !== undefined) {
102 await update($, subagents, list => list.map(a => (a.id === agentId ? { ...a, state: 'run' as const } : a)))
103 } else if (text.trim() !== '' && !text.trimStart().startsWith('<')) {
104 await update($, order, () => firstLine(text))
105 await update($, subagents, list => list.filter(a => a.state === 'run'))
106 }
107}
108
109/** 서브에이전트의 도구 호출로 그 에이전트가 지금 하는 일을 갱신한다. */
110async function noteSubagentTool($: EngineInterface, agentId: string | undefined, tool: string, target: string): Promise<void> {
111 if (agentId === undefined) return
112 await update($, subagents, list => list.map(a => (a.id === agentId ? { ...a, tool, target } : a)))
113}
114
115/** 자동 압축 기준 토큰 수를 다시 읽는다, summary 는 로컬 추정이라 API 요청이 없다. */
116async function refreshCompactAt($: EngineInterface): Promise<void> {
117 try {
118 const { context } = await $.session.usage({ breakdown: 'summary' })
119 await update($, compactAt, () => context.breakdown?.autoCompactThreshold ?? null)
120 } catch {
121 // 다음 turn 끝에 다시 읽는다
122 }
123}
124
125/** 압축 기준까지 찬 비율(%), 기준을 모르면 창 전체 대비 비율로 대신한다. */
126function compactPercent(figures: UsageSnapshot, threshold: number | null): number | undefined {
127 if (threshold !== null && threshold > 0 && figures.contextTokens !== undefined) return (figures.contextTokens / threshold) * 100
128 return figures.contextPercent
129}
130
131/** 캐릭터 높이를 범위 안으로 맞춰 저장하고 pane 높이도 그만큼 청한다, 정한 높이를 돌려준다. */
132async function resizeStage($: EngineInterface, change: (last: number) => number): Promise<number> {
133 const size = await update($, stageRows, last => Math.max(STAGE_ROWS_MIN, Math.min(STAGE_ROWS_MAX, Math.round(change(last)))))
134 await $.store.set('stageRows', size)
135 // 사람이 테두리로 정한 크기가 있으면 그쪽이 이긴다
136 await $.ui.open({ id: PANE, title: paneTitle(), columns: DOCK_COLS, rows: paneRows(size) })
137 return size
138}
139
140
141
142/** Takes the engine's usage figures, if they look like figures. */
143function isRawUsage(value: unknown): value is RawUsage {
144 const v = value as { context?: unknown; rateLimits?: unknown } | null
145 return typeof v === 'object' && v !== null && typeof v.context === 'object' && Array.isArray(v.rateLimits)
146}
147
148function folderOf(cwd: string): string {
149 const parts = cwd.split(/[\\/]/).filter(part => part !== '')
150 return plainText(parts.at(-1) ?? '') || 'untitled'
151}
152
153/** What a tool call's result says about failure: an error result or a refusal. */
154function failureOf(result: unknown): string | undefined {
155 const r = (result ?? {}) as { isError?: boolean; text?: unknown; deny?: unknown }
156 if (typeof r.deny === 'string') return firstLine(r.deny)
157 if (r.isError === true) return firstLine(typeof r.text === 'string' ? r.text : '')
158 return undefined
159}
160
161export const register: Register = (on, options) => {
162 configured = characterFor(String(options.character ?? 'usagi'))
163 on('session.start', async ($, e, next) => {
164 folder = folderOf(e.cwd)
165 for (const [id, { name }] of Object.entries(CHARACTERS)) await $.command.register({ name: id, description: `Open or close Usagi code with ${name} on stage` })
166 await refreshCompactAt($)
167 const kept = Number(await $.store.get('stageRows'))
168 if (Number.isFinite(kept) && kept > 0) await update($, stageRows, () => Math.max(STAGE_ROWS_MIN, Math.min(STAGE_ROWS_MAX, kept)))
169 const animate = async () => {
170 tick += 1
171 now = await $.clock.now()
172 const last = drawn
173 if (last === null || last.mounted.size === 0) return
174 for (const frame of rasterFrames(last.model, tick, now)) {
175 if (!last.mounted.has(frame.key)) continue
176 const painted = await $.ui.blit({ requestId: PANE, key: frame.key, cells: frame.cells })
177 if (painted.deny !== undefined) last.mounted.delete(frame.key)
178 }
179 }
180 $.clock.every(TICK_MS, () => {
181 // a surface still taking the last frame skips this tick rather than stacking repaints
182 if (isAnimating) return
183 isAnimating = true
184 void animate()
185 .catch(() => undefined)
186 .finally(() => {
187 isAnimating = false
188 })
189 })
190 try {
191 const figures: unknown = await $.session.usage()
192 if (isRawUsage(figures)) {
193 const calls = (await read($, keyframes)).at(-1)?.n ?? 0
194 await update($, usage, () => snapshotFrom(figures, calls))
195 await refreshStatus($)
196 }
197 } catch {
198 // no figures yet; session.measure brings them
199 }
200 const hasTerminal = (await $.session.surfaces()).includes('terminal')
201 if (options.openOnStartup !== false && hasTerminal) {
202 void $.ui.open({ id: PANE, title: paneTitle(), columns: DOCK_COLS, rows: paneRows(await read($, stageRows)) })
203 }
204 return next(e)
205 })
206
207 // 캐릭터마다 같은 명령 (/usagi, /chiikawa): 쓰면 그 캐릭터가 이번 세션 무대에 선다
208 for (const id of Object.keys(CHARACTERS) as CharacterId[]) {
209 const { name } = CHARACTERS[id]
210 on('command.run', { command: id }, async ($, e) => {
211 if (!(await $.session.surfaces()).includes('terminal')) return { text: TERMINAL_ONLY }
212 const [verb = '', arg = ''] = String((e as { args?: unknown }).args ?? '').trim().toLowerCase().split(/\s+/)
213 if (!['', 'theme', '+', '-', 'size'].includes(verb)) {
214 return { text: `Try /${id}, /${id} + | - | size <n>, or /${id} theme <windows7 | macos | ubuntu>.` }
215 }
216 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
217 const switched = (await onStage($)) !== id
218 await update($, characterOverride, () => id)
219 if (verb === 'theme') {
220 const chosen = lookup<(typeof THEMES)[ThemeName]>(THEMES, arg)
221 if (chosen === undefined) return { text: 'Themes: windows7, macos, ubuntu. To keep one, set Theme with /plugin configure.' }
222 await update($, themeOverride, () => chosen.name)
223 return { text: `Theme set to ${chosen.label} (this session).` }
224 }
225 // 일반 화면 모드에선 ▲▼ 클릭이 안 닿아서 명령으로도 받는다
226 if (verb === '+' || verb === '-' || verb === 'size') {
227 const wanted = Number(arg)
228 if (verb === 'size' && !Number.isFinite(wanted)) return { text: `Usage: /${id} size <${STAGE_ROWS_MIN}-${STAGE_ROWS_MAX}>, or /${id} + / /${id} -.` }
229 const size = await resizeStage($, last => (verb === 'size' ? wanted : last + (verb === '+' ? STAGE_STEP : -STAGE_STEP)))
230 return { text: `${name} size ${size} rows.` }
231 }
232 // 다른 캐릭터가 서 있던 pane 은 닫지 않고 바꿔 세운다
233 if (isOpen && switched) return { text: `${name} is on stage (this session). To keep it, set Character with /plugin configure.` }
234 if (isOpen) {
235 await $.ui.close({ id: PANE })
236 return { text: 'Usagi code closed.' }
237 }
238 await $.ui.open({ id: PANE, title: paneTitle(), columns: DOCK_COLS, rows: paneRows(await read($, stageRows)) })
239 return { text: 'Usagi code opened.' }
240 })
241 }
242
243 on('tool.call', async ($, e, next) => {
244 let id = ''
245 let started = 0
246 try {
247 started = await $.clock.now()
248 const tool = plainText(String(e.tool))
249 const target = targetOf(e as unknown as Record<string, unknown>)
250 const pose = poseForTool(tool)
251 await noteSubagentTool($, e.agentId, tool, target)
252 await update($, keyframes, list => {
253 id = e.tool_use_id ?? `k${(list.at(-1)?.n ?? 0) + 1}`
254 return startKeyframe(list, { id, tool, target, pose, startedAt: started })
255 })
256 await update($, activity, () => ({ pose, tool, target, since: started }))
257 await update($, idleSince, () => null)
258 await clearDialog($, 'error')
259 if (tool === 'AskUserQuestion') await setDialog($, { kind: 'needs-you', text: questionText(await mascot($)), at: started })
260 } catch {
261 // the studio never stands in the way of a tool call
262 }
263 const result = await next(e)
264 try {
265 const ended = await $.clock.now()
266 const errorLine = failureOf(result)
267 await update($, keyframes, list =>
268 finishKeyframe(list, id, {
269 durationMs: ended - started,
270 isError: errorLine !== undefined,
271 ...(errorLine !== undefined ? { errorLine } : {}),
272 }),
273 )
274 await clearDialog($, 'needs-you')
275 const isTurnRunning = (await read($, turnStartedAt)) !== null
276 const isAllDone = (await read($, keyframes)).every(frame => frame.durationMs !== undefined)
277 if (errorLine !== undefined) {
278 const tool = String(e.tool)
279 await update($, activity, last => ({ ...last, pose: 'oops', since: ended }) as Activity)
280 await setDialog($, { kind: 'error', tool, line: errorLine, at: ended })
281 $.clock.after(ERROR_DIALOG_MS, () => {
282 void (async () => {
283 const shown = await read($, dialog)
284 if (shown?.kind === 'error' && shown.at === ended) await setDialog($, null)
285 })()
286 })
287 } else if (isTurnRunning && isAllDone) {
288 await update($, activity, last => ({ ...last, pose: 'thinking', since: ended }) as Activity)
289 }
290 } catch {
291 // observing only
292 }
293 return result
294 }).catch(($, e, next) => next(e))
295
296 on('turn.start', async ($, e, next) => {
297 try {
298 const now = await $.clock.now()
299 await update($, activity, () => ({ pose: 'thinking', since: now }) as Activity)
300 await update($, turnStartedAt, () => now)
301 await update($, idleSince, () => null)
302 await noteTurnStart($, e.agentId, e.text, now)
303 } catch {
304 // observing only
305 }
306 return next(e)
307 }).catch(($, e, next) => next(e))
308
309 // 서브에이전트가 만들어지면 종류와 맡은 일을 기록한다
310 on('agent.spawn', async ($, e, next) => {
311 const result = await next(e)
312 try {
313 if (result.agentId !== undefined) {
314 const agent: SubAgent = { id: result.agentId, type: e.subagentType, description: firstLine(e.description), state: 'run' }
315 await update($, subagents, list => [...list.filter(a => a.id !== agent.id), agent].slice(-20))
316 }
317 } catch {
318 // observing only
319 }
320 return result
321 }).catch(($, e, next) => next(e))
322
323 on('turn.complete', async ($, e, next) => {
324 const result = await next(e)
325 if (e.agentId !== undefined) {
326 try {
327 const state: SubAgent['state'] = e.reason === 'answer' ? 'done' : 'fail'
328 await update($, subagents, list => list.map(a => (a.id === e.agentId ? { ...a, state } : a)))
329 } catch {
330 // observing only
331 }
332 return result
333 }
334 try {
335 const now = await $.clock.now()
336 await clearDialog($, 'needs-you')
337 await update($, activity, () => ({ pose: 'done', since: now }) as Activity)
338 await update($, turnStartedAt, () => null)
339 await refreshCompactAt($)
340 $.clock.after(HOP_MS, () => {
341 void (async () => {
342 if ((await read($, turnStartedAt)) !== null) return
343 const at = await $.clock.now()
344 await update($, activity, () => ({ pose: 'idle', since: at }) as Activity)
345 await update($, idleSince, () => at)
346 })()
347 })
348 } catch {
349 // observing only
350 }
351 return result
352 }).catch(($, e, next) => next(e))
353
354 on('classic.PermissionRequest', async ($, e, next) => {
355 try {
356 const target = targetOf((e.tool_input ?? {}) as Record<string, unknown>)
357 await needsYou($, needsYouText(e.tool_name, target, await mascot($)))
358 } catch {
359 // observing only: the person decides, never the studio
360 }
361 return next(e)
362 }).catch(($, e, next) => next(e))
363
364 on('classic.Notification', async ($, e, next) => {
365 try {
366 if (e.notification_type === 'permission_prompt' && (await read($, dialog))?.kind !== 'needs-you') {
367 await needsYou($, notifiedText(e.message, await mascot($)))
368 }
369 } catch {
370 // observing only
371 }
372 return next(e)
373 }).catch(($, e, next) => next(e))
374
375 on('prompt.submit', async ($, e, next) => {
376 try {
377 await clearDialog($, 'needs-you')
378 } catch {
379 // observing only
380 }
381 return next(e)
382 }).catch(($, e, next) => next(e))
383
384 on('ui.message', { requestId: PANE }, async ($, e, next) => {
385 try {
386 const message = parseClientMessage(e.data)
387 const click = message !== null && 'click' in message ? message.click : null
388 if (click === 'ok') await setDialog($, null)
389 if (click === 'close') await $.ui.close({ id: PANE })
390 if (click === 'stage-grow') await resizeStage($, last => last + STAGE_STEP)
391 if (click === 'stage-shrink') await resizeStage($, last => last - STAGE_STEP)
392 } catch {
393 // a click the studio cannot act on is ignored
394 }
395 return next(e)
396 }).catch(($, e, next) => next(e))
397
398 on('ui.fault', { requestId: PANE }, async ($, e, next) => {
399 faulted.add(e.element)
400 return next(e)
401 }).catch(($, e, next) => next(e))
402
403 on('session.measure', async ($, e, next) => {
404 try {
405 const calls = (await read($, keyframes)).at(-1)?.n ?? 0
406 await update($, usage, () => snapshotFrom(e, calls))
407 await refreshStatus($)
408 } catch {
409 // observing only
410 }
411 return next(e)
412 }).catch(($, e, next) => next(e))
413
414 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
415 if (e.surface !== 'terminal') {
416 const { Text } = $.ui.resolve(e)
417 return <Text>{TERMINAL_ONLY}</Text>
418 }
419 const els = $.ui.resolve(e)
420 const cols = e.props.bodyColumns
421 const rows = e.props.scroll.bodyRows
422 const at = await $.clock.now()
423 const act = await read($, activity)
424 const frames = await read($, keyframes)
425 const override = await read($, themeOverride)
426 const theme = themeFor(override ?? String(options.theme ?? 'auto'), OS)
427 const startedAt = await read($, turnStartedAt)
428 const figures = await read($, usage)
429 const quietSince = await read($, idleSince)
430 const shownDialog = await read($, dialog)
431 const threshold = await read($, compactAt)
432 const orderText = await read($, order)
433 const agentList = await read($, subagents)
434 const layout = layoutV2(cols, rows, await read($, stageRows))
435 const current = frames.at(-1)?.n ?? 0
436
437 const model: AnimModel = {}
438 if (!layout.tooNarrow) {
439 model.stage = {
440 cols: layout.stageCols,
441 rows: layout.stageRows,
442 pose: act.pose,
443 hat: hatFor(new Date(at)),
444 theme: theme.name,
445 idleSince: quietSince,
446 character: await onStage($),
447 }
448 }
449 const rasters = rasterFrames(model, tick, at)
450 drawn = { model, mounted: new Set(rasters.map(frame => frame.key)) }
451
452 const row = (key: string, segments: RowSegment[], width = cols) =>
453 faulted.has(key) ? segmentsText(els, segments) : <els.Client key={key} module="./client/row.tsx" props={{ segments }} width={width} height={1} />
454 const fiveHour = figures.rateLimits.find(limit => limit.kind === 'five_hour')?.percentUsed
455 const fromAt = await read($, turnFrom)
456 const timeline =
457 !layout.tooNarrow
458 ? timelineTabView(
459 els,
460 { cols: layout.infoCols, theme, current, activity: act, elapsed: startedAt === null ? '' : formatElapsed(at - startedAt), contextPct: compactPercent(figures, threshold), steps: frames, order: orderText, agents: agentList, treeRows: Math.max(1, layout.content - 6), now: at, turnFrom: fromAt },
461 )
462 : null
463 const parts = {
464 timeline,
465 dialog: dialogView(els, shownDialog, cols, theme, shownDialog?.kind === 'error' ? row('ok', okSegments(theme, cols), dialogWidth(cols) - 2) : null),
466 title: row('title', titleSegments(theme, cols)),
467 status: row('status', statusSegments(theme, cols, {
468 ...(figures.contextPercent !== undefined ? { context: figures.contextPercent } : {}),
469 ...(fiveHour !== undefined ? { fiveHour } : {}),
470 })),
471 }
472
473 return studioView(
474 els,
475 {
476 cols,
477 layout,
478 theme,
479 frames: Object.fromEntries(rasters.map(frame => [frame.key, frame])),
480 },
481 parts,
482 )
483 })
484}
485hooks/activity.ts 126 lines1import { plainText } from './text'
2import type { Keyframe, Pose } from '../types'
3
4export const MAX_KEYFRAMES = 200
5/** Future frames kept visible to the right of the playhead. */
6const PLAYHEAD_MARGIN = 8
7
8const POSES: Readonly<Record<string, Pose>> = {
9 Read: 'reading',
10 Grep: 'reading',
11 Glob: 'reading',
12 LS: 'reading',
13 NotebookRead: 'reading',
14 ToolSearch: 'reading',
15 Edit: 'coding',
16 Write: 'coding',
17 MultiEdit: 'coding',
18 NotebookEdit: 'coding',
19 Bash: 'terminal',
20 PowerShell: 'terminal',
21 BashOutput: 'terminal',
22 KillShell: 'terminal',
23 Monitor: 'terminal',
24 WebFetch: 'browsing',
25 WebSearch: 'browsing',
26 Agent: 'helper',
27 Task: 'helper',
28 AskUserQuestion: 'waving',
29}
30
31export function poseForTool(tool: string): Pose {
32 const pose = POSES[tool]
33 if (pose !== undefined) return pose
34 const lower = tool.toLowerCase()
35 if (lower.includes('browser') || lower.includes('chrome')) return 'browsing'
36 return 'coding'
37}
38
39/** The first line of some outside text, plain. */
40export function firstLine(text: string): string {
41 return plainText(text.split(/\r?\n/, 1)[0] ?? '')
42}
43
44const TARGET_FIELDS = ['file_path', 'path', 'notebook_path', 'pattern', 'url', 'query', 'command', 'description'] as const
45
46/** What a call works on: the first present of its path, pattern, url, query, command or description. */
47export function targetOf(input: Readonly<Record<string, unknown>>): string {
48 for (const field of TARGET_FIELDS) {
49 const value = input[field]
50 if (typeof value === 'string' && value !== '') return firstLine(value)
51 }
52 return ''
53}
54
55export function truncateMiddle(text: string, max: number): string {
56 const chars = [...text]
57 if (chars.length <= max) return text
58 if (max <= 1) return '…'.slice(0, max)
59 const head = Math.ceil((max - 1) / 2)
60 const tail = max - 1 - head
61 return chars.slice(0, head).join('') + '…' + (tail > 0 ? chars.slice(-tail).join('') : '')
62}
63
64export function startKeyframe(list: readonly Keyframe[], start: Omit<Keyframe, 'n'>): Keyframe[] {
65 const n = (list.at(-1)?.n ?? 0) + 1
66 return [...list, { ...start, n }].slice(-MAX_KEYFRAMES)
67}
68
69export function finishKeyframe(
70 list: readonly Keyframe[],
71 id: string,
72 end: { durationMs: number; isError: boolean; errorLine?: string },
73): Keyframe[] {
74 return list.map(frame => (frame.id === id ? { ...frame, ...end } : frame))
75}
76
77/** The frames shown, scrolled so the playhead keeps 8 future frames to its right. */
78export function timelineWindow(current: number, width: number): { start: number; end: number } {
79 const end = Math.max(width, current + PLAYHEAD_MARGIN)
80 return { start: end - width + 1, end }
81}
82
83export type LayerCell = 'key' | 'span' | 'playhead' | 'future'
84
85export function layerCells(
86 keys: ReadonlySet<number> | 'all',
87 win: { start: number; end: number },
88 current: number,
89): LayerCell[] {
90 const cells: LayerCell[] = []
91 for (let n = win.start; n <= win.end; n++) {
92 if (n === current) cells.push('playhead')
93 else if (n > current) cells.push('future')
94 else cells.push(keys === 'all' || keys.has(n) ? 'key' : 'span')
95 }
96 return cells
97}
98
99export type ClientMessage = { click: string } | { pick: number }
100
101/** What a Client posted, if it is something the studio acts on: a click id, or a whole, non-negative step number. */
102export function parseClientMessage(data: unknown): ClientMessage | null {
103 if (typeof data !== 'object' || data === null) return null
104 const d = data as { click?: unknown; pick?: unknown }
105 if (typeof d.click === 'string') return { click: d.click }
106 if (typeof d.pick === 'number' && Number.isInteger(d.pick) && d.pick >= 0) return { pick: d.pick }
107 return null
108}
109
110export type StepAction = 'prev' | 'next' | 'live' | { pick: number }
111
112/** The step to pin after an action, or null for live; a pick that names no kept step leaves the pin as it was. */
113export function stepTarget(list: readonly Keyframe[], pinned: number | null, action: StepAction): number | null {
114 if (action === 'live') return null
115 if (action === 'prev') {
116 const at = pinned === null ? list.length : list.findIndex(frame => frame.n === pinned)
117 return list[Math.max(0, at - 1)]?.n ?? pinned
118 }
119 if (action === 'next') {
120 if (pinned === null) return null
121 const at = list.findIndex(frame => frame.n === pinned)
122 return at >= 0 && at < list.length - 1 ? (list[at + 1]?.n ?? null) : null
123 }
124 return list.some(frame => frame.n === action.pick) ? action.pick : pinned
125}
126hooks/animator.ts 29 lines1import type { Hat, Pose, ThemeName } from '../types'
2import { stageCells } from './art/stage'
3import type { CharacterId } from './art/characters'
4import { THEMES } from './themes'
5
6export const TICK_MS = 166
7const ASLEEP_AFTER_MS = 60_000
8
9export type RasterKey = 'stage'
10export type RasterFrame = { key: RasterKey; columns: number; rows: number; cells: string }
11
12export type AnimModel = {
13 /** `idleSince`: when the studio went quiet; a minute later an idle character dozes. */
14 stage?: { cols: number; rows: number; pose: Pose; hat: Hat; theme: ThemeName; idleSince: number | null; character?: CharacterId }
15}
16
17/** Every raster the pane shows, drawn for one animation tick. */
18export function rasterFrames(m: AnimModel, tick: number, now: number): RasterFrame[] {
19 const frames: RasterFrame[] = []
20 if (m.stage !== undefined) {
21 const { cols, rows, hat, idleSince, character = 'usagi' } = m.stage
22 const isAsleep = m.stage.pose === 'idle' && idleSince !== null && now - idleSince >= ASLEEP_AFTER_MS
23 const pose: Pose = isAsleep ? 'asleep' : m.stage.pose
24 const backdrop = THEMES[m.stage.theme].backdrop
25 frames.push({ key: 'stage', columns: cols, rows, cells: stageCells({ cols, rows, pose, hat, tick, backdrop, character }) })
26 }
27 return frames
28}
29hooks/calendar.ts 39 lines1import type { Hat } from '../types'
2
3const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
4
5/** The mascot's hat for a local date. */
6export function hatFor(d: Date): Hat {
7 const month = d.getMonth() + 1
8 const day = d.getDate()
9 if (month === 10) return 'pumpkin'
10 if (month === 12) return 'santa'
11 if (month === 1 && day === 1) return 'party'
12 if (month === 2 && day === 14) return 'heart'
13 return 'none'
14}
15
16function clockTime(d: Date): string {
17 const hour = d.getHours() % 12 === 0 ? 12 : d.getHours() % 12
18 const minute = String(d.getMinutes()).padStart(2, '0')
19 return `${hour}:${minute} ${d.getHours() < 12 ? 'AM' : 'PM'}`
20}
21
22function sameLocalDay(a: Date, b: Date): boolean {
23 return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
24}
25
26/** `4:10 PM` for a reset later today, `Mon 9:00 AM` for another day, `''` when missing, malformed or past. */
27export function formatReset(iso: string | undefined, now: Date): string {
28 if (iso === undefined) return ''
29 const at = new Date(iso)
30 if (Number.isNaN(at.getTime()) || at.getTime() <= now.getTime()) return ''
31 return sameLocalDay(at, now) ? clockTime(at) : `${DAYS[at.getDay()]} ${clockTime(at)}`
32}
33
34/** `m:ss`, minutes unbounded. */
35export function formatElapsed(ms: number): string {
36 const seconds = Math.max(0, Math.floor(ms / 1000))
37 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
38}
39hooks/alerts.ts 27 lines1import type { Dialog } from '../types'
2import { plainText } from './text'
3
4// 문구 속 이름은 무대에 선 캐릭터 이름 (mascot)
5const waiting = (mascot: string) => `${mascot} is waiting on you`
6export const questionText = (mascot = 'Usagi') => `${mascot}: hey! i have a question for you`
7const asking = (mascot: string) => `${mascot}: hey! i need ur OK`
8
9/** The instant message for a permission prompt on a tool and its target. */
10export function needsYouText(tool: string, target: string, mascot = 'Usagi'): string {
11 const name = plainText(tool)
12 const what = plainText(target)
13 return what === '' ? `${asking(mascot)} to run ${name}` : `${asking(mascot)} to run ${name}: ${what}`
14}
15
16/** The instant message for a permission notification, which names no tool. */
17export function notifiedText(message: string, mascot = 'Usagi'): string {
18 const words = plainText(message)
19 return words === '' ? asking(mascot) : `${asking(mascot)} — ${words}`
20}
21
22/** The plugin's one status line: waiting on the person, else the usage warning, else nothing. */
23export function statusLine(dialog: Dialog, warning: string | undefined, mascot = 'Usagi'): string | undefined {
24 if (dialog?.kind === 'needs-you') return waiting(mascot)
25 return warning
26}
27hooks/layout.ts 37 lines1import { SCENE_W } from './art/scene'
2import { STAGE_ROWS } from './art/stage'
3
4export const MIN_COLS = 32
5// 이 폭부터 캐릭터 옆에 정보 칸을 둔다 (위아래 분할의 넓은 창)
6export const SIDE_MIN_COLS = 80
7
8export type LayoutV2 = { tooNarrow: boolean; side: boolean; content: number; stageCols: number; stageRows: number; infoCols: number }
9
10const CHROME_ROWS = 3 // title chrome 2, status 1
11const CONTENT_MIN = 4
12const CONTENT_MAX = 16
13// 사용자가 ▲▼ 로 고르는 캐릭터 높이 범위
14export const STAGE_ROWS_MIN = 4
15export const STAGE_ROWS_MAX = 44
16/** 오른쪽에 붙을 때(dock) 청하는 본문 폭: 위아래로 쌓아도 22줄 무대(42열)가 다 들어간다. */
17export const DOCK_COLS = SCENE_W + 6
18/** 캐릭터를 stageRows 줄로 다 보이는 데 드는 본문 줄 수, inline 으로 열 때 이만큼 청한다. */
19export const paneRows = (stageRows: number = STAGE_ROWS) => CHROME_ROWS + stageRows
20
21/** 넓으면 캐릭터 | 정보 가로 배치, 캐릭터는 남는 줄에 맞춰 줄인다. 좁으면 위아래로 쌓는다. */
22export function layoutV2(cols: number, rows: number, wanted = STAGE_ROWS): LayoutV2 {
23 if (cols >= SIDE_MIN_COLS) {
24 const stageRows = Math.max(STAGE_ROWS_MIN, Math.min(wanted, rows - CHROME_ROWS))
25 const stageCols = Math.ceil((SCENE_W * stageRows) / STAGE_ROWS) + 2
26 return { tooNarrow: false, side: true, content: stageRows, stageCols, stageRows, infoCols: cols - stageCols - 2 }
27 }
28 return {
29 tooNarrow: cols < MIN_COLS,
30 side: false,
31 content: Math.max(CONTENT_MIN, Math.min(CONTENT_MAX, rows - CHROME_ROWS - wanted)),
32 stageCols: cols - 2,
33 stageRows: wanted,
34 infoCols: cols,
35 }
36}
37hooks/art/stage.ts 85 lines1import type { Hat, Pose } from '../../types'
2import { SCENE_H, SCENE_W, sceneFrame, sceneLabel, sceneSize } from './scene'
3import type { CharacterId } from './characters'
4import { drawSprite, gridToWords, newGrid, textWords, wordsToCells } from './pixels'
5import type { Grid } from './pixels'
6
7export const STAGE_ROWS = 22
8
9// 풀밭 배경: 위는 연한 민트, 아래로 짙은 초록, 풀잎 결과 흰 꽃이 흩어진다 (프레임마다 같은 무늬)
10const MEADOW_TOP = 0xd6efd0
11const MEADOW_BOTTOM = 0x86c865
12const BLADE = 0x5fa844
13const FLOWER = 0xffffff
14const FLOWER_CORE = 0xfff2a8
15
16const mix = (a: number, b: number, t: number): number =>
17 [16, 8, 0].reduce((out, s) => out | (Math.round(((a >> s) & 255) + (((b >> s) & 255) - ((a >> s) & 255)) * t) << s), 0)
18const hash = (x: number, y: number): number => (((x * 73856093) ^ (y * 19349663) ^ (x * y * 83492791)) >>> 0) % 1000
19
20/** 높이 h 인 무대 (x, y) 픽셀의 풀밭 색. */
21export function meadow(x: number, y: number, h = SCENE_H): number {
22 const base = mix(MEADOW_TOP, MEADOW_BOTTOM, (y / (h - 1)) ** 0.8)
23 const r = hash(x, y)
24 if (y > 8 && r < 10) return FLOWER
25 if (y > 8 && r < 13) return FLOWER_CORE
26 if (y > 4 && hash(x, Math.floor(y / 3)) % 100 < 7 && y % 3 !== 0) return mix(base, BLADE, 0.45)
27 if (r < 180) return mix(base, BLADE, 0.18)
28 if (r < 260) return mix(base, 0xffffff, 0.15)
29 return base
30}
31
32export type StageOptions = {
33 cols: number
34 pose: Pose
35 hat: Hat
36 tick: number
37 /** 무대 줄 수, 캐릭터에 손으로 그린 장면이 있으면 그대로, 그 밖은 22줄 장면을 맞춰 줄인다 (없으면 22). */
38 rows?: number
39 /** 무대에 세울 캐릭터 (없으면 usagi). */
40 character?: CharacterId
41 /** The theme's backdrop colour at scene pixel (x, y). */
42 backdrop: (x: number, y: number) => number
43}
44
45/** The Stage: a meadow across the full width and the scene centred (or clipped on the right when narrow). */
46export function stageCells(o: StageOptions): string {
47 const rows = o.rows ?? STAGE_ROWS
48 const character = o.character ?? 'usagi'
49 // 손으로 그린 크기는 그대로, 나머지는 22줄 장면을 그린 뒤 줄인다
50 const art = sceneSize(rows, character) === undefined ? STAGE_ROWS : rows
51 const { w: sceneW, h: sceneH } = sceneSize(art, character) ?? { w: SCENE_W, h: SCENE_H }
52 const scale = rows / art
53 const cols = scale === 1 ? o.cols : Math.max(1, Math.round(o.cols / scale))
54 const full = newGrid(cols, sceneH, 0)
55 const x0 = cols >= sceneW + 2 ? Math.floor((cols - sceneW) / 2) : 1
56 for (let y = 0; y < sceneH; y++) {
57 for (let x = 0; x < cols; x++) full.px[y * cols + x] = meadow(x, y, sceneH)
58 }
59 drawSprite(full, sceneFrame(o.pose, o.hat, o.tick, art, character), x0, 0)
60 const words = gridToWords(scale === 1 ? full : shrink(full, o.cols, rows * 2))
61 // 말풍선 글자는 셀째로 덮어쓴다, 줄인 만큼 글자 수도 줄이고 무대 밖은 자른다
62 const label = sceneLabel(o.pose, o.tick, art, character)
63 if (label !== null) {
64 const x = Math.round((x0 + label.x) * scale)
65 const row = Math.round(label.row * scale)
66 const n = Math.max(0, Math.min(Math.round(label.text.length * scale), o.cols - x))
67 if (row < rows) words.set(textWords(label.text, n, label.fg, label.bg), (row * o.cols + x) * 3)
68 }
69 return wordsToCells(words)
70}
71
72// ponytail: 최근접 축소라 작게 줄이면 1px 테두리가 끊긴다, 거슬리면 크기별 도안을 따로 둔다
73/** 격자를 w×h 로 최근접 픽셀 축소. */
74function shrink(g: Grid, w: number, h: number): Grid {
75 const out = newGrid(w, h, 0)
76 for (let y = 0; y < h; y++) {
77 const sy = Math.min(g.h - 1, Math.floor(((y + 0.5) * g.h) / h))
78 for (let x = 0; x < w; x++) {
79 const sx = Math.min(g.w - 1, Math.floor(((x + 0.5) * g.w) / w))
80 out.px[y * w + x] = g.px[sy * g.w + sx] ?? 0
81 }
82 }
83 return out
84}
85hooks/art/characters.ts 377 lines1// 무대에 세울 캐릭터들: 이름, 색, 줄 수마다 손으로 그린 도안과 배치표.
2// 새 캐릭터는 도안과 22줄 배치표를 만들어 CHARACTERS 에 올린다, 배치표가 없는 줄 수는 22줄 장면을 줄여 쓴다.
3
4import type { CharacterId } from '../../types'
5import { CLAWD_PALETTE } from './clawd'
6
7export type { CharacterId }
8
9/**
10 * 줄 수마다 손으로 맞춘 장면: 캐릭터 도안(art)과 왼쪽 위 자리, 키보드 가로 범위·윗줄 y, 두 앞발 x, 말풍선 왼쪽 x.
11 * dots 가 있으면 말풍선에 thinking 대신 점 dots 개, mini 면 얇은 키보드와 한 줄짜리 앞발.
12 * 졸 때: eyes 는 감길 눈 왼쪽 위(도안 기준), snot 은 콧방울이 매달릴 코 자리와 최대 반지름, zs 는 떠오를 z 의 시작 자리(장면 기준)와 큰 글씨 여부.
13 */
14export type SceneLayout = {
15 w: number
16 h: number
17 art: readonly string[]
18 rx: number
19 ry: number
20 keys: readonly [number, number]
21 top: number
22 paws: readonly [number, number]
23 bubbleX: number
24 dots?: number
25 mini?: boolean
26 eyes: readonly (readonly [number, number])[]
27 snot?: { at: readonly [number, number]; max: number }
28 zs: readonly (readonly [number, number, boolean])[]
29}
30
31export type Character = {
32 name: string
33 /** 도안 글자의 색: d 테두리, o 몸, p 속귀·볼, w 눈 반짝임 (키보드 앞발·감은 눈도 이 색으로 그린다) */
34 palette: Readonly<Record<string, number>>
35 /** 줄 수마다 손으로 맞춘 장면, 22줄은 꼭 있어야 한다 */
36 layouts: Readonly<Record<number, SceneLayout>> & { readonly 22: SceneLayout }
37}
38
39// Usagi: 도안 그대로, d 테두리, o 몸, p 속귀·볼, w 눈 반짝임
40const RABBIT = [
41 '..........dd.....dd..........',
42 '.........dpod...dopd.........',
43 '.........dppod.doppd.........',
44 '.........dppod.doppd.........',
45 '.........dppod.doppd.........',
46 '.........dppod.doppd.........',
47 '.........dppod.doppd.........',
48 '.........dppod.doppd.........',
49 '..........dpod.dopd..........',
50 '..........dood.dood..........',
51 '.......ddoooodddoooodd.......',
52 '.....ddoooooooooooooood......',
53 '....dooooddooooooddooood.....',
54 '...doooodoooooooooodooood....',
55 '..doooodoooooooooooodooood...',
56 '..dooodoooooooooooooodoood...',
57 '.doooodoooooooooooooodooood..',
58 '.doooooooddooooooddoooooood..',
59 '.dooooooddwdooooddwdooooood..',
60 '.doooooodwddoooodwddooooood..',
61 '.dooopppoddooooooddopppoood..',
62 '.doopdpdpooooodoooopdpdpood..',
63 '.doopdpdpooodododoopdpdpood..',
64 '.dooopppooooododoooopppoood..',
65 '..dooooooooooooooooooooood...',
66 '...doooooooooddoooooooood....',
67 '....ddooooooooooooooooodd....',
68 '....doooooooooooooooooood....',
69 '....dodooooooooooooooodod....',
70 '....dodooooooooooooooodod....',
71 '.....doooooooooooooooodd.....',
72 '......doooooooooooooood......',
73 '......doooooooooooooood......',
74 '......doooooooooooooood......',
75 '......dodddooooooodddod......',
76 '......dod..ddddddd..dod......',
77 '.......d.............d.......',
78]
79// 20·18줄용: 얼굴(눈썹·눈·볼·입)은 22줄 그대로, 귀·몸·팔 길이와 양옆 한 열씩만 덜었다
80const RABBIT_20 = [
81 '.........dd.....dd.........',
82 '........dpod...dopd........',
83 '........dppod.doppd........',
84 '........dppod.doppd........',
85 '........dppod.doppd........',
86 '........dppod.doppd........',
87 '.........dpod.dopd.........',
88 '.........dood.dood.........',
89 '......ddoooodddoooodd......',
90 '....ddoooooooooooooood.....',
91 '...dooooddooooooddooood....',
92 '..doooodoooooooooodooood...',
93 '..dooodoooooooooooodoood...',
94 '..doodoooooooooooooodood...',
95 '.dooodoooooooooooooodoood..',
96 '.dooooooddooooooddooooood..',
97 '.doooooddwdooooddwdoooood..',
98 '.dooooodwddoooodwddoooood..',
99 '.doopppoddooooooddopppood..',
100 '.dopdpdpooooodoooopdpdpod..',
101 '.dopdpdpooodododoopdpdpod..',
102 '.doopppooooododoooopppood..',
103 '..dooooooooooooooooooood...',
104 '..doooooooooddoooooooood...',
105 '...ddooooooooooooooooodd...',
106 '...doooooooooooooooooood...',
107 '...dodooooooooooooooodod...',
108 '...dodooooooooooooooodod...',
109 '....doooooooooooooooodd....',
110 '.....doooooooooooooood.....',
111 '.....doooooooooooooood.....',
112 '.....dodddooooooodddod.....',
113 '.....dod..ddddddd..dod.....',
114 '......d.............d......',
115]
116const RABBIT_18 = [
117 '.........dd.....dd.........',
118 '........dpod...dopd........',
119 '........dppod.doppd........',
120 '........dppod.doppd........',
121 '........dppod.doppd........',
122 '.........dpod.dopd.........',
123 '.........dood.dood.........',
124 '......ddoooodddoooodd......',
125 '....ddoooooooooooooood.....',
126 '...dooooddooooooddooood....',
127 '..doooodoooooooooodooood...',
128 '..dooodoooooooooooodoood...',
129 '..doodoooooooooooooodood...',
130 '.dooodoooooooooooooodoood..',
131 '.dooooooddooooooddooooood..',
132 '.doooooddwdooooddwdoooood..',
133 '.dooooodwddoooodwddoooood..',
134 '.doopppoddooooooddopppood..',
135 '.dopdpdpooooodoooopdpdpod..',
136 '.dopdpdpooodododoopdpdpod..',
137 '.doopppooooododoooopppood..',
138 '..dooooooooooooooooooood...',
139 '..doooooooooddoooooooood...',
140 '...ddooooooooooooooooodd...',
141 '...dodooooooooooooooodod...',
142 '....doooooooooooooooodd....',
143 '.....doooooooooooooood.....',
144 '.....dodddooooooodddod.....',
145 '.....dod..ddddddd..dod.....',
146 '......d.............d......',
147]
148// 14줄: 귀·눈썹·몸을 줄인 21폭, 눈 4x4·볼·입 무늬는 그대로
149const RABBIT_14 = [
150 '......dd.....dd......',
151 '.....dpod...dopd.....',
152 '.....dppod.doppd.....',
153 '.....dppod.doppd.....',
154 '......dood.dood......',
155 '...ddoooodddoooodd...',
156 '.ddooooooooooooooodd.',
157 'doooooodooooodooooood',
158 'dooooodooooooodoooood',
159 'doooooooooooooooooood',
160 'doooooddoooooddoooood',
161 'dooooddwdoooddwdooood',
162 'doooodwddooodwddooood',
163 'dopppoddoooooddopppod',
164 'dpdpdpoooodoooopdpdpd',
165 'dpdpdpoodododoopdpdpd',
166 'dopppoooododoooopppod',
167 '.doooooooooooooooood.',
168 '..doooooodddooooood..',
169 '..dodddooooooodddod..',
170 '..dod..ddddddd..dod..',
171 '...d.............d...',
172]
173// 8줄: 11폭 작은 토끼, 눈은 2x2 (반짝임을 넣으면 ㄴ자로 보여 뺐다)
174const RABBIT_8 = [
175 '...d...d...',
176 '..dpd.dpd..',
177 '..dpd.dpd..',
178 '..dpd.dpd..',
179 '..dpd.dpd..',
180 '.ddodddodd.',
181 'doooooooood',
182 'doddoooddod',
183 'doddododdod',
184 'dpoododoopd',
185 '.doooooood.',
186 '..dd...dd..',
187]
188
189// pane 이 주는 무대 폭(22줄 42열, 20줄 39열, 18줄 35열, 14줄 28열, 8줄 17열)에 맞췄다, 18줄 이하는 캐릭터를 가운데 두고 점 말풍선
190const USAGI: Character = {
191 name: 'Usagi',
192 palette: CLAWD_PALETTE,
193 layouts: {
194 22: {
195 w: 40, h: 44, art: RABBIT, rx: 5, ry: 3, keys: [3, 36], top: 35, paws: [10, 26], bubbleX: 26,
196 eyes: [[8, 17], [16, 17]], snot: { at: [14, 21], max: 2 }, zs: [[32, 13, false], [36, 9, true]],
197 },
198 20: {
199 w: 37, h: 40, art: RABBIT_20, rx: 3, ry: 2, keys: [1, 32], top: 31, paws: [7, 23], bubbleX: 23,
200 eyes: [[7, 15], [15, 15]], snot: { at: [13, 19], max: 2 }, zs: [[29, 12, false], [33, 8, true]],
201 },
202 18: {
203 w: 33, h: 36, art: RABBIT_18, rx: 3, ry: 2, keys: [1, 32], top: 27, paws: [7, 23], bubbleX: 23, dots: 3,
204 eyes: [[7, 14], [15, 14]], snot: { at: [13, 18], max: 2 }, zs: [[29, 11, false]],
205 },
206 14: {
207 w: 26, h: 28, art: RABBIT_14, rx: 2, ry: 2, keys: [0, 25], top: 21, paws: [5, 16], bubbleX: 20, dots: 3,
208 eyes: [[5, 10], [12, 10]], snot: { at: [10, 14], max: 1 }, zs: [[23, 8, false]],
209 },
210 8: {
211 w: 15, h: 16, art: RABBIT_8, rx: 2, ry: 1, keys: [1, 13], top: 12, paws: [3, 8], bubbleX: 12, dots: 1, mini: true,
212 eyes: [[2, 7], [7, 7]], zs: [[12, 6, false]],
213 },
214 },
215}
216
217// Chiikawa: 29x29 도안 그대로, 아래 몸통은 Usagi 와 같은 틀이고 귀가 짧아 머리가 8줄 낮다
218const CHIIKAWA_ART = [
219 '......dd...........dd........',
220 '.....dood.........dood.......',
221 '.....doodddddddddddood.......',
222 '......dooooooooooooood.......',
223 '....dooooooooooooooooood.....',
224 '...dooooooooooooooooooood....',
225 '..dooooooooooooooooooooood...',
226 '..dooooooddooooooddooooood...',
227 '.dooooooooooooooooooooooood..',
228 '.doooooooddooooooddoooooood..',
229 '.dooooooddwdooooddwdooooood..',
230 '.dooopppdwddoooodwddpppoood..',
231 '.doopdpdpddooodooddpdpdpood..',
232 '.doopdpdpooodododoopdpdpood..',
233 '.dooopppooooododoooopppoood..',
234 '.dooooooooooooooooooooooood..',
235 '..dooooooooooddooooooooood...',
236 '...dooooooooooooooooooood....',
237 '....ddooooooooooooooooodd....',
238 '....doooooooooooooooooood....',
239 '....dodooooooooooooooodod....',
240 '....dodooooooooooooooodod....',
241 '.....doooooooooooooooodd.....',
242 '......doooooooooooooood......',
243 '......doooooooooooooood......',
244 '......doooooooooooooood......',
245 '......dodddooooooodddod......',
246 '......dod..ddddddd..dod......',
247 '.......d.............d.......',
248]
249
250// 20·18줄: 얼굴은 22줄 그대로, 양옆 한 열씩과 몸통 길이만 덜었다 (Usagi 20·18 과 같은 방식)
251const CHIIKAWA_20 = [
252 '.....dd...........dd.......',
253 '....dood.........dood......',
254 '....doodddddddddddood......',
255 '.....dooooooooooooood......',
256 '...dooooooooooooooooood....',
257 '..dooooooooooooooooooood...',
258 '..dooooooooooooooooooood...',
259 '..doooooddooooooddoooood...',
260 '.dooooooooooooooooooooood..',
261 '.dooooooddooooooddooooood..',
262 '.doooooddwdooooddwdoooood..',
263 '.doopppdwddoooodwddpppood..',
264 '.dopdpdpddooodooddpdpdpod..',
265 '.dopdpdpooodododoopdpdpod..',
266 '.doopppooooododoooopppood..',
267 '.dooooooooooooooooooooood..',
268 '..doooooooooddoooooooood...',
269 '..dooooooooooooooooooood...',
270 '...ddooooooooooooooooodd...',
271 '...doooooooooooooooooood...',
272 '...dodooooooooooooooodod...',
273 '...dodooooooooooooooodod...',
274 '....doooooooooooooooodd....',
275 '.....doooooooooooooood.....',
276 '.....doooooooooooooood.....',
277 '.....dodddooooooodddod.....',
278 '.....dod..ddddddd..dod.....',
279 '......d.............d......',
280]
281// 18줄: 20줄에서 팔 위·팔·아래 몸통을 한 줄씩 더 덜었다
282const CHIIKAWA_18 = [
283 '.....dd...........dd.......',
284 '....dood.........dood......',
285 '....doodddddddddddood......',
286 '.....dooooooooooooood......',
287 '...dooooooooooooooooood....',
288 '..dooooooooooooooooooood...',
289 '..dooooooooooooooooooood...',
290 '..doooooddooooooddoooood...',
291 '.dooooooooooooooooooooood..',
292 '.dooooooddooooooddooooood..',
293 '.doooooddwdooooddwdoooood..',
294 '.doopppdwddoooodwddpppood..',
295 '.dopdpdpddooodooddpdpdpod..',
296 '.dopdpdpooodododoopdpdpod..',
297 '.doopppooooododoooopppood..',
298 '.dooooooooooooooooooooood..',
299 '..doooooooooddoooooooood...',
300 '..dooooooooooooooooooood...',
301 '...ddooooooooooooooooodd...',
302 '...dodooooooooooooooodod...',
303 '....doooooooooooooooodd....',
304 '.....doooooooooooooood.....',
305 '.....dodddooooooodddod.....',
306 '.....dod..ddddddd..dod.....',
307 '......d.............d......',
308]
309// 14줄: 21폭, 눈 4x4·볼·입 무늬는 Usagi 14 와 같고 볼·입이 눈 쪽으로 한 줄 올라간다
310const CHIIKAWA_14 = [
311 '....dd.........dd....',
312 '...dood.......dood...',
313 '...doodddddddddood...',
314 '..doooooooooooooood..',
315 '.doooooooooooooooood.',
316 'doooooddoooooddoooood',
317 'doooooooooooooooooood',
318 'doooooddoooooddoooood',
319 'dooooddwdoooddwdooood',
320 'dopppdwddooodwddpppod',
321 'dpdpdpddoodooddpdpdpd',
322 'dpdpdpoodododoopdpdpd',
323 'dopppoooododoooopppod',
324 '.doooooooooooooooood.',
325 '..doooooodddooooood..',
326 '..dodddooooooodddod..',
327 '..dod..ddddddd..dod..',
328 '...d.............d...',
329]
330// 8줄: 11폭, 눈 2x2, 짧고 둥근 귀
331const CHIIKAWA_8 = [
332 '.dd.....dd.',
333 '.dodddddod.',
334 'doooooooood',
335 'doddoooddod',
336 'doddododdod',
337 'dpoododoopd',
338 'doooooooood',
339 '.doooooood.',
340 '..dd...dd..',
341]
342
343// 발끝 높이·키보드·앞발·말풍선 자리는 같은 줄 수의 Usagi 와 맞췄다
344const CHIIKAWA: Character = {
345 name: 'Chiikawa',
346 palette: { ...CLAWD_PALETTE, o: 0xfdfcf8, d: 0x3c271c, p: 0xf3c0c5 },
347 layouts: {
348 22: {
349 w: 40, h: 44, art: CHIIKAWA_ART, rx: 5, ry: 11, keys: [3, 36], top: 35, paws: [10, 26], bubbleX: 26,
350 eyes: [[8, 9], [16, 9]], snot: { at: [15, 14], max: 2 }, zs: [[32, 18, false], [36, 14, true]],
351 },
352 20: {
353 w: 37, h: 40, art: CHIIKAWA_20, rx: 3, ry: 8, keys: [1, 32], top: 31, paws: [7, 23], bubbleX: 23,
354 eyes: [[7, 9], [15, 9]], snot: { at: [14, 14], max: 2 }, zs: [[29, 15, false], [33, 11, true]],
355 },
356 18: {
357 w: 33, h: 36, art: CHIIKAWA_18, rx: 3, ry: 7, keys: [1, 32], top: 27, paws: [7, 23], bubbleX: 23, dots: 3,
358 eyes: [[7, 9], [15, 9]], snot: { at: [14, 14], max: 2 }, zs: [[29, 14, false]],
359 },
360 14: {
361 w: 26, h: 28, art: CHIIKAWA_14, rx: 2, ry: 6, keys: [0, 25], top: 21, paws: [5, 16], bubbleX: 20, dots: 3,
362 eyes: [[5, 7], [12, 7]], snot: { at: [11, 12], max: 1 }, zs: [[23, 11, false]],
363 },
364 8: {
365 w: 15, h: 16, art: CHIIKAWA_8, rx: 2, ry: 4, keys: [1, 13], top: 12, paws: [3, 8], bubbleX: 12, dots: 1, mini: true,
366 eyes: [[2, 3], [7, 3]], zs: [[12, 5, false]],
367 },
368 },
369}
370
371export const CHARACTERS: Readonly<Record<CharacterId, Character>> = { usagi: USAGI, chiikawa: CHIIKAWA }
372
373/** 설정값(캐릭터 id)의 캐릭터, 모르는 값이면 usagi. */
374export function characterFor(setting: string): CharacterId {
375 return Object.hasOwn(CHARACTERS, setting) ? (setting as CharacterId) : 'usagi'
376}
377hooks/themes.ts 100 lines1// The three 2010s skins. A theme is pure data: colours for every part of the
2// pane and a Stage backdrop.
3
4import type { ThemeName } from '../types'
5import { lookup } from './text'
6
7export type Theme = {
8 name: ThemeName
9 label: string
10 title: { top: string; bottom: string; text: string; buttons: 'right' | 'traffic' | 'ubuntu' }
11 tabs: { bar: string; idle: string; active: string; activeText: string; text: string; hover: string }
12 body: string
13 ink: string
14 soft: string
15 accent: string
16 hover: string
17 film: { film: string; hole: string; gap: string; playhead: string }
18 status: { bg: string; text: string }
19 dialog: { title: string; titleText: string; body: string; text: string; button: { fg: string; bg: string; hover: string } }
20 levels: { ok: string; warn: string; critical: string }
21 graph: { bg: string; grid: string; line: string }
22 /** The Stage backdrop's colour at pixel (x, y) of the 40x24 scene, as 0xRRGGBB. */
23 backdrop(x: number, y: number): number
24}
25
26function mix(from: number, to: number, t: number): number {
27 const u = Math.min(1, Math.max(0, t))
28 const channel = (shift: number) => {
29 const a = (from >> shift) & 0xff
30 const b = (to >> shift) & 0xff
31 return Math.round(a + (b - a) * u) << shift
32 }
33 return channel(16) | channel(8) | channel(0)
34}
35
36export const THEMES: Readonly<Record<ThemeName, Theme>> = {
37 windows7: {
38 name: 'windows7',
39 label: 'Windows 7',
40 title: { top: '#DDEBFA', bottom: '#B9D3F0', text: '#1E395B', buttons: 'right' },
41 tabs: { bar: '#E3EDF8', idle: '#E3EDF8', active: '#FFFFFF', activeText: '#1E395B', text: '#3D5A80', hover: '#FFF3C4' },
42 body: '#F3F7FC',
43 ink: '#1E395B',
44 soft: '#56708F',
45 accent: '#3B7FC4',
46 hover: '#FFF3C4',
47 film: { film: '#2A2F36', hole: '#9AA6B2', gap: '#1A1D22', playhead: '#E81123' },
48 status: { bg: '#D6E4F3', text: '#1E395B' },
49 dialog: { title: '#B9D3F0', titleText: '#1E395B', body: '#FFFFFF', text: '#1E395B', button: { fg: '#1E395B', bg: '#E3EDF8', hover: '#FFF3C4' } },
50 levels: { ok: '#3FA9F5', warn: '#FFB000', critical: '#E5484D' },
51 graph: { bg: '#000000', grid: '#008040', line: '#00FF00' },
52 backdrop: (_x, y) => mix(0x3b7fc4, 0xa9d2f5, y / 23),
53 },
54 macos: {
55 name: 'macos',
56 label: 'Mac OS X',
57 title: { top: '#EDEDED', bottom: '#C9C9C9', text: '#4D4D4D', buttons: 'traffic' },
58 tabs: { bar: '#D8D8D8', idle: '#F7F7F7', active: '#3D8BE8', activeText: '#FFFFFF', text: '#333333', hover: '#E3EEFC' },
59 body: '#ECECEC',
60 ink: '#2E2E2E',
61 soft: '#7A7A7A',
62 accent: '#3D8BE8',
63 hover: '#E3EEFC',
64 film: { film: '#C9C9C9', hole: '#ECECEC', gap: '#ECECEC', playhead: '#F5C400' },
65 status: { bg: '#D0D0D0', text: '#505050' },
66 dialog: { title: '#E2E2E2', titleText: '#4D4D4D', body: '#ECECEC', text: '#2E2E2E', button: { fg: '#FFFFFF', bg: '#3D8BE8', hover: '#5AA0F0' } },
67 levels: { ok: '#3D8BE8', warn: '#F5A623', critical: '#E5484D' },
68 graph: { bg: '#1E1E1E', grid: '#3A3A3A', line: '#3D8BE8' },
69 backdrop: (x, y) => ((x * 3 + y * 5) % 7 < 3 ? 0x3e4350 : 0x383d49),
70 },
71 ubuntu: {
72 name: 'ubuntu',
73 label: 'Ubuntu',
74 title: { top: '#5A5954', bottom: '#3C3B37', text: '#DFDBD2', buttons: 'ubuntu' },
75 tabs: { bar: '#E6E4E1', idle: '#E6E4E1', active: '#FFFFFF', activeText: '#3C3B37', text: '#5E5C57', hover: '#FBD9C8' },
76 body: '#F2F1F0',
77 ink: '#3C3B37',
78 soft: '#7A776F',
79 accent: '#E95420',
80 hover: '#FBD9C8',
81 film: { film: '#2C2C2C', hole: '#77746E', gap: '#1C1C1C', playhead: '#F07746' },
82 status: { bg: '#DFDBD2', text: '#3C3B37' },
83 dialog: { title: '#3C3B37', titleText: '#DFDBD2', body: '#F2F1F0', text: '#3C3B37', button: { fg: '#FFFFFF', bg: '#E95420', hover: '#F07746' } },
84 levels: { ok: '#4E9A06', warn: '#F57900', critical: '#CC0000' },
85 graph: { bg: '#2C001E', grid: '#4A1B3F', line: '#F07746' },
86 backdrop: (x, y) => {
87 const t = Math.min(1, (x * 0.6 + y * 1.4) / 44)
88 return t < 0.6 ? mix(0x2c001e, 0x77216f, t / 0.6) : mix(0x77216f, 0xe95420, (t - 0.6) / 0.4)
89 },
90 },
91}
92
93export type Os = 'windows' | 'macos' | 'linux' | 'other'
94const AUTO: Readonly<Record<Os, ThemeName>> = { windows: 'windows7', macos: 'macos', linux: 'ubuntu', other: 'ubuntu' }
95
96/** The theme for a setting (`auto` or a theme name) on an OS; anything unknown behaves as `auto`. */
97export function themeFor(setting: string, os: Os): Theme {
98 return lookup<Theme>(THEMES, setting) ?? THEMES[AUTO[os]]
99}
100hooks/views/chrome.tsx 140 lines1// The themed window chrome: the title bar (a gradient raster row plus a
2// clickable text row), the tab row and the status bar. Each text row is a list
3// of segments that client/row.tsx draws and makes clickable; `segmentsText`
4// draws the same segments as plain Text when a Client is not available.
5
6import type { Elements } from 'claude-code'
7
8import type { RowSegment } from '../client/row'
9import { DEFAULT_COLOR, textWords, wordsToCells } from '../art/pixels'
10import type { Theme } from '../themes'
11
12type Els = Elements['terminal']
13
14export const TITLE = 'Usagi code'
15const CLOSE_RED = '#C75050'
16const CLOSE_HOVER = '#E04343'
17const TRAFFIC = ['#FF5F57', '#FEBC2E', '#28C840'] as const
18const UBUNTU_GREY = '#8A8780'
19const CLAWD_ORANGE = '#D97757'
20
21// 터미널 칸 폭: 한글·한자 같은 넓은 글자는 두 칸
22export const len = (s: string) => [...s].reduce((n, ch) => n + (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch) ? 2 : 1), 0)
23const plain = (text: string, fg: string, bg: string, bold = false): RowSegment => ({ id: '', text, fg, bg, bold })
24
25/** Pads (or cuts) a row of segments to exactly `cols` cells, the padding in `fill`. */
26export function fitRow(segments: readonly RowSegment[], cols: number, fill: string, fillFg: string): RowSegment[] {
27 const out: RowSegment[] = []
28 let used = 0
29 for (const seg of segments) {
30 if (used >= cols) break
31 const room = cols - used
32 const text = len(seg.text) <= room ? seg.text : [...seg.text].slice(0, room).join('')
33 out.push({ ...seg, text })
34 used += len(text)
35 }
36 if (used < cols) out.push(plain(' '.repeat(cols - used), fillFg, fill))
37 return out
38}
39
40function mixHex(a: string, b: string, t: number): number {
41 const x = parseInt(a.slice(1), 16)
42 const y = parseInt(b.slice(1), 16)
43 const ch = (s: number) => Math.round(((x >> s) & 255) + (((y >> s) & 255) - ((x >> s) & 255)) * t) << s
44 return ch(16) | ch(8) | ch(0)
45}
46
47/** The title bar's top raster row: a glossy gradient with rounded corners. */
48export function chromeCells(theme: Theme, cols: number): string {
49 const top = parseInt(theme.title.top.slice(1), 16)
50 const lower = mixHex(theme.title.top, theme.title.bottom, 0.6)
51 const words = new Uint32Array(cols * 3)
52 for (let i = 0; i < cols; i++) {
53 const corner = i === 0 || i === cols - 1
54 words[i * 3] = 0x2580
55 words[i * 3 + 1] = corner ? DEFAULT_COLOR : top
56 words[i * 3 + 2] = lower
57 }
58 return wordsToCells(words)
59}
60
61/** The title row: the window buttons where the theme puts them, the title, and a clickable close. */
62export function titleSegments(theme: Theme, cols: number): RowSegment[] {
63 const bg = theme.title.bottom
64 const ink = theme.title.text
65 if (theme.title.buttons === 'traffic') {
66 const lights: RowSegment[] = [
67 plain(' ', ink, bg),
68 { id: 'close', text: '●', fg: TRAFFIC[0], bg, hoverBg: theme.title.top },
69 plain(' ', ink, bg),
70 plain('●', TRAFFIC[1], bg),
71 plain(' ', ink, bg),
72 plain('●', TRAFFIC[2], bg),
73 ]
74 const before = Math.max(1, Math.floor((cols - len(TITLE)) / 2) - 6)
75 return fitRow([...lights, plain(' '.repeat(before), ink, bg), plain(TITLE, ink, bg, true)], cols, bg, ink)
76 }
77 if (theme.title.buttons === 'ubuntu') {
78 return fitRow(
79 [
80 plain(' ', ink, bg),
81 { id: 'close', text: '●', fg: theme.accent, bg, hoverBg: theme.title.top },
82 plain(' ', ink, bg),
83 plain('●', UBUNTU_GREY, bg),
84 plain(' ', ink, bg),
85 plain('●', UBUNTU_GREY, bg),
86 plain(' ', ink, bg),
87 plain(TITLE, ink, bg, true),
88 ],
89 cols,
90 bg,
91 ink,
92 )
93 }
94 const right = [plain(' ─ ', ink, bg), plain(' □ ', ink, bg), { id: 'close', text: ' ✕ ', fg: '#FFFFFF', bg: CLOSE_RED, hoverBg: CLOSE_HOVER, bold: true }]
95 const left = [plain(' ', ink, bg), plain('◆', CLAWD_ORANGE, bg, true), plain(' ', ink, bg), plain(TITLE, ink, bg)]
96 const gap = Math.max(1, cols - left.reduce((n, s) => n + len(s.text), 0) - right.reduce((n, s) => n + len(s.text), 0))
97 return fitRow([...left, plain(' '.repeat(gap), ink, bg), ...right], cols, bg, ink)
98}
99
100
101export type StatusFigures = { context?: number; fiveHour?: number }
102
103const SIZE_BUTTONS_W = 7 // ' ▲ ▼ ' + 여백
104
105/** The status bar: context and 5-hour usage, and the ▲▼ buttons (keys + -) that resize the Stage at the right. */
106export function statusSegments(theme: Theme, cols: number, s: StatusFigures): RowSegment[] {
107 const { bg, text } = theme.status
108 const segments: RowSegment[] = [plain(' ', text, bg)]
109 segments.push(plain(`Context ${s.context === undefined ? '—' : `${s.context}%`}`, text, bg))
110 if (s.fiveHour !== undefined) {
111 segments.push(plain(' · ', text, bg))
112 segments.push(plain(`5-hour ${s.fiveHour}%`, text, bg))
113 }
114 const left = fitRow(segments, Math.max(0, cols - SIZE_BUTTONS_W), bg, text)
115 const buttons: RowSegment[] = [
116 { id: 'stage-grow', text: ' ▲ ', fg: text, bg, hoverBg: theme.accent, key: '+' },
117 { id: 'stage-shrink', text: ' ▼ ', fg: text, bg, hoverBg: theme.accent, key: '-' },
118 ]
119 return fitRow([...left, ...buttons], cols, bg, text)
120}
121
122/** The same segments as plain, non-clickable Text: the fallback when a Client is not available. */
123export function segmentsText(els: Els, segments: readonly RowSegment[]) {
124 const { Box, Text } = els
125 return (
126 <Box flexDirection="row">
127 {segments.map(seg => (
128 <Text color={seg.fg} backgroundColor={seg.bg} bold={seg.bold === true}>
129 {seg.text}
130 </Text>
131 ))}
132 </Box>
133 )
134}
135
136/** A single row of raster text, for places a Client row is not wanted. */
137export function textRowCells(text: string, cols: number, fg: number, bg: number): string {
138 return wordsToCells(textWords(text, cols, fg, bg))
139}
140hooks/client/row.tsx 61 lines1// A surface module: one row of coloured text segments. Segments with a
2// `hoverBg` are clickable: they light up under the pointer and post
3// `{ click: id }` to the plugin on a left click, or on their `key` once the
4// row has the focus. Every segment sets its own colours, so the row reads the
5// same on light and dark terminals.
6
7import type { ClientSurface } from 'claude-code'
8
9export type RowSegment = {
10 id: string
11 text: string
12 fg: string
13 bg: string
14 hoverBg?: string
15 bold?: boolean
16 key?: string
17}
18
19export type RowProps = { segments: RowSegment[] }
20
21type RowState = { hover: string | null }
22
23// 터미널 칸 폭: 한글·한자 같은 넓은 글자는 두 칸
24const widthOf = (text: string) => [...text].reduce((n, ch) => n + (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch) ? 2 : 1), 0)
25
26export default function Row(props: RowProps, surface: ClientSurface<RowState>) {
27 const { Box, Text } = surface.elements
28 const hover = surface.state?.hover ?? null
29 const spans: { seg: RowSegment; from: number; to: number }[] = []
30 let x = 0
31 for (const seg of props.segments) {
32 spans.push({ seg, from: x, to: x + widthOf(seg.text) })
33 x += widthOf(seg.text)
34 }
35 const clickableAt = (col: number) => spans.find(s => col >= s.from && col < s.to && s.seg.hoverBg !== undefined)?.seg
36
37 surface.onPointer(e => {
38 const seg = clickableAt(e.x)
39 if (e.type === 'down' && e.button === 'left' && seg !== undefined) {
40 surface.post({ click: seg.id })
41 return
42 }
43 const next = e.type === 'leave' ? null : (seg?.id ?? null)
44 if ((e.type === 'move' || e.type === 'enter' || e.type === 'leave') && next !== hover) surface.setState({ hover: next })
45 })
46 surface.onKey(e => {
47 const seg = props.segments.find(s => s.key !== undefined && s.key === e.key)
48 if (seg !== undefined) surface.post({ click: seg.id })
49 })
50
51 return (
52 <Box flexDirection="row">
53 {props.segments.map(seg => (
54 <Text color={seg.fg} backgroundColor={seg.id === hover && seg.hoverBg !== undefined ? seg.hoverBg : seg.bg} bold={seg.bold === true}>
55 {seg.text}
56 </Text>
57 ))}
58 </Box>
59 )
60}
61hooks/usage.ts 59 lines1import type { RateLimitView, UsageSnapshot } from '../types'
2import { formatReset } from './calendar'
3
4export const WARN_AT = 80
5export const CRITICAL_AT = 95
6
7export type Level = 'ok' | 'warn' | 'critical'
8
9export type RawUsage = {
10 context: { tokens?: number; percent?: number; window?: number }
11 rateLimits: readonly RateLimitView[]
12 cost?: { usd: number }
13}
14
15export function snapshotFrom(raw: RawUsage, toolCalls: number): UsageSnapshot {
16 return {
17 ...(raw.context.percent !== undefined ? { contextPercent: raw.context.percent } : {}),
18 ...(raw.context.tokens !== undefined ? { contextTokens: raw.context.tokens } : {}),
19 ...(raw.context.window !== undefined ? { contextWindow: raw.context.window } : {}),
20 rateLimits: raw.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, ...(resetsAt !== undefined ? { resetsAt } : {}) })),
21 ...(raw.cost !== undefined ? { costUsd: raw.cost.usd } : {}),
22 toolCalls,
23 }
24}
25
26export function level(pct: number | undefined): Level {
27 if (pct === undefined || pct < WARN_AT) return 'ok'
28 return pct < CRITICAL_AT ? 'warn' : 'critical'
29}
30
31const limit = (s: UsageSnapshot, kind: string) => s.rateLimits.find(l => l.kind === kind)
32
33
34export function costLabel(s: UsageSnapshot): 'Est. cost' | 'Cost' {
35 return s.rateLimits.length > 0 ? 'Est. cost' : 'Cost'
36}
37
38
39
40const LIMIT_NAMES: Readonly<Record<string, string>> = { five_hour: '5-hour limit', seven_day: 'weekly limit' }
41
42/** The status-line warning for the highest figure at 80% or more. */
43export function usageWarning(s: UsageSnapshot, now: Date, mascot = 'Usagi'): string | undefined {
44 const candidates: { pct: number; text: string }[] = []
45 if (s.contextPercent !== undefined) candidates.push({ pct: s.contextPercent, text: `context ${s.contextPercent}% · consider /compact` })
46 for (const l of s.rateLimits) {
47 const name = LIMIT_NAMES[l.kind] ?? `${l.kind.replace(/_/g, ' ')} limit`
48 const reset = formatReset(l.resetsAt, now)
49 candidates.push({ pct: l.percentUsed, text: `${name} ${l.percentUsed}%${reset === '' ? '' : ` · resets ${reset}`}` })
50 }
51 const top = candidates.filter(c => c.pct >= WARN_AT).sort((a, b) => b.pct - a.pct)[0]
52 return top === undefined ? undefined : `${mascot}: ${top.text}`
53}
54
55
56export function pushHistory(list: readonly number[], pct: number | undefined, max: number): number[] {
57 return pct === undefined ? [...list] : [...list, pct].slice(-max)
58}
59