@slkiser/opencode-quota 3.11.2 → 4.0.1

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 (202) hide show
  1. package/README.md +92 -62
  2. package/dist/bin/opencode-quota.d.ts.map +1 -1
  3. package/dist/bin/opencode-quota.js +16 -7
  4. package/dist/bin/opencode-quota.js.map +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/cli-show.d.ts.map +1 -1
  10. package/dist/lib/cli-show.js +11 -7
  11. package/dist/lib/cli-show.js.map +1 -1
  12. package/dist/lib/command-surfaces.d.ts +10 -0
  13. package/dist/lib/command-surfaces.d.ts.map +1 -0
  14. package/dist/lib/command-surfaces.js +9 -0
  15. package/dist/lib/command-surfaces.js.map +1 -0
  16. package/dist/lib/config-file-utils.d.ts +5 -0
  17. package/dist/lib/config-file-utils.d.ts.map +1 -1
  18. package/dist/lib/config-file-utils.js +20 -3
  19. package/dist/lib/config-file-utils.js.map +1 -1
  20. package/dist/lib/config.d.ts +5 -3
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/config.js +124 -32
  23. package/dist/lib/config.js.map +1 -1
  24. package/dist/lib/copilot.d.ts +19 -12
  25. package/dist/lib/copilot.d.ts.map +1 -1
  26. package/dist/lib/copilot.js +462 -566
  27. package/dist/lib/copilot.js.map +1 -1
  28. package/dist/lib/display-sanitize.d.ts.map +1 -1
  29. package/dist/lib/display-sanitize.js +13 -0
  30. package/dist/lib/display-sanitize.js.map +1 -1
  31. package/dist/lib/entries.d.ts +55 -4
  32. package/dist/lib/entries.d.ts.map +1 -1
  33. package/dist/lib/entries.js.map +1 -1
  34. package/dist/lib/init-installer.d.ts +11 -1
  35. package/dist/lib/init-installer.d.ts.map +1 -1
  36. package/dist/lib/init-installer.js +524 -158
  37. package/dist/lib/init-installer.js.map +1 -1
  38. package/dist/lib/maintainer-announcements.d.ts.map +1 -1
  39. package/dist/lib/maintainer-announcements.js +7 -15
  40. package/dist/lib/maintainer-announcements.js.map +1 -1
  41. package/dist/lib/openai.d.ts.map +1 -1
  42. package/dist/lib/openai.js +15 -0
  43. package/dist/lib/openai.js.map +1 -1
  44. package/dist/lib/opencode-config-editor.d.ts +59 -0
  45. package/dist/lib/opencode-config-editor.d.ts.map +1 -0
  46. package/dist/lib/opencode-config-editor.js +320 -0
  47. package/dist/lib/opencode-config-editor.js.map +1 -0
  48. package/dist/lib/opencode-config-providers.d.ts +18 -0
  49. package/dist/lib/opencode-config-providers.d.ts.map +1 -1
  50. package/dist/lib/opencode-config-providers.js +75 -4
  51. package/dist/lib/opencode-config-providers.js.map +1 -1
  52. package/dist/lib/opencode-runtime-paths.d.ts.map +1 -1
  53. package/dist/lib/opencode-runtime-paths.js +10 -3
  54. package/dist/lib/opencode-runtime-paths.js.map +1 -1
  55. package/dist/lib/opencode-storage.d.ts +10 -0
  56. package/dist/lib/opencode-storage.d.ts.map +1 -1
  57. package/dist/lib/opencode-storage.js +75 -0
  58. package/dist/lib/opencode-storage.js.map +1 -1
  59. package/dist/lib/provider-add-command.d.ts +35 -0
  60. package/dist/lib/provider-add-command.d.ts.map +1 -0
  61. package/dist/lib/provider-add-command.js +212 -0
  62. package/dist/lib/provider-add-command.js.map +1 -0
  63. package/dist/lib/provider-add.d.ts +21 -0
  64. package/dist/lib/provider-add.d.ts.map +1 -0
  65. package/dist/lib/provider-add.js +219 -0
  66. package/dist/lib/provider-add.js.map +1 -0
  67. package/dist/lib/provider-metadata.d.ts +1 -1
  68. package/dist/lib/provider-metadata.d.ts.map +1 -1
  69. package/dist/lib/provider-metadata.js +11 -5
  70. package/dist/lib/provider-metadata.js.map +1 -1
  71. package/dist/lib/quota-command-format.d.ts +2 -0
  72. package/dist/lib/quota-command-format.d.ts.map +1 -1
  73. package/dist/lib/quota-command-format.js +94 -26
  74. package/dist/lib/quota-command-format.js.map +1 -1
  75. package/dist/lib/quota-dialog-commands.d.ts.map +1 -1
  76. package/dist/lib/quota-dialog-commands.js +8 -4
  77. package/dist/lib/quota-dialog-commands.js.map +1 -1
  78. package/dist/lib/quota-export-types.d.ts +48 -27
  79. package/dist/lib/quota-export-types.d.ts.map +1 -1
  80. package/dist/lib/quota-export-types.js +0 -7
  81. package/dist/lib/quota-export-types.js.map +1 -1
  82. package/dist/lib/quota-export.d.ts.map +1 -1
  83. package/dist/lib/quota-export.js +63 -16
  84. package/dist/lib/quota-export.js.map +1 -1
  85. package/dist/lib/quota-providers-local.d.ts +55 -0
  86. package/dist/lib/quota-providers-local.d.ts.map +1 -0
  87. package/dist/lib/quota-providers-local.js +313 -0
  88. package/dist/lib/quota-providers-local.js.map +1 -0
  89. package/dist/lib/quota-providers-remote.d.ts +28 -0
  90. package/dist/lib/quota-providers-remote.d.ts.map +1 -0
  91. package/dist/lib/quota-providers-remote.js +682 -0
  92. package/dist/lib/quota-providers-remote.js.map +1 -0
  93. package/dist/lib/quota-providers.d.ts +158 -0
  94. package/dist/lib/quota-providers.d.ts.map +1 -0
  95. package/dist/lib/quota-providers.js +1167 -0
  96. package/dist/lib/quota-providers.js.map +1 -0
  97. package/dist/lib/quota-render-data.d.ts +1 -0
  98. package/dist/lib/quota-render-data.d.ts.map +1 -1
  99. package/dist/lib/quota-render-data.js +35 -16
  100. package/dist/lib/quota-render-data.js.map +1 -1
  101. package/dist/lib/quota-runtime-context.d.ts.map +1 -1
  102. package/dist/lib/quota-runtime-context.js +3 -4
  103. package/dist/lib/quota-runtime-context.js.map +1 -1
  104. package/dist/lib/quota-state.d.ts +5 -2
  105. package/dist/lib/quota-state.d.ts.map +1 -1
  106. package/dist/lib/quota-state.js +268 -51
  107. package/dist/lib/quota-state.js.map +1 -1
  108. package/dist/lib/quota-status.d.ts +2 -1
  109. package/dist/lib/quota-status.d.ts.map +1 -1
  110. package/dist/lib/quota-status.js +247 -69
  111. package/dist/lib/quota-status.js.map +1 -1
  112. package/dist/lib/qwen-local-quota.d.ts +12 -11
  113. package/dist/lib/qwen-local-quota.d.ts.map +1 -1
  114. package/dist/lib/qwen-local-quota.js +82 -87
  115. package/dist/lib/qwen-local-quota.js.map +1 -1
  116. package/dist/lib/scoped-update.d.ts.map +1 -1
  117. package/dist/lib/scoped-update.js +32 -22
  118. package/dist/lib/scoped-update.js.map +1 -1
  119. package/dist/lib/tui-runtime.d.ts +2 -0
  120. package/dist/lib/tui-runtime.d.ts.map +1 -1
  121. package/dist/lib/tui-runtime.js +33 -9
  122. package/dist/lib/tui-runtime.js.map +1 -1
  123. package/dist/lib/types.d.ts +51 -15
  124. package/dist/lib/types.d.ts.map +1 -1
  125. package/dist/lib/types.js +2 -1
  126. package/dist/lib/types.js.map +1 -1
  127. package/dist/plugin.d.ts.map +1 -1
  128. package/dist/plugin.js +90 -111
  129. package/dist/plugin.js.map +1 -1
  130. package/dist/providers/alibaba-coding-plan.d.ts.map +1 -1
  131. package/dist/providers/alibaba-coding-plan.js +35 -4
  132. package/dist/providers/alibaba-coding-plan.js.map +1 -1
  133. package/dist/providers/anthropic.d.ts.map +1 -1
  134. package/dist/providers/anthropic.js +13 -1
  135. package/dist/providers/anthropic.js.map +1 -1
  136. package/dist/providers/chutes.d.ts.map +1 -1
  137. package/dist/providers/chutes.js +6 -0
  138. package/dist/providers/chutes.js.map +1 -1
  139. package/dist/providers/copilot.d.ts +2 -1
  140. package/dist/providers/copilot.d.ts.map +1 -1
  141. package/dist/providers/copilot.js +143 -60
  142. package/dist/providers/copilot.js.map +1 -1
  143. package/dist/providers/cursor.d.ts.map +1 -1
  144. package/dist/providers/cursor.js +30 -1
  145. package/dist/providers/cursor.js.map +1 -1
  146. package/dist/providers/deepseek.d.ts.map +1 -1
  147. package/dist/providers/deepseek.js +13 -1
  148. package/dist/providers/deepseek.js.map +1 -1
  149. package/dist/providers/google-agy.d.ts.map +1 -1
  150. package/dist/providers/google-agy.js +7 -1
  151. package/dist/providers/google-agy.js.map +1 -1
  152. package/dist/providers/google-antigravity.d.ts.map +1 -1
  153. package/dist/providers/google-antigravity.js +10 -2
  154. package/dist/providers/google-antigravity.js.map +1 -1
  155. package/dist/providers/google-gemini-cli.d.ts.map +1 -1
  156. package/dist/providers/google-gemini-cli.js +8 -2
  157. package/dist/providers/google-gemini-cli.js.map +1 -1
  158. package/dist/providers/kimi-code.d.ts.map +1 -1
  159. package/dist/providers/kimi-code.js +6 -0
  160. package/dist/providers/kimi-code.js.map +1 -1
  161. package/dist/providers/minimax-coding-plan.d.ts.map +1 -1
  162. package/dist/providers/minimax-coding-plan.js +41 -15
  163. package/dist/providers/minimax-coding-plan.js.map +1 -1
  164. package/dist/providers/nanogpt.d.ts.map +1 -1
  165. package/dist/providers/nanogpt.js +19 -1
  166. package/dist/providers/nanogpt.js.map +1 -1
  167. package/dist/providers/ollama-cloud.d.ts.map +1 -1
  168. package/dist/providers/ollama-cloud.js +12 -0
  169. package/dist/providers/ollama-cloud.js.map +1 -1
  170. package/dist/providers/openai.d.ts.map +1 -1
  171. package/dist/providers/openai.js +6 -0
  172. package/dist/providers/openai.js.map +1 -1
  173. package/dist/providers/opencode-go.d.ts.map +1 -1
  174. package/dist/providers/opencode-go.js +9 -1
  175. package/dist/providers/opencode-go.js.map +1 -1
  176. package/dist/providers/quota-providers.d.ts +6 -0
  177. package/dist/providers/quota-providers.d.ts.map +1 -0
  178. package/dist/providers/quota-providers.js +279 -0
  179. package/dist/providers/quota-providers.js.map +1 -0
  180. package/dist/providers/qwen-code.d.ts.map +1 -1
  181. package/dist/providers/qwen-code.js +29 -3
  182. package/dist/providers/qwen-code.js.map +1 -1
  183. package/dist/providers/registry.d.ts.map +1 -1
  184. package/dist/providers/registry.js +2 -0
  185. package/dist/providers/registry.js.map +1 -1
  186. package/dist/providers/result-helpers.d.ts +2 -1
  187. package/dist/providers/result-helpers.d.ts.map +1 -1
  188. package/dist/providers/result-helpers.js +6 -1
  189. package/dist/providers/result-helpers.js.map +1 -1
  190. package/dist/providers/synthetic.d.ts.map +1 -1
  191. package/dist/providers/synthetic.js +7 -1
  192. package/dist/providers/synthetic.js.map +1 -1
  193. package/dist/providers/zai.d.ts.map +1 -1
  194. package/dist/providers/zai.js +7 -1
  195. package/dist/providers/zai.js.map +1 -1
  196. package/dist/providers/zhipu.d.ts.map +1 -1
  197. package/dist/providers/zhipu.js +7 -1
  198. package/dist/providers/zhipu.js.map +1 -1
  199. package/dist/tui.d.ts.map +1 -1
  200. package/dist/tui.js +345 -41
  201. package/dist/tui.tsx +267 -18
  202. package/package.json +28 -21
