@cortexkit/opencode-magic-context 0.21.6 → 0.21.7

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 (56) hide show
  1. package/README.md +1 -1
  2. package/dist/config/agent-disable.d.ts +26 -0
  3. package/dist/config/agent-disable.d.ts.map +1 -0
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/schema/magic-context.d.ts +0 -6
  6. package/dist/config/schema/magic-context.d.ts.map +1 -1
  7. package/dist/features/magic-context/compartment-lease.d.ts +14 -0
  8. package/dist/features/magic-context/compartment-lease.d.ts.map +1 -0
  9. package/dist/features/magic-context/compartment-storage.d.ts +5 -1
  10. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  11. package/dist/features/magic-context/compression-depth-storage.d.ts +2 -1
  12. package/dist/features/magic-context/compression-depth-storage.d.ts.map +1 -1
  13. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  14. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  15. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  16. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  17. package/dist/features/magic-context/storage.d.ts +1 -1
  18. package/dist/features/magic-context/storage.d.ts.map +1 -1
  19. package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
  20. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts +4 -0
  21. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts.map +1 -1
  22. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  23. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  24. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  25. package/dist/hooks/magic-context/compartment-runner-types.d.ts +2 -0
  26. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  27. package/dist/hooks/magic-context/compartment-runner.d.ts +5 -0
  28. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  29. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  30. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +2 -2
  31. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  32. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +1 -0
  33. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  34. package/dist/hooks/magic-context/transform.d.ts +2 -0
  35. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +617 -173
  38. package/dist/plugin/conflict-warning-hook.d.ts +10 -0
  39. package/dist/plugin/conflict-warning-hook.d.ts.map +1 -1
  40. package/dist/plugin/dream-timer.d.ts.map +1 -1
  41. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  42. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  43. package/dist/plugin/tool-registry.d.ts.map +1 -1
  44. package/dist/shared/announcement.d.ts +55 -0
  45. package/dist/shared/announcement.d.ts.map +1 -0
  46. package/dist/shared/format-threshold.d.ts +24 -0
  47. package/dist/shared/format-threshold.d.ts.map +1 -0
  48. package/dist/tui/data/context-db.d.ts +14 -0
  49. package/dist/tui/data/context-db.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/shared/announcement.test.ts +143 -0
  52. package/src/shared/announcement.ts +97 -0
  53. package/src/shared/format-threshold.ts +28 -0
  54. package/src/tui/data/context-db.ts +43 -0
  55. package/src/tui/index.tsx +68 -4
  56. package/src/tui/slots/sidebar-content.tsx +11 -2
