blyuecode 0.1.10 → 0.1.12

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,14 @@
1
+ export async function detectOllama(url = 'http://localhost:11434') {
2
+ try {
3
+ const res = await fetch(`${url}/api/tags`, {
4
+ signal: AbortSignal.timeout(1500),
5
+ });
6
+ if (!res.ok)
7
+ return [];
8
+ const json = await res.json();
9
+ return (json.models || []).map((m) => m.name);
10
+ }
11
+ catch {
12
+ return [];
13
+ }
14
+ }
@@ -1,4 +1,10 @@
1
1
  import OpenAI from 'openai';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { redact } from './redact.js';
6
+ const DEBUG_DIR = path.join(os.homedir(), '.blyue');
7
+ const LAST_REQUEST = path.join(DEBUG_DIR, 'last-request.json');
2
8
  export class Provider {
3
9
  client;
4
10
  model;
@@ -12,14 +18,46 @@ export class Provider {
12
18
  baseURL: process.env.OPENAI_BASE_URL,
13
19
  });
14
20
  }
21
+ sanitize(messages) {
22
+ let redactedCount = 0;
23
+ const out = messages.map((m) => {
24
+ if (typeof m.content !== 'string')
25
+ return m;
26
+ const { text, count } = redact(m.content);
27
+ redactedCount += count;
28
+ return { ...m, content: text };
29
+ });
30
+ return { messages: out, redactedCount };
31
+ }
32
+ writeDebug(payload) {
33
+ try {
34
+ if (!fs.existsSync(DEBUG_DIR))
35
+ fs.mkdirSync(DEBUG_DIR, { recursive: true });
36
+ fs.writeFileSync(LAST_REQUEST, JSON.stringify(payload, null, 2));
37
+ }
38
+ catch { }
39
+ }
15
40
  async chat(messages, tools) {
41
+ const { messages: safeMessages, redactedCount } = this.sanitize(messages);
16
42
  try {
17
43
  const res = await this.client.chat.completions.create({
18
44
  model: this.model,
19
- messages: messages,
45
+ messages: safeMessages,
20
46
  tools,
21
47
  tool_choice: 'auto',
22
48
  });
49
+ // Log debug info after successful call
50
+ const totalBytes = JSON.stringify(safeMessages).length;
51
+ this.writeDebug({
52
+ model: this.model,
53
+ baseUrl: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
54
+ messages: safeMessages,
55
+ approxTokens: Math.round(totalBytes / 4),
56
+ approxBytes: totalBytes,
57
+ messageCount: safeMessages.length,
58
+ redactedCount,
59
+ timestamp: new Date().toISOString(),
60
+ });
23
61
  const msg = res.choices[0].message;
24
62
  return {
25
63
  content: msg.content,
@@ -0,0 +1,42 @@
1
+ const PATTERNS = [
2
+ // OpenAI / OpenRouter
3
+ [/sk-or-v1-[a-zA-Z0-9]{32,}/g, 'sk-or-v1-***REDACTED***'],
4
+ [/sk-[a-zA-Z0-9]{20,}/g, 'sk-***REDACTED***'],
5
+ // Anthropic
6
+ [/sk-ant-[a-zA-Z0-9\-_]{20,}/g, 'sk-ant-***REDACTED***'],
7
+ // GitHub
8
+ [/ghp_[a-zA-Z0-9]{36,}/g, 'ghp_***REDACTED***'],
9
+ [/gho_[a-zA-Z0-9]{36,}/g, 'gho_***REDACTED***'],
10
+ [/ghs_[a-zA-Z0-9]{36,}/g, 'ghs_***REDACTED***'],
11
+ [/github_pat_[a-zA-Z0-9_]{60,}/g, 'github_pat_***REDACTED***'],
12
+ // AWS
13
+ [/AKIA[0-9A-Z]{16}/g, 'AKIA***REDACTED***'],
14
+ [/aws_secret_access_key\s*=\s*[A-Za-z0-9/+=]{40}/gi, 'aws_secret_access_key=***REDACTED***'],
15
+ // Google
16
+ [/AIza[0-9A-Za-z_\-]{35}/g, 'AIza***REDACTED***'],
17
+ // Slack
18
+ [/xox[baprs]-[a-zA-Z0-9-]{10,}/g, 'xox***REDACTED***'],
19
+ // Stripe
20
+ [/sk_live_[a-zA-Z0-9]{24,}/g, 'sk_live_***REDACTED***'],
21
+ [/pk_live_[a-zA-Z0-9]{24,}/g, 'pk_live_***REDACTED***'],
22
+ // JWT
23
+ [/eyJ[a-zA-Z0-9_\-]{10,}\.eyJ[a-zA-Z0-9_\-]{10,}\.[a-zA-Z0-9_\-]{10,}/g, '***REDACTED JWT***'],
24
+ // Bearer tokens
25
+ [/Bearer\s+[a-zA-Z0-9_\-\.]{20,}/g, 'Bearer ***REDACTED***'],
26
+ // PEM keys
27
+ [/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, '-----***REDACTED KEY***-----'],
28
+ // Generic env-style secrets (password=, token=, secret=)
29
+ [/(password|passwd|pwd|secret|token|api_key|apikey)\s*[:=]\s*["']?([A-Za-z0-9_\-\.]{16,})["']?/gi,
30
+ '$1=***REDACTED***'],
31
+ ];
32
+ export function redact(text) {
33
+ let out = text;
34
+ let count = 0;
35
+ for (const [re, replacement] of PATTERNS) {
36
+ const before = out;
37
+ out = out.replace(re, replacement);
38
+ if (out !== before)
39
+ count++;
40
+ }
41
+ return { text: out, count };
42
+ }
@@ -0,0 +1,35 @@
1
+ import path from 'path';
2
+ import os from 'os';
3
+ const BLOCKED_NAMES = [
4
+ '.env', '.env.local', '.env.production', '.env.staging',
5
+ 'id_rsa', 'id_ed25519', 'id_dsa', 'id_ecdsa',
6
+ 'credentials', 'secrets.json', 'secrets.yml', 'secrets.yaml',
7
+ '.git-credentials', '.npmrc', '.pypirc', '.netrc', '.htpasswd',
8
+ 'known_hosts', 'authorized_keys',
9
+ ];
10
+ const BLOCKED_EXTENSIONS = [
11
+ '.pem', '.key', '.p12', '.pfx', '.keystore', '.jks',
12
+ ];
13
+ const BLOCKED_DIRS = [
14
+ '.ssh', '.aws', '.gnupg', '.gpg', '.kube',
15
+ '.docker', '.config/gcloud', '.password-store',
16
+ ];
17
+ export function isBlocked(p) {
18
+ const abs = path.resolve(p);
19
+ const base = path.basename(abs).toLowerCase();
20
+ if (BLOCKED_NAMES.some((n) => base === n.toLowerCase())) {
21
+ return `"${path.basename(abs)}" is a sensitive file (blocked)`;
22
+ }
23
+ const ext = path.extname(abs).toLowerCase();
24
+ if (BLOCKED_EXTENSIONS.includes(ext)) {
25
+ return `"${path.basename(abs)}" has a sensitive extension (blocked)`;
26
+ }
27
+ const home = os.homedir();
28
+ for (const d of BLOCKED_DIRS) {
29
+ const target = path.join(home, d);
30
+ if (abs === target || abs.startsWith(target + path.sep)) {
31
+ return `Path is inside ~/${d} (blocked)`;
32
+ }
33
+ }
34
+ return null;
35
+ }
@@ -5,6 +5,7 @@ import { promisify } from 'util';
5
5
  import { hasN8n } from './config.js';
6
6
  import { permissions } from './permission.js';
7
7
  import { lineDiff, compactDiff } from './diff.js';
8
+ import { isBlocked } from './sandbox.js';
8
9
  import { n8nListWorkflows, n8nTriggerWorkflow, n8nListExecutions, n8nGetWorkflow, n8nActivateWorkflow, n8nDeactivateWorkflow, } from './n8n.js';
9
10
  const execAsync = promisify(exec);
10
11
  const BASE_TOOLS = [
@@ -12,7 +13,7 @@ const BASE_TOOLS = [
12
13
  type: 'function',
13
14
  function: {
14
15
  name: 'read_file',
15
- description: 'Read a file from disk',
16
+ description: 'Read a file from disk. Sensitive files (.env, .ssh, keys) are blocked.',
16
17
  parameters: {
17
18
  type: 'object',
18
19
  properties: { path: { type: 'string' } },
@@ -71,13 +72,12 @@ export function getToolDefs() {
71
72
  return hasN8n() ? [...BASE_TOOLS, ...N8N_TOOLS] : [...BASE_TOOLS];
72
73
  }
73
74
  export const toolDefs = BASE_TOOLS;
74
- // Dangerous command patterns
75
75
  const DANGER_PATTERNS = [
76
76
  { re: /\brm\s+(-[a-z]*[rf][a-z]*\s+)?/, level: 'high' },
77
77
  { re: /\bsudo\b/, level: 'high' },
78
78
  { re: /\bmkfs\b|\bdd\s+if=/, level: 'high' },
79
79
  { re: />\s*\/dev\//, level: 'high' },
80
- { re: /:\(\)\s*\{/, level: 'high' }, // fork bomb
80
+ { re: /:\(\)\s*\{/, level: 'high' },
81
81
  { re: /\bchmod\s+(-R\s+)?777\b/, level: 'medium' },
82
82
  { re: /\bgit\s+push\b/, level: 'medium' },
83
83
  { re: /\bnpm\s+(publish|unpublish)\b/, level: 'medium' },
@@ -93,11 +93,18 @@ function classify(cmd) {
93
93
  export async function executeTool(name, args) {
94
94
  try {
95
95
  switch (name) {
96
- case 'read_file':
96
+ case 'read_file': {
97
+ const blocked = isBlocked(args.path);
98
+ if (blocked)
99
+ return `Blocked by sandbox: ${blocked}`;
97
100
  return await fs.readFile(args.path, 'utf8');
101
+ }
98
102
  case 'write_file': {
99
103
  const path = args.path;
100
104
  const content = args.content;
105
+ const blockedWrite = isBlocked(path);
106
+ if (blockedWrite)
107
+ return `Blocked by sandbox: ${blockedWrite}`;
101
108
  const existed = existsSync(path);
102
109
  let oldText = '';
103
110
  if (existed) {
package/dist/index.js CHANGED
@@ -5,12 +5,16 @@ import { render, Box, Static } from 'ink';
5
5
  import { Setup } from './ui/Setup.js';
6
6
  import { Chat } from './ui/Chat.js';
7
7
  import { Banner } from './ui/Banner.js';
8
+ import { Consent, hasConsented } from './ui/Consent.js';
8
9
  import { loadConfig, applyConfig } from './core/config.js';
9
10
  import { loadSession } from './core/session.js';
10
11
  const App = () => {
11
12
  const [ready, setReady] = useState(false);
12
13
  const [resumedCount, setResumedCount] = useState(0);
14
+ const [consented, setConsented] = useState(hasConsented());
13
15
  useEffect(() => {
16
+ if (!consented)
17
+ return;
14
18
  const existing = loadConfig();
15
19
  if (existing) {
16
20
  applyConfig(existing);
@@ -18,8 +22,8 @@ const App = () => {
18
22
  setResumedCount(hist ? hist.length : 0);
19
23
  setReady(true);
20
24
  }
21
- }, []);
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) }))] }));
25
+ }, [consented]);
26
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Static, { items: ['banner'], children: () => _jsx(Banner, {}) }), !consented ? (_jsx(Consent, { onAccept: () => setConsented(true) })) : ready ? (_jsx(Chat, { resumedCount: resumedCount })) : (_jsx(Setup, { onDone: () => setReady(true) }))] }));
23
27
  };
24
28
  render(_jsx(App, {}), {
25
29
  patchConsole: false,
package/dist/ui/Chat.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Box, Text, useInput, useStdout, Static } from 'ink';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import os from 'os';
4
7
  import { Agent } from '../core/agent.js';
5
8
  import { permissions } from '../core/permission.js';
6
9
  import { InputBar } from './InputBar.js';
@@ -22,6 +25,28 @@ const HistoryLine = ({ item }) => {
22
25
  }
23
26
  return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.text, children: item.text }) }));
