Claude Code mod that opens your Asana tasks in a side pane (/desk-asana): the tasks assigned to you, a search across the workspace and your projects' tasks by…

English | 한국어
desk-asana is a Claude Code plugin (mod) that opens your Asana tasks in a side pane next to the conversation. It comes from the same marketplace as desk-look, which redraws the Claude Code terminal to look like the Claude desktop app. It works on its own; with desk-look, /desk-fold folds its pane too and it follows desk-look's language.
At the prompt of a Claude Code terminal session:
/plugin install desk-asana --marketplace KyongSik-Yoon/cc-desktop-mod
It follows the latest Claude Code release, like desk-look. /desk-update from desk-look updates it too; without desk-look, run claude plugin update desk-asana.
/desk-asana: Asana tasksYour open Asana tasks, read through the Asana MCP server Claude Code already has connected (no token or setting of its own). It uses Asana's official V2 server (https://mcp.asana.com/v2/mcp, the one with get_my_tasks) when one is connected, otherwise the claude.ai Asana connector; on the connector, listing your tasks needs a Premium workspace (Asana's task search). With neither, the pane says to connect Asana in /mcp.
Done in the last 14 days shows the tasks completed in the last 14 days, dimmed, with their status (● On hold). Depending on the task type, statuses such as on hold or waiting for release count as complete in Asana, so a task moved to one of them can still be opened from the pane● My tasks ○ Projects tabs at the top switch views. Projects lists the projects you are a member of, most recently changed first (member count, and open task count on the V2 server). Click one for its open tasks, whoever they are assigned to, grouped by section with the assignee and due date (sections with no open task are left out), and tasks completed in the last 14 days in a Done in the last 14 days group at the end; click a section row to fold it, or Collapse all (z) to fold or unfold every section. Up to 300 open tasks are read per project. ← Projects (b), at the top and again at the bottom, goes back to the listSearch box above it finds open tasks anywhere in the workspace you can see, by name, description or comment (the 50 most recently changed), shown the same way with each task's assignee; ✕ My tasks or an empty search goes back to your tasks. Search needs a Premium workspace (Asana's task search)● Open), projects and sections, assignee, due date, custom fields, the description with its formatting as on the web (headings, bold, lists, inline code, code blocks, links and task mentions) and the latest 30 comments, formatted the same way with mentions by name (on the claude.ai connector, the comments among the task's first 100 activity entries). ↻ (r in every view) reloads, ← Tasks (b, also at the bottom) goes back to the list, the search or the project@ Mention below it finds people by name or email; pressing one puts @Name in the comment, and it is sent as an Asana @-mention (which notifies them) as long as the @Name is still in the text when you send✎ Edit next to one of your own comments edits it in place (Enter saves). The Asana MCP servers have no tool for editing a comment, so this one call goes to Asana's REST API with a personal access token you put in yourself: create one at https://app.asana.com/0/my-apps, put it in Asana personal access token under /plugin configure desk-asana@cc-desktop-mod (kept in secure storage) and choose Save configuration, which reloads the plugin. Without a token, ✎ Edit explains this and calls nothing. Only the characters you change are written back into the comment's original rich text, so mentions, links, bold, lists, code blocks and images you did not touch stay exactly as they were. List marks (• , 1. ), images ([image]) and the letters of a mention or link are shown but cannot be changed here (a whole mention can be deleted); an edit that touches them is refused and kept in the box. Only comments the token's owner wrote can be editedChange next to the assignee finds people by name or email (or Me); handing the task to someone takes a second press within 5 seconds› next to a dropdown custom field lists its options; press one to set itHand to Claude fills the prompt with the task's title, link, status, description and recent commentsChange next to the task type's status (Open, In Progress…) lists the type's statuses; picking one changes it. The Asana MCP servers drop this key, so like ✎ Edit it goes to Asana's REST API only when you have put in a personal access token; without one, Change explains how and calls nothingpermissions.allow in ~/.claude/settings.json; while some are missing, the pane lists them. For the V2 server: "mcp__asana-v2__get_my_tasks", "mcp__asana-v2__search_tasks", "mcp__asana-v2__get_task",
"mcp__asana-v2__get_task_stories", "mcp__asana-v2__add_comment", "mcp__asana-v2__update_tasks", "mcp__asana-v2__search_objects",
"mcp__asana-v2__get_me", "mcp__asana-v2__get_projects", "mcp__asana-v2__get_tasks"
The rule allows the same tools for Claude too, including adding comments and changing tasks
desk-asana collects no data and runs no programs. Everything it reads stays on your machine, except what is listed under "Sends".
Reads: the Asana personal access token you put in its settings (asanaToken, used only as described under "Sends"), Claude Code's language setting, and desk-look's language and /desk-fold state when desk-look is installed.
Sends: only through the Asana MCP server Claude Code already has connected (with Claude Code's own connection and login), only when you open /desk-asana or press something in it: it reads your tasks, open tasks matching text you search for, the workspace's projects and the open tasks of a project you open, a task's details and comments, and people matching a name you search for, and adds a comment, changes the assignee or sets a dropdown field when you submit or press those. With an Asana personal access token in the plugin settings, two actions call Asana's REST API (https://app.asana.com/api/1.0) directly through $.http.fetch, the token only in the Authorization header: ✎ Edit on one of your comments reads that comment's rich text and your Asana user ID, and saving writes the edited text back; picking a task type's status sets it on the task. Without a token these actions call nothing. The MCP calls go through Claude Code's permission check like any other; desk-asana only asks, with $.tool.check, which of the pane's Asana tools have no allow rule, to list them in the pane.
Every hook below passes the event on with next(e) and only watches the result, except where it says otherwise.
ui.render: draws the /desk-asana panesession.start, session.end: register /desk-asana and tell desk-look the plugin is loaded (again after /resume or /clear in the same window)state.set on desk-look's lang and folded: after desk-look's own write goes through, follow its language and fold or unfold this plugin's pane with /desk-foldcommand.run: handles /desk-asana onlyhooks/shared/ is a copy of desk-look's Markdown renderer, used to draw task descriptions and comments; hooks/shared/vendor/lovely-mermaid.js is lovely-mermaid 0.3.3 (Apache-2.0, hooks/shared/vendor/LICENSE.lovely-mermaid), bundled without minification. It runs no programs and makes no requests.
Search for desk-asana in /config to find these options.
language: auto (default), en, ko. Language of the plugin's own labels. auto follows desk-look's language, or without desk-look Claude Code's language in /config: Korean when it names Korean, English otherwiseasanaToken: empty (default). An Asana personal access token, optional. Only ✎ Edit on your Asana comments and changing a task type's status use it (see /desk-asana); set it with /plugin configure desk-asana@cc-desktop-modasanaKeyField: empty (default). Optional. The name of the Asana custom field that holds each task's key, as shown next to its value in a task's details (JKD for a field showing JKD-1630; not the field's ID, any case). With it, the list shows the key dimmed after each task's title, typing a key (JKD-1630) in the search box finds that open task first, and Hand to Claude includes the key. Several names separated by commas (JKD, J5S) use the first one a task has a value for. Empty shows titles onlyMIT (LICENSE)
hooks/register.tsx 1025 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AsanaDetail, AsanaEdit, AsanaProject, AsanaProjects, AsanaProjectView, AsanaSearch, AsanaServer, AsanaState, AsanaTab, AsanaTask, AsanaUser, AsanaView, FoldedPane } from '../types'
5import {
6 ASANA_API,
7 asanaPrompt,
8 keyFieldGids,
9 keyFieldNames,
10 keySearchCalls,
11 commentCall,
12 dueChip,
13 findAsanaServer,
14 groupTasks,
15 listCalls,
16 DONE_DAYS,
17 doneSince,
18 localDay,
19 meCall,
20 myGid,
21 nextOffset,
22 paneTools,
23 projectsCall,
24 projectTasksCall,
25 resultData,
26 searchCall,
27 storiesCall,
28 storyPatch,
29 taskCall,
30 toDetail,
31 toProjects,
32 toStory,
33 toTasks,
34 toUsers,
35 updateCall,
36 updateProblem,
37 usersCall,
38} from './asana'
39import type { AsanaCall } from './asana'
40import { T, langFromConfig, langSetting, setLang } from './i18n'
41import { renderMarkdown } from './shared/markdown'
42import { COLORS, ago, clip, fillPrompt, rowButton } from './util'
43
44const ASANA_PANE = 'desk-asana'
45// 댓글 입력칸이 자랄 수 있는 줄 수(desk-look 의 diff 줄 댓글 칸과 같다).
46const COMMENT_ROWS = 3
47
48// 내 태스크 목록, 펼친 태스크 하나(null 이면 목록), 담당자 바꾸기에서 찾은 사람들.
49// 쓰던 댓글·찾는 이름은 변수에 둔다(atom 이면 글자마다 패널 전체를 다시 그린다).
50const asana = atom({ plugin: 'desk-asana', key: 'asana' } as const, null as AsanaState | null)
51const asanaView = atom({ plugin: 'desk-asana', key: 'asanaView' } as const, null as AsanaView | null)
52const asanaUsers = atom({ plugin: 'desk-asana', key: 'asanaUsers' } as const, null as { query: string; status: 'loading' | 'ok' | 'failed'; users: AsanaUser[] } | null)
53// 목록 위 검색창: 찾은 결과(null 이면 내 태스크).
54const asanaSearch = atom({ plugin: 'desk-asana', key: 'asanaSearch' } as const, null as AsanaSearch | null)
55// 탭(내 태스크·프로젝트), 프로젝트 목록, 펼친 프로젝트(null 이면 프로젝트 목록). 태스크 상세는 두 탭이 함께 쓴다(asanaView).
56const asanaTab = atom({ plugin: 'desk-asana', key: 'asanaTab' } as const, 'mine' as AsanaTab)
57const asanaProjects = atom({ plugin: 'desk-asana', key: 'asanaProjects' } as const, null as AsanaProjects | null)
58const asanaProject = atom({ plugin: 'desk-asana', key: 'asanaProject' } as const, null as AsanaProjectView | null)
59// 댓글 고치기에서 고치는 중인 것.
60const asanaEdit = atom({ plugin: 'desk-asana', key: 'asanaEdit' } as const, null as AsanaEdit | null)
61// 펼친 묶음·담당자 확인처럼 켜고 끄는 것.
62const open = atom({ plugin: 'desk-asana', key: 'open' } as const, {} as Record<string, boolean>)
63// desk-look 이 읽는 값: 이 세션에 떠 있음(옮김 안내), /desk-fold 로 접어 둔 패널.
64const ready = atom({ plugin: 'desk-asana', key: 'ready' } as const, false)
65const folded = atom({ plugin: 'desk-asana', key: 'folded' } as const, null as FoldedPane[] | null)
66// 이 플러그인의 패널이 지금 열려 있는지(ui.open·ui.close 훅이 맞춘다). desk-look 의 /desk-fold 가 접을지 펼지 고른다.
67const shown = atom({ plugin: 'desk-asana', key: 'shown' } as const, false)
68let asanaDraft = ''
69let asanaQuery = ''
70// 댓글 칸의 키 차례: 보낸 뒤나 멘션을 끼운 뒤 새 value 로 다시 그린다. 끼운 사람은 보낼 때 "@이름" 이 남은 것만 멘션.
71let asanaWriteRound = 0
72let asanaMentions: AsanaUser[] = []
73// 댓글 고치기: 사람이 넣은 Asana 개인 토큰(userConfig asanaToken, 없으면 ''), 패널에서 고치는 중인 글,
74// 내 gid(MCP 로 읽어 ✎ 를 내 댓글에만 붙인다)와 토큰 주인 gid(고치기 전에 원문 작성자와 견준다).
75let asanaToken = ''
76// 태스크 키: 설정 asanaKeyField 의 필드 이름들(없으면 []), 목록에서 본 그 필드들의 gid(키로 찾기에 쓴다).
77let asanaKeyFields: string[] = []
78const asanaKeyGids = new Set<string>()
79let asanaEditDraft = ''
80// 고치는 칸의 키 차례: 칸은 Enter 뒤에 비므로, 거절·실패하면 고치던 글을 새 키로 다시 그린다.
81let asanaEditRound = 0
82let asanaMe: string | null = null
83let asanaTokenMe: string | null = null
84let asanaFindText = ''
85let asanaFindRound = 0
86
87// 처음 상태가 펼침인 행도 있어서, 지금 보이는 상태를 받아 뒤집는다.
88const toggle = ($: EngineInterface, id: string, shown?: boolean) =>
89 update($, open, state => ({ ...state, [id]: !(state[id] ?? shown ?? false) }))
90
91// /desk-asana 의 Asana 호출(asana.ts 참고). MCP 서버는 세션이 시작된 뒤에 붙으므로 열 때마다 도구 목록에서 찾는다.
92// 엔진이 거절하면(서버가 끊김, 권한) 던지는 대신 오류 글로 돌려준다.
93async function callAsana($: EngineInterface, server: AsanaServer, call: AsanaCall) {
94 try {
95 return resultData(await $.mcp.call(server.name, call.tool, call.args))
96 } catch (error) {
97 return { ok: false as const, error: (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '' }
98 }
99}
100
101// MCP 결과 크기 한도(엔진 MAX_MCP_OUTPUT_TOKENS, 플러그인은 못 바꾼다)에 걸린 실패. 응답 크기는 태스크 수보다
102// 프로젝트의 사용자 정의 필드 수를 따라 미리 알 수 없어, 걸리면 쪽을 반으로 줄여 다시 읽는다.
103const isTooLarge = (error: string) => error.includes('exceeds maximum allowed tokens')
104// 도구(프로젝트 태스크는 프로젝트)마다 줄여 둔 쪽 크기. 세션 동안 기억해 같은 실패와 엔진이 남기는 결과 파일을 되풀이하지 않는다.
105const asanaPageLimits = new Map<string, number>()
106const MIN_PAGE = 10
107const MAX_PAGE_CALLS = 12
108
109type AsanaPages = { ok: true; data: { data: unknown[] }; isPartial: boolean } | { ok: false; error: string }
110
111// 쪽을 이어 읽는다: max 개가 차거나 다음 쪽이 없을 때까지(호출은 MAX_PAGE_CALLS 번까지). 한도에 걸리면 같은 쪽을 반 크기로
112// 다시 읽는다. 검색은 다음 쪽이 없어 줄인 만큼 적게 보인다. onPage 는 쪽마다 지금까지 읽은 것으로.
113async function readPages($: EngineInterface, server: AsanaServer, call: AsanaCall, max: number, onPage?: (data: { data: unknown[] }, isLast: boolean, isPartial: boolean) => Promise<unknown>): Promise<AsanaPages> {
114 const key = `${call.tool}:${String(call.args.project ?? '')}`
115 let limit = Math.min(Number(call.args.limit), asanaPageLimits.get(key) ?? Infinity)
116 const items: unknown[] = []
117 let offset: string | null = null
118 for (let calls = 0; calls < MAX_PAGE_CALLS; calls++) {
119 const got = await callAsana($, server, { tool: call.tool, args: { ...call.args, limit, ...(offset === null ? {} : { offset }) } })
120 if (!got.ok && isTooLarge(got.error) && limit > MIN_PAGE) {
121 limit = Math.max(MIN_PAGE, Math.floor(limit / 2))
122 asanaPageLimits.set(key, limit)
123 continue
124 }
125 if (!got.ok) return got
126 items.push(...((got.data as { data?: unknown[] } | null)?.data ?? []))
127 offset = nextOffset(got.data)
128 const isLast = offset === null || items.length >= max || calls === MAX_PAGE_CALLS - 1
129 const data = { data: [...items] }
130 await onPage?.(data, isLast, offset !== null)
131 if (isLast) return { ok: true, data, isPartial: offset !== null }
132 }
133 return { ok: false, error: 'too many pages' }
134}
135
136async function findAsana($: EngineInterface): Promise<AsanaServer | null> {
137 const tools = await $.tool.list().catch(() => [])
138 return findAsanaServer(tools.filter(tool => tool.mcp).map(tool => tool.name))
139}
140
141// 패널 도구 중 allow 규칙이 없는 것. $.tool.check 는 규칙만 묻는다(창도 분류기도 없다).
142async function findUnruled($: EngineInterface, server: AsanaServer) {
143 const tools = paneTools(server)
144 const verdicts = await Promise.all(tools.map(tool => $.tool.check({ tool, input: {} }).catch(() => null)))
145 return tools.filter((_, index) => verdicts[index]?.decision !== 'allow')
146}
147
148let asanaFetch: Promise<void> | null = null
149
150// 목록을 다시 읽는다. 읽는 동안은 전에 읽은 목록을 그대로 보인다(↻ 가 … 로).
151async function refreshAsana($: EngineInterface) {
152 if (asanaFetch) return asanaFetch
153 asanaFetch = (async () => {
154 await update($, asana, (state): AsanaState => ({ status: 'loading', server: state?.server ?? null, tasks: state?.tasks ?? [], unruled: state?.unruled ?? [] }))
155 const server = await findAsana($)
156 if (server === null) {
157 await update($, asana, (): AsanaState => ({ status: 'none', server: null, tasks: [], unruled: [] }))
158 return
159 }
160 const since = doneSince(await $.clock.now())
161 const [unruled, ...gots] = await Promise.all([findUnruled($, server), ...listCalls(server, since, asanaKeyFields).map(call => readPages($, server, call, LIST_MAX))])
162 const failed = gots.find(got => !got.ok)
163 for (const got of gots) if (got.ok) keyFieldGids(got.data, asanaKeyFields).forEach(gid => asanaKeyGids.add(gid))
164 await update($, asana, (): AsanaState =>
165 failed && !failed.ok
166 ? { status: 'failed', server, tasks: [], unruled, error: failed.error }
167 : { status: 'ok', server, tasks: gots.flatMap(got => (got.ok ? toTasks(got.data, asanaKeyFields) : [])), unruled },
168 )
169 })().finally(() => {
170 asanaFetch = null
171 })
172 return asanaFetch
173}
174
175async function openAsana($: EngineInterface) {
176 const shown = refreshAsana($).catch(() => undefined)
177 await $.ui.open({ id: ASANA_PANE, title: 'Asana', focus: true })
178 await shown
179}
180
181// 읽는 한도: 프로젝트는 500개(쪽마다 MCP 호출 한 번), 한 프로젝트의 태스크와 내 태스크는 개수로(readPages).
182const PROJECT_PAGES = 5
183const PROJECT_TASK_MAX = 300
184const LIST_MAX = 100
185
186async function switchAsanaTab($: EngineInterface, tab: AsanaTab) {
187 await update($, asanaTab, () => tab)
188 const projects = await read($, asanaProjects)
189 if (tab === 'projects' && (projects === null || projects.status === 'failed')) await refreshAsanaProjects($)
190}
191
192let asanaProjectsFetch: Promise<void> | null = null
193
194// 나와 프로젝트 목록을 함께 읽는다. 서버는 내 태스크를 읽을 때 찾아 둔 것.
195async function refreshAsanaProjects($: EngineInterface) {
196 if (asanaProjectsFetch) return asanaProjectsFetch
197 asanaProjectsFetch = (async () => {
198 const server = (await read($, asana))?.server ?? null
199 if (server === null) return
200 await update($, asanaProjects, (state): AsanaProjects => ({ status: 'loading', projects: state?.projects ?? [] }))
201 const [me, first] = await Promise.all([callAsana($, server, meCall(server)), callAsana($, server, projectsCall(server))])
202 const pages = [first]
203 let offset = first.ok ? nextOffset(first.data) : null
204 while (offset !== null && pages.length < PROJECT_PAGES) {
205 const got = await callAsana($, server, projectsCall(server, offset))
206 pages.push(got)
207 offset = got.ok ? nextOffset(got.data) : null
208 }
209 const failed = [me, ...pages].find(got => !got.ok)
210 const gid = me.ok ? myGid(me.data) : null
211 await update($, asanaProjects, (): AsanaProjects =>
212 failed && !failed.ok
213 ? { status: 'failed', projects: [], error: failed.error }
214 : { status: 'ok', projects: toProjects({ data: pages.flatMap(got => (got.ok ? ((got.data as { data?: unknown[] }).data ?? []) : [])) }, gid) },
215 )
216 })().finally(() => {
217 asanaProjectsFetch = null
218 })
219 return asanaProjectsFetch
220}
221
222// 프로젝트 하나를 펼친다: 열린 태스크를 쪽마다 더해 그린다. 다른 프로젝트로 옮겼으면 늦게 온 쪽은 버린다.
223async function openAsanaProject($: EngineInterface, project: AsanaProject) {
224 const server = (await read($, asana))?.server ?? null
225 if (server === null) return
226 await update($, asanaProject, (view): AsanaProjectView => ({ project, status: 'loading', tasks: view?.project.gid === project.gid ? view.tasks : [], isPartial: false }))
227 const since = doneSince(await $.clock.now())
228 const got = await readPages($, server, projectTasksCall(server, project.gid, since, asanaKeyFields), PROJECT_TASK_MAX, (data, isLast, isPartial) => {
229 keyFieldGids(data, asanaKeyFields).forEach(gid => asanaKeyGids.add(gid))
230 const tasks = toTasks(data, asanaKeyFields, project.gid)
231 return update($, asanaProject, (view): AsanaProjectView | null =>
232 view?.project.gid !== project.gid ? view : { project, status: isLast ? 'ok' : 'loading', tasks, isPartial: isLast && isPartial },
233 )
234 })
235 if (!got.ok) await update($, asanaProject, (view): AsanaProjectView | null => (view?.project.gid !== project.gid ? view : { ...view, status: 'failed', error: got.error }))
236}
237
238async function closeAsanaProject($: EngineInterface) {
239 await update($, asanaProject, () => null)
240}
241
242// 태스크 하나를 펼친다(같은 태스크를 다시 읽는 동안은 전 내용을 그대로 보인다).
243async function openAsanaTask($: EngineInterface, server: AsanaServer, gid: string) {
244 await update($, asanaView, (view): AsanaView => ({ gid, status: 'loading', task: view?.gid === gid ? view.task : null }))
245 // 댓글 서식은 stories 에만 있다. v2 는 allow 규칙이 없으면 부르지 않고(기본 모드는 열 때마다 창) get_task 의 평문 댓글을 쓴다.
246 // v1 은 댓글이 stories 에만 있어 늘 부른다.
247 const storyCall = storiesCall(server, gid)
248 const canStory = server.dialect === 'v1' || !(await read($, asana))?.unruled.includes(`mcp__${server.name}__${storyCall.tool}`)
249 const [got, stories, me] = await Promise.all([
250 callAsana($, server, taskCall(server, gid)),
251 canStory ? callAsana($, server, storyCall) : null,
252 asanaMe === null ? callAsana($, server, meCall(server)) : null,
253 ])
254 if (me?.ok) asanaMe = myGid(me.data)
255 const task = got.ok ? toDetail(got.data, stories?.ok ? stories.data : undefined, asanaKeyFields) : null
256 const error = got.ok ? (task ? undefined : 'unexpected result') : got.error
257 await update($, asanaView, (view): AsanaView | null => (view?.gid !== gid ? view : task ? { gid, status: 'ok', task } : { gid, status: 'failed', task: null, error }))
258}
259
260async function closeAsanaTask($: EngineInterface) {
261 asanaDraft = ''
262 asanaQuery = ''
263 asanaMentions = []
264 await update($, asanaUsers, () => null)
265 await update($, asanaEdit, () => null)
266 await update($, open, state => ({ ...state, 'asana:assign': false, 'asana:mention': false, 'asana:status': false }))
267 await update($, asanaView, () => null)
268}
269
270// 빈 글자로 찾으면 검색을 끝내고 내 태스크로 돌아간다. 늦게 온 옛 검색 결과는 버린다.
271async function searchAsana($: EngineInterface, server: AsanaServer, value: string) {
272 const query = value.trim()
273 if (query === '') return clearAsanaSearch($)
274 asanaFindText = value
275 await update($, asanaSearch, (): AsanaSearch => ({ query, status: 'loading', tasks: [] }))
276 // 키로 찾은 것을 앞에. 키로 찾기가 실패해도 글자 검색 결과는 보인다.
277 const [got, byKey] = await Promise.all([
278 readPages($, server, searchCall(server, query, asanaKeyFields), 50),
279 Promise.all(keySearchCalls(server, query, asanaKeyFields, [...asanaKeyGids]).map(call => readPages($, server, call, 10))),
280 ])
281 // 내 목록에 키 필드가 없던 사람도 검색에 걸린 태스크에서 gid 를 배워 다음 키 검색부터 찾는다.
282 if (got.ok) keyFieldGids(got.data, asanaKeyFields).forEach(gid => asanaKeyGids.add(gid))
283 const keyed = byKey.flatMap(found => (found.ok ? toTasks(found.data, asanaKeyFields) : []))
284 const texted = got.ok ? toTasks(got.data, asanaKeyFields) : []
285 const tasks = [...keyed, ...texted].filter((task, index, all) => all.findIndex(other => other.gid === task.gid) === index)
286 await update($, asanaSearch, (search): AsanaSearch | null =>
287 search?.query !== query ? search : got.ok || keyed.length > 0 ? { query, status: 'ok', tasks } : { query, status: 'failed', tasks: [], error: got.ok ? '' : got.error },
288 )
289}
290
291async function clearAsanaSearch($: EngineInterface) {
292 asanaFindText = ''
293 asanaFindRound++
294 await update($, asanaSearch, () => null)
295}
296
297async function postAsanaComment($: EngineInterface, server: AsanaServer, gid: string, value: string) {
298 const text = value.trim()
299 if (text === '') return
300 const got = await callAsana($, server, commentCall(server, gid, text, asanaMentions))
301 if (!got.ok) {
302 // 칸은 Enter 뒤에 비므로 쓰던 글을 새 키로 다시 그린다.
303 $.ui.toast(T.asanaCommentFailed(got.error))
304 asanaDraft = value
305 asanaWriteRound++
306 await update($, asanaView, view => (view ? { ...view } : view))
307 await $.ui.focus({ requestId: ASANA_PANE, key: `asana-write-${gid}-${asanaWriteRound}` }).catch(() => undefined)
308 return
309 }
310 asanaDraft = ''
311 asanaMentions = []
312 asanaWriteRound++
313 $.ui.toast(T.asanaCommented)
314 await openAsanaTask($, server, gid)
315}
316
317// 지우기: 쓴 글과 끼운 멘션을 비운 칸을 새로 그려 그리로 돌아간다.
318async function clearAsanaDraft($: EngineInterface, gid: string) {
319 asanaDraft = ''
320 asanaMentions = []
321 asanaWriteRound++
322 await update($, asanaView, view => (view ? { ...view } : view))
323 await $.ui.open({ id: ASANA_PANE, title: 'Asana', focus: true })
324 await $.ui.focus({ requestId: ASANA_PANE, key: `asana-write-${gid}-${asanaWriteRound}` }).catch(() => undefined)
325}
326
327// Asana REST(댓글 고치기·상태 바꾸기만). 토큰은 헤더로만 보내고 오류 글에도 싣지 않는다. 엔진이 거절해도 오류 글로.
328async function asanaRest($: EngineInterface, path: string, init: { method?: string; body?: unknown } = {}): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> {
329 const headers = { Authorization: `Bearer ${asanaToken}`, Accept: 'application/json', ...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }) }
330 const request = { method: init.method ?? 'GET', headers, ...(init.body === undefined ? {} : { body: JSON.stringify({ data: init.body }) }) }
331 try {
332 const res = await $.http.fetch(`${ASANA_API}${path}`, request)
333 if (res.status === 401) return { ok: false, error: T.asanaTokenRejected }
334 const json = JSON.parse(res.text || 'null') as { errors?: Array<{ message?: string }> } | null
335 if (!res.ok) return { ok: false, error: json?.errors?.[0]?.message ?? `HTTP ${res.status}` }
336 return { ok: true, data: json }
337 } catch (error) {
338 return { ok: false, error: error instanceof Error ? error.message : String(error) }
339 }
340}
341
342// ✎ 를 누르면: 토큰이 없으면 넣는 법을 펼치고, 있으면 REST 로 원문(html_text)과 토큰 주인을 읽어 고치는 칸을 연다.
343// 다시 누르거나 취소하면 닫는다. 토큰 주인이 쓴 댓글이 아니면 열지 않는다(Asana 는 남의 댓글도 is_editable 로 줄 때가 있다).
344async function startAsanaEdit($: EngineInterface, comment: string) {
345 const shown = await read($, asanaEdit)
346 if (shown?.comment === comment) return update($, asanaEdit, () => null)
347 if (asanaToken === '') return update($, asanaEdit, (): AsanaEdit => ({ comment, status: 'notoken', story: null }))
348 await update($, asanaEdit, (): AsanaEdit => ({ comment, status: 'loading', story: null }))
349 const [got, me] = await Promise.all([
350 asanaRest($, `/stories/${comment}?opt_fields=html_text,created_by.gid`),
351 asanaTokenMe === null ? asanaRest($, '/users/me?opt_fields=gid') : null,
352 ])
353 if (me?.ok) asanaTokenMe = myGid(me.data)
354 const story = got.ok ? toStory(got.data) : null
355 const problem =
356 !got.ok ? got.error : me && !me.ok ? me.error : story === null ? 'empty result' : story.author !== asanaTokenMe ? T.asanaEditNotMine : story.draft === null ? T.asanaEditFormatted : null
357 if (problem !== null || story === null || story.draft === null) {
358 $.ui.toast(T.asanaEditFailed(problem ?? ''))
359 return update($, asanaEdit, () => null)
360 }
361 asanaEditDraft = story.draft
362 asanaEditRound = 0
363 await update($, asanaEdit, (state): AsanaEdit | null => (state?.comment !== comment ? state : { comment, status: 'ready', story: { text: story.draft as string, html: story.html } }))
364 await $.ui.open({ id: ASANA_PANE, title: 'Asana', focus: true })
365 await $.ui.focus({ requestId: ASANA_PANE, key: `asana-editing-${comment}-${asanaEditRound}` }).catch(() => undefined)
366}
367
368async function saveAsanaEdit($: EngineInterface, server: AsanaServer, task: string, value: string) {
369 const state = await read($, asanaEdit)
370 if (state?.status !== 'ready' || state.story === null || value.trim() === '') return
371 // 바뀐 글자만 원문 html_text 에 끼운다. 목록 기호·이미지·멘션 글자를 건드렸으면 고치던 글을 두고 알린다.
372 const patch = storyPatch(state.story.html, value)
373 if (patch === null) return update($, asanaEdit, () => null)
374 if ('error' in patch) {
375 $.ui.toast(T.asanaEditFailed(T.asanaEditFormatted))
376 return keepAsanaEdit($, state.comment, value)
377 }
378 await update($, asanaEdit, (shown): AsanaEdit | null => (shown?.comment !== state.comment ? shown : { ...shown, status: 'saving' }))
379 const got = await asanaRest($, `/stories/${state.comment}?opt_fields=text`, { method: 'PUT', body: { html_text: patch.html } })
380 if (!got.ok) {
381 $.ui.toast(T.asanaEditFailed(got.error))
382 return keepAsanaEdit($, state.comment, value)
383 }
384 asanaEditDraft = ''
385 $.ui.toast(T.asanaEdited)
386 await update($, asanaEdit, () => null)
387 await openAsanaTask($, server, task)
388}
389
390// 거절·실패한 글을 다시 그리거나(Enter 뒤라 칸이 키를 갖고 있다) 지우기로 비운 칸을 그린다(버튼을 눌렀으니 패널을 다시 연다).
391async function keepAsanaEdit($: EngineInterface, comment: string, value: string, isPressed = false) {
392 asanaEditDraft = value
393 asanaEditRound++
394 await update($, asanaEdit, (shown): AsanaEdit | null => (shown?.comment !== comment ? shown : { ...shown, status: 'ready' }))
395 if (isPressed) await $.ui.open({ id: ASANA_PANE, title: 'Asana', focus: true })
396 await $.ui.focus({ requestId: ASANA_PANE, key: `asana-editing-${comment}-${asanaEditRound}` }).catch(() => undefined)
397}
398
399// 작업 유형 상태 바꾸기: MCP update_tasks 는 custom_type_status_option 을 조용히 버려, 개인 토큰이 있을 때만 REST 로 바꾼다.
400// 고른 상태의 완료 여부(completion_state)는 Asana 가 태스크 완료에 함께 반영한다.
401async function changeAsanaStatus($: EngineInterface, server: AsanaServer, task: string, option: string) {
402 const got = await asanaRest($, `/tasks/${task}?opt_fields=gid`, { method: 'PUT', body: { custom_type_status_option: option } })
403 if (!got.ok) return $.ui.toast(T.asanaUpdateFailed(got.error))
404 await update($, open, state => ({ ...state, 'asana:status': false }))
405 void refreshAsana($).catch(() => undefined)
406 const shown = await read($, asanaProject)
407 if (shown) void openAsanaProject($, shown.project).catch(() => undefined)
408 await openAsanaTask($, server, task)
409}
410
411// 사람 찾기 칸은 담당자 바꾸기와 멘션이 함께 쓴다. 한쪽 결과를 다른 쪽에서 누르지 않게 하나만 열고 결과를 비운다.
412async function toggleAsanaFinder($: EngineInterface, mode: 'assign' | 'mention') {
413 const other = mode === 'assign' ? 'asana:mention' : 'asana:assign'
414 asanaQuery = ''
415 await update($, asanaUsers, () => null)
416 const isOpening = !((await read($, open))[`asana:${mode}`] ?? false)
417 await update($, open, state => ({ ...state, [`asana:${mode}`]: isOpening, [other]: false }))
418 // 댓글 칸에 고리가 있으면 autoFocus 가 듣지 않아(사람이 옮긴 고리는 그대로) 연 찾기 칸으로 직접 옮긴다.
419 if (!isOpening) return
420 await $.ui.open({ id: ASANA_PANE, title: 'Asana', focus: true })
421 await $.ui.focus({ requestId: ASANA_PANE, key: mode === 'assign' ? 'asana-user-search' : 'asana-mention-search' }).catch(() => undefined)
422}
423
424// 댓글 칸에 "@이름 " 을 끼우고(비었으면 그것만, 쓰던 글이 있으면 뒤에) 다시 그 칸으로 커서를 돌린다.
425async function pickMention($: EngineInterface, gid: string, user: AsanaUser) {
426 if (!asanaMentions.some(item => item.gid === user.gid)) asanaMentions = [...asanaMentions, user]
427 const gap = asanaDraft === '' || /\s$/.test(asanaDraft) ? '' : ' '
428 asanaDraft = `${asanaDraft}${gap}@${user.name} `
429 asanaWriteRound++
430 asanaQuery = ''
431 await update($, asanaUsers, () => null)
432 await update($, open, state => ({ ...state, 'asana:mention': false }))
433 await $.ui.open({ id: ASANA_PANE, title: 'Asana', focus: true })
434 await $.ui.focus({ requestId: ASANA_PANE, key: `asana-write-${gid}-${asanaWriteRound}` }).catch(() => undefined)
435}
436
437async function findAsanaUsers($: EngineInterface, server: AsanaServer, value: string) {
438 const query = value.trim()
439 asanaQuery = value
440 if (query === '') return
441 await update($, asanaUsers, () => ({ query, status: 'loading' as const, users: [] }))
442 const got = await callAsana($, server, usersCall(server, query))
443 await update($, asanaUsers, () => (got.ok ? { query, status: 'ok' as const, users: toUsers(got.data) } : { query, status: 'failed' as const, users: [] }))
444}
445
446// 담당자·드롭다운 필드 바꾸기. 담당자는 다른 사람에게 넘기면 내 목록에서 빠지므로 머지처럼 두 번 눌러야 한다.
447const ASSIGN_ARM_MS = 5000
448
449async function changeAsana($: EngineInterface, server: AsanaServer, task: AsanaDetail, change: { assignee: string; name: string } | { field: string; option: string }) {
450 const got = await callAsana($, server, updateCall(server, task.gid, 'field' in change ? change : { assignee: change.assignee }))
451 const problem = got.ok ? updateProblem(server, got.data) : got.error
452 if (problem !== null) {
453 $.ui.toast(T.asanaUpdateFailed(problem))
454 return
455 }
456 if ('assignee' in change) {
457 $.ui.toast(T.asanaAssigned(change.name))
458 asanaQuery = ''
459 await update($, asanaUsers, () => null)
460 await update($, open, state => ({ ...state, 'asana:assign': false }))
461 void refreshAsana($).catch(() => undefined)
462 const shown = await read($, asanaProject)
463 if (shown) void openAsanaProject($, shown.project).catch(() => undefined)
464 } else {
465 await update($, open, state => ({ ...state, [`asana:field:${change.field}`]: false }))
466 }
467 await openAsanaTask($, server, task.gid)
468}
469
470async function armAssign($: EngineInterface, server: AsanaServer, task: AsanaDetail, user: { gid: string; name: string }, isArmed: boolean) {
471 const key = `asana:arm:${user.gid}`
472 if (!isArmed) {
473 await update($, open, state => ({ ...state, [key]: true }))
474 $.clock.after(ASSIGN_ARM_MS, () => void update($, open, state => ({ ...state, [key]: false })))
475 return
476 }
477 await update($, open, state => ({ ...state, [key]: false }))
478 await changeAsana($, server, task, { assignee: user.gid, name: user.name })
479}
480
481// 세션마다 비워지는 상태를 다시 채운다: 세션이 시작될 때와 창 안에서 다른 세션으로 넘어간 뒤(/resume·/clear).
482function refreshSessionState($: EngineInterface) {
483 void update($, ready, () => true).catch(() => undefined)
484}
485
486// desk-look 의 /desk-fold 를 따라 접고 편다: desk-look 이 folded 에 목록을 쓰면(접을 desk-look 패널이 없으면 빈 목록)
487// 이 플러그인의 패널도 접고, null 을 쓰면 접어 둔 것을 다시 연다. 엔진은 다른 플러그인의 패널을 닫게 하지 않는다.
488async function followFold($: EngineInterface, isFolding: boolean) {
489 if (isFolding) {
490 const panes = (await $.ui.panes()).filter(pane => pane.isPlaced)
491 if (panes.length === 0) return
492 // 이미 접어 둔 것이 있으면 새로 접는 것을 더한다(desk-look 의 toggleFold 와 같다).
493 const saved = await read($, folded)
494 const fresh = panes.map(pane => ({ id: pane.id, title: pane.title, isShown: pane.isShown, offset: 0 }))
495 await update($, folded, () => [...(saved ?? []).filter(pane => !fresh.some(item => item.id === pane.id)), ...fresh])
496 for (const pane of panes) await $.ui.close({ id: pane.id }).catch(() => undefined)
497 return
498 }
499 const saved = await read($, folded)
500 await update($, folded, () => null)
501 for (const pane of saved ?? []) await $.ui.open({ id: pane.id, title: pane.title, ...(pane.isShown ? { focus: true as const } : {}) })
502}
503
504// 설정 language 가 auto 일 때: desk-look 이 있으면 desk-look 이 고른 언어, 없으면 Claude Code /config 의 language.
505async function pickLang($: EngineInterface) {
506 const { value } = await $.state.get({ plugin: 'desk-look', key: 'lang' })
507 if (value === 'en' || value === 'ko') return setLang(value)
508 const row = (await $.config.list().catch(() => [])).find(item => item.key === 'language')
509 return setLang(langFromConfig(row?.value))
510}
511
512export const register: Register = (on, options) => {
513 const language = langSetting(options)
514 setLang(language === 'auto' ? 'en' : language)
515 asanaToken = typeof options.asanaToken === 'string' ? options.asanaToken.trim() : ''
516 asanaKeyFields = keyFieldNames(options.asanaKeyField)
517 asanaKeyGids.clear()
518 asanaMe = null
519 asanaTokenMe = null
520
521 on('session.start', async ($, e, next) => {
522 if (language === 'auto' && (await pickLang($).catch(() => false))) $.ui.invalidate('ui.render')
523 // 패널만 여는 명령은 턴 중에도 바로 연다(immediate).
524 await $.command.register({ name: 'desk-asana', description: T.cmdAsana, immediate: true })
525 refreshSessionState($)
526 return next(e)
527 })
528
529 // 창 안의 /resume·/clear 는 상태(atom)를 비우고 session.start 없이 이어 간다. 넘어간 뒤 다시 채운다.
530 on('session.end', async ($, e, next) => {
531 const done = await next(e)
532 if (e.reason === 'resume' || e.reason === 'clear') $.clock.after(300, () => refreshSessionState($))
533 return done
534 })
535
536 on('state.set', { plugin: 'desk-look', key: 'lang' }, async ($, e, next) => {
537 const done = await next(e)
538 if (language === 'auto' && (e.value === 'en' || e.value === 'ko') && setLang(e.value)) $.ui.invalidate('ui.render')
539 return done
540 }).catch(($, e, next) => next(e))
541
542 on('state.set', { plugin: 'desk-look', key: 'folded' }, async ($, e, next) => {
543 const done = await next(e)
544 await followFold($, e.value !== null).catch(() => undefined)
545 return done
546 }).catch(($, e, next) => next(e))
547
548 // 패널이 열려 있는지 desk-look 에 알린다. 사람이 ✕ 로 닫아도, 플러그인을 내려도 ui.close 가 온다.
549 on('ui.open', { id: ASANA_PANE }, async ($, e, next) => {
550 const opened = await next(e)
551 if (opened.value?.isPlaced) await update($, shown, () => true).catch(() => undefined)
552 return opened
553 })
554
555 on('ui.close', { id: ASANA_PANE }, async ($, e, next) => {
556 const done = await next(e)
557 await update($, shown, () => false).catch(() => undefined)
558 return done
559 })
560
561 on('command.run', { command: 'desk-asana' }, async $ => {
562 await openAsana($)
563 const state = await read($, asana)
564 return { text: state?.status === 'none' ? T.asanaNoServer : T.asanaOpened }
565 })
566
567 // Asana 패널: 내 태스크를 프로젝트별로, 하나를 누르면 상세(상태·담당자·드롭다운 필드·설명·댓글)와 댓글 달기.
568 on('ui.render', { component: 'Pane', requestId: ASANA_PANE }, async ($, e, next) => {
569 if (e.surface !== 'terminal') return next(e)
570 const els = $.ui.resolve(e)
571 const { Box, Text, Button, Input, Link } = els
572 const [state, view, armed, found, search, tab, projects, project, editing, now] = await Promise.all([
573 read($, asana),
574 read($, asanaView),
575 read($, open),
576 read($, asanaUsers),
577 read($, asanaSearch),
578 read($, asanaTab),
579 read($, asanaProjects),
580 read($, asanaProject),
581 read($, asanaEdit),
582 $.clock.now(),
583 ])
584 const columns = Math.max(24, e.props.bodyColumns)
585 const server = state?.server ?? null
586 const today = localDay(now)
587 const due = (dueOn: string | null) => {
588 const chip = dueChip(dueOn, today)
589 return chip && <Text color={COLORS[chip.level]}>{` ${chip.text}`}</Text>
590 }
591
592 if (view !== null && server !== null) {
593 const task = view.task
594 const head = (
595 <Box columnGap={1}>
596 <Button key="asana-back" plain dimColor hotkey="b" label={T.asanaBack} onPress={() => void closeAsanaTask($)} />
597 <Button key="asana-reload" plain dimColor hotkey="r" label={view.status === 'loading' ? '…' : '↻'} onPress={() => void openAsanaTask($, server, view.gid)} />
598 </Box>
599 )
600 if (task === null) {
601 return (
602 <Box flexDirection="column" rowGap={1}>
603 {head}
604 <Text color={COLORS.muted}>{view.status === 'failed' ? T.asanaFailed(view.error ?? '') : T.asanaLoading}</Text>
605 </Box>
606 )
607 }
608 const isAssigning = armed['asana:assign'] ?? false
609 const isMentioning = armed['asana:mention'] ?? false
610 // 담당자 바꾸기와 멘션이 함께 쓰는 사람 찾기 칸. 행은 모드마다 키가 달라 누른 쪽 동작만 한다.
611 const finder = (key: string, rows: Array<ReturnType<typeof rowButton>>) => (
612 <Box flexDirection="column" paddingLeft={2}>
613 <Input
614 key={key}
615 label={T.asanaFindLabel}
616 placeholder={T.asanaFindPlaceholder}
617 value={asanaQuery}
618 submitLabel={T.asanaFind}
619 autoFocus
620 onInput={value => (asanaQuery = value)}
621 onSubmit={value => void findAsanaUsers($, server, value)}
622 />
623 {rows}
624 {found?.status === 'loading' && <Text color={COLORS.muted}>{T.asanaFinding}</Text>}
625 {found?.status === 'failed' && <Text color={COLORS.danger}>{T.asanaFindFailed}</Text>}
626 {found?.status === 'ok' && found.users.length === 0 && <Text color={COLORS.muted}>{T.asanaNoPeople}</Text>}
627 </Box>
628 )
629 const mentionRow = (user: AsanaUser) =>
630 rowButton(
631 els,
632 { key: `asana-mention-${user.gid}`, dimColor: true, onPress: () => void pickMention($, task.gid, user) },
633 `@${user.name}`,
634 user.email ? <Text color={COLORS.muted}>{` ${user.email}`}</Text> : null,
635 )
636 const userRow = (user: { gid: string; name: string; email?: string | null }) => {
637 const isArmed = armed[`asana:arm:${user.gid}`] ?? false
638 return rowButton(
639 els,
640 { key: `asana-user-${user.gid}`, dimColor: !isArmed, onPress: () => void armAssign($, server, task, user, isArmed) },
641 isArmed ? T.asanaAssignConfirm(user.name) : user.name,
642 !isArmed && user.email ? <Text color={COLORS.muted}>{` ${user.email}`}</Text> : null,
643 )
644 }
645 const fields = task.fields.filter(field => field.value !== null || field.options !== null)
646 const canPickStatus = task.status !== null && task.statuses.length > 0
647 const isStatusPicking = armed['asana:status'] ?? false
648 return (
649 <Box flexDirection="column" rowGap={1}>
650 {head}
651 <Box flexDirection="column">
652 <Text color={COLORS.text} weight="semibold">
653 {task.name}
654 </Text>
655 <Box columnGap={1}>
656 {task.status !== null ? (
657 <Text color={task.completed ? COLORS.success : COLORS.accent}>{`● ${task.status}`}</Text>
658 ) : (
659 task.completed && <Text color={COLORS.success}>{T.asanaDone}</Text>
660 )}
661 {canPickStatus && <Button key="asana-status" plain dimColor label={isStatusPicking ? T.cancel : T.asanaChange} onPress={() => void toggle($, 'asana:status')} />}
662 {!canPickStatus && task.places.length > 0 && (
663 <Text color={COLORS.muted} wrap="truncate-end">
664 {task.places.join(' · ')}
665 </Text>
666 )}
667 </Box>
668 {isStatusPicking &&
669 (asanaToken === '' ? (
670 <Text color={COLORS.warning}>{T.asanaNeedToken(T.asanaStatusWhat)}</Text>
671 ) : (
672 <Box paddingLeft={2} columnGap={2} flexWrap="wrap">
673 {task.statuses.map(option => (
674 <Button
675 key={`asana-status-${option.gid}`}
676 plain
677 dimColor={option.name !== task.status}
678 label={option.name === task.status ? `✓ ${option.name}` : option.name}
679 onPress={() => void changeAsanaStatus($, server, task.gid, option.gid)}
680 />
681 ))}
682 </Box>
683 ))}
684 {/* 상태 옆 바꾸기 버튼과 장소의 › 가 한 줄에 섞이지 않게 장소를 다음 줄로 내린다. */}
685 {canPickStatus && task.places.length > 0 && (
686 <Text color={COLORS.muted} wrap="truncate-end">
687 {task.places.join(' · ')}
688 </Text>
689 )}
690 <Box columnGap={2}>
691 <Button key="asana-claude" plain label={T.handToClaude} onPress={() => void fillPrompt($, asanaPrompt(task))} />
692 {task.url && <Link href={task.url}>{T.asanaOpenOn}</Link>}
693 </Box>
694 </Box>
695 <Box flexDirection="column">
696 <Box columnGap={1}>
697 <Text color={COLORS.muted}>{T.asanaAssignee}</Text>
698 <Text color={task.assignee ? COLORS.text : COLORS.muted}>{task.assignee?.name ?? T.asanaNobody}</Text>
699 <Button key="asana-assign" plain dimColor label={isAssigning ? T.cancel : T.asanaChange} onPress={() => void toggleAsanaFinder($, 'assign')} />
700 </Box>
701 {isAssigning && finder('asana-user-search', [userRow({ gid: 'me', name: T.asanaMe }), ...(found?.status === 'ok' ? found.users.map(userRow) : [])])}
702 {task.dueOn !== null && (
703 <Box columnGap={1}>
704 <Text color={COLORS.muted}>{T.asanaDueLabel}</Text>
705 <Text color={COLORS.text}>{task.dueOn}</Text>
706 {due(task.dueOn)}
707 </Box>
708 )}
709 {fields.map(field => {
710 const isPicking = armed[`asana:field:${field.gid}`] ?? false
711 return (
712 <Box key={`asana-fieldrow-${field.gid}`} flexDirection="column">
713 <Box columnGap={1}>
714 <Text color={COLORS.muted}>{clip(field.name, Math.max(8, Math.floor(columns / 3)))}</Text>
715 <Text color={field.value ? COLORS.text : COLORS.muted}>{field.value ?? '—'}</Text>
716 {field.options && (
717 <Button key={`asana-field-${field.gid}`} plain dimColor label={isPicking ? '⌄' : '›'} onPress={() => void toggle($, `asana:field:${field.gid}`)} />
718 )}
719 </Box>
720 {isPicking && field.options && (
721 <Box paddingLeft={2} columnGap={2} flexWrap="wrap">
722 {field.options.map(option => (
723 <Button
724 key={`asana-option-${field.gid}-${option.gid}`}
725 plain
726 dimColor={option.name !== field.value}
727 label={option.name === field.value ? `✓ ${option.name}` : option.name}
728 onPress={() => void changeAsana($, server, task, { field: field.gid, option: option.gid })}
729 />
730 ))}
731 </Box>
732 )}
733 </Box>
734 )
735 })}
736 </Box>
737 {/* 0.48 이 열어 둔 상세(asanaView atom)는 /reload-plugins 뒤에도 남고 richNotes 가 없다. */}
738 {typeof task.richNotes === 'string'
739 ? task.richNotes !== '' && renderMarkdown(task.richNotes, els, COLORS, columns, { document: true })
740 : task.notes.trim() !== '' && <Text color={COLORS.text}>{task.notes.trim()}</Text>}
741 <Box flexDirection="column" rowGap={1}>
742 <Text color={COLORS.text} weight="semibold">
743 {T.asanaComments(task.comments.length)}
744 </Text>
745 {task.comments.map(comment => (
746 <Box key={`asana-comment-${comment.gid}`} flexDirection="column">
747 <Box columnGap={1}>
748 <Text color={COLORS.accent}>{comment.author}</Text>
749 <Text color={COLORS.muted}>{Number.isNaN(Date.parse(comment.at)) ? '' : ago(Date.parse(comment.at), now)}</Text>
750 {asanaMe !== null && comment.authorGid === asanaMe && (
751 <Button
752 key={`asana-edit-${comment.gid}`}
753 plain
754 dimColor
755 label={editing?.comment === comment.gid ? (editing.status === 'loading' || editing.status === 'saving' ? '…' : T.cancel) : T.asanaEdit}
756 onPress={() => void startAsanaEdit($, comment.gid)}
757 />
758 )}
759 </Box>
760 {editing?.comment === comment.gid && editing.story !== null ? (
761 <Box flexDirection="column">
762 <Input
763 key={`asana-editing-${comment.gid}-${asanaEditRound}`}
764 label={T.asanaEditLabel}
765 rows={Math.min(12, Math.max(COMMENT_ROWS, editing.story.text.split('\n').length + 1))}
766 value={asanaEditDraft}
767 submitLabel={editing.status === 'saving' ? '…' : T.asanaEditSave}
768 onInput={value => (asanaEditDraft = value)}
769 onSubmit={value => void saveAsanaEdit($, server, task.gid, value)}
770 />
771 <Box>
772 <Button key={`asana-edit-clear-${comment.gid}`} plain dimColor label={T.inputClear} onPress={() => void keepAsanaEdit($, comment.gid, '', true)} />
773 </Box>
774 </Box>
775 ) : typeof comment.richText === 'string' ? (
776 // 0.49 가 열어 둔 상세(asanaView atom)의 댓글에는 richText 가 없다.
777 comment.richText !== '' && renderMarkdown(comment.richText, els, COLORS, columns, { document: true })
778 ) : (
779 <Text color={COLORS.text}>{comment.text.trim()}</Text>
780 )}
781 {editing?.comment === comment.gid && editing.status === 'notoken' && <Text color={COLORS.warning}>{T.asanaNeedToken(T.asanaEditWhat)}</Text>}
782 </Box>
783 ))}
784 <Input
785 key={`asana-write-${task.gid}-${asanaWriteRound}`}
786 label={T.asanaCommentLabel}
787 placeholder={T.asanaCommentPlaceholder}
788 rows={COMMENT_ROWS}
789 value={asanaDraft}
790 submitLabel={T.asanaSend}
791 onInput={value => (asanaDraft = value)}
792 onSubmit={value => void postAsanaComment($, server, task.gid, value)}
793 />
794 <Box columnGap={2}>
795 <Button key="asana-mention" plain dimColor label={isMentioning ? T.cancel : T.asanaMention} onPress={() => void toggleAsanaFinder($, 'mention')} />
796 <Button key="asana-write-clear" plain dimColor label={T.inputClear} onPress={() => void clearAsanaDraft($, task.gid)} />
797 </Box>
798 {isMentioning && finder('asana-mention-search', found?.status === 'ok' ? found.users.map(mentionRow) : [])}
799 </Box>
800 <Box>
801 <Button key="asana-back-end" plain dimColor label={T.asanaBack} onPress={() => void closeAsanaTask($)} />
802 </Box>
803 </Box>
804 )
805 }
806
807 const room = Math.max(16, columns - 10)
808 const owner = (name: string | null) => name !== null && <Text color={COLORS.muted}>{` ${clip(name, 12)}`}</Text>
809 // 완료된 태스크는 흐리게, 마감일 대신 상태(없으면 ✓)를 붙인다. 태스크 키는 제목 바로 뒤에 흐리게.
810 const taskRow = (task: AsanaTask) => {
811 const key = task.key ?? null
812 return rowButton(
813 els,
814 { key: `asana-task-${task.gid}`, dimColor: task.isDone, onPress: () => void (server && openAsanaTask($, server, task.gid)) },
815 clip(task.name, Math.max(12, room - (task.assignee === null ? 0 : 13) - (key === null ? 0 : key.length + 1))),
816 key !== null && <Text color={COLORS.muted}>{` ${key}`}</Text>,
817 owner(task.assignee),
818 task.isDone ? <Text color={COLORS.success}>{` ${task.status === null ? '✓' : `● ${task.status}`}`}</Text> : due(task.dueOn),
819 )
820 }
821 // 탭은 diff 패널의 범위 고르기와 같은 모양. allow 규칙 안내는 두 탭 모두에.
822 const tabs = (
823 <Box key="asana-tabs" columnGap={2}>
824 {(['mine', 'projects'] as const).map(name => (
825 <Button
826 key={`asana-tab-${name}`}
827 plain
828 dimColor={name !== tab}
829 label={`${name === tab ? '●' : '○'} ${name === 'mine' ? T.asanaTabMine : T.asanaTabProjects}`}
830 onPress={() => void switchAsanaTab($, name)}
831 />
832 ))}
833 </Box>
834 )
835 const unruled = (state?.unruled.length ?? 0) > 0 && (
836 <Box flexDirection="column">
837 <Text color={COLORS.warning}>{T.asanaUnruled}</Text>
838 <Text color={COLORS.muted}>{state?.unruled.map(tool => `"${tool}",`).join('\n')}</Text>
839 </Box>
840 )
841 const isUnready = state === null || state.status === 'none' || (state.status === 'loading' && state.server === null)
842
843 // 프로젝트 탭: 펼친 프로젝트가 있으면 그 열린 태스크를 섹션별로(담당자 누구든), 아니면 내가 멤버인 프로젝트.
844 if (tab === 'projects' && !isUnready) {
845 if (project !== null) {
846 const openTasks = project.tasks.filter(task => !task.isDone)
847 const doneTasks = project.tasks.filter(task => task.isDone)
848 const groups = groupTasks(openTasks, 'section')
849 const foldOf = (name: string | null) => `asana:fold:${project.project.gid}:${name ?? ''}`
850 const doneFold = `asana:done:${project.project.gid}`
851 // 하나라도 펴져 있으면 모두 접고, 다 접혀 있으면 모두 편다(diff 패널의 모두 접기와 같다).
852 const folds = [...groups.map(group => foldOf(group.name)), ...(doneTasks.length > 0 ? [doneFold] : [])]
853 const anyOpen = folds.some(fold => !(armed[fold] ?? false))
854 const foldAll = () => update($, open, state => ({ ...state, ...Object.fromEntries(folds.map(fold => [fold, anyOpen])) }))
855 const message =
856 project.status === 'failed'
857 ? T.asanaFailed(project.error ?? '')
858 : openTasks.length === 0
859 ? project.status === 'loading'
860 ? T.asanaLoading
861 : T.asanaProjectEmpty
862 : null
863 return (
864 <Box flexDirection="column" rowGap={1}>
865 {tabs}
866 {unruled}
867 <Box flexDirection="column">
868 <Box columnGap={1}>
869 <Button key="asana-project-back" plain dimColor hotkey="b" label={T.asanaProjectsBack} onPress={() => void closeAsanaProject($)} />
870 <Button key="asana-project-refresh" plain dimColor hotkey="r" label={project.status === 'loading' ? '…' : '↻'} onPress={() => void openAsanaProject($, project.project)} />
871 {folds.length > 1 && (
872 <Button key="asana-fold-all" plain dimColor hotkey="z" label={anyOpen ? T.foldAll : T.unfoldAll} onPress={() => void foldAll()} />
873 )}
874 </Box>
875 <Box columnGap={1}>
876 <Text color={COLORS.text} weight="semibold" wrap="truncate-end">
877 {clip(project.project.name, Math.max(8, columns - 16))}
878 </Text>
879 <Text color={COLORS.muted}>{T.asanaOpenCount(openTasks.length)}</Text>
880 </Box>
881 {project.project.url && <Link href={project.project.url}>{T.asanaOpenOn}</Link>}
882 </Box>
883 {message !== null && <Text color={COLORS.muted}>{message}</Text>}
884 {groups.map((group, index) => {
885 const fold = foldOf(group.name)
886 const isFolded = armed[fold] ?? false
887 return (
888 <Box key={`asana-sectionbox-${index}`} flexDirection="column">
889 {rowButton(
890 els,
891 { key: `asana-section-${index}`, onPress: () => void toggle($, fold) },
892 isFolded ? '› ' : '⌄ ',
893 <Text color={COLORS.muted} weight="semibold">
894 {clip(group.name === null || group.name === 'Untitled section' ? T.asanaNoSection : group.name, Math.max(8, columns - 12))}
895 </Text>,
896 <Text color={COLORS.muted}>{` · ${group.tasks.length}`}</Text>,
897 )}
898 {!isFolded && group.tasks.map(taskRow)}
899 </Box>
900 )
901 })}
902 {doneTasks.length > 0 && (
903 <Box key="asana-sectionbox-done" flexDirection="column">
904 {rowButton(
905 els,
906 { key: 'asana-section-done', onPress: () => void toggle($, doneFold) },
907 (armed[doneFold] ?? false) ? '› ' : '⌄ ',
908 <Text color={COLORS.muted} weight="semibold">
909 {T.asanaRecentDone(DONE_DAYS, doneTasks.length)}
910 </Text>,
911 )}
912 {!(armed[doneFold] ?? false) && doneTasks.map(taskRow)}
913 </Box>
914 )}
915 {project.isPartial && <Text color={COLORS.muted}>{T.asanaProjectPartial(project.tasks.length)}</Text>}
916 {/* 목록이 길면 위로 되돌아가지 않도록 맨 아래에도 같은 버튼 */}
917 <Box>
918 <Button key="asana-project-back-end" plain dimColor label={T.asanaProjectsBack} onPress={() => void closeAsanaProject($)} />
919 </Box>
920 </Box>
921 )
922 }
923 const mine = (projects?.projects ?? []).filter(item => item.isMine)
924 const message =
925 projects === null || (projects.status === 'loading' && mine.length === 0)
926 ? T.asanaLoading
927 : projects.status === 'failed'
928 ? T.asanaFailed(projects.error ?? '')
929 : mine.length === 0
930 ? T.asanaNoProjects
931 : null
932 return (
933 <Box flexDirection="column" rowGap={1}>
934 {tabs}
935 {unruled}
936 <Box columnGap={1}>
937 <Text color={COLORS.text} weight="semibold">
938 {T.asanaMyProjects(mine.length)}
939 </Text>
940 <Button key="asana-projects-refresh" plain dimColor hotkey="r" label={projects?.status === 'loading' ? '…' : '↻'} onPress={() => void refreshAsanaProjects($)} />
941 </Box>
942 {message !== null && <Text color={COLORS.muted}>{message}</Text>}
943 <Box flexDirection="column">
944 {mine.map(item =>
945 rowButton(
946 els,
947 { key: `asana-project-${item.gid}`, onPress: () => void openAsanaProject($, item) },
948 clip(item.name, Math.max(12, room - 16)),
949 <Text color={COLORS.muted}>{` ${T.asanaProjectMeta(item.members, item.open)}`}</Text>,
950 ),
951 )}
952 </Box>
953 </Box>
954 )
955 }
956
957 // 검색 중이면 찾은 태스크를, 아니면 내 태스크를 같은 모양(프로젝트 묶음)으로 보인다.
958 const listed = (search ?? state)?.tasks ?? []
959 const tasks = listed.filter(task => !task.isDone)
960 const doneTasks = listed.filter(task => task.isDone)
961 const message =
962 state === null || (state.status === 'loading' && listed.length === 0 && search === null)
963 ? T.asanaLoading
964 : state.status === 'none'
965 ? T.asanaNoServer
966 : search?.status === 'loading'
967 ? T.asanaSearching
968 : (search ?? state).status === 'failed'
969 ? T.asanaFailed((search ?? state).error ?? '')
970 : tasks.length === 0
971 ? search
972 ? T.asanaNoMatch
973 : T.asanaEmpty
974 : null
975 return (
976 <Box flexDirection="column" rowGap={1}>
977 {!isUnready && tabs}
978 {unruled}
979 <Box columnGap={1}>
980 <Text color={COLORS.text} weight="semibold" wrap="truncate-end">
981 {search ? T.asanaFound(clip(search.query, Math.max(8, columns - 20)), tasks.length) : T.asanaMine(tasks.length)}
982 </Text>
983 <Button
984 key="asana-refresh"
985 plain
986 dimColor
987 hotkey="r"
988 label={(search ?? state)?.status === 'loading' ? '…' : '↻'}
989 onPress={() => void (search && server ? searchAsana($, server, search.query) : refreshAsana($))}
990 />
991 {search && <Button key="asana-search-clear" plain dimColor label={T.asanaSearchClear} onPress={() => void clearAsanaSearch($)} />}
992 </Box>
993 {state?.server && (
994 <Input
995 key={`asana-search-${asanaFindRound}`}
996 label={T.asanaSearchLabel}
997 placeholder={asanaKeyFields.length === 0 ? T.asanaSearchPlaceholder : T.asanaSearchPlaceholderKey(asanaKeyFields.join('·'))}
998 value={asanaFindText}
999 submitLabel={T.asanaFind}
1000 onInput={value => (asanaFindText = value)}
1001 onSubmit={value => void (server && searchAsana($, server, value))}
1002 />
1003 )}
1004 {message !== null && <Text color={COLORS.muted}>{message}</Text>}
1005 {groupTasks(tasks).map(group => (
1006 <Box key={`asana-group-${group.name ?? ''}`} flexDirection="column">
1007 <Text color={COLORS.muted} weight="semibold" wrap="truncate-end">
1008 {group.name ?? T.asanaNoProject}
1009 </Text>
1010 {group.tasks.map(taskRow)}
1011 </Box>
1012 ))}
1013 {doneTasks.length > 0 && (
1014 <Box key="asana-group-done" flexDirection="column">
1015 <Text color={COLORS.muted} weight="semibold">
1016 {T.asanaRecentDone(DONE_DAYS, doneTasks.length)}
1017 </Text>
1018 {doneTasks.map(taskRow)}
1019 </Box>
1020 )}
1021 </Box>
1022 )
1023 })
1024}
1025hooks/asana.ts 833 lines1import type { AsanaComment, AsanaDetail, AsanaField, AsanaProject, AsanaServer, AsanaTask, AsanaUser } from '../types'
2import { T } from './i18n'
3
4// /desk-asana: 내게 할당된 Asana 태스크. 엔진에 이미 붙은 Asana MCP 서버를 그대로 쓴다($.mcp.call, 엔진의 연결·인증).
5// 서버 이름은 사람마다 달라 도구 목록에서 찾는다: Asana 공식 v2 서버(get_my_tasks)를 먼저, 없으면 claude.ai 커넥터(asana_*).
6// 검색(v2 search_tasks, v1 asana_search_tasks)은 Premium 워크스페이스에서만 된다. v1 은 내 태스크 목록도 이 검색이다.
7// 프로젝트 탭: 워크스페이스의 프로젝트(get_projects)를 멤버에 내가 있는지로 나누고, 고른 프로젝트의 열린 태스크(get_tasks)를
8// 섹션별로 묶는다. get_tasks 는 보드 차례(섹션 차례)로 주므로 섹션 목록을 따로 읽지 않는다(빈 섹션은 안 보인다).
9// 사용자 지정 작업 유형의 상태(Open·In Progress…)는 두 서버 모두 바꾸는 도구가 없어(v2 update_tasks 는 키를 버린다) 보여 주기만 한다.
10
11export const ASANA_TOOLS = {
12 v2: {
13 list: 'get_my_tasks',
14 search: 'search_tasks',
15 task: 'get_task',
16 stories: 'get_task_stories',
17 comment: 'add_comment',
18 update: 'update_tasks',
19 users: 'search_objects',
20 me: 'get_me',
21 projects: 'get_projects',
22 tasks: 'get_tasks',
23 },
24 v1: {
25 list: 'asana_search_tasks',
26 search: 'asana_search_tasks',
27 task: 'asana_get_task',
28 stories: 'asana_get_stories_for_task',
29 comment: 'asana_create_task_story',
30 update: 'asana_update_task',
31 users: 'asana_typeahead_search',
32 me: 'asana_get_user',
33 projects: 'asana_get_projects',
34 tasks: 'asana_get_tasks',
35 },
36} as const
37
38// 상세에 보이는 댓글 수. v2 get_task 는 최근 것부터 이만큼, v1 은 활동 100개에서 댓글만 골라 끝에서 이만큼.
39export const COMMENT_LIMIT = 30
40const LIST_FIELDS = 'name,due_on,completed,custom_type_status_option.name,memberships.project.name,memberships.section.name,permalink_url'
41// 검색 결과는 남의 태스크도 섞이니 담당자 이름까지.
42const SEARCH_FIELDS = `${LIST_FIELDS},assignee.name`
43const PROJECT_TASK_FIELDS = 'name,due_on,completed,custom_type_status_option.name,assignee.name,permalink_url,memberships.project.gid,memberships.project.name,memberships.section.name'
44// 태스크 키(taskKey)는 키 필드를 설정했을 때만 읽는다. 없으면 목록 응답을 예전 그대로.
45const withKey = (fields: string, keyFields: readonly string[]) => (keyFields.length === 0 ? fields : `${fields},custom_fields.name,custom_fields.display_value`)
46// 목록 한 쪽의 크기. custom_fields 는 프로젝트의 모든 필드를 태스크마다 실어 응답이 커지니(필드 셋에 약 230자) 반으로 시작한다.
47// 그래도 MCP 결과 한도를 넘으면 패널이 더 줄여 다시 읽는다(register.tsx readPages).
48const pageSize = (keyFields: readonly string[]) => (keyFields.length === 0 ? 100 : 50)
49
50// 설정 asanaKeyField: 태스크 키로 보일 사용자 정의 필드 이름들(쉼표로, 앞의 것부터). 예: "JKD" 또는 "JKD, J5S".
51export function keyFieldNames(value: unknown): string[] {
52 return typeof value !== 'string' ? [] : value.split(',').map(name => name.trim()).filter(name => name !== '')
53}
54const TASK_FIELDS = [
55 'name,notes,html_notes,completed,due_on,permalink_url,assignee.name',
56 'memberships.project.name,memberships.section.name',
57 'custom_type_status_option.name,custom_type.status_options.gid,custom_type.status_options.name,custom_type.status_options.enabled',
58 'custom_fields.name,custom_fields.type,custom_fields.display_value,custom_fields.is_formula_field',
59 'custom_fields.enum_options.name,custom_fields.enum_options.enabled',
60].join(',')
61
62// 도구 이름(mcp__<서버>__<도구>) 목록에서 Asana 서버를 찾는다. 패널이 쓰는 도구가 그 서버에 다 있어야 한다.
63export function findAsanaServer(names: readonly string[]): AsanaServer | null {
64 const all = new Set(names)
65 for (const dialect of ['v2', 'v1'] as const) {
66 const tools = Object.values(ASANA_TOOLS[dialect])
67 const suffix = `__${ASANA_TOOLS[dialect].list}`
68 for (const name of names) {
69 if (!name.startsWith('mcp__') || !name.endsWith(suffix)) continue
70 const server = name.slice('mcp__'.length, -suffix.length)
71 if (server !== '' && tools.every(tool => all.has(`mcp__${server}__${tool}`))) return { name: server, dialect }
72 }
73 }
74 return null
75}
76
77// 패널이 그 서버에서 부르는 도구의 전체 이름. 플러그인의 $.mcp.call 도 권한 검사를 받아(auto mode 분류기는 거절,
78// 기본 모드는 누를 때마다 창) 사람이 settings 의 permissions.allow 에 이 이름들을 넣어야 패널이 막힘없이 돈다.
79export function paneTools(server: AsanaServer): string[] {
80 return [...new Set<string>(Object.values(ASANA_TOOLS[server.dialect]))].map(tool => `mcp__${server.name}__${tool}`)
81}
82
83export type AsanaCall = { tool: string; args: Record<string, unknown> }
84
85// 작업 유형에 따라 보류·배포 대기 같은 상태도 Asana 에선 완료라, 열린 태스크와 함께 이 날수 안에 완료된 태스크도 읽어
86// 목록 끝 "최근 완료" 묶음으로 보인다. since 는 그 시작 ISO 시각(doneSince).
87export const DONE_DAYS = 14
88export const doneSince = (now: number) => new Date(now - DONE_DAYS * 86_400_000).toISOString()
89
90// 내 태스크: v2 는 completed_since 하나로 열린 것과 그 뒤 완료된 것을 함께 준다. v1 검색은 둘을 한 번에 못 받아 따로 부른다.
91export function listCalls(server: AsanaServer, since: string, keyFields: readonly string[] = []): AsanaCall[] {
92 const opt_fields = withKey(LIST_FIELDS, keyFields)
93 const limit = pageSize(keyFields)
94 if (server.dialect === 'v2') return [{ tool: ASANA_TOOLS.v2.list, args: { completed_since: since, limit, opt_fields } }]
95 const args = { assignee_any: 'me', limit, opt_fields }
96 return [
97 { tool: ASANA_TOOLS.v1.list, args: { ...args, completed: false, sort_by: 'modified_at' } },
98 { tool: ASANA_TOOLS.v1.list, args: { ...args, completed: true, completed_at_after: since, sort_by: 'completed_at' } },
99 ]
100}
101
102// 워크스페이스 전체에서 글자로 찾는다(이름·설명·댓글). 열린 태스크만, 최근에 바뀐 것부터.
103export function searchCall(server: AsanaServer, text: string, keyFields: readonly string[] = []): AsanaCall {
104 return { tool: ASANA_TOOLS[server.dialect].search, args: { text, completed: false, limit: 50, sort_by: 'modified_at', opt_fields: withKey(SEARCH_FIELDS, keyFields) } }
105}
106
107// 키로 찾기: 검색어가 "JKD-1630" 모양이면 키 필드마다 값이 같은 열린 태스크를 찾는다. 글자 검색은 필드 값을 보지 않는다.
108// gids 는 목록에서 본 키 필드의 gid(keyFieldGids). 아직 못 봤으면 키로는 찾지 않는다.
109export function keySearchCalls(server: AsanaServer, text: string, keyFields: readonly string[], gids: readonly string[]): AsanaCall[] {
110 if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(text)) return []
111 return gids.map(gid => ({
112 tool: ASANA_TOOLS[server.dialect].search,
113 args: { custom_fields: JSON.stringify({ [`${gid}.value`]: text.toUpperCase() }), completed: false, limit: 10, opt_fields: withKey(SEARCH_FIELDS, keyFields) },
114 }))
115}
116
117export function taskCall(server: AsanaServer, gid: string): AsanaCall {
118 return server.dialect === 'v2'
119 ? { tool: ASANA_TOOLS.v2.task, args: { task_id: gid, comment_limit: COMMENT_LIMIT, include_subtasks: false, opt_fields: TASK_FIELDS } }
120 : { tool: ASANA_TOOLS.v1.task, args: { task_id: gid, opt_fields: TASK_FIELDS } }
121}
122
123// 댓글은 활동(story)에서 따로 읽는다. v2 get_task 의 댓글은 html_text 를 주지 않아(opt_fields 도 안 먹는다) 서식·멘션 이름이 없다.
124// v2 는 최근 것부터 COMMENT_LIMIT 개, v1 은 정렬을 고를 수 없어 오래된 것부터 100개다.
125const STORY_FIELDS = 'text,html_text,created_at,created_by.name,created_by.gid,resource_subtype'
126export function storiesCall(server: AsanaServer, gid: string): AsanaCall {
127 return server.dialect === 'v2'
128 ? { tool: ASANA_TOOLS.v2.stories, args: { task_id: gid, resource_subtype: 'comment_added', sort_ascending: false, limit: COMMENT_LIMIT, opt_fields: STORY_FIELDS } }
129 : { tool: ASANA_TOOLS.v1.stories, args: { task_id: gid, limit: 100, opt_fields: STORY_FIELDS } }
130}
131
132// 고른 사람이 글에 "@이름" 으로 남아 있으면 html_text 로 보낸다(<a data-asana-gid/> 가 멘션이 된다). 지웠으면 그냥 글.
133export function commentCall(server: AsanaServer, gid: string, text: string, mentions: readonly AsanaUser[] = []): AsanaCall {
134 const html = mentionHtml(text, mentions)
135 return { tool: ASANA_TOOLS[server.dialect].comment, args: html === null ? { task_id: gid, text } : { task_id: gid, html_text: html } }
136}
137
138// 고른 사람의 "@이름" 을 <a data-asana-gid/> 로 바꾼다(Asana 가 이름으로 펼친다). 하나도 안 남았으면 null(그냥 글).
139// 긴 이름부터 맞춰 "Kim" 이 "Kim Lee" 의 앞을 먹지 않게 한다. 줄바꿈은 <body> 안에 그대로 둔다(Asana 도 html_text 에 \n 으로 둔다).
140export function mentionHtml(text: string, mentions: readonly AsanaUser[]): string | null {
141 const byName = new Map(mentions.filter(user => user.name !== '').map(user => [escapeHtml(`@${user.name}`), user.gid]))
142 if (byName.size === 0) return null
143 const names = [...byName.keys()].sort((a, b) => b.length - a.length).map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
144 let isUsed = false
145 const body = escapeHtml(text).replace(new RegExp(names.join('|'), 'g'), name => {
146 isUsed = true
147 return `<a data-asana-gid="${byName.get(name)}"/>`
148 })
149 return isUsed ? `<body>${body}</body>` : null
150}
151
152const escapeHtml = (value: string) => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
153const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' }
154
155// 글 조각을 글자마다 원래 모양(엔티티면 " 그대로)과 함께 푼다. 고친 조각도 안 바꾼 글자는 원래 모양으로 다시 쓴다.
156function decodeText(raw: string): { text: string; pieces: string[] } {
157 const pieces: string[] = []
158 let text = ''
159 for (const match of raw.matchAll(/&(#\d+|#x[0-9a-f]+|[a-z]+);|[\s\S]/gi)) {
160 const entity = match[1]
161 const code = entity?.startsWith('#x') || entity?.startsWith('#X') ? parseInt(entity.slice(2), 16) : entity?.startsWith('#') ? Number(entity.slice(1)) : null
162 const value = entity === undefined ? match[0] : code !== null ? String.fromCodePoint(code) : (ENTITIES[entity.toLowerCase()] ?? match[0])
163 // 한 글자가 UTF-16 두 칸이면 원래 모양은 첫 칸에만 둔다.
164 for (let unit = 0; unit < value.length; unit++) pieces.push(unit === 0 ? match[0] : '')
165 text += value
166 }
167 return { text, pieces }
168}
169const unescapeHtml = (raw: string) => decodeText(raw).text
170
171// 댓글 고치기: 두 Asana MCP 서버 모두 댓글(story)을 고치는 도구가 없어, 사람이 넣은 개인 토큰(PAT, userConfig asanaToken)이
172// 있을 때만 REST API 로 한다. story 의 text 는 멘션을 프로필 URL 로 풀고 서식을 버리므로, 고칠 글은 html_text 에서 만들고
173// 저장은 바뀐 글자만 원래 html_text 의 그 자리에 끼워 넣는다(storyPatch). 손대지 않은 태그·글은 바이트 그대로 남는다.
174export const ASANA_API = 'https://app.asana.com/api/1.0'
175
176export type AsanaStory = { gid: string; author: string | null; html: string; draft: string | null }
177
178export function toStory(json: unknown): AsanaStory | null {
179 const raw = dataOf(json) as { gid?: unknown; html_text?: unknown; created_by?: Named } | undefined
180 if (typeof raw?.gid !== 'string' || typeof raw.html_text !== 'string') return null
181 return { gid: raw.gid, author: str(raw.created_by?.gid), html: raw.html_text, draft: storyView(raw.html_text)?.text ?? null }
182}
183
184type Tok = { raw: string; kind: 'text' | 'open' | 'close' | 'self'; name: string; text: string; pieces: string[] }
185// 고칠 글의 글자 한 칸: 글 조각(seg 번째 토큰의 off 번째 글자) 아니면 고칠 수 없는 표시(group). 표시는 멘션·링크·이미지(인라인)와
186// 목록 기호·블록 앞뒤 줄바꿈(구조)이다. before·after 는 그 표시 앞뒤에 글을 끼울 수 있는 토큰 자리(끼울 수 없으면 null).
187type Cell = { seg: number; off: number } | { group: number }
188type Group = { start: number; end: number; before: number | null; after: number | null; isDeletable: boolean; first: number; last: number }
189
190function tokenize(html: string): Tok[] {
191 const body = html.replace(/^\s*<body>/, '').replace(/<\/body>\s*$/, '')
192 return (body.match(/<[^>]+>|[^<]+/g) ?? []).map(raw => {
193 if (!raw.startsWith('<')) return { raw, kind: 'text', name: '', ...decodeText(raw) }
194 const name = (/^<\/?\s*([a-zA-Z][a-zA-Z0-9]*)/.exec(raw)?.[1] ?? '').toLowerCase()
195 const kind = raw.startsWith('</') ? 'close' : raw.endsWith('/>') || name === 'img' || name === 'hr' || name === 'br' ? 'self' : 'open'
196 return { raw, kind, name, text: '', pieces: [] }
197 })
198}
199
200const BLOCKS = new Set(['h1', 'h2', 'blockquote', 'pre', 'ul', 'ol'])
201
202export function storyView(html: string): { text: string; cells: Cell[]; groups: Group[]; toks: Tok[] } | null {
203 const toks = tokenize(html)
204 const cells: Cell[] = []
205 const groups: Group[] = []
206 const lists: Array<{ ordered: boolean; n: number }> = []
207 let text = ''
208 let pendingBreak: number | null = null
209 const atLineStart = () => text === '' || text.endsWith('\n')
210 const fixed = (shown: string, at: Omit<Group, 'start' | 'end'>) => {
211 if (shown === '') return
212 const group = groups.length
213 groups.push({ ...at, start: text.length, end: text.length + shown.length })
214 for (const _ of shown) cells.push({ group })
215 text += shown
216 }
217 const flushBreak = (next: string) => {
218 if (pendingBreak !== null && !next.startsWith('\n') && !atLineStart()) fixed('\n', { before: null, after: pendingBreak, isDeletable: false, first: pendingBreak, last: pendingBreak })
219 pendingBreak = null
220 }
221 for (let i = 0; i < toks.length; i++) {
222 const tok = toks[i] as Tok
223 if (tok.kind === 'text') {
224 flushBreak(tok.text)
225 for (let off = 0; off < tok.text.length; off++) cells.push({ seg: i, off })
226 text += tok.text
227 } else if (tok.kind === 'open' && tok.name === 'a') {
228 // 링크·멘션은 통째로 하나의 표시: 안쪽 글자를 보이되 고칠 수 없고, 다 지우면 링크째 지운다.
229 let close = i + 1
230 while (close < toks.length && !(toks[close]?.kind === 'close' && toks[close]?.name === 'a')) close++
231 if (close >= toks.length) return null
232 flushBreak('')
233 fixed(toks.slice(i + 1, close).map(inner => inner.text).join(''), { before: i, after: close, isDeletable: true, first: i, last: close })
234 i = close
235 } else if (tok.kind === 'self' && tok.name === 'img') {
236 flushBreak('')
237 fixed(T.asanaImage, { before: i, after: i, isDeletable: false, first: i, last: i })
238 } else if (tok.kind === 'self' && tok.name === 'hr') {
239 flushBreak('')
240 fixed(`${atLineStart() ? '' : '\n'}———\n`, { before: i, after: i, isDeletable: false, first: i, last: i })
241 } else if (tok.kind === 'open' && (tok.name === 'ul' || tok.name === 'ol')) {
242 flushBreak('')
243 lists.push({ ordered: tok.name === 'ol', n: 0 })
244 } else if (tok.kind === 'open' && tok.name === 'li') {
245 flushBreak('')
246 const list = lists.at(-1)
247 if (list) list.n++
248 const mark = list?.ordered ? `${list.n}. ` : '• '
249 fixed(`${atLineStart() ? '' : '\n'}${' '.repeat(Math.max(0, lists.length - 1))}${mark}`, { before: null, after: i, isDeletable: false, first: i, last: i })
250 } else if (tok.kind === 'open' && BLOCKS.has(tok.name)) {
251 flushBreak('')
252 if (!atLineStart()) fixed('\n', { before: i, after: i, isDeletable: false, first: i, last: i })
253 } else if (tok.kind === 'close' && BLOCKS.has(tok.name)) {
254 if (tok.name === 'ul' || tok.name === 'ol') lists.pop()
255 pendingBreak = i
256 }
257 }
258 return { text, cells, groups, toks }
259}
260
261// 태스크 본문(html_notes)을 패널 마크다운으로 옮겨 웹처럼 제목·굵게·목록·코드를 살린다. Asana 서식 태그는 몇 개뿐이다
262// (h1·h2·strong·em·u·s·code·a·ul·ol·li·blockquote·pre·hr·img·table). 글은 마크다운 표시로 읽히지 않게 \ 로 막고(parseInline 이 푼다),
263// 블록 밖 줄바꿈 하나가 문단 하나다. 밑줄은 마크다운에 없어 글만 남긴다.
264type HtmlNode = string | { name: string; raw: string; children: HtmlNode[] }
265type HtmlElement = Exclude<HtmlNode, string>
266
267function htmlTree(html: string): HtmlNode[] {
268 const root: HtmlElement = { name: '', raw: '', children: [] }
269 const stack = [root]
270 for (const tok of tokenize(html)) {
271 const top = stack[stack.length - 1] as HtmlElement
272 if (tok.kind === 'text') top.children.push(tok.text)
273 else if (tok.kind === 'self') top.children.push({ name: tok.name, raw: tok.raw, children: [] })
274 else if (tok.kind === 'open') {
275 const node: HtmlElement = { name: tok.name, raw: tok.raw, children: [] }
276 top.children.push(node)
277 stack.push(node)
278 } else {
279 // 짝이 안 맞는 닫는 태그는 버린다.
280 let at = stack.length - 1
281 while (at > 0 && stack[at]?.name !== tok.name) at--
282 if (at > 0) stack.length = at
283 }
284 }
285 return root.children
286}
287
288const textOf = (node: HtmlNode): string => (typeof node === 'string' ? node : node.children.map(textOf).join(''))
289const attrOf = (node: HtmlElement, name: string) => {
290 const value = new RegExp(`\\s${name}="([^"]*)"`).exec(node.raw)?.[1]
291 return value === undefined ? null : unescapeHtml(value)
292}
293// 주소는 그대로 둬야 맨 주소 링크가 깨지지 않는다.
294const escapeText = (text: string) =>
295 text
296 .split(/(https?:\/\/[^\s<>()]+)/)
297 .map((part, index) => (index % 2 === 1 ? part : part.replace(/[\\`*_~[\]|]/g, '\\$&')))
298 .join('')
299// 줄 머리의 #·>·-·+·"1." 은 제목·인용·목록으로 읽힌다.
300const escapeLineStart = (line: string) => line.replace(/^(\s*)([#>+-])/, '$1\\$2').replace(/^(\s*\d+)([.)])/, '$1\\$2')
301// 감쌀 글을 첫 인자로 둔다: 표시 글자 '*' 가 첫 인자면 디렉터리 검사가 모든 도구에 거는 훅 등록으로 읽고 막는다.
302const wrapInline = (inner: string, mark: string) => (inner.trim() === '' ? inner : `${mark}${inner}${mark}`)
303
304function inlineMarkdown(nodes: readonly HtmlNode[]): string {
305 return nodes
306 .map(node => {
307 if (typeof node === 'string') return escapeText(node)
308 const inner = inlineMarkdown(node.children)
309 switch (node.name) {
310 case 'strong':
311 case 'b':
312 return wrapInline(inner, '**')
313 case 'em':
314 case 'i':
315 return wrapInline(inner, '*')
316 case 's':
317 case 'strike':
318 case 'del':
319 return wrapInline(inner, '~~')
320 case 'code': {
321 const raw = textOf(node)
322 return raw.trim() !== '' && !/[`\n]/.test(raw) ? `\`${raw}\`` : inner
323 }
324 case 'a': {
325 const href = attrOf(node, 'href')?.replace(/[\s()]/g, ch => encodeURIComponent(ch))
326 return href ? `[${inner.trim() === '' ? escapeText(href) : inner}](${href})` : inner
327 }
328 case 'img':
329 return escapeText(T.asanaImage)
330 default:
331 return inner
332 }
333 })
334 .join('')
335}
336
337const INLINE_TAGS = new Set(['strong', 'b', 'em', 'i', 'u', 's', 'strike', 'del', 'code', 'a', 'img', 'span'])
338
339function listMarkdown(list: HtmlElement, level: number): string[] {
340 const lines: string[] = []
341 let n = 0
342 for (const item of list.children) {
343 if (typeof item === 'string' || item.name !== 'li') continue
344 n++
345 const text = inlineMarkdown(item.children.filter(child => typeof child === 'string' || (child.name !== 'ul' && child.name !== 'ol')))
346 lines.push(`${' '.repeat(Math.min(4, level))}${list.name === 'ol' ? `${n}.` : '-'} ${text.replace(/\s*\n\s*/g, ' ').trim()}`)
347 for (const child of item.children) if (typeof child !== 'string' && (child.name === 'ul' || child.name === 'ol')) lines.push(...listMarkdown(child, level + 1))
348 }
349 return lines
350}
351
352function blockMarkdown(nodes: readonly HtmlNode[]): string[] {
353 const out: string[] = []
354 let run: HtmlNode[] = []
355 const flush = () => {
356 for (const line of inlineMarkdown(run).split('\n')) if (line.trim() !== '') out.push(escapeLineStart(line.trim()))
357 run = []
358 }
359 for (const node of nodes) {
360 if (typeof node === 'string' || INLINE_TAGS.has(node.name)) {
361 run.push(node)
362 continue
363 }
364 flush()
365 switch (node.name) {
366 case 'h1':
367 case 'h2':
368 case 'h3':
369 out.push(`${'#'.repeat(Number(node.name[1]))} ${inlineMarkdown(node.children).replace(/\s*\n\s*/g, ' ').trim()}`)
370 break
371 case 'ul':
372 case 'ol':
373 out.push(listMarkdown(node, 0).join('\n'))
374 break
375 case 'blockquote':
376 out.push(
377 blockMarkdown(node.children)
378 .join('\n\n')
379 .split('\n')
380 .map(line => (line === '' ? '>' : `> ${line}`))
381 .join('\n'),
382 )
383 break
384 case 'pre': {
385 const source = textOf(node).replace(/\n+$/, '')
386 const fence = '`'.repeat(Math.max(3, ...[...source.matchAll(/`+/g)].map(match => match[0].length + 1)))
387 out.push(`${fence}\n${source}\n${fence}`)
388 break
389 }
390 case 'hr':
391 // --- 는 맨 앞이면 front matter 로 읽힌다.
392 out.push('***')
393 break
394 case 'table': {
395 const rows: string[][] = []
396 const collect = (parent: HtmlElement) => {
397 for (const child of parent.children) {
398 if (typeof child === 'string') continue
399 if (child.name === 'tr') rows.push(child.children.filter((cell): cell is HtmlElement => typeof cell !== 'string' && (cell.name === 'td' || cell.name === 'th')).map(cell => inlineMarkdown(cell.children).replace(/\s*\n\s*/g, ' ').trim()))
400 else collect(child)
401 }
402 }
403 collect(node)
404 const [header, ...body] = rows
405 if (header) out.push([header, header.map(() => '---'), ...body].map(row => `| ${row.join(' | ')} |`).join('\n'))
406 break
407 }
408 default:
409 out.push(...blockMarkdown(node.children))
410 }
411 }
412 flush()
413 return out.filter(block => block.trim() !== '')
414}
415
416export function notesMarkdown(html: string): string {
417 return blockMarkdown(htmlTree(html)).join('\n\n')
418}
419
420// 글자 단위 diff(Myers). 앞뒤 같은 글은 먼저 걷어 낸다. 돌려주는 것은 옛 글의 [start, end) 를 text 로 바꾸는 덩어리들.
421// 바뀐 곳이 DIFF_LIMIT 를 넘으면(거의 새로 쓴 글) 기억이 D² 로 불어나므로, 가운데를 통째로 바꾸는 한 덩어리로 돌려준다.
422const DIFF_LIMIT = 500
423
424export function textDiff(a: string, b: string): Array<{ start: number; end: number; text: string }> {
425 let head = 0
426 while (head < a.length && head < b.length && a[head] === b[head]) head++
427 let tail = 0
428 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
429 const x0 = a.slice(head, a.length - tail)
430 const y0 = b.slice(head, b.length - tail)
431 const n = x0.length
432 const m = y0.length
433 const max = n + m
434 const v = new Map<number, number>([[1, 0]])
435 const trace: Array<Map<number, number>> = []
436 let found = false
437 for (let d = 0; d <= max && !found; d++) {
438 if (d > DIFF_LIMIT) return [{ start: head, end: a.length - tail, text: y0 }]
439 trace.push(new Map(v))
440 for (let k = -d; k <= d; k += 2) {
441 let x = k === -d || (k !== d && (v.get(k - 1) ?? -1) < (v.get(k + 1) ?? -1)) ? (v.get(k + 1) ?? 0) : (v.get(k - 1) ?? 0) + 1
442 let y = x - k
443 while (x < n && y < m && x0[x] === y0[y]) {
444 x++
445 y++
446 }
447 v.set(k, x)
448 if (x >= n && y >= m) {
449 found = true
450 break
451 }
452 }
453 }
454 // 거꾸로 따라가며 한 칸씩 (지움 | 넣음 | 같음) 을 모은다.
455 const ops: Array<{ kind: 'eq' | 'del' | 'ins'; a: number; b: number }> = []
456 let x = n
457 let y = m
458 for (let d = trace.length - 1; d >= 0; d--) {
459 const prev = trace[d] as Map<number, number>
460 const k = x - y
461 const prevK = k === -d || (k !== d && (prev.get(k - 1) ?? -1) < (prev.get(k + 1) ?? -1)) ? k + 1 : k - 1
462 const prevX = d === 0 ? 0 : (prev.get(prevK) ?? 0)
463 const prevY = d === 0 ? 0 : prevX - prevK
464 while (x > prevX && y > prevY) {
465 x--
466 y--
467 ops.push({ kind: 'eq', a: x, b: y })
468 }
469 if (d > 0) {
470 if (x === prevX) ops.push({ kind: 'ins', a: x, b: y - 1 })
471 else ops.push({ kind: 'del', a: x - 1, b: y })
472 x = prevX
473 y = prevY
474 }
475 }
476 ops.reverse()
477 const hunks: Array<{ start: number; end: number; text: string }> = []
478 for (const op of ops) {
479 if (op.kind === 'eq') continue
480 const last = hunks.at(-1)
481 const start = op.a
482 if (last && last.end === start) {
483 if (op.kind === 'del') last.end = op.a + 1
484 else last.text += y0[op.b]
485 } else hunks.push({ start, end: op.kind === 'del' ? op.a + 1 : start, text: op.kind === 'ins' ? (y0[op.b] as string) : '' })
486 }
487 return hunks.map(hunk => ({ start: hunk.start + head, end: hunk.end + head, text: hunk.text }))
488}
489
490// 고친 글을 원래 html_text 에 끼워 넣는다. 바뀐 덩어리가 고칠 수 없는 표시(목록 기호, 이미지, 멘션 글자의 일부)를 건드리면 거절한다.
491// 멘션·링크는 통째로 지우는 것만 된다. 끼워 넣는 글은 왼쪽 글 조각의 서식을 따르고, 없으면 오른쪽, 그것도 없으면 표시의 앞뒤 자리에 둔다.
492export function storyPatch(html: string, edited: string): { html: string } | { error: 'format' } | null {
493 const view = storyView(html)
494 if (view === null) return { error: 'format' }
495 const { cells, groups, toks } = view
496 const hunks = textDiff(view.text, edited)
497 if (hunks.length === 0) return null
498 const del = new Map<number, Set<number>>()
499 const ins = new Map<number, Map<number, string>>()
500 const extra = new Map<number, string>()
501 const removed = new Set<number>()
502 const putText = (seg: number, off: number, value: string) => {
503 const at = ins.get(seg) ?? new Map<number, string>()
504 at.set(off, (at.get(off) ?? '') + value)
505 ins.set(seg, at)
506 }
507 for (const hunk of hunks) {
508 const covered = cells.slice(hunk.start, hunk.end)
509 for (const cell of covered) {
510 if ('seg' in cell) {
511 const set = del.get(cell.seg) ?? new Set<number>()
512 set.add(cell.off)
513 del.set(cell.seg, set)
514 continue
515 }
516 const group = groups[cell.group] as Group
517 if (!group.isDeletable || group.start < hunk.start || group.end > hunk.end) return { error: 'format' }
518 for (let tok = group.first; tok <= group.last; tok++) removed.add(tok)
519 }
520 if (hunk.text === '') continue
521 const firstText = covered.find(cell => 'seg' in cell) as { seg: number; off: number } | undefined
522 const left = cells[hunk.start - 1]
523 const right = cells[hunk.end]
524 const groupOf = (cell: Cell | undefined) => (cell && 'group' in cell ? (groups[cell.group] as Group) : null)
525 // 줄 첫머리의 목록 기호 바로 앞에 쓴 글은 html 에서 앞 문단 끝으로 갈 수밖에 없어, 줄로 끝나지 않으면 거절한다.
526 const mark = groupOf(right)
527 if (mark && mark.before === null && mark.start === hunk.end && (hunk.start === 0 || view.text[hunk.start - 1] === '\n') && !hunk.text.endsWith('\n')) {
528 return { error: 'format' }
529 }
530 if (firstText) putText(firstText.seg, firstText.off, hunk.text)
531 else if (left && 'seg' in left) putText(left.seg, left.off + 1, hunk.text)
532 else if (right && 'seg' in right) putText(right.seg, right.off, hunk.text)
533 else {
534 const before = groupOf(left)
535 const after = groupOf(right)
536 if (before && before.end === hunk.start && before.after !== null) extra.set(before.after, (extra.get(before.after) ?? '') + hunk.text)
537 else if (after && after.start === hunk.end && after.before !== null) extra.set(after.before - 1, (extra.get(after.before - 1) ?? '') + hunk.text)
538 else if (!left && !right) extra.set(-1, (extra.get(-1) ?? '') + hunk.text)
539 else return { error: 'format' }
540 }
541 }
542 const out: string[] = [escapeHtml(extra.get(-1) ?? '')]
543 toks.forEach((tok, i) => {
544 if (!removed.has(i)) {
545 if (tok.kind === 'text' && (del.has(i) || ins.has(i))) {
546 const gone = del.get(i)
547 const added = ins.get(i)
548 let value = ''
549 for (let off = 0; off <= tok.text.length; off++) {
550 value += escapeHtml(added?.get(off) ?? '')
551 if (off < tok.text.length && !gone?.has(off)) value += tok.pieces[off]
552 }
553 out.push(value)
554 } else out.push(tok.raw)
555 }
556 if (extra.has(i)) out.push(escapeHtml(extra.get(i) as string))
557 })
558 return { html: `<body>${out.join('')}</body>` }
559}
560
561// 담당자나 드롭다운 필드 하나를 바꾼다. v2 는 여러 태스크를 받는 배열, v1 의 custom_fields 는 JSON 글자.
562export function updateCall(server: AsanaServer, gid: string, change: { assignee: string } | { field: string; option: string }): AsanaCall {
563 const fields = 'field' in change ? { [change.field]: change.option } : null
564 if (server.dialect === 'v2') {
565 return { tool: ASANA_TOOLS.v2.update, args: { tasks: [{ task: gid, ...(fields ? { custom_fields: fields } : { assignee: (change as { assignee: string }).assignee }) }] } }
566 }
567 return { tool: ASANA_TOOLS.v1.update, args: { task_id: gid, ...(fields ? { custom_fields: JSON.stringify(fields) } : { assignee: (change as { assignee: string }).assignee }) } }
568}
569
570// 나(gid). 프로젝트 멤버에 내가 있는지 견준다. v1 asana_get_user 는 인자가 없으면 나다.
571export function meCall(server: AsanaServer): AsanaCall {
572 return { tool: ASANA_TOOLS[server.dialect].me, args: {} }
573}
574
575// 워크스페이스의 보관하지 않은 프로젝트. 다음 쪽은 앞 결과의 next_page.offset 으로.
576export function projectsCall(server: AsanaServer, offset?: string): AsanaCall {
577 return {
578 tool: ASANA_TOOLS[server.dialect].projects,
579 args: { archived: false, limit: 100, opt_fields: 'name,members.gid,modified_at,permalink_url', ...(offset ? { offset } : {}) },
580 }
581}
582
583// 프로젝트의 열린 태스크와 since 뒤에 완료된 태스크. v2 completed_since 는 ISO 시각만 받는다('now' 를 거절).
584export function projectTasksCall(server: AsanaServer, project: string, since: string, keyFields: readonly string[]): AsanaCall {
585 return {
586 tool: ASANA_TOOLS[server.dialect].tasks,
587 args: { project, completed_since: since, limit: pageSize(keyFields), opt_fields: withKey(PROJECT_TASK_FIELDS, keyFields) },
588 }
589}
590
591export function usersCall(server: AsanaServer, query: string): AsanaCall {
592 return { tool: ASANA_TOOLS[server.dialect].users, args: { resource_type: 'user', query, count: 8, opt_fields: 'name,email' } }
593}
594
595type Block = { type: string; text?: string }
596
597// MCP 결과: 첫 text 블록의 JSON. 실패(isError)나 JSON 이 아니면 그 글을 오류로.
598export function resultData(result: { content: readonly Block[]; isError: boolean }): { ok: true; data: unknown } | { ok: false; error: string } {
599 const text = result.content
600 .filter(block => block.type === 'text' && typeof block.text === 'string')
601 .map(block => block.text as string)
602 .join('\n')
603 .trim()
604 if (result.isError) return { ok: false, error: firstLine(text) || 'error' }
605 try {
606 return { ok: true, data: JSON.parse(text) as unknown }
607 } catch {
608 return { ok: false, error: firstLine(text) || 'empty result' }
609 }
610}
611
612const firstLine = (text: string) => (text.split('\n').find(line => line.trim() !== '') ?? '').trim().slice(0, 200)
613
614type Named = { gid?: string; name?: string | null } | null | undefined
615type RawProject = { gid?: string; name?: string; members?: Named[]; modified_at?: string; permalink_url?: string; task_counts?: { num_incomplete_tasks?: number } | null }
616type RawMembership = { project?: Named; section?: Named }
617type RawTask = {
618 gid?: string
619 name?: string
620 notes?: string
621 html_notes?: string
622 completed?: boolean
623 due_on?: string | null
624 permalink_url?: string
625 assignee?: Named
626 memberships?: RawMembership[]
627 custom_type_status_option?: Named
628 custom_type?: { status_options?: Array<{ gid?: string; name?: string; enabled?: boolean }> } | null
629 custom_fields?: Array<{
630 gid?: string
631 name?: string
632 type?: string
633 display_value?: string | null
634 is_formula_field?: boolean
635 enum_options?: Array<{ gid?: string; name?: string; enabled?: boolean }>
636 }>
637 comments?: RawStory[]
638}
639type RawStory = { gid?: string; text?: string; html_text?: string; created_at?: string; created_by?: Named; resource_subtype?: string }
640
641const str = (value: unknown) => (typeof value === 'string' ? value : null)
642const dataOf = (json: unknown) => (json as { data?: unknown } | null)?.data
643
644// 필드 이름은 대소문자를 가리지 않는다(설정에 "jkd" 라고 넣어도 JKD 필드).
645const isKeyField = (name: unknown, keyFields: readonly string[]) => typeof name === 'string' && keyFields.some(key => key.toLowerCase() === name.toLowerCase())
646
647// 태스크 키: 설정한 키 필드 중 앞의 것부터 값이 있는 것(Asana ID 필드면 "JKD-1630").
648function taskKey(fields: RawTask['custom_fields'], keyFields: readonly string[]): string | null {
649 for (const name of keyFields) {
650 const value = str(fields?.find(field => isKeyField(field?.name, [name]))?.display_value)
651 if (value !== null && value.trim() !== '') return value.trim()
652 }
653 return null
654}
655
656// 응답의 태스크들에 붙은 키 필드의 gid. 키로 찾기(keySearchCalls)는 이름이 아니라 gid 로 묻는다.
657export function keyFieldGids(json: unknown, keyFields: readonly string[]): string[] {
658 const data = dataOf(json)
659 if (!Array.isArray(data)) return []
660 const fields = (data as RawTask[]).flatMap(raw => raw?.custom_fields ?? [])
661 return [...new Set(fields.filter(field => typeof field?.gid === 'string' && isKeyField(field.name, keyFields)).map(field => field.gid as string))]
662}
663
664// project 를 주면 그 프로젝트의 자리(섹션)를 쓴다. 아니면 이름이 있는 첫 프로젝트.
665export function toTasks(json: unknown, keyFields: readonly string[] = [], project?: string): AsanaTask[] {
666 const data = dataOf(json)
667 if (!Array.isArray(data)) return []
668 return (data as RawTask[])
669 .filter(raw => typeof raw?.gid === 'string')
670 .map(raw => {
671 const place = raw.memberships?.find(item => (project === undefined ? str(item?.project?.name) !== null : item?.project?.gid === project))
672 return {
673 gid: raw.gid as string,
674 name: str(raw.name) ?? '',
675 key: taskKey(raw.custom_fields, keyFields),
676 dueOn: str(raw.due_on),
677 project: str(place?.project?.name),
678 section: str(place?.section?.name),
679 url: str(raw.permalink_url),
680 assignee: str(raw.assignee?.name),
681 isDone: raw.completed === true,
682 status: str(raw.custom_type_status_option?.name),
683 }
684 })
685}
686
687// 다음 쪽 표시(next_page.offset). 없으면 마지막 쪽.
688export function nextOffset(json: unknown): string | null {
689 return str((json as { next_page?: { offset?: unknown } | null } | null)?.next_page?.offset)
690}
691
692export function myGid(json: unknown): string | null {
693 return str((dataOf(json) as { gid?: unknown } | undefined)?.gid)
694}
695
696// 최근에 바뀐 것부터(Asana 홈의 "최근"처럼). 열린 태스크 수는 v2 만 준다.
697export function toProjects(json: unknown, me: string | null): AsanaProject[] {
698 const data = dataOf(json)
699 if (!Array.isArray(data)) return []
700 return (data as RawProject[])
701 .filter(raw => typeof raw?.gid === 'string')
702 .map(raw => {
703 const members = (raw.members ?? []).filter(member => typeof member?.gid === 'string')
704 const open = raw.task_counts?.num_incomplete_tasks
705 return {
706 gid: raw.gid as string,
707 name: str(raw.name) ?? '',
708 url: str(raw.permalink_url),
709 members: members.length,
710 open: typeof open === 'number' ? open : null,
711 isMine: me !== null && members.some(member => member?.gid === me),
712 modifiedAt: str(raw.modified_at) ?? '',
713 }
714 })
715 .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
716}
717
718function toComment(raw: RawStory): AsanaComment | null {
719 if (typeof raw?.gid !== 'string' || typeof raw.text !== 'string') return null
720 if (raw.resource_subtype !== undefined && raw.resource_subtype !== 'comment_added') return null
721 return {
722 gid: raw.gid,
723 author: str(raw.created_by?.name) ?? '?',
724 authorGid: str(raw.created_by?.gid),
725 at: str(raw.created_at) ?? '',
726 text: raw.text,
727 richText: typeof raw.html_text === 'string' ? notesMarkdown(raw.html_text) : null,
728 }
729}
730
731// 상세: 댓글은 활동 목록(stories)에서 고른다. stories 를 못 읽으면 v2 get_task 에 든 평문 댓글을 쓴다.
732export function toDetail(json: unknown, stories?: unknown, keyFields: readonly string[] = []): AsanaDetail | null {
733 const raw = dataOf(json) as RawTask | undefined
734 if (!raw || typeof raw.gid !== 'string') return null
735 const storyList = Array.isArray(dataOf(stories)) ? dataOf(stories) : raw.comments
736 // v2 stories 는 최근 것부터 온다. 오래된 것부터 읽히게.
737 const comments = (Array.isArray(storyList) ? (storyList as RawStory[]) : [])
738 .map(toComment)
739 .filter((item): item is AsanaComment => item !== null)
740 .sort((a, b) => a.at.localeCompare(b.at))
741 const fields: AsanaField[] = (raw.custom_fields ?? [])
742 .filter(field => typeof field?.gid === 'string' && typeof field.name === 'string')
743 .map(field => {
744 const options = (field.enum_options ?? []).filter(option => typeof option?.gid === 'string' && option.enabled !== false)
745 const isPickable = field.type === 'enum' && field.is_formula_field !== true && options.length > 0
746 return {
747 gid: field.gid as string,
748 name: field.name as string,
749 value: str(field.display_value),
750 options: isPickable ? options.map(option => ({ gid: option.gid as string, name: str(option.name) ?? '' })) : null,
751 }
752 })
753 return {
754 gid: raw.gid,
755 name: str(raw.name) ?? '',
756 key: taskKey(raw.custom_fields, keyFields),
757 notes: str(raw.notes) ?? '',
758 richNotes: typeof raw.html_notes === 'string' ? notesMarkdown(raw.html_notes) : null,
759 assignee: typeof raw.assignee?.gid === 'string' ? { gid: raw.assignee.gid, name: str(raw.assignee.name) ?? '?' } : null,
760 dueOn: str(raw.due_on),
761 completed: raw.completed === true,
762 status: str(raw.custom_type_status_option?.name),
763 statuses: (raw.custom_type?.status_options ?? [])
764 .filter(option => typeof option?.gid === 'string' && option.enabled !== false)
765 .map(option => ({ gid: option.gid as string, name: str(option.name) ?? '' })),
766 places: (raw.memberships ?? [])
767 .map(item => [str(item?.project?.name), str(item?.section?.name)].filter(Boolean).join(' › '))
768 .filter(place => place !== ''),
769 url: str(raw.permalink_url),
770 fields,
771 comments: comments.slice(-COMMENT_LIMIT),
772 }
773}
774
775export function toUsers(json: unknown): AsanaUser[] {
776 const data = dataOf(json)
777 if (!Array.isArray(data)) return []
778 return (data as Array<{ gid?: string; name?: string; email?: string }>)
779 .filter(user => typeof user?.gid === 'string' && typeof user.name === 'string')
780 .map(user => ({ gid: user.gid as string, name: user.name as string, email: str(user.email) }))
781}
782
783// 바꾸기 결과의 실패: v2 update_tasks 는 성공(data)·실패(failed) 목록을 따로 돌려준다(모르는 키는 조용히 버리고 0건).
784export function updateProblem(server: AsanaServer, json: unknown): string | null {
785 if (server.dialect === 'v1') return null
786 const result = json as { data?: unknown[]; failed?: Array<{ errors?: unknown[] }>; summary?: string } | null
787 const failed = result?.failed ?? []
788 if (failed.length > 0) return firstLine(JSON.stringify(failed[0]?.errors ?? failed[0]))
789 if (!Array.isArray(result?.data) || result.data.length === 0) return result?.summary ?? 'not updated'
790 return null
791}
792
793// 프로젝트(프로젝트 탭에서는 섹션)별로 묶는다(처음 나온 차례). 이름이 없는 것은 맨 끝 한 묶음.
794export function groupTasks(tasks: readonly AsanaTask[], by: 'project' | 'section' = 'project'): Array<{ name: string | null; tasks: AsanaTask[] }> {
795 const groups = new Map<string | null, AsanaTask[]>()
796 for (const task of tasks) groups.set(task[by], [...(groups.get(task[by]) ?? []), task])
797 const named = [...groups].filter(([name]) => name !== null)
798 const loose = groups.get(null)
799 return [...named, ...(loose ? [[null, loose] as const] : [])].map(([name, list]) => ({ name, tasks: list }))
800}
801
802// 마감일 칩: 지난 것은 빨강, 오늘은 노랑, 나머지는 흐리게 "10/20"(올해가 아니면 연도까지).
803export function dueChip(dueOn: string | null, today: string): { text: string; level: 'danger' | 'warning' | 'muted' } | null {
804 if (!dueOn || !/^\d{4}-\d{2}-\d{2}$/.test(dueOn)) return null
805 if (dueOn === today) return { text: T.asanaToday, level: 'warning' }
806 const [year, month, day] = dueOn.split('-')
807 const text = year === today.slice(0, 4) ? `${Number(month)}/${Number(day)}` : `${year}/${Number(month)}/${Number(day)}`
808 return { text, level: dueOn < today ? 'danger' : 'muted' }
809}
810
811// 로컬 날짜(YYYY-MM-DD). Asana 의 due_on 은 날짜만이라 사람 시간대의 오늘과 견준다.
812export function localDay(now: number): string {
813 const at = new Date(now)
814 const pad = (n: number) => String(n).padStart(2, '0')
815 return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`
816}
817
818// "Claude에게 맡기기": 태스크 제목·링크·상태·설명과 최근 댓글 몇 개를 입력창에 채운다.
819export function asanaPrompt(task: AsanaDetail): string {
820 const quote = (text: string, limit: number) =>
821 text
822 .trim()
823 .slice(0, limit)
824 .split('\n')
825 .map(line => `> ${line}`)
826 .join('\n')
827 const head = [T.asanaPromptHead(task.name, task.key ?? null), task.url, [task.status, ...task.places].filter(Boolean).join(' · ')].filter(Boolean).join('\n')
828 const notes = task.notes.trim() === '' ? '' : `\n\n${quote(task.notes, 3000)}`
829 const recent = task.comments.slice(-5)
830 const comments = recent.length === 0 ? '' : `\n\n${T.asanaPromptComments}\n${recent.map(c => `- ${c.author}: ${c.text.trim().slice(0, 600).replaceAll('\n', ' ')}`).join('\n')}`
831 return `${head}${notes}${comments}`
832}
833hooks/i18n.ts 195 lines1// 화면 문구 사전: 영어(기본)와 한국어. desk-look 이 고른 언어(desk-look.lang 상태)를 따르고, desk-look 이
2// 없으면 Claude Code 의 /config 언어(language)를 따른다. T 는 한 객체를 계속 쓰고 setLang 이 내용만 바꾼다.
3
4export type Lang = 'en' | 'ko'
5
6const ko = {
7 cancel: '취소',
8 // 여러 줄 입력칸 뒤 버튼: 쓴 글을 한 번에 비운다(엔진 칸의 ^U 는 한 줄씩만 지운다)
9 inputClear: '지우기',
10 foldAll: '모두 접기',
11 unfoldAll: '모두 펴기',
12 asanaNoServer: 'Asana MCP 서버가 연결돼 있지 않아요. /mcp 에서 Asana 를 연결하세요.',
13 asanaLoading: 'Asana 를 읽는 중이에요…',
14 asanaFailed: (reason: string) => `Asana 를 읽지 못했어요: ${reason}`,
15 asanaEmpty: '할당된 열린 태스크가 없어요.',
16 asanaMine: (count: number) => `내 태스크 ${count}`,
17 asanaRecentDone: (days: number, count: number) => `최근 ${days}일 완료 · ${count}`,
18 asanaNoProject: '프로젝트 없음',
19 asanaUnruled: '허용 규칙이 없어 Claude Code 가 패널의 Asana 호출을 물어보거나 막아요(auto mode). ~/.claude/settings.json 의 permissions.allow 에 넣으세요:',
20 asanaTabMine: '내 태스크',
21 asanaTabProjects: '프로젝트',
22 asanaMyProjects: (count: number) => `내 프로젝트 ${count}`,
23 asanaNoProjects: '멤버로 들어간 프로젝트가 없어요.',
24 asanaProjectMeta: (members: number, open: number | null) => (open === null ? `멤버 ${members}` : `멤버 ${members} · 열린 ${open}`),
25 asanaProjectsBack: '← 프로젝트',
26 asanaOpenCount: (count: number) => `열린 태스크 ${count}`,
27 asanaProjectEmpty: '열린 태스크가 없어요.',
28 asanaNoSection: '섹션 없음',
29 asanaProjectPartial: (count: number) => `처음 ${count}개만 읽었어요. 나머지는 Asana 에서.`,
30 asanaSearchLabel: '검색',
31 asanaSearchPlaceholder: '워크스페이스의 열린 태스크를 이름·설명·댓글로',
32 asanaSearchPlaceholderKey: (fields: string) => `워크스페이스의 열린 태스크를 이름·설명·댓글로, 또는 ${fields} 키로`,
33 asanaSearching: '찾는 중…',
34 asanaNoMatch: '맞는 열린 태스크가 없어요.',
35 asanaFound: (query: string, count: number) => `검색 "${query}" ${count}`,
36 asanaSearchClear: '✕ 내 태스크',
37 asanaBack: '← 목록',
38 asanaDone: '✓ 완료',
39 asanaOpenOn: 'Asana에서 열기',
40 asanaAssignee: '담당',
41 asanaNobody: '없음',
42 asanaChange: '바꾸기',
43 asanaFindLabel: '이름',
44 asanaFindPlaceholder: '이름이나 이메일로 찾기',
45 asanaFind: '찾기',
46 asanaMe: '나',
47 asanaFinding: '찾는 중…',
48 asanaFindFailed: '사람을 찾지 못했어요.',
49 asanaNoPeople: '맞는 사람이 없어요.',
50 asanaAssignConfirm: (name: string) => `${name} 에게 넘길까요? 다시 누르세요`,
51 asanaAssigned: (name: string) => `${name} 에게 할당했어요`,
52 asanaUpdateFailed: (reason: string) => `바꾸지 못했어요: ${reason}`,
53 asanaDueLabel: '마감',
54 asanaToday: '오늘',
55 asanaComments: (count: number) => `댓글 ${count}`,
56 asanaCommentLabel: '댓글',
57 asanaCommentPlaceholder: 'Asana 에 남길 댓글',
58 asanaSend: '보내기',
59 asanaMention: '@ 멘션',
60 asanaEdit: '✎ 고치기',
61 asanaEditLabel: '고치기',
62 asanaEditSave: '저장',
63 asanaEdited: '댓글을 고쳤어요',
64 asanaEditFailed: (reason: string) => `댓글을 고치지 못했어요: ${reason}`,
65 asanaEditNotMine: '내가 쓴 댓글만 고칠 수 있어요',
66 asanaEditFormatted: '목록 기호·이미지·멘션 글자 같은 서식 자리는 여기서 못 바꿔요. 그 부분은 Asana 에서 고치세요',
67 asanaImage: '[이미지]',
68 asanaTokenRejected: 'Asana 개인 토큰이 맞지 않아요(만료·취소). 새로 넣으세요',
69 asanaEditWhat: '댓글 고치기',
70 asanaStatusWhat: '상태 바꾸기',
71 asanaNeedToken: (what: string) =>
72 `${what}는 Asana 개인 토큰(PAT)이 있어야 해요. Asana MCP 서버로는 할 수 없어서예요.\n1. https://app.asana.com/0/my-apps 에서 Personal access token 을 만든다\n2. /plugin configure desk-asana@cc-desktop-mod 의 Asana personal access token 에 넣고 Save configuration(저장하면 바로 적용돼요)`,
73 asanaCommented: '댓글을 달았어요',
74 asanaCommentFailed: (reason: string) => `댓글을 달지 못했어요: ${reason}`,
75 asanaPromptHead: (name: string, key: string | null) => `Asana 태스크 ${key === null ? '' : `${key} `}"${name}" 을(를) 함께 봐 주세요.`,
76 asanaPromptComments: '최근 댓글:',
77 handToClaude: 'Claude에게 맡기기',
78 // Asana 패널
79 cmdAsana: '내게 할당된 Asana 태스크와 내 프로젝트의 섹션별 태스크를 패널로 연다: 상세·댓글·댓글 달기·담당자·드롭다운 필드 (Asana MCP 서버 필요)',
80 asanaOpened: 'Asana 패널을 열었습니다.',
81 justNow: '방금',
82 minutesShort: (count: number) => `${count}분`,
83 hoursShort: (count: number) => `${count}시간`,
84 daysShort: (count: number) => `${count}일`,
85 filled: '입력창에 채웠어요. 고쳐서 Enter 로 보내세요.',
86 fillFailed: '입력창에 채우지 못했어요.',
87}
88
89export type Strings = typeof ko
90
91const en: Strings = {
92 cancel: 'Cancel',
93 inputClear: 'Clear',
94 foldAll: 'Collapse all',
95 unfoldAll: 'Expand all',
96 asanaNoServer: 'No Asana MCP server is connected. Connect Asana in /mcp.',
97 asanaLoading: 'Reading Asana…',
98 asanaFailed: reason => `Could not read Asana: ${reason}`,
99 asanaEmpty: 'No open tasks are assigned to you.',
100 asanaMine: count => `My tasks ${count}`,
101 asanaRecentDone: (days, count) => `Done in the last ${days} days · ${count}`,
102 asanaNoProject: 'No project',
103 asanaUnruled: 'With no allow rule, Claude Code asks about the pane\'s Asana calls (auto mode refuses them). Add these to permissions.allow in ~/.claude/settings.json:',
104 asanaTabMine: 'My tasks',
105 asanaTabProjects: 'Projects',
106 asanaMyProjects: count => `My projects ${count}`,
107 asanaNoProjects: 'You are not a member of any project.',
108 asanaProjectMeta: (members, open) => {
109 const people = members === 1 ? '1 member' : `${members} members`
110 return open === null ? people : `${people} · ${open} open`
111 },
112 asanaProjectsBack: '← Projects',
113 asanaOpenCount: count => `${count} open`,
114 asanaProjectEmpty: 'No open tasks.',
115 asanaNoSection: 'No section',
116 asanaProjectPartial: count => `Only the first ${count} were read; see the rest on Asana.`,
117 asanaSearchLabel: 'Search',
118 asanaSearchPlaceholder: 'Open tasks in the workspace, by name, description or comment',
119 asanaSearchPlaceholderKey: fields => `Open tasks in the workspace, by name, description or comment, or by ${fields} key`,
120 asanaSearching: 'Searching…',
121 asanaNoMatch: 'No open task matches.',
122 asanaFound: (query, count) => `Search "${query}" ${count}`,
123 asanaSearchClear: '✕ My tasks',
124 asanaBack: '← Tasks',
125 asanaDone: '✓ Done',
126 asanaOpenOn: 'Open on Asana',
127 asanaAssignee: 'Assignee',
128 asanaNobody: 'None',
129 asanaChange: 'Change',
130 asanaFindLabel: 'Name',
131 asanaFindPlaceholder: 'Find by name or email',
132 asanaFind: 'Find',
133 asanaMe: 'Me',
134 asanaFinding: 'Finding…',
135 asanaFindFailed: 'Could not look people up.',
136 asanaNoPeople: 'Nobody matches.',
137 asanaAssignConfirm: name => `Hand it to ${name}? Press again`,
138 asanaAssigned: name => `Assigned to ${name}`,
139 asanaUpdateFailed: reason => `Could not change it: ${reason}`,
140 asanaDueLabel: 'Due',
141 asanaToday: 'today',
142 asanaComments: count => `Comments ${count}`,
143 asanaCommentLabel: 'Comment',
144 asanaCommentPlaceholder: 'A comment to leave on Asana',
145 asanaSend: 'Send',
146 asanaMention: '@ Mention',
147 asanaEdit: '✎ Edit',
148 asanaEditLabel: 'Edit',
149 asanaEditSave: 'Save',
150 asanaEdited: 'Comment edited',
151 asanaEditFailed: reason => `Could not edit the comment: ${reason}`,
152 asanaEditNotMine: 'Only your own comments can be edited',
153 asanaEditFormatted: 'List marks, images and the letters of a mention cannot be changed here; change those on Asana',
154 asanaImage: '[image]',
155 asanaTokenRejected: 'The Asana personal access token was refused (expired or revoked). Put in a new one',
156 asanaEditWhat: 'Editing comments',
157 asanaStatusWhat: 'Changing the status',
158 asanaNeedToken: what =>
159 `${what} needs an Asana personal access token (PAT): the Asana MCP servers cannot do it.\n1. Create a personal access token at https://app.asana.com/0/my-apps\n2. Put it in Asana personal access token under /plugin configure desk-asana@cc-desktop-mod and Save configuration (it applies at once)`,
160 asanaCommented: 'Comment added',
161 asanaCommentFailed: reason => `Could not add the comment: ${reason}`,
162 asanaPromptHead: (name, key) => `Let's look at the Asana task ${key === null ? '' : `${key} `}"${name}" together.`,
163 asanaPromptComments: 'Recent comments:',
164 handToClaude: 'Hand to Claude',
165 // Asana pane
166 cmdAsana: 'Open the Asana tasks assigned to you, and your projects\' tasks by section, in a pane: details, comments, adding a comment, assignee and dropdown fields (needs an Asana MCP server)',
167 asanaOpened: 'Opened the Asana pane.',
168 justNow: 'now',
169 minutesShort: count => `${count}m`,
170 hoursShort: count => `${count}h`,
171 daysShort: count => `${count}d`,
172 filled: 'Put it in the prompt. Edit it and press Enter to send.',
173 fillFailed: "Couldn't fill the prompt.",
174}
175
176export const T: Strings = { ...en }
177let current: Lang = 'en'
178
179export function setLang(lang: Lang): boolean {
180 if (lang === current) return false
181 current = lang
182 Object.assign(T, lang === 'ko' ? ko : en)
183 return true
184}
185
186// Claude Code /config 의 language 값(자유 글: "Korean", "한국어", "ko" …). 모르면 영어.
187export function langFromConfig(value: unknown): Lang {
188 return typeof value === 'string' && /korean|한국|^ko(\b|[-_])/i.test(value.trim()) ? 'ko' : 'en'
189}
190
191// 설정 language: en·ko 면 그것, 그 밖은 auto(desk-look 의 언어, 없으면 Claude Code /config 의 language).
192export function langSetting(options: Record<string, unknown>): Lang | 'auto' {
193 return options.language === 'en' || options.language === 'ko' ? options.language : 'auto'
194}
195hooks/shared/markdown.tsx 554 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
20export type 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
211// \ 뒤 문장부호는 글자 그대로(CommonMark). 링크 글자에도 \] 가 들어갈 수 있다.
212const INLINE = /\\([!-/:-@[-`{-~])|`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|~~(.+?)~~|\*([^*\s][^*]*?)\*|\[((?:\\.|[^\]\\])+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)|<(https?:\/\/[^>\s]+)>|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/
213
214export function parseInline(text: string): Inline[] {
215 const out: Inline[] = []
216 let rest = text
217 while (rest.length > 0) {
218 const match = INLINE.exec(rest)
219 if (!match) {
220 out.push({ kind: 'text', text: rest })
221 break
222 }
223 if (match.index > 0) {
224 out.push({ kind: 'text', text: rest.slice(0, match.index) })
225 }
226 const [whole, escaped, code, bold1, bold2, strike, italic, linkText, linkHref, angleUrl, bareUrl] = match
227 if (escaped !== undefined) out.push({ kind: 'text', text: escaped })
228 else if (code !== undefined) out.push({ kind: 'code', text: code })
229 else if (bold1 !== undefined || bold2 !== undefined) out.push({ kind: 'bold', children: parseInline(bold1 ?? bold2 ?? '') })
230 else if (strike !== undefined) out.push({ kind: 'strike', children: parseInline(strike) })
231 else if (italic !== undefined) out.push({ kind: 'italic', children: parseInline(italic) })
232 else if (linkText !== undefined && linkHref !== undefined) out.push({ kind: 'link', href: linkHref, children: parseInline(linkText) })
233 else {
234 const url = angleUrl ?? bareUrl ?? whole
235 out.push({ kind: 'link', href: url, children: [{ kind: 'text', text: url }] })
236 }
237 rest = rest.slice(match.index + whole.length)
238 }
239 return out
240}
241
242// 엔진 Link 는 줄이 넘어가면 첫 줄에만 걸린다(2.1.302). 주소가 곧 글자인 링크는 한 줄에 들어가게
243// 가운데를 줄여 그린다: 줄바꿈은 단어 단위라 이 길이면 통째로 다음 줄로 넘어간다.
244const URL_LABEL = 40
245const isUrl = (text: string) => /^https?:\/\/\S+$/.test(text)
246
247export function urlLabel(url: string): string {
248 if (cellWidth(url) <= URL_LABEL) return url
249 const bare = url.replace(/^https?:\/\//, '')
250 return bare.length <= URL_LABEL ? bare : `${bare.slice(0, 24)}…${bare.slice(-15)}`
251}
252
253// 링크의 글자가 주소 그대로면 줄인 문구를 돌려준다.
254function linkLabel(node: Extract<Inline, { kind: 'link' }>): string | null {
255 const [only] = node.children
256 return node.children.length === 1 && only?.kind === 'text' && only.text === node.href && isUrl(node.href) ? urlLabel(node.href) : null
257}
258
259// 그려질 칸 수. 바탕을 칠한 인라인 코드는 앞뒤 공백 한 칸씩이 더 붙는다.
260export function inlineWidth(nodes: Inline[], codePadding: number): number {
261 return nodes.reduce(
262 (sum, node) =>
263 sum +
264 (node.kind === 'text'
265 ? cellWidth(node.text)
266 : node.kind === 'code'
267 ? cellWidth(isUrl(node.text) ? urlLabel(node.text) : node.text) + codePadding * 2
268 : node.kind === 'link' && linkLabel(node) !== null
269 ? cellWidth(linkLabel(node) ?? '')
270 : inlineWidth(node.children, codePadding)),
271 0,
272 )
273}
274
275export function plainText(nodes: Inline[]): string {
276 return nodes
277 .map(node => (node.kind === 'text' || node.kind === 'code' ? node.text : plainText(node.children)))
278 .join('')
279}
280
281// 표 열 너비: 자연 너비가 들어가면 그대로, 넘치면 좁은 열은 지키고 넓은 열부터 줄인다.
282export function columnWidths(natural: number[], room: number, fill = false): number[] {
283 const padding = 2
284 const total = natural.reduce((sum, width) => sum + width + padding, 0)
285 if (total <= room) {
286 if (!fill) return natural
287 const content = natural.reduce((sum, width) => sum + width, 0)
288 const extra = room - total
289 const grown = natural.map(width => width + Math.floor((extra * width) / Math.max(1, content)))
290 const last = grown.length - 1
291 grown[last] = (grown[last] ?? 0) + room - grown.reduce((sum, width) => sum + width + padding, 0)
292 return grown
293 }
294 let budget = Math.max(natural.length * (4 + padding), room) - natural.length * padding
295 const widths = natural.map(() => 0)
296 let open = natural.map((_, index) => index)
297 while (open.length > 0) {
298 const share = Math.floor(budget / open.length)
299 const fits = open.filter(index => (natural[index] ?? 0) <= share)
300 if (fits.length === 0) {
301 open.forEach((index, n) => (widths[index] = Math.max(4, share + (n < budget - share * open.length ? 1 : 0))))
302 break
303 }
304 for (const index of fits) {
305 widths[index] = natural[index] ?? 0
306 budget -= natural[index] ?? 0
307 }
308 open = open.filter(index => !fits.includes(index))
309 }
310 return widths
311}
312
313type Els = Elements['terminal']
314
315export const ALERT = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*\n?/i
316
317export const ALERTS: Record<string, { title: string; color: string }> = {
318 NOTE: { title: 'ⓘ Note', color: 'suggestion' },
319 TIP: { title: '✓ Tip', color: 'success' },
320 IMPORTANT: { title: '! Important', color: 'claude' },
321 WARNING: { title: '⚠ Warning', color: 'warning' },
322 CAUTION: { title: '✕ Caution', color: 'error' },
323}
324
325// 파일 맨 앞의 YAML front matter(--- … ---)를 떼어 낸다. 키: 값 줄만 모으고, 닫는 줄이 없으면 본문 그대로.
326export function splitFrontMatter(markdown: string): { fields: [string, string][]; body: string } {
327 const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
328 if (lines[0]?.trim() !== '---') return { fields: [], body: markdown }
329 const end = lines.findIndex((line, index) => index > 0 && /^(---|\.\.\.)\s*$/.test(line))
330 if (end < 0) return { fields: [], body: markdown }
331 const fields = lines
332 .slice(1, end)
333 .map(line => /^([^\s:#][^:]*):\s*(.*)$/.exec(line))
334 .filter((match): match is RegExpExecArray => match !== null)
335 .map(match => [match[1] ?? '', (match[2] ?? '').replace(/^(["'])(.*)\1$/, '$2')] as [string, string])
336 return { fields, body: lines.slice(end + 1).join('\n') }
337}
338
339// onCopy 가 있으면 코드 블록 위 테두리 오른쪽에 마우스를 올렸을 때만 보이는 "⧉ copy" 버튼을 단다.
340// document 는 파일 한 편을 읽는 패널용: 제목 단계를 색·밑줄로 나누고 front matter 를 흐린 머리로 접는다.
341export type MarkdownOptions = { onCopy?: (source: string, press: UiPressArgument) => void; document?: boolean }
342
343export function renderMarkdown(markdown: string, els: Els, colors: Palette, columns: number, options: MarkdownOptions = {}) {
344 const { Box, Text, Code, Link, Button } = els
345 const codePadding = colors.codeBg ? 1 : 0
346
347 const inline = (nodes: Inline[], key: string): (string | JSX.Element)[] =>
348 nodes.map((node, index) => {
349 const k = `${key}.${index}`
350 switch (node.kind) {
351 case 'text':
352 return node.text
353 case 'code': {
354 // 백틱 안 주소도 누를 수 있게(엔진 Markdown 처럼).
355 const text = isUrl(node.text) ? <Link href={node.text}>{urlLabel(node.text)}</Link> : node.text
356 return colors.codeBg ? (
357 <Text color={colors.code} backgroundColor={colors.codeBg}>
358 {' '}
359 {text}{' '}
360 </Text>
361 ) : (
362 <Text color={colors.code}>{text}</Text>
363 )
364 }
365 case 'bold':
366 return <Text weight="semibold">{inline(node.children, k)}</Text>
367 case 'italic':
368 return <Text italic>{inline(node.children, k)}</Text>
369 case 'strike':
370 return <Text strikethrough>{inline(node.children, k)}</Text>
371 case 'link':
372 return (
373 <Text color={colors.link} underline>
374 <Link href={node.href}>{linkLabel(node) ?? inline(node.children, k)}</Link>
375 </Text>
376 )
377 }
378 })
379
380 const prose = (text: string, key: string, props: Record<string, unknown> = {}) => (
381 <Text color={colors.text} {...props}>
382 {inline(parseInline(text), key)}
383 </Text>
384 )
385
386 const table = (block: Extract<Block, { kind: 'table' }>, key: string, room: number) => {
387 const count = Math.max(block.header.length, ...block.rows.map(row => row.length))
388 const cell = (row: string[], index: number) => row[index] ?? ''
389 const natural = Array.from({ length: count }, (_, index) =>
390 Math.max(
391 1,
392 inlineWidth(parseInline(cell(block.header, index)), codePadding),
393 ...block.rows.map(row => inlineWidth(parseInline(cell(row, index)), codePadding)),
394 ),
395 )
396 // 데스크톱 메시지 열처럼 폭을 채우되, 아주 넓은 터미널에서 늘어지지 않게 100칸에서 멈춘다.
397 const widths = columnWidths(natural, Math.min(room, 100) - 2, true)
398 const inner = widths.reduce((sum, width) => sum + width + 2, 0)
399 const justify = (index: number) =>
400 block.align[index] === 'right' ? 'flex-end' : block.align[index] === 'center' ? 'center' : 'flex-start'
401 const headerFill = colors.codeBg ? { backgroundColor: colors.codeBg } : {}
402 const row = (cells: string[], rowKey: string, isHeader: boolean) => (
403 <Box flexDirection="row" {...(isHeader ? headerFill : {})}>
404 {widths.map((width, index) => (
405 <Box width={width + 2} paddingX={1} justifyContent={justify(index)} {...(isHeader ? headerFill : {})}>
406 {prose(cell(cells, index), `${rowKey}.${index}`, isHeader ? (colors.codeBg ? headerFill : { weight: 'semibold' }) : {})}
407 </Box>
408 ))}
409 </Box>
410 )
411 const rule = <Text color={colors.border}>{'─'.repeat(inner)}</Text>
412
413 return (
414 <Box flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={colors.border}>
415 {row(block.header, `${key}.h`, true)}
416 {block.rows.flatMap((cells, index) => [rule, row(cells, `${key}.${index}`, false)])}
417 </Box>
418 )
419 }
420
421 const blocks = (list: Block[], key: string, room: number, tone?: string): JSX.Element[] =>
422 list.map((block, index) => {
423 const k = `${key}.${index}`
424 const gap = index === 0 ? 0 : 1
425 const toned = tone ? { color: tone } : {}
426 switch (block.kind) {
427 case 'heading':
428 // 문서: H1 은 강조색, H1·H2 는 아래에 가로줄. 위는 한 줄 더 띄워 절이 갈리게 한다.
429 if (options.document && !tone) {
430 const underline = block.level <= 2
431 return (
432 <Box marginTop={index === 0 ? 0 : 2} flexDirection="column">
433 {prose(block.text, k, { weight: 'semibold', ...(block.level === 1 ? { color: colors.accent } : {}) })}
434 {underline && <Text color={colors.border}>{'─'.repeat(Math.max(4, Math.min(room, 100)))}</Text>}
435 </Box>
436 )
437 }
438 return <Box marginTop={gap}>{prose(block.text, k, { weight: 'semibold', ...toned })}</Box>
439 case 'paragraph':
440 return <Box marginTop={gap}>{prose(block.text, k, toned)}</Box>
441 case 'rule':
442 return (
443 <Box marginTop={gap}>
444 <Text color={colors.border}>{'─'.repeat(Math.max(4, Math.min(room, 80)))}</Text>
445 </Box>
446 )
447 // mermaid 는 박스 문자 그림으로 그린다(lovely-mermaid). 폭이 모자라거나 못 그리는 문법이면 코드 블록으로.
448 case 'code':
449 if (block.language === 'mermaid') {
450 const diagram = renderDiagram(block.source)
451 if (diagram !== null && diagram.width + 6 <= room) {
452 const tone = { border: colors.muted, line: colors.muted, arrow: colors.accent, label: colors.muted, text: colors.text }
453 return (
454 <Box marginTop={gap} flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={colors.border} paddingX={2} paddingY={1}>
455 {diagram.rows.map(row => (
456 <Text wrap="truncate-end">
457 {row.length === 0
458 ? ' '
459 : row.map(seg =>
460 seg.href ? (
461 <Text color={colors.link} underline>
462 <Link href={seg.href}>{seg.text}</Link>
463 </Text>
464 ) : (
465 <Text color={tone[seg.kind]} italic={seg.kind === 'label'}>
466 {seg.text}
467 </Text>
468 ),
469 )}
470 </Text>
471 ))}
472 </Box>
473 )
474 }
475 }
476 {
477 const code = (
478 <Box flexDirection="column" borderStyle="round" borderColor={colors.border} paddingX={1}>
479 <Code source={block.source} language={block.language} wrap="wrap" />
480 </Box>
481 )
482 const onCopy = options.onCopy
483 if (!onCopy) return <Box marginTop={gap}>{code}</Box>
484 return (
485 <Box key={`code${k}`} marginTop={gap} flexDirection="column">
486 {code}
487 <Box position="absolute" top={0} right={2} display="none" hover={{ display: 'flex' }}>
488 <Button key={`copy${k}`} plain dimColor label=" ⧉ copy " onPress={press => onCopy(block.source, press)} />
489 </Box>
490 </Box>
491 )
492 }
493 case 'table':
494 return <Box marginTop={gap}>{table(block, k, room)}</Box>
495 case 'quote':
496 // GitHub 알림(> [!NOTE] 등): 종류 색 테두리 + 제목 줄.
497 {
498 const alert = ALERT.exec(block.raw)
499 const style = alert ? ALERTS[(alert[1] ?? '').toUpperCase()] : undefined
500 if (alert && style) {
501 return (
502 <Box marginTop={gap} flexDirection="column" borderStyle="round" borderColor={style.color} paddingX={1}>
503 <Text color={style.color} weight="semibold">
504 {style.title}
505 </Text>
506 {blocks(parseBlocks(block.raw.slice(alert[0].length)), k, room - 4)}
507 </Box>
508 )
509 }
510 }
511 return (
512 <Box marginTop={gap} borderStyle="quote" borderColor={colors.border} paddingLeft={1} flexDirection="column">
513 {blocks(block.blocks, k, room - 2, colors.muted)}
514 </Box>
515 )
516 case 'list':
517 return (
518 <Box marginTop={gap} flexDirection="column">
519 {block.items.map((item, n) => (
520 <Box flexDirection="row" paddingLeft={item.level * 2}>
521 <Box width={cellWidth(item.marker) + 1} flexShrink={0}>
522 <Text color={colors.muted}>{item.marker}</Text>
523 </Box>
524 {prose(item.text, `${k}.${n}`, toned)}
525 </Box>
526 ))}
527 </Box>
528 )
529 }
530 })
531
532 const room = Math.max(20, columns)
533 if (!options.document) {
534 return <Box flexDirection="column">{blocks(parseBlocks(markdown), 'md', room)}</Box>
535 }
536 const { fields, body } = splitFrontMatter(markdown)
537 const keyWidth = Math.max(0, ...fields.map(([name]) => cellWidth(name)))
538 return (
539 <Box flexDirection="column">
540 {fields.length > 0 && (
541 <Box flexDirection="column" marginBottom={1} borderStyle="round" borderColor={colors.border} paddingX={1} alignSelf="flex-start">
542 {fields.map(([name, value]) => (
543 <Text>
544 <Text color={colors.muted}>{name + ' '.repeat(keyWidth - cellWidth(name) + 2)}</Text>
545 <Text color={colors.text}>{value}</Text>
546 </Text>
547 ))}
548 </Box>
549 )}
550 {blocks(parseBlocks(body), 'md', room)}
551 </Box>
552 )
553}
554hooks/util.tsx 84 lines1import type { Elements, EngineInterface, TextHoverProps, UiPressArgument } from 'claude-code'
2import { T } from './i18n'
3
4// desk-look 에서 함께 쓰던 작은 도우미를 그대로 옮겨 온 것. 원본은 desk-slack/hooks/util.tsx 이고
5// scripts/sync-shared.sh 가 desk-asana 로 복사한다(cellWidth 는 desk-look markdown.tsx 와 같아야 한다).
6// 플러그인은 자기 디렉터리만 설치되므로 desk-look 의 파일을 가져올 수 없다.
7
8// 데스크톱 앱 CDS 토큰의 역할을 엔진 테마 키에 대응시킨다(desk-look 과 같은 색).
9export const COLORS = {
10 text: 'text',
11 muted: 'inactive',
12 border: 'subtle',
13 clay: 'claude',
14 accent: 'claude',
15 link: 'suggestion',
16 code: 'error',
17 success: 'success',
18 danger: 'error',
19 warning: 'warning',
20} as const
21
22export type Els = Elements['terminal']
23
24// 터미널 칸 수: 한글·한자·전각·이모지는 두 칸.
25export function cellWidth(text: string): number {
26 let width = 0
27 for (const ch of text) {
28 const code = ch.codePointAt(0) ?? 0
29 const isWide =
30 (code >= 0x1100 && code <= 0x115f) ||
31 (code >= 0x2e80 && code <= 0xa4cf) ||
32 (code >= 0xac00 && code <= 0xd7a3) ||
33 (code >= 0xf900 && code <= 0xfaff) ||
34 (code >= 0xfe30 && code <= 0xfe4f) ||
35 (code >= 0xff00 && code <= 0xff60) ||
36 (code >= 0xffe0 && code <= 0xffe6) ||
37 (code >= 0x1f300 && code <= 0x1faff)
38 width += isWide ? 2 : 1
39 }
40 return width
41}
42
43// 칸 수 기준으로 자르고 말줄임표를 붙인다.
44export function clip(text: string, width: number): string {
45 if (cellWidth(text) <= width) return text
46 let out = ''
47 for (const ch of text) {
48 if (cellWidth(out + ch) > width - 1) break
49 out += ch
50 }
51 return `${out}…`
52}
53
54export function ago(at: number, now: number): string {
55 const minutes = Math.max(0, Math.floor((now - at) / 60_000))
56 if (minutes < 1) return T.justNow
57 if (minutes < 60) return T.minutesShort(minutes)
58 const hours = Math.floor(minutes / 60)
59 if (hours < 24) return T.hoursShort(hours)
60 return T.daysShort(Math.floor(hours / 24))
61}
62
63// 줄 전체가 눌리는 버튼: 라벨 뒤에 칩·흐린 설명(Text)을 함께 넣는다.
64type RowButtonProps = { key: string; dimColor?: boolean; hotkey?: string; hover?: TextHoverProps; onPress: (press: UiPressArgument) => void }
65
66export function rowButton(els: Els, props: RowButtonProps, label: string, ...rest: unknown[]) {
67 const { Button } = els
68 const parts = rest.filter(part => part !== null && part !== undefined && part !== false && part !== '')
69 return (
70 <Button plain {...props}>
71 {label}
72 {parts as never}
73 </Button>
74 )
75}
76
77// "Claude에게 맡기기": 입력창이 비어 있으면 채우고, 쓰던 글이 있으면 뒤에 붙인다.
78export async function fillPrompt($: EngineInterface, text: string, done = T.filled) {
79 const box = await $.prompt.read().catch(() => ({ text: '', cursor: 0 }))
80 const isEmpty = box.text.trim() === ''
81 const filled = await $.prompt.fill({ text: isEmpty ? text : `\n\n${text}`, mode: isEmpty ? 'replace' : 'append' }).catch(() => null)
82 $.ui.toast(filled?.isFilled ? done : T.fillFailed)
83}
84hooks/shared/diagram.ts 98 lines1import { cellWidth } from './markdown'
2import { render } from './vendor/lovely-mermaid.js'
3
4// mermaid 를 박스 문자 그림으로 그린다(lovely-mermaid). 렌더러가 칸마다 역할(border·text·edge·edgeLabel)을
5// 붙여 주므로, 테두리는 흐리게·글자는 본문색·화살표만 강조색으로 칠한다. 한글 폭도 렌더러가 맞춘다.
6
7export type Segment = { text: string; kind: 'border' | 'text' | 'line' | 'arrow' | 'label'; href?: string }
8
9export type Diagram = { rows: Segment[][]; width: number }
10
11type Cell = { ch: string; kind: Segment['kind']; href?: string; isContinuation?: boolean }
12
13// 일반 노드 상자는 둥근 모서리로. 분기 노드의 이중선(╔╗╚╝)은 구분되도록 그대로 둔다.
14const ROUNDED: Record<string, string> = { '┌': '╭', '┐': '╮', '└': '╰', '┘': '╯' }
15
16// 글꼴의 ▼ 는 칸 안에 여백을 두고 작게 그려져 선·상자와 떨어져 보인다. 화살표 칸은 선으로 잇고,
17// 그 앞 상자 테두리 칸에 Ghostty 가 칸 가장자리까지 그리는 삼각형 블록을 꽂는다.
18const HEADS: Record<string, { dy: number; dx: number; line: string; block: string }> = {
19 '▼': { dy: 1, dx: 0, line: '│', block: '\u{1FB6D}' },
20 '▲': { dy: -1, dx: 0, line: '│', block: '\u{1FB6F}' },
21 '▶': { dy: 0, dx: 1, line: '─', block: '\u{1FB6C}' },
22 '►': { dy: 0, dx: 1, line: '─', block: '\u{1FB6C}' },
23 '◀': { dy: 0, dx: -1, line: '─', block: '\u{1FB6E}' },
24 '◄': { dy: 0, dx: -1, line: '─', block: '\u{1FB6E}' },
25}
26
27const cache = new Map<string, Diagram | null>()
28
29function toCells(row: ReadonlyArray<{ text: string; role: string; href?: string }>): Cell[] {
30 const cells: Cell[] = []
31 for (const span of row) {
32 const kind: Cell['kind'] =
33 span.role === 'border' ? 'border' : span.role === 'edge' ? 'line' : span.role === 'edgeLabel' ? 'label' : 'text'
34 for (const ch of span.text) {
35 const cell: Cell = { ch, kind, ...(span.href ? { href: span.href } : {}) }
36 if (kind === 'border') cell.ch = ROUNDED[ch] ?? ch
37 if (kind === 'line' && HEADS[ch]) cell.kind = 'arrow'
38 cells.push(cell)
39 // 두 칸 글자 뒤에 자리만 차지하는 칸을 둬서 칸 좌표로 위아래 이웃을 찾을 수 있게 한다
40 if (cellWidth(ch) === 2) cells.push({ ch: '', kind, isContinuation: true })
41 }
42 }
43 return cells
44}
45
46export function plugArrows(grid: Cell[][]): void {
47 for (let y = 0; y < grid.length; y++) {
48 for (const [x, cell] of (grid[y] ?? []).entries()) {
49 const head = cell.kind === 'arrow' ? HEADS[cell.ch] : undefined
50 if (!head) continue
51 const next = grid[y + head.dy]?.[x + head.dx]
52 if (next && next.kind === 'border' && !next.isContinuation) {
53 next.ch = head.block
54 next.kind = 'arrow'
55 cell.ch = head.line
56 cell.kind = 'line'
57 } else {
58 cell.ch = head.block
59 }
60 }
61 }
62}
63
64function toSegments(cells: Cell[]): Segment[] {
65 const out: Segment[] = []
66 for (const cell of cells) {
67 if (cell.isContinuation) continue
68 const last = out[out.length - 1]
69 if (last && last.kind === cell.kind && last.href === cell.href) last.text += cell.ch
70 else out.push({ text: cell.ch, kind: cell.kind, ...(cell.href ? { href: cell.href } : {}) })
71 }
72 // 오른쪽 끝 공백은 칠할 필요가 없다
73 const last = out[out.length - 1]
74 if (last && last.kind === 'text' && last.text.trim() === '') out.pop()
75 return out
76}
77
78export function renderDiagram(source: string): Diagram | null {
79 const cached = cache.get(source)
80 if (cached !== undefined) return cached
81 let diagram: Diagram | null = null
82 try {
83 const art = render(source)
84 if (art && art.warnings.length === 0) {
85 const grid = art.styled.map(toCells)
86 plugArrows(grid)
87 const rows = grid.map(toSegments)
88 while (rows.length > 0 && (rows[rows.length - 1] ?? []).length === 0) rows.pop()
89 diagram = { rows, width: art.width }
90 }
91 } catch {
92 diagram = null
93 }
94 if (cache.size > 50) cache.clear()
95 cache.set(source, diagram)
96 return diagram
97}
98hooks/shared/vendor/lovely-mermaid.js 5117 lines1// lovely-mermaid@0.3.3 (Apache-2.0, https://github.com/xl0/lovely-mermaid) — render 만 bun build 로 묶은 파일(압축하지 않음: 디렉터리 보안 검사가 읽을 수 있게).
2// 다시 만들기: npm pack lovely-mermaid@0.3.3 → export { render } from '<package>/dist/index.js' 를 bun build --format=esm. 라이선스: ./LICENSE.lovely-mermaid
3// ../../../../../tmp/claude-1000/-home-sam-dev-git-repo-cc-desktop-mod/8ad3cbd6-8ea7-4498-b5d0-a4c2c4ac0105/scratchpad/lm-pkg/package/dist/width-data.js
4var WIDTHS = [
5 [0, 172, 1],
6 [173, 173, 0],
7 [174, 767, 1],
8 [768, 879, 0],
9 [880, 1154, 1],
10 [1155, 1161, 0],
11 [1162, 1424, 1],
12 [1425, 1469, 0],
13 [1470, 1470, 1],
14 [1471, 1471, 0],
15 [1472, 1472, 1],
16 [1473, 1474, 0],
17 [1475, 1475, 1],
18 [1476, 1477, 0],
19 [1478, 1478, 1],
20 [1479, 1479, 0],
21 [1480, 1540, 1],
22 [1541, 1541, 0],
23 [1542, 1551, 1],
24 [1552, 1562, 0],
25 [1563, 1563, 1],
26 [1564, 1564, 0],
27 [1565, 1610, 1],
28 [1611, 1631, 0],
29 [1632, 1647, 1],
30 [1648, 1648, 0],
31 [1649, 1749, 1],
32 [1750, 1756, 0],
33 [1757, 1758, 1],
34 [1759, 1764, 0],
35 [1765, 1766, 1],
36 [1767, 1768, 0],
37 [1769, 1769, 1],
38 [1770, 1773, 0],
39 [1774, 1806, 1],
40 [1807, 1807, 0],
41 [1808, 1808, 1],
42 [1809, 1809, 0],
43 [1810, 1839, 1],
44 [1840, 1866, 0],
45 [1867, 1957, 1],
46 [1958, 1968, 0],
47 [1969, 2026, 1],
48 [2027, 2035, 0],
49 [2036, 2044, 1],
50 [2045, 2045, 0],
51 [2046, 2069, 1],
52 [2070, 2073, 0],
53 [2074, 2074, 1],
54 [2075, 2083, 0],
55 [2084, 2084, 1],
56 [2085, 2087, 0],
57 [2088, 2088, 1],
58 [2089, 2093, 0],
59 [2094, 2136, 1],
60 [2137, 2139, 0],
61 [2140, 2191, 1],
62 [2192, 2193, 0],
63 [2194, 2199, 1],
64 [2200, 2207, 0],
65 [2208, 2249, 1],
66 [2250, 2306, 0],
67 [2307, 2361, 1],
68 [2362, 2362, 0],
69 [2363, 2363, 1],
70 [2364, 2364, 0],
71 [2365, 2368, 1],
72 [2369, 2376, 0],
73 [2377, 2380, 1],
74 [2381, 2381, 0],
75 [2382, 2384, 1],
76 [2385, 2391, 0],
77 [2392, 2401, 1],
78 [2402, 2403, 0],
79 [2404, 2432, 1],
80 [2433, 2433, 0],
81 [2434, 2491, 1],
82 [2492, 2492, 0],
83 [2493, 2493, 1],
84 [2494, 2494, 0],
85 [2495, 2496, 1],
86 [2497, 2500, 0],
87 [2501, 2508, 1],
88 [2509, 2509, 0],
89 [2510, 2518, 1],
90 [2519, 2519, 0],
91 [2520, 2529, 1],
92 [2530, 2531, 0],
93 [2532, 2557, 1],
94 [2558, 2558, 0],
95 [2559, 2560, 1],
96 [2561, 2562, 0],
97 [2563, 2619, 1],
98 [2620, 2620, 0],
99 [2621, 2624, 1],
100 [2625, 2626, 0],
101 [2627, 2630, 1],
102 [2631, 2632, 0],
103 [2633, 2634, 1],
104 [2635, 2637, 0],
105 [2638, 2640, 1],
106 [2641, 2641, 0],
107 [2642, 2671, 1],
108 [2672, 2673, 0],
109 [2674, 2676, 1],
110 [2677, 2677, 0],
111 [2678, 2688, 1],
112 [2689, 2690, 0],
113 [2691, 2747, 1],
114 [2748, 2748, 0],
115 [2749, 2752, 1],
116 [2753, 2757, 0],
117 [2758, 2758, 1],
118 [2759, 2760, 0],
119 [2761, 2764, 1],
120 [2765, 2765, 0],
121 [2766, 2785, 1],
122 [2786, 2787, 0],
123 [2788, 2809, 1],
124 [2810, 2815, 0],
125 [2816, 2816, 1],
126 [2817, 2817, 0],
127 [2818, 2875, 1],
128 [2876, 2876, 0],
129 [2877, 2877, 1],
130 [2878, 2879, 0],
131 [2880, 2880, 1],
132 [2881, 2884, 0],
133 [2885, 2892, 1],
134 [2893, 2893, 0],
135 [2894, 2900, 1],
136 [2901, 2903, 0],
137 [2904, 2913, 1],
138 [2914, 2915, 0],
139 [2916, 2945, 1],
140 [2946, 2946, 0],
141 [2947, 3005, 1],
142 [3006, 3006, 0],
143 [3007, 3007, 1],
144 [3008, 3008, 0],
145 [3009, 3020, 1],
146 [3021, 3021, 0],
147 [3022, 3030, 1],
148 [3031, 3031, 0],
149 [3032, 3071, 1],
150 [3072, 3072, 0],
151 [3073, 3075, 1],
152 [3076, 3076, 0],
153 [3077, 3131, 1],
154 [3132, 3132, 0],
155 [3133, 3133, 1],
156 [3134, 3136, 0],
157 [3137, 3141, 1],
158 [3142, 3144, 0],
159 [3145, 3145, 1],
160 [3146, 3149, 0],
161 [3150, 3156, 1],
162 [3157, 3158, 0],
163 [3159, 3169, 1],
164 [3170, 3171, 0],
165 [3172, 3200, 1],
166 [3201, 3201, 0],
167 [3202, 3259, 1],
168 [3260, 3260, 0],
169 [3261, 3262, 1],
170 [3263, 3264, 0],
171 [3265, 3265, 1],
172 [3266, 3266, 0],
173 [3267, 3269, 1],
174 [3270, 3272, 0],
175 [3273, 3273, 1],
176 [3274, 3277, 0],
177 [3278, 3284, 1],
178 [3285, 3286, 0],
179 [3287, 3297, 1],
180 [3298, 3299, 0],
181 [3300, 3327, 1],
182 [3328, 3329, 0],
183 [3330, 3386, 1],
184 [3387, 3388, 0],
185 [3389, 3389, 1],
186 [3390, 3390, 0],
187 [3391, 3392, 1],
188 [3393, 3396, 0],
189 [3397, 3404, 1],
190 [3405, 3406, 0],
191 [3407, 3414, 1],
192 [3415, 3415, 0],
193 [3416, 3425, 1],
194 [3426, 3427, 0],
195 [3428, 3456, 1],
196 [3457, 3457, 0],
197 [3458, 3529, 1],
198 [3530, 3530, 0],
199 [3531, 3534, 1],
200 [3535, 3535, 0],
201 [3536, 3537, 1],
202 [3538, 3540, 0],
203 [3541, 3541, 1],
204 [3542, 3542, 0],
205 [3543, 3550, 1],
206 [3551, 3551, 0],
207 [3552, 3632, 1],
208 [3633, 3633, 0],
209 [3634, 3635, 1],
210 [3636, 3642, 0],
211 [3643, 3654, 1],
212 [3655, 3662, 0],
213 [3663, 3760, 1],
214 [3761, 3761, 0],
215 [3762, 3763, 1],
216 [3764, 3772, 0],
217 [3773, 3783, 1],
218 [3784, 3790, 0],
219 [3791, 3863, 1],
220 [3864, 3865, 0],
221 [3866, 3892, 1],
222 [3893, 3893, 0],
223 [3894, 3894, 1],
224 [3895, 3895, 0],
225 [3896, 3896, 1],
226 [3897, 3897, 0],
227 [3898, 3952, 1],
228 [3953, 3966, 0],
229 [3967, 3967, 1],
230 [3968, 3972, 0],
231 [3973, 3973, 1],
232 [3974, 3975, 0],
233 [3976, 3980, 1],
234 [3981, 3991, 0],
235 [3992, 3992, 1],
236 [3993, 4028, 0],
237 [4029, 4037, 1],
238 [4038, 4038, 0],
239 [4039, 4140, 1],
240 [4141, 4144, 0],
241 [4145, 4145, 1],
242 [4146, 4151, 0],
243 [4152, 4152, 1],
244 [4153, 4154, 0],
245 [4155, 4156, 1],
246 [4157, 4158, 0],
247 [4159, 4183, 1],
248 [4184, 4185, 0],
249 [4186, 4189, 1],
250 [4190, 4192, 0],
251 [4193, 4208, 1],
252 [4209, 4212, 0],
253 [4213, 4225, 1],
254 [4226, 4226, 0],
255 [4227, 4228, 1],
256 [4229, 4230, 0],
257 [4231, 4236, 1],
258 [4237, 4237, 0],
259 [4238, 4252, 1],
260 [4253, 4253, 0],
261 [4254, 4351, 1],
262 [4352, 4447, 2],
263 [4448, 4607, 0],
264 [4608, 4956, 1],
265 [4957, 4959, 0],
266 [4960, 5905, 1],
267 [5906, 5908, 0],
268 [5909, 5937, 1],
269 [5938, 5939, 0],
270 [5940, 5969, 1],
271 [5970, 5971, 0],
272 [5972, 6001, 1],
273 [6002, 6003, 0],
274 [6004, 6051, 1],
275 [6052, 6052, 2],
276 [6053, 6067, 1],
277 [6068, 6069, 0],
278 [6070, 6070, 1],
279 [6071, 6077, 0],
280 [6078, 6085, 1],
281 [6086, 6086, 0],
282 [6087, 6088, 1],
283 [6089, 6099, 0],
284 [6100, 6103, 1],
285 [6104, 6104, 3],
286 [6105, 6108, 1],
287 [6109, 6109, 0],
288 [6110, 6154, 1],
289 [6155, 6159, 0],
290 [6160, 6276, 1],
291 [6277, 6278, 0],
292 [6279, 6312, 1],
293 [6313, 6313, 0],
294 [6314, 6431, 1],
295 [6432, 6434, 0],
296 [6435, 6438, 1],
297 [6439, 6440, 0],
298 [6441, 6449, 1],
299 [6450, 6450, 0],
300 [6451, 6456, 1],
301 [6457, 6459, 0],
302 [6460, 6678, 1],
303 [6679, 6680, 0],
304 [6681, 6682, 1],
305 [6683, 6683, 0],
306 [6684, 6741, 1],
307 [6742, 6742, 0],
308 [6743, 6743, 1],
309 [6744, 6750, 0],
310 [6751, 6751, 1],
311 [6752, 6752, 0],
312 [6753, 6753, 1],
313 [6754, 6754, 0],
314 [6755, 6756, 1],
315 [6757, 6764, 0],
316 [6765, 6770, 1],
317 [6771, 6780, 0],
318 [6781, 6782, 1],
319 [6783, 6783, 0],
320 [6784, 6831, 1],
321 [6832, 6862, 0],
322 [6863, 6911, 1],
323 [6912, 6915, 0],
324 [6916, 6963, 1],
325 [6964, 6973, 0],
326 [6974, 6977, 1],
327 [6978, 6979, 0],
328 [6980, 7018, 1],
329 [7019, 7027, 0],
330 [7028, 7039, 1],
331 [7040, 7041, 0],
332 [7042, 7073, 1],
333 [7074, 7077, 0],
334 [7078, 7079, 1],
335 [7080, 7081, 0],
336 [7082, 7082, 1],
337 [7083, 7085, 0],
338 [7086, 7141, 1],
339 [7142, 7142, 0],
340 [7143, 7143, 1],
341 [7144, 7145, 0],
342 [7146, 7148, 1],
343 [7149, 7149, 0],
344 [7150, 7150, 1],
345 [7151, 7153, 0],
346 [7154, 7211, 1],
347 [7212, 7219, 0],
348 [7220, 7221, 1],
349 [7222, 7223, 0],
350 [7224, 7375, 1],
351 [7376, 7378, 0],
352 [7379, 7379, 1],
353 [7380, 7392, 0],
354 [7393, 7393, 1],
355 [7394, 7400, 0],
356 [7401, 7404, 1],
357 [7405, 7405, 0],
358 [7406, 7411, 1],
359 [7412, 7412, 0],
360 [7413, 7415, 1],
361 [7416, 7417, 0],
362 [7418, 7615, 1],
363 [7616, 7679, 0],
364 [7680, 8202, 1],
365 [8203, 8207, 0],
366 [8208, 8233, 1],
367 [8234, 8238, 0],
368 [8239, 8287, 1],
369 [8288, 8303, 0],
370 [8304, 8399, 1],
371 [8400, 8432, 0],
372 [8433, 8985, 1],
373 [8986, 8987, 2],
374 [8988, 9000, 1],
375 [9001, 9002, 2],
376 [9003, 9192, 1],
377 [9193, 9196, 2],
378 [9197, 9199, 1],
379 [9200, 9200, 2],
380 [9201, 9202, 1],
381 [9203, 9203, 2],
382 [9204, 9724, 1],
383 [9725, 9726, 2],
384 [9727, 9747, 1],
385 [9748, 9749, 2],
386 [9750, 9799, 1],
387 [9800, 9811, 2],
388 [9812, 9854, 1],
389 [9855, 9855, 2],
390 [9856, 9874, 1],
391 [9875, 9875, 2],
392 [9876, 9888, 1],
393 [9889, 9889, 2],
394 [9890, 9897, 1],
395 [9898, 9899, 2],
396 [9900, 9916, 1],
397 [9917, 9918, 2],
398 [9919, 9923, 1],
399 [9924, 9925, 2],
400 [9926, 9933, 1],
401 [9934, 9934, 2],
402 [9935, 9939, 1],
403 [9940, 9940, 2],
404 [9941, 9961, 1],
405 [9962, 9962, 2],
406 [9963, 9969, 1],
407 [9970, 9971, 2],
408 [9972, 9972, 1],
409 [9973, 9973, 2],
410 [9974, 9977, 1],
411 [9978, 9978, 2],
412 [9979, 9980, 1],
413 [9981, 9981, 2],
414 [9982, 9988, 1],
415 [9989, 9989, 2],
416 [9990, 9993, 1],
417 [9994, 9995, 2],
418 [9996, 10023, 1],
419 [10024, 10024, 2],
420 [10025, 10059, 1],
421 [10060, 10060, 2],
422 [10061, 10061, 1],
423 [10062, 10062, 2],
424 [10063, 10066, 1],
425 [10067, 10069, 2],
426 [10070, 10070, 1],
427 [10071, 10071, 2],
428 [10072, 10132, 1],
429 [10133, 10135, 2],
430 [10136, 10159, 1],
431 [10160, 10160, 2],
432 [10161, 10174, 1],
433 [10175, 10175, 2],
434 [10176, 11034, 1],
435 [11035, 11036, 2],
436 [11037, 11087, 1],
437 [11088, 11088, 2],
438 [11089, 11092, 1],
439 [11093, 11093, 2],
440 [11094, 11502, 1],
441 [11503, 11505, 0],
442 [11506, 11743, 1],
443 [11744, 11775, 0],
444 [11776, 11903, 1],
445 [11904, 11929, 2],
446 [11930, 11930, 1],
447 [11931, 12019, 2],
448 [12020, 12031, 1],
449 [12032, 12245, 2],
450 [12246, 12271, 1],
451 [12272, 12329, 2],
452 [12330, 12335, 0],
453 [12336, 12350, 2],
454 [12351, 12352, 1],
455 [12353, 12438, 2],
456 [12439, 12440, 1],
457 [12441, 12442, 0],
458 [12443, 12543, 2],
459 [12544, 12548, 1],
460 [12549, 12591, 2],
461 [12592, 12592, 1],
462 [12593, 12643, 2],
463 [12644, 12644, 0],
464 [12645, 12686, 2],
465 [12687, 12687, 1],
466 [12688, 12771, 2],
467 [12772, 12782, 1],
468 [12783, 12830, 2],
469 [12831, 12831, 1],
470 [12832, 12871, 2],
471 [12872, 12879, 1],
472 [12880, 19903, 2],
473 [19904, 19967, 1],
474 [19968, 42124, 2],
475 [42125, 42127, 1],
476 [42128, 42182, 2],
477 [42183, 42606, 1],
478 [42607, 42610, 0],
479 [42611, 42611, 1],
480 [42612, 42621, 0],
481 [42622, 42653, 1],
482 [42654, 42655, 0],
483 [42656, 42735, 1],
484 [42736, 42737, 0],
485 [42738, 43009, 1],
486 [43010, 43010, 0],
487 [43011, 43013, 1],
488 [43014, 43014, 0],
489 [43015, 43018, 1],
490 [43019, 43019, 0],
491 [43020, 43044, 1],
492 [43045, 43046, 0],
493 [43047, 43051, 1],
494 [43052, 43052, 0],
495 [43053, 43203, 1],
496 [43204, 43205, 0],
497 [43206, 43231, 1],
498 [43232, 43249, 0],
499 [43250, 43257, 1],
500 [43258, 43258, 0],
501 [43259, 43262, 1],
502 [43263, 43263, 0],
503 [43264, 43301, 1],
504 [43302, 43309, 0],
505 [43310, 43334, 1],
506 [43335, 43345, 0],
507 [43346, 43359, 1],
508 [43360, 43388, 2],
509 [43389, 43391, 1],
510 [43392, 43394, 0],
511 [43395, 43442, 1],
512 [43443, 43443, 0],
513 [43444, 43445, 1],
514 [43446, 43449, 0],
515 [43450, 43451, 1],
516 [43452, 43453, 0],
517 [43454, 43492, 1],
518 [43493, 43493, 0],
519 [43494, 43560, 1],
520 [43561, 43566, 0],
521 [43567, 43568, 1],
522 [43569, 43570, 0],
523 [43571, 43572, 1],
524 [43573, 43574, 0],
525 [43575, 43586, 1],
526 [43587, 43587, 0],
527 [43588, 43595, 1],
528 [43596, 43596, 0],
529 [43597, 43643, 1],
530 [43644, 43644, 0],
531 [43645, 43695, 1],
532 [43696, 43696, 0],
533 [43697, 43697, 1],
534 [43698, 43700, 0],
535 [43701, 43702, 1],
536 [43703, 43704, 0],
537 [43705, 43709, 1],
538 [43710, 43711, 0],
539 [43712, 43712, 1],
540 [43713, 43713, 0],
541 [43714, 43755, 1],
542 [43756, 43757, 0],
543 [43758, 43765, 1],
544 [43766, 43766, 0],
545 [43767, 44004, 1],
546 [44005, 44005, 0],
547 [44006, 44007, 1],
548 [44008, 44008, 0],
549 [44009, 44012, 1],
550 [44013, 44013, 0],
551 [44014, 44031, 1],
552 [44032, 55203, 2],
553 [55204, 55215, 1],
554 [55216, 55238, 0],
555 [55239, 55242, 1],
556 [55243, 55291, 0],
557 [55292, 63743, 1],
558 [63744, 64255, 2],
559 [64256, 64285, 1],
560 [64286, 64286, 0],
561 [64287, 65023, 1],
562 [65024, 65039, 0],
563 [65040, 65049, 2],
564 [65050, 65055, 1],
565 [65056, 65071, 0],
566 [65072, 65106, 2],
567 [65107, 65107, 1],
568 [65108, 65126, 2],
569 [65127, 65127, 1],
570 [65128, 65131, 2],
571 [65132, 65278, 1],
572 [65279, 65279, 0],
573 [65280, 65280, 1],
574 [65281, 65376, 2],
575 [65377, 65437, 1],
576 [65438, 65440, 0],
577 [65441, 65503, 1],
578 [65504, 65510, 2],
579 [65511, 65519, 1],
580 [65520, 65528, 0],
581 [65529, 66044, 1],
582 [66045, 66045, 0],
583 [66046, 66271, 1],
584 [66272, 66272, 0],
585 [66273, 66421, 1],
586 [66422, 66426, 0],
587 [66427, 68096, 1],
588 [68097, 68099, 0],
589 [68100, 68100, 1],
590 [68101, 68102, 0],
591 [68103, 68107, 1],
592 [68108, 68111, 0],
593 [68112, 68151, 1],
594 [68152, 68154, 0],
595 [68155, 68158, 1],
596 [68159, 68159, 0],
597 [68160, 68324, 1],
598 [68325, 68326, 0],
599 [68327, 68899, 1],
600 [68900, 68903, 0],
601 [68904, 69290, 1],
602 [69291, 69292, 0],
603 [69293, 69372, 1],
604 [69373, 69375, 0],
605 [69376, 69445, 1],
606 [69446, 69456, 0],
607 [69457, 69505, 1],
608 [69506, 69509, 0],
609 [69510, 69632, 1],
610 [69633, 69633, 0],
611 [69634, 69687, 1],
612 [69688, 69702, 0],
613 [69703, 69743, 1],
614 [69744, 69744, 0],
615 [69745, 69746, 1],
616 [69747, 69748, 0],
617 [69749, 69758, 1],
618 [69759, 69761, 0],
619 [69762, 69810, 1],
620 [69811, 69814, 0],
621 [69815, 69816, 1],
622 [69817, 69818, 0],
623 [69819, 69825, 1],
624 [69826, 69826, 0],
625 [69827, 69887, 1],
626 [69888, 69890, 0],
627 [69891, 69926, 1],
628 [69927, 69931, 0],
629 [69932, 69932, 1],
630 [69933, 69940, 0],
631 [69941, 70002, 1],
632 [70003, 70003, 0],
633 [70004, 70015, 1],
634 [70016, 70017, 0],
635 [70018, 70069, 1],
636 [70070, 70078, 0],
637 [70079, 70081, 1],
638 [70082, 70083, 0],
639 [70084, 70088, 1],
640 [70089, 70092, 0],
641 [70093, 70094, 1],
642 [70095, 70095, 0],
643 [70096, 70190, 1],
644 [70191, 70193, 0],
645 [70194, 70195, 1],
646 [70196, 70196, 0],
647 [70197, 70197, 1],
648 [70198, 70199, 0],
649 [70200, 70205, 1],
650 [70206, 70206, 0],
651 [70207, 70208, 1],
652 [70209, 70209, 0],
653 [70210, 70366, 1],
654 [70367, 70367, 0],
655 [70368, 70370, 1],
656 [70371, 70378, 0],
657 [70379, 70399, 1],
658 [70400, 70401, 0],
659 [70402, 70458, 1],
660 [70459, 70460, 0],
661 [70461, 70461, 1],
662 [70462, 70462, 0],
663 [70463, 70463, 1],
664 [70464, 70464, 0],
665 [70465, 70486, 1],
666 [70487, 70487, 0],
667 [70488, 70501, 1],
668 [70502, 70508, 0],
669 [70509, 70511, 1],
670 [70512, 70516, 0],
671 [70517, 70711, 1],
672 [70712, 70719, 0],
673 [70720, 70721, 1],
674 [70722, 70724, 0],
675 [70725, 70725, 1],
676 [70726, 70726, 0],
677 [70727, 70749, 1],
678 [70750, 70750, 0],
679 [70751, 70831, 1],
680 [70832, 70832, 0],
681 [70833, 70834, 1],
682 [70835, 70840, 0],
683 [70841, 70841, 1],
684 [70842, 70842, 0],
685 [70843, 70844, 1],
686 [70845, 70845, 0],
687 [70846, 70846, 1],
688 [70847, 70848, 0],
689 [70849, 70849, 1],
690 [70850, 70851, 0],
691 [70852, 71086, 1],
692 [71087, 71087, 0],
693 [71088, 71089, 1],
694 [71090, 71093, 0],
695 [71094, 71099, 1],
696 [71100, 71101, 0],
697 [71102, 71102, 1],
698 [71103, 71104, 0],
699 [71105, 71131, 1],
700 [71132, 71133, 0],
701 [71134, 71218, 1],
702 [71219, 71226, 0],
703 [71227, 71228, 1],
704 [71229, 71229, 0],
705 [71230, 71230, 1],
706 [71231, 71232, 0],
707 [71233, 71338, 1],
708 [71339, 71339, 0],
709 [71340, 71340, 1],
710 [71341, 71341, 0],
711 [71342, 71343, 1],
712 [71344, 71349, 0],
713 [71350, 71350, 1],
714 [71351, 71351, 0],
715 [71352, 71452, 1],
716 [71453, 71455, 0],
717 [71456, 71457, 1],
718 [71458, 71461, 0],
719 [71462, 71462, 1],
720 [71463, 71467, 0],
721 [71468, 71726, 1],
722 [71727, 71735, 0],
723 [71736, 71736, 1],
724 [71737, 71738, 0],
725 [71739, 71983, 1],
726 [71984, 71984, 0],
727 [71985, 71994, 1],
728 [71995, 71996, 0],
729 [71997, 71997, 1],
730 [71998, 71999, 0],
731 [72000, 72000, 1],
732 [72001, 72001, 0],
733 [72002, 72002, 1],
734 [72003, 72003, 0],
735 [72004, 72147, 1],
736 [72148, 72151, 0],
737 [72152, 72153, 1],
738 [72154, 72155, 0],
739 [72156, 72159, 1],
740 [72160, 72160, 0],
741 [72161, 72192, 1],
742 [72193, 72202, 0],
743 [72203, 72242, 1],
744 [72243, 72248, 0],
745 [72249, 72249, 1],
746 [72250, 72254, 0],
747 [72255, 72262, 1],
748 [72263, 72263, 0],
749 [72264, 72272, 1],
750 [72273, 72278, 0],
751 [72279, 72280, 1],
752 [72281, 72283, 0],
753 [72284, 72323, 1],
754 [72324, 72342, 0],
755 [72343, 72343, 1],
756 [72344, 72345, 0],
757 [72346, 72751, 1],
758 [72752, 72758, 0],
759 [72759, 72759, 1],
760 [72760, 72765, 0],
761 [72766, 72766, 1],
762 [72767, 72767, 0],
763 [72768, 72849, 1],
764 [72850, 72871, 0],
765 [72872, 72873, 1],
766 [72874, 72880, 0],
767 [72881, 72881, 1],
768 [72882, 72883, 0],
769 [72884, 72884, 1],
770 [72885, 72886, 0],
771 [72887, 73008, 1],
772 [73009, 73014, 0],
773 [73015, 73017, 1],
774 [73018, 73018, 0],
775 [73019, 73019, 1],
776 [73020, 73021, 0],
777 [73022, 73022, 1],
778 [73023, 73031, 0],
779 [73032, 73103, 1],
780 [73104, 73105, 0],
781 [73106, 73108, 1],
782 [73109, 73109, 0],
783 [73110, 73110, 1],
784 [73111, 73111, 0],
785 [73112, 73458, 1],
786 [73459, 73460, 0],
787 [73461, 73471, 1],
788 [73472, 73474, 0],
789 [73475, 73525, 1],
790 [73526, 73530, 0],
791 [73531, 73535, 1],
792 [73536, 73536, 0],
793 [73537, 73537, 1],
794 [73538, 73538, 0],
795 [73539, 78911, 1],
796 [78912, 78912, 0],
797 [78913, 78918, 1],
798 [78919, 78933, 0],
799 [78934, 92911, 1],
800 [92912, 92916, 0],
801 [92917, 92975, 1],
802 [92976, 92982, 0],
803 [92983, 94030, 1],
804 [94031, 94031, 0],
805 [94032, 94094, 1],
806 [94095, 94098, 0],
807 [94099, 94175, 1],
808 [94176, 94179, 2],
809 [94180, 94180, 0],
810 [94181, 94191, 1],
811 [94192, 94193, 2],
812 [94194, 94207, 1],
813 [94208, 100343, 2],
814 [100344, 100351, 1],
815 [100352, 101589, 2],
816 [101590, 101631, 1],
817 [101632, 101640, 2],
818 [101641, 110575, 1],
819 [110576, 110579, 2],
820 [110580, 110580, 1],
821 [110581, 110587, 2],
822 [110588, 110588, 1],
823 [110589, 110590, 2],
824 [110591, 110591, 1],
825 [110592, 110882, 2],
826 [110883, 110897, 1],
827 [110898, 110898, 2],
828 [110899, 110927, 1],
829 [110928, 110930, 2],
830 [110931, 110932, 1],
831 [110933, 110933, 2],
832 [110934, 110947, 1],
833 [110948, 110951, 2],
834 [110952, 110959, 1],
835 [110960, 111355, 2],
836 [111356, 113820, 1],
837 [113821, 113822, 0],
838 [113823, 113823, 1],
839 [113824, 113827, 0],
840 [113828, 118527, 1],
841 [118528, 118573, 0],
842 [118574, 118575, 1],
843 [118576, 118598, 0],
844 [118599, 119140, 1],
845 [119141, 119141, 0],
846 [119142, 119142, 1],
847 [119143, 119145, 0],
848 [119146, 119149, 1],
849 [119150, 119170, 0],
850 [119171, 119172, 1],
851 [119173, 119179, 0],
852 [119180, 119209, 1],
853 [119210, 119213, 0],
854 [119214, 119361, 1],
855 [119362, 119364, 0],
856 [119365, 121343, 1],
857 [121344, 121398, 0],
858 [121399, 121402, 1],
859 [121403, 121452, 0],
860 [121453, 121460, 1],
861 [121461, 121461, 0],
862 [121462, 121475, 1],
863 [121476, 121476, 0],
864 [121477, 121498, 1],
865 [121499, 121503, 0],
866 [121504, 121504, 1],
867 [121505, 121519, 0],
868 [121520, 122879, 1],
869 [122880, 122886, 0],
870 [122887, 122887, 1],
871 [122888, 122904, 0],
872 [122905, 122906, 1],
873 [122907, 122913, 0],
874 [122914, 122914, 1],
875 [122915, 122916, 0],
876 [122917, 122917, 1],
877 [122918, 122922, 0],
878 [122923, 123022, 1],
879 [123023, 123023, 0],
880 [123024, 123183, 1],
881 [123184, 123190, 0],
882 [123191, 123565, 1],
883 [123566, 123566, 0],
884 [123567, 123627, 1],
885 [123628, 123631, 0],
886 [123632, 124139, 1],
887 [124140, 124143, 0],
888 [124144, 125135, 1],
889 [125136, 125142, 0],
890 [125143, 125251, 1],
891 [125252, 125258, 0],
892 [125259, 126979, 1],
893 [126980, 126980, 2],
894 [126981, 127182, 1],
895 [127183, 127183, 2],
896 [127184, 127373, 1],
897 [127374, 127374, 2],
898 [127375, 127376, 1],
899 [127377, 127386, 2],
900 [127387, 127487, 1],
901 [127488, 127490, 2],
902 [127491, 127503, 1],
903 [127504, 127547, 2],
904 [127548, 127551, 1],
905 [127552, 127560, 2],
906 [127561, 127567, 1],
907 [127568, 127569, 2],
908 [127570, 127583, 1],
909 [127584, 127589, 2],
910 [127590, 127743, 1],
911 [127744, 127776, 2],
912 [127777, 127788, 1],
913 [127789, 127797, 2],
914 [127798, 127798, 1],
915 [127799, 127868, 2],
916 [127869, 127869, 1],
917 [127870, 127891, 2],
918 [127892, 127903, 1],
919 [127904, 127946, 2],
920 [127947, 127950, 1],
921 [127951, 127955, 2],
922 [127956, 127967, 1],
923 [127968, 127984, 2],
924 [127985, 127987, 1],
925 [127988, 127988, 2],
926 [127989, 127991, 1],
927 [127992, 128062, 2],
928 [128063, 128063, 1],
929 [128064, 128064, 2],
930 [128065, 128065, 1],
931 [128066, 128252, 2],
932 [128253, 128254, 1],
933 [128255, 128317, 2],
934 [128318, 128330, 1],
935 [128331, 128334, 2],
936 [128335, 128335, 1],
937 [128336, 128359, 2],
938 [128360, 128377, 1],
939 [128378, 128378, 2],
940 [128379, 128404, 1],
941 [128405, 128406, 2],
942 [128407, 128419, 1],
943 [128420, 128420, 2],
944 [128421, 128506, 1],
945 [128507, 128591, 2],
946 [128592, 128639, 1],
947 [128640, 128709, 2],
948 [128710, 128715, 1],
949 [128716, 128716, 2],
950 [128717, 128719, 1],
951 [128720, 128722, 2],
952 [128723, 128724, 1],
953 [128725, 128727, 2],
954 [128728, 128731, 1],
955 [128732, 128735, 2],
956 [128736, 128746, 1],
957 [128747, 128748, 2],
958 [128749, 128755, 1],
959 [128756, 128764, 2],
960 [128765, 128991, 1],
961 [128992, 129003, 2],
962 [129004, 129007, 1],
963 [129008, 129008, 2],
964 [129009, 129291, 1],
965 [129292, 129338, 2],
966 [129339, 129339, 1],
967 [129340, 129349, 2],
968 [129350, 129350, 1],
969 [129351, 129535, 2],
970 [129536, 129647, 1],
971 [129648, 129660, 2],
972 [129661, 129663, 1],
973 [129664, 129672, 2],
974 [129673, 129679, 1],
975 [129680, 129725, 2],
976 [129726, 129726, 1],
977 [129727, 129733, 2],
978 [129734, 129741, 1],
979 [129742, 129755, 2],
980 [129756, 129759, 1],
981 [129760, 129768, 2],
982 [129769, 129775, 1],
983 [129776, 129784, 2],
984 [129785, 131071, 1],
985 [131072, 196605, 2],
986 [196606, 196607, 1],
987 [196608, 262141, 2],
988 [262142, 917503, 1],
989 [917504, 921599, 0],
990 [921600, 1114111, 1]
991];
992
993// ../../../../../tmp/claude-1000/-home-sam-dev-git-repo-cc-desktop-mod/8ad3cbd6-8ea7-4498-b5d0-a4c2c4ac0105/scratchpad/lm-pkg/package/dist/width.js
994var segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
995var VS16 = 65039;
996var isRegionalIndicator = (cp) => cp >= 127462 && cp <= 127487;
997function codePointWidth(cp) {
998 let lo = 0;
999 let hi = WIDTHS.length - 1;
1000 while (lo <= hi) {
1001 const mid = lo + hi >> 1;
1002 const run = WIDTHS[mid];
1003 if (cp < run[0])
1004 hi = mid - 1;
1005 else if (cp > run[1])
1006 lo = mid + 1;
1007 else
1008 return run[2];
1009 }
1010 return 1;
1011}
1012function clusterWidth(cluster) {
1013 let w = 0;
1014 let vs16 = false;
1015 let regional = 0;
1016 for (const ch of cluster) {
1017 const cp = ch.codePointAt(0);
1018 if (cp === VS16)
1019 vs16 = true;
1020 if (isRegionalIndicator(cp))
1021 regional++;
1022 const cw = codePointWidth(cp);
1023 if (cw > w)
1024 w = cw;
1025 }
1026 return vs16 || regional >= 2 ? 2 : w;
1027}
1028function* measured(s) {
1029 for (const { segment } of segmenter.segment(s))
1030 yield [segment, clusterWidth(segment)];
1031}
1032function stringWidth(s) {
1033 let w = 0;
1034 for (const { segment } of segmenter.segment(s))
1035 w += clusterWidth(segment);
1036 return w;
1037}
1038
1039// ../../../../../tmp/claude-1000/-home-sam-dev-git-repo-cc-desktop-mod/8ad3cbd6-8ea7-4498-b5d0-a4c2c4ac0105/scratchpad/lm-pkg/package/dist/labels.js
1040var WRAP_WIDTH = 24;
1041var MAX_LINES = 4;
1042var MAX_LABEL = 28;
1043var LABEL_BREAK_CHARS = ["_", "-", ".", "/"];
1044var asciiLower = (s) => s.replace(/[A-Z]/g, (c) => c.toLowerCase());
1045var asciiUpper = (s) => s.replace(/[a-z]/g, (c) => c.toUpperCase());
1046var CONTROLS = /[\0-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
1047var stripControls = (src) => src.replace(CONTROLS, "");
1048function srcLines(src) {
1049 const out = src.split(`
1050`).map((l) => l.endsWith("\r") ? l.slice(0, -1) : l);
1051 if (out.length > 0 && out[out.length - 1] === "")
1052 out.pop();
1053 return out;
1054}
1055var ALNUM = /[\p{Alphabetic}\p{N}]/u;
1056var isAlphanumeric = (c) => ALNUM.test(c);
1057var isIdChar = (c) => isAlphanumeric(c) || c === "_";
1058var ENTITY_LOOKAHEAD = 10;
1059var NAMED_ENTITIES = {
1060 lt: "<",
1061 gt: ">",
1062 amp: "&",
1063 quot: '"',
1064 apos: "'"
1065};
1066function decodeEntityBody(body) {
1067 const named = NAMED_ENTITIES[body];
1068 if (named !== undefined)
1069 return named;
1070 if (!body.startsWith("#"))
1071 return null;
1072 const num = body.slice(1);
1073 const hex = /^[xX]/.test(num);
1074 const digits = hex ? num.slice(1) : num;
1075 if (!(hex ? /^[0-9a-fA-F]+$/ : /^[0-9]+$/).test(digits))
1076 return null;
1077 const code = Number.parseInt(digits, hex ? 16 : 10);
1078 if (code > 1114111 || code >= 55296 && code <= 57343)
1079 return null;
1080 if (code < 32 || code >= 127 && code <= 159)
1081 return null;
1082 return String.fromCodePoint(code);
1083}
1084function decodeHtmlEntities(s) {
1085 if (!s.includes("&"))
1086 return s;
1087 const chars = [...s];
1088 let out = "";
1089 let i = 0;
1090 while (i < chars.length) {
1091 if (chars[i] !== "&") {
1092 out += chars[i];
1093 i++;
1094 continue;
1095 }
1096 const hi = Math.min(i + 1 + ENTITY_LOOKAHEAD, chars.length);
1097 let semi = -1;
1098 for (let j = i + 1;j < hi; j++) {
1099 if (chars[j] === ";") {
1100 semi = j;
1101 break;
1102 }
1103 }
1104 const decoded = semi === -1 ? null : decodeEntityBody(chars.slice(i + 1, semi).join(""));
1105 if (decoded === null) {
1106 out += "&";
1107 i++;
1108 } else {
1109 out += decoded;
1110 i = semi + 1;
1111 }
1112 }
1113 return out;
1114}
1115function stripMarkdown(s) {
1116 const noCode = [...s].filter((c) => c !== "`").join("");
1117 const noStrong = noCode.replaceAll("**", "").replaceAll("__", "");
1118 const chars = [...noStrong];
1119 let out = "";
1120 for (let i = 0;i < chars.length; i++) {
1121 const c = chars[i];
1122 const inWord = i > 0 && isAlphanumeric(chars[i - 1]) && chars[i + 1] !== undefined && isAlphanumeric(chars[i + 1]);
1123 if ((c === "*" || c === "_") && !inWord)
1124 continue;
1125 out += c;
1126 }
1127 return out.trim();
1128}
1129var HTML_FORMAT_TAGS = new Set([
1130 "b",
1131 "strong",
1132 "i",
1133 "em",
1134 "u",
1135 "s",
1136 "strike",
1137 "del",
1138 "ins",
1139 "mark",
1140 "small",
1141 "big",
1142 "sub",
1143 "sup",
1144 "code",
1145 "kbd",
1146 "samp",
1147 "var",
1148 "tt",
1149 "span",
1150 "font",
1151 "q",
1152 "abbr",
1153 "cite",
1154 "pre"
1155]);
1156function htmlTagAt(chars, start) {
1157 let i = start + 1;
1158 if (chars[i] === "/")
1159 i++;
1160 const nameStart = i;
1161 while (i < chars.length && /^[0-9A-Za-z]$/.test(chars[i]))
1162 i++;
1163 if (i === nameStart)
1164 return null;
1165 const name = chars.slice(nameStart, i).join("");
1166 while (i < chars.length && chars[i] !== ">") {
1167 if (chars[i] === "<")
1168 return null;
1169 i++;
1170 }
1171 return chars[i] === ">" ? { name, end: i + 1 } : null;
1172}
1173function stripHtmlTags(s) {
1174 const chars = [...s];
1175 let out = "";
1176 let i = 0;
1177 while (i < chars.length) {
1178 if (chars[i] === "<") {
1179 const tag = htmlTagAt(chars, i);
1180 if (tag) {
1181 const lower = tag.name.toLowerCase();
1182 if (lower === "br") {
1183 out += " ";
1184 i = tag.end;
1185 continue;
1186 }
1187 if (HTML_FORMAT_TAGS.has(lower)) {
1188 i = tag.end;
1189 continue;
1190 }
1191 }
1192 }
1193 out += chars[i];
1194 i++;
1195 }
1196 return out;
1197}
1198function unwrap(s, open, close) {
1199 return s.length >= open.length + close.length && s.startsWith(open) && s.endsWith(close) ? s.slice(open.length, s.length - close.length) : null;
1200}types/index.d.ts 85 lines1// /desk-asana: 엔진에 붙은 Asana MCP 서버로 읽는 내 태스크. v2 는 Asana 공식 v2 서버(get_my_tasks),
2// v1 은 claude.ai 커넥터(asana_* 도구). name 은 $.mcp.call 이 받는 서버 이름(도구 이름의 가운데).
3export type AsanaServer = { name: string; dialect: 'v2' | 'v1' }
4// assignee 는 검색 결과에만 있다(내 태스크 목록은 모두 나).
5// isDone: Asana 에서 완료(작업 유형의 보류·배포 대기 같은 상태도 완료일 수 있다). status 는 작업 유형의 상태 이름.
6// key: 태스크 키(설정 asanaKeyField 로 고른 필드의 값, Asana ID 필드면 "JKD-1630"). 설정이 없거나 값이 없으면 null,
7// 0.55.2 전에 저장된 목록에는 없다(atom 은 /reload-plugins 뒤에도 남는다).
8export type AsanaTask = { gid: string; name: string; key?: string | null; dueOn: string | null; project: string | null; section: string | null; url: string | null; assignee: string | null; isDone: boolean; status: string | null }
9export type AsanaComment = {
10 gid: string
11 author: string
12 authorGid: string | null
13 at: string
14 text: string
15 // html_text 를 옮긴 마크다운(notesMarkdown). stories 를 못 읽어 html_text 가 없으면 null 이고 text 평문을 그린다.
16 richText: string | null
17}
18// 드롭다운(enum) 필드만 options 가 있다(패널에서 바꿀 수 있는 것). 수식 필드는 바꾸지 않는다.
19export type AsanaField = { gid: string; name: string; value: string | null; options: Array<{ gid: string; name: string }> | null }
20export type AsanaDetail = {
21 gid: string
22 name: string
23 // 태스크 키(AsanaTask.key). 0.55.2 전에 연 상세에는 없다.
24 key?: string | null
25 notes: string
26 // html_notes 를 옮긴 마크다운(notesMarkdown). 서버가 html_notes 를 주지 않으면 null 이고 notes 평문을 그린다.
27 richNotes: string | null
28 assignee: { gid: string; name: string } | null
29 dueOn: string | null
30 completed: boolean
31 // 사용자 지정 작업 유형의 상태(Open·In Progress…)와 고를 수 있는 상태들. MCP 로는 바꿀 수 없어 개인 토큰이 있을 때만 REST 로 바꾼다.
32 status: string | null
33 statuses: Array<{ gid: string; name: string }>
34 places: string[]
35 url: string | null
36 fields: AsanaField[]
37 comments: AsanaComment[]
38}
39// status: none 은 Asana 서버가 연결돼 있지 않음, failed 는 읽지 못함(error).
40// unruled: 패널이 부르는 도구 중 allow 규칙이 없어 엔진이 물어보거나 막을 것(paneTools).
41export type AsanaState = { status: 'loading' | 'ok' | 'none' | 'failed'; server: AsanaServer | null; tasks: AsanaTask[]; unruled: string[]; error?: string }
42export type AsanaView = { gid: string; status: 'loading' | 'ok' | 'failed'; task: AsanaDetail | null; error?: string }
43export type AsanaUser = { gid: string; name: string; email: string | null }
44// 패널 검색창: 이 글자로 찾은 워크스페이스 태스크. null 이면 내 태스크 목록.
45export type AsanaSearch = { query: string; status: 'loading' | 'ok' | 'failed'; tasks: AsanaTask[]; error?: string }
46// 프로젝트 탭. isMine: 프로젝트 멤버에 내가 있다. open: 열린 태스크 수(v2 만, v1 은 null). modifiedAt 은 정렬용 ISO 시각.
47export type AsanaTab = 'mine' | 'projects'
48export type AsanaProject = { gid: string; name: string; url: string | null; members: number; open: number | null; isMine: boolean; modifiedAt: string }
49export type AsanaProjects = { status: 'loading' | 'ok' | 'failed'; projects: AsanaProject[]; error?: string }
50// 펼친 프로젝트의 열린 태스크(섹션은 task.section). isPartial: 읽는 쪽 수 한도에 걸려 뒤가 더 있다.
51// 댓글 고치기(PAT 가 있을 때만): notoken 은 토큰을 넣으라는 안내, loading 은 REST 로 원문을 읽는 중, saving 은 PUT 중.
52// story 는 원문 html_text 와 거기서 만든 고칠 글(저장할 때 둘을 견줘 바뀐 글자만 끼운다).
53export type AsanaEdit = { comment: string; status: 'notoken' | 'loading' | 'ready' | 'saving'; story: { text: string; html: string } | null }
54export type AsanaProjectView = { project: AsanaProject; status: 'loading' | 'ok' | 'failed'; tasks: AsanaTask[]; isPartial: boolean; error?: string }
55
56// /desk-fold 로 접어 둔 패널(desk-look 의 FoldedPane 과 같은 모양): 다시 열 때의 제목, 앞에 있던 것인지, 스크롤 위치.
57export type FoldedPane = { id: string; title: string; isShown: boolean; offset: number }
58
59declare module 'claude-code' {
60 interface PluginState {
61 'desk-asana': {
62 asana: AsanaState | null
63 asanaView: AsanaView | null
64 asanaUsers: { query: string; status: 'loading' | 'ok' | 'failed'; users: AsanaUser[] } | null
65 asanaSearch: AsanaSearch | null
66 asanaTab: AsanaTab
67 asanaProjects: AsanaProjects | null
68 asanaProject: AsanaProjectView | null
69 asanaEdit: AsanaEdit | null
70 // 펼친 묶음·담당자 확인처럼 켜고 끄는 것.
71 open: Record<string, boolean>
72 // desk-look 이 읽는 값: 이 세션에 떠 있음, /desk-fold 로 접어 둔 패널.
73 ready: boolean
74 folded: FoldedPane[] | null
75 // 패널이 지금 열려 있음(desk-look 의 /desk-fold 가 읽는다).
76 shown: boolean
77 }
78 // desk-look 의 값 가운데 이 플러그인이 읽는 것(그쪽 types 와 같은 모양): 화면 언어, 접어 둔 desk-look 패널.
79 'desk-look': {
80 lang: 'en' | 'ko'
81 folded: FoldedPane[] | null
82 }
83 }
84}
85