@icode-js/icode 3.1.0 → 3.2.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.
Files changed (39) hide show
  1. package/README.md +105 -5
  2. package/package.json +1 -1
  3. package/src/cli.js +6 -0
  4. package/src/commands/clean.js +4 -1
  5. package/src/commands/codereview.js +4 -1
  6. package/src/commands/completion.js +9 -6
  7. package/src/commands/config.js +9 -6
  8. package/src/commands/doctor.js +80 -0
  9. package/src/commands/explain.js +4 -1
  10. package/src/commands/help.js +10 -0
  11. package/src/commands/info.js +4 -1
  12. package/src/commands/migrate.js +38 -1
  13. package/src/commands/push.js +45 -18
  14. package/src/commands/ready.js +110 -0
  15. package/src/commands/save.js +98 -0
  16. package/src/commands/undo.js +13 -1
  17. package/src/core/ai/diff-prompt.js +204 -0
  18. package/src/core/cli/args.js +23 -2
  19. package/src/core/cli/completion.js +33 -0
  20. package/src/core/config/store.js +2 -4
  21. package/src/core/git/context.js +14 -15
  22. package/src/core/git/preconditions.js +88 -0
  23. package/src/core/git/service.js +151 -1
  24. package/src/core/remote-merge/providers/gitlab.js +149 -90
  25. package/src/core/tools/interactive.js +209 -34
  26. package/src/core/tools/loading.js +5 -1
  27. package/src/core/tools/logger.js +5 -0
  28. package/src/workflows/ai-codereview-workflow.js +14 -2
  29. package/src/workflows/ai-commit-workflow.js +14 -6
  30. package/src/workflows/checkout-workflow.js +14 -2
  31. package/src/workflows/clean-workflow.js +87 -70
  32. package/src/workflows/doctor-workflow.js +131 -0
  33. package/src/workflows/migrate-workflow.js +265 -58
  34. package/src/workflows/push-workflow.js +232 -50
  35. package/src/workflows/ready-workflow.js +278 -0
  36. package/src/workflows/rollback-workflow.js +20 -5
  37. package/src/workflows/save-workflow.js +152 -0
  38. package/src/workflows/sync-workflow.js +18 -2
  39. package/src/workflows/undo-workflow.js +25 -7
@@ -1,7 +1,10 @@
1
1
  import { IcodeError } from '../../errors.js'
2
+ import { withSpinner } from '../../tools/loading.js'
3
+ import { logger } from '../../tools/logger.js'
2
4
  import { normalizeRemoteMergeReason, parseRemoteMergePayload } from '../shared.js'
3
5
 
4
6
  const GITLAB_MERGE_STATUS_POLL_INTERVAL_MS = 1000
7
+ const GITLAB_MERGE_STATUS_MAX_ATTEMPTS = 60
5
8
  const GITLAB_PENDING_MERGE_STATUSES = new Set([
6
9
  'unchecked',
7
10
  'checking',
@@ -19,6 +22,15 @@ const GITLAB_ACCEPTED_MERGE_STATUSES = new Set([
19
22
  'merge_time'
20
23
  ])
21
24
 
25
+ // 生成可读的 GitLab MR 标签,输入 MR id 与 URL,输出适合日志展示的短文本。
26
+ function formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl) {
27
+ if (mergeRequestUrl) {
28
+ return `${logger.color('green', mergeRequestUrl)} (#${mergeRequestId})`
29
+ }
30
+
31
+ return `#${mergeRequestId}`
32
+ }
33
+
22
34
  // 根据 Git remote URL 生成标准 GitLab 项目 URL,无法识别时返回空串。
23
35
  export function resolveGitLabProjectUrlFromRemote(remoteUrl = '') {
24
36
  const raw = String(remoteUrl || '').trim()
@@ -121,6 +133,24 @@ function delay(ms) {
121
133
  })
122
134
  }
123
135
 
