@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 +1 -1
- package/src/generated/ApiCall.ts +77 -0
- package/src/generated/ApiOk.ts +2 -0
- package/src/generated/ChecksState.ts +14 -0
- package/src/generated/FloatConfig.ts +33 -0
- package/src/generated/ModelInfo.ts +16 -0
- package/src/generated/PluginEvent.ts +1 -0
- package/src/generated/PullRequest.ts +30 -0
- package/src/generated/PullState.ts +11 -0
- package/src/generated/ScrollAmount.ts +16 -0
- package/src/index.ts +241 -3
- package/src/ui.ts +276 -55
package/package.json
CHANGED
package/src/generated/ApiCall.ts
CHANGED
|
@@ -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
|
*/
|
package/src/generated/ApiOk.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
534
|
-
// when there is one
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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 +
|
|
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 +
|
|
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,
|
|
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 +
|
|
856
|
+
cursor = Math.min(last(), cursor + rows);
|
|
873
857
|
break;
|
|
874
858
|
case "page_up":
|
|
875
|
-
cursor = Math.max(0, cursor -
|
|
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
|
-
|
|
1029
|
-
const
|
|
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:
|
|
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 (
|
|
1054
|
-
lines.push(...
|
|
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 <
|
|
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,
|
|
2228
|
-
|
|
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
|
|
2340
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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: {
|
|
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;
|