opencode-context-indicator 1.7.0 → 1.8.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/lib/limits.js CHANGED
@@ -10,17 +10,21 @@
10
10
  // Compaction ceiling (opencode calculateCeiling): opencode's core
11
11
  // calculateCeiling() in packages/core/src/session/compaction.ts derives the
12
12
  // auto-compaction trigger ceiling as window - reserve, where
13
- // window = limit.input || limit.context (null/zero falls through to the context
14
- // window) and
15
- // reserve = max(floor(window * 0.10), window >= 2 * 16_000 ? 16_000 : 0)
16
- // The share was 15% up to v2.1.x and is 10% since v2.2.0 (PR #51271 "fit output
17
- // limits to the context window"); the `2 * RESERVE_MIN` gate is unchanged. A
18
- // wrong reserve makes every displayed percentage wrong, so this mirrors the
19
- // host exactly.
13
+ // window = input && context ? min(input, context) : input || context
14
+ // (null/zero falls through; since opencode 2.2.5 (#60660) an input limit ABOVE
15
+ // the context window is capped at the context window, because the context also
16
+ // holds the reply) and
17
+ // reserve = max(floor(window * 0.15), window >= 2 * 16_000 ? 16_000 : 0)
18
+ // `RESERVE_SHARE = 0.15` in every RELEASED host from 2.1.4 through 2.2.5
19
+ // (verified tag by tag; the `2 * RESERVE_MIN` gate is older and unchanged). The
20
+ // unreleased `v2` branch HEAD has an inline `0.1` instead - do NOT mirror a
21
+ // branch head: a wrong reserve makes every displayed percentage wrong.
20
22
  // A compaction.buffer configured in opencode itself is NOT visible to the
21
23
  // plugin (the plugin cannot read opencode's internal compaction config); the
22
24
  // env var OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER overrides the reserve for
23
25
  // testing and power users only (non-negative finite integer <= 1e9, ignore invalid).
26
+ // opencode 2.2.5 also accepts a percentage there ("buffer": "20%"), which is
27
+ // likewise invisible here, so a percentage buffer shows the default reserve.
24
28
  // Like core, a buffer that is NOT smaller than the window is ignored: a buffer
25
29
  // set for a larger model must never leave zero room.
26
30
 
@@ -85,11 +89,11 @@ function getModelLimit(providerID, modelID) {
85
89
  }
86
90
 
87
91
  // Compute the reserve for a given window, matching opencode's
88
- // packages/core/src/session/compaction.ts calculateCeiling() in 2.2.x (the
89
- // share became 10% in 2.2.0; the 2 * RESERVE_MIN gate is older and unchanged).
92
+ // packages/core/src/session/compaction.ts calculateCeiling() as RELEASED
93
+ // (RESERVE_SHARE = 0.15 in 2.1.4 .. 2.2.5; the branch head is not a release).
90
94
  // Returns null when
91
95
  // window is not a finite positive number.
92
- const RESERVE_SHARE = 0.1; // core: 10% of the window (15% before v2.2.0)
96
+ const RESERVE_SHARE = 0.15; // core: RESERVE_SHARE in every released 2.x
93
97
  const RESERVE_MIN = 16_000; // core: RESERVE_MIN