24
27
  };
28
+ const DEBUG_FILE = path.join(os.homedir(), '.blyue', 'last-request.json');
29
+ function readDebug() {
30
+ if (!fs.existsSync(DEBUG_FILE)) {
31
+ return ['No request sent yet in this session.'];
32
+ }
33
+ try {
34
+ const d = JSON.parse(fs.readFileSync(DEBUG_FILE, 'utf8'));
35
+ const kb = (d.approxBytes / 1024).toFixed(1);
36
+ return [
37
+ `Model: ${d.model}`,
38
+ `Provider: ${d.baseUrl}`,
39
+ `Messages: ${d.messageCount}`,
40
+ `Approx size: ~${d.approxTokens} tokens · ${kb} KB`,
41
+ `Redactions: ${d.redactedCount || 0} secrets scrubbed`,
42
+ `Timestamp: ${d.timestamp}`,
43
+ `Full payload: ${DEBUG_FILE}`,
44
+ ];
45
+ }
46
+ catch (e) {
47
+ return ['Debug file unreadable: ' + e.message];
48
+ }
49
+ }
25
50
  export const Chat = ({ resumedCount = 0 }) => {
26
51
  const { stdout } = useStdout();
27
52
  const width = stdout?.columns ?? 80;
@@ -37,14 +62,13 @@ export const Chat = ({ resumedCount = 0 }) => {
37
62
  const [permReq, setPermReq] = useState(null);
38
63
  const [currentModel, setCurrentModel] = useState(process.env.MODEL || '');
39
64
  const [agent, setAgent] = useState(() => new Agent());
40
- // Subscribe to permission requests
41
65
  useEffect(() => {
42
66
  const unsub = permissions.subscribe((req) => setPermReq(req));
43
67
  return unsub;
44
68
  }, []);
45
69
  useInput((inputChar, key) => {
46
70
  if (permReq)
47
- return; // permission prompt handles its own input
71
+ return;
48
72
  if (key.ctrl && inputChar === 'p')
49
73
  setOverlay('model');
50
74
  if (key.ctrl && inputChar === 's')
@@ -64,6 +88,16 @@ export const Chat = ({ resumedCount = 0 }) => {
64
88
  setOverlay('settings');
65
89
  return;
66
90
  }
91
+ if (trimmed === '/debug') {
92
+ setInput('');
93
+ const lines = readDebug();
94
+ setHistory((h) => [
95
+ ...h,
96
+ mkItem('tool', 'Last request to provider:'),
97
+ ...lines.map((l) => mkItem('tool', ' ' + l)),
98
+ ]);
99
+ return;
100
+ }
67
101
  if (trimmed === '/clear') {
68
102
  setInput('');
69
103
  agent.clear();
@@ -76,8 +110,9 @@ export const Chat = ({ resumedCount = 0 }) => {
76
110
  setHistory((h) => [
77
111
  ...h,
78
112
  mkItem('tool', 'Commands:'),
79
- mkItem('tool', ' /model switch model (Ctrl+P)'),
80
- mkItem('tool', ' /settings open settings (Ctrl+S)'),
113
+ mkItem('tool', ' /model switch model (Ctrl+P)'),
114
+ mkItem('tool', ' /settings open settings (Ctrl+S)'),
115
+ mkItem('tool', ' /debug show last request to provider'),
81
116
  mkItem('tool', ' /clear clear session and permissions'),
82
117
  mkItem('tool', ' /help show this list'),
83
118
  ]);
@@ -104,7 +139,6 @@ export const Chat = ({ resumedCount = 0 }) => {
104
139
  setAgent(new Agent());
105
140
  setHistory((h) => [...h, mkItem('tool', `model → ${id}`)]);
106
141
  };
107
- // Permission prompt takes priority over everything
108
142
  if (permReq) {
109
143
  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
144
  }
@@ -121,5 +155,5 @@ export const Chat = ({ resumedCount = 0 }) => {
121
155
  const shortModel = currentModel.length > maxModelLen
122
156
  ? '…' + currentModel.slice(-(maxModelLen - 1))
123
157
  : currentModel || 'unset';
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 })] })] })] }));
158
+ 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.accent, children: "/debug" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.text, children: shortModel })] })] })] }));
125
159
  };
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { theme } from './theme.js';
7
+ const FLAG = path.join(os.homedir(), '.blyue', '.consent');
8
+ export function hasConsented() {
9
+ return fs.existsSync(FLAG);
10
+ }
11
+ export function markConsented() {
12
+ fs.mkdirSync(path.dirname(FLAG), { recursive: true });
13
+ fs.writeFileSync(FLAG, new Date().toISOString());
14
+ }
15
+ export const Consent = ({ onAccept }) => {
16
+ useInput((input) => {
17
+ if (input === 'y' || input === 'Y') {
18
+ markConsented();
19
+ onAccept();
20
+ }
21
+ else if (input === 'n' || input === 'N') {
22
+ process.exit(0);
23
+ }
24
+ });
25
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: theme.warning, bold: true, children: "\u26A0 Privacy notice" }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.text, children: "BLYUE sends your prompts, file contents, and command output to your configured LLM provider so it can help you." }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.muted, children: "Do NOT run this on directories containing secrets, keys, or private data unless you trust the provider." }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.muted, children: "Built-in protections:" }), _jsx(Text, { color: theme.muted, children: ' · .env, .ssh, .aws, *.pem, *.key are blocked from reading' }), _jsx(Text, { color: theme.muted, children: ' · API keys and tokens are redacted before sending' }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: theme.success, bold: true, children: " [y]" }), _jsx(Text, { color: theme.text, children: " I understand, continue" })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.error, bold: true, children: " [n]" }), _jsx(Text, { color: theme.text, children: " Exit" })] })] }));
26
+ };
@@ -1,8 +1,9 @@
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 } from 'ink';
4
4
  import { KeyInput } from './KeyInput.js';
