SLOPSHOPPER

desk-look

Claude Code mod that redraws the terminal transcript to look like the Code tab of the Claude desktop app: prompt bubbles, markdown tables, mermaid diagrams…

newpanebandspinnerrowsguard
★ 1v0.15.0no licenseupdated 2026-10-09KyongSik-Yoon/cc-desktop-mod/desk-look
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desk-look
│ ┃ Sessions ✕ │ ┃ 0 recent sessions ↻ ╭─────────────────────────────────────────────────────╮ │ ┃ │ fix the failing auth test and add an audit log call │ │ ┃ Search: title or folder ⏎ Open first result ╰─────────────────────────────────────────────────────╯ │ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ Edited auth.ts +0 −0 › │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ Done. I made refresh reject expired claims and added an audit call. │ │ ✻ Worked… │ ╭──────────────────────────────────────────────────────────────────── │ │ Edited 3 files +16 −1 │ │ auth.ts +2 −1 │ │ audit.ts +6 −0 │ │ cache.ts +8 −0 │ ╰──────────────────────────────────────────────────────────────────── │ │ › /desk-sessions │ ⎿ desk-look: Opened the sessions pane. │ │ ·●· Thinking 0s ⟨Claude Code's own drawing⟩ app feat/auth-refresh +2 −1  #NaN ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ app feat/auth-refresh +2 −1  #NaN
Pane · Sessions
0 recent sessions ↻ Search: title or folder ⏎ Open first result
Pane · Changes
● Uncommitted ○ Branch ○ Commits Edited 1 file +2 −1 ↻ src/auth.ts +2 −1 ⌄ Revert 3 export async function refresh(token: string) { 4 const claims = await verify(token) 5 - if (!claims) throw new Error('invalid token') 5 + if (!claims || claims.exp < Date.now() / 1000) throw 6 + await audit('refresh', claims.sub) 7 return issue(claims.sub)
Pane · PR
 #NaN ↻ Open · ← · +0 −0 Checks none Unresolved reviews unavailable
Pane · Context
Context ◑ 49% ↻ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 97.4k / 200.0k tokens Usage limits 5-hour ▰▰▰▱▱▱▱▱▱▱ 31% This session cost $0.42
Spinner
·●· Thinking 0s
Turn line
✻ Worked… ╭──────────────────────────────────────────────────────────────────────╮ │ Edited 3 files +16 −1 │ │ auth.ts +2 −1 │ │ audit.ts +6 −0 │ │ cache.ts +8 −0 │ ╰──────────────────────────────────────────────────────────────────────╯
Your message
╭─────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰─────────────────────────────────────────────────────╯
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
Edited auth.ts +0 −0 ›
README

cc-desktop-mod: Claude desktop look for the Claude Code terminal

English | 한국어

desk-look is a Claude Code plugin (mod) that makes the Claude Code CLI terminal UI look like the Code tab of the Claude desktop app: prompt bubbles, full-width markdown tables, mermaid diagrams, folded tool calls, subagent cards, a plan card, a checklist, a diff pane, a pull request bar for GitHub and GitLab, and a context usage meter. It is a Claude Code function-hook plugin: the engine stays as it is, and only the way things are drawn on screen changes.

desk-look in the Claude Code terminal: a prompt bubble, folded tool calls, a markdown table, a mermaid flowchart, a note box and the edited-files card

desk-look

AreaTerminal defaultdesk-look
Your promptA line starting with >A gray bubble (a pill for one line, a box with rounded corners for several). Left (default) or right, set in the config
Answers● bullet + engine markdownA markdown renderer in the desktop prose style
TablesGrid tablesFull-width tables with a gray header and row separators
mermaidCode blockBox diagrams (lovely-mermaid) with arrowheads that touch the boxes
> [!NOTE] alertsBlockquoteA box with a border colored by alert type
Tool callsOne row per call + a result blockConsecutive calls fold into one line, Ran 2 commands, edited a file +3 −1 ›, that expands on click. Expanded, edit diffs show right away (file line numbers, three lines of context)
SubagentsOne Ran an agent line; notifications and messages only expand with ctrl+oA card per agent (type and description; tool count, tokens, time and changed lines; click for the result). Background notifications read ✓ description completed · 5s; click an agent message ◆ name first line › to expand it in place
End of turnA duration lineA card of the files edited that turn, Edited N files +N −M (click a file row to open the /desk-diff pane at that file), and ⧉ copy at the right edge to copy the answer
Plan (ExitPlanMode)Folded into one line with other toolsA card of its own, ◇ Plan Approved (it also shows waiting, rejected and interrupted), with the plan as markdown. A long plan shows its start and expands with Show all. The approval dialog is the engine's own
TasksTool linesA checklist card above the prompt, Tasks 2/5 (✓ done, ◉ in progress, ○ to do). Only in sessions that have TodoWrite or TaskCreate
Above the prompt—A rounded gray band like the chin under the desktop prompt: repo branch on the left, +N −M on the right. Once context passes half, a ◑ 62% chip sits next to the band (caution color from 75%, warning color from 90%); click it for the /desk-context pane
Progress✻ Simmering… (12s · ↓ 300 tokens)●·· Running… 2m 29s (a word that matches the work, clay-colored dots)
Code blocksNo way to copyHover to see ⧉ copy at the right of the top border; click to copy the same way /copy does
Questions (AskUserQuestion)A list under the promptA question card right above the prompt. With an empty prompt, pick by number alone (1-8 options, 0 submit, 9 the default dialog); multi-select with ☐/☑; Other takes two clicks to type (the first selects the row); hover an option for its preview. A notification goes out when the card opens, as the engine dialog does (preferredNotifChannel). The transcript records Asked question → answer
Command outputPlain textOutput with tables, headings, code or quotes renders as markdown, like an answer
Mode indicatorDim text at the bottom right of the promptA pill chip (with the omarchy theme)
Hint line? for shortcutsWhile a question card is open, ends with the keys that answer it (pick by number · 9 default dialog · Esc cancel)
PR bar—A chip next to the band for the current branch's pull request: #12 ✓ 5/5, ✗ 1 failing, ● 3/5 running, ◆ 2 unresolved review threads. GitHub PRs through gh, GitLab merge requests (!7, pipeline jobs, unresolved discussions) through glab. Click it for the /desk-pr pane
/desk-pr—A right-side pane with the PR or MR: state, base ← head, +N −M, review decision and merge state, failing and running checks (passing ones folded), unresolved review threads. Hand to Claude next to a failing check or a review thread fills the prompt with it. Read-only: nothing is merged, approved or posted
/desk-diff [file]—A right-side pane with per-file diffs and three scopes, like the desktop diff panel: Uncommitted, Branch (the whole branch since it left the base branch, via merge-base) and Commits (per commit: click a commit to expand its diff). Give a file name (the end of the path is enough) to scroll to it, or branch / commits to open in that scope. Click a file's header row to fold or unfold its diff. Uncommitted files have Revert: press it twice within 5 seconds to git restore (not offered for new files)
/desk-sessions—A right-side pane with recent sessions grouped by project. A search box at the top (title and folder; Enter opens the first result). Click a row or run /desk-sessions <number> to switch
/desk-contextThe /context gridA right-side pane with a context bar (tokens / window, auto-compact point), a per-category breakdown (the same categories as /context; a local estimate, so no API requests), 5-hour and 7-day usage limits with time until reset, and the session cost. ↻ recalculates

Tool lines, agent cards, agent messages, session rows, question options, the context and PR chips, commit rows, and file rows in the end-of-turn card and the diff pane are buttons across the whole row, so clicking the description or the › works too (Claude Code 2.1.295+; older versions only take the leading part).

The mod's own labels are in English or Korean: the language setting picks one, and by default follows Claude Code's own language setting.

Nothing changes in the Code tab of the desktop app (the desktop surface).

Install

At the prompt of a Claude Code terminal session:

/plugin install desk-look --marketplace KyongSik-Yoon/cc-desktop-mod

Answer y when asked to add the marketplace, and pick the user scope.

Settings

Search for desk-look in /config to find these options.

OptionValuesDescription
languageauto (default), en, koLanguage of the mod's own labels, panes, toasts and the text it puts in the prompt. auto follows Claude Code's language in /config when the session starts: Korean when it names Korean, English otherwise
bubbleSideleft (default), rightWhere your prompt bubble sits. The attachment lines the engine draws under the bubble (└ 1 skill available and so on) can't be moved by a plugin and always sit on the left, so left keeps them aligned. right is like the desktop app
askNotifyon (default), offNotify when a question card opens. The engine dialog notifies when it opens, but the engine sends nothing while the card is showing, so the card sends it itself (2.1.295+)
prBaron (default), offThe PR chip next to the band. It reads the current branch's PR with gh (GitHub) or glab (GitLab) at session start and after each turn, at most once every 30 seconds; without either tool, or in a repository on neither host, it stays hidden and is not asked again for that branch; network errors are retried after the next turn. /desk-pr opens regardless of this setting
contextChipauto (default), always, offThe context chip next to the band above the prompt. auto shows it only from 50% (a status line or another plugin often shows context all the time already), always always shows it, off hides it. The /desk-context pane opens regardless of this setting

Update

claude plugin update desk-look

Then run /reload-plugins in your session.

Switching sessions

Click a row in the /desk-sessions pane, or give a number as in /desk-sessions 3:

  • A session in the same folder: the current window switches to that session right away (runs /resume <id>). If running it is blocked, the command is placed in the prompt; press Enter.
  • A session in another folder: copies cd <folder> && claude --resume <id> to the clipboard (wl-copy).

Behavior by environment

  • Colors: uses engine theme keys (text, inactive, claude …), so both light and dark themes are followed.
  • Gray bubble fill: drawn only when the omarchy theme (~/.local/state/omarchy/current/theme/colors.toml) can be read; a theme change is picked up within 3 seconds. Elsewhere the bubble is a rounded border. The rounded ends use Nerd Font half-circle glyphs and Symbols for Legacy Computing blocks. The chin band above the prompt works the same way: without the theme it draws only the chips, with no gray band.
  • Image thumbnails: drawn above the bubble only in kitty and Ghostty outside a multiplexer (tmux, zellij, herdr).
  • Question card: used only in sessions with a single terminal attached. When a remote client (mobile or web) is attached, or an option has a preview, the engine's default question dialog opens instead. Esc stops the turn, so it also cancels the question.
  • Links: inside a multiplexer that doesn't pass OSC 8 hyperlinks through, the URL is appended dimly after the name.

Development

cd desk-look
claude plugin validate .   # check the manifest and hook modules
claude plugin test .       # run tests/*.test.tsx

Add the local folder as a marketplace as is, and changes in the repo only need /reload-plugins.

claude plugin marketplace add ~/dev/git-repo/cc-desktop-mod

desk-look/.claude-plugin/types/ holds type definitions the engine lays down again every time it loads the mod, so it isn't committed.

Third party

  • desk-look/hooks/vendor/lovely-mermaid.js: a bundle of lovely-mermaid, Apache-2.0 (LICENSE.lovely-mermaid)
Source 15 files
hooks/register.tsx 2279 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, TextHoverProps, Timer, ToolGroupCall, UiPressArgument } from 'claude-code'
3
4import type { DiffScope, DiffView, FileDiff, ImageInfo, PrCheck, PrInfo, PrState, PrThread, RepoInfo, RunCall, Runs, AskState, SessionEntry, Surface, TaskItem, TurnCard, TurnEdit, UsageBreakdown, UsageInfo } from '../types'
5import { IMAGE_TOKEN, LIST_IMAGES, parseImages, supportsGraphics, thumbnailSize } from './images'
6import { cellWidth, parseBlocks, renderMarkdown } from './markdown'
7import { agentOf, computeRuns, isAgentTool, isPlanTool, planOf } from './runs'
8import { LIST_SESSIONS, ago, clip, filterSessions, groupSessions, numbered, parseSessions } from './sessions'
9import { WRITE as ASK_WRITE, answerOf, numberProblem, registerAsk } from './ask'
10import type { AskReply } from './ask'
11import { computeTasks, visibleTasks } from './tasks'
12import { OMARCHY_COLORS, parseSurface } from './theme'
13import { limitName, contextChipMode, meterBar, meterLevel, resetIn, ring, showsChip, toBreakdown, toUsage } from './usage'
14import type { ContextChipMode } from './usage'
15import { checkPrompt, checkSummary, fetchReview, mergeLevel, mergeName, prChipParts, prNumber, prRef, reviewName, threadPrompt } from './pr'
16import { LOG_FORMAT, MAX_COMMITS, SCOPES, baseCandidates, parseCommits, scopeLabel, scopeOf } from './gitview'
17import { T, langFromConfig, langSetting, setLang } from './i18n'
18
19export { cellWidth } from './markdown'
20
21// 데스크톱 앱 CDS 토큰의 역할을 엔진 테마 키에 대응시킨다.
22// hex 를 박으면 터미널 테마(밝음/어두움)와 어긋나므로, 사용자의 테마를 따르는 키만 쓴다.
23const COLORS = {
24  text: 'text',
25  muted: 'inactive',
26  border: 'subtle',
27  clay: 'claude',
28  accent: 'claude',
29  link: 'suggestion',
30  code: 'error',
31  success: 'success',
32  danger: 'error',
33  warning: 'warning',
34} as const
35
36const PANE = 'desk-diff'
37const SESSIONS_PANE = 'desk-sessions'
38const CONTEXT_PANE = 'desk-context'
39const PR_PANE = 'desk-pr'
40const PER_GROUP = 8
41
42const open = atom({ plugin: 'desk-look', key: 'open' } as const, {} as Record<string, boolean>)
43const diff = atom({ plugin: 'desk-look', key: 'diff' } as const, [] as FileDiff[])
44const repo = atom({ plugin: 'desk-look', key: 'repo' } as const, null as RepoInfo | null)
45const surface = atom({ plugin: 'desk-look', key: 'surface' } as const, null as Surface | null)
46const runs = atom({ plugin: 'desk-look', key: 'runs' } as const, { firstOf: {}, calls: {} } as Runs)
47const images = atom({ plugin: 'desk-look', key: 'images' } as const, {} as Record<string, ImageInfo>)
48const graphics = atom({ plugin: 'desk-look', key: 'graphics' } as const, false)
49const pendingEdits = atom({ plugin: 'desk-look', key: 'pendingEdits' } as const, [] as TurnEdit[])
50const turnCards = atom({ plugin: 'desk-look', key: 'turnCards' } as const, [] as TurnCard[])
51const sessions = atom({ plugin: 'desk-look', key: 'sessions' } as const, [] as SessionEntry[])
52const sessionQuery = atom({ plugin: 'desk-look', key: 'sessionQuery' } as const, '')
53const turnStartedAt = atom({ plugin: 'desk-look', key: 'turnStartedAt' } as const, null as number | null)
54// 질문 카드 상태. ask.tsx 의 것과 같은 키다(상태 원본은 파일마다 선언해야 한다).
55const asking = atom({ plugin: 'desk-look', key: 'ask' } as const, null as AskState | null)
56const tasks = atom({ plugin: 'desk-look', key: 'tasks' } as const, [] as TaskItem[])
57const tick = atom({ plugin: 'desk-look', key: 'tick' } as const, 0)
58const usage = atom({ plugin: 'desk-look', key: 'usage' } as const, null as UsageInfo | null)
59// Button 안에 글·Text 를 넣을 수 있는 엔진(2.1.295+)인지. 모르는 동안은 옛 모양.
60const richButtons = atom({ plugin: 'desk-look', key: 'richButtons' } as const, false)
61const usageBreakdown = atom({ plugin: 'desk-look', key: 'usageBreakdown' } as const, null as UsageBreakdown | null)
62const pr = atom({ plugin: 'desk-look', key: 'pr' } as const, null as PrState | null)
63const diffView = atom({ plugin: 'desk-look', key: 'diffView' } as const, {
64  scope: 'uncommitted',
65  base: null,
66  files: [],
67  commits: [],
68  commit: null,
69  commitFiles: [],
70} as DiffView)
71
72// 스피너 시계: 엔진은 모드가 바뀔 때만 Spinner 를 다시 그리므로 직접 tick 을 올려 다시 그리게 한다.
73// 스피너가 사라지면(마지막으로 그린 지 2초가 지나면) 스스로 멈춘다.
74const SPINNER_TICK = 500
75let ticker: Timer | null = null
76const THEME_POLL = 3000
77let themeWatch: Timer | null = null
78let spinnerSeen = 0
79let spinnerFallbackStart = 0
80
81const FILE_EDITING = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
82const CARD_ROWS = 3
83// 턴 기록은 최근 60턴, 복사할 답변은 턴마다 5만 자까지만 둔다.
84const TURN_CARDS = 60
85const MAX_COPY = 50_000
86
87// 같은 파일을 여러 번 고치면 한 줄로 합친다.
88export function mergeEdit(list: TurnEdit[], edit: TurnEdit): TurnEdit[] {
89  const index = list.findIndex(item => item.path === edit.path)
90  if (index === -1) return [...list, edit]
91  return list.map((item, at) =>
92    at === index ? { ...item, added: item.added + edit.added, removed: item.removed + edit.removed } : item,
93  )
94}
95
96// 소요 시간 줄과 턴 기록을 durationMs 로 짝짓는다. 둘이 같은 값을 재지만 반올림 차이를 1초까지 봐준다.
97export function cardFor(cards: ReadonlyArray<TurnCard>, durationMs: number): TurnCard | undefined {
98  let best: TurnCard | undefined
99  for (const card of cards) {
100    const gap = Math.abs(card.durationMs - durationMs)
101    if (gap <= 1000 && (best === undefined || gap < Math.abs(best.durationMs - durationMs))) best = card
102  }
103  return best
104}
105
106const EDITING_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit', 'Bash'])
107
108type Call = Pick<ToolGroupCall, 'tool' | 'input' | 'isRunning' | 'isErrored' | 'isInterrupted' | 'output'>
109
110type Category = 'command' | 'read' | 'edit' | 'create' | 'search' | 'web' | 'agent' | 'todo' | 'ask' | 'other'
111
112const CATEGORY_OF: Record<string, Category> = {
113  Bash: 'command',
114  BashOutput: 'command',
115  KillShell: 'command',
116  PowerShell: 'command',
117  Read: 'read',
118  NotebookRead: 'read',
119  Edit: 'edit',
120  MultiEdit: 'edit',
121  NotebookEdit: 'edit',
122  Write: 'create',
123  Grep: 'search',
124  Glob: 'search',
125  LS: 'search',
126  ToolSearch: 'search',
127  WebFetch: 'web',
128  WebSearch: 'web',
129  Agent: 'agent',
130  Task: 'agent',
131  TodoWrite: 'todo',
132  TaskCreate: 'todo',
133  TaskUpdate: 'todo',
134  TaskList: 'todo',
135  AskUserQuestion: 'ask',
136}
137
138// 데스크톱의 "Ran 2 commands, created a file, edited 3 files, and 3 more actions" 문구.
139const PHRASES: Record<Category, [string, (count: number) => string]> = {
140  command: ['ran a command', count => `ran ${count} commands`],
141  read: ['read a file', count => `read ${count} files`],
142  edit: ['edited a file', count => `edited ${count} files`],
143  create: ['created a file', count => `created ${count} files`],
144  search: ['searched', count => `searched ${count} times`],
145  web: ['fetched a page', count => `fetched ${count} pages`],
146  agent: ['ran an agent', count => `ran ${count} agents`],
147  todo: ['updated todos', () => 'updated todos'],
148  ask: ['asked a question', count => `asked ${count} questions`],
149  other: ['used a tool', count => `used ${count} tools`],
150}
151
152const VERB: Record<Category, string> = {
153  command: 'Ran',
154  read: 'Read',
155  edit: 'Edited',
156  create: 'Created',
157  search: 'Searched',
158  web: 'Fetched',
159  agent: 'Agent',
160  todo: 'Updated todos',
161  ask: 'Asked',
162  other: 'Used',
163}
164
165const categoryOf = (tool: string): Category => CATEGORY_OF[tool] ?? 'other'
166
167export function describeCalls(calls: ReadonlyArray<Call>): string {
168  const counts = new Map<Category, number>()
169  for (const call of calls) {
170    const category = categoryOf(call.tool)
171    counts.set(category, (counts.get(category) ?? 0) + 1)
172  }
173  const entries = [...counts]
174  const phrase = ([category, count]: [Category, number]) =>
175    count === 1 ? PHRASES[category][0] : PHRASES[category][1](count)
176  const shown = entries.slice(0, 3).map(phrase)
177  const rest = entries.slice(3).reduce((sum, [, count]) => sum + count, 0)
178  const text = rest > 0 ? `${shown.join(', ')}, and ${rest} more action${rest === 1 ? '' : 's'}` : shown.join(', ')
179  const failed = calls.filter(call => call.isErrored).length
180  return text.charAt(0).toUpperCase() + text.slice(1) + (failed > 0 ? ` (${failed} failed)` : '')
181}
182
183const fieldsOf = (input: unknown): Record<string, unknown> =>
184  typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
185
186const stringField = (fields: Record<string, unknown>, key: string) =>
187  typeof fields[key] === 'string' ? (fields[key] as string) : undefined
188
189// 동사 옆에 붙는 회색 대상: 파일명, 명령, 패턴, URL.
190export function summarize(tool: string, input: unknown): string {
191  const fields = fieldsOf(input)
192  const path = stringField(fields, 'file_path') ?? stringField(fields, 'notebook_path') ?? stringField(fields, 'path')
193  const questions = Array.isArray(fields.questions) ? fields.questions.map(fieldsOf) : []
194  const summary =
195    (tool === 'Bash' ? stringField(fields, 'command') : undefined) ??
196    (tool === 'AskUserQuestion' ? stringField(questions[0] ?? {}, 'question') : undefined) ??
197    (path ? path.split('/').pop() : undefined) ??
198    stringField(fields, 'pattern') ??
199    stringField(fields, 'url') ??
200    stringField(fields, 'query') ??
201    stringField(fields, 'description') ??
202    stringField(fields, 'prompt') ??
203    Object.values(fields).find((value): value is string => typeof value === 'string') ??
204    ''
205  return summary.split('\n')[0] ?? ''
206}
207
208// 질문 도구의 답: 결과의 answers 값들. 없으면 null.
209export function askAnswer(output: unknown): string | null {
210  let value = output
211  if (typeof value === 'string') {
212    try {
213      value = JSON.parse(value)
214    } catch {
215      return null
216    }
217  }
218  const answers = fieldsOf(fieldsOf(value).answers)
219  const list = Object.values(answers).filter((answer): answer is string => typeof answer === 'string' && answer !== '')
220  return list.length > 0 ? list.join(' · ') : null
221}
222
223function verbOf(tool: string): string {
224  const category = categoryOf(tool)
225  if (category !== 'other') return VERB[category]
226  // mcp__server__tool → tool
227  return `Used ${tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool) : tool}`
228}
229
230const lineCount = (text: unknown) => (typeof text === 'string' && text.length > 0 ? text.split('\n').length : 0)
231
232// 앞뒤로 같은 줄 수. 편집은 old_string 에 문맥 줄을 함께 넣으니 그 줄은 바뀐 것으로 치지 않는다.
233function commonEnds(before: string[], after: string[]): { head: number; tail: number } {
234  let head = 0
235  while (head < before.length && head < after.length && before[head] === after[head]) head++
236  let tail = 0
237  while (
238    tail < before.length - head &&
239    tail < after.length - head &&
240    before[before.length - 1 - tail] === after[after.length - 1 - tail]
241  )
242    tail++
243  return { head, tail }
244}
245
246const linesOf = (text: string) => (text === '' ? [] : text.split('\n'))
247
248// Edit 의 old/new 는 바꾼 줄 앞뒤 문맥 줄을 함께 담는다. 앞뒤 공통 줄을 걷어내고 남은 줄만 센다.
249export function lineChange(oldText: string, newText: string): { added: number; removed: number } {
250  const before = linesOf(oldText)
251  const after = linesOf(newText)
252  const { head, tail } = commonEnds(before, after)
253  return { added: after.length - head - tail, removed: before.length - head - tail }
254}
255
256// 편집 도구의 +/− 줄 수: 데스크톱의 초록·빨강 칩.
257export function diffStat(call: Call): { added: number; removed: number } | null {
258  if (call.isErrored || call.isInterrupted) return null
259  const fields = fieldsOf(call.input)
260  switch (call.tool) {
261    case 'Edit':
262      return lineChange(stringField(fields, 'old_string') ?? '', stringField(fields, 'new_string') ?? '')
263    case 'MultiEdit': {
264      const edits = Array.isArray(fields.edits) ? fields.edits.map(fieldsOf) : []
265      const changes = edits.map(edit => lineChange(stringField(edit, 'old_string') ?? '', stringField(edit, 'new_string') ?? ''))
266      return {
267        added: changes.reduce((sum, change) => sum + change.added, 0),
268        removed: changes.reduce((sum, change) => sum + change.removed, 0),
269      }
270    }
271    case 'Write':
272      return { added: lineCount(fields.content), removed: 0 }
273    case 'NotebookEdit':
274      return { added: lineCount(fields.new_source), removed: 0 }
275    default:
276      return null
277  }
278}
279
280export function outputText(output: unknown): string {
281  if (output === undefined || output === null) return ''
282  if (typeof output === 'string') return output
283  const fields = fieldsOf(output)
284  const stdout = stringField(fields, 'stdout')
285  if (stdout !== undefined) return [stdout, stringField(fields, 'stderr') ?? ''].filter(Boolean).join('\n')
286  const file = fieldsOf(fields.file)
287  const content = stringField(file, 'content') ?? stringField(fields, 'content')
288  if (content !== undefined) return content
289  try {
290    return JSON.stringify(output, null, 2)
291  } catch {
292    return String(output)
293  }
294}
295
296function cap(text: string, lines: number): string {
297  const all = text.replace(/\s+$/, '').split('\n')
298  return all.length <= lines ? all.join('\n') : `${all.slice(0, lines).join('\n')}\n${T.moreLines(all.length - lines)}`
299}
300
301// Edit·MultiEdit 결과의 structuredPatch: 파일 기준 줄 번호와 앞뒤 문맥이 붙은 헌크. 없으면 null.
302export function patchOf(output: unknown): string | null {
303  const hunks = (output as { structuredPatch?: unknown } | null | undefined)?.structuredPatch
304  if (!Array.isArray(hunks) || hunks.length === 0) return null
305  return hunks
306    .map(hunk => {
307      const h = hunk as { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
308      return [`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`, ...h.lines].join('\n')
309    })
310    .join('\n')
311}
312
313// 편집 하나를 diff 헌크로(결과에 헌크가 없을 때): 바뀐 줄만 −/+, 앞뒤 같은 줄은 문맥으로 두 줄까지.
314export function editHunk(oldText: string, newText: string, context = 2): string {
315  const before = linesOf(oldText)
316  const after = linesOf(newText)
317  const { head, tail } = commonEnds(before, after)
318  const lead = before.slice(Math.max(0, head - context), head)
319  const trail = before.slice(before.length - tail, before.length - tail + context)
320  const removed = before.slice(head, before.length - tail)
321  const added = after.slice(head, after.length - tail)
322  const start = head - lead.length + 1
323  return [
324    `@@ -${start},${lead.length + removed.length + trail.length} +${start},${lead.length + added.length + trail.length} @@`,
325    ...lead.map(line => ` ${line}`),
326    ...removed.map(line => `-${line}`),
327    ...added.map(line => `+${line}`),
328    ...trail.map(line => ` ${line}`),
329  ].join('\n')
330}
331
332// `git diff` 출력을 파일 단위로 나눈다. Code format: 'diff' 는 @@ 헌크부터 받는다.
333export function parseDiff(text: string, isNew = false): FileDiff[] {
334  const files: FileDiff[] = []
335  for (const chunk of text.split(/^diff --git /m).slice(1)) {
336    const lines = chunk.split('\n')
337    const header = lines[0] ?? ''
338    const path = header.replace(/^a\/.* b\/(.*)$/, '$1')
339    const firstHunk = lines.findIndex(line => line.startsWith('@@'))
340    const hunks = firstHunk === -1 ? [] : lines.slice(firstHunk)
341    files.push({
342      path,
343      added: hunks.filter(line => line.startsWith('+')).length,
344      removed: hunks.filter(line => line.startsWith('-')).length,
345      patch: hunks.join('\n').trimEnd(),
346      isNew,
347    })
348  }
349  return files
350}
351
352// 사용자 git 설정(diff.mnemonicPrefix·noprefix)과 상관없이 머리말을 a/ b/ 로 고정해 경로를 읽는다.
353const GIT_DIFF = ['--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/']
354
355// 컨텍스트 칩의 숫자: 인자 없는 usage() 는 상태 줄의 값이라 공짜다. 턴이 끝날 때마다 다시 읽는다.
356async function refreshUsage($: EngineInterface) {
357  const [raw, now] = await Promise.all([$.session.usage(), $.clock.now()])
358  const next = toUsage(raw, now)
359  await update($, usage, previous => (JSON.stringify({ ...previous, at: 0 }) === JSON.stringify({ ...next, at: 0 }) ? previous : next))
360}
361
362// 패널을 열 때만 항목별 내역을 계산한다. summary 는 로컬 추정이라 API 요청이 없다(full 은 도구마다 요청).
363async function refreshBreakdown($: EngineInterface) {
364  const [raw, now] = await Promise.all([$.session.usage({ breakdown: 'summary' }), $.clock.now()])
365  await update($, usage, () => toUsage(raw, now))
366  await update($, usageBreakdown, () => toBreakdown(raw, now))
367}
368
369async function openContext($: EngineInterface) {
370  await refreshBreakdown($).catch(() => undefined)
371  await $.ui.open({ id: CONTEXT_PANE, title: T.paneContext, focus: true })
372}
373
374// 줄 전체가 눌리는 버튼. 2.1.295 부터 Button 안에 글·Text 를 넣을 수 있다. 그 전 엔진은 그런 그림을
375// 통째로 거부하고 자기 그림을 그리므로, 그때는 첫 조각만 버튼 라벨로 하고 나머지는 옆에 둔다.
376type RowButtonProps = { key: string; dimColor?: boolean; hotkey?: string; hover?: TextHoverProps; onPress: (press: UiPressArgument) => void }
377
378function rowButton(els: Els, rich: boolean, props: RowButtonProps, label: string, ...rest: unknown[]) {
379  const { Box, Text, Button } = els
380  const parts = rest.filter(part => part !== null && part !== undefined && part !== false && part !== '')
381  if (rich) {
382    return (
383      <Button plain {...props}>
384        {label}
385        {parts as never}
386      </Button>
387    )
388  }
389  return (
390    <Box>
391      <Button plain {...props} label={label} />
392      {parts.map(part => (typeof part === 'string' ? <Text>{part}</Text> : part)) as never}
393    </Box>
394  )
395}
396
397export function supportsRichButtons(base: string | undefined): boolean {
398  const [major = 0, minor = 0, patch = 0] = (base ?? '').split('-')[0]!.split('.').map(Number)
399  return major > 2 || (major === 2 && (minor > 1 || (minor === 1 && patch >= 295)))
400}
401
402// 아래턱 오른쪽의 "◔ 62%": 링은 단계 색, 칩 전체가 컨텍스트 패널을 여는 버튼.
403function contextMeter(els: Els, $: EngineInterface, info: UsageInfo | null, mode: ContextChipMode, rich: boolean) {
404  const percent = info?.percent ?? null
405  if (!showsChip(mode, percent)) return null
406  const { Box, Text, Button } = els
407  const level = meterLevel(percent)
408  const label = `${percent}%`
409  return {
410    width: 2 + cellWidth(label),
411    node: rich ? (
412      <Button key="context-chip" plain dimColor={level === 'muted'} onPress={() => void openContext($)}>
413        <Text color={COLORS[level]}>{ring(percent)}</Text>
414        {` ${label}`}
415      </Button>
416    ) : (
417      <Box columnGap={1}>
418        <Text color={COLORS[level]}>{ring(percent)}</Text>
419        <Button key="context-chip" plain dimColor={level === 'muted'} label={label} onPress={() => void openContext($)} />
420      </Box>
421    ),
422  }
423}
424
425// 아래턱 오른쪽의 PR 칩 "#12 ✓ 5/5": 누르면 /desk-pr 패널.
426function prChip(els: Els, $: EngineInterface, state: PrState | null, rich: boolean) {
427  const info = state?.pr
428  if (!info) return null
429  const { Box, Text, Button } = els
430  const parts = prChipParts(info)
431  const text = parts.map(part => part.text).join(' ')
432  const colored = parts.slice(1).map((part, index) => (
433    <Text key={`pr-part-${index}`} color={COLORS[part.color]}>{` ${part.text}`}</Text>
434  ))
435  return {
436    width: cellWidth(text),
437    node: rich ? (
438      <Button key="pr-chip" plain dimColor onPress={() => void openPr($)}>
439        {parts[0]!.text}
440        {colored as never}
441      </Button>
442    ) : (
443      <Box>
444        <Button key="pr-chip" plain dimColor label={parts[0]!.text} onPress={() => void openPr($)} />
445        {colored as never}
446      </Box>
447    ),
448  }
449}
450
451async function refreshDiff($: EngineInterface) {
452  const git = (args: string[]) => $.process.run(['git', ...args], { timeoutMs: 5000 })
453  const inside = await git(['rev-parse', '--is-inside-work-tree'])
454  if (inside.exitCode !== 0) {
455    const cwd = await $.session.cwd()
456    await update($, repo, () => ({ name: cwd.split('/').pop() ?? cwd, branch: null, isRepo: false }))
457    await update($, diff, () => [])
458    return
459  }
460  const [top, branch, tracked, untracked] = await Promise.all([
461    git(['rev-parse', '--show-toplevel']),
462    git(['rev-parse', '--abbrev-ref', 'HEAD']),
463    git(['diff', 'HEAD', ...GIT_DIFF]).then(ran => (ran.exitCode === 0 ? ran : git(['diff', ...GIT_DIFF]))),
464    git(['ls-files', '--others', '--exclude-standard']),
465  ])
466  // 새로 만든 파일은 `git diff HEAD` 에 안 잡힌다. 데스크톱처럼 보이도록 최대 20개까지 붙인다.
467  const fresh = untracked.stdout.split('\n').filter(Boolean).slice(0, 20)
468  const freshDiffs = await Promise.all(fresh.map(path => git(['diff', '--no-index', ...GIT_DIFF, '--', '/dev/null', path])))
469  const files = [
470    ...parseDiff(tracked.stdout),
471    ...freshDiffs.flatMap(ran => parseDiff(ran.stdout, true)),
472  ]
473  await update($, repo, () => ({
474    name: top.stdout.trim().split('/').pop() ?? '',
475    branch: branch.exitCode === 0 ? branch.stdout.trim() : null,
476    isRepo: true,
477  }))
478  await update($, diff, () => files)
479}
480
481// diff 패널의 되돌리기: 처음 누르면 5초 동안 확인 상태, 그 안에 한 번 더 누르면 HEAD 로 되돌린다.
482// 커밋하지 않은 변경을 버리는 일이라 두 번 눌러야 하고, git 이 아는 파일만 대상이다(새 파일은 지우게 되므로 없음).
483const REVERT_ARM_MS = 5000
484
485async function revertFile($: EngineInterface, path: string, isArmed: boolean) {
486  const key = `revert:${path}`
487  if (!isArmed) {
488    await update($, open, state => ({ ...state, [key]: true }))
489    $.clock.after(REVERT_ARM_MS, () => void update($, open, state => ({ ...state, [key]: false })))
490    return
491  }
492  await update($, open, state => ({ ...state, [key]: false }))
493  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
494  const ran = await $.process.run(['git', 'restore', '--source=HEAD', '--staged', '--worktree', '--', path], {
495    cwd: top.stdout.trim() || undefined,
496    timeoutMs: 5000,
497  })
498  $.ui.toast(ran.exitCode === 0 ? T.reverted(path) : T.revertFailed(ran.stderr.trim().split('\n')[0] ?? ''))
499  await refreshDiff($)
500}
501
502// 처음 상태가 펼침인 행(묶음 안의 편집)도 있어서, 지금 보이는 상태를 받아 뒤집는다.
503const toggle = ($: EngineInterface, id: string, shown?: boolean) =>
504  update($, open, state => ({ ...state, [id]: !(state[id] ?? shown ?? false) }))
505
506// 턴 끝 카드의 파일(도구가 준 경로, 보통 절대 경로)이나 /desk-diff 에 준 이름(뒷부분)에 맞는
507// diff 패널 파일(저장소 기준 경로). 절대 경로 쪽은 가장 길게 맞는 것, 이름 쪽은 첫 번째.
508export function matchDiffFile(files: ReadonlyArray<{ path: string }>, path: string): string | null {
509  const whole = files.filter(file => file.path === path || path.endsWith(`/${file.path}`))
510  const best = whole.sort((a, b) => b.path.length - a.path.length)[0]
511  return best?.path ?? files.find(file => file.path.endsWith(`/${path}`))?.path ?? null
512}
513
514// 앞을 줄인다: 경로는 파일 이름 쪽이 중요하다.
515export function clipStart(text: string, width: number): string {
516  if (cellWidth(text) <= width) return text
517  let out = ''
518  for (const ch of [...text].reverse()) {
519    if (cellWidth(ch + out) > width - 1) break
520    out = ch + out
521  }
522  return `…${out}`
523}
524
525// 데스크톱처럼 턴 끝 카드의 파일을 누르면 diff 패널을 그 파일에서 연다(접혀 있으면 펼쳐서).
526async function openDiffAt($: EngineInterface, path: string) {
527  await refreshDiffView($, 'uncommitted')
528  const target = matchDiffFile(await read($, diff), path)
529  await $.ui.open({ id: PANE, title: T.paneChanges, focus: true })
530  if (target === null) {
531    $.ui.toast(T.notInDiff)
532    return
533  }
534  await update($, open, state => ({ ...state, [`fold:${target}`]: false }))
535  // 막 연 패널은 아직 그려지기 전이라 스크롤이 거부된다("not this plugin's site"). 그려질 때까지 잠깐 다시 한다.
536  for (let tries = 0; tries < 8; tries++) {
537    const moved = await $.ui.scroll({ to: { key: `file-${target}` }, in: PANE, block: 'start' }).catch(() => ({ deny: 'failed' }))
538    if (!moved.deny) return
539    await $.clock.sleep(100).catch(() => undefined)
540  }
541}
542
543// PR 바: 턴이 끝날 때마다 다시 읽되 30초 안에는 건너뛴다(gh·glab 은 네트워크를 탄다). 도구가 없거나 어느
544// 호스트의 저장소도 아니면(isSticky) 같은 브랜치에서는 다시 묻지 않는다. 패널을 열거나 ↻ 를 누르면 바로 읽는다.
545const PR_TTL = 30_000
546let prFetch: Promise<void> | null = null
547
548async function refreshPr($: EngineInterface, force: boolean) {
549  if (prFetch) return prFetch
550  prFetch = (async () => {
551    const [branchRan, now] = await Promise.all([
552      $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 }).catch(() => null),
553      $.clock.now(),
554    ])
555    const branch = branchRan?.exitCode === 0 ? branchRan.stdout.trim() : null
556    const last = await read($, pr)
557    if (branch === null || branch === 'HEAD') {
558      await update($, pr, (): PrState => ({ status: 'none', branch, pr: null, at: now }))
559      return
560    }
561    if (!force && last && last.branch === branch && ((last.status === 'off' && last.isSticky) || now - last.at < PR_TTL)) return
562    if (force || last?.branch !== branch) await update($, pr, (previous): PrState => ({ status: 'loading', branch, pr: previous?.branch === branch ? previous.pr : null, at: now }))
563    const next = await fetchReview(argv => $.process.run(argv, { timeoutMs: 15_000 }), branch, now)
564    await update($, pr, () => next)
565  })().finally(() => {
566    prFetch = null
567  })
568  return prFetch
569}
570
571async function openPr($: EngineInterface) {
572  const shown = refreshPr($, true).catch(() => undefined)
573  await $.ui.open({ id: PR_PANE, title: 'PR', focus: true })
574  await shown
575}
576
577// "Claude에게 맡기기": 입력창이 비어 있으면 채우고, 쓰던 글이 있으면 뒤에 붙인다.
578async function fillPrompt($: EngineInterface, text: string) {
579  const box = await $.prompt.read().catch(() => ({ text: '', cursor: 0 }))
580  const isEmpty = box.text.trim() === ''
581  const filled = await $.prompt.fill({ text: isEmpty ? text : `\n\n${text}`, mode: isEmpty ? 'replace' : 'append' }).catch(() => null)
582  $.ui.toast(filled?.isFilled ? T.filled : T.fillFailed)
583}
584
585// diff 패널의 브랜치·커밋 범위. 커밋 안 한 변경은 refreshDiff 의 diff 를 그대로 쓴다.
586async function findBase($: EngineInterface, git: (args: string[]) => Promise<{ exitCode: number; stdout: string }>) {
587  const [originHead, state] = await Promise.all([git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']), read($, pr)])
588  const candidates = baseCandidates(state?.pr?.base ?? null, originHead.exitCode === 0 ? originHead.stdout.trim() : null)
589  for (const ref of candidates) {
590    const found = await git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
591    if (found.exitCode !== 0) continue
592    const base = await git(['merge-base', 'HEAD', ref])
593    if (base.exitCode === 0 && base.stdout.trim() !== '') return { ref, sha: base.stdout.trim() }
594  }
595  return null
596}
597
598async function refreshDiffView($: EngineInterface, scope?: DiffScope) {
599  const view = await read($, diffView)
600  const next = scope ?? view.scope
601  if (next === 'uncommitted') {
602    await refreshDiff($)
603    await update($, diffView, previous => ({ ...previous, scope: next }))
604    return
605  }
606  const git = (args: string[]) => $.process.run(['git', ...args], { timeoutMs: 8000 })
607  const base = await findBase($, git)
608  if (next === 'branch') {
609    await refreshDiff($)
610    const ran = base ? await git(['diff', base.sha, ...GIT_DIFF]) : null
611    // 브랜치 전체에도 아직 git 에 없는 새 파일을 넣는다(커밋 안 한 변경 쪽에서 이미 읽었다)
612    const fresh = (await read($, diff)).filter(file => file.isNew)
613    const files = ran && ran.exitCode === 0 ? [...parseDiff(ran.stdout), ...fresh] : []
614    await update($, diffView, previous => ({ ...previous, scope: next, base, files }))
615    return
616  }
617  const log = await git(['log', LOG_FORMAT, `-n${MAX_COMMITS}`, ...(base ? [`${base.sha}..HEAD`] : [])])
618  const commits = log.exitCode === 0 ? parseCommits(log.stdout) : []
619  const keep = commits.some(commit => commit.sha === view.commit) ? view.commit : null
620  await update($, diffView, previous => ({ ...previous, scope: next, base, commits, commit: keep, commitFiles: keep ? previous.commitFiles : [] }))
621}
622
623// 커밋 줄을 누르면 그 커밋의 diff 를 펼치고, 다시 누르면 접는다.
624async function pickCommit($: EngineInterface, sha: string) {
625  const view = await read($, diffView)
626  if (view.commit === sha) {
627    await update($, diffView, previous => ({ ...previous, commit: null, commitFiles: [] }))
628    return
629  }
630  const ran = await $.process.run(['git', 'show', '--format=', ...GIT_DIFF, sha], { timeoutMs: 8000 })
631  const files = ran.exitCode === 0 ? parseDiff(ran.stdout) : []
632  await update($, diffView, previous => ({ ...previous, commit: sha, commitFiles: files }))
633}
634
635async function refreshSurface($: EngineInterface) {
636  const home = await $.env.get('HOME')
637  const toml = home ? await $.fs.read(`${home}/${OMARCHY_COLORS}`).catch(() => null) : null
638  const next = toml === null ? null : parseSurface(toml)
639  await update($, surface, previous => (JSON.stringify(previous) === JSON.stringify(next) ? previous : next))
640}
641
642async function refreshImages($: EngineInterface) {
643  const ran = await $.process.run(['sh', '-c', LIST_IMAGES, 'sh', await $.session.id()], { timeoutMs: 3000 })
644  const next = ran.exitCode === 0 ? parseImages(ran.stdout) : {}
645  await update($, images, previous => (JSON.stringify(previous) === JSON.stringify(next) ? previous : next))
646}
647
648async function refreshSessions($: EngineInterface) {
649  // 빈 세션을 걸러 낸 뒤에도 40개가 차도록 넉넉히 읽는다
650  const ran = await $.process.run(['sh', '-c', LIST_SESSIONS, 'sh', '80'], { timeoutMs: 5000 })
651  const next = ran.exitCode === 0 ? parseSessions(ran.stdout).slice(0, 40) : []
652  await update($, sessions, () => next)
653}
654
655// 세션 줄을 눌렀을 때. 같은 폴더의 세션은 /resume <id> 를 실행해 지금 창에서 바로 넘어간다.
656// $.command.run 은 턴이 기다리는 훅(/desk-sessions 의 command.run) 안에서는 거부되므로, 훅이 끝난 뒤
657// 타이머로 실행한다. 그래도 실패하면 입력창에 채워 Enter 한 번으로 넘어가게 한다.
658// 다른 폴더의 세션은 그 폴더에서 열어야 하므로 실행할 명령을 클립보드에 복사한다.
659async function openSession($: EngineInterface, entry: SessionEntry) {
660  const cwd = await $.session.cwd()
661  if (entry.id === (await $.session.id())) return
662  if (entry.cwd === cwd) {
663    $.clock.after(50, () => {
664      void $.command.run({ command: 'resume', args: entry.id }).catch(async () => {
665        await $.prompt.fill({ text: `/resume ${entry.id}` })
666        $.ui.toast(T.resumeHint(entry.title))
667      })
668    })
669    return
670  }
671  const shell = `cd '${entry.cwd.replace(/'/g, "'\\''")}' && claude --resume ${entry.id}`
672  const copied = await $.process.run(['wl-copy', '--', shell], { timeoutMs: 2000 }).catch(() => null)
673  $.ui.toast(copied?.exitCode === 0 ? T.otherFolderCopied(shell) : T.otherFolder(shell))
674}
675
676// 엔진의 /copy 와 같은 길(클립보드 도구, 없으면 OSC 52)로 복사한다. 멀티플렉서·SSH 안에서도 된다.
677async function copyText($: EngineInterface, text: string, press: UiPressArgument, what: 'code' | 'answer') {
678  const copied = await $.ui.copy({ text, surface: press.surface })
679  const lines = text.split('\n').length
680  $.ui.toast(copied.isCopied ? T.copied(what, lines) : T.copyFailed(copied.reason))
681}
682
683async function refreshRuns($: EngineInterface) {
684  const messages = await $.session.messages()
685  const next = computeRuns(messages)
686  await update($, runs, previous => (JSON.stringify(previous) === JSON.stringify(next) ? previous : next))
687  const list = computeTasks(messages)
688  await update($, tasks, previous => (JSON.stringify(previous) === JSON.stringify(list) ? previous : list))
689}
690
691type Els = Elements['terminal']
692
693// 호출 하나를 펼쳤을 때: 명령과 출력, 편집 diff, 새 파일 내용.
694function callDetail({ Box, Text, Code }: Els, call: RunCall) {
695  const fields = fieldsOf(call.input)
696  const filePath = stringField(fields, 'file_path') ?? stringField(fields, 'notebook_path')
697  const output = outputText(call.output)
698  switch (call.tool) {
699    case 'Bash':
700      return (
701        <Box flexDirection="column">
702          <Code source={stringField(fields, 'command') ?? ''} language="bash" wrap="wrap" />
703          {output && <Text color={COLORS.muted}>{cap(output, 30)}</Text>}
704        </Box>
705      )
706    case 'Edit':
707    case 'MultiEdit': {
708      const edits = call.tool === 'Edit' ? [fields] : Array.isArray(fields.edits) ? fields.edits.map(fieldsOf) : []
709      const guessed = edits.map(edit => editHunk(stringField(edit, 'old_string') ?? '', stringField(edit, 'new_string') ?? ''))
710      return <Code source={cap(patchOf(call.output) ?? guessed.join('\n'), 40)} path={filePath} format="diff" />
711    }
712    case 'Write':
713      return <Code source={cap(stringField(fields, 'content') ?? '', 40)} path={filePath} />
714    default:
715      return <Text color={COLORS.muted}>{cap(output || JSON.stringify(call.input, null, 2), 20)}</Text>
716  }
717}
718
719// 백그라운드 작업이 끝났다는 알림 한 줄: "✓ Find chin in README  completed · 5s".
720// 제목은 그 작업을 띄운 호출의 설명(에이전트) 또는 요약(명령), 모르면 알림 글의 첫 줄.
721const TASK_MARK: Record<string, { mark: string; color: string }> = {
722  completed: { mark: '✓', color: COLORS.success },
723  failed: { mark: '✕', color: COLORS.danger },
724  killed: { mark: '■', color: COLORS.muted },
725}
726
727function taskRow(els: Els, task: { status?: string; toolUseId?: string; durationMs?: number }, text: string, state: Runs) {
728  const { Box, Text } = els
729  const call = Object.values(state.calls)
730    .flat()
731    .find(item => item.tool_use_id === task.toolUseId)
732  const fields = fieldsOf(call?.input)
733  const title =
734    stringField(fields, 'description') ?? (call ? summarize(call.tool, call.input) : undefined) ?? text.split('\n')[0] ?? ''
735  const style = TASK_MARK[task.status ?? ''] ?? { mark: '◆', color: COLORS.clay }
736  const facts = [task.status, task.durationMs !== undefined ? formatElapsed(task.durationMs) : null].filter(Boolean).join(' · ')
737  return (
738    <Box columnGap={1}>
739      <Text color={style.color}>{style.mark}</Text>
740      <Text color={COLORS.text} wrap="truncate-end">
741        {title}
742      </Text>
743      {facts !== '' && <Text color={COLORS.muted}>{facts}</Text>}
744    </Box>
745  )
746}
747
748// 글을 짧은 키로(같은 메시지는 같은 키): 펼침 상태를 메시지마다 기억한다.
749export function textKey(text: string): string {
750  let hash = 5381
751  for (let index = 0; index < text.length; index++) hash = ((hash << 5) + hash + text.charCodeAt(index)) | 0
752  return (hash >>> 0).toString(36)
753}
754
755// 에이전트가 보낸 메시지: "◆ Explore  첫 줄 미리보기 ›", 누르면 본문을 마크다운으로.
756function peerRow(els: Els, $: EngineInterface, name: string, text: string, openState: Record<string, boolean>, columns: number, rich: boolean) {
757  const { Box, Text, Button } = els
758  const key = `peer-${textKey(name + text)}`
759  const isOpen = openState[key] ?? false
760  const preview = text.split('\n').find(line => line.trim() !== '')?.trim() ?? ''
761  const who = name.replace(/^@/, '')
762  return (
763    <Box flexDirection="column">
764      <Box columnGap={1}>
765        <Text color={COLORS.clay}>◆</Text>
766        {rowButton(
767          els,
768          rich,
769          { key, onPress: () => toggle($, key, isOpen) },
770          who,
771          !isOpen && preview !== '' && <Text color={COLORS.muted}>{` ${clip(preview, Math.max(10, columns - cellWidth(who) - 6))}`}</Text>,
772          <Text color={COLORS.muted}>{isOpen ? ' ⌄' : ' ›'}</Text>,
773        )}
774      </Box>
775      {isOpen && (
776        <Box marginLeft={2} paddingX={1} borderStyle="round" borderColor={COLORS.border} flexDirection="column">
777          {renderMarkdown(text, els, COLORS, Math.max(20, columns - 8))}
778        </Box>
779      )}
780    </Box>
781  )
782}
783
784// 서브에이전트 카드: 데스크톱처럼 에이전트마다 한 장. 종류·설명, 끝나면 도구 수·토큰·시간·바꾼 줄.
785// 설명을 누르면 결과 글을 마크다운으로 펼친다.
786export function formatTokens(count: number): string {
787  if (count >= 1_000_000) return `${Number((count / 1_000_000).toFixed(1))}M`
788  return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count)
789}
790
791function agentCard(els: Els, $: EngineInterface, call: RunCall, openState: Record<string, boolean>, rich: boolean) {
792  const { Box, Text, Button } = els
793  const fields = fieldsOf(call.input)
794  const id = call.tool_use_id
795  const isOpen = openState[id] ?? false
796  const summary = agentOf(call.output)
797  const kind = stringField(fields, 'subagent_type') ?? summary?.type ?? 'agent'
798  const title = stringField(fields, 'description') ?? stringField(fields, 'name') ?? 'Agent'
799  const isBackground = summary?.status === 'async_launched'
800  const facts = [
801    summary?.toolUses !== undefined ? `${summary.toolUses} tool use${summary.toolUses === 1 ? '' : 's'}` : null,
802    summary?.tokens !== undefined ? `${formatTokens(summary.tokens)} tokens` : null,
803    summary?.durationMs !== undefined ? formatElapsed(summary.durationMs) : null,
804  ].filter((fact): fact is string => fact !== null)
805  const changed = (summary?.added ?? 0) + (summary?.removed ?? 0) > 0
806  const body = summary?.text ?? (isOpen ? cap(stringField(fields, 'prompt') ?? '', 12) : '')
807
808  return (
809    <Box flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={call.isRunning ? COLORS.clay : COLORS.border} paddingX={1}>
810      <Box columnGap={1}>
811        <Text color={COLORS.clay}>{call.isRunning ? '···' : '◆'}</Text>
812        <Text color={COLORS.muted}>{kind}</Text>
813        {rowButton(
814          els,
815          rich,
816          { key: `agent-${id}`, onPress: () => toggle($, id, isOpen) },
817          title,
818          call.isRunning && <Text color={COLORS.muted}> running</Text>,
819          isBackground && <Text color={COLORS.muted}> in background</Text>,
820          call.isErrored && !call.isInterrupted && <Text color={COLORS.danger}> failed</Text>,
821          call.isInterrupted && <Text color={COLORS.muted}> interrupted</Text>,
822          body !== '' && <Text color={COLORS.muted}>{isOpen ? ' ⌄' : ' ›'}</Text>,
823        )}
824      </Box>
825      {(facts.length > 0 || changed) && (
826        <Box columnGap={1}>
827          <Text color={COLORS.muted}>{facts.join(' · ')}</Text>
828          {changed && diffChips(Text, summary?.added ?? 0, summary?.removed ?? 0)}
829        </Box>
830      )}
831      {isOpen && body !== '' && (
832        <Box marginTop={1} flexDirection="column">
833          {summary?.text ? renderMarkdown(cap(summary.text, 40), els, COLORS, 76) : <Text color={COLORS.muted}>{body}</Text>}
834        </Box>
835      )}
836    </Box>
837  )
838}
839
840// 계획 카드에서 접힌 채 보일 만큼: 줄 수가 넘으면 그다음 빈 줄(코드 울타리 밖)에서 자른다.
841const PLAN_ROWS = 14
842
843export function splitPlan(plan: string, rows: number): { head: string; hidden: number } {
844  const lines = plan.split('\n')
845  let fenced = false
846  for (let index = 0; index < lines.length; index++) {
847    const line = lines[index] ?? ''
848    if (/^\s*(```|~~~)/.test(line)) fenced = !fenced
849    if (index >= rows && !fenced && line.trim() === '') {
850      const hidden = lines.slice(index).filter(rest => rest.trim() !== '').length
851      return hidden > 0 ? { head: lines.slice(0, index).join('\n'), hidden } : { head: plan, hidden: 0 }
852    }
853  }
854  return { head: plan, hidden: 0 }
855}
856
857// 계획(ExitPlanMode): 데스크톱처럼 묶음에 섞지 않고 카드로. 승인 창은 엔진 것이고 이건 기록에 남는 모습.
858function planCard(els: Els, $: EngineInterface, call: RunCall, openState: Record<string, boolean>, columns: number, rich: boolean) {
859  const { Box, Text } = els
860  const plan = planOf(call.output)?.plan ?? ''
861  const key = `plan-${call.tool_use_id}`
862  const isOpen = openState[key] ?? false
863  // 승인 창이 떠 있는 동안 엔진은 이 호출을 아직 시작 전으로 넘긴다(isRunning false, 결과·오류 없음).
864  const isWaiting = call.isRunning || (call.output === undefined && !call.isErrored)
865  const status = isWaiting
866    ? { label: T.planWaiting, color: COLORS.muted }
867    : call.isInterrupted
868      ? { label: T.planInterrupted, color: COLORS.muted }
869      : call.isErrored
870        ? { label: T.planRejected, color: COLORS.danger }
871        : { label: T.planApproved, color: COLORS.success }
872  const width = Math.max(30, Math.min(columns - 2, 100))
873  const { head, hidden } = splitPlan(plan, PLAN_ROWS)
874  const empty = isWaiting ? T.planInDialog : T.planMissing
875  return (
876    <Box flexDirection="column" width={width} borderStyle="round" borderColor={COLORS.clay} paddingX={1}>
877      <Box columnGap={1}>
878        <Text color={COLORS.clay}>◇</Text>
879        <Text color={COLORS.text} bold>
880          Plan
881        </Text>
882        <Text color={status.color}>{status.label}</Text>
883      </Box>
884      {plan === '' ? <Text color={COLORS.muted}>{empty}</Text> : renderMarkdown(isOpen ? plan : head, els, COLORS, width - 4)}
885      {hidden > 0 &&
886        rowButton(els, rich, { key, dimColor: true, onPress: () => toggle($, key) }, isOpen ? 'Show less' : `Show all · ${hidden} more lines`)}
887    </Box>
888  )
889}
890
891// 호출 한 줄: "Edited register.tsx +12 −3 ›", 누르면 아래로 펼친다.
892function callLine(els: Els, $: EngineInterface, call: RunCall, openState: Record<string, boolean>, columns: number, rich: boolean, indent = 0, openFirst = false) {
893  if (isAgentTool(call.tool)) return agentCard(els, $, call, openState, rich)
894  if (isPlanTool(call.tool)) return planCard(els, $, call, openState, columns, rich)
895  const { Box, Text, Button } = els
896  const id = call.tool_use_id
897  const isOpen = openState[id] ?? openFirst
898  const stat = diffStat(call)
899  const answer = call.tool === 'AskUserQuestion' ? askAnswer(call.output) : null
900  // 버튼 안의 글은 칸에 맞춰 잘리지 않으니 직접 자른다(동사·칩·꺾쇠 몫을 빼고).
901  const room = Math.max(12, columns - indent - cellWidth(verbOf(call.tool)) - (stat ? 16 : 0) - 8)
902  return (
903    <Box flexDirection="column" paddingLeft={indent}>
904      <Box columnGap={1}>
905        {call.isRunning && <Text color={COLORS.clay}>···</Text>}
906        {rowButton(
907          els,
908          rich,
909          { key: `row-${id}`, dimColor: true, onPress: () => toggle($, id, isOpen) },
910          verbOf(call.tool),
911          <Text color={COLORS.muted}>{` ${clip(summarize(call.tool, call.input), answer ? Math.ceil(room / 2) : room)}`}</Text>,
912          stat && ' ',
913          stat && diffChips(els.Text, stat.added, stat.removed),
914          answer && <Text color={COLORS.text}>{` → ${clip(answer, Math.floor(room / 2))}`}</Text>,
915          call.isErrored && !call.isInterrupted && <Text color={COLORS.danger}> failed</Text>,
916          call.isInterrupted && <Text color={COLORS.muted}> interrupted</Text>,
917          <Text color={COLORS.muted}>{isOpen ? ' ⌄' : ' ›'}</Text>,
918        )}
919      </Box>
920      {isOpen && (
921        <Box marginLeft={2} borderStyle="round" borderColor={COLORS.border} paddingX={1} flexDirection="column">
922          {callDetail(els, call)}
923        </Box>
924      )}
925    </Box>
926  )
927}
928
929// 묶음 한 줄: "Ran 2 commands, edited 3 files +54 −12 ›", 펼치면 호출마다 한 줄.
930// 데스크톱처럼 성공한 편집은 펼치자마자 diff 까지 보인다.
931function runLine(els: Els, $: EngineInterface, runId: string, calls: RunCall[], openState: Record<string, boolean>, columns: number, rich: boolean) {
932  // 에이전트만 모인 묶음은 접지 않고 카드를 쌓는다.
933  if (calls.length > 0 && calls.every(call => isAgentTool(call.tool))) {
934    return <els.Box flexDirection="column">{calls.map(call => agentCard(els, $, call, openState, rich))}</els.Box>
935  }
936  const { Box, Text, Button } = els
937  const key = `run-${runId}`
938  const isOpen = openState[key] ?? false
939  const stats = calls.map(diffStat).filter((stat): stat is { added: number; removed: number } => stat !== null)
940  const added = stats.reduce((sum, stat) => sum + stat.added, 0)
941  const removed = stats.reduce((sum, stat) => sum + stat.removed, 0)
942  return (
943    <Box flexDirection="column">
944      <Box columnGap={1}>
945        {calls.some(call => call.isRunning) && <Text color={COLORS.clay}>···</Text>}
946        {rowButton(
947          els,
948          rich,
949          { key, dimColor: true, onPress: () => toggle($, key) },
950          describeCalls(calls),
951          stats.length > 0 && ' ',
952          stats.length > 0 && diffChips(els.Text, added, removed),
953          <Text color={COLORS.muted}>{isOpen ? ' ⌄' : ' ›'}</Text>,
954        )}
955      </Box>
956      {isOpen && calls.map(call => callLine(els, $, call, openState, columns, rich, 2, isEditShown(call)))}
957    </Box>
958  )
959}
960
961const isEditShown = (call: RunCall) => FILE_EDITING.has(call.tool) && call.tool !== 'Write' && diffStat(call) !== null
962
963export type RowPlan = { kind: 'hide' } | { kind: 'run'; first: string; calls: RunCall[] } | { kind: 'own' }
964
965// 행(ToolUse 하나, 또는 엔진이 접은 ToolGroup 하나)이 맡은 호출 id 들로 무엇을 그릴지 정한다.
966// 묶음 첫 호출을 가진 행이 묶음 전체를 그리고, 그 묶음의 다른 행은 비운다.
967export function planRow(ids: ReadonlyArray<string>, runs: Runs): RowPlan {
968  const first = ids[0] !== undefined ? runs.firstOf[ids[0]] : undefined
969  if (first === undefined) return { kind: 'own' }
970  if (!ids.includes(first)) return { kind: 'hide' }
971  const calls = runs.calls[first] ?? []
972  return calls.length > ids.length ? { kind: 'run', first, calls } : { kind: 'own' }
973}
974
975const asRunCall = (call: ToolGroupCall | (Call & { tool_use_id: string })): RunCall => ({
976  tool_use_id: call.tool_use_id ?? '',
977  tool: call.tool,
978  input: call.input,
979  isRunning: call.isRunning,
980  isErrored: call.isErrored,
981  isInterrupted: call.isInterrupted,
982  output: call.output,
983})
984
985// 내 말풍선 위치(설정 메뉴의 bubbleSide). 왼쪽이 기본: 엔진이 말풍선 아래에 그리는 첨부 줄(└ …)이 왼쪽이라 나란해진다.
986export function bubbleSide(options: Record<string, unknown>): 'left' | 'right' {
987  return options.bubbleSide === 'right' ? 'right' : 'left'
988}
989
990export const register: Register = (on, options) => {
991  const side = bubbleSide(options)
992  const edge = side === 'right' ? 'flex-end' : 'flex-start'
993  const chipMode = contextChipMode(options)
994  const showPr = options.prBar !== 'off'
995  // 화면 언어: 설정이 en·ko 면 그것, auto 면 세션이 시작될 때 Claude Code /config 의 language 를 따른다.
996  const language = langSetting(options)
997  setLang(language === 'auto' ? 'en' : language)
998  registerAsk(on, options.askNotify !== 'off')
999
1000  on('session.start', async ($, e, next) => {
1001    if (language === 'auto') {
1002      const row = (await $.config.list().catch(() => [])).find(item => item.key === 'language')
1003      if (setLang(langFromConfig(row?.value))) $.ui.invalidate('ui.render')
1004    }
1005    await $.command.register({
1006      name: 'desk-sessions',
1007      description: T.cmdSessions,
1008      argumentHint: T.cmdSessionsHint,
1009    })
1010    await $.command.register({
1011      name: 'desk-diff',
1012      description: T.cmdDiff,
1013      argumentHint: T.cmdDiffHint,
1014    })
1015    await $.command.register({
1016      name: 'desk-pr',
1017      description: T.cmdPr,
1018    })
1019    await $.command.register({
1020      name: 'desk-context',
1021      description: T.cmdContext,
1022    })
1023    void refreshDiff($).catch(() => undefined)
1024    void refreshUsage($).catch(() => undefined)
1025    if (showPr) void refreshPr($, false).catch(() => undefined)
1026    void $.session
1027      .version()
1028      .then(engine => update($, richButtons, () => supportsRichButtons(engine.base ?? engine.version)))
1029      .catch(() => undefined)
1030    void refreshSurface($).catch(() => undefined)
1031    // 테마를 바꾸면 3초 안에 말풍선·아래턱 색이 따라간다. 바뀌지 않았으면 다시 그리지 않는다.
1032    themeWatch?.cancel()
1033    themeWatch = $.clock.every(THEME_POLL, () => void refreshSurface($).catch(() => undefined))
1034    void refreshRuns($).catch(() => undefined)
1035    void refreshImages($).catch(() => undefined)
1036    void (async () => {
1037      const [TMUX, ZELLIJ, HERDR_ENV, TERM, TERM_PROGRAM] = await Promise.all([
1038        $.env.get('TMUX'),
1039        $.env.get('ZELLIJ'),
1040        $.env.get('HERDR_ENV'),
1041        $.env.get('TERM'),
1042        $.env.get('TERM_PROGRAM'),
1043      ])
1044      await update($, graphics, () => supportsGraphics({ TMUX, ZELLIJ, HERDR_ENV, TERM, TERM_PROGRAM }))
1045    })().catch(() => undefined)
1046    return next(e)
1047  })
1048
1049  // 테마를 바꿨을 수 있으니 프롬프트마다 다시 읽는다(타이머를 기다리지 않게).
1050  on('prompt.submit', async ($, e, next) => {
1051    void refreshSurface($).catch(() => undefined)
1052    const submitted = await next(e)
1053    // 붙여 넣은 이미지가 저장된 뒤 다시 훑는다. 말풍선이 이 값을 읽어 썸네일로 다시 그려진다.
1054    void refreshImages($).catch(() => undefined)
1055    return submitted
1056  })
1057
1058  // 대화에 메시지가 붙을 때마다 도구 묶음을 다시 계산한다. 첫 행이 이 값을 읽어 다시 그려진다.
1059  on('session.append', async ($, e, next) => {
1060    const appended = await next(e)
1061    if (e.agentId === undefined && (e.message.type === 'assistant' || e.message.type === 'user')) {
1062      void refreshRuns($).catch(() => undefined)
1063    }
1064    if (e.agentId === undefined && e.message.type !== 'assistant') {
1065      void refreshImages($).catch(() => undefined)
1066    }
1067    return appended
1068  })
1069
1070  on('turn.start', async ($, e, next) => {
1071    await update($, pendingEdits, () => [])
1072    const now = await $.clock.now()
1073    await update($, turnStartedAt, () => now)
1074    return next(e)
1075  })
1076
1077  on('turn.complete', async ($, e, next) => {
1078    const done = await next(e)
1079    if (e.agentId === undefined) {
1080      const files = await read($, pendingEdits)
1081      const text = done.text.trim().slice(0, MAX_COPY)
1082      if (files.length > 0 || text !== '') {
1083        await update($, turnCards, cards => [...cards, { durationMs: e.durationMs, files, text }].slice(-TURN_CARDS))
1084      }
1085      await update($, pendingEdits, () => [])
1086      await update($, turnStartedAt, () => null)
1087      void refreshUsage($).catch(() => undefined)
1088      if (showPr) void refreshPr($, false).catch(() => undefined)
1089    }
1090    return done
1091  })
1092
1093  on('command.run', { command: 'desk-sessions' }, async ($, e) => {
1094    const index = Number.parseInt(e.args.trim(), 10)
1095    if (Number.isInteger(index) && index > 0) {
1096      const list = numbered(await read($, sessions), await $.session.cwd(), PER_GROUP, await $.session.id())
1097      const entry = list[index - 1]
1098      if (!entry) return { text: T.noSession(index) }
1099      await openSession($, entry)
1100      return { text: T.sessionPicked(index, entry.title) }
1101    }
1102    await refreshSessions($)
1103    await $.ui.open({ id: SESSIONS_PANE, title: T.paneSessions, focus: true })
1104    return { text: T.sessionsOpened }
1105  })
1106
1107  on('command.run', { command: 'desk-context' }, async $ => {
1108    await openContext($)
1109    return { text: T.contextOpened }
1110  })
1111
1112  on('command.run', { command: 'desk-diff' }, async ($, e) => {
1113    const name = e.args.trim()
1114    const scope = scopeOf(name)
1115    if (name !== '' && scope === null) {
1116      await openDiffAt($, name)
1117      return { text: T.diffOpenedAt(name) }
1118    }
1119    await refreshDiffView($, scope ?? undefined)
1120    await $.ui.open({ id: PANE, title: T.paneChanges, focus: true })
1121    return { text: T.diffOpened }
1122  })
1123
1124  on('command.run', { command: 'desk-pr' }, async $ => {
1125    await openPr($)
1126    const state = await read($, pr)
1127    return { text: T.prOpened(state?.pr ? prRef(state.pr) : null) }
1128  })
1129
1130  on('tool.call', async ($, e, next) => {
1131    const ran = await next(e)
1132    // 턴 끝 카드용: 성공한 편집만 파일별 +/− 로 모은다
1133    const tool = String(e.tool)
1134    if (FILE_EDITING.has(tool) && !ran.isError) {
1135      const fields = fieldsOf(e)
1136      const path = stringField(fields, 'file_path') ?? stringField(fields, 'notebook_path')
1137      const stat = diffStat({ tool, input: e, isRunning: false, isErrored: false, isInterrupted: false })
1138      if (path && stat) {
1139        await update($, pendingEdits, list => mergeEdit(list, { path, ...stat }))
1140      }
1141    }
1142    // 도구 결과를 git 실행 시간만큼 붙잡지 않도록 기다리지 않는다. 실패는 패널이 비어 보일 뿐이다.
1143    if (EDITING_TOOLS.has(String(e.tool))) {
1144      void refreshDiff($).catch(() => undefined)
1145    }
1146    return ran
1147  })
1148
1149  // 사용자 프롬프트: 오른쪽 정렬 말풍선. omarchy 테마를 읽었으면 데스크톱처럼 회색 면, 아니면 둥근 테두리.
1150  // [Image #N] 은 데스크톱처럼 말풍선 위 썸네일로 바꾼다(kitty 그래픽 프로토콜, 안 되는 터미널은 alt 글자).
1151  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
1152    // 백그라운드 작업 알림과 에이전트가 보낸 메시지: 데스크톱처럼 그 자리에서 펼치는 줄. ctrl+o 화면은 엔진 그대로.
1153    if (e.surface === 'terminal' && !e.props.isExpanded && (e.props.from || e.props.task)) {
1154      const els = $.ui.resolve(e)
1155      const [openState, state] = await Promise.all([read($, open), read($, runs)])
1156      if (e.props.task) return taskRow(els, e.props.task, e.props.text, state)
1157      return peerRow(els, $, e.props.from?.name ?? 'agent', e.props.text, openState, e.viewport?.columns ?? 80, await read($, richButtons))
1158    }
1159    if (e.surface !== 'terminal' || e.props.origin.kind !== 'composer') {
1160      return next(e)
1161    }
1162    const { Box, Text, Image } = $.ui.resolve(e)
1163    const [face, known, canDraw] = await Promise.all([read($, surface), read($, images), read($, graphics)])
1164    const columns = e.viewport?.columns ?? 80
1165    const attached = [...e.props.text.matchAll(IMAGE_TOKEN)]
1166      .map(match => ({ number: match[1] ?? '', info: known[match[1] ?? ''] }))
1167      .filter((image): image is { number: string; info: ImageInfo } => canDraw && image.info !== undefined)
1168    const shown = new Set(attached.map(image => image.number))
1169    // 그림을 못 그리는 터미널에서는 [Image #N] 을 지운다. 엔진이 말풍선 아래 └ [Image #N] 줄로 이미 알려 준다.
1170    const text = e.props.text
1171      .replace(IMAGE_TOKEN, (token, number: string) => (shown.has(number) || !canDraw ? '' : token))
1172      .replace(/[ \t]{2,}/g, ' ')
1173      .replace(/^\s*\n|\n\s*$/g, '')
1174      .trim()
1175    const longest = Math.max(0, ...text.split('\n').map(cellWidth))
1176    const width = Math.max(8, Math.min(longest + 4, Math.floor(columns * 0.85)))
1177    const thumbColumns = Math.min(36, Math.floor(columns * 0.4))
1178
1179    return (
1180      <Box flexDirection="column" alignItems={edge} marginTop={1} rowGap={attached.length > 0 && text ? 1 : 0}>
1181        {attached.length > 0 && (
1182          <Box flexDirection="row" columnGap={1} flexWrap="wrap" justifyContent={edge}>
1183            {attached.map(image => (
1184              <Image
1185                key={`image-${image.number}`}
1186                source={{ file: image.info.path, format: 'png' }}
1187                alt={`[Image #${image.number}]`}
1188                {...thumbnailSize(image.info, thumbColumns)}
1189              />
1190            ))}
1191          </Box>
1192        )}
1193        {text !== '' &&
1194          (face ? (
1195            bubbleLines(Text, wrapText(text, Math.floor(columns * 0.85) - 6), face.bubble)
1196          ) : (
1197            <Box width={width} borderStyle="round" borderColor={COLORS.border} paddingX={1}>
1198              <Text color={COLORS.text}>{text}</Text>
1199            </Box>
1200          ))}
hooks/images.ts 48 lines
1import type { ImageInfo } from '../types'
2
3// 붙여 넣은 이미지는 세션 임시 폴더의 images/N.png 에 [Image #N] 번호 그대로 저장된다.
4// 각 파일 경로와 PNG 헤더 앞 24바이트(IHDR 의 가로·세로)를 한 줄씩 출력한다.
5export const LIST_IMAGES = `d=$(ls -d /tmp/claude-$(id -u)/*/"$1"/images 2>/dev/null | head -1)
6[ -n "$d" ] || exit 0
7for f in "$d"/*.png; do
8  [ -f "$f" ] || continue
9  printf '%s ' "$f"; head -c 24 "$f" | od -An -tu1 | tr -s ' \\n' '  '; echo
10done`
11
12export function parseImages(listing: string): Record<string, ImageInfo> {
13  const images: Record<string, ImageInfo> = {}
14  for (const line of listing.split('\n')) {
15    const [path, ...rest] = line.trim().split(/\s+/)
16    const number = path ? /\/(\d+)\.png$/.exec(path)?.[1] : undefined
17    const bytes = rest.map(Number)
18    if (!path || number === undefined || bytes.length < 24) continue
19    const word = (at: number) => bytes.slice(at, at + 4).reduce((value, byte) => value * 256 + byte, 0)
20    const width = word(16)
21    const height = word(20)
22    if (width > 0 && height > 0) images[number] = { path, width, height }
23  }
24  return images
25}
26
27// 썸네일 칸 수. 터미널 셀은 세로가 가로의 약 두 배라 행 수는 절반으로 센다.
28export function thumbnailSize(image: ImageInfo, maxColumns: number, maxRows = 12) {
29  let columns = Math.max(4, maxColumns)
30  let rows = Math.round((columns * image.height) / image.width / 2)
31  if (rows > maxRows) {
32    rows = maxRows
33    columns = Math.max(4, Math.round((rows * 2 * image.width) / image.height))
34  }
35  return { columns: Math.min(columns, 255), rows: Math.max(1, Math.min(rows, 255)) }
36}
37
38export const IMAGE_TOKEN = /\[Image #(\d+)\]/g
39
40// kitty 그래픽 프로토콜을 그릴 수 있는 환경인지. tmux·zellij·herdr 같은 멀티플렉서는 통과시키지 않아
41// 그림 대신 alt 글자가 나오므로, 그 안에서는 썸네일을 그리지 않는다.
42export function supportsGraphics(env: Record<string, string | undefined>): boolean {
43  if (env.TMUX || env.ZELLIJ || env.HERDR_ENV === '1') return false
44  const term = (env.TERM ?? '').toLowerCase()
45  const program = (env.TERM_PROGRAM ?? '').toLowerCase()
46  return term.includes('kitty') || term.includes('ghostty') || ['ghostty', 'kitty', 'wezterm'].includes(program)
47}
48
hooks/markdown.tsx 486 lines
1import type { Elements, UiPressArgument } from 'claude-code'
2
3import { renderDiagram } from './diagram'
4
5// 데스크톱 앱 산문 스타일로 마크다운을 Box/Text 트리로 그린다.
6// 엔진의 Markdown 요소는 표·인용·코드 블록 모양을 바꿀 수 없어서 직접 파싱한다.
7
8export type Palette = {
9  text: string
10  muted: string
11  border: string
12  code: string
13  link: string
14  // 인라인 코드 바탕. 터미널 배경을 알 때만 칠한다.
15  codeBg?: string
16  // 다이어그램 화살표 강조색.
17  accent: string
18}
19
20type Inline =
21  | { kind: 'text'; text: string }
22  | { kind: 'code'; text: string }
23  | { kind: 'bold' | 'italic' | 'strike'; children: Inline[] }
24  | { kind: 'link'; href: string; children: Inline[] }
25
26type Align = 'left' | 'center' | 'right'
27
28export type Block =
29  | { kind: 'heading'; level: number; text: string }
30  | { kind: 'paragraph'; text: string }
31  | { kind: 'code'; language?: string; source: string }
32  | { kind: 'table'; header: string[]; align: Align[]; rows: string[][] }
33  | { kind: 'list'; items: { level: number; marker: string; text: string }[] }
34  | { kind: 'quote'; blocks: Block[]; raw: string }
35  | { kind: 'rule' }
36
37// 한글·CJK·이모지는 터미널에서 두 칸이다.
38export function cellWidth(text: string): number {
39  let width = 0
40  for (const ch of text) {
41    const code = ch.codePointAt(0) ?? 0
42    const isWide =
43      (code >= 0x1100 && code <= 0x115f) ||
44      (code >= 0x2e80 && code <= 0xa4cf) ||
45      (code >= 0xac00 && code <= 0xd7a3) ||
46      (code >= 0xf900 && code <= 0xfaff) ||
47      (code >= 0xfe30 && code <= 0xfe4f) ||
48      (code >= 0xff00 && code <= 0xff60) ||
49      (code >= 0xffe0 && code <= 0xffe6) ||
50      (code >= 0x1f300 && code <= 0x1faff)
51    width += isWide ? 2 : 1
52  }
53  return width
54}
55
56// 여는 펜스 뒤 info string 은 첫 단어만 언어로 쓰고 나머지(title="…" 등)는 버린다.
57const FENCE = /^\s*(`{3,}|~{3,})\s*([^\s`]*)[^`]*$/
58const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
59const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/
60const BULLETS = ['•', '◦', '▪']
61const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
62const TABLE_SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
63
64function splitRow(line: string): string[] {
65  const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '')
66  const cells: string[] = []
67  let current = ''
68  let inCode = false
69  for (let i = 0; i < trimmed.length; i++) {
70    const ch = trimmed[i]
71    if (ch === '\\' && trimmed[i + 1] === '|') {
72      current += '|'
73      i++
74    } else if (ch === '`') {
75      inCode = !inCode
76      current += ch
77    } else if (ch === '|' && !inCode) {
78      cells.push(current.trim())
79      current = ''
80    } else {
81      current += ch
82    }
83  }
84  cells.push(current.trim())
85  return cells
86}
87
88function isBlockStart(line: string, next: string | undefined): boolean {
89  return (
90    FENCE.test(line) ||
91    HEADING.test(line) ||
92    RULE.test(line) ||
93    LIST_ITEM.test(line) ||
94    /^\s*>/.test(line) ||
95    (line.includes('|') && next !== undefined && TABLE_SEPARATOR.test(next) && next.includes('-'))
96  )
97}
98
99export function parseBlocks(markdown: string): Block[] {
100  const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
101  const blocks: Block[] = []
102  let i = 0
103
104  while (i < lines.length) {
105    const line = lines[i] ?? ''
106    const next = lines[i + 1]
107
108    if (line.trim() === '') {
109      i++
110      continue
111    }
112
113    const fence = FENCE.exec(line)
114    if (fence) {
115      const marker = fence[1] ?? '```'
116      const body: string[] = []
117      i++
118      const closes = (candidate: string) => {
119        const trimmed = candidate.trim()
120        return trimmed.length >= marker.length && /^(`+|~+)$/.test(trimmed) && trimmed[0] === marker[0]
121      }
122      while (i < lines.length && !closes(lines[i] ?? '')) {
123        body.push(lines[i] ?? '')
124        i++
125      }
126      i++
127      blocks.push({ kind: 'code', language: fence[2] || undefined, source: body.join('\n') })
128      continue
129    }
130
131    const heading = HEADING.exec(line)
132    if (heading) {
133      blocks.push({ kind: 'heading', level: (heading[1] ?? '#').length, text: heading[2] ?? '' })
134      i++
135      continue
136    }
137
138    if (RULE.test(line) && !LIST_ITEM.test(line.replace(/\s+/g, ''))) {
139      blocks.push({ kind: 'rule' })
140      i++
141      continue
142    }
143
144    if (line.includes('|') && next !== undefined && TABLE_SEPARATOR.test(next) && next.includes('-')) {
145      const header = splitRow(line)
146      const align = splitRow(next).map((cell): Align => {
147        const left = cell.startsWith(':')
148        const right = cell.endsWith(':')
149        return left && right ? 'center' : right ? 'right' : 'left'
150      })
151      const rows: string[][] = []
152      i += 2
153      while (i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim() !== '') {
154        rows.push(splitRow(lines[i] ?? ''))
155        i++
156      }
157      blocks.push({ kind: 'table', header, align, rows })
158      continue
159    }
160
161    if (/^\s*>/.test(line)) {
162      const body: string[] = []
163      while (i < lines.length && /^\s*>/.test(lines[i] ?? '')) {
164        body.push((lines[i] ?? '').replace(/^\s*>\s?/, ''))
165        i++
166      }
167      blocks.push({ kind: 'quote', blocks: parseBlocks(body.join('\n')), raw: body.join('\n') })
168      continue
169    }
170
171    if (LIST_ITEM.test(line)) {
172      const items: { level: number; marker: string; text: string }[] = []
173      while (i < lines.length) {
174        const current = lines[i] ?? ''
175        const item = LIST_ITEM.exec(current)
176        if (item) {
177          const indent = (item[1] ?? '').replace(/\t/g, '    ').length
178          const marker = item[2] ?? '-'
179          items.push({
180            level: Math.min(4, Math.floor(indent / 2)),
181            marker: /\d/.test(marker) ? marker : (BULLETS[Math.min(4, Math.floor(indent / 2)) % BULLETS.length] ?? '•'),
182            text: item[3] ?? '',
183          })
184          i++
185        } else if (current.trim() !== '' && /^\s+/.test(current) && items.length > 0) {
186          // 들여쓴 이어지는 줄은 앞 항목에 붙인다.
187          const last = items[items.length - 1]
188          if (last) last.text += ' ' + current.trim()
189          i++
190        } else {
191          break
192        }
193      }
194      blocks.push({ kind: 'list', items })
195      continue
196    }
197
198    const paragraph: string[] = []
199    while (i < lines.length) {
200      const current = lines[i] ?? ''
201      if (current.trim() === '' || (paragraph.length > 0 && isBlockStart(current, lines[i + 1]))) break
202      paragraph.push(current.trim())
203      i++
204    }
205    blocks.push({ kind: 'paragraph', text: paragraph.join(' ') })
206  }
207
208  return blocks
209}
210
211const INLINE = /`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|~~(.+?)~~|\*([^*\s][^*]*?)\*|\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)|<(https?:\/\/[^>\s]+)>|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/
212
213export function parseInline(text: string): Inline[] {
214  const out: Inline[] = []
215  let rest = text
216  while (rest.length > 0) {
217    const match = INLINE.exec(rest)
218    if (!match) {
219      out.push({ kind: 'text', text: rest })
220      break
221    }
222    if (match.index > 0) {
223      out.push({ kind: 'text', text: rest.slice(0, match.index) })
224    }
225    const [whole, code, bold1, bold2, strike, italic, linkText, linkHref, angleUrl, bareUrl] = match
226    if (code !== undefined) out.push({ kind: 'code', text: code })
227    else if (bold1 !== undefined || bold2 !== undefined) out.push({ kind: 'bold', children: parseInline(bold1 ?? bold2 ?? '') })
228    else if (strike !== undefined) out.push({ kind: 'strike', children: parseInline(strike) })
229    else if (italic !== undefined) out.push({ kind: 'italic', children: parseInline(italic) })
230    else if (linkText !== undefined && linkHref !== undefined) out.push({ kind: 'link', href: linkHref, children: parseInline(linkText) })
231    else {
232      const url = angleUrl ?? bareUrl ?? whole
233      out.push({ kind: 'link', href: url, children: [{ kind: 'text', text: url }] })
234    }
235    rest = rest.slice(match.index + whole.length)
236  }
237  return out
238}
239
240// 그려질 칸 수. 바탕을 칠한 인라인 코드는 앞뒤 공백 한 칸씩이 더 붙는다.
241export function inlineWidth(nodes: Inline[], codePadding: number): number {
242  return nodes.reduce(
243    (sum, node) =>
244      sum +
245      (node.kind === 'text'
246        ? cellWidth(node.text)
247        : node.kind === 'code'
248          ? cellWidth(node.text) + codePadding * 2
249          : inlineWidth(node.children, codePadding)),
250    0,
251  )
252}
253
254export function plainText(nodes: Inline[]): string {
255  return nodes
256    .map(node => (node.kind === 'text' || node.kind === 'code' ? node.text : plainText(node.children)))
257    .join('')
258}
259
260// 표 열 너비: 자연 너비가 들어가면 그대로, 넘치면 좁은 열은 지키고 넓은 열부터 줄인다.
261export function columnWidths(natural: number[], room: number, fill = false): number[] {
262  const padding = 2
263  const total = natural.reduce((sum, width) => sum + width + padding, 0)
264  if (total <= room) {
265    if (!fill) return natural
266    const content = natural.reduce((sum, width) => sum + width, 0)
267    const extra = room - total
268    const grown = natural.map(width => width + Math.floor((extra * width) / Math.max(1, content)))
269    const last = grown.length - 1
270    grown[last] = (grown[last] ?? 0) + room - grown.reduce((sum, width) => sum + width + padding, 0)
271    return grown
272  }
273  let budget = Math.max(natural.length * (4 + padding), room) - natural.length * padding
274  const widths = natural.map(() => 0)
275  let open = natural.map((_, index) => index)
276  while (open.length > 0) {
277    const share = Math.floor(budget / open.length)
278    const fits = open.filter(index => (natural[index] ?? 0) <= share)
279    if (fits.length === 0) {
280      open.forEach((index, n) => (widths[index] = Math.max(4, share + (n < budget - share * open.length ? 1 : 0))))
281      break
282    }
283    for (const index of fits) {
284      widths[index] = natural[index] ?? 0
285      budget -= natural[index] ?? 0
286    }
287    open = open.filter(index => !fits.includes(index))
288  }
289  return widths
290}
291
292type Els = Elements['terminal']
293
294const ALERT = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*\n?/i
295
296const ALERTS: Record<string, { title: string; color: string }> = {
297  NOTE: { title: 'ⓘ Note', color: 'suggestion' },
298  TIP: { title: '✓ Tip', color: 'success' },
299  IMPORTANT: { title: '! Important', color: 'claude' },
300  WARNING: { title: '⚠ Warning', color: 'warning' },
301  CAUTION: { title: '✕ Caution', color: 'error' },
302}
303
304// onCopy 가 있으면 코드 블록 위 테두리 오른쪽에 마우스를 올렸을 때만 보이는 "⧉ copy" 버튼을 단다.
305export type MarkdownOptions = { onCopy?: (source: string, press: UiPressArgument) => void }
306
307export function renderMarkdown(markdown: string, els: Els, colors: Palette, columns: number, options: MarkdownOptions = {}) {
308  const { Box, Text, Code, Link, Button } = els
309  const codePadding = colors.codeBg ? 1 : 0
310
311  const inline = (nodes: Inline[], key: string): (string | ReturnType<typeof h>)[] =>
312    nodes.map((node, index) => {
313      const k = `${key}.${index}`
314      switch (node.kind) {
315        case 'text':
316          return node.text
317        case 'code':
318          return colors.codeBg ? (
319            <Text color={colors.code} backgroundColor={colors.codeBg}>{` ${node.text} `}</Text>
320          ) : (
321            <Text color={colors.code}>{node.text}</Text>
322          )
323        case 'bold':
324          return <Text bold>{inline(node.children, k)}</Text>
325        case 'italic':
326          return <Text italic>{inline(node.children, k)}</Text>
327        case 'strike':
328          return <Text strikethrough>{inline(node.children, k)}</Text>
329        case 'link':
330          return (
331            <Text color={colors.link} underline>
332              <Link href={node.href}>{inline(node.children, k)}</Link>
333            </Text>
334          )
335      }
336    })
337
338  const prose = (text: string, key: string, props: Record<string, unknown> = {}) => (
339    <Text color={colors.text} {...props}>
340      {inline(parseInline(text), key)}
341    </Text>
342  )
343
344  const table = (block: Extract<Block, { kind: 'table' }>, key: string, room: number) => {
345    const count = Math.max(block.header.length, ...block.rows.map(row => row.length))
346    const cell = (row: string[], index: number) => row[index] ?? ''
347    const natural = Array.from({ length: count }, (_, index) =>
348      Math.max(
349        1,
350        inlineWidth(parseInline(cell(block.header, index)), codePadding),
351        ...block.rows.map(row => inlineWidth(parseInline(cell(row, index)), codePadding)),
352      ),
353    )
354    // 데스크톱 메시지 열처럼 폭을 채우되, 아주 넓은 터미널에서 늘어지지 않게 100칸에서 멈춘다.
355    const widths = columnWidths(natural, Math.min(room, 100) - 2, true)
356    const inner = widths.reduce((sum, width) => sum + width + 2, 0)
357    const justify = (index: number) =>
358      block.align[index] === 'right' ? 'flex-end' : block.align[index] === 'center' ? 'center' : 'flex-start'
359    const headerFill = colors.codeBg ? { backgroundColor: colors.codeBg } : {}
360    const row = (cells: string[], rowKey: string, isHeader: boolean) => (
361      <Box flexDirection="row" {...(isHeader ? headerFill : {})}>
362        {widths.map((width, index) => (
363          <Box width={width + 2} paddingX={1} justifyContent={justify(index)} {...(isHeader ? headerFill : {})}>
364            {prose(cell(cells, index), `${rowKey}.${index}`, isHeader ? (colors.codeBg ? headerFill : { bold: true }) : {})}
365          </Box>
366        ))}
367      </Box>
368    )
369    const rule = <Text color={colors.border}>{'─'.repeat(inner)}</Text>
370
371    return (
372      <Box flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={colors.border}>
373        {row(block.header, `${key}.h`, true)}
374        {block.rows.flatMap((cells, index) => [rule, row(cells, `${key}.${index}`, false)])}
375      </Box>
376    )
377  }
378
379  const blocks = (list: Block[], key: string, room: number, tone?: string): ReturnType<typeof h>[] =>
380    list.map((block, index) => {
381      const k = `${key}.${index}`
382      const gap = index === 0 ? 0 : 1
383      const toned = tone ? { color: tone } : {}
384      switch (block.kind) {
385        case 'heading':
386          return <Box marginTop={gap}>{prose(block.text, k, { bold: true, ...toned })}</Box>
387        case 'paragraph':
388          return <Box marginTop={gap}>{prose(block.text, k, toned)}</Box>
389        case 'rule':
390          return (
391            <Box marginTop={gap}>
392              <Text color={colors.border}>{'─'.repeat(Math.max(4, Math.min(room, 80)))}</Text>
393            </Box>
394          )
395        // mermaid 는 박스 문자 그림으로 그린다(lovely-mermaid). 폭이 모자라거나 못 그리는 문법이면 코드 블록으로.
396        case 'code':
397          if (block.language === 'mermaid') {
398            const diagram = renderDiagram(block.source)
399            if (diagram !== null && diagram.width + 6 <= room) {
400              const tone = { border: colors.muted, line: colors.muted, arrow: colors.accent, label: colors.muted, text: colors.text }
401              return (
402                <Box marginTop={gap} flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={colors.border} paddingX={2} paddingY={1}>
403                  {diagram.rows.map(row => (
404                    <Text wrap="truncate-end">
405                      {row.length === 0
406                        ? ' '
407                        : row.map(seg =>
408                            seg.href ? (
409                              <Text color={colors.link} underline>
410                                <Link href={seg.href}>{seg.text}</Link>
411                              </Text>
412                            ) : (
413                              <Text color={tone[seg.kind]} italic={seg.kind === 'label'}>
414                                {seg.text}
415                              </Text>
416                            ),
417                          )}
418                    </Text>
419                  ))}
420                </Box>
421              )
422            }
423          }
424          {
425            const code = (
426              <Box flexDirection="column" borderStyle="round" borderColor={colors.border} paddingX={1}>
427                <Code source={block.source} language={block.language} wrap="wrap" />
428              </Box>
429            )
430            const onCopy = options.onCopy
431            if (!onCopy) return <Box marginTop={gap}>{code}</Box>
432            return (
433              <Box key={`code${k}`} marginTop={gap} flexDirection="column">
434                {code}
435                <Box position="absolute" top={0} right={2} display="none" hover={{ display: 'flex' }}>
436                  <Button key={`copy${k}`} plain dimColor label=" ⧉ copy " onPress={press => onCopy(block.source, press)} />
437                </Box>
438              </Box>
439            )
440          }
441        case 'table':
442          return <Box marginTop={gap}>{table(block, k, room)}</Box>
443        case 'quote':
444          // GitHub 알림(> [!NOTE] 등): 종류 색 테두리 + 제목 줄.
445          {
446            const alert = ALERT.exec(block.raw)
447            const style = alert ? ALERTS[(alert[1] ?? '').toUpperCase()] : undefined
448            if (alert && style) {
449              return (
450                <Box marginTop={gap} flexDirection="column" borderStyle="round" borderColor={style.color} paddingX={1}>
451                  <Text color={style.color} bold>
452                    {style.title}
453                  </Text>
454                  {blocks(parseBlocks(block.raw.slice(alert[0].length)), k, room - 4)}
455                </Box>
456              )
457            }
458          }
459          return (
460            <Box marginTop={gap} borderStyle="quote" borderColor={colors.border} paddingLeft={1} flexDirection="column">
461              {blocks(block.blocks, k, room - 2, colors.muted)}
462            </Box>
463          )
464        case 'list':
465          return (
466            <Box marginTop={gap} flexDirection="column">
467              {block.items.map((item, n) => (
468                <Box flexDirection="row" paddingLeft={item.level * 2}>
469                  <Box width={cellWidth(item.marker) + 1} flexShrink={0}>
470                    <Text color={colors.muted}>{item.marker}</Text>
471                  </Box>
472                  {prose(item.text, `${k}.${n}`, toned)}
473                </Box>
474              ))}
475            </Box>
476          )
477      }
478    })
479
480  return (
481    <Box flexDirection="column">
482      {blocks(parseBlocks(markdown), 'md', Math.max(20, columns))}
483    </Box>
484  )
485}
486
hooks/runs.ts 169 lines
1import type { SessionMessage } from 'claude-code'
2
3import type { RunCall, Runs } from '../types'
4
5// $.state 에 도구 출력 전문(Read 파일 내용, 긴 stdout)을 쌓지 않는다. 펼친 행은 30줄 남짓만 보여 준다.
6const LIMIT = 4000
7
8const clip = (text: string) => (text.length > LIMIT ? `${text.slice(0, LIMIT)}\n…` : text)
9
10// 계획(ExitPlanMode)은 길어도 카드에서 펼쳐 읽으므로 넉넉히 둔다.
11const PLAN_LIMIT = 20000
12
13export type PlanSummary = { plan: string | null; filePath?: string }
14
15function outputOf(value: unknown): string | { structuredPatch: unknown[] } | { answers: object } | { agent: AgentSummary } | PlanSummary | undefined {
16  if (value === undefined || value === null) return undefined
17  if (typeof value === 'string') return clip(value)
18  const fields = value as Record<string, unknown>
19  if (typeof fields.stdout === 'string') {
20    return clip([fields.stdout, typeof fields.stderr === 'string' ? fields.stderr : ''].filter(Boolean).join('\n'))
21  }
22  // 편집 결과: 파일 전문(originalFile)은 버리고 실제 줄 번호가 붙은 헌크만 둔다.
23  if (Array.isArray(fields.structuredPatch)) return { structuredPatch: fields.structuredPatch.slice(0, 20) }
24  // 서브에이전트 결과: 카드에 쓸 요약만(결과 글은 앞부분만).
25  const agent = agentOf(fields)
26  if (agent) return { agent }
27  // 계획 결과: 계획 글과 저장된 파일 경로만.
28  const plan = planOf(fields)
29  if (plan) return plan
30  // 질문 결과: 질문 전문은 입력에 있으니 답만 둔다.
31  if (typeof fields.answers === 'object' && fields.answers !== null) return { answers: fields.answers }
32  const file = (fields.file ?? {}) as Record<string, unknown>
33  const content = typeof file.content === 'string' ? file.content : typeof fields.content === 'string' ? fields.content : undefined
34  if (content !== undefined) return clip(content)
35  try {
36    return clip(JSON.stringify(value, null, 2))
37  } catch {
38    return clip(String(value))
39  }
40}
41
42function clipInput(input: Record<string, unknown>): Record<string, unknown> {
43  return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, typeof value === 'string' ? clip(value) : value]))
44}
45
46// 서브에이전트 카드에 쓰는 결과 요약.
47export type AgentSummary = {
48  status: string
49  type?: string
50  model?: string
51  toolUses?: number
52  durationMs?: number
53  tokens?: number
54  added?: number
55  removed?: number
56  text?: string
57}
58
59const AGENT_TEXT = 2000
60const numberOf = (value: unknown) => (typeof value === 'number' ? value : undefined)
61const textOf = (value: unknown) => (typeof value === 'string' ? value : undefined)
62
63// Agent 도구 결과(엔진이 준 그대로, 저장해 둔 요약, JSON 글 어느 것이든)에서 요약을 읽는다. 아니면 null.
64export function agentOf(output: unknown): AgentSummary | null {
65  let value = output
66  if (typeof value === 'string') {
67    try {
68      value = JSON.parse(value)
69    } catch {
70      return null
71    }
72  }
73  if (typeof value !== 'object' || value === null) return null
74  const fields = value as Record<string, unknown>
75  if (typeof fields.agent === 'object' && fields.agent !== null) return fields.agent as AgentSummary
76  if (typeof fields.agentId !== 'string' || typeof fields.status !== 'string') return null
77  const stats = (fields.toolStats ?? {}) as Record<string, unknown>
78  const content = Array.isArray(fields.content) ? (fields.content as Array<Record<string, unknown>>) : []
79  const text = content.map(block => textOf(block.text) ?? '').join('\n').trim()
80  return {
81    status: fields.status,
82    type: textOf(fields.agentType),
83    model: textOf(fields.resolvedModel),
84    toolUses: numberOf(fields.totalToolUseCount),
85    durationMs: numberOf(fields.totalDurationMs),
86    tokens: numberOf(fields.totalTokens),
87    added: numberOf(stats.linesAdded),
88    removed: numberOf(stats.linesRemoved),
89    text: text.length > AGENT_TEXT ? `${text.slice(0, AGENT_TEXT)}…` : text || undefined,
90  }
91}
92
93export const isAgentTool = (tool: string) => tool === 'Agent' || tool === 'Task'
94
95export const isPlanTool = (tool: string) => tool === 'ExitPlanMode'
96
97// ExitPlanMode 결과(엔진이 준 그대로, 저장해 둔 요약, JSON 글, 모델이 받은 결과 글 어느 것이든)에서 계획을 읽는다.
98export function planOf(output: unknown): PlanSummary | null {
99  if (typeof output === 'string') {
100    try {
101      return planOf(JSON.parse(output))
102    } catch {
103      const approved = /##\s*Approved Plan[^\n]*\n([\s\S]+)$/.exec(output)
104      return approved?.[1] ? { plan: approved[1].trim() } : null
105    }
106  }
107  if (typeof output !== 'object' || output === null || !('plan' in output)) return null
108  const fields = output as Record<string, unknown>
109  const plan = typeof fields.plan === 'string' ? fields.plan : null
110  return {
111    plan: plan !== null && plan.length > PLAN_LIMIT ? `${plan.slice(0, PLAN_LIMIT)}\n…` : plan,
112    ...(typeof fields.filePath === 'string' ? { filePath: fields.filePath } : {}),
113  }
114}
115
116type RunKind = 'agent' | 'plan' | 'tool'
117const kindOf = (tool: string): RunKind => (isAgentTool(tool) ? 'agent' : isPlanTool(tool) ? 'plan' : 'tool')
118
119// 답변 텍스트나 사용자 프롬프트가 끼기 전까지 이어진 도구 호출을 한 묶음으로 본다.
120// 데스크톱이 "Ran 2 commands, edited 3 files" 한 줄로 접는 단위와 같다.
121// 서브에이전트는 따로 카드로 그리므로 다른 도구와 섞지 않는다(연달아 띄운 에이전트끼리만 묶는다).
122// 계획(ExitPlanMode)은 늘 혼자 카드 하나다.
123export function computeRuns(messages: ReadonlyArray<SessionMessage>): Runs {
124  const results = new Map<string, { isError: boolean; result: unknown; text: string }>()
125  for (const message of messages) {
126    for (const result of message.toolResults ?? []) {
127      results.set(result.tool_use_id, { isError: result.isError, result: result.result, text: result.text })
128    }
129  }
130
131  const firstOf: Record<string, string> = {}
132  const calls: Record<string, RunCall[]> = {}
133  let current: string | null = null
134  let currentKind: RunKind = 'tool'
135
136  for (const message of messages) {
137    const isPrompt = message.role === 'user' && (message.toolResults ?? []).length === 0 && message.text.trim() !== ''
138    if (isPrompt || (message.role === 'assistant' && message.text.trim() !== '')) {
139      current = null
140    }
141    if (message.role !== 'assistant') continue
142    for (const use of message.toolUses) {
143      const settled = results.get(use.tool_use_id)
144      const output = outputOf(use.result ?? settled?.result ?? settled?.text)
145      const isErrored = use.isError === true || settled?.isError === true
146      const text = settled?.text ?? ''
147      const call: RunCall = {
148        tool_use_id: use.tool_use_id,
149        tool: use.tool,
150        input: clipInput(use.input),
151        isRunning: output === undefined && !isErrored,
152        isErrored,
153        isInterrupted: isErrored && /interrupt/i.test(text),
154        output,
155      }
156      const kind = kindOf(use.tool)
157      if (current !== null && (currentKind !== kind || kind === 'plan')) current = null
158      if (current === null) {
159        current = use.tool_use_id
160        currentKind = kind
161        calls[current] = []
162      }
163      calls[current]?.push(call)
164      firstOf[use.tool_use_id] = current
165    }
166  }
167  return { firstOf, calls }
168}
169
hooks/sessions.ts 80 lines
1import type { SessionEntry } from '../types'
2import { cellWidth } from './markdown'
3import { T } from './i18n'
4
5// ~/.claude/projects/<프로젝트>/<세션>.jsonl 에서 최근 세션을 뽑는다.
6// 한 줄에 세션 id, 마지막 활동(초), 작업 폴더, 사용자가 붙인 제목, 자동 제목, 마지막 프롬프트를 탭으로 나눠 출력한다.
7// 기록 파일은 수십 MB 까지 커지므로 제목은 끝에서부터(tac) 첫 일치만 읽는다.
8export const LIST_SESSIONS = `ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -n "$1" | while IFS= read -r f; do
9  id=$(basename "$f" .jsonl)
10  mtime=$(stat -c %Y "$f")
11  cwd=$(grep -m1 -o '"cwd":"[^"]*"' "$f" | cut -d'"' -f4)
12  custom=$(tac "$f" | grep -m1 -o '"customTitle":"[^"]*"' | cut -d'"' -f4)
13  ai=$(tac "$f" | grep -m1 -o '"aiTitle":"[^"]*"' | cut -d'"' -f4)
14  last=$(tac "$f" | grep -m1 -o '"lastPrompt":"[^"]*"' | cut -d'"' -f4 | cut -c1-120)
15  printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' "$id" "$mtime" "$cwd" "$custom" "$ai" "$last"
16done`
17
18export function parseSessions(listing: string): SessionEntry[] {
19  const out: SessionEntry[] = []
20  for (const line of listing.split('\n')) {
21    const [id, mtime, cwd, custom, ai, last] = line.split('\t')
22    if (!id || !mtime || !/^[0-9a-f-]{36}$/.test(id)) continue
23    // 제목도 프롬프트도 없는 세션은 대화가 한 번도 없었던 빈 세션이라 목록에서 뺀다
24    const title = custom || ai || last
25    if (!title) continue
26    out.push({ id, updatedAt: Number(mtime) * 1000, cwd: cwd ?? '', title })
27  }
28  return out
29}
30
31// 패널에 그리는 순서(묶음 순서)대로 번호를 매긴다. 현재 세션은 번호 없이 ● 로 그리므로 뺀다.
32// `/desk-sessions <번호>` 가 이 순서를 쓴다.
33export function numbered(list: SessionEntry[], currentCwd: string, perGroup: number, currentId = ''): SessionEntry[] {
34  return groupSessions(list, currentCwd)
35    .flatMap(group => group.sessions.slice(0, perGroup))
36    .filter(entry => entry.id !== currentId)
37}
38
39// 프로젝트(작업 폴더)별로 묶는다. 현재 폴더가 맨 위, 나머지는 최근 활동 순.
40export function groupSessions(list: SessionEntry[], currentCwd: string): Array<{ cwd: string; sessions: SessionEntry[] }> {
41  const groups = new Map<string, SessionEntry[]>()
42  for (const entry of list) {
43    const key = entry.cwd || T.unknownFolder
44    groups.set(key, [...(groups.get(key) ?? []), entry])
45  }
46  return [...groups]
47    .map(([cwd, sessions]) => ({ cwd, sessions }))
48    .sort((a, b) => (a.cwd === currentCwd ? -1 : b.cwd === currentCwd ? 1 : 0))
49}
50
51export function ago(at: number, now: number): string {
52  const minutes = Math.max(0, Math.floor((now - at) / 60_000))
53  if (minutes < 1) return T.justNow
54  if (minutes < 60) return T.minutesShort(minutes)
55  const hours = Math.floor(minutes / 60)
56  if (hours < 24) return T.hoursShort(hours)
57  return T.daysShort(Math.floor(hours / 24))
58}
59
60// 칸 수 기준으로 자르고 말줄임표를 붙인다.
61export function clip(text: string, width: number): string {
62  if (cellWidth(text) <= width) return text
63  let out = ''
64  for (const ch of text) {
65    if (cellWidth(out + ch) > width - 1) break
66    out += ch
67  }
68  return `${out}…`
69}
70
71// 검색: 띄어 쓴 낱말이 모두 제목이나 폴더에 들어 있는 세션만(대소문자 무시).
72export function filterSessions(list: SessionEntry[], query: string): SessionEntry[] {
73  const words = query.toLowerCase().split(/\s+/).filter(Boolean)
74  if (words.length === 0) return list
75  return list.filter(entry => {
76    const hay = `${entry.title} ${entry.cwd}`.toLowerCase()
77    return words.every(word => hay.includes(word))
78  })
79}
80
hooks/ask.tsx 142 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AskQuestion, AskState } from '../types'
5import { T } from './i18n'
6
7// 질문 카드: AskUserQuestion 을 엔진 창 대신 입력창 위 띠의 카드로 묻는다(데스크톱과 같은 자리).
8// 도구 호출 훅이 답을 기다리며 결과를 직접 돌려주고, 엔진은 그 결과를 도구의 원래 변환기로 모델에게 넘긴다.
9// 입력창이 비어 있으면 숫자 키가 띠의 버튼을 바로 누른다: 1-8 선택지, 0 제출(여러 개 고르기), 9 엔진 창.
10// 터미널 말고 다른 화면이 붙은 세션은 엔진 창 그대로.
11// 엔진 창은 뜰 때 알림(permission_prompt)을 보내지만 카드는 도구가 도는 중이라 엔진이 보내지 않는다. 그래서 카드가 직접 알린다(askNotify).
12
13type On = Parameters<Register>[0]
14
15const asking = atom({ plugin: 'desk-look', key: 'ask' } as const, null as AskState | null)
16
17// 훅은 자기 코드 시간 10초 안에 끝나야 하지만, 엔진 호출($)을 기다리는 동안은 시계가 멈춘다.
18// 그래서 답 파일이 생길 때까지 도는 셸을 띄워 두고 그 실행을 기다린다. 버튼이 그 파일을 쓴다.
19// process.run 은 한 번에 10분까지라, 9분마다 빈손(종료 코드 3)으로 돌아와 다시 기다린다.
20const WAIT = 'end=$(( $(date +%s) + 540 )); while [ ! -e "$1/answer" ]; do [ "$(date +%s)" -ge "$end" ] && exit 3; sleep 0.1; done; cat "$1/answer"; rm -rf "$1"'
21export const WRITE = 'printf %s "$2" > "$1/answer.tmp" && mv "$1/answer.tmp" "$1/answer"'
22const WAIT_ROUND_MS = 570_000
23const WAIT_ROUNDS = 40
24
25export type AskReply =
26  | { kind: 'answer'; answers: Record<string, string> }
27  | { kind: 'engine' }
28  | { kind: 'dismiss' }
29  | { kind: 'cancel' }
30
31const str = (value: unknown) => (typeof value === 'string' ? value : undefined)
32const num = (value: unknown) => (typeof value === 'number' ? value : undefined)
33
34// 도구 입력을 읽는다. 그릴 수 없는 질문이 하나라도 있으면 null(엔진 창).
35// 미리보기(preview)는 선택지에 마우스를 올리면 카드 아래에 보인다.
36export function parseQuestions(value: unknown): AskQuestion[] | null {
37  if (!Array.isArray(value) || value.length === 0) return null
38  const out: AskQuestion[] = []
39  for (const raw of value as Array<Record<string, unknown>>) {
40    const question = str(raw?.question)
41    if (!question) return null
42    const kind = raw.kind === 'text' || raw.kind === 'number' ? raw.kind : 'choice'
43    const options = Array.isArray(raw.options) ? (raw.options as Array<Record<string, unknown>>) : []
44    if (kind === 'choice' && options.length === 0) return null
45    out.push({
46      question,
47      header: str(raw.header) ?? '',
48      kind,
49      description: str(raw.description),
50      options: options.map(option => ({ label: str(option.label) ?? '', description: str(option.description), preview: str(option.preview) })),
51      multiSelect: raw.multiSelect === true,
52      placeholder: str(raw.placeholder),
53      min: num(raw.min),
54      max: num(raw.max),
55      unit: str(raw.unit),
56    })
57  }
58  return out
59}
60
61// 한 질문의 답: 직접 입력한 글이 있으면 그것(여러 개 고르기면 고른 것 뒤에), 아니면 고른 라벨.
62export function answerOf(question: AskQuestion, picks: readonly string[], text: string): string {
63  const typed = text.trim()
64  if (question.kind !== 'choice') return typed
65  // 여러 개 고르기는 누른 순서가 아니라 선택지 순서로(엔진 창과 같게)
66  const ordered = question.options.map(option => option.label).filter(label => picks.includes(label))
67  if (question.multiSelect) return [...ordered, ...(typed ? [typed] : [])].join(', ')
68  return typed || picks[0] || ''
69}
70
71// 숫자 답 검사: 틀리면 이유, 맞으면 null.
72export function numberProblem(question: AskQuestion, text: string): string | null {
73  if (question.kind !== 'number') return null
74  const value = Number(text.trim())
75  if (text.trim() === '' || !Number.isFinite(value)) return T.needNumber
76  if (question.min !== undefined && value < question.min) return T.atLeast(question.min)
77  if (question.max !== undefined && value > question.max) return T.atMost(question.max)
78  return null
79}
80
81async function reply($: EngineInterface, dir: string, payload: AskReply) {
82  await $.process.run(['sh', '-c', WRITE, 'sh', dir, JSON.stringify(payload)], { timeoutMs: 3000 })
83}
84
85// 알림 본문: 첫 질문, 여러 개면 "외 N개".
86export function askNotice(questions: readonly AskQuestion[]): string {
87  const first = questions[0]?.question ?? ''
88  return questions.length > 1 ? T.moreQuestions(first, questions.length - 1) : first
89}
90
91export function registerAsk(on: On, notify: boolean) {
92  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
93    const questions = parseQuestions(e.questions)
94    if (questions === null) return next(e)
95    const surfaces = await $.session.surfaces()
96    if (surfaces.length !== 1 || surfaces[0] !== 'terminal') return next(e)
97    if ((await read($, asking)) !== null) return next(e)
98
99    const made = await $.process.run(['mktemp', '-d'], { timeoutMs: 3000 }).catch(() => null)
100    const dir = made?.exitCode === 0 ? made.stdout.trim() : ''
101    if (dir === '') return next(e)
102    const id = e.tool_use_id ?? dir
103    const shownAt = await $.clock.now()
104    await update($, asking, () => ({ id, dir, questions, step: 0, picks: questions.map(() => []), texts: questions.map(() => ''), shownAt }))
105    if (notify) {
106      // 2.1.294 이하 엔진에는 notify 가 없다: 알림만 빠지고 카드는 그대로 뜬다.
107      try {
108        void $.ui.notify(askNotice(questions), { title: T.askTitle }).catch(() => undefined)
109      } catch {}
110    }
111
112    // 턴을 중단하면(Esc) 기다리던 셸을 끝낸다.
113    const onAbort = () => void reply($, dir, { kind: 'cancel' }).catch(() => undefined)
114    next.signal.addEventListener('abort', onAbort)
115    let ran: { exitCode: number; stdout: string } | null = null
116    for (let round = 0; round < WAIT_ROUNDS && !next.signal.aborted; round++) {
117      ran = await $.process.run(['sh', '-c', WAIT, 'sh', dir], { timeoutMs: WAIT_ROUND_MS }).catch(() => null)
118      if (ran?.exitCode !== 3) break
119    }
120    next.signal.removeEventListener('abort', onAbort)
121    await update($, asking, () => null)
122
123    let answer: AskReply = { kind: 'cancel' }
124    try {
125      answer = JSON.parse(ran?.stdout ?? '') as AskReply
126    } catch {
127      await $.process.run(['rm', '-rf', dir], { timeoutMs: 3000 }).catch(() => undefined)
128    }
129    switch (answer.kind) {
130      case 'answer':
131        return { result: { questions: e.questions, answers: answer.answers } }
132      case 'engine':
133        return next(e)
134      case 'dismiss':
135        return { deny: 'The user closed the question without answering.' }
136      case 'cancel':
137        return { deny: 'The question was cancelled before the user answered.' }
138    }
139  })
140}
141
142
hooks/tasks.ts 93 lines
1import type { SessionMessage } from 'claude-code'
2
3import type { TaskItem } from '../types'
4
5type Status = TaskItem['status']
6
7const STATUSES = new Set<Status>(['pending', 'in_progress', 'completed'])
8
9const str = (value: unknown) => (typeof value === 'string' ? value : undefined)
10const status = (value: unknown): Status | undefined => (STATUSES.has(value as Status) ? (value as Status) : undefined)
11
12// 세션 기록에서 지금의 할 일 목록을 다시 세운다. 데스크톱의 계획 카드와 같은 내용이다.
13// TodoWrite 는 목록 전체를 바꾸고, TaskCreate 는 하나를 더하고, TaskUpdate 는 고치거나(deleted 면) 지운다.
14// TaskList 의 결과는 그 시점의 정답이라 상태를 거기에 맞춘다. 실패한 호출은 건너뛴다.
15export function computeTasks(messages: ReadonlyArray<SessionMessage>): TaskItem[] {
16  const results = new Map<string, { isError: boolean; result: unknown }>()
17  for (const message of messages) {
18    for (const result of message.toolResults ?? []) {
19      results.set(result.tool_use_id, { isError: result.isError, result: result.result })
20    }
21  }
22
23  let list: TaskItem[] = []
24  for (const message of messages) {
25    if (message.role !== 'assistant') continue
26    for (const use of message.toolUses) {
27      const settled = results.get(use.tool_use_id)
28      if (use.isError === true || settled?.isError === true) continue
29      const output = (use.result ?? settled?.result ?? {}) as Record<string, unknown>
30      const input = use.input
31      switch (use.tool) {
32        case 'TodoWrite': {
33          const todos = Array.isArray(input.todos) ? (input.todos as Array<Record<string, unknown>>) : []
34          list = todos.map((todo, index) => ({
35            id: `todo-${index}`,
36            subject: str(todo.content) ?? '',
37            status: status(todo.status) ?? 'pending',
38            activeForm: str(todo.activeForm),
39          }))
40          break
41        }
42        case 'TaskCreate': {
43          const task = (output.task ?? {}) as Record<string, unknown>
44          const id = str(task.id) ?? String(list.length + 1)
45          list = [...list, { id, subject: str(input.subject) ?? str(task.subject) ?? '', status: 'pending', activeForm: str(input.activeForm) }]
46          break
47        }
48        case 'TaskUpdate': {
49          const id = str(input.taskId)
50          if (input.status === 'deleted') {
51            list = list.filter(item => item.id !== id)
52            break
53          }
54          list = list.map(item =>
55            item.id === id
56              ? {
57                  ...item,
58                  subject: str(input.subject) ?? item.subject,
59                  status: status(input.status) ?? item.status,
60                  activeForm: str(input.activeForm) ?? item.activeForm,
61                }
62              : item,
63          )
64          break
65        }
66        case 'TaskList': {
67          const tasks = Array.isArray(output.tasks) ? (output.tasks as Array<Record<string, unknown>>) : null
68          if (tasks === null) break
69          list = tasks.map(task => {
70            const id = str(task.id) ?? ''
71            const known = list.find(item => item.id === id)
72            return { id, subject: str(task.subject) ?? known?.subject ?? '', status: status(task.status) ?? 'pending', activeForm: known?.activeForm }
73          })
74          break
75        }
76      }
77    }
78  }
79  return list.filter(item => item.subject !== '')
80}
81
82// 띠에 보일 줄: 다 끝났으면 아무것도. 너무 길면 끝난 일부터 빼고, 그래도 길면 뒤쪽 대기 일을 접는다.
83export function visibleTasks(list: ReadonlyArray<TaskItem>, rows: number): { shown: TaskItem[]; hidden: number } {
84  if (list.length === 0 || list.every(item => item.status === 'completed')) return { shown: [], hidden: 0 }
85  if (list.length <= rows) return { shown: [...list], hidden: 0 }
86  const open = list.filter(item => item.status !== 'completed')
87  const done = list.filter(item => item.status === 'completed')
88  const keepDone = Math.max(0, Math.min(done.length, rows - open.length))
89  const kept = new Set([...done.slice(done.length - keepDone), ...open.slice(0, rows)])
90  const shown = list.filter(item => kept.has(item)).slice(0, rows)
91  return { shown, hidden: list.length - shown.length }
92}
93
hooks/theme.ts 39 lines
1import type { Surface } from '../types'
2
3// omarchy 현재 테마(HOME 기준). 다른 환경이면 파일이 없고 null 을 돌려준다.
4export const OMARCHY_COLORS = '.local/state/omarchy/current/theme/colors.toml'
5
6const hex = (value: string) => {
7  const match = /^#?([0-9a-f]{6})$/i.exec(value.trim())
8  return match?.[1] ? parseInt(match[1], 16) : null
9}
10
11// 데스크톱 CDS 의 --cds-bg-user-message: 밝은 테마는 검정 5%, 어두운 테마는 흰색 5% 를 배경에 얹는다.
12export function mix(background: string, toward: string, amount: number): string {
13  const from = hex(background) ?? 0
14  const to = hex(toward) ?? 0
15  const channel = (shift: number) => {
16    const a = (from >> shift) & 0xff
17    const b = (to >> shift) & 0xff
18    return Math.round(a + (b - a) * amount)
19  }
20  return `#${[16, 8, 0].map(shift => channel(shift).toString(16).padStart(2, '0')).join('')}`
21}
22
23export function parseSurface(toml: string): Surface | null {
24  const field = (key: string) => new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm').exec(toml)?.[1]
25  const background = field('background')
26  if (!background || hex(background) === null) return null
27  const declared = field('mode')
28  const luminance = (() => {
29    const value = hex(background) ?? 0
30    return (0.2126 * ((value >> 16) & 0xff) + 0.7152 * ((value >> 8) & 0xff) + 0.0722 * (value & 0xff)) / 255
31  })()
32  const mode = declared === 'light' || declared === 'dark' ? declared : luminance > 0.5 ? 'light' : 'dark'
33  return {
34    mode,
35    background: mix(background, background, 0),
36    bubble: mix(background, mode === 'light' ? '#000000' : '#ffffff', mode === 'light' ? 0.055 : 0.08),
37  }
38}
39
hooks/usage.ts 76 lines
1import type { SessionUsage } from 'claude-code'
2
3import type { UsageBreakdown, UsageInfo } from '../types'
4import { T } from './i18n'
5
6// 컨텍스트 칩: 데스크톱 입력창 옆의 컨텍스트 표시. 설정(contextChip)으로 언제 보일지 고른다.
7// auto 는 반을 넘겼을 때만 보인다: 상태 줄이나 다른 플러그인이 이미 늘 보여 주는 경우가 많아서다.
8export type ContextChipMode = 'auto' | 'always' | 'off'
9export const CHIP_FROM = 50
10
11export function contextChipMode(options: Record<string, unknown>): ContextChipMode {
12  return options.contextChip === 'always' || options.contextChip === 'off' ? options.contextChip : 'auto'
13}
14
15export function showsChip(mode: ContextChipMode, percent: number | null): percent is number {
16  if (percent === null || mode === 'off') return false
17  return mode === 'always' || percent >= CHIP_FROM
18}
19
20const RINGS = ['○', '◔', '◑', '◕', '●'] as const
21
22export function ring(percent: number): string {
23  return RINGS[Math.min(4, Math.max(0, Math.round(percent / 25)))] ?? '○'
24}
25
26// 75% 부터 주의, 90% 부터 위험(자동 압축이 가까움).
27export function meterLevel(percent: number): 'muted' | 'warning' | 'danger' {
28  return percent >= 90 ? 'danger' : percent >= 75 ? 'warning' : 'muted'
29}
30
31export function meterBar(percent: number, width: number): string {
32  const filled = Math.min(width, Math.max(0, Math.round((percent / 100) * width)))
33  return '▰'.repeat(filled) + '▱'.repeat(width - filled)
34}
35
36export function limitName(kind: string): string {
37  return T.limitNames[kind] ?? kind
38}
39
40// 초기화까지 남은 시간: "1일 5시간", "4시간 7분", "12분". 모르거나 지났으면 null.
41export function resetIn(resetsAt: string | null, now: number): string | null {
42  const at = resetsAt === null ? Number.NaN : Date.parse(resetsAt)
43  const left = Math.floor((at - now) / 60_000)
44  if (!Number.isFinite(left) || left <= 0) return null
45  const days = Math.floor(left / 1440)
46  const hours = Math.floor((left % 1440) / 60)
47  const minutes = left % 60
48  return T.duration(days, hours, minutes)
49}
50
51export function toUsage(raw: SessionUsage, at: number): UsageInfo {
52  return {
53    percent: raw.context.percent ?? null,
54    tokens: raw.context.tokens ?? null,
55    window: raw.context.window,
56    limits: raw.rateLimits.map(limit => ({ kind: limit.kind, percentUsed: limit.percentUsed, resetsAt: limit.resetsAt ?? null })),
57    costUsd: raw.cost?.usd ?? null,
58    at,
59  }
60}
61
62export function toBreakdown(raw: SessionUsage, at: number): UsageBreakdown | null {
63  const breakdown = raw.context.breakdown
64  if (!breakdown) return null
65  return {
66    categories: breakdown.categories
67      .filter(category => category.kind !== 'deferred')
68      .map(category => ({ name: category.name, tokens: category.tokens, kind: category.kind, color: category.color })),
69    total: breakdown.totalTokens,
70    max: breakdown.rawMaxTokens,
71    compactAt: breakdown.autoCompactThreshold ?? null,
72    model: breakdown.model,
73    at,
74  }
75}
76
hooks/pr.ts 359 lines
1import type { PrCheck, PrInfo, PrState, PrThread } from '../types'
2import { T } from './i18n'
3
4// 데스크톱 Code 탭의 PR 바: 지금 브랜치의 PR 상태·체크·안 풀린 리뷰. 데스크톱도 gh 로 읽는다
5// (같은 필드: state isDraft reviewDecision mergeStateStatus additions deletions statusCheckRollup).
6// 읽기만 한다. 병합·Ready 같은 바꾸는 일은 GitHub 에 바로 나가므로 넣지 않는다.
7export const PR_FIELDS = 'number,title,url,state,isDraft,reviewDecision,mergeStateStatus,headRefName,baseRefName,additions,deletions,statusCheckRollup'
8
9export const THREADS_QUERY =
10  'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved isOutdated path line comments(first:1){nodes{author{login} body url}}}}}}}'
11
12type RawCheck = {
13  __typename?: string
14  name?: string
15  context?: string
16  status?: string
17  conclusion?: string | null
18  state?: string
19  detailsUrl?: string | null
20  targetUrl?: string | null
21}
22
23// CheckRun 은 status·conclusion, StatusContext(옛 커밋 상태)는 state 하나.
24export function checkState(raw: RawCheck): PrCheck['state'] {
25  if (raw.__typename === 'StatusContext' || (raw.status === undefined && raw.state !== undefined)) {
26    return raw.state === 'SUCCESS' ? 'pass' : raw.state === 'PENDING' || raw.state === 'EXPECTED' ? 'pending' : 'fail'
27  }
28  if (raw.status !== 'COMPLETED') return 'pending'
29  switch (raw.conclusion) {
30    case 'SUCCESS':
31      return 'pass'
32    case 'NEUTRAL':
33    case 'SKIPPED':
34    case 'STALE':
35      return 'skip'
36    default:
37      return 'fail'
38  }
39}
40
41const CHECK_ORDER: Record<PrCheck['state'], number> = { fail: 0, pending: 1, pass: 2, skip: 3 }
42
43export function toChecks(rollup: unknown): PrCheck[] {
44  if (!Array.isArray(rollup)) return []
45  return (rollup as RawCheck[])
46    .map(raw => ({ name: raw.name ?? raw.context ?? '?', state: checkState(raw), url: raw.detailsUrl ?? raw.targetUrl ?? null }))
47    .sort((a, b) => CHECK_ORDER[a.state] - CHECK_ORDER[b.state] || a.name.localeCompare(b.name))
48}
49
50export function checkSummary(checks: ReadonlyArray<PrCheck>) {
51  const count = (state: PrCheck['state']) => checks.filter(check => check.state === state).length
52  const passed = count('pass')
53  const failed = count('fail')
54  const pending = count('pending')
55  return { passed, failed, pending, total: passed + failed + pending }
56}
57
58type RawThread = {
59  isResolved?: boolean
60  isOutdated?: boolean
61  path?: string
62  line?: number | null
63  comments?: { nodes?: Array<{ author?: { login?: string } | null; body?: string; url?: string }> }
64}
65
66// 안 풀린 스레드만, 첫 코멘트로.
67export function toThreads(response: unknown): PrThread[] | null {
68  const nodes = (response as { data?: { repository?: { pullRequest?: { reviewThreads?: { nodes?: RawThread[] } } } } })?.data?.repository?.pullRequest
69    ?.reviewThreads?.nodes
70  if (!Array.isArray(nodes)) return null
71  return nodes
72    .filter(node => node.isResolved === false)
73    .map(node => {
74      const first = node.comments?.nodes?.[0]
75      return {
76        path: node.path ?? '',
77        line: typeof node.line === 'number' ? node.line : null,
78        author: first?.author?.login ?? '?',
79        body: (first?.body ?? '').trim(),
80        url: first?.url ?? null,
81        isOutdated: node.isOutdated === true,
82      }
83    })
84}
85
86export function toPr(raw: Record<string, unknown>, threads: PrThread[] | null): PrInfo {
87  const state = raw.state === 'MERGED' || raw.state === 'CLOSED' ? raw.state : 'OPEN'
88  return {
89    provider: 'github',
90    number: Number(raw.number),
91    title: String(raw.title ?? ''),
92    url: String(raw.url ?? ''),
93    state,
94    isDraft: raw.isDraft === true,
95    reviewDecision: typeof raw.reviewDecision === 'string' && raw.reviewDecision !== '' ? raw.reviewDecision : null,
96    mergeState: typeof raw.mergeStateStatus === 'string' ? raw.mergeStateStatus : null,
97    head: String(raw.headRefName ?? ''),
98    base: String(raw.baseRefName ?? ''),
99    added: Number(raw.additions ?? 0),
100    removed: Number(raw.deletions ?? 0),
101    checks: toChecks(raw.statusCheckRollup),
102    threads,
103  }
104}
105
106// https://github.com/owner/name/pull/12 → 호스트·owner·name (GitHub Enterprise 도).
107export function prRepo(url: string): { host: string; owner: string; name: string } | null {
108  const match = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/pull\/\d+/.exec(url)
109  return match ? { host: match[1]!, owner: match[2]!, name: match[3]! } : null
110}
111
112// 칩 조각: 흐린 PR 번호, 그 뒤 상태 하나. color 는 COLORS 의 키.
113export type ChipPart = { text: string; color: 'muted' | 'success' | 'danger' | 'warning' }
114
115export function prChipParts(pr: PrInfo): ChipPart[] {
116  const parts: ChipPart[] = [{ text: `\uf407 ${prNumber(pr)}`, color: 'muted' }]
117  if (pr.state === 'MERGED') return [...parts, { text: 'merged', color: 'muted' }]
118  if (pr.state === 'CLOSED') return [...parts, { text: 'closed', color: 'muted' }]
119  if (pr.isDraft) parts.push({ text: 'draft', color: 'muted' })
120  const { passed, failed, pending, total } = checkSummary(pr.checks)
121  if (failed > 0) parts.push({ text: `✗ ${failed}`, color: 'danger' })
122  else if (pending > 0) parts.push({ text: `● ${passed}/${total}`, color: 'warning' })
123  else if (total > 0) parts.push({ text: `✓ ${passed}/${total}`, color: 'success' })
124  if (pr.reviewDecision === 'CHANGES_REQUESTED') parts.push({ text: T.chipChangesRequested, color: 'danger' })
125  const open = pr.threads?.length ?? 0
126  if (open > 0) parts.push({ text: `◆ ${open}`, color: 'warning' })
127  return parts
128}
129
130export function reviewName(decision: string): string {
131  return T.reviewNames[decision] ?? decision
132}
133
134// GitHub mergeStateStatus(대문자)와 GitLab detailed_merge_status(소문자).
135export function mergeName(state: string): string {
136  return T.mergeNames[state] ?? state
137}
138
139export function mergeLevel(state: string): 'success' | 'danger' | 'muted' {
140  return state === 'CLEAN' || state === 'HAS_HOOKS' || state === 'mergeable' ? 'success' : state === 'DIRTY' || state === 'conflict' ? 'danger' : 'muted'
141}
142
143// GitHub 은 PR #12, GitLab 은 MR !12.
144export function prNumber(pr: Pick<PrInfo, 'provider' | 'number'>): string {
145  return pr.provider === 'gitlab' ? `!${pr.number}` : `#${pr.number}`
146}
147
148export function prRef(pr: Pick<PrInfo, 'provider' | 'number'>): string {
149  return `${pr.provider === 'gitlab' ? 'MR' : 'PR'} ${prNumber(pr)}`
150}
151
152// "Claude에게 맡기기" 가 입력창에 채우는 글.
153export function checkPrompt(pr: PrInfo, check: PrCheck): string {
154  return T.checkPrompt(prRef(pr), pr.provider === 'gitlab', check.name, check.url)
155}
156
157export function threadPrompt(pr: PrInfo, thread: PrThread): string {
158  const where = thread.line === null ? thread.path : `${thread.path}:${thread.line}`
159  const quoted = thread.body
160    .slice(0, 1500)
161    .split('\n')
162    .map(line => `> ${line}`)
163    .join('\n')
164  return `${T.threadPrompt(prRef(pr), where, thread.author)}\n${quoted}`
165}
166
167type Ran = { exitCode: number; stdout: string; stderr: string }
168
169// 다시 물어도 같을 실패: 도구가 없음(실행 실패), git 저장소가 아님, gh 가 모르는 호스트.
170export function isLocalFailure(ran: Ran): boolean {
171  return ran.exitCode === 127 || /not a git repository|known GitHub host/i.test(ran.stderr)
172}
173type Run = (argv: string[]) => Promise<Ran>
174
175// gh 가 없거나(실행 실패), GitHub 저장소가 아니면 off. PR 이 없는 브랜치는 none.
176export async function fetchPr(run: Run, branch: string | null, at: number): Promise<PrState> {
177  const view = await run(['gh', 'pr', 'view', '--json', PR_FIELDS]).catch(error => ({ exitCode: 127, stdout: '', stderr: String(error) }))
178  if (view.exitCode !== 0) {
179    const reason = view.stderr.trim().split('\n')[0] ?? ''
180    return /no (open )?pull requests? found/i.test(view.stderr)
181      ? { status: 'none', branch, pr: null, at }
182      : { status: 'off', branch, pr: null, at, error: reason, isSticky: isLocalFailure(view) }
183  }
184  let raw: Record<string, unknown>
185  try {
186    raw = JSON.parse(view.stdout) as Record<string, unknown>
187  } catch {
188    return { status: 'off', branch, pr: null, at, error: T.outputUnreadable('gh') }
189  }
190  const where = prRepo(String(raw.url ?? ''))
191  let threads: PrThread[] | null = null
192  if (where) {
193    const ran = await run([
194      'gh',
195      'api',
196      'graphql',
197      '--hostname',
198      where.host,
199      '-f',
200      `query=${THREADS_QUERY}`,
201      '-F',
202      `owner=${where.owner}`,
203      '-F',
204      `name=${where.name}`,
205      '-F',
206      `number=${Number(raw.number)}`,
207    ]).catch(() => null)
208    if (ran?.exitCode === 0) {
209      try {
210        threads = toThreads(JSON.parse(ran.stdout))
211      } catch {
212        threads = null
213      }
214    }
215  }
216  return { status: 'ok', branch, pr: toPr(raw, threads), at }
217}
218
219// ── GitLab (glab) ──────────────────────────────────────────────────────────────
220// glab mr view 는 지금 브랜치의 열린 MR 을 준다. 파이프라인 job 은 체크로, 풀 수 있는 토론 중 안 풀린 것은
221// 리뷰 스레드로 옮긴다. MR JSON 에는 줄 수가 없어서 diff_refs 의 두 커밋으로 로컬 git 에서 센다.
222
223const GITLAB_STATES: Record<string, PrInfo['state']> = { opened: 'OPEN', merged: 'MERGED', closed: 'CLOSED', locked: 'CLOSED' }
224
225export function jobState(job: { status?: string; allow_failure?: boolean }): PrCheck['state'] {
226  switch (job.status) {
227    case 'success':
228      return 'pass'
229    case 'failed':
230      return job.allow_failure ? 'skip' : 'fail'
231    case 'canceled':
232      return 'fail'
233    case 'skipped':
234    case 'manual':
235      return 'skip'
236    default:
237      return 'pending'
238  }
239}
240
241export function toJobs(jobs: unknown): PrCheck[] {
242  if (!Array.isArray(jobs)) return []
243  return (jobs as Array<{ name?: string; stage?: string; status?: string; allow_failure?: boolean; web_url?: string }>)
244    .map(job => ({ name: job.stage ? `${job.stage} / ${job.name ?? '?'}` : (job.name ?? '?'), state: jobState(job), url: job.web_url ?? null }))
245    .sort((a, b) => CHECK_ORDER[a.state] - CHECK_ORDER[b.state] || a.name.localeCompare(b.name))
246}
247
248type GitlabNote = {
249  id?: number
250  body?: string
251  system?: boolean
252  resolvable?: boolean
253  resolved?: boolean
254  author?: { username?: string } | null
255  position?: { new_path?: string; new_line?: number | null; old_path?: string; old_line?: number | null } | null
256}
257
258export function toDiscussions(discussions: unknown, webUrl: string): PrThread[] | null {
259  if (!Array.isArray(discussions)) return null
260  return (discussions as Array<{ notes?: GitlabNote[] }>)
261    .filter(discussion => (discussion.notes ?? []).some(note => note.resolvable === true && note.resolved !== true))
262    .map(discussion => {
263      const first = (discussion.notes ?? []).find(note => !note.system) ?? discussion.notes![0]!
264      const position = first.position ?? null
265      return {
266        path: position?.new_path ?? position?.old_path ?? '',
267        line: position?.new_line ?? position?.old_line ?? null,
268        author: first.author?.username ?? '?',
269        body: (first.body ?? '').trim(),
270        url: first.id !== undefined && webUrl ? `${webUrl}#note_${first.id}` : null,
271        isOutdated: false,
272      }
273    })
274}
275
276// detailed_merge_status 를 PR 의 리뷰 결정으로(승인 여부는 MR JSON 에 따로 없다).
277export function gitlabReview(status: string | null): string | null {
278  return status === 'requested_changes' ? 'CHANGES_REQUESTED' : status === 'not_approved' ? 'REVIEW_REQUIRED' : null
279}
280
281export function shortStat(text: string): { added: number; removed: number } | null {
282  if (!/changed/.test(text)) return null
283  return { added: Number(/(\d+) insertion/.exec(text)?.[1] ?? 0), removed: Number(/(\d+) deletion/.exec(text)?.[1] ?? 0) }
284}
285
286export function toMr(raw: Record<string, unknown>, checks: PrCheck[], threads: PrThread[] | null, stat: { added: number; removed: number } | null): PrInfo {
287  const status = typeof raw.detailed_merge_status === 'string' ? raw.detailed_merge_status : null
288  return {
289    provider: 'gitlab',
290    number: Number(raw.iid),
291    title: String(raw.title ?? ''),
292    url: String(raw.web_url ?? ''),
293    state: GITLAB_STATES[String(raw.state)] ?? 'OPEN',
294    isDraft: raw.draft === true || raw.work_in_progress === true,
295    reviewDecision: gitlabReview(status),
296    mergeState: raw.has_conflicts === true ? 'conflict' : status,
297    head: String(raw.source_branch ?? ''),
298    base: String(raw.target_branch ?? ''),
299    added: stat?.added ?? null,
300    removed: stat?.removed ?? null,
301    checks,
302    threads,
303  }
304}
305
306// glab mr view 를 인자 없이 쓰면 같은 브랜치에 병합된 MR 이 여럿일 때 고르라며 실패한다. 그래서 열린 MR 을
307// 브랜치로 먼저 찾고 번호로 읽는다.
308export async function fetchMr(run: Run, branch: string | null, at: number): Promise<PrState> {
309  const failed = (error: unknown) => ({ exitCode: 127, stdout: '', stderr: String(error) })
310  const reasonOf = (stderr: string) => stderr.split('\n').map(line => line.trim()).find(line => line !== '' && line !== 'ERROR') ?? ''
311  if (branch === null) return { status: 'none', branch, pr: null, at }
312  const list = await run(['glab', 'mr', 'list', '--source-branch', branch, '--output', 'json']).catch(failed)
313  if (list.exitCode !== 0) return { status: 'off', branch, pr: null, at, error: reasonOf(list.stderr), isSticky: isLocalFailure(list) }
314  let iid: number | undefined
315  try {
316    iid = (JSON.parse(list.stdout) as Array<{ iid?: number }>)[0]?.iid
317  } catch {
318    return { status: 'off', branch, pr: null, at, error: T.outputUnreadable('glab') }
319  }
320  if (iid === undefined) return { status: 'none', branch, pr: null, at }
321  const view = await run(['glab', 'mr', 'view', String(iid), '--output', 'json']).catch(failed)
322  if (view.exitCode !== 0) return { status: 'off', branch, pr: null, at, error: reasonOf(view.stderr) }
323  let raw: Record<string, unknown>
324  try {
325    raw = JSON.parse(view.stdout) as Record<string, unknown>
326  } catch {
327    return { status: 'off', branch, pr: null, at, error: T.outputUnreadable('glab') }
328  }
329  const webUrl = String(raw.web_url ?? '')
330  const host = /^https?:\/\/([^/]+)\//.exec(webUrl)?.[1]
331  const api = (path: string) =>
332    run(['glab', 'api', ...(host ? ['--hostname', host] : []), path])
333      .then(ran => (ran.exitCode === 0 ? (JSON.parse(ran.stdout) as unknown) : null))
334      .catch(() => null)
335  const pipeline = (raw.head_pipeline ?? raw.pipeline) as { id?: number; status?: string; web_url?: string } | null
336  const refs = raw.diff_refs as { base_sha?: string; head_sha?: string } | null
337  const [jobs, discussions, stat] = await Promise.all([
338    pipeline?.id ? api(`projects/${raw.project_id}/pipelines/${pipeline.id}/jobs?per_page=100`) : Promise.resolve(null),
339    api(`projects/${raw.project_id}/merge_requests/${raw.iid}/discussions?per_page=100`),
340    refs?.base_sha && refs.head_sha
341      ? run(['git', 'diff', '--shortstat', refs.base_sha, refs.head_sha])
342          .then(ran => (ran.exitCode === 0 ? shortStat(ran.stdout) : null))
343          .catch(() => null)
344      : Promise.resolve(null),
345  ])
346  // job 목록을 못 읽으면 파이프라인 전체 상태 하나를 체크로
347  const checks = jobs !== null ? toJobs(jobs) : pipeline?.status ? [{ name: 'pipeline', state: jobState(pipeline), url: pipeline.web_url ?? null }] : []
348  return { status: 'ok', branch, pr: toMr(raw, checks, toDiscussions(discussions, webUrl), stat), at }
349}
350
351// GitHub 저장소가 아니면 gh 는 네트워크 없이 곧바로 실패한다. 그때 glab 으로 다시 본다.
352export async function fetchReview(run: Run, branch: string | null, at: number): Promise<PrState> {
353  const github = await fetchPr(run, branch, at)
354  if (github.status !== 'off') return github
355  const gitlab = await fetchMr(run, branch, at)
356  if (gitlab.status !== 'off') return gitlab
357  return { ...github, error: [github.error, gitlab.error].filter(Boolean).join(' / ') || undefined, isSticky: github.isSticky === true && gitlab.isSticky === true }
358}
359
hooks/gitview.ts 47 lines
1import type { CommitInfo, DiffScope } from '../types'
2import { T, allScopeLabels } from './i18n'
3
4// 데스크톱 diff 패널의 범위: 커밋 안 한 변경 / 브랜치 전체(기본 브랜치에서 갈라진 뒤) / 커밋별.
5// 데스크톱도 기본 브랜치와의 기준점을 merge-base 로 잡는다.
6export const SCOPES: ReadonlyArray<DiffScope> = ['uncommitted', 'branch', 'commits']
7
8export function scopeLabel(scope: DiffScope): string {
9  return scope === 'branch' ? T.scopeBranch : scope === 'commits' ? T.scopeCommits : T.scopeUncommitted
10}
11
12// /desk-diff 의 인자: 범위 키(branch …)나 어느 언어의 탭 이름이든.
13export function scopeOf(word: string): DiffScope | null {
14  if ((SCOPES as readonly string[]).includes(word)) return word as DiffScope
15  const found = allScopeLabels().find(([label]) => label.toLowerCase() === word.toLowerCase())
16  return (found?.[1] as DiffScope | undefined) ?? null
17}
18
19export const LOG_FORMAT = '--format=%H%x1f%h%x1f%s%x1f%an%x1f%ar'
20export const MAX_COMMITS = 50
21
22export function parseCommits(text: string): CommitInfo[] {
23  return text
24    .split('\n')
25    .filter(line => line.includes('\x1f'))
26    .map(line => {
27      const [sha = '', short = '', subject = '', author = '', ago = ''] = line.split('\x1f')
28      return { sha, short, subject, author, ago }
29    })
30}
31
32// 기준 브랜치 후보: PR 의 base 가 있으면 그것, 그다음 origin 의 기본 브랜치, 흔한 이름.
33export function baseCandidates(prBase: string | null, originHead: string | null): string[] {
34  const names = [
35    prBase ? `origin/${prBase}` : null,
36    prBase,
37    originHead,
38    'origin/main',
39    'origin/master',
40    'origin/develop',
41    'main',
42    'master',
43    'develop',
44  ]
45  return [...new Set(names.filter((name): name is string => !!name))]
46}
47
hooks/i18n.ts 327 lines
1// 화면 문구 사전: 영어(기본)와 한국어. 설정 language(auto·en·ko)로 고르고, auto 는 Claude Code 의
2// /config 언어(language)를 따른다. T 는 한 객체를 계속 쓰고 setLang 이 내용만 바꾼다(가져간 쪽이 그대로 본다).
3
4export type Lang = 'en' | 'ko'
5export type LangSetting = Lang | 'auto'
6
7const ko = {
8  // 공통
9  moreLines: (count: number) => `… ${count}줄 더`,
10  copied: (what: 'code' | 'answer', lines: number) => `${what === 'code' ? '코드' : '답변'} ${lines}줄을 복사했어요`,
11  copyFailed: (reason: string) => `복사하지 못했어요: ${reason}`,
12  filled: '입력창에 채웠어요. 고쳐서 Enter 로 보내세요.',
13  fillFailed: '입력창에 채우지 못했어요.',
14  // 패널 제목
15  paneContext: '컨텍스트',
16  paneChanges: '변경 사항',
17  paneSessions: '세션',
18  // 명령
19  cmdSessions: '데스크톱 사이드바처럼 최근 세션 목록 패널을 연다 (번호를 주면 그 세션으로 이동)',
20  cmdSessionsHint: '[번호]',
21  cmdDiff: '데스크톱 앱처럼 우측에 변경 파일 diff 패널을 연다 (파일 이름을 주면 그 파일에서, branch·commits 면 그 범위로)',
22  cmdDiffHint: '[파일|branch|commits]',
23  cmdPr: '지금 브랜치의 PR(GitHub)·MR(GitLab) 상태·체크·안 풀린 리뷰를 패널로 연다 (gh·glab 필요)',
24  cmdContext: '컨텍스트 사용량을 항목별로, 사용 한도·비용과 함께 패널로 연다',
25  noSession: (index: number) => `${index}번 세션이 없어요. /desk-sessions 로 목록을 다시 열어 주세요.`,
26  sessionPicked: (index: number, title: string) => `${index}번 세션: ${title}`,
27  sessionsOpened: '세션 패널을 열었습니다.',
28  contextOpened: '컨텍스트 패널을 열었습니다.',
29  diffOpenedAt: (name: string) => `변경 사항 패널을 ${name} 에서 열었습니다.`,
30  diffOpened: '변경 사항 패널을 열었습니다.',
31  prOpened: (ref: string | null): string => (ref ? `${ref} 패널을 열었습니다.` : 'PR 패널을 열었습니다.'),
32  // 세션
33  resumeHint: (title: string) => `Enter 를 누르면 "${title}" 세션으로 넘어가요.`,
34  otherFolderCopied: (shell: string) => `다른 폴더의 세션이에요. 명령을 복사했어요: ${shell}`,
35  otherFolder: (shell: string) => `다른 폴더의 세션이에요: ${shell}`,
36  recentSessions: (count: number) => `최근 세션 ${count}개`,
37  search: '검색',
38  searchPlaceholder: '제목이나 폴더',
39  openFirst: '첫 결과 열기',
40  noMatch: '맞는 세션이 없어요.',
41  unknownFolder: '(알 수 없음)',
42  justNow: '방금',
43  minutesShort: (count: number) => `${count}분`,
44  hoursShort: (count: number) => `${count}시간`,
45  daysShort: (count: number) => `${count}일`,
46  // 계획 카드
47  planWaiting: '승인 대기',
48  planInterrupted: '중단됨',
49  planRejected: '거절됨',
50  planApproved: '승인됨',
51  planInDialog: '승인 창에서 계획을 보여 주고 있어요.',
52  planMissing: '기록에 계획 내용이 없어요.',
53  // 컨텍스트 패널
54  contextTitle: '컨텍스트',
55  noUsage: '아직 모델 응답이 없어서 사용량을 몰라요.',
56  tokensOf: (used: string, window: string) => `${used} / ${window} 토큰`,
57  windowTokens: (window: string) => `창 ${window} 토큰`,
58  autoCompact: (at: string) => `자동 압축 ${at}`,
59  breakdown: (model: string) => `항목별 · ${model} · 추정`,
60  limits: '사용 한도',
61  resetsIn: (left: string) => `${left} 뒤 초기화`,
62  sessionCost: (usd: string) => `이 세션 비용 $${usd}`,
63  limitNames: { five_hour: '5시간', seven_day: '7일', spend_limit: '지출 한도' } as Record<string, string>,
64  duration: (days: number, hours: number, minutes: number) =>
65    days > 0 ? (hours > 0 ? `${days}일 ${hours}시간` : `${days}일`) : hours > 0 ? (minutes > 0 ? `${hours}시간 ${minutes}분` : `${hours}시간`) : `${minutes}분`,
66  // diff 패널
67  scopeUncommitted: '커밋 안 한 변경',
68  scopeBranch: '브랜치 전체',
69  scopeCommits: '커밋별',
70  reverted: (path: string) => `${path} 을(를) 되돌렸어요`,
71  revertFailed: (reason: string) => `되돌리지 못했어요: ${reason}`,
72  notInDiff: '이 파일은 지금 커밋되지 않은 변경에 없어요(커밋했거나 저장소 밖).',
73  noBase: '기준 브랜치(origin 의 기본 브랜치, main·master·develop)를 찾지 못했어요.',
74  notRepoDiff: 'git 저장소가 아니라서 보여 줄 diff가 없어요.',
75  noChangesSince: (ref: string) => `${ref} 에서 갈라진 뒤 바뀐 것이 없어요.`,
76  noUncommitted: '커밋되지 않은 변경이 없어요.',
77  revert: '되돌리기',
78  revertConfirm: '다시 누르면 되돌려요',
79  binary: '바이너리 또는 모드 변경',
80  commitsTitle: '커밋',
81  changesTitle: '변경 사항',
82  notRepo: 'git 저장소가 아니에요.',
83  noCommitsSince: (ref: string) => `${ref} 이후 커밋이 없어요.`,
84  noCommits: '커밋이 없어요.',
85  emptyCommit: '보여 줄 diff 가 없어요(병합 커밋이거나 빈 커밋).',
86  // PR 패널
87  prLoading: 'PR 을 읽는 중이에요…',
88  prNone: (branch: string) => `${branch} 브랜치에는 열린 PR·MR 이 없어요.`,
89  prDetached: '브랜치가 아니라서(detached HEAD) PR 을 찾을 수 없어요.',
90  prFailed: (error: string | undefined) =>
91    `PR·MR 을 읽지 못했어요${error ? `: ${error}` : ''}. GitHub 은 gh, GitLab 은 glab 이 설치·로그인되어 있는지 확인해 주세요.`,
92  handToClaude: 'Claude에게 맡기기',
93  review: (word: string) => `리뷰 ${word}`,
94  openOn: (provider: 'github' | 'gitlab'): string => (provider === 'gitlab' ? 'GitLab에서 열기' : 'GitHub에서 열기'),
95  checksNone: '없음',
96  passedOf: (passed: number, total: number) => `${passed}/${total} 통과`,
97  failedCount: (count: number) => `${count} 실패`,
98  runningCount: (count: number) => `${count} 진행 중`,
99  passesFolded: (count: number) => `통과·건너뜀 ${count}개`,
100  passesShown: (count: number) => `통과·건너뜀 ${count}개 접기`,
101  unresolved: '안 풀린 리뷰',
102  unreadable: '읽지 못함',
103  count: (count: number) => `${count}개`,
104  chipChangesRequested: '변경 요청',
105  reviewNames: { APPROVED: '승인됨', CHANGES_REQUESTED: '변경 요청됨', REVIEW_REQUIRED: '리뷰 필요' } as Record<string, string>,
106  mergeNames: {
107    CLEAN: '병합 가능',
108    HAS_HOOKS: '병합 가능',
109    BLOCKED: '막힘 (필수 체크·리뷰)',
110    BEHIND: '기본 브랜치보다 뒤처짐',
111    DIRTY: '충돌 있음',
112    UNSTABLE: '통과 못 한 체크 있음',
113    DRAFT: '초안',
114    UNKNOWN: '계산 중',
115    mergeable: '병합 가능',
116    conflict: '충돌 있음',
117    not_approved: '승인 필요',
118    requested_changes: '변경 요청됨',
119    ci_must_pass: '파이프라인 통과 필요',
120    ci_still_running: '파이프라인 진행 중',
121    discussions_not_resolved: '안 풀린 토론 있음',
122    draft_status: '초안',
123    need_rebase: '리베이스 필요',
124    blocked_status: '막힘',
125    not_open: '열려 있지 않음',
126    checking: '계산 중',
127    unchecked: '계산 중',
128    approvals_syncing: '승인 확인 중',
129  } as Record<string, string>,
130  checkPrompt: (ref: string, isGitlab: boolean, name: string, url: string | null) =>
131    `${ref} 의 ${isGitlab ? '파이프라인 job' : 'CI 체크'} "${name}" 이(가) 실패했어요${url ? ` (${url})` : ''}. 로그를 확인해서 원인을 찾아 고쳐 주세요.`,
132  threadPrompt: (ref: string, where: string, author: string) => `${ref} 리뷰 코멘트를 반영해 주세요. ${where} (${author})`,
133  outputUnreadable: (tool: string) => `${tool} 출력을 읽지 못했어요`,
134  // 질문 카드
135  askHowText: '입력칸을 클릭해 답하기',
136  askHowMulti: '숫자로 고르고 0 제출',
137  askHowPick: '숫자로 고르기',
138  askHintTail: '9 기본 창 · Esc 취소',
139  answerInDefault: '기본 창에서 답하기',
140  toDefault: '기본 창으로',
141  close: '닫기',
142  submit: '제출',
143  next: '다음',
144  nextArrow: '다음 →',
145  hoverPreview: '선택지에 마우스를 올리면 미리보기',
146  answerLabel: (unit: string) => `답${unit}`,
147  numberPlaceholder: '숫자',
148  otherPlaceholder: '두 번 클릭해서 입력',
149  needNumber: '숫자를 입력해 주세요',
150  atLeast: (min: number) => `${min} 이상이어야 해요`,
151  atMost: (max: number) => `${max} 이하여야 해요`,
152  moreQuestions: (first: string, more: number) => `${first} (외 ${more}개)`,
153  askTitle: 'Claude 질문',
154}
155
156export type Strings = typeof ko
157
158const en: Strings = {
159  moreLines: count => `… ${count} more line${count === 1 ? '' : 's'}`,
160  copied: (what, lines) => `Copied ${lines} line${lines === 1 ? '' : 's'} of the ${what}`,
161  copyFailed: reason => `Couldn't copy: ${reason}`,
162  filled: 'Put it in the prompt. Edit it and press Enter to send.',
163  fillFailed: "Couldn't fill the prompt.",
164  paneContext: 'Context',
165  paneChanges: 'Changes',
166  paneSessions: 'Sessions',
167  cmdSessions: 'Open the recent sessions pane, like the desktop sidebar (give a number to switch to that session)',
168  cmdSessionsHint: '[number]',
169  cmdDiff: 'Open the diff pane on the right, like the desktop app (give a file name to open at it, or branch / commits for that scope)',
170  cmdDiffHint: '[file|branch|commits]',
171  cmdPr: "Open the current branch's PR (GitHub) or MR (GitLab) with checks and unresolved reviews (needs gh or glab)",
172  cmdContext: 'Open context usage by category, with usage limits and cost',
173  noSession: index => `There is no session ${index}. Run /desk-sessions to open the list again.`,
174  sessionPicked: (index, title) => `Session ${index}: ${title}`,
175  sessionsOpened: 'Opened the sessions pane.',
176  contextOpened: 'Opened the context pane.',
177  diffOpenedAt: name => `Opened the changes pane at ${name}.`,
178  diffOpened: 'Opened the changes pane.',
179  prOpened: ref => (ref ? `Opened the ${ref} pane.` : 'Opened the PR pane.'),
180  resumeHint: title => `Press Enter to switch to "${title}".`,
181  otherFolderCopied: shell => `That session is in another folder. Copied the command: ${shell}`,
182  otherFolder: shell => `That session is in another folder: ${shell}`,
183  recentSessions: count => `${count} recent session${count === 1 ? '' : 's'}`,
184  search: 'Search',
185  searchPlaceholder: 'title or folder',
186  openFirst: 'Open first result',
187  noMatch: 'No matching sessions.',
188  unknownFolder: '(unknown)',
189  justNow: 'now',
190  minutesShort: count => `${count}m`,
191  hoursShort: count => `${count}h`,
192  daysShort: count => `${count}d`,
193  planWaiting: 'Waiting for approval',
194  planInterrupted: 'Interrupted',
195  planRejected: 'Rejected',
196  planApproved: 'Approved',
197  planInDialog: 'The plan is shown in the approval dialog.',
198  planMissing: 'The transcript has no plan text.',
199  contextTitle: 'Context',
200  noUsage: 'No model response yet, so usage is unknown.',
201  tokensOf: (used, window) => `${used} / ${window} tokens`,
202  windowTokens: window => `${window} token window`,
203  autoCompact: at => `auto-compact at ${at}`,
204  breakdown: model => `By category · ${model} · estimate`,
205  limits: 'Usage limits',
206  resetsIn: left => `resets in ${left}`,
207  sessionCost: usd => `This session cost $${usd}`,
208  limitNames: { five_hour: '5-hour', seven_day: '7-day', spend_limit: 'Spend limit' },
209  duration: (days, hours, minutes) =>
210    days > 0 ? (hours > 0 ? `${days}d ${hours}h` : `${days}d`) : hours > 0 ? (minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`) : `${minutes}m`,
211  scopeUncommitted: 'Uncommitted',
212  scopeBranch: 'Branch',
213  scopeCommits: 'Commits',
214  reverted: path => `Reverted ${path}`,
215  revertFailed: reason => `Couldn't revert: ${reason}`,
216  notInDiff: 'This file has no uncommitted changes now (committed, or outside the repository).',
217  noBase: "Couldn't find a base branch (origin's default branch, main, master or develop).",
218  notRepoDiff: 'Not a git repository, so there is no diff to show.',
219  noChangesSince: ref => `Nothing changed since the branch left ${ref}.`,
220  noUncommitted: 'No uncommitted changes.',
221  revert: 'Revert',
222  revertConfirm: 'Press again to revert',
223  binary: 'Binary or mode change',
224  commitsTitle: 'Commits',
225  changesTitle: 'Changes',
226  notRepo: 'Not a git repository.',
227  noCommitsSince: ref => `No commits since ${ref}.`,
228  noCommits: 'No commits.',
229  emptyCommit: 'No diff to show (a merge or empty commit).',
230  prLoading: 'Reading the PR…',
231  prNone: branch => `No open PR or MR for ${branch}.`,
232  prDetached: 'Not on a branch (detached HEAD), so there is no PR to find.',
233  prFailed: error => `Couldn't read the PR or MR${error ? `: ${error}` : ''}. Check that gh (GitHub) or glab (GitLab) is installed and logged in.`,
234  handToClaude: 'Hand to Claude',
235  review: word => `Review: ${word}`,
236  openOn: provider => (provider === 'gitlab' ? 'Open on GitLab' : 'Open on GitHub'),
237  checksNone: 'none',
238  passedOf: (passed, total) => `${passed}/${total} passed`,
239  failedCount: count => `${count} failed`,
240  runningCount: count => `${count} running`,
241  passesFolded: count => `${count} passed or skipped`,
242  passesShown: count => `Hide ${count} passed or skipped`,
243  unresolved: 'Unresolved reviews',
244  unreadable: 'unavailable',
245  count: count => `${count}`,
246  chipChangesRequested: 'changes requested',
247  reviewNames: { APPROVED: 'approved', CHANGES_REQUESTED: 'changes requested', REVIEW_REQUIRED: 'review required' },
248  mergeNames: {
249    CLEAN: 'Ready to merge',
250    HAS_HOOKS: 'Ready to merge',
251    BLOCKED: 'Blocked (required checks or reviews)',
252    BEHIND: 'Behind the base branch',
253    DIRTY: 'Has conflicts',
254    UNSTABLE: 'Some checks did not pass',
255    DRAFT: 'Draft',
256    UNKNOWN: 'Checking',
257    mergeable: 'Ready to merge',
258    conflict: 'Has conflicts',
259    not_approved: 'Needs approval',
260    requested_changes: 'Changes requested',
261    ci_must_pass: 'Pipeline must pass',
262    ci_still_running: 'Pipeline running',
263    discussions_not_resolved: 'Unresolved discussions',
264    draft_status: 'Draft',
265    need_rebase: 'Needs rebase',
266    blocked_status: 'Blocked',
267    not_open: 'Not open',
268    checking: 'Checking',
269    unchecked: 'Checking',
270    approvals_syncing: 'Syncing approvals',
271  },
272  checkPrompt: (ref, isGitlab, name, url) =>
273    `The ${isGitlab ? 'pipeline job' : 'CI check'} "${name}" failed on ${ref}${url ? ` (${url})` : ''}. Check the log, find the cause and fix it.`,
274  threadPrompt: (ref, where, author) => `Address this review comment on ${ref}. ${where} (${author})`,
275  outputUnreadable: tool => `Couldn't read ${tool} output`,
276  askHowText: 'click the field to answer',
277  askHowMulti: 'pick by number, 0 to submit',
278  askHowPick: 'pick by number',
279  askHintTail: '9 default dialog · Esc cancel',
280  answerInDefault: 'Answer in the default dialog',
281  toDefault: 'Default dialog',
282  close: 'Close',
283  submit: 'Submit',
284  next: 'Next',
285  nextArrow: 'Next →',
286  hoverPreview: 'Hover an option to preview it',
287  answerLabel: unit => `Answer${unit}`,
288  numberPlaceholder: 'number',
289  otherPlaceholder: 'double-click to type',
290  needNumber: 'Enter a number',
291  atLeast: min => `Must be at least ${min}`,
292  atMost: max => `Must be at most ${max}`,
293  moreQuestions: (first, more) => `${first} (+${more} more)`,
294  askTitle: 'Claude question',
295}
296
297export const T: Strings = { ...en }
298let current: Lang = 'en'
299
300export function setLang(lang: Lang): boolean {
301  if (lang === current) return false
302  current = lang
303  Object.assign(T, lang === 'ko' ? ko : en)
304  return true
305}
306
307export function langSetting(options: Record<string, unknown>): LangSetting {
308  return options.language === 'en' || options.language === 'ko' ? options.language : 'auto'
309}
310
311// Claude Code /config 의 language 값(자유 글: "Korean", "한국어", "ko" …). 모르면 영어.
312export function langFromConfig(value: unknown): Lang {
313  return typeof value === 'string' && /korean|한국|^ko(\b|[-_])/i.test(value.trim()) ? 'ko' : 'en'
314}
315
316// 범위 이름은 두 언어 모두 받는다(/desk-diff 브랜치 전체 등).
317export function allScopeLabels(): Array<[string, string]> {
318  return [
319    [ko.scopeUncommitted, 'uncommitted'],
320    [ko.scopeBranch, 'branch'],
321    [ko.scopeCommits, 'commits'],
322    [en.scopeUncommitted, 'uncommitted'],
323    [en.scopeBranch, 'branch'],
324    [en.scopeCommits, 'commits'],
325  ]
326}
327