94
98
  function reserveFor(window, bufferOverride) {
95
99
  if (
@@ -109,7 +113,7 @@ function reserveFor(window, bufferOverride) {
109
113
  return bufferOverride;
110
114
  }
111
115
  // Default reserve formula (mirror of opencode core):
112
- // max(floor(window * 0.10), window >= 2 * RESERVE_MIN ? RESERVE_MIN : 0)
116
+ // max(floor(window * 0.15), window >= 2 * RESERVE_MIN ? RESERVE_MIN : 0)
113
117
  // The threshold is 2 * RESERVE_MIN (32_000), not RESERVE_MIN.
114
118
  return Math.max(
115
119
  Math.floor(window * RESERVE_SHARE),
@@ -117,8 +121,8 @@ function reserveFor(window, bufferOverride) {
117
121
  );
118
122
  }
119
123
 
120
- // Native opencode compaction ceiling (calculateCeiling, core v2.2.x):
121
- // window = limit.input || limit.context (zero/null falls through to context)
124
+ // Native opencode compaction ceiling (calculateCeiling, released 2.x):
125
+ // window = input && context ? min(input, context) : input || context
122
126
  // reserve = reserveFor(window) [or bufferOverride when provided]
123
127
  // usable = window - reserve
124
128
  // Returns { usable, limit: window, maxOutput, reserved } or null when not
@@ -127,6 +131,11 @@ function reserveFor(window, bufferOverride) {
127
131
  // participate in this calculation; opencode's internal buffer is not visible.
128
132
  // The buffer override (env or argument) replaces the whole reserve only while
129
133
  // it is smaller than the window — a buffer >= window falls back to the share.
134
+ //
135
+ // The min() is opencode 2.2.5 (#60660): a provider that reports an input limit
136
+ // ABOVE the context window cannot use it in full, because the context also holds
137
+ // the reply. Using input alone would overstate the denominator (and show a
138
+ // compact-at point the host never reaches).
130
139
  function getUsableContext(providerID, modelID, bufferOverride) {
131
140
  const rec = getModelLimitRecord(providerID, modelID);
132
141
  if (!rec) return null;
@@ -135,8 +144,11 @@ function getUsableContext(providerID, modelID, bufferOverride) {
135
144
  const input =
136
145
  typeof rec.input === "number" && rec.input > 0 ? rec.input : null;
137
146
  if (input == null && context == null) return null;
138
- // Mirror core's limit.input || limit.context (zero/null falls through to context).
139
- const window = input != null && input > 0 ? input : context;
147
+ // Mirror core's inputWindow(): min(input, context) when both are known.
148
+ // The `??` is core's `||` here: a non-positive input was normalized to null
149
+ // above, so there is no 0 to fall through.
150
+ const window =
151
+ input != null && context != null ? Math.min(input, context) : input ?? context;
140
152
  if (window == null || !(window > 0)) return null;
141
153
 
142
154
  // When bufferOverride is omitted, read it from the env var once per call.
package/lib/state.js CHANGED
@@ -60,7 +60,7 @@ const LOG_FILE =
60
60
  process.env.OPENCODE_CONTEXT_INDICATOR_LOG_FILE ||
61
61
  join(tmpdir(), "context-breakdown.log");
62
62
 
63
- // Machine-readable snapshot consumed by the optional TUI sidebar (./tui.tsx,
63
+ // Machine-readable snapshot consumed by the optional TUI sidebar (./tui.ts,
64
64
  // package entry "./tui"). Written next to each log update — same moment, same
65
65
  // data. See writeStateFile() below.
66
66
  // State file path — overridable via env for test isolation.
@@ -417,7 +417,7 @@ function renameWithRetry(from, to, attempts = 3, delayMs = 50) {
417
417
  // TUI state bridge (opencode-context-indicator)
418
418
  // ---------------------------------------------------------------------------
419
419
  // Persist a tiny machine-readable snapshot of the CURRENT breakdown for one
420
- // session so the optional TUI sidebar (./tui.tsx, package entry "./tui") can
420
+ // session so the optional TUI sidebar (./tui.ts, package entry "./tui") can
421
421
  // render it without talking to the server. The file is one JSON object keyed by
422
422
  // sessionID, rewritten atomically (temp + rename) at every log write — same
423
423
  // moment, same data. Strictly additive: any failure is swallowed and must never
@@ -460,9 +460,10 @@ function writeStateFile(
460
460
  try {
461
461
  const bd = breakdownCache.get(sessionID) || null;
462
462
  const usableInfo = getUsableContext(providerID, modelID);
463
- // Persist the EFFECTIVE window (limit.input || limit.context — the same
464
- // window the ceiling was derived from), not the raw context window: on a
465
- // cold instance hydrateModelLimitsFromState seeds context = limit, so a
463
+ // Persist the EFFECTIVE window (min(input, context) when both are known,
464
+ // else input || context: the same window the ceiling was derived from),
465
+ // not the raw context window: on a cold instance
466
+ // hydrateModelLimitsFromState seeds context = limit, so a
466
467
  // mismatched limit would re-derive a WRONG (too large) ceiling for models
467
468
  // where limit.input < limit.context.
468
469
  const limit =
package/lib/tui-data.d.ts CHANGED
@@ -54,7 +54,6 @@ export interface StateEntry {
54
54
 
55
55
  export declare const STATE_FILE: string
56
56
  export declare const POLL_MS: number
57
- export declare function isNodeModulesInstall(): boolean
58
57
  export declare function readSnapshot(): Record<string, StateEntry>
59
58
  export declare function collectFamily(
60
59
  snapshot: Record<string, StateEntry>,
package/lib/tui-data.js CHANGED
@@ -1,13 +1,13 @@
1
1
  // lib/tui-data.js
2
2
  //
3
- // Data layer for the TUI entry (tui.tsx): the state.json bridge (readSnapshot /
3
+ // Data layer for the TUI entry (tui.ts): the state.json bridge (readSnapshot /
4
4
  // isEmptyEntry / collectFamily), the shared formatting helpers (fmt / num / fit
5
- // / updatedAt) and the npm-install detection. Plain JS with JSDoc — same shape
6
- // as lib/contributors.js — so tui.tsx stays a thin JSX/setup shell and the node
7
- // tooling (resolve gate, future unit tests) can import it without a transform.
5
+ // / updatedAt) and the family walk. Plain JS with JSDoc — same shape as
6
+ // lib/contributors.js — so tui.ts stays a thin setup shell and the node tooling
7
+ // (resolve gate, unit tests) can import it without a transform.
8
8
  //
9
9
  // These helpers must stay free of solid-js / @opentui imports: the TUI host
10
- // bridges those modules only for the JSX entry, and keeping this file pure
10
+ // bridges those modules only for the plugin bundle, and keeping this file pure
11
11
  // means it also loads anywhere node does.
12
12
  //
13
13
  // state.json shape (written by writeStateFile() in index.js, untrusted input):
@@ -20,7 +20,6 @@
20
20
  import { readFileSync } from "node:fs";
21
21
  import { tmpdir } from "node:os";
22
22
  import { join } from "node:path";
23
- import { fileURLToPath } from "node:url";
24
23
  import { isSafeSessionKey, sumCategories } from "./estimate.js";
25
24
  import { fmt, num } from "./format.js";
26
25
 
@@ -38,21 +37,6 @@ export const STATE_FILE =
38
37
 
39
38
  export const POLL_MS = 1000;
40
39
 
41
- // True when this package was loaded from an npm install, i.e. its own file
42
- // path contains a `node_modules` segment (OpenCode's TUI loader skips the host
43
- // Solid transform for such paths → slots mount once but never live-update; see
44
- // the Graceful mode note in tui.tsx). Normalised to forward slashes for
45
- // Windows paths. ANY file of the package yields the same answer, so checking
46
- // from lib/ is equivalent to checking from the tui.tsx entry.
47
- export function isNodeModulesInstall() {
48
- try {
49
- const selfPath = fileURLToPath(import.meta.url).replace(/\\/g, "/").toLowerCase()
50
- return selfPath.includes("/node_modules/")
51
- } catch {
52
- return false
53
- }
54
- }
55
-
56
40
  // Whole state.json snapshot ({ sessionID: entry }) or {} on any error. The
57
41
  // sidebar resolves one entry from it and the breakdown panel walks the
58
42
  // parentID family over it. Never throws — a missing / empty / corrupt
package/lib/tui-h.ts ADDED
@@ -0,0 +1,73 @@
1
+ // lib/tui-h.ts
2
+ //
3
+ // The two tree builders every TUI surface in this package uses instead of JSX.
4
+ //
5
+ // WHY NO JSX (do not "simplify" this back):
6
+ // OpenCode installs npm plugins inside a `node_modules` tree
7
+ // (~/.cache/opencode/npm/<name>@<version>/<ts>/node_modules/<name>). For such
8
+ // paths the host deliberately skips its Solid/Babel JSX transform (sourceFilter
9
+ // in @opentui/solid/scripts/solid-plugin.js excludes node_modules, upstream
10
+ // anomalyco/opencode#33884). A JSX-compiled file in that position also needs
11
+ // `@opentui/solid` to be installed next to it just to resolve its emitted
12
+ // jsx-runtime import. Files WITHOUT JSX syntax are unaffected: their literal
13
+ // `@opentui/solid/jsx-runtime` import IS rewritten by the host to the
14
+ // in-process OpenTUI module (`nodeModulesRuntimeSpecifiers: true` in
15
+ // DEFAULT_RUNTIME_PLUGIN_REWRITE_OPTIONS of @opentui/core), so the tree stays
16
+ // reactive and the package needs no runtime dependency at all.
17
+ //
18
+ // Therefore: no JSX syntax anywhere in this package's TUI files, and no
19
+ // `@jsxImportSource` pragma. Build the tree with jsx() and mark every DYNAMIC
20
+ // prop as a getter — that is exactly what the Solid compiler emits for
21
+ // `prop={expr}` in JSX, so the runtime semantics are identical:
22
+ //
23
+ // <text fg={base()}>{line}</text>
24
+ // -> h("text", { get fg() { return base() }, get children() { return line } })
25
+ //
26
+ // A plain (non-getter) value is read ONCE, like a literal attribute in JSX.
27
+ // Children follow the same rule: an array of elements is fine (it is diffed by
28
+ // the runtime), a function child is a reactive accessor.
29
+ //
30
+ // RELATIVE IMPORTS OF .ts FILES MUST CARRY THE EXTENSION (`./tui-h.ts`, not
31
+ // `./tui-h`): the host decides which files get the runtime-specifier rewrite by
32
+ // walking the import graph with `import.meta.resolve(specifier, importer)`,
33
+ // which does not probe extensions. An extensionless import is simply not
34
+ // followed, so that file keeps the literal `@opentui/solid/jsx-runtime` import
35
+ // and the npm install dies with `Cannot find package '@opentui/solid'`.
36
+ // test/prepublish.mjs gate (b) enforces this.
37
+ //
38
+ // This module is the only place that imports the jsx runtime, so the allowlist
39
+ // in test/prepublish.mjs stays short.
40
+ //
41
+ // NEVER pass a `key` prop to h()/c(). The jsx runtime's normalizeProps() sees
42
+ // `key`, strips it with `{ ...rest }`, and the spread turns every getter into a
43
+ // plain value read ONCE — the whole subtree freezes with no error. (No list in
44
+ // this package is keyed; if you ever need one, do not add `key` here.)
45
+
46
+ import { jsx } from "@opentui/solid/jsx-runtime"
47
+ import type { JSX } from "@opentui/solid/jsx-runtime"
48
+
49
+ /** An intrinsic OpenTUI element: `h("text", { fg, children })`. */
50
+ export function h<K extends string>(
51
+ tag: K,
52
+ props: K extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[K] : never,
53
+ ): JSX.Element {
54
+ return jsx(tag, props as unknown as Record<string, unknown>)
55
+ }
56
+
57
+ /**
58
+ * A component: `c(Show, { get when() {...}, get fallback() {...} })`.
59
+ *
60
+ * The component's own prop type is intentionally not enforced here: host
61
+ * components (Show, and any OpenTUI component) are generic and their unions do
62
+ * not survive inference through a wrapper, while the shapes used in this
63
+ * package are small and covered by the e2e liveness check.
64
+ */
65
+ export function c<C extends (props: any) => unknown, P extends object>(
66
+ component: C,
67
+ props: P,
68
+ ): JSX.Element {
69
+ return jsx(
70
+ component as unknown as (props: Record<string, unknown>) => unknown,
71
+ props as unknown as Record<string, unknown>,
72
+ )
73
+ }