opencode-courier 0.7.1 → 0.7.3

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/README.md CHANGED
@@ -21,7 +21,7 @@ The edge cases on the live terminal UI, narrated ([what it shows](#demos)).
21
21
  ## Quickstart
22
22
 
23
23
  ```bash
24
- npm install -g @opencode/cli@2.0.30 # OpenCode V2, at the version courier is tested on
24
+ npm install -g @opencode/cli@2.0.32 # OpenCode V2, at the version courier is tested on
25
25
  opencode plugin add opencode-courier # by name, so OpenCode offers new releases
26
26
  opencode # in any folder
27
27
  ```
@@ -43,9 +43,9 @@ sometimes stall on a request; if nothing moves for a few minutes, send the promp
43
43
 
44
44
  Courier is built on OpenCode V2's plugin API (`@opencode/plugin`), not the V1 one
45
45
  (`@opencode-ai/plugin`), and needs OpenCode V2. Each release is pinned to one V2 version and tested
46
- against it end to end; the current one is tested on **OpenCode 2.0.30**. CI also runs the live
46
+ against it end to end; the current one is tested on **OpenCode 2.0.32**. CI also runs the live
47
47
  suite on the newest OpenCode release, on `main` and nightly, so a host release that breaks the
48
- plugin shows up there first. Older releases and their versions: [Supported OpenCode version](#supported-opencode-version).
48
+ plugin shows up there first. Older releases and their versions: [every release](docs/reference.md#the-opencode-version).
49
49
 
50
50
  ## Courier or the built-in subagent tool?
51
51
 
@@ -111,24 +111,23 @@ pinned to its version. OpenCode never checks it:
111
111
  the plugin installs and loads on any V2 host, and on a version other than the pinned one writes one
112
112
  line to the server log naming both ([more](docs/reference.md#the-opencode-version)).
113
113
 
114
+ <!-- compat-table:start rows=3: generated by script/compat-table.ts from the release tags -->
114
115
  | opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
115
116
  |---|---|
117
+ | 0.7.3 | 2.0.32 |
118
+ | 0.7.2 | 2.0.31 |
116
119
  | 0.7.1 | 2.0.30 |
117
- | 0.5.0, 0.6.0, 0.7.0 | 2.0.29 |
118
- | 0.4.0, 0.4.1 | 2.0.28 |
119
- | 0.2.3, 0.3.0 | 2.0.26 |
120
- | 0.2.2 | 2.0.24 |
121
- | 0.2.1 | 2.0.23 |
122
- | 0.2.0 | 2.0.22 |
123
- | 0.1.6 | 0.0.0-beta-19271 (the beta line: `opencode2` from `@opencode-ai/cli`, and `@opencode-ai/plugin`) |
120
+ <!-- compat-table:end -->
121
+
122
+ Older releases and their versions: [every release](docs/reference.md#the-opencode-version).
124
123
 
125
124
  The [terminal part](#in-the-terminal) relies on the host to provide its UI modules and is tested on
126
- OpenCode 2.0.30 only; the server part works as before on older V2 hosts from 2.0.4 on.
125
+ OpenCode 2.0.32 only; the server part works as before on older V2 hosts from 2.0.4 on.
127
126
 
128
127
  Check yours with `opencode --version`, and install the matching CLI with:
129
128
 
130
129
  ```bash
131
- npm install -g @opencode/cli@2.0.30
130
+ npm install -g @opencode/cli@2.0.32
132
131
  ```
133
132
 
134
133
  ### The plugin
@@ -271,17 +270,34 @@ courier 1 waiting on you · 2 running · group pair 1/2
271
270
  ```
272
271
 
273
272
  `/courier tree` opens a panel beside the session with the tree under it, a row per session indented
274
- by depth, with what it does, its report and its group's standing; a session waiting on you gets a line per
273
+ by depth, with a mark and colour from your theme for its state (`✓` reported, a spinner `⠋` while it runs, `●` waiting on you, `!` stalled,
274
+ `✗` failed or interrupted), what it does, its report and its group's standing; a session waiting on you gets a line per
275
275
  request or form (`shows a form: Web Search`), a stalled one since when its message waits, a session that started others gets
276
- a bar of the parts reported, a leaf how long it ran, its last tool call and its cost. A tree that has
276
+ a bar of the parts reported, a leaf how long it ran, its last tool call, how full its context is (`ctx 31%`, in the warning colour past 80%) and its cost. A tree that has
277
277
  started more than `maxTotal` sessions over time is listed live ones first, then newest, and the header
278
278
  says how many older ones are left out; the footer counts them all. `↑`/`↓` select,
279
- `enter` opens the session, `s` stops it and everything under it after you confirm, as `courier_stop`
279
+ `enter` or a click opens the session, a row under the pointer is highlighted, `s` stops it and everything under it after you confirm, as `courier_stop`
280
280
  would, `x` forgets a `gone` session after you confirm, removing its worktree by `courier_cleanup`'s
281
281
  rules, and `q` closes the panel; `/courier stop <session>` stops from the prompt. While open, the panel
282
282
  follows you between sessions: into one of its tree it keeps that tree, the session on screen marked
283
283
  `(on screen)`; elsewhere it shows that session's tree, or hides while you are in a session without one,
284
284
  or on the home screen, and shows again once you are back in one; only `q` closes it for good.
285
+ A row whose report arrives flashes in your theme's success colour and fades back over 1.5 s, and the header's bar
286
+ grows to its new length; nothing in the footer moves. Next to the header's title digs Digby, the courier's mole, drawn
287
+ in your theme's colours, whose state follows the tree's: a breathing mound while all is idle, a trembling one while
288
+ children run, up with a blinking sign while one waits on you, slumped while one is stalled, popping up with a letter
289
+ for a report and raising mounds for a spawn. Where the panel has room the tree does not use, on an empty panel or 10
290
+ rows free below the last row, a larger Digby, 16 by 8, shows the same state there instead, paws, sign, letter and all
291
+ ([more](docs/reference.md#in-the-terminal)).
292
+ To keep the panel still (`•` for a running row, no flash, the bar at once, Digby in each state's first frame), give the TUI part `"animations": false` in OpenCode's terminal config, `~/.config/opencode/cli.json`:
293
+ the TUI passes options to its plugins only from there, not from the server's `opencode.json`, and has no animations
294
+ setting a plugin can read ([more](docs/reference.md#in-the-terminal)).
295
+
296
+ ```jsonc
297
+ {
298
+ "plugins": [{ "package": "opencode-courier", "options": { "animations": false } }]
299
+ }
300
+ ```
285
301
 
286
302
  `ctrl+x k` (the leader key, then `k`), `/asks` (or `/ask`), the palette's "Courier: what waits on you" or a click
287
303
  on "waiting on you" opens the asks inbox: every permission request, question and form waiting
package/dist/index.js CHANGED
@@ -87,6 +87,7 @@ export const courier = () => Plugin.define({
87
87
  directory: ctx.location.directory,
88
88
  projectID: ctx.location.project.id,
89
89
  now: Date.now,
90
+ windows: async () => (await ctx.model.list()).data.map((model) => ({ providerID: model.providerID, id: model.id, context: model.limit.context })),
90
91
  head: headOf,
91
92
  dirty: dirtyOf,
92
93
  pending: async (sessionID) => {
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- const README_TABLE = "https://github.com/ivopogace/opencode-courier#supported-opencode-version";
2
+ const VERSION_TABLE = "https://github.com/ivopogace/opencode-courier/blob/main/docs/reference.md#the-opencode-version";
3
3
  /**
4
4
  * Reads from the package's own package.json, next to `dist/` as to `src/`: `version` and the exact
5
5
  * `@opencode/plugin` devDependency version, which the peer repeats. Throws if either is missing.
@@ -25,5 +25,5 @@ export function versionNotice(built, app) {
25
25
  const running = version === undefined ? "this server reports no version" : `this server is ${version}`;
26
26
  const channel = typeof app?.channel === "string" ? ` (channel ${app.channel})` : "";
27
27
  return (`opencode-courier ${built.plugin} was built and tested against OpenCode ${built.opencode}; ${running}${channel}. ` +
28
- `Its tools may fail: see the Supported OpenCode version table in the README, ${README_TABLE}`);
28
+ `Its tools may fail: see every release and the OpenCode it is pinned to, ${VERSION_TABLE}`);
29
29
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A pixel of Digby, the tree panel's mole, as the theme colour it takes: `b` body, `k` background (eyes), `g` mound,
3
+ * `m` muted (paws, dirt, post, arm), `a` accent (snout, letter), `w`, `e` and `s` warning, error and success (sign, dots, spark).
4
+ */
5
+ export type Token = "b" | "k" | "g" | "m" | "a" | "w" | "e" | "s";
6
+ /** What Digby shows: the tree's state, or for a while after one arrives, a spawn or a report. */
7
+ export type MascotState = "idle" | "spawn" | "running" | "waiting" | "stalled" | "report" | "gone";
8
+ /** One cell of a drawing: `▀` the top pixel's colour over the bottom's, `▄` only a bottom pixel, `█` both alike, or a character. */
9
+ export type Cell = {
10
+ readonly char: string;
11
+ readonly fg?: Token;
12
+ readonly bg?: Token;
13
+ };
14
+ /** Digby's frames take one panel frame in this many, about every 450 ms. */
15
+ export declare const MASCOT_EVERY = 5;
16
+ /** He blinks on every this-many-th of his frames where his eyes are open. */
17
+ export declare const BLINK_EVERY = 11;
18
+ /**
19
+ * The 8×4 glyph's frames by state, pixel rows top to bottom; a state with several cycles through them, one per mascot frame.
20
+ * No muted pixel touches the mound, here or in the 16×16, as `text.muted` is close to `background.raised.max`.
21
+ */
22
+ export declare const GLYPH: Readonly<Record<MascotState, ReadonlyArray<ReadonlyArray<string>>>>;
23
+ /** The 8×4 glyph's pixels for `state` at mascot frame `frame`, its first frame with none: animations off. */
24
+ export declare function glyph(state: MascotState, frame?: number): ReadonlyArray<string>;
25
+ /** Pixel rows, two to a line of cells, by the half-block rule; an odd last row has nothing under it. */
26
+ export declare function cells(pixels: ReadonlyArray<string>): ReadonlyArray<ReadonlyArray<Cell>>;
27
+ /** How his eyes are drawn: one pixel each, `wide` two tall, `shut` two wide, or `none` (blinking). */
28
+ export type Eyes = "open" | "wide" | "shut" | "none";
29
+ /**
30
+ * The 16×16 Digby, his head raised by `rise` (0 in the hole, 1 fully up; below 0 out of sight) over a body in the hole,
31
+ * then the mound (its top row `top` wide, `shift` columns over) and the ground, both `mound`, drawn over it but for the hole.
32
+ */
33
+ export declare function digby(rise: number, { top, eyes, slump, shift, mound }?: {
34
+ top?: number;
35
+ eyes?: Eyes;
36
+ slump?: boolean;
37
+ shift?: number;
38
+ mound?: "g" | "m";
39
+ }): ReadonlyArray<string>;
40
+ /** A spot of `pixels` with its first at row `row`, column `column`; `.` leaves what is under it. */
41
+ type Spot = readonly [row: number, column: number, pixels: string];
42
+ /** `pixels` with `spots` drawn over them. */
43
+ export declare function paint(pixels: ReadonlyArray<string>, spots: ReadonlyArray<Spot>): ReadonlyArray<string>;
44
+ /** The 16×16's frames by state but idle, each its pixels with the eyes open or, for a blink, not; the first reads alone. */
45
+ export declare const ART: Readonly<Record<Exclude<MascotState, "idle">, ReadonlyArray<(open: boolean) => ReadonlyArray<string>>>>;
46
+ /** The 16×16 idle Digby at mascot frame `frame`, its first with none: the mound breathing, a `z` drifting up beside it. */
47
+ export declare function idleArt(frame?: number): ReadonlyArray<ReadonlyArray<Cell>>;
48
+ /** The 16×16 Digby for `state` at mascot frame `frame`, its first frame with none: animations off. */
49
+ export declare function art(state: MascotState, frame?: number): ReadonlyArray<ReadonlyArray<Cell>>;
50
+ export {};
@@ -0,0 +1,171 @@
1
+ /** Digby's frames take one panel frame in this many, about every 450 ms. */
2
+ export const MASCOT_EVERY = 5;
3
+ /** He blinks on every this-many-th of his frames where his eyes are open. */
4
+ export const BLINK_EVERY = 11;
5
+ /**
6
+ * The 8×4 glyph's frames by state, pixel rows top to bottom; a state with several cycles through them, one per mascot frame.
7
+ * No muted pixel touches the mound, here or in the 16×16, as `text.muted` is close to `background.raised.max`.
8
+ */
9
+ export const GLYPH = {
10
+ idle: [["........", "........", "..gggg..", "gggggggg"]],
11
+ spawn: [
12
+ ["........", "...bb...", "g.bbbb.g", "gggggggg"],
13
+ [".g....g.", "...bb...", "ggbbbbgg", "gggggggg"],
14
+ ],
15
+ running: [
16
+ ["..g..g..", "...bb...", ".gbbbbg.", "gggggggg"],
17
+ [".g....g.", "........", ".gbbbbg.", "gggggggg"],
18
+ ],
19
+ waiting: [
20
+ [".bbbbbbw", ".bkbbkbw", ".bbaabbm", "ggggggg."],
21
+ [".bbbbbbb", ".bkbbkbb", ".bbaabbm", "ggggggg."],
22
+ ],
23
+ stalled: [
24
+ ["..e.....", ".bbbbbb.", ".bkbbkb.", "gbbaabbg"],
25
+ ["..e.e...", ".bbbbbb.", ".bkbbkb.", "gbbaabbg"],
26
+ ["..e.e.e.", ".bbbbbb.", ".bkbbkb.", "gbbaabbg"],
27
+ ],
28
+ report: [
29
+ ["......aa", "bbbbbbaa", "bkbbkbm.", "bbaabbm."],
30
+ ["s.....aa", "bbbbbbaa", "bkbbkbm.", "bbaabbm."],
31
+ ],
32
+ gone: [["........", ".mmmmmm.", ".mkmmkm.", "mmmmmmmm"]],
33
+ };
34
+ /** The states in which his eyes are open, so he blinks. */
35
+ const OPEN_EYES = new Set(["waiting", "report"]);
36
+ /** The 8×4 glyph's pixels for `state` at mascot frame `frame`, its first frame with none: animations off. */
37
+ export function glyph(state, frame) {
38
+ const frames = GLYPH[state];
39
+ const pixels = frames[(frame ?? 0) % frames.length];
40
+ if (frame === undefined)
41
+ return pixels;
42
+ if (state === "idle" && breathesOut(frame))
43
+ return [...pixels.slice(0, 2), ".gggggg.", ...pixels.slice(3)];
44
+ return OPEN_EYES.has(state) && blinks(frame) ? pixels.map((row) => row.replaceAll("k", "b")) : pixels;
45
+ }
46
+ /** The mound's top row widens for two frames in four. */
47
+ const breathesOut = (frame) => Math.floor(frame / 2) % 2 === 1;
48
+ const blinks = (frame) => frame % BLINK_EVERY === BLINK_EVERY - 1;
49
+ /** Pixel rows, two to a line of cells, by the half-block rule; an odd last row has nothing under it. */
50
+ export function cells(pixels) {
51
+ const lines = [];
52
+ for (let row = 0; row < pixels.length; row += 2) {
53
+ const top = pixels[row];
54
+ const bottom = pixels[row + 1] ?? "";
55
+ lines.push([...top].map((pixel, column) => cell(token(pixel), token(bottom[column]))));
56
+ }
57
+ return lines;
58
+ }
59
+ const token = (pixel) => (pixel && pixel !== "." ? pixel : undefined);
60
+ function cell(top, bottom) {
61
+ if (!top && !bottom)
62
+ return { char: " " };
63
+ if (!top)
64
+ return { char: "▄", fg: bottom };
65
+ if (top === bottom)
66
+ return { char: "█", fg: top };
67
+ return bottom ? { char: "▀", fg: top, bg: bottom } : { char: "▀", fg: top };
68
+ }
69
+ /** The 16×16's size, in pixels. */
70
+ const SIZE = 16;
71
+ /** How far each of the head's rows leans right when he slumps. */
72
+ const SLUMP = [2, 2, 2, 1, 1, 0, 0];
73
+ /**
74
+ * The 16×16 Digby, his head raised by `rise` (0 in the hole, 1 fully up; below 0 out of sight) over a body in the hole,
75
+ * then the mound (its top row `top` wide, `shift` columns over) and the ground, both `mound`, drawn over it but for the hole.
76
+ */
77
+ export function digby(rise, { top = 6, eyes = "open", slump = false, shift = 0, mound = "g" } = {}) {
78
+ const grid = Array.from({ length: SIZE }, () => new Array(SIZE).fill("."));
79
+ const span = (row, width, pixel, skip = (_column) => false, by = 0) => {
80
+ if (row < 0 || row >= SIZE)
81
+ return;
82
+ for (let column = (SIZE - width) / 2; column < (SIZE + width) / 2; column++)
83
+ if (!skip(column))
84
+ grid[row][column + by] = pixel;
85
+ };
86
+ const head = Math.round(10 - 8 * rise);
87
+ const lean = (at) => (slump ? SLUMP[at] : 0);
88
+ [4, 6, 8, 8, 8, 6, 4].forEach((width, at) => span(head + at, width, "b", undefined, lean(at)));
89
+ for (let row = head + 7; row <= 11; row++)
90
+ span(row, 4, "b");
91
+ const eyeColumns = eyes === "shut" ? [5, 6, 9, 10] : [6, 9];
92
+ if (eyes !== "none")
93
+ span(head + 3, 8, "k", (column) => !eyeColumns.includes(column), lean(3));
94
+ if (eyes === "wide")
95
+ span(head + 2, 8, "k", (column) => !eyeColumns.includes(column), lean(2));
96
+ span(head + 4, 2, "a", undefined, lean(4));
97
+ span(10, SIZE, ".", (column) => column >= 6 && column <= 9);
98
+ span(10, top, mound, (column) => column >= 6 && column <= 9, shift);
99
+ span(11, 10, mound, undefined, shift);
100
+ span(12, 12, mound, undefined, shift);
101
+ for (const row of [13, 14, 15])
102
+ span(row, SIZE, mound);
103
+ return grid.map((row) => row.join(""));
104
+ }
105
+ /** `pixels` with `spots` drawn over them. */
106
+ export function paint(pixels, spots) {
107
+ const grid = pixels.map((row) => [...row]);
108
+ for (const [row, column, drawn] of spots)
109
+ [...drawn].forEach((pixel, at) => {
110
+ if (pixel !== "." && grid[row]?.[column + at] !== undefined)
111
+ grid[row][column + at] = pixel;
112
+ });
113
+ return grid.map((row) => row.join(""));
114
+ }
115
+ /** A column of `length` pixels of `pixel` from row `row`. */
116
+ const post = (row, column, length, pixel) => Array.from({ length }, (_, at) => [row + at, column, pixel]);
117
+ /** The head's top row at `rise`, where running's snout pokes up. */
118
+ const headTop = (rise) => Math.round(10 - 8 * rise);
119
+ const running = (rise, shift, dirt) => paint(digby(rise, { shift }), [[headTop(rise), 7, "aa"], ...dirt]);
120
+ /** A child's head peeking out of a side mound at columns `column`–`column + 2`, `up` or half sunk. */
121
+ const peek = (column, up) => [
122
+ ...(up ? [[8, column, ".b."], [9, column, "bkb"]] : [[9, column, ".b."]]),
123
+ [10, column, "gbg"],
124
+ [11, column, "ggg"],
125
+ ];
126
+ const spawn = (up, dirt) => paint(digby(0.1), [...peek(0, up), ...peek(13, up), [12, 0, "gg"], [12, 14, "gg"], ...dirt]);
127
+ /** The sign on its post, which stops a pixel above the ground so the muted post does not sit on it. */
128
+ const sign = (shown) => [...(shown ? [[2, 13, "www"], [3, 13, "wkw"], [4, 13, "www"]] : []), ...post(5, 14, 7, "m")];
129
+ /** His paws, held a pixel above the mound's rim so the muted pixels do not touch it. */
130
+ const waiting = (shown) => (open) => paint(digby(1, { eyes: open ? "wide" : "none" }), [[8, 4, "mm"], [8, 10, "mm"], ...sign(shown)]);
131
+ const stalled = (dots) => () => paint(digby(0.7, { eyes: "shut", slump: true }), [[5, 0, dots]]);
132
+ /** The letter held up on his arm, which stops a pixel above the mound so it does not touch it, and the spark beside him. */
133
+ const report = (rise, spark) => (open) => paint(digby(rise, { eyes: open ? "open" : "none" }), [
134
+ [0, 12, "aaaa"],
135
+ [1, 12, "akka"],
136
+ [2, 12, "aaaa"],
137
+ ...post(3, 13, 8, "m"),
138
+ ...spark.map((row, at) => [1 + at, 0, row]),
139
+ ]);
140
+ /** The 16×16's frames by state but idle, each its pixels with the eyes open or, for a blink, not; the first reads alone. */
141
+ export const ART = {
142
+ spawn: [
143
+ () => spawn(true, [[3, 1, "m"], [5, 3, "m"], [2, 14, "m"], [5, 12, "m"]]),
144
+ () => spawn(false, [[1, 0, "m"], [4, 2, "m"], [3, 15, "m"], [6, 13, "m"]]),
145
+ ],
146
+ running: [
147
+ () => running(0.22, 0, [[5, 2, "m"], [7, 1, "m"], [4, 13, "m"], [6, 14, "m"]]),
148
+ () => running(0.12, 1, [[3, 1, "m"], [6, 3, "m"], [5, 12, "m"], [3, 15, "m"]]),
149
+ ],
150
+ waiting: [waiting(true), waiting(false)],
151
+ stalled: [stalled("e.e.e"), stalled("e"), stalled("e.e")],
152
+ report: [report(1, [".s.", "sss", ".s."]), report(1.25, ["s.s", ".s.", "s.s"]), report(1.1, [".s.", "sss", ".s."])],
153
+ gone: [() => digby(-0.3, { mound: "m" })],
154
+ };
155
+ /** Where idle's `z` drifts up, by cell line, one step per mascot frame, then gone a frame. */
156
+ const Z_LINES = [3, 2, 1, undefined];
157
+ /** The 16×16 idle Digby at mascot frame `frame`, its first with none: the mound breathing, a `z` drifting up beside it. */
158
+ export function idleArt(frame) {
159
+ const lines = cells(digby(-0.3, { top: frame !== undefined && breathesOut(frame) ? 8 : 6 }));
160
+ const line = Z_LINES[(frame ?? 0) % Z_LINES.length];
161
+ if (line === undefined)
162
+ return lines;
163
+ return lines.map((cellsOf, at) => (at === line ? cellsOf.map((one, column) => (column === 13 ? { char: "z", fg: "m" } : one)) : cellsOf));
164
+ }
165
+ /** The 16×16 Digby for `state` at mascot frame `frame`, its first frame with none: animations off. */
166
+ export function art(state, frame) {
167
+ if (state === "idle")
168
+ return idleArt(frame);
169
+ const frames = ART[state];
170
+ return cells(frames[(frame ?? 0) % frames.length](!(frame !== undefined && OPEN_EYES.has(state) && blinks(frame))));
171
+ }
@@ -1,4 +1,4 @@
1
- import type { CourierPorts, Pending, ShownForm } from "./ports.js";
1
+ import type { CourierPorts, ModelWindow, Pending, ShownForm } from "./ports.js";
2
2
  import type { Tree, Wait } from "./rpc.js";
3
3
  type Messages = Awaited<ReturnType<CourierPorts["session"]["context"]>>;
4
4
  /** The last tool call in a session's messages, and when it was made. */
@@ -6,6 +6,8 @@ export declare function lastTool(messages: Messages): {
6
6
  name: string;
7
7
  at: number;
8
8
  } | undefined;
9
+ /** How full a session's context is, as OpenCode's session view reads it (packages/tui/src/util/session.ts, v2.0.32): the last usage since the last compaction over its model's window. */
10
+ export declare function contextOf(messages: Messages, windows: ReadonlyArray<ModelWindow>): number | undefined;
9
11
  /** A request as the panel says what a paused session waits on: a permission request, or its first question. */
10
12
  export declare const waitOf: (pending: Pending) => Wait;
11
13
  /** A form a session shows, as the panel says what it waits on. */
@@ -15,6 +15,17 @@ export function lastTool(messages) {
15
15
  }
16
16
  return undefined;
17
17
  }
18
+ /** How full a session's context is, as OpenCode's session view reads it (packages/tui/src/util/session.ts, v2.0.32): the last usage since the last compaction over its model's window. */
19
+ export function contextOf(messages, windows) {
20
+ const compacted = messages.findLastIndex((message) => message.type === "compaction" && message.status === "completed");
21
+ const last = messages.findLast((message, index) => index > compacted && message.type === "assistant" && message.tokens !== undefined);
22
+ if (last?.type !== "assistant" || !last.tokens)
23
+ return undefined;
24
+ const { input, output, reasoning, cache } = last.tokens;
25
+ const used = input + output + reasoning + cache.read + cache.write;
26
+ const window = windows.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)?.context;
27
+ return used > 0 && window ? used / window : undefined;
28
+ }
18
29
  /** A request as the panel says what a paused session waits on: a permission request, or its first question. */
19
30
  export const waitOf = (pending) => pending.type === "permission"
20
31
  ? { type: "permission", action: pending.action, resources: [...pending.resources] }
@@ -44,8 +55,8 @@ function stateOf(info, waits, stalled, running) {
44
55
  return "stalled";
45
56
  return running ? "running" : "idle";
46
57
  }
47
- /** One session of the panel: what it is doing, its report and group standing, its last tool call and its cost. */
48
- async function row(ports, { entry, depth, info, pending, forms, report }, stalled) {
58
+ /** One session of the panel: what it is doing, its report and group standing, its last tool call, context use and cost. */
59
+ async function row(ports, windows, { entry, depth, info, pending, forms, report }, stalled) {
49
60
  const { sessionID } = entry;
50
61
  const [messages, status, standing, stopped] = await Promise.all([
51
62
  ports.session.context({ sessionID }).catch(() => []),
@@ -55,6 +66,7 @@ async function row(ports, { entry, depth, info, pending, forms, report }, stalle
55
66
  ]);
56
67
  const running = info !== undefined && busy(info.time);
57
68
  const tool = lastTool(messages);
69
+ const context = contextOf(messages, windows);
58
70
  return {
59
71
  sessionID,
60
72
  parentID: entry.parentID,
@@ -70,6 +82,7 @@ async function row(ports, { entry, depth, info, pending, forms, report }, stalle
70
82
  created: info?.time.created ?? entry.createdAt,
71
83
  ...(info && !running && info.time.idle !== undefined ? { idle: info.time.idle } : {}),
72
84
  ...(info ? { cost: info.cost } : {}),
85
+ ...(context !== undefined ? { context } : {}),
73
86
  ...(tool ? { tool } : {}),
74
87
  waits: [...pending.map(waitOf), ...forms.map(formWait)],
75
88
  };
@@ -81,7 +94,8 @@ async function row(ports, { entry, depth, info, pending, forms, report }, stalle
81
94
  export async function panel(ports, rootID) {
82
95
  const { kept, leftOut } = await readTree(ports, rootID);
83
96
  const stalled = await stalledAmong(ports, [rootID, ...kept.map((node) => node.entry.sessionID)]);
84
- const rows = await Promise.all(kept.map((node) => row(ports, node, stalled.get(node.entry.sessionID))));
97
+ const windows = await ports.windows().catch(() => []);
98
+ const rows = await Promise.all(kept.map((node) => row(ports, windows, node, stalled.get(node.entry.sessionID))));
85
99
  const own = stalled.get(rootID);
86
100
  return { rows, ...(leftOut ? { truncated: leftOut } : {}), ...(own !== undefined ? { stalled: own } : {}) };
87
101
  }
@@ -164,6 +164,8 @@ export interface CourierPorts {
164
164
  readonly pending: (sessionID: string) => Promise<ReadonlyArray<Pending>>;
165
165
  /** The forms a session shows, which only the person can answer: web search's, a plugin's; not the question tool's. */
166
166
  readonly forms: (sessionID: string) => Promise<ReadonlyArray<ShownForm>>;
167
+ /** The context windows of the models available, by provider and model id, as the panel reads a session's context use. */
168
+ readonly windows: () => Promise<ReadonlyArray<ModelWindow>>;
167
169
  readonly limits: Limits;
168
170
  /** The `nativeChildren` option: a child started without isolate is created with OpenCode's own `parentID`. */
169
171
  readonly nativeChildren?: boolean;
@@ -174,6 +176,12 @@ export interface CourierPorts {
174
176
  readonly nudge: () => void;
175
177
  readonly log: (message: string) => void;
176
178
  }
179
+ /** A model's context window in tokens, as `CourierPorts.windows` lists it. */
180
+ export interface ModelWindow {
181
+ readonly providerID: string;
182
+ readonly id: string;
183
+ readonly context: number;
184
+ }
177
185
  /** A form a session shows, as `CourierPorts.forms` lists it; `person/` keeps them. */
178
186
  export interface ShownForm {
179
187
  readonly id: string;
@@ -77,6 +77,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
77
77
  readonly standing: string;
78
78
  } | undefined;
79
79
  readonly stopped?: boolean | undefined;
80
+ readonly context?: number | undefined;
80
81
  readonly idle?: number | undefined;
81
82
  readonly report?: {
82
83
  readonly progressed: boolean;
@@ -118,6 +119,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
118
119
  readonly standing: string;
119
120
  } | undefined;
120
121
  readonly stopped?: boolean | undefined;
122
+ readonly context?: number | undefined;
121
123
  readonly idle?: number | undefined;
122
124
  readonly report?: {
123
125
  readonly progressed: boolean;
@@ -157,6 +159,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
157
159
  readonly created: import("effect/Schema").Number;
158
160
  readonly idle: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
159
161
  readonly cost: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
162
+ readonly context: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
160
163
  readonly tool: import("effect/Schema").optionalKey<import("effect/Schema").Struct<{
161
164
  readonly name: import("effect/Schema").String;
162
165
  readonly at: import("effect/Schema").Number;
@@ -63,6 +63,8 @@ declare const Row: Schema.Struct<{
63
63
  readonly idle: Schema.optionalKey<Schema.Number>;
64
64
  /** What it has cost so far, in US dollars. */
65
65
  readonly cost: Schema.optionalKey<Schema.Number>;
66
+ /** How full its context is, as a fraction of the model's window; left out when the model or its limit cannot be read. */
67
+ readonly context: Schema.optionalKey<Schema.Number>;
66
68
  /** Its last tool call, and when it was made. */
67
69
  readonly tool: Schema.optionalKey<Schema.Struct<{
68
70
  readonly name: Schema.String;
@@ -116,6 +118,8 @@ declare const Tree: Schema.Struct<{
116
118
  readonly idle: Schema.optionalKey<Schema.Number>;
117
119
  /** What it has cost so far, in US dollars. */
118
120
  readonly cost: Schema.optionalKey<Schema.Number>;
121
+ /** How full its context is, as a fraction of the model's window; left out when the model or its limit cannot be read. */
122
+ readonly context: Schema.optionalKey<Schema.Number>;
119
123
  /** Its last tool call, and when it was made. */
120
124
  readonly tool: Schema.optionalKey<Schema.Struct<{
121
125
  readonly name: Schema.String;
@@ -240,6 +244,7 @@ export declare const treeRpc: {
240
244
  readonly standing: string;
241
245
  } | undefined;
242
246
  readonly stopped?: boolean | undefined;
247
+ readonly context?: number | undefined;
243
248
  readonly idle?: number | undefined;
244
249
  readonly report?: {
245
250
  readonly progressed: boolean;
@@ -281,6 +286,7 @@ export declare const treeRpc: {
281
286
  readonly standing: string;
282
287
  } | undefined;
283
288
  readonly stopped?: boolean | undefined;
289
+ readonly context?: number | undefined;
284
290
  readonly idle?: number | undefined;
285
291
  readonly report?: {
286
292
  readonly progressed: boolean;
@@ -332,6 +338,8 @@ export declare const treeRpc: {
332
338
  readonly idle: Schema.optionalKey<Schema.Number>;
333
339
  /** What it has cost so far, in US dollars. */
334
340
  readonly cost: Schema.optionalKey<Schema.Number>;
341
+ /** How full its context is, as a fraction of the model's window; left out when the model or its limit cannot be read. */
342
+ readonly context: Schema.optionalKey<Schema.Number>;
335
343
  /** Its last tool call, and when it was made. */
336
344
  readonly tool: Schema.optionalKey<Schema.Struct<{
337
345
  readonly name: Schema.String;
package/dist/tree/rpc.js CHANGED
@@ -53,6 +53,8 @@ const Row = Schema.Struct({
53
53
  idle: Schema.optionalKey(Schema.Number),
54
54
  /** What it has cost so far, in US dollars. */
55
55
  cost: Schema.optionalKey(Schema.Number),
56
+ /** How full its context is, as a fraction of the model's window; left out when the model or its limit cannot be read. */
57
+ context: Schema.optionalKey(Schema.Number),
56
58
  /** Its last tool call, and when it was made. */
57
59
  tool: Schema.optionalKey(Schema.Struct({ name: Schema.String, at: Schema.Number })),
58
60
  waits: Schema.Array(Wait),