SLOPSHOPPER

usagi-code

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…

newpaneguardcommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usagi-code
│ ┃ Usagi code · app ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▣ client module ./client/row.tsx ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /usagi │ ┃ ⎿ usagi-code: Usagi code closed. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ auto-compact ━━━━━━━━━━━━━━━━━━──────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Usagi code · app
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▣ client module ./client/row.tsx ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ auto-compact ━━━━━━━━━━━━━━━━━━─────────────────── 49% ▶ 지시 아직 없음 └─ 서브에이전트 없음 • Claude … ─▶ $ rm -rf …n main ─▶ $ cat .env 완료 · 9단계 끝남 · 단계 9개 (완료 8 · 실패 1) ▣ client module ./client/row.tsx
README

<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.

Features

<table> <tr> <td width="50%" valign="middle">

Stage

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">

Dozing

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">

Characters

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>

  • Timeline — the order tree for this turn, the live step with its tool chain, and a progress line (steps, done, failed).
  • Subagents — each spawned agent with its type, task, state and the tool it is using now.
  • Heads-up popups — a permission prompt or a question pops a dialog (“Usagi: hey! i need ur OK …”, in the voice of whoever is on stage); a failed tool shows its first error line.
  • Usage warnings — context %, 5-hour and weekly limits in the status bar, with a nudge toward /compact and the reset time when you get close.
  • 2010s skins — Windows 7, Mac OS X and Ubuntu themes, picked from your OS or set by hand.
  • Resizable stage — ▲ ▼ in the status bar, or /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.

Usage

CommandWhat it does
/usagi · /chiikawaOpen 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 22 brings 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.

Credits

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.

Source 20 files
hooks/register.tsx 485 lines
1import { 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}
485
hooks/activity.ts 126 lines
1import { 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}
126
hooks/animator.ts 29 lines
1import 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}
29
hooks/calendar.ts 39 lines
1import 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}
39
hooks/alerts.ts 27 lines
1import 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}
27
hooks/layout.ts 37 lines
1import { 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}
37
hooks/art/stage.ts 85 lines
1import 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}
85
hooks/art/characters.ts 377 lines
1// 무대에 세울 캐릭터들: 이름, 색, 줄 수마다 손으로 그린 도안과 배치표.
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}
377
hooks/themes.ts 100 lines
1// 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}
100
hooks/views/chrome.tsx 140 lines
1// 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}
140
hooks/client/row.tsx 61 lines
1// 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}
61
hooks/usage.ts 59 lines
1import 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