@vincentbanana/bananacli 0.4.3 → 0.4.5

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.
@@ -1,10 +1,41 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, copyFileSync, cpSync } from 'node:fs';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  export function bananaHome() {
6
6
  const custom = process.env['BANANA_HOME']?.trim();
7
- return custom ? resolve(custom) : join(homedir(), '.banana');
7
+ if (custom)
8
+ return resolve(custom);
9
+ // Windows 默认落到 D 盘:配置、会话、备份、缓存都不写系统盘。
10
+ // 老版本的数据在 %USERPROFILE%\.banana,第一次启动时自动搬过去(只搬一次)。
11
+ if (process.platform === 'win32') {
12
+ for (const candidate of ['D:\\BananaCLI-data', 'D:\\BananaData']) {
13
+ try {
14
+ mkdirSync(candidate, { recursive: true });
15
+ const legacy = join(homedir(), '.banana');
16
+ if (existsSync(legacy)) {
17
+ if (!existsSync(join(candidate, 'config.json')) && existsSync(join(legacy, 'config.json'))) {
18
+ copyFileSync(join(legacy, 'config.json'), join(candidate, 'config.json'));
19
+ }
20
+ for (const name of ['sessions', 'backups']) {
21
+ if (!existsSync(join(candidate, name)) && existsSync(join(legacy, name))) {
22
+ try {
23
+ cpSync(join(legacy, name), join(candidate, name), { recursive: true });
24
+ }
25
+ catch {
26
+ // 单个会话复制失败不影响启动
27
+ }
28
+ }
29
+ }
30
+ }
31
+ return candidate;
32
+ }
33
+ catch {
34
+ // 没有 D 盘或没写权限,试下一个位置
35
+ }
36
+ }
37
+ }
38
+ return join(homedir(), '.banana');
8
39
  }
9
40
  export function configPath() {
10
41
  const custom = process.env['BANANA_CONFIG']?.trim();
@@ -81,6 +81,10 @@ export async function startUiServer(options) {
81
81
  '多半是内核版本太旧或安装不完整,重新安装即可:npm install -g @vincentbanana/bananacli\n');
82
82
  }
83
83
  let turn = null;
84
+ // 内嵌终端:不弹外部窗口,命令在当前工作区里执行,cd 之后的工作目录会被记住
85
+ let terminalCwd = null;
86
+ let terminalChild = null;
87
+ const terminalWorkdir = () => terminalCwd && existsSync(terminalCwd) ? terminalCwd : config.workspace;
84
88
  const provider = () => createProvider(config.current, config);
85
89
  const backups = (sessionId) => new BackupStore(join(bananaHome(), 'backups'), sessionId);
