@vincentbanana/bananacli 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -31,6 +31,19 @@
31
31
  - **改前先看 diff、改后能回滚**:写文件前展示具体增删行,`/undo` 还原上一轮所有文件改动。
32
32
  - **多模态与扩展**:`/image` 附加截图,`.banana/tools/` 放脚本即可注册自定义工具,成本按 token 估算。
33
33
 
34
+ ## 图形界面
35
+
36
+ 同一个内核,两种界面。CLI 加 `--ui` 就能开图形界面:
37
+
38
+ ```bash
39
+ banana --ui # 打开图形界面
40
+ npm install -g @vincentbanana/banana-gui && banana-gui # 或装独立的 GUI 包
41
+ ```
42
+
43
+ 图形界面与终端**共用同一套内核**(agent 循环、工具、权限、`commands.ts` 里的斜杠命令),行为完全一致,配置也共用 `~/.banana/config.json`。
44
+
45
+ 界面提供:会话管理与导出(Markdown / JSON)、`/` 命令面板、`@` 文件补全、项目文件面板、工具输出详情、三态主题、API Key 设置。
46
+
34
47
  ## 平台支持
35
48
 
36
49
  | 平台 | 状态 | 说明 |
package/dist/cli/ui.js CHANGED
@@ -35,11 +35,24 @@ export const c = {
35
35
  magenta: paint(35, 39),
36
36
  gray: paint(90, 39),
37
37
  };
38
+ let sink = null;
39
+ /** 把输出转到别处(GUI 用);返回还原函数。 */
40
+ export function captureOutput(target) {
41
+ const previous = sink;
42
+ sink = target;
43
+ return () => {
44
+ sink = previous;
45
+ };
46
+ }
38
47
  export function write(text) {
48
+ if (sink) {
49
+ sink(text);
50
+ return;
51
+ }
39
52
  process.stdout.write(text);
40
53
  }
41
54
  export function line(text = '') {
42
- process.stdout.write(`${text}\n`);
55
+ write(`${text}\n`);
43
56
  }
44
57
  export function info(text) {
45
58
  line(c.cyan(text));
@@ -174,6 +187,10 @@ export function relativeTime(iso) {
174
187
  return `${Math.round(hours / 24)} 天前`;
175
188
  }
176
189
  export function clearScreen() {
190
+ if (sink) {
191
+ sink('\u001b[clear]\n');
192
+ return;
193
+ }
177
194
  if (process.stdout.isTTY)
178
195
  process.stdout.write('\u001b[2J\u001b[H');
179
196
  }
package/dist/index.js CHANGED
@@ -220,13 +220,23 @@ async function main() {
220
220
  provider = createProvider(config.current, config);
221
221
  }
222
222
  catch (error) {
223
- ui.error(error instanceof Error ? error.message : String(error));
224
- if (interactive)
225
- ui.hint(' 运行 `banana --setup` 运行配置向导,或设置对应环境变量后重试。');
226
- process.exit(1);
223
+ // GUI 模式下不因缺 key 而退出:界面本身可以用来填 key
224
+ if (resolved.ui) {
225
+ ui.warn(error instanceof Error ? error.message : String(error));
226
+ ui.hint(' 图形界面会照常打开,在设置里填入 API Key 即可开始使用。');
227
+ provider = null;
228
+ }
229
+ else {
230
+ ui.error(error instanceof Error ? error.message : String(error));
231
+ if (interactive)
232
+ ui.hint(' 运行 `banana --setup` 运行配置向导,或设置对应环境变量后重试。');
233
+ process.exit(1);
234
+ }
235
+ }
236
+ if (provider) {
237
+ session.provider = provider.name;
238
+ session.model = provider.model;
227
239
  }
228
- session.provider = provider.name;
229
- session.model = provider.model;
230
240
  const tools = resolved.noTools ? [] : [...allTools];
231
241
  if (resolved.noTools)
232
242
  ui.hint('已禁用全部工具(--no-tools)。');
@@ -256,10 +266,11 @@ async function main() {
256
266
  });
257
267
  return;
258
268
  }
