@neosh/api 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neosh/api",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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",
@@ -5,6 +5,7 @@ import type { Capability } from "./Capability";
5
5
  import type { CursorMotion } from "./CursorMotion";
6
6
  import type { CursorShape } from "./CursorShape";
7
7
  import type { DiffTarget } from "./DiffTarget";
8
+ import type { Direction } from "./Direction";
8
9
  import type { DriverKind } from "./DriverKind";
9
10
  import type { ExtmarkId } from "./ExtmarkId";
10
11
  import type { ExtmarkOpts } from "./ExtmarkOpts";
@@ -25,6 +26,7 @@ import type { NodeId } from "./NodeId";
25
26
  import type { NoticeKind } from "./NoticeKind";
26
27
  import type { OptionSpec } from "./OptionSpec";
27
28
  import type { OptionValue } from "./OptionValue";
29
+ import type { PaneId } from "./PaneId";
28
30
  import type { PermissionMode } from "./PermissionMode";
29
31
  import type { ProjectKey } from "./ProjectKey";
30
32
  import type { ProviderEvent } from "./ProviderEvent";
@@ -36,6 +38,7 @@ import type { StatusSegment } from "./StatusSegment";
36
38
  import type { StreamId } from "./StreamId";
37
39
  import type { SurfaceCell } from "./SurfaceCell";
38
40
  import type { SurfaceId } from "./SurfaceId";
41
+ import type { TabId } from "./TabId";
39
42
  import type { TextEdit } from "./TextEdit";
40
43
  import type { ToolDef } from "./ToolDef";
41
44
  import type { UsageResolution } from "./UsageResolution";
@@ -55,6 +58,7 @@ export type ApiCall =
55
58
  */
56
59
  kind?: string | null;
57
60
  }
61
+ | { "call": "buf_delete"; buf: BufferId }
58
62
  | { "call": "buf_line_count"; buf: BufferId }
59
63
  | {
60
64
  "call": "buf_get_lines";
@@ -110,6 +114,38 @@ export type ApiCall =
110
114
  | { "call": "win_get_viewport"; win: WindowId }
111
115
  | { "call": "win_scroll_to"; win: WindowId; top_line: number | null }
112
116
  | { "call": "win_list" }
117
+ | {
118
+ "call": "pane_split";
119
+ pane: PaneId;
120
+ /**
121
+ * Which side of `pane` the new one takes. `Right` and `Down` put it after `pane` in the
122
+ * split; `Left` and `Up` put it before.
123
+ */
124
+ dir: Direction;
125
+ }
126
+ | { "call": "pane_close"; pane: PaneId }
127
+ | { "call": "pane_focus"; pane: PaneId }
128
+ | { "call": "pane_focus_dir"; view?: ViewId | null; dir: Direction }
129
+ | { "call": "pane_resize"; pane: PaneId; dir: Direction; delta: number }
130
+ | { "call": "pane_swap"; pane: PaneId; with: PaneId }
131
+ | { "call": "pane_move_edge"; pane: PaneId; dir: Direction }
132
+ | { "call": "pane_equalize"; view?: ViewId | null }
133
+ | {
134
+ "call": "tab_new";
135
+ view?: ViewId | null;
136
+ title?: string | null;
137
+ /**
138
+ * Whether to go to it. A tab opened by a key is; a tab opened by an orchestrator putting
139
+ * work somewhere is not, for the same reason `SessionNew { activate: false }` exists.
140
+ */
141
+ activate: boolean;
142
+ }
143
+ | { "call": "tab_close"; tab: TabId }
144
+ | { "call": "tab_select"; tab: TabId }
145
+ | { "call": "tab_step"; view?: ViewId | null; delta: number }
146
+ | { "call": "tab_move"; tab: TabId; to: number }
147
+ | { "call": "tab_rename"; tab: TabId; title: string | null }
148
+ | { "call": "tab_list"; view?: ViewId | null }
113
149
  | {
114
150
  "call": "win_motion";
115
151
  win: WindowId;
@@ -366,9 +402,11 @@ export type ApiCall =
366
402
  | { "call": "swarm_probe"; addr: string }
367
403
  | { "call": "swarm_pair"; node: NodeId; name: string; addr?: string | null }
368
404
  | { "call": "swarm_unpair"; node: NodeId }
405
+ | { "call": "swarm_rename"; node: NodeId; name?: string | null }
369
406
  | { "call": "swarm_reconnect"; node: NodeId }
370
407
  | { "call": "swarm_disconnect"; node: NodeId }
371
408
  | { "call": "swarm_strangers" }
409
+ | { "call": "swarm_browse"; node: NodeId; prefix: string }
372
410
  | { "call": "quota_list" }
373
411
  | { "call": "quota_refresh"; instance?: InstanceId | null }
374
412
  | { "call": "quota_report"; snapshot: QuotaSnapshot }
@@ -20,6 +20,7 @@ import type { ModelSelection } from "./ModelSelection";
20
20
  import type { NamespaceId } from "./NamespaceId";
21
21
  import type { NodeInfo } from "./NodeInfo";
22
22
  import type { OptionEntry } from "./OptionEntry";
23
+ import type { PaneId } from "./PaneId";
23
24
  import type { PermissionDecision } from "./PermissionDecision";
24
25
  import type { PermissionMode } from "./PermissionMode";
25
26
  import type { PluginInfo } from "./PluginInfo";
@@ -34,6 +35,8 @@ import type { SurfaceId } from "./SurfaceId";
34
35
  import type { SwarmAgent } from "./SwarmAgent";
35
36
  import type { SwarmNode } from "./SwarmNode";
36
37
  import type { SwarmStranger } from "./SwarmStranger";
38
+ import type { TabId } from "./TabId";
39
+ import type { TabInfo } from "./TabInfo";
37
40
  import type { ToolDef } from "./ToolDef";
38
41
  import type { UpdateOutcome } from "./UpdateOutcome";
39
42
  import type { UpdateStatus } from "./UpdateStatus";
@@ -56,6 +59,9 @@ export type ApiOk =
56
59
  | { "ok": "views"; views: Array<ViewInfo> }
57
60
  | { "ok": "ns"; ns: NamespaceId }
58
61
  | { "ok": "mark"; id: ExtmarkId }
62
+ | { "ok": "pane"; pane: PaneId | null }
63
+ | { "ok": "tab"; tab: TabId }
64
+ | { "ok": "tabs"; tabs: Array<TabInfo>; active: TabId }
59
65
  | { "ok": "surface"; surface: SurfaceId }
60
66
  | { "ok": "lines"; lines: Array<string> }
61
67
  | { "ok": "count"; n: number }
@@ -76,7 +82,7 @@ export type ApiOk =
76
82
  | { "ok": "focused_win"; win: WindowId | null }
77
83
  | { "ok": "option"; entry: OptionEntry | null }
78
84
  | { "ok": "options"; options: Array<OptionEntry> }
79
- | { "ok": "paths"; paths: Array<string> }
85
+ | { "ok": "paths"; paths: Array<string>; denied?: string | null }
80
86
  | { "ok": "viewport"; viewport: Viewport | null }
81
87
  | { "ok": "sessions"; sessions: Array<SessionInfo> }
82
88
  | { "ok": "credentials"; credentials: Array<CredentialInfo> }
@@ -107,6 +113,7 @@ export type ApiOk =
107
113
  | { "ok": "swarm_nodes"; nodes: Array<SwarmNode> }
108
114
  | { "ok": "swarm_agents"; agents: Array<SwarmAgent> }
109
115
  | { "ok": "swarm_strangers"; strangers: Array<SwarmStranger> }
116
+ | { "ok": "swarm_commanded"; session?: SessionId | null }
110
117
  | { "ok": "quotas"; quotas: Array<QuotaSnapshot> }
111
118
  | { "ok": "quota_history"; samples: Array<QuotaSample> }
112
119
  | { "ok": "usage_history"; history: UsageHistory }
@@ -70,4 +70,6 @@ export type AscpMessage =
70
70
  session?: SessionId | null;
71
71
  }
72
72
  | { "type": "refused"; id: string; refusal: Refusal }
73
+ | { "type": "browse"; id: string; prefix: string }
74
+ | { "type": "browsed"; id: string; paths: Array<string> }
73
75
  | { "type": "goodbye"; message?: string | null };
@@ -9,6 +9,11 @@
9
9
  export type AuthRef =
10
10
  | { "kind": "env"; var: string }
11
11
  | { "kind": "command"; argv: Array<string> }
12
- | { "kind": "cli"; program: string; login?: string | null }
12
+ | {
13
+ "kind": "cli";
14
+ program: string;
15
+ login?: string | null;
16
+ retired?: string | null;
17
+ }
13
18
  | { "kind": "inherited" }
14
19
  | { "kind": "none" };
@@ -1,6 +1,8 @@
1
1
  // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ import type { ApiCall } from "./ApiCall";
2
3
  import type { BuildId } from "./BuildId";
3
4
  import type { KeyPress } from "./KeyPress";
5
+ import type { SessionId } from "./SessionId";
4
6
  import type { WindowId } from "./WindowId";
5
7
 
6
8
  /**
@@ -32,11 +34,33 @@ export type ClientMessage =
32
34
  * from the workspace anyway, and refusing it would be a worse answer than saying nothing.
33
35
  */
34
36
  build: BuildId;
37
+ /**
38
+ * Open in this conversation. See [`InputEvent::Attached::session`].
39
+ */
40
+ session?: SessionId | null;
35
41
  }
