SLOPSHOPPER

desk-asana

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…

newpanecommandtoastnetworktimer
★ 2v0.59.2MITupdated 2026-10-26KyongSik-Yoon/cc-desktop-mod/desk-asana
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desk-asana
│ ┃ Asana ✕ › fix the failing auth test and add an audit log call │ ┃ My tasks 0 r: ↻ │ ┃ ⏺ Read(src/auth.ts) │ ┃ No Asana MCP server is connected. Connect ⎿ Read 6 lines │ ┃ Asana in /mcp. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /desk-asana │ ⎿ desk-asana: No Asana MCP server is connected. Connect Asana in / │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Asana
My tasks 0 r: ↻ No Asana MCP server is connected. Connect Asana in /mcp.
README

desk-asana: Asana tasks in a Claude Code side pane

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.

Install

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 tasks

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

  • The list groups your incomplete tasks by project, with the due date at the end of the row (red when overdue). Below them, 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
  • The ● 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 list
  • The Search 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)
  • Click a task for its details: the task type's status (● 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
  • Write a comment in the box at the bottom and press Enter to add it on Asana. @ 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 edited
  • Change 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 it
  • Hand to Claude fills the prompt with the task's title, link, status, description and recent comments
  • Change 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 nothing
  • These Asana calls are made only when you open the pane or press something in it, and they go through Claude Code's permission check like Claude's own calls: auto mode refuses them and the default mode asks each time, unless an allow rule covers them. Add the pane's tools to permissions.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

What it runs, reads and sends

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.

Hooks and what they do

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 pane
  • session.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-fold
  • command.run: handles /desk-asana only

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

Settings

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 otherwise
  • asanaToken: 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-mod
  • asanaKeyField: 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 only

License

MIT (LICENSE)

Source 8 files
hooks/register.tsx 1025 lines
1import { 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}
1025
hooks/asana.ts 833 lines
1import 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
153const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' }
154
155// 글 조각을 글자마다 원래 모양(엔티티면 &quot; 그대로)과 함께 푼다. 고친 조각도 안 바꾼 글자는 원래 모양으로 다시 쓴다.
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}
833
hooks/i18n.ts 195 lines
1// 화면 문구 사전: 영어(기본)와 한국어. 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}
195
hooks/shared/markdown.tsx 554 lines
1import type { Elements, UiPressArgument } from 'claude-code'
2
3import { renderDiagram } from './diagram'
4
5// 데스크톱 앱 산문 스타일로 마크다운을 Box/Text 트리로 그린다.
6// 엔진의 Markdown 요소는 표·인용·코드 블록 모양을 바꿀 수 없어서 직접 파싱한다.
7
8export type Palette = {
9  text: string
10  muted: string
11  border: string
12  code: string
13  link: string
14  // 인라인 코드 바탕. 터미널 배경을 알 때만 칠한다.
15  codeBg?: string
16  // 다이어그램 화살표 강조색.
17  accent: string
18}
19
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}
554
hooks/util.tsx 84 lines
1import 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}
84
hooks/shared/diagram.ts 98 lines
1import { 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}
98
hooks/shared/vendor/lovely-mermaid.js 5117 lines
1// 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 lines
1// /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