opencode/packages/plugin/src/gui/host-apis.ts

2086 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Data } from "@opencode/client/solid"
import type { LocationRef, OpenCodeClient, ProjectListOutput, WorktreeDirectory } from "@opencode/client/promise"
import type { Schema } from "effect"
import type { JSX } from "solid-js"
import type { Store } from "solid-js/store"
import type { Cleanup, OS, Persisted, StoreFrom } from "./core.js"
import type { IconName, Link, SessionPanelMenuItem } from "./registries.js"
/**
* A server the app lists. One ref per id that follows the live connection: read `client`, `data` and `url` from it
* each time, as a restarted or re-authenticated server gets a new controller under the same id.
*/
export interface ServerRef {
/** Host key: "sidecar", an http URL, or `${plugin}:${id}` for servers a plugin contributes. */
readonly id: string
/** Display name; the id when the server has none. */
readonly name: string
/** The server's base URL. */
readonly url: string
/** The HTTP basic password the user configured; undefined when there is none. */
readonly password?: string
/** The server's API client. */
readonly client: OpenCodeClient
/** The server's synced data (sessions, projects, …). Reactive. */
readonly data: Data
/** The app's own local server, or a loopback http connection to its port. */
readonly local: boolean
/** The app's own server, the desktop sidecar. */
readonly builtin: boolean
/** The server's version works with this app; false after a health check found it incompatible. */
readonly compatible: boolean
/** The event connection to this server is up. */
readonly connected: boolean
}
/** A session owned by an open shell tab, mounted or not. */
export interface SessionRef {
/** `${server id}\n${session id}`: unique across servers. Key per-session state by it. */
readonly key: string
/** The session's id on its server. */
readonly id: string
/** The key of the shell tab that owns the session. */
readonly tab: string
/** The session's server. */
readonly server: ServerRef
/** The server is still creating the session. */
readonly pending: boolean
/**
* Where the session runs. Undefined until the server reports it, and again while it is unknown, e.g. after the
* server re-authenticates. Layout reads return nothing meanwhile; layout writes wait for it.
*/
readonly location: LocationRef | undefined
}
/** A project as the sidebar lists it, with the worktrees found on disk and the user's local name and icon. */
export type Project = Omit<ProjectListOutput[number], "canonical"> & {
/** The project's root directory. */
worktree: string
/** The project's worktrees. */
worktrees: WorktreeDirectory[]
}
/** A file's content as the server read it. */
export type FileContent = {
/**
* How to read `content`.
* - `text`: `content` is the text.
* - `binary`: `bytes` holds the raw bytes, `content` is base64 when `encoding` says so, or empty when only `size` is known.
*/
type: "text" | "binary"
/** The text, or the base64 bytes of a binary file. */
content: string
/** The raw bytes of a binary file, when kept without base64 encoding. */
bytes?: Uint8Array
/** A unified diff of the file's uncommitted change, when there is one. */
diff?: string
/** The same change, parsed. */
patch?: {
/** The file's name before the change. */
oldFileName: string
/** The file's name after the change. */
newFileName: string
/** The header of the old side. */
oldHeader?: string
/** The header of the new side. */
newHeader?: string
/** The changed regions. */
hunks: Array<{
/** First line of the hunk in the old file. */
oldStart: number
/** Lines of the hunk in the old file. */
oldLines: number
/** First line of the hunk in the new file. */
newStart: number
/** Lines of the hunk in the new file. */
newLines: number
/** The hunk's lines, each prefixed with " ", "+" or "-". */
lines: string[]
}>
/** The diff's index line. */
index?: string
}
/** `base64` when `content` holds encoded bytes. */
encoding?: "base64"
/** The file's MIME type, when the server knows it. */
mimeType?: string
/** On-disk size when the bytes themselves are not retained. */
size?: number
/** A URL the server streams the file from with range requests, set instead of `bytes` for images, audio and video. */
url?: string
}
/** A range of lines in a file, or in one side of its diff. */
export interface LineRange {
/** The first line. */
start: number
/** The last line. */
end: number
/**
* The diff side `start` is on. Omit it for the file itself.
* - `additions`: the new side.
* - `deletions`: the old side.
*/
side?: "additions" | "deletions"
/** The diff side `end` is on, when it differs from `side`. */
endSide?: "additions" | "deletions"
}
/** One file of the session's workspace as the file model holds it. */
export interface FileState {
/** The workspace-relative path. */
path: string
/** The file's name. */
name: string
/** The content has loaded at least once. */
loaded?: boolean
/** A load is in flight. */
loading?: boolean
/** The last load found no such file. */
notFound?: boolean
/** The last load's error message. */
error?: string
/** The loaded content. */
content?: FileContent
}
/** An entry of the workspace file tree. */
export interface FileNode {
/** The entry's name. */
name: string
/** The workspace-relative path. */
path: string
/** The absolute path. */
absolute: string
/**
* What the entry is.
* - `file`: a file.
* - `directory`: a directory.
*/
type: "file" | "directory"
/** Ignored by the workspace's ignore files. */
ignored: boolean
}
/** The session screen's workspace files: content, view state and the file tree, of the routed session. */
export interface Files {
/** The workspace root directory. */
readonly root: string
/** The per-file view state (selection, scroll) has loaded. Reactive. */
ready(): boolean
/**
* Normalizes a path, URL or `file://` link to a workspace-relative path. A path outside the root stays absolute.
*
* @param path - Any path or link to a file.
*/
resolve(path: string): string
/**
* Whether a normalized path is absolute, and so outside the workspace.
*
* @param path - A path from `resolve`.
*/
absolute(path: string): boolean
/**
* The file's state; undefined until `sync` first loads it. Reactive.
*
* @param path - A workspace-relative path.
*/
get(path: string): FileState | undefined
/**
* The last load found no such file. Unlike `get`, it does not touch the content cache.
*
* @param path - A workspace path.
*/
missing(path: string): boolean
/**
* Loads the file's content, once unless forced, and asks the server to watch a workspace file for changes.
*
* @param path - A workspace path.
* @param options - Load options.
*/
sync(
path: string,
options?: {
/** Reloads even when the content has loaded. Defaults to false. */
readonly force?: boolean
/** Suppresses the error toast when the load fails. Defaults to false. */
readonly silent?: boolean
},
): Promise<void>
/**
* Stops the server watch `sync` started, for a file whose tab closed.
*
* @param path - A workspace path.
*/
unwatch(path: string): void
/**
* Whether a file exists, checked by listing its directory without reading the file.
*
* @param path - A workspace-relative or absolute path.
* @returns False when the file is missing or its directory cannot be listed.
*/
exists(path: string): Promise<boolean>
/**
* Searches the workspace by fuzzy path.
*
* @param query - The search text.
* @param options - Search options.
* @returns Matching workspace-relative paths, best first.
*/
search(
query: string,
options?: {
/**
* What to match. Defaults to `file`.
* - `file`: files only.
* - `any`: files and directories.
*/
readonly kind?: "file" | "any"
/** The most results to return. Defaults to the server's limit. */
readonly limit?: number
/** Aborts the search, which then rejects. A search that fails otherwise resolves with no results. */
readonly signal?: AbortSignal
},
): Promise<string[]>
/** The selected line range per file, kept with the session's view state. */
readonly selection: {
/**
* The file's selected range: null when the selection was cleared, undefined when none is stored. Reactive.
*
* @param path - A workspace path.
*/
get(path: string): LineRange | null | undefined
/**
* Selects a range, or clears the selection with null.
*
* @param path - A workspace path.
* @param range - The range to select.
*/
set(path: string, range: LineRange | null): void
}
/** The scroll offset per file, kept with the session's view state. */
readonly scroll: {
/**
* The file's stored scroll offset.
*
* @param path - A workspace path.
*/
get(path: string): {
/** Vertical offset in CSS pixels; undefined when none is stored. */
readonly top?: number
/** Horizontal offset in CSS pixels; undefined when none is stored. */
readonly left?: number
}
/**
* Stores the file's scroll offset. An offset left out keeps its stored value.
*
* @param path - A workspace path.
* @param value - The offsets to store.
*/
set(
path: string,
value: {
/** Vertical offset in CSS pixels. */
readonly top?: number
/** Horizontal offset in CSS pixels. */
readonly left?: number
},
): void
}
/** The workspace file tree, loaded one directory at a time. */
readonly tree: {
/**
* The loaded entries of a directory; empty until `sync` loads it. Reactive.
*
* @param path - A workspace-relative directory; "" for the root.
*/
list(path: string): readonly FileNode[]
/**
* A directory's tree state; undefined before anything touched it. Reactive.
*
* @param path - A workspace-relative directory.
*/
state(path: string):
| {
/** The directory is expanded. */
expanded: boolean
/** Its entries have loaded. */
loaded?: boolean
/** A load is in flight. */
loading?: boolean
/** The last load's error message. */
error?: string
}
| undefined
/**
* Loads a directory's entries, once unless forced.
*
* @param path - A workspace-relative directory.
* @param options - Load options.
*/
sync(
path: string,
options?: {
/** Lists the directory again even when it has loaded. Defaults to false. */
readonly force?: boolean
},
): Promise<void>
/**
* Expands a directory.
*
* @param path - A workspace-relative directory.
* @param options - Expand options.
*/
expand(
path: string,
options?: {
/** False expands without loading its entries. Defaults to true. */
readonly list?: boolean
},
): void
/**
* Collapses a directory.
*
* @param path - A workspace-relative directory.
*/
collapse(path: string): void
}
}
/** A line comment the user left on a workspace file, before it is sent. */
export interface Comment {
/** The comment's id. */
id: string
/** When it was made, in milliseconds since the epoch. */
time: number
/** The workspace path of the file. */
file: string
/** The lines it is about. */
selection: LineRange
/** The comment text. */
comment: string
}
/** The session screen's line comments, by file, of the routed session's next prompt. */
export interface Comments {
/**
* The comments on one file, or on every file. Reactive.
*
* @param file - A workspace path; omit it for all comments.
*/
list(file?: string): readonly Comment[]
/**
* Adds a comment and focuses it.
*
* @param input - The comment without its id and time.
* @returns The comment with its id and time.
*/
add(input: Omit<Comment, "id" | "time">): Comment
/**
* Replaces a comment's text. An unknown id does nothing.
*
* @param id - The comment's id.
* @param comment - The new text.
*/
update(id: string, comment: string): void
/**
* Removes a comment. An unknown id does nothing.
*
* @param id - The comment's id.
*/
remove(id: string): void
/** The comment a view should reveal and edit, e.g. the one just added. */
readonly focus: {
/** The focused comment, or null. Reactive. */
current(): {
/** The comment's file. */
readonly file: string
/** The comment's id. */
readonly id: string
} | null
/**
* Focuses a comment, or clears the focus with null.
*
* @param value - The comment's file and id.
*/
set(
value: {
/** The comment's file. */
readonly file: string
/** The comment's id. */
readonly id: string
} | null,
): void
}
/** The comment views mark as current. */
readonly active: {
/** The current comment, or null. Reactive. */
current(): {
/** The comment's file. */
readonly file: string
/** The comment's id. */
readonly id: string
} | null
/**
* Marks a comment as current, or clears the mark with null.
*
* @param value - The comment's file and id.
*/
set(
value: {
/** The comment's file. */
readonly file: string
/** The comment's id. */
readonly id: string
} | null,
): void
}
}
/** A workspace file attached to the composer, with an optional line selection and comment. */
export interface ComposerFile {
/** The part kind. */
type: "file"
/** The workspace path. */
path: string
/** The selected text, when only part of the file is attached. */
selection?: {
/** First selected line. */
startLine: number
/** Last selected line. */
endLine: number
/** First selected character on `startLine`. */
startChar: number
/** Character after the selection on `endLine`. */
endChar: number
}
/** Text the chip previews. */
preview?: string
/** The user's comment on the attachment. */
comment?: string
/** The id that `Composer.update` and `detach` take. */
commentID?: string
/**
* The view the comment was made in.
* - `review`: the review panel.
* - `file`: a file tab.
*/
commentOrigin?: "review" | "file"
}
/**
* A reference to something other than workspace lines, such as an element picked in a page, with an optional comment.
* The model reads "The user referenced <subject>." when `comment` is empty, otherwise
* "The user made the following comment regarding <subject>: <comment>".
*/
export interface ComposerNote {
/** The part kind. */
type: "note"
/** The plugin that attached it. Opening the chip routes `Links.open({ href, origin })` to its LinkHandler. */
origin: string
/** The id that `Composer.update` and `detach` take. */
commentID: string
/** Chip text naming the subject, e.g. `button#save`. */
label: string
/** Primary heading while `comment` is empty, e.g. `button#save`. Defaults to `label`; ignored with a comment. */
title?: string
/** Plain-text footer with or without a comment, e.g. `Browser annotation`. Defaults to `label` when omitted. */
subtitle?: string
/** Chip icon. */
icon: IconName
/** What the comment is about, for the model. Quote untrusted text such as page content. */
subject: string
/** The user's comment. An empty string attaches the subject without a comment. */
comment: string
/** The link opening the chip routes, with `origin`. */
href?: string
/**
* Replaces `subject` and `href` while the note stays in this app process, for references only this process
* can resolve (e.g. a page element ref). A stored draft and a sent message restored by revert or fork drop it.
*/
live?: {
/** The subject while the note stays in this process. */
readonly subject: string
/** The link while the note stays in this process. */
readonly href?: string
}
}
/**
* Text the user quoted from something the plugin shows, such as terminal output, with an optional comment. It
* renders like a quote from the session timeline, under the source's icon and label. The model reads "The user quoted
* the following from <subject>:" and the text, each line prefixed with `> `, then the comment when there is one.
*/
export interface ComposerQuote {
/** The part kind. */
type: "quote"
/** The id that `Composer.update`, `detach` and `focus` take. */
commentID: string
/** The quoted text, as plain text. */
text: string
/** The user's comment on the quote. Omit it to let the user add one from the chip. */
comment?: string
/** Where the text came from. It stays with the draft and the sent message. */
source: {
/** Chip text while the quote has no comment, e.g. `Terminal`. */
readonly label: string
/** Chip icon. */
readonly icon: IconName
/** Where the text came from, for the model, e.g. `the terminal`. Not localized. */
readonly subject: string
}
}
/**
* Positions a quote's optional comment editor beside its source, in viewport pixels. Called after the first editor
* measurement, on scroll and resize, and when reactive values it reads change. The editor holds its opening bottom
* edge as its input grows upward. Return undefined when the source is unavailable to use the composer tile instead.
* Never persisted.
*
* @param size - The current editor width and its frozen opening height; never called with an unmeasured zero height.
* @example
* const anchor: ComposerQuoteAnchor = (size) => ({ top: 200, left: 400 - size.width / 2 })
*/
export type ComposerQuoteAnchor = (size: {
/** Current editor width in pixels. */
width: number
/** Measured opening height in pixels, unchanged as the input grows. */
height: number
}) =>
| {
/** Physical distance from the viewport's top edge, in pixels. Clamped to the viewport. */
top: number
/** Physical distance from the viewport's left edge, in pixels. Clamped to the viewport. */
left: number
}
| undefined
/** Optional placement for a newly attached quote's comment editor. */
export interface ComposerFocusOptions {
/** Positions the editor beside its source instead of its tile. Omit for tile placement. */
anchor?: ComposerQuoteAnchor
}
/** A model the composer sends a prompt with, as the model picker names it. */
export interface ComposerModel {
/** The provider's id, e.g. `anthropic`. */
providerID: string
/** The model's id within its provider, e.g. `claude-sonnet-4-5`. */
modelID: string
/** The model's variant, e.g. `high`. Undefined for the model's default. */
variant?: string
}
/**
* The session screen's composer: the routed session's next prompt, with its text, its attached parts and the model it
* is sent with. Nothing here sends the prompt; the user does. A write to the draft (`insert`, `setText`, `attach`,
* `update`, `detach`) made before the session's stored draft loads waits for it, then applies to the session routed
* when it was made, in call order with the other draft writes; the stored draft is never replaced.
*/
export interface Composer {
/**
* The prompt's text, as plain text: a mention reads as its `@` text, and attachments and attached parts are left
* out. In shell mode, the shell command. Empty until the session's draft loads, and in a subagent's session, which
* has no composer. Reactive.
*
* @example
* const empty = () => screen.composer.text().trim() === ""
*/
text(): string
/**
* Inserts plain text into the prompt as typing would, at the cursor the user last left in it, else at the end, and
* moves the cursor after it. Selected text is not replaced. The text is not parsed: `@path` stays text rather than a
* mention, and line breaks stay line breaks. Focus stays where it is; call `focus` to move it. Before the session's
* draft loads, it waits and applies in call order. Does nothing in a subagent's session, which has no composer, or
* for empty text.
*
* @param text - The text to insert.
*
* @example
* screen.composer.insert(`Explain ${path}`)
*/
insert(text: string): void
/**
* Replaces the prompt's text, mentions included, and moves the cursor to the end. Attachments and attached parts
* stay. Before the session's draft loads, it waits and applies in call order. Does nothing in a subagent's session,
* which has no composer.
*
* @param text - The new text. Empty clears the text.
*/
setText(text: string): void
/**
* The model the next prompt is sent with: the user's choice for the routed session, else the session's agent
* default, else the app's default. Undefined while the model catalog loads, when no connected provider offers the
* choice, and in a subagent's session, which has no composer. Reactive.
*/
model(): ComposerModel | undefined
/**
* Selects the model for the routed session's next prompts, as picking it in the model picker does: it becomes the
* session's choice and the first recent model. Without `variant`, the model keeps the variant the picker would show
* for it. Returns false, and changes nothing, while the model catalog loads, when no connected provider offers the
* model, when the model has no such variant, and in a subagent's session.
*
* @param model - The model, and optionally its variant.
*/
setModel(model: ComposerModel): boolean
/**
* Selects a variant of the current model, as the composer's variant control does. Returns false, and changes
* nothing, when there is no current model, when the model has no such variant, and in a subagent's session.
*
* @param variant - The variant. Undefined selects the model's default.
*/
setVariant(variant: string | undefined): boolean
/**
* Attaches a file, a note or a quote to the next prompt. Focus stays where it is; call `focus` to move it. Before
* the session's draft loads, it waits and applies in call order.
*
* @param part - The part to attach.
*/
attach(part: ComposerFile | ComposerNote | ComposerQuote): void
/**
* Whether the composer takes quotes now. False in a subagent's session, which has no composer, and while the
* composer runs shell commands. Reactive.
*
* @example
* if (screen.composer.quotable()) screen.composer.attach(quote)
*/
quotable(): boolean
/**
* Moves focus to the prompt, at its cursor. With the `commentID` of an attached quote, opens that quote's comment
* editor instead. While the composer is not shown, such as on a narrow screen showing a panel, the prompt keeps
* its focus and the comment editor opens once the composer shows.
*
* @param commentID - The attached quote whose comment editor to open. Omit it to focus the prompt.
* @param options - Optional placement beside the quote's source. Omit to anchor to its composer tile.
*/
focus(commentID?: string, options?: ComposerFocusOptions): void
/**
* Changes an attached part. Files, notes and quotes alike; a note or a quote takes only `comment`. Before the
* session's draft loads, it waits and applies in call order.
*
* @param id - The part's `commentID`.
* @param patch - The fields to change.
*/
update(
id: string,
patch: {
/** The new comment. */
readonly comment?: string
/** The new preview, for a file. */
readonly preview?: string
},
): void
/**
* Removes an attached part. Before the session's draft loads, it waits and applies in call order.
*
* @param id - The part's `commentID`.
*/
detach(id: string): void
/**
* Whether keyboard focus is in the prompt now. Not reactive: read it where you act, such as in `Command.run`.
*
* @example
* run: () => (screen.composer.focused() ? send() : false)
*/
focused(): boolean
}
/** Work the session moved to the background. */
export interface BackgroundTask {
/** The task's id. */
id: string
/**
* What runs.
* - `shell`: a shell command.
* - `subagent`: a subagent.
*/
type: "shell" | "subagent"
/** The task's display label. */
label: string
/** The subagent's name, for `subagent` tasks. */
agent?: string
}
/**
* A routed session on the session screen: its identity and data, and nothing that acts on whichever session is
* routed. Slot inputs and panel renders receive it, and `Sessions.current` returns it. One frozen object per routed
* session and directory: a new one each time a session is routed, each time the routed session moves to another
* directory, and once its shell tab is known. `key`, `id`, `tab`, `server`, `directory` and `visit` never change on
* one object, and the other fields read this session's own data, never the route's. A render receives each new
* object through its reactive input instead of remounting, so read `input.session` or `props.session` where you use
* it rather than copying it. Key per-session state by `key`, which a move keeps. The workspace files, comments and
* composer follow the route instead, so they belong to the screen: see `Screen`.
*/
export interface MountedSession extends SessionRef {
/** This routing visit: a new object each time the session is routed, e.g. after Home and back; a move keeps it. */
readonly visit: object
/**
* This session's project. `sandboxes` includes worktrees found on disk; `name` and `icon` carry the user's local
* overrides. Reactive.
*/
readonly project: Project | undefined
/**
* The sidebar project whose worktree or a sandbox is this session's directory, with the user's local name and
* icon. Undefined when no listed project is opened there, e.g. for a session in a project subfolder. Reactive.
*/
readonly listedProject:
| {
/** The project's root directory. */
readonly worktree: string
/** The user's local name for the project. */
readonly name?: string
/** The project's icon. */
readonly icon?: Project["icon"]
}
| undefined
/** The session's workspace directory. When the session moves, the next object carries the new one. */
readonly directory: string
/** The session runs in the project root rather than a worktree. Reactive. */
readonly local: boolean
/** Shell commands and subagents the session moved to the background; empty once another session is routed. */
readonly background: readonly BackgroundTask[]
}
/**
* The session screen: the app's view that shows the routed session, and stays the same object while it routes A, then
* B, then A again. It follows the route on purpose: its files, comments and composer are the screen's workspace models,
* and every action through them targets the session routed at that moment. Read it where you act, or pass it to the
* views that render inside it; a `MountedSession` you keep never acts on another session.
*/
export interface SessionScreen {
/** The routed workspace's files. */
readonly file: Files
/** The line comments of the routed session's next prompt. */
readonly comment: Comments
/** The routed session's composer. */
readonly composer: Composer
}
/** The session screen, while it routes the mounted session. */
export interface Screen {
/**
* The mounted session screen: the same object for as long as it stays mounted, whichever session it routes. Defined
* exactly when `Sessions.current()` is, after the screen's first render; undefined on Home, on a draft,
* while the route has left the mounted screen, and before the app interface mounts. Panel callbacks and session
* slots receive a non-null screen directly. Reactive.
*/
current(): SessionScreen | undefined
}
/** The sessions of open shell tabs. */
export interface Sessions {
/** Sessions owned by open shell tabs. Empty until the app interface mounts. Reactive. */
list(): readonly SessionRef[]
/**
* The routed, mounted session: a new object each time a session is routed. Undefined on Home, on a draft, and
* before the app interface mounts and during the screen's first render. Defined exactly when `Screen.current()`
* is, after that first render. Panel and session-slot inputs are available during render directly.
* Reactive.
*/
current(): MountedSession | undefined
}
/**
* Where a panel key stands in a session.
* - `closed`: not in the strip.
* - `open`: in the strip, not the tab its pane shows.
* - `active`: the tab its pane shows, with the pane hidden (the side region closed, or the bottom pane hidden).
* - `visible`: the tab its pane shows, on screen.
*/
export type PanelState = "closed" | "open" | "active" | "visible"
/** How `Layout.open` places a tab. */
export interface OpenOptions {
/**
* How the tab lands in the strip. Defaults to `open`.
* - `open`: select it, reusing the preview slot.
* - `preview`: select it as the new preview tab.
* - `append`: add it at the end without selecting it, changing neither the region nor the preview tab.
* - `select`: add it at the end and select it, keeping the preview tab. Like `background`, it keeps the
* narrow-screen view, and opens the side region.
*/
readonly tab?: "open" | "preview" | "append" | "select"
/**
* On narrow screens, keep the current view, and open the side region so the tab shows when the window
* is wide. Pass it when the user stays where they are (a palette pick, a composer chip) or the agent opened the tab.
* Defaults to false.
*/
readonly background?: boolean
}
/**
* The session layout: side panel tabs, scroll offsets, and the settings and project dialogs. Panel keys are
* `${plugin}:${tab id}`. Nothing throws before the app interface mounts: `ready()` is false, reads return what an
* empty layout holds (each says its default), and writes (`open`, `close`, `toggle`, `hide`, `side.toggle`, `scroll.set`,
* `settings`, `project`) wait and apply in call order once it mounts.
*/
export interface Layout {
/** Viewport under 768px. Reactive. */
narrow(): boolean
/** Stored layout (tabs, scroll) has loaded; false before the app interface mounts. Reactive. */
ready(): boolean
/**
* Opens a panel tab. Works for sessions that are not mounted. On narrow screens, an `open` or `preview` selects the
* panel's mobile view; opening a tab its panel does not list, or a `transient` tab, stores nothing. A transient tab
* is never selected on narrow screens, but a stored one stays the preview slot. Writes (`open`, `close`, `toggle`, `scroll.set`) made while `session.location` is
* unknown wait until it is known, and apply in order.
*
* @param key - `${plugin}:${tab id}`.
* @param session - The session whose strip changes.
* @param options - How the tab lands; see `OpenOptions`.
*/
open(key: string, session: SessionRef, options?: OpenOptions): void
/**
* Removes a tab from the strip and calls its panel's `close`.
*
* @param key - `${plugin}:${tab id}`.
* @param session - The session whose strip changes.
*/
close(key: string, session: SessionRef): void
/**
* Closes a visible tab, else opens it. Closing the last panel the side region was opened for also closes the region.
*
* @param key - `${plugin}:${tab id}`.
* @param session - The session whose strip changes.
*/
toggle(key: string, session: SessionRef): void
/**
* Hides the pane that shows a visible tab without closing the tab: the side region for a tab in the side panes, the
* bottom pane for a tab the user moved there. Does nothing unless `state` is "visible". Opening the tab again shows
* its pane.
*
* @param key - `${plugin}:${tab id}`.
* @param session - The session whose pane hides.
*/
hide(key: string, session: SessionRef): void
/**
* Where a key stands. "closed" while the session's location is unknown and before the app interface mounts.
* Reactive.
*
* @param key - `${plugin}:${tab id}`.
* @param session - Any session.
*/
state(key: string, session: SessionRef): PanelState
/**
* This plugin's tab ids stored in the session's side strip, mounted or not. Empty while the session's location
* is unknown, as `state` is then "closed", and before the app interface mounts. Reactive.
*
* @param session - Any session.
*/
stored(session: SessionRef): readonly string[]
/** The side region. */
readonly side: {
/**
* The side region is open; false before the app interface mounts. Reactive.
*
* @param session - Any session.
*/
opened(session: SessionRef): boolean
/**
* Opens or closes the side region.
*
* @param session - Any session.
*/
toggle(session: SessionRef): void
/**
* The + menu's items: every plugin's `session.panel` `MenuItem`, in `order`. Use it to list them on a page of
* your own, such as a new tab page. Empty before the app interface mounts. Reactive.
*
* @example
* ```ts
* const launchers = () => ctx.layout.side.menu().filter((item) => !item.primary && !item.opened)
* ```
*/
menu(): readonly SessionPanelMenuItem[]
}
/**
* The inner sidebar preference every side panel shares, which `usePanel().sidebar` also reads inside a panel render.
* One value for the app, for code outside a render such as a tab's fields. Reactive.
*/
readonly sidebar: {
/** The inner sidebar is open; true until a session screen shows the preference. Reactive. */
opened(): boolean
}
/** Scroll offsets the host stores per session and key. */
readonly scroll: {
/**
* The stored offset; undefined when none is stored, the location is unknown, or the app interface is not mounted.
*
* @param session - Any session.
* @param key - Your own key, e.g. a panel key.
*/
get(
session: SessionRef,
key: string,
):
| {
/** Horizontal offset in CSS pixels. */
readonly x: number
/** Vertical offset in CSS pixels. */
readonly y: number
}
| undefined
/**
* Stores an offset. Waits while the session's location is unknown.
*
* @param session - Any session.
* @param key - Your own key, e.g. a panel key.
* @param value - The offset.
*/
set(
session: SessionRef,
key: string,
value: {
/** Horizontal offset in CSS pixels. */
readonly x: number
/** Vertical offset in CSS pixels. */
readonly y: number
},
): void
}
/**
* Opens Settings.
*
* @param page - A `SettingsPage` id you contributed, or a host page tab; defaults to `general`.
*/
settings(page?: string): void
/**
* Opens a project on a server: a directory picker titled `title`, then a new draft. Waits until the server is listed.
*
* @param server - The server's `ServerRef.id`.
* @param title - The picker's title.
*/
project(server: string, title: string): void
/**
* Opens a new draft session in a directory on a server with its prompt filled in, for the user to review and send.
* The draft opens in its own tab, as a new session from Home does. Does nothing when the app does not list the
* server.
*
* @param input - Where the draft runs and what it asks.
*
* @example
* ```ts
* ctx.layout.draft({ server: session.server.id, directory, prompt: ctx.t("setup.prompt") })
* ```
*/
draft(input: {
/** The server's `ServerRef.id`. */
readonly server: string
/** The directory the new session runs in. */
readonly directory: string
/** The prompt the draft starts with. */
readonly prompt: string
}): void
}
/**
* Where `Storage.store` keeps a value.
* - `"global"`: one value for the app. The default.
* - `{ server, directory? }`: one value per server, or per workspace directory on it. Opens once the app interface
* mounts; until then `value` is undefined and `update` waits.
* - `{ session }`: one value per session. Also waits for the session's location, and opens again in a new directory;
* a declared `Store.session` does the same for every session.
*/
export type StorageScope =
| "global"
| {
/** The server's `ServerRef.id`. */
readonly server: string
/** A workspace directory on that server; omit it for one value per server. */
readonly directory?: string
}
| {
/** The session. */
readonly session: SessionRef
}
/** What `Storage.store` opens. */
export interface StoreOptions<S extends Schema.ConstraintCodec<object, unknown>> {
/** Decodes the stored JSON, as `StoreDeclaration.schema` does. */
readonly schema: S
/** The value before anything is stored, and after `remove`. */
readonly initial: S["Type"]
/** Where the value lives. Defaults to `"global"`. */
readonly scope?: StorageScope
/**
* Imports an older key of the same storage once (the raw stored key, e.g. `"plugin.acme.recent"`), or the first of
* several that holds a value. See `StoreFrom`.
*/
readonly from?: StoreFrom | readonly StoreFrom[]
}
/**
* Window storage, in the plugin's namespace (`plugin.<id>.<key>`). Synced across windows. On desktop it loads
* over IPC, so a value is undefined until it loads; on the web it is synchronous.
*/
export interface Storage {
/**
* Opens a durable, schema-decoded store. For keys only known at runtime, such as one per server and directory;
* declare `stores` in the definition for keys known up front. Derive nothing from it, such as a request, before
* `value` is defined.
*
* @param key - The store's key in your namespace.
* @param options - Its schema, initial value, scope and older homes.
*/
store<S extends Schema.ConstraintCodec<object, unknown>>(key: string, options: StoreOptions<S>): Persisted<S["Type"]>
/**
* Window-local state that survives plugin reloads, but not a window reload. The first open of a key creates it;
* later opens return the same store and ignore `initial`.
*
* @param key - The store's key in your namespace.
* @param options - The store's options.
* @returns The store and its setter, which edits a draft.
*/
memory<T extends object>(
key: string,
options: {
/** The value the first open creates. */
readonly initial: T
},
): readonly [Store<T>, (mutation: (draft: T) => void) => void]
/**
* Deletes the value, so opening the key again reads its `initial`. Pass the store's `scope` and `from`: an older key
* `from` names is deleted too, so it is never imported again. A server or session store's removal waits, in call
* order, until the app interface mounts and the session's location is known.
*
* @param key - The store's key in your namespace.
* @param options - The store's scope and older homes.
*/
remove(
key: string,
options?: {
/** The store's scope. Defaults to `"global"`. */
readonly scope?: StorageScope
/** The store's `from`. */
readonly from?: StoreFrom | readonly StoreFrom[]
},
): void
}
/** System services that work on every platform. */
export interface System {
/**
* Copies text to the clipboard.
*
* @param text - The text to copy.
*/
copy(text: string): Promise<void>
/**
* Saves a file: a save dialog on desktop, a download on the web.
*
* @param file - The suggested name and the content.
* @returns False when the user cancelled the desktop dialog; always true on the web.
*/
save(file: {
/** The suggested file name. */
readonly name: string
/** The file's content. */
readonly content: string
}): Promise<boolean>
/**
* Opens a URL in the system browser; desktop opens file:// URLs with the default app. For links inside the app,
* use `Links.open`.
*
* @param url - The URL to open.
*/
openExternal(url: string): void
}
/** Desktop-only abilities; `ctx.desktop` is undefined on the web. */
export interface Desktop {
/** The operating system. */
readonly os: OS
/** The window's zoom factor; 1 is 100%. */
zoom(): number
/**
* Opens a path with an app, or with the default app.
*
* @param path - An absolute path.
* @param app - The app's name or path; omit it for the default app.
*/
launch(path: string, app?: string): Promise<void>
/**
* Shows a path in the system file manager.
*
* @param path - An absolute path.
* @returns False when it could not.
*/
reveal(path: string): Promise<boolean>
/**
* Whether an app is installed.
*
* @param app - The app's name.
*/
installed(app: string): Promise<boolean>
/**
* Asks the user for files or folders with the system's open dialog. Desktop only: a browser never tells the page
* where a picked file is.
*
* @param options - What to pick.
* @returns The absolute paths the user picked, in the order the dialog reports them; empty when the user cancelled.
* Rejects when the dialog cannot open.
*
* @example
* ```ts
* const [folder] = (await ctx.desktop?.pick({ directory: true, title: ctx.t("pick.folder") })) ?? []
* ```
*/
pick(options?: PickOptions): Promise<readonly string[]>
}
/** What `Desktop.pick` asks for. */
export interface PickOptions {
/** Picks folders instead of files. Defaults to false. */
readonly directory?: boolean
/** Lets the user pick more than one. Defaults to false. */
readonly multiple?: boolean
/** The dialog's title; the system's default title when omitted. Not every system shows it. */
readonly title?: string
/** File extensions to offer, without the dot (e.g. `["json"]`); every file when omitted. Ignored for folders. */
readonly extensions?: readonly string[]
}
/** The interface language and its writing direction. */
export interface Locale {
/** BCP 47 locale of the interface language, for Intl formatting. Reactive. */
locale(): string
/**
* The writing direction. Reactive.
* - `ltr`: left to right.
* - `rtl`: right to left.
*/
direction(): "ltr" | "rtl"
}
/** The user's appearance settings. */
export interface Appearance {
/**
* The CSS font family the user chose; the default mono font before the app interface mounts. Reactive.
*
* @param kind - `mono`, the terminal font.
*/
font(kind: "mono"): string
/**
* The color scheme the interface shows: the user's choice in Settings › Appearance, with "System" resolved to the
* system's current scheme. Before the app interface mounts, the system's scheme. Reactive.
* - `light`: dark text on light backgrounds.
* - `dark`: light text on dark backgrounds.
*
* @example
* ```ts
* const background = () => (ctx.appearance.mode() === "dark" ? "#000" : "#fff")
* ```
*/
mode(): "light" | "dark"
}
/** The app's route. */
export interface Router {
/** A route transition is in progress. Reactive. */
routing(): boolean
/** The current route path with its query string; "" before the app interface mounts. Reactive. */
path(): string
}
/** The effective keybinds of published commands. */
export interface Keybinds {
/**
* Display parts of a published command's effective keybind, e.g. ["Ctrl", "K"]. Empty when unbound. Reactive.
*
* @param command - A published command id, `${plugin}.${id}`.
*/
keybind(command: string): readonly string[]
/**
* Display parts of a chord the app does not own, e.g. "mod+shift+c" that a page handles itself.
*
* @param bind - A chord in keybind syntax.
*/
keys(bind: string): readonly string[]
/**
* The event matches a published command's effective keybind.
*
* @param command - A published command id.
* @param event - The keyboard event.
*/
matches(command: string, event: KeyboardEvent): boolean
}
/** The servers the app lists. */
export interface Servers {
/** Ids of the servers the app lists (`ServerRef.id`). Reactive. */
list(): readonly string[]
/**
* A listed server's ref, already authenticated for this window: the desktop's own server included, whose credentials
* stay in the main process. The same ref for the same id; read its `client` each time you call it. Reactive.
*
* @param id - The server's id, as `list()` returns it.
* @returns Undefined while the app does not list the server, and before the app interface mounts.
*
* @example
* ```ts
* const builtin = () => ctx.servers.list().map((id) => ctx.servers.get(id)).find((server) => server?.builtin)
* const info = () => builtin()?.client.server.info()
* ```
*/
get(id: string): ServerRef | undefined
}
/** Workspace lifecycle events. */
export interface Workspaces {
/**
* Listens to an event. The listener is removed with the current owner, else with the plugin.
*
* @param event - `remove`: the user removed a workspace directory from a server.
* @param handler - Receives the server's id and the directory.
* @returns Removes the listener.
*/
on(
event: "remove",
handler: (value: {
/** The server's `ServerRef.id`. */
readonly server: string
/** The removed directory. */
readonly directory: string
}) => void,
): Cleanup
}
/** Routes local links to the plugins that handle them. */
export interface Links {
/**
* Routes a local link to the LinkHandler with the highest priority that matches it.
*
* @param link - The link to open.
* @returns False when no handler matches.
*/
open(link: Link): boolean
/**
* Whether a link's target exists, so text that names it can be styled as a link. Asks the LinkHandler `open` would
* use. Untracked: calling it inside an effect does not subscribe that effect.
*
* @param link - The candidate link.
* @returns False when no handler matches, the handler has no `exists`, or the target does not exist.
*/
exists(link: Link): boolean | Promise<boolean>
}
/** One dialog `Dialogs.open` opened. */
export interface DialogHandle {
/**
* Closes this dialog, wherever it is in the stack; another dialog stays open. A dialog that still waits for the app
* interface never shows. Does nothing once the dialog closed, including when it never opened because its plugin
* went away first.
*/
close(): void
}
/**
* Dialogs. The render runs with this plugin's context. A dialog closes when the owner that opened it ends (the
* component or `createKeyed` run), else when the plugin goes away.
*/
export interface Dialogs {
/**
* Opens a dialog above the open ones. Opening is deferred to a transition, so a dialog opened while its owner ends
* never shows. Before the app interface mounts, as during setup, the dialog waits and shows after the interface's
* first render, behind the writes made before it: never behind the startup screen, and never before the restored
* route takes focus. A render that throws closes the dialog and records the error.
*
* @param render - Renders the dialog's content; receives the dialog's handle, so the content can close itself.
* @param options - How the dialog opens.
* @returns The dialog's handle.
*
* @example
* ```ts
* const dialog = ctx.dialogs.open((dialog) => <Confirm onDone={dialog.close} />)
* ```
*/
open(
render: (dialog: DialogHandle) => JSX.Element,
options?: {
/** Closes every open dialog first, the host's and other plugins' too. Defaults to false. */
readonly replace?: boolean
},
): DialogHandle
/** Some dialog is open. Reactive. */
active(): boolean
/**
* Asks the user to confirm an action, in the app's own confirmation dialog. Opens as `open` does, with the same
* lifetime: it waits for the app interface, and closes with the owner that asked, else with the plugin.
*
* @param options - The question and its buttons.
* @returns True only when the user chose the confirm button. False when they chose cancel, pressed Escape or clicked
* outside, when another dialog replaced it, and when it closed or never opened because its owner or plugin went
* away.
*
* @example
* ```ts
* if (await ctx.dialogs.confirm({ title: ctx.t("end.title"), confirm: ctx.t("end"), danger: true })) end()
* ```
*/
confirm(options: ConfirmOptions): Promise<boolean>
/**
* Tells the user something they must acknowledge, in a dialog with one button. Opens and closes as `confirm` does.
*
* @param options - What the dialog says.
* @returns Resolves once the dialog closes, however it closes.
*
* @example
* ```ts
* await ctx.dialogs.alert({ title: ctx.t("export.done"), description: path })
* ```
*/
alert(options: AlertOptions): Promise<void>
/**
* Asks the user for a line of text, in the app's own dialog. Enter submits. Opens and closes as `confirm` does.
*
* @param options - The question, the field and its button.
* @returns The text the user submits, which is never empty: the submit button and Enter do nothing while the field
* is empty. Undefined when the user cancels, dismisses the dialog, or it closes with its owner.
*
* @example
* ```ts
* const invite = await ctx.dialogs.prompt({ title: ctx.t("join.title"), placeholder: "https://…" })
* ```
*/
prompt(options: PromptOptions): Promise<string | undefined>
/**
* Asks the user to pick one option from a searchable list, in the app's own dialog, as the command palette lists
* commands. Opens and closes as `confirm` does.
*
* @param options - The title, the search field and the options.
* @returns The picked option's `value`; undefined when the user dismisses the dialog or it closes with its owner.
*
* @example
* ```ts
* const url = await ctx.dialogs.select({ title: ctx.t("links"), options: links.map((url) => ({ title: url, value: url })) })
* ```
*/
select<T>(options: SelectOptions<T>): Promise<T | undefined>
}
/** What `Dialogs.confirm` asks. */
export interface ConfirmOptions {
/** The question, as the dialog's title. */
readonly title: string
/** More detail under the title, such as what the action cannot undo. Omit it for none. */
readonly description?: string
/** The confirm button's label. Defaults to the app's "Confirm". */
readonly confirm?: string
/** The cancel button's label. Defaults to the app's "Cancel". */
readonly cancel?: string
/**
* Styles the confirm button as destructive, for an action that deletes or ends something, and starts focus on
* Cancel. Defaults to false.
*/
readonly danger?: boolean
}
/** What `Dialogs.alert` says. */
export interface AlertOptions {
/** The headline, as the dialog's title. */
readonly title: string
/** More detail under the title. Omit it for none. */
readonly description?: string
/** The button's label. Defaults to the app's "OK". */
readonly ok?: string
}
/** What `Dialogs.prompt` asks. */
export interface PromptOptions {
/** The question, as the dialog's title. */
readonly title: string
/** More detail under the title. Omit it for none. */
readonly description?: string
/** The field's placeholder. Omit it for none. */
readonly placeholder?: string
/** The field's initial text, selected so typing replaces it. Defaults to "". */
readonly value?: string
/**
* Hides what the user types, for a password or a token. The host neither stores nor logs the value. Defaults to
* false.
*/
readonly secret?: boolean
/** The submit button's label. Defaults to the app's "Submit". */
readonly submit?: string
}
/** One option of `Dialogs.select`. */
export interface SelectOption<T> {
/** The option's label; search matches it. */
readonly title: string
/** What `select` resolves with when the user picks this option. */
readonly value: T
/** A muted second line; search matches it too. Omit it for none. */
readonly description?: string
/** The heading the option lists under. Options of one group list together, in the order first seen. */
readonly group?: string
}
/** What `Dialogs.select` asks. */
export interface SelectOptions<T> {
/** The dialog's title. */
readonly title: string
/** The search field's placeholder. Defaults to the app's "Search". */
readonly placeholder?: string
/** The options, in display order. */
readonly options: readonly SelectOption<T>[]
/** The value of the option to highlight first, compared with `===`. Defaults to the first option. */
readonly current?: T
}
/** A button on a toast. */
export interface ToastAction {
/** The button's label. */
readonly label: string
/** Runs when the user clicks it; the toast then closes. A throw is logged to the console and the toast still closes. */
run(): void
}
/** What `Toasts.show` shows. */
export interface ToastOptions {
/** The headline. */
readonly title: string
/** More detail under the title. Omit it for none. */
readonly description?: string
/**
* The toast's tone. Defaults to `default`.
* - `default`: neutral information.
* - `success`: something finished; shows a check icon unless `icon` is set.
* - `error`: something failed.
* - `loading`: something is in progress. Pair it with `persistent` and dismiss it when the work ends.
*/
readonly variant?: "default" | "success" | "error" | "loading"
/** An icon before the title. Omit it for none, or the variant's own. */
readonly icon?: IconName
/** Milliseconds before the toast closes itself. Ignored when `persistent` is true. Defaults to the app's 5 seconds. */
readonly duration?: number
/** Stays until the user or `dismiss` closes it, instead of closing after `duration`. Defaults to false. */
readonly persistent?: boolean
/** Buttons on the toast, in order. Omit it for none. */
readonly actions?: readonly ToastAction[]
}
/** One toast `Toasts.show` showed. */
export interface ToastHandle {
/** Closes the toast. Does nothing once it closed. */
dismiss(): void
}
/**
* The app's toasts: short messages in a corner of the window that do not take focus. Every toast of a plugin that is
* still showing closes when the plugin goes away (disabled, reloaded or removed), so no button outlives the code it
* runs; a toast does not close with the component or `createKeyed` run that showed it.
*/
export interface Toasts {
/**
* Shows a toast at once, also before the app interface mounts: it appears as soon as the window shows its toasts.
* Showing a toast equal to one still showing (same title, description, variant, duration, persistence and action
* labels) replaces that one instead of stacking a copy.
*
* @param options - What the toast shows.
* @returns The toast's handle.
*/
show(options: ToastOptions): ToastHandle
}
/** What `Attention.notify` sends. */
export interface NotifyOptions {
/** The notification's headline. */
readonly title: string
/** The notification's body. Omit it for none. */
readonly message?: string
/**
* Which of the user's notification settings it follows, both the system notification and the sound. Defaults to
* `default`.
* - `default`: Settings › Notifications › Agent ("complete or needs attention") and its sound.
* - `error`: Settings › Notifications › Errors and its sound.
*/
readonly variant?: "default" | "error"
/** The session the notification is about: clicking the notification opens and shows it, as `Tabs.open` does. */
readonly session?: {
/** The server's `ServerRef.id`. */
readonly server: string
/** The session's id on that server. */
readonly id: string
}
/** Plays the sound the user chose for `variant`, when its sound is on. False never plays one. Defaults to true. */
readonly sound?: boolean
/**
* Shows the notification and plays its sound at most once across the app's windows, each of which runs its own
* instance of the plugin: the first window to send an id wins, and later sends with that id do nothing. Use an id
* of the event, such as a run's id. Omit it to always notify.
*/
readonly id?: string
}
/** What `Attention.notify` did. */
export interface NotifyResult {
/**
* The app asked the system to show the notification. False when the user turned it off for `variant`, when one of
* the app's windows has focus, or when another window already sent `id`. The system may still hide one it was
* asked to show, for example when the user denied a browser's permission.
*/
readonly notification: boolean
/** The sound started. False when `sound` is false, the user turned it off for `variant`, or `id` was sent before. */
readonly sound: boolean
}
/** Gets the user's attention while they look elsewhere, with the same rules as the app's own notifications. */
export interface Attention {
/**
* Sends a system notification while no window of the app has focus, and plays a sound, each only when the user's
* notification settings for `variant` allow it. Clicking the notification brings the window forward and opens
* `session`, when given. Before the app interface mounts, it waits for it.
*
* @param options - What to send.
* @returns What it did; never rejects.
*/
notify(options: NotifyOptions): Promise<NotifyResult>
}
/** The commands the app runs from its command palette, keybinds and slash menu. */
export interface Commands {
/**
* Runs a command as choosing it in the command palette does: a published plugin command (`${plugin}.${id}`, any
* plugin's) or one of the app's own. The app's command ids are not a stable API and may change between releases.
* A command that is listed but disabled does not run. A command that runs async work is not awaited.
*
* @param id - The command's id.
* @returns False when no enabled command has the id, and before the app interface mounts, when the app has
* registered no commands yet.
*/
run(id: string): boolean
}
/** One tab of the shell's tab strip. */
export interface ShellTab {
/** The tab's key, `SessionRef.tab` for a session's tab. Stays the same while the tab is open. */
readonly key: string
/** The session the tab shows; undefined for a draft. */
readonly session: SessionRef | undefined
/** The tab is the one on screen. */
readonly active: boolean
}
/**
* The shell's tab strip: the sessions and drafts open as tabs. Nothing throws before the app interface mounts:
* `list` is empty, and writes wait and apply in call order once it mounts.
*/
export interface Tabs {
/** The open tabs, in strip order. Reactive. */
list(): readonly ShellTab[]
/**
* Opens a session as a tab at the end of the strip, or keeps its open tab where it is. A tab that shows the session
* already, such as a subagent opened from its parent's tab, is its tab. Does nothing when the app does not list the
* server. A session id the server does not know still opens a tab, which shows the app's "session not found" page.
*
* @param session - The session's server and id.
* @param options - Whether to show the tab.
*
* @example
* ```ts
* ctx.tabs.open({ server: session.server.id, id: session.id }, { focus: true })
* ```
*/
open(
session: {
/** The server's `ServerRef.id`. */
readonly server: string
/** The session's id on that server. */
readonly id: string
},
options?: {
/**
* Also shows the tab and the session in it, as clicking the session in the session list does. Defaults to
* false: the tab opens in the background.
*/
readonly focus?: boolean
},
): void
/**
* Shows an open tab, as a click on it does. Does nothing when no open tab has the key.
*
* @param key - The tab's `ShellTab.key`.
*/
focus(key: string): void
/**
* Moves an open tab to a place in the strip. Does nothing when no open tab has the key.
*
* @param key - The tab's `ShellTab.key`.
* @param index - The tab's new place, from 0; clamped to the strip.
*/
move(key: string, index: number): void
/**
* Closes an open tab, as the user does: it lists under recently closed tabs. Does nothing when no open tab has the key.
*
* @param key - The tab's `ShellTab.key`.
*/
close(key: string): void
}
/** Props of `Embeds.View`. */
export interface EmbedProps {
/** An embed the plugin's main entry created with `Embeds.create`. Undefined renders the box alone. */
readonly id: string | undefined
/** The embed should be on screen. The host also hides it while the window is hidden or a dialog is open. */
readonly visible: boolean
/** Paints a still of the embed in place of the live view, so DOM content can float above it. */
readonly frozen?: boolean
/**
* What the embed shows, such as its URL. While a panel moves, the host paints a still of the embed in its place; a
* still taken while this had another value is dropped rather than shown.
*/
readonly page?: string
/** Radius of the bottom corners in CSS pixels. */
readonly radius?: number
/** CSS color the rounded corners show; defaults to the app backdrop behind the panel. */
readonly background?: string
/** Classes of the box. */
readonly class?: string
/** Rendered inside the box, under the embed. */
readonly children?: JSX.Element
}
/** Web pages (Electron WebContentsViews) the plugin's main entry placed in the window layout. */
export interface Embeds {
/**
* The box a main-process embed fills. The host measures it (webview zoom included), pushes
* coalesced layouts, masks the rounded corners, hides the embed while it is invisible or unmounted,
* and paints a still of it while floating content covers it. Children render inside the box.
*
* @param props - The embed and how to show it.
*/
View(props: EmbedProps): JSX.Element
/**
* A JPEG still of a shown embed; undefined while it is hidden, and always on the web.
*
* @param id - The embed's id.
*/
capture(id: string): Promise<Uint8Array | undefined>
}
/** Props of `Ui.Icon`. */
export interface UiIconProps {
/** The artwork, by its name in the app's icon catalog. */
readonly name: IconName
/**
* The icon's size. Defaults to `normal`.
* - `small`: 14px.
* - `normal`: 16px.
* - `large`: 20px.
*/
readonly size?: "small" | "normal" | "large"
/** Classes of the `svg`, for layout and color: the icon draws in `currentColor`. */
readonly class?: string
}
/** Props of `Ui.Button`. */
export interface UiButtonProps {
/** The button's text. */
readonly children: JSX.Element
/** Runs when the user clicks the button, or presses Enter or Space on it. Not called while it is disabled or busy. */
readonly onClick?: (event: MouseEvent) => void
/**
* How the button looks. Defaults to `neutral`.
* - `neutral`: a bordered button for most actions.
* - `contrast`: the main action of a dialog or form.
* - `ghost`: no border or background until hovered, for toolbars.
* - `ghost-muted`: like `ghost`, with muted text, for secondary toolbar actions.
* - `danger`: an action that deletes or discards.
*/
readonly variant?: "neutral" | "contrast" | "ghost" | "ghost-muted" | "danger"
/**
* The button's height. Defaults to `normal`.
* - `small`: for dense rows.
* - `normal`: for most places.
* - `large`: for toolbars that use large icon buttons.
*/
readonly size?: "small" | "normal" | "large"
/** An icon before the text. Omit it for none. */
readonly icon?: IconName
/** The button cannot be clicked. Defaults to false. */
readonly disabled?: boolean
/**
* The action is running: a loading indicator replaces the icon, and the button cannot be clicked until it is false
* again. Defaults to false.
*/
readonly busy?: boolean
/** `submit` submits the form the button is in. Defaults to `button`. */
readonly type?: "button" | "submit"
/** Takes focus when its dialog opens, as the app's own dialogs focus their main button. Defaults to false. */
readonly autofocus?: boolean
/** Classes of the button, for layout. The app's styles give it its look. */
readonly class?: string
}
/** Props of `Ui.IconButton`. */
export interface UiIconButtonProps {
/** The button's icon. */
readonly icon: IconName
/** The button's accessible name, which screen readers announce. Pass translated copy; it is not shown. */
readonly label: string
/** Runs when the user clicks the button, or presses Enter or Space on it. Not called while it is disabled or busy. */
readonly onClick?: (event: MouseEvent) => void
/**
* How the button looks. Defaults to `ghost-muted`.
* - `neutral`: a bordered button.
* - `ghost`: no border or background until hovered.
* - `ghost-muted`: like `ghost`, with a muted icon, for toolbars.
*/
readonly variant?: "neutral" | "ghost" | "ghost-muted"
/**
* The button's size. Defaults to `normal`.
* - `small`: for dense rows.
* - `normal`: for most places.
* - `large`: for panel toolbars.
*/
readonly size?: "small" | "normal" | "large"
/** The button cannot be clicked. Defaults to false. */
readonly disabled?: boolean
/** The button is a toggle that is on: it shows pressed and reports `aria-pressed`. Omit it for a plain button. */
readonly pressed?: boolean
/**
* The action is running: a loading indicator replaces the icon, and the button cannot be clicked until it is false
* again. Defaults to false.
*/
readonly busy?: boolean
/** Classes of the button, for layout. The app's styles give it its look. */
readonly class?: string
}
/** Props of `Ui.Tooltip`. */
export interface UiTooltipProps {
/** What the tooltip shows: usually translated text. */
readonly value: JSX.Element
/** Keys shown after `value`, such as `ctx.keybinds.keybind(command)`. Omit it, or pass an empty list, for none. */
readonly keys?: readonly string[]
/** Where the tooltip opens from the element. Defaults to `top`; it flips when there is no room. */
readonly placement?: "top" | "bottom" | "left" | "right"
/** The element that shows the tooltip while the pointer rests on it or it has keyboard focus. */
readonly children: JSX.Element
}
/** An action in `UiMenuProps.items`. */
export interface UiMenuItem {
/** Marks the entry as an action. Optional: an entry without `type` is an action. */
readonly type?: "item"
/** The action's text. */
readonly label: string
/** An icon before the text. Omit it for none. */
readonly icon?: IconName
/** Keys shown at the end of the row, such as `ctx.keybinds.keybind(command)`. Omit it for none. */
readonly keys?: readonly string[]
/** The action cannot be chosen. Defaults to false. */
readonly disabled?: boolean
/** Runs when the user chooses the action; the menu then closes. */
onSelect(): void
}
/** A line between entries in `UiMenuProps.items`. */
export interface UiMenuSeparator {
/** Marks the entry as a separator. */
readonly type: "separator"
}
/** Actions under a heading in `UiMenuProps.items`. */
export interface UiMenuGroup {
/** Marks the entry as a group. */
readonly type: "group"
/** The group's heading. */
readonly label: string
/** The group's actions, in order. */
readonly items: readonly UiMenuItem[]
}
/** One entry of `UiMenuProps.items`: an action, a separator, or a group of actions under a heading. */
export type UiMenuEntry = UiMenuItem | UiMenuSeparator | UiMenuGroup
/** Props of `Ui.Menu`. */
export interface UiMenuProps {
/** The trigger's accessible name, and its text when `trigger` is `button`. Pass translated copy. */
readonly label: string
/** The menu's entries, in order. */
readonly items: readonly UiMenuEntry[]
/**
* The button that opens the menu. Defaults to `icon`.
* - `icon`: an icon button, as `Ui.IconButton` with the `ghost-muted` look.
* - `button`: a button that shows `label`, as `Ui.Button`.
*/
readonly trigger?: "icon" | "button"
/** The trigger's icon. Defaults to the "more" dots for an `icon` trigger, and to no icon for a `button` trigger. */
readonly icon?: IconName
/** Where the menu opens from the trigger. Defaults to `bottom-end`; it flips when there is no room. */
readonly placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end"
/** The trigger cannot open the menu. Defaults to false. */
readonly disabled?: boolean
}
/** Props of `Ui.Loader`. */
export interface UiLoaderProps {
/**
* The indicator's size. Defaults to `normal`.
* - `small`: 14px, for buttons and dense rows.
* - `normal`: 16px.
* - `large`: 20px, for a pane that loads.
*/
readonly size?: "small" | "normal" | "large"
/** Classes of the indicator, for layout and color. */
readonly class?: string
}
/** Props of `Ui.Dialog`. */
export interface UiDialogProps {
/** The dialog's title, which also names it for screen readers. */
readonly title: string
/** Text under the title. Omit it for none. */
readonly description?: string
/** The dialog's content, with the app's dialog padding. It scrolls when it is taller than the dialog. */
readonly children?: JSX.Element
/** The dialog's buttons, aligned to the end of its last row. Omit it for none. */
readonly footer?: JSX.Element
/**
* The dialog's size. Defaults to `fit`.
* - `fit`: 480px wide and as tall as its content.
* - `normal`: 480 by 368px.
* - `large`: 640 by 480px.
* - `x-large`: up to 980 by 600px, smaller in a small window.
*/
readonly size?: "fit" | "normal" | "large" | "x-large"
/**
* Shows the close button in the header, labelled with the app's "Close". Escape and a click outside close the dialog
* either way. Defaults to true.
*/
readonly closable?: boolean
}
/** Props of `Ui.Switch`. */
export interface UiSwitchProps {
/** The switch is on. */
readonly checked: boolean
/** Runs with the new state when the user flips the switch. Pass it on to `checked` to keep the switch in step. */
readonly onChange: (checked: boolean) => void
/** The switch's label, which also names it for screen readers. Pass translated copy. */
readonly label: string
/** Hides the label from view and keeps it for screen readers, for a row that shows its own text. Defaults to false. */
readonly hideLabel?: boolean
/** Text under the label. Omit it for none. */
readonly description?: string
/** The switch cannot be flipped. Defaults to false. */
readonly disabled?: boolean
}
/** Props of `Ui.TextInput`. */
export interface UiTextInputProps {
/** The field's text. */
readonly value: string
/** Runs with the field's text on every edit. Pass it on to `value` to keep the field in step. */
readonly onInput: (value: string) => void
/** The field's accessible name, which screen readers announce. Pass translated copy; it is not shown. */
readonly label: string
/** Text the empty field shows. Omit it for none. */
readonly placeholder?: string
/** Runs with the field's text when the user presses Enter, except while an input method composes text. */
readonly onSubmit?: (value: string) => void
/**
* What the field holds. Defaults to `text`.
* - `text`: plain text.
* - `password`: hidden text.
* - `search`: a search query.
* - `url`: a web address.
*/
readonly type?: "text" | "password" | "search" | "url"
/**
* The field's height. Defaults to `normal`.
* - `normal`: 28px.
* - `large`: 32px, as in dialogs.
*/
readonly size?: "normal" | "large"
/** The field cannot be edited. Defaults to false. */
readonly disabled?: boolean
/** Shows the field as invalid and reports `aria-invalid`. Defaults to false. */
readonly invalid?: boolean
/** Takes focus when its dialog opens. Defaults to false. */
readonly autofocus?: boolean
/** Classes of the field, for layout. The app's styles give it its look. */
readonly class?: string
}
/** Props of `Ui.Keybind`. */
export interface UiKeybindProps {
/** The keys, one chip each, as `Keybinds.keybind` and `Keybinds.keys` return them. Empty shows nothing. */
readonly keys: readonly string[]
/** Classes of the chips' row, for layout. */
readonly class?: string
}
/** Props of `Ui.Markdown`. */
export interface UiMarkdownProps {
/** The Markdown source. */
readonly text: string
/** Classes of the rendered block, such as a text size. */
readonly class?: string
}
/** Props of `Ui.Code`. */
export interface UiCodeProps {
/** The code, shown as it is. */
readonly content: string
/**
* The language to highlight, by a highlighter id or a common alias (`ts`, `tsx`, `py`, `json`, `bash`). A language
* the app cannot highlight shows plain text; `mermaid` draws the diagram, as the timeline does. Defaults to plain
* text.
*/
readonly language?: string
/** Classes of the code block, for layout. */
readonly class?: string
}
/**
* The app's own components, so a plugin's UI looks and behaves like the app's: the same buttons, menus, dialogs and
* fields, in the user's theme, fonts and language. Each takes a small set of props the SDK keeps stable. A component
* renders with the context of the plugin that renders it, and works in any of its contributions and dialogs.
*/
export interface Ui {
/**
* An icon from the app's catalog, the artwork every `icon` field of the SDK names.
*
* @param props - The icon and its size.
*
* @example
* ```tsx
* <ctx.ui.Icon name="branch" size="small" />
* ```
*/
Icon(props: UiIconProps): JSX.Element
/**
* A button with text.
*
* @param props - Its text, look and action.
*
* @example
* ```tsx
* <ctx.ui.Button variant="contrast" busy={saving()} onClick={save}>{ctx.t("save")}</ctx.ui.Button>
* ```
*/
Button(props: UiButtonProps): JSX.Element
/**
* A button that shows only an icon. Wrap it in `Tooltip` to show its label on hover.
*
* @param props - Its icon, accessible name and action.
*
* @example
* ```tsx
* <ctx.ui.IconButton icon="outline-rotate-clockwise" label={ctx.t("reload")} onClick={reload} />
* ```
*/
IconButton(props: UiIconButtonProps): JSX.Element
/**
* A tooltip on an element.
*
* @param props - What it shows, and the element.
*
* @example
* ```tsx
* <ctx.ui.Tooltip value={ctx.t("toggle")} keys={ctx.keybinds.keybind(`${ctx.id}.toggle`)}>
* <ctx.ui.IconButton icon="sidebar" label={ctx.t("toggle")} onClick={toggle} />
* </ctx.ui.Tooltip>
* ```
*/
Tooltip(props: UiTooltipProps): JSX.Element
/**
* A button that opens a menu of actions.
*
* @param props - The trigger and the entries.
*
* @example
* ```tsx
* <ctx.ui.Menu label={ctx.t("more")} items={[{ label: ctx.t("delete"), icon: "trash", onSelect: remove }]} />
* ```
*/
Menu(props: UiMenuProps): JSX.Element
/**
* The app's loading indicator, for anything that loads, connects or runs that is not the AI working.
*
* @param props - Its size.
*
* @example
* ```tsx
* <Show when={!loading()} fallback={<ctx.ui.Loader />}>{content()}</Show>
* ```
*/
Loader(props: UiLoaderProps): JSX.Element
/**
* The app's dialog frame: title, content and buttons. Render it from `ctx.dialogs.open`; it renders nothing useful
* elsewhere.
*
* @param props - The title, content and buttons.
*
* @example
* ```tsx
* ctx.dialogs.open((dialog) => (
* <ctx.ui.Dialog title={ctx.t("about")} footer={<ctx.ui.Button onClick={dialog.close}>{ctx.t("done")}</ctx.ui.Button>}>
* {ctx.t("about.body")}
* </ctx.ui.Dialog>
* ))
* ```
*/
Dialog(props: UiDialogProps): JSX.Element
/**
* An on/off switch with a label.
*
* @param props - Its state, label and handler.
*
* @example
* ```tsx
* <ctx.ui.Switch label={ctx.t("wrap")} checked={wrap()} onChange={setWrap} />
* ```
*/
Switch(props: UiSwitchProps): JSX.Element
/**
* A one-line text field.
*
* @param props - Its text, accessible name and handlers.
*
* @example
* ```tsx
* <ctx.ui.TextInput label={ctx.t("name")} value={name()} onInput={setName} onSubmit={save} />
* ```
*/
TextInput(props: UiTextInputProps): JSX.Element
/**
* Keys shown as chips, as menus and tooltips show shortcuts.
*
* @param props - The keys.
*
* @example
* ```tsx
* <ctx.ui.Keybind keys={ctx.keybinds.keybind(`${ctx.id}.toggle`)} />
* ```
*/
Keybind(props: UiKeybindProps): JSX.Element
/**
* Markdown rendered as the timeline renders a message: headings, lists, tables, links, math and highlighted code
* blocks with a copy button. On the session screen, links to local files open through the LinkHandler
* contributions, as the timeline's do. Rendering is asynchronous: the block fills in shortly after it mounts.
*
* @param props - The Markdown source.
*
* @example
* ```tsx
* <ctx.ui.Markdown text={summary()} />
* ```
*/
Markdown(props: UiMarkdownProps): JSX.Element
/**
* A read-only code block, highlighted as the timeline highlights code, with a copy button. Highlighting is
* asynchronous: the block fills in shortly after it mounts.
*
* @param props - The code and its language.
*
* @example
* ```tsx
* <ctx.ui.Code content={JSON.stringify(config, null, 2)} language="json" />
* ```
*/
Code(props: UiCodeProps): JSX.Element
}