5
5
  import { getConfig, saveConfig, applyConfig } from '../core/config.js';
6
+ import { detectOllama } from '../core/localCheck.js';
6
7
  import { theme } from './theme.js';
7
8
  export const Settings = ({ onClose, onModelChange }) => {
8
9
  const { stdout } = useStdout();
@@ -12,6 +13,15 @@ export const Settings = ({ onClose, onModelChange }) => {
12
13
  const [cfg, setCfg] = useState(() => getConfig());
13
14
  const [draft, setDraft] = useState('');
14
15
  const [testResult, setTestResult] = useState(null);
16
+ const [ollamaModels, setOllamaModels] = useState([]);
17
+ const [ollamaChecked, setOllamaChecked] = useState(false);
18
+ useEffect(() => {
19
+ (async () => {
20
+ const models = await detectOllama();
21
+ setOllamaModels(models);
22
+ setOllamaChecked(true);
23
+ })();
24
+ }, []);
15
25
  const persist = (next) => {
16
26
  saveConfig(next);
17
27
  applyConfig(next);
@@ -28,7 +38,6 @@ export const Settings = ({ onClose, onModelChange }) => {
28
38
  : 'not configured',
29
39
  action: () => setScreen('n8n'),
30
40
  },
31
- { label: 'Reset all config', hint: 'delete ~/.blyue/.env', action: () => setScreen('reset') },
32
41
  { label: 'Close', hint: 'esc', action: onClose },
33
42
  ];
34
43
  // ---- Main menu navigation ----
@@ -42,7 +51,7 @@ export const Settings = ({ onClose, onModelChange }) => {
42
51
  if (key.return)
43
52
  mainItems[cursor].action();
44
53
  }, { isActive: screen === 'main' });
45
- // ---- n8n submenu navigation ----
54
+ // ---- n8n submenu ----
46
55
  const n8nItems = [
47
56
  { label: 'URL', hint: cfg.n8nUrl || 'not set', action: () => { setDraft(cfg.n8nUrl || ''); setScreen('n8n-url'); } },
48
57
  { label: 'API key', hint: cfg.n8nApiKey ? '•••' + cfg.n8nApiKey.slice(-4) : 'not set', action: () => { setDraft(cfg.n8nApiKey || ''); setScreen('n8n-key'); } },
@@ -77,18 +86,23 @@ export const Settings = ({ onClose, onModelChange }) => {
77
86
  if (key.return)
78
87
  n8nItems[n8nCursor].action();
79
88
  }, { isActive: screen === 'n8n' });
89
+ // ---- n8n test ----
90
+ useInput(() => setScreen('n8n'), { isActive: screen === 'n8n-test' });
80
91
  // ---- Frame helpers ----
81
92
  const inner = width - 4;
82
93
  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
94
  const bot = (_jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }));
