@parallel-cli/parallel 0.3.3

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.
@@ -0,0 +1,154 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { matchCommands } from '../commands.js';
5
+ import { t } from '../i18n.js';
6
+ import { readClipboardImage } from './clipboard.js';
7
+ /** A paste is "long" when it spans multiple lines — it then collapses into a chip. */
8
+ const PASTE_MIN_LINES = 2;
9
+ export function CommandInput({ active, placeholder, mask, agentNames = [], onSubmit, onEscape, notify }) {
10
+ const [value, setValue] = useState('');
11
+ const [attachments, setAttachments] = useState([]);
12
+ const [history, setHistory] = useState([]);
13
+ const [histIdx, setHistIdx] = useState(-1);
14
+ const attSeq = useRef(0);
15
+ const reset = () => {
16
+ setValue('');
17
+ setAttachments([]);
18
+ };
19
+ /** Expand collapsed paste markers back into their full text. */
20
+ const expand = (v) => {
21
+ let out = v;
22
+ for (const a of attachments) {
23
+ if (a.kind === 'paste')
24
+ out = out.replace(a.marker, a.text);
25
+ }
26
+ return out;
27
+ };
28
+ const submit = (v) => {
29
+ const full = expand(v).trim();
30
+ const images = attachments.filter((a) => a.kind === 'image');
31
+ if (!full && images.length === 0)
32
+ return;
33
+ setHistory((h) => [...h.slice(-49), v]);
34
+ setHistIdx(-1);
35
+ reset();
36
+ onSubmit(full, images.length > 0 ? images.map((i) => i.dataUri) : undefined);
37
+ };
38
+ /** Collapse a multi-line paste into a numbered chip + inline marker. */
39
+ const addPaste = (text) => {
40
+ const n = ++attSeq.current;
41
+ const lines = text.split('\n').length;
42
+ const marker = t('input.pasted', { n, lines });
43
+ setAttachments((arr) => [...arr, { kind: 'paste', n, marker, text, lines }]);
44
+ return marker;
45
+ };
46
+ const pasteImage = () => {
47
+ const img = readClipboardImage();
48
+ if (!img) {
49
+ notify?.(t('input.imageNone'));
50
+ return;
51
+ }
52
+ const n = ++attSeq.current;
53
+ setAttachments((arr) => [...arr, { kind: 'image', n, dataUri: img.dataUri, label: img.label }]);
54
+ notify?.(t('input.imageAdded'));
55
+ };
56
+ useInput((input, key) => {
57
+ if (key.escape) {
58
+ if (value || attachments.length > 0)
59
+ reset();
60
+ else
61
+ onEscape?.();
62
+ return;
63
+ }
64
+ if (key.return) {
65
+ submit(value);
66
+ return;
67
+ }
68
+ if (key.backspace || key.delete) {
69
+ // If the cursor sits right after a paste marker, remove the whole chip at once.
70
+ const at = attachments.find((a) => a.kind === 'paste' && value.endsWith(a.marker));
71
+ if (at) {
72
+ setValue((v) => v.slice(0, -at.marker.length));
73
+ setAttachments((arr) => arr.filter((a) => a !== at));
74
+ }
75
+ else {
76
+ setValue((v) => v.slice(0, -1));
77
+ }
78
+ return;
79
+ }
80
+ if (key.upArrow) {
81
+ setHistIdx((i) => {
82
+ const ni = i === -1 ? history.length - 1 : Math.max(0, i - 1);
83
+ if (history[ni] !== undefined)
84
+ setValue(history[ni]);
85
+ return ni;
86
+ });
87
+ return;
88
+ }
89
+ if (key.downArrow) {
90
+ setHistIdx((i) => {
91
+ if (i === -1)
92
+ return -1;
93
+ const ni = i + 1;
94
+ if (ni >= history.length) {
95
+ setValue('');
96
+ return -1;
97
+ }
98
+ setValue(history[ni]);
99
+ return ni;
100
+ });
101
+ return;
102
+ }
103
+ if (key.tab) {
104
+ const cmds = matchCommands(value);
105
+ if (cmds.length > 0) {
106
+ setValue(cmds[0].name + ' ');
107
+ return;
108
+ }
109
+ if (value.startsWith('@')) {
110
+ const frag = value.slice(1).toLowerCase();
111
+ const m = ['all', ...agentNames].find((n) => n.toLowerCase().startsWith(frag));
112
+ if (m)
113
+ setValue('@' + m + ' ');
114
+ }
115
+ return;
116
+ }
117
+ if (key.ctrl && input === 'u') {
118
+ reset();
119
+ return;
120
+ }
121
+ if (key.ctrl && input === 'v') {
122
+ pasteImage();
123
+ return;
124
+ }
125
+ if (key.ctrl || key.meta)
126
+ return;
127
+ if (!input)
128
+ return;
129
+ // Multi-line paste (Ink delivers it as one chunked input event) →
130
+ // collapse into a chip like Codex/Claude Code instead of submitting lines.
131
+ if (/[\r\n]/.test(input)) {
132
+ const normalized = input.replace(/\r\n|\r/g, '\n');
133
+ const lineCount = normalized.split('\n').filter((l, i, arr) => l !== '' || i < arr.length - 1).length;
134
+ if (normalized.replace(/\n+$/, '').includes('\n') || lineCount >= PASTE_MIN_LINES) {
135
+ const marker = addPaste(normalized.replace(/\n+$/, ''));
136
+ setValue((v) => v + marker + ' ');
137
+ }
138
+ else {
139
+ // single line ending with Enter → treat the newline as validation
140
+ const v = (value + normalized.split('\n')[0]).trim();
141
+ if (v)
142
+ submit(v);
143
+ }
144
+ return;
145
+ }
146
+ setValue((v) => v + input);
147
+ }, { isActive: active });
148
+ const cmdSuggestions = value.startsWith('/') && !value.includes(' ') ? matchCommands(value).slice(0, 8) : [];
149
+ const agentSuggestions = value.startsWith('@') && !value.includes(' ')
150
+ ? ['all', ...agentNames].filter((n) => n.toLowerCase().startsWith(value.slice(1).toLowerCase())).slice(0, 8)
151
+ : [];
152
+ const shown = mask ? '•'.repeat(value.length) : value;
153
+ return (_jsxs(Box, { flexDirection: "column", children: [cmdSuggestions.length > 0 && (_jsx(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: cmdSuggestions.map((c) => (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: c.name.padEnd(18) }), _jsx(Text, { color: "yellow", children: c.args.padEnd(24) }), _jsx(Text, { color: "gray", children: t(c.descKey) })] }, c.name))) })), agentSuggestions.length > 0 && (_jsx(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: agentSuggestions.map((n) => (_jsxs(Text, { children: [_jsxs(Text, { color: "magenta", bold: true, children: ["@", n] }), _jsxs(Text, { color: "gray", children: [t('input.atHint'), n === 'all' ? t('input.atAll') : ''] })] }, n))) })), attachments.length > 0 && (_jsx(Box, { flexDirection: "row", gap: 1, paddingX: 1, children: attachments.map((a) => (_jsxs(Text, { color: "cyan", backgroundColor: "gray", children: [' ', a.kind === 'paste' ? t('input.attPaste', { n: a.n, lines: a.lines }) : t('input.attImage', { n: a.n, file: a.label }), ' '] }, a.n))) })), _jsxs(Box, { borderStyle: "round", borderColor: active ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { color: "cyanBright", bold: true, children: ["\u276F", ' '] }), shown ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: shown }), active && _jsx(Text, { color: "cyanBright", children: "\u2588" })] })) : (_jsxs(_Fragment, { children: [active && _jsx(Text, { color: "cyanBright", children: "\u2588" }), _jsx(Text, { color: "gray", children: placeholder })] }))] })] }));
154
+ }
package/dist/ui/Md.js ADDED
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ /**
4
+ * Mini markdown renderer for agent summaries — line-based, zero deps.
5
+ * Supports: ## headers, - / * bullets, numbered lists, **bold**, `code`.
6
+ * Everything else is printed as wrapped plain text (Ink handles the wrapping).
7
+ */
8
+ function inline(text, keyPrefix) {
9
+ const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).filter(Boolean);
10
+ return parts.map((p, i) => {
11
+ if (p.startsWith('**') && p.endsWith('**')) {
12
+ return (_jsx(Text, { bold: true, children: p.slice(2, -2) }, `${keyPrefix}-${i}`));
13
+ }
14
+ if (p.startsWith('`') && p.endsWith('`')) {
15
+ return (_jsx(Text, { color: "yellowBright", children: p.slice(1, -1) }, `${keyPrefix}-${i}`));
16
+ }
17
+ return _jsx(Text, { children: p }, `${keyPrefix}-${i}`);
18
+ });
19
+ }
20
+ export function Md({ text, dim }) {
21
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
22
+ return (_jsx(Box, { flexDirection: "column", children: lines.map((raw, i) => {
23
+ const line = raw.trimEnd();
24
+ if (!line.trim())
25
+ return _jsx(Text, { children: " " }, i);
26
+ const header = line.match(/^#{1,3}\s+(.*)$/);
27
+ if (header) {
28
+ return (_jsx(Text, { bold: true, color: "cyanBright", children: header[1] }, i));
29
+ }
30
+ const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
31
+ if (bullet) {
32
+ return (_jsxs(Text, { wrap: "wrap", dimColor: dim, children: [bullet[1], _jsx(Text, { color: "cyan", children: "\u2022 " }), inline(bullet[2], `b${i}`)] }, i));
33
+ }
34
+ const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/);
35
+ if (numbered) {
36
+ return (_jsxs(Text, { wrap: "wrap", dimColor: dim, children: [numbered[1], _jsxs(Text, { color: "cyan", children: [numbered[2], ". "] }), inline(numbered[3], `n${i}`)] }, i));
37
+ }
38
+ return (_jsx(Text, { wrap: "wrap", dimColor: dim, children: inline(line, `l${i}`) }, i));
39
+ }) }));
40
+ }
@@ -0,0 +1,58 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { t } from '../i18n.js';
5
+ const COUNTDOWN_S = 30;
6
+ const SENTINEL = '<<NAME>>';
7
+ /**
8
+ * Agent question with auto-run: a visible 30s countdown; when it reaches 0 the
9
+ * recommended option is chosen automatically. Any keystroke (navigation or
10
+ * digit) PAUSES the countdown — the user has shown they are at the keyboard.
11
+ */
12
+ export function QuestionPrompt({ question, pendingCount, onAnswer }) {
13
+ const [cursor, setCursor] = useState(question.recommended);
14
+ const [left, setLeft] = useState(COUNTDOWN_S);
15
+ const [paused, setPaused] = useState(false);
16
+ const answered = useRef(false);
17
+ const answer = (idx, auto = false) => {
18
+ if (answered.current)
19
+ return;
20
+ answered.current = true;
21
+ onAnswer(question.id, question.options[idx], auto);
22
+ };
23
+ // Countdown — ticks only while not paused; fires the recommended option at 0.
24
+ useEffect(() => {
25
+ if (paused)
26
+ return;
27
+ const timer = setInterval(() => {
28
+ setLeft((s) => {
29
+ if (s <= 1) {
30
+ clearInterval(timer);
31
+ answer(question.recommended, true);
32
+ return 0;
33
+ }
34
+ return s - 1;
35
+ });
36
+ }, 1000);
37
+ return () => clearInterval(timer);
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, [paused, question.id]);
40
+ useInput((input, key) => {
41
+ // The user is typing → pause the auto-run countdown.
42
+ if (!paused)
43
+ setPaused(true);
44
+ if (key.upArrow)
45
+ setCursor((c) => (c - 1 + question.options.length) % question.options.length);
46
+ else if (key.downArrow)
47
+ setCursor((c) => (c + 1) % question.options.length);
48
+ else if (key.return)
49
+ answer(cursor);
50
+ else {
51
+ const n = parseInt(input, 10);
52
+ if (!Number.isNaN(n) && n >= 1 && n <= question.options.length)
53
+ answer(n - 1);
54
+ }
55
+ });
56
+ const [before, after = ''] = t('q.from', { name: SENTINEL }).split(SENTINEL);
57
+ return (_jsxs(Box, { borderStyle: "double", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: "yellow", children: [t('q.title'), pendingCount > 1 ? t('q.pending', { n: pendingCount }) : ''] }), _jsx(Text, { color: paused ? 'gray' : left <= 10 ? 'redBright' : 'yellow', bold: true, children: paused ? t('q.paused') : t('q.autorun', { s: String(left) }) })] }), _jsxs(Text, { children: [before, _jsx(Text, { bold: true, children: question.agentName }), after, " ", question.question] }), question.options.map((opt, i) => (_jsxs(Text, { color: i === cursor ? 'yellowBright' : undefined, bold: i === cursor, children: [i === cursor ? ' ▸ ' : ' ', i + 1, ". ", opt, i === question.recommended ? _jsxs(Text, { color: "green", children: [" ", t('q.recommended')] }) : null] }, i))), _jsx(Text, { color: "gray", children: t('q.keys') })] }));
58
+ }
@@ -0,0 +1,217 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { createSkillTemplate, createSpecialistTemplate } from '../skills.js';
5
+ import { priceFor } from '../pricing.js';
6
+ import { SelectList } from './Wizard.js';
7
+ import { LANGS, getLang, setLang, t } from '../i18n.js';
8
+ function masked(key) {
9
+ if (!key)
10
+ return '—';
11
+ return '••••' + key.slice(-4);
12
+ }
13
+ /**
14
+ * /settings → scope 'global' : persisted in ~/.parallel/config.json
15
+ * /settings-session → scope 'session' : this session only, never persisted
16
+ */
17
+ export function SettingsPanel({ ctl, scope, onClose, }) {
18
+ const [step, setStep] = useState({ id: 'root' });
19
+ const [flash, setFlash] = useState('');
20
+ const saved = () => setFlash(t('set.saved'));
21
+ const cfg = ctl.config;
22
+ const rootItems = scope === 'global'
23
+ ? [
24
+ { label: t('set.language', { lang: LANGS.find((l) => l.code === getLang())?.label ?? getLang() }), value: 'lang' },
25
+ {
26
+ label: t('set.defaultPM', {
27
+ pm: cfg.defaultProvider ? `${cfg.defaultProvider}:${cfg.providers.find((p) => p.name === cfg.defaultProvider)?.defaultModel ?? '?'}` : '—',
28
+ }),
29
+ value: 'defaultPM',
30
+ },
31
+ ...cfg.providers.map((p) => ({
32
+ label: t('set.key', { name: p.name, masked: masked(p.apiKey) }),
33
+ value: `key:${p.name}`,
34
+ })),
35
+ { label: t('set.addProvider'), value: 'add' },
36
+ { label: t('set.models'), value: 'models' },
37
+ { label: t('set.prices'), value: 'prices' },
38
+ { label: t('set.newSkill'), value: 'newSkill' },
39
+ { label: t('set.newSpecialist'), value: 'newSpecialist' },
40
+ { label: t('set.approvals', { mode: cfg.approvalMode }), value: 'approvals' },
41
+ { label: t('set.sound', { state: cfg.soundEnabled ? 'on' : 'off' }), value: 'sound' },
42
+ { label: t('set.back'), value: 'back' },
43
+ ]
44
+ : [
45
+ {
46
+ label: t('sset.model', { pm: `${ctl.session.providerName || '—'}:${ctl.session.model || '—'}` }),
47
+ value: 'model',
48
+ },
49
+ { label: t('sset.approvals', { mode: ctl.session.approvalMode }), value: 'approvals' },
50
+ { label: t('sset.sound', { state: ctl.session.soundEnabled ? 'on' : 'off' }), value: 'sound' },
51
+ { label: t('set.back'), value: 'back' },
52
+ ];
53
+ const chooseRoot = (v) => {
54
+ setFlash('');
55
+ if (v === 'back')
56
+ return onClose();
57
+ if (v === 'lang')
58
+ return setStep({ id: 'lang' });
59
+ if (v === 'defaultPM' || v === 'model')
60
+ return setStep({ id: 'pickProvider', next: 'model' });
61
+ if (v.startsWith('key:')) {
62
+ const p = cfg.providers.find((x) => x.name === v.slice(4));
63
+ if (p)
64
+ setStep({ id: 'key', provider: p });
65
+ return;
66
+ }
67
+ if (v === 'add')
68
+ return setStep({ id: 'newName' });
69
+ if (v === 'models')
70
+ return setStep({ id: 'pickProvider', next: 'models' });
71
+ if (v === 'prices')
72
+ return setStep({ id: 'pickProvider', next: 'prices' });
73
+ if (v === 'newSkill')
74
+ return setStep({ id: 'newSkill' });
75
+ if (v === 'newSpecialist')
76
+ return setStep({ id: 'newSpecialist' });
77
+ if (v === 'approvals') {
78
+ if (scope === 'global')
79
+ ctl.setGlobalApprovalMode(cfg.approvalMode === 'ask' ? 'auto' : 'ask');
80
+ else
81
+ ctl.setSessionApprovalMode(ctl.session.approvalMode === 'ask' ? 'auto' : 'ask');
82
+ if (scope === 'global')
83
+ saved();
84
+ return;
85
+ }
86
+ if (v === 'sound') {
87
+ if (scope === 'global')
88
+ ctl.setGlobalSound(!cfg.soundEnabled);
89
+ else
90
+ ctl.setSessionSound(!ctl.session.soundEnabled);
91
+ if (scope === 'global')
92
+ saved();
93
+ return;
94
+ }
95
+ };
96
+ const pickModel = (provider, model) => {
97
+ if (scope === 'global') {
98
+ provider.defaultModel = model;
99
+ if (!provider.models.includes(model))
100
+ provider.models.push(model);
101
+ ctl.saveProvider(provider);
102
+ ctl.setDefaultProvider(provider.name);
103
+ saved();
104
+ }
105
+ else {
106
+ ctl.setSessionModel(`${provider.name}:${model}`);
107
+ }
108
+ setStep({ id: 'root' });
109
+ };
110
+ const finishNewProvider = (name, url, model, key) => {
111
+ ctl.saveProvider({ name, baseUrl: url, apiKey: key, models: [model], defaultModel: model });
112
+ saved();
113
+ setStep({ id: 'root' });
114
+ };
115
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: scope === 'global' ? t('set.title') : t('sset.title') }), flash ? _jsx(Text, { color: "green", children: flash }) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [step.id === 'root' && _jsx(SelectList, { items: rootItems, onSelect: chooseRoot }), step.id === 'lang' && (_jsx(SelectList, { items: LANGS.map((l) => ({ label: l.label, value: l.code })), onSelect: (code) => {
116
+ setLang(code);
117
+ ctl.setLanguage(code);
118
+ saved();
119
+ setStep({ id: 'root' });
120
+ } })), step.id === 'pickProvider' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.chooseProvider') }), _jsx(SelectList, { items: [
121
+ ...cfg.providers.map((p) => ({ label: p.name, value: p.name, hint: `(${p.baseUrl})` })),
122
+ { label: t('set.back'), value: '__back__' },
123
+ ], onSelect: (v) => {
124
+ if (v === '__back__')
125
+ return setStep({ id: 'root' });
126
+ const p = cfg.providers.find((x) => x.name === v);
127
+ if (!p)
128
+ return;
129
+ if (step.next === 'prices')
130
+ return setStep({ id: 'priceModel', provider: p });
131
+ if (step.next === 'models')
132
+ return setStep({ id: 'modelList', provider: p });
133
+ setStep({ id: 'model', provider: p });
134
+ } })] })), step.id === 'modelList' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.modelsFor', { name: step.provider.name }) }), _jsx(SelectList, { items: [
135
+ ...step.provider.models.map((m) => ({
136
+ label: m,
137
+ value: m,
138
+ hint: m === step.provider.defaultModel ? t('wiz.model.default') : t('set.makeDefault'),
139
+ })),
140
+ { label: t('set.back'), value: '__back__' },
141
+ ], allowInput: true, inputPlaceholder: t('set.addModelName'), onSelect: (v) => {
142
+ if (v === '__back__')
143
+ return setStep({ id: 'root' });
144
+ step.provider.defaultModel = v;
145
+ ctl.saveProvider(step.provider);
146
+ ctl.setDefaultProvider(step.provider.name);
147
+ saved();
148
+ setStep({ id: 'root' });
149
+ }, onInput: (m) => {
150
+ const model = m.trim();
151
+ if (!model)
152
+ return;
153
+ if (!step.provider.models.includes(model))
154
+ step.provider.models.push(model);
155
+ step.provider.defaultModel = model;
156
+ ctl.saveProvider(step.provider);
157
+ ctl.setDefaultProvider(step.provider.name);
158
+ saved();
159
+ setStep({ id: 'root' });
160
+ } })] })), step.id === 'priceModel' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.priceModel', { name: step.provider.name }) }), _jsx(SelectList, { items: [
161
+ ...step.provider.models.map((m) => {
162
+ const pr = priceFor(step.provider, m);
163
+ return {
164
+ label: m,
165
+ value: m,
166
+ hint: pr ? `($${pr.input}/M in · $${pr.output}/M out${step.provider.prices?.[m] ? ' — override' : ''})` : `(${t('set.priceUnknown')})`,
167
+ };
168
+ }),
169
+ { label: t('set.back'), value: '__back__' },
170
+ ], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onSelect: (v) => {
171
+ if (v === '__back__')
172
+ return setStep({ id: 'root' });
173
+ setStep({ id: 'priceValue', provider: step.provider, model: v });
174
+ }, onInput: (m) => setStep({ id: 'priceValue', provider: step.provider, model: m.trim() }) })] })), step.id === 'priceValue' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.priceValue', { model: step.model }) }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: "0.27, 1.10", onInput: (v) => {
175
+ const m = v.match(/^\s*([\d.]+)\s*[,;\s]\s*([\d.]+)\s*$/);
176
+ if (!m)
177
+ return setFlash(t('set.priceBad'));
178
+ step.provider.prices = { ...step.provider.prices, [step.model]: { input: parseFloat(m[1]), output: parseFloat(m[2]) } };
179
+ ctl.saveProvider(step.provider);
180
+ saved();
181
+ setStep({ id: 'root' });
182
+ } })] })), step.id === 'newSkill' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.newSkillName') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: "review, deploy, tests\u2026", onInput: (name) => {
183
+ try {
184
+ const file = createSkillTemplate(name.trim(), '', 'global', ctl.projectRoot);
185
+ setFlash(t('m.skillCreated', { file }));
186
+ }
187
+ catch (e) {
188
+ setFlash(t('m.alreadyExists', { msg: e?.message ?? '' }));
189
+ }
190
+ setStep({ id: 'root' });
191
+ } })] })), step.id === 'newSpecialist' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.newSpecialistName') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: "reviewer, architect, tester\u2026", onInput: (name) => {
192
+ try {
193
+ const file = createSpecialistTemplate(name.trim(), '', 'global', ctl.projectRoot);
194
+ setFlash(t('m.specCreated', { file }));
195
+ }
196
+ catch (e) {
197
+ setFlash(t('m.alreadyExists', { msg: e?.message ?? '' }));
198
+ }
199
+ setStep({ id: 'root' });
200
+ } })] })), step.id === 'model' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('set.chooseModel', { name: step.provider.name }) }), _jsx(SelectList, { items: [
201
+ ...step.provider.models.map((m) => ({
202
+ label: m,
203
+ value: m,
204
+ hint: m === step.provider.defaultModel ? t('wiz.model.default') : undefined,
205
+ })),
206
+ { label: t('set.back'), value: '__back__' },
207
+ ], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onSelect: (v) => {
208
+ if (v === '__back__')
209
+ return setStep({ id: 'root' });
210
+ pickModel(step.provider, v);
211
+ }, onInput: (m) => pickModel(step.provider, m) })] })), step.id === 'key' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.key.title', { name: step.provider.name }) }), _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onInput: (k) => {
212
+ step.provider.apiKey = k.trim();
213
+ ctl.saveProvider(step.provider);
214
+ saved();
215
+ setStep({ id: 'root' });
216
+ } })] })), step.id === 'newName' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.name.title') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.name.ph'), onInput: (name) => setStep({ id: 'newUrl', name }) })] })), step.id === 'newUrl' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.url.title') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.url.ph'), onInput: (url) => setStep({ id: 'newModel', name: step.name, url }) })] })), step.id === 'newModel' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.model.title') }), _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onInput: (model) => setStep({ id: 'newKey', name: step.name, url: step.url, model }) })] })), step.id === 'newKey' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: t('wiz.provider.key.title', { name: step.name }) }), _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onInput: (key) => finishNewProvider(step.name, step.url, step.model, key.trim()) })] }))] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: t('set.esc') }) })] }));
217
+ }
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Text } from 'ink';
4
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
+ export function Spinner({ color }) {
6
+ const [i, setI] = useState(0);
7
+ useEffect(() => {
8
+ const t = setInterval(() => setI((v) => (v + 1) % FRAMES.length), 80);
9
+ return () => clearInterval(t);
10
+ }, []);
11
+ return _jsx(Text, { color: color ?? 'yellow', children: FRAMES[i] });
12
+ }
@@ -0,0 +1,66 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { t } from '../i18n.js';
5
+ /**
6
+ * Simple ↑/↓ + Entrée select list. If `allowInput` is set, the user can also
7
+ * type a free value (e.g. a folder path or a custom model name) — typing
8
+ * switches to input mode, Esc comes back to the list.
9
+ */
10
+ export function SelectList({ items, allowInput, inputPlaceholder, mask, onBack, onSelect, onInput, }) {
11
+ const [idx, setIdx] = useState(0);
12
+ const [typed, setTyped] = useState('');
13
+ const typing = allowInput && typed.length > 0;
14
+ useInput((input, key) => {
15
+ if (key.escape) {
16
+ if (typed)
17
+ setTyped('');
18
+ else
19
+ onBack?.();
20
+ return;
21
+ }
22
+ if (key.return) {
23
+ if (typing) {
24
+ const v = typed.trim();
25
+ setTyped('');
26
+ if (v)
27
+ onInput?.(v);
28
+ }
29
+ else if (items[idx]) {
30
+ onSelect?.(items[idx].value);
31
+ }
32
+ return;
33
+ }
34
+ if (key.backspace || key.delete) {
35
+ setTyped((v) => v.slice(0, -1));
36
+ return;
37
+ }
38
+ if (key.upArrow) {
39
+ if (!typing)
40
+ setIdx((i) => (i - 1 + items.length) % Math.max(1, items.length));
41
+ return;
42
+ }
43
+ if (key.downArrow) {
44
+ if (!typing)
45
+ setIdx((i) => (i + 1) % Math.max(1, items.length));
46
+ return;
47
+ }
48
+ if (key.tab || key.ctrl || key.meta)
49
+ return;
50
+ if (!allowInput || !input)
51
+ return;
52
+ // Pasted / chunked input may contain a newline → treat as validation.
53
+ if (/[\r\n]/.test(input)) {
54
+ const v = (typed + input).split(/\r\n|\r|\n/)[0].trim();
55
+ setTyped('');
56
+ if (v)
57
+ onInput?.(v);
58
+ return;
59
+ }
60
+ setTyped((v) => v + input);
61
+ });
62
+ return (_jsxs(Box, { flexDirection: "column", children: [items.map((it, i) => (_jsxs(Text, { children: [_jsxs(Text, { color: !typing && i === idx ? 'cyanBright' : 'gray', bold: !typing && i === idx, children: [!typing && i === idx ? '❯ ' : ' ', it.label] }), it.hint ? _jsxs(Text, { color: "gray", children: [" ", it.hint] }) : null] }, it.value + i))), allowInput && (_jsx(Box, { marginTop: items.length > 0 ? 1 : 0, children: _jsxs(Text, { color: typing ? 'cyanBright' : 'gray', children: ["\u270E", ' ', typing ? (_jsx(Text, { color: "white", children: mask ? '•'.repeat(typed.length) : typed })) : (_jsx(Text, { color: "gray", children: inputPlaceholder ?? '…' })), typing ? _jsx(Text, { color: "cyanBright", children: "\u2588" }) : null] }) }))] }));
63
+ }
64
+ export function WizardStep({ step, total, title, children, footer, }) {
65
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["[", step, "/", total, "] ", title] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: children }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: footer ?? t('wiz.footer.select') }) })] }));
66
+ }
@@ -0,0 +1,36 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
3
+ function human(bytes) {
4
+ if (bytes < 1024)
5
+ return `${bytes} B`;
6
+ if (bytes < 1024 * 1024)
7
+ return `${Math.round(bytes / 1024)} KB`;
8
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
9
+ }
10
+ /**
11
+ * Read a PNG image from the system clipboard (Wayland: wl-paste, X11: xclip).
12
+ * Returns a data URI usable in multimodal chat messages, or null if the
13
+ * clipboard holds no image / no tool is available.
14
+ */
15
+ export function readClipboardImage() {
16
+ const attempts = [
17
+ ['wl-paste', ['--type', 'image/png']],
18
+ ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']],
19
+ ];
20
+ for (const [cmd, args] of attempts) {
21
+ try {
22
+ const buf = execFileSync(cmd, args, {
23
+ maxBuffer: 32 * 1024 * 1024,
24
+ stdio: ['ignore', 'pipe', 'ignore'],
25
+ timeout: 3000,
26
+ });
27
+ if (buf && buf.length > 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) {
28
+ return { dataUri: `data:image/png;base64,${buf.toString('base64')}`, label: human(buf.length) };
29
+ }
30
+ }
31
+ catch {
32
+ // tool missing or clipboard not an image — try the next one
33
+ }
34
+ }
35
+ return null;
36
+ }
@@ -0,0 +1,27 @@
1
+ import { t } from '../i18n.js';
2
+ /** Strong visual cues: icon + label (i18n key) + color per state. */
3
+ export const STATE_LABEL = {
4
+ idle: { icon: '◇', labelKey: 'st.idle', color: 'gray' },
5
+ thinking: { icon: '🧠', labelKey: 'st.thinking', color: 'yellow' },
6
+ listening: { icon: '👂', labelKey: 'st.listening', color: 'cyanBright' },
7
+ working: { icon: '🔨', labelKey: 'st.working', color: 'green' },
8
+ waiting: { icon: '✋', labelKey: 'st.waiting', color: 'magenta' },
9
+ paused: { icon: '⏸', labelKey: 'st.paused', color: 'blue' },
10
+ done: { icon: '✅', labelKey: 'st.done', color: 'greenBright' },
11
+ error: { icon: '✖', labelKey: 'st.error', color: 'red' },
12
+ stopped: { icon: '⏹', labelKey: 'st.stopped', color: 'redBright' },
13
+ };
14
+ export function stateLabel(state) {
15
+ return t(STATE_LABEL[state].labelKey);
16
+ }
17
+ export function elapsed(since) {
18
+ const s = Math.floor((Date.now() - since) / 1000);
19
+ if (s < 60)
20
+ return `${s}s`;
21
+ const m = Math.floor(s / 60);
22
+ return `${m}m${String(s % 60).padStart(2, '0')}s`;
23
+ }
24
+ export function truncate(text, max) {
25
+ const oneLine = text.replace(/\s+/g, ' ').trim();
26
+ return oneLine.length > max ? oneLine.slice(0, max - 1) + '…' : oneLine;
27
+ }