@wayfarer35/ccs 0.2.2

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/dist/formUi.js ADDED
@@ -0,0 +1,608 @@
1
+ import { Box, render, Text, useInput, useStdout } from 'ink';
2
+ import React, { useEffect, useMemo, useReducer, useState } from 'react';
3
+ import { ALIAS_TIERS, buildResult, EFFORT_LEVELS, initState, TIERS, validateState, } from './form.js';
4
+ import { t } from './i18n.js';
5
+ import { redactSettings } from './launch.js';
6
+ import { Cancel } from './tui.js';
7
+ import { clearScreen } from './screen.js';
8
+ const h = React.createElement;
9
+ const TAB_IDS = ['apikey', 'models', 'options', 'review'];
10
+ // ---------- search helpers ----------
11
+ function filterModels(all, filter) {
12
+ const q = filter.trim().toLowerCase();
13
+ if (!q)
14
+ return all;
15
+ return all.filter((m) => m.toLowerCase().includes(q));
16
+ }
17
+ /** 当前聚焦字段是否带可搜索下拉。 */
18
+ function fieldHasSupport(field) {
19
+ return !!field && field.kind === 'text' && !!field.support && field.support.length > 0;
20
+ }
21
+ // ---------- field value accessors ----------
22
+ function getTextValue(form, field) {
23
+ switch (field.id) {
24
+ case 'baseUrl': return form.baseUrl;
25
+ case 'token': return form.apiKey;
26
+ case 'authMethod': return form.authMethod;
27
+ case 'singleModel': return form.singleModel;
28
+ case 'alias_FABLE': return form.aliases.FABLE ?? '';
29
+ case 'alias_OPUS': return form.aliases.OPUS ?? '';
30
+ case 'alias_SONNET': return form.aliases.SONNET ?? '';
31
+ case 'alias_HAIKU': return form.aliases.HAIKU ?? '';
32
+ case 'autoCompactWindow': return String(form.options.autoCompactWindow);
33
+ case 'customParams': return form.customParams;
34
+ default: return '';
35
+ }
36
+ }
37
+ function setTextValue(form, field, v) {
38
+ switch (field.id) {
39
+ case 'baseUrl': return { ...form, baseUrl: v };
40
+ case 'token': return { ...form, apiKey: v, keepExistingKey: v ? false : form.keepExistingKey };
41
+ case 'authMethod': return { ...form, authMethod: v };
42
+ case 'singleModel': return { ...form, singleModel: v };
43
+ case 'alias_FABLE': return { ...form, aliases: { ...form.aliases, FABLE: v } };
44
+ case 'alias_OPUS': return { ...form, aliases: { ...form.aliases, OPUS: v } };
45
+ case 'alias_SONNET': return { ...form, aliases: { ...form.aliases, SONNET: v } };
46
+ case 'alias_HAIKU': return { ...form, aliases: { ...form.aliases, HAIKU: v } };
47
+ case 'autoCompactWindow': return { ...form, options: { ...form.options, autoCompactWindow: v } };
48
+ case 'customParams': return { ...form, customParams: v };
49
+ default: return form;
50
+ }
51
+ }
52
+ function getBoolValue(form, field) {
53
+ if (field.id === 'aliases')
54
+ return form.mode === 'alias';
55
+ if (field.id === 'attributionHeader')
56
+ return form.options.attributionHeader;
57
+ if (field.id === 'disableNonEssentialTraffic')
58
+ return form.options.disableNonEssentialTraffic;
59
+ return false;
60
+ }
61
+ function setBoolValue(form, field) {
62
+ if (field.id === 'aliases') {
63
+ return { ...form, mode: form.mode === 'alias' ? 'single' : 'alias' };
64
+ }
65
+ if (field.id === 'attributionHeader') {
66
+ return { ...form, options: { ...form.options, attributionHeader: !form.options.attributionHeader } };
67
+ }
68
+ if (field.id === 'disableNonEssentialTraffic') {
69
+ return { ...form, options: { ...form.options, disableNonEssentialTraffic: !form.options.disableNonEssentialTraffic } };
70
+ }
71
+ return form;
72
+ }
73
+ /**
74
+ * select 字段的取值集合与读写器。新增 select 字段在此登记即可,
75
+ * 无需改动 reducer 与渲染逻辑。
76
+ */
77
+ function selectConfig(fieldId) {
78
+ if (fieldId === 'authMethod') {
79
+ return {
80
+ values: ['auth_token', 'api_key'],
81
+ get: (f) => f.authMethod,
82
+ set: (f, v) => ({ ...f, authMethod: v }),
83
+ };
84
+ }
85
+ if (fieldId === 'tier') {
86
+ return {
87
+ values: TIERS.map((x) => x.value),
88
+ get: (f) => f.tier,
89
+ set: (f, v) => ({ ...f, tier: v }),
90
+ };
91
+ }
92
+ if (fieldId === 'effort') {
93
+ return {
94
+ values: EFFORT_LEVELS,
95
+ get: (f) => f.options.effort,
96
+ set: (f, v) => ({ ...f, options: { ...f.options, effort: v } }),
97
+ };
98
+ }
99
+ return null;
100
+ }
101
+ // ---------- text editing helpers ----------
102
+ function insertAt(s, idx, ch) { return s.slice(0, idx) + ch + s.slice(idx); }
103
+ function eraseBack(s, idx) { return idx > 0 ? s.slice(0, idx - 1) + s.slice(idx) : s; }
104
+ function eraseFwd(s, idx) { return idx < s.length ? s.slice(0, idx) + s.slice(idx + 1) : s; }
105
+ // ---------- labels ----------
106
+ function fieldLabel(field) {
107
+ switch (field.id) {
108
+ case 'baseUrl': return t('form.fBaseUrl');
109
+ case 'authMethod': return t('form.fAuthMethod');
110
+ case 'token': return t('form.fToken');
111
+ case 'aliases': return t('form.fAliases');
112
+ case 'singleModel': return t('form.fModel');
113
+ case 'tier': return t('form.fTier');
114
+ case 'alias_FABLE': return t('form.fAliasShort', { tier: 'FABLE' });
115
+ case 'alias_OPUS': return t('form.fAliasShort', { tier: 'OPUS' });
116
+ case 'alias_SONNET': return t('form.fAliasShort', { tier: 'SONNET' });
117
+ case 'alias_HAIKU': return t('form.fAliasShort', { tier: 'HAIKU' });
118
+ case 'attributionHeader': return t('form.fAttr');
119
+ case 'disableNonEssentialTraffic': return t('form.fNonEss');
120
+ case 'effort': return t('form.fEffort');
121
+ case 'autoCompactWindow': return t('form.fAutoCompact');
122
+ case 'customParams': return t('form.fCustomParams');
123
+ case 'submit': return t('tab.submit');
124
+ case 'cancel': return t('tab.cancel');
125
+ case 'nextTab': return t('tab.next');
126
+ default: return field.id;
127
+ }
128
+ }
129
+ function tabLabel(id) {
130
+ if (id === 'review')
131
+ return t('tab.review');
132
+ return t(`tab.${id}`);
133
+ }
134
+ // ---------- tabFields ----------
135
+ /** 当前 tab 的可聚焦字段列表(review 的 preview 是静态展示,不参与聚焦)。 */
136
+ export function tabFields(form, tabIndex) {
137
+ const id = TAB_IDS[tabIndex];
138
+ if (id === undefined)
139
+ return [];
140
+ if (id === 'apikey') {
141
+ return [
142
+ { id: 'baseUrl', kind: 'text' },
143
+ { id: 'authMethod', kind: 'select' },
144
+ { id: 'token', kind: 'password' },
145
+ { id: 'nextTab', kind: 'button' },
146
+ ];
147
+ }
148
+ if (id === 'models') {
149
+ const base = [{ id: 'aliases', kind: 'toggle' }];
150
+ if (form.mode === 'single') {
151
+ return [
152
+ ...base,
153
+ { id: 'singleModel', kind: 'text', support: form.modelSupport },
154
+ { id: 'nextTab', kind: 'button' },
155
+ ];
156
+ }
157
+ return [
158
+ ...base,
159
+ { id: 'tier', kind: 'select' },
160
+ ...ALIAS_TIERS.map((tier) => ({
161
+ id: `alias_${tier}`,
162
+ kind: 'text',
163
+ support: form.modelSupport,
164
+ })),
165
+ { id: 'nextTab', kind: 'button' },
166
+ ];
167
+ }
168
+ if (id === 'options') {
169
+ return [
170
+ { id: 'attributionHeader', kind: 'toggle' },
171
+ { id: 'disableNonEssentialTraffic', kind: 'toggle' },
172
+ { id: 'effort', kind: 'select' },
173
+ { id: 'autoCompactWindow', kind: 'number' },
174
+ { id: 'customParams', kind: 'text' },
175
+ { id: 'nextTab', kind: 'button' },
176
+ ];
177
+ }
178
+ // review
179
+ return [
180
+ { id: 'submit', kind: 'button' },
181
+ { id: 'cancel', kind: 'button' },
182
+ ];
183
+ }
184
+ function isTextKind(kind) {
185
+ return kind === 'text' || kind === 'password' || kind === 'number';
186
+ }
187
+ // ---------- reducer ----------
188
+ function init(form) {
189
+ return withAc(sanitize({ tabIndex: 0, fieldIndex: 0, cursor: 0, form, status: 'editing', error: null, ac: null }), true);
190
+ }
191
+ /** 夹紧 fieldIndex、并在聚焦到文本字段时把光标置到末尾。 */
192
+ function sanitize(s) {
193
+ const fs = tabFields(s.form, s.tabIndex);
194
+ let fi = s.fieldIndex;
195
+ if (fi < 0)
196
+ fi = 0;
197
+ if (fs.length && fi >= fs.length)
198
+ fi = fs.length - 1;
199
+ if (!fs.length)
200
+ fi = 0;
201
+ const f = fs[fi];
202
+ const cursor = f && isTextKind(f.kind) ? (getTextValue(s.form, f) || '').length : 0;
203
+ return { ...s, fieldIndex: fi, cursor };
204
+ }
205
+ /**
206
+ * 同步内联下拉状态:聚焦字段带 support 时打开(reset=true 重置到首条),
207
+ * 否则关闭。filter 始终取字段当前文本值,故无需单独存储。
208
+ */
209
+ function withAc(s, reset) {
210
+ const f = tabFields(s.form, s.tabIndex)[s.fieldIndex];
211
+ if (!fieldHasSupport(f))
212
+ return { ...s, ac: null };
213
+ const filtered = filterModels(f.support, getTextValue(s.form, f));
214
+ let index = reset ? 0 : (s.ac ? s.ac.index : 0);
215
+ if (filtered.length > 0) {
216
+ if (index > filtered.length - 1)
217
+ index = filtered.length - 1;
218
+ if (index < 0)
219
+ index = 0;
220
+ }
221
+ else {
222
+ index = 0;
223
+ }
224
+ return { ...s, ac: { index } };
225
+ }
226
+ function reduce(state, action) {
227
+ const next = reduceRaw(state, action);
228
+ // AC 导航保留已算好的 index(仅夹紧);其余动作重新同步(聚焦/文本变化时重置到首条)。
229
+ return action.type === 'AC_NEXT' || action.type === 'AC_PREV'
230
+ ? withAc(next, false)
231
+ : withAc(next, true);
232
+ }
233
+ function reduceRaw(state, action) {
234
+ const s = state;
235
+ switch (action.type) {
236
+ case 'NEXT_TAB':
237
+ return sanitize({ ...s, tabIndex: (s.tabIndex + 1) % TAB_IDS.length, fieldIndex: 0, error: null });
238
+ case 'PREV_TAB':
239
+ return sanitize({ ...s, tabIndex: (s.tabIndex + TAB_IDS.length - 1) % TAB_IDS.length, fieldIndex: 0, error: null });
240
+ case 'NEXT_FIELD': {
241
+ const fs = tabFields(s.form, s.tabIndex);
242
+ return sanitize({ ...s, fieldIndex: (s.fieldIndex + 1) % fs.length, error: null });
243
+ }
244
+ case 'PREV_FIELD': {
245
+ const fs = tabFields(s.form, s.tabIndex);
246
+ return sanitize({ ...s, fieldIndex: (s.fieldIndex + fs.length - 1) % fs.length, error: null });
247
+ }
248
+ case 'CURSOR_MOVE': {
249
+ const f = tabFields(s.form, s.tabIndex)[s.fieldIndex];
250
+ if (!f || !isTextKind(f.kind))
251
+ return s;
252
+ const len = (getTextValue(s.form, f) || '').length;
253
+ return { ...s, cursor: Math.max(0, Math.min(s.cursor + action.delta, len)) };
254
+ }
255
+ case 'INSERT': {
256
+ const f = tabFields(s.form, s.tabIndex)[s.fieldIndex];
257
+ if (!f || !isTextKind(f.kind))
258
+ return s;
259
+ const v = getTextValue(s.form, f) || '';
260
+ const nv = insertAt(v, s.cursor, action.char);
261
+ return { ...s, form: setTextValue(s.form, f, nv), cursor: s.cursor + 1, error: null };
262
+ }
263
+ case 'ERASE': {
264
+ const f = tabFields(s.form, s.tabIndex)[s.fieldIndex];
265
+ if (!f || !isTextKind(f.kind))
266
+ return s;
267
+ const v = getTextValue(s.form, f) || '';
268
+ if (action.dir < 0) {
269
+ if (s.cursor <= 0)
270
+ return s;
271
+ return { ...s, form: setTextValue(s.form, f, eraseBack(v, s.cursor)), cursor: s.cursor - 1 };
272
+ }
273
+ if (s.cursor >= v.length)
274
+ return s;
275
+ return { ...s, form: setTextValue(s.form, f, eraseFwd(v, s.cursor)) };
276
+ }
277
+ case 'TOGGLE':
278
+ return sanitize({ ...s, form: setBoolValue(s.form, action.field), error: null });
279
+ case 'SELECT_DELTA': {
280
+ const cfg = selectConfig(action.field.id);
281
+ if (!cfg)
282
+ return s;
283
+ const arr = cfg.values;
284
+ const i = arr.indexOf(cfg.get(s.form));
285
+ const ni = (i + action.delta + arr.length) % arr.length;
286
+ const nv = arr[ni];
287
+ if (nv === undefined)
288
+ return s;
289
+ return { ...s, form: cfg.set(s.form, nv), error: null };
290
+ }
291
+ case 'AC_NEXT': {
292
+ const f = tabFields(s.form, s.tabIndex)[s.fieldIndex];
293
+ const fs = tabFields(s.form, s.tabIndex);
294
+ if (!f)
295
+ return s;
296
+ const filtered = filterModels(f.support || [], getTextValue(s.form, f));
297
+ const cur = s.ac ? s.ac.index : 0;
298
+ // 无候选项 或 已到末条 → 跳到下一字段
299
+ if (!filtered.length || cur >= filtered.length - 1) {
300
+ return sanitize({ ...s, fieldIndex: (s.fieldIndex + 1) % fs.length, ac: { index: 0 }, error: null });
301
+ }
302
+ return { ...s, ac: { index: cur + 1 } };
303
+ }
304
+ case 'AC_PREV': {
305
+ const f = tabFields(s.form, s.tabIndex)[s.fieldIndex];
306
+ const fs = tabFields(s.form, s.tabIndex);
307
+ if (!f)
308
+ return s;
309
+ const filtered = filterModels(f.support || [], getTextValue(s.form, f));
310
+ const cur = s.ac ? s.ac.index : 0;
311
+ // 无候选项 或 已到首条 → 跳到上一字段
312
+ if (!filtered.length || cur <= 0) {
313
+ return sanitize({ ...s, fieldIndex: (s.fieldIndex + fs.length - 1) % fs.length, ac: { index: 0 }, error: null });
314
+ }
315
+ return { ...s, ac: { index: cur - 1 } };
316
+ }
317
+ case 'AC_ACCEPT': {
318
+ const f = tabFields(s.form, s.tabIndex)[s.fieldIndex];
319
+ const fs = tabFields(s.form, s.tabIndex);
320
+ if (f) {
321
+ const filtered = filterModels(f.support || [], getTextValue(s.form, f));
322
+ const cur = s.ac ? s.ac.index : 0;
323
+ const item = filtered[cur];
324
+ // 有高亮候选 → 填回字段值
325
+ if (item) {
326
+ const nf = setTextValue(s.form, f, item);
327
+ return sanitize({ ...s, form: nf, fieldIndex: (s.fieldIndex + 1) % fs.length, cursor: (getTextValue(nf, f) || '').length, error: null });
328
+ }
329
+ }
330
+ // 无候选 → 保留已输入文本,跳到下一字段
331
+ return sanitize({ ...s, fieldIndex: (s.fieldIndex + 1) % fs.length, error: null });
332
+ }
333
+ case 'ACTIVATE': {
334
+ if (action.field.id === 'cancel')
335
+ return { ...s, status: 'cancel' };
336
+ if (action.field.id === 'nextTab') {
337
+ return sanitize({ ...s, tabIndex: (s.tabIndex + 1) % TAB_IDS.length, fieldIndex: 0, error: null });
338
+ }
339
+ if (action.field.id === 'submit') {
340
+ const err = validateForSubmit(s.form);
341
+ if (err)
342
+ return { ...s, error: err };
343
+ return { ...s, status: 'done', error: null };
344
+ }
345
+ return s;
346
+ }
347
+ case 'CANCEL':
348
+ return { ...s, status: 'cancel' };
349
+ default:
350
+ return s;
351
+ }
352
+ }
353
+ /** 把运行态表单转回标准 FormState(autoCompactWindow 转数字)。 */
354
+ function toFormState(form) {
355
+ const { autoCompactWindow, ...restOptions } = form.options;
356
+ return {
357
+ ...form,
358
+ options: { ...restOptions, autoCompactWindow: Number(autoCompactWindow) || 0 },
359
+ };
360
+ }
361
+ function validateForSubmit(form) {
362
+ const e = validateState(toFormState(form));
363
+ if (e)
364
+ return e;
365
+ const ac = String(form.options.autoCompactWindow).trim();
366
+ if (!/^\d+$/.test(ac) || Number(ac) <= 0)
367
+ return t('form.autoCompactValidate');
368
+ return null;
369
+ }
370
+ function FieldRow({ field, form, focused, cursor, blinkOn, acIndex }) {
371
+ const prefix = focused ? h(Text, { color: 'cyan' }, '▸ ') : h(Text, null, ' ');
372
+ if (field.kind === 'text' || field.kind === 'password' || field.kind === 'number') {
373
+ const raw = getTextValue(form, field) || '';
374
+ const disp = field.kind === 'password' ? '*'.repeat(raw.length) : raw;
375
+ const keptHint = field.kind === 'password' && form.existingKey && !raw
376
+ ? h(Text, { dimColor: true }, ' ' + t('form.fTokenKept'))
377
+ : null;
378
+ const label = h(Text, null, fieldLabel(field) + ': ');
379
+ // text 且有 support:提示内联下拉的用法
380
+ const hasSupport = field.kind === 'text' && !!field.support && field.support.length > 0;
381
+ const acHint = hasSupport
382
+ ? h(Text, { dimColor: true }, ' ' + t('form.acHint'))
383
+ : null;
384
+ const renderText = () => {
385
+ if (!focused) {
386
+ return h(Text, null, prefix, label, h(Text, null, disp), keptHint, acHint);
387
+ }
388
+ // 闪烁的细竖线光标:blinkOn 时显示 ▏(遮住光标位字符),否则正常显示该字符。
389
+ const cur = Math.min(cursor, disp.length);
390
+ const charAt = disp.slice(cur, cur + 1);
391
+ const cursorNode = blinkOn
392
+ ? h(Text, { color: 'cyan' }, '▏')
393
+ : h(Text, null, charAt || ' ');
394
+ return h(Text, null, prefix, label, h(Text, { color: 'cyan' }, disp.slice(0, cur)), cursorNode, h(Text, { color: 'cyan' }, disp.slice(cur + 1)), keptHint, acHint);
395
+ };
396
+ // 聚焦且带 support:在输入框下方展开内联下拉
397
+ if (focused && hasSupport && acIndex !== null) {
398
+ return h(Box, { flexDirection: 'column' }, renderText(), h(ModelDropdown, { field, form, acIndex }));
399
+ }
400
+ return renderText();
401
+ }
402
+ if (field.kind === 'toggle') {
403
+ const on = getBoolValue(form, field);
404
+ // 只用 ✓/✗,不留空,避免「空」的歧义。
405
+ return h(Text, null, prefix, h(Text, on ? { color: 'green' } : {}, on ? '[✓]' : '[✗]'), ' ', fieldLabel(field));
406
+ }
407
+ if (field.kind === 'select') {
408
+ // 左右不再改值,仅用空格循环切换;去掉 ◀▶ 以免误导。
409
+ const cfg = selectConfig(field.id);
410
+ const val = cfg ? cfg.get(form) : '';
411
+ return h(Text, null, prefix, fieldLabel(field), ': ', h(Text, { color: 'cyan' }, val), ' ', h(Text, { dimColor: true }, t('form.spaceHint')));
412
+ }
413
+ // button
414
+ return h(Text, focused ? { backgroundColor: 'cyan', color: 'black' } : {}, ` ${fieldLabel(field)} `);
415
+ }
416
+ /**
417
+ * 模型字段内联下拉:以字段当前文本为 filter,最多显示 5 条可滚动,
418
+ * 高亮 acIndex。memo 化以隔离光标闪烁导致的父级重渲染(防闪烁)。
419
+ */
420
+ const ModelDropdown = React.memo(function ModelDropdown({ field, form, acIndex }) {
421
+ const support = field.support || [];
422
+ const filtered = filterModels(support, getTextValue(form, field));
423
+ if (!filtered.length)
424
+ return null;
425
+ const maxItems = 5;
426
+ const startIdx = Math.max(0, Math.min(acIndex, filtered.length - maxItems));
427
+ const visible = filtered.slice(startIdx, startIdx + maxItems);
428
+ return h(Box, { flexDirection: 'column' }, ...visible.map((model, i) => {
429
+ const abs = startIdx + i;
430
+ const sel = abs === acIndex;
431
+ return h(Text, sel ? { backgroundColor: 'cyan', color: 'black' } : { dimColor: true }, (sel ? '▸ ' : ' ') + model);
432
+ }), filtered.length > maxItems
433
+ ? h(Text, { dimColor: true }, ` (${startIdx + 1}-${startIdx + visible.length}/${filtered.length})`)
434
+ : null);
435
+ });
436
+ function ReviewBody({ form, state, blinkOn }) {
437
+ const redacted = JSON.stringify(redactSettings(buildResult(toFormState(form))), null, 2);
438
+ const fs = tabFields(form, 3); // review
439
+ return h(Box, { flexDirection: 'column' }, h(Box, { flexDirection: 'column', borderStyle: 'single', borderColor: 'gray', paddingLeft: 1, paddingRight: 1, marginBottom: 1 }, h(Text, { dimColor: true }, t('tab.previewTitle')), h(Text, null, redacted)), h(Box, { flexDirection: 'row', gap: 2 }, h(FieldRow, { field: fs[0], form, focused: state.fieldIndex === 0, cursor: state.cursor, blinkOn, acIndex: null }), h(FieldRow, { field: fs[1], form, focused: state.fieldIndex === 1, cursor: state.cursor, blinkOn, acIndex: null })));
440
+ }
441
+ function FormApp({ initialForm, title, onDone, onCancel }) {
442
+ const [state, dispatch] = useReducer(reduce, initialForm, init);
443
+ const { stdout } = useStdout();
444
+ const cols = stdout && stdout.columns ? stdout.columns : 60;
445
+ // 文本字段光标闪烁;移动光标/切字段时立即重显示。
446
+ const [blinkOn, setBlinkOn] = useState(true);
447
+ useEffect(() => {
448
+ const id = setInterval(() => setBlinkOn((b) => !b), 530);
449
+ return () => clearInterval(id);
450
+ }, []);
451
+ useEffect(() => { setBlinkOn(true); }, [state.cursor, state.fieldIndex, state.tabIndex]);
452
+ useEffect(() => {
453
+ if (state.status === 'done')
454
+ onDone(state.form);
455
+ else if (state.status === 'cancel')
456
+ onCancel();
457
+ }, [state.status]);
458
+ useInput((input, key) => {
459
+ if (state.status !== 'editing')
460
+ return;
461
+ if (key.escape) {
462
+ dispatch({ type: 'CANCEL' });
463
+ return;
464
+ }
465
+ const field = tabFields(state.form, state.tabIndex)[state.fieldIndex];
466
+ if (!field)
467
+ return;
468
+ const hasSupport = field.kind === 'text' && !!field.support && field.support.length > 0;
469
+ // Tab = 切换 tab(模型字段已内联下拉,不再用单独覆盖层)
470
+ if (key.tab) {
471
+ dispatch({ type: key.shift ? 'PREV_TAB' : 'NEXT_TAB' });
472
+ return;
473
+ }
474
+ if (key.return) {
475
+ if (field.kind === 'button')
476
+ dispatch({ type: 'ACTIVATE', field });
477
+ else if (hasSupport)
478
+ dispatch({ type: 'AC_ACCEPT' });
479
+ else
480
+ dispatch({ type: 'NEXT_FIELD' });
481
+ return;
482
+ }
483
+ if (isTextKind(field.kind)) {
484
+ // 文本字段内:左右 = 光标移动。
485
+ if (key.leftArrow) {
486
+ dispatch({ type: 'CURSOR_MOVE', delta: -1 });
487
+ return;
488
+ }
489
+ if (key.rightArrow) {
490
+ dispatch({ type: 'CURSOR_MOVE', delta: 1 });
491
+ return;
492
+ }
493
+ // ink 把 \x7f(Linux/WSL 的 Backspace 键)映射成 key.delete 而非 key.backspace,
494
+ // 且无法与真正的 Delete 键区分。两者都按「向后删除」处理,否则光标在末尾时退格无效。
495
+ if (key.backspace || key.delete) {
496
+ dispatch({ type: 'ERASE', dir: -1 });
497
+ return;
498
+ }
499
+ // 模型字段:上下导航内联下拉(到底/到顶则继续切字段);普通文本字段:上下切字段。
500
+ if (hasSupport) {
501
+ if (key.upArrow) {
502
+ dispatch({ type: 'AC_PREV' });
503
+ return;
504
+ }
505
+ if (key.downArrow) {
506
+ dispatch({ type: 'AC_NEXT' });
507
+ return;
508
+ }
509
+ }
510
+ else {
511
+ if (key.upArrow) {
512
+ dispatch({ type: 'PREV_FIELD' });
513
+ return;
514
+ }
515
+ if (key.downArrow) {
516
+ dispatch({ type: 'NEXT_FIELD' });
517
+ return;
518
+ }
519
+ }
520
+ // ink 可能把快速连按的多个字符合并成一次 input(如 "abc"),逐字符处理。
521
+ if (input && !key.ctrl && !key.meta) {
522
+ for (const ch of input) {
523
+ if (ch.charCodeAt(0) < 32)
524
+ continue;
525
+ if (field.kind === 'number' && !/^\d$/.test(ch))
526
+ continue;
527
+ dispatch({ type: 'INSERT', field, char: ch });
528
+ }
529
+ }
530
+ return;
531
+ }
532
+ // 非文本字段:上下/左右用于切换字段(review 的提交/取消按钮横向排列时尤其有用)。
533
+ if (key.upArrow) {
534
+ dispatch({ type: 'PREV_FIELD' });
535
+ return;
536
+ }
537
+ if (key.downArrow) {
538
+ dispatch({ type: 'NEXT_FIELD' });
539
+ return;
540
+ }
541
+ if (key.leftArrow) {
542
+ dispatch({ type: 'PREV_FIELD' });
543
+ return;
544
+ }
545
+ if (key.rightArrow) {
546
+ dispatch({ type: 'NEXT_FIELD' });
547
+ return;
548
+ }
549
+ // 'n' 快捷键:跳到下一个 tab(review 是最后一个,不往后切)。
550
+ if (input === 'n' && TAB_IDS[state.tabIndex] !== 'review') {
551
+ dispatch({ type: 'NEXT_TAB' });
552
+ return;
553
+ }
554
+ // 选值只通过空格切换,避免左右改值与「横向切字段」语义冲突。
555
+ if (field.kind === 'toggle') {
556
+ if (input === ' ')
557
+ dispatch({ type: 'TOGGLE', field });
558
+ return;
559
+ }
560
+ if (field.kind === 'select') {
561
+ if (input === ' ')
562
+ dispatch({ type: 'SELECT_DELTA', field, delta: 1 });
563
+ return;
564
+ }
565
+ });
566
+ const tabId = TAB_IDS[state.tabIndex];
567
+ // memo 化字段列表:blink 仅改 blinkOn(不在 reducer state 内),form/tabIndex 不变时
568
+ // 返回同一组 field 引用,使 ModelDropdown 的 memo 生效,避免下拉随光标闪烁重绘。
569
+ const fields = useMemo(() => tabFields(state.form, state.tabIndex), [state.form, state.tabIndex]);
570
+ const acIndex = state.ac ? state.ac.index : null;
571
+ return h(Box, { flexDirection: 'column' }, title ? h(Text, { color: 'cyan', bold: true }, title) : null,
572
+ // tab bar
573
+ h(Box, { flexDirection: 'row', gap: 1 }, ...TAB_IDS.map((id, i) => {
574
+ const active = i === state.tabIndex;
575
+ return h(Text, active ? { backgroundColor: 'cyan', color: 'black' } : { dimColor: true }, ` ${tabLabel(id)} `);
576
+ })), h(Text, { dimColor: true }, '─'.repeat(Math.min(cols, 64))),
577
+ // content
578
+ h(Box, { flexDirection: 'column' }, tabId === 'review'
579
+ ? h(ReviewBody, { form: state.form, state, blinkOn })
580
+ : h(Box, { flexDirection: 'column' }, ...fields.map((f, i) => h(FieldRow, {
581
+ key: f.id, field: f, form: state.form, focused: i === state.fieldIndex, cursor: state.cursor, blinkOn,
582
+ acIndex: i === state.fieldIndex ? acIndex : null,
583
+ })))),
584
+ // footer
585
+ h(Text, { dimColor: true }, t('form.help')), state.error ? h(Text, { color: 'red' }, '✗ ' + state.error) : null);
586
+ }
587
+ export function runProviderForm(opts = {}) {
588
+ const { initial = {}, preset = null, title } = opts;
589
+ const base = initState(initial, preset);
590
+ // 携带供应商支持的模型列表(用于模型字段内联下拉)。
591
+ const modelSupport = preset?.model?.support;
592
+ // autoCompactWindow 以字符串形式编辑,提交时再校验/转换。
593
+ const initialForm = {
594
+ ...base,
595
+ modelSupport,
596
+ options: { ...base.options, autoCompactWindow: String(base.options.autoCompactWindow) },
597
+ };
598
+ return new Promise((resolve, reject) => {
599
+ let inst;
600
+ const onDone = (form) => { inst.unmount(); resolve(buildResult(toFormState(form))); };
601
+ const onCancel = () => { inst.unmount(); reject(new Cancel()); };
602
+ const props = { initialForm, onDone, onCancel };
603
+ if (title !== undefined)
604
+ props.title = title;
605
+ clearScreen();
606
+ inst = render(h(FormApp, props));
607
+ });
608
+ }
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export interface LocaleOption {
2
+ value: string;
3
+ label: string;
4
+ }
5
+ export declare const LOCALES: readonly LocaleOption[];
6
+ interface CcsConfig {
7
+ locale?: string;
8
+ }
9
+ /** 读取 ~/.ccs/config.json(不存在则空对象)。 */
10
+ export declare function getConfig(): CcsConfig;
11
+ /** 合并写入 ~/.ccs/config.json。 */
12
+ export declare function setConfig(patch: Partial<CcsConfig>): CcsConfig;
13
+ /**
14
+ * 语言探测顺序:config.locale → LC_ALL → LC_MESSAGES → LANG → 'en'。
15
+ * 形如 zh_CN / zh_TW / zh-Hans 均归为 zh-CN,其余归 en。
16
+ */
17
+ export declare function detectLocale(): string;
18
+ /** 翻译变量:{ name: 'foo' } 替换 {name}。 */
19
+ type Vars = Record<string, string | number>;
20
+ /**
21
+ * 翻译。t(key, { name: 'foo' }) 替换 {name}。
22
+ */
23
+ export declare function t(key: string, vars?: Vars): string;
24
+ export {};