@firstpick/pi-package-webui 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -60,6 +60,7 @@ const elements = {
60
60
  backgroundStatus: $("#backgroundStatus"),
61
61
  networkStatus: $("#networkStatus"),
62
62
  openNetworkButton: $("#openNetworkButton"),
63
+ stopServerButton: $("#stopServerButton"),
63
64
  agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
64
65
  agentDoneNotificationsStatus: $("#agentDoneNotificationsStatus"),
65
66
  optionalFeaturesBox: $("#optionalFeaturesBox"),
@@ -127,6 +128,7 @@ let refreshMessagesTimer = null;
127
128
  let refreshStateTimer = null;
128
129
  let refreshFooterTimer = null;
129
130
  let refreshTabsTimer = null;
131
+ let foregroundReconcileTimer = null;
130
132
  let eventSource = null;
131
133
  let activeDialog = null;
132
134
  let nativeCommandTabId = null;
@@ -156,6 +158,8 @@ let codexUsageRenderTimer = null;
156
158
  let backendOffline = false;
157
159
  let backendOfflineNoticeShown = false;
158
160
  let latestMessages = [];
161
+ let promptHistoryByTab = new Map();
162
+ let promptHistoryNavigation = null;
159
163
  let transientMessages = [];
160
164
  let actionEntrySeenKeysByTab = new Map();
161
165
  let actionEntryAnimationPrimedTabs = new Set();
@@ -167,6 +171,7 @@ let blockedTabNotificationPermissionRequested = false;
167
171
  let blockedTabNotificationFallbackNoted = false;
168
172
  let agentDoneNotificationsEnabled = false;
169
173
  let thinkingOutputVisible = true;
174
+ let toolOutputGloballyExpanded = false;
170
175
  let agentDoneNotificationPermissionRequested = false;
171
176
  let agentDoneNotificationFallbackNoted = false;
172
177
  let agentDoneNotificationKeys = new Set();
@@ -192,6 +197,9 @@ let currentRunStartedAt = null;
192
197
  let currentRunStreamChars = 0;
193
198
  let latestTokPerSecond = null;
194
199
  let abortRequestInFlight = false;
200
+ let userBashByTab = new Map();
201
+ let userBashQueuesByTab = new Map();
202
+ let latestQueuedMessagesByTab = new Map();
195
203
  let abortLongPressTimer = null;
196
204
  let abortLongPressHandled = false;
197
205
  const dialogQueue = [];
@@ -201,6 +209,7 @@ const TAB_STORAGE_KEY = "pi-webui-active-tab";
201
209
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
202
210
  const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
203
211
  const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
212
+ const TOOL_OUTPUT_EXPANDED_STORAGE_KEY = "pi-webui-tool-output-expanded";
204
213
  const THEME_STORAGE_KEY = "pi-webui-theme";
205
214
  const CUSTOM_BACKGROUND_STORAGE_KEY = "pi-webui-custom-background";
206
215
  const CUSTOM_BACKGROUNDS_STORAGE_KEY = "pi-webui-custom-backgrounds";
@@ -212,6 +221,8 @@ const DEFAULT_WEBUI_PORT = "31415";
212
221
  const CUSTOM_BACKGROUND_MAX_FILE_BYTES = 24 * 1024 * 1024;
213
222
  const OPTIONAL_FEATURES_STORAGE_KEY = "pi-webui-optional-features-disabled";
214
223
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
224
+ const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history";
225
+ const PROMPT_HISTORY_LIMIT_PER_TAB = 50;
215
226
  const ATTACHMENT_MAX_FILES = 12;
216
227
  const ATTACHMENT_MAX_FILE_BYTES = 64 * 1024 * 1024;
217
228
  const ATTACHMENT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
@@ -237,10 +248,12 @@ const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
237
248
  const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
238
249
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
239
250
  const TOOL_LIVE_UPDATE_THROTTLE_MS = 80;
251
+ const UNEXPOSED_THINKING_TEXT = "No thinking content was exposed by the provider.";
240
252
  const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
241
253
  const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
242
254
  const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
243
255
  const TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS = 1200;
256
+ const FOREGROUND_RECONCILE_DELAY_MS = 120;
244
257
  const TAB_GROUP_STATUS_PRIORITY = ["blocked", "done", "idle", "working"];
245
258
  const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
246
259
  const BLOCKED_TAB_NOTIFICATION_TAG_PREFIX = "pi-webui-blocked-tab";
@@ -249,6 +262,7 @@ const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
249
262
  const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
250
263
  const statusEntries = new Map();
251
264
  const widgets = new Map();
265
+ const todoProgressWidgetExpandedByTab = new Map();
252
266
  const liveToolRuns = new Map();
253
267
  const liveToolCards = new Map();
254
268
  const liveToolRenderQueue = new Map();
@@ -360,6 +374,10 @@ function bindGitWorkflowToActiveTab() {
360
374
  return gitWorkflow;
361
375
  }
362
376
 
377
+ function gitWorkflowActionTabId() {
378
+ return activeTabId;
379
+ }
380
+
363
381
  function resetGitWorkflowForTab(tabId = activeTabId) {
364
382
  if (!tabId) return;
365
383
  gitWorkflowsByTab.set(tabId, createGitWorkflowState());
@@ -434,10 +452,12 @@ function sidePanelSectionRecords() {
434
452
 
435
453
  function readStoredSidePanelSectionCollapsedIds() {
436
454
  try {
437
- const parsed = JSON.parse(localStorage.getItem(SIDE_PANEL_SECTION_STORAGE_KEY) || "[]");
455
+ const stored = localStorage.getItem(SIDE_PANEL_SECTION_STORAGE_KEY);
456
+ if (stored === null) return null;
457
+ const parsed = JSON.parse(stored);
438
458
  return new Set(Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : []);
439
459
  } catch {
440
- return new Set();
460
+ return null;
441
461
  }
442
462
  }
443
463
 
@@ -462,17 +482,32 @@ function setSidePanelSectionCollapsed(record, collapsed, { persist = true } = {}
462
482
  if (persist) persistSidePanelSectionState();
463
483
  }
464
484
 
485
+ function setOnlySidePanelSectionExpanded(targetRecord, { persist = true } = {}) {
486
+ const targetId = targetRecord?.id || null;
487
+ for (const record of sidePanelSectionRecords()) {
488
+ setSidePanelSectionCollapsed(record, record.id !== targetId, { persist: false });
489
+ }
490
+ if (persist) persistSidePanelSectionState();
491
+ }
492
+
465
493
  function restoreSidePanelSectionState() {
494
+ const records = sidePanelSectionRecords();
466
495
  const collapsedIds = readStoredSidePanelSectionCollapsedIds();
467
- for (const record of sidePanelSectionRecords()) {
468
- setSidePanelSectionCollapsed(record, collapsedIds.has(record.id), { persist: false });
496
+ const expandedRecords = collapsedIds ? records.filter(({ id }) => !collapsedIds.has(id)) : [];
497
+ const expandedId = expandedRecords.length === 1 ? expandedRecords[0].id : null;
498
+ for (const record of records) {
499
+ setSidePanelSectionCollapsed(record, record.id !== expandedId, { persist: false });
469
500
  }
470
501
  }
471
502
 
472
503
  function bindSidePanelSectionToggles() {
473
504
  for (const record of sidePanelSectionRecords()) {
474
505
  record.button.addEventListener("click", () => {
475
- setSidePanelSectionCollapsed(record, !record.section.classList.contains("collapsed"));
506
+ if (record.section.classList.contains("collapsed")) {
507
+ setOnlySidePanelSectionExpanded(record);
508
+ } else {
509
+ setSidePanelSectionCollapsed(record, true);
510
+ }
476
511
  });
477
512
  }
478
513
  }
@@ -560,6 +595,22 @@ function persistThinkingOutputVisible(visible) {
560
595
  }
561
596
  }
562
597
 
598
+ function readStoredToolOutputExpanded() {
599
+ try {
600
+ return localStorage.getItem(TOOL_OUTPUT_EXPANDED_STORAGE_KEY) === "1";
601
+ } catch {
602
+ return false;
603
+ }
604
+ }
605
+
606
+ function persistToolOutputExpanded(expanded) {
607
+ try {
608
+ localStorage.setItem(TOOL_OUTPUT_EXPANDED_STORAGE_KEY, expanded ? "1" : "0");
609
+ } catch {
610
+ // Ignore storage failures; this can remain a page-local preference.
611
+ }
612
+ }
613
+
563
614
  function thinkingVisibilityStatusText() {
564
615
  return thinkingOutputVisible ? "Visible" : "Hidden from transcript";
565
616
  }
@@ -587,6 +638,24 @@ function setThinkingOutputVisible(visible, { announce = false } = {}) {
587
638
  if (announce) addEvent(thinkingOutputVisible ? "thinking output shown" : "thinking output hidden", thinkingOutputVisible ? "info" : "warn");
588
639
  }
589
640
 
641
+ function applyToolOutputExpansionToDom(expanded = toolOutputGloballyExpanded) {
642
+ for (const details of elements.chat.querySelectorAll(".tool-output-details, .tool-raw-details, .message.toolResult .message-collapse, .message.toolExecution details, .message.bashExecution .message-collapse")) {
643
+ details.open = !!expanded;
644
+ }
645
+ }
646
+
647
+ function setToolOutputGloballyExpanded(expanded, { announce = false, rerender = false } = {}) {
648
+ toolOutputGloballyExpanded = !!expanded;
649
+ persistToolOutputExpanded(toolOutputGloballyExpanded);
650
+ if (rerender) renderAllMessages({ preserveScroll: true });
651
+ else applyToolOutputExpansionToDom();
652
+ if (announce) addEvent(toolOutputGloballyExpanded ? "tool and bash output expanded" : "tool and bash output collapsed", "info");
653
+ }
654
+
655
+ function restoreToolOutputExpansionSetting() {
656
+ toolOutputGloballyExpanded = readStoredToolOutputExpanded();
657
+ }
658
+
590
659
  function restoreThinkingVisibilitySetting() {
591
660
  thinkingOutputVisible = readStoredThinkingOutputVisible();
592
661
  renderThinkingVisibilityToggle();
@@ -599,12 +668,34 @@ function setComposerActionsOpen(open) {
599
668
  if (!shouldOpen) setPublishMenuOpen(false);
600
669
  }
601
670
 
671
+ function isUserBashActive(tabId = activeTabId) {
672
+ return !!tabId && userBashByTab.has(tabId);
673
+ }
674
+
675
+ function userBashQueueForTab(tabId) {
676
+ if (!tabId) return [];
677
+ let queue = userBashQueuesByTab.get(tabId);
678
+ if (!queue) {
679
+ queue = [];
680
+ userBashQueuesByTab.set(tabId, queue);
681
+ }
682
+ return queue;
683
+ }
684
+
685
+ function queuedUserBashCount(tabId = activeTabId) {
686
+ return tabId ? userBashQueueForTab(tabId).length : 0;
687
+ }
688
+
689
+ function isUserBashRunningOrQueued(tabId = activeTabId) {
690
+ return isUserBashActive(tabId) || queuedUserBashCount(tabId) > 0;
691
+ }
692
+
602
693
  function isRunActive() {
603
- return !!currentState?.isStreaming || (runIndicatorLocallyActive && !currentState?.isCompacting);
694
+ return !!currentState?.isStreaming || isUserBashRunningOrQueued() || (runIndicatorLocallyActive && !currentState?.isCompacting);
604
695
  }
605
696
 
606
697
  function isAbortAvailable() {
607
- return runIndicatorIsActive();
698
+ return runIndicatorIsActive() || isUserBashActive();
608
699
  }
609
700
 
610
701
  function resizePromptInput() {
@@ -841,6 +932,20 @@ async function copyText(text) {
841
932
  if (!copied) throw new Error("Clipboard copy failed");
842
933
  }
843
934
 
935
+ function triggerNativeDownload(download) {
936
+ const url = String(download?.url || "").trim();
937
+ if (!url) return false;
938
+ const anchor = document.createElement("a");
939
+ anchor.href = new URL(url, window.location.href).href;
940
+ anchor.download = String(download.fileName || "");
941
+ anchor.rel = "noopener";
942
+ anchor.hidden = true;
943
+ document.body.append(anchor);
944
+ anchor.click();
945
+ anchor.remove();
946
+ return true;
947
+ }
948
+
844
949
  async function copyServerStartCommand() {
845
950
  const command = serverStartCommandText();
846
951
  try {
@@ -2164,6 +2269,7 @@ function saveActiveDraft() {
2164
2269
  }
2165
2270
 
2166
2271
  function restoreActiveDraft() {
2272
+ resetPromptHistoryNavigation();
2167
2273
  elements.promptInput.value = activeTabId ? tabDrafts.get(activeTabId) || "" : "";
2168
2274
  resizePromptInput();
2169
2275
  renderCommandSuggestions();
@@ -2243,8 +2349,12 @@ function resetActiveTabUi() {
2243
2349
  resetChatOutput();
2244
2350
  elements.stateDetails.replaceChildren();
2245
2351
  elements.eventLog.replaceChildren();
2246
- elements.queueBox.textContent = "No queued messages.";
2247
- elements.queueBox.classList.add("muted");
2352
+ const queuedSnapshot = activeTabId ? latestQueuedMessagesByTab.get(activeTabId) : null;
2353
+ if (queuedSnapshot) renderQueue({ tabId: activeTabId, ...queuedSnapshot });
2354
+ else {
2355
+ elements.queueBox.textContent = "No queued messages.";
2356
+ elements.queueBox.classList.add("muted");
2357
+ }
2248
2358
  elements.commandsBox.textContent = "Loading…";
2249
2359
  elements.commandsBox.classList.add("muted");
2250
2360
  elements.sessionLine.textContent = activeTab() ? "Connecting…" : "No terminal tabs.";
@@ -3960,12 +4070,19 @@ function renderTodoProgressWidget(_key, lines) {
3960
4070
  const todo = parseTodoProgressWidget(lines);
3961
4071
  if (!todo) return null;
3962
4072
 
3963
- const node = make("section", "widget todo-widget");
4073
+ const tabId = activeTabId || "default";
4074
+ const node = make("details", "widget todo-widget");
4075
+ node.open = todoProgressWidgetExpandedByTab.get(tabId) === true;
3964
4076
  node.setAttribute("aria-label", "Todo progress");
4077
+ node.addEventListener("toggle", () => {
4078
+ todoProgressWidgetExpandedByTab.set(tabId, node.open);
4079
+ });
3965
4080
 
3966
4081
  const percent = todo.total > 0 ? Math.max(0, Math.min(100, (todo.done / todo.total) * 100)) : 0;
4082
+ const summary = make("summary", "todo-widget-summary");
3967
4083
  const header = make("div", "todo-widget-header");
3968
4084
  header.append(
4085
+ make("span", "todo-widget-toggle", "›"),
3969
4086
  make("span", "todo-widget-title", "Todo progress"),
3970
4087
  make("span", "todo-widget-count", `${todo.done}/${todo.total}`),
3971
4088
  make("span", "todo-widget-meta", todo.partial ? `${todo.partial} partial` : "active"),
@@ -3975,7 +4092,9 @@ function renderTodoProgressWidget(_key, lines) {
3975
4092
  const fill = make("span", "todo-widget-progress-fill");
3976
4093
  fill.style.width = `${percent}%`;
3977
4094
  progress.append(fill);
4095
+ summary.append(header, progress);
3978
4096
 
4097
+ const body = make("div", "todo-widget-body");
3979
4098
  const list = make("ol", "todo-widget-list");
3980
4099
  for (const item of todo.items) {
3981
4100
  const row = make("li", `todo-widget-item ${item.status}`);
@@ -3985,9 +4104,11 @@ function renderTodoProgressWidget(_key, lines) {
3985
4104
  );
3986
4105
  list.append(row);
3987
4106
  }
4107
+ if (todo.items.length) body.append(list);
4108
+ if (todo.footer) body.append(make("div", "todo-widget-footer", todo.footer));
3988
4109
 
3989
- node.append(header, progress, list);
3990
- if (todo.footer) node.append(make("div", "todo-widget-footer", todo.footer));
4110
+ node.append(summary);
4111
+ if (body.children.length) node.append(body);
3991
4112
  return node;
3992
4113
  }
3993
4114
 
@@ -4049,6 +4170,17 @@ function releaseNpmActionButton(label, command, className = "") {
4049
4170
  return button;
4050
4171
  }
4051
4172
 
4173
+ function releaseNpmStreamHeader(label, lineCount, { live = false } = {}) {
4174
+ const header = make("div", "release-npm-stream-header");
4175
+ const safeLineCount = Math.max(0, Number(lineCount) || 0);
4176
+ header.append(
4177
+ make("span", `release-npm-stream-dot${live ? " live" : ""}`),
4178
+ make("span", "release-npm-stream-title", label),
4179
+ make("span", "release-npm-stream-count", `${safeLineCount} line${safeLineCount === 1 ? "" : "s"}`),
4180
+ );
4181
+ return header;
4182
+ }
4183
+
4052
4184
  function renderReleaseNpmOutputWidget() {
4053
4185
  if (!isOptionalFeatureEnabled("releaseNpm")) return null;
4054
4186
  const outputLines = getWidgetLines("release-npm:output");
@@ -4074,15 +4206,17 @@ function renderReleaseNpmOutputWidget() {
4074
4206
  );
4075
4207
  header.append(titleWrap, meta, actions);
4076
4208
 
4209
+ const streamLines = outputLines.length ? outputLines : ["Waiting for release output..."];
4210
+ const streamHeader = releaseNpmStreamHeader("Live output stream", outputLines.length, { live: true });
4077
4211
  const terminal = make("div", "release-npm-terminal");
4078
4212
  terminal.setAttribute("role", "log");
4079
4213
  terminal.setAttribute("aria-live", "polite");
4080
- for (const line of (outputLines.length ? outputLines : ["Waiting for release output..."])) {
4214
+ for (const line of streamLines) {
4081
4215
  appendReleaseNpmTerminalLine(terminal, line);
4082
4216
  }
4083
4217
 
4084
4218
  const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-toggle expands/collapses · /release-abort stops subprocess");
4085
- node.append(header, terminal, controls);
4219
+ node.append(header, streamHeader, terminal, controls);
4086
4220
  requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4087
4221
  return node;
4088
4222
  }
@@ -4106,11 +4240,13 @@ function renderReleaseNpmLogWidget() {
4106
4240
  actions.append(releaseNpmActionButton("Close log", "/release-npm-logs close"));
4107
4241
  header.append(titleWrap, meta, actions);
4108
4242
 
4243
+ const logLines = lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim());
4244
+ const streamHeader = releaseNpmStreamHeader("Saved output stream", logLines.length);
4109
4245
  const terminal = make("div", "release-npm-terminal");
4110
- for (const line of lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim())) {
4246
+ for (const line of logLines) {
4111
4247
  appendReleaseNpmTerminalLine(terminal, line);
4112
4248
  }
4113
- node.append(header, terminal);
4249
+ node.append(header, streamHeader, terminal);
4114
4250
  requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4115
4251
  return node;
4116
4252
  }
@@ -4140,15 +4276,17 @@ function renderReleaseAurOutputWidget() {
4140
4276
  );
4141
4277
  header.append(titleWrap, meta, actions);
4142
4278
 
4279
+ const streamLines = outputLines.length ? outputLines : ["Waiting for release-aur output..."];
4280
+ const streamHeader = releaseNpmStreamHeader("Live AUR output stream", outputLines.length, { live: true });
4143
4281
  const terminal = make("div", "release-npm-terminal");
4144
4282
  terminal.setAttribute("role", "log");
4145
4283
  terminal.setAttribute("aria-live", "polite");
4146
- for (const line of (outputLines.length ? outputLines : ["Waiting for release-aur output..."])) {
4284
+ for (const line of streamLines) {
4147
4285
  appendReleaseNpmTerminalLine(terminal, line);
4148
4286
  }
4149
4287
 
4150
4288
  const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-aur toggle expands/collapses · /release-aur abort stops subprocess");
4151
- node.append(header, terminal, controls);
4289
+ node.append(header, streamHeader, terminal, controls);
4152
4290
  requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4153
4291
  return node;
4154
4292
  }
@@ -4172,11 +4310,13 @@ function renderReleaseAurLogWidget() {
4172
4310
  actions.append(releaseNpmActionButton("Close log", "/release-aur logs close"));
4173
4311
  header.append(titleWrap, meta, actions);
4174
4312
 
4313
+ const logLines = lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim());
4314
+ const streamHeader = releaseNpmStreamHeader("Saved AUR output stream", logLines.length);
4175
4315
  const terminal = make("div", "release-npm-terminal");
4176
- for (const line of lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim())) {
4316
+ for (const line of logLines) {
4177
4317
  appendReleaseNpmTerminalLine(terminal, line);
4178
4318
  }
4179
- node.append(header, terminal);
4319
+ node.append(header, streamHeader, terminal);
4180
4320
  requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4181
4321
  return node;
4182
4322
  }
@@ -4312,24 +4452,24 @@ function renderGitWorkflow() {
4312
4452
  elements.gitWorkflowCancelButton.disabled = false;
4313
4453
 
4314
4454
  if (gitWorkflow.step === "add") {
4315
- addGitWorkflowAction("Run git add .", runGitAdd, "primary", false);
4455
+ addGitWorkflowAction("Run git add .", () => runGitAdd(), "primary", false);
4316
4456
  } else if (gitWorkflow.step === "generate") {
4317
- addGitWorkflowAction("Run /git-staged-msg", runGitMessagePrompt, "primary", false);
4457
+ addGitWorkflowAction("Run /git-staged-msg", () => runGitMessagePrompt(), "primary", false);
4318
4458
  addGitWorkflowAction("Preview current message files", () => loadGitWorkflowMessage({ requireFresh: false }), "", false);
4319
4459
  } else if (gitWorkflow.step === "generating") {
4320
4460
  addGitWorkflowAction("Refresh message preview", () => loadGitWorkflowMessage({ requireFresh: true }), "", false);
4321
4461
  } else if (gitWorkflow.step === "message") {
4322
4462
  addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), "primary", false);
4323
4463
  addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), "primary", false);
4324
- addGitWorkflowAction("Regenerate", runGitMessagePrompt, "", false);
4464
+ addGitWorkflowAction("Regenerate", () => runGitMessagePrompt(), "", false);
4325
4465
  } else if (gitWorkflow.step === "push") {
4326
- addGitWorkflowAction("Run git push", pushGitWorkflow, "primary", false);
4466
+ addGitWorkflowAction("Run git push", () => pushGitWorkflow(), "primary", false);
4327
4467
  } else if (gitWorkflow.step === "done") {
4328
4468
  addGitWorkflowAction("Close", () => setGitWorkflow({ active: false }), "primary", false);
4329
- addGitWorkflowAction("Start another", startGitWorkflow, "", false);
4469
+ addGitWorkflowAction("Start another", () => startGitWorkflow(), "", false);
4330
4470
  } else if (["cancelled", "error"].includes(gitWorkflow.step)) {
4331
4471
  addGitWorkflowAction("Close", () => setGitWorkflow({ active: false }), "primary", false);
4332
- addGitWorkflowAction("Restart", startGitWorkflow, "", false);
4472
+ addGitWorkflowAction("Restart", () => startGitWorkflow(), "", false);
4333
4473
  }
4334
4474
  }
4335
4475
 
@@ -4357,8 +4497,7 @@ function failGitWorkflow(error, step, { tabId = activeTabId } = {}) {
4357
4497
  }, { tabId });
4358
4498
  }
4359
4499
 
4360
- function startGitWorkflow() {
4361
- const tabId = activeTabId;
4500
+ function startGitWorkflow(tabId = activeTabId) {
4362
4501
  if (!tabId) return;
4363
4502
  if (!isOptionalFeatureEnabled("gitWorkflow")) {
4364
4503
  const tabContext = activeTabContext(tabId);
@@ -4382,8 +4521,7 @@ function startGitWorkflow() {
4382
4521
  }, { tabId });
4383
4522
  }
4384
4523
 
4385
- async function cancelGitWorkflow() {
4386
- const tabId = activeTabId;
4524
+ async function cancelGitWorkflow(tabId = gitWorkflowActionTabId()) {
4387
4525
  const tabContext = activeTabContext(tabId);
4388
4526
  const workflow = gitWorkflowForTab(tabId, { create: false });
4389
4527
  if (!workflow?.active) return;
@@ -4398,8 +4536,7 @@ async function cancelGitWorkflow() {
4398
4536
  if (shouldAbortPi && isCurrentTabContext(tabContext)) scheduleAbortStateChecks();
4399
4537
  }
4400
4538
 
4401
- async function runGitAdd() {
4402
- const tabId = activeTabId;
4539
+ async function runGitAdd(tabId = gitWorkflowActionTabId()) {
4403
4540
  const tabContext = activeTabContext(tabId);
4404
4541
  const workflow = gitWorkflowForTab(tabId, { create: false });
4405
4542
  if (!workflow) return;
@@ -4415,10 +4552,11 @@ async function runGitAdd() {
4415
4552
  }
4416
4553
  }
4417
4554
 
4418
- async function runGitMessagePrompt() {
4419
- const tabId = activeTabId;
4555
+ async function runGitMessagePrompt(tabId = gitWorkflowActionTabId()) {
4420
4556
  const tabContext = activeTabContext(tabId);
4421
- if (currentState?.isStreaming) {
4557
+ const targetTab = tabs.find((tab) => tab.id === tabId);
4558
+ const targetBusy = tabId === activeTabId ? !!currentState?.isStreaming : activityForTab(targetTab).isWorking;
4559
+ if (targetBusy) {
4422
4560
  failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a staged commit message."), "generate", { tabId });
4423
4561
  return;
4424
4562
  }
@@ -4441,7 +4579,8 @@ async function runGitMessagePrompt() {
4441
4579
  if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
4442
4580
  setTimeout(() => {
4443
4581
  const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
4444
- if (isCurrentTabContext(tabContext) && isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "generating" && !currentState?.isStreaming) {
4582
+ const targetStillBusy = tabId === activeTabId && currentState?.isStreaming;
4583
+ if (isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "generating" && !targetStillBusy) {
4445
4584
  loadGitWorkflowMessage({ requireFresh: true, retries: 1, runId, tabId });
4446
4585
  }
4447
4586
  }, 2500);
@@ -4483,8 +4622,7 @@ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId
4483
4622
  }
4484
4623
  }
4485
4624
 
4486
- async function commitGitWorkflow(variant) {
4487
- const tabId = activeTabId;
4625
+ async function commitGitWorkflow(variant, tabId = gitWorkflowActionTabId()) {
4488
4626
  const tabContext = activeTabContext(tabId);
4489
4627
  const workflow = gitWorkflowForTab(tabId, { create: false });
4490
4628
  if (!workflow) return;
@@ -4500,8 +4638,7 @@ async function commitGitWorkflow(variant) {
4500
4638
  }
4501
4639
  }
4502
4640
 
4503
- async function pushGitWorkflow() {
4504
- const tabId = activeTabId;
4641
+ async function pushGitWorkflow(tabId = gitWorkflowActionTabId()) {
4505
4642
  const tabContext = activeTabContext(tabId);
4506
4643
  const workflow = gitWorkflowForTab(tabId, { create: false });
4507
4644
  if (!workflow) return;
@@ -4521,19 +4658,31 @@ function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
4521
4658
  if (!isCurrentTabContext(tabContext)) return;
4522
4659
  bindGitWorkflowToActiveTab();
4523
4660
  renderGitWorkflow();
4524
- if (gitWorkflow.active && gitWorkflow.step === "generating" && !currentState?.isStreaming) {
4661
+ const workflowTabId = gitWorkflowActionTabId();
4662
+ if (workflowTabId === tabContext.tabId && gitWorkflow.active && gitWorkflow.step === "generating" && !currentState?.isStreaming) {
4525
4663
  const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.messageRequestedAt || 0)));
4526
4664
  if (retryDelayMs > 0) {
4527
4665
  setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
4528
4666
  return;
4529
4667
  }
4530
- loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: tabContext.tabId });
4668
+ loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
4531
4669
  }
4532
4670
  }
4533
4671
 
4672
+ function normalizeQueuedMessages(event) {
4673
+ const normalize = (items) => (Array.isArray(items) ? items.map((item) => String(item || "")).filter((item) => item.trim()) : []);
4674
+ return {
4675
+ steering: normalize(event?.steering),
4676
+ followUp: normalize(event?.followUp),
4677
+ };
4678
+ }
4679
+
4534
4680
  function renderQueue(event) {
4535
- const steering = event?.steering || [];
4536
- const followUp = event?.followUp || [];
4681
+ const snapshot = normalizeQueuedMessages(event);
4682
+ const tabId = event?.tabId || activeTabId;
4683
+ if (tabId) latestQueuedMessagesByTab.set(tabId, snapshot);
4684
+ const steering = snapshot.steering;
4685
+ const followUp = snapshot.followUp;
4537
4686
  if (steering.length === 0 && followUp.length === 0) {
4538
4687
  elements.queueBox.textContent = "No queued messages.";
4539
4688
  elements.queueBox.classList.add("muted");
@@ -4543,9 +4692,32 @@ function renderQueue(event) {
4543
4692
  const lines = [];
4544
4693
  if (steering.length) lines.push(`Steering (${steering.length}):`, ...steering.map((item) => `• ${item}`));
4545
4694
  if (followUp.length) lines.push(`Follow-up (${followUp.length}):`, ...followUp.map((item) => `• ${item}`));
4695
+ lines.push("↳ Alt+Up restores the latest observed queue snapshot to the composer (RPC queue clearing is pending upstream support).");
4546
4696
  elements.queueBox.textContent = lines.join("\n");
4547
4697
  }
4548
4698
 
4699
+ function queuedMessagesForComposer(tabId = activeTabId) {
4700
+ const snapshot = latestQueuedMessagesByTab.get(tabId) || { steering: [], followUp: [] };
4701
+ return [...(snapshot.steering || []), ...(snapshot.followUp || [])].map((item) => String(item || "").trim()).filter(Boolean);
4702
+ }
4703
+
4704
+ function restoreQueuedMessagesToComposerFromShortcut() {
4705
+ const queued = queuedMessagesForComposer();
4706
+ if (queued.length === 0) {
4707
+ addEvent("no queued messages to restore", "warn");
4708
+ return false;
4709
+ }
4710
+ const queuedText = queued.join("\n\n");
4711
+ const currentText = elements.promptInput.value || "";
4712
+ elements.promptInput.value = [queuedText, currentText].filter((item) => item.trim()).join("\n\n");
4713
+ resizePromptInput();
4714
+ renderCommandSuggestions();
4715
+ saveActiveDraft();
4716
+ focusPromptInput({ defer: true });
4717
+ addEvent(`restored ${queued.length} queued message${queued.length === 1 ? "" : "s"} to composer; Pi's RPC queue is still pending upstream clear support`, "warn");
4718
+ return true;
4719
+ }
4720
+
4549
4721
  function appendText(parent, text, className = "text-block") {
4550
4722
  const block = make("pre", className);
4551
4723
  block.textContent = text || "";
@@ -5107,11 +5279,12 @@ function renderContent(parent, content, { markdown = false } = {}) {
5107
5279
  if (markdown) appendMarkdown(parent, stripTodoProgressLines(text));
5108
5280
  else appendText(parent, text);
5109
5281
  } else if (part.type === "thinking") {
5110
- if (!thinkingOutputVisible) continue;
5282
+ const thinking = visibleThinkingText(assistantThinkingText(part));
5283
+ if (!thinkingOutputVisible || !thinking) continue;
5111
5284
  const details = make("details", "thinking-block");
5112
5285
  details.open = true;
5113
5286
  details.append(make("summary", undefined, "thinking"));
5114
- appendText(details, part.thinking || "No thinking content was exposed by the provider.", "thinking-text");
5287
+ appendText(details, thinking, "thinking-text");
5115
5288
  parent.append(details);
5116
5289
  } else if (part.type === "toolCall") {
5117
5290
  const details = make("details");
@@ -5147,6 +5320,13 @@ function assistantThinkingText(part) {
5147
5320
  return typeof part.content === "string" ? part.content : "";
5148
5321
  }
5149
5322
 
5323
+ function visibleThinkingText(text) {
5324
+ const value = String(text || "");
5325
+ const trimmed = value.trim();
5326
+ if (!trimmed || trimmed === UNEXPOSED_THINKING_TEXT) return "";
5327
+ return value;
5328
+ }
5329
+
5150
5330
  function isAssistantToolCallPart(part) {
5151
5331
  return !!(part && typeof part === "object" && (part.type === "toolCall" || part.toolCall));
5152
5332
  }
@@ -5208,8 +5388,8 @@ function assistantDisplayMessages(message) {
5208
5388
  const part = content[index];
5209
5389
  const isThinkingPart = part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string");
5210
5390
  if (isThinkingPart) {
5211
- const thinking = assistantThinkingText(part) || "No thinking content was exposed by the provider.";
5212
- displayMessages.push({ ...base, role: "thinking", title: "thinking", content: thinking, thinking });
5391
+ const thinking = visibleThinkingText(assistantThinkingText(part));
5392
+ if (thinking) displayMessages.push({ ...base, role: "thinking", title: "thinking", content: thinking, thinking });
5213
5393
  continue;
5214
5394
  }
5215
5395
  if (isAssistantToolCallPart(part)) {
@@ -5251,6 +5431,136 @@ function stickyUserPromptPreview(message) {
5251
5431
  return stickyUserPromptPreviewText(messageUserPromptText(message));
5252
5432
  }
5253
5433
 
5434
+ function promptHistoryText(value) {
5435
+ return stripAnsi(String(value ?? "")).replace(/\r\n?/g, "\n").trim();
5436
+ }
5437
+
5438
+ function promptHistoryMessageText(message) {
5439
+ if (message?.role !== "user") return "";
5440
+ const text = promptHistoryText(textFromContent(message.content));
5441
+ return text.startsWith("/") ? "" : text;
5442
+ }
5443
+
5444
+ function promptHistoryForTab(tabId = activeTabId) {
5445
+ if (!tabId) return [];
5446
+ return promptHistoryByTab.get(tabId) || [];
5447
+ }
5448
+
5449
+ function promptHistoryWithEntry(history, text) {
5450
+ const prompt = promptHistoryText(text);
5451
+ if (!prompt) return history || [];
5452
+ return [...(history || []).filter((entry) => entry !== prompt), prompt].slice(-PROMPT_HISTORY_LIMIT_PER_TAB);
5453
+ }
5454
+
5455
+ function promptHistoryEqual(left = [], right = []) {
5456
+ return left.length === right.length && left.every((entry, index) => entry === right[index]);
5457
+ }
5458
+
5459
+ function setPromptHistoryForTab(tabId, history, { persist = true } = {}) {
5460
+ if (!tabId) return;
5461
+ const entries = (history || []).map(promptHistoryText).filter(Boolean).slice(-PROMPT_HISTORY_LIMIT_PER_TAB);
5462
+ if (entries.length) promptHistoryByTab.set(tabId, entries);
5463
+ else promptHistoryByTab.delete(tabId);
5464
+ if (persist) persistPromptHistoryCache();
5465
+ }
5466
+
5467
+ function loadPromptHistoryCache() {
5468
+ try {
5469
+ const raw = JSON.parse(localStorage.getItem(PROMPT_HISTORY_STORAGE_KEY) || "{}");
5470
+ promptHistoryByTab = new Map(Object.entries(raw)
5471
+ .map(([tabId, entries]) => [tabId, Array.isArray(entries) ? entries.map(promptHistoryText).filter(Boolean).slice(-PROMPT_HISTORY_LIMIT_PER_TAB) : []])
5472
+ .filter(([, entries]) => entries.length));
5473
+ } catch {
5474
+ promptHistoryByTab = new Map();
5475
+ }
5476
+ }
5477
+
5478
+ function persistPromptHistoryCache() {
5479
+ try {
5480
+ const entries = [...promptHistoryByTab.entries()]
5481
+ .filter(([tabId, history]) => tabId && Array.isArray(history) && history.length)
5482
+ .slice(-24)
5483
+ .map(([tabId, history]) => [tabId, history.slice(-PROMPT_HISTORY_LIMIT_PER_TAB)]);
5484
+ localStorage.setItem(PROMPT_HISTORY_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
5485
+ } catch {
5486
+ // Ignore storage failures; in-memory prompt history still works for this page load.
5487
+ }
5488
+ }
5489
+
5490
+ function rememberPromptHistory(text, { tabId = activeTabId } = {}) {
5491
+ if (!tabId) return;
5492
+ setPromptHistoryForTab(tabId, promptHistoryWithEntry(promptHistoryForTab(tabId), text));
5493
+ }
5494
+
5495
+ function syncPromptHistoryFromMessages(messages = latestMessages) {
5496
+ if (!activeTabId) return;
5497
+ const prompts = (messages || []).map(promptHistoryMessageText).filter(Boolean);
5498
+ if (!prompts.length) return;
5499
+ const currentHistory = promptHistoryForTab(activeTabId);
5500
+ let nextHistory = currentHistory;
5501
+ for (const prompt of prompts) nextHistory = promptHistoryWithEntry(nextHistory, prompt);
5502
+ if (!promptHistoryEqual(currentHistory, nextHistory)) setPromptHistoryForTab(activeTabId, nextHistory);
5503
+ }
5504
+
5505
+ function resetPromptHistoryNavigation() {
5506
+ promptHistoryNavigation = null;
5507
+ }
5508
+
5509
+ function activePromptHistoryNavigation(history = promptHistoryForTab()) {
5510
+ if (!promptHistoryNavigation || promptHistoryNavigation.tabId !== activeTabId) return null;
5511
+ const index = promptHistoryNavigation.index;
5512
+ if (!Number.isInteger(index) || index < 0 || index >= history.length || elements.promptInput.value !== history[index]) {
5513
+ resetPromptHistoryNavigation();
5514
+ return null;
5515
+ }
5516
+ return promptHistoryNavigation;
5517
+ }
5518
+
5519
+ function applyPromptHistoryValue(value) {
5520
+ const input = elements.promptInput;
5521
+ input.value = value || "";
5522
+ resizePromptInput();
5523
+ try {
5524
+ input.setSelectionRange(input.value.length, input.value.length);
5525
+ } catch {
5526
+ // Some input implementations can reject selection updates; history recall still worked.
5527
+ }
5528
+ hideCommandSuggestions();
5529
+ }
5530
+
5531
+ function recallPreviousPromptFromHistory() {
5532
+ if (!activeTabId) return false;
5533
+ const history = promptHistoryForTab(activeTabId);
5534
+ if (!history.length) return false;
5535
+ const navigation = activePromptHistoryNavigation(history);
5536
+ if (!navigation && elements.promptInput.value.trim()) return false;
5537
+ const index = navigation ? Math.max(0, navigation.index - 1) : history.length - 1;
5538
+ promptHistoryNavigation = {
5539
+ tabId: activeTabId,
5540
+ index,
5541
+ draft: navigation ? navigation.draft : elements.promptInput.value || "",
5542
+ };
5543
+ applyPromptHistoryValue(history[index]);
5544
+ return true;
5545
+ }
5546
+
5547
+ function recallNextPromptFromHistory() {
5548
+ if (!activeTabId) return false;
5549
+ const history = promptHistoryForTab(activeTabId);
5550
+ const navigation = activePromptHistoryNavigation(history);
5551
+ if (!navigation) return false;
5552
+ if (navigation.index >= history.length - 1) {
5553
+ const draft = navigation.draft || "";
5554
+ resetPromptHistoryNavigation();
5555
+ applyPromptHistoryValue(draft);
5556
+ return true;
5557
+ }
5558
+ const index = navigation.index + 1;
5559
+ promptHistoryNavigation = { ...navigation, index };
5560
+ applyPromptHistoryValue(history[index]);
5561
+ return true;
5562
+ }
5563
+
5254
5564
  function loadLastUserPromptCache() {
5255
5565
  try {
5256
5566
  const raw = JSON.parse(localStorage.getItem(LAST_USER_PROMPT_STORAGE_KEY) || "{}");
@@ -5326,8 +5636,18 @@ function stickyUserPromptViewportGap() {
5326
5636
 
5327
5637
  function resetChatOutput() {
5328
5638
  liveToolCards.clear();
5329
- elements.chat.replaceChildren();
5330
- if (elements.stickyUserPromptButton) elements.chat.append(elements.stickyUserPromptButton);
5639
+ const preservedNodes = [];
5640
+ if (elements.stickyUserPromptButton) preservedNodes.push(elements.stickyUserPromptButton);
5641
+ if (runIndicatorBubble?.parentElement === elements.chat) preservedNodes.push(runIndicatorBubble);
5642
+ elements.chat.replaceChildren(...preservedNodes);
5643
+ }
5644
+
5645
+ function appendChatMessageBubble(bubble) {
5646
+ if (runIndicatorBubble?.parentElement === elements.chat && bubble !== runIndicatorBubble) {
5647
+ elements.chat.insertBefore(bubble, runIndicatorBubble);
5648
+ } else {
5649
+ elements.chat.append(bubble);
5650
+ }
5331
5651
  }
5332
5652
 
5333
5653
  function userPromptTargets() {
@@ -5539,7 +5859,7 @@ function appendToolOutput(parent, text, { label = "output", previewLines = 10, p
5539
5859
  const lines = clean.split(/\r?\n/);
5540
5860
  if (lines.length > previewLines) {
5541
5861
  const details = make("details", "tool-output-details");
5542
- details.open = open;
5862
+ details.open = open || toolOutputGloballyExpanded;
5543
5863
  details.append(make("summary", "tool-output-summary", `${label} (${lines.length} lines; expand)`));
5544
5864
  appendText(details, clean, "code-block tool-output-code");
5545
5865
  parent.append(details);
@@ -5649,6 +5969,37 @@ function appendToolRawDetails(parent, tool) {
5649
5969
  parent.append(details);
5650
5970
  }
5651
5971
 
5972
+ function toolRenderSignatureReplacer() {
5973
+ const seen = new WeakSet();
5974
+ return (key, value) => {
5975
+ if (typeof value === "bigint") return `${value}n`;
5976
+ if (typeof value === "string" && value.length > 8000) return `${value.slice(0, 4000)}…${value.slice(-4000)} (${value.length} chars)`;
5977
+ if (value && typeof value === "object") {
5978
+ if (seen.has(value)) return "[Circular]";
5979
+ seen.add(value);
5980
+ }
5981
+ return toolRawDetailsReplacer(key, value);
5982
+ };
5983
+ }
5984
+
5985
+ function toolExecutionRenderSignature(message) {
5986
+ const tool = normalizeToolExecution(message);
5987
+ try {
5988
+ return JSON.stringify({
5989
+ name: tool.name,
5990
+ args: tool.args,
5991
+ result: tool.result,
5992
+ details: tool.details,
5993
+ isPartial: tool.isPartial,
5994
+ isError: tool.isError,
5995
+ startedAt: tool.startedAt,
5996
+ endedAt: tool.endedAt,
5997
+ }, toolRenderSignatureReplacer());
5998
+ } catch {
5999
+ return `${message?.toolName || message?.name || "tool"}|${message?.toolCallId || ""}|${message?.isPartial ? "partial" : "final"}|${message?.isError ? "error" : "ok"}`;
6000
+ }
6001
+ }
6002
+
5652
6003
  function renderBashToolExecution(parent, tool) {
5653
6004
  const command = toolArgText(tool.args, "command", "");
5654
6005
  const timeout = toolArgValue(tool.args, "timeout");
@@ -5750,9 +6101,15 @@ function liveToolRunMessage(run) {
5750
6101
 
5751
6102
  function applyToolExecutionBubbleState(bubble, message) {
5752
6103
  const status = toolExecutionStatus(message);
5753
- bubble.classList.remove("tool-pending", "tool-running", "tool-success", "tool-error", "error");
5754
- bubble.classList.add(`tool-${status}`);
5755
- if (message.isError || status === "error") bubble.classList.add("error");
6104
+ const nextClass = `tool-${status}`;
6105
+ if (bubble.dataset.toolStatus !== status || !bubble.classList.contains(nextClass)) {
6106
+ for (const className of ["tool-pending", "tool-running", "tool-success", "tool-error"]) {
6107
+ if (className !== nextClass) bubble.classList.remove(className);
6108
+ }
6109
+ bubble.classList.add(nextClass);
6110
+ bubble.dataset.toolStatus = status;
6111
+ }
6112
+ bubble.classList.toggle("error", !!(message.isError || status === "error"));
5756
6113
  if (message.toolCallId) {
5757
6114
  const id = String(message.toolCallId);
5758
6115
  bubble.dataset.toolCallId = id;
@@ -5815,7 +6172,7 @@ function reuseToolExecutionBubble(reusableToolCards, message, { streaming = fals
5815
6172
  bubble.removeAttribute("data-user-prompt");
5816
6173
  }
5817
6174
  if (!streaming && !transient) renderActionFeedbackControls(bubble, message, messageIndex);
5818
- elements.chat.append(bubble);
6175
+ appendChatMessageBubble(bubble);
5819
6176
  return { bubble, body };
5820
6177
  }
5821
6178
 
@@ -5829,10 +6186,13 @@ function updateLiveToolCard(bubble, message) {
5829
6186
  if (role) role.textContent = messageTitle(message);
5830
6187
  const timestamp = header?.querySelector(".muted");
5831
6188
  if (timestamp) timestamp.textContent = formatDate(message.timestamp);
6189
+ const nextRenderSignature = toolExecutionRenderSignature(message);
6190
+ if (bubble._toolRenderSignature === nextRenderSignature && body.childElementCount > 0) return true;
5832
6191
  const detailsOpenState = captureToolDetailsOpenState(body);
5833
6192
  body.replaceChildren();
5834
6193
  renderToolExecution(body, message);
5835
6194
  restoreToolDetailsOpenState(body, detailsOpenState);
6195
+ bubble._toolRenderSignature = nextRenderSignature;
5836
6196
  return true;
5837
6197
  }
5838
6198
 
@@ -5979,9 +6339,10 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
5979
6339
  if (message.isError) bubble.classList.add("error");
5980
6340
  } else if (message.role === "toolExecution") {
5981
6341
  renderToolExecution(body, message);
6342
+ bubble._toolRenderSignature = toolExecutionRenderSignature(message);
5982
6343
  } else if (message.role === "thinking") {
5983
- const thinkingText = message.thinking || textFromContent(message.content);
5984
- if (thinkingOutputVisible && (thinkingText || !streaming)) appendText(body, thinkingText || "No thinking content was exposed by the provider.", "thinking-text");
6344
+ const thinkingText = visibleThinkingText(message.thinking || textFromContent(message.content));
6345
+ if (thinkingOutputVisible && thinkingText) appendText(body, thinkingText, "thinking-text");
5985
6346
  } else if (message.role === "toolCall") {
5986
6347
  appendText(body, JSON.stringify(message.arguments ?? message.content ?? {}, null, 2), "code-block");
5987
6348
  } else if (message.role === "assistantEvent") {
@@ -5992,7 +6353,7 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
5992
6353
 
5993
6354
  if (isCollapsibleOutput) {
5994
6355
  const details = make("details", "message-collapse");
5995
- if (message.isError) details.open = true;
6356
+ if (message.isError || toolOutputGloballyExpanded) details.open = true;
5996
6357
  details.append(header, body);
5997
6358
  bubble.append(details);
5998
6359
  if (message.role === "toolResult" && !message.isError) {
@@ -6006,7 +6367,7 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
6006
6367
  bubble.append(header, body);
6007
6368
  }
6008
6369
  if (!streaming && !transient) renderActionFeedbackControls(bubble, message, messageIndex);
6009
- elements.chat.append(bubble);
6370
+ appendChatMessageBubble(bubble);
6010
6371
  return { bubble, body };
6011
6372
  }
6012
6373
 
@@ -6049,11 +6410,11 @@ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1
6049
6410
  }
6050
6411
 
6051
6412
  function stateHasRunIndicatorActivity(state = currentState) {
6052
- return !!state?.isStreaming || !!state?.isCompacting;
6413
+ return !!state?.isStreaming || !!state?.isCompacting || isUserBashActive();
6053
6414
  }
6054
6415
 
6055
6416
  function runIndicatorIsActive() {
6056
- return runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState);
6417
+ return runIndicatorLocallyActive || stateHasRunIndicatorActivity(currentState) || isUserBashActive();
6057
6418
  }
6058
6419
 
6059
6420
  function clearRunIndicatorGraceCheck() {
@@ -6126,22 +6487,24 @@ function stopRunIndicatorTicker() {
6126
6487
  runIndicatorTimer = null;
6127
6488
  }
6128
6489
 
6490
+ function createRunIndicatorBubble() {
6491
+ runIndicatorBubble = make("article", "message runIndicator run-indicator-message streaming");
6492
+ runIndicatorBubble.setAttribute("aria-live", "polite");
6493
+ runIndicatorBubble.setAttribute("aria-label", "Agent is running:");
6494
+
6495
+ const body = make("div", "message-body");
6496
+ const row = make("div", "run-indicator-row");
6497
+ const pulse = make("span", "run-indicator-pulse");
6498
+ pulse.setAttribute("aria-hidden", "true");
6499
+ runIndicatorText = make("span", "run-indicator-text");
6500
+ runIndicatorMeta = make("span", "run-indicator-meta");
6501
+ row.append(pulse, runIndicatorText, runIndicatorMeta);
6502
+ body.append(row);
6503
+ runIndicatorBubble.append(body);
6504
+ }
6505
+
6129
6506
  function ensureRunIndicatorBubble() {
6130
- if (runIndicatorBubble?.parentElement !== elements.chat) {
6131
- runIndicatorBubble = make("article", "message runIndicator run-indicator-message streaming");
6132
- runIndicatorBubble.setAttribute("aria-live", "polite");
6133
- runIndicatorBubble.setAttribute("aria-label", "Agent is running:");
6134
-
6135
- const body = make("div", "message-body");
6136
- const row = make("div", "run-indicator-row");
6137
- const pulse = make("span", "run-indicator-pulse");
6138
- pulse.setAttribute("aria-hidden", "true");
6139
- runIndicatorText = make("span", "run-indicator-text");
6140
- runIndicatorMeta = make("span", "run-indicator-meta");
6141
- row.append(pulse, runIndicatorText, runIndicatorMeta);
6142
- body.append(row);
6143
- runIndicatorBubble.append(body);
6144
- }
6507
+ if (!runIndicatorBubble || !runIndicatorText || !runIndicatorMeta) createRunIndicatorBubble();
6145
6508
  if (elements.chat.lastElementChild !== runIndicatorBubble) elements.chat.append(runIndicatorBubble);
6146
6509
  }
6147
6510
 
@@ -6149,9 +6512,11 @@ function updateRunIndicatorBubble() {
6149
6512
  if (!runIndicatorIsActive()) return;
6150
6513
  if (!runIndicatorStartedAt) runIndicatorStartedAt = performance.now();
6151
6514
  ensureRunIndicatorBubble();
6152
- runIndicatorText.textContent = runIndicatorHeadline();
6515
+ const headline = runIndicatorHeadline();
6516
+ if (runIndicatorText.textContent !== headline) runIndicatorText.textContent = headline;
6153
6517
  const detail = runIndicatorDetail();
6154
- runIndicatorMeta.textContent = runIndicatorShowsElapsed() ? `${detail} · run time ${formatRunIndicatorElapsed()}` : detail;
6518
+ const meta = runIndicatorShowsElapsed() ? `${detail} · run time ${formatRunIndicatorElapsed()}` : detail;
6519
+ if (runIndicatorMeta.textContent !== meta) runIndicatorMeta.textContent = meta;
6155
6520
  }
6156
6521
 
6157
6522
  function removeRunIndicatorBubble() {
@@ -6324,6 +6689,7 @@ function renderAllMessages({ preserveScroll = false } = {}) {
6324
6689
  });
6325
6690
  }
6326
6691
  rememberActionEntries(transcriptItems);
6692
+ applyToolOutputExpansionToDom();
6327
6693
  renderRunIndicator({ scroll: false });
6328
6694
  updateStickyUserPromptButton();
6329
6695
  if (shouldFollow) scrollChatToBottom({ force: true });
@@ -6335,12 +6701,13 @@ function renderAllMessages({ preserveScroll = false } = {}) {
6335
6701
  updateStickyUserPromptButton();
6336
6702
  }
6337
6703
 
6338
- function addTransientMessage({ role = "notice", title, content, level = "info" }) {
6704
+ function addTransientMessage({ role = "notice", title, content, level = "info", ...details }) {
6339
6705
  transientMessages.push({
6340
6706
  role,
6341
6707
  title,
6342
6708
  level,
6343
6709
  content,
6710
+ ...details,
6344
6711
  timestamp: Date.now(),
6345
6712
  });
6346
6713
  if (transientMessages.length > 80) transientMessages.splice(0, transientMessages.length - 80);
@@ -7154,6 +7521,7 @@ function renderMessages(messages) {
7154
7521
  latestMessages = messages || [];
7155
7522
  cleanupLiveToolRunsForMessages(latestMessages);
7156
7523
  syncLastUserPromptFromMessages(latestMessages);
7524
+ syncPromptHistoryFromMessages(latestMessages);
7157
7525
  renderAllMessages();
7158
7526
  renderFooter();
7159
7527
  renderFeedbackTray();
@@ -7237,9 +7605,9 @@ function ensureStreamingThinkingBubble() {
7237
7605
  return true;
7238
7606
  }
7239
7607
 
7240
- function showStreamingThinking(placeholder = "Thinking…") {
7608
+ function showStreamingThinking(initialText = "") {
7241
7609
  if (!ensureStreamingThinkingBubble()) return;
7242
- if (!streamThinking.textContent) streamThinking.textContent = placeholder;
7610
+ if (initialText && !streamThinking.textContent) streamThinking.textContent = initialText;
7243
7611
  }
7244
7612
 
7245
7613
  function resetStreamBubble() {
@@ -7255,7 +7623,7 @@ function resetStreamBubble() {
7255
7623
  }
7256
7624
 
7257
7625
  function thinkingDeltaText(update) {
7258
- return update.delta || update.thinking || update.content || "";
7626
+ return visibleThinkingText(update.delta || update.thinking || update.content || "");
7259
7627
  }
7260
7628
 
7261
7629
  function assistantStreamingMessage(event) {
@@ -7282,37 +7650,38 @@ function assistantThinkingTextFromMessage(message) {
7282
7650
  if (!Array.isArray(content)) return null;
7283
7651
  const parts = content
7284
7652
  .filter((part) => part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string"))
7285
- .map((part) => assistantThinkingText(part))
7653
+ .map((part) => visibleThinkingText(assistantThinkingText(part)))
7286
7654
  .filter((text) => text.trim());
7287
7655
  return parts.length ? parts.join("\n\n") : "";
7288
7656
  }
7289
7657
 
7290
7658
  function setStreamingThinkingText(text) {
7291
- if (!thinkingOutputVisible) return;
7659
+ const thinking = visibleThinkingText(text);
7660
+ if (!thinkingOutputVisible || !thinking) return false;
7292
7661
  showStreamingThinking("");
7293
- if (streamThinking) streamThinking.textContent = text;
7662
+ if (streamThinking) streamThinking.textContent = thinking;
7663
+ return true;
7294
7664
  }
7295
7665
 
7296
7666
  function syncStreamingThinkingFromMessage(event, { placeholder = "" } = {}) {
7297
7667
  if (!thinkingOutputVisible) return true;
7298
7668
  const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event));
7299
7669
  if (text === null) return false;
7300
- if (text || placeholder || streamThinkingBubble) setStreamingThinkingText(text || placeholder);
7301
- return true;
7670
+ return setStreamingThinkingText(text || placeholder);
7302
7671
  }
7303
7672
 
7304
7673
  function handleMessageUpdate(event) {
7305
7674
  const update = event.assistantMessageEvent || {};
7306
7675
  if (update.type === "thinking_start") {
7307
7676
  setRunIndicatorActivity("Thinking…", { scroll: false });
7308
- syncStreamingThinkingFromMessage(event, { placeholder: "Thinking…" });
7677
+ syncStreamingThinkingFromMessage(event);
7309
7678
  scrollChatToBottom();
7310
7679
  } else if (update.type === "thinking_delta") {
7311
7680
  const delta = thinkingDeltaText(update);
7312
7681
  currentRunStreamChars += delta.length;
7313
7682
  setRunIndicatorActivity("Thinking…", { scroll: false });
7314
7683
  const synced = syncStreamingThinkingFromMessage(event);
7315
- if (thinkingOutputVisible && (!synced || (!streamThinking?.textContent && delta))) {
7684
+ if (thinkingOutputVisible && delta && (!synced || !streamThinking?.textContent)) {
7316
7685
  showStreamingThinking("");
7317
7686
  if (streamThinking?.textContent === "Thinking…") streamThinking.textContent = "";
7318
7687
  if (streamThinking) streamThinking.textContent += delta;
@@ -7932,6 +8301,35 @@ async function refreshAll(tabContext = activeTabContext()) {
7932
8301
  resumeGitWorkflowForActiveTab(tabContext);
7933
8302
  }
7934
8303
 
8304
+ function ensureActiveEventStream(tabContext = activeTabContext()) {
8305
+ if (!tabContext.tabId || !isCurrentTabContext(tabContext)) return;
8306
+ if (!eventSource || eventSource.readyState === EventSource.CLOSED) connectEvents(tabContext);
8307
+ }
8308
+
8309
+ async function reconcileForegroundState(reason = "resume") {
8310
+ if (document.visibilityState === "hidden") return;
8311
+
8312
+ const tabResult = await Promise.allSettled([refreshTabs()]);
8313
+ const tabContext = activeTabContext();
8314
+ ensureActiveEventStream(tabContext);
8315
+
8316
+ const results = [...tabResult];
8317
+ if (tabContext.tabId) results.push(...(await Promise.allSettled([refreshAll(tabContext)])));
8318
+ if (!isCurrentTabContext(tabContext)) return;
8319
+
8320
+ for (const result of results) {
8321
+ if (result.status === "rejected") addEvent(`foreground refresh failed after ${reason}: ${result.reason?.message || String(result.reason)}`, "error");
8322
+ }
8323
+ }
8324
+
8325
+ function scheduleForegroundReconcile(reason = "resume", delay = FOREGROUND_RECONCILE_DELAY_MS) {
8326
+ clearTimeout(foregroundReconcileTimer);
8327
+ foregroundReconcileTimer = setTimeout(() => {
8328
+ foregroundReconcileTimer = null;
8329
+ reconcileForegroundState(reason).catch((error) => addEvent(`foreground refresh failed after ${reason}: ${error.message || String(error)}`, "error"));
8330
+ }, delay);
8331
+ }
8332
+
7935
8333
  async function openToNetwork() {
7936
8334
  if (latestNetwork?.open) {
7937
8335
  await closeNetworkAccess();
@@ -8006,6 +8404,216 @@ async function closeNetworkAccess() {
8006
8404
  }
8007
8405
  }
8008
8406
 
8407
+ async function stopServer() {
8408
+ if (!confirm("Stop the Pi Web UI server?\n\nThis disconnects all browser clients and stops the Pi tabs managed by this Web UI.")) return;
8409
+
8410
+ const button = elements.stopServerButton;
8411
+ button.disabled = true;
8412
+ button.textContent = "Stopping…";
8413
+ try {
8414
+ await api("/api/shutdown", { method: "POST", scoped: false });
8415
+ addEvent("Pi Web UI server stop requested", "warn");
8416
+ setBackendOffline(true, new Error("stop requested from side panel"));
8417
+ } catch (error) {
8418
+ if (error?.backendOffline) {
8419
+ addEvent("Pi Web UI server appears to be offline after stop request", "warn");
8420
+ setBackendOffline(true, error);
8421
+ return;
8422
+ }
8423
+ addEvent(error.message || String(error), "error");
8424
+ button.disabled = false;
8425
+ button.textContent = "Stop Server";
8426
+ }
8427
+ }
8428
+
8429
+ function appShortcutModelLabel(model) {
8430
+ return model ? `${model.provider}/${model.id}` : "unknown model";
8431
+ }
8432
+
8433
+ async function cycleModelFromShortcut(direction = "forward") {
8434
+ const tabContext = activeTabContext();
8435
+ if (!tabContext.tabId) return;
8436
+ try {
8437
+ const response = await api("/api/model-cycle", { method: "POST", body: { direction }, tabId: tabContext.tabId });
8438
+ applyResponseTab(response);
8439
+ const model = response.data?.model;
8440
+ const scope = response.data?.scoped ? `scoped (${response.data.scopeSource})` : "all models";
8441
+ if (isCurrentTabContext(tabContext)) {
8442
+ addTransientMessage({ role: "native", title: "model cycle", content: `Model set to ${appShortcutModelLabel(model)} via ${direction} cycle over ${scope}.`, level: "info" });
8443
+ await Promise.allSettled([refreshState(tabContext), refreshModels(tabContext), refreshStats(tabContext)]);
8444
+ }
8445
+ } catch (error) {
8446
+ if (isCurrentTabContext(tabContext)) {
8447
+ addEvent(error.message, "error");
8448
+ addTransientMessage({ role: "error", title: "model cycle", content: error.message, level: "error" });
8449
+ }
8450
+ }
8451
+ }
8452
+
8453
+ async function cycleThinkingFromShortcut() {
8454
+ const tabContext = activeTabContext();
8455
+ if (!tabContext.tabId) return;
8456
+ try {
8457
+ const response = await api("/api/thinking-cycle", { method: "POST", body: {}, tabId: tabContext.tabId });
8458
+ if (response.data?.level && currentState) currentState = { ...currentState, thinkingLevel: response.data.level };
8459
+ if (isCurrentTabContext(tabContext)) {
8460
+ addTransientMessage({ role: "native", title: "thinking", content: response.data?.level ? `Thinking level: ${response.data.level}` : "Thinking level did not change.", level: "info" });
8461
+ await Promise.allSettled([refreshState(tabContext), refreshStats(tabContext)]);
8462
+ }
8463
+ } catch (error) {
8464
+ if (isCurrentTabContext(tabContext)) {
8465
+ addEvent(error.message, "error");
8466
+ addTransientMessage({ role: "error", title: "thinking", content: error.message, level: "error" });
8467
+ }
8468
+ }
8469
+ }
8470
+
8471
+ function clearPromptFromShortcut() {
8472
+ const input = elements.promptInput;
8473
+ if (document.activeElement !== input) return false;
8474
+ if (input.selectionStart !== input.selectionEnd) return false;
8475
+ if (!input.value) return false;
8476
+ input.value = "";
8477
+ resizePromptInput();
8478
+ renderCommandSuggestions();
8479
+ addEvent("prompt cleared", "info");
8480
+ return true;
8481
+ }
8482
+
8483
+ function parseUserBashInput(message) {
8484
+ const text = String(message || "").trim();
8485
+ if (!text.startsWith("!") || text === "!" || text === "!!") return null;
8486
+ const excludeFromContext = text.startsWith("!!");
8487
+ const command = text.slice(excludeFromContext ? 2 : 1).trim();
8488
+ if (!command) return null;
8489
+ return { command, excludeFromContext };
8490
+ }
8491
+
8492
+ function userBashOutputSummary(result = {}, excludeFromContext = false) {
8493
+ const output = String(result.output || "").trimEnd();
8494
+ const status = result.cancelled ? "cancelled" : result.exitCode === 0 ? "exit 0" : result.exitCode === undefined || result.exitCode === null ? "finished" : `exit ${result.exitCode}`;
8495
+ const context = excludeFromContext ? "excluded from LLM context" : "included in the next LLM context";
8496
+ const lines = [`# ${status}; ${context}`];
8497
+ if (output) lines.push("", output);
8498
+ if (result.truncated && result.fullOutputPath) lines.push("", `Full output: ${result.fullOutputPath}`);
8499
+ return lines.join("\n");
8500
+ }
8501
+
8502
+ function clearComposerAfterUserBash({ usesPromptInput, targetTabId, tabContext }) {
8503
+ if (!usesPromptInput) return;
8504
+ clearAttachments(targetTabId);
8505
+ if (isCurrentTabContext(tabContext)) {
8506
+ elements.promptInput.value = "";
8507
+ resizePromptInput();
8508
+ } else {
8509
+ tabDrafts.set(targetTabId, "");
8510
+ }
8511
+ }
8512
+
8513
+ function enqueueUserBashCommand(parsed, { usesPromptInput = false, targetTabId = activeTabId } = {}) {
8514
+ if (!targetTabId || !parsed?.command) return;
8515
+ const tabContext = activeTabContext(targetTabId);
8516
+ clearComposerAfterUserBash({ usesPromptInput, targetTabId, tabContext });
8517
+ const queue = userBashQueueForTab(targetTabId);
8518
+ queue.push({ command: parsed.command, excludeFromContext: parsed.excludeFromContext === true, enqueuedAt: Date.now() });
8519
+ const waiting = queue.length;
8520
+ if (isCurrentTabContext(tabContext)) {
8521
+ addTransientMessage({
8522
+ role: "bashExecution",
8523
+ title: parsed.excludeFromContext ? "bash (!! queued)" : "bash (! queued)",
8524
+ command: parsed.command,
8525
+ output: `Queued behind the active bash command. Position: ${waiting}.\n\nOutput will be ${parsed.excludeFromContext ? "excluded from" : "included in the next"} LLM context when it runs.`,
8526
+ excludeFromContext: parsed.excludeFromContext === true,
8527
+ level: "info",
8528
+ });
8529
+ addEvent(`bash queued (${waiting} waiting): ${parsed.command}`, "info");
8530
+ setRunIndicatorActivity(`Bash queued (${waiting} waiting)…`);
8531
+ updateComposerModeButtons();
8532
+ }
8533
+ }
8534
+
8535
+ function dequeueNextUserBashCommand(targetTabId) {
8536
+ return userBashQueueForTab(targetTabId).shift() || null;
8537
+ }
8538
+
8539
+ async function runUserBashCommand(parsed, { usesPromptInput = false, targetTabId = activeTabId, queued = false } = {}) {
8540
+ if (!targetTabId || !parsed?.command) return;
8541
+ const tabContext = activeTabContext(targetTabId);
8542
+ const { command, excludeFromContext } = parsed;
8543
+ autoFollowChat = true;
8544
+ setComposerActionsOpen(false);
8545
+ hideCommandSuggestions();
8546
+ userBashByTab.set(targetTabId, { command, excludeFromContext, startedAt: Date.now() });
8547
+ markTabWorkingLocally(targetTabId);
8548
+ if (isCurrentTabContext(tabContext)) {
8549
+ const waiting = queuedUserBashCount(targetTabId);
8550
+ setRunIndicatorActivity(`Running bash: ${command}${waiting ? ` (${waiting} queued)` : ""}`);
8551
+ addTransientMessage({
8552
+ role: "bashExecution",
8553
+ title: excludeFromContext ? "bash (!!)" : "bash (!)" ,
8554
+ command,
8555
+ output: `${queued ? "Dequeued and running.\n\n" : ""}${excludeFromContext ? "Output will be excluded from LLM context." : "Output will be included in the next LLM context."}\n\nRunning…`,
8556
+ excludeFromContext,
8557
+ level: "info",
8558
+ });
8559
+ }
8560
+ clearComposerAfterUserBash({ usesPromptInput, targetTabId, tabContext });
8561
+
8562
+ try {
8563
+ const response = await api("/api/bash", { method: "POST", body: { command, excludeFromContext }, tabId: targetTabId });
8564
+ const result = response.data || {};
8565
+ applyResponseTab(response);
8566
+ if (isCurrentTabContext(tabContext)) {
8567
+ addTransientMessage({
8568
+ role: "bashExecution",
8569
+ title: excludeFromContext ? "bash (!! complete)" : "bash (! complete)",
8570
+ command,
8571
+ output: userBashOutputSummary(result, excludeFromContext),
8572
+ exitCode: result.exitCode,
8573
+ cancelled: result.cancelled === true,
8574
+ truncated: result.truncated === true,
8575
+ fullOutputPath: result.fullOutputPath,
8576
+ excludeFromContext,
8577
+ level: result.cancelled ? "warn" : result.exitCode ? "error" : "info",
8578
+ });
8579
+ addEvent(`bash ${result.cancelled ? "cancelled" : "finished"}: ${command}`, result.cancelled || result.exitCode ? "warn" : "info");
8580
+ scheduleRefreshMessages(250, tabContext);
8581
+ scheduleRefreshState(250, tabContext);
8582
+ } else {
8583
+ scheduleRefreshTabs(300);
8584
+ }
8585
+ } catch (error) {
8586
+ if (isCurrentTabContext(tabContext)) {
8587
+ addEvent(error.message, "error");
8588
+ addTransientMessage({ role: "error", title: excludeFromContext ? "!! bash failed" : "! bash failed", content: error.message, level: "error" });
8589
+ }
8590
+ } finally {
8591
+ userBashByTab.delete(targetTabId);
8592
+ const nextQueued = dequeueNextUserBashCommand(targetTabId);
8593
+ if (isCurrentTabContext(tabContext)) {
8594
+ if (nextQueued) {
8595
+ setRunIndicatorActivity(`Starting queued bash (${queuedUserBashCount(targetTabId)} waiting)…`);
8596
+ } else if (!currentState?.isStreaming && !currentState?.isCompacting) {
8597
+ markTabIdleLocally(targetTabId);
8598
+ clearRunIndicatorActivity();
8599
+ } else {
8600
+ syncRunIndicatorFromState(currentState);
8601
+ }
8602
+ updateComposerModeButtons();
8603
+ }
8604
+ if (nextQueued) void runUserBashCommand(nextQueued, { usesPromptInput: false, targetTabId, queued: true });
8605
+ }
8606
+ }
8607
+
8608
+ async function sendUserBashCommand(parsed, { usesPromptInput = false, targetTabId = activeTabId } = {}) {
8609
+ if (!targetTabId || !parsed?.command) return;
8610
+ if (isUserBashActive(targetTabId) || queuedUserBashCount(targetTabId) > 0) {
8611
+ enqueueUserBashCommand(parsed, { usesPromptInput, targetTabId });
8612
+ return;
8613
+ }
8614
+ await runUserBashCommand(parsed, { usesPromptInput, targetTabId });
8615
+ }
8616
+
8009
8617
  async function sendPrompt(kind = "prompt", explicitMessage) {
8010
8618
  const usesPromptInput = explicitMessage === undefined;
8011
8619
  const rawMessage = usesPromptInput ? elements.promptInput.value : explicitMessage;
@@ -8016,6 +8624,11 @@ async function sendPrompt(kind = "prompt", explicitMessage) {
8016
8624
  const attachments = usesPromptInput ? [...attachmentsForTab(targetTabId)] : [];
8017
8625
  if (!originalMessage && attachments.length === 0) return;
8018
8626
  if (kind === "prompt" && attachments.length === 0 && await handleNativeSlashSelectorCommand(originalMessage, { usesPromptInput })) return;
8627
+ const userBash = kind === "prompt" && attachments.length === 0 ? parseUserBashInput(originalMessage) : null;
8628
+ if (userBash) {
8629
+ await sendUserBashCommand(userBash, { usesPromptInput, targetTabId });
8630
+ return;
8631
+ }
8019
8632
 
8020
8633
  const targetWasStreaming = !!currentState?.isStreaming;
8021
8634
  const busyBehavior = elements.busyBehavior.value || "followUp";
@@ -8034,7 +8647,10 @@ async function sendPrompt(kind = "prompt", explicitMessage) {
8034
8647
  message = composeMessageWithAttachments(originalMessage, prepared.uploadedFiles, prepared.inlineImageIds);
8035
8648
  const bodyBase = { message };
8036
8649
  if (prepared.images.length) bodyBase.images = prepared.images;
8037
- if (kind === "prompt" && !message.startsWith("/")) rememberLastUserPrompt(message, { tabId: targetTabId });
8650
+ if (!message.startsWith("/")) {
8651
+ rememberPromptHistory(message, { tabId: targetTabId });
8652
+ if (kind === "prompt") rememberLastUserPrompt(message, { tabId: targetTabId });
8653
+ }
8038
8654
  if (startsRun && isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending prompt to Pi…");
8039
8655
 
8040
8656
  let response;
@@ -8060,12 +8676,15 @@ async function sendPrompt(kind = "prompt", explicitMessage) {
8060
8676
  }
8061
8677
  if (targetStillActive && response?.command === "native_slash_command" && response.data?.copyText) {
8062
8678
  try {
8063
- await navigator.clipboard.writeText(response.data.copyText);
8679
+ await copyText(response.data.copyText);
8064
8680
  } catch (error) {
8065
8681
  response.data.message = `${response.data.message || "Copy requested, but clipboard access failed."}\n\nClipboard access failed: ${error.message}\n\n${response.data.copyText}`;
8066
8682
  response.data.level = "warn";
8067
8683
  }
8068
8684
  }
8685
+ if (targetStillActive && response?.command === "native_slash_command" && response.data?.download) {
8686
+ if (triggerNativeDownload(response.data.download)) addEvent(`download started: ${response.data.download.fileName || response.data.download.url}`, "info");
8687
+ }
8069
8688
  if (targetStillActive && response?.command === "native_slash_command" && response.data?.message) {
8070
8689
  addTransientMessage({ role: "native", title: message.split(/\s+/, 1)[0], content: response.data.message, level: response.data.level || "info" });
8071
8690
  }
@@ -8271,7 +8890,7 @@ function handleEvent(event) {
8271
8890
  switch (event.type) {
8272
8891
  case "webui_connected":
8273
8892
  addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
8274
- refreshTabs().catch((error) => addEvent(error.message, "error"));
8893
+ scheduleForegroundReconcile("event stream reconnect", 0);
8275
8894
  break;
8276
8895
  case "webui_tab_renamed":
8277
8896
  applyTabMetadata(event.tab || { id: event.tabId, title: event.tabTitle, activity: event.tabActivity });
@@ -8459,7 +9078,7 @@ function handleEvent(event) {
8459
9078
  syncActiveTabActivityFromState(currentState);
8460
9079
  syncRunIndicatorFromState(currentState);
8461
9080
  renderStatus();
8462
- } else if (["set_model", "set_thinking_level", "new_session", "compact"].includes(event.command)) {
9081
+ } else if (["set_model", "cycle_model", "set_thinking_level", "cycle_thinking_level", "new_session", "compact"].includes(event.command)) {
8463
9082
  if (event.command === "new_session") {
8464
9083
  const tabId = event.tabId || activeTabId;
8465
9084
  forgetLastUserPrompt(tabId);
@@ -8531,7 +9150,7 @@ publishMenuContainer?.addEventListener("focusout", () => {
8531
9150
  });
8532
9151
  elements.releaseNpmButton.addEventListener("click", () => runPublishWorkflow("/release-npm"));
8533
9152
  elements.releaseAurButton.addEventListener("click", () => runPublishWorkflow("/release-aur"));
8534
- elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
9153
+ elements.gitWorkflowCancelButton.addEventListener("click", () => cancelGitWorkflow());
8535
9154
  elements.nativeCommandDialog.addEventListener("close", () => {
8536
9155
  elements.nativeCommandSearch.oninput = null;
8537
9156
  nativeCommandTabId = null;
@@ -8550,8 +9169,17 @@ async function abortActiveRun({ source = "button" } = {}) {
8550
9169
  abortRequestInFlight = true;
8551
9170
  resetAbortLongPressAffordance();
8552
9171
  updateComposerModeButtons();
9172
+ const hadActiveBash = isUserBashActive(tabContext.tabId);
8553
9173
  const hadActiveRun = runIndicatorIsActive();
8554
9174
  try {
9175
+ if (hadActiveBash) {
9176
+ const command = userBashByTab.get(tabContext.tabId)?.command || "bash";
9177
+ setRunIndicatorActivity(`Abort requested${source === "escape" ? " from Esc" : source === "long-press" ? " from long-press" : ""}; stopping bash…`);
9178
+ await api("/api/abort-bash", { method: "POST", body: {}, tabId: tabContext.tabId });
9179
+ if (!isCurrentTabContext(tabContext)) return;
9180
+ addTransientMessage({ role: "native", title: "bash aborted", content: `⛔ Abort requested for bash command:\n${command}`, level: "warn" });
9181
+ return;
9182
+ }
8555
9183
  if (hadActiveRun) setRunIndicatorActivity(`Abort requested${source === "escape" ? " from Esc" : source === "long-press" ? " from long-press" : ""}; checking whether Pi stopped…`);
8556
9184
  await api("/api/abort", { method: "POST", body: {}, tabId: tabContext.tabId });
8557
9185
  if (!isCurrentTabContext(tabContext)) return;
@@ -8671,6 +9299,7 @@ if (elements.backgroundClearButton) {
8671
9299
  elements.backgroundClearButton.addEventListener("click", () => clearCustomBackground().catch((error) => addEvent(error.message || String(error), "error")));
8672
9300
  }
8673
9301
  elements.openNetworkButton.addEventListener("click", openToNetwork);
9302
+ elements.stopServerButton.addEventListener("click", stopServer);
8674
9303
  elements.agentDoneNotificationsToggle.addEventListener("change", () => {
8675
9304
  setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
8676
9305
  requestPermission: elements.agentDoneNotificationsToggle.checked,
@@ -8729,6 +9358,72 @@ document.addEventListener("pointermove", (event) => {
8729
9358
  }
8730
9359
  rememberPointerPosition(event);
8731
9360
  }, { passive: true });
9361
+
9362
+ function isTextEntryTarget(target) {
9363
+ if (!target) return false;
9364
+ const tag = String(target.tagName || "").toLowerCase();
9365
+ return target.isContentEditable || tag === "textarea" || tag === "input" || tag === "select";
9366
+ }
9367
+
9368
+ function shouldHandleNativeAppShortcut(event) {
9369
+ if (event.defaultPrevented) return false;
9370
+ if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.nativeCommandDialog?.open) return false;
9371
+ return event.target === elements.promptInput || !isTextEntryTarget(event.target);
9372
+ }
9373
+
9374
+ function handleNativeAppShortcut(event) {
9375
+ if (!shouldHandleNativeAppShortcut(event)) return;
9376
+ const key = event.key;
9377
+ const lowerKey = String(key || "").toLowerCase();
9378
+ const ctrlOrMeta = event.ctrlKey || event.metaKey;
9379
+
9380
+ if (ctrlOrMeta && !event.altKey && lowerKey === "l") {
9381
+ event.preventDefault();
9382
+ openNativeModelSelector();
9383
+ return;
9384
+ }
9385
+ if (ctrlOrMeta && !event.altKey && lowerKey === "p") {
9386
+ event.preventDefault();
9387
+ cycleModelFromShortcut(event.shiftKey ? "backward" : "forward");
9388
+ return;
9389
+ }
9390
+ if (ctrlOrMeta && !event.altKey && !event.shiftKey && lowerKey === "t") {
9391
+ event.preventDefault();
9392
+ setThinkingOutputVisible(!thinkingOutputVisible, { announce: true });
9393
+ return;
9394
+ }
9395
+ if (ctrlOrMeta && !event.altKey && !event.shiftKey && lowerKey === "o") {
9396
+ event.preventDefault();
9397
+ setToolOutputGloballyExpanded(!toolOutputGloballyExpanded, { announce: true });
9398
+ return;
9399
+ }
9400
+ if (ctrlOrMeta && !event.altKey && !event.shiftKey && lowerKey === "c") {
9401
+ if (clearPromptFromShortcut()) event.preventDefault();
9402
+ return;
9403
+ }
9404
+ if (!event.ctrlKey && !event.metaKey && !event.altKey && event.shiftKey && key === "Tab") {
9405
+ event.preventDefault();
9406
+ cycleThinkingFromShortcut();
9407
+ return;
9408
+ }
9409
+ if (!event.ctrlKey && !event.metaKey && event.altKey && key === "Enter") {
9410
+ event.preventDefault();
9411
+ if (hasComposerPayload()) sendPrompt("follow-up");
9412
+ return;
9413
+ }
9414
+ if (!event.ctrlKey && !event.metaKey && event.altKey && key === "ArrowUp") {
9415
+ event.preventDefault();
9416
+ restoreQueuedMessagesToComposerFromShortcut();
9417
+ }
9418
+ }
9419
+
9420
+ window.addEventListener("keydown", handleNativeAppShortcut, { capture: true });
9421
+ document.addEventListener("visibilitychange", () => {
9422
+ if (document.visibilityState === "visible") scheduleForegroundReconcile("visibility resume", 0);
9423
+ });
9424
+ window.addEventListener("pageshow", () => scheduleForegroundReconcile("page show", 0));
9425
+ window.addEventListener("focus", () => scheduleForegroundReconcile("window focus"));
9426
+ window.addEventListener("online", () => scheduleForegroundReconcile("network online", 0));
8732
9427
  window.addEventListener("keydown", (event) => {
8733
9428
  if (event.key !== "Escape") return;
8734
9429
  if (elements.dialog?.open || elements.pathPickerDialog?.open) return;
@@ -8789,6 +9484,7 @@ elements.composer.addEventListener("dragleave", handleComposerDragLeave);
8789
9484
  elements.composer.addEventListener("drop", handleComposerDrop);
8790
9485
 
8791
9486
  elements.promptInput.addEventListener("keydown", (event) => {
9487
+ if (event.defaultPrevented) return;
8792
9488
  if (shouldSendPromptFromEnter(event)) {
8793
9489
  event.preventDefault();
8794
9490
  hideCommandSuggestions();
@@ -8817,9 +9513,18 @@ elements.promptInput.addEventListener("keydown", (event) => {
8817
9513
  hideCommandSuggestions();
8818
9514
  }
8819
9515
  }
9516
+
9517
+ if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key === "ArrowUp" && recallPreviousPromptFromHistory()) {
9518
+ event.preventDefault();
9519
+ return;
9520
+ }
9521
+ if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key === "ArrowDown" && recallNextPromptFromHistory()) {
9522
+ event.preventDefault();
9523
+ }
8820
9524
  });
8821
9525
 
8822
9526
  elements.promptInput.addEventListener("input", () => {
9527
+ resetPromptHistoryNavigation();
8823
9528
  resizePromptInput();
8824
9529
  renderCommandSuggestions();
8825
9530
  });
@@ -8828,6 +9533,7 @@ elements.promptInput.addEventListener("focus", () => {
8828
9533
  setTimeout(updateVisualViewportVars, 0);
8829
9534
  });
8830
9535
  elements.promptInput.addEventListener("click", () => {
9536
+ resetPromptHistoryNavigation();
8831
9537
  updateVisualViewportVars();
8832
9538
  syncMobileChatToBottomForInput();
8833
9539
  renderCommandSuggestions();
@@ -8848,6 +9554,7 @@ focusPromptInput({ defer: true });
8848
9554
  updateComposerModeButtons();
8849
9555
  updateOptionalFeatureAvailability();
8850
9556
  loadLastUserPromptCache();
9557
+ loadPromptHistoryCache();
8851
9558
  installViewportHandlers();
8852
9559
  currentThemeName = storedThemeName();
8853
9560
  renderBackgroundControl();
@@ -8858,6 +9565,7 @@ initializeThemes().catch((error) => {
8858
9565
  initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
8859
9566
  restoreAgentDoneNotificationsSetting();
8860
9567
  restoreThinkingVisibilitySetting();
9568
+ restoreToolOutputExpansionSetting();
8861
9569
  restoreSidePanelSectionState();
8862
9570
  bindSidePanelSectionToggles();
8863
9571
  restoreSidePanelState();