269
+ // 走到这里说明 provider 可用(GUI 分支已在上方返回)
259
270
  const outcome = await startRepl({
260
271
  config,
261
272
  store,
262
- provider,
273
+ provider: provider,
263
274
  tools,
264
275
  session,
265
276
  oneShot: resolved.oneShot,
@@ -1,6 +1,6 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomBytes } from 'node:crypto';
3
- import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
4
4
  import { extname, join, resolve, dirname } from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
@@ -14,6 +14,9 @@ import { BackupStore, newTurnId } from "../session/backups.js";
14
14
  import { allTools, permissionFor, summarizeInput } from "../tools/index.js";
15
15
  import { loadPluginTools } from "../tools/plugins.js";
16
16
  import { resolvePath } from "../tools/fs.js";
17
+ import { commands, findCommand, suggestCommand } from "../cli/commands.js";
18
+ import { completeCommand, completeFileReference } from "../cli/completion.js";
19
+ import { captureOutput } from "../cli/ui.js";
17
20
  const MIME = {
18
21
  '.html': 'text/html; charset=utf-8',
19
22
  '.css': 'text/css; charset=utf-8',
@@ -28,6 +31,16 @@ const IMAGE_TYPES = {
28
31
  '.webp': 'image/webp',
29
32
  };
30
33
  const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
34
+ const MAX_FILE_BYTES = 400 * 1024;
35
+ const TEXT_EXT = new Set([
36
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.md', '.txt', '.yml', '.yaml',
37
+ '.toml', '.css', '.scss', '.html', '.sh', '.bash', '.zsh', '.py', '.go', '.rs', '.java',
38
+ '.c', '.h', '.cpp', '.hpp', '.sql', '.xml', '.ini', '.conf', '.env', '.gitignore', '.lock',
39
+ ]);
40
+ const SKIP_DIRS = new Set([
41
+ 'node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', 'coverage', '__pycache__',
42
+ '.venv', 'venv', 'target', '.cache', '.idea', '.gradle',
43
+ ]);
31
44
  /** 静态资源目录:默认是 CLI 包自带的 ui/,GUI 包启动时可以传自己的目录进来。 */
32
45
  function defaultAssetsDir() {
33
46
  return join(dirname(fileURLToPath(import.meta.url)), '..', 'ui');
@@ -57,6 +70,8 @@ export async function startUiServer(options) {
57
70
  current: name === config.current,
58
71
  keySaved: Boolean(item.apiKey),
59
72
  keyEnv: item.apiKeyEnv ?? null,
73
+ // 环境变量名只是"配置里写了",不代表真的设了值,所以把可用性也算出来
74
+ keyReady: Boolean(item.apiKey || (item.apiKeyEnv && process.env[item.apiKeyEnv])),
60
75
  })),
61
76
  workspace: config.workspace,
62
77
  platform: process.platform,
@@ -100,6 +115,36 @@ export async function startUiServer(options) {
100
115
  return json(res, 200, await info(session));
101
116
  if (path === '/api/sessions' && req.method === 'GET')
102
117
  return json(res, 200, { sessions: await listSessions(), current: session.id });
118
+ if (path === '/api/files' && req.method === 'GET') {
119
+ const target = url.searchParams.get('path') ?? '.';
120
+ try {
121
+ return json(res, 200, { entries: await listTree(config, target) });
122
+ }
123
+ catch (error) {
124
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
125
+ }
126
+ }
127
+ if (path === '/api/file' && req.method === 'GET') {
128
+ const requested = url.searchParams.get('path') ?? '';
129
+ try {
130
+ const context = sandboxContext(config);
131
+ const absolute = resolvePath(requested, context);
132
+ const info = await stat(absolute);
133
+ if (!info.isFile())
134
+ return json(res, 400, { error: '不是文件' });
135
+ if (info.size > MAX_FILE_BYTES)
136
+ return json(res, 200, { path: requested, tooLarge: true, bytes: info.size });
137
+ const ext = extname(absolute).toLowerCase();
138
+ if (!TEXT_EXT.has(ext) && ext !== '') {
139
+ return json(res, 200, { path: requested, binary: true, bytes: info.size });
140
+ }
141
+ const content = await readFile(absolute, 'utf8');
142
+ return json(res, 200, { path: requested, content, bytes: info.size, truncated: info.size >= MAX_FILE_BYTES });
143
+ }
144
+ catch (error) {
145
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
146
+ }
147
+ }
103
148
  if (path === '/api/session' && req.method === 'POST') {
104
149
  const body = await readJson(req);
105
150
  if (body.action === 'new') {
@@ -137,6 +182,31 @@ export async function startUiServer(options) {
137
182
  turn.session = session;
138
183
  return json(res, 200, { session: session.id, sessions: await listSessions(), info: await info(session) });
139
184
  }
185
+ if (path === '/api/complete' && req.method === 'GET') {
186
+ const line = url.searchParams.get('line') ?? '';
187
+ // 与 CLI 共用同一份补全实现
188
+ const files = completeFileReference(line, config.workspace, { limit: 12 });
189
+ if (files)
190
+ return json(res, 200, { kind: 'file', replace: files[1], candidates: files[0] });
191
+ const commandHits = completeCommand(line, commands.map((command) => command.name));
192
+ if (commandHits)
193
+ return json(res, 200, { kind: 'command', replace: commandHits[1], candidates: commandHits[0] });
194
+ return json(res, 200, { kind: 'none', replace: '', candidates: [] });
195
+ }
196
+ if (path === '/api/command' && req.method === 'POST') {
197
+ const body = await readJson(req);
198
+ return runCliCommand(body.input ?? '', res, session);
199
+ }
200
+ if (path === '/api/commands' && req.method === 'GET') {
201
+ return json(res, 200, {
202
+ commands: commands.map((command) => ({
203
+ name: command.name,
204
+ aliases: command.aliases ?? [],
205
+ usage: command.usage,
206
+ summary: command.summary,
207
+ })),
208
+ });
209
+ }
140
210
  if (path === '/api/action' && req.method === 'POST') {
141
211
  const body = await readJson(req);
142
212
  if (body.action === 'provider' && body.name && config.providers[body.name]) {
@@ -221,6 +291,98 @@ export async function startUiServer(options) {
221
291
  }
222
292
  return notFound(res);
223
293
  }
294
+ /**
295
+ * 执行 CLI 命令:命令表只有一份(cli/commands.ts),GUI 只是换了个输出通道。
296
+ * 输出逐行推给前端,行为和终端里一致。
297
+ */
298
+ async function runCliCommand(input, res, session) {
299
+ const trimmed = input.trim();
300
+ const [token = '', ...rest] = trimmed.replace(/^\//, '').split(/\s+/);
301
+ const command = findCommand(token);
302
+ if (!command) {
303
+ const suggestion = suggestCommand(token);
304
+ json(res, 200, {
305
+ ok: false,
306
+ output: suggestion
307
+ ? `未知命令 /${token},你是不是想用 /${suggestion}?`
308
+ : `未知命令 /${token}。输入 /help 看可用命令。`,
309
+ info: await info(session),
310
+ });
311
+ return;
312
+ }
313
+ const chunks = [];
314
+ const restore = captureOutput((text) => chunks.push(text));
315
+ let next = session;
316
+ let failure = '';
317
+ // provider 惰性创建:像 /help、/cwd 这类命令不需要 key 也能跑
318
+ let lazyProvider = null;
319
+ const needProvider = () => {
320
+ if (!lazyProvider) {
321
+ try {
322
+ lazyProvider = provider();
323
+ }
324
+ catch (error) {
325
+ throw new Error(`${error instanceof Error ? error.message : String(error)}\n请先在设置里填入 API Key。`);
326
+ }
327
+ }
328
+ return lazyProvider;
329
+ };
330
+ try {
331
+ await command.run(rest.join(' '), {
332
+ config,
333
+ store,
334
+ session,
335
+ get provider() {
336
+ return needProvider();
337
+ },
338
+ tools: options.tools,
339
+ ask: async (question) => {
340
+ throw new Error(`${question}(这个命令需要交互输入,请用界面上的对应功能)`);
341
+ },
342
+ confirm: async () => false,
343
+ setSession: (value) => {
344
+ next = value;
345
+ },
346
+ setSessionAndSave: async (value) => {
347
+ next = value;
348
+ await store.save(value);
349
+ },
350
+ setProvider: () => { },
351
+ setConfig: (value) => Object.assign(config, value),
352
+ setTools: () => { },
353
+ saveSession: async () => {
354
+ await store.save(next);
355
+ },
356
+ reloadProvider: () => { },
357
+ compactNow: async () => { },
358
+ runSetup: async () => {
359
+ throw new Error('配置向导请在终端运行 banana --setup');
360
+ },
361
+ undo: async () => backups(next.id).undo(),
362
+ clearBackups: async (sessionId) => {
363
+ await backups(sessionId).clear();
364
+ },
365
+ attachImage: async () => ({ ok: false, message: '请直接在界面里粘贴图片' }),
366
+ listImages: () => [],
367
+ clearImages: () => { },
368
+ clearScreen: () => { },
369
+ exit: () => { },
370
+ });
371
+ }
372
+ catch (error) {
373
+ failure = error instanceof Error ? error.message : String(error);
374
+ }
375
+ finally {
376
+ restore();
377
+ }
378
+ return json(res, 200, {
379
+ ok: !failure,
380
+ output: chunks.join(''),
381
+ error: failure,
382
+ info: await info(next),
383
+ sessions: await listSessions(),
384
+ });
385
+ }
224
386
  async function chat(body, res, session) {
225
387
  const controller = new AbortController();
226
388
  const active = { controller, pending: new Map(), session };
@@ -235,11 +397,28 @@ export async function startUiServer(options) {
235
397
  if (!res.writableEnded)
236
398
  res.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`);
237
399
  };
400
+ const finish = () => {
401
+ turn = null;
402
+ if (!res.writableEnded)
403
+ res.end();
404
+ };
238
405
  send('start', { session: session.id });
239
406
  res.on('close', () => {
240
407
  if (!res.writableEnded)
241
408
  controller.abort();
242
409
  });
410
+ // 没配 key 之类的问题要在流里说清楚,否则前端会一直转圈
411
+ const current = config.providers[config.current];
412
+ const hasKey = Boolean(current?.apiKey || (current?.apiKeyEnv && process.env[current.apiKeyEnv]));
413
+ if (!hasKey) {
414
+ send('error', {
415
+ message: `还没有配置 ${config.current} 的 API key。点右上角的模型按钮,在设置里填入 API Key 就能开始对话。`,
416
+ hint: '也可以在终端运行 banana --setup 走一遍配置向导。',
417
+ code: 'no_api_key',
418
+ });
419
+ finish();
420
+ return;
421
+ }
243
422
  const dataUrls = await Promise.all((body.images ?? []).map(async (file) => {
244
423
  const absolute = resolve(file);
245
424
  const type = IMAGE_TYPES[extname(absolute).toLowerCase()];
@@ -271,30 +450,49 @@ export async function startUiServer(options) {
271
450
  todos: session.todos,
272
451
  };
273
452
  const started = Date.now();
274
- const result = await runTurn({
275
- provider: provider(),
276
- tools: options.tools,
277
- permissions: config.permissions,
278
- system: config.systemPrompt,
279
- maxSteps: config.maxSteps,
280
- maxContextChars: config.maxContextChars,
281
- toolsWorkspace: config.workspace,
282
- allowOutsideWorkspace: config.allowOutsideWorkspace,
283
- signal: controller.signal,
284
- todos: session.todos,
285
- recordChange: (path, beforeContent) => recorder.record(path, beforeContent),
286
- onText: (chunk) => send('text', { chunk }),
287
- onReasoning: (chunk) => send('reasoning', { chunk }),
288
- onToolStart: (name, input) => send('tool_start', { name, detail: summarizeInput(input) }),
289
- onToolEnd: (name, toolResult) => send('tool_end', { name, ok: toolResult.ok, display: toolResult.display ?? '', error: toolResult.ok ? '' : toolResult.content.slice(0, 400) }),
290
- onNotice: (message) => send('status', { message }),
291
- onCompact: () => send('status', { message: '已压缩历史对话' }),
292
- onRetry: (info) => send('status', { message: `网络重试 ${info.attempt}:${info.reason}` }),
293
- approve,
294
- }, session.messages, body.message, dataUrls.filter((item) => Boolean(item)));
453
+ let result;
454
+ try {
455
+ result = await runTurn({
456
+ provider: provider(),
457
+ tools: options.tools,
458
+ permissions: config.permissions,
459
+ system: config.systemPrompt,
460
+ maxSteps: config.maxSteps,
461
+ maxContextChars: config.maxContextChars,
462
+ toolsWorkspace: config.workspace,
463
+ allowOutsideWorkspace: config.allowOutsideWorkspace,
464
+ signal: controller.signal,
465
+ todos: session.todos,
466
+ recordChange: (path, beforeContent) => recorder.record(path, beforeContent),
467
+ onText: (chunk) => send('text', { chunk }),
468
+ onReasoning: (chunk) => send('reasoning', { chunk }),
469
+ onToolStart: (name, input) => send('tool_start', { name, detail: summarizeInput(input) }),
470
+ onToolEnd: (name, toolResult) => send('tool_end', {
471
+ name,
472
+ ok: toolResult.ok,
473
+ display: toolResult.display ?? '',
474
+ error: toolResult.ok ? '' : toolResult.content.slice(0, 400),
475
+ // 详情面板用:命令输出 / 搜索结果等,最多 8KB
476
+ detail: toolResult.content.slice(0, 8000),
477
+ }),
478
+ onNotice: (message) => send('status', { message }),
479
+ onCompact: () => send('status', { message: '已压缩历史对话' }),
480
+ onRetry: (info) => send('status', { message: `网络重试 ${info.attempt}:${info.reason}` }),
481
+ approve,
482
+ }, session.messages, body.message, dataUrls.filter((item) => Boolean(item)));
483
+ }
484
+ catch (error) {
485
+ // 任何异常都要以 error 事件收尾,否则前端会一直等下去
486
+ send('error', {
487
+ message: error instanceof Error ? error.message : String(error),
488
+ code: 'turn_failed',
489
+ });
490
+ finish();
491
+ return;
492
+ }
295
493
  session.messages = result.messages;
296
494
  session.provider = config.current;
297
- session.model = provider().model;
495
+ session.model = session.model || provider().model;
298
496
  const pricing = resolvePricing(session.model, config.providers[config.current]?.pricing);
299
497
  const cost = estimateCost(result.usage, pricing);
300
498
  session.usage = {
@@ -304,7 +502,6 @@ export async function startUiServer(options) {
304
502
  };
305
503
  await store.save(session);
306
504
  await recorder.commit().catch(() => { });
307
- turn = null;
308
505
  send('done', {
309
506
  stopReason: result.stopReason,
310
507
  error: result.error ?? '',
@@ -315,7 +512,7 @@ export async function startUiServer(options) {
315
512
  todos: JSON.stringify(session.todos ?? []) !== todosBefore ? session.todos : undefined,
316
513
  info: await info(session),
317
514
  });
318
- res.end();
515
+ finish();
319
516
  }
320
517
  const port = await new Promise((resolvePromise, rejectPromise) => {
321
518
  server.once('error', rejectPromise);
@@ -337,6 +534,42 @@ function json(res, status, payload) {
337
534
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
338
535
  res.end(body);
339
536
  }
537
+ function sandboxContext(config) {
538
+ return {
539
+ workspace: config.workspace,
540
+ signal: new AbortController().signal,
541
+ approve: async () => false,
542
+ log: () => { },
543
+ allowOutsideWorkspace: false,
544
+ permissions: config.permissions,
545
+ todos: [],
546
+ };
547
+ }
548
+ /** 目录树:只返回一层子项,按需展开,避免大仓库一次拉全量。 */
549
+ async function listTree(config, requested) {
550
+ const { readdir, stat: statFile } = await import('node:fs/promises');
551
+ const root = resolvePath(requested || '.', sandboxContext(config));
552
+ const info = await statFile(root);
553
+ if (!info.isDirectory())
554
+ throw new Error(`${root} 不是目录`);
555
+ const items = await readdir(root, { withFileTypes: true });
556
+ const entries = [];
557
+ for (const item of items) {
558
+ if (item.name.startsWith('.'))
559
+ continue;
560
+ if (item.isDirectory() && SKIP_DIRS.has(item.name))
561
+ continue;
562
+ const rel = join(requested === '.' ? '' : requested, item.name).split('\\').join('/');
563
+ if (item.isDirectory()) {
564
+ entries.push({ name: item.name, path: rel, type: 'dir' });
565
+ }
566
+ else if (item.isFile()) {
567
+ entries.push({ name: item.name, path: rel, type: 'file' });
568
+ }
569
+ }
570
+ entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
571
+ return entries.slice(0, 500);
572
+ }
340
573
  function notFound(res) {
341
574
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
342
575
  res.end('not found');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentbanana/bananacli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A multi-provider AI agent for your terminal: file tools, shell, web search, persistent sessions, diff preview and undo",
5
5
  "type": "module",
6
6
  "engines": {
@@ -49,4 +49,4 @@
49
49
  "@types/node": "^22.20.5",
50
50
  "typescript": "^5.7.3"
51
51
  }
52
- }
52
+ }