@neosh/api 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -0
- package/src/generated/AccountKind.ts +9 -0
- package/src/generated/Activity.ts +85 -0
- package/src/generated/AgentCommand.ts +18 -0
- package/src/generated/AgentState.ts +6 -0
- package/src/generated/AgentSummary.ts +46 -0
- package/src/generated/Anchor.ts +12 -0
- package/src/generated/Animation.ts +40 -0
- package/src/generated/ApiCall.ts +520 -0
- package/src/generated/ApiError.ts +15 -0
- package/src/generated/ApiOk.ts +110 -0
- package/src/generated/ApiResponse.ts +8 -0
- package/src/generated/AscpMessage.ts +73 -0
- package/src/generated/AttachmentInfo.ts +25 -0
- package/src/generated/Attrs.ts +10 -0
- package/src/generated/AuthRef.ts +14 -0
- package/src/generated/BackgroundTask.ts +23 -0
- package/src/generated/BlockStartKind.ts +8 -0
- package/src/generated/BorderStyle.ts +3 -0
- package/src/generated/BranchInfo.ts +19 -0
- package/src/generated/Brand.ts +31 -0
- package/src/generated/BufferId.ts +6 -0
- package/src/generated/BuildId.ts +24 -0
- package/src/generated/Capability.ts +10 -0
- package/src/generated/ClientMessage.ts +67 -0
- package/src/generated/Color.ts +10 -0
- package/src/generated/CommandEntry.ts +13 -0
- package/src/generated/CommitInfo.ts +10 -0
- package/src/generated/ContentBlock.ts +39 -0
- package/src/generated/Contribution.ts +16 -0
- package/src/generated/CostBasis.ts +6 -0
- package/src/generated/CredentialInfo.ts +30 -0
- package/src/generated/CredentialSource.ts +18 -0
- package/src/generated/CursorMotion.ts +21 -0
- package/src/generated/CursorShape.ts +6 -0
- package/src/generated/DetachReason.ts +5 -0
- package/src/generated/DiffTarget.ts +10 -0
- package/src/generated/Dock.ts +9 -0
- package/src/generated/DriverCommand.ts +20 -0
- package/src/generated/DriverKind.ts +8 -0
- package/src/generated/Extent.ts +20 -0
- package/src/generated/ExtmarkId.ts +6 -0
- package/src/generated/ExtmarkInfo.ts +43 -0
- package/src/generated/ExtmarkOpts.ts +33 -0
- package/src/generated/ExtmarkRender.ts +44 -0
- package/src/generated/FileChange.ts +18 -0
- package/src/generated/FileState.ts +15 -0
- package/src/generated/FloatConfig.ts +63 -0
- package/src/generated/FrameSet.ts +9 -0
- package/src/generated/Gravity.ts +11 -0
- package/src/generated/HighlightDef.ts +13 -0
- package/src/generated/HighlightEntry.ts +14 -0
- package/src/generated/HighlightSpec.ts +19 -0
- package/src/generated/Hint.ts +18 -0
- package/src/generated/HlTarget.ts +10 -0
- package/src/generated/HookInvocation.ts +14 -0
- package/src/generated/HookName.ts +17 -0
- package/src/generated/HookOutcome.ts +14 -0
- package/src/generated/HookPayload.ts +95 -0
- package/src/generated/InputEvent.ts +31 -0
- package/src/generated/InstanceConfig.ts +26 -0
- package/src/generated/InstanceId.ts +6 -0
- package/src/generated/KeyCode.ts +20 -0
- package/src/generated/KeyContext.ts +24 -0
- package/src/generated/KeyMods.ts +8 -0
- package/src/generated/KeyPress.ts +5 -0
- package/src/generated/KeymapEntry.ts +11 -0
- package/src/generated/KeymapScope.ts +20 -0
- package/src/generated/LineDraw.ts +15 -0
- package/src/generated/LineRender.ts +9 -0
- package/src/generated/LinkState.ts +16 -0
- package/src/generated/MarkDraw.ts +40 -0
- package/src/generated/Message.ts +5 -0
- package/src/generated/MessageLevel.ts +3 -0
- package/src/generated/Mode.ts +6 -0
- package/src/generated/ModelCapabilities.ts +14 -0
- package/src/generated/ModelEntry.ts +13 -0
- package/src/generated/ModelId.ts +6 -0
- package/src/generated/ModelInfo.ts +30 -0
- package/src/generated/ModelSelection.ts +15 -0
- package/src/generated/ModelTier.ts +14 -0
- package/src/generated/NamespaceId.ts +6 -0
- package/src/generated/NodeCapabilities.ts +30 -0
- package/src/generated/NodeId.ts +11 -0
- package/src/generated/NodeInfo.ts +24 -0
- package/src/generated/NoticeKind.ts +11 -0
- package/src/generated/Offset.ts +7 -0
- package/src/generated/OnDelete.ts +7 -0
- package/src/generated/OptionChoice.ts +8 -0
- package/src/generated/OptionEntry.ts +23 -0
- package/src/generated/OptionSelection.ts +4 -0
- package/src/generated/OptionSpec.ts +17 -0
- package/src/generated/OptionType.ts +16 -0
- package/src/generated/OptionValue.ts +13 -0
- package/src/generated/PermissionDecision.ts +6 -0
- package/src/generated/PermissionMode.ts +3 -0
- package/src/generated/PermissionOption.ts +21 -0
- package/src/generated/PermissionOptionKind.ts +13 -0
- package/src/generated/PlanState.ts +6 -0
- package/src/generated/PlanStep.ts +7 -0
- package/src/generated/PluginActivation.ts +10 -0
- package/src/generated/PluginEvent.ts +77 -0
- package/src/generated/PluginId.ts +6 -0
- package/src/generated/PluginInbound.ts +10 -0
- package/src/generated/PluginInfo.ts +19 -0
- package/src/generated/PluginManifest.ts +54 -0
- package/src/generated/PluginOutbound.ts +11 -0
- package/src/generated/PluginPermission.ts +18 -0
- package/src/generated/PluginProvides.ts +19 -0
- package/src/generated/PluginRequest.ts +11 -0
- package/src/generated/PluginResponse.ts +10 -0
- package/src/generated/PointInfo.ts +16 -0
- package/src/generated/Pricing.ts +12 -0
- package/src/generated/ProjectKey.ts +21 -0
- package/src/generated/ProviderEmit.ts +11 -0
- package/src/generated/ProviderEvent.ts +32 -0
- package/src/generated/ProviderOptionDescriptor.ts +38 -0
- package/src/generated/ProviderOptionValue.ts +3 -0
- package/src/generated/QuestionAnswer.ts +24 -0
- package/src/generated/QuestionOption.ts +17 -0
- package/src/generated/QuotaCredits.ts +23 -0
- package/src/generated/QuotaSample.ts +19 -0
- package/src/generated/QuotaSeverity.ts +11 -0
- package/src/generated/QuotaSnapshot.ts +34 -0
- package/src/generated/QuotaSource.ts +6 -0
- package/src/generated/QuotaWindow.ts +40 -0
- package/src/generated/Rect.ts +7 -0
- package/src/generated/Refusal.ts +10 -0
- package/src/generated/RemoteProject.ts +19 -0
- package/src/generated/RepoInfo.ts +23 -0
- package/src/generated/RepoStatus.ts +5 -0
- package/src/generated/RequestId.ts +6 -0
- package/src/generated/Role.ts +3 -0
- package/src/generated/RunningTurn.ts +12 -0
- package/src/generated/SelectShape.ts +6 -0
- package/src/generated/ServerMessage.ts +15 -0
- package/src/generated/SessionId.ts +6 -0
- package/src/generated/SessionInfo.ts +171 -0
- package/src/generated/StatusAlign.ts +3 -0
- package/src/generated/StatusSegment.ts +49 -0
- package/src/generated/StopReason.ts +16 -0
- package/src/generated/StreamEvent.ts +26 -0
- package/src/generated/StreamId.ts +6 -0
- package/src/generated/SurfaceCell.ts +22 -0
- package/src/generated/SurfaceId.ts +6 -0
- package/src/generated/SwarmAgent.ts +11 -0
- package/src/generated/SwarmNode.ts +24 -0
- package/src/generated/SwarmStranger.ts +21 -0
- package/src/generated/TaskId.ts +11 -0
- package/src/generated/TaskStatus.ts +11 -0
- package/src/generated/TextEdit.ts +19 -0
- package/src/generated/ToolCall.ts +13 -0
- package/src/generated/ToolCallId.ts +6 -0
- package/src/generated/ToolDef.ts +12 -0
- package/src/generated/ToolInvocation.ts +11 -0
- package/src/generated/ToolResult.ts +3 -0
- package/src/generated/ToolSource.ts +12 -0
- package/src/generated/TurnId.ts +6 -0
- package/src/generated/TurnRequest.ts +56 -0
- package/src/generated/UiEvent.ts +80 -0
- package/src/generated/Usage.ts +15 -0
- package/src/generated/UsageBucket.ts +32 -0
- package/src/generated/UsageHistory.ts +23 -0
- package/src/generated/UsageResolution.ts +6 -0
- package/src/generated/UsageScanSource.ts +24 -0
- package/src/generated/UsageScanStatus.ts +3 -0
- package/src/generated/UserQuestion.ts +34 -0
- package/src/generated/VarScope.ts +18 -0
- package/src/generated/ViewId.ts +16 -0
- package/src/generated/ViewInfo.ts +23 -0
- package/src/generated/Viewport.ts +18 -0
- package/src/generated/VirtChunk.ts +6 -0
- package/src/generated/VirtTextPos.ts +6 -0
- package/src/generated/WindowId.ts +6 -0
- package/src/generated/WindowInfo.ts +22 -0
- package/src/generated/WindowLayout.ts +28 -0
- package/src/generated/WorkspaceStatus.ts +28 -0
- package/src/generated/WorktreeInfo.ts +16 -0
- package/src/globals.d.ts +39 -0
- package/src/index.ts +2961 -0
- package/src/ui.ts +3279 -0
- package/tsconfig.json +17 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,2961 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@neosh/api` — the entire plugin surface.
|
|
3
|
+
*
|
|
4
|
+
* This file is both the implementation and the types. It is embedded into the host binary and
|
|
5
|
+
* transpiled at load, and it is what plugin authors type-check against, so the two cannot drift
|
|
6
|
+
* apart — there is no second copy to forget to update.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is built on the wire types in `./generated/`, which are emitted from the Rust
|
|
9
|
+
* side by ts-rs and drift-checked in CI. Nothing in this file reaches the host except as an
|
|
10
|
+
* `ApiCall`, which means an out-of-process plugin in another language has exactly this surface.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Activity } from "./generated/Activity";
|
|
14
|
+
import type { AttachmentInfo } from "./generated/AttachmentInfo";
|
|
15
|
+
import type { ApiCall } from "./generated/ApiCall";
|
|
16
|
+
import type { ApiError } from "./generated/ApiError";
|
|
17
|
+
import type { ApiOk } from "./generated/ApiOk";
|
|
18
|
+
import type { ApiResponse } from "./generated/ApiResponse";
|
|
19
|
+
import type { BranchInfo } from "./generated/BranchInfo";
|
|
20
|
+
import type { BufferId } from "./generated/BufferId";
|
|
21
|
+
import type { Capability } from "./generated/Capability";
|
|
22
|
+
import type { CommitInfo } from "./generated/CommitInfo";
|
|
23
|
+
import type { CommandEntry } from "./generated/CommandEntry";
|
|
24
|
+
import type { AgentCommand } from "./generated/AgentCommand";
|
|
25
|
+
import type { AgentState } from "./generated/AgentState";
|
|
26
|
+
import type { AgentSummary } from "./generated/AgentSummary";
|
|
27
|
+
import type { Contribution } from "./generated/Contribution";
|
|
28
|
+
import type { AccountKind } from "./generated/AccountKind";
|
|
29
|
+
import type { Brand } from "./generated/Brand";
|
|
30
|
+
import type { CredentialInfo } from "./generated/CredentialInfo";
|
|
31
|
+
import type { CredentialSource } from "./generated/CredentialSource";
|
|
32
|
+
import type { CursorMotion } from "./generated/CursorMotion";
|
|
33
|
+
import type { CursorShape } from "./generated/CursorShape";
|
|
34
|
+
import type { SelectShape } from "./generated/SelectShape";
|
|
35
|
+
import type { DiffTarget } from "./generated/DiffTarget";
|
|
36
|
+
import type { Dock } from "./generated/Dock";
|
|
37
|
+
import type { Gravity } from "./generated/Gravity";
|
|
38
|
+
import type { Hint } from "./generated/Hint";
|
|
39
|
+
import type { ExtmarkId } from "./generated/ExtmarkId";
|
|
40
|
+
import type { FileChange } from "./generated/FileChange";
|
|
41
|
+
import type { FileState } from "./generated/FileState";
|
|
42
|
+
import type { ExtmarkInfo } from "./generated/ExtmarkInfo";
|
|
43
|
+
import type { ExtmarkOpts } from "./generated/ExtmarkOpts";
|
|
44
|
+
import type { FloatConfig } from "./generated/FloatConfig";
|
|
45
|
+
import type { HighlightDef } from "./generated/HighlightDef";
|
|
46
|
+
import type { HighlightEntry } from "./generated/HighlightEntry";
|
|
47
|
+
import type { HighlightSpec } from "./generated/HighlightSpec";
|
|
48
|
+
import type { HlTarget } from "./generated/HlTarget";
|
|
49
|
+
import type { HookName } from "./generated/HookName";
|
|
50
|
+
import type { HookOutcome } from "./generated/HookOutcome";
|
|
51
|
+
import type { HookPayload } from "./generated/HookPayload";
|
|
52
|
+
import type { InstanceConfig } from "./generated/InstanceConfig";
|
|
53
|
+
import type { KeyContext } from "./generated/KeyContext";
|
|
54
|
+
import type { KeymapEntry } from "./generated/KeymapEntry";
|
|
55
|
+
import type { KeymapScope } from "./generated/KeymapScope";
|
|
56
|
+
import type { MessageLevel } from "./generated/MessageLevel";
|
|
57
|
+
import type { Mode } from "./generated/Mode";
|
|
58
|
+
import type { ModelEntry } from "./generated/ModelEntry";
|
|
59
|
+
import type { ModelInfo } from "./generated/ModelInfo";
|
|
60
|
+
import type { ModelSelection } from "./generated/ModelSelection";
|
|
61
|
+
import type { ModelTier } from "./generated/ModelTier";
|
|
62
|
+
import type { NamespaceId } from "./generated/NamespaceId";
|
|
63
|
+
import type { OptionChoice } from "./generated/OptionChoice";
|
|
64
|
+
import type { OptionEntry } from "./generated/OptionEntry";
|
|
65
|
+
import type { OptionSelection } from "./generated/OptionSelection";
|
|
66
|
+
import type { OptionSpec } from "./generated/OptionSpec";
|
|
67
|
+
import type { OptionType } from "./generated/OptionType";
|
|
68
|
+
import type { OptionValue } from "./generated/OptionValue";
|
|
69
|
+
import type { PermissionDecision } from "./generated/PermissionDecision";
|
|
70
|
+
import type { PermissionMode } from "./generated/PermissionMode";
|
|
71
|
+
import type { PermissionOption } from "./generated/PermissionOption";
|
|
72
|
+
import type { PermissionOptionKind } from "./generated/PermissionOptionKind";
|
|
73
|
+
import type { QuestionAnswer } from "./generated/QuestionAnswer";
|
|
74
|
+
import type { QuestionOption } from "./generated/QuestionOption";
|
|
75
|
+
import type { UserQuestion } from "./generated/UserQuestion";
|
|
76
|
+
import type { PluginEvent } from "./generated/PluginEvent";
|
|
77
|
+
import type { PointInfo } from "./generated/PointInfo";
|
|
78
|
+
import type { PluginInfo } from "./generated/PluginInfo";
|
|
79
|
+
import type { PluginManifest } from "./generated/PluginManifest";
|
|
80
|
+
import type { Pricing } from "./generated/Pricing";
|
|
81
|
+
import type { QuotaCredits } from "./generated/QuotaCredits";
|
|
82
|
+
import type { QuotaSample } from "./generated/QuotaSample";
|
|
83
|
+
import type { QuotaSeverity } from "./generated/QuotaSeverity";
|
|
84
|
+
import type { QuotaSnapshot } from "./generated/QuotaSnapshot";
|
|
85
|
+
import type { QuotaSource } from "./generated/QuotaSource";
|
|
86
|
+
import type { QuotaWindow } from "./generated/QuotaWindow";
|
|
87
|
+
import type { UsageBucket } from "./generated/UsageBucket";
|
|
88
|
+
import type { UsageHistory } from "./generated/UsageHistory";
|
|
89
|
+
import type { UsageResolution } from "./generated/UsageResolution";
|
|
90
|
+
import type { UsageScanSource } from "./generated/UsageScanSource";
|
|
91
|
+
import type { CostBasis } from "./generated/CostBasis";
|
|
92
|
+
import type { DriverCommand } from "./generated/DriverCommand";
|
|
93
|
+
import type { PlanState } from "./generated/PlanState";
|
|
94
|
+
import type { PlanStep } from "./generated/PlanStep";
|
|
95
|
+
import type { ProviderEvent } from "./generated/ProviderEvent";
|
|
96
|
+
import type { TaskId } from "./generated/TaskId";
|
|
97
|
+
import type { TaskStatus } from "./generated/TaskStatus";
|
|
98
|
+
import type { ProviderOptionDescriptor } from "./generated/ProviderOptionDescriptor";
|
|
99
|
+
import type { Message } from "./generated/Message";
|
|
100
|
+
import type { Rect } from "./generated/Rect";
|
|
101
|
+
import type { RepoInfo } from "./generated/RepoInfo";
|
|
102
|
+
import type { RepoStatus } from "./generated/RepoStatus";
|
|
103
|
+
import type { SessionId } from "./generated/SessionId";
|
|
104
|
+
import type { SessionInfo } from "./generated/SessionInfo";
|
|
105
|
+
import type { StatusAlign } from "./generated/StatusAlign";
|
|
106
|
+
import type { StatusSegment } from "./generated/StatusSegment";
|
|
107
|
+
import type { StopReason } from "./generated/StopReason";
|
|
108
|
+
import type { SurfaceCell } from "./generated/SurfaceCell";
|
|
109
|
+
import type { TextEdit } from "./generated/TextEdit";
|
|
110
|
+
import type { SurfaceId } from "./generated/SurfaceId";
|
|
111
|
+
import type { ToolCall } from "./generated/ToolCall";
|
|
112
|
+
import type { ToolDef } from "./generated/ToolDef";
|
|
113
|
+
import type { ToolResult } from "./generated/ToolResult";
|
|
114
|
+
import type { TurnRequest } from "./generated/TurnRequest";
|
|
115
|
+
import type { Usage } from "./generated/Usage";
|
|
116
|
+
import type { NodeCapabilities } from "./generated/NodeCapabilities";
|
|
117
|
+
import type { NodeId } from "./generated/NodeId";
|
|
118
|
+
import type { NodeInfo } from "./generated/NodeInfo";
|
|
119
|
+
import type { ProjectKey } from "./generated/ProjectKey";
|
|
120
|
+
import type { RemoteProject } from "./generated/RemoteProject";
|
|
121
|
+
import type { StreamEvent } from "./generated/StreamEvent";
|
|
122
|
+
import type { SwarmAgent } from "./generated/SwarmAgent";
|
|
123
|
+
import type { SwarmNode } from "./generated/SwarmNode";
|
|
124
|
+
import type { SwarmStranger } from "./generated/SwarmStranger";
|
|
125
|
+
import type { VarScope } from "./generated/VarScope";
|
|
126
|
+
import type { ViewId } from "./generated/ViewId";
|
|
127
|
+
import type { ViewInfo } from "./generated/ViewInfo";
|
|
128
|
+
import type { Viewport } from "./generated/Viewport";
|
|
129
|
+
import type { WindowId } from "./generated/WindowId";
|
|
130
|
+
import type { WindowInfo } from "./generated/WindowInfo";
|
|
131
|
+
import type { WindowLayout } from "./generated/WindowLayout";
|
|
132
|
+
import type { WorktreeInfo } from "./generated/WorktreeInfo";
|
|
133
|
+
|
|
134
|
+
export type {
|
|
135
|
+
AccountKind, Activity, ApiError, BranchInfo, Brand, BufferId, Capability, CommandEntry, CommitInfo,
|
|
136
|
+
AgentCommand, AgentState, AgentSummary,
|
|
137
|
+
Contribution, CredentialInfo, CredentialSource, CursorShape, DiffTarget, Dock, CursorMotion, ExtmarkId, ExtmarkInfo, ExtmarkOpts, FileChange, FileState, FloatConfig,
|
|
138
|
+
Gravity, HighlightDef, HighlightEntry, HighlightSpec, Hint, HlTarget, HookName, HookOutcome, HookPayload, InstanceConfig, KeyContext,
|
|
139
|
+
AttachmentInfo,
|
|
140
|
+
KeymapEntry, KeymapScope, MessageLevel, Mode, ModelEntry, ModelInfo, ModelSelection, ModelTier, NamespaceId,
|
|
141
|
+
OptionChoice, OptionEntry, OptionSelection, OptionSpec, OptionType, OptionValue,
|
|
142
|
+
DriverCommand, PlanState, PlanStep, TaskId, TaskStatus,
|
|
143
|
+
Message, PermissionDecision, PermissionMode, PermissionOption, PermissionOptionKind, PluginEvent, PluginInfo, PluginManifest, PointInfo, Pricing, ProviderEvent, ProviderOptionDescriptor,
|
|
144
|
+
QuestionAnswer, QuestionOption, UserQuestion,
|
|
145
|
+
CostBasis, QuotaCredits, QuotaSample, QuotaSeverity, QuotaSnapshot, QuotaSource, QuotaWindow,
|
|
146
|
+
UsageBucket, UsageHistory, UsageResolution, UsageScanSource,
|
|
147
|
+
Rect, RepoInfo, RepoStatus, SelectShape, SessionId, SessionInfo, StatusAlign, StatusSegment, StopReason,
|
|
148
|
+
SurfaceCell, SurfaceId, TextEdit, ToolCall, ToolDef, ToolResult, TurnRequest, Usage,
|
|
149
|
+
NodeCapabilities, NodeId, NodeInfo, ProjectKey, RemoteProject, StreamEvent,
|
|
150
|
+
SwarmAgent, SwarmNode, SwarmStranger,
|
|
151
|
+
VarScope, ViewId, ViewInfo, Viewport,
|
|
152
|
+
WindowId, WindowInfo, WindowLayout,
|
|
153
|
+
WorktreeInfo,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Columns
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* UTF-8 byte length of a string.
|
|
162
|
+
*
|
|
163
|
+
* Every column in this API is a **UTF-8 byte offset**, matching Neovim and for the same reason: it
|
|
164
|
+
* keeps display-width math in exactly one place, the frontend. JavaScript's `.length` is UTF-16
|
|
165
|
+
* code units, which agrees with bytes only for ASCII — so a highlight placed with `.length` on a
|
|
166
|
+
* line containing an emoji or any CJK lands in the wrong column, or inside a character.
|
|
167
|
+
*/
|
|
168
|
+
export function byteLength(s: string): number {
|
|
169
|
+
let n = 0;
|
|
170
|
+
for (const ch of s) {
|
|
171
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
172
|
+
n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
|
|
173
|
+
}
|
|
174
|
+
return n;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Byte offset of each code point in `s`, plus one final entry holding the total length.
|
|
179
|
+
*
|
|
180
|
+
* `byteOffsets(s)[i]` is the column to pass for the `i`th code point, so a match found with
|
|
181
|
+
* `Array.from(s)` can be turned into marks without measuring the prefix again per position.
|
|
182
|
+
*/
|
|
183
|
+
export function byteOffsets(s: string): number[] {
|
|
184
|
+
const out: number[] = [];
|
|
185
|
+
let n = 0;
|
|
186
|
+
for (const ch of s) {
|
|
187
|
+
out.push(n);
|
|
188
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
189
|
+
n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
|
|
190
|
+
}
|
|
191
|
+
out.push(n);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Transport
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/** The ops the host installs. Everything else is built on these. */
|
|
200
|
+
interface CoreOps {
|
|
201
|
+
op_neosh_send(msg: unknown): void;
|
|
202
|
+
op_neosh_next(): Promise<unknown>;
|
|
203
|
+
op_neosh_width(text: string): number;
|
|
204
|
+
op_neosh_clip(text: string, columns: number): string;
|
|
205
|
+
}
|
|
206
|
+
declare const Deno: { core: { ops: CoreOps } };
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* How many terminal columns a string occupies.
|
|
210
|
+
*
|
|
211
|
+
* Use this for any layout with a column in it. JavaScript offers `String.length` (UTF-16 units) and
|
|
212
|
+
* `Array.from(s).length` (code points), and both are wrong for the text a model produces: `"日本"`
|
|
213
|
+
* is 2 code points and **4** columns, `"👋🏽"` is 2 code points and **2**, `"é"` may be 2 code points
|
|
214
|
+
* and **1**. Padding with either draws a ragged rule the first time a CJK model name appears.
|
|
215
|
+
*
|
|
216
|
+
* Synchronous — it is an op, not a host call, so calling it per row costs nothing. It uses the same
|
|
217
|
+
* measurement the renderer does, so a plugin and the frontend agree by construction.
|
|
218
|
+
*/
|
|
219
|
+
export function width(text: string): number {
|
|
220
|
+
return Deno.core.ops.op_neosh_width(text);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Truncate to at most `columns` columns, cutting on a grapheme boundary rather than mid-character. */
|
|
224
|
+
export function clipToWidth(text: string, columns: number): string {
|
|
225
|
+
return Deno.core.ops.op_neosh_clip(text, Math.max(0, Math.floor(columns)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Pad on the right to exactly `columns`, clipping if it is already wider. */
|
|
229
|
+
export function padToWidth(text: string, columns: number): string {
|
|
230
|
+
const clipped = clipToWidth(text, columns);
|
|
231
|
+
return clipped + " ".repeat(Math.max(0, columns - width(clipped)));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Thrown when the host refuses a call. Carries the structured reason. */
|
|
235
|
+
export class NeoshError extends Error {
|
|
236
|
+
constructor(readonly cause_: ApiError) {
|
|
237
|
+
super(describeError(cause_));
|
|
238
|
+
this.name = "NeoshError";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function describeError(e: ApiError): string {
|
|
243
|
+
switch (e.kind) {
|
|
244
|
+
case "not_found": return `not found: ${e.what}`;
|
|
245
|
+
case "invalid_argument": return `invalid argument: ${e.message}`;
|
|
246
|
+
case "denied": return `denied: ${e.reason}`;
|
|
247
|
+
case "not_permitted": return `not permitted: ${e.capability}`;
|
|
248
|
+
case "busy": return `busy: ${e.message}`;
|
|
249
|
+
case "internal": return `internal error: ${e.message}`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let seq = 0;
|
|
254
|
+
const pending = new Map<string, { resolve: (v: ApiOk) => void; reject: (e: unknown) => void }>();
|
|
255
|
+
|
|
256
|
+
function send(msg: unknown): void {
|
|
257
|
+
Deno.core.ops.op_neosh_send(msg);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Issue a call that wants an answer.
|
|
262
|
+
*
|
|
263
|
+
* Calls from one plugin are applied by the host in the order they were issued, so a caller that
|
|
264
|
+
* fires several mutations without awaiting cannot race itself.
|
|
265
|
+
*/
|
|
266
|
+
function call(plugin: string, c: ApiCall, view?: ViewId): Promise<ApiOk> {
|
|
267
|
+
const id = `${plugin}#${++seq}`;
|
|
268
|
+
return new Promise<ApiOk>((resolve, reject) => {
|
|
269
|
+
pending.set(id, { resolve, reject });
|
|
270
|
+
send({ type: "plugin", plugin, msg: { type: "call", id, call: c, view } });
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Fire-and-forget. Used on the streaming path so appending a token is not round-trip bound. */
|
|
275
|
+
function notify(plugin: string, c: ApiCall, view?: ViewId): void {
|
|
276
|
+
send({ type: "plugin", plugin, msg: { type: "notify", call: c, view } });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function settle(id: string, response: ApiResponse): void {
|
|
280
|
+
const p = pending.get(id);
|
|
281
|
+
if (!p) return;
|
|
282
|
+
pending.delete(id);
|
|
283
|
+
if (response.status === "ok") p.resolve(response.value);
|
|
284
|
+
else p.reject(new NeoshError(response.error));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Narrow an `ApiOk` to the variant a call is documented to return. */
|
|
288
|
+
function expect<K extends ApiOk["ok"]>(v: ApiOk, kind: K): Extract<ApiOk, { ok: K }> {
|
|
289
|
+
if (v.ok !== kind) {
|
|
290
|
+
throw new Error(`host returned ${v.ok} where ${kind} was expected`);
|
|
291
|
+
}
|
|
292
|
+
return v as Extract<ApiOk, { ok: K }>;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Public surface
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
export interface Disposable {
|
|
300
|
+
dispose(): void;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface PluginContext {
|
|
304
|
+
readonly neosh: Neosh;
|
|
305
|
+
readonly pluginId: string;
|
|
306
|
+
readonly config: unknown;
|
|
307
|
+
/** Anything pushed here is disposed when the plugin unloads. */
|
|
308
|
+
readonly subscriptions: Disposable[];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** A plugin's entry module exports this. */
|
|
312
|
+
export type Activate = (ctx: PluginContext) => void | Promise<void>;
|
|
313
|
+
|
|
314
|
+
export interface VirtText {
|
|
315
|
+
text: string;
|
|
316
|
+
hlGroup?: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface MarkOptions {
|
|
320
|
+
endCol?: number;
|
|
321
|
+
hlGroup?: string;
|
|
322
|
+
/**
|
|
323
|
+
* A background for the whole rendered row, not just the bytes the mark covers.
|
|
324
|
+
*
|
|
325
|
+
* It sits *under* every ranged `hlGroup` on the row rather than competing with them, so a row can
|
|
326
|
+
* be banded — selected, changed, at fault — and the text on it keeps whatever colour said what it
|
|
327
|
+
* was. `hlGroup` across the same span would replace that colour instead, which is how a selected
|
|
328
|
+
* row ends up the only one that has stopped saying anything.
|
|
329
|
+
*/
|
|
330
|
+
lineHlGroup?: string;
|
|
331
|
+
virtText?: VirtText[];
|
|
332
|
+
virtTextPos?: ExtmarkOpts["virt_text_pos"];
|
|
333
|
+
onDelete?: ExtmarkOpts["on_delete"];
|
|
334
|
+
priority?: number;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** One mark of a {@link DrawnRow}, positioned on that row. */
|
|
338
|
+
export interface DrawnMark {
|
|
339
|
+
/**
|
|
340
|
+
* UTF-8 byte offset into the row's text — the unit every column on the wire uses. Build it with
|
|
341
|
+
* {@link byteLength}, never `.length`.
|
|
342
|
+
*/
|
|
343
|
+
col: number;
|
|
344
|
+
opts?: MarkOptions;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** One row of a repaint: its text, and everything drawn on it. */
|
|
348
|
+
export interface DrawnRow {
|
|
349
|
+
text: string;
|
|
350
|
+
marks?: DrawnMark[];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface FloatOptions {
|
|
354
|
+
anchor?: FloatConfig["anchor"];
|
|
355
|
+
offset?: { row: number; col: number };
|
|
356
|
+
width?: FloatConfig["width"];
|
|
357
|
+
height?: FloatConfig["height"];
|
|
358
|
+
z?: number;
|
|
359
|
+
border?: FloatConfig["border"];
|
|
360
|
+
borderHl?: string;
|
|
361
|
+
title?: string;
|
|
362
|
+
closeOnBlur?: boolean;
|
|
363
|
+
focusable?: boolean;
|
|
364
|
+
/**
|
|
365
|
+
* Take the keyboard while this float has focus.
|
|
366
|
+
*
|
|
367
|
+
* Global bindings stop resolving: `^N`, `^T`, `^G` and the rest do nothing until it closes,
|
|
368
|
+
* instead of opening a second panel behind the first. Your own bindings still work — window,
|
|
369
|
+
* buffer and kind scopes are all nearer than global — and so does anything in
|
|
370
|
+
* `ui.modal_escape_keys` (`^Q` and `^R` by default), so a panel that forgets to bind a way out
|
|
371
|
+
* is never a terminal somebody has to kill. A key nothing claimed is swallowed rather than
|
|
372
|
+
* falling through to the composer.
|
|
373
|
+
*
|
|
374
|
+
* For a panel you are meant to answer before doing anything else: a question, a confirmation, a
|
|
375
|
+
* control sheet. Not for a hint or a hover card.
|
|
376
|
+
*/
|
|
377
|
+
modal?: boolean;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export interface Neosh {
|
|
381
|
+
readonly version: number;
|
|
382
|
+
readonly buf: BufferApi;
|
|
383
|
+
readonly win: WindowApi;
|
|
384
|
+
readonly float: FloatApi;
|
|
385
|
+
readonly edit: EditApi;
|
|
386
|
+
readonly ns: NamespaceApi;
|
|
387
|
+
readonly hl: HighlightApi;
|
|
388
|
+
readonly ui: RawCellApi;
|
|
389
|
+
readonly cmd: CommandApi;
|
|
390
|
+
readonly keymap: KeymapApi;
|
|
391
|
+
readonly focus: FocusApi;
|
|
392
|
+
readonly agent: AgentApi;
|
|
393
|
+
readonly tool: ToolApi;
|
|
394
|
+
readonly hook: HookApi;
|
|
395
|
+
readonly provider: ProviderApi;
|
|
396
|
+
readonly git: GitApi;
|
|
397
|
+
readonly gen: GenApi;
|
|
398
|
+
readonly session: SessionApi;
|
|
399
|
+
readonly view: ViewApi;
|
|
400
|
+
readonly status: StatusApi;
|
|
401
|
+
readonly hint: HintApi;
|
|
402
|
+
readonly opt: OptionApi;
|
|
403
|
+
readonly state: StateApi;
|
|
404
|
+
readonly vars: VarApi;
|
|
405
|
+
readonly ext: ExtensionApi;
|
|
406
|
+
readonly event: EventApi;
|
|
407
|
+
readonly swarm: SwarmApi;
|
|
408
|
+
readonly quota: QuotaApi;
|
|
409
|
+
readonly rtp: RuntimePathApi;
|
|
410
|
+
readonly path: PathApi;
|
|
411
|
+
readonly timer: TimerApi;
|
|
412
|
+
readonly log: Logger;
|
|
413
|
+
/**
|
|
414
|
+
* Say something in the corner, as a reply to a key the user just pressed.
|
|
415
|
+
*
|
|
416
|
+
* The default and by far the commonest case: feedback for a keystroke. It does not stack — a
|
|
417
|
+
* second one replaces the first, because two keys pressed quickly are two keys and the reply
|
|
418
|
+
* you want is the one for the second — it lives about six seconds, and it never leaves the
|
|
419
|
+
* terminal.
|
|
420
|
+
*
|
|
421
|
+
* What it is *not* for is the thing the user can already see. `favourited ~/proj` next to a row
|
|
422
|
+
* that just grew a pin is the same fact printed twice, and a corner that is usually saying
|
|
423
|
+
* something you did not need is a corner people stop reading. See
|
|
424
|
+
* {@link Neosh.progress} and {@link Neosh.alert} for the two things that are not this.
|
|
425
|
+
*/
|
|
426
|
+
notify(message: string, level?: MessageLevel): void;
|
|
427
|
+
/**
|
|
428
|
+
* Say what is happening, in a row that gets replaced rather than stacked.
|
|
429
|
+
*
|
|
430
|
+
* Keyed: writing the same key again replaces the row, and {@link Neosh.done} takes it away. This
|
|
431
|
+
* is what `pulling…` should have been — it was pushed onto the message stack, and so was the
|
|
432
|
+
* `up to date` that superseded it, which is how one pull drew two rows.
|
|
433
|
+
*
|
|
434
|
+
* A row nobody finishes is dropped after a minute, so a plugin that crashes mid-operation cannot
|
|
435
|
+
* leave a permanent claim on screen. Relying on that is a row that lies for up to a minute.
|
|
436
|
+
*/
|
|
437
|
+
progress(key: string, message: string): void;
|
|
438
|
+
/** Take a progress row off, because the thing it was about has finished. */
|
|
439
|
+
done(key: string): void;
|
|
440
|
+
/**
|
|
441
|
+
* News: something happened that the user did not ask for.
|
|
442
|
+
*
|
|
443
|
+
* Drawn in the corner like a message, and — if the host works out that nobody is looking —
|
|
444
|
+
* raised outside the terminal as well, as an escape sequence the terminal turns into a real
|
|
445
|
+
* notification. Whether that happens is the host's decision and not yours: only it knows which
|
|
446
|
+
* conversation is on screen, which terminals are attached and whether any has focus.
|
|
447
|
+
*
|
|
448
|
+
* Needs `notify` in `plugin.toml`, because a plugin that can interrupt somebody who is in
|
|
449
|
+
* another application is a capability rather than a way of drawing. Rejected with
|
|
450
|
+
* `not permitted` otherwise.
|
|
451
|
+
*
|
|
452
|
+
* @param session Which conversation this is about, if it is about one. The test for "can they
|
|
453
|
+
* see this already" is asked against it; an alert about no conversation is never on screen.
|
|
454
|
+
*/
|
|
455
|
+
alert(
|
|
456
|
+
title: string,
|
|
457
|
+
message: string,
|
|
458
|
+
opts?: { level?: MessageLevel; session?: SessionId },
|
|
459
|
+
): Promise<void>;
|
|
460
|
+
/** Ask the host whether a side effect is allowed. */
|
|
461
|
+
permit(capability: Capability): Promise<PermissionDecision>;
|
|
462
|
+
/**
|
|
463
|
+
* Ask the *person* a question, and wait for the answer.
|
|
464
|
+
*
|
|
465
|
+
* Not a permission and not a picker. A permission asks whether something may happen and policy
|
|
466
|
+
* can answer it without waking anybody; a picker is a list you opened. This is the panel an agent
|
|
467
|
+
* gets when it asks you which library, which approach, which of these to enable — several
|
|
468
|
+
* questions in one sitting, some of them taking more than one answer, any of them answerable with
|
|
469
|
+
* something nobody listed.
|
|
470
|
+
*
|
|
471
|
+
* Whatever serves the `ask_user` hook draws it, so your question and the agent's look the same
|
|
472
|
+
* and a plugin that replaces the panel replaces both.
|
|
473
|
+
*
|
|
474
|
+
* `null` is *nobody answered* — dismissed, or timed out. Not an error: treating it as one means
|
|
475
|
+
* reporting a failure every time somebody presses `<Esc>`.
|
|
476
|
+
*/
|
|
477
|
+
ask(questions: UserQuestion[]): Promise<QuestionAnswer[] | null>;
|
|
478
|
+
/**
|
|
479
|
+
* A terminal attached to a workspace that was already running. Only a plugin drawing raw cells
|
|
480
|
+
* needs this — the core forwards a surface's cells and keeps no copy, so they have to be painted
|
|
481
|
+
* again; everything in a buffer is republished without help.
|
|
482
|
+
*/
|
|
483
|
+
onViewAttached(cb: () => void): Disposable;
|
|
484
|
+
/**
|
|
485
|
+
* The workspace is stopping. `deactivate` is called after this; both are bounded, so say your
|
|
486
|
+
* goodbye quickly. Reload is not this: a reloaded plugin gets `deactivate` and nothing here.
|
|
487
|
+
*/
|
|
488
|
+
onShutdown(cb: () => void): Disposable;
|
|
489
|
+
/**
|
|
490
|
+
* What the agent is allowed to do without asking, and how to change it.
|
|
491
|
+
*
|
|
492
|
+
* `setMode` lasts for this session only. A mode switched on to get through one task should not
|
|
493
|
+
* still be on next week, and writing it to a file is exactly how that happens — so it is not
|
|
494
|
+
* written to one.
|
|
495
|
+
*/
|
|
496
|
+
readonly permission: PermissionApi;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export interface BufferApi {
|
|
500
|
+
/**
|
|
501
|
+
* `kind` is what this buffer *is* — `neosh.sidebar`, `acme.tasks`. Say it if anything you draw is
|
|
502
|
+
* a panel somebody else might want to extend: it is what `keymap.set(..., { scope: { kind } })`
|
|
503
|
+
* binds against and what `win.list()` reports, and it costs one argument. Reverse domain by
|
|
504
|
+
* convention.
|
|
505
|
+
*/
|
|
506
|
+
create(opts?: { name?: string; scratch?: boolean; kind?: string }): Promise<BufferId>;
|
|
507
|
+
lineCount(buf: BufferId): Promise<number>;
|
|
508
|
+
/**
|
|
509
|
+
* `end` is exclusive. Negative indices are `len + 1 + i`, as in Neovim's `nvim_buf_set_lines`:
|
|
510
|
+
* **`-1` is one past the last line**, so `getLines(buf, 0, -1)` is the whole buffer. To address
|
|
511
|
+
* the last line itself, use `-2, -1`.
|
|
512
|
+
*/
|
|
513
|
+
getLines(buf: BufferId, start: number, end: number): Promise<string[]>;
|
|
514
|
+
/**
|
|
515
|
+
* Range replacement. There is no whole-document write, by design — streaming a response must not
|
|
516
|
+
* resend the document once per token.
|
|
517
|
+
*
|
|
518
|
+
* `setLines(buf, 0, -1, lines)` replaces the buffer; `setLines(buf, -1, -1, lines)` appends.
|
|
519
|
+
*/
|
|
520
|
+
setLines(buf: BufferId, start: number, end: number, lines: string[]): Promise<void>;
|
|
521
|
+
/**
|
|
522
|
+
* Replace a range of rows **and** `ns`'s marks on them, in one call. What a panel should use to
|
|
523
|
+
* draw itself.
|
|
524
|
+
*
|
|
525
|
+
* The atomic form of `setLines` + `ns.clear` + a `ns.mark` per mark. That sequence is correct at
|
|
526
|
+
* rest and wrong in flight: each call is a round trip, the frontend draws on a ~16 ms deadline
|
|
527
|
+
* that knows nothing about how far through a repaint you are, and a frame landing after the clear
|
|
528
|
+
* draws every row with no marks at all — in `Normal`, which is near-white. A dim panel redrawing
|
|
529
|
+
* ten times a second flashes bright. This has no halfway state to observe, and costs one round
|
|
530
|
+
* trip instead of one per mark.
|
|
531
|
+
*
|
|
532
|
+
* Indices resolve as {@link setLines}'s do, and the clear covers exactly the rows written — so a
|
|
533
|
+
* partial repaint leaves the rest of the panel alone. Other namespaces are untouched, which is
|
|
534
|
+
* what lets an overlay survive the panel under it redrawing.
|
|
535
|
+
*/
|
|
536
|
+
render(
|
|
537
|
+
buf: BufferId,
|
|
538
|
+
ns: NamespaceId,
|
|
539
|
+
start: number,
|
|
540
|
+
end: number,
|
|
541
|
+
rows: DrawnRow[],
|
|
542
|
+
): Promise<void>;
|
|
543
|
+
/** Append to the final line without resending it. The streaming fast path. */
|
|
544
|
+
appendText(buf: BufferId, text: string): Promise<void>;
|
|
545
|
+
setName(buf: BufferId, name: string): Promise<void>;
|
|
546
|
+
/** Declare — or with `null`, withdraw — what this buffer is. See `create`. */
|
|
547
|
+
setKind(buf: BufferId, kind: string | null): Promise<void>;
|
|
548
|
+
kind(buf: BufferId): Promise<string | null>;
|
|
549
|
+
onChange(
|
|
550
|
+
buf: BufferId,
|
|
551
|
+
cb: (e: { buf: BufferId; start: number; oldEnd: number; newEnd: number }) => void,
|
|
552
|
+
): Promise<Disposable>;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export interface WindowApi {
|
|
556
|
+
/**
|
|
557
|
+
* `gravity` is which end short content settles against: `"start"` (the default) pins it to the
|
|
558
|
+
* top, `"end"` to the bottom, which is what makes a transcript read as a conversation rather
|
|
559
|
+
* than as a document that happens to be in a window.
|
|
560
|
+
*
|
|
561
|
+
* `wrap` makes long lines fold rather than clip. Docks clip by default — a side panel that
|
|
562
|
+
* wrapped a long path would reflow every row below it — but a text field is prose and wants
|
|
563
|
+
* this on. A bottom dock that wraps also grows to show the folded rows, `size` acting as its
|
|
564
|
+
* floor.
|
|
565
|
+
*/
|
|
566
|
+
open(
|
|
567
|
+
buf: BufferId,
|
|
568
|
+
dock: Dock,
|
|
569
|
+
opts?: { size?: number; gravity?: Gravity; wrap?: boolean },
|
|
570
|
+
): Promise<WindowId>;
|
|
571
|
+
close(win: WindowId): Promise<void>;
|
|
572
|
+
/**
|
|
573
|
+
* Change how wide (or tall) a docked window is, without closing it.
|
|
574
|
+
*
|
|
575
|
+
* Reopening is not the same thing: the window id changes and whatever had the keyboard loses it,
|
|
576
|
+
* so a panel resized from inside itself would throw the cursor back to the composer on every
|
|
577
|
+
* press. `null` gives the dock its default extent back. Floats are configured with
|
|
578
|
+
* {@link FloatApi.configure}, and are refused here.
|
|
579
|
+
*/
|
|
580
|
+
resize(win: WindowId, size: number | null): Promise<void>;
|
|
581
|
+
setBuf(win: WindowId, buf: BufferId): Promise<void>;
|
|
582
|
+
/** `col` is a UTF-8 byte offset, not a character or display column. */
|
|
583
|
+
cursor(win: WindowId): Promise<{ row: number; col: number }>;
|
|
584
|
+
setCursor(win: WindowId, row: number, col: number): Promise<void>;
|
|
585
|
+
/**
|
|
586
|
+
* Put a buffer row at the top of a window, or hand the scroll position back.
|
|
587
|
+
*
|
|
588
|
+
* `null` is *unscrolled*, which is where a window starts and is not the same place as row `0`:
|
|
589
|
+
* a window that follows its content — the transcript — shows its last screenful unscrolled and
|
|
590
|
+
* its first row at `0`. Anything else shows the same thing either way.
|
|
591
|
+
*/
|
|
592
|
+
scrollTo(win: WindowId, topLine: number | null): Promise<void>;
|
|
593
|
+
/**
|
|
594
|
+
* How big this window actually is, in cells.
|
|
595
|
+
*
|
|
596
|
+
* `null` until the frontend has drawn it once. This is the only way to learn real geometry:
|
|
597
|
+
* everything about display width is resolved by the frontend, so a plugin sizing a meter or
|
|
598
|
+
* deciding what to drop at 60 columns asks rather than computing an answer it cannot compute
|
|
599
|
+
* correctly.
|
|
600
|
+
*/
|
|
601
|
+
viewport(win: WindowId): Promise<Viewport | null>;
|
|
602
|
+
/**
|
|
603
|
+
* Every window that is open, and what is in it.
|
|
604
|
+
*
|
|
605
|
+
* How you find somebody else's panel. A window id belongs to whoever opened it and changes every
|
|
606
|
+
* time the panel is reopened, so this plus a buffer `kind` is the only way to say "the sidebar,
|
|
607
|
+
* whichever window that is right now" — and therefore the only way to act on one you did not
|
|
608
|
+
* open.
|
|
609
|
+
*/
|
|
610
|
+
list(): Promise<WindowInfo[]>;
|
|
611
|
+
/** The open windows showing a buffer of this kind. Sugar over `list()`, which is the common case. */
|
|
612
|
+
ofKind(kind: string): Promise<WindowInfo[]>;
|
|
613
|
+
/**
|
|
614
|
+
* Remap group names for one window, or for every window of a buffer kind — Neovim's
|
|
615
|
+
* `winhighlight`. `{ Normal: "Acme.Panel", "Sidebar.Selected": "Acme.Sel" }` on
|
|
616
|
+
* `{ kind: "neosh.sidebar" }` recolours the sidebar without redefining the groups anything else
|
|
617
|
+
* draws with. A window's map sits over its kind's. An empty map clears; the remap is yours and
|
|
618
|
+
* goes when your plugin does.
|
|
619
|
+
*/
|
|
620
|
+
setHighlights(
|
|
621
|
+
target: { win: WindowId } | { kind: string },
|
|
622
|
+
map: Record<string, string>,
|
|
623
|
+
): Promise<void>;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Moving and editing text where a window's cursor is.
|
|
628
|
+
*
|
|
629
|
+
* Verbs rather than positions, because grapheme and word boundaries are genuinely hard and nobody
|
|
630
|
+
* should have to get them right twice. A text field in your plugin behaves the same way the
|
|
631
|
+
* composer does because they are asking the same question, not because they each reimplemented it.
|
|
632
|
+
*/
|
|
633
|
+
export interface EditApi {
|
|
634
|
+
/**
|
|
635
|
+
* Move the cursor. `select` extends a selection from wherever it was anchored — anchoring first
|
|
636
|
+
* if nothing was — which is shift-and-arrow. Without it the selection is dropped.
|
|
637
|
+
*/
|
|
638
|
+
move(win: WindowId, motion: CursorMotion, opts?: { select?: boolean }): Promise<void>;
|
|
639
|
+
/** Edit at the cursor. Typing over a selection replaces it, as everywhere else. */
|
|
640
|
+
apply(win: WindowId, edit: TextEdit): Promise<void>;
|
|
641
|
+
/** Anchor a selection where the cursor is, or drop the one there is. */
|
|
642
|
+
select(win: WindowId, on: boolean): Promise<void>;
|
|
643
|
+
/**
|
|
644
|
+
* What the two ends of the selection *mean*.
|
|
645
|
+
*
|
|
646
|
+
* `"exclusive"` is a text field's: the cursor sits between characters and the one it is on is
|
|
647
|
+
* not selected. `"inclusive"` is a normal mode's — the cursor is *on* a character and that
|
|
648
|
+
* character is in — and `"line"` takes whole rows in whichever direction the selection runs.
|
|
649
|
+
* Dropping a selection puts this back to `"exclusive"`.
|
|
650
|
+
*/
|
|
651
|
+
selectShape(win: WindowId, shape: SelectShape): Promise<void>;
|
|
652
|
+
/**
|
|
653
|
+
* What the caret looks like here: a bar between two characters, or a block on one.
|
|
654
|
+
*
|
|
655
|
+
* The one thing on screen that says whether keys are being typed or obeyed, before any of them
|
|
656
|
+
* is pressed.
|
|
657
|
+
*/
|
|
658
|
+
cursorShape(win: WindowId, shape: CursorShape): Promise<void>;
|
|
659
|
+
/** What is selected. `""` when nothing is. */
|
|
660
|
+
selection(win: WindowId): Promise<string>;
|
|
661
|
+
/**
|
|
662
|
+
* Put text on the system clipboard.
|
|
663
|
+
*
|
|
664
|
+
* A capability rather than something you could do yourself: the runtime has no terminal, and the
|
|
665
|
+
* frontend is the only thing holding the stream this has to travel down. Over SSH it reaches the
|
|
666
|
+
* terminal you are sitting at, which a clipboard library on the remote host would not.
|
|
667
|
+
*/
|
|
668
|
+
copy(text: string): Promise<void>;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export interface FloatApi {
|
|
672
|
+
open(buf: BufferId, opts?: FloatOptions): Promise<WindowId>;
|
|
673
|
+
configure(win: WindowId, opts?: FloatOptions): Promise<void>;
|
|
674
|
+
close(win: WindowId): Promise<void>;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export interface NamespaceApi {
|
|
678
|
+
create(name: string): Promise<NamespaceId>;
|
|
679
|
+
/** `col` is a UTF-8 byte offset. */
|
|
680
|
+
mark(ns: NamespaceId, buf: BufferId, row: number, col: number, opts?: MarkOptions): Promise<ExtmarkId>;
|
|
681
|
+
getMark(ns: NamespaceId, buf: BufferId, id: ExtmarkId): Promise<ExtmarkInfo | null>;
|
|
682
|
+
allMarks(ns: NamespaceId, buf: BufferId): Promise<ExtmarkInfo[]>;
|
|
683
|
+
delMark(ns: NamespaceId, buf: BufferId, id: ExtmarkId): Promise<void>;
|
|
684
|
+
clear(ns: NamespaceId, buf: BufferId, start?: number, end?: number): Promise<void>;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export interface HighlightApi {
|
|
688
|
+
/**
|
|
689
|
+
* Declare a semantic group. Prefer `link` so an unknown theme still looks right.
|
|
690
|
+
*
|
|
691
|
+
* Yours from then on: a theme switch leaves it alone, and unloading your plugin takes it back to
|
|
692
|
+
* the theme's definition or away. `default: true` is Neovim's `:hi default` — define only if
|
|
693
|
+
* nobody has — which is what to use for the groups your plugin introduces, so a user's `init.ts`
|
|
694
|
+
* wins whichever of you loaded first.
|
|
695
|
+
*/
|
|
696
|
+
define(
|
|
697
|
+
name: string,
|
|
698
|
+
def: { link: string } | HighlightSpec,
|
|
699
|
+
opts?: { default?: boolean },
|
|
700
|
+
): Promise<void>;
|
|
701
|
+
/**
|
|
702
|
+
* What a group is, and what it resolves to after following links. Both `null` for a name nobody
|
|
703
|
+
* defined. The way to compute "a little dimmer than `Normal`" rather than guess at it.
|
|
704
|
+
*/
|
|
705
|
+
get(name: string): Promise<{ def: HighlightDef | null; resolved: HighlightSpec | null }>;
|
|
706
|
+
/** Every group, with which plugin owns it (`owner` absent for the theme's own). */
|
|
707
|
+
list(): Promise<HighlightEntry[]>;
|
|
708
|
+
/** Undo your definition of a group. Rejects for a group another plugin owns. */
|
|
709
|
+
reset(name: string): Promise<void>;
|
|
710
|
+
/**
|
|
711
|
+
* Groups changed — defined, reset, or all of them on a theme switch. `names` says which. A
|
|
712
|
+
* panel that cached a colour reads it again here; nothing else needs to, because the frontend
|
|
713
|
+
* redraws on its own.
|
|
714
|
+
*/
|
|
715
|
+
onChange(cb: (e: { names: string[] }) => void): Disposable;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export interface RawCellApi {
|
|
719
|
+
claim(win: WindowId, rect: Rect): Promise<SurfaceId>;
|
|
720
|
+
put(surface: SurfaceId, cells: SurfaceCell[]): Promise<void>;
|
|
721
|
+
release(surface: SurfaceId): Promise<void>;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** What a command handler is given and what it may give back. */
|
|
725
|
+
export type CommandHandler = (args: string[], key?: KeyContext) => unknown | Promise<unknown>;
|
|
726
|
+
|
|
727
|
+
export interface CommandApi {
|
|
728
|
+
/**
|
|
729
|
+
* Register a command by name. Keys bind to the name; `cmd.exec` runs it; `cmd.call` runs it and
|
|
730
|
+
* returns what the handler returned, so a command is also how one plugin asks another a
|
|
731
|
+
* question — `sidebar.cursor`, `git.status.of` — without importing it.
|
|
732
|
+
*/
|
|
733
|
+
register(name: string, fn: CommandHandler, opts?: { desc?: string }): Promise<Disposable>;
|
|
734
|
+
/** Run a command and do not wait for it. A key press, from code. */
|
|
735
|
+
exec(name: string, args?: string[]): Promise<void>;
|
|
736
|
+
/**
|
|
737
|
+
* Run a command and wait for its answer.
|
|
738
|
+
*
|
|
739
|
+
* Whatever the handler returned, as JSON — `null` for a handler that returned nothing. Rejects
|
|
740
|
+
* with the handler's error if it threw, with `not found` if nothing registered the name, and
|
|
741
|
+
* after a long timeout if the owner never answered. Routed through the host, so it works for
|
|
742
|
+
* the host's own commands (which answer `null`) and does not care which plugin owns the name.
|
|
743
|
+
*
|
|
744
|
+
* For a typed, zero-round-trip call into a plugin you depend on, `import { api } from
|
|
745
|
+
* "plugin:<name>"` instead — see the `requires` manifest field.
|
|
746
|
+
*/
|
|
747
|
+
call<T = unknown>(name: string, args?: string[]): Promise<T>;
|
|
748
|
+
list(): Promise<CommandEntry[]>;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export interface KeymapApi {
|
|
752
|
+
/**
|
|
753
|
+
* Bind a key to a *command name*, never to a callback.
|
|
754
|
+
*
|
|
755
|
+
* That indirection is what makes every binding listable and remappable by the user, and it lets
|
|
756
|
+
* the host resolve routing without calling into a plugin.
|
|
757
|
+
*
|
|
758
|
+
* Scope resolves window → buffer → buffer kind → global, first match winning. `{ kind: "buf_kind",
|
|
759
|
+
* name: "neosh.sidebar" }` is the one to reach for when the thing you are binding into is
|
|
760
|
+
* somebody else's panel: a window id is private to whoever opened it and dies with the window,
|
|
761
|
+
* whereas a kind is a name the panel publishes and every window of that kind — including ones
|
|
762
|
+
* opened tomorrow — is covered by one call.
|
|
763
|
+
*/
|
|
764
|
+
set(mode: Mode, lhs: string, command: string, opts?: { scope?: KeymapScope; desc?: string }): Promise<void>;
|
|
765
|
+
del(mode: Mode, lhs: string, scope?: KeymapScope): Promise<void>;
|
|
766
|
+
list(mode?: Mode): Promise<KeymapEntry[]>;
|
|
767
|
+
/**
|
|
768
|
+
* While `win` is focused, receive every key the keymaps did not claim.
|
|
769
|
+
*
|
|
770
|
+
* For widgets that need raw input: a filter box, a text field, a modal list. Bindings still win,
|
|
771
|
+
* so `<C-q>` keeps quitting while your picker is open — you get what nothing else wanted. The
|
|
772
|
+
* command is invoked with a `KeyContext`, so one handler can switch on the key.
|
|
773
|
+
*
|
|
774
|
+
* Dispose to release. A capture is also dropped when the window closes or your plugin unloads,
|
|
775
|
+
* so a crash cannot leave the keyboard pointing at nothing.
|
|
776
|
+
*/
|
|
777
|
+
capture(win: WindowId, command: string): Promise<Disposable>;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export interface FocusApi {
|
|
781
|
+
push(win: WindowId): Promise<void>;
|
|
782
|
+
pop(): Promise<void>;
|
|
783
|
+
current(): Promise<WindowId | null>;
|
|
784
|
+
/**
|
|
785
|
+
* The keyboard moved. `win` is `null` when nothing has it — the composer. The same fact also
|
|
786
|
+
* arrives as `neosh.win.enter` / `neosh.win.leave` on the event bus, with the buffer's kind,
|
|
787
|
+
* which is the form to use when you only care about one panel.
|
|
788
|
+
*/
|
|
789
|
+
onChange(cb: (e: { win: WindowId | null }) => void): Disposable;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export interface AgentApi {
|
|
793
|
+
/**
|
|
794
|
+
* Send a message. Anything on the composer's attachment row goes with it.
|
|
795
|
+
*
|
|
796
|
+
* `images` are extra paths to attach on the way through, for a plugin that has *produced* a
|
|
797
|
+
* picture rather than one somebody pasted — a rendered chart, a screenshot it took. The
|
|
798
|
+
* bytes are copied into the workspace, so a temporary file may be handed over and forgotten.
|
|
799
|
+
*/
|
|
800
|
+
send(text: string, opts?: { images?: string[] }): Promise<void>;
|
|
801
|
+
cancel(): Promise<void>;
|
|
802
|
+
/**
|
|
803
|
+
* Do something to a conversation by id, rather than to whichever one is on screen.
|
|
804
|
+
*
|
|
805
|
+
* The same vocabulary `swarm.command` carries to another machine — steer, interrupt, re-model,
|
|
806
|
+
* rename, archive, start — pointed at a conversation here. That symmetry is the point: an
|
|
807
|
+
* orchestrator that fans work out over several conversations and joins the results is one
|
|
808
|
+
* program whether the conversations are on this laptop or spread over the swarm, and until this
|
|
809
|
+
* existed it was only writable for the ones that were somewhere else.
|
|
810
|
+
*
|
|
811
|
+
* Everything that *watches* a conversation already names one — `onToken`, `onTurnEnd`,
|
|
812
|
+
* `sessions.messages` — so this is the half that was missing. Without it, driving a second
|
|
813
|
+
* conversation meant `sessions.switch` first, which moves the screen out from under whoever is
|
|
814
|
+
* reading it.
|
|
815
|
+
*
|
|
816
|
+
* Omit `session` for the conversation on screen. Answers with the conversation the command was
|
|
817
|
+
* about, which is how `new_session` says what it made.
|
|
818
|
+
*
|
|
819
|
+
* ```ts
|
|
820
|
+
* // Ask three conversations the same thing without touching the screen.
|
|
821
|
+
* for (const s of await neosh.session.list()) {
|
|
822
|
+
* await neosh.agent.command({ command: "send", text: "status?" }, s.id);
|
|
823
|
+
* }
|
|
824
|
+
* ```
|
|
825
|
+
*/
|
|
826
|
+
command(command: AgentCommand, session?: string): Promise<string | null>;
|
|
827
|
+
selection(): Promise<ModelSelection | null>;
|
|
828
|
+
/** Hot-swap the model. Takes effect on the next turn. */
|
|
829
|
+
setSelection(selection: ModelSelection): Promise<void>;
|
|
830
|
+
/**
|
|
831
|
+
* Every reachable model, each paired with the instance that serves it.
|
|
832
|
+
*
|
|
833
|
+
* The pairing matters: a model id is unique per instance, not globally, so a picker that dropped
|
|
834
|
+
* it would have to guess the owner — and guessing wrong sends the conversation to a different
|
|
835
|
+
* endpoint with no visible error.
|
|
836
|
+
*
|
|
837
|
+
* Answers from a session cache. Discovery is a network round trip per configured provider, so
|
|
838
|
+
* pass `refresh` only when you have reason to believe a lineup changed.
|
|
839
|
+
*/
|
|
840
|
+
listModels(instance?: string, opts?: { refresh?: boolean }): Promise<ModelEntry[]>;
|
|
841
|
+
listInstances(): Promise<InstanceConfig[]>;
|
|
842
|
+
/**
|
|
843
|
+
* What the driver behind this conversation accepts as a slash command.
|
|
844
|
+
*
|
|
845
|
+
* Reported by the driver at its handshake, not configured. Which commands exist depends on the
|
|
846
|
+
* install — `claude` counts project `.claude/commands/`, plugin commands and MCP prompts among
|
|
847
|
+
* its own — so any list written down in a plugin would be wrong on the first machine that had
|
|
848
|
+
* one of its own. Empty until the conversation has run a turn: there has been nothing to ask.
|
|
849
|
+
*/
|
|
850
|
+
driverCommands(): Promise<DriverCommand[]>;
|
|
851
|
+
/**
|
|
852
|
+
* Replace what is in the composer, caret at the end.
|
|
853
|
+
*
|
|
854
|
+
* For completion: a `/` menu, an `@file` menu, a path menu. Pair with the `composerChanged`
|
|
855
|
+
* event, which is the other half — one says what has been typed, this puts the answer back.
|
|
856
|
+
*/
|
|
857
|
+
setDraft(text: string): Promise<void>;
|
|
858
|
+
/**
|
|
859
|
+
* Attach an image to whatever is about to be sent.
|
|
860
|
+
*
|
|
861
|
+
* With a path, that file. Without one, whatever image is on the system clipboard — which is
|
|
862
|
+
* the only way a picture can reach a terminal at all: bracketed paste is a text protocol, and a
|
|
863
|
+
* screenshot pasted into one arrives as nothing. That is why `^V` is a key rather than a paste.
|
|
864
|
+
*
|
|
865
|
+
* The bytes are copied into the workspace's own directory, sniffed for what they actually are
|
|
866
|
+
* rather than what they are called, and shrunk if they are enormous. Rejects when there is no
|
|
867
|
+
* image to be had, with a reason worth showing.
|
|
868
|
+
*/
|
|
869
|
+
attach(path?: string): Promise<AttachmentInfo>;
|
|
870
|
+
/** What is attached to the composer right now, oldest first. */
|
|
871
|
+
attachments(): Promise<AttachmentInfo[]>;
|
|
872
|
+
/**
|
|
873
|
+
* Take something off the attachment row: the one at `index`, or the newest.
|
|
874
|
+
*
|
|
875
|
+
* Answers with what came off, or nothing if there was nothing there — the row may have
|
|
876
|
+
* gone out with a send between asking and answering, and that is not an error.
|
|
877
|
+
*/
|
|
878
|
+
detach(index?: number): Promise<AttachmentInfo | null>;
|
|
879
|
+
/** Take the whole attachment row off. Answers with what was on it. */
|
|
880
|
+
detachAll(): Promise<AttachmentInfo[]>;
|
|
881
|
+
/**
|
|
882
|
+
* Where each configured provider's key comes from — and never what it is.
|
|
883
|
+
*
|
|
884
|
+
* There is deliberately no call that returns a secret. A plugin can find out that `anthropic` is
|
|
885
|
+
* authenticated from the keychain, and can ask the host to collect a new key; it cannot read one,
|
|
886
|
+
* so it cannot leak one.
|
|
887
|
+
*/
|
|
888
|
+
credentials(): Promise<CredentialInfo[]>;
|
|
889
|
+
/**
|
|
890
|
+
* Ask the host to collect an API key for `instance` from the keyboard.
|
|
891
|
+
*
|
|
892
|
+
* The host runs this prompt itself: a plugin one would have to put the key in a buffer, and a
|
|
893
|
+
* buffer is drawn — the value would cross the frontend boundary and land in whatever it logs.
|
|
894
|
+
* What comes back is whether a key was stored, never the key.
|
|
895
|
+
*
|
|
896
|
+
* Rejects when the instance signs in on its own (a CLI login) or needs no key at all, and when it
|
|
897
|
+
* already has one unless you pass `replace`.
|
|
898
|
+
*/
|
|
899
|
+
setCredential(instance: string, opts?: { replace?: boolean }): Promise<boolean>;
|
|
900
|
+
/** Drop a stored key from memory and from the keychain. The environment is not ours to clear. */
|
|
901
|
+
forgetCredential(instance: string): Promise<void>;
|
|
902
|
+
/**
|
|
903
|
+
* The model this conversation will use changed, whoever changed it.
|
|
904
|
+
*
|
|
905
|
+
* Not the same as `opt.onChange` for `agent.model`: that option is a preference, and the
|
|
906
|
+
* selection also moves when a conversation is restored, when a stored model turns out not to
|
|
907
|
+
* authenticate, and when a provider registers late and the model somebody asked for finally
|
|
908
|
+
* becomes reachable. Anything that names the model — a footer, a context meter measuring against
|
|
909
|
+
* its window — wants this one.
|
|
910
|
+
*/
|
|
911
|
+
onSelectionChange(cb: (e: { selection: ModelSelection }) => void): Disposable;
|
|
912
|
+
/**
|
|
913
|
+
* The composer's text changed — a keystroke, a paste, a conversation switch, a send.
|
|
914
|
+
*
|
|
915
|
+
* The other half of {@link AgentApi.setDraft}. Completion of any kind is these two: watch what
|
|
916
|
+
* has been typed, offer something, put the answer back.
|
|
917
|
+
*/
|
|
918
|
+
onComposerChange(cb: (e: { text: string }) => void): Disposable;
|
|
919
|
+
/**
|
|
920
|
+
* What a driver's own loop said about itself — a sub-agent, a plan, a compaction, how full its
|
|
921
|
+
* context is.
|
|
922
|
+
*
|
|
923
|
+
* The only signal that moves *during* a turn. Everything else about usage arrives when the turn
|
|
924
|
+
* ends, which for an agent driver can be twenty minutes after the number changed.
|
|
925
|
+
*/
|
|
926
|
+
onActivity(cb: (e: { session: SessionId; turn: string; activity: Activity }) => void): Disposable;
|
|
927
|
+
/**
|
|
928
|
+
* A turn has begun.
|
|
929
|
+
*
|
|
930
|
+
* Every turn event says which conversation it belongs to. A workspace runs several at once, so
|
|
931
|
+
* anything that draws a turn has to check: by the time one ends, the conversation it ran in may
|
|
932
|
+
* not be the one on screen. `neosh.session.list()` flags the active one.
|
|
933
|
+
*/
|
|
934
|
+
onTurnStart(cb: (e: { session: string; turn: string }) => void): Disposable;
|
|
935
|
+
/** One streamed chunk of assistant text. Chunks are provider-sized, not characters. */
|
|
936
|
+
onToken(cb: (e: { session: string; turn: string; text: string }) => void): Disposable;
|
|
937
|
+
onThinking(cb: (e: { session: string; turn: string; text: string }) => void): Disposable;
|
|
938
|
+
onTurnEnd(
|
|
939
|
+
cb: (e: { session: string; turn: string; stopReason: StopReason; usage: Usage }) => void,
|
|
940
|
+
): Disposable;
|
|
941
|
+
/**
|
|
942
|
+
* A tool is about to run.
|
|
943
|
+
*
|
|
944
|
+
* Distinct from the `tool_pre` hook: that one is asked *whether* the call may proceed and can
|
|
945
|
+
* veto it. This is told that it is happening, cannot influence it, and is therefore what a
|
|
946
|
+
* transcript wants.
|
|
947
|
+
*/
|
|
948
|
+
onToolStart(cb: (e: { session: string; turn: string; call: ToolCall }) => void): Disposable;
|
|
949
|
+
onToolEnd(
|
|
950
|
+
cb: (e: { session: string; turn: string; call: ToolCall; result: ToolResult }) => void,
|
|
951
|
+
): Disposable;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export interface ToolApi {
|
|
955
|
+
/** Lands in the same namespace and shape as a built-in or MCP tool. */
|
|
956
|
+
register(
|
|
957
|
+
def: { name: string; description: string; inputSchema: Record<string, unknown> },
|
|
958
|
+
handler: (input: unknown) => ToolResult | Promise<ToolResult>,
|
|
959
|
+
): Promise<Disposable>;
|
|
960
|
+
list(): Promise<ToolDef[]>;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
export interface HookApi {
|
|
964
|
+
/**
|
|
965
|
+
* Register a hook.
|
|
966
|
+
*
|
|
967
|
+
* `blocking: false` (the default) is a pure observer whose return value is ignored — that is what
|
|
968
|
+
* stops an audit plugin from wedging the agent loop. A blocking hook is awaited and may veto;
|
|
969
|
+
* **a blocking hook that does not answer in time is treated as a veto**, so a policy plugin fails
|
|
970
|
+
* closed.
|
|
971
|
+
*/
|
|
972
|
+
register(
|
|
973
|
+
hook: HookName,
|
|
974
|
+
fn: (payload: HookPayload) => HookOutcome | Promise<HookOutcome>,
|
|
975
|
+
opts?: { blocking?: boolean; timeoutMs?: number },
|
|
976
|
+
): Promise<Disposable>;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
export interface ProviderApi {
|
|
980
|
+
/**
|
|
981
|
+
* Register a model provider implemented in this plugin.
|
|
982
|
+
*
|
|
983
|
+
* Supporting another vendor is a plugin, not a core change. Because a stream cannot cross the
|
|
984
|
+
* RPC boundary as a return value, `handler` receives an `emit` callback and pushes events until
|
|
985
|
+
* it emits `message_stop`.
|
|
986
|
+
*/
|
|
987
|
+
register(
|
|
988
|
+
driver: string,
|
|
989
|
+
instances: InstanceConfig[],
|
|
990
|
+
handler: (req: TurnRequest, emit: (e: ProviderEvent) => void, signal: { cancelled: boolean }) => void | Promise<void>,
|
|
991
|
+
opts?: {
|
|
992
|
+
/**
|
|
993
|
+
* This driver runs its own agent loop — it has its own tools, and calls them itself.
|
|
994
|
+
*
|
|
995
|
+
* neosh then sends it no tool list, does not execute the calls in its stream, and records the
|
|
996
|
+
* conversation in the shape that actually happened. Leaving it off for such a driver makes
|
|
997
|
+
* the host run every tool call a second time.
|
|
998
|
+
*/
|
|
999
|
+
agentLoop?: boolean;
|
|
1000
|
+
},
|
|
1001
|
+
): Promise<Disposable>;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Version control.
|
|
1006
|
+
*
|
|
1007
|
+
* The plugin runtime has no process access, so `git` is a host capability rather than something a
|
|
1008
|
+
* plugin shells out to. One implementation means a sidebar, a branch picker and a commit UI agree
|
|
1009
|
+
* about what "dirty" means instead of each parsing porcelain slightly differently.
|
|
1010
|
+
*
|
|
1011
|
+
* Reads are free. Writes go through the permission layer as `exec` of `git <verb>`, so
|
|
1012
|
+
* `permissions.allow_commands = ["git"]` covers them and a policy hook watching exec sees them.
|
|
1013
|
+
*
|
|
1014
|
+
* Every call rejects with `not_found` when neosh was started outside a repository — check once with
|
|
1015
|
+
* `status()` rather than guarding each call.
|
|
1016
|
+
*/
|
|
1017
|
+
export interface GitApi {
|
|
1018
|
+
/** The working tree's state. `cwd` is any checkout; omitted, the one this conversation is in. */
|
|
1019
|
+
status(opts?: { cwd?: string }): Promise<RepoStatus>;
|
|
1020
|
+
/** Local branches, most recently committed first. */
|
|
1021
|
+
branches(opts?: { includeRemote?: boolean; cwd?: string }): Promise<BranchInfo[]>;
|
|
1022
|
+
/**
|
|
1023
|
+
* Every checkout of the repository.
|
|
1024
|
+
*
|
|
1025
|
+
* `cwd` picks which repository to ask about; without it the answer is the one this conversation
|
|
1026
|
+
* is in, which is what a status bar or a branch picker means. A panel means the other thing —
|
|
1027
|
+
* it lists several projects at once, and the row under the cursor is not always the conversation
|
|
1028
|
+
* you are in.
|
|
1029
|
+
*/
|
|
1030
|
+
worktrees(opts?: { cwd?: string }): Promise<WorktreeInfo[]>;
|
|
1031
|
+
log(limit?: number): Promise<CommitInfo[]>;
|
|
1032
|
+
/** The patch. Pass `stat` for `--stat`, which is what a prompt wants. */
|
|
1033
|
+
diff(target?: DiffTarget, opts?: { stat?: boolean }): Promise<string>;
|
|
1034
|
+
/** What this branch would merge into: `origin/HEAD`, else `main`/`master`. */
|
|
1035
|
+
defaultBranch(): Promise<string | null>;
|
|
1036
|
+
createBranch(name: string, opts?: { from?: string }): Promise<void>;
|
|
1037
|
+
/**
|
|
1038
|
+
* Move a branch to another name — `git branch -m`.
|
|
1039
|
+
*
|
|
1040
|
+
* One ref write. The working tree is untouched, so this is safe on a branch that is checked out
|
|
1041
|
+
* and safe while an agent is editing files against it — which is the case it exists for: naming
|
|
1042
|
+
* a worktree's branch from the first message, once there is a message to name it from.
|
|
1043
|
+
*
|
|
1044
|
+
* `cwd` is the checkout the branch belongs to, and you almost always want it: the worktree being
|
|
1045
|
+
* renamed is very often not the one the active conversation is standing in.
|
|
1046
|
+
*
|
|
1047
|
+
* Fails if `next` is taken — a generated name does not get to overwrite somebody's branch. Ask
|
|
1048
|
+
* `branches()` and pick a free one.
|
|
1049
|
+
*/
|
|
1050
|
+
renameBranch(name: string, next: string, opts?: { cwd?: string }): Promise<void>;
|
|
1051
|
+
checkout(rev: string): Promise<void>;
|
|
1052
|
+
/** Empty `paths` stages everything, like `git add .` from the repository root. */
|
|
1053
|
+
stage(paths?: string[]): Promise<void>;
|
|
1054
|
+
unstage(paths?: string[]): Promise<void>;
|
|
1055
|
+
commit(message: string): Promise<CommitInfo>;
|
|
1056
|
+
/**
|
|
1057
|
+
* `git pull`, answering with git's own summary — "Already up to date.", the fast-forward range —
|
|
1058
|
+
* because those are different answers and a caller showing neither is a caller nobody trusts.
|
|
1059
|
+
* `cwd` picks the repository, as everywhere; absent means the conversation's own.
|
|
1060
|
+
*/
|
|
1061
|
+
pull(opts?: { cwd?: string }): Promise<string>;
|
|
1062
|
+
addWorktree(
|
|
1063
|
+
path: string,
|
|
1064
|
+
branch: string,
|
|
1065
|
+
opts?: { create?: boolean; cwd?: string },
|
|
1066
|
+
): Promise<void>;
|
|
1067
|
+
/**
|
|
1068
|
+
* `cwd` names the repository the worktree belongs to. `git worktree remove` must run from a
|
|
1069
|
+
* checkout other than the one being removed, and the active conversation may be standing in
|
|
1070
|
+
* exactly that one.
|
|
1071
|
+
*/
|
|
1072
|
+
removeWorktree(path: string, opts?: { force?: boolean; cwd?: string }): Promise<void>;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* One-shot generation: a prompt through a model, outside the conversation.
|
|
1077
|
+
*
|
|
1078
|
+
* Branch names, commit messages, thread titles and PR descriptions are all this call. It is
|
|
1079
|
+
* deliberately *not* `agent.send` — nothing here enters session history, so asking for a commit
|
|
1080
|
+
* message does not change what the agent believes it was asked to do.
|
|
1081
|
+
*
|
|
1082
|
+
* The model is `gen.model` when set, else the conversation's own. Point that option at something
|
|
1083
|
+
* cheap; naming a branch does not need a frontier model.
|
|
1084
|
+
*/
|
|
1085
|
+
export interface GenApi {
|
|
1086
|
+
complete(prompt: string, opts?: { system?: string; selection?: ModelSelection }): Promise<string>;
|
|
1087
|
+
/**
|
|
1088
|
+
* Same, but parse the answer as JSON.
|
|
1089
|
+
*
|
|
1090
|
+
* The host tolerates what models actually return — code fences, a "Sure!" preamble — so callers
|
|
1091
|
+
* do not each reimplement that. Rejects if there is no JSON in the response at all.
|
|
1092
|
+
*/
|
|
1093
|
+
json<T = unknown>(prompt: string, opts?: { system?: string; selection?: ModelSelection }): Promise<T>;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Conversations.
|
|
1098
|
+
*
|
|
1099
|
+
* A workspace is not one conversation: a branch you are on, a review you are half-way through, a
|
|
1100
|
+
* question from yesterday. These are the verbs a thread list needs; what it looks like is yours.
|
|
1101
|
+
*
|
|
1102
|
+
* Conversations are saved to the state directory as you go and restored at startup, so switching
|
|
1103
|
+
* away from one is not a way to lose it.
|
|
1104
|
+
*/
|
|
1105
|
+
/**
|
|
1106
|
+
* The terminals looking at this workspace.
|
|
1107
|
+
*
|
|
1108
|
+
* A workspace can have several and they are not copies of each other: each has its own
|
|
1109
|
+
* conversation on screen, its own scroll position, its own composer and its own panels. What they
|
|
1110
|
+
* share is the work — the conversations themselves, the turns running in them, everything a plugin
|
|
1111
|
+
* registered.
|
|
1112
|
+
*
|
|
1113
|
+
* A plugin that owns a **dock** has to open one panel per view, and `onOpen` is when. A plugin that
|
|
1114
|
+
* only opens floats in answer to a key needs none of this: the host puts a float in the terminal
|
|
1115
|
+
* whose key press opened it.
|
|
1116
|
+
*/
|
|
1117
|
+
export interface ViewApi {
|
|
1118
|
+
/** Every terminal, and what each is looking at. */
|
|
1119
|
+
list(): Promise<ViewInfo[]>;
|
|
1120
|
+
/** The one being served — the terminal whose key press is running. */
|
|
1121
|
+
current(): Promise<ViewInfo | null>;
|
|
1122
|
+
/**
|
|
1123
|
+
* The whole `neosh` namespace, bound to one terminal.
|
|
1124
|
+
*
|
|
1125
|
+
* Every call on it is the call it always was, except that a window opened through it lands
|
|
1126
|
+
* there. The same object a command handler is given as its third argument.
|
|
1127
|
+
*/
|
|
1128
|
+
at(view: ViewId): Neosh;
|
|
1129
|
+
/**
|
|
1130
|
+
* A terminal arrived. Open your panel in it.
|
|
1131
|
+
*
|
|
1132
|
+
* Fired for every view that already exists when the plugin loads, too, so a plugin does not have
|
|
1133
|
+
* to decide whether it was here first.
|
|
1134
|
+
*/
|
|
1135
|
+
onOpen(cb: (view: ViewId) => void): Disposable;
|
|
1136
|
+
/** A terminal went away. Its windows are already closed; let go of what you were keeping. */
|
|
1137
|
+
onClose(cb: (view: ViewId) => void): Disposable;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
export interface SessionApi {
|
|
1141
|
+
/**
|
|
1142
|
+
* Most recently active first, with exactly one flagged `is_active`.
|
|
1143
|
+
*
|
|
1144
|
+
* Archived conversations are left out unless you ask for them. That is what archiving is for, and
|
|
1145
|
+
* a list that included them by default would make every caller responsible for remembering.
|
|
1146
|
+
*/
|
|
1147
|
+
list(opts?: { includeArchived?: boolean }): Promise<SessionInfo[]>;
|
|
1148
|
+
current(): Promise<SessionInfo>;
|
|
1149
|
+
/**
|
|
1150
|
+
* Start one. It inherits the model and system prompt you are using — starting a conversation
|
|
1151
|
+
* should not silently change what you are talking to.
|
|
1152
|
+
*
|
|
1153
|
+
* `cwd` opens it against another checkout, which is how a second project becomes visible.
|
|
1154
|
+
*/
|
|
1155
|
+
create(opts?: { cwd?: string; title?: string; activate?: boolean }): Promise<SessionInfo>;
|
|
1156
|
+
/**
|
|
1157
|
+
* Look at another conversation.
|
|
1158
|
+
*
|
|
1159
|
+
* Never refused, including while a turn is running. A turn belongs to its conversation and keeps
|
|
1160
|
+
* streaming into it; what you see is rebuilt from whichever one you switched to, and switching
|
|
1161
|
+
* back puts you in the middle of the answer where you left it.
|
|
1162
|
+
*/
|
|
1163
|
+
switch(session: SessionId): Promise<void>;
|
|
1164
|
+
/**
|
|
1165
|
+
* Close one. Closing the active conversation moves to the most recently used other; closing the
|
|
1166
|
+
* last one is an error, because there is always somewhere for the next thing you type.
|
|
1167
|
+
*
|
|
1168
|
+
* A turn running in it is cancelled: there is about to be nowhere to put its answer.
|
|
1169
|
+
*
|
|
1170
|
+
* A conversation this workspace never loaded — one past the restore cap, which {@link stored}
|
|
1171
|
+
* is how you find — is deleted from disk just the same. One verb, whether the store is holding it
|
|
1172
|
+
* or only the directory is.
|
|
1173
|
+
*/
|
|
1174
|
+
close(session: SessionId): Promise<void>;
|
|
1175
|
+
/** Pass `null` to clear a title and go back to the first-message label. */
|
|
1176
|
+
rename(session: SessionId, title: string | null): Promise<void>;
|
|
1177
|
+
/**
|
|
1178
|
+
* Put a conversation away, or bring it back.
|
|
1179
|
+
*
|
|
1180
|
+
* Not `close`: nothing is deleted, every message survives, and `list({ includeArchived: true })`
|
|
1181
|
+
* still finds it. Archiving the active conversation moves you to the most recently used other
|
|
1182
|
+
* one — or to a fresh empty one if there is no other.
|
|
1183
|
+
*/
|
|
1184
|
+
archive(session: SessionId, archived?: boolean): Promise<void>;
|
|
1185
|
+
/**
|
|
1186
|
+
* Every conversation *on disk*, loaded or not — newest first.
|
|
1187
|
+
*
|
|
1188
|
+
* {@link list} answers about the workspace's store, and the store is a window rather than the
|
|
1189
|
+
* whole directory: a workspace restores the most recent few hundred conversations and leaves the
|
|
1190
|
+
* rest as files. Those files are in no list, which is fine until something has to say what has
|
|
1191
|
+
* accumulated or take it away — so an archive that only ever asked `list` would report a number
|
|
1192
|
+
* that was not the number, and empty itself down to a directory that was still full.
|
|
1193
|
+
*
|
|
1194
|
+
* The rows are ordinary {@link SessionInfo}s, so one renderer draws both. Which of them this
|
|
1195
|
+
* workspace is actually holding is the difference between the two calls, and a caller that cares
|
|
1196
|
+
* asks both and compares ids.
|
|
1197
|
+
*
|
|
1198
|
+
* It reads and parses every file, so it is answered off the host loop and is not something to put
|
|
1199
|
+
* on a redraw.
|
|
1200
|
+
*/
|
|
1201
|
+
stored(): Promise<SessionInfo[]>;
|
|
1202
|
+
/** The conversation itself, for a transcript view that renders rather than replays. */
|
|
1203
|
+
messages(session?: SessionId): Promise<Message[]>;
|
|
1204
|
+
/**
|
|
1205
|
+
* Fires whenever a terminal is looking at a different conversation — switched, created, closed.
|
|
1206
|
+
*
|
|
1207
|
+
* `view` is which terminal moved. A workspace can have several and each is somewhere, so
|
|
1208
|
+
* "the active conversation" is a question with as many answers as there are screens.
|
|
1209
|
+
*/
|
|
1210
|
+
onChange(cb: (e: { session: SessionId; view: ViewId }) => void): Disposable;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* The status line — the composer footer.
|
|
1215
|
+
*
|
|
1216
|
+
* The host owns the strip: one line, always visible, never scrolled. Plugins own what is in it.
|
|
1217
|
+
* That split is what lets the model switcher and the git plugin each put something there without
|
|
1218
|
+
* either knowing the other exists.
|
|
1219
|
+
*
|
|
1220
|
+
* Setting the same key again replaces that segment, so updating a meter every tick does not need a
|
|
1221
|
+
* clear first and cannot leave two. Segments are namespaced per plugin, so two plugins choosing
|
|
1222
|
+
* `"model"` cannot collide, and unloading a plugin takes its segments with it.
|
|
1223
|
+
*/
|
|
1224
|
+
/**
|
|
1225
|
+
* The shortcut row under the composer.
|
|
1226
|
+
*
|
|
1227
|
+
* Whoever owns a feature owns its hint, which is the only arrangement that stays true: the row is
|
|
1228
|
+
* built from what is actually registered right now, so a plugin that is switched off takes its
|
|
1229
|
+
* shortcut with it rather than leaving a key advertised that no longer does anything.
|
|
1230
|
+
*
|
|
1231
|
+
* Write the key the way the user would press it — `^P`, `⇧⏎`, `^Z` — not the way a keymap spells
|
|
1232
|
+
* it. Hints are dropped from the end when the terminal is too narrow, so put the one you would
|
|
1233
|
+
* most want seen at the lowest priority.
|
|
1234
|
+
*/
|
|
1235
|
+
export interface HintApi {
|
|
1236
|
+
set(key: string, hint: { keys: string; label: string; priority?: number }): Promise<void>;
|
|
1237
|
+
clear(key: string): Promise<void>;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
export interface PermissionApi {
|
|
1241
|
+
mode(): Promise<PermissionMode>;
|
|
1242
|
+
setMode(mode: PermissionMode): Promise<PermissionMode>;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
export interface StatusApi {
|
|
1246
|
+
/**
|
|
1247
|
+
* `keys` is drawn immediately after `text`, dimmed — the key that changes this thing, beside the
|
|
1248
|
+
* thing it changes. Write it the way the user would press it (`^P`, `^Z`), not the way a keymap
|
|
1249
|
+
* spells it.
|
|
1250
|
+
*
|
|
1251
|
+
* `short` is the same thing said in less room, and the strip asks for it before it drops your
|
|
1252
|
+
* segment. Give one to anything wide: without it a segment costs its full width or nothing, so
|
|
1253
|
+
* the widest thing in the strip is the first thing to vanish on a narrow terminal — which is
|
|
1254
|
+
* usually the thing worth the most. It is not a truncation and the host will not invent one; it
|
|
1255
|
+
* is the fact with a part left out, and only you know which part that is.
|
|
1256
|
+
*
|
|
1257
|
+
* `priority` is where the segment sits *and* what the strip gives up first, in reverse.
|
|
1258
|
+
*/
|
|
1259
|
+
set(
|
|
1260
|
+
key: string,
|
|
1261
|
+
segment: {
|
|
1262
|
+
text: string;
|
|
1263
|
+
/** The same fact in fewer columns, used before this segment is dropped for want of room. */
|
|
1264
|
+
short?: string;
|
|
1265
|
+
keys?: string;
|
|
1266
|
+
hl?: string;
|
|
1267
|
+
align?: StatusAlign;
|
|
1268
|
+
priority?: number;
|
|
1269
|
+
},
|
|
1270
|
+
): Promise<void>;
|
|
1271
|
+
clear(key: string): Promise<void>;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
export interface OptionApi {
|
|
1275
|
+
/**
|
|
1276
|
+
* Declare an option this plugin owns.
|
|
1277
|
+
*
|
|
1278
|
+
* neosh's own settings are declared through this same call at startup, so there is nothing the
|
|
1279
|
+
* built-in options can do that yours cannot — including being set from `config.toml` and shown
|
|
1280
|
+
* by a settings UI that has never heard of your plugin.
|
|
1281
|
+
*
|
|
1282
|
+
* Names are dot-separated lowercase. Namespace yours under your plugin id.
|
|
1283
|
+
*/
|
|
1284
|
+
declare(spec: OptionSpec): Promise<Disposable>;
|
|
1285
|
+
/** Typed read. Rejects if the option was never declared. */
|
|
1286
|
+
get<T = OptionValue>(name: string): Promise<T>;
|
|
1287
|
+
/** Full entry, including type, default and owner — or `null` if undeclared. */
|
|
1288
|
+
entry(name: string): Promise<OptionEntry | null>;
|
|
1289
|
+
/**
|
|
1290
|
+
* Set a declared option. Rejects on an unknown name or a value that does not match the declared
|
|
1291
|
+
* type, rather than quietly doing nothing.
|
|
1292
|
+
*/
|
|
1293
|
+
set(name: string, value: OptionValue): Promise<void>;
|
|
1294
|
+
/** Restore the declared default. */
|
|
1295
|
+
reset(name: string): Promise<void>;
|
|
1296
|
+
all(): Promise<OptionEntry[]>;
|
|
1297
|
+
/** Fires for every option, not just your own: a setting is shared state. */
|
|
1298
|
+
onChange(cb: (e: { name: string; value: OptionValue }) => void): Disposable;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* What your plugin remembers between runs.
|
|
1303
|
+
*
|
|
1304
|
+
* Small facts a panel needs so it is still arranged the way you left it: which projects are pinned,
|
|
1305
|
+
* what order you dragged them into, which sections are folded. Deliberately *not* options — an
|
|
1306
|
+
* option is configuration the user writes, and a plugin that rewrites someone's config file because
|
|
1307
|
+
* they pressed a key is one they stop trusting with the file.
|
|
1308
|
+
*
|
|
1309
|
+
* Keyed by your plugin id, which the host knows and you cannot forge, so no other plugin can read
|
|
1310
|
+
* or clobber what you store. It is plain JSON on disk: **nothing secret belongs here.**
|
|
1311
|
+
*/
|
|
1312
|
+
export interface StateApi {
|
|
1313
|
+
/**
|
|
1314
|
+
* `null` when nothing was stored — indistinguishable from having stored `null`, which no caller
|
|
1315
|
+
* has ever needed to tell apart.
|
|
1316
|
+
*/
|
|
1317
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
1318
|
+
set(key: string, value: unknown): Promise<void>;
|
|
1319
|
+
remove(key: string): Promise<void>;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* What *everybody* remembers about a project or a conversation.
|
|
1324
|
+
*
|
|
1325
|
+
* The counterpart to `state`, and the difference is who may look. State is keyed by your plugin and
|
|
1326
|
+
* private, which is right for your fold set and wrong for "this project is a favourite" — with
|
|
1327
|
+
* state, a sidebar of somebody's own starts with no favourites and pinning one in ours is invisible
|
|
1328
|
+
* to it. A var is scoped to the thing it describes, and anyone may read or write it.
|
|
1329
|
+
*
|
|
1330
|
+
* Namespace your keys (`sidebar.favorite`, `acme.colour`) for the reason options are namespaced:
|
|
1331
|
+
* nothing stops two plugins choosing `colour`, and a prefix is what makes them not want to.
|
|
1332
|
+
*
|
|
1333
|
+
* Persisted, shared, and plain JSON on disk: **nothing secret belongs here.** A conversation's vars
|
|
1334
|
+
* are deleted with it; a project's outlive every conversation in it, because a project is a
|
|
1335
|
+
* directory and the directory is still there.
|
|
1336
|
+
*/
|
|
1337
|
+
export interface VarApi {
|
|
1338
|
+
get<T = unknown>(scope: VarScope, key: string): Promise<T | null>;
|
|
1339
|
+
set(scope: VarScope, key: string, value: unknown): Promise<void>;
|
|
1340
|
+
remove(scope: VarScope, key: string): Promise<void>;
|
|
1341
|
+
/** Everything on one scope, in one round trip. What a panel reads per project rather than per key. */
|
|
1342
|
+
all(scope: VarScope): Promise<Record<string, unknown>>;
|
|
1343
|
+
/**
|
|
1344
|
+
* A var changed, whoever changed it — including you. The signal to redraw on.
|
|
1345
|
+
*
|
|
1346
|
+
* `value` is `undefined` when it was removed.
|
|
1347
|
+
*/
|
|
1348
|
+
onChange(
|
|
1349
|
+
cb: (e: { scope: VarScope; key: string; value: unknown }) => void,
|
|
1350
|
+
): Disposable;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** Sugar for the two scopes anything with a panel spends its time in. */
|
|
1354
|
+
export function projectScope(cwd: string): VarScope {
|
|
1355
|
+
return { scope: "project", cwd };
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
export function sessionScope(session: SessionId): VarScope {
|
|
1359
|
+
return { scope: "session", session };
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* How your plugin puts something in somebody else's panel.
|
|
1364
|
+
*
|
|
1365
|
+
* A *point* is a name a plugin agrees to read — `sidebar.section`, `project.action`, `palette.entry`
|
|
1366
|
+
* — and a contribution is a JSON item on it, conventionally carrying the name of a command to run.
|
|
1367
|
+
* The indirection is the whole trick: the sidebar renders rows it did not write and invokes commands
|
|
1368
|
+
* it has never heard of, and neither side imports the other.
|
|
1369
|
+
*
|
|
1370
|
+
* Data rather than a callback on purpose. A contribution can be listed by the palette, described in
|
|
1371
|
+
* `^Z` and disabled by the user, none of which is possible for a function held inside your closure.
|
|
1372
|
+
*
|
|
1373
|
+
* Your contributions are withdrawn when your plugin unloads, so `plugins.disabled` takes your rows
|
|
1374
|
+
* with it and there is no way to leave a row behind pointing at a command that no longer exists.
|
|
1375
|
+
*/
|
|
1376
|
+
export interface ExtensionApi {
|
|
1377
|
+
/**
|
|
1378
|
+
* Put an item on a point, replacing whatever you had there under the same `id`.
|
|
1379
|
+
*
|
|
1380
|
+
* Higher `priority` sorts first; ties break on plugin and id, so the order is stable across
|
|
1381
|
+
* restarts rather than being whatever order plugins happened to activate in.
|
|
1382
|
+
*/
|
|
1383
|
+
contribute(
|
|
1384
|
+
point: string,
|
|
1385
|
+
id: string,
|
|
1386
|
+
item: unknown,
|
|
1387
|
+
opts?: { priority?: number },
|
|
1388
|
+
): Promise<Disposable>;
|
|
1389
|
+
remove(point: string, id: string): Promise<void>;
|
|
1390
|
+
/** Everything on a point, in order, whoever contributed it. What a panel calls when it draws. */
|
|
1391
|
+
list<T = unknown>(point: string): Promise<Array<Contribution & { item: T }>>;
|
|
1392
|
+
/**
|
|
1393
|
+
* Somebody added to or withdrew from a point. Redraw.
|
|
1394
|
+
*
|
|
1395
|
+
* Without this a plugin that loads after your panel has drawn contributes rows nobody sees until
|
|
1396
|
+
* the next unrelated refresh — which on a quiet workspace is several seconds of a panel missing
|
|
1397
|
+
* half of itself.
|
|
1398
|
+
*/
|
|
1399
|
+
onChange(cb: (e: { point: string }) => void): Disposable;
|
|
1400
|
+
/**
|
|
1401
|
+
* Every point anybody reads or writes: who declared it (`[provides] points` in their manifest)
|
|
1402
|
+
* and who has something on it. A point with contributors and no readers is almost always a
|
|
1403
|
+
* typo, and neosh says so at startup.
|
|
1404
|
+
*/
|
|
1405
|
+
points(): Promise<PointInfo[]>;
|
|
1406
|
+
/**
|
|
1407
|
+
* Every plugin the workspace knows about, with its manifest and what became of it — `loaded`,
|
|
1408
|
+
* `held` until one of its activation triggers, or `failed` with the reason. The list a plugins
|
|
1409
|
+
* panel is drawn from.
|
|
1410
|
+
*/
|
|
1411
|
+
plugins(): Promise<PluginInfo[]>;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Saying that something happened, to whoever cares.
|
|
1416
|
+
*
|
|
1417
|
+
* Plugin-defined and broadcast — Neovim's `User` autocmd. Nothing validates the names; namespace
|
|
1418
|
+
* them like everything else.
|
|
1419
|
+
*
|
|
1420
|
+
* Fire and forget by construction. There is no reply and no way to be blocked, because an emitter
|
|
1421
|
+
* that could be is an emitter every listener is on the critical path of, which is how one slow
|
|
1422
|
+
* plugin wedges a panel. When you need an answer, register a command or read a contribution point.
|
|
1423
|
+
*
|
|
1424
|
+
* The host emits one of its own: **`neosh.ready`**, `from: "neosh"`, once every plugin has loaded.
|
|
1425
|
+
* Your `activate` returning is not that moment — the others are still loading alongside you, so a
|
|
1426
|
+
* command you would call is a name nothing answers to yet, a contribution point somebody else
|
|
1427
|
+
* fills is still empty, and a model a plugin registers is not selectable. Anything that depends on
|
|
1428
|
+
* the *rest* of the workspace goes in a `neosh.ready` listener rather than at the end of
|
|
1429
|
+
* `activate`. It is said again after `^R`, which is the same fact being true a second time.
|
|
1430
|
+
*/
|
|
1431
|
+
/** What the host says about a window on the bus: `neosh.win.enter`, `.leave`, `.open`. */
|
|
1432
|
+
export interface WindowEvent {
|
|
1433
|
+
win: WindowId;
|
|
1434
|
+
buf: BufferId | null;
|
|
1435
|
+
/** The buffer's kind — the field to filter on. */
|
|
1436
|
+
kind: string | null;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
export interface EventApi {
|
|
1440
|
+
emit(name: string, data?: unknown): Promise<void>;
|
|
1441
|
+
/**
|
|
1442
|
+
* Listen. `from` is the plugin that emitted it, stamped by the host — one of the few things in a
|
|
1443
|
+
* plugin message nobody can forge.
|
|
1444
|
+
*
|
|
1445
|
+
* You hear your own events too. Filtering on `from === ctx.plugin` is how you skip them, and it
|
|
1446
|
+
* is deliberately your choice: a panel that reacts to its own writes uniformly has one code path
|
|
1447
|
+
* instead of two.
|
|
1448
|
+
*/
|
|
1449
|
+
/**
|
|
1450
|
+
* Hear one event by name. `kind` keeps only events whose `data.kind` matches — the way to
|
|
1451
|
+
* listen for `neosh.win.enter` on the sidebar and nothing else.
|
|
1452
|
+
*
|
|
1453
|
+
* The host's own, `from: "neosh"`: `neosh.ready`; `neosh.win.enter` / `neosh.win.leave` /
|
|
1454
|
+
* `neosh.win.open` (a {@link WindowEvent}); `neosh.win.close` (`{ win }`); `neosh.cursor`
|
|
1455
|
+
* (`{ win, row, col }`); `neosh.mode` (`{ mode }`); `neosh.viewport` (`{ win, width, height }`).
|
|
1456
|
+
* Neovim's autocmds, on the same bus a plugin's own events travel.
|
|
1457
|
+
*/
|
|
1458
|
+
on(name: string, cb: (e: { data: unknown; from: string }) => void, opts?: { kind?: string }): Disposable;
|
|
1459
|
+
/** Every event, whatever it is called. For a logger or a debugger, rarely for a feature. */
|
|
1460
|
+
onAny(cb: (e: { name: string; data: unknown; from: string }) => void): Disposable;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* The other computers.
|
|
1465
|
+
*
|
|
1466
|
+
* ASCP — see `docs/ascp/SPEC.md`. Everything here is a *description* of what another machine is
|
|
1467
|
+
* running, or a *request* to it, and never a handle: an agent belongs to the node it was started on
|
|
1468
|
+
* for the whole of its life, because its files, its shell and its credentials are there.
|
|
1469
|
+
*
|
|
1470
|
+
* Empty and harmless when no swarm is configured, which is the default. A plugin that draws remote
|
|
1471
|
+
* agents needs no special case for the single-machine setup — `nodes()` is simply empty.
|
|
1472
|
+
*/
|
|
1473
|
+
/**
|
|
1474
|
+
* What the plan has left, and what the week went on.
|
|
1475
|
+
*
|
|
1476
|
+
* Two questions that look alike and are not. {@link list} is *now* — an opaque fraction of an
|
|
1477
|
+
* allowance the vendor enforces, which is the number that decides whether to start something.
|
|
1478
|
+
* {@link usage} is *history* — tokens and their money-equivalent, read out of the vendor CLIs' own
|
|
1479
|
+
* transcripts, which is the number that explains where the week went. Neither converts into the
|
|
1480
|
+
* other, and a chart that put them on one axis would be inventing an exchange rate.
|
|
1481
|
+
*
|
|
1482
|
+
* Everything here is data. Nothing in this API draws, which is what makes the bundled strip
|
|
1483
|
+
* replaceable by a panel of your own that reads exactly the same calls.
|
|
1484
|
+
*/
|
|
1485
|
+
export interface QuotaApi {
|
|
1486
|
+
/**
|
|
1487
|
+
* The latest snapshot for every instance that has one, freshest observation first.
|
|
1488
|
+
*
|
|
1489
|
+
* Answers from what the workspace kept rather than by asking a vendor, so it costs nothing and is
|
|
1490
|
+
* safe to call on every redraw. `observed_at` is how stale each one is — draw it, because a
|
|
1491
|
+
* percentage with no age on it is one people will trust for longer than they should.
|
|
1492
|
+
*/
|
|
1493
|
+
list(): Promise<QuotaSnapshot[]>;
|
|
1494
|
+
/**
|
|
1495
|
+
* Ask the vendor now, for one instance or for every instance that can be asked.
|
|
1496
|
+
*
|
|
1497
|
+
* Resolves as soon as the request is *made*. A poll is a network round trip or a process spawn,
|
|
1498
|
+
* so a panel that awaited the answer would be a panel that opens late; the answer arrives at
|
|
1499
|
+
* {@link onChange}, the same way an unprompted one does, so there is one code path rather than
|
|
1500
|
+
* two.
|
|
1501
|
+
*/
|
|
1502
|
+
refresh(instance?: string): Promise<void>;
|
|
1503
|
+
/**
|
|
1504
|
+
* Publish a snapshot for an instance your own driver serves.
|
|
1505
|
+
*
|
|
1506
|
+
* The other half of {@link ProviderApi.register}: a provider written as a plugin knows its
|
|
1507
|
+
* vendor's allowance and nothing in the workspace does. Reported this way it is kept, sampled,
|
|
1508
|
+
* broadcast and drawn exactly like a built-in one. Rejects for an instance your plugin did not
|
|
1509
|
+
* register a driver for — a figure anybody could spoof is not one this should draw as fact.
|
|
1510
|
+
*/
|
|
1511
|
+
report(snapshot: QuotaSnapshot): Promise<void>;
|
|
1512
|
+
/**
|
|
1513
|
+
* Every percentage this workspace has seen in a span, so a gauge can be a line.
|
|
1514
|
+
*
|
|
1515
|
+
* `since` and `until` are unix **seconds**, `until` exclusive. Sampled on change, so the points
|
|
1516
|
+
* are unevenly spaced and the gaps are real: a stretch with no samples is a stretch where the
|
|
1517
|
+
* machine was off, and drawing a straight line across it invents usage that did not happen.
|
|
1518
|
+
*/
|
|
1519
|
+
history(opts: { since: number; until: number; instance?: string }): Promise<QuotaSample[]>;
|
|
1520
|
+
/**
|
|
1521
|
+
* Tokens and their money-equivalent over a span, from the vendor CLIs' transcripts.
|
|
1522
|
+
*
|
|
1523
|
+
* Not from this workspace's conversations: a turn you ran in `claude` directly spent the same
|
|
1524
|
+
* allowance, and a history that could not see it would answer a different question. Reads
|
|
1525
|
+
* thousands of files, so this is a call a panel makes when it opens — never one it makes on a
|
|
1526
|
+
* redraw.
|
|
1527
|
+
*
|
|
1528
|
+
* `cost_usd` is what those tokens would cost at API rates. It is not money spent: a subscription
|
|
1529
|
+
* bills separately. Check `fully_priced` before putting a currency symbol in a heading.
|
|
1530
|
+
*/
|
|
1531
|
+
usage(opts: {
|
|
1532
|
+
since: number;
|
|
1533
|
+
until: number;
|
|
1534
|
+
resolution: UsageResolution;
|
|
1535
|
+
/** IANA zone to bucket days in. Defaults to this machine's. */
|
|
1536
|
+
timeZone?: string;
|
|
1537
|
+
}): Promise<UsageHistory>;
|
|
1538
|
+
/**
|
|
1539
|
+
* Any account's allowance changed, whatever moved it: a driver reporting mid-turn, a poll
|
|
1540
|
+
* landing, a plugin publishing its own.
|
|
1541
|
+
*
|
|
1542
|
+
* One thing to listen to instead of knowing which vendors exist — which is what lets a strip draw
|
|
1543
|
+
* a provider that shipped after it did.
|
|
1544
|
+
*/
|
|
1545
|
+
onChange(cb: (snapshot: QuotaSnapshot) => void): Disposable;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
export interface SwarmApi {
|
|
1549
|
+
/** This machine, or `null` when the swarm is not running. */
|
|
1550
|
+
self(): Promise<NodeInfo | null>;
|
|
1551
|
+
/** Every node known, up or down. A node that has gone keeps its agents, marked `up: false`. */
|
|
1552
|
+
nodes(): Promise<SwarmNode[]>;
|
|
1553
|
+
/** Every agent on every reachable node, flattened, each carrying the node it belongs to. */
|
|
1554
|
+
agents(): Promise<SwarmAgent[]>;
|
|
1555
|
+
/**
|
|
1556
|
+
* Which *other* machines have this project.
|
|
1557
|
+
*
|
|
1558
|
+
* The answer to "is this project on more than this computer". Empty when it is only here. Keyed
|
|
1559
|
+
* on {@link ProjectKey}, which is the normalised git remote — the one thing about a checkout that
|
|
1560
|
+
* is the same on every machine that has it.
|
|
1561
|
+
*/
|
|
1562
|
+
hostsOf(project: ProjectKey): Promise<string[]>;
|
|
1563
|
+
/**
|
|
1564
|
+
* Ask a node to do something with one of its agents.
|
|
1565
|
+
*
|
|
1566
|
+
* Settles when that machine answers — every ASCP command is answered exactly once — and rejects
|
|
1567
|
+
* with the owner's reason when it says no. A node may refuse anything; `NodeCapabilities` on its
|
|
1568
|
+
* {@link SwarmNode} says in advance what it is likely to accept, so a menu can grey out a verb
|
|
1569
|
+
* rather than offering one that will bounce.
|
|
1570
|
+
*/
|
|
1571
|
+
command(node: NodeId, session: string, command: AgentCommand): Promise<void>;
|
|
1572
|
+
/**
|
|
1573
|
+
* Watch a remote conversation: its history now, then everything as it happens, delivered to
|
|
1574
|
+
* {@link onStream}.
|
|
1575
|
+
*
|
|
1576
|
+
* Drop it when you stop looking. A subscription is the difference between a quiet swarm and one
|
|
1577
|
+
* where every machine sends every token to every other one.
|
|
1578
|
+
*/
|
|
1579
|
+
subscribe(node: NodeId, session: string): Promise<void>;
|
|
1580
|
+
unsubscribe(node: NodeId, session: string): Promise<void>;
|
|
1581
|
+
/**
|
|
1582
|
+
* Ask what machine is at an address, without joining it.
|
|
1583
|
+
*
|
|
1584
|
+
* The first half of pairing. A node presents its identity to anything that connects — as an SSH
|
|
1585
|
+
* server presents a host key — so what comes back was *proven*, not claimed, which is what makes
|
|
1586
|
+
* it safe to show somebody and ask. Rejects, with the address in the message, when nothing
|
|
1587
|
+
* answers.
|
|
1588
|
+
*/
|
|
1589
|
+
probe(addr: string): Promise<NodeInfo>;
|
|
1590
|
+
/**
|
|
1591
|
+
* Authorise a machine and start connecting to it.
|
|
1592
|
+
*
|
|
1593
|
+
* Immediate: no restart. Written to the state directory rather than to `config.toml`, because an
|
|
1594
|
+
* editor that edits your config file because you pressed a key is one you stop trusting with it.
|
|
1595
|
+
*/
|
|
1596
|
+
pair(node: NodeId, opts?: { name?: string; addr?: string }): Promise<void>;
|
|
1597
|
+
/** Withdraw authorisation and stop connecting. Refuses for a machine your config declared. */
|
|
1598
|
+
unpair(node: NodeId): Promise<void>;
|
|
1599
|
+
/**
|
|
1600
|
+
* Dial a down peer again now, rather than waiting out its retry delay.
|
|
1601
|
+
*
|
|
1602
|
+
* Also what lifts a {@link disconnect}: for a peer that dials *this* machine, being willing to
|
|
1603
|
+
* answer again is the whole of what "reconnect" can mean.
|
|
1604
|
+
*/
|
|
1605
|
+
reconnect(node: NodeId): Promise<void>;
|
|
1606
|
+
/**
|
|
1607
|
+
* Close the connection to a peer and stop dialling it, keeping the pairing.
|
|
1608
|
+
*
|
|
1609
|
+
* Holds until {@link reconnect} or a restart — the peer stays authorised, so this is "leave it
|
|
1610
|
+
* alone for now", and {@link unpair} is the stronger verb. Its `SwarmNode` row goes
|
|
1611
|
+
* `link: down`, with its agents still described.
|
|
1612
|
+
*/
|
|
1613
|
+
disconnect(node: NodeId): Promise<void>;
|
|
1614
|
+
/**
|
|
1615
|
+
* Machines that proved who they are and have not been paired with.
|
|
1616
|
+
*
|
|
1617
|
+
* `dialled` says which question to ask: `true` is "this is what is at that address — add it?",
|
|
1618
|
+
* `false` is "this machine wants to join — allow it?". Same button, different question.
|
|
1619
|
+
*/
|
|
1620
|
+
strangers(): Promise<SwarmStranger[]>;
|
|
1621
|
+
/** A node joined, left, changed what it is running, or asked to join. Redraw. */
|
|
1622
|
+
onChange(cb: () => void): Disposable;
|
|
1623
|
+
onStream(
|
|
1624
|
+
cb: (e: { node: NodeId; session: string; event: StreamEvent }) => void,
|
|
1625
|
+
): Disposable;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
export interface RuntimePathApi {
|
|
1629
|
+
/**
|
|
1630
|
+
* Add a directory to search for plugins.
|
|
1631
|
+
*
|
|
1632
|
+
* Only effective before discovery runs, which means: from `init.ts`. That is the point — your
|
|
1633
|
+
* config is the thing that decides what else loads, exactly as `init.lua` is.
|
|
1634
|
+
*/
|
|
1635
|
+
add(path: string): Promise<void>;
|
|
1636
|
+
list(): Promise<string[]>;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/**
|
|
1640
|
+
* Completing a typed path.
|
|
1641
|
+
*
|
|
1642
|
+
* The runtime has no filesystem — deliberately — and a path field without completion is a path
|
|
1643
|
+
* field you type wrong. Narrow on purpose: directory names, one level, never file contents and
|
|
1644
|
+
* never a recursive walk.
|
|
1645
|
+
*/
|
|
1646
|
+
export interface PathApi {
|
|
1647
|
+
/**
|
|
1648
|
+
* Directories whose path begins with `prefix`, each with a trailing `/` so the answer can go
|
|
1649
|
+
* straight back into the field and be completed again.
|
|
1650
|
+
*
|
|
1651
|
+
* `~` expands against the home directory. A prefix with no `/` completes against the active
|
|
1652
|
+
* conversation's directory, which is what someone typing `src` means.
|
|
1653
|
+
*/
|
|
1654
|
+
complete(prefix: string): Promise<string[]>;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
export interface TimerApi {
|
|
1658
|
+
/**
|
|
1659
|
+
* Run `fn` once, no sooner than `ms` from now.
|
|
1660
|
+
*
|
|
1661
|
+
* The returned `Disposable` cancels it, and **every timer is cancelled when your plugin
|
|
1662
|
+
* unloads** — which the global `setTimeout` cannot promise, because it has no idea who called it.
|
|
1663
|
+
* Prefer this one; reach for the global only when porting code that expects it.
|
|
1664
|
+
*/
|
|
1665
|
+
after(ms: number, fn: () => void): Disposable;
|
|
1666
|
+
/** Run `fn` repeatedly until disposed. */
|
|
1667
|
+
every(ms: number, fn: () => void): Disposable;
|
|
1668
|
+
/**
|
|
1669
|
+
* Coalesce bursts: calling the returned function repeatedly runs `fn` once, `ms` after the last
|
|
1670
|
+
* call. The shape almost every "re-render after the tokens stop" wants.
|
|
1671
|
+
*/
|
|
1672
|
+
debounce<A extends unknown[]>(ms: number, fn: (...args: A) => void): ((...args: A) => void) & Disposable;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
export interface Logger {
|
|
1676
|
+
info(msg: string): void;
|
|
1677
|
+
warn(msg: string): void;
|
|
1678
|
+
error(msg: string): void;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// ---------------------------------------------------------------------------
|
|
1682
|
+
// Implementation
|
|
1683
|
+
// ---------------------------------------------------------------------------
|
|
1684
|
+
|
|
1685
|
+
interface Registered {
|
|
1686
|
+
commands: Map<string, (args: string[], key?: KeyContext, here?: Neosh) => unknown>;
|
|
1687
|
+
tools: Map<string, (input: unknown) => ToolResult | Promise<ToolResult>>;
|
|
1688
|
+
hooks: Map<HookName, (p: HookPayload) => HookOutcome | Promise<HookOutcome>>;
|
|
1689
|
+
providers: Map<string, (req: TurnRequest, emit: (e: ProviderEvent) => void, signal: { cancelled: boolean }) => unknown>;
|
|
1690
|
+
bufferListeners: Map<number, Array<(e: { buf: BufferId; start: number; oldEnd: number; newEnd: number }) => void>>;
|
|
1691
|
+
optionListeners: Array<(e: { name: string; value: OptionValue }) => void>;
|
|
1692
|
+
/// The protocol version this plugin was handed, kept so a namespace can be rebuilt for a
|
|
1693
|
+
/// view long after `__createContext` returned.
|
|
1694
|
+
version: number;
|
|
1695
|
+
sessionListeners: Array<(e: { session: SessionId; view: ViewId }) => void>;
|
|
1696
|
+
viewOpenListeners: Array<(view: ViewId) => void>;
|
|
1697
|
+
viewCloseListeners: Array<(view: ViewId) => void>;
|
|
1698
|
+
selectionListeners: Array<(e: { selection: ModelSelection }) => void>;
|
|
1699
|
+
composerListeners: Array<(e: { text: string }) => void>;
|
|
1700
|
+
activityListeners: Array<(e: { session: SessionId; turn: string; activity: Activity }) => void>;
|
|
1701
|
+
varListeners: Array<(e: { scope: VarScope; key: string; value: unknown }) => void>;
|
|
1702
|
+
swarmListeners: Array<() => void>;
|
|
1703
|
+
quotaListeners: Array<(snapshot: QuotaSnapshot) => void>;
|
|
1704
|
+
swarmStreamListeners: Array<
|
|
1705
|
+
(e: { node: NodeId; session: string; event: StreamEvent }) => void
|
|
1706
|
+
>;
|
|
1707
|
+
contributionListeners: Array<(e: { point: string }) => void>;
|
|
1708
|
+
highlightListeners: Array<(e: { names: string[] }) => void>;
|
|
1709
|
+
focusListeners: Array<(e: { win: WindowId | null }) => void>;
|
|
1710
|
+
viewListeners: Array<() => void>;
|
|
1711
|
+
shutdownListeners: Array<() => void>;
|
|
1712
|
+
/**
|
|
1713
|
+
* Listeners by event name, plus `null` for the ones that asked for everything.
|
|
1714
|
+
*
|
|
1715
|
+
* Filtered here rather than in the host on purpose: a subscription table on the far side of the
|
|
1716
|
+
* boundary is one the host can only ever guess is current, and getting it wrong means a plugin
|
|
1717
|
+
* silently stops hearing things. Broadcasting everything and matching a string is cheap.
|
|
1718
|
+
*/
|
|
1719
|
+
eventListeners: Map<string | null, Array<(e: { name: string; data: unknown; from: string }) => void>>;
|
|
1720
|
+
options: Set<string>;
|
|
1721
|
+
/// Timer handles this plugin armed, cleared on unload.
|
|
1722
|
+
timers: Set<number>;
|
|
1723
|
+
agentListeners: { [K in "turnStart" | "token" | "thinking" | "turnEnd" | "toolStart" | "toolEnd"]: Array<(e: never) => void> };
|
|
1724
|
+
streams: Map<string, { cancelled: boolean }>;
|
|
1725
|
+
subscriptions: Disposable[];
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
const plugins = new Map<string, Registered>();
|
|
1729
|
+
|
|
1730
|
+
function reg(plugin: string): Registered {
|
|
1731
|
+
let r = plugins.get(plugin);
|
|
1732
|
+
if (!r) {
|
|
1733
|
+
r = {
|
|
1734
|
+
commands: new Map(),
|
|
1735
|
+
tools: new Map(),
|
|
1736
|
+
hooks: new Map(),
|
|
1737
|
+
providers: new Map(),
|
|
1738
|
+
bufferListeners: new Map(),
|
|
1739
|
+
optionListeners: [],
|
|
1740
|
+
version: 0,
|
|
1741
|
+
sessionListeners: [],
|
|
1742
|
+
viewOpenListeners: [],
|
|
1743
|
+
viewCloseListeners: [],
|
|
1744
|
+
selectionListeners: [],
|
|
1745
|
+
composerListeners: [],
|
|
1746
|
+
activityListeners: [],
|
|
1747
|
+
varListeners: [],
|
|
1748
|
+
swarmListeners: [],
|
|
1749
|
+
quotaListeners: [],
|
|
1750
|
+
swarmStreamListeners: [],
|
|
1751
|
+
contributionListeners: [],
|
|
1752
|
+
highlightListeners: [],
|
|
1753
|
+
focusListeners: [],
|
|
1754
|
+
viewListeners: [],
|
|
1755
|
+
shutdownListeners: [],
|
|
1756
|
+
eventListeners: new Map(),
|
|
1757
|
+
options: new Set(),
|
|
1758
|
+
timers: new Set(),
|
|
1759
|
+
agentListeners: { turnStart: [], token: [], thinking: [], turnEnd: [], toolStart: [], toolEnd: [] },
|
|
1760
|
+
streams: new Map(),
|
|
1761
|
+
subscriptions: [],
|
|
1762
|
+
};
|
|
1763
|
+
plugins.set(plugin, r);
|
|
1764
|
+
}
|
|
1765
|
+
return r;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/** A listener on one event name, or on all of them when `name` is `null`. */
|
|
1769
|
+
function eventListener(
|
|
1770
|
+
r: Registered,
|
|
1771
|
+
name: string | null,
|
|
1772
|
+
cb: (e: { name: string; data: unknown; from: string }) => void,
|
|
1773
|
+
): Disposable {
|
|
1774
|
+
const list = r.eventListeners.get(name) ?? [];
|
|
1775
|
+
r.eventListeners.set(name, list);
|
|
1776
|
+
return listener(list, cb);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function listener<T>(list: Array<(e: T) => void>, cb: (e: T) => void): Disposable {
|
|
1780
|
+
list.push(cb as (e: never) => void as (e: T) => void);
|
|
1781
|
+
return {
|
|
1782
|
+
dispose() {
|
|
1783
|
+
const i = list.indexOf(cb);
|
|
1784
|
+
if (i >= 0) list.splice(i, 1);
|
|
1785
|
+
},
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function floatConfig(o: FloatOptions = {}): FloatConfig {
|
|
1790
|
+
return {
|
|
1791
|
+
anchor: o.anchor ?? { kind: "screen" },
|
|
1792
|
+
offset: o.offset ?? { row: 0, col: 0 },
|
|
1793
|
+
width: o.width ?? { kind: "auto" },
|
|
1794
|
+
height: o.height ?? { kind: "auto" },
|
|
1795
|
+
z: o.z ?? 100,
|
|
1796
|
+
border: o.border ?? "rounded",
|
|
1797
|
+
border_hl: o.borderHl ?? null,
|
|
1798
|
+
title: o.title ?? null,
|
|
1799
|
+
close_on_blur: o.closeOnBlur ?? false,
|
|
1800
|
+
focusable: o.focusable ?? true,
|
|
1801
|
+
modal: o.modal ?? false,
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function markOpts(o: MarkOptions = {}): ExtmarkOpts {
|
|
1806
|
+
return {
|
|
1807
|
+
end_col: o.endCol ?? null,
|
|
1808
|
+
hl_group: o.hlGroup ?? null,
|
|
1809
|
+
line_hl_group: o.lineHlGroup ?? null,
|
|
1810
|
+
virt_text: (o.virtText ?? []).map((v) => ({ text: v.text, hl_group: v.hlGroup ?? null })),
|
|
1811
|
+
virt_text_pos: o.virtTextPos ?? "eol",
|
|
1812
|
+
on_delete: o.onDelete ?? "clamp",
|
|
1813
|
+
priority: o.priority ?? 0,
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* Build the whole `neosh` namespace over one pair of call functions.
|
|
1819
|
+
*
|
|
1820
|
+
* Called once per plugin with calls that say nothing about where they land, which is the ordinary
|
|
1821
|
+
* `neosh`: the host works out which terminal a window belongs in, and for anything done in answer
|
|
1822
|
+
* to a key that is exact. And again, per view, with calls that name it — which is what a command
|
|
1823
|
+
* handler is handed as its third argument. Inside a handler, `here.win.open(...)` is a panel in
|
|
1824
|
+
* the terminal the key was pressed in, and every other call on `here` is the call it always was.
|
|
1825
|
+
*/
|
|
1826
|
+
function build(
|
|
1827
|
+
plugin: string,
|
|
1828
|
+
version: number,
|
|
1829
|
+
r: ReturnType<typeof reg>,
|
|
1830
|
+
view: ViewId | null,
|
|
1831
|
+
): Neosh {
|
|
1832
|
+
// On the envelope rather than in each call: a call comes *from* a terminal, exactly as a key
|
|
1833
|
+
// press does, and it is the same fact for all of them. It matters for far more than opening a
|
|
1834
|
+
// window — which conversation is on screen, what is in the composer, what `session.list` marks
|
|
1835
|
+
// as the one you are in are all questions with an answer per terminal.
|
|
1836
|
+
const c = (x: ApiCall) => call(plugin, x, view ?? undefined);
|
|
1837
|
+
const n = (x: ApiCall) => notify(plugin, x, view ?? undefined);
|
|
1838
|
+
|
|
1839
|
+
const api: Neosh = {
|
|
1840
|
+
version,
|
|
1841
|
+
notify(message, level) {
|
|
1842
|
+
n({ call: "notify", level: level ?? "info", message, kind: "reply" });
|
|
1843
|
+
},
|
|
1844
|
+
progress(key, message) {
|
|
1845
|
+
n({ call: "notify", level: "info", message, kind: "progress", key });
|
|
1846
|
+
},
|
|
1847
|
+
done(key) {
|
|
1848
|
+
n({ call: "notify_done", key });
|
|
1849
|
+
},
|
|
1850
|
+
async alert(title, message, opts) {
|
|
1851
|
+
// Awaited rather than fired and forgotten, unlike the three above: this is the one that can
|
|
1852
|
+
// be refused — for want of `notify` in the manifest — and a capability error nobody sees is
|
|
1853
|
+
// a plugin that silently never notifies.
|
|
1854
|
+
await c({
|
|
1855
|
+
call: "alert",
|
|
1856
|
+
level: opts?.level ?? "info",
|
|
1857
|
+
title,
|
|
1858
|
+
message,
|
|
1859
|
+
session: opts?.session,
|
|
1860
|
+
});
|
|
1861
|
+
},
|
|
1862
|
+
async permit(capability) {
|
|
1863
|
+
return expect(await c({ call: "permission_check", capability }), "permission").decision;
|
|
1864
|
+
},
|
|
1865
|
+
onViewAttached(cb) {
|
|
1866
|
+
return listener(r.viewListeners, cb);
|
|
1867
|
+
},
|
|
1868
|
+
onShutdown(cb) {
|
|
1869
|
+
return listener(r.shutdownListeners, cb);
|
|
1870
|
+
},
|
|
1871
|
+
async ask(questions) {
|
|
1872
|
+
return expect(await c({ call: "ask_user", questions }), "answers").answers ?? null;
|
|
1873
|
+
},
|
|
1874
|
+
opt: {
|
|
1875
|
+
async declare(spec) {
|
|
1876
|
+
await c({ call: "opt_declare", spec });
|
|
1877
|
+
r.options.add(spec.name);
|
|
1878
|
+
return { dispose: () => r.options.delete(spec.name) };
|
|
1879
|
+
},
|
|
1880
|
+
async get<T = OptionValue>(name: string): Promise<T> {
|
|
1881
|
+
const entry = expect(await c({ call: "opt_get", name }), "option").entry;
|
|
1882
|
+
if (!entry) throw new Error(`option ${name} is not declared`);
|
|
1883
|
+
return entry.value as T;
|
|
1884
|
+
},
|
|
1885
|
+
async entry(name) {
|
|
1886
|
+
return expect(await c({ call: "opt_get", name }), "option").entry;
|
|
1887
|
+
},
|
|
1888
|
+
async set(name, value) {
|
|
1889
|
+
await c({ call: "opt_set", name, value });
|
|
1890
|
+
},
|
|
1891
|
+
async reset(name) {
|
|
1892
|
+
await c({ call: "opt_reset", name });
|
|
1893
|
+
},
|
|
1894
|
+
async all() {
|
|
1895
|
+
return expect(await c({ call: "opt_all" }), "options").options;
|
|
1896
|
+
},
|
|
1897
|
+
onChange(cb) {
|
|
1898
|
+
return listener(r.optionListeners, cb);
|
|
1899
|
+
},
|
|
1900
|
+
},
|
|
1901
|
+
edit: {
|
|
1902
|
+
async move(win, motion, opts) {
|
|
1903
|
+
await c({ call: "win_motion", win, motion, select: opts?.select ?? false });
|
|
1904
|
+
},
|
|
1905
|
+
async apply(win, edit) {
|
|
1906
|
+
await c({ call: "win_edit", win, edit });
|
|
1907
|
+
},
|
|
1908
|
+
async select(win, on) {
|
|
1909
|
+
await c({ call: "win_select", win, on });
|
|
1910
|
+
},
|
|
1911
|
+
async selectShape(win, shape) {
|
|
1912
|
+
await c({ call: "win_select_shape", win, shape });
|
|
1913
|
+
},
|
|
1914
|
+
async cursorShape(win, shape) {
|
|
1915
|
+
await c({ call: "win_cursor_shape", win, shape });
|
|
1916
|
+
},
|
|
1917
|
+
async selection(win) {
|
|
1918
|
+
return expect(await c({ call: "win_selection", win }), "text").text;
|
|
1919
|
+
},
|
|
1920
|
+
async copy(text) {
|
|
1921
|
+
await c({ call: "clipboard_write", text });
|
|
1922
|
+
},
|
|
1923
|
+
},
|
|
1924
|
+
path: {
|
|
1925
|
+
async complete(prefix) {
|
|
1926
|
+
return expect(await c({ call: "path_complete", prefix }), "paths").paths;
|
|
1927
|
+
},
|
|
1928
|
+
},
|
|
1929
|
+
state: {
|
|
1930
|
+
async get<T = unknown>(key: string): Promise<T | null> {
|
|
1931
|
+
const value = expect(await c({ call: "state_get", key }), "json").value;
|
|
1932
|
+
return (value ?? null) as T | null;
|
|
1933
|
+
},
|
|
1934
|
+
async set(key, value) {
|
|
1935
|
+
await c({ call: "state_set", key, value });
|
|
1936
|
+
},
|
|
1937
|
+
async remove(key) {
|
|
1938
|
+
await c({ call: "state_delete", key });
|
|
1939
|
+
},
|
|
1940
|
+
},
|
|
1941
|
+
vars: {
|
|
1942
|
+
async get<T = unknown>(scope: VarScope, key: string): Promise<T | null> {
|
|
1943
|
+
const value = expect(await c({ call: "var_get", scope, key }), "json").value;
|
|
1944
|
+
return (value ?? null) as T | null;
|
|
1945
|
+
},
|
|
1946
|
+
async set(scope, key, value) {
|
|
1947
|
+
await c({ call: "var_set", scope, key, value });
|
|
1948
|
+
},
|
|
1949
|
+
async remove(scope, key) {
|
|
1950
|
+
await c({ call: "var_delete", scope, key });
|
|
1951
|
+
},
|
|
1952
|
+
async all(scope) {
|
|
1953
|
+
return expect(await c({ call: "var_all", scope }), "vars").vars;
|
|
1954
|
+
},
|
|
1955
|
+
onChange(cb) {
|
|
1956
|
+
return listener(r.varListeners, cb);
|
|
1957
|
+
},
|
|
1958
|
+
},
|
|
1959
|
+
ext: {
|
|
1960
|
+
async contribute(point, id, item, opts) {
|
|
1961
|
+
await c({ call: "ext_contribute", point, id, item, priority: opts?.priority ?? 0 });
|
|
1962
|
+
return {
|
|
1963
|
+
dispose() {
|
|
1964
|
+
void c({ call: "ext_remove", point, id });
|
|
1965
|
+
},
|
|
1966
|
+
};
|
|
1967
|
+
},
|
|
1968
|
+
async remove(point, id) {
|
|
1969
|
+
await c({ call: "ext_remove", point, id });
|
|
1970
|
+
},
|
|
1971
|
+
async list<T = unknown>(point: string) {
|
|
1972
|
+
const got = expect(await c({ call: "ext_list", point }), "contributions").contributions;
|
|
1973
|
+
return got as Array<Contribution & { item: T }>;
|
|
1974
|
+
},
|
|
1975
|
+
onChange(cb) {
|
|
1976
|
+
return listener(r.contributionListeners, cb);
|
|
1977
|
+
},
|
|
1978
|
+
async points() {
|
|
1979
|
+
return expect(await c({ call: "ext_points" }), "points").points;
|
|
1980
|
+
},
|
|
1981
|
+
async plugins() {
|
|
1982
|
+
return expect(await c({ call: "plugin_list" }), "plugins").plugins;
|
|
1983
|
+
},
|
|
1984
|
+
},
|
|
1985
|
+
swarm: {
|
|
1986
|
+
async self() {
|
|
1987
|
+
return expect(await c({ call: "swarm_self" }), "swarm_self").node ?? null;
|
|
1988
|
+
},
|
|
1989
|
+
async nodes() {
|
|
1990
|
+
return expect(await c({ call: "swarm_nodes" }), "swarm_nodes").nodes;
|
|
1991
|
+
},
|
|
1992
|
+
async agents() {
|
|
1993
|
+
return expect(await c({ call: "swarm_agents" }), "swarm_agents").agents;
|
|
1994
|
+
},
|
|
1995
|
+
async probe(addr) {
|
|
1996
|
+
const found = expect(await c({ call: "swarm_probe", addr }), "swarm_self").node;
|
|
1997
|
+
if (!found) throw new NeoshError({ kind: "not_found", what: addr });
|
|
1998
|
+
return found;
|
|
1999
|
+
},
|
|
2000
|
+
async pair(node, opts) {
|
|
2001
|
+
await c({
|
|
2002
|
+
call: "swarm_pair",
|
|
2003
|
+
node,
|
|
2004
|
+
name: opts?.name ?? "",
|
|
2005
|
+
addr: opts?.addr ?? null,
|
|
2006
|
+
});
|
|
2007
|
+
},
|
|
2008
|
+
async unpair(node) {
|
|
2009
|
+
await c({ call: "swarm_unpair", node });
|
|
2010
|
+
},
|
|
2011
|
+
async reconnect(node) {
|
|
2012
|
+
await c({ call: "swarm_reconnect", node });
|
|
2013
|
+
},
|
|
2014
|
+
async disconnect(node) {
|
|
2015
|
+
await c({ call: "swarm_disconnect", node });
|
|
2016
|
+
},
|
|
2017
|
+
async strangers() {
|
|
2018
|
+
return expect(await c({ call: "swarm_strangers" }), "swarm_strangers").strangers;
|
|
2019
|
+
},
|
|
2020
|
+
async hostsOf(project) {
|
|
2021
|
+
return expect(await c({ call: "swarm_hosts_of", project }), "names").names;
|
|
2022
|
+
},
|
|
2023
|
+
async command(node, session, command) {
|
|
2024
|
+
await c({ call: "swarm_command", node, session, command });
|
|
2025
|
+
},
|
|
2026
|
+
async subscribe(node, session) {
|
|
2027
|
+
await c({ call: "swarm_subscribe", node, session });
|
|
2028
|
+
},
|
|
2029
|
+
async unsubscribe(node, session) {
|
|
2030
|
+
await c({ call: "swarm_unsubscribe", node, session });
|
|
2031
|
+
},
|
|
2032
|
+
onChange(cb) {
|
|
2033
|
+
return listener(r.swarmListeners, cb);
|
|
2034
|
+
},
|
|
2035
|
+
onStream(cb) {
|
|
2036
|
+
return listener(r.swarmStreamListeners, cb);
|
|
2037
|
+
},
|
|
2038
|
+
},
|
|
2039
|
+
quota: {
|
|
2040
|
+
async list() {
|
|
2041
|
+
return expect(await c({ call: "quota_list" }), "quotas").quotas;
|
|
2042
|
+
},
|
|
2043
|
+
async refresh(instance) {
|
|
2044
|
+
await c({ call: "quota_refresh", instance: instance ?? null });
|
|
2045
|
+
},
|
|
2046
|
+
async report(snapshot) {
|
|
2047
|
+
await c({ call: "quota_report", snapshot });
|
|
2048
|
+
},
|
|
2049
|
+
async history(opts) {
|
|
2050
|
+
const got = await c({
|
|
2051
|
+
call: "quota_history",
|
|
2052
|
+
instance: opts.instance ?? null,
|
|
2053
|
+
since: opts.since,
|
|
2054
|
+
until: opts.until,
|
|
2055
|
+
});
|
|
2056
|
+
return expect(got, "quota_history").samples;
|
|
2057
|
+
},
|
|
2058
|
+
async usage(opts) {
|
|
2059
|
+
const got = await c({
|
|
2060
|
+
call: "usage_history",
|
|
2061
|
+
since: opts.since,
|
|
2062
|
+
until: opts.until,
|
|
2063
|
+
resolution: opts.resolution,
|
|
2064
|
+
time_zone: opts.timeZone ?? null,
|
|
2065
|
+
});
|
|
2066
|
+
return expect(got, "usage_history").history;
|
|
2067
|
+
},
|
|
2068
|
+
onChange(cb) {
|
|
2069
|
+
return listener(r.quotaListeners, cb);
|
|
2070
|
+
},
|
|
2071
|
+
},
|
|
2072
|
+
event: {
|
|
2073
|
+
async emit(name, data) {
|
|
2074
|
+
await c({ call: "event_emit", name, data: data ?? null });
|
|
2075
|
+
},
|
|
2076
|
+
on(name, cb, opts) {
|
|
2077
|
+
const kind = opts?.kind;
|
|
2078
|
+
return eventListener(r, name, (e) => {
|
|
2079
|
+
if (kind !== undefined) {
|
|
2080
|
+
const d = e.data as { kind?: unknown } | null | undefined;
|
|
2081
|
+
if (d?.kind !== kind) return;
|
|
2082
|
+
}
|
|
2083
|
+
cb({ data: e.data, from: e.from });
|
|
2084
|
+
});
|
|
2085
|
+
},
|
|
2086
|
+
onAny(cb) {
|
|
2087
|
+
return eventListener(r, null, cb);
|
|
2088
|
+
},
|
|
2089
|
+
},
|
|
2090
|
+
timer: {
|
|
2091
|
+
after(ms, fn) {
|
|
2092
|
+
const id = setTimeout(() => {
|
|
2093
|
+
r.timers.delete(id);
|
|
2094
|
+
fn();
|
|
2095
|
+
}, ms);
|
|
2096
|
+
r.timers.add(id);
|
|
2097
|
+
return {
|
|
2098
|
+
dispose() {
|
|
2099
|
+
clearTimeout(id);
|
|
2100
|
+
r.timers.delete(id);
|
|
2101
|
+
},
|
|
2102
|
+
};
|
|
2103
|
+
},
|
|
2104
|
+
every(ms, fn) {
|
|
2105
|
+
const id = setInterval(fn, ms);
|
|
2106
|
+
r.timers.add(id);
|
|
2107
|
+
return {
|
|
2108
|
+
dispose() {
|
|
2109
|
+
clearInterval(id);
|
|
2110
|
+
r.timers.delete(id);
|
|
2111
|
+
},
|
|
2112
|
+
};
|
|
2113
|
+
},
|
|
2114
|
+
debounce<A extends unknown[]>(ms: number, fn: (...args: A) => void) {
|
|
2115
|
+
let pending: number | undefined;
|
|
2116
|
+
const run = (...args: A) => {
|
|
2117
|
+
if (pending !== undefined) {
|
|
2118
|
+
clearTimeout(pending);
|
|
2119
|
+
r.timers.delete(pending);
|
|
2120
|
+
}
|
|
2121
|
+
const id = setTimeout(() => {
|
|
2122
|
+
pending = undefined;
|
|
2123
|
+
// Prune here too, not only on the re-arm and dispose paths: a debounce that settles is
|
|
2124
|
+
// the *normal* outcome, and leaving its handle behind grows the set once per burst for
|
|
2125
|
+
// the life of the plugin.
|
|
2126
|
+
r.timers.delete(id);
|
|
2127
|
+
fn(...args);
|
|
2128
|
+
}, ms);
|
|
2129
|
+
pending = id;
|
|
2130
|
+
r.timers.add(id);
|
|
2131
|
+
};
|
|
2132
|
+
run.dispose = () => {
|
|
2133
|
+
if (pending !== undefined) {
|
|
2134
|
+
clearTimeout(pending);
|
|
2135
|
+
r.timers.delete(pending);
|
|
2136
|
+
pending = undefined;
|
|
2137
|
+
}
|
|
2138
|
+
};
|
|
2139
|
+
return run;
|
|
2140
|
+
},
|
|
2141
|
+
},
|
|
2142
|
+
rtp: {
|
|
2143
|
+
async add(path) {
|
|
2144
|
+
await c({ call: "rtp_add", path });
|
|
2145
|
+
},
|
|
2146
|
+
async list() {
|
|
2147
|
+
return expect(await c({ call: "rtp_list" }), "paths").paths;
|
|
2148
|
+
},
|
|
2149
|
+
},
|
|
2150
|
+
log: {
|
|
2151
|
+
info: (message) => n({ call: "log", level: "info", message }),
|
|
2152
|
+
warn: (message) => n({ call: "log", level: "warn", message }),
|
|
2153
|
+
error: (message) => n({ call: "log", level: "error", message }),
|
|
2154
|
+
},
|
|
2155
|
+
buf: {
|
|
2156
|
+
async create(opts) {
|
|
2157
|
+
return expect(await c({
|
|
2158
|
+
call: "buf_create",
|
|
2159
|
+
name: opts?.name ?? null,
|
|
2160
|
+
scratch: opts?.scratch ?? false,
|
|
2161
|
+
kind: opts?.kind ?? null,
|
|
2162
|
+
}), "buf").buf;
|
|
2163
|
+
},
|
|
2164
|
+
async lineCount(buf) {
|
|
2165
|
+
return expect(await c({ call: "buf_line_count", buf }), "count").n;
|
|
2166
|
+
},
|
|
2167
|
+
async getLines(buf, start, end) {
|
|
2168
|
+
return expect(await c({ call: "buf_get_lines", buf, start, end }), "lines").lines;
|
|
2169
|
+
},
|
|
2170
|
+
async setLines(buf, start, end, lines) {
|
|
2171
|
+
await c({ call: "buf_set_lines", buf, start, end, lines });
|
|
2172
|
+
},
|
|
2173
|
+
async render(buf, ns, start, end, rows) {
|
|
2174
|
+
await c({
|
|
2175
|
+
call: "buf_render",
|
|
2176
|
+
buf,
|
|
2177
|
+
ns,
|
|
2178
|
+
start,
|
|
2179
|
+
end,
|
|
2180
|
+
lines: rows.map((r) => ({
|
|
2181
|
+
text: r.text,
|
|
2182
|
+
marks: (r.marks ?? []).map((m) => ({ col: m.col, ...markOpts(m.opts) })),
|
|
2183
|
+
})),
|
|
2184
|
+
});
|
|
2185
|
+
},
|
|
2186
|
+
async appendText(buf, text) {
|
|
2187
|
+
await c({ call: "buf_append_text", buf, text });
|
|
2188
|
+
},
|
|
2189
|
+
async setName(buf, name) {
|
|
2190
|
+
await c({ call: "buf_set_name", buf, name });
|
|
2191
|
+
},
|
|
2192
|
+
async setKind(buf, kind) {
|
|
2193
|
+
await c({ call: "buf_set_kind", buf, kind });
|
|
2194
|
+
},
|
|
2195
|
+
async kind(buf) {
|
|
2196
|
+
return expect(await c({ call: "buf_get_kind", buf }), "maybe_text").text ?? null;
|
|
2197
|
+
},
|
|
2198
|
+
async onChange(buf, cb) {
|
|
2199
|
+
await c({ call: "buf_attach", buf });
|
|
2200
|
+
const list = r.bufferListeners.get(buf) ?? [];
|
|
2201
|
+
r.bufferListeners.set(buf, list);
|
|
2202
|
+
return listener(list, cb);
|
|
2203
|
+
},
|
|
2204
|
+
},
|
|
2205
|
+
win: {
|
|
2206
|
+
async open(buf, dock, opts) {
|
|
2207
|
+
const layout: WindowLayout = {
|
|
2208
|
+
kind: "docked",
|
|
2209
|
+
dock,
|
|
2210
|
+
size: opts?.size ?? null,
|
|
2211
|
+
gravity: opts?.gravity ?? "start",
|
|
2212
|
+
wrap: opts?.wrap ?? null,
|
|
2213
|
+
};
|
|
2214
|
+
return expect(await c({ call: "win_open", buf, layout }), "win").win;
|
|
2215
|
+
},
|
|
2216
|
+
async close(win) {
|
|
2217
|
+
await c({ call: "win_close", win });
|
|
2218
|
+
},
|
|
2219
|
+
async resize(win, size) {
|
|
2220
|
+
await c({ call: "win_resize", win, size });
|
|
2221
|
+
},
|
|
2222
|
+
async setBuf(win, buf) {
|
|
2223
|
+
await c({ call: "win_set_buf", win, buf });
|
|
2224
|
+
},
|
|
2225
|
+
async cursor(win) {
|
|
2226
|
+
const v = expect(await c({ call: "win_get_cursor", win }), "cursor");
|
|
2227
|
+
return { row: v.row, col: v.col };
|
|
2228
|
+
},
|
|
2229
|
+
async setCursor(win, row, col) {
|
|
2230
|
+
await c({ call: "win_set_cursor", win, row, col });
|
|
2231
|
+
},
|
|
2232
|
+
async scrollTo(win, topLine) {
|
|
2233
|
+
await c({ call: "win_scroll_to", win, top_line: topLine });
|
|
2234
|
+
},
|
|
2235
|
+
async viewport(win) {
|
|
2236
|
+
return expect(await c({ call: "win_get_viewport", win }), "viewport").viewport ?? null;
|
|
2237
|
+
},
|
|
2238
|
+
async list() {
|
|
2239
|
+
return expect(await c({ call: "win_list" }), "windows").windows;
|
|
2240
|
+
},
|
|
2241
|
+
async ofKind(kind) {
|
|
2242
|
+
const windows = expect(await c({ call: "win_list" }), "windows").windows;
|
|
2243
|
+
return windows.filter((w) => w.kind === kind);
|
|
2244
|
+
},
|
|
2245
|
+
async setHighlights(target, map) {
|
|
2246
|
+
const t: HlTarget = "win" in target
|
|
2247
|
+
? { kind: "window", win: target.win }
|
|
2248
|
+
: { kind: "kind", name: target.kind };
|
|
2249
|
+
await c({ call: "win_set_highlights", target: t, map });
|
|
2250
|
+
},
|
|
2251
|
+
},
|
|
2252
|
+
float: {
|
|
2253
|
+
async open(buf, opts) {
|
|
2254
|
+
return expect(await c({ call: "float_open", buf, config: floatConfig(opts) }), "win").win;
|
|
2255
|
+
},
|
|
2256
|
+
async configure(win, opts) {
|
|
2257
|
+
await c({ call: "float_configure", win, config: floatConfig(opts) });
|
|
2258
|
+
},
|
|
2259
|
+
async close(win) {
|
|
2260
|
+
await c({ call: "win_close", win });
|
|
2261
|
+
},
|
|
2262
|
+
},
|
|
2263
|
+
ns: {
|
|
2264
|
+
async create(name) {
|
|
2265
|
+
return expect(await c({ call: "ns_create", name }), "ns").ns;
|
|
2266
|
+
},
|
|
2267
|
+
async mark(ns, buf, row, col, opts) {
|
|
2268
|
+
return expect(await c({ call: "mark_set", ns, buf, row, col, opts: markOpts(opts) }), "mark").id;
|
|
2269
|
+
},
|
|
2270
|
+
async getMark(ns, buf, id) {
|
|
2271
|
+
return expect(await c({ call: "mark_get", ns, buf, id }), "mark_info").info;
|
|
2272
|
+
},
|
|
2273
|
+
async allMarks(ns, buf) {
|
|
2274
|
+
return expect(await c({ call: "mark_all", ns, buf }), "marks").marks;
|
|
2275
|
+
},
|
|
2276
|
+
async delMark(ns, buf, id) {
|
|
2277
|
+
await c({ call: "mark_del", ns, buf, id });
|
|
2278
|
+
},
|
|
2279
|
+
async clear(ns, buf, start, end) {
|
|
2280
|
+
await c({ call: "mark_clear", ns, buf, start: start ?? null, end: end ?? null });
|
|
2281
|
+
},
|
|
2282
|
+
},
|
|
2283
|
+
hl: {
|
|
2284
|
+
async define(name, def, opts) {
|
|
2285
|
+
const d: HighlightDef =
|
|
2286
|
+
"link" in def ? { kind: "link", to: def.link } : { kind: "spec", spec: def };
|
|
2287
|
+
await c({ call: "hl_define", name, def: d, default: opts?.default ?? false });
|
|
2288
|
+
},
|
|
2289
|
+
async get(name) {
|
|
2290
|
+
const v = expect(await c({ call: "hl_get", name }), "highlight");
|
|
2291
|
+
return { def: v.def ?? null, resolved: v.resolved ?? null };
|
|
2292
|
+
},
|
|
2293
|
+
async list() {
|
|
2294
|
+
return expect(await c({ call: "hl_list" }), "highlights").groups;
|
|
2295
|
+
},
|
|
2296
|
+
async reset(name) {
|
|
2297
|
+
await c({ call: "hl_reset", name });
|
|
2298
|
+
},
|
|
2299
|
+
onChange(cb) {
|
|
2300
|
+
return listener(r.highlightListeners, cb);
|
|
2301
|
+
},
|
|
2302
|
+
},
|
|
2303
|
+
ui: {
|
|
2304
|
+
async claim(win, rect) {
|
|
2305
|
+
return expect(await c({ call: "surface_claim", win, rect }), "surface").surface;
|
|
2306
|
+
},
|
|
2307
|
+
async put(surface, cells) {
|
|
2308
|
+
await c({ call: "surface_put", surface, cells });
|
|
2309
|
+
},
|
|
2310
|
+
async release(surface) {
|
|
2311
|
+
await c({ call: "surface_release", surface });
|
|
2312
|
+
},
|
|
2313
|
+
},
|
|
2314
|
+
cmd: {
|
|
2315
|
+
async register(name, fn, opts) {
|
|
2316
|
+
await c({ call: "cmd_register", name, desc: opts?.desc ?? null });
|
|
2317
|
+
r.commands.set(name, fn);
|
|
2318
|
+
return {
|
|
2319
|
+
dispose: () => {
|
|
2320
|
+
r.commands.delete(name);
|
|
2321
|
+
n({ call: "cmd_unregister", name });
|
|
2322
|
+
},
|
|
2323
|
+
};
|
|
2324
|
+
},
|
|
2325
|
+
async exec(name, args) {
|
|
2326
|
+
await c({ call: "cmd_exec", name, args: args ?? [] });
|
|
2327
|
+
},
|
|
2328
|
+
async call(name, args) {
|
|
2329
|
+
return expect(await c({ call: "cmd_call", name, args: args ?? [] }), "json").value as never;
|
|
2330
|
+
},
|
|
2331
|
+
async list() {
|
|
2332
|
+
return expect(await c({ call: "cmd_list" }), "commands").commands;
|
|
2333
|
+
},
|
|
2334
|
+
},
|
|
2335
|
+
keymap: {
|
|
2336
|
+
async set(mode, lhs, command, opts) {
|
|
2337
|
+
await c({ call: "keymap_set", mode, lhs, command, scope: opts?.scope ?? null, desc: opts?.desc ?? null });
|
|
2338
|
+
},
|
|
2339
|
+
async del(mode, lhs, scope) {
|
|
2340
|
+
await c({ call: "keymap_del", mode, lhs, scope: scope ?? null });
|
|
2341
|
+
},
|
|
2342
|
+
async list(mode) {
|
|
2343
|
+
return expect(await c({ call: "keymap_list", mode: mode ?? null }), "keymaps").keymaps;
|
|
2344
|
+
},
|
|
2345
|
+
async capture(win, command) {
|
|
2346
|
+
await c({ call: "keymap_capture", win, command });
|
|
2347
|
+
return { dispose: () => n({ call: "keymap_release", win }) };
|
|
2348
|
+
},
|
|
2349
|
+
},
|
|
2350
|
+
focus: {
|
|
2351
|
+
async push(win) {
|
|
2352
|
+
await c({ call: "focus_push", win });
|
|
2353
|
+
},
|
|
2354
|
+
async pop() {
|
|
2355
|
+
await c({ call: "focus_pop" });
|
|
2356
|
+
},
|
|
2357
|
+
async current() {
|
|
2358
|
+
return expect(await c({ call: "focus_current" }), "focused_win").win;
|
|
2359
|
+
},
|
|
2360
|
+
onChange(cb) {
|
|
2361
|
+
return listener(r.focusListeners, cb);
|
|
2362
|
+
},
|
|
2363
|
+
},
|
|
2364
|
+
agent: {
|
|
2365
|
+
async send(text, opts) {
|
|
2366
|
+
await c({ call: "agent_send", text, images: opts?.images ?? [] });
|
|
2367
|
+
},
|
|
2368
|
+
async cancel() {
|
|
2369
|
+
await c({ call: "agent_cancel" });
|
|
2370
|
+
},
|
|
2371
|
+
async command(command, session) {
|
|
2372
|
+
const v = await c({ call: "agent_command", session: session ?? null, command });
|
|
2373
|
+
return expect(v, "maybe_session").session;
|
|
2374
|
+
},
|
|
2375
|
+
async selection() {
|
|
2376
|
+
return expect(await c({ call: "agent_get_selection" }), "selection").selection;
|
|
2377
|
+
},
|
|
2378
|
+
async setSelection(selection) {
|
|
2379
|
+
await c({ call: "agent_set_selection", selection });
|
|
2380
|
+
},
|
|
2381
|
+
async listModels(instance, opts) {
|
|
2382
|
+
const v = await c({
|
|
2383
|
+
call: "agent_list_models",
|
|
2384
|
+
instance: instance ?? null,
|
|
2385
|
+
refresh: opts?.refresh ?? false,
|
|
2386
|
+
});
|
|
2387
|
+
return expect(v, "models").models;
|
|
2388
|
+
},
|
|
2389
|
+
async listInstances() {
|
|
2390
|
+
return expect(await c({ call: "agent_list_instances" }), "instances").instances;
|
|
2391
|
+
},
|
|
2392
|
+
async driverCommands() {
|
|
2393
|
+
return expect(await c({ call: "agent_driver_commands" }), "driver_commands").commands;
|
|
2394
|
+
},
|
|
2395
|
+
async setDraft(text) {
|
|
2396
|
+
await c({ call: "chat_set_draft", text });
|
|
2397
|
+
},
|
|
2398
|
+
async attach(path) {
|
|
2399
|
+
const v = await c({ call: "chat_attach", path: path ?? null });
|
|
2400
|
+
// Exactly one, because exactly one was asked for. An empty answer would mean the host
|
|
2401
|
+
// silently attached nothing, which it does not — it rejects.
|
|
2402
|
+
const [one] = expect(v, "attachments").attachments;
|
|
2403
|
+
if (!one) throw new Error("nothing was attached");
|
|
2404
|
+
return one;
|
|
2405
|
+
},
|
|
2406
|
+
async attachments() {
|
|
2407
|
+
return expect(await c({ call: "chat_attachments" }), "attachments").attachments;
|
|
2408
|
+
},
|
|
2409
|
+
async detach(index) {
|
|
2410
|
+
const v = await c({ call: "chat_detach", index: index ?? null });
|
|
2411
|
+
return expect(v, "attachments").attachments[0] ?? null;
|
|
2412
|
+
},
|
|
2413
|
+
async detachAll() {
|
|
2414
|
+
return expect(await c({ call: "chat_detach_all" }), "attachments").attachments;
|
|
2415
|
+
},
|
|
2416
|
+
async credentials() {
|
|
2417
|
+
return expect(await c({ call: "provider_credentials" }), "credentials").credentials;
|
|
2418
|
+
},
|
|
2419
|
+
async setCredential(instance, opts) {
|
|
2420
|
+
// Does not settle until the prompt closes — the answer is what the user did.
|
|
2421
|
+
const v = await c({
|
|
2422
|
+
call: "provider_set_credential",
|
|
2423
|
+
instance,
|
|
2424
|
+
replace: opts?.replace ?? false,
|
|
2425
|
+
});
|
|
2426
|
+
return expect(v, "bool").value;
|
|
2427
|
+
},
|
|
2428
|
+
async forgetCredential(instance) {
|
|
2429
|
+
await c({ call: "provider_forget_credential", instance });
|
|
2430
|
+
},
|
|
2431
|
+
onSelectionChange: (cb) => listener(r.selectionListeners, cb),
|
|
2432
|
+
onComposerChange: (cb) => listener(r.composerListeners, cb),
|
|
2433
|
+
onActivity: (cb) => listener(r.activityListeners, cb),
|
|
2434
|
+
onTurnStart: (cb) =>
|
|
2435
|
+
listener(r.agentListeners.turnStart as Array<(e: { session: string; turn: string }) => void>, cb),
|
|
2436
|
+
onToken: (cb) =>
|
|
2437
|
+
listener(r.agentListeners.token as Array<(e: { session: string; turn: string; text: string }) => void>, cb),
|
|
2438
|
+
onThinking: (cb) =>
|
|
2439
|
+
listener(r.agentListeners.thinking as Array<(e: { session: string; turn: string; text: string }) => void>, cb),
|
|
2440
|
+
onTurnEnd: (cb) =>
|
|
2441
|
+
listener(
|
|
2442
|
+
r.agentListeners.turnEnd as Array<
|
|
2443
|
+
(e: { session: string; turn: string; stopReason: StopReason; usage: Usage }) => void
|
|
2444
|
+
>,
|
|
2445
|
+
cb,
|
|
2446
|
+
),
|
|
2447
|
+
onToolStart: (cb) =>
|
|
2448
|
+
listener(
|
|
2449
|
+
r.agentListeners.toolStart as Array<(e: { session: string; turn: string; call: ToolCall }) => void>,
|
|
2450
|
+
cb,
|
|
2451
|
+
),
|
|
2452
|
+
onToolEnd: (cb) =>
|
|
2453
|
+
listener(
|
|
2454
|
+
r.agentListeners.toolEnd as Array<
|
|
2455
|
+
(e: { session: string; turn: string; call: ToolCall; result: ToolResult }) => void
|
|
2456
|
+
>,
|
|
2457
|
+
cb,
|
|
2458
|
+
),
|
|
2459
|
+
},
|
|
2460
|
+
tool: {
|
|
2461
|
+
async register(def, handler) {
|
|
2462
|
+
await c({
|
|
2463
|
+
call: "tool_register",
|
|
2464
|
+
def: {
|
|
2465
|
+
name: def.name,
|
|
2466
|
+
description: def.description,
|
|
2467
|
+
input_schema: def.inputSchema,
|
|
2468
|
+
source: { kind: "builtin" },
|
|
2469
|
+
},
|
|
2470
|
+
});
|
|
2471
|
+
r.tools.set(def.name, handler);
|
|
2472
|
+
return {
|
|
2473
|
+
dispose: () => {
|
|
2474
|
+
r.tools.delete(def.name);
|
|
2475
|
+
n({ call: "tool_unregister", name: def.name });
|
|
2476
|
+
},
|
|
2477
|
+
};
|
|
2478
|
+
},
|
|
2479
|
+
async list() {
|
|
2480
|
+
return expect(await c({ call: "tool_list" }), "tools").tools;
|
|
2481
|
+
},
|
|
2482
|
+
},
|
|
2483
|
+
hook: {
|
|
2484
|
+
async register(hook, fn, opts) {
|
|
2485
|
+
const blocking = opts?.blocking ?? false;
|
|
2486
|
+
await c({ call: "hook_register", hook, blocking, timeout_ms: opts?.timeoutMs ?? null });
|
|
2487
|
+
r.hooks.set(hook, fn);
|
|
2488
|
+
return {
|
|
2489
|
+
dispose: () => {
|
|
2490
|
+
r.hooks.delete(hook);
|
|
2491
|
+
n({ call: "hook_unregister", hook });
|
|
2492
|
+
},
|
|
2493
|
+
};
|
|
2494
|
+
},
|
|
2495
|
+
},
|
|
2496
|
+
git: {
|
|
2497
|
+
async status(opts) {
|
|
2498
|
+
return expect(await c({ call: "git_status", cwd: opts?.cwd ?? null }), "status").status;
|
|
2499
|
+
},
|
|
2500
|
+
async branches(opts) {
|
|
2501
|
+
const v = await c({
|
|
2502
|
+
call: "git_branches",
|
|
2503
|
+
include_remote: opts?.includeRemote ?? false,
|
|
2504
|
+
cwd: opts?.cwd ?? null,
|
|
2505
|
+
});
|
|
2506
|
+
return expect(v, "branches").branches;
|
|
2507
|
+
},
|
|
2508
|
+
async worktrees(opts) {
|
|
2509
|
+
const v = await c({ call: "git_worktrees", cwd: opts?.cwd ?? null });
|
|
2510
|
+
return expect(v, "worktrees").worktrees;
|
|
2511
|
+
},
|
|
2512
|
+
async log(limit) {
|
|
2513
|
+
return expect(await c({ call: "git_log", limit: limit ?? 20 }), "commits").commits;
|
|
2514
|
+
},
|
|
2515
|
+
async diff(target, opts) {
|
|
2516
|
+
const v = await c({
|
|
2517
|
+
call: "git_diff",
|
|
2518
|
+
target: target ?? { kind: "unstaged" },
|
|
2519
|
+
stat: opts?.stat ?? false,
|
|
2520
|
+
});
|
|
2521
|
+
return expect(v, "text").text;
|
|
2522
|
+
},
|
|
2523
|
+
async defaultBranch() {
|
|
2524
|
+
return expect(await c({ call: "git_default_branch" }), "maybe_text").text ?? null;
|
|
2525
|
+
},
|
|
2526
|
+
async createBranch(name, opts) {
|
|
2527
|
+
await c({ call: "git_create_branch", name, from: opts?.from ?? null });
|
|
2528
|
+
},
|
|
2529
|
+
async renameBranch(name, next, opts) {
|
|
2530
|
+
await c({ call: "git_rename_branch", old: name, new: next, cwd: opts?.cwd ?? null });
|
|
2531
|
+
},
|
|
2532
|
+
async checkout(rev) {
|
|
2533
|
+
await c({ call: "git_checkout", rev });
|
|
2534
|
+
},
|
|
2535
|
+
async stage(paths) {
|
|
2536
|
+
await c({ call: "git_stage", paths: paths ?? [] });
|
|
2537
|
+
},
|
|
2538
|
+
async unstage(paths) {
|
|
2539
|
+
await c({ call: "git_unstage", paths: paths ?? [] });
|
|
2540
|
+
},
|
|
2541
|
+
async commit(message) {
|
|
2542
|
+
return expect(await c({ call: "git_commit", message }), "commit").commit;
|
|
2543
|
+
},
|
|
2544
|
+
async addWorktree(path, branch, opts) {
|
|
2545
|
+
await c({
|
|
2546
|
+
call: "git_add_worktree",
|
|
2547
|
+
path,
|
|
2548
|
+
branch,
|
|
2549
|
+
create: opts?.create ?? false,
|
|
2550
|
+
cwd: opts?.cwd ?? null,
|
|
2551
|
+
});
|
|
2552
|
+
},
|
|
2553
|
+
async pull(opts) {
|
|
2554
|
+
const v = await c({ call: "git_pull", cwd: opts?.cwd ?? null });
|
|
2555
|
+
return expect(v, "text").text;
|
|
2556
|
+
},
|
|
2557
|
+
async removeWorktree(path, opts) {
|
|
2558
|
+
await c({
|
|
2559
|
+
call: "git_remove_worktree",
|
|
2560
|
+
path,
|
|
2561
|
+
force: opts?.force ?? false,
|
|
2562
|
+
cwd: opts?.cwd ?? null,
|
|
2563
|
+
});
|
|
2564
|
+
},
|
|
2565
|
+
},
|
|
2566
|
+
gen: {
|
|
2567
|
+
async complete(prompt, opts) {
|
|
2568
|
+
const v = await c({
|
|
2569
|
+
call: "gen_complete",
|
|
2570
|
+
prompt,
|
|
2571
|
+
system: opts?.system ?? null,
|
|
2572
|
+
json: false,
|
|
2573
|
+
selection: opts?.selection ?? null,
|
|
2574
|
+
});
|
|
2575
|
+
return expect(v, "text").text;
|
|
2576
|
+
},
|
|
2577
|
+
async json(prompt, opts) {
|
|
2578
|
+
const v = await c({
|
|
2579
|
+
call: "gen_complete",
|
|
2580
|
+
prompt,
|
|
2581
|
+
system: opts?.system ?? null,
|
|
2582
|
+
json: true,
|
|
2583
|
+
selection: opts?.selection ?? null,
|
|
2584
|
+
});
|
|
2585
|
+
return expect(v, "json").value as never;
|
|
2586
|
+
},
|
|
2587
|
+
},
|
|
2588
|
+
view: {
|
|
2589
|
+
async list() {
|
|
2590
|
+
return expect(await c({ call: "view_list" }), "views").views;
|
|
2591
|
+
},
|
|
2592
|
+
async current() {
|
|
2593
|
+
const views = expect(await c({ call: "view_list" }), "views").views;
|
|
2594
|
+
return views.find((v) => v.current) ?? null;
|
|
2595
|
+
},
|
|
2596
|
+
at: (id) => build(plugin, version, r, id),
|
|
2597
|
+
onOpen(cb) {
|
|
2598
|
+
// The terminals that were already here, as well as the ones still to come. A plugin loaded
|
|
2599
|
+
// after them missed their arrival, and "open a panel in every view" would otherwise mean
|
|
2600
|
+
// every view *from now on* — which is every view except the one you are sitting in.
|
|
2601
|
+
//
|
|
2602
|
+
// Announced once each: a view that arrives while the list is in flight arrives by the
|
|
2603
|
+
// event too, and `seen` is what stops it being announced twice.
|
|
2604
|
+
const seen = new Set<ViewId>();
|
|
2605
|
+
const announce = (id: ViewId) => {
|
|
2606
|
+
if (seen.has(id)) return;
|
|
2607
|
+
seen.add(id);
|
|
2608
|
+
cb(id);
|
|
2609
|
+
};
|
|
2610
|
+
const d = listener(r.viewOpenListeners, announce);
|
|
2611
|
+
void c({ call: "view_list" })
|
|
2612
|
+
.then((v) => {
|
|
2613
|
+
for (const info of expect(v, "views").views) announce(info.view);
|
|
2614
|
+
})
|
|
2615
|
+
.catch(() => {});
|
|
2616
|
+
return d;
|
|
2617
|
+
},
|
|
2618
|
+
onClose: (cb) => listener(r.viewCloseListeners, cb),
|
|
2619
|
+
},
|
|
2620
|
+
session: {
|
|
2621
|
+
async list(opts) {
|
|
2622
|
+
const v = await c({
|
|
2623
|
+
call: "session_list",
|
|
2624
|
+
include_archived: opts?.includeArchived ?? false,
|
|
2625
|
+
});
|
|
2626
|
+
return expect(v, "sessions").sessions;
|
|
2627
|
+
},
|
|
2628
|
+
async current() {
|
|
2629
|
+
return expect(await c({ call: "session_current" }), "session").session;
|
|
2630
|
+
},
|
|
2631
|
+
async create(opts) {
|
|
2632
|
+
const v = await c({
|
|
2633
|
+
call: "session_new",
|
|
2634
|
+
cwd: opts?.cwd ?? null,
|
|
2635
|
+
title: opts?.title ?? null,
|
|
2636
|
+
activate: opts?.activate ?? true,
|
|
2637
|
+
});
|
|
2638
|
+
return expect(v, "session").session;
|
|
2639
|
+
},
|
|
2640
|
+
async switch(session) {
|
|
2641
|
+
await c({ call: "session_switch", session });
|
|
2642
|
+
},
|
|
2643
|
+
async close(session) {
|
|
2644
|
+
await c({ call: "session_close", session });
|
|
2645
|
+
},
|
|
2646
|
+
async rename(session, title) {
|
|
2647
|
+
await c({ call: "session_rename", session, title });
|
|
2648
|
+
},
|
|
2649
|
+
async archive(session, archived) {
|
|
2650
|
+
await c({ call: "session_archive", session, archived: archived ?? true });
|
|
2651
|
+
},
|
|
2652
|
+
async stored() {
|
|
2653
|
+
return expect(await c({ call: "sessions_stored" }), "sessions").sessions;
|
|
2654
|
+
},
|
|
2655
|
+
async messages(session) {
|
|
2656
|
+
const v = await c({ call: "session_messages", session: session ?? null });
|
|
2657
|
+
return expect(v, "messages").messages;
|
|
2658
|
+
},
|
|
2659
|
+
onChange: (cb) => listener(r.sessionListeners, cb),
|
|
2660
|
+
},
|
|
2661
|
+
permission: {
|
|
2662
|
+
async mode() {
|
|
2663
|
+
return expect(await c({ call: "permission_get_mode" }), "permission_mode").mode;
|
|
2664
|
+
},
|
|
2665
|
+
async setMode(mode) {
|
|
2666
|
+
return expect(await c({ call: "permission_set_mode", mode }), "permission_mode").mode;
|
|
2667
|
+
},
|
|
2668
|
+
},
|
|
2669
|
+
hint: {
|
|
2670
|
+
async set(key, hint) {
|
|
2671
|
+
await c({
|
|
2672
|
+
call: "hint_set",
|
|
2673
|
+
key,
|
|
2674
|
+
hint: { keys: hint.keys, label: hint.label, priority: hint.priority ?? 0 },
|
|
2675
|
+
});
|
|
2676
|
+
},
|
|
2677
|
+
async clear(key) {
|
|
2678
|
+
await c({ call: "hint_clear", key });
|
|
2679
|
+
},
|
|
2680
|
+
},
|
|
2681
|
+
status: {
|
|
2682
|
+
async set(key, segment) {
|
|
2683
|
+
await c({
|
|
2684
|
+
call: "status_set",
|
|
2685
|
+
key,
|
|
2686
|
+
segment: {
|
|
2687
|
+
text: segment.text,
|
|
2688
|
+
short: segment.short ?? null,
|
|
2689
|
+
keys: segment.keys ?? null,
|
|
2690
|
+
hl: segment.hl ?? null,
|
|
2691
|
+
align: segment.align ?? "left",
|
|
2692
|
+
priority: segment.priority ?? 0,
|
|
2693
|
+
},
|
|
2694
|
+
});
|
|
2695
|
+
},
|
|
2696
|
+
async clear(key) {
|
|
2697
|
+
await c({ call: "status_clear", key });
|
|
2698
|
+
},
|
|
2699
|
+
},
|
|
2700
|
+
provider: {
|
|
2701
|
+
async register(driver, instances, handler, opts) {
|
|
2702
|
+
await c({
|
|
2703
|
+
call: "provider_register_driver",
|
|
2704
|
+
driver,
|
|
2705
|
+
instances,
|
|
2706
|
+
agent_loop: opts?.agentLoop ?? false,
|
|
2707
|
+
});
|
|
2708
|
+
r.providers.set(driver, handler);
|
|
2709
|
+
return { dispose: () => r.providers.delete(driver) };
|
|
2710
|
+
},
|
|
2711
|
+
},
|
|
2712
|
+
};
|
|
2713
|
+
return api;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
/** Build the API object handed to one plugin. Internal; the host calls this. */
|
|
2717
|
+
export function __createContext(plugin: string, config: unknown, version: number): PluginContext {
|
|
2718
|
+
const r = reg(plugin);
|
|
2719
|
+
r.version = version;
|
|
2720
|
+
return {
|
|
2721
|
+
neosh: build(plugin, version, r, null),
|
|
2722
|
+
pluginId: plugin,
|
|
2723
|
+
config,
|
|
2724
|
+
subscriptions: r.subscriptions,
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
/** Route one host message. Internal; the host's bootstrap calls this. */
|
|
2729
|
+
export async function __dispatch(plugin: string, msg: Record<string, unknown>): Promise<void> {
|
|
2730
|
+
const r = reg(plugin);
|
|
2731
|
+
|
|
2732
|
+
if (msg.type === "response") {
|
|
2733
|
+
settle(msg.id as string, msg.response as ApiResponse);
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
if (msg.type === "request") {
|
|
2738
|
+
const id = msg.id as string;
|
|
2739
|
+
const req = msg.request as Record<string, unknown>;
|
|
2740
|
+
const respond = (response: unknown) =>
|
|
2741
|
+
send({ type: "plugin", plugin, msg: { type: "response", id, response } });
|
|
2742
|
+
|
|
2743
|
+
try {
|
|
2744
|
+
if (req.type === "run_tool") {
|
|
2745
|
+
const h = r.tools.get(req.name as string);
|
|
2746
|
+
if (!h) {
|
|
2747
|
+
respond({ type: "error", message: `plugin ${plugin} has no tool ${req.name}` });
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
respond({ type: "tool", result: await h(req.input) });
|
|
2751
|
+
} else if (req.type === "command") {
|
|
2752
|
+
const name = req.name as string;
|
|
2753
|
+
const h = r.commands.get(name);
|
|
2754
|
+
if (!h) {
|
|
2755
|
+
respond({ type: "error", message: `plugin ${plugin} has no command ${name}` });
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
const value = await h((req.args as string[]) ?? [], undefined);
|
|
2759
|
+
// `undefined` is not JSON; a handler that returned nothing answers `null`.
|
|
2760
|
+
respond({ type: "command", value: value === undefined ? null : value });
|
|
2761
|
+
} else if (req.type === "hook") {
|
|
2762
|
+
const h = r.hooks.get(req.hook as HookName);
|
|
2763
|
+
// A hook the plugin no longer has must not block the action: continue, do not veto.
|
|
2764
|
+
const outcome: HookOutcome = h ? await h(req.payload as HookPayload) : { action: "continue" };
|
|
2765
|
+
respond({ type: "hook", outcome });
|
|
2766
|
+
} else if (req.type === "provider_stream") {
|
|
2767
|
+
const streamId = req.stream as string;
|
|
2768
|
+
const tr = req.request as TurnRequest;
|
|
2769
|
+
const h = r.providers.get(tr.selection.instance);
|
|
2770
|
+
const byDriver = h ?? [...r.providers.values()][0];
|
|
2771
|
+
if (!byDriver) {
|
|
2772
|
+
respond({ type: "error", message: `plugin ${plugin} has no provider` });
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
const signal = { cancelled: false };
|
|
2776
|
+
r.streams.set(streamId, signal);
|
|
2777
|
+
// Answer immediately: a stream cannot be a return value across this boundary.
|
|
2778
|
+
respond({ type: "provider_accepted" });
|
|
2779
|
+
void (async () => {
|
|
2780
|
+
try {
|
|
2781
|
+
await byDriver(tr, (e) => notify(plugin, { call: "provider_emit", stream: streamId, event: e }), signal);
|
|
2782
|
+
} finally {
|
|
2783
|
+
r.streams.delete(streamId);
|
|
2784
|
+
}
|
|
2785
|
+
})();
|
|
2786
|
+
} else {
|
|
2787
|
+
respond({ type: "error", message: `unknown request ${String(req.type)}` });
|
|
2788
|
+
}
|
|
2789
|
+
} catch (e) {
|
|
2790
|
+
respond({ type: "error", message: e instanceof Error ? e.message : String(e) });
|
|
2791
|
+
}
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
if (msg.type === "event") {
|
|
2796
|
+
const ev = msg.event as PluginEvent;
|
|
2797
|
+
try {
|
|
2798
|
+
await dispatchEvent(plugin, r, ev);
|
|
2799
|
+
} catch (e) {
|
|
2800
|
+
// A listener that throws is one plugin's bug, and it used to be every plugin's: an
|
|
2801
|
+
// unhandled rejection stops the runtime, and the runtime is shared. Reported and survived.
|
|
2802
|
+
const what = e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
2803
|
+
notify(plugin, { call: "log", level: "error", message: `handling ${ev.type}: ${what}` });
|
|
2804
|
+
}
|
|
2805
|
+
return;
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
async function dispatchEvent(
|
|
2810
|
+
plugin: string,
|
|
2811
|
+
r: ReturnType<typeof reg>,
|
|
2812
|
+
ev: PluginEvent,
|
|
2813
|
+
): Promise<void> {
|
|
2814
|
+
{
|
|
2815
|
+
switch (ev.type) {
|
|
2816
|
+
case "command_invoked": {
|
|
2817
|
+
const h = r.commands.get(ev.name);
|
|
2818
|
+
if (!h) break;
|
|
2819
|
+
// The third argument is the whole namespace bound to the terminal the key was pressed in.
|
|
2820
|
+
// A handler that opens a panel writes `here.win.open(...)` and it lands where the person
|
|
2821
|
+
// pressing the key is looking, without having to say so or to know that views exist.
|
|
2822
|
+
const here = ev.key ? build(plugin, r.version, r, ev.key.view) : undefined;
|
|
2823
|
+
await h(ev.args ?? [], ev.key ?? undefined, here);
|
|
2824
|
+
break;
|
|
2825
|
+
}
|
|
2826
|
+
case "buffer_changed": {
|
|
2827
|
+
for (const cb of r.bufferListeners.get(ev.buf) ?? []) {
|
|
2828
|
+
cb({ buf: ev.buf, start: ev.start, oldEnd: ev.old_end, newEnd: ev.new_end });
|
|
2829
|
+
}
|
|
2830
|
+
break;
|
|
2831
|
+
}
|
|
2832
|
+
case "turn_started":
|
|
2833
|
+
for (const cb of r.agentListeners.turnStart)
|
|
2834
|
+
(cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn });
|
|
2835
|
+
break;
|
|
2836
|
+
case "token":
|
|
2837
|
+
for (const cb of r.agentListeners.token)
|
|
2838
|
+
(cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn, text: ev.text });
|
|
2839
|
+
break;
|
|
2840
|
+
case "thinking_token":
|
|
2841
|
+
for (const cb of r.agentListeners.thinking)
|
|
2842
|
+
(cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn, text: ev.text });
|
|
2843
|
+
break;
|
|
2844
|
+
case "turn_ended":
|
|
2845
|
+
for (const cb of r.agentListeners.turnEnd)
|
|
2846
|
+
(cb as (e: unknown) => void)({
|
|
2847
|
+
session: ev.session,
|
|
2848
|
+
turn: ev.turn,
|
|
2849
|
+
stopReason: ev.stop_reason,
|
|
2850
|
+
usage: ev.usage,
|
|
2851
|
+
});
|
|
2852
|
+
break;
|
|
2853
|
+
case "tool_started":
|
|
2854
|
+
for (const cb of r.agentListeners.toolStart)
|
|
2855
|
+
(cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn, call: ev.call });
|
|
2856
|
+
break;
|
|
2857
|
+
case "tool_finished":
|
|
2858
|
+
for (const cb of r.agentListeners.toolEnd)
|
|
2859
|
+
(cb as (e: unknown) => void)({
|
|
2860
|
+
session: ev.session,
|
|
2861
|
+
turn: ev.turn,
|
|
2862
|
+
call: ev.call,
|
|
2863
|
+
result: ev.result,
|
|
2864
|
+
});
|
|
2865
|
+
break;
|
|
2866
|
+
case "hook_observed": {
|
|
2867
|
+
const h = r.hooks.get(ev.hook);
|
|
2868
|
+
if (h) await h(ev.payload);
|
|
2869
|
+
break;
|
|
2870
|
+
}
|
|
2871
|
+
case "provider_cancel": {
|
|
2872
|
+
const s = r.streams.get(ev.stream);
|
|
2873
|
+
if (s) s.cancelled = true;
|
|
2874
|
+
break;
|
|
2875
|
+
}
|
|
2876
|
+
case "option_changed":
|
|
2877
|
+
for (const cb of r.optionListeners) cb({ name: ev.name, value: ev.value });
|
|
2878
|
+
break;
|
|
2879
|
+
case "session_changed":
|
|
2880
|
+
for (const cb of r.sessionListeners) cb({ session: ev.session, view: ev.view });
|
|
2881
|
+
break;
|
|
2882
|
+
case "view_attached":
|
|
2883
|
+
for (const cb of r.viewOpenListeners) cb(ev.view);
|
|
2884
|
+
break;
|
|
2885
|
+
case "view_closed":
|
|
2886
|
+
for (const cb of r.viewCloseListeners) cb(ev.view);
|
|
2887
|
+
break;
|
|
2888
|
+
case "selection_changed":
|
|
2889
|
+
for (const cb of r.selectionListeners) cb({ selection: ev.selection });
|
|
2890
|
+
break;
|
|
2891
|
+
case "composer_changed":
|
|
2892
|
+
for (const cb of r.composerListeners) cb({ text: ev.text });
|
|
2893
|
+
break;
|
|
2894
|
+
case "activity":
|
|
2895
|
+
for (const cb of r.activityListeners)
|
|
2896
|
+
cb({ session: ev.session, turn: ev.turn, activity: ev.activity });
|
|
2897
|
+
break;
|
|
2898
|
+
case "var_changed":
|
|
2899
|
+
for (const cb of r.varListeners)
|
|
2900
|
+
cb({ scope: ev.scope, key: ev.key, value: ev.value });
|
|
2901
|
+
break;
|
|
2902
|
+
case "quota":
|
|
2903
|
+
for (const cb of [...r.quotaListeners]) cb(ev.snapshot);
|
|
2904
|
+
break;
|
|
2905
|
+
case "swarm_changed":
|
|
2906
|
+
for (const cb of [...r.swarmListeners]) cb();
|
|
2907
|
+
break;
|
|
2908
|
+
case "swarm_stream":
|
|
2909
|
+
for (const cb of [...r.swarmStreamListeners]) {
|
|
2910
|
+
cb({ node: ev.node, session: ev.session, event: ev.event });
|
|
2911
|
+
}
|
|
2912
|
+
break;
|
|
2913
|
+
case "contributions_changed":
|
|
2914
|
+
for (const cb of r.contributionListeners) cb({ point: ev.point });
|
|
2915
|
+
break;
|
|
2916
|
+
case "focus_changed":
|
|
2917
|
+
for (const cb of r.focusListeners) cb({ win: ev.win ?? null });
|
|
2918
|
+
break;
|
|
2919
|
+
case "view_attached":
|
|
2920
|
+
for (const cb of r.viewListeners) cb();
|
|
2921
|
+
break;
|
|
2922
|
+
case "shutdown":
|
|
2923
|
+
for (const cb of r.shutdownListeners) cb();
|
|
2924
|
+
break;
|
|
2925
|
+
case "highlight_changed":
|
|
2926
|
+
for (const cb of r.highlightListeners) cb({ names: ev.names });
|
|
2927
|
+
break;
|
|
2928
|
+
case "event": {
|
|
2929
|
+
// Copied before iterating: a listener that unsubscribes itself — the ordinary shape of
|
|
2930
|
+
// "wait for the thing to happen once" — would otherwise shorten the array underneath the
|
|
2931
|
+
// loop and skip whoever was next.
|
|
2932
|
+
const named = [...(r.eventListeners.get(ev.name) ?? [])];
|
|
2933
|
+
const all = [...(r.eventListeners.get(null) ?? [])];
|
|
2934
|
+
const e = { name: ev.name, data: ev.data ?? null, from: ev.from };
|
|
2935
|
+
for (const cb of named) cb(e);
|
|
2936
|
+
for (const cb of all) cb(e);
|
|
2937
|
+
break;
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
/** Dispose everything a plugin registered. Internal. */
|
|
2944
|
+
export function __teardown(plugin: string): void {
|
|
2945
|
+
const r = plugins.get(plugin);
|
|
2946
|
+
if (!r) return;
|
|
2947
|
+
// Before the disposers: a timer that fires mid-teardown would call into a plugin that is halfway
|
|
2948
|
+
// gone.
|
|
2949
|
+
for (const id of r.timers) {
|
|
2950
|
+
clearTimeout(id);
|
|
2951
|
+
}
|
|
2952
|
+
r.timers.clear();
|
|
2953
|
+
for (const d of r.subscriptions) {
|
|
2954
|
+
try {
|
|
2955
|
+
d.dispose();
|
|
2956
|
+
} catch {
|
|
2957
|
+
// A failing disposer must not stop the rest from running.
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
plugins.delete(plugin);
|
|
2961
|
+
}
|