84
95
  const row = (content) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), content] }));
85
- // ---- Screens ----
96
+ // ---- Main screen ----
86
97
  if (screen === 'main') {
87
- return (_jsxs(Box, { flexDirection: "column", children: [top('settings'), mainItems.map((it, i) => {
98
+ const baseUrlIsLocal = (cfg.baseUrl || '').includes('localhost') ||
99
+ (cfg.baseUrl || '').includes('127.0.0.1');
100
+ return (_jsxs(Box, { flexDirection: "column", children: [top('settings'), ollamaChecked && ollamaModels.length > 0 && (row(_jsxs(Text, { children: [_jsx(Text, { color: theme.success, children: "\u25CF " }), _jsx(Text, { color: theme.muted, children: "Ollama detected \u00B7 " }), _jsxs(Text, { color: theme.text, children: [ollamaModels.length, " model(s)"] })] }))), ollamaChecked && ollamaModels.length === 0 && baseUrlIsLocal && (row(_jsxs(Text, { children: [_jsx(Text, { color: theme.error, children: "\u25CF " }), _jsx(Text, { color: theme.muted, children: "Ollama not running at localhost:11434" })] }))), mainItems.map((it, i) => {
88
101
  const active = i === cursor;
89
102
  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
103
  }), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc close" })] }));
91
104
  }
105
+ // ---- API key / Base URL editors ----
92
106
  if (screen === 'apikey' || screen === 'baseurl') {
93
107
  const isKey = screen === 'apikey';
94
108
  const title = isKey ? 'api key' : 'base url';
@@ -97,21 +111,26 @@ export const Settings = ({ onClose, onModelChange }) => {
97
111
  const next = { ...cfg, [isKey ? 'apiKey' : 'baseUrl']: v };
98
112
  persist(next);
99
113
  };
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" })] }));
114
+ return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Paste your new API key' : 'e.g. http://localhost:11434/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
115
  }
116
+ // ---- Model editor ----
102
117
  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: () => {
118
+ const isLocal = (cfg.baseUrl || '').includes('localhost');
119
+ return (_jsxs(Box, { flexDirection: "column", children: [top('model'), row(_jsx(Text, { color: theme.muted, children: isLocal && ollamaModels.length > 0
120
+ ? 'Available: ' + ollamaModels.slice(0, 3).join(', ')
121
+ : 'Type model name' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: cfg.model, onChange: (v) => persist({ ...cfg, model: v }), placeholder: "qwen2.5-coder:1.5b", onSubmit: () => {
105
122
  onModelChange?.(cfg.model);
106
123
  setScreen('main');
107
124
  } })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 save \u00B7 esc back" })] }));
108
125
  }
126
+ // ---- n8n submenu ----
109
127
  if (screen === 'n8n') {
110
128
  return (_jsxs(Box, { flexDirection: "column", children: [top('n8n integration'), n8nItems.map((it, i) => {
111
129
  const active = i === n8nCursor;
112
130
  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
131
  }), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc back" })] }));
114
132
  }
133
+ // ---- n8n URL / key editors ----
115
134
  if (screen === 'n8n-url' || screen === 'n8n-key') {
116
135
  const isKey = screen === 'n8n-key';
117
136
  const title = isKey ? 'n8n api key' : 'n8n url';
@@ -122,22 +141,9 @@ export const Settings = ({ onClose, onModelChange }) => {
122
141
  };
123
142
  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
143
  }
144
+ // ---- n8n test result ----
125
145
  if (screen === 'n8n-test') {
126
- useInput(() => setScreen('n8n'), { isActive: screen === 'n8n-test' });
127
146
  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
147
  }
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
148
  return null;
143
149
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",