@moxxy/sdk 0.11.0 → 0.12.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/dist/elision-state.d.ts.map +1 -1
- package/dist/elision-state.js +6 -0
- package/dist/elision-state.js.map +1 -1
- package/dist/events.d.ts +33 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mode-helpers.d.ts +12 -0
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +105 -3
- package/dist/mode-helpers.js.map +1 -1
- package/dist/mode.d.ts +9 -0
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/plugin.d.ts +10 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/provider-login-bridge.d.ts +53 -0
- package/dist/provider-login-bridge.d.ts.map +1 -0
- package/dist/provider-login-bridge.js +95 -0
- package/dist/provider-login-bridge.js.map +1 -0
- package/dist/provider.d.ts +53 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/subagent.d.ts +6 -0
- package/dist/subagent.d.ts.map +1 -1
- package/dist/surface.d.ts +159 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +27 -0
- package/dist/surface.js.map +1 -0
- package/dist/tool-display.d.ts +73 -0
- package/dist/tool-display.d.ts.map +1 -0
- package/dist/tool-display.js +66 -0
- package/dist/tool-display.js.map +1 -0
- package/package.json +5 -1
- package/src/elision-state.ts +5 -0
- package/src/events.ts +36 -0
- package/src/index.ts +41 -0
- package/src/loop-helpers.test.ts +40 -0
- package/src/mode-helpers.ts +114 -3
- package/src/mode.ts +7 -0
- package/src/plugin.ts +10 -1
- package/src/provider-login-bridge.test.ts +78 -0
- package/src/provider-login-bridge.ts +105 -0
- package/src/provider.ts +45 -2
- package/src/subagent.ts +6 -0
- package/src/surface.ts +173 -0
- package/src/tool-display.test.ts +71 -0
- package/src/tool-display.ts +118 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process bridge for relaying a provider login's interactive prompts —
|
|
3
|
+
* the out-of-band token / authorization-code paste that `claude-code` needs —
|
|
4
|
+
* from a spawned `moxxy login --stdin-prompts` subprocess to a GUI host that
|
|
5
|
+
* has no TTY (the desktop app).
|
|
6
|
+
*
|
|
7
|
+
* The subprocess emits each `ctx.prompt(question)` call as a NUL-bracketed
|
|
8
|
+
* JSON marker on stdout. NUL never appears in normal terminal output, so the
|
|
9
|
+
* host can scan the byte stream for these markers, render the prompt, and
|
|
10
|
+
* write the user's answer back as one stdin line. Loopback flows that never
|
|
11
|
+
* call `ctx.prompt` (e.g. openai-codex, which captures the code via a local
|
|
12
|
+
* callback server) emit no markers — the host just streams their output and
|
|
13
|
+
* waits for the process to exit.
|
|
14
|
+
*
|
|
15
|
+
* Living here (not in the CLI or the desktop contract) keeps the one wire
|
|
16
|
+
* format in a package both the CLI producer and the desktop consumer already
|
|
17
|
+
* depend on, so the two ends can never drift.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The byte that brackets a prompt marker. Absent from normal CLI output. */
|
|
21
|
+
const NUL = '\u0000';
|
|
22
|
+
|
|
23
|
+
/** Tag distinguishing a login-prompt marker from any other NUL-bracketed run. */
|
|
24
|
+
const PROMPT_TAG = 'moxxy.login.prompt';
|
|
25
|
+
|
|
26
|
+
export interface LoginPromptRequest {
|
|
27
|
+
/** The question to show the user, verbatim from `ctx.prompt`. */
|
|
28
|
+
readonly question: string;
|
|
29
|
+
/** True for secrets — the host should mask the input field. */
|
|
30
|
+
readonly mask: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One decoded item from a login subprocess's stdout stream. */
|
|
34
|
+
export type LoginStreamItem =
|
|
35
|
+
| { readonly type: 'output'; readonly text: string }
|
|
36
|
+
| { readonly type: 'prompt'; readonly prompt: LoginPromptRequest };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Serialize a prompt request as a NUL-bracketed marker for stdout. The
|
|
40
|
+
* leading NUL also delimits it from any partial preceding `ctx.write` output
|
|
41
|
+
* the host scanner has buffered.
|
|
42
|
+
*/
|
|
43
|
+
export function encodeLoginPrompt(req: LoginPromptRequest): string {
|
|
44
|
+
return NUL + JSON.stringify({ tag: PROMPT_TAG, question: req.question, mask: req.mask }) + NUL;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Try to parse a single inter-NUL segment as a prompt marker. Returns null
|
|
49
|
+
* when it isn't one, so the host can pass the segment through as output.
|
|
50
|
+
*/
|
|
51
|
+
export function decodeLoginPrompt(segment: string): LoginPromptRequest | null {
|
|
52
|
+
if (!segment.startsWith('{')) return null;
|
|
53
|
+
try {
|
|
54
|
+
const o = JSON.parse(segment) as Record<string, unknown>;
|
|
55
|
+
if (o.tag !== PROMPT_TAG || typeof o.question !== 'string') return null;
|
|
56
|
+
return { question: o.question, mask: o.mask === true };
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Streaming scanner for a login subprocess's stdout. Feed it chunks; it
|
|
64
|
+
* returns the ordered output / prompt items decoded so far, holding back any
|
|
65
|
+
* incomplete trailing marker (an opening NUL with no closing NUL yet) until
|
|
66
|
+
* the rest of it arrives in a later chunk.
|
|
67
|
+
*/
|
|
68
|
+
export function createLoginStreamScanner(): {
|
|
69
|
+
push(chunk: string): ReadonlyArray<LoginStreamItem>;
|
|
70
|
+
} {
|
|
71
|
+
let buf = '';
|
|
72
|
+
return {
|
|
73
|
+
push(chunk: string): ReadonlyArray<LoginStreamItem> {
|
|
74
|
+
buf += chunk;
|
|
75
|
+
const out: LoginStreamItem[] = [];
|
|
76
|
+
// Consume every COMPLETE marker (NUL <json> NUL), emitting the plain
|
|
77
|
+
// text before each as output. Stop at the first NUL with no closing
|
|
78
|
+
// partner — that's a marker still mid-flight, kept for the next chunk.
|
|
79
|
+
for (;;) {
|
|
80
|
+
const open = buf.indexOf(NUL);
|
|
81
|
+
if (open === -1) break;
|
|
82
|
+
const close = buf.indexOf(NUL, open + 1);
|
|
83
|
+
if (close === -1) break;
|
|
84
|
+
if (open > 0) out.push({ type: 'output', text: buf.slice(0, open) });
|
|
85
|
+
const segment = buf.slice(open + 1, close);
|
|
86
|
+
const prompt = decodeLoginPrompt(segment);
|
|
87
|
+
// A NUL-bracketed run that isn't a valid marker is anomalous; surface
|
|
88
|
+
// its inner text as output rather than swallowing it.
|
|
89
|
+
out.push(prompt ? { type: 'prompt', prompt } : { type: 'output', text: segment });
|
|
90
|
+
buf = buf.slice(close + 1);
|
|
91
|
+
}
|
|
92
|
+
// Flush any plain text that precedes a still-incomplete marker (or all
|
|
93
|
+
// of it when no NUL is pending), keeping only the partial marker.
|
|
94
|
+
const pending = buf.indexOf(NUL);
|
|
95
|
+
if (pending === -1) {
|
|
96
|
+
if (buf) out.push({ type: 'output', text: buf });
|
|
97
|
+
buf = '';
|
|
98
|
+
} else if (pending > 0) {
|
|
99
|
+
out.push({ type: 'output', text: buf.slice(0, pending) });
|
|
100
|
+
buf = buf.slice(pending);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -19,7 +19,24 @@ export type ContentBlock =
|
|
|
19
19
|
* to a text placeholder. Text/Office files are inlined as `text` instead — only
|
|
20
20
|
* formats a model ingests as bytes become `document` blocks.
|
|
21
21
|
*/
|
|
22
|
-
| { readonly type: 'document'; readonly mediaType: string; readonly data: string; readonly name?: string }
|
|
22
|
+
| { readonly type: 'document'; readonly mediaType: string; readonly data: string; readonly name?: string }
|
|
23
|
+
/**
|
|
24
|
+
* A model reasoning/thinking block, preserved in conversation history so it
|
|
25
|
+
* can be replayed on the next request. Anthropic REQUIRES the signed
|
|
26
|
+
* `thinking` block be sent back (as the first block of an assistant turn that
|
|
27
|
+
* also carries tool_use) on an interleaved-thinking continuation — a missing
|
|
28
|
+
* or unsigned block is a hard 400, so the loop drops unsigned reasoning from
|
|
29
|
+
* what it replays. `redacted` blocks carry only `encrypted` (the opaque blob)
|
|
30
|
+
* and `text: ''`; they are replayed verbatim, never shown. Providers without
|
|
31
|
+
* reasoning ignore this block.
|
|
32
|
+
*/
|
|
33
|
+
| {
|
|
34
|
+
readonly type: 'reasoning';
|
|
35
|
+
readonly text: string;
|
|
36
|
+
readonly signature?: string;
|
|
37
|
+
readonly redacted?: boolean;
|
|
38
|
+
readonly encrypted?: string;
|
|
39
|
+
};
|
|
23
40
|
|
|
24
41
|
/**
|
|
25
42
|
* Provider-neutral instruction for where a prompt-cache breakpoint should be
|
|
@@ -56,6 +73,15 @@ export interface ProviderRequest {
|
|
|
56
73
|
readonly signal?: AbortSignal;
|
|
57
74
|
/** Where to place prompt-cache breakpoints. Set by the active CacheStrategy. */
|
|
58
75
|
readonly cacheHints?: ReadonlyArray<CacheHint>;
|
|
76
|
+
/**
|
|
77
|
+
* Request reasoning/thinking from the model. `false`/absent = off. Providers
|
|
78
|
+
* gate on this AND the model descriptor's `supportsReasoning`; unsupported
|
|
79
|
+
* providers/models ignore it. The loop sets it from the active provider's
|
|
80
|
+
* reasoning config (see the per-provider reasoning setting). `effort` maps to
|
|
81
|
+
* each provider's native knob (Anthropic thinking budget, OpenAI/Codex
|
|
82
|
+
* `reasoning.effort`).
|
|
83
|
+
*/
|
|
84
|
+
readonly reasoning?: { readonly effort?: 'low' | 'medium' | 'high' } | boolean;
|
|
59
85
|
}
|
|
60
86
|
|
|
61
87
|
export type ProviderEvent =
|
|
@@ -65,7 +91,17 @@ export type ProviderEvent =
|
|
|
65
91
|
| { readonly type: 'tool_use_delta'; readonly id: string; readonly partialInput: string }
|
|
66
92
|
| { readonly type: 'tool_use_end'; readonly id: string; readonly input: unknown }
|
|
67
93
|
| { readonly type: 'message_end'; readonly stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'error'; readonly usage?: TokenUsage }
|
|
68
|
-
| { readonly type: 'error'; readonly message: string; readonly retryable: boolean }
|
|
94
|
+
| { readonly type: 'error'; readonly message: string; readonly retryable: boolean }
|
|
95
|
+
/** A streamed reasoning/thinking text delta (visible summary). */
|
|
96
|
+
| { readonly type: 'reasoning_delta'; readonly delta: string }
|
|
97
|
+
/**
|
|
98
|
+
* End-of-reasoning-block metadata for history round-trip, emitted once per
|
|
99
|
+
* reasoning block. `signature` is Anthropic's thinking-block signature;
|
|
100
|
+
* `encrypted` carries an opaque blob (Anthropic redacted_thinking data /
|
|
101
|
+
* Codex reasoning encrypted_content) that must be replayed verbatim;
|
|
102
|
+
* `redacted` marks reasoning that must never be displayed.
|
|
103
|
+
*/
|
|
104
|
+
| { readonly type: 'reasoning_signature'; readonly signature?: string; readonly redacted?: boolean; readonly encrypted?: string };
|
|
69
105
|
|
|
70
106
|
export interface TokenUsage {
|
|
71
107
|
readonly inputTokens: number;
|
|
@@ -101,6 +137,13 @@ export interface ModelDescriptor {
|
|
|
101
137
|
* the transcript as text instead.
|
|
102
138
|
*/
|
|
103
139
|
readonly supportsAudio?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Whether this model can emit reasoning/thinking summaries (Anthropic
|
|
142
|
+
* extended thinking, OpenAI o-series / Codex reasoning). Gates the
|
|
143
|
+
* per-provider reasoning config UI and whether the loop requests reasoning
|
|
144
|
+
* for this model. When false, `ProviderRequest.reasoning` is ignored.
|
|
145
|
+
*/
|
|
146
|
+
readonly supportsReasoning?: boolean;
|
|
104
147
|
}
|
|
105
148
|
|
|
106
149
|
export interface LLMProvider {
|
package/src/subagent.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface SubagentSpec {
|
|
|
26
26
|
readonly allowedTools?: ReadonlyArray<string>;
|
|
27
27
|
/** Human-readable label surfaced in `subagent_*` event payloads. */
|
|
28
28
|
readonly label?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Named kind this child was spawned as (e.g. `'Explore'`, `'code-reviewer'`).
|
|
31
|
+
* Surfaced in `subagent_*` payloads so sibling agents of the same kind group
|
|
32
|
+
* under one collapsible header. Defaults to `'default'`.
|
|
33
|
+
*/
|
|
34
|
+
readonly agentType?: string;
|
|
29
35
|
/**
|
|
30
36
|
* When true, the child session stays alive after the first turn for
|
|
31
37
|
* {@link SubagentSpawner.continue}. `subagent_completed` is deferred until
|
package/src/surface.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Surface is a long-lived, interactive pane that BOTH a human viewer (a
|
|
3
|
+
* desktop panel) and the agent drive together: an embedded terminal (a shared
|
|
4
|
+
* PTY) or an in-window browser (a shared page). Unlike a {@link Channel} — which
|
|
5
|
+
* is a whole conversation transport — a Surface is a single resource attached to
|
|
6
|
+
* an existing Session, streaming opaque output frames out and accepting opaque
|
|
7
|
+
* input messages in.
|
|
8
|
+
*
|
|
9
|
+
* Surfaces are RUNNER-OWNED: the underlying resource (PTY, Playwright page) lives
|
|
10
|
+
* in the same process as the agent's tools, so the agent reads/writes the exact
|
|
11
|
+
* same instance the user watches. A thin client (the desktop) renders the
|
|
12
|
+
* streamed frames and relays the user's input back over the runner protocol —
|
|
13
|
+
* there is no reverse RPC. Plugins contribute surfaces via
|
|
14
|
+
* `definePlugin({ surfaces: [defineSurface(...)] })`; the plugin's own tools
|
|
15
|
+
* (e.g. `terminal`, `browser_session`) share the resource through module state.
|
|
16
|
+
*
|
|
17
|
+
* The payloads (`SurfaceDataMessage.payload`, `SurfaceInputMessage`) are
|
|
18
|
+
* deliberately opaque (`unknown` / open record): they cross the JSON-RPC wire
|
|
19
|
+
* verbatim and each surface kind defines its own shape (PTY bytes, a base64
|
|
20
|
+
* frame, a navigate request). Keeping them untyped here means a new surface kind
|
|
21
|
+
* never has to touch this file or the protocol.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Surface kind discriminator. Open string union so plugins add kinds without
|
|
26
|
+
* editing the SDK; the built-ins are `'terminal'` and `'browser'`.
|
|
27
|
+
*/
|
|
28
|
+
export type SurfaceKind = 'terminal' | 'browser' | (string & {});
|
|
29
|
+
|
|
30
|
+
/** One outbound frame from a surface instance to its viewers. */
|
|
31
|
+
export interface SurfaceDataMessage {
|
|
32
|
+
/** The instance this frame belongs to (a viewer may watch several). */
|
|
33
|
+
readonly surfaceId: string;
|
|
34
|
+
readonly kind: SurfaceKind;
|
|
35
|
+
/** Surface-specific payload (PTY text, a base64 frame, a url/title update). */
|
|
36
|
+
readonly payload: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One inbound message from a viewer to a surface instance — a keystroke, a
|
|
41
|
+
* mouse event, a navigate request, a resize. `type` discriminates; the rest is
|
|
42
|
+
* surface-specific.
|
|
43
|
+
*/
|
|
44
|
+
export interface SurfaceInputMessage {
|
|
45
|
+
readonly type: string;
|
|
46
|
+
readonly [key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Viewport hint for a resize (terminal cols/rows or pixel width/height). */
|
|
50
|
+
export interface SurfaceSize {
|
|
51
|
+
readonly cols?: number;
|
|
52
|
+
readonly rows?: number;
|
|
53
|
+
readonly width?: number;
|
|
54
|
+
readonly height?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A live, attached surface instance — the running PTY or browser page. Opening
|
|
59
|
+
* the same kind twice returns the SAME instance (surfaces are shared), so a
|
|
60
|
+
* viewer that joins late gets the existing resource plus its {@link snapshot}.
|
|
61
|
+
*/
|
|
62
|
+
export interface SurfaceInstance {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
readonly kind: SurfaceKind;
|
|
65
|
+
/** Subscribe to outbound frames. Returns an unsubscribe fn. */
|
|
66
|
+
onData(cb: (payload: unknown) => void): () => void;
|
|
67
|
+
/**
|
|
68
|
+
* A catch-up payload for a late-joining viewer (terminal scrollback, the last
|
|
69
|
+
* browser frame, the current url). Optional — a surface with no replayable
|
|
70
|
+
* state omits it.
|
|
71
|
+
*/
|
|
72
|
+
snapshot?(): unknown;
|
|
73
|
+
/** Apply an inbound message from a viewer (or the agent's tool). */
|
|
74
|
+
input(msg: SurfaceInputMessage): void | Promise<void>;
|
|
75
|
+
/** Re-size the viewport, when the surface is size-aware. */
|
|
76
|
+
resize?(size: SurfaceSize): void | Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Detach this instance. The underlying shared resource MAY persist (the
|
|
79
|
+
* agent's tool can still use the PTY/page); `close` just tears down streaming
|
|
80
|
+
* + viewer state. A surface that owns its resource exclusively disposes it.
|
|
81
|
+
*/
|
|
82
|
+
close(): void | Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether a surface kind can run right now (e.g. node-pty present). */
|
|
86
|
+
export interface SurfaceAvailability {
|
|
87
|
+
readonly ok: boolean;
|
|
88
|
+
/** Human-readable explanation when `ok` is false (shown in the empty pane). */
|
|
89
|
+
readonly reason?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Context handed to a surface when it opens. */
|
|
93
|
+
export interface SurfaceContext {
|
|
94
|
+
/** Working directory of the owning Session. */
|
|
95
|
+
readonly cwd: string;
|
|
96
|
+
readonly logger?: {
|
|
97
|
+
debug?(msg: string, meta?: Record<string, unknown>): void;
|
|
98
|
+
info?(msg: string, meta?: Record<string, unknown>): void;
|
|
99
|
+
warn?(msg: string, meta?: Record<string, unknown>): void;
|
|
100
|
+
error?(msg: string, meta?: Record<string, unknown>): void;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A registered, named factory for a Surface. `open` is idempotent per kind: a
|
|
106
|
+
* second `open` attaches to the resource the first one created (the host
|
|
107
|
+
* enforces this), so the agent's tool and the user's pane share one instance.
|
|
108
|
+
*/
|
|
109
|
+
export interface SurfaceDef {
|
|
110
|
+
readonly kind: SurfaceKind;
|
|
111
|
+
readonly description?: string;
|
|
112
|
+
open(ctx: SurfaceContext): Promise<SurfaceInstance> | SurfaceInstance;
|
|
113
|
+
/** Optional runtime gate (default: always available). */
|
|
114
|
+
isAvailable?(ctx: SurfaceContext): Promise<SurfaceAvailability> | SurfaceAvailability;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Identity helper mirroring `defineTool` / `defineChannel`. */
|
|
118
|
+
export function defineSurface(def: SurfaceDef): SurfaceDef {
|
|
119
|
+
return def;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read-only registry of surface defs contributed by plugins. Implementation
|
|
124
|
+
* lives in @moxxy/core.
|
|
125
|
+
*/
|
|
126
|
+
export interface SurfaceRegistry {
|
|
127
|
+
list(): ReadonlyArray<SurfaceDef>;
|
|
128
|
+
get(kind: SurfaceKind): SurfaceDef | undefined;
|
|
129
|
+
has(kind: SurfaceKind): boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Wire-friendly surface descriptor for `surface.list` (no functions). */
|
|
133
|
+
export interface SurfaceInfo {
|
|
134
|
+
readonly kind: SurfaceKind;
|
|
135
|
+
readonly description?: string;
|
|
136
|
+
readonly available: boolean;
|
|
137
|
+
/** Why it is unavailable, when `available` is false. */
|
|
138
|
+
readonly reason?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Result of opening a surface — its id + a catch-up snapshot for the viewer. */
|
|
142
|
+
export interface OpenSurfaceResult {
|
|
143
|
+
readonly surfaceId: string;
|
|
144
|
+
readonly kind: SurfaceKind;
|
|
145
|
+
readonly snapshot?: unknown;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The runtime manager of open surface instances, present on an in-process
|
|
150
|
+
* `Session`. The runner server drives it on behalf of attached clients; the
|
|
151
|
+
* agent's tools reach the SAME underlying resources through plugin module state.
|
|
152
|
+
* Output from every open instance is multiplexed through {@link onData}.
|
|
153
|
+
*/
|
|
154
|
+
export interface SurfaceHost {
|
|
155
|
+
/** Available surface kinds with their current availability. */
|
|
156
|
+
list(): Promise<ReadonlyArray<SurfaceInfo>>;
|
|
157
|
+
/** Open (or attach to the shared) instance for a kind. */
|
|
158
|
+
open(kind: SurfaceKind): Promise<OpenSurfaceResult>;
|
|
159
|
+
/** Route a viewer message to an open instance. No-op if it isn't open. */
|
|
160
|
+
input(surfaceId: string, msg: SurfaceInputMessage): Promise<void>;
|
|
161
|
+
/** Resize an open instance. No-op if it isn't open or isn't size-aware. */
|
|
162
|
+
resize(surfaceId: string, size: SurfaceSize): Promise<void>;
|
|
163
|
+
/** Close one open instance. */
|
|
164
|
+
close(surfaceId: string): Promise<void>;
|
|
165
|
+
/**
|
|
166
|
+
* Subscribe to outbound frames from EVERY open instance (multiplexed by
|
|
167
|
+
* `surfaceId`). Returns an unsubscribe fn. The runner subscribes once and
|
|
168
|
+
* broadcasts each frame as a `surface.data` notification.
|
|
169
|
+
*/
|
|
170
|
+
onData(cb: (msg: SurfaceDataMessage) => void): () => void;
|
|
171
|
+
/** Close every open instance (session teardown). */
|
|
172
|
+
closeAll(): Promise<void>;
|
|
173
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
diffGutterNo,
|
|
4
|
+
fileDiffSummary,
|
|
5
|
+
fileDiffVerb,
|
|
6
|
+
isFileDiffDisplay,
|
|
7
|
+
isToolDisplayResult,
|
|
8
|
+
toDiffRows,
|
|
9
|
+
type FileDiffDisplay,
|
|
10
|
+
} from './tool-display.js';
|
|
11
|
+
|
|
12
|
+
const diff = (over: Partial<FileDiffDisplay> = {}): FileDiffDisplay => ({
|
|
13
|
+
kind: 'file-diff',
|
|
14
|
+
path: 'src/a.ts',
|
|
15
|
+
mode: 'update',
|
|
16
|
+
added: 10,
|
|
17
|
+
removed: 1,
|
|
18
|
+
hunks: [],
|
|
19
|
+
...over,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('fileDiffSummary', () => {
|
|
23
|
+
it('pluralizes and joins', () => {
|
|
24
|
+
expect(fileDiffSummary(diff())).toBe('Added 10 lines, removed 1 line');
|
|
25
|
+
});
|
|
26
|
+
it('handles add-only / remove-only', () => {
|
|
27
|
+
expect(fileDiffSummary(diff({ removed: 0 }))).toBe('Added 10 lines');
|
|
28
|
+
expect(fileDiffSummary(diff({ added: 0, removed: 3 }))).toBe('Removed 3 lines');
|
|
29
|
+
});
|
|
30
|
+
it('notes truncation', () => {
|
|
31
|
+
expect(fileDiffSummary(diff({ truncated: true }))).toContain('truncated');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('fileDiffVerb', () => {
|
|
36
|
+
it('maps mode to verb', () => {
|
|
37
|
+
expect(fileDiffVerb(diff({ mode: 'create' }))).toBe('Create');
|
|
38
|
+
expect(fileDiffVerb(diff({ mode: 'update' }))).toBe('Update');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('diffGutterNo', () => {
|
|
43
|
+
it('uses old number for deletions, new otherwise', () => {
|
|
44
|
+
expect(diffGutterNo({ kind: 'del', text: '', oldNo: 5, newNo: undefined })).toBe(5);
|
|
45
|
+
expect(diffGutterNo({ kind: 'add', text: '', newNo: 7 })).toBe(7);
|
|
46
|
+
expect(diffGutterNo({ kind: 'context', text: '', oldNo: 3, newNo: 4 })).toBe(4);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('toDiffRows', () => {
|
|
51
|
+
it('inserts a gap marker between hunks', () => {
|
|
52
|
+
const d = diff({
|
|
53
|
+
hunks: [
|
|
54
|
+
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: [{ kind: 'context', text: 'a' }] },
|
|
55
|
+
{ oldStart: 9, oldLines: 1, newStart: 9, newLines: 1, lines: [{ kind: 'context', text: 'b' }] },
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
const rows = toDiffRows(d);
|
|
59
|
+
expect(rows.map((r) => r.kind)).toEqual(['context', 'gap', 'context']);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('guards', () => {
|
|
64
|
+
it('isFileDiffDisplay / isToolDisplayResult', () => {
|
|
65
|
+
expect(isFileDiffDisplay(diff())).toBe(true);
|
|
66
|
+
expect(isFileDiffDisplay({ kind: 'other' })).toBe(false);
|
|
67
|
+
expect(isToolDisplayResult({ forModel: 'x', display: diff() })).toBe(true);
|
|
68
|
+
expect(isToolDisplayResult({ forModel: 'x' })).toBe(false);
|
|
69
|
+
expect(isToolDisplayResult('wrote 12 chars')).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-agnostic "rich tool result" payloads.
|
|
3
|
+
*
|
|
4
|
+
* A tool whose result is more than a line of text (a file diff, eventually a
|
|
5
|
+
* table or chart) returns a `ToolDisplayResult`: a short `forModel` string the
|
|
6
|
+
* model sees, plus a structured `display` every channel can render natively.
|
|
7
|
+
* The projection layer (see `mode-helpers.ts`) sends ONLY `forModel` to the
|
|
8
|
+
* model, so the rich payload never bloats the context window.
|
|
9
|
+
*
|
|
10
|
+
* The first (and currently only) display kind is `file-diff`, emitted by the
|
|
11
|
+
* built-in Write/Edit tools. It carries the changed slices of a file (a few
|
|
12
|
+
* lines of context around each edit) — never the whole file — so the payload
|
|
13
|
+
* stays bounded regardless of file size. Channels render it as a classic diff:
|
|
14
|
+
* line numbers, +/- markers, green/red line backgrounds.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One rendered line of a diff. */
|
|
18
|
+
export interface DiffLine {
|
|
19
|
+
readonly kind: 'context' | 'add' | 'del';
|
|
20
|
+
readonly text: string;
|
|
21
|
+
/** 1-based line number in the OLD file (del + context lines). */
|
|
22
|
+
readonly oldNo?: number;
|
|
23
|
+
/** 1-based line number in the NEW file (add + context lines). */
|
|
24
|
+
readonly newNo?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A contiguous changed region plus its surrounding context lines. */
|
|
28
|
+
export interface DiffHunk {
|
|
29
|
+
readonly oldStart: number;
|
|
30
|
+
readonly oldLines: number;
|
|
31
|
+
readonly newStart: number;
|
|
32
|
+
readonly newLines: number;
|
|
33
|
+
readonly lines: ReadonlyArray<DiffLine>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Structured diff for a single file write/edit. */
|
|
37
|
+
export interface FileDiffDisplay {
|
|
38
|
+
readonly kind: 'file-diff';
|
|
39
|
+
/** Display path — relative to cwd when possible, else absolute. */
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly mode: 'create' | 'update';
|
|
42
|
+
readonly added: number;
|
|
43
|
+
readonly removed: number;
|
|
44
|
+
readonly hunks: ReadonlyArray<DiffHunk>;
|
|
45
|
+
/** Set when a very large diff was capped (some hunks/lines dropped). */
|
|
46
|
+
readonly truncated?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Extensible union of structured tool-result payloads. */
|
|
50
|
+
export type ToolDisplay = FileDiffDisplay;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The shape a tool returns when it wants a rich, channel-rendered result.
|
|
54
|
+
* `forModel` is the only thing the model sees; `display` is for channels.
|
|
55
|
+
*/
|
|
56
|
+
export interface ToolDisplayResult {
|
|
57
|
+
readonly forModel: string;
|
|
58
|
+
readonly display: ToolDisplay;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isToolDisplayResult(x: unknown): x is ToolDisplayResult {
|
|
62
|
+
return (
|
|
63
|
+
typeof x === 'object' &&
|
|
64
|
+
x !== null &&
|
|
65
|
+
typeof (x as { forModel?: unknown }).forModel === 'string' &&
|
|
66
|
+
isToolDisplay((x as { display?: unknown }).display)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isToolDisplay(x: unknown): x is ToolDisplay {
|
|
71
|
+
return isFileDiffDisplay(x);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isFileDiffDisplay(x: unknown): x is FileDiffDisplay {
|
|
75
|
+
return (
|
|
76
|
+
typeof x === 'object' &&
|
|
77
|
+
x !== null &&
|
|
78
|
+
(x as { kind?: unknown }).kind === 'file-diff' &&
|
|
79
|
+
Array.isArray((x as { hunks?: unknown }).hunks)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Human summary, e.g. "Added 10 lines, removed 1 line". */
|
|
84
|
+
export function fileDiffSummary(d: FileDiffDisplay): string {
|
|
85
|
+
const plural = (n: number, w: string): string => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
86
|
+
const parts: string[] = [];
|
|
87
|
+
if (d.added > 0 || d.removed === 0) parts.push(`Added ${plural(d.added, 'line')}`);
|
|
88
|
+
if (d.removed > 0) parts.push(`${parts.length ? 'removed' : 'Removed'} ${plural(d.removed, 'line')}`);
|
|
89
|
+
let summary = parts.join(', ');
|
|
90
|
+
if (d.truncated) summary += ' (diff truncated)';
|
|
91
|
+
return summary;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Verb for the diff header — "Create" for new files, else "Update". */
|
|
95
|
+
export function fileDiffVerb(d: FileDiffDisplay): string {
|
|
96
|
+
return d.mode === 'create' ? 'Create' : 'Update';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Gutter number a renderer should show for a line (new number, or old for deletions). */
|
|
100
|
+
export function diffGutterNo(line: DiffLine): number | undefined {
|
|
101
|
+
return line.kind === 'del' ? line.oldNo : line.newNo;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A renderable row: either a diff line or a gap marker between hunks. */
|
|
105
|
+
export type DiffRow = DiffLine | { readonly kind: 'gap' };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Flatten a file diff's hunks into a single row list, inserting a `gap` marker
|
|
109
|
+
* between non-contiguous hunks (channels render it as a `⋯` separator).
|
|
110
|
+
*/
|
|
111
|
+
export function toDiffRows(d: FileDiffDisplay): DiffRow[] {
|
|
112
|
+
const rows: DiffRow[] = [];
|
|
113
|
+
d.hunks.forEach((hunk, i) => {
|
|
114
|
+
if (i > 0) rows.push({ kind: 'gap' });
|
|
115
|
+
for (const line of hunk.lines) rows.push(line);
|
|
116
|
+
});
|
|
117
|
+
return rows;
|
|
118
|
+
}
|