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 82a6724c7..c841a4755 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, @@ -60,6 +58,7 @@ import { type Toast, UNFULFILLED_META_KEY, } from "./engine-types" +import { readAttachSnapshot, writeAttachSnapshot, type AttachSnapshot } from "./attach-snapshot" export * from "./engine-types" export * from "./engine-offer" @@ -138,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 @@ -249,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 } @@ -263,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) { @@ -318,6 +320,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) @@ -711,6 +726,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: @@ -740,12 +766,18 @@ 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, + }), // Severity follows what is callable, not only what is reported: two raw // keys that sanitise to one catalog entry leave the headline short with an // empty report. With no report nothing is claimed, so that stays info. @@ -756,6 +788,26 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS }) } +/** 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${input.gaps === 1 ? "s" : ""} 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 @@ -825,6 +877,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 590f44681..894af962b 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -304,48 +304,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..ee8484efe --- /dev/null +++ b/packages/opencode/src/altimate/workspace/status-view.ts @@ -0,0 +1,217 @@ +// 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 { sanitize } from "@/mcp/catalog" +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() + // 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) + 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(sanitize(k)) && !reportedKeys.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 + // 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, + 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${view.gaps === 1 ? "s" : ""} 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/altimate/workspace/welcome-lines.ts b/packages/opencode/src/altimate/workspace/welcome-lines.ts new file mode 100644 index 000000000..92cf2e169 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/welcome-lines.ts @@ -0,0 +1,60 @@ +// 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 { sanitize } from "@/mcp/catalog" +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 + // 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}`, + 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(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 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: [] })) + } + // altimate_change end let refreshInFlight = false let refreshQueued = false @@ -189,6 +211,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 +296,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 +334,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-welcome.tsx b/packages/opencode/src/plugin/tui/altimate/workspace-welcome.tsx new file mode 100644 index 000000000..b382e6af5 --- /dev/null +++ b/packages/opencode/src/plugin/tui/altimate/workspace-welcome.tsx @@ -0,0 +1,69 @@ +// altimate_change - new file +// The workspace-mode block inside the boot box, under "What is Altimate Code": +// which mode and workspace this is, the slash commands the mode adds, and what +// the last session got from the workspace. Registered only under the +// ALTIMATE_WORKSPACE flag (see ./index.ts), so outside workspace mode the box +// is unchanged. Read-only, like the sidebar tile: the binding from its cache +// file, the attach outcome from its snapshot file. +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" +import { createSignal, onCleanup, onMount } from "solid-js" +import { readLocalBinding, type CachedBinding } from "@/altimate/workspace/state" +import { attachSnapshot } from "@/altimate/workspace/engine-overlay" +import { welcomeLines, type WelcomeLines } from "@/altimate/workspace/welcome-lines" + +const id = "altimate:welcome-workspace" + +/** The box is on screen before the first message and through the session, + * so the integrations line has to pick up the attach after it settles; a + * short poll of two small files is the cheapest way without an event bus. */ +const POLL_MS = 5_000 + +function View(props: { api: TuiPluginApi }) { + const theme = () => 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/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 16ebc2942..21ea4c704 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 { @@ -49,11 +51,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, @@ -125,12 +123,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 @@ -143,12 +136,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 }) } @@ -239,9 +227,7 @@ function OfferDialog(props: OfferProps) { return } // link → picker (fresh-project attach path) - props.api.ui.dialog.replace(() => ( - - )) + props.api.ui.dialog.replace(() => ) }} /> ) @@ -348,11 +334,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", @@ -384,10 +366,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.`, @@ -446,7 +425,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. @@ -1232,8 +1199,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(() => ( @@ -1315,12 +1281,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 @@ -1328,12 +1289,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 }) } @@ -1812,6 +1768,151 @@ 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 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)`, + 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 + ? [ + { + 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${view.engineVersion ? ` (engine ${view.engineVersion})` : ""}.`, + 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 + } + // 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() + }} + /> + )) +} + +/** 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. */ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise { const report = await Manage.status(directory) @@ -1823,6 +1924,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", @@ -1846,8 +1952,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 @@ -1952,9 +2062,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 cdff71bba..62ad90e89 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 { log } from "../../../src/altimate/workspace/engine-seams" import { DATAMATE_KEY } from "../../../src/altimate/datamate-transport" @@ -53,6 +55,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 @@ -95,6 +98,7 @@ function install(opts: { invalidates: 0, probes: 0, toasts: [], + persisted: [], lines: [], clock: 1_000_000, fingerprint: "bin-1", @@ -113,6 +117,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) } @@ -409,8 +416,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") @@ -425,14 +440,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 () => { @@ -444,9 +459,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") }) @@ -461,10 +474,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") }) @@ -479,8 +489,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 () => { @@ -492,7 +502,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") // Fewer callable than declared is a shortfall the user should notice even // though the engine reported nothing. (multi-model review) expect(h.toasts[0].variant).toBe("warning") @@ -508,7 +518,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 () => { @@ -530,7 +540,7 @@ describe("beforeTurn — what a turn boundary does", () => { missing: [], unfulfilled: report, }) - 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") expect(h.toasts[0].variant).toBe("info") }) @@ -540,7 +550,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") }) @@ -554,9 +564,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") }) @@ -572,7 +580,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("a report that turns malformed is logged once per transition, without a second toast", async () => { @@ -601,8 +612,8 @@ describe("beforeTurn — what a turn boundary does", () => { }) test("a gap whose error text changed under the same reason is announced again", async () => { - // The remediation in the toast is the detail; a stale one sends the user - // after the wrong fix. (multi-model review) + // The remediation is the detail; a stale one sends the user after the + // wrong fix. (multi-model review) const gap = (detail: string) => ({ [UNFULFILLED_META_KEY]: [{ key: "gh_list_prs", integrationId: "github-mcp", reason: "spawn-failed", detail }], }) @@ -613,7 +624,10 @@ describe("beforeTurn — what a turn boundary does", () => { h.meta = gap("spawn failed (EACCES)") await beforeTurn("s1") expect(h.toasts).toHaveLength(2) - expect(h.toasts[1].message).toContain("(spawn failed (EACCES)): gh_list_prs") + // The toast carries numbers only; the new error text 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.detail)).toEqual(["spawn failed (EACCES)"]) }) 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 67cd41d8f..8598a0b4e 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..e89749fe2 --- /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 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", + ) + }) + + 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 needs 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/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/opencode/test/mcp/engine-unfulfilled.e2e.test.ts b/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts index 115d7e03c..2db6dba1a 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, @@ -223,13 +223,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() diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 917be70b1..30fa3ec6e 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -485,6 +485,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