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…

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.

| Area | Terminal default | desk-look |
|---|---|---|
| Your prompt | A 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 markdown | A markdown renderer in the desktop prose style |
| Tables | Grid tables | Full-width tables with a gray header and row separators |
| mermaid | Code block | Box diagrams (lovely-mermaid) with arrowheads that touch the boxes |
> [!NOTE] alerts | Blockquote | A box with a border colored by alert type |
| Tool calls | One row per call + a result block | Consecutive 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) |
| Subagents | One Ran an agent line; notifications and messages only expand with ctrl+o | A 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 turn | A duration line | A 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 tools | A 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 |
| Tasks | Tool lines | A 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 blocks | No way to copy | Hover to see ⧉ copy at the right of the top border; click to copy the same way /copy does |
| Questions (AskUserQuestion) | A list under the prompt | A 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 output | Plain text | Output with tables, headings, code or quotes renders as markdown, like an answer |
| Mode indicator | Dim text at the bottom right of the prompt | A pill chip (with the omarchy theme) |
| Hint line | ? for shortcuts | While 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-context | The /context grid | A 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).
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.
Search for desk-look in /config to find these options.
| Option | Values | Description |
|---|---|---|
language | auto (default), en, ko | Language 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 |
bubbleSide | left (default), right | Where 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 |
askNotify | on (default), off | Notify 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+) |
prBar | on (default), off | The 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 |
contextChip | auto (default), always, off | The 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 |
claude plugin update desk-look
Then run /reload-plugins in your session.
Click a row in the /desk-sessions pane, or give a number as in /desk-sessions 3:
/resume <id>). If running it is blocked, the command is placed in the prompt; press Enter.cd <folder> && claude --resume <id> to the clipboard (wl-copy).text, inactive, claude …), so both light and dark themes are followed.~/.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.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.
desk-look/hooks/vendor/lovely-mermaid.js: a bundle of lovely-mermaid, Apache-2.0 (LICENSE.lovely-mermaid)hooks/register.tsx 2279 lines1import { 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 lines1import 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}
48hooks/markdown.tsx 486 lines1import 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}
486hooks/runs.ts 169 lines1import 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}
169hooks/sessions.ts 80 lines1import 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}
80hooks/ask.tsx 142 lines1import { 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
142hooks/tasks.ts 93 lines1import 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}
93hooks/theme.ts 39 lines1import 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}
39hooks/usage.ts 76 lines1import 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}
76hooks/pr.ts 359 lines1import 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}
359hooks/gitview.ts 47 lines1import 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}
47hooks/i18n.ts 327 lines1// 화면 문구 사전: 영어(기본)와 한국어. 설정 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