@silo-code/sdk 0.21.0 → 0.22.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.
Files changed (45) hide show
  1. package/dist/editor-service.d.ts +135 -3
  2. package/dist/editor-service.d.ts.map +1 -1
  3. package/dist/event.d.ts +28 -0
  4. package/dist/event.d.ts.map +1 -0
  5. package/dist/event.js +2 -0
  6. package/dist/event.js.map +1 -0
  7. package/dist/file-service.d.ts +33 -2
  8. package/dist/file-service.d.ts.map +1 -1
  9. package/dist/index.d.ts +15 -7
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +15 -5
  12. package/dist/index.js.map +1 -1
  13. package/dist/network-service.d.ts +46 -2
  14. package/dist/network-service.d.ts.map +1 -1
  15. package/dist/path.d.ts +62 -0
  16. package/dist/path.d.ts.map +1 -0
  17. package/dist/path.js +150 -0
  18. package/dist/path.js.map +1 -0
  19. package/dist/process-service.d.ts +24 -4
  20. package/dist/process-service.d.ts.map +1 -1
  21. package/dist/processes-service.d.ts +1 -1
  22. package/dist/search-service.d.ts +11 -0
  23. package/dist/search-service.d.ts.map +1 -1
  24. package/dist/system-service.d.ts +2 -2
  25. package/dist/terminal-service.d.ts +37 -0
  26. package/dist/terminal-service.d.ts.map +1 -1
  27. package/dist/types.d.ts +12 -5
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/workspace-service.d.ts +13 -0
  30. package/dist/workspace-service.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/editor-service.ts +141 -5
  33. package/src/event.ts +28 -0
  34. package/src/file-service.ts +33 -2
  35. package/src/index.ts +24 -5
  36. package/src/network-service.ts +51 -2
  37. package/src/path.test.ts +135 -0
  38. package/src/path.ts +188 -0
  39. package/src/process-service.ts +24 -4
  40. package/src/processes-service.ts +1 -1
  41. package/src/search-service.ts +11 -0
  42. package/src/system-service.ts +2 -2
  43. package/src/terminal-service.ts +40 -0
  44. package/src/types.ts +12 -5
  45. package/src/workspace-service.ts +13 -0
package/src/event.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { Disposable } from "./types";
2
+
3
+ /**
4
+ * A subscribable event: call it with a listener and receive a
5
+ * {@link Disposable} that cancels the subscription. Modeled on VS Code's
6
+ * `Event<T>` convention.
7
+ *
8
+ * Services that emit events expose a member typed as `Event<T>` — the
9
+ * consuming extension calls it directly and pushes the returned disposable
10
+ * onto `ctx.subscriptions`:
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * ctx.subscriptions.push(
15
+ * ctx.editors.onDidSave(({ editorId, filePath }) => {
16
+ * console.log("saved", filePath);
17
+ * }),
18
+ * );
19
+ * ```
20
+ *
21
+ * **Host-side:** the matching emitter (`EventEmitter<T>`) lives in the
22
+ * extension-host package (not the SDK). Only the subscribable `Event<T>` type
23
+ * is public — extensions never construct emitters.
24
+ *
25
+ * @category Core Types
26
+ * @public
27
+ */
28
+ export type Event<T> = (listener: (e: T) => void) => Disposable;
@@ -82,15 +82,46 @@ export interface FileService {
82
82
  readBytes(path: string): Promise<ArrayBuffer>;
83
83
  /** List a directory's immediate entries. */
84
84
  readDir(path: string): Promise<FileMeta[]>;
85
- /** Resolve true if a file or directory exists at `path`. */
85
+ /**
86
+ * Resolve true if a file or directory exists at `path`. Prefer
87
+ * {@link FileService.stat} when you also need the entry's metadata — `stat`
88
+ * returning non-`null` subsumes this check in one call.
89
+ */
86
90
  pathExists(path: string): Promise<boolean>;
91
+ /**
92
+ * Metadata for a single path, following symlinks, or `null` if nothing
93
+ * exists there. Resolving `null` (rather than rejecting) for an absent path
94
+ * is deliberate — it makes `stat` a one-call replacement for
95
+ * {@link FileService.pathExists} that also returns size / mtime / type.
96
+ * Rejects only on a real I/O error (e.g. a permission failure).
97
+ */
98
+ stat(path: string): Promise<FileMeta | null>;
87
99
  /** Write UTF-8 text to a file, creating or overwriting it. */
88
100
  writeText(path: string, content: string): Promise<void>;
101
+ /**
102
+ * Write raw bytes to a file, creating or overwriting it (and creating any
103
+ * missing parent directories). The byte-oriented counterpart to
104
+ * {@link FileService.writeText} / {@link FileService.readBytes} — use it for
105
+ * binary assets (images, archives) where `writeText` would corrupt the data.
106
+ */
107
+ writeBytes(path: string, data: ArrayBuffer | Uint8Array): Promise<void>;
89
108
  /** Create a directory (and any missing parents). */
90
109
  createDir(path: string): Promise<void>;
110
+ /**
111
+ * Copy a file or directory from `src` to `dest`, recursively for
112
+ * directories, creating any missing parent directories. Requires read access
113
+ * to `src` and write access to `dest` (both are workspace-scoped). Overwrites
114
+ * existing files at the destination.
115
+ */
116
+ copy(src: string, dest: string): Promise<void>;
91
117
  /** Rename / move a file or directory. */
92
118
  rename(oldPath: string, newPath: string): Promise<void>;
93
- /** Delete a file or directory. */
119
+ /**
120
+ * **Permanently** delete a file or directory (directories are removed
121
+ * recursively). This does **not** move the entry to the OS trash/recycle
122
+ * bin — the delete is irreversible, so confirm destructive removals with the
123
+ * user first. Rejects if the path does not exist.
124
+ */
94
125
  delete(path: string): Promise<void>;
95
126
  /** Reveal a path in the OS file manager (Finder / Explorer). */
96
127
  reveal(path: string): Promise<void>;
package/src/index.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  /**
2
- * The public Silo extension API surface — the single curated entry point an
3
- * extension author imports from. This is the seed of the future `@silo-code/sdk`
4
- * package: it re-exports **only** the blessed, permanently supported types.
5
- * Anything not re-exported here is host-internal and may change without notice.
2
+ * The `@silo-code/sdk` public surface — the single curated entry point an
3
+ * extension author imports from. Re-exports **only** the blessed, permanently
4
+ * supported types and runtime helpers. Anything not re-exported here is
5
+ * host-internal and may change without notice.
6
6
  *
7
- * It is also the entry point the API-reference generator (TypeDoc) reads, so
7
+ * **What's here:** the types-first extension contract (see `types.ts`) plus a
8
+ * small set of blessed runtime helpers (`Tooltip`, `useFocusGroup`,
9
+ * `useServiceState`, `DND_MIME`, `PathDeniedError`, `NetworkError`). The SDK
10
+ * peer-depends on React 19; changes to the runtime helpers can be breaking
11
+ * even when the types are unchanged.
12
+ *
13
+ * This is also the entry point the API-reference generator (TypeDoc) reads, so
8
14
  * the published reference is exactly this surface — no more, no less.
9
15
  *
10
16
  * @packageDocumentation
@@ -66,6 +72,9 @@ export type {
66
72
  DiffContent,
67
73
  DiffContentRequest,
68
74
  DiffContentProvider,
75
+ ActiveEditorInfo,
76
+ EditorsState,
77
+ EditorSaveEvent,
69
78
  } from "./editor-service";
70
79
  export type {
71
80
  LayoutService,
@@ -167,6 +176,7 @@ export type {
167
176
  NetworkService,
168
177
  NetworkRequestOptions,
169
178
  NetworkResponse,
179
+ NetworkBytesResponse,
170
180
  } from "./network-service";
171
181
 
172
182
  // Static host-platform metadata: OS, CPU arch, and Silo version.
@@ -180,6 +190,15 @@ export type { LogService, LogLevel } from "./output-service";
180
190
  // Context keys referenced by `when` predicates on menu items / keybindings.
181
191
  export type { ContextKeys } from "./context-keys";
182
192
 
193
+ // Typed event primitive: a subscribable Event<T> that returns a Disposable.
194
+ // The matching host-side emitter is internal to the extension-host package.
195
+ export type { Event } from "./event";
196
+
197
+ // Pure path utilities — cross-platform replacement for `node:path` (banned in
198
+ // extensions). All outputs use forward-slash separators. Both "/" and "\" are
199
+ // accepted as input. Exported as a namespaced `path` object.
200
+ export { path } from "./path";
201
+
183
202
  // Tooltip — the same styled hover popup the host uses in the status bar.
184
203
  // Extensions use this instead of native `title` attributes to match host chrome.
185
204
  export { Tooltip } from "./Tooltip";
@@ -44,8 +44,12 @@ export interface NetworkRequestOptions {
44
44
  method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "PATCH";
45
45
  /** Request headers to send. */
46
46
  headers?: Record<string, string>;
47
- /** Request body (string). Only meaningful for methods that carry a body. */
48
- body?: string;
47
+ /**
48
+ * Request body. A string is sent as-is; an `ArrayBuffer` / `Uint8Array` is
49
+ * sent as raw bytes (e.g. uploading a file). Only meaningful for methods that
50
+ * carry a body.
51
+ */
52
+ body?: string | ArrayBuffer | Uint8Array;
49
53
  /** Follow HTTP redirects. Defaults to `true`. */
50
54
  followRedirects?: boolean;
51
55
  /** Request timeout in milliseconds. Omit for the platform default (~30 s). */
@@ -72,6 +76,24 @@ export interface NetworkResponse {
72
76
  finalUrl: string;
73
77
  }
74
78
 
79
+ /**
80
+ * Response from {@link NetworkService.fetchBytes} — identical to
81
+ * {@link NetworkResponse} but with the body delivered as raw bytes.
82
+ *
83
+ * @category Core Types
84
+ * @public
85
+ */
86
+ export interface NetworkBytesResponse {
87
+ /** HTTP status code. */
88
+ status: number;
89
+ /** Response headers, lowercased (multi-value joined with `", "`). */
90
+ headers: Record<string, string>;
91
+ /** Response body as raw bytes. */
92
+ body: ArrayBuffer;
93
+ /** Final URL after redirects. */
94
+ finalUrl: string;
95
+ }
96
+
75
97
  /**
76
98
  * Server-side HTTP client exposed as {@link ExtensionContext.net}. Requests
77
99
  * run in the Rust backend via `reqwest`, so they bypass the browser's CORS
@@ -104,6 +126,33 @@ export interface NetworkService {
104
126
  */
105
127
  fetch(url: string, options?: NetworkRequestOptions): Promise<NetworkResponse>;
106
128
 
129
+ /**
130
+ * Like {@link NetworkService.fetch}, but resolves the response body as raw
131
+ * bytes ({@link NetworkBytesResponse}) instead of decoding it as UTF-8 text —
132
+ * for downloading images, archives, or any binary payload.
133
+ *
134
+ * @param url - The URL to fetch.
135
+ * @param options - Method, headers, body, redirect and timeout controls. The
136
+ * body may itself be binary (`ArrayBuffer` / `Uint8Array`).
137
+ * @throws {@link NetworkError} if the request fails.
138
+ *
139
+ * @remarks
140
+ * The body rides Tauri's binary IPC channel (no base64), but the whole
141
+ * response is still buffered in memory on both sides — suitable for typical
142
+ * asset downloads (up to a few tens of MB), not for streaming multi-hundred-MB
143
+ * files.
144
+ *
145
+ * @example
146
+ * ```ts
147
+ * const { body } = await ctx.net.fetchBytes("https://example.com/logo.png");
148
+ * await ctx.files.writeBytes("logo.png", body);
149
+ * ```
150
+ */
151
+ fetchBytes(
152
+ url: string,
153
+ options?: NetworkRequestOptions,
154
+ ): Promise<NetworkBytesResponse>;
155
+
107
156
  /**
108
157
  * Send a `HEAD` request and return only the response headers — no body is
109
158
  * downloaded. More efficient than {@link NetworkService.fetch} when you only
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { path } from "./path";
3
+
4
+ describe("path.normalize", () => {
5
+ it("collapses dot segments", () =>
6
+ expect(path.normalize("a/./b")).toBe("a/b"));
7
+ it("resolves dot-dot segments", () =>
8
+ expect(path.normalize("a/b/../c")).toBe("a/c"));
9
+ it("converts backslash to forward-slash", () =>
10
+ expect(path.normalize("a\\b")).toBe("a/b"));
11
+ it("collapses duplicate slashes", () =>
12
+ expect(path.normalize("a//b")).toBe("a/b"));
13
+ it("preserves POSIX absolute", () =>
14
+ expect(path.normalize("/a/b")).toBe("/a/b"));
15
+ it("handles Windows drive", () =>
16
+ expect(path.normalize("C:\\a\\b")).toBe("C:/a/b"));
17
+ it("handles Windows drive with forward-slash", () =>
18
+ expect(path.normalize("C:/a/b")).toBe("C:/a/b"));
19
+ it("stays at root when dot-dot exceeds root", () =>
20
+ expect(path.normalize("/a/../..")).toBe("/"));
21
+ it("returns dot for empty-ish relative path", () =>
22
+ expect(path.normalize("a/..")).toBe("."));
23
+ it("UNC path is preserved", () =>
24
+ expect(path.normalize("\\\\server\\share\\foo")).toBe(
25
+ "//server/share/foo",
26
+ ));
27
+ });
28
+
29
+ describe("path.join", () => {
30
+ it("joins two segments", () => expect(path.join("a", "b")).toBe("a/b"));
31
+ it("joins many segments", () =>
32
+ expect(path.join("a", "b", "c")).toBe("a/b/c"));
33
+ it("resolves dot-dot across join", () =>
34
+ expect(path.join("a/b", "../c")).toBe("a/c"));
35
+ it("preserves leading slash from first segment", () =>
36
+ expect(path.join("/a", "b")).toBe("/a/b"));
37
+ it("converts backslash", () => expect(path.join("a\\b", "c")).toBe("a/b/c"));
38
+ it("returns dot for no arguments", () => expect(path.join()).toBe("."));
39
+ it("ignores empty string segments", () =>
40
+ expect(path.join("a", "", "b")).toBe("a/b"));
41
+ it("handles Windows drive", () =>
42
+ expect(path.join("C:\\a", "b")).toBe("C:/a/b"));
43
+ it('join("/", rel) produces "/rel" not "//rel" (UNC)', () =>
44
+ expect(path.join("/", "linked.md")).toBe("/linked.md"));
45
+ it("join of POSIX root with nested rel stays absolute", () =>
46
+ expect(path.join("/", "a/b/../c")).toBe("/a/c"));
47
+ });
48
+
49
+ describe("path.dirname", () => {
50
+ it("returns parent dir", () => expect(path.dirname("/a/b/c")).toBe("/a/b"));
51
+ it("returns slash for top-level file", () =>
52
+ expect(path.dirname("/a")).toBe("/"));
53
+ it("returns dot for bare filename", () =>
54
+ expect(path.dirname("a")).toBe("."));
55
+ it("handles Windows drive", () =>
56
+ expect(path.dirname("C:\\a\\b")).toBe("C:/a"));
57
+ it("returns drive root for top-level", () =>
58
+ expect(path.dirname("C:\\a")).toBe("C:/"));
59
+ it("resolves trailing slash before computing", () =>
60
+ expect(path.dirname("/a/b/")).toBe("/a"));
61
+ it("handles UNC path", () =>
62
+ expect(path.dirname("//server/share/foo")).toBe("//server/share"));
63
+ it("returns dot for relative bare name", () =>
64
+ expect(path.dirname("file.ts")).toBe("."));
65
+ it("returns relative parent", () => expect(path.dirname("a/b")).toBe("a"));
66
+ });
67
+
68
+ describe("path.basename", () => {
69
+ it("returns last segment", () =>
70
+ expect(path.basename("/a/b/file.ts")).toBe("file.ts"));
71
+ it("strips provided extension", () =>
72
+ expect(path.basename("file.ts", ".ts")).toBe("file"));
73
+ it("does not strip if extension doesn't match", () =>
74
+ expect(path.basename("file.ts", ".js")).toBe("file.ts"));
75
+ it("handles trailing slash", () => expect(path.basename("/a/b/")).toBe("b"));
76
+ it("handles Windows path", () =>
77
+ expect(path.basename("C:\\a\\b.txt")).toBe("b.txt"));
78
+ it("returns empty string for root", () =>
79
+ expect(path.basename("/")).toBe(""));
80
+ it("returns bare name", () =>
81
+ expect(path.basename("file.ts")).toBe("file.ts"));
82
+ });
83
+
84
+ describe("path.extname", () => {
85
+ it("returns extension with dot", () =>
86
+ expect(path.extname("file.ts")).toBe(".ts"));
87
+ it("returns last extension only", () =>
88
+ expect(path.extname("file.test.ts")).toBe(".ts"));
89
+ it("returns empty for no extension", () =>
90
+ expect(path.extname("file")).toBe(""));
91
+ it("returns empty for dotfile (dot at position 0)", () =>
92
+ expect(path.extname(".gitignore")).toBe(""));
93
+ it("returns dot for trailing dot", () =>
94
+ expect(path.extname("file.")).toBe("."));
95
+ it("handles full path", () =>
96
+ expect(path.extname("/a/b/file.md")).toBe(".md"));
97
+ });
98
+
99
+ describe("path.isAbsolute", () => {
100
+ it("true for POSIX absolute", () =>
101
+ expect(path.isAbsolute("/foo")).toBe(true));
102
+ it("true for Windows drive with backslash", () =>
103
+ expect(path.isAbsolute("C:\\foo")).toBe(true));
104
+ it("true for Windows drive with forward-slash", () =>
105
+ expect(path.isAbsolute("C:/foo")).toBe(true));
106
+ it("true for UNC backslash", () =>
107
+ expect(path.isAbsolute("\\\\server\\share")).toBe(true));
108
+ it("true for UNC forward-slash", () =>
109
+ expect(path.isAbsolute("//server/share")).toBe(true));
110
+ it("false for relative path", () =>
111
+ expect(path.isAbsolute("foo/bar")).toBe(false));
112
+ it("false for drive-relative (no slash)", () =>
113
+ expect(path.isAbsolute("C:foo")).toBe(false));
114
+ it("false for bare filename", () =>
115
+ expect(path.isAbsolute("file.ts")).toBe(false));
116
+ });
117
+
118
+ describe("path.relative", () => {
119
+ it("computes sibling relative path", () =>
120
+ expect(path.relative("/a/b", "/a/c")).toBe("../c"));
121
+ it("returns dot for same path", () =>
122
+ expect(path.relative("/a/b", "/a/b")).toBe("."));
123
+ it("returns descendant path", () =>
124
+ expect(path.relative("/a", "/a/b/c")).toBe("b/c"));
125
+ it("returns parent path", () =>
126
+ expect(path.relative("/a/b/c", "/a")).toBe("../.."));
127
+ it("handles root to subpath", () =>
128
+ expect(path.relative("/", "/a/b")).toBe("a/b"));
129
+ it("returns normalized `to` for different Windows drives", () =>
130
+ expect(path.relative("C:/a", "D:/b")).toBe("D:/b"));
131
+ it("same drive computes relative", () =>
132
+ expect(path.relative("C:/a/b", "C:/a/c")).toBe("../c"));
133
+ it("handles backslash inputs", () =>
134
+ expect(path.relative("C:\\a\\b", "C:\\a\\c")).toBe("../c"));
135
+ });
package/src/path.ts ADDED
@@ -0,0 +1,188 @@
1
+ // Pure path utilities for extensions. A cross-platform replacement for
2
+ // `node:path`, which extensions are banned from importing (platform ban).
3
+ // All outputs use forward-slash separators — the form FileService accepts on
4
+ // every platform. Both "/" and "\" are accepted as inputs.
5
+
6
+ /** Normalize all separators to forward-slash. */
7
+ function normSep(p: string): string {
8
+ return p.replace(/\\/g, "/");
9
+ }
10
+
11
+ /** Extract a Windows drive-letter prefix ("C:") from a forward-slash path, or null. */
12
+ function parseDrive(p: string): string | null {
13
+ const m = /^([A-Za-z]):/.exec(p);
14
+ return m ? m[1].toUpperCase() + ":" : null;
15
+ }
16
+
17
+ /**
18
+ * Normalize segments of a forward-slash path: collapse duplicate slashes,
19
+ * resolve "." and ".." segments. Preserves leading "/" (POSIX absolute),
20
+ * "C:/" (Windows drive-letter), and "//" (UNC paths).
21
+ */
22
+ function normSegments(s: string): string {
23
+ const drive = parseDrive(s);
24
+ const afterDrive = drive ? s.slice(2) : s;
25
+ const isUnc = afterDrive.startsWith("//");
26
+ const isAbs = isUnc || afterDrive.startsWith("/");
27
+
28
+ const stack: string[] = [];
29
+ for (const seg of afterDrive.split("/")) {
30
+ if (seg === "" || seg === ".") continue;
31
+ if (seg === "..") {
32
+ if (stack.length > 0 && stack[stack.length - 1] !== "..") {
33
+ stack.pop();
34
+ } else if (!isAbs) {
35
+ stack.push("..");
36
+ }
37
+ } else {
38
+ stack.push(seg);
39
+ }
40
+ }
41
+
42
+ let result = stack.join("/");
43
+ if (isUnc) result = "//" + result;
44
+ else if (isAbs) result = "/" + result;
45
+ if (drive) result = drive + result;
46
+ if (!result) return isAbs ? (drive ? drive + "/" : "/") : ".";
47
+ return result;
48
+ }
49
+
50
+ /**
51
+ * Path utilities for extensions — a cross-platform replacement for
52
+ * `node:path`, which extensions are banned from importing. All output paths
53
+ * use forward-slash separators (the form {@link FileService} accepts on every
54
+ * platform). Both `/` and `\` are accepted as input separators.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import { path } from "@silo-code/sdk";
59
+ *
60
+ * const dir = path.dirname(filePath); // "/home/user/docs"
61
+ * const full = path.join(dir, "images/fig.png"); // "/home/user/docs/images/fig.png"
62
+ * const rel = path.relative(dir, full); // "images/fig.png"
63
+ * const ext = path.extname(full); // ".png"
64
+ * ```
65
+ *
66
+ * @category Core Types
67
+ * @public
68
+ */
69
+ export const path: {
70
+ /**
71
+ * Join path segments and normalize the result. Empty segments are ignored;
72
+ * `\` separators in any segment are treated as `/`.
73
+ */
74
+ join(...parts: string[]): string;
75
+ /**
76
+ * Return the directory portion of a path — everything up to (not including)
77
+ * the last `/`. Returns `"."` for a bare filename with no directory component.
78
+ */
79
+ dirname(p: string): string;
80
+ /**
81
+ * Return the final component of a path. If `ext` is supplied and the
82
+ * basename ends with that string, it is stripped from the result.
83
+ */
84
+ basename(p: string, ext?: string): string;
85
+ /**
86
+ * Return the extension of a path — the portion from the last `.` of the
87
+ * basename, including the dot. Returns `""` for paths with no extension and
88
+ * for dotfiles with no secondary extension (e.g. `".gitignore"` → `""`).
89
+ */
90
+ extname(p: string): string;
91
+ /**
92
+ * Compute the relative path from `from` to `to`. Both should be absolute
93
+ * paths on the same drive. When they are on different Windows drive letters,
94
+ * `to` (normalized) is returned unchanged — no relative path exists between
95
+ * drives.
96
+ */
97
+ relative(from: string, to: string): string;
98
+ /**
99
+ * Return `true` if `p` is an absolute path: starts with `/` (POSIX), has a
100
+ * drive letter followed by a slash (`C:/`, `C:\`), or is a UNC path
101
+ * (`\\server\share` / `//server/share`). Note: `C:foo` (drive-relative,
102
+ * no slash) is NOT absolute.
103
+ */
104
+ isAbsolute(p: string): boolean;
105
+ /**
106
+ * Normalize a path: convert `\` to `/`, collapse duplicate slashes, and
107
+ * resolve `.` and `..` segments.
108
+ */
109
+ normalize(p: string): string;
110
+ } = {
111
+ normalize(p) {
112
+ return normSegments(normSep(p));
113
+ },
114
+
115
+ join(...parts) {
116
+ const nonEmpty = parts.filter((p) => p.length > 0);
117
+ if (nonEmpty.length === 0) return ".";
118
+ // Concatenate without adding "/" when adjacent parts already supply the
119
+ // boundary, so join("/", "rel") → "/rel" rather than "//rel" (UNC).
120
+ const raw = nonEmpty.map(normSep).reduce((a, b) => {
121
+ if (a.endsWith("/") || b.startsWith("/")) return a + b;
122
+ return a + "/" + b;
123
+ });
124
+ return normSegments(raw);
125
+ },
126
+
127
+ dirname(p) {
128
+ const n = normSegments(normSep(p));
129
+ const drive = parseDrive(n);
130
+ const rest = drive ? n.slice(2) : n;
131
+ const i = rest.lastIndexOf("/");
132
+ if (i < 0) return drive ? drive + "." : ".";
133
+ if (i === 0) return drive ? drive + "/" : "/";
134
+ return drive ? drive + rest.slice(0, i) : rest.slice(0, i);
135
+ },
136
+
137
+ basename(p, ext) {
138
+ const n = normSep(p);
139
+ const segs = n.split("/");
140
+ let name = segs[segs.length - 1] ?? "";
141
+ // Trailing slash: "foo/" → last segment is "" → fall back to prior segment
142
+ if (name === "" && segs.length > 1) name = segs[segs.length - 2] ?? "";
143
+ if (ext !== undefined && name.endsWith(ext)) {
144
+ name = name.slice(0, name.length - ext.length);
145
+ }
146
+ return name;
147
+ },
148
+
149
+ extname(p) {
150
+ const base = path.basename(normSep(p));
151
+ const i = base.lastIndexOf(".");
152
+ // i === 0 means a dotfile like ".gitignore" — no extension
153
+ if (i <= 0) return "";
154
+ return base.slice(i);
155
+ },
156
+
157
+ isAbsolute(p) {
158
+ const n = normSep(p);
159
+ if (n.startsWith("//")) return true; // UNC (\\server\share or //server/share)
160
+ if (/^[A-Za-z]:\//.test(n)) return true; // Windows drive with slash (C:/ or C:\)
161
+ return n.startsWith("/"); // POSIX
162
+ },
163
+
164
+ relative(from, to) {
165
+ const nFrom = normSegments(normSep(from));
166
+ const nTo = normSegments(normSep(to));
167
+ const driveFrom = parseDrive(nFrom);
168
+ const driveTo = parseDrive(nTo);
169
+ // Can't express a relative path across different drives
170
+ if (driveFrom !== driveTo) return nTo;
171
+ const segsFrom = (driveFrom ? nFrom.slice(2) : nFrom)
172
+ .split("/")
173
+ .filter(Boolean);
174
+ const segsTo = (driveTo ? nTo.slice(2) : nTo).split("/").filter(Boolean);
175
+ let common = 0;
176
+ while (
177
+ common < segsFrom.length &&
178
+ common < segsTo.length &&
179
+ segsFrom[common] === segsTo[common]
180
+ ) {
181
+ common++;
182
+ }
183
+ const ups = segsFrom.length - common;
184
+ const downs = segsTo.slice(common);
185
+ const parts = [...Array<string>(ups).fill(".."), ...downs];
186
+ return parts.join("/") || ".";
187
+ },
188
+ };
@@ -67,6 +67,24 @@ export interface ProcessExecOptions {
67
67
  * {@link Permission}. First-party (bundled) extensions are unscoped.
68
68
  */
69
69
  cwd?: string;
70
+ /**
71
+ * Extra environment variables, **merged over** the host's environment (the
72
+ * command inherits the host env; these keys add to or override it). Use it to
73
+ * set things like `GIT_PAGER=cat` or a locale without clobbering `PATH`.
74
+ */
75
+ env?: Record<string, string>;
76
+ /**
77
+ * Kill the process and reject after this many milliseconds. The whole process
78
+ * group is terminated (not just the direct child), so shell wrappers don't
79
+ * leak orphans. The rejection is an `Error` whose `name` is `"AbortError"`.
80
+ */
81
+ timeoutMs?: number;
82
+ /**
83
+ * Abort handle. Aborting kills the process (and its group) and rejects the
84
+ * `exec` promise with an `Error` whose `name` is `"AbortError"` — the same
85
+ * shape as a `timeoutMs` expiry, so callers branch on `err.name`.
86
+ */
87
+ signal?: AbortSignal;
70
88
  }
71
89
 
72
90
  /**
@@ -116,10 +134,12 @@ export interface ProcessService {
116
134
  * interactive sessions instead.
117
135
  *
118
136
  * Runs **off the UI thread**, so a slow or network-bound command never
119
- * stutters the app. The returned promise rejects only if the process could
120
- * not be spawned (e.g. the command was not found); a command that runs but
121
- * exits non-zero **resolves** check {@link ProcessExecResult.code} and
122
- * {@link ProcessExecResult.stderr}.
137
+ * stutters the app. The returned promise rejects if the process could not be
138
+ * spawned (e.g. the command was not found), or if a
139
+ * {@link ProcessExecOptions.timeoutMs | timeout} / {@link ProcessExecOptions.signal | abort}
140
+ * fires (an `Error` with `name === "AbortError"`); a command that runs to
141
+ * completion but exits non-zero **resolves** — check
142
+ * {@link ProcessExecResult.code} and {@link ProcessExecResult.stderr}.
123
143
  *
124
144
  * @param command - Executable to run (resolved via `PATH`), e.g. `"git"`.
125
145
  * @param args - Arguments passed verbatim — not shell-interpreted, so no
@@ -88,7 +88,7 @@ export interface ProcessInfo {
88
88
  * // Notify when all agents in the workspace are idle.
89
89
  * const sub = ctx.processes.subscribe((procs) => {
90
90
  * const allIdle = procs.every((p) => p.atPrompt);
91
- * if (allIdle) ctx.ui.notify({ title: "All agents finished" });
91
+ * if (allIdle) ctx.ui.notify("info", "All agents finished");
92
92
  * });
93
93
  * ctx.subscriptions.push(sub);
94
94
  *
@@ -43,6 +43,17 @@ export interface SearchOptions {
43
43
  * is hit, the search stops early and {@link SearchResponse.truncated} is true.
44
44
  */
45
45
  maxResults?: number;
46
+ /**
47
+ * Cancel the search. When the signal aborts, the promise returned by
48
+ * {@link SearchService.search} rejects with an `Error` whose `name` is
49
+ * `"AbortError"` (the `fetch` convention — branch on `err.name`).
50
+ *
51
+ * Cancellation is observable immediately, but the native search may still run
52
+ * to completion in the background — its result is simply discarded. Use this
53
+ * to abandon a stale query (e.g. superseded by the next keystroke) rather than
54
+ * to reclaim native CPU the instant you abort.
55
+ */
56
+ signal?: AbortSignal;
46
57
  }
47
58
 
48
59
  /**
@@ -53,13 +53,13 @@ export interface SystemInfo {
53
53
  * ctx.subscriptions.push(
54
54
  * ctx.registerCommand({
55
55
  * id: "my.reveal-in-finder",
56
- * title: "Reveal in Finder",
56
+ * label: "Reveal in Finder",
57
57
  * run() { ... },
58
58
  * }),
59
59
  * );
60
60
  * }
61
61
  *
62
- * ctx.ui.notify({ title: `Running Silo ${siloVersion} on ${os}/${arch}` });
62
+ * ctx.ui.notify("info", `Running Silo ${siloVersion} on ${os}/${arch}`);
63
63
  * },
64
64
  * };
65
65
  * ```
@@ -98,11 +98,51 @@ export interface TerminalService {
98
98
  * Open a new terminal in a workspace (defaults to the active one). Returns the
99
99
  * created {@link TerminalRecord}; the PTY session spawns lazily when its tab
100
100
  * mounts.
101
+ *
102
+ * Returns `undefined` only when `input.workspaceId` is not given and there is
103
+ * no active workspace at the time of the call — in normal use this does not
104
+ * happen because activating any workspace happens before extensions run.
101
105
  */
102
106
  create(input?: CreateTerminalInput): TerminalRecord | undefined;
103
107
  /** Close and kill every terminal in a workspace (e.g. on workspace delete). */
104
108
  closeWorkspace(workspaceId: string): void;
105
109
 
110
+ /**
111
+ * Write text to a terminal's PTY as if the user typed it. By default a
112
+ * carriage return is appended so the line executes; pass `addNewline: false`
113
+ * to stage text without running it.
114
+ *
115
+ * Works even if the terminal tab has never been shown: the PTY spawns lazily
116
+ * on first mount, and `sendText` force-spawns it on demand (a later mount then
117
+ * attaches to that same session). No-op for an unknown `terminalId`.
118
+ *
119
+ * @param terminalId - The {@link TerminalRecord.id} to write to.
120
+ * @param text - The text to send.
121
+ * @param addNewline - Append a carriage return to execute. Defaults to `true`.
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * const term = ctx.terminals.create({ cwd: workspaceFolder });
126
+ * if (term) ctx.terminals.sendText(term.id, "npm run build");
127
+ * ```
128
+ */
129
+ sendText(terminalId: string, text: string, addNewline?: boolean): void;
130
+
131
+ /**
132
+ * Close one terminal tab and kill its PTY session. No-op if the id is unknown.
133
+ * To reap every terminal in a workspace at once use
134
+ * {@link TerminalService.closeWorkspace}.
135
+ */
136
+ close(terminalId: string): void;
137
+
138
+ /**
139
+ * Set a terminal's user-facing name ({@link TerminalRecord.customName}),
140
+ * shown on its tab and persisted across restarts. Passing an empty string
141
+ * clears the custom name, letting the PTY-derived title take over again.
142
+ * No-op for an unknown `terminalId`.
143
+ */
144
+ rename(terminalId: string, name: string): void;
145
+
106
146
  /**
107
147
  * Switch to the workspace containing this terminal and activate its tab in
108
148
  * the center dock. No-ops if the terminal id is unknown.