jinzd-ai-cli 0.4.30 → 0.4.32

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.
@@ -9,7 +9,7 @@ import {
9
9
  SUBAGENT_DEFAULT_MAX_ROUNDS,
10
10
  SUBAGENT_MAX_ROUNDS_LIMIT,
11
11
  runTestsTool
12
- } from "./chunk-4WGZEAGJ.js";
12
+ } from "./chunk-LY2B3WHN.js";
13
13
 
14
14
  // src/tools/builtin/bash.ts
15
15
  import { execSync } from "child_process";
@@ -2948,7 +2948,8 @@ var ENV_KEY_MAP = {
2948
2948
  kimi: "AICLI_API_KEY_KIMI",
2949
2949
  openai: "AICLI_API_KEY_OPENAI",
2950
2950
  openrouter: "AICLI_API_KEY_OPENROUTER",
2951
- "google-search": "AICLI_API_KEY_GOOGLESEARCH"
2951
+ "google-search": "AICLI_API_KEY_GOOGLESEARCH",
2952
+ ollama: "AICLI_API_KEY_OLLAMA"
2952
2953
  };
2953
2954
  var EnvLoader = class {
2954
2955
  /**
@@ -6,7 +6,7 @@ import { platform } from "os";
6
6
  import chalk from "chalk";
7
7
 
8
8
  // src/core/constants.ts
9
- var VERSION = "0.4.30";
9
+ var VERSION = "0.4.31";
10
10
  var APP_NAME = "ai-cli";
11
11
  var CONFIG_DIR_NAME = ".aicli";
12
12
  var CONFIG_FILE_NAME = "config.json";
@@ -7,7 +7,7 @@ import {
7
7
  ProviderNotFoundError,
8
8
  RateLimitError,
9
9
  schemaToJsonSchema
10
- } from "./chunk-4EOFMJCR.js";
10
+ } from "./chunk-C2E47GOR.js";
11
11
  import {
12
12
  APP_NAME,
13
13
  CONFIG_DIR_NAME,
@@ -20,7 +20,7 @@ import {
20
20
  MCP_TOOL_PREFIX,
21
21
  PLUGINS_DIR_NAME,
22
22
  VERSION
23
- } from "./chunk-4WGZEAGJ.js";
23
+ } from "./chunk-LY2B3WHN.js";
24
24
 
25
25
  // src/config/config-manager.ts
26
26
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
@@ -2095,6 +2095,103 @@ var OpenRouterProvider = class extends OpenAICompatibleProvider {
2095
2095
  };
2096
2096
  };
2097
2097
 
2098
+ // src/providers/ollama.ts
2099
+ var OllamaProvider = class extends OpenAICompatibleProvider {
2100
+ defaultBaseUrl = "http://localhost:11434/v1";
2101
+ defaultTimeout = 12e4;
2102
+ // 本地推理可能较慢,默认 2 分钟
2103
+ /** 动态模型列表,initialize 时从 Ollama 获取 */
2104
+ dynamicModels = [];
2105
+ info = {
2106
+ id: "ollama",
2107
+ displayName: "Ollama (Local)",
2108
+ defaultModel: "",
2109
+ // 动态设置
2110
+ apiKeyEnvVar: "",
2111
+ requiresApiKey: false,
2112
+ baseUrl: this.defaultBaseUrl,
2113
+ models: []
2114
+ };
2115
+ async initialize(apiKey, options) {
2116
+ const baseUrl = options?.baseUrl ?? this.defaultBaseUrl;
2117
+ const ollamaHost = baseUrl.replace(/\/v1\/?$/, "");
2118
+ this.dynamicModels = await this.fetchModels(ollamaHost);
2119
+ if (this.dynamicModels.length === 0) {
2120
+ throw new Error("Ollama is running but no models are installed. Run `ollama pull <model>` to install one.");
2121
+ }
2122
+ const preferred = ["llama3.1", "llama3", "qwen2.5", "qwen2", "deepseek-r1", "mistral", "gemma2"];
2123
+ const defaultModel = this.dynamicModels.find(
2124
+ (m) => preferred.some((p) => m.id.startsWith(p))
2125
+ )?.id ?? this.dynamicModels[0].id;
2126
+ Object.assign(this.info, {
2127
+ defaultModel,
2128
+ baseUrl,
2129
+ models: this.dynamicModels
2130
+ });
2131
+ await super.initialize(apiKey || "ollama", { ...options, baseUrl });
2132
+ }
2133
+ /**
2134
+ * 从 Ollama /api/tags 获取本地模型列表。
2135
+ * 如果 Ollama 未运行则抛出错误。
2136
+ */
2137
+ async fetchModels(ollamaHost) {
2138
+ const url = `${ollamaHost}/api/tags`;
2139
+ let response;
2140
+ try {
2141
+ response = await fetch(url, { signal: AbortSignal.timeout(5e3) });
2142
+ } catch {
2143
+ throw new Error(
2144
+ `Cannot connect to Ollama at ${ollamaHost}. Make sure Ollama is running (https://ollama.com).`
2145
+ );
2146
+ }
2147
+ if (!response.ok) {
2148
+ throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
2149
+ }
2150
+ const data = await response.json();
2151
+ if (!data.models || data.models.length === 0) {
2152
+ return [];
2153
+ }
2154
+ return data.models.map((m) => {
2155
+ const paramSize = m.details?.parameter_size ?? "";
2156
+ const sizeGB = (m.size / 1e9).toFixed(1);
2157
+ return {
2158
+ id: m.name,
2159
+ displayName: `${m.name} (${paramSize || sizeGB + "GB"})`,
2160
+ contextWindow: this.estimateContextWindow(m.name),
2161
+ supportsStreaming: true,
2162
+ supportsThinking: false
2163
+ };
2164
+ });
2165
+ }
2166
+ /** 根据模型名估算上下文窗口(Ollama 模型元数据不含此信息) */
2167
+ estimateContextWindow(modelName) {
2168
+ const name = modelName.toLowerCase();
2169
+ if (name.includes("llama3") || name.includes("llama-3")) return 131072;
2170
+ if (name.includes("qwen2.5") || name.includes("qwen3")) return 131072;
2171
+ if (name.includes("qwen2") || name.includes("qwen-2")) return 32768;
2172
+ if (name.includes("deepseek")) return 65536;
2173
+ if (name.includes("gemma2") || name.includes("gemma-2")) return 8192;
2174
+ if (name.includes("mistral")) return 32768;
2175
+ if (name.includes("phi")) return 16384;
2176
+ if (name.includes("codellama") || name.includes("code-llama")) return 16384;
2177
+ return 8192;
2178
+ }
2179
+ /** 动态返回当前 Ollama 安装的模型 */
2180
+ async listModels() {
2181
+ return this.dynamicModels.length > 0 ? this.dynamicModels : this.info.models;
2182
+ }
2183
+ /** Ollama 无需验证 API Key,只检查服务是否可达 */
2184
+ async validateApiKey() {
2185
+ try {
2186
+ const ollamaHost = this.info.baseUrl.replace(/\/v1\/?$/, "");
2187
+ await fetch(`${ollamaHost}/api/tags`, { signal: AbortSignal.timeout(5e3) });
2188
+ return true;
2189
+ } catch {
2190
+ return false;
2191
+ }
2192
+ }
2193
+ };
2194
+
2098
2195
  // src/providers/custom.ts