136
+ // 合并检查轮询间隔,测试可显式传入 0 以避免等待;输入 options,输出毫秒数。
137
+ function resolvePollIntervalMs(options = {}) {
138
+ if (Number.isInteger(options.pollIntervalMs) && options.pollIntervalMs >= 0) {
139
+ return options.pollIntervalMs
140
+ }
141
+
142
+ return GITLAB_MERGE_STATUS_POLL_INTERVAL_MS
143
+ }
144
+
145
+ // 合并检查最大轮询次数,测试或调用方可显式缩短;输入 options,输出正整数次数。
146
+ function resolveMaxPollAttempts(options = {}) {
147
+ if (Number.isInteger(options.maxPollAttempts) && options.maxPollAttempts > 0) {
148
+ return options.maxPollAttempts
149
+ }
150
+
151
+ return GITLAB_MERGE_STATUS_MAX_ATTEMPTS
152
+ }
153
+
124
154
  // 执行一次 GitLab JSON 请求,并统一返回响应对象、原始文本和解析后的负载。
125
155
  async function performGitLabJsonRequest(endpoint, requestOptions, errorMessage) {
126
156
  let response
@@ -180,6 +210,27 @@ async function fetchGitLabMergeRequest(project, mergeRequestId, apiKey) {
180
210
  }
181
211
  }
182
212
 
213
+ // 构建 GitLab 状态轮询超时结果,输入阶段信息和最后一次响应,输出统一失败结构。
214
+ function buildGitLabPollTimeoutResult(stage, mergeRequestId, mergeRequestUrl, maxAttempts, lastPayload) {
215
+ const status = typeof lastPayload?.detailed_merge_status === 'string' && lastPayload.detailed_merge_status.trim()
216
+ ? lastPayload.detailed_merge_status.trim()
217
+ : (typeof lastPayload?.merge_status === 'string' ? lastPayload.merge_status.trim() : '')
218
+ const reason = status
219
+ ? `GitLab 合并检查超时,最后状态: ${status},已轮询 ${maxAttempts} 次。`
220
+ : `GitLab 合并检查超时,已轮询 ${maxAttempts} 次。`
221
+
222
+ return {
223
+ ok: false,
224
+ conflict: false,
225
+ status: 408,
226
+ reason,
227
+ step: stage,
228
+ mergeRequestId,
229
+ mergeRequestUrl,
230
+ payload: lastPayload
231
+ }
232
+ }
233
+
183
234
  // 将 GitLab MR 状态转换成统一结果,保证上层能明确识别冲突分支。
184
235
  function buildGitLabMergeStatusResult(payload, mergeRequestId, mergeRequestUrl) {
185
236
  const detailedMergeStatus = typeof payload?.detailed_merge_status === 'string'
@@ -331,61 +382,97 @@ function buildGitLabMergeReadinessResult(payload, mergeRequestId, mergeRequestUr
331
382
  }
332
383
 
333
384
  // 轮询 GitLab MR,直到“是否允许 merge”检查完成。
334
- async function waitForGitLabMergeReadiness(project, mergeRequestId, mergeRequestUrl, apiKey) {
335
- while (true) {
336
- const mergeRequest = await fetchGitLabMergeRequest(project, mergeRequestId, apiKey)
337
- if (!mergeRequest.response.ok) {
338
- return buildGitLabFailedResult(mergeRequest.response, mergeRequest.rawText, mergeRequest.payload, {
339
- step: 'inspect',
340
- mergeRequestId,
341
- mergeRequestUrl,
342
- payload: {
343
- create: null,
344
- approve: null,
345
- merge: null,
346
- inspect: mergeRequest.payload
385
+ async function waitForGitLabMergeReadiness(project, mergeRequestId, mergeRequestUrl, apiKey, options = {}) {
386
+ const maxAttempts = resolveMaxPollAttempts(options)
387
+ const pollIntervalMs = resolvePollIntervalMs(options)
388
+ let lastPayload = null
389
+
390
+ const pollResult = await withSpinner(
391
+ `等待 GitLab 完成合并前检查 ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`,
392
+ async () => {
393
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
394
+ const mergeRequest = await fetchGitLabMergeRequest(project, mergeRequestId, apiKey)
395
+ lastPayload = mergeRequest.payload
396
+ if (!mergeRequest.response.ok) {
397
+ return buildGitLabFailedResult(mergeRequest.response, mergeRequest.rawText, mergeRequest.payload, {
398
+ step: 'inspect',
399
+ mergeRequestId,
400
+ mergeRequestUrl,
401
+ payload: {
402
+ create: null,
403
+ merge: null,
404
+ inspect: mergeRequest.payload
405
+ }
406
+ })
347
407
  }
348
- })
349
- }
350
408
 
351
- const result = buildGitLabMergeReadinessResult(mergeRequest.payload, mergeRequestId, mergeRequestUrl)
352
- if (result) {
353
- return result
354
- }
409
+ const result = buildGitLabMergeReadinessResult(mergeRequest.payload, mergeRequestId, mergeRequestUrl)
410
+ if (result) {
411
+ return result
412
+ }
355
413
 
356
- await delay(GITLAB_MERGE_STATUS_POLL_INTERVAL_MS)
414
+ if (attempt < maxAttempts) {
415
+ await delay(pollIntervalMs)
416
+ }
417
+ }
418
+
419
+ return null
420
+ }
421
+ )
422
+ if (pollResult) {
423
+ return pollResult
357
424
  }
425
+
426
+ return buildGitLabPollTimeoutResult('inspect', mergeRequestId, mergeRequestUrl, maxAttempts, lastPayload)
358
427
  }
359
428
 
360
429
  // 轮询 GitLab MR,直到异步冲突检查完成并得到可归档的最终状态。
361
- async function waitForGitLabMergeOutcome(project, mergeRequestId, mergeRequestUrl, apiKey) {
362
- while (true) {
363
- const mergeRequest = await fetchGitLabMergeRequest(project, mergeRequestId, apiKey)
364
- if (!mergeRequest.response.ok) {
365
- return buildGitLabFailedResult(mergeRequest.response, mergeRequest.rawText, mergeRequest.payload, {
366
- step: 'inspect',
367
- mergeRequestId,
368
- mergeRequestUrl,
369
- payload: {
370
- create: null,
371
- approve: null,
372
- merge: null,
373
- inspect: mergeRequest.payload
430
+ async function waitForGitLabMergeOutcome(project, mergeRequestId, mergeRequestUrl, apiKey, options = {}) {
431
+ const maxAttempts = resolveMaxPollAttempts(options)
432
+ const pollIntervalMs = resolvePollIntervalMs(options)
433
+ let lastPayload = null
434
+
435
+ const pollResult = await withSpinner(
436
+ `等待 GitLab 完成远程合并 ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`,
437
+ async () => {
438
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
439
+ const mergeRequest = await fetchGitLabMergeRequest(project, mergeRequestId, apiKey)
440
+ lastPayload = mergeRequest.payload
441
+ if (!mergeRequest.response.ok) {
442
+ return buildGitLabFailedResult(mergeRequest.response, mergeRequest.rawText, mergeRequest.payload, {
443
+ step: 'inspect',
444
+ mergeRequestId,
445
+ mergeRequestUrl,
446
+ payload: {
447
+ create: null,
448
+ merge: null,
449
+ inspect: mergeRequest.payload
450
+ }
451
+ })
374
452
  }
375
- })
376
- }
377
453
 
378
- const result = buildGitLabMergeStatusResult(mergeRequest.payload, mergeRequestId, mergeRequestUrl)
379
- if (result) {
380
- return result
381
- }
454
+ const result = buildGitLabMergeStatusResult(mergeRequest.payload, mergeRequestId, mergeRequestUrl)
455
+ if (result) {
456
+ return result
457
+ }
458
+
459
+ if (attempt < maxAttempts) {
460
+ await delay(pollIntervalMs)
461
+ }
462
+ }
382
463
 
383
- await delay(GITLAB_MERGE_STATUS_POLL_INTERVAL_MS)
464
+ return null
465
+ }
466
+ )
467
+ if (pollResult) {
468
+ return pollResult
384
469
  }
470
+
471
+ return buildGitLabPollTimeoutResult('inspect', mergeRequestId, mergeRequestUrl, maxAttempts, lastPayload)
385
472
  }
386
473
 
387
- // 根据创建 MR 的结果先执行审批,再提交自动合并,确保完整走完 GitLab 审批合并链路。
388
- async function processGitLabMergeRequest(project, createPayload, apiKey) {
474
+ // 根据创建 MR 的结果提交自动合并,由 GitLab merge 接口按项目审批规则判定是否允许合并。
475
+ async function processGitLabMergeRequest(project, createPayload, apiKey, options = {}) {
389
476
  const mergeRequestId = createPayload?.iid ? String(createPayload.iid) : ''
390
477
  const mergeRequestUrl = typeof createPayload?.web_url === 'string' ? createPayload.web_url : ''
391
478
 
@@ -400,56 +487,26 @@ async function processGitLabMergeRequest(project, createPayload, apiKey) {
400
487
  mergeRequestUrl,
401
488
  payload: {
402
489
  create: createPayload,
490
+ inspect: null,
403
491
  merge: null
404
492
  }
405
493
  }
406
494
  }
407
495
 
408
496
  const sourceSha = typeof createPayload?.sha === 'string' ? createPayload.sha.trim() : ''
409
- const readinessResult = await waitForGitLabMergeReadiness(project, mergeRequestId, mergeRequestUrl, apiKey)
497
+ logger.success(`GitLab 合并请求已创建: ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`)
498
+ const readinessResult = await waitForGitLabMergeReadiness(project, mergeRequestId, mergeRequestUrl, apiKey, options)
410
499
  if (!readinessResult.ok) {
411
500
  return {
412
501
  ...readinessResult,
413
502
  payload: {
414
503
  create: createPayload,
415
- approve: null,
416
504
  merge: null,
417
505
  inspect: readinessResult.payload
418
506
  }
419
507
  }
420
508
  }
421
-
422
- const approveRequestBody = {}
423
- if (sourceSha) {
424
- approveRequestBody.sha = sourceSha
425
- }
426
-
427
- const approveEndpoint = `${project.baseUrl}/api/v4/projects/${project.projectId}/merge_requests/${mergeRequestId}/approve`
428
- const approveResult = await performGitLabJsonRequest(
429
- approveEndpoint,
430
- {
431
- method: 'POST',
432
- headers: {
433
- 'Content-Type': 'application/json',
434
- 'PRIVATE-TOKEN': apiKey
435
- },
436
- body: JSON.stringify(approveRequestBody)
437
- },
438
- 'GitLab 合并审批请求失败'
439
- )
440
-
441
- if (!approveResult.response.ok) {
442
- return buildGitLabFailedResult(approveResult.response, approveResult.rawText, approveResult.payload, {
443
- step: 'approve',
444
- mergeRequestId,
445
- mergeRequestUrl,
446
- payload: {
447
- create: createPayload,
448
- approve: approveResult.payload,
449
- merge: null
450
- }
451
- })
452
- }
509
+ logger.success(`GitLab 合并前检查通过: ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`)
453
510
 
454
511
  const mergeRequestBody = {
455
512
  auto_merge: true
@@ -459,17 +516,20 @@ async function processGitLabMergeRequest(project, createPayload, apiKey) {
459
516
  }
460
517
 
461
518
  const mergeEndpoint = `${project.baseUrl}/api/v4/projects/${project.projectId}/merge_requests/${mergeRequestId}/merge`
462
- const mergeResult = await performGitLabJsonRequest(
463
- mergeEndpoint,
464
- {
465
- method: 'PUT',
466
- headers: {
467
- 'Content-Type': 'application/json',
468
- 'PRIVATE-TOKEN': apiKey
519
+ const mergeResult = await withSpinner(
520
+ `提交 GitLab 自动合并 ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`,
521
+ () => performGitLabJsonRequest(
522
+ mergeEndpoint,
523
+ {
524
+ method: 'PUT',
525
+ headers: {
526
+ 'Content-Type': 'application/json',
527
+ 'PRIVATE-TOKEN': apiKey
528
+ },
529
+ body: JSON.stringify(mergeRequestBody)
469
530
  },
470
- body: JSON.stringify(mergeRequestBody)
471
- },
472
- 'GitLab 自动合并请求失败'
531
+ 'GitLab 自动合并请求失败'
532
+ )
473
533
  )
474
534
 
475
535
  if (!mergeResult.response.ok) {
@@ -479,18 +539,17 @@ async function processGitLabMergeRequest(project, createPayload, apiKey) {
479
539
  mergeRequestUrl,
480
540
  payload: {
481
541
  create: createPayload,
482
- approve: approveResult.payload,
483
542
  merge: mergeResult.payload
484
543
  }
485
544
  })
486
545
  }
487
546
 
488
- const finalResult = await waitForGitLabMergeOutcome(project, mergeRequestId, mergeRequestUrl, apiKey)
547
+ logger.success(`GitLab 自动合并已提交: ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`)
548
+ const finalResult = await waitForGitLabMergeOutcome(project, mergeRequestId, mergeRequestUrl, apiKey, options)
489
549
  return {
490
550
  ...finalResult,
491
551
  payload: {
492
552
  create: createPayload,
493
- approve: approveResult.payload,
494
553
  merge: mergeResult.payload,
495
554
  inspect: finalResult.payload
496
555
  }
@@ -533,5 +592,5 @@ export async function requestGitLabRemoteMerge(options = {}) {
533
592
  })
534
593
  }
535
594
 
536
- return processGitLabMergeRequest(project, payload, apiKey)
595
+ return processGitLabMergeRequest(project, payload, apiKey, options)
537
596
  }
@@ -115,33 +115,22 @@ export async function chooseOne(message, choices, defaultIndex = 0) {
115
115
  }
116
116
 
117
117
  /**
118
- * 在给定选项中进行多选;非交互环境返回默认值集合。
119
- * 输入为提示文案、选项列表与多选配置,输出为选中的 value 数组或 null。
118
+ * 按选项顺序输出已选 value。
119
+ * 输入为选项与已选集合,输出为稳定顺序的 value 数组。
120
120
  */
121
- export async function chooseMany(message, choices, options = {}) {
122
- if (!Array.isArray(choices) || choices.length === 0) {
123
- throw new Error('chooseMany 需要至少一个可选项')
124
- }
125
-
126
- const parsedMin = Number(options.minSelections ?? 0)
127
- const parsedMax = Number(options.maxSelections ?? choices.length)
128
- const minSelections = Number.isFinite(parsedMin) ? Math.max(0, Math.floor(parsedMin)) : 0
129
- const maxCap = Number.isFinite(parsedMax) ? Math.max(0, Math.floor(parsedMax)) : choices.length
130
- const maxSelections = Math.max(minSelections, Math.min(choices.length, maxCap))
131
- const doneLabel = options.doneLabel || '完成选择'
132
- const cancelLabel = options.cancelLabel || '取消'
133
- const defaultValues = Array.isArray(options.defaultValues) ? options.defaultValues : []
134
- const defaultSet = new Set(defaultValues)
135
- const selectedValues = choices
121
+ function collectSelectedValues(choices, selected, maxSelections) {
122
+ return choices
136
123
  .map((choice) => choice.value)
137
- .filter((value) => defaultSet.has(value))
124
+ .filter((value) => selected.has(value))
138
125
  .slice(0, maxSelections)
126
+ }
139
127
 
140
- if (!isInteractive()) {
141
- return selectedValues
142
- }
143
-
144
- const selected = new Set(selectedValues)
128
+ /**
129
+ * 使用旧版数字菜单进行多选,供不支持 raw mode 的终端回退。
130
+ * 输入为多选上下文,输出为已选 value 数组或 null。
131
+ */
132
+ async function chooseManyByNumberInput(message, choices, context) {
133
+ const selected = new Set(context.selectedValues)
145
134
  const doneValue = '__prompt_done__'
146
135
  const cancelValue = '__prompt_cancel__'
147
136
 
@@ -152,14 +141,14 @@ export async function chooseMany(message, choices, options = {}) {
152
141
  }))
153
142
  menuChoices.push({
154
143
  value: doneValue,
155
- label: `${doneLabel}(已选 ${selected.size} 项)`
144
+ label: `${context.doneLabel}(已选 ${selected.size} 项)`
156
145
  })
157
146
  menuChoices.push({
158
147
  value: cancelValue,
159
- label: cancelLabel
148
+ label: context.cancelLabel
160
149
  })
161
150
 
162
- const defaultChoiceValue = selected.size >= minSelections ? doneValue : choices[0].value
151
+ const defaultChoiceValue = selected.size >= context.minSelections ? doneValue : choices[0].value
163
152
  const resolvedDefaultIndex = Math.max(0, menuChoices.findIndex((item) => item.value === defaultChoiceValue))
164
153
  const selectedAction = await chooseOne(message, menuChoices, resolvedDefaultIndex)
165
154
 
@@ -168,14 +157,11 @@ export async function chooseMany(message, choices, options = {}) {
168
157
  }
169
158
 
170
159
  if (selectedAction === doneValue) {
171
- if (selected.size < minSelections) {
172
- stdout.write(`至少需要选择 ${minSelections} 项。\n`)
160
+ if (selected.size < context.minSelections) {
161
+ stdout.write(`至少需要选择 ${context.minSelections} 项。\n`)
173
162
  continue
174
163
  }
175
- return choices
176
- .map((choice) => choice.value)
177
- .filter((value) => selected.has(value))
178
- .slice(0, maxSelections)
164
+ return collectSelectedValues(choices, selected, context.maxSelections)
179
165
  }
180
166
 
181
167
  if (selected.has(selectedAction)) {
@@ -183,11 +169,200 @@ export async function chooseMany(message, choices, options = {}) {
183
169
  continue
184
170
  }
185
171
 
186
- if (selected.size >= maxSelections) {
187
- stdout.write(`最多只能选择 ${maxSelections} 项。\n`)
172
+ if (selected.size >= context.maxSelections) {
173
+ stdout.write(`最多只能选择 ${context.maxSelections} 项。\n`)
188
174
  continue
189
175
  }
190
176
 
191
177
  selected.add(selectedAction)
192
178
  }
193
179
  }
180
+
181
+ /**
182
+ * 渲染 checkbox 多选列表。
183
+ * 输入为当前光标和选择状态,输出为本次渲染占用的行数。
184
+ */
185
+ function renderCheckboxChoices(message, choices, context, cursorIndex, selected, statusMessage, renderedLines) {
186
+ if (renderedLines > 0) {
187
+ stdout.write(`\x1b[${renderedLines}F`)
188
+ stdout.write('\x1b[J')
189
+ }
190
+
191
+ const lines = [
192
+ message,
193
+ `↑/↓ 移动,Space 选中/取消,Enter ${context.doneLabel},q ${context.cancelLabel}(已选 ${selected.size} 项)`
194
+ ]
195
+
196
+ if (statusMessage) {
197
+ lines.push(statusMessage)
198
+ }
199
+
200
+ choices.forEach((choice, index) => {
201
+ const cursor = index === cursorIndex ? '>' : ' '
202
+ const checkbox = selected.has(choice.value) ? '[x]' : '[ ]'
203
+ lines.push(`${cursor} ${checkbox} ${choice.label}`)
204
+ })
205
+
206
+ stdout.write(`${lines.join('\n')}\n`)
207
+ return lines.length
208
+ }
209
+
210
+ /**
211
+ * 使用键盘 checkbox 进行多选。
212
+ * 输入为多选上下文,输出为已选 value 数组或 null。
213
+ */
214
+ function chooseManyByCheckbox(message, choices, context) {
215
+ const selected = new Set(context.selectedValues)
216
+ let cursorIndex = 0
217
+ let renderedLines = 0
218
+ let statusMessage = ''
219
+ let closed = false
220
+ const wasRaw = stdin.isRaw === true
221
+
222
+ return new Promise((resolve) => {
223
+ const finish = (value) => {
224
+ if (closed) {
225
+ return
226
+ }
227
+
228
+ closed = true
229
+ stdin.off('data', onData)
230
+ stdin.setRawMode(wasRaw)
231
+ stdin.pause()
232
+ resolve(value)
233
+ }
234
+
235
+ const render = () => {
236
+ renderedLines = renderCheckboxChoices(message, choices, context, cursorIndex, selected, statusMessage, renderedLines)
237
+ }
238
+
239
+ const moveCursor = (offset) => {
240
+ statusMessage = ''
241
+ cursorIndex = (cursorIndex + offset + choices.length) % choices.length
242
+ render()
243
+ }
244
+
245
+ const toggleCurrent = () => {
246
+ statusMessage = ''
247
+ const currentValue = choices[cursorIndex].value
248
+ if (selected.has(currentValue)) {
249
+ selected.delete(currentValue)
250
+ render()
251
+ return
252
+ }
253
+
254
+ if (selected.size >= context.maxSelections) {
255
+ statusMessage = `最多只能选择 ${context.maxSelections} 项。`
256
+ render()
257
+ return
258
+ }
259
+
260
+ selected.add(currentValue)
261
+ render()
262
+ }
263
+
264
+ const complete = () => {
265
+ if (selected.size < context.minSelections) {
266
+ statusMessage = `至少需要选择 ${context.minSelections} 项。`
267
+ render()
268
+ return
269
+ }
270
+ finish(collectSelectedValues(choices, selected, context.maxSelections))
271
+ }
272
+
273
+ function onData(chunk) {
274
+ const input = chunk.toString('utf8')
275
+
276
+ for (let index = 0; index < input.length; index += 1) {
277
+ if (closed) {
278
+ return
279
+ }
280
+
281
+ const sequence = input.slice(index, index + 3)
282
+ if (sequence === '\u001b[A') {
283
+ moveCursor(-1)
284
+ index += 2
285
+ continue
286
+ }
287
+
288
+ if (sequence === '\u001b[B') {
289
+ moveCursor(1)
290
+ index += 2
291
+ continue
292
+ }
293
+
294
+ const key = input[index]
295
+ if (key === '\u0003' || key === '\u001b' || key.toLowerCase() === 'q') {
296
+ finish(null)
297
+ return
298
+ }
299
+
300
+ if (key === '\r' || key === '\n') {
301
+ complete()
302
+ continue
303
+ }
304
+
305
+ if (key === ' ') {
306
+ toggleCurrent()
307
+ continue
308
+ }
309
+
310
+ if (key.toLowerCase() === 'k') {
311
+ moveCursor(-1)
312
+ continue
313
+ }
314
+
315
+ if (key.toLowerCase() === 'j') {
316
+ moveCursor(1)
317
+ }
318
+ }
319
+ }
320
+
321
+ stdin.setRawMode(true)
322
+ stdin.resume()
323
+ stdin.on('data', onData)
324
+ render()
325
+ })
326
+ }
327
+
328
+ /**
329
+ * 在给定选项中进行多选;非交互环境返回默认值集合。
330
+ * 输入为提示文案、选项列表与多选配置,输出为选中的 value 数组或 null。
331
+ */
332
+ export async function chooseMany(message, choices, options = {}) {
333
+ if (!Array.isArray(choices) || choices.length === 0) {
334
+ throw new Error('chooseMany 需要至少一个可选项')
335
+ }
336
+
337
+ const parsedMin = Number(options.minSelections ?? 0)
338
+ const parsedMax = Number(options.maxSelections ?? choices.length)
339
+ const minSelections = Number.isFinite(parsedMin) ? Math.max(0, Math.floor(parsedMin)) : 0
340
+ const maxCap = Number.isFinite(parsedMax) ? Math.max(0, Math.floor(parsedMax)) : choices.length
341
+ const maxSelections = Math.max(minSelections, Math.min(choices.length, maxCap))
342
+ const doneLabel = options.doneLabel || '完成选择'
343
+ const cancelLabel = options.cancelLabel || '取消'
344
+ const defaultValues = Array.isArray(options.defaultValues) ? options.defaultValues : []
345
+ const defaultSet = new Set(defaultValues)
346
+ const selectedValues = choices
347
+ .map((choice) => choice.value)
348
+ .filter((value) => defaultSet.has(value))
349
+ .slice(0, maxSelections)
350
+
351
+ if (!isInteractive()) {
352
+ return selectedValues
353
+ }
354
+
355
+ const context = {
356
+ minSelections,
357
+ maxSelections,
358
+ doneLabel,
359
+ cancelLabel,
360
+ selectedValues
361
+ }
362
+
363
+ if (typeof stdin.setRawMode !== 'function') {
364
+ return chooseManyByNumberInput(message, choices, context)
365
+ }
366
+
367
+ return chooseManyByCheckbox(message, choices, context)
368
+ }
@@ -1,9 +1,11 @@
1
1
  const SPINNER_FRAMES = ['-', '\\', '|', '/']
2
2
 
3
+ // 判断当前进程是否适合渲染动态等待效果,输入为空,输出布尔值。
3
4
  function canRenderSpinner() {
4
5
  return Boolean(process.stderr.isTTY) && process.env.ICODE_NO_SPINNER !== '1'
5
6
  }
6
7
 
8
+ // 清理当前终端行,输入为空,输出为空,仅在 TTY 中生效。
7
9
  function clearLine() {
8
10
  if (!canRenderSpinner()) {
9
11
  return
@@ -12,6 +14,7 @@ function clearLine() {
12
14
  process.stderr.write('\r\x1b[2K')
13
15
  }
14
16
 
17
+ // 执行异步任务时展示等待动画,输入展示文案和任务函数,输出任务结果。
15
18
  export async function withSpinner(text, task) {
16
19
  if (!canRenderSpinner()) {
17
20
  return task()
@@ -21,7 +24,8 @@ export async function withSpinner(text, task) {
21
24
  const render = () => {
22
25
  const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]
23
26
  frameIndex += 1
24
- process.stderr.write(`\r[icode] ${frame} ${text}`)
27
+ clearLine()
28
+ process.stderr.write(`[icode] ${frame} ${text}`)
25
29
  }
26
30
 
27
31
  render()
@@ -25,6 +25,11 @@ function print(stream, level, message) {
25
25
  }
26
26
 
27
27
  export const logger = {
28
+ // 按指定颜色渲染短文本,输入颜色名和文本,输出可直接拼进日志的 ANSI 字符串。
29
+ color(color, message) {
30
+ return colorize(color, String(message ?? ''))
31
+ },
32
+
28
33
  setVerbose(enabled) {
29
34
  verboseEnabled = Boolean(enabled)
30
35
  },