36
42
  | { "kind": "input" }
37
43
  & (
38
44
  | { "type": "ready"; width: number; height: number }
39
- | { "type": "attached"; width: number; height: number }
45
+ | {
46
+ "type": "attached";
47
+ width: number;
48
+ height: number;
49
+ /**
50
+ * Which conversation this terminal would like to open in.
51
+ *
52
+ * `None` — every attach until `neosh "a prompt"` existed — is the ordinary placement: the
53
+ * most recent conversation nobody else is reading, or the screen a workspace nobody was
54
+ * watching kept for whoever came back. That second case is why this cannot be left to
55
+ * "the newest conversation wins": a terminal starting one and then attaching would be
56
+ * handed the abandoned screen instead, and the conversation it had just started would be
57
+ * running somewhere nothing on that screen mentions.
58
+ *
59
+ * A request rather than an instruction: a conversation deleted between the ask and the
60
+ * arrival gets the ordinary placement rather than an error nobody can act on.
61
+ */
62
+ session?: SessionId | null;
63
+ }
40
64
  | { "type": "detached" }
41
65
  | { "type": "key"; key: KeyPress }
42
66
  | { "type": "paste"; text: string }
@@ -65,4 +89,14 @@ export type ClientMessage =
65
89
  )
66
90
  | { "kind": "detach" }
67
91
  | { "kind": "stop" }
68
- | { "kind": "status" };
92
+ | { "kind": "status" }
93
+ | {
94
+ "kind": "call";
95
+ /**
96
+ * Echoed on [`ServerMessage::Called`]. Any string the caller likes — several may be in
97
+ * flight at once, which is the point of it being here rather than implied by order.
98
+ */
99
+ id: string;
100
+ call: ApiCall;
101
+ }
102
+ | { "kind": "subscribe" };
@@ -9,6 +9,7 @@
9
9
  export type CredentialSource =
10
10
  | { "kind": "plan"; via: string }
11
11
  | { "kind": "plan_missing"; program: string; hint?: string | null }
12
+ | { "kind": "plan_retired"; program: string; note: string }
12
13
  | { "kind": "env"; var: string }
13
14
  | { "kind": "keychain" }
14
15
  | { "kind": "session" }
@@ -0,0 +1,6 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+
3
+ /**
4
+ * Which way to go, for moving between panes and for growing one.
5
+ */
6
+ export type Direction = "left" | "right" | "up" | "down";
@@ -1,9 +1,15 @@
1
1
  // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
2
 
3
3
  /**
4
- * Where a non-floating window sits.
4
+ * Which edge of a rectangle a non-floating window sits against.
5
5
  *
6
- * Deliberately *not* an arbitrary split tree: agent UIs do not need one, and a split tree creates
7
- * layout ambiguity that every plugin then has to reason about.
6
+ * The rectangle is the screen, or when the window names a [`PaneId`] — one pane of the main
7
+ * region. That is the only difference between the two, and it is what let splitting arrive without
8
+ * a second geometry vocabulary: a composer docked `Bottom` in a pane is the same call as a
9
+ * composer docked `Bottom` on the screen, asked about a smaller rectangle.
10
+ *
11
+ * Still not an arbitrary split tree *here*: a window says which edge of which rectangle it wants,
12
+ * and the tree that makes those rectangles is [`PaneNode`], one level up. Keeping the two apart is
13
+ * what stops every plugin from having to reason about nesting to place a status line.
8
14
  */
9
- export type Dock = "left" | "right" | "bottom" | "main";
15
+ export type Dock = "left" | "right" | "top" | "bottom" | "main";
@@ -1,10 +1,29 @@
1
1
  // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
2
  import type { KeyPress } from "./KeyPress";
3
+ import type { SessionId } from "./SessionId";
3
4
  import type { WindowId } from "./WindowId";
4
5
 
5
6
  export type InputEvent =
6
7
  | { "type": "ready"; width: number; height: number }
