@rch4com/agent-setup 1.4.0 → 1.6.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.
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,
package/lib/tui/run.mjs CHANGED
@@ -7,10 +7,13 @@ import { netFetch, CATALOG_PATH } from '../design-md/catalog.mjs'
7
7
  import { createT, toText, LOCALES } from '../i18n/index.mjs'
8
8
  import { RECORD_REL, writeLang } from '../bootstrap/record.mjs'
9
9
  import { collectRows, installedIds } from './rows.mjs'
10
+ import { CLI_IDS } from '../clis.mjs'
10
11
  import {
11
12
  createState, setQuery, setFocus, move, moveTab, toggle, toggleVisible, scroll, currentRow, replaceRows, activeTab,
13
+ cycleCliFilter,
12
14
  } from './state.mjs'
13
15
  import { render, renderReview, bodyHeight } from './render.mjs'
16
+ import { createProgress, applyEvent, progressLines } from './progress.mjs'
14
17
 
15
18
  const ESC = String.fromCharCode(27)
16
19
  const HIDE_CURSOR = `${ESC}[?25l`
@@ -34,6 +37,16 @@ function keyReader(stdin) {
34
37
  stdin.on('keypress', onKey)
35
38
  return {
36
39
  next: () => (queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => { waiting = r })),
40
+ // 적용 중 중단 감시용. 큐를 **소비하지 않고** Ctrl+C만 들여다본다.
41
+ // next()로 기다리는 두 번째 소비자를 두면 적용이 끝난 뒤 눌린 키가
42
+ // 그 대기로 흘러 사라지고, 본 루프의 "아무 키나" 대기가 멈춘다.
43
+ // Ctrl+C가 있을 때만 거기까지를 버린다.
44
+ hasAbort: () => {
45
+ const at = queue.findIndex((k) => k.ctrl && k.name === 'c')
46
+ if (at === -1) return false
47
+ queue.splice(0, at + 1)
48
+ return true
49
+ },
37
50
  stop: () => stdin.off('keypress', onKey),
38
51
  }
39
52
  }
@@ -50,7 +63,9 @@ function printPlain(rows, log, t = createT('en')) {
50
63
  for (const row of rows) {
51
64
  if (row.section !== section) { section = row.section; log(`[${t(`section.${section}`)}]`) }
52
65
  const mark = row.kind === 'action' ? '▶' : row.status === 'absent' ? ' ' : '×'
53
- log(` [${mark}] ${row.label}${row.hint ? ` — ${row.hint}` : ''}`)
66
+ // 화면 폭 제약이 없는 자리다 — 여기서는 긴 힌트가 낫다.
67
+ const hint = row.fullHint ?? row.hint
68
+ log(` [${mark}] ${row.label}${hint ? ` — ${hint}` : ''}`)
54
69
  }
55
70
  }
56
71
 
