@volter-ai-dev/supercode-ui 0.1.35 → 0.1.37

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/composer.mjs CHANGED
@@ -32,6 +32,7 @@ var EMPTY_UI_STATE = Object.freeze({
32
32
  mode: "none",
33
33
  strategy: null,
34
34
  canSend: false,
35
+ canSteer: false,
35
36
  canResume: false,
36
37
  continuationModes: Object.freeze([]),
37
38
  canBranch: false,
@@ -67,6 +68,16 @@ var EMPTY_UI_STATE = Object.freeze({
67
68
  function harnessDisplayName(id) {
68
69
  return HARNESS_NAMES[id] ?? id;
69
70
  }
71
+ var ACTIVITY_PRIORITY = Object.freeze({
72
+ "needs-input": 70,
73
+ failed: 60,
74
+ working: 50,
75
+ unseen: 40,
76
+ finished: 30,
77
+ running: 20,
78
+ recent: 10,
79
+ idle: 0
80
+ });
70
81
  function canContinueHere(state) {
71
82
  if (state.mode !== "mirror" || state.canSend) return false;
72
83
  const row = state.attached?.key ? state.sessions.find((item) => item.key === state.attached.key) : null;
@@ -130,7 +141,7 @@ var ICONS = {
130
141
  function UiIcon({ name, size = 16, class: className = "" }) {
131
142
  const Glyph = ICONS[name];
132
143
  if (!Glyph) return null;
133
- return /* @__PURE__ */ jsx("svg", { class: `scui-icon ${className}`, style: { "--scui-icon-size": `${size}px` }, viewBox: "0 0 18 18", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx(Glyph, {}) });
144
+ return /* @__PURE__ */ jsx("svg", { className: `scui-icon ${className}`, style: { "--scui-icon-size": `${size}px` }, viewBox: "0 0 18 18", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx(Glyph, {}) });
134
145
  }
135
146
 
136
147
  // src/context.jsx
@@ -204,6 +215,26 @@ function partitionAttachments(value) {
204
215
  }
205
216
  return { context, images };
206
217
  }
218
+ function attachmentKey(item) {
219
+ if (item.id) return `id:${item.id}`;
220
+ return "detail" in item ? `context:${item.kind ?? ""}\0${item.label}\0${item.detail}` : `image:${item.url}`;
221
+ }
222
+ function ContextCandidate({ attachment, attached, onAttach }) {
223
+ const image = "url" in attachment;
224
+ return /* @__PURE__ */ jsxs2("button", { type: "button", disabled: attached, "aria-label": `${attached ? "Attached" : "Attach"} ${attachment.label}`, onClick: () => onAttach(attachment), children: [
225
+ image ? /* @__PURE__ */ jsx2("img", { src: attachment.url, alt: "" }) : /* @__PURE__ */ jsx2(UiIcon, { name: "attach", size: 13 }),
226
+ /* @__PURE__ */ jsxs2("span", { children: [
227
+ /* @__PURE__ */ jsx2("strong", { children: attachment.label }),
228
+ /* @__PURE__ */ jsx2("small", { children: image ? "Image" : attachment.kind || "Context" })
229
+ ] }),
230
+ attached ? /* @__PURE__ */ jsx2(UiIcon, { name: "check", size: 13 }) : /* @__PURE__ */ jsx2(UiIcon, { name: "plus", size: 13 })
231
+ ] });
232
+ }
233
+ function ContextCandidates({ items, context, images, state, adapter, onAttach, component: Candidate = ContextCandidate }) {
234
+ if (!items.length) return null;
235
+ const attached = new Set([...context, ...images].map(attachmentKey));
236
+ return /* @__PURE__ */ jsx2("div", { className: "scui-context-candidates", "aria-label": "Available context", children: items.map((attachment, index) => /* @__PURE__ */ jsx2(Candidate, { value: attachment, attachment, attached: attached.has(attachmentKey(attachment)), state, adapter, onAttach, index }, attachmentKey(attachment))) });
237
+ }
207
238
  async function imageAttachmentsFromFiles(value) {
208
239
  const files = Array.from(value ?? []).filter((file) => file?.type?.startsWith("image/"));
209
240
  if (files.length > MAX_IMAGE_ITEMS) throw new Error(`Attach at most ${MAX_IMAGE_ITEMS} images at a time.`);
@@ -221,7 +252,7 @@ async function imageAttachmentsFromFiles(value) {
221
252
  }
222
253
  function ContextTray({ items, onRemove }) {
223
254
  if (!items.length) return null;
224
- return /* @__PURE__ */ jsx2("div", { class: "scui-compose-context", "aria-label": "Attached context", children: items.map((item, index) => /* @__PURE__ */ jsxs2("span", { children: [
255
+ return /* @__PURE__ */ jsx2("div", { className: "scui-compose-context", "aria-label": "Attached context", children: items.map((item, index) => /* @__PURE__ */ jsxs2("span", { children: [
225
256
  /* @__PURE__ */ jsx2(UiIcon, { name: "attach", size: 12 }),
226
257
  /* @__PURE__ */ jsx2("strong", { children: item.label }),
227
258
  /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": `Remove context ${item.label}`, onClick: () => onRemove(index), children: /* @__PURE__ */ jsx2(UiIcon, { name: "close", size: 12 }) })
@@ -229,13 +260,23 @@ function ContextTray({ items, onRemove }) {
229
260
  }
230
261
  function ImageTray({ items, onRemove }) {
231
262
  if (!items.length) return null;
232
- return /* @__PURE__ */ jsx2("div", { class: "scui-compose-images", "aria-label": "Attached images", children: items.map((item, index) => /* @__PURE__ */ jsxs2("span", { children: [
263
+ return /* @__PURE__ */ jsx2("div", { className: "scui-compose-images", "aria-label": "Attached images", children: items.map((item, index) => /* @__PURE__ */ jsxs2("span", { children: [
233
264
  /* @__PURE__ */ jsx2("img", { src: item.url, alt: "" }),
234
265
  /* @__PURE__ */ jsx2("strong", { children: item.label }),
235
266
  onRemove ? /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": `Remove image ${item.label}`, onClick: () => onRemove(index), children: /* @__PURE__ */ jsx2(UiIcon, { name: "close", size: 12 }) }) : null
236
267
  ] }, item.id ?? `${item.label}:${index}`)) });
237
268
  }
238
269
 
270
+ // src/intent.js
271
+ var CONFIRMABLE_ACTIONS = /* @__PURE__ */ new Set(["resume", "join", "branch", "reduce", "export"]);
272
+ async function dispatchConfirmedIntent(adapter, intent) {
273
+ if (CONFIRMABLE_ACTIONS.has(intent.action) && adapter.confirmIntent) {
274
+ const confirmed = await adapter.confirmIntent(intent);
275
+ if (!confirmed) return;
276
+ }
277
+ return adapter.onIntent(intent);
278
+ }
279
+
239
280
  // src/textarea.js
240
281
  import { useLayoutEffect } from "preact/hooks";
241
282
  function useAutosizeTextarea(ref, value) {
@@ -262,28 +303,29 @@ function ContinuationBar({ state, adapter, labels = DEFAULT_LABELS }) {
262
303
  const terminal = resume && state.continuationModes?.includes("terminal");
263
304
  const join = state.canAttach;
264
305
  const branch = state.canBranch;
265
- if (!resume && !join && !branch) return /* @__PURE__ */ jsx3("div", { class: "scui-continuation", children: /* @__PURE__ */ jsxs3("span", { children: [
306
+ if (!resume && !join && !branch) return /* @__PURE__ */ jsx3("div", { className: "scui-continuation", children: /* @__PURE__ */ jsxs3("span", { children: [
266
307
  /* @__PURE__ */ jsx3("strong", { children: activeElsewhere ? "Active elsewhere" : "Read-only" }),
267
308
  /* @__PURE__ */ jsx3("small", { children: "This session cannot be continued by an available harness." })
268
309
  ] }) });
269
- return /* @__PURE__ */ jsxs3("div", { class: "scui-continuation", children: [
310
+ return /* @__PURE__ */ jsxs3("div", { className: "scui-continuation", children: [
270
311
  /* @__PURE__ */ jsxs3("span", { children: [
271
312
  /* @__PURE__ */ jsx3("strong", { children: activeElsewhere ? "Active elsewhere" : "Read-only" }),
272
313
  /* @__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." })
273
314
  ] }),
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
315
+ /* @__PURE__ */ jsxs3("span", { className: "scui-continuation-actions", children: [
316
+ /* @__PURE__ */ jsx3("button", { type: "button", disabled: Boolean(state.operation), onClick: () => dispatchConfirmedIntent(adapter, join ? { action: "join" } : resume ? { action: "resume", mode: "headless" } : { action: "branch" }), children: join ? labels.joinLive : resume ? labels.continueHere : labels.forkHere }),
317
+ terminal ? /* @__PURE__ */ jsx3("button", { type: "button", className: "scui-secondary", disabled: Boolean(state.operation), onClick: () => dispatchConfirmedIntent(adapter, { action: "resume", mode: "terminal" }), children: labels.continueWithTerminal ?? DEFAULT_LABELS.continueWithTerminal }) : null
277
318
  ] })
278
319
  ] });
279
320
  }
280
- function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.attached?.key || `${state.harness}:${state.workspace}`, pendingStatus = null, restoreDraft = null, onPending, onDraftRestored }) {
321
+ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.attached?.key || `${state.harness}:${state.workspace}`, pendingStatus = null, restoreDraft = null, contextCandidates = [], components = {}, onPending, onDraftRestored }) {
281
322
  const remembered = composerMemory.get(memoryKey) ?? { draft: state.savedDraft, context: [], images: [], queue: [] };
282
323
  const [draft, setDraft] = useState2(remembered.draft);
283
324
  const [context, setContext] = useState2(remembered.context ?? []);
284
325
  const [images, setImages] = useState2(remembered.images ?? []);
285
326
  const [queue, setQueue] = useState2((remembered.queue ?? []).map((item) => ({ ...item, context: item.context ?? [], images: item.images ?? [] })));
286
327
  const [dispatching, setDispatching] = useState2(false);
328
+ const [steering, setSteering] = useState2(false);
287
329
  const [picking, setPicking] = useState2(false);
288
330
  const [dragging, setDragging] = useState2(false);
289
331
  const [pickerError, setPickerError] = useState2(null);
@@ -298,8 +340,10 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
298
340
  remember(draft, context, images, next);
299
341
  return next;
300
342
  });
301
- const queueBlocked = state.busy || pendingStatus !== null || dispatching;
302
- const queuesNewMessage = state.busy || pendingStatus === "sending" || pendingStatus === "failed" || dispatching;
343
+ const queueBlocked = state.busy || pendingStatus !== null || dispatching || steering;
344
+ const queuesNewMessage = state.busy || pendingStatus === "sending" || pendingStatus === "failed" || dispatching || steering;
345
+ const steerAvailable = state.busy && state.canSteer && !steering && pendingStatus === null;
346
+ const canSteerDraft = steerAvailable && Boolean(draft.trim()) && context.length === 0 && images.length === 0;
303
347
  useEffect2(() => {
304
348
  if (!queueBlocked && state.canSend && queue.length) {
305
349
  const [next, ...rest] = queue;
@@ -351,6 +395,14 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
351
395
  });
352
396
  }).catch((error) => setPickerError(error instanceof Error ? error.message : "Could not attach context.")).finally(() => setPicking(false));
353
397
  };
398
+ const attachCandidate = (picked) => {
399
+ const attachments = partitionAttachments(picked);
400
+ const nextContext = mergeContext(context, attachments.context);
401
+ const nextImages = mergeImages(images, attachments.images);
402
+ setContext(nextContext);
403
+ setImages(nextImages);
404
+ remember(draft, nextContext, nextImages, queue);
405
+ };
354
406
  const addImageFiles = (value, source) => {
355
407
  const allFiles = Array.from(value ?? []);
356
408
  if (!allFiles.length) return false;
@@ -379,9 +431,17 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
379
431
  setDragging(false);
380
432
  if (addImageFiles(event.dataTransfer?.files, "drop")) event.preventDefault();
381
433
  };
382
- const send = () => {
434
+ const send = (forceQueue = false) => {
383
435
  const text = draft.trim();
384
436
  if (!text && !images.length) return;
437
+ if (canSteerDraft && !forceQueue) {
438
+ setSteering(true);
439
+ Promise.resolve(adapter.onIntent({ action: "steer", text })).then(() => {
440
+ setDraft("");
441
+ remember("", context, images, queue);
442
+ }, (error) => setPickerError(error instanceof Error ? error.message : "Could not steer the active turn.")).finally(() => setSteering(false));
443
+ return;
444
+ }
385
445
  const message = { text, context, images };
386
446
  if (queuesNewMessage) updateQueue((items) => [...items, message]);
387
447
  else if (state.canSend) {
@@ -394,8 +454,8 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
394
454
  setImages([]);
395
455
  remember("", [], [], queuesNewMessage ? [...queue, message] : queue);
396
456
  };
397
- return /* @__PURE__ */ jsxs3("div", { class: "scui-compose", children: [
398
- queue.length ? /* @__PURE__ */ jsxs3("div", { class: "scui-queue", children: [
457
+ return /* @__PURE__ */ jsxs3("div", { className: "scui-compose", children: [
458
+ queue.length ? /* @__PURE__ */ jsxs3("div", { className: "scui-queue", children: [
399
459
  /* @__PURE__ */ jsxs3("strong", { children: [
400
460
  queue.length,
401
461
  " queued"
@@ -411,6 +471,7 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
411
471
  /* @__PURE__ */ jsx3("button", { type: "button", "aria-label": `Remove queued message ${index + 1}`, onClick: () => updateQueue((items) => items.filter((_, itemIndex) => itemIndex !== index)), children: /* @__PURE__ */ jsx3(UiIcon, { name: "close", size: 13 }) })
412
472
  ] }, `${index}:${item.text}`))
413
473
  ] }) : null,
474
+ /* @__PURE__ */ jsx3(ContextCandidates, { items: contextCandidates, context, images, state, adapter, onAttach: attachCandidate, component: components.ContextCandidate }),
414
475
  /* @__PURE__ */ jsx3(ImageTray, { items: images, onRemove: (index) => setImages((items) => {
415
476
  const next = items.filter((_, itemIndex) => itemIndex !== index);
416
477
  remember(draft, context, next, queue);
@@ -421,8 +482,8 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
421
482
  remember(draft, next, images, queue);
422
483
  return next;
423
484
  }) }),
424
- pickerError ? /* @__PURE__ */ jsx3("small", { class: "scui-context-error", role: "alert", children: pickerError }) : null,
425
- /* @__PURE__ */ jsxs3("div", { class: `scui-envelope${dragging ? " scui-drop-target" : ""}`, onDragEnter: (event) => {
485
+ pickerError ? /* @__PURE__ */ jsx3("small", { className: "scui-context-error", role: "alert", children: pickerError }) : null,
486
+ /* @__PURE__ */ jsxs3("div", { className: `scui-envelope${dragging ? " scui-drop-target" : ""}`, onDragEnter: (event) => {
426
487
  if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
427
488
  event.preventDefault();
428
489
  setDragging(true);
@@ -432,8 +493,8 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
432
493
  }, onDragLeave: (event) => {
433
494
  if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false);
434
495
  }, onDrop: dropImages, children: [
435
- adapter.pickContext && state.mode === "control" ? /* @__PURE__ */ jsx3("button", { class: "scui-attach", type: "button", "aria-label": "Attach files or images", disabled: picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS, onClick: pickContext, children: picking ? /* @__PURE__ */ jsx3("i", { class: "scui-control-spinner" }) : /* @__PURE__ */ jsx3(UiIcon, { name: "attach", size: 17 }) }) : null,
436
- /* @__PURE__ */ jsx3("textarea", { ref: textarea, rows: 1, "aria-label": `Message ${harnessDisplayName(state.harness) || "agent"}`, placeholder: state.startup !== "ready" ? "Connecting\u2026" : pendingStatus === "failed" ? "Retry or edit the unsent message\u2026" : pendingStatus === "editing" ? "Edit and resend\u2026" : queueBlocked ? "Queue a follow-up\u2026" : labels.askAgent, value: draft, disabled: state.mode !== "control" && !state.canSend, onPaste: pasteImages, onInput: (event) => {
496
+ adapter.pickContext && state.mode === "control" ? /* @__PURE__ */ jsx3("button", { className: "scui-attach", type: "button", "aria-label": "Attach files or images", disabled: picking || context.length >= MAX_CONTEXT_ITEMS && images.length >= MAX_IMAGE_ITEMS, onClick: pickContext, children: picking ? /* @__PURE__ */ jsx3("i", { className: "scui-control-spinner" }) : /* @__PURE__ */ jsx3(UiIcon, { name: "attach", size: 17 }) }) : null,
497
+ /* @__PURE__ */ jsx3("textarea", { ref: textarea, rows: 1, "aria-label": `Message ${harnessDisplayName(state.harness) || "agent"}`, placeholder: state.startup !== "ready" ? "Connecting\u2026" : pendingStatus === "failed" ? "Retry or edit the unsent message\u2026" : pendingStatus === "editing" ? "Edit and resend\u2026" : steerAvailable && context.length === 0 && images.length === 0 ? "Redirect the current turn\u2026" : queueBlocked ? "Queue a follow-up\u2026" : labels.askAgent, value: draft, disabled: state.mode !== "control" && !state.canSend, onPaste: pasteImages, onInput: (event) => {
437
498
  const value = event.currentTarget.value;
438
499
  setDraft(value);
439
500
  remember(value, context, images, queue);
@@ -444,8 +505,9 @@ function Composer({ state, adapter, labels = DEFAULT_LABELS, memoryKey = state.a
444
505
  }
445
506
  } }),
446
507
  /* @__PURE__ */ jsxs3("span", { children: [
447
- state.busy ? /* @__PURE__ */ jsx3("button", { class: "scui-stop", type: "button", "aria-label": "Stop agent", disabled: !state.canInterrupt, onClick: () => adapter.onIntent({ action: "interrupt" }), children: /* @__PURE__ */ jsx3(UiIcon, { name: "stop", size: 15 }) }) : null,
448
- /* @__PURE__ */ jsx3("button", { class: "scui-send", type: "button", "aria-label": queuesNewMessage ? "Queue message" : "Send message", disabled: !draft.trim() && !images.length || !queuesNewMessage && !state.canSend, onClick: send, children: /* @__PURE__ */ jsx3(UiIcon, { name: queuesNewMessage ? "plus" : "send", size: 17 }) })
508
+ state.busy ? /* @__PURE__ */ jsx3("button", { className: "scui-stop", type: "button", "aria-label": "Stop agent", disabled: !state.canInterrupt, onClick: () => adapter.onIntent({ action: "interrupt" }), children: /* @__PURE__ */ jsx3(UiIcon, { name: "stop", size: 15 }) }) : null,
509
+ canSteerDraft ? /* @__PURE__ */ jsx3("button", { className: "scui-queue-send", type: "button", "aria-label": "Queue follow-up instead", onClick: () => send(true), children: /* @__PURE__ */ jsx3(UiIcon, { name: "plus", size: 15 }) }) : null,
510
+ /* @__PURE__ */ jsx3("button", { className: "scui-send", type: "button", "aria-label": canSteerDraft ? "Steer current turn" : queuesNewMessage ? "Queue message" : "Send message", disabled: steering || !draft.trim() && !images.length || !queuesNewMessage && !state.canSend, onClick: () => send(), children: steering ? /* @__PURE__ */ jsx3("i", { className: "scui-control-spinner" }) : /* @__PURE__ */ jsx3(UiIcon, { name: canSteerDraft ? "send" : queuesNewMessage ? "plus" : "send", size: 17 }) })
449
511
  ] })
450
512
  ] })
451
513
  ] });
package/controller.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  SessionArtifact,
3
+ SessionDescriptor,
3
4
  SessionFormat,
4
5
  } from '@volter-ai-dev/supercode-harness-sdk';
5
6
  import type {
@@ -55,6 +56,30 @@ export function projectClientSnapshot(
55
56
  options?: ClientProjectionOptions,
56
57
  ): SupercodeUiState;
57
58
 
59
+ export interface SessionInventoryProjectionOptions {
60
+ /** Mint a transport-safe key while retaining the reversible locator only in the trusted host. */
61
+ keyFor(descriptor: SessionDescriptor): string;
62
+ now?: number;
63
+ home?: string;
64
+ active?: { harness: string; sessionId: string | null } | null;
65
+ maxSessions?: number;
66
+ liveWindowMs?: number;
67
+ preserveOrder?: boolean;
68
+ }
69
+
70
+ export function sessionConversationUpdatedAt(descriptor: SessionDescriptor): number | null;
71
+ export function sessionDescriptorRuntimeStatus(
72
+ descriptor: SessionDescriptor,
73
+ ): 'running' | 'busy' | 'idle' | null;
74
+ export interface ProjectedSessionRowModel extends SessionRowModel {
75
+ preview: string;
76
+ previewUpdatedAt: number | null;
77
+ }
78
+ export function projectSessionInventory(
79
+ descriptors: readonly SessionDescriptor[],
80
+ options: SessionInventoryProjectionOptions,
81
+ ): ProjectedSessionRowModel[];
82
+
58
83
  export interface ResolvableTranscriptImage {
59
84
  id?: string;
60
85
  label: string;
@@ -79,6 +104,8 @@ export interface ControllerBindingOptions {
79
104
  onIntent?: (intent: SupercodeUiIntent) => void | Promise<void>;
80
105
  onUnsupported?: (intent: SupercodeUiIntent) => void | Promise<void>;
81
106
  onError?: (error: unknown, intent: SupercodeUiIntent) => void | Promise<void>;
107
+ /** Return true after handling an intent to replace the standard controller mapping. */
108
+ handleIntent?: (intent: SupercodeUiIntent) => boolean | Promise<boolean>;
82
109
  onArtifact?: (artifact: SessionArtifact, intent: Extract<SupercodeUiIntent, { action: 'export' }>) => void | Promise<void>;
83
110
  onDraft?: (text: string) => void | Promise<void>;
84
111
  onAcknowledge?: (key: string) => void | Promise<void>;
@@ -88,8 +115,24 @@ export interface ControllerBindingOptions {
88
115
  onResumeTerminal?: (intent: Extract<SupercodeUiIntent, { action: 'resume' }>) => void | Promise<void>;
89
116
  copyText?: UiAdapter['copyText'];
90
117
  resolveImage?: UiAdapter['resolveImage'];
118
+ confirmIntent?: UiAdapter['confirmIntent'];
119
+ pickContext?: UiAdapter['pickContext'];
120
+ onClose?: UiAdapter['onClose'];
121
+ onOpen?: UiAdapter['onOpen'];
122
+ }
123
+
124
+ export interface ControllerIntentTarget {
125
+ getSnapshot(): SupercodeClientSnapshot;
126
+ dispatch(action: Parameters<SupercodeController['dispatch']>[0]): Promise<SupercodeClientSnapshot>;
127
+ exportSession?: SupercodeController['exportSession'];
91
128
  }
92
129
 
130
+ export function dispatchControllerIntent(
131
+ controller: ControllerIntentTarget,
132
+ intent: SupercodeUiIntent,
133
+ options?: ControllerBindingOptions,
134
+ ): Promise<void>;
135
+
93
136
  export interface SupercodeUiBinding {
94
137
  adapter: UiAdapter;
95
138
  getState(): SupercodeUiState;
package/controller.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { conversationPreviewText } from '@volter-ai-dev/supercode-client';
1
2
  import { createToolPresentation, normalizeUiState, relativeAge } from './core.mjs';
2
3
 
3
4
  const HARNESS_LABELS = {
@@ -28,6 +29,110 @@ function workspaceName(cwd) {
28
29
  return cwd.replaceAll('\\', '/').split('/').filter(Boolean).at(-1) ?? cwd;
29
30
  }
30
31
 
32
+ function shortWorkspacePath(cwd, home) {
33
+ if (typeof cwd !== 'string' || !cwd) return '';
34
+ const root = typeof home === 'string' && home.endsWith('/') ? home.slice(0, -1) : home;
35
+ return root && (cwd === root || cwd.startsWith(`${root}/`)) ? `~${cwd.slice(root.length)}` : cwd;
36
+ }
37
+
38
+ function compactTopic(value) {
39
+ const text = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
40
+ if (text.length <= 72) return text;
41
+ const prefix = text.slice(0, 71);
42
+ const boundary = prefix.lastIndexOf(' ');
43
+ return `${prefix.slice(0, boundary >= 40 ? boundary : 71).trimEnd()}…`;
44
+ }
45
+
46
+ export function sessionDescriptorRuntimeStatus(descriptor) {
47
+ const activity = descriptor?.activity;
48
+ if (activity && typeof activity === 'object') {
49
+ if (activity.presence === 'persisted') return null;
50
+ if (activity.turn === 'working') return 'busy';
51
+ if (activity.turn === 'idle' || activity.turn === 'needs_input') return 'idle';
52
+ if (activity.presence === 'running' || activity.presence === 'shutting_down') return 'running';
53
+ }
54
+ return ['running', 'busy', 'idle'].includes(descriptor?.live_status)
55
+ ? descriptor.live_status
56
+ : null;
57
+ }
58
+
59
+ function descriptorPreview(descriptor) {
60
+ for (const candidate of descriptor?.latest_message_candidates ?? []) {
61
+ const text = conversationPreviewText([candidate]);
62
+ if (!text) continue;
63
+ return { text, updatedAt: timestampFromMetadata(candidate.metadata) };
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function descriptorTitle(descriptor) {
69
+ const nativeTitle = typeof descriptor?.title === 'string' ? descriptor.title.trim() : '';
70
+ const workspace = workspaceName(descriptor?.cwd);
71
+ if (nativeTitle && nativeTitle !== workspace && nativeTitle !== descriptor?.cwd) {
72
+ return compactTopic(nativeTitle);
73
+ }
74
+ if (descriptor?.locator?.harness !== 'claude-code' && descriptor?.locator?.harness !== 'codex') {
75
+ return compactTopic(nativeTitle || workspace || descriptor?.locator?.session_id?.slice(0, 8)) || 'Untitled chat';
76
+ }
77
+ const openingUserMessages = (descriptor?.preview_candidates ?? []).filter(
78
+ (candidate) => candidate?.role === undefined || candidate.role === 'user',
79
+ );
80
+ return compactTopic(conversationPreviewText(openingUserMessages)) || 'Untitled chat';
81
+ }
82
+
83
+ /** Timestamp represented by a messenger row, independent of native store heartbeat writes. */
84
+ export function sessionConversationUpdatedAt(descriptor) {
85
+ return descriptorPreview(descriptor)?.updatedAt ?? descriptor?.updated_at_ms ?? null;
86
+ }
87
+
88
+ /**
89
+ * Project trusted machine-wide descriptors into the same bounded rows as controller snapshots.
90
+ * The host supplies opaque keys because only it may retain the reversible locator mapping.
91
+ */
92
+ export function projectSessionInventory(descriptors, options) {
93
+ if (typeof options?.keyFor !== 'function') {
94
+ throw new TypeError('projectSessionInventory requires an opaque keyFor callback');
95
+ }
96
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
97
+ const maxSessions = positiveInteger(options.maxSessions, DEFAULT_MAX_SESSIONS);
98
+ const liveWindowMs = positiveInteger(options.liveWindowMs, DEFAULT_LIVE_WINDOW_MS);
99
+ const active = options.active;
100
+ const seen = new Set();
101
+ const rows = [];
102
+ const ordered = options.preserveOrder
103
+ ? [...(descriptors ?? [])]
104
+ : [...(descriptors ?? [])].sort(
105
+ (left, right) => (sessionConversationUpdatedAt(right) ?? 0) - (sessionConversationUpdatedAt(left) ?? 0),
106
+ );
107
+ for (const descriptor of ordered) {
108
+ if (!descriptor?.locator) continue;
109
+ const key = options.keyFor(descriptor);
110
+ if (typeof key !== 'string' || !key || seen.has(key)) continue;
111
+ seen.add(key);
112
+ const preview = descriptorPreview(descriptor);
113
+ const updatedAt = typeof descriptor.updated_at_ms === 'number' ? descriptor.updated_at_ms : null;
114
+ rows.push({
115
+ key,
116
+ harness: descriptor.locator.harness,
117
+ name: workspaceName(descriptor.cwd) || 'no workspace',
118
+ cwd: shortWorkspacePath(descriptor.cwd, options.home),
119
+ title: descriptorTitle(descriptor),
120
+ preview: preview?.text ?? '',
121
+ age: relativeAge(preview?.updatedAt ?? updatedAt, now),
122
+ previewUpdatedAt: preview?.updatedAt ?? null,
123
+ updatedAt,
124
+ messages: typeof descriptor.message_count === 'number' ? descriptor.message_count : null,
125
+ active: active?.sessionId != null
126
+ && descriptor.locator.harness === active.harness
127
+ && descriptor.locator.session_id === active.sessionId,
128
+ live: updatedAt !== null && now - updatedAt <= liveWindowMs,
129
+ runtimeStatus: sessionDescriptorRuntimeStatus(descriptor),
130
+ });
131
+ if (rows.length >= maxSessions) break;
132
+ }
133
+ return rows;
134
+ }
135
+
31
136
  function payloadText(payload) {
32
137
  if (typeof payload === 'string') return payload;
33
138
  try {
@@ -365,6 +470,7 @@ function projectClientSnapshotInternal(snapshot, options, imageRegistry) {
365
470
  mode: snapshot.connection?.mode ?? 'none',
366
471
  strategy: snapshot.connection?.strategy ?? null,
367
472
  canSend: actions.send === true,
473
+ canSteer: actions.steer === true,
368
474
  canResume: actions.resume === true,
369
475
  continuationModes: options.continuationModes ?? (actions.resume === true ? ['headless'] : []),
370
476
  canBranch: actions.branch === true,
@@ -403,6 +509,19 @@ function projectClientSnapshotInternal(snapshot, options, imageRegistry) {
403
509
  installed: harness.installed === true,
404
510
  startable: harness.availableActions?.start === true,
405
511
  reason: harness.reason ?? null,
512
+ auth: harness.auth,
513
+ runtime: harness.runtime,
514
+ protocol: harness.protocol,
515
+ repair: harness.repair ?? null,
516
+ capabilities: {
517
+ start: harness.availableActions?.start === true,
518
+ resume: harness.availableActions?.resume === true,
519
+ attach: harness.availableActions?.attach === true,
520
+ send: harness.availableActions?.send === true,
521
+ interrupt: harness.availableActions?.interrupt === true,
522
+ steer: harness.availableActions?.steer === true,
523
+ respond: harness.availableActions?.respond === true,
524
+ },
406
525
  })),
407
526
  history: {
408
527
  sessionLimit: options.history?.sessionLimit ?? sessions.length,
@@ -440,12 +559,14 @@ export function createClientProjection(snapshot, options = {}) {
440
559
  };
441
560
  }
442
561
 
443
- async function dispatchStandard(controller, intent, options) {
562
+ export async function dispatchControllerIntent(controller, intent, options = {}) {
563
+ if (await options.handleIntent?.(intent)) return;
444
564
  const snapshot = controller.getSnapshot();
445
565
  const active = snapshot.activeSessionKey;
446
566
  if (intent.action === 'mounted') return;
447
567
  if (intent.action === 'attach') return controller.dispatch({ type: 'observe', sessionKey: intent.key });
448
568
  if (intent.action === 'send') return controller.dispatch({ type: 'send', text: intent.text, ...(intent.context?.length ? { context: intent.context } : {}), ...(intent.images?.length ? { images: intent.images } : {}) });
569
+ if (intent.action === 'steer') return controller.dispatch({ type: 'steer', text: intent.text });
449
570
  if (intent.action === 'new') {
450
571
  if (intent.mode === 'terminal') {
451
572
  return options.onStartTerminal
@@ -496,12 +617,16 @@ export function createControllerBinding(controller, options = {}) {
496
617
  const adapter = {
497
618
  onIntent(intent) {
498
619
  return Promise.resolve(options.onIntent?.(intent))
499
- .then(() => dispatchStandard(controller, intent, options))
620
+ .then(() => dispatchControllerIntent(controller, intent, options))
500
621
  .catch((error) => options.onError?.(error, intent))
501
622
  .then(() => undefined);
502
623
  },
503
624
  ...(options.copyText ? { copyText: options.copyText } : {}),
504
625
  ...(options.resolveImage ? { resolveImage: options.resolveImage } : {}),
626
+ ...(options.confirmIntent ? { confirmIntent: options.confirmIntent } : {}),
627
+ ...(options.pickContext ? { pickContext: options.pickContext } : {}),
628
+ ...(options.onClose ? { onClose: options.onClose } : {}),
629
+ ...(options.onOpen ? { onOpen: options.onOpen } : {}),
505
630
  };
506
631
  return {
507
632
  adapter,