@vincentbanana/bananacli 0.4.4 → 0.4.6

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) => ({
@@ -263,23 +267,8 @@ export async function startUiServer(options) {
263
267
  return json(res, 400, { error: `目录不存在:${next}` });
264
268
  }
265
269
  config.workspace = next;
266
- }
267
- else if (body.action === 'terminal') {
268
- // 在系统终端里打开当前工作区(GUI 跑在浏览器里,开终端必须借系统命令)
269
- const dir = config.workspace;
270
- if (!existsSync(dir))
271
- return json(res, 400, { error: `目录不存在:${dir}` });
272
- try {
273
- const target = process.platform === 'win32'
274
- ? { file: 'cmd', args: ['/c', 'start', '', 'cmd', '/k', 'cd', '/d', dir] }
275
- : process.platform === 'darwin'
276
- ? { file: 'open', args: ['-a', 'Terminal', dir] }
277
- : { file: 'x-terminal-emulator', args: ['--working-directory', dir] };
278
- spawn(target.file, target.args, { detached: true, stdio: 'ignore' }).unref();
279
- }
280
- catch (error) {
281
- return json(res, 500, { error: `打开终端失败:${error instanceof Error ? error.message : String(error)}` });
282
- }
270
+ // 换工作区后终端也要跟着换,否则 cd 还停在旧目录
271
+ terminalCwd = null;
283
272
  }
284
273
  const { saveConfig } = await import("../config/config.js");
285
274
  await saveConfig(config);
@@ -333,8 +322,108 @@ export async function startUiServer(options) {
333
322
  return json(res, 409, { error: '当前正在跑一轮对话' });
334
323
  return chat(body, res, session);
335
324
  }
325
+ if (path === '/api/terminal' && req.method === 'POST') {
326
+ const body = await readJson(req);
327
+ return runTerminal(String(body.command ?? ''), res);
328
+ }
336
329
  return notFound(res);
337
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
+ }
338
427
  /**
339
428
  * 执行 CLI 命令:命令表只有一份(cli/commands.ts),GUI 只是换了个输出通道。
340
429
  * 输出逐行推给前端,行为和终端里一致。
@@ -106,7 +106,8 @@ export class SessionStore {
106
106
  /** 返回最近一次更新且有内容的会话 id。 */
107
107
  async lastActive() {
108
108
  const sessions = await this.list();
109
- const withMessages = sessions.find((item) => item.turns > 0) ?? sessions[0];
110
- return withMessages?.id ?? null;
109
+ // 必须用「最近更新的会话」,包括刚建好的空会话。
110
+ // 以前写的是「优先找有消息的会话」,结果新建对话后第一次发消息会被悄悄塞进上一个旧对话。
111
+ return sessions[0]?.id ?? null;
111
112
  }
112
113
  }
package/dist/ui/app.js CHANGED
@@ -976,15 +976,11 @@ function renderSettings() {
976
976
  const terminalField = document.createElement('div');
977
977
  terminalField.className = 'field';
978
978
  terminalField.innerHTML =
979
- '<label>终端</label><div class="row"><button class="btn">在工作区打开终端</button></div>' +
980
- '<div class="hint">会弹出一个系统终端窗口,已切到当前工作区,方便你手动跑命令。</div>';
981
- terminalField.querySelector('button').addEventListener('click', async () => {
982
- try {
983
- await post('action', { action: 'terminal' });
984
- toast('终端已打开');
985
- } catch (error) {
986
- toast('打开终端失败:' + (error?.message ?? error));
987
- }
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();
988
984
  });
989
985
  body.appendChild(terminalField);
990
986
 
@@ -1198,6 +1194,199 @@ function closeSettings() {
1198
1194
  setTimeout(() => { if ($('settingsSheet').hidden) $('settingsBody').innerHTML = ''; }, 240);
1199
1195
  }
1200
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
+
1201
1390
  function bind() {
1202
1391
  $('newChat').innerHTML = svg('plus');
1203
1392
  $('sendBtn').innerHTML = svg('send');
@@ -1206,6 +1395,7 @@ function bind() {
1206
1395
  $('closeSettings').innerHTML = svg('x');
1207
1396
  $('filesBtn').innerHTML = svg('panel');
1208
1397
  $('closeFiles').innerHTML = svg('x');
1398
+ bindTerminal();
1209
1399
  hydrate();
1210
1400
  loadTheme();
1211
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
@@ -933,4 +933,116 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
933
933
  .only-narrow { display: inline-grid; }
934
934
  .msg { padding: 0 16px; }
935
935
  .composer-wrap { padding: 8px 14px 12px; }
936
- }
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.4",
3
+ "version": "0.4.6",
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": {