2099
2196
  var CustomProvider = class extends OpenAICompatibleProvider {
2100
2197
  defaultBaseUrl;
@@ -2137,7 +2234,8 @@ var BUILT_IN_PROVIDERS = [
2137
2234
  ZhipuProvider,
2138
2235
  KimiProvider,
2139
2236
  OpenAIProvider,
2140
- OpenRouterProvider
2237
+ OpenRouterProvider,
2238
+ OllamaProvider
2141
2239
  ];
2142
2240
  var ProviderRegistry = class {
2143
2241
  providers = /* @__PURE__ */ new Map();
@@ -2147,10 +2245,14 @@ var ProviderRegistry = class {
2147
2245
  for (const ProviderClass of BUILT_IN_PROVIDERS) {
2148
2246
  const provider = new ProviderClass();
2149
2247
  const apiKey = getApiKey(provider.info.id);
2150
- if (apiKey) {
2248
+ if (apiKey || !provider.info.requiresApiKey) {
2151
2249
  const options = getOptions?.(provider.info.id) ?? {};
2152
- await provider.initialize(apiKey, options);
2153
- this.providers.set(provider.info.id, provider);
2250
+ try {
2251
+ await provider.initialize(apiKey ?? "", options);
2252
+ this.providers.set(provider.info.id, provider);
2253
+ } catch (err) {
2254
+ if (provider.info.requiresApiKey) throw err;
2255
+ }
2154
2256
  }
2155
2257
  }
2156
2258
  this.customConfigs = customProviderConfigs ?? [];
@@ -8,7 +8,7 @@ import { platform } from "os";
8
8
  import chalk from "chalk";
9
9
 
10
10
  // src/core/constants.ts
11
- var VERSION = "0.4.30";
11
+ var VERSION = "0.4.31";
12
12
  var APP_NAME = "ai-cli";
13
13
  var CONFIG_DIR_NAME = ".aicli";
14
14
  var CONFIG_FILE_NAME = "config.json";
@@ -387,7 +387,7 @@ ${content}`);
387
387
  }
388
388
  }
389
389
  async function runTaskMode(config, providers, configManager, topic) {
390
- const { TaskOrchestrator } = await import("./task-orchestrator-VUAHWAAP.js");
390
+ const { TaskOrchestrator } = await import("./task-orchestrator-V7BBE2VP.js");
391
391
  const orchestrator = new TaskOrchestrator(config, providers, configManager);
392
392
  let interrupted = false;
393
393
  const onSigint = () => {
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  saveDevState,
25
25
  sessionHasMeaningfulContent,
26
26
  setupProxy
27
- } from "./chunk-HNS4LEHL.js";
27
+ } from "./chunk-FSAKLKZH.js";
28
28
  import {
29
29
  ToolExecutor,
30
30
  ToolRegistry,
@@ -37,7 +37,7 @@ import {
37
37
  spawnAgentContext,
38
38
  theme,
39
39
  undoStack
40
- } from "./chunk-4EOFMJCR.js";
40
+ } from "./chunk-C2E47GOR.js";
41
41
  import {
42
42
  fileCheckpoints
43
43
  } from "./chunk-4BKXL7SM.js";
@@ -61,7 +61,7 @@ import {
61
61
  SKILLS_DIR_NAME,
62
62
  VERSION,
63
63
  buildUserIdentityPrompt
64
- } from "./chunk-4WGZEAGJ.js";
64
+ } from "./chunk-LY2B3WHN.js";
65
65
 
66
66
  // src/index.ts
67
67
  import { program } from "commander";
@@ -2087,7 +2087,7 @@ ${hint}` : "")
2087
2087
  usage: "/test [command|filter]",
2088
2088
  async execute(args, ctx) {
2089
2089
  try {
2090
- const { executeTests } = await import("./run-tests-NBCWIU4B.js");
2090
+ const { executeTests } = await import("./run-tests-OUR565AK.js");
2091
2091
  const argStr = args.join(" ").trim();
2092
2092
  let testArgs = {};
2093
2093
  if (argStr) {
@@ -5397,7 +5397,7 @@ program.command("web").description("Start Web UI server with browser-based chat
5397
5397
  console.error("Error: Invalid port number. Must be between 1 and 65535.");
5398
5398
  process.exit(1);
5399
5399
  }
5400
- const { startWebServer } = await import("./server-67B52TFH.js");
5400
+ const { startWebServer } = await import("./server-T3ABTKH3.js");
5401
5401
  await startWebServer({ port, host: options.host });
5402
5402
  });
5403
5403
  program.command("user [action] [username]").description("Manage Web UI users (list | create <name> | delete <name> | reset-password <name> | migrate <name>)").action(async (action, username) => {
@@ -5630,7 +5630,7 @@ program.command("hub [topic]").description("Start multi-agent hub (discuss / bra
5630
5630
  }),
5631
5631
  config.get("customProviders")
5632
5632
  );
5633
- const { startHub } = await import("./hub-FKLTP6IO.js");
5633
+ const { startHub } = await import("./hub-J6CX3YDH.js");
5634
5634
  await startHub(
5635
5635
  {
5636
5636
  topic: topic ?? "",
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  executeTests,
4
4
  runTestsTool
5
- } from "./chunk-4WGZEAGJ.js";
5
+ } from "./chunk-LY2B3WHN.js";
6
6
  export {
7
7
  executeTests,
8
8
  runTestsTool
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  executeTests,
3
3
  runTestsTool
4
- } from "./chunk-4TUVB27S.js";
4
+ } from "./chunk-EF6CCPWA.js";
5
5
  export {
6
6
  executeTests,
7
7
  runTestsTool
@@ -15,7 +15,7 @@ import {
15
15
  hadPreviousWriteToolCalls,
16
16
  loadDevState,
17
17
  setupProxy
18
- } from "./chunk-HNS4LEHL.js";
18
+ } from "./chunk-FSAKLKZH.js";
19
19
  import {
20
20
  AuthManager
21
21
  } from "./chunk-BYNY5JPB.js";
@@ -33,7 +33,7 @@ import {
33
33
  spawnAgentContext,
34
34
  truncateOutput,
35
35
  undoStack
36
- } from "./chunk-4EOFMJCR.js";
36
+ } from "./chunk-C2E47GOR.js";
37
37
  import "./chunk-4BKXL7SM.js";
38
38
  import {
39
39
  AGENTIC_BEHAVIOR_GUIDELINE,
@@ -52,7 +52,7 @@ import {
52
52
  SKILLS_DIR_NAME,
53
53
  VERSION,
54
54
  buildUserIdentityPrompt
55
- } from "./chunk-4WGZEAGJ.js";
55
+ } from "./chunk-LY2B3WHN.js";
56
56
 
57
57
  // src/web/server.ts
58
58
  import express from "express";
@@ -1606,7 +1606,7 @@ ${undoResults.map((r) => ` \u2022 ${r}`).join("\n")}` });
1606
1606
  case "test": {
1607
1607
  this.send({ type: "info", message: "\u{1F9EA} Running tests..." });
1608
1608
  try {
1609
- const { executeTests } = await import("./run-tests-NBCWIU4B.js");
1609
+ const { executeTests } = await import("./run-tests-OUR565AK.js");
1610
1610
  const argStr = args.join(" ").trim();
1611
1611
  let testArgs = {};
1612
1612
  if (argStr) {
@@ -4,11 +4,11 @@ import {
4
4
  getDangerLevel,
5
5
  googleSearchContext,
6
6
  truncateOutput
7
- } from "./chunk-4EOFMJCR.js";
7
+ } from "./chunk-C2E47GOR.js";
8
8
  import "./chunk-4BKXL7SM.js";
9
9
  import {
10
10
  SUBAGENT_ALLOWED_TOOLS
11
- } from "./chunk-4WGZEAGJ.js";
11
+ } from "./chunk-LY2B3WHN.js";
12
12
 
13
13
  // src/hub/task-orchestrator.ts
14
14
  import { createInterface } from "readline";
@@ -22,6 +22,12 @@ let historyIndex = -1; // -1 = not browsing history
22
22
  let savedInputDraft = ''; // Saved current input when entering history mode
23
23
  let toolTimers = new Map(); // callId → { startTime, intervalId }
24
24
 
25
+ // ── Multi-Tab state (P2-1) ────────────────────────────────────────
26
+ // Each "tab" represents an open session within the single page.
27
+ // Only one tab is active at a time; others store a DOM snapshot.
28
+ let sessionTabs = []; // [{ id, sessionId, title, messagesHtml, scrollPos, tokenUsage, isProcessing }]
29
+ let activeTabIdx = -1; // Index into sessionTabs
30
+
25
31
  // ── DOM refs ───────────────────────────────────────────────────────
26
32
 
27
33
  const messagesEl = document.getElementById('messages');
@@ -46,6 +52,8 @@ const sessionSearchInput = document.getElementById('session-search');
46
52
  const toolsSearchInput = document.getElementById('tools-search');
47
53
  let cachedSessions = [];
48
54
  let cachedToolsData = null;
55
+ const sessionTabsListEl = document.getElementById('session-tabs-list');
56
+ const btnAddTab = document.getElementById('btn-add-tab');
49
57
 
50
58
  // ── Configure marked.js ────────────────────────────────────────────
51
59
 
@@ -103,10 +111,25 @@ function connect() {
103
111
  setProcessing(false);
104
112
  addInfoMessage('⚡ Reconnected — previous generation may have been interrupted.');
105
113
  }
106
- // Restore the last active session for this tab (page reload / reconnect)
107
- const savedSession = sessionStorage.getItem('aicli-active-session');
108
- if (savedSession && !processing) {
109
- send({ type: 'command', name: 'session', args: ['load', savedSession] });
114
+ // Restore tabs on reconnect / page reload
115
+ if (sessionTabs.length === 0) {
116
+ // First connect try to restore tabs from sessionStorage
117
+ const restored = restoreTabState();
118
+ if (restored && sessionTabs[activeTabIdx]?.sessionId) {
119
+ send({ type: 'command', name: 'session', args: ['load', sessionTabs[activeTabIdx].sessionId] });
120
+ } else if (!restored) {
121
+ // Legacy fallback: check old single-session storage
122
+ const savedSession = sessionStorage.getItem('aicli-active-session');
123
+ if (savedSession && !processing) {
124
+ addTab(savedSession, null);
125
+ } else {
126
+ // Create initial tab
127
+ addTab();
128
+ }
129
+ }
130
+ } else if (sessionTabs[activeTabIdx]?.sessionId) {
131
+ // Reconnecting with existing tabs — reload active session
132
+ send({ type: 'command', name: 'session', args: ['load', sessionTabs[activeTabIdx].sessionId] });
110
133
  }
111
134
  };
112
135
 
@@ -448,6 +471,9 @@ function handleStatus(msg) {
448
471
  sessionStorage.setItem('aicli-active-session', msg.sessionId);
449
472
  }
450
473
 
474
+ // Update multi-tab state
475
+ updateActiveTabFromStatus(msg);
476
+
451
477
  // Update browser tab title to reflect current session
452
478
  const title = msg.sessionTitle || msg.sessionId?.slice(0, 8) || 'New Session';
453
479
  document.title = `ai-cli — ${title}`;
@@ -603,6 +629,11 @@ function scrollToBottom() {
603
629
 
604
630
  function setProcessing(value) {
605
631
  processing = value;
632
+ // Sync tab processing indicator
633
+ if (activeTabIdx >= 0 && activeTabIdx < sessionTabs.length) {
634
+ sessionTabs[activeTabIdx].isProcessing = value;
635
+ renderTabBar();
636
+ }
606
637
  // During processing: show BOTH send (as interjection) and stop buttons
607
638
  // Send button changes style to indicate interjection mode
608
639
  btnStop.classList.toggle('hidden', !value);
@@ -753,6 +784,24 @@ function renderSessionList(sessions) {
753
784
  renderFilteredSessions(sessionSearchInput?.value || '');
754
785
  }
755
786
 
787
+ /** Load a session — switch to its tab if already open, otherwise load in active tab */
788
+ function loadSessionInTab(sessionId, title) {
789
+ // If this session is already open in a tab, just switch to it
790
+ const existingIdx = findTabBySessionId(sessionId);
791
+ if (existingIdx >= 0 && existingIdx !== activeTabIdx) {
792
+ switchToTab(existingIdx);
793
+ return;
794
+ }
795
+ // Load in current active tab
796
+ if (activeTabIdx >= 0 && activeTabIdx < sessionTabs.length) {
797
+ sessionTabs[activeTabIdx].sessionId = sessionId;
798
+ if (title) sessionTabs[activeTabIdx].title = title;
799
+ renderTabBar();
800
+ saveTabState();
801
+ }
802
+ send({ type: 'command', name: 'session', args: ['load', sessionId] });
803
+ }
804
+
756
805
  let batchSelectMode = false;
757
806
  const batchSelectedIds = new Set();
758
807
 
@@ -799,14 +848,14 @@ function renderFilteredSessions(filter) {
799
848
  clickTimer = setTimeout(() => {
800
849
  clickTimer = null;
801
850
  const id = el.dataset.sessionId;
802
- if (id) send({ type: 'command', name: 'session', args: ['load', id] });
851
+ if (id) loadSessionInTab(id, el.querySelector('.session-title')?.textContent);
803
852
  }, 300);
804
853
  return;
805
854
  }
806
855
  // Clicking elsewhere on the item — load immediately
807
856
  const id = el.dataset.sessionId;
808
857
  if (!id) return;
809
- send({ type: 'command', name: 'session', args: ['load', id] });
858
+ loadSessionInTab(id, el.querySelector('.session-title')?.textContent);
810
859
  });
811
860
 
812
861
  if (!batchSelectMode) {
@@ -930,11 +979,9 @@ function renderSessionMessages(messages) {
930
979
  scrollToBottom();
931
980
  }
932
981
 
933
- // New session button
982
+ // New session button — opens in a new tab
934
983
  btnNewSession.addEventListener('click', () => {
935
- send({ type: 'command', name: 'session', args: ['new'] });
936
- // Clear chat area
937
- messagesEl.innerHTML = '';
984
+ addTab();
938
985
  });
939
986
 
940
987
  // Request session list on connect
@@ -1744,6 +1791,213 @@ if (btnFileTreeRefresh) {
1744
1791
  });
1745
1792
  }
1746
1793
 
1794
+ // ── Multi-Tab Management (P2-1) ───────────────────────────────────
1795
+
1796
+ function generateTabId() {
1797
+ return 'stab-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
1798
+ }
1799
+
1800
+ /** Save current active tab's DOM state (messages, scroll, streaming state) */
1801
+ function snapshotActiveTab() {
1802
+ if (activeTabIdx < 0 || activeTabIdx >= sessionTabs.length) return;
1803
+ const tab = sessionTabs[activeTabIdx];
1804
+ tab.messagesHtml = messagesEl.innerHTML;
1805
+ tab.scrollPos = chatArea.scrollTop;
1806
+ tab.isProcessing = processing;
1807
+ // Save streaming state so we don't lose partial content
1808
+ tab._currentAssistantContent = currentAssistantContent;
1809
+ }
1810
+
1811
+ /** Restore a tab's DOM state */
1812
+ function restoreTab(index) {
1813
+ const tab = sessionTabs[index];
1814
+ if (!tab) return;
1815
+ messagesEl.innerHTML = tab.messagesHtml || '';
1816
+ chatArea.scrollTop = tab.scrollPos || 0;
1817
+ // Reset streaming state for the restored tab
1818
+ currentAssistantEl = null;
1819
+ currentAssistantContent = '';
1820
+ currentThinkingEl = null;
1821
+ currentThinkingContent = '';
1822
+ }
1823
+
1824
+ /** Render the tab bar UI */
1825
+ function renderTabBar() {
1826
+ if (!sessionTabsListEl) return;
1827
+ sessionTabsListEl.innerHTML = sessionTabs.map((tab, i) => {
1828
+ const active = i === activeTabIdx ? ' active' : '';
1829
+ const title = tab.title || 'New Chat';
1830
+ const processingDot = tab.isProcessing ? '<span class="tab-processing"></span>' : '';
1831
+ return `<div class="session-tab-item${active}" data-tab-index="${i}" title="${escapeHtml(title)}">
1832
+ ${processingDot}<span class="tab-title">${escapeHtml(title)}</span>
1833
+ <span class="tab-close" data-tab-close="${i}">&times;</span>
1834
+ </div>`;
1835
+ }).join('');
1836
+
1837
+ // Click handlers
1838
+ sessionTabsListEl.querySelectorAll('.session-tab-item').forEach(el => {
1839
+ el.addEventListener('click', (e) => {
1840
+ if (e.target.closest('.tab-close')) return;
1841
+ const idx = parseInt(el.dataset.tabIndex);
1842
+ if (idx !== activeTabIdx) switchToTab(idx);
1843
+ });
1844
+ });
1845
+ sessionTabsListEl.querySelectorAll('.tab-close').forEach(btn => {
1846
+ btn.addEventListener('click', (e) => {
1847
+ e.stopPropagation();
1848
+ closeTab(parseInt(btn.dataset.tabClose));
1849
+ });
1850
+ });
1851
+
1852
+ // Scroll active tab into view
1853
+ const activeEl = sessionTabsListEl.querySelector('.session-tab-item.active');
1854
+ if (activeEl) activeEl.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1855
+ }
1856
+
1857
+ /** Add a new tab. If sessionId provided, load that session; otherwise create new. */
1858
+ function addTab(sessionId, title) {
1859
+ // Snapshot current tab before adding
1860
+ snapshotActiveTab();
1861
+
1862
+ const tab = {
1863
+ id: generateTabId(),
1864
+ sessionId: sessionId || null,
1865
+ title: title || 'New Chat',
1866
+ messagesHtml: '',
1867
+ scrollPos: 0,
1868
+ tokenUsage: { inputTokens: 0, outputTokens: 0 },
1869
+ isProcessing: false,
1870
+ _currentAssistantContent: '',
1871
+ };
1872
+ sessionTabs.push(tab);
1873
+ activeTabIdx = sessionTabs.length - 1;
1874
+
1875
+ // Clear chat area for the new tab
1876
+ messagesEl.innerHTML = '';
1877
+ currentAssistantEl = null;
1878
+ currentAssistantContent = '';
1879
+ currentThinkingEl = null;
1880
+ currentThinkingContent = '';
1881
+ chatArea.scrollTop = 0;
1882
+
1883
+ // Tell server to create or load session
1884
+ if (sessionId) {
1885
+ send({ type: 'command', name: 'session', args: ['load', sessionId] });
1886
+ } else {
1887
+ send({ type: 'command', name: 'session', args: ['new'] });
1888
+ }
1889
+
1890
+ renderTabBar();
1891
+ saveTabState();
1892
+ }
1893
+
1894
+ /** Switch to an existing tab by index */
1895
+ function switchToTab(index) {
1896
+ if (index === activeTabIdx || index < 0 || index >= sessionTabs.length) return;
1897
+
1898
+ // Snapshot current
1899
+ snapshotActiveTab();
1900
+
1901
+ activeTabIdx = index;
1902
+ const tab = sessionTabs[index];
1903
+
1904
+ // Restore DOM
1905
+ restoreTab(index);
1906
+ renderTabBar();
1907
+
1908
+ // Tell server to switch session
1909
+ if (tab.sessionId) {
1910
+ send({ type: 'command', name: 'session', args: ['load', tab.sessionId] });
1911
+ } else {
1912
+ send({ type: 'command', name: 'session', args: ['new'] });
1913
+ }
1914
+
1915
+ saveTabState();
1916
+ }
1917
+
1918
+ /** Close a tab */
1919
+ function closeTab(index) {
1920
+ if (sessionTabs.length <= 1) return; // Must keep at least 1 tab
1921
+
1922
+ sessionTabs.splice(index, 1);
1923
+
1924
+ if (index === activeTabIdx) {
1925
+ // Closing active tab — switch to nearest
1926
+ activeTabIdx = Math.min(index, sessionTabs.length - 1);
1927
+ restoreTab(activeTabIdx);
1928
+ const tab = sessionTabs[activeTabIdx];
1929
+ if (tab.sessionId) {
1930
+ send({ type: 'command', name: 'session', args: ['load', tab.sessionId] });
1931
+ }
1932
+ } else if (index < activeTabIdx) {
1933
+ activeTabIdx--;
1934
+ }
1935
+
1936
+ renderTabBar();
1937
+ saveTabState();
1938
+ }
1939
+
1940
+ /** Find tab by sessionId, or -1 */
1941
+ function findTabBySessionId(sessionId) {
1942
+ return sessionTabs.findIndex(t => t.sessionId === sessionId);
1943
+ }
1944
+
1945
+ /** Update the active tab's metadata from a status message */
1946
+ function updateActiveTabFromStatus(msg) {
1947
+ if (activeTabIdx < 0 || activeTabIdx >= sessionTabs.length) return;
1948
+ const tab = sessionTabs[activeTabIdx];
1949
+ if (msg.sessionId) tab.sessionId = msg.sessionId;
1950
+ if (msg.sessionTitle) tab.title = msg.sessionTitle;
1951
+ else if (msg.sessionId && !tab.title) tab.title = msg.sessionId.slice(0, 8);
1952
+ tab.isProcessing = processing;
1953
+ if (msg.tokenUsage) tab.tokenUsage = msg.tokenUsage;
1954
+ renderTabBar();
1955
+ saveTabState();
1956
+ }
1957
+
1958
+ /** Persist tab state to sessionStorage for page reload */
1959
+ function saveTabState() {
1960
+ try {
1961
+ const data = sessionTabs.map(t => ({
1962
+ id: t.id,
1963
+ sessionId: t.sessionId,
1964
+ title: t.title,
1965
+ }));
1966
+ sessionStorage.setItem('aicli-tabs', JSON.stringify({ tabs: data, activeIdx: activeTabIdx }));
1967
+ } catch {}
1968
+ }
1969
+
1970
+ /** Restore tabs from sessionStorage (called on page load) */
1971
+ function restoreTabState() {
1972
+ try {
1973
+ const raw = sessionStorage.getItem('aicli-tabs');
1974
+ if (!raw) return false;
1975
+ const { tabs, activeIdx } = JSON.parse(raw);
1976
+ if (!Array.isArray(tabs) || tabs.length === 0) return false;
1977
+
1978
+ sessionTabs = tabs.map(t => ({
1979
+ id: t.id || generateTabId(),
1980
+ sessionId: t.sessionId,
1981
+ title: t.title || 'New Chat',
1982
+ messagesHtml: '',
1983
+ scrollPos: 0,
1984
+ tokenUsage: { inputTokens: 0, outputTokens: 0 },
1985
+ isProcessing: false,
1986
+ _currentAssistantContent: '',
1987
+ }));
1988
+ activeTabIdx = Math.min(activeIdx || 0, sessionTabs.length - 1);
1989
+ renderTabBar();
1990
+ return true;
1991
+ } catch {
1992
+ return false;
1993
+ }
1994
+ }
1995
+
1996
+ // "+" button
1997
+ if (btnAddTab) {
1998
+ btnAddTab.addEventListener('click', () => addTab());
1999
+ }
2000
+
1747
2001
  // ── Initialize ─────────────────────────────────────────────────────
1748
2002
 
1749
2003
  // Restore theme + sync code highlight
@@ -153,6 +153,12 @@
153
153
  <div id="sidebar-backdrop" class="sidebar-backdrop hidden" onclick="closeSidebar()"></div>
154
154
 
155
155
  <!-- Chat Area -->
156
+ <div class="flex-1 flex flex-col overflow-hidden">
157
+ <!-- Session Tab Bar -->
158
+ <div id="session-tabs" class="session-tab-bar bg-base-200 border-b border-base-content/10 flex-shrink-0">
159
+ <div id="session-tabs-list" class="session-tabs-scroll"></div>
160
+ <button id="btn-add-tab" class="session-tab-add" title="New tab">+</button>
161
+ </div>
156
162
  <main id="chat-area" class="flex-1 overflow-y-auto px-4 py-4 relative">
157
163
  <!-- Round progress bar (sticky top, hidden by default) -->
158
164
  <div id="round-progress" class="round-progress-bar hidden">
@@ -171,6 +177,7 @@
171
177
  </div>
172
178
  </div>
173
179
  </main>
180
+ </div><!-- end chat column (tab bar + chat area) -->
174
181
 
175
182
  </div>
176
183
 
@@ -510,6 +510,114 @@
510
510
  font-family: 'Fira Code', 'JetBrains Mono', 'Consolas', monospace;
511
511
  }
512
512
 
513
+ /* ── Session Tab Bar (P2-1: multi-tab in page) ──────── */
514
+ .session-tab-bar {
515
+ display: flex;
516
+ align-items: stretch;
517
+ min-height: 2.25rem;
518
+ padding: 0;
519
+ gap: 0;
520
+ overflow: hidden;
521
+ }
522
+ .session-tabs-scroll {
523
+ display: flex;
524
+ align-items: stretch;
525
+ overflow-x: auto;
526
+ overflow-y: hidden;
527
+ flex: 1;
528
+ min-width: 0;
529
+ scrollbar-width: thin;
530
+ scrollbar-color: oklch(var(--bc) / 0.15) transparent;
531
+ }
532
+ .session-tabs-scroll::-webkit-scrollbar { height: 3px; }
533
+ .session-tabs-scroll::-webkit-scrollbar-thumb { background: oklch(var(--bc) / 0.15); border-radius: 2px; }
534
+
535
+ .session-tab-item {
536
+ display: flex;
537
+ align-items: center;
538
+ gap: 0.35rem;
539
+ padding: 0 0.75rem;
540
+ font-size: 0.78rem;
541
+ white-space: nowrap;
542
+ cursor: pointer;
543
+ border-right: 1px solid oklch(var(--bc) / 0.08);
544
+ transition: background 0.12s;
545
+ min-width: 0;
546
+ max-width: 12rem;
547
+ flex-shrink: 0;
548
+ position: relative;
549
+ user-select: none;
550
+ }
551
+ .session-tab-item:hover {
552
+ background: oklch(var(--b3));
553
+ }
554
+ .session-tab-item.active {
555
+ background: oklch(var(--b1));
556
+ border-bottom: 2px solid oklch(var(--p));
557
+ color: oklch(var(--p));
558
+ font-weight: 600;
559
+ }
560
+ .session-tab-item:not(.active) {
561
+ border-bottom: 2px solid transparent;
562
+ }
563
+ .session-tab-item .tab-title {
564
+ overflow: hidden;
565
+ text-overflow: ellipsis;
566
+ white-space: nowrap;
567
+ min-width: 0;
568
+ flex: 1;
569
+ }
570
+ .session-tab-item .tab-processing {
571
+ width: 6px;
572
+ height: 6px;
573
+ border-radius: 50%;
574
+ background: oklch(var(--wa));
575
+ flex-shrink: 0;
576
+ animation: tab-pulse 1.2s infinite;
577
+ }
578
+ @keyframes tab-pulse {
579
+ 0%, 100% { opacity: 0.4; }
580
+ 50% { opacity: 1; }
581
+ }
582
+ .session-tab-item .tab-close {
583
+ opacity: 0;
584
+ font-size: 0.85rem;
585
+ line-height: 1;
586
+ padding: 0 0.15rem;
587
+ border-radius: 0.2rem;
588
+ cursor: pointer;
589
+ flex-shrink: 0;
590
+ transition: opacity 0.12s, background 0.12s;
591
+ }
592
+ .session-tab-item:hover .tab-close,
593
+ .session-tab-item.active .tab-close {
594
+ opacity: 0.5;
595
+ }
596
+ .session-tab-item .tab-close:hover {
597
+ opacity: 1;
598
+ background: oklch(var(--er) / 0.2);
599
+ color: oklch(var(--er));
600
+ }
601
+ .session-tab-add {
602
+ display: flex;
603
+ align-items: center;
604
+ justify-content: center;
605
+ width: 2rem;
606
+ flex-shrink: 0;
607
+ font-size: 1.1rem;
608
+ font-weight: 300;
609
+ cursor: pointer;
610
+ opacity: 0.4;
611
+ transition: opacity 0.12s, background 0.12s;
612
+ border: none;
613
+ background: transparent;
614
+ color: inherit;
615
+ }
616
+ .session-tab-add:hover {
617
+ opacity: 1;
618
+ background: oklch(var(--b3));
619
+ }
620
+
513
621
  /* ── Round progress bar (sticky top of chat area) ──── */
514
622
  .round-progress-bar {
515
623
  position: sticky;
@@ -684,6 +792,11 @@ button, a, .session-item, .file-tree-row, .template-item, .tool-item, .mcp-serve
684
792
  /* Sidebar: full width on small phones */
685
793
  .sidebar.sidebar-open { width: min(85vw, 20rem); }
686
794
 
795
+ /* Tab bar: smaller on phone */
796
+ .session-tab-item { padding: 0 0.5rem; font-size: 0.72rem; max-width: 9rem; }
797
+ .session-tab-bar { min-height: 2rem; }
798
+ .session-tab-item .tab-close { opacity: 0.4; } /* always visible on mobile (no hover) */
799
+
687
800
  /* Chat area */
688
801
  #chat-area { padding: 0.75rem 0.5rem; }
689
802
  .msg-assistant { padding: 0.75rem; font-size: 0.92rem; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jinzd-ai-cli",
3
- "version": "0.4.30",
3
+ "version": "0.4.32",
4
4
  "description": "Cross-platform REPL-style AI CLI with multi-provider support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",