@co0ontty/wand 1.6.2 → 1.9.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.
@@ -58,6 +58,337 @@
58
58
 
59
59
  (function() {
60
60
  var configPath = "${escapeHtml(configPath)}";
61
+ var CHAT_EXPAND_STATE_STORAGE_KEY = "wand-chat-expand-state-v1";
62
+ var CHAT_AUTO_FOLLOW_STORAGE_KEY = "wand-chat-auto-follow";
63
+ var DEFAULT_PANEL_STATE = {
64
+ sessionsDrawerOpen: false,
65
+ filePanelOpen: false,
66
+ shortcutsExpanded: false,
67
+ claudeHistoryExpanded: true,
68
+ chatMessageExpanded: true,
69
+ structuredThinkingExpanded: true,
70
+ structuredToolGroupExpanded: false,
71
+ structuredInlineToolExpanded: false,
72
+ structuredTerminalExpanded: false,
73
+ structuredToolCardExpanded: false,
74
+ };
75
+
76
+ function getConfiguredPanelDefaults(configOverride) {
77
+ var currentConfig = configOverride;
78
+ if (!currentConfig || typeof currentConfig !== "object") {
79
+ return {
80
+ sessionsDrawerOpen: DEFAULT_PANEL_STATE.sessionsDrawerOpen,
81
+ filePanelOpen: DEFAULT_PANEL_STATE.filePanelOpen,
82
+ shortcutsExpanded: DEFAULT_PANEL_STATE.shortcutsExpanded,
83
+ claudeHistoryExpanded: DEFAULT_PANEL_STATE.claudeHistoryExpanded,
84
+ chatMessageExpanded: DEFAULT_PANEL_STATE.chatMessageExpanded,
85
+ structuredThinkingExpanded: DEFAULT_PANEL_STATE.structuredThinkingExpanded,
86
+ structuredToolGroupExpanded: DEFAULT_PANEL_STATE.structuredToolGroupExpanded,
87
+ structuredInlineToolExpanded: DEFAULT_PANEL_STATE.structuredInlineToolExpanded,
88
+ structuredTerminalExpanded: DEFAULT_PANEL_STATE.structuredTerminalExpanded,
89
+ structuredToolCardExpanded: DEFAULT_PANEL_STATE.structuredToolCardExpanded,
90
+ };
91
+ }
92
+ var preferences = currentConfig.uiPreferences;
93
+ var configured = preferences && typeof preferences === "object" ? preferences.defaultPanelState : null;
94
+ return {
95
+ sessionsDrawerOpen: configured && typeof configured.sessionsDrawerOpen === "boolean" ? configured.sessionsDrawerOpen : DEFAULT_PANEL_STATE.sessionsDrawerOpen,
96
+ filePanelOpen: configured && typeof configured.filePanelOpen === "boolean" ? configured.filePanelOpen : DEFAULT_PANEL_STATE.filePanelOpen,
97
+ shortcutsExpanded: configured && typeof configured.shortcutsExpanded === "boolean" ? configured.shortcutsExpanded : DEFAULT_PANEL_STATE.shortcutsExpanded,
98
+ claudeHistoryExpanded: configured && typeof configured.claudeHistoryExpanded === "boolean" ? configured.claudeHistoryExpanded : DEFAULT_PANEL_STATE.claudeHistoryExpanded,
99
+ chatMessageExpanded: configured && typeof configured.chatMessageExpanded === "boolean" ? configured.chatMessageExpanded : DEFAULT_PANEL_STATE.chatMessageExpanded,
100
+ structuredThinkingExpanded: configured && typeof configured.structuredThinkingExpanded === "boolean" ? configured.structuredThinkingExpanded : DEFAULT_PANEL_STATE.structuredThinkingExpanded,
101
+ structuredToolGroupExpanded: configured && typeof configured.structuredToolGroupExpanded === "boolean" ? configured.structuredToolGroupExpanded : DEFAULT_PANEL_STATE.structuredToolGroupExpanded,
102
+ structuredInlineToolExpanded: configured && typeof configured.structuredInlineToolExpanded === "boolean" ? configured.structuredInlineToolExpanded : DEFAULT_PANEL_STATE.structuredInlineToolExpanded,
103
+ structuredTerminalExpanded: configured && typeof configured.structuredTerminalExpanded === "boolean" ? configured.structuredTerminalExpanded : DEFAULT_PANEL_STATE.structuredTerminalExpanded,
104
+ structuredToolCardExpanded: configured && typeof configured.structuredToolCardExpanded === "boolean" ? configured.structuredToolCardExpanded : DEFAULT_PANEL_STATE.structuredToolCardExpanded,
105
+ };
106
+ }
107
+
108
+ function getStoredBoolean(key) {
109
+ try {
110
+ var saved = localStorage.getItem(key);
111
+ if (saved === "true") return true;
112
+ if (saved === "false") return false;
113
+ return null;
114
+ } catch (e) {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ function getInitialPanelBoolean(key, fieldName) {
120
+ var stored = getStoredBoolean(key);
121
+ if (stored !== null) return stored;
122
+ return DEFAULT_PANEL_STATE[fieldName];
123
+ }
124
+
125
+ function persistPanelBoolean(key, value) {
126
+ try {
127
+ localStorage.setItem(key, String(!!value));
128
+ } catch (e) {}
129
+ }
130
+
131
+ function applyConfiguredPanelDefaults() {
132
+ var defaults = getConfiguredPanelDefaults((typeof state !== "undefined" && state) ? state.config : null);
133
+ if (getStoredBoolean("wand-sessions-drawer-open") === null) {
134
+ state.sessionsDrawerOpen = defaults.sessionsDrawerOpen;
135
+ }
136
+ if (getStoredBoolean("wand-file-panel-open") === null) {
137
+ state.filePanelOpen = defaults.filePanelOpen;
138
+ }
139
+ if (getStoredBoolean("wand-shortcuts-expanded") === null) {
140
+ state.shortcutsExpanded = defaults.shortcutsExpanded;
141
+ }
142
+ if (getStoredBoolean("wand-claude-history-expanded") === null) {
143
+ state.claudeHistoryExpanded = defaults.claudeHistoryExpanded;
144
+ }
145
+ }
146
+
147
+ function getPanelStateSettingsFormValues() {
148
+ return {
149
+ sessionsDrawerOpen: !!((document.getElementById("cfg-panel-sessions-drawer") || {}).checked),
150
+ filePanelOpen: !!((document.getElementById("cfg-panel-file") || {}).checked),
151
+ shortcutsExpanded: !!((document.getElementById("cfg-panel-shortcuts") || {}).checked),
152
+ claudeHistoryExpanded: !!((document.getElementById("cfg-panel-history") || {}).checked),
153
+ chatMessageExpanded: !!((document.getElementById("cfg-panel-chat-message") || {}).checked),
154
+ structuredThinkingExpanded: !!((document.getElementById("cfg-panel-structured-thinking") || {}).checked),
155
+ structuredToolGroupExpanded: !!((document.getElementById("cfg-panel-structured-tool-group") || {}).checked),
156
+ structuredInlineToolExpanded: !!((document.getElementById("cfg-panel-structured-inline-tool") || {}).checked),
157
+ structuredTerminalExpanded: !!((document.getElementById("cfg-panel-structured-terminal") || {}).checked),
158
+ structuredToolCardExpanded: !!((document.getElementById("cfg-panel-structured-tool-card") || {}).checked),
159
+ };
160
+ }
161
+
162
+ function syncPanelStateSettingsForm(panelDefaults) {
163
+ var defaults = panelDefaults || getConfiguredPanelDefaults();
164
+ var sessionsDrawerEl = document.getElementById("cfg-panel-sessions-drawer");
165
+ var filePanelEl = document.getElementById("cfg-panel-file");
166
+ var shortcutsEl = document.getElementById("cfg-panel-shortcuts");
167
+ var historyEl = document.getElementById("cfg-panel-history");
168
+ var chatMessageEl = document.getElementById("cfg-panel-chat-message");
169
+ var structuredThinkingEl = document.getElementById("cfg-panel-structured-thinking");
170
+ var structuredToolGroupEl = document.getElementById("cfg-panel-structured-tool-group");
171
+ var structuredInlineToolEl = document.getElementById("cfg-panel-structured-inline-tool");
172
+ var structuredTerminalEl = document.getElementById("cfg-panel-structured-terminal");
173
+ var structuredToolCardEl = document.getElementById("cfg-panel-structured-tool-card");
174
+ if (sessionsDrawerEl) sessionsDrawerEl.checked = !!defaults.sessionsDrawerOpen;
175
+ if (filePanelEl) filePanelEl.checked = !!defaults.filePanelOpen;
176
+ if (shortcutsEl) shortcutsEl.checked = !!defaults.shortcutsExpanded;
177
+ if (historyEl) historyEl.checked = !!defaults.claudeHistoryExpanded;
178
+ if (chatMessageEl) chatMessageEl.checked = !!defaults.chatMessageExpanded;
179
+ if (structuredThinkingEl) structuredThinkingEl.checked = !!defaults.structuredThinkingExpanded;
180
+ if (structuredToolGroupEl) structuredToolGroupEl.checked = !!defaults.structuredToolGroupExpanded;
181
+ if (structuredInlineToolEl) structuredInlineToolEl.checked = !!defaults.structuredInlineToolExpanded;
182
+ if (structuredTerminalEl) structuredTerminalEl.checked = !!defaults.structuredTerminalExpanded;
183
+ if (structuredToolCardEl) structuredToolCardEl.checked = !!defaults.structuredToolCardExpanded;
184
+ }
185
+
186
+ function applySettingsConfig(config) {
187
+ state.config = config || null;
188
+ applyConfiguredPanelDefaults();
189
+ updatePanelDefaultControls();
190
+ }
191
+
192
+ function renderSettingsNav() {
193
+ return '<div class="settings-nav">' +
194
+ '<button class="settings-tab active" data-tab="about">关于</button>' +
195
+ '<button class="settings-tab" data-tab="general">基本配置</button>' +
196
+ '<button class="settings-tab" data-tab="security">安全</button>' +
197
+ '<button class="settings-tab" data-tab="presets">命令预设</button>' +
198
+ '</div>';
199
+ }
200
+
201
+ function renderAppearanceSettingsCard() {
202
+ return '<div class="settings-card settings-card-accent">' +
203
+ '<div class="settings-card-header">' +
204
+ '<div>' +
205
+ '<h3 class="settings-section-title">界面偏好</h3>' +
206
+ '<p class="settings-hint">这些选项决定新页面或未保存本地偏好的默认展开状态。</p>' +
207
+ '</div>' +
208
+ '</div>' +
209
+ '<div class="settings-toggle-list">' +
210
+ '<label class="settings-toggle-item" for="cfg-panel-sessions-drawer">' +
211
+ '<div><span class="settings-toggle-title">默认展开会话侧栏</span><span class="settings-toggle-desc">进入页面时左侧会话列表默认展开。</span></div>' +
212
+ '<input id="cfg-panel-sessions-drawer" type="checkbox" class="field-checkbox" />' +
213
+ '</label>' +
214
+ '<label class="settings-toggle-item" for="cfg-panel-file">' +
215
+ '<div><span class="settings-toggle-title">默认展开文件面板</span><span class="settings-toggle-desc">右侧文件浏览器在初始状态下打开。</span></div>' +
216
+ '<input id="cfg-panel-file" type="checkbox" class="field-checkbox" />' +
217
+ '</label>' +
218
+ '<label class="settings-toggle-item" for="cfg-panel-shortcuts">' +
219
+ '<div><span class="settings-toggle-title">默认展开快捷键栏</span><span class="settings-toggle-desc">移动端快捷键面板首次显示时展开完整行。</span></div>' +
220
+ '<input id="cfg-panel-shortcuts" type="checkbox" class="field-checkbox" />' +
221
+ '</label>' +
222
+ '<label class="settings-toggle-item" for="cfg-panel-history">' +
223
+ '<div><span class="settings-toggle-title">默认展开 Claude 历史</span><span class="settings-toggle-desc">侧栏里的 Claude 历史分组默认展开。</span></div>' +
224
+ '<input id="cfg-panel-history" type="checkbox" class="field-checkbox" />' +
225
+ '</label>' +
226
+ '<label class="settings-toggle-item" for="cfg-panel-chat-message">' +
227
+ '<div><span class="settings-toggle-title">默认展开聊天详情</span><span class="settings-toggle-desc">当某条消息没有本地展开记录时,采用这里的默认值。</span></div>' +
228
+ '<input id="cfg-panel-chat-message" type="checkbox" class="field-checkbox" />' +
229
+ '</label>' +
230
+ '<label class="settings-toggle-item" for="cfg-panel-structured-thinking">' +
231
+ '<div><span class="settings-toggle-title">默认展开思考卡片</span><span class="settings-toggle-desc">结构化模式中的 thinking 块默认展开。</span></div>' +
232
+ '<input id="cfg-panel-structured-thinking" type="checkbox" class="field-checkbox" />' +
233
+ '</label>' +
234
+ '<label class="settings-toggle-item" for="cfg-panel-structured-tool-group">' +
235
+ '<div><span class="settings-toggle-title">默认展开工具组</span><span class="settings-toggle-desc">连续工具调用合并后的工具组默认展开。</span></div>' +
236
+ '<input id="cfg-panel-structured-tool-group" type="checkbox" class="field-checkbox" />' +
237
+ '</label>' +
238
+ '<label class="settings-toggle-item" for="cfg-panel-structured-inline-tool">' +
239
+ '<div><span class="settings-toggle-title">默认展开内联工具</span><span class="settings-toggle-desc">Read、Grep、Glob 等内联工具结果默认展开。</span></div>' +
240
+ '<input id="cfg-panel-structured-inline-tool" type="checkbox" class="field-checkbox" />' +
241
+ '</label>' +
242
+ '<label class="settings-toggle-item" for="cfg-panel-structured-terminal">' +
243
+ '<div><span class="settings-toggle-title">默认展开终端卡片</span><span class="settings-toggle-desc">Bash 等终端输出卡片默认展开。</span></div>' +
244
+ '<input id="cfg-panel-structured-terminal" type="checkbox" class="field-checkbox" />' +
245
+ '</label>' +
246
+ '<label class="settings-toggle-item" for="cfg-panel-structured-tool-card">' +
247
+ '<div><span class="settings-toggle-title">默认展开通用工具卡</span><span class="settings-toggle-desc">工具调用、计划类卡片等通用卡片默认展开。</span></div>' +
248
+ '<input id="cfg-panel-structured-tool-card" type="checkbox" class="field-checkbox" />' +
249
+ '</label>' +
250
+ '</div>' +
251
+ '</div>';
252
+ }
253
+
254
+ function buildSettingsGeneralPanel() {
255
+ return '<div class="settings-panel" id="settings-tab-general">' +
256
+ '<div class="settings-card settings-card-accent">' +
257
+ '<div class="settings-card-header">' +
258
+ '<div>' +
259
+ '<h3 class="settings-section-title">基础运行配置</h3>' +
260
+ '<p class="settings-hint">影响服务器监听、默认模式和 CLI 行为。</p>' +
261
+ '</div>' +
262
+ '</div>' +
263
+ '<div class="field-row">' +
264
+ '<div class="field">' +
265
+ '<label class="field-label" for="cfg-host">监听地址 (host)</label>' +
266
+ '<input id="cfg-host" type="text" class="field-input" placeholder="127.0.0.1" />' +
267
+ '</div>' +
268
+ '<div class="field">' +
269
+ '<label class="field-label" for="cfg-port">端口 (port)</label>' +
270
+ '<input id="cfg-port" type="number" class="field-input" placeholder="8443" min="1" max="65535" />' +
271
+ '</div>' +
272
+ '</div>' +
273
+ '<div class="field field-inline">' +
274
+ '<input id="cfg-https" type="checkbox" class="field-checkbox" />' +
275
+ '<label class="field-label" for="cfg-https">启用 HTTPS</label>' +
276
+ '</div>' +
277
+ '<div class="field-row">' +
278
+ '<div class="field">' +
279
+ '<label class="field-label" for="cfg-mode">默认执行模式</label>' +
280
+ '<select id="cfg-mode" class="field-input">' +
281
+ '<option value="default">default</option>' +
282
+ '<option value="assist">assist</option>' +
283
+ '<option value="agent">agent</option>' +
284
+ '<option value="agent-max">agent-max</option>' +
285
+ '<option value="auto-edit">auto-edit</option>' +
286
+ '<option value="full-access">full-access</option>' +
287
+ '<option value="native">native</option>' +
288
+ '<option value="managed">managed</option>' +
289
+ '</select>' +
290
+ '</div>' +
291
+ '<div class="field">' +
292
+ '<label class="field-label" for="cfg-language">回复语言</label>' +
293
+ '<select id="cfg-language" class="field-input">' +
294
+ '<option value="">自动(不指定)</option>' +
295
+ '<option value="中文">中文</option>' +
296
+ '<option value="English">English</option>' +
297
+ '<option value="日本語">日本語</option>' +
298
+ '<option value="한국어">한국어</option>' +
299
+ '<option value="Español">Español</option>' +
300
+ '<option value="Français">Français</option>' +
301
+ '<option value="Deutsch">Deutsch</option>' +
302
+ '<option value="Русский">Русский</option>' +
303
+ '</select>' +
304
+ '</div>' +
305
+ '</div>' +
306
+ '<p class="field-hint settings-inline-hint">设置回复语言后,Claude 将尽量使用指定语言回复。</p>' +
307
+ '<div class="field">' +
308
+ '<label class="field-label" for="cfg-cwd">默认工作目录</label>' +
309
+ '<input id="cfg-cwd" type="text" class="field-input" placeholder="/home/user" />' +
310
+ '</div>' +
311
+ '<div class="field">' +
312
+ '<label class="field-label" for="cfg-shell">Shell</label>' +
313
+ '<input id="cfg-shell" type="text" class="field-input" placeholder="/bin/bash" />' +
314
+ '</div>' +
315
+ '</div>' +
316
+ renderAppearanceSettingsCard() +
317
+ '<button id="save-config-button" class="btn btn-primary btn-block">保存配置</button>' +
318
+ '<p id="config-message" class="hint hidden"></p>' +
319
+ '</div>';
320
+ }
321
+
322
+ function persistSettingsBackedUiState() {
323
+ persistPanelBoolean("wand-sessions-drawer-open", state.sessionsDrawerOpen);
324
+ persistPanelBoolean("wand-file-panel-open", state.filePanelOpen);
325
+ persistPanelBoolean("wand-shortcuts-expanded", state.shortcutsExpanded);
326
+ persistPanelBoolean("wand-claude-history-expanded", state.claudeHistoryExpanded);
327
+ }
328
+
329
+ function resetSettingsBackedUiStateToDefaults() {
330
+ var defaults = getConfiguredPanelDefaults();
331
+ state.sessionsDrawerOpen = defaults.sessionsDrawerOpen;
332
+ state.filePanelOpen = defaults.filePanelOpen;
333
+ state.shortcutsExpanded = defaults.shortcutsExpanded;
334
+ state.claudeHistoryExpanded = defaults.claudeHistoryExpanded;
335
+ persistSettingsBackedUiState();
336
+ }
337
+
338
+ function handleSettingsConfigSaved(nextConfig) {
339
+ applySettingsConfig(nextConfig || state.config);
340
+ syncPanelStateSettingsForm();
341
+ resetSettingsBackedUiStateToDefaults();
342
+ updateLayoutState();
343
+ updateSessionsList();
344
+ updateCurrentSession();
345
+ }
346
+
347
+ function updateSettingsActiveNav() {
348
+ var activeTab = document.querySelector(".settings-tab.active");
349
+ var nav = document.querySelector(".settings-nav");
350
+ if (!nav) return;
351
+ if (activeTab) {
352
+ nav.setAttribute("data-active-tab", activeTab.getAttribute("data-tab") || "");
353
+ }
354
+ }
355
+
356
+ function updateCollapsedShortcutsUi() {
357
+ var wrap = document.querySelector(".inline-shortcuts-wrap");
358
+ var row = document.querySelector(".inline-shortcuts-expanded-row");
359
+ var toggle = document.querySelector(".shortcuts-toggle");
360
+ if (wrap) wrap.classList.toggle("expanded", state.shortcutsExpanded);
361
+ if (row) row.classList.toggle("visible", state.shortcutsExpanded);
362
+ if (toggle) {
363
+ toggle.classList.toggle("active", state.shortcutsExpanded);
364
+ toggle.textContent = state.shortcutsExpanded ? "\u203a" : "\u2039";
365
+ }
366
+ }
367
+
368
+ function updatePanelDefaultControls() {
369
+ syncPanelStateSettingsForm();
370
+ }
371
+
372
+ function persistDrawerState() {
373
+ persistPanelBoolean("wand-sessions-drawer-open", state.sessionsDrawerOpen);
374
+ }
375
+
376
+ function persistHistoryPanelState() {
377
+ persistPanelBoolean("wand-claude-history-expanded", state.claudeHistoryExpanded);
378
+ }
379
+
380
+ function persistShortcutsExpandedState() {
381
+ persistPanelBoolean("wand-shortcuts-expanded", state.shortcutsExpanded);
382
+ }
383
+
384
+ function persistFilePanelState() {
385
+ persistPanelBoolean("wand-file-panel-open", state.filePanelOpen);
386
+ }
387
+
388
+ function refreshUiAfterPanelStateChange() {
389
+ updateLayoutState();
390
+ updateSessionsList();
391
+ }
61
392
 
62
393
  var state = {
63
394
  selectedId: (function() {
@@ -75,6 +406,7 @@
75
406
  _lastDomHtml: "",
76
407
  terminalSessionId: null,
77
408
  terminalOutput: "",
409
+ terminalLiveStreamSessions: {},
78
410
  terminalViewportSize: { width: 0, height: 0 },
79
411
  terminalAutoFollow: true,
80
412
  terminalScrollIdleTimer: null,
@@ -90,20 +422,35 @@
90
422
  inputQueue: Promise.resolve(),
91
423
  pendingMessages: [], // WebSocket 断线期间的消息队列
92
424
  messageQueue: [], // 用户消息排队等待发送
93
- crossSessionQueue: [], // 跨会话排队消息 [{ id, text, cwd, mode, tool }]
425
+ crossSessionQueue: (function() {
426
+ try {
427
+ var saved = localStorage.getItem("wand-cross-session-queue");
428
+ var parsed = saved ? JSON.parse(saved) : [];
429
+ return Array.isArray(parsed) ? parsed : [];
430
+ } catch (e) {
431
+ return [];
432
+ }
433
+ })(), // 跨会话排队消息 [{ id, text, cwd, mode, tool }]
94
434
  structuredInputQueue: [], // 结构化会话同会话排队消息
95
435
  drafts: {},
96
436
  isSyncingInputBox: false,
97
437
  loginPending: false,
98
438
  loginChecked: false,
99
- sessionsDrawerOpen: false,
439
+ bootstrapping: true,
440
+ sessionsDrawerOpen: getInitialPanelBoolean("wand-sessions-drawer-open", "sessionsDrawerOpen"),
100
441
  modalOpen: false,
101
442
  presetValue: "",
102
443
  cwdValue: "",
103
444
  modeValue: "managed",
104
445
  chatMode: "managed",
105
446
  sessionCreateKind: "structured",
447
+ sessionCreateWorktree: false,
106
448
  sessionTool: "claude",
449
+ activeWorktreeMergeSessionId: null,
450
+ worktreeMergeCheckResult: null,
451
+ worktreeMergeLoading: false,
452
+ worktreeMergeSubmitting: false,
453
+ worktreeMergeError: "",
107
454
  preferredCommand: "claude",
108
455
  structuredRunner: "claude-cli-print",
109
456
  lastResize: { cols: 0, rows: 0 },
@@ -123,13 +470,22 @@
123
470
  })(),
124
471
  terminalBaseFontSize: 13,
125
472
  keyboardPopupOpen: false,
126
- filePanelOpen: (function() {
473
+ filePanelOpen: getInitialPanelBoolean("wand-file-panel-open", "filePanelOpen"),
474
+ chatAutoFollow: (function() {
127
475
  try {
128
- return localStorage.getItem("wand-file-panel-open") === "true";
476
+ var saved = localStorage.getItem(CHAT_AUTO_FOLLOW_STORAGE_KEY);
477
+ return saved === null ? true : saved === "true";
129
478
  } catch (e) {
130
- return false;
479
+ return true;
131
480
  }
132
481
  })(),
482
+ showChatJumpToBottom: false,
483
+ chatScrollThreshold: 200,
484
+ chatIsProgrammaticScroll: false,
485
+ chatScrollElement: null,
486
+ chatScrollHandler: null,
487
+ lastForegroundSyncAt: 0,
488
+ foregroundSyncTimer: null,
133
489
  currentMessages: [],
134
490
  lastRenderedHash: 0,
135
491
  lastRenderedMsgCount: 0,
@@ -138,14 +494,14 @@
138
494
  currentTask: null, // Current task title from Claude
139
495
  terminalInteractive: false,
140
496
  miniKeyboardVisible: false,
141
- shortcutsExpanded: false,
497
+ shortcutsExpanded: getInitialPanelBoolean("wand-shortcuts-expanded", "shortcutsExpanded"),
142
498
  modifiers: { ctrl: false, alt: false, shift: false },
143
499
  fileSearchQuery: "",
144
500
  fileExplorerLoading: false,
145
501
  allFiles: [],
146
502
  claudeHistory: [],
147
503
  claudeHistoryLoaded: false,
148
- claudeHistoryExpanded: true,
504
+ claudeHistoryExpanded: getInitialPanelBoolean("wand-claude-history-expanded", "claudeHistoryExpanded"),
149
505
  claudeHistoryExpandedDirs: {},
150
506
  sessionsManageMode: false,
151
507
  selectedSessionIds: {},
@@ -244,27 +600,472 @@
244
600
  }
245
601
  }
246
602
 
247
- // Helper function to persist selected session ID to localStorage
248
- function persistSelectedId() {
249
- try {
250
- if (state.selectedId) {
251
- localStorage.setItem("wand-selected-session", state.selectedId);
252
- } else {
253
- localStorage.removeItem("wand-selected-session");
254
- }
255
- } catch (e) {
256
- // Ignore localStorage errors
257
- }
603
+ function persistChatAutoFollow() {
604
+ try {
605
+ localStorage.setItem(CHAT_AUTO_FOLLOW_STORAGE_KEY, state.chatAutoFollow ? "true" : "false");
606
+ } catch (e) {
607
+ // Ignore localStorage errors
608
+ }
609
+ }
610
+
611
+ function getChatScrollElement() {
612
+ var chatOutput = document.getElementById("chat-output");
613
+ if (!chatOutput) {
614
+ state.chatScrollElement = null;
615
+ return null;
616
+ }
617
+ var chatMessages = chatOutput.querySelector(".chat-messages");
618
+ if (chatMessages) {
619
+ state.chatScrollElement = chatMessages;
620
+ return chatMessages;
621
+ }
622
+ state.chatScrollElement = null;
623
+ return null;
624
+ }
625
+
626
+ function isChatNearBottom(chatMsgs) {
627
+ var el = chatMsgs || getChatScrollElement();
628
+ if (!el) return true;
629
+ return el.scrollTop < state.chatScrollThreshold;
630
+ }
631
+
632
+ function updateChatFollowToggleButton() {
633
+ var button = document.getElementById("chat-follow-toggle");
634
+ if (!button) return;
635
+ var enabled = !!state.chatAutoFollow;
636
+ button.classList.toggle("active", enabled);
637
+ button.setAttribute("aria-pressed", enabled ? "true" : "false");
638
+ button.setAttribute("title", enabled ? "追踪底部:开启" : "追踪底部:已暂停");
639
+ button.textContent = enabled ? "追底" : "暂停";
640
+ }
641
+
642
+ function updateChatJumpToBottomButton() {
643
+ var button = document.getElementById("chat-jump-bottom");
644
+ var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
645
+ var shouldShow = !!selectedSession
646
+ && state.currentView === "chat"
647
+ && !state.chatAutoFollow
648
+ && !isChatNearBottom();
649
+ state.showChatJumpToBottom = shouldShow;
650
+ if (button) {
651
+ button.classList.toggle("visible", shouldShow);
652
+ }
653
+ }
654
+
655
+ function scrollChatToBottom(smooth) {
656
+ var chatMsgs = getChatScrollElement();
657
+ if (!chatMsgs || !chatMsgs.isConnected) return;
658
+ state.chatIsProgrammaticScroll = true;
659
+ if (smooth && typeof chatMsgs.scrollTo === "function") {
660
+ chatMsgs.scrollTo({ top: 0, behavior: "smooth" });
661
+ setTimeout(function() {
662
+ state.chatIsProgrammaticScroll = false;
663
+ updateChatJumpToBottomButton();
664
+ }, 220);
665
+ return;
666
+ }
667
+ chatMsgs.scrollTop = 0;
668
+ requestAnimationFrame(function() {
669
+ state.chatIsProgrammaticScroll = false;
670
+ updateChatJumpToBottomButton();
671
+ });
672
+ }
673
+
674
+ function setChatAutoFollow(enabled, options) {
675
+ options = options || {};
676
+ state.chatAutoFollow = !!enabled;
677
+ persistChatAutoFollow();
678
+ updateChatFollowToggleButton();
679
+ if (state.chatAutoFollow && options.scrollNow !== false) {
680
+ scrollChatToBottom(!!options.smooth);
681
+ } else {
682
+ updateChatJumpToBottomButton();
683
+ }
684
+ }
685
+
686
+ function bindChatScrollListener() {
687
+ var chatMsgs = getChatScrollElement();
688
+ if (!chatMsgs || !chatMsgs.isConnected) return;
689
+ if (state.chatScrollElement === chatMsgs && state.chatScrollHandler) {
690
+ updateChatJumpToBottomButton();
691
+ return;
692
+ }
693
+ if (state.chatScrollElement && state.chatScrollHandler) {
694
+ state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
695
+ }
696
+ state.chatScrollElement = chatMsgs;
697
+ state.chatScrollHandler = function() {
698
+ if (!chatMsgs.isConnected) return;
699
+ if (state.chatIsProgrammaticScroll) {
700
+ updateChatJumpToBottomButton();
701
+ return;
702
+ }
703
+ if (!isChatNearBottom(chatMsgs)) {
704
+ if (state.chatAutoFollow) {
705
+ setChatAutoFollow(false, { scrollNow: false });
706
+ } else {
707
+ updateChatJumpToBottomButton();
708
+ }
709
+ return;
710
+ }
711
+ updateChatJumpToBottomButton();
712
+ };
713
+ chatMsgs.addEventListener("scroll", state.chatScrollHandler, { passive: true });
714
+ updateChatJumpToBottomButton();
715
+ }
716
+
717
+ // Helper function to persist selected session ID to localStorage
718
+ function persistSelectedId() {
719
+ try {
720
+ if (state.selectedId) {
721
+ localStorage.setItem("wand-selected-session", state.selectedId);
722
+ } else {
723
+ localStorage.removeItem("wand-selected-session");
724
+ }
725
+ } catch (e) {
726
+ // Ignore localStorage errors
727
+ }
728
+ }
729
+
730
+ function getStructuredQueuedInputs(session) {
731
+ if (session && Array.isArray(session.queuedMessages)) {
732
+ return session.queuedMessages;
733
+ }
734
+ return state.structuredInputQueue;
735
+ }
736
+
737
+ function getSelectedStructuredQueuedInputs() {
738
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
739
+ return getStructuredQueuedInputs(session);
740
+ }
741
+
742
+ function syncStructuredQueueFromSession(session) {
743
+ var queued = getStructuredQueuedInputs(session);
744
+ state.structuredInputQueue = Array.isArray(queued) ? queued.slice() : [];
745
+ }
746
+
747
+ function hasRenderOnlyStructuredBlock(message, marker) {
748
+ return !!(message && Array.isArray(message.content) && message.content.some(function(block) {
749
+ return block && typeof block === "object" && block[marker];
750
+ }));
751
+ }
752
+
753
+ function isQueuedStructuredMessage(message) {
754
+ return !!(message && message.role === "user" && hasRenderOnlyStructuredBlock(message, "__queued"));
755
+ }
756
+
757
+ function isProcessingStructuredMessage(message) {
758
+ return !!(message && message.role === "assistant" && hasRenderOnlyStructuredBlock(message, "__processing"));
759
+ }
760
+
761
+ function stripRenderOnlyStructuredMessages(messages) {
762
+ if (!Array.isArray(messages)) return [];
763
+ var removed = false;
764
+ var filtered = [];
765
+ for (var i = 0; i < messages.length; i++) {
766
+ var message = messages[i];
767
+ if (isQueuedStructuredMessage(message) || isProcessingStructuredMessage(message)) {
768
+ removed = true;
769
+ continue;
770
+ }
771
+ filtered.push(message);
772
+ }
773
+ return removed ? filtered : messages;
774
+ }
775
+
776
+ function normalizeStructuredSnapshot(snapshot, existingSession) {
777
+ if (!snapshot || !Array.isArray(snapshot.messages)) {
778
+ return snapshot;
779
+ }
780
+ var sessionKind = snapshot.sessionKind || (existingSession && existingSession.sessionKind);
781
+ if (sessionKind !== "structured") {
782
+ return snapshot;
783
+ }
784
+ var sanitizedMessages = stripRenderOnlyStructuredMessages(snapshot.messages);
785
+ if (sanitizedMessages === snapshot.messages) {
786
+ return snapshot;
787
+ }
788
+ return Object.assign({}, snapshot, { messages: sanitizedMessages });
789
+ }
790
+
791
+ function saveStructuredQueue() {
792
+ try {
793
+ var queued = getSelectedStructuredQueuedInputs();
794
+ if (!state.selectedId || queued.length === 0) {
795
+ return;
796
+ }
797
+ localStorage.setItem("wand-structured-queue", JSON.stringify({
798
+ sessionId: state.selectedId,
799
+ items: queued
800
+ }));
801
+ } catch (e) {
802
+ // Ignore localStorage errors
803
+ }
804
+ }
805
+
806
+ function clearStructuredQueuePersistence(sessionId) {
807
+ try {
808
+ var saved = localStorage.getItem("wand-structured-queue");
809
+ if (!saved) return;
810
+ var parsed = JSON.parse(saved);
811
+ if (!sessionId || !parsed || parsed.sessionId === sessionId) {
812
+ localStorage.removeItem("wand-structured-queue");
813
+ }
814
+ } catch (e) {
815
+ localStorage.removeItem("wand-structured-queue");
816
+ }
817
+ }
818
+
819
+ function restoreStructuredQueue() {
820
+ var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
821
+ if (selectedSession && Array.isArray(selectedSession.queuedMessages)) {
822
+ syncStructuredQueueFromSession(selectedSession);
823
+ saveStructuredQueue();
824
+ return;
825
+ }
826
+ try {
827
+ var saved = localStorage.getItem("wand-structured-queue");
828
+ if (!saved) return;
829
+ var parsed = JSON.parse(saved);
830
+ if (!parsed || parsed.sessionId !== state.selectedId || !Array.isArray(parsed.items)) {
831
+ return;
832
+ }
833
+ state.structuredInputQueue = parsed.items.slice(0, 10);
834
+ } catch (e) {
835
+ state.structuredInputQueue = [];
836
+ }
837
+ }
838
+
839
+ function persistCrossSessionQueue() {
840
+ try {
841
+ if (state.crossSessionQueue.length === 0) {
842
+ localStorage.removeItem("wand-cross-session-queue");
843
+ return;
844
+ }
845
+ localStorage.setItem("wand-cross-session-queue", JSON.stringify(state.crossSessionQueue));
846
+ } catch (e) {
847
+ // Ignore localStorage errors
848
+ }
849
+ }
850
+
851
+ function getConfigCwd() {
852
+ return (state.config && state.config.defaultCwd) || "/tmp";
853
+ }
854
+
855
+ function loadChatExpandStateMap() {
856
+ try {
857
+ var saved = localStorage.getItem(CHAT_EXPAND_STATE_STORAGE_KEY);
858
+ if (!saved) return {};
859
+ var parsed = JSON.parse(saved);
860
+ return parsed && typeof parsed === "object" ? parsed : {};
861
+ } catch (e) {
862
+ return {};
863
+ }
864
+ }
865
+
866
+ function getDefaultChatMessageExpanded() {
867
+ return getConfiguredPanelDefaults().chatMessageExpanded;
868
+ }
869
+
870
+ function getDefaultStructuredCardExpanded(cardType, fallbackValue) {
871
+ var defaults = getConfiguredPanelDefaults();
872
+ if (cardType === "thinking") {
873
+ return defaults.structuredThinkingExpanded;
874
+ }
875
+ if (cardType === "tool-group") {
876
+ return defaults.structuredToolGroupExpanded;
877
+ }
878
+ if (cardType === "inline-tool") {
879
+ return defaults.structuredInlineToolExpanded;
880
+ }
881
+ if (cardType === "terminal") {
882
+ return defaults.structuredTerminalExpanded;
883
+ }
884
+ if (cardType === "tool-card") {
885
+ return defaults.structuredToolCardExpanded;
886
+ }
887
+ if (typeof fallbackValue === "boolean") {
888
+ return fallbackValue;
889
+ }
890
+ return defaults.chatMessageExpanded;
891
+ }
892
+
893
+ function hasPersistedExpandState(itemKey) {
894
+ if (!itemKey || !state.selectedId) return false;
895
+ var sessionState = getCurrentChatExpandState();
896
+ return typeof sessionState[itemKey] === "boolean";
897
+ }
898
+
899
+ function getExpandState(itemKey, cardType, fallbackValue) {
900
+ if (!itemKey || !state.selectedId) {
901
+ if (typeof fallbackValue === "boolean") return fallbackValue;
902
+ return getDefaultStructuredCardExpanded(cardType, fallbackValue);
903
+ }
904
+ var sessionState = getCurrentChatExpandState();
905
+ if (typeof sessionState[itemKey] === "boolean") return sessionState[itemKey];
906
+ return getDefaultStructuredCardExpanded(cardType, fallbackValue);
907
+ }
908
+
909
+ function getPersistedExpandState(itemKey) {
910
+ if (!itemKey || !state.selectedId) return null;
911
+ var sessionState = getCurrentChatExpandState();
912
+ if (typeof sessionState[itemKey] === "boolean") return sessionState[itemKey];
913
+ return null;
914
+ }
915
+
916
+ function saveChatExpandStateMap(map) {
917
+ try {
918
+ if (!map || Object.keys(map).length === 0) {
919
+ localStorage.removeItem(CHAT_EXPAND_STATE_STORAGE_KEY);
920
+ return;
921
+ }
922
+ localStorage.setItem(CHAT_EXPAND_STATE_STORAGE_KEY, JSON.stringify(map));
923
+ } catch (e) {
924
+ // Ignore localStorage errors
925
+ }
926
+ }
927
+
928
+ function getCurrentChatExpandState() {
929
+ var sessionId = state.selectedId;
930
+ if (!sessionId) return {};
931
+ var map = loadChatExpandStateMap();
932
+ var sessionState = map[sessionId];
933
+ return sessionState && typeof sessionState === "object" ? sessionState : {};
934
+ }
935
+
936
+ function setPersistedExpandState(itemKey, expanded) {
937
+ if (!itemKey || !state.selectedId) return;
938
+ var map = loadChatExpandStateMap();
939
+ var sessionId = state.selectedId;
940
+ var sessionState = map[sessionId];
941
+ if (!sessionState || typeof sessionState !== "object") {
942
+ sessionState = {};
943
+ }
944
+ sessionState[itemKey] = !!expanded;
945
+ map[sessionId] = sessionState;
946
+ saveChatExpandStateMap(map);
947
+ }
948
+
949
+ function getMessageKey(msg, fallbackIndex) {
950
+ if (!msg) {
951
+ return "msg:unknown-" + (typeof fallbackIndex === "number" ? fallbackIndex : 0);
952
+ }
953
+ if (msg.uuid) return "msg:" + msg.uuid;
954
+ if (msg.id) return "msg:" + msg.id;
955
+ if (msg.messageId) return "msg:" + msg.messageId;
956
+ if (msg.turnId) return "msg:" + msg.turnId;
957
+ return "msg:" + (typeof fallbackIndex === "number" ? fallbackIndex : 0);
958
+ }
959
+
960
+ function buildExpandKey(kind, parts) {
961
+ var filtered = [];
962
+ for (var i = 0; i < parts.length; i++) {
963
+ var part = parts[i];
964
+ if (part === undefined || part === null || part === "") continue;
965
+ filtered.push(String(part));
966
+ }
967
+ return kind + ":" + filtered.join(":");
968
+ }
969
+
970
+ function getElementExpandKey(el) {
971
+ if (!el || !el.dataset) return "";
972
+ return el.dataset.expandKey || "";
973
+ }
974
+
975
+ function isElementExpanded(el, kind) {
976
+ if (!el) return false;
977
+ switch (kind) {
978
+ case "tool-card":
979
+ return !el.classList.contains("collapsed");
980
+ case "thinking":
981
+ return el.classList.contains("expanded") && !el.classList.contains("collapsed");
982
+ case "inline-tool":
983
+ return el.classList.contains("inline-tool-open");
984
+ case "terminal": {
985
+ var body = el.querySelector(".term-body");
986
+ if (body) return body.style.display !== "none";
987
+ return el.dataset.expanded === "true";
988
+ }
989
+ case "tool-group":
990
+ return el.getAttribute("data-expanded") === "true";
991
+ default:
992
+ return false;
993
+ }
994
+ }
995
+
996
+ function applyExpandedState(el, kind, expanded) {
997
+ if (!el) return;
998
+ switch (kind) {
999
+ case "tool-card": {
1000
+ el.classList.toggle("collapsed", !expanded);
1001
+ break;
1002
+ }
1003
+ case "thinking": {
1004
+ el.classList.toggle("collapsed", !expanded);
1005
+ el.classList.toggle("expanded", !!expanded);
1006
+ var previewEl = el.querySelector(".thinking-inline-preview");
1007
+ if (previewEl) {
1008
+ var fullText = el.dataset.thinking || "";
1009
+ var preview = fullText.slice(0, 57) + (fullText.length > 60 ? "…" : "");
1010
+ previewEl.textContent = expanded ? fullText : preview;
1011
+ }
1012
+ var actionEl = el.querySelector(".thinking-inline-action");
1013
+ if (actionEl) actionEl.textContent = expanded ? "收起" : "展开";
1014
+ break;
1015
+ }
1016
+ case "inline-tool": {
1017
+ el.classList.toggle("inline-tool-open", !!expanded);
1018
+ var inlineBody = el.querySelector(".inline-tool-expanded");
1019
+ if (inlineBody) inlineBody.style.display = expanded ? "block" : "none";
1020
+ break;
1021
+ }
1022
+ case "terminal": {
1023
+ var body = el.querySelector(".term-body");
1024
+ if (body) body.style.display = expanded ? "block" : "none";
1025
+ el.dataset.expanded = expanded ? "true" : "false";
1026
+ var toggleIcon = el.querySelector(".term-toggle-icon");
1027
+ if (toggleIcon) toggleIcon.textContent = expanded ? "▼" : "▶";
1028
+ break;
1029
+ }
1030
+ case "tool-group": {
1031
+ el.setAttribute("data-expanded", expanded ? "true" : "false");
1032
+ var groupBody = el.querySelector(".tool-group-body");
1033
+ if (groupBody) groupBody.style.display = expanded ? "block" : "none";
1034
+ var chevron = el.querySelector(".tool-group-chevron");
1035
+ if (chevron) chevron.style.transform = expanded ? "rotate(180deg)" : "";
1036
+ break;
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ function persistElementExpandState(el, kind) {
1042
+ var itemKey = getElementExpandKey(el);
1043
+ if (!itemKey) return;
1044
+ setPersistedExpandState(itemKey, isElementExpanded(el, kind));
258
1045
  }
259
1046
 
260
- function getConfigCwd() {
261
- return (state.config && state.config.defaultCwd) || "/tmp";
1047
+ function applyPersistedExpandState(container) {
1048
+ if (!container || !state.selectedId) return;
1049
+ container.querySelectorAll("[data-expand-key]").forEach(function(el) {
1050
+ var itemKey = getElementExpandKey(el);
1051
+ var kind = el.dataset.expandKind || "";
1052
+ if (!kind || !hasPersistedExpandState(itemKey)) return;
1053
+ applyExpandedState(el, kind, getPersistedExpandState(itemKey));
1054
+ });
262
1055
  }
263
1056
 
264
1057
  function resetChatRenderCache() {
265
1058
  state.lastRenderedHash = 0;
266
1059
  state.lastRenderedMsgCount = 0;
267
1060
  state.lastRenderedEmpty = null;
1061
+ state.renderPending = false;
1062
+ if (state.chatScrollElement && state.chatScrollHandler) {
1063
+ state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
1064
+ }
1065
+ state.chatScrollElement = null;
1066
+ state.chatScrollHandler = null;
1067
+ state.showChatJumpToBottom = false;
1068
+ state.chatIsProgrammaticScroll = false;
268
1069
  }
269
1070
 
270
1071
  function getEffectiveCwd() {
@@ -314,9 +1115,6 @@
314
1115
  }
315
1116
  }
316
1117
 
317
- renderBootLoading();
318
- restoreLoginSession();
319
-
320
1118
  function renderBootLoading() {
321
1119
  var app = document.getElementById("app");
322
1120
  if (!app) return;
@@ -324,11 +1122,65 @@
324
1122
  '<div class="boot-loading">' +
325
1123
  '<div class="boot-loading-card">' +
326
1124
  '<div class="boot-loading-spinner"></div>' +
327
- '<div class="boot-loading-text">正在恢复会话…</div>' +
1125
+ '<div class="boot-loading-text">正在连接 Wand…</div>' +
328
1126
  '</div>' +
329
1127
  '</div>';
330
1128
  }
331
1129
 
1130
+ function scheduleForegroundSync(reason) {
1131
+ if (!state.config) return;
1132
+ if (document.hidden) return;
1133
+ var now = Date.now();
1134
+ if (now - state.lastForegroundSyncAt < 1500) return;
1135
+ state.lastForegroundSyncAt = now;
1136
+ if (state.foregroundSyncTimer) {
1137
+ clearTimeout(state.foregroundSyncTimer);
1138
+ }
1139
+ state.foregroundSyncTimer = setTimeout(function() {
1140
+ state.foregroundSyncTimer = null;
1141
+ syncOnForeground(reason);
1142
+ }, 80);
1143
+ }
1144
+
1145
+ function syncOnForeground(reason) {
1146
+ if (!state.config) return Promise.resolve();
1147
+ if (document.hidden) return Promise.resolve();
1148
+ if (!state.ws || (state.ws.readyState !== WebSocket.OPEN && state.ws.readyState !== WebSocket.CONNECTING)) {
1149
+ initWebSocket();
1150
+ }
1151
+ return loadSessions({ skipSelectedOutputReload: true }).then(function() {
1152
+ if (state.selectedId) {
1153
+ return loadOutput(state.selectedId);
1154
+ }
1155
+ scheduleChatRender(true);
1156
+ }).catch(function(e) {
1157
+ console.error("[wand] foreground sync failed:", reason, e);
1158
+ });
1159
+ }
1160
+
1161
+ function bindForegroundSyncListeners() {
1162
+ if (window.__wandForegroundSyncBound) return;
1163
+ window.__wandForegroundSyncBound = true;
1164
+
1165
+ document.addEventListener("visibilitychange", function() {
1166
+ if (!document.hidden) {
1167
+ scheduleForegroundSync("visibility");
1168
+ }
1169
+ });
1170
+
1171
+ window.addEventListener("focus", function() {
1172
+ scheduleForegroundSync("focus");
1173
+ });
1174
+
1175
+ window.addEventListener("pageshow", function() {
1176
+ scheduleForegroundSync("pageshow");
1177
+ });
1178
+
1179
+ window.addEventListener("resume", function() {
1180
+ scheduleForegroundSync("resume");
1181
+ });
1182
+ }
1183
+
332
1184
  function restoreLoginSession() {
333
1185
  fetch("/api/config", { credentials: "same-origin" })
334
1186
  .then(function(res) {
@@ -341,23 +1193,19 @@
341
1193
  })
342
1194
  .then(function(config) {
343
1195
  if (!config) return;
344
- state.config = config;
1196
+ applySettingsConfig(config);
345
1197
  state.loginChecked = true;
346
1198
  requestAnimationFrame(function() {
347
- // Render the app shell first, THEN load session data into it.
348
- // Skip updateShellChrome() here — sessions aren't loaded yet.
349
- // refreshAll() will call updateShellChrome() after sessions arrive.
350
1199
  try {
351
1200
  render({ skipShellChrome: true });
352
1201
  } catch (_e) {
353
1202
  // render() may fail if external scripts (xterm.js) failed to load;
354
1203
  // continue with polling and session loading so the app remains functional
355
1204
  }
1205
+ bindForegroundSyncListeners();
356
1206
  startPolling();
357
1207
  refreshAll();
358
- // Request browser notification permission after login
359
1208
  requestNotificationPermission();
360
- // Show update bubble if server reports an available update
361
1209
  if (config.updateAvailable && config.latestVersion) {
362
1210
  showNotificationBubble({
363
1211
  title: "\u53d1\u73b0\u65b0\u7248\u672c",
@@ -373,7 +1221,6 @@
373
1221
  });
374
1222
  sendBrowserNotification("Wand \u53d1\u73b0\u65b0\u7248\u672c", "\u5f53\u524d " + (config.currentVersion || "-") + " \u2192 \u6700\u65b0 " + config.latestVersion, { tag: "wand-update" });
375
1223
  }
376
- // Auto-load claude history since section defaults to expanded
377
1224
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
378
1225
  loadClaudeHistory();
379
1226
  }
@@ -381,7 +1228,6 @@
381
1228
  })
382
1229
  .catch(function() {
383
1230
  state.loginChecked = true;
384
- // If offline (no network), show a friendly offline message instead of login
385
1231
  if (!navigator.onLine) {
386
1232
  var app = document.getElementById("app");
387
1233
  if (app) {
@@ -394,7 +1240,6 @@
394
1240
  '</div>' +
395
1241
  '</div>';
396
1242
  }
397
- // Retry when network comes back
398
1243
  window.addEventListener('online', function() { location.reload(); }, { once: true });
399
1244
  return;
400
1245
  }
@@ -402,6 +1247,9 @@
402
1247
  });
403
1248
  }
404
1249
 
1250
+ renderBootLoading();
1251
+ restoreLoginSession();
1252
+
405
1253
  function render(options) {
406
1254
  var skipShellChrome = options && options.skipShellChrome;
407
1255
  var app = document.getElementById("app");
@@ -565,12 +1413,9 @@
565
1413
  var preferredTool = getComposerTool();
566
1414
  var composerMode = getSafeModeForTool(preferredTool, state.chatMode);
567
1415
 
1416
+ var showTerminalHeaderControls = !!selectedSession && state.currentView === "terminal";
1417
+ var showChatHeaderControls = !!selectedSession && state.currentView !== "terminal";
568
1418
  return '<div class="app-container">' +
569
- '<button id="sessions-toggle-button" class="floating-sidebar-toggle' + (state.sessionsDrawerOpen ? ' active' : '') + '" aria-label="Toggle sidebar">' +
570
- '<span class="hamburger-icon">' +
571
- '<span></span><span></span><span></span>' +
572
- '</span>' +
573
- '</button>' +
574
1419
  '<div id="sessions-drawer-backdrop" class="drawer-backdrop' + drawerClass + '"></div>' +
575
1420
  '<div class="main-layout' + (state.sessionsDrawerOpen ? ' sidebar-open' : '') + '">' +
576
1421
  '<aside id="sessions-drawer" class="sidebar' + drawerClass + '">' +
@@ -612,8 +1457,37 @@
612
1457
  '</div>' +
613
1458
  '</aside>' +
614
1459
  '<main class="main-content">' +
615
- '<span class="current-task hidden" id="current-task"></span>' +
616
- '' +
1460
+ '<div class="main-content-header">' +
1461
+ '<div class="main-content-header-left">' +
1462
+ '<button id="sessions-toggle-button" class="main-header-btn menu-toggle-btn' + (state.sessionsDrawerOpen ? ' active' : '') + '" type="button" aria-label="打开菜单" title="菜单">' +
1463
+ '<span class="hamburger-icon">' +
1464
+ '<span></span><span></span><span></span>' +
1465
+ '</span>' +
1466
+ '</button>' +
1467
+ '<span class="current-task hidden" id="current-task"></span>' +
1468
+ '</div>' +
1469
+ '<div class="main-content-header-center">' +
1470
+ '<div class="view-toggle-bar' + (state.selectedId ? '' : ' hidden') + '" id="view-toggle-bar">' +
1471
+ '<button id="view-terminal-btn" class="topbar-btn' + (state.currentView === "terminal" ? ' active' : '') + '" type="button" title="查看原始终端输出">终端</button>' +
1472
+ '<button id="view-chat-btn" class="topbar-btn' + (state.currentView !== "terminal" ? ' active' : '') + '" type="button" title="查看聊天解析视图">聊天</button>' +
1473
+ '</div>' +
1474
+ '</div>' +
1475
+ '<div class="main-content-header-right">' +
1476
+ '<div class="main-header-controls' + (showTerminalHeaderControls ? '' : ' hidden') + '" id="terminal-header-controls">' +
1477
+ '<button id="terminal-scale-down-top" class="main-header-btn terminal-scale-btn" type="button" title="缩小">−</button>' +
1478
+ '<span class="main-header-label terminal-scale-label" id="terminal-scale-label-top">' + Math.round(state.terminalScale * 100) + '%</span>' +
1479
+ '<button id="terminal-scale-up-top" class="main-header-btn terminal-scale-btn" type="button" title="放大">+</button>' +
1480
+ '<button id="page-refresh-btn" class="main-header-btn" type="button" title="刷新页面">↻</button>' +
1481
+ '<button id="terminal-jump-bottom" class="main-header-btn jump-latest-btn' + (state.showTerminalJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部">↓ 最新</button>' +
1482
+ '</div>' +
1483
+ '<div class="main-header-controls' + (showChatHeaderControls ? '' : ' hidden') + '" id="chat-header-controls">' +
1484
+ '<button id="chat-follow-toggle" class="chat-follow-toggle topbar-btn' + (state.chatAutoFollow ? ' active' : '') + '" type="button" aria-pressed="' + (state.chatAutoFollow ? 'true' : 'false') + '" title="' + (state.chatAutoFollow ? '追踪底部:开启' : '追踪底部:已暂停') + '">' + (state.chatAutoFollow ? '追底' : '暂停') + '</button>' +
1485
+ '<button id="chat-jump-bottom" class="main-header-btn jump-latest-btn' + (state.showChatJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部并继续追底">↓ 最新</button>' +
1486
+ '</div>' +
1487
+ '<button id="topbar-new-session-button" class="main-header-btn main-header-new-session" type="button" title="新对话">+ 新对话</button>' +
1488
+ '</div>' +
1489
+ '</div>' +
1490
+ '<div class="main-content-body">' +
617
1491
  // File panel backdrop (mobile)
618
1492
  '<div id="file-panel-backdrop" class="file-panel-backdrop' + (state.filePanelOpen ? " open" : "") + '"></div>' +
619
1493
  // File side panel
@@ -634,16 +1508,7 @@
634
1508
  '<div class="file-explorer" id="file-explorer">' + renderFileExplorer(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '</div>' +
635
1509
  '</div>' +
636
1510
  '</div>' +
637
- '<div id="output" class="terminal-container' + (state.selectedId ? "" : " hidden") + ' active">' +
638
- '<div class="terminal-scale-overlay" aria-label="终端缩放控件">' +
639
- '<button id="terminal-scale-down-top" class="terminal-scale-overlay-btn terminal-scale-btn" type="button" title="缩小">−</button>' +
640
- '<span class="terminal-scale-overlay-label terminal-scale-label" id="terminal-scale-label-top">' + Math.round(state.terminalScale * 100) + '%</span>' +
641
- '<button id="terminal-scale-up-top" class="terminal-scale-overlay-btn terminal-scale-btn" type="button" title="放大">+</button>' +
642
- '<span class="terminal-scale-overlay-divider"></span>' +
643
- '<button id="page-refresh-btn" class="terminal-scale-overlay-btn" type="button" title="刷新页面">↻</button>' +
644
- '</div>' +
645
- '<button id="terminal-jump-bottom" class="terminal-jump-bottom' + (state.showTerminalJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部">↓ 最新</button>' +
646
- '</div>' +
1511
+ '<div id="output" class="terminal-container' + (state.selectedId ? "" : " hidden") + ' active"></div>' +
647
1512
  '<div id="chat-output" class="chat-container hidden"></div>' +
648
1513
  '<div id="blank-chat" class="blank-chat' + (state.selectedId ? " hidden" : "") + '">' +
649
1514
  '<div class="blank-chat-inner">' +
@@ -654,6 +1519,9 @@
654
1519
  '<button class="blank-chat-tool-btn" id="welcome-tool-claude" type="button">' +
655
1520
  '<span class="tool-icon">🤖</span>新建终端会话' +
656
1521
  '</button>' +
1522
+ '<button class="blank-chat-tool-btn" id="welcome-tool-codex" type="button">' +
1523
+ '<span class="tool-icon">⌘</span>新建 Codex 会话' +
1524
+ '</button>' +
657
1525
  '<button class="blank-chat-tool-btn" id="welcome-tool-structured" type="button">' +
658
1526
  '<span class="tool-icon">💬</span>新建结构化会话' +
659
1527
  '</button>' +
@@ -683,7 +1551,7 @@
683
1551
  '</div>' +
684
1552
  '</div>' +
685
1553
  '<div class="input-composer">' +
686
- '<textarea id="input-box" class="input-textarea" placeholder="' + (state.terminalInteractive ? "终端交互模式开启中,请直接在终端中输入" : "输入消息...") + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
1554
+ '<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
687
1555
  '<div class="input-composer-bar">' +
688
1556
  '<div class="input-composer-left">' +
689
1557
  '<select id="chat-mode-select" class="chat-mode-select" title="仅对新建会话生效">' +
@@ -721,7 +1589,7 @@
721
1589
  '<span id="session-kind-display" class="session-kind-display">' + (selectedSession ? getSessionKindLabel(selectedSession) : '终端') + '</span>' +
722
1590
  '<span class="session-info-separator">|</span>' +
723
1591
  '<span id="session-status-display" class="session-status-display">' + (selectedSession ? getSessionStatusLabel(selectedSession) : '-') + '</span>' +
724
- (selectedSession && selectedSession.claudeSessionId ? '<span class="session-info-separator">|</span><span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>' : '') +
1592
+ (selectedSession && selectedSession.provider === "claude" && selectedSession.claudeSessionId ? '<span class="session-info-separator">|</span><span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>' : '') +
725
1593
  (selectedSession && !isStructuredSession(selectedSession) ? '<span class="session-info-separator">|</span><span id="session-exit-display" class="session-exit-display">退出码=' + (selectedSession.exitCode !== undefined ? selectedSession.exitCode : 'n/a') + '</span>' : '') +
726
1594
  '</div>' +
727
1595
  '</div>' +
@@ -751,7 +1619,29 @@
751
1619
  '</section>' +
752
1620
  '</main>' +
753
1621
  '</div>' +
754
- '</div>' + renderSessionModal() + renderSettingsModal();
1622
+ '</div>' + renderSessionModal() + renderWorktreeMergeModal() + renderSettingsModal();
1623
+ }
1624
+
1625
+ function renderWorktreeMergeModal() {
1626
+ return '<section id="worktree-merge-modal" class="modal-backdrop hidden">' +
1627
+ '<div class="modal worktree-merge-modal">' +
1628
+ '<div class="modal-header">' +
1629
+ '<div>' +
1630
+ '<h2 class="modal-title">合并 Worktree</h2>' +
1631
+ '<p class="modal-subtitle">检查当前任务分支并快捷合并到主分支。</p>' +
1632
+ '</div>' +
1633
+ '<button id="close-worktree-merge-button" class="btn btn-ghost btn-icon">&times;</button>' +
1634
+ '</div>' +
1635
+ '<div class="modal-body">' +
1636
+ '<div id="worktree-merge-content" class="worktree-merge-content"></div>' +
1637
+ '<p id="worktree-merge-error" class="error-message hidden"></p>' +
1638
+ '<div class="worktree-merge-actions">' +
1639
+ '<button id="worktree-merge-cancel-button" class="btn btn-secondary">取消</button>' +
1640
+ '<button id="worktree-merge-confirm-button" class="btn btn-primary">确认合并并清理</button>' +
1641
+ '</div>' +
1642
+ '</div>' +
1643
+ '</div>' +
1644
+ '</section>';
755
1645
  }
756
1646
 
757
1647
  function renderSettingsModal() {
@@ -761,16 +1651,9 @@
761
1651
  '<h2 class="modal-title">设置</h2>' +
762
1652
  '<button id="close-settings-button" class="btn btn-ghost btn-icon">×</button>' +
763
1653
  '</div>' +
764
- '<div class="modal-body">' +
765
- // Tabs
766
- '<div class="settings-tabs">' +
767
- '<button class="settings-tab active" data-tab="about">关于</button>' +
768
- '<button class="settings-tab" data-tab="general">基本配置</button>' +
769
- '<button class="settings-tab" data-tab="security">安全</button>' +
770
- '<button class="settings-tab" data-tab="presets">命令预设</button>' +
771
- '</div>' +
772
-
773
- // About tab
1654
+ '<div class="modal-body settings-layout">' +
1655
+ renderSettingsNav() +
1656
+ '<div class="settings-content">' +
774
1657
  '<div class="settings-panel active" id="settings-tab-about">' +
775
1658
  '<div class="settings-about-info">' +
776
1659
  '<div class="settings-about-row"><span class="settings-label">包名</span><span class="settings-value" id="settings-pkg-name">-</span></div>' +
@@ -803,63 +1686,7 @@
803
1686
  '</div>' +
804
1687
  '</div>' +
805
1688
 
806
- // General config tab
807
- '<div class="settings-panel" id="settings-tab-general">' +
808
- '<div class="field-row">' +
809
- '<div class="field">' +
810
- '<label class="field-label" for="cfg-host">监听地址 (host)</label>' +
811
- '<input id="cfg-host" type="text" class="field-input" placeholder="127.0.0.1" />' +
812
- '</div>' +
813
- '<div class="field">' +
814
- '<label class="field-label" for="cfg-port">端口 (port)</label>' +
815
- '<input id="cfg-port" type="number" class="field-input" placeholder="8443" min="1" max="65535" />' +
816
- '</div>' +
817
- '</div>' +
818
- '<div class="field field-inline">' +
819
- '<input id="cfg-https" type="checkbox" class="field-checkbox" />' +
820
- '<label class="field-label" for="cfg-https">启用 HTTPS</label>' +
821
- '</div>' +
822
- '<div class="field-row">' +
823
- '<div class="field">' +
824
- '<label class="field-label" for="cfg-mode">默认执行模式</label>' +
825
- '<select id="cfg-mode" class="field-input">' +
826
- '<option value="default">default</option>' +
827
- '<option value="assist">assist</option>' +
828
- '<option value="agent">agent</option>' +
829
- '<option value="agent-max">agent-max</option>' +
830
- '<option value="auto-edit">auto-edit</option>' +
831
- '<option value="full-access">full-access</option>' +
832
- '<option value="native">native</option>' +
833
- '<option value="managed">managed</option>' +
834
- '</select>' +
835
- '</div>' +
836
- '<div class="field">' +
837
- '<label class="field-label" for="cfg-language">回复语言</label>' +
838
- '<select id="cfg-language" class="field-input">' +
839
- '<option value="">自动(不指定)</option>' +
840
- '<option value="中文">中文</option>' +
841
- '<option value="English">English</option>' +
842
- '<option value="日本語">日本語</option>' +
843
- '<option value="한국어">한국어</option>' +
844
- '<option value="Español">Español</option>' +
845
- '<option value="Français">Français</option>' +
846
- '<option value="Deutsch">Deutsch</option>' +
847
- '<option value="Русский">Русский</option>' +
848
- '</select>' +
849
- '</div>' +
850
- '</div>' +
851
- '<p class="field-hint" style="margin-top:-4px;">设置回复语言后,Claude 将尽量使用指定语言回复。</p>' +
852
- '<div class="field">' +
853
- '<label class="field-label" for="cfg-cwd">默认工作目录</label>' +
854
- '<input id="cfg-cwd" type="text" class="field-input" placeholder="/home/user" />' +
855
- '</div>' +
856
- '<div class="field">' +
857
- '<label class="field-label" for="cfg-shell">Shell</label>' +
858
- '<input id="cfg-shell" type="text" class="field-input" placeholder="/bin/bash" />' +
859
- '</div>' +
860
- '<button id="save-config-button" class="btn btn-primary btn-block">保存配置</button>' +
861
- '<p id="config-message" class="hint hidden"></p>' +
862
- '</div>' +
1689
+ buildSettingsGeneralPanel() +
863
1690
 
864
1691
  // Security tab
865
1692
  '<div class="settings-panel" id="settings-tab-security">' +
@@ -897,6 +1724,7 @@
897
1724
  '<div class="settings-panel" id="settings-tab-presets">' +
898
1725
  '<div id="presets-list" class="presets-list"></div>' +
899
1726
  '</div>' +
1727
+ '</div>' +
900
1728
  '</div>' +
901
1729
  '</div>' +
902
1730
  '</section>';
@@ -1259,9 +2087,7 @@
1259
2087
 
1260
2088
  function setFilePanelOpen(nextOpen) {
1261
2089
  state.filePanelOpen = nextOpen;
1262
- try {
1263
- localStorage.setItem("wand-file-panel-open", String(state.filePanelOpen));
1264
- } catch (e) {}
2090
+ persistFilePanelState();
1265
2091
  if (state.filePanelOpen && isMobileLayout()) {
1266
2092
  state.sessionsDrawerOpen = false;
1267
2093
  }
@@ -1796,7 +2622,7 @@
1796
2622
  var recoveryHint = "";
1797
2623
  var checkbox = renderManageCheckbox("sessions", session.id, "选择会话 " + session.command);
1798
2624
 
1799
- if (session.claudeSessionId) {
2625
+ if (session.provider === "claude" && session.claudeSessionId) {
1800
2626
  var shortId = session.claudeSessionId.slice(0, 8);
1801
2627
  sessionIdDisplay = '<span class="session-id" title="' + escapeHtml(session.claudeSessionId) + '">' + escapeHtml(shortId) + '</span>';
1802
2628
  if (session.status !== "running" && !state.sessionsManageMode && !isStructuredSession(session)) {
@@ -1808,9 +2634,18 @@
1808
2634
  recoveryHint = '<span class="session-id" title="自动恢复的会话">自动恢复</span>';
1809
2635
  }
1810
2636
 
2637
+ var canOpenMerge = !state.sessionsManageMode && session.worktreeEnabled && session.worktree && session.worktree.branch && session.worktree.path;
2638
+ var needsCleanup = session.worktreeMergeStatus === "merged" && session.worktreeMergeInfo && session.worktreeMergeInfo.cleanupDone === false;
2639
+ var mergeDisabled = session.status === "running" || session.worktreeMergeStatus === "merging";
2640
+ var mergeTitle = needsCleanup ? "重试清理 worktree" : "合并到主分支";
2641
+ var mergeButton = canOpenMerge && session.worktreeMergeStatus !== "merged"
2642
+ ? '<button class="session-action-btn merge-btn" data-action="worktree-merge" data-session-id="' + session.id + '" type="button" aria-label="' + escapeHtml(mergeTitle) + '" title="' + escapeHtml(mergeTitle) + '"' + (mergeDisabled ? ' disabled' : '') + '><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h10"/><path d="M7 12h10"/><path d="M7 17h10"/><path d="M5 7l-2 2 2 2"/><path d="M19 15l2 2-2 2"/></svg></button>'
2643
+ : needsCleanup
2644
+ ? '<button class="session-action-btn merge-btn" data-action="worktree-cleanup" data-session-id="' + session.id + '" type="button" aria-label="重试清理 worktree" title="重试清理 worktree"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg></button>'
2645
+ : "";
1811
2646
  var deleteButton = state.sessionsManageMode ? '' : '<button class="session-action-btn delete-btn" data-action="delete-session" data-session-id="' + session.id + '" type="button" aria-label="删除会话" title="删除此会话"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg></button>';
1812
2647
  var modeBadge = renderSessionKindBadge(session);
1813
- var actionsHtml = '<span class="session-actions">' + resumeButton + deleteButton + '</span>';
2648
+ var actionsHtml = '<span class="session-actions">' + resumeButton + mergeButton + deleteButton + '</span>';
1814
2649
 
1815
2650
  return '<div class="session-item' + activeClass + selectedClass + '" data-session-id="' + session.id + '" role="button" tabindex="0">' +
1816
2651
  '<div class="session-item-content">' +
@@ -1834,12 +2669,43 @@
1834
2669
  '</div>';
1835
2670
  }
1836
2671
 
2672
+ function getWorktreeMergeStatusLabel(session) {
2673
+ if (!session || !session.worktreeMergeStatus) return "";
2674
+ var labels = {
2675
+ ready: "可合并",
2676
+ checking: "检查中",
2677
+ merging: "合并中",
2678
+ merged: session.worktreeMergeInfo && session.worktreeMergeInfo.cleanupDone === false ? "已合并待清理" : "已合并",
2679
+ failed: "合并失败"
2680
+ };
2681
+ return labels[session.worktreeMergeStatus] || "";
2682
+ }
2683
+
2684
+ function renderWorktreeMergeBadge(session) {
2685
+ var label = getWorktreeMergeStatusLabel(session);
2686
+ if (!label) return "";
2687
+ return '<span class="session-kind-badge worktree-merge ' + escapeHtml(session.worktreeMergeStatus || "") + '">' + escapeHtml(label) + '</span>';
2688
+ }
2689
+
2690
+ function renderWorktreeBadge(session) {
2691
+ if (!session || !session.worktreeEnabled) return "";
2692
+ var titleParts = [];
2693
+ if (session.worktree && session.worktree.branch) {
2694
+ titleParts.push('Worktree: ' + session.worktree.branch);
2695
+ }
2696
+ if (session.worktree && session.worktree.path) {
2697
+ titleParts.push('Path: ' + session.worktree.path);
2698
+ }
2699
+ var title = titleParts.length > 0 ? ' title="' + escapeHtml(titleParts.join('\n')) + '"' : '';
2700
+ return '<span class="session-kind-badge worktree"' + title + '>Worktree</span>' + renderWorktreeMergeBadge(session);
2701
+ }
2702
+
1837
2703
  function renderSessionKindBadge(session) {
1838
2704
  if (!session) return "";
1839
- if (isStructuredSession(session)) {
1840
- return '<span class="session-kind-badge structured">Structured</span>';
1841
- }
1842
- return '<span class="session-kind-badge pty">PTY</span>';
2705
+ var primary = isStructuredSession(session)
2706
+ ? '<span class="session-kind-badge structured">Structured</span>'
2707
+ : '<span class="session-kind-badge pty">PTY</span>';
2708
+ return primary + renderWorktreeBadge(session);
1843
2709
  }
1844
2710
 
1845
2711
  function renderModeCards(selectedMode) {
@@ -1859,6 +2725,20 @@
1859
2725
  }).join("");
1860
2726
  }
1861
2727
 
2728
+ function renderProviderOptions(selectedTool) {
2729
+ var tools = [
2730
+ { id: "claude", label: "Claude", desc: "完整 Claude 会话能力" },
2731
+ { id: "codex", label: "Codex", desc: "PTY 透传,全权限启动" }
2732
+ ];
2733
+ return tools.map(function(tool) {
2734
+ var active = tool.id === selectedTool ? " active" : "";
2735
+ return '<button type="button" class="mode-card provider-card' + active + '" data-provider="' + tool.id + '">' +
2736
+ '<span class="mode-card-label">' + tool.label + '</span>' +
2737
+ '<span class="mode-card-desc">' + tool.desc + '</span>' +
2738
+ '</button>';
2739
+ }).join("");
2740
+ }
2741
+
1862
2742
  function renderSessionKindOptions(selectedKind) {
1863
2743
  var kinds = [
1864
2744
  { id: "structured", label: "结构化", desc: "智能对话模式" },
@@ -1866,17 +2746,29 @@
1866
2746
  ];
1867
2747
  return kinds.map(function(kind) {
1868
2748
  var active = kind.id === selectedKind ? " active" : "";
1869
- return '<button type="button" class="mode-card session-kind-card' + active + '" data-session-kind="' + kind.id + '">' +
2749
+ var disabled = (state.sessionTool === "codex" && kind.id === "structured") ? " disabled" : "";
2750
+ return '<button type="button" class="mode-card session-kind-card' + active + disabled + '" data-session-kind="' + kind.id + '">' +
1870
2751
  '<span class="mode-card-label">' + kind.label + '</span>' +
1871
2752
  '<span class="mode-card-desc">' + kind.desc + '</span>' +
1872
2753
  '</button>';
1873
2754
  }).join("");
1874
2755
  }
1875
2756
 
2757
+ function renderWorktreeToggle(enabled) {
2758
+ return '<label class="session-inline-toggle" for="session-worktree-toggle">' +
2759
+ '<input id="session-worktree-toggle" type="checkbox" class="field-checkbox"' + (enabled ? ' checked' : '') + ' />' +
2760
+ '<span class="session-inline-toggle-label">Worktree 模式</span>' +
2761
+ '</label>';
2762
+ }
2763
+
1876
2764
  function getSessionKindHint(kind) {
2765
+ var tool = state.sessionTool || "claude";
1877
2766
  if (kind === "structured") {
1878
2767
  return "结构化聊天界面,支持多轮对话、流式输出和工具调用展示。";
1879
2768
  }
2769
+ if (tool === "codex") {
2770
+ return "Codex 仅支持 PTY;terminal 是原始输出,chat 是解析后的阅读视图。";
2771
+ }
1880
2772
  return "原始 PTY 终端会话,支持持续交互、终端视图和权限流。";
1881
2773
  }
1882
2774
 
@@ -1884,22 +2776,32 @@
1884
2776
  var modalTool = getPreferredTool();
1885
2777
  var modalMode = getSafeModeForTool(modalTool, state.modeValue || state.chatMode || "default");
1886
2778
  var sessionKind = state.sessionCreateKind || "structured";
2779
+ var worktreeEnabled = state.sessionCreateWorktree === true;
1887
2780
  return '<section id="session-modal" class="modal-backdrop hidden">' +
1888
2781
  '<div class="modal session-modal">' +
1889
2782
  '<div class="modal-header">' +
1890
2783
  '<div>' +
1891
2784
  '<h2 class="modal-title">新对话</h2>' +
1892
- '<p class="modal-subtitle">启动 Claude 会话,选择会话类型、模式和工作目录。</p>' +
2785
+ '<p class="modal-subtitle">启动 Claude 或 Codex 会话,选择 provider、会话类型、模式和工作目录。</p>' +
1893
2786
  '</div>' +
1894
2787
  '<button id="close-modal-button" class="btn btn-ghost btn-icon">&times;</button>' +
1895
2788
  '</div>' +
1896
2789
  '<div class="modal-body">' +
2790
+ '<div class="field">' +
2791
+ '<label class="field-label">Provider</label>' +
2792
+ '<div id="provider-cards" class="mode-cards">' +
2793
+ renderProviderOptions(modalTool) +
2794
+ '</div>' +
2795
+ '</div>' +
1897
2796
  '<div class="field">' +
1898
2797
  '<label class="field-label">会话类型</label>' +
1899
2798
  '<div id="session-kind-cards" class="mode-cards">' +
1900
2799
  renderSessionKindOptions(sessionKind) +
1901
2800
  '</div>' +
1902
- '<p id="session-kind-description" class="field-hint">' + escapeHtml(getSessionKindHint(sessionKind)) + '</p>' +
2801
+ '<div class="field-hint session-kind-hint-row">' +
2802
+ '<span id="session-kind-description">' + escapeHtml(getSessionKindHint(sessionKind)) + '</span>' +
2803
+ renderWorktreeToggle(worktreeEnabled) +
2804
+ '</div>' +
1903
2805
  '</div>' +
1904
2806
  '<div class="field">' +
1905
2807
  '<label class="field-label">模式</label>' +
@@ -1927,7 +2829,10 @@
1927
2829
  // Global toggle function for tool card headers — called via onclick attribute
1928
2830
  window.__tcToggle = function(e, headerEl) {
1929
2831
  var card = headerEl.closest(".tool-use-card");
1930
- if (card) card.classList.toggle("collapsed");
2832
+ if (card) {
2833
+ card.classList.toggle("collapsed");
2834
+ persistElementExpandState(card, "tool-card");
2835
+ }
1931
2836
  if (e) { e.preventDefault(); e.stopPropagation(); }
1932
2837
  };
1933
2838
  // Toggle function for inline thinking blocks — called via onclick attribute
@@ -1947,6 +2852,7 @@
1947
2852
  var action = el.querySelector(".thinking-inline-action");
1948
2853
  if (action) action.textContent = "展开";
1949
2854
  }
2855
+ persistElementExpandState(el, "thinking");
1950
2856
  };
1951
2857
  // Toggle function for inline tool rows (Read, Glob, Grep, etc.)
1952
2858
  window.__inlineToolToggle = function(el) {
@@ -1964,6 +2870,7 @@
1964
2870
  statusSpan.textContent = "✓";
1965
2871
  }
1966
2872
  }
2873
+ persistElementExpandState(el, "inline-tool");
1967
2874
  };
1968
2875
  // Toggle function for terminal tool blocks
1969
2876
  window.__terminalExpand = function(el) {
@@ -1976,6 +2883,7 @@
1976
2883
  container.dataset.expanded = isHidden ? "true" : "false";
1977
2884
  var toggleIcon = el.querySelector(".term-toggle-icon");
1978
2885
  if (toggleIcon) toggleIcon.textContent = isHidden ? "▼" : "▶";
2886
+ persistElementExpandState(container, "terminal");
1979
2887
  }
1980
2888
  };
1981
2889
  // Update streaming thinking content (called from WebSocket handler)
@@ -2075,6 +2983,15 @@
2075
2983
  quickStartSession();
2076
2984
  });
2077
2985
  }
2986
+ var welcomeCodexBtn = document.getElementById("welcome-tool-codex");
2987
+ if (welcomeCodexBtn) {
2988
+ welcomeCodexBtn.addEventListener("click", function() {
2989
+ state.sessionTool = "codex";
2990
+ state.preferredCommand = "codex";
2991
+ state.modeValue = "full-access";
2992
+ quickStartSession();
2993
+ });
2994
+ }
2078
2995
  var welcomeStructuredBtn = document.getElementById("welcome-tool-structured");
2079
2996
  if (welcomeStructuredBtn) {
2080
2997
  welcomeStructuredBtn.addEventListener("click", function() {
@@ -2097,10 +3014,26 @@
2097
3014
  // Claude session ID badge click-to-copy (event delegation on document)
2098
3015
  document.addEventListener("click", handleClaudeIdCopy);
2099
3016
 
3017
+ var providerCardsEl = document.getElementById("provider-cards");
3018
+ if (providerCardsEl) providerCardsEl.addEventListener("click", function(e) {
3019
+ var card = e.target.closest(".provider-card");
3020
+ if (!card || card.classList.contains("disabled")) return;
3021
+ var provider = card.getAttribute("data-provider");
3022
+ if (provider) {
3023
+ state.sessionTool = provider;
3024
+ state.preferredCommand = provider;
3025
+ if (provider === "codex") {
3026
+ state.sessionCreateKind = "pty";
3027
+ state.modeValue = "full-access";
3028
+ }
3029
+ syncSessionModalUI();
3030
+ }
3031
+ });
3032
+
2100
3033
  var kindCardsEl = document.getElementById("session-kind-cards");
2101
3034
  if (kindCardsEl) kindCardsEl.addEventListener("click", function(e) {
2102
3035
  var card = e.target.closest(".session-kind-card");
2103
- if (!card) return;
3036
+ if (!card || card.classList.contains("disabled")) return;
2104
3037
  var kind = card.getAttribute("data-session-kind");
2105
3038
  if (kind) {
2106
3039
  state.sessionCreateKind = kind;
@@ -2118,6 +3051,10 @@
2118
3051
  syncSessionModalUI();
2119
3052
  }
2120
3053
  });
3054
+ var worktreeToggleEl = document.getElementById("session-worktree-toggle");
3055
+ if (worktreeToggleEl) worktreeToggleEl.addEventListener("change", function() {
3056
+ state.sessionCreateWorktree = this.checked;
3057
+ });
2121
3058
  var cwdEl = document.getElementById("cwd");
2122
3059
  if (cwdEl) {
2123
3060
  cwdEl.addEventListener("input", function() { state.cwdValue = this.value; });
@@ -2156,15 +3093,7 @@
2156
3093
  });
2157
3094
  var savePassBtn = document.getElementById("save-password-button");
2158
3095
  if (savePassBtn) savePassBtn.addEventListener("click", savePassword);
2159
- // Settings tab clicks
2160
- var settingsTabs = document.querySelectorAll(".settings-tab");
2161
- for (var ti = 0; ti < settingsTabs.length; ti++) {
2162
- settingsTabs[ti].addEventListener("click", function(e) {
2163
- switchSettingsTab(e.target.getAttribute("data-tab"));
2164
- });
2165
- }
2166
- var saveConfigBtn = document.getElementById("save-config-button");
2167
- if (saveConfigBtn) saveConfigBtn.addEventListener("click", saveConfigSettings);
3096
+ bindSettingsModalEvents();
2168
3097
  var uploadCertBtn = document.getElementById("upload-cert-button");
2169
3098
  if (uploadCertBtn) uploadCertBtn.addEventListener("click", uploadCertificates);
2170
3099
  var checkUpdateBtn = document.getElementById("check-update-button");
@@ -2187,6 +3116,12 @@
2187
3116
  if (drawerNewSessBtn) drawerNewSessBtn.addEventListener("click", openSessionModal);
2188
3117
  var closeModalBtn = document.getElementById("close-modal-button");
2189
3118
  if (closeModalBtn) closeModalBtn.addEventListener("click", closeSessionModal);
3119
+ var closeWorktreeMergeBtn = document.getElementById("close-worktree-merge-button");
3120
+ if (closeWorktreeMergeBtn) closeWorktreeMergeBtn.addEventListener("click", closeWorktreeMergeModal);
3121
+ var worktreeMergeCancelBtn = document.getElementById("worktree-merge-cancel-button");
3122
+ if (worktreeMergeCancelBtn) worktreeMergeCancelBtn.addEventListener("click", closeWorktreeMergeModal);
3123
+ var worktreeMergeConfirmBtn = document.getElementById("worktree-merge-confirm-button");
3124
+ if (worktreeMergeConfirmBtn) worktreeMergeConfirmBtn.addEventListener("click", confirmWorktreeMerge);
2190
3125
  var runBtn = document.getElementById("run-button");
2191
3126
  if (runBtn) runBtn.addEventListener("click", runCommand);
2192
3127
  var approvePermissionBtn = document.getElementById("approve-permission-btn");
@@ -2211,6 +3146,7 @@
2211
3146
  var sessionModal = document.getElementById("session-modal");
2212
3147
  if (sessionModal) sessionModal.addEventListener("click", function(e) {
2213
3148
  if (e.target.id === "session-modal") closeSessionModal();
3149
+ if (e.target.id === "worktree-merge-modal") closeWorktreeMergeModal();
2214
3150
  });
2215
3151
 
2216
3152
  var inputBox = document.getElementById("input-box");
@@ -2219,6 +3155,9 @@
2219
3155
  inputBox.addEventListener("keydown", handleInputBoxKeydown);
2220
3156
  inputBox.addEventListener("paste", handleInputPaste);
2221
3157
  inputBox.addEventListener("input", function() {
3158
+ if (handleInteractiveTextInput(inputBox)) {
3159
+ return;
3160
+ }
2222
3161
  refreshInputBoxState(inputBox);
2223
3162
  setDraftValue(inputBox.value, true);
2224
3163
  });
@@ -2233,6 +3172,8 @@
2233
3172
  // View toggle handlers
2234
3173
  var viewTermBtn = document.getElementById("view-terminal-btn");
2235
3174
  if (viewTermBtn) viewTermBtn.addEventListener("click", function() { setView("terminal"); });
3175
+ var viewChatBtn = document.getElementById("view-chat-btn");
3176
+ if (viewChatBtn) viewChatBtn.addEventListener("click", function() { setView("chat"); });
2236
3177
  // Terminal interactive toggle (both topbar and terminal-header)
2237
3178
  var terminalInteractiveToggles = ["terminal-interactive-toggle-top"];
2238
3179
  terminalInteractiveToggles.forEach(function(id) {
@@ -2249,15 +3190,8 @@
2249
3190
  if (shortcutsToggleBtn) shortcutsToggleBtn.addEventListener("click", function(e) {
2250
3191
  e.stopPropagation();
2251
3192
  state.shortcutsExpanded = !state.shortcutsExpanded;
2252
- var wrap = document.querySelector(".inline-shortcuts-wrap");
2253
- var toggle = document.querySelector(".shortcuts-toggle");
2254
- var row = document.querySelector(".inline-shortcuts-expanded-row");
2255
- if (wrap) wrap.classList.toggle("expanded", state.shortcutsExpanded);
2256
- if (row) row.classList.toggle("visible", state.shortcutsExpanded);
2257
- if (toggle) {
2258
- toggle.classList.toggle("active", state.shortcutsExpanded);
2259
- toggle.textContent = state.shortcutsExpanded ? "\u203a" : "\u2039";
2260
- }
3193
+ persistShortcutsExpandedState();
3194
+ updateCollapsedShortcutsUi();
2261
3195
  });
2262
3196
  // Close shortcuts strip on outside click
2263
3197
  document.addEventListener("click", function(e) {
@@ -2267,13 +3201,8 @@
2267
3201
  var clickedInsideRow = expandedRow && expandedRow.contains(e.target);
2268
3202
  if (wrap && !wrap.contains(e.target) && !clickedInsideRow) {
2269
3203
  state.shortcutsExpanded = false;
2270
- wrap.classList.remove("expanded");
2271
- if (expandedRow) expandedRow.classList.remove("visible");
2272
- var toggle = document.querySelector(".shortcuts-toggle");
2273
- if (toggle) {
2274
- toggle.classList.remove("active");
2275
- toggle.textContent = "\u2039";
2276
- }
3204
+ persistShortcutsExpandedState();
3205
+ updateCollapsedShortcutsUi();
2277
3206
  }
2278
3207
  });
2279
3208
 
@@ -2312,8 +3241,18 @@
2312
3241
  if (jumpBottomBtn) jumpBottomBtn.addEventListener("click", function() {
2313
3242
  maybeScrollTerminalToBottom("force");
2314
3243
  });
2315
-
2316
- // File explorer
3244
+ var chatFollowToggle = document.getElementById("chat-follow-toggle");
3245
+ if (chatFollowToggle) chatFollowToggle.addEventListener("click", function() {
3246
+ if (state.chatAutoFollow) {
3247
+ setChatAutoFollow(false, { scrollNow: false });
3248
+ } else {
3249
+ setChatAutoFollow(true, { scrollNow: true, smooth: false });
3250
+ }
3251
+ });
3252
+ var chatJumpBottomBtn = document.getElementById("chat-jump-bottom");
3253
+ if (chatJumpBottomBtn) chatJumpBottomBtn.addEventListener("click", function() {
3254
+ setChatAutoFollow(true, { scrollNow: true, smooth: true });
3255
+ });
2317
3256
  var fileRefresh = document.getElementById("file-explorer-refresh");
2318
3257
  if (fileRefresh) fileRefresh.addEventListener("click", refreshFileExplorer);
2319
3258
 
@@ -2742,6 +3681,7 @@
2742
3681
  event.preventDefault();
2743
3682
  event.stopPropagation();
2744
3683
  state.claudeHistoryExpanded = !state.claudeHistoryExpanded;
3684
+ persistHistoryPanelState();
2745
3685
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
2746
3686
  loadClaudeHistory();
2747
3687
  }
@@ -2789,6 +3729,10 @@
2789
3729
  handleResumeAction(actionButton);
2790
3730
  } else if (actionButton.dataset.action === "resume-history" && actionButton.dataset.claudeSessionId) {
2791
3731
  handleResumeHistoryAction(actionButton);
3732
+ } else if (actionButton.dataset.action === "worktree-merge" && actionButton.dataset.sessionId) {
3733
+ openWorktreeMergeModal(actionButton.dataset.sessionId);
3734
+ } else if (actionButton.dataset.action === "worktree-cleanup" && actionButton.dataset.sessionId) {
3735
+ retryWorktreeCleanup(actionButton.dataset.sessionId);
2792
3736
  }
2793
3737
  return;
2794
3738
  }
@@ -2959,8 +3903,8 @@
2959
3903
 
2960
3904
  function setTerminalManualScrollActive() {
2961
3905
  state.terminalAutoFollow = false;
3906
+ clearTerminalScrollIdleTimer();
2962
3907
  updateTerminalJumpToBottomButton();
2963
- scheduleTerminalResumeFollow();
2964
3908
  }
2965
3909
 
2966
3910
  function maybeScrollTerminalToBottom(reason) {
@@ -3171,6 +4115,7 @@
3171
4115
  var shouldScroll = opts.scroll !== false;
3172
4116
  var sessionChanged = state.terminalSessionId !== nextSessionId;
3173
4117
  var currentOutput = state.terminalOutput || "";
4118
+ var liveChunkStream = !!(nextSessionId && state.terminalLiveStreamSessions[nextSessionId]);
3174
4119
  var wrote = false;
3175
4120
 
3176
4121
  if (normalizedOutput === currentOutput && !sessionChanged) {
@@ -3199,6 +4144,10 @@
3199
4144
  } else if (normalizedOutput.length < currentOutput.length && !sessionChanged) {
3200
4145
  // Ignore regressive snapshots for the active session; wait for an explicit replace.
3201
4146
  return false;
4147
+ } else if (liveChunkStream && !sessionChanged && mode !== "replace" && currentOutput && !normalizedOutput.startsWith(currentOutput)) {
4148
+ // When a session is already streaming live chunks, do not let polled snapshots
4149
+ // rewrite the terminal unless they are strict appends of what we've rendered.
4150
+ return false;
3202
4151
  } else if (normalizedOutput.startsWith(currentOutput)) {
3203
4152
  var delta = normalizedOutput.slice(currentOutput.length);
3204
4153
  if (delta) {
@@ -3352,7 +4301,7 @@
3352
4301
  if (state.selectedId) {
3353
4302
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
3354
4303
  if (session) {
3355
- syncTerminalBuffer(session.id, session.output || "", { mode: "replace", scroll: false });
4304
+ syncTerminalBuffer(session.id, session.output || "", { mode: "append", scroll: false });
3356
4305
  }
3357
4306
  } else {
3358
4307
  state.terminal.writeln("点击上方「新对话」开始你的第一次对话。");
@@ -3404,7 +4353,7 @@
3404
4353
  })
3405
4354
  .then(function(res) { return res.json(); })
3406
4355
  .then(function(config) {
3407
- state.config = config;
4356
+ applySettingsConfig(config);
3408
4357
  var statusDot = document.getElementById("status-dot");
3409
4358
  var statusText = document.getElementById("status-text");
3410
4359
  if (statusDot) statusDot.classList.add("active");
@@ -3442,9 +4391,16 @@
3442
4391
  state.sessions = [];
3443
4392
  state.claudeHistory = [];
3444
4393
  state.claudeHistoryLoaded = false;
3445
- state.claudeHistoryExpanded = true;
4394
+ state.claudeHistoryExpanded = getConfiguredPanelDefaults().claudeHistoryExpanded;
4395
+ persistHistoryPanelState();
3446
4396
  state.claudeHistoryExpandedDirs = {};
3447
- state.sessionsDrawerOpen = false;
4397
+ state.sessionsDrawerOpen = getConfiguredPanelDefaults().sessionsDrawerOpen;
4398
+ persistDrawerState();
4399
+ state.filePanelOpen = getConfiguredPanelDefaults().filePanelOpen;
4400
+ persistFilePanelState();
4401
+ state.shortcutsExpanded = getConfiguredPanelDefaults().shortcutsExpanded;
4402
+ persistShortcutsExpandedState();
4403
+ updateCollapsedShortcutsUi();
3448
4404
  render();
3449
4405
  }
3450
4406
 
@@ -3467,14 +4423,38 @@
3467
4423
  }
3468
4424
 
3469
4425
  function getPreferredTool() {
3470
- return "claude";
4426
+ return state.sessionTool || state.preferredCommand || "claude";
3471
4427
  }
3472
4428
 
3473
4429
  function getComposerTool() {
3474
- return "claude";
4430
+ var selected = state.sessions.find(function(s) { return s.id === state.selectedId; });
4431
+ return (selected && selected.provider) || state.preferredCommand || "claude";
4432
+ }
4433
+
4434
+ function getComposerPlaceholder(session, terminalInteractive) {
4435
+ if (terminalInteractive) {
4436
+ return "终端交互模式开启中,请直接在终端中输入";
4437
+ }
4438
+ if (session && session.provider === "codex") {
4439
+ if (session.status !== "running") {
4440
+ return "Codex 会话已结束,无法继续发送";
4441
+ }
4442
+ return state.currentView === "terminal"
4443
+ ? "向 Codex 发送输入;terminal 为原始 TUI 输出"
4444
+ : "向 Codex 发送输入;chat 为解析后的阅读视图";
4445
+ }
4446
+ if (session && !isStructuredSession(session) && session.status !== "running") {
4447
+ return "会话已结束,无法继续发送";
4448
+ }
4449
+ return session && isStructuredSession(session) && session.structuredState && session.structuredState.inFlight
4450
+ ? "思考中,可继续发送,消息会自动排队"
4451
+ : "输入消息...";
3475
4452
  }
3476
4453
 
3477
4454
  function getToolModeHint(tool, mode) {
4455
+ if (tool === "codex") {
4456
+ return "Codex 当前仅支持 PTY 透传,并固定以 full-access 启动。";
4457
+ }
3478
4458
  if (mode === "full-access") {
3479
4459
  return "自动确认权限请求与高权限操作,适合你确认环境安全后的连续修改。";
3480
4460
  }
@@ -3491,6 +4471,9 @@
3491
4471
  }
3492
4472
 
3493
4473
  function getSupportedModes(tool) {
4474
+ if (tool === "codex") {
4475
+ return ["full-access"];
4476
+ }
3494
4477
  return ["default", "full-access", "auto-edit", "native", "managed"];
3495
4478
  }
3496
4479
 
@@ -3523,13 +4506,21 @@
3523
4506
  }
3524
4507
 
3525
4508
  function getSessionKindLabel(session) {
3526
- return isStructuredSession(session) ? "结构化" : "终端";
4509
+ var provider = session && session.provider ? session.provider : "claude";
4510
+ return (isStructuredSession(session) ? "结构化" : "终端") + " · " + provider;
3527
4511
  }
3528
4512
 
3529
4513
  function getSessionKindDescription(session) {
3530
4514
  return isStructuredSession(session)
3531
4515
  ? "结构化 · 块级记录"
3532
- : "终端 · PTY 会话";
4516
+ : (session && session.provider === "codex"
4517
+ ? "终端 · Codex PTY(chat 为解析视图)"
4518
+ : "终端 · PTY 会话");
4519
+ }
4520
+
4521
+ function shouldRequestChatFormat(session) {
4522
+ if (!session) return false;
4523
+ return isStructuredSession(session) || session.provider === "codex";
3533
4524
  }
3534
4525
 
3535
4526
  function isRecoverableToolError(toolResult, nextResult) {
@@ -3546,9 +4537,7 @@
3546
4537
  }
3547
4538
 
3548
4539
  function isStructuredSession(session) {
3549
- var result = !!session && (session.sessionKind === "structured" || session.runner === "claude-cli-print");
3550
- if (session) console.log("[WAND] isStructuredSession id:", session.id, "sessionKind:", session.sessionKind, "runner:", session.runner, "=>", result);
3551
- return result;
4540
+ return !!session && (session.sessionKind === "structured" || session.runner === "claude-cli-print");
3552
4541
  }
3553
4542
 
3554
4543
  function syncComposerModeSelect() {
@@ -3561,12 +4550,13 @@
3561
4550
  if (modeHint) modeHint.textContent = getModeHint(state.chatMode);
3562
4551
  }
3563
4552
 
3564
- function createStructuredSession(prompt, cwdOverride, modeOverride) {
4553
+ function createStructuredSession(prompt, cwdOverride, modeOverride, worktreeEnabled) {
3565
4554
  var payload = {
3566
4555
  cwd: cwdOverride || getEffectiveCwd(),
3567
4556
  mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
3568
4557
  runner: state.structuredRunner || "claude-cli-print",
3569
- prompt: prompt || undefined
4558
+ prompt: prompt || undefined,
4559
+ worktreeEnabled: worktreeEnabled === true
3570
4560
  };
3571
4561
  console.log("[WAND] createStructuredSession payload:", JSON.stringify(payload));
3572
4562
  return fetch("/api/structured-sessions", {
@@ -3599,24 +4589,31 @@
3599
4589
  function applyCurrentView() {
3600
4590
  var hasSession = !!state.selectedId;
3601
4591
  var terminalBtn = document.getElementById("view-terminal-btn");
4592
+ var chatBtn = document.getElementById("view-chat-btn");
4593
+ var toggleBar = document.getElementById("view-toggle-bar");
3602
4594
  var terminalContainer = document.getElementById("output");
3603
4595
  var chatContainer = document.getElementById("chat-output");
3604
4596
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
3605
4597
  var structured = isStructuredSession(selectedSession);
3606
4598
  var showTerminal = hasSession && !structured && state.currentView === "terminal";
3607
4599
  var showChat = hasSession && (structured || state.currentView !== "terminal");
3608
- console.log("[WAND] applyCurrentView hasSession:", hasSession, "structured:", structured, "currentView:", state.currentView, "showTerminal:", showTerminal, "showChat:", showChat, "sessionKind:", selectedSession && selectedSession.sessionKind, "runner:", selectedSession && selectedSession.runner);
3609
-
3610
4600
  if (structured) {
3611
4601
  state.currentView = "chat";
3612
4602
  } else if (!hasSession) {
3613
4603
  state.currentView = "terminal";
3614
4604
  }
3615
4605
 
4606
+ if (toggleBar) {
4607
+ toggleBar.classList.toggle("hidden", !hasSession);
4608
+ }
3616
4609
  if (terminalBtn) {
3617
4610
  terminalBtn.classList.toggle("hidden", structured || !hasSession);
3618
4611
  terminalBtn.classList.toggle("active", showTerminal);
3619
4612
  }
4613
+ if (chatBtn) {
4614
+ chatBtn.classList.toggle("hidden", !hasSession);
4615
+ chatBtn.classList.toggle("active", showChat);
4616
+ }
3620
4617
  if (terminalContainer) {
3621
4618
  terminalContainer.classList.toggle("active", showTerminal);
3622
4619
  terminalContainer.classList.toggle("hidden", !showTerminal);
@@ -3625,22 +4622,45 @@
3625
4622
  chatContainer.classList.toggle("active", showChat);
3626
4623
  chatContainer.classList.toggle("hidden", !showChat);
3627
4624
  }
4625
+ if (chatContainer && showChat) {
4626
+ ensureChatMessagesContainer(chatContainer);
4627
+ }
4628
+ bindChatScrollListener();
4629
+ updateChatFollowToggleButton();
4630
+ updateChatJumpToBottomButton();
3628
4631
  updateInteractiveControls();
3629
4632
  }
3630
4633
 
3631
4634
  function syncSessionModalUI() {
3632
4635
  var modeHint = document.getElementById("mode-description");
3633
4636
  var kindHint = document.getElementById("session-kind-description");
3634
- var tool = "claude";
4637
+ var tool = state.sessionTool || "claude";
3635
4638
  var sessionKind = state.sessionCreateKind || "structured";
3636
4639
 
4640
+ if (tool === "codex" && sessionKind === "structured") {
4641
+ sessionKind = "pty";
4642
+ state.sessionCreateKind = "pty";
4643
+ }
4644
+
3637
4645
  state.sessionTool = tool;
3638
4646
  state.modeValue = getSafeModeForTool(tool, state.modeValue || state.chatMode || "default");
3639
4647
 
4648
+ var providerCards = document.querySelectorAll("#provider-cards .provider-card");
4649
+ if (providerCards.length) {
4650
+ providerCards.forEach(function(card) {
4651
+ var provider = card.getAttribute("data-provider");
4652
+ card.classList.toggle("active", provider === tool);
4653
+ card.classList.remove("disabled");
4654
+ });
4655
+ }
4656
+
3640
4657
  var kindCards = document.querySelectorAll("#session-kind-cards .session-kind-card");
3641
4658
  if (kindCards.length) {
3642
4659
  kindCards.forEach(function(card) {
3643
- card.classList.toggle("active", card.getAttribute("data-session-kind") === sessionKind);
4660
+ var kind = card.getAttribute("data-session-kind");
4661
+ var disabled = tool === "codex" && kind === "structured";
4662
+ card.classList.toggle("active", kind === sessionKind);
4663
+ card.classList.toggle("disabled", disabled);
3644
4664
  });
3645
4665
  }
3646
4666
 
@@ -3657,33 +4677,31 @@
3657
4677
 
3658
4678
  function updateSessionSnapshot(snapshot) {
3659
4679
  if (!snapshot || !snapshot.id) return;
3660
- if (snapshot.id === state.selectedId || (snapshot.sessionKind === "structured") || snapshot.structuredState) {
3661
- console.log("[WAND] updateSessionSnapshot", snapshot.id, JSON.stringify({
3662
- status: snapshot.status,
3663
- exitCode: snapshot.exitCode,
3664
- sessionKind: snapshot.sessionKind,
3665
- runner: snapshot.runner,
3666
- inFlight: snapshot.structuredState && snapshot.structuredState.inFlight,
3667
- msgCount: snapshot.messages && snapshot.messages.length
3668
- }));
3669
- }
4680
+ var currentSession = state.sessions.find(function(session) { return session.id === snapshot.id; }) || null;
4681
+ var normalizedSnapshot = normalizeStructuredSnapshot(snapshot, currentSession);
3670
4682
  var updated = false;
3671
4683
  var prevSession = null;
3672
4684
  state.sessions = state.sessions.map(function(session) {
3673
- if (session.id !== snapshot.id) return session;
4685
+ if (session.id !== normalizedSnapshot.id) return session;
3674
4686
  prevSession = session;
3675
4687
  updated = true;
3676
- return Object.assign({}, session, snapshot);
4688
+ return Object.assign({}, session, normalizedSnapshot);
3677
4689
  });
3678
4690
  if (!updated) {
3679
- state.sessions.unshift(snapshot);
4691
+ state.sessions.unshift(normalizedSnapshot);
3680
4692
  }
3681
- if (snapshot.id === state.selectedId) {
4693
+ var updatedSession = state.sessions.find(function(session) { return session.id === normalizedSnapshot.id; }) || normalizedSnapshot;
4694
+ if (updatedSession && Array.isArray(updatedSession.queuedMessages) && normalizedSnapshot.id === state.selectedId) {
4695
+ syncStructuredQueueFromSession(updatedSession);
4696
+ saveStructuredQueue();
4697
+ updateStructuredQueueCounter();
4698
+ }
4699
+ if (normalizedSnapshot.id === state.selectedId) {
3682
4700
  reconcileInteractiveState();
3683
4701
  updateTaskDisplay();
3684
4702
  }
3685
4703
  // When a session transitions to a non-running state, try flushing cross-session queue
3686
- if (snapshot.status && snapshot.status !== "running" && state.crossSessionQueue.length > 0) {
4704
+ if (normalizedSnapshot.status && normalizedSnapshot.status !== "running" && state.crossSessionQueue.length > 0) {
3687
4705
  // Use setTimeout(0) to let the current event processing complete first
3688
4706
  setTimeout(flushCrossSessionQueue, 0);
3689
4707
  }
@@ -3703,6 +4721,7 @@
3703
4721
  var keepLocalOutput = localOutput.length > serverOutput.length;
3704
4722
  var localStructuredState = localSession.structuredState || null;
3705
4723
  var serverStructuredState = serverSession.structuredState || null;
4724
+ var structuredSession = (localSession.sessionKind === "structured") || (serverSession.sessionKind === "structured");
3706
4725
  var localHasPendingAssistant = !!(localSession.messages && localSession.messages.length && (function() {
3707
4726
  var last = localSession.messages[localSession.messages.length - 1];
3708
4727
  return last && last.role === "assistant" && Array.isArray(last.content) && last.content.some(function(block) {
@@ -3716,6 +4735,16 @@
3716
4735
  && localHasPendingAssistant
3717
4736
  && !!localStructuredState.activeRequestId
3718
4737
  && (!serverStructuredState || !serverStructuredState.activeRequestId || serverStructuredState.activeRequestId === localStructuredState.activeRequestId);
4738
+ var localMessages = Array.isArray(localSession.messages)
4739
+ ? (structuredSession ? stripRenderOnlyStructuredMessages(localSession.messages) : localSession.messages)
4740
+ : [];
4741
+ var serverMessages = Array.isArray(serverSession.messages)
4742
+ ? (structuredSession ? stripRenderOnlyStructuredMessages(serverSession.messages) : serverSession.messages)
4743
+ : [];
4744
+ var preserveLocalMessages = localMessages.length > serverMessages.length
4745
+ || (localMessages.length > 0 && serverMessages.length > 0
4746
+ && JSON.stringify(localMessages[localMessages.length - 1]) !== JSON.stringify(serverMessages[serverMessages.length - 1])
4747
+ && JSON.stringify(localMessages).length > JSON.stringify(serverMessages).length);
3719
4748
 
3720
4749
  if (keepLocalOutput) {
3721
4750
  merged.output = localOutput;
@@ -3727,6 +4756,10 @@
3727
4756
  merged.messages = localSession.messages;
3728
4757
  }
3729
4758
 
4759
+ if (preserveLocalMessages) {
4760
+ merged.messages = localMessages;
4761
+ }
4762
+
3730
4763
  if (localSession.id === state.selectedId) {
3731
4764
  if (localSession.permissionBlocked && serverSession.permissionBlocked === false) {
3732
4765
  } else if (localSession.permissionBlocked && !serverSession.permissionBlocked) {
@@ -3749,6 +4782,9 @@
3749
4782
  if (session && session.messages && session.messages.length > 0) {
3750
4783
  return session.messages;
3751
4784
  }
4785
+ if (session && session.sessionKind === "structured") {
4786
+ return [];
4787
+ }
3752
4788
  if (!allowFallback) {
3753
4789
  return [];
3754
4790
  }
@@ -3774,7 +4810,8 @@
3774
4810
  return recent ? recent.id : sessions[0].id;
3775
4811
  }
3776
4812
 
3777
- function loadSessions() {
4813
+ function loadSessions(options) {
4814
+ var opts = options || {};
3778
4815
  return fetch("/api/sessions", { credentials: "same-origin" })
3779
4816
  .then(function(res) {
3780
4817
  if (res.status === 401) {
@@ -3800,6 +4837,9 @@
3800
4837
  if (preferredSessionId !== undefined) {
3801
4838
  state.selectedId = preferredSessionId;
3802
4839
  }
4840
+ restoreStructuredQueue();
4841
+ updateStructuredQueueCounter();
4842
+ state.bootstrapping = false;
3803
4843
  persistSelectedId();
3804
4844
  if (state.modalOpen) {
3805
4845
  updateSessionsList();
@@ -3817,23 +4857,34 @@
3817
4857
  }
3818
4858
  updateShellChrome();
3819
4859
 
3820
- // For structured sessions, loadOutput is needed to fetch messages
3821
- // (the sessions list endpoint doesn't include them).
3822
- // On page refresh this is the only place that can trigger it.
3823
- if (state.selectedId) {
4860
+ var reloadPromise = Promise.resolve();
4861
+ if (!opts.skipSelectedOutputReload && state.selectedId) {
4862
+ reloadPromise = loadOutput(state.selectedId);
4863
+ } else if (state.selectedId) {
3824
4864
  var sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
3825
4865
  if (isStructuredSession(sel)) {
3826
- loadOutput(state.selectedId);
4866
+ resetChatRenderCache();
4867
+ scheduleChatRender(true);
3827
4868
  }
3828
4869
  }
3829
4870
 
3830
- // Try to flush cross-session queue on every session list refresh
3831
- if (state.crossSessionQueue.length > 0) {
3832
- flushCrossSessionQueue();
3833
- }
4871
+ return reloadPromise.then(function() {
4872
+ if (state.crossSessionQueue.length > 0) {
4873
+ flushCrossSessionQueue();
4874
+ }
4875
+ renderCrossSessionQueue();
4876
+ });
3834
4877
  })
3835
4878
  .catch(function(e) {
3836
- console.error("[wand] loadSessions failed:", e);
4879
+ var message = (e && e.message) || "";
4880
+ var isTransientAbort =
4881
+ message === "Failed to fetch" ||
4882
+ message === "NetworkError when attempting to fetch resource." ||
4883
+ message === "Load failed" ||
4884
+ /aborted|aborterror|networkerror|failed to fetch/i.test(message);
4885
+ if (!isTransientAbort) {
4886
+ console.error("[wand] loadSessions failed:", e);
4887
+ }
3837
4888
  });
3838
4889
  }
3839
4890
 
@@ -3896,7 +4947,7 @@
3896
4947
  }
3897
4948
 
3898
4949
  if (selectedSession && state.terminal) {
3899
- syncTerminalBuffer(selectedSession.id, selectedSession.output || "", { mode: "replace" });
4950
+ syncTerminalBuffer(selectedSession.id, selectedSession.output || "", { mode: "append", scroll: false });
3900
4951
  } else if (!selectedSession) {
3901
4952
  state.terminalSessionId = null;
3902
4953
  state.terminalOutput = "";
@@ -3933,7 +4984,7 @@
3933
4984
  }
3934
4985
  var sess = state.sessions.find(function(s) { return s.id === id; });
3935
4986
  var url = "/api/sessions/" + id;
3936
- if (isStructuredSession(sess)) {
4987
+ if (shouldRequestChatFormat(sess)) {
3937
4988
  url += "?format=chat";
3938
4989
  }
3939
4990
  return fetch(url, { credentials: "same-origin" })
@@ -3952,14 +5003,7 @@
3952
5003
  updateShellChrome();
3953
5004
 
3954
5005
  var selectedSession = state.sessions.find(function(s) { return s.id === id; });
3955
- state.currentMessages = getPreferredMessages(selectedSession, data.output, false);
3956
- if (selectedSession && selectedSession.sessionKind === "structured") {
3957
- appendQueuedPlaceholders(state.currentMessages);
3958
- }
3959
-
3960
- if (state.terminal) {
3961
- syncTerminalBuffer(id, data.output || "", { mode: "replace" });
3962
- }
5006
+ state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, data.output, false));
3963
5007
 
3964
5008
  renderChat(false);
3965
5009
  });
@@ -3967,9 +5011,7 @@
3967
5011
 
3968
5012
  function selectSession(id) {
3969
5013
  var foundSession = state.sessions.find(function(item) { return item.id === id; });
3970
- console.log("[WAND] selectSession id:", id, "found:", !!foundSession, "sessionKind:", foundSession && foundSession.sessionKind, "runner:", foundSession && foundSession.runner, "isStructured:", isStructuredSession(foundSession));
3971
5014
  if (!foundSession) {
3972
- console.warn("[WAND] selectSession: session not found, skipping", id);
3973
5015
  return;
3974
5016
  }
3975
5017
  state.selectedId = id;
@@ -3977,7 +5019,8 @@
3977
5019
  // Clear queued inputs from the previous session to prevent cross-session leaks
3978
5020
  state.messageQueue = [];
3979
5021
  state.pendingMessages = [];
3980
- state.structuredInputQueue = [];
5022
+ syncStructuredQueueFromSession(foundSession);
5023
+ restoreStructuredQueue();
3981
5024
  updateStructuredQueueCounter();
3982
5025
  resetChatRenderCache();
3983
5026
  state.currentMessages = [];
@@ -4023,11 +5066,10 @@
4023
5066
 
4024
5067
  function toggleSessionsDrawer() {
4025
5068
  state.sessionsDrawerOpen = !state.sessionsDrawerOpen;
5069
+ persistDrawerState();
4026
5070
  if (state.sessionsDrawerOpen && isMobileLayout()) {
4027
5071
  state.filePanelOpen = false;
4028
- try {
4029
- localStorage.setItem("wand-file-panel-open", "false");
4030
- } catch (e) {}
5072
+ persistFilePanelState();
4031
5073
  }
4032
5074
  updateLayoutState();
4033
5075
  }
@@ -4036,6 +5078,7 @@
4036
5078
  if (!state.sessionsDrawerOpen) return;
4037
5079
  closeSwipedItem();
4038
5080
  state.sessionsDrawerOpen = false;
5081
+ persistDrawerState();
4039
5082
  updateLayoutState();
4040
5083
  }
4041
5084
 
@@ -4054,7 +5097,9 @@
4054
5097
  modal.classList.remove("hidden");
4055
5098
  lastFocusedElement = document.activeElement;
4056
5099
  state.sessionTool = getPreferredTool();
4057
- state.sessionCreateKind = "structured";
5100
+ state.preferredCommand = state.sessionTool;
5101
+ state.sessionCreateKind = state.sessionTool === "codex" ? "pty" : "structured";
5102
+ state.sessionCreateWorktree = false;
4058
5103
  state.modeValue = getSafeModeForTool(state.sessionTool, state.modeValue || state.chatMode);
4059
5104
  syncSessionModalUI();
4060
5105
  loadRecentPathBubbles();
@@ -4107,21 +5152,178 @@
4107
5152
  e.preventDefault();
4108
5153
  lastEl.focus();
4109
5154
  }
4110
- } else {
4111
- // Tab
4112
- if (document.activeElement === lastEl) {
4113
- e.preventDefault();
4114
- firstEl.focus();
5155
+ } else {
5156
+ // Tab
5157
+ if (document.activeElement === lastEl) {
5158
+ e.preventDefault();
5159
+ firstEl.focus();
5160
+ }
5161
+ }
5162
+ };
5163
+
5164
+ document.addEventListener("keydown", focusTrapHandler);
5165
+ }
5166
+
5167
+ function getActiveWorktreeMergeSession() {
5168
+ if (!state.activeWorktreeMergeSessionId) return null;
5169
+ return state.sessions.find(function(session) { return session.id === state.activeWorktreeMergeSessionId; }) || null;
5170
+ }
5171
+
5172
+ function renderWorktreeMergeContent() {
5173
+ var container = document.getElementById("worktree-merge-content");
5174
+ var confirmBtn = document.getElementById("worktree-merge-confirm-button");
5175
+ var errorEl = document.getElementById("worktree-merge-error");
5176
+ var session = getActiveWorktreeMergeSession();
5177
+ var result = state.worktreeMergeCheckResult;
5178
+ if (!container || !confirmBtn) return;
5179
+ if (!session || !session.worktree) {
5180
+ container.innerHTML = '<p class="field-hint">未找到可合并的 worktree 会话。</p>';
5181
+ confirmBtn.disabled = true;
5182
+ return;
5183
+ }
5184
+ if (errorEl) {
5185
+ if (state.worktreeMergeError) {
5186
+ showError(errorEl, state.worktreeMergeError);
5187
+ } else {
5188
+ hideError(errorEl);
5189
+ }
5190
+ }
5191
+ var rows = [
5192
+ '<div class="worktree-merge-row"><span>来源分支</span><strong>' + escapeHtml(session.worktree.branch || "-") + '</strong></div>',
5193
+ '<div class="worktree-merge-row"><span>工作目录</span><strong>' + escapeHtml(session.worktree.path || "-") + '</strong></div>'
5194
+ ];
5195
+ if (result) {
5196
+ rows.push('<div class="worktree-merge-row"><span>目标分支</span><strong>' + escapeHtml(result.targetBranch || "-") + '</strong></div>');
5197
+ rows.push('<div class="worktree-merge-row"><span>待合并提交</span><strong>' + escapeHtml(String(result.aheadCount || 0)) + '</strong></div>');
5198
+ rows.push('<div class="worktree-merge-row"><span>未提交改动</span><strong>' + escapeHtml(result.hasUncommittedChanges ? "有" : "无") + '</strong></div>');
5199
+ rows.push('<div class="worktree-merge-row"><span>冲突风险</span><strong>' + escapeHtml(result.hasConflicts ? "有" : "无") + '</strong></div>');
5200
+ if (result.reason) {
5201
+ rows.push('<p class="field-hint">' + escapeHtml(result.reason) + '</p>');
5202
+ }
5203
+ } else if (state.worktreeMergeLoading) {
5204
+ rows.push('<p class="field-hint">正在检查 worktree 合并状态…</p>');
5205
+ }
5206
+ container.innerHTML = rows.join("");
5207
+ confirmBtn.disabled = state.worktreeMergeLoading || state.worktreeMergeSubmitting || !result || result.ok !== true;
5208
+ confirmBtn.textContent = state.worktreeMergeSubmitting ? "合并中..." : "确认合并并清理";
5209
+ }
5210
+
5211
+ function openWorktreeMergeModal(sessionId) {
5212
+ state.activeWorktreeMergeSessionId = sessionId;
5213
+ state.worktreeMergeCheckResult = null;
5214
+ state.worktreeMergeLoading = true;
5215
+ state.worktreeMergeSubmitting = false;
5216
+ state.worktreeMergeError = "";
5217
+ closeSessionModal();
5218
+ closeSettingsModal();
5219
+ var modal = document.getElementById("worktree-merge-modal");
5220
+ if (modal) {
5221
+ modal.classList.remove("hidden");
5222
+ lastFocusedElement = document.activeElement;
5223
+ setupFocusTrap(modal);
5224
+ }
5225
+ renderWorktreeMergeContent();
5226
+ fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/worktree/merge/check", {
5227
+ method: "POST",
5228
+ credentials: "same-origin"
5229
+ })
5230
+ .then(function(res) { return res.json(); })
5231
+ .then(function(data) {
5232
+ if (data && data.error) {
5233
+ throw new Error(data.error);
5234
+ }
5235
+ if (data && data.session) {
5236
+ updateSessionSnapshot(data.session);
5237
+ }
5238
+ state.worktreeMergeCheckResult = data.result || null;
5239
+ state.worktreeMergeError = "";
5240
+ })
5241
+ .catch(function(error) {
5242
+ state.worktreeMergeError = (error && error.message) || "无法检查 worktree 合并状态。";
5243
+ })
5244
+ .finally(function() {
5245
+ state.worktreeMergeLoading = false;
5246
+ renderWorktreeMergeContent();
5247
+ });
5248
+ }
5249
+
5250
+ function closeWorktreeMergeModal() {
5251
+ var modal = document.getElementById("worktree-merge-modal");
5252
+ state.activeWorktreeMergeSessionId = null;
5253
+ state.worktreeMergeCheckResult = null;
5254
+ state.worktreeMergeLoading = false;
5255
+ state.worktreeMergeSubmitting = false;
5256
+ state.worktreeMergeError = "";
5257
+ if (modal) {
5258
+ modal.classList.add("hidden");
5259
+ }
5260
+ if (focusTrapHandler) {
5261
+ document.removeEventListener("keydown", focusTrapHandler);
5262
+ focusTrapHandler = null;
5263
+ }
5264
+ if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
5265
+ lastFocusedElement.focus();
5266
+ }
5267
+ }
5268
+
5269
+ function confirmWorktreeMerge() {
5270
+ if (!state.activeWorktreeMergeSessionId || state.worktreeMergeSubmitting) return;
5271
+ state.worktreeMergeSubmitting = true;
5272
+ state.worktreeMergeError = "";
5273
+ renderWorktreeMergeContent();
5274
+ fetch("/api/sessions/" + encodeURIComponent(state.activeWorktreeMergeSessionId) + "/worktree/merge", {
5275
+ method: "POST",
5276
+ credentials: "same-origin",
5277
+ headers: { "Content-Type": "application/json" },
5278
+ body: JSON.stringify({})
5279
+ })
5280
+ .then(function(res) { return res.json(); })
5281
+ .then(function(data) {
5282
+ if (data && data.error) {
5283
+ throw new Error(data.error);
5284
+ }
5285
+ if (data && data.session) {
5286
+ updateSessionSnapshot(data.session);
5287
+ }
5288
+ showToast("已合并到 " + escapeHtml((data.result && data.result.targetBranch) || "主分支") + ((data.result && data.result.cleanupDone === false) ? ",但工作树待清理。" : "。"), "info");
5289
+ closeWorktreeMergeModal();
5290
+ return refreshAll();
5291
+ })
5292
+ .catch(function(error) {
5293
+ state.worktreeMergeError = (error && error.message) || "无法合并 worktree。";
5294
+ renderWorktreeMergeContent();
5295
+ })
5296
+ .finally(function() {
5297
+ state.worktreeMergeSubmitting = false;
5298
+ renderWorktreeMergeContent();
5299
+ });
5300
+ }
5301
+
5302
+ function retryWorktreeCleanup(sessionId) {
5303
+ fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/worktree/cleanup", {
5304
+ method: "POST",
5305
+ credentials: "same-origin"
5306
+ })
5307
+ .then(function(res) { return res.json(); })
5308
+ .then(function(data) {
5309
+ if (data && data.error) {
5310
+ throw new Error(data.error);
5311
+ }
5312
+ if (data && data.session) {
5313
+ updateSessionSnapshot(data.session);
4115
5314
  }
4116
- }
4117
- };
4118
-
4119
- document.addEventListener("keydown", focusTrapHandler);
5315
+ showToast("已完成 worktree 清理。", "info");
5316
+ return refreshAll();
5317
+ })
5318
+ .catch(function(error) {
5319
+ showToast((error && error.message) || "无法清理 worktree。", "error");
5320
+ });
4120
5321
  }
4121
5322
 
4122
5323
  function openSettingsModal() {
4123
5324
  // Close session modal first if open (mutual exclusion)
4124
5325
  closeSessionModal();
5326
+ refreshSettingsModalUi();
4125
5327
  var modal = document.getElementById("settings-modal");
4126
5328
  if (modal) {
4127
5329
  modal.classList.remove("hidden");
@@ -4132,9 +5334,8 @@
4132
5334
  if (confirmEl) confirmEl.value = "";
4133
5335
  hideSettingsMessages();
4134
5336
  setupFocusTrap(modal);
4135
- // Activate first tab
5337
+ bindSettingsModalEvents();
4136
5338
  switchSettingsTab("about");
4137
- // Load settings data
4138
5339
  loadSettingsData();
4139
5340
  }
4140
5341
  }
@@ -4225,62 +5426,120 @@
4225
5426
  panels[j].classList.remove("active");
4226
5427
  }
4227
5428
  }
5429
+ updateSettingsActiveNav();
5430
+ }
5431
+
5432
+ function refreshSettingsGeneralPanel() {
5433
+ var existingPanel = document.getElementById("settings-tab-general");
5434
+ if (!existingPanel) return;
5435
+ var wrapper = document.createElement("div");
5436
+ wrapper.innerHTML = buildSettingsGeneralPanel();
5437
+ var nextPanel = wrapper.firstChild;
5438
+ if (!nextPanel) return;
5439
+ existingPanel.replaceWith(nextPanel);
5440
+ var saveConfigBtn = document.getElementById("save-config-button");
5441
+ if (saveConfigBtn) saveConfigBtn.addEventListener("click", saveConfigSettings);
5442
+ syncPanelStateSettingsForm();
5443
+ }
5444
+
5445
+ function bindSettingsTabEvents() {
5446
+ var settingsTabs = document.querySelectorAll(".settings-tab");
5447
+ for (var ti = 0; ti < settingsTabs.length; ti++) {
5448
+ settingsTabs[ti].addEventListener("click", function(e) {
5449
+ switchSettingsTab(e.target.getAttribute("data-tab"));
5450
+ });
5451
+ }
5452
+ }
5453
+
5454
+ function bindSettingsConfigActions() {
5455
+ var saveConfigBtn = document.getElementById("save-config-button");
5456
+ if (saveConfigBtn) saveConfigBtn.addEventListener("click", saveConfigSettings);
5457
+ }
5458
+
5459
+ function bindSettingsModalEvents() {
5460
+ bindSettingsTabEvents();
5461
+ bindSettingsConfigActions();
5462
+ }
5463
+
5464
+ function refreshSettingsModalUi() {
5465
+ refreshSettingsGeneralPanel();
5466
+ updateSettingsActiveNav();
5467
+ }
5468
+
5469
+ function normalizePanelStateSettings(cfg) {
5470
+ var panelState = cfg && cfg.uiPreferences && cfg.uiPreferences.defaultPanelState;
5471
+ var defaults = getConfiguredPanelDefaults(cfg);
5472
+ return {
5473
+ sessionsDrawerOpen: panelState && typeof panelState.sessionsDrawerOpen === "boolean" ? panelState.sessionsDrawerOpen : defaults.sessionsDrawerOpen,
5474
+ filePanelOpen: panelState && typeof panelState.filePanelOpen === "boolean" ? panelState.filePanelOpen : defaults.filePanelOpen,
5475
+ shortcutsExpanded: panelState && typeof panelState.shortcutsExpanded === "boolean" ? panelState.shortcutsExpanded : defaults.shortcutsExpanded,
5476
+ claudeHistoryExpanded: panelState && typeof panelState.claudeHistoryExpanded === "boolean" ? panelState.claudeHistoryExpanded : defaults.claudeHistoryExpanded,
5477
+ chatMessageExpanded: panelState && typeof panelState.chatMessageExpanded === "boolean" ? panelState.chatMessageExpanded : defaults.chatMessageExpanded,
5478
+ structuredThinkingExpanded: panelState && typeof panelState.structuredThinkingExpanded === "boolean" ? panelState.structuredThinkingExpanded : defaults.structuredThinkingExpanded,
5479
+ structuredToolGroupExpanded: panelState && typeof panelState.structuredToolGroupExpanded === "boolean" ? panelState.structuredToolGroupExpanded : defaults.structuredToolGroupExpanded,
5480
+ structuredInlineToolExpanded: panelState && typeof panelState.structuredInlineToolExpanded === "boolean" ? panelState.structuredInlineToolExpanded : defaults.structuredInlineToolExpanded,
5481
+ structuredTerminalExpanded: panelState && typeof panelState.structuredTerminalExpanded === "boolean" ? panelState.structuredTerminalExpanded : defaults.structuredTerminalExpanded,
5482
+ structuredToolCardExpanded: panelState && typeof panelState.structuredToolCardExpanded === "boolean" ? panelState.structuredToolCardExpanded : defaults.structuredToolCardExpanded,
5483
+ };
5484
+ }
5485
+
5486
+ function applyLoadedSettingsData(data) {
5487
+ var cfg = data.config || {};
5488
+ applySettingsConfig(cfg);
5489
+
5490
+ var nameEl = document.getElementById("settings-pkg-name");
5491
+ var verEl = document.getElementById("settings-version");
5492
+ var nodeEl = document.getElementById("settings-node-req");
5493
+ var repoEl = document.getElementById("settings-repo-url");
5494
+ if (nameEl) nameEl.textContent = data.packageName || "-";
5495
+ if (verEl) verEl.textContent = data.version || "-";
5496
+ if (nodeEl) nodeEl.textContent = data.nodeVersion || "-";
5497
+ if (repoEl && data.repoUrl) {
5498
+ repoEl.innerHTML = '<a href="' + escapeHtml(data.repoUrl) + '" target="_blank" rel="noopener">' + escapeHtml(data.repoUrl) + '</a>';
5499
+ }
5500
+
5501
+ var hostEl = document.getElementById("cfg-host");
5502
+ var portEl = document.getElementById("cfg-port");
5503
+ var httpsEl = document.getElementById("cfg-https");
5504
+ var modeEl = document.getElementById("cfg-mode");
5505
+ var cwdEl = document.getElementById("cfg-cwd");
5506
+ var shellEl = document.getElementById("cfg-shell");
5507
+ if (hostEl) hostEl.value = cfg.host || "";
5508
+ if (portEl) portEl.value = cfg.port || "";
5509
+ if (httpsEl) httpsEl.checked = cfg.https === true;
5510
+ if (modeEl) modeEl.value = cfg.defaultMode || "default";
5511
+ if (cwdEl) cwdEl.value = cfg.defaultCwd || "";
5512
+ if (shellEl) shellEl.value = cfg.shell || "";
5513
+ var langEl = document.getElementById("cfg-language");
5514
+ if (langEl) langEl.value = cfg.language || "";
5515
+ syncPanelStateSettingsForm(normalizePanelStateSettings(cfg));
5516
+
5517
+ var certStatus = document.getElementById("cert-status");
5518
+ if (certStatus) {
5519
+ certStatus.textContent = data.hasCert ? "已安装 SSL 证书" : "未安装证书(使用自签名或 HTTP)";
5520
+ certStatus.style.color = data.hasCert ? "var(--success)" : "var(--text-secondary)";
5521
+ }
5522
+
5523
+ var presetsList = document.getElementById("presets-list");
5524
+ if (presetsList && cfg.commandPresets) {
5525
+ var html = "";
5526
+ for (var i = 0; i < cfg.commandPresets.length; i++) {
5527
+ var p = cfg.commandPresets[i];
5528
+ html += '<div class="preset-item">' +
5529
+ '<span class="preset-label">' + escapeHtml(p.label) + '</span>' +
5530
+ '<span class="preset-detail">' + escapeHtml(p.command) + (p.mode ? ' (' + escapeHtml(p.mode) + ')' : '') + '</span>' +
5531
+ '</div>';
5532
+ }
5533
+ if (!html) html = '<div class="empty-state-compact"><span class="empty-icon">\u2699</span><span>没有命令预设</span><span class="hint">在 config.json 的 commandPresets 中配置</span></div>';
5534
+ presetsList.innerHTML = html;
5535
+ }
4228
5536
  }
4229
5537
 
4230
5538
  function loadSettingsData() {
4231
5539
  fetch("/api/settings", { credentials: "same-origin" })
4232
5540
  .then(function(res) { return res.json(); })
4233
5541
  .then(function(data) {
4234
- // About
4235
- var nameEl = document.getElementById("settings-pkg-name");
4236
- var verEl = document.getElementById("settings-version");
4237
- var nodeEl = document.getElementById("settings-node-req");
4238
- var repoEl = document.getElementById("settings-repo-url");
4239
- if (nameEl) nameEl.textContent = data.packageName || "-";
4240
- if (verEl) verEl.textContent = data.version || "-";
4241
- if (nodeEl) nodeEl.textContent = data.nodeVersion || "-";
4242
- if (repoEl && data.repoUrl) {
4243
- repoEl.innerHTML = '<a href="' + escapeHtml(data.repoUrl) + '" target="_blank" rel="noopener">' + escapeHtml(data.repoUrl) + '</a>';
4244
- }
4245
-
4246
- // Config fields
4247
- var cfg = data.config || {};
4248
- var hostEl = document.getElementById("cfg-host");
4249
- var portEl = document.getElementById("cfg-port");
4250
- var httpsEl = document.getElementById("cfg-https");
4251
- var modeEl = document.getElementById("cfg-mode");
4252
- var cwdEl = document.getElementById("cfg-cwd");
4253
- var shellEl = document.getElementById("cfg-shell");
4254
- if (hostEl) hostEl.value = cfg.host || "";
4255
- if (portEl) portEl.value = cfg.port || "";
4256
- if (httpsEl) httpsEl.checked = cfg.https === true;
4257
- if (modeEl) modeEl.value = cfg.defaultMode || "default";
4258
- if (cwdEl) cwdEl.value = cfg.defaultCwd || "";
4259
- if (shellEl) shellEl.value = cfg.shell || "";
4260
- var langEl = document.getElementById("cfg-language");
4261
- if (langEl) langEl.value = cfg.language || "";
4262
-
4263
- // Cert status
4264
- var certStatus = document.getElementById("cert-status");
4265
- if (certStatus) {
4266
- certStatus.textContent = data.hasCert ? "已安装 SSL 证书" : "未安装证书(使用自签名或 HTTP)";
4267
- certStatus.style.color = data.hasCert ? "var(--success)" : "var(--text-secondary)";
4268
- }
4269
-
4270
- // Presets
4271
- var presetsList = document.getElementById("presets-list");
4272
- if (presetsList && cfg.commandPresets) {
4273
- var html = "";
4274
- for (var i = 0; i < cfg.commandPresets.length; i++) {
4275
- var p = cfg.commandPresets[i];
4276
- html += '<div class="preset-item">' +
4277
- '<span class="preset-label">' + escapeHtml(p.label) + '</span>' +
4278
- '<span class="preset-detail">' + escapeHtml(p.command) + (p.mode ? ' (' + escapeHtml(p.mode) + ')' : '') + '</span>' +
4279
- '</div>';
4280
- }
4281
- if (!html) html = '<div class="empty-state-compact"><span class="empty-icon">\u2699</span><span>\u6ca1\u6709\u547d\u4ee4\u9884\u8bbe</span><span class="hint">\u5728 config.json \u7684 commandPresets \u4e2d\u914d\u7f6e</span></div>';
4282
- presetsList.innerHTML = html;
4283
- }
5542
+ applyLoadedSettingsData(data);
4284
5543
  })
4285
5544
  .catch(function() {});
4286
5545
  }
@@ -4297,6 +5556,9 @@
4297
5556
  defaultCwd: (document.getElementById("cfg-cwd") || {}).value,
4298
5557
  shell: (document.getElementById("cfg-shell") || {}).value,
4299
5558
  language: (document.getElementById("cfg-language") || {}).value || "",
5559
+ uiPreferences: {
5560
+ defaultPanelState: getPanelStateSettingsFormValues(),
5561
+ }
4300
5562
  };
4301
5563
 
4302
5564
  fetch("/api/settings/config", {
@@ -4314,6 +5576,7 @@
4314
5576
  } else {
4315
5577
  msgEl.textContent = "配置已保存,部分更改需要重启后生效。";
4316
5578
  msgEl.style.color = "var(--success)";
5579
+ handleSettingsConfigSaved(data.config);
4317
5580
  }
4318
5581
  msgEl.classList.remove("hidden");
4319
5582
  }
@@ -4327,6 +5590,7 @@
4327
5590
  });
4328
5591
  }
4329
5592
 
5593
+
4330
5594
  function uploadCertificates() {
4331
5595
  var keyFile = document.getElementById("cert-key-file");
4332
5596
  var certFile = document.getElementById("cert-cert-file");
@@ -4553,14 +5817,14 @@
4553
5817
  function quickStartSession() {
4554
5818
  var command = getPreferredTool();
4555
5819
  var defaultCwd = getEffectiveCwd();
4556
- var defaultMode = (state.config && state.config.defaultMode) ? state.config.defaultMode : "default";
5820
+ var defaultMode = getSafeModeForTool(command, (state.config && state.config.defaultMode) ? state.config.defaultMode : "default");
4557
5821
  state.preferredCommand = command;
4558
5822
  state.chatMode = getSafeModeForTool(command, state.chatMode);
4559
5823
  fetch("/api/commands", {
4560
5824
  method: "POST",
4561
5825
  headers: { "Content-Type": "application/json" },
4562
5826
  credentials: "same-origin",
4563
- body: JSON.stringify({ command: command, cwd: defaultCwd, mode: defaultMode })
5827
+ body: JSON.stringify({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode })
4564
5828
  })
4565
5829
  .then(function(res) { return res.json(); })
4566
5830
  .then(function(data) {
@@ -4588,6 +5852,7 @@
4588
5852
  var errorEl = document.getElementById("modal-error");
4589
5853
  var command = getPreferredTool();
4590
5854
  var sessionKind = state.sessionCreateKind || "structured";
5855
+ var worktreeEnabled = state.sessionCreateWorktree === true;
4591
5856
 
4592
5857
  hideError(errorEl);
4593
5858
 
@@ -4596,22 +5861,22 @@
4596
5861
  var selectedMode = getSafeModeForTool(command, state.modeValue);
4597
5862
 
4598
5863
  if (sessionKind === "structured") {
4599
- startStructuredSessionFromModal(cwd, selectedMode, errorEl);
5864
+ startStructuredSessionFromModal(cwd, selectedMode, worktreeEnabled, errorEl);
4600
5865
  return;
4601
5866
  }
4602
5867
 
4603
- runPtyCommandFromModal(command, cwd, selectedMode, errorEl);
5868
+ runPtyCommandFromModal(command, cwd, selectedMode, worktreeEnabled, errorEl);
4604
5869
  }
4605
5870
 
4606
- function startStructuredSessionFromModal(cwd, mode, errorEl) {
4607
- console.log("[WAND] startStructuredSessionFromModal cwd:", cwd, "mode:", mode);
5871
+ function startStructuredSessionFromModal(cwd, mode, worktreeEnabled, errorEl) {
5872
+ console.log("[WAND] startStructuredSessionFromModal cwd:", cwd, "mode:", mode, "worktreeEnabled:", worktreeEnabled);
4608
5873
  _sessionCreating = true;
4609
5874
  state.modeValue = mode;
4610
5875
  state.chatMode = mode;
4611
5876
  state.sessionTool = "claude";
4612
5877
  state.preferredCommand = "claude";
4613
5878
  syncComposerModeSelect();
4614
- return createStructuredSession(undefined, cwd, mode)
5879
+ return createStructuredSession(undefined, cwd, mode, worktreeEnabled)
4615
5880
  .then(function(data) {
4616
5881
  saveWorkingDir(cwd);
4617
5882
  closeSessionModal();
@@ -4625,8 +5890,8 @@
4625
5890
  .finally(function() { _sessionCreating = false; });
4626
5891
  }
4627
5892
 
4628
- function runPtyCommandFromModal(command, cwd, mode, errorEl) {
4629
- console.log("[WAND] runPtyCommandFromModal command:", command, "cwd:", cwd, "mode:", mode);
5893
+ function runPtyCommandFromModal(command, cwd, mode, worktreeEnabled, errorEl) {
5894
+ console.log("[WAND] runPtyCommandFromModal command:", command, "cwd:", cwd, "mode:", mode, "worktreeEnabled:", worktreeEnabled);
4630
5895
  _sessionCreating = true;
4631
5896
  state.modeValue = mode;
4632
5897
  state.chatMode = mode;
@@ -4640,8 +5905,10 @@
4640
5905
  credentials: "same-origin",
4641
5906
  body: JSON.stringify({
4642
5907
  command: command,
5908
+ provider: command,
4643
5909
  cwd: cwd,
4644
- mode: mode
5910
+ mode: mode,
5911
+ worktreeEnabled: worktreeEnabled
4645
5912
  })
4646
5913
  })
4647
5914
  .then(function(res) { return res.json(); })
@@ -4669,7 +5936,9 @@
4669
5936
  }
4670
5937
  })
4671
5938
  .catch(function() {
4672
- showError(errorEl, "无法启动会话,请确认 Claude 已正确安装。");
5939
+ showError(errorEl, command === "codex"
5940
+ ? "无法启动 Codex 会话,请确认 codex 已正确安装并可在终端中执行。"
5941
+ : "无法启动 Claude 会话,请确认 Claude 已正确安装。");
4673
5942
  })
4674
5943
  .finally(function() { _sessionCreating = false; });
4675
5944
  }
@@ -4957,10 +6226,25 @@
4957
6226
  }
4958
6227
  }
4959
6228
 
6229
+ function handleInteractiveTextInput(inputBox) {
6230
+ if (!state.terminalInteractive || !inputBox) return false;
6231
+ var value = inputBox.value || "";
6232
+ if (!value) return false;
6233
+ queueDirectInput(value, "interactive_text").catch(function() {});
6234
+ inputBox.value = "";
6235
+ autoResizeInput(inputBox);
6236
+ setDraftValue("", true);
6237
+ return true;
6238
+ }
6239
+
4960
6240
  function handleInputPaste(event) {
4961
6241
  var pasted = event.clipboardData && event.clipboardData.getData("text");
4962
6242
  if (!pasted) return;
4963
6243
  event.preventDefault();
6244
+ if (state.terminalInteractive) {
6245
+ queueDirectInput(pasted, "paste").catch(function() {});
6246
+ return;
6247
+ }
4964
6248
  var inputBox = document.getElementById("input-box");
4965
6249
  if (inputBox) {
4966
6250
  var start = inputBox.selectionStart || 0;
@@ -5065,6 +6349,7 @@
5065
6349
  tool: getPreferredTool(),
5066
6350
  queuedAt: Date.now()
5067
6351
  });
6352
+ persistCrossSessionQueue();
5068
6353
  renderCrossSessionQueue();
5069
6354
  }
5070
6355
 
@@ -5089,6 +6374,7 @@
5089
6374
  showToast(data.error, "error");
5090
6375
  // 失败回填队首,不丢消息
5091
6376
  state.crossSessionQueue.unshift(item);
6377
+ persistCrossSessionQueue();
5092
6378
  renderCrossSessionQueue();
5093
6379
  return null;
5094
6380
  }
@@ -5098,6 +6384,7 @@
5098
6384
  _queueLaunching = false;
5099
6385
  showToast((error && error.message) || "无法启动排队会话。", "error");
5100
6386
  state.crossSessionQueue.unshift(item);
6387
+ persistCrossSessionQueue();
5101
6388
  renderCrossSessionQueue();
5102
6389
  });
5103
6390
  }
