@bubblebrain-ai/bubble 0.0.1 → 0.0.2

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;
@@ -367,15 +395,211 @@ function OpenTuiApp(props) {
367
395
  const activePrompt = () => isHomeSurfaceActive()
368
396
  ? homePromptRef ?? sessionPromptRef
369
397
  : sessionPromptRef ?? homePromptRef;
398
+ function setPromptText(value) {
399
+ promptText = value;
400
+ const prompt = activePrompt();
401
+ if (!prompt)
402
+ return;
403
+ prompt.setText(value);
404
+ prompt.cursorOffset = value.length;
405
+ prompt.focus();
406
+ }
407
+ function resetPromptHistoryBrowse() {
408
+ promptHistoryIndex = undefined;
409
+ promptHistoryDraft = "";
410
+ }
411
+ function rememberPromptHistory(input) {
412
+ const value = input.trimEnd();
413
+ if (!value.trim())
414
+ return;
415
+ promptHistory.push(value);
416
+ if (promptHistory.length > PROMPT_HISTORY_LIMIT) {
417
+ promptHistory = promptHistory.slice(-PROMPT_HISTORY_LIMIT);
418
+ }
419
+ resetPromptHistoryBrowse();
420
+ }
421
+ function canBrowsePromptHistory(direction) {
422
+ if (promptHistoryIndex !== undefined)
423
+ return true;
424
+ const prompt = activePrompt();
425
+ const text = prompt?.plainText ?? promptText;
426
+ if (!text)
427
+ return true;
428
+ const cursor = prompt?.logicalCursor;
429
+ if (!cursor)
430
+ return true;
431
+ if (direction === "up")
432
+ return cursor.row === 0;
433
+ return cursor.row >= text.split("\n").length - 1;
434
+ }
435
+ function browsePromptHistory(direction) {
436
+ if (!promptHistory.length)
437
+ return false;
438
+ if (!canBrowsePromptHistory(direction))
439
+ return false;
440
+ if (direction === "up") {
441
+ if (promptHistoryIndex === undefined) {
442
+ promptHistoryDraft = readPromptText() || promptText;
443
+ promptHistoryIndex = promptHistory.length - 1;
444
+ }
445
+ else {
446
+ promptHistoryIndex = Math.max(0, promptHistoryIndex - 1);
447
+ }
448
+ setPromptText(promptHistory[promptHistoryIndex] ?? "");
449
+ return true;
450
+ }
451
+ if (promptHistoryIndex === undefined)
452
+ return false;
453
+ if (promptHistoryIndex < promptHistory.length - 1) {
454
+ promptHistoryIndex += 1;
455
+ setPromptText(promptHistory[promptHistoryIndex] ?? "");
456
+ }
457
+ else {
458
+ setPromptText(promptHistoryDraft);
459
+ resetPromptHistoryBrowse();
460
+ }
461
+ return true;
462
+ }
463
+ function handlePromptHistoryKey(event) {
464
+ if (event.shift || event.ctrl || event.meta || event.super || event.hyper)
465
+ return false;
466
+ const name = keyNameFromEvent(event);
467
+ if (name !== "up" && name !== "down")
468
+ return false;
469
+ if (!browsePromptHistory(name))
470
+ return false;
471
+ event.preventDefault?.();
472
+ event.stopPropagation?.();
473
+ return true;
474
+ }
475
+ function blurInputsForModal() {
476
+ homePromptRef?.blur();
477
+ sessionPromptRef?.blur();
478
+ questionCustomInput?.blur();
479
+ providerDialogInput?.blur();
480
+ }
481
+ function focusApprovalPanel() {
482
+ setTimeout(() => {
483
+ if (pendingApproval() || pendingPlan())
484
+ approvalRoot?.focus();
485
+ }, 0);
486
+ }
487
+ function focusQuestionPanel() {
488
+ setTimeout(() => {
489
+ const state = pendingQuestion();
490
+ if (!state || state.editing)
491
+ return;
492
+ questionRoot?.focus();
493
+ }, 0);
494
+ }
495
+ function restorePromptAfterModal() {
496
+ setTimeout(() => {
497
+ if (!activeModalKeyOwner())
498
+ activePrompt()?.focus();
499
+ }, 0);
500
+ }
370
501
  const activeComposerShell = () => isHomeSurfaceActive()
371
502
  ? homeComposerShell ?? sessionComposerShell
372
503
  : sessionComposerShell ?? homeComposerShell;
373
504
  onCleanup(() => {
374
505
  uiDisposed = true;
506
+ if (copyToastClearTimer)
507
+ clearTimeout(copyToastClearTimer);
375
508
  promptModeLabels.clear();
376
509
  promptModelLabels.clear();
377
510
  footerModeBadge = undefined;
378
511
  });
512
+ function showCopyToast(toast, ttl = 2200) {
513
+ if (copyToastClearTimer)
514
+ clearTimeout(copyToastClearTimer);
515
+ const sidebarOffset = sidebarVisible() ? SESSION_SIDEBAR_WIDTH : 0;
516
+ const mainAreaWidth = Math.max(20, dimensions().width - sidebarOffset - 4);
517
+ const color = toast.variant === "success"
518
+ ? theme.success
519
+ : toast.variant === "error"
520
+ ? theme.error
521
+ : toast.variant === "warning"
522
+ ? theme.warning
523
+ : theme.info;
524
+ const width = Math.max(24, Math.min(60, Math.min(mainAreaWidth, toast.message.length + 6)));
525
+ if (copyToastRoot) {
526
+ copyToastRoot.visible = true;
527
+ copyToastRoot.width = width;
528
+ copyToastRoot.right = sidebarOffset + 2;
529
+ copyToastRoot.borderColor = color;
530
+ }
531
+ if (copyToastText) {
532
+ copyToastText.fg = theme.text;
533
+ safeSetText(copyToastText, toast.message);
534
+ }
535
+ renderer.requestRender();
536
+ copyToastClearTimer = setTimeout(() => {
537
+ if (copyToastRoot)
538
+ copyToastRoot.visible = false;
539
+ renderer.requestRender();
540
+ copyToastClearTimer = undefined;
541
+ }, ttl);
542
+ }
543
+ async function copySelectionText(text) {
544
+ const now = Date.now();
545
+ if (!text.trim())
546
+ return;
547
+ if (text === lastCopiedSelection && now - lastCopiedSelectionAt < 350)
548
+ return;
549
+ const serial = ++selectionCopySerial;
550
+ let copied = false;
551
+ try {
552
+ await copyTextToClipboard(text);
553
+ copied = true;
554
+ }
555
+ catch {
556
+ try {
557
+ copied = renderer.copyToClipboardOSC52(text);
558
+ }
559
+ catch {
560
+ copied = false;
561
+ }
562
+ }
563
+ if (serial !== selectionCopySerial)
564
+ return;
565
+ if (copied) {
566
+ lastCopiedSelection = text;
567
+ lastCopiedSelectionAt = Date.now();
568
+ showCopyToast({ message: "Copied to clipboard", variant: "info" });
569
+ }
570
+ else {
571
+ showCopyToast({ message: "Failed to copy selection", variant: "error" }, 3000);
572
+ }
573
+ }
574
+ function isInsideRenderable(renderable, container) {
575
+ if (!container)
576
+ return false;
577
+ let current = renderable;
578
+ while (current) {
579
+ if (current === container)
580
+ return true;
581
+ current = current.parent;
582
+ }
583
+ return false;
584
+ }
585
+ function getOpenTuiSelectionText(selection) {
586
+ const selectedRenderables = Array.isArray(selection?.selectedRenderables)
587
+ ? [...selection.selectedRenderables]
588
+ : undefined;
589
+ if (!selectedRenderables?.length) {
590
+ return typeof selection?.getSelectedText === "function" ? selection.getSelectedText() : "";
591
+ }
592
+ return selectedRenderables
593
+ .filter((renderable) => !renderable.isDestroyed && !isInsideRenderable(renderable, sidebarShell))
594
+ .sort((a, b) => {
595
+ if (a.y !== b.y)
596
+ return a.y - b.y;
597
+ return a.x - b.x;
598
+ })
599
+ .map((renderable) => typeof renderable.getSelectedText === "function" ? renderable.getSelectedText() : "")
600
+ .filter(Boolean)
601
+ .join("\n");
602
+ }
379
603
  const readPromptText = () => {
380
604
  try {
381
605
  return activePrompt()?.plainText ?? "";
@@ -422,10 +646,13 @@ function OpenTuiApp(props) {
422
646
  setSidebarText(sidebarTokenText, `${formatCompactNumber(context.tokens)} tokens`);
423
647
  setSidebarText(sidebarPercentText, `${context.percent}% used`);
424
648
  if (sidebarGaugeText) {
425
- sidebarGaugeText.content = buildColoredGauge(context.percent, 30);
649
+ sidebarGaugeText.content = buildContextGauge(context.percent, 30);
650
+ sidebarGaugeText.requestRender();
426
651
  }
427
652
  if (sidebarGaugeLabelText) {
428
- sidebarGaugeLabelText.content = buildGaugeLabel(context.percent);
653
+ sidebarGaugeLabelText.content = buildGaugeLabel(context.percent, context.remainingTokens);
654
+ sidebarGaugeLabelText.fg = context.percent >= 80 ? theme.error : context.percent >= 60 ? theme.warning : theme.success;
655
+ sidebarGaugeLabelText.requestRender();
429
656
  }
430
657
  setSidebarText(sidebarUsageText, context.turns > 0
431
658
  ? `${formatCompactNumber(context.promptTokens)} in · ${formatCompactNumber(context.completionTokens)} out`
@@ -517,7 +744,7 @@ function OpenTuiApp(props) {
517
744
  }
518
745
  const promptModeTitle = () => mode() === "plan" ? "Plan" : "Build";
519
746
  const promptModeBadge = () => promptModeBadgeContent(mode());
520
- const footerModeText = () => mode() !== "default" ? ` ${mode()} · tab` : "";
747
+ const footerModeText = () => footerPermissionModeText(mode());
521
748
  function syncModeChrome() {
522
749
  if (uiDisposed)
523
750
  return;
@@ -525,8 +752,11 @@ function OpenTuiApp(props) {
525
752
  if (!safeSetText(label, promptModeBadge()))
526
753
  promptModeLabels.delete(label);
527
754
  }
528
- if (footerModeBadge && !safeSetText(footerModeBadge, footerModeText()))
529
- footerModeBadge = undefined;
755
+ if (footerModeBadge) {
756
+ footerModeBadge.fg = permissionModeColor(mode());
757
+ if (!safeSetText(footerModeBadge, footerModeText()))
758
+ footerModeBadge = undefined;
759
+ }
530
760
  safeRequestRender(homeComposerShell);
531
761
  safeRequestRender(sessionComposerShell);
532
762
  safeRequestRender(rootBox);
@@ -567,10 +797,10 @@ function OpenTuiApp(props) {
567
797
  const cycleMode = () => {
568
798
  if (picker || pendingPlan())
569
799
  return false;
570
- const next = getNextPermissionMode(props.agent.mode, { bypassEnabled: props.options.bypassEnabled });
800
+ const next = getNextPermissionMode(props.agent.mode);
571
801
  props.agent.setMode(next);
572
802
  setMode(next);
573
- setNotice(`Mode: ${next === "plan" ? "Plan" : "Build"}`);
803
+ setNotice(`Mode: ${permissionModeBadgeLabel(next)}`);
574
804
  redrawDock();
575
805
  syncPromptSurfaces();
576
806
  syncModeChrome();
@@ -584,9 +814,6 @@ function OpenTuiApp(props) {
584
814
  return true;
585
815
  };
586
816
  const cycleModeFromRawSequence = (sequence) => {
587
- const rawKey = keyNameFromSequence(sequence);
588
- if (rawKey && handleApprovalNavigation(rawKey))
589
- return true;
590
817
  if (!isModeCycleSequence(sequence))
591
818
  return false;
592
819
  return cycleMode();
@@ -599,40 +826,7 @@ function OpenTuiApp(props) {
599
826
  ? ["Allow once", "Allow always", "Reject"]
600
827
  : ["Allow once", "Reject"];
601
828
  };
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) => {
829
+ const modalKeyNameFromSequence = (sequence) => {
636
830
  const name = keyNameFromSequence(sequence);
637
831
  if (name)
638
832
  return name;
@@ -652,12 +846,16 @@ function OpenTuiApp(props) {
652
846
  const rejectPendingPlan = (plan) => {
653
847
  setPendingPlan(undefined);
654
848
  setApprovalOptionIdx(0);
849
+ forceApprovalUI();
850
+ restorePromptAfterModal();
655
851
  plan.resolve({ action: "reject", reason: "Rejected by user." });
656
852
  };
657
853
  const resolvePendingPlanSelection = (plan) => {
658
854
  const sel = approvalOptionIdx();
659
855
  setPendingPlan(undefined);
660
856
  setApprovalOptionIdx(0);
857
+ forceApprovalUI();
858
+ restorePromptAfterModal();
661
859
  if (sel === 0) {
662
860
  plan.resolve({ action: "approve", plan: plan.plan });
663
861
  }
@@ -710,6 +908,7 @@ function OpenTuiApp(props) {
710
908
  setPendingApproval(undefined);
711
909
  setApprovalOptionIdx(0);
712
910
  forceApprovalUI();
911
+ restorePromptAfterModal();
713
912
  if (choice === "Allow once") {
714
913
  approval.resolve({ action: "approve" });
715
914
  return true;
@@ -722,7 +921,7 @@ function OpenTuiApp(props) {
722
921
  approval.resolve({ action: "reject", feedback: "Rejected by user." });
723
922
  return true;
724
923
  };
725
- const handleApprovalNavigation = (name, preventOnly = false) => {
924
+ const handleApprovalNavigation = (name, preventOnly = false, shift = false) => {
726
925
  const approval = pendingApproval();
727
926
  if (approval) {
728
927
  const opts = approvalOptionsFor(approval.request);
@@ -734,6 +933,10 @@ function OpenTuiApp(props) {
734
933
  moveApprovalOption(1, opts.length);
735
934
  return true;
736
935
  }
936
+ if (name === "tab") {
937
+ moveApprovalOption(shift ? -1 : 1, opts.length);
938
+ return true;
939
+ }
737
940
  if (name === "enter") {
738
941
  if (!preventOnly)
739
942
  resolveApprovalSelection();
@@ -745,6 +948,7 @@ function OpenTuiApp(props) {
745
948
  setPendingApproval(undefined);
746
949
  setApprovalOptionIdx(0);
747
950
  forceApprovalUI();
951
+ restorePromptAfterModal();
748
952
  approval.resolve({ action: "reject", feedback: "Rejected by user." });
749
953
  }
750
954
  return true;
@@ -760,6 +964,10 @@ function OpenTuiApp(props) {
760
964
  moveApprovalOption(1, PLAN_OPTIONS.length);
761
965
  return true;
762
966
  }
967
+ if (name === "tab") {
968
+ moveApprovalOption(shift ? -1 : 1, PLAN_OPTIONS.length);
969
+ return true;
970
+ }
763
971
  if (name === "enter") {
764
972
  if (!preventOnly)
765
973
  resolvePendingPlanSelection(plan);
@@ -775,7 +983,7 @@ function OpenTuiApp(props) {
775
983
  };
776
984
  const handleApprovalKey = (event) => {
777
985
  const name = keyNameFromEvent(event);
778
- if (handleApprovalNavigation(name)) {
986
+ if (handleApprovalNavigation(name, false, !!event.shift)) {
779
987
  event.preventDefault?.();
780
988
  event.stopPropagation?.();
781
989
  return true;
@@ -787,6 +995,8 @@ function OpenTuiApp(props) {
787
995
  const plan = pendingPlan();
788
996
  syncPromptSurfaces();
789
997
  const prompt = activePrompt();
998
+ if (approval || plan)
999
+ blurInputsForModal();
790
1000
  if (prompt) {
791
1001
  if (approval) {
792
1002
  const options = approvalOptionsFor(approval.request);
@@ -803,6 +1013,8 @@ function OpenTuiApp(props) {
803
1013
  }
804
1014
  redrawDock();
805
1015
  redrawApprovalPanel();
1016
+ if (approval || plan)
1017
+ focusApprovalPanel();
806
1018
  redrawTranscript();
807
1019
  };
808
1020
  function questionStateFromRequest(request) {
@@ -851,15 +1063,67 @@ function OpenTuiApp(props) {
851
1063
  questionSyncTimers.add(timer);
852
1064
  }
853
1065
  }
1066
+ function transcriptMaxScrollTop() {
1067
+ if (!scrollbox)
1068
+ return 0;
1069
+ return Math.max(0, scrollbox.scrollHeight - scrollbox.viewport.height);
1070
+ }
1071
+ function isTranscriptAtBottom() {
1072
+ if (!scrollbox)
1073
+ return true;
1074
+ return scrollbox.scrollTop >= transcriptMaxScrollTop() - 1;
1075
+ }
1076
+ function updateTranscriptScrollFollowingFromPosition() {
1077
+ if (!scrollbox)
1078
+ return;
1079
+ transcriptScrollFollowing = isTranscriptAtBottom();
1080
+ transcriptScrollInitialized = true;
1081
+ }
1082
+ function shouldFollowTranscriptBeforeUpdate() {
1083
+ if (!scrollbox)
1084
+ return transcriptScrollFollowing;
1085
+ if (!transcriptScrollInitialized)
1086
+ return true;
1087
+ transcriptScrollFollowing = isTranscriptAtBottom();
1088
+ return transcriptScrollFollowing;
1089
+ }
1090
+ function scrollTranscriptToBottom() {
1091
+ if (!scrollbox)
1092
+ return;
1093
+ scrollbox.scrollTo(scrollbox.scrollHeight);
1094
+ transcriptScrollFollowing = true;
1095
+ transcriptScrollInitialized = true;
1096
+ }
1097
+ function scheduleTranscriptScrollAfterUpdate(shouldFollow, delay = 50) {
1098
+ setTimeout(() => {
1099
+ if (!scrollbox)
1100
+ return;
1101
+ if (shouldFollow && transcriptScrollFollowing) {
1102
+ scrollTranscriptToBottom();
1103
+ }
1104
+ else {
1105
+ updateTranscriptScrollFollowingFromPosition();
1106
+ }
1107
+ }, delay);
1108
+ }
1109
+ function handleTranscriptMouseScroll() {
1110
+ setTimeout(updateTranscriptScrollFollowingFromPosition, 0);
1111
+ }
854
1112
  function syncQuestionUI(focusCustom = false) {
855
1113
  redrawQuestionPanel();
856
1114
  syncPromptSurfaces();
1115
+ const question = pendingQuestion();
1116
+ if (question)
1117
+ blurInputsForModal();
857
1118
  redrawDock();
858
1119
  rootBox?.requestRender();
859
1120
  scrollbox?.requestRender();
860
1121
  if (focusCustom) {
861
1122
  setTimeout(() => questionCustomInput?.focus(), 0);
862
1123
  }
1124
+ else if (question) {
1125
+ focusQuestionPanel();
1126
+ }
863
1127
  }
864
1128
  function updateQuestionState(updater, focusCustom = false) {
865
1129
  setPendingQuestion((current) => current ? updater(current) : undefined);
@@ -1095,17 +1359,49 @@ function OpenTuiApp(props) {
1095
1359
  }
1096
1360
  return false;
1097
1361
  }
1098
- function handleQuestionRawSequence(sequence) {
1099
- const state = pendingQuestion();
1100
- if (!state || pendingApproval())
1362
+ function activeModalKeyOwner() {
1363
+ if (pendingApproval() || pendingPlan())
1364
+ return "approval";
1365
+ if (pendingQuestion())
1366
+ return "question";
1367
+ if (providerDialog)
1368
+ return "provider";
1369
+ if (picker)
1370
+ return "picker";
1371
+ return undefined;
1372
+ }
1373
+ function routeModalKey(event) {
1374
+ const owner = activeModalKeyOwner();
1375
+ if (!owner)
1101
1376
  return false;
1102
- const name = questionKeyNameFromSequence(sequence);
1103
- if (!name)
1377
+ switch (owner) {
1378
+ case "approval":
1379
+ return handleApprovalKey(event);
1380
+ case "question":
1381
+ return handleQuestionKey(event);
1382
+ case "provider":
1383
+ return handleProviderDialogKey(event);
1384
+ case "picker":
1385
+ return handlePickerKey(event);
1386
+ }
1387
+ }
1388
+ function shouldModalSwallowUnhandledKey(owner) {
1389
+ if (owner === "approval")
1390
+ return true;
1391
+ if (owner === "question") {
1392
+ const state = pendingQuestion();
1393
+ return !state?.editing || isQuestionConfirmTab(state);
1394
+ }
1395
+ return false;
1396
+ }
1397
+ function routeModalRawSequence(sequence) {
1398
+ const owner = activeModalKeyOwner();
1399
+ if (!owner)
1104
1400
  return false;
1105
- if (state.editing && !isQuestionConfirmTab(state) && name !== "escape" && name !== "enter") {
1401
+ const name = modalKeyNameFromSequence(sequence);
1402
+ if (!name)
1106
1403
  return false;
1107
- }
1108
- return handleQuestionKey({
1404
+ const handled = routeModalKey({
1109
1405
  name,
1110
1406
  key: name,
1111
1407
  input: sequence,
@@ -1115,11 +1411,13 @@ function OpenTuiApp(props) {
1115
1411
  preventDefault() { },
1116
1412
  stopPropagation() { },
1117
1413
  });
1414
+ return handled || shouldModalSwallowUnhandledKey(owner);
1118
1415
  }
1119
1416
  const installInteractiveHandlers = () => {
1120
1417
  if (props.options.planHandlerRef) {
1121
1418
  props.options.planHandlerRef.current = (plan) => new Promise((resolve) => {
1122
1419
  setPendingPlan({ plan, resolve });
1420
+ blurInputsForModal();
1123
1421
  forceApprovalUI();
1124
1422
  });
1125
1423
  }
@@ -1129,6 +1427,7 @@ function OpenTuiApp(props) {
1129
1427
  picker = undefined;
1130
1428
  providerDialog = undefined;
1131
1429
  setPendingApproval({ request, resolve });
1430
+ blurInputsForModal();
1132
1431
  forceApprovalUI();
1133
1432
  });
1134
1433
  }
@@ -1162,8 +1461,7 @@ function OpenTuiApp(props) {
1162
1461
  onCleanup(unsubscribeQuestion);
1163
1462
  syncFirstPendingQuestion();
1164
1463
  }
1165
- props.setRawModeCycleHandler?.(cycleModeFromRawSequence);
1166
- props.setRawQuestionHandler?.(handleQuestionRawSequence);
1464
+ props.setRawGlobalKeyHandler?.(routeGlobalRawSequence);
1167
1465
  const unsubscribeLsp = lspService.onStatusChange(() => {
1168
1466
  syncSidebarLsp();
1169
1467
  });
@@ -1172,12 +1470,11 @@ function OpenTuiApp(props) {
1172
1470
  refreshGitSidebar();
1173
1471
  setTimeout(() => {
1174
1472
  activePrompt()?.focus();
1175
- scrollbox?.scrollTo(scrollbox.scrollHeight);
1473
+ scrollTranscriptToBottom();
1176
1474
  }, 25);
1177
1475
  });
1178
1476
  onCleanup(() => {
1179
- props.setRawModeCycleHandler?.(undefined);
1180
- props.setRawQuestionHandler?.(undefined);
1477
+ props.setRawGlobalKeyHandler?.(undefined);
1181
1478
  if (sidebarLspSyncTimer)
1182
1479
  clearInterval(sidebarLspSyncTimer);
1183
1480
  for (const timer of questionSyncTimers)
@@ -1189,66 +1486,38 @@ function OpenTuiApp(props) {
1189
1486
  if (props.options.approvalHandlerRef)
1190
1487
  props.options.approvalHandlerRef.current = undefined;
1191
1488
  });
1192
- useKeyboard((event) => {
1193
- const name = String(event.name || "").toLowerCase();
1194
- if (event.ctrl && name === "c") {
1195
- void requestExit();
1196
- 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))
1489
+ let lastCopiedSelection = "";
1490
+ let lastCopiedSelectionAt = 0;
1491
+ let selectionCopySerial = 0;
1492
+ let rawSelectionStart;
1493
+ useSelectionHandler((selection) => {
1494
+ if (selection.isDragging)
1208
1495
  return;
1209
- if (handleProviderDialogKey(event))
1210
- return;
1211
- if (handlePickerKey(event))
1496
+ const selectedText = getOpenTuiSelectionText(selection);
1497
+ void copySelectionText(selectedText);
1498
+ });
1499
+ function handleRawMouseSelection(event) {
1500
+ if (event.button !== 0)
1212
1501
  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?.();
1502
+ if (event.type === "down") {
1503
+ rawSelectionStart = { x: event.x, y: event.y };
1243
1504
  return;
1244
1505
  }
1245
- if (cycleModeFromKey(event))
1506
+ if (event.type !== "up")
1246
1507
  return;
1247
- if (event.ctrl && name === "p" && !picker && !isRunning()) {
1248
- openCommandPalette();
1249
- event.preventDefault?.();
1508
+ const start = rawSelectionStart;
1509
+ rawSelectionStart = undefined;
1510
+ if (!start || (start.x === event.x && start.y === event.y))
1250
1511
  return;
1251
- }
1512
+ const selection = renderer.getSelection();
1513
+ if (!selection || selection.isDragging)
1514
+ return;
1515
+ void copySelectionText(getOpenTuiSelectionText(selection));
1516
+ }
1517
+ props.setRawMouseSelectionHandler?.(handleRawMouseSelection);
1518
+ onCleanup(() => props.setRawMouseSelectionHandler?.(undefined));
1519
+ useKeyboard((event) => {
1520
+ routeGlobalKeyEvent(event);
1252
1521
  }, {});
1253
1522
  function currentTranscriptMessages(extra) {
1254
1523
  return compactDisplayMessages(extra ? [...displayMessages, extra] : displayMessages);
@@ -1298,6 +1567,74 @@ function OpenTuiApp(props) {
1298
1567
  // Keep the agent loop alive even if a renderable is already gone.
1299
1568
  }
1300
1569
  }
1570
+ function beginAgentRun() {
1571
+ const run = { id: ++nextRunId, abortController: new AbortController() };
1572
+ activeRun = run;
1573
+ setRunningState(true);
1574
+ return run;
1575
+ }
1576
+ function finishAgentRun(run) {
1577
+ if (activeRun?.id === run.id)
1578
+ activeRun = undefined;
1579
+ setRunningState(false);
1580
+ }
1581
+ function cancelActiveAgentRun() {
1582
+ if (!activeRun || activeRun.abortController.signal.aborted)
1583
+ return false;
1584
+ activeRun.abortController.abort(new AgentAbortError("Agent run cancelled by user."));
1585
+ setNotice("Agent run cancelled");
1586
+ redrawDock();
1587
+ return true;
1588
+ }
1589
+ function preventGlobalKey(event) {
1590
+ event.preventDefault?.();
1591
+ event.stopPropagation?.();
1592
+ }
1593
+ function routeRunningCancel(name, event) {
1594
+ if (name !== "escape")
1595
+ return false;
1596
+ if (!cancelActiveAgentRun())
1597
+ return false;
1598
+ if (event)
1599
+ preventGlobalKey(event);
1600
+ return true;
1601
+ }
1602
+ function routeGlobalRawSequence(sequence) {
1603
+ const name = keyNameFromSequence(sequence);
1604
+ if (routeRunningCancel(name))
1605
+ return true;
1606
+ if (routeModalRawSequence(sequence))
1607
+ return true;
1608
+ if (cycleModeFromRawSequence(sequence))
1609
+ return true;
1610
+ return false;
1611
+ }
1612
+ function routeGlobalKeyEvent(event) {
1613
+ const name = keyNameFromEvent(event);
1614
+ if (event.ctrl && name === "c") {
1615
+ void requestExit();
1616
+ return true;
1617
+ }
1618
+ if (routeRunningCancel(name, event))
1619
+ return true;
1620
+ // Ctrl+Shift+M opens the MCP reconnect picker. Shift is required because
1621
+ // bare Ctrl+M is Enter on most terminals (historical TTY mapping).
1622
+ if (event.ctrl && event.shift && name === "m") {
1623
+ openMcpReconnectPicker();
1624
+ event.preventDefault?.();
1625
+ return true;
1626
+ }
1627
+ if (routeModalKey(event))
1628
+ return true;
1629
+ if (cycleModeFromKey(event))
1630
+ return true;
1631
+ if (event.ctrl && name === "p" && !picker && !isRunning()) {
1632
+ openCommandPalette();
1633
+ event.preventDefault?.();
1634
+ return true;
1635
+ }
1636
+ return false;
1637
+ }
1301
1638
  function transcriptOptions() {
1302
1639
  return {
1303
1640
  cwd: props.args.cwd,
@@ -1371,28 +1708,22 @@ function OpenTuiApp(props) {
1371
1708
  }, PROMPT_SCANNER_INTERVAL_MS);
1372
1709
  }
1373
1710
  function redrawTranscript(extra, baseMessages = displayMessages) {
1711
+ const shouldFollow = shouldFollowTranscriptBeforeUpdate();
1374
1712
  streamingDisplay = extra;
1375
1713
  const nextMessages = compactDisplayMessages(extra ? [...baseMessages, extra] : baseMessages);
1376
1714
  syncSessionMessages(nextMessages);
1377
1715
  rootBox?.requestRender();
1378
1716
  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);
1717
+ scheduleTranscriptScrollAfterUpdate(shouldFollow);
1388
1718
  }
1389
1719
  createEffect(() => {
1720
+ const shouldFollow = shouldFollowTranscriptBeforeUpdate();
1390
1721
  dimensions();
1391
1722
  sessionActive();
1392
1723
  syncSidebarChrome();
1393
1724
  redrawQuestionPanel();
1394
1725
  scrollbox?.requestRender();
1395
- setTimeout(() => scrollbox?.scrollTo(scrollbox.scrollHeight), 50);
1726
+ scheduleTranscriptScrollAfterUpdate(shouldFollow);
1396
1727
  });
1397
1728
  function redrawDock() {
1398
1729
  if (dock) {
@@ -1479,6 +1810,8 @@ function OpenTuiApp(props) {
1479
1810
  return buildProviderConnectItems();
1480
1811
  if (step === "auth")
1481
1812
  return providerId ? buildPickerItems("provider-auth", providerId) : [];
1813
+ if (step === "skills")
1814
+ return buildSkillItems();
1482
1815
  if (step === "models") {
1483
1816
  const modelItems = buildPickerItems("model", providerId);
1484
1817
  if (modelItems.length || providerId)
@@ -1634,17 +1967,19 @@ function OpenTuiApp(props) {
1634
1967
  gutter.fg = active ? activeText : providerDialogGutterColor(row.item.gutter ?? (isCurrentModelItem(row.item) ? "●" : undefined));
1635
1968
  }
1636
1969
  if (label) {
1637
- label.content = truncate(row.item.label, 37);
1970
+ label.content = truncate(row.item.label, providerDialogLabelWidth(state));
1638
1971
  label.fg = active ? activeText : isCurrentModelItem(row.item) ? theme.primary : theme.text;
1639
1972
  }
1640
1973
  if (detail) {
1641
1974
  const detailText = state.query.trim() && state.step === "models"
1642
1975
  ? row.item.category ?? row.item.detail ?? ""
1643
1976
  : row.item.detail ?? "";
1644
- detail.content = truncate(detailText, providerDialogDetailWidth());
1977
+ detail.width = providerDialogDetailWidth(state);
1978
+ detail.content = truncate(detailText, providerDialogDetailWidth(state));
1645
1979
  detail.fg = active ? activeText : theme.textMuted;
1646
1980
  }
1647
1981
  if (footer) {
1982
+ footer.width = providerDialogFooterWidth(state);
1648
1983
  footer.content = row.item.footer ?? "";
1649
1984
  footer.fg = active ? activeText : theme.textMuted;
1650
1985
  }
@@ -1659,6 +1994,8 @@ function OpenTuiApp(props) {
1659
1994
  function providerDialogTitleFor(state) {
1660
1995
  if (state.step === "providers")
1661
1996
  return "Connect a provider";
1997
+ if (state.step === "skills")
1998
+ return "Select skill";
1662
1999
  const provider = providerDisplayName(state.providerId);
1663
2000
  if (state.step === "auth")
1664
2001
  return `${provider} auth method`;
@@ -1677,6 +2014,8 @@ function OpenTuiApp(props) {
1677
2014
  const connect = state.providerId ? "" : " · ctrl+o providers";
1678
2015
  return `↑/↓ move · enter select · esc close${connect}${count}`;
1679
2016
  }
2017
+ if (state.step === "skills")
2018
+ return `↑/↓ move · enter insert · esc close${count}`;
1680
2019
  const escLabel = state.step === "providers" ? "esc close" : "esc back";
1681
2020
  return `↑/↓ move · enter select · ${escLabel}${count}`;
1682
2021
  }
@@ -1689,8 +2028,14 @@ function OpenTuiApp(props) {
1689
2028
  return theme.warning;
1690
2029
  return theme.textMuted;
1691
2030
  }
1692
- function providerDialogDetailWidth() {
1693
- return 16;
2031
+ function providerDialogLabelWidth(state) {
2032
+ return state.step === "skills" ? 22 : 37;
2033
+ }
2034
+ function providerDialogDetailWidth(state) {
2035
+ return state.step === "skills" ? 26 : 16;
2036
+ }
2037
+ function providerDialogFooterWidth(state) {
2038
+ return state.step === "skills" ? 9 : 8;
1694
2039
  }
1695
2040
  function isCurrentModelItem(item) {
1696
2041
  return item.value === props.agent.model || item.detail?.includes("current");
@@ -1755,7 +2100,7 @@ function OpenTuiApp(props) {
1755
2100
  else if (state.step === "key") {
1756
2101
  openProviderDialog(state.providerId && registry.supportsOAuth(state.providerId) ? "auth" : "providers", state.providerId);
1757
2102
  }
1758
- else if (state.step === "models") {
2103
+ else if (state.step === "models" || state.step === "skills") {
1759
2104
  closeProviderDialog();
1760
2105
  }
1761
2106
  else {
@@ -1885,6 +2230,11 @@ function OpenTuiApp(props) {
1885
2230
  }
1886
2231
  closeProviderDialog();
1887
2232
  await executeSlash(item.command);
2233
+ return;
2234
+ }
2235
+ if (state.step === "skills") {
2236
+ closeProviderDialog();
2237
+ insertSkillPrompt(item.value);
1888
2238
  }
1889
2239
  }
1890
2240
  function ensureProviderConfiguredForKey(providerId) {
@@ -2262,6 +2612,8 @@ function OpenTuiApp(props) {
2262
2612
  const clearMessages = () => {
2263
2613
  displayMessages = [];
2264
2614
  streamingDisplay = undefined;
2615
+ promptHistory = [];
2616
+ resetPromptHistoryBrowse();
2265
2617
  redrawTranscript(undefined, []);
2266
2618
  };
2267
2619
  async function submitPrompt() {
@@ -2275,9 +2627,7 @@ function OpenTuiApp(props) {
2275
2627
  }
2276
2628
  const plan = pendingPlan();
2277
2629
  if (plan) {
2278
- setPendingPlan(undefined);
2279
- setApprovalOptionIdx(0);
2280
- plan.resolve({ action: "approve", plan: plan.plan });
2630
+ resolvePendingPlanSelection(plan);
2281
2631
  return;
2282
2632
  }
2283
2633
  if (isRunning())
@@ -2294,6 +2644,7 @@ function OpenTuiApp(props) {
2294
2644
  return;
2295
2645
  activePrompt()?.clear();
2296
2646
  promptText = "";
2647
+ resetPromptHistoryBrowse();
2297
2648
  if (picker?.kind === "key") {
2298
2649
  const providerId = picker.providerId;
2299
2650
  const after = picker.after;
@@ -2310,9 +2661,15 @@ function OpenTuiApp(props) {
2310
2661
  }
2311
2662
  if (input.startsWith("/") && !/\s/.test(input)) {
2312
2663
  const query = input.slice(1).toLowerCase();
2313
- const matches = slashRegistry.list().filter((command) => command.name.toLowerCase().startsWith(query));
2664
+ const matches = slashCandidates().filter((command) => command.name.toLowerCase().startsWith(query));
2314
2665
  if (matches.length === 1) {
2315
- await executeSlash(`/${matches[0].name}`);
2666
+ const match = matches[0];
2667
+ if (match.source === "skill") {
2668
+ insertSkillPrompt(match.name);
2669
+ }
2670
+ else {
2671
+ await executeSlash(`/${match.name}`);
2672
+ }
2316
2673
  return;
2317
2674
  }
2318
2675
  if (matches.length > 1) {
@@ -2332,6 +2689,10 @@ function OpenTuiApp(props) {
2332
2689
  function onPromptContentChange(value) {
2333
2690
  const nextValue = typeof value === "string" ? value : readPromptText();
2334
2691
  promptText = nextValue;
2692
+ if (promptHistoryIndex !== undefined
2693
+ && nextValue !== (promptHistory[promptHistoryIndex] ?? "")) {
2694
+ resetPromptHistoryBrowse();
2695
+ }
2335
2696
  if (providerDialog)
2336
2697
  return;
2337
2698
  if (picker?.kind === "key")
@@ -2432,25 +2793,39 @@ function OpenTuiApp(props) {
2432
2793
  activePrompt()?.focus();
2433
2794
  redrawDock();
2434
2795
  }
2435
- function buildSlashItems(query = "") {
2436
- const normalizedQuery = query.trim().toLowerCase();
2437
- const commands = [
2796
+ function slashCandidates() {
2797
+ const skillCommands = skills.summaries().map((skill) => ({
2798
+ name: skill.name,
2799
+ description: skill.description,
2800
+ source: "skill",
2801
+ sourceLabel: skill.source,
2802
+ }));
2803
+ return [
2438
2804
  ...LOCAL_SLASH_COMMANDS.map((c) => ({ ...c, source: "builtin" })),
2805
+ ...skillCommands,
2439
2806
  ...slashRegistry.list(),
2440
2807
  ];
2808
+ }
2809
+ function buildSlashItems(query = "") {
2810
+ const normalizedQuery = query.trim().toLowerCase();
2811
+ const commands = slashCandidates();
2441
2812
  const matches = slashCommandMatches(commands, normalizedQuery);
2442
2813
  return matches.map(({ command }) => {
2443
2814
  const isMcp = command.source === "mcp";
2444
- const badge = isMcp ? " :mcp" : "";
2815
+ const isSkill = command.source === "skill";
2816
+ const badge = isMcp ? " :mcp" : isSkill ? " :skill" : "";
2445
2817
  const label = `/${command.name}${badge}`;
2446
2818
  const detail = isMcp && command.sourceLabel
2447
2819
  ? `[${command.sourceLabel}] ${command.description}`
2448
- : command.description;
2820
+ : isSkill && command.sourceLabel
2821
+ ? `[${command.sourceLabel}] ${command.description}`
2822
+ : command.description;
2449
2823
  return {
2450
2824
  label,
2451
2825
  detail,
2452
2826
  value: command.name,
2453
2827
  command: `/${command.name}`,
2828
+ action: isSkill ? "insert-skill" : undefined,
2454
2829
  };
2455
2830
  });
2456
2831
  }
@@ -2575,6 +2950,12 @@ function OpenTuiApp(props) {
2575
2950
  prompt.cursorOffset = next.length;
2576
2951
  closePicker();
2577
2952
  }
2953
+ function insertSkillPrompt(skillName) {
2954
+ closePicker();
2955
+ resetPromptHistoryBrowse();
2956
+ setPromptText(`/${skillName} `);
2957
+ redrawDock();
2958
+ }
2578
2959
  async function handleInput(input) {
2579
2960
  setNotice("");
2580
2961
  if (input.startsWith("/")) {
@@ -2671,6 +3052,10 @@ function OpenTuiApp(props) {
2671
3052
  openProviderDialog(kind === "provider-auth" ? "auth" : "providers", providerId);
2672
3053
  return;
2673
3054
  }
3055
+ if (kind === "skill") {
3056
+ openProviderDialog("skills");
3057
+ return;
3058
+ }
2674
3059
  if (kind === "key") {
2675
3060
  picker = {
2676
3061
  kind: "key",
@@ -2710,6 +3095,10 @@ function OpenTuiApp(props) {
2710
3095
  applyFileSuggestion(item.value);
2711
3096
  return;
2712
3097
  }
3098
+ if (item.action === "insert-skill" || (picker?.kind === "select" && picker.mode === "skill")) {
3099
+ insertSkillPrompt(item.value);
3100
+ return;
3101
+ }
2713
3102
  if (picker?.kind === "select" && picker.mode === "slash") {
2714
3103
  activePrompt()?.clear();
2715
3104
  closePicker();
@@ -2796,6 +3185,8 @@ function OpenTuiApp(props) {
2796
3185
  return [];
2797
3186
  if (kind === "mcp-reconnect")
2798
3187
  return buildMcpReconnectItems();
3188
+ if (kind === "skill")
3189
+ return buildSkillItems();
2799
3190
  if (kind === "model") {
2800
3191
  const items = [];
2801
3192
  for (const provider of registry.getEnabled()) {
@@ -2909,6 +3300,25 @@ function OpenTuiApp(props) {
2909
3300
  command: `/logout ${provider.id}`,
2910
3301
  }));
2911
3302
  }
3303
+ function buildSkillItems() {
3304
+ return skills.summaries().map((skill) => {
3305
+ const tags = skill.tags && skill.tags.length > 0 ? ` · ${skill.tags.join(", ")}` : "";
3306
+ const category = skill.source === "project"
3307
+ ? "Project skills"
3308
+ : skill.source === "configured"
3309
+ ? "Configured skills"
3310
+ : "User skills";
3311
+ return {
3312
+ label: skill.name,
3313
+ detail: `${skill.description}${tags}`,
3314
+ value: skill.name,
3315
+ command: `/${skill.name}`,
3316
+ action: "insert-skill",
3317
+ category,
3318
+ footer: skill.source,
3319
+ };
3320
+ });
3321
+ }
2912
3322
  function buildProviderConnectItems() {
2913
3323
  const configuredProviders = registry.getConfigured();
2914
3324
  const configured = new Map(configuredProviders.map((provider) => [provider.id, provider]));
@@ -3002,17 +3412,19 @@ function OpenTuiApp(props) {
3002
3412
  addMessage("error", "No model selected. Use /model after /login or provider setup.");
3003
3413
  return;
3004
3414
  }
3415
+ rememberPromptHistory(displayInput);
3005
3416
  const nextMessages = [...displayMessages, { role: "user", content: displayInput }];
3006
3417
  displayMessages = nextMessages;
3007
3418
  streamingDisplay = undefined;
3008
3419
  redrawTranscript(undefined, nextMessages);
3009
- setRunningState(true);
3420
+ const run = beginAgentRun();
3010
3421
  let assistantContent = "";
3011
3422
  let assistantReasoning = "";
3012
3423
  const toolCalls = [];
3013
3424
  let runError;
3425
+ let runCancelled = false;
3014
3426
  try {
3015
- for await (const event of props.agent.run(actualInput, props.args.cwd)) {
3427
+ for await (const event of props.agent.run(actualInput, props.args.cwd, { abortSignal: run.abortController.signal })) {
3016
3428
  if (event.type === "turn_start") {
3017
3429
  assistantContent = "";
3018
3430
  assistantReasoning = "";
@@ -3092,6 +3504,7 @@ function OpenTuiApp(props) {
3092
3504
  else if (event.type === "turn_end") {
3093
3505
  if (event.usage) {
3094
3506
  setSidebarUsage((current) => ({
3507
+ contextTokens: event.usage.promptTokens || current.contextTokens,
3095
3508
  promptTokens: current.promptTokens + event.usage.promptTokens,
3096
3509
  completionTokens: current.completionTokens + event.usage.completionTokens,
3097
3510
  promptCacheHitTokens: current.promptCacheHitTokens + (event.usage.promptCacheHitTokens ?? 0),
@@ -3121,13 +3534,16 @@ function OpenTuiApp(props) {
3121
3534
  }
3122
3535
  }
3123
3536
  catch (error) {
3124
- runError = error?.message || String(error);
3537
+ runCancelled = error instanceof AgentAbortError || run.abortController.signal.aborted || error?.name === "AbortError";
3538
+ if (!runCancelled) {
3539
+ runError = error?.message || String(error);
3540
+ }
3125
3541
  }
3126
3542
  finally {
3127
3543
  pendingApprovalRef = undefined;
3128
3544
  setPendingApproval(undefined);
3129
3545
  setApprovalOptionIdx(0);
3130
- setRunningState(false);
3546
+ finishAgentRun(run);
3131
3547
  streamingDisplay = undefined;
3132
3548
  if (runError) {
3133
3549
  const errorMessage = runError;
@@ -3135,6 +3551,11 @@ function OpenTuiApp(props) {
3135
3551
  displayMessages = nextMessages;
3136
3552
  redrawTranscript(undefined, nextMessages);
3137
3553
  }
3554
+ else if (runCancelled) {
3555
+ if (!notice())
3556
+ setNotice("Agent run cancelled");
3557
+ redrawTranscript();
3558
+ }
3138
3559
  else {
3139
3560
  redrawTranscript();
3140
3561
  }
@@ -3145,24 +3566,20 @@ function OpenTuiApp(props) {
3145
3566
  }
3146
3567
  }
3147
3568
  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?.();
3569
+ if (routeRunningCancel(keyNameFromEvent(event), event))
3162
3570
  return true;
3571
+ const modalOwner = activeModalKeyOwner();
3572
+ if (modalOwner) {
3573
+ if (routeModalKey(event) || shouldModalSwallowUnhandledKey(modalOwner)) {
3574
+ event.preventDefault?.();
3575
+ event.stopPropagation?.();
3576
+ return true;
3577
+ }
3163
3578
  }
3164
3579
  if (cycleModeFromKey(event))
3165
3580
  return true;
3581
+ if (handlePromptHistoryKey(event))
3582
+ return true;
3166
3583
  return false;
3167
3584
  }
3168
3585
  function renderComposer() {
@@ -3267,6 +3684,8 @@ function OpenTuiApp(props) {
3267
3684
  redrawQuestionPanel();
3268
3685
  },
3269
3686
  visible: false,
3687
+ focusable: true,
3688
+ onKeyDown: handleQuestionKey,
3270
3689
  position: "absolute",
3271
3690
  left: 2,
3272
3691
  right: 2,
@@ -3680,7 +4099,7 @@ function OpenTuiApp(props) {
3680
4099
  flexShrink: 0,
3681
4100
  ref: (ref) => {
3682
4101
  sidebarGaugeLabelText = ref;
3683
- ref.content = buildGaugeLabel(context.percent);
4102
+ ref.content = buildGaugeLabel(context.percent, context.remainingTokens);
3684
4103
  },
3685
4104
  }),
3686
4105
  h("text", {
@@ -3873,6 +4292,13 @@ function OpenTuiApp(props) {
3873
4292
  ? getContextBudget(providerId, modelId, props.agent.messages)
3874
4293
  : undefined;
3875
4294
  const usage = sidebarUsage();
4295
+ const contextTokens = usage.contextTokens || (budget?.estimatedTokens ?? 0);
4296
+ const contextPercent = budget?.contextWindow
4297
+ ? Math.min(100, Math.round((contextTokens / budget.contextWindow) * 100))
4298
+ : Math.round(budget?.percent ?? 0);
4299
+ const remainingTokens = budget?.contextWindow !== undefined
4300
+ ? Math.max(0, budget.contextWindow - contextTokens)
4301
+ : undefined;
3876
4302
  const tokenUsage = {
3877
4303
  promptTokens: usage.promptTokens,
3878
4304
  completionTokens: usage.completionTokens,
@@ -3883,8 +4309,9 @@ function OpenTuiApp(props) {
3883
4309
  };
3884
4310
  const cost = providerId && modelId ? calculateUsageCost(providerId, modelId, tokenUsage) : undefined;
3885
4311
  return {
3886
- tokens: budget?.estimatedTokens ?? 0,
3887
- percent: Math.round(budget?.percent ?? 0),
4312
+ tokens: contextTokens,
4313
+ percent: contextPercent,
4314
+ remainingTokens,
3888
4315
  promptTokens: usage.promptTokens,
3889
4316
  completionTokens: usage.completionTokens,
3890
4317
  reasoningTokens: usage.reasoningTokens,
@@ -3914,6 +4341,7 @@ function OpenTuiApp(props) {
3914
4341
  ref: (ref) => { scrollbox = ref; },
3915
4342
  stickyScroll: true,
3916
4343
  stickyStart: "bottom",
4344
+ onMouseScroll: handleTranscriptMouseScroll,
3917
4345
  flexGrow: 1,
3918
4346
  minHeight: 0,
3919
4347
  }, h("box", { height: 1 }), h("box", {
@@ -3925,7 +4353,8 @@ function OpenTuiApp(props) {
3925
4353
  updateTranscriptHost(ref, transcriptState, currentTranscriptMessages(streamingDisplay), transcriptOptions(), props.syntaxStyle, props.subtleSyntaxStyle);
3926
4354
  syncThinkingSpinner();
3927
4355
  syncPromptSurfaces(isNewHost);
3928
- setTimeout(() => scrollbox?.scrollTo(scrollbox.scrollHeight), 0);
4356
+ if (isNewHost)
4357
+ scheduleTranscriptScrollAfterUpdate(transcriptScrollFollowing, 0);
3929
4358
  },
3930
4359
  flexDirection: "column",
3931
4360
  flexShrink: 0,
@@ -3935,6 +4364,8 @@ function OpenTuiApp(props) {
3935
4364
  }, notice()) : null, renderQuestionPanelHost(), h("box", {
3936
4365
  ref: (ref) => { approvalRoot = ref; },
3937
4366
  visible: !!approval,
4367
+ focusable: true,
4368
+ onKeyDown: handleApprovalKey,
3938
4369
  position: "absolute",
3939
4370
  left: 2,
3940
4371
  right: 2,
@@ -4046,9 +4477,38 @@ function OpenTuiApp(props) {
4046
4477
  ref: (ref) => { approvalOptionTexts[index] = ref; },
4047
4478
  fg: theme.textMuted,
4048
4479
  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")))))),
4480
+ })))), 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
4481
  ]);
4051
4482
  }
4483
+ function renderNoticeOverlay() {
4484
+ return h("box", {
4485
+ ref: (ref) => {
4486
+ copyToastRoot = ref;
4487
+ ref.visible = false;
4488
+ },
4489
+ visible: false,
4490
+ position: "absolute",
4491
+ top: 2,
4492
+ right: sidebarVisible() ? SESSION_SIDEBAR_WIDTH + 2 : 2,
4493
+ zIndex: 4000,
4494
+ flexDirection: "column",
4495
+ alignItems: "flex-start",
4496
+ width: 24,
4497
+ paddingLeft: 2,
4498
+ paddingRight: 2,
4499
+ paddingTop: 1,
4500
+ paddingBottom: 1,
4501
+ backgroundColor: theme.backgroundPanel,
4502
+ border: ["left", "right"],
4503
+ borderColor: theme.info,
4504
+ }, h("text", {
4505
+ ref: (ref) => { copyToastText = ref; },
4506
+ fg: theme.text,
4507
+ wrapMode: "word",
4508
+ width: "100%",
4509
+ content: "",
4510
+ }));
4511
+ }
4052
4512
  return h("box", {
4053
4513
  ref: (ref) => { rootBox = ref; },
4054
4514
  flexDirection: "column",
@@ -4080,6 +4540,7 @@ function OpenTuiApp(props) {
4080
4540
  registerModeBadge: registerFooterModeBadge,
4081
4541
  }),
4082
4542
  renderProviderDialog(),
4543
+ renderNoticeOverlay(),
4083
4544
  ]);
4084
4545
  }
4085
4546
  function renderPrompt(input) {
@@ -4988,9 +5449,9 @@ function renderFooter(input) {
4988
5449
  idleFg: theme.textMuted,
4989
5450
  runningFg: theme.primary,
4990
5451
  }), h("text", {
4991
- fg: theme.warning,
5452
+ fg: permissionModeColor(input.mode()),
4992
5453
  ref: input.registerModeBadge,
4993
- }, input.mode() !== "default" ? ` ${input.mode()} · tab` : ""), h("box", { flexGrow: 1 }));
5454
+ }, footerPermissionModeText(input.mode())), h("box", { flexGrow: 1 }));
4994
5455
  }
4995
5456
  function pickerTitle(kind, providerId) {
4996
5457
  switch (kind) {
@@ -5012,6 +5473,8 @@ function pickerTitle(kind, providerId) {
5012
5473
  return "Select Login Provider";
5013
5474
  case "logout":
5014
5475
  return "Select Logout Provider";
5476
+ case "skill":
5477
+ return "Skills";
5015
5478
  case "slash":
5016
5479
  return "Commands";
5017
5480
  case "file":
@@ -5447,13 +5910,37 @@ function contrastText(color) {
5447
5910
  return luminance > 160 ? "#000000" : "#ffffff";
5448
5911
  }
5449
5912
  function promptModeBadgeContent(mode) {
5450
- const isPlan = mode === "plan";
5451
- const color = isPlan ? theme.accent : theme.primary;
5452
- const label = isPlan ? "Plan" : "Build";
5913
+ const color = permissionModeColor(mode);
5914
+ const label = permissionModeBadgeLabel(mode);
5453
5915
  return new StyledText([
5454
5916
  bg(color)(fg(contrastText(color))(bold(` ${label} `))),
5455
5917
  ]);
5456
5918
  }
5919
+ function permissionModeBadgeLabel(mode) {
5920
+ switch (mode) {
5921
+ case "default": return "Build";
5922
+ case "plan": return "Plan";
5923
+ case "bypassPermissions": return "Bypass";
5924
+ }
5925
+ }
5926
+ function footerPermissionModeText(mode) {
5927
+ const info = PERMISSION_MODE_INFO[mode];
5928
+ if (mode === "default")
5929
+ return " mode: build · shift+tab plan";
5930
+ if (mode === "plan")
5931
+ return " mode: plan · shift+tab bypass";
5932
+ return ` mode: ${info.shortTitle} · shift+tab build`;
5933
+ }
5934
+ function permissionModeColor(mode) {
5935
+ const info = PERMISSION_MODE_INFO[mode];
5936
+ switch (info.color) {
5937
+ case "accent": return theme.accent;
5938
+ case "success": return theme.success;
5939
+ case "warning": return theme.warning;
5940
+ case "error": return theme.error;
5941
+ case "muted": return theme.primary;
5942
+ }
5943
+ }
5457
5944
  function toolColor(tool) {
5458
5945
  if (tool.isError)
5459
5946
  return theme.toolError;
@@ -5622,18 +6109,6 @@ function assistantStatusLabel(message) {
5622
6109
  return "Responding...";
5623
6110
  return message.streaming ? "Thinking..." : "Thinking";
5624
6111
  }
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
6112
  function buildContextGauge(percent, barWidth) {
5638
6113
  const clamped = Math.max(0, Math.min(100, percent));
5639
6114
  const filled = Math.round((clamped / 100) * barWidth);
@@ -5642,17 +6117,27 @@ function buildContextGauge(percent, barWidth) {
5642
6117
  const space = empty > 0 ? "░".repeat(empty) : "";
5643
6118
  return `${block}${space}`;
5644
6119
  }
5645
- function buildGaugeLabel(percent) {
6120
+ function buildGaugeLabel(percent, remainingTokens) {
6121
+ const remaining = remainingTokens === undefined ? "" : ` · ${formatContextRemaining(remainingTokens)} left`;
5646
6122
  if (percent >= 95)
5647
- return "⚠ Compact imminent";
6123
+ return `⚠ Compact imminent${remaining}`;
5648
6124
  if (percent >= 80)
5649
- return "⚠ Approaching limit";
6125
+ return `⚠ Approaching limit${remaining}`;
5650
6126
  if (percent >= 60)
5651
- return "● Context growing";
6127
+ return `● Context growing${remaining}`;
5652
6128
  if (percent > 0)
5653
- return "○ Available";
6129
+ return `○ Available${remaining}`;
5654
6130
  return "○ Empty";
5655
6131
  }
6132
+ function formatContextRemaining(value) {
6133
+ if (value >= 1_000_000)
6134
+ return `${(value / 1_000_000).toFixed(2)}M`;
6135
+ if (value >= 100_000)
6136
+ return `${Math.round(value / 1_000)}K`;
6137
+ if (value >= 1_000)
6138
+ return `${(value / 1_000).toFixed(1)}K`;
6139
+ return String(value);
6140
+ }
5656
6141
  function thinkingToggleLabel(expanded, streaming = false, spinnerFrame = "") {
5657
6142
  const arrow = expanded ? "▼" : "▶";
5658
6143
  return streaming && spinnerFrame ? `${spinnerFrame} ${arrow} Thinking` : `${arrow} Thinking`;