@rch4com/agent-setup 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ // 상세 패널 — 커서가 놓인 항목의 전문을 여러 줄로 편다.
2
+ // 순수 함수 모듈이다. 색도 커서도 터미널도 모른다 — render.mjs가 배치하고 칠한다.
3
+ //
4
+ // rows.mjs를 import 하지 않는 것이 규칙이다. render.mjs가 이 모듈을 쓰므로,
5
+ // 여기서 rows를 끌어오면 render → detail → rows 방향이 생겨 rows.mjs가 순수
6
+ // 렌더 층의 의존성이 된다. 그래서 사유 그룹핑이 rows가 아니라 여기 있고,
7
+ // rows.mjs가 반대로 여기서 가져다 쓴다.
8
+ import { toText, createT } from '../i18n/index.mjs'
9
+ import { CLIS } from '../clis.mjs'
10
+ import { categoryLabel } from '../design-md/flow.mjs'
11
+ import { cut, pad, width, wrap } from '../width.mjs'
12
+
13
+ // 미배선 사유를 **같은 사유끼리** 묶는다. 사유 하나가 CLI 아홉 개에 그대로
14
+ // 반복되면 줄만 길어지고 "무엇이 왜 빠졌는가"는 오히려 묻힌다.
15
+ // 사유가 둘 이상이면(ponytail처럼) 갈래가 그대로 보인다.
16
+ export function unsupportedGroups(item, t) {
17
+ const entries = Object.entries(item?.unsupported ?? {})
18
+ if (entries.length === 0) return []
19
+ const byReason = new Map()
20
+ for (const [cli, why] of entries) {
21
+ const text = toText(t, why)
22
+ if (!byReason.has(text)) byReason.set(text, [])
23
+ byReason.get(text).push(cli)
24
+ }
25
+ return [...byReason].map(([why, clis]) => ({ clis, why }))
26
+ }
27
+
28
+ // '배선 ' / '미배선 ' 라벨 자리. 로케일마다 길이가 달라 상수로 박으면 열이
29
+ // 어긋난다 — 그 로케일의 실제 라벨에서 폭을 뽑는다.
30
+ function leadWidth(t) {
31
+ return Math.max(width(t('detail.wired')), width(t('detail.unwired'))) + 1
32
+ }
33
+
34
+ function headLine(row, w, t) {
35
+ const item = row.item
36
+ // item.scope는 오늘 모든 항목 종류가 채운다 — 그래도 이 값 하나로 화면
37
+ // 전체가 죽을 수는 없다. t()는 모르는 키에 던지고(i18n/index.mjs) 이
38
+ // 함수는 render() 안에서 돈다 — 항목 하나의 결손이 패널 하나가 아니라
39
+ // TUI 전체를 끌고 내려간다.
40
+ const bits = [item.category, item.scope ? t(`detail.scope.${item.scope}`) : null, t(`status.${row.status}`)]
41
+ return cut(`${item.label} ${bits.filter(Boolean).join(' · ')}`, w)
42
+ }
43
+
44
+ function wiredLines(item, w, lead, t) {
45
+ const supports = item.supports ?? []
46
+ if (supports.length === 0) return []
47
+ // 경로는 MCP에서만 붙인다. 그때만 clis.mjs 어댑터가 경로의 유일한 진실이다 —
48
+ // plugin·skill은 설치 자리가 항목마다 달라 어댑터가 알지 못한다.
49
+ const showFile = item.category === 'mcp'
50
+ const nameWidth = Math.max(...supports.map((c) => width(c)))
51
+ return supports.map((cli, i) => {
52
+ const head = i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)
53
+ const file = showFile && CLIS[cli]?.file ? ` ${CLIS[cli].file}` : ''
54
+ // 이름 뒤에 열이 없으면 맞출 대상도 없다 — 경로가 붙는 mcp에서만 이름을 편다.
55
+ const name = showFile ? pad(cli, nameWidth) : cli
56
+ return cut(`${head}✔ ${name}${file}`, w)
57
+ })
58
+ }
59
+
60
+ function unwiredLines(item, w, lead, t) {
61
+ const out = []
62
+ unsupportedGroups(item, t).forEach((group, i) => {
63
+ const head = i === 0 ? pad(t('detail.unwired'), lead) : ' '.repeat(lead)
64
+ out.push(cut(`${head}✖ ${group.clis.join('·')}`, w))
65
+ // 사유는 한 칸 더 들여 이어 붙인다. 첫 줄만 └를 달아 어느 CLI 묶음의
66
+ // 사유인지 눈으로 잇는다.
67
+ wrap(group.why, Math.max(1, w - lead - 3)).forEach((line, j) => {
68
+ out.push(cut(`${' '.repeat(lead + 1)}${j === 0 ? '└ ' : ' '}${line}`, w))
69
+ })
70
+ })
71
+ return out
72
+ }
73
+
74
+ function agentLines(row, w, t) {
75
+ const item = row.item
76
+ const lead = leadWidth(t)
77
+ const out = [headLine(row, w, t)]
78
+ if (item.note) out.push(...wrap(t(item.note), w))
79
+ if (row.statusDetail) out.push(...wrap(row.statusDetail, w))
80
+ out.push(...wiredLines(item, w, lead, t))
81
+ out.push(...unwiredLines(item, w, lead, t))
82
+ return out
83
+ }
84
+
85
+ function designLines(row, w, t) {
86
+ const item = row.item
87
+ const lead = leadWidth(t)
88
+ const bits = [item.category, categoryLabel(t, item.designCategory), t(`status.${row.status}`)]
89
+ const out = [cut(`${item.label} ${bits.filter(Boolean).join(' · ')}`, w)]
90
+ if (item.providerId) out.push(cut(`${pad(t('detail.provider'), lead)}${item.providerId}`, w))
91
+ const target = row.previewTarget ?? item.webUrl ?? item.previewPath
92
+ if (target) out.push(cut(`${pad(t('detail.preview'), lead)}${target}`, w))
93
+ if (item.description) out.push(...wrap(item.description, w))
94
+ return out
95
+ }
96
+
97
+ // 액션 행은 체크 대상이 아니라 실행 대상이다 — 배선표가 없다.
98
+ function actionLines(row, w) {
99
+ return [cut(row.label, w), ...wrap(row.fullHint ?? row.hint ?? '', w)]
100
+ }
101
+
102
+ // 행 하나와 지면 크기를 받아 줄 배열을 돌려준다. 색도 커서도 모른다.
103
+ export function detailLines(row, { width: columns = 80, height = 8, t = createT('en') } = {}) {
104
+ if (!row || height <= 0) return []
105
+ const w = Math.max(20, columns)
106
+ const lines = row.kind === 'action'
107
+ ? actionLines(row, w)
108
+ : row.item?.category === 'design' ? designLines(row, w, t) : agentLines(row, w, t)
109
+
110
+ if (lines.length <= height) return lines
111
+ // 넘치면 조용히 자르지 않는다 — 남은 줄 수와 펼치는 길을 함께 알린다.
112
+ // 안내줄이 한 줄을 차지하므로 본문은 height - 1줄만 쓴다. height===1이면
113
+ // 그 한 줄조차 없어 안내줄 하나가 전부를 대신하고, 그때는 "한 줄 빼고 남은
114
+ // 수"가 아니라 "전부"를 세어야 거짓말이 되지 않는다.
115
+ const room = height - 1
116
+ const marker = cut(t('detail.more', { count: lines.length - Math.max(0, room) }), w)
117
+ return room <= 0 ? [marker] : [...lines.slice(0, room), marker]
118
+ }
@@ -0,0 +1,160 @@
1
+ // 진행 화면 — 순수 함수 모듈이다. 터미널도 시계도 모른다.
2
+ //
3
+ // 시각을 인자로 받는 것이 핵심이다. 모듈 안에서 Date.now()를 부르면 테스트가
4
+ // 실제 시간에 묶여 "14초 경과"를 검증할 방법이 없다.
5
+ import { createT } from '../i18n/index.mjs'
6
+ import { cut, labelWidth, pad, width } from '../width.mjs'
7
+
8
+ const ESC = String.fromCharCode(27)
9
+ const DIM = `${ESC}[2m`
10
+ const BOLD = `${ESC}[1m`
11
+ const RESET = `${ESC}[0m`
12
+
13
+ const MARK = { done: '✔', failed: '✖', running: '▸', pending: ' ', skipped: '–' }
14
+
15
+ export function createProgress(changes) {
16
+ return {
17
+ total: changes.length,
18
+ startedAt: null,
19
+ aborted: false,
20
+ entries: changes.map(({ item, action }) => ({
21
+ item, action, state: 'pending', ok: null, ms: 0, command: null, startedAt: null,
22
+ })),
23
+ }
24
+ }
25
+
26
+ // 이벤트 하나를 접어 넣는다. 새 상태를 돌려준다 — 화면은 이 값만 보고 그린다.
27
+ export function applyEvent(progress, event, now) {
28
+ const entries = progress.entries.slice()
29
+ const at = entries[event.index]
30
+ if (!at) return progress
31
+ if (event.phase === 'start') entries[event.index] = { ...at, state: 'running', startedAt: now }
32
+ else if (event.phase === 'command') entries[event.index] = { ...at, command: event.command }
33
+ else if (event.phase === 'done') {
34
+ entries[event.index] = { ...at, state: event.ok ? 'done' : 'failed', ok: event.ok, ms: event.ms ?? 0 }
35
+ }
36
+ return { ...progress, entries, startedAt: progress.startedAt ?? now }
37
+ }
38
+
39
+ function seconds(ms) {
40
+ return (Math.max(0, ms) / 1000).toFixed(1).replace(/\.0$/, '')
41
+ }
42
+
43
+ function elapsedText(ms, t) {
44
+ const total = Math.floor(Math.max(0, ms) / 1000)
45
+ return total >= 60
46
+ ? t('progress.elapsedMin', { minutes: Math.floor(total / 60), seconds: total % 60 })
47
+ : t('progress.elapsed', { seconds: total })
48
+ }
49
+
50
+ function bar(done, total, room) {
51
+ if (room <= 2) return ''
52
+ const inner = room - 2
53
+ const filled = total === 0 ? 0 : Math.round((done / total) * inner)
54
+ return `[${'█'.repeat(filled)}${'░'.repeat(Math.max(0, inner - filled))}]`
55
+ }
56
+
57
+ // 항목 한 개가 차지하는 줄. 실행 중이면 경과와 명령을 함께 낸다.
58
+ function entryLines(entry, w, actionWidth, now, t) {
59
+ let tail = ''
60
+ if (entry.state === 'running') {
61
+ const ran = Math.floor(Math.max(0, now - (entry.startedAt ?? now)) / 1000)
62
+ tail = ` ${t('progress.running', { seconds: ran })}`
63
+ } else if (entry.state === 'done' || entry.state === 'failed') {
64
+ tail = ` ${t('progress.elapsed', { seconds: seconds(entry.ms) })}`
65
+ } else if (entry.state === 'skipped') {
66
+ tail = ` ${t('progress.skipped')}`
67
+ }
68
+ const mark = MARK[entry.state] ?? ' '
69
+ const out = [cut(`${mark} ${pad(t(`change.${entry.action}`), actionWidth)} ${entry.item.label}${tail}`, w)]
70
+ // 명령은 실행 중일 때만. 끝난 뒤에도 남기면 화면이 명령 목록이 된다.
71
+ if (entry.state === 'running' && entry.command) out.push(cut(` ${entry.command}`, w))
72
+ return out
73
+ }
74
+
75
+ // 중단되면 아직 시작하지 않은(pending) 항목이 곧 건너뛴 항목이다. applyEvent는
76
+ // Task 9의 이벤트(start·command·done)만 받아 항목 상태가 'skipped'가 되는
77
+ // 경로가 없다 — 그래서 화면을 그릴 때만 pending을 skipped로 겹쳐 본다.
78
+ // 집계와 표시가 같은 규칙을 쓰게 해, "건너뜀 0건"처럼 숫자와 말이 어긋나지 않게 한다.
79
+ function viewEntry(entry, aborted) {
80
+ return aborted && entry.state === 'pending' ? { ...entry, state: 'skipped' } : entry
81
+ }
82
+
83
+ export function progressLines(progress, opts = {}) {
84
+ const { width: columns = 80, height = 24, color = false, dryRun = false, now = 0, t = createT('en') } = opts
85
+ const w = Math.max(24, columns - 1)
86
+ const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
87
+
88
+ const done = progress.entries.filter((e) => e.state === 'done' || e.state === 'failed').length
89
+ const skipped = progress.aborted ? progress.entries.filter((e) => e.state === 'pending').length : 0
90
+ const percent = progress.total === 0 ? 100 : Math.round((done / progress.total) * 100)
91
+ const counter = t('progress.counter', { done, total: progress.total, percent })
92
+ const elapsed = progress.startedAt === null ? '' : ` ${elapsedText(now - progress.startedAt, t)}`
93
+
94
+ const title = t('progress.title', { count: progress.total, suffix: dryRun ? ' (dry-run)' : '' })
95
+ const lines = [paint(BOLD, cut(title, w)), '']
96
+
97
+ const meta = ` ${counter}${elapsed}`
98
+ lines.push(cut(`${bar(done, progress.total, Math.max(0, w - width(meta)))}${meta}`, w))
99
+ lines.push('')
100
+
101
+ const actionWidth = labelWidth(t, ['change.install', 'change.complete', 'change.uninstall'])
102
+ const body = Math.max(1, height - lines.length - 2)
103
+
104
+ // 지면이 모자라면 **완료된 것부터** 접는다. 지금 무엇이 도는지가 가장 알고
105
+ // 싶은 정보라, 실행 중 항목은 언제나 화면에 남는다.
106
+ //
107
+ // 실행 중 항목(없으면 마지막으로 끝난 항목)을 기준점으로 잡고 지면이
108
+ // 허락하는 만큼만 위로 거슬러 올라간다. 기준점에서 시작하므로 그 줄이
109
+ // 잘려 나갈 수 없다 — "완료분부터 접는다"를 규칙이 아니라 구조로 보장한다.
110
+ const blocks = progress.entries.map((e) => entryLines(viewEntry(e, progress.aborted), w, actionWidth, now, t))
111
+ const running = progress.entries.findIndex((e) => e.state === 'running')
112
+ const anchor = running === -1 ? Math.max(0, done - 1) : running
113
+
114
+ let from = anchor
115
+ let used = blocks.slice(anchor).reduce((n, b) => n + b.length, 0)
116
+ while (from > 0 && used + blocks[from - 1].length <= body - 1) {
117
+ from--
118
+ used += blocks[from].length
119
+ }
120
+ // '더보기' 줄은 부가 정보일 뿐이다. 지면이 1줄밖에 없으면 실행 중 항목이
121
+ // 이긴다 — 더보기 줄에 밀려 정작 지금 도는 항목이 사라지면 안 된다.
122
+ const showMore = from > 0 && body > 1
123
+ const shown = blocks.slice(from).flat().slice(0, Math.max(1, body - (showMore ? 1 : 0)))
124
+ if (showMore) lines.push(paint(DIM, cut(t('progress.more', { count: from }), w)))
125
+ for (const line of shown) lines.push(line)
126
+ for (let i = lines.length; i < height - 2; i++) lines.push('')
127
+
128
+ lines.push('')
129
+ // done 분기(아래)는 aborted가 아닐 때만 오고, 그때 skipped는 위에서 늘
130
+ // 0이다 — 그래서 skippedSuffix는 항상 빈 문자열이다. progress.doneSkipped
131
+ // 키는 이 분기가 실제로 채울 일이 없어 지웠다(더는 어디서도 쓰지 않는다).
132
+ const foot = progress.aborted
133
+ ? t('progress.aborted', { count: skipped })
134
+ : done < progress.total
135
+ ? t('progress.abortHint')
136
+ : t('progress.done', {
137
+ ok: progress.entries.filter((e) => e.state === 'done').length,
138
+ failed: progress.entries.filter((e) => e.state === 'failed').length,
139
+ skippedSuffix: '',
140
+ })
141
+ lines.push(paint(DIM, cut(foot, w)))
142
+ return lines.slice(0, height)
143
+ }
144
+
145
+ // 비TTY(CI·파이프)용. 바를 그리지 않고 항목마다 한 줄씩 흘린다 —
146
+ // ANSI 제어문자로 CI 로그를 더럽히지 않는다.
147
+ export function plainLine(event, t = createT('en')) {
148
+ if (event.phase === 'start') {
149
+ return t('progress.plain', {
150
+ index: event.index + 1,
151
+ total: event.total,
152
+ action: t(`change.${event.action}`),
153
+ label: event.item.label,
154
+ })
155
+ }
156
+ if (event.phase === 'done') {
157
+ return t('progress.plainDone', { mark: event.ok ? '✔' : '✖', seconds: seconds(event.ms ?? 0) })
158
+ }
159
+ return null
160
+ }
@@ -1,10 +1,11 @@
1
1
  // 순수 렌더 — 상태와 크기를 받아 화면 줄 배열을 돌려준다.