86
90
  const info = async (session) => ({
@@ -258,7 +262,13 @@ export async function startUiServer(options) {
258
262
  config.systemPrompt = body.value;
259
263
  }
260
264
  else if (body.action === 'workspace' && body.value) {
261
- config.workspace = resolve(body.value);
265
+ const next = resolve(String(body.value).trim());
266
+ if (!existsSync(next)) {
267
+ return json(res, 400, { error: `目录不存在:${next}` });
268
+ }
269
+ config.workspace = next;
270
+ // 换工作区后终端也要跟着换,否则 cd 还停在旧目录
271
+ terminalCwd = null;
262
272
  }
263
273
  const { saveConfig } = await import("../config/config.js");
264
274
  await saveConfig(config);
@@ -312,8 +322,108 @@ export async function startUiServer(options) {
312
322
  return json(res, 409, { error: '当前正在跑一轮对话' });
313
323
  return chat(body, res, session);
314
324
  }
325
+ if (path === '/api/terminal' && req.method === 'POST') {
326
+ const body = await readJson(req);
327
+ return runTerminal(String(body.command ?? ''), res);
328
+ }
315
329
  return notFound(res);
316
330
  }
331
+ /**
332
+ * 内嵌终端:把一条命令跑在工作区里,输出按行流式推给界面。
333
+ * 这里刻意不依赖 node-pty(原生编译在 Windows 上太重),用管道 + 目录标记实现,
334
+ * 足以跑 git/npm/测试这类常用命令;cd 之后目录会被记住,Ctrl+C 会杀掉子进程。
335
+ */
336
+ async function runTerminal(command, res) {
337
+ const cwd = terminalWorkdir();
338
+ const trimmed = command.trim();
339
+ if (!trimmed)
340
+ return json(res, 200, { cwd });
341
+ if (terminalChild)
342
+ return json(res, 409, { error: '上一条命令还在跑,先按停止。' });
343
+ res.writeHead(200, {
344
+ 'Content-Type': 'text/event-stream; charset=utf-8',
345
+ 'Cache-Control': 'no-cache, no-transform',
346
+ Connection: 'keep-alive',
347
+ 'X-Accel-Buffering': 'no',
348
+ });
349
+ const send = (type, payload = {}) => {
350
+ if (!res.writableEnded)
351
+ res.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`);
352
+ };
353
+ send('start', { cwd });
354
+ const isWin = process.platform === 'win32';
355
+ const marker = '__BANANA_CWD__';
356
+ // 命令跑完后回显一次当前目录,用来记住 cd 的结果
357
+ const wrapped = isWin ? `${trimmed} & echo ${marker}%CD%` : `${trimmed}\n echo ${marker}$(pwd)`;
358
+ let child;
359
+ try {
360
+ child = isWin
361
+ ? spawn('cmd.exe', ['/d', '/s', '/c', wrapped], { cwd, env: process.env, windowsHide: true })
362
+ : spawn('/bin/sh', ['-c', wrapped], { cwd, env: process.env, windowsHide: true });
363
+ }
364
+ catch (error) {
365
+ send('exit', { code: -1, error: error instanceof Error ? error.message : String(error), cwd });
366
+ res.end();
367
+ return;
368
+ }
369
+ terminalChild = child;
370
+ let tailOut = '';
371
+ let tailErr = '';
372
+ const pump = (text, stream) => {
373
+ let buffer = (stream === 'stdout' ? tailOut : tailErr) + text;
374
+ const lines = buffer.split('\n');
375
+ buffer = lines.pop() ?? '';
376
+ for (const raw of lines) {
377
+ const line = raw.replace(/\r$/, '');
378
+ if (line.startsWith(marker)) {
379
+ const next = line.slice(marker.length).trim();
380
+ if (next && existsSync(next))
381
+ terminalCwd = next;
382
+ continue;
383
+ }
384
+ send('out', { stream, data: line + '\n' });
385
+ }
386
+ if (stream === 'stdout')
387
+ tailOut = buffer;
388
+ else
389
+ tailErr = buffer;
390
+ };
391
+ const flush = () => {
392
+ for (const [buffer, stream] of [[tailOut, 'stdout'], [tailErr, 'stderr']]) {
393
+ if (!buffer)
394
+ continue;
395
+ if (!buffer.startsWith(marker))
396
+ send('out', { stream, data: buffer.replace(/\r$/, '') });
397
+ }
398
+ tailOut = '';
399
+ tailErr = '';
400
+ };
401
+ child.stdout?.setEncoding('utf8');
402
+ child.stderr?.setEncoding('utf8');
403
+ child.stdout?.on('data', (chunk) => pump(chunk, 'stdout'));
404
+ child.stderr?.on('data', (chunk) => pump(chunk, 'stderr'));
405
+ // 前端断开(按停止 / 关页面)时连同子进程一起收掉
406
+ res.on('close', () => {
407
+ if (terminalChild === child)
408
+ terminalChild = null;
409
+ if (!child.killed)
410
+ child.kill();
411
+ });
412
+ await new Promise((resolvePromise) => {
413
+ child.on('error', (error) => {
414
+ send('out', { stream: 'stderr', data: `启动失败:${error.message}\n` });
415
+ });
416
+ child.on('close', (code) => {
417
+ flush();
418
+ if (terminalChild === child)
419
+ terminalChild = null;
420
+ send('exit', { code: code ?? 0, cwd: terminalWorkdir() });
421
+ if (!res.writableEnded)
422
+ res.end();
423
+ resolvePromise();
424
+ });
425
+ });
426
+ }
317
427
  /**
318
428
  * 执行 CLI 命令:命令表只有一份(cli/commands.ts),GUI 只是换了个输出通道。
319
429
  * 输出逐行推给前端,行为和终端里一致。
package/dist/tools/web.js CHANGED
@@ -54,7 +54,7 @@ function unwrapDuckDuckGo(href) {
54
54
  return decodeURIComponent(match[1]);
55
55
  }
56
56
  async function searchTavily(query, count, ctx) {
57
- const apiKey = process.env['TAVILY_API_KEY'];
57
+ const apiKey = process.env['TAVILY_API_KEY'] ?? process.env['BANANA_TAVILY_KEY'];
58
58
  if (!apiKey)
59
59
  return null;
60
60
  const response = await fetch('https://api.tavily.com/search', {
@@ -73,10 +73,10 @@ async function searchTavily(query, count, ctx) {
73
73
  }));
74
74
  }
75
75
  async function searchBrave(query, count, ctx) {
76
- const apiKey = process.env['BRAVE_API_KEY'];
76
+ const apiKey = process.env['BRAVE_API_KEY'] ?? process.env['BANANA_BRAVE_KEY'];
77
77
  if (!apiKey)
78
78
  return null;
79
- const url = `https://api.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
79
+ const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
80
80
  const response = await fetch(url, {
81
81
  headers: { 'x-subscription-token': apiKey, accept: 'application/json' },
82
82
  signal: ctx.signal,
@@ -176,6 +176,43 @@ function parseDuckDuckGo(html, count) {
176
176
  }
177
177
  return hits;
178
178
  }
179
+ /** 归一化 URL:忽略大小写、www. 前缀、末尾斜杠和 #hash,用来去重。 */
180
+ export function normalizeUrl(href) {
181
+ try {
182
+ const url = new URL(href);
183
+ const host = url.hostname.replace(/^www\./i, '').toLowerCase();
184
+ const path = url.pathname.replace(/\/+$/, '');
185
+ return `${host}${path}${url.search}`;
186
+ }
187
+ catch {
188
+ return href.trim();
189
+ }
190
+ }
191
+ /** 搜索结果清洗:去掉站内跳转、重复链接和空条目。 */
192
+ export function sanitizeHits(hits, count) {
193
+ const seen = new Set();
194
+ const out = [];
195
+ for (const hit of hits) {
196
+ const url = (hit.url ?? '').trim();
197
+ if (!/^https?:\/\//i.test(url))
198
+ continue;
199
+ // 搜索引擎自己的搜索页 / 跳转页不是结果
200
+ if (/^https?:\/\/([^/]*\.)?(bing|duckduckgo|google|baidu)\.(com|cn)\//i.test(url) && /\/(search|ck\/a|url|link)\b/i.test(url))
201
+ continue;
202
+ const key = normalizeUrl(url);
203
+ if (seen.has(key))
204
+ continue;
205
+ seen.add(key);
206
+ out.push({
207
+ title: (hit.title ?? '').trim() || url,
208
+ url,
209
+ snippet: (hit.snippet ?? '').trim().slice(0, 500),
210
+ });
211
+ if (out.length >= count)
212
+ break;
213
+ }
214
+ return out;
215
+ }
179
216
  export const webSearchTool = {
180
217
  name: 'web_search',
181
218
  description: [
@@ -221,6 +258,7 @@ export const webSearchTool = {
221
258
  hits = await searchDuckDuckGo(query, count, ctx);
222
259
  if (problems.length)
223
260
  ctx.log(`搜索 API 降级: ${problems.join('; ')}`);
261
+ hits = sanitizeHits(hits, count);
224
262
  if (!hits.length)
225
263
  return fail(`没有找到 "${query}" 的结果,换个关键词试试。`);
226
264
  const body = hits
package/dist/ui/app.js CHANGED
@@ -510,7 +510,7 @@ async function send() {
510
510
  renderEmpty();
511
511
 
512
512
  const userMsg = document.createElement('article');
513
- userMsg.className = 'msg user';
513
+ userMsg.className = 'msg user enter';
514
514
  userMsg.innerHTML = '<div class="msg-role">你</div>';
515
515
  const userBody = document.createElement('div');
516
516
  userBody.className = 'msg-body';
@@ -519,7 +519,7 @@ async function send() {
519
519
  THREAD.appendChild(userMsg);
520
520
 
521
521
  const botMsg = document.createElement('article');
522
- botMsg.className = 'msg assistant';
522
+ botMsg.className = 'msg assistant enter';
523
523
  botMsg.innerHTML = '<div class="msg-role">Banana</div>';
524
524
  const toolWrap = document.createElement('div');
525
525
  toolWrap.className = 'tool-group';
@@ -822,7 +822,7 @@ async function runCommand(text) {
822
822
  THREAD.appendChild(userMsg);
823
823
 
824
824
  const botMsg = document.createElement('article');
825
- botMsg.className = 'msg assistant';
825
+ botMsg.className = 'msg assistant enter';
826
826
  const role = document.createElement('div');
827
827
  role.className = 'msg-role';
828
828
  role.textContent = '命令输出';
@@ -945,6 +945,45 @@ function renderSettings() {
945
945
  });
946
946
  body.appendChild(keyField);
947
947
 
948
+ // 工作区:换目录后所有文件工具和命令都跟着换,所以放显眼位置
949
+ const workspaceField = document.createElement('div');
950
+ workspaceField.className = 'field';
951
+ workspaceField.innerHTML =
952
+ '<label>工作区</label>' +
953
+ `<div class="row"><input value="${escapeHtml(info.workspace ?? '')}" spellcheck="false" /><button class="btn">切换</button></div>` +
954
+ '<div class="hint">Banana 只能读写这个目录下的文件,命令也在这里执行。绝对路径,例如 D:\\project。</div>';
955
+ workspaceField.querySelector('button').addEventListener('click', async () => {
956
+ const input = workspaceField.querySelector('input');
957
+ const value = input.value.trim();
958
+ if (!value) {
959
+ toast('请填写目录路径');
960
+ return;
961
+ }
962
+ try {
963
+ await post('action', { action: 'workspace', value });
964
+ await refreshSettings();
965
+ renderHeader();
966
+ toast('工作区已切换到 ' + value);
967
+ } catch (error) {
968
+ toast('切换失败:' + (error?.message ?? error));
969
+ }
970
+ });
971
+ workspaceField.querySelector('input').addEventListener('keydown', (event) => {
972
+ if (event.key === 'Enter') workspaceField.querySelector('button').click();
973
+ });
974
+ body.appendChild(workspaceField);
975
+
976
+ const terminalField = document.createElement('div');
977
+ terminalField.className = 'field';
978
+ terminalField.innerHTML =
979
+ '<label>终端</label><div class="row"><button class="btn">在界面里打开终端</button></div>' +
980
+ '<div class="hint">内嵌在对话下方,不弹外部窗口;直接在当前工作区执行命令,cd 会记住目录。</div>';
981
+ terminalField.querySelector('button').addEventListener('click', () => {
982
+ closeSettings();
983
+ termOpen();
984
+ });
985
+ body.appendChild(terminalField);
986
+
948
987
  const permField = document.createElement('div');
949
988
  permField.className = 'field';
950
989
  permField.innerHTML = '<label>权限</label>';
@@ -1155,6 +1194,199 @@ function closeSettings() {
1155
1194
  setTimeout(() => { if ($('settingsSheet').hidden) $('settingsBody').innerHTML = ''; }, 240);
1156
1195
  }
1157
1196
 
1197
+ // ---------------- 内嵌终端 ----------------
1198
+ const term = { open: false, running: false, history: [], cursor: 0, controller: null, size: 0 };
1199
+
1200
+ const ANSI_COLORS = {
1201
+ 30: '#6b6b72', 31: '#b4453a', 32: '#2f7d50', 33: '#966d1f',
1202
+ 34: '#3b63b0', 35: '#7b4d9e', 36: '#257f80', 37: '#55555c',
1203
+ 90: '#8c8c93', 91: '#c9564a', 92: '#3f9a66', 93: '#b0852d',
1204
+ 94: '#527cd1', 95: '#9a67c4', 96: '#2f9a9a', 97: '#16161a',
1205
+ };
1206
+
1207
+ /** 把 ANSI 上色转成 span;其他控制序列直接丢掉,避免满屏乱码。 */
1208
+ function ansiToHtml(input) {
1209
+ const text = input.replace(/\x1b\][^\x07]*\x07/g, '').replace(/\x1b\[[0-9;?]*[A-LN-Za-ln-z]/g, '');
1210
+ let out = '';
1211
+ let last = 0;
1212
+ let color = '';
1213
+ let bold = false;
1214
+ let dim = false;
1215
+ const style = () =>
1216
+ color || bold || dim
1217
+ ? `color:${color || 'inherit'};font-weight:${bold ? 600 : 400};opacity:${dim ? 0.7 : 1}`
1218
+ : '';
1219
+ for (const match of text.matchAll(/\x1b\[([0-9;]*)m/g)) {
1220
+ out += `<span style="${style()}">${escapeHtml(text.slice(last, match.index))}</span>`;
1221
+ last = match.index + match[0].length;
1222
+ for (const code of (match[1] || '0').split(';').filter(Boolean).map(Number)) {
1223
+ if (code === 0) { color = ''; bold = false; dim = false; }
1224
+ else if (code === 1) bold = true;
1225
+ else if (code === 2) dim = true;
1226
+ else if (code === 22) { bold = false; dim = false; }
1227
+ else if (code === 39) color = '';
1228
+ else if (ANSI_COLORS[code]) color = ANSI_COLORS[code];
1229
+ }
1230
+ }
1231
+ out += `<span style="${style()}">${escapeHtml(text.slice(last))}</span>`;
1232
+ return out;
1233
+ }
1234
+
1235
+ function termWrite(text, kind = 'out') {
1236
+ if (!text) return;
1237
+ const body = $('termBody');
1238
+ const chunk = document.createElement('span');
1239
+ chunk.className = 'term-chunk' + (kind === 'stderr' ? ' stderr' : kind === 'input' ? ' input' : '');
1240
+ chunk.innerHTML = ansiToHtml(text);
1241
+ body.appendChild(chunk);
1242
+ term.size += text.length;
1243
+ if (term.size > 240_000) {
1244
+ body.textContent = '';
1245
+ term.size = 0;
1246
+ termWrite('输出太多,已清空旧内容。\n', 'input');
1247
+ }
1248
+ body.scrollTop = body.scrollHeight;
1249
+ }
1250
+
1251
+ function termOpen() {
1252
+ const panel = $('terminalPanel');
1253
+ if (!term.open) {
1254
+ term.open = true;
1255
+ panel.hidden = false;
1256
+ requestAnimationFrame(() => panel.classList.add('show'));
1257
+ if (!term.size) {
1258
+ termWrite('在当前工作区执行命令;cd 会记住目录,clear 清屏。\n', 'input');
1259
+ }
1260
+ }
1261
+ $('termCwd').textContent = state.info?.workspace ?? '';
1262
+ $('termInput').focus();
1263
+ }
1264
+
1265
+ function termClose() {
1266
+ term.open = false;
1267
+ const panel = $('terminalPanel');
1268
+ panel.classList.remove('show');
1269
+ panel.classList.add('closing');
1270
+ setTimeout(() => {
1271
+ if (term.open) return;
1272
+ panel.hidden = true;
1273
+ panel.classList.remove('closing');
1274
+ }, 160);
1275
+ }
1276
+
1277
+ function termToggle() {
1278
+ if (term.open) termClose();
1279
+ else termOpen();
1280
+ }
1281
+
1282
+ async function termRun(command) {
1283
+ const cmd = command.trim();
1284
+ if (!cmd || term.running) return;
1285
+ if (/^(clear|cls)$/i.test(cmd)) {
1286
+ $('termBody').textContent = '';
1287
+ term.size = 0;
1288
+ return;
1289
+ }
1290
+ term.running = true;
1291
+ $('termStop').hidden = false;
1292
+ $('termSend').disabled = true;
1293
+ termWrite(`\n> ${cmd}\n`, 'input');
1294
+
1295
+ const history = term.history;
1296
+ if (history[history.length - 1] !== cmd) history.push(cmd);
1297
+ term.cursor = history.length;
1298
+
1299
+ term.controller = new AbortController();
1300
+ try {
1301
+ const response = await fetch(API + 'terminal', {
1302
+ method: 'POST',
1303
+ headers: { 'content-type': 'application/json' },
1304
+ body: JSON.stringify({ command: cmd }),
1305
+ signal: term.controller.signal,
1306
+ });
1307
+ if (!response.ok) {
1308
+ const payload = await response.json().catch(() => ({}));
1309
+ termWrite((payload.error ?? `终端启动失败(${response.status})`) + '\n', 'stderr');
1310
+ return;
1311
+ }
1312
+ const reader = response.body.getReader();
1313
+ const decoder = new TextDecoder();
1314
+ let buffer = '';
1315
+ for (;;) {
1316
+ const { done, value } = await reader.read();
1317
+ if (done) break;
1318
+ buffer += decoder.decode(value, { stream: true });
1319
+ let index;
1320
+ while ((index = buffer.indexOf('\n\n')) >= 0) {
1321
+ const raw = buffer.slice(0, index);
1322
+ buffer = buffer.slice(index + 2);
1323
+ const line = raw.startsWith('data: ') ? raw.slice(6) : raw;
1324
+ if (!line) continue;
1325
+ let event;
1326
+ try { event = JSON.parse(line); } catch { continue; }
1327
+ if (event.type === 'out') termWrite(event.data, event.stream);
1328
+ else if (event.type === 'start' || event.type === 'exit') {
1329
+ if (event.cwd) $('termCwd').textContent = event.cwd;
1330
+ if (event.error) termWrite(event.error + '\n', 'stderr');
1331
+ if (event.type === 'exit' && event.code) termWrite(`进程退出,代码 ${event.code}\n`, 'stderr');
1332
+ }
1333
+ }
1334
+ }
1335
+ } catch (error) {
1336
+ if (error?.name !== 'AbortError') termWrite(`终端错误:${error?.message ?? error}\n`, 'stderr');
1337
+ else termWrite('已停止。\n', 'input');
1338
+ } finally {
1339
+ term.running = false;
1340
+ term.controller = null;
1341
+ $('termStop').hidden = true;
1342
+ $('termSend').disabled = false;
1343
+ if (term.open) $('termInput').focus();
1344
+ }
1345
+ }
1346
+
1347
+ function bindTerminal() {
1348
+ $('termBtn').innerHTML = svg('terminal');
1349
+ $('termStop').innerHTML = svg('stop');
1350
+ $('termClear').innerHTML = svg('trash');
1351
+ $('termClose').innerHTML = svg('x');
1352
+ $('termBtn').addEventListener('click', termToggle);
1353
+ $('termClose').addEventListener('click', termClose);
1354
+ $('termClear').addEventListener('click', () => {
1355
+ $('termBody').textContent = '';
1356
+ term.size = 0;
1357
+ });
1358
+ $('termStop').addEventListener('click', () => term.controller?.abort());
1359
+ $('termForm').addEventListener('submit', (event) => {
1360
+ event.preventDefault();
1361
+ const input = $('termInput');
1362
+ const value = input.value;
1363
+ input.value = '';
1364
+ termRun(value);
1365
+ });
1366
+ $('termInput').addEventListener('keydown', (event) => {
1367
+ if (event.key === 'ArrowUp') {
1368
+ event.preventDefault();
1369
+ if (!term.history.length) return;
1370
+ term.cursor = Math.max(0, term.cursor - 1);
1371
+ $('termInput').value = term.history[term.cursor] ?? '';
1372
+ } else if (event.key === 'ArrowDown') {
1373
+ event.preventDefault();
1374
+ term.cursor = Math.min(term.history.length, term.cursor + 1);
1375
+ $('termInput').value = term.history[term.cursor] ?? '';
1376
+ } else if (event.key === 'Escape') {
1377
+ termClose();
1378
+ }
1379
+ });
1380
+ window.addEventListener('keydown', (event) => {
1381
+ if (event.ctrlKey && event.key === '`') {
1382
+ event.preventDefault();
1383
+ termToggle();
1384
+ }
1385
+ });
1386
+ // 新建 / 切换会话时同步一下工作区显示
1387
+ $('termCwd').textContent = state.info?.workspace ?? '';
1388
+ }
1389
+
1158
1390
  function bind() {
1159
1391
  $('newChat').innerHTML = svg('plus');
1160
1392
  $('sendBtn').innerHTML = svg('send');
@@ -1163,6 +1395,7 @@ function bind() {
1163
1395
  $('closeSettings').innerHTML = svg('x');
1164
1396
  $('filesBtn').innerHTML = svg('panel');
1165
1397
  $('closeFiles').innerHTML = svg('x');
1398
+ bindTerminal();
1166
1399
  hydrate();
1167
1400
  loadTheme();
1168
1401
 
@@ -43,6 +43,7 @@
43
43
  <span data-icon="chip"></span><span id="modelLabel">—</span>
44
44
  </button>
45
45
  <button class="icon-btn" id="filesBtn" title="项目文件" aria-label="项目文件"></button>
46
+ <button class="icon-btn" id="termBtn" title="终端(Ctrl+`)" aria-label="终端"></button>
46
47
  <button class="icon-btn" id="themeBtn" title="切换主题" aria-label="切换主题"></button>
47
48
  <button class="icon-btn" id="abortBtn" title="停止" aria-label="停止" hidden></button>
48
49
  </div>
@@ -50,6 +51,25 @@
50
51
 
51
52
  <div class="thread" id="thread"></div>
52
53
 
54
+ <section class="terminal" id="terminalPanel" hidden>
55
+ <header class="term-head">
56
+ <span class="term-dot" aria-hidden="true"></span>
57
+ <span class="term-title">终端</span>
58
+ <span class="term-cwd" id="termCwd"></span>
59
+ <div class="term-actions">
60
+ <button class="icon-btn" id="termStop" title="停止" aria-label="停止" hidden></button>
61
+ <button class="icon-btn" id="termClear" title="清空" aria-label="清空"></button>
62
+ <button class="icon-btn" id="termClose" title="收起(Ctrl+`)" aria-label="收起"></button>
63
+ </div>
64
+ </header>
65
+ <div class="term-body" id="termBody" aria-live="polite"></div>
66
+ <form class="term-row" id="termForm">
67
+ <span class="term-prompt" aria-hidden="true">&gt;</span>
68
+ <input id="termInput" autocomplete="off" spellcheck="false" placeholder="输入命令,Enter 执行,↑↓ 翻历史" />
69
+ <button class="btn tiny" type="submit" id="termSend">运行</button>
70
+ </form>
71
+ </section>
72
+
53
73
  <div class="composer-wrap">
54
74
  <div class="attachments" id="attachments" hidden></div>
55
75
  <form class="composer" id="composer">
package/dist/ui/style.css CHANGED
@@ -13,6 +13,8 @@
13
13
  --radius: 12px;
14
14
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
15
15
  --ease-in: cubic-bezier(0.4, 0, 1, 1);
16
+ /* 带一点点回弹,用于按压力气和弹出层,别过量 */
17
+ --spring: cubic-bezier(0.34, 1.56, 0.64, 1);
16
18
  --dur: 220ms;
17
19
  --sidebar: 274px;
18
20
  --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
@@ -262,7 +264,48 @@ button, input, textarea, select { font: inherit; color: inherit; }
262
264
  contain-intrinsic-size: auto 120px;
263
265
  }
264
266
 
265
- /* 切换会话时给一点即时反馈,避免点了像没反应 */
267
+ /* ---------- 轻微回弹 ----------
268
+ 只给「按下去」和「弹出来」加一点过冲,不影响滚动手感。 */
269
+ .btn,
270
+ .icon-btn,
271
+ .send-btn,
272
+ .s-act,
273
+ .popover button,
274
+ .session-item {
275
+ transition:
276
+ background 140ms var(--ease),
277
+ color 140ms var(--ease),
278
+ border-color 140ms var(--ease),
279
+ transform 180ms var(--spring);
280
+ }
281
+
282
+ .btn:active,
283
+ .popover button:active,
284
+ .s-act:active {
285
+ transform: scale(0.95);
286
+ }
287
+
288
+ .btn:hover,
289
+ .icon-btn:hover {
290
+ transform: translateY(-1px);
291
+ }
292
+
293
+ .send-btn:not(:disabled):hover {
294
+ transform: translateY(-1px) scale(1.04);
295
+ }
296
+
297
+ .send-btn:not(:disabled):active {
298
+ transform: scale(0.93);
299
+ }
300
+
301
+ .session-item:active {
302
+ transform: scale(0.985);
303
+ }
304
+
305
+ /* 新消息轻轻上浮,只有真正新产生的消息走这条 */
306
+ .msg.enter {
307
+ animation: rise 240ms var(--spring) both;
308
+ }
266
309
  .thread { transition: opacity 120ms var(--ease); }
267
310
  body.switching .thread { opacity: 0.45; }
268
311
  body.switching { cursor: progress; }
@@ -520,7 +563,7 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
520
563
  box-shadow: var(--shadow-lg);
521
564
  display: flex;
522
565
  flex-direction: column;
523
- animation: slide var(--dur) var(--ease) both;
566
+ animation: slide 250ms var(--spring) both;
524
567
  will-change: transform;
525
568
  }
526
569
  @keyframes slide {
@@ -774,7 +817,8 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
774
817
  border-radius: 10px;
775
818
  background: var(--bg);
776
819
  box-shadow: var(--shadow-lg);
777
- animation: pop 160ms var(--ease) both;
820
+ animation: pop 190ms var(--spring) both;
821
+ transform-origin: top right;
778
822
  }
779
823
  .popover button {
780
824
  display: block;
@@ -889,4 +933,116 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
889
933
  .only-narrow { display: inline-grid; }
890
934
  .msg { padding: 0 16px; }
891
935
  .composer-wrap { padding: 8px 14px 12px; }
892
- }
936
+ }
937
+
938
+ /* ---------- 内嵌终端 ---------- */
939
+ .terminal {
940
+ display: flex;
941
+ flex-direction: column;
942
+ height: 38vh;
943
+ min-height: 170px;
944
+ border-top: 1px solid var(--line);
945
+ background: var(--panel);
946
+ animation: termIn 220ms var(--spring) both;
947
+ }
948
+ .terminal[hidden] { display: none; }
949
+ .terminal.closing { animation: termOut 150ms var(--ease-in) both; }
950
+ @keyframes termIn { from { opacity: 0; transform: translateY(14px) scale(0.995); } to { opacity: 1; transform: none; } }
951
+ @keyframes termOut { to { opacity: 0; transform: translateY(10px); } }
952
+
953
+ .term-head {
954
+ display: flex;
955
+ align-items: center;
956
+ gap: 8px;
957
+ height: 38px;
958
+ padding: 0 10px 0 14px;
959
+ border-bottom: 1px solid var(--line);
960
+ font-size: 12px;
961
+ color: var(--text-3);
962
+ }
963
+ .term-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.55; }
964
+ .term-title { font-weight: 600; color: var(--text-2); letter-spacing: 0.02em; }
965
+ .term-cwd { font-family: var(--mono); font-size: 11.5px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
966
+ .term-actions { margin-left: auto; display: flex; gap: 2px; align-items: center; }
967
+
968
+ .term-body {
969
+ flex: 1;
970
+ overflow-y: auto;
971
+ padding: 10px 14px;
972
+ font-family: var(--mono);
973
+ font-size: 12.4px;
974
+ line-height: 1.6;
975
+ color: var(--text-2);
976
+ white-space: pre-wrap;
977
+ overflow-wrap: anywhere;
978
+ scrollbar-gutter: stable;
979
+ }
980
+ .term-chunk.stderr { color: #a8493d; }
981
+ .term-chunk.input { color: var(--text-3); }
982
+
983
+ .term-row {
984
+ display: flex;
985
+ align-items: center;
986
+ gap: 8px;
987
+ padding: 8px 12px;
988
+ border-top: 1px solid var(--line);
989
+ background: var(--bg);
990
+ }
991
+ .term-prompt { font-family: var(--mono); color: var(--text-3); font-size: 13px; }
992
+ .term-row input {
993
+ flex: 1;
994
+ min-width: 0;
995
+ height: 30px;
996
+ border: 0;
997
+ outline: none;
998
+ background: transparent;
999
+ color: var(--text);
1000
+ font-family: var(--mono);
1001
+ font-size: 12.8px;
1002
+ }
1003
+ .term-row input:focus { transform: none; box-shadow: none; }
1004
+
1005
+ /* ---------- 全局轻微弹力 ----------
1006
+ 所有可点组件统一手感:按下回缩、松开弹回;幅度很小,不抢内容。 */
1007
+ button,
1008
+ .session-item,
1009
+ .popover button,
1010
+ .files-row,
1011
+ .palette-item,
1012
+ .suggest button,
1013
+ .tool-head,
1014
+ summary {
1015
+ transition:
1016
+ transform 200ms var(--spring),
1017
+ background-color 140ms var(--ease),
1018
+ color 140ms var(--ease),
1019
+ border-color 160ms var(--ease),
1020
+ box-shadow 160ms var(--ease),
1021
+ opacity 140ms var(--ease);
1022
+ }
1023
+ button:active,
1024
+ .session-item:active,
1025
+ .files-row:active,
1026
+ .palette-item:active {
1027
+ transform: scale(0.96);
1028
+ }
1029
+ @media (hover: hover) {
1030
+ button:hover,
1031
+ .files-row:hover,
1032
+ .palette-item:hover {
1033
+ transform: translateY(-1px);
1034
+ }
1035
+ }
1036
+
1037
+ /* 输入类控件聚焦时轻轻抬起 */
1038
+ .field input,
1039
+ .field select,
1040
+ .term-row input,
1041
+ .composer textarea {
1042
+ transition: transform 180ms var(--spring), border-color 160ms var(--ease), box-shadow 160ms var(--ease);
1043
+ }
1044
+ .field input:focus,
1045
+ .field select:focus,
1046
+ .composer textarea:focus {
1047
+ transform: translateY(-1px);
1048
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentbanana/bananacli",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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": {