@rch4com/agent-setup 1.18.0 → 1.20.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.
@@ -19,9 +19,73 @@ const BOLD = `${ESC}[1m`
19
19
  const REVERSE = `${ESC}[7m`
20
20
  const RESET = `${ESC}[0m`
21
21
 
22
- // 머리글·탭줄·검색줄·구분 공백·바닥글이 차지하는 줄 수.
22
+ // 머리글·탭줄·검색줄·구분 공백·바닥글(한 줄)이 차지하는 줄 수.
23
+ // 바닥글이 두 줄이 되면(footerHeight) 그만큼 더 뺀다.
23
24
  const CHROME = 6
24
25
  export const LABEL_WIDTH = 24
26
+ // 라벨 열의 상한. 터미널이 넓으면 긴 라벨(`Understand Anything (저장소)`)을
27
+ // 자르지 않도록 열을 늘리되, 힌트 자리(HINT_MIN)는 남긴다. 50칸은 80칸
28
+ // 터미널의 힌트 자리(49칸)보다 커서, 80칸에서는 옛 24칸이 그대로 유지되고
29
+ // 100칸쯤부터 늘기 시작한다 — 좁은 화면에서 라벨을 늘리면 전역 항목의
30
+ // `gemini 없음` 같은 힌트 꼬리가 대신 잘렸다.
31
+ const LABEL_MAX = 40
32
+ const HINT_MIN = 50
33
+ // rows.mjs의 NAME_LIMIT와 같은 규칙 — 넷 이하면 이름, 그보다 많으면 수.
34
+ const NAME_LIMIT = 4
35
+
36
+ // 행 라벨 열의 폭. 24칸 고정이면 넓은 화면에서도 긴 라벨이 잘렸다. 화면에
37
+ // 있는 가장 긴 라벨까지 늘리되 상한과 힌트 자리를 지킨다.
38
+ export function labelColumn(rows, w) {
39
+ const longest = Math.max(LABEL_WIDTH, ...rows.filter((r) => r.kind === 'item').map((r) => width(r.label ?? '')))
40
+ return Math.max(LABEL_WIDTH, Math.min(longest, LABEL_MAX, w - 6 - HINT_MIN))
41
+ }
42
+
43
+ // 넓은 화면에서는 전체 경로, 자리가 없으면 마지막 디렉터리 이름만.
44
+ function repoLabel(repo, room) {
45
+ const full = String(repo ?? '')
46
+ if (width(full) <= room) return full
47
+ return full.split(/[\\/]/).filter(Boolean).pop() ?? full
48
+ }
49
+
50
+ // 힌트 항목 사이의 구분자. 카탈로그 문자열이 이 간격으로 항목을 나눈다 —
51
+ // 좁은 폭에서 두 줄로 접을 때 이 경계에서만 끊는다.
52
+ const HINT_SEP = ' '
53
+
54
+ // 바닥글 힌트를 폭에 맞춰 최대 두 줄로 접는다. 예전에는 한 줄로 두고 뒤를
55
+ // 잘랐는데, 80칸 한국어 화면에서 `Ctrl+A 전체 C…`에서 끊겨 CLI 필터(Ctrl+F)·
56
+ // 상세 펼침(Ctrl+D)·미리보기(Ctrl+O)를 발견할 길이 없었다. 두 줄로도 모자라면
57
+ // 둘째 줄을 자른다 — 세 줄은 목록 지면을 너무 갉아먹는다.
58
+ export function hintLines(hint, room) {
59
+ const lines = []
60
+ let cur = ''
61
+ for (const seg of String(hint ?? '').split(HINT_SEP)) {
62
+ const next = cur ? `${cur}${HINT_SEP}${seg}` : seg
63
+ if (cur && width(next) > room) {
64
+ lines.push(cur)
65
+ cur = seg
66
+ } else {
67
+ cur = next
68
+ }
69
+ }
70
+ if (cur) lines.push(cur)
71
+ if (lines.length <= 2) return lines
72
+ return [lines[0], cut(lines.slice(1).join(HINT_SEP), room)]
73
+ }
74
+
75
+ // 힌트 오른쪽 끝의 종료 키를 뺀 나머지 자리. 8칸도 안 남으면 힌트를 버리고
76
+ // 종료 키만 남긴다(render의 바닥글과 같은 판정).
77
+ function hintRoom(w, t) {
78
+ return w - width(t('tui.hint.quit')) - 2
79
+ }
80
+
81
+ // 바닥글 줄 수(1 또는 2). 검색칸·목록 두 포커스의 힌트 중 긴 쪽으로 정한다 —
82
+ // 포커스를 옮길 때마다 목록 높이가 바뀌면 화면이 출렁인다.
83
+ export function footerHeight(columns = 80, t = createT('en')) {
84
+ const w = Math.max(24, columns - 1)
85
+ const room = hintRoom(w, t)
86
+ if (room < 8) return 1
87
+ return Math.max(hintLines(t('tui.hint.list'), room).length, hintLines(t('tui.hint.search'), room).length)
88
+ }
25
89
 
