@rch4com/agent-setup 1.8.0 → 1.10.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/LICENSE +21 -21
- package/README.md +199 -197
- 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 +426 -425
- package/lib/i18n/catalog/ko.mjs +411 -410
- 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.mattpocock-skills.mjs +8 -8
- package/lib/items/plugin.ponytail.mjs +104 -104
- package/lib/items/skill.gsd.mjs +44 -44
- package/lib/items/skill.gstack.mjs +54 -54
- package/lib/items/skill.hallmark.mjs +10 -0
- 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/run.mjs
CHANGED
|
@@ -1,396 +1,396 @@
|
|
|
1
|
-
// 유일한 부수효과 지점 — 키 입력, 화면 그리기, 액션 실행, 적용.
|
|
2
|
-
// 무엇을 보여줄지(render)와 무엇이 선택됐는지(state)는 순수 모듈이 정한다.
|
|
3
|
-
import { emitKeypressEvents } from 'node:readline'
|
|
4
|
-
import { planChanges, apply } from '../engine.mjs'
|
|
5
|
-
import { makeOpener, openPreview } from '../design-md/open.mjs'
|
|
6
|
-
import { netFetch, CATALOG_PATH } from '../design-md/catalog.mjs'
|
|
7
|
-
import { createT, toText, LOCALES } from '../i18n/index.mjs'
|
|
8
|
-
import { RECORD_REL, writeLang } from '../bootstrap/record.mjs'
|
|
9
|
-
import { collectRows, installedIds } from './rows.mjs'
|
|
10
|
-
import { CLI_IDS } from '../clis.mjs'
|
|
11
|
-
import {
|
|
12
|
-
createState, setQuery, setFocus, move, moveTab, toggle, toggleVisible, scroll, currentRow, replaceRows, activeTab,
|
|
13
|
-
cycleCliFilter,
|
|
14
|
-
} from './state.mjs'
|
|
15
|
-
import { render, renderReview, bodyHeight } from './render.mjs'
|
|
16
|
-
import { createProgress, applyEvent, progressLines } from './progress.mjs'
|
|
17
|
-
|
|
18
|
-
const ESC = String.fromCharCode(27)
|
|
19
|
-
const HIDE_CURSOR = `${ESC}[?25l`
|
|
20
|
-
const SHOW_CURSOR = `${ESC}[?25h`
|
|
21
|
-
const ALT_ON = `${ESC}[?1049h`
|
|
22
|
-
const ALT_OFF = `${ESC}[?1049l`
|
|
23
|
-
const HOME = `${ESC}[H`
|
|
24
|
-
const CLEAR_LINE = `${ESC}[K`
|
|
25
|
-
const CLEAR_DOWN = `${ESC}[J`
|
|
26
|
-
|
|
27
|
-
// 키를 큐에 쌓아 두고 하나씩 꺼내 쓴다. 이벤트 상태 기계 대신
|
|
28
|
-
// `for(;;) await next()` 루프로 흐름을 읽을 수 있게 하기 위해서다.
|
|
29
|
-
function keyReader(stdin) {
|
|
30
|
-
const queue = []
|
|
31
|
-
let waiting = null
|
|
32
|
-
const onKey = (str, key) => {
|
|
33
|
-
const event = { str, ...(key ?? {}) }
|
|
34
|
-
if (waiting) { const resolve = waiting; waiting = null; resolve(event) }
|
|
35
|
-
else queue.push(event)
|
|
36
|
-
}
|
|
37
|
-
stdin.on('keypress', onKey)
|
|
38
|
-
return {
|
|
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
|
-
},
|
|
50
|
-
stop: () => stdin.off('keypress', onKey),
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function itemStates(rows) {
|
|
55
|
-
return rows.filter((r) => r.kind === 'item').map((r) => ({ item: r.item, status: r.status }))
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// row.section은 이제 소문자 id다(rows.mjs Task 9) — t 없이 그대로 찍으면
|
|
59
|
-
// 화면에 'action'·'plugin' 같은 raw id가 새어 나간다. 이 경로도 사용자가
|
|
60
|
-
// 보는 출력이라 여기서도 t를 받는다.
|
|
61
|
-
function printPlain(rows, log, t = createT('en')) {
|
|
62
|
-
let section = null
|
|
63
|
-
for (const row of rows) {
|
|
64
|
-
if (row.section !== section) { section = row.section; log(`[${t(`section.${section}`)}]`) }
|
|
65
|
-
const mark = row.kind === 'action' ? '▶' : row.status === 'absent' ? ' ' : '×'
|
|
66
|
-
// 화면 폭 제약이 없는 자리다 — 여기서는 긴 힌트가 낫다.
|
|
67
|
-
const hint = row.fullHint ?? row.hint
|
|
68
|
-
log(` [${mark}] ${row.label}${hint ? ` — ${hint}` : ''}`)
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// 화면에 찍히는 글자인가(스페이스 포함). 검색칸 포커스에서 검색어로 들어갈 후보다.
|
|
73
|
-
// 스페이스의 뜻은 포커스가 가른다: 검색칸에서는 검색어, 목록에서는 선택.
|
|
74
|
-
// 그래서 목록에서 타이핑으로 검색칸에 올라갈 때는 호출부에서 스페이스를 뺀다.
|
|
75
|
-
function isPrintable(key) {
|
|
76
|
-
return Boolean(key.str) && !key.ctrl && !key.meta && key.str >= ' ' && key.str !== ESC
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export async function runTui(root, opts = {}) {
|
|
80
|
-
const {
|
|
81
|
-
dryRun = false,
|
|
82
|
-
skillMode = 'auto',
|
|
83
|
-
fetchImpl = netFetch,
|
|
84
|
-
designDirs = [],
|
|
85
|
-
env = process.env,
|
|
86
|
-
catalogFile = CATALOG_PATH,
|
|
87
|
-
log = console.log,
|
|
88
|
-
stdin = process.stdin,
|
|
89
|
-
stdout = process.stdout,
|
|
90
|
-
} = opts
|
|
91
|
-
const opener = opts.opener ?? makeOpener(dryRun, log)
|
|
92
|
-
// let으로 둔다 — 언어 전환 행(cycleLanguage)이 실행 중에 갈아끼운다.
|
|
93
|
-
let { t = createT('en'), localeForced = false } = opts
|
|
94
|
-
|
|
95
|
-
let collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, log: () => {}, t })
|
|
96
|
-
|
|
97
|
-
// raw 모드를 켤 수 없으면(CI·파이프) 목록만 내고 끝낸다.
|
|
98
|
-
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
99
|
-
printPlain(collected.rows, log, t)
|
|
100
|
-
log(`\n${t('tui.nonInteractive')}`)
|
|
101
|
-
return { interactive: false }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// 처음엔 목록에 포커스를 둔다 — 곧바로 화살표 이동·Space 선택·Enter 실행이 되게.
|
|
105
|
-
// 타이핑하면 검색칸으로 올라간다.
|
|
106
|
-
let state = createState(collected.rows, { selectedIds: installedIds(collected.rows), focus: 'list' })
|
|
107
|
-
let status = ''
|
|
108
|
-
// 순환 대상: 전체(null) + CLI 10개. state.mjs는 아무것도 import 하지 않으므로
|
|
109
|
-
// 목록을 여기서 만들어 넘긴다.
|
|
110
|
-
const CLI_OPTIONS = [null, ...CLI_IDS]
|
|
111
|
-
let detailExpanded = false
|
|
112
|
-
|
|
113
|
-
emitKeypressEvents(stdin)
|
|
114
|
-
stdin.setRawMode(true)
|
|
115
|
-
stdin.resume()
|
|
116
|
-
const keys = keyReader(stdin)
|
|
117
|
-
stdout.write(ALT_ON + HIDE_CURSOR)
|
|
118
|
-
|
|
119
|
-
const color = !env.NO_COLOR && stdout.isTTY
|
|
120
|
-
const draw = (lines) => stdout.write(HOME + lines.map((l) => l + CLEAR_LINE).join('\n') + '\n' + CLEAR_DOWN)
|
|
121
|
-
const paint = () => {
|
|
122
|
-
const height = stdout.rows ?? 24
|
|
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
|
-
}))
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// 로그는 화면 안에 우겨넣지 않는다 — 실패 메시지가 길고, 잘리면 진단이 불가능해진다.
|
|
131
|
-
const suspend = async (fn) => {
|
|
132
|
-
stdout.write(ALT_OFF + SHOW_CURSOR)
|
|
133
|
-
try { return await fn() } finally { stdout.write(ALT_ON + HIDE_CURSOR) }
|
|
134
|
-
}
|
|
135
|
-
const confirm = async (message) => {
|
|
136
|
-
stdout.write(`\n${message}${t('tui.confirmSuffix')}`)
|
|
137
|
-
const key = await keys.next()
|
|
138
|
-
stdout.write('\n')
|
|
139
|
-
return String(key.str ?? '').toLowerCase() === 'y'
|
|
140
|
-
}
|
|
141
|
-
const pause = async () => {
|
|
142
|
-
stdout.write(`\n${t('tui.pressAnyKey')}`)
|
|
143
|
-
await keys.next()
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// 언어 전환은 선택을 보존해야 한다. 적용·액션 실행 뒤 재스캔은 그 반대로
|
|
147
|
-
// 실제 설치 상태로 되돌려야 한다 — 둘을 한 함수로 묶으면 언어를 바꿀 때
|
|
148
|
-
// 사용자가 고르던 항목이 조용히 날아간다.
|
|
149
|
-
const rebuild = async (keepSelection) => {
|
|
150
|
-
collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, t, log: () => {} })
|
|
151
|
-
const ids = keepSelection ? [...state.selected] : installedIds(collected.rows)
|
|
152
|
-
state = replaceRows(state, collected.rows, ids)
|
|
153
|
-
}
|
|
154
|
-
const recollect = () => rebuild(false)
|
|
155
|
-
|
|
156
|
-
const cycleLanguage = async () => {
|
|
157
|
-
const next = LOCALES[(LOCALES.indexOf(t.locale) + 1) % LOCALES.length]
|
|
158
|
-
t = createT(next)
|
|
159
|
-
let note
|
|
160
|
-
try {
|
|
161
|
-
writeLang(root, next, { dryRun, log: () => {}, t })
|
|
162
|
-
note = dryRun ? t('tui.lang.dryRun') : t('tui.lang.saved', { path: RECORD_REL })
|
|
163
|
-
} catch (err) {
|
|
164
|
-
// 언어는 부수적 설정이다. 저장에 실패했다고 화면이 죽으면 설치기를 못 쓴다.
|
|
165
|
-
note = t('tui.lang.saveFailed', { message: err.message })
|
|
166
|
-
}
|
|
167
|
-
// 전환은 이번 실행에 이미 적용됐다(위에서 t를 갈아끼우고 아래에서 다시
|
|
168
|
-
// 그린다) — 읽을 수 없는 언어에 갇힌 사용자가 빠져나올 길이 있어야 한다.
|
|
169
|
-
// 알릴 것은 "다음 실행에서는 플래그·환경변수가 다시 이긴다"뿐이다.
|
|
170
|
-
// note는 이미 "저장했다"를 말하므로 겹치지 않게 이어 붙인다.
|
|
171
|
-
if (localeForced) note = `${note} · ${t('tui.lang.overridden')}`
|
|
172
|
-
await rebuild(true)
|
|
173
|
-
return note
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const select = () => {
|
|
177
|
-
const row = currentRow(state)
|
|
178
|
-
if (row?.kind !== 'item') return t('tui.notItemRow')
|
|
179
|
-
const before = state.selected
|
|
180
|
-
state = toggle(state)
|
|
181
|
-
// 배타 묶음에서 형제가 함께 꺼졌으면 그 사실을 말한다. 체크가 조용히
|
|
182
|
-
// 뒤집히면 사용자는 자기가 잘못 눌렀다고 읽는다 — 검색으로 형제가
|
|
183
|
-
// 화면 밖에 있을 때는 아예 무슨 일이 일어났는지 볼 수조차 없다.
|
|
184
|
-
const dropped = state.rows.filter((r) => r.id !== row.id && before.has(r.id) && !state.selected.has(r.id))
|
|
185
|
-
if (dropped.length > 0) return t('tui.exclusiveSwitched', { kept: row.label, dropped: dropped.map((r) => r.label).join(', ') })
|
|
186
|
-
return ''
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// 미리보기는 두 포커스에서 모두 통해야 한다 — 검색으로 찾은 직후가 가장 열어 보고 싶은 순간이다.
|
|
190
|
-
const preview = () => {
|
|
191
|
-
const row = currentRow(state)
|
|
192
|
-
if (row?.kind !== 'item' || !row.previewTarget) return t('tui.noPreview')
|
|
193
|
-
const notes = []
|
|
194
|
-
openPreview(opener, row.item, (m) => notes.push(String(m).trim()), t)
|
|
195
|
-
return notes.length > 0 ? notes.join(' ') : t('tui.opened', { target: row.previewTarget })
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// 제출 검토 — 적용 직전에 변경 목록을 보여 주고 Enter/Esc만 받는다.
|
|
199
|
-
const review = async (changes) => {
|
|
200
|
-
for (;;) {
|
|
201
|
-
draw(renderReview(changes, { width: stdout.columns ?? 80, height: stdout.rows ?? 24, dryRun, color, t }))
|
|
202
|
-
const key = await keys.next()
|
|
203
|
-
if (key.ctrl && (key.name === 'c' || key.name === 'q')) return 'quit'
|
|
204
|
-
if (key.name === 'escape') return 'cancel'
|
|
205
|
-
if (key.name === 'return' || key.name === 'enter') return 'apply'
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// 적용 중에는 alt 화면을 떠나지 않는다. exec가 비동기가 된 덕에 이벤트
|
|
210
|
-
// 루프가 살아 있어, 100ms 타이머가 경과 시간을 실제로 흘려 준다.
|
|
211
|
-
const runApply = async (changes) => {
|
|
212
|
-
let progress = { ...createProgress(changes), startedAt: Date.now() }
|
|
213
|
-
let stopRequested = false
|
|
214
|
-
|
|
215
|
-
// 중단 요청은 큐를 엿봐서 안다. 한 번 서면 되돌리지 않는다.
|
|
216
|
-
const checkAbort = () => {
|
|
217
|
-
if (!stopRequested && keys.hasAbort()) stopRequested = true
|
|
218
|
-
return stopRequested
|
|
219
|
-
}
|
|
220
|
-
const drawProgress = () => draw(progressLines(progress, {
|
|
221
|
-
width: stdout.columns ?? 80, height: stdout.rows ?? 24, color, dryRun, now: Date.now(), t,
|
|
222
|
-
}))
|
|
223
|
-
|
|
224
|
-
drawProgress()
|
|
225
|
-
const timer = setInterval(() => { checkAbort(); drawProgress() }, 100)
|
|
226
|
-
// 타이머가 프로세스를 붙잡지 않게 한다 — 화면 갱신은 종료를 미룰 이유가 없다.
|
|
227
|
-
timer.unref?.()
|
|
228
|
-
|
|
229
|
-
try {
|
|
230
|
-
const results = await apply(root, changes, {
|
|
231
|
-
dryRun,
|
|
232
|
-
log: () => {}, // 로그는 진행 화면이 대신한다
|
|
233
|
-
t,
|
|
234
|
-
shouldStop: checkAbort,
|
|
235
|
-
onProgress: (event) => {
|
|
236
|
-
progress = applyEvent(progress, event, Date.now())
|
|
237
|
-
drawProgress()
|
|
238
|
-
},
|
|
239
|
-
})
|
|
240
|
-
// aborted는 apply()가 끝난 뒤에만 세운다 — 도중에 세우면 실행 중 항목이
|
|
241
|
-
// aborted:true와 공존해, progressLines의 pending→skipped 겹쳐 보기가
|
|
242
|
-
// 아직 끝나지 않은 항목까지 건너뛴 것으로 잘못 그린다. entries 자체는
|
|
243
|
-
// 건드리지 않는다 — 시도조차 하지 않은 항목은 그대로 pending으로
|
|
244
|
-
// 남아 있어야 progressLines(viewEntry)가 그 상태를 skipped로 겹쳐
|
|
245
|
-
// 보며 표시와 집계("N건 건너뜀")를 함께 맞출 수 있다. 여기서 먼저
|
|
246
|
-
// state를 skipped로 바꿔 버리면 그 겹쳐 보기가 더는 pending을 찾지
|
|
247
|
-
// 못해 집계가 항상 0이 된다.
|
|
248
|
-
progress = { ...progress, aborted: stopRequested }
|
|
249
|
-
drawProgress()
|
|
250
|
-
if (results.some((r) => !r.ok && !r.skipped)) process.exitCode = 1
|
|
251
|
-
return results
|
|
252
|
-
} finally {
|
|
253
|
-
clearInterval(timer)
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
try {
|
|
258
|
-
for (;;) {
|
|
259
|
-
paint()
|
|
260
|
-
const key = await keys.next()
|
|
261
|
-
status = ''
|
|
262
|
-
|
|
263
|
-
if (key.ctrl && key.name === 'c') break
|
|
264
|
-
|
|
265
|
-
// Ctrl 조합은 두 포커스에서 모두 통한다 — 검색으로 좁힌 직후가 CLI
|
|
266
|
-
// 필터를 겹쳐 걸고 싶은 순간이다. 글자 키(c·d)를 쓰지 않는 이유는
|
|
267
|
-
// 목록 포커스에서 아무 글자나 누르면 검색칸으로 올라가기 때문이다:
|
|
268
|
-
// c를 필터에 배정하면 codex·claude를 검색어로 칠 수 없다.
|
|
269
|
-
// 종료 전용 키. Ctrl+C는 "중단"이고 Esc는 검색어·포커스를 먼저 되돌리느라
|
|
270
|
-
// 최대 세 번을 눌러야 나간다 — 어느 쪽도 "나가는 키"로 배우기 어렵다.
|
|
271
|
-
// Ctrl+Q는 두 포커스와 검토 화면에서 한 번에 통하며, 화면 오른쪽 끝에
|
|
272
|
-
// 늘 표시된다(render의 고정 자리). 글자 q를 쓰지 않는 이유는 Ctrl+F·
|
|
273
|
-
// Ctrl+D와 같다: 목록에서 아무 글자나 누르면 검색칸으로 올라간다.
|
|
274
|
-
// raw 모드가 IXON(흐름 제어)을 끄므로 Ctrl+Q는 터미널에 먹히지 않는다.
|
|
275
|
-
if (key.ctrl && key.name === 'q') break
|
|
276
|
-
|
|
277
|
-
if (key.ctrl && (key.name === 'f' || key.name === 'b')) {
|
|
278
|
-
state = cycleCliFilter(state, key.name === 'f' ? 1 : -1, CLI_OPTIONS)
|
|
279
|
-
continue
|
|
280
|
-
}
|
|
281
|
-
if (key.ctrl && key.name === 'd') { detailExpanded = !detailExpanded; continue }
|
|
282
|
-
|
|
283
|
-
// ── 검색칸에 포커스: 타이핑이 곧 검색어다(스페이스 포함, 두 단어 검색 가능).
|
|
284
|
-
if (state.focus === 'search') {
|
|
285
|
-
// Esc: 검색어가 있으면 지우고 검색칸에 남는다. 이미 비었으면 목록으로 내려간다.
|
|
286
|
-
if (key.name === 'escape') {
|
|
287
|
-
state = state.query ? setQuery(state, '') : setFocus(state, 'list')
|
|
288
|
-
continue
|
|
289
|
-
}
|
|
290
|
-
// Enter·↓: 검색을 마치고 목록으로 내려간다. 결과의 첫 항목이 커서다.
|
|
291
|
-
if (key.name === 'return' || key.name === 'enter' || key.name === 'down') { state = setFocus(state, 'list'); continue }
|
|
292
|
-
if (key.name === 'backspace') { state = setQuery(state, state.query.slice(0, -1)); continue }
|
|
293
|
-
if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
|
|
294
|
-
if (key.ctrl && key.name === 'o') { status = preview(); continue }
|
|
295
|
-
if (isPrintable(key)) state = setQuery(state, state.query + key.str)
|
|
296
|
-
continue
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// ── 목록에 포커스
|
|
300
|
-
if (key.name === 'escape') {
|
|
301
|
-
if (state.query) { state = setQuery(state, ''); continue }
|
|
302
|
-
break
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// / 또는 글자를 누르면 검색칸으로 올라가 타이핑을 시작한다. 스페이스는 선택이므로 제외한다.
|
|
306
|
-
if (key.str === '/') { state = setFocus(state, 'search'); continue }
|
|
307
|
-
if (key.name !== 'space' && isPrintable(key)) {
|
|
308
|
-
state = setQuery(setFocus(state, 'search'), state.query + key.str)
|
|
309
|
-
continue
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// 맨 위에서 ↑를 누르면 검색칸으로 되돌아간다 — 목록과 검색칸이 위아래로 이어진 느낌.
|
|
313
|
-
if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
|
|
314
|
-
state = state.cursor === 0 ? setFocus(state, 'search') : move(state, -1)
|
|
315
|
-
continue
|
|
316
|
-
}
|
|
317
|
-
if (key.name === 'down' || (key.ctrl && key.name === 'n')) { state = move(state, 1); continue }
|
|
318
|
-
if (key.name === 'pageup') { state = move(state, -bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
|
|
319
|
-
if (key.name === 'pagedown') { state = move(state, bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
|
|
320
|
-
|
|
321
|
-
// 탭 이동 — Tab/Shift+Tab, 좌우 화살표.
|
|
322
|
-
if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
|
|
323
|
-
if (key.name === 'right') { state = moveTab(state, 1); continue }
|
|
324
|
-
if (key.name === 'left') { state = moveTab(state, -1); continue }
|
|
325
|
-
|
|
326
|
-
// 선택 토글 — 액션 행에서는 아무 일도 하지 않는다(제거 개념이 없다).
|
|
327
|
-
if (key.name === 'space') { status = select(); continue }
|
|
328
|
-
|
|
329
|
-
// 지금 탭에서 보이는 항목 전체 토글.
|
|
330
|
-
if (key.ctrl && key.name === 'a') {
|
|
331
|
-
const before = state.selected.size
|
|
332
|
-
state = toggleVisible(state)
|
|
333
|
-
const statusKey = state.selected.size >= before ? 'tui.toggledAll' : 'tui.toggledNone'
|
|
334
|
-
status = t(statusKey, { tab: t(`section.${activeTab(state)}`) })
|
|
335
|
-
continue
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
if (key.ctrl && key.name === 'o') { status = preview(); continue }
|
|
339
|
-
|
|
340
|
-
if (key.name === 'return' || key.name === 'enter') {
|
|
341
|
-
// 액션 행은 그 자리에서 실행한다. 나머지는 제출(검토 → 일괄 적용)이다.
|
|
342
|
-
const row = currentRow(state)
|
|
343
|
-
// 언어 행은 화면을 벗어나지 않는다 — 그 자리에서 t를 갈아끼우고 다시 그린다.
|
|
344
|
-
if (row?.id === 'action.language') { status = await cycleLanguage(); continue }
|
|
345
|
-
if (row?.kind === 'action') {
|
|
346
|
-
await suspend(async () => {
|
|
347
|
-
await row.run({ root, dryRun, skillMode, fetchImpl, catalogFile, log, confirm, t })
|
|
348
|
-
await pause()
|
|
349
|
-
})
|
|
350
|
-
await recollect()
|
|
351
|
-
continue
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const changes = planChanges(itemStates(state.rows), state.selected)
|
|
355
|
-
if (changes.length === 0) { status = t('tui.noChanges'); continue }
|
|
356
|
-
|
|
357
|
-
const verdict = await review(changes)
|
|
358
|
-
if (verdict === 'quit') break
|
|
359
|
-
if (verdict === 'cancel') { status = t('tui.submitCancelled'); continue }
|
|
360
|
-
|
|
361
|
-
const results = await runApply(changes)
|
|
362
|
-
|
|
363
|
-
// 실패가 있으면 자세한 사연을 화면 밖에서 보여 준다 — 실패 메시지는
|
|
364
|
-
// 길고, 화면 안에서 잘리면 진단이 불가능해진다. 성공해도 message가
|
|
365
|
-
// 붙은 결과(definePlugin·ponytail이 claude CLI 부재로 config 직접
|
|
366
|
-
// 기록으로 대체할 때 등)는 똑같이 사연이 있다 — ok만 보고 걸러내면
|
|
367
|
-
// 그 사연이 조용히 사라진다. 건너뜀은 실패가 아니다 — 진행 화면에
|
|
368
|
-
// 이미 나타났고, 다시 여기 나열하면 사용자가 스스로 취소한 일을
|
|
369
|
-
// 실패처럼 읽게 된다.
|
|
370
|
-
const notable = results.filter((r) => !r.skipped && (!r.ok || r.message))
|
|
371
|
-
await suspend(async () => {
|
|
372
|
-
if (notable.length > 0) {
|
|
373
|
-
log('')
|
|
374
|
-
for (const r of notable) {
|
|
375
|
-
const message = toText(t, r.message)
|
|
376
|
-
log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${r.item.label}${message ? ` — ${message}` : ''}`)
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
log(`\n${t('apply.seeGitDiff')}`)
|
|
380
|
-
await pause()
|
|
381
|
-
})
|
|
382
|
-
await recollect()
|
|
383
|
-
continue
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
} finally {
|
|
387
|
-
keys.stop()
|
|
388
|
-
stdout.write(ALT_OFF + SHOW_CURSOR)
|
|
389
|
-
stdin.setRawMode(false)
|
|
390
|
-
stdin.pause()
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
return { interactive: true, state }
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
export { printPlain, itemStates }
|
|
1
|
+
// 유일한 부수효과 지점 — 키 입력, 화면 그리기, 액션 실행, 적용.
|
|
2
|
+
// 무엇을 보여줄지(render)와 무엇이 선택됐는지(state)는 순수 모듈이 정한다.
|
|
3
|
+
import { emitKeypressEvents } from 'node:readline'
|
|
4
|
+
import { planChanges, apply } from '../engine.mjs'
|
|
5
|
+
import { makeOpener, openPreview } from '../design-md/open.mjs'
|
|
6
|
+
import { netFetch, CATALOG_PATH } from '../design-md/catalog.mjs'
|
|
7
|
+
import { createT, toText, LOCALES } from '../i18n/index.mjs'
|
|
8
|
+
import { RECORD_REL, writeLang } from '../bootstrap/record.mjs'
|
|
9
|
+
import { collectRows, installedIds } from './rows.mjs'
|
|
10
|
+
import { CLI_IDS } from '../clis.mjs'
|
|
11
|
+
import {
|
|
12
|
+
createState, setQuery, setFocus, move, moveTab, toggle, toggleVisible, scroll, currentRow, replaceRows, activeTab,
|
|
13
|
+
cycleCliFilter,
|
|
14
|
+
} from './state.mjs'
|
|
15
|
+
import { render, renderReview, bodyHeight } from './render.mjs'
|
|
16
|
+
import { createProgress, applyEvent, progressLines } from './progress.mjs'
|
|
17
|
+
|
|
18
|
+
const ESC = String.fromCharCode(27)
|
|
19
|
+
const HIDE_CURSOR = `${ESC}[?25l`
|
|
20
|
+
const SHOW_CURSOR = `${ESC}[?25h`
|
|
21
|
+
const ALT_ON = `${ESC}[?1049h`
|
|
22
|
+
const ALT_OFF = `${ESC}[?1049l`
|
|
23
|
+
const HOME = `${ESC}[H`
|
|
24
|
+
const CLEAR_LINE = `${ESC}[K`
|
|
25
|
+
const CLEAR_DOWN = `${ESC}[J`
|
|
26
|
+
|
|
27
|
+
// 키를 큐에 쌓아 두고 하나씩 꺼내 쓴다. 이벤트 상태 기계 대신
|
|
28
|
+
// `for(;;) await next()` 루프로 흐름을 읽을 수 있게 하기 위해서다.
|
|
29
|
+
function keyReader(stdin) {
|
|
30
|
+
const queue = []
|
|
31
|
+
let waiting = null
|
|
32
|
+
const onKey = (str, key) => {
|
|
33
|
+
const event = { str, ...(key ?? {}) }
|
|
34
|
+
if (waiting) { const resolve = waiting; waiting = null; resolve(event) }
|
|
35
|
+
else queue.push(event)
|
|
36
|
+
}
|
|
37
|
+
stdin.on('keypress', onKey)
|
|
38
|
+
return {
|
|
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
|
+
},
|
|
50
|
+
stop: () => stdin.off('keypress', onKey),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function itemStates(rows) {
|
|
55
|
+
return rows.filter((r) => r.kind === 'item').map((r) => ({ item: r.item, status: r.status }))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// row.section은 이제 소문자 id다(rows.mjs Task 9) — t 없이 그대로 찍으면
|
|
59
|
+
// 화면에 'action'·'plugin' 같은 raw id가 새어 나간다. 이 경로도 사용자가
|
|
60
|
+
// 보는 출력이라 여기서도 t를 받는다.
|
|
61
|
+
function printPlain(rows, log, t = createT('en')) {
|
|
62
|
+
let section = null
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
if (row.section !== section) { section = row.section; log(`[${t(`section.${section}`)}]`) }
|
|
65
|
+
const mark = row.kind === 'action' ? '▶' : row.status === 'absent' ? ' ' : '×'
|
|
66
|
+
// 화면 폭 제약이 없는 자리다 — 여기서는 긴 힌트가 낫다.
|
|
67
|
+
const hint = row.fullHint ?? row.hint
|
|
68
|
+
log(` [${mark}] ${row.label}${hint ? ` — ${hint}` : ''}`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 화면에 찍히는 글자인가(스페이스 포함). 검색칸 포커스에서 검색어로 들어갈 후보다.
|
|
73
|
+
// 스페이스의 뜻은 포커스가 가른다: 검색칸에서는 검색어, 목록에서는 선택.
|
|
74
|
+
// 그래서 목록에서 타이핑으로 검색칸에 올라갈 때는 호출부에서 스페이스를 뺀다.
|
|
75
|
+
function isPrintable(key) {
|
|
76
|
+
return Boolean(key.str) && !key.ctrl && !key.meta && key.str >= ' ' && key.str !== ESC
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function runTui(root, opts = {}) {
|
|
80
|
+
const {
|
|
81
|
+
dryRun = false,
|
|
82
|
+
skillMode = 'auto',
|
|
83
|
+
fetchImpl = netFetch,
|
|
84
|
+
designDirs = [],
|
|
85
|
+
env = process.env,
|
|
86
|
+
catalogFile = CATALOG_PATH,
|
|
87
|
+
log = console.log,
|
|
88
|
+
stdin = process.stdin,
|
|
89
|
+
stdout = process.stdout,
|
|
90
|
+
} = opts
|
|
91
|
+
const opener = opts.opener ?? makeOpener(dryRun, log)
|
|
92
|
+
// let으로 둔다 — 언어 전환 행(cycleLanguage)이 실행 중에 갈아끼운다.
|
|
93
|
+
let { t = createT('en'), localeForced = false } = opts
|
|
94
|
+
|
|
95
|
+
let collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, log: () => {}, t })
|
|
96
|
+
|
|
97
|
+
// raw 모드를 켤 수 없으면(CI·파이프) 목록만 내고 끝낸다.
|
|
98
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
99
|
+
printPlain(collected.rows, log, t)
|
|
100
|
+
log(`\n${t('tui.nonInteractive')}`)
|
|
101
|
+
return { interactive: false }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 처음엔 목록에 포커스를 둔다 — 곧바로 화살표 이동·Space 선택·Enter 실행이 되게.
|
|
105
|
+
// 타이핑하면 검색칸으로 올라간다.
|
|
106
|
+
let state = createState(collected.rows, { selectedIds: installedIds(collected.rows), focus: 'list' })
|
|
107
|
+
let status = ''
|
|
108
|
+
// 순환 대상: 전체(null) + CLI 10개. state.mjs는 아무것도 import 하지 않으므로
|
|
109
|
+
// 목록을 여기서 만들어 넘긴다.
|
|
110
|
+
const CLI_OPTIONS = [null, ...CLI_IDS]
|
|
111
|
+
let detailExpanded = false
|
|
112
|
+
|
|
113
|
+
emitKeypressEvents(stdin)
|
|
114
|
+
stdin.setRawMode(true)
|
|
115
|
+
stdin.resume()
|
|
116
|
+
const keys = keyReader(stdin)
|
|
117
|
+
stdout.write(ALT_ON + HIDE_CURSOR)
|
|
118
|
+
|
|
119
|
+
const color = !env.NO_COLOR && stdout.isTTY
|
|
120
|
+
const draw = (lines) => stdout.write(HOME + lines.map((l) => l + CLEAR_LINE).join('\n') + '\n' + CLEAR_DOWN)
|
|
121
|
+
const paint = () => {
|
|
122
|
+
const height = stdout.rows ?? 24
|
|
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
|
+
}))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 로그는 화면 안에 우겨넣지 않는다 — 실패 메시지가 길고, 잘리면 진단이 불가능해진다.
|
|
131
|
+
const suspend = async (fn) => {
|
|
132
|
+
stdout.write(ALT_OFF + SHOW_CURSOR)
|
|
133
|
+
try { return await fn() } finally { stdout.write(ALT_ON + HIDE_CURSOR) }
|
|
134
|
+
}
|
|
135
|
+
const confirm = async (message) => {
|
|
136
|
+
stdout.write(`\n${message}${t('tui.confirmSuffix')}`)
|
|
137
|
+
const key = await keys.next()
|
|
138
|
+
stdout.write('\n')
|
|
139
|
+
return String(key.str ?? '').toLowerCase() === 'y'
|
|
140
|
+
}
|
|
141
|
+
const pause = async () => {
|
|
142
|
+
stdout.write(`\n${t('tui.pressAnyKey')}`)
|
|
143
|
+
await keys.next()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 언어 전환은 선택을 보존해야 한다. 적용·액션 실행 뒤 재스캔은 그 반대로
|
|
147
|
+
// 실제 설치 상태로 되돌려야 한다 — 둘을 한 함수로 묶으면 언어를 바꿀 때
|
|
148
|
+
// 사용자가 고르던 항목이 조용히 날아간다.
|
|
149
|
+
const rebuild = async (keepSelection) => {
|
|
150
|
+
collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, t, log: () => {} })
|
|
151
|
+
const ids = keepSelection ? [...state.selected] : installedIds(collected.rows)
|
|
152
|
+
state = replaceRows(state, collected.rows, ids)
|
|
153
|
+
}
|
|
154
|
+
const recollect = () => rebuild(false)
|
|
155
|
+
|
|
156
|
+
const cycleLanguage = async () => {
|
|
157
|
+
const next = LOCALES[(LOCALES.indexOf(t.locale) + 1) % LOCALES.length]
|
|
158
|
+
t = createT(next)
|
|
159
|
+
let note
|
|
160
|
+
try {
|
|
161
|
+
writeLang(root, next, { dryRun, log: () => {}, t })
|
|
162
|
+
note = dryRun ? t('tui.lang.dryRun') : t('tui.lang.saved', { path: RECORD_REL })
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// 언어는 부수적 설정이다. 저장에 실패했다고 화면이 죽으면 설치기를 못 쓴다.
|
|
165
|
+
note = t('tui.lang.saveFailed', { message: err.message })
|
|
166
|
+
}
|
|
167
|
+
// 전환은 이번 실행에 이미 적용됐다(위에서 t를 갈아끼우고 아래에서 다시
|
|
168
|
+
// 그린다) — 읽을 수 없는 언어에 갇힌 사용자가 빠져나올 길이 있어야 한다.
|
|
169
|
+
// 알릴 것은 "다음 실행에서는 플래그·환경변수가 다시 이긴다"뿐이다.
|
|
170
|
+
// note는 이미 "저장했다"를 말하므로 겹치지 않게 이어 붙인다.
|
|
171
|
+
if (localeForced) note = `${note} · ${t('tui.lang.overridden')}`
|
|
172
|
+
await rebuild(true)
|
|
173
|
+
return note
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const select = () => {
|
|
177
|
+
const row = currentRow(state)
|
|
178
|
+
if (row?.kind !== 'item') return t('tui.notItemRow')
|
|
179
|
+
const before = state.selected
|
|
180
|
+
state = toggle(state)
|
|
181
|
+
// 배타 묶음에서 형제가 함께 꺼졌으면 그 사실을 말한다. 체크가 조용히
|
|
182
|
+
// 뒤집히면 사용자는 자기가 잘못 눌렀다고 읽는다 — 검색으로 형제가
|
|
183
|
+
// 화면 밖에 있을 때는 아예 무슨 일이 일어났는지 볼 수조차 없다.
|
|
184
|
+
const dropped = state.rows.filter((r) => r.id !== row.id && before.has(r.id) && !state.selected.has(r.id))
|
|
185
|
+
if (dropped.length > 0) return t('tui.exclusiveSwitched', { kept: row.label, dropped: dropped.map((r) => r.label).join(', ') })
|
|
186
|
+
return ''
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 미리보기는 두 포커스에서 모두 통해야 한다 — 검색으로 찾은 직후가 가장 열어 보고 싶은 순간이다.
|
|
190
|
+
const preview = () => {
|
|
191
|
+
const row = currentRow(state)
|
|
192
|
+
if (row?.kind !== 'item' || !row.previewTarget) return t('tui.noPreview')
|
|
193
|
+
const notes = []
|
|
194
|
+
openPreview(opener, row.item, (m) => notes.push(String(m).trim()), t)
|
|
195
|
+
return notes.length > 0 ? notes.join(' ') : t('tui.opened', { target: row.previewTarget })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 제출 검토 — 적용 직전에 변경 목록을 보여 주고 Enter/Esc만 받는다.
|
|
199
|
+
const review = async (changes) => {
|
|
200
|
+
for (;;) {
|
|
201
|
+
draw(renderReview(changes, { width: stdout.columns ?? 80, height: stdout.rows ?? 24, dryRun, color, t }))
|
|
202
|
+
const key = await keys.next()
|
|
203
|
+
if (key.ctrl && (key.name === 'c' || key.name === 'q')) return 'quit'
|
|
204
|
+
if (key.name === 'escape') return 'cancel'
|
|
205
|
+
if (key.name === 'return' || key.name === 'enter') return 'apply'
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 적용 중에는 alt 화면을 떠나지 않는다. exec가 비동기가 된 덕에 이벤트
|
|
210
|
+
// 루프가 살아 있어, 100ms 타이머가 경과 시간을 실제로 흘려 준다.
|
|
211
|
+
const runApply = async (changes) => {
|
|
212
|
+
let progress = { ...createProgress(changes), startedAt: Date.now() }
|
|
213
|
+
let stopRequested = false
|
|
214
|
+
|
|
215
|
+
// 중단 요청은 큐를 엿봐서 안다. 한 번 서면 되돌리지 않는다.
|
|
216
|
+
const checkAbort = () => {
|
|
217
|
+
if (!stopRequested && keys.hasAbort()) stopRequested = true
|
|
218
|
+
return stopRequested
|
|
219
|
+
}
|
|
220
|
+
const drawProgress = () => draw(progressLines(progress, {
|
|
221
|
+
width: stdout.columns ?? 80, height: stdout.rows ?? 24, color, dryRun, now: Date.now(), t,
|
|
222
|
+
}))
|
|
223
|
+
|
|
224
|
+
drawProgress()
|
|
225
|
+
const timer = setInterval(() => { checkAbort(); drawProgress() }, 100)
|
|
226
|
+
// 타이머가 프로세스를 붙잡지 않게 한다 — 화면 갱신은 종료를 미룰 이유가 없다.
|
|
227
|
+
timer.unref?.()
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const results = await apply(root, changes, {
|
|
231
|
+
dryRun,
|
|
232
|
+
log: () => {}, // 로그는 진행 화면이 대신한다
|
|
233
|
+
t,
|
|
234
|
+
shouldStop: checkAbort,
|
|
235
|
+
onProgress: (event) => {
|
|
236
|
+
progress = applyEvent(progress, event, Date.now())
|
|
237
|
+
drawProgress()
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
// aborted는 apply()가 끝난 뒤에만 세운다 — 도중에 세우면 실행 중 항목이
|
|
241
|
+
// aborted:true와 공존해, progressLines의 pending→skipped 겹쳐 보기가
|
|
242
|
+
// 아직 끝나지 않은 항목까지 건너뛴 것으로 잘못 그린다. entries 자체는
|
|
243
|
+
// 건드리지 않는다 — 시도조차 하지 않은 항목은 그대로 pending으로
|
|
244
|
+
// 남아 있어야 progressLines(viewEntry)가 그 상태를 skipped로 겹쳐
|
|
245
|
+
// 보며 표시와 집계("N건 건너뜀")를 함께 맞출 수 있다. 여기서 먼저
|
|
246
|
+
// state를 skipped로 바꿔 버리면 그 겹쳐 보기가 더는 pending을 찾지
|
|
247
|
+
// 못해 집계가 항상 0이 된다.
|
|
248
|
+
progress = { ...progress, aborted: stopRequested }
|
|
249
|
+
drawProgress()
|
|
250
|
+
if (results.some((r) => !r.ok && !r.skipped)) process.exitCode = 1
|
|
251
|
+
return results
|
|
252
|
+
} finally {
|
|
253
|
+
clearInterval(timer)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
for (;;) {
|
|
259
|
+
paint()
|
|
260
|
+
const key = await keys.next()
|
|
261
|
+
status = ''
|
|
262
|
+
|
|
263
|
+
if (key.ctrl && key.name === 'c') break
|
|
264
|
+
|
|
265
|
+
// Ctrl 조합은 두 포커스에서 모두 통한다 — 검색으로 좁힌 직후가 CLI
|
|
266
|
+
// 필터를 겹쳐 걸고 싶은 순간이다. 글자 키(c·d)를 쓰지 않는 이유는
|
|
267
|
+
// 목록 포커스에서 아무 글자나 누르면 검색칸으로 올라가기 때문이다:
|
|
268
|
+
// c를 필터에 배정하면 codex·claude를 검색어로 칠 수 없다.
|
|
269
|
+
// 종료 전용 키. Ctrl+C는 "중단"이고 Esc는 검색어·포커스를 먼저 되돌리느라
|
|
270
|
+
// 최대 세 번을 눌러야 나간다 — 어느 쪽도 "나가는 키"로 배우기 어렵다.
|
|
271
|
+
// Ctrl+Q는 두 포커스와 검토 화면에서 한 번에 통하며, 화면 오른쪽 끝에
|
|
272
|
+
// 늘 표시된다(render의 고정 자리). 글자 q를 쓰지 않는 이유는 Ctrl+F·
|
|
273
|
+
// Ctrl+D와 같다: 목록에서 아무 글자나 누르면 검색칸으로 올라간다.
|
|
274
|
+
// raw 모드가 IXON(흐름 제어)을 끄므로 Ctrl+Q는 터미널에 먹히지 않는다.
|
|
275
|
+
if (key.ctrl && key.name === 'q') break
|
|
276
|
+
|
|
277
|
+
if (key.ctrl && (key.name === 'f' || key.name === 'b')) {
|
|
278
|
+
state = cycleCliFilter(state, key.name === 'f' ? 1 : -1, CLI_OPTIONS)
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
if (key.ctrl && key.name === 'd') { detailExpanded = !detailExpanded; continue }
|
|
282
|
+
|
|
283
|
+
// ── 검색칸에 포커스: 타이핑이 곧 검색어다(스페이스 포함, 두 단어 검색 가능).
|
|
284
|
+
if (state.focus === 'search') {
|
|
285
|
+
// Esc: 검색어가 있으면 지우고 검색칸에 남는다. 이미 비었으면 목록으로 내려간다.
|
|
286
|
+
if (key.name === 'escape') {
|
|
287
|
+
state = state.query ? setQuery(state, '') : setFocus(state, 'list')
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
// Enter·↓: 검색을 마치고 목록으로 내려간다. 결과의 첫 항목이 커서다.
|
|
291
|
+
if (key.name === 'return' || key.name === 'enter' || key.name === 'down') { state = setFocus(state, 'list'); continue }
|
|
292
|
+
if (key.name === 'backspace') { state = setQuery(state, state.query.slice(0, -1)); continue }
|
|
293
|
+
if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
|
|
294
|
+
if (key.ctrl && key.name === 'o') { status = preview(); continue }
|
|
295
|
+
if (isPrintable(key)) state = setQuery(state, state.query + key.str)
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── 목록에 포커스
|
|
300
|
+
if (key.name === 'escape') {
|
|
301
|
+
if (state.query) { state = setQuery(state, ''); continue }
|
|
302
|
+
break
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// / 또는 글자를 누르면 검색칸으로 올라가 타이핑을 시작한다. 스페이스는 선택이므로 제외한다.
|
|
306
|
+
if (key.str === '/') { state = setFocus(state, 'search'); continue }
|
|
307
|
+
if (key.name !== 'space' && isPrintable(key)) {
|
|
308
|
+
state = setQuery(setFocus(state, 'search'), state.query + key.str)
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 맨 위에서 ↑를 누르면 검색칸으로 되돌아간다 — 목록과 검색칸이 위아래로 이어진 느낌.
|
|
313
|
+
if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
|
|
314
|
+
state = state.cursor === 0 ? setFocus(state, 'search') : move(state, -1)
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
if (key.name === 'down' || (key.ctrl && key.name === 'n')) { state = move(state, 1); continue }
|
|
318
|
+
if (key.name === 'pageup') { state = move(state, -bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
|
|
319
|
+
if (key.name === 'pagedown') { state = move(state, bodyHeight(stdout.rows ?? 24, detailExpanded)); continue }
|
|
320
|
+
|
|
321
|
+
// 탭 이동 — Tab/Shift+Tab, 좌우 화살표.
|
|
322
|
+
if (key.name === 'tab') { state = moveTab(state, key.shift ? -1 : 1); continue }
|
|
323
|
+
if (key.name === 'right') { state = moveTab(state, 1); continue }
|
|
324
|
+
if (key.name === 'left') { state = moveTab(state, -1); continue }
|
|
325
|
+
|
|
326
|
+
// 선택 토글 — 액션 행에서는 아무 일도 하지 않는다(제거 개념이 없다).
|
|
327
|
+
if (key.name === 'space') { status = select(); continue }
|
|
328
|
+
|
|
329
|
+
// 지금 탭에서 보이는 항목 전체 토글.
|
|
330
|
+
if (key.ctrl && key.name === 'a') {
|
|
331
|
+
const before = state.selected.size
|
|
332
|
+
state = toggleVisible(state)
|
|
333
|
+
const statusKey = state.selected.size >= before ? 'tui.toggledAll' : 'tui.toggledNone'
|
|
334
|
+
status = t(statusKey, { tab: t(`section.${activeTab(state)}`) })
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (key.ctrl && key.name === 'o') { status = preview(); continue }
|
|
339
|
+
|
|
340
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
341
|
+
// 액션 행은 그 자리에서 실행한다. 나머지는 제출(검토 → 일괄 적용)이다.
|
|
342
|
+
const row = currentRow(state)
|
|
343
|
+
// 언어 행은 화면을 벗어나지 않는다 — 그 자리에서 t를 갈아끼우고 다시 그린다.
|
|
344
|
+
if (row?.id === 'action.language') { status = await cycleLanguage(); continue }
|
|
345
|
+
if (row?.kind === 'action') {
|
|
346
|
+
await suspend(async () => {
|
|
347
|
+
await row.run({ root, dryRun, skillMode, fetchImpl, catalogFile, log, confirm, t })
|
|
348
|
+
await pause()
|
|
349
|
+
})
|
|
350
|
+
await recollect()
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const changes = planChanges(itemStates(state.rows), state.selected)
|
|
355
|
+
if (changes.length === 0) { status = t('tui.noChanges'); continue }
|
|
356
|
+
|
|
357
|
+
const verdict = await review(changes)
|
|
358
|
+
if (verdict === 'quit') break
|
|
359
|
+
if (verdict === 'cancel') { status = t('tui.submitCancelled'); continue }
|
|
360
|
+
|
|
361
|
+
const results = await runApply(changes)
|
|
362
|
+
|
|
363
|
+
// 실패가 있으면 자세한 사연을 화면 밖에서 보여 준다 — 실패 메시지는
|
|
364
|
+
// 길고, 화면 안에서 잘리면 진단이 불가능해진다. 성공해도 message가
|
|
365
|
+
// 붙은 결과(definePlugin·ponytail이 claude CLI 부재로 config 직접
|
|
366
|
+
// 기록으로 대체할 때 등)는 똑같이 사연이 있다 — ok만 보고 걸러내면
|
|
367
|
+
// 그 사연이 조용히 사라진다. 건너뜀은 실패가 아니다 — 진행 화면에
|
|
368
|
+
// 이미 나타났고, 다시 여기 나열하면 사용자가 스스로 취소한 일을
|
|
369
|
+
// 실패처럼 읽게 된다.
|
|
370
|
+
const notable = results.filter((r) => !r.skipped && (!r.ok || r.message))
|
|
371
|
+
await suspend(async () => {
|
|
372
|
+
if (notable.length > 0) {
|
|
373
|
+
log('')
|
|
374
|
+
for (const r of notable) {
|
|
375
|
+
const message = toText(t, r.message)
|
|
376
|
+
log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${r.item.label}${message ? ` — ${message}` : ''}`)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
log(`\n${t('apply.seeGitDiff')}`)
|
|
380
|
+
await pause()
|
|
381
|
+
})
|
|
382
|
+
await recollect()
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} finally {
|
|
387
|
+
keys.stop()
|
|
388
|
+
stdout.write(ALT_OFF + SHOW_CURSOR)
|
|
389
|
+
stdin.setRawMode(false)
|
|
390
|
+
stdin.pause()
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return { interactive: true, state }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export { printPlain, itemStates }
|