@workerdeck/ui 0.7.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +44 -3
  2. package/build/index.d.mts +767 -21
  3. package/build/index.mjs +3268 -348
  4. package/build/index.mjs.map +1 -1
  5. package/package.json +5 -4
  6. package/src/components/agent/CodeEditor.tsx +300 -0
  7. package/src/components/agent/Composer.tsx +379 -56
  8. package/src/components/agent/ContextDialog.tsx +99 -0
  9. package/src/components/agent/EditorTabs.tsx +165 -0
  10. package/src/components/agent/FileTree.tsx +287 -0
  11. package/src/components/agent/FileViewer.tsx +148 -0
  12. package/src/components/agent/HostFilesDialog.tsx +218 -0
  13. package/src/components/agent/McpDialog.tsx +363 -0
  14. package/src/components/agent/ModelSelect.tsx +34 -6
  15. package/src/components/agent/PermissionModeSelect.tsx +99 -22
  16. package/src/components/agent/PermissionPrompt.tsx +72 -6
  17. package/src/components/agent/PromptTokenText.tsx +39 -0
  18. package/src/components/agent/SessionEmptyState.tsx +65 -0
  19. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  20. package/src/components/agent/SessionPanel.tsx +380 -40
  21. package/src/components/agent/SessionWorkspace.tsx +282 -0
  22. package/src/components/agent/SkillsDialog.tsx +195 -0
  23. package/src/components/agent/StatusBar.tsx +85 -18
  24. package/src/components/agent/ToolCallCard.tsx +80 -4
  25. package/src/components/agent/Transcript.tsx +109 -12
  26. package/src/components/agent/UsageDialog.tsx +168 -0
  27. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  28. package/src/components/prompt-area/types.ts +15 -0
  29. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  30. package/src/components/ui/CopyButton.tsx +8 -1
  31. package/src/components/ui/Dialog.tsx +92 -0
  32. package/src/components/ui/Menu.tsx +55 -0
  33. package/src/components/ui/Splitter.tsx +133 -0
  34. package/src/components/ui/Tooltip.tsx +22 -5
  35. package/src/index.ts +53 -1
  36. package/src/lib/clipboard.ts +56 -0
  37. package/src/lib/format.ts +48 -0
  38. package/src/lib/tool-icon.ts +74 -0
package/build/index.mjs CHANGED
@@ -1,17 +1,20 @@
1
- import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
1
+ import { Fragment, forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import { cva } from "class-variance-authority";
3
3
  import { clsx } from "clsx";
4
4
  import { extendTailwindMerge } from "tailwind-merge";
5
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
6
  import { Select as Select$1 } from "@base-ui/react/select";
7
- import { ArrowDown, ArrowUp, Brain, Check, ChevronDown, ChevronsUpDown, Clock, Copy, Download, FileText, LoaderCircle, MessageCircleQuestion, ShieldAlert, Square, Trash2, WifiOff, Wrench, X } from "lucide-react";
7
+ import { ArrowDown, ArrowDownCircle, ArrowUp, AtSign, Brain, ChartPie, Check, CheckSquare, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, ClipboardList, Clock, Code, Copy, Download, File, FileDiff, FileText, FileWarning, Folder, FolderOpen, FolderSearch, FolderTree, Gauge, Globe, Hand, Image, Info, Link2, LoaderCircle, MessageCircleQuestion, MessageSquareText, MoreHorizontal, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plug, Power, PowerOff, Puzzle, RefreshCw, RotateCcw, RotateCw, Search, ShieldCheck, SlashSquare, Sparkles, Square, SquarePen, Terminal, Trash2, TriangleAlert, UsersRound, WifiOff, Wrench, X, Zap } from "lucide-react";
8
8
  import { AlertDialog as AlertDialog$1 } from "@base-ui/react/alert-dialog";
9
+ import { Menu as Menu$1 } from "@base-ui/react/menu";
10
+ import { Dialog as Dialog$1 } from "@base-ui/react/dialog";
9
11
  import { Tooltip } from "@base-ui/react/tooltip";
10
12
  import { Toaster as Toaster$1, toast } from "sonner";
11
- import { PROVIDER_PERMISSION_MODES } from "@workerdeck/protocol";
12
- import { useClaudeSession, useToolCallHost } from "@workerdeck/react";
13
+ import { PROTOCOL_VERSION } from "@workerdeck/protocol";
14
+ import { currentText, isDirty, rateLimitWindows, scanPromptTokens, useAttachments, useClaudeSession, useHostFileRoots, useHostFileSearch, useHostFileTree, useOpenFiles, useSessionInfo, useToolCallHost } from "@workerdeck/react";
13
15
  import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
14
16
  import { Streamdown } from "streamdown";
17
+ import { createPortal } from "react-dom";
15
18
  //#region src/lib/utils.ts
16
19
  const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: [
17
20
  "display-xl",
@@ -207,6 +210,86 @@ const AlertDialogDescription = ({ className, ...props }) => /* @__PURE__ */ jsx(
207
210
  ...props
208
211
  });
209
212
  //#endregion
213
+ //#region src/components/ui/Menu.tsx
214
+ const Menu = Menu$1.Root;
215
+ const MenuTrigger = Menu$1.Trigger;
216
+ const MenuContent = ({ className, align = "end", side = "bottom", sideOffset = 6, ...props }) => /* @__PURE__ */ jsx(Menu$1.Portal, { children: /* @__PURE__ */ jsx(Menu$1.Positioner, {
217
+ align,
218
+ side,
219
+ sideOffset,
220
+ className: "isolate z-60 outline-none",
221
+ children: /* @__PURE__ */ jsx(Menu$1.Popup, {
222
+ "data-slot": "menu-content",
223
+ className: cn("min-w-48 rounded-md border border-border bg-surface p-1 text-fg-1 shadow-(--shadow-lg) outline-none", "transition-[opacity,transform] duration-(--motion-base)", "data-starting-style:scale-95 data-starting-style:opacity-0", "data-ending-style:scale-95 data-ending-style:opacity-0", className),
224
+ ...props
225
+ })
226
+ }) });
227
+ const MenuItem = ({ className, destructive, ...props }) => /* @__PURE__ */ jsx(Menu$1.Item, {
228
+ "data-slot": "menu-item",
229
+ className: cn("flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-body-sm outline-none select-none", "data-highlighted:bg-surface-hover", destructive ? "text-danger" : "text-text", className),
230
+ ...props
231
+ });
232
+ const MenuSeparator = ({ className, ...props }) => /* @__PURE__ */ jsx(Menu$1.Separator, {
233
+ className: cn("my-1 h-px bg-border", className),
234
+ ...props
235
+ });
236
+ //#endregion
237
+ //#region src/components/ui/Dialog.tsx
238
+ const Dialog = Dialog$1.Root;
239
+ const DialogTrigger = Dialog$1.Trigger;
240
+ const DialogClose = Dialog$1.Close;
241
+ /**
242
+ * A dismissible panel, sized for reading rather than confirming — the web
243
+ * counterpart of the iOS app's detail sheets (context, usage, session info, MCP).
244
+ *
245
+ * Taller than {@link AlertDialogContent} and scrollable inside, because these
246
+ * carry lists whose length is the engine's business, not the layout's.
247
+ */
248
+ const DialogContent = ({ className, children, size = "md", ...props }) => /* @__PURE__ */ jsxs(Dialog$1.Portal, { children: [/* @__PURE__ */ jsx(Dialog$1.Backdrop, { className: cn("fixed inset-0 z-70 bg-black/40 backdrop-blur-[1px]", "transition-opacity duration-(--motion-base)", "data-starting-style:opacity-0 data-ending-style:opacity-0") }), /* @__PURE__ */ jsx(Dialog$1.Popup, {
249
+ "data-slot": "dialog-content",
250
+ className: cn("fixed top-1/2 left-1/2 z-70 flex max-h-[min(42rem,calc(100dvh-3rem))] -translate-x-1/2 -translate-y-1/2 flex-col", size === "sm" && "w-[min(24rem,calc(100vw-2rem))]", size === "md" && "w-[min(32rem,calc(100vw-2rem))]", size === "lg" && "w-[min(46rem,calc(100vw-2rem))]", "rounded-lg border border-border bg-surface shadow-(--shadow-lg) outline-none", "transition-[opacity,transform] duration-(--motion-base)", "data-starting-style:scale-95 data-starting-style:opacity-0", "data-ending-style:scale-95 data-ending-style:opacity-0", className),
251
+ ...props,
252
+ children
253
+ })] });
254
+ /** Title row with the close button, pinned above the scrolling body. */
255
+ const DialogHeader = ({ title, description, actions }) => /* @__PURE__ */ jsxs("div", {
256
+ className: "flex items-start gap-2 border-b border-border px-4 py-3",
257
+ children: [
258
+ /* @__PURE__ */ jsxs("div", {
259
+ className: "min-w-0 flex-1",
260
+ children: [/* @__PURE__ */ jsx(Dialog$1.Title, {
261
+ className: "truncate text-body-sm font-semibold text-text",
262
+ children: title
263
+ }), description ? /* @__PURE__ */ jsx(Dialog$1.Description, {
264
+ className: "mt-0.5 text-label text-fg-4",
265
+ children: description
266
+ }) : null]
267
+ }),
268
+ actions,
269
+ /* @__PURE__ */ jsx(Dialog$1.Close, {
270
+ "aria-label": "Close",
271
+ className: "-mr-1 flex size-6 shrink-0 items-center justify-center rounded-md text-fg-3 transition-colors outline-none hover:bg-surface-hover hover:text-fg-1",
272
+ children: /* @__PURE__ */ jsx(X, { className: "size-3.5" })
273
+ })
274
+ ]
275
+ });
276
+ /** The scrolling region under the header. */
277
+ const DialogBody = ({ className, ...props }) => /* @__PURE__ */ jsx("div", {
278
+ className: cn("min-h-0 flex-1 overflow-y-auto p-4", className),
279
+ ...props
280
+ });
281
+ /** A label/value row — the shape every one of these panels is mostly made of. */
282
+ const DialogRow = ({ label, children, mono }) => /* @__PURE__ */ jsxs("div", {
283
+ className: "flex items-baseline justify-between gap-4 py-1.5",
284
+ children: [/* @__PURE__ */ jsx("span", {
285
+ className: "shrink-0 text-label text-fg-3",
286
+ children: label
287
+ }), /* @__PURE__ */ jsx("span", {
288
+ className: cn("min-w-0 truncate text-right text-body-sm text-fg-1", mono && "font-mono text-label"),
289
+ children
290
+ })]
291
+ });
292
+ //#endregion
210
293
  //#region src/components/ui/Tooltip.tsx
211
294
  const TooltipProvider = Tooltip.Provider;
212
295
  const TooltipContent = ({ className, side = "top", sideOffset = 6, ...props }) => /* @__PURE__ */ jsx(Tooltip.Portal, { children: /* @__PURE__ */ jsx(Tooltip.Positioner, {
@@ -219,12 +302,22 @@ const TooltipContent = ({ className, side = "top", sideOffset = 6, ...props }) =
219
302
  ...props
220
303
  })
221
304
  }) });
222
- /** Convenience wrapper: <Tip content="..."><Button/></Tip> */
223
- function Tip({ content, children }) {
305
+ /**
306
+ * Convenience wrapper: `<Tip content="..."><Button/></Tip>`.
307
+ *
308
+ * Pass `render` when the trigger must *be* an element you already have — a tab, a
309
+ * row — rather than something wrapped in a span. The default span is fine beside
310
+ * a button but would break any layout that styles its own children (a flex tab
311
+ * strip gets an extra box between the container and its items).
312
+ */
313
+ function Tip({ content, render, side, children }) {
224
314
  return /* @__PURE__ */ jsxs(Tooltip.Root, { children: [/* @__PURE__ */ jsx(Tooltip.Trigger, {
225
- render: /* @__PURE__ */ jsx("span", { className: "inline-flex" }),
315
+ render: render ?? /* @__PURE__ */ jsx("span", { className: "inline-flex" }),
226
316
  children
227
- }), /* @__PURE__ */ jsx(TooltipContent, { children: content })] });
317
+ }), /* @__PURE__ */ jsx(TooltipContent, {
318
+ side,
319
+ children: content
320
+ })] });
228
321
  }
229
322
  //#endregion
230
323
  //#region src/components/ui/Sonner.tsx
@@ -243,6 +336,53 @@ function Toaster() {
243
336
  });
244
337
  }
245
338
  //#endregion
339
+ //#region src/lib/clipboard.ts
340
+ /**
341
+ * Copy text to the clipboard, on origins where the modern API does not exist.
342
+ *
343
+ * `navigator.clipboard` is gated on a **secure context**: HTTPS, or localhost.
344
+ * A WorkerDeck dashboard reached the way it is meant to be reached — plain HTTP
345
+ * on a LAN address, from a laptop or a phone — is neither, so `navigator.clipboard`
346
+ * is `undefined` there and touching `.writeText` throws outright. That is the
347
+ * normal deployment, not an edge case, which is why this falls back rather than
348
+ * feature-detecting into a disabled button.
349
+ *
350
+ * The fallback is `document.execCommand('copy')` over an off-screen textarea.
351
+ * It is deprecated and it is also the only thing that works here; every browser
352
+ * still implements it. Returns whether the text actually landed, so a caller can
353
+ * avoid claiming success it did not have.
354
+ */
355
+ async function copyText(value) {
356
+ try {
357
+ if (navigator.clipboard?.writeText) {
358
+ await navigator.clipboard.writeText(value);
359
+ return true;
360
+ }
361
+ } catch {}
362
+ return legacyCopy(value);
363
+ }
364
+ function legacyCopy(value) {
365
+ if (typeof document === "undefined") return false;
366
+ const textarea = document.createElement("textarea");
367
+ textarea.value = value;
368
+ textarea.setAttribute("readonly", "");
369
+ textarea.style.position = "fixed";
370
+ textarea.style.top = "-9999px";
371
+ textarea.style.opacity = "0";
372
+ document.body.appendChild(textarea);
373
+ const previous = document.activeElement;
374
+ try {
375
+ textarea.select();
376
+ textarea.setSelectionRange(0, value.length);
377
+ return document.execCommand("copy");
378
+ } catch {
379
+ return false;
380
+ } finally {
381
+ document.body.removeChild(textarea);
382
+ if (previous instanceof HTMLElement) previous.focus();
383
+ }
384
+ }
385
+ //#endregion
246
386
  //#region src/components/ui/CopyButton.tsx