@@ -21,4 +21,14 @@ export declare function cleanupConflictWarnings(client: unknown, directory: stri
21
21
  * Sends an ignored message that auto-deletes after 1 second.
22
22
  */
23
23
  export declare function sendTuiSetupNotification(client: unknown, directory: string, serverUrl?: string): Promise<void>;
24
+ /**
25
+ * Desktop startup announcement: post a one-shot ignored message describing
26
+ * what's new in this release. Mirrors the TUI's RPC-driven dialog path so both
27
+ * surfaces deliver the same announcement once per ANNOUNCEMENT_VERSION.
28
+ *
29
+ * Persistence lives in `getMagicContextStorageDir()/last_announced_version`,
30
+ * shared with the TUI handlers and the Pi plugin so a dismissal in any harness
31
+ * suppresses the others for the same announcement.
32
+ */
33
+ export declare function sendStartupAnnouncement(client: unknown, directory: string, version: string, features: ReadonlyArray<string>, footer: string, markSeen: (version: string) => void): Promise<void>;
24
34
  //# sourceMappingURL=conflict-warning-hook.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"conflict-warning-hook.d.ts","sourceRoot":"","sources":["../../src/plugin/conflict-warning-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAmLlE;;GAEG;AACH,wBAAsB,mBAAmB,CACrC,MAAM,EAAE,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,cAAc,GAC/B,OAAO,CAAC,IAAI,CAAC,CA+Cf;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CACzC,MAAM,EAAE,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CAqHf;AAkCD;;;GAGG;AACH,wBAAsB,wBAAwB,CAC1C,MAAM,EAAE,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CAkEf"}
1
+ {"version":3,"file":"conflict-warning-hook.d.ts","sourceRoot":"","sources":["../../src/plugin/conflict-warning-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAoLlE;;GAEG;AACH,wBAAsB,mBAAmB,CACrC,MAAM,EAAE,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,cAAc,GAC/B,OAAO,CAAC,IAAI,CAAC,CA+Cf;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CACzC,MAAM,EAAE,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CAqHf;AAkCD;;;GAGG;AACH,wBAAsB,wBAAwB,CAC1C,MAAM,EAAE,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CAkEf;AAED;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CACzC,MAAM,EAAE,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,aAAa,CAAC,MAAM,CAAC,EAC/B,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GACpC,OAAO,CAAC,IAAI,CAAC,CA8Df"}
@@ -1 +1 @@
1
- {"version":3,"file":"dream-timer.d.ts","sourceRoot":"","sources":["../../src/plugin/dream-timer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AAapE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAK7C;;;;;GAKG;AACH,UAAU,mBAAmB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IAChC,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,wBAAwB,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,kBAAkB,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5E,uBAAuB,CAAC,EAAE;QACtB,OAAO,EAAE,OAAO,CAAC;QACjB,YAAY,EAAE,MAAM,CAAC;QACrB,SAAS,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,iBAAiB,CAAC,EAAE;QAChB,OAAO,EAAE,OAAO,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;QACnB,WAAW,EAAE,MAAM,CAAC;KACvB,CAAC;IACF,gBAAgB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxE;AAQD;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,CACzC,IAAI,EAAE,mBAAmB,GAC1B,OAAO,CAAC,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC,CA0DnC"}
1
+ {"version":3,"file":"dream-timer.d.ts","sourceRoot":"","sources":["../../src/plugin/dream-timer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AAapE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAK7C;;;;;GAKG;AACH,UAAU,mBAAmB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IAChC,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,wBAAwB,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,kBAAkB,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5E,uBAAuB,CAAC,EAAE;QACtB,OAAO,EAAE,OAAO,CAAC;QACjB,YAAY,EAAE,MAAM,CAAC;QACrB,SAAS,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,iBAAiB,CAAC,EAAE;QAChB,OAAO,EAAE,OAAO,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;QACnB,WAAW,EAAE,MAAM,CAAC;KACvB,CAAC;IACF,gBAAgB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxE;AAQD;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,CACzC,IAAI,EAAE,mBAAmB,GAC1B,OAAO,CAAC,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC,CA4DnC"}
@@ -1 +1 @@
1
- {"version":3,"file":"create-session-hooks.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/create-session-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAU7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8CAA8C,CAAC;AACrF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE9C,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACrC,GAAG,EAAE,aAAa,CAAC;IACnB,YAAY,EAAE,wBAAwB,CAAC;IACvC,gBAAgB,EAAE,gBAAgB,CAAC;CACtC;;;;;;qBAoDkuJ,CAAC;;;;;;;;;;;;qBAA9sC,CAAC;mBAAyB,CAAC;iBAAuB,CAAC;iBAAuB,CAAC;0BAAc,CAAC;uBAAiB,CAAC;;;;;;0BAAwgtB,CAAC;;;;;;EAD1o0B"}
1
+ {"version":3,"file":"create-session-hooks.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/create-session-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAU7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8CAA8C,CAAC;AACrF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE9C,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACrC,GAAG,EAAE,aAAa,CAAC;IACnB,YAAY,EAAE,wBAAwB,CAAC;IACvC,gBAAgB,EAAE,gBAAgB,CAAC;CACtC;;;;;;qBAoDkuJ,CAAC;;;;;;;;;;;;qBAA9sC,CAAC;mBAAyB,CAAC;iBAAuB,CAAC;iBAAuB,CAAC;0BAAc,CAAC;uBAAiB,CAAC;;;;;;0BAA+luB,CAAC;;;;;;EADju1B"}
@@ -1 +1 @@
1
- {"version":3,"file":"rpc-handlers.d.ts","sourceRoot":"","sources":["../../src/plugin/rpc-handlers.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAGzE,OAAO,EAAE,KAAK,eAAe,IAAI,QAAQ,EAAgB,MAAM,mCAAmC,CAAC;AAWnG,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AASlF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAClE,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAmDzE,wBAAgB,oBAAoB,CAChC,EAAE,EAAE,QAAQ,EACZ,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,gBAAgB,CAAC,EAAE,gBAAgB,EACnC,qBAAqB,CAAC,EAAE,MAAM,EAK9B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACjC,eAAe,CA6UjB;AAED,wBAAgB,iBAAiB,CAC7B,EAAE,EAAE,QAAQ,EACZ,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,gBAAgB,CAAC,EAAE,gBAAgB,EACnC,qBAAqB,CAAC,EAAE,MAAM,GAC/B,YAAY,CAuKd;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAC/B,SAAS,EAAE,qBAAqB,EAChC,IAAI,EAAE;IACF,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,MAAM,EAAE,OAAO,CAAC;IAChB,gBAAgB,EAAE,gBAAgB,CAAC;CACtC,GACF,IAAI,CAwIN"}
1
+ {"version":3,"file":"rpc-handlers.d.ts","sourceRoot":"","sources":["../../src/plugin/rpc-handlers.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAGzE,OAAO,EAAE,KAAK,eAAe,IAAI,QAAQ,EAAgB,MAAM,mCAAmC,CAAC;AAWnG,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAgBlF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAClE,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAmDzE,wBAAgB,oBAAoB,CAChC,EAAE,EAAE,QAAQ,EACZ,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,gBAAgB,CAAC,EAAE,gBAAgB,EACnC,qBAAqB,CAAC,EAAE,MAAM,EAK9B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACjC,eAAe,CA6UjB;AAED,wBAAgB,iBAAiB,CAC7B,EAAE,EAAE,QAAQ,EACZ,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,gBAAgB,CAAC,EAAE,gBAAgB,EACnC,qBAAqB,CAAC,EAAE,MAAM,GAC/B,YAAY,CAuKd;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAC/B,SAAS,EAAE,qBAAqB,EAChC,IAAI,EAAE;IACF,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,MAAM,EAAE,OAAO,CAAC;IAChB,gBAAgB,EAAE,gBAAgB,CAAC;CACtC,GACF,IAAI,CAkKN"}
@@ -1 +1 @@
1
- {"version":3,"file":"tool-registry.d.ts","sourceRoot":"","sources":["../../src/plugin/tool-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAC;AAe1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7C,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACrC,GAAG,EAAE,aAAa,CAAC;IACnB,YAAY,EAAE,wBAAwB,CAAC;CAC1C,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAyEjC"}
1
+ {"version":3,"file":"tool-registry.d.ts","sourceRoot":"","sources":["../../src/plugin/tool-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAC;AAgB1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7C,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACrC,GAAG,EAAE,aAAa,CAAC;IACnB,YAAY,EAAE,wBAAwB,CAAC;CAC1C,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAyEjC"}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Release-notes startup announcement shared by OpenCode plugin and Pi plugin.
3
+ *
4
+ * Bump `ANNOUNCEMENT_VERSION` and populate `ANNOUNCEMENT_FEATURES` *only* when a
5
+ * release ships user-facing news worth surfacing once at startup. Patch releases
6
+ * with no user-visible changes should leave both untouched — that way a user who
7
+ * already dismissed the dialog for the current `ANNOUNCEMENT_VERSION` won't see
8
+ * it again on the next bugfix bump.
9
+ *
10
+ * The persisted state is a single line of text (`last_announced_version`) under
11
+ * `getMagicContextStorageDir()`. OpenCode and Pi share the same file because
12
+ * they share the same storage root — so dismissing in one harness suppresses
13
+ * the dialog in the other for the same announcement.
14
+ *
15
+ * Leave both empty (`""` and `[]`) to skip the dialog entirely.
16
+ */
17
+ /**
18
+ * Bump only when there are user-visible changes worth a startup dialog.
19
+ * Does NOT need to match the published package version.
20
+ */
21
+ export declare const ANNOUNCEMENT_VERSION = "0.21.7";
22
+ /**
23
+ * Short, user-facing bullet strings. Keep each line ~80 chars or shorter so the
24
+ * TUI dialog renders cleanly without horizontal scroll on a typical terminal.
25
+ */
26
+ export declare const ANNOUNCEMENT_FEATURES: ReadonlyArray<string>;
27
+ /**
28
+ * Persistent footer rendered below the version-specific bullets in every
29
+ * announcement. Stays in place across releases so users always see the Discord
30
+ * invite without us needing to repeat it in `ANNOUNCEMENT_FEATURES` each time.
31
+ *
32
+ * Leave empty (`""`) to suppress the footer.
33
+ */
34
+ export declare const ANNOUNCEMENT_FOOTER = "Join us on Discord: https://discord.gg/F2uWxjGnU";
35
+ /**
36
+ * Read the most recently dismissed announcement version, or `""` if none.
37
+ *
38
+ * Best-effort: any read failure returns `""` (which forces the announcement to
39
+ * re-show). The cost of a spurious second dialog is much smaller than the cost
40
+ * of suppressing a real announcement due to a transient FS error.
41
+ */
42
+ export declare function readLastAnnouncedVersion(): string;
43
+ /**
44
+ * Persist `version` as the most recently dismissed announcement. Best-effort:
45
+ * write failures are swallowed so dialog-confirm flows never throw on storage
46
+ * errors. Worst case the user sees the same dialog once more on next startup.
47
+ */
48
+ export declare function markAnnouncementSeen(version: string): void;
49
+ /**
50
+ * True when the configured `ANNOUNCEMENT_VERSION` has not yet been dismissed
51
+ * AND there is at least one feature to show. Used by both the TUI dialog path
52
+ * and the Desktop ignored-message fallback.
53
+ */
54
+ export declare function shouldShowAnnouncement(): boolean;
55
+ //# sourceMappingURL=announcement.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"announcement.d.ts","sourceRoot":"","sources":["../../src/shared/announcement.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAMH;;;GAGG;AACH,eAAO,MAAM,oBAAoB,WAAW,CAAC;AAE7C;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,aAAa,CAAC,MAAM,CAOvD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,qDAAqD,CAAC;AAQtF;;;;;;GAMG;AACH,wBAAgB,wBAAwB,IAAI,MAAM,CAQjD;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAS1D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,IAAI,OAAO,CAGhD"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Format an execute-threshold percentage for human-facing display.
3
+ *
4
+ * `executeThreshold` in the snapshot is always a percentage number, but it
5
+ * comes from two very different config paths:
6
+ * 1. `execute_threshold_percentage` (or its model-keyed variant) — user
7
+ * configures an integer like 65 directly. We must render exactly that.
8
+ * 2. `execute_threshold_tokens` — user configures absolute token cap (e.g.
9
+ * 128000). The resolver in `event-resolvers.ts` divides that by the
10
+ * model's context limit (`(128000 / 907788) * 100`) and the result is
11
+ * a long float like `14.099783080260304` that overflows the TUI cell
12
+ * (issue #90).
13
+ *
14
+ * Behaviour:
15
+ * - Integer input (≤0.05 fractional drift) renders without decimals.
16
+ * - Anything else is rendered with one decimal digit, which is precise
17
+ * enough to convey the configured token budget without smearing across
18
+ * two lines in a narrow sidebar.
19
+ *
20
+ * Returns the formatted percentage WITHOUT the trailing `%` so callers can
21
+ * compose richer strings like `47.5% / 65%` consistently.
22
+ */
23
+ export declare function formatThresholdPercent(value: number | undefined | null): string;
24
+ //# sourceMappingURL=format-threshold.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-threshold.d.ts","sourceRoot":"","sources":["../../src/shared/format-threshold.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CAK/E"}
@@ -17,6 +17,20 @@ export interface TuiMessage {
17
17
  payload: Record<string, unknown>;
18
18
  sessionId?: string;
19
19
  }
20
+ /**
21
+ * Fetch the current startup announcement from the server, if any.
22
+ * Returns `{show: false}` when there's nothing to announce or when the
23
+ * configured ANNOUNCEMENT_VERSION has already been dismissed.
24
+ */
25
+ export interface AnnouncementResponse {
26
+ show: boolean;
27
+ version?: string;
28
+ features?: string[];
29
+ footer?: string;
30
+ }
31
+ export declare function getAnnouncement(): Promise<AnnouncementResponse>;
32
+ /** Mark the current ANNOUNCEMENT_VERSION as dismissed on the server. */
33
+ export declare function markAnnounced(): Promise<boolean>;
20
34
  /** Poll for pending server→TUI notifications via RPC. */
21
35
  export declare function consumeTuiMessages(): Promise<TuiMessage[]>;
22
36
  //# sourceMappingURL=context-db.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"context-db.d.ts","sourceRoot":"","sources":["../../../src/tui/data/context-db.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAA0B,eAAe,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAEpG,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC;AAc9C,2DAA2D;AAC3D,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAGrD;AAED,+BAA+B;AAC/B,wBAAgB,QAAQ,IAAI,IAAI,CAI/B;AAwFD,sDAAsD;AACtD,wBAAsB,mBAAmB,CACrC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAClB,OAAO,CAAC,eAAe,CAAC,CA4B1B;AAED,wDAAwD;AACxD,wBAAsB,gBAAgB,CAClC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,YAAY,CAAC,CA4CvB;AAED,qCAAqC;AACrC,wBAAsB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ5E;AAED,6CAA6C;AAC7C,wBAAsB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAQvE;AAED,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,yDAAyD;AACzD,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC,CAqBhE"}
1
+ {"version":3,"file":"context-db.d.ts","sourceRoot":"","sources":["../../../src/tui/data/context-db.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAA0B,eAAe,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAEpG,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC;AAc9C,2DAA2D;AAC3D,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAGrD;AAED,+BAA+B;AAC/B,wBAAgB,QAAQ,IAAI,IAAI,CAI/B;AAwFD,sDAAsD;AACtD,wBAAsB,mBAAmB,CACrC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAClB,OAAO,CAAC,eAAe,CAAC,CA4B1B;AAED,wDAAwD;AACxD,wBAAsB,gBAAgB,CAClC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,YAAY,CAAC,CA4CvB;AAED,qCAAqC;AACrC,wBAAsB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ5E;AAED,6CAA6C;AAC7C,wBAAsB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAQvE;AAED,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACjC,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAsB,eAAe,IAAI,OAAO,CAAC,oBAAoB,CAAC,CAkBrE;AAED,wEAAwE;AACxE,wBAAsB,aAAa,IAAI,OAAO,CAAC,OAAO,CAAC,CAQtD;AAED,yDAAyD;AACzD,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC,CAqBhE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/opencode-magic-context",
3
- "version": "0.21.6",
3
+ "version": "0.21.7",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Magic Context — cross-session memory and context management",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,143 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ /**
7
+ * `announcement.ts` reads/writes a single `last_announced_version` file under
8
+ * `getMagicContextStorageDir()`. The behavior we test:
9
+ * 1. `markAnnouncementSeen` then `readLastAnnouncedVersion` round-trips
10
+ * 2. `shouldShowAnnouncement` returns false after a matching mark
11
+ * 3. `shouldShowAnnouncement` returns true after a non-matching mark
12
+ * 4. Empty-version inputs are no-ops (don't crash, don't write garbage)
13
+ *
14
+ * We isolate writes by pointing `XDG_DATA_HOME` at a temp dir before requiring
15
+ * the module fresh per test, since the module captures the storage path at
16
+ * import time via `getMagicContextStorageDir()`.
17
+ */
18
+
19
+ let tmpRoot = "";
20
+ let originalXdg: string | undefined;
21
+
22
+ beforeEach(() => {
23
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mc-announcement-test-"));
24
+ originalXdg = process.env.XDG_DATA_HOME;
25
+ process.env.XDG_DATA_HOME = tmpRoot;
26
+ });
27
+
28
+ afterEach(() => {
29
+ if (originalXdg === undefined) {
30
+ delete process.env.XDG_DATA_HOME;
31
+ } else {
32
+ process.env.XDG_DATA_HOME = originalXdg;
33
+ }
34
+ try {
35
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
36
+ } catch {
37
+ // best-effort
38
+ }
39
+ });
40
+
41
+ describe("announcement state persistence", () => {
42
+ test("round-trips a dismissed version through the file", async () => {
43
+ // Fresh import after XDG override so the module captures the temp path
44
+ const mod = await import(`./announcement?t=${Date.now()}-rt`);
45
+ const { readLastAnnouncedVersion, markAnnouncementSeen } = mod;
46
+
47
+ expect(readLastAnnouncedVersion()).toBe("");
48
+
49
+ markAnnouncementSeen("9.9.9");
50
+ expect(readLastAnnouncedVersion()).toBe("9.9.9");
51
+
52
+ markAnnouncementSeen("9.9.10");
53
+ expect(readLastAnnouncedVersion()).toBe("9.9.10");
54
+ });
55
+
56
+ test("ignores empty / zero-length version marks", async () => {
57
+ const mod = await import(`./announcement?t=${Date.now()}-empty`);
58
+ const { readLastAnnouncedVersion, markAnnouncementSeen } = mod;
59
+
60
+ markAnnouncementSeen("");
61
+ expect(readLastAnnouncedVersion()).toBe("");
62
+ });
63
+
64
+ test("creates the storage directory if it does not exist", async () => {
65
+ const mod = await import(`./announcement?t=${Date.now()}-mkdir`);
66
+ const { markAnnouncementSeen } = mod;
67
+
68
+ // Storage dir lives under tmpRoot + cortexkit/magic-context — does not
69
+ // exist yet at the start of the test
70
+ const expectedDir = path.join(tmpRoot, "cortexkit", "magic-context");
71
+ expect(fs.existsSync(expectedDir)).toBe(false);
72
+
73
+ markAnnouncementSeen("0.21.7");
74
+
75
+ expect(fs.existsSync(expectedDir)).toBe(true);
76
+ expect(fs.readFileSync(path.join(expectedDir, "last_announced_version"), "utf-8")).toBe(
77
+ "0.21.7",
78
+ );
79
+ });
80
+
81
+ test("trims whitespace from stored version on read", async () => {
82
+ const mod = await import(`./announcement?t=${Date.now()}-trim`);
83
+ const { readLastAnnouncedVersion } = mod;
84
+
85
+ const dir = path.join(tmpRoot, "cortexkit", "magic-context");
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ fs.writeFileSync(path.join(dir, "last_announced_version"), " 1.2.3 \n");
88
+
89
+ expect(readLastAnnouncedVersion()).toBe("1.2.3");
90
+ });
91
+ });
92
+
93
+ describe("shouldShowAnnouncement gating", () => {
94
+ test("returns false when the live version is already marked", async () => {
95
+ const mod = await import(`./announcement?t=${Date.now()}-match`);
96
+ const {
97
+ ANNOUNCEMENT_VERSION,
98
+ ANNOUNCEMENT_FEATURES,
99
+ markAnnouncementSeen,
100
+ shouldShowAnnouncement,
101
+ } = mod;
102
+
103
+ // Skip the test if announcements are currently disabled (empty constants)
104
+ // — the gate's empty-input behavior is covered separately below.
105
+ if (!ANNOUNCEMENT_VERSION || ANNOUNCEMENT_FEATURES.length === 0) {
106
+ return;
107
+ }
108
+
109
+ markAnnouncementSeen(ANNOUNCEMENT_VERSION);
110
+ expect(shouldShowAnnouncement()).toBe(false);
111
+ });
112
+
113
+ test("returns true when the live version was never marked", async () => {
114
+ const mod = await import(`./announcement?t=${Date.now()}-none`);
115
+ const { ANNOUNCEMENT_VERSION, ANNOUNCEMENT_FEATURES, shouldShowAnnouncement } = mod;
116
+
117
+ if (!ANNOUNCEMENT_VERSION || ANNOUNCEMENT_FEATURES.length === 0) {
118
+ // When empty, the gate is always false regardless of state
119
+ expect(shouldShowAnnouncement()).toBe(false);
120
+ return;
121
+ }
122
+
123
+ // No mark has been written in this fresh tmpRoot, so the gate is open
124
+ expect(shouldShowAnnouncement()).toBe(true);
125
+ });
126
+
127
+ test("returns true when a different (older) version is marked", async () => {
128
+ const mod = await import(`./announcement?t=${Date.now()}-older`);
129
+ const {
130
+ ANNOUNCEMENT_VERSION,
131
+ ANNOUNCEMENT_FEATURES,
132
+ markAnnouncementSeen,
133
+ shouldShowAnnouncement,
134
+ } = mod;
135
+
136
+ if (!ANNOUNCEMENT_VERSION || ANNOUNCEMENT_FEATURES.length === 0) {
137
+ return;
138
+ }
139
+
140
+ markAnnouncementSeen("0.0.0-pre-historic");
141
+ expect(shouldShowAnnouncement()).toBe(true);
142
+ });
143
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Release-notes startup announcement shared by OpenCode plugin and Pi plugin.
3
+ *
4
+ * Bump `ANNOUNCEMENT_VERSION` and populate `ANNOUNCEMENT_FEATURES` *only* when a
5
+ * release ships user-facing news worth surfacing once at startup. Patch releases
6
+ * with no user-visible changes should leave both untouched — that way a user who
7
+ * already dismissed the dialog for the current `ANNOUNCEMENT_VERSION` won't see
8
+ * it again on the next bugfix bump.
9
+ *
10
+ * The persisted state is a single line of text (`last_announced_version`) under
11
+ * `getMagicContextStorageDir()`. OpenCode and Pi share the same file because
12
+ * they share the same storage root — so dismissing in one harness suppresses
13
+ * the dialog in the other for the same announcement.
14
+ *
15
+ * Leave both empty (`""` and `[]`) to skip the dialog entirely.
16
+ */
17
+
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { getMagicContextStorageDir } from "./data-path";
21
+
22
+ /**
23
+ * Bump only when there are user-visible changes worth a startup dialog.
24
+ * Does NOT need to match the published package version.
25
+ */
26
+ export const ANNOUNCEMENT_VERSION = "0.21.7";
27
+
28
+ /**
29
+ * Short, user-facing bullet strings. Keep each line ~80 chars or shorter so the
30
+ * TUI dialog renders cleanly without horizontal scroll on a typical terminal.
31
+ */
32
+ export const ANNOUNCEMENT_FEATURES: ReadonlyArray<string> = [
33
+ "Pi parity sweep: 44 audit findings fixed, including a critical SHIP-BLOCKER where /ctx-flush did not drain the pending Pi compaction queue.",
34
+ "Pi historian recovery fix: empty/no-op historian returns now clear emergency recovery so sessions cannot loop forever at 95%.",
35
+ "trimPiMessagesToBoundary now sweeps non-contiguous tool-result orphans, fixing provider 400s after compaction in long Pi sessions.",
36
+ "Hidden subagent tool isolation: historian, dreamer, and sidekick can no longer spawn subagents or run unsafe tools.",
37
+ "TUI sidebar and /ctx-status header now show execute threshold inline: '47.5% / 65%' on the left, '475K / 1.0M' on the right.",
38
+ "doctor --issue now caps GitHub issue bodies at ~60KB with a dedicated 'Recent errors' section so reports stay submittable.",
39
+ ];
40
+
41
+ /**
42
+ * Persistent footer rendered below the version-specific bullets in every
43
+ * announcement. Stays in place across releases so users always see the Discord
44
+ * invite without us needing to repeat it in `ANNOUNCEMENT_FEATURES` each time.
45
+ *
46
+ * Leave empty (`""`) to suppress the footer.
47
+ */
48
+ export const ANNOUNCEMENT_FOOTER = "Join us on Discord: https://discord.gg/F2uWxjGnU";
49
+
50
+ const STATE_FILENAME = "last_announced_version";
51
+
52
+ function getStateFilePath(): string {
53
+ return path.join(getMagicContextStorageDir(), STATE_FILENAME);
54
+ }
55
+
56
+ /**
57
+ * Read the most recently dismissed announcement version, or `""` if none.
58
+ *
59
+ * Best-effort: any read failure returns `""` (which forces the announcement to
60
+ * re-show). The cost of a spurious second dialog is much smaller than the cost
61
+ * of suppressing a real announcement due to a transient FS error.
62
+ */
63
+ export function readLastAnnouncedVersion(): string {
64
+ try {
65
+ const file = getStateFilePath();
66
+ if (!fs.existsSync(file)) return "";
67
+ return fs.readFileSync(file, "utf-8").trim();
68
+ } catch {
69
+ return "";
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Persist `version` as the most recently dismissed announcement. Best-effort:
75
+ * write failures are swallowed so dialog-confirm flows never throw on storage
76
+ * errors. Worst case the user sees the same dialog once more on next startup.
77
+ */
78
+ export function markAnnouncementSeen(version: string): void {
79
+ if (!version) return;
80
+ try {
81
+ const dir = getMagicContextStorageDir();
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ fs.writeFileSync(getStateFilePath(), version);
84
+ } catch {
85
+ // best-effort
86
+ }
87
+ }
88
+
89
+ /**
90
+ * True when the configured `ANNOUNCEMENT_VERSION` has not yet been dismissed
91
+ * AND there is at least one feature to show. Used by both the TUI dialog path
92
+ * and the Desktop ignored-message fallback.
93
+ */
94
+ export function shouldShowAnnouncement(): boolean {
95
+ if (!ANNOUNCEMENT_VERSION || ANNOUNCEMENT_FEATURES.length === 0) return false;
96
+ return readLastAnnouncedVersion() !== ANNOUNCEMENT_VERSION;
97
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Format an execute-threshold percentage for human-facing display.
3
+ *
4
+ * `executeThreshold` in the snapshot is always a percentage number, but it
5
+ * comes from two very different config paths:
6
+ * 1. `execute_threshold_percentage` (or its model-keyed variant) — user
7
+ * configures an integer like 65 directly. We must render exactly that.
8
+ * 2. `execute_threshold_tokens` — user configures absolute token cap (e.g.
9
+ * 128000). The resolver in `event-resolvers.ts` divides that by the
10
+ * model's context limit (`(128000 / 907788) * 100`) and the result is
11
+ * a long float like `14.099783080260304` that overflows the TUI cell
12
+ * (issue #90).
13
+ *
14
+ * Behaviour:
15
+ * - Integer input (≤0.05 fractional drift) renders without decimals.
16
+ * - Anything else is rendered with one decimal digit, which is precise
17
+ * enough to convey the configured token budget without smearing across
18
+ * two lines in a narrow sidebar.
19
+ *
20
+ * Returns the formatted percentage WITHOUT the trailing `%` so callers can
21
+ * compose richer strings like `47.5% / 65%` consistently.
22
+ */
23
+ export function formatThresholdPercent(value: number | undefined | null): string {
24
+ if (typeof value !== "number" || !Number.isFinite(value)) return "—";
25
+ const rounded = Math.round(value);
26
+ if (Math.abs(value - rounded) < 0.05) return String(rounded);
27
+ return value.toFixed(1);
28
+ }
@@ -233,6 +233,49 @@ export interface TuiMessage {
233
233
  sessionId?: string;
234
234
  }
235
235
 
236
+ /**
237
+ * Fetch the current startup announcement from the server, if any.
238
+ * Returns `{show: false}` when there's nothing to announce or when the
239
+ * configured ANNOUNCEMENT_VERSION has already been dismissed.
240
+ */
241
+ export interface AnnouncementResponse {
242
+ show: boolean;
243
+ version?: string;
244
+ features?: string[];
245
+ footer?: string;
246
+ }
247
+
248
+ export async function getAnnouncement(): Promise<AnnouncementResponse> {
249
+ if (!rpcClient) return { show: false };
250
+ try {
251
+ const result = await rpcClient.call<{
252
+ show?: boolean;
253
+ version?: string;
254
+ features?: string[];
255
+ footer?: string;
256
+ }>("get-announcement", {});
257
+ return {
258
+ show: result.show === true,
259
+ version: result.version,
260
+ features: Array.isArray(result.features) ? result.features : undefined,
261
+ footer: typeof result.footer === "string" ? result.footer : undefined,
262
+ };
263
+ } catch {
264
+ return { show: false };
265
+ }
266
+ }
267
+
268
+ /** Mark the current ANNOUNCEMENT_VERSION as dismissed on the server. */
269
+ export async function markAnnounced(): Promise<boolean> {
270
+ if (!rpcClient) return false;
271
+ try {
272
+ const result = await rpcClient.call<{ ok?: boolean }>("mark-announced", {});
273
+ return result.ok === true;
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+
236
279
  /** Poll for pending server→TUI notifications via RPC. */
237
280
  export async function consumeTuiMessages(): Promise<TuiMessage[]> {
238
281
  if (!rpcClient) return [];
package/src/tui/index.tsx CHANGED
@@ -6,7 +6,8 @@ import { createMemo } from "solid-js"
6
6
  import type { TuiPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
7
7
  import { createSidebarContentSlot } from "./slots/sidebar-content"
8
8
  import packageJson from "../../package.json"
9
- import { closeRpc, consumeTuiMessages, getCompartmentCount, initRpcClient, loadStatusDetail, requestRecomp, type StatusDetail } from "./data/context-db"
9
+ import { closeRpc, consumeTuiMessages, getAnnouncement, getCompartmentCount, initRpcClient, loadStatusDetail, markAnnounced, requestRecomp, type StatusDetail } from "./data/context-db"
10
+ import { formatThresholdPercent } from "../shared/format-threshold"
10
11
  import { detectConflicts } from "../shared/conflict-detector"
11
12
  import { fixConflicts } from "../shared/conflict-fixer"
12
13
  import { readJsoncFile } from "../shared/jsonc-parser"
@@ -296,7 +297,7 @@ const StatusDialog = (props: { api: TuiPluginApi; s: StatusDetail }) => {
296
297
  them how close they are to compaction triggering. */}
297
298
  <box flexDirection="row" justifyContent="space-between" width="100%">
298
299
  <text fg={s().usagePercentage >= 80 ? t().error : s().usagePercentage >= 65 ? t().warning : t().accent}>
299
- <b>{s().usagePercentage.toFixed(1)}%</b> / {s().executeThreshold}%
300
+ <b>{s().usagePercentage.toFixed(1)}%</b> / {formatThresholdPercent(s().executeThreshold)}%
300
301
  </text>
301
302
  <text fg={s().usagePercentage >= 80 ? t().error : s().usagePercentage >= 65 ? t().warning : t().accent}>
302
303
  {fmt(s().inputTokens)} / {contextLimit() > 0 ? fmt(contextLimit()) : "?"} tokens
@@ -341,7 +342,7 @@ const StatusDialog = (props: { api: TuiPluginApi; s: StatusDetail }) => {
341
342
  <R t={t()} l="Configured" v={s().cacheTtl} />
342
343
  <R t={t()} l="Last response" v={s().lastResponseTime > 0 ? `${Math.round(elapsed() / 1000)}s ago` : "never"} />
343
344
  <R t={t()} l="Remaining" v={s().cacheExpired ? "expired" : `${Math.round(s().cacheRemainingMs / 1000)}s`} fg={s().cacheExpired ? t().warning : t().textMuted} />
344
- <R t={t()} l="Auto-execute" v={s().cacheExpired ? "yes (expired)" : `at TTL or ≥${s().executeThreshold}%`} fg={t().textMuted} />
345
+ <R t={t()} l="Auto-execute" v={s().cacheExpired ? "yes (expired)" : `at TTL or ≥${formatThresholdPercent(s().executeThreshold)}%`} fg={t().textMuted} />
345
346
  <box marginTop={1}>
346
347
  <text fg={t().text}><b>Memory</b></text>
347
348
  </box>
@@ -351,7 +352,7 @@ const StatusDialog = (props: { api: TuiPluginApi; s: StatusDetail }) => {
351
352
  {/* Right column */}
352
353
  <box flexDirection="column" flexGrow={1} flexBasis={0}>
353
354
  <text fg={t().text}><b>Rolling Nudges</b></text>
354
- <R t={t()} l="Execute threshold" v={`${s().executeThreshold}%`} />
355
+ <R t={t()} l="Execute threshold" v={`${formatThresholdPercent(s().executeThreshold)}%`} />
355
356
  <R t={t()} l="Nudge anchor" v={`${fmt(s().lastNudgeTokens)} tok`} />
356
357
  <R t={t()} l="Interval" v={`${fmt(s().nudgeInterval)} tok`} fg={t().textMuted} />
357
358
  <R t={t()} l="Next nudge after" v={`${fmt(s().nextNudgeAfter)} tok`} />
@@ -563,6 +564,63 @@ function registerCommandPaletteEntries(api: TuiPluginApi): void {
563
564
  // via RPC.
564
565
  }
565
566
 
567
+ /**
568
+ * Show the one-shot "What's new" dialog on TUI startup if the server tells us
569
+ * to. The server is the source of truth: it has the version + features
570
+ * constants AND owns the persistence file. We just render and report back.
571
+ *
572
+ * Failure-tolerant by design — if the server isn't ready or the RPC fails,
573
+ * we silently skip (the next TUI launch will retry).
574
+ */
575
+ /**
576
+ * URLs render as plain text. Modern terminals (iTerm2, kitty, WezTerm, Ghostty,
577
+ * recent macOS Terminal) auto-detect URLs and let users Cmd-click; older
578
+ * terminals require manual copy. We tried opentui's `<a href>` JSX intrinsic
579
+ * for application-level OSC 8 clickability, but it's a span-like element that
580
+ * forced text out of opentui's word-wrap mode, causing bullets to bleed past
581
+ * the dialog border. Pure-string children of `<text>` wrap correctly, so the
582
+ * AFT-style DialogAlert + plain string is the right surface here.
583
+ */
584
+ async function showStartupAnnouncement(api: TuiPluginApi): Promise<void> {
585
+ try {
586
+ const ann = await getAnnouncement()
587
+ if (!ann.show || !ann.version || !ann.features || ann.features.length === 0) return
588
+
589
+ const title = `Magic Context v${ann.version}`
590
+ const lines: string[] = [
591
+ "What's new:",
592
+ "",
593
+ ...ann.features.map((line) => ` • ${line}`),
594
+ ]
595
+ if (ann.footer && ann.footer.trim().length > 0) {
596
+ // Blank-line separator keeps the persistent footer (Discord invite,
597
+ // etc.) visually distinct from the version-specific bullets.
598
+ lines.push("", ann.footer)
599
+ }
600
+ const message = lines.join("\n")
601
+
602
+ api.ui.dialog.replace(
603
+ () => (
604
+ <api.ui.DialogAlert
605
+ title={title}
606
+ message={message}
607
+ onConfirm={() => {
608
+ void markAnnounced()
609
+ }}
610
+ />
611
+ ),
612
+ () => {
613
+ // User dismissed via Escape rather than confirming. Mark
614
+ // dismissed anyway — they saw the dialog, that's the contract.
615
+ void markAnnounced()
616
+ },
617
+ )
618
+ } catch {
619
+ // RPC not ready yet (port file missing or transient HTTP failure) —
620
+ // silently skip. The next TUI start re-checks.
621
+ }
622
+ }
623
+
566
624
  const tui: TuiPlugin = async (api, _options, meta) => {
567
625
  // Initialize RPC client for server communication
568
626
  const directory = api.state.path.directory ?? ""
@@ -623,6 +681,12 @@ const tui: TuiPlugin = async (api, _options, meta) => {
623
681
  return
624
682
  }
625
683
 
684
+ // Show one-shot release announcement after conflict gate.
685
+ // Fire-and-forget: if the server isn't ready or RPC fails, the next TUI
686
+ // launch will retry. Dialog only appears once per ANNOUNCEMENT_VERSION
687
+ // (persisted via mark-announced RPC writing last_announced_version).
688
+ void showStartupAnnouncement(api)
689
+
626
690
  // Note: if TUI plugin is loaded, tui.json already has our entry.
627
691
  // But if the user added it manually and later removes it, or if they
628
692
  // use setup/doctor which handles tui.json, this code is already running.
@@ -3,6 +3,7 @@ import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
3
3
  import type { TuiSlotPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
4
4
  import packageJson from "../../../package.json"
5
5
  import { loadSidebarSnapshot, type SidebarSnapshot } from "../data/context-db"
6
+ import { formatThresholdPercent } from "../../shared/format-threshold"
6
7
 
7
8
  const SINGLE_BORDER = { type: "single" } as any
8
9
  const REFRESH_DEBOUNCE_MS = 150
@@ -47,7 +48,15 @@ const TokenBreakdown = (props: {
47
48
  theme: TuiThemeCurrent
48
49
  snapshot: SidebarSnapshot
49
50
  }) => {
50
- const barWidth = 36
51
+ // Bar width is hardcoded because the @opencode-ai/plugin/tui slot API does
52
+ // not expose the rendered sidebar width to plugins. 24 chars is the safe
53
+ // floor that fits every realistic sidebar configuration we've observed —
54
+ // OpenCode TUI's sidebar narrows with the terminal, and 36 (the previous
55
+ // value) overflowed users' actual layouts (issue #90), wrapping the bar
56
+ // onto a second line. If/when the slot API surfaces a real width, this
57
+ // should become Math.max(20, providedWidth) like the Pi status dialog
58
+ // already does (`Math.max(20, innerWidth)`).
59
+ const barWidth = 24
51
60
 
52
61
  const segments = createMemo<TokenSegment[]>(() => {
53
62
  const s = props.snapshot
@@ -370,7 +379,7 @@ const SidebarContent = (props: {
370
379
  "47.5% / 65%" tells the user how close they
371
380
  are to the next compaction trigger. */}
372
381
  <text fg={contextSummaryColor()}>
373
- <b>{s()!.usagePercentage.toFixed(1)}%</b> / {s()!.executeThreshold}%
382
+ <b>{s()!.usagePercentage.toFixed(1)}%</b> / {formatThresholdPercent(s()!.executeThreshold)}%
374
383
  </text>
375
384
  {/* Right: absolute token usage vs the model's
376
385
  full context window (separate from the