@volter-ai-dev/supercode-ui 0.1.32 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,6 +103,13 @@ Native continuation is headless by default. A host with a real terminal provider
103
103
  that strategy beside “Continue here.” The UI never infers terminal support from a generic runtime
104
104
  handoff, and never presents a terminal strategy when the host cannot create one.
105
105
 
106
+ New-session execution is advertised per harness. Set `launchModes: ['headless', 'terminal']` and
107
+ `preferredLaunchMode` on a `HarnessOption`; the complete messenger renders a compact “Chat” versus
108
+ “Terminal” choice and emits that `mode` on the `new` intent. Direct controller bindings keep Chat as
109
+ the standard path and delegate Terminal only through `onStartTerminal`, so a terminal selection can
110
+ never accidentally create a second headless runtime. Hosts may remember the preference per harness;
111
+ the messenger also retains the current browser draft choice while its New Chat view is open.
112
+
106
113
  The default controller projection is display-bounded: 120 visible transcript rows, at most 480
107
114
  native entries inspected to fill that tail, 16,000 characters per independent entry field, 100
108
115
  session rows, 20 fidelity-residue details, and 50 subagents. It filters harness-injected context
package/components.mjs CHANGED
@@ -744,7 +744,11 @@ function normalizeUiState(value) {
744
744
  recoverable: raw.recoverable === true,
745
745
  harnesses: Array.isArray(raw.harnesses) ? raw.harnesses.flatMap((candidate) => {
746
746
  const item = record(candidate);
747
- return item && typeof item.id === "string" ? [{ id: item.id, label: string(item.label, harnessDisplayName(item.id)), installed: item.installed === true, startable: item.startable === true, reason: typeof item.reason === "string" ? item.reason : null }] : [];
747
+ if (!item || typeof item.id !== "string") return [];
748
+ const startable = item.startable === true;
749
+ const launchModes = Array.isArray(item.launchModes) ? [...new Set(item.launchModes.filter((mode) => mode === "headless" || mode === "terminal"))] : startable ? ["headless"] : [];
750
+ const preferredLaunchMode = launchModes.includes(item.preferredLaunchMode) ? item.preferredLaunchMode : launchModes[0] ?? null;
751
+ return [{ id: item.id, label: string(item.label, harnessDisplayName(item.id)), installed: item.installed === true, startable, reason: typeof item.reason === "string" ? item.reason : null, launchModes, preferredLaunchMode }];
748
752
  }) : [],
749
753
  history: { sessionLimit: number(history?.sessionLimit), hasMoreSessions: history?.hasMoreSessions === true, transcriptLimit: number(history?.transcriptLimit, 120), hasEarlier: history?.hasEarlier === true },
750
754
  savedDraft: string(raw.savedDraft),
@@ -2550,32 +2554,39 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
2550
2554
  }
