@rch4com/agent-setup 1.3.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.
- package/install.mjs +11 -1
- package/lib/bootstrap/flow.mjs +75 -8
- package/lib/catalog.mjs +29 -11
- package/lib/clis.mjs +15 -6
- package/lib/design-md/cache/awesome-design-md/LICENSE.md +38 -0
- package/lib/design-md/flow.mjs +12 -1
- package/lib/design-md/providers/awesome-design-md.mjs +7 -0
- package/lib/engine.mjs +57 -5
- package/lib/i18n/catalog/en.mjs +32 -3
- package/lib/i18n/catalog/ko.mjs +29 -3
- package/lib/items/plugin.ponytail.mjs +3 -3
- package/lib/items/skill.gsd.mjs +2 -2
- package/lib/items/skill.gstack.mjs +3 -3
- package/lib/tui/detail.mjs +118 -0
- package/lib/tui/progress.mjs +160 -0
- package/lib/tui/render.mjs +103 -20
- package/lib/tui/rows.mjs +41 -14
- package/lib/tui/run.mjs +101 -12
- package/lib/tui/state.mjs +39 -9
- package/lib/width.mjs +58 -0
- package/package.json +1 -1
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
|
-
|
|
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, {
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.5.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",
|