From 16284a3bfc647f751ed7f3aaa6bcc590b28ad20e Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 24 Sep 2026 11:31:30 +0800 Subject: [PATCH 1/5] feat(workspace): keep the probed engine version on the overlay The overlay records the engine version it probed: the version it attached with, or the too-old version it refused; null when the engine is missing. The attach snapshot reads it to say which engine served the session. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0172qrhMa5TQgETASi5hxMqD --- packages/opencode/src/altimate/workspace/engine-overlay.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 806e1fb81..bf69b03bc 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -137,6 +137,8 @@ type Overlay = { /** The derived entry, or null when the engine is unusable. */ entry: LocalMcpConfig | null refusal: Extract | null + /** The probed engine version when the engine ran; null when it is missing. */ + version: string | null } /** Per-directory state. Config and MCP state are per project instance, and one @@ -248,7 +250,7 @@ export async function overlay( const entry = engineEntry(workspace.id) config.mcp ??= {} config.mcp[DATAMATE_KEY] = entry - state.current = { directory, workspace, entry, refusal: null } + state.current = { directory, workspace, entry, refusal: null, version: probe.version } log.info("workspace engine overlay applied", { workspaceId: workspace.id, version: probe.version }) return } @@ -262,6 +264,7 @@ export async function overlay( workspace, entry: null, refusal: probe.kind === "missing" ? { kind: "engine-missing" } : { kind: "engine-too-old", found: probe.found }, + version: probe.kind === "missing" ? null : probe.found, } log.info("workspace engine overlay refused", { workspaceId: workspace.id, reason: probe.kind }) } catch (err) { From bfec7bfbb2c65a9793e4943522bd2323acc17bc4 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Tue, 15 Sep 2026 01:09:27 +0800 Subject: [PATCH 2/5] feat(workspace): one-line attach toast, and a /workspace Status view for the detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attach toast carried the whole engine report — every undelivered key grouped by reason, with the engine's detail — and on a workspace with a few gaps it read as noise (review of the first cut, #1311). Numbers belong in the toast; the keys and reasons belong somewhere they can be read again. - The toast says `2 of 9 integration tools available · 7 need attention. Details: /workspace` and nothing more; extension tools a live bridge serves add `· N more via VS Code`. `describeMissing` / `describeExtensionServed` go; `reasonPhrase` keeps the wording for the view. - The overlay keeps an attach snapshot per directory — workspace, engine version, the allowlist, what the engine served, its report — in memory and in `altimate-attach-snapshots.json` under the state directory, because the TUI plugin runs in another process than the overlay (the same reason the binding cache is a file). Bounded to 64 directories; a test seam keeps the suite out of the real state directory. - `status-view.ts`, transport-agnostic like `manage.ts`: the snapshot joined to the workspace's selection and the catalog, one row per integration — served / partial / missing with reasons / idle for an extension without a window — attention first, keys beyond the allowlist as extras, and the headline the toast, the menu row and the sidebar share. - `/workspace` gains a Status row (its description is the headline, read from the snapshot so the menu opens without a network call) that opens the view: one row per integration with counts and the reason, the keys and reasons as the row's footer, and "Open on the web" / "Re-read" / "Done" as action rows. A snapshot from a workspace this project was since re-linked away from is ignored. - The sidebar's Workspace tile shows the headline under the name once a session has attached, with the same staleness guard. Tests: the snapshot file round-trip, cap and corrupt-file recovery; the status view (rows, ordering, name fallback, counts matching the toast, partial and bridged states, reports for integrations no longer selected, no allowlist); the overlay's toast wording, snapshot and persistence; the e2e engine report checked through `reasonPhrase`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../src/altimate/workspace/attach-snapshot.ts | 83 +++++++ .../src/altimate/workspace/engine-overlay.ts | 62 ++++- .../src/altimate/workspace/engine-seams.ts | 3 + .../src/altimate/workspace/engine-types.ts | 46 +--- .../src/altimate/workspace/status-view.ts | 211 ++++++++++++++++++ .../plugin/tui/altimate/workspace-sidebar.tsx | 94 +++++--- .../src/plugin/tui/altimate/workspace.tsx | 209 +++++++++++------ .../workspace/attach-snapshot.test.ts | 59 +++++ .../altimate/workspace/engine-overlay.test.ts | 53 +++-- .../altimate/workspace/engine-types.test.ts | 53 +---- .../altimate/workspace/status-view.test.ts | 98 ++++++++ .../test/mcp/engine-unfulfilled.e2e.test.ts | 16 +- 12 files changed, 768 insertions(+), 219 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/attach-snapshot.ts create mode 100644 packages/opencode/src/altimate/workspace/status-view.ts create mode 100644 packages/opencode/test/altimate/workspace/attach-snapshot.test.ts create mode 100644 packages/opencode/test/altimate/workspace/status-view.test.ts diff --git a/packages/opencode/src/altimate/workspace/attach-snapshot.ts b/packages/opencode/src/altimate/workspace/attach-snapshot.ts new file mode 100644 index 000000000..938713b12 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/attach-snapshot.ts @@ -0,0 +1,83 @@ +// altimate_change - new file +// +// What the last attach in a directory produced, on disk. The overlay settles +// an attach inside the server process; the TUI plugin (the `/workspace` +// menu, the sidebar tile) runs in another, so the memory the overlay keeps is +// invisible to it — the same reason the binding cache lives in a file. One +// small JSON under the state directory, keyed by project directory, latest +// attach per directory, bounded. +import path from "node:path" +import { chmodSync, existsSync, readFileSync } from "node:fs" +import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" +import { Log } from "@/altimate/util/log" +import type { Declared, Unfulfilled } from "./engine-types" + +const log = Log.create({ service: "altimate-workspace-attach-snapshot" }) + +export interface AttachSnapshot { + workspace: { id: string; name: string } + engineVersion: string | null + /** The allowlist the workspace declared, split like `Declared`; null when + * the lookup failed and the engine was taken at its word. */ + declared: Declared | null + /** Every key the engine served under the workspace key, allowlisted or not. */ + present: string[] + /** The engine's full report; undefined when it sent none. */ + unfulfilled: Unfulfilled[] | undefined + /** Extension-declared keys a live IDE bridge served. */ + extServed: number + at: number +} + +interface SnapshotFile { + version: 1 + snapshots: Record +} + +/** Enough for a machine's worth of projects; the oldest go first. */ +const MAX_SNAPSHOTS = 64 + +export function snapshotPath(): string { + return path.join(Global.Path.state, "altimate-attach-snapshots.json") +} + +function readFile(): SnapshotFile | null { + const p = snapshotPath() + if (!existsSync(p)) return null + try { + const raw = JSON.parse(readFileSync(p, "utf8")) as Partial | null + if (!raw || raw.version !== 1 || typeof raw.snapshots !== "object" || raw.snapshots === null) return null + return raw as SnapshotFile + } catch (err) { + log.warn("attach snapshot file is corrupt, discarding", { code: (err as NodeJS.ErrnoException)?.code }) + return null + } +} + +/** Best-effort, like every write to the state directory: a read-only home + * must not turn a successful attach into a failure. */ +export function writeAttachSnapshot(directory: string, snapshot: AttachSnapshot): void { + try { + const file = readFile() ?? { version: 1, snapshots: {} } + file.snapshots[path.resolve(directory)] = snapshot + const entries = Object.entries(file.snapshots) + if (entries.length > MAX_SNAPSHOTS) { + entries.sort((a, b) => a[1].at - b[1].at) + file.snapshots = Object.fromEntries(entries.slice(entries.length - MAX_SNAPSHOTS)) + } + const p = snapshotPath() + Filesystem.writeJsonAtomic(p, file) + try { + chmodSync(p, 0o600) + } catch { + // Umask permissions until the next write; the file holds tool keys, not credentials. + } + } catch (err) { + log.warn("could not write the attach snapshot", { err: String(err) }) + } +} + +export function readAttachSnapshot(directory: string): AttachSnapshot | undefined { + return readFile()?.snapshots[path.resolve(directory)] +} diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index bf69b03bc..80424b8f0 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -44,8 +44,6 @@ import { REPAIRABLE, TOOL_PREFIX, clearsFloor, - describeExtensionServed, - describeMissing, parseUnfulfilled, reportedMissing, describeRefusal, @@ -59,6 +57,7 @@ import { type Outcome, type Toast, } from "./engine-types" +import { readAttachSnapshot, writeAttachSnapshot, type AttachSnapshot } from "./attach-snapshot" export * from "./engine-types" export * from "./engine-offer" @@ -313,6 +312,19 @@ const declaredCache = new Map() /** Verdict signatures a headless process has already printed to stderr. */ const headlessPrinted = new Set() +/** What the last attach in a directory produced, kept for the surfaces that + * describe it after the fact — the sidebar tile and the `/workspace` status + * view. In memory for this process, and on disk for the TUI process, which + * is where those surfaces run (see `attach-snapshot.ts`). */ +const lastAttach = new Map() + +/** The last attach snapshot for a directory: this process's, else the one on + * disk, else undefined before any session has settled there. */ +export function attachSnapshot(directory: string | null = currentDirectory()): AttachSnapshot | undefined { + if (directory === null) return undefined + return lastAttach.get(directory) ?? readAttachSnapshot(directory) +} + function record(sessionID: string, outcome: Outcome): SessionRecord { const previous = sessions.get(sessionID) sessions.delete(sessionID) @@ -705,6 +717,17 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS ...(declared?.extensions?.length ? { extensions: declared.extensions } : {}), } const rec = record(sessionID, outcome) + const snapshot: AttachSnapshot = { + workspace: { id: workspace.id, name: workspace.name }, + engineVersion: overlayNow.version, + declared, + present: [...present], + unfulfilled, + extServed, + at: now(), + } + lastAttach.set(directory, snapshot) + ;(syncInternals.persistSnapshot ?? writeAttachSnapshot)(directory, snapshot) // Keyed on the workspace too: a re-link with an identical inventory is still // a new verdict the user should hear. // extServed is part of what the user hears, so it is part of the signature: @@ -723,16 +746,42 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS unfulfilled, }) if (isHeadless()) return - const headline = declared - ? `${served} of ${declared.keys.length} declared integration tools available.` - : `${outcome.available} integration tools available.` + // Numbers only. The keys and their reasons live in the `/workspace` status + // view, which the toast points at; a toast that tried to carry them read as + // noise (review of the first cut). await notify({ title: `Workspace "${workspace.name}"`, - message: `${headline}${describeMissing(missingReport ?? [])}${describeExtensionServed(extServed)}`, + message: attachSummary({ + served, + declared: declared?.keys.length, + available: outcome.available, + gaps: missingReport?.length ?? 0, + extServed, + }), variant: missingReport !== undefined && missingReport.length > 0 ? "warning" : "info", }) } +/** The one line a settled attach is announced with: counts, then where the + * detail is. `declared` undefined means no allowlist was readable, so only + * what the engine serves can be counted. */ +export function attachSummary(input: { + served: number + declared: number | undefined + available: number + gaps: number + extServed: number +}): string { + const parts = [ + input.declared === undefined + ? `${input.available} integration tools available` + : `${input.served} of ${input.declared} integration tools available`, + ] + if (input.gaps > 0) parts.push(`${input.gaps} need attention`) + if (input.extServed > 0) parts.push(`${input.extServed} more via VS Code`) + return `${parts.join(" · ")}. Details: /workspace` +} + /** Tell the session about a refusal, once per unchanged verdict. * * The substitution point for the install offer: when installing would help @@ -802,6 +851,7 @@ export function isRepairable(outcome: Outcome | undefined): boolean { /** Test-only: forget everything this process learned. */ export function resetForTests(): void { + lastAttach.clear() directories.clear() probeMemo = null sessions.clear() diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index ef9ccc8a4..3d2f52578 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -6,6 +6,7 @@ import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" import type { CachedBinding } from "./state" +import type { AttachSnapshot } from "./attach-snapshot" import type { Declared, LocalMcpConfig, McpEntry, McpStatus, Toast } from "./engine-types" import type { EngineOffer, InstallResult } from "./engine-offer" @@ -45,6 +46,8 @@ export const syncInternals: { headless?: () => boolean serve?: () => boolean now?: () => number + /** Tests keep the attach snapshot out of the real state directory. */ + persistSnapshot?: (directory: string, snapshot: AttachSnapshot) => void mcp?: { status: () => Promise add: (name: string, cfg: LocalMcpConfig | McpEntry) => Promise diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 3e34eec62..a15012b68 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -294,48 +294,10 @@ const REASON_PHRASE: Record = { "no-bridge": "needs a VS Code window", } -const MISSING_SHOWN = 5 -const DETAIL_CHARS = 60 - -/** The gaps, grouped by reason AND integration in report order, at most - * `MISSING_SHOWN` keys across the groups; a group's first detail (the engine's - * error text, e.g. `spawn docker ENOENT`) stands for the group. Grouped per - * integration so one integration's error is never printed as another's — two - * servers that both failed to start failed for their own reasons. (multi-model review) */ -export function describeMissing(missing: Unfulfilled[]): string { - if (missing.length === 0) return "" - const groups = new Map() - for (const u of missing) { - const id = `${u.reason}${u.integrationId}` - const group = groups.get(id) ?? { reason: u.reason, keys: [] } - group.keys.push(u.key) - if (group.detail === undefined && u.detail) group.detail = u.detail - groups.set(id, group) - } - let budget = MISSING_SHOWN - const parts: string[] = [] - for (const { reason, ...group } of groups.values()) { - if (budget <= 0) break - const shown = group.keys.slice(0, budget) - budget -= shown.length - const phrase = (REASON_PHRASE as Record)[reason] ?? reason - const detail = group.detail === undefined ? "" : ` (${truncate(group.detail, DETAIL_CHARS)})` - parts.push(`${phrase}${detail}: ${shown.join(", ")}`) - } - const more = missing.length > MISSING_SHOWN ? ` (+${missing.length - MISSING_SHOWN} more)` : "" - return ` Declared but not available — ${parts.join("; ")}${more}.` -} - -function truncate(text: string, max: number): string { - return text.length <= max ? text : `${text.slice(0, max - 1)}…` -} - -/** Extension-declared tools a connected IDE bridge is actually serving. Zero - * is the normal no-IDE case and says nothing — absent extension tools are - * expected, not missing, so they never join `describeMissing`. */ -export function describeExtensionServed(count: number): string { - if (count === 0) return "" - return ` Plus ${count} extension tool${count === 1 ? "" : "s"} via the connected VS Code window.` +/** A reason in the user's words. An unknown reason (a newer engine) is shown + * verbatim rather than dropped. */ +export function reasonPhrase(reason: string): string { + return (REASON_PHRASE as Record)[reason] ?? reason } /** What each outcome MEANS, as tables over the whole union: a new variant diff --git a/packages/opencode/src/altimate/workspace/status-view.ts b/packages/opencode/src/altimate/workspace/status-view.ts new file mode 100644 index 000000000..d9b10dbb4 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/status-view.ts @@ -0,0 +1,211 @@ +// altimate_change - new file +// +// What the last session got from its workspace, per integration — the view +// behind `/workspace` → Status and the sidebar's counts line. Built from the +// overlay's attach snapshot (what the engine served and what it reported it +// could not) joined to the workspace's own selection and the catalog (which +// integration each key belongs to, and its display name). +// +// TRANSPORT-AGNOSTIC, like `manage.ts`: plain data in, plain data out, no TUI +// or CLI imports, nothing printed. The dialog and the sidebar render it; a +// headless route could serve it as is. +import { AltimateApi } from "@/altimate/api/client" +import { Log } from "@/altimate/util/log" +import { attachSnapshot } from "./engine-overlay" +import type { AttachSnapshot } from "./attach-snapshot" +import { reasonPhrase, type Unfulfilled } from "./engine-types" + +const log = Log.create({ service: "altimate-workspace-status" }) + +export interface Gap { + key: string + reason: string + /** The reason in the user's words. */ + phrase: string + detail?: string +} + +/** One integration the workspace declared, and how much of it this session got. */ +export interface IntegrationRow { + id: string + name: string + /** `served`: every declared key present. `partial`: some. `missing`: none, + * with reasons. `idle`: an extension integration with no IDE bridge — expected + * without a VS Code window, not a gap. */ + state: "served" | "partial" | "missing" | "idle" + extension: boolean + declared: string[] + served: string[] + gaps: Gap[] +} + +export interface StatusView { + workspace: { id: string; name: string } + engineVersion: string | null + /** Declared keys present, over declared keys — the same pair the toast says. */ + served: number + declared: number | undefined + /** Gaps the engine reported, excluding the expected no-bridge case. */ + gaps: number + extServed: number + at: number + rows: IntegrationRow[] + /** Keys the engine served beyond the allowlist (knowledge, memory). */ + extras: string[] +} + +interface SelectionIntegration { + id: string + tools?: { key: string }[] +} +interface CatalogEntry { + id: string + name: string + type?: string +} + +/** Join the snapshot to the selection and the catalog. Pure. A key the engine + * reported for an integration the selection no longer lists still gets a row, + * named by its id, so a report is never silently dropped. */ +export function buildStatusView( + snapshot: AttachSnapshot, + selection: SelectionIntegration[], + catalog: CatalogEntry[], +): StatusView { + const byId = new Map(catalog.map((c) => [String(c.id), c])) + const present = new Set(snapshot.present) + const reported = new Map() + for (const u of snapshot.unfulfilled ?? []) { + const list = reported.get(u.integrationId) ?? [] + list.push(u) + reported.set(u.integrationId, list) + } + const rows: IntegrationRow[] = [] + const declaredKeys = new Set() + const seen = new Set() + for (const integration of selection) { + const id = String(integration.id) + seen.add(id) + const entry = byId.get(id) + const declared = (integration.tools ?? []).map((t) => t.key) + for (const k of declared) declaredKeys.add(k) + const served = declared.filter((k) => present.has(k)) + const gaps = toGaps(reported.get(id) ?? []) + const extension = entry?.type === "extension" + rows.push({ + id, + name: entry?.name ?? `Integration ${id}`, + extension, + declared, + served, + gaps, + state: rowState({ declared, served, gaps, extension }), + }) + } + // Reported for an integration the selection does not carry: keep it visible. + for (const [id, list] of reported) { + if (seen.has(id)) continue + const gaps = toGaps(list) + rows.push({ + id, + name: byId.get(id)?.name ?? `Integration ${id}`, + extension: byId.get(id)?.type === "extension", + declared: list.map((u) => u.key), + served: [], + gaps, + state: gaps.length > 0 ? "missing" : "idle", + }) + } + rows.sort(byAttention) + const extras = snapshot.present.filter((k) => !declaredKeys.has(k)).sort() + const declaredCount = snapshot.declared?.keys.length + const served = snapshot.declared ? snapshot.declared.keys.filter((k) => present.has(k)).length : present.size + const gapCount = (snapshot.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + return { + workspace: snapshot.workspace, + engineVersion: snapshot.engineVersion, + served, + declared: declaredCount, + gaps: gapCount, + extServed: snapshot.extServed, + at: snapshot.at, + rows, + extras, + } +} + +function toGaps(list: Unfulfilled[]): Gap[] { + return list + .filter((u) => u.reason !== "no-bridge") + .map((u) => ({ + key: u.key, + reason: u.reason, + phrase: reasonPhrase(u.reason), + ...(u.detail ? { detail: u.detail } : {}), + })) +} + +function rowState(row: { + declared: string[] + served: string[] + gaps: Gap[] + extension: boolean +}): IntegrationRow["state"] { + if (row.declared.length > 0 && row.served.length === row.declared.length) return "served" + if (row.served.length > 0) return "partial" + if (row.gaps.length > 0) return "missing" + // Nothing served and nothing reported wrong: an extension waiting for its + // window, or an integration the engine had nothing to say about. + return row.extension ? "idle" : row.declared.length === 0 ? "served" : "idle" +} + +/** Rows that need attention first, then partial, then served, then idle. */ +const ORDER: Record = { missing: 0, partial: 1, served: 2, idle: 3 } +function byAttention(a: IntegrationRow, b: IntegrationRow): number { + return ORDER[a.state] - ORDER[b.state] || a.name.localeCompare(b.name) +} + +/** The headline the dialog and the sidebar share: counts only. */ +export function statusHeadline(view: Pick): string { + const parts = [ + view.declared === undefined + ? `${view.served} integration tools available` + : `${view.served} of ${view.declared} integration tools available`, + ] + if (view.gaps > 0) parts.push(`${view.gaps} need attention`) + if (view.extServed > 0) parts.push(`${view.extServed} more via VS Code`) + return parts.join(" · ") +} + +/** One line for a row: counts and, when something is wrong, why. */ +export function rowLine(row: IntegrationRow): string { + const counts = row.declared.length > 0 ? `${row.served.length} of ${row.declared.length}` : `${row.served.length}` + if (row.state === "idle") return `${counts} · needs a VS Code window open on this project` + if (row.gaps.length === 0) return counts + const phrases = [...new Set(row.gaps.map((g) => g.phrase))] + const detail = row.gaps.find((g) => g.detail)?.detail + return `${counts} · ${phrases.join("; ")}${detail ? ` (${detail})` : ""}` +} + +/** Load the view for a directory: the snapshot from memory, the selection and + * the catalog from the API. Null when no session has attached there yet; the + * snapshot alone (rows named by id) when the API cannot be reached, so a + * network blip does not hide what the session already knows. */ +export async function loadStatusView(directory: string): Promise { + const snapshot = attachSnapshot(directory) + if (!snapshot) return null + try { + const [workspace, catalog] = await Promise.all([ + AltimateApi.getDatamate(snapshot.workspace.id), + AltimateApi.listIntegrations(), + ]) + return buildStatusView( + snapshot, + (workspace.integrations ?? []).map((i) => ({ id: String(i.id), tools: i.tools })), + catalog.map((c) => ({ id: String(c.id), name: c.name ?? `Integration ${c.id}`, type: c.type })), + ) + } catch (err) { + log.warn("could not load the workspace selection for the status view", { err: String(err) }) + return buildStatusView(snapshot, [], []) + } +} diff --git a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx index 5262c5e8b..4b5f8ce66 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx @@ -15,6 +15,10 @@ import * as Manage from "@/altimate/workspace/manage" // altimate_change end import { buildManageUrl, resolveWorkspaceWebUrl } from "@/altimate/workspace/browser-handoff" import { getResolvedWorkspaceId } from "@/altimate/workspace/session-context" +// altimate_change start - counts from the last attach under the workspace name +import { attachSnapshot } from "@/altimate/workspace/engine-overlay" +import { statusHeadline } from "@/altimate/workspace/status-view" +// altimate_change end import { AltimateApi } from "@/altimate/api/client" import { openManageUrl } from "./workspace" @@ -80,6 +84,20 @@ function View(props: { api: TuiPluginApi }) { // altimate_change start - status lines const [detail, setDetail] = createSignal(null) // altimate_change end + // altimate_change start - what the last session got, in numbers + const [attachLine, setAttachLine] = createSignal(null) + const readAttachLine = (bound: CachedBinding | null) => { + const snapshot = attachSnapshot(props.api.state.path.directory) + // Only for the workspace this project is bound to now; a snapshot from a + // previous binding would describe the wrong workspace under this name. + if (!snapshot || !bound || snapshot.workspace.id !== String(bound.datamateId)) return setAttachLine(null) + const present = new Set(snapshot.present) + const declared = snapshot.declared?.keys.length + const served = snapshot.declared ? snapshot.declared.keys.filter((k) => present.has(k)).length : present.size + const gaps = (snapshot.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + setAttachLine(statusHeadline({ served, declared, gaps, extServed: snapshot.extServed, rows: [] })) + } + // altimate_change end let refreshInFlight = false let refreshQueued = false @@ -189,6 +207,9 @@ function View(props: { api: TuiPluginApi }) { setBinding(null) } const b = binding() + // altimate_change start - what the last session got, in numbers + readAttachLine(b ?? null) + // altimate_change end // No clear here: every path that reaches this with no binding has already // cleared the manage URL, or never set one. if (!b) return @@ -271,43 +292,30 @@ function View(props: { api: TuiPluginApi }) { {(b) => ( <> {/* Clicking the name (or the URL line below) opens the workspace - * in the browser — the manage URL is deterministic from tenant - * + id (see resolveManageBase above), so there's no extra - * round-trip before it's clickable. The whole line is the click - * target (mouse events only land on block-level ``/``, - * not inline ``/`` nodes), while only the name itself - * is styled to look like a link — matching the footer's docs/ - * community links (sidebar/footer.tsx), which use the same - * span-style + onMouseUp pair because raw `` hyperlink - * nodes crash in this JSX layer. ``onMouseUp`` is omitted - * entirely (not just a no-op) when there's no URL yet, so the - * name never advertises a click target that does nothing. The - * "pinned via --workspace" hint lives on its own line below - * (rather than appended inline here) so the click region - * doesn't extend over text that isn't part of the link — same - * reasoning as the URL line already being separate. (multi-model - * review, PR #1274.) */} - openManageUrl(props.api, manageUrl()!) : undefined}> + * in the browser — the manage URL is deterministic from tenant + * + id (see resolveManageBase above), so there's no extra + * round-trip before it's clickable. The whole line is the click + * target (mouse events only land on block-level ``/``, + * not inline ``/`` nodes), while only the name itself + * is styled to look like a link — matching the footer's docs/ + * community links (sidebar/footer.tsx), which use the same + * span-style + onMouseUp pair because raw `` hyperlink + * nodes crash in this JSX layer. ``onMouseUp`` is omitted + * entirely (not just a no-op) when there's no URL yet, so the + * name never advertises a click target that does nothing. The + * "pinned via --workspace" hint lives on its own line below + * (rather than appended inline here) so the click region + * doesn't extend over text that isn't part of the link — same + * reasoning as the URL line already being separate. (multi-model + * review, PR #1274.) */} + openManageUrl(props.api, manageUrl()!) : undefined} + > {(_u) => {b().datamateName}} - {/* ``pinned via --workspace`` means "this SESSION was launched - * with --workspace and it resolved to this id". It does NOT - * mean "the current binding was set by --workspace" — if the - * user relinks mid-session to a different workspace, the pin - * disappears (id mismatch); if they relink to the same id, - * the pin correctly stays because the launch fact is - * unchanged. Known imprecision: relink-to-same-id looks - * indistinguishable from "never relinked". Accepted per - * altimate-harness-bot round 8 (option b of the review). - * ``getResolvedWorkspaceId`` returns null when the launch - * had no --workspace flag or the flag failed to resolve, - * so the pin never falsely appears for a session that - * wasn't launched with the flag. */} - - (pinned via --workspace) - {/* altimate_change start - status lines: what has drifted, so the * reason to run `/workspace` is visible before you need it. */} @@ -322,6 +330,26 @@ function View(props: { api: TuiPluginApi }) { {(at) => {`skills synced ${describeAge(at())}`}} {/* altimate_change end */} + {/* ``pinned via --workspace`` means "this SESSION was launched + * with --workspace and it resolved to this id". It does NOT + * mean "the current binding was set by --workspace" — if the + * user relinks mid-session to a different workspace, the pin + * disappears (id mismatch); if they relink to the same id, + * the pin correctly stays because the launch fact is + * unchanged. Known imprecision: relink-to-same-id looks + * indistinguishable from "never relinked". Accepted per + * altimate-harness-bot round 8 (option b of the review). + * ``getResolvedWorkspaceId`` returns null when the launch + * had no --workspace flag or the flag failed to resolve, + * so the pin never falsely appears for a session that + * wasn't launched with the flag. */} + {/* altimate_change start - the toast's numbers, kept visible; + * the reasons are under /workspace → Status */} + {(line) => {line()} · /workspace} + {/* altimate_change end */} + + (pinned via --workspace) + {(u) => ( openManageUrl(props.api, u())}> diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 228a279aa..5d95993da 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -29,6 +29,8 @@ import open from "open" // altimate_change start - the /workspace action menu import * as Manage from "@/altimate/workspace/manage" import { inertWorkspaceName } from "@/altimate/workspace/workspace-name" +import { attachSnapshot } from "@/altimate/workspace/engine-overlay" +import { loadStatusView, rowLine, statusHeadline, type IntegrationRow } from "@/altimate/workspace/status-view" // altimate_change end import { createSignal, onCleanup, onMount } from "solid-js" import { @@ -48,11 +50,7 @@ import { resolveWorkspaceWebUrl, type HandoffResult, } from "@/altimate/workspace/browser-handoff" -import { - projectNameFromPath, - projectNameFromRemote, - resolveProjectIdentifier, -} from "@/altimate/workspace/detect" +import { projectNameFromPath, projectNameFromRemote, resolveProjectIdentifier } from "@/altimate/workspace/detect" import { readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" import { describeOffer, @@ -124,12 +122,7 @@ function skipKey(id: ProjectIdentifier, scope: LatchScope | null): string { ) } -function isSkipActive( - api: TuiPluginApi, - id: ProjectIdentifier, - scope: LatchScope | null, - nowMs: number, -): boolean { +function isSkipActive(api: TuiPluginApi, id: ProjectIdentifier, scope: LatchScope | null, nowMs: number): boolean { const rec = api.kv.get<{ skippedAt: number }>(skipKey(id, scope)) if (!rec || typeof rec.skippedAt !== "number") return false // Reject records timestamped in the future — a system-clock rewind after @@ -142,12 +135,7 @@ function isSkipActive( return delta < SKIP_TTL_MS } -function recordSkip( - api: TuiPluginApi, - id: ProjectIdentifier, - scope: LatchScope | null, - nowMs: number, -): void { +function recordSkip(api: TuiPluginApi, id: ProjectIdentifier, scope: LatchScope | null, nowMs: number): void { api.kv.set(skipKey(id, scope), { skippedAt: nowMs }) } @@ -238,9 +226,7 @@ function OfferDialog(props: OfferProps) { return } // link → picker (fresh-project attach path) - props.api.ui.dialog.replace(() => ( - - )) + props.api.ui.dialog.replace(() => ) }} /> ) @@ -347,11 +333,7 @@ let activeHandoffAbort: AbortController | null = null * returned workspace via the existing ``POST /bind`` endpoint. Every failure * mode surfaces as a toast; the user can always fall back to another option * by re-invoking the dialog. */ -async function runBrowserHandoff( - api: TuiPluginApi, - identifier: ProjectIdentifier, - projectName: string, -): Promise { +async function runBrowserHandoff(api: TuiPluginApi, identifier: ProjectIdentifier, projectName: string): Promise { api.ui.dialog.clear() api.ui.toast({ variant: "info", @@ -383,10 +365,7 @@ async function runBrowserHandoff( // credentials we're about to bind under, and refuse if either drifted. try { const fresh = await AltimateApi.getCredentials() - if ( - fresh.altimateInstanceName !== result.credentials.tenant || - fresh.altimateUrl !== result.credentials.apiUrl - ) { + if (fresh.altimateInstanceName !== result.credentials.tenant || fresh.altimateUrl !== result.credentials.apiUrl) { api.ui.toast({ variant: "error", message: `Your Altimate credentials changed while the browser was open (was ${result.credentials.tenant}, now ${fresh.altimateInstanceName}). Re-run to link this project.`, @@ -445,7 +424,8 @@ function toastHandoffFailure(api: TuiPluginApi, result: Extract { // stored — the repo was renamed / remote swapped. The dialog surfaces // this so the user isn't silently attached to a stale binding. (M3) const boundIdent = - serverBinding.matchedBy === "remote" - ? serverBinding.binding.repo_remote - : serverBinding.binding.project_path - const currentIdent = - serverBinding.matchedBy === "remote" ? identifier.repoRemote : identifier.projectPath + serverBinding.matchedBy === "remote" ? serverBinding.binding.repo_remote : serverBinding.binding.project_path + const currentIdent = serverBinding.matchedBy === "remote" ? identifier.repoRemote : identifier.projectPath const hasDrift = boundIdent != null && currentIdent != null && boundIdent !== currentIdent // Resolved before the dialog renders — see AlreadyLinkedDialog's comment // on why this can't be fetched async inside the dialog itself. @@ -1193,8 +1160,7 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { // ordering as the server-side pre-check: remote first, path fallback. const cachedMatchedBy: MatchedIdentifier = local.repoRemote ? "remote" : "path" const cachedIdent = local.repoRemote ?? local.projectPath ?? "" - const currentIdent = - cachedMatchedBy === "remote" ? identifier.repoRemote : identifier.projectPath + const currentIdent = cachedMatchedBy === "remote" ? identifier.repoRemote : identifier.projectPath const hasDrift = cachedIdent !== "" && currentIdent != null && cachedIdent !== currentIdent const manageUrl = await resolveManageUrl(local.datamateId) api.ui.dialog.replace(() => ( @@ -1276,12 +1242,7 @@ async function awaitKvReady( /** Same clock-rewind handling as the post-scan latch; the TTL is the one the * attach side's announce dedupe expires on, so both agree on "7 days". */ -function isEngineSkipActive( - api: TuiPluginApi, - workspaceId: string, - scope: LatchScope | null, - nowMs: number, -): boolean { +function isEngineSkipActive(api: TuiPluginApi, workspaceId: string, scope: LatchScope | null, nowMs: number): boolean { const rec = api.kv.get<{ skippedAt: number }>(engineSkipKey(workspaceId, scope)) if (!rec || typeof rec.skippedAt !== "number") return false const delta = nowMs - rec.skippedAt @@ -1289,12 +1250,7 @@ function isEngineSkipActive( return delta < OFFER_SKIP_TTL_MS } -function recordEngineSkip( - api: TuiPluginApi, - workspaceId: string, - scope: LatchScope | null, - nowMs: number, -): void { +function recordEngineSkip(api: TuiPluginApi, workspaceId: string, scope: LatchScope | null, nowMs: number): void { api.kv.set(engineSkipKey(workspaceId, scope), { skippedAt: nowMs }) } @@ -1771,6 +1727,122 @@ function syncMessage(result: Manage.SyncReport): string { return parts.join(", ") + "." } +/** The description of the Status row: counts from the last attach, or why + * there are none yet. Read from memory, so the menu opens without waiting. */ +function statusRowDescription(directory: string, boundId: number): string { + const snapshot = attachSnapshot(directory) + // A snapshot from a workspace this project was since re-linked away from is + // about the wrong workspace; say nothing rather than something stale. + if (!snapshot || snapshot.workspace.id !== String(boundId)) { + return "No session has attached yet — send a message first." + } + const present = new Set(snapshot.present) + const declared = snapshot.declared?.keys.length + const served = snapshot.declared ? snapshot.declared.keys.filter((k) => present.has(k)).length : present.size + const gaps = (snapshot.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + return statusHeadline({ served, declared, gaps, extServed: snapshot.extServed, rows: [] }) +} + +const STATE_MARK: Record = { + served: "●", + partial: "◐", + missing: "○", + idle: "◌", +} + +/** `/workspace` → Status: what the last session got from each integration + * and why, the detail the attach toast now only points at. Rows are + * informational; the actions open the workspace on the web or re-read. */ +async function showWorkspaceStatus(api: TuiPluginApi, directory: string, boundId: number): Promise { + const view = await loadStatusView(directory) + if (!view || view.workspace.id !== String(boundId)) { + api.ui.dialog.replace(() => ( + api.ui.dialog.clear()} + /> + )) + return + } + const manageUrl = await resolveManageUrl(Number(view.workspace.id)) + const engine = view.engineVersion ? ` · engine ${view.engineVersion}` : "" + const title = `${view.workspace.name}${engine} · ${statusHeadline(view)}` + // The plugin's DialogSelect has rows and a footer per row, no action bar: + // the integrations are rows under one category, the actions rows under + // another, and a row's footer carries its keys and reasons. + const rows = view.rows.map((row) => ({ + title: `${STATE_MARK[row.state]} ${row.name}`, + value: `row:${row.id}`, + description: rowLine(row), + footer: rowDetails(row).join("\n"), + category: "Integrations", + })) + if (view.extras.length > 0) { + rows.push({ + title: `${STATE_MARK.served} Workspace extras`, + value: "row:extras", + description: `${view.extras.length} beyond the allowlist (knowledge, memory)`, + footer: view.extras.join(", "), + category: "Integrations", + }) + } + const actions = [ + ...(manageUrl + ? [ + { + title: "Open on the web", + value: "open", + description: "Connections and the selection live there.", + category: "Actions", + }, + ] + : []), + { + title: "Re-read", + value: "reread", + description: "Read the selection and the last attach again.", + category: "Actions", + }, + { title: "Done", value: "done", description: "Close this view.", category: "Actions" }, + ] + api.ui.dialog.replace(() => ( + { + if (option.value === "open" && manageUrl) { + api.ui.dialog.clear() + openManageUrl(api, manageUrl) + return + } + if (option.value === "reread") { + showWorkspaceStatus(api, directory, boundId).catch((err) => reportFlowFailure(api, err)) + return + } + api.ui.dialog.clear() + }} + /> + )) +} + +/** Served keys, then the gaps with their reason — the row's "details" lines. */ +function rowDetails(row: IntegrationRow): string[] { + const lines: string[] = [] + if (row.served.length > 0) lines.push(`available: ${row.served.join(", ")}`) + for (const gap of row.gaps) lines.push(`${gap.key} — ${gap.phrase}${gap.detail ? ` (${gap.detail})` : ""}`) + if (row.state === "idle" && row.declared.length > 0) lines.push(`via VS Code: ${row.declared.join(", ")}`) + return lines +} + /** The `/workspace` menu. */ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise { const report = await Manage.status(directory) @@ -1782,6 +1854,11 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise options={ linked ? [ + { + title: "Status", + value: "status", + description: statusRowDescription(directory, report.binding!.datamateId), + }, { title: "Refresh", value: "refresh", @@ -1805,8 +1882,12 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise }, ] } - current={linked ? "refresh" : "done"} + current={linked ? "status" : "done"} onSelect={(option) => { + if (option.value === "status") { + showWorkspaceStatus(api, directory, report.binding!.datamateId).catch((err) => reportFlowFailure(api, err)) + return + } if (option.value === "unlink") { confirmUnlink(api, directory, report.binding?.datamateName ?? "this workspace") return @@ -1911,9 +1992,7 @@ const tui: TuiPlugin = async (api) => { run() { // User-initiated → jump straight to picker (currently-linked marked, // "+ Create new" as the first row). No Skip funnel — they invoked. - runOnDemandPicker(api, api.state.path.directory).catch((err) => - reportFlowFailure(api, err), - ) + runOnDemandPicker(api, api.state.path.directory).catch((err) => reportFlowFailure(api, err)) }, }, ], diff --git a/packages/opencode/test/altimate/workspace/attach-snapshot.test.ts b/packages/opencode/test/altimate/workspace/attach-snapshot.test.ts new file mode 100644 index 000000000..07c5114c0 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/attach-snapshot.test.ts @@ -0,0 +1,59 @@ +// The attach snapshot file: what the overlay writes for the TUI process to +// read. Sandboxed state directory, like manage.test.ts. +import { afterAll, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" + +const SANDBOX = mkdtempSync(path.join(tmpdir(), "attach-snapshot-")) +const ORIGINAL_XDG_STATE_HOME = process.env.XDG_STATE_HOME +process.env.XDG_STATE_HOME = path.join(SANDBOX, "state") +afterAll(() => { + if (ORIGINAL_XDG_STATE_HOME === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = ORIGINAL_XDG_STATE_HOME + rmSync(SANDBOX, { recursive: true, force: true }) +}) + +const { readAttachSnapshot, writeAttachSnapshot, snapshotPath } = await import( + "../../../src/altimate/workspace/attach-snapshot" +) + +const snap = (at: number, id = "6") => ({ + workspace: { id, name: "e2e-demo-live" }, + engineVersion: "0.7.2", + declared: { keys: ["a", "b"], extensionKeys: [] }, + present: ["a"], + unfulfilled: [{ key: "b", integrationId: "jira", reason: "invalid-connection" }], + extServed: 0, + at, +}) + +describe("attach snapshot file", () => { + beforeEach(() => rmSync(snapshotPath(), { force: true })) + + test("round-trips per directory, latest attach wins, and a directory with none reads undefined", () => { + writeAttachSnapshot("/proj/a", snap(1)) + writeAttachSnapshot("/proj/b", snap(2, "7")) + writeAttachSnapshot("/proj/a", snap(3)) + expect(readAttachSnapshot("/proj/a")).toEqual(snap(3)) + expect(readAttachSnapshot("/proj/b")).toEqual(snap(2, "7")) + expect(readAttachSnapshot("/proj/c")).toBeUndefined() + }) + + test("keeps the newest 64 directories", () => { + for (let i = 0; i < 70; i++) writeAttachSnapshot(`/proj/${i}`, snap(i)) + expect(readAttachSnapshot("/proj/0")).toBeUndefined() + expect(readAttachSnapshot("/proj/5")).toBeUndefined() + expect(readAttachSnapshot("/proj/6")).toEqual(snap(6)) + expect(readAttachSnapshot("/proj/69")).toEqual(snap(69)) + }) + + test("a corrupt file reads as empty and is replaced by the next write", () => { + const { mkdirSync, writeFileSync } = require("node:fs") as typeof import("node:fs") + mkdirSync(path.dirname(snapshotPath()), { recursive: true }) + writeFileSync(snapshotPath(), "{not json") + expect(readAttachSnapshot("/proj/a")).toBeUndefined() + writeAttachSnapshot("/proj/a", snap(1)) + expect(readAttachSnapshot("/proj/a")).toEqual(snap(1)) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 16c42943f..ef05b95ce 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -26,8 +26,10 @@ import { type LocalMcpConfig, type McpEntry, type Toast, + attachSnapshot, } from "../../../src/altimate/workspace/engine-overlay" import type { ScopedBinding } from "../../../src/altimate/workspace/engine-seams" +import type { AttachSnapshot } from "../../../src/altimate/workspace/attach-snapshot" import { DATAMATE_KEY } from "../../../src/altimate/datamate-transport" const DIR = "/tmp/analytics" @@ -52,6 +54,7 @@ type Harness = { invalidates: number probes: number toasts: Toast[] + persisted: AttachSnapshot[] lines: string[] clock: number /** Whether MCP holds a client under the key — set when MCP "bootstraps" from @@ -94,6 +97,7 @@ function install(opts: { invalidates: 0, probes: 0, toasts: [], + persisted: [], lines: [], clock: 1_000_000, fingerprint: "bin-1", @@ -112,6 +116,9 @@ function install(opts: { opts.declared === undefined ? { keys: ["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"], extensionKeys: [] } : opts.declared + syncInternals.persistSnapshot = (_dir, snap) => { + h.persisted.push(snap) + } syncInternals.notify = async (toast) => { h.toasts.push(toast) } @@ -408,8 +415,16 @@ describe("beforeTurn — what a turn boundary does", () => { unfulfilled: report, }) expect(h.toasts).toHaveLength(1) - expect(h.toasts[0].message).toContain("2 of 3 declared integration tools available") - expect(h.toasts[0].message).toContain("no usable connection: dbt_execute_sql") + expect(h.toasts[0].message).toBe("2 of 3 integration tools available · 1 needs attention. Details: /workspace") + expect(h.toasts[0].variant).toBe("warning") + const snap = attachSnapshot(DIR)! + expect(snap.workspace).toEqual({ id: String(h.binding!.datamateId), name: h.binding!.datamateName }) + expect([...snap.present].sort()).toEqual(["dbt_build_model", "dbt_compile_model"]) + expect(snap.unfulfilled).toEqual(report) + expect(snap.extServed).toBe(0) + expect(snap.declared?.keys).toEqual(["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"]) + // Persisted for the TUI process, which cannot see this one's memory. + expect(h.persisted).toEqual([snap]) // The engine was started by MCP bootstrap from the injected entry, not by the hook. expect(h.added).toEqual([]) await beforeTurn("s1") @@ -424,14 +439,14 @@ describe("beforeTurn — what a turn boundary does", () => { }) await beforeTurn("s1") expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [], unfulfilled: [] }) - expect(h.toasts[0].message).toBe("2 of 2 declared integration tools available.") + expect(h.toasts[0].message).toBe("2 of 2 integration tools available. Details: /workspace") }) test("attached without an allowlist reports only what is available", async () => { const h = install({ declared: null }) await beforeTurn("s1") expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2, missing: [], unfulfilled: [] }) - expect(h.toasts[0].message).toBe("2 integration tools available.") + expect(h.toasts[0].message).toBe("2 integration tools available. Details: /workspace") }) test("extension tools a live bridge serves are announced; absent ones are expected, not missing", async () => { @@ -443,9 +458,7 @@ describe("beforeTurn — what a turn boundary does", () => { // `run_model` is declared extension-type but no bridge serves it: that is // the normal no-IDE case, so the outcome stays clean and unwarned. expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [], unfulfilled: [] }) - expect(h.toasts[0].message).toBe( - "2 of 2 declared integration tools available. Plus 1 extension tool via the connected VS Code window.", - ) + expect(h.toasts[0].message).toBe("2 of 2 integration tools available · 1 more via VS Code. Details: /workspace") expect(h.toasts[0].variant).toBe("info") }) @@ -460,10 +473,7 @@ describe("beforeTurn — what a turn boundary does", () => { const h = install({ meta: { [UNFULFILLED_META_KEY]: report } }) await beforeTurn("s1") expect(settledOutcome("s1")).toMatchObject({ missing: ["dbt_execute_sql", "gh_list_prs", "gh_create_pr"] }) - expect(h.toasts[0].message).toBe( - "2 of 3 declared integration tools available. Declared but not available — no usable connection: dbt_execute_sql; " + - "server could not be started or reached (spawn docker ENOENT): gh_list_prs, gh_create_pr.", - ) + expect(h.toasts[0].message).toBe("2 of 3 integration tools available · 3 need attention. Details: /workspace") expect(h.toasts[0].variant).toBe("warning") }) @@ -478,8 +488,8 @@ describe("beforeTurn — what a turn boundary does", () => { meta: { [UNFULFILLED_META_KEY]: report }, }) await beforeTurn("s1") - expect(h.toasts[0].message).toContain("1 of 2 declared integration tools available") - expect(h.toasts[0].message).toContain("not offered by the integration: foo.bar") + // This branch's toast is one line; the reason for `foo.bar` lives under /workspace → Status. + expect(h.toasts[0].message).toBe("1 of 2 integration tools available · 1 needs attention. Details: /workspace") }) test("two declarations that sanitise to one catalog entry count once, even with nothing reported", async () => { @@ -491,7 +501,7 @@ describe("beforeTurn — what a turn boundary does", () => { meta: { [UNFULFILLED_META_KEY]: [] }, }) await beforeTurn("s1") - expect(h.toasts[0].message).toBe("1 of 2 declared integration tools available.") + expect(h.toasts[0].message).toBe("1 of 2 integration tools available. Details: /workspace") }) test("a collision across the ordinary and extension groups is one entry, counted once", async () => { @@ -504,7 +514,7 @@ describe("beforeTurn — what a turn boundary does", () => { meta: { [UNFULFILLED_META_KEY]: [] }, }) await beforeTurn("s1") - expect(h.toasts[0].message).toBe("1 of 1 declared integration tools available.") + expect(h.toasts[0].message).toBe("1 of 1 integration tools available. Details: /workspace") }) test("no-bridge entries in the report are expected, never missing", async () => { @@ -521,7 +531,7 @@ describe("beforeTurn — what a turn boundary does", () => { missing: [], unfulfilled: report, }) - expect(h.toasts[0].message).toBe("2 of 3 declared integration tools available.") + expect(h.toasts[0].message).toBe("2 of 3 integration tools available. Details: /workspace") expect(h.toasts[0].variant).toBe("info") }) @@ -531,7 +541,7 @@ describe("beforeTurn — what a turn boundary does", () => { // Two of three declared keys are present; without the engine's report // the third is neither claimed missing nor claimed served. expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2, declared: 3 }) - expect(h.toasts[0].message).toBe("2 of 3 declared integration tools available.") + expect(h.toasts[0].message).toBe("2 of 3 integration tools available. Details: /workspace") expect(h.toasts[0].variant).toBe("info") }) @@ -545,9 +555,7 @@ describe("beforeTurn — what a turn boundary does", () => { missing: ["jira_search_issues"], unfulfilled: report, }) - expect(h.toasts[0].message).toBe( - "2 integration tools available. Declared but not available — no usable connection: jira_search_issues.", - ) + expect(h.toasts[0].message).toBe("2 integration tools available · 1 needs attention. Details: /workspace") expect(h.toasts[0].variant).toBe("warning") }) @@ -563,7 +571,10 @@ describe("beforeTurn — what a turn boundary does", () => { } await beforeTurn("s1") expect(h.toasts).toHaveLength(2) - expect(h.toasts[1].message).toContain("no usable connection: gh_list_prs") + // The toast carries numbers only; the changed reason is in the snapshot the + // status view reads. + expect(h.toasts[1].message).toBe("2 of 3 integration tools available · 1 needs attention. Details: /workspace") + expect(attachSnapshot(DIR)?.unfulfilled?.map((u) => u.reason)).toEqual(["invalid-connection"]) }) test("the outcome carries the declared extension groups, and only when the allowlist names any", async () => { diff --git a/packages/opencode/test/altimate/workspace/engine-types.test.ts b/packages/opencode/test/altimate/workspace/engine-types.test.ts index 1a596a439..380d6000c 100644 --- a/packages/opencode/test/altimate/workspace/engine-types.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-types.test.ts @@ -12,7 +12,6 @@ import { attributableEngine, clearsFloor, compareVersions, - describeMissing, parseUnfulfilled, reportedMissing, UNFULFILLED_META_KEY, @@ -23,6 +22,7 @@ import { installWouldHelp, pinnedWorkspace, type Outcome, + reasonPhrase, } from "../../../src/altimate/workspace/engine-types" describe("compareVersions", () => { @@ -158,51 +158,16 @@ describe("messages", () => { "Update with: npm i -g @altimateai/datamate@next", ) }) - test("the missing line groups by reason, carries the engine's detail, and truncates after five", () => { - const u = (key: string, reason: string, detail?: string) => ({ - key, - integrationId: "i", - reason, - ...(detail ? { detail } : {}), - }) - expect(describeMissing([])).toBe("") - expect(describeMissing([u("a", "invalid-connection"), u("b", "invalid-connection")])).toBe( - " Declared but not available — no usable connection: a, b.", - ) - expect( - describeMissing([ - u("a", "spawn-failed", "spawn docker ENOENT"), - u("b", "spawn-failed", "spawn docker ENOENT"), - u("c", "catalog-missing"), - u("d", "unknown-key"), - u("e", "exception", "boom"), - ]), - ).toBe( - " Declared but not available — server could not be started or reached (spawn docker ENOENT): a, b; " + - "no longer in the catalog: c; not offered by the integration: d; failed to load (boom): e.", - ) - expect(describeMissing(["a", "b", "c", "d", "e", "f", "g"].map((k) => u(k, "invalid-connection")))).toBe( - " Declared but not available — no usable connection: a, b, c, d, e (+2 more).", - ) - // A reason this client does not know is shown verbatim rather than dropped. - expect(describeMissing([u("a", "quota-exceeded")])).toBe(" Declared but not available — quota-exceeded: a.") - // A long detail is cut so the toast stays a toast. - expect(describeMissing([u("a", "exception", "x".repeat(80))])).toContain(`(${"x".repeat(59)}…)`) + test("a reason is named in the user's words, and an unknown one is kept verbatim", () => { + expect(reasonPhrase("invalid-connection")).toBe("no usable connection") + expect(reasonPhrase("spawn-failed")).toBe("server could not be started or reached") + expect(reasonPhrase("catalog-missing")).toBe("no longer in the catalog") + expect(reasonPhrase("unknown-key")).toBe("not offered by the integration") + expect(reasonPhrase("exception")).toBe("failed to load") + expect(reasonPhrase("no-bridge")).toBe("needs a VS Code window") + expect(reasonPhrase("quota-exceeded")).toBe("quota-exceeded") }) - test("two integrations that failed the same way keep their own details", () => { - // Grouped by reason alone, the first integration's error stood for both and - // the toast handed the user the wrong repair for the second. (multi-model review) - const out = describeMissing([ - { key: "gh_list_prs", integrationId: "github-mcp", reason: "spawn-failed", detail: "spawn docker ENOENT" }, - { key: "jira_search", integrationId: "jira-mcp", reason: "spawn-failed", detail: "spawn /opt/jira ENOENT" }, - { key: "gh_get_pr", integrationId: "github-mcp", reason: "spawn-failed" }, - ]) - expect(out).toBe( - " Declared but not available — server could not be started or reached (spawn docker ENOENT): gh_list_prs, gh_get_pr; " + - "server could not be started or reached (spawn /opt/jira ENOENT): jira_search.", - ) - }) test("an entry with a malformed detail is a malformed report, not a report missing a field", () => { // Dropping the field and accepting the rest would announce a gap on the diff --git a/packages/opencode/test/altimate/workspace/status-view.test.ts b/packages/opencode/test/altimate/workspace/status-view.test.ts new file mode 100644 index 000000000..a5aacc5f4 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/status-view.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import type { AttachSnapshot } from "../../../src/altimate/workspace/attach-snapshot" +import { buildStatusView, rowLine, statusHeadline } from "../../../src/altimate/workspace/status-view" + +const snapshot = (over: Partial = {}): AttachSnapshot => ({ + workspace: { id: "6", name: "e2e-demo-live" }, + engineVersion: "0.7.2", + declared: { + keys: ["altimate_a", "altimate_b", "jira_search", "jira_create", "demo_tool"], + extensionKeys: ["get_projects"], + }, + present: ["altimate_a", "altimate_b", "altimate_knowledge_search"], + unfulfilled: [ + { key: "jira_search", integrationId: "jira", reason: "invalid-connection" }, + { key: "jira_create", integrationId: "jira", reason: "invalid-connection" }, + { key: "demo_tool", integrationId: "1", reason: "spawn-failed", detail: "altimate-demo-missing-mcp: ENOENT" }, + { key: "get_projects", integrationId: "power-user-for-dbt", reason: "no-bridge" }, + ], + extServed: 0, + at: 1, + ...over, +}) +const selection = [ + { id: "altimate", tools: [{ key: "altimate_a" }, { key: "altimate_b" }] }, + { id: "jira", tools: [{ key: "jira_search" }, { key: "jira_create" }] }, + { id: "power-user-for-dbt", tools: [{ key: "get_projects" }] }, + { id: "1", tools: [{ key: "demo_tool" }] }, +] +const catalog = [ + { id: "altimate", name: "Altimate", type: "tool" }, + { id: "jira", name: "Jira", type: "tool" }, + { id: "power-user-for-dbt", name: "Power User for dbt", type: "extension" }, +] + +describe("buildStatusView", () => { + test("one row per declared integration, attention first, named from the catalog with an id fallback", () => { + const view = buildStatusView(snapshot(), selection, catalog) + expect(view.rows.map((r) => [r.name, r.state])).toEqual([ + ["Integration 1", "missing"], + ["Jira", "missing"], + ["Altimate", "served"], + ["Power User for dbt", "idle"], + ]) + const jira = view.rows.find((r) => r.name === "Jira")! + expect(jira.gaps.map((g) => g.phrase)).toEqual(["no usable connection", "no usable connection"]) + expect(rowLine(jira)).toBe("0 of 2 · no usable connection") + expect(rowLine(view.rows[0]!)).toBe("0 of 1 · server failed to start (altimate-demo-missing-mcp: ENOENT)") + expect(rowLine(view.rows.find((r) => r.name === "Altimate")!)).toBe("2 of 2") + expect(rowLine(view.rows.find((r) => r.name === "Power User for dbt")!)).toBe( + "0 of 1 · needs a VS Code window open on this project", + ) + }) + + test("counts match the toast: declared keys present over declared, gaps without no-bridge, extras beyond the allowlist", () => { + const view = buildStatusView(snapshot(), selection, catalog) + expect(view.served).toBe(2) + expect(view.declared).toBe(5) + expect(view.gaps).toBe(3) + expect(view.extras).toEqual(["altimate_knowledge_search"]) + expect(statusHeadline(view)).toBe("2 of 5 integration tools available · 3 need attention") + }) + + test("a partially served integration and a live bridge read as such", () => { + const view = buildStatusView( + snapshot({ + present: ["altimate_a", "get_projects"], + unfulfilled: [{ key: "altimate_b", integrationId: "altimate", reason: "exception" }], + extServed: 1, + }), + selection, + catalog, + ) + const altimate = view.rows.find((r) => r.name === "Altimate")! + expect(altimate.state).toBe("partial") + expect(rowLine(altimate)).toBe("1 of 2 · failed to load") + expect(view.rows.find((r) => r.name === "Power User for dbt")!.state).toBe("served") + expect(statusHeadline(view)).toBe("1 of 5 integration tools available · 1 need attention · 1 more via VS Code") + }) + + test("a report for an integration the selection no longer lists still gets a row", () => { + const view = buildStatusView( + snapshot({ unfulfilled: [{ key: "old_tool", integrationId: "retired", reason: "catalog-missing" }] }), + [{ id: "altimate", tools: [{ key: "altimate_a" }] }], + catalog, + ) + expect(view.rows.map((r) => [r.name, r.state])).toEqual([ + ["Integration retired", "missing"], + ["Altimate", "served"], + ]) + }) + + test("without an allowlist the headline counts what the engine serves", () => { + const view = buildStatusView(snapshot({ declared: null, unfulfilled: undefined }), [], []) + expect(view.declared).toBeUndefined() + expect(statusHeadline(view)).toBe("3 integration tools available") + expect(view.rows).toEqual([]) + }) +}) diff --git a/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts b/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts index 7fd1aca4c..71a89bb18 100644 --- a/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts +++ b/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts @@ -17,7 +17,7 @@ import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" import { MCP } from "../../src/mcp/index" import { - describeMissing, + reasonPhrase, parseUnfulfilled, reportedMissing, UNFULFILLED_META_KEY, @@ -222,13 +222,13 @@ describe.skipIf(!runnable)("engine unfulfilled report through the MCP service", }) expect(report!.some((u) => `datamate_${u.key}` in tools)).toBe(false) - // What the user would read on attach: every gap but the IDE one, with reasons. - expect(describeMissing(reportedMissing(report!))).toBe( - " Declared but not available — no usable connection: jira_search_issues; " + - "not offered by the integration: ghost; " + - "server could not be started or reached (spawn altimate-e2e-missing-binary ENOENT): whatever; " + - "no longer in the catalog: retired_tool.", - ) + // What the status view would list on attach: every gap but the IDE one, each with its reason. + expect(reportedMissing(report!).map((u) => `${u.key}: ${reasonPhrase(u.reason)}`)).toEqual([ + "jira_search_issues: no usable connection", + "ghost: not offered by the integration", + "whatever: server could not be started or reached", + "retired_tool: no longer in the catalog", + ]) expect(api.unhandled).toEqual([]) yield* mcp.remove("datamate") expect(yield* mcp.listMeta("datamate")).toBeUndefined() From 4db9ecd0697e715e8c9f61215820db4e99c00943 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Tue, 15 Sep 2026 01:15:32 +0800 Subject: [PATCH 3/5] fix(workspace): key sub-rows the dialog will show, and a title that fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin's DialogSelect renders an option's footer inline with its title, which left three characters of "Altimate", and it filters `disabled` rows out entirely, so the keys under each integration never appeared. The keys are now ordinary indented rows — choosing one keeps the view open — and the engine version moves off the title (which wrapped) onto the Re-read row. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../src/plugin/tui/altimate/workspace.tsx | 71 +++++++++++++------ 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 5d95993da..4c3569952 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1772,26 +1772,47 @@ async function showWorkspaceStatus(api: TuiPluginApi, directory: string, boundId return } const manageUrl = await resolveManageUrl(Number(view.workspace.id)) - const engine = view.engineVersion ? ` · engine ${view.engineVersion}` : "" - const title = `${view.workspace.name}${engine} · ${statusHeadline(view)}` - // The plugin's DialogSelect has rows and a footer per row, no action bar: - // the integrations are rows under one category, the actions rows under - // another, and a row's footer carries its keys and reasons. - const rows = view.rows.map((row) => ({ - title: `${STATE_MARK[row.state]} ${row.name}`, - value: `row:${row.id}`, - description: rowLine(row), - footer: rowDetails(row).join("\n"), - category: "Integrations", - })) + const title = `${view.workspace.name} · ${statusHeadline(view)}` + // The plugin's DialogSelect renders a row's footer inline with its title, + // which squeezes the title to a few characters, so the keys go on sub-rows + // under each integration instead — ordinary rows, since the dialog hides + // disabled ones: gaps with their reason first, then what is available, + // capped so a 40-tool integration stays readable. + const rows: { title: string; value: string; description?: string; category: string }[] = [] + for (const row of view.rows) { + rows.push({ + title: `${STATE_MARK[row.state]} ${row.name}`, + value: `row:${row.id}`, + description: rowLine(row), + category: "Integrations", + }) + for (const line of rowDetails(row)) { + rows.push({ + title: ` ${line.key}`, + value: `key:${row.id}:${line.key}`, + description: line.note, + category: "Integrations", + }) + } + } if (view.extras.length > 0) { rows.push({ title: `${STATE_MARK.served} Workspace extras`, value: "row:extras", description: `${view.extras.length} beyond the allowlist (knowledge, memory)`, - footer: view.extras.join(", "), category: "Integrations", }) + for (const line of capped( + view.extras.map((key) => ({ key, note: "available" })), + 4, + )) { + rows.push({ + title: ` ${line.key}`, + value: `key:extras:${line.key}`, + description: line.note, + category: "Integrations", + }) + } } const actions = [ ...(manageUrl @@ -1807,7 +1828,7 @@ async function showWorkspaceStatus(api: TuiPluginApi, directory: string, boundId { title: "Re-read", value: "reread", - description: "Read the selection and the last attach again.", + description: `Read the selection and the last attach again${view.engineVersion ? ` (engine ${view.engineVersion})` : ""}.`, category: "Actions", }, { title: "Done", value: "done", description: "Close this view.", category: "Actions" }, @@ -1828,19 +1849,27 @@ async function showWorkspaceStatus(api: TuiPluginApi, directory: string, boundId showWorkspaceStatus(api, directory, boundId).catch((err) => reportFlowFailure(api, err)) return } + // A key row is information, not an action: choosing it keeps the view open. + if (String(option.value).startsWith("key:")) return api.ui.dialog.clear() }} /> )) } -/** Served keys, then the gaps with their reason — the row's "details" lines. */ -function rowDetails(row: IntegrationRow): string[] { - const lines: string[] = [] - if (row.served.length > 0) lines.push(`available: ${row.served.join(", ")}`) - for (const gap of row.gaps) lines.push(`${gap.key} — ${gap.phrase}${gap.detail ? ` (${gap.detail})` : ""}`) - if (row.state === "idle" && row.declared.length > 0) lines.push(`via VS Code: ${row.declared.join(", ")}`) - return lines +/** The sub-rows under an integration: gaps with their reason, then what is + * available (or would be through a VS Code window), capped. */ +function rowDetails(row: IntegrationRow): { key: string; note: string }[] { + const gaps = row.gaps.map((gap) => ({ key: gap.key, note: `${gap.phrase}${gap.detail ? ` (${gap.detail})` : ""}` })) + const served = row.served.map((key) => ({ key, note: "available" })) + const idle = row.state === "idle" ? row.declared.map((key) => ({ key, note: "via VS Code" })) : [] + return [...capped(gaps, 6), ...capped(served, 4), ...capped(idle, 4)] +} + +/** The first `max` lines, then one line saying how many were left out. */ +function capped(lines: { key: string; note: string }[], max: number): { key: string; note: string }[] { + if (lines.length <= max) return lines + return [...lines.slice(0, max), { key: `+${lines.length - max} more`, note: "" }] } /** The `/workspace` menu. */ From 9d87a32e4d112d88d5b93821944c652911263933 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Tue, 15 Sep 2026 04:00:14 +0800 Subject: [PATCH 4/5] feat(workspace): say so in the boot box when the CLI runs in workspace mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under "What is Altimate Code", three lines that only exist in workspace mode: the mode and the workspace this project is linked to, the slash commands the mode adds (/workspace, /skills), and what the last session got from the workspace — "attach on your first message" before one, the toast's numbers with a pointer at /workspace after. - A `welcome_extra` slot inside the boot box (medium and full variants); the panel asks for the plugin runtime without throwing, so its unit tests and any provider-less render simply omit the slot. - `welcome-lines.ts`: the three lines as a pure function of the binding and the attach snapshot (unlinked, linked-before-a-session, after a session, and a snapshot from another workspace ignored), tested. - `workspace-welcome.tsx`: the plugin that fills the slot, registered only under the workspace flag like the sidebar tile, reading the two cache files on a short poll so the integrations line follows the attach. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../src/altimate/workspace/welcome-lines.ts | 54 +++++++++++++++ .../opencode/src/plugin/tui/altimate/index.ts | 3 +- .../plugin/tui/altimate/workspace-welcome.tsx | 69 +++++++++++++++++++ .../altimate/workspace/welcome-lines.test.ts | 50 ++++++++++++++ packages/plugin/src/tui.ts | 3 + packages/tui/src/component/welcome-panel.tsx | 11 +++ packages/tui/src/plugin/runtime.tsx | 8 +++ 7 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/altimate/workspace/welcome-lines.ts create mode 100644 packages/opencode/src/plugin/tui/altimate/workspace-welcome.tsx create mode 100644 packages/opencode/test/altimate/workspace/welcome-lines.test.ts diff --git a/packages/opencode/src/altimate/workspace/welcome-lines.ts b/packages/opencode/src/altimate/workspace/welcome-lines.ts new file mode 100644 index 000000000..dbe4308fc --- /dev/null +++ b/packages/opencode/src/altimate/workspace/welcome-lines.ts @@ -0,0 +1,54 @@ +// altimate_change - new file +// +// The three lines the boot box shows under "What is Altimate Code" when the +// CLI runs in workspace mode: which mode and workspace, which slash commands +// the mode adds, and what the last session got from the workspace. Pure, so +// the plugin that renders them stays a thin view. +import type { AttachSnapshot } from "./attach-snapshot" +import type { CachedBinding } from "./state" +import { statusHeadline } from "./status-view" + +export interface WelcomeLines { + /** "Workspace mode · linked to …" or the unlinked variant. */ + mode: string + /** The slash commands workspace mode adds, with what each does. */ + commands: string + /** What the last session got, or what will happen on the first message. */ + integrations: string +} + +/** The commands workspace mode registers in the palette. Kept here rather + * than read from the palette so the line is stable and testable; the plugin + * that registers them is the same one that renders this. */ +export const WORKSPACE_COMMANDS = "/workspace — status, refresh, sync, unlink · /skills — the workspace's skills" + +export function welcomeLines(input: { + binding: CachedBinding | null + snapshot: AttachSnapshot | undefined +}): WelcomeLines { + const { binding, snapshot } = input + if (!binding) { + return { + mode: "Workspace mode · this project is not linked", + commands: "altimate-code link — bind this project to a workspace, then the commands below apply", + integrations: "Integrations: none until the project is linked", + } + } + const current = snapshot && snapshot.workspace.id === String(binding.datamateId) ? snapshot : undefined + if (!current) { + return { + mode: `Workspace mode · linked to ${binding.datamateName}`, + commands: WORKSPACE_COMMANDS, + integrations: "Integrations: attach on your first message", + } + } + const present = new Set(current.present) + const declared = current.declared?.keys.length + const served = current.declared ? current.declared.keys.filter((k) => present.has(k)).length : present.size + const gaps = (current.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + return { + mode: `Workspace mode · linked to ${binding.datamateName}`, + commands: WORKSPACE_COMMANDS, + integrations: `Integrations: ${statusHeadline({ served, declared, gaps, extServed: current.extServed, rows: [] })}${gaps > 0 ? " — /workspace for the reasons" : ""}`, + } +} diff --git a/packages/opencode/src/plugin/tui/altimate/index.ts b/packages/opencode/src/plugin/tui/altimate/index.ts index 8e90e364b..2ba576bbf 100644 --- a/packages/opencode/src/plugin/tui/altimate/index.ts +++ b/packages/opencode/src/plugin/tui/altimate/index.ts @@ -16,6 +16,7 @@ import SkillOps from "./skill-ops" import TraceViewer from "./trace-viewer" import Workspace from "./workspace" import WorkspaceSidebar from "./workspace-sidebar" +import WorkspaceWelcome from "./workspace-welcome" // Feature plugins are registered here as they are ported from the pre-merge sources on `main` // (see the ADR re-home plan). Each lives in its own file under this directory and default-exports @@ -32,6 +33,6 @@ export function altimateTuiPlugins(_flags: Pick props.api.theme.current + const [lines, setLines] = createSignal(null) + let inFlight = false + const refresh = async () => { + if (inFlight) return + inFlight = true + try { + const dir = props.api.state.path.directory + const binding: CachedBinding | null = await readLocalBinding(dir).catch(() => null) + setLines(welcomeLines({ binding, snapshot: attachSnapshot(dir) })) + } finally { + inFlight = false + } + } + onMount(() => { + void refresh() + const timer = setInterval(() => void refresh(), POLL_MS) + onCleanup(() => clearInterval(timer)) + }) + const current = () => lines() + return ( + + + {current()?.mode ?? "Workspace mode"} + + + {current()?.commands ?? ""} + + + {current()?.integrations ?? ""} + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 100, + slots: { + welcome_extra() { + return + }, + }, + }) +} + +export default { id, tui } satisfies BuiltinTuiPlugin diff --git a/packages/opencode/test/altimate/workspace/welcome-lines.test.ts b/packages/opencode/test/altimate/workspace/welcome-lines.test.ts new file mode 100644 index 000000000..c2e7f8c6b --- /dev/null +++ b/packages/opencode/test/altimate/workspace/welcome-lines.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { welcomeLines, WORKSPACE_COMMANDS } from "../../../src/altimate/workspace/welcome-lines" + +const binding = { + datamateId: 6, + datamateName: "e2e-demo-live", + repoRemote: null, + projectPath: "/proj", + linkedAt: 1, +} +const snapshot = (id = "6") => ({ + workspace: { id, name: "e2e-demo-live" }, + engineVersion: "0.7.2", + declared: { keys: ["a", "b", "c"], extensionKeys: ["x"] }, + present: ["a", "x"], + unfulfilled: [ + { key: "b", integrationId: "jira", reason: "invalid-connection" }, + { key: "c", integrationId: "jira", reason: "invalid-connection" }, + ], + extServed: 1, + at: 1, +}) + +describe("welcomeLines", () => { + test("unlinked: says so, and points at the link command rather than the menu", () => { + const lines = welcomeLines({ binding: null, snapshot: snapshot() }) + expect(lines.mode).toBe("Workspace mode · this project is not linked") + expect(lines.commands).toContain("altimate-code link") + expect(lines.integrations).toBe("Integrations: none until the project is linked") + }) + + test("linked before any session: names the workspace and promises the attach", () => { + const lines = welcomeLines({ binding, snapshot: undefined }) + expect(lines.mode).toBe("Workspace mode · linked to e2e-demo-live") + expect(lines.commands).toBe(WORKSPACE_COMMANDS) + expect(lines.integrations).toBe("Integrations: attach on your first message") + }) + + test("after a session: the toast's numbers, with a pointer when something needs attention", () => { + const lines = welcomeLines({ binding, snapshot: snapshot() }) + expect(lines.integrations).toBe( + "Integrations: 1 of 3 integration tools available · 2 need attention · 1 more via VS Code — /workspace for the reasons", + ) + }) + + test("a snapshot from another workspace is ignored", () => { + const lines = welcomeLines({ binding, snapshot: snapshot("9") }) + expect(lines.integrations).toBe("Integrations: attach on your first message") + }) +}) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 70c15b8f4..787740baa 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -462,6 +462,9 @@ export type TuiHostSlotMap = { app: {} app_bottom: {} home_logo: {} + // altimate_change start — a line block inside the boot box, under "What is Altimate Code" + welcome_extra: {} + // altimate_change end home_prompt: { ref?: (ref: TuiPromptRef | undefined) => void } diff --git a/packages/tui/src/component/welcome-panel.tsx b/packages/tui/src/component/welcome-panel.tsx index e42982d70..e126eae17 100644 --- a/packages/tui/src/component/welcome-panel.tsx +++ b/packages/tui/src/component/welcome-panel.tsx @@ -5,6 +5,9 @@ import { Logo } from "./logo" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { useReady } from "./altimate-onboarding" import { welcomePanelVariant } from "./welcome-panel-utils" +// altimate_change start — workspace-mode lines under "What is Altimate Code" (plugin slot) +import { usePluginRuntimeOptional } from "../plugin/runtime" +// altimate_change end const CONNECT_CTA = "Connect your AI model to start." @@ -40,6 +43,12 @@ export function WelcomePanel(props: { availableWidth: number; availableHeight: n // props are reactive getters, so reading them inside the memo tracks — the // variant recomputes when the caller's dimensions/sidebar change. const variant = createMemo(() => welcomePanelVariant(props.availableWidth, props.availableHeight)) + // altimate_change start — the workspace plugin fills `welcome_extra` in + // workspace mode (mode, the commands it adds, integration status); outside + // a plugin runtime (unit tests) the slot is simply absent. + const runtime = usePluginRuntimeOptional() + const extra = () => (runtime ? : null) + // altimate_change end const title = InstallationVersion === "local" ? " Altimate Code " : ` Altimate Code v${InstallationVersion} ` @@ -81,6 +90,7 @@ export function WelcomePanel(props: { availableWidth: number; availableHeight: n {CONNECT_CTA} + {extra()} @@ -133,6 +143,7 @@ export function WelcomePanel(props: { availableWidth: number; availableHeight: n + {extra()} diff --git a/packages/tui/src/plugin/runtime.tsx b/packages/tui/src/plugin/runtime.tsx index 4130ac9be..85cf27669 100644 --- a/packages/tui/src/plugin/runtime.tsx +++ b/packages/tui/src/plugin/runtime.tsx @@ -79,3 +79,11 @@ export function usePluginRuntime() { if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider") return runtime } + +// altimate_change start — a component that is also rendered without the +// provider (the boot box, in its unit tests) asks for the runtime without +// throwing, and simply omits its slot when there is none. +export function usePluginRuntimeOptional() { + return useContext(Context) +} +// altimate_change end From 0fe5709e7406ba9d4c0c8e58cfbd19cbbb1da9ce Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 17 Sep 2026 23:23:22 +0800 Subject: [PATCH 5/5] chore(workspace): carry the served-count and wording fixes as a commit of their own Rebuilt on main after the sidebar work landed there. The edits that counted served tools per catalog entry (never a key the engine reports unfulfilled, two raw keys can sanitise to one name) and pluralised "needs attention" lived only in the earlier merge resolutions, so the replay dropped them. Restored here, on top of the sidebar's three-valued scope check from main. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015ZJ5wpVAJca9Td2qdvUTk3 --- .../src/altimate/workspace/engine-overlay.ts | 2 +- .../opencode/src/altimate/workspace/status-view.ts | 12 +++++++++--- .../opencode/src/altimate/workspace/welcome-lines.ts | 8 +++++++- .../src/plugin/tui/altimate/workspace-sidebar.tsx | 6 +++++- .../test/altimate/workspace/status-view.test.ts | 4 ++-- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 80424b8f0..1c238f91c 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -777,7 +777,7 @@ export function attachSummary(input: { ? `${input.available} integration tools available` : `${input.served} of ${input.declared} integration tools available`, ] - if (input.gaps > 0) parts.push(`${input.gaps} need attention`) + if (input.gaps > 0) parts.push(`${input.gaps} need${input.gaps === 1 ? "s" : ""} attention`) if (input.extServed > 0) parts.push(`${input.extServed} more via VS Code`) return `${parts.join(" · ")}. Details: /workspace` } diff --git a/packages/opencode/src/altimate/workspace/status-view.ts b/packages/opencode/src/altimate/workspace/status-view.ts index d9b10dbb4..ee8484efe 100644 --- a/packages/opencode/src/altimate/workspace/status-view.ts +++ b/packages/opencode/src/altimate/workspace/status-view.ts @@ -10,6 +10,7 @@ // or CLI imports, nothing printed. The dialog and the sidebar render it; a // headless route could serve it as is. import { AltimateApi } from "@/altimate/api/client" +import { sanitize } from "@/mcp/catalog" import { Log } from "@/altimate/util/log" import { attachSnapshot } from "./engine-overlay" import type { AttachSnapshot } from "./attach-snapshot" @@ -82,6 +83,8 @@ export function buildStatusView( } const rows: IntegrationRow[] = [] const declaredKeys = new Set() + // Never a key the engine reports unfulfilled: two raw keys can sanitise to one catalog name. + const reportedKeys = new Set((snapshot.unfulfilled ?? []).map((u) => u.key)) const seen = new Set() for (const integration of selection) { const id = String(integration.id) @@ -89,7 +92,7 @@ export function buildStatusView( const entry = byId.get(id) const declared = (integration.tools ?? []).map((t) => t.key) for (const k of declared) declaredKeys.add(k) - const served = declared.filter((k) => present.has(k)) + const served = declared.filter((k) => present.has(sanitize(k)) && !reportedKeys.has(k)) const gaps = toGaps(reported.get(id) ?? []) const extension = entry?.type === "extension" rows.push({ @@ -119,7 +122,10 @@ export function buildStatusView( rows.sort(byAttention) const extras = snapshot.present.filter((k) => !declaredKeys.has(k)).sort() const declaredCount = snapshot.declared?.keys.length - const served = snapshot.declared ? snapshot.declared.keys.filter((k) => present.has(k)).length : present.size + // Counted per catalog entry: declarations that sanitise to one name are one tool. + const served = snapshot.declared + ? new Set(snapshot.declared.keys.filter((k) => present.has(sanitize(k)) && !reportedKeys.has(k)).map(sanitize)).size + : present.size const gapCount = (snapshot.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length return { workspace: snapshot.workspace, @@ -172,7 +178,7 @@ export function statusHeadline(view: Pick 0) parts.push(`${view.gaps} need attention`) + if (view.gaps > 0) parts.push(`${view.gaps} need${view.gaps === 1 ? "s" : ""} attention`) if (view.extServed > 0) parts.push(`${view.extServed} more via VS Code`) return parts.join(" · ") } diff --git a/packages/opencode/src/altimate/workspace/welcome-lines.ts b/packages/opencode/src/altimate/workspace/welcome-lines.ts index dbe4308fc..92cf2e169 100644 --- a/packages/opencode/src/altimate/workspace/welcome-lines.ts +++ b/packages/opencode/src/altimate/workspace/welcome-lines.ts @@ -5,6 +5,7 @@ // the mode adds, and what the last session got from the workspace. Pure, so // the plugin that renders them stays a thin view. import type { AttachSnapshot } from "./attach-snapshot" +import { sanitize } from "@/mcp/catalog" import type { CachedBinding } from "./state" import { statusHeadline } from "./status-view" @@ -44,7 +45,12 @@ export function welcomeLines(input: { } const present = new Set(current.present) const declared = current.declared?.keys.length - const served = current.declared ? current.declared.keys.filter((k) => present.has(k)).length : present.size + // Never a key the engine reports unfulfilled: two raw keys can sanitise to one catalog name. + const reported = new Set((current.unfulfilled ?? []).map((u) => u.key)) + // Counted per catalog entry: declarations that sanitise to one name are one tool. + const served = current.declared + ? new Set(current.declared.keys.filter((k) => present.has(sanitize(k)) && !reported.has(k)).map(sanitize)).size + : present.size const gaps = (current.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length return { mode: `Workspace mode · linked to ${binding.datamateName}`, diff --git a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx index 4b5f8ce66..113654b1d 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx @@ -7,6 +7,7 @@ // Deliberately read-only. All bind mutations live in workspace.tsx / link.ts; // this tile just reflects state. import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import { sanitize } from "@/mcp/catalog" import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import { createSignal, onCleanup, onMount, Show } from "solid-js" import { onBindingChanged, resolveBindingOutcome, type CachedBinding } from "@/altimate/workspace/state" @@ -93,7 +94,10 @@ function View(props: { api: TuiPluginApi }) { if (!snapshot || !bound || snapshot.workspace.id !== String(bound.datamateId)) return setAttachLine(null) const present = new Set(snapshot.present) const declared = snapshot.declared?.keys.length - const served = snapshot.declared ? snapshot.declared.keys.filter((k) => present.has(k)).length : present.size + const reported = new Set((snapshot.unfulfilled ?? []).map((u) => u.key)) + const served = snapshot.declared + ? new Set(snapshot.declared.keys.filter((k) => present.has(sanitize(k)) && !reported.has(k)).map(sanitize)).size + : present.size const gaps = (snapshot.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length setAttachLine(statusHeadline({ served, declared, gaps, extServed: snapshot.extServed, rows: [] })) } diff --git a/packages/opencode/test/altimate/workspace/status-view.test.ts b/packages/opencode/test/altimate/workspace/status-view.test.ts index a5aacc5f4..e89749fe2 100644 --- a/packages/opencode/test/altimate/workspace/status-view.test.ts +++ b/packages/opencode/test/altimate/workspace/status-view.test.ts @@ -44,7 +44,7 @@ describe("buildStatusView", () => { const jira = view.rows.find((r) => r.name === "Jira")! expect(jira.gaps.map((g) => g.phrase)).toEqual(["no usable connection", "no usable connection"]) expect(rowLine(jira)).toBe("0 of 2 · no usable connection") - expect(rowLine(view.rows[0]!)).toBe("0 of 1 · server failed to start (altimate-demo-missing-mcp: ENOENT)") + expect(rowLine(view.rows[0]!)).toBe("0 of 1 · server could not be started or reached (altimate-demo-missing-mcp: ENOENT)") expect(rowLine(view.rows.find((r) => r.name === "Altimate")!)).toBe("2 of 2") expect(rowLine(view.rows.find((r) => r.name === "Power User for dbt")!)).toBe( "0 of 1 · needs a VS Code window open on this project", @@ -74,7 +74,7 @@ describe("buildStatusView", () => { expect(altimate.state).toBe("partial") expect(rowLine(altimate)).toBe("1 of 2 · failed to load") expect(view.rows.find((r) => r.name === "Power User for dbt")!.state).toBe("served") - expect(statusHeadline(view)).toBe("1 of 5 integration tools available · 1 need attention · 1 more via VS Code") + expect(statusHeadline(view)).toBe("1 of 5 integration tools available · 1 needs attention · 1 more via VS Code") }) test("a report for an integration the selection no longer lists still gets a row", () => {