@@ -5106,6 +6393,7 @@
5106
6393
  var idx = state.crossSessionQueue.findIndex(function(q) { return q.id === queueId; });
5107
6394
  if (idx < 0) return;
5108
6395
  var item = state.crossSessionQueue.splice(idx, 1)[0];
6396
+ persistCrossSessionQueue();
5109
6397
  renderCrossSessionQueue();
5110
6398
  // 立即发送不受 _queueLaunching 限制
5111
6399
  fetch("/api/commands", {
@@ -5123,12 +6411,18 @@
5123
6411
  .then(function(data) {
5124
6412
  if (data.error) {
5125
6413
  showToast(data.error, "error");
6414
+ state.crossSessionQueue.splice(idx, 0, item);
6415
+ persistCrossSessionQueue();
6416
+ renderCrossSessionQueue();
5126
6417
  return null;
5127
6418
  }
5128
6419
  return activateSession(data);
5129
6420
  })
5130
6421
  .catch(function(error) {
5131
6422
  showToast((error && error.message) || "无法启动排队会话。", "error");
6423
+ state.crossSessionQueue.splice(idx, 0, item);
6424
+ persistCrossSessionQueue();
6425
+ renderCrossSessionQueue();
5132
6426
  });
5133
6427
  }
5134
6428
 
@@ -5136,6 +6430,7 @@
5136
6430
  var idx = state.crossSessionQueue.findIndex(function(q) { return q.id === queueId; });
5137
6431
  if (idx < 0) return;
5138
6432
  state.crossSessionQueue.splice(idx, 1);
6433
+ persistCrossSessionQueue();
5139
6434
  renderCrossSessionQueue();
5140
6435
  if (state.crossSessionQueue.length === 0) {
5141
6436
  showToast("排队已清空。", "info");
@@ -5168,6 +6463,7 @@
5168
6463
 
5169
6464
  if (state.crossSessionQueue.length === 0) {
5170
6465
  if (container) container.remove();
6466
+ persistCrossSessionQueue();
5171
6467
  return;
5172
6468
  }
5173
6469
 
@@ -5239,6 +6535,7 @@
5239
6535
  if (e.target.closest("#queue-clear-all")) {
5240
6536
  e.preventDefault();
5241
6537
  state.crossSessionQueue = [];
6538
+ persistCrossSessionQueue();
5242
6539
  renderCrossSessionQueue();
5243
6540
  showToast("排队已清空。", "info");
5244
6541
  return;
@@ -5286,6 +6583,7 @@
5286
6583
  credentials: "same-origin",
5287
6584
  body: JSON.stringify({
5288
6585
  command: preferredTool,
6586
+ provider: preferredTool,
5289
6587
  cwd: defaultCwd,
5290
6588
  mode: mode,
5291
6589
  initialInput: value
@@ -5314,7 +6612,9 @@
5314
6612
  });
5315
6613
  })
5316
6614
  .catch(function(error) {
5317
- showToast((error && error.message) || "无法启动会话。", "error");
6615
+ showToast((error && error.message) || (preferredTool === "codex"
6616
+ ? "无法启动 Codex 会话。"
6617
+ : "无法启动 Claude 会话。"), "error");
5318
6618
  welcomeInput.placeholder = "输入你的问题,按 Enter 发送...";
5319
6619
  welcomeInput.disabled = false;
5320
6620
  });
@@ -5353,6 +6653,7 @@
5353
6653
  credentials: "same-origin",
5354
6654
  body: JSON.stringify({
5355
6655
  command: preferredTool,
6656
+ provider: preferredTool,
5356
6657
  cwd: defaultCwd,
5357
6658
  mode: mode,
5358
6659
  initialInput: value || undefined
@@ -5378,7 +6679,9 @@
5378
6679
  return loadOutput(data.id);
5379
6680
  })
5380
6681
  .catch(function(error) {
5381
- showToast((error && error.message) || "无法启动会话。", "error");
6682
+ showToast((error && error.message) || (preferredTool === "codex"
6683
+ ? "无法启动 Codex 会话。"
6684
+ : "无法启动 Claude 会话。"), "error");
5382
6685
  });
5383
6686
  }