@@ -90,6 +105,10 @@ export async function runTui(root, opts = {}) {
90
105
  // 타이핑하면 검색칸으로 올라간다.
91
106
  let state = createState(collected.rows, { selectedIds: installedIds(collected.rows), focus: 'list' })
92
107
  let status = ''
108
+ // 순환 대상: 전체(null) + CLI 10개. state.mjs는 아무것도 import 하지 않으므로
109
+ // 목록을 여기서 만들어 넘긴다.
110
+ const CLI_OPTIONS = [null, ...CLI_IDS]
111
+ let detailExpanded = false
93
112
 
94
113
  emitKeypressEvents(stdin)
95
114
  stdin.setRawMode(true)
@@ -101,8 +120,11 @@ export async function runTui(root, opts = {}) {
101
120
  const draw = (lines) => stdout.write(HOME + lines.map((l) => l + CLEAR_LINE).join('\n') + '\n' + CLEAR_DOWN)
102
121
  const paint = () => {
103
122
  const height = stdout.rows ?? 24
104
- state = scroll(state, bodyHeight(height))
105
- draw(render(state, { width: stdout.columns ?? 80, height, repo: root, dryRun, color, status, t }))
123
+ state = scroll(state, bodyHeight(height, detailExpanded))
124
+ draw(render(state, {
125
+ width: stdout.columns ?? 80, height, repo: root, dryRun, color, status, t,
126
+ detailExpanded, cliOptions: CLI_OPTIONS,
127
+ }))
106
128
  }
107
129
 
108
130
  // 로그는 화면 안에 우겨넣지 않는다 — 실패 메시지가 길고, 잘리면 진단이 불가능해진다.
@@ -178,6 +200,54 @@ export async function runTui(root, opts = {}) {
178
200
  }
179
201
  }
180
202
 
203
+ // 적용 중에는 alt 화면을 떠나지 않는다. exec가 비동기가 된 덕에 이벤트
204
+ // 루프가 살아 있어, 100ms 타이머가 경과 시간을 실제로 흘려 준다.
205
+ const runApply = async (changes) => {
206
+ let progress = { ...createProgress(changes), startedAt: Date.now() }
207
+ let stopRequested = false
208
+
209
+ // 중단 요청은 큐를 엿봐서 안다. 한 번 서면 되돌리지 않는다.
210
+ const checkAbort = () => {
211
+ if (!stopRequested && keys.hasAbort()) stopRequested = true
212
+ return stopRequested
213
+ }
214
+ const drawProgress = () => draw(progressLines(progress, {
215
+ width: stdout.columns ?? 80, height: stdout.rows ?? 24, color, dryRun, now: Date.now(), t,
216
+ }))
217
+
218
+ drawProgress()
219
+ const timer = setInterval(() => { checkAbort(); drawProgress() }, 100)
220
+ // 타이머가 프로세스를 붙잡지 않게 한다 — 화면 갱신은 종료를 미룰 이유가 없다.
221
+ timer.unref?.()
222
+
223
+ try {
224
+ const results = await apply(root, changes, {
225
+ dryRun,
226
+ log: () => {}, // 로그는 진행 화면이 대신한다
227
+ t,
228
+ shouldStop: checkAbort,
229
+ onProgress: (event) => {
230
+ progress = applyEvent(progress, event, Date.now())
231
+ drawProgress()
232
+ },
233
+ })
234
+ // aborted는 apply()가 끝난 뒤에만 세운다 — 도중에 세우면 실행 중 항목이
235
+ // aborted:true와 공존해, progressLines의 pending→skipped 겹쳐 보기가
236
+ // 아직 끝나지 않은 항목까지 건너뛴 것으로 잘못 그린다. entries 자체는
237
+ // 건드리지 않는다 — 시도조차 하지 않은 항목은 그대로 pending으로
238
+ // 남아 있어야 progressLines(viewEntry)가 그 상태를 skipped로 겹쳐
239
+ // 보며 표시와 집계("N건 건너뜀")를 함께 맞출 수 있다. 여기서 먼저
240
+ // state를 skipped로 바꿔 버리면 그 겹쳐 보기가 더는 pending을 찾지
241
+ // 못해 집계가 항상 0이 된다.
242
+ progress = { ...progress, aborted: stopRequested }
243
+ drawProgress()
244
+ if (results.some((r) => !r.ok && !r.skipped)) process.exitCode = 1
245
+ return results
246
+ } finally {
247
+ clearInterval(timer)
248
+ }
249
+ }
250
+
181
251
  try {
182
252
  for (;;) {
183
253
  paint()
@@ -186,6 +256,16 @@ export async function runTui(root, opts = {}) {
186
256
 
187
257
  if (key.ctrl && key.name === 'c') break
188
258
 
259
+ // Ctrl 조합은 두 포커스에서 모두 통한다 — 검색으로 좁힌 직후가 CLI
260
+ // 필터를 겹쳐 걸고 싶은 순간이다. 글자 키(c·d)를 쓰지 않는 이유는
261
+ // 목록 포커스에서 아무 글자나 누르면 검색칸으로 올라가기 때문이다:
262
+ // c를 필터에 배정하면 codex·claude를 검색어로 칠 수 없다.
263
+ if (key.ctrl && (key.name === 'f' || key.name === 'b')) {
264
+ state = cycleCliFilter(state, key.name === 'f' ? 1 : -1, CLI_OPTIONS)
265
+ continue
266
+ }
267
+ if (key.ctrl && key.name === 'd') { detailExpanded = !detailExpanded; continue }
268
+
189
269
  // ── 검색칸에 포커스: 타이핑이 곧 검색어다(스페이스 포함, 두 단어 검색 가능).
190
270
  if (state.focus === 'search') {
191
271
  // Esc: 검색어가 있으면 지우고 검색칸에 남는다. 이미 비었으면 목록으로 내려간다.
@@ -221,8 +301,8 @@ export async function runTui(root, opts = {}) {
221
301
  continue
222
302
  }
223
303
  if (key.name === 'down' || (key.ctrl && key.name === 'n')) { state = move(state, 1); continue }
224
- if (key.name === 'pageup') { state = move(state, -bodyHeight(stdout.rows ?? 24)); continue }
225
- if (key.name === 'pagedown') { state = move(state, bodyHeight(stdout.rows ?? 24)); continue }
304
+ if (key.name === 'pageup') { state = move(state, -bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
305
+ if (key.name === 'pagedown') { state = move(state, bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
226
306
 
227
307
  // 탭 이동 — Tab/Shift+Tab, 좌우 화살표.
228
308
  if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
@@ -264,15 +344,24 @@ export async function runTui(root, opts = {}) {
264
344
  if (verdict === 'quit') break
265
345
  if (verdict === 'cancel') { status = t('tui.submitCancelled'); continue }
266
346
 
347
+ const results = await runApply(changes)
348
+
349
+ // 실패가 있으면 자세한 사연을 화면 밖에서 보여 준다 — 실패 메시지는
350
+ // 길고, 화면 안에서 잘리면 진단이 불가능해진다. 성공해도 message가
351
+ // 붙은 결과(definePlugin·ponytail이 claude CLI 부재로 config 직접
352
+ // 기록으로 대체할 때 등)는 똑같이 사연이 있다 — ok만 보고 걸러내면
353
+ // 그 사연이 조용히 사라진다. 건너뜀은 실패가 아니다 — 진행 화면에
354
+ // 이미 나타났고, 다시 여기 나열하면 사용자가 스스로 취소한 일을
355
+ // 실패처럼 읽게 된다.
356
+ const notable = results.filter((r) => !r.skipped && (!r.ok || r.message))
267
357
  await suspend(async () => {
268
- log(`\n${t('tui.applyHeader', { count: changes.length, suffix: dryRun ? ' (dry-run)' : '' })}`)
269
- for (const c of changes) log(` ${t(`change.${c.action}`)} ${c.item.label}`)
270
- const results = await apply(root, changes, { dryRun, log, t })
271
- for (const r of results) {
272
- const message = toText(t, r.message)
273
- log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${r.item.label}${message ? ` — ${message}` : ''}`)
358
+ if (notable.length > 0) {
359
+ log('')
360
+ for (const r of notable) {
361
+ const message = toText(t, r.message)
362
+ log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${r.item.label}${message ? ` — ${message}` : ''}`)
363
+ }
274
364
  }
275
- if (results.some((r) => !r.ok)) process.exitCode = 1
276
365
  log(`\n${t('apply.seeGitDiff')}`)
277
366
  await pause()
278
367
  })
package/lib/tui/state.mjs CHANGED
@@ -17,7 +17,7 @@ export function tabsOf(rows) {
17
17
  // 어느 쪽이 포커스인지가 Space·타이핑의 뜻을 가른다 — 검색칸에서는 글자가
18
18
  // 검색어(스페이스 포함)로, 목록에서는 Space가 선택으로 동작한다.
19
19
  // 흔한 TUI의 두-존(zone) 모델이다.
20
- export function createState(rows, { selectedIds = [], query = '', tabIndex = 0, focus = 'list' } = {}) {
20
+ export function createState(rows, { selectedIds = [], query = '', tabIndex = 0, focus = 'list', cliFilter = null } = {}) {
21
21
  const tabs = tabsOf(rows)
22
22
  const index = clamp(tabIndex, 0, Math.max(0, tabs.length - 1))
23
23
  return {
@@ -26,7 +26,8 @@ export function createState(rows, { selectedIds = [], query = '', tabIndex = 0,
26
26
  tabIndex: index,
27
27
  focus,
28
28
  query,
29
- filtered: visibleRows(rows, query, tabs[index]),
29
+ cliFilter,
30
+ filtered: visibleRows(rows, query, tabs[index], cliFilter),
30
31
  selected: new Set(selectedIds),
31
32
  cursor: 0,
32
33
  offset: 0,
@@ -47,20 +48,33 @@ export function filterRows(rows, query) {
47
48
  return rows.filter((row) => tokens.every((tok) => row.searchText.includes(tok)))
48
49
  }
49
50
 
50
- // 화면에 실제로 보이는 행 = 활성 탭 ∩ 검색 결과.
51
+ // CLI 필터 통과 판정. 액션 행은 CLI 개념이 없고(설치가 아니라 실행이다),
52
+ // design.md는 모든 CLI가 함께 읽는 문서라 supports를 갖지 않는다.
53
+ // 둘을 걸러 내면 필터를 건 순간 화면에서 길이 끊긴다.
54
+ export function matchesCli(row, cliFilter) {
55
+ if (!cliFilter) return true
56
+ if (row.kind !== 'item') return true
57
+ const supports = row.item?.supports
58
+ if (!supports) return true
59
+ return supports.includes(cliFilter)
60
+ }
61
+
62
+ // 화면에 실제로 보이는 행 = 활성 탭 ∩ 검색 결과 ∩ CLI 필터.
51
63
  // 검색은 탭 안으로 스코프하되, 다른 탭의 적중 수는 tabCounts로 함께 보여 준다 —
52
64
  // 그래야 "어느 탭에 있는지" 를 잃지 않으면서도 Tab 키가 늘 의미를 갖는다.
53
- function visibleRows(rows, query, tab) {
54
- return filterRows(rows, query).filter((row) => row.section === tab)
65
+ function visibleRows(rows, query, tab, cliFilter) {
66
+ return filterRows(rows, query).filter((row) => row.section === tab && matchesCli(row, cliFilter))
55
67
  }
56
68
 
57
69
  export function activeTab(state) {
58
70
  return state.tabs[state.tabIndex] ?? null
59
71
  }
60
72
 
61
- // 탭별 적중 수 — 검색 중에도 어느 탭에 결과가 있는지 한눈에 보이게 한다.
73
+ // 탭별 적중 수 — 검색·필터 중에도 어느 탭에 결과가 있는지 한눈에 보이게 한다.
74
+ // total은 필터를 타지 않는다: 분모가 함께 줄면 "이 탭에서 몇 개가 걸러졌나"를
75
+ // 알 수 없다.
62
76
  export function tabCounts(state) {
63
- const hits = filterRows(state.rows, state.query)
77
+ const hits = filterRows(state.rows, state.query).filter((r) => matchesCli(r, state.cliFilter))
64
78
  return state.tabs.map((tab) => ({
65
79
  tab,
66
80
  shown: hits.filter((r) => r.section === tab).length,
@@ -74,7 +88,7 @@ export function currentRow(state) {
74
88
 
75
89
  function refocus(state, patch) {
76
90
  const next = { ...state, ...patch }
77
- return { ...next, filtered: visibleRows(next.rows, next.query, activeTab(next)), cursor: 0, offset: 0 }
91
+ return { ...next, filtered: visibleRows(next.rows, next.query, activeTab(next), next.cliFilter), cursor: 0, offset: 0 }
78
92
  }
79
93
 
80
94
  // 검색어를 바꾸면 커서는 첫 행으로 돌아간다.
@@ -98,6 +112,22 @@ export function setTab(state, index) {
98
112
  return tabIndex === state.tabIndex ? state : refocus(state, { tabIndex })
99
113
  }
100
114
 
115
+ // 필터를 바꾸면 커서는 첫 행으로 돌아간다. selected는 절대 건드리지 않는다 —
116
+ // setQuery와 같은 규칙이다. 화면 밖 설치본이 조용히 사라지지 않게 하는 핵심이다.
117
+ export function setCliFilter(state, cliFilter) {
118
+ return cliFilter === state.cliFilter ? state : refocus(state, { cliFilter })
119
+ }
120
+
121
+ // 순환 대상은 [null, ...CLI_IDS]다. 이 모듈은 아무것도 import 하지 않으므로
122
+ // 목록을 인자로 받는다 — 그 규칙이 이 파일을 순수하게 지켜 준다.
123
+ export function cycleCliFilter(state, delta, options) {
124
+ if (!options || options.length === 0) return state
125
+ const at = options.indexOf(state.cliFilter ?? null)
126
+ const n = options.length
127
+ const next = options[((((at === -1 ? 0 : at) + delta) % n) + n) % n]
128
+ return setCliFilter(state, next ?? null)
129
+ }
130
+
101
131
  // 양 끝에서 멈춘다(순환 없음) — 76개 목록에서 한 칸 지나쳤을 때 반대편으로 튀면 길을 잃는다.
102
132
  export function move(state, delta) {
103
133
  if (state.filtered.length === 0) return state
@@ -134,7 +164,7 @@ export function toggleVisible(state, on) {
134
164
  export function replaceRows(state, rows, selectedIds) {
135
165
  const tabs = tabsOf(rows)
136
166
  const tabIndex = clamp(tabs.indexOf(activeTab(state)), 0, Math.max(0, tabs.length - 1))
137
- const filtered = visibleRows(rows, state.query, tabs[tabIndex])
167
+ const filtered = visibleRows(rows, state.query, tabs[tabIndex], state.cliFilter)
138
168
  return {
139
169
  ...state,
140
170
  rows,
package/lib/width.mjs CHANGED
@@ -49,6 +49,64 @@ export function pad(text, limit) {
49
49
  return s + ' '.repeat(Math.max(0, limit - width(s)))
50
50
  }
51
51
 
52
+ // 접기. cut은 넘치는 글을 버리고 pad는 모자란 폭을 채우지만, 상세 패널과
53
+ // 진행 화면은 넘치는 글을 다음 줄로 이어 가야 한다.
54
+ //
55
+ // 공백이 있으면 마지막 공백에서 끊고(영문), 한 낱말이 limit보다 길거나
56
+ // 공백이 없으면(한글) 표시 폭에서 끊는다. limit이 0 이하면 빈 배열을
57
+ // 돌려준다 — 호출부가 폭을 잘못 계산했을 때 무한 루프에 빠지지 않게 한다.
58
+ //
59
+ // 문자 하나의 표시 폭이 limit보다 넓으면 그 문자를 버린다. limit < 2에서는
60
+ // 폭을 지키면서 동시에 모든 문자를 담을 수 없다. 폭을 넘기면 TUI 레이아웃이
61
+ // 무너지지만, 버리면 내용만 손실된다. 따라서 폭 불변식을 택했다.
62
+ export function wrap(text, limit) {
63
+ const s = String(text ?? '')
64
+ if (limit <= 0 || s === '') return []
65
+ const out = []
66
+ for (const para of s.split('\n')) {
67
+ let line = ''
68
+ let w = 0
69
+ let breakAt = -1 // line 안에서 마지막으로 본 공백의 위치
70
+ for (const ch of para) {
71
+ const cw = charWidth(ch.codePointAt(0))
72
+ // 공백을 먼저 기록한다. 이렇게 해야 가장 최근의 공백을 추적할 수 있다.
73
+ // 뒤에 아직 추가될 문자들이 있어도, 그 공백이 overflow가 되지 않으면
74
+ // breakAt으로 유지되고, 되면 그 공백에서 끊을 준비가 된다.
75
+ if (ch === ' ') breakAt = line.length
76
+ if (w + cw > limit) {
77
+ // Overflow: line이 limit을 초과할 것이다.
78
+ if (breakAt > 0) {
79
+ // 이전에 공백을 본 적이 있다. 그 공백에서 끊는다.
80
+ out.push(line.slice(0, breakAt))
81
+ line = line.slice(breakAt + 1)
82
+ } else {
83
+ // 공백이 없다. 현재 line을 그대로 끊는다.
84
+ // 단, 현재 line이 비어있으면 빈 항목을 만들지 않는다.
85
+ if (line) out.push(line)
86
+ line = ''
87
+ }
88
+ w = width(line)
89
+ breakAt = -1
90
+ // Overflow를 유발한 공백은 버린다. 그 공백은 이미 line을 초과시켰으므로
91
+ // 다음 줄에 포함하면 불필요한 공백이 앞에 붙는다.
92
+ if (ch === ' ') continue
93
+ }
94
+ // Overflow가 아니라면, 또는 처리 후라면, 문자를 추가한다.
95
+ // 단, 현재 line이 비어있는데 그 문자 자체가 limit보다 넓으면 건너뛴다.
96
+ // 호출부가 폭을 잘못 계산해도 이 함수가 limit을 지키기 위함이다.
97
+ if (w === 0 && cw > limit) {
98
+ continue
99
+ }
100
+ line += ch
101
+ w += cw
102
+ }
103
+ // 이번 문단을 끝낸다. 문단이 비어있거나 line이 비어있지 않으면 추가한다.
104
+ // 이렇게 해서 "접힘 자리의 공백 때문에 빈 줄이 생기는" 문제를 피한다.
105
+ if (line || para === '') out.push(line)
106
+ }
107
+ return out
108
+ }
109
+
52
110
  // 상태 라벨은 로케일마다 길이가 다르다(영어 'Not installed' 13, 한국어
53
111
  // '미설치' 6). 한 번 재어 상수로 박으면 다른 로케일에서 열이 깨지므로,
54
112
  // 화면을 그릴 때 그 로케일의 실제 라벨에서 폭을 뽑는다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rch4com/agent-setup",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",