blyuecode 0.1.8 → 0.1.10
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/core/agent.js +39 -12
- package/dist/core/config.js +33 -3
- package/dist/core/diff.js +63 -0
- package/dist/core/permission.js +44 -0
- package/dist/core/session.js +57 -0
- package/dist/core/tools.js +85 -94
- package/dist/index.js +5 -3
- package/dist/ui/Chat.js +63 -18
- package/dist/ui/PermissionPrompt.js +34 -0
- package/dist/ui/Settings.js +143 -0
- package/dist/ui/theme.js +2 -1
- package/package.json +1 -1
package/dist/core/agent.js
CHANGED
|
@@ -1,28 +1,54 @@
|
|
|
1
1
|
import { Provider } from './provider.js';
|
|
2
|
-
import {
|
|
2
|
+
import { getToolDefs, executeTool } from './tools.js';
|
|
3
|
+
import { loadSession, saveSession, clearSession } from './session.js';
|
|
4
|
+
const SYSTEM_PROMPT = 'You are BLYUE, a coding assistant running in a terminal. ' +
|
|
5
|
+
'Use tools to read, write, and run commands. ' +
|
|
6
|
+
'If n8n tools are available, you can also manage n8n workflows. ' +
|
|
7
|
+
'Always explain what you are doing. Be concise.';
|
|
3
8
|
export class Agent {
|
|
4
9
|
provider = null;
|
|
10
|
+
messages;
|
|
11
|
+
constructor(resume = true) {
|
|
12
|
+
const sys = { role: 'system', content: SYSTEM_PROMPT };
|
|
13
|
+
const history = resume ? loadSession() : null;
|
|
14
|
+
if (history && history.length > 0) {
|
|
15
|
+
this.messages = [sys, ...history];
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
this.messages = [sys];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
5
21
|
getProvider() {
|
|
6
22
|
if (!this.provider)
|
|
7
23
|
this.provider = new Provider();
|
|
8
24
|
return this.provider;
|
|
9
25
|
}
|
|
10
|
-
|
|
11
|
-
{
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
persist() {
|
|
27
|
+
try {
|
|
28
|
+
saveSession(this.messages.slice(1)); // skip system prompt
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
}
|
|
32
|
+
historySize() {
|
|
33
|
+
return this.messages.length - 1;
|
|
34
|
+
}
|
|
35
|
+
clear() {
|
|
36
|
+
this.messages = [{ role: 'system', content: SYSTEM_PROMPT }];
|
|
37
|
+
clearSession();
|
|
38
|
+
}
|
|
18
39
|
async *run(userInput) {
|
|
19
40
|
this.messages.push({ role: 'user', content: userInput });
|
|
41
|
+
this.persist();
|
|
20
42
|
while (true) {
|
|
21
|
-
const
|
|
22
|
-
|
|
43
|
+
const tools = getToolDefs();
|
|
44
|
+
const res = await this.getProvider().chat(this.messages, tools);
|
|
45
|
+
if (res.content) {
|
|
23
46
|
yield res.content;
|
|
24
|
-
|
|
47
|
+
}
|
|
48
|
+
if (res.toolCalls.length === 0) {
|
|
49
|
+
this.persist();
|
|
25
50
|
break;
|
|
51
|
+
}
|
|
26
52
|
this.messages.push({
|
|
27
53
|
role: 'assistant',
|
|
28
54
|
content: res.content,
|
|
@@ -48,6 +74,7 @@ export class Agent {
|
|
|
48
74
|
const preview = result.length > 500 ? result.slice(0, 500) + '...' : result;
|
|
49
75
|
yield '[Result]: ' + preview + '\n';
|
|
50
76
|
}
|
|
77
|
+
this.persist();
|
|
51
78
|
}
|
|
52
79
|
}
|
|
53
80
|
}
|
package/dist/core/config.js
CHANGED
|
@@ -3,9 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
const GLOBAL_DIR = path.join(os.homedir(), '.blyue');
|
|
5
5
|
const GLOBAL_ENV = path.join(GLOBAL_DIR, '.env');
|
|
6
|
-
// Anything matching this = not a real key
|
|
7
6
|
const PLACEHOLDER = /your[-_]?key|placeholder|^sk-your|^xxx|^sk-xxx|here$/i;
|
|
8
|
-
/** Only look at ~/.blyue/.env (global, works from any cwd) */
|
|
9
7
|
export function loadConfig() {
|
|
10
8
|
if (!fs.existsSync(GLOBAL_ENV))
|
|
11
9
|
return null;
|
|
@@ -20,7 +18,7 @@ function parseEnv(file) {
|
|
|
20
18
|
const text = fs.readFileSync(file, 'utf8');
|
|
21
19
|
const map = {};
|
|
22
20
|
for (const line of text.split('\n')) {
|
|
23
|
-
const m = line.match(/^\s*([A-
|
|
21
|
+
const m = line.match(/^\s*([A-Z_0-9]+)\s*=\s*(.*)\s*$/);
|
|
24
22
|
if (m)
|
|
25
23
|
map[m[1]] = m[2].trim();
|
|
26
24
|
}
|
|
@@ -28,6 +26,8 @@ function parseEnv(file) {
|
|
|
28
26
|
apiKey: map.OPENAI_API_KEY || '',
|
|
29
27
|
baseUrl: map.OPENAI_BASE_URL || undefined,
|
|
30
28
|
model: map.MODEL || 'gpt-4o-mini',
|
|
29
|
+
n8nUrl: map.N8N_URL || undefined,
|
|
30
|
+
n8nApiKey: map.N8N_API_KEY || undefined,
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
export function saveConfig(cfg) {
|
|
@@ -40,6 +40,10 @@ export function saveConfig(cfg) {
|
|
|
40
40
|
if (cfg.baseUrl)
|
|
41
41
|
lines.push(`OPENAI_BASE_URL=${cfg.baseUrl}`);
|
|
42
42
|
lines.push(`MODEL=${cfg.model}`);
|
|
43
|
+
if (cfg.n8nUrl)
|
|
44
|
+
lines.push(`N8N_URL=${cfg.n8nUrl}`);
|
|
45
|
+
if (cfg.n8nApiKey)
|
|
46
|
+
lines.push(`N8N_API_KEY=${cfg.n8nApiKey}`);
|
|
43
47
|
lines.push('');
|
|
44
48
|
fs.writeFileSync(GLOBAL_ENV, lines.join('\n'), { mode: 0o600 });
|
|
45
49
|
return GLOBAL_ENV;
|
|
@@ -48,5 +52,31 @@ export function applyConfig(cfg) {
|
|
|
48
52
|
process.env.OPENAI_API_KEY = cfg.apiKey;
|
|
49
53
|
if (cfg.baseUrl)
|
|
50
54
|
process.env.OPENAI_BASE_URL = cfg.baseUrl;
|
|
55
|
+
else
|
|
56
|
+
delete process.env.OPENAI_BASE_URL;
|
|
51
57
|
process.env.MODEL = cfg.model;
|
|
58
|
+
if (cfg.n8nUrl)
|
|
59
|
+
process.env.N8N_URL = cfg.n8nUrl;
|
|
60
|
+
else
|
|
61
|
+
delete process.env.N8N_URL;
|
|
62
|
+
if (cfg.n8nApiKey)
|
|
63
|
+
process.env.N8N_API_KEY = cfg.n8nApiKey;
|
|
64
|
+
else
|
|
65
|
+
delete process.env.N8N_API_KEY;
|
|
66
|
+
}
|
|
67
|
+
export function getConfig() {
|
|
68
|
+
const cfg = loadConfig();
|
|
69
|
+
if (cfg)
|
|
70
|
+
return cfg;
|
|
71
|
+
return {
|
|
72
|
+
apiKey: process.env.OPENAI_API_KEY || '',
|
|
73
|
+
baseUrl: process.env.OPENAI_BASE_URL,
|
|
74
|
+
model: process.env.MODEL || 'gpt-4o-mini',
|
|
75
|
+
n8nUrl: process.env.N8N_URL,
|
|
76
|
+
n8nApiKey: process.env.N8N_API_KEY,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function hasN8n() {
|
|
80
|
+
const cfg = getConfig();
|
|
81
|
+
return !!(cfg.n8nUrl && cfg.n8nApiKey);
|
|
52
82
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Simple LCS-based line diff. Good enough for previews.
|
|
2
|
+
export function lineDiff(oldText, newText) {
|
|
3
|
+
const a = oldText.length ? oldText.split('\n') : [];
|
|
4
|
+
const b = newText.length ? newText.split('\n') : [];
|
|
5
|
+
const m = a.length;
|
|
6
|
+
const n = b.length;
|
|
7
|
+
// LCS table
|
|
8
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
9
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
10
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
11
|
+
if (a[i] === b[j])
|
|
12
|
+
dp[i][j] = dp[i + 1][j + 1] + 1;
|
|
13
|
+
else
|
|
14
|
+
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const out = [];
|
|
18
|
+
let i = 0;
|
|
19
|
+
let j = 0;
|
|
20
|
+
while (i < m && j < n) {
|
|
21
|
+
if (a[i] === b[j]) {
|
|
22
|
+
out.push({ type: 'same', text: a[i] });
|
|
23
|
+
i++;
|
|
24
|
+
j++;
|
|
25
|
+
}
|
|
26
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
27
|
+
out.push({ type: 'remove', text: a[i] });
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
out.push({ type: 'add', text: b[j] });
|
|
32
|
+
j++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
while (i < m)
|
|
36
|
+
out.push({ type: 'remove', text: a[i++] });
|
|
37
|
+
while (j < n)
|
|
38
|
+
out.push({ type: 'add', text: b[j++] });
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
// Compact display: only show changed regions with context
|
|
42
|
+
export function compactDiff(diff, context = 2) {
|
|
43
|
+
const keep = new Set();
|
|
44
|
+
diff.forEach((d, idx) => {
|
|
45
|
+
if (d.type !== 'same') {
|
|
46
|
+
for (let k = Math.max(0, idx - context); k <= Math.min(diff.length - 1, idx + context); k++) {
|
|
47
|
+
keep.add(k);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
const out = [];
|
|
52
|
+
let lastKept = -1;
|
|
53
|
+
for (let i = 0; i < diff.length; i++) {
|
|
54
|
+
if (keep.has(i)) {
|
|
55
|
+
if (lastKept !== -1 && i > lastKept + 1) {
|
|
56
|
+
out.push({ type: 'same', text: '…' });
|
|
57
|
+
}
|
|
58
|
+
out.push(diff[i]);
|
|
59
|
+
lastKept = i;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
class PermissionBroker {
|
|
2
|
+
pending = null;
|
|
3
|
+
// Session-scoped "always allow" set (cleared on restart)
|
|
4
|
+
alwaysAllow = new Set();
|
|
5
|
+
listeners = new Set();
|
|
6
|
+
nextId = 0;
|
|
7
|
+
subscribe(fn) {
|
|
8
|
+
this.listeners.add(fn);
|
|
9
|
+
// Immediately notify with current state
|
|
10
|
+
fn(this.pending?.req ?? null);
|
|
11
|
+
return () => this.listeners.delete(fn);
|
|
12
|
+
}
|
|
13
|
+
notify(req) {
|
|
14
|
+
for (const fn of this.listeners)
|
|
15
|
+
fn(req);
|
|
16
|
+
}
|
|
17
|
+
isAlwaysAllowed(tool) {
|
|
18
|
+
return this.alwaysAllow.has(tool);
|
|
19
|
+
}
|
|
20
|
+
async ask(req) {
|
|
21
|
+
if (this.alwaysAllow.has(req.tool))
|
|
22
|
+
return 'yes';
|
|
23
|
+
const id = `p${++this.nextId}`;
|
|
24
|
+
const full = { ...req, id };
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
this.pending = { req: full, resolve };
|
|
27
|
+
this.notify(full);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
answer(a) {
|
|
31
|
+
if (!this.pending)
|
|
32
|
+
return;
|
|
33
|
+
const { req, resolve } = this.pending;
|
|
34
|
+
if (a === 'always')
|
|
35
|
+
this.alwaysAllow.add(req.tool);
|
|
36
|
+
this.pending = null;
|
|
37
|
+
this.notify(null);
|
|
38
|
+
resolve(a);
|
|
39
|
+
}
|
|
40
|
+
resetSession() {
|
|
41
|
+
this.alwaysAllow.clear();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export const permissions = new PermissionBroker();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
const SESSIONS_DIR = path.join(os.homedir(), '.blyue', 'sessions');
|
|
6
|
+
export function sessionFile(cwd = process.cwd()) {
|
|
7
|
+
if (!fs.existsSync(SESSIONS_DIR))
|
|
8
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
9
|
+
const hash = crypto.createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
|
10
|
+
return path.join(SESSIONS_DIR, `${hash}.json`);
|
|
11
|
+
}
|
|
12
|
+
export function loadSession(cwd = process.cwd()) {
|
|
13
|
+
const file = sessionFile(cwd);
|
|
14
|
+
if (!fs.existsSync(file))
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
18
|
+
return data.messages || null;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function saveSession(messages, cwd = process.cwd()) {
|
|
25
|
+
const file = sessionFile(cwd);
|
|
26
|
+
const data = {
|
|
27
|
+
project: cwd,
|
|
28
|
+
updatedAt: Date.now(),
|
|
29
|
+
messages,
|
|
30
|
+
};
|
|
31
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
export function clearSession(cwd = process.cwd()) {
|
|
34
|
+
const file = sessionFile(cwd);
|
|
35
|
+
if (fs.existsSync(file))
|
|
36
|
+
fs.unlinkSync(file);
|
|
37
|
+
}
|
|
38
|
+
export function listSessions() {
|
|
39
|
+
if (!fs.existsSync(SESSIONS_DIR))
|
|
40
|
+
return [];
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const f of fs.readdirSync(SESSIONS_DIR)) {
|
|
43
|
+
if (!f.endsWith('.json'))
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
|
|
47
|
+
out.push({
|
|
48
|
+
file: f,
|
|
49
|
+
project: data.project,
|
|
50
|
+
updatedAt: data.updatedAt,
|
|
51
|
+
count: data.messages.length,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
}
|
|
56
|
+
return out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
57
|
+
}
|
package/dist/core/tools.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
3
|
import { exec } from 'child_process';
|
|
3
4
|
import { promisify } from 'util';
|
|
5
|
+
import { hasN8n } from './config.js';
|
|
6
|
+
import { permissions } from './permission.js';
|
|
7
|
+
import { lineDiff, compactDiff } from './diff.js';
|
|
4
8
|
import { n8nListWorkflows, n8nTriggerWorkflow, n8nListExecutions, n8nGetWorkflow, n8nActivateWorkflow, n8nDeactivateWorkflow, } from './n8n.js';
|
|
5
9
|
const execAsync = promisify(exec);
|
|
6
|
-
|
|
7
|
-
// Tool definitions
|
|
8
|
-
// ─────────────────────────────────────────────
|
|
9
|
-
export const toolDefs = [
|
|
10
|
-
// ───── File ops ─────
|
|
10
|
+
const BASE_TOOLS = [
|
|
11
11
|
{
|
|
12
12
|
type: 'function',
|
|
13
13
|
function: {
|
|
@@ -58,111 +58,102 @@ export const toolDefs = [
|
|
|
58
58
|
},
|
|
59
59
|
},
|
|
60
60
|
},
|
|
61
|
-
// ───── n8n ─────
|
|
62
|
-
{
|
|
63
|
-
type: 'function',
|
|
64
|
-
function: {
|
|
65
|
-
name: 'n8n_list_workflows',
|
|
66
|
-
description: 'List all n8n workflows. Returns id, name, active status, updatedAt.',
|
|
67
|
-
parameters: { type: 'object', properties: {} },
|
|
68
|
-
},
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
type: 'function',
|
|
72
|
-
function: {
|
|
73
|
-
name: 'n8n_get_workflow',
|
|
74
|
-
description: 'Get details of a specific n8n workflow by ID',
|
|
75
|
-
parameters: {
|
|
76
|
-
type: 'object',
|
|
77
|
-
properties: { id: { type: 'string' } },
|
|
78
|
-
required: ['id'],
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
type: 'function',
|
|
84
|
-
function: {
|
|
85
|
-
name: 'n8n_trigger_workflow',
|
|
86
|
-
description: 'Trigger an n8n workflow via its webhook URL',
|
|
87
|
-
parameters: {
|
|
88
|
-
type: 'object',
|
|
89
|
-
properties: {
|
|
90
|
-
workflowId: { type: 'string' },
|
|
91
|
-
data: {
|
|
92
|
-
type: 'object',
|
|
93
|
-
description: 'JSON payload to send to the webhook',
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
required: ['workflowId'],
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
type: 'function',
|
|
102
|
-
function: {
|
|
103
|
-
name: 'n8n_list_executions',
|
|
104
|
-
description: 'List recent n8n executions (runs)',
|
|
105
|
-
parameters: {
|
|
106
|
-
type: 'object',
|
|
107
|
-
properties: {
|
|
108
|
-
workflowId: { type: 'string', description: 'Filter by workflow ID' },
|
|
109
|
-
limit: { type: 'number', description: 'Max results (default 10)' },
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
type: 'function',
|
|
116
|
-
function: {
|
|
117
|
-
name: 'n8n_activate_workflow',
|
|
118
|
-
description: 'Activate an n8n workflow so it runs on its trigger',
|
|
119
|
-
parameters: {
|
|
120
|
-
type: 'object',
|
|
121
|
-
properties: { id: { type: 'string' } },
|
|
122
|
-
required: ['id'],
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
type: 'function',
|
|
128
|
-
function: {
|
|
129
|
-
name: 'n8n_deactivate_workflow',
|
|
130
|
-
description: 'Deactivate an n8n workflow',
|
|
131
|
-
parameters: {
|
|
132
|
-
type: 'object',
|
|
133
|
-
properties: { id: { type: 'string' } },
|
|
134
|
-
required: ['id'],
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
},
|
|
138
61
|
];
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
62
|
+
const N8N_TOOLS = [
|
|
63
|
+
{ type: 'function', function: { name: 'n8n_list_workflows', description: 'List n8n workflows', parameters: { type: 'object', properties: {} } } },
|
|
64
|
+
{ type: 'function', function: { name: 'n8n_get_workflow', description: 'Get n8n workflow by ID', parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } },
|
|
65
|
+
{ type: 'function', function: { name: 'n8n_trigger_workflow', description: 'Trigger n8n workflow', parameters: { type: 'object', properties: { workflowId: { type: 'string' }, data: { type: 'object' } }, required: ['workflowId'] } } },
|
|
66
|
+
{ type: 'function', function: { name: 'n8n_list_executions', description: 'List n8n executions', parameters: { type: 'object', properties: { workflowId: { type: 'string' }, limit: { type: 'number' } } } } },
|
|
67
|
+
{ type: 'function', function: { name: 'n8n_activate_workflow', description: 'Activate n8n workflow', parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } },
|
|
68
|
+
{ type: 'function', function: { name: 'n8n_deactivate_workflow', description: 'Deactivate n8n workflow', parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } },
|
|
69
|
+
];
|
|
70
|
+
export function getToolDefs() {
|
|
71
|
+
return hasN8n() ? [...BASE_TOOLS, ...N8N_TOOLS] : [...BASE_TOOLS];
|
|
72
|
+
}
|
|
73
|
+
export const toolDefs = BASE_TOOLS;
|
|
74
|
+
// Dangerous command patterns
|
|
75
|
+
const DANGER_PATTERNS = [
|
|
76
|
+
{ re: /\brm\s+(-[a-z]*[rf][a-z]*\s+)?/, level: 'high' },
|
|
77
|
+
{ re: /\bsudo\b/, level: 'high' },
|
|
78
|
+
{ re: /\bmkfs\b|\bdd\s+if=/, level: 'high' },
|
|
79
|
+
{ re: />\s*\/dev\//, level: 'high' },
|
|
80
|
+
{ re: /:\(\)\s*\{/, level: 'high' }, // fork bomb
|
|
81
|
+
{ re: /\bchmod\s+(-R\s+)?777\b/, level: 'medium' },
|
|
82
|
+
{ re: /\bgit\s+push\b/, level: 'medium' },
|
|
83
|
+
{ re: /\bnpm\s+(publish|unpublish)\b/, level: 'medium' },
|
|
84
|
+
{ re: /\bcurl\b.*\|\s*(sh|bash)\b/, level: 'high' },
|
|
85
|
+
];
|
|
86
|
+
function classify(cmd) {
|
|
87
|
+
for (const { re, level } of DANGER_PATTERNS) {
|
|
88
|
+
if (re.test(cmd))
|
|
89
|
+
return level;
|
|
90
|
+
}
|
|
91
|
+
return 'low';
|
|
92
|
+
}
|
|
142
93
|
export async function executeTool(name, args) {
|
|
143
94
|
try {
|
|
144
95
|
switch (name) {
|
|
145
|
-
// ───── File ops ─────
|
|
146
96
|
case 'read_file':
|
|
147
97
|
return await fs.readFile(args.path, 'utf8');
|
|
148
|
-
case 'write_file':
|
|
149
|
-
|
|
150
|
-
|
|
98
|
+
case 'write_file': {
|
|
99
|
+
const path = args.path;
|
|
100
|
+
const content = args.content;
|
|
101
|
+
const existed = existsSync(path);
|
|
102
|
+
let oldText = '';
|
|
103
|
+
if (existed) {
|
|
104
|
+
try {
|
|
105
|
+
oldText = readFileSync(path, 'utf8');
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
108
|
+
}
|
|
109
|
+
if (!permissions.isAlwaysAllowed('write_file')) {
|
|
110
|
+
const lines = content.split('\n');
|
|
111
|
+
const body = [
|
|
112
|
+
existed ? `Overwrite ${path}` : `Create ${path}`,
|
|
113
|
+
`${lines.length} lines · ${content.length} bytes`,
|
|
114
|
+
];
|
|
115
|
+
let diffPreview;
|
|
116
|
+
if (existed && oldText !== content) {
|
|
117
|
+
const d = compactDiff(lineDiff(oldText, content), 2);
|
|
118
|
+
diffPreview = d.slice(0, 60);
|
|
119
|
+
}
|
|
120
|
+
const answer = await permissions.ask({
|
|
121
|
+
tool: 'write_file',
|
|
122
|
+
title: existed ? 'Write file' : 'Create file',
|
|
123
|
+
body,
|
|
124
|
+
diff: diffPreview,
|
|
125
|
+
danger: existed ? 'medium' : 'low',
|
|
126
|
+
});
|
|
127
|
+
if (answer === 'no')
|
|
128
|
+
return `Denied by user: write_file ${path}`;
|
|
129
|
+
}
|
|
130
|
+
await fs.writeFile(path, content, 'utf8');
|
|
131
|
+
return 'Wrote ' + path;
|
|
132
|
+
}
|
|
151
133
|
case 'list_files': {
|
|
152
134
|
const dir = args.path || '.';
|
|
153
135
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
154
|
-
return entries
|
|
155
|
-
.map((e) => (e.isDirectory() ? e.name + '/' : e.name))
|
|
156
|
-
.join('\n');
|
|
136
|
+
return entries.map((e) => (e.isDirectory() ? e.name + '/' : e.name)).join('\n');
|
|
157
137
|
}
|
|
158
138
|
case 'run_command': {
|
|
159
|
-
const
|
|
139
|
+
const cmd = args.command;
|
|
140
|
+
if (!permissions.isAlwaysAllowed('run_command')) {
|
|
141
|
+
const danger = classify(cmd);
|
|
142
|
+
const answer = await permissions.ask({
|
|
143
|
+
tool: 'run_command',
|
|
144
|
+
title: 'Run command',
|
|
145
|
+
body: ['$ ' + cmd, `cwd: ${process.cwd()}`, `risk: ${danger}`],
|
|
146
|
+
danger,
|
|
147
|
+
});
|
|
148
|
+
if (answer === 'no')
|
|
149
|
+
return `Denied by user: run_command`;
|
|
150
|
+
}
|
|
151
|
+
const { stdout, stderr } = await execAsync(cmd, {
|
|
160
152
|
cwd: process.cwd(),
|
|
161
153
|
maxBuffer: 10 * 1024 * 1024,
|
|
162
154
|
});
|
|
163
155
|
return 'stdout:\n' + stdout + '\nstderr:\n' + stderr;
|
|
164
156
|
}
|
|
165
|
-
// ───── n8n ─────
|
|
166
157
|
case 'n8n_list_workflows':
|
|
167
158
|
return JSON.stringify(await n8nListWorkflows(), null, 2);
|
|
168
159
|
case 'n8n_get_workflow':
|
package/dist/index.js
CHANGED
|
@@ -6,20 +6,22 @@ import { Setup } from './ui/Setup.js';
|
|
|
6
6
|
import { Chat } from './ui/Chat.js';
|
|
7
7
|
import { Banner } from './ui/Banner.js';
|
|
8
8
|
import { loadConfig, applyConfig } from './core/config.js';
|
|
9
|
+
import { loadSession } from './core/session.js';
|
|
9
10
|
const App = () => {
|
|
10
11
|
const [ready, setReady] = useState(false);
|
|
12
|
+
const [resumedCount, setResumedCount] = useState(0);
|
|
11
13
|
useEffect(() => {
|
|
12
14
|
const existing = loadConfig();
|
|
13
15
|
if (existing) {
|
|
14
16
|
applyConfig(existing);
|
|
17
|
+
const hist = loadSession();
|
|
18
|
+
setResumedCount(hist ? hist.length : 0);
|
|
15
19
|
setReady(true);
|
|
16
20
|
}
|
|
17
21
|
}, []);
|
|
18
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Static, { items: ['banner'], children: () => _jsx(Banner, {}) }), ready ? _jsx(Chat, {}) : _jsx(Setup, { onDone: () => setReady(true) })] }));
|
|
22
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Static, { items: ['banner'], children: () => _jsx(Banner, {}) }), ready ? (_jsx(Chat, { resumedCount: resumedCount })) : (_jsx(Setup, { onDone: () => setReady(true) }))] }));
|
|
19
23
|
};
|
|
20
24
|
render(_jsx(App, {}), {
|
|
21
|
-
// Prevent console.log interference from npm/openai warnings
|
|
22
25
|
patchConsole: false,
|
|
23
|
-
// Only redraw when Ink state actually changes
|
|
24
26
|
exitOnCtrlC: true,
|
|
25
27
|
});
|
package/dist/ui/Chat.js
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Box, Text, useInput, useStdout, Static } from 'ink';
|
|
4
4
|
import { Agent } from '../core/agent.js';
|
|
5
|
+
import { permissions } from '../core/permission.js';
|
|
5
6
|
import { InputBar } from './InputBar.js';
|
|
6
7
|
import { ModelPicker } from './ModelPicker.js';
|
|
8
|
+
import { Settings } from './Settings.js';
|
|
9
|
+
import { PermissionPrompt } from './PermissionPrompt.js';
|
|
7
10
|
import { theme } from './theme.js';
|
|
8
11
|
let nextId = 0;
|
|
9
|
-
const mkItem = (kind, text) => ({
|
|
10
|
-
kind,
|
|
11
|
-
text,
|
|
12
|
-
id: nextId++,
|
|
13
|
-
});
|
|
14
|
-
// ─────────────────────────────────────────────
|
|
15
|
-
// One history line — rendered ONCE via <Static>
|
|
16
|
-
// ─────────────────────────────────────────────
|
|
12
|
+
const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
|
|
17
13
|
const HistoryLine = ({ item }) => {
|
|
18
14
|
if (item.kind === 'user') {
|
|
19
15
|
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(Text, { color: theme.text, bold: true, children: item.text })] }));
|
|
@@ -26,18 +22,33 @@ const HistoryLine = ({ item }) => {
|
|
|
26
22
|
}
|
|
27
23
|
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.text, children: item.text }) }));
|
|
28
24
|
};
|
|
29
|
-
export const Chat = () => {
|
|
25
|
+
export const Chat = ({ resumedCount = 0 }) => {
|
|
30
26
|
const { stdout } = useStdout();
|
|
31
27
|
const width = stdout?.columns ?? 80;
|
|
32
|
-
const [history, setHistory] = useState(
|
|
28
|
+
const [history, setHistory] = useState(() => {
|
|
29
|
+
if (resumedCount > 0) {
|
|
30
|
+
return [mkItem('tool', `(resumed session · ${resumedCount} prior messages)`)];
|
|
31
|
+
}
|
|
32
|
+
return [];
|
|
33
|
+
});
|
|
33
34
|
const [input, setInput] = useState('');
|
|
34
35
|
const [loading, setLoading] = useState(false);
|
|
35
|
-
const [
|
|
36
|
+
const [overlay, setOverlay] = useState('none');
|
|
37
|
+
const [permReq, setPermReq] = useState(null);
|
|
36
38
|
const [currentModel, setCurrentModel] = useState(process.env.MODEL || '');
|
|
37
39
|
const [agent, setAgent] = useState(() => new Agent());
|
|
40
|
+
// Subscribe to permission requests
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const unsub = permissions.subscribe((req) => setPermReq(req));
|
|
43
|
+
return unsub;
|
|
44
|
+
}, []);
|
|
38
45
|
useInput((inputChar, key) => {
|
|
46
|
+
if (permReq)
|
|
47
|
+
return; // permission prompt handles its own input
|
|
39
48
|
if (key.ctrl && inputChar === 'p')
|
|
40
|
-
|
|
49
|
+
setOverlay('model');
|
|
50
|
+
if (key.ctrl && inputChar === 's')
|
|
51
|
+
setOverlay('settings');
|
|
41
52
|
});
|
|
42
53
|
const handleSubmit = async (text) => {
|
|
43
54
|
if (!text.trim() || loading)
|
|
@@ -45,7 +56,31 @@ export const Chat = () => {
|
|
|
45
56
|
const trimmed = text.trim();
|
|
46
57
|
if (trimmed === '/model' || trimmed === '/models') {
|
|
47
58
|
setInput('');
|
|
48
|
-
|
|
59
|
+
setOverlay('model');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (trimmed === '/settings' || trimmed === '/config') {
|
|
63
|
+
setInput('');
|
|
64
|
+
setOverlay('settings');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (trimmed === '/clear') {
|
|
68
|
+
setInput('');
|
|
69
|
+
agent.clear();
|
|
70
|
+
permissions.resetSession();
|
|
71
|
+
setHistory([mkItem('tool', '(conversation cleared)')]);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (trimmed === '/help') {
|
|
75
|
+
setInput('');
|
|
76
|
+
setHistory((h) => [
|
|
77
|
+
...h,
|
|
78
|
+
mkItem('tool', 'Commands:'),
|
|
79
|
+
mkItem('tool', ' /model switch model (Ctrl+P)'),
|
|
80
|
+
mkItem('tool', ' /settings open settings (Ctrl+S)'),
|
|
81
|
+
mkItem('tool', ' /clear clear session and permissions'),
|
|
82
|
+
mkItem('tool', ' /help show this list'),
|
|
83
|
+
]);
|
|
49
84
|
return;
|
|
50
85
|
}
|
|
51
86
|
setInput('');
|
|
@@ -65,16 +100,26 @@ export const Chat = () => {
|
|
|
65
100
|
const handleModelSelect = (id) => {
|
|
66
101
|
process.env.MODEL = id;
|
|
67
102
|
setCurrentModel(id);
|
|
68
|
-
|
|
103
|
+
setOverlay('none');
|
|
69
104
|
setAgent(new Agent());
|
|
70
105
|
setHistory((h) => [...h, mkItem('tool', `model → ${id}`)]);
|
|
71
106
|
};
|
|
72
|
-
|
|
73
|
-
|
|
107
|
+
// Permission prompt takes priority over everything
|
|
108
|
+
if (permReq) {
|
|
109
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsx(Box, { marginTop: 1, children: _jsx(PermissionPrompt, { req: permReq }) })] }));
|
|
110
|
+
}
|
|
111
|
+
if (overlay === 'model') {
|
|
112
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(ModelPicker, { current: currentModel, onSelect: handleModelSelect, onCancel: () => setOverlay('none') }) }));
|
|
113
|
+
}
|
|
114
|
+
if (overlay === 'settings') {
|
|
115
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(Settings, { onClose: () => setOverlay('none'), onModelChange: (m) => {
|
|
116
|
+
setCurrentModel(m);
|
|
117
|
+
setAgent(new Agent());
|
|
118
|
+
} }) }));
|
|
74
119
|
}
|
|
75
120
|
const maxModelLen = Math.max(10, width - 14);
|
|
76
121
|
const shortModel = currentModel.length > maxModelLen
|
|
77
122
|
? '…' + currentModel.slice(-(maxModelLen - 1))
|
|
78
123
|
: currentModel || 'unset';
|
|
79
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(InputBar, { value: input, onChange: setInput, onSubmit: handleSubmit, loading: loading }), _jsxs(Text, { children: [_jsx(Text, { color: theme.accent, children: " /model" }),
|
|
124
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(InputBar, { value: input, onChange: setInput, onSubmit: handleSubmit, loading: loading }), _jsxs(Text, { children: [_jsx(Text, { color: theme.accent, children: " /model" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.accent, children: "/settings" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.text, children: shortModel })] })] })] }));
|
|
80
125
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
3
|
+
import { permissions } from '../core/permission.js';
|
|
4
|
+
import { theme } from './theme.js';
|
|
5
|
+
export const PermissionPrompt = ({ req }) => {
|
|
6
|
+
const { stdout } = useStdout();
|
|
7
|
+
const width = Math.max(40, stdout?.columns ?? 60);
|
|
8
|
+
const inner = width - 4;
|
|
9
|
+
useInput((input, key) => {
|
|
10
|
+
if (input === 'y' || input === 'Y')
|
|
11
|
+
permissions.answer('yes');
|
|
12
|
+
else if (input === 'n' || input === 'N' || key.escape)
|
|
13
|
+
permissions.answer('no');
|
|
14
|
+
else if (input === 'a' || input === 'A')
|
|
15
|
+
permissions.answer('always');
|
|
16
|
+
});
|
|
17
|
+
const dangerColor = req.danger === 'high' ? theme.error :
|
|
18
|
+
req.danger === 'medium' ? theme.warning :
|
|
19
|
+
theme.accent;
|
|
20
|
+
const title = `${req.danger === 'high' ? '⚠ ' : ''}${req.title}`;
|
|
21
|
+
const topPad = Math.max(1, inner - title.length - 3);
|
|
22
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: dangerColor, bold: true, children: title }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(topPad) + '╮' })] }), req.body.map((line, i) => {
|
|
23
|
+
const pad = Math.max(0, inner - line.length - 2);
|
|
24
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.text, children: line }), _jsx(Text, { children: ' '.repeat(pad) })] }, i));
|
|
25
|
+
}), req.diff && req.diff.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: '│' }), req.diff.map((d, i) => {
|
|
26
|
+
const sign = d.type === 'add' ? '+' : d.type === 'remove' ? '-' : ' ';
|
|
27
|
+
const color = d.type === 'add' ? theme.success :
|
|
28
|
+
d.type === 'remove' ? theme.error :
|
|
29
|
+
theme.muted;
|
|
30
|
+
const text = `${sign} ${d.text}`.slice(0, inner - 2);
|
|
31
|
+
const pad = Math.max(0, inner - text.length - 2);
|
|
32
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: color, children: text }), _jsx(Text, { children: ' '.repeat(pad) })] }, i));
|
|
33
|
+
})] })), _jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }), _jsxs(Text, { children: [_jsx(Text, { color: theme.success, bold: true, children: " [y]" }), _jsx(Text, { color: theme.muted, children: " allow once " }), _jsx(Text, { color: theme.accent, bold: true, children: "[a]" }), _jsx(Text, { color: theme.muted, children: " always (this session) " }), _jsx(Text, { color: theme.error, bold: true, children: "[n]" }), _jsx(Text, { color: theme.muted, children: " deny" })] })] }));
|
|
34
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
|
+
import { KeyInput } from './KeyInput.js';
|
|
5
|
+
import { getConfig, saveConfig, applyConfig } from '../core/config.js';
|
|
6
|
+
import { theme } from './theme.js';
|
|
7
|
+
export const Settings = ({ onClose, onModelChange }) => {
|
|
8
|
+
const { stdout } = useStdout();
|
|
9
|
+
const width = Math.max(40, stdout?.columns ?? 60);
|
|
10
|
+
const [screen, setScreen] = useState('main');
|
|
11
|
+
const [cursor, setCursor] = useState(0);
|
|
12
|
+
const [cfg, setCfg] = useState(() => getConfig());
|
|
13
|
+
const [draft, setDraft] = useState('');
|
|
14
|
+
const [testResult, setTestResult] = useState(null);
|
|
15
|
+
const persist = (next) => {
|
|
16
|
+
saveConfig(next);
|
|
17
|
+
applyConfig(next);
|
|
18
|
+
setCfg(next);
|
|
19
|
+
};
|
|
20
|
+
const mainItems = [
|
|
21
|
+
{ label: 'API key', hint: cfg.apiKey ? '•••' + cfg.apiKey.slice(-4) : 'not set', action: () => setScreen('apikey') },
|
|
22
|
+
{ label: 'Base URL', hint: cfg.baseUrl || 'default (OpenAI)', action: () => setScreen('baseurl') },
|
|
23
|
+
{ label: 'Model', hint: cfg.model, action: () => setScreen('model') },
|
|
24
|
+
{
|
|
25
|
+
label: 'n8n',
|
|
26
|
+
hint: cfg.n8nUrl
|
|
27
|
+
? (cfg.n8nApiKey ? `${cfg.n8nUrl} ✓` : `${cfg.n8nUrl} (no key)`)
|
|
28
|
+
: 'not configured',
|
|
29
|
+
action: () => setScreen('n8n'),
|
|
30
|
+
},
|
|
31
|
+
{ label: 'Reset all config', hint: 'delete ~/.blyue/.env', action: () => setScreen('reset') },
|
|
32
|
+
{ label: 'Close', hint: 'esc', action: onClose },
|
|
33
|
+
];
|
|
34
|
+
// ---- Main menu navigation ----
|
|
35
|
+
useInput((_, key) => {
|
|
36
|
+
if (key.escape)
|
|
37
|
+
return onClose();
|
|
38
|
+
if (key.upArrow)
|
|
39
|
+
setCursor((c) => (c - 1 + mainItems.length) % mainItems.length);
|
|
40
|
+
if (key.downArrow)
|
|
41
|
+
setCursor((c) => (c + 1) % mainItems.length);
|
|
42
|
+
if (key.return)
|
|
43
|
+
mainItems[cursor].action();
|
|
44
|
+
}, { isActive: screen === 'main' });
|
|
45
|
+
// ---- n8n submenu navigation ----
|
|
46
|
+
const n8nItems = [
|
|
47
|
+
{ label: 'URL', hint: cfg.n8nUrl || 'not set', action: () => { setDraft(cfg.n8nUrl || ''); setScreen('n8n-url'); } },
|
|
48
|
+
{ label: 'API key', hint: cfg.n8nApiKey ? '•••' + cfg.n8nApiKey.slice(-4) : 'not set', action: () => { setDraft(cfg.n8nApiKey || ''); setScreen('n8n-key'); } },
|
|
49
|
+
{ label: 'Test connection', hint: 'ping n8n API', action: async () => {
|
|
50
|
+
setScreen('n8n-test');
|
|
51
|
+
setTestResult(null);
|
|
52
|
+
try {
|
|
53
|
+
const { n8nListWorkflows } = await import('../core/n8n.js');
|
|
54
|
+
const wf = await n8nListWorkflows();
|
|
55
|
+
setTestResult(`✓ Connected. ${wf.length} workflow(s) found.`);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
setTestResult('✗ ' + (e?.message || String(e)));
|
|
59
|
+
}
|
|
60
|
+
} },
|
|
61
|
+
{ label: 'Disable n8n', hint: 'remove URL + key', action: () => {
|
|
62
|
+
const next = { ...cfg };
|
|
63
|
+
delete next.n8nUrl;
|
|
64
|
+
delete next.n8nApiKey;
|
|
65
|
+
persist(next);
|
|
66
|
+
} },
|
|
67
|
+
{ label: 'Back', hint: 'esc', action: () => setScreen('main') },
|
|
68
|
+
];
|
|
69
|
+
const [n8nCursor, setN8nCursor] = useState(0);
|
|
70
|
+
useInput((_, key) => {
|
|
71
|
+
if (key.escape)
|
|
72
|
+
return setScreen('main');
|
|
73
|
+
if (key.upArrow)
|
|
74
|
+
setN8nCursor((c) => (c - 1 + n8nItems.length) % n8nItems.length);
|
|
75
|
+
if (key.downArrow)
|
|
76
|
+
setN8nCursor((c) => (c + 1) % n8nItems.length);
|
|
77
|
+
if (key.return)
|
|
78
|
+
n8nItems[n8nCursor].action();
|
|
79
|
+
}, { isActive: screen === 'n8n' });
|
|
80
|
+
// ---- Frame helpers ----
|
|
81
|
+
const inner = width - 4;
|
|
82
|
+
const top = (label) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(Math.max(1, inner - label.length - 3)) + '╮' })] }));
|
|
83
|
+
const bot = (_jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }));
|
|
84
|
+
const row = (content) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), content] }));
|
|
85
|
+
// ---- Screens ----
|
|
86
|
+
if (screen === 'main') {
|
|
87
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('settings'), mainItems.map((it, i) => {
|
|
88
|
+
const active = i === cursor;
|
|
89
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(20) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
|
|
90
|
+
}), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc close" })] }));
|
|
91
|
+
}
|
|
92
|
+
if (screen === 'apikey' || screen === 'baseurl') {
|
|
93
|
+
const isKey = screen === 'apikey';
|
|
94
|
+
const title = isKey ? 'api key' : 'base url';
|
|
95
|
+
const value = isKey ? cfg.apiKey : cfg.baseUrl || '';
|
|
96
|
+
const setValue = (v) => {
|
|
97
|
+
const next = { ...cfg, [isKey ? 'apiKey' : 'baseUrl']: v };
|
|
98
|
+
persist(next);
|
|
99
|
+
};
|
|
100
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Paste your new API key' : 'e.g. https://openrouter.ai/api/v1' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: value, onChange: setValue, placeholder: isKey ? 'sk-...' : 'https://...', masked: isKey, onSubmit: () => setScreen('main') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 backspace \u00B7 \u21B5 save \u00B7 esc back" })] }));
|
|
101
|
+
}
|
|
102
|
+
if (screen === 'model') {
|
|
103
|
+
// Simple inline editor for now — full ModelPicker opens separately if user wants
|
|
104
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('model'), row(_jsx(Text, { color: theme.muted, children: "Type model name (or use /model for picker)" })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: cfg.model, onChange: (v) => persist({ ...cfg, model: v }), placeholder: "gpt-4o-mini", onSubmit: () => {
|
|
105
|
+
onModelChange?.(cfg.model);
|
|
106
|
+
setScreen('main');
|
|
107
|
+
} })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 save \u00B7 esc back" })] }));
|
|
108
|
+
}
|
|
109
|
+
if (screen === 'n8n') {
|
|
110
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('n8n integration'), n8nItems.map((it, i) => {
|
|
111
|
+
const active = i === n8nCursor;
|
|
112
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(20) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
|
|
113
|
+
}), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc back" })] }));
|
|
114
|
+
}
|
|
115
|
+
if (screen === 'n8n-url' || screen === 'n8n-key') {
|
|
116
|
+
const isKey = screen === 'n8n-key';
|
|
117
|
+
const title = isKey ? 'n8n api key' : 'n8n url';
|
|
118
|
+
const value = isKey ? cfg.n8nApiKey || '' : cfg.n8nUrl || '';
|
|
119
|
+
const setValue = (v) => {
|
|
120
|
+
const next = { ...cfg, [isKey ? 'n8nApiKey' : 'n8nUrl']: v };
|
|
121
|
+
persist(next);
|
|
122
|
+
};
|
|
123
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Settings → n8n API → create key' : 'e.g. http://localhost:5678' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: value, onChange: setValue, placeholder: isKey ? 'n8n_api_...' : 'http://localhost:5678', masked: isKey, onSubmit: () => setScreen('n8n') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 backspace \u00B7 \u21B5 save \u00B7 esc back" })] }));
|
|
124
|
+
}
|
|
125
|
+
if (screen === 'n8n-test') {
|
|
126
|
+
useInput(() => setScreen('n8n'), { isActive: screen === 'n8n-test' });
|
|
127
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('n8n test'), row(_jsx(Text, { color: testResult?.startsWith('✓') ? theme.success : theme.muted, children: testResult || 'testing…' })), bot, _jsx(Text, { color: theme.muted, children: "press any key to go back" })] }));
|
|
128
|
+
}
|
|
129
|
+
if (screen === 'reset') {
|
|
130
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('reset config'), row(_jsx(Text, { color: theme.error, children: "Delete ~/.blyue/.env and reconfigure?" })), row(_jsx(Text, { color: theme.muted, children: "Press Y to confirm, N to cancel." })), bot] }));
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
// Handle Y/N for reset outside component main body
|
|
135
|
+
export const SettingsResetHandler = ({ onConfirm, onCancel, }) => {
|
|
136
|
+
useInput((input) => {
|
|
137
|
+
if (input === 'y' || input === 'Y')
|
|
138
|
+
onConfirm();
|
|
139
|
+
if (input === 'n' || input === 'N')
|
|
140
|
+
onCancel();
|
|
141
|
+
});
|
|
142
|
+
return null;
|
|
143
|
+
};
|
package/dist/ui/theme.js
CHANGED