5384
6687
 
@@ -5434,7 +6737,7 @@
5434
6737
 
5435
6738
  var inputBox = document.getElementById("input-box");
5436
6739
  var value = inputBox ? inputBox.value : "";
5437
- var selectedSession = state.sessions.find(function(session) { return session.id === state.selectedId; }) || null;
6740
+ var selectedSession = getSelectedSession();
5438
6741
  if (value) {
5439
6742
  console.log("[WAND] sendInputFromBox", {
5440
6743
  sessionId: state.selectedId,
@@ -5455,16 +6758,12 @@
5455
6758
  return postStructuredInput(value, inputBox, selectedSession);
5456
6759
  }
5457
6760
 
5458
- // Send text + Enter as a single call to avoid race conditions
5459
- var combinedInput = value + getControlInput("enter");
6761
+ var submitChunks = getTerminalSubmitChunks(selectedSession, value);
5460
6762
  var isOffline = !state.wsConnected;
5461
6763
 
5462
6764
  if (isOffline) {
5463
6765
  // Offline: queue for flush on reconnect, clear input immediately
5464
- if (state.pendingMessages.length >= 100) {
5465
- state.pendingMessages.shift();
5466
- }
5467
- state.pendingMessages.push(combinedInput);
6766
+ queueOfflineTerminalChunks(submitChunks);
5468
6767
  if (inputBox) {
5469
6768
  inputBox.value = "";
5470
6769
  autoResizeInput(inputBox);
@@ -5479,7 +6778,11 @@
5479
6778
  showToast("会话未就绪,将稍后重试。", "info");
5480
6779
  return null;
5481
6780
  }
5482
- return queueDirectInput(combinedInput, "enter_text").then(function() {
6781
+ var submitView = state.currentView;
6782
+ if (readySession && readySession.provider === "codex" && state.selectedId !== readySession.id) {
6783
+ throw new Error("Codex session changed before input send.");
6784
+ }
6785
+ return sendTerminalChunks(submitChunks, "enter_text", 0, submitView).then(function() {
5483
6786
  // Clear input only after the send succeeds
5484
6787
  if (inputBox && inputBox.value === value) {
5485
6788
  inputBox.value = "";
@@ -5502,58 +6805,32 @@
5502
6805
  showToast("会话不存在,请重新选择或新建会话。", "error");
5503
6806
  return Promise.resolve();
5504
6807
  }
5505
- if (session.structuredState && session.structuredState.inFlight && session.status === "running") {
5506
- // Queue the message for sending after current processing completes
5507
- if (state.structuredInputQueue.length >= 10) {
5508
- showToast("排队消息已满(最多 10 条),请等待当前消息处理完成。", "warning");
5509
- return Promise.resolve();
5510
- }
5511
- state.structuredInputQueue.push(input);
5512
- if (inputBox) {
5513
- inputBox.value = "";
5514
- autoResizeInput(inputBox);
5515
- }
5516
- setDraftValue("");
5517
- // Show the queued message in chat view with a "queued" marker
5518
- var queuedTurn = { role: "user", content: [{ type: "text", text: input, __queued: true }] };
5519
- var curMsgs = Array.isArray(state.currentMessages) ? state.currentMessages.slice() : [];
5520
- curMsgs.push(queuedTurn);
5521
- state.currentMessages = curMsgs;
5522
- // Also update session.messages so the queued turn survives WS updates
5523
- session.messages = curMsgs;
6808
+
6809
+ var isQueueing = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
6810
+ if (!isQueueing) {
6811
+ // Immediately render user message with thinking indicator
6812
+ var userTurn = { role: "user", content: [{ type: "text", text: input }] };
6813
+ var userMsgs = stripRenderOnlyStructuredMessages(Array.isArray(session.messages) ? session.messages.slice() : []);
6814
+ userMsgs.push(userTurn);
6815
+ var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
6816
+ updateSessionSnapshot({
6817
+ id: session.id,
6818
+ status: "running",
6819
+ structuredState: optimisticStructuredState,
6820
+ });
6821
+ state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
6822
+ status: "running",
6823
+ structuredState: optimisticStructuredState,
6824
+ }), userMsgs);
6825
+ updateInputHint("思考中…");
5524
6826
  renderChat(true);
5525
- showToast("已排队(第 " + state.structuredInputQueue.length + " 条),将在当前消息处理完成后自动发送。", "info");
5526
- updateStructuredQueueCounter();
5527
- return Promise.resolve();
5528
6827
  }
5529
6828
 
5530
- // Immediately render user message with thinking indicator
5531
- var userTurn = { role: "user", content: [{ type: "text", text: input }] };
5532
- var thinkingTurn = { role: "assistant", content: [{ type: "text", text: "", __processing: true }] };
5533
- var userMsgs = Array.isArray(session.messages) ? session.messages.slice() : [];
5534
- // Filter out __queued placeholders — they'll be re-appended after the new turns
5535
- userMsgs = userMsgs.filter(function(m) {
5536
- return !(m.role === "user" && m.content && m.content.some(function(b) { return b.__queued; }));
5537
- });
5538
- userMsgs.push(userTurn);
5539
- userMsgs.push(thinkingTurn);
5540
- // Re-append remaining queued messages after the current send
5541
- appendQueuedPlaceholders(userMsgs);
5542
- session.messages = userMsgs;
5543
- state.currentMessages = userMsgs;
5544
- // Mark inFlight optimistically to prevent double-send via WS updates
5545
- if (session.structuredState) {
5546
- session.structuredState.inFlight = true;
5547
- }
5548
- session.status = "running";
5549
6829
  if (inputBox) {
5550
6830
  inputBox.value = "";
5551
6831
  autoResizeInput(inputBox);
5552
6832
  }
5553
- // Keep send button enabled so user can queue more messages
5554
- updateInputHint("思考中…");
5555
6833
  setDraftValue("");
5556
- renderChat(true);
5557
6834
 
5558
6835
  return fetch("/api/structured-sessions/" + state.selectedId + "/messages", {
5559
6836
  method: "POST",
@@ -5568,27 +6845,29 @@
5568
6845
  }
5569
6846
  if (snapshot && snapshot.id) {
5570
6847
  updateSessionSnapshot(snapshot);
5571
- if (snapshot.messages && snapshot.messages.length > 0) {
5572
- state.currentMessages = snapshot.messages;
5573
- // Re-append queued user messages
5574
- appendQueuedPlaceholders(state.currentMessages);
5575
- }
6848
+ var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
6849
+ state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
5576
6850
  renderChat(true);
5577
- updateInputHint("Enter 发送 · Shift+Enter 换行");
6851
+ if (isQueueing) {
6852
+ var queuedCount = getStructuredQueuedInputs(refreshedSession).length;
6853
+ showToast("已排队(第 " + queuedCount + " 条),将在当前消息处理完成后自动发送。", "info");
6854
+ } else {
6855
+ updateInputHint("Enter 发送 · Shift+Enter 换行");
6856
+ }
5578
6857
  }
5579
6858
  })
5580
6859
  .catch(function(error) {
5581
- // Reset inFlight so user can send again
5582
- if (session.structuredState) {
5583
- session.structuredState.inFlight = false;
5584
- }
5585
- // Clear remaining queued messages since the session is likely broken
5586
- if (state.structuredInputQueue.length > 0) {
5587
- var dropped = state.structuredInputQueue.length;
5588
- state.structuredInputQueue = [];
5589
- updateStructuredQueueCounter();
5590
- showToast("发送失败,已清空 " + dropped + " 条排队消息。", "error");
5591
- } else {
6860
+ updateSessionSnapshot({
6861
+ id: session.id,
6862
+ structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }),
6863
+ });
6864
+ var message = (error && error.message) || "";
6865
+ var isTransientAbort =
6866
+ message === "Failed to fetch" ||
6867
+ message === "NetworkError when attempting to fetch resource." ||
6868
+ message === "Load failed" ||
6869
+ /aborted|aborterror|networkerror|failed to fetch/i.test(message);
6870
+ if (!isTransientAbort) {
5592
6871
  showToast((error && error.message) || "无法发送结构化消息。", "error");
5593
6872
  }
5594
6873
  updateInputHint("Enter 发送 · Shift+Enter 换行");
@@ -5602,7 +6881,7 @@
5602
6881
 
5603
6882
  function updateStructuredQueueCounter() {
5604
6883
  var counter = document.getElementById("queue-counter");
5605
- var count = state.structuredInputQueue.length;
6884
+ var count = getSelectedStructuredQueuedInputs().length;
5606
6885
  if (counter) {
5607
6886
  counter.textContent = "队列: " + count;
5608
6887
  if (count > 0) {
@@ -5615,56 +6894,48 @@
5615
6894
 
5616
6895
  // Append queued user message placeholders to currentMessages so they
5617
6896
  // remain visible across WS updates and re-renders.
5618
- function appendQueuedPlaceholders(messages) {
5619
- if (state.structuredInputQueue.length === 0) return messages;
5620
- for (var qi = 0; qi < state.structuredInputQueue.length; qi++) {
5621
- messages.push({ role: "user", content: [{ type: "text", text: state.structuredInputQueue[qi], __queued: true }] });
6897
+ function buildMessagesForRender(session, messages) {
6898
+ var sanitized = Array.isArray(messages) ? stripRenderOnlyStructuredMessages(messages) : [];
6899
+ var base = Array.isArray(sanitized) ? sanitized.slice() : [];
6900
+ if (!session || session.sessionKind !== "structured") {
6901
+ return base;
5622
6902
  }
5623
- return messages;
6903
+ var queued = getStructuredQueuedInputs(session);
6904
+ if (queued && queued.length > 0) {
6905
+ for (var qi = 0; qi < queued.length; qi++) {
6906
+ base.push({ role: "user", content: [{ type: "text", text: queued[qi], __queued: true }] });
6907
+ }
6908
+ }
6909
+ if (session.structuredState && session.structuredState.inFlight) {
6910
+ var last = base[base.length - 1];
6911
+ if (!last || last.role !== "assistant") {
6912
+ base.push({ role: "assistant", content: [{ type: "text", text: "", __processing: true }] });
6913
+ }
6914
+ }
6915
+ return base;
5624
6916
  }
5625
6917
 
6918
+
5626
6919
  function flushStructuredInputQueue() {
5627
- if (state.structuredInputQueue.length === 0) return;
5628
6920
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
5629
- if (!session || session.sessionKind !== "structured") {
5630
- state.structuredInputQueue = [];
5631
- updateStructuredQueueCounter();
5632
- return;
5633
- }
5634
- // Only flush if not inFlight
5635
- if (session.structuredState && session.structuredState.inFlight) return;
5636
- var nextInput = state.structuredInputQueue.shift();
6921
+ syncStructuredQueueFromSession(session);
5637
6922
  updateStructuredQueueCounter();
5638
- if (nextInput) {
5639
- // Remove __queued marker from the matching user turn already in chat.
5640
- // postStructuredInput will find it's not inFlight now and do the
5641
- // normal send path, which re-adds the user turn + thinking turn.
5642
- // So we need to remove the queued placeholder first to avoid duplicates.
5643
- var msgs = Array.isArray(state.currentMessages) ? state.currentMessages : [];
5644
- for (var qi = msgs.length - 1; qi >= 0; qi--) {
5645
- var qm = msgs[qi];
5646
- if (qm.role === "user" && qm.content && qm.content.some(function(b) {
5647
- return b.__queued && b.text === nextInput;
5648
- })) {
5649
- msgs.splice(qi, 1);
5650
- break;
5651
- }
5652
- }
5653
- state.currentMessages = msgs;
5654
- if (session.messages) session.messages = msgs;
5655
- // Pass null for inputBox to avoid clearing user's current typing
5656
- postStructuredInput(nextInput, null, session);
5657
- }
5658
6923
  }
5659
6924
 
5660
6925
  function getInputErrorMessage(error) {
6926
+ var selectedSession = getSelectedSession();
6927
+ var isCodex = selectedSession && selectedSession.provider === "codex";
5661
6928
  if (error && (error.errorCode === "SESSION_NOT_RUNNING" || error.errorCode === "SESSION_NO_PTY")) {
5662
- return "会话已结束;若存在 Claude 历史会话,将在你下次发送消息时自动恢复。";
6929
+ return isCodex
6930
+ ? "Codex 会话已结束,请新建会话后继续。"
6931
+ : "会话已结束;若存在 Claude 历史会话,将在你下次发送消息时自动恢复。";
5663
6932
  }
5664
6933
  if (error && error.errorCode === "SESSION_NOT_FOUND") {
5665
6934
  return "会话不存在,请重新选择或新建会话。";
5666
6935
  }
5667
- return (error && error.message) || "会话暂不可用;若存在 Claude 历史会话,将自动尝试恢复。";
6936
+ return (error && error.message) || (isCodex
6937
+ ? "Codex 会话暂不可用,请检查终端视图或新建会话。"
6938
+ : "会话暂不可用;若存在 Claude 历史会话,将自动尝试恢复。");
5668
6939
  }
5669
6940
 
5670
6941
  function buildInputError(payload) {
@@ -5704,7 +6975,7 @@
5704
6975
  }
5705
6976
 
5706
6977
  function canAutoResumeSession(session) {
5707
- return !!(session && session.status === "exited" && session.claudeSessionId && hasRealConversationHistory(session));
6978
+ return !!(session && session.provider === "claude" && session.status === "exited" && session.claudeSessionId && hasRealConversationHistory(session));
5708
6979
  }
5709
6980
 
5710
6981
  function ensureSessionReadyForInput(session, errorEl) {
@@ -5735,11 +7006,48 @@
5735
7006
  });
5736
7007
  }
5737
7008
 
5738
- function queueDirectInput(input, shortcutKey) {
7009
+ function getTerminalSubmitChunks(session, text) {
7010
+ if (session && session.provider === "codex") {
7011
+ return [text, String.fromCharCode(13)];
7012
+ }
7013
+ return [text + String.fromCharCode(13)];
7014
+ }
7015
+
7016
+ function sendTerminalChunks(chunks, shortcutKey, delayMs, viewOverride) {
7017
+ var sequence = Array.isArray(chunks) ? chunks.filter(function(chunk) { return !!chunk; }) : [];
7018
+ if (sequence.length === 0) {
7019
+ return Promise.resolve();
7020
+ }
7021
+ var delay = typeof delayMs === "number" ? delayMs : 0;
7022
+ return sequence.reduce(function(promise, chunk, index) {
7023
+ return promise.then(function() {
7024
+ if (index > 0 && delay > 0) {
7025
+ return new Promise(function(resolve) {
7026
+ setTimeout(resolve, delay);
7027
+ }).then(function() {
7028
+ return queueDirectInput(chunk, index === sequence.length - 1 ? shortcutKey : undefined, viewOverride);
7029
+ });
7030
+ }
7031
+ return queueDirectInput(chunk, index === sequence.length - 1 ? shortcutKey : undefined, viewOverride);
7032
+ });
7033
+ }, Promise.resolve());
7034
+ }
7035
+
7036
+ function queueOfflineTerminalChunks(chunks) {
7037
+ var sequence = Array.isArray(chunks) ? chunks.filter(function(chunk) { return !!chunk; }) : [];
7038
+ sequence.forEach(function(chunk) {
7039
+ if (state.pendingMessages.length >= 100) {
7040
+ state.pendingMessages.shift();
7041
+ }
7042
+ state.pendingMessages.push(chunk);
7043
+ });
7044
+ }
7045
+
7046
+ function queueDirectInput(input, shortcutKey, viewOverride) {
5739
7047
  if (!input || !state.selectedId) return Promise.resolve();
5740
7048
  state.messageQueue.push(input);
5741
7049
  state.inputQueue = state.inputQueue.then(function() {
5742
- return postInput(input, shortcutKey).finally(function() {
7050
+ return postInput(input, shortcutKey, viewOverride).finally(function() {
5743
7051
  var idx = state.messageQueue.indexOf(input);
5744
7052
  if (idx > -1) state.messageQueue.splice(idx, 1);
5745
7053
  scheduleMobileDomUpdate();
@@ -5748,8 +7056,9 @@
5748
7056
  return state.inputQueue;
5749
7057
  }
5750
7058
 
5751
- function postInput(input, shortcutKey) {
7059
+ function postInput(input, shortcutKey, viewOverride) {
5752
7060
  if (!state.selectedId) return Promise.resolve();
7061
+ var effectiveView = viewOverride || state.currentView;
5753
7062
 
5754
7063
  // Pre-check: don't send if session is not running
5755
7064
  if (!isSelectedSessionRunning()) {
@@ -5788,7 +7097,7 @@
5788
7097
  console.log("[wand] postInput: sending", {
5789
7098
  sessionId: state.selectedId,
5790
7099
  inputLength: input.length,
5791
- view: state.currentView,
7100
+ view: effectiveView,
5792
7101
  wsConnected: state.wsConnected
5793
7102
  });
5794
7103
 
@@ -5796,7 +7105,7 @@
5796
7105
  method: "POST",
5797
7106
  headers: { "Content-Type": "application/json" },
5798
7107
  credentials: "same-origin",
5799
- body: JSON.stringify({ input: input, view: state.currentView, shortcutKey: shortcutKey || undefined })
7108
+ body: JSON.stringify({ input: input, view: effectiveView, shortcutKey: shortcutKey || undefined })
5800
7109
  })
5801
7110
  .then(function(res) {
5802
7111
  if (!res.ok) {
@@ -5834,6 +7143,14 @@
5834
7143
  return queueDirectInput(input);
5835
7144
  }
5836
7145
 
7146
+ function getSelectedSession() {
7147
+ return state.sessions.find(function(session) { return session.id === state.selectedId; }) || null;
7148
+ }
7149
+
7150
+ function getTerminalSubmitSequence(session) {
7151
+ return session && session.provider === "codex" ? "\n" : String.fromCharCode(13);
7152
+ }
7153
+
5837
7154
  function isTerminalInteractionAvailable() {
5838
7155
  return !!state.selectedId && state.currentView === "terminal";
5839
7156
  }
@@ -6004,6 +7321,9 @@
6004
7321
  function updateInteractiveControls() {
6005
7322
  var selectedSession = state.sessions.find(function(session) { return session.id === state.selectedId; });
6006
7323
  var structured = isStructuredSession(selectedSession);
7324
+ var isCodex = selectedSession && selectedSession.provider === "codex";
7325
+ var isRunning = !!selectedSession && selectedSession.status === "running";
7326
+ var composer = document.getElementById("input-box");
6007
7327
  // Update both toggle buttons (topbar and terminal-header)
6008
7328
  var toggles = ["terminal-interactive-toggle-top"];
6009
7329
  toggles.forEach(function(id) {
@@ -6019,7 +7339,27 @@
6019
7339
  var expandedRow = document.querySelector(".inline-shortcuts-expanded-row");
6020
7340
  if (expandedRow) expandedRow.classList.toggle("hidden", structured || state.currentView !== "terminal");
6021
7341
  var inputHint = document.querySelector(".input-hint");
6022
- if (inputHint) inputHint.classList.toggle("hidden", structured ? true : state.currentView === "terminal");
7342
+ if (inputHint) {
7343
+ inputHint.classList.toggle("hidden", structured ? true : state.currentView === "terminal");
7344
+ if (!structured && selectedSession) {
7345
+ inputHint.textContent = isCodex
7346
+ ? "Enter 发送 · chat 为解析视图,terminal 为原始输出"
7347
+ : "Enter 发送 · Shift+Enter 换行";
7348
+ }
7349
+ }
7350
+ var disableStructuredInput = !!selectedSession && structured && isCodex && !isRunning;
7351
+ if (composer) {
7352
+ composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
7353
+ composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning);
7354
+ composer.setAttribute("aria-disabled", composer.disabled ? "true" : "false");
7355
+ }
7356
+ var sendBtn = document.getElementById("send-input-button");
7357
+ if (sendBtn) {
7358
+ sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning);
7359
+ sendBtn.setAttribute("title", isCodex
7360
+ ? (isRunning ? "发送给 Codex" : "Codex 会话已结束")
7361
+ : (structured ? "发送" : (!selectedSession || isRunning ? "发送" : "会话已结束")));
7362
+ }
6023
7363
  var container = document.getElementById("output");
6024
7364
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
6025
7365
  }
@@ -7345,6 +8685,10 @@
7345
8685
  }
7346
8686
 
7347
8687
  function focusInputFromTap() {
8688
+ if (state.terminalInteractive) {
8689
+ focusTerminalContainer();
8690
+ return;
8691
+ }
7348
8692
  var inputBox = document.getElementById('input-box');
7349
8693
  if (!inputBox || !state.selectedId || document.activeElement === inputBox) return;
7350
8694
  focusInputWithSelection(inputBox);
@@ -7355,6 +8699,10 @@
7355
8699
  if (!output) return;
7356
8700
  output.setAttribute("tabindex", "0");
7357
8701
  output.focus();
8702
+ var terminalTextarea = output.querySelector(".xterm-helper-textarea");
8703
+ if (terminalTextarea && typeof terminalTextarea.focus === "function") {
8704
+ terminalTextarea.focus();
8705
+ }
7358
8706
  }
7359
8707
 
7360
8708
  // Mobile keyboard handling
@@ -7450,7 +8798,7 @@
7450
8798
 
7451
8799
  function initTerminalResizeHandle() {
7452
8800
  // 终端容器拖动调整大小功能
7453
- var container = document.getElementById("terminal-container");
8801
+ var container = document.getElementById("output");
7454
8802
  if (!container) return;
7455
8803
 
7456
8804
  // 创建拖动手柄
@@ -7747,27 +9095,19 @@
7747
9095
  if (msg.data.messages) {
7748
9096
  snapshot.messages = msg.data.messages;
7749
9097
  }
9098
+ if (msg.data.queuedMessages) {
9099
+ snapshot.queuedMessages = msg.data.queuedMessages;
9100
+ }
9101
+ if (msg.data.structuredState) {
9102
+ snapshot.structuredState = msg.data.structuredState;
9103
+ }
7750
9104
  updateSessionSnapshot(snapshot);
7751
9105
  if (msg.sessionId === state.selectedId) {
7752
- state.currentMessages = getPreferredMessages(snapshot, msg.data.output, false);
7753
- // Re-append queued user messages that haven't been sent yet
7754
- if (msg.data.sessionKind === 'structured') {
7755
- appendQueuedPlaceholders(state.currentMessages);
7756
- }
7757
- // Structured session with inFlight: keep __processing placeholder
7758
- // so the loading indicator stays visible until assistant content arrives
7759
- if (msg.data.sessionKind === 'structured') {
7760
- var outSession = state.sessions.find(function(s) { return s.id === msg.sessionId; });
7761
- if (outSession && outSession.structuredState && outSession.structuredState.inFlight) {
7762
- var lastCur = state.currentMessages[state.currentMessages.length - 1];
7763
- if (!lastCur || lastCur.role !== 'assistant') {
7764
- state.currentMessages.push({ role: "assistant", content: [{ type: "text", text: "", __processing: true }] });
7765
- }
7766
- }
7767
- }
9106
+ var updatedSession = state.sessions.find(function(s) { return s.id === msg.sessionId; }) || snapshot;
9107
+ state.currentMessages = buildMessagesForRender(updatedSession, getPreferredMessages(updatedSession, msg.data.output, false));
7768
9108
  updateTaskDisplay();
7769
9109
  // Structured sessions: render immediately for responsiveness
7770
- if (msg.data.sessionKind === 'structured') {
9110
+ if (updatedSession.sessionKind === 'structured' || msg.data.sessionKind === 'structured') {
7771
9111
  renderChat();
7772
9112
  } else {
7773
9113
  scheduleChatRender();
@@ -7780,10 +9120,13 @@
7780
9120
  if (msg.data.chunk && (!state.terminalSessionId || state.terminalSessionId === msg.sessionId)) {
7781
9121
  // Fast path: write chunk directly to avoid full-output comparison
7782
9122
  // which can trigger terminal.reset() and cause screen flicker.
9123
+ state.terminalLiveStreamSessions[msg.sessionId] = true;
7783
9124
  state.terminal.write(msg.data.chunk);
7784
9125
  state.terminalSessionId = msg.sessionId;
7785
9126
  if (msg.data.output) {
7786
9127
  state.terminalOutput = normalizeTerminalOutput(msg.data.output);
9128
+ } else {
9129
+ state.terminalOutput = normalizeTerminalOutput((state.terminalOutput || "") + msg.data.chunk);
7787
9130
  }
7788
9131
  maybeScrollTerminalToBottom("output");
7789
9132
  updateTerminalJumpToBottomButton();
@@ -7811,6 +9154,9 @@
7811
9154
  if (msg.data && msg.data.structuredState) {
7812
9155
  endedSnapshot.structuredState = msg.data.structuredState;
7813
9156
  }
9157
+ if (msg.data && msg.data.queuedMessages) {
9158
+ endedSnapshot.queuedMessages = msg.data.queuedMessages;
9159
+ }
7814
9160
  updateSessionSnapshot(endedSnapshot);
7815
9161
 
7816
9162
  if (msg.sessionId === state.selectedId) {
@@ -7846,22 +9192,24 @@
7846
9192
  }
7847
9193
 
7848
9194
  // Clear stale queued inputs for PTY sessions.
7849
- // For structured sessions, each "ended" means one turn completed (not
7850
- // the session terminated), so we must NOT clear the structured queue —
7851
- // instead, flush the next queued message.
9195
+ // For structured sessions, the queue is now managed by the server snapshot.
7852
9196
  state.messageQueue = [];
7853
9197
  state.pendingMessages = [];
7854
9198
 
7855
9199
  var endedSessionObj = state.sessions.find(function(s) { return s.id === msg.sessionId; });
7856
- var isStructuredEnded = endedSessionObj && endedSessionObj.sessionKind === "structured";
9200
+ var selectedSessionObj = msg.sessionId === state.selectedId
9201
+ ? state.sessions.find(function(s) { return s.id === state.selectedId; })
9202
+ : null;
9203
+ var isStructuredEnded = !!(
9204
+ (endedSessionObj && endedSessionObj.sessionKind === "structured") ||
9205
+ (selectedSessionObj && selectedSessionObj.sessionKind === "structured")
9206
+ );
7857
9207
 
7858
- if (isStructuredEnded && msg.sessionId === state.selectedId &&
7859
- state.structuredInputQueue.length > 0) {
7860
- // Structured session turn completed — flush next queued message
7861
- setTimeout(flushStructuredInputQueue, 50);
9208
+ if (isStructuredEnded && msg.sessionId === state.selectedId) {
9209
+ flushStructuredInputQueue();
7862
9210
  } else if (!isStructuredEnded) {
7863
- // PTY session ended — clear structured queue too
7864
9211
  state.structuredInputQueue = [];
9212
+ clearStructuredQueuePersistence(state.selectedId);
7865
9213
  updateStructuredQueueCounter();
7866
9214
  }
7867
9215
 
@@ -7896,7 +9244,13 @@
7896
9244
  // Initial state for subscribed session (after reconnect or subscription)
7897
9245
  if (msg.sessionId === state.selectedId && msg.data) {
7898
9246
  if (chatRenderTimer) { clearTimeout(chatRenderTimer); chatRenderTimer = null; }
7899
- updateTerminalOutput(msg.data.output || "", msg.sessionId, "replace");
9247
+ updateSessionSnapshot(msg.data);
9248
+ var initSession = state.sessions.find(function(s) { return s.id === msg.sessionId; });
9249
+ state.currentMessages = buildMessagesForRender(initSession || msg.data, getPreferredMessages(initSession || msg.data, msg.data.output, false));
9250
+ renderChat(true);
9251
+ updateTaskDisplay();
9252
+ updateApprovalStats();
9253
+ updateTerminalOutput(msg.data.output || "", msg.sessionId, "append");
7900
9254
  // Ensure terminal is properly fitted after receiving initial data
7901
9255
  scheduleTerminalResize(true);
7902
9256
  }
@@ -7913,7 +9267,6 @@
7913
9267
  break;
7914
9268
  case 'status':
7915
9269
  if (msg.sessionId && msg.data) {
7916
- console.log('[WAND] ws status', msg.sessionId, JSON.stringify(msg.data));
7917
9270
  var statusUpdate = { id: msg.sessionId };
7918
9271
  if (Object.prototype.hasOwnProperty.call(msg.data, 'status')) {
7919
9272
  statusUpdate.status = msg.data.status;
@@ -8026,8 +9379,14 @@
8026
9379
  var permissionLabel = document.getElementById("permission-actions-label");
8027
9380
  if (!taskEl) return;
8028
9381
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
9382
+ if (selectedSession && selectedSession.provider === "codex") {
9383
+ if (permissionActionsEl) permissionActionsEl.classList.add("hidden");
9384
+ taskEl.classList.remove("permission-blocked");
9385
+ }
8029
9386
  var pendingEscalation = selectedSession && selectedSession.pendingEscalation ? selectedSession.pendingEscalation : null;
8030
- var isBlocked = pendingEscalation || (selectedSession && selectedSession.permissionBlocked);
9387
+ var isBlocked = selectedSession && selectedSession.provider !== "codex"
9388
+ ? (pendingEscalation || selectedSession.permissionBlocked)
9389
+ : false;
8031
9390
 
8032
9391
  if (isBlocked) {
8033
9392
  var isAutoApprove = selectedSession && selectedSession.autoApprovePermissions;
@@ -8161,6 +9520,11 @@
8161
9520
 
8162
9521
  function toggleAutoApprove() {
8163
9522
  if (!state.selectedId) return;
9523
+ var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
9524
+ if (selectedSession && selectedSession.provider === "codex") {
9525
+ showToast("Codex 会话固定以 full-access PTY 启动,不支持切换自动批准。", "info");
9526
+ return;
9527
+ }
8164
9528
  var toggle = document.getElementById("auto-approve-toggle");
8165
9529
  if (toggle) toggle.style.opacity = "0.5";
8166
9530
  fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/toggle-auto-approve", {
@@ -8190,6 +9554,12 @@
8190
9554
  var toggle = document.getElementById("auto-approve-toggle");
8191
9555
  if (!toggle) return;
8192
9556
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
9557
+ if (selectedSession && selectedSession.provider === "codex") {
9558
+ toggle.className = "auto-approve-indicator active";
9559
+ toggle.title = "Codex 固定以 full-access PTY 启动,不支持切换自动批准";
9560
+ toggle.textContent = "🛡 Codex 固定全权限";
9561
+ return;
9562
+ }
8193
9563
  var enabled = selectedSession && selectedSession.autoApprovePermissions;
8194
9564
  if (enabled) {
8195
9565
  toggle.className = "auto-approve-indicator active";
@@ -8222,6 +9592,10 @@
8222
9592
  }
8223
9593
  applyCurrentView();
8224
9594
  reconcileInteractiveState();
9595
+ var selectedSession = getSelectedSession();
9596
+ if (selectedSession) {
9597
+ state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, selectedSession.output, true));
9598
+ }
8225
9599
  updateTerminalJumpToBottomButton();
8226
9600
  if (state.currentView === "terminal") {
8227
9601
  state.terminalViewportSize = { width: 0, height: 0 };
@@ -8260,10 +9634,7 @@
8260
9634
  // Re-parse messages from the latest session output (fallback for edge cases)
8261
9635
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
8262
9636
  if (selectedSession) {
8263
- state.currentMessages = getPreferredMessages(selectedSession, selectedSession.output, true);
8264
- if (selectedSession.sessionKind === "structured") {
8265
- appendQueuedPlaceholders(state.currentMessages);
8266
- }
9637
+ state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, selectedSession.output, true));
8267
9638
  }
8268
9639
  renderChat();
8269
9640
  }, 30);
@@ -8353,6 +9724,26 @@
8353
9724
  return systemInfo;
8354
9725
  }
8355
9726
 
9727
+ function ensureChatMessagesContainer(chatOutput) {
9728
+ if (!chatOutput) return null;
9729
+ var chatMessages = chatOutput.querySelector(".chat-messages");
9730
+ if (chatMessages) return chatMessages;
9731
+ chatMessages = document.createElement("div");
9732
+ chatMessages.className = "chat-messages";
9733
+ chatOutput.appendChild(chatMessages);
9734
+ return chatMessages;
9735
+ }
9736
+
9737
+ function renderChatEmptyState(chatOutput, html) {
9738
+ var chatMessages = ensureChatMessagesContainer(chatOutput);
9739
+ if (!chatMessages) return null;
9740
+ chatMessages.innerHTML = html;
9741
+ bindChatScrollListener();
9742
+ updateChatFollowToggleButton();
9743
+ updateChatJumpToBottomButton();
9744
+ return chatMessages;
9745
+ }
9746
+
8356
9747
  function doRenderChat(forceFullRender) {
8357
9748
  var chatOutput = document.getElementById("chat-output");
8358
9749
  if (!chatOutput) return;
@@ -8360,7 +9751,7 @@
8360
9751
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
8361
9752
  if (!selectedSession) {
8362
9753
  if (state.lastRenderedEmpty !== "none") {
8363
- chatOutput.innerHTML = '<div class="empty-state"><strong>未选择会话</strong><br>点击上方「新对话」开始你的第一次对话。</div>';
9754
+ renderChatEmptyState(chatOutput, '<div class="empty-state"><strong>未选择会话</strong><br>点击上方「新对话」开始你的第一次对话。</div>');
8364
9755
  state.lastRenderedEmpty = "none";
8365
9756
  state.lastRenderedMsgCount = 0;
8366
9757
  }
@@ -8371,7 +9762,7 @@
8371
9762
 
8372
9763
  if (messages.length === 0) {
8373
9764
  if (state.lastRenderedEmpty !== "empty") {
8374
- chatOutput.innerHTML = '<div class="empty-state"><strong>对话已开始</strong><br>在下方输入框发送消息,Claude 会自动回复。</div>';
9765
+ renderChatEmptyState(chatOutput, '<div class="empty-state"><strong>对话已开始</strong><br>在下方输入框发送消息,Claude 会自动回复。</div>');
8375
9766
  state.lastRenderedEmpty = "empty";
8376
9767
  state.lastRenderedMsgCount = 0;
8377
9768
  }
@@ -8424,12 +9815,8 @@
8424
9815
  state.lastRenderedMsgCount = msgCount;
8425
9816
  state.lastRenderedHash = outputHash;
8426
9817
 
8427
- var chatMessages = chatOutput.querySelector(".chat-messages");
8428
- if (!chatMessages) {
8429
- // First render - create container
8430
- chatOutput.innerHTML = '<div class="chat-messages"></div>';
8431
- chatMessages = chatOutput.querySelector(".chat-messages");
8432
- }
9818
+ var chatMessages = ensureChatMessagesContainer(chatOutput);
9819
+ if (!chatMessages) return;
8433
9820
 
8434
9821
  var existingCount = chatMessages.querySelectorAll(".chat-message").length;
8435
9822
  // Full render when: forced, no existing messages, or message count decreased/changed
@@ -8447,7 +9834,7 @@
8447
9834
  for (var i = 0; i < reversedMessages.length; i++) {
8448
9835
  var msg = reversedMessages[i];
8449
9836
  var originalIndex = msgCount - 1 - i; // Original index in messages array
8450
-
9837
+
8451
9838
  // Find system info for this message position
8452
9839
  var sysInfo = null;
8453
9840
  for (var j = 0; j < systemInfo.length; j++) {
@@ -8456,7 +9843,7 @@
8456
9843
  break;
8457
9844
  }
8458
9845
  }
8459
-
9846
+
8460
9847
  // Render system info card if exists
8461
9848
  if (sysInfo) {
8462
9849
  html += '<div class="chat-message system-info">' +
@@ -8466,21 +9853,30 @@
8466
9853
  '</div>' +
8467
9854
  '</div>';
8468
9855
  }
8469
-
9856
+
8470
9857
  // Render message
8471
- html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null);
9858
+ html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null, originalIndex);
8472
9859
  }
8473
-
9860
+
8474
9861
  chatMessages.innerHTML = html;
8475
9862
  attachAllCopyHandlers(chatMessages);
9863
+ bindChatScrollListener();
9864
+ applyPersistedExpandState(chatMessages);
8476
9865
  // Only expand the single newest tool card (first chat-message = newest due to column-reverse)
8477
9866
  var firstMsg = chatMessages.querySelector(".chat-message:not(.system-info)");
8478
9867
  if (firstMsg) {
8479
9868
  var cards = firstMsg.querySelectorAll(".tool-use-card");
8480
9869
  if (cards.length > 0) {
8481
- cards[0].classList.remove("collapsed");
9870
+ var firstCard = cards[0];
9871
+ var firstCardKey = getElementExpandKey(firstCard);
9872
+ if (!hasPersistedExpandState(firstCardKey) && !getConfiguredPanelDefaults().structuredToolCardExpanded) {
9873
+ firstCard.classList.remove("collapsed");
9874
+ }
8482
9875
  for (var ci = 1; ci < cards.length; ci++) {
8483
- cards[ci].classList.add("collapsed");
9876
+ var cardKey = getElementExpandKey(cards[ci]);
9877
+ if (!hasPersistedExpandState(cardKey) && !getConfiguredPanelDefaults().structuredToolCardExpanded) {
9878
+ cards[ci].classList.add("collapsed");
9879
+ }
8484
9880
  }
8485
9881
  }
8486
9882
  }
@@ -8495,6 +9891,8 @@
8495
9891
  function collapseOldToolCards(container, newEls) {
8496
9892
  var allCards = container.querySelectorAll(".tool-use-card");
8497
9893
  allCards.forEach(function(c) {
9894
+ var cardKey = getElementExpandKey(c);
9895
+ if (hasPersistedExpandState(cardKey) || getConfiguredPanelDefaults().structuredToolCardExpanded) return;
8498
9896
  // Keep expanded if this card is inside a newly added message
8499
9897
  if (newEls) {
8500
9898
  for (var i = 0; i < newEls.length; i++) {
@@ -8558,7 +9956,7 @@
8558
9956
  for (var i = 0; i < newMessages.length; i++) {
8559
9957
  var div = document.createElement("div");
8560
9958
  var nmOrigIdx = existingCount + (newMessages.length - 1 - i);
8561
- div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null);
9959
+ div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null, nmOrigIdx);
8562
9960
  var el = div.firstElementChild;
8563
9961
  if (el) {
8564
9962
  el.classList.add("animate-in");
@@ -8567,7 +9965,9 @@
8567
9965
  }
8568
9966
  }
8569
9967
  chatMessages.insertBefore(fragment, chatMessages.firstChild);
9968
+ bindChatScrollListener();
8570
9969
  attachAllCopyHandlers(chatMessages);
9970
+ applyPersistedExpandState(chatMessages);
8571
9971
  // Collapse all existing cards; new cards (with animate-in) stay expanded
8572
9972
  collapseOldToolCards(chatMessages, insertedEls);
8573
9973
  // Scroll to bottom (newest message) - column-reverse: scrollTop=0 is visual bottom
@@ -8588,7 +9988,7 @@
8588
9988
  var currentEl = existingEls[mi];
8589
9989
  var tmpWrap = document.createElement("div");
8590
9990
  var srOrigIdx = reversedMessages.length - 1 - mi;
8591
- tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null);
9991
+ tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null, srOrigIdx);
8592
9992
  var replacementEl = tmpWrap.firstElementChild;
8593
9993
  if (!replacementEl) continue;
8594
9994
  if (currentEl.innerHTML !== replacementEl.innerHTML || currentEl.className !== replacementEl.className) {
@@ -8606,6 +10006,8 @@
8606
10006
  fullRenderChat();
8607
10007
  }
8608
10008
  if (replacedAny) {
10009
+ bindChatScrollListener();
10010
+ applyPersistedExpandState(chatMessages);
8609
10011
  requestAnimationFrame(function() {
8610
10012
  smartScrollToBottom(chatMessages);
8611
10013
  });
@@ -8613,6 +10015,8 @@
8613
10015
  var allCards = chatMessages.querySelectorAll(".tool-use-card");
8614
10016
  var newestCard = null;
8615
10017
  allCards.forEach(function(c) {
10018
+ var cardKey = getElementExpandKey(c);
10019
+ if (hasPersistedExpandState(cardKey) || getConfiguredPanelDefaults().structuredToolCardExpanded) return;
8616
10020
  if (newestMsgEl && newestMsgEl.contains(c)) {
8617
10021
  if (!newestCard) newestCard = c;
8618
10022
  else c.classList.add("collapsed");
@@ -8635,14 +10039,15 @@
8635
10039
  // Smart scroll: only auto-scroll if user is near bottom
8636
10040
  // column-reverse: scrollTop near 0 = visual bottom (newest messages)
8637
10041
  function smartScrollToBottom(container) {
8638
- var chatMsgs = container.querySelector ? container.querySelector(".chat-messages") : container;
8639
- if (!chatMsgs) chatMsgs = container;
8640
- var threshold = 200;
8641
- // column-reverse: scrollTop=0 is the visual bottom; positive = scrolled up
8642
- var isNearBottom = chatMsgs.scrollTop < threshold;
8643
- if (isNearBottom) {
8644
- chatMsgs.scrollTop = 0;
10042
+ if (!state.chatAutoFollow) {
10043
+ updateChatJumpToBottomButton();
10044
+ return;
8645
10045
  }
10046
+ var chatMsgs = container && container.classList && container.classList.contains("chat-messages")
10047
+ ? container
10048
+ : getChatScrollElement();
10049
+ if (!chatMsgs || !chatMsgs.isConnected) return;
10050
+ scrollChatToBottom(false);
8646
10051
  }
8647
10052
 
8648
10053
  // --- Todo progress bar ---
@@ -9026,6 +10431,74 @@
9026
10431
  }, 150);
9027
10432
  }
9028
10433
 
10434
+ function isNoiseLine(line) {
10435
+ if (!line) return false;
10436
+ var trimmed = String(line).trim();
10437
+ if (!trimmed) return false;
10438
+ if (trimmed.indexOf("────") === 0) return true;
10439
+ if (trimmed === "❯" || trimmed === "›") return true;
10440
+ if (/^[╭╰│┌└┐┘├┤┬┴┼─═]{2,}$/.test(trimmed)) return true;
10441
+ if (/^[▁▂▃▄▅▆▇█▔▕▏▐]+$/.test(trimmed)) return true;
10442
+ if (trimmed.indexOf("esc to interrupt") !== -1) return true;
10443
+ if (trimmed.indexOf("Claude Code v") !== -1) return true;
10444
+ if (/^Sonnet\b/.test(trimmed)) return true;
10445
+ if (trimmed.indexOf("Failed to install Anthropic") !== -1) return true;
10446
+ if (trimmed.indexOf("Claude Code has switched") !== -1) return true;
10447
+ if (trimmed.indexOf("? for shortcuts") !== -1) return true;
10448
+ if (trimmed.indexOf("Claude is waiting") !== -1) return true;
10449
+ if (trimmed.indexOf("[wand]") !== -1) return true;
10450
+ if (trimmed.indexOf("0;") === 0 || trimmed.indexOf("9;") === 0) return true;
10451
+ if (trimmed.indexOf("ctrl+g") !== -1) return true;
10452
+ if (trimmed.indexOf("/effort") !== -1) return true;
10453
+ if (/^Using .* for .* session/.test(trimmed)) return true;
10454
+ if (trimmed.indexOf("Press ") === 0 && trimmed.indexOf(" for") !== -1) return true;
10455
+ if (trimmed.indexOf("type ") === 0 && trimmed.indexOf(" to ") !== -1) return true;
10456
+ if (trimmed.indexOf("auto mode is unavailable") !== -1) return true;
10457
+ if (/MCP server.*failed/i.test(trimmed)) return true;
10458
+ if (trimmed.indexOf("Germinating") !== -1 || trimmed.indexOf("Doodling") !== -1 || trimmed.indexOf("Brewing") !== -1) return true;
10459
+ if (trimmed.indexOf("Permissions") !== -1 && trimmed.indexOf("mode") !== -1) return true;
10460
+ if (trimmed.indexOf("●") === 0 && trimmed.indexOf("·") !== -1) return true;
10461
+ if (trimmed.indexOf("[>") === 0 || trimmed.indexOf("[<") === 0) return true;
10462
+ if (trimmed.indexOf("Captured Claude session ID") !== -1) return true;
10463
+ if (/^>_\s*OpenAI Codex\b/.test(trimmed)) return true;
10464
+ if (/^OpenAI Codex\b/i.test(trimmed)) return true;
10465
+ if (/^(model|directory):\s+/i.test(trimmed)) return true;
10466
+ if (/^(tip|context):\s+/i.test(trimmed)) return true;
10467
+ if (/^work(tree|space):\s+/i.test(trimmed)) return true;
10468
+ if (/^(approvals?|sandbox|provider|session id):\s+/i.test(trimmed)) return true;
10469
+ if (/^(thinking|working)(\.\.\.|…)?$/i.test(trimmed)) return true;
10470
+ if (/^[•◦·]\s+Working\b/i.test(trimmed)) return true;
10471
+ if (/^[•◦·]\s+(Running|Planning|Applying|Reading|Searching)\b/i.test(trimmed)) return true;
10472
+ if (/^[•◦·]\s+(Inspecting|Reviewing|Summarizing|Editing|Updating|Writing)\b/i.test(trimmed)) return true;
10473
+ if (/^[•◦·]\s+Completed\b/i.test(trimmed)) return true;
10474
+ if (/^(ctrl|enter|tab|shift|esc|alt)\+/i.test(trimmed)) return true;
10475
+ if (/\b(open|close|toggle) (chat|terminal)\b/i.test(trimmed)) return true;
10476
+ if (/\b(approve|deny)\b.*\b(permission|approval)\b/i.test(trimmed)) return true;
10477
+ if (/^(use|press) .* (to|for) .*/i.test(trimmed)) return true;
10478
+ if (/^(?:token|context window|remaining context|conversation):\s+/i.test(trimmed)) return true;
10479
+ if (/^(?:cwd|path):\s+\//i.test(trimmed)) return true;
10480
+ if (/^[<>│┆╎].*[<>│┆╎]$/.test(trimmed) && trimmed.length < 8) return true;
10481
+ return false;
10482
+ }
10483
+
10484
+ function stripAnsi(text) {
10485
+ return String(text || "")
10486
+ .replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "")
10487
+ .replace(/\x1b\[(\d+)C/g, function(_match, count) { return " ".repeat(Number(count) || 1); })
10488
+ .replace(/\x1b\[[0-9;?]*[AB]/g, "\n")
10489
+ .replace(/\x1b\[[0-9;?]*[su]/g, "")
10490
+ .replace(/\x1b\[[0-9;?]*[HfJKr]/g, "\n")
10491
+ .replace(/\x1bM/g, "\n")
10492
+ .replace(/\x1b\[[0-9;?]*[ST]/g, "\n")
10493
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "")
10494
+ .replace(/\x1b[><=ePX^_]/g, "")
10495
+ .replace(/[\u00a0\u200b-\u200d\ufeff]/g, " ")
10496
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
10497
+ .replace(/\r\n?/g, "\n")
10498
+ .replace(/[ \t]+\n/g, "\n")
10499
+ .replace(/\n{3,}/g, "\n\n");
10500
+ }
10501
+
9029
10502
  function parseMessages(output, command) {
9030
10503
  var messages = [];
9031
10504
  if (!output) return messages;
@@ -9035,6 +10508,269 @@
9035
10508
  var carriageReturn = String.fromCharCode(13);
9036
10509
  var esc = String.fromCharCode(27);
9037
10510
 
10511
+ if (/^codex\b/.test(String(command || "").trim())) {
10512
+ var codexFooterRe = /\bgpt-\d+(?:\.\d+)?(?:\s+[a-z0-9.-]+)?\s+·\s+\d+%\s+left\s+·\s+(?:\/|~\/).+/i;
10513
+ var codexActivityRe = /^(?:thinking|working|running|planning|applying|reading|searching|inspecting|reviewing|summarizing|editing|updating|writing|completed)\b/i;
10514
+
10515
+ function stripCodexSegment(raw) {
10516
+ return String(raw || "")
10517
+ .replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "")
10518
+ .replace(/\x1b\[(\d+)C/g, function(_match, count) { return " ".repeat(Number(count) || 1); })
10519
+ .replace(/\x1b\[[0-9;?]*[AB]/g, newline)
10520
+ .replace(/\x1b\[[0-9;?]*[su]/g, "")
10521
+ .replace(/\x1b\[[0-9;?]*[HfJKr]/g, newline)
10522
+ .replace(/\x1bM/g, newline)
10523
+ .replace(/\x1b\[[0-9;?]*[ST]/g, newline)
10524
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "")
10525
+ .replace(/\x1b[><=ePX^_]/g, "")
10526
+ .replace(/[\u00a0\u200b-\u200d\ufeff]/g, " ")
10527
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
10528
+ .replace(/[ \t]+\n/g, newline);
10529
+ }
10530
+
10531
+ function normalizeCodexText(value) {
10532
+ return String(value || "")
10533
+ .replace(/\s+/g, " ")
10534
+ .replace(/[M]+$/g, "")
10535
+ .trim();
10536
+ }
10537
+
10538
+ function normalizeCodexPromptLine(line) {
10539
+ return String(line || "")
10540
+ .replace(/^›\s*/, "")
10541
+ .replace(/^>\s*/, "")
10542
+ .trim();
10543
+ }
10544
+
10545
+ function shouldIgnoreCodexLine(line) {
10546
+ var trimmed = String(line || "").trim();
10547
+ if (!trimmed) return true;
10548
+ if (isNoiseLine(trimmed)) return true;
10549
+ if (codexFooterRe.test(trimmed)) return true;
10550
+ if (/^[╭╰│┌└┐┘├┤┬┴┼─═]/.test(trimmed)) return true;
10551
+ if (/^\[>[0-9;?]*u$/i.test(trimmed)) return true;
10552
+ if (/^M+$/i.test(trimmed)) return true;
10553
+ if (/^(?:OpenAI Codex|Codex)\b/i.test(trimmed)) return true;
10554
+ if (/^(?:tokens?|context window|remaining context|approvals?|sandbox|provider|session id):\s*/i.test(trimmed)) return true;
10555
+ if (/^(?:thinking|working)\s*(?:\.\.\.|…)?$/i.test(trimmed)) return true;
10556
+ if (/^[•◦·]\s+(?:thinking|working|running|planning|applying|reading|searching|inspecting|reviewing|summarizing|editing|updating|writing|completed)\b/i.test(trimmed)) return true;
10557
+ if (/^(?:model|directory|tip|context|cwd|path):\s+/i.test(trimmed)) return true;
10558
+ return false;
10559
+ }
10560
+
10561
+ function extractCodexPromptCandidate(line) {
10562
+ var trimmed = String(line || "").trim();
10563
+ if (!/^›(?:\s|$)/.test(trimmed)) return null;
10564
+ if (codexFooterRe.test(trimmed)) return null;
10565
+ var prompt = normalizeCodexText(normalizeCodexPromptLine(trimmed));
10566
+ if (!prompt || shouldIgnoreCodexLine(prompt)) return null;
10567
+ return prompt;
10568
+ }
10569
+
10570
+ function extractCodexAssistantCandidate(line) {
10571
+ var trimmed = String(line || "").trim();
10572
+ if (!/^[•◦·⏺]/.test(trimmed)) return null;
10573
+
10574
+ var assistant = trimmed
10575
+ .replace(/^[•◦·]\s*/, "")
10576
+ .replace(/^⏺\s+/, "")
10577
+ .replace(/^│\s*/, "")
10578
+ .trim();
10579
+ if (!assistant || /^[•◦·⏺]$/.test(assistant)) return null;
10580
+
10581
+ assistant = assistant
10582
+ .replace(/\s*\(\d+[smh]?\s*•\s*esc to interrupt\)[\s\S]*$/i, "")
10583
+ .replace(/(?:[a-z]{1,6})?›[\s\S]*$/, "")
10584
+ .replace(/\s{2,}gpt-\d[\s\S]*$/i, "")
10585
+ .replace(/\b(?:OpenAI Codex|model:|directory:|Tip:)\b[\s\S]*$/i, "");
10586
+ assistant = normalizeCodexText(assistant);
10587
+
10588
+ if (!assistant || assistant.length < 2 || codexActivityRe.test(assistant) || shouldIgnoreCodexLine(assistant)) {
10589
+ return null;
10590
+ }
10591
+ return assistant;
10592
+ }
10593
+
10594
+ function extractCodexEchoCandidate(line) {
10595
+ var trimmed = normalizeCodexText(line);
10596
+ if (!trimmed || shouldIgnoreCodexLine(trimmed)) return null;
10597
+ if (/^[•◦·⏺›]/.test(trimmed)) return null;
10598
+ if (/^[\[\]<>0-9;?]+u?$/i.test(trimmed)) return null;
10599
+ if (/^[╭╰│┌└┐┘├┤┬┴┼─═]/.test(trimmed)) return null;
10600
+ if (trimmed.length > 500) return null;
10601
+ return trimmed;
10602
+ }
10603
+
10604
+ function isLikelyAssistantTailArtifact(longer, shorter) {
10605
+ if (longer.indexOf(shorter) !== 0) return false;
10606
+ var suffix = longer.slice(shorter.length);
10607
+ return /^[a-z]{1,4}$/i.test(suffix);
10608
+ }
10609
+
10610
+ function coalesceAssistantLines(lines) {
10611
+ var collected = [];
10612
+ for (var i = 0; i < lines.length; i++) {
10613
+ var normalized = normalizeCodexText(lines[i]);
10614
+ if (!normalized || normalized.length < 2 || shouldIgnoreCodexLine(normalized)) continue;
10615
+
10616
+ var previous = collected[collected.length - 1];
10617
+ if (!previous) {
10618
+ collected.push(normalized);
10619
+ continue;
10620
+ }
10621
+ if (normalized === previous) continue;
10622
+ if (normalized.indexOf(previous) === 0) {
10623
+ collected[collected.length - 1] = normalized;
10624
+ continue;
10625
+ }
10626
+ if (previous.indexOf(normalized) === 0) {
10627
+ if (isLikelyAssistantTailArtifact(previous, normalized)) {
10628
+ collected[collected.length - 1] = normalized;
10629
+ }
10630
+ continue;
10631
+ }
10632
+ collected.push(normalized);
10633
+ }
10634
+ return collected.join(newline).trim();
10635
+ }
10636
+
10637
+ function extractVisiblePrompt(lines) {
10638
+ for (var i = 0; i < lines.length; i++) {
10639
+ var line = String(lines[i] || "").trim();
10640
+ if (!line) continue;
10641
+
10642
+ var inlinePrompt = extractCodexPromptCandidate(line);
10643
+ if (inlinePrompt) return inlinePrompt;
10644
+
10645
+ if (line === "›") {
10646
+ for (var j = i + 1; j < lines.length; j++) {
10647
+ var nextLine = normalizeCodexText(lines[j]);
10648
+ if (!nextLine || codexFooterRe.test(nextLine) || shouldIgnoreCodexLine(nextLine)) continue;
10649
+ return nextLine;
10650
+ }
10651
+ }
10652
+ }
10653
+ return null;
10654
+ }
10655
+
10656
+ function extractVisibleAssistantLines(lines) {
10657
+ var assistantLines = [];
10658
+ var collecting = false;
10659
+
10660
+ for (var i = 0; i < lines.length; i++) {
10661
+ var line = String(lines[i] || "").trim();
10662
+ if (!line) {
10663
+ if (collecting) break;
10664
+ continue;
10665
+ }
10666
+
10667
+ var assistant = extractCodexAssistantCandidate(line);
10668
+ if (assistant) {
10669
+ assistantLines.push(assistant);
10670
+ collecting = true;
10671
+ continue;
10672
+ }
10673
+
10674
+ if (collecting) {
10675
+ if (line === "›" || /^›(?:\s|$)/.test(line) || codexFooterRe.test(line) || shouldIgnoreCodexLine(line)) {
10676
+ break;
10677
+ }
10678
+ assistantLines.push(normalizeCodexText(line));
10679
+ }
10680
+ }
10681
+
10682
+ return assistantLines;
10683
+ }
10684
+
10685
+ var rawCandidates = [];
10686
+ var candidateOrder = 0;
10687
+ var rawSegments = text.replace(/\r\n?/g, newline).split(newline);
10688
+ for (var rs = 0; rs < rawSegments.length; rs++) {
10689
+ var cleanedSegment = stripCodexSegment(rawSegments[rs]);
10690
+ var pieces = cleanedSegment.split(newline);
10691
+ for (var pi = 0; pi < pieces.length; pi++) {
10692
+ var piece = String(pieces[pi] || "").trim();
10693
+ if (!piece) continue;
10694
+
10695
+ var promptCandidate = extractCodexPromptCandidate(piece);
10696
+ if (promptCandidate) {
10697
+ rawCandidates.push({ kind: "user", order: candidateOrder++, text: promptCandidate });
10698
+ continue;
10699
+ }
10700
+
10701
+ var assistantCandidate = extractCodexAssistantCandidate(piece);
10702
+ if (assistantCandidate) {
10703
+ rawCandidates.push({ kind: "assistant", order: candidateOrder++, text: assistantCandidate });
10704
+ continue;
10705
+ }
10706
+
10707
+ var echoCandidate = extractCodexEchoCandidate(piece);
10708
+ if (echoCandidate) {
10709
+ rawCandidates.push({ kind: "echo", order: candidateOrder++, text: echoCandidate });
10710
+ }
10711
+ }
10712
+ }
10713
+
10714
+ var candidates = rawCandidates.filter(function(candidate, index, list) {
10715
+ var previous = list[index - 1];
10716
+ return !previous || previous.kind !== candidate.kind || previous.text !== candidate.text;
10717
+ });
10718
+
10719
+ var explicitUsers = candidates.filter(function(candidate) { return candidate.kind === "user"; });
10720
+ var assistantCandidates = candidates.filter(function(candidate) { return candidate.kind === "assistant"; });
10721
+ var echoCandidates = candidates.filter(function(candidate) { return candidate.kind === "echo"; });
10722
+ var strippedOutput = stripAnsi(text);
10723
+ var strippedLines = strippedOutput.split(newline).map(function(line) { return String(line || "").trimEnd(); });
10724
+ var visiblePrompt = extractVisiblePrompt(strippedLines);
10725
+ var latestExplicitUser = explicitUsers.length ? explicitUsers[explicitUsers.length - 1] : null;
10726
+ var echoedUserCandidates = echoCandidates
10727
+ .map(function(candidate) { return candidate.text; })
10728
+ .filter(function(value) { return value.length >= 3; });
10729
+ var latestEchoUser = null;
10730
+ for (var eu = echoedUserCandidates.length - 1; eu >= 0; eu--) {
10731
+ if (echoedUserCandidates[eu] !== visiblePrompt) {
10732
+ latestEchoUser = echoedUserCandidates[eu];
10733
+ break;
10734
+ }
10735
+ }
10736
+ if (!latestEchoUser && echoedUserCandidates.length) {
10737
+ latestEchoUser = echoedUserCandidates[echoedUserCandidates.length - 1];
10738
+ }
10739
+
10740
+ var currentUser = latestExplicitUser ? latestExplicitUser.text : latestEchoUser;
10741
+ var rawAssistantLines = assistantCandidates
10742
+ .filter(function(candidate) { return !latestExplicitUser || candidate.order > latestExplicitUser.order; })
10743
+ .map(function(candidate) { return candidate.text; });
10744
+ var visibleAssistantFallback = [];
10745
+ var bulletMatches = strippedOutput.match(/^[ \t]*[•◦·⏺][ \t]*(.+)$/gm) || [];
10746
+ for (var bm = 0; bm < bulletMatches.length; bm++) {
10747
+ var bulletContent = normalizeCodexText(bulletMatches[bm].replace(/^[ \t]*[•◦·⏺][ \t]*/, ""));
10748
+ if (!bulletContent) continue;
10749
+ if (codexActivityRe.test(bulletContent)) continue;
10750
+ if (codexFooterRe.test(bulletContent)) continue;
10751
+ if (/\b(?:OpenAI Codex|model:|directory:|Tip:|esc to interrupt)\b/i.test(bulletContent)) continue;
10752
+ visibleAssistantFallback.push(bulletContent);
10753
+ }
10754
+
10755
+ var assistantText = coalesceAssistantLines(rawAssistantLines)
10756
+ || coalesceAssistantLines(extractVisibleAssistantLines(strippedLines))
10757
+ || (visibleAssistantFallback.length ? visibleAssistantFallback[visibleAssistantFallback.length - 1] : null);
10758
+
10759
+ if (currentUser) {
10760
+ messages.push({ role: "user", content: currentUser });
10761
+ }
10762
+ if (assistantText) {
10763
+ messages.push({ role: "assistant", content: assistantText });
10764
+ }
10765
+ if (!messages.length && latestExplicitUser) {
10766
+ messages.push({ role: "user", content: latestExplicitUser.text });
10767
+ } else if (!messages.length && latestEchoUser) {
10768
+ messages.push({ role: "user", content: latestEchoUser });
10769
+ }
10770
+
10771
+ return messages;
10772
+ }
10773
+
9038
10774
  // Optimized ANSI escape sequence stripping
9039
10775
  // Handles: CSI sequences, OSC sequences, single-character escapes, control chars
9040
10776
  var nul = String.fromCharCode(0);
@@ -9366,14 +11102,16 @@
9366
11102
  '</div>';
9367
11103
  }
9368
11104
 
9369
- function renderChatMessage(msg, roundUsage) {
11105
+ function renderChatMessage(msg, roundUsage, messageIndex) {
9370
11106
  // Thinking card (deep thought) — from PTY parsing
9371
11107
  if (msg.role === "thinking") {
11108
+ var thinkingKey = buildExpandKey("thinking", [getMessageKey(msg, messageIndex), "pty"]);
11109
+ var thinkingExpanded = getExpandState(thinkingKey, "thinking");
9372
11110
  return '<div class="chat-message thinking">' +
9373
- '<div class="thinking-inline thinking-pty collapsed" data-thinking="" onclick="__thinkingToggle(this)">' +
11111
+ '<div class="thinking-inline thinking-pty ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="" onclick="__thinkingToggle(this)">' +
9374
11112
  '<span class="thinking-inline-icon">⦿</span>' +
9375
11113
  '<span class="thinking-inline-preview">' + escapeHtml(msg.content) + '</span>' +
9376
- '<span class="thinking-inline-action">展开</span>' +
11114
+ '<span class="thinking-inline-action">' + (thinkingExpanded ? '收起' : '展开') + '</span>' +
9377
11115
  '</div>' +
9378
11116
  '</div>';
9379
11117
  }
@@ -9390,7 +11128,7 @@
9390
11128
 
9391
11129
  // Structured content blocks (from JSON chat mode)
9392
11130
  if (Array.isArray(msg.content)) {
9393
- return renderStructuredMessage(msg, roundUsage);
11131
+ return renderStructuredMessage(msg, roundUsage, messageIndex);
9394
11132
  }
9395
11133
 
9396
11134
  // Legacy string content (from PTY parsing)
@@ -9480,7 +11218,7 @@
9480
11218
 
9481
11219
  var TOOL_GROUP_LABELS = { Read: "读取", Glob: "搜索", Grep: "搜索", WebFetch: "抓取", WebSearch: "搜索", TodoRead: "待办" };
9482
11220
 
9483
- function renderToolGroup(items, role, toolResults) {
11221
+ function renderToolGroup(items, role, toolResults, messageKey) {
9484
11222
  // Count by tool name
9485
11223
  var counts = {};
9486
11224
  for (var k = 0; k < items.length; k++) {
@@ -9504,28 +11242,31 @@
9504
11242
  parts.push(counts[name] + " " + (TOOL_GROUP_LABELS[name] || name));
9505
11243
  }
9506
11244
  var summaryText = parts.join(" · ");
11245
+ var groupKey = buildExpandKey("tool-group", [messageKey, items[0] && items[0].index, items.length]);
11246
+ var shouldExpand = getExpandState(groupKey, "tool-group");
9507
11247
 
9508
11248
  // Render each item's inline-tool card
9509
11249
  var innerHtml = "";
9510
11250
  for (var k = 0; k < items.length; k++) {
9511
11251
  try {
9512
- innerHtml += renderContentBlock(items[k].block, role, toolResults, items[k].index);
11252
+ innerHtml += renderContentBlock(items[k].block, role, toolResults, items[k].index, messageKey);
9513
11253
  } catch (e) {
9514
11254
  innerHtml += '<div class="render-error">工具渲染失败</div>';
9515
11255
  }
9516
11256
  }
9517
11257
 
9518
- return '<div class="tool-group" data-expanded="false" data-status="' + statusClass + '">' +
11258
+ return '<div class="tool-group" data-expand-kind="tool-group" data-expand-key="' + escapeHtml(groupKey) + '" data-expanded="' + (shouldExpand ? 'true' : 'false') + '" data-status="' + statusClass + '">' +
9519
11259
  '<div class="tool-group-summary" onclick="__toolGroupToggle(this.parentNode)">' +
9520
11260
  '<span class="tool-group-status">' + statusIcon + '</span>' +
9521
11261
  '<span class="tool-group-text">' + escapeHtml(summaryText) + '</span>' +
9522
11262
  '<span class="tool-group-count">' + items.length + ' 个调用</span>' +
9523
- '<svg class="tool-group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>' +
11263
+ '<svg class="tool-group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform:' + (shouldExpand ? 'rotate(180deg)' : '') + '"><polyline points="6 9 12 15 18 9"/></svg>' +
9524
11264
  '</div>' +
9525
- '<div class="tool-group-body">' + innerHtml + '</div>' +
11265
+ '<div class="tool-group-body" style="display:' + (shouldExpand ? 'block' : 'none') + ';">' + innerHtml + '</div>' +
9526
11266
  '</div>';
9527
11267
  }
9528
11268
 
11269
+
9529
11270
  // global toggle
9530
11271
  window.__toolGroupToggle = function(el) {
9531
11272
  if (!el) return;
@@ -9535,11 +11276,13 @@
9535
11276
  if (body) body.style.display = expanded ? "none" : "block";
9536
11277
  var chevron = el.querySelector(".tool-group-chevron");
9537
11278
  if (chevron) chevron.style.transform = expanded ? "" : "rotate(180deg)";
11279
+ persistElementExpandState(el, "tool-group");
9538
11280
  };
9539
11281
 
9540
- function renderStructuredMessage(msg, roundUsage) {
11282
+ function renderStructuredMessage(msg, roundUsage, messageIndex) {
9541
11283
  var role = msg.role;
9542
11284
  var avatar = chatAvatar(role);
11285
+ var messageKey = getMessageKey(msg, messageIndex);
9543
11286
 
9544
11287
  // Check if this is a queued user message
9545
11288
  var isQueued = role === "user" && msg.content && msg.content.some(function(b) { return b.__queued; });
@@ -9563,9 +11306,9 @@
9563
11306
  var grp = groups[g];
9564
11307
  try {
9565
11308
  if (grp.type === "group") {
9566
- blocksHtml += renderToolGroup(grp.items, role, toolResults);
11309
+ blocksHtml += renderToolGroup(grp.items, role, toolResults, messageKey);
9567
11310
  } else {
9568
- blocksHtml += renderContentBlock(grp.block, role, toolResults, grp.index);
11311
+ blocksHtml += renderContentBlock(grp.block, role, toolResults, grp.index, messageKey);
9569
11312
  }
9570
11313
  } catch (e) {
9571
11314
  blocksHtml += '<div class="render-error">消息块渲染失败</div>';
@@ -9582,13 +11325,13 @@
9582
11325
  var queuedClass = isQueued ? " queued" : "";
9583
11326
  var queuedBadge = isQueued ? '<span class="queued-badge">排队中</span>' : "";
9584
11327
 
9585
- return '<div class="chat-message ' + role + queuedClass + '">' +
11328
+ return '<div class="chat-message ' + role + queuedClass + '" data-message-key="' + escapeHtml(messageKey) + '">' +
9586
11329
  avatar +
9587
11330
  '<div class="chat-message-content">' + blocksHtml + queuedBadge + '</div>' +
9588
11331
  usageHtml +
9589
11332
  '</div>';
9590
11333
  }
9591
- function renderContentBlock(block, role, toolResults, index) {
11334
+ function renderContentBlock(block, role, toolResults, index, messageKey) {
9592
11335
  if (!block || !block.type) return "";
9593
11336
 
9594
11337
  switch (block.type) {
@@ -9610,15 +11353,17 @@
9610
11353
  '</div>' +
9611
11354
  '</div>';
9612
11355
  }
9613
- return '<div class="thinking-inline collapsed" data-thinking="' + escapeHtml(thinkingText) + '" onclick="__thinkingToggle(this)">' +
11356
+ var thinkingKey = buildExpandKey("thinking", [messageKey, index]);
11357
+ var thinkingExpanded = getExpandState(thinkingKey, "thinking");
11358
+ return '<div class="thinking-inline ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="' + escapeHtml(thinkingText) + '" onclick="__thinkingToggle(this)">' +
9614
11359
  '<span class="thinking-inline-icon">⦿</span>' +
9615
- '<span class="thinking-inline-preview">' + escapeHtml(preview) + '</span>' +
9616
- '<span class="thinking-inline-action">展开</span>' +
11360
+ '<span class="thinking-inline-preview">' + escapeHtml(thinkingExpanded ? thinkingText : preview) + '</span>' +
11361
+ '<span class="thinking-inline-action">' + (thinkingExpanded ? '收起' : '展开') + '</span>' +
9617
11362
  '</div>';
9618
11363
 
9619
11364
  case "tool_use":
9620
11365
  var toolResult = pickToolResultForDisplay(toolResults, block.id);
9621
- var rendered = renderToolUseCard(block, toolResult, index);
11366
+ var rendered = renderToolUseCard(block, toolResult, index, messageKey);
9622
11367
  if (hasRecoveredToolNoise(toolResults, block.id)) {
9623
11368
  rendered = renderRecoveredToolHint(block.name || "工具") + rendered;
9624
11369
  }
@@ -9632,8 +11377,9 @@
9632
11377
  }
9633
11378
  }
9634
11379
 
9635
- function renderInlineTool(block, toolResult, toolName, fileInfo, extraInfo) {
11380
+ function renderInlineTool(block, toolResult, toolName, fileInfo, extraInfo, messageKey, index) {
9636
11381
  var toolId = block.id || "tool-" + toolName;
11382
+ var expandKey = buildExpandKey("inline-tool", [messageKey, toolId || index, index]);
9637
11383
  var inputData = block.input || {};
9638
11384
  var resultContent = extractToolResultText(toolResult && toolResult.content);
9639
11385
 
@@ -9704,16 +11450,16 @@
9704
11450
  var fullResult = resultContent;
9705
11451
 
9706
11452
  var expandedHtml = "";
9707
- var shouldExpand = false; // All inline tools collapsed by default
11453
+ var shouldExpand = getExpandState(expandKey, "inline-tool");
9708
11454
  if (hasResult) {
9709
11455
  expandedHtml = '<div class="inline-tool-expanded" style="display: ' + (shouldExpand ? 'block' : 'none') + ';">' +
9710
11456
  '<div class="inline-tool-result">' + formatInlineResult(resultContent, toolName) + '</div>' +
9711
11457
  '</div>';
9712
11458
  } else if (isError) {
9713
- expandedHtml = '<div class="inline-tool-expanded" style="display: none;"><div class="inline-tool-result inline-tool-error">' +
11459
+ expandedHtml = '<div class="inline-tool-expanded" style="display: ' + (shouldExpand ? 'block' : 'none') + ';"><div class="inline-tool-result inline-tool-error">' +
9714
11460
  escapeHtml(resultContent || "操作失败") + '</div></div>';
9715
11461
  } else if (!toolResult) {
9716
- expandedHtml = '<div class="inline-tool-expanded" style="display: none;"><div class="inline-tool-loading">等待响应…</div></div>';
11462
+ expandedHtml = '<div class="inline-tool-expanded" style="display: ' + (shouldExpand ? 'block' : 'none') + ';"><div class="inline-tool-loading">等待响应…</div></div>';
9717
11463
  }
9718
11464
 
9719
11465
  var extraInfoHtml = meta ? '<span class="inline-tool-meta">' + escapeHtml(meta) + '</span>' : '';
@@ -9721,6 +11467,8 @@
9721
11467
  if (shouldExpand) extraClass += ' inline-tool-open';
9722
11468
 
9723
11469
  return '<div class="inline-tool ' + extraClass + '" ' +
11470
+ 'data-expand-kind="inline-tool" ' +
11471
+ 'data-expand-key="' + escapeHtml(expandKey) + '" ' +
9724
11472
  'data-result="' + escapeHtml(fullResult) + '" ' +
9725
11473
  'data-preview="' + previewDataAttr + '" ' +
9726
11474
  'data-status="' + (isError ? 'error' : (hasResult ? 'done' : 'pending')) + '" ' +
@@ -9736,10 +11484,12 @@
9736
11484
  }
9737
11485
 
9738
11486
  // Terminal-style display for Bash commands
9739
- function renderTerminalTool(block, toolResult, toolName) {
11487
+ function renderTerminalTool(block, toolResult, toolName, messageKey, index) {
9740
11488
  var inputData = block.input || {};
9741
11489
  var command = inputData.command || inputData.cmd || "";
9742
11490
  var resultContent = extractToolResultText(toolResult && toolResult.content);
11491
+ var toolId = block.id || "tool-" + toolName;
11492
+ var expandKey = buildExpandKey("terminal", [messageKey, toolId || index, index]);
9743
11493
 
9744
11494
  var isError = toolResult && toolResult.is_error;
9745
11495
  var exitCode = inputData.exitCode;
@@ -9777,22 +11527,21 @@
9777
11527
 
9778
11528
  // Show command preview in header (truncate long commands)
9779
11529
  var cmdPreview = command.length > 80 ? command.slice(0, 77) + "…" : command;
11530
+ var shouldExpand = getExpandState(expandKey, "terminal");
9780
11531
 
9781
- return '<div class="inline-terminal" data-expanded="false">' +
11532
+ return '<div class="inline-terminal" data-expand-kind="terminal" data-expand-key="' + escapeHtml(expandKey) + '" data-expanded="' + (shouldExpand ? 'true' : 'false') + '">' +
9782
11533
  '<div class="term-header" onclick="__terminalExpand(this)">' +
9783
11534
  statusDot +
9784
11535
  '<span class="term-cmd-preview"><span class="term-prompt">$</span> ' + escapeHtml(cmdPreview) + '</span>' +
9785
- '<span class="term-toggle-icon">▶</span>' +
11536
+ '<span class="term-toggle-icon">' + (shouldExpand ? '▼' : '▶') + '</span>' +
9786
11537
  '</div>' +
9787
- '<div class="term-body" style="display:none;">' +
11538
+ '<div class="term-body" style="display:' + (shouldExpand ? 'block' : 'none') + ';">' +
9788
11539
  '<div class="term-command"><span class="term-prompt">$</span> ' + cmdDisplay + '</div>' +
9789
11540
  (outputHtml ? '<div class="term-output">' + outputHtml + '</div>' : '') +
9790
11541
  exitCodeHtml +
9791
11542
  '</div>' +
9792
11543
  '</div>';
9793
11544
  }
9794
-
9795
- // GitHub-style diff display for Edit/Write/MultiEdit
9796
11545
  function extractToolResultText(content) {
9797
11546
  if (!content) return "";
9798
11547
  if (typeof content === "string") return content;
@@ -9883,7 +11632,7 @@
9883
11632
  return '<pre class="inline-tool-result-text" style="max-height: 300px; overflow-y: auto;">' + escapeHtml(content) + '</pre>';
9884
11633
  }
9885
11634
 
9886
- function renderToolUseCard(block, toolResult, index) {
11635
+ function renderToolUseCard(block, toolResult, index, messageKey) {
9887
11636
  var toolName = block.name || "unknown";
9888
11637
  var toolId = block.id || "tool-" + toolName + "-" + (typeof index === "number" ? index : 0);
9889
11638
  var fileInfo = extractFileInfo(toolName, block.input);
@@ -9891,12 +11640,12 @@
9891
11640
  // ── Lightweight inline tools: Read, Glob, Grep, WebFetch, WebSearch, TodoRead
9892
11641
  if (toolName === "Read" || toolName === "Glob" || toolName === "Grep" ||
9893
11642
  toolName === "WebFetch" || toolName === "WebSearch" || toolName === "TodoRead") {
9894
- return renderInlineTool(block, toolResult, toolName, fileInfo, "");
11643
+ return renderInlineTool(block, toolResult, toolName, fileInfo, "", messageKey, index);
9895
11644
  }
9896
11645
 
9897
11646
  // ── Terminal-style: Bash
9898
11647
  if (toolName === "Bash") {
9899
- return renderTerminalTool(block, toolResult, toolName);
11648
+ return renderTerminalTool(block, toolResult, toolName, messageKey, index);
9900
11649
  }
9901
11650
 
9902
11651
  // ── Diff-style: Edit, Write, MultiEdit
@@ -9975,9 +11724,11 @@
9975
11724
  headerIcon = getToolIcon(toolName);
9976
11725
  }
9977
11726
 
9978
- var collapsedClass = statusClass !== "loading" ? " collapsed" : "";
11727
+ var expandKey = buildExpandKey("tool-card", [messageKey, toolId]);
11728
+ var shouldExpand = getExpandState(expandKey, "tool-card", statusClass === "loading");
11729
+ var collapsedClass = shouldExpand ? "" : " collapsed";
9979
11730
  var toggleHtml = '<span class="tool-use-toggle">▼</span>';
9980
- return '<div class="tool-use-card ' + statusClass + collapsedClass + '" data-tool-use-id="' + escapeHtml(toolId) + '">' +
11731
+ return '<div class="tool-use-card ' + statusClass + collapsedClass + '" data-expand-kind="tool-card" data-expand-key="' + escapeHtml(expandKey) + '" data-tool-use-id="' + escapeHtml(toolId) + '">' +
9981
11732
  '<div class="tool-use-header" data-tool-toggle onclick="__tcToggle(event,this)">' +
9982
11733
  '<span class="tool-use-icon">' + headerIcon + '</span>' +
9983
11734
  '<span class="tool-use-name">' + escapeHtml(titleText) + '</span>' +