@@ -1,18 +1,21 @@
1
1
  import { existsSync } from "fs";
2
2
  import { readFile } from "fs/promises";
3
- import { basename } from "path";
3
+ import { basename, join } from "path";
4
4
  import { writeJsonAtomic } from "./atomic-json.js";
5
+ import { applyConfigDocumentEdit, planConfigDocumentEdit, validateConfigDocumentEdit, } from "./opencode-config-editor.js";
5
6
  import { dedupeNonEmptyStrings, extractPluginSpecsFromParsedConfig, getPluginSpecFromEntry, isQuotaPluginSpec, resolveEditableConfigPath, findGitWorktreeRoot, } from "./config-file-utils.js";
6
7
  import { parseJsonOrJsonc } from "./jsonc.js";
8
+ import { QUOTA_PROVIDERS_AGGREGATE_ID } from "./quota-providers.js";
7
9
  import { getOpencodeRuntimeDirCandidates } from "./opencode-runtime-paths.js";
8
10
  import { QUOTA_PROVIDER_SHAPES, getQuotaProviderDisplayLabel, normalizeQuotaProviderId, } from "./provider-metadata.js";
9
11
  import { getQuotaFormatStyleLabel, isQuotaFormatStyle, resolveQuotaFormatStyle, } from "./quota-format-style.js";
10
- import { getQuotaToastConfigPath, QUOTA_TOAST_CONFIG_RELATIVE_PATH } from "./config.js";
12
+ import { getQuotaToastConfigPath, QUOTA_TOAST_CONFIG_RELATIVE_PATH, QUOTA_TOAST_CONFIG_RELATIVE_PATHS, } from "./config.js";
11
13
  const QUOTA_PLUGIN_SPEC = "@slkiser/opencode-quota@latest";
12
14
  const OPENCODE_SCHEMA_URL = "https://opencode.ai/config.json";
13
15
  const TUI_SCHEMA_URL = "https://opencode.ai/tui.json";
14
16
  const GITHUB_REPO_URL = "https://github.com/slkiser/opencode-quota";
15
17
  const GITHUB_STAR_NOTE = `if this helps, stars are appreciated: ${GITHUB_REPO_URL}`;
