@bubblebrain-ai/bubble 0.0.1 → 0.0.3

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/dist/tui/run.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { BoxRenderable, CodeRenderable, createCliRenderer, DiffRenderable, getTreeSitterClient, MarkdownRenderable, LineNumberRenderable, StyledText, RGBA, fg, bg, bold, dim, TextAttributes, TextRenderable, } from "@opentui/core";
2
- import { createComponent, createElement, insert, render, spread, useKeyboard, useTerminalDimensions, } from "@opentui/solid";
2
+ import { createComponent, createElement, insert, render, spread, useKeyboard, useRenderer, useSelectionHandler, useTerminalDimensions, } from "@opentui/solid";
3
3
  import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
4
+ import { AgentAbortError } from "../agent.js";
4
5
  import { BUILTIN_PROVIDERS, decodeModel, displayModel, isUserVisibleProvider } from "../provider-registry.js";
5
6
  import { listBuiltinModels } from "../model-catalog.js";
6
7
  import { calculateUsageCost } from "../model-pricing.js";
@@ -12,14 +13,17 @@ import { sidebarMcpRowsFromStates, renderMcpRowMarker } from "./sidebar-mcp.js";
12
13
  import { expandAtMentions, filterFileSuggestions, findAtContext, listProjectFiles } from "./file-mentions.js";
13
14
  import { compactDisplayMessages } from "./display-history.js";
14
15
  import { createMarkdownSyntaxStyle, createSubtleMarkdownSyntaxStyle } from "./markdown-theme.js";
15
- import { getNextPermissionMode } from "../permission/mode.js";
16
+ import { getNextPermissionMode, PERMISSION_MODE_INFO } from "../permission/mode.js";
16
17
  import { getContextBudget } from "../context/budget.js";
17
18
  import { getLspService } from "../lsp/index.js";
18
19
  import { inferBashPrefix } from "../approval/session-cache.js";
19
20
  import { createFrames } from "./opencode-spinner.js";
21
+ import { copyTextToClipboard } from "./clipboard.js";
20
22
  import { readGitSidebarState } from "./sidebar-state.js";
21
23
  import { isModeCycleKeyEvent, isModeCycleSequence, isModifiedEnterSequence, PROMPT_TEXTAREA_KEYBINDINGS, } from "./prompt-keybindings.js";
24
+ import { keyNameFromEvent, keyNameFromSequence } from "./global-key-router.js";
22
25
  const treeSitterClient = getTreeSitterClient();