2551
2555
  function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey }) {
2552
2556
  const startable = state.harnesses.filter((item) => item.startable);
2553
- const startableKey = startable.map((item) => item.id).join("\0");
2554
- const remembered = newChatMemory.get(memoryKey) ?? { harness: startable[0]?.id ?? "", draft: "", context: [], images: [] };
2557
+ const startableKey = startable.map((item) => `${item.id}:${item.launchModes?.join(",")}:${item.preferredLaunchMode ?? ""}`).join("\0");
2558
+ const remembered = newChatMemory.get(memoryKey) ?? { harness: startable[0]?.id ?? "", draft: "", context: [], images: [], modes: {} };
2555
2559
  const [harness, setHarness] = useState6(remembered.harness);
2556
2560
  const [draft, setDraft] = useState6(remembered.draft);
2557
2561
  const [context, setContext] = useState6(remembered.context);
2558
2562
  const [images, setImages] = useState6(remembered.images ?? []);
2563
+ const [modes, setModes] = useState6(remembered.modes ?? {});
2559
2564
  const [starting, setStarting] = useState6(null);
2560
2565
  const [picking, setPicking] = useState6(false);
2561
2566
  const [dragging, setDragging] = useState6(false);
2562
2567
  const [pickerError, setPickerError] = useState6(null);
2563
2568
  const startSequence = useRef7(0);
2564
2569
  const textarea = useRef7(null);
2570
+ const selectedHarness = startable.find((item) => item.id === harness) ?? null;
2571
+ const launchModes = selectedHarness?.launchModes?.length ? selectedHarness.launchModes : ["headless"];
2572
+ const rememberedMode = modes[harness];
2573
+ const mode = launchModes.includes(rememberedMode) ? rememberedMode : launchModes.includes(selectedHarness?.preferredLaunchMode) ? selectedHarness.preferredLaunchMode : launchModes[0];
2574
+ const terminalAttachments = mode === "terminal" && (context.length > 0 || images.length > 0);
2575
+ const remember = (overrides = {}) => boundedSet(newChatMemory, memoryKey, { harness, draft, context, images, modes, ...overrides });
2565
2576
  useAutosizeTextarea(textarea, draft);
2566
2577
  useEffect8(() => {
2567
2578
  if (startable.some((item) => item.id === harness)) return;
2568
2579
  const next = startable[0]?.id ?? "";
2569
2580
  setHarness(next);
2570
- boundedSet(newChatMemory, memoryKey, { harness: next, draft, context, images });
2581
+ remember({ harness: next });
2571
2582
  }, [harness, startableKey]);
2572
2583
  useEffect8(() => {
2573
- if (!starting) return;
2584
+ if (!starting || starting.mode === "terminal") return;
2574
2585
  const sessionChanged = (state.attached?.key ?? null) !== starting.attachedKey;
2575
2586
  const beganWorking = !starting.busy && state.busy;
2576
2587
  if (!sessionChanged && !beganWorking) return;
2577
2588
  newChatMemory.delete(memoryKey);
2578
- onStarted();
2589
+ onStarted("headless");
2579
2590
  }, [memoryKey, onStarted, starting, state.attached?.key, state.busy]);
2580
2591
  useEffect8(() => {
2581
2592
  if (starting && state.error !== starting.initialError && !state.busy && !state.operation) setStarting(null);
@@ -2584,7 +2595,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2584
2595
  textarea.current?.focus({ preventScroll: true });
2585
2596
  }, []);
2586
2597
  const pickContext = () => {
2587
- if (!adapter.pickContext || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
2598
+ if (mode === "terminal" || !adapter.pickContext || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
2588
2599
  setPicking(true);
2589
2600
  setPickerError(null);
2590
2601
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
@@ -2593,12 +2604,12 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2593
2604
  if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
2594
2605
  setContext((current) => {
2595
2606
  const next = mergeContext(current, attachments.context);
2596
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2607
+ remember({ context: next });
2597
2608
  return next;
2598
2609
  });
2599
2610
  setImages((current) => {
2600
2611
  const next = mergeImages(current, attachments.images);
2601
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2612
+ remember({ images: next });
2602
2613
  return next;
2603
2614
  });
2604
2615
  }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
@@ -2606,6 +2617,10 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2606
2617
  const addImageFiles = (value, source) => {
2607
2618
  const allFiles = Array.from(value ?? []);
2608
2619
  if (!allFiles.length) return false;
2620
+ if (mode === "terminal") {
2621
+ setPickerError("Terminal starts currently accept text only. Choose Chat to attach context or images.");
2622
+ return true;
2623
+ }
2609
2624
  const files = allFiles.filter((file) => file.type.startsWith("image/"));
2610
2625
  if (files.length !== allFiles.length) {
2611
2626
  setPickerError("Drop or paste PNG, JPEG, GIF, or WebP images.");
@@ -2619,7 +2634,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2619
2634
  setPickerError(null);
2620
2635
  imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
2621
2636
  const next = mergeImages(current, picked);
2622
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2637
+ remember({ images: next });
2623
2638
  return next;
2624
2639
  }), (error) => setPickerError(error instanceof Error ? error.message : `Could not ${source} image.`)).finally(() => setPicking(false));
2625
2640
  return true;
@@ -2633,12 +2648,17 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2633
2648
  };
2634
2649
  const send = () => {
2635
2650
  const text = draft.trim();
2636
- if (!text && !images.length || !harness || starting) return;
2651
+ if (!text && !images.length || !harness || starting || terminalAttachments || mode === "terminal" && !text) return;
2637
2652
  const id = startSequence.current + 1;
2638
2653
  startSequence.current = id;
2639
- setStarting({ id, attachedKey: state.attached?.key ?? null, busy: state.busy, initialError: state.error });
2640
- const result = adapter.onIntent({ action: "new", harness, text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
2641
- if (result && typeof result.then === "function") {
2654
+ setStarting({ id, mode, attachedKey: state.attached?.key ?? null, busy: state.busy, initialError: state.error });
2655
+ const result = adapter.onIntent({ action: "new", harness, mode, text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
2656
+ if (mode === "terminal") {
2657
+ Promise.resolve(result).then(() => {
2658
+ newChatMemory.delete(memoryKey);
2659
+ onStarted("terminal");
2660
+ }, () => setStarting((current) => current?.id === id ? null : current));
2661
+ } else if (result && typeof result.then === "function") {
2642
2662
  Promise.resolve(result).catch(() => setStarting((current) => current?.id === id ? null : current));
2643
2663
  }
2644
2664
  };
@@ -2653,7 +2673,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2653
2673
  ] }),
2654
2674
  starting ? /* @__PURE__ */ jsxs8("div", { class: "scui-operation", role: "status", children: [
2655
2675
  /* @__PURE__ */ jsx9("i", {}),
2656
- operationLabel("start")
2676
+ mode === "terminal" ? "Opening terminal\u2026" : operationLabel("start")
2657
2677
  ] }) : null,
2658
2678
  /* @__PURE__ */ jsxs8("div", { class: "scui-new", children: [
2659
2679
  /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2726" }),
@@ -2667,23 +2687,38 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2667
2687
  /* @__PURE__ */ jsx9("select", { value: harness, disabled: Boolean(starting), onChange: (event) => {
2668
2688
  const value = event.currentTarget.value;
2669
2689
  setHarness(value);
2670
- boundedSet(newChatMemory, memoryKey, { harness: value, draft, context, images });
2690
+ remember({ harness: value });
2671
2691
  }, children: state.harnesses.map((item) => /* @__PURE__ */ jsxs8("option", { value: item.id, disabled: !item.startable, children: [
2672
2692
  item.label,
2673
2693
  item.startable ? "" : " \xB7 unavailable"
2674
2694
  ] }, item.id)) })
2675
2695
  ] }),
2696
+ launchModes.length > 1 ? /* @__PURE__ */ jsxs8("fieldset", { class: "scui-launch-modes", disabled: Boolean(starting), children: [
2697
+ /* @__PURE__ */ jsx9("legend", { children: "Run as" }),
2698
+ launchModes.map((item) => /* @__PURE__ */ jsxs8("label", { children: [
2699
+ /* @__PURE__ */ jsx9("input", { type: "radio", name: `launch-mode-${memoryKey}`, value: item, checked: mode === item, onChange: () => {
2700
+ const next = { ...modes, [harness]: item };
2701
+ setModes(next);
2702
+ setPickerError(null);
2703
+ remember({ modes: next });
2704
+ } }),
2705
+ /* @__PURE__ */ jsxs8("span", { children: [
2706
+ /* @__PURE__ */ jsx9("strong", { children: item === "terminal" ? "Terminal" : "Chat" }),
2707
+ /* @__PURE__ */ jsx9("small", { children: item === "terminal" ? "Interactive tmux session" : "Managed here" })
2708
+ ] })
2709
+ ] }, item))
2710
+ ] }) : null,
2676
2711
  /* @__PURE__ */ jsx9(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
2677
2712
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2678
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2713
+ remember({ images: next });
2679
2714
  return next;
2680
2715
  }) }),
2681
2716
  /* @__PURE__ */ jsx9(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
2682
2717
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2683
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2718
+ remember({ context: next });
2684
2719
  return next;
2685
2720
  }) }),
2686
- pickerError ? /* @__PURE__ */ jsx9("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
2721
+ terminalAttachments ? /* @__PURE__ */ jsx9("small", { class: "scui-context-error", role: "alert", children: "Terminal starts currently accept text only. Remove attachments or choose Chat." }) : pickerError ? /* @__PURE__ */ jsx9("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
2687
2722
  /* @__PURE__ */ jsxs8("div", { class: `scui-envelope${dragging ? " scui-drop-target" : ""}`, onDragEnter: (event) => {
2688
2723
  if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
2689
2724
  event.preventDefault();
@@ -2694,18 +2729,18 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2694
2729
  }, onDragLeave: (event) => {
2695
2730
  if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false);
2696
2731
  }, onDrop: dropImages, children: [
2697
- adapter.pickContext ? /* @__PURE__ */ jsx9("button", { class: "scui-attach", type: "button", "aria-label": "Attach files or images", disabled: picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS || Boolean(starting), onClick: pickContext, children: picking ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "attach", size: 17 }) }) : null,
2732
+ adapter.pickContext ? /* @__PURE__ */ jsx9("button", { class: "scui-attach", type: "button", "aria-label": "Attach files or images", disabled: mode === "terminal" || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS || Boolean(starting), onClick: pickContext, children: picking ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "attach", size: 17 }) }) : null,
2698
2733
  /* @__PURE__ */ jsx9("textarea", { ref: textarea, rows: 3, "aria-label": "Message coding agent", placeholder: startable.length ? "What should the agent do?" : "No coding harness is available", value: draft, disabled: !startable.length || Boolean(starting), onPaste: pasteImages, onInput: (event) => {
2699
2734
  const value = event.currentTarget.value;
2700
2735
  setDraft(value);
2701
- boundedSet(newChatMemory, memoryKey, { harness, draft: value, context, images });
2736
+ remember({ draft: value });
2702
2737
  }, onKeyDown: (event) => {
2703
2738
  if (isSendKey(event)) {
2704
2739
  event.preventDefault();
2705
2740
  send();
2706
2741
  }
2707
2742
  } }),
2708
- /* @__PURE__ */ jsx9("span", { children: /* @__PURE__ */ jsx9("button", { type: "button", class: "scui-send", "aria-label": "Start chat", disabled: !draft.trim() && !images.length || !harness || Boolean(starting), onClick: send, children: starting ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "send", size: 17 }) }) })
2743
+ /* @__PURE__ */ jsx9("span", { children: /* @__PURE__ */ jsx9("button", { type: "button", class: "scui-send", "aria-label": mode === "terminal" ? "Start terminal session" : "Start chat", disabled: !draft.trim() && !images.length || !harness || Boolean(starting) || terminalAttachments || mode === "terminal" && !draft.trim(), onClick: send, children: starting ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "send", size: 17 }) }) })
2709
2744
  ] })
2710
2745
  ] })
2711
2746
  ] });
@@ -2742,7 +2777,7 @@ function SupercodeMessenger({ state: stateInput, adapter, class: className = "",
2742
2777
  setListFocus("@new");
2743
2778
  setView("new");
2744
2779
  }, onClose: adapter.onClose ? close : void 0, components, labels: copy, memoryKey }) : null,
2745
- view === "new" ? /* @__PURE__ */ jsx9(NewChat, { state, adapter, onBack: () => setView("list"), onClose: adapter.onClose ? close : void 0, onStarted: () => setView("chat"), labels: copy, memoryKey }) : null,
2780
+ view === "new" ? /* @__PURE__ */ jsx9(NewChat, { state, adapter, onBack: () => setView("list"), onClose: adapter.onClose ? close : void 0, onStarted: (mode) => setView(mode === "terminal" ? "list" : "chat"), labels: copy, memoryKey }) : null,
2746
2781
  view === "chat" ? /* @__PURE__ */ jsx9(Chat, { state, adapter, onBack: () => {
2747
2782
  setListFocus(state.attached?.key ?? listFocus);
2748
2783
  setView("list");
package/controller.d.ts CHANGED
@@ -84,6 +84,7 @@ export interface ControllerBindingOptions {
84
84
  onAcknowledge?: (key: string) => void | Promise<void>;
85
85
  onLoadSessions?: () => void | Promise<void>;
86
86
  onLoadEarlier?: () => void | Promise<void>;
87
+ onStartTerminal?: (intent: Extract<SupercodeUiIntent, { action: 'new' }>) => void | Promise<void>;
87
88
  onResumeTerminal?: (intent: Extract<SupercodeUiIntent, { action: 'resume' }>) => void | Promise<void>;
88
89
  copyText?: UiAdapter['copyText'];
89
90
  resolveImage?: UiAdapter['resolveImage'];
package/controller.mjs CHANGED
@@ -447,6 +447,11 @@ async function dispatchStandard(controller, intent, options) {
447
447
  if (intent.action === 'attach') return controller.dispatch({ type: 'observe', sessionKey: intent.key });
448
448
  if (intent.action === 'send') return controller.dispatch({ type: 'send', text: intent.text, ...(intent.context?.length ? { context: intent.context } : {}), ...(intent.images?.length ? { images: intent.images } : {}) });
449
449
  if (intent.action === 'new') {
450
+ if (intent.mode === 'terminal') {
451
+ return options.onStartTerminal
452
+ ? options.onStartTerminal(intent)
453
+ : options.onUnsupported?.(intent);
454
+ }
450
455
  await controller.dispatch({ type: 'start', harness: intent.harness });
451
456
  return controller.dispatch({ type: 'send', text: intent.text, ...(intent.context?.length ? { context: intent.context } : {}), ...(intent.images?.length ? { images: intent.images } : {}) });
452
457
  }
package/core.mjs CHANGED
@@ -796,7 +796,15 @@ export function normalizeUiState(value) {
796
796
  recoverable: raw.recoverable === true,
797
797
  harnesses: Array.isArray(raw.harnesses) ? raw.harnesses.flatMap((candidate) => {
798
798
  const item = record(candidate);
799
- return item && typeof item.id === 'string' ? [{ id: item.id, label: string(item.label, harnessDisplayName(item.id)), installed: item.installed === true, startable: item.startable === true, reason: typeof item.reason === 'string' ? item.reason : null }] : [];
799
+ if (!item || typeof item.id !== 'string') return [];
800
+ const startable = item.startable === true;
801
+ const launchModes = Array.isArray(item.launchModes)
802
+ ? [...new Set(item.launchModes.filter((mode) => mode === 'headless' || mode === 'terminal'))]
803
+ : startable ? ['headless'] : [];
804
+ const preferredLaunchMode = launchModes.includes(item.preferredLaunchMode)
805
+ ? item.preferredLaunchMode
806
+ : launchModes[0] ?? null;
807
+ return [{ id: item.id, label: string(item.label, harnessDisplayName(item.id)), installed: item.installed === true, startable, reason: typeof item.reason === 'string' ? item.reason : null, launchModes, preferredLaunchMode }];
800
808
  }) : [],
801
809
  history: { sessionLimit: number(history?.sessionLimit), hasMoreSessions: history?.hasMoreSessions === true, transcriptLimit: number(history?.transcriptLimit, 120), hasEarlier: history?.hasEarlier === true },
802
810
  savedDraft: string(raw.savedDraft),
package/embed.mjs CHANGED
@@ -747,7 +747,11 @@ function normalizeUiState(value) {
747
747
  recoverable: raw.recoverable === true,
748
748
  harnesses: Array.isArray(raw.harnesses) ? raw.harnesses.flatMap((candidate) => {
749
749
  const item = record(candidate);
750
- return item && typeof item.id === "string" ? [{ id: item.id, label: string(item.label, harnessDisplayName(item.id)), installed: item.installed === true, startable: item.startable === true, reason: typeof item.reason === "string" ? item.reason : null }] : [];
750
+ if (!item || typeof item.id !== "string") return [];
751
+ const startable = item.startable === true;
752
+ const launchModes = Array.isArray(item.launchModes) ? [...new Set(item.launchModes.filter((mode) => mode === "headless" || mode === "terminal"))] : startable ? ["headless"] : [];
753
+ const preferredLaunchMode = launchModes.includes(item.preferredLaunchMode) ? item.preferredLaunchMode : launchModes[0] ?? null;
754
+ return [{ id: item.id, label: string(item.label, harnessDisplayName(item.id)), installed: item.installed === true, startable, reason: typeof item.reason === "string" ? item.reason : null, launchModes, preferredLaunchMode }];
751
755
  }) : [],
752
756
  history: { sessionLimit: number(history?.sessionLimit), hasMoreSessions: history?.hasMoreSessions === true, transcriptLimit: number(history?.transcriptLimit, 120), hasEarlier: history?.hasEarlier === true },
753
757
  savedDraft: string(raw.savedDraft),
@@ -2529,32 +2533,39 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
2529
2533
  }
2530
2534
  function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey }) {
2531
2535
  const startable = state.harnesses.filter((item) => item.startable);
2532
- const startableKey = startable.map((item) => item.id).join("\0");
2533
- const remembered = newChatMemory.get(memoryKey) ?? { harness: startable[0]?.id ?? "", draft: "", context: [], images: [] };
2536
+ const startableKey = startable.map((item) => `${item.id}:${item.launchModes?.join(",")}:${item.preferredLaunchMode ?? ""}`).join("\0");
2537
+ const remembered = newChatMemory.get(memoryKey) ?? { harness: startable[0]?.id ?? "", draft: "", context: [], images: [], modes: {} };
2534
2538
  const [harness, setHarness] = useState6(remembered.harness);
2535
2539
  const [draft, setDraft] = useState6(remembered.draft);
2536
2540
  const [context, setContext] = useState6(remembered.context);
2537
2541
  const [images, setImages] = useState6(remembered.images ?? []);
2542
+ const [modes, setModes] = useState6(remembered.modes ?? {});
2538
2543
  const [starting, setStarting] = useState6(null);
2539
2544
  const [picking, setPicking] = useState6(false);
2540
2545
  const [dragging, setDragging] = useState6(false);
2541
2546
  const [pickerError, setPickerError] = useState6(null);
2542
2547
  const startSequence = useRef7(0);
2543
2548
  const textarea = useRef7(null);
2549
+ const selectedHarness = startable.find((item) => item.id === harness) ?? null;
2550
+ const launchModes = selectedHarness?.launchModes?.length ? selectedHarness.launchModes : ["headless"];
2551
+ const rememberedMode = modes[harness];
2552
+ const mode = launchModes.includes(rememberedMode) ? rememberedMode : launchModes.includes(selectedHarness?.preferredLaunchMode) ? selectedHarness.preferredLaunchMode : launchModes[0];
2553
+ const terminalAttachments = mode === "terminal" && (context.length > 0 || images.length > 0);
2554
+ const remember = (overrides = {}) => boundedSet(newChatMemory, memoryKey, { harness, draft, context, images, modes, ...overrides });
2544
2555
  useAutosizeTextarea(textarea, draft);
2545
2556
  useEffect8(() => {
2546
2557
  if (startable.some((item) => item.id === harness)) return;
2547
2558
  const next = startable[0]?.id ?? "";
2548
2559
  setHarness(next);
2549
- boundedSet(newChatMemory, memoryKey, { harness: next, draft, context, images });
2560
+ remember({ harness: next });
2550
2561
  }, [harness, startableKey]);
2551
2562
  useEffect8(() => {
2552
- if (!starting) return;
2563
+ if (!starting || starting.mode === "terminal") return;
2553
2564
  const sessionChanged = (state.attached?.key ?? null) !== starting.attachedKey;
2554
2565
  const beganWorking = !starting.busy && state.busy;
2555
2566
  if (!sessionChanged && !beganWorking) return;
2556
2567
  newChatMemory.delete(memoryKey);
2557
- onStarted();
2568
+ onStarted("headless");
2558
2569
  }, [memoryKey, onStarted, starting, state.attached?.key, state.busy]);
2559
2570
  useEffect8(() => {
2560
2571
  if (starting && state.error !== starting.initialError && !state.busy && !state.operation) setStarting(null);
@@ -2563,7 +2574,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2563
2574
  textarea.current?.focus({ preventScroll: true });
2564
2575
  }, []);
2565
2576
  const pickContext = () => {
2566
- if (!adapter.pickContext || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
2577
+ if (mode === "terminal" || !adapter.pickContext || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
2567
2578
  setPicking(true);
2568
2579
  setPickerError(null);
2569
2580
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
@@ -2572,12 +2583,12 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2572
2583
  if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
2573
2584
  setContext((current) => {
2574
2585
  const next = mergeContext(current, attachments.context);
2575
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2586
+ remember({ context: next });
2576
2587
  return next;
2577
2588
  });
2578
2589
  setImages((current) => {
2579
2590
  const next = mergeImages(current, attachments.images);
2580
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2591
+ remember({ images: next });
2581
2592
  return next;
2582
2593
  });
2583
2594
  }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
@@ -2585,6 +2596,10 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2585
2596
  const addImageFiles = (value, source) => {
2586
2597
  const allFiles = Array.from(value ?? []);
2587
2598
  if (!allFiles.length) return false;
2599
+ if (mode === "terminal") {
2600
+ setPickerError("Terminal starts currently accept text only. Choose Chat to attach context or images.");
2601
+ return true;
2602
+ }
2588
2603
  const files = allFiles.filter((file) => file.type.startsWith("image/"));
2589
2604
  if (files.length !== allFiles.length) {
2590
2605
  setPickerError("Drop or paste PNG, JPEG, GIF, or WebP images.");
@@ -2598,7 +2613,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2598
2613
  setPickerError(null);
2599
2614
  imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
2600
2615
  const next = mergeImages(current, picked);
2601
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2616
+ remember({ images: next });
2602
2617
  return next;
2603
2618
  }), (error) => setPickerError(error instanceof Error ? error.message : `Could not ${source} image.`)).finally(() => setPicking(false));
2604
2619
  return true;
@@ -2612,12 +2627,17 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2612
2627
  };
2613
2628
  const send = () => {
2614
2629
  const text = draft.trim();
2615
- if (!text && !images.length || !harness || starting) return;
2630
+ if (!text && !images.length || !harness || starting || terminalAttachments || mode === "terminal" && !text) return;
2616
2631
  const id = startSequence.current + 1;
2617
2632
  startSequence.current = id;
2618
- setStarting({ id, attachedKey: state.attached?.key ?? null, busy: state.busy, initialError: state.error });
2619
- const result = adapter.onIntent({ action: "new", harness, text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
2620
- if (result && typeof result.then === "function") {
2633
+ setStarting({ id, mode, attachedKey: state.attached?.key ?? null, busy: state.busy, initialError: state.error });
2634
+ const result = adapter.onIntent({ action: "new", harness, mode, text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
2635
+ if (mode === "terminal") {
2636
+ Promise.resolve(result).then(() => {
2637
+ newChatMemory.delete(memoryKey);
2638
+ onStarted("terminal");
2639
+ }, () => setStarting((current) => current?.id === id ? null : current));
2640
+ } else if (result && typeof result.then === "function") {
2621
2641
  Promise.resolve(result).catch(() => setStarting((current) => current?.id === id ? null : current));
2622
2642
  }
2623
2643
  };
@@ -2632,7 +2652,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2632
2652
  ] }),
2633
2653
  starting ? /* @__PURE__ */ jsxs8("div", { class: "scui-operation", role: "status", children: [
2634
2654
  /* @__PURE__ */ jsx9("i", {}),
2635
- operationLabel("start")
2655
+ mode === "terminal" ? "Opening terminal\u2026" : operationLabel("start")
2636
2656
  ] }) : null,
2637
2657
  /* @__PURE__ */ jsxs8("div", { class: "scui-new", children: [
2638
2658
  /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2726" }),
@@ -2646,23 +2666,38 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2646
2666
  /* @__PURE__ */ jsx9("select", { value: harness, disabled: Boolean(starting), onChange: (event) => {
2647
2667
  const value = event.currentTarget.value;
2648
2668
  setHarness(value);
2649
- boundedSet(newChatMemory, memoryKey, { harness: value, draft, context, images });
2669
+ remember({ harness: value });
2650
2670
  }, children: state.harnesses.map((item) => /* @__PURE__ */ jsxs8("option", { value: item.id, disabled: !item.startable, children: [
2651
2671
  item.label,
2652
2672
  item.startable ? "" : " \xB7 unavailable"
2653
2673
  ] }, item.id)) })
2654
2674
  ] }),
2675
+ launchModes.length > 1 ? /* @__PURE__ */ jsxs8("fieldset", { class: "scui-launch-modes", disabled: Boolean(starting), children: [
2676
+ /* @__PURE__ */ jsx9("legend", { children: "Run as" }),
2677
+ launchModes.map((item) => /* @__PURE__ */ jsxs8("label", { children: [
2678
+ /* @__PURE__ */ jsx9("input", { type: "radio", name: `launch-mode-${memoryKey}`, value: item, checked: mode === item, onChange: () => {
2679
+ const next = { ...modes, [harness]: item };
2680
+ setModes(next);
2681
+ setPickerError(null);
2682
+ remember({ modes: next });
2683
+ } }),
2684
+ /* @__PURE__ */ jsxs8("span", { children: [
2685
+ /* @__PURE__ */ jsx9("strong", { children: item === "terminal" ? "Terminal" : "Chat" }),
2686
+ /* @__PURE__ */ jsx9("small", { children: item === "terminal" ? "Interactive tmux session" : "Managed here" })
2687
+ ] })
2688
+ ] }, item))
2689
+ ] }) : null,
2655
2690
  /* @__PURE__ */ jsx9(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
2656
2691
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2657
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2692
+ remember({ images: next });
2658
2693
  return next;
2659
2694
  }) }),
2660
2695
  /* @__PURE__ */ jsx9(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
2661
2696
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2662
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2697
+ remember({ context: next });
2663
2698
  return next;
2664
2699
  }) }),
2665
- pickerError ? /* @__PURE__ */ jsx9("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
2700
+ terminalAttachments ? /* @__PURE__ */ jsx9("small", { class: "scui-context-error", role: "alert", children: "Terminal starts currently accept text only. Remove attachments or choose Chat." }) : pickerError ? /* @__PURE__ */ jsx9("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
2666
2701
  /* @__PURE__ */ jsxs8("div", { class: `scui-envelope${dragging ? " scui-drop-target" : ""}`, onDragEnter: (event) => {
2667
2702
  if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
2668
2703
  event.preventDefault();
@@ -2673,18 +2708,18 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2673
2708
  }, onDragLeave: (event) => {
2674
2709
  if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false);
2675
2710
  }, onDrop: dropImages, children: [
2676
- adapter.pickContext ? /* @__PURE__ */ jsx9("button", { class: "scui-attach", type: "button", "aria-label": "Attach files or images", disabled: picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS || Boolean(starting), onClick: pickContext, children: picking ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "attach", size: 17 }) }) : null,
2711
+ adapter.pickContext ? /* @__PURE__ */ jsx9("button", { class: "scui-attach", type: "button", "aria-label": "Attach files or images", disabled: mode === "terminal" || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS || Boolean(starting), onClick: pickContext, children: picking ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "attach", size: 17 }) }) : null,
2677
2712
  /* @__PURE__ */ jsx9("textarea", { ref: textarea, rows: 3, "aria-label": "Message coding agent", placeholder: startable.length ? "What should the agent do?" : "No coding harness is available", value: draft, disabled: !startable.length || Boolean(starting), onPaste: pasteImages, onInput: (event) => {
2678
2713
  const value = event.currentTarget.value;
2679
2714
  setDraft(value);
2680
- boundedSet(newChatMemory, memoryKey, { harness, draft: value, context, images });
2715
+ remember({ draft: value });
2681
2716
  }, onKeyDown: (event) => {
2682
2717
  if (isSendKey(event)) {
2683
2718
  event.preventDefault();
2684
2719
  send();
2685
2720
  }
2686
2721
  } }),
2687
- /* @__PURE__ */ jsx9("span", { children: /* @__PURE__ */ jsx9("button", { type: "button", class: "scui-send", "aria-label": "Start chat", disabled: !draft.trim() && !images.length || !harness || Boolean(starting), onClick: send, children: starting ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "send", size: 17 }) }) })
2722
+ /* @__PURE__ */ jsx9("span", { children: /* @__PURE__ */ jsx9("button", { type: "button", class: "scui-send", "aria-label": mode === "terminal" ? "Start terminal session" : "Start chat", disabled: !draft.trim() && !images.length || !harness || Boolean(starting) || terminalAttachments || mode === "terminal" && !draft.trim(), onClick: send, children: starting ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "send", size: 17 }) }) })
2688
2723
  ] })
2689
2724
  ] })
2690
2725
  ] });
@@ -2721,7 +2756,7 @@ function SupercodeMessenger({ state: stateInput, adapter, class: className = "",
2721
2756
  setListFocus("@new");
2722
2757
  setView("new");
2723
2758
  }, onClose: adapter.onClose ? close : void 0, components, labels: copy, memoryKey }) : null,
2724
- view === "new" ? /* @__PURE__ */ jsx9(NewChat, { state, adapter, onBack: () => setView("list"), onClose: adapter.onClose ? close : void 0, onStarted: () => setView("chat"), labels: copy, memoryKey }) : null,
2759
+ view === "new" ? /* @__PURE__ */ jsx9(NewChat, { state, adapter, onBack: () => setView("list"), onClose: adapter.onClose ? close : void 0, onStarted: (mode) => setView(mode === "terminal" ? "list" : "chat"), labels: copy, memoryKey }) : null,
2725
2760
  view === "chat" ? /* @__PURE__ */ jsx9(Chat, { state, adapter, onBack: () => {
2726
2761
  setListFocus(state.attached?.key ?? listFocus);
2727
2762
  setView("list");
package/index.d.ts CHANGED
@@ -5,7 +5,9 @@ export type UiTone = 'live' | 'warn' | 'dead' | 'off';
5
5
  export type StartupPhase = 'connecting' | 'starting' | 'discovering' | 'ready';
6
6
  export type SessionMode = 'none' | 'control' | 'mirror';
7
7
  export type ControlStrategy = 'start' | 'resume' | 'attach' | 'branch' | 'reduce' | null;
8
- export type ContinuationMode = 'headless' | 'terminal';
8
+ export type ExecutionMode = 'headless' | 'terminal';
9
+ /** @deprecated Use ExecutionMode. */
10
+ export type ContinuationMode = ExecutionMode;
9
11
  export type SessionActivity = 'idle' | 'recent' | 'running' | 'working' | 'needs-input' | 'finished' | 'failed' | 'unseen';
10
12
 
11
13
  export interface HarnessOption {
@@ -14,6 +16,10 @@ export interface HarnessOption {
14
16
  installed: boolean;
15
17
  startable: boolean;
16
18
  reason: string | null;
19
+ /** Host-supported ways to start this harness. Omitted means headless when startable. */
20
+ launchModes?: ExecutionMode[];
21
+ /** Host preference, used unless this browser has a remembered choice for the harness. */
22
+ preferredLaunchMode?: ExecutionMode | null;
17
23
  }
18
24
 
19
25
  export interface SessionRowModel {
@@ -287,7 +293,7 @@ export type SupercodeUiIntent =
287
293
  | { action: 'loadEarlier' }
288
294
  | { action: 'draft'; text: string }
289
295
  | { action: 'send'; text: string; context?: TranscriptContext[]; images?: Array<TranscriptImage & { url: string }> }
290
- | { action: 'new'; harness: HarnessId; text: string; context?: TranscriptContext[]; images?: Array<TranscriptImage & { url: string }> }
296
+ | { action: 'new'; harness: HarnessId; mode?: ExecutionMode; text: string; context?: TranscriptContext[]; images?: Array<TranscriptImage & { url: string }> }
291
297
  | { action: 'resume'; mode?: ContinuationMode }
292
298
  | { action: 'join' }
293
299
  | { action: 'detach' }
package/messenger.mjs CHANGED
@@ -744,7 +744,11 @@ function normalizeUiState(value) {
744
744
  recoverable: raw.recoverable === true,
745
745
  harnesses: Array.isArray(raw.harnesses) ? raw.harnesses.flatMap((candidate) => {
746
746
  const item = record(candidate);
747
- return item && typeof item.id === "string" ? [{ id: item.id, label: string(item.label, harnessDisplayName(item.id)), installed: item.installed === true, startable: item.startable === true, reason: typeof item.reason === "string" ? item.reason : null }] : [];
747
+ if (!item || typeof item.id !== "string") return [];
748
+ const startable = item.startable === true;
749
+ const launchModes = Array.isArray(item.launchModes) ? [...new Set(item.launchModes.filter((mode) => mode === "headless" || mode === "terminal"))] : startable ? ["headless"] : [];
750
+ const preferredLaunchMode = launchModes.includes(item.preferredLaunchMode) ? item.preferredLaunchMode : launchModes[0] ?? null;
751
+ return [{ id: item.id, label: string(item.label, harnessDisplayName(item.id)), installed: item.installed === true, startable, reason: typeof item.reason === "string" ? item.reason : null, launchModes, preferredLaunchMode }];
748
752
  }) : [],
749
753
  history: { sessionLimit: number(history?.sessionLimit), hasMoreSessions: history?.hasMoreSessions === true, transcriptLimit: number(history?.transcriptLimit, 120), hasEarlier: history?.hasEarlier === true },
750
754
  savedDraft: string(raw.savedDraft),
@@ -2526,32 +2530,39 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
2526
2530
  }
2527
2531
  function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey }) {
2528
2532
  const startable = state.harnesses.filter((item) => item.startable);
2529
- const startableKey = startable.map((item) => item.id).join("\0");
2530
- const remembered = newChatMemory.get(memoryKey) ?? { harness: startable[0]?.id ?? "", draft: "", context: [], images: [] };
2533
+ const startableKey = startable.map((item) => `${item.id}:${item.launchModes?.join(",")}:${item.preferredLaunchMode ?? ""}`).join("\0");
2534
+ const remembered = newChatMemory.get(memoryKey) ?? { harness: startable[0]?.id ?? "", draft: "", context: [], images: [], modes: {} };
2531
2535
  const [harness, setHarness] = useState6(remembered.harness);
2532
2536
  const [draft, setDraft] = useState6(remembered.draft);
2533
2537
  const [context, setContext] = useState6(remembered.context);
2534
2538
  const [images, setImages] = useState6(remembered.images ?? []);
2539
+ const [modes, setModes] = useState6(remembered.modes ?? {});
2535
2540
  const [starting, setStarting] = useState6(null);
2536
2541
  const [picking, setPicking] = useState6(false);
2537
2542
  const [dragging, setDragging] = useState6(false);
2538
2543
  const [pickerError, setPickerError] = useState6(null);
2539
2544
  const startSequence = useRef7(0);
2540
2545
  const textarea = useRef7(null);
2546
+ const selectedHarness = startable.find((item) => item.id === harness) ?? null;
2547
+ const launchModes = selectedHarness?.launchModes?.length ? selectedHarness.launchModes : ["headless"];
2548
+ const rememberedMode = modes[harness];
2549
+ const mode = launchModes.includes(rememberedMode) ? rememberedMode : launchModes.includes(selectedHarness?.preferredLaunchMode) ? selectedHarness.preferredLaunchMode : launchModes[0];
2550
+ const terminalAttachments = mode === "terminal" && (context.length > 0 || images.length > 0);
2551
+ const remember = (overrides = {}) => boundedSet(newChatMemory, memoryKey, { harness, draft, context, images, modes, ...overrides });
2541
2552
  useAutosizeTextarea(textarea, draft);
2542
2553
  useEffect8(() => {
2543
2554
  if (startable.some((item) => item.id === harness)) return;
2544
2555
  const next = startable[0]?.id ?? "";
2545
2556
  setHarness(next);
2546
- boundedSet(newChatMemory, memoryKey, { harness: next, draft, context, images });
2557
+ remember({ harness: next });
2547
2558
  }, [harness, startableKey]);
2548
2559
  useEffect8(() => {
2549
- if (!starting) return;
2560
+ if (!starting || starting.mode === "terminal") return;
2550
2561
  const sessionChanged = (state.attached?.key ?? null) !== starting.attachedKey;
2551
2562
  const beganWorking = !starting.busy && state.busy;
2552
2563
  if (!sessionChanged && !beganWorking) return;
2553
2564
  newChatMemory.delete(memoryKey);
2554
- onStarted();
2565
+ onStarted("headless");
2555
2566
  }, [memoryKey, onStarted, starting, state.attached?.key, state.busy]);
2556
2567
  useEffect8(() => {
2557
2568
  if (starting && state.error !== starting.initialError && !state.busy && !state.operation) setStarting(null);
@@ -2560,7 +2571,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2560
2571
  textarea.current?.focus({ preventScroll: true });
2561
2572
  }, []);
2562
2573
  const pickContext = () => {
2563
- if (!adapter.pickContext || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
2574
+ if (mode === "terminal" || !adapter.pickContext || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS) return;
2564
2575
  setPicking(true);
2565
2576
  setPickerError(null);
2566
2577
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
@@ -2569,12 +2580,12 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2569
2580
  if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
2570
2581
  setContext((current) => {
2571
2582
  const next = mergeContext(current, attachments.context);
2572
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2583
+ remember({ context: next });
2573
2584
  return next;
2574
2585
  });
2575
2586
  setImages((current) => {
2576
2587
  const next = mergeImages(current, attachments.images);
2577
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2588
+ remember({ images: next });
2578
2589
  return next;
2579
2590
  });
2580
2591
  }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
@@ -2582,6 +2593,10 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2582
2593
  const addImageFiles = (value, source) => {
2583
2594
  const allFiles = Array.from(value ?? []);
2584
2595
  if (!allFiles.length) return false;
2596
+ if (mode === "terminal") {
2597
+ setPickerError("Terminal starts currently accept text only. Choose Chat to attach context or images.");
2598
+ return true;
2599
+ }
2585
2600
  const files = allFiles.filter((file) => file.type.startsWith("image/"));
2586
2601
  if (files.length !== allFiles.length) {
2587
2602
  setPickerError("Drop or paste PNG, JPEG, GIF, or WebP images.");
@@ -2595,7 +2610,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2595
2610
  setPickerError(null);
2596
2611
  imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
2597
2612
  const next = mergeImages(current, picked);
2598
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2613
+ remember({ images: next });
2599
2614
  return next;
2600
2615
  }), (error) => setPickerError(error instanceof Error ? error.message : `Could not ${source} image.`)).finally(() => setPicking(false));
2601
2616
  return true;
@@ -2609,12 +2624,17 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2609
2624
  };
2610
2625
  const send = () => {
2611
2626
  const text = draft.trim();
2612
- if (!text && !images.length || !harness || starting) return;
2627
+ if (!text && !images.length || !harness || starting || terminalAttachments || mode === "terminal" && !text) return;
2613
2628
  const id = startSequence.current + 1;
2614
2629
  startSequence.current = id;
2615
- setStarting({ id, attachedKey: state.attached?.key ?? null, busy: state.busy, initialError: state.error });
2616
- const result = adapter.onIntent({ action: "new", harness, text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
2617
- if (result && typeof result.then === "function") {
2630
+ setStarting({ id, mode, attachedKey: state.attached?.key ?? null, busy: state.busy, initialError: state.error });
2631
+ const result = adapter.onIntent({ action: "new", harness, mode, text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
2632
+ if (mode === "terminal") {
2633
+ Promise.resolve(result).then(() => {
2634
+ newChatMemory.delete(memoryKey);
2635
+ onStarted("terminal");
2636
+ }, () => setStarting((current) => current?.id === id ? null : current));
2637
+ } else if (result && typeof result.then === "function") {
2618
2638
  Promise.resolve(result).catch(() => setStarting((current) => current?.id === id ? null : current));
2619
2639
  }
2620
2640
  };
@@ -2629,7 +2649,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2629
2649
  ] }),
2630
2650
  starting ? /* @__PURE__ */ jsxs8("div", { class: "scui-operation", role: "status", children: [
2631
2651
  /* @__PURE__ */ jsx9("i", {}),
2632
- operationLabel("start")
2652
+ mode === "terminal" ? "Opening terminal\u2026" : operationLabel("start")
2633
2653
  ] }) : null,
2634
2654
  /* @__PURE__ */ jsxs8("div", { class: "scui-new", children: [
2635
2655
  /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2726" }),
@@ -2643,23 +2663,38 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2643
2663
  /* @__PURE__ */ jsx9("select", { value: harness, disabled: Boolean(starting), onChange: (event) => {
2644
2664
  const value = event.currentTarget.value;
2645
2665
  setHarness(value);
2646
- boundedSet(newChatMemory, memoryKey, { harness: value, draft, context, images });
2666
+ remember({ harness: value });
2647
2667
  }, children: state.harnesses.map((item) => /* @__PURE__ */ jsxs8("option", { value: item.id, disabled: !item.startable, children: [
2648
2668
  item.label,
2649
2669
  item.startable ? "" : " \xB7 unavailable"
2650
2670
  ] }, item.id)) })
2651
2671
  ] }),
2672
+ launchModes.length > 1 ? /* @__PURE__ */ jsxs8("fieldset", { class: "scui-launch-modes", disabled: Boolean(starting), children: [
2673
+ /* @__PURE__ */ jsx9("legend", { children: "Run as" }),
2674
+ launchModes.map((item) => /* @__PURE__ */ jsxs8("label", { children: [
2675
+ /* @__PURE__ */ jsx9("input", { type: "radio", name: `launch-mode-${memoryKey}`, value: item, checked: mode === item, onChange: () => {
2676
+ const next = { ...modes, [harness]: item };
2677
+ setModes(next);
2678
+ setPickerError(null);
2679
+ remember({ modes: next });
2680
+ } }),
2681
+ /* @__PURE__ */ jsxs8("span", { children: [
2682
+ /* @__PURE__ */ jsx9("strong", { children: item === "terminal" ? "Terminal" : "Chat" }),
2683
+ /* @__PURE__ */ jsx9("small", { children: item === "terminal" ? "Interactive tmux session" : "Managed here" })
2684
+ ] })
2685
+ ] }, item))
2686
+ ] }) : null,
2652
2687
  /* @__PURE__ */ jsx9(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
2653
2688
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2654
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2689
+ remember({ images: next });
2655
2690
  return next;
2656
2691
  }) }),
2657
2692
  /* @__PURE__ */ jsx9(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
2658
2693
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2659
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2694
+ remember({ context: next });
2660
2695
  return next;
2661
2696
  }) }),
2662
- pickerError ? /* @__PURE__ */ jsx9("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
2697
+ terminalAttachments ? /* @__PURE__ */ jsx9("small", { class: "scui-context-error", role: "alert", children: "Terminal starts currently accept text only. Remove attachments or choose Chat." }) : pickerError ? /* @__PURE__ */ jsx9("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
2663
2698
  /* @__PURE__ */ jsxs8("div", { class: `scui-envelope${dragging ? " scui-drop-target" : ""}`, onDragEnter: (event) => {
2664
2699
  if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
2665
2700
  event.preventDefault();
@@ -2670,18 +2705,18 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2670
2705
  }, onDragLeave: (event) => {
2671
2706
  if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false);
2672
2707
  }, onDrop: dropImages, children: [
2673
- adapter.pickContext ? /* @__PURE__ */ jsx9("button", { class: "scui-attach", type: "button", "aria-label": "Attach files or images", disabled: picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS || Boolean(starting), onClick: pickContext, children: picking ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "attach", size: 17 }) }) : null,
2708
+ adapter.pickContext ? /* @__PURE__ */ jsx9("button", { class: "scui-attach", type: "button", "aria-label": "Attach files or images", disabled: mode === "terminal" || picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS || Boolean(starting), onClick: pickContext, children: picking ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "attach", size: 17 }) }) : null,
2674
2709
  /* @__PURE__ */ jsx9("textarea", { ref: textarea, rows: 3, "aria-label": "Message coding agent", placeholder: startable.length ? "What should the agent do?" : "No coding harness is available", value: draft, disabled: !startable.length || Boolean(starting), onPaste: pasteImages, onInput: (event) => {
2675
2710
  const value = event.currentTarget.value;
2676
2711
  setDraft(value);
2677
- boundedSet(newChatMemory, memoryKey, { harness, draft: value, context, images });
2712
+ remember({ draft: value });
2678
2713
  }, onKeyDown: (event) => {
2679
2714
  if (isSendKey(event)) {
2680
2715
  event.preventDefault();
2681
2716
  send();
2682
2717
  }
2683
2718
  } }),
2684
- /* @__PURE__ */ jsx9("span", { children: /* @__PURE__ */ jsx9("button", { type: "button", class: "scui-send", "aria-label": "Start chat", disabled: !draft.trim() && !images.length || !harness || Boolean(starting), onClick: send, children: starting ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "send", size: 17 }) }) })
2719
+ /* @__PURE__ */ jsx9("span", { children: /* @__PURE__ */ jsx9("button", { type: "button", class: "scui-send", "aria-label": mode === "terminal" ? "Start terminal session" : "Start chat", disabled: !draft.trim() && !images.length || !harness || Boolean(starting) || terminalAttachments || mode === "terminal" && !draft.trim(), onClick: send, children: starting ? /* @__PURE__ */ jsx9("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx9(UiIcon, { name: "send", size: 17 }) }) })
2685
2720
  ] })
2686
2721
  ] })
2687
2722
  ] });
@@ -2718,7 +2753,7 @@ function SupercodeMessenger({ state: stateInput, adapter, class: className = "",
2718
2753
  setListFocus("@new");
2719
2754
  setView("new");
2720
2755
  }, onClose: adapter.onClose ? close : void 0, components, labels: copy, memoryKey }) : null,
2721
- view === "new" ? /* @__PURE__ */ jsx9(NewChat, { state, adapter, onBack: () => setView("list"), onClose: adapter.onClose ? close : void 0, onStarted: () => setView("chat"), labels: copy, memoryKey }) : null,
2756
+ view === "new" ? /* @__PURE__ */ jsx9(NewChat, { state, adapter, onBack: () => setView("list"), onClose: adapter.onClose ? close : void 0, onStarted: (mode) => setView(mode === "terminal" ? "list" : "chat"), labels: copy, memoryKey }) : null,
2722
2757
  view === "chat" ? /* @__PURE__ */ jsx9(Chat, { state, adapter, onBack: () => {
2723
2758
  setListFocus(state.attached?.key ?? listFocus);
2724
2759
  setView("list");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter-ai-dev/supercode-ui",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "description": "Composable default UI kit for Supercode-powered coding-agent experiences",
6
6
  "exports": {
package/styles.css CHANGED
@@ -191,6 +191,7 @@
191
191
  .scui-send,.scui-stop { display:grid; width:29px; height:29px; padding:0; place-items:center; border:0; border-radius:8px; background:var(--scui-accent); color:#fff; cursor:pointer }.scui-stop { background:var(--scui-danger) }.scui-send:disabled,.scui-stop:disabled { opacity:.4; cursor:default }.scui-control-spinner { box-sizing:border-box; width:13px; height:13px; border:1.5px solid currentColor; border-right-color:transparent; border-radius:50%; animation:scui-spin .75s linear infinite }
192
192
  .scui-queue { display:grid; gap:4px; max-height:85px; margin-bottom:6px; overflow:auto; font-size:10.5px }.scui-queue > span { display:flex; gap:6px; padding:4px 6px; border-radius:5px; background:var(--scui-fill) }.scui-queue > span > span { display:grid; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }.scui-queue small { color:var(--scui-muted) }.scui-queue > span button { margin-left:auto; border:0; background:transparent }
193
193
  .scui-harness-picker { display:flex; align-items:center; gap:7px; margin-bottom:7px }.scui-harness-picker select { margin-left:auto; max-width:55%; padding:4px; border:1px solid var(--scui-border); border-radius:6px; background:var(--scui-bg); color:var(--scui-fg) }
194
+ .scui-launch-modes { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:5px; margin:0 0 7px; padding:0; border:0 }.scui-launch-modes legend { position:absolute; overflow:hidden; width:1px; height:1px; clip:rect(0 0 0 0); white-space:nowrap }.scui-launch-modes label { position:relative; min-width:0; cursor:pointer }.scui-launch-modes input { position:absolute; opacity:0; pointer-events:none }.scui-launch-modes label > span { display:flex; min-height:42px; flex-direction:column; justify-content:center; padding:6px 9px; border:1px solid var(--scui-border); border-radius:7px; background:var(--scui-bg); transition:border-color .14s ease,background .14s ease }.scui-launch-modes strong { font-size:11px; font-weight:650 }.scui-launch-modes small { overflow:hidden; color:var(--scui-muted); font-size:9px; text-overflow:ellipsis; white-space:nowrap }.scui-launch-modes input:checked + span { border-color:color-mix(in srgb,var(--scui-accent) 58%,var(--scui-border)); background:color-mix(in srgb,var(--scui-accent) 8%,var(--scui-bg)) }.scui-launch-modes input:focus-visible + span { outline:2px solid var(--scui-accent); outline-offset:1px }.scui-launch-modes:disabled label { cursor:default; opacity:.62 }
194
195
  .scui-new { display:grid; justify-items:center; gap:5px; margin:auto; padding:20px; color:var(--scui-muted); text-align:center }.scui-new > span { color:var(--scui-accent); font-size:28px }.scui-new strong { color:var(--scui-fg) }
195
196
  .scui-opening { position:absolute; z-index:30; inset:49px 0 0; display:flex; align-items:center; justify-content:center; gap:10px; padding:20px; background:var(--scui-bg) }.scui-opening > span { display:grid }.scui-opening small { color:var(--scui-muted) }.scui-opening > i { width:16px; height:16px; border:2px solid var(--scui-border); border-top-color:var(--scui-accent); border-radius:50%; animation:scui-spin .8s linear infinite }
196
197