2
2
  // 커서 이동·지우기 같은 제어 시퀀스는 run.mjs가 맡는다.
3
- import { displayList, tabCounts, activeTab } from './state.mjs'
3
+ import { displayList, tabCounts, activeTab, currentRow } from './state.mjs'
4
4
  import { CLI_IDS } from '../clis.mjs'
5
5
  import { createT } from '../i18n/index.mjs'
6
6
  import { categoryLabel } from '../design-md/flow.mjs'
7
7
  import { cut, pad, width } from '../width.mjs'
8
+ import { detailLines } from './detail.mjs'
8
9
 
9
10
  // 폭 계산은 화면 전용이 아니다 — 비대화형 목록도 같은 열 맞춤이 필요해
10
11
  // lib/width.mjs에 있다. 여기서 다시 내보내는 것은 호출부(테스트 포함)가
@@ -21,7 +22,34 @@ const RESET = `${ESC}[0m`
21
22
  const CHROME = 6
22
23
  export const LABEL_WIDTH = 24
23
24
 
24
- export function bodyHeight(height) {
25
+ // 상세 패널은 목록과 화면을 나눠 갖는다. 높이를 커서가 아니라 **터미널
26
+ // 크기로만** 정하는 것이 핵심이다 — 커서를 옮길 때마다 높이가 변하면
27
+ // 목록이 출렁이고, 그것이 아코디언(행 펼침)을 기각한 이유였다.
28
+ const PANEL_SHARE = 0.4
29
+ const PANEL_MIN = 4
30
+ const PANEL_MAX = 12
31
+ // 목록이 3줄 밑으로 내려가는 쪽이 패널이 사라지는 것보다 나쁘다.
32
+ const PANEL_FLOOR = PANEL_MIN + 3
33
+
34
+ export function panelHeight(height, expanded = false) {
35
+ const room = Math.max(0, height - CHROME)
36
+ if (expanded) return room
37
+ if (room < PANEL_FLOOR) return 0
38
+ return Math.min(PANEL_MAX, Math.max(PANEL_MIN, Math.round(room * PANEL_SHARE)))
39
+ }
40
+
41
+ export function bodyHeight(height, expanded = false) {
42
+ const room = Math.max(0, height - CHROME)
43
+ const panel = panelHeight(height, expanded)
44
+ // 패널이 없을 때는 예전과 같이 최소 3줄을 보장한다.
45
+ return panel === 0 ? Math.max(3, room) : room - panel
46
+ }
47
+
48
+ // 제출 검토 화면(renderReview) 전용 — 거긴 패널을 그리지 않는다. bodyHeight를
49
+ // 그대로 재사용하면 그리지도 않는 패널 몫을 미리 떼어 두게 되고, 적용 직전
50
+ // 마지막 확인 화면이 화면에 여유가 남았는데도 변경 목록을 이유 없이 잘라
51
+ // "…외 N건"으로 감춘다. 그래서 패널을 도입하기 전의 옛 산식을 그대로 쓴다.
52
+ export function reviewBodyHeight(height) {
25
53
  return Math.max(3, height - CHROME)
26
54
  }
27
55
 
@@ -32,20 +60,22 @@ function checkbox(row, selected) {
32
60
  return `[${selected.has(row.id) ? MARK.on : MARK.off}]`
33
61
  }
34
62
 
35
- // 탭 줄. 검색 중이면 탭마다 적중 수를 보여 준다 —
36
- // 검색은 활성 탭 안으로만 걸리므로, 다른 탭에 결과가 있다는 사실을 여기서 알린다.
63
+ // 탭 줄. 목록이 좁혀졌으면(검색어·CLI 필터 어느 쪽이든) 탭마다 적중 수를
64
+ // 보여 준다 — 검색·필터는 활성 탭 안으로만 걸리므로, 다른 탭에 결과가
65
+ // 있다는 사실을 여기서 알린다. 그래야 필터가 사용자를 빈 탭에 가둬 두고
66
+ // 아무 설명도 없이 방치하는 일이 없다.
37
67
  // 폭이 모자라면 활성 탭 하나 + 위치 표시로 줄인다(줄바꿈은 화면을 무너뜨린다).
38
68
  // tab 자체는 rows.mjs가 만든 소문자 id다 — 화면에는 t로 번역한 이름만 낸다.
39
- export function tabBar(state, { width: limit, color = false, searching = false, t = createT('en') } = {}) {
69
+ export function tabBar(state, { width: limit, color = false, narrowed = false, t = createT('en') } = {}) {
40
70
  const counts = tabCounts(state)
41
71
  if (counts.length === 0) return ''
42
72
  const active = activeTab(state)
43
73
 
44
74
  const segs = counts.map(({ tab, shown, total }) => ({
45
75
  tab,
46
- text: searching ? `${t(`section.${tab}`)} ${shown}/${total}` : `${t(`section.${tab}`)} ${total}`,
76
+ text: narrowed ? `${t(`section.${tab}`)} ${shown}/${total}` : `${t(`section.${tab}`)} ${total}`,
47
77
  active: tab === active,
48
- empty: searching && shown === 0,
78
+ empty: narrowed && shown === 0,
49
79
  }))
50
80
 
51
81
  const SEP = ' '
@@ -65,23 +95,52 @@ export function tabBar(state, { width: limit, color = false, searching = false,
65
95
  .join('')
66
96
  }
67
97
 
98
+ // 필터 표시. 검색줄 오른쪽 끝에 붙는다 — 새 줄을 만들면 CHROME이 늘어
99
+ // 목록이 그만큼 준다. 필터가 없으면 아무것도 내지 않는다(잡음 방지).
100
+ //
101
+ // 색은 여기서 입히지 않는다 — 이 반환값이 searchLine의 tailWidth 계산에도
102
+ // 그대로 쓰이는데, 색 코드를 섞으면 ESC 시퀀스가 표시 폭에 끼어든다(폭은
103
+ // 항상 색을 입히기 전에 재야 한다). 칠하는 일은 호출부가 폭 계산을 끝낸
104
+ // 뒤 마지막에 한다.
105
+ export function filterSegment(state, { t = createT('en'), options = [] } = {}) {
106
+ if (!state.cliFilter) return ''
107
+ const at = options.indexOf(state.cliFilter)
108
+ const pos = at === -1 ? '' : ` (${t('tui.filter.position', { current: at + 1, total: options.length })})`
109
+ return `${t('tui.filter.prefix')}${state.cliFilter}${pos}`
110
+ }
111
+
68
112
  // 검색줄 = 하나의 입력칸이다. 포커스가 여기 있으면 입력 커서(▌)로 드러내고,
69
- // 컬러에서는 줄 전체를 반전시켜 "지금 여기에 타이핑된다"를 분명히 한다 —
113
+ // 컬러에서는 입력 영역을 반전시켜 "지금 여기에 타이핑된다"를 분명히 한다 —
70
114
  // 이 상태에서만 스페이스가 선택이 아니라 검색어로 들어가기 때문이다.
71
- function searchLine(state, { limit, color, paint, t }) {
115
+ //
116
+ // 반전은 **입력 영역까지만** 칠한다. 줄 끝까지 채워 반전시키면 오른쪽 필터
117
+ // 표시가 반전에 먹혀 읽히지 않는다.
118
+ function searchLine(state, { limit, color, paint, t, filter = '' }) {
72
119
  const prefix = t('tui.search.prefix')
73
- const room = Math.max(0, limit - width(prefix))
120
+ // filter는 항상 순수 텍스트다(filterSegment 참고) — 폭은 색을 입히기 전에
121
+ // 재고, tail을 화면에 낼 때만 paint로 칠한다.
122
+ const tailWidth = filter ? width(filter) + 2 : 0
123
+ const tail = filter ? ` ${paint(BOLD, filter)}` : ''
124
+ const room = Math.max(0, limit - width(prefix) - tailWidth)
125
+
126
+ // 필터를 넣을 자리가 없으면 필터를 버린다 — 타이핑 중인 글자가 사라지는
127
+ // 쪽이 더 나쁘다. 탭 줄의 shown/total 표기가 필터가 걸려 있음을 이미 알린다.
128
+ if (room < 8) return searchLine(state, { limit, color, paint, t, filter: '' })
129
+
74
130
  if (state.focus === 'search') {
75
- const text = `${prefix}${cut(`${state.query}▌`, room)}`
76
- return color ? `${REVERSE}${pad(text, limit)}${RESET}` : text
131
+ const field = `${prefix}${cut(`${state.query}▌`, room)}`
132
+ const body = color ? `${REVERSE}${pad(field, limit - tailWidth)}${RESET}` : field
133
+ return `${body}${tail}`
77
134
  }
78
- if (state.query) return `${prefix}${cut(state.query, room)}`
79
- return `${prefix}${paint(DIM, cut(t('tui.search.placeholder'), room))}`
135
+ const body = state.query
136
+ ? `${prefix}${cut(state.query, room)}`
137
+ : `${prefix}${paint(DIM, cut(t('tui.search.placeholder'), room))}`
138
+ return `${body}${tail}`
80
139
  }
81
140
 
82
141
  export function render(state, opts = {}) {
83
142
  // columns로 받는다 — width로 두면 모듈의 width() 함수를 함수 스코프 전체에서 가린다.
84
- const { width: columns = 80, height = 24, repo = '', dryRun = false, color = false, status = '', t = createT('en') } = opts
143
+ const { width: columns = 80, height = 24, repo = '', dryRun = false, color = false, status = '', detailExpanded = false, cliOptions = [], t = createT('en') } = opts
85
144
 
86
145
  // 마지막 칸은 비워 둔다 — 폭을 꽉 채우면 터미널이 줄을 넘긴다.
87
146
  const w = Math.max(24, columns - 1)
@@ -94,19 +153,31 @@ export function render(state, opts = {}) {
94
153
  const counts = t('tui.counts', { picked, total: items.length })
95
154
  const head = cut(`${title} ${counts} ${repo}`, w)
96
155
  const searching = String(state.query ?? '').trim() !== ''
156
+ // 목록이 좁혀졌는가 — 검색어·CLI 필터 어느 쪽이든 활성 탭 안에서만 걸리는
157
+ // 좁힘이라, 다른 탭에 뭐가 남았는지 알리는 몫(탭 줄의 shown/total)은
158
+ // 둘이 똑같이 진다.
159
+ const narrowed = searching || Boolean(state.cliFilter)
97
160
 
161
+ const filter = filterSegment(state, { t, options: cliOptions })
98
162
  const lines = [
99
163
  color ? `${BOLD}${title}${RESET}${cut(` ${counts} ${repo}`, Math.max(0, w - width(title)))}` : head,
100
- tabBar(state, { width: w, color, searching, t }),
101
- searchLine(state, { limit: w, color, paint, t }),
164
+ tabBar(state, { width: w, color, narrowed, t }),
165
+ searchLine(state, { limit: w, color, paint, t, filter }),
102
166
  '',
103
167
  ]
104
168
 
105
- const body = bodyHeight(height)
169
+ const body = bodyHeight(height, detailExpanded)
106
170
  const all = displayList(state)
107
171
 
108
172
  if (all.length === 0) {
109
- lines.push(paint(DIM, cut(searching ? t('tui.empty.filtered') : t('tui.empty.none'), w)))
173
+ // 빈 이유가 검색어 때문일 수도 있는데, cliFilter를 먼저 물으면 "이 CLI에는
174
+ // 배선된 게 없다"는 문구가 거짓으로 뜰 수 있다 — 그 CLI가 이 탭의 다른
175
+ // 항목들을 지원하더라도, 검색어가 전부 걸러내면 이 분기에 들어온다.
176
+ // 검색어가 있으면 그쪽이 항상 참이므로 우선한다.
177
+ const empty = searching
178
+ ? t('tui.empty.filtered')
179
+ : state.cliFilter ? t('tui.filter.empty', { cli: state.cliFilter }) : t('tui.empty.none')
180
+ if (body > 0) lines.push(paint(DIM, cut(empty, w)))
110
181
  for (let i = 1; i < body; i++) lines.push('')
111
182
  } else {
112
183
  const window = all.slice(state.offset, state.offset + body)
@@ -128,6 +199,18 @@ export function render(state, opts = {}) {
128
199
  for (let i = window.length; i < body; i++) lines.push('')
129
200
  }
130
201
 
202
+ // 상세 패널 — 목록 아래 고정 자리. 높이가 커서와 무관하므로 목록이
203
+ // 출렁이지 않는다. 지면이 모자라면 panelHeight가 0을 돌려 통째로 빠진다.
204
+ const panel = panelHeight(height, detailExpanded)
205
+ if (panel > 0) {
206
+ lines.push(paint(DIM, '─'.repeat(w)))
207
+ const room = panel - 1
208
+ const detail = detailLines(currentRow(state), { width: w, height: room, t })
209
+ const shown = detail.length > 0 ? detail : [paint(DIM, cut(t('detail.empty'), w))]
210
+ for (const line of shown.slice(0, room)) lines.push(line)
211
+ for (let i = shown.length; i < room; i++) lines.push('')
212
+ }
213
+
131
214
  const hint = state.focus === 'search' ? t('tui.hint.search') : t('tui.hint.list')
132
215
  lines.push('')
133
216
  lines.push(paint(DIM, cut(status || hint, w)))
@@ -146,7 +229,7 @@ export function renderReview(changes, opts = {}) {
146
229
  const title = `${t('tui.review.title', { count: changes.length })}${dryRun ? ' (dry-run)' : ''}`
147
230
  const lines = [color ? `${BOLD}${cut(title, w)}${RESET}` : cut(title, w), '']
148
231
 
149
- const body = bodyHeight(height)
232
+ const body = reviewBodyHeight(height)
150
233
  const room = Math.max(1, body - 1)
151
234
  const shown = changes.slice(0, room)
152
235
  for (const c of shown) {
package/lib/tui/rows.mjs CHANGED
@@ -11,6 +11,7 @@ import { createT, toText } from '../i18n/index.mjs'
11
11
  import { loadCatalog, buildItems, netFetch, CATALOG_PATH } from '../design-md/catalog.mjs'
12
12
  import { discoverSources, extraDirsFromEnv } from '../design-md/scan.mjs'
13
13
  import { refreshCatalog, updateInstalled, findStale, categoryLabel } from '../design-md/flow.mjs'
14
+ import { unsupportedGroups } from './detail.mjs'
14
15
 
15
16
  // 섹션은 탭 이름이자 정렬 키이자 state.tabs의 원소다. 표시 문자열을 그대로
16
17
  // 쓰면 번역하는 순간 정렬이 깨진다 — id를 두고 render.mjs가 표시할 때만
@@ -53,17 +54,15 @@ function sectionTerms(t, section) {
53
54
  // 미지원 CLI를 **같은 사유끼리 묶어** 한 줄로 만든다. 사유 하나가 CLI 아홉 개에
54
55
  // 그대로 반복되면 줄만 길어지고 "무엇이 왜 빠졌는가"는 오히려 묻힌다.
55
56
  // 사유가 둘 이상이면(ponytail처럼) 갈래가 그대로 보인다.
57
+ // 그룹핑은 detail.mjs가 한다 — 상세 패널과 규칙을 한 곳에 둔다.
56
58
  export function unsupportedText(item, t) {
57
- const entries = Object.entries(item.unsupported ?? {})
58
- if (entries.length === 0) return null
59
- const byReason = new Map()
60
- for (const [cli, why] of entries) {
61
- const text = toText(t, why)
62
- if (!byReason.has(text)) byReason.set(text, [])
63
- byReason.get(text).push(cli)
64
- }
65
- const groups = [...byReason].map(([why, clis]) => t('item.unsupportedGroup', { clis: clis.join('·'), why }))
66
- return t('item.unsupportedList', { count: entries.length, groups: groups.join(' / ') })
59
+ const groups = unsupportedGroups(item, t)
60
+ if (groups.length === 0) return null
61
+ const count = groups.reduce((n, g) => n + g.clis.length, 0)
62
+ const text = groups
63
+ .map((g) => t('item.unsupportedGroup', { clis: g.clis.join('·'), why: g.why }))
64
+ .join(' / ')
65
+ return t('item.unsupportedList', { count, groups: text })
67
66
  }
68
67
 
69
68
  // 에이전트 항목 힌트 — 상태·CLI 커버리지·설치 위치·미지원 사유까지 한 줄에 담는다.
@@ -97,9 +96,28 @@ export function designHint(state, multiProvider = false, t) {
97
96
  return parts.filter(Boolean).join(' · ')
98
97
  }
99
98
 
99
+ // 목록 행에 실제로 찍히는 힌트. 80칸 터미널이면 힌트 자리는 49칸(한글 24자)뿐이라,
100
+ // 예전처럼 사유까지 이어 붙이면 뒤쪽이 통째로 잘렸다. 사유·note·detail은
101
+ // 상세 패널이 여러 줄로 편다 — 여기 남는 것은 잘릴 일이 없는 두 가지뿐이다.
102
+ export function agentShortHint(item, state, t = createT('en')) {
103
+ const parts = []
104
+ if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
105
+ if (item.supports) parts.push(t('item.cliCoverage', { covered: item.supports.length, total: CLI_IDS.length }))
106
+ return parts.join(' · ')
107
+ }
108
+
109
+ // design.md도 같은 이유로 줄인다. 설명 전문은 상세 패널이 맡는다.
110
+ export function designShortHint(state, multiProvider = false, t = createT('en')) {
111
+ const parts = []
112
+ if (multiProvider) parts.push(state.item.providerId)
113
+ parts.push(categoryLabel(t, state.item.designCategory))
114
+ if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
115
+ return parts.filter(Boolean).join(' · ')
116
+ }
117
+
100
118
  // group = 탭 **안쪽**의 소분류 헤더. design.md만 76개라 카테고리로 갈라야 읽힌다.
101
119
  // 나머지 탭은 항목이 적어 그룹 없이 평평하게 둔다.
102
- function itemRow({ id, section, label, hint, status, previewTarget = null, item, extra = '', group = null, t = createT('en') }) {
120
+ function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = null, status, previewTarget = null, item, extra = '', group = null, t = createT('en') }) {
103
121
  return {
104
122
  kind: 'item',
105
123
  id,
@@ -107,10 +125,14 @@ function itemRow({ id, section, label, hint, status, previewTarget = null, item,
107
125
  group,
108
126
  label,
109
127
  hint,
128
+ // 화면에 안 찍히는 긴 힌트. 비대화형 목록(printPlain)과 검색이 쓴다 —
129
+ // 짧은 힌트로 검색하면 'AGENTS.md'로 ponytail을 찾을 수 없게 된다.
130
+ fullHint,
131
+ statusDetail,
110
132
  status,
111
133
  previewTarget,
112
134
  item,
113
- searchText: `${label} ${hint} ${sectionTerms(t, section)} ${extra}`.toLowerCase(),
135
+ searchText: `${label} ${fullHint} ${sectionTerms(t, section)} ${extra}`.toLowerCase(),
114
136
  }
115
137
  }
116
138
 
@@ -122,6 +144,8 @@ function actionRow({ id, label, hint, run = null, t = createT('en') }) {
122
144
  group: null,
123
145
  label,
124
146
  hint,
147
+ fullHint: hint,
148
+ statusDetail: null,
125
149
  status: 'absent',
126
150
  previewTarget: null,
127
151
  run,
@@ -201,7 +225,9 @@ export function buildRows({ actions = [], agentStates = [], designStates = [], m
201
225
  section: s.item.category,
202
226
  group: s.item.group ?? null,
203
227
  label: s.item.label,
204
- hint: agentHint(s.item, s, t),
228
+ hint: agentShortHint(s.item, s, t),
229
+ fullHint: agentHint(s.item, s, t),
230
+ statusDetail: toText(t, s.detail) ?? null,
205
231
  status: s.status,
206
232
  item: s.item,
207
233
  extra: s.item.id,
@@ -221,7 +247,8 @@ export function buildRows({ actions = [], agentStates = [], designStates = [], m
221
247
  section: 'design',
222
248
  group: s.item.designCategory || CATCH_ALL_CATEGORY,
223
249
  label: s.item.label,
224
- hint: designHint(s, multiProvider, t),
250
+ hint: designShortHint(s, multiProvider, t),
251
+ fullHint: designHint(s, multiProvider, t),
225
252
  status: s.status,
226
253
  previewTarget: s.item.webUrl ?? s.item.previewPath ?? null,
227
254
  item: s.item,