26
+ const PROMPT_HISTORY_LIMIT = 100;
23
27
  const PROVIDER_PRIORITY = new Map([
24
28
  ["openai", 0],
25
29
  ["deepseek", 1],
@@ -104,7 +108,7 @@ const HOME_LOGO = [
104
108
  ];
105
109
  const HOME_TIPS = [
106
110
  "Type @ followed by a filename to attach file context",
107
- "Press Tab to cycle Build and Plan modes",
111
+ "Press Shift+Tab to cycle Build, Plan, and Bypass modes",
108
112
  "Type / or press Ctrl+P to open commands",
109
113
  "Use /compact to summarize long sessions near context limits",
110
114
  "Shift+Enter or Ctrl+J inserts a newline in your prompt",
@@ -158,8 +162,8 @@ export async function runTui(agent, args, options = {}) {
158
162
  let renderer;
159
163
  let syntaxStyle;
160
164
  let subtleSyntaxStyle;
161
- let rawModeCycleHandler;
162
- let rawQuestionHandler;
165
+ let rawGlobalKeyHandler;
166
+ let rawMouseSelectionHandler;
163
167
  const exit = () => {
164
168
  try {
165
169
  renderer?.destroy();
@@ -181,20 +185,29 @@ export async function runTui(agent, args, options = {}) {
181
185
  exitOnCtrlC: false,
182
186
  useKittyKeyboard: {},
183
187
  prependInputHandlers: [
184
- (sequence) => rawQuestionHandler?.(sequence) || rawModeCycleHandler?.(sequence) || false,
188
+ (sequence) => rawGlobalKeyHandler?.(sequence) || false,
185
189
  ],
186
190
  autoFocus: true,
187
191
  useMouse: true,
188
192
  openConsoleOnError: false,
189
193
  backgroundColor: theme.background,
190
194
  });
191
- const setRawModeCycleHandler = (handler) => {
192
- rawModeCycleHandler = handler;
195
+ const setRawGlobalKeyHandler = (handler) => {
196
+ rawGlobalKeyHandler = handler;
193
197
  };
194
- const setRawQuestionHandler = (handler) => {
195
- rawQuestionHandler = handler;
198
+ const setRawMouseSelectionHandler = (handler) => {
199
+ rawMouseSelectionHandler = handler;
196
200
  };
197
- await render(() => h(OpenTuiApp, { agent, args, options, onExit: exit, syntaxStyle, subtleSyntaxStyle, setRawModeCycleHandler, setRawQuestionHandler }), renderer);
201
+ const processSingleMouseEvent = renderer.processSingleMouseEvent;
202
+ if (typeof processSingleMouseEvent === "function") {
203
+ renderer.processSingleMouseEvent = (event) => {
204
+ const handled = processSingleMouseEvent.call(renderer, event);
205
+ if (handled)
206
+ rawMouseSelectionHandler?.(event);
207
+ return handled;
208
+ };
209
+ }
210
+ await render(() => h(OpenTuiApp, { agent, args, options, onExit: exit, syntaxStyle, subtleSyntaxStyle, setRawGlobalKeyHandler, setRawMouseSelectionHandler }), renderer);
198
211
  }
199
212
  catch (error) {
200
213
  syntaxStyle?.destroy();
@@ -223,6 +236,7 @@ function isColorValue(value) {
223
236
  || value === "none";
224
237
  }
225
238
  function OpenTuiApp(props) {
239
+ const renderer = useRenderer();
226
240
  const dimensions = useTerminalDimensions();
227
241
  const registry = props.options.registry;
228
242
  const skills = props.options.skillRegistry;
@@ -245,13 +259,24 @@ function OpenTuiApp(props) {
245
259
  const homeTip = HOME_TIPS[Math.floor(Math.random() * HOME_TIPS.length)] ?? HOME_TIPS[0];
246
260
  const homePrompt = HOME_PROMPTS[Math.floor(Math.random() * HOME_PROMPTS.length)] ?? HOME_PROMPTS[0];
247
261
  let promptText = "";
262
+ let promptHistory = displayMessages
263
+ .filter((message) => message.role === "user" && message.content !== "(multimedia)")
264
+ .map((message) => message.content)
265
+ .slice(-PROMPT_HISTORY_LIMIT);
266
+ let promptHistoryIndex;
267
+ let promptHistoryDraft = "";
248
268
  const [isRunning, setIsRunning] = createSignal(false);
269
+ let activeRun;
270
+ let nextRunId = 0;
249
271
  const [showThinking, setShowThinking] = createSignal(true);
250
272
  let streamingDisplay;
251
273
  let sidebarLspSyncTimer;
252
274
  const [todos, setTodos] = createSignal(props.agent.getTodos());
253
275
  const [mode, setMode] = createSignal(props.agent.mode);
254
276
  const [notice, setNotice] = createSignal("");
277
+ let copyToastClearTimer;
278
+ let copyToastRoot;
279
+ let copyToastText;
255
280
  const [sessionActive, setSessionActive] = createSignal(false);
256
281
  const [sidebarTick, setSidebarTick] = createSignal(0);
257
282
  // Sidebar MCP section collapsed state. Persisted across sidebarTick bumps,
@@ -260,6 +285,7 @@ function OpenTuiApp(props) {
260
285
  const lspService = props.options.lspService ?? getLspService(props.args.cwd, props.options.settingsManager?.getMerged().lsp);
261
286
  const [lspStatuses, setLspStatuses] = createSignal(lspService.status());
262
287
  const [sidebarUsage, setSidebarUsage] = createSignal({
288
+ contextTokens: 0,
263
289
  promptTokens: 0,
264
290
  completionTokens: 0,
265
291
  promptCacheHitTokens: 0,
@@ -282,6 +308,8 @@ function OpenTuiApp(props) {
282
308
  let homePromptRef;
283
309
  let sessionPromptRef;
284
310
  let scrollbox;
311
+ let transcriptScrollFollowing = true;
312
+ let transcriptScrollInitialized = false;
285
313
  let rootBox;
286
314
  let sidebarShell;
287
315
  let transcriptHost;
@@ -359,6 +387,10 @@ function OpenTuiApp(props) {
359
387
  const sidebarLspRows = [];
360
388
  const sidebarLspMarkers = [];
361
389
  const sidebarLspLabels = [];
390
+ let sidebarTodoSection;
391
+ const sidebarTodoRows = [];
392
+ const sidebarTodoMarkers = [];
393
+ const sidebarTodoLabels = [];
362
394
  const sidebarFileRows = [];
363
395
  const sidebarFileLabels = [];
364
396
  const sidebarFileAdditions = [];
@@ -367,15 +399,211 @@ function OpenTuiApp(props) {
367
399
  const activePrompt = () => isHomeSurfaceActive()
368
400
  ? homePromptRef ?? sessionPromptRef
369
401
  : sessionPromptRef ?? homePromptRef;
402
+ function setPromptText(value) {
403
+ promptText = value;
404
+ const prompt = activePrompt();
405
+ if (!prompt)
406
+ return;
407
+ prompt.setText(value);
408
+ prompt.cursorOffset = value.length;
409
+ prompt.focus();
410
+ }
411
+ function resetPromptHistoryBrowse() {
412
+ promptHistoryIndex = undefined;
413
+ promptHistoryDraft = "";
414
+ }
415
+ function rememberPromptHistory(input) {
416
+ const value = input.trimEnd();
417
+ if (!value.trim())
418
+ return;
419
+ promptHistory.push(value);
420
+ if (promptHistory.length > PROMPT_HISTORY_LIMIT) {
421
+ promptHistory = promptHistory.slice(-PROMPT_HISTORY_LIMIT);
422
+ }
423
+ resetPromptHistoryBrowse();
424
+ }
425
+ function canBrowsePromptHistory(direction) {
426
+ if (promptHistoryIndex !== undefined)
427
+ return true;
428
+ const prompt = activePrompt();
429
+ const text = prompt?.plainText ?? promptText;
430
+ if (!text)
431
+ return true;
432
+ const cursor = prompt?.logicalCursor;
433
+ if (!cursor)
434
+ return true;
435
+ if (direction === "up")
436
+ return cursor.row === 0;
437
+ return cursor.row >= text.split("\n").length - 1;
438
+ }
439
+ function browsePromptHistory(direction) {
440
+ if (!promptHistory.length)
441
+ return false;
442
+ if (!canBrowsePromptHistory(direction))
443
+ return false;
444
+ if (direction === "up") {
445
+ if (promptHistoryIndex === undefined) {
446
+ promptHistoryDraft = readPromptText() || promptText;
447
+ promptHistoryIndex = promptHistory.length - 1;
448
+ }
449
+ else {
450
+ promptHistoryIndex = Math.max(0, promptHistoryIndex - 1);
451
+ }
452
+ setPromptText(promptHistory[promptHistoryIndex] ?? "");
453
+ return true;
454
+ }
455
+ if (promptHistoryIndex === undefined)
456
+ return false;
457
+ if (promptHistoryIndex < promptHistory.length - 1) {
458
+ promptHistoryIndex += 1;
459
+ setPromptText(promptHistory[promptHistoryIndex] ?? "");
460
+ }
461
+ else {
462
+ setPromptText(promptHistoryDraft);
463
+ resetPromptHistoryBrowse();
464
+ }
465
+ return true;
466
+ }
467
+ function handlePromptHistoryKey(event) {
468
+ if (event.shift || event.ctrl || event.meta || event.super || event.hyper)
469
+ return false;
470
+ const name = keyNameFromEvent(event);
471
+ if (name !== "up" && name !== "down")
472
+ return false;
473
+ if (!browsePromptHistory(name))
474
+ return false;
475
+ event.preventDefault?.();
476
+ event.stopPropagation?.();
477
+ return true;
478
+ }
479
+ function blurInputsForModal() {
480
+ homePromptRef?.blur();
481
+ sessionPromptRef?.blur();
482
+ questionCustomInput?.blur();
483
+ providerDialogInput?.blur();
484
+ }
485
+ function focusApprovalPanel() {
486
+ setTimeout(() => {
487
+ if (pendingApproval() || pendingPlan())
488
+ approvalRoot?.focus();
489
+ }, 0);
490
+ }
491
+ function focusQuestionPanel() {
492
+ setTimeout(() => {
493
+ const state = pendingQuestion();
494
+ if (!state || state.editing)
495
+ return;
496
+ questionRoot?.focus();
497
+ }, 0);
498
+ }
499
+ function restorePromptAfterModal() {
500
+ setTimeout(() => {
501
+ if (!activeModalKeyOwner())
502
+ activePrompt()?.focus();
503
+ }, 0);
504
+ }
370
505
  const activeComposerShell = () => isHomeSurfaceActive()
371
506
  ? homeComposerShell ?? sessionComposerShell
372
507
  : sessionComposerShell ?? homeComposerShell;
373
508
  onCleanup(() => {
374
509
  uiDisposed = true;
510
+ if (copyToastClearTimer)
511
+ clearTimeout(copyToastClearTimer);
375
512
  promptModeLabels.clear();
376
513
  promptModelLabels.clear();
377
514
  footerModeBadge = undefined;
378
515
  });
516
+ function showCopyToast(toast, ttl = 2200) {
517
+ if (copyToastClearTimer)
518
+ clearTimeout(copyToastClearTimer);
519
+ const sidebarOffset = sidebarVisible() ? SESSION_SIDEBAR_WIDTH : 0;
520
+ const mainAreaWidth = Math.max(20, dimensions().width - sidebarOffset - 4);
521
+ const color = toast.variant === "success"
522
+ ? theme.success
523
+ : toast.variant === "error"
524
+ ? theme.error
525
+ : toast.variant === "warning"
526
+ ? theme.warning
527
+ : theme.info;
528
+ const width = Math.max(24, Math.min(60, Math.min(mainAreaWidth, toast.message.length + 6)));
529
+ if (copyToastRoot) {
530
+ copyToastRoot.visible = true;
531
+ copyToastRoot.width = width;
532
+ copyToastRoot.right = sidebarOffset + 2;
533
+ copyToastRoot.borderColor = color;
534
+ }
535
+ if (copyToastText) {
536
+ copyToastText.fg = theme.text;
537
+ safeSetText(copyToastText, toast.message);
538
+ }
539
+ renderer.requestRender();
540
+ copyToastClearTimer = setTimeout(() => {
541
+ if (copyToastRoot)
542
+ copyToastRoot.visible = false;
543
+ renderer.requestRender();
544
+ copyToastClearTimer = undefined;
545
+ }, ttl);
546
+ }
547
+ async function copySelectionText(text) {
548
+ const now = Date.now();
549
+ if (!text.trim())
550
+ return;
551
+ if (text === lastCopiedSelection && now - lastCopiedSelectionAt < 350)
552
+ return;
553
+ const serial = ++selectionCopySerial;
554
+ let copied = false;
555
+ try {
556
+ await copyTextToClipboard(text);
557
+ copied = true;
558
+ }
559
+ catch {
560
+ try {
561
+ copied = renderer.copyToClipboardOSC52(text);
562
+ }
563
+ catch {
564
+ copied = false;
565
+ }
566
+ }
567
+ if (serial !== selectionCopySerial)
568
+ return;
569
+ if (copied) {
570
+ lastCopiedSelection = text;
571
+ lastCopiedSelectionAt = Date.now();
572
+ showCopyToast({ message: "Copied to clipboard", variant: "info" });
573
+ }
574
+ else {
575
+ showCopyToast({ message: "Failed to copy selection", variant: "error" }, 3000);
576
+ }
577
+ }
578
+ function isInsideRenderable(renderable, container) {
579
+ if (!container)
580
+ return false;
581
+ let current = renderable;
582
+ while (current) {
583
+ if (current === container)
584
+ return true;
585
+ current = current.parent;
586
+ }
587
+ return false;
588
+ }
589
+ function getOpenTuiSelectionText(selection) {
590
+ const selectedRenderables = Array.isArray(selection?.selectedRenderables)
591
+ ? [...selection.selectedRenderables]
592
+ : undefined;
593
+ if (!selectedRenderables?.length) {
594
+ return typeof selection?.getSelectedText === "function" ? selection.getSelectedText() : "";
595
+ }
596
+ return selectedRenderables
597
+ .filter((renderable) => !renderable.isDestroyed && !isInsideRenderable(renderable, sidebarShell))
598
+ .sort((a, b) => {
599
+ if (a.y !== b.y)
600
+ return a.y - b.y;
601
+ return a.x - b.x;
602
+ })
603
+ .map((renderable) => typeof renderable.getSelectedText === "function" ? renderable.getSelectedText() : "")
604
+ .filter(Boolean)
605
+ .join("\n");
606
+ }
379
607
  const readPromptText = () => {
380
608
  try {
381
609
  return activePrompt()?.plainText ?? "";
@@ -397,6 +625,12 @@ function OpenTuiApp(props) {
397
625
  setSidebarTick((value) => value + 1);
398
626
  syncSidebarContext();
399
627
  };
628
+ const syncTodosFromAgent = () => {
629
+ const nextTodos = props.agent.getTodos();
630
+ setTodos(nextTodos);
631
+ syncSidebarTodos(nextTodos);
632
+ bumpSidebar();
633
+ };
400
634
  function refreshGitSidebar() {
401
635
  setGitState(readGitSidebarState(props.args.cwd));
402
636
  syncSidebarFiles();
@@ -422,10 +656,13 @@ function OpenTuiApp(props) {
422
656
  setSidebarText(sidebarTokenText, `${formatCompactNumber(context.tokens)} tokens`);
423
657
  setSidebarText(sidebarPercentText, `${context.percent}% used`);
424
658
  if (sidebarGaugeText) {
425
- sidebarGaugeText.content = buildColoredGauge(context.percent, 30);
659
+ sidebarGaugeText.content = buildContextGauge(context.percent, 30);
660
+ sidebarGaugeText.requestRender();
426
661
  }
427
662
  if (sidebarGaugeLabelText) {
428
- sidebarGaugeLabelText.content = buildGaugeLabel(context.percent);
663
+ sidebarGaugeLabelText.content = buildGaugeLabel(context.percent, context.remainingTokens);
664
+ sidebarGaugeLabelText.fg = context.percent >= 80 ? theme.error : context.percent >= 60 ? theme.warning : theme.success;
665
+ sidebarGaugeLabelText.requestRender();
429
666
  }
430
667
  setSidebarText(sidebarUsageText, context.turns > 0
431
668
  ? `${formatCompactNumber(context.promptTokens)} in · ${formatCompactNumber(context.completionTokens)} out`
@@ -494,6 +731,35 @@ function OpenTuiApp(props) {
494
731
  }
495
732
  sidebarShell?.requestRender();
496
733
  }
734
+ function syncSidebarTodos(nextTodos = todos()) {
735
+ const visible = nextTodos.slice(0, 8);
736
+ if (sidebarTodoSection) {
737
+ sidebarTodoSection.visible = visible.length > 0;
738
+ }
739
+ for (let index = 0; index < 8; index++) {
740
+ const row = sidebarTodoRows[index];
741
+ const marker = sidebarTodoMarkers[index];
742
+ const label = sidebarTodoLabels[index];
743
+ const todo = visible[index];
744
+ if (!row || !marker || !label)
745
+ continue;
746
+ row.visible = !!todo;
747
+ if (!todo) {
748
+ safeRequestRender(row);
749
+ continue;
750
+ }
751
+ const completed = todo.status === "completed";
752
+ const inProgress = todo.status === "in_progress";
753
+ const labelText = inProgress ? (todo.activeForm || todo.content) : todo.content;
754
+ marker.content = completed ? "✓" : inProgress ? "◉" : "○";
755
+ marker.fg = completed ? theme.success : inProgress ? theme.warning : theme.textMuted;
756
+ label.content = labelText;
757
+ label.fg = completed ? theme.success : inProgress ? theme.warning : theme.textMuted;
758
+ safeRequestRender(row);
759
+ }
760
+ sidebarShell?.requestRender();
761
+ rootBox?.requestRender();
762
+ }
497
763
  function showSidebarLspRows(statuses) {
498
764
  for (let index = 0; index < sidebarLspRows.length; index++) {
499
765
  const row = sidebarLspRows[index];
@@ -517,7 +783,7 @@ function OpenTuiApp(props) {
517
783
  }
518
784
  const promptModeTitle = () => mode() === "plan" ? "Plan" : "Build";
519
785
  const promptModeBadge = () => promptModeBadgeContent(mode());
520
- const footerModeText = () => mode() !== "default" ? ` ${mode()} · tab` : "";
786
+ const footerModeText = () => footerPermissionModeText(mode());
521
787
  function syncModeChrome() {
522
788
  if (uiDisposed)
523
789
  return;
@@ -525,8 +791,11 @@ function OpenTuiApp(props) {
525
791
  if (!safeSetText(label, promptModeBadge()))
526
792
  promptModeLabels.delete(label);
527
793
  }
528
- if (footerModeBadge && !safeSetText(footerModeBadge, footerModeText()))
529
- footerModeBadge = undefined;
794
+ if (footerModeBadge) {
795
+ footerModeBadge.fg = permissionModeColor(mode());
796
+ if (!safeSetText(footerModeBadge, footerModeText()))
797
+ footerModeBadge = undefined;
798
+ }
530
799
  safeRequestRender(homeComposerShell);
531
800
  safeRequestRender(sessionComposerShell);
532
801
  safeRequestRender(rootBox);
@@ -567,10 +836,10 @@ function OpenTuiApp(props) {
567
836
  const cycleMode = () => {
568
837
  if (picker || pendingPlan())
569
838
  return false;
570
- const next = getNextPermissionMode(props.agent.mode, { bypassEnabled: props.options.bypassEnabled });
839
+ const next = getNextPermissionMode(props.agent.mode);
571
840
  props.agent.setMode(next);
572
841
  setMode(next);
573
- setNotice(`Mode: ${next === "plan" ? "Plan" : "Build"}`);
842
+ setNotice(`Mode: ${permissionModeBadgeLabel(next)}`);
574
843
  redrawDock();
575
844
  syncPromptSurfaces();
576
845
  syncModeChrome();
@@ -584,9 +853,6 @@ function OpenTuiApp(props) {
584
853
  return true;
585
854
  };
586
855
  const cycleModeFromRawSequence = (sequence) => {
587
- const rawKey = keyNameFromSequence(sequence);
588
- if (rawKey && handleApprovalNavigation(rawKey))
589
- return true;
590
856
  if (!isModeCycleSequence(sequence))
591
857
  return false;
592
858
  return cycleMode();
@@ -599,40 +865,7 @@ function OpenTuiApp(props) {
599
865
  ? ["Allow once", "Allow always", "Reject"]
600
866
  : ["Allow once", "Reject"];
601
867
  };
602
- const keyNameFromSequence = (sequence) => {
603
- if (!sequence)
604
- return "";
605
- if (sequence === "\x1b[D" || /^\x1b\[[0-9;]*D$/.test(sequence))
606
- return "left";
607
- if (sequence === "\x1b[C" || /^\x1b\[[0-9;]*C$/.test(sequence))
608
- return "right";
609
- if (sequence === "\x1b[A" || /^\x1b\[[0-9;]*A$/.test(sequence))
610
- return "up";
611
- if (sequence === "\x1b[B" || /^\x1b\[[0-9;]*B$/.test(sequence))
612
- return "down";
613
- if (sequence === "\r" || sequence === "\n")
614
- return "enter";
615
- if (sequence === "\x1b")
616
- return "escape";
617
- return "";
618
- };
619
- const keyNameFromEvent = (event) => {
620
- const rawName = String(event.name || event.key || event.input || "").toLowerCase();
621
- if (rawName === "arrowleft" || rawName === "left_arrow")
622
- return "left";
623
- if (rawName === "arrowright" || rawName === "right_arrow")
624
- return "right";
625
- if (rawName === "arrowup" || rawName === "up_arrow")
626
- return "up";
627
- if (rawName === "arrowdown" || rawName === "down_arrow")
628
- return "down";
629
- if (rawName === "return")
630
- return "enter";
631
- if (rawName === "esc")
632
- return "escape";
633
- return rawName || keyNameFromSequence(event.raw || event.sequence);
634
- };
635
- const questionKeyNameFromSequence = (sequence) => {
868
+ const modalKeyNameFromSequence = (sequence) => {
636
869
  const name = keyNameFromSequence(sequence);
637
870
  if (name)
638
871
  return name;
@@ -652,12 +885,16 @@ function OpenTuiApp(props) {
652
885
  const rejectPendingPlan = (plan) => {
653
886
  setPendingPlan(undefined);
654
887
  setApprovalOptionIdx(0);
888
+ forceApprovalUI();
889
+ restorePromptAfterModal();
655
890
  plan.resolve({ action: "reject", reason: "Rejected by user." });
656
891
  };
657
892
  const resolvePendingPlanSelection = (plan) => {
658
893
  const sel = approvalOptionIdx();
659
894
  setPendingPlan(undefined);
660
895
  setApprovalOptionIdx(0);
896
+ forceApprovalUI();
897
+ restorePromptAfterModal();
661
898
  if (sel === 0) {
662
899
  plan.resolve({ action: "approve", plan: plan.plan });
663
900
  }
@@ -710,6 +947,7 @@ function OpenTuiApp(props) {
710
947
  setPendingApproval(undefined);
711
948
  setApprovalOptionIdx(0);
712
949
  forceApprovalUI();
950
+ restorePromptAfterModal();
713
951
  if (choice === "Allow once") {
714
952
  approval.resolve({ action: "approve" });
715
953
  return true;
@@ -722,7 +960,7 @@ function OpenTuiApp(props) {
722
960
  approval.resolve({ action: "reject", feedback: "Rejected by user." });
723
961
  return true;
724
962
  };
725
- const handleApprovalNavigation = (name, preventOnly = false) => {
963
+ const handleApprovalNavigation = (name, preventOnly = false, shift = false) => {
726
964
  const approval = pendingApproval();
727
965
  if (approval) {
728
966
  const opts = approvalOptionsFor(approval.request);
@@ -734,6 +972,10 @@ function OpenTuiApp(props) {
734
972
  moveApprovalOption(1, opts.length);
735
973
  return true;
736
974
  }
975
+ if (name === "tab") {
976
+ moveApprovalOption(shift ? -1 : 1, opts.length);
977
+ return true;
978
+ }
737
979
  if (name === "enter") {
738
980
  if (!preventOnly)
739
981
  resolveApprovalSelection();
@@ -745,6 +987,7 @@ function OpenTuiApp(props) {
745
987
  setPendingApproval(undefined);
746
988
  setApprovalOptionIdx(0);
747
989
  forceApprovalUI();
990
+ restorePromptAfterModal();
748
991
  approval.resolve({ action: "reject", feedback: "Rejected by user." });
749
992
  }
750
993
  return true;
@@ -760,6 +1003,10 @@ function OpenTuiApp(props) {
760
1003
  moveApprovalOption(1, PLAN_OPTIONS.length);
761
1004
  return true;
762
1005
  }
1006
+ if (name === "tab") {
1007
+ moveApprovalOption(shift ? -1 : 1, PLAN_OPTIONS.length);
1008
+ return true;
1009
+ }
763
1010
  if (name === "enter") {
764
1011
  if (!preventOnly)
765
1012
  resolvePendingPlanSelection(plan);
@@ -775,7 +1022,7 @@ function OpenTuiApp(props) {
775
1022
  };
776
1023
  const handleApprovalKey = (event) => {
777
1024
  const name = keyNameFromEvent(event);
778
- if (handleApprovalNavigation(name)) {
1025
+ if (handleApprovalNavigation(name, false, !!event.shift)) {
779
1026
  event.preventDefault?.();
780
1027
  event.stopPropagation?.();
781
1028
  return true;
@@ -787,6 +1034,8 @@ function OpenTuiApp(props) {
787
1034
  const plan = pendingPlan();
788
1035
  syncPromptSurfaces();
789
1036
  const prompt = activePrompt();
1037
+ if (approval || plan)
1038
+ blurInputsForModal();
790
1039
  if (prompt) {
791
1040
  if (approval) {
792
1041
  const options = approvalOptionsFor(approval.request);
@@ -803,6 +1052,8 @@ function OpenTuiApp(props) {
803
1052
  }
804
1053
  redrawDock();
805
1054
  redrawApprovalPanel();
1055
+ if (approval || plan)
1056
+ focusApprovalPanel();
806
1057
  redrawTranscript();
807
1058
  };
808
1059
  function questionStateFromRequest(request) {
@@ -851,15 +1102,67 @@ function OpenTuiApp(props) {
851
1102
  questionSyncTimers.add(timer);
852
1103
  }
853
1104
  }
1105
+ function transcriptMaxScrollTop() {
1106
+ if (!scrollbox)
1107
+ return 0;
1108
+ return Math.max(0, scrollbox.scrollHeight - scrollbox.viewport.height);
1109
+ }
1110
+ function isTranscriptAtBottom() {
1111
+ if (!scrollbox)
1112
+ return true;
1113
+ return scrollbox.scrollTop >= transcriptMaxScrollTop() - 1;
1114
+ }
1115
+ function updateTranscriptScrollFollowingFromPosition() {
1116
+ if (!scrollbox)
1117
+ return;
1118
+ transcriptScrollFollowing = isTranscriptAtBottom();
1119
+ transcriptScrollInitialized = true;
1120
+ }
1121
+ function shouldFollowTranscriptBeforeUpdate() {
1122
+ if (!scrollbox)
1123
+ return transcriptScrollFollowing;
1124
+ if (!transcriptScrollInitialized)
1125
+ return true;
1126
+ transcriptScrollFollowing = isTranscriptAtBottom();
1127
+ return transcriptScrollFollowing;
1128
+ }
1129
+ function scrollTranscriptToBottom() {
1130
+ if (!scrollbox)
1131
+ return;
1132
+ scrollbox.scrollTo(scrollbox.scrollHeight);
1133
+ transcriptScrollFollowing = true;
1134
+ transcriptScrollInitialized = true;
1135
+ }
1136
+ function scheduleTranscriptScrollAfterUpdate(shouldFollow, delay = 50) {
1137
+ setTimeout(() => {
1138
+ if (!scrollbox)
1139
+ return;
1140
+ if (shouldFollow && transcriptScrollFollowing) {
1141
+ scrollTranscriptToBottom();
1142
+ }
1143
+ else {
1144
+ updateTranscriptScrollFollowingFromPosition();
1145
+ }
1146
+ }, delay);
1147
+ }
1148
+ function handleTranscriptMouseScroll() {
1149
+ setTimeout(updateTranscriptScrollFollowingFromPosition, 0);
1150
+ }
854
1151
  function syncQuestionUI(focusCustom = false) {
855
1152
  redrawQuestionPanel();
856
1153
  syncPromptSurfaces();
1154
+ const question = pendingQuestion();
1155
+ if (question)
1156
+ blurInputsForModal();
857
1157
  redrawDock();
858
1158
  rootBox?.requestRender();
859
1159
  scrollbox?.requestRender();
860
1160
  if (focusCustom) {
861
1161
  setTimeout(() => questionCustomInput?.focus(), 0);
862
1162
  }
1163
+ else if (question) {
1164
+ focusQuestionPanel();
1165
+ }
863
1166
  }
864
1167
  function updateQuestionState(updater, focusCustom = false) {
865
1168
  setPendingQuestion((current) => current ? updater(current) : undefined);
@@ -1095,17 +1398,49 @@ function OpenTuiApp(props) {
1095
1398
  }
1096
1399
  return false;
1097
1400
  }
1098
- function handleQuestionRawSequence(sequence) {
1099
- const state = pendingQuestion();
1100
- if (!state || pendingApproval())
1401
+ function activeModalKeyOwner() {
1402
+ if (pendingApproval() || pendingPlan())
1403
+ return "approval";
1404
+ if (pendingQuestion())
1405
+ return "question";
1406
+ if (providerDialog)
1407
+ return "provider";
1408
+ if (picker)
1409
+ return "picker";
1410
+ return undefined;
1411
+ }
1412
+ function routeModalKey(event) {
1413
+ const owner = activeModalKeyOwner();
1414
+ if (!owner)
1101
1415
  return false;
1102
- const name = questionKeyNameFromSequence(sequence);
1103
- if (!name)
1416
+ switch (owner) {
1417
+ case "approval":
1418
+ return handleApprovalKey(event);
1419
+ case "question":
1420
+ return handleQuestionKey(event);
1421
+ case "provider":
1422
+ return handleProviderDialogKey(event);
1423
+ case "picker":
1424
+ return handlePickerKey(event);
1425
+ }
1426
+ }
1427
+ function shouldModalSwallowUnhandledKey(owner) {
1428
+ if (owner === "approval")
1429
+ return true;
1430
+ if (owner === "question") {
1431
+ const state = pendingQuestion();
1432
+ return !state?.editing || isQuestionConfirmTab(state);
1433
+ }
1434
+ return false;
1435
+ }
1436
+ function routeModalRawSequence(sequence) {
1437
+ const owner = activeModalKeyOwner();
1438
+ if (!owner)
1104
1439
  return false;
1105
- if (state.editing && !isQuestionConfirmTab(state) && name !== "escape" && name !== "enter") {
1440
+ const name = modalKeyNameFromSequence(sequence);
1441
+ if (!name)
1106
1442
  return false;
1107
- }
1108
- return handleQuestionKey({
1443
+ const handled = routeModalKey({
1109
1444
  name,
1110
1445
  key: name,
1111
1446
  input: sequence,
@@ -1115,11 +1450,13 @@ function OpenTuiApp(props) {
1115
1450
  preventDefault() { },
1116
1451
  stopPropagation() { },
1117
1452
  });
1453
+ return handled || shouldModalSwallowUnhandledKey(owner);
1118
1454
  }
1119
1455
  const installInteractiveHandlers = () => {
1120
1456
  if (props.options.planHandlerRef) {
1121
1457
  props.options.planHandlerRef.current = (plan) => new Promise((resolve) => {
1122
1458
  setPendingPlan({ plan, resolve });
1459
+ blurInputsForModal();
1123
1460
  forceApprovalUI();
1124
1461
  });
1125
1462
  }
@@ -1129,6 +1466,7 @@ function OpenTuiApp(props) {
1129
1466
  picker = undefined;
1130
1467
  providerDialog = undefined;
1131
1468
  setPendingApproval({ request, resolve });
1469
+ blurInputsForModal();
1132
1470
  forceApprovalUI();
1133
1471
  });
1134
1472
  }
@@ -1162,8 +1500,7 @@ function OpenTuiApp(props) {
1162
1500
  onCleanup(unsubscribeQuestion);
1163
1501
  syncFirstPendingQuestion();
1164
1502
  }
1165
- props.setRawModeCycleHandler?.(cycleModeFromRawSequence);
1166
- props.setRawQuestionHandler?.(handleQuestionRawSequence);
1503
+ props.setRawGlobalKeyHandler?.(routeGlobalRawSequence);
1167
1504
  const unsubscribeLsp = lspService.onStatusChange(() => {
1168
1505
  syncSidebarLsp();
1169
1506
  });
@@ -1172,12 +1509,11 @@ function OpenTuiApp(props) {
1172
1509
  refreshGitSidebar();
1173
1510
  setTimeout(() => {
1174
1511
  activePrompt()?.focus();
1175
- scrollbox?.scrollTo(scrollbox.scrollHeight);
1512
+ scrollTranscriptToBottom();
1176
1513
  }, 25);
1177
1514
  });
1178
1515
  onCleanup(() => {
1179
- props.setRawModeCycleHandler?.(undefined);
1180
- props.setRawQuestionHandler?.(undefined);
1516
+ props.setRawGlobalKeyHandler?.(undefined);
1181
1517
  if (sidebarLspSyncTimer)
1182
1518
  clearInterval(sidebarLspSyncTimer);
1183
1519
  for (const timer of questionSyncTimers)
@@ -1189,66 +1525,38 @@ function OpenTuiApp(props) {
1189
1525
  if (props.options.approvalHandlerRef)
1190
1526
  props.options.approvalHandlerRef.current = undefined;
1191
1527
  });
1192
- useKeyboard((event) => {
1193
- const name = String(event.name || "").toLowerCase();
1194
- if (event.ctrl && name === "c") {
1195
- void requestExit();
1528
+ let lastCopiedSelection = "";
1529
+ let lastCopiedSelectionAt = 0;
1530
+ let selectionCopySerial = 0;
1531
+ let rawSelectionStart;
1532
+ useSelectionHandler((selection) => {
1533
+ if (selection.isDragging)
1196
1534
  return;
1197
- }
1198
- // Ctrl+Shift+M opens the MCP reconnect picker. Shift is required because
1199
- // bare Ctrl+M is Enter on most terminals (historical TTY mapping).
1200
- if (event.ctrl && event.shift && name === "m") {
1201
- openMcpReconnectPicker();
1202
- event.preventDefault?.();
1203
- return;
1204
- }
1205
- if (handleApprovalKey(event))
1206
- return;
1207
- if (handleQuestionKey(event))
1208
- return;
1209
- if (handleProviderDialogKey(event))
1535
+ const selectedText = getOpenTuiSelectionText(selection);
1536
+ void copySelectionText(selectedText);
1537
+ });
1538
+ function handleRawMouseSelection(event) {
1539
+ if (event.button !== 0)
1210
1540
  return;
1211
- if (handlePickerKey(event))
1212
- return;
1213
- const plan = pendingPlan();
1214
- if (plan) {
1215
- if (name === "left" || name === "right" || name === "h" || name === "l") {
1216
- const opts = PLAN_OPTIONS;
1217
- const idx = approvalOptionIdx();
1218
- const next = name === "left" || name === "h"
1219
- ? (idx - 1 + opts.length) % opts.length
1220
- : (idx + 1) % opts.length;
1221
- setApprovalOptionIdx(next);
1222
- forceApprovalUI();
1223
- event.preventDefault?.();
1224
- return;
1225
- }
1226
- if (name === "return" || name === "enter") {
1227
- const sel = approvalOptionIdx();
1228
- setPendingPlan(undefined);
1229
- setApprovalOptionIdx(0);
1230
- if (sel === 0) {
1231
- plan.resolve({ action: "approve", plan: plan.plan });
1232
- }
1233
- else {
1234
- plan.resolve({ action: "reject", reason: "Rejected by user." });
1235
- }
1236
- }
1237
- if (name === "escape") {
1238
- setPendingPlan(undefined);
1239
- setApprovalOptionIdx(0);
1240
- plan.resolve({ action: "reject", reason: "Rejected by user." });
1241
- }
1242
- event.preventDefault?.();
1541
+ if (event.type === "down") {
1542
+ rawSelectionStart = { x: event.x, y: event.y };
1243
1543
  return;
1244
1544
  }
1245
- if (cycleModeFromKey(event))
1545
+ if (event.type !== "up")
1246
1546
  return;
1247
- if (event.ctrl && name === "p" && !picker && !isRunning()) {
1248
- openCommandPalette();
1249
- event.preventDefault?.();
1547
+ const start = rawSelectionStart;
1548
+ rawSelectionStart = undefined;
1549
+ if (!start || (start.x === event.x && start.y === event.y))
1250
1550
  return;
1251
- }
1551
+ const selection = renderer.getSelection();
1552
+ if (!selection || selection.isDragging)
1553
+ return;
1554
+ void copySelectionText(getOpenTuiSelectionText(selection));
1555
+ }
1556
+ props.setRawMouseSelectionHandler?.(handleRawMouseSelection);
1557
+ onCleanup(() => props.setRawMouseSelectionHandler?.(undefined));
1558
+ useKeyboard((event) => {
1559
+ routeGlobalKeyEvent(event);
1252
1560
  }, {});
1253
1561
  function currentTranscriptMessages(extra) {
1254
1562
  return compactDisplayMessages(extra ? [...displayMessages, extra] : displayMessages);
@@ -1298,6 +1606,74 @@ function OpenTuiApp(props) {
1298
1606
  // Keep the agent loop alive even if a renderable is already gone.
1299
1607
  }
1300
1608
  }
1609
+ function beginAgentRun() {
1610
+ const run = { id: ++nextRunId, abortController: new AbortController() };
1611
+ activeRun = run;
1612
+ setRunningState(true);
1613
+ return run;
1614
+ }
1615
+ function finishAgentRun(run) {
1616
+ if (activeRun?.id === run.id)
1617
+ activeRun = undefined;
1618
+ setRunningState(false);
1619
+ }
1620
+ function cancelActiveAgentRun() {
1621
+ if (!activeRun || activeRun.abortController.signal.aborted)
1622
+ return false;
1623
+ activeRun.abortController.abort(new AgentAbortError("Agent run cancelled by user."));
1624
+ setNotice("Agent run cancelled");
1625
+ redrawDock();
1626
+ return true;
1627
+ }
1628
+ function preventGlobalKey(event) {
1629
+ event.preventDefault?.();
1630
+ event.stopPropagation?.();
1631
+ }
1632
+ function routeRunningCancel(name, event) {
1633
+ if (name !== "escape")
1634
+ return false;
1635
+ if (!cancelActiveAgentRun())
1636
+ return false;
1637
+ if (event)
1638
+ preventGlobalKey(event);
1639
+ return true;
1640
+ }
1641
+ function routeGlobalRawSequence(sequence) {
1642
+ const name = keyNameFromSequence(sequence);
1643
+ if (routeRunningCancel(name))
1644
+ return true;
1645
+ if (routeModalRawSequence(sequence))
1646
+ return true;
1647
+ if (cycleModeFromRawSequence(sequence))
1648
+ return true;
1649
+ return false;
1650
+ }
1651
+ function routeGlobalKeyEvent(event) {
1652
+ const name = keyNameFromEvent(event);
1653
+ if (event.ctrl && name === "c") {
1654
+ void requestExit();
1655
+ return true;
1656
+ }
1657
+ if (routeRunningCancel(name, event))
1658
+ return true;
1659
+ // Ctrl+Shift+M opens the MCP reconnect picker. Shift is required because
1660
+ // bare Ctrl+M is Enter on most terminals (historical TTY mapping).
1661
+ if (event.ctrl && event.shift && name === "m") {
1662
+ openMcpReconnectPicker();
1663
+ event.preventDefault?.();
1664
+ return true;
1665
+ }
1666
+ if (routeModalKey(event))
1667
+ return true;
1668
+ if (cycleModeFromKey(event))
1669
+ return true;
1670
+ if (event.ctrl && name === "p" && !picker && !isRunning()) {
1671
+ openCommandPalette();
1672
+ event.preventDefault?.();
1673
+ return true;
1674
+ }
1675
+ return false;
1676
+ }
1301
1677
  function transcriptOptions() {
1302
1678
  return {
1303
1679
  cwd: props.args.cwd,
@@ -1371,28 +1747,22 @@ function OpenTuiApp(props) {
1371
1747
  }, PROMPT_SCANNER_INTERVAL_MS);
1372
1748
  }
1373
1749
  function redrawTranscript(extra, baseMessages = displayMessages) {
1750
+ const shouldFollow = shouldFollowTranscriptBeforeUpdate();
1374
1751
  streamingDisplay = extra;
1375
1752
  const nextMessages = compactDisplayMessages(extra ? [...baseMessages, extra] : baseMessages);
1376
1753
  syncSessionMessages(nextMessages);
1377
1754
  rootBox?.requestRender();
1378
1755
  scrollbox?.requestRender();
1379
- setTimeout(() => {
1380
- if (!scrollbox)
1381
- return;
1382
- if (nextMessages.length <= 3) {
1383
- scrollbox.scrollTo(0);
1384
- return;
1385
- }
1386
- scrollbox.scrollTo(scrollbox.scrollHeight);
1387
- }, 50);
1756
+ scheduleTranscriptScrollAfterUpdate(shouldFollow);
1388
1757
  }
1389
1758
  createEffect(() => {
1759
+ const shouldFollow = shouldFollowTranscriptBeforeUpdate();
1390
1760
  dimensions();
1391
1761
  sessionActive();
1392
1762
  syncSidebarChrome();
1393
1763
  redrawQuestionPanel();
1394
1764
  scrollbox?.requestRender();
1395
- setTimeout(() => scrollbox?.scrollTo(scrollbox.scrollHeight), 50);
1765
+ scheduleTranscriptScrollAfterUpdate(shouldFollow);
1396
1766
  });
1397
1767
  function redrawDock() {
1398
1768
  if (dock) {
@@ -1479,6 +1849,8 @@ function OpenTuiApp(props) {
1479
1849
  return buildProviderConnectItems();
1480
1850
  if (step === "auth")
1481
1851
  return providerId ? buildPickerItems("provider-auth", providerId) : [];
1852
+ if (step === "skills")
1853
+ return buildSkillItems();
1482
1854
  if (step === "models") {
1483
1855
  const modelItems = buildPickerItems("model", providerId);
1484
1856
  if (modelItems.length || providerId)
@@ -1634,17 +2006,19 @@ function OpenTuiApp(props) {
1634
2006
  gutter.fg = active ? activeText : providerDialogGutterColor(row.item.gutter ?? (isCurrentModelItem(row.item) ? "●" : undefined));
1635
2007
  }
1636
2008
  if (label) {
1637
- label.content = truncate(row.item.label, 37);
2009
+ label.content = truncate(row.item.label, providerDialogLabelWidth(state));
1638
2010
  label.fg = active ? activeText : isCurrentModelItem(row.item) ? theme.primary : theme.text;
1639
2011
  }
1640
2012
  if (detail) {
1641
2013
  const detailText = state.query.trim() && state.step === "models"
1642
2014
  ? row.item.category ?? row.item.detail ?? ""
1643
2015
  : row.item.detail ?? "";
1644
- detail.content = truncate(detailText, providerDialogDetailWidth());
2016
+ detail.width = providerDialogDetailWidth(state);
2017
+ detail.content = truncate(detailText, providerDialogDetailWidth(state));
1645
2018
  detail.fg = active ? activeText : theme.textMuted;
1646
2019
  }
1647
2020
  if (footer) {
2021
+ footer.width = providerDialogFooterWidth(state);
1648
2022
  footer.content = row.item.footer ?? "";
1649
2023
  footer.fg = active ? activeText : theme.textMuted;
1650
2024
  }
@@ -1659,6 +2033,8 @@ function OpenTuiApp(props) {
1659
2033
  function providerDialogTitleFor(state) {
1660
2034
  if (state.step === "providers")
1661
2035
  return "Connect a provider";
2036
+ if (state.step === "skills")
2037
+ return "Select skill";
1662
2038
  const provider = providerDisplayName(state.providerId);
1663
2039
  if (state.step === "auth")
1664
2040
  return `${provider} auth method`;
@@ -1677,6 +2053,8 @@ function OpenTuiApp(props) {
1677
2053
  const connect = state.providerId ? "" : " · ctrl+o providers";
1678
2054
  return `↑/↓ move · enter select · esc close${connect}${count}`;
1679
2055
  }
2056
+ if (state.step === "skills")
2057
+ return `↑/↓ move · enter insert · esc close${count}`;
1680
2058
  const escLabel = state.step === "providers" ? "esc close" : "esc back";
1681
2059
  return `↑/↓ move · enter select · ${escLabel}${count}`;
1682
2060
  }
@@ -1689,8 +2067,14 @@ function OpenTuiApp(props) {
1689
2067
  return theme.warning;
1690
2068
  return theme.textMuted;
1691
2069
  }
1692
- function providerDialogDetailWidth() {
1693
- return 16;
2070
+ function providerDialogLabelWidth(state) {
2071
+ return state.step === "skills" ? 22 : 37;
2072
+ }
2073
+ function providerDialogDetailWidth(state) {
2074
+ return state.step === "skills" ? 26 : 16;
2075
+ }
2076
+ function providerDialogFooterWidth(state) {
2077
+ return state.step === "skills" ? 9 : 8;
1694
2078
  }
1695
2079
  function isCurrentModelItem(item) {
1696
2080
  return item.value === props.agent.model || item.detail?.includes("current");
@@ -1755,7 +2139,7 @@ function OpenTuiApp(props) {
1755
2139
  else if (state.step === "key") {
1756
2140
  openProviderDialog(state.providerId && registry.supportsOAuth(state.providerId) ? "auth" : "providers", state.providerId);
1757
2141
  }
1758
- else if (state.step === "models") {
2142
+ else if (state.step === "models" || state.step === "skills") {
1759
2143
  closeProviderDialog();
1760
2144
  }
1761
2145
  else {
@@ -1885,6 +2269,11 @@ function OpenTuiApp(props) {
1885
2269
  }
1886
2270
  closeProviderDialog();
1887
2271
  await executeSlash(item.command);
2272
+ return;
2273
+ }
2274
+ if (state.step === "skills") {
2275
+ closeProviderDialog();
2276
+ insertSkillPrompt(item.value);
1888
2277
  }
1889
2278
  }
1890
2279
  function ensureProviderConfiguredForKey(providerId) {
@@ -2262,6 +2651,8 @@ function OpenTuiApp(props) {
2262
2651
  const clearMessages = () => {
2263
2652
  displayMessages = [];
2264
2653
  streamingDisplay = undefined;
2654
+ promptHistory = [];
2655
+ resetPromptHistoryBrowse();
2265
2656
  redrawTranscript(undefined, []);
2266
2657
  };
2267
2658
  async function submitPrompt() {
@@ -2275,9 +2666,7 @@ function OpenTuiApp(props) {
2275
2666
  }
2276
2667
  const plan = pendingPlan();
2277
2668
  if (plan) {
2278
- setPendingPlan(undefined);
2279
- setApprovalOptionIdx(0);
2280
- plan.resolve({ action: "approve", plan: plan.plan });
2669
+ resolvePendingPlanSelection(plan);
2281
2670
  return;
2282
2671
  }
2283
2672
  if (isRunning())
@@ -2294,6 +2683,7 @@ function OpenTuiApp(props) {
2294
2683
  return;
2295
2684
  activePrompt()?.clear();
2296
2685
  promptText = "";
2686
+ resetPromptHistoryBrowse();
2297
2687
  if (picker?.kind === "key") {
2298
2688
  const providerId = picker.providerId;
2299
2689
  const after = picker.after;
@@ -2310,9 +2700,15 @@ function OpenTuiApp(props) {
2310
2700
  }
2311
2701
  if (input.startsWith("/") && !/\s/.test(input)) {
2312
2702
  const query = input.slice(1).toLowerCase();
2313
- const matches = slashRegistry.list().filter((command) => command.name.toLowerCase().startsWith(query));
2703
+ const matches = slashCandidates().filter((command) => command.name.toLowerCase().startsWith(query));
2314
2704
  if (matches.length === 1) {
2315
- await executeSlash(`/${matches[0].name}`);
2705
+ const match = matches[0];
2706
+ if (match.source === "skill") {
2707
+ insertSkillPrompt(match.name);
2708
+ }
2709
+ else {
2710
+ await executeSlash(`/${match.name}`);
2711
+ }
2316
2712
  return;
2317
2713
  }
2318
2714
  if (matches.length > 1) {
@@ -2332,6 +2728,10 @@ function OpenTuiApp(props) {
2332
2728
  function onPromptContentChange(value) {
2333
2729
  const nextValue = typeof value === "string" ? value : readPromptText();
2334
2730
  promptText = nextValue;
2731
+ if (promptHistoryIndex !== undefined
2732
+ && nextValue !== (promptHistory[promptHistoryIndex] ?? "")) {
2733
+ resetPromptHistoryBrowse();
2734
+ }
2335
2735
  if (providerDialog)
2336
2736
  return;
2337
2737
  if (picker?.kind === "key")
@@ -2432,25 +2832,39 @@ function OpenTuiApp(props) {
2432
2832
  activePrompt()?.focus();
2433
2833
  redrawDock();
2434
2834
  }
2435
- function buildSlashItems(query = "") {
2436
- const normalizedQuery = query.trim().toLowerCase();
2437
- const commands = [
2835
+ function slashCandidates() {
2836
+ const skillCommands = skills.summaries().map((skill) => ({
2837
+ name: skill.name,
2838
+ description: skill.description,
2839
+ source: "skill",
2840
+ sourceLabel: skill.source,
2841
+ }));
2842
+ return [
2438
2843
  ...LOCAL_SLASH_COMMANDS.map((c) => ({ ...c, source: "builtin" })),
2844
+ ...skillCommands,
2439
2845
  ...slashRegistry.list(),
2440
2846
  ];
2847
+ }
2848
+ function buildSlashItems(query = "") {
2849
+ const normalizedQuery = query.trim().toLowerCase();
2850
+ const commands = slashCandidates();
2441
2851
  const matches = slashCommandMatches(commands, normalizedQuery);
2442
2852
  return matches.map(({ command }) => {
2443
2853
  const isMcp = command.source === "mcp";
2444
- const badge = isMcp ? " :mcp" : "";
2854
+ const isSkill = command.source === "skill";
2855
+ const badge = isMcp ? " :mcp" : isSkill ? " :skill" : "";
2445
2856
  const label = `/${command.name}${badge}`;
2446
2857
  const detail = isMcp && command.sourceLabel
2447
2858
  ? `[${command.sourceLabel}] ${command.description}`
2448
- : command.description;
2859
+ : isSkill && command.sourceLabel
2860
+ ? `[${command.sourceLabel}] ${command.description}`
2861
+ : command.description;
2449
2862
  return {
2450
2863
  label,
2451
2864
  detail,
2452
2865
  value: command.name,
2453
2866
  command: `/${command.name}`,
2867
+ action: isSkill ? "insert-skill" : undefined,
2454
2868
  };
2455
2869
  });
2456
2870
  }
@@ -2575,6 +2989,12 @@ function OpenTuiApp(props) {
2575
2989
  prompt.cursorOffset = next.length;
2576
2990
  closePicker();
2577
2991
  }
2992
+ function insertSkillPrompt(skillName) {
2993
+ closePicker();
2994
+ resetPromptHistoryBrowse();
2995
+ setPromptText(`/${skillName} `);
2996
+ redrawDock();
2997
+ }
2578
2998
  async function handleInput(input) {
2579
2999
  setNotice("");
2580
3000
  if (input.startsWith("/")) {
@@ -2635,6 +3055,7 @@ function OpenTuiApp(props) {
2635
3055
  return true;
2636
3056
  if (props.agent.mode !== mode())
2637
3057
  setMode(props.agent.mode);
3058
+ syncTodosFromAgent();
2638
3059
  syncModelChrome();
2639
3060
  syncModeChrome();
2640
3061
  if (uiDisposed)
@@ -2671,6 +3092,10 @@ function OpenTuiApp(props) {
2671
3092
  openProviderDialog(kind === "provider-auth" ? "auth" : "providers", providerId);
2672
3093
  return;
2673
3094
  }
3095
+ if (kind === "skill") {
3096
+ openProviderDialog("skills");
3097
+ return;
3098
+ }
2674
3099
  if (kind === "key") {
2675
3100
  picker = {
2676
3101
  kind: "key",
@@ -2710,6 +3135,10 @@ function OpenTuiApp(props) {
2710
3135
  applyFileSuggestion(item.value);
2711
3136
  return;
2712
3137
  }
3138
+ if (item.action === "insert-skill" || (picker?.kind === "select" && picker.mode === "skill")) {
3139
+ insertSkillPrompt(item.value);
3140
+ return;
3141
+ }
2713
3142
  if (picker?.kind === "select" && picker.mode === "slash") {
2714
3143
  activePrompt()?.clear();
2715
3144
  closePicker();
@@ -2796,6 +3225,8 @@ function OpenTuiApp(props) {
2796
3225
  return [];
2797
3226
  if (kind === "mcp-reconnect")
2798
3227
  return buildMcpReconnectItems();
3228
+ if (kind === "skill")
3229
+ return buildSkillItems();
2799
3230
  if (kind === "model") {
2800
3231
  const items = [];
2801
3232
  for (const provider of registry.getEnabled()) {
@@ -2909,6 +3340,25 @@ function OpenTuiApp(props) {
2909
3340
  command: `/logout ${provider.id}`,
2910
3341
  }));
2911
3342
  }
3343
+ function buildSkillItems() {
3344
+ return skills.summaries().map((skill) => {
3345
+ const tags = skill.tags && skill.tags.length > 0 ? ` · ${skill.tags.join(", ")}` : "";
3346
+ const category = skill.source === "project"
3347
+ ? "Project skills"
3348
+ : skill.source === "configured"
3349
+ ? "Configured skills"
3350
+ : "User skills";
3351
+ return {
3352
+ label: skill.name,
3353
+ detail: `${skill.description}${tags}`,
3354
+ value: skill.name,
3355
+ command: `/${skill.name}`,
3356
+ action: "insert-skill",
3357
+ category,
3358
+ footer: skill.source,
3359
+ };
3360
+ });
3361
+ }
2912
3362
  function buildProviderConnectItems() {
2913
3363
  const configuredProviders = registry.getConfigured();
2914
3364
  const configured = new Map(configuredProviders.map((provider) => [provider.id, provider]));
@@ -3002,17 +3452,19 @@ function OpenTuiApp(props) {
3002
3452
  addMessage("error", "No model selected. Use /model after /login or provider setup.");
3003
3453
  return;
3004
3454
  }
3455
+ rememberPromptHistory(displayInput);
3005
3456
  const nextMessages = [...displayMessages, { role: "user", content: displayInput }];
3006
3457
  displayMessages = nextMessages;
3007
3458
  streamingDisplay = undefined;
3008
3459
  redrawTranscript(undefined, nextMessages);
3009
- setRunningState(true);
3460
+ const run = beginAgentRun();
3010
3461
  let assistantContent = "";
3011
3462
  let assistantReasoning = "";
3012
3463
  const toolCalls = [];
3013
3464
  let runError;
3465
+ let runCancelled = false;
3014
3466
  try {
3015
- for await (const event of props.agent.run(actualInput, props.args.cwd)) {
3467
+ for await (const event of props.agent.run(actualInput, props.args.cwd, { abortSignal: run.abortController.signal })) {
3016
3468
  if (event.type === "turn_start") {
3017
3469
  assistantContent = "";
3018
3470
  assistantReasoning = "";
@@ -3082,6 +3534,7 @@ function OpenTuiApp(props) {
3082
3534
  }
3083
3535
  else if (event.type === "todos_updated") {
3084
3536
  setTodos(event.todos);
3537
+ syncSidebarTodos(event.todos);
3085
3538
  bumpSidebar();
3086
3539
  }
3087
3540
  else if (event.type === "mode_changed") {
@@ -3092,6 +3545,7 @@ function OpenTuiApp(props) {
3092
3545
  else if (event.type === "turn_end") {
3093
3546
  if (event.usage) {
3094
3547
  setSidebarUsage((current) => ({
3548
+ contextTokens: event.usage.promptTokens || current.contextTokens,
3095
3549
  promptTokens: current.promptTokens + event.usage.promptTokens,
3096
3550
  completionTokens: current.completionTokens + event.usage.completionTokens,
3097
3551
  promptCacheHitTokens: current.promptCacheHitTokens + (event.usage.promptCacheHitTokens ?? 0),
@@ -3121,13 +3575,16 @@ function OpenTuiApp(props) {
3121
3575
  }
3122
3576
  }
3123
3577
  catch (error) {
3124
- runError = error?.message || String(error);
3578
+ runCancelled = error instanceof AgentAbortError || run.abortController.signal.aborted || error?.name === "AbortError";
3579
+ if (!runCancelled) {
3580
+ runError = error?.message || String(error);
3581
+ }
3125
3582
  }
3126
3583
  finally {
3127
3584
  pendingApprovalRef = undefined;
3128
3585
  setPendingApproval(undefined);
3129
3586
  setApprovalOptionIdx(0);
3130
- setRunningState(false);
3587
+ finishAgentRun(run);
3131
3588
  streamingDisplay = undefined;
3132
3589
  if (runError) {
3133
3590
  const errorMessage = runError;
@@ -3135,6 +3592,11 @@ function OpenTuiApp(props) {
3135
3592
  displayMessages = nextMessages;
3136
3593
  redrawTranscript(undefined, nextMessages);
3137
3594
  }
3595
+ else if (runCancelled) {
3596
+ if (!notice())
3597
+ setNotice("Agent run cancelled");
3598
+ redrawTranscript();
3599
+ }
3138
3600
  else {
3139
3601
  redrawTranscript();
3140
3602
  }
@@ -3145,24 +3607,20 @@ function OpenTuiApp(props) {
3145
3607
  }
3146
3608
  }
3147
3609
  function promptUiKeyDown(event) {
3148
- if (handleApprovalKey(event))
3149
- return true;
3150
- if (handleQuestionKey(event))
3151
- return true;
3152
- const name = String(event.name || "").toLowerCase();
3153
- const plan = pendingPlan();
3154
- if (plan && (name === "left" || name === "right" || name === "h" || name === "l")) {
3155
- const idx = approvalOptionIdx();
3156
- const next = name === "left" || name === "h"
3157
- ? (idx - 1 + PLAN_OPTIONS.length) % PLAN_OPTIONS.length
3158
- : (idx + 1) % PLAN_OPTIONS.length;
3159
- setApprovalOptionIdx(next);
3160
- forceApprovalUI();
3161
- event.preventDefault?.();
3610
+ if (routeRunningCancel(keyNameFromEvent(event), event))
3162
3611
  return true;
3612
+ const modalOwner = activeModalKeyOwner();
3613
+ if (modalOwner) {
3614
+ if (routeModalKey(event) || shouldModalSwallowUnhandledKey(modalOwner)) {
3615
+ event.preventDefault?.();
3616
+ event.stopPropagation?.();
3617
+ return true;
3618
+ }
3163
3619
  }
3164
3620
  if (cycleModeFromKey(event))
3165
3621
  return true;
3622
+ if (handlePromptHistoryKey(event))
3623
+ return true;
3166
3624
  return false;
3167
3625
  }
3168
3626
  function renderComposer() {
@@ -3267,6 +3725,8 @@ function OpenTuiApp(props) {
3267
3725
  redrawQuestionPanel();
3268
3726
  },
3269
3727
  visible: false,
3728
+ focusable: true,
3729
+ onKeyDown: handleQuestionKey,
3270
3730
  position: "absolute",
3271
3731
  left: 2,
3272
3732
  right: 2,
@@ -3649,7 +4109,6 @@ function OpenTuiApp(props) {
3649
4109
  const context = sidebarContextState();
3650
4110
  const mcpStates = sidebarMcpStates();
3651
4111
  const files = gitState().files;
3652
- const activeTodos = todos().filter((todo) => todo.status !== "completed");
3653
4112
  return h("box", {
3654
4113
  ref: (ref) => {
3655
4114
  sidebarShell = ref;
@@ -3680,7 +4139,7 @@ function OpenTuiApp(props) {
3680
4139
  flexShrink: 0,
3681
4140
  ref: (ref) => {
3682
4141
  sidebarGaugeLabelText = ref;
3683
- ref.content = buildGaugeLabel(context.percent);
4142
+ ref.content = buildGaugeLabel(context.percent, context.remainingTokens);
3684
4143
  },
3685
4144
  }),
3686
4145
  h("text", {
@@ -3735,7 +4194,7 @@ function OpenTuiApp(props) {
3735
4194
  ? renderSidebarSection("Compactions", [
3736
4195
  h("text", { fg: theme.info, wrapMode: "word" }, `${currentTranscriptMessages().filter((m) => m.syntheticKind === "ui_compact_card").length} in this session`),
3737
4196
  ])
3738
- : null, renderSidebarMcp(mcpStates), renderSidebarLsp(), activeTodos.length ? renderSidebarTodos(activeTodos) : null, renderSidebarFiles(files))),
4197
+ : null, renderSidebarMcp(mcpStates), renderSidebarLsp(), renderSidebarTodos(todos()), renderSidebarFiles(files))),
3739
4198
  renderSidebarFooter(),
3740
4199
  ]);
3741
4200
  }
@@ -3820,11 +4279,37 @@ function OpenTuiApp(props) {
3820
4279
  }),
3821
4280
  ]);
3822
4281
  }
3823
- function renderSidebarTodos(activeTodos) {
3824
- return renderSidebarSection("Todo", activeTodos.slice(0, 6).map((todo) => {
3825
- const marker = todo.status === "in_progress" ? ">" : "o";
3826
- const color = todo.status === "in_progress" ? theme.primary : theme.textMuted;
3827
- return h("text", { fg: color, wrapMode: "word" }, `${marker} ${todo.activeForm || todo.content}`);
4282
+ function renderSidebarTodos(todos) {
4283
+ const visible = todos.slice(0, 8);
4284
+ return h("box", {
4285
+ flexDirection: "column",
4286
+ flexShrink: 0,
4287
+ visible: visible.length > 0,
4288
+ ref: (ref) => {
4289
+ sidebarTodoSection = ref;
4290
+ syncSidebarTodos();
4291
+ },
4292
+ }, h("text", { fg: theme.text }, "Todo"), ...Array.from({ length: 8 }, (_, index) => {
4293
+ const todo = visible[index];
4294
+ const completed = todo?.status === "completed";
4295
+ const inProgress = todo?.status === "in_progress";
4296
+ const labelText = todo
4297
+ ? (inProgress ? (todo.activeForm || todo.content) : todo.content)
4298
+ : "";
4299
+ return h("box", {
4300
+ flexDirection: "row",
4301
+ gap: 1,
4302
+ visible: !!todo,
4303
+ ref: (ref) => { sidebarTodoRows[index] = ref; },
4304
+ }, h("text", {
4305
+ fg: completed ? theme.success : inProgress ? theme.warning : theme.textMuted,
4306
+ flexShrink: 0,
4307
+ ref: (ref) => { sidebarTodoMarkers[index] = ref; },
4308
+ }, completed ? "✓" : inProgress ? "◉" : "○"), h("text", {
4309
+ fg: completed ? theme.success : inProgress ? theme.warning : theme.textMuted,
4310
+ wrapMode: "word",
4311
+ ref: (ref) => { sidebarTodoLabels[index] = ref; },
4312
+ }, labelText));
3828
4313
  }));
3829
4314
  }
3830
4315
  function renderSidebarFiles(files) {
@@ -3873,6 +4358,13 @@ function OpenTuiApp(props) {
3873
4358
  ? getContextBudget(providerId, modelId, props.agent.messages)
3874
4359
  : undefined;
3875
4360
  const usage = sidebarUsage();
4361
+ const contextTokens = usage.contextTokens || (budget?.estimatedTokens ?? 0);
4362
+ const contextPercent = budget?.contextWindow
4363
+ ? Math.min(100, Math.round((contextTokens / budget.contextWindow) * 100))
4364
+ : Math.round(budget?.percent ?? 0);
4365
+ const remainingTokens = budget?.contextWindow !== undefined
4366
+ ? Math.max(0, budget.contextWindow - contextTokens)
4367
+ : undefined;
3876
4368
  const tokenUsage = {
3877
4369
  promptTokens: usage.promptTokens,
3878
4370
  completionTokens: usage.completionTokens,
@@ -3883,8 +4375,9 @@ function OpenTuiApp(props) {
3883
4375
  };
3884
4376
  const cost = providerId && modelId ? calculateUsageCost(providerId, modelId, tokenUsage) : undefined;
3885
4377
  return {
3886
- tokens: budget?.estimatedTokens ?? 0,
3887
- percent: Math.round(budget?.percent ?? 0),
4378
+ tokens: contextTokens,
4379
+ percent: contextPercent,
4380
+ remainingTokens,
3888
4381
  promptTokens: usage.promptTokens,
3889
4382
  completionTokens: usage.completionTokens,
3890
4383
  reasoningTokens: usage.reasoningTokens,
@@ -3914,6 +4407,7 @@ function OpenTuiApp(props) {
3914
4407
  ref: (ref) => { scrollbox = ref; },
3915
4408
  stickyScroll: true,
3916
4409
  stickyStart: "bottom",
4410
+ onMouseScroll: handleTranscriptMouseScroll,
3917
4411
  flexGrow: 1,
3918
4412
  minHeight: 0,
3919
4413
  }, h("box", { height: 1 }), h("box", {
@@ -3925,7 +4419,8 @@ function OpenTuiApp(props) {
3925
4419
  updateTranscriptHost(ref, transcriptState, currentTranscriptMessages(streamingDisplay), transcriptOptions(), props.syntaxStyle, props.subtleSyntaxStyle);
3926
4420
  syncThinkingSpinner();
3927
4421
  syncPromptSurfaces(isNewHost);
3928
- setTimeout(() => scrollbox?.scrollTo(scrollbox.scrollHeight), 0);
4422
+ if (isNewHost)
4423
+ scheduleTranscriptScrollAfterUpdate(transcriptScrollFollowing, 0);
3929
4424
  },
3930
4425
  flexDirection: "column",
3931
4426
  flexShrink: 0,
@@ -3935,6 +4430,8 @@ function OpenTuiApp(props) {
3935
4430
  }, notice()) : null, renderQuestionPanelHost(), h("box", {
3936
4431
  ref: (ref) => { approvalRoot = ref; },
3937
4432
  visible: !!approval,
4433
+ focusable: true,
4434
+ onKeyDown: handleApprovalKey,
3938
4435
  position: "absolute",
3939
4436
  left: 2,
3940
4437
  right: 2,
@@ -4046,9 +4543,38 @@ function OpenTuiApp(props) {
4046
4543
  ref: (ref) => { approvalOptionTexts[index] = ref; },
4047
4544
  fg: theme.textMuted,
4048
4545
  content: "",
4049
- })))), h("box", { flexDirection: "row", gap: 2, flexShrink: 0 }, h("text", { fg: theme.text }, " ", h("span", { fg: theme.textMuted }, "select")), h("text", { fg: theme.text }, "enter ", h("span", { fg: theme.textMuted }, "confirm")), h("text", { fg: theme.text }, "esc ", h("span", { fg: theme.textMuted }, "reject")))))),
4546
+ })))), h("box", { flexDirection: "row", gap: 2, flexShrink: 0 }, h("text", { fg: theme.text }, "⇆/tab ", h("span", { fg: theme.textMuted }, "select")), h("text", { fg: theme.text }, "enter ", h("span", { fg: theme.textMuted }, "confirm")), h("text", { fg: theme.text }, "esc ", h("span", { fg: theme.textMuted }, "reject")))))),
4050
4547
  ]);
4051
4548
  }
4549
+ function renderNoticeOverlay() {
4550
+ return h("box", {
4551
+ ref: (ref) => {
4552
+ copyToastRoot = ref;
4553
+ ref.visible = false;
4554
+ },
4555
+ visible: false,
4556
+ position: "absolute",
4557
+ top: 2,
4558
+ right: sidebarVisible() ? SESSION_SIDEBAR_WIDTH + 2 : 2,
4559
+ zIndex: 4000,
4560
+ flexDirection: "column",
4561
+ alignItems: "flex-start",
4562
+ width: 24,
4563
+ paddingLeft: 2,
4564
+ paddingRight: 2,
4565
+ paddingTop: 1,
4566
+ paddingBottom: 1,
4567
+ backgroundColor: theme.backgroundPanel,
4568
+ border: ["left", "right"],
4569
+ borderColor: theme.info,
4570
+ }, h("text", {
4571
+ ref: (ref) => { copyToastText = ref; },
4572
+ fg: theme.text,
4573
+ wrapMode: "word",
4574
+ width: "100%",
4575
+ content: "",
4576
+ }));
4577
+ }
4052
4578
  return h("box", {
4053
4579
  ref: (ref) => { rootBox = ref; },
4054
4580
  flexDirection: "column",
@@ -4080,6 +4606,7 @@ function OpenTuiApp(props) {
4080
4606
  registerModeBadge: registerFooterModeBadge,
4081
4607
  }),
4082
4608
  renderProviderDialog(),
4609
+ renderNoticeOverlay(),
4083
4610
  ]);
4084
4611
  }
4085
4612
  function renderPrompt(input) {
@@ -4788,10 +5315,50 @@ function createModelSwitchEntry(ctx, model, key, signature) {
4788
5315
  ]);
4789
5316
  return { key, signature, node, refs: {} };
4790
5317
  }
5318
+ function createTodoWriteRenderable(ctx, tool) {
5319
+ const todos = tool.args.todos || [];
5320
+ const summary = tool.result || "";
5321
+ if (!isToolFinished(tool)) {
5322
+ return createBox(ctx, {
5323
+ paddingLeft: 3,
5324
+ marginTop: 1,
5325
+ flexDirection: "column",
5326
+ flexShrink: 0,
5327
+ }, [
5328
+ createText(ctx, `~ → Planning tasks...`, { fg: toolColor(tool) }),
5329
+ ]);
5330
+ }
5331
+ return createBox(ctx, {
5332
+ border: ["left"],
5333
+ borderColor: theme.borderSubtle,
5334
+ backgroundColor: theme.backgroundPanel,
5335
+ marginTop: 1,
5336
+ paddingTop: 1,
5337
+ paddingBottom: 1,
5338
+ paddingLeft: 2,
5339
+ flexDirection: "column",
5340
+ flexShrink: 0,
5341
+ }, [
5342
+ createText(ctx, `# Todo ${summary ? `— ${summary}` : ""}`, { fg: theme.textMuted }),
5343
+ ...todos.map((todo, index) => {
5344
+ const completed = todo.status === "completed";
5345
+ const inProgress = todo.status === "in_progress";
5346
+ const marker = completed ? "✓" : inProgress ? "◉" : "○";
5347
+ const fg = completed ? theme.success : inProgress ? theme.warning : theme.textMuted;
5348
+ return createText(ctx, ` ${marker} ${todo.content}`, {
5349
+ fg,
5350
+ marginTop: index === 0 ? 1 : 0,
5351
+ });
5352
+ }),
5353
+ ]);
5354
+ }
4791
5355
  function createToolRenderable(ctx, tool, syntaxStyle, width = 80) {
4792
5356
  if (tool.name === "question") {
4793
5357
  return createQuestionToolRenderable(ctx, tool);
4794
5358
  }
5359
+ if (tool.name === "todo_write") {
5360
+ return createTodoWriteRenderable(ctx, tool);
5361
+ }
4795
5362
  const icon = tool.name === "bash" ? "$" : tool.name === "edit" || tool.name === "write" ? "✎" : "●";
4796
5363
  const color = toolColor(tool);
4797
5364
  const header = toolHeader(tool);
@@ -4988,9 +5555,9 @@ function renderFooter(input) {
4988
5555
  idleFg: theme.textMuted,
4989
5556
  runningFg: theme.primary,
4990
5557
  }), h("text", {
4991
- fg: theme.warning,
5558
+ fg: permissionModeColor(input.mode()),
4992
5559
  ref: input.registerModeBadge,
4993
- }, input.mode() !== "default" ? ` ${input.mode()} · tab` : ""), h("box", { flexGrow: 1 }));
5560
+ }, footerPermissionModeText(input.mode())), h("box", { flexGrow: 1 }));
4994
5561
  }
4995
5562
  function pickerTitle(kind, providerId) {
4996
5563
  switch (kind) {
@@ -5012,6 +5579,8 @@ function pickerTitle(kind, providerId) {
5012
5579
  return "Select Login Provider";
5013
5580
  case "logout":
5014
5581
  return "Select Logout Provider";
5582
+ case "skill":
5583
+ return "Skills";
5015
5584
  case "slash":
5016
5585
  return "Commands";
5017
5586
  case "file":
@@ -5447,13 +6016,37 @@ function contrastText(color) {
5447
6016
  return luminance > 160 ? "#000000" : "#ffffff";
5448
6017
  }
5449
6018
  function promptModeBadgeContent(mode) {
5450
- const isPlan = mode === "plan";
5451
- const color = isPlan ? theme.accent : theme.primary;
5452
- const label = isPlan ? "Plan" : "Build";
6019
+ const color = permissionModeColor(mode);
6020
+ const label = permissionModeBadgeLabel(mode);
5453
6021
  return new StyledText([
5454
6022
  bg(color)(fg(contrastText(color))(bold(` ${label} `))),
5455
6023
  ]);
5456
6024
  }
6025
+ function permissionModeBadgeLabel(mode) {
6026
+ switch (mode) {
6027
+ case "default": return "Build";
6028
+ case "plan": return "Plan";
6029
+ case "bypassPermissions": return "Bypass";
6030
+ }
6031
+ }
6032
+ function footerPermissionModeText(mode) {
6033
+ const info = PERMISSION_MODE_INFO[mode];
6034
+ if (mode === "default")
6035
+ return " mode: build · shift+tab plan";
6036
+ if (mode === "plan")
6037
+ return " mode: plan · shift+tab bypass";
6038
+ return ` mode: ${info.shortTitle} · shift+tab build`;
6039
+ }
6040
+ function permissionModeColor(mode) {
6041
+ const info = PERMISSION_MODE_INFO[mode];
6042
+ switch (info.color) {
6043
+ case "accent": return theme.accent;
6044
+ case "success": return theme.success;
6045
+ case "warning": return theme.warning;
6046
+ case "error": return theme.error;
6047
+ case "muted": return theme.primary;
6048
+ }
6049
+ }
5457
6050
  function toolColor(tool) {
5458
6051
  if (tool.isError)
5459
6052
  return theme.toolError;
@@ -5501,8 +6094,10 @@ function extractToolDiff(tool) {
5501
6094
  const index = tool.result.indexOf(marker);
5502
6095
  if (index === -1)
5503
6096
  return undefined;
5504
- const diff = tool.result.slice(index + marker.length).trim();
5505
- return diff ? diff : undefined;
6097
+ const rawDiff = tool.result.slice(index + marker.length);
6098
+ const diagnosticsIndex = rawDiff.search(/\n\nLSP diagnostics in /);
6099
+ const diff = diagnosticsIndex === -1 ? rawDiff : rawDiff.slice(0, diagnosticsIndex);
6100
+ return diff.trim().length > 0 ? diff : undefined;
5506
6101
  }
5507
6102
  function diffViewMode(width = 80) {
5508
6103
  return width > 120 ? "split" : "unified";
@@ -5622,18 +6217,6 @@ function assistantStatusLabel(message) {
5622
6217
  return "Responding...";
5623
6218
  return message.streaming ? "Thinking..." : "Thinking";
5624
6219
  }
5625
- function buildColoredGauge(percent, barWidth) {
5626
- const clamped = Math.max(0, Math.min(100, percent));
5627
- const filled = Math.round((clamped / 100) * barWidth);
5628
- const empty = barWidth - filled;
5629
- const gaugeColor = clamped >= 80 ? theme.error : clamped >= 60 ? theme.warning : theme.success;
5630
- const chunks = [];
5631
- if (filled > 0)
5632
- chunks.push(fg(gaugeColor)("█".repeat(filled)));
5633
- if (empty > 0)
5634
- chunks.push(fg(theme.borderSubtle)("░".repeat(empty)));
5635
- return new StyledText(chunks);
5636
- }
5637
6220
  function buildContextGauge(percent, barWidth) {
5638
6221
  const clamped = Math.max(0, Math.min(100, percent));
5639
6222
  const filled = Math.round((clamped / 100) * barWidth);
@@ -5642,17 +6225,27 @@ function buildContextGauge(percent, barWidth) {
5642
6225
  const space = empty > 0 ? "░".repeat(empty) : "";
5643
6226
  return `${block}${space}`;
5644
6227
  }
5645
- function buildGaugeLabel(percent) {
6228
+ function buildGaugeLabel(percent, remainingTokens) {
6229
+ const remaining = remainingTokens === undefined ? "" : ` · ${formatContextRemaining(remainingTokens)} left`;
5646
6230
  if (percent >= 95)
5647
- return "⚠ Compact imminent";
6231
+ return `⚠ Compact imminent${remaining}`;
5648
6232
  if (percent >= 80)
5649
- return "⚠ Approaching limit";
6233
+ return `⚠ Approaching limit${remaining}`;
5650
6234
  if (percent >= 60)
5651
- return "● Context growing";
6235
+ return `● Context growing${remaining}`;
5652
6236
  if (percent > 0)
5653
- return "○ Available";
6237
+ return `○ Available${remaining}`;
5654
6238
  return "○ Empty";
5655
6239
  }
6240
+ function formatContextRemaining(value) {
6241
+ if (value >= 1_000_000)
6242
+ return `${(value / 1_000_000).toFixed(2)}M`;
6243
+ if (value >= 100_000)
6244
+ return `${Math.round(value / 1_000)}K`;
6245
+ if (value >= 1_000)
6246
+ return `${(value / 1_000).toFixed(1)}K`;
6247
+ return String(value);
6248
+ }
5656
6249
  function thinkingToggleLabel(expanded, streaming = false, spinnerFrame = "") {
5657
6250
  const arrow = expanded ? "▼" : "▶";
5658
6251
  return streaming && spinnerFrame ? `${spinnerFrame} ${arrow} Thinking` : `${arrow} Thinking`;