@rch4com/agent-setup 1.6.0 → 1.8.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 +2 -1
- package/lib/engine.mjs +16 -1
- package/lib/gitmessage.mjs +230 -0
- package/lib/i18n/catalog/en.mjs +21 -1
- package/lib/i18n/catalog/ko.mjs +19 -1
- package/lib/items/config.gitmessage.en.mjs +10 -0
- package/lib/items/config.gitmessage.ko.mjs +10 -0
- package/lib/tui/detail.mjs +13 -4
- package/lib/tui/render.mjs +17 -2
- package/lib/tui/rows.mjs +14 -2
- package/lib/tui/run.mjs +15 -1
- package/lib/tui/state.mjs +36 -5
- package/package.json +1 -1
package/install.mjs
CHANGED
|
@@ -51,7 +51,7 @@ async function openTui(root, { dryRun, skillMode, designDirs, t, localeForced })
|
|
|
51
51
|
// --list / --set 전용 비대화형 경로.
|
|
52
52
|
async function runClassic(root, { dryRun, listOnly, setArg, t }) {
|
|
53
53
|
const { loadItems } = await withDeps(() => import('./lib/catalog.mjs'), t)
|
|
54
|
-
const { scan, planChanges, apply } = await withDeps(() => import('./lib/engine.mjs'), t)
|
|
54
|
+
const { scan, planChanges, apply, assertExclusive } = await withDeps(() => import('./lib/engine.mjs'), t)
|
|
55
55
|
const { plainLine } = await withDeps(() => import('./lib/tui/progress.mjs'), t)
|
|
56
56
|
const items = await loadItems()
|
|
57
57
|
const states = await scan(root, items)
|
|
@@ -68,6 +68,7 @@ async function runClassic(root, { dryRun, listOnly, setArg, t }) {
|
|
|
68
68
|
const selectedIds = new Set(setArg.split(',').map((s) => s.trim()).filter(Boolean))
|
|
69
69
|
const known = new Set(items.map((i) => i.id))
|
|
70
70
|
for (const id of selectedIds) if (!known.has(id)) throw new LocalizedError('error.unknownItem', { id })
|
|
71
|
+
assertExclusive(items, selectedIds)
|
|
71
72
|
|
|
72
73
|
const changes = planChanges(states, selectedIds)
|
|
73
74
|
if (changes.length === 0) { console.log(t('apply.noChanges')); return }
|
package/lib/engine.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { makeExec } from './catalog.mjs'
|
|
2
|
-
import { createT, msg } from './i18n/index.mjs'
|
|
2
|
+
import { createT, LocalizedError, msg } from './i18n/index.mjs'
|
|
3
3
|
|
|
4
4
|
export async function scan(root, items) {
|
|
5
5
|
const states = []
|
|
@@ -14,6 +14,21 @@ export async function scan(root, items) {
|
|
|
14
14
|
return states
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// item.exclusive는 "이 항목들은 같은 파일 하나를 두고 다툰다"는 표시다.
|
|
18
|
+
// TUI는 고르는 순간에 배타를 지키지만(state.mjs), --set은 목록을 통째로 받는다 —
|
|
19
|
+
// 둘을 함께 주면 나중 항목이 앞선 항목을 덮어써 성공한 것처럼 끝난다.
|
|
20
|
+
// 조용히 하나를 고르는 대신 어느 쪽을 원했는지 되묻는다.
|
|
21
|
+
export function assertExclusive(items, selectedIds) {
|
|
22
|
+
const groups = new Map()
|
|
23
|
+
for (const item of items) {
|
|
24
|
+
if (!item.exclusive || !selectedIds.has(item.id)) continue
|
|
25
|
+
groups.set(item.exclusive, [...(groups.get(item.exclusive) ?? []), item.id])
|
|
26
|
+
}
|
|
27
|
+
for (const ids of groups.values()) {
|
|
28
|
+
if (ids.length > 1) throw new LocalizedError('error.exclusiveItems', { ids: ids.join(', ') })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
export function planChanges(states, selectedIds) {
|
|
18
33
|
const changes = []
|
|
19
34
|
for (const { item, status } of states) {
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// 커밋 메시지 템플릿(.gitmessage.txt) 항목의 단일 출처.
|
|
2
|
+
//
|
|
3
|
+
// 한 파일에 언어가 다른 두 판이 있다 — 그래서 항목도 둘이지만 대상 파일은
|
|
4
|
+
// 하나다. 둘을 동시에 고를 수 없게 하는 것은 TUI(state.mjs)와 --set
|
|
5
|
+
// (engine.mjs의 assertExclusive)이 exclusive 키로 맡는다. 여기서는 "내가 쓴
|
|
6
|
+
// 판이 지금 놓여 있는가"만 판정하고, 아니면 아무것도 건드리지 않는다.
|
|
7
|
+
//
|
|
8
|
+
// 항목 파일(lib/items/*.mjs)에 두지 않는 이유: loadItems가 그 디렉터리의
|
|
9
|
+
// 모든 .mjs를 항목으로 읽어 검증하므로, 보조 모듈이 하나라도 섞이면 로드가
|
|
10
|
+
// 통째로 실패한다.
|
|
11
|
+
import { execFileSync } from 'node:child_process'
|
|
12
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { normalizeBody } from './bootstrap/text.mjs'
|
|
14
|
+
import { repoPath, repoPathStrict } from './context.mjs'
|
|
15
|
+
import { LocalizedError, msg } from './i18n/index.mjs'
|
|
16
|
+
|
|
17
|
+
export const GITMESSAGE_REL = '.gitmessage.txt'
|
|
18
|
+
// 한 파일을 두고 다투는 항목들의 묶음 이름.
|
|
19
|
+
export const GITMESSAGE_EXCLUSIVE = 'gitmessage'
|
|
20
|
+
|
|
21
|
+
const CONFIG_KEY = 'commit.template'
|
|
22
|
+
|
|
23
|
+
// 이 저장소 루트의 .gitmessage.txt와 같은 내용이다 —
|
|
24
|
+
// test/items.gitmessage.test.mjs가 두 값이 갈리지 않게 고정한다.
|
|
25
|
+
export const GITMESSAGE_KO = `# Commit message template ( * 주석 처리(#)된 부분들은 커밋 메시지에 포함되지 않으니 참고해서 작성)
|
|
26
|
+
# <타입(<영역>)>: <주제>
|
|
27
|
+
# - 타입은 필수 작성. 해당 커밋의 성격을 나타내며 타입의 종류 중 하나만 선택, 영어 소문자로 시작
|
|
28
|
+
# - 영역은 필요 시 작성. 변경된 부분을 직접적으로 나타냄 ex) refactor(MemberService): loginMember 메소드 명을 login으로 수정
|
|
29
|
+
# - 주제는 필수 작성. 최대 50글자까지, 끝에 마침표 금지, 한글로 간단 명료하게 작성, 현재형 사용
|
|
30
|
+
# 바로 밑줄에 작성
|
|
31
|
+
|
|
32
|
+
# 바로 아래 한 줄 공백 유지 (타입 본문의 분리를 위함)
|
|
33
|
+
|
|
34
|
+
################################
|
|
35
|
+
# <본문> - 필요시 작성. 한 줄당 최대 72자까지, 72자 초과시 줄바꿈, ”왜”, “무엇을 위해” 작업했는지에 초점을 맞춰 작성
|
|
36
|
+
# 바로 밑줄에 작성
|
|
37
|
+
|
|
38
|
+
# 바로 아래 한 줄 공백 유지 (본문과 바닥글의 분리를 위함)
|
|
39
|
+
|
|
40
|
+
################################
|
|
41
|
+
# <바닥글> - 필요시 작성
|
|
42
|
+
# 바로 밑줄에 작성 (이슈 해결 시에는 resolve: #99와 같이 작성)
|
|
43
|
+
|
|
44
|
+
######## [타입 리스트] ############
|
|
45
|
+
# feat: 새로운 기능 추가
|
|
46
|
+
# fix: 버그 수정
|
|
47
|
+
# style: 코드의 의미에 영향을 주지 않는 변경 사항(공백, 형식 지정, 세미콜론 누락 등), 클래스명 수정&가독성을 위해 변수명을 변경한 경우도 포함
|
|
48
|
+
# refactor: 버그 수정, 기능 추가도 아닌 코드 변경
|
|
49
|
+
# chore: 소스코드의 변경이 없으며 나열된 타입 이외 간단 변경
|
|
50
|
+
# add: feat 이외의 부수적인 코드, 라이브러리 등을 추가한 경우, 새로운 파일(Component나 Activity 등)을 생성한 경우도 포함
|
|
51
|
+
# remove: 코드, 파일을 삭제한 경우, 필요 없는 주석 삭제도 포함
|
|
52
|
+
# move: fix, refactor 등과 관계 없이 코드, 파일 등의 위치를 이동하는 작업만 수행한 경우
|
|
53
|
+
# comment: 필요한 주석을 추가, 수정한 경우( * 필요 없는 주석을 삭제한 경우는 remove)
|
|
54
|
+
# perf: 성능을 향상시키는 코드 변경
|
|
55
|
+
# test: 테스트 코드를 추가, 수정, 삭제한 경우
|
|
56
|
+
# docs: 문서를 추가, 수정한 경우
|
|
57
|
+
# design: CSS 등 사용자 UI 디자인을 추가, 수정한 경우
|
|
58
|
+
# revert: 이전 커밋을 되돌린 경우
|
|
59
|
+
# !BREAKING CHANGE: 커다란 API 변경의 경우 (MAJOR 버전과 상관 관계)
|
|
60
|
+
# !HOTFIX: 급하게 치명적인 버그를 고쳐야하는 경우
|
|
61
|
+
######## [커밋 메시지 예시] #########
|
|
62
|
+
# fix: 로그인 기능 구현
|
|
63
|
+
#
|
|
64
|
+
# 로그인 시 JWT 발급
|
|
65
|
+
#
|
|
66
|
+
# resolve: #232
|
|
67
|
+
# ref: #122
|
|
68
|
+
# related to: #30, #50
|
|
69
|
+
# ------------------
|
|
70
|
+
# [* 지켜야 할 보편적 규칙]
|
|
71
|
+
# 1. 언어는 한국어를 사용한다.
|
|
72
|
+
# - 예외 사항) 최초 커밋 메시지는 “Initial commit”으로 한다.
|
|
73
|
+
# 2. 문장의 구성
|
|
74
|
+
# 1) 문장은 간결하게 구성한다.
|
|
75
|
+
# 2) 현재형을 사용한다.
|
|
76
|
+
# 3. 작성한 당사자가 아닌 제 3자가 이해할 수 있도록 작성한다.
|
|
77
|
+
# 4. 단순한 문장일수록 좋다.
|
|
78
|
+
# ------------------
|
|
79
|
+
# [* 커밋 타이밍]
|
|
80
|
+
# - 의미 있는 코드의 변화가 있다면 커밋을 진행한다.
|
|
81
|
+
# - 의미가 있다면 사소한 것을 커밋 하는 것은 전혀 잘못된 것이 아니다.
|
|
82
|
+
# - 다만 의미 없는 잦은 커밋은 히스토리 추적에 불편함을 주므로 의미 없는 커밋은 자제한다.
|
|
83
|
+
################################
|
|
84
|
+
`
|
|
85
|
+
|
|
86
|
+
// 한국어판을 줄 단위로 옮긴 것이다. 타입 목록과 구획선은 같은 자리에 둔다 —
|
|
87
|
+
// 두 판을 오가도 커밋 히스토리의 모양이 달라지지 않아야 한다.
|
|
88
|
+
export const GITMESSAGE_EN = `# Commit message template ( * Lines that start with # are comments and never reach the commit message)
|
|
89
|
+
# <type(<scope>)>: <subject>
|
|
90
|
+
# - Type is required. It names the nature of this commit. Pick exactly one from the list below, lowercase English
|
|
91
|
+
# - Scope is optional. It points straight at what changed ex) refactor(MemberService): rename loginMember to login
|
|
92
|
+
# - Subject is required. 50 characters at most, no trailing period, plain and clear, present tense
|
|
93
|
+
# Write it on the line right below
|
|
94
|
+
|
|
95
|
+
# Keep one blank line right below (it separates the subject from the body)
|
|
96
|
+
|
|
97
|
+
################################
|
|
98
|
+
# <Body> - optional. 72 characters per line at most, wrap past that, focus on "why" and "what for"
|
|
99
|
+
# Write it on the line right below
|
|
100
|
+
|
|
101
|
+
# Keep one blank line right below (it separates the body from the footer)
|
|
102
|
+
|
|
103
|
+
################################
|
|
104
|
+
# <Footer> - optional
|
|
105
|
+
# Write it on the line right below (for a resolved issue, write resolve: #99)
|
|
106
|
+
|
|
107
|
+
######## [Type list] ############
|
|
108
|
+
# feat: add a new feature
|
|
109
|
+
# fix: fix a bug
|
|
110
|
+
# style: changes that do not affect the meaning of the code (whitespace, formatting, missing semicolons), including class or variable renames made for readability
|
|
111
|
+
# refactor: a code change that is neither a bug fix nor a new feature
|
|
112
|
+
# chore: no source change; small changes outside the types listed here
|
|
113
|
+
# add: supporting code, libraries and the like added outside feat, including brand new files (a Component, an Activity)
|
|
114
|
+
# remove: code or files deleted, including the removal of comments that are no longer needed
|
|
115
|
+
# move: only moving code or files around, unrelated to fix, refactor and the rest
|
|
116
|
+
# comment: a needed comment added or reworded ( * deleting a comment that is not needed is remove)
|
|
117
|
+
# perf: a code change that improves performance
|
|
118
|
+
# test: test code added, changed or deleted
|
|
119
|
+
# docs: documentation added or changed
|
|
120
|
+
# design: user-facing UI design added or changed (CSS and the like)
|
|
121
|
+
# revert: an earlier commit rolled back
|
|
122
|
+
# !BREAKING CHANGE: a large API change (tied to the MAJOR version)
|
|
123
|
+
# !HOTFIX: an urgent fix for a critical bug
|
|
124
|
+
######## [Example commit message] #########
|
|
125
|
+
# fix: implement the login feature
|
|
126
|
+
#
|
|
127
|
+
# Issue a JWT on login
|
|
128
|
+
#
|
|
129
|
+
# resolve: #232
|
|
130
|
+
# ref: #122
|
|
131
|
+
# related to: #30, #50
|
|
132
|
+
# ------------------
|
|
133
|
+
# [* Universal rules]
|
|
134
|
+
# 1. Write in English.
|
|
135
|
+
# - Exception) the very first commit message of a repository is "Initial commit".
|
|
136
|
+
# 2. Sentence shape
|
|
137
|
+
# 1) Keep sentences short.
|
|
138
|
+
# 2) Use the present tense.
|
|
139
|
+
# 3. Write so that a third party, not only the author, can follow it.
|
|
140
|
+
# 4. The simpler the sentence, the better.
|
|
141
|
+
# ------------------
|
|
142
|
+
# [* When to commit]
|
|
143
|
+
# - Commit whenever there is a meaningful change in the code.
|
|
144
|
+
# - Committing something small is not wrong at all as long as it means something.
|
|
145
|
+
# - Frequent meaningless commits make the history hard to follow, so hold those back.
|
|
146
|
+
################################
|
|
147
|
+
`
|
|
148
|
+
|
|
149
|
+
export const GITMESSAGE_BODIES = [GITMESSAGE_EN, GITMESSAGE_KO].map(normalizeBody)
|
|
150
|
+
|
|
151
|
+
function readCurrent(root) {
|
|
152
|
+
try {
|
|
153
|
+
return readFileSync(repoPath(root, GITMESSAGE_REL), 'utf8')
|
|
154
|
+
} catch {
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 저장소 설정만 본다. --local이 없으면 전역·시스템 설정까지 함께 읽혀,
|
|
160
|
+
// 사용자가 ~/.gitconfig에 같은 값을 둔 것만으로 이 저장소가 설치된 것처럼
|
|
161
|
+
// 보인다 — 이 도구는 저장소 안만 건드리므로 판정도 저장소 안만 봐야 한다.
|
|
162
|
+
// 값이 없으면 git이 종료 코드 1로 끝나므로 예외가 곧 "설정 없음"이다.
|
|
163
|
+
// git이 아예 없어도 같은 길로 떨어진다 — 그때는 상태 판정만 보수적으로
|
|
164
|
+
// 흐를 뿐, 화면이 죽지는 않는다.
|
|
165
|
+
function pointsHere(root) {
|
|
166
|
+
try {
|
|
167
|
+
const out = execFileSync('git', ['config', '--local', '--get', CONFIG_KEY], {
|
|
168
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
169
|
+
})
|
|
170
|
+
return out.trim() === GITMESSAGE_REL
|
|
171
|
+
} catch {
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// supports를 두지 않는다. 이 항목은 CLI 배선이 아니라 저장소 규약이라
|
|
177
|
+
// "10개 중 몇 개"라는 물음 자체가 성립하지 않는다 — 힌트의 커버리지 줄,
|
|
178
|
+
// 상세 패널의 배선표, CLI 필터가 모두 supports 없는 항목을 건너뛴다.
|
|
179
|
+
export function defineGitmessage({ id, label, body, note }) {
|
|
180
|
+
const wanted = normalizeBody(body)
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
id,
|
|
184
|
+
category: 'config',
|
|
185
|
+
label,
|
|
186
|
+
scope: 'project',
|
|
187
|
+
group: '__commit',
|
|
188
|
+
exclusive: GITMESSAGE_EXCLUSIVE,
|
|
189
|
+
// 이 항목이 손대는 파일. 그룹 헤더가 없는 평평한 목록에서도 무엇이
|
|
190
|
+
// 놓이는지 보이고, '.gitmessage'로 검색해도 걸린다.
|
|
191
|
+
target: GITMESSAGE_REL,
|
|
192
|
+
unsupported: {},
|
|
193
|
+
note,
|
|
194
|
+
|
|
195
|
+
async detect({ root }) {
|
|
196
|
+
const current = readCurrent(root)
|
|
197
|
+
if (current === null || normalizeBody(current) !== wanted) return { status: 'absent' }
|
|
198
|
+
// 파일만 놓여 있고 git이 그것을 모르면 템플릿은 한 번도 뜨지 않는다.
|
|
199
|
+
// 설치됨으로 찍으면 "왜 안 뜨지"를 사용자가 혼자 풀어야 한다.
|
|
200
|
+
return pointsHere(root) ? { status: 'installed' } : { status: 'partial', detail: msg('item.gitmessage.unregistered') }
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
async install({ root, dryRun, exec, log = () => {}, t }) {
|
|
204
|
+
const current = readCurrent(root)
|
|
205
|
+
// 손으로 쓴 템플릿은 덮지 않는다 — 이 저장소의 다른 모든 쓰기가 지키는
|
|
206
|
+
// 규칙이다. 우리가 아는 두 판 사이를 오가는 것만 덮어쓰기로 본다.
|
|
207
|
+
if (current !== null && !GITMESSAGE_BODIES.includes(normalizeBody(current))) {
|
|
208
|
+
throw new LocalizedError('error.gitmessageForeign', { path: GITMESSAGE_REL })
|
|
209
|
+
}
|
|
210
|
+
if (dryRun) log(t('log.gitmessage.write', { path: GITMESSAGE_REL }))
|
|
211
|
+
else writeFileSync(repoPathStrict(root, GITMESSAGE_REL), wanted, { encoding: 'utf8' })
|
|
212
|
+
|
|
213
|
+
const r = await exec('git', ['config', '--local', CONFIG_KEY, GITMESSAGE_REL], { cwd: root })
|
|
214
|
+
if (!r.ok) throw new LocalizedError('error.gitmessageConfig', { output: r.output })
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async uninstall({ root, dryRun, exec, log = () => {}, t }) {
|
|
218
|
+
const current = readCurrent(root)
|
|
219
|
+
// 같은 적용 안에서 다른 언어 판이 먼저 설치돼 파일을 갈아 끼웠을 수
|
|
220
|
+
// 있다(en 설치 → ko 제거 순서). 그때 지우면 방금 쓴 파일이 날아간다 —
|
|
221
|
+
// 내용이 내 판일 때만 손댄다.
|
|
222
|
+
if (current === null || normalizeBody(current) !== wanted) return
|
|
223
|
+
if (dryRun) log(t('log.gitmessage.remove', { path: GITMESSAGE_REL }))
|
|
224
|
+
else rmSync(repoPathStrict(root, GITMESSAGE_REL), { force: true })
|
|
225
|
+
|
|
226
|
+
// 남의 값이나 빈 값에 --unset을 걸면 git이 실패를 돌려준다.
|
|
227
|
+
if (pointsHere(root)) await exec('git', ['config', '--local', '--unset', CONFIG_KEY], { cwd: root })
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
}
|
package/lib/i18n/catalog/en.mjs
CHANGED
|
@@ -261,6 +261,16 @@ export default {
|
|
|
261
261
|
'item.skill.karpathy.note': 'behavioral guidelines that curb overbuilding and vague success criteria',
|
|
262
262
|
'item.skill.gstack.note': 'repo-local clone + setup (needs bash; Git Bash on Windows). Runtime state (~/.gstack) may be created globally.',
|
|
263
263
|
'item.skill.gsd.note': 'npx @opengsd/gsd-core, installed per project. Upstream supports 18 runtimes (Codex, Antigravity, …); this item wires --claude only',
|
|
264
|
+
// 커밋 템플릿은 CLI 배선이 아니라 저장소 규약이다 — note가 "무엇이 놓이고
|
|
265
|
+
// 무엇이 설정되는가"를 대신 말한다(상세 패널에 배선표가 없다).
|
|
266
|
+
'item.config.gitmessage.en.note': 'commit messages in English. Writes the template and points git commit.template at it, so it opens in every editor and every tool in this repository',
|
|
267
|
+
'item.config.gitmessage.ko.note': 'commit messages in Korean. Writes the template and points git commit.template at it, so it opens in every editor and every tool in this repository',
|
|
268
|
+
'item.gitmessage.unregistered': 'file is in place, but commit.template does not point at it yet',
|
|
269
|
+
'log.gitmessage.write': ' [dry-run] write {path}',
|
|
270
|
+
'log.gitmessage.remove': ' [dry-run] remove {path}',
|
|
271
|
+
'error.gitmessageForeign': '{path} already exists and was not written by this tool. A hand-written template is never overwritten — move or delete it first.',
|
|
272
|
+
'error.gitmessageConfig': 'git config commit.template failed: {output}',
|
|
273
|
+
'error.exclusiveItems': 'These items write the same file, so only one can be chosen: {ids}',
|
|
264
274
|
'error.gstackClone': 'gstack clone failed: {output}',
|
|
265
275
|
'error.gstackSetup': 'gstack setup failed: {output}',
|
|
266
276
|
'error.gsdInstall': 'GSD install failed: {output}',
|
|
@@ -277,6 +287,7 @@ export default {
|
|
|
277
287
|
'section.plugin': 'PLUGIN',
|
|
278
288
|
'section.mcp': 'MCP',
|
|
279
289
|
'section.skill': 'SKILL',
|
|
290
|
+
'section.config': 'CONFIG',
|
|
280
291
|
'section.design': 'DESIGN.MD',
|
|
281
292
|
|
|
282
293
|
// TUI 목록·검토 화면 문구. item.*는 agentHint가, action.*.label/hint는
|
|
@@ -289,9 +300,11 @@ export default {
|
|
|
289
300
|
'tui.empty.none': ' Nothing here.',
|
|
290
301
|
'tui.hint.search': 'type to search (spaces included) ↓ to list Tab tab Esc clear Ctrl+F CLI Ctrl+D detail',
|
|
291
302
|
'tui.hint.list': 'Space select ↑↓ move (↑ at top = search) Tab tab Enter run/submit Ctrl+A all Ctrl+O preview Ctrl+F CLI Ctrl+D detail',
|
|
303
|
+
// 힌트 줄 오른쪽 끝의 고정 자리. 나머지 힌트가 다 잘려 나가도 이 자리는 남는다.
|
|
304
|
+
'tui.hint.quit': 'Ctrl+Q quit',
|
|
292
305
|
'tui.review.title': 'Review — {count} change(s)',
|
|
293
306
|
'tui.review.more': ' …and {count} more',
|
|
294
|
-
'tui.review.hint': 'Enter apply Esc cancel',
|
|
307
|
+
'tui.review.hint': 'Enter apply Esc cancel Ctrl+Q quit',
|
|
295
308
|
'tui.notItemRow': 'Press Enter to run this row.',
|
|
296
309
|
'tui.noPreview': 'This item has no preview.',
|
|
297
310
|
'tui.opened': 'Opened: {target}',
|
|
@@ -299,6 +312,8 @@ export default {
|
|
|
299
312
|
'tui.submitCancelled': 'Submission cancelled.',
|
|
300
313
|
'tui.toggledAll': 'Selected every visible item in the {tab} tab.',
|
|
301
314
|
'tui.toggledNone': 'Cleared every visible item in the {tab} tab.',
|
|
315
|
+
// 배타 묶음에서 형제가 함께 꺼졌을 때. 조용히 뒤집히면 오조작으로 읽힌다.
|
|
316
|
+
'tui.exclusiveSwitched': 'Switched to {kept} — they share one file, so {dropped} was cleared.',
|
|
302
317
|
'tui.nonInteractive': 'The interactive screen only opens in a terminal. --list and --set work too.',
|
|
303
318
|
'tui.pressAnyKey': 'Press any key to continue…',
|
|
304
319
|
'tui.confirmSuffix': ' [y/N] ',
|
|
@@ -332,6 +347,7 @@ export default {
|
|
|
332
347
|
'tui.filter.empty': ' Nothing in this tab is wired for {cli}.',
|
|
333
348
|
|
|
334
349
|
'item.location.user': 'installs to: user global',
|
|
350
|
+
'item.target': 'file: {path}',
|
|
335
351
|
// 커버리지는 전부 지원할 때도 찍는다 — 표시가 없는 것과 "10/10"은 뜻이 다르다.
|
|
336
352
|
'item.cliCoverage': 'CLI {covered}/{total}',
|
|
337
353
|
'item.unsupportedList': 'not wired for {count}: {groups}',
|
|
@@ -340,6 +356,7 @@ export default {
|
|
|
340
356
|
'detail.unwired': 'unwired',
|
|
341
357
|
'detail.provider': 'source',
|
|
342
358
|
'detail.preview': 'preview',
|
|
359
|
+
'detail.target': 'file',
|
|
343
360
|
'detail.scope.project': 'repo scope',
|
|
344
361
|
'detail.scope.user': 'user global',
|
|
345
362
|
'detail.more': ' …and {count} more line(s) — Ctrl+D to expand',
|
|
@@ -366,6 +383,9 @@ export default {
|
|
|
366
383
|
'category.context': 'Code understanding',
|
|
367
384
|
'category.style': 'Design taste',
|
|
368
385
|
'category.flow': 'Way of working',
|
|
386
|
+
// 헤더 하나가 "무엇을 고르는 자리이고 규칙이 무엇인가"를 함께 말한다 —
|
|
387
|
+
// 대상 파일이 하나뿐인 배타 묶음이라 라디오 표시와 짝이 된다.
|
|
388
|
+
'category.commit': 'Commit template · .gitmessage.txt · pick one',
|
|
369
389
|
'category.service': 'External services',
|
|
370
390
|
'design.localSuffix': '{id} (local)',
|
|
371
391
|
'design.localTag': 'local',
|
package/lib/i18n/catalog/ko.mjs
CHANGED
|
@@ -254,6 +254,15 @@ export default {
|
|
|
254
254
|
'item.skill.karpathy.note': '과설계와 모호한 완료 기준을 줄이는 행동 지침',
|
|
255
255
|
'item.skill.gstack.note': '저장소 로컬 clone + setup (bash 필요, Windows는 Git Bash). 런타임 상태(~/.gstack)는 전역에 생길 수 있음.',
|
|
256
256
|
'item.skill.gsd.note': 'npx @opengsd/gsd-core 프로젝트 로컬 설치. 상류는 Codex·Antigravity 등 18개 런타임을 지원합니다(이 항목은 --claude)',
|
|
257
|
+
// 커밋 템플릿은 CLI 배선이 아니라 저장소 규약이다 — en.mjs 쪽 주석 참고.
|
|
258
|
+
'item.config.gitmessage.en.note': '커밋 메시지를 영어로 씁니다. 템플릿을 놓고 git commit.template이 그것을 가리키게 하므로, 저장소의 모든 편집기와 도구에서 함께 열립니다',
|
|
259
|
+
'item.config.gitmessage.ko.note': '커밋 메시지를 한국어로 씁니다. 템플릿을 놓고 git commit.template이 그것을 가리키게 하므로, 저장소의 모든 편집기와 도구에서 함께 열립니다',
|
|
260
|
+
'item.gitmessage.unregistered': '파일은 놓였지만 commit.template이 아직 가리키지 않습니다',
|
|
261
|
+
'log.gitmessage.write': ' [dry-run] {path} 생성',
|
|
262
|
+
'log.gitmessage.remove': ' [dry-run] {path} 제거',
|
|
263
|
+
'error.gitmessageForeign': '{path}가 이미 있고 이 도구가 쓴 내용이 아닙니다. 손으로 쓴 템플릿은 덮어쓰지 않습니다 — 먼저 옮기거나 지우세요.',
|
|
264
|
+
'error.gitmessageConfig': 'git config commit.template 실패: {output}',
|
|
265
|
+
'error.exclusiveItems': '같은 파일을 쓰는 항목이라 하나만 고를 수 있습니다: {ids}',
|
|
257
266
|
'error.gstackClone': 'gstack clone 실패: {output}',
|
|
258
267
|
'error.gstackSetup': 'gstack setup 실패: {output}',
|
|
259
268
|
'error.gsdInstall': 'GSD 설치 실패: {output}',
|
|
@@ -270,6 +279,7 @@ export default {
|
|
|
270
279
|
'section.plugin': 'PLUGIN',
|
|
271
280
|
'section.mcp': 'MCP',
|
|
272
281
|
'section.skill': 'SKILL',
|
|
282
|
+
'section.config': 'CONFIG',
|
|
273
283
|
'section.design': 'DESIGN.MD',
|
|
274
284
|
|
|
275
285
|
// TUI 목록·검토 화면 문구. en.mjs 쪽 주석 참고.
|
|
@@ -280,9 +290,11 @@ export default {
|
|
|
280
290
|
'tui.empty.none': ' 항목이 없습니다.',
|
|
281
291
|
'tui.hint.search': '입력=검색어 ↓ 목록으로 Tab 탭이동 Esc 검색해제 Ctrl+F CLI Ctrl+D 상세',
|
|
282
292
|
'tui.hint.list': 'Space 선택 ↑↓ 이동 Tab 탭 Enter 실행/제출 Ctrl+A 전체 Ctrl+O 미리보기 Ctrl+F CLI Ctrl+D 상세',
|
|
293
|
+
// 힌트 줄 오른쪽 끝의 고정 자리. en.mjs 쪽 주석 참고.
|
|
294
|
+
'tui.hint.quit': 'Ctrl+Q 종료',
|
|
283
295
|
'tui.review.title': '제출 검토 — 변경 {count}건',
|
|
284
296
|
'tui.review.more': ' …외 {count}건',
|
|
285
|
-
'tui.review.hint': 'Enter 적용 Esc 취소',
|
|
297
|
+
'tui.review.hint': 'Enter 적용 Esc 취소 Ctrl+Q 종료',
|
|
286
298
|
'tui.notItemRow': '이 행은 Enter로 실행합니다.',
|
|
287
299
|
'tui.noPreview': '이 항목은 미리보기를 제공하지 않습니다.',
|
|
288
300
|
'tui.opened': '열었습니다: {target}',
|
|
@@ -290,6 +302,8 @@ export default {
|
|
|
290
302
|
'tui.submitCancelled': '제출을 취소했습니다.',
|
|
291
303
|
'tui.toggledAll': '{tab} 탭의 보이는 항목을 모두 선택했습니다.',
|
|
292
304
|
'tui.toggledNone': '{tab} 탭의 보이는 항목을 모두 해제했습니다.',
|
|
305
|
+
// 배타 묶음에서 형제가 함께 꺼졌을 때. en.mjs 쪽 주석 참고.
|
|
306
|
+
'tui.exclusiveSwitched': '{kept}(으)로 바꿨습니다 — 같은 파일이라 {dropped}은(는) 해제했습니다.',
|
|
293
307
|
'tui.nonInteractive': '대화형 화면은 터미널에서만 열립니다. --list · --set 으로도 다룰 수 있습니다.',
|
|
294
308
|
'tui.pressAnyKey': '계속하려면 아무 키나 누르세요…',
|
|
295
309
|
'tui.confirmSuffix': ' [y/N] ',
|
|
@@ -320,6 +334,7 @@ export default {
|
|
|
320
334
|
'tui.filter.empty': ' 이 탭에는 {cli}에 배선되는 항목이 없습니다.',
|
|
321
335
|
|
|
322
336
|
'item.location.user': '설치 위치: 사용자 글로벌',
|
|
337
|
+
'item.target': '대상 파일: {path}',
|
|
323
338
|
// en.mjs 쪽 주석 참고.
|
|
324
339
|
'item.cliCoverage': 'CLI {covered}/{total}',
|
|
325
340
|
'item.unsupportedList': '미배선 {count}곳: {groups}',
|
|
@@ -328,6 +343,7 @@ export default {
|
|
|
328
343
|
'detail.unwired': '미배선',
|
|
329
344
|
'detail.provider': '공급자',
|
|
330
345
|
'detail.preview': '미리보기',
|
|
346
|
+
'detail.target': '대상 파일',
|
|
331
347
|
'detail.scope.project': '저장소 스코프',
|
|
332
348
|
'detail.scope.user': '사용자 전역',
|
|
333
349
|
'detail.more': ' …외 {count}줄 — Ctrl+D로 펼치기',
|
|
@@ -353,6 +369,8 @@ export default {
|
|
|
353
369
|
'category.context': '코드 이해',
|
|
354
370
|
'category.style': '디자인 감각',
|
|
355
371
|
'category.flow': '작업 방식',
|
|
372
|
+
// 헤더 하나가 무엇을 고르는 자리이고 규칙이 무엇인지 함께 말한다. en.mjs 쪽 주석 참고.
|
|
373
|
+
'category.commit': '커밋 메시지 템플릿 · .gitmessage.txt · 하나만 고릅니다',
|
|
356
374
|
'category.service': '외부 서비스',
|
|
357
375
|
'design.localSuffix': '{id} (로컬)',
|
|
358
376
|
'design.localTag': '로컬',
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineGitmessage, GITMESSAGE_EN } from '../gitmessage.mjs'
|
|
2
|
+
|
|
3
|
+
export default defineGitmessage({
|
|
4
|
+
id: 'config.gitmessage.en',
|
|
5
|
+
// 두 판을 가르는 낱말을 맨 앞에 둔다 — 라벨 자리(24칸)에서 눈이 먼저 닿는
|
|
6
|
+
// 곳이고, 그룹 헤더가 이미 "커밋 템플릿"을 말하고 있어 뒤쪽은 확인용이다.
|
|
7
|
+
label: 'English commit template',
|
|
8
|
+
body: GITMESSAGE_EN,
|
|
9
|
+
note: 'item.config.gitmessage.en.note',
|
|
10
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineGitmessage, GITMESSAGE_KO } from '../gitmessage.mjs'
|
|
2
|
+
|
|
3
|
+
export default defineGitmessage({
|
|
4
|
+
id: 'config.gitmessage.ko',
|
|
5
|
+
// 라벨은 우리 카탈로그 데이터라 두 로케일에서 같은 값이 나간다 — 영어 화면에
|
|
6
|
+
// 한글('한국어')이 새면 그 자리는 번역 누락으로 읽힌다(i18n.en.test.mjs).
|
|
7
|
+
label: 'Korean commit template',
|
|
8
|
+
body: GITMESSAGE_KO,
|
|
9
|
+
note: 'item.config.gitmessage.ko.note',
|
|
10
|
+
})
|
package/lib/tui/detail.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { toText, createT } from '../i18n/index.mjs'
|
|
9
9
|
import { CLIS } from '../clis.mjs'
|
|
10
10
|
import { categoryLabel } from '../design-md/flow.mjs'
|
|
11
|
-
import { cut, pad, width, wrap } from '../width.mjs'
|
|
11
|
+
import { cut, labelWidth, pad, width, wrap } from '../width.mjs'
|
|
12
12
|
|
|
13
13
|
// 미배선 사유를 **같은 사유끼리** 묶는다. 사유 하나가 CLI 아홉 개에 그대로
|
|
14
14
|
// 반복되면 줄만 길어지고 "무엇이 왜 빠졌는가"는 오히려 묻힌다.
|
|
@@ -25,10 +25,16 @@ export function unsupportedGroups(item, t) {
|
|
|
25
25
|
return [...byReason].map(([why, clis]) => ({ clis, why }))
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
// '배선
|
|
29
|
-
//
|
|
28
|
+
// 왼쪽 라벨 열('배선'·'미배선'·'출처'·'미리보기'·'대상 파일')의 폭. 로케일마다
|
|
29
|
+
// 길이가 달라 상수로 박으면 열이 어긋나므로 그 로케일의 실제 라벨에서 뽑는다.
|
|
30
|
+
//
|
|
31
|
+
// **이 열에 놓이는 라벨을 하나도 빠뜨리면 안 된다.** 예전에는 배선·미배선만
|
|
32
|
+
// 재서, 한국어의 '미리보기'(8칸)가 폭 7의 열에 들어가 '미리…'로 잘렸다 —
|
|
33
|
+
// pad는 넘치는 글을 cut으로 버리기 때문에 조용히 사라진다.
|
|
34
|
+
const LEAD_KEYS = ['detail.wired', 'detail.unwired', 'detail.provider', 'detail.preview', 'detail.target']
|
|
35
|
+
|
|
30
36
|
function leadWidth(t) {
|
|
31
|
-
return
|
|
37
|
+
return labelWidth(t, LEAD_KEYS) + 1
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
function headLine(row, w, t) {
|
|
@@ -75,6 +81,9 @@ function agentLines(row, w, t) {
|
|
|
75
81
|
const item = row.item
|
|
76
82
|
const lead = leadWidth(t)
|
|
77
83
|
const out = [headLine(row, w, t)]
|
|
84
|
+
// 대상 파일은 note보다 먼저 낸다 — MCP의 배선표가 "어느 파일에 쓰이나"를
|
|
85
|
+
// 답하는 자리와 같은 몫이고, 산문보다 먼저 눈에 들어와야 한다.
|
|
86
|
+
if (item.target) out.push(cut(`${pad(t('detail.target'), lead)}${item.target}`, w))
|
|
78
87
|
if (item.note) out.push(...wrap(t(item.note), w))
|
|
79
88
|
if (row.statusDetail) out.push(...wrap(row.statusDetail, w))
|
|
80
89
|
out.push(...wiredLines(item, w, lead, t))
|
package/lib/tui/render.mjs
CHANGED
|
@@ -55,9 +55,15 @@ export function reviewBodyHeight(height) {
|
|
|
55
55
|
|
|
56
56
|
const MARK = { action: '▶', on: '×', off: ' ' }
|
|
57
57
|
|
|
58
|
+
// 대괄호는 "여러 개 고를 수 있다"는 보편적 신호다 — 한 파일에 한 벌만 놓이는
|
|
59
|
+
// 배타 묶음(커밋 템플릿의 두 언어판)에 그대로 쓰면 화면이 거짓말을 한다.
|
|
60
|
+
// 둥근 괄호로 바꿔 라디오임을 드러낸다. 안쪽 글자는 그대로 둔다: ×는 이미
|
|
61
|
+
// 이 화면이 쓰는 글자라 폭 계산이 검증된 값이고, ●·•는 동아시아 폭이
|
|
62
|
+
// 모호(Ambiguous)해 터미널에 따라 두 칸으로 그려져 열을 밀 수 있다.
|
|
58
63
|
function checkbox(row, selected) {
|
|
59
64
|
if (row.kind === 'action') return `[${MARK.action}]`
|
|
60
|
-
|
|
65
|
+
const mark = selected.has(row.id) ? MARK.on : MARK.off
|
|
66
|
+
return row.exclusive ? `(${mark})` : `[${mark}]`
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
// 탭 줄. 목록이 좁혀졌으면(검색어·CLI 필터 어느 쪽이든) 탭마다 적중 수를
|
|
@@ -211,9 +217,18 @@ export function render(state, opts = {}) {
|
|
|
211
217
|
for (let i = shown.length; i < room; i++) lines.push('')
|
|
212
218
|
}
|
|
213
219
|
|
|
220
|
+
// 종료 키는 힌트 줄 **오른쪽 끝에 고정**한다. 나머지 힌트는 좁은 터미널에서
|
|
221
|
+
// 뒤쪽부터 잘려 나가는데, 하필 그때 가장 절실한 것이 "여기서 어떻게
|
|
222
|
+
// 빠져나가나"다 — 잘려도 되는 안내와 절대 사라지면 안 되는 안내를 같은
|
|
223
|
+
// 줄에 이어 붙여 두면 늘 후자가 먼저 사라진다. 상태 메시지가 힌트를
|
|
224
|
+
// 대신할 때도 이 자리는 그대로 남는다.
|
|
214
225
|
const hint = state.focus === 'search' ? t('tui.hint.search') : t('tui.hint.list')
|
|
226
|
+
const quit = t('tui.hint.quit')
|
|
227
|
+
const room = w - width(quit) - 2
|
|
215
228
|
lines.push('')
|
|
216
|
-
lines.push(
|
|
229
|
+
lines.push(room < 8
|
|
230
|
+
? paint(BOLD, cut(quit, w))
|
|
231
|
+
: `${paint(DIM, pad(status || hint, room))} ${paint(BOLD, quit)}`)
|
|
217
232
|
return lines
|
|
218
233
|
}
|
|
219
234
|
|
package/lib/tui/rows.mjs
CHANGED
|
@@ -20,13 +20,16 @@ export const ACTION_SECTION = 'action'
|
|
|
20
20
|
// design-md/scan.mjs의 BUNDLE_CATEGORY와 같은 값이다 — 카테고리를 못 얻은
|
|
21
21
|
// 항목이 모이는 자리. 두 값이 갈리면 같은 그룹이 두 개로 쪼개진다.
|
|
22
22
|
export const CATCH_ALL_CATEGORY = '__other'
|
|
23
|
-
export const SECTION_ORDER = [ACTION_SECTION, 'plugin', 'mcp', 'skill', 'design']
|
|
23
|
+
export const SECTION_ORDER = [ACTION_SECTION, 'plugin', 'mcp', 'skill', 'config', 'design']
|
|
24
24
|
|
|
25
25
|
// 탭 안쪽 소분류는 **성격**으로 가른다 — 같은 탭에 "무엇으로 설치되는가"가 같고
|
|
26
26
|
// "무엇을 해 주는가"가 다른 항목이 섞여 있어서, 기구(plugin·mcp·skill)만으로는
|
|
27
27
|
// 고를 때 비교가 되지 않는다. 표시 순서는 이 배열이 정한다.
|
|
28
28
|
// `__`로 시작하는 id만 categoryLabel이 번역한다(design.md 카테고리와 같은 규칙).
|
|
29
|
-
|
|
29
|
+
// __commit은 CONFIG 탭에만 나오지만 규칙은 같다 — 헤더가 "무엇을 고르는
|
|
30
|
+
// 자리인가"를 말한다. 커밋 템플릿은 대상 파일이 하나뿐이라 헤더에 그 파일
|
|
31
|
+
// 이름과 "하나만"이라는 규칙까지 실어, 라디오 표시와 함께 읽히게 한다.
|
|
32
|
+
export const GROUP_ORDER = ['__token', '__context', '__style', '__flow', '__commit', '__service']
|
|
30
33
|
|
|
31
34
|
function groupRank(group) {
|
|
32
35
|
const i = GROUP_ORDER.indexOf(group)
|
|
@@ -75,6 +78,10 @@ export function agentHint(item, state, t = createT('en')) {
|
|
|
75
78
|
if (item.supports) parts.push(t('item.cliCoverage', { covered: item.supports.length, total: CLI_IDS.length }))
|
|
76
79
|
const detail = toText(t, state.detail)
|
|
77
80
|
if (detail) parts.push(detail)
|
|
81
|
+
// 대상 파일이 하나로 정해진 항목만 target을 갖는다. 평평한 목록(printPlain·
|
|
82
|
+
// --list)에는 그룹 헤더가 없어 무엇을 건드리는지 알 길이 없고, 검색어로도
|
|
83
|
+
// '.gitmessage'가 걸리지 않았다.
|
|
84
|
+
if (item.target) parts.push(t('item.target', { path: item.target }))
|
|
78
85
|
if (item.scope === 'user') parts.push(t('item.location.user'))
|
|
79
86
|
// note는 이제 카탈로그 키다.
|
|
80
87
|
if (item.note) parts.push(t(item.note))
|
|
@@ -123,6 +130,10 @@ function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = nul
|
|
|
123
130
|
id,
|
|
124
131
|
section,
|
|
125
132
|
group,
|
|
133
|
+
// 같은 파일을 두고 다투는 항목들의 묶음 이름(.gitmessage.txt의 두 언어판).
|
|
134
|
+
// 선택 배타는 state.mjs가 이 값만 보고 처리한다 — 순수 리듀서가 항목의
|
|
135
|
+
// 속내를 몰라도 되게 행에 미리 올려 둔다.
|
|
136
|
+
exclusive: item?.exclusive ?? null,
|
|
126
137
|
label,
|
|
127
138
|
hint,
|
|
128
139
|
// 화면에 안 찍히는 긴 힌트. 비대화형 목록(printPlain)과 검색이 쓴다 —
|
|
@@ -142,6 +153,7 @@ function actionRow({ id, label, hint, run = null, t = createT('en') }) {
|
|
|
142
153
|
id,
|
|
143
154
|
section: ACTION_SECTION,
|
|
144
155
|
group: null,
|
|
156
|
+
exclusive: null,
|
|
145
157
|
label,
|
|
146
158
|
hint,
|
|
147
159
|
fullHint: hint,
|
package/lib/tui/run.mjs
CHANGED
|
@@ -176,7 +176,13 @@ export async function runTui(root, opts = {}) {
|
|
|
176
176
|
const select = () => {
|
|
177
177
|
const row = currentRow(state)
|
|
178
178
|
if (row?.kind !== 'item') return t('tui.notItemRow')
|
|
179
|
+
const before = state.selected
|
|
179
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(', ') })
|
|
180
186
|
return ''
|
|
181
187
|
}
|
|
182
188
|
|
|
@@ -194,7 +200,7 @@ export async function runTui(root, opts = {}) {
|
|
|
194
200
|
for (;;) {
|
|
195
201
|
draw(renderReview(changes, { width: stdout.columns ?? 80, height: stdout.rows ?? 24, dryRun, color, t }))
|
|
196
202
|
const key = await keys.next()
|
|
197
|
-
if (key.ctrl && key.name === 'c') return 'quit'
|
|
203
|
+
if (key.ctrl && (key.name === 'c' || key.name === 'q')) return 'quit'
|
|
198
204
|
if (key.name === 'escape') return 'cancel'
|
|
199
205
|
if (key.name === 'return' || key.name === 'enter') return 'apply'
|
|
200
206
|
}
|
|
@@ -260,6 +266,14 @@ export async function runTui(root, opts = {}) {
|
|
|
260
266
|
// 필터를 겹쳐 걸고 싶은 순간이다. 글자 키(c·d)를 쓰지 않는 이유는
|
|
261
267
|
// 목록 포커스에서 아무 글자나 누르면 검색칸으로 올라가기 때문이다:
|
|
262
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
|
+
|
|
263
277
|
if (key.ctrl && (key.name === 'f' || key.name === 'b')) {
|
|
264
278
|
state = cycleCliFilter(state, key.name === 'f' ? 1 : -1, CLI_OPTIONS)
|
|
265
279
|
continue
|
package/lib/tui/state.mjs
CHANGED
|
@@ -135,13 +135,40 @@ export function move(state, delta) {
|
|
|
135
135
|
return cursor === state.cursor ? state : { ...state, cursor }
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// row.exclusive는 "이 항목들은 같은 파일 하나를 두고 다툰다"는 표시다
|
|
139
|
+
// (.gitmessage.txt의 영어판·한국어판). 하나를 켜면 나머지는 꺼진다 — 둘을 함께
|
|
140
|
+
// 고르면 나중에 적용된 쪽이 앞선 쪽을 조용히 덮어써, 화면의 체크 두 개가
|
|
141
|
+
// 디스크의 한 파일을 가리키는 거짓말이 된다.
|
|
142
|
+
//
|
|
143
|
+
// 화면 밖(검색·CLI 필터로 가려진) 형제도 끈다. 보이는 것만 건드린다는 이
|
|
144
|
+
// 모듈의 규칙에서 유일하게 벗어나는 자리인데, 여기서는 그래야 한다 —
|
|
145
|
+
// 가려진 형제를 남겨 두면 사용자가 방금 명시적으로 고른 판이 적용 순서에
|
|
146
|
+
// 따라 뒤집힐 수 있다.
|
|
147
|
+
function dropSiblings(rows, selected, row) {
|
|
148
|
+
if (!row.exclusive) return
|
|
149
|
+
for (const other of rows) {
|
|
150
|
+
if (other.id !== row.id && other.exclusive === row.exclusive) selected.delete(other.id)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 형제 중 하나라도 이미 켜져 있는가. 배타 묶음은 "전부 켜짐"이 불가능하므로
|
|
155
|
+
// 전체 토글의 방향 판정도 이 기준으로 봐야 한다 — 아니면 Ctrl+A가 영원히
|
|
156
|
+
// 켜기만 하고 끄지 못한다.
|
|
157
|
+
function groupCovered(items, selected, row) {
|
|
158
|
+
return items.some((other) => other.exclusive === row.exclusive && selected.has(other.id))
|
|
159
|
+
}
|
|
160
|
+
|
|
138
161
|
// 커서가 항목 행일 때만 뒤집는다. 액션 행은 제거 개념이 없어 체크 대상이 아니다.
|
|
139
162
|
export function toggle(state) {
|
|
140
163
|
const row = currentRow(state)
|
|
141
164
|
if (row?.kind !== 'item') return state
|
|
142
165
|
const selected = new Set(state.selected)
|
|
143
|
-
if (selected.has(row.id))
|
|
144
|
-
|
|
166
|
+
if (selected.has(row.id)) {
|
|
167
|
+
selected.delete(row.id)
|
|
168
|
+
} else {
|
|
169
|
+
selected.add(row.id)
|
|
170
|
+
dropSiblings(state.rows, selected, row)
|
|
171
|
+
}
|
|
145
172
|
return { ...state, selected }
|
|
146
173
|
}
|
|
147
174
|
|
|
@@ -151,10 +178,14 @@ export function toggleVisible(state, on) {
|
|
|
151
178
|
const items = state.filtered.filter((r) => r.kind === 'item')
|
|
152
179
|
if (items.length === 0) return state
|
|
153
180
|
const selected = new Set(state.selected)
|
|
154
|
-
const turnOn = on ?? !items.every((r) => selected.has(r.id))
|
|
181
|
+
const turnOn = on ?? !items.every((r) => selected.has(r.id) || (r.exclusive && groupCovered(items, selected, r)))
|
|
155
182
|
for (const row of items) {
|
|
156
|
-
if (turnOn) selected.
|
|
157
|
-
|
|
183
|
+
if (!turnOn) { selected.delete(row.id); continue }
|
|
184
|
+
// 배타 묶음에서 이미 켜진 형제가 있으면 그 선택을 이긴다 — 전체 선택이
|
|
185
|
+
// 사용자가 고른 언어판을 목록 순서로 뒤집으면 안 된다.
|
|
186
|
+
if (row.exclusive && groupCovered(items, selected, row)) continue
|
|
187
|
+
selected.add(row.id)
|
|
188
|
+
dropSiblings(state.rows, selected, row)
|
|
158
189
|
}
|
|
159
190
|
return { ...state, selected }
|
|
160
191
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rch4com/agent-setup",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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",
|