@neosh/api 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neosh/api",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "The neosh plugin API. Types are generated from the Rust side and drift-checked in CI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,7 @@ import type { ProjectKey } from "./ProjectKey";
32
32
  import type { ProviderEvent } from "./ProviderEvent";
33
33
  import type { QuotaSnapshot } from "./QuotaSnapshot";
34
34
  import type { Rect } from "./Rect";
35
+ import type { ScrollAmount } from "./ScrollAmount";
35
36
  import type { SelectShape } from "./SelectShape";
36
37
  import type { SessionId } from "./SessionId";
37
38
  import type { StatusSegment } from "./StatusSegment";
@@ -113,6 +114,7 @@ export type ApiCall =
113
114
  | { "call": "win_set_cursor"; win: WindowId; row: number; col: number }
114
115
  | { "call": "win_get_viewport"; win: WindowId }
115
116
  | { "call": "win_scroll_to"; win: WindowId; top_line: number | null }
117
+ | { "call": "win_scroll"; win: WindowId; amount: ScrollAmount }
116
118
  | { "call": "win_list" }
117
119
  | {
118
120
  "call": "pane_split";
@@ -478,12 +480,37 @@ export type ApiCall =
478
480
  | { "call": "git_stage"; paths: Array<string> }
479
481
  | { "call": "git_unstage"; paths: Array<string> }
480
482
  | { "call": "git_commit"; message: string }
483
+ | {
484
+ "call": "git_fetch";
485
+ /**
486
+ * The repository to fetch in. The conversation's own when absent.
487
+ */
488
+ cwd?: string | null;
489
+ }
481
490
  | {
482
491
  "call": "git_pull";
483
492
  /**
484
493
  * The repository to pull in. The conversation's own when absent.
485
494
  */
486
495
  cwd?: string | null;
496
+ /**
497
+ * Replay this branch's own commits on top of what arrived, rather than merging.
498
+ *
499
+ * A flag rather than a call of its own because it is one argument to one command, and the
500
+ * caller that needs it is the one that has just been told the branch diverged — at which
501
+ * point "rebase or merge" is the question being answered, and two entry points for the two
502
+ * answers would be two things to keep in step for no gain.
503
+ *
504
+ * It rewrites local commits, which is why nothing sets it without asking first.
505
+ */
506
+ rebase: boolean;
507
+ }
508
+ | {
509
+ "call": "forge_pulls";
510
+ /**
511
+ * The repository to ask about. The conversation's own when absent.
512
+ */
513
+ cwd?: string | null;
487
514
  }
488
515
  | {
489
516
  "call": "git_add_worktree";
@@ -507,6 +534,32 @@ export type ApiCall =
507
534
  */
508
535
  cwd?: string | null;
509
536
  }
537
+ | {
538
+ "call": "git_clone";
539
+ /**
540
+ * Anything `git clone` takes: an `https://` or `git@` URL, or a local path.
541
+ */
542
+ url: string;
543
+ /**
544
+ * Where the working tree lands, in full. The caller resolved the root and the name, so
545
+ * that a picker can *show* the destination on the row before anything is written.
546
+ */
547
+ path: string;
548
+ }
549
+ | {
550
+ "call": "git_move_worktree";
551
+ path: string;
552
+ /**
553
+ * Where it lands, in full. The leaf must not exist; the parent is created.
554
+ */
555
+ dest: string;
556
+ /**
557
+ * The repository it belongs to. `git worktree move` runs from a checkout, and the
558
+ * conversation the caller is in may be standing in the one being moved — the same reason
559
+ * [`Self::GitRemoveWorktree`] takes one.
560
+ */
561
+ cwd?: string | null;
562
+ }
510
563
  | {
511
564
  "call": "gen_complete";
512
565
  prompt: string;
@@ -515,6 +568,30 @@ export type ApiCall =
515
568
  * Ask for JSON and parse it host-side, tolerating the code fences models wrap it in.
516
569
  */
517
570
  json: boolean;
571
+ /**
572
+ * The one key the answer is *about*, when the whole answer is one value.
573
+ *
574
+ * A prompt that says "return `{"branch": …}`" is answered with the object most of the
575
+ * time and with a bare `fix/composer-paste` the rest of it — the model did the work and
576
+ * skipped the envelope, and a caller that only accepts the envelope throws a correct
577
+ * answer away. Measured on this workspace's own history it was two runs in twenty-two:
578
+ * twice a branch was never named, silently, and both times the name was sitting in the
579
+ * reply.
580
+ *
581
+ * So: extraction first, exactly as before, and this is what a *bare* answer means. Named
582
+ * rather than inferred, because only the caller knows which key one value belongs under
583
+ * — and absent, nothing changes, which is what every existing caller wants.
584
+ *
585
+ * Only for a one-value answer. A commit message is a subject *and* a body, and guessing
586
+ * which half a bare paragraph is would be inventing the other one.
587
+ *
588
+ * And only for a value a *wrong* answer is recognisable in. A branch name is checkable
589
+ * and one `git branch -m` from being fixed; a thread title is any short line, which is
590
+ * also what a refusal and a driver's own error message look like — there the envelope is
591
+ * the only evidence the question was answered rather than commented on, and it is worth
592
+ * the one reply in ten that arrives without it.
593
+ */
594
+ field?: string | null;
518
595
  /**
519
596
  * Defaults to `gen.model` if set, else the session's own selection.
520
597
  */
@@ -25,6 +25,7 @@ import type { PermissionDecision } from "./PermissionDecision";
25
25
  import type { PermissionMode } from "./PermissionMode";
26
26
  import type { PluginInfo } from "./PluginInfo";
27
27
  import type { PointInfo } from "./PointInfo";
28
+ import type { PullRequest } from "./PullRequest";
28
29
  import type { QuestionAnswer } from "./QuestionAnswer";
29
30
  import type { QuotaSample } from "./QuotaSample";
30
31
  import type { QuotaSnapshot } from "./QuotaSnapshot";
@@ -92,6 +93,7 @@ export type ApiOk =
92
93
  | { "ok": "status"; status: RepoStatus }
93
94
  | { "ok": "branches"; branches: Array<BranchInfo> }
94
95
  | { "ok": "worktrees"; worktrees: Array<WorktreeInfo> }
96
+ | { "ok": "pulls"; pulls: Array<PullRequest> }
95
97
  | { "ok": "commits"; commits: Array<CommitInfo> }
96
98
  | { "ok": "commit"; commit: CommitInfo }
97
99
  | { "ok": "text"; text: string }
@@ -0,0 +1,14 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+
3
+ /**
4
+ * What the forge's checks say, folded to the one thing a row has room for.
5
+ *
6
+ * Folded here rather than on the wire as a list, because the question a project row answers is
7
+ * "is anything wrong" and the answer is one glyph. The counts travel beside it for whoever wants
8
+ * to say `✗2`; the full list of check names is a panel's job and a different call.
9
+ *
10
+ * `Pending` and `None` are deliberately different. Nothing has run yet is a state that resolves on
11
+ * its own in a few minutes; there are no checks configured at all is permanent, and drawing a
12
+ * spinner over it for ever is the failure this distinction avoids.
13
+ */
14
+ export type ChecksState = "none" | "pending" | "passing" | "failing";
@@ -23,6 +23,17 @@ export type FloatConfig = {
23
23
  */
24
24
  border_hl?: string | null;
25
25
  title?: string | null;
26
+ /**
27
+ * A strip on the bottom border: what the keys here do.
28
+ *
29
+ * On the border rather than in the buffer, because a key strip written as a row of content is
30
+ * one that scrolls away exactly when it is wanted, and is the first thing clipped on a terminal
31
+ * too short for the panel. Both of those happened: the picker's strip is the row that says how
32
+ * to get out, and on a sixteen-row screen it was the row that did not fit.
33
+ *
34
+ * Clipped to the border's width like the title, so it is for a legend and not for prose.
35
+ */
36
+ footer?: string | null;
26
37
  /**
27
38
  * Close automatically when focus moves elsewhere. The right default for pickers and hovers.
28
39
  */
@@ -60,4 +71,26 @@ export type FloatConfig = {
60
71
  * characters through to the composer would be typing into a field you cannot see.
61
72
  */
62
73
  modal: boolean;
74
+ /**
75
+ * This panel is a place you can move in when it does not fit.
76
+ *
77
+ * A float asks for a height and the frontend gives it what there is — so on a short terminal
78
+ * every row past the bottom edge is content that exists, is drawn nowhere, and has no key
79
+ * pointed at it. `height: Max { n: 30 }` on a twenty-row screen is not thirty rows of panel, it
80
+ * is ten rows of panel and twenty rows nobody can reach.
81
+ *
82
+ * Setting this adds one scope to the keymap chain while this float has focus —
83
+ * `BufKind { name: "neosh.scroll" }` — which is where the workspace binds the reader's motions:
84
+ * `j`/`k`, `<C-d>`/`<C-u>`, `<C-f>`/`<C-b>`, `gg`/`G`, the arrows and the paging keys. A scope
85
+ * rather than a capture, so all three of the usual rules hold: `^Z` lists the keys, `init.ts`
86
+ * moves them, and a panel that wants `j` for something of its own binds it on *its* kind, which
87
+ * is nearer and wins. It sits below the panel's own kind and above `Global`, so it is a default
88
+ * that never takes a key away from the thing it is scrolling.
89
+ *
90
+ * Off by default, and not for want of ambition: a picker whose filter takes printable
91
+ * characters through a capture would have `j` and `G` resolved out from under it by a scope, so
92
+ * typing a model's name would scroll instead of filtering. A panel that already moves in itself
93
+ * says so by staying quiet here.
94
+ */
95
+ scroll: boolean;
63
96
  };
@@ -27,4 +27,20 @@ export type ModelInfo = {
27
27
  * numbers are already columns.
28
28
  */
29
29
  tagline?: string | null;
30
+ /**
31
+ * Why this one cannot be chosen here, in a sentence, or nothing when it can.
32
+ *
33
+ * A model a driver lists but will not run — the vendor CLI on this machine is too old for it,
34
+ * most often. Said rather than filtered out, for the reason a missing provider is listed
35
+ * rather than dropped: nothing there and not allowed are the same empty list, and only one of
36
+ * them is fixed by doing something. A model that silently vanishes from the picker is a
37
+ * question with nowhere to ask it — you go looking for the release notes, or for our bug
38
+ * tracker, and the answer was one `claude update` away the whole time.
39
+ *
40
+ * The sentence carries the fix, not just the diagnosis: "needs claude 2.1.251 — run `claude
41
+ * update`" is actionable and "unsupported model" is not. Anything reading a catalogue to
42
+ * *choose* — the startup default, `model.upgrade`, `model.line` — skips these; only a picker
43
+ * draws them, and draws them disabled.
44
+ */
45
+ unavailable?: string | null;
30
46
  };
@@ -63,6 +63,7 @@ export type PluginEvent =
63
63
  | { "type": "composer_changed"; text: string }
64
64
  | { "type": "event"; name: string; data?: unknown; from: string }
65
65
  | { "type": "var_changed"; scope: VarScope; key: string; value?: unknown }
66
+ | { "type": "project_moved"; from: string; to: string }
66
67
  | { "type": "highlight_changed"; names: Array<string> }
67
68
  | { "type": "contributions_changed"; point: string }
68
69
  | { "type": "swarm_changed" }
@@ -0,0 +1,30 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ import type { ChecksState } from "./ChecksState";
3
+ import type { PullState } from "./PullState";
4
+
5
+ /**
6
+ * One pull request, as much of it as a row can use.
7
+ *
8
+ * **Keyed by `branch`**, which is what makes one call answer for every worktree at once: a
9
+ * repository's pull requests come back together and each checkout finds its own by the branch it
10
+ * has out. Asking per branch would be one network round trip per row in the panel.
11
+ */
12
+ export type PullRequest = {
13
+ number: number;
14
+ title: string;
15
+ /**
16
+ * For opening it. The forge's own URL, so nothing here has to know how to build one.
17
+ */
18
+ url: string;
19
+ /**
20
+ * The head branch. What a checkout matches itself against.
21
+ */
22
+ branch: string;
23
+ state: PullState;
24
+ checks: ChecksState;
25
+ /**
26
+ * How many checks have failed, for a row that wants to say `✗2` rather than `✗`.
27
+ */
28
+ checks_failed: number;
29
+ checks_total: number;
30
+ };
@@ -0,0 +1,11 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+
3
+ /**
4
+ * Where a pull request has got to.
5
+ *
6
+ * Four states because that is what a person means by "what happened to it": one is a review you
7
+ * are waiting for, one is a review you have not asked for yet, one is done, and one is not going
8
+ * to happen. Draft is its own state rather than a flag on `Open` because on a row it is the whole
9
+ * difference between "somebody should look at this" and "I am still writing it".
10
+ */
11
+ export type PullState = "open" | "draft" | "merged" | "closed";
@@ -0,0 +1,16 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+
3
+ /**
4
+ * How far to move a window's scroll. See [`crate::ApiCall::WinScroll`].
5
+ *
6
+ * Verbs rather than a row number, for the reason [`CursorMotion`] is: only the core knows how many
7
+ * buffer rows the frontend last drew, so "half a screen" is a question the caller cannot answer and
8
+ * a caller that tried would answer it with a height. Signed, so one variant covers both directions
9
+ * and a binding for `<C-d>` is the binding for `<C-u>` with the sign flipped.
10
+ */
11
+ export type ScrollAmount =
12
+ | { "kind": "lines"; n: number }
13
+ | { "kind": "half"; n: number }
14
+ | { "kind": "page"; n: number }
15
+ | { "kind": "top" }
16
+ | { "kind": "bottom" };
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ import type { CredentialSource } from "./generated/CredentialSource";
36
36
  import type { CursorMotion } from "./generated/CursorMotion";
37
37
  import type { CursorShape } from "./generated/CursorShape";
38
38
  import type { SelectShape } from "./generated/SelectShape";
39
+ import type { ScrollAmount } from "./generated/ScrollAmount";
39
40
  import type { DiffTarget } from "./generated/DiffTarget";
40
41
  import type { Dock } from "./generated/Dock";
41
42
  import type { Gravity } from "./generated/Gravity";
@@ -136,6 +137,9 @@ import type { Viewport } from "./generated/Viewport";
136
137
  import type { WindowId } from "./generated/WindowId";
137
138
  import type { WindowInfo } from "./generated/WindowInfo";
138
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";
139
143
  import type { WorktreeInfo } from "./generated/WorktreeInfo";
140
144
 
141
145
  export type {
@@ -153,12 +157,15 @@ export type {
153
157
  CostBasis, QuotaCredits, QuotaSample, QuotaSeverity, QuotaSnapshot, QuotaSource, QuotaWindow,
154
158
  InstallMethod, UpdateOutcome, UpdateStatus,
155
159
  UsageBucket, UsageHistory, UsageResolution, UsageScanSource,
156
- Rect, RepoInfo, RepoStatus, SelectShape, SessionId, SessionInfo, StatusAlign, StatusSegment, StopReason,
160
+ Rect, RepoInfo, RepoStatus, ScrollAmount, SelectShape, SessionId, SessionInfo, StatusAlign, StatusSegment, StopReason,
157
161
  SurfaceCell, SurfaceId, TextEdit, ToolCall, ToolDef, ToolResult, TurnRequest, Usage,
158
162
  NodeCapabilities, NodeId, NodeInfo, ProjectKey, RemoteProject, StreamEvent,
159
163
  SwarmAgent, SwarmNode, SwarmStranger,
160
164
  VarScope, ViewId, ViewInfo, Viewport,
161
165
  WindowId, WindowInfo, WindowLayout,
166
+ ChecksState,
167
+ PullRequest,
168
+ PullState,
162
169
  WorktreeInfo,
163
170
  };
164
171
 
@@ -368,6 +375,16 @@ export interface FloatOptions {
368
375
  border?: FloatConfig["border"];
369
376
  borderHl?: string;
370
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;
371
388
  closeOnBlur?: boolean;
372
389
  focusable?: boolean;
373
390
  /**
@@ -384,6 +401,24 @@ export interface FloatOptions {
384
401
  * control sheet. Not for a hint or a hover card.
385
402
  */
386
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;
387
422
  }
388
423
 
389
424
  export interface Neosh {
@@ -415,6 +450,8 @@ export interface Neosh {
415
450
  readonly opt: OptionApi;
416
451
  readonly state: StateApi;
417
452
  readonly vars: VarApi;
453
+ /** The directories somebody works in. See {@link ProjectApi}. */
454
+ readonly project: ProjectApi;
418
455
  readonly ext: ExtensionApi;
419
456
  readonly event: EventApi;
420
457
  readonly swarm: SwarmApi;
@@ -689,6 +726,19 @@ export interface WindowApi {
689
726
  * its first row at `0`. Anything else shows the same thing either way.
690
727
  */
691
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>;
692
742
  /**
693
743
  * How big this window actually is, in cells.
694
744
  *
@@ -1152,12 +1202,52 @@ export interface GitApi {
1152
1202
  stage(paths?: string[]): Promise<void>;
1153
1203
  unstage(paths?: string[]): Promise<void>;
1154
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[]>;
1155
1241
  /**
1156
1242
  * `git pull`, answering with git's own summary — "Already up to date.", the fast-forward range —
1157
1243
  * because those are different answers and a caller showing neither is a caller nobody trusts.
1158
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.
1159
1249
  */
1160
- pull(opts?: { cwd?: string }): Promise<string>;
1250
+ pull(opts?: { cwd?: string; rebase?: boolean }): Promise<string>;
1161
1251
  addWorktree(
1162
1252
  path: string,
1163
1253
  branch: string,
@@ -1169,6 +1259,60 @@ export interface GitApi {
1169
1259
  * exactly that one.
1170
1260
  */
1171
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;
1172
1316
  }
1173
1317
 
1174
1318
  /**
@@ -1190,6 +1334,32 @@ export interface GenApi {
1190
1334
  * do not each reimplement that. Rejects if there is no JSON in the response at all.
1191
1335
  */
1192
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>;
1193
1363
  }
1194
1364
 
1195
1365
  /**
@@ -1449,6 +1619,27 @@ export interface VarApi {
1449
1619
  ): Disposable;
1450
1620
  }
1451
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
+
1452
1643
  /** Sugar for the two scopes anything with a panel spends its time in. */
1453
1644
  export function projectScope(cwd: string): VarScope {
1454
1645
  return { scope: "project", cwd };
@@ -1876,6 +2067,7 @@ interface Registered {
1876
2067
  composerListeners: Array<(e: { text: string }) => void>;
1877
2068
  activityListeners: Array<(e: { session: SessionId; turn: string; activity: Activity }) => void>;
1878
2069
  varListeners: Array<(e: { scope: VarScope; key: string; value: unknown }) => void>;
2070
+ projectMovedListeners: Array<(e: { from: string; to: string }) => void>;
1879
2071
  swarmListeners: Array<() => void>;
1880
2072
  quotaListeners: Array<(snapshot: QuotaSnapshot) => void>;
1881
2073
  swarmStreamListeners: Array<
@@ -1922,6 +2114,7 @@ function reg(plugin: string): Registered {
1922
2114
  composerListeners: [],
1923
2115
  activityListeners: [],
1924
2116
  varListeners: [],
2117
+ projectMovedListeners: [],
1925
2118
  swarmListeners: [],
1926
2119
  quotaListeners: [],
1927
2120
  swarmStreamListeners: [],
@@ -1973,9 +2166,11 @@ function floatConfig(o: FloatOptions = {}): FloatConfig {
1973
2166
  border: o.border ?? "rounded",
1974
2167
  border_hl: o.borderHl ?? null,
1975
2168
  title: o.title ?? null,
2169
+ footer: o.footer ?? null,
1976
2170
  close_on_blur: o.closeOnBlur ?? false,
1977
2171
  focusable: o.focusable ?? true,
1978
2172
  modal: o.modal ?? false,
2173
+ scroll: o.scroll ?? false,
1979
2174
  };
1980
2175
  }
1981
2176
 
@@ -2134,6 +2329,11 @@ function build(
2134
2329
  return listener(r.varListeners, cb);
2135
2330
  },
2136
2331
  },
2332
+ project: {
2333
+ onMove(cb) {
2334
+ return listener(r.projectMovedListeners, cb);
2335
+ },
2336
+ },
2137
2337
  ext: {
2138
2338
  async contribute(point, id, item, opts) {
2139
2339
  await c({ call: "ext_contribute", point, id, item, priority: opts?.priority ?? 0 });
@@ -2499,6 +2699,9 @@ function build(
2499
2699
  async scrollTo(win, topLine) {
2500
2700
  await c({ call: "win_scroll_to", win, top_line: topLine });
2501
2701
  },
2702
+ async scroll(win, amount) {
2703
+ await c({ call: "win_scroll", win, amount });
2704
+ },
2502
2705
  async viewport(win) {
2503
2706
  return expect(await c({ call: "win_get_viewport", win }), "viewport").viewport ?? null;
2504
2707
  },
@@ -2817,8 +3020,16 @@ function build(
2817
3020
  cwd: opts?.cwd ?? null,
2818
3021
  });
2819
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
+ },
2820
3031
  async pull(opts) {
2821
- 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 });
2822
3033
  return expect(v, "text").text;
2823
3034
  },
2824
3035
  async removeWorktree(path, opts) {
@@ -2829,6 +3040,13 @@ function build(
2829
3040
  cwd: opts?.cwd ?? null,
2830
3041
  });
2831
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
+ },
2832
3050
  },
2833
3051
  gen: {
2834
3052
  async complete(prompt, opts) {
@@ -2851,6 +3069,23 @@ function build(
2851
3069
  });
2852
3070
  return expect(v, "json").value as never;
2853
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
+ },
2854
3089
  },
2855
3090
  view: {
2856
3091
  async list() {
@@ -3166,6 +3401,9 @@ async function dispatchEvent(
3166
3401
  for (const cb of r.varListeners)
3167
3402
  cb({ scope: ev.scope, key: ev.key, value: ev.value });
3168
3403
  break;
3404
+ case "project_moved":
3405
+ for (const cb of r.projectMovedListeners) cb({ from: ev.from, to: ev.to });
3406
+ break;
3169
3407
  case "quota":
3170
3408
  for (const cb of [...r.quotaListeners]) cb(ev.snapshot);
3171
3409
  break;
package/src/ui.ts CHANGED
@@ -513,7 +513,11 @@ export async function picker<T>(
513
513
  items: PickerItem<T>[],
514
514
  opts: PickerOptions<T> = {},
515
515
  ): Promise<T | null> {
516
- const height = Math.max(1, opts.height ?? 12);
516
+ // What the list would like. What it *gets* is settled by `measure` below, once the frontend has
517
+ // said how much of the screen there was — a picker asking for twelve rows on a sixteen-row
518
+ // terminal is not a twelve-row picker, and every number this widget computes from `rows` was
519
+ // wrong by the difference until it asked.
520
+ let rows = Math.max(1, opts.height ?? 12);
517
521
  const width = Math.max(20, opts.width ?? 64);
518
522
  const filtering = opts.filter !== false;
519
523
 
@@ -530,13 +534,13 @@ export async function picker<T>(
530
534
  anchor: opts.anchor ?? { kind: "screen" },
531
535
  offset: opts.offset,
532
536
  width: { kind: "fixed", n: width },
533
- // Exactly the rows that get drawn: the list, plus a filter line when there is one, a title
534
- // when there is one, and the key strip unless it was waived.
535
- height: {
536
- kind: "fixed",
537
- n: height + (filtering ? 1 : 0) + (opts.title ? 1 : 0) + (hints === "" ? 0 : 1),
538
- },
537
+ // Exactly the rows that get drawn: the list, plus a filter line when there is one and a title
538
+ // when there is one. The key strip is not among them any more — it is on the bottom border,
539
+ // where it costs no row and cannot be the row that does not fit. It was a row, and on a
540
+ // sixteen-row terminal it was reliably the one clipped: the strip that says how to get out.
541
+ height: { kind: "fixed", n: rows + (filtering ? 1 : 0) + (opts.title ? 1 : 0) },
539
542
  border: "rounded",
543
+ footer: hints === "" ? undefined : ` ${hints} `,
540
544
  focusable: true,
541
545
  closeOnBlur: true,
542
546
  // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
@@ -625,7 +629,7 @@ export async function picker<T>(
625
629
  if (closed) return;
626
630
  // Keep the cursor on screen without recentring on every keystroke.
627
631
  if (cursor < top) top = cursor;
628
- if (cursor >= top + height) top = cursor - height + 1;
632
+ if (cursor >= top + rows) top = cursor - rows + 1;
629
633
 
630
634
  const lines: string[] = [];
631
635
  if (opts.title) lines.push(opts.title);
@@ -636,7 +640,7 @@ export async function picker<T>(
636
640
  // dock. Measuring against anything else puts the last visible character on both lines — the
637
641
  // continuation says `/ finds` under a row that already ended in `/`.
638
642
  const inner = Math.max(8, width);
639
- let window = visible.slice(top, top + height);
643
+ let window = visible.slice(top, top + rows);
640
644
  // One gutter for the whole list, or none at all. Giving it only to the rows that asked for an
641
645
  // icon would step every other label one column left, which reads as a list that cannot decide
642
646
  // where its left margin is.
@@ -672,7 +676,7 @@ export async function picker<T>(
672
676
  // float's height: the window was sized for `height` lines and growing past it would push the
673
677
  // key strip off the bottom, which is the row that says how to get out.
674
678
  if (rest.length > 0) {
675
- const room = Math.max(1, height - rest.length);
679
+ const room = Math.max(1, rows - rest.length);
676
680
  if (cursor < top) top = cursor;
677
681
  if (cursor >= top + room) top = cursor - room + 1;
678
682
  window = visible.slice(top, top + room);
@@ -691,23 +695,6 @@ export async function picker<T>(
691
695
  if (!isCursor) continue;
692
696
  for (const line of rest) lines.push(` ${line}`);
693
697
  }
694
- // Pushed onto the last row rather than floated: the float is sized for it, and a strip that
695
- // moved up as the list shortened would be a strip you have to look for.
696
- //
697
- // The placeholder takes a row of the list's own space, so an empty list has used one of the
698
- // `height` rows and not none. Counting it as none put the strip one row past the bottom of a
699
- // float sized for exactly `height`, where it was silently clipped — leaving the empty state,
700
- // the one state where you most want to be told what the keys do, as the only one with no keys
701
- // on it.
702
- // Lines, not rows: the row under the cursor is more than one of them when it has unfolded, and
703
- // counting rows here would leave the strip that many lines low — off the bottom of a float
704
- // sized for exactly `height`.
705
- const listRows = Math.max(1, lines.length - firstListLine);
706
- const hintLine = hints === "" ? -1 : lines.length + Math.max(0, height - listRows);
707
- if (hintLine >= 0) {
708
- while (lines.length < hintLine) lines.push("");
709
- lines.push(` ${hints}`);
710
- }
711
698
  // Marks are collected against their row and handed over with the text, in one call. Set one at
712
699
  // a time they were a sequence the frontend could draw the middle of: the moment the clear had
713
700
  // landed and the marks had not, every row drew unmarked — in `Normal`, which is near-white.
@@ -716,9 +703,6 @@ export async function picker<T>(
716
703
  drawn[line]?.marks!.push({ col, opts: o });
717
704
  };
718
705
 
719
- if (hintLine >= 0) {
720
- mark(hintLine, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[hintLine] ?? "") });
721
- }
722
706
  const listTop = (opts.title ? 1 : 0) + (filtering ? 1 : 0);
723
707
  for (let i = 0; i < window.length; i++) {
724
708
  const row = window[i]!;
@@ -869,10 +853,10 @@ export async function picker<T>(
869
853
  cursor = Math.max(0, cursor - 1);
870
854
  break;
871
855
  case "page_down":
872
- cursor = Math.min(last(), cursor + height);
856
+ cursor = Math.min(last(), cursor + rows);
873
857
  break;
874
858
  case "page_up":
875
- cursor = Math.max(0, cursor - height);
859
+ cursor = Math.max(0, cursor - rows);
876
860
  break;
877
861
  case "first":
878
862
  cursor = 0;
@@ -923,6 +907,33 @@ export async function picker<T>(
923
907
  }, { desc: "picker key" }),
924
908
  );
925
909
 
910
+ /**
911
+ * How many list rows there turned out to be room for.
912
+ *
913
+ * A float is *asked* for a height and given whatever the screen has, so `opts.height` is a wish
914
+ * and this is the answer. Everything above counts in `rows` — where the window starts, what a
915
+ * page step is, how far the cursor may go before the list scrolls — and while that number was the
916
+ * wish, a picker on a short terminal scrolled to keep the cursor on a row that was not drawn.
917
+ *
918
+ * Read once when the panel opens and again whenever the frontend says this window changed size,
919
+ * rather than on every keystroke: a round trip per character typed is latency on the one path in
920
+ * this widget that has to feel immediate.
921
+ */
922
+ async function measure(): Promise<void> {
923
+ if (closed) return;
924
+ const v = await neosh.win.viewport(win).catch(() => null);
925
+ if (!v) return;
926
+ const next = Math.max(1, v.height - (filtering ? 1 : 0) - (opts.title ? 1 : 0));
927
+ if (next === rows) return;
928
+ rows = next;
929
+ await render();
930
+ }
931
+ disposers.push(
932
+ neosh.event.on("neosh.viewport", (e) => {
933
+ if ((e as { win?: WindowId }).win === win) void measure();
934
+ }),
935
+ );
936
+
926
937
  await neosh.focus.push(win);
927
938
  disposers.push(await neosh.keymap.capture(win, command));
928
939
  // `<C-c>` is bound globally to `interrupt`, which would arm "press again to quit" while a picker
@@ -935,6 +946,7 @@ export async function picker<T>(
935
946
  if (typeof opts.query === "function") query = opts.query();
936
947
  await refetch();
937
948
  await render();
949
+ await measure();
938
950
  if (opts.onHighlight) {
939
951
  const row = visible[cursor];
940
952
  if (row) opts.onHighlight(row.item, row.index);
@@ -957,6 +969,145 @@ function dropSegment(text: string): string {
957
969
  return at < 0 ? "" : trimmed.slice(0, at + 1);
958
970
  }
959
971
 
972
+ // ---------------------------------------------------------------------------
973
+ // Reading something too long for the screen
974
+ // ---------------------------------------------------------------------------
975
+
976
+ export interface PagerOptions {
977
+ /** On the top border. */
978
+ title?: string;
979
+ /**
980
+ * On the bottom border, replacing the default legend.
981
+ *
982
+ * The default already names the scroll keys and the way out, read out of nothing — they are fixed
983
+ * defaults on `neosh.scroll` and `^Z` is where a rebinding shows up. Pass your own only to add a
984
+ * verb of your own to it.
985
+ */
986
+ footer?: string;
987
+ /** Columns of content. The border is drawn outside it. */
988
+ width?: number;
989
+ /**
990
+ * The most rows of content to ask for. Fewer if the content is shorter, fewer still if the screen
991
+ * is — which is the case this whole widget is about, and is why it is a ceiling and not a size.
992
+ */
993
+ height?: number;
994
+ /**
995
+ * The buffer kind, so a third party can bind keys in *this* panel and find it with `win.ofKind`.
996
+ *
997
+ * Defaults to {@link KIND_PAGER}, which every pager in the workspace shares. Pass your own when
998
+ * the panel is a thing in its own right — a diff, a status — and somebody might reasonably want
999
+ * keys on that and not on every pager there is.
1000
+ */
1001
+ kind?: string;
1002
+ /** Marks to lay over the rows, in the same shape {@link Neosh.buf.render} takes. */
1003
+ marks?: DrawnRow["marks"][];
1004
+ z?: number;
1005
+ /**
1006
+ * Handed this panel's own close, as soon as it is on screen.
1007
+ *
1008
+ * For the caller that has to be able to put it away itself — a key that toggles, a panel that
1009
+ * closes when the thing it is about goes. Without it the only way out is a key, and a `^Z` that
1010
+ * opens a sheet and then opens a second one behind it is the bug this exists to prevent.
1011
+ */
1012
+ onOpen?: (close: () => Promise<void>) => void;
1013
+ }
1014
+
1015
+ /** The kind a pager's buffer gets when the caller does not name one. */
1016
+ export const KIND_PAGER = "neosh.pager";
1017
+
1018
+ /**
1019
+ * Show rows you can read but not edit, in a float that scrolls when it does not fit.
1020
+ *
1021
+ * The shape almost every read-only panel in the workspace turned out to want, and the thing each of
1022
+ * them was missing: a float is given whatever height the screen has, so a status of sixty changed
1023
+ * files or a diff of four hundred lines was thirty rows on screen and the rest drawn nowhere, with
1024
+ * no key pointed at it and nothing to say it was there. Every one of them had independently written
1025
+ * "open a float, put lines in it" and independently stopped before the part where the terminal is
1026
+ * short.
1027
+ *
1028
+ * What you get: the reader's motions (`j`/`k`, `^D`/`^U`, `^F`/`^B`, `gg`/`G`, arrows, paging keys)
1029
+ * and the wheel, because the float sets `scroll` and those are ordinary bindings on
1030
+ * `neosh.scroll` — so `^Z` lists them and `init.ts` moves them. A bar down the right border while
1031
+ * anything is hidden. A legend on the bottom border, which cannot scroll away and is not the first
1032
+ * row clipped. `<Esc>`, `q`, `<CR>` and `^C` close it.
1033
+ *
1034
+ * Resolves when it closes.
1035
+ */
1036
+ export async function pager(
1037
+ neosh: Neosh,
1038
+ lines: string[],
1039
+ opts: PagerOptions = {},
1040
+ ): Promise<void> {
1041
+ const buf = await neosh.buf.create({
1042
+ name: `[${opts.title?.trim() || "pager"}]`,
1043
+ scratch: true,
1044
+ kind: opts.kind ?? KIND_PAGER,
1045
+ });
1046
+ const ns = await neosh.ns.create("neosh.ui.pager");
1047
+ await neosh.buf.render(
1048
+ buf,
1049
+ ns,
1050
+ 0,
1051
+ -1,
1052
+ lines.map((text, i) => ({ text, marks: opts.marks?.[i] ?? [] })),
1053
+ );
1054
+
1055
+ const win = await neosh.float.open(buf, {
1056
+ anchor: { kind: "screen" },
1057
+ width: opts.width === undefined ? { kind: "auto" } : { kind: "fixed", n: opts.width },
1058
+ // A ceiling, never a size. `fixed` here would ask for rows the screen does not have and get
1059
+ // clipped to them anyway — the difference being that `max` also shrinks to fit content that is
1060
+ // shorter, so a two-line status is a two-line panel rather than a mostly empty box.
1061
+ height: { kind: "max", n: opts.height ?? 30 },
1062
+ border: "rounded",
1063
+ title: opts.title,
1064
+ footer: opts.footer ?? " j k move ^D ^U half gg G ends esc close ",
1065
+ focusable: true,
1066
+ closeOnBlur: true,
1067
+ scroll: true,
1068
+ z: opts.z ?? 200,
1069
+ });
1070
+ await neosh.focus.push(win);
1071
+
1072
+ let settle: () => void = () => {};
1073
+ const done = new Promise<void>((resolve) => {
1074
+ settle = resolve;
1075
+ });
1076
+
1077
+ const command = `neosh.ui.pager.key.${++pickerSeq}`;
1078
+ const disposers: Disposable[] = [];
1079
+ let closed = false;
1080
+ const close = async () => {
1081
+ if (closed) return;
1082
+ closed = true;
1083
+ for (const d of disposers) d.dispose();
1084
+ await neosh.focus.pop().catch(() => {});
1085
+ await neosh.win.close(win).catch(() => {});
1086
+ settle();
1087
+ };
1088
+
1089
+ disposers.push(
1090
+ await neosh.cmd.register(command, (_args, key) => {
1091
+ // Only what nothing else claimed reaches here, and the scroll keys are claimed — they resolve
1092
+ // at `neosh.scroll`, which is nearer than this capture is late. So what is left is dismissal.
1093
+ // Which is *named*: a panel you scroll is a panel you spend time in, and "any key closes it"
1094
+ // turns a mistyped letter into losing your place.
1095
+ if (!key || closesPager(key)) void close();
1096
+ }, { desc: "close this panel" }),
1097
+ );
1098
+ disposers.push(await neosh.keymap.capture(win, command));
1099
+ opts.onOpen?.(close);
1100
+ return done;
1101
+ }
1102
+
1103
+ /** `<Esc>`, `q`, `<CR>` and `^C` — the four keys every panel in this workspace closes on. */
1104
+ function closesPager(key: KeyContext): boolean {
1105
+ const { code, mods } = key.key;
1106
+ if (code.kind === "esc" || code.kind === "enter") return true;
1107
+ if (code.kind !== "char") return false;
1108
+ return mods.ctrl ? code.c.toLowerCase() === "c" : code.c.toLowerCase() === "q";
1109
+ }
1110
+
960
1111
  // ---------------------------------------------------------------------------
961
1112
  // Derived widgets
962
1113
  // ---------------------------------------------------------------------------
@@ -1013,6 +1164,18 @@ export async function confirm(
1013
1164
  `y ${yes.toLowerCase()} n ${no.toLowerCase()} ↵ choose esc cancel`,
1014
1165
  width - 2,
1015
1166
  );
1167
+ /**
1168
+ * How many of the detail lines there is room for.
1169
+ *
1170
+ * Settled once the frontend has said how tall this dialog turned out to be — a dialog is asked
1171
+ * for a height and given what the screen has, and the rows it loses are the ones at the bottom.
1172
+ * Which for a question with the answers under the detail meant the answers, so a long `detail` on
1173
+ * a short terminal was a dialog you could not see either option in.
1174
+ *
1175
+ * The detail is what gives way, because the detail is the part you can do without: the question
1176
+ * and the two answers are the dialog. What went is said rather than silently dropped.
1177
+ */
1178
+ let room = detail.length;
1016
1179
 
1017
1180
  // On the answer that changes nothing, when the other one cannot be taken back.
1018
1181
  let cursor = opts.dangerous ? 1 : 0;
@@ -1025,14 +1188,17 @@ export async function confirm(
1025
1188
 
1026
1189
  /** Where each part of the dialog starts, so the marks do not have to count rows twice. */
1027
1190
  const detailAt = asked.length + 1;
1028
- const answersAt = detailAt + (detail.length === 0 ? 0 : detail.length + 1);
1029
- const stripAt = answersAt + answers.length + 1;
1191
+ /** Every row but the detail: the question, the blanks around it, and the two answers. */
1192
+ const fixedRows = asked.length + 1 + answers.length;
1030
1193
 
1031
1194
  const win = await neosh.float.open(buf, {
1032
1195
  anchor: { kind: "screen" },
1033
1196
  width: { kind: "fixed", n: width },
1034
- height: { kind: "fixed", n: stripAt + 1 },
1197
+ height: { kind: "fixed", n: fixedRows + (detail.length === 0 ? 0 : detail.length + 1) },
1035
1198
  border: "rounded",
1199
+ // The key strip, on the edge. It was the last row of the buffer, which made it both the row a
1200
+ // long `detail` pushed off the bottom and a row of height every dialog had to pay for.
1201
+ footer: ` ${strip} `,
1036
1202
  focusable: true,
1037
1203
  closeOnBlur: true,
1038
1204
  // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
@@ -1048,15 +1214,20 @@ export async function confirm(
1048
1214
  });
1049
1215
 
1050
1216
  const render = async () => {
1217
+ const shownDetail = room >= detail.length
1218
+ ? detail
1219
+ : // One row of the budget goes to saying what is not being shown, so a dialog that has had to
1220
+ // cut something never looks like a dialog that had nothing more to say.
1221
+ [...detail.slice(0, Math.max(0, room - 1)), `… and ${detail.length - Math.max(0, room - 1)} more lines`];
1222
+ const detailRows = shownDetail.length;
1223
+ const answersAt = detailAt + (detailRows === 0 ? 0 : detailRows + 1);
1051
1224
  const lines: string[] = asked.map((l) => ` ${l}`);
1052
1225
  lines.push("");
1053
- if (detail.length > 0) {
1054
- lines.push(...detail.map((l) => ` ${l}`));
1226
+ if (detailRows > 0) {
1227
+ lines.push(...shownDetail.map((l) => ` ${l}`));
1055
1228
  lines.push("");
1056
1229
  }
1057
1230
  answers.forEach((a, i) => lines.push(`${i === cursor ? `${CURSOR_MARKER}` : BLANK_MARKER}${a}`));
1058
- lines.push("");
1059
- lines.push(` ${strip}`);
1060
1231
  // Text and marks together, in one call: this is redrawn on every keystroke, and a repaint the
1061
1232
  // frontend can draw the middle of is one that flashes — unmarked rows draw in `Normal`. It also
1062
1233
  // takes care of the older half of the same bug, that a mark whose line was replaced under it
@@ -1069,7 +1240,7 @@ export async function confirm(
1069
1240
  for (let i = 0; i < asked.length; i++) {
1070
1241
  mark(i, 0, { hlGroup: "Title", endCol: byteLength(lines[i] ?? "") });
1071
1242
  }
1072
- for (let i = 0; i < detail.length; i++) {
1243
+ for (let i = 0; i < detailRows; i++) {
1073
1244
  mark(detailAt + i, 0, {
1074
1245
  hlGroup: "Comment",
1075
1246
  endCol: byteLength(lines[detailAt + i] ?? ""),
@@ -1090,7 +1261,6 @@ export async function confirm(
1090
1261
  });
1091
1262
  }
1092
1263
  }
1093
- mark(stripAt, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[stripAt] ?? "") });
1094
1264
  await neosh.buf.render(buf, ns, 0, -1, drawn);
1095
1265
  // The caret marks the answer, since there is nothing here to type into.
1096
1266
  await neosh.win.setCursor(win, answersAt + cursor, 0);
@@ -1161,6 +1331,23 @@ export async function confirm(
1161
1331
  disposers.push(await neosh.keymap.capture(win, command));
1162
1332
  await bindWidgetKeys(neosh, win, command, keys);
1163
1333
  await render();
1334
+ // And then how much of it there was room for. Asked after the first draw, because a window the
1335
+ // frontend has never laid out has no answer to give.
1336
+ const measure = async () => {
1337
+ if (closed) return;
1338
+ const v = await neosh.win.viewport(win).catch(() => null);
1339
+ if (!v) return;
1340
+ const next = Math.max(0, v.height - fixedRows - 1);
1341
+ if (next === room) return;
1342
+ room = next;
1343
+ await render();
1344
+ };
1345
+ disposers.push(
1346
+ neosh.event.on("neosh.viewport", (e) => {
1347
+ if ((e as { win?: WindowId }).win === win) void measure();
1348
+ }),
1349
+ );
1350
+ await measure();
1164
1351
  return done;
1165
1352
  }
1166
1353
 
@@ -1401,6 +1588,9 @@ export async function prompt(
1401
1588
  width: { kind: "fixed", n: width },
1402
1589
  height: { kind: "fixed", n: 2 },
1403
1590
  border: "rounded",
1591
+ // A field with no legend on it is a field you guess at. On the border, so it costs neither of
1592
+ // the two rows this has.
1593
+ footer: " ↵ accept ^W a word ^U all esc cancel ",
1404
1594
  focusable: true,
1405
1595
  closeOnBlur: true,
1406
1596
  // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
@@ -2224,9 +2414,11 @@ export async function railPicker<G, T>(
2224
2414
  const win = await neosh.float.open(buf, {
2225
2415
  anchor: { kind: "screen" },
2226
2416
  width: { kind: "fixed", n: total },
2227
- // title, filter, rule, body, rule, hints
2228
- height: { kind: "fixed", n: height + 5 },
2417
+ // title, filter, rule, body, rule. The key strip is not among them: it is on the bottom border,
2418
+ // where it costs no row of body and cannot be the row a short terminal clips.
2419
+ height: { kind: "fixed", n: height + 4 },
2229
2420
  border: "rounded",
2421
+ footer: ` ${opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close"} `,
2230
2422
  focusable: true,
2231
2423
  closeOnBlur: true,
2232
2424
  // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
@@ -2336,8 +2528,21 @@ export async function railPicker<G, T>(
2336
2528
  all = got;
2337
2529
  rebuild();
2338
2530
  cursor = Math.max(0, Math.min(opts.itemAt?.(all) ?? 0, Math.max(0, rows.length - 1)));
2339
- // Land on something selectable rather than on a section header.
2340
- if (rows[cursor]?.kind !== "item") cursor = rows.findIndex((r) => r.kind === "item");
2531
+ // Land on something you can actually press `↵` on: not a section header, and not a row the
2532
+ // caller disabled. `movePane` has always stepped over disabled rows, so the only way to be
2533
+ // standing on one was to open here — and the first row of a list is where a picker opens by
2534
+ // default. A pane whose top row is "this needs a newer CLI" then greeted every `↵` with
2535
+ // nothing happening, which reads as the picker being broken rather than the row being refused.
2536
+ const landable = (at: number) => {
2537
+ const row = rows[at];
2538
+ return row?.kind === "item" && !row.item.disabled;
2539
+ };
2540
+ if (!landable(cursor)) {
2541
+ const next = rows.findIndex((_, at) => landable(at));
2542
+ // Every row refused is still a list worth opening — you came to read why. Falling back to
2543
+ // the first item rather than to nothing keeps the detail line of *something* on screen.
2544
+ cursor = next >= 0 ? next : rows.findIndex((r) => r.kind === "item");
2545
+ }
2341
2546
  if (cursor < 0) cursor = 0;
2342
2547
  paneTop = 0;
2343
2548
  };
@@ -2415,7 +2620,11 @@ export async function railPicker<G, T>(
2415
2620
  ? takeWords(detail, Math.max(0, room))
2416
2621
  : [clipToWidth(detail, Math.max(0, room)), ""];
2417
2622
  const label = padToWidth(labelHead, labelWidth);
2418
- const badgeCell = badgeWidth === 0 ? "" : padToWidth(clipToWidth(badge, badgeWidth), badgeWidth);
2623
+ // One column narrower than the cell it sits in, so there is always a space before the detail.
2624
+ // Clipped to the full width, a badge that happens to fill it runs straight into the sentence
2625
+ // after it and the two read as one word.
2626
+ const badgeCell =
2627
+ badgeWidth === 0 ? "" : padToWidth(clipToWidth(badge, badgeWidth - 1), badgeWidth);
2419
2628
  const text = `${head}${label}${badgeCell}${detailHead}`;
2420
2629
 
2421
2630
  const labelAt = byteLength(head);
@@ -2543,12 +2752,6 @@ export async function railPicker<G, T>(
2543
2752
  line: lines.length - 1,
2544
2753
  mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Separator" },
2545
2754
  });
2546
- const hints = opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close";
2547
- lines.push(` ${hints}`);
2548
- marks.push({
2549
- line: lines.length - 1,
2550
- mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Sidebar.Dim" },
2551
- });
2552
2755
 
2553
2756
  // The marks were already collected against their rows; they now travel with them. One call
2554
2757
  // rather than one per mark, so there is no frame in which the text has arrived and the colour
@@ -2766,7 +2969,16 @@ export interface ActionItem {
2766
2969
  /** For the hint strip and `^Z`. */
2767
2970
  label: string;
2768
2971
  command: string;
2769
- /** Which rows it applies to — a row kind the panel names, `custom` for contributed rows, or `any`. */
2972
+ /**
2973
+ * Which rows it applies to — a row kind the panel names, `custom` for contributed rows, or `any`.
2974
+ *
2975
+ * `custom:<section id>` narrows it to the rows of **one** section: `custom:git` is a verb about
2976
+ * the git block and nothing else. Bare `custom` means every contributed row in the panel, which
2977
+ * is right for a verb about contributions in general and wrong for the usual case — two plugins
2978
+ * each with a block in the column and each wanting `<Tab>` on their own rows were, without this,
2979
+ * one key that one of them won and the other silently lost. A panel binds the key once and sends
2980
+ * it to whichever action matches the row the cursor is actually on.
2981
+ */
2770
2982
  on?: string;
2771
2983
  }
2772
2984
 
@@ -3012,7 +3224,16 @@ export function placeSections<S extends string>(
3012
3224
  */
3013
3225
  export function sectionRows<T>(
3014
3226
  c: Contribution & { item: SectionItem },
3015
- opts: { width: number; custom: (command: string, args: string[]) => T },
3227
+ opts: {
3228
+ width: number;
3229
+ /**
3230
+ * What a landable contributed row *is*, in the panel's own vocabulary.
3231
+ *
3232
+ * The section's id comes third so a panel can tell one contributor's rows from another's —
3233
+ * which is what {@link ActionItem.on}'s `custom:<id>` form is matched against.
3234
+ */
3235
+ custom: (command: string, args: string[], section: string) => T;
3236
+ },
3016
3237
  ): ListRow<T>[] {
3017
3238
  const rows: ListRow<T>[] = [];
3018
3239
  const contributed = Array.isArray(c.item?.rows) ? c.item.rows : [];
@@ -3052,7 +3273,7 @@ export function sectionRows<T>(
3052
3273
  spans: spans.length > 0 ? spans : undefined,
3053
3274
  right: typeof r.right?.text === "string" ? { text: `${r.right.text} `, hl: r.right.hl } : undefined,
3054
3275
  inert: typeof r.command !== "string",
3055
- value: typeof r.command === "string" ? opts.custom(r.command, args) : undefined,
3276
+ value: typeof r.command === "string" ? opts.custom(r.command, args, c.id) : undefined,
3056
3277
  });
3057
3278
  }
3058
3279
  return rows;