@rch4com/agent-setup 1.10.0 → 1.11.1
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.
- package/LICENSE +21 -21
- package/README.md +199 -199
- package/install.mjs +163 -163
- package/lib/args.mjs +276 -276
- package/lib/bootstrap/adapter.mjs +96 -96
- package/lib/bootstrap/apply.mjs +380 -380
- package/lib/bootstrap/flow.mjs +140 -140
- package/lib/bootstrap/record.mjs +154 -154
- package/lib/bootstrap/templates.mjs +148 -148
- package/lib/bootstrap/text.mjs +19 -19
- package/lib/catalog.mjs +234 -234
- package/lib/claude-plugins.mjs +64 -64
- package/lib/clis.mjs +111 -111
- package/lib/context.mjs +66 -66
- package/lib/deps.mjs +17 -17
- package/lib/design-md/catalog.json +454 -454
- package/lib/design-md/catalog.mjs +266 -266
- package/lib/design-md/flow.mjs +192 -192
- package/lib/design-md/open.mjs +51 -51
- package/lib/design-md/providers/awesome-design-md.mjs +124 -124
- package/lib/design-md/providers/index.mjs +9 -9
- package/lib/design-md/scan.mjs +295 -295
- package/lib/engine.mjs +115 -115
- package/lib/gitignore.mjs +18 -18
- package/lib/i18n/catalog/en.mjs +431 -426
- package/lib/i18n/catalog/ko.mjs +416 -411
- package/lib/i18n/detect.mjs +39 -39
- package/lib/i18n/index.mjs +93 -93
- package/lib/items/mcp.codebase-memory.mjs +6 -6
- package/lib/items/mcp.notion.mjs +6 -6
- package/lib/items/mcp.supabase.mjs +6 -6
- package/lib/items/mcp.vercel.mjs +6 -6
- package/lib/items/plugin.bkit.mjs +5 -3
- package/lib/items/plugin.ecc.mjs +17 -2
- package/lib/items/plugin.mattpocock-skills.mjs +18 -8
- package/lib/items/plugin.ponytail.mjs +106 -104
- package/lib/items/plugin.superpowers.mjs +7 -4
- package/lib/items/plugin.understand-anything.mjs +13 -2
- package/lib/items/skill.diagram-design.mjs +14 -0
- package/lib/items/skill.gsd.mjs +46 -44
- package/lib/items/skill.gstack.mjs +54 -54
- package/lib/jsonfile.mjs +40 -40
- package/lib/status.mjs +93 -93
- package/lib/tomlfile.mjs +60 -60
- package/lib/tui/detail.mjs +127 -127
- package/lib/tui/progress.mjs +160 -160
- package/lib/tui/render.mjs +268 -268
- package/lib/tui/rows.mjs +317 -317
- package/lib/tui/run.mjs +396 -396
- package/lib/tui/state.mjs +249 -249
- package/lib/update.mjs +85 -85
- package/lib/width.mjs +115 -115
- package/package.json +36 -36
package/lib/tui/render.mjs
CHANGED
|
@@ -1,268 +1,268 @@
|
|
|
1
|
-
// 순수 렌더 — 상태와 크기를 받아 화면 줄 배열을 돌려준다.
|
|
2
|
-
// 커서 이동·지우기 같은 제어 시퀀스는 run.mjs가 맡는다.
|
|
3
|
-
import { displayList, tabCounts, activeTab, currentRow } from './state.mjs'
|
|
4
|
-
import { CLI_IDS } from '../clis.mjs'
|
|
5
|
-
import { createT } from '../i18n/index.mjs'
|
|
6
|
-
import { categoryLabel } from '../design-md/flow.mjs'
|
|
7
|
-
import { cut, pad, width } from '../width.mjs'
|
|
8
|
-
import { detailLines } from './detail.mjs'
|
|
9
|
-
|
|
10
|
-
// 폭 계산은 화면 전용이 아니다 — 비대화형 목록도 같은 열 맞춤이 필요해
|
|
11
|
-
// lib/width.mjs에 있다. 여기서 다시 내보내는 것은 호출부(테스트 포함)가
|
|
12
|
-
// "화면 폭"을 render에서 찾는 기존 습관을 깨지 않기 위해서다.
|
|
13
|
-
export { cut, width }
|
|
14
|
-
|
|
15
|
-
const ESC = String.fromCharCode(27)
|
|
16
|
-
const DIM = `${ESC}[2m`
|
|
17
|
-
const BOLD = `${ESC}[1m`
|
|
18
|
-
const REVERSE = `${ESC}[7m`
|
|
19
|
-
const RESET = `${ESC}[0m`
|
|
20
|
-
|
|
21
|
-
// 머리글·탭줄·검색줄·구분 공백·바닥글이 차지하는 줄 수.
|
|
22
|
-
const CHROME = 6
|
|
23
|
-
export const LABEL_WIDTH = 24
|
|
24
|
-
|
|
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) {
|
|
53
|
-
return Math.max(3, height - CHROME)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const MARK = { action: '▶', on: '×', off: ' ' }
|
|
57
|
-
|
|
58
|
-
// 대괄호는 "여러 개 고를 수 있다"는 보편적 신호다 — 한 파일에 한 벌만 놓이는
|
|
59
|
-
// 배타 묶음(커밋 템플릿의 두 언어판)에 그대로 쓰면 화면이 거짓말을 한다.
|
|
60
|
-
// 둥근 괄호로 바꿔 라디오임을 드러낸다. 안쪽 글자는 그대로 둔다: ×는 이미
|
|
61
|
-
// 이 화면이 쓰는 글자라 폭 계산이 검증된 값이고, ●·•는 동아시아 폭이
|
|
62
|
-
// 모호(Ambiguous)해 터미널에 따라 두 칸으로 그려져 열을 밀 수 있다.
|
|
63
|
-
function checkbox(row, selected) {
|
|
64
|
-
if (row.kind === 'action') return `[${MARK.action}]`
|
|
65
|
-
const mark = selected.has(row.id) ? MARK.on : MARK.off
|
|
66
|
-
return row.exclusive ? `(${mark})` : `[${mark}]`
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// 탭 줄. 목록이 좁혀졌으면(검색어·CLI 필터 어느 쪽이든) 탭마다 적중 수를
|
|
70
|
-
// 보여 준다 — 검색·필터는 활성 탭 안으로만 걸리므로, 다른 탭에 결과가
|
|
71
|
-
// 있다는 사실을 여기서 알린다. 그래야 필터가 사용자를 빈 탭에 가둬 두고
|
|
72
|
-
// 아무 설명도 없이 방치하는 일이 없다.
|
|
73
|
-
// 폭이 모자라면 활성 탭 하나 + 위치 표시로 줄인다(줄바꿈은 화면을 무너뜨린다).
|
|
74
|
-
// tab 자체는 rows.mjs가 만든 소문자 id다 — 화면에는 t로 번역한 이름만 낸다.
|
|
75
|
-
export function tabBar(state, { width: limit, color = false, narrowed = false, t = createT('en') } = {}) {
|
|
76
|
-
const counts = tabCounts(state)
|
|
77
|
-
if (counts.length === 0) return ''
|
|
78
|
-
const active = activeTab(state)
|
|
79
|
-
|
|
80
|
-
const segs = counts.map(({ tab, shown, total }) => ({
|
|
81
|
-
tab,
|
|
82
|
-
text: narrowed ? `${t(`section.${tab}`)} ${shown}/${total}` : `${t(`section.${tab}`)} ${total}`,
|
|
83
|
-
active: tab === active,
|
|
84
|
-
empty: narrowed && shown === 0,
|
|
85
|
-
}))
|
|
86
|
-
|
|
87
|
-
const SEP = ' '
|
|
88
|
-
const plain = segs.map((s) => s.text).join(SEP)
|
|
89
|
-
// 실제로 그려지는 줄은 plain보다 늘 2칸 더 넓다 — 세그먼트 사이의 구분 공백(SEP)이
|
|
90
|
-
// 아니라 각 세그먼트 앞뒤의 표시(활성은 대괄호, 나머지는 공백)가 SEP 하나만큼을
|
|
91
|
-
// 대체하고 그 위에 1칸씩 더 얹기 때문이다(세그먼트 수와 무관하게 항상 +2).
|
|
92
|
-
// 이 2칸을 빼먹으면 영어처럼 긴 라벨에서 줄바꿈을 놓친다.
|
|
93
|
-
if (width(plain) + 2 > limit) {
|
|
94
|
-
const i = segs.findIndex((s) => s.active)
|
|
95
|
-
const compact = `‹ ${segs[i]?.text ?? ''} › ${i + 1}/${segs.length}`
|
|
96
|
-
return color ? `${BOLD}${cut(compact, limit)}${RESET}` : cut(compact, limit)
|
|
97
|
-
}
|
|
98
|
-
if (!color) return segs.map((s) => (s.active ? `[${s.text}]` : ` ${s.text} `)).join('')
|
|
99
|
-
return segs
|
|
100
|
-
.map((s) => (s.active ? `${REVERSE} ${s.text} ${RESET}` : s.empty ? `${DIM} ${s.text} ${RESET}` : ` ${s.text} `))
|
|
101
|
-
.join('')
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// 필터 표시. 검색줄 오른쪽 끝에 붙는다 — 새 줄을 만들면 CHROME이 늘어
|
|
105
|
-
// 목록이 그만큼 준다. 필터가 없으면 아무것도 내지 않는다(잡음 방지).
|
|
106
|
-
//
|
|
107
|
-
// 색은 여기서 입히지 않는다 — 이 반환값이 searchLine의 tailWidth 계산에도
|
|
108
|
-
// 그대로 쓰이는데, 색 코드를 섞으면 ESC 시퀀스가 표시 폭에 끼어든다(폭은
|
|
109
|
-
// 항상 색을 입히기 전에 재야 한다). 칠하는 일은 호출부가 폭 계산을 끝낸
|
|
110
|
-
// 뒤 마지막에 한다.
|
|
111
|
-
export function filterSegment(state, { t = createT('en'), options = [] } = {}) {
|
|
112
|
-
if (!state.cliFilter) return ''
|
|
113
|
-
const at = options.indexOf(state.cliFilter)
|
|
114
|
-
const pos = at === -1 ? '' : ` (${t('tui.filter.position', { current: at + 1, total: options.length })})`
|
|
115
|
-
return `${t('tui.filter.prefix')}${state.cliFilter}${pos}`
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// 검색줄 = 하나의 입력칸이다. 포커스가 여기 있으면 입력 커서(▌)로 드러내고,
|
|
119
|
-
// 컬러에서는 입력 영역을 반전시켜 "지금 여기에 타이핑된다"를 분명히 한다 —
|
|
120
|
-
// 이 상태에서만 스페이스가 선택이 아니라 검색어로 들어가기 때문이다.
|
|
121
|
-
//
|
|
122
|
-
// 반전은 **입력 영역까지만** 칠한다. 줄 끝까지 채워 반전시키면 오른쪽 필터
|
|
123
|
-
// 표시가 반전에 먹혀 읽히지 않는다.
|
|
124
|
-
function searchLine(state, { limit, color, paint, t, filter = '' }) {
|
|
125
|
-
const prefix = t('tui.search.prefix')
|
|
126
|
-
// filter는 항상 순수 텍스트다(filterSegment 참고) — 폭은 색을 입히기 전에
|
|
127
|
-
// 재고, tail을 화면에 낼 때만 paint로 칠한다.
|
|
128
|
-
const tailWidth = filter ? width(filter) + 2 : 0
|
|
129
|
-
const tail = filter ? ` ${paint(BOLD, filter)}` : ''
|
|
130
|
-
const room = Math.max(0, limit - width(prefix) - tailWidth)
|
|
131
|
-
|
|
132
|
-
// 필터를 넣을 자리가 없으면 필터를 버린다 — 타이핑 중인 글자가 사라지는
|
|
133
|
-
// 쪽이 더 나쁘다. 탭 줄의 shown/total 표기가 필터가 걸려 있음을 이미 알린다.
|
|
134
|
-
if (room < 8) return searchLine(state, { limit, color, paint, t, filter: '' })
|
|
135
|
-
|
|
136
|
-
if (state.focus === 'search') {
|
|
137
|
-
const field = `${prefix}${cut(`${state.query}▌`, room)}`
|
|
138
|
-
const body = color ? `${REVERSE}${pad(field, limit - tailWidth)}${RESET}` : field
|
|
139
|
-
return `${body}${tail}`
|
|
140
|
-
}
|
|
141
|
-
const body = state.query
|
|
142
|
-
? `${prefix}${cut(state.query, room)}`
|
|
143
|
-
: `${prefix}${paint(DIM, cut(t('tui.search.placeholder'), room))}`
|
|
144
|
-
return `${body}${tail}`
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function render(state, opts = {}) {
|
|
148
|
-
// columns로 받는다 — width로 두면 모듈의 width() 함수를 함수 스코프 전체에서 가린다.
|
|
149
|
-
const { width: columns = 80, height = 24, repo = '', dryRun = false, color = false, status = '', detailExpanded = false, cliOptions = [], t = createT('en') } = opts
|
|
150
|
-
|
|
151
|
-
// 마지막 칸은 비워 둔다 — 폭을 꽉 채우면 터미널이 줄을 넘긴다.
|
|
152
|
-
const w = Math.max(24, columns - 1)
|
|
153
|
-
const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
|
|
154
|
-
|
|
155
|
-
const items = state.rows.filter((r) => r.kind === 'item')
|
|
156
|
-
const picked = items.filter((r) => state.selected.has(r.id)).length
|
|
157
|
-
|
|
158
|
-
const title = `agent-installer${dryRun ? ' (dry-run)' : ''}`
|
|
159
|
-
const counts = t('tui.counts', { picked, total: items.length })
|
|
160
|
-
const head = cut(`${title} ${counts} ${repo}`, w)
|
|
161
|
-
const searching = String(state.query ?? '').trim() !== ''
|
|
162
|
-
// 목록이 좁혀졌는가 — 검색어·CLI 필터 어느 쪽이든 활성 탭 안에서만 걸리는
|
|
163
|
-
// 좁힘이라, 다른 탭에 뭐가 남았는지 알리는 몫(탭 줄의 shown/total)은
|
|
164
|
-
// 둘이 똑같이 진다.
|
|
165
|
-
const narrowed = searching || Boolean(state.cliFilter)
|
|
166
|
-
|
|
167
|
-
const filter = filterSegment(state, { t, options: cliOptions })
|
|
168
|
-
const lines = [
|
|
169
|
-
color ? `${BOLD}${title}${RESET}${cut(` ${counts} ${repo}`, Math.max(0, w - width(title)))}` : head,
|
|
170
|
-
tabBar(state, { width: w, color, narrowed, t }),
|
|
171
|
-
searchLine(state, { limit: w, color, paint, t, filter }),
|
|
172
|
-
'',
|
|
173
|
-
]
|
|
174
|
-
|
|
175
|
-
const body = bodyHeight(height, detailExpanded)
|
|
176
|
-
const all = displayList(state)
|
|
177
|
-
|
|
178
|
-
if (all.length === 0) {
|
|
179
|
-
// 빈 이유가 검색어 때문일 수도 있는데, cliFilter를 먼저 물으면 "이 CLI에는
|
|
180
|
-
// 배선된 게 없다"는 문구가 거짓으로 뜰 수 있다 — 그 CLI가 이 탭의 다른
|
|
181
|
-
// 항목들을 지원하더라도, 검색어가 전부 걸러내면 이 분기에 들어온다.
|
|
182
|
-
// 검색어가 있으면 그쪽이 항상 참이므로 우선한다.
|
|
183
|
-
const empty = searching
|
|
184
|
-
? t('tui.empty.filtered')
|
|
185
|
-
: state.cliFilter ? t('tui.filter.empty', { cli: state.cliFilter }) : t('tui.empty.none')
|
|
186
|
-
if (body > 0) lines.push(paint(DIM, cut(empty, w)))
|
|
187
|
-
for (let i = 1; i < body; i++) lines.push('')
|
|
188
|
-
} else {
|
|
189
|
-
const window = all.slice(state.offset, state.offset + body)
|
|
190
|
-
for (const entry of window) {
|
|
191
|
-
if (entry.type === 'header') {
|
|
192
|
-
const count = entry.shown === entry.total ? `${entry.total}` : `${entry.shown}/${entry.total}`
|
|
193
|
-
// entry.section은 사실 row.group(디자인 카테고리) 값이다. categoryLabel은
|
|
194
|
-
// 우리가 만든 catch-all(__other·__local)만 번역하고, 공급자가 준 실제
|
|
195
|
-
// 카테고리는 이미 영어 데이터라 그대로 통과시킨다.
|
|
196
|
-
lines.push(paint(DIM, cut(` ${categoryLabel(t, entry.section)} (${count})`, w)))
|
|
197
|
-
continue
|
|
198
|
-
}
|
|
199
|
-
const { row, index } = entry
|
|
200
|
-
const here = index === state.cursor
|
|
201
|
-
const hintWidth = Math.max(0, w - LABEL_WIDTH - 6)
|
|
202
|
-
const text = cut(`${here ? '❯' : ' '} ${checkbox(row, state.selected)} ${pad(row.label, LABEL_WIDTH)} ${cut(row.hint, hintWidth)}`, w)
|
|
203
|
-
lines.push(here ? paint(REVERSE, text) : text)
|
|
204
|
-
}
|
|
205
|
-
for (let i = window.length; i < body; i++) lines.push('')
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// 상세 패널 — 목록 아래 고정 자리. 높이가 커서와 무관하므로 목록이
|
|
209
|
-
// 출렁이지 않는다. 지면이 모자라면 panelHeight가 0을 돌려 통째로 빠진다.
|
|
210
|
-
const panel = panelHeight(height, detailExpanded)
|
|
211
|
-
if (panel > 0) {
|
|
212
|
-
lines.push(paint(DIM, '─'.repeat(w)))
|
|
213
|
-
const room = panel - 1
|
|
214
|
-
const detail = detailLines(currentRow(state), { width: w, height: room, t })
|
|
215
|
-
const shown = detail.length > 0 ? detail : [paint(DIM, cut(t('detail.empty'), w))]
|
|
216
|
-
for (const line of shown.slice(0, room)) lines.push(line)
|
|
217
|
-
for (let i = shown.length; i < room; i++) lines.push('')
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// 종료 키는 힌트 줄 **오른쪽 끝에 고정**한다. 나머지 힌트는 좁은 터미널에서
|
|
221
|
-
// 뒤쪽부터 잘려 나가는데, 하필 그때 가장 절실한 것이 "여기서 어떻게
|
|
222
|
-
// 빠져나가나"다 — 잘려도 되는 안내와 절대 사라지면 안 되는 안내를 같은
|
|
223
|
-
// 줄에 이어 붙여 두면 늘 후자가 먼저 사라진다. 상태 메시지가 힌트를
|
|
224
|
-
// 대신할 때도 이 자리는 그대로 남는다.
|
|
225
|
-
const hint = state.focus === 'search' ? t('tui.hint.search') : t('tui.hint.list')
|
|
226
|
-
const quit = t('tui.hint.quit')
|
|
227
|
-
const room = w - width(quit) - 2
|
|
228
|
-
lines.push('')
|
|
229
|
-
lines.push(room < 8
|
|
230
|
-
? paint(BOLD, cut(quit, w))
|
|
231
|
-
: `${paint(DIM, pad(status || hint, room))} ${paint(BOLD, quit)}`)
|
|
232
|
-
return lines
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const CHANGE_MARK = { install: '+', complete: '±', uninstall: '−' }
|
|
236
|
-
|
|
237
|
-
// 제출 검토 화면 — 적용 직전에 무엇이 바뀌는지만 보여 준다.
|
|
238
|
-
// 목록이 길면 잘라내고 남은 건수를 알린다(스크롤 대신) — 여기서 길을 잃을 이유는 없다.
|
|
239
|
-
export function renderReview(changes, opts = {}) {
|
|
240
|
-
const { width: columns = 80, height = 24, dryRun = false, color = false, t = createT('en') } = opts
|
|
241
|
-
const w = Math.max(24, columns - 1)
|
|
242
|
-
const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
|
|
243
|
-
|
|
244
|
-
const title = `${t('tui.review.title', { count: changes.length })}${dryRun ? ' (dry-run)' : ''}`
|
|
245
|
-
const lines = [color ? `${BOLD}${cut(title, w)}${RESET}` : cut(title, w), '']
|
|
246
|
-
|
|
247
|
-
const body = reviewBodyHeight(height)
|
|
248
|
-
const room = Math.max(1, body - 1)
|
|
249
|
-
const shown = changes.slice(0, room)
|
|
250
|
-
for (const c of shown) {
|
|
251
|
-
// 적용 직전 마지막 화면이다. 일부 CLI에만 들어가는 항목은 여기서도 그 사실을
|
|
252
|
-
// 밝힌다 — 목록에서 지나쳤더라도 되돌릴 수 있는 마지막 지점이다.
|
|
253
|
-
// 전부 지원하는 항목은 조용히 둔다: 경고가 흔해지면 아무도 읽지 않는다.
|
|
254
|
-
const partial = c.item.supports && c.item.supports.length < CLI_IDS.length
|
|
255
|
-
const cov = partial ? ` · ${t('item.cliCoverage', { covered: c.item.supports.length, total: CLI_IDS.length })}` : ''
|
|
256
|
-
lines.push(cut(` ${CHANGE_MARK[c.action] ?? '?'} ${pad(t(`change.${c.action}`), 10)} ${c.item.label}${cov}`, w))
|
|
257
|
-
}
|
|
258
|
-
if (changes.length > shown.length) {
|
|
259
|
-
lines.push(paint(DIM, cut(t('tui.review.more', { count: changes.length - shown.length }), w)))
|
|
260
|
-
} else {
|
|
261
|
-
lines.push('')
|
|
262
|
-
}
|
|
263
|
-
for (let i = shown.length + 1; i < body; i++) lines.push('')
|
|
264
|
-
|
|
265
|
-
lines.push('')
|
|
266
|
-
lines.push(paint(DIM, cut(t('tui.review.hint'), w)))
|
|
267
|
-
return lines
|
|
268
|
-
}
|
|
1
|
+
// 순수 렌더 — 상태와 크기를 받아 화면 줄 배열을 돌려준다.
|
|
2
|
+
// 커서 이동·지우기 같은 제어 시퀀스는 run.mjs가 맡는다.
|
|
3
|
+
import { displayList, tabCounts, activeTab, currentRow } from './state.mjs'
|
|
4
|
+
import { CLI_IDS } from '../clis.mjs'
|
|
5
|
+
import { createT } from '../i18n/index.mjs'
|
|
6
|
+
import { categoryLabel } from '../design-md/flow.mjs'
|
|
7
|
+
import { cut, pad, width } from '../width.mjs'
|
|
8
|
+
import { detailLines } from './detail.mjs'
|
|
9
|
+
|
|
10
|
+
// 폭 계산은 화면 전용이 아니다 — 비대화형 목록도 같은 열 맞춤이 필요해
|
|
11
|
+
// lib/width.mjs에 있다. 여기서 다시 내보내는 것은 호출부(테스트 포함)가
|
|
12
|
+
// "화면 폭"을 render에서 찾는 기존 습관을 깨지 않기 위해서다.
|
|
13
|
+
export { cut, width }
|
|
14
|
+
|
|
15
|
+
const ESC = String.fromCharCode(27)
|
|
16
|
+
const DIM = `${ESC}[2m`
|
|
17
|
+
const BOLD = `${ESC}[1m`
|
|
18
|
+
const REVERSE = `${ESC}[7m`
|
|
19
|
+
const RESET = `${ESC}[0m`
|
|
20
|
+
|
|
21
|
+
// 머리글·탭줄·검색줄·구분 공백·바닥글이 차지하는 줄 수.
|
|
22
|
+
const CHROME = 6
|
|
23
|
+
export const LABEL_WIDTH = 24
|
|
24
|
+
|
|
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) {
|
|
53
|
+
return Math.max(3, height - CHROME)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const MARK = { action: '▶', on: '×', off: ' ' }
|
|
57
|
+
|
|
58
|
+
// 대괄호는 "여러 개 고를 수 있다"는 보편적 신호다 — 한 파일에 한 벌만 놓이는
|
|
59
|
+
// 배타 묶음(커밋 템플릿의 두 언어판)에 그대로 쓰면 화면이 거짓말을 한다.
|
|
60
|
+
// 둥근 괄호로 바꿔 라디오임을 드러낸다. 안쪽 글자는 그대로 둔다: ×는 이미
|
|
61
|
+
// 이 화면이 쓰는 글자라 폭 계산이 검증된 값이고, ●·•는 동아시아 폭이
|
|
62
|
+
// 모호(Ambiguous)해 터미널에 따라 두 칸으로 그려져 열을 밀 수 있다.
|
|
63
|
+
function checkbox(row, selected) {
|
|
64
|
+
if (row.kind === 'action') return `[${MARK.action}]`
|
|
65
|
+
const mark = selected.has(row.id) ? MARK.on : MARK.off
|
|
66
|
+
return row.exclusive ? `(${mark})` : `[${mark}]`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 탭 줄. 목록이 좁혀졌으면(검색어·CLI 필터 어느 쪽이든) 탭마다 적중 수를
|
|
70
|
+
// 보여 준다 — 검색·필터는 활성 탭 안으로만 걸리므로, 다른 탭에 결과가
|
|
71
|
+
// 있다는 사실을 여기서 알린다. 그래야 필터가 사용자를 빈 탭에 가둬 두고
|
|
72
|
+
// 아무 설명도 없이 방치하는 일이 없다.
|
|
73
|
+
// 폭이 모자라면 활성 탭 하나 + 위치 표시로 줄인다(줄바꿈은 화면을 무너뜨린다).
|
|
74
|
+
// tab 자체는 rows.mjs가 만든 소문자 id다 — 화면에는 t로 번역한 이름만 낸다.
|
|
75
|
+
export function tabBar(state, { width: limit, color = false, narrowed = false, t = createT('en') } = {}) {
|
|
76
|
+
const counts = tabCounts(state)
|
|
77
|
+
if (counts.length === 0) return ''
|
|
78
|
+
const active = activeTab(state)
|
|
79
|
+
|
|
80
|
+
const segs = counts.map(({ tab, shown, total }) => ({
|
|
81
|
+
tab,
|
|
82
|
+
text: narrowed ? `${t(`section.${tab}`)} ${shown}/${total}` : `${t(`section.${tab}`)} ${total}`,
|
|
83
|
+
active: tab === active,
|
|
84
|
+
empty: narrowed && shown === 0,
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
const SEP = ' '
|
|
88
|
+
const plain = segs.map((s) => s.text).join(SEP)
|
|
89
|
+
// 실제로 그려지는 줄은 plain보다 늘 2칸 더 넓다 — 세그먼트 사이의 구분 공백(SEP)이
|
|
90
|
+
// 아니라 각 세그먼트 앞뒤의 표시(활성은 대괄호, 나머지는 공백)가 SEP 하나만큼을
|
|
91
|
+
// 대체하고 그 위에 1칸씩 더 얹기 때문이다(세그먼트 수와 무관하게 항상 +2).
|
|
92
|
+
// 이 2칸을 빼먹으면 영어처럼 긴 라벨에서 줄바꿈을 놓친다.
|
|
93
|
+
if (width(plain) + 2 > limit) {
|
|
94
|
+
const i = segs.findIndex((s) => s.active)
|
|
95
|
+
const compact = `‹ ${segs[i]?.text ?? ''} › ${i + 1}/${segs.length}`
|
|
96
|
+
return color ? `${BOLD}${cut(compact, limit)}${RESET}` : cut(compact, limit)
|
|
97
|
+
}
|
|
98
|
+
if (!color) return segs.map((s) => (s.active ? `[${s.text}]` : ` ${s.text} `)).join('')
|
|
99
|
+
return segs
|
|
100
|
+
.map((s) => (s.active ? `${REVERSE} ${s.text} ${RESET}` : s.empty ? `${DIM} ${s.text} ${RESET}` : ` ${s.text} `))
|
|
101
|
+
.join('')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 필터 표시. 검색줄 오른쪽 끝에 붙는다 — 새 줄을 만들면 CHROME이 늘어
|
|
105
|
+
// 목록이 그만큼 준다. 필터가 없으면 아무것도 내지 않는다(잡음 방지).
|
|
106
|
+
//
|
|
107
|
+
// 색은 여기서 입히지 않는다 — 이 반환값이 searchLine의 tailWidth 계산에도
|
|
108
|
+
// 그대로 쓰이는데, 색 코드를 섞으면 ESC 시퀀스가 표시 폭에 끼어든다(폭은
|
|
109
|
+
// 항상 색을 입히기 전에 재야 한다). 칠하는 일은 호출부가 폭 계산을 끝낸
|
|
110
|
+
// 뒤 마지막에 한다.
|
|
111
|
+
export function filterSegment(state, { t = createT('en'), options = [] } = {}) {
|
|
112
|
+
if (!state.cliFilter) return ''
|
|
113
|
+
const at = options.indexOf(state.cliFilter)
|
|
114
|
+
const pos = at === -1 ? '' : ` (${t('tui.filter.position', { current: at + 1, total: options.length })})`
|
|
115
|
+
return `${t('tui.filter.prefix')}${state.cliFilter}${pos}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 검색줄 = 하나의 입력칸이다. 포커스가 여기 있으면 입력 커서(▌)로 드러내고,
|
|
119
|
+
// 컬러에서는 입력 영역을 반전시켜 "지금 여기에 타이핑된다"를 분명히 한다 —
|
|
120
|
+
// 이 상태에서만 스페이스가 선택이 아니라 검색어로 들어가기 때문이다.
|
|
121
|
+
//
|
|
122
|
+
// 반전은 **입력 영역까지만** 칠한다. 줄 끝까지 채워 반전시키면 오른쪽 필터
|
|
123
|
+
// 표시가 반전에 먹혀 읽히지 않는다.
|
|
124
|
+
function searchLine(state, { limit, color, paint, t, filter = '' }) {
|
|
125
|
+
const prefix = t('tui.search.prefix')
|
|
126
|
+
// filter는 항상 순수 텍스트다(filterSegment 참고) — 폭은 색을 입히기 전에
|
|
127
|
+
// 재고, tail을 화면에 낼 때만 paint로 칠한다.
|
|
128
|
+
const tailWidth = filter ? width(filter) + 2 : 0
|
|
129
|
+
const tail = filter ? ` ${paint(BOLD, filter)}` : ''
|
|
130
|
+
const room = Math.max(0, limit - width(prefix) - tailWidth)
|
|
131
|
+
|
|
132
|
+
// 필터를 넣을 자리가 없으면 필터를 버린다 — 타이핑 중인 글자가 사라지는
|
|
133
|
+
// 쪽이 더 나쁘다. 탭 줄의 shown/total 표기가 필터가 걸려 있음을 이미 알린다.
|
|
134
|
+
if (room < 8) return searchLine(state, { limit, color, paint, t, filter: '' })
|
|
135
|
+
|
|
136
|
+
if (state.focus === 'search') {
|
|
137
|
+
const field = `${prefix}${cut(`${state.query}▌`, room)}`
|
|
138
|
+
const body = color ? `${REVERSE}${pad(field, limit - tailWidth)}${RESET}` : field
|
|
139
|
+
return `${body}${tail}`
|
|
140
|
+
}
|
|
141
|
+
const body = state.query
|
|
142
|
+
? `${prefix}${cut(state.query, room)}`
|
|
143
|
+
: `${prefix}${paint(DIM, cut(t('tui.search.placeholder'), room))}`
|
|
144
|
+
return `${body}${tail}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function render(state, opts = {}) {
|
|
148
|
+
// columns로 받는다 — width로 두면 모듈의 width() 함수를 함수 스코프 전체에서 가린다.
|
|
149
|
+
const { width: columns = 80, height = 24, repo = '', dryRun = false, color = false, status = '', detailExpanded = false, cliOptions = [], t = createT('en') } = opts
|
|
150
|
+
|
|
151
|
+
// 마지막 칸은 비워 둔다 — 폭을 꽉 채우면 터미널이 줄을 넘긴다.
|
|
152
|
+
const w = Math.max(24, columns - 1)
|
|
153
|
+
const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
|
|
154
|
+
|
|
155
|
+
const items = state.rows.filter((r) => r.kind === 'item')
|
|
156
|
+
const picked = items.filter((r) => state.selected.has(r.id)).length
|
|
157
|
+
|
|
158
|
+
const title = `agent-installer${dryRun ? ' (dry-run)' : ''}`
|
|
159
|
+
const counts = t('tui.counts', { picked, total: items.length })
|
|
160
|
+
const head = cut(`${title} ${counts} ${repo}`, w)
|
|
161
|
+
const searching = String(state.query ?? '').trim() !== ''
|
|
162
|
+
// 목록이 좁혀졌는가 — 검색어·CLI 필터 어느 쪽이든 활성 탭 안에서만 걸리는
|
|
163
|
+
// 좁힘이라, 다른 탭에 뭐가 남았는지 알리는 몫(탭 줄의 shown/total)은
|
|
164
|
+
// 둘이 똑같이 진다.
|
|
165
|
+
const narrowed = searching || Boolean(state.cliFilter)
|
|
166
|
+
|
|
167
|
+
const filter = filterSegment(state, { t, options: cliOptions })
|
|
168
|
+
const lines = [
|
|
169
|
+
color ? `${BOLD}${title}${RESET}${cut(` ${counts} ${repo}`, Math.max(0, w - width(title)))}` : head,
|
|
170
|
+
tabBar(state, { width: w, color, narrowed, t }),
|
|
171
|
+
searchLine(state, { limit: w, color, paint, t, filter }),
|
|
172
|
+
'',
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
const body = bodyHeight(height, detailExpanded)
|
|
176
|
+
const all = displayList(state)
|
|
177
|
+
|
|
178
|
+
if (all.length === 0) {
|
|
179
|
+
// 빈 이유가 검색어 때문일 수도 있는데, cliFilter를 먼저 물으면 "이 CLI에는
|
|
180
|
+
// 배선된 게 없다"는 문구가 거짓으로 뜰 수 있다 — 그 CLI가 이 탭의 다른
|
|
181
|
+
// 항목들을 지원하더라도, 검색어가 전부 걸러내면 이 분기에 들어온다.
|
|
182
|
+
// 검색어가 있으면 그쪽이 항상 참이므로 우선한다.
|
|
183
|
+
const empty = searching
|
|
184
|
+
? t('tui.empty.filtered')
|
|
185
|
+
: state.cliFilter ? t('tui.filter.empty', { cli: state.cliFilter }) : t('tui.empty.none')
|
|
186
|
+
if (body > 0) lines.push(paint(DIM, cut(empty, w)))
|
|
187
|
+
for (let i = 1; i < body; i++) lines.push('')
|
|
188
|
+
} else {
|
|
189
|
+
const window = all.slice(state.offset, state.offset + body)
|
|
190
|
+
for (const entry of window) {
|
|
191
|
+
if (entry.type === 'header') {
|
|
192
|
+
const count = entry.shown === entry.total ? `${entry.total}` : `${entry.shown}/${entry.total}`
|
|
193
|
+
// entry.section은 사실 row.group(디자인 카테고리) 값이다. categoryLabel은
|
|
194
|
+
// 우리가 만든 catch-all(__other·__local)만 번역하고, 공급자가 준 실제
|
|
195
|
+
// 카테고리는 이미 영어 데이터라 그대로 통과시킨다.
|
|
196
|
+
lines.push(paint(DIM, cut(` ${categoryLabel(t, entry.section)} (${count})`, w)))
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
const { row, index } = entry
|
|
200
|
+
const here = index === state.cursor
|
|
201
|
+
const hintWidth = Math.max(0, w - LABEL_WIDTH - 6)
|
|
202
|
+
const text = cut(`${here ? '❯' : ' '} ${checkbox(row, state.selected)} ${pad(row.label, LABEL_WIDTH)} ${cut(row.hint, hintWidth)}`, w)
|
|
203
|
+
lines.push(here ? paint(REVERSE, text) : text)
|
|
204
|
+
}
|
|
205
|
+
for (let i = window.length; i < body; i++) lines.push('')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 상세 패널 — 목록 아래 고정 자리. 높이가 커서와 무관하므로 목록이
|
|
209
|
+
// 출렁이지 않는다. 지면이 모자라면 panelHeight가 0을 돌려 통째로 빠진다.
|
|
210
|
+
const panel = panelHeight(height, detailExpanded)
|
|
211
|
+
if (panel > 0) {
|
|
212
|
+
lines.push(paint(DIM, '─'.repeat(w)))
|
|
213
|
+
const room = panel - 1
|
|
214
|
+
const detail = detailLines(currentRow(state), { width: w, height: room, t })
|
|
215
|
+
const shown = detail.length > 0 ? detail : [paint(DIM, cut(t('detail.empty'), w))]
|
|
216
|
+
for (const line of shown.slice(0, room)) lines.push(line)
|
|
217
|
+
for (let i = shown.length; i < room; i++) lines.push('')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 종료 키는 힌트 줄 **오른쪽 끝에 고정**한다. 나머지 힌트는 좁은 터미널에서
|
|
221
|
+
// 뒤쪽부터 잘려 나가는데, 하필 그때 가장 절실한 것이 "여기서 어떻게
|
|
222
|
+
// 빠져나가나"다 — 잘려도 되는 안내와 절대 사라지면 안 되는 안내를 같은
|
|
223
|
+
// 줄에 이어 붙여 두면 늘 후자가 먼저 사라진다. 상태 메시지가 힌트를
|
|
224
|
+
// 대신할 때도 이 자리는 그대로 남는다.
|
|
225
|
+
const hint = state.focus === 'search' ? t('tui.hint.search') : t('tui.hint.list')
|
|
226
|
+
const quit = t('tui.hint.quit')
|
|
227
|
+
const room = w - width(quit) - 2
|
|
228
|
+
lines.push('')
|
|
229
|
+
lines.push(room < 8
|
|
230
|
+
? paint(BOLD, cut(quit, w))
|
|
231
|
+
: `${paint(DIM, pad(status || hint, room))} ${paint(BOLD, quit)}`)
|
|
232
|
+
return lines
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const CHANGE_MARK = { install: '+', complete: '±', uninstall: '−' }
|
|
236
|
+
|
|
237
|
+
// 제출 검토 화면 — 적용 직전에 무엇이 바뀌는지만 보여 준다.
|
|
238
|
+
// 목록이 길면 잘라내고 남은 건수를 알린다(스크롤 대신) — 여기서 길을 잃을 이유는 없다.
|
|
239
|
+
export function renderReview(changes, opts = {}) {
|
|
240
|
+
const { width: columns = 80, height = 24, dryRun = false, color = false, t = createT('en') } = opts
|
|
241
|
+
const w = Math.max(24, columns - 1)
|
|
242
|
+
const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
|
|
243
|
+
|
|
244
|
+
const title = `${t('tui.review.title', { count: changes.length })}${dryRun ? ' (dry-run)' : ''}`
|
|
245
|
+
const lines = [color ? `${BOLD}${cut(title, w)}${RESET}` : cut(title, w), '']
|
|
246
|
+
|
|
247
|
+
const body = reviewBodyHeight(height)
|
|
248
|
+
const room = Math.max(1, body - 1)
|
|
249
|
+
const shown = changes.slice(0, room)
|
|
250
|
+
for (const c of shown) {
|
|
251
|
+
// 적용 직전 마지막 화면이다. 일부 CLI에만 들어가는 항목은 여기서도 그 사실을
|
|
252
|
+
// 밝힌다 — 목록에서 지나쳤더라도 되돌릴 수 있는 마지막 지점이다.
|
|
253
|
+
// 전부 지원하는 항목은 조용히 둔다: 경고가 흔해지면 아무도 읽지 않는다.
|
|
254
|
+
const partial = c.item.supports && c.item.supports.length < CLI_IDS.length
|
|
255
|
+
const cov = partial ? ` · ${t('item.cliCoverage', { covered: c.item.supports.length, total: CLI_IDS.length })}` : ''
|
|
256
|
+
lines.push(cut(` ${CHANGE_MARK[c.action] ?? '?'} ${pad(t(`change.${c.action}`), 10)} ${c.item.label}${cov}`, w))
|
|
257
|
+
}
|
|
258
|
+
if (changes.length > shown.length) {
|
|
259
|
+
lines.push(paint(DIM, cut(t('tui.review.more', { count: changes.length - shown.length }), w)))
|
|
260
|
+
} else {
|
|
261
|
+
lines.push('')
|
|
262
|
+
}
|
|
263
|
+
for (let i = shown.length + 1; i < body; i++) lines.push('')
|
|
264
|
+
|
|
265
|
+
lines.push('')
|
|
266
|
+
lines.push(paint(DIM, cut(t('tui.review.hint'), w)))
|
|
267
|
+
return lines
|
|
268
|
+
}
|