26
90
  // 상세 패널은 목록과 화면을 나눠 갖는다. 높이를 커서가 아니라 **터미널
27
91
  // 크기로만** 정하는 것이 핵심이다 — 커서를 옮길 때마다 높이가 변하면
@@ -32,16 +96,17 @@ const PANEL_MAX = 12
32
96
  // 목록이 3줄 밑으로 내려가는 쪽이 패널이 사라지는 것보다 나쁘다.
33
97
  const PANEL_FLOOR = PANEL_MIN + 3
34
98
 
35
- export function panelHeight(height, expanded = false) {
36
- const room = Math.max(0, height - CHROME)
99
+ // footer는 바닥글 줄 수(footerHeight)다. 기본 1이면 옛 산식과 같다.
100
+ export function panelHeight(height, expanded = false, footer = 1) {
101
+ const room = Math.max(0, height - CHROME - (footer - 1))
37
102
  if (expanded) return room
38
103
  if (room < PANEL_FLOOR) return 0
39
104
  return Math.min(PANEL_MAX, Math.max(PANEL_MIN, Math.round(room * PANEL_SHARE)))
40
105
  }
41
106
 
42
- export function bodyHeight(height, expanded = false) {
43
- const room = Math.max(0, height - CHROME)
44
- const panel = panelHeight(height, expanded)
107
+ export function bodyHeight(height, expanded = false, footer = 1) {
108
+ const room = Math.max(0, height - CHROME - (footer - 1))
109
+ const panel = panelHeight(height, expanded, footer)
45
110
  // 패널이 없을 때는 예전과 같이 최소 3줄을 보장한다.
46
111
  return panel === 0 ? Math.max(3, room) : room - panel
47
112
  }
@@ -54,19 +119,41 @@ export function reviewBodyHeight(height) {
54
119
  return Math.max(3, height - CHROME)
55
120
  }
56
121
 
57
- const MARK = { action: '▶', on: '×', off: ' ' }
122
+ // on = 설치돼 있고 그대로 둔다, add = 이번에 새로 고름(설치·보완 설치 예정),
123
+ // remove = 설치돼 있는데 체크를 풀었다(제거 예정), off = 없고 안 고름.
124
+ const MARK = { action: '▶', on: '×', add: '+', remove: '-', off: ' ' }
58
125
 
59
126
  // 대괄호는 "여러 개 고를 수 있다"는 보편적 신호다 — 한 파일에 한 벌만 놓이는
60
127
  // 배타 묶음(커밋 템플릿의 두 언어판)에 그대로 쓰면 화면이 거짓말을 한다.
61
128
  // 둥근 괄호로 바꿔 라디오임을 드러낸다. 안쪽 글자는 그대로 둔다: ×는 이미
62
129
  // 이 화면이 쓰는 글자라 폭 계산이 검증된 값이고, ●·•는 동아시아 폭이
63
130
  // 모호(Ambiguous)해 터미널에 따라 두 칸으로 그려져 열을 밀 수 있다.
131
+ //
132
+ // 표식은 "지금 체크됐나"가 아니라 "Enter를 누르면 무슨 일이 나나"를 말한다.
133
+ // 예전에는 ×가 "설치됨(유지)"과 "새로 고름"에 똑같이 쓰였고, 설치된 항목의
134
+ // 체크를 풀면 빈 칸이 돼 힌트의 '설치됨'과 조합해야 제거 예정임을 알 수
135
+ // 있었다. +·-는 ASCII라 폭이 모호하지 않다(−·±는 동아시아 폭이 모호하다).
136
+ export function changeMark(row, selected) {
137
+ const on = selected.has(row.id)
138
+ if (on) return row.status === 'installed' ? MARK.on : MARK.add
139
+ return row.status === 'absent' ? MARK.off : MARK.remove
140
+ }
141
+
64
142
  function checkbox(row, selected) {
65
143
  if (row.kind === 'action') return `[${MARK.action}]`
66
- const mark = selected.has(row.id) ? MARK.on : MARK.off
144
+ const mark = changeMark(row, selected)
67
145
  return row.exclusive ? `(${mark})` : `[${mark}]`
68
146
  }
69
147
 
148
+ // Enter를 누르면 바뀔 항목 수 — engine.planChanges와 같은 판정이다.
149
+ // add는 설치·보완 설치, remove는 제거다.
150
+ export function pendingCounts(state) {
151
+ const marks = state.rows.filter((r) => r.kind === 'item').map((r) => changeMark(r, state.selected))
152
+ const add = marks.filter((m) => m === MARK.add).length
153
+ const remove = marks.filter((m) => m === MARK.remove).length
154
+ return { add, remove, total: add + remove }
155
+ }
156
+
70
157
  // 탭 줄. 목록이 좁혀졌으면(검색어·CLI 필터 어느 쪽이든) 탭마다 적중 수를
71
158
  // 보여 준다 — 검색·필터는 활성 탭 안으로만 걸리므로, 다른 탭에 결과가
72
159
  // 있다는 사실을 여기서 알린다. 그래야 필터가 사용자를 빈 탭에 가둬 두고
@@ -109,10 +196,15 @@ export function tabBar(state, { width: limit, color = false, narrowed = false, t
109
196
  // 그대로 쓰이는데, 색 코드를 섞으면 ESC 시퀀스가 표시 폭에 끼어든다(폭은
110
197
  // 항상 색을 입히기 전에 재야 한다). 칠하는 일은 호출부가 폭 계산을 끝낸
111
198
  // 뒤 마지막에 한다.
199
+ //
200
+ // 위치는 CLI 사이에서만 센다. options[0]은 "전체"(null)라 순환 목록에는 있지만
201
+ // 사용자에게는 CLI가 아니다 — 그것까지 세면 `codex (3/11)`처럼 CLI가 열
202
+ // 개인데 열한 번째가 있다고 읽힌다.
112
203
  export function filterSegment(state, { t = createT('en'), options = [] } = {}) {
113
204
  if (!state.cliFilter) return ''
114
- const at = options.indexOf(state.cliFilter)
115
- const pos = at === -1 ? '' : ` (${t('tui.filter.position', { current: at + 1, total: options.length })})`
205
+ const clis = options.filter((o) => o !== null)
206
+ const at = clis.indexOf(state.cliFilter)
207
+ const pos = at === -1 ? '' : ` (${t('tui.filter.position', { current: at + 1, total: clis.length })})`
116
208
  return `${t('tui.filter.prefix')}${state.cliFilter}${pos}`
117
209
  }
118
210
 
@@ -157,8 +249,14 @@ export function render(state, opts = {}) {
157
249
  const picked = items.filter((r) => state.selected.has(r.id)).length
158
250
 
159
251
  const title = `agent-installer${dryRun ? ' (dry-run)' : ''}`
160
- const counts = t('tui.counts', { picked, total: items.length })
161
- const head = cut(`${title} ${counts} ${repo}`, w)
252
+ // 선택 수 옆에 변경 예정 건수를 둔다 — "선택 3 / 전체 106"은 설치 상태의
253
+ // 합계라 Enter를 누르면 무슨 일이 날지 말해 주지 않는다. 항상 찍는다:
254
+ // 0건이면 "지금 Enter는 아무것도 안 한다"는 뜻이라 그것대로 정보다.
255
+ const pending = pendingCounts(state)
256
+ const marks = pending.total > 0 ? t('tui.pendingMarks', { add: pending.add, remove: pending.remove }) : ''
257
+ const counts = `${t('tui.counts', { picked, total: items.length })} ${t('tui.pending', { count: pending.total, marks })}`
258
+ const repoText = repoLabel(repo, w - width(`${title} ${counts} `))
259
+ const head = cut(`${title} ${counts} ${repoText}`, w)
162
260
  const searching = String(state.query ?? '').trim() !== ''
163
261
  // 목록이 좁혀졌는가 — 검색어·CLI 필터 어느 쪽이든 활성 탭 안에서만 걸리는
164
262
  // 좁힘이라, 다른 탭에 뭐가 남았는지 알리는 몫(탭 줄의 shown/total)은
@@ -167,13 +265,14 @@ export function render(state, opts = {}) {
167
265
 
168
266
  const filter = filterSegment(state, { t, options: cliOptions })
169
267
  const lines = [
170
- color ? `${BOLD}${title}${RESET}${cut(` ${counts} ${repo}`, Math.max(0, w - width(title)))}` : head,
268
+ color ? `${BOLD}${title}${RESET}${cut(` ${counts} ${repoText}`, Math.max(0, w - width(title)))}` : head,
171
269
  tabBar(state, { width: w, color, narrowed, t }),
172
270
  searchLine(state, { limit: w, color, paint, t, filter }),
173
271
  '',
174
272
  ]
175
273
 
176
- const body = bodyHeight(height, detailExpanded)
274
+ const footer = footerHeight(columns, t)
275
+ const body = bodyHeight(height, detailExpanded, footer)
177
276
  const all = displayList(state)
178
277
 
179
278
  if (all.length === 0) {
@@ -188,6 +287,7 @@ export function render(state, opts = {}) {
188
287
  for (let i = 1; i < body; i++) lines.push('')
189
288
  } else {
190
289
  const window = all.slice(state.offset, state.offset + body)
290
+ const labelCol = labelColumn(state.rows, w)
191
291
  for (const entry of window) {
192
292
  if (entry.type === 'header') {
193
293
  const count = entry.shown === entry.total ? `${entry.total}` : `${entry.shown}/${entry.total}`
@@ -199,8 +299,8 @@ export function render(state, opts = {}) {
199
299
  }
200
300
  const { row, index } = entry
201
301
  const here = index === state.cursor
202
- const hintWidth = Math.max(0, w - LABEL_WIDTH - 6)
203
- const text = cut(`${here ? '❯' : ' '} ${checkbox(row, state.selected)} ${pad(row.label, LABEL_WIDTH)} ${cut(row.hint, hintWidth)}`, w)
302
+ const hintWidth = Math.max(0, w - labelCol - 6)
303
+ const text = cut(`${here ? '❯' : ' '} ${checkbox(row, state.selected)} ${pad(row.label, labelCol)} ${cut(row.hint, hintWidth)}`, w)
204
304
  lines.push(here ? paint(REVERSE, text) : text)
205
305
  }
206
306
  for (let i = window.length; i < body; i++) lines.push('')
@@ -208,7 +308,7 @@ export function render(state, opts = {}) {
208
308
 
209
309
  // 상세 패널 — 목록 아래 고정 자리. 높이가 커서와 무관하므로 목록이
210
310
  // 출렁이지 않는다. 지면이 모자라면 panelHeight가 0을 돌려 통째로 빠진다.
211
- const panel = panelHeight(height, detailExpanded)
311
+ const panel = panelHeight(height, detailExpanded, footer)
212
312
  if (panel > 0) {
213
313
  lines.push(paint(DIM, '─'.repeat(w)))
214
314
  const room = panel - 1
@@ -223,13 +323,25 @@ export function render(state, opts = {}) {
223
323
  // 빠져나가나"다 — 잘려도 되는 안내와 절대 사라지면 안 되는 안내를 같은
224
324
  // 줄에 이어 붙여 두면 늘 후자가 먼저 사라진다. 상태 메시지가 힌트를
225
325
  // 대신할 때도 이 자리는 그대로 남는다.
326
+ //
327
+ // 바닥글은 footer줄이다(1 또는 2). 힌트는 위에서부터 채우고 종료 키는 항상
328
+ // 마지막 줄 오른쪽 끝이다. 상태 메시지는 힌트를 통째로 대신하되 마지막
329
+ // 줄에 놓는다 — 폭이 달라져도 "맨 아랫줄 = 상태 + 종료 키"가 유지된다.
226
330
  const hint = state.focus === 'search' ? t('tui.hint.search') : t('tui.hint.list')
227
331
  const quit = t('tui.hint.quit')
228
- const room = w - width(quit) - 2
332
+ const room = hintRoom(w, t)
229
333
  lines.push('')
230
- lines.push(room < 8
231
- ? paint(BOLD, cut(quit, w))
232
- : `${paint(DIM, pad(status || hint, room))} ${paint(BOLD, quit)}`)
334
+ if (room < 8) {
335
+ for (let i = 1; i < footer; i++) lines.push('')
336
+ lines.push(paint(BOLD, cut(quit, w)))
337
+ return lines
338
+ }
339
+ const shown = status ? [status] : hintLines(hint, room)
340
+ while (shown.length < footer) shown.unshift('')
341
+ shown.slice(0, footer).forEach((text, i) => {
342
+ const last = i === footer - 1
343
+ lines.push(last ? `${paint(DIM, pad(text, room))} ${paint(BOLD, quit)}` : paint(DIM, cut(text, w)))
344
+ })
233
345
  return lines
234
346
  }
235
347
 
@@ -248,9 +360,15 @@ export function renderReview(changes, opts = {}) {
248
360
  // 적용 직전 마지막 화면이다. 일부 CLI에만 들어가는 항목은 여기서도 그 사실을
249
361
  // 밝힌다 — 목록에서 지나쳤더라도 되돌릴 수 있는 마지막 지점이다.
250
362
  // 전부 지원하는 항목은 조용히 둔다: 경고가 흔해지면 아무도 읽지 않는다.
363
+ // 목록과 같은 규칙으로 — 넷 이하면 이름을 적는다. 숫자만 있으면 어느
364
+ // CLI인지 알려고 Esc로 되돌아가야 했다.
251
365
  const changeLine = (c) => {
252
- const partial = c.item.supports && c.item.supports.length < CLI_IDS.length
253
- const cov = partial ? ` · ${t('item.cliCoverage', { covered: c.item.supports.length, total: CLI_IDS.length })}` : ''
366
+ const supports = c.item.supports
367
+ const partial = supports && supports.length < CLI_IDS.length
368
+ const covText = supports?.length > 0 && supports.length <= NAME_LIMIT
369
+ ? supports.join('·')
370
+ : t('item.cliCoverage', { covered: supports?.length ?? 0, total: CLI_IDS.length })
371
+ const cov = partial ? ` · ${covText}` : ''
254
372
  return cut(` ${CHANGE_MARK[c.action] ?? '?'} ${pad(t(`change.${c.action}`), 10)} ${scopedLabel(c.item, t)}${cov}`, w)
255
373
  }
256
374
 
@@ -305,3 +423,17 @@ export function renderReview(changes, opts = {}) {
305
423
  lines.push(paint(DIM, cut(t('tui.review.hint'), w)))
306
424
  return lines
307
425
  }
426
+
427
+ // 도움말 화면 — F1. 바닥글 두 줄로는 키의 뜻까지 담을 수 없어, 전체 키를
428
+ // 설명과 함께 한 화면에 편다. 아무 키나 누르면 닫힌다.
429
+ export function renderHelp(opts = {}) {
430
+ const { width: columns = 80, height = 24, color = false, t = createT('en') } = opts
431
+ const w = Math.max(24, columns - 1)
432
+ const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
433
+ const lines = [paint(BOLD, cut(t('help.title'), w)), '']
434
+ for (const line of t('help.lines').split('\n')) lines.push(cut(line, w))
435
+ while (lines.length < height - 2) lines.push('')
436
+ lines.push('')
437
+ lines.push(paint(DIM, cut(t('help.close'), w)))
438
+ return lines.slice(0, height)
439
+ }
package/lib/tui/rows.mjs CHANGED
@@ -104,16 +104,38 @@ export function designHint(state, multiProvider = false, t) {
104
104
  return parts.filter(Boolean).join(' · ')
105
105
  }
106
106
 
107
+ // 이름을 나열할 수 있는 최대 CLI 수. 넷까지는 이름(codex·gemini·opencode·copilot)이
108
+ // 80칸 힌트 자리에 들고, 그보다 많으면 "몇 개나 되나"가 더 유용하다.
109
+ const NAME_LIMIT = 4
110
+
107
111
  // 목록 행에 실제로 찍히는 힌트. 80칸 터미널이면 힌트 자리는 49칸(한글 24자)뿐이라,
108
112
  // 예전처럼 사유까지 이어 붙이면 뒤쪽이 통째로 잘렸다. 사유·note·detail은
109
113
  // 상세 패널이 여러 줄로 편다 — 여기 남는 것은 잘릴 일이 없는 두 가지뿐이다.
114
+ //
115
+ // 커버리지 표기는 탭과 무관하게 한 규칙이다 — 예전에는 plugin 탭만 이름을,
116
+ // 나머지는 수를 찍어 한 화면 안에서 규칙이 바뀌었다. 이제 지원 CLI가 넷
117
+ // 이하면 이름을, 그보다 많으면 `CLI n/N`을 찍는다. 이름을 나열할 때는 이
118
+ // 머신에 없는 CLI(state.excluded — 전역 항목이 detect에서 알린다)를 빼고
119
+ // 따로 적는다: 상세에는 "CLI 없음"이라 하면서 행에는 그 이름이 그대로
120
+ // 찍히면 어느 쪽이 참인지 알 수 없다.
121
+ export function coverageText(item, state, t = createT('en')) {
122
+ if (!item.supports) return ''
123
+ if (item.supports.length > NAME_LIMIT || item.supports.length === 0) {
124
+ return t('item.cliCoverage', { covered: item.supports.length, total: CLI_IDS.length })
125
+ }
126
+ const excluded = state?.excluded ?? []
127
+ const here = item.supports.filter((c) => !excluded.includes(c))
128
+ const parts = []
129
+ if (here.length > 0) parts.push(here.join('·'))
130
+ if (excluded.length > 0) parts.push(t('item.cliMissing', { list: excluded.join('·') }))
131
+ return parts.join(' · ')
132
+ }
133
+
110
134
  export function agentShortHint(item, state, t = createT('en')) {
111
135
  const parts = []
112
136
  if (state.status !== 'absent') parts.push(t(`status.${state.status}`))
113
- // plugin은 수 대신 이름을 나열한다 — 어느 CLI에 설치되는가가 이 탭에서
114
- // 고르는 기준이고, 가장 긴 나열(codex·gemini·opencode·copilot)도 자리에 든다.
115
- if (item.category === 'plugin' && item.supports?.length) parts.push(item.supports.join('·'))
116
- else if (item.supports) parts.push(t('item.cliCoverage', { covered: item.supports.length, total: CLI_IDS.length }))
137
+ const cov = coverageText(item, state, t)
138
+ if (cov) parts.push(cov)
117
139
  return parts.join(' · ')
118
140
  }
119
141
 
@@ -151,11 +173,14 @@ function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = nul
151
173
  }
152
174
  }
153
175
 
154
- function actionRow({ id, label, hint, run = null, t = createT('en') }) {
176
+ // section 기본값은 작업 탭이다. design.md 동기화처럼 한 탭에만 관계있는
177
+ // 작업은 그 탭을 넘겨 그 자리 맨 위에 놓는다 — 첫 화면의 작업 탭에 design.md
178
+ // 전용 작업이 섞여 있으면 무엇을 위한 화면인지부터 흐려진다.
179
+ function actionRow({ id, label, hint, run = null, section = ACTION_SECTION, t = createT('en') }) {
155
180
  return {
156
181
  kind: 'action',
157
182
  id,
158
- section: ACTION_SECTION,
183
+ section,
159
184
  group: null,
160
185
  exclusive: null,
161
186
  label,
@@ -165,7 +190,7 @@ function actionRow({ id, label, hint, run = null, t = createT('en') }) {
165
190
  status: 'absent',
166
191
  previewTarget: null,
167
192
  run,
168
- searchText: `${label} ${hint} ${sectionTerms(t, ACTION_SECTION)}`.toLowerCase(),
193
+ searchText: `${label} ${hint} ${sectionTerms(t, section)}`.toLowerCase(),
169
194
  }
170
195
  }
171
196
 
@@ -192,8 +217,10 @@ export function buildActions(root, { designItems = [], t = createT('en') } = {})
192
217
  run: ({ dryRun, skillMode, log, t: rt }) => runBootstrap(root, { dryRun, skillMode, log, t: rt }),
193
218
  t,
194
219
  }),
220
+ // design.md 전용 세 작업은 DESIGN.MD 탭 맨 위에 놓는다.
195
221
  actionRow({
196
222
  id: 'action.sync.installed',
223
+ section: 'design',
197
224
  label: t('action.sync.installed.label'),
198
225
  hint: t('action.sync.installed.hint'),
199
226
  run: ({ dryRun, log, t: rt }) => updateInstalled(root, designItems, { dryRun, log, t: rt }),
@@ -201,6 +228,7 @@ export function buildActions(root, { designItems = [], t = createT('en') } = {})
201
228
  }),
202
229
  actionRow({
203
230
  id: 'action.sync.catalog',
231
+ section: 'design',
204
232
  label: t('action.sync.catalog.label'),
205
233
  hint: t('action.sync.catalog.hint'),
206
234
  run: ({ dryRun, fetchImpl, log, catalogFile, t: rt }) => refreshCatalog({ dryRun, fetchImpl, log, catalogFile, t: rt }),
@@ -208,6 +236,7 @@ export function buildActions(root, { designItems = [], t = createT('en') } = {})
208
236
  }),
209
237
  actionRow({
210
238
  id: 'action.sync.stale',
239
+ section: 'design',
211
240
  label: t('action.sync.stale.label'),
212
241
  hint: t('action.sync.stale.hint'),
213
242
  run: async ({ dryRun, log, confirm, t: rt }) => {
package/lib/tui/run.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  createState, setQuery, setFocus, move, moveTab, toggle, toggleVisible, scroll, currentRow, replaceRows, activeTab,
13
13
  cycleCliFilter,
14
14
  } from './state.mjs'
15
- import { render, renderReview, bodyHeight } from './render.mjs'
15
+ import { render, renderReview, renderHelp, bodyHeight, footerHeight } from './render.mjs'
16
16
  import { createProgress, applyEvent, progressLines } from './progress.mjs'
17
17
  import { scopedLabel } from '../labels.mjs'
18
18
 
@@ -119,19 +119,36 @@ export async function runTui(root, opts = {}) {
119
119
 
120
120
  const color = !env.NO_COLOR && stdout.isTTY
121
121
  const draw = (lines) => stdout.write(HOME + lines.map((l) => l + CLEAR_LINE).join('\n') + '\n' + CLEAR_DOWN)
122
+ // 목록 지면은 바닥글 줄 수에 따라 달라진다 — render와 같은 산식을 써야
123
+ // 스크롤·페이지 이동이 화면과 어긋나지 않는다. t는 언어 행이 갈아끼우므로
124
+ // 그때그때 다시 잰다.
125
+ const listHeight = () => bodyHeight(stdout.rows ?? 24, detailExpanded, footerHeight(stdout.columns ?? 80, t))
122
126
  const paint = () => {
123
127
  const height = stdout.rows ?? 24
124
- state = scroll(state, bodyHeight(height, detailExpanded))
128
+ state = scroll(state, listHeight())
125
129
  draw(render(state, {
126
130
  width: stdout.columns ?? 80, height, repo: root, dryRun, color, status, t,
127
131
  detailExpanded, cliOptions: CLI_OPTIONS,
128
132
  }))
129
133
  }
134
+ const paintHelp = () => draw(renderHelp({ width: stdout.columns ?? 80, height: stdout.rows ?? 24, color, t }))
135
+
136
+ // 터미널 크기가 바뀌면 지금 떠 있는 화면을 새 크기로 다시 그린다 — 예전에는
137
+ // 다음 키를 누를 때까지 옛 크기로 남았다. 화면마다 자기 그리기 함수를
138
+ // redraw에 걸고, 화면 밖(suspend)에서는 null로 둬 아무것도 안 한다.
139
+ // 테스트의 가짜 stdout에는 on이 없을 수 있어 선택 호출이다.
140
+ let redraw = paint
141
+ const onResize = () => {
142
+ try { redraw?.() } catch { /* 그리기 실패로 루프를 죽이지 않는다 */ }
143
+ }
144
+ stdout.on?.('resize', onResize)
130
145
 
131
146
  // 로그는 화면 안에 우겨넣지 않는다 — 실패 메시지가 길고, 잘리면 진단이 불가능해진다.
132
147
  const suspend = async (fn) => {
148
+ const was = redraw
149
+ redraw = null
133
150
  stdout.write(ALT_OFF + SHOW_CURSOR)
134
- try { return await fn() } finally { stdout.write(ALT_ON + HIDE_CURSOR) }
151
+ try { return await fn() } finally { stdout.write(ALT_ON + HIDE_CURSOR); redraw = was }
135
152
  }
136
153
  const confirm = async (message) => {
137
154
  stdout.write(`\n${message}${t('tui.confirmSuffix')}`)
@@ -201,12 +218,18 @@ export async function runTui(root, opts = {}) {
201
218
 
202
219
  // 제출 검토 — 적용 직전에 변경 목록을 보여 주고 Enter/Esc만 받는다.
203
220
  const review = async (changes) => {
204
- for (;;) {
205
- draw(renderReview(changes, { width: stdout.columns ?? 80, height: stdout.rows ?? 24, dryRun, color, t }))
206
- const key = await keys.next()
207
- if (key.ctrl && (key.name === 'c' || key.name === 'q')) return 'quit'
208
- if (key.name === 'escape') return 'cancel'
209
- if (key.name === 'return' || key.name === 'enter') return 'apply'
221
+ const paintReview = () => draw(renderReview(changes, { width: stdout.columns ?? 80, height: stdout.rows ?? 24, dryRun, color, t }))
222
+ redraw = paintReview
223
+ try {
224
+ for (;;) {
225
+ paintReview()
226
+ const key = await keys.next()
227
+ if (key.ctrl && (key.name === 'c' || key.name === 'q')) return 'quit'
228
+ if (key.name === 'escape') return 'cancel'
229
+ if (key.name === 'return' || key.name === 'enter') return 'apply'
230
+ }
231
+ } finally {
232
+ redraw = paint
210
233
  }
211
234
  }
212
235
 
@@ -225,6 +248,7 @@ export async function runTui(root, opts = {}) {
225
248
  width: stdout.columns ?? 80, height: stdout.rows ?? 24, color, dryRun, now: Date.now(), t,
226
249
  }))
227
250
 
251
+ redraw = drawProgress
228
252
  drawProgress()
229
253
  const timer = setInterval(() => { checkAbort(); drawProgress() }, 100)
230
254
  // 타이머가 프로세스를 붙잡지 않게 한다 — 화면 갱신은 종료를 미룰 이유가 없다.
@@ -255,6 +279,7 @@ export async function runTui(root, opts = {}) {
255
279
  return results
256
280
  } finally {
257
281
  clearInterval(timer)
282
+ redraw = paint
258
283
  }
259
284
  }
260
285
 
@@ -284,6 +309,17 @@ export async function runTui(root, opts = {}) {
284
309
  }
285
310
  if (key.ctrl && key.name === 'd') { detailExpanded = !detailExpanded; continue }
286
311
 
312
+ // F1: 도움말. 바닥글 두 줄로는 키의 뜻까지 담을 수 없다. 아무 키나 닫는다 —
313
+ // 그 키는 소비만 하고 명령으로 해석하지 않는다(도움말을 닫으려 누른
314
+ // Space가 항목을 고르면 안 된다).
315
+ if (key.name === 'f1') {
316
+ redraw = paintHelp
317
+ paintHelp()
318
+ await keys.next()
319
+ redraw = paint
320
+ continue
321
+ }
322
+
287
323
  // ── 검색칸에 포커스: 타이핑이 곧 검색어다(스페이스 포함, 두 단어 검색 가능).
288
324
  if (state.focus === 'search') {
289
325
  // Esc: 검색어가 있으면 지우고 검색칸에 남는다. 이미 비었으면 목록으로 내려간다.
@@ -319,8 +355,11 @@ export async function runTui(root, opts = {}) {
319
355
  continue
320
356
  }
321
357
  if (key.name === 'down' || (key.ctrl && key.name === 'n')) { state = move(state, 1); continue }
322
- if (key.name === 'pageup') { state = move(state, -bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
323
- if (key.name === 'pagedown') { state = move(state, bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
358
+ if (key.name === 'pageup') { state = move(state, -listHeight()); continue }
359
+ if (key.name === 'pagedown') { state = move(state, listHeight()); continue }
360
+ // 76개짜리 DESIGN.MD 탭에서 끝으로 가려면 PgDn을 여러 번 눌러야 했다.
361
+ if (key.name === 'home') { state = move(state, -state.filtered.length); continue }
362
+ if (key.name === 'end') { state = move(state, state.filtered.length); continue }
324
363
 
325
364
  // 탭 이동 — Tab/Shift+Tab, 좌우 화살표.
326
365
  if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
@@ -389,6 +428,7 @@ export async function runTui(root, opts = {}) {
389
428
  }
390
429
  } finally {
391
430
  keys.stop()
431
+ stdout.off?.('resize', onResize)
392
432
  stdout.write(ALT_OFF + SHOW_CURSOR)
393
433
  stdin.setRawMode(false)
394
434
  stdin.pause()
package/lib/tui/state.mjs CHANGED
@@ -73,13 +73,17 @@ export function activeTab(state) {
73
73
  // 탭별 적중 수 — 검색·필터 중에도 어느 탭에 결과가 있는지 한눈에 보이게 한다.
74
74
  // total은 필터를 타지 않는다: 분모가 함께 줄면 "이 탭에서 몇 개가 걸러졌나"를
75
75
  // 알 수 없다.
76
+ //
77
+ // 항목이 있는 탭은 항목만 센다 — DESIGN.MD 탭 맨 위의 작업 행 셋까지 세면
78
+ // 항목 76개인 탭이 79로 찍혀 머리글의 "전체"와 어긋난다. 작업만 있는 탭은
79
+ // 작업을 센다(0으로 두면 탭이 비어 보인다).
76
80
  export function tabCounts(state) {
77
81
  const hits = filterRows(state.rows, state.query).filter((r) => matchesCli(r, state.cliFilter))
78
- return state.tabs.map((tab) => ({
79
- tab,
80
- shown: hits.filter((r) => r.section === tab).length,
81
- total: state.rows.filter((r) => r.section === tab).length,
82
- }))
82
+ return state.tabs.map((tab) => {
83
+ const hasItems = state.rows.some((r) => r.section === tab && r.kind === 'item')
84
+ const pick = (rows) => rows.filter((r) => r.section === tab && (!hasItems || r.kind === 'item'))
85
+ return { tab, shown: pick(hits).length, total: pick(state.rows).length }
86
+ })
83
87
  }
84
88
 
85
89
  export function currentRow(state) {
package/lib/update.mjs CHANGED
@@ -11,9 +11,11 @@ import { RECORD_REL, managedKey, readRecord, toolVersion, writeRecord } from './
11
11
  import { LocalizedError, createT, toText } from './i18n/index.mjs'
12
12
 
13
13
  // git이 유일한 되돌리기 수단이다. 커밋되지 않은 변경 위에 덮어쓰면 사용자가
14
- // 잃은 것을 복구할 방법이 없다.
14
+ // 잃은 것을 복구할 방법이 없다. 추적되지 않는 파일은 세지 않는다 — 갱신
15
+ // 대상은 기록에 해시가 있는 추적 파일뿐이라, 스크래치 파일 하나가 --force를
16
+ // 막을 이유가 없다.
15
17
  function assertCleanWorktree(root) {
16
- const out = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' })
18
+ const out = execFileSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=no'], { encoding: 'utf8' })
17
19
  if (out.trim()) {
18
20
  throw new LocalizedError('error.forceNeedsCleanTree')
19
21
  }
@@ -65,10 +67,18 @@ export async function runUpdate(root, opts = {}) {
65
67
  const drift = results.filter((r) => r.action === 'drift')
66
68
  const updated = results.filter((r) => r.action === 'update')
67
69
  const created = results.filter((r) => r.action === 'create')
70
+ // 어댑터가 격리해 돌려준 실패다. 요약이 갱신·신규·드리프트만 세면 이
71
+ // 결과는 아무 데도 나오지 않고 종료 코드도 0이라, 링크가 끊긴 채 "갱신
72
+ // 0건"으로 끝난다. flow.mjs의 bootstrap과 같은 형식으로 보고한다.
73
+ const failed = results.filter((r) => !r.ok)
68
74
 
69
75
  if (!dryRun) writeRecord(root, { ...record, managed }, { dryRun, log: say, t })
70
76
 
71
77
  log('')
78
+ if (failed.length > 0) {
79
+ say(t('bootstrap.failures', { count: failed.length }))
80
+ for (const f of failed) say(` ✖ ${f.path} — ${toText(t, f.message)}`)
81
+ }
72
82
  say(t('update.summary', { updated: updated.length, created: created.length, drift: drift.length }))
73
83
  if (drift.length > 0) {
74
84
  say(t('update.driftHeader'))
@@ -81,5 +91,5 @@ export async function runUpdate(root, opts = {}) {
81
91
  say(t('update.driftHint'))
82
92
  }
83
93
 
84
- return { results, drift, record: { ...record, managed } }
94
+ return { results, drift, failed, record: { ...record, managed } }
85
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rch4com/agent-setup",
3
- "version": "1.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "Repository-scoped bootstrap and optional-item installer for using multiple coding-agent CLIs in one repository | 여러 코딩 에이전트 CLI를 한 저장소에서 함께 쓰기 위한 저장소 범위 부트스트랩과 선택 항목 설치기",
5
5
  "keywords": [
6
6
  "agents-md",