@opencode-cockpit/status 0.3.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +254 -0
  3. package/dist/core/ansi.js +145 -0
  4. package/dist/core/authoring.js +13 -0
  5. package/dist/core/builtins/index.js +10 -0
  6. package/dist/core/builtins/model.js +204 -0
  7. package/dist/core/builtins/place.js +66 -0
  8. package/dist/core/builtins/session.js +72 -0
  9. package/dist/core/builtins/settings.js +28 -0
  10. package/dist/core/builtins/system.js +58 -0
  11. package/dist/core/claude-code.js +79 -0
  12. package/dist/core/command.js +77 -0
  13. package/dist/core/config.js +187 -0
  14. package/dist/core/context.js +24 -0
  15. package/dist/core/custom.js +125 -0
  16. package/dist/core/format.js +156 -0
  17. package/dist/core/render.js +88 -0
  18. package/dist/core/segments.js +148 -0
  19. package/dist/core/types.js +1 -0
  20. package/dist/tui/components/statusline.js +135 -0
  21. package/dist/tui/index.js +110 -0
  22. package/dist/tui/state/snapshot.js +144 -0
  23. package/dist/tui/state/store.js +51 -0
  24. package/package.json +63 -0
  25. package/types/core/ansi.d.ts +8 -0
  26. package/types/core/authoring.d.ts +17 -0
  27. package/types/core/builtins/index.d.ts +6 -0
  28. package/types/core/builtins/model.d.ts +3 -0
  29. package/types/core/builtins/place.d.ts +3 -0
  30. package/types/core/builtins/session.d.ts +3 -0
  31. package/types/core/builtins/settings.d.ts +13 -0
  32. package/types/core/builtins/system.d.ts +3 -0
  33. package/types/core/claude-code.d.ts +61 -0
  34. package/types/core/command.d.ts +35 -0
  35. package/types/core/config.d.ts +141 -0
  36. package/types/core/context.d.ts +78 -0
  37. package/types/core/custom.d.ts +49 -0
  38. package/types/core/format.d.ts +56 -0
  39. package/types/core/render.d.ts +22 -0
  40. package/types/core/segments.d.ts +26 -0
  41. package/types/core/types.d.ts +52 -0
  42. package/types/tui/components/statusline.d.ts +26 -0
  43. package/types/tui/index.d.ts +10 -0
  44. package/types/tui/state/snapshot.d.ts +11 -0
  45. package/types/tui/state/store.d.ts +28 -0
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The snapshot a segment sees. Deliberately a plain object rather than the live plugin api: every
3
+ * built-in is then a pure function of it, which is what makes the line testable without an
4
+ * OpenCode to draw it in.
5
+ */
6
+ export interface TokenCounts {
7
+ input: number;
8
+ output: number;
9
+ reasoning: number;
10
+ cache: {
11
+ read: number;
12
+ write: number;
13
+ };
14
+ }
15
+ export interface SessionSnapshot {
16
+ id: string;
17
+ title?: string;
18
+ /** `retry` carries the attempt and when the next one is due. */
19
+ status: "idle" | "busy" | "retry";
20
+ retry?: {
21
+ attempt: number;
22
+ message: string;
23
+ next: number;
24
+ };
25
+ model?: {
26
+ providerID: string;
27
+ modelID: string;
28
+ /** Absent for a provider whose context window nobody has declared. */
29
+ contextLimit?: number;
30
+ };
31
+ /** From the last assistant message: what is actually in the window right now. */
32
+ tokens?: TokenCounts;
33
+ /**
34
+ * Summed over the session. Zero for a provider with no declared prices — which is why the cost
35
+ * segment reports whether prices exist rather than trusting the number.
36
+ */
37
+ cost: number;
38
+ /** False when no model in play has prices, so cost is 0 because nobody said otherwise. */
39
+ priced: boolean;
40
+ messages: number;
41
+ startedAt?: number;
42
+ diff: {
43
+ files: number;
44
+ additions: number;
45
+ deletions: number;
46
+ };
47
+ todo: {
48
+ total: number;
49
+ completed: number;
50
+ };
51
+ }
52
+ export interface ServiceSnapshot {
53
+ name: string;
54
+ /** Anything but "connected"/"ready" reads as unhealthy. */
55
+ status: string;
56
+ }
57
+ export interface StatusContext {
58
+ now: number;
59
+ /** OpenCode's current directory and the worktree root it sits in. */
60
+ directory: string;
61
+ worktree: string;
62
+ home: string;
63
+ branch?: string;
64
+ defaultBranch?: string;
65
+ version: string;
66
+ session?: SessionSnapshot;
67
+ lsp: ServiceSnapshot[];
68
+ mcp: ServiceSnapshot[];
69
+ /** Output of the configured commands, by name. Absent until the first run finishes. */
70
+ commands: Record<string, string>;
71
+ /** Terminal width the line has to fit into. */
72
+ width: number;
73
+ }
74
+ /** Tokens that occupy the context window right now: everything the model reads back. */
75
+ export declare function contextUsed(tokens: TokenCounts | undefined): number;
76
+ export declare function contextRatio(session: SessionSnapshot | undefined): number | undefined;
77
+ export declare function todoRemaining(session: SessionSnapshot | undefined): number;
78
+ export declare function unhealthy(list: readonly ServiceSnapshot[]): ServiceSnapshot[];
@@ -0,0 +1,49 @@
1
+ import type { SegmentConfig } from "./config.ts";
2
+ import type { StatusContext } from "./context.ts";
3
+ import type { Run, SegmentDef, Tone } from "./segments.ts";
4
+ /**
5
+ * Your own segments, written in TypeScript.
6
+ *
7
+ * The declarative config covers the usual line and a shell command covers anything with a CLI, but
8
+ * neither can read the session and decide. A module can: it is handed the same snapshot the
9
+ * built-ins get, and what it returns is placed, coloured, prioritised and collapsed exactly like
10
+ * one of them.
11
+ *
12
+ * A module default-exports its segments by name:
13
+ *
14
+ * import type { StatusContext } from "@opencode-cockpit/status/segment"
15
+ *
16
+ * export default {
17
+ * segments: {
18
+ * burn: (ctx: StatusContext) => {
19
+ * const mins = (ctx.now - (ctx.session?.startedAt ?? ctx.now)) / 60000
20
+ * if (!ctx.session?.priced || mins < 1) return undefined
21
+ * return { text: `$${(ctx.session.cost / mins).toFixed(2)}/min`, tone: "warning" }
22
+ * },
23
+ * },
24
+ * }
25
+ *
26
+ * and the name is then usable in the config like any built-in: `"segments": ["burn"]`.
27
+ */
28
+ /** What a module's segment function is handed and what it may return. */
29
+ export type CustomRender = (ctx: StatusContext, config: SegmentConfig) => {
30
+ text: string;
31
+ tone?: Tone;
32
+ color?: string;
33
+ } | {
34
+ runs: Run[];
35
+ } | string | undefined;
36
+ export interface CustomModule {
37
+ segments?: Record<string, CustomRender | {
38
+ render: CustomRender;
39
+ priority?: number;
40
+ }>;
41
+ }
42
+ export interface LoadResult {
43
+ segments: Map<string, SegmentDef>;
44
+ /** One line per module that could not be loaded, for a toast the user can act on. */
45
+ errors: string[];
46
+ }
47
+ /** `~/x`, an absolute path, or one relative to the project. */
48
+ export declare function resolveModulePath(path: string, directory: string, home?: string): string;
49
+ export declare function loadCustomSegments(paths: readonly string[], directory: string, importer?: (path: string) => Promise<unknown>): Promise<LoadResult>;
@@ -0,0 +1,56 @@
1
+ /** Formatting for a line where every column costs something. */
2
+ /** 1234 → "1.2k", 1_200_000 → "1.2M". Whole numbers below 1000 stay as they are. */
3
+ export declare function compact(n: number): string;
4
+ /**
5
+ * Money, at the precision the amount deserves: cents matter at $0.42, they do not at $124.
6
+ * Sub-cent spend reads as "<$0.01" rather than "$0.00", which looks like nothing was spent.
7
+ */
8
+ export declare function money(amount: number, currency?: string): string;
9
+ /** "4s", "3m", "2h 5m" — a statusline has no room for "2 hours, 5 minutes". */
10
+ export declare function duration(ms: number): string;
11
+ /** A percentage with no decimal point, because the last digit never changes a decision. */
12
+ export declare function percent(ratio: number): string;
13
+ /** A fractional bar: "███▌ ". Width is in cells, and the result is always exactly that wide. */
14
+ export declare function bar(ratio: number, width: number): string;
15
+ /**
16
+ * The path as a person would say it: "" at the worktree root, "src/tui" inside it, "~/other"
17
+ * elsewhere under home, and the plain path otherwise.
18
+ */
19
+ export declare function shortPath(directory: string, worktree: string, home: string): string;
20
+ export declare function basename(path: string): string;
21
+ /**
22
+ * Model ids carry a vendor prefix and a date nobody reads at a glance
23
+ * ("anthropic/claude-opus-5-20260101" → "claude-opus-5").
24
+ */
25
+ export declare function shortModel(modelID: string): string;
26
+ /**
27
+ * Cuts from the left, keeping the end. For a path the tail is what identifies it: "…/src/tui"
28
+ * says where you are, "/Users/me/very/lo…" does not.
29
+ */
30
+ export declare function truncateStart(text: string, max: number): string;
31
+ /** Cuts to `max` cells, marking the cut. Never returns more than `max`. */
32
+ export declare function truncate(text: string, max: number): string;
33
+ type Rgb = [number, number, number];
34
+ /**
35
+ * The colour at `t` (0..1) along a gradient, interpolated rather than bucketed. A bar whose cells
36
+ * step smoothly from green to red reads as a measurement; one that flips between three colours
37
+ * reads as three states.
38
+ */
39
+ export declare function gradient(t: number, stops?: Rgb[]): string;
40
+ /**
41
+ * "3m42s" while you are watching it, "2d 13h" once you are not.
42
+ *
43
+ * Each tier drops the one below as it stops mattering: seconds are worth watching in the first
44
+ * minute and meaningless after an hour, and "61h48m" is a number nobody converts in their head.
45
+ */
46
+ export declare function preciseDuration(ms: number): string;
47
+ /**
48
+ * Filling `{name}` placeholders from a segment's own values.
49
+ *
50
+ * This is what a `format` setting runs on. A segment that draws several figures should not also
51
+ * decide the words between them — "3f +12 -4" suits one line and "+12/-4" another, and neither is
52
+ * ours to insist on. An unknown placeholder is left as written, so a typo is visible rather than
53
+ * silently blank.
54
+ */
55
+ export declare function template(text: string, values: Record<string, string | number>): string;
56
+ export {};
@@ -0,0 +1,22 @@
1
+ import { type Segment } from "./segments.ts";
2
+ /**
3
+ * Fitting the line to the terminal.
4
+ *
5
+ * Every statusline eventually meets a narrow window. Wrapping turns it into noise and a hard cut
6
+ * loses whichever segments happen to sit on the right, so instead the lowest-priority segments are
7
+ * dropped until what is left fits — the things you actually need (how full the context is, whether
8
+ * something is retrying) survive a 60-column terminal, and the decorations do not.
9
+ */
10
+ export interface FitResult {
11
+ segments: Segment[];
12
+ /** How many were dropped, so the line can say so. */
13
+ dropped: number;
14
+ }
15
+ export declare function lineWidth(segments: readonly Segment[], separator: string): number;
16
+ export declare function fit(segments: readonly Segment[], width: number, separator: string): FitResult;
17
+ /**
18
+ * Fitting a column. Height is the constraint rather than width, so segments are not merged onto a
19
+ * row: each takes one, cut to the column's width, and the lowest-priority ones go when there are
20
+ * more segments than rows.
21
+ */
22
+ export declare function fitColumn(segments: readonly Segment[], width: number, maxRows: number): FitResult;
@@ -0,0 +1,26 @@
1
+ import { BUILTINS } from "./builtins/index.ts";
2
+ import type { SegmentConfig } from "./config.ts";
3
+ import type { StatusContext } from "./context.ts";
4
+ import type { Piece, Run, Segment, SegmentDef } from "./types.ts";
5
+ /**
6
+ * Turning a line's configuration into the segments a surface draws. The segment model itself lives
7
+ * in `types.ts` and the built-ins in `builtins/`; this file is only the assembly.
8
+ */
9
+ export type { Piece, Run, Segment, SegmentDef, Tone } from "./types.ts";
10
+ export { BUILTINS };
11
+ export declare function runsOf(piece: Piece): Run[];
12
+ export declare function segmentText(segment: Segment): string;
13
+ export declare function segmentWidth(segment: Segment): number;
14
+ /** Cuts a segment to `max` cells, keeping each run's styling up to the cut. */
15
+ export declare function cutSegment(segment: Segment, max: number): Segment;
16
+ export declare function findSegment(type: string): SegmentDef | undefined;
17
+ /**
18
+ * Builds the line's segments in order, dropping the ones with nothing to say. An unknown type is
19
+ * dropped too rather than drawn as an error: a stale config should cost you a segment, not a line.
20
+ */
21
+ export interface BuildOptions {
22
+ custom?: ReadonlyMap<string, SegmentDef>;
23
+ /** Icons are on by default; a terminal without the glyphs can switch them off. */
24
+ icons?: boolean;
25
+ }
26
+ export declare function buildSegments(ctx: StatusContext, configs: SegmentConfig[], options?: BuildOptions | ReadonlyMap<string, SegmentDef>): Segment[];
@@ -0,0 +1,52 @@
1
+ import type { SegmentConfig } from "./config.ts";
2
+ import type { StatusContext } from "./context.ts";
3
+ /**
4
+ * A drawn piece of the line. `tone` names a theme colour rather than a literal, so the line
5
+ * belongs to whatever theme the user runs.
6
+ */
7
+ export type Tone = "text" | "muted" | "accent" | "success" | "warning" | "error" | "info"
8
+ /** The window's own background and panel colours — what a filled pill puts its text on. */
9
+ | "background" | "panel" | "border";
10
+ /**
11
+ * A styled piece of text. A segment is a list of these rather than one string, which is what lets
12
+ * a single segment carry an icon in one colour, a figure in another, and a bar whose own cells are
13
+ * coloured by what they represent.
14
+ */
15
+ export interface Run {
16
+ text: string;
17
+ tone?: Tone;
18
+ /** A literal `#rrggbb`, which wins over `tone`. */
19
+ color?: string;
20
+ /** Background, for pills and filled bars. */
21
+ bg?: string;
22
+ bgTone?: Tone;
23
+ bold?: boolean;
24
+ dim?: boolean;
25
+ }
26
+ export interface Segment {
27
+ id: string;
28
+ runs: Run[];
29
+ /** Higher survives when the line is too long for the terminal. */
30
+ priority: number;
31
+ }
32
+ /** What a segment may return: one styled string, or several. */
33
+ export type Piece = {
34
+ text: string;
35
+ tone?: Tone;
36
+ color?: string;
37
+ } | {
38
+ runs: Run[];
39
+ };
40
+ /**
41
+ * A built-in. Returning `undefined` hides it, and that is the important half of the contract:
42
+ * a segment whose input is missing must say nothing. A cost of "$0.00" on a provider nobody
43
+ * declared prices for reads as "this was free", which is worse than an absent segment.
44
+ */
45
+ export interface SegmentDef {
46
+ name: string;
47
+ /** Used when the config does not override it. */
48
+ priority: number;
49
+ /** Shown before the text when icons are on. */
50
+ icon?: string;
51
+ render(ctx: StatusContext, config: SegmentConfig): Piece | undefined;
52
+ }
@@ -0,0 +1,26 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui";
3
+ import type { Segment, Tone } from "../../core/segments.ts";
4
+ /**
5
+ * One line of segments, each segment a list of styled runs. Colour comes from the running theme
6
+ * rather than literals, so the line belongs to whatever theme the user has chosen — a statusline
7
+ * in someone else's palette is the first thing that makes a plugin look bolted on.
8
+ */
9
+ /**
10
+ * Theme colours are RGBA objects, and OpenTUI wants the object. Stringifying one yields garbage
11
+ * that the renderer falls back to magenta on, which is how the whole line once came out pink.
12
+ */
13
+ export type Colour = TuiThemeCurrent["text"];
14
+ export declare function toneColour(theme: TuiThemeCurrent, tone: Tone | undefined): Colour;
15
+ export interface StatusLineProps {
16
+ api: TuiPluginApi;
17
+ segments: () => Segment[];
18
+ separator: string;
19
+ /** Across the window, or down a column. */
20
+ stack?: "horizontal" | "vertical";
21
+ paddingLeft?: number;
22
+ paddingRight?: number;
23
+ paddingTop?: number;
24
+ paddingBottom?: number;
25
+ }
26
+ export declare function StatusLine(props: StatusLineProps): import("solid-js").JSX.Element;
@@ -0,0 +1,10 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
3
+ /** Status' TUI half as a factory, so bundles such as `opencode-cockpit` can include it. */
4
+ export declare function createStatusTui({ source }?: {
5
+ source?: string;
6
+ }): TuiPlugin;
7
+ declare const plugin: TuiPluginModule & {
8
+ id: string;
9
+ };
10
+ export default plugin;
@@ -0,0 +1,11 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
2
+ import type { SessionSnapshot, StatusContext } from "../../core/context.ts";
3
+ /** The session the interface is showing, when it is showing one. */
4
+ export declare function currentSession(api: TuiPluginApi): string | undefined;
5
+ export declare function sessionSnapshot(api: TuiPluginApi, id: string, now: number): SessionSnapshot;
6
+ export declare function buildContext(api: TuiPluginApi, options: {
7
+ now: number;
8
+ width: number;
9
+ version: string;
10
+ commands: Record<string, string>;
11
+ }): StatusContext;
@@ -0,0 +1,28 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
2
+ import { type Accessor } from "solid-js";
3
+ import type { StatusConfig } from "../../core/config.ts";
4
+ import type { StatusContext } from "../../core/context.ts";
5
+ /**
6
+ * Keeps one snapshot of OpenCode's state for every line to read. One memo rather than one per
7
+ * segment: the whole snapshot is a handful of reads of state already in memory, and recomputing it
8
+ * once a second costs less than the bookkeeping to avoid it.
9
+ */
10
+ export interface StatusStore {
11
+ context: Accessor<StatusContext>;
12
+ dispose(): void;
13
+ }
14
+ export interface StoreOptions {
15
+ version: string;
16
+ /** How often the line recomputes. */
17
+ tickMs?: number;
18
+ /** Injected in tests, so no shell runs and no clock is needed. */
19
+ exec?: (command: string, stdin: string, timeoutMs: number) => Promise<string>;
20
+ now?: () => number;
21
+ build: (api: TuiPluginApi, input: {
22
+ now: number;
23
+ width: number;
24
+ version: string;
25
+ commands: Record<string, string>;
26
+ }) => StatusContext;
27
+ }
28
+ export declare function createStatusStore(api: TuiPluginApi, config: StatusConfig, options: StoreOptions): StatusStore;