7
- | { "type": "attached"; width: number; height: number }
8
+ | {
9
+ "type": "attached";
10
+ width: number;
11
+ height: number;
12
+ /**
13
+ * Which conversation this terminal would like to open in.
14
+ *
15
+ * `None` — every attach until `neosh "a prompt"` existed — is the ordinary placement: the
16
+ * most recent conversation nobody else is reading, or the screen a workspace nobody was
17
+ * watching kept for whoever came back. That second case is why this cannot be left to
18
+ * "the newest conversation wins": a terminal starting one and then attaching would be
19
+ * handed the abandoned screen instead, and the conversation it had just started would be
20
+ * running somewhere nothing on that screen mentions.
21
+ *
22
+ * A request rather than an instruction: a conversation deleted between the ask and the
23
+ * arrival gets the ordinary placement rather than an error nobody can act on.
24
+ */
25
+ session?: SessionId | null;
26
+ }
8
27
  | { "type": "detached" }
9
28
  | { "type": "key"; key: KeyPress }
10
29
  | { "type": "paste"; text: string }
@@ -3,14 +3,15 @@
3
3
  /**
4
4
  * How the connection to a peer stands, beyond the fact of it being up or not.
5
5
  *
6
- * `up` is the summary and this is the story, because "not connected" is three different rows on a
6
+ * `up` is the summary and this is the story, because "not connected" is four different rows on a
7
7
  * board: a machine being dialled that has never answered, one that was here and is being dialled
8
- * again, and one nothing is dialling at all — it reached us last time, or it was told to stop.
9
- * `attempt` counts dials since the last success, so a panel can say `try 4` instead of drawing a
10
- * spinner that has been spinning since Tuesday.
8
+ * again, one nothing is dialling at all — it reached us last time, or it was told to stop — and
9
+ * one that answered and is waiting on a person. `attempt` counts dials since the last success, so
10
+ * a panel can say `try 4` instead of drawing a spinner that has been spinning since Tuesday.
11
11
  */
12
12
  export type LinkState =
13
13
  | { "state": "connecting"; attempt: number }
14
14
  | { "state": "up" }
15
+ | { "state": "waiting" }
15
16
  | { "state": "retrying"; attempt: number }
16
17
  | { "state": "down" };