247
387
  function CopyButton({ value, className, variant = "ghost", size = "icon-sm", ...props }) {
248
388
  const [copied, setCopied] = useState(false);
@@ -252,7 +392,8 @@ function CopyButton({ value, className, variant = "ghost", size = "icon-sm", ...
252
392
  "aria-label": "Copy",
253
393
  className: cn("text-fg-3", className),
254
394
  onClick: () => {
255
- navigator.clipboard.writeText(value).then(() => {
395
+ copyText(value).then((ok) => {
396
+ if (!ok) return;
256
397
  setCopied(true);
257
398
  setTimeout(() => setCopied(false), 1500);
258
399
  });
@@ -290,6 +431,81 @@ function CodeBlock({ code, label, copyable = true, className }) {
290
431
  });
291
432
  }
292
433
  //#endregion
434
+ //#region src/components/ui/Splitter.tsx
435
+ /**
436
+ * A draggable pane divider.
437
+ *
438
+ * Hand-rolled rather than depended on: `@base-ui/react` ships no splitter, the
439
+ * behaviour is a hundred lines of pointer events, and this repo's instinct at
440
+ * this layer is to own it (the composer is vendored for the same reason).
441
+ *
442
+ * Pointer capture is what makes it survive a fast drag — without it the pointer
443
+ * leaves the 5px bar within a frame and the moves go to whatever is underneath,
444
+ * which for this layout is an iframe-free but still selection-happy code pane.
445
+ * The drag origin is captured on pointerdown and every move is measured against
446
+ * it, so the pane cannot drift relative to the cursor over a long drag the way
447
+ * per-move deltas do once clamping is involved.
448
+ *
449
+ * Keyboard-operable and announced as a separator, because a pane you can only
450
+ * size by dragging is a pane some people cannot size.
451
+ */
452
+ function Splitter({ orientation, value, onValueChange, min, max, step = 16, inverted, "aria-label": label, className }) {
453
+ const drag = useRef(null);
454
+ const vertical = orientation === "vertical";
455
+ const clamp = useCallback((next) => Math.min(max, Math.max(min, next)), [min, max]);
456
+ const onPointerDown = (event) => {
457
+ if (event.button !== 0) return;
458
+ event.preventDefault();
459
+ event.currentTarget.setPointerCapture(event.pointerId);
460
+ drag.current = {
461
+ origin: vertical ? event.clientX : event.clientY,
462
+ start: value
463
+ };
464
+ };
465
+ const onPointerMove = (event) => {
466
+ const state = drag.current;
467
+ if (!state) return;
468
+ const delta = (vertical ? event.clientX : event.clientY) - state.origin;
469
+ onValueChange(clamp(state.start + (inverted ? -delta : delta)));
470
+ };
471
+ const endDrag = (event) => {
472
+ if (!drag.current) return;
473
+ drag.current = null;
474
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
475
+ };
476
+ const onKeyDown = (event) => {
477
+ const grow = vertical ? "ArrowRight" : "ArrowDown";
478
+ const shrink = vertical ? "ArrowLeft" : "ArrowUp";
479
+ const direction = inverted ? -1 : 1;
480
+ if (event.key === grow) onValueChange(clamp(value + step * direction));
481
+ else if (event.key === shrink) onValueChange(clamp(value - step * direction));
482
+ else if (event.key === "Home") onValueChange(min);
483
+ else if (event.key === "End") onValueChange(max);
484
+ else return;
485
+ event.preventDefault();
486
+ };
487
+ return /* @__PURE__ */ jsx("div", {
488
+ "data-slot": "splitter",
489
+ role: "separator",
490
+ tabIndex: 0,
491
+ "aria-label": label,
492
+ "aria-orientation": orientation,
493
+ "aria-valuenow": Math.round(value),
494
+ "aria-valuemin": min,
495
+ "aria-valuemax": max,
496
+ onPointerDown,
497
+ onPointerMove,
498
+ onPointerUp: endDrag,
499
+ onPointerCancel: endDrag,
500
+ onKeyDown,
501
+ className: cn("group relative shrink-0 touch-none bg-border transition-colors", "hover:bg-border-strong focus-visible:bg-accent focus-visible:outline-none", vertical ? "w-px cursor-col-resize" : "h-px cursor-row-resize", className),
502
+ children: /* @__PURE__ */ jsx("span", {
503
+ "aria-hidden": true,
504
+ className: cn("absolute", vertical ? "-inset-x-[3px] inset-y-0" : "-inset-y-[3px] inset-x-0")
505
+ })
506
+ });
507
+ }
508
+ //#endregion
293
509
  //#region src/components/ui/ProgressRing.tsx
294
510
  /** Tiny SVG progress circle; stroke color comes from `currentColor` so callers set it
295
511
  * via text color classes (e.g. warning/danger past thresholds). */
@@ -519,6 +735,58 @@ function resolveChip(segments, activeTrigger, chip) {
519
735
  };
520
736
  }
521
737
  /**
738
+ * Replaces the active trigger's range with **plain text** instead of a chip.
739
+ *
740
+ * The sibling of {@link resolveChip}, for suggestions that are a typing aid
741
+ * rather than a token: what lands in the document is ordinary editable text the
742
+ * user is expected to finish and change, and it must not look or behave like a
743
+ * resolved chip — no immutability, no trigger character, nothing for a consumer
744
+ * to parse back out. The caret is left at the end of the inserted text so typing
745
+ * continues from there.
746
+ */
747
+ function resolveText(segments, activeTrigger, text) {
748
+ const triggerStart = activeTrigger.startOffset;
749
+ const triggerEnd = triggerStart + 1 + activeTrigger.query.length;
750
+ const newSegments = [];
751
+ let offset = 0;
752
+ let cursorOffset = triggerStart + text.length;
753
+ for (const seg of segments) {
754
+ if (seg.type === "chip") {
755
+ const chipEnd = offset + `${seg.trigger}${seg.displayText}`.length;
756
+ if (chipEnd <= triggerStart || offset >= triggerEnd) newSegments.push(seg);
757
+ offset = chipEnd;
758
+ continue;
759
+ }
760
+ const textStart = offset;
761
+ const textEnd = offset + seg.text.length;
762
+ if (textEnd <= triggerStart || textStart >= triggerEnd) newSegments.push(seg);
763
+ else {
764
+ const before = seg.text.slice(0, Math.max(0, triggerStart - textStart));
765
+ const after = seg.text.slice(Math.min(seg.text.length, triggerEnd - textStart));
766
+ if (before) newSegments.push({
767
+ type: "text",
768
+ text: before
769
+ });
770
+ newSegments.push({
771
+ type: "text",
772
+ text
773
+ });
774
+ if (after) newSegments.push({
775
+ type: "text",
776
+ text: after
777
+ });
778
+ }
779
+ offset = textEnd;
780
+ }
781
+ const merged = mergeAdjacentTextSegments(newSegments);
782
+ const total = segmentsToPlainText(merged).length;
783
+ if (cursorOffset > total) cursorOffset = total;
784
+ return {
785
+ segments: merged,
786
+ cursorOffset
787
+ };
788
+ }
789
+ /**
522
790
  * Removes a chip at the given segment index and merges adjacent text segments.
523
791
  *
524
792
  * @param segments - Current document segments
@@ -2908,6 +3176,20 @@ function usePromptArea({ value, onChange, triggers = [], disabled = false, onSub
2908
3176
  const selectSuggestionInternal = useCallback((suggestion) => {
2909
3177
  if (!activeTrigger) return;
2910
3178
  const segments = readSegmentsFromDOM();
3179
+ const asText = activeTrigger.config.insertAsText?.(suggestion);
3180
+ if (asText !== void 0) {
3181
+ const inserted = resolveText(segments, activeTrigger, asText);
3182
+ events.pushUndo(segments);
3183
+ onChange(inserted.segments);
3184
+ renderSegmentsToDOM(inserted.segments);
3185
+ const editor = editorRef.current;
3186
+ if (editor) setCursorAtOffset(editor, inserted.cursorOffset);
3187
+ dismissTrigger();
3188
+ setTimeout(() => {
3189
+ editorRef.current?.focus();
3190
+ }, 0);
3191
+ return;
3192
+ }
2911
3193
  const displayText = activeTrigger.config.onSelect?.(suggestion) ?? suggestion.label;
2912
3194
  const chipData = {
2913
3195
  value: suggestion.value,
@@ -3522,8 +3804,8 @@ function Svg({ className, children }) {
3522
3804
  children
3523
3805
  });
3524
3806
  }
3525
- const FileBody = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("path", { d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" }), /* @__PURE__ */ jsx("path", { d: "M14 2v5a1 1 0 0 0 1 1h5" })] });
3526
- const File = ({ className }) => /* @__PURE__ */ jsx(Svg, {
3807
+ const FileBody = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("path", { d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" }), /* @__PURE__ */ jsx("path", { d: "M14 2v5a1 1 0 0 0 1 1h5" })] });
3808
+ const File$1 = ({ className }) => /* @__PURE__ */ jsx(Svg, {
3527
3809
  className,
3528
3810
  children: FileBody
3529
3811
  });
@@ -3589,7 +3871,7 @@ const FILE_ICONS = {
3589
3871
  spreadsheet: FileSpreadsheet,
3590
3872
  code: FileCode,
3591
3873
  image: ImageIcon,
3592
- default: File
3874
+ default: File$1
3593
3875
  };
3594
3876
  /** Format bytes into a human-readable string. */
3595
3877
  function formatFileSize(bytes) {
@@ -4057,134 +4339,1144 @@ function getChipsByTrigger(segments, trigger) {
4057
4339
  return segments.filter((seg) => seg.type === "chip" && seg.trigger === trigger);
4058
4340
  }
4059
4341
  //#endregion
4342
+ //#region src/lib/format.ts
4343
+ function formatCost(usd) {
4344
+ if (usd === void 0 || Number.isNaN(usd)) return "—";
4345
+ if (usd === 0) return "$0.00";
4346
+ if (usd < .01) return "<$0.01";
4347
+ return `$${usd.toFixed(2)}`;
4348
+ }
4349
+ function formatDuration(ms) {
4350
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
4351
+ const s = ms / 1e3;
4352
+ if (s < 60) return `${s.toFixed(1)}s`;
4353
+ return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
4354
+ }
4355
+ /** Compact token count, Claude Code-style: 850 → "850", 359_000 → "359.0k", 1_200_000 → "1.2M". */
4356
+ function formatTokens(tokens) {
4357
+ if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(1)}M`;
4358
+ if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1)}k`;
4359
+ return String(Math.round(tokens));
4360
+ }
4361
+ function formatBytes(bytes) {
4362
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
4363
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
4364
+ return `${bytes} B`;
4365
+ }
4366
+ /** Countdown to an epoch-ms deadline: "2h 18m", "12m", "<1m"; "now" once passed. */
4367
+ function formatCountdown(untilEpochMs, now = Date.now()) {
4368
+ const remaining = untilEpochMs - now;
4369
+ if (remaining <= 0) return "now";
4370
+ const minutes = Math.ceil(remaining / 6e4);
4371
+ if (minutes < 1) return "<1m";
4372
+ if (minutes < 60) return `${minutes}m`;
4373
+ const days = Math.floor(minutes / 1440);
4374
+ if (days >= 1) return `${days}d ${Math.floor(minutes % 1440 / 60)}h`;
4375
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
4376
+ }
4377
+ function formatRelativeTime(epochMs, now = Date.now()) {
4378
+ if (!epochMs) return "—";
4379
+ const diff = Math.max(0, now - epochMs);
4380
+ const s = Math.floor(diff / 1e3);
4381
+ if (s < 60) return "just now";
4382
+ const m = Math.floor(s / 60);
4383
+ if (m < 60) return `${m}m ago`;
4384
+ const h = Math.floor(m / 60);
4385
+ if (h < 24) return `${h}h ago`;
4386
+ return `${Math.floor(h / 24)}d ago`;
4387
+ }
4388
+ /**
4389
+ * Human label for a rate-limit window key, compact: 'five_hour' → "5h",
4390
+ * 'seven_day_opus' → "7d opus". The per-model suffix is an open set — the CLI
4391
+ * adds buckets as plans gain them — so it is rewritten rather than enumerated.
4392
+ */
4393
+ function formatRateLimitWindow(key) {
4394
+ if (key === "five_hour") return "5h";
4395
+ if (key === "seven_day") return "7d";
4396
+ const spaced = key.replaceAll("_", " ");
4397
+ return key.startsWith("seven_day_") ? `7d ${spaced.slice(10)}` : spaced;
4398
+ }
4399
+ /** The same key spelled out, where there is room: 'five_hour' → "5-hour
4400
+ * session", 'seven_day_fable' → "Weekly · Fable". */
4401
+ function formatRateLimitWindowLong(key) {
4402
+ if (key === "five_hour") return "5-hour session";
4403
+ if (key === "seven_day") return "Weekly";
4404
+ if (key === "seven_day_oauth_apps") return "Weekly · apps";
4405
+ const capitalize = (s) => s.replace(/\b\w/g, (c) => c.toUpperCase());
4406
+ if (!key.startsWith("seven_day_")) return capitalize(key.replaceAll("_", " "));
4407
+ return `Weekly · ${capitalize(key.slice(10).replaceAll("_", " "))}`;
4408
+ }
4409
+ /**
4410
+ * How long a rate-limit window is, in seconds — the denominator behind the pace
4411
+ * marker. Derived from the key rather than reported: the CLI sends a reset time
4412
+ * and a percentage, never a duration. `undefined` for a window whose key doesn't
4413
+ * say, and the marker is then simply not drawn rather than guessed.
4414
+ */
4415
+ function rateLimitWindowSeconds(key) {
4416
+ if (key === "five_hour") return 5 * 3600;
4417
+ if (key.startsWith("seven_day")) return 7 * 86400;
4418
+ }
4419
+ /** "8 secs ago" / "3 mins ago" — a freshness line finer-grained than
4420
+ * {@link formatRelativeTime}, because a poll that just landed should say so. */
4421
+ function formatAgoPrecise(epochMs, now = Date.now()) {
4422
+ const seconds = Math.max(0, Math.floor((now - epochMs) / 1e3));
4423
+ if (seconds < 60) return `${seconds} sec${seconds === 1 ? "" : "s"} ago`;
4424
+ if (seconds < 3600) {
4425
+ const minutes = Math.floor(seconds / 60);
4426
+ return `${minutes} min${minutes === 1 ? "" : "s"} ago`;
4427
+ }
4428
+ const hours = Math.floor(seconds / 3600);
4429
+ return `${hours} hour${hours === 1 ? "" : "s"} ago`;
4430
+ }
4431
+ /** Compact one-line preview of a tool input for card headers. */
4432
+ function toolInputPreview(input, max = 80) {
4433
+ if (input === null || input === void 0) return "";
4434
+ if (typeof input === "object") {
4435
+ const o = input;
4436
+ const primary = o.command ?? o.file_path ?? o.path ?? o.url ?? o.pattern ?? o.query ?? o.description;
4437
+ if (typeof primary === "string") return primary.length > max ? primary.slice(0, max - 1) + "…" : primary;
4438
+ }
4439
+ const text = JSON.stringify(input) ?? "";
4440
+ return text.length > max ? text.slice(0, max - 1) + "…" : text;
4441
+ }
4442
+ //#endregion
4060
4443
  //#region src/components/agent/Composer.tsx
4061
4444
  /** CLI names may carry display annotations (e.g. "foo (MCP)") the parser rejects. */
4062
4445
  const cleanName = (name) => name.replace(/\s*\(MCP\)$/i, "");
4063
- /** Framed prompt input built on prompt-area's contentEditable: typing "/" — at the
4064
- * start or after whitespace — opens a suggestion dropdown fed by `commands`, and a
4065
- * picked command becomes an inline chip. Submit button flips to stop while a turn
4066
- * is running (messages still queue while busy). */
4067
- function Composer({ onSend, onInterrupt, busy, disabled, placeholder = "Message Claude…", commands, toolbar, className }) {
4446
+ /**
4447
+ * What a picked skill types into the composer.
4448
+ *
4449
+ * The engine's own `defaultPrompt` when it declared one — it knows what its
4450
+ * skill wants to be asked and otherwise `$name`, which is codex's native way
4451
+ * of referring to a skill in prompt text: its `skill-creator` documents the form
4452
+ * (`Use $skill-x at /path/to/skill-x to solve problem y`) and its own bundled
4453
+ * prompts are written that way ("Use $pdf to …"). Spelling it the way the engine
4454
+ * spells it beats paraphrasing into "Use the X skill to".
4455
+ *
4456
+ * Either way it ends in a space so the caret lands ready for the rest of the
4457
+ * sentence, and either way it is ordinary text: nothing here is submitted, and
4458
+ * nothing is parsed back out.
4459
+ */
4460
+ function skillPrompt(skill) {
4461
+ const base = skill.defaultPrompt?.trim() || `$${skill.name}`;
4462
+ return /\s$/.test(base) ? base : base + " ";
4463
+ }
4464
+ /** Ranks a haystack set against the typed query: 2 for a prefix hit, 1 for a
4465
+ * substring, 0 for no match. Shared so commands and skills sort as one list
4466
+ * rather than two concatenated ones. */
4467
+ function matchScore(query, haystacks) {
4468
+ const needle = query.toLowerCase();
4469
+ const lowered = haystacks.map((s) => s.toLowerCase());
4470
+ if (lowered.some((h) => h.startsWith(needle))) return 2;
4471
+ return lowered.some((h) => h.includes(needle)) ? 1 : 0;
4472
+ }
4473
+ /**
4474
+ * Framed prompt input built on prompt-area's contentEditable.
4475
+ *
4476
+ * Three completions ride the same field and behave nothing alike. `/` is the
4477
+ * CLI's command list and `$` is the engine's skill list — both local, so they
4478
+ * filter completely and instantly; `@` is a search against the host filesystem,
4479
+ * debounced and abortable so a fast typist makes one request rather than eight.
4480
+ *
4481
+ * `/` and `$` are separate keys rather than one merged menu, and that mirrors
4482
+ * the engines themselves: codex completes skills on `$` and reserves `/` for
4483
+ * commands. The behaviours differ too — a command resolves to a **chip**,
4484
+ * because the CLI really does parse `/name` out of the message, while a skill
4485
+ * resolves to plain editable **text**, because no engine parses `$name` as
4486
+ * syntax; it is prose the model reads. Rendering them alike would promise
4487
+ * something that does not happen.
4488
+ *
4489
+ * Files can arrive three ways — the paperclip, a drop, or a paste — because on a
4490
+ * desktop all three are things people already do, and the upload starts the
4491
+ * moment one lands rather than at send time.
4492
+ */
4493
+ function Composer({ onSend, onInterrupt, busy, disabled, placeholder = "Message the agent…", commands, skills, onSearchFiles, attachments, toolbar, className, ref }) {
4068
4494
  const { bind, plainText, isEmpty, clear, focus } = usePromptAreaState();
4495
+ const fileInput = useRef(null);
4496
+ const [dragging, setDragging] = useState(false);
4497
+ useImperativeHandle(ref, () => ({ insertText: (text) => {
4498
+ const prefix = plainText.length > 0 && !/\s$/.test(plainText) ? " " : "";
4499
+ bind.ref.current?.appendText(prefix + text);
4500
+ focus();
4501
+ } }), [
4502
+ bind.ref,
4503
+ plainText,
4504
+ focus
4505
+ ]);
4069
4506
  const triggers = useMemo(() => {
4070
- if (!commands || commands.length === 0) return void 0;
4071
- const seen = /* @__PURE__ */ new Set();
4072
- const unique = commands.flatMap((c) => {
4073
- const name = cleanName(c.name);
4074
- if (seen.has(name)) return [];
4075
- seen.add(name);
4076
- return [{
4077
- ...c,
4078
- name
4079
- }];
4080
- });
4081
- return [commandTrigger({
4507
+ const configured = [];
4508
+ const usableSkills = (skills ?? []).filter((s) => s.enabled);
4509
+ if (commands && commands.length > 0) {
4510
+ const seen = /* @__PURE__ */ new Set();
4511
+ const unique = commands.flatMap((c) => {
4512
+ const name = cleanName(c.name);
4513
+ if (seen.has(name)) return [];
4514
+ seen.add(name);
4515
+ return [{
4516
+ ...c,
4517
+ name
4518
+ }];
4519
+ });
4520
+ configured.push(commandTrigger({
4521
+ onSearch: (query) => {
4522
+ const scored = [];
4523
+ for (const c of unique) {
4524
+ const score = matchScore(query, [
4525
+ c.name,
4526
+ ...c.aliases ?? [],
4527
+ ...c.name.split(":")
4528
+ ]);
4529
+ if (score === 0) continue;
4530
+ scored.push({
4531
+ score,
4532
+ suggestion: {
4533
+ value: c.name,
4534
+ label: `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ""}`,
4535
+ description: c.description
4536
+ }
4537
+ });
4538
+ }
4539
+ scored.sort((a, b) => b.score - a.score);
4540
+ return scored.map(({ suggestion }) => suggestion);
4541
+ },
4542
+ onSelect: (suggestion) => suggestion.value,
4543
+ chipClassName: "font-mono"
4544
+ }));
4545
+ }
4546
+ if (usableSkills.length > 0) configured.push(commandTrigger({
4547
+ char: "$",
4548
+ accessibilityLabel: "skill",
4082
4549
  onSearch: (query) => {
4083
- const needle = query.toLowerCase();
4084
- const scored = unique.flatMap((c) => {
4085
- const haystacks = [c.name, ...c.aliases ?? []].map((s) => s.toLowerCase());
4086
- const score = haystacks.some((h) => h.startsWith(needle)) ? 2 : haystacks.some((h) => h.includes(needle)) ? 1 : 0;
4087
- return score === 0 ? [] : [{
4088
- c,
4089
- score
4090
- }];
4091
- });
4550
+ const scored = [];
4551
+ for (const skill of usableSkills) {
4552
+ const score = matchScore(query, [skill.name, ...skill.name.split(/[-:_]/)]);
4553
+ if (score === 0) continue;
4554
+ const summary = skill.shortDescription ?? skill.description;
4555
+ scored.push({
4556
+ score,
4557
+ suggestion: {
4558
+ value: skill.name,
4559
+ label: skill.displayName ?? skill.name,
4560
+ description: summary ? `Skill · ${summary}` : "Skill · inserts a message you can edit",
4561
+ icon: /* @__PURE__ */ jsx(Sparkles, { className: "size-3.5 text-fg-3" })
4562
+ }
4563
+ });
4564
+ }
4092
4565
  scored.sort((a, b) => b.score - a.score);
4093
- return scored.map(({ c }) => ({
4094
- value: c.name,
4095
- label: `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ""}`,
4096
- description: c.description
4566
+ return scored.map(({ suggestion }) => suggestion);
4567
+ },
4568
+ insertAsText: (suggestion) => {
4569
+ const skill = usableSkills.find((s) => s.name === suggestion.value);
4570
+ return skill ? skillPrompt(skill) : `$${suggestion.value} `;
4571
+ }
4572
+ }));
4573
+ if (onSearchFiles) configured.push(mentionTrigger({
4574
+ searchDebounceMs: 150,
4575
+ onSearch: async (query, options) => {
4576
+ return (await onSearchFiles(query, options)).map((match) => ({
4577
+ value: match.relative,
4578
+ label: match.relative,
4579
+ description: match.path
4097
4580
  }));
4098
4581
  },
4099
4582
  onSelect: (suggestion) => suggestion.value,
4100
- chipClassName: "font-mono"
4101
- })];
4102
- }, [commands]);
4583
+ chipStyle: "inline",
4584
+ chipClassName: "font-mono",
4585
+ emptyMessage: "No matching files"
4586
+ }));
4587
+ return configured.length > 0 ? configured : void 0;
4588
+ }, [
4589
+ commands,
4590
+ skills,
4591
+ onSearchFiles
4592
+ ]);
4593
+ const staged = attachments?.items ?? [];
4594
+ const canSend = !disabled && (!isEmpty || staged.length > 0) && !attachments?.uploading && !attachments?.hasFailure;
4103
4595
  const submit = () => {
4104
- const trimmed = plainText.trim();
4105
- if (!trimmed || disabled) return;
4106
- onSend(trimmed);
4596
+ if (!canSend) return;
4597
+ onSend(plainText.trim(), attachments?.readyIds ?? []);
4598
+ attachments?.clear();
4107
4599
  clear();
4108
4600
  focus();
4109
4601
  };
4110
- const canSend = !disabled && !isEmpty;
4602
+ const pick = (files) => {
4603
+ if (files && files.length > 0) attachments?.add(files);
4604
+ };
4111
4605
  return /* @__PURE__ */ jsxs("div", {
4112
4606
  "data-slot": "composer",
4113
4607
  className: cn("px-3 pb-3", className),
4114
4608
  children: [/* @__PURE__ */ jsxs("div", {
4115
- className: cn("mx-auto w-full max-w-3xl overflow-hidden rounded-lg border border-border bg-bg shadow-(--shadow-xs)", "transition-colors focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30", disabled && "opacity-60"),
4116
- children: [/* @__PURE__ */ jsx(PromptArea, {
4117
- ...bind,
4118
- triggers,
4119
- onSubmit: submit,
4120
- disabled,
4121
- placeholder: disabled ? "Session ended" : placeholder,
4122
- minHeight: 28,
4123
- maxHeight: 192,
4124
- "aria-label": "Message Claude",
4125
- className: "px-3 pt-2.5 pb-1 text-body-sm text-text"
4126
- }), /* @__PURE__ */ jsxs("div", {
4127
- className: "flex items-center justify-between gap-2 px-2 pb-2",
4128
- children: [/* @__PURE__ */ jsx("div", {
4129
- className: "flex min-w-0 items-center gap-1",
4130
- children: toolbar
4131
- }), busy ? /* @__PURE__ */ jsx(Button, {
4132
- variant: "outline",
4133
- size: "icon-sm",
4134
- "aria-label": "Interrupt",
4135
- className: "rounded-full",
4136
- onClick: onInterrupt,
4137
- children: /* @__PURE__ */ jsx(Square, { className: "size-3" })
4138
- }) : /* @__PURE__ */ jsx(Button, {
4139
- size: "icon-sm",
4140
- "aria-label": "Send",
4141
- className: "rounded-full",
4142
- disabled: !canSend,
4143
- onClick: submit,
4144
- children: /* @__PURE__ */ jsx(ArrowUp, { className: "size-4" })
4145
- })]
4146
- })]
4147
- }), /* @__PURE__ */ jsx("div", {
4609
+ onDragOver: (e) => {
4610
+ if (attachments && !attachments.disabled) {
4611
+ e.preventDefault();
4612
+ setDragging(true);
4613
+ }
4614
+ },
4615
+ onDragLeave: () => setDragging(false),
4616
+ onDrop: (e) => {
4617
+ if (!attachments || attachments.disabled) return;
4618
+ e.preventDefault();
4619
+ setDragging(false);
4620
+ pick(e.dataTransfer.files);
4621
+ },
4622
+ className: cn("mx-auto w-full max-w-3xl overflow-hidden rounded-lg border border-border bg-bg shadow-(--shadow-xs)", "transition-colors focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30", dragging && "border-ring ring-2 ring-ring/30", disabled && "opacity-60"),
4623
+ children: [
4624
+ staged.length > 0 && attachments ? /* @__PURE__ */ jsx(AttachmentStrip, { attachments }) : null,
4625
+ /* @__PURE__ */ jsx(PromptArea, {
4626
+ ...bind,
4627
+ triggers,
4628
+ onSubmit: submit,
4629
+ disabled,
4630
+ placeholder: disabled ? "Session ended" : placeholder,
4631
+ minHeight: 28,
4632
+ maxHeight: 192,
4633
+ "aria-label": "Message the agent",
4634
+ className: "px-3 pt-2.5 pb-1 text-body-sm text-text",
4635
+ onImagePaste: (file) => attachments?.add([file])
4636
+ }),
4637
+ /* @__PURE__ */ jsxs("div", {
4638
+ className: "flex items-center justify-between gap-2 px-2 pb-2",
4639
+ children: [/* @__PURE__ */ jsxs("div", {
4640
+ className: "flex min-w-0 items-center gap-1",
4641
+ children: [attachments && !attachments.disabled ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("input", {
4642
+ ref: fileInput,
4643
+ type: "file",
4644
+ multiple: true,
4645
+ accept: attachments.accept || void 0,
4646
+ className: "hidden",
4647
+ onChange: (e) => {
4648
+ pick(e.target.files);
4649
+ e.target.value = "";
4650
+ }
4651
+ }), /* @__PURE__ */ jsx(Button, {
4652
+ variant: "ghost",
4653
+ size: "icon-sm",
4654
+ "aria-label": "Attach files",
4655
+ disabled,
4656
+ onClick: () => fileInput.current?.click(),
4657
+ children: /* @__PURE__ */ jsx(Paperclip, { className: "size-4" })
4658
+ })] }) : null, toolbar]
4659
+ }), busy && !canSend ? /* @__PURE__ */ jsx(Button, {
4660
+ variant: "outline",
4661
+ size: "icon-sm",
4662
+ "aria-label": "Interrupt",
4663
+ className: "rounded-full",
4664
+ onClick: onInterrupt,
4665
+ children: /* @__PURE__ */ jsx(Square, { className: "size-3" })
4666
+ }) : /* @__PURE__ */ jsx(Button, {
4667
+ size: "icon-sm",
4668
+ "aria-label": "Send",
4669
+ className: "rounded-full",
4670
+ disabled: !canSend,
4671
+ onClick: submit,
4672
+ children: /* @__PURE__ */ jsx(ArrowUp, { className: "size-4" })
4673
+ })]
4674
+ })
4675
+ ]
4676
+ }), attachments?.error ? /* @__PURE__ */ jsxs("div", {
4677
+ className: "mx-auto mt-1 flex w-full max-w-3xl items-center gap-2 text-label text-danger",
4678
+ children: [
4679
+ /* @__PURE__ */ jsx(TriangleAlert, { className: "size-3 shrink-0" }),
4680
+ /* @__PURE__ */ jsx("span", {
4681
+ className: "min-w-0 flex-1",
4682
+ children: attachments.error
4683
+ }),
4684
+ /* @__PURE__ */ jsx("button", {
4685
+ type: "button",
4686
+ onClick: attachments.dismissError,
4687
+ "aria-label": "Dismiss",
4688
+ className: "shrink-0 opacity-70 hover:opacity-100",
4689
+ children: /* @__PURE__ */ jsx(X, { className: "size-3" })
4690
+ })
4691
+ ]
4692
+ }) : /* @__PURE__ */ jsx("div", {
4148
4693
  className: "mx-auto mt-1 w-full max-w-3xl text-center text-label text-fg-4",
4149
4694
  children: "Enter to send · Shift+Enter for a new line"
4150
4695
  })]
4151
4696
  });
4152
4697
  }
4153
- //#endregion
4154
- //#region src/components/agent/ModelSelect.tsx
4155
- /** The CLI's supportedModels list leads with a "Default (recommended)" row whose value
4156
- * is a sentinel, not a model id — selecting it means "clear the override". */
4157
- const isDefaultOption = (value) => value === "default";
4158
- /** Find the option matching a (possibly decorated/aliased) session model id. */
4159
- function matchModel(models, model) {
4160
- if (!model) return void 0;
4161
- const normalized = model.replace(/\[.*\]$/, "");
4162
- const concrete = models.filter((m) => !isDefaultOption(m.value));
4163
- return concrete.find((m) => m.value === normalized) ?? concrete.find((m) => normalized.includes(m.value) || m.value.includes(normalized));
4698
+ /** Staged files as a scrolling row of chips above the field. The thumbnail is
4699
+ * the local blob, so nothing here waits on the network; the upload's state rides
4700
+ * on top of it and the takes it back off. */
4701
+ function AttachmentStrip({ attachments }) {
4702
+ return /* @__PURE__ */ jsx("div", {
4703
+ className: "flex gap-2 overflow-x-auto border-b border-border px-2 py-2",
4704
+ children: attachments.items.map((item) => /* @__PURE__ */ jsx(AttachmentChip, {
4705
+ item,
4706
+ onRetry: () => attachments.retry(item.key),
4707
+ onRemove: () => attachments.remove(item.key)
4708
+ }, item.key))
4709
+ });
4164
4710
  }
4165
- /** Compact model switcher for the composer toolbar; fed by the `capabilities` event.
4166
- * Rows render CLI-style: bold display name with the model's description beneath. */
4167
- function ModelSelect({ models, model, onModelChange, variant = "toolbar", disabled, className }) {
4168
- const selected = matchModel(models, model) ?? (model ? void 0 : models.find((m) => isDefaultOption(m.value)));
4169
- return /* @__PURE__ */ jsxs(Select, {
4170
- items: models.map((m) => ({
4171
- value: m.value,
4172
- label: m.displayName
4173
- })),
4174
- value: selected?.value ?? null,
4175
- onValueChange: (value) => {
4176
- if (typeof value !== "string" || value === selected?.value) return;
4177
- onModelChange(isDefaultOption(value) ? void 0 : value);
4178
- },
4179
- disabled,
4180
- children: [/* @__PURE__ */ jsx(SelectTrigger, {
4181
- "aria-label": "Model",
4182
- className: cn(variant === "toolbar" && "h-6 max-w-56 border-transparent bg-transparent text-fg-3 hover:bg-surface-hover", className),
4183
- children: /* @__PURE__ */ jsx("span", {
4184
- className: cn("truncate", variant === "toolbar" && "font-mono text-label"),
4185
- children: /* @__PURE__ */ jsx(SelectValue, { placeholder: model ?? "model" })
4186
- })
4187
- }), /* @__PURE__ */ jsx(SelectContent, {
4711
+ function AttachmentChip({ item, onRetry, onRemove }) {
4712
+ const failed = item.status === "failed";
4713
+ return /* @__PURE__ */ jsxs("div", {
4714
+ className: "group relative shrink-0",
4715
+ title: failed ? `${item.name} ${item.error}` : `${item.name} · ${formatBytes(item.bytes)}`,
4716
+ children: [
4717
+ /* @__PURE__ */ jsx("div", {
4718
+ className: cn("flex size-14 items-center justify-center overflow-hidden rounded-md border border-border bg-surface", failed && "border-danger/50"),
4719
+ children: item.previewUrl ? /* @__PURE__ */ jsx("img", {
4720
+ src: item.previewUrl,
4721
+ alt: item.name,
4722
+ className: "size-full object-cover"
4723
+ }) : /* @__PURE__ */ jsxs("div", {
4724
+ className: "flex flex-col items-center gap-0.5 text-fg-3",
4725
+ children: [/* @__PURE__ */ jsx(FileText, { className: "size-4" }), /* @__PURE__ */ jsx("span", {
4726
+ className: "max-w-12 truncate text-[9px] font-semibold uppercase",
4727
+ children: extensionOf(item.name)
4728
+ })]
4729
+ })
4730
+ }),
4731
+ item.status === "uploading" ? /* @__PURE__ */ jsx("div", {
4732
+ className: "absolute inset-0 flex items-center justify-center rounded-md bg-black/35",
4733
+ children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-white" })
4734
+ }) : null,
4735
+ failed ? /* @__PURE__ */ jsx("button", {
4736
+ type: "button",
4737
+ onClick: onRetry,
4738
+ "aria-label": `Retry ${item.name}`,
4739
+ className: "absolute inset-0 flex items-center justify-center rounded-md bg-black/45 text-warning",
4740
+ children: /* @__PURE__ */ jsx(RotateCw, { className: "size-4" })
4741
+ }) : null,
4742
+ /* @__PURE__ */ jsx("button", {
4743
+ type: "button",
4744
+ onClick: onRemove,
4745
+ "aria-label": `Remove ${item.name}`,
4746
+ className: "absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full border border-border bg-surface text-fg-3 shadow-(--shadow-xs) hover:text-fg-1",
4747
+ children: /* @__PURE__ */ jsx(X, { className: "size-2.5" })
4748
+ })
4749
+ ]
4750
+ });
4751
+ }
4752
+ const extensionOf = (name) => {
4753
+ const dot = name.lastIndexOf(".");
4754
+ return dot > 0 ? name.slice(dot + 1).toUpperCase() : "FILE";
4755
+ };
4756
+ //#endregion
4757
+ //#region src/components/agent/ContextDialog.tsx
4758
+ /** The CLI reports category colors as its own theme token names ('inactive',
4759
+ * 'promptBorder', ...), not CSS colors — only pass through what CSS can render. */
4760
+ const cssColor$1 = (color) => typeof CSS !== "undefined" && CSS.supports("color", color) ? color : void 0;
4761
+ const usageTint$1 = (pct) => pct >= 90 ? "bg-danger" : pct >= 70 ? "bg-warning" : "bg-accent";
4762
+ /**
4763
+ * What is in the model's context window right now, category by category.
4764
+ *
4765
+ * One of the three panels the status bar opens. Context, usage and session info
4766
+ * are different questions asked at different moments, so they are different
4767
+ * screens rather than one "details" list you scroll past two answers to reach.
4768
+ */
4769
+ function ContextDialog({ usage, open, onOpenChange }) {
4770
+ return /* @__PURE__ */ jsx(Dialog, {
4771
+ open,
4772
+ onOpenChange,
4773
+ children: /* @__PURE__ */ jsxs(DialogContent, { children: [/* @__PURE__ */ jsx(DialogHeader, {
4774
+ title: "Context",
4775
+ description: usage?.model
4776
+ }), /* @__PURE__ */ jsx(DialogBody, { children: !usage ? /* @__PURE__ */ jsx("p", {
4777
+ className: "py-6 text-center text-body-sm text-fg-4",
4778
+ children: "No reading yet — the context window is measured after a turn completes."
4779
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
4780
+ /* @__PURE__ */ jsxs("div", {
4781
+ className: "flex items-baseline justify-between gap-4",
4782
+ children: [/* @__PURE__ */ jsx("span", {
4783
+ className: "text-label text-fg-3",
4784
+ children: "Used"
4785
+ }), /* @__PURE__ */ jsxs("span", {
4786
+ className: "font-mono text-body-sm text-fg-1",
4787
+ children: [
4788
+ formatTokens(usage.totalTokens),
4789
+ " / ",
4790
+ formatTokens(usage.maxTokens),
4791
+ " ·",
4792
+ " ",
4793
+ usage.percentage.toFixed(0),
4794
+ "%"
4795
+ ]
4796
+ })]
4797
+ }),
4798
+ /* @__PURE__ */ jsx("div", {
4799
+ className: "mt-2 h-2 overflow-hidden rounded-full bg-border",
4800
+ children: /* @__PURE__ */ jsx("div", {
4801
+ className: cn("h-full rounded-full", usageTint$1(usage.percentage)),
4802
+ style: { width: `${Math.min(100, Math.max(2, usage.percentage))}%` }
4803
+ })
4804
+ }),
4805
+ usage.categories.length > 0 ? /* @__PURE__ */ jsxs("div", {
4806
+ className: "mt-5",
4807
+ children: [/* @__PURE__ */ jsx("h3", {
4808
+ className: "text-label font-medium text-fg-3",
4809
+ children: "Breakdown"
4810
+ }), /* @__PURE__ */ jsx("div", {
4811
+ className: "mt-2 flex flex-col gap-3",
4812
+ children: usage.categories.map((category) => {
4813
+ const share = category.tokens / Math.max(usage.maxTokens, 1) * 100;
4814
+ const color = cssColor$1(category.color);
4815
+ return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("div", {
4816
+ className: "flex items-baseline justify-between gap-3",
4817
+ children: [/* @__PURE__ */ jsxs("span", {
4818
+ className: "flex min-w-0 items-center gap-2",
4819
+ children: [/* @__PURE__ */ jsx("span", {
4820
+ className: "size-2 shrink-0 rounded-full bg-fg-4",
4821
+ style: color ? { backgroundColor: color } : void 0
4822
+ }), /* @__PURE__ */ jsx("span", {
4823
+ className: "truncate text-body-sm text-fg-1",
4824
+ children: category.name
4825
+ })]
4826
+ }), /* @__PURE__ */ jsx("span", {
4827
+ className: "shrink-0 font-mono text-label text-fg-3",
4828
+ children: formatTokens(category.tokens)
4829
+ })]
4830
+ }), /* @__PURE__ */ jsx("div", {
4831
+ className: "mt-1 h-1 overflow-hidden rounded-full bg-border",
4832
+ children: /* @__PURE__ */ jsx("div", {
4833
+ className: "h-full rounded-full bg-fg-4",
4834
+ style: {
4835
+ width: `${Math.min(100, share)}%`,
4836
+ backgroundColor: color
4837
+ }
4838
+ })
4839
+ })] }, category.name);
4840
+ })
4841
+ })]
4842
+ }) : null
4843
+ ] }) })] })
4844
+ });
4845
+ }
4846
+ //#endregion
4847
+ //#region src/components/agent/HostFilesDialog.tsx
4848
+ /**
4849
+ * Browse the project the session is working in.
4850
+ *
4851
+ * Deliberately rooted at the session's cwd rather than at the server's
4852
+ * `hostFiles.roots`: the roots are the *security* boundary (the server enforces
4853
+ * them on every request), but what someone wants while watching an agent is this
4854
+ * project's tree. Read-only — writing is a separate server opt-in and not
4855
+ * something a session viewer should be doing behind the agent's back.
4856
+ */
4857
+ function HostFilesDialog({ client, cwd, open, onOpenChange }) {
4858
+ const [path, setPath] = useState(cwd);
4859
+ const [entries, setEntries] = useState([]);
4860
+ const [truncated, setTruncated] = useState(false);
4861
+ const [query, setQuery] = useState("");
4862
+ const [matches, setMatches] = useState();
4863
+ const [file, setFile] = useState();
4864
+ const [loading, setLoading] = useState(false);
4865
+ const [error, setError] = useState();
4866
+ const list = useCallback(async (target) => {
4867
+ setLoading(true);
4868
+ setError(void 0);
4869
+ try {
4870
+ const response = await client.listHostDir(target);
4871
+ setPath(response.path);
4872
+ setEntries(response.entries);
4873
+ setTruncated(response.truncated ?? false);
4874
+ } catch (e) {
4875
+ setError(e instanceof Error ? e.message : "Could not read that directory");
4876
+ } finally {
4877
+ setLoading(false);
4878
+ }
4879
+ }, [client]);
4880
+ useEffect(() => {
4881
+ if (!open) return;
4882
+ setFile(void 0);
4883
+ setQuery("");
4884
+ setMatches(void 0);
4885
+ setError(void 0);
4886
+ if (cwd) list(cwd);
4887
+ }, [
4888
+ open,
4889
+ cwd,
4890
+ list
4891
+ ]);
4892
+ useEffect(() => {
4893
+ if (!open || !cwd) return;
4894
+ const q = query.trim();
4895
+ if (!q) {
4896
+ setMatches(void 0);
4897
+ return;
4898
+ }
4899
+ const timer = setTimeout(() => {
4900
+ client.findHostFiles(cwd, q, 40).then((response) => setMatches(response.matches)).catch(() => setMatches([]));
4901
+ }, 150);
4902
+ return () => clearTimeout(timer);
4903
+ }, [
4904
+ client,
4905
+ cwd,
4906
+ query,
4907
+ open
4908
+ ]);
4909
+ const openFile = async (target) => {
4910
+ setLoading(true);
4911
+ setError(void 0);
4912
+ try {
4913
+ const response = await client.readHostFile(target);
4914
+ setFile({
4915
+ path: response.path,
4916
+ bytes: response.bytes,
4917
+ content: response.encoding === "utf8" ? response.content : "(binary file — not shown)"
4918
+ });
4919
+ } catch (e) {
4920
+ setError(e instanceof Error ? e.message : "Could not read that file");
4921
+ } finally {
4922
+ setLoading(false);
4923
+ }
4924
+ };
4925
+ const parent = path && cwd && path !== cwd ? path.slice(0, path.lastIndexOf("/")) || "/" : void 0;
4926
+ const shown = matches ?? entries;
4927
+ return /* @__PURE__ */ jsx(Dialog, {
4928
+ open,
4929
+ onOpenChange,
4930
+ children: /* @__PURE__ */ jsxs(DialogContent, {
4931
+ size: "lg",
4932
+ children: [/* @__PURE__ */ jsx(DialogHeader, {
4933
+ title: file ? file.path.split("/").pop() : "Files",
4934
+ description: file ? file.path : path,
4935
+ actions: file ? /* @__PURE__ */ jsxs(Button, {
4936
+ variant: "ghost",
4937
+ size: "xs",
4938
+ onClick: () => setFile(void 0),
4939
+ children: [/* @__PURE__ */ jsx(ChevronLeft, { className: "size-3.5" }), "Back"]
4940
+ }) : parent ? /* @__PURE__ */ jsxs(Button, {
4941
+ variant: "ghost",
4942
+ size: "xs",
4943
+ onClick: () => void list(parent),
4944
+ children: [/* @__PURE__ */ jsx(ChevronLeft, { className: "size-3.5" }), "Up"]
4945
+ }) : null
4946
+ }), /* @__PURE__ */ jsxs(DialogBody, {
4947
+ className: "flex flex-col gap-3",
4948
+ children: [error ? /* @__PURE__ */ jsx("div", {
4949
+ className: "rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger",
4950
+ children: error
4951
+ }) : null, file ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("p", {
4952
+ className: "text-label text-fg-4",
4953
+ children: formatBytes(file.bytes)
4954
+ }), /* @__PURE__ */ jsx(CodeBlock, {
4955
+ code: file.content,
4956
+ label: file.path.split("/").pop()
4957
+ })] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
4958
+ /* @__PURE__ */ jsxs("div", {
4959
+ className: "relative",
4960
+ children: [/* @__PURE__ */ jsx(Search, { className: "absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4" }), /* @__PURE__ */ jsx(Input, {
4961
+ value: query,
4962
+ onChange: (e) => setQuery(e.target.value),
4963
+ placeholder: "Search this project…",
4964
+ className: "pl-7",
4965
+ spellCheck: false
4966
+ })]
4967
+ }),
4968
+ loading && shown.length === 0 ? /* @__PURE__ */ jsx("div", {
4969
+ className: "py-6 text-center",
4970
+ children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-fg-4" })
4971
+ }) : shown.length === 0 ? /* @__PURE__ */ jsx("p", {
4972
+ className: "py-6 text-center text-body-sm text-fg-4",
4973
+ children: matches ? "No matching files." : "This directory is empty."
4974
+ }) : /* @__PURE__ */ jsx("ul", {
4975
+ className: "flex flex-col",
4976
+ children: matches ? matches.map((match) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
4977
+ type: "button",
4978
+ onClick: () => void openFile(match.path),
4979
+ className: "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover",
4980
+ children: [/* @__PURE__ */ jsx(File, { className: "size-3.5 shrink-0 text-fg-4" }), /* @__PURE__ */ jsx("span", {
4981
+ className: "min-w-0 flex-1 truncate font-mono text-label text-fg-1",
4982
+ children: match.relative
4983
+ })]
4984
+ }) }, match.path)) : entries.map((entry) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
4985
+ type: "button",
4986
+ onClick: () => entry.type === "dir" ? void list(entry.path) : void openFile(entry.path),
4987
+ className: "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover",
4988
+ children: [
4989
+ /* @__PURE__ */ jsx(EntryIcon$1, { type: entry.type }),
4990
+ /* @__PURE__ */ jsxs("span", {
4991
+ className: "min-w-0 flex-1 truncate font-mono text-label text-fg-1",
4992
+ children: [entry.name, entry.type === "dir" ? "/" : ""]
4993
+ }),
4994
+ entry.bytes !== void 0 ? /* @__PURE__ */ jsx("span", {
4995
+ className: "shrink-0 text-label text-fg-4",
4996
+ children: formatBytes(entry.bytes)
4997
+ }) : null
4998
+ ]
4999
+ }) }, entry.path))
5000
+ }),
5001
+ truncated && !matches ? /* @__PURE__ */ jsx("p", {
5002
+ className: "text-label text-fg-4",
5003
+ children: "More entries than the server will return — use the search box."
5004
+ }) : null
5005
+ ] })]
5006
+ })]
5007
+ })
5008
+ });
5009
+ }
5010
+ /** A symlink is reported as itself and never silently resolved — following it is
5011
+ * the next request's problem, and that request is refused if it escapes the roots. */
5012
+ function EntryIcon$1({ type }) {
5013
+ if (type === "dir") return /* @__PURE__ */ jsx(Folder, { className: "size-3.5 shrink-0 text-accent" });
5014
+ if (type === "symlink") return /* @__PURE__ */ jsx(Link2, { className: "size-3.5 shrink-0 text-fg-4" });
5015
+ return /* @__PURE__ */ jsx(File, { className: "size-3.5 shrink-0 text-fg-4" });
5016
+ }
5017
+ //#endregion
5018
+ //#region src/components/agent/McpDialog.tsx
5019
+ /** The engine's status vocabulary is open — anything unrecognised renders
5020
+ * neutrally rather than being forced into one of these. */
5021
+ const STATUS_VARIANT = {
5022
+ connected: "success",
5023
+ failed: "danger",
5024
+ "needs-auth": "warning",
5025
+ pending: "info",
5026
+ disabled: "neutral"
5027
+ };
5028
+ /**
5029
+ * The session's MCP servers, at the CLI's own `/mcp` depth: servers → one server
5030
+ * → its tools → one tool, with Reconnect / Enable / Disable where they apply.
5031
+ *
5032
+ * Two things vary by engine rather than being fixed here. **The actions** exist
5033
+ * only where the engine has them (`canManageServers`): codex reports rich status
5034
+ * but has no per-server reconnect or toggle, so its panel is read-only. And
5035
+ * **tool parameters** appear only where the engine reports a schema — codex
5036
+ * returns each tool's full JSON Schema, the Agent SDK returns none at all, so
5037
+ * the tool view either renders it or says why it can't, rather than leaving a
5038
+ * silent gap or claiming the absence is universal.
5039
+ */
5040
+ function McpDialog({ client, sessionId, open, onOpenChange, canManageServers = true }) {
5041
+ const [servers, setServers] = useState();
5042
+ const [error, setError] = useState();
5043
+ const [loading, setLoading] = useState(false);
5044
+ const [busyServer, setBusyServer] = useState();
5045
+ const [selectedServer, setSelectedServer] = useState();
5046
+ const [selectedTool, setSelectedTool] = useState();
5047
+ const load = useCallback(async () => {
5048
+ if (!sessionId) return;
5049
+ setLoading(true);
5050
+ setError(void 0);
5051
+ try {
5052
+ setServers(await client.listMcpServers(sessionId));
5053
+ } catch (e) {
5054
+ setError(e instanceof Error ? e.message : "Could not read MCP status");
5055
+ } finally {
5056
+ setLoading(false);
5057
+ }
5058
+ }, [client, sessionId]);
5059
+ useEffect(() => {
5060
+ if (!open) return;
5061
+ setSelectedServer(void 0);
5062
+ setSelectedTool(void 0);
5063
+ load();
5064
+ }, [open, load]);
5065
+ const act = async (name, action) => {
5066
+ if (!sessionId) return;
5067
+ setBusyServer(name);
5068
+ setError(void 0);
5069
+ try {
5070
+ setServers(await client.mcpServerAction(sessionId, name, action));
5071
+ } catch (e) {
5072
+ setError(e instanceof Error ? e.message : `Could not ${action} ${name}`);
5073
+ } finally {
5074
+ setBusyServer(void 0);
5075
+ }
5076
+ };
5077
+ const server = servers?.find((s) => s.name === selectedServer);
5078
+ const tool = server?.tools?.find((t) => t.name === selectedTool);
5079
+ return /* @__PURE__ */ jsx(Dialog, {
5080
+ open,
5081
+ onOpenChange,
5082
+ children: /* @__PURE__ */ jsxs(DialogContent, { children: [/* @__PURE__ */ jsx(DialogHeader, {
5083
+ title: tool?.name ?? server?.name ?? "MCP servers",
5084
+ description: tool ? `${server?.name} tool` : server ? server.serverInfo?.name : void 0,
5085
+ actions: selectedServer ? /* @__PURE__ */ jsxs(Button, {
5086
+ variant: "ghost",
5087
+ size: "xs",
5088
+ onClick: () => selectedTool ? setSelectedTool(void 0) : setSelectedServer(void 0),
5089
+ children: [/* @__PURE__ */ jsx(ChevronLeft, { className: "size-3.5" }), "Back"]
5090
+ }) : /* @__PURE__ */ jsxs(Button, {
5091
+ variant: "ghost",
5092
+ size: "xs",
5093
+ onClick: () => void load(),
5094
+ disabled: loading,
5095
+ children: [loading ? /* @__PURE__ */ jsx(Spinner, { className: "size-3 text-current" }) : /* @__PURE__ */ jsx(RotateCw, { className: "size-3" }), "Refresh"]
5096
+ })
5097
+ }), /* @__PURE__ */ jsxs(DialogBody, { children: [error ? /* @__PURE__ */ jsx("div", {
5098
+ className: "mb-3 rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger",
5099
+ children: error
5100
+ }) : null, tool ? /* @__PURE__ */ jsx(ToolView, { tool }) : server ? /* @__PURE__ */ jsx(ServerView, {
5101
+ server,
5102
+ busy: busyServer === server.name,
5103
+ canManage: canManageServers,
5104
+ onAct: (action) => void act(server.name, action),
5105
+ onSelectTool: setSelectedTool
5106
+ }) : error && !servers ? null : /* @__PURE__ */ jsx(ServerList, {
5107
+ servers,
5108
+ loading,
5109
+ onSelect: setSelectedServer
5110
+ })] })] })
5111
+ });
5112
+ }
5113
+ function ServerList({ servers, loading, onSelect }) {
5114
+ if (loading && !servers) return /* @__PURE__ */ jsx("div", {
5115
+ className: "py-6 text-center",
5116
+ children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-fg-4" })
5117
+ });
5118
+ if (!servers?.length) return /* @__PURE__ */ jsx("p", {
5119
+ className: "py-6 text-center text-body-sm text-fg-4",
5120
+ children: "No MCP servers configured for this session."
5121
+ });
5122
+ return /* @__PURE__ */ jsx("div", {
5123
+ className: "flex flex-col gap-4",
5124
+ children: [...new Set(servers.map((s) => s.scope ?? "other"))].map((scope) => /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h3", {
5125
+ className: "text-label font-medium text-fg-3 capitalize",
5126
+ children: scope
5127
+ }), /* @__PURE__ */ jsx("ul", {
5128
+ className: "mt-1 flex flex-col",
5129
+ children: servers.filter((s) => (s.scope ?? "other") === scope).map((s) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
5130
+ type: "button",
5131
+ onClick: () => onSelect(s.name),
5132
+ className: "flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-surface-hover",
5133
+ children: [
5134
+ /* @__PURE__ */ jsx("span", {
5135
+ className: "min-w-0 flex-1 truncate text-body-sm text-fg-1",
5136
+ children: s.name
5137
+ }),
5138
+ s.tools?.length ? /* @__PURE__ */ jsxs("span", {
5139
+ className: "shrink-0 text-label text-fg-4",
5140
+ children: [s.tools.length, " tools"]
5141
+ }) : null,
5142
+ /* @__PURE__ */ jsx(Badge, {
5143
+ variant: STATUS_VARIANT[s.status] ?? "neutral",
5144
+ dot: true,
5145
+ className: "shrink-0",
5146
+ children: s.status
5147
+ }),
5148
+ /* @__PURE__ */ jsx(ChevronRight, { className: "size-3.5 shrink-0 text-fg-4" })
5149
+ ]
5150
+ }) }, s.name))
5151
+ })] }, scope))
5152
+ });
5153
+ }
5154
+ function ServerView({ server, busy, canManage, onAct, onSelectTool }) {
5155
+ const disabled = server.status === "disabled";
5156
+ return /* @__PURE__ */ jsxs("div", {
5157
+ className: "flex flex-col gap-4",
5158
+ children: [
5159
+ /* @__PURE__ */ jsxs("div", { children: [
5160
+ /* @__PURE__ */ jsx(DialogRow, {
5161
+ label: "Status",
5162
+ children: /* @__PURE__ */ jsx(Badge, {
5163
+ variant: STATUS_VARIANT[server.status] ?? "neutral",
5164
+ dot: true,
5165
+ children: server.status
5166
+ })
5167
+ }),
5168
+ server.transport ? /* @__PURE__ */ jsx(DialogRow, {
5169
+ label: "Transport",
5170
+ children: server.transport
5171
+ }) : null,
5172
+ server.scope ? /* @__PURE__ */ jsx(DialogRow, {
5173
+ label: "Scope",
5174
+ children: server.scope
5175
+ }) : null,
5176
+ server.serverInfo ? /* @__PURE__ */ jsxs(DialogRow, {
5177
+ label: "Server",
5178
+ children: [
5179
+ server.serverInfo.name,
5180
+ " ",
5181
+ server.serverInfo.version
5182
+ ]
5183
+ }) : null,
5184
+ server.command ? /* @__PURE__ */ jsx(DialogRow, {
5185
+ label: "Command",
5186
+ mono: true,
5187
+ children: [server.command, ...server.args ?? []].join(" ")
5188
+ }) : null,
5189
+ server.url ? /* @__PURE__ */ jsx(DialogRow, {
5190
+ label: "URL",
5191
+ mono: true,
5192
+ children: server.url
5193
+ }) : null
5194
+ ] }),
5195
+ server.error ? /* @__PURE__ */ jsx("div", {
5196
+ className: "rounded-md bg-danger-bg px-3 py-2 text-body-sm break-words text-danger",
5197
+ children: server.error
5198
+ }) : null,
5199
+ canManage ? /* @__PURE__ */ jsxs("div", {
5200
+ className: "flex gap-2",
5201
+ children: [/* @__PURE__ */ jsxs(Button, {
5202
+ variant: "outline",
5203
+ size: "sm",
5204
+ disabled: busy,
5205
+ onClick: () => onAct("reconnect"),
5206
+ children: [busy ? /* @__PURE__ */ jsx(Spinner, { className: "size-3 text-current" }) : /* @__PURE__ */ jsx(RotateCw, { className: "size-3" }), "Reconnect"]
5207
+ }), /* @__PURE__ */ jsxs(Button, {
5208
+ variant: "outline",
5209
+ size: "sm",
5210
+ disabled: busy,
5211
+ onClick: () => onAct(disabled ? "enable" : "disable"),
5212
+ children: [disabled ? /* @__PURE__ */ jsx(Power, { className: "size-3" }) : /* @__PURE__ */ jsx(PowerOff, { className: "size-3" }), disabled ? "Enable" : "Disable"]
5213
+ })]
5214
+ }) : null,
5215
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h3", {
5216
+ className: "text-label font-medium text-fg-3",
5217
+ children: "Tools"
5218
+ }), !server.tools?.length ? /* @__PURE__ */ jsx("p", {
5219
+ className: "py-2 text-body-sm text-fg-4",
5220
+ children: server.status === "connected" ? "This server exposes no tools." : "Tools are listed once the server connects."
5221
+ }) : /* @__PURE__ */ jsx("ul", {
5222
+ className: "mt-1 flex flex-col",
5223
+ children: server.tools.map((t) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
5224
+ type: "button",
5225
+ onClick: () => onSelectTool(t.name),
5226
+ className: "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover",
5227
+ children: [/* @__PURE__ */ jsx("span", {
5228
+ className: "min-w-0 flex-1 truncate font-mono text-label text-fg-1",
5229
+ children: t.name
5230
+ }), /* @__PURE__ */ jsx(ChevronRight, { className: "size-3.5 shrink-0 text-fg-4" })]
5231
+ }) }, t.name))
5232
+ })] })
5233
+ ]
5234
+ });
5235
+ }
5236
+ function ToolView({ tool }) {
5237
+ const annotations = tool.annotations;
5238
+ return /* @__PURE__ */ jsxs("div", {
5239
+ className: "flex flex-col gap-3",
5240
+ children: [
5241
+ tool.description ? /* @__PURE__ */ jsx("p", {
5242
+ className: "text-body-sm whitespace-pre-wrap text-fg-2",
5243
+ children: tool.description
5244
+ }) : /* @__PURE__ */ jsx("p", {
5245
+ className: "text-body-sm text-fg-4",
5246
+ children: "This tool carries no description."
5247
+ }),
5248
+ annotations ? /* @__PURE__ */ jsxs("div", {
5249
+ className: "flex flex-wrap gap-1.5",
5250
+ children: [
5251
+ annotations.readOnly ? /* @__PURE__ */ jsx(Badge, {
5252
+ variant: "success",
5253
+ children: "read-only"
5254
+ }) : null,
5255
+ annotations.destructive ? /* @__PURE__ */ jsx(Badge, {
5256
+ variant: "danger",
5257
+ children: "destructive"
5258
+ }) : null,
5259
+ annotations.openWorld ? /* @__PURE__ */ jsx(Badge, {
5260
+ variant: "warning",
5261
+ children: "open world"
5262
+ }) : null
5263
+ ]
5264
+ }) : null,
5265
+ tool.inputSchema !== void 0 ? /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h3", {
5266
+ className: "text-label font-medium text-fg-3",
5267
+ children: "Parameters"
5268
+ }), /* @__PURE__ */ jsx("pre", {
5269
+ className: "mt-1 max-h-64 overflow-auto rounded-md bg-surface px-3 py-2 text-label text-fg-2",
5270
+ children: safeSchema(tool.inputSchema)
5271
+ })] }) : /* @__PURE__ */ jsx("p", {
5272
+ className: cn("text-label text-fg-4"),
5273
+ children: "Parameters aren’t available: this engine names and describes each tool but reports no input schema."
5274
+ })
5275
+ ]
5276
+ });
5277
+ }
5278
+ /** The schema is an opaque JSON document from another process — pretty-print it,
5279
+ * and never let an unserializable value take the dialog down with it. */
5280
+ function safeSchema(schema) {
5281
+ try {
5282
+ return JSON.stringify(schema, null, 2);
5283
+ } catch {
5284
+ return String(schema);
5285
+ }
5286
+ }
5287
+ //#endregion
5288
+ //#region src/components/agent/SkillsDialog.tsx
5289
+ /** Where the skill came from. The engine's set is open, so an unrecognised
5290
+ * scope renders as itself rather than being forced into a bucket. */
5291
+ const SCOPE_LABEL = {
5292
+ user: "Personal",
5293
+ repo: "This project",
5294
+ system: "System",
5295
+ admin: "Managed"
5296
+ };
5297
+ /**
5298
+ * What this session's engine can do beyond its own tools: the skills it found,
5299
+ * grouped by where they came from, with one drilled-down view each.
5300
+ *
5301
+ * The framing matters more here than in most panels. A skill is **not** a
5302
+ * command — the model decides to use one by reading its description, and there
5303
+ * is no wire syntax that invokes it. So this screen is a *discovery* surface,
5304
+ * and the one action it offers ("Use this skill") is honest about being a
5305
+ * drafting aid: it types a message for the operator to edit and send.
5306
+ *
5307
+ * Fed from the session's `skills` event rather than a REST route, because that
5308
+ * is the channel the engine refreshes on its own when a skill changes on disk.
5309
+ */
5310
+ function SkillsDialog({ skills, open, onOpenChange, onUse }) {
5311
+ const [selected, setSelected] = useState();
5312
+ const skill = skills?.find((s) => s.name === selected);
5313
+ return /* @__PURE__ */ jsx(Dialog, {
5314
+ open,
5315
+ onOpenChange: (next) => {
5316
+ if (!next) setSelected(void 0);
5317
+ onOpenChange(next);
5318
+ },
5319
+ children: /* @__PURE__ */ jsxs(DialogContent, { children: [/* @__PURE__ */ jsx(DialogHeader, {
5320
+ title: skill ? skill.displayName ?? skill.name : "Skills",
5321
+ description: skill ? skill.name !== (skill.displayName ?? skill.name) ? skill.name : void 0 : "Capabilities the agent can choose to use. Not commands — the model picks them from their descriptions.",
5322
+ actions: skill ? /* @__PURE__ */ jsxs(Button, {
5323
+ variant: "ghost",
5324
+ size: "xs",
5325
+ onClick: () => setSelected(void 0),
5326
+ children: [/* @__PURE__ */ jsx(ChevronLeft, { className: "size-3.5" }), "Back"]
5327
+ }) : void 0
5328
+ }), /* @__PURE__ */ jsx(DialogBody, { children: skill ? /* @__PURE__ */ jsx(SkillView, {
5329
+ skill,
5330
+ onUse,
5331
+ onUsed: () => onOpenChange(false)
5332
+ }) : /* @__PURE__ */ jsx(SkillList, {
5333
+ skills,
5334
+ onSelect: setSelected
5335
+ }) })] })
5336
+ });
5337
+ }
5338
+ function SkillList({ skills, onSelect }) {
5339
+ if (!skills) return /* @__PURE__ */ jsx("p", {
5340
+ className: "py-6 text-center text-body-sm text-fg-4",
5341
+ children: "Skills are listed once the session connects — send a message first."
5342
+ });
5343
+ if (skills.length === 0) return /* @__PURE__ */ jsx("p", {
5344
+ className: "py-6 text-center text-body-sm text-fg-4",
5345
+ children: "This session found no skills."
5346
+ });
5347
+ return /* @__PURE__ */ jsx("div", {
5348
+ className: "flex flex-col gap-4",
5349
+ children: [...new Set(skills.map((s) => s.scope ?? "other"))].map((scope) => /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h3", {
5350
+ className: "text-label font-medium text-fg-3",
5351
+ children: SCOPE_LABEL[scope] ?? scope
5352
+ }), /* @__PURE__ */ jsx("ul", {
5353
+ className: "mt-1 flex flex-col",
5354
+ children: skills.filter((s) => (s.scope ?? "other") === scope).map((s) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
5355
+ type: "button",
5356
+ onClick: () => onSelect(s.name),
5357
+ className: "flex w-full items-start gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-surface-hover",
5358
+ children: [
5359
+ /* @__PURE__ */ jsxs("span", {
5360
+ className: "min-w-0 flex-1",
5361
+ children: [/* @__PURE__ */ jsx("span", {
5362
+ className: "block truncate text-body-sm text-fg-1",
5363
+ children: s.displayName ?? s.name
5364
+ }), s.shortDescription ?? s.description ? /* @__PURE__ */ jsx("span", {
5365
+ className: "block truncate text-label text-fg-4",
5366
+ children: s.shortDescription ?? s.description
5367
+ }) : null]
5368
+ }),
5369
+ !s.enabled ? /* @__PURE__ */ jsx(Badge, {
5370
+ variant: "neutral",
5371
+ className: "mt-0.5 shrink-0",
5372
+ children: "off"
5373
+ }) : null,
5374
+ /* @__PURE__ */ jsx(ChevronRight, { className: "mt-0.5 size-3.5 shrink-0 text-fg-4" })
5375
+ ]
5376
+ }) }, s.name))
5377
+ })] }, scope))
5378
+ });
5379
+ }
5380
+ function SkillView({ skill, onUse, onUsed }) {
5381
+ return /* @__PURE__ */ jsxs("div", {
5382
+ className: "flex flex-col gap-4",
5383
+ children: [
5384
+ /* @__PURE__ */ jsxs("div", { children: [skill.scope ? /* @__PURE__ */ jsx(DialogRow, {
5385
+ label: "Scope",
5386
+ children: SCOPE_LABEL[skill.scope] ?? skill.scope
5387
+ }) : null, /* @__PURE__ */ jsx(DialogRow, {
5388
+ label: "Status",
5389
+ children: /* @__PURE__ */ jsx(Badge, {
5390
+ variant: skill.enabled ? "success" : "neutral",
5391
+ dot: true,
5392
+ children: skill.enabled ? "enabled" : "disabled"
5393
+ })
5394
+ })] }),
5395
+ skill.description ? /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h3", {
5396
+ className: "text-label font-medium text-fg-3",
5397
+ children: "Description"
5398
+ }), /* @__PURE__ */ jsx("p", {
5399
+ className: "mt-1 text-body-sm whitespace-pre-wrap text-fg-2",
5400
+ children: skill.description
5401
+ })] }) : /* @__PURE__ */ jsx("p", {
5402
+ className: "text-body-sm text-fg-4",
5403
+ children: "This skill carries no description."
5404
+ }),
5405
+ onUse && skill.enabled ? /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Button, {
5406
+ variant: "outline",
5407
+ size: "sm",
5408
+ onClick: () => {
5409
+ onUse(skill);
5410
+ onUsed();
5411
+ },
5412
+ children: "Use this skill"
5413
+ }), /* @__PURE__ */ jsx("p", {
5414
+ className: "mt-1.5 text-label text-fg-4",
5415
+ children: "Writes an opening message into the composer for you to edit — it isn’t sent, and there is no command that runs a skill directly."
5416
+ })] }) : null
5417
+ ]
5418
+ });
5419
+ }
5420
+ //#endregion
5421
+ //#region src/components/agent/ModelSelect.tsx
5422
+ /** The CLI's supportedModels list leads with a "Default (recommended)" row whose value
5423
+ * is a sentinel, not a model id — selecting it means "clear the override". */
5424
+ const isDefaultOption = (value) => value === "default";
5425
+ /** Everything before a '[1m]'-style context-window suffix. */
5426
+ const dropVariant = (id) => id.replace(/\[.*\]$/, "");
5427
+ /** 'claude-opus-4-8' → "opus", 'sonnet' → "sonnet". The vendor prefix and the
5428
+ * version tail are dropped; what is left is the name a person would say. */
5429
+ function family(id) {
5430
+ const parts = id.toLowerCase().split("-");
5431
+ if (parts[0] === "claude") parts.shift();
5432
+ return parts[0] ?? "";
5433
+ }
5434
+ /**
5435
+ * Whether a row is the one naming `model`.
5436
+ *
5437
+ * Three passes, narrowest first, because the rows and the id a session *reports*
5438
+ * are written differently: the rows are aliases ('opus[1m]', 'sonnet',
5439
+ * 'claude-fable-5[1m]') and a session reports a resolved wire id
5440
+ * ('claude-opus-5[1m]'). `resolvedModel` is the server's own answer to this and
5441
+ * wins when present; the family fallback covers a server that doesn't send it,
5442
+ * which is the difference between the chip reading "Opus 5" and reading
5443
+ * `claude-opus-5[1m]`. Kept identical to the iOS client's `ModelOption.matches`.
5444
+ */
5445
+ function optionMatches(option, model) {
5446
+ if (model === option.value || model === option.resolvedModel) return true;
5447
+ const stripped = dropVariant(model);
5448
+ if (option.resolvedModel) return stripped === dropVariant(option.resolvedModel);
5449
+ const token = family(stripped);
5450
+ return token !== "" && token === family(dropVariant(option.value));
5451
+ }
5452
+ /** Find the option matching a (possibly decorated/aliased) session model id. */
5453
+ function matchModel(models, model) {
5454
+ if (!model) return void 0;
5455
+ return models.filter((m) => !isDefaultOption(m.value)).find((m) => optionMatches(m, model));
5456
+ }
5457
+ /** Compact model switcher for the composer toolbar; fed by the `capabilities` event.
5458
+ * Rows render CLI-style: bold display name with the model's description beneath. */
5459
+ function ModelSelect({ models, model, onModelChange, variant = "toolbar", disabled, className }) {
5460
+ const selected = matchModel(models, model) ?? (model ? void 0 : models.find((m) => isDefaultOption(m.value)));
5461
+ return /* @__PURE__ */ jsxs(Select, {
5462
+ items: models.map((m) => ({
5463
+ value: m.value,
5464
+ label: m.displayName
5465
+ })),
5466
+ value: selected?.value ?? null,
5467
+ onValueChange: (value) => {
5468
+ if (typeof value !== "string" || value === selected?.value) return;
5469
+ onModelChange(isDefaultOption(value) ? void 0 : value);
5470
+ },
5471
+ disabled,
5472
+ children: [/* @__PURE__ */ jsx(SelectTrigger, {
5473
+ "aria-label": "Model",
5474
+ className: cn(variant === "toolbar" && "h-6 max-w-56 border-transparent bg-transparent text-fg-3 hover:bg-surface-hover", className),
5475
+ children: /* @__PURE__ */ jsx("span", {
5476
+ className: cn("truncate", variant === "toolbar" && "font-mono text-label"),
5477
+ children: /* @__PURE__ */ jsx(SelectValue, { placeholder: model ?? "model" })
5478
+ })
5479
+ }), /* @__PURE__ */ jsx(SelectContent, {
4188
5480
  className: "min-w-72",
4189
5481
  children: models.map((m) => /* @__PURE__ */ jsxs(SelectItem, {
4190
5482
  value: m.value,
@@ -4201,44 +5493,67 @@ function ModelSelect({ models, model, onModelChange, variant = "toolbar", disabl
4201
5493
  }
4202
5494
  //#endregion
4203
5495
  //#region src/components/agent/PermissionModeSelect.tsx
4204
- /** The modes surfaced across UI surfaces (session creation, in-session switcher). */
5496
+ /**
5497
+ * The modes surfaced across UI surfaces (session creation, in-session switcher),
5498
+ * ordered by how much of the approval gate they give away.
5499
+ *
5500
+ * Notably `default` is **"Manual"**: the wire value is `default`, but calling it
5501
+ * that in the UI conflates a real mode (ask me every time) with "whatever the
5502
+ * server picked", which is the one confusion a mode chip exists to avoid. The
5503
+ * naming, the icons and the summaries are shared with the iOS app on purpose —
5504
+ * the two surfaces should read as the same list.
5505
+ */
4205
5506
  const PERMISSION_MODES = [
4206
5507
  {
4207
5508
  value: "default",
4208
- label: "default",
4209
- description: "ask for approval"
5509
+ label: "Manual",
5510
+ shortLabel: "Manual",
5511
+ description: "Always ask before making changes",
5512
+ icon: Hand
4210
5513
  },
4211
5514
  {
4212
5515
  value: "acceptEdits",
4213
- label: "acceptEdits",
4214
- description: "auto-approve file edits"
5516
+ label: "Accept edits",
5517
+ shortLabel: "Edits",
5518
+ description: "Automatically accept all file edits",
5519
+ icon: Code
4215
5520
  },
4216
5521
  {
4217
5522
  value: "plan",
4218
- label: "plan",
4219
- description: "read-only planning"
5523
+ label: "Plan",
5524
+ shortLabel: "Plan",
5525
+ description: "Create a plan before making changes",
5526
+ icon: ClipboardList
4220
5527
  },
4221
5528
  {
4222
5529
  value: "auto",
4223
- label: "auto",
4224
- description: "model decides when to ask"
5530
+ label: "Auto",
5531
+ shortLabel: "Auto",
5532
+ description: "Claude handles permission decisions",
5533
+ icon: Zap
4225
5534
  },
4226
5535
  {
4227
5536
  value: "dontAsk",
4228
- label: "dontAsk",
4229
- description: "never ask — deny unapproved"
5537
+ label: "Don't ask",
5538
+ shortLabel: "Don't ask",
5539
+ description: "Never ask — deny anything not pre-approved",
5540
+ icon: ShieldCheck
4230
5541
  },
4231
5542
  {
4232
5543
  value: "bypassPermissions",
4233
- label: "bypassPermissions",
4234
- description: "no prompts (danger)",
5544
+ label: "Bypass permissions",
5545
+ shortLabel: "Bypass",
5546
+ description: "Skip every approval — the agent is unsupervised",
5547
+ icon: TriangleAlert,
4235
5548
  dangerous: true
4236
5549
  }
4237
5550
  ];
5551
+ const permissionModeMeta = (mode) => PERMISSION_MODES.find((m) => m.value === mode);
4238
5552
  /** Permission-mode switcher: compact in the composer toolbar, field-sized in forms. */
4239
- function PermissionModeSelect({ mode, onModeChange, modes, variant = "toolbar", disabled, className }) {
5553
+ function PermissionModeSelect({ mode, onModeChange, modes, canBypass, variant = "toolbar", disabled, className }) {
4240
5554
  const dangerous = mode === "bypassPermissions";
4241
5555
  const offered = modes ? PERMISSION_MODES.filter((m) => modes.includes(m.value)) : PERMISSION_MODES;
5556
+ const unavailable = (meta) => meta.value === "bypassPermissions" && canBypass === false;
4242
5557
  return /* @__PURE__ */ jsxs(Select, {
4243
5558
  items: offered.map((m) => ({
4244
5559
  value: m.value,
@@ -4253,46 +5568,131 @@ function PermissionModeSelect({ mode, onModeChange, modes, variant = "toolbar",
4253
5568
  "aria-label": "Permission mode",
4254
5569
  className: cn(variant === "toolbar" && "h-6 max-w-44 border-transparent bg-transparent hover:bg-surface-hover", dangerous ? "text-danger" : variant === "toolbar" ? "text-fg-3" : void 0, className),
4255
5570
  children: /* @__PURE__ */ jsx("span", {
4256
- className: cn("truncate", variant === "toolbar" && "font-mono text-label"),
5571
+ className: cn("truncate", variant === "toolbar" && "text-label"),
4257
5572
  children: /* @__PURE__ */ jsx(SelectValue, { placeholder: "permissions" })
4258
5573
  })
4259
5574
  }), /* @__PURE__ */ jsx(SelectContent, {
4260
- className: "min-w-56",
4261
- children: offered.map((m) => /* @__PURE__ */ jsxs(SelectItem, {
4262
- value: m.value,
4263
- children: [/* @__PURE__ */ jsx(SelectItemText, { children: /* @__PURE__ */ jsx("span", {
4264
- className: cn("font-medium", m.dangerous && "text-danger"),
4265
- children: m.label
4266
- }) }), /* @__PURE__ */ jsx("span", {
4267
- className: cn("text-label", m.dangerous ? "text-danger/80" : "text-fg-4"),
4268
- children: m.description
4269
- })]
4270
- }, m.value))
5575
+ className: "min-w-64",
5576
+ children: offered.map((m) => {
5577
+ const blocked = unavailable(m);
5578
+ return /* @__PURE__ */ jsxs(SelectItem, {
5579
+ value: m.value,
5580
+ disabled: blocked,
5581
+ children: [/* @__PURE__ */ jsx(SelectItemText, { children: /* @__PURE__ */ jsxs("span", {
5582
+ className: "flex items-center gap-2",
5583
+ children: [/* @__PURE__ */ jsx(m.icon, { className: cn("size-3.5 shrink-0", m.dangerous ? "text-danger" : "text-fg-3") }), /* @__PURE__ */ jsx("span", {
5584
+ className: cn("font-medium", m.dangerous && "text-danger"),
5585
+ children: m.label
5586
+ })]
5587
+ }) }), /* @__PURE__ */ jsx("span", {
5588
+ className: cn("pl-5.5 text-label", blocked ? "text-fg-4" : m.dangerous ? "text-danger/80" : "text-fg-4"),
5589
+ children: blocked ? "Only available to a session started in this mode" : m.description
5590
+ })]
5591
+ }, m.value);
5592
+ })
4271
5593
  })]
4272
5594
  });
4273
5595
  }
4274
5596
  //#endregion
5597
+ //#region src/lib/tool-icon.ts
5598
+ /**
5599
+ * An icon per tool, so a transcript can be skimmed by shape rather than read.
5600
+ *
5601
+ * The same mapping the iOS app makes, in lucide's vocabulary rather than SF
5602
+ * Symbols — the two clients should be recognisably showing the same thing. An
5603
+ * unknown tool falls back to a wrench, and an MCP tool (`mcp__server__name`) to
5604
+ * the puzzle piece the MCP screens use, because "which server is this from" is
5605
+ * the useful thing to see at a glance.
5606
+ */
5607
+ function toolIcon(toolName) {
5608
+ switch (toolName) {
5609
+ case "Bash":
5610
+ case "BashOutput":
5611
+ case "KillShell": return Terminal;
5612
+ case "Read": return FileText;
5613
+ case "Write": return SquarePen;
5614
+ case "Edit":
5615
+ case "MultiEdit":
5616
+ case "NotebookEdit": return PencilLine;
5617
+ case "Glob": return FolderSearch;
5618
+ case "Grep": return Search;
5619
+ case "WebFetch": return ArrowDownCircle;
5620
+ case "WebSearch": return Globe;
5621
+ case "Task":
5622
+ case "Agent": return UsersRound;
5623
+ case "TodoWrite": return CheckSquare;
5624
+ case "Skill": return Sparkles;
5625
+ case "AskUserQuestion": return MessageCircleQuestion;
5626
+ case "CodexCommand": return Terminal;
5627
+ case "CodexFileChange": return FileDiff;
5628
+ case "CodexWebSearch": return Globe;
5629
+ case "CodexImageGeneration":
5630
+ case "CodexImageView": return Image;
5631
+ default: return toolName.startsWith("mcp__") ? Puzzle : Wrench;
5632
+ }
5633
+ }
5634
+ //#endregion
4275
5635
  //#region src/components/agent/PermissionPrompt.tsx
5636
+ /**
5637
+ * Generic allow/deny prompt for a pending permission request.
5638
+ *
5639
+ * Three outcomes, not two: denying usually means "not that, try something else",
5640
+ * so plain Deny lets the turn continue (with an optional reason the agent reads)
5641
+ * while "Deny & stop" also interrupts.
5642
+ *
5643
+ * The heading is whatever the engine authored — `title`, else `displayName`.
5644
+ * Composing "wants to use {tool}" instead would be wrong for codex, where an
5645
+ * approval is usually an *escalation after a sandbox refusal* and the runner has
5646
+ * already written the sentence that says so.
5647
+ */
4276
5648
  function PermissionPrompt({ request, onApprove, onDeny, className }) {
4277
5649
  const [showInput, setShowInput] = useState(false);
4278
- return /* @__PURE__ */ jsx("div", {
5650
+ const [denying, setDenying] = useState(false);
5651
+ const [reason, setReason] = useState("");
5652
+ const deny = (interrupt) => {
5653
+ const message = reason.trim();
5654
+ onDeny(request.id, message || void 0, interrupt || void 0);
5655
+ setReason("");
5656
+ setDenying(false);
5657
+ };
5658
+ const summary = toolInputPreview(request.input);
5659
+ const ToolIcon = toolIcon(request.toolName);
5660
+ return /* @__PURE__ */ jsxs("div", {
4279
5661
  "data-slot": "permission-prompt",
4280
5662
  className: cn("rounded-lg border border-warning/40 bg-warning-bg p-3", className),
4281
- children: /* @__PURE__ */ jsxs("div", {
5663
+ children: [/* @__PURE__ */ jsxs("div", {
4282
5664
  className: "flex items-start gap-2.5",
4283
5665
  children: [
4284
- /* @__PURE__ */ jsx(ShieldAlert, { className: "mt-0.5 size-4 shrink-0 text-warning" }),
5666
+ /* @__PURE__ */ jsx(Hand, { className: "mt-0.5 size-4 shrink-0 text-warning" }),
4285
5667
  /* @__PURE__ */ jsxs("div", {
4286
5668
  className: "min-w-0 flex-1",
4287
5669
  children: [
4288
5670
  /* @__PURE__ */ jsx("div", {
4289
5671
  className: "text-body-sm font-medium text-fg-1",
4290
- children: request.title ?? `Claude wants to use ${request.toolName}`
5672
+ children: request.title ?? request.displayName ?? "Permission needed"
4291
5673
  }),
4292
5674
  request.description ? /* @__PURE__ */ jsx("div", {
4293
5675
  className: "mt-0.5 text-label text-fg-3",
4294
5676
  children: request.description
4295
5677
  }) : null,
5678
+ request.decisionReason ? /* @__PURE__ */ jsx("div", {
5679
+ className: "mt-0.5 text-label text-fg-4",
5680
+ children: request.decisionReason
5681
+ }) : null,
5682
+ /* @__PURE__ */ jsxs("div", {
5683
+ className: "mt-1.5 flex min-w-0 items-center gap-2",
5684
+ children: [
5685
+ /* @__PURE__ */ jsx(ToolIcon, { className: "size-3 shrink-0 text-fg-3" }),
5686
+ /* @__PURE__ */ jsx("span", {
5687
+ className: "shrink-0 font-mono text-label font-medium text-fg-2",
5688
+ children: request.toolName
5689
+ }),
5690
+ summary ? /* @__PURE__ */ jsx("span", {
5691
+ className: "min-w-0 truncate font-mono text-label text-fg-4",
5692
+ children: summary
5693
+ }) : null
5694
+ ]
5695
+ }),
4296
5696
  /* @__PURE__ */ jsxs("button", {
4297
5697
  type: "button",
4298
5698
  className: "mt-1 font-mono text-label text-fg-3 underline-offset-2 hover:underline",
@@ -4311,20 +5711,56 @@ function PermissionPrompt({ request, onApprove, onDeny, className }) {
4311
5711
  ]
4312
5712
  }),
4313
5713
  /* @__PURE__ */ jsxs("div", {
4314
- className: "flex shrink-0 gap-1.5",
4315
- children: [/* @__PURE__ */ jsx(Button, {
4316
- size: "sm",
4317
- onClick: () => onApprove(request.id),
4318
- children: "Allow"
4319
- }), /* @__PURE__ */ jsx(Button, {
4320
- size: "sm",
4321
- variant: "outline",
4322
- onClick: () => onDeny(request.id, "Denied by user"),
4323
- children: "Deny"
4324
- })]
5714
+ className: "flex shrink-0 flex-wrap justify-end gap-1.5",
5715
+ children: [
5716
+ /* @__PURE__ */ jsx(Button, {
5717
+ size: "sm",
5718
+ onClick: () => onApprove(request.id),
5719
+ children: "Allow"
5720
+ }),
5721
+ /* @__PURE__ */ jsx(Button, {
5722
+ size: "sm",
5723
+ variant: "outline",
5724
+ onClick: () => setDenying((v) => !v),
5725
+ children: "Deny"
5726
+ }),
5727
+ /* @__PURE__ */ jsx(Button, {
5728
+ size: "sm",
5729
+ variant: "outline",
5730
+ onClick: () => deny(true),
5731
+ children: "Deny & stop"
5732
+ })
5733
+ ]
4325
5734
  })
4326
5735
  ]
4327
- })
5736
+ }), denying ? /* @__PURE__ */ jsxs("div", {
5737
+ className: "mt-2.5 flex items-center gap-2 border-t border-warning/30 pt-2.5",
5738
+ children: [
5739
+ /* @__PURE__ */ jsx(Input, {
5740
+ autoFocus: true,
5741
+ value: reason,
5742
+ onChange: (e) => setReason(e.target.value),
5743
+ onKeyDown: (e) => {
5744
+ if (e.key === "Enter") deny(false);
5745
+ if (e.key === "Escape") setDenying(false);
5746
+ },
5747
+ placeholder: "Reason (optional) — the agent reads this and can try something else",
5748
+ className: "h-7 flex-1"
5749
+ }),
5750
+ /* @__PURE__ */ jsx(Button, {
5751
+ size: "sm",
5752
+ variant: "outline",
5753
+ onClick: () => setDenying(false),
5754
+ children: "Cancel"
5755
+ }),
5756
+ /* @__PURE__ */ jsx(Button, {
5757
+ size: "sm",
5758
+ variant: "destructive",
5759
+ onClick: () => deny(false),
5760
+ children: "Deny"
5761
+ })
5762
+ ]
5763
+ }) : null]
4328
5764
  });
4329
5765
  }
4330
5766
  //#endregion
@@ -4488,62 +5924,170 @@ function QuestionPrompt({ request, onAnswer, onDismiss, className }) {
4488
5924
  });
4489
5925
  }
4490
5926
  //#endregion
4491
- //#region src/lib/format.ts
4492
- function formatCost(usd) {
4493
- if (usd === void 0 || Number.isNaN(usd)) return "—";
4494
- if (usd === 0) return "$0.00";
4495
- if (usd < .01) return "<$0.01";
4496
- return `$${usd.toFixed(2)}`;
4497
- }
4498
- function formatDuration(ms) {
4499
- if (ms < 1e3) return `${Math.round(ms)}ms`;
4500
- const s = ms / 1e3;
4501
- if (s < 60) return `${s.toFixed(1)}s`;
4502
- return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
4503
- }
4504
- /** Compact token count, Claude Code-style: 850 → "850", 359_000 → "359.0k", 1_200_000 → "1.2M". */
4505
- function formatTokens(tokens) {
4506
- if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(1)}M`;
4507
- if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1)}k`;
4508
- return String(Math.round(tokens));
4509
- }
4510
- function formatBytes(bytes) {
4511
- if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
4512
- if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
4513
- return `${bytes} B`;
4514
- }
4515
- /** Countdown to an epoch-ms deadline: "2h 18m", "12m", "<1m"; "now" once passed. */
4516
- function formatCountdown(untilEpochMs, now = Date.now()) {
4517
- const remaining = untilEpochMs - now;
4518
- if (remaining <= 0) return "now";
4519
- const minutes = Math.ceil(remaining / 6e4);
4520
- if (minutes < 1) return "<1m";
4521
- if (minutes < 60) return `${minutes}m`;
4522
- const days = Math.floor(minutes / 1440);
4523
- if (days >= 1) return `${days}d ${Math.floor(minutes % 1440 / 60)}h`;
4524
- return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
5927
+ //#region src/components/agent/SessionInfoDialog.tsx
5928
+ /**
5929
+ * What this session *is*: engine, profile, model, mode, where it runs, which
5930
+ * credentials it found, and the files it has handed over.
5931
+ *
5932
+ * The identity half of the session's facts. Context and usage have their own
5933
+ * panels — they change every turn and are consulted mid-run, while everything
5934
+ * here is fixed at creation and looked up once.
5935
+ */
5936
+ function SessionInfoDialog({ state, client, sessionId, open, onOpenChange }) {
5937
+ const session = state.session;
5938
+ const mode = state.permissionMode ? permissionModeMeta(state.permissionMode) : void 0;
5939
+ return /* @__PURE__ */ jsx(Dialog, {
5940
+ open,
5941
+ onOpenChange,
5942
+ children: /* @__PURE__ */ jsxs(DialogContent, { children: [/* @__PURE__ */ jsx(DialogHeader, {
5943
+ title: "Session info",
5944
+ description: session?.title
5945
+ }), /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs("div", {
5946
+ className: "flex flex-col divide-y divide-border",
5947
+ children: [
5948
+ /* @__PURE__ */ jsxs("div", {
5949
+ className: "pb-2",
5950
+ children: [
5951
+ /* @__PURE__ */ jsx(DialogRow, {
5952
+ label: "Engine",
5953
+ children: state.engine ?? "claude"
5954
+ }),
5955
+ session?.profile ? /* @__PURE__ */ jsx(DialogRow, {
5956
+ label: "Profile",
5957
+ children: session.profile
5958
+ }) : null,
5959
+ state.model ? /* @__PURE__ */ jsx(DialogRow, {
5960
+ label: "Model",
5961
+ mono: true,
5962
+ children: state.model
5963
+ }) : null,
5964
+ mode ? /* @__PURE__ */ jsx(DialogRow, {
5965
+ label: "Permission mode",
5966
+ children: mode.label
5967
+ }) : null,
5968
+ session?.apiKeySource ? /* @__PURE__ */ jsx(DialogRow, {
5969
+ label: "Credentials",
5970
+ children: session.apiKeySource
5971
+ }) : null
5972
+ ]
5973
+ }),
5974
+ /* @__PURE__ */ jsxs("div", {
5975
+ className: "py-2",
5976
+ children: [
5977
+ state.cwd ? /* @__PURE__ */ jsx(CopyRow, {
5978
+ label: "Working directory",
5979
+ value: state.cwd
5980
+ }) : null,
5981
+ state.sdkSessionId ? /* @__PURE__ */ jsx(CopyRow, {
5982
+ label: "Engine session id",
5983
+ value: state.sdkSessionId
5984
+ }) : null,
5985
+ session ? /* @__PURE__ */ jsx(CopyRow, {
5986
+ label: "Gateway session id",
5987
+ value: session.id
5988
+ }) : null
5989
+ ]
5990
+ }),
5991
+ /* @__PURE__ */ jsxs("div", {
5992
+ className: "py-2",
5993
+ children: [
5994
+ session?.createdAt ? /* @__PURE__ */ jsx(DialogRow, {
5995
+ label: "Started",
5996
+ children: formatRelativeTime(session.createdAt)
5997
+ }) : null,
5998
+ session?.numTurns !== void 0 ? /* @__PURE__ */ jsx(DialogRow, {
5999
+ label: "Turns",
6000
+ children: session.numTurns
6001
+ }) : null,
6002
+ /* @__PURE__ */ jsx(DialogRow, {
6003
+ label: "Cost",
6004
+ mono: true,
6005
+ children: formatCost(state.totalCostUsd)
6006
+ })
6007
+ ]
6008
+ }),
6009
+ state.capabilities.vfs ? /* @__PURE__ */ jsx(SessionFiles, {
6010
+ client,
6011
+ sessionId,
6012
+ open
6013
+ }) : null
6014
+ ]
6015
+ }) })] })
6016
+ });
4525
6017
  }
4526
- function formatRelativeTime(epochMs, now = Date.now()) {
4527
- if (!epochMs) return "—";
4528
- const diff = Math.max(0, now - epochMs);
4529
- const s = Math.floor(diff / 1e3);
4530
- if (s < 60) return "just now";
4531
- const m = Math.floor(s / 60);
4532
- if (m < 60) return `${m}m ago`;
4533
- const h = Math.floor(m / 60);
4534
- if (h < 24) return `${h}h ago`;
4535
- return `${Math.floor(h / 24)}d ago`;
6018
+ /** Long ids are for pasting elsewhere, so give them a copy target. */
6019
+ function CopyRow({ label, value }) {
6020
+ return /* @__PURE__ */ jsxs("div", {
6021
+ className: "flex items-start justify-between gap-2 py-1.5",
6022
+ children: [/* @__PURE__ */ jsxs("div", {
6023
+ className: "min-w-0 flex-1",
6024
+ children: [/* @__PURE__ */ jsx("div", {
6025
+ className: "text-label text-fg-3",
6026
+ children: label
6027
+ }), /* @__PURE__ */ jsx("div", {
6028
+ className: "mt-0.5 font-mono text-label break-all text-fg-1",
6029
+ children: value
6030
+ })]
6031
+ }), /* @__PURE__ */ jsx(CopyButton, {
6032
+ value,
6033
+ "aria-label": `Copy ${label.toLowerCase()}`
6034
+ })]
6035
+ });
4536
6036
  }
4537
- /** Compact one-line preview of a tool input for card headers. */
4538
- function toolInputPreview(input, max = 80) {
4539
- if (input === null || input === void 0) return "";
4540
- if (typeof input === "object") {
4541
- const o = input;
4542
- const primary = o.command ?? o.file_path ?? o.path ?? o.url ?? o.pattern ?? o.query ?? o.description;
4543
- if (typeof primary === "string") return primary.length > max ? primary.slice(0, max - 1) + "…" : primary;
4544
- }
4545
- const text = JSON.stringify(input) ?? "";
4546
- return text.length > max ? text.slice(0, max - 1) + "…" : text;
6037
+ /** Files the agent produced inside the session's VFS. Fetched when the panel
6038
+ * opens this is not a live list and nothing pushes changes to it. */
6039
+ function SessionFiles({ client, sessionId, open }) {
6040
+ const [files, setFiles] = useState();
6041
+ const [loading, setLoading] = useState(false);
6042
+ useEffect(() => {
6043
+ if (!open || !sessionId) return;
6044
+ let cancelled = false;
6045
+ setLoading(true);
6046
+ client.listSessionFiles(sessionId).then((list) => {
6047
+ if (!cancelled) setFiles(list);
6048
+ }).catch(() => {
6049
+ if (!cancelled) setFiles([]);
6050
+ }).finally(() => {
6051
+ if (!cancelled) setLoading(false);
6052
+ });
6053
+ return () => {
6054
+ cancelled = true;
6055
+ };
6056
+ }, [
6057
+ client,
6058
+ sessionId,
6059
+ open
6060
+ ]);
6061
+ return /* @__PURE__ */ jsxs("div", {
6062
+ className: "pt-3",
6063
+ children: [/* @__PURE__ */ jsx("h3", {
6064
+ className: "text-label font-medium text-fg-3",
6065
+ children: "Files"
6066
+ }), loading ? /* @__PURE__ */ jsx("div", {
6067
+ className: "py-3 text-center",
6068
+ children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-fg-4" })
6069
+ }) : !files?.length ? /* @__PURE__ */ jsx("p", {
6070
+ className: "py-2 text-body-sm text-fg-4",
6071
+ children: "Nothing delivered yet."
6072
+ }) : /* @__PURE__ */ jsx("ul", {
6073
+ className: "mt-1 flex flex-col",
6074
+ children: files.map((file) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("a", {
6075
+ href: sessionId ? client.sessionFileUrl(sessionId, file.path) : void 0,
6076
+ className: "flex items-center gap-2 rounded-md px-1 py-1.5 hover:bg-surface-hover",
6077
+ children: [
6078
+ /* @__PURE__ */ jsx(Download, { className: "size-3.5 shrink-0 text-fg-4" }),
6079
+ /* @__PURE__ */ jsx("span", {
6080
+ className: "min-w-0 flex-1 truncate font-mono text-label text-fg-1",
6081
+ children: file.path
6082
+ }),
6083
+ /* @__PURE__ */ jsx("span", {
6084
+ className: "shrink-0 text-label text-fg-4",
6085
+ children: formatBytes(file.bytes)
6086
+ })
6087
+ ]
6088
+ }) }, file.path))
6089
+ })]
6090
+ });
4547
6091
  }
4548
6092
  //#endregion
4549
6093
  //#region src/components/agent/status.ts
@@ -4685,40 +6229,68 @@ function RateLimitMeter({ label, info, now }) {
4685
6229
  })
4686
6230
  });
4687
6231
  }
4688
- function StatusBar({ state, connected, className }) {
6232
+ /** A gauge that leads somewhere. `undefined` handler leaves it inert, so an
6233
+ * embedder that mounts no panels doesn't get buttons that do nothing. */
6234
+ function Slot({ onClick, hint, children }) {
6235
+ if (!onClick) return /* @__PURE__ */ jsx(Fragment$1, { children });
6236
+ return /* @__PURE__ */ jsx("button", {
6237
+ type: "button",
6238
+ onClick,
6239
+ "aria-label": hint,
6240
+ className: "rounded-md px-1 py-0.5 transition-colors outline-none hover:bg-surface-hover focus-visible:bg-surface-hover",
6241
+ children
6242
+ });
6243
+ }
6244
+ function StatusBar({ state, connected, connection, onOpenStatus, onOpenContext, onOpenUsage, actions, className }) {
4689
6245
  const meta = STATUS_META[state.status];
4690
6246
  const now = useNow();
4691
6247
  const session = state.rateLimits?.five_hour;
4692
6248
  const weekly = state.rateLimits?.seven_day;
6249
+ const link = connection ?? (connected === false ? "reconnecting" : "live");
4693
6250
  return /* @__PURE__ */ jsxs("div", {
4694
6251
  "data-slot": "status-bar",
4695
- className: cn("flex items-center gap-3 border-b border-border bg-surface px-3 py-2", className),
6252
+ className: cn("flex items-center gap-2 border-b border-border bg-surface px-3 py-1.5", className),
4696
6253
  children: [
4697
- /* @__PURE__ */ jsxs(Badge, {
4698
- variant: meta.variant,
4699
- dot: !meta.busy,
4700
- children: [meta.busy ? /* @__PURE__ */ jsx(Spinner, { className: "size-3 text-current" }) : null, meta.label]
6254
+ /* @__PURE__ */ jsx(Slot, {
6255
+ onClick: onOpenStatus,
6256
+ hint: "Session info",
6257
+ children: link === "live" ? /* @__PURE__ */ jsxs(Badge, {
6258
+ variant: meta.variant,
6259
+ dot: !meta.busy,
6260
+ children: [meta.busy ? /* @__PURE__ */ jsx(Spinner, { className: "size-3 text-current" }) : null, meta.label]
6261
+ }) : /* @__PURE__ */ jsxs(Badge, {
6262
+ variant: link === "offline" ? "danger" : "warning",
6263
+ dot: false,
6264
+ children: [link === "offline" ? /* @__PURE__ */ jsx(WifiOff, { className: "size-3 text-current" }) : /* @__PURE__ */ jsx(RefreshCw, { className: "size-3 animate-spin text-current" }), link === "offline" ? "Offline" : "Reconnecting…"]
6265
+ })
4701
6266
  }),
4702
- state.contextUsage ? /* @__PURE__ */ jsx(ContextMeter, { usage: state.contextUsage }) : null,
4703
- session ? /* @__PURE__ */ jsx(RateLimitMeter, {
4704
- label: "Session",
4705
- info: session,
4706
- now
6267
+ state.capabilities.contextUsage && state.contextUsage ? /* @__PURE__ */ jsx(Slot, {
6268
+ onClick: onOpenContext,
6269
+ hint: "Context breakdown",
6270
+ children: /* @__PURE__ */ jsx(ContextMeter, { usage: state.contextUsage })
4707
6271
  }) : null,
4708
- weekly ? /* @__PURE__ */ jsx(RateLimitMeter, {
4709
- label: "Weekly",
4710
- info: weekly,
4711
- now
6272
+ session || weekly ? /* @__PURE__ */ jsx(Slot, {
6273
+ onClick: onOpenUsage,
6274
+ hint: "Plan usage",
6275
+ children: /* @__PURE__ */ jsxs("span", {
6276
+ className: "inline-flex items-center gap-2",
6277
+ children: [session ? /* @__PURE__ */ jsx(RateLimitMeter, {
6278
+ label: "Session",
6279
+ info: session,
6280
+ now
6281
+ }) : null, weekly ? /* @__PURE__ */ jsx(RateLimitMeter, {
6282
+ label: "Weekly",
6283
+ info: weekly,
6284
+ now
6285
+ }) : null]
6286
+ })
4712
6287
  }) : null,
4713
6288
  /* @__PURE__ */ jsx("span", { className: "flex-1" }),
4714
- !connected ? /* @__PURE__ */ jsxs("span", {
4715
- className: "inline-flex items-center gap-1 text-label text-warning",
4716
- children: [/* @__PURE__ */ jsx(WifiOff, { className: "size-3" }), " reconnecting…"]
4717
- }) : null,
4718
6289
  /* @__PURE__ */ jsx("span", {
4719
6290
  className: "font-mono text-label text-fg-3",
4720
6291
  children: formatCost(state.totalCostUsd)
4721
- })
6292
+ }),
6293
+ actions
4722
6294
  ]
4723
6295
  });
4724
6296
  }
@@ -4829,6 +6401,40 @@ function MessageContent({ className, ...props }) {
4829
6401
  });
4830
6402
  }
4831
6403
  //#endregion
6404
+ //#region src/components/agent/PromptTokenText.tsx
6405
+ /**
6406
+ * A sent message, with its `@file` and `/command` tokens styled the way the CLI
6407
+ * writes them: monospace and tinted, no background — the bubble already has one,
6408
+ * and a second fill inside it reads as a button.
6409
+ *
6410
+ * Literal text, never markdown: what was typed is what was sent. The one pass
6411
+ * over it is this, so a message reads the same after sending as it did in the
6412
+ * composer. Two tokens, two meanings — a file is a reference, a command is an
6413
+ * action — so they are told apart by hue rather than by shape alone.
6414
+ */
6415
+ function PromptTokenText({ text, className }) {
6416
+ const tokens = scanPromptTokens(text);
6417
+ if (tokens.length === 0) return /* @__PURE__ */ jsx("span", {
6418
+ className,
6419
+ children: text
6420
+ });
6421
+ const parts = [];
6422
+ let cursor = 0;
6423
+ for (const [index, token] of tokens.entries()) {
6424
+ if (token.start > cursor) parts.push(text.slice(cursor, token.start));
6425
+ parts.push(/* @__PURE__ */ jsx("span", {
6426
+ className: cn("font-mono", token.kind === "file" ? "text-accent" : "text-info"),
6427
+ children: token.text
6428
+ }, `${token.start}-${index}`));
6429
+ cursor = token.end;
6430
+ }
6431
+ if (cursor < text.length) parts.push(text.slice(cursor));
6432
+ return /* @__PURE__ */ jsx("span", {
6433
+ className,
6434
+ children: parts.map((part, index) => /* @__PURE__ */ jsx(Fragment, { children: part }, index))
6435
+ });
6436
+ }
6437
+ //#endregion
4832
6438
  //#region src/components/agent/Response.tsx
4833
6439
  /** Markdown renderer for assistant output — streaming-safe via streamdown, code
4834
6440
  * highlighted with shiki (dual theme follows [data-theme] through the dark: variant). */
@@ -4885,8 +6491,73 @@ function Reasoning({ children, isStreaming = false, defaultOpen, className }) {
4885
6491
  });
4886
6492
  }
4887
6493
  //#endregion
6494
+ //#region src/components/agent/SessionEmptyState.tsx
6495
+ /**
6496
+ * What a session shows before it has said anything: where the agent is sitting,
6497
+ * and what the composer accepts beyond prose.
6498
+ *
6499
+ * Every hint is conditional on the affordance actually existing — an engine
6500
+ * without slash commands, or a gateway without host files, is not told about
6501
+ * them. Deliberately no project name (the header already carries it) and no
6502
+ * brand mark (its geometry is inlined in several places already and they are
6503
+ * meant to stay identical).
6504
+ */
6505
+ function SessionEmptyState({ cwd, hasCommands, hasSkills, canBrowseFiles, className }) {
6506
+ const hints = [
6507
+ {
6508
+ icon: MessageSquareText,
6509
+ text: "Tell the agent what to do."
6510
+ },
6511
+ ...hasCommands ? [{
6512
+ icon: SlashSquare,
6513
+ text: "Type / for the CLI’s slash commands."
6514
+ }] : [],
6515
+ ...hasSkills ? [{
6516
+ icon: Sparkles,
6517
+ text: "Type $ to draft a message for one of the agent’s skills."
6518
+ }] : [],
6519
+ ...canBrowseFiles ? [{
6520
+ icon: AtSign,
6521
+ text: "Type @ to search this project’s files."
6522
+ }] : []
6523
+ ];
6524
+ return /* @__PURE__ */ jsxs("div", {
6525
+ className: cn("flex flex-col items-center gap-4 py-10", className),
6526
+ children: [
6527
+ /* @__PURE__ */ jsx("div", {
6528
+ className: "flex size-12 items-center justify-center rounded-xl bg-surface text-fg-3",
6529
+ children: /* @__PURE__ */ jsx(Terminal, { className: "size-5" })
6530
+ }),
6531
+ cwd ? /* @__PURE__ */ jsx("p", {
6532
+ className: "max-w-full truncate font-mono text-label text-fg-4",
6533
+ children: cwd
6534
+ }) : null,
6535
+ /* @__PURE__ */ jsx("ul", {
6536
+ className: "w-full max-w-sm divide-y divide-border overflow-hidden rounded-lg bg-surface",
6537
+ children: hints.map((hint) => /* @__PURE__ */ jsxs("li", {
6538
+ className: "flex items-center gap-3 px-3.5 py-2.5",
6539
+ children: [/* @__PURE__ */ jsx(hint.icon, { className: "size-4 shrink-0 text-fg-4" }), /* @__PURE__ */ jsx("span", {
6540
+ className: "text-body-sm text-fg-3",
6541
+ children: hint.text
6542
+ })]
6543
+ }, hint.text))
6544
+ })
6545
+ ]
6546
+ });
6547
+ }
6548
+ //#endregion
4888
6549
  //#region src/components/agent/ToolCallCard.tsx
4889
6550
  const RESULT_PREVIEW_CHARS = 2e3;
6551
+ /** Codex's built-in image tools name a host path rather than sending bytes —
6552
+ * an event log carries references, never base64. Rendering the picture means
6553
+ * reading that path back through the gateway's host-file route. */
6554
+ const IMAGE_TOOLS = new Set(["CodexImageGeneration", "CodexImageView"]);
6555
+ const imagePathOf = (item) => {
6556
+ if (!IMAGE_TOOLS.has(item.name)) return void 0;
6557
+ const input = item.input;
6558
+ const path = input?.savedPath ?? input?.path;
6559
+ return typeof path === "string" ? path : void 0;
6560
+ };
4890
6561
  /** Badge per execution state. `pending`/`deferred` mean the work is happening
4891
6562
  * somewhere else (this tab's sandbox, a queue) — distinct from the model still
4892
6563
  * running the call itself. */
@@ -4917,12 +6588,14 @@ const STATE_BADGE = {
4917
6588
  busy: false
4918
6589
  }
4919
6590
  };
4920
- function ToolCallCard({ item, className }) {
6591
+ function ToolCallCard({ item, hostImage, className }) {
4921
6592
  const [open, setOpen] = useState(false);
4922
6593
  const [fullResult, setFullResult] = useState(false);
6594
+ const imagePath = imagePathOf(item);
4923
6595
  const status = item.status ?? (item.result === void 0 ? "running" : "settled");
4924
6596
  const badge = STATE_BADGE[status];
4925
6597
  const isError = status === "failed" || item.result?.isError === true;
6598
+ const Icon = toolIcon(item.name);
4926
6599
  const resultText = item.result?.text ?? "";
4927
6600
  const truncated = !fullResult && resultText.length > RESULT_PREVIEW_CHARS;
4928
6601
  const shownResult = truncated ? resultText.slice(0, RESULT_PREVIEW_CHARS) : resultText;
@@ -4930,59 +6603,102 @@ function ToolCallCard({ item, className }) {
4930
6603
  "data-slot": "tool-call",
4931
6604
  "data-state": status,
4932
6605
  className: cn("w-full overflow-hidden rounded-lg border border-border bg-surface", className),
4933
- children: [/* @__PURE__ */ jsxs("button", {
4934
- type: "button",
4935
- onClick: () => setOpen((v) => !v),
4936
- className: cn("flex w-full items-center gap-2 px-3 py-2 text-left transition-colors outline-none", "hover:bg-surface-hover focus-visible:bg-surface-hover"),
4937
- children: [
4938
- /* @__PURE__ */ jsx(Wrench, { className: "size-3.5 shrink-0 text-fg-3" }),
4939
- /* @__PURE__ */ jsx("span", {
4940
- className: "shrink-0 font-mono text-body-sm font-medium text-fg-1",
4941
- children: item.name
4942
- }),
4943
- /* @__PURE__ */ jsx("span", {
4944
- className: "min-w-0 flex-1 truncate font-mono text-label text-fg-4",
4945
- children: toolInputPreview(item.input)
4946
- }),
4947
- /* @__PURE__ */ jsxs(Badge, {
4948
- variant: badge.variant,
4949
- dot: !badge.busy,
4950
- className: "shrink-0 gap-1",
4951
- children: [
4952
- badge.busy ? /* @__PURE__ */ jsx(Spinner, { className: "size-3 text-current" }) : null,
4953
- status === "deferred" ? /* @__PURE__ */ jsx(Clock, { className: "size-3 text-current" }) : null,
4954
- badge.label
4955
- ]
4956
- }),
4957
- /* @__PURE__ */ jsx(ChevronDown, { className: cn("size-3.5 shrink-0 text-fg-4 transition-transform", open && "rotate-180") })
4958
- ]
4959
- }), open ? /* @__PURE__ */ jsxs("div", {
4960
- className: "flex flex-col gap-2 border-t border-border p-2.5",
4961
- children: [
4962
- /* @__PURE__ */ jsx(CodeBlock, {
4963
- code: JSON.stringify(item.input, null, 2),
4964
- label: "Parameters"
4965
- }),
4966
- item.logs?.length ? /* @__PURE__ */ jsx(CodeBlock, {
4967
- code: item.logs.join("\n"),
4968
- label: "Logs"
4969
- }) : null,
4970
- item.result !== void 0 ? /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(CodeBlock, {
4971
- code: shownResult || "(empty result)",
4972
- label: isError ? "Error" : "Result",
4973
- className: cn(isError && "border-danger/40 [&_pre]:text-danger")
4974
- }), truncated ? /* @__PURE__ */ jsxs("button", {
4975
- type: "button",
4976
- className: "mt-1 text-label text-fg-3 underline-offset-2 hover:underline",
4977
- onClick: () => setFullResult(true),
4978
- children: [
4979
- "Show all ",
4980
- resultText.length.toLocaleString(),
4981
- " chars"
4982
- ]
4983
- }) : null] }) : null
4984
- ]
4985
- }) : null]
6606
+ children: [
6607
+ /* @__PURE__ */ jsxs("button", {
6608
+ type: "button",
6609
+ onClick: () => setOpen((v) => !v),
6610
+ className: cn("flex w-full items-center gap-2 px-3 py-2 text-left transition-colors outline-none", "hover:bg-surface-hover focus-visible:bg-surface-hover"),
6611
+ children: [
6612
+ /* @__PURE__ */ jsx(Icon, { className: "size-3.5 shrink-0 text-fg-3" }),
6613
+ /* @__PURE__ */ jsx("span", {
6614
+ className: "shrink-0 font-mono text-body-sm font-medium text-fg-1",
6615
+ children: item.name
6616
+ }),
6617
+ item.backend && item.backend !== "server" ? /* @__PURE__ */ jsx(Badge, {
6618
+ variant: "neutral",
6619
+ className: "shrink-0",
6620
+ children: item.backend
6621
+ }) : null,
6622
+ /* @__PURE__ */ jsx("span", {
6623
+ className: "min-w-0 flex-1 truncate font-mono text-label text-fg-4",
6624
+ children: toolInputPreview(item.input)
6625
+ }),
6626
+ /* @__PURE__ */ jsxs(Badge, {
6627
+ variant: badge.variant,
6628
+ dot: !badge.busy,
6629
+ className: "shrink-0 gap-1",
6630
+ children: [
6631
+ badge.busy ? /* @__PURE__ */ jsx(Spinner, { className: "size-3 text-current" }) : null,
6632
+ status === "deferred" ? /* @__PURE__ */ jsx(Clock, { className: "size-3 text-current" }) : null,
6633
+ badge.label
6634
+ ]
6635
+ }),
6636
+ /* @__PURE__ */ jsx(ChevronDown, { className: cn("size-3.5 shrink-0 text-fg-4 transition-transform", open && "rotate-180") })
6637
+ ]
6638
+ }),
6639
+ imagePath && hostImage ? /* @__PURE__ */ jsx(HostImage, {
6640
+ path: imagePath,
6641
+ load: hostImage
6642
+ }) : null,
6643
+ open ? /* @__PURE__ */ jsxs("div", {
6644
+ className: "flex flex-col gap-2 border-t border-border p-2.5",
6645
+ children: [
6646
+ /* @__PURE__ */ jsx(CodeBlock, {
6647
+ code: JSON.stringify(item.input, null, 2),
6648
+ label: "Parameters"
6649
+ }),
6650
+ item.logs?.length ? /* @__PURE__ */ jsx(CodeBlock, {
6651
+ code: item.logs.join("\n"),
6652
+ label: "Logs"
6653
+ }) : null,
6654
+ item.result !== void 0 ? /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(CodeBlock, {
6655
+ code: shownResult || "(empty result)",
6656
+ label: isError ? "Error" : "Result",
6657
+ className: cn(isError && "border-danger/40 [&_pre]:text-danger")
6658
+ }), truncated ? /* @__PURE__ */ jsxs("button", {
6659
+ type: "button",
6660
+ className: "mt-1 text-label text-fg-3 underline-offset-2 hover:underline",
6661
+ onClick: () => setFullResult(true),
6662
+ children: [
6663
+ "Show all ",
6664
+ resultText.length.toLocaleString(),
6665
+ " chars"
6666
+ ]
6667
+ }) : null] }) : null
6668
+ ]
6669
+ }) : null
6670
+ ]
6671
+ });
6672
+ }
6673
+ /**
6674
+ * A picture that lives on the host, fetched through the gateway's host-file
6675
+ * route and shown inline.
6676
+ *
6677
+ * Silent on failure by design: a path outside the server's allowed roots is the
6678
+ * *expected* case for codex's default save location, and the card's result text
6679
+ * already names where the file went. An error banner over that would be noise
6680
+ * about a thing the operator can fix in one line of config.
6681
+ */
6682
+ function HostImage({ path, load }) {
6683
+ const [src, setSrc] = useState();
6684
+ useEffect(() => {
6685
+ let cancelled = false;
6686
+ setSrc(void 0);
6687
+ load(path).then((url) => {
6688
+ if (!cancelled) setSrc(url);
6689
+ }).catch(() => {});
6690
+ return () => {
6691
+ cancelled = true;
6692
+ };
6693
+ }, [path, load]);
6694
+ if (!src) return null;
6695
+ return /* @__PURE__ */ jsx("div", {
6696
+ className: "border-t border-border p-2.5",
6697
+ children: /* @__PURE__ */ jsx("img", {
6698
+ src,
6699
+ alt: path.split("/").pop() ?? "Generated image",
6700
+ className: "max-h-96 w-auto max-w-full rounded-md border border-border"
6701
+ })
4986
6702
  });
4987
6703
  }
4988
6704
  //#endregion
@@ -4990,22 +6706,31 @@ function ToolCallCard({ item, className }) {
4990
6706
  function TurnResultRow({ item }) {
4991
6707
  return /* @__PURE__ */ jsxs("div", {
4992
6708
  "data-slot": "turn-result",
4993
- className: "flex items-center gap-2 py-1",
4994
- children: [
4995
- /* @__PURE__ */ jsx("div", { className: "h-px flex-1 bg-border" }),
4996
- /* @__PURE__ */ jsxs("span", {
4997
- className: cn("font-mono text-label", item.isError ? "text-danger" : "text-fg-4"),
4998
- children: [
4999
- item.isError ? item.subtype : "turn done",
5000
- " · ",
5001
- formatDuration(item.durationMs),
5002
- " ·",
5003
- " ",
5004
- formatCost(item.totalCostUsd)
5005
- ]
5006
- }),
5007
- /* @__PURE__ */ jsx("div", { className: "h-px flex-1 bg-border" })
5008
- ]
6709
+ className: "py-1",
6710
+ children: [/* @__PURE__ */ jsxs("div", {
6711
+ className: "flex items-center gap-2",
6712
+ children: [
6713
+ /* @__PURE__ */ jsx("div", { className: "h-px flex-1 bg-border" }),
6714
+ /* @__PURE__ */ jsxs("span", {
6715
+ className: cn("font-mono text-label", item.isError ? "text-danger" : "text-fg-4"),
6716
+ children: [
6717
+ item.isError ? item.subtype : "turn done",
6718
+ " · ",
6719
+ formatDuration(item.durationMs),
6720
+ " ·",
6721
+ " ",
6722
+ formatCost(item.totalCostUsd)
6723
+ ]
6724
+ }),
6725
+ /* @__PURE__ */ jsx("div", { className: "h-px flex-1 bg-border" })
6726
+ ]
6727
+ }), item.errors?.length ? /* @__PURE__ */ jsx("ul", {
6728
+ className: "mt-1 flex flex-col gap-0.5 text-center",
6729
+ children: item.errors.map((message, index) => /* @__PURE__ */ jsx("li", {
6730
+ className: "text-label break-words text-danger",
6731
+ children: message
6732
+ }, index))
6733
+ }) : null]
5009
6734
  });
5010
6735
  }
5011
6736
  function NoticeRow({ item }) {
@@ -5015,11 +6740,14 @@ function NoticeRow({ item }) {
5015
6740
  children: item.text
5016
6741
  });
5017
6742
  }
5018
- function TranscriptItemView({ item, fileUrl }) {
6743
+ function TranscriptItemView({ item, fileUrl, attachmentUrl, hostImage }) {
5019
6744
  switch (item.kind) {
5020
- case "user": return /* @__PURE__ */ jsx(Message, {
6745
+ case "user": return /* @__PURE__ */ jsxs(Message, {
5021
6746
  from: "user",
5022
- children: /* @__PURE__ */ jsx(MessageContent, { children: item.text })
6747
+ children: [item.attachments?.length ? /* @__PURE__ */ jsx(SentAttachments, {
6748
+ attachments: item.attachments,
6749
+ attachmentUrl
6750
+ }) : null, item.text ? /* @__PURE__ */ jsx(MessageContent, { children: /* @__PURE__ */ jsx(PromptTokenText, { text: item.text }) }) : null]
5023
6751
  });
5024
6752
  case "assistant_text": return /* @__PURE__ */ jsx(Message, {
5025
6753
  from: "assistant",
@@ -5032,7 +6760,10 @@ function TranscriptItemView({ item, fileUrl }) {
5032
6760
  isStreaming: item.id === "streaming-thinking",
5033
6761
  children: item.text
5034
6762
  });
5035
- case "tool_call": return /* @__PURE__ */ jsx(ToolCallCard, { item });
6763
+ case "tool_call": return /* @__PURE__ */ jsx(ToolCallCard, {
6764
+ item,
6765
+ hostImage
6766
+ });
5036
6767
  case "turn_result": return /* @__PURE__ */ jsx(TurnResultRow, { item });
5037
6768
  case "notice": return /* @__PURE__ */ jsx(NoticeRow, { item });
5038
6769
  case "file_delivered": return /* @__PURE__ */ jsx(FileCard, {
@@ -5052,32 +6783,204 @@ function showLoader(state) {
5052
6783
  if (last.kind === "thinking" && last.id === "streaming-thinking") return false;
5053
6784
  return last.kind !== "turn_result" || state.status === "running";
5054
6785
  }
5055
- function Transcript({ state, fileUrl, className }) {
6786
+ /** Files sent with a message: thumbnails for images, named chips for the rest.
6787
+ * References only — the bytes are fetched from the gateway. */
6788
+ function SentAttachments({ attachments, attachmentUrl }) {
6789
+ return /* @__PURE__ */ jsx("div", {
6790
+ className: "mb-1 flex flex-wrap justify-end gap-1.5",
6791
+ children: attachments.map((attachment) => {
6792
+ const href = attachmentUrl?.(attachment.id);
6793
+ return attachment.mediaType.startsWith("image/") && href ? /* @__PURE__ */ jsx("img", {
6794
+ src: href,
6795
+ alt: attachment.name,
6796
+ className: "size-20 rounded-md border border-border object-cover"
6797
+ }, attachment.id) : /* @__PURE__ */ jsx("span", {
6798
+ className: "rounded-full border border-border bg-surface px-2.5 py-1 text-body-xs text-fg-3",
6799
+ children: attachment.name
6800
+ }, attachment.id);
6801
+ })
6802
+ });
6803
+ }
6804
+ /** Rows produced inside a subagent (`parentToolUseId != null`) are stepped in
6805
+ * behind a rule, so a Task's own output reads as belonging to the tool call
6806
+ * above it rather than as the main thread carrying on. */
6807
+ function nestedClass(item) {
6808
+ return "parentToolUseId" in item && item.parentToolUseId != null ? "border-l-2 border-border pl-3" : void 0;
6809
+ }
6810
+ function Transcript({ state, fileUrl, attachmentUrl, canBrowseFiles, hostImage, className }) {
5056
6811
  return /* @__PURE__ */ jsxs(Conversation, {
5057
6812
  className,
5058
- children: [/* @__PURE__ */ jsxs(ConversationContent, { children: [state.items.length === 0 && state.status !== "starting" ? /* @__PURE__ */ jsx("div", {
5059
- className: "py-12 text-center text-body-sm text-fg-4",
5060
- children: "No messages yet."
5061
- }) : state.items.map((item) => /* @__PURE__ */ jsx(TranscriptItemView, {
5062
- item,
5063
- fileUrl
6813
+ children: [/* @__PURE__ */ jsxs(ConversationContent, { children: [state.items.length === 0 && state.status !== "starting" ? /* @__PURE__ */ jsx(SessionEmptyState, {
6814
+ cwd: state.cwd,
6815
+ hasCommands: !!state.commands?.length,
6816
+ hasSkills: !!state.skills?.some((s) => s.enabled),
6817
+ canBrowseFiles
6818
+ }) : state.items.map((item) => /* @__PURE__ */ jsx("div", {
6819
+ className: nestedClass(item),
6820
+ children: /* @__PURE__ */ jsx(TranscriptItemView, {
6821
+ item,
6822
+ fileUrl,
6823
+ attachmentUrl,
6824
+ hostImage
6825
+ })
5064
6826
  }, `${item.kind}:${item.id}`)), showLoader(state) ? /* @__PURE__ */ jsx(Loader, { label: state.status === "starting" ? "Starting session…" : void 0 }) : null] }), /* @__PURE__ */ jsx(ConversationScrollButton, {})]
5065
6827
  });
5066
6828
  }
5067
6829
  //#endregion
6830
+ //#region src/components/agent/UsageDialog.tsx
6831
+ /** Ticking clock — the countdowns and the pace markers both move with it, and a
6832
+ * minute is the finest resolution either of them prints. */
6833
+ function useMinuteClock(open) {
6834
+ const [now, setNow] = useState(() => Date.now());
6835
+ useEffect(() => {
6836
+ if (!open) return;
6837
+ setNow(Date.now());
6838
+ const timer = setInterval(() => setNow(Date.now()), 6e4);
6839
+ return () => clearInterval(timer);
6840
+ }, [open]);
6841
+ return now;
6842
+ }
6843
+ const usageTint = (pct) => pct >= 90 ? "bg-danger" : pct >= 70 ? "bg-warning" : "bg-accent";
6844
+ /**
6845
+ * The plan's rate-limit windows, spelled out: how much of each is used, how that
6846
+ * compares to the pace that would spend the window exactly, and when it resets.
6847
+ *
6848
+ * The pace marker is the point. A bar alone says "17% used", which is only
6849
+ * alarming or reassuring once you know how far into the week you are — so every
6850
+ * window draws a tick at the elapsed share of its duration. Left of the tick is
6851
+ * under budget, right of it is ahead of it. The duration comes from the window
6852
+ * key (5h, 7d) because the CLI reports a reset time and a percentage and never a
6853
+ * duration; a window whose key doesn't say gets no marker rather than a guessed one.
6854
+ */
6855
+ function UsageDialog({ rateLimits, subscriptionType, engine, totalCostUsd, updatedAt, open, onOpenChange }) {
6856
+ const now = useMinuteClock(open);
6857
+ return /* @__PURE__ */ jsx(Dialog, {
6858
+ open,
6859
+ onOpenChange,
6860
+ children: /* @__PURE__ */ jsxs(DialogContent, { children: [/* @__PURE__ */ jsx(DialogHeader, {
6861
+ title: "Usage",
6862
+ description: engine === "claude" ? "Claude Code" : engine,
6863
+ actions: subscriptionType ? /* @__PURE__ */ jsx(Badge, {
6864
+ variant: "accent",
6865
+ className: "mt-0.5 shrink-0 capitalize",
6866
+ children: subscriptionType
6867
+ }) : null
6868
+ }), /* @__PURE__ */ jsxs(DialogBody, { children: [
6869
+ rateLimits.length === 0 ? /* @__PURE__ */ jsx("p", {
6870
+ className: "py-6 text-center text-body-sm text-fg-4",
6871
+ children: engine === "claude" ? "This session reports no plan windows — API-key sessions have none, and a subscription session reports them once a turn has run." : `Plan windows are a claude.ai subscription thing; this session runs on the ${engine} engine.`
6872
+ }) : /* @__PURE__ */ jsx("div", {
6873
+ className: "flex flex-col gap-5",
6874
+ children: rateLimits.map(({ key, info }) => /* @__PURE__ */ jsx(UsageWindow, {
6875
+ windowKey: key,
6876
+ info,
6877
+ now
6878
+ }, key))
6879
+ }),
6880
+ /* @__PURE__ */ jsxs("div", {
6881
+ className: "mt-5 flex items-baseline justify-between gap-4 border-t border-border pt-3",
6882
+ children: [/* @__PURE__ */ jsx("span", {
6883
+ className: "text-label text-fg-3",
6884
+ children: "This session has cost"
6885
+ }), /* @__PURE__ */ jsx("span", {
6886
+ className: "font-mono text-body-sm text-fg-1",
6887
+ children: formatCost(totalCostUsd)
6888
+ })]
6889
+ }),
6890
+ updatedAt ? /* @__PURE__ */ jsxs("p", {
6891
+ className: "mt-2 text-label text-fg-4",
6892
+ children: ["Updated ", formatAgoPrecise(updatedAt, now)]
6893
+ }) : null
6894
+ ] })] })
6895
+ });
6896
+ }
6897
+ function UsageWindow({ windowKey, info, now }) {
6898
+ const utilization = info.utilization ?? 0;
6899
+ const resetsAtMs = info.resetsAt !== void 0 ? info.resetsAt * 1e3 : void 0;
6900
+ const duration = rateLimitWindowSeconds(windowKey);
6901
+ const remaining = resetsAtMs !== void 0 ? (resetsAtMs - now) / 1e3 : void 0;
6902
+ const pace = duration !== void 0 && remaining !== void 0 && remaining > 0 && remaining < duration ? (duration - remaining) / duration : void 0;
6903
+ return /* @__PURE__ */ jsxs("div", { children: [
6904
+ /* @__PURE__ */ jsxs("div", {
6905
+ className: "flex items-baseline justify-between gap-3",
6906
+ children: [/* @__PURE__ */ jsx("span", {
6907
+ className: "truncate text-body-sm text-fg-1",
6908
+ children: formatRateLimitWindowLong(windowKey)
6909
+ }), /* @__PURE__ */ jsxs("span", {
6910
+ className: cn("shrink-0 font-mono text-body-sm font-medium", info.status === "rejected" ? "text-danger" : "text-fg-1"),
6911
+ children: [utilization.toFixed(0), "% used"]
6912
+ })]
6913
+ }),
6914
+ /* @__PURE__ */ jsxs("div", {
6915
+ className: "relative mt-2 h-2 rounded-full bg-border",
6916
+ children: [/* @__PURE__ */ jsx("div", {
6917
+ className: cn("h-full rounded-full", usageTint(utilization)),
6918
+ style: { width: `${Math.min(100, Math.max(2, utilization))}%` }
6919
+ }), pace !== void 0 ? /* @__PURE__ */ jsx("span", {
6920
+ "aria-hidden": true,
6921
+ title: "Spent evenly, usage would be here by now",
6922
+ className: "absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1",
6923
+ style: { left: `${Math.min(100, Math.max(0, pace * 100))}%` }
6924
+ }) : null]
6925
+ }),
6926
+ /* @__PURE__ */ jsxs("div", {
6927
+ className: "mt-1.5 flex items-center gap-3 text-label text-fg-4",
6928
+ children: [
6929
+ resetsAtMs !== void 0 ? /* @__PURE__ */ jsxs("span", {
6930
+ className: "inline-flex items-center gap-1",
6931
+ children: [
6932
+ /* @__PURE__ */ jsx(RotateCcw, { className: "size-3" }),
6933
+ " Resets in ",
6934
+ formatCountdown(resetsAtMs, now)
6935
+ ]
6936
+ }) : null,
6937
+ info.isUsingOverage ? /* @__PURE__ */ jsx("span", {
6938
+ className: "text-warning",
6939
+ children: "overage"
6940
+ }) : null,
6941
+ info.status === "rejected" ? /* @__PURE__ */ jsx("span", {
6942
+ className: "text-danger",
6943
+ children: "limit reached"
6944
+ }) : null
6945
+ ]
6946
+ })
6947
+ ] });
6948
+ }
6949
+ //#endregion
5068
6950
  //#region src/components/agent/SessionPanel.tsx
5069
6951
  /**
5070
6952
  * The all-in-one embeddable session surface: status bar, streaming transcript,
5071
6953
  * permission prompts, composer. Attaches via useClaudeSession; remount (key) to switch
5072
6954
  * sessions.
6955
+ *
6956
+ * Every affordance is gated on the session's **capability record** rather than on
6957
+ * the engine name — an absent capability hides the control instead of offering
6958
+ * one that can only fail.
5073
6959
  */
5074
6960
  function SessionPanel({ client, sessionId, header, className }) {
5075
6961
  const [protocolError, setProtocolError] = useState(void 0);
5076
- const { state, connected, handle, send, approve, deny, interrupt, setModel, setPermissionMode } = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError });
6962
+ const [panel, setPanel] = useState();
6963
+ const { state, connection, protocolMismatch, models, effectiveModel, handle, send, approve, deny, interrupt, setModel, setPermissionMode, reconnectNow } = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError });
5077
6964
  useEffect(() => setProtocolError(void 0), [sessionId]);
6965
+ useEffect(() => {
6966
+ const onVisible = () => {
6967
+ if (document.visibilityState === "visible") reconnectNow();
6968
+ };
6969
+ document.addEventListener("visibilitychange", onVisible);
6970
+ return () => document.removeEventListener("visibilitychange", onVisible);
6971
+ }, [reconnectNow]);
5078
6972
  useToolCallHost(handle);
6973
+ const capabilities = state.capabilities;
5079
6974
  const busy = state.status === "running" || state.status === "awaiting_approval";
5080
6975
  const ended = state.status === "failed" || state.status === "closed";
6976
+ const attachments = useAttachments(client, sessionId, {
6977
+ capabilities,
6978
+ engine: state.engine
6979
+ });
6980
+ const hostFiles = useHostFileSearch(client, state.cwd);
6981
+ const windows = useMemo(() => rateLimitWindows(state), [state]);
6982
+ const hostImage = useHostImage(client, sessionId, state.producedFiles);
6983
+ const composerRef = useRef(null);
5081
6984
  const commands = useMemo(() => {
5082
6985
  if (!state.commands) return void 0;
5083
6986
  if (state.commands.some((c) => c.name === "model")) return state.commands;
@@ -5087,52 +6990,95 @@ function SessionPanel({ client, sessionId, header, className }) {
5087
6990
  argumentHint: "<model>"
5088
6991
  }, ...state.commands];
5089
6992
  }, [state.commands]);
5090
- const handleSend = (text) => {
5091
- const modelCommand = /^\/model\s+(\S+)$/.exec(text);
5092
- if (modelCommand) {
5093
- setModel(modelCommand[1]);
5094
- return;
6993
+ const handleSend = (text, attachmentIds) => {
6994
+ if (attachmentIds.length === 0) {
6995
+ const modelCommand = /^\/model\s+(\S+)$/.exec(text);
6996
+ if (modelCommand) {
6997
+ setModel(modelCommand[1]);
6998
+ return;
6999
+ }
7000
+ if (capabilities.mcpStatus && text.trim() === "/mcp") {
7001
+ setPanel("mcp");
7002
+ return;
7003
+ }
5095
7004
  }
5096
- send(text);
7005
+ send(text, attachmentIds);
5097
7006
  };
7007
+ const actionsMenu = /* @__PURE__ */ jsxs(Menu, { children: [/* @__PURE__ */ jsx(MenuTrigger, { render: /* @__PURE__ */ jsx(Button, {
7008
+ variant: "ghost",
7009
+ size: "icon-sm",
7010
+ "aria-label": "Session actions",
7011
+ children: /* @__PURE__ */ jsx(MoreHorizontal, { className: "size-4" })
7012
+ }) }), /* @__PURE__ */ jsxs(MenuContent, { children: [
7013
+ capabilities.contextUsage ? /* @__PURE__ */ jsxs(MenuItem, {
7014
+ onClick: () => setPanel("context"),
7015
+ children: [/* @__PURE__ */ jsx(ChartPie, { className: "size-3.5 text-fg-3" }), " Context"]
7016
+ }) : null,
7017
+ capabilities.rateLimits ? /* @__PURE__ */ jsxs(MenuItem, {
7018
+ onClick: () => setPanel("usage"),
7019
+ children: [/* @__PURE__ */ jsx(Gauge, { className: "size-3.5 text-fg-3" }), " Usage"]
7020
+ }) : null,
7021
+ /* @__PURE__ */ jsxs(MenuItem, {
7022
+ onClick: () => setPanel("info"),
7023
+ children: [/* @__PURE__ */ jsx(Info, { className: "size-3.5 text-fg-3" }), " Session info"]
7024
+ }),
7025
+ capabilities.mcpStatus ? /* @__PURE__ */ jsxs(MenuItem, {
7026
+ onClick: () => setPanel("mcp"),
7027
+ children: [/* @__PURE__ */ jsx(Plug, { className: "size-3.5 text-fg-3" }), " MCP servers"]
7028
+ }) : null,
7029
+ capabilities.skillsList ? /* @__PURE__ */ jsxs(MenuItem, {
7030
+ onClick: () => setPanel("skills"),
7031
+ children: [/* @__PURE__ */ jsx(Sparkles, { className: "size-3.5 text-fg-3" }), " Skills"]
7032
+ }) : null,
7033
+ hostFiles.available ? /* @__PURE__ */ jsxs(MenuItem, {
7034
+ onClick: () => setPanel("files"),
7035
+ children: [/* @__PURE__ */ jsx(FolderTree, { className: "size-3.5 text-fg-3" }), " Project files"]
7036
+ }) : null
7037
+ ] })] });
7038
+ const headerTakesActions = typeof header === "function";
5098
7039
  return /* @__PURE__ */ jsxs("div", {
5099
7040
  "data-slot": "session-panel",
5100
7041
  className: cn("flex h-full min-h-0 flex-col overflow-hidden bg-bg", className),
5101
7042
  children: [
5102
- header,
7043
+ headerTakesActions ? header({ actions: actionsMenu }) : header,
5103
7044
  /* @__PURE__ */ jsx(StatusBar, {
5104
7045
  state,
5105
- connected
7046
+ connection,
7047
+ onOpenStatus: () => setPanel("info"),
7048
+ onOpenContext: () => setPanel("context"),
7049
+ onOpenUsage: () => setPanel("usage"),
7050
+ actions: headerTakesActions ? void 0 : actionsMenu
5106
7051
  }),
5107
- protocolError ? /* @__PURE__ */ jsx("div", {
5108
- className: "px-3 pt-2",
5109
- children: /* @__PURE__ */ jsxs("div", {
5110
- role: "alert",
5111
- className: "mx-auto flex w-full max-w-3xl items-start gap-2 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-body-sm text-danger",
5112
- children: [/* @__PURE__ */ jsx("span", {
5113
- className: "min-w-0 flex-1 break-words",
5114
- children: protocolError
5115
- }), /* @__PURE__ */ jsx("button", {
5116
- type: "button",
5117
- onClick: () => setProtocolError(void 0),
5118
- "aria-label": "Dismiss error",
5119
- className: "shrink-0 opacity-70 transition-opacity hover:opacity-100",
5120
- children: "✕"
5121
- })]
5122
- })
7052
+ protocolMismatch !== void 0 ? /* @__PURE__ */ jsxs(Notice, {
7053
+ level: "warning",
7054
+ children: [
7055
+ "Server speaks protocol v",
7056
+ protocolMismatch,
7057
+ ", this build renders v",
7058
+ PROTOCOL_VERSION,
7059
+ ". Some events may not render."
7060
+ ]
7061
+ }) : null,
7062
+ protocolError ? /* @__PURE__ */ jsx(Notice, {
7063
+ level: "error",
7064
+ onDismiss: () => setProtocolError(void 0),
7065
+ children: protocolError
5123
7066
  }) : null,
5124
7067
  /* @__PURE__ */ jsx(Transcript, {
5125
7068
  state,
5126
- fileUrl: sessionId ? (path) => client.sessionFileUrl(sessionId, path) : void 0
7069
+ fileUrl: sessionId ? (path) => client.sessionFileUrl(sessionId, path) : void 0,
7070
+ attachmentUrl: sessionId ? (id) => client.attachmentUrl(sessionId, id) : void 0,
7071
+ canBrowseFiles: hostFiles.available,
7072
+ hostImage
5127
7073
  }),
5128
- state.pendingApprovals.length > 0 ? /* @__PURE__ */ jsx("div", {
7074
+ capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? /* @__PURE__ */ jsx("div", {
5129
7075
  className: "px-3 pb-2",
5130
7076
  children: /* @__PURE__ */ jsx("div", {
5131
7077
  className: "mx-auto flex w-full max-w-3xl flex-col gap-2",
5132
7078
  children: state.pendingApprovals.map((request) => request.toolName === "AskUserQuestion" && parseUserQuestions(request.input).length > 0 ? /* @__PURE__ */ jsx(QuestionPrompt, {
5133
7079
  request,
5134
7080
  onAnswer: approve,
5135
- onDismiss: deny
7081
+ onDismiss: (id) => deny(id, "Question dismissed by user")
5136
7082
  }, request.id) : /* @__PURE__ */ jsx(PermissionPrompt, {
5137
7083
  request,
5138
7084
  onApprove: approve,
@@ -5141,26 +7087,1000 @@ function SessionPanel({ client, sessionId, header, className }) {
5141
7087
  })
5142
7088
  }) : null,
5143
7089
  /* @__PURE__ */ jsx(Composer, {
7090
+ ref: composerRef,
5144
7091
  onSend: handleSend,
5145
7092
  onInterrupt: interrupt,
5146
7093
  busy,
5147
7094
  disabled: ended || !sessionId,
5148
- commands,
5149
- toolbar: /* @__PURE__ */ jsxs(Fragment, { children: [state.models?.length ? /* @__PURE__ */ jsx(ModelSelect, {
5150
- models: state.models,
5151
- model: state.model,
7095
+ commands: capabilities.slashCommands ? commands : void 0,
7096
+ skills: capabilities.skillsList ? state.skills : void 0,
7097
+ attachments,
7098
+ onSearchFiles: hostFiles.available ? (query, options) => hostFiles.search(query, {
7099
+ ...options,
7100
+ limit: 8
7101
+ }) : void 0,
7102
+ toolbar: /* @__PURE__ */ jsxs(Fragment$1, { children: [models.length ? /* @__PURE__ */ jsx(ModelSelect, {
7103
+ models,
7104
+ model: effectiveModel,
5152
7105
  onModelChange: setModel,
5153
7106
  disabled: ended
5154
7107
  }) : null, state.permissionMode ? /* @__PURE__ */ jsx(PermissionModeSelect, {
5155
7108
  mode: state.permissionMode,
5156
7109
  onModeChange: setPermissionMode,
5157
- modes: state.engine === "provider" ? PROVIDER_PERMISSION_MODES : void 0,
7110
+ modes: capabilities.permissionModes,
7111
+ canBypass: state.session?.canBypassPermissions,
5158
7112
  disabled: ended
5159
7113
  }) : null] })
7114
+ }),
7115
+ /* @__PURE__ */ jsx(SessionInfoDialog, {
7116
+ state,
7117
+ client,
7118
+ sessionId,
7119
+ open: panel === "info",
7120
+ onOpenChange: (next) => setPanel(next ? "info" : void 0)
7121
+ }),
7122
+ /* @__PURE__ */ jsx(ContextDialog, {
7123
+ usage: state.contextUsage,
7124
+ open: panel === "context",
7125
+ onOpenChange: (next) => setPanel(next ? "context" : void 0)
7126
+ }),
7127
+ /* @__PURE__ */ jsx(UsageDialog, {
7128
+ rateLimits: windows,
7129
+ subscriptionType: state.subscriptionType,
7130
+ engine: state.engine ?? "claude",
7131
+ totalCostUsd: state.totalCostUsd,
7132
+ updatedAt: state.rateLimitsUpdatedAt,
7133
+ open: panel === "usage",
7134
+ onOpenChange: (next) => setPanel(next ? "usage" : void 0)
7135
+ }),
7136
+ /* @__PURE__ */ jsx(McpDialog, {
7137
+ client,
7138
+ sessionId,
7139
+ canManageServers: capabilities.mcpServerActions,
7140
+ open: panel === "mcp",
7141
+ onOpenChange: (next) => setPanel(next ? "mcp" : void 0)
7142
+ }),
7143
+ /* @__PURE__ */ jsx(SkillsDialog, {
7144
+ skills: state.skills,
7145
+ open: panel === "skills",
7146
+ onOpenChange: (next) => setPanel(next ? "skills" : void 0),
7147
+ onUse: (skill) => composerRef.current?.insertText(skillPrompt(skill))
7148
+ }),
7149
+ /* @__PURE__ */ jsx(HostFilesDialog, {
7150
+ client,
7151
+ cwd: state.cwd,
7152
+ open: panel === "files",
7153
+ onOpenChange: (next) => setPanel(next ? "files" : void 0)
5160
7154
  })
5161
7155
  ]
5162
7156
  });
5163
7157
  }
7158
+ /**
7159
+ * Turns a host path a tool card is holding into something an `<img>` can show.
7160
+ *
7161
+ * Two sources, tried in that order and for a reason:
7162
+ *
7163
+ * 1. **The session's produced files.** If this session's own runner announced
7164
+ * writing that path (`file_produced`), the gateway will serve it from
7165
+ * `/sessions/:id/produced/:fileId` — no host-file roots to declare, no byte
7166
+ * cap to raise. This is the path codex's generated images take, and it is
7167
+ * why they now render out of the box.
7168
+ * 2. **`/fs/read`.** For a path nothing produced — a picture the model looked
7169
+ * at, an image already in the tree — where the operator's declared roots are
7170
+ * the right gate and the answer is legitimately "no" outside them.
7171
+ *
7172
+ * The cache is what makes this usable from a transcript row: rows re-render on
7173
+ * every streamed delta, and an uncached resolver would re-fetch the picture each
7174
+ * time. A refusal is cached too, so it costs one request rather than one per
7175
+ * render.
7176
+ *
7177
+ * Keyed by `fileId`-or-path so that a path which becomes produced *after* a
7178
+ * failed `/fs/read` is retried under a different key rather than staying cached
7179
+ * as a miss.
7180
+ */
7181
+ function useHostImage(client, sessionId, producedFiles) {
7182
+ const cache = useRef(/* @__PURE__ */ new Map());
7183
+ const objectUrls = useRef([]);
7184
+ useEffect(() => () => {
7185
+ for (const url of objectUrls.current) URL.revokeObjectURL(url);
7186
+ objectUrls.current = [];
7187
+ }, []);
7188
+ return useCallback((path) => {
7189
+ const produced = producedFiles?.[path];
7190
+ const key = produced ? `produced:${produced.fileId}` : `fs:${path}`;
7191
+ const hit = cache.current.get(key);
7192
+ if (hit) return hit;
7193
+ const pending = produced && sessionId ? client.readProducedFile(sessionId, produced.fileId).then((blob) => {
7194
+ if (blob.size === 0) return void 0;
7195
+ const url = URL.createObjectURL(blob);
7196
+ objectUrls.current.push(url);
7197
+ return url;
7198
+ }).catch(() => void 0) : client.readHostFile(path).then((file) => {
7199
+ if (file.encoding !== "base64") return void 0;
7200
+ const mediaType = IMAGE_MEDIA_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()];
7201
+ return mediaType ? `data:${mediaType};base64,${file.content}` : void 0;
7202
+ }).catch(() => void 0);
7203
+ cache.current.set(key, pending);
7204
+ return pending;
7205
+ }, [
7206
+ client,
7207
+ sessionId,
7208
+ producedFiles
7209
+ ]);
7210
+ }
7211
+ /** Extensions worth rendering inline, and what to call them. Anything else is
7212
+ * left to the card's path text — guessing a media type is how an HTML file ends
7213
+ * up in an `<img>`. */
7214
+ const IMAGE_MEDIA_TYPES = {
7215
+ png: "image/png",
7216
+ jpg: "image/jpeg",
7217
+ jpeg: "image/jpeg",
7218
+ gif: "image/gif",
7219
+ webp: "image/webp"
7220
+ };
7221
+ /** A dismissible advisory strip above the transcript. */
7222
+ function Notice({ level, onDismiss, children }) {
7223
+ return /* @__PURE__ */ jsx("div", {
7224
+ className: "px-3 pt-2",
7225
+ children: /* @__PURE__ */ jsxs("div", {
7226
+ role: "alert",
7227
+ className: cn("mx-auto flex w-full max-w-3xl items-start gap-2 rounded-md border px-3 py-2 text-body-sm", level === "error" ? "border-danger/40 bg-danger-bg text-danger" : "border-warning/40 bg-warning-bg text-warning"),
7228
+ children: [
7229
+ /* @__PURE__ */ jsx(TriangleAlert, { className: "mt-0.5 size-3.5 shrink-0" }),
7230
+ /* @__PURE__ */ jsx("span", {
7231
+ className: "min-w-0 flex-1 break-words",
7232
+ children
7233
+ }),
7234
+ onDismiss ? /* @__PURE__ */ jsx("button", {
7235
+ type: "button",
7236
+ onClick: onDismiss,
7237
+ "aria-label": "Dismiss",
7238
+ className: "shrink-0 opacity-70 transition-opacity hover:opacity-100",
7239
+ children: /* @__PURE__ */ jsx(X, { className: "size-3.5" })
7240
+ }) : null
7241
+ ]
7242
+ })
7243
+ });
7244
+ }
7245
+ //#endregion
7246
+ //#region src/components/agent/EditorTabs.tsx
7247
+ /**
7248
+ * The open-file tab strip.
7249
+ *
7250
+ * Hand-rolled rather than built on `@base-ui/react`'s `Tabs`: a VS Code tab
7251
+ * carries a close button, and a `<button>` inside a `<button>` is invalid HTML —
7252
+ * getting the primitive to render something else costs more than the roving
7253
+ * tabindex it would have provided. So that part is here, explicitly, along with
7254
+ * the two affordances that actually make a tab strip feel right: middle-click to
7255
+ * close, and the active tab scrolling itself into view.
7256
+ *
7257
+ * Deliberately state-free. Which files are open, which is focused and what
7258
+ * closing does are all decided by `useOpenFiles`.
7259
+ */
7260
+ function EditorTabs({ files, activePath, onActivate, onClose, className }) {
7261
+ return /* @__PURE__ */ jsx(TooltipProvider, {
7262
+ delay: 500,
7263
+ closeDelay: 0,
7264
+ children: /* @__PURE__ */ jsx("div", {
7265
+ "data-slot": "editor-tabs",
7266
+ role: "tablist",
7267
+ "aria-label": "Open files",
7268
+ className: cn("flex shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface", className),
7269
+ children: files.map((file) => /* @__PURE__ */ jsx(Tab, {
7270
+ file,
7271
+ active: file.path === activePath,
7272
+ onActivate: () => onActivate(file.path),
7273
+ onClose: () => onClose(file.path),
7274
+ onArrow: (direction) => {
7275
+ const next = files[files.findIndex((f) => f.path === file.path) + direction];
7276
+ if (next) onActivate(next.path);
7277
+ }
7278
+ }, file.path))
7279
+ })
7280
+ });
7281
+ }
7282
+ function Tab({ file, active, onActivate, onClose, onArrow }) {
7283
+ const dirty = isDirty(file);
7284
+ const ref = useRef(null);
7285
+ useEffect(() => {
7286
+ if (active) ref.current?.scrollIntoView({
7287
+ block: "nearest",
7288
+ inline: "nearest"
7289
+ });
7290
+ }, [active]);
7291
+ const onAuxClick = (event) => {
7292
+ if (event.button !== 1) return;
7293
+ event.preventDefault();
7294
+ onClose();
7295
+ };
7296
+ return /* @__PURE__ */ jsxs(Tip, {
7297
+ side: "bottom",
7298
+ render: /* @__PURE__ */ jsx("div", {
7299
+ ref,
7300
+ role: "tab",
7301
+ tabIndex: active ? 0 : -1,
7302
+ "aria-selected": active,
7303
+ onClick: onActivate,
7304
+ onAuxClick,
7305
+ onKeyDown: (event) => {
7306
+ if (event.key === "ArrowRight") onArrow(1);
7307
+ else if (event.key === "ArrowLeft") onArrow(-1);
7308
+ else if (event.key === "Enter" || event.key === " ") onActivate();
7309
+ else return;
7310
+ event.preventDefault();
7311
+ },
7312
+ className: cn("group flex min-w-0 shrink-0 cursor-pointer items-center gap-1.5 border-r border-border px-3 py-1.5 transition-colors", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-inset", active ? "bg-bg text-fg-1" : "text-fg-3 hover:bg-surface-hover hover:text-fg-2")
7313
+ }),
7314
+ content: /* @__PURE__ */ jsxs("span", {
7315
+ className: "flex flex-col gap-0.5",
7316
+ children: [/* @__PURE__ */ jsx("span", {
7317
+ className: "font-mono break-all",
7318
+ children: file.path
7319
+ }), file.bytes !== void 0 ? /* @__PURE__ */ jsx("span", {
7320
+ className: "text-fg-4",
7321
+ children: formatBytes(file.bytes)
7322
+ }) : null]
7323
+ }),
7324
+ children: [
7325
+ /* @__PURE__ */ jsx("span", {
7326
+ className: cn("max-w-40 truncate font-mono text-label", dirty && "italic"),
7327
+ children: file.name
7328
+ }),
7329
+ file.status === "error" ? /* @__PURE__ */ jsx("span", {
7330
+ className: "shrink-0 text-danger",
7331
+ children: "!"
7332
+ }) : null,
7333
+ /* @__PURE__ */ jsxs("button", {
7334
+ type: "button",
7335
+ "aria-label": dirty ? `Close ${file.name} (unsaved changes)` : `Close ${file.name}`,
7336
+ onClick: (event) => {
7337
+ event.stopPropagation();
7338
+ onClose();
7339
+ },
7340
+ className: cn("shrink-0 rounded p-0.5 text-fg-4 transition-opacity hover:bg-surface-hover hover:text-fg-1", active || dirty ? "opacity-70" : "opacity-0 group-hover:opacity-70 focus-visible:opacity-100"),
7341
+ children: [/* @__PURE__ */ jsx("span", {
7342
+ className: cn("block", dirty && "group-hover:hidden"),
7343
+ children: dirty ? /* @__PURE__ */ jsx("span", { className: "block size-3 rounded-full bg-fg-2" }) : /* @__PURE__ */ jsx(X, { className: "size-3" })
7344
+ }), dirty ? /* @__PURE__ */ jsx("span", {
7345
+ className: "hidden group-hover:block",
7346
+ children: /* @__PURE__ */ jsx(X, { className: "size-3" })
7347
+ }) : null]
7348
+ })
7349
+ ]
7350
+ });
7351
+ }
7352
+ //#endregion
7353
+ //#region src/components/agent/FileTree.tsx
7354
+ /** How far one level of nesting indents, in pixels. Inline rather than a Tailwind
7355
+ * class because depth is a number at runtime and `pl-${n}` is not a class. */
7356
+ const INDENT = 12;
7357
+ /**
7358
+ * The workspace's left rail: an expandable tree of the session's project,
7359
+ * with a search box over the same fuzzy route `@file` completion uses.
7360
+ *
7361
+ * Presentational by construction — every piece of state it renders comes from
7362
+ * the hooks in `@workerdeck/react`, and the only thing it owns is the search
7363
+ * query, which is the text in its own input.
7364
+ *
7365
+ * Searching replaces the tree with matches rather than filtering it: the route
7366
+ * answers with paths from all over the project, and threading those back into
7367
+ * tree positions would mean expanding a dozen directories to show six results.
7368
+ */
7369
+ function FileTree({ tree, search, activePath, onOpenFile, onCollapse, style, className }) {
7370
+ const [query, setQuery] = useState("");
7371
+ const [matches, setMatches] = useState();
7372
+ const searching = query.trim().length > 0;
7373
+ useEffect(() => {
7374
+ if (!search?.available) return;
7375
+ const q = query.trim();
7376
+ if (!q) {
7377
+ setMatches(void 0);
7378
+ return;
7379
+ }
7380
+ const controller = new AbortController();
7381
+ const timer = setTimeout(() => {
7382
+ search.search(q, {
7383
+ limit: 60,
7384
+ signal: controller.signal
7385
+ }).then((found) => {
7386
+ if (!controller.signal.aborted) setMatches(found);
7387
+ });
7388
+ }, 150);
7389
+ return () => {
7390
+ controller.abort();
7391
+ clearTimeout(timer);
7392
+ };
7393
+ }, [search, query]);
7394
+ return /* @__PURE__ */ jsxs("div", {
7395
+ "data-slot": "file-tree",
7396
+ style,
7397
+ className: cn("flex min-h-0 min-w-0 flex-col bg-surface", className),
7398
+ children: [/* @__PURE__ */ jsxs("div", {
7399
+ className: "flex items-center gap-1 px-2 pt-2",
7400
+ children: [
7401
+ search?.available ? /* @__PURE__ */ jsxs("div", {
7402
+ className: "relative min-w-0 flex-1",
7403
+ children: [
7404
+ /* @__PURE__ */ jsx(Search, { className: "absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4" }),
7405
+ /* @__PURE__ */ jsx(Input, {
7406
+ value: query,
7407
+ onChange: (e) => setQuery(e.target.value),
7408
+ placeholder: "Search files…",
7409
+ className: "h-7 pr-7 pl-7 text-label",
7410
+ spellCheck: false
7411
+ }),
7412
+ searching ? /* @__PURE__ */ jsx("button", {
7413
+ type: "button",
7414
+ onClick: () => setQuery(""),
7415
+ "aria-label": "Clear search",
7416
+ className: "absolute top-1/2 right-1.5 -translate-y-1/2 text-fg-4 transition-colors hover:text-fg-2",
7417
+ children: /* @__PURE__ */ jsx(X, { className: "size-3.5" })
7418
+ }) : null
7419
+ ]
7420
+ }) : /* @__PURE__ */ jsx("span", {
7421
+ className: "min-w-0 flex-1 truncate px-1 font-mono text-label text-fg-4",
7422
+ children: tree.root
7423
+ }),
7424
+ /* @__PURE__ */ jsx(Button, {
7425
+ variant: "ghost",
7426
+ size: "icon-sm",
7427
+ "aria-label": "Refresh the file tree",
7428
+ onClick: () => tree.refresh(),
7429
+ children: /* @__PURE__ */ jsx(RefreshCw, { className: "size-3.5 text-fg-3" })
7430
+ }),
7431
+ onCollapse ? /* @__PURE__ */ jsx(Button, {
7432
+ variant: "ghost",
7433
+ size: "icon-sm",
7434
+ "aria-label": "Hide project files",
7435
+ onClick: onCollapse,
7436
+ children: /* @__PURE__ */ jsx(PanelLeftClose, { className: "size-3.5 text-fg-3" })
7437
+ }) : null
7438
+ ]
7439
+ }), /* @__PURE__ */ jsx("div", {
7440
+ className: "min-h-0 flex-1 overflow-auto px-1 py-1",
7441
+ children: tree.error ? /* @__PURE__ */ jsx("p", {
7442
+ className: "px-2 py-3 text-body-sm text-danger",
7443
+ children: tree.error
7444
+ }) : searching ? matches === void 0 ? /* @__PURE__ */ jsx("div", {
7445
+ className: "py-6 text-center",
7446
+ children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-fg-4" })
7447
+ }) : matches.length === 0 ? /* @__PURE__ */ jsx("p", {
7448
+ className: "px-2 py-6 text-center text-body-sm text-fg-4",
7449
+ children: "No matching files."
7450
+ }) : /* @__PURE__ */ jsx("ul", { children: matches.map((match) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(Row, {
7451
+ label: fileName(match.relative),
7452
+ detail: directoryOf(match.relative),
7453
+ title: match.relative,
7454
+ icon: /* @__PURE__ */ jsx(File, { className: "size-3.5 shrink-0 text-fg-4" }),
7455
+ active: match.path === activePath,
7456
+ onClick: () => onOpenFile(match.path)
7457
+ }) }, match.path)) }) : tree.loading ? /* @__PURE__ */ jsx("div", {
7458
+ className: "py-6 text-center",
7459
+ children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-fg-4" })
7460
+ }) : tree.rows.length === 0 ? /* @__PURE__ */ jsx("p", {
7461
+ className: "px-2 py-6 text-center text-body-sm text-fg-4",
7462
+ children: "This project is empty."
7463
+ }) : /* @__PURE__ */ jsx("ul", {
7464
+ role: "tree",
7465
+ "aria-label": "Project files",
7466
+ children: tree.rows.map((row) => /* @__PURE__ */ jsxs("li", {
7467
+ role: "treeitem",
7468
+ "aria-expanded": row.expanded,
7469
+ "aria-level": row.depth + 1,
7470
+ children: [/* @__PURE__ */ jsx(Row, {
7471
+ label: row.entry.name,
7472
+ indent: row.depth,
7473
+ active: row.entry.path === activePath,
7474
+ icon: row.entry.type === "dir" ? row.loading ? /* @__PURE__ */ jsx(Spinner, { className: "size-3.5 shrink-0 text-fg-4" }) : row.expanded ? /* @__PURE__ */ jsx(FolderOpen, { className: "size-3.5 shrink-0 text-accent" }) : /* @__PURE__ */ jsx(Folder, { className: "size-3.5 shrink-0 text-accent" }) : /* @__PURE__ */ jsx(EntryIcon, { type: row.entry.type }),
7475
+ chevron: row.entry.type === "dir" ? /* @__PURE__ */ jsx(ChevronRight, { className: cn("size-3 shrink-0 text-fg-4 transition-transform", row.expanded && "rotate-90") }) : void 0,
7476
+ onClick: () => row.entry.type === "dir" ? tree.toggle(row.entry.path) : onOpenFile(row.entry.path)
7477
+ }), row.truncated ? /* @__PURE__ */ jsx("p", {
7478
+ className: "truncate py-0.5 text-label text-fg-4",
7479
+ style: { paddingLeft: (row.depth + 1) * INDENT + 22 },
7480
+ children: "More entries than the server will return — use search."
7481
+ }) : null]
7482
+ }, row.entry.path))
7483
+ })
7484
+ })]
7485
+ });
7486
+ }
7487
+ /** One clickable line. A directory and a file differ only in what the click does
7488
+ * and whether there is a chevron — visually they are the same row. */
7489
+ function Row({ label, detail, title, icon, chevron, indent = 0, active, onClick }) {
7490
+ const ref = useRef(null);
7491
+ useEffect(() => {
7492
+ if (active) ref.current?.scrollIntoView({ block: "nearest" });
7493
+ }, [active]);
7494
+ return /* @__PURE__ */ jsxs("button", {
7495
+ ref,
7496
+ type: "button",
7497
+ onClick,
7498
+ title: title ?? label,
7499
+ style: { paddingLeft: indent * INDENT + 4 },
7500
+ className: cn("flex w-full items-center gap-1 rounded py-1 pr-2 text-left transition-colors", active ? "bg-surface-hover text-fg-1" : "text-fg-2 hover:bg-surface-hover"),
7501
+ children: [
7502
+ /* @__PURE__ */ jsx("span", {
7503
+ className: "flex size-3 shrink-0 items-center justify-center",
7504
+ children: chevron
7505
+ }),
7506
+ icon,
7507
+ /* @__PURE__ */ jsx("span", {
7508
+ className: "shrink-0 truncate font-mono text-label",
7509
+ children: label
7510
+ }),
7511
+ detail ? /* @__PURE__ */ jsx("span", {
7512
+ className: "min-w-0 flex-1 truncate font-mono text-label text-fg-4",
7513
+ children: detail
7514
+ }) : null
7515
+ ]
7516
+ });
7517
+ }
7518
+ /** Last segment of a relative match. */
7519
+ function fileName(relative) {
7520
+ return relative.slice(relative.lastIndexOf("/") + 1);
7521
+ }
7522
+ /** Everything before it, or `undefined` for a file at the search root. */
7523
+ function directoryOf(relative) {
7524
+ const cut = relative.lastIndexOf("/");
7525
+ return cut === -1 ? void 0 : relative.slice(0, cut);
7526
+ }
7527
+ /** A symlink is reported as itself and never silently resolved — following it is
7528
+ * the next request's problem, and that request is refused if it escapes the roots. */
7529
+ function EntryIcon({ type }) {
7530
+ if (type === "symlink") return /* @__PURE__ */ jsx(Link2, { className: "size-3.5 shrink-0 text-fg-4" });
7531
+ return /* @__PURE__ */ jsx(File, { className: "size-3.5 shrink-0 text-fg-4" });
7532
+ }
7533
+ //#endregion
7534
+ //#region src/components/agent/CodeEditor.tsx
7535
+ /**
7536
+ * Monaco — VS Code's own editor — behind a small React surface.
7537
+ *
7538
+ * Two deliberate choices, both about `@workerdeck/ui` being a **published
7539
+ * library** rather than an app:
7540
+ *
7541
+ * 1. **Loaded on demand.** The `import()` is inside an effect, so Monaco is a
7542
+ * separate chunk that arrives when someone first opens a file. A dashboard
7543
+ * that never opens one never pays for it, and Monaco's ~90 language grammars
7544
+ * are themselves lazy (each `registerLanguage` carries an `import()` loader),
7545
+ * so opening a `.ts` file fetches the TypeScript grammar and nothing else.
7546
+ * 2. **No `MonacoEnvironment` is configured here, and none is needed.** Workers
7547
+ * in a library become every embedder's bootstrapping problem, and
7548
+ * `packages/web` ships prebuilt static files at a domain root, which is
7549
+ * exactly where hardcoded worker URLs break. The editor is configured so it
7550
+ * never asks for one: `wordBasedSuggestions` and `quickSuggestions` off,
7551
+ * no diff editor. A host that wants the worker-backed language services
7552
+ * (TypeScript IntelliSense, JSON schema validation) sets `MonacoEnvironment`
7553
+ * itself before the first file is opened — Monaco is a singleton and nothing
7554
+ * here fights that.
7555
+ *
7556
+ * One model per path, kept across tab switches, so undo history and view state
7557
+ * survive clicking away and back — which is most of what makes tabs feel like
7558
+ * tabs rather than like re-opening a file.
7559
+ */
7560
+ function CodeEditor({ path, value, onChange, onSave, readOnly, className }) {
7561
+ const host = useRef(null);
7562
+ const editor = useRef(null);
7563
+ const monaco = useRef(null);
7564
+ const [ready, setReady] = useState(false);
7565
+ const theme = useDocumentTheme();
7566
+ const handlers = useRef({
7567
+ onChange,
7568
+ onSave
7569
+ });
7570
+ handlers.current = {
7571
+ onChange,
7572
+ onSave
7573
+ };
7574
+ useEffect(() => {
7575
+ let disposed = false;
7576
+ loadMonaco().then((api) => {
7577
+ if (disposed || !host.current) return;
7578
+ monaco.current = api;
7579
+ const instance = api.editor.create(host.current, {
7580
+ value,
7581
+ language: languageOf(path),
7582
+ readOnly,
7583
+ automaticLayout: true,
7584
+ theme: monacoTheme(document.documentElement.getAttribute("data-theme")),
7585
+ minimap: { enabled: false },
7586
+ scrollBeyondLastLine: false,
7587
+ fontSize: 12,
7588
+ lineHeight: 20,
7589
+ fontFamily: "ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace",
7590
+ renderLineHighlight: "line",
7591
+ smoothScrolling: true,
7592
+ padding: {
7593
+ top: 8,
7594
+ bottom: 8
7595
+ },
7596
+ scrollbar: {
7597
+ verticalScrollbarSize: 10,
7598
+ horizontalScrollbarSize: 10
7599
+ },
7600
+ wordBasedSuggestions: "off",
7601
+ quickSuggestions: false
7602
+ });
7603
+ editor.current = instance;
7604
+ instance.onDidChangeModelContent(() => {
7605
+ handlers.current.onChange?.(instance.getValue());
7606
+ });
7607
+ instance.addCommand(api.KeyMod.CtrlCmd | api.KeyCode.KeyS, () => {
7608
+ handlers.current.onSave?.();
7609
+ });
7610
+ setReady(true);
7611
+ });
7612
+ return () => {
7613
+ disposed = true;
7614
+ editor.current?.dispose();
7615
+ editor.current = null;
7616
+ setReady(false);
7617
+ };
7618
+ }, []);
7619
+ useEffect(() => {
7620
+ const api = monaco.current;
7621
+ const instance = editor.current;
7622
+ if (!api || !instance || !ready) return;
7623
+ const uri = api.Uri.parse(`workerdeck://host${path}`);
7624
+ const model = api.editor.getModel(uri) ?? api.editor.createModel(value, languageOf(path), uri);
7625
+ if (instance.getModel() !== model) instance.setModel(model);
7626
+ }, [
7627
+ path,
7628
+ ready,
7629
+ value
7630
+ ]);
7631
+ useEffect(() => {
7632
+ const instance = editor.current;
7633
+ if (!instance || !ready) return;
7634
+ const model = instance.getModel();
7635
+ if (!model || model.getValue() === value) return;
7636
+ model.pushEditOperations([], [{
7637
+ range: model.getFullModelRange(),
7638
+ text: value
7639
+ }], () => null);
7640
+ }, [value, ready]);
7641
+ useEffect(() => {
7642
+ if (ready) editor.current?.updateOptions({ readOnly });
7643
+ }, [readOnly, ready]);
7644
+ useEffect(() => {
7645
+ if (ready) monaco.current?.editor.setTheme(monacoTheme(theme));
7646
+ }, [theme, ready]);
7647
+ return /* @__PURE__ */ jsxs("div", {
7648
+ className: cn("relative min-h-0 min-w-0 flex-1", className),
7649
+ children: [/* @__PURE__ */ jsx("div", {
7650
+ ref: host,
7651
+ className: "absolute inset-0"
7652
+ }), !ready ? /* @__PURE__ */ jsx("div", {
7653
+ className: "absolute inset-0 flex items-center justify-center bg-bg",
7654
+ children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-fg-4" })
7655
+ }) : null]
7656
+ });
7657
+ }
7658
+ /**
7659
+ * Follow the `data-theme` the design tokens already swap on, so the editor is
7660
+ * never the one light rectangle in a dark app (or the reverse). An attribute
7661
+ * observer rather than a media query: the app has a manual toggle, and the
7662
+ * attribute is what that toggle writes.
7663
+ */
7664
+ function useDocumentTheme() {
7665
+ const [theme, setTheme] = useState(() => typeof document === "undefined" ? null : document.documentElement.getAttribute("data-theme"));
7666
+ useEffect(() => {
7667
+ const root = document.documentElement;
7668
+ const sync = () => setTheme(root.getAttribute("data-theme"));
7669
+ sync();
7670
+ const observer = new MutationObserver(sync);
7671
+ observer.observe(root, {
7672
+ attributes: true,
7673
+ attributeFilter: ["data-theme"]
7674
+ });
7675
+ return () => observer.disconnect();
7676
+ }, []);
7677
+ return theme;
7678
+ }
7679
+ /** An unset attribute means the host never opted into the token themes, in which
7680
+ * case dark matches this package's default surface. */
7681
+ function monacoTheme(theme) {
7682
+ return theme === "light" ? "wd-light" : "wd-dark";
7683
+ }
7684
+ /**
7685
+ * Load Monaco once, register the themes, and hand back the API.
7686
+ *
7687
+ * Cached as a promise rather than a value so that several editors mounting in
7688
+ * the same frame share one load instead of racing three of them.
7689
+ */
7690
+ let monacoPromise;
7691
+ function loadMonaco() {
7692
+ monacoPromise ??= (async () => {
7693
+ const api = await import("monaco-editor");
7694
+ api.editor.defineTheme("wd-dark", {
7695
+ base: "vs-dark",
7696
+ inherit: true,
7697
+ rules: [],
7698
+ colors: { "editor.background": "#00000000" }
7699
+ });
7700
+ api.editor.defineTheme("wd-light", {
7701
+ base: "vs",
7702
+ inherit: true,
7703
+ rules: [],
7704
+ colors: { "editor.background": "#00000000" }
7705
+ });
7706
+ return api;
7707
+ })();
7708
+ return monacoPromise;
7709
+ }
7710
+ /**
7711
+ * Monaco's language id for a path.
7712
+ *
7713
+ * By extension, with a short table for the files that have none — Monaco's own
7714
+ * registry is keyed on extensions it knows, and an unmatched file falls through
7715
+ * to `plaintext`, which is the honest answer rather than a guess.
7716
+ */
7717
+ function languageOf(path) {
7718
+ const name = path.slice(path.lastIndexOf("/") + 1);
7719
+ const byName = FILENAME_LANGUAGES[name.toLowerCase()];
7720
+ if (byName) return byName;
7721
+ return EXTENSION_LANGUAGES[name.slice(name.lastIndexOf(".") + 1).toLowerCase()] ?? "plaintext";
7722
+ }
7723
+ /** Files whose type is their whole name. */
7724
+ const FILENAME_LANGUAGES = {
7725
+ dockerfile: "dockerfile",
7726
+ makefile: "plaintext",
7727
+ ".gitignore": "plaintext",
7728
+ ".env": "shell"
7729
+ };
7730
+ /** Extension → Monaco language id. Only where the id differs from the extension
7731
+ * or the extension is ambiguous; everything else Monaco resolves itself. */
7732
+ const EXTENSION_LANGUAGES = {
7733
+ ts: "typescript",
7734
+ tsx: "typescript",
7735
+ mts: "typescript",
7736
+ cts: "typescript",
7737
+ js: "javascript",
7738
+ jsx: "javascript",
7739
+ mjs: "javascript",
7740
+ cjs: "javascript",
7741
+ json: "json",
7742
+ jsonc: "json",
7743
+ md: "markdown",
7744
+ mdx: "markdown",
7745
+ css: "css",
7746
+ scss: "scss",
7747
+ less: "less",
7748
+ html: "html",
7749
+ htm: "html",
7750
+ xml: "xml",
7751
+ svg: "xml",
7752
+ yml: "yaml",
7753
+ yaml: "yaml",
7754
+ toml: "plaintext",
7755
+ sh: "shell",
7756
+ bash: "shell",
7757
+ zsh: "shell",
7758
+ fish: "shell",
7759
+ py: "python",
7760
+ rb: "ruby",
7761
+ rs: "rust",
7762
+ go: "go",
7763
+ swift: "swift",
7764
+ java: "java",
7765
+ kt: "kotlin",
7766
+ c: "c",
7767
+ h: "c",
7768
+ cpp: "cpp",
7769
+ cc: "cpp",
7770
+ hpp: "cpp",
7771
+ cs: "csharp",
7772
+ php: "php",
7773
+ sql: "sql",
7774
+ graphql: "graphql",
7775
+ gql: "graphql",
7776
+ lua: "lua",
7777
+ r: "r",
7778
+ pl: "perl",
7779
+ dart: "dart",
7780
+ scala: "scala",
7781
+ ini: "ini",
7782
+ cfg: "ini",
7783
+ conf: "ini",
7784
+ txt: "plaintext",
7785
+ log: "plaintext"
7786
+ };
7787
+ //#endregion
7788
+ //#region src/components/agent/FileViewer.tsx
7789
+ /**
7790
+ * The focused file: Monaco, plus the states a file can be in that are not
7791
+ * "here is some text".
7792
+ *
7793
+ * No path row — the tab's tooltip carries the path and the size, and a line of
7794
+ * monospace above every file is chrome that never earns its height.
7795
+ */
7796
+ function FileViewer({ file, canWrite, onChange, onSave, onRevert, onReload, onOverwrite, onDismissConflict, className }) {
7797
+ if (!file) return null;
7798
+ return /* @__PURE__ */ jsxs("div", {
7799
+ "data-slot": "file-viewer",
7800
+ className: cn("flex min-h-0 min-w-0 flex-1 flex-col bg-bg", className),
7801
+ children: [
7802
+ file.conflict ? /* @__PURE__ */ jsxs("div", {
7803
+ className: "flex shrink-0 flex-wrap items-center gap-2 border-b border-warning/40 bg-warning-bg px-3 py-2",
7804
+ children: [
7805
+ /* @__PURE__ */ jsx(TriangleAlert, { className: "size-3.5 shrink-0 text-warning" }),
7806
+ /* @__PURE__ */ jsx("span", {
7807
+ className: "min-w-0 flex-1 text-body-sm text-warning",
7808
+ children: "This file changed on disk since you opened it."
7809
+ }),
7810
+ /* @__PURE__ */ jsx(Button, {
7811
+ variant: "outline",
7812
+ size: "xs",
7813
+ onClick: () => onReload?.(file.path),
7814
+ children: "Use the version on disk"
7815
+ }),
7816
+ /* @__PURE__ */ jsx(Button, {
7817
+ variant: "outline",
7818
+ size: "xs",
7819
+ onClick: () => onOverwrite?.(file.path),
7820
+ children: "Keep mine"
7821
+ }),
7822
+ /* @__PURE__ */ jsx(Button, {
7823
+ variant: "ghost",
7824
+ size: "xs",
7825
+ onClick: () => onDismissConflict?.(file.path),
7826
+ children: "Dismiss"
7827
+ })
7828
+ ]
7829
+ }) : file.saveError ? /* @__PURE__ */ jsxs("div", {
7830
+ className: "flex shrink-0 items-center gap-2 border-b border-danger/40 bg-danger-bg px-3 py-2",
7831
+ children: [
7832
+ /* @__PURE__ */ jsx(TriangleAlert, { className: "size-3.5 shrink-0 text-danger" }),
7833
+ /* @__PURE__ */ jsx("span", {
7834
+ className: "min-w-0 flex-1 text-body-sm text-danger",
7835
+ children: file.saveError
7836
+ }),
7837
+ /* @__PURE__ */ jsx(Button, {
7838
+ variant: "ghost",
7839
+ size: "xs",
7840
+ onClick: () => onDismissConflict?.(file.path),
7841
+ children: "Dismiss"
7842
+ })
7843
+ ]
7844
+ }) : null,
7845
+ file.status === "loading" ? /* @__PURE__ */ jsx(Centred, { children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-fg-4" }) }) : file.status === "error" ? /* @__PURE__ */ jsxs(Centred, { children: [/* @__PURE__ */ jsx(TriangleAlert, { className: "size-4 text-danger" }), /* @__PURE__ */ jsx("p", {
7846
+ className: "text-body-sm text-danger",
7847
+ children: file.error
7848
+ })] }) : file.status === "binary" ? /* @__PURE__ */ jsxs(Centred, { children: [
7849
+ /* @__PURE__ */ jsx(FileWarning, { className: "size-4 text-fg-4" }),
7850
+ /* @__PURE__ */ jsx("p", {
7851
+ className: "text-body-sm text-fg-4",
7852
+ children: "This file isn’t text."
7853
+ }),
7854
+ /* @__PURE__ */ jsx("p", {
7855
+ className: "text-label text-fg-4",
7856
+ children: "It can’t be edited here without corrupting it."
7857
+ })
7858
+ ] }) : /* @__PURE__ */ jsx(CodeEditor, {
7859
+ path: file.path,
7860
+ value: currentText(file),
7861
+ readOnly: !canWrite,
7862
+ onChange: onChange ? (content) => onChange(file.path, content) : void 0,
7863
+ onSave: onSave ? () => onSave(file.path) : void 0
7864
+ }),
7865
+ file.status === "ready" && (file.saving || isDirty(file) || !canWrite) ? /* @__PURE__ */ jsx("div", {
7866
+ className: "flex shrink-0 items-center gap-2 border-t border-border px-3 py-1",
7867
+ children: file.saving ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Spinner, { className: "size-3 text-fg-4" }), /* @__PURE__ */ jsx("span", {
7868
+ className: "text-label text-fg-4",
7869
+ children: "Saving…"
7870
+ })] }) : !canWrite ? /* @__PURE__ */ jsx("span", {
7871
+ className: "text-label text-fg-4",
7872
+ children: "Read-only — this gateway doesn’t allow writes."
7873
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
7874
+ /* @__PURE__ */ jsx("span", {
7875
+ className: "text-label text-fg-3",
7876
+ children: "Unsaved changes"
7877
+ }),
7878
+ /* @__PURE__ */ jsx("span", { className: "flex-1" }),
7879
+ /* @__PURE__ */ jsx(Button, {
7880
+ variant: "ghost",
7881
+ size: "xs",
7882
+ onClick: () => onRevert?.(file.path),
7883
+ children: "Revert"
7884
+ }),
7885
+ /* @__PURE__ */ jsxs(Button, {
7886
+ variant: "outline",
7887
+ size: "xs",
7888
+ onClick: () => onSave?.(file.path),
7889
+ children: ["Save", /* @__PURE__ */ jsx("span", {
7890
+ className: "ml-1 text-fg-4",
7891
+ children: "⌘S"
7892
+ })]
7893
+ })
7894
+ ] })
7895
+ }) : null
7896
+ ]
7897
+ });
7898
+ }
7899
+ function Centred({ children }) {
7900
+ return /* @__PURE__ */ jsx("div", {
7901
+ className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-6",
7902
+ children
7903
+ });
7904
+ }
7905
+ //#endregion
7906
+ //#region src/components/agent/SessionWorkspace.tsx
7907
+ const RAIL_MIN = 180;
7908
+ const RAIL_MAX = 520;
7909
+ /** How little of the agent column the editor may leave. Below this the composer
7910
+ * and a line of transcript stop fitting together, which is the point at which the
7911
+ * agent has stopped being usable rather than merely small. */
7912
+ const AGENT_MIN = 220;
7913
+ const EDITOR_MIN = 120;
7914
+ /**
7915
+ * A VS Code-shaped workspace around a live session: file tree on the left, open
7916
+ * files above, the agent below.
7917
+ *
7918
+ * **Strictly additive.** {@link SessionPanel} is untouched and still the whole
7919
+ * session surface on its own — an embedder picks one or the other, and one that
7920
+ * has its own file tree keeps using the panel.
7921
+ *
7922
+ * Two things here are load-bearing and easy to break:
7923
+ *
7924
+ * 1. **The editor region is absent from the layout when nothing is open**, not
7925
+ * collapsed to zero height. A zero-height pane leaves a draggable splitter and
7926
+ * parks the composer at an odd offset; absence is what makes the agent's
7927
+ * "claims the full column" state actually look like the panel alone.
7928
+ * 2. **`SessionPanel` keeps its position in the tree across that transition.** It
7929
+ * holds the WebSocket attach and the entire transcript, so moving it between
7930
+ * parents — or wrapping it conditionally — would remount it and drop the
7931
+ * session's rendered history on the floor the first time someone opens a file.
7932
+ * The conditional children before it are `? :` expressions that leave a null in
7933
+ * their slot, which is exactly what keeps its index stable.
7934
+ */
7935
+ function SessionWorkspace({ client, sessionId, header, defaultRailWidth = 260, defaultRailCollapsed, className }) {
7936
+ const { info } = useSessionInfo(client, sessionId);
7937
+ const cwd = info?.cwd;
7938
+ const tree = useHostFileTree(client, cwd);
7939
+ const search = useHostFileSearch(client, cwd);
7940
+ const files = useOpenFiles(client);
7941
+ const { canWrite } = useHostFileRoots(client);
7942
+ useEffect(() => {
7943
+ if (!files.hasUnsaved) return;
7944
+ const warn = (event) => event.preventDefault();
7945
+ window.addEventListener("beforeunload", warn);
7946
+ return () => window.removeEventListener("beforeunload", warn);
7947
+ }, [files.hasUnsaved]);
7948
+ const wide = useIsWide();
7949
+ const [railCollapsed, setRailCollapsed] = useState(defaultRailCollapsed ?? false);
7950
+ const [railWidth, setRailWidth] = useState(defaultRailWidth);
7951
+ const [editorHeight, setEditorHeight] = useState(360);
7952
+ const hasFiles = files.files.length > 0;
7953
+ const overlayRail = !wide;
7954
+ const railOpen = tree.available && !railCollapsed;
7955
+ useEffect(() => {
7956
+ if (overlayRail) setRailCollapsed(true);
7957
+ }, [overlayRail]);
7958
+ const closeTab = useCallback((path) => {
7959
+ const file = files.files.find((f) => f.path === path);
7960
+ if (file && isDirty(file)) {
7961
+ const name = file.name;
7962
+ if (!window.confirm(`${name} has unsaved changes. Close it and lose them?`)) return;
7963
+ }
7964
+ files.close(path);
7965
+ }, [files]);
7966
+ const column = useRef(null);
7967
+ const columnHeight = useElementHeight(column);
7968
+ const editorMax = Math.max(EDITOR_MIN, columnHeight - AGENT_MIN);
7969
+ const [topBar, setTopBar] = useState(null);
7970
+ const hoisted = header === void 0 || topBar === null ? void 0 : typeof header === "function" ? (slots) => createPortal(header(slots), topBar) : createPortal(header, topBar);
7971
+ return /* @__PURE__ */ jsxs("div", {
7972
+ "data-slot": "session-workspace",
7973
+ className: cn("flex h-full min-h-0 w-full flex-col overflow-hidden bg-bg", className),
7974
+ children: [header !== void 0 ? /* @__PURE__ */ jsx("div", {
7975
+ ref: setTopBar,
7976
+ className: "shrink-0"
7977
+ }) : null, /* @__PURE__ */ jsxs("div", {
7978
+ className: "relative flex min-h-0 flex-1",
7979
+ children: [
7980
+ railOpen ? /* @__PURE__ */ jsx(FileTree, {
7981
+ tree,
7982
+ search,
7983
+ activePath: files.activePath,
7984
+ onOpenFile: files.open,
7985
+ onCollapse: () => setRailCollapsed(true),
7986
+ style: { width: overlayRail ? Math.min(railWidth, 320) : railWidth },
7987
+ className: cn("shrink-0 border-r border-border", overlayRail && "absolute inset-y-0 left-0 z-20 shadow-lg")
7988
+ }) : tree.available ? /* @__PURE__ */ jsx("div", {
7989
+ className: "flex w-8 shrink-0 flex-col items-center border-r border-border bg-surface pt-1.5",
7990
+ children: /* @__PURE__ */ jsx(Button, {
7991
+ variant: "ghost",
7992
+ size: "icon-sm",
7993
+ "aria-label": "Show project files",
7994
+ onClick: () => setRailCollapsed(false),
7995
+ children: /* @__PURE__ */ jsx(PanelLeftOpen, { className: "size-4 text-fg-3" })
7996
+ })
7997
+ }) : null,
7998
+ railOpen && !overlayRail ? /* @__PURE__ */ jsx(Splitter, {
7999
+ orientation: "vertical",
8000
+ value: railWidth,
8001
+ onValueChange: setRailWidth,
8002
+ min: RAIL_MIN,
8003
+ max: RAIL_MAX,
8004
+ "aria-label": "Resize the file tree"
8005
+ }) : null,
8006
+ /* @__PURE__ */ jsxs("div", {
8007
+ ref: column,
8008
+ className: "flex min-h-0 min-w-0 flex-1 flex-col",
8009
+ children: [
8010
+ hasFiles ? /* @__PURE__ */ jsxs("div", {
8011
+ className: "flex min-h-0 shrink-0 flex-col overflow-hidden",
8012
+ style: { height: Math.min(editorHeight, editorMax || editorHeight) },
8013
+ children: [/* @__PURE__ */ jsx(EditorTabs, {
8014
+ files: files.files,
8015
+ activePath: files.activePath,
8016
+ onActivate: files.activate,
8017
+ onClose: closeTab
8018
+ }), /* @__PURE__ */ jsx(FileViewer, {
8019
+ file: files.active,
8020
+ canWrite,
8021
+ onChange: files.edit,
8022
+ onSave: (path) => void files.save(path),
8023
+ onRevert: files.revert,
8024
+ onReload: files.reload,
8025
+ onOverwrite: (path) => void files.overwrite(path),
8026
+ onDismissConflict: files.dismissConflict
8027
+ })]
8028
+ }) : null,
8029
+ hasFiles ? /* @__PURE__ */ jsx(Splitter, {
8030
+ orientation: "horizontal",
8031
+ value: Math.min(editorHeight, editorMax || editorHeight),
8032
+ onValueChange: setEditorHeight,
8033
+ min: EDITOR_MIN,
8034
+ max: editorMax,
8035
+ "aria-label": "Resize the open file"
8036
+ }) : null,
8037
+ /* @__PURE__ */ jsx(SessionPanel, {
8038
+ client,
8039
+ sessionId,
8040
+ header: hoisted,
8041
+ className: "min-h-0 flex-1"
8042
+ })
8043
+ ]
8044
+ }),
8045
+ overlayRail && railOpen ? /* @__PURE__ */ jsx("button", {
8046
+ type: "button",
8047
+ "aria-label": "Close the file tree",
8048
+ onClick: () => setRailCollapsed(true),
8049
+ className: "absolute inset-0 z-10 bg-black/30"
8050
+ }) : null
8051
+ ]
8052
+ })]
8053
+ });
8054
+ }
8055
+ /** Live height of an element, for a splitter that needs to know how much room it
8056
+ * is dividing. Zero until the first observation, which callers treat as
8057
+ * "unmeasured" rather than as a real bound. */
8058
+ function useElementHeight(ref) {
8059
+ const [height, setHeight] = useState(0);
8060
+ useLayoutEffect(() => {
8061
+ const element = ref.current;
8062
+ if (!element) return;
8063
+ const observer = new ResizeObserver(([entry]) => {
8064
+ if (entry) setHeight(entry.contentRect.height);
8065
+ });
8066
+ observer.observe(element);
8067
+ return () => observer.disconnect();
8068
+ }, [ref]);
8069
+ return height;
8070
+ }
8071
+ /** Whether there is room for a rail beside the content. Presentation only — the
8072
+ * workspace's actual state lives in the hooks from `@workerdeck/react`. */
8073
+ function useIsWide() {
8074
+ const [wide, setWide] = useState(true);
8075
+ useEffect(() => {
8076
+ const query = window.matchMedia("(min-width: 768px)");
8077
+ const sync = () => setWide(query.matches);
8078
+ sync();
8079
+ query.addEventListener("change", sync);
8080
+ return () => query.removeEventListener("change", sync);
8081
+ }, []);
8082
+ return wide;
8083
+ }
5164
8084
  //#endregion
5165
8085
  //#region src/components/agent/SessionList.tsx
5166
8086
  function SessionListItem({ session, active, onSelect, onDelete }) {
@@ -5231,6 +8151,6 @@ function SessionList({ sessions, activeId, onSelect, onDelete, emptyText = "No s
5231
8151
  });
5232
8152
  }
5233
8153
  //#endregion
5234
- export { AlertDialog, AlertDialogClose, AlertDialogContent, AlertDialogDescription, AlertDialogTitle, AlertDialogTrigger, Badge, Button, Card, CardContent, CardHeader, CardTitle, CodeBlock, Composer, Conversation, ConversationContent, ConversationScrollButton, CopyButton, FileCard, Input, Loader, Message, MessageContent, ModelSelect, PERMISSION_MODES, PermissionModeSelect, PermissionPrompt, ProgressRing, PromptArea, QUESTION_BEHAVIORS, QuestionPrompt, Reasoning, Response, STATUS_META, Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue, SessionList, SessionListItem, SessionPanel, Spinner, StatusBar, Textarea, Tip, Toaster, ToolCallCard, TooltipContent, TooltipProvider, Transcript, badgeVariants, buttonVariants, cn, commandTrigger, formatBytes, formatCost, formatCountdown, formatDuration, formatRelativeTime, formatTokens, getChipsByTrigger, hashtagTrigger, isSegmentsEmpty, mentionTrigger, parseUserQuestions, plainTextToSegments, segmentsToPlainText, toast, toolInputPreview, usePromptAreaState };
8154
+ export { AlertDialog, AlertDialogClose, AlertDialogContent, AlertDialogDescription, AlertDialogTitle, AlertDialogTrigger, Badge, Button, Card, CardContent, CardHeader, CardTitle, CodeBlock, CodeEditor, Composer, ContextDialog, Conversation, ConversationContent, ConversationScrollButton, CopyButton, Dialog, DialogBody, DialogClose, DialogContent, DialogHeader, DialogRow, DialogTrigger, EditorTabs, FileCard, FileTree, FileViewer, HostFilesDialog, Input, Loader, McpDialog, Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger, Message, MessageContent, ModelSelect, PERMISSION_MODES, PermissionModeSelect, PermissionPrompt, ProgressRing, PromptArea, PromptTokenText, QUESTION_BEHAVIORS, QuestionPrompt, Reasoning, Response, STATUS_META, Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue, SessionEmptyState, SessionInfoDialog, SessionList, SessionListItem, SessionPanel, SessionWorkspace, SkillsDialog, Spinner, Splitter, StatusBar, Textarea, Tip, Toaster, ToolCallCard, TooltipContent, TooltipProvider, Transcript, UsageDialog, badgeVariants, buttonVariants, cn, commandTrigger, copyText, formatAgoPrecise, formatBytes, formatCost, formatCountdown, formatDuration, formatRateLimitWindow, formatRateLimitWindowLong, formatRelativeTime, formatTokens, getChipsByTrigger, hashtagTrigger, isSegmentsEmpty, mentionTrigger, parseUserQuestions, permissionModeMeta, plainTextToSegments, rateLimitWindowSeconds, segmentsToPlainText, skillPrompt, toast, toolIcon, toolInputPreview, usePromptAreaState };
5235
8155
 
5236
8156
  //# sourceMappingURL=index.mjs.map