18
+ const TUI_COMMAND_DISPLAY_COMMENT = "// OpenCode Quota: tuiCommandDisplay chooses whether native TUI command output appears in the session transcript or a local popup dialog.";
16
19
  export class InitInstallerError extends Error {
17
20
  details;
18
21
  constructor(message, details) {
@@ -30,44 +33,37 @@ function hasOwnKey(value, key) {
30
33
  function jsonEqual(left, right) {
31
34
  return JSON.stringify(left) === JSON.stringify(right);
32
35
  }
33
- const QUOTA_UI_CHOICE_ORDER = ["toast", "sidebar", "compact_status", "none"];
36
+ const QUOTA_UI_CHOICE_ORDER = ["sidebar", "toast", "compact_status", "none"];
34
37
  function normalizeQuotaUiIntent(selections) {
35
- const legacySelections = selections;
36
- const quotaUi = legacySelections.quotaUi ?? [];
37
- const rawChoices = Array.isArray(quotaUi)
38
- ? quotaUi
39
- : quotaUi === "toast_sidebar"
40
- ? ["toast", "sidebar"]
41
- : [quotaUi];
38
+ const rawSelections = selections;
39
+ if (hasOwnKey(rawSelections, "tuiCompactStatus")) {
40
+ throw new InitInstallerError("Unsupported installer selection: tuiCompactStatus");
41
+ }
42
+ const quotaUi = rawSelections.quotaUi;
43
+ if (!Array.isArray(quotaUi)) {
44
+ throw new InitInstallerError("Quota UI selections must be an array.");
45
+ }
46
+ if (quotaUi.length === 0) {
47
+ throw new InitInstallerError("Quota UI selections must not be empty.");
48
+ }
42
49
  const seen = new Set();
43
- for (const rawChoice of rawChoices) {
50
+ for (const rawChoice of quotaUi) {
44
51
  if (typeof rawChoice !== "string" ||
45
52
  !QUOTA_UI_CHOICE_ORDER.includes(rawChoice)) {
46
53
  throw new InitInstallerError(`Unknown Quota UI option: ${String(rawChoice)}`);
47
54
  }
48
55
  seen.add(rawChoice);
49
56
  }
50
- const legacyCompactMode = legacySelections.tuiCompactStatus;
51
- if (legacyCompactMode !== undefined && legacyCompactMode !== "off") {
52
- if (legacyCompactMode !== "home_bottom" && legacyCompactMode !== "home_bottom_session_prompt") {
53
- throw new InitInstallerError(`Unknown Compact TUI status: ${String(legacyCompactMode)}`);
54
- }
55
- seen.delete("none");
56
- seen.add("compact_status");
57
- }
58
- let choices = QUOTA_UI_CHOICE_ORDER.filter((choice) => seen.has(choice));
59
- if (choices.length === 0) {
60
- choices = ["none"];
61
- }
62
- else if (choices.length > 1 && choices.includes("none")) {
63
- choices = choices.filter((choice) => choice !== "none");
57
+ if (seen.has("none") && seen.size > 1) {
58
+ throw new InitInstallerError("Manual commands only cannot be combined with automatic quota displays.");
64
59
  }
60
+ const choices = QUOTA_UI_CHOICE_ORDER.filter((choice) => seen.has(choice));
65
61
  const enableSidebarPanel = choices.includes("sidebar");
66
62
  const enableCompactStatus = choices.includes("compact_status");
67
63
  return {
68
64
  choices,
69
65
  enableToast: choices.includes("toast"),
70
- installTuiPlugin: enableSidebarPanel || enableCompactStatus,
66
+ installTuiPlugin: selections.interfaces !== "web",
71
67
  enableSidebarPanel,
72
68
  enableCompactStatus,
73
69
  };
@@ -80,7 +76,7 @@ function getUiLabel(choices) {
80
76
  return "Sidebar";
81
77
  if (choice === "compact_status")
82
78
  return "Compact status";
83
- return "No automatic UI surfaces";
79
+ return "Manual commands only";
84
80
  });
85
81
  return labels.join(" + ");
86
82
  }
@@ -90,13 +86,6 @@ function getProviderModeLabel(mode) {
90
86
  function getPercentDisplayModeLabel(mode) {
91
87
  return mode === "used" ? "Used" : "Remaining";
92
88
  }
93
- function getTuiCompactStatusLabel(mode) {
94
- if (mode === "home_bottom")
95
- return "Home bottom only";
96
- if (mode === "home_bottom_session_prompt")
97
- return "Home bottom + session prompt";
98
- return "Off";
99
- }
100
89
  function resolveRequestedProviders(selections) {
101
90
  if (selections.providerMode === "auto") {
102
91
  return "auto";
@@ -199,9 +188,11 @@ function setInstallerOwnedSetting(target, key, value, pathLabel, edit) {
199
188
  return;
200
189
  }
201
190
  if (!jsonEqual(target[key], value)) {
191
+ const previousValue = target[key];
202
192
  target[key] = value;
203
193
  edit.changed = true;
204
194
  edit.updatedKeys.push(pathLabel);
195
+ edit.valueChanges.push(`${pathLabel}: ${JSON.stringify(previousValue)} -> ${JSON.stringify(value)}`);
205
196
  }
206
197
  }
207
198
  function planTuiSidebarPanelConfig(params) {
@@ -300,7 +291,10 @@ async function readLegacyQuotaToastSeed(baseDir) {
300
291
  if (!target.existed) {
301
292
  return null;
302
293
  }
303
- const root = await readExistingConfig(target);
294
+ const root = await readExistingConfig({
295
+ path: target.sourcePath,
296
+ format: target.sourcePath.endsWith(".jsonc") ? "jsonc" : "json",
297
+ });
304
298
  const experimental = isPlainObject(root.experimental) ? root.experimental : null;
305
299
  const quotaToast = experimental && isPlainObject(experimental.quotaToast) ? experimental.quotaToast : null;
306
300
  return quotaToast ? cloneJsonObject(quotaToast) : null;
@@ -361,7 +355,12 @@ function syncLegacyQuotaToast(params) {
361
355
  }
362
356
  }
363
357
  async function planOpencodeEdit(params) {
364
- const target = resolveEditableConfigPath({ dir: params.baseDir, kind: "opencode" });
358
+ const target = resolveEditableConfigPath({
359
+ dir: params.baseDir,
360
+ kind: "opencode",
361
+ preferredFormat: params.selections.configFormat ?? "jsonc",
362
+ convertJsonToJsonc: true,
363
+ });
365
364
  const edit = {
366
365
  kind: "opencode",
367
366
  path: target.path,
@@ -371,13 +370,19 @@ async function planOpencodeEdit(params) {
371
370
  addedPlugins: [],
372
371
  addedKeys: [],
373
372
  updatedKeys: [],
373
+ valueChanges: [],
374
374
  skippedValues: [],
375
- warnings: target.format === "jsonc"
376
- ? ["Existing JSONC comments/trailing commas will be stripped."]
377
- : [],
375
+ warnings: [],
378
376
  };
379
- const root = target.existed ? await readExistingConfig(target) : {};
380
- ensureSchema(root, OPENCODE_SCHEMA_URL, edit);
377
+ const root = target.existed
378
+ ? await readExistingConfig({
379
+ path: target.sourcePath,
380
+ format: target.sourcePath.endsWith(".jsonc") ? "jsonc" : "json",
381
+ })
382
+ : {};
383
+ if (!target.existed) {
384
+ ensureSchema(root, OPENCODE_SCHEMA_URL, edit);
385
+ }
381
386
  const plugin = ensureTopLevelPluginArray(root, edit);
382
387
  appendQuotaPluginIfMissing({
383
388
  container: plugin,
@@ -392,46 +397,109 @@ async function planOpencodeEdit(params) {
392
397
  edit,
393
398
  });
394
399
  }
395
- if (edit.changed) {
396
- edit.nextData = root;
397
- }
400
+ const documentEdit = await planConfigDocumentEdit({
401
+ target,
402
+ desiredData: root,
403
+ managedComments: [
404
+ {
405
+ path: ["plugin"],
406
+ text: TUI_COMMAND_DISPLAY_COMMENT,
407
+ },
408
+ {
409
+ path: ["plugin"],
410
+ text: "// OpenCode Quota: loads the server plugin for slash commands and quota checks.",
411
+ },
412
+ ],
413
+ });
414
+ edit.changed = documentEdit.changed;
415
+ edit.documentEdit = documentEdit;
398
416
  return edit;
399
417
  }
418
+ function resolveQuotaConfigTarget(baseDir, preferredFormat) {
419
+ const jsoncPath = getQuotaToastConfigPath(baseDir, "jsonc");
420
+ if (existsSync(jsoncPath)) {
421
+ return {
422
+ path: jsoncPath,
423
+ sourcePath: jsoncPath,
424
+ format: "jsonc",
425
+ existed: true,
426
+ };
427
+ }
428
+ const jsonPath = getQuotaToastConfigPath(baseDir, "json");
429
+ if (existsSync(jsonPath)) {
430
+ if (preferredFormat === "jsonc") {
431
+ return {
432
+ path: jsoncPath,
433
+ sourcePath: jsonPath,
434
+ removeSourcePath: jsonPath,
435
+ format: "jsonc",
436
+ existed: true,
437
+ };
438
+ }
439
+ return {
440
+ path: jsonPath,
441
+ sourcePath: jsonPath,
442
+ format: "json",
443
+ existed: true,
444
+ };
445
+ }
446
+ const path = getQuotaToastConfigPath(baseDir, preferredFormat);
447
+ return {
448
+ path,
449
+ sourcePath: path,
450
+ format: preferredFormat,
451
+ existed: false,
452
+ };
453
+ }
400
454
  async function planQuotaConfigEdit(params) {
401
- const path = getQuotaToastConfigPath(params.baseDir);
402
- const existed = existsSync(path);
455
+ const target = resolveQuotaConfigTarget(params.baseDir, params.selections.configFormat ?? "jsonc");
403
456
  const edit = {
404
457
  kind: "quota",
405
- path,
406
- existed,
407
- format: "json",
458
+ path: target.path,
459
+ existed: target.existed,
460
+ format: target.format,
408
461
  changed: false,
409
462
  addedPlugins: [],
410
463
  addedKeys: [],
411
464
  updatedKeys: [],
465
+ valueChanges: [],
412
466
  skippedValues: [],
413
467
  warnings: [],
414
468
  };
415
- const legacyQuotaToast = existed ? null : await readLegacyQuotaToastSeed(params.baseDir);
416
- const quotaToast = existed
417
- ? await readExistingConfig({ path, format: "json" })
469
+ if (target.path.endsWith(".jsonc") &&
470
+ existsSync(getQuotaToastConfigPath(params.baseDir, "json"))) {
471
+ edit.warnings.push("Both quota-toast.jsonc and quota-toast.json exist; using JSONC and preserving the JSON file.");
472
+ }
473
+ const legacyQuotaToast = target.existed ? null : await readLegacyQuotaToastSeed(params.baseDir);
474
+ const quotaToast = target.existed
475
+ ? await readExistingConfig({
476
+ path: target.sourcePath,
477
+ format: target.sourcePath.endsWith(".jsonc") ? "jsonc" : "json",
478
+ })
418
479
  : legacyQuotaToast
419
480
  ? cloneJsonObject(legacyQuotaToast)
420
481
  : {};
421
- if (!existed) {
422
- edit.changed = true;
482
+ if (!target.existed) {
423
483
  edit.addedKeys.push(legacyQuotaToast
424
- ? `${QUOTA_TOAST_CONFIG_RELATIVE_PATH} (seeded from experimental.quotaToast)`
425
- : QUOTA_TOAST_CONFIG_RELATIVE_PATH);
484
+ ? `${target.path.endsWith(".jsonc") ? QUOTA_TOAST_CONFIG_RELATIVE_PATHS[0] : QUOTA_TOAST_CONFIG_RELATIVE_PATH} (seeded from experimental.quotaToast)`
485
+ : target.path.endsWith(".jsonc")
486
+ ? QUOTA_TOAST_CONFIG_RELATIVE_PATHS[0]
487
+ : QUOTA_TOAST_CONFIG_RELATIVE_PATH);
426
488
  }
427
489
  setInstallerOwnedSetting(quotaToast, "enableToast", params.quotaUiIntent.enableToast, "quotaToast.enableToast", edit);
428
- addSettingIfMissing(quotaToast, "showSessionTokens", params.selections.showSessionTokens, "quotaToast.showSessionTokens", edit);
429
- addSettingIfMissing(quotaToast, "enabledProviders", resolveRequestedProviders(params.selections), "quotaToast.enabledProviders", edit);
430
- addSettingIfMissing(quotaToast, "formatStyle", pickFormatStyleToWrite({
431
- quotaToast,
432
- selectedFormatStyle: params.selections.formatStyle,
433
- }), "quotaToast.formatStyle", edit);
434
- addSettingIfMissing(quotaToast, "percentDisplayMode", params.selections.percentDisplayMode, "quotaToast.percentDisplayMode", edit);
490
+ if (params.selections.tuiCommandDisplay !== undefined) {
491
+ setInstallerOwnedSetting(quotaToast, "tuiCommandDisplay", params.selections.tuiCommandDisplay, "quotaToast.tuiCommandDisplay", edit);
492
+ }
493
+ setInstallerOwnedSetting(quotaToast, "showSessionTokens", params.selections.showSessionTokens, "quotaToast.showSessionTokens", edit);
494
+ const requestedProviders = resolveRequestedProviders(params.selections);
495
+ const enabledProviders = requestedProviders !== "auto" &&
496
+ Array.isArray(quotaToast.enabledProviders) &&
497
+ quotaToast.enabledProviders.includes(QUOTA_PROVIDERS_AGGREGATE_ID)
498
+ ? [...requestedProviders, QUOTA_PROVIDERS_AGGREGATE_ID]
499
+ : requestedProviders;
500
+ setInstallerOwnedSetting(quotaToast, "enabledProviders", enabledProviders, "quotaToast.enabledProviders", edit);
501
+ setInstallerOwnedSetting(quotaToast, "formatStyle", params.selections.formatStyle, "quotaToast.formatStyle", edit);
502
+ setInstallerOwnedSetting(quotaToast, "percentDisplayMode", params.selections.percentDisplayMode, "quotaToast.percentDisplayMode", edit);
435
503
  planTuiSidebarPanelConfig({
436
504
  quotaToast,
437
505
  quotaUiIntent: params.quotaUiIntent,
@@ -447,14 +515,86 @@ async function planQuotaConfigEdit(params) {
447
515
  selections: params.selections,
448
516
  edit,
449
517
  });
518
+ const documentEdit = await planConfigDocumentEdit({
519
+ target,
520
+ desiredData: quotaToast,
521
+ managedComments: [
522
+ {
523
+ path: ["enableToast"],
524
+ text: "// Automatic quota surfaces. Slash commands remain available when these are disabled.",
525
+ },
526
+ {
527
+ path: ["enabledProviders"],
528
+ text: '// Provider selection: "auto" detects providers; an array tracks only listed providers.',
529
+ },
530
+ {
531
+ path: ["formatStyle"],
532
+ text: "// Quota presentation and reset-period choices.",
533
+ },
534
+ {
535
+ path: ["showSessionTokens"],
536
+ text: "// Include or hide current-session input and output token totals.",
537
+ },
538
+ {
539
+ path: ["tuiSidebarPanel"],
540
+ text: "// TUI-only surfaces.",
541
+ },
542
+ {
543
+ path: ["maintainerAnnouncements"],
544
+ text: "// Optional bundled maintainer Home announcements.",
545
+ },
546
+ ],
547
+ });
548
+ edit.changed = documentEdit.changed;
549
+ edit.documentEdit = documentEdit;
450
550
  edit.plannedData = quotaToast;
451
- if (edit.changed) {
452
- edit.nextData = quotaToast;
453
- }
454
551
  return edit;
455
552
  }
553
+ function getCanonicalQuotaPackageSpecForRemoval(entry) {
554
+ const spec = typeof entry === "string"
555
+ ? entry
556
+ : Array.isArray(entry) && typeof entry[0] === "string"
557
+ ? entry[0]
558
+ : undefined;
559
+ if (spec === "@slkiser/opencode-quota")
560
+ return spec;
561
+ if (spec !== undefined &&
562
+ /^@slkiser\/opencode-quota@(?:v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?|[A-Za-z][0-9A-Za-z._-]*)$/.test(spec)) {
563
+ return spec;
564
+ }
565
+ return undefined;
566
+ }
567
+ function removeQuotaPluginsFromTui(root, edit) {
568
+ const containers = [
569
+ { value: root.plugin, pathLabel: "plugin" },
570
+ ];
571
+ if (isPlainObject(root.tui)) {
572
+ containers.push({ value: root.tui.plugin, pathLabel: "tui.plugin" });
573
+ }
574
+ for (const container of containers) {
575
+ if (!Array.isArray(container.value))
576
+ continue;
577
+ const retained = container.value.filter((entry) => {
578
+ const spec = getCanonicalQuotaPackageSpecForRemoval(entry);
579
+ if (spec !== undefined) {
580
+ edit.updatedKeys.push(`${container.pathLabel}: remove ${spec}`);
581
+ return false;
582
+ }
583
+ return true;
584
+ });
585
+ if (retained.length !== container.value.length) {
586
+ container.value.splice(0, container.value.length, ...retained);
587
+ edit.changed = true;
588
+ }
589
+ }
590
+ }
456
591
  async function planTuiEdit(params) {
457
- const target = resolveEditableConfigPath({ dir: params.baseDir, kind: "tui" });
592
+ const target = resolveEditableConfigPath({
593
+ dir: params.baseDir,
594
+ kind: "tui",
595
+ preferredFormat: params.selections.configFormat ?? "jsonc",
596
+ convertJsonToJsonc: params.selections.interfaces !== "web",
597
+ });
458
598
  const edit = {
459
599
  kind: "tui",
460
600
  path: target.path,
@@ -464,54 +604,95 @@ async function planTuiEdit(params) {
464
604
  addedPlugins: [],
465
605
  addedKeys: [],
466
606
  updatedKeys: [],
607
+ valueChanges: [],
467
608
  skippedValues: [],
468
- warnings: target.format === "jsonc"
469
- ? ["Existing JSONC comments/trailing commas will be stripped."]
470
- : [],
609
+ warnings: [],
471
610
  };
472
- const root = target.existed ? await readExistingConfig(target) : {};
473
- ensureSchema(root, TUI_SCHEMA_URL, edit);
474
- const existingPluginSpecs = extractPluginSpecsFromParsedConfig(root);
475
- if (existingPluginSpecs.some((spec) => isQuotaPluginSpec(spec, "tui"))) {
476
- edit.skippedValues.push(`tui config already includes ${QUOTA_PLUGIN_SPEC}`);
611
+ if (params.selections.interfaces === "web" && !target.existed) {
612
+ return edit;
613
+ }
614
+ const root = target.existed
615
+ ? await readExistingConfig({
616
+ path: target.sourcePath,
617
+ format: target.sourcePath.endsWith(".jsonc") ? "jsonc" : "json",
618
+ })
619
+ : {};
620
+ if (!target.existed) {
621
+ ensureSchema(root, TUI_SCHEMA_URL, edit);
477
622
  }
478
- else {
479
- const pluginTarget = ensureTuiPluginArray(root, edit);
480
- appendQuotaPluginIfMissing({
481
- container: pluginTarget.container,
482
- pathLabel: pluginTarget.pathLabel,
483
- kind: "tui",
484
- edit,
485
- });
623
+ if (params.selections.interfaces === "web") {
624
+ removeQuotaPluginsFromTui(root, edit);
486
625
  }
487
- if (edit.changed) {
488
- edit.nextData = root;
626
+ else {
627
+ const existingPluginSpecs = extractPluginSpecsFromParsedConfig(root);
628
+ if (existingPluginSpecs.some((spec) => isQuotaPluginSpec(spec, "tui"))) {
629
+ edit.skippedValues.push(`tui config already includes ${QUOTA_PLUGIN_SPEC}`);
630
+ }
631
+ else {
632
+ const pluginTarget = ensureTuiPluginArray(root, edit);
633
+ appendQuotaPluginIfMissing({
634
+ container: pluginTarget.container,
635
+ pathLabel: pluginTarget.pathLabel,
636
+ kind: "tui",
637
+ edit,
638
+ });
639
+ }
489
640
  }
641
+ const documentEdit = await planConfigDocumentEdit({
642
+ target,
643
+ desiredData: root,
644
+ managedComments: params.selections.interfaces === "web"
645
+ ? []
646
+ : [
647
+ {
648
+ path: ["plugin"],
649
+ text: TUI_COMMAND_DISPLAY_COMMENT,
650
+ },
651
+ {
652
+ path: ["plugin"],
653
+ text: "// OpenCode Quota: loads the TUI sidebar, compact status, and local commands.",
654
+ },
655
+ ],
656
+ });
657
+ edit.changed = documentEdit.changed;
658
+ edit.documentEdit = documentEdit;
490
659
  return edit;
491
660
  }
492
661
  function buildPlanSummary(plan) {
493
662
  const quotaUiIntent = normalizeQuotaUiIntent(plan.selections);
663
+ const opencodeFormat = plan.edits.find((edit) => edit.kind === "opencode")?.format ??
664
+ plan.selections.configFormat ??
665
+ "jsonc";
494
666
  const lines = [
667
+ `Interface: ${plan.selections.interfaces === "tui" ? "TUI" : plan.selections.interfaces === "web" ? "Web" : "Both"}`,
495
668
  `Scope: ${plan.selections.scope} (${plan.baseDir})`,
496
- `Quota UI: ${getUiLabel(quotaUiIntent.choices)}`,
497
- `Provider mode: ${getProviderModeLabel(plan.selections.providerMode)}`,
498
- `Quota reset periods: ${getQuotaFormatStyleLabel(plan.selections.formatStyle)}`,
499
- `Quota percentage meaning: ${getPercentDisplayModeLabel(plan.selections.percentDisplayMode)}`,
500
- `Session token details: ${plan.selections.showSessionTokens ? "Show" : "Hide"}`,
669
+ `Config format: ${opencodeFormat.toUpperCase()}`,
501
670
  ];
671
+ if (plan.selections.interfaces !== "web") {
672
+ lines.push(`TUI surfaces: ${getUiLabel(quotaUiIntent.choices)}`);
673
+ lines.push(`Command display: ${plan.selections.tuiCommandDisplay === "inline" ? "Inline with messages" : "Popup dialog"}`);
674
+ }
675
+ lines.push(`Provider mode: ${getProviderModeLabel(plan.selections.providerMode)}`, `Quota reset periods: ${getQuotaFormatStyleLabel(plan.selections.formatStyle)}`, `Quota percentage meaning: ${getPercentDisplayModeLabel(plan.selections.percentDisplayMode)}`, `Session input/output tokens: ${plan.selections.showSessionTokens ? "Show" : "Hide"}`);
502
676
  if (plan.selections.maintainerAnnouncements !== undefined) {
503
677
  lines.push(`Maintainer announcements: ${plan.selections.maintainerAnnouncements ? "Enabled" : "Disabled"}`);
504
678
  }
505
679
  if (quotaUiIntent.enableCompactStatus) {
506
- lines.push(`Compact status mode: ${getTuiCompactStatusLabel("home_bottom_session_prompt")}`);
680
+ lines.push("Compact status mode: Home bottom + session prompt");
507
681
  }
508
682
  const requestedProviders = resolveRequestedProviders(plan.selections);
509
683
  if (requestedProviders !== "auto") {
510
684
  lines.push(`Manual providers: ${requestedProviders.map((providerId) => getQuotaProviderDisplayLabel(providerId)).join(", ")}`);
511
685
  }
512
686
  for (const edit of plan.edits) {
513
- const mode = !edit.existed ? "create" : edit.changed ? "update" : "unchanged";
514
- lines.push(`${mode}: ${edit.path}`);
687
+ const convertedFrom = edit.documentEdit?.removeSourcePath;
688
+ const mode = convertedFrom
689
+ ? "convert"
690
+ : !edit.existed
691
+ ? "create"
692
+ : edit.changed
693
+ ? "update"
694
+ : "unchanged";
695
+ lines.push(convertedFrom ? `${mode}: ${convertedFrom} -> ${edit.path}` : `${mode}: ${edit.path}`);
515
696
  for (const plugin of edit.addedPlugins) {
516
697
  lines.push(` + plugin ${plugin}`);
517
698
  }
@@ -521,6 +702,9 @@ function buildPlanSummary(plan) {
521
702
  for (const key of edit.updatedKeys) {
522
703
  lines.push(` ~ ${key}`);
523
704
  }
705
+ for (const change of edit.valueChanges) {
706
+ lines.push(` ${change}`);
707
+ }
524
708
  for (const skipped of edit.skippedValues) {
525
709
  lines.push(` = ${skipped}`);
526
710
  }
@@ -562,10 +746,18 @@ export function resolveInitInstallerBaseDir(params) {
562
746
  return findGitWorktreeRoot(cwd) ?? cwd;
563
747
  }
564
748
  export async function planInitInstaller(params) {
565
- const quotaUiIntent = normalizeQuotaUiIntent(params.selections);
566
- const selections = {
749
+ const requestedSelections = {
567
750
  ...params.selections,
751
+ quotaUi: params.selections.interfaces === "web" ? ["none"] : params.selections.quotaUi,
752
+ };
753
+ const quotaUiIntent = normalizeQuotaUiIntent(requestedSelections);
754
+ const selections = {
755
+ ...requestedSelections,
756
+ configFormat: params.selections.configFormat ?? "jsonc",
568
757
  quotaUi: quotaUiIntent.choices,
758
+ tuiCommandDisplay: params.selections.interfaces === "web"
759
+ ? undefined
760
+ : (params.selections.tuiCommandDisplay ?? "inline"),
569
761
  maintainerAnnouncements: params.selections.maintainerAnnouncements,
570
762
  manualProviders: params.selections.providerMode === "manual"
571
763
  ? resolveRequestedProviders(params.selections)
@@ -586,8 +778,9 @@ export async function planInitInstaller(params) {
586
778
  }),
587
779
  quotaEdit,
588
780
  ];
589
- if (quotaUiIntent.installTuiPlugin) {
590
- edits.push(await planTuiEdit({ selections, baseDir }));
781
+ const tuiEdit = await planTuiEdit({ selections, baseDir });
782
+ if (selections.interfaces !== "web" || tuiEdit.existed) {
783
+ edits.push(tuiEdit);
591
784
  }
592
785
  const quickSetupNotes = buildQuickSetupNotes(selections);
593
786
  const warnings = edits.flatMap((edit) => edit.warnings);
@@ -605,13 +798,30 @@ export async function planInitInstaller(params) {
605
798
  export async function applyInitInstallerPlan(plan) {
606
799
  const writtenPaths = [];
607
800
  const unchangedPaths = [];
801
+ try {
802
+ await Promise.all(plan.edits.flatMap((edit) => edit.documentEdit ? [validateConfigDocumentEdit(edit.documentEdit)] : []));
803
+ }
804
+ catch (error) {
805
+ const path = error && typeof error === "object" && "path" in error
806
+ ? String(error.path ?? "")
807
+ : "";
808
+ throw new InitInstallerError(`Config changed since preview${path ? `: ${path}` : "."}`, {
809
+ path: path || undefined,
810
+ writtenPaths,
811
+ });
812
+ }
608
813
  for (const edit of plan.edits) {
609
- if (!edit.changed || !edit.nextData) {
814
+ if (!edit.changed || (!edit.nextData && !edit.documentEdit)) {
610
815
  unchangedPaths.push(edit.path);
611
816
  continue;
612
817
  }
613
818
  try {
614
- await writeJsonAtomic(edit.path, edit.nextData, { trailingNewline: true });
819
+ if (edit.documentEdit) {
820
+ await applyConfigDocumentEdit(edit.documentEdit);
821
+ }
822
+ else {
823
+ await writeJsonAtomic(edit.path, edit.nextData, { trailingNewline: true });
824
+ }
615
825
  writtenPaths.push(edit.path);
616
826
  }
617
827
  catch (error) {
@@ -626,63 +836,183 @@ export async function applyInitInstallerPlan(plan) {
626
836
  unchangedPaths,
627
837
  };
628
838
  }
629
- async function promptForSelections(prompts) {
839
+ async function readExistingInstallerAnswers(baseDir) {
840
+ const existingQuotaPath = QUOTA_TOAST_CONFIG_RELATIVE_PATHS.map((relativePath) => join(baseDir, relativePath)).find((path) => existsSync(path));
841
+ const existingHostPath = ["opencode.jsonc", "opencode.json"]
842
+ .map((name) => join(baseDir, name))
843
+ .find((path) => existsSync(path));
844
+ const answers = {
845
+ configFormat: existingQuotaPath?.endsWith(".jsonc") || existingHostPath?.endsWith(".jsonc")
846
+ ? "jsonc"
847
+ : existingQuotaPath || existingHostPath
848
+ ? "json"
849
+ : undefined,
850
+ };
851
+ if (!existingQuotaPath)
852
+ return answers;
853
+ const quotaToast = await readExistingConfig({
854
+ path: existingQuotaPath,
855
+ format: existingQuotaPath.endsWith(".jsonc") ? "jsonc" : "json",
856
+ });
857
+ const quotaUi = [];
858
+ if (isPlainObject(quotaToast.tuiSidebarPanel) && quotaToast.tuiSidebarPanel.enabled === true) {
859
+ quotaUi.push("sidebar");
860
+ }
861
+ if (quotaToast.enableToast === true)
862
+ quotaUi.push("toast");
863
+ if (isPlainObject(quotaToast.tuiCompactStatus) && quotaToast.tuiCompactStatus.enabled === true) {
864
+ quotaUi.push("compact_status");
865
+ }
866
+ answers.quotaUi = quotaUi.length > 0 ? quotaUi : ["none"];
867
+ if (quotaToast.enabledProviders === "auto") {
868
+ answers.providerMode = "auto";
869
+ answers.manualProviders = [];
870
+ }
871
+ else if (Array.isArray(quotaToast.enabledProviders)) {
872
+ answers.providerMode = "manual";
873
+ answers.manualProviders = quotaToast.enabledProviders.filter((value) => typeof value === "string" &&
874
+ QUOTA_PROVIDER_SHAPES.some((shape) => shape.id === normalizeQuotaProviderId(value)));
875
+ }
876
+ if (isQuotaFormatStyle(quotaToast.formatStyle)) {
877
+ answers.formatStyle = resolveQuotaFormatStyle(quotaToast.formatStyle);
878
+ }
879
+ if (quotaToast.percentDisplayMode === "remaining" || quotaToast.percentDisplayMode === "used") {
880
+ answers.percentDisplayMode = quotaToast.percentDisplayMode;
881
+ }
882
+ if (typeof quotaToast.showSessionTokens === "boolean") {
883
+ answers.showSessionTokens = quotaToast.showSessionTokens;
884
+ }
885
+ if (quotaToast.tuiCommandDisplay === "inline" || quotaToast.tuiCommandDisplay === "dialog") {
886
+ answers.tuiCommandDisplay = quotaToast.tuiCommandDisplay;
887
+ }
888
+ if (isPlainObject(quotaToast.maintainerAnnouncements) &&
889
+ typeof quotaToast.maintainerAnnouncements.enabled === "boolean") {
890
+ answers.maintainerAnnouncements = quotaToast.maintainerAnnouncements.enabled;
891
+ }
892
+ return answers;
893
+ }
894
+ async function promptForSelections(prompts, context) {
895
+ const interfaces = await prompts.select({
896
+ message: "Which OpenCode interfaces do you use?",
897
+ options: [
898
+ { label: "TUI", value: "tui", hint: "terminal interface" },
899
+ { label: "Web", value: "web", hint: "browser interface" },
900
+ {
901
+ label: "Both",
902
+ value: "both",
903
+ hint: "configure terminal and browser interfaces",
904
+ },
905
+ ],
906
+ });
907
+ if (prompts.isCancel(interfaces))
908
+ return null;
909
+ if (interfaces === "web") {
910
+ prompts.log.info("Web slash commands appear inline. TUI-only surfaces and popup dialogs are unavailable.");
911
+ }
630
912
  const scope = await prompts.select({
631
- message: "Install scope",
913
+ message: "Where should OpenCode Quota be configured?",
914
+ initialValue: "global",
632
915
  options: [
633
- { label: "Project config", value: "project", hint: "install only for this repo/worktree" },
634
916
  {
635
- label: "Global OpenCode config",
917
+ label: "Global OpenCode config (recommended)",
636
918
  value: "global",
637
919
  hint: "install for all projects using your global config",
638
920
  },
921
+ { label: "Project config", value: "project", hint: "install only for this repo/worktree" },
639
922
  ],
640
923
  });
641
924
  if (prompts.isCancel(scope))
642
925
  return null;
643
- const quotaUi = await prompts.multiselect({
644
- message: "Quota UI",
645
- required: true,
926
+ const baseDir = resolveInitInstallerBaseDir({
927
+ scope: scope,
928
+ cwd: context.cwd,
929
+ env: context.env,
930
+ homeDir: context.homeDir,
931
+ });
932
+ const existing = await readExistingInstallerAnswers(baseDir);
933
+ const configFormat = await prompts.select({
934
+ message: "OpenCode config format",
935
+ initialValue: existing.configFormat ?? "jsonc",
646
936
  options: [
647
937
  {
648
- label: "Toast",
649
- value: "toast",
650
- hint: "popup quota summaries after idle/question/compact events",
651
- },
652
- {
653
- label: "Sidebar panel",
654
- value: "sidebar",
655
- hint: "full Quota panel in the OpenCode session sidebar",
656
- },
657
- {
658
- label: "Compact status line",
659
- value: "compact_status",
660
- hint: "short quota summary in the TUI status area",
661
- },
662
- {
663
- label: "No automatic UI surfaces",
664
- value: "none",
665
- hint: "no toast, sidebar, compact status, or TUI dialogs; server slash commands stay installed",
938
+ label: "JSONC (recommended)",
939
+ value: "jsonc",
940
+ hint: "keeps useful section comments and trailing commas",
666
941
  },
942
+ { label: "JSON", value: "json", hint: "strict JSON without comments" },
667
943
  ],
668
944
  });
669
- if (prompts.isCancel(quotaUi))
945
+ if (prompts.isCancel(configFormat))
670
946
  return null;
671
- if (!Array.isArray(quotaUi)) {
672
- throw new InitInstallerError("Quota UI requires selected options.");
947
+ let quotaUi = ["none"];
948
+ let tuiCommandDisplay;
949
+ let maintainerAnnouncements;
950
+ if (interfaces !== "web") {
951
+ while (true) {
952
+ quotaUi = await prompts.multiselect({
953
+ message: "Which automatic quota displays do you want?",
954
+ required: true,
955
+ initialValues: existing.quotaUi ?? ["sidebar"],
956
+ options: [
957
+ {
958
+ label: "Sidebar panel (TUI)",
959
+ value: "sidebar",
960
+ hint: "recommended; full Quota panel in the session sidebar",
961
+ },
962
+ { label: "Toast (TUI)", value: "toast", hint: "popup quota summaries" },
963
+ {
964
+ label: "Compact status line (TUI)",
965
+ value: "compact_status",
966
+ hint: "short quota summary below the message input",
967
+ },
968
+ {
969
+ label: "Manual commands only",
970
+ value: "none",
971
+ hint: "no automatic surfaces; slash commands remain available",
972
+ },
973
+ ],
974
+ });
975
+ if (prompts.isCancel(quotaUi))
976
+ return null;
977
+ if (!Array.isArray(quotaUi)) {
978
+ throw new InitInstallerError("Automatic quota surfaces require selected options.");
979
+ }
980
+ if (!(quotaUi.includes("none") && quotaUi.length > 1))
981
+ break;
982
+ prompts.log.error("Manual commands only cannot be combined with automatic surfaces.");
983
+ }
984
+ tuiCommandDisplay = await prompts.select({
985
+ message: "Where should slash commands (e.g. /quota) appear?",
986
+ initialValue: existing.tuiCommandDisplay ?? "inline",
987
+ options: [
988
+ {
989
+ label: "Inline with messages",
990
+ value: "inline",
991
+ hint: "persist output in the message transcript",
992
+ },
993
+ {
994
+ label: "Popup dialog",
995
+ value: "dialog",
996
+ hint: "show output in a temporary TUI popup",
997
+ },
998
+ ],
999
+ });
1000
+ if (prompts.isCancel(tuiCommandDisplay))
1001
+ return null;
673
1002
  }
674
1003
  const providerMode = await prompts.select({
675
- message: "Provider mode",
1004
+ message: "How should pre-configured providers be selected?",
1005
+ initialValue: existing.providerMode ?? "auto",
676
1006
  options: [
677
1007
  {
678
1008
  label: "Auto-detect providers",
679
1009
  value: "auto",
680
- hint: "recommended; use providers found in your OpenCode/auth setup",
1010
+ hint: "use providers found in your OpenCode configuration and authentication",
681
1011
  },
682
1012
  {
683
1013
  label: "Choose providers manually",
684
1014
  value: "manual",
685
- hint: "only track the providers you select",
1015
+ hint: "track only the pre-configured providers you select",
686
1016
  },
687
1017
  ],
688
1018
  });
@@ -691,8 +1021,9 @@ async function promptForSelections(prompts) {
691
1021
  let manualProviders = [];
692
1022
  if (providerMode === "manual") {
693
1023
  const selected = await prompts.multiselect({
694
- message: "Manual providers",
1024
+ message: "Which pre-configured providers should be tracked?",
695
1025
  required: true,
1026
+ initialValues: existing.manualProviders,
696
1027
  options: getInstallerProviderPromptOptions(),
697
1028
  });
698
1029
  if (prompts.isCancel(selected))
@@ -702,23 +1033,29 @@ async function promptForSelections(prompts) {
702
1033
  }
703
1034
  manualProviders = selected.filter((value) => typeof value === "string");
704
1035
  }
1036
+ prompts.log.info("Custom providers are configured after installation.");
1037
+ prompts.log.info("npx @slkiser/opencode-quota@latest provider add");
705
1038
  const formatStyle = await prompts.select({
706
1039
  message: "Quota reset periods",
1040
+ initialValue: existing.formatStyle ?? "allWindows",
707
1041
  options: [
708
1042
  {
709
- label: "All reset periods per provider (all windows; compare every tracked reset period)",
1043
+ label: "All reset periods",
710
1044
  value: "allWindows",
1045
+ hint: "show every available quota window",
711
1046
  },
712
1047
  {
713
- label: "One reset period per provider (single window; best for quick quota checks)",
1048
+ label: "One reset period (expiring soonest)",
714
1049
  value: "singleWindow",
1050
+ hint: "show only the next quota window to expire",
715
1051
  },
716
1052
  ],
717
1053
  });
718
1054
  if (prompts.isCancel(formatStyle))
719
1055
  return null;
720
1056
  const percentDisplayMode = await prompts.select({
721
- message: "Quota percentage meaning",
1057
+ message: "What should quota percentages show?",
1058
+ initialValue: existing.percentDisplayMode ?? "remaining",
722
1059
  options: [
723
1060
  { label: "Remaining quota", value: "remaining", hint: "show how much quota is left" },
724
1061
  { label: "Used quota", value: "used", hint: "show how much quota has been consumed" },
@@ -727,42 +1064,54 @@ async function promptForSelections(prompts) {
727
1064
  if (prompts.isCancel(percentDisplayMode))
728
1065
  return null;
729
1066
  const showSessionTokens = await prompts.select({
730
- message: "Session token details",
1067
+ message: "Session input/output tokens",
1068
+ initialValue: existing.showSessionTokens === true ? "yes" : "no",
731
1069
  options: [
732
- { label: "Hide session tokens", value: "no", hint: "keep quota output shorter" },
1070
+ { label: "Hide", value: "no", hint: "keep output shorter" },
733
1071
  {
734
- label: "Show session tokens",
1072
+ label: "Show",
735
1073
  value: "yes",
736
- hint: "include current session input/output token counts when available",
1074
+ hint: "include current-session input and output totals",
737
1075
  },
738
1076
  ],
739
1077
  });
740
1078
  if (prompts.isCancel(showSessionTokens))
741
1079
  return null;
742
- const maintainerAnnouncements = await prompts.confirm({
743
- message: "Show bundled maintainer notices automatically when available?",
744
- initialValue: true,
745
- });
746
- if (prompts.isCancel(maintainerAnnouncements))
747
- return null;
1080
+ if (interfaces !== "web") {
1081
+ maintainerAnnouncements = await prompts.confirm({
1082
+ message: "Show maintainer announcements on the TUI Home screen when available?",
1083
+ initialValue: existing.maintainerAnnouncements ?? true,
1084
+ });
1085
+ if (prompts.isCancel(maintainerAnnouncements))
1086
+ return null;
1087
+ }
748
1088
  return {
1089
+ interfaces: interfaces,
749
1090
  scope: scope,
750
- quotaUi: quotaUi.filter((value) => typeof value === "string"),
1091
+ quotaUi: Array.isArray(quotaUi)
1092
+ ? quotaUi.filter((value) => typeof value === "string")
1093
+ : ["none"],
751
1094
  providerMode: providerMode,
752
1095
  manualProviders,
753
1096
  formatStyle: formatStyle,
754
1097
  percentDisplayMode: percentDisplayMode,
755
1098
  showSessionTokens: showSessionTokens === "yes",
756
- maintainerAnnouncements: maintainerAnnouncements !== false,
1099
+ tuiCommandDisplay: interfaces === "web" ? undefined : tuiCommandDisplay,
1100
+ maintainerAnnouncements: interfaces === "web" ? undefined : maintainerAnnouncements !== false,
1101
+ configFormat: configFormat,
757
1102
  };
758
1103
  }
759
1104
  export async function runInitInstaller(params) {
760
1105
  const prompts = params?.prompts ?? (await import("@clack/prompts"));
761
1106
  prompts.intro("Configure @slkiser/opencode-quota");
762
1107
  try {
763
- const selections = await promptForSelections(prompts);
1108
+ const selections = await promptForSelections(prompts, {
1109
+ cwd: params?.cwd,
1110
+ env: params?.env,
1111
+ homeDir: params?.homeDir,
1112
+ });
764
1113
  if (!selections) {
765
- prompts.outro("Cancelled");
1114
+ prompts.outro("OpenCode Quota setup cancelled — no files changed.");
766
1115
  return 0;
767
1116
  }
768
1117
  const plan = await planInitInstaller({
@@ -776,7 +1125,11 @@ export async function runInitInstaller(params) {
776
1125
  prompts.log.info(line);
777
1126
  }
778
1127
  if (!plan.edits.some((edit) => edit.changed)) {
779
- prompts.outro(`No changes needed — ${GITHUB_STAR_NOTE}`);
1128
+ prompts.outro(`OpenCode Quota is already configured and current. No files changed. ${GITHUB_STAR_NOTE}`);
1129
+ return 0;
1130
+ }
1131
+ if (params?.dryRun) {
1132
+ prompts.outro("OpenCode Quota setup preview complete — no files changed. Run npx @slkiser/opencode-quota@latest init to apply.");
780
1133
  return 0;
781
1134
  }
782
1135
  const confirmed = await prompts.confirm({
@@ -784,7 +1137,7 @@ export async function runInitInstaller(params) {
784
1137
  initialValue: true,
785
1138
  });
786
1139
  if (prompts.isCancel(confirmed) || !confirmed) {
787
- prompts.outro("Cancelled");
1140
+ prompts.outro("OpenCode Quota setup cancelled — no files changed.");
788
1141
  return 0;
789
1142
  }
790
1143
  const result = await applyInitInstallerPlan(plan);
@@ -800,20 +1153,33 @@ export async function runInitInstaller(params) {
800
1153
  prompts.log.info(`- ${note.label}: README.md#${note.anchor}`);
801
1154
  }
802
1155
  }
803
- prompts.outro(`Quota init complete — ${GITHUB_STAR_NOTE}`);
1156
+ const interfaceLabel = plan.selections.interfaces === "tui"
1157
+ ? "TUI"
1158
+ : plan.selections.interfaces === "web"
1159
+ ? "Web"
1160
+ : "TUI and Web";
1161
+ const configuredPaths = [...result.writtenPaths, ...result.unchangedPaths];
1162
+ prompts.outro([
1163
+ "OpenCode Quota setup complete.",
1164
+ `Interfaces: ${interfaceLabel}`,
1165
+ "Configured paths:",
1166
+ ...configuredPaths.map((path) => `- ${path}`),
1167
+ "Restart OpenCode and run /quota.",
1168
+ `If OpenCode Quota helps, please consider a star: ${GITHUB_REPO_URL}`,
1169
+ ].join("\n"));
804
1170
  return 0;
805
1171
  }
806
1172
  catch (error) {
807
- if (error instanceof InitInstallerError) {
808
- prompts.log.error(error.message);
809
- if (error.details?.writtenPaths?.length) {
810
- prompts.log.info(`Already written: ${error.details.writtenPaths.join(", ")}`);
811
- }
1173
+ const reason = error instanceof Error ? error.message : String(error);
1174
+ prompts.log.error(reason);
1175
+ const writtenPaths = error instanceof InitInstallerError ? (error.details?.writtenPaths ?? []) : [];
1176
+ if (writtenPaths.length > 0) {
1177
+ prompts.log.info(`Files changed before failure: ${writtenPaths.join(", ")}`);
1178
+ prompts.outro("OpenCode Quota setup failed after some files changed. Review the listed files, fix the reason above, then rerun init.");
812
1179
  }
813
1180
  else {
814
- prompts.log.error(error instanceof Error ? error.message : String(error));
1181
+ prompts.outro("OpenCode Quota setup failed before any files changed. Fix the reason above, then rerun init.");
815
1182
  }
816
- prompts.outro("Quota init failed");
817
1183
  return 1;
818
1184
  }
819
1185
  }