@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.
- package/LICENSE +21 -0
- package/README.md +316 -0
- package/dist/agents/agent.js +518 -0
- package/dist/agents/tools.js +570 -0
- package/dist/commands.js +480 -0
- package/dist/config.js +163 -0
- package/dist/controller.js +703 -0
- package/dist/coordination/blackboard.js +225 -0
- package/dist/i18n.js +1087 -0
- package/dist/index.js +196 -0
- package/dist/llm/client.js +46 -0
- package/dist/pricing.js +76 -0
- package/dist/server.js +149 -0
- package/dist/skills.js +132 -0
- package/dist/types.js +1 -0
- package/dist/ui/AgentPanel.js +25 -0
- package/dist/ui/App.js +400 -0
- package/dist/ui/ApprovalPrompt.js +18 -0
- package/dist/ui/AttachApp.js +126 -0
- package/dist/ui/CommandInput.js +154 -0
- package/dist/ui/Md.js +40 -0
- package/dist/ui/QuestionPrompt.js +58 -0
- package/dist/ui/SettingsPanel.js +217 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/Wizard.js +66 -0
- package/dist/ui/clipboard.js +36 -0
- package/dist/ui/theme.js +27 -0
- package/dist/ui/views.js +94 -0
- package/package.json +59 -0
|
@@ -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
|
+
}
|
package/dist/ui/theme.js
ADDED
|
@@ -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
|
+
}
|