@volter-ai-dev/supercode-ui 0.1.31 → 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
@@ -98,6 +98,18 @@ drafts, and artifact materialization remain explicit callbacks. A
98
98
  browser should receive projected state from a trusted host rather than instantiate a local
99
99
  controller or gain filesystem authority.
100
100
 
101
+ Native continuation is headless by default. A host with a real terminal provider can add
102
+ `terminal` to `continuationModes` and handle `onResumeTerminal`; the continuation bar then exposes
103
+ that strategy beside “Continue here.” The UI never infers terminal support from a generic runtime
104
+ handoff, and never presents a terminal strategy when the host cannot create one.
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
+
101
113
  The default controller projection is display-bounded: 120 visible transcript rows, at most 480
102
114
  native entries inspected to fill that tail, 16,000 characters per independent entry field, 100
103
115
  session rows, 20 fidelity-residue details, and 50 subagents. It filters harness-injected context
package/components.mjs CHANGED
@@ -17,6 +17,7 @@ var DEFAULT_LABELS = Object.freeze({
17
17
  searchChats: "Search chats",
18
18
  askAgent: "Ask your agent\u2026",
19
19
  continueHere: "Continue here",
20
+ continueWithTerminal: "Continue with terminal",
20
21
  joinLive: "Join live",
21
22
  forkHere: "Fork here"
22
23
  });
@@ -32,6 +33,7 @@ var EMPTY_UI_STATE = Object.freeze({
32
33
  strategy: null,
33
34
  canSend: false,
34
35
  canResume: false,
36
+ continuationModes: Object.freeze([]),
35
37
  canBranch: false,
36
38
  canAttach: false,
37
39
  canDetach: false,
@@ -704,6 +706,8 @@ function normalizeUiState(value) {
704
706
  const pill = record(raw.pill);
705
707
  const history = record(raw.history);
706
708
  const attachError = record(raw.attachError);
709
+ const canResume = raw.canResume === true;
710
+ const continuationModes = Array.isArray(raw.continuationModes) ? [...new Set(raw.continuationModes.filter((mode) => mode === "headless" || mode === "terminal"))] : canResume ? ["headless"] : [];
707
711
  return {
708
712
  pill: { tone: ["live", "warn", "dead"].includes(pill?.tone) ? pill.tone : "off", label: string(pill?.label, "connecting\u2026") },
709
713
  startup: STARTUP.has(raw.startup) ? raw.startup : "connecting",
@@ -715,7 +719,8 @@ function normalizeUiState(value) {
715
719
  mode: MODES.has(raw.mode) ? raw.mode : "none",
716
720
  strategy: STRATEGIES.has(raw.strategy) ? raw.strategy : null,
717
721
  canSend: raw.canSend === true,
718
- canResume: raw.canResume === true,
722
+ canResume,
723
+ continuationModes,
719
724
  canBranch: raw.canBranch === true,
720
725
  canAttach: raw.canAttach === true,
721
726
  canDetach: raw.canDetach === true,
@@ -739,7 +744,11 @@ function normalizeUiState(value) {
739
744
  recoverable: raw.recoverable === true,
740
745
  harnesses: Array.isArray(raw.harnesses) ? raw.harnesses.flatMap((candidate) => {
741
746
  const item = record(candidate);
742
- 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 }];
743
752
  }) : [],
744
753
  history: { sessionLimit: number(history?.sessionLimit), hasMoreSessions: history?.hasMoreSessions === true, transcriptLimit: number(history?.transcriptLimit, 120), hasEarlier: history?.hasEarlier === true },
745
754
  savedDraft: string(raw.savedDraft),
@@ -763,9 +772,9 @@ function sessionDisplayName(session) {
763
772
  function sessionActivity(state, row) {
764
773
  if (state.needsInput && row.active) return "needs-input";
765
774
  if (state.busy && row.active) return "working";
775
+ if (row.runtimeStatus === "busy") return "working";
766
776
  const attention = state.attention.find((item) => item.key === row.key)?.kind;
767
777
  if (attention) return attention;
768
- if (row.runtimeStatus === "busy") return "working";
769
778
  if (row.runtimeStatus === "running") return "running";
770
779
  if (row.live || row.runtimeStatus === "idle") return "recent";
771
780
  return "idle";
@@ -1173,6 +1182,7 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
1173
1182
  const row = attached?.key ? state.sessions.find((item) => item.key === attached.key) : null;
1174
1183
  const activeElsewhere = row?.runtimeStatus === "running" || row?.runtimeStatus === "busy" || row?.runtimeStatus === "idle";
1175
1184
  const resume = canContinueHere(state);
1185
+ const terminal = resume && state.continuationModes?.includes("terminal");
1176
1186
  const join = state.canAttach;
1177
1187
  const branch = state.canBranch;
1178
1188
  if (!resume && !join && !branch) return /* @__PURE__ */ jsx3("div", { class: "scui-continuation", children: /* @__PURE__ */ jsxs3("span", { children: [
@@ -1184,7 +1194,10 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
1184
1194
  /* @__PURE__ */ jsx3("strong", { children: activeElsewhere ? "Active elsewhere" : "Read-only" }),
1185
1195
  /* @__PURE__ */ jsx3("small", { children: join ? "Join the proven live runtime without taking it over." : resume ? `Resume this ${harnessDisplayName(state.harness)} session here.` : "Start an independent continuation." })
1186
1196
  ] }),
1187
- /* @__PURE__ */ jsx3("button", { type: "button", disabled: Boolean(state.operation), onClick: () => adapter.onIntent(join ? { action: "join" } : resume ? { action: "resume" } : { action: "branch" }), children: join ? labels.joinLive : resume ? labels.continueHere : labels.forkHere })
1197
+ /* @__PURE__ */ jsxs3("span", { class: "scui-continuation-actions", children: [
1198
+ /* @__PURE__ */ jsx3("button", { type: "button", disabled: Boolean(state.operation), onClick: () => adapter.onIntent(join ? { action: "join" } : resume ? { action: "resume", mode: "headless" } : { action: "branch" }), children: join ? labels.joinLive : resume ? labels.continueHere : labels.forkHere }),
1199
+ terminal ? /* @__PURE__ */ jsx3("button", { type: "button", class: "scui-secondary", disabled: Boolean(state.operation), onClick: () => adapter.onIntent({ action: "resume", mode: "terminal" }), children: labels.continueWithTerminal ?? DEFAULT_LABELS.continueWithTerminal }) : null
1200
+ ] })
1188
1201
  ] });
1189
1202
  }
1190
1203
  function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.attached?.key || `${state.harness}:${state.workspace}`, pendingStatus = null, restoreDraft = null, onPending, onDraftRestored }) {
@@ -2541,32 +2554,39 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
2541
2554
  }
2542
2555
  function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey }) {
2543
2556
  const startable = state.harnesses.filter((item) => item.startable);
2544
- const startableKey = startable.map((item) => item.id).join("\0");
2545
- 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: {} };
2546
2559
  const [harness, setHarness] = useState6(remembered.harness);
2547
2560
  const [draft, setDraft] = useState6(remembered.draft);
2548
2561
  const [context, setContext] = useState6(remembered.context);
2549
2562
  const [images, setImages] = useState6(remembered.images ?? []);
2563
+ const [modes, setModes] = useState6(remembered.modes ?? {});
2550
2564
  const [starting, setStarting] = useState6(null);
2551
2565
  const [picking, setPicking] = useState6(false);
2552
2566
  const [dragging, setDragging] = useState6(false);
2553
2567
  const [pickerError, setPickerError] = useState6(null);
2554
2568
  const startSequence = useRef7(0);
2555
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 });
2556
2576
  useAutosizeTextarea(textarea, draft);
2557
2577
  useEffect8(() => {
2558
2578
  if (startable.some((item) => item.id === harness)) return;
2559
2579
  const next = startable[0]?.id ?? "";
2560
2580
  setHarness(next);
2561
- boundedSet(newChatMemory, memoryKey, { harness: next, draft, context, images });
2581
+ remember({ harness: next });
2562
2582
  }, [harness, startableKey]);
2563
2583
  useEffect8(() => {
2564
- if (!starting) return;
2584
+ if (!starting || starting.mode === "terminal") return;
2565
2585
  const sessionChanged = (state.attached?.key ?? null) !== starting.attachedKey;
2566
2586
  const beganWorking = !starting.busy && state.busy;
2567
2587
  if (!sessionChanged && !beganWorking) return;
2568
2588
  newChatMemory.delete(memoryKey);
2569
- onStarted();
2589
+ onStarted("headless");
2570
2590
  }, [memoryKey, onStarted, starting, state.attached?.key, state.busy]);
2571
2591
  useEffect8(() => {
2572
2592
  if (starting && state.error !== starting.initialError && !state.busy && !state.operation) setStarting(null);
@@ -2575,7 +2595,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2575
2595
  textarea.current?.focus({ preventScroll: true });
2576
2596
  }, []);
2577
2597
  const pickContext = () => {
2578
- 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;
2579
2599
  setPicking(true);
2580
2600
  setPickerError(null);
2581
2601
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
@@ -2584,12 +2604,12 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2584
2604
  if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
2585
2605
  setContext((current) => {
2586
2606
  const next = mergeContext(current, attachments.context);
2587
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2607
+ remember({ context: next });
2588
2608
  return next;
2589
2609
  });
2590
2610
  setImages((current) => {
2591
2611
  const next = mergeImages(current, attachments.images);
2592
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2612
+ remember({ images: next });
2593
2613
  return next;
2594
2614
  });
2595
2615
  }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
@@ -2597,6 +2617,10 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2597
2617
  const addImageFiles = (value, source) => {
2598
2618
  const allFiles = Array.from(value ?? []);
2599
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
+ }
2600
2624
  const files = allFiles.filter((file) => file.type.startsWith("image/"));
2601
2625
  if (files.length !== allFiles.length) {
2602
2626
  setPickerError("Drop or paste PNG, JPEG, GIF, or WebP images.");
@@ -2610,7 +2634,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2610
2634
  setPickerError(null);
2611
2635
  imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
2612
2636
  const next = mergeImages(current, picked);
2613
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2637
+ remember({ images: next });
2614
2638
  return next;
2615
2639
  }), (error) => setPickerError(error instanceof Error ? error.message : `Could not ${source} image.`)).finally(() => setPicking(false));
2616
2640
  return true;
@@ -2624,12 +2648,17 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2624
2648
  };
2625
2649
  const send = () => {
2626
2650
  const text = draft.trim();
2627
- if (!text && !images.length || !harness || starting) return;
2651
+ if (!text && !images.length || !harness || starting || terminalAttachments || mode === "terminal" && !text) return;
2628
2652
  const id = startSequence.current + 1;
2629
2653
  startSequence.current = id;
2630
- setStarting({ id, attachedKey: state.attached?.key ?? null, busy: state.busy, initialError: state.error });
2631
- const result = adapter.onIntent({ action: "new", harness, text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
2632
- 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") {
2633
2662
  Promise.resolve(result).catch(() => setStarting((current) => current?.id === id ? null : current));
2634
2663
  }
2635
2664
  };
@@ -2644,7 +2673,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2644
2673
  ] }),
2645
2674
  starting ? /* @__PURE__ */ jsxs8("div", { class: "scui-operation", role: "status", children: [
2646
2675
  /* @__PURE__ */ jsx9("i", {}),
2647
- operationLabel("start")
2676
+ mode === "terminal" ? "Opening terminal\u2026" : operationLabel("start")
2648
2677
  ] }) : null,
2649
2678
  /* @__PURE__ */ jsxs8("div", { class: "scui-new", children: [
2650
2679
  /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2726" }),
@@ -2658,23 +2687,38 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2658
2687
  /* @__PURE__ */ jsx9("select", { value: harness, disabled: Boolean(starting), onChange: (event) => {
2659
2688
  const value = event.currentTarget.value;
2660
2689
  setHarness(value);
2661
- boundedSet(newChatMemory, memoryKey, { harness: value, draft, context, images });
2690
+ remember({ harness: value });
2662
2691
  }, children: state.harnesses.map((item) => /* @__PURE__ */ jsxs8("option", { value: item.id, disabled: !item.startable, children: [
2663
2692
  item.label,
2664
2693
  item.startable ? "" : " \xB7 unavailable"
2665
2694
  ] }, item.id)) })
2666
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,
2667
2711
  /* @__PURE__ */ jsx9(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
2668
2712
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2669
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2713
+ remember({ images: next });
2670
2714
  return next;
2671
2715
  }) }),
2672
2716
  /* @__PURE__ */ jsx9(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
2673
2717
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2674
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2718
+ remember({ context: next });
2675
2719
  return next;
2676
2720
  }) }),
2677
- 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,
2678
2722
  /* @__PURE__ */ jsxs8("div", { class: `scui-envelope${dragging ? " scui-drop-target" : ""}`, onDragEnter: (event) => {
2679
2723
  if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
2680
2724
  event.preventDefault();
@@ -2685,18 +2729,18 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2685
2729
  }, onDragLeave: (event) => {
2686
2730
  if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false);
2687
2731
  }, onDrop: dropImages, children: [
2688
- 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,
2689
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) => {
2690
2734
  const value = event.currentTarget.value;
2691
2735
  setDraft(value);
2692
- boundedSet(newChatMemory, memoryKey, { harness, draft: value, context, images });
2736
+ remember({ draft: value });
2693
2737
  }, onKeyDown: (event) => {
2694
2738
  if (isSendKey(event)) {
2695
2739
  event.preventDefault();
2696
2740
  send();
2697
2741
  }
2698
2742
  } }),
2699
- /* @__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 }) }) })
2700
2744
  ] })
2701
2745
  ] })
2702
2746
  ] });
@@ -2733,7 +2777,7 @@ function SupercodeMessenger({ state: stateInput, adapter, class: className = "",
2733
2777
  setListFocus("@new");
2734
2778
  setView("new");
2735
2779
  }, onClose: adapter.onClose ? close : void 0, components, labels: copy, memoryKey }) : null,
2736
- 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,
2737
2781
  view === "chat" ? /* @__PURE__ */ jsx9(Chat, { state, adapter, onBack: () => {
2738
2782
  setListFocus(state.attached?.key ?? listFocus);
2739
2783
  setView("list");
package/composer.mjs CHANGED
@@ -17,6 +17,7 @@ var DEFAULT_LABELS = Object.freeze({
17
17
  searchChats: "Search chats",
18
18
  askAgent: "Ask your agent\u2026",
19
19
  continueHere: "Continue here",
20
+ continueWithTerminal: "Continue with terminal",
20
21
  joinLive: "Join live",
21
22
  forkHere: "Fork here"
22
23
  });
@@ -32,6 +33,7 @@ var EMPTY_UI_STATE = Object.freeze({
32
33
  strategy: null,
33
34
  canSend: false,
34
35
  canResume: false,
36
+ continuationModes: Object.freeze([]),
35
37
  canBranch: false,
36
38
  canAttach: false,
37
39
  canDetach: false,
@@ -257,6 +259,7 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
257
259
  const row = attached?.key ? state.sessions.find((item) => item.key === attached.key) : null;
258
260
  const activeElsewhere = row?.runtimeStatus === "running" || row?.runtimeStatus === "busy" || row?.runtimeStatus === "idle";
259
261
  const resume = canContinueHere(state);
262
+ const terminal = resume && state.continuationModes?.includes("terminal");
260
263
  const join = state.canAttach;
261
264
  const branch = state.canBranch;
262
265
  if (!resume && !join && !branch) return /* @__PURE__ */ jsx3("div", { class: "scui-continuation", children: /* @__PURE__ */ jsxs3("span", { children: [
@@ -268,7 +271,10 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
268
271
  /* @__PURE__ */ jsx3("strong", { children: activeElsewhere ? "Active elsewhere" : "Read-only" }),
269
272
  /* @__PURE__ */ jsx3("small", { children: join ? "Join the proven live runtime without taking it over." : resume ? `Resume this ${harnessDisplayName(state.harness)} session here.` : "Start an independent continuation." })
270
273
  ] }),
271
- /* @__PURE__ */ jsx3("button", { type: "button", disabled: Boolean(state.operation), onClick: () => adapter.onIntent(join ? { action: "join" } : resume ? { action: "resume" } : { action: "branch" }), children: join ? labels.joinLive : resume ? labels.continueHere : labels.forkHere })
274
+ /* @__PURE__ */ jsxs3("span", { class: "scui-continuation-actions", children: [
275
+ /* @__PURE__ */ jsx3("button", { type: "button", disabled: Boolean(state.operation), onClick: () => adapter.onIntent(join ? { action: "join" } : resume ? { action: "resume", mode: "headless" } : { action: "branch" }), children: join ? labels.joinLive : resume ? labels.continueHere : labels.forkHere }),
276
+ terminal ? /* @__PURE__ */ jsx3("button", { type: "button", class: "scui-secondary", disabled: Boolean(state.operation), onClick: () => adapter.onIntent({ action: "resume", mode: "terminal" }), children: labels.continueWithTerminal ?? DEFAULT_LABELS.continueWithTerminal }) : null
277
+ ] })
272
278
  ] });
273
279
  }
274
280
  function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.attached?.key || `${state.harness}:${state.workspace}`, pendingStatus = null, restoreDraft = null, onPending, onDraftRestored }) {
package/controller.d.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  } from '@volter-ai-dev/supercode-client';
9
9
  import type {
10
10
  AttachedSessionModel,
11
+ ContinuationMode,
11
12
  SessionAttention,
12
13
  SessionRowModel,
13
14
  StartupPhase,
@@ -40,6 +41,8 @@ export interface ClientProjectionOptions {
40
41
  exportBackTarget?: SessionFormat | null;
41
42
  exportReceipt?: SupercodeUiState['exportReceipt'];
42
43
  reductionReceipt?: SupercodeUiState['reductionReceipt'];
44
+ /** Host-provided execution strategies. A plain controller projects headless resume only. */
45
+ continuationModes?: ContinuationMode[];
43
46
  /** Trusted-host inventory and lifecycle overlays (for example a machine-wide session catalog). */
44
47
  sessions?: SessionRowModel[];
45
48
  attached?: AttachedSessionModel | null;
@@ -81,6 +84,8 @@ export interface ControllerBindingOptions {
81
84
  onAcknowledge?: (key: string) => void | Promise<void>;
82
85
  onLoadSessions?: () => void | Promise<void>;
83
86
  onLoadEarlier?: () => void | Promise<void>;
87
+ onStartTerminal?: (intent: Extract<SupercodeUiIntent, { action: 'new' }>) => void | Promise<void>;
88
+ onResumeTerminal?: (intent: Extract<SupercodeUiIntent, { action: 'resume' }>) => void | Promise<void>;
84
89
  copyText?: UiAdapter['copyText'];
85
90
  resolveImage?: UiAdapter['resolveImage'];
86
91
  }
package/controller.mjs CHANGED
@@ -366,6 +366,7 @@ function projectClientSnapshotInternal(snapshot, options, imageRegistry) {
366
366
  strategy: snapshot.connection?.strategy ?? null,
367
367
  canSend: actions.send === true,
368
368
  canResume: actions.resume === true,
369
+ continuationModes: options.continuationModes ?? (actions.resume === true ? ['headless'] : []),
369
370
  canBranch: actions.branch === true,
370
371
  canAttach: actions.attach === true,
371
372
  canDetach: actions.detach === true,
@@ -446,10 +447,22 @@ async function dispatchStandard(controller, intent, options) {
446
447
  if (intent.action === 'attach') return controller.dispatch({ type: 'observe', sessionKey: intent.key });
447
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 } : {}) });
448
449
  if (intent.action === 'new') {
450
+ if (intent.mode === 'terminal') {
451
+ return options.onStartTerminal
452
+ ? options.onStartTerminal(intent)
453
+ : options.onUnsupported?.(intent);
454
+ }
449
455
  await controller.dispatch({ type: 'start', harness: intent.harness });
450
456
  return controller.dispatch({ type: 'send', text: intent.text, ...(intent.context?.length ? { context: intent.context } : {}), ...(intent.images?.length ? { images: intent.images } : {}) });
451
457
  }
452
- if (intent.action === 'resume' && active) return controller.dispatch({ type: 'resume', sessionKey: active });
458
+ if (intent.action === 'resume' && active) {
459
+ if (intent.mode === 'terminal') {
460
+ return options.onResumeTerminal
461
+ ? options.onResumeTerminal(intent)
462
+ : options.onUnsupported?.(intent);
463
+ }
464
+ return controller.dispatch({ type: 'resume', sessionKey: active });
465
+ }
453
466
  if (intent.action === 'join' && active) return controller.dispatch({ type: 'attach', sessionKey: active });
454
467
  if (intent.action === 'detach') return controller.dispatch({ type: 'detach' });
455
468
  if (intent.action === 'branch' && active) return controller.dispatch({ type: 'branch', sessionKey: active, ...(intent.targetHarness ? { targetHarness: intent.targetHarness } : {}) });
package/conversation.mjs CHANGED
@@ -18,6 +18,7 @@ var DEFAULT_LABELS = Object.freeze({
18
18
  searchChats: "Search chats",
19
19
  askAgent: "Ask your agent\u2026",
20
20
  continueHere: "Continue here",
21
+ continueWithTerminal: "Continue with terminal",
21
22
  joinLive: "Join live",
22
23
  forkHere: "Fork here"
23
24
  });
@@ -33,6 +34,7 @@ var EMPTY_UI_STATE = Object.freeze({
33
34
  strategy: null,
34
35
  canSend: false,
35
36
  canResume: false,
37
+ continuationModes: Object.freeze([]),
36
38
  canBranch: false,
37
39
  canAttach: false,
38
40
  canDetach: false,
package/core.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type {
2
2
  AttachedSessionModel,
3
+ ContinuationMode,
3
4
  ControlStrategy,
4
5
  HarnessId,
5
6
  HarnessOption,
package/core.mjs CHANGED
@@ -14,6 +14,7 @@ export const DEFAULT_LABELS = Object.freeze({
14
14
  searchChats: 'Search chats',
15
15
  askAgent: 'Ask your agent…',
16
16
  continueHere: 'Continue here',
17
+ continueWithTerminal: 'Continue with terminal',
17
18
  joinLive: 'Join live',
18
19
  forkHere: 'Fork here',
19
20
  });
@@ -30,6 +31,7 @@ export const EMPTY_UI_STATE = Object.freeze({
30
31
  strategy: null,
31
32
  canSend: false,
32
33
  canResume: false,
34
+ continuationModes: Object.freeze([]),
33
35
  canBranch: false,
34
36
  canAttach: false,
35
37
  canDetach: false,
@@ -754,6 +756,10 @@ export function normalizeUiState(value) {
754
756
  const pill = record(raw.pill);
755
757
  const history = record(raw.history);
756
758
  const attachError = record(raw.attachError);
759
+ const canResume = raw.canResume === true;
760
+ const continuationModes = Array.isArray(raw.continuationModes)
761
+ ? [...new Set(raw.continuationModes.filter((mode) => mode === 'headless' || mode === 'terminal'))]
762
+ : canResume ? ['headless'] : [];
757
763
  return {
758
764
  pill: { tone: ['live', 'warn', 'dead'].includes(pill?.tone) ? pill.tone : 'off', label: string(pill?.label, 'connecting…') },
759
765
  startup: STARTUP.has(raw.startup) ? raw.startup : 'connecting',
@@ -765,7 +771,8 @@ export function normalizeUiState(value) {
765
771
  mode: MODES.has(raw.mode) ? raw.mode : 'none',
766
772
  strategy: STRATEGIES.has(raw.strategy) ? raw.strategy : null,
767
773
  canSend: raw.canSend === true,
768
- canResume: raw.canResume === true,
774
+ canResume,
775
+ continuationModes,
769
776
  canBranch: raw.canBranch === true,
770
777
  canAttach: raw.canAttach === true,
771
778
  canDetach: raw.canDetach === true,
@@ -789,7 +796,15 @@ export function normalizeUiState(value) {
789
796
  recoverable: raw.recoverable === true,
790
797
  harnesses: Array.isArray(raw.harnesses) ? raw.harnesses.flatMap((candidate) => {
791
798
  const item = record(candidate);
792
- 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 }];
793
808
  }) : [],
794
809
  history: { sessionLimit: number(history?.sessionLimit), hasMoreSessions: history?.hasMoreSessions === true, transcriptLimit: number(history?.transcriptLimit, 120), hasEarlier: history?.hasEarlier === true },
795
810
  savedDraft: string(raw.savedDraft),
@@ -818,9 +833,11 @@ export function sessionDisplayName(session) {
818
833
  export function sessionActivity(state, row) {
819
834
  if (state.needsInput && row.active) return 'needs-input';
820
835
  if (state.busy && row.active) return 'working';
836
+ // Unread/finished attention has its own badge. It must not suppress the
837
+ // transient working indicator when the harness proves this row is busy.
838
+ if (row.runtimeStatus === 'busy') return 'working';
821
839
  const attention = state.attention.find((item) => item.key === row.key)?.kind;
822
840
  if (attention) return attention;
823
- if (row.runtimeStatus === 'busy') return 'working';
824
841
  if (row.runtimeStatus === 'running') return 'running';
825
842
  if (row.live || row.runtimeStatus === 'idle') return 'recent';
826
843
  return 'idle';
package/embed.mjs CHANGED
@@ -20,6 +20,7 @@ var DEFAULT_LABELS = Object.freeze({
20
20
  searchChats: "Search chats",
21
21
  askAgent: "Ask your agent\u2026",
22
22
  continueHere: "Continue here",
23
+ continueWithTerminal: "Continue with terminal",
23
24
  joinLive: "Join live",
24
25
  forkHere: "Fork here"
25
26
  });
@@ -35,6 +36,7 @@ var EMPTY_UI_STATE = Object.freeze({
35
36
  strategy: null,
36
37
  canSend: false,
37
38
  canResume: false,
39
+ continuationModes: Object.freeze([]),
38
40
  canBranch: false,
39
41
  canAttach: false,
40
42
  canDetach: false,
@@ -707,6 +709,8 @@ function normalizeUiState(value) {
707
709
  const pill = record(raw.pill);
708
710
  const history = record(raw.history);
709
711
  const attachError = record(raw.attachError);
712
+ const canResume = raw.canResume === true;
713
+ const continuationModes = Array.isArray(raw.continuationModes) ? [...new Set(raw.continuationModes.filter((mode) => mode === "headless" || mode === "terminal"))] : canResume ? ["headless"] : [];
710
714
  return {
711
715
  pill: { tone: ["live", "warn", "dead"].includes(pill?.tone) ? pill.tone : "off", label: string(pill?.label, "connecting\u2026") },
712
716
  startup: STARTUP.has(raw.startup) ? raw.startup : "connecting",
@@ -718,7 +722,8 @@ function normalizeUiState(value) {
718
722
  mode: MODES.has(raw.mode) ? raw.mode : "none",
719
723
  strategy: STRATEGIES.has(raw.strategy) ? raw.strategy : null,
720
724
  canSend: raw.canSend === true,
721
- canResume: raw.canResume === true,
725
+ canResume,
726
+ continuationModes,
722
727
  canBranch: raw.canBranch === true,
723
728
  canAttach: raw.canAttach === true,
724
729
  canDetach: raw.canDetach === true,
@@ -742,7 +747,11 @@ function normalizeUiState(value) {
742
747
  recoverable: raw.recoverable === true,
743
748
  harnesses: Array.isArray(raw.harnesses) ? raw.harnesses.flatMap((candidate) => {
744
749
  const item = record(candidate);
745
- 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 }];
746
755
  }) : [],
747
756
  history: { sessionLimit: number(history?.sessionLimit), hasMoreSessions: history?.hasMoreSessions === true, transcriptLimit: number(history?.transcriptLimit, 120), hasEarlier: history?.hasEarlier === true },
748
757
  savedDraft: string(raw.savedDraft),
@@ -766,9 +775,9 @@ function sessionDisplayName(session) {
766
775
  function sessionActivity(state, row) {
767
776
  if (state.needsInput && row.active) return "needs-input";
768
777
  if (state.busy && row.active) return "working";
778
+ if (row.runtimeStatus === "busy") return "working";
769
779
  const attention = state.attention.find((item) => item.key === row.key)?.kind;
770
780
  if (attention) return attention;
771
- if (row.runtimeStatus === "busy") return "working";
772
781
  if (row.runtimeStatus === "running") return "running";
773
782
  if (row.live || row.runtimeStatus === "idle") return "recent";
774
783
  return "idle";
@@ -1179,6 +1188,7 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
1179
1188
  const row = attached?.key ? state.sessions.find((item) => item.key === attached.key) : null;
1180
1189
  const activeElsewhere = row?.runtimeStatus === "running" || row?.runtimeStatus === "busy" || row?.runtimeStatus === "idle";
1181
1190
  const resume = canContinueHere(state);
1191
+ const terminal = resume && state.continuationModes?.includes("terminal");
1182
1192
  const join = state.canAttach;
1183
1193
  const branch = state.canBranch;
1184
1194
  if (!resume && !join && !branch) return /* @__PURE__ */ jsx3("div", { class: "scui-continuation", children: /* @__PURE__ */ jsxs3("span", { children: [
@@ -1190,7 +1200,10 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
1190
1200
  /* @__PURE__ */ jsx3("strong", { children: activeElsewhere ? "Active elsewhere" : "Read-only" }),
1191
1201
  /* @__PURE__ */ jsx3("small", { children: join ? "Join the proven live runtime without taking it over." : resume ? `Resume this ${harnessDisplayName(state.harness)} session here.` : "Start an independent continuation." })
1192
1202
  ] }),
1193
- /* @__PURE__ */ jsx3("button", { type: "button", disabled: Boolean(state.operation), onClick: () => adapter.onIntent(join ? { action: "join" } : resume ? { action: "resume" } : { action: "branch" }), children: join ? labels.joinLive : resume ? labels.continueHere : labels.forkHere })
1203
+ /* @__PURE__ */ jsxs3("span", { class: "scui-continuation-actions", children: [
1204
+ /* @__PURE__ */ jsx3("button", { type: "button", disabled: Boolean(state.operation), onClick: () => adapter.onIntent(join ? { action: "join" } : resume ? { action: "resume", mode: "headless" } : { action: "branch" }), children: join ? labels.joinLive : resume ? labels.continueHere : labels.forkHere }),
1205
+ terminal ? /* @__PURE__ */ jsx3("button", { type: "button", class: "scui-secondary", disabled: Boolean(state.operation), onClick: () => adapter.onIntent({ action: "resume", mode: "terminal" }), children: labels.continueWithTerminal ?? DEFAULT_LABELS.continueWithTerminal }) : null
1206
+ ] })
1194
1207
  ] });
1195
1208
  }
1196
1209
  function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.attached?.key || `${state.harness}:${state.workspace}`, pendingStatus = null, restoreDraft = null, onPending, onDraftRestored }) {
@@ -2520,32 +2533,39 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
2520
2533
  }
2521
2534
  function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey }) {
2522
2535
  const startable = state.harnesses.filter((item) => item.startable);
2523
- const startableKey = startable.map((item) => item.id).join("\0");
2524
- 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: {} };
2525
2538
  const [harness, setHarness] = useState6(remembered.harness);
2526
2539
  const [draft, setDraft] = useState6(remembered.draft);
2527
2540
  const [context, setContext] = useState6(remembered.context);
2528
2541
  const [images, setImages] = useState6(remembered.images ?? []);
2542
+ const [modes, setModes] = useState6(remembered.modes ?? {});
2529
2543
  const [starting, setStarting] = useState6(null);
2530
2544
  const [picking, setPicking] = useState6(false);
2531
2545
  const [dragging, setDragging] = useState6(false);
2532
2546
  const [pickerError, setPickerError] = useState6(null);
2533
2547
  const startSequence = useRef7(0);
2534
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 });
2535
2555
  useAutosizeTextarea(textarea, draft);
2536
2556
  useEffect8(() => {
2537
2557
  if (startable.some((item) => item.id === harness)) return;
2538
2558
  const next = startable[0]?.id ?? "";
2539
2559
  setHarness(next);
2540
- boundedSet(newChatMemory, memoryKey, { harness: next, draft, context, images });
2560
+ remember({ harness: next });
2541
2561
  }, [harness, startableKey]);
2542
2562
  useEffect8(() => {
2543
- if (!starting) return;
2563
+ if (!starting || starting.mode === "terminal") return;
2544
2564
  const sessionChanged = (state.attached?.key ?? null) !== starting.attachedKey;
2545
2565
  const beganWorking = !starting.busy && state.busy;
2546
2566
  if (!sessionChanged && !beganWorking) return;
2547
2567
  newChatMemory.delete(memoryKey);
2548
- onStarted();
2568
+ onStarted("headless");
2549
2569
  }, [memoryKey, onStarted, starting, state.attached?.key, state.busy]);
2550
2570
  useEffect8(() => {
2551
2571
  if (starting && state.error !== starting.initialError && !state.busy && !state.operation) setStarting(null);
@@ -2554,7 +2574,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2554
2574
  textarea.current?.focus({ preventScroll: true });
2555
2575
  }, []);
2556
2576
  const pickContext = () => {
2557
- 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;
2558
2578
  setPicking(true);
2559
2579
  setPickerError(null);
2560
2580
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
@@ -2563,12 +2583,12 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2563
2583
  if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
2564
2584
  setContext((current) => {
2565
2585
  const next = mergeContext(current, attachments.context);
2566
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2586
+ remember({ context: next });
2567
2587
  return next;
2568
2588
  });
2569
2589
  setImages((current) => {
2570
2590
  const next = mergeImages(current, attachments.images);
2571
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2591
+ remember({ images: next });
2572
2592
  return next;
2573
2593
  });
2574
2594
  }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
@@ -2576,6 +2596,10 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2576
2596
  const addImageFiles = (value, source) => {
2577
2597
  const allFiles = Array.from(value ?? []);
2578
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
+ }
2579
2603
  const files = allFiles.filter((file) => file.type.startsWith("image/"));
2580
2604
  if (files.length !== allFiles.length) {
2581
2605
  setPickerError("Drop or paste PNG, JPEG, GIF, or WebP images.");
@@ -2589,7 +2613,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2589
2613
  setPickerError(null);
2590
2614
  imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
2591
2615
  const next = mergeImages(current, picked);
2592
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2616
+ remember({ images: next });
2593
2617
  return next;
2594
2618
  }), (error) => setPickerError(error instanceof Error ? error.message : `Could not ${source} image.`)).finally(() => setPicking(false));
2595
2619
  return true;
@@ -2603,12 +2627,17 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2603
2627
  };
2604
2628
  const send = () => {
2605
2629
  const text = draft.trim();
2606
- if (!text && !images.length || !harness || starting) return;
2630
+ if (!text && !images.length || !harness || starting || terminalAttachments || mode === "terminal" && !text) return;
2607
2631
  const id = startSequence.current + 1;
2608
2632
  startSequence.current = id;
2609
- setStarting({ id, attachedKey: state.attached?.key ?? null, busy: state.busy, initialError: state.error });
2610
- const result = adapter.onIntent({ action: "new", harness, text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
2611
- 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") {
2612
2641
  Promise.resolve(result).catch(() => setStarting((current) => current?.id === id ? null : current));
2613
2642
  }
2614
2643
  };
@@ -2623,7 +2652,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2623
2652
  ] }),
2624
2653
  starting ? /* @__PURE__ */ jsxs8("div", { class: "scui-operation", role: "status", children: [
2625
2654
  /* @__PURE__ */ jsx9("i", {}),
2626
- operationLabel("start")
2655
+ mode === "terminal" ? "Opening terminal\u2026" : operationLabel("start")
2627
2656
  ] }) : null,
2628
2657
  /* @__PURE__ */ jsxs8("div", { class: "scui-new", children: [
2629
2658
  /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2726" }),
@@ -2637,23 +2666,38 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2637
2666
  /* @__PURE__ */ jsx9("select", { value: harness, disabled: Boolean(starting), onChange: (event) => {
2638
2667
  const value = event.currentTarget.value;
2639
2668
  setHarness(value);
2640
- boundedSet(newChatMemory, memoryKey, { harness: value, draft, context, images });
2669
+ remember({ harness: value });
2641
2670
  }, children: state.harnesses.map((item) => /* @__PURE__ */ jsxs8("option", { value: item.id, disabled: !item.startable, children: [
2642
2671
  item.label,
2643
2672
  item.startable ? "" : " \xB7 unavailable"
2644
2673
  ] }, item.id)) })
2645
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,
2646
2690
  /* @__PURE__ */ jsx9(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
2647
2691
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2648
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2692
+ remember({ images: next });
2649
2693
  return next;
2650
2694
  }) }),
2651
2695
  /* @__PURE__ */ jsx9(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
2652
2696
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2653
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2697
+ remember({ context: next });
2654
2698
  return next;
2655
2699
  }) }),
2656
- 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,
2657
2701
  /* @__PURE__ */ jsxs8("div", { class: `scui-envelope${dragging ? " scui-drop-target" : ""}`, onDragEnter: (event) => {
2658
2702
  if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
2659
2703
  event.preventDefault();
@@ -2664,18 +2708,18 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2664
2708
  }, onDragLeave: (event) => {
2665
2709
  if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false);
2666
2710
  }, onDrop: dropImages, children: [
2667
- 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,
2668
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) => {
2669
2713
  const value = event.currentTarget.value;
2670
2714
  setDraft(value);
2671
- boundedSet(newChatMemory, memoryKey, { harness, draft: value, context, images });
2715
+ remember({ draft: value });
2672
2716
  }, onKeyDown: (event) => {
2673
2717
  if (isSendKey(event)) {
2674
2718
  event.preventDefault();
2675
2719
  send();
2676
2720
  }
2677
2721
  } }),
2678
- /* @__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 }) }) })
2679
2723
  ] })
2680
2724
  ] })
2681
2725
  ] });
@@ -2712,7 +2756,7 @@ function SupercodeMessenger({ state: stateInput, adapter, class: className = "",
2712
2756
  setListFocus("@new");
2713
2757
  setView("new");
2714
2758
  }, onClose: adapter.onClose ? close : void 0, components, labels: copy, memoryKey }) : null,
2715
- 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,
2716
2760
  view === "chat" ? /* @__PURE__ */ jsx9(Chat, { state, adapter, onBack: () => {
2717
2761
  setListFocus(state.attached?.key ?? listFocus);
2718
2762
  setView("list");
package/index.d.ts CHANGED
@@ -5,6 +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 ExecutionMode = 'headless' | 'terminal';
9
+ /** @deprecated Use ExecutionMode. */
10
+ export type ContinuationMode = ExecutionMode;
8
11
  export type SessionActivity = 'idle' | 'recent' | 'running' | 'working' | 'needs-input' | 'finished' | 'failed' | 'unseen';
9
12
 
10
13
  export interface HarnessOption {
@@ -13,6 +16,10 @@ export interface HarnessOption {
13
16
  installed: boolean;
14
17
  startable: boolean;
15
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;
16
23
  }
17
24
 
18
25
  export interface SessionRowModel {
@@ -234,6 +241,8 @@ export interface SupercodeUiState {
234
241
  strategy: ControlStrategy;
235
242
  canSend: boolean;
236
243
  canResume: boolean;
244
+ /** Execution strategies the current host can actually provide. Headless is the default. */
245
+ continuationModes: ContinuationMode[];
237
246
  canBranch: boolean;
238
247
  canAttach: boolean;
239
248
  canDetach: boolean;
@@ -284,8 +293,8 @@ export type SupercodeUiIntent =
284
293
  | { action: 'loadEarlier' }
285
294
  | { action: 'draft'; text: string }
286
295
  | { action: 'send'; text: string; context?: TranscriptContext[]; images?: Array<TranscriptImage & { url: string }> }
287
- | { action: 'new'; harness: HarnessId; text: string; context?: TranscriptContext[]; images?: Array<TranscriptImage & { url: string }> }
288
- | { action: 'resume' }
296
+ | { action: 'new'; harness: HarnessId; mode?: ExecutionMode; text: string; context?: TranscriptContext[]; images?: Array<TranscriptImage & { url: string }> }
297
+ | { action: 'resume'; mode?: ContinuationMode }
289
298
  | { action: 'join' }
290
299
  | { action: 'detach' }
291
300
  | { action: 'branch'; targetHarness?: HarnessId }
@@ -316,6 +325,7 @@ export interface MessengerLabels {
316
325
  searchChats: string;
317
326
  askAgent: string;
318
327
  continueHere: string;
328
+ continueWithTerminal?: string;
319
329
  joinLive: string;
320
330
  forkHere: string;
321
331
  }
package/messenger.mjs CHANGED
@@ -17,6 +17,7 @@ var DEFAULT_LABELS = Object.freeze({
17
17
  searchChats: "Search chats",
18
18
  askAgent: "Ask your agent\u2026",
19
19
  continueHere: "Continue here",
20
+ continueWithTerminal: "Continue with terminal",
20
21
  joinLive: "Join live",
21
22
  forkHere: "Fork here"
22
23
  });
@@ -32,6 +33,7 @@ var EMPTY_UI_STATE = Object.freeze({
32
33
  strategy: null,
33
34
  canSend: false,
34
35
  canResume: false,
36
+ continuationModes: Object.freeze([]),
35
37
  canBranch: false,
36
38
  canAttach: false,
37
39
  canDetach: false,
@@ -704,6 +706,8 @@ function normalizeUiState(value) {
704
706
  const pill = record(raw.pill);
705
707
  const history = record(raw.history);
706
708
  const attachError = record(raw.attachError);
709
+ const canResume = raw.canResume === true;
710
+ const continuationModes = Array.isArray(raw.continuationModes) ? [...new Set(raw.continuationModes.filter((mode) => mode === "headless" || mode === "terminal"))] : canResume ? ["headless"] : [];
707
711
  return {
708
712
  pill: { tone: ["live", "warn", "dead"].includes(pill?.tone) ? pill.tone : "off", label: string(pill?.label, "connecting\u2026") },
709
713
  startup: STARTUP.has(raw.startup) ? raw.startup : "connecting",
@@ -715,7 +719,8 @@ function normalizeUiState(value) {
715
719
  mode: MODES.has(raw.mode) ? raw.mode : "none",
716
720
  strategy: STRATEGIES.has(raw.strategy) ? raw.strategy : null,
717
721
  canSend: raw.canSend === true,
718
- canResume: raw.canResume === true,
722
+ canResume,
723
+ continuationModes,
719
724
  canBranch: raw.canBranch === true,
720
725
  canAttach: raw.canAttach === true,
721
726
  canDetach: raw.canDetach === true,
@@ -739,7 +744,11 @@ function normalizeUiState(value) {
739
744
  recoverable: raw.recoverable === true,
740
745
  harnesses: Array.isArray(raw.harnesses) ? raw.harnesses.flatMap((candidate) => {
741
746
  const item = record(candidate);
742
- 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 }];
743
752
  }) : [],
744
753
  history: { sessionLimit: number(history?.sessionLimit), hasMoreSessions: history?.hasMoreSessions === true, transcriptLimit: number(history?.transcriptLimit, 120), hasEarlier: history?.hasEarlier === true },
745
754
  savedDraft: string(raw.savedDraft),
@@ -763,9 +772,9 @@ function sessionDisplayName(session) {
763
772
  function sessionActivity(state, row) {
764
773
  if (state.needsInput && row.active) return "needs-input";
765
774
  if (state.busy && row.active) return "working";
775
+ if (row.runtimeStatus === "busy") return "working";
766
776
  const attention = state.attention.find((item) => item.key === row.key)?.kind;
767
777
  if (attention) return attention;
768
- if (row.runtimeStatus === "busy") return "working";
769
778
  if (row.runtimeStatus === "running") return "running";
770
779
  if (row.live || row.runtimeStatus === "idle") return "recent";
771
780
  return "idle";
@@ -1176,6 +1185,7 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
1176
1185
  const row = attached?.key ? state.sessions.find((item) => item.key === attached.key) : null;
1177
1186
  const activeElsewhere = row?.runtimeStatus === "running" || row?.runtimeStatus === "busy" || row?.runtimeStatus === "idle";
1178
1187
  const resume = canContinueHere(state);
1188
+ const terminal = resume && state.continuationModes?.includes("terminal");
1179
1189
  const join = state.canAttach;
1180
1190
  const branch = state.canBranch;
1181
1191
  if (!resume && !join && !branch) return /* @__PURE__ */ jsx3("div", { class: "scui-continuation", children: /* @__PURE__ */ jsxs3("span", { children: [
@@ -1187,7 +1197,10 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
1187
1197
  /* @__PURE__ */ jsx3("strong", { children: activeElsewhere ? "Active elsewhere" : "Read-only" }),
1188
1198
  /* @__PURE__ */ jsx3("small", { children: join ? "Join the proven live runtime without taking it over." : resume ? `Resume this ${harnessDisplayName(state.harness)} session here.` : "Start an independent continuation." })
1189
1199
  ] }),
1190
- /* @__PURE__ */ jsx3("button", { type: "button", disabled: Boolean(state.operation), onClick: () => adapter.onIntent(join ? { action: "join" } : resume ? { action: "resume" } : { action: "branch" }), children: join ? labels.joinLive : resume ? labels.continueHere : labels.forkHere })
1200
+ /* @__PURE__ */ jsxs3("span", { class: "scui-continuation-actions", children: [
1201
+ /* @__PURE__ */ jsx3("button", { type: "button", disabled: Boolean(state.operation), onClick: () => adapter.onIntent(join ? { action: "join" } : resume ? { action: "resume", mode: "headless" } : { action: "branch" }), children: join ? labels.joinLive : resume ? labels.continueHere : labels.forkHere }),
1202
+ terminal ? /* @__PURE__ */ jsx3("button", { type: "button", class: "scui-secondary", disabled: Boolean(state.operation), onClick: () => adapter.onIntent({ action: "resume", mode: "terminal" }), children: labels.continueWithTerminal ?? DEFAULT_LABELS.continueWithTerminal }) : null
1203
+ ] })
1191
1204
  ] });
1192
1205
  }
1193
1206
  function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.attached?.key || `${state.harness}:${state.workspace}`, pendingStatus = null, restoreDraft = null, onPending, onDraftRestored }) {
@@ -2517,32 +2530,39 @@ function Chat({ state, adapter, onBack, onNew, onClose, components, slots, label
2517
2530
  }
2518
2531
  function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey }) {
2519
2532
  const startable = state.harnesses.filter((item) => item.startable);
2520
- const startableKey = startable.map((item) => item.id).join("\0");
2521
- 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: {} };
2522
2535
  const [harness, setHarness] = useState6(remembered.harness);
2523
2536
  const [draft, setDraft] = useState6(remembered.draft);
2524
2537
  const [context, setContext] = useState6(remembered.context);
2525
2538
  const [images, setImages] = useState6(remembered.images ?? []);
2539
+ const [modes, setModes] = useState6(remembered.modes ?? {});
2526
2540
  const [starting, setStarting] = useState6(null);
2527
2541
  const [picking, setPicking] = useState6(false);
2528
2542
  const [dragging, setDragging] = useState6(false);
2529
2543
  const [pickerError, setPickerError] = useState6(null);
2530
2544
  const startSequence = useRef7(0);
2531
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 });
2532
2552
  useAutosizeTextarea(textarea, draft);
2533
2553
  useEffect8(() => {
2534
2554
  if (startable.some((item) => item.id === harness)) return;
2535
2555
  const next = startable[0]?.id ?? "";
2536
2556
  setHarness(next);
2537
- boundedSet(newChatMemory, memoryKey, { harness: next, draft, context, images });
2557
+ remember({ harness: next });
2538
2558
  }, [harness, startableKey]);
2539
2559
  useEffect8(() => {
2540
- if (!starting) return;
2560
+ if (!starting || starting.mode === "terminal") return;
2541
2561
  const sessionChanged = (state.attached?.key ?? null) !== starting.attachedKey;
2542
2562
  const beganWorking = !starting.busy && state.busy;
2543
2563
  if (!sessionChanged && !beganWorking) return;
2544
2564
  newChatMemory.delete(memoryKey);
2545
- onStarted();
2565
+ onStarted("headless");
2546
2566
  }, [memoryKey, onStarted, starting, state.attached?.key, state.busy]);
2547
2567
  useEffect8(() => {
2548
2568
  if (starting && state.error !== starting.initialError && !state.busy && !state.operation) setStarting(null);
@@ -2551,7 +2571,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2551
2571
  textarea.current?.focus({ preventScroll: true });
2552
2572
  }, []);
2553
2573
  const pickContext = () => {
2554
- 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;
2555
2575
  setPicking(true);
2556
2576
  setPickerError(null);
2557
2577
  Promise.resolve().then(() => adapter.pickContext()).then((picked) => {
@@ -2560,12 +2580,12 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2560
2580
  if (images.length + attachments.images.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images.`);
2561
2581
  setContext((current) => {
2562
2582
  const next = mergeContext(current, attachments.context);
2563
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2583
+ remember({ context: next });
2564
2584
  return next;
2565
2585
  });
2566
2586
  setImages((current) => {
2567
2587
  const next = mergeImages(current, attachments.images);
2568
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2588
+ remember({ images: next });
2569
2589
  return next;
2570
2590
  });
2571
2591
  }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
@@ -2573,6 +2593,10 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2573
2593
  const addImageFiles = (value, source) => {
2574
2594
  const allFiles = Array.from(value ?? []);
2575
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
+ }
2576
2600
  const files = allFiles.filter((file) => file.type.startsWith("image/"));
2577
2601
  if (files.length !== allFiles.length) {
2578
2602
  setPickerError("Drop or paste PNG, JPEG, GIF, or WebP images.");
@@ -2586,7 +2610,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2586
2610
  setPickerError(null);
2587
2611
  imageAttachmentsFromFiles(files).then((picked) => setImages((current) => {
2588
2612
  const next = mergeImages(current, picked);
2589
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2613
+ remember({ images: next });
2590
2614
  return next;
2591
2615
  }), (error) => setPickerError(error instanceof Error ? error.message : `Could not ${source} image.`)).finally(() => setPicking(false));
2592
2616
  return true;
@@ -2600,12 +2624,17 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2600
2624
  };
2601
2625
  const send = () => {
2602
2626
  const text = draft.trim();
2603
- if (!text && !images.length || !harness || starting) return;
2627
+ if (!text && !images.length || !harness || starting || terminalAttachments || mode === "terminal" && !text) return;
2604
2628
  const id = startSequence.current + 1;
2605
2629
  startSequence.current = id;
2606
- setStarting({ id, attachedKey: state.attached?.key ?? null, busy: state.busy, initialError: state.error });
2607
- const result = adapter.onIntent({ action: "new", harness, text, ...context.length ? { context } : {}, ...images.length ? { images } : {} });
2608
- 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") {
2609
2638
  Promise.resolve(result).catch(() => setStarting((current) => current?.id === id ? null : current));
2610
2639
  }
2611
2640
  };
@@ -2620,7 +2649,7 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2620
2649
  ] }),
2621
2650
  starting ? /* @__PURE__ */ jsxs8("div", { class: "scui-operation", role: "status", children: [
2622
2651
  /* @__PURE__ */ jsx9("i", {}),
2623
- operationLabel("start")
2652
+ mode === "terminal" ? "Opening terminal\u2026" : operationLabel("start")
2624
2653
  ] }) : null,
2625
2654
  /* @__PURE__ */ jsxs8("div", { class: "scui-new", children: [
2626
2655
  /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2726" }),
@@ -2634,23 +2663,38 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2634
2663
  /* @__PURE__ */ jsx9("select", { value: harness, disabled: Boolean(starting), onChange: (event) => {
2635
2664
  const value = event.currentTarget.value;
2636
2665
  setHarness(value);
2637
- boundedSet(newChatMemory, memoryKey, { harness: value, draft, context, images });
2666
+ remember({ harness: value });
2638
2667
  }, children: state.harnesses.map((item) => /* @__PURE__ */ jsxs8("option", { value: item.id, disabled: !item.startable, children: [
2639
2668
  item.label,
2640
2669
  item.startable ? "" : " \xB7 unavailable"
2641
2670
  ] }, item.id)) })
2642
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,
2643
2687
  /* @__PURE__ */ jsx9(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
2644
2688
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2645
- boundedSet(newChatMemory, memoryKey, { harness, draft, context, images: next });
2689
+ remember({ images: next });
2646
2690
  return next;
2647
2691
  }) }),
2648
2692
  /* @__PURE__ */ jsx9(ContextTray, { items: context, onRemove: (index) => setContext((items) => {
2649
2693
  const next = items.filter((_, itemIndex) => itemIndex !== index);
2650
- boundedSet(newChatMemory, memoryKey, { harness, draft, context: next, images });
2694
+ remember({ context: next });
2651
2695
  return next;
2652
2696
  }) }),
2653
- 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,
2654
2698
  /* @__PURE__ */ jsxs8("div", { class: `scui-envelope${dragging ? " scui-drop-target" : ""}`, onDragEnter: (event) => {
2655
2699
  if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
2656
2700
  event.preventDefault();
@@ -2661,18 +2705,18 @@ function NewChat({ state, adapter, onBack, onClose, onStarted, labels, memoryKey
2661
2705
  }, onDragLeave: (event) => {
2662
2706
  if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false);
2663
2707
  }, onDrop: dropImages, children: [
2664
- 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,
2665
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) => {
2666
2710
  const value = event.currentTarget.value;
2667
2711
  setDraft(value);
2668
- boundedSet(newChatMemory, memoryKey, { harness, draft: value, context, images });
2712
+ remember({ draft: value });
2669
2713
  }, onKeyDown: (event) => {
2670
2714
  if (isSendKey(event)) {
2671
2715
  event.preventDefault();
2672
2716
  send();
2673
2717
  }
2674
2718
  } }),
2675
- /* @__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 }) }) })
2676
2720
  ] })
2677
2721
  ] })
2678
2722
  ] });
@@ -2709,7 +2753,7 @@ function SupercodeMessenger({ state: stateInput, adapter, class: className = "",
2709
2753
  setListFocus("@new");
2710
2754
  setView("new");
2711
2755
  }, onClose: adapter.onClose ? close : void 0, components, labels: copy, memoryKey }) : null,
2712
- 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,
2713
2757
  view === "chat" ? /* @__PURE__ */ jsx9(Chat, { state, adapter, onBack: () => {
2714
2758
  setListFocus(state.attached?.key ?? listFocus);
2715
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.31",
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/sessions.mjs CHANGED
@@ -17,6 +17,7 @@ var DEFAULT_LABELS = Object.freeze({
17
17
  searchChats: "Search chats",
18
18
  askAgent: "Ask your agent\u2026",
19
19
  continueHere: "Continue here",
20
+ continueWithTerminal: "Continue with terminal",
20
21
  joinLive: "Join live",
21
22
  forkHere: "Fork here"
22
23
  });
@@ -32,6 +33,7 @@ var EMPTY_UI_STATE = Object.freeze({
32
33
  strategy: null,
33
34
  canSend: false,
34
35
  canResume: false,
36
+ continuationModes: Object.freeze([]),
35
37
  canBranch: false,
36
38
  canAttach: false,
37
39
  canDetach: false,
@@ -81,9 +83,9 @@ function sessionDisplayName(session) {
81
83
  function sessionActivity(state, row) {
82
84
  if (state.needsInput && row.active) return "needs-input";
83
85
  if (state.busy && row.active) return "working";
86
+ if (row.runtimeStatus === "busy") return "working";
84
87
  const attention = state.attention.find((item) => item.key === row.key)?.kind;
85
88
  if (attention) return attention;
86
- if (row.runtimeStatus === "busy") return "working";
87
89
  if (row.runtimeStatus === "running") return "running";
88
90
  if (row.live || row.runtimeStatus === "idle") return "recent";
89
91
  return "idle";
package/settings.mjs CHANGED
@@ -17,6 +17,7 @@ var DEFAULT_LABELS = Object.freeze({
17
17
  searchChats: "Search chats",
18
18
  askAgent: "Ask your agent\u2026",
19
19
  continueHere: "Continue here",
20
+ continueWithTerminal: "Continue with terminal",
20
21
  joinLive: "Join live",
21
22
  forkHere: "Fork here"
22
23
  });
@@ -32,6 +33,7 @@ var EMPTY_UI_STATE = Object.freeze({
32
33
  strategy: null,
33
34
  canSend: false,
34
35
  canResume: false,
36
+ continuationModes: Object.freeze([]),
35
37
  canBranch: false,
36
38
  canAttach: false,
37
39
  canDetach: false,
package/styles.css CHANGED
@@ -179,7 +179,7 @@
179
179
  .scui-plan > summary { display:flex; justify-content:space-between; padding:6px 8px; border:1px solid var(--scui-border); border-radius:7px; background:var(--scui-bg-raised) }.scui-plan ol { display:grid; gap:4px; box-sizing:border-box; max-height:140px; margin:5px 0 0; padding:7px 8px 7px 25px; overflow:auto; border:1px solid var(--scui-border); border-radius:7px }.scui-plan li[data-status="completed"] { color:var(--scui-fg); text-decoration:line-through }
180
180
  .scui-working { display:flex; align-items:center; gap:4px; color:var(--scui-muted) }.scui-working > span { color:var(--scui-accent) }.scui-working > i { width:4px; height:4px; border-radius:50%; background:currentColor; animation:scui-dots 1.2s infinite }.scui-working > i:nth-of-type(2) { animation-delay:.15s }.scui-working > i:nth-of-type(3) { animation-delay:.3s }
181
181
 
182
- .scui-continuation { display:flex; align-items:center; gap:8px; padding:7px 9px; border-top:1px solid var(--scui-border); background:var(--scui-bg-raised) }.scui-continuation > span { display:grid; flex:1 }.scui-continuation small { color:var(--scui-muted); font-size:10px }.scui-continuation button { padding:5px 8px; border:1px solid var(--scui-accent); border-radius:7px; background:transparent; color:var(--scui-accent); cursor:pointer }
182
+ .scui-continuation { display:flex; align-items:center; gap:8px; padding:7px 9px; border-top:1px solid var(--scui-border); background:var(--scui-bg-raised) }.scui-continuation > span:not(.scui-continuation-actions) { display:grid; flex:1 }.scui-continuation small { color:var(--scui-muted); font-size:10px }.scui-continuation-actions { display:flex; align-items:center; gap:5px; flex-wrap:wrap; justify-content:flex-end }.scui-continuation button { padding:5px 8px; border:1px solid var(--scui-accent); border-radius:7px; background:transparent; color:var(--scui-accent); cursor:pointer; white-space:nowrap }.scui-continuation button.scui-secondary { border-color:var(--scui-border-strong); color:var(--scui-fg) }
183
183
  .scui-compose { flex:none; padding:8px; border-top:1px solid var(--scui-border); background:var(--scui-bg-raised) }
184
184
  .scui-envelope { display:flex; align-items:flex-end; gap:7px; padding:7px; border:1px solid var(--scui-border-strong); border-radius:16px; background:var(--scui-bg) }.scui-envelope textarea { flex:1; min-width:0; min-height:34px; max-height:150px; resize:none; overflow-y:hidden; border:0; outline:0; background:transparent; color:var(--scui-fg) }.scui-envelope > span { display:flex; gap:4px }
185
185
  .scui-envelope.scui-drop-target { border-color:var(--scui-accent); background:color-mix(in srgb,var(--scui-accent) 7%,var(--scui-bg)); box-shadow:0 0 0 2px color-mix(in srgb,var(--scui-accent) 16%,transparent) }
@@ -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