@@ -23,6 +23,17 @@ export type NodeCapabilities = {
23
23
  * Whether [`AscpMessage::Subscribe`] will produce anything.
24
24
  */
25
25
  streams: boolean;
26
+ /**
27
+ * Whether [`AscpMessage::Browse`] will be answered.
28
+ *
29
+ * Not a permission — it follows `accepts_commands`, for the reason written on `Browse` — but a
30
+ * *compatibility* flag, and that is why it is here rather than derived. A node built before
31
+ * `Browse` existed cannot skip a message it has never heard of: the frame parses or the
32
+ * connection fails, so a new node that sent one on spec would drop an old peer's link every
33
+ * time somebody opened a directory picker. It defaults to `false`, which is exactly what an
34
+ * older node's handshake decodes to, so "does not say" and "cannot" are the same answer.
35
+ */
36
+ browse: boolean;
26
37
  /**
27
38
  * The checkouts this node has, for starting something on it.
28
39
  */
@@ -0,0 +1,18 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ import type { PaneNode } from "./PaneNode";
3
+
4
+ /**
5
+ * One child of a split, and how much of the axis it gets.
6
+ */
7
+ export type PaneChild = {
8
+ node: PaneNode;
9
+ /**
10
+ * Share of the parent's axis, relative to its siblings.
11
+ *
12
+ * A weight rather than cells, because the terminal is resized by somebody dragging its corner
13
+ * and a layout stored in cells has to be recomputed — badly, and with rounding that
14
+ * accumulates — every time that happens. Weights survive a resize by meaning the same thing at
15
+ * every size. [`WEIGHT`] is what an even split is made of.
16
+ */
17
+ weight: number;
18
+ };
@@ -0,0 +1,15 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+
3
+ /**
4
+ * One rectangle of the main region, and everything docked inside it.
5
+ *
6
+ * A pane is *where* rather than *what*: it owns no buffer and draws nothing. A chat pane is a
7
+ * transcript window docked `Main` in it plus a composer docked `Bottom` in it; a pane showing
8
+ * one thing is one window docked `Main`. That is the whole of why splitting did not need a
9
+ * second dock vocabulary — [`Dock`](crate::Dock) already answers "where inside this rectangle",
10
+ * and a pane is just a smaller rectangle to ask it about.
11
+ *
12
+ * Panes live in a tree per tab ([`PaneNode`](crate::PaneNode)). The tree says how the main
13
+ * region is divided; the frontend turns it into rectangles, exactly as it does for docks.
14
+ */
15
+ export type PaneId = number;
@@ -0,0 +1,17 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ import type { PaneChild } from "./PaneChild";
3
+ import type { PaneId } from "./PaneId";
4
+ import type { SplitDir } from "./SplitDir";
5
+
6
+ /**
7
+ * How the main region of one tab is divided.
8
+ *
9
+ * A tree rather than a list because a split of a split is the thing people actually build — a chat
10
+ * on the left, and on the right a chat above a terminal — and a flat list of rectangles cannot say
11
+ * that without every consumer re-deriving the nesting from coordinates.
12
+ */
13
+ export type PaneNode = { "kind": "leaf"; pane: PaneId } | {
14
+ "kind": "split";
15
+ dir: SplitDir;
16
+ children: Array<PaneChild>;
17
+ };
@@ -14,6 +14,24 @@ export type RemoteProject = {
14
14
  cwd: string;
15
15
  /**
16
16
  * Whether this node has a conversation open in it now.
17
+ *
18
+ * Meant something for exactly as long as this list was derived from live conversations: every
19
+ * project on it had one, so every one of them said `true` and a board drawing the flag drew
20
+ * one colour. A node now advertises the places it *works in* — which is the list its own panel
21
+ * shows, and includes the project you cleared out this morning — so the two states exist and
22
+ * the flag is worth reading.
17
23
  */
18
24
  active: boolean;
25
+ /**
26
+ * How many conversations are open in it there.
27
+ */
28
+ sessions: number;
29
+ /**
30
+ * How many of those are mid-turn.
31
+ *
32
+ * Separate from `sessions` because they are different questions and a board answers both in
33
+ * one row: `3 conversations · 1 working` is what the sidebar says about a local project, and a
34
+ * remote one should not have to say less.
35
+ */
36
+ running: number;
19
37
  };
@@ -1,6 +1,8 @@
1
1
  // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ import type { ApiResponse } from "./ApiResponse";
2
3
  import type { BuildId } from "./BuildId";
3
4
  import type { DetachReason } from "./DetachReason";
5
+ import type { PluginEvent } from "./PluginEvent";
4
6
  import type { UiEvent } from "./UiEvent";
5
7
  import type { WorkspaceStatus } from "./WorkspaceStatus";
6
8
 
@@ -12,4 +14,6 @@ export type ServerMessage =
12
14
  | { "kind": "refused"; reason: string; protocol_version: number }
13
15
  | { "kind": "events"; batch: Array<UiEvent> }
14
16
  | { "kind": "detached"; reason: DetachReason }
15
- | { "kind": "status"; status: WorkspaceStatus };
17
+ | { "kind": "status"; status: WorkspaceStatus }
18
+ | { "kind": "called"; id: string; response: ApiResponse }
19
+ | { "kind": "event"; event: PluginEvent };
@@ -0,0 +1,12 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+
3
+ /**
4
+ * Which way a split lays its children out.
5
+ *
6
+ * Named for what it does to the screen rather than for the key that made it, because those two
7
+ * disagree in every editor that has ever shipped one: Vim's `:vsplit` produces panes side by side,
8
+ * and a `Vertical` that means "stacked" to the layout code and "side by side" to the user is a
9
+ * bug waiting for its first off-by-one. `Row` is a row of panes; `Column` is a column of them.
10
+ * The *commands* say `right` and `down`, which is the one description nobody can misread.
11
+ */
12
+ export type SplitDir = "row" | "column";
@@ -0,0 +1,10 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+
3
+ /**
4
+ * One tab of one view: a title, and a tree of panes.
5
+ *
6
+ * Per view rather than per workspace, for the same reason a window is: what the agent produced
7
+ * is the workspace's, and where you are looking is yours. Two terminals attached to one
8
+ * workspace have their own tabs over the same conversations.
9
+ */
10
+ export type TabId = number;
@@ -0,0 +1,27 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ import type { PaneId } from "./PaneId";
3
+ import type { PaneNode } from "./PaneNode";
4
+ import type { TabId } from "./TabId";
5
+
6
+ /**
7
+ * One tab of one view.
8
+ */
9
+ export type TabInfo = {
10
+ id: TabId;
11
+ /**
12
+ * What the tab bar calls it.
13
+ *
14
+ * `None` is "name it after what is in it" — the conversation's title, the terminal's command —
15
+ * which is what a tab nobody has renamed should say, and it has to stay `None` to keep saying
16
+ * it: a title resolved once and stored is a tab still called `main` three conversations later.
17
+ */
18
+ title?: string | null;
19
+ root: PaneNode;
20
+ /**
21
+ * Which pane in this tab has the keyboard when the tab does.
22
+ *
23
+ * Per tab rather than per view, so that leaving a tab and coming back puts you where you were
24
+ * rather than in whichever pane happens to be first.
25
+ */
26
+ active_pane: PaneId;
27
+ };
@@ -8,6 +8,8 @@ import type { NoticeKind } from "./NoticeKind";
8
8
  import type { Rect } from "./Rect";
9
9
  import type { SurfaceCell } from "./SurfaceCell";
10
10
  import type { SurfaceId } from "./SurfaceId";
11
+ import type { TabId } from "./TabId";
12
+ import type { TabInfo } from "./TabInfo";
11
13
  import type { WindowId } from "./WindowId";
12
14
  import type { WindowLayout } from "./WindowLayout";
13
15
 
@@ -45,7 +47,9 @@ export type UiEvent =
45
47
  | { "type": "surface_claimed"; surface: SurfaceId; win: WindowId; rect: Rect }
46
48
  | { "type": "surface_cells"; surface: SurfaceId; cells: Array<SurfaceCell> }
47
49
  | { "type": "surface_released"; surface: SurfaceId }
50
+ | { "type": "panes_changed"; tabs: Array<TabInfo>; active: TabId }
48
51
  | { "type": "focus_changed"; win: WindowId | null }
52
+ | { "type": "home_changed"; win: WindowId | null }
49
53
  | {
50
54
  "type": "message";
51
55
  level: MessageLevel;
@@ -2,12 +2,23 @@
2
2
  import type { Dock } from "./Dock";
3
3
  import type { FloatConfig } from "./FloatConfig";
4
4
  import type { Gravity } from "./Gravity";
5
+ import type { PaneId } from "./PaneId";
5
6
 
6
7
  /**
7
8
  * Declarative placement for a window. The frontend turns this into a rectangle.
8
9
  */
9
10
  export type WindowLayout = {
10
11
  "kind": "docked";
12
+ /**
13
+ * Which pane's rectangle `dock` is measured against, or the screen's when absent.
14
+ *
15
+ * An `Option` rather than a pane that happens to mean "the whole screen", because the two
16
+ * are different things and one of them outlives the other: a pane is closed when its split
17
+ * is, and a window docked to the screen — the sidebar, the status line — must not go with
18
+ * it. A window naming a pane that has gone is not drawn at all, which is the honest answer
19
+ * and the one the frontend can give without inventing a rectangle.
20
+ */
21
+ pane?: PaneId | null;
11
22
  dock: Dock;
12
23
  /**
13
24
  * Preferred extent along the dock's variable axis.
package/src/index.ts CHANGED
@@ -17,6 +17,10 @@ import type { ApiError } from "./generated/ApiError";
17
17
  import type { ApiOk } from "./generated/ApiOk";
18
18
  import type { ApiResponse } from "./generated/ApiResponse";
19
19
  import type { BranchInfo } from "./generated/BranchInfo";
20
+ import type { Direction } from "./generated/Direction";
21
+ import type { PaneId } from "./generated/PaneId";
22
+ import type { TabId } from "./generated/TabId";
23
+ import type { TabInfo } from "./generated/TabInfo";
20
24
  import type { BufferId } from "./generated/BufferId";
21
25
  import type { Capability } from "./generated/Capability";
22
26
  import type { CommitInfo } from "./generated/CommitInfo";
@@ -137,7 +141,8 @@ import type { WorktreeInfo } from "./generated/WorktreeInfo";
137
141
  export type {
138
142
  AccountKind, Activity, ApiError, BranchInfo, Brand, BufferId, Capability, CommandEntry, CommitInfo,
139
143
  AgentCommand, AgentState, AgentSummary,
140
- Contribution, CredentialInfo, CredentialSource, CursorShape, DiffTarget, Dock, CursorMotion, ExtmarkId, ExtmarkInfo, ExtmarkOpts, FileChange, FileState, FloatConfig,
144
+ Contribution, CredentialInfo, CredentialSource, CursorShape, DiffTarget, Direction, Dock, CursorMotion, ExtmarkId, ExtmarkInfo, ExtmarkOpts, FileChange, FileState, FloatConfig,
145
+ PaneId, TabId, TabInfo,
141
146
  Gravity, HighlightDef, HighlightEntry, HighlightSpec, Hint, HlTarget, HookName, HookOutcome, HookPayload, InstanceConfig, KeyContext,
142
147
  AttachmentInfo,
143
148
  KeymapEntry, KeymapScope, MessageLevel, Mode, ModelEntry, ModelInfo, ModelSelection, ModelTier, NamespaceId,
@@ -385,6 +390,10 @@ export interface Neosh {
385
390
  readonly version: number;
386
391
  readonly buf: BufferApi;
387
392
  readonly win: WindowApi;
393
+ /** The panes of the main region. See {@link PaneApi}. */
394
+ readonly pane: PaneApi;
395
+ /** The tabs each terminal has. See {@link TabApi}. */
396
+ readonly tab: TabApi;
388
397
  readonly float: FloatApi;
389
398
  readonly edit: EditApi;
390
399
  readonly ns: NamespaceApi;
@@ -558,6 +567,84 @@ export interface BufferApi {
558
567
  ): Promise<Disposable>;
559
568
  }
560
569
 
570
+ /**
571
+ * The panes of the main region, and the tabs they live in.
572
+ *
573
+ * A pane is *where*, not *what*: it owns no buffer and draws nothing. You split one, then dock
574
+ * windows into it with {@link WindowApi.open} — which is why splitting a chat and splitting a
575
+ * terminal are the same two calls.
576
+ *
577
+ * Everything here is what the workspace's own `<C-w>` keys are built from. There is no private
578
+ * path: `<C-w>v` is `pane.split(await pane.active(), "right")` and nothing else, so a plugin that
579
+ * wants a different window manager can have one without forking anything.
580
+ */
581
+ export interface PaneApi {
582
+ /** The pane this terminal's keys belong to. */
583
+ active(view?: ViewId): Promise<PaneId>;
584
+ /**
585
+ * Divide a pane in two, and answer with the new one.
586
+ *
587
+ * The new pane is *empty* — nothing is drawn in it until something is docked into it. Focus does
588
+ * not move either: a plugin splitting to show you something and a person pressing a key to go
589
+ * and work there are different intentions, and only the second one wants the keyboard.
590
+ */
591
+ split(pane: PaneId, dir: Direction): Promise<PaneId>;
592
+ /**
593
+ * Close a pane and every window docked in it.
594
+ *
595
+ * Rejects on the last pane of the last tab: a terminal with nowhere to draw has no key that
596
+ * would bring a pane back.
597
+ */
598
+ close(pane: PaneId): Promise<void>;
599
+ focus(pane: PaneId): Promise<void>;
600
+ /**
601
+ * Move the keyboard to the nearest pane in a direction, and answer with it — or `null` at the
602
+ * edge of the layout, which is an ordinary outcome of holding a key down rather than an error.
603
+ * There is deliberately no wrap-around.
604
+ */
605
+ focusDir(dir: Direction, view?: ViewId): Promise<PaneId | null>;
606
+ /**
607
+ * Move the boundary on a pane's `dir` side. `delta` is in weight units — an even split is
608
+ * {@link WEIGHT} each — so a resize means the same thing at every terminal size and survives the
609
+ * terminal being resized under it. A pane with no boundary on that side is a no-op.
610
+ */
611
+ resize(pane: PaneId, dir: Direction, delta: number): Promise<void>;
612
+ /**
613
+ * Exchange two panes' places, keeping what is in each. The weights belong to the slots, so
614
+ * swapping into the wide one is how you make a pane wide.
615
+ */
616
+ swap(pane: PaneId, withPane: PaneId): Promise<void>;
617
+ /**
618
+ * Send a pane to the far edge of its tab — the counterpart to {@link PaneApi.focusDir}, which
619
+ * moves the keyboard rather than the pane. It arrives with an even share rather than the size it
620
+ * had: it is among different neighbours now, and carrying the old number over would make it the
621
+ * odd one out for a reason nobody could see.
622
+ */
623
+ moveToEdge(pane: PaneId, dir: Direction): Promise<void>;
624
+ /** Give every pane of the active tab an equal share, at every level. */
625
+ equalize(view?: ViewId): Promise<void>;
626
+ }
627
+
628
+ /** The tabs of one terminal. Each is a title and a tree of panes. */
629
+ export interface TabApi {
630
+ /** Every tab of a terminal, its panes, and which one is on screen. */
631
+ list(view?: ViewId): Promise<{ tabs: TabInfo[]; active: TabId }>;
632
+ /**
633
+ * Open a tab with one empty pane. `activate` goes to it — `false` is for putting work somewhere
634
+ * without moving the screen out from under whoever is reading it.
635
+ */
636
+ create(opts?: { title?: string; activate?: boolean; view?: ViewId }): Promise<TabId>;
637
+ /** Close a tab and every pane in it. Rejects on a terminal's last tab. */
638
+ close(tab: TabId): Promise<void>;
639
+ select(tab: TabId): Promise<void>;
640
+ /** Go `delta` tabs along, wrapping — a tab bar is a list you can see all of. */
641
+ step(delta: number, view?: ViewId): Promise<void>;
642
+ /** Move a tab along the bar, clamped at the ends. This is a drag; a drag that teleports is a bug. */
643
+ move(tab: TabId, to: number): Promise<void>;
644
+ /** Name a tab, or pass `null` to give it back its derived name. */
645
+ rename(tab: TabId, title: string | null): Promise<void>;
646
+ }
647
+
561
648
  export interface WindowApi {
562
649
  /**
563
650
  * `gravity` is which end short content settles against: `"start"` (the default) pins it to the
@@ -568,11 +655,17 @@ export interface WindowApi {
568
655
  * wrapped a long path would reflow every row below it — but a text field is prose and wants
569
656
  * this on. A bottom dock that wraps also grows to show the folded rows, `size` acting as its
570
657
  * floor.
658
+ *
659
+ * `pane` docks the window inside one pane of the main region rather than against the screen.
660
+ * The same four edges either way — a composer at the foot of a pane is this call with a `pane`,
661
+ * and a status line along the foot of the terminal is this call without one. Omit it for
662
+ * anything that belongs to the whole terminal: a sidebar docked into a pane would be a sidebar
663
+ * that disappears when you close that split.
571
664
  */
572
665
  open(
573
666
  buf: BufferId,
574
667
  dock: Dock,
575
- opts?: { size?: number; gravity?: Gravity; wrap?: boolean },
668
+ opts?: { size?: number; gravity?: Gravity; wrap?: boolean; pane?: PaneId },
576
669
  ): Promise<WindowId>;
577
670
  close(win: WindowId): Promise<void>;
578
671
  /**
@@ -1612,8 +1705,29 @@ export interface SwarmApi {
1612
1705
  * with the owner's reason when it says no. A node may refuse anything; `NodeCapabilities` on its
1613
1706
  * {@link SwarmNode} says in advance what it is likely to accept, so a menu can grey out a verb
1614
1707
  * rather than offering one that will bounce.
1708
+ *
1709
+ * Answers with the conversation a `new_session` created, and `null` for everything else — which
1710
+ * is what makes starting something over there a place you can then go: pass it to
1711
+ * {@link subscribe}, or to `swarm.open`, without waiting for the next inventory to notice it.
1615
1712
  */
1616
- command(node: NodeId, session: string, command: AgentCommand): Promise<void>;
1713
+ command(node: NodeId, session: string, command: AgentCommand): Promise<string | null>;
1714
+ /**
1715
+ * Which directories on another machine start with `prefix` — `path.complete`, asked over there.
1716
+ *
1717
+ * The same answers in the same shape as the local one: each entry as you would have typed it,
1718
+ * trailing separator and all, so a single field can complete against either machine and the only
1719
+ * thing that changes is which one is asked. Directory names only.
1720
+ *
1721
+ * {@link NodeCapabilities.projects} is what a machine *offers*, and it is the short list of
1722
+ * places it already works in; this is how you reach the directory over there that neither machine
1723
+ * has ever opened. Rejects when the peer is not connected, when it does not accept commands, and
1724
+ * when it is running a neosh too old to have heard of the question — the message says which, and
1725
+ * an empty list is a directory with nothing in it rather than a failure.
1726
+ *
1727
+ * Passing this machine's own node id answers locally, so a picker that walks `nodes()` needs no
1728
+ * special case for the row that is itself.
1729
+ */
1730
+ browse(node: NodeId, prefix?: string): Promise<string[]>;
1617
1731
  /**
1618
1732
  * Watch a remote conversation: its history now, then everything as it happens, delivered to
1619
1733
  * {@link onStream}.
@@ -1641,6 +1755,17 @@ export interface SwarmApi {
1641
1755
  pair(node: NodeId, opts?: { name?: string; addr?: string }): Promise<void>;
1642
1756
  /** Withdraw authorisation and stop connecting. Refuses for a machine your config declared. */
1643
1757
  unpair(node: NodeId): Promise<void>;
1758
+ /**
1759
+ * Call a machine something else — an SSH `Host` alias, and for the same reasons.
1760
+ *
1761
+ * A hostname is chosen by whoever set the machine up, is often `Mac` or `ubuntu` on every box
1762
+ * somebody owns, and is not what its owner calls it. This wins over the announced name
1763
+ * everywhere a `SwarmNode` is read, so every panel agrees; `null` gives the announced name back.
1764
+ *
1765
+ * Refuses for a machine your `config.toml` declared, naming the `name` field to change instead —
1766
+ * an alias stored beside a config peer would be reverted by the next reload.
1767
+ */
1768
+ rename(node: NodeId, name: string | null): Promise<void>;
1644
1769
  /**
1645
1770
  * Dial a down peer again now, rather than waiting out its retry delay.
1646
1771
  *
@@ -1695,8 +1820,15 @@ export interface PathApi {
1695
1820
  *
1696
1821
  * `~` expands against the home directory. A prefix with no `/` completes against the active
1697
1822
  * conversation's directory, which is what someone typing `src` means.
1823
+ *
1824
+ * `denied` is set when the list is empty *because the operating system refused*, rather than
1825
+ * because nothing matched. The two are the same empty array and they are not the same answer:
1826
+ * macOS grants folder access to the **terminal**, so `~/Documents` full of projects reads as
1827
+ * empty until somebody ticks a box, and a field that draws "no directory matches" over that
1828
+ * sends people to go and check a path that was right all along. When it is set it says which
1829
+ * folder, which application and which System Settings pane, and it is meant to be shown.
1698
1830
  */
1699
- complete(prefix: string): Promise<string[]>;
1831
+ complete(prefix: string): Promise<{ paths: string[]; denied?: string }>;
1700
1832
  }
1701
1833
 
1702
1834
  export interface TimerApi {
@@ -1968,7 +2100,8 @@ function build(
1968
2100
  },
1969
2101
  path: {
1970
2102
  async complete(prefix) {
1971
- return expect(await c({ call: "path_complete", prefix }), "paths").paths;
2103
+ const answer = expect(await c({ call: "path_complete", prefix }), "paths");
2104
+ return { paths: answer.paths, denied: answer.denied ?? undefined };
1972
2105
  },
1973
2106
  },
1974
2107
  state: {
@@ -2053,6 +2186,9 @@ function build(
2053
2186
  async unpair(node) {
2054
2187
  await c({ call: "swarm_unpair", node });
2055
2188
  },
2189
+ async rename(node, name) {
2190
+ await c({ call: "swarm_rename", node, name });
2191
+ },
2056
2192
  async reconnect(node) {
2057
2193
  await c({ call: "swarm_reconnect", node });
2058
2194
  },
@@ -2066,7 +2202,13 @@ function build(
2066
2202
  return expect(await c({ call: "swarm_hosts_of", project }), "names").names;
2067
2203
  },
2068
2204
  async command(node, session, command) {
2069
- await c({ call: "swarm_command", node, session, command });
2205
+ const done = await c({ call: "swarm_command", node, session, command });
2206
+ // Only `new_session` says anything, so an `ok` of any other shape is an older peer
2207
+ // answering a command that had nothing to report — which is a success, not a mismatch.
2208
+ return done.ok === "swarm_commanded" ? done.session ?? null : null;
2209
+ },
2210
+ async browse(node, prefix) {
2211
+ return expect(await c({ call: "swarm_browse", node, prefix: prefix ?? "" }), "paths").paths;
2070
2212
  },
2071
2213
  async subscribe(node, session) {
2072
2214
  await c({ call: "swarm_subscribe", node, session });
@@ -2258,10 +2400,79 @@ function build(
2258
2400
  return listener(list, cb);
2259
2401
  },
2260
2402
  },
2403
+ pane: {
2404
+ async active(view) {
2405
+ const { tabs, active } = expect(await c({ call: "tab_list", view }), "tabs");
2406
+ // The active tab, or the first — a terminal always has at least one, and a `tabs` that
2407
+ // came back empty is a workspace that has gone wrong in a way this cannot paper over.
2408
+ const tab = tabs.find((t) => t.id === active) ?? tabs[0];
2409
+ if (!tab) throw new Error("this terminal has no tabs");
2410
+ return tab.active_pane;
2411
+ },
2412
+ async split(pane, dir) {
2413
+ const p = expect(await c({ call: "pane_split", pane, dir }), "pane").pane;
2414
+ if (p === null) throw new Error(`pane ${pane} could not be split`);
2415
+ return p;
2416
+ },
2417
+ async close(pane) {
2418
+ await c({ call: "pane_close", pane });
2419
+ },
2420
+ async focus(pane) {
2421
+ await c({ call: "pane_focus", pane });
2422
+ },
2423
+ async focusDir(dir, view) {
2424
+ return expect(await c({ call: "pane_focus_dir", dir, view }), "pane").pane;
2425
+ },
2426
+ async resize(pane, dir, delta) {
2427
+ await c({ call: "pane_resize", pane, dir, delta });
2428
+ },
2429
+ async swap(pane, withPane) {
2430
+ await c({ call: "pane_swap", pane, with: withPane });
2431
+ },
2432
+ async moveToEdge(pane, dir) {
2433
+ await c({ call: "pane_move_edge", pane, dir });
2434
+ },
2435
+ async equalize(view) {
2436
+ await c({ call: "pane_equalize", view });
2437
+ },
2438
+ },
2439
+ tab: {
2440
+ async list(view) {
2441
+ const r = expect(await c({ call: "tab_list", view }), "tabs");
2442
+ return { tabs: r.tabs, active: r.active };
2443
+ },
2444
+ async create(opts) {
2445
+ return expect(
2446
+ await c({
2447
+ call: "tab_new",
2448
+ view: opts?.view,
2449
+ title: opts?.title,
2450
+ activate: opts?.activate ?? true,
2451
+ }),
2452
+ "tab",
2453
+ ).tab;
2454
+ },
2455
+ async close(tab) {
2456
+ await c({ call: "tab_close", tab });
2457
+ },
2458
+ async select(tab) {
2459
+ await c({ call: "tab_select", tab });
2460
+ },
2461
+ async step(delta, view) {
2462
+ await c({ call: "tab_step", delta, view });
2463
+ },
2464
+ async move(tab, to) {
2465
+ await c({ call: "tab_move", tab, to });
2466
+ },
2467
+ async rename(tab, title) {
2468
+ await c({ call: "tab_rename", tab, title });
2469
+ },
2470
+ },
2261
2471
  win: {
2262
2472
  async open(buf, dock, opts) {
2263
2473
  const layout: WindowLayout = {
2264
2474
  kind: "docked",
2475
+ pane: opts?.pane ?? null,
2265
2476
  dock,
2266
2477
  size: opts?.size ?? null,
2267
2478
  gravity: opts?.gravity ?? "start",
package/src/ui.ts CHANGED
@@ -1352,6 +1352,10 @@ export async function pathPicker(
1352
1352
  title: string,
1353
1353
  opts: { initial?: string; width?: number; height?: number } = {},
1354
1354
  ): Promise<string | null> {
1355
+ // The last refusal said out loud, so a folder the OS is hiding is reported once rather than on
1356
+ // every keystroke into it. `source` runs per character typed, and a notice per character is the
1357
+ // same message forty times for one cause.
1358
+ let saidDenied: string | null = null;
1355
1359
  return picker<string>(neosh, [], {
1356
1360
  title,
1357
1361
  width: Math.max(40, opts.width ?? 72),
@@ -1359,8 +1363,19 @@ export async function pathPicker(
1359
1363
  query: opts.initial ?? "",
1360
1364
  placeholder: "no directory matches — <CR> takes what you typed",
1361
1365
  source: async (query) => {
1362
- const paths = await neosh.path.complete(query).catch(() => []);
1363
- return paths.map((path) => ({ label: path, value: path }));
1366
+ const answer = await neosh.path
1367
+ .complete(query)
1368
+ .catch(() => ({ paths: [] as string[], denied: undefined }));
1369
+ // An empty list because nobody was allowed to look is not an empty directory, and the
1370
+ // placeholder under this field can only say the second one. Said the way the remote branch
1371
+ // of the same question says it — a notice, because it is feedback for a key you pressed.
1372
+ if (answer.denied && answer.denied !== saidDenied) {
1373
+ saidDenied = answer.denied;
1374
+ neosh.notify(answer.denied, "warn");
1375
+ } else if (!answer.denied) {
1376
+ saidDenied = null;
1377
+ }
1378
+ return answer.paths.map((path) => ({ label: path, value: path }));
1364
1379
  },
1365
1380
  // What you typed, when it is not one of the offered rows. A completion list that refuses a path
1366
1381
  // it did not think of is a list that gets in the way.
@@ -2755,12 +2770,34 @@ export interface ActionItem {
2755
2770
  on?: string;
2756
2771
  }
2757
2772
 
2773
+ /** One coloured run of a badge. A part with no `hl` is the panel's own colour — a separator. */
2774
+ export interface BadgePart {
2775
+ text: string;
2776
+ hl?: string;
2777
+ }
2778
+
2779
+ /**
2780
+ * A mark after a row's name: one coloured run, or several.
2781
+ *
2782
+ * Several, because a badge is often several facts rather than one — `↓2` is not the same news as
2783
+ * `~7`, and a strip of git stats drawn in one colour is a string you have to read rather than a
2784
+ * row you can glance at. The single form stays because most badges *are* one fact.
2785
+ *
2786
+ * `short` is the same badge with less in it, for a row too narrow to carry all of it — the fewest
2787
+ * parts still worth drawing, not a truncation. A panel is what knows how many columns there are;
2788
+ * which fact to give up is the badge's to say, exactly as it is for a status segment. Absent means
2789
+ * there is no shorter true version, and the full badge stands.
2790
+ */
2791
+ export type BadgeSpec =
2792
+ | { text: string; hl?: string }
2793
+ | { parts: BadgePart[]; short?: BadgePart[] };
2794
+
2758
2795
  /** A mark on a row a panel already draws, keyed by what the row is about. */
2759
2796
  export interface DecorationItem {
2760
2797
  /** The row it is about, in the panel's own terms: `{ project: cwd }`, `{ task: id }`. */
2761
2798
  target: Record<string, string>;
2762
- /** A short mark after the name, in `hl`. The name is clipped to make room. */
2763
- badge?: { text: string; hl?: string };
2799
+ /** A short mark after the name. The name is clipped to make room. */
2800
+ badge?: BadgeSpec;
2764
2801
  /** The row's highlight, when the panel has no opinion of its own. */
2765
2802
  hl?: string;
2766
2803
  /** The right-hand column, on a row that is not busy. */
@@ -2769,11 +2806,43 @@ export interface DecorationItem {
2769
2806
 
2770
2807
  /** Every decoration on one target, merged. */
2771
2808
  export interface Decoration {
2772
- badge?: { text: string; hl?: string };
2809
+ /** `text` is every part run together — what a caller measuring the badge reads. */
2810
+ badge?: { text: string; hl?: string; parts: BadgePart[]; short: BadgePart[] };
2773
2811
  hl?: string;
2774
2812
  right?: { text: string; hl?: string };
2775
2813
  }
2776
2814
 
2815
+ /** A badge in either form, as the run of parts the rest of this file works in. */
2816
+ function badgeParts(badge: BadgeSpec | undefined): BadgePart[] {
2817
+ if (!badge || typeof badge !== "object") return [];
2818
+ const many = (badge as { parts?: unknown }).parts;
2819
+ if (Array.isArray(many)) {
2820
+ return many.filter((p): p is BadgePart =>
2821
+ Boolean(p) && typeof p === "object" && typeof (p as BadgePart).text === "string" &&
2822
+ (p as BadgePart).text !== ""
2823
+ );
2824
+ }
2825
+ const one = badge as { text?: unknown; hl?: unknown };
2826
+ if (typeof one.text !== "string" || one.text === "") return [];
2827
+ return [{ text: one.text, hl: typeof one.hl === "string" ? one.hl : undefined }];
2828
+ }
2829
+
2830
+ /**
2831
+ * A badge's short form, falling back to the whole of it.
2832
+ *
2833
+ * The fallback is what lets several decorations merge without anybody declaring one: a plugin with
2834
+ * nothing to give up keeps all of its parts in both forms, and {@link fitBadge} notices that the
2835
+ * two are the same width and leaves the badge alone.
2836
+ */
2837
+ function badgeShort(badge: BadgeSpec | undefined): BadgePart[] {
2838
+ const declared = (badge as { short?: unknown } | undefined)?.short;
2839
+ if (!Array.isArray(declared)) return badgeParts(badge);
2840
+ return declared.filter((p): p is BadgePart =>
2841
+ Boolean(p) && typeof p === "object" && typeof (p as BadgePart).text === "string" &&
2842
+ (p as BadgePart).text !== ""
2843
+ );
2844
+ }
2845
+
2777
2846
  /** The key a decoration's `target` files under: `project:/w/x`, `task:17`. */
2778
2847
  export function decorationKey(target: unknown): string | null {
2779
2848
  if (!target || typeof target !== "object") return null;
@@ -2797,11 +2866,16 @@ export function mergeDecorations(
2797
2866
  const key = decorationKey(c.item?.target);
2798
2867
  if (key === null) continue;
2799
2868
  const d = out.get(key) ?? {};
2800
- const badge = c.item.badge;
2801
- if (typeof badge?.text === "string" && badge.text !== "") {
2802
- d.badge = d.badge
2803
- ? { text: `${d.badge.text} ${badge.text}`, hl: d.badge.hl }
2804
- : { text: badge.text, hl: badge.hl };
2869
+ const parts = badgeParts(c.item.badge);
2870
+ if (parts.length > 0) {
2871
+ const short = badgeShort(c.item.badge);
2872
+ const merged = d.badge ? [...d.badge.parts, { text: " " }, ...parts] : parts;
2873
+ d.badge = {
2874
+ text: merged.map((p) => p.text).join(""),
2875
+ hl: d.badge?.hl ?? parts[0]?.hl,
2876
+ parts: merged,
2877
+ short: d.badge ? [...d.badge.short, { text: " " }, ...short] : short,
2878
+ };
2805
2879
  }
2806
2880
  if (d.hl === undefined && typeof c.item.hl === "string") d.hl = c.item.hl;
2807
2881
  if (d.right === undefined && typeof c.item.right?.text === "string") {
@@ -2812,9 +2886,41 @@ export function mergeDecorations(
2812
2886
  return out;
2813
2887
  }
2814
2888
 
2815
- /** How many columns a decoration's badge will take, for a row builder to leave free. */
2889
+ /**
2890
+ * How many columns a decoration's badge will take, for a row builder to leave free.
2891
+ *
2892
+ * Columns rather than bytes, which is what a row builder is subtracting this from: `●3` is four
2893
+ * bytes and two columns, and a strip of arrows is three bytes each — measured in bytes, a badge
2894
+ * that says `↓2 ↑5 ~7` takes eleven columns off a name to occupy nine.
2895
+ */
2816
2896
  export function badgeWidth(d: Decoration | undefined): number {
2817
- return d?.badge ? byteLength(` ${d.badge.text}`) : 0;
2897
+ return d?.badge ? width(` ${d.badge.text}`) : 0;
2898
+ }
2899
+
2900
+ /**
2901
+ * The badge as this row can actually afford it: the whole of it, or its short form.
2902
+ *
2903
+ * **The badge yields before the name does.** A row's name is what the row *is* — a project you
2904
+ * cannot read the name of is not a row you can use — and a badge is news about it. So the full
2905
+ * strip is drawn whenever the name fits beside it, and a name that would be clipped by the badge
2906
+ * gets the short form instead. Only if it still does not fit is the name clipped, which is the
2907
+ * panel's own business and unchanged.
2908
+ *
2909
+ * `room` is every column the name and the badge have between them, `name` the columns the name
2910
+ * wants. A badge whose short form saves nothing is left alone, which is what makes the fallback in
2911
+ * {@link badgeShort} safe: a plugin that declared no short form never has one chosen for it.
2912
+ */
2913
+ export function fitBadge(
2914
+ d: Decoration | undefined,
2915
+ room: number,
2916
+ name: number,
2917
+ ): Decoration | undefined {
2918
+ if (!d?.badge) return d;
2919
+ const full = badgeWidth(d);
2920
+ if (name + full <= room) return d;
2921
+ const short = { ...d.badge, text: d.badge.short.map((p) => p.text).join("") };
2922
+ if (width(` ${short.text}`) >= full) return d;
2923
+ return { ...d, badge: { ...short, parts: d.badge.short } };
2818
2924
  }
2819
2925
 
2820
2926
  /**
@@ -2833,12 +2939,18 @@ export function decorateRow<T>(
2833
2939
  if (!d) return row;
2834
2940
  if (d.badge) {
2835
2941
  const mark = ` ${d.badge.text}`;
2836
- const from = byteLength(row.text);
2942
+ // Past the space this puts between the name and the badge: it is the panel's colour, not the
2943
+ // badge's, and a span over it would be one colour claiming a column it does not fill.
2944
+ let at = byteLength(row.text) + 1;
2837
2945
  row.text = `${row.text}${mark}`;
2838
2946
  if (row.full !== undefined) row.full = `${row.full}${mark}`;
2839
- if (d.badge.hl) {
2840
- row.spans = [...(row.spans ?? []), { from, to: from + byteLength(mark), hl: d.badge.hl }];
2947
+ const spans = [...(row.spans ?? [])];
2948
+ for (const part of d.badge.parts) {
2949
+ const to = at + byteLength(part.text);
2950
+ if (part.hl) spans.push({ from: at, to, hl: part.hl });
2951
+ at = to;
2841
2952
  }
2953
+ if (spans.length > 0) row.spans = spans;
2842
2954
  }
2843
2955
  if (d.hl && row.hl === undefined) row.hl = d.hl;
2844
2956
  if (d.right && !busy) row.right = { text: `${d.right.text} `, hl: d.right.hl };