@volter/supercode-frontend-vscode 0.1.16 → 0.1.17

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
@@ -11,6 +11,88 @@ package or npm.
11
11
 
12
12
  ## Native chat controls
13
13
 
14
+ The optional host-controls state can include `setup: { ready, actions, reason,
15
+ cwd }`. Each action supplies `kind` (`login` or `install`), `harness`, `name` and
16
+ the exact `command` shown before execution. Chat renders native buttons, with
17
+ **Sign in with ChatGPT** for Codex and the reported name for other harnesses.
18
+ Clicking asks the host's authenticated `/setup` door to revalidate `{ kind,
19
+ harness }` and return the action plus a structured `{ program, arguments, cwd, env? }`
20
+ terminal launch. Absolute executables and an optional host-authored PATH keep
21
+ the terminal on the same installation and discovery path as the host's probe.
22
+ It runs directly as the integrated terminal's process, without typing a shell
23
+ command; credentials and
24
+ browser interaction belong to the harness. Failed or cancelled processes can be
25
+ retried, and closing a terminal never counts as successful authentication.
26
+
27
+ While setup is not ready, Chat refreshes `/state` every three seconds and exposes
28
+ **Check again**. When the host returns a ready runtime connection, it attaches
29
+ and reveals that conversation without a reload. Polling only finishes after both
30
+ attachment and the host's native reveal command succeed for that same session
31
+ and runtime URL. A transient attach/reveal failure stays pending and is retried;
32
+ **Check again** retries that handoff even when the host already reports ready.
33
+ Changed setup resources refresh
34
+ Code-OSS's cached status history after an install. Hosts without these optional
35
+ fields keep the existing runtime-attachment behavior.
36
+
37
+ `starterPrompts?: {label: string; prompt: string}[]` is project data supplied by
38
+ the host. An eligible new conversation exposes at most three short command links
39
+ in the default participant's native welcome content, outside the transcript.
40
+ The host's `fillInputCommand` receives the exact native session resource and full
41
+ prompt and must return `{sessionResource, input}` after filling that existing
42
+ widget, without submitting. A missing or mismatched receipt is an error.
43
+ The untargeted partial-query command is not used. Links never send or write a turn.
44
+ Presentation and clicks use the same `starterEligibility` calculation; runtime
45
+ and host state changes update the welcome message, and a user turn clears it.
46
+ Native Chat also hides its welcome surface immediately when a request is added.
47
+ Eligibility requires a successful attachment, an idle runtime, no pending request,
48
+ no saved/canonical non-system messages or truncated saved history, and no observed
49
+ user turn. Accepted-input events (`user_message`, `turn_started`) and busy snapshots
50
+ record turn evidence that survives re-attachment and replay eviction. Setup/status
51
+ events can advance cursors without disqualifying a new conversation; system-only
52
+ history also stays eligible. The checks run again after
53
+ project data loads and before filling; a failed attachment never offers starters.
54
+ `supercode.frontend.status(true)` includes each connection's `starterEligibility`:
55
+ attachment state, turn state, pending count, saved-history presence/truncation/count
56
+ and roles, canonical count/roles, sticky turn evidence, both cursors, replay count
57
+ and kinds, active host ID, host busy state, connection identity check, and rejection
58
+ reasons. `lastStarterAttempt` preserves the exact post-attachment guard snapshot
59
+ and native binding from the latest click. These contain no message bodies or tokens.
60
+ A rejected click names its failed conditions instead of a generic new/idle error.
61
+ The pinned Chat session API exposes immutable history, without withdrawal or
62
+ live enablement of a rendered command part. Starters therefore never enter history.
63
+ `defaultChatParticipant.additionalWelcomeMessage` supplies trusted Markdown with
64
+ only `supercode.frontend.fillStarter` allowed; labels are escaped and command
65
+ arguments are encoded. Hosts without that proposal get no starter offer rather
66
+ than permanent transcript buttons. Existing setup actions keep their native status
67
+ presentation. No new workbench API or editor-side contribution is required.
68
+ The pinned Code-OSS `f16dc165` has no `suggestedPrompts` field in either
69
+ `vscode.proposed.defaultChatParticipant.d.ts` or the internal
70
+ `IChatViewWelcomeContent`. `ChatViewWelcomePart` renders `additionalMessage` as
71
+ Markdown; its optional `firstLinkToButton` converts only one anchor and the
72
+ participant welcome never enables that option. The editor's native overlay can
73
+ render standalone trusted command links as welcome buttons without a new extension
74
+ API; other hosts keep Markdown links. The two introductory lines live together in
75
+ this extension's `package.json`: `chatSessions.welcomeMessage` and
76
+ `supercodeFrontendWelcome.starterIntroduction`. Hosts own their project prompt data.
77
+ If `starterPromptsPending` is true, the project configuration is still loading.
78
+ Empty-conversation resolution refreshes the host until that declaration arrives
79
+ (cancellable, with a 30-second timeout), so native Chat does not cache an empty
80
+ offer before the project adapter loads.
81
+
82
+ W32/W37 passed starter filling and W37 passed healthy refresh with draft retention.
83
+ W44 passed a real boost turn and exposed a presentation defect: transcript starter
84
+ buttons remained after the turn even though the guard rejected them. W50 passed
85
+ the native welcome fix at `29d8fac99`: all three pointer fills, one real HUD turn,
86
+ first-turn disappearance, return-to-idle suppression, and no starters when reopening
87
+ the earlier conversation with seven history entries. The shorter introductory copy
88
+ is source-only; it needs a frontend build plus a workbench overlay copy.
89
+ The accompanying native button and New Session repairs require a Code-OSS compile.
90
+ W50 also found that the generic native New Session action
91
+ bypasses this provider's creation command; its owning host must route that action
92
+ through `chatSessions/newSession` rather than opening an unbound untitled resource.
93
+ Signed-out/install transitions and failed-discovery/attachment recovery remain
94
+ unverified live. A published frontend and rebuilt workbench are needed for npx users.
95
+
14
96
  A bundling host can expose a private lifecycle endpoint through
15
97
  `SUPERCODE_FRONTEND_HOST_URL` and `SUPERCODE_FRONTEND_HOST_CREDENTIAL_FILE`.
16
98
  The endpoint must be loopback HTTP and the credential follows the runtime credential-file
@@ -0,0 +1,6 @@
1
+ /** Native Chat presentation; the host supplies inventory, terminal actions and project data. */
2
+ import * as vscode from 'vscode';
3
+ import type { ChatSetupState, ChatStarterPrompt } from './host-controls.js';
4
+ export declare function setupParts(setup: ChatSetupState): Array<vscode.ChatResponseMarkdownPart | vscode.ChatResponseCommandButtonPart>;
5
+ /** Welcome content is owned by the empty-state renderer, never transcript history. */
6
+ export declare function starterWelcome(prompts: readonly ChatStarterPrompt[], session: string, resource: string, introduction: string): vscode.MarkdownString;
@@ -0,0 +1,29 @@
1
+ /** Native Chat presentation; the host supplies inventory, terminal actions and project data. */
2
+ import * as vscode from 'vscode';
3
+ export function setupParts(setup) {
4
+ const parts = [
5
+ new vscode.ChatResponseMarkdownPart('Sign in to use Chat. Complete sign-in in the terminal and browser.'),
6
+ ];
7
+ for (const action of setup.actions) {
8
+ const title = action.kind === 'install' ? `Install ${action.name}`
9
+ : action.harness === 'codex' ? 'Sign in with ChatGPT' : `Sign in with ${action.name}`;
10
+ parts.push(new vscode.ChatResponseMarkdownPart(`Runs: \`${action.command.replace(/`/g, '\\`')}\``));
11
+ parts.push(new vscode.ChatResponseCommandButtonPart({ title, command: 'supercode.frontend.setup', arguments: [action.kind, action.harness] }));
12
+ }
13
+ if (!setup.actions.length) {
14
+ parts.push(new vscode.ChatResponseMarkdownPart(setup.reason || 'No sign-in action is available.'));
15
+ }
16
+ parts.push(new vscode.ChatResponseCommandButtonPart({ title: 'Check again', command: 'supercode.frontend.refreshSetup' }));
17
+ return parts;
18
+ }
19
+ /** Welcome content is owned by the empty-state renderer, never transcript history. */
20
+ export function starterWelcome(prompts, session, resource, introduction) {
21
+ const message = new vscode.MarkdownString().appendText(introduction).appendMarkdown('\n\n');
22
+ message.isTrusted = { enabledCommands: ['supercode.frontend.fillStarter'] };
23
+ for (const [index, starter] of prompts.slice(0, 3).entries()) {
24
+ const args = encodeURIComponent(JSON.stringify([session, index, resource]));
25
+ message.appendMarkdown('[').appendText(starter.label)
26
+ .appendMarkdown(`](command:supercode.frontend.fillStarter?${args})\n\n`);
27
+ }
28
+ return message;
29
+ }
package/dist/extension.js CHANGED
@@ -2,6 +2,7 @@ import { resolveSessionBinding } from './bindings.js';
2
2
  import { withTextContext } from './context.js';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { ChatHostControls, runtimeIdentity, harnessDisplayName, turnPermission } from './host-controls.js';
5
+ import { setupParts, starterWelcome } from './chat-setup.js';
5
6
  import * as vscode from 'vscode';
6
7
  import { createFrontendClient } from './client.js';
7
8
  import { readCredentialFile } from './credential.js';
@@ -32,6 +33,44 @@ const ENV = Object.freeze({
32
33
  */
33
34
  unavailable: 'SUPERCODE_FRONTEND_UNAVAILABLE',
34
35
  });
36
+ /** Transport progress includes setup events; it is not a user-turn count. */
37
+ function conversationEligibility(connection) {
38
+ const session = connection.session;
39
+ const inputs = {
40
+ attachmentState: session.connection,
41
+ turnState: session.descriptor?.turn_state ?? null,
42
+ pendingRequestCount: session.pendingRequests.size,
43
+ historyTruncated: connection.historyTruncated ?? false,
44
+ persistedHistoryLoaded: connection.persistedHistory !== undefined,
45
+ persistedHistoryCount: connection.persistedHistory?.length ?? 0,
46
+ persistedHistoryRoles: connection.persistedHistory?.map(message => message.role) ?? [],
47
+ canonicalHistoryCount: session.history.length,
48
+ canonicalHistoryRoles: session.history.map(message => message.role),
49
+ hasUserTurn: session.hasUserTurn,
50
+ historyCursor: session.historyCursor,
51
+ eventCursor: session.cursor,
52
+ replayLength: session.replay.length,
53
+ replayKinds: [...new Set(session.replay.map(event => event.kind))],
54
+ nativeEventKinds: [...new Set(session.replay.filter(event => event.kind === 'native_event')
55
+ .map(event => event.payload?.kind).filter(kind => typeof kind === 'string'))],
56
+ };
57
+ const reasons = [];
58
+ if (inputs.attachmentState !== 'connected')
59
+ reasons.push(`attachmentState=${inputs.attachmentState}`);
60
+ if (inputs.turnState !== 'idle')
61
+ reasons.push(`turnState=${inputs.turnState}`);
62
+ if (inputs.pendingRequestCount)
63
+ reasons.push(`pendingRequestCount=${inputs.pendingRequestCount}`);
64
+ if (inputs.historyTruncated)
65
+ reasons.push('saved history is truncated');
66
+ if (inputs.persistedHistoryRoles.some(role => role !== 'system'))
67
+ reasons.push('saved history contains a turn');
68
+ if (inputs.canonicalHistoryRoles.some(role => role !== 'system'))
69
+ reasons.push('canonical history contains a turn');
70
+ if (inputs.hasUserTurn)
71
+ reasons.push('a user turn has been observed');
72
+ return { ...inputs, eligible: reasons.length === 0, reasons };
73
+ }
35
74
  function normalizePermissions(value, fallback) {
36
75
  const raw = Array.isArray(value)
37
76
  ? value.map(String)
@@ -324,6 +363,24 @@ export async function activate(context) {
324
363
  const boundId = (resource) => resolveSessionBinding(resource, controls?.state.sessions ?? [], resourceBindings);
325
364
  let applying = Promise.resolve();
326
365
  const frontends = new SupercodeFrontends();
366
+ const starterEligibility = (connection) => {
367
+ const conversation = conversationEligibility(connection);
368
+ const activeSession = controls?.state.activeSession ?? null;
369
+ const hostBusy = Boolean(controls?.state.busy);
370
+ const connectionMatches = frontends.get(connection.id) === connection;
371
+ const reasons = [...conversation.reasons];
372
+ if (!controls)
373
+ reasons.push('host controls unavailable');
374
+ if (activeSession !== connection.id)
375
+ reasons.push(`activeSession=${activeSession}; expected ${connection.id}`);
376
+ if (hostBusy)
377
+ reasons.push('hostBusy=true');
378
+ if (!connectionMatches)
379
+ reasons.push('connection was replaced');
380
+ return { ...conversation, controlsAvailable: Boolean(controls), connectionId: connection.id, activeSession, hostBusy, connectionMatches,
381
+ eligible: reasons.length === 0, reasons };
382
+ };
383
+ const lastStarterAttempts = new Map();
327
384
  // Runtimes attached by address ("Set Runtime Token", "Attach to Runtime")
328
385
  // beside a host's managed conversation. The host's catalogue does not know
329
386
  // them, so they open as what they are: attachments, shown as their runtime
@@ -332,6 +389,28 @@ export async function activate(context) {
332
389
  const managed = () => frontends.all.find((connection) => !attachedIds.has(connection.id));
333
390
  // The attached runtime the person is looking at: the Chat names its harness, not the host's.
334
391
  let focusedAttached;
392
+ const welcomeParticipants = new Set();
393
+ const welcomeValues = new WeakMap();
394
+ const starterResources = new Map();
395
+ const updateStarterWelcome = () => {
396
+ if (!granted.has('defaultChatParticipant'))
397
+ return;
398
+ const connection = managed();
399
+ const prompts = controls?.state.starterPrompts ?? [];
400
+ // This is the click guard's exact state, including host activity and connection
401
+ // identity. A later idle state must not revive an offer after a user turn.
402
+ const message = connection && !focusedAttached && !controls?.state.starterPromptsPending
403
+ && prompts.length && starterEligibility(connection).eligible
404
+ ? starterWelcome(prompts, connection.id, starterResources.get(connection.id) ?? sessionResource(connection).toString(), context.extension.packageJSON.supercodeFrontendWelcome.starterIntroduction)
405
+ : new vscode.MarkdownString('');
406
+ for (const participant of welcomeParticipants) {
407
+ if (welcomeValues.get(participant) === message.value)
408
+ continue;
409
+ participant.additionalWelcomeMessage = message;
410
+ welcomeValues.set(participant, message.value);
411
+ }
412
+ };
413
+ context.subscriptions.push(frontends.onDidChange(updateStarterWelcome), controlsChanged.event(updateStarterWelcome));
335
414
  let approvalAvailable = false;
336
415
  context.subscriptions.push(frontends.onDidChange(() => {
337
416
  const active = managed();
@@ -392,8 +471,11 @@ export async function activate(context) {
392
471
  throw new Error('The requested conversation has no runtime connection.');
393
472
  const id = controls.state.activeSession;
394
473
  const existing = frontends.get(id);
395
- if (existing && existing.baseUrl === env.SUPERCODE_FRONTEND_URL)
474
+ if (existing && existing.baseUrl === env.SUPERCODE_FRONTEND_URL && existing.session.connection !== 'closed') {
475
+ if (existing.session.connection !== 'connected')
476
+ await existing.session.attach();
396
477
  return existing;
478
+ }
397
479
  for (const connection of frontends.all.filter((entry) => !attachedIds.has(entry.id)))
398
480
  await frontends.disconnect(connection.id);
399
481
  const connection = await frontends.connect({ connectionId: id, baseUrl: env.SUPERCODE_FRONTEND_URL,
@@ -403,12 +485,124 @@ export async function activate(context) {
403
485
  throw new Error('Could not connect to the requested conversation.');
404
486
  return connection;
405
487
  };
488
+ let setupHandoff;
489
+ const setupHandoffComplete = () => {
490
+ const id = controls?.state.activeSession;
491
+ const connection = id ? frontends.get(id) : undefined;
492
+ return Boolean(setupHandoff && setupHandoff.id === id
493
+ && setupHandoff.baseUrl === controls?.state.connection?.SUPERCODE_FRONTEND_URL
494
+ && connection?.baseUrl === setupHandoff.baseUrl && connection.session.connection === 'connected');
495
+ };
406
496
  const revealManaged = async () => {
407
497
  const id = controls?.state.activeSession;
408
498
  if (!id || !controls?.state.openSessionCommand)
409
499
  throw new Error('The host cannot reveal this conversation.');
410
500
  await vscode.commands.executeCommand(controls.state.openSessionCommand, vscode.Uri.from({ scheme: SESSION_SCHEME, authority: id, path: '/conversation' }).toString());
501
+ const connection = frontends.get(id);
502
+ if (controls.state.activeSession !== id || !connection || connection.session.connection !== 'connected'
503
+ || connection.baseUrl !== controls.state.connection?.SUPERCODE_FRONTEND_URL) {
504
+ throw new Error('The conversation changed or disconnected before setup could reveal it.');
505
+ }
506
+ // Host readiness alone is not completion: the native reveal must succeed too.
507
+ setupHandoff = { id, baseUrl: connection.baseUrl };
508
+ };
509
+ let setupFingerprint = '';
510
+ const revealSetup = async () => {
511
+ if (!controls?.state.setup || controls.state.setup.ready || !controls.state.openSessionCommand)
512
+ return;
513
+ const fingerprint = JSON.stringify(controls.state.setup);
514
+ if (setupFingerprint === fingerprint)
515
+ return;
516
+ setupFingerprint = fingerprint;
517
+ // A changed resource avoids Code-OSS reusing cached status history after an install.
518
+ try {
519
+ await vscode.commands.executeCommand(controls.state.openSessionCommand, vscode.Uri.from({ scheme: SESSION_SCHEME, path: UNAVAILABLE_PATH, query: encodeURIComponent(fingerprint) }).toString());
520
+ }
521
+ catch (error) {
522
+ setupFingerprint = '';
523
+ throw error;
524
+ }
411
525
  };
526
+ let refreshingSetup;
527
+ const refreshSetup = () => {
528
+ refreshingSetup ??= (async () => {
529
+ if (!controls)
530
+ return;
531
+ await controls.refresh();
532
+ if (controls.state.setup?.ready) {
533
+ await connectManaged();
534
+ controlsChanged.fire();
535
+ if (!setupHandoffComplete())
536
+ await revealManaged();
537
+ }
538
+ else {
539
+ setupHandoff = undefined;
540
+ controlsChanged.fire();
541
+ await revealSetup();
542
+ }
543
+ })().finally(() => { refreshingSetup = undefined; });
544
+ return refreshingSetup;
545
+ };
546
+ const setupTerminals = new Map();
547
+ context.subscriptions.push(vscode.window.onDidCloseTerminal(terminal => {
548
+ for (const [key, value] of setupTerminals)
549
+ if (value === terminal)
550
+ setupTerminals.delete(key);
551
+ }));
552
+ context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.refreshSetup', refreshSetup));
553
+ context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.setup', async (kind, harness) => {
554
+ if (!controls)
555
+ throw new Error('This host does not expose sign-in.');
556
+ const key = `${kind}/${harness}`;
557
+ const existing = setupTerminals.get(key);
558
+ if (existing && existing.exitStatus === undefined) {
559
+ existing.show();
560
+ return;
561
+ }
562
+ await controls.refresh();
563
+ const offered = controls.state.setup?.actions.find(a => a.kind === kind && a.harness === harness);
564
+ if (!offered) {
565
+ await refreshSetup();
566
+ throw new Error('This setup action is no longer available.');
567
+ }
568
+ const action = await controls.setup(kind, harness);
569
+ if (action.command !== offered.command)
570
+ throw new Error('The setup command changed. Check again before running it.');
571
+ const terminal = vscode.window.createTerminal({ name: `${action.name} · ${kind === 'login' ? 'sign in' : 'install'}`,
572
+ cwd: action.cwd, shellPath: action.program, shellArgs: action.arguments, env: action.env });
573
+ setupTerminals.set(key, terminal);
574
+ terminal.show();
575
+ }));
576
+ context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.fillStarter', async (session, index, nativeResource) => {
577
+ if (!controls || !Number.isInteger(index) || index < 0 || index > 2)
578
+ throw new Error('Invalid starter selection.');
579
+ const connection = frontends.get(session);
580
+ if (!connection)
581
+ throw new Error(`Starter prompt unavailable: connection ${session} is absent.`);
582
+ const resource = nativeResource ? vscode.Uri.parse(nativeResource)
583
+ : vscode.Uri.from({ scheme: SESSION_SCHEME, authority: session, path: '/conversation' });
584
+ if (boundId(resource) !== session)
585
+ throw new Error('The starter has no matching conversation binding.');
586
+ await connection.session.attach();
587
+ const guard = starterEligibility(connection);
588
+ lastStarterAttempts.set(session, { at: new Date().toISOString(), resource: resource.toString(),
589
+ boundSession: boundId(resource), guard });
590
+ while (lastStarterAttempts.size > 50)
591
+ lastStarterAttempts.delete(lastStarterAttempts.keys().next().value);
592
+ if (!guard.eligible)
593
+ throw new Error(`Starter prompt unavailable: ${guard.reasons.join('; ')}.`);
594
+ const prompt = controls.state.starterPrompts?.[index]?.prompt;
595
+ if (!prompt)
596
+ throw new Error('This starter is no longer available.');
597
+ if (!controls.state.fillInputCommand)
598
+ throw new Error('This host does not support filling a conversation draft.');
599
+ // The host addresses the existing native widget, never the default participant or last focus.
600
+ const receipt = await vscode.commands.executeCommand(controls.state.fillInputCommand, resource.toString(), prompt);
601
+ if (!receipt || receipt.sessionResource !== resource.toString() || receipt.input !== prompt) {
602
+ throw new Error('Chat did not confirm the requested draft.');
603
+ }
604
+ return receipt;
605
+ }));
412
606
  const connectionFor = async (resource) => {
413
607
  if (!controls) {
414
608
  const connection = frontends.get(resource.authority);
@@ -500,6 +694,25 @@ export async function activate(context) {
500
694
  attached: (connection) => attachedIds.has(connection.id),
501
695
  catalogue: () => controls?.state.sessions ?? [],
502
696
  bindings: boundId,
697
+ setup: () => controls?.state.setup,
698
+ prepareStarters: async (session, resource, token) => {
699
+ // Project configuration can arrive after the runtime connects. Wait for
700
+ // its real answer before publishing the conversation's welcome actions.
701
+ const deadline = Date.now() + 30_000;
702
+ await controls?.refresh();
703
+ while (controls?.state.starterPromptsPending) {
704
+ if (token.isCancellationRequested)
705
+ throw new vscode.CancellationError();
706
+ if (Date.now() >= deadline)
707
+ throw new Error('Project configuration is still loading. Reopen Chat once the project is ready.');
708
+ await new Promise(resolve => setTimeout(resolve, 250));
709
+ await controls.refresh();
710
+ }
711
+ if (token.isCancellationRequested)
712
+ throw new vscode.CancellationError();
713
+ starterResources.set(session, resource);
714
+ controlsChanged.fire();
715
+ },
503
716
  };
504
717
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.activateSession', async (resource) => { await connectionFor(vscode.Uri.parse(resource)); }));
505
718
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.openSession', async (id) => {
@@ -608,6 +821,11 @@ export async function activate(context) {
608
821
  const handler = async (request, chatContext, stream, token) => {
609
822
  await applying;
610
823
  const requestedResource = chatContext.chatSessionContext?.chatSessionItem?.resource;
824
+ if (controls?.state.setup && !controls.state.setup.ready) {
825
+ for (const part of setupParts(controls.state.setup))
826
+ stream.push(part);
827
+ return {};
828
+ }
611
829
  const connection = requestedResource
612
830
  ? await connectionFor(requestedResource)
613
831
  : controls ? undefined : managed();
@@ -657,6 +875,8 @@ export async function activate(context) {
657
875
  const participant = vscode.chat.createChatParticipant(id, handler);
658
876
  participant.iconPath = icon();
659
877
  participants.push(participant);
878
+ welcomeParticipants.add(participant);
879
+ updateStarterWelcome();
660
880
  context.subscriptions.push(participant);
661
881
  }
662
882
  catch (error) {
@@ -679,6 +899,8 @@ export async function activate(context) {
679
899
  ? vscode.chat.createDynamicChatParticipant(SESSION_SCHEME, { name: current.name.replace(/[^a-zA-Z0-9]/g, ''), fullName: current.name, publisherName: 'Volter', description: 'Ask a question or describe a task' }, handler)
680
900
  : vscode.chat.createChatParticipant(SESSION_SCHEME, handler);
681
901
  participant.iconPath = icon();
902
+ welcomeParticipants.add(participant);
903
+ updateStarterWelcome();
682
904
  return participant;
683
905
  };
684
906
  let sessionParticipant = makeParticipant();
@@ -687,6 +909,7 @@ export async function activate(context) {
687
909
  if (participantKey() === previousIdentity)
688
910
  return;
689
911
  previousIdentity = participantKey();
912
+ welcomeParticipants.delete(sessionParticipant);
690
913
  sessionParticipant.dispose();
691
914
  sessionParticipant = makeParticipant();
692
915
  for (const p of participants)
@@ -788,7 +1011,16 @@ export async function activate(context) {
788
1011
  if (controls)
789
1012
  await controls.refresh();
790
1013
  if (inspect === true)
791
- return { connections: frontends.all.map(c => ({ ...frontends.info(c), permissions: [...c.session.permissions], pendingRequests: [...c.session.pendingRequests.values()] })), selection: controls?.state.selection, activeSession: controls?.state.activeSession, busy: Boolean(controls?.state.busy || frontends.all.some(c => c.session.descriptor?.turn_state === 'busy' || c.session.pendingRequests.size)), sessions: controls?.state.sessions, permission: currentOptions().permissions, identity: identity() };
1014
+ return {
1015
+ connections: frontends.all.map(c => ({ ...frontends.info(c), permissions: [...c.session.permissions],
1016
+ pendingRequests: [...c.session.pendingRequests.values()], starterEligibility: starterEligibility(c),
1017
+ lastStarterAttempt: lastStarterAttempts.get(c.id) ?? null })),
1018
+ selection: controls?.state.selection, activeSession: controls?.state.activeSession,
1019
+ busy: Boolean(controls?.state.busy || frontends.all.some(c => c.session.descriptor?.turn_state === 'busy' || c.session.pendingRequests.size)),
1020
+ setupHandoff: { hostReady: controls?.state.setup?.ready ?? null, complete: setupHandoffComplete(),
1021
+ revealed: setupHandoff ?? null },
1022
+ sessions: controls?.state.sessions, permission: currentOptions().permissions, identity: identity(),
1023
+ };
792
1024
  const rows = frontends.all.map((connection) => {
793
1025
  const info = frontends.info(connection);
794
1026
  return `${info.label} · ${info.baseUrl} · ${info.model ?? 'unknown model'} · ${info.turnState ?? 'unattached'}`;
@@ -875,6 +1107,10 @@ export async function activate(context) {
875
1107
  if (!granted.has('chatSessionsProvider'))
876
1108
  return;
877
1109
  if (controls) {
1110
+ if (controls.state.setup && !controls.state.setup.ready) {
1111
+ await revealSetup();
1112
+ return;
1113
+ }
878
1114
  try {
879
1115
  await revealManaged();
880
1116
  }
@@ -906,8 +1142,21 @@ export async function activate(context) {
906
1142
  // NOT awaited: this opens an editor, and activation must return before the
907
1143
  // workbench will run one. Awaiting it here left activation hanging and the
908
1144
  // panel on "Loading…" — observed.
909
- const opening = setTimeout(() => void openResumedSession(), 500);
1145
+ const opening = setTimeout(() => void openResumedSession().catch(error => log.warn(`Could not reveal Chat: ${String(error)}`)), 500);
910
1146
  context.subscriptions.push({ dispose: () => clearTimeout(opening) });
1147
+ // Retry until host readiness has completed both attachment AND native reveal.
1148
+ // A transient failure in either step leaves the handoff pending, even if ready=true.
1149
+ let setupTimer;
1150
+ let setupDisposed = false;
1151
+ const pollSetup = () => {
1152
+ if (setupDisposed || !controls?.state.setup || (controls.state.setup.ready && setupHandoffComplete()))
1153
+ return;
1154
+ setupTimer = setTimeout(() => {
1155
+ void refreshSetup().catch(error => log.warn(`Could not refresh sign-in: ${String(error)}`)).finally(pollSetup);
1156
+ }, 3000);
1157
+ };
1158
+ pollSetup();
1159
+ context.subscriptions.push({ dispose: () => { setupDisposed = true; clearTimeout(setupTimer); } });
911
1160
  return {
912
1161
  connect: async (options) => {
913
1162
  const connection = await frontends.connect(options, log);
@@ -1053,7 +1302,7 @@ function registerSessions(context, frontends, participant, granted, handler, con
1053
1302
  const unavailableItem = () => {
1054
1303
  if (!frontends.unavailable || frontends.all.length > 0)
1055
1304
  return undefined;
1056
- const item = controller.createChatSessionItem(unavailableResource(), 'Volter Harness · no runtime');
1305
+ const item = controller.createChatSessionItem(unavailableResource(), controls?.setup() ? 'Sign in to Chat' : 'Volter Harness · no runtime');
1057
1306
  item.description = frontends.unavailable.split('\n')[0];
1058
1307
  item.status = vscode.ChatSessionStatus.Failed;
1059
1308
  return item;
@@ -1122,10 +1371,10 @@ function registerSessions(context, frontends, participant, granted, handler, con
1122
1371
  // the session opens, with no request behind it and nothing to wait
1123
1372
  // for — which is the whole point, since the person has not typed.
1124
1373
  return {
1125
- title: 'Volter Harness · no runtime',
1374
+ title: controls?.setup() ? 'Sign in to Chat' : 'Volter Harness · no runtime',
1126
1375
  history: [
1127
1376
  new turns.ChatRequestTurn(STATUS_MARKER_PROMPT, undefined, [], participant.id, []),
1128
- new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart(unavailableMarkdown(frontends.unavailable))], {}, participant.id),
1377
+ new turns.ChatResponseTurn(controls?.setup() ? setupParts(controls.setup()) : [new vscode.ChatResponseMarkdownPart(unavailableMarkdown(frontends.unavailable))], {}, participant.id),
1129
1378
  ],
1130
1379
  requestHandler: boundHandler,
1131
1380
  };
@@ -1145,8 +1394,9 @@ function registerSessions(context, frontends, participant, granted, handler, con
1145
1394
  // `state.history`), so the copy taken when this window attached is
1146
1395
  // stale as soon as a turn runs. The generated client reuses the
1147
1396
  // already-open event stream, so this is a snapshot, not a reconnect.
1148
- await connection.session.attach().catch(() => {
1397
+ const attached = await connection.session.attach().then(() => true).catch(() => {
1149
1398
  // An unreachable runtime replays what this window last saw.
1399
+ return false;
1150
1400
  });
1151
1401
  // The attach snapshot IS the session's history: the runtime's own
1152
1402
  // canonical transcript, replayed as turns.
@@ -1184,6 +1434,12 @@ function registerSessions(context, frontends, participant, granted, handler, con
1184
1434
  }
1185
1435
  history.push(new turns.ChatResponseTurn(historyParts(oneRowPerToolCall(turn.parts ?? [])), {}, participant.id));
1186
1436
  }
1437
+ // The public API cannot withdraw a rendered history command part. Keep
1438
+ // starters in the default participant's native welcome content instead:
1439
+ // Chat hides that whole surface as soon as the first request is added.
1440
+ if (attached && history.length === 0 && !controls?.attached(connection) && controls) {
1441
+ await controls.prepareStarters(connection.id, resource.toString(), _token);
1442
+ }
1187
1443
  const busy = connection.session.descriptor?.turn_state === 'busy';
1188
1444
  return {
1189
1445
  title: connection.label,
@@ -10,6 +10,10 @@ export interface ChatChoice {
10
10
  name: string;
11
11
  description?: string;
12
12
  }
13
+ export interface ChatStarterPrompt {
14
+ label: string;
15
+ prompt: string;
16
+ }
13
17
  export interface ManagedChatSession {
14
18
  id: string;
15
19
  selection: ChatSelection;
@@ -17,11 +21,28 @@ export interface ManagedChatSession {
17
21
  title: string;
18
22
  created: number;
19
23
  }
24
+ /** Host-owned operations. The frontend displays commands before the person runs them. */
25
+ export interface ChatSetupAction {
26
+ kind: 'login' | 'install';
27
+ harness: string;
28
+ name: string;
29
+ command: string;
30
+ }
31
+ export interface ChatSetupState {
32
+ ready: boolean;
33
+ actions: ChatSetupAction[];
34
+ reason: string | null;
35
+ cwd: string;
36
+ }
20
37
  export interface HostState {
21
38
  history?: ChatMessage[];
22
39
  historyTruncated?: boolean;
23
40
  activeSession: string | null;
24
41
  openSessionCommand?: string;
42
+ /** Fill the composer of an exact native session; returns {sessionResource, input}. */
43
+ fillInputCommand?: string;
44
+ /** The native renderer supports a leading response with no visible user request. */
45
+ responseOnlyHistory?: boolean;
25
46
  sessions: ManagedChatSession[];
26
47
  selection: ChatSelection;
27
48
  connection?: Record<string, string>;
@@ -31,6 +52,10 @@ export interface HostState {
31
52
  busy: boolean;
32
53
  actualModel?: string | null;
33
54
  configurable: boolean;
55
+ setup?: ChatSetupState;
56
+ starterPrompts?: ChatStarterPrompt[];
57
+ /** The host's project configuration has not finished loading yet. */
58
+ starterPromptsPending?: boolean;
34
59
  }
35
60
  export interface HostSelectionResult extends HostState {
36
61
  connection: Record<string, string>;
@@ -45,6 +70,12 @@ export declare class ChatHostControls {
45
70
  select(selection: ChatSelection): Promise<HostSelectionResult>;
46
71
  remember(id: string, nativeId: string): Promise<void>;
47
72
  open(id: string): Promise<HostSelectionResult>;
73
+ setup(kind: ChatSetupAction['kind'], harness: string): Promise<ChatSetupAction & {
74
+ cwd: string;
75
+ program: string;
76
+ arguments: string[];
77
+ env?: Record<string, string>;
78
+ }>;
48
79
  private request;
49
80
  }
50
81
  /** Approval policy is captured for one turn, never inferred from connection privileges. */
@@ -31,6 +31,9 @@ export class ChatHostControls {
31
31
  this.state = result;
32
32
  return result;
33
33
  }
34
+ async setup(kind, harness) {
35
+ return this.request('/setup', { kind, harness });
36
+ }
34
37
  async request(path, selection) {
35
38
  const response = await fetch(this.baseUrl + path, {
36
39
  method: selection ? 'POST' : 'GET', headers: { Authorization: `Bearer ${this.credential}`, 'Content-Type': 'application/json' },
package/dist/session.d.ts CHANGED
@@ -77,6 +77,8 @@ export declare class VscodeFrontendSession {
77
77
  history: ChatMessage[];
78
78
  historyCursor: number;
79
79
  cursor: number;
80
+ /** Semantic evidence, retained even when replay is evicted or re-attached. */
81
+ hasUserTurn: boolean;
80
82
  /**
81
83
  * Every event this attachment has seen, in order — NOT just the last
82
84
  * snapshot's. A re-attach asks `afterSequence: cursor`, so its `replay` is
package/dist/session.js CHANGED
@@ -44,6 +44,8 @@ export class VscodeFrontendSession {
44
44
  history = [];
45
45
  historyCursor = 0;
46
46
  cursor = 0;
47
+ /** Semantic evidence, retained even when replay is evicted or re-attached. */
48
+ hasUserTurn = false;
47
49
  /**
48
50
  * Every event this attachment has seen, in order — NOT just the last
49
51
  * snapshot's. A re-attach asks `afterSequence: cursor`, so its `replay` is
@@ -103,6 +105,8 @@ export class VscodeFrontendSession {
103
105
  descriptor: structuredClone(snapshot.descriptor),
104
106
  pendingRequests: new Map([...this.pendingRequests].map(([id, request]) => [id, structuredClone(request)])),
105
107
  cursor: Math.max(this.cursor, snapshot.history_cursor),
108
+ hasUserTurn: this.hasUserTurn || snapshot.descriptor.turn_state === 'busy'
109
+ || snapshot.history.some(message => message.role !== 'system'),
106
110
  };
107
111
  // Validate and project the whole attachment before any observable field
108
112
  // moves or a live listener is registered.
@@ -125,6 +129,7 @@ export class VscodeFrontendSession {
125
129
  this.appendTranscript(structuredClone(snapshot.replay));
126
130
  this.pendingRequests = staged.pendingRequests;
127
131
  this.cursor = staged.cursor;
132
+ this.hasUserTurn = staged.hasUserTurn;
128
133
  this.connection = 'connected';
129
134
  this.attachedAtMs ??= Date.now();
130
135
  if (!this.#subscribed) {
@@ -252,6 +257,11 @@ export class VscodeFrontendSession {
252
257
  throw new EventSequenceGapError(state.cursor + 1, event.sequence);
253
258
  }
254
259
  state.cursor = event.sequence;
260
+ // Hosted runtimes publish user_message + turn_started when accepting input.
261
+ // Native init/status notifications also consume sequence numbers, but are
262
+ // not user turns. Keep this independent of the bounded replay buffer.
263
+ if (BUSY_KINDS.has(event.kind))
264
+ state.hasUserTurn = true;
255
265
  const payload = (event.payload ?? {});
256
266
  if (event.kind === 'request' && payload.type === 'request') {
257
267
  const request = payload.request;
package/dist/view.js CHANGED
@@ -639,6 +639,9 @@ export function projectReplayTurns(events, granted, context) {
639
639
  */
640
640
  export function projectHistory(history, granted, pendingToolNames = new Set()) {
641
641
  const turns = [];
642
+ // Keep results inside the native invocation, as the live stream does. A
643
+ // separate Markdown result bypasses the editor's collapsed tool-step group.
644
+ const toolRows = new Map();
642
645
  const pushAssistant = (parts) => {
643
646
  const last = turns[turns.length - 1];
644
647
  if (last && last.role === 'assistant' && last.parts) {
@@ -659,6 +662,7 @@ export function projectHistory(history, granted, pendingToolNames = new Set()) {
659
662
  for (const message of history) {
660
663
  const body = messageText(message);
661
664
  if (message.role === 'user') {
665
+ toolRows.clear();
662
666
  turns.push({ role: 'user', prompt: clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters) });
663
667
  continue;
664
668
  }
@@ -674,14 +678,21 @@ export function projectHistory(history, granted, pendingToolNames = new Set()) {
674
678
  pushAssistant([{ part: 'markdown', value: clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters) }]);
675
679
  }
676
680
  for (const call of message.tool_calls ?? []) {
681
+ const subject = toolSubject(call.function.arguments);
682
+ const label = clamp(stripControls(subject ? `${call.function.name} · ${subject}` : call.function.name), VSCODE_CHAT_LIMITS.titleCharacters);
683
+ const row = {
684
+ part: 'tool',
685
+ toolName: call.function.name,
686
+ toolCallId: call.id,
687
+ invocationMessage: label,
688
+ pastTenseMessage: label,
689
+ inputText: clamp(stripControls(parseArguments(call.function.arguments)), VSCODE_CHAT_LIMITS.partCharacters),
690
+ isComplete: true,
691
+ };
692
+ if (call.id)
693
+ toolRows.set(call.id, row);
677
694
  pushAssistant(resolveParts([{
678
- emit: {
679
- part: 'tool',
680
- toolName: call.function.name,
681
- toolCallId: call.id,
682
- invocationMessage: clamp(call.function.name, VSCODE_CHAT_LIMITS.titleCharacters),
683
- isComplete: true,
684
- },
695
+ emit: row,
685
696
  needs: 'chatParticipantAdditions',
686
697
  fallback: [{
687
698
  part: 'markdown',
@@ -696,10 +707,43 @@ export function projectHistory(history, granted, pendingToolNames = new Set()) {
696
707
  // interruption. Only the final matching tool is affected, never old history.
697
708
  const pending = message === history.at(-1) && pendingToolNames.has(message.name ?? '')
698
709
  && body === '[no tool result recorded — turn interrupted]';
710
+ if (pending) {
711
+ // The pinned editor serializes history tool parts as complete even if
712
+ // isComplete=false. Keep this explicit marker for matched AND orphan
713
+ // results; the active response separately restores the approval controls.
714
+ pushAssistant([{ part: 'markdown', value: '**Awaiting approval** — no tool result yet.' }]);
715
+ continue;
716
+ }
717
+ if (granted.has('chatParticipantAdditions')) {
718
+ const row = message.tool_call_id ? toolRows.get(message.tool_call_id) : undefined;
719
+ const output = clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters);
720
+ if (row) {
721
+ row.resultText = output;
722
+ // Canonical ChatMessage has no general error flag. Only this known
723
+ // synthetic marker establishes interruption; do not guess from prose.
724
+ if (body === '[no tool result recorded — turn interrupted]')
725
+ row.isError = true;
726
+ toolRows.delete(message.tool_call_id);
727
+ }
728
+ else {
729
+ // A bounded transcript can retain a result whose call was omitted.
730
+ // Preserve it as an inspectable tool row without inventing its input.
731
+ const label = clamp(stripControls(message.name || 'Tool result'), VSCODE_CHAT_LIMITS.titleCharacters);
732
+ pushAssistant([{
733
+ part: 'tool',
734
+ toolName: message.name || 'tool',
735
+ toolCallId: message.tool_call_id || `history-result-${history.indexOf(message)}`,
736
+ invocationMessage: label,
737
+ pastTenseMessage: label,
738
+ isComplete: true,
739
+ resultText: output,
740
+ }]);
741
+ }
742
+ continue;
743
+ }
699
744
  pushAssistant([{
700
745
  part: 'markdown',
701
- value: pending ? '**Awaiting approval** — no tool result yet.'
702
- : `**Tool result${message.name ? ` · ${message.name}` : ''}**\n\n${fence(clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters))}`,
746
+ value: `**Tool result${message.name ? ` · ${message.name}` : ''}**\n\n${fence(clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters))}`,
703
747
  }]);
704
748
  }
705
749
  if (turns.length <= VSCODE_CHAT_LIMITS.historyTurns)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@volter/supercode-frontend-vscode",
3
3
  "displayName": "Volter Harness",
4
- "version": "0.1.16",
4
+ "version": "0.1.17",
5
5
  "type": "module",
6
6
  "description": "VS Code chat-view frontend for an existing Volter Harness SDK runtime",
7
7
  "publisher": "volter-ai-dev",
@@ -84,7 +84,7 @@
84
84
  "description": "Conversations with your AI assistant",
85
85
  "icon": "$(server-process)",
86
86
  "welcomeTitle": "Chat",
87
- "welcomeMessage": "Ask a question or continue a saved conversation.",
87
+ "welcomeMessage": "Write a message to your coding agent.",
88
88
  "inputPlaceholder": "Ask a question or describe a task",
89
89
  "order": 1,
90
90
  "capabilities": {
@@ -191,6 +191,9 @@
191
191
  "@types/vscode": "1.138.0",
192
192
  "typescript": "5.6.3"
193
193
  },
194
+ "supercodeFrontendWelcome": {
195
+ "starterIntroduction": "Choose an idea to edit before sending:"
196
+ },
194
197
  "supercodeFrontendContract": {
195
198
  "name": "@volter/supercode-frontend",
196
199
  "version": "0.2.7",