@neosh/api 0.3.0 → 0.4.1

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/src/index.ts CHANGED
@@ -17,6 +17,10 @@ import type { ApiError } from "./generated/ApiError";
17
17
  import type { ApiOk } from "./generated/ApiOk";
18
18
  import type { ApiResponse } from "./generated/ApiResponse";
19
19
  import type { BranchInfo } from "./generated/BranchInfo";
20
+ import type { Direction } from "./generated/Direction";
21
+ import type { PaneId } from "./generated/PaneId";
22
+ import type { TabId } from "./generated/TabId";
23
+ import type { TabInfo } from "./generated/TabInfo";
20
24
  import type { BufferId } from "./generated/BufferId";
21
25
  import type { Capability } from "./generated/Capability";
22
26
  import type { CommitInfo } from "./generated/CommitInfo";
@@ -32,6 +36,7 @@ import type { CredentialSource } from "./generated/CredentialSource";
32
36
  import type { CursorMotion } from "./generated/CursorMotion";
33
37
  import type { CursorShape } from "./generated/CursorShape";
34
38
  import type { SelectShape } from "./generated/SelectShape";
39
+ import type { ScrollAmount } from "./generated/ScrollAmount";
35
40
  import type { DiffTarget } from "./generated/DiffTarget";
36
41
  import type { Dock } from "./generated/Dock";
37
42
  import type { Gravity } from "./generated/Gravity";
@@ -132,12 +137,16 @@ import type { Viewport } from "./generated/Viewport";
132
137
  import type { WindowId } from "./generated/WindowId";
133
138
  import type { WindowInfo } from "./generated/WindowInfo";
134
139
  import type { WindowLayout } from "./generated/WindowLayout";
140
+ import type { ChecksState } from "./generated/ChecksState";
141
+ import type { PullRequest } from "./generated/PullRequest";
142
+ import type { PullState } from "./generated/PullState";
135
143
  import type { WorktreeInfo } from "./generated/WorktreeInfo";
136
144
 
137
145
  export type {
138
146
  AccountKind, Activity, ApiError, BranchInfo, Brand, BufferId, Capability, CommandEntry, CommitInfo,
139
147
  AgentCommand, AgentState, AgentSummary,
140
- Contribution, CredentialInfo, CredentialSource, CursorShape, DiffTarget, Dock, CursorMotion, ExtmarkId, ExtmarkInfo, ExtmarkOpts, FileChange, FileState, FloatConfig,
148
+ Contribution, CredentialInfo, CredentialSource, CursorShape, DiffTarget, Direction, Dock, CursorMotion, ExtmarkId, ExtmarkInfo, ExtmarkOpts, FileChange, FileState, FloatConfig,
149
+ PaneId, TabId, TabInfo,
141
150
  Gravity, HighlightDef, HighlightEntry, HighlightSpec, Hint, HlTarget, HookName, HookOutcome, HookPayload, InstanceConfig, KeyContext,
142
151
  AttachmentInfo,
143
152
  KeymapEntry, KeymapScope, MessageLevel, Mode, ModelEntry, ModelInfo, ModelSelection, ModelTier, NamespaceId,
@@ -148,12 +157,15 @@ export type {
148
157
  CostBasis, QuotaCredits, QuotaSample, QuotaSeverity, QuotaSnapshot, QuotaSource, QuotaWindow,
149
158
  InstallMethod, UpdateOutcome, UpdateStatus,
150
159
  UsageBucket, UsageHistory, UsageResolution, UsageScanSource,
151
- Rect, RepoInfo, RepoStatus, SelectShape, SessionId, SessionInfo, StatusAlign, StatusSegment, StopReason,
160
+ Rect, RepoInfo, RepoStatus, ScrollAmount, SelectShape, SessionId, SessionInfo, StatusAlign, StatusSegment, StopReason,
152
161
  SurfaceCell, SurfaceId, TextEdit, ToolCall, ToolDef, ToolResult, TurnRequest, Usage,
153
162
  NodeCapabilities, NodeId, NodeInfo, ProjectKey, RemoteProject, StreamEvent,
154
163
  SwarmAgent, SwarmNode, SwarmStranger,
155
164
  VarScope, ViewId, ViewInfo, Viewport,
156
165
  WindowId, WindowInfo, WindowLayout,
166
+ ChecksState,
167
+ PullRequest,
168
+ PullState,
157
169
  WorktreeInfo,
158
170
  };
159
171
 
@@ -363,6 +375,16 @@ export interface FloatOptions {
363
375
  border?: FloatConfig["border"];
364
376
  borderHl?: string;
365
377
  title?: string;
378
+ /**
379
+ * A strip on the bottom border: what the keys here do.
380
+ *
381
+ * On the border rather than in the buffer, because a key strip written as a row of content is one
382
+ * that scrolls away exactly when it is wanted, and is the first thing clipped on a terminal too
383
+ * short for the panel. It costs no content row and cannot be scrolled off.
384
+ *
385
+ * Clipped to the border's width like the title, so it is for a legend and not for prose.
386
+ */
387
+ footer?: string;
366
388
  closeOnBlur?: boolean;
367
389
  focusable?: boolean;
368
390
  /**
@@ -379,12 +401,34 @@ export interface FloatOptions {
379
401
  * control sheet. Not for a hint or a hover card.
380
402
  */
381
403
  modal?: boolean;
404
+ /**
405
+ * This panel is a place you can move in when it does not fit.
406
+ *
407
+ * You ask for a height and get whatever there is: `{ kind: "max", n: 30 }` on a twenty-row
408
+ * terminal is ten rows of panel and twenty rows of content drawn nowhere, with no key pointed at
409
+ * them and nothing on screen to say they exist. Set this and the workspace's scroll keys resolve
410
+ * here — `j`/`k`, `^D`/`^U`, `^F`/`^B`, `gg`/`G`, the arrows and the paging keys — along with the
411
+ * mouse wheel, and a bar appears down the right border while anything is hidden.
412
+ *
413
+ * They are ordinary bindings on `{ kind: "buf_kind", name: "neosh.scroll" }`, so `^Z` lists them
414
+ * and `init.ts` moves them. That scope sits *below* your buffer's own kind, so a key you bind on
415
+ * your panel is still yours — and above `global`, so it works under `modal` too.
416
+ *
417
+ * For a panel of rows you read: a key sheet, a diff, a status, a help screen. **Not** for one with
418
+ * a cursor of its own — a picker, a list — which already scrolls to keep the cursor on screen and
419
+ * whose filter would lose `j` and `G` to this.
420
+ */
421
+ scroll?: boolean;
382
422
  }
383
423
 
384
424
  export interface Neosh {
385
425
  readonly version: number;
386
426
  readonly buf: BufferApi;
387
427
  readonly win: WindowApi;
428
+ /** The panes of the main region. See {@link PaneApi}. */
429
+ readonly pane: PaneApi;
430
+ /** The tabs each terminal has. See {@link TabApi}. */
431
+ readonly tab: TabApi;
388
432
  readonly float: FloatApi;
389
433
  readonly edit: EditApi;
390
434
  readonly ns: NamespaceApi;
@@ -406,6 +450,8 @@ export interface Neosh {
406
450
  readonly opt: OptionApi;
407
451
  readonly state: StateApi;
408
452
  readonly vars: VarApi;
453
+ /** The directories somebody works in. See {@link ProjectApi}. */
454
+ readonly project: ProjectApi;
409
455
  readonly ext: ExtensionApi;
410
456
  readonly event: EventApi;
411
457
  readonly swarm: SwarmApi;
@@ -558,6 +604,84 @@ export interface BufferApi {
558
604
  ): Promise<Disposable>;
559
605
  }
560
606
 
607
+ /**
608
+ * The panes of the main region, and the tabs they live in.
609
+ *
610
+ * A pane is *where*, not *what*: it owns no buffer and draws nothing. You split one, then dock
611
+ * windows into it with {@link WindowApi.open} — which is why splitting a chat and splitting a
612
+ * terminal are the same two calls.
613
+ *
614
+ * Everything here is what the workspace's own `<C-w>` keys are built from. There is no private
615
+ * path: `<C-w>v` is `pane.split(await pane.active(), "right")` and nothing else, so a plugin that
616
+ * wants a different window manager can have one without forking anything.
617
+ */
618
+ export interface PaneApi {
619
+ /** The pane this terminal's keys belong to. */
620
+ active(view?: ViewId): Promise<PaneId>;
621
+ /**
622
+ * Divide a pane in two, and answer with the new one.
623
+ *
624
+ * The new pane is *empty* — nothing is drawn in it until something is docked into it. Focus does
625
+ * not move either: a plugin splitting to show you something and a person pressing a key to go
626
+ * and work there are different intentions, and only the second one wants the keyboard.
627
+ */
628
+ split(pane: PaneId, dir: Direction): Promise<PaneId>;
629
+ /**
630
+ * Close a pane and every window docked in it.
631
+ *
632
+ * Rejects on the last pane of the last tab: a terminal with nowhere to draw has no key that
633
+ * would bring a pane back.
634
+ */
635
+ close(pane: PaneId): Promise<void>;
636
+ focus(pane: PaneId): Promise<void>;
637
+ /**
638
+ * Move the keyboard to the nearest pane in a direction, and answer with it — or `null` at the
639
+ * edge of the layout, which is an ordinary outcome of holding a key down rather than an error.
640
+ * There is deliberately no wrap-around.
641
+ */
642
+ focusDir(dir: Direction, view?: ViewId): Promise<PaneId | null>;
643
+ /**
644
+ * Move the boundary on a pane's `dir` side. `delta` is in weight units — an even split is
645
+ * {@link WEIGHT} each — so a resize means the same thing at every terminal size and survives the
646
+ * terminal being resized under it. A pane with no boundary on that side is a no-op.
647
+ */
648
+ resize(pane: PaneId, dir: Direction, delta: number): Promise<void>;
649
+ /**
650
+ * Exchange two panes' places, keeping what is in each. The weights belong to the slots, so
651
+ * swapping into the wide one is how you make a pane wide.
652
+ */
653
+ swap(pane: PaneId, withPane: PaneId): Promise<void>;
654
+ /**
655
+ * Send a pane to the far edge of its tab — the counterpart to {@link PaneApi.focusDir}, which
656
+ * moves the keyboard rather than the pane. It arrives with an even share rather than the size it
657
+ * had: it is among different neighbours now, and carrying the old number over would make it the
658
+ * odd one out for a reason nobody could see.
659
+ */
660
+ moveToEdge(pane: PaneId, dir: Direction): Promise<void>;
661
+ /** Give every pane of the active tab an equal share, at every level. */
662
+ equalize(view?: ViewId): Promise<void>;
663
+ }
664
+
665
+ /** The tabs of one terminal. Each is a title and a tree of panes. */
666
+ export interface TabApi {
667
+ /** Every tab of a terminal, its panes, and which one is on screen. */
668
+ list(view?: ViewId): Promise<{ tabs: TabInfo[]; active: TabId }>;
669
+ /**
670
+ * Open a tab with one empty pane. `activate` goes to it — `false` is for putting work somewhere
671
+ * without moving the screen out from under whoever is reading it.
672
+ */
673
+ create(opts?: { title?: string; activate?: boolean; view?: ViewId }): Promise<TabId>;
674
+ /** Close a tab and every pane in it. Rejects on a terminal's last tab. */
675
+ close(tab: TabId): Promise<void>;
676
+ select(tab: TabId): Promise<void>;
677
+ /** Go `delta` tabs along, wrapping — a tab bar is a list you can see all of. */
678
+ step(delta: number, view?: ViewId): Promise<void>;
679
+ /** Move a tab along the bar, clamped at the ends. This is a drag; a drag that teleports is a bug. */
680
+ move(tab: TabId, to: number): Promise<void>;
681
+ /** Name a tab, or pass `null` to give it back its derived name. */
682
+ rename(tab: TabId, title: string | null): Promise<void>;
683
+ }
684
+
561
685
  export interface WindowApi {
562
686
  /**
563
687
  * `gravity` is which end short content settles against: `"start"` (the default) pins it to the
@@ -568,11 +692,17 @@ export interface WindowApi {
568
692
  * wrapped a long path would reflow every row below it — but a text field is prose and wants
569
693
  * this on. A bottom dock that wraps also grows to show the folded rows, `size` acting as its
570
694
  * floor.
695
+ *
696
+ * `pane` docks the window inside one pane of the main region rather than against the screen.
697
+ * The same four edges either way — a composer at the foot of a pane is this call with a `pane`,
698
+ * and a status line along the foot of the terminal is this call without one. Omit it for
699
+ * anything that belongs to the whole terminal: a sidebar docked into a pane would be a sidebar
700
+ * that disappears when you close that split.
571
701
  */
572
702
  open(
573
703
  buf: BufferId,
574
704
  dock: Dock,
575
- opts?: { size?: number; gravity?: Gravity; wrap?: boolean },
705
+ opts?: { size?: number; gravity?: Gravity; wrap?: boolean; pane?: PaneId },
576
706
  ): Promise<WindowId>;
577
707
  close(win: WindowId): Promise<void>;
578
708
  /**
@@ -596,6 +726,19 @@ export interface WindowApi {
596
726
  * its first row at `0`. Anything else shows the same thing either way.
597
727
  */
598
728
  scrollTo(win: WindowId, topLine: number | null): Promise<void>;
729
+ /**
730
+ * Move a window's scroll by a line, a half screen, a screen, or all the way to an end.
731
+ *
732
+ * What {@link scrollTo} cannot express, because the arithmetic is not yours to do: "half a screen"
733
+ * is counted in the buffer rows the frontend actually drew — which on a wrapping window is fewer
734
+ * than its height — and the floor is the last screenful rather than the last line, so the bottom
735
+ * is a full panel and not one row above an empty one. Both numbers are a frame old by the time
736
+ * {@link viewport} hands them to you; this reads them where they live.
737
+ *
738
+ * The keys bound on `neosh.scroll` run exactly this. Call it directly for a scroll a key did not
739
+ * ask for — following output, or a button on a panel of your own.
740
+ */
741
+ scroll(win: WindowId, amount: ScrollAmount): Promise<void>;
599
742
  /**
600
743
  * How big this window actually is, in cells.
601
744
  *
@@ -1059,12 +1202,52 @@ export interface GitApi {
1059
1202
  stage(paths?: string[]): Promise<void>;
1060
1203
  unstage(paths?: string[]): Promise<void>;
1061
1204
  commit(message: string): Promise<CommitInfo>;
1205
+ /**
1206
+ * `git fetch --prune`, answering with where the tree stands *after* it.
1207
+ *
1208
+ * **The one call that makes `ahead` and `behind` mean anything.** `status()` reads them off
1209
+ * `git status --branch`, which compares HEAD with the remote-tracking ref sitting on this disk —
1210
+ * so a panel drawing `↓0` from it is reporting the state of the world as of whenever this
1211
+ * checkout last spoke to a remote. Call this first and the number is news; do not, and it is
1212
+ * archaeology.
1213
+ *
1214
+ * It answers with the fresh {@link RepoStatus} rather than nothing, so "fetch and see where I am"
1215
+ * is one round trip. Prunes, because a picker offering six branches that were deleted when their
1216
+ * pull requests merged is worse than one that is a fetch behind.
1217
+ *
1218
+ * A write, and the reason is the network rather than the working tree, which does not move:
1219
+ * this contacts a remote and writes refs. It fails rather than hangs when there are no
1220
+ * credentials — stdin is closed and every askpass unset — which is what makes it safe to put on
1221
+ * a timer. Expect it to reject routinely: no remote, no network, a key the host will not take.
1222
+ */
1223
+ fetch(opts?: { cwd?: string }): Promise<RepoStatus>;
1224
+ /**
1225
+ * Every pull request the forge knows about for this repository, open and closed.
1226
+ *
1227
+ * **One call answers for every checkout.** They come back keyed by
1228
+ * {@link PullRequest.branch}, so a repository with six worktrees is one request and six lookups
1229
+ * — which is the difference between a panel that costs a round trip every few minutes and one
1230
+ * that costs six.
1231
+ *
1232
+ * Answered by `gh`, the CLI the user has already signed into, so tokens, enterprise hosts, SSO
1233
+ * and rate limits are already handled and the answer is the one they get in their own terminal.
1234
+ *
1235
+ * **Rejects rather than answering empty** when it cannot know — no `gh`, not signed in, no
1236
+ * remote on a forge. An empty list is the claim "this repository has no pull requests", and
1237
+ * three of the four ways this fails would be making it falsely. Catch it, say it once, and do
1238
+ * not say it again every tick.
1239
+ */
1240
+ pulls(opts?: { cwd?: string }): Promise<PullRequest[]>;
1062
1241
  /**
1063
1242
  * `git pull`, answering with git's own summary — "Already up to date.", the fast-forward range —
1064
1243
  * because those are different answers and a caller showing neither is a caller nobody trusts.
1065
1244
  * `cwd` picks the repository, as everywhere; absent means the conversation's own.
1245
+ *
1246
+ * `rebase` replays this branch's commits on top of what arrived instead of merging them. It is
1247
+ * the answer to a diverged branch and it rewrites local commits, so ask before you set it — the
1248
+ * caller this exists for is a panel that has just said "diverged" and offered the choice.
1066
1249
  */
1067
- pull(opts?: { cwd?: string }): Promise<string>;
1250
+ pull(opts?: { cwd?: string; rebase?: boolean }): Promise<string>;
1068
1251
  addWorktree(
1069
1252
  path: string,
1070
1253
  branch: string,
@@ -1076,6 +1259,60 @@ export interface GitApi {
1076
1259
  * exactly that one.
1077
1260
  */
1078
1261
  removeWorktree(path: string, opts?: { force?: boolean; cwd?: string }): Promise<void>;
1262
+ /**
1263
+ * Move a worktree to `dest` — `git worktree move`, and everything that has to follow it.
1264
+ *
1265
+ * A worktree's path is an identity here, not a coordinate: it is the key of the project facts
1266
+ * every list draws, of the conversations living in it, of the project vars holding a pin and a
1267
+ * fold state, and of the working directory each vendor CLI was started in. This call moves all
1268
+ * of them, which is why it is a call rather than a `git worktree move` you could have run
1269
+ * yourself — the git part is the part that was never hard.
1270
+ *
1271
+ * `dest` is the full path it lands at. Its parent is created; the leaf must not exist.
1272
+ *
1273
+ * Refused while a turn is running anywhere in the tree, because a CLI holds its working
1274
+ * directory from the moment it is spawned and would otherwise write the file it is editing into
1275
+ * the place the tree used to be. Interrupt it and ask again.
1276
+ *
1277
+ * `cwd` names the repository, as everywhere.
1278
+ */
1279
+ moveWorktree(path: string, dest: string, opts?: { cwd?: string }): Promise<void>;
1280
+ /**
1281
+ * Clone `url` into `path`, resolving to the path once it is there.
1282
+ *
1283
+ * The one call here with no `cwd`: everything else asks a repository a question, and this one
1284
+ * arrives before there is a repository to ask. `path` is absolute and its parent need not
1285
+ * exist — cloning into a location you have just invented is the ordinary case.
1286
+ *
1287
+ * **It reports progress while it runs**, on the bus as {@link CLONE_EVENT}, so a caller that
1288
+ * wants to draw a clone rather than block on one subscribes before awaiting this. Keyed by
1289
+ * `path`, because a workspace may be cloning two things at once.
1290
+ *
1291
+ * Needs `vcs_write` in the manifest, like every other call here that changes a disk.
1292
+ */
1293
+ clone(url: string, path: string): Promise<string>;
1294
+ }
1295
+
1296
+ /**
1297
+ * What {@link GitApi.clone} says about itself while it runs, on {@link EventsApi.on}.
1298
+ *
1299
+ * `phase` is git's own word for what it is doing — `Receiving objects`, `Resolving deltas` — and
1300
+ * `percent` is absent for the phases that have no total, which draw as a spinner rather than as a
1301
+ * bar. `done` arrives exactly once per clone, on success and on failure alike, because a panel
1302
+ * drawing itself from these has no other way to learn it may stop.
1303
+ */
1304
+ export const CLONE_EVENT = "neosh.git.clone";
1305
+
1306
+ /** One {@link CLONE_EVENT} payload. */
1307
+ export interface CloneProgress {
1308
+ /** Which clone this is about. The key, since two may be running. */
1309
+ path: string;
1310
+ url: string;
1311
+ phase: string;
1312
+ percent?: number | null;
1313
+ done?: boolean;
1314
+ /** Present, with git's own last word in it, only when the clone failed. */
1315
+ error?: string;
1079
1316
  }
1080
1317
 
1081
1318
  /**
@@ -1097,6 +1334,32 @@ export interface GenApi {
1097
1334
  * do not each reimplement that. Rejects if there is no JSON in the response at all.
1098
1335
  */
1099
1336
  json<T = unknown>(prompt: string, opts?: { system?: string; selection?: ModelSelection }): Promise<T>;
1337
+ /**
1338
+ * One value, asked for as JSON and accepted however it comes back.
1339
+ *
1340
+ * The shape almost every generating plugin actually wants: a branch name, a thread title, a PR
1341
+ * subject — one string, asked for as `{"branch": …}` because that is how you pin a model down,
1342
+ * and answered as a bare `fix/composer-paste` often enough to matter. Both are the same answer,
1343
+ * and {@link GenApi.json} rejects the second one — which is a correct name thrown away, with
1344
+ * nothing on screen to say so.
1345
+ *
1346
+ * So the key is passed down and a bare reply is read as its value. Everything
1347
+ * {@link GenApi.json} tolerates is tolerated first and unchanged; this is only what happens when
1348
+ * there is no JSON at all. Rejects on an empty answer, or on prose it will not guess at.
1349
+ *
1350
+ * For one value only. A commit message is a subject and a body, and there is no answering the
1351
+ * question of which one a lone paragraph is — that stays {@link GenApi.json}.
1352
+ *
1353
+ * And only for a value you would know was wrong on sight. A branch name is that, and a wrong one
1354
+ * is one rename away; a thread title is any short line, and so is a refusal or a driver's own
1355
+ * error message — where nothing distinguishes an answer from a remark, the envelope is the
1356
+ * evidence, and {@link GenApi.json} is the call.
1357
+ */
1358
+ field(
1359
+ prompt: string,
1360
+ key: string,
1361
+ opts?: { system?: string; selection?: ModelSelection },
1362
+ ): Promise<string>;
1100
1363
  }
1101
1364
 
1102
1365
  /**
@@ -1356,6 +1619,27 @@ export interface VarApi {
1356
1619
  ): Disposable;
1357
1620
  }
1358
1621
 
1622
+ /**
1623
+ * The directories somebody works in, and the one thing that can happen to one.
1624
+ *
1625
+ * A project *is* its path everywhere else in this API — `projectScope` keys vars by it, a panel's
1626
+ * list is a list of them, `SessionInfo.cwd` names one. Which is exactly why a path that changes
1627
+ * needs saying out loud rather than inferring: from the outside, a worktree that moved and a
1628
+ * project that was deleted while another was added are the same two facts in the same order.
1629
+ */
1630
+ export interface ProjectApi {
1631
+ /**
1632
+ * A project's directory is now somewhere else — a worktree that was relocated.
1633
+ *
1634
+ * The host has already moved everything it owns by the time this arrives: the conversations, the
1635
+ * project vars, the names and branches every list draws. What is left is whatever *you* keyed by
1636
+ * the old path — a list of directories, a cache, a decoration target — and the point of getting
1637
+ * both ends in one event is that you can re-key in place instead of dropping a row and gaining a
1638
+ * stranger.
1639
+ */
1640
+ onMove(cb: (e: { from: string; to: string }) => void): Disposable;
1641
+ }
1642
+
1359
1643
  /** Sugar for the two scopes anything with a panel spends its time in. */
1360
1644
  export function projectScope(cwd: string): VarScope {
1361
1645
  return { scope: "project", cwd };
@@ -1612,8 +1896,29 @@ export interface SwarmApi {
1612
1896
  * with the owner's reason when it says no. A node may refuse anything; `NodeCapabilities` on its
1613
1897
  * {@link SwarmNode} says in advance what it is likely to accept, so a menu can grey out a verb
1614
1898
  * rather than offering one that will bounce.
1899
+ *
1900
+ * Answers with the conversation a `new_session` created, and `null` for everything else — which
1901
+ * is what makes starting something over there a place you can then go: pass it to
1902
+ * {@link subscribe}, or to `swarm.open`, without waiting for the next inventory to notice it.
1903
+ */
1904
+ command(node: NodeId, session: string, command: AgentCommand): Promise<string | null>;
1905
+ /**
1906
+ * Which directories on another machine start with `prefix` — `path.complete`, asked over there.
1907
+ *
1908
+ * The same answers in the same shape as the local one: each entry as you would have typed it,
1909
+ * trailing separator and all, so a single field can complete against either machine and the only
1910
+ * thing that changes is which one is asked. Directory names only.
1911
+ *
1912
+ * {@link NodeCapabilities.projects} is what a machine *offers*, and it is the short list of
1913
+ * places it already works in; this is how you reach the directory over there that neither machine
1914
+ * has ever opened. Rejects when the peer is not connected, when it does not accept commands, and
1915
+ * when it is running a neosh too old to have heard of the question — the message says which, and
1916
+ * an empty list is a directory with nothing in it rather than a failure.
1917
+ *
1918
+ * Passing this machine's own node id answers locally, so a picker that walks `nodes()` needs no
1919
+ * special case for the row that is itself.
1615
1920
  */
1616
- command(node: NodeId, session: string, command: AgentCommand): Promise<void>;
1921
+ browse(node: NodeId, prefix?: string): Promise<string[]>;
1617
1922
  /**
1618
1923
  * Watch a remote conversation: its history now, then everything as it happens, delivered to
1619
1924
  * {@link onStream}.
@@ -1706,8 +2011,15 @@ export interface PathApi {
1706
2011
  *
1707
2012
  * `~` expands against the home directory. A prefix with no `/` completes against the active
1708
2013
  * conversation's directory, which is what someone typing `src` means.
2014
+ *
2015
+ * `denied` is set when the list is empty *because the operating system refused*, rather than
2016
+ * because nothing matched. The two are the same empty array and they are not the same answer:
2017
+ * macOS grants folder access to the **terminal**, so `~/Documents` full of projects reads as
2018
+ * empty until somebody ticks a box, and a field that draws "no directory matches" over that
2019
+ * sends people to go and check a path that was right all along. When it is set it says which
2020
+ * folder, which application and which System Settings pane, and it is meant to be shown.
1709
2021
  */
1710
- complete(prefix: string): Promise<string[]>;
2022
+ complete(prefix: string): Promise<{ paths: string[]; denied?: string }>;
1711
2023
  }
1712
2024
 
1713
2025
  export interface TimerApi {
@@ -1755,6 +2067,7 @@ interface Registered {
1755
2067
  composerListeners: Array<(e: { text: string }) => void>;
1756
2068
  activityListeners: Array<(e: { session: SessionId; turn: string; activity: Activity }) => void>;
1757
2069
  varListeners: Array<(e: { scope: VarScope; key: string; value: unknown }) => void>;
2070
+ projectMovedListeners: Array<(e: { from: string; to: string }) => void>;
1758
2071
  swarmListeners: Array<() => void>;
1759
2072
  quotaListeners: Array<(snapshot: QuotaSnapshot) => void>;
1760
2073
  swarmStreamListeners: Array<
@@ -1801,6 +2114,7 @@ function reg(plugin: string): Registered {
1801
2114
  composerListeners: [],
1802
2115
  activityListeners: [],
1803
2116
  varListeners: [],
2117
+ projectMovedListeners: [],
1804
2118
  swarmListeners: [],
1805
2119
  quotaListeners: [],
1806
2120
  swarmStreamListeners: [],
@@ -1852,9 +2166,11 @@ function floatConfig(o: FloatOptions = {}): FloatConfig {
1852
2166
  border: o.border ?? "rounded",
1853
2167
  border_hl: o.borderHl ?? null,
1854
2168
  title: o.title ?? null,
2169
+ footer: o.footer ?? null,
1855
2170
  close_on_blur: o.closeOnBlur ?? false,
1856
2171
  focusable: o.focusable ?? true,
1857
2172
  modal: o.modal ?? false,
2173
+ scroll: o.scroll ?? false,
1858
2174
  };
1859
2175
  }
1860
2176
 
@@ -1979,7 +2295,8 @@ function build(
1979
2295
  },
1980
2296
  path: {
1981
2297
  async complete(prefix) {
1982
- return expect(await c({ call: "path_complete", prefix }), "paths").paths;
2298
+ const answer = expect(await c({ call: "path_complete", prefix }), "paths");
2299
+ return { paths: answer.paths, denied: answer.denied ?? undefined };
1983
2300
  },
1984
2301
  },
1985
2302
  state: {
@@ -2012,6 +2329,11 @@ function build(
2012
2329
  return listener(r.varListeners, cb);
2013
2330
  },
2014
2331
  },
2332
+ project: {
2333
+ onMove(cb) {
2334
+ return listener(r.projectMovedListeners, cb);
2335
+ },
2336
+ },
2015
2337
  ext: {
2016
2338
  async contribute(point, id, item, opts) {
2017
2339
  await c({ call: "ext_contribute", point, id, item, priority: opts?.priority ?? 0 });
@@ -2080,7 +2402,13 @@ function build(
2080
2402
  return expect(await c({ call: "swarm_hosts_of", project }), "names").names;
2081
2403
  },
2082
2404
  async command(node, session, command) {
2083
- await c({ call: "swarm_command", node, session, command });
2405
+ const done = await c({ call: "swarm_command", node, session, command });
2406
+ // Only `new_session` says anything, so an `ok` of any other shape is an older peer
2407
+ // answering a command that had nothing to report — which is a success, not a mismatch.
2408
+ return done.ok === "swarm_commanded" ? done.session ?? null : null;
2409
+ },
2410
+ async browse(node, prefix) {
2411
+ return expect(await c({ call: "swarm_browse", node, prefix: prefix ?? "" }), "paths").paths;
2084
2412
  },
2085
2413
  async subscribe(node, session) {
2086
2414
  await c({ call: "swarm_subscribe", node, session });
@@ -2272,10 +2600,79 @@ function build(
2272
2600
  return listener(list, cb);
2273
2601
  },
2274
2602
  },
2603
+ pane: {
2604
+ async active(view) {
2605
+ const { tabs, active } = expect(await c({ call: "tab_list", view }), "tabs");
2606
+ // The active tab, or the first — a terminal always has at least one, and a `tabs` that
2607
+ // came back empty is a workspace that has gone wrong in a way this cannot paper over.
2608
+ const tab = tabs.find((t) => t.id === active) ?? tabs[0];
2609
+ if (!tab) throw new Error("this terminal has no tabs");
2610
+ return tab.active_pane;
2611
+ },
2612
+ async split(pane, dir) {
2613
+ const p = expect(await c({ call: "pane_split", pane, dir }), "pane").pane;
2614
+ if (p === null) throw new Error(`pane ${pane} could not be split`);
2615
+ return p;
2616
+ },
2617
+ async close(pane) {
2618
+ await c({ call: "pane_close", pane });
2619
+ },
2620
+ async focus(pane) {
2621
+ await c({ call: "pane_focus", pane });
2622
+ },
2623
+ async focusDir(dir, view) {
2624
+ return expect(await c({ call: "pane_focus_dir", dir, view }), "pane").pane;
2625
+ },
2626
+ async resize(pane, dir, delta) {
2627
+ await c({ call: "pane_resize", pane, dir, delta });
2628
+ },
2629
+ async swap(pane, withPane) {
2630
+ await c({ call: "pane_swap", pane, with: withPane });
2631
+ },
2632
+ async moveToEdge(pane, dir) {
2633
+ await c({ call: "pane_move_edge", pane, dir });
2634
+ },
2635
+ async equalize(view) {
2636
+ await c({ call: "pane_equalize", view });
2637
+ },
2638
+ },
2639
+ tab: {
2640
+ async list(view) {
2641
+ const r = expect(await c({ call: "tab_list", view }), "tabs");
2642
+ return { tabs: r.tabs, active: r.active };
2643
+ },
2644
+ async create(opts) {
2645
+ return expect(
2646
+ await c({
2647
+ call: "tab_new",
2648
+ view: opts?.view,
2649
+ title: opts?.title,
2650
+ activate: opts?.activate ?? true,
2651
+ }),
2652
+ "tab",
2653
+ ).tab;
2654
+ },
2655
+ async close(tab) {
2656
+ await c({ call: "tab_close", tab });
2657
+ },
2658
+ async select(tab) {
2659
+ await c({ call: "tab_select", tab });
2660
+ },
2661
+ async step(delta, view) {
2662
+ await c({ call: "tab_step", delta, view });
2663
+ },
2664
+ async move(tab, to) {
2665
+ await c({ call: "tab_move", tab, to });
2666
+ },
2667
+ async rename(tab, title) {
2668
+ await c({ call: "tab_rename", tab, title });
2669
+ },
2670
+ },
2275
2671
  win: {
2276
2672
  async open(buf, dock, opts) {
2277
2673
  const layout: WindowLayout = {
2278
2674
  kind: "docked",
2675
+ pane: opts?.pane ?? null,
2279
2676
  dock,
2280
2677
  size: opts?.size ?? null,
2281
2678
  gravity: opts?.gravity ?? "start",
@@ -2302,6 +2699,9 @@ function build(
2302
2699
  async scrollTo(win, topLine) {
2303
2700
  await c({ call: "win_scroll_to", win, top_line: topLine });
2304
2701
  },
2702
+ async scroll(win, amount) {
2703
+ await c({ call: "win_scroll", win, amount });
2704
+ },
2305
2705
  async viewport(win) {
2306
2706
  return expect(await c({ call: "win_get_viewport", win }), "viewport").viewport ?? null;
2307
2707
  },
@@ -2620,8 +3020,16 @@ function build(
2620
3020
  cwd: opts?.cwd ?? null,
2621
3021
  });
2622
3022
  },
3023
+ async fetch(opts) {
3024
+ const v = await c({ call: "git_fetch", cwd: opts?.cwd ?? null });
3025
+ return expect(v, "status").status;
3026
+ },
3027
+ async pulls(opts) {
3028
+ const v = await c({ call: "forge_pulls", cwd: opts?.cwd ?? null });
3029
+ return expect(v, "pulls").pulls;
3030
+ },
2623
3031
  async pull(opts) {
2624
- const v = await c({ call: "git_pull", cwd: opts?.cwd ?? null });
3032
+ const v = await c({ call: "git_pull", cwd: opts?.cwd ?? null, rebase: opts?.rebase ?? false });
2625
3033
  return expect(v, "text").text;
2626
3034
  },
2627
3035
  async removeWorktree(path, opts) {
@@ -2632,6 +3040,13 @@ function build(
2632
3040
  cwd: opts?.cwd ?? null,
2633
3041
  });
2634
3042
  },
3043
+ async moveWorktree(path, dest, opts) {
3044
+ await c({ call: "git_move_worktree", path, dest, cwd: opts?.cwd ?? null });
3045
+ },
3046
+ async clone(url, path) {
3047
+ const v = await c({ call: "git_clone", url, path });
3048
+ return expect(v, "text").text;
3049
+ },
2635
3050
  },
2636
3051
  gen: {
2637
3052
  async complete(prompt, opts) {
@@ -2654,6 +3069,23 @@ function build(
2654
3069
  });
2655
3070
  return expect(v, "json").value as never;
2656
3071
  },
3072
+ async field(prompt, key, opts) {
3073
+ const v = await c({
3074
+ call: "gen_complete",
3075
+ prompt,
3076
+ system: opts?.system ?? null,
3077
+ json: true,
3078
+ field: key,
3079
+ selection: opts?.selection ?? null,
3080
+ });
3081
+ const value = (expect(v, "json").value as Record<string, unknown>)?.[key];
3082
+ // A key that came back as something other than a non-empty string is an answer to a
3083
+ // different question, and the caller is about to name a branch after it.
3084
+ if (typeof value !== "string" || value.trim() === "") {
3085
+ throw new Error(`the model returned no ${key}`);
3086
+ }
3087
+ return value.trim();
3088
+ },
2657
3089
  },
2658
3090
  view: {
2659
3091
  async list() {
@@ -2969,6 +3401,9 @@ async function dispatchEvent(
2969
3401
  for (const cb of r.varListeners)
2970
3402
  cb({ scope: ev.scope, key: ev.key, value: ev.value });
2971
3403
  break;
3404
+ case "project_moved":
3405
+ for (const cb of r.projectMovedListeners) cb({ from: ev.from, to: ev.to });
3406
+ break;
2972
3407
  case "quota":
2973
3408
  for (const cb of [...r.quotaListeners]) cb(ev.snapshot);
2974
3409
  break;