@volter/supercode-frontend-vscode 0.1.15 → 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 +82 -0
- package/dist/chat-setup.d.ts +6 -0
- package/dist/chat-setup.js +29 -0
- package/dist/extension.js +378 -41
- package/dist/host-controls.d.ts +31 -0
- package/dist/host-controls.js +3 -0
- package/dist/session.d.ts +2 -0
- package/dist/session.js +10 -0
- package/dist/view.js +53 -9
- package/package.json +5 -2
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,13 +2,14 @@ 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';
|
|
8
9
|
import { grantedProposals, PROPOSAL_EFFECTS, REQUESTED_PROPOSALS } from './proposals.js';
|
|
9
10
|
import { observeActiveResponse, sessionStatus } from './native-session.js';
|
|
10
11
|
import { RequestNotPendingError, VscodeFrontendSession } from './session.js';
|
|
11
|
-
import {
|
|
12
|
+
import { acceptedDecisions, opaqueText, projectEvent, projectPendingRequests, projectHistory, projectReplayTurns, resolveParts, resolutionSentence, typedAnswer, } from './view.js';
|
|
12
13
|
const SESSION_SCHEME = 'supercode';
|
|
13
14
|
const PARTICIPANT_ID = 'supercode.chat';
|
|
14
15
|
const DEFAULT_PARTICIPANT_ID = 'supercode.chat.default';
|
|
@@ -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,9 +363,57 @@ 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();
|
|
384
|
+
// Runtimes attached by address ("Set Runtime Token", "Attach to Runtime")
|
|
385
|
+
// beside a host's managed conversation. The host's catalogue does not know
|
|
386
|
+
// them, so they open as what they are: attachments, shown as their runtime
|
|
387
|
+
// reports them, and never taken for the conversation the host manages.
|
|
388
|
+
const attachedIds = new Set();
|
|
389
|
+
const managed = () => frontends.all.find((connection) => !attachedIds.has(connection.id));
|
|
390
|
+
// The attached runtime the person is looking at: the Chat names its harness, not the host's.
|
|
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));
|
|
327
414
|
let approvalAvailable = false;
|
|
328
415
|
context.subscriptions.push(frontends.onDidChange(() => {
|
|
329
|
-
const active =
|
|
416
|
+
const active = managed();
|
|
330
417
|
const available = Boolean(active?.session.descriptor?.actions.respond && active.session.permissions.has('approve'));
|
|
331
418
|
if (available !== approvalAvailable) {
|
|
332
419
|
approvalAvailable = available;
|
|
@@ -334,7 +421,9 @@ export async function activate(context) {
|
|
|
334
421
|
}
|
|
335
422
|
}));
|
|
336
423
|
const identity = () => {
|
|
337
|
-
|
|
424
|
+
if (focusedAttached)
|
|
425
|
+
return runtimeIdentity(focusedAttached.session.descriptor?.source_harness, focusedAttached.session.descriptor?.model);
|
|
426
|
+
const descriptor = managed()?.session.descriptor;
|
|
338
427
|
return runtimeIdentity(controls?.state.selection.harness ?? descriptor?.source_harness, controls?.state.selection.model || controls?.state.actualModel || descriptor?.model);
|
|
339
428
|
};
|
|
340
429
|
const icon = () => {
|
|
@@ -382,9 +471,13 @@ export async function activate(context) {
|
|
|
382
471
|
throw new Error('The requested conversation has no runtime connection.');
|
|
383
472
|
const id = controls.state.activeSession;
|
|
384
473
|
const existing = frontends.get(id);
|
|
385
|
-
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();
|
|
386
477
|
return existing;
|
|
387
|
-
|
|
478
|
+
}
|
|
479
|
+
for (const connection of frontends.all.filter((entry) => !attachedIds.has(entry.id)))
|
|
480
|
+
await frontends.disconnect(connection.id);
|
|
388
481
|
const connection = await frontends.connect({ connectionId: id, baseUrl: env.SUPERCODE_FRONTEND_URL,
|
|
389
482
|
token: await readCredentialFile(env.SUPERCODE_FRONTEND_CREDENTIAL_FILE), clientId: env.SUPERCODE_FRONTEND_CLIENT_ID,
|
|
390
483
|
permissions: normalizePermissions(env.SUPERCODE_FRONTEND_PERMISSIONS, ['observe']), label: identity().name }, log);
|
|
@@ -392,12 +485,124 @@ export async function activate(context) {
|
|
|
392
485
|
throw new Error('Could not connect to the requested conversation.');
|
|
393
486
|
return connection;
|
|
394
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
|
+
};
|
|
395
496
|
const revealManaged = async () => {
|
|
396
497
|
const id = controls?.state.activeSession;
|
|
397
498
|
if (!id || !controls?.state.openSessionCommand)
|
|
398
499
|
throw new Error('The host cannot reveal this conversation.');
|
|
399
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
|
+
}
|
|
400
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
|
+
}));
|
|
401
606
|
const connectionFor = async (resource) => {
|
|
402
607
|
if (!controls) {
|
|
403
608
|
const connection = frontends.get(resource.authority);
|
|
@@ -405,10 +610,17 @@ export async function activate(context) {
|
|
|
405
610
|
throw new Error('This conversation is no longer connected.');
|
|
406
611
|
return connection;
|
|
407
612
|
}
|
|
613
|
+
const attached = attachedIds.has(resource.authority) ? frontends.get(resource.authority) : undefined;
|
|
614
|
+
if (focusedAttached !== attached) {
|
|
615
|
+
focusedAttached = attached;
|
|
616
|
+
controlsChanged.fire();
|
|
617
|
+
}
|
|
618
|
+
if (attached)
|
|
619
|
+
return attached;
|
|
408
620
|
const id = boundId(resource);
|
|
409
621
|
if (!id)
|
|
410
622
|
throw new Error('This chat has no backend session binding. Start a new chat; no message was sent.');
|
|
411
|
-
const active =
|
|
623
|
+
const active = managed();
|
|
412
624
|
if (active && active.id !== id && (active.session.descriptor?.turn_state === 'busy' || active.session.pendingRequests.size))
|
|
413
625
|
throw new Error('Finish or cancel the active turn before opening another conversation.');
|
|
414
626
|
try {
|
|
@@ -432,7 +644,7 @@ export async function activate(context) {
|
|
|
432
644
|
async function applySelection(selection, openChat = true) {
|
|
433
645
|
if (!controls)
|
|
434
646
|
throw new Error('This host does not expose harness or model configuration.');
|
|
435
|
-
const active =
|
|
647
|
+
const active = managed();
|
|
436
648
|
if (active?.session.descriptor?.turn_state === 'busy' || active?.session.pendingRequests.size)
|
|
437
649
|
throw new Error('Finish or cancel the current turn before starting a new chat.');
|
|
438
650
|
await controls.select(selection);
|
|
@@ -454,7 +666,7 @@ export async function activate(context) {
|
|
|
454
666
|
currentOptions,
|
|
455
667
|
options: (resource) => {
|
|
456
668
|
const id = resource ? boundId(resource) : undefined;
|
|
457
|
-
const active = resource ? (id ? frontends.get(id) : undefined) :
|
|
669
|
+
const active = resource ? (id ? frontends.get(id) : undefined) : managed();
|
|
458
670
|
const canApprove = active?.session.descriptor?.actions.respond && active.session.permissions.has('approve');
|
|
459
671
|
const permissionItems = [{ id: 'ask', name: canApprove ? 'Ask for approval' : 'Harness permissions', description: canApprove ? 'Show each approval requested by the harness.' : 'Approval controls are unavailable for this harness.' },
|
|
460
672
|
...((canApprove || (!active && resource && currentOptions(resource).permissions === 'autoApprove')) ? [{ id: 'autoApprove', name: 'Auto approve', description: 'Allow tool approval requests for this chat. Harness sandbox rules still apply.' }] : [])];
|
|
@@ -479,8 +691,28 @@ export async function activate(context) {
|
|
|
479
691
|
return connection;
|
|
480
692
|
},
|
|
481
693
|
resolve: connectionFor,
|
|
694
|
+
attached: (connection) => attachedIds.has(connection.id),
|
|
482
695
|
catalogue: () => controls?.state.sessions ?? [],
|
|
483
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
|
+
},
|
|
484
716
|
};
|
|
485
717
|
context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.activateSession', async (resource) => { await connectionFor(vscode.Uri.parse(resource)); }));
|
|
486
718
|
context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.openSession', async (id) => {
|
|
@@ -578,7 +810,7 @@ export async function activate(context) {
|
|
|
578
810
|
}
|
|
579
811
|
finally {
|
|
580
812
|
cancellation.dispose();
|
|
581
|
-
if (controls) {
|
|
813
|
+
if (controls && !attachedIds.has(connection.id)) {
|
|
582
814
|
if (connection.session.descriptor?.session_id)
|
|
583
815
|
await controls.remember(connection.id, connection.session.descriptor.session_id).catch(error => log.warn(`Could not save conversation identity: ${String(error)}`));
|
|
584
816
|
await controls.refresh().catch(error => log.warn(`Could not refresh chat identity: ${String(error)}`));
|
|
@@ -589,10 +821,15 @@ export async function activate(context) {
|
|
|
589
821
|
const handler = async (request, chatContext, stream, token) => {
|
|
590
822
|
await applying;
|
|
591
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
|
+
}
|
|
592
829
|
const connection = requestedResource
|
|
593
830
|
? await connectionFor(requestedResource)
|
|
594
|
-
: controls ? undefined :
|
|
595
|
-
if (controls && connection && request.model?.vendor === 'supercode-runtime') {
|
|
831
|
+
: controls ? undefined : managed();
|
|
832
|
+
if (controls && connection && !attachedIds.has(connection.id) && request.model?.vendor === 'supercode-runtime') {
|
|
596
833
|
const selection = controls.state.sessions.find(s => s.id === connection.id).selection;
|
|
597
834
|
const expected = `${selection.harness}/${encodeURIComponent(selection.model || '__default__')}`;
|
|
598
835
|
if (request.model.id !== expected || (typeof request.modelConfiguration?.reasoningEffort === 'string' && request.modelConfiguration.reasoningEffort !== selection.effort)) {
|
|
@@ -638,6 +875,8 @@ export async function activate(context) {
|
|
|
638
875
|
const participant = vscode.chat.createChatParticipant(id, handler);
|
|
639
876
|
participant.iconPath = icon();
|
|
640
877
|
participants.push(participant);
|
|
878
|
+
welcomeParticipants.add(participant);
|
|
879
|
+
updateStarterWelcome();
|
|
641
880
|
context.subscriptions.push(participant);
|
|
642
881
|
}
|
|
643
882
|
catch (error) {
|
|
@@ -652,7 +891,7 @@ export async function activate(context) {
|
|
|
652
891
|
// canDelegate is disabled to avoid a generic workbench-owned registration.
|
|
653
892
|
// A participant names the harness; the native model picker names the model.
|
|
654
893
|
// Keep the author stable when a model changes during request dispatch.
|
|
655
|
-
const participantName = () => harnessDisplayName(controls?.state.selection.harness ??
|
|
894
|
+
const participantName = () => harnessDisplayName(focusedAttached?.session.descriptor?.source_harness ?? controls?.state.selection.harness ?? managed()?.session.descriptor?.source_harness);
|
|
656
895
|
const participantKey = () => `${participantName()}/${identity().provider ?? ''}`;
|
|
657
896
|
const makeParticipant = () => {
|
|
658
897
|
const current = { name: participantName() };
|
|
@@ -660,6 +899,8 @@ export async function activate(context) {
|
|
|
660
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)
|
|
661
900
|
: vscode.chat.createChatParticipant(SESSION_SCHEME, handler);
|
|
662
901
|
participant.iconPath = icon();
|
|
902
|
+
welcomeParticipants.add(participant);
|
|
903
|
+
updateStarterWelcome();
|
|
663
904
|
return participant;
|
|
664
905
|
};
|
|
665
906
|
let sessionParticipant = makeParticipant();
|
|
@@ -668,6 +909,7 @@ export async function activate(context) {
|
|
|
668
909
|
if (participantKey() === previousIdentity)
|
|
669
910
|
return;
|
|
670
911
|
previousIdentity = participantKey();
|
|
912
|
+
welcomeParticipants.delete(sessionParticipant);
|
|
671
913
|
sessionParticipant.dispose();
|
|
672
914
|
sessionParticipant = makeParticipant();
|
|
673
915
|
for (const p of participants)
|
|
@@ -769,7 +1011,16 @@ export async function activate(context) {
|
|
|
769
1011
|
if (controls)
|
|
770
1012
|
await controls.refresh();
|
|
771
1013
|
if (inspect === true)
|
|
772
|
-
return {
|
|
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
|
+
};
|
|
773
1024
|
const rows = frontends.all.map((connection) => {
|
|
774
1025
|
const info = frontends.info(connection);
|
|
775
1026
|
return `${info.label} · ${info.baseUrl} · ${info.model ?? 'unknown model'} · ${info.turnState ?? 'unattached'}`;
|
|
@@ -792,11 +1043,16 @@ export async function activate(context) {
|
|
|
792
1043
|
log.info('Volter Harness: a base URL is configured but no token is stored; run Volter Harness: Set Runtime Token.');
|
|
793
1044
|
return;
|
|
794
1045
|
}
|
|
795
|
-
await frontends
|
|
1046
|
+
const connection = await frontends
|
|
796
1047
|
.connect({ baseUrl, token, permissions: normalizePermissions(settings.get('permissions'), ['observe']) }, log)
|
|
797
1048
|
.catch((error) => {
|
|
798
1049
|
log.error(`Volter Harness could not attach to ${baseUrl}: ${String(error?.message ?? error)}`);
|
|
1050
|
+
return undefined;
|
|
799
1051
|
});
|
|
1052
|
+
if (controls && connection) {
|
|
1053
|
+
attachedIds.add(connection.id);
|
|
1054
|
+
controlsChanged.fire();
|
|
1055
|
+
}
|
|
800
1056
|
}
|
|
801
1057
|
/**
|
|
802
1058
|
* The handover path: a runtime (or a product that launched this editor's
|
|
@@ -851,6 +1107,10 @@ export async function activate(context) {
|
|
|
851
1107
|
if (!granted.has('chatSessionsProvider'))
|
|
852
1108
|
return;
|
|
853
1109
|
if (controls) {
|
|
1110
|
+
if (controls.state.setup && !controls.state.setup.ready) {
|
|
1111
|
+
await revealSetup();
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
854
1114
|
try {
|
|
855
1115
|
await revealManaged();
|
|
856
1116
|
}
|
|
@@ -882,8 +1142,21 @@ export async function activate(context) {
|
|
|
882
1142
|
// NOT awaited: this opens an editor, and activation must return before the
|
|
883
1143
|
// workbench will run one. Awaiting it here left activation hanging and the
|
|
884
1144
|
// panel on "Loading…" — observed.
|
|
885
|
-
const opening = setTimeout(() => void openResumedSession(), 500);
|
|
1145
|
+
const opening = setTimeout(() => void openResumedSession().catch(error => log.warn(`Could not reveal Chat: ${String(error)}`)), 500);
|
|
886
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); } });
|
|
887
1160
|
return {
|
|
888
1161
|
connect: async (options) => {
|
|
889
1162
|
const connection = await frontends.connect(options, log);
|
|
@@ -903,10 +1176,79 @@ export function deactivate() {
|
|
|
903
1176
|
// Every disposable is owned by the extension context.
|
|
904
1177
|
}
|
|
905
1178
|
/**
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
1179
|
+
* A tool call reaches a turn as two parts, its start and its completion, which
|
|
1180
|
+
* a live stream draws as one row because the editor merges them by
|
|
1181
|
+
* `toolCallId`. A HISTORY turn is markdown and merges nothing, so each call
|
|
1182
|
+
* showed twice ("Tool call: exec" and again with its output). The start keeps
|
|
1183
|
+
* its place and its description (the command it ran); the completion gives it
|
|
1184
|
+
* its output and outcome.
|
|
1185
|
+
*/
|
|
1186
|
+
function oneRowPerToolCall(parts) {
|
|
1187
|
+
const rows = [];
|
|
1188
|
+
const started = new Map();
|
|
1189
|
+
for (const part of parts) {
|
|
1190
|
+
if (part.part === 'tool' && part.toolCallId) {
|
|
1191
|
+
const at = started.get(part.toolCallId);
|
|
1192
|
+
const first = at === undefined ? undefined : rows[at];
|
|
1193
|
+
if (first && first.part === 'tool' && part.isComplete) {
|
|
1194
|
+
rows[at] = {
|
|
1195
|
+
...first,
|
|
1196
|
+
...part,
|
|
1197
|
+
pastTenseMessage: first.invocationMessage ?? part.pastTenseMessage,
|
|
1198
|
+
invocationMessage: first.invocationMessage ?? part.invocationMessage,
|
|
1199
|
+
};
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
if (!part.isComplete)
|
|
1203
|
+
started.set(part.toolCallId, rows.length);
|
|
1204
|
+
}
|
|
1205
|
+
rows.push(part);
|
|
1206
|
+
}
|
|
1207
|
+
return rows;
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* A saved turn's parts, drawn as the live stream draws them. The editor
|
|
1211
|
+
* converts a history turn's response with the same converter as a live
|
|
1212
|
+
* response (`convertResponseTurn` maps every part through it), so a tool call
|
|
1213
|
+
* is the same `ChatToolInvocationPart` row it was while it ran; only the text
|
|
1214
|
+
* between tools is markdown, consecutive text joined into one part. A turn
|
|
1215
|
+
* looks the same however late the Chat was opened.
|
|
1216
|
+
*/
|
|
1217
|
+
function historyParts(parts) {
|
|
1218
|
+
const out = [];
|
|
1219
|
+
let text = [];
|
|
1220
|
+
const flush = () => {
|
|
1221
|
+
if (text.length)
|
|
1222
|
+
out.push(new vscode.ChatResponseMarkdownPart(text.join('\n\n')));
|
|
1223
|
+
text = [];
|
|
1224
|
+
};
|
|
1225
|
+
for (const part of parts) {
|
|
1226
|
+
if (part.part === 'tool') {
|
|
1227
|
+
flush();
|
|
1228
|
+
const toolName = part.toolName || 'tool';
|
|
1229
|
+
const invocation = new vscode.ChatToolInvocationPart(toolName, part.toolCallId);
|
|
1230
|
+
invocation.invocationMessage = part.invocationMessage || toolName;
|
|
1231
|
+
invocation.pastTenseMessage = part.pastTenseMessage || part.invocationMessage || toolName;
|
|
1232
|
+
invocation.isConfirmed = true;
|
|
1233
|
+
invocation.isComplete = part.isComplete;
|
|
1234
|
+
if (part.isError !== undefined)
|
|
1235
|
+
invocation.isError = part.isError;
|
|
1236
|
+
if (part.isComplete) {
|
|
1237
|
+
invocation.toolSpecificData = { input: part.inputText ?? '', output: part.resultText ?? '' };
|
|
1238
|
+
}
|
|
1239
|
+
out.push(invocation);
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
const value = historyText(part);
|
|
1243
|
+
if (value)
|
|
1244
|
+
text.push(value);
|
|
1245
|
+
}
|
|
1246
|
+
flush();
|
|
1247
|
+
return out.length ? out : [new vscode.ChatResponseMarkdownPart('')];
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* One text part as the markdown a HISTORY turn carries (tool calls are drawn
|
|
1251
|
+
* as tool rows by `historyParts`).
|
|
910
1252
|
*/
|
|
911
1253
|
function historyText(part) {
|
|
912
1254
|
switch (part.part) {
|
|
@@ -921,17 +1263,9 @@ function historyText(part) {
|
|
|
921
1263
|
return part.value;
|
|
922
1264
|
case 'reference':
|
|
923
1265
|
return `\`${part.path}\``;
|
|
924
|
-
case 'tool':
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
// line plus its output, fenced.
|
|
928
|
-
const summary = part.pastTenseMessage ?? part.invocationMessage ?? part.toolName;
|
|
929
|
-
if (!summary)
|
|
930
|
-
return '';
|
|
931
|
-
const verb = part.isError ? 'Tool failed:' : 'Tool call:';
|
|
932
|
-
const result = part.resultText ? `\n\n${fence(part.resultText)}` : '';
|
|
933
|
-
return `${verb} \`${summary}\`${result}`;
|
|
934
|
-
}
|
|
1266
|
+
case 'tool':
|
|
1267
|
+
// Drawn as a tool row by `historyParts`.
|
|
1268
|
+
return '';
|
|
935
1269
|
case 'answers':
|
|
936
1270
|
return `**${part.title}**\n\n${part.message}`;
|
|
937
1271
|
}
|
|
@@ -968,7 +1302,7 @@ function registerSessions(context, frontends, participant, granted, handler, con
|
|
|
968
1302
|
const unavailableItem = () => {
|
|
969
1303
|
if (!frontends.unavailable || frontends.all.length > 0)
|
|
970
1304
|
return undefined;
|
|
971
|
-
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');
|
|
972
1306
|
item.description = frontends.unavailable.split('\n')[0];
|
|
973
1307
|
item.status = vscode.ChatSessionStatus.Failed;
|
|
974
1308
|
return item;
|
|
@@ -1037,10 +1371,10 @@ function registerSessions(context, frontends, participant, granted, handler, con
|
|
|
1037
1371
|
// the session opens, with no request behind it and nothing to wait
|
|
1038
1372
|
// for — which is the whole point, since the person has not typed.
|
|
1039
1373
|
return {
|
|
1040
|
-
title: 'Volter Harness · no runtime',
|
|
1374
|
+
title: controls?.setup() ? 'Sign in to Chat' : 'Volter Harness · no runtime',
|
|
1041
1375
|
history: [
|
|
1042
1376
|
new turns.ChatRequestTurn(STATUS_MARKER_PROMPT, undefined, [], participant.id, []),
|
|
1043
|
-
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),
|
|
1044
1378
|
],
|
|
1045
1379
|
requestHandler: boundHandler,
|
|
1046
1380
|
};
|
|
@@ -1051,7 +1385,7 @@ function registerSessions(context, frontends, participant, granted, handler, con
|
|
|
1051
1385
|
if (!connection) {
|
|
1052
1386
|
return { history: [], requestHandler: boundHandler };
|
|
1053
1387
|
}
|
|
1054
|
-
if (controls) {
|
|
1388
|
+
if (controls && !controls.attached(connection)) {
|
|
1055
1389
|
controls.setPermission(resource, inputState.groups.find(group => group.id === 'permissions')?.selected?.id);
|
|
1056
1390
|
inputState.groups = controls.options(resource);
|
|
1057
1391
|
}
|
|
@@ -1060,8 +1394,9 @@ function registerSessions(context, frontends, participant, granted, handler, con
|
|
|
1060
1394
|
// `state.history`), so the copy taken when this window attached is
|
|
1061
1395
|
// stale as soon as a turn runs. The generated client reuses the
|
|
1062
1396
|
// already-open event stream, so this is a snapshot, not a reconnect.
|
|
1063
|
-
await connection.session.attach().catch(() => {
|
|
1397
|
+
const attached = await connection.session.attach().then(() => true).catch(() => {
|
|
1064
1398
|
// An unreachable runtime replays what this window last saw.
|
|
1399
|
+
return false;
|
|
1065
1400
|
});
|
|
1066
1401
|
// The attach snapshot IS the session's history: the runtime's own
|
|
1067
1402
|
// canonical transcript, replayed as turns.
|
|
@@ -1097,16 +1432,18 @@ function registerSessions(context, frontends, participant, granted, handler, con
|
|
|
1097
1432
|
history.push(new turns.ChatRequestTurn(turn.prompt ?? '', undefined, [], participant.id, []));
|
|
1098
1433
|
continue;
|
|
1099
1434
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1435
|
+
history.push(new turns.ChatResponseTurn(historyParts(oneRowPerToolCall(turn.parts ?? [])), {}, participant.id));
|
|
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);
|
|
1105
1442
|
}
|
|
1106
1443
|
const busy = connection.session.descriptor?.turn_state === 'busy';
|
|
1107
1444
|
return {
|
|
1108
1445
|
title: connection.label,
|
|
1109
|
-
options: controls?.currentOptions(resource),
|
|
1446
|
+
options: controls?.attached(connection) ? undefined : controls?.currentOptions(resource),
|
|
1110
1447
|
history,
|
|
1111
1448
|
requestHandler: boundHandler,
|
|
1112
1449
|
activeResponseCallback: busy
|
package/dist/host-controls.d.ts
CHANGED
|
@@ -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. */
|
package/dist/host-controls.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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": "
|
|
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",
|