@kolisachint/hoocode-agent 0.5.29 → 0.5.31

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 (74) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/core/agent-selection-eval.d.ts +91 -0
  3. package/dist/core/agent-selection-eval.d.ts.map +1 -0
  4. package/dist/core/agent-selection-eval.js +186 -0
  5. package/dist/core/agent-selection-eval.js.map +1 -0
  6. package/dist/core/builtin-skills.d.ts +61 -0
  7. package/dist/core/builtin-skills.d.ts.map +1 -0
  8. package/dist/core/builtin-skills.js +106 -0
  9. package/dist/core/builtin-skills.js.map +1 -0
  10. package/dist/core/extensions/plugins/default-marketplace/.agents-plugin/marketplace.json +5 -1
  11. package/dist/core/extensions/plugins/trigger-judge.d.ts +42 -0
  12. package/dist/core/extensions/plugins/trigger-judge.d.ts.map +1 -0
  13. package/dist/core/extensions/plugins/trigger-judge.js +121 -0
  14. package/dist/core/extensions/plugins/trigger-judge.js.map +1 -0
  15. package/dist/core/external-tools.d.ts +67 -0
  16. package/dist/core/external-tools.d.ts.map +1 -0
  17. package/dist/core/external-tools.js +120 -0
  18. package/dist/core/external-tools.js.map +1 -0
  19. package/dist/core/light.d.ts +9 -0
  20. package/dist/core/light.d.ts.map +1 -1
  21. package/dist/core/light.js +12 -4
  22. package/dist/core/light.js.map +1 -1
  23. package/dist/core/mode-prompts.d.ts +15 -3
  24. package/dist/core/mode-prompts.d.ts.map +1 -1
  25. package/dist/core/mode-prompts.js +17 -29
  26. package/dist/core/mode-prompts.js.map +1 -1
  27. package/dist/core/tools/propose-plugin.d.ts.map +1 -1
  28. package/dist/core/tools/propose-plugin.js +7 -7
  29. package/dist/core/tools/propose-plugin.js.map +1 -1
  30. package/dist/core/tools/subagent.d.ts +7 -1
  31. package/dist/core/tools/subagent.d.ts.map +1 -1
  32. package/dist/core/tools/subagent.js +12 -24
  33. package/dist/core/tools/subagent.js.map +1 -1
  34. package/dist/extensions/core/modes.d.ts.map +1 -1
  35. package/dist/extensions/core/modes.js +21 -23
  36. package/dist/extensions/core/modes.js.map +1 -1
  37. package/dist/extensions/core/scaffold.d.ts.map +1 -1
  38. package/dist/extensions/core/scaffold.js +28 -24
  39. package/dist/extensions/core/scaffold.js.map +1 -1
  40. package/dist/init-templates.generated.d.ts +3 -0
  41. package/dist/init-templates.generated.d.ts.map +1 -1
  42. package/dist/init-templates.generated.js +12 -0
  43. package/dist/init-templates.generated.js.map +1 -1
  44. package/dist/main.d.ts.map +1 -1
  45. package/dist/main.js +14 -1
  46. package/dist/main.js.map +1 -1
  47. package/dist/modes/interactive/components/settings-selector.d.ts +27 -0
  48. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  49. package/dist/modes/interactive/components/settings-selector.js +256 -45
  50. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  51. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  52. package/dist/modes/interactive/interactive-mode.js +26 -1
  53. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  54. package/dist/utils/tools-manager.d.ts +30 -0
  55. package/dist/utils/tools-manager.d.ts.map +1 -1
  56. package/dist/utils/tools-manager.js +35 -0
  57. package/dist/utils/tools-manager.js.map +1 -1
  58. package/docs/modes.md +4 -0
  59. package/docs/plugins.md +10 -0
  60. package/docs/settings.md +63 -0
  61. package/docs/skills.md +30 -0
  62. package/docs/usage.md +3 -3
  63. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  64. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  65. package/examples/extensions/sandbox/package.json +1 -1
  66. package/examples/extensions/with-deps/package.json +1 -1
  67. package/package.json +5 -4
  68. package/templates/prompts/grill-bridge.md +1 -0
  69. package/templates/prompts/grill-me.md +7 -0
  70. package/templates/prompts/grill-plan.md +9 -0
  71. package/templates/prompts/task-background-agents.md +2 -0
  72. package/templates/prompts/task-background-none.md +1 -0
  73. package/templates/prompts/task-main.md +19 -0
  74. package/templates/skills/plugin-authoring/SKILL.md +81 -0
@@ -1,4 +1,5 @@
1
1
  import { Container, getCapabilities, Input, SelectList, SettingsList, Spacer, Text, } from "@kolisachint/hoocode-tui";
2
+ import { buildRowGates, statusLabel } from "../../../core/external-tools.js";
2
3
  import { getSelectListTheme, getSettingsListTheme, getThemeDescription, theme } from "../theme/theme.js";
3
4
  import { DynamicBorder } from "./dynamic-border.js";
4
5
  import { keyDisplayText } from "./keybinding-hints.js";
@@ -164,6 +165,34 @@ class PlatformSubmenu extends Container {
164
165
  this.settingsList.handleInput(data);
165
166
  }
166
167
  }
168
+ /**
169
+ * Trailing sentence for a row whose setting is inert until an external binary
170
+ * shows up. The row is annotated rather than hidden: the binaries were invisible
171
+ * precisely because nothing ever mentioned them, and a row that disappears when
172
+ * its dependency is missing teaches the user nothing at all.
173
+ */
174
+ function gateNote(gate) {
175
+ if (!gate || gate.installed)
176
+ return "";
177
+ return gate.downloadable
178
+ ? ` Needs the ${gate.tool} binary, which hoocode fetches the first time this is used. Until then: ${gate.fallback}`
179
+ : ` Needs the ${gate.tool} binary, and this environment will not fetch it. Until then: ${gate.fallback}`;
180
+ }
181
+ /** "3 of 5 installed" - the one number that says whether this category needs attention. */
182
+ function externalCount(statuses) {
183
+ const installed = statuses.filter((status) => status.installed).length;
184
+ return `${installed} of ${statuses.length} installed`;
185
+ }
186
+ /** The capability words, so the category row says what the binaries are for. */
187
+ function externalSummary(statuses) {
188
+ return statuses.map((status) => status.tool).join(", ");
189
+ }
190
+ /** Value-column marker for a gated row, so the list shows it without selecting. */
191
+ function gateSuffix(gate) {
192
+ if (!gate || gate.installed)
193
+ return undefined;
194
+ return `needs ${gate.tool}`;
195
+ }
167
196
  /**
168
197
  * Submenu for tool availability. The first rows are group switches (web,
169
198
  * semantic search) that decide whether a group's tools
@@ -180,16 +209,26 @@ class ToolsSubmenu extends Container {
180
209
  enabled;
181
210
  static CORE = new Set(["read", "bash", "edit", "write"]);
182
211
  static GROUP_PREFIX = "group:";
183
- constructor(tools, groups, onChange, onGroupChange, onCancel) {
212
+ constructor(tools, groups,
213
+ /** Rows gated on an external binary, keyed by row id. See buildRowGates. */
214
+ gates, onChange, onGroupChange, onCancel) {
184
215
  super();
185
216
  this.enabled = new Map(tools.map((t) => [t.name, t.enabled]));
186
- const groupItems = groups.map((group) => ({
187
- id: `${ToolsSubmenu.GROUP_PREFIX}${group.id}`,
188
- label: `[group] ${group.label}`,
189
- description: `${group.description} Governs whether these tools exist; applies on the next session.`,
190
- currentValue: group.enabled ? "on" : "off",
191
- values: ["on", "off"],
192
- }));
217
+ const groupItems = groups.map((group) => {
218
+ const id = `${ToolsSubmenu.GROUP_PREFIX}${group.id}`;
219
+ const gate = gates.get(id);
220
+ return {
221
+ id,
222
+ label: `[group] ${group.label}`,
223
+ description: `${group.description} Governs whether these tools exist; applies on the next session.${gateNote(gate)}`,
224
+ currentValue: group.enabled ? "on" : "off",
225
+ // The switch stays settable with the binary missing: the setting is
226
+ // what makes hoocode fetch it, so refusing the toggle would make the
227
+ // group unreachable forever.
228
+ valueSuffix: gateSuffix(gate),
229
+ values: ["on", "off"],
230
+ };
231
+ });
193
232
  const toolItems = tools.map((tool) => ({
194
233
  id: tool.name,
195
234
  label: tool.name,
@@ -197,6 +236,10 @@ class ToolsSubmenu extends Container {
197
236
  ? "Core tool. Disabling leaves the agent unable to perform this action in every session."
198
237
  : "Disable to remove this tool from the agent this session and every future session.",
199
238
  currentValue: tool.enabled ? "on" : "off",
239
+ // What the schema costs on every request, whether the tool is on or off:
240
+ // off, it is the price of turning it back on. This is the number that
241
+ // makes a tool worth disabling, so it belongs beside the switch.
242
+ valueSuffix: tool.tokens !== undefined ? `${tokenCount(tool.tokens)} tok/turn` : undefined,
200
243
  values: ["on", "off"],
201
244
  }));
202
245
  const items = [...groupItems, ...toolItems];
@@ -223,6 +266,88 @@ class ToolsSubmenu extends Container {
223
266
  this.settingsList.handleInput(data);
224
267
  }
225
268
  }
269
+ /**
270
+ * Read-only detail for one external binary: what it is, whether it is here, what
271
+ * it turns on, and what hoocode does without it.
272
+ *
273
+ * A submenu rather than a long `description` because the useful answer is five
274
+ * or six lines and the pane shows descriptions on one. Nothing here is settable
275
+ * - the binary is either on the machine or it is not - so the rows carry no
276
+ * `values` and Enter does nothing but close.
277
+ */
278
+ class ExternalToolDetailSubmenu extends Container {
279
+ settingsList;
280
+ constructor(status, onCancel) {
281
+ super();
282
+ const acquisition = status.acquisition === "startup"
283
+ ? "Downloaded in the background at startup."
284
+ : status.acquisition === "on-demand"
285
+ ? "Downloaded the first time the feature is used."
286
+ : "Never downloaded automatically; install it yourself.";
287
+ const location = status.installed
288
+ ? status.source === "path"
289
+ ? `found on PATH as \`${status.path}\``
290
+ : status.source === "override"
291
+ ? `${status.overrideEnv} points at ${status.path}`
292
+ : `hoocode's own copy at ${status.path}`
293
+ : status.downloadable
294
+ ? `${acquisition} Not on this machine yet.`
295
+ : "Not installed, and this environment will not download it (offline mode, or no published build for this platform).";
296
+ const rows = [
297
+ ["Status", statusLabel(status), location],
298
+ [
299
+ "Enables",
300
+ `${status.enables.length} feature${status.enables.length === 1 ? "" : "s"}`,
301
+ status.enables.join("; "),
302
+ ],
303
+ ["Without it", "fallback", status.fallback],
304
+ ["Source", status.repo || "-", `Release archives come from github.com/${status.repo}. ${acquisition}`],
305
+ ["Env", String(status.env.length), status.env.join(" | ")],
306
+ ];
307
+ const items = rows.map(([label, value, description], index) => ({
308
+ id: `detail-${index}`,
309
+ label,
310
+ currentValue: value,
311
+ description,
312
+ }));
313
+ this.settingsList = new SettingsList(items, items.length, getSettingsListTheme(), () => { }, onCancel);
314
+ this.addChild(this.settingsList);
315
+ }
316
+ handleInput(data) {
317
+ this.settingsList.handleInput(data);
318
+ }
319
+ }
320
+ /**
321
+ * The external-binary category: one row per managed Rust binary.
322
+ *
323
+ * These binaries were entirely invisible before this pane existed. hoocode works
324
+ * without every one of them, which is the whole reason nobody found them: search
325
+ * silently got slower, semantic ranking silently never happened, and web/voice
326
+ * were simply features nobody knew were there. The row states presence; the
327
+ * submenu states what presence buys.
328
+ */
329
+ class ExternalToolsSubmenu extends Container {
330
+ settingsList;
331
+ constructor(statuses, onCancel) {
332
+ super();
333
+ const items = statuses.map((status) => ({
334
+ id: `ext-${status.tool}`,
335
+ label: status.label,
336
+ description: `${status.summary} Without it: ${status.fallback}`,
337
+ currentValue: statusLabel(status),
338
+ // The value column says whether it is here; the suffix says what it is
339
+ // for, so the list reads without opening every row.
340
+ valueSuffix: status.installed ? undefined : status.downloadable ? "auto-fetch" : "manual",
341
+ keywords: [status.tool, status.repo, ...status.settingsKeys, ...status.enables].join(" "),
342
+ submenu: (_currentValue, done) => new ExternalToolDetailSubmenu(status, () => done()),
343
+ }));
344
+ this.settingsList = new SettingsList(items, Math.min(items.length, 10), getSettingsListTheme(), () => { }, onCancel, { enableSearch: true });
345
+ this.addChild(this.settingsList);
346
+ }
347
+ handleInput(data) {
348
+ this.settingsList.handleInput(data);
349
+ }
350
+ }
226
351
  /** Byte-cap presets shown as human labels; mapped back to raw byte counts. */
227
352
  const TOOL_OUTPUT_BYTE_PRESETS = [
228
353
  ["8 KB", 8 * 1024],
@@ -243,39 +368,45 @@ function byteLabels(current) {
243
368
  return all.sort((a, b) => a[1] - b[1]).map(([label]) => label);
244
369
  }
245
370
  /**
246
- * Submenu for per-tool runtime settings. These feed the tool runtime the next
247
- * time it is built (next session / rebuild), so changes apply to future tool
248
- * calls rather than retroactively.
371
+ * Submenu for what a tool result looks like: how much of it is rendered, and
372
+ * where it is truncated. The display level applies to the transcript at once;
373
+ * the caps feed the tool runtime, so they bind on future tool calls.
374
+ *
375
+ * Context GC is deliberately not here. It is not about a tool's output but about
376
+ * what stays in the outgoing context, which is the Context category's subject.
249
377
  */
250
378
  class ToolSettingsSubmenu extends Container {
251
379
  settingsList;
252
380
  constructor(config, callbacks, onCancel) {
253
381
  super();
254
382
  const items = [
383
+ {
384
+ id: "tool-output-display",
385
+ label: "Display",
386
+ description: "How tool results render. 'standard': shown (expandable). 'collapsed': hidden. 'peek': hidden with a ▸ reveal caret (press the expand key to reveal).",
387
+ currentValue: config.toolOutputDisplay,
388
+ values: ["standard", "collapsed", "peek"],
389
+ },
255
390
  {
256
391
  id: "output-max-bytes",
257
- label: "Output max bytes",
392
+ label: "Max bytes",
258
393
  description: "Byte cap on a single read/bash result before truncation. Applies to future tool calls.",
259
394
  currentValue: bytesToLabel(config.toolOutputMaxBytes),
260
395
  values: byteLabels(config.toolOutputMaxBytes),
261
396
  },
262
397
  {
263
398
  id: "output-max-lines",
264
- label: "Output max lines",
399
+ label: "Max lines",
265
400
  description: "Line cap on a single read/bash result before truncation. Applies to future tool calls.",
266
401
  currentValue: String(config.toolOutputMaxLines),
267
402
  values: presetValues([200, 400, 800, 1600, 3200], config.toolOutputMaxLines),
268
403
  },
269
- {
270
- id: "context-gc",
271
- label: "Context GC",
272
- description: "Stub superseded read results (files later edited/re-read) out of the outgoing context.",
273
- currentValue: config.contextGc ? "true" : "false",
274
- values: ["true", "false"],
275
- },
276
404
  ];
277
405
  this.settingsList = new SettingsList(items, Math.min(items.length, 10), getSettingsListTheme(), (id, newValue) => {
278
406
  switch (id) {
407
+ case "tool-output-display":
408
+ callbacks.onToolOutputDisplayChange(newValue);
409
+ break;
279
410
  case "output-max-bytes": {
280
411
  const preset = TOOL_OUTPUT_BYTE_PRESETS.find(([label]) => label === newValue);
281
412
  // A hand-set cap has no preset entry; its label is "<n> KB" by
@@ -288,9 +419,6 @@ class ToolSettingsSubmenu extends Container {
288
419
  case "output-max-lines":
289
420
  callbacks.onToolOutputMaxLinesChange(parseInt(newValue, 10));
290
421
  break;
291
- case "context-gc":
292
- callbacks.onContextGcChange(newValue === "true");
293
- break;
294
422
  }
295
423
  }, onCancel);
296
424
  this.addChild(this.settingsList);
@@ -420,13 +548,41 @@ class SelectSubmenu extends Container {
420
548
  this.selectList.handleInput(data);
421
549
  }
422
550
  }
551
+ /**
552
+ * Token counts, grouped and exact.
553
+ *
554
+ * `formatTokens` (2.7k) exists for the fixed-width chrome, where a count must
555
+ * never grow the box it sits in. This pane is the opposite case: the point of
556
+ * showing a number here is to compare it with another one, and "2.7k" hides the
557
+ * difference between the tool that costs 2,710 and the one that costs 2,749.
558
+ */
559
+ function tokenCount(tokens) {
560
+ return tokens.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
561
+ }
562
+ /**
563
+ * The fixed per-turn cost, as one line under the pane.
564
+ *
565
+ * Every byte of system prompt and active tool schema is re-sent on every
566
+ * request, and the pane is where that number is decided - so the pane is where
567
+ * it should be visible. It reports the *live* session: a tool toggle applies at
568
+ * once and moves it, while a setting that only takes effect next session (a tool
569
+ * group, the light preset) leaves it alone until then.
570
+ */
571
+ function formatSurfaceLine(surface) {
572
+ const tools = `${surface.tools.length} tool${surface.tools.length === 1 ? "" : "s"}`;
573
+ return (` Per-turn surface: ${tokenCount(surface.totalTokens)} tokens ` +
574
+ `${theme.fg("dim", `(${tokenCount(surface.systemPromptTokens)} system prompt + ${tokenCount(surface.toolSchemaTokens)} schemas, ${tools})`)}`);
575
+ }
423
576
  /**
424
577
  * Main settings selector component.
425
578
  */
426
579
  export class SettingsSelectorComponent extends Container {
427
580
  settingsList;
581
+ surfaceLine;
582
+ measureTokenSurface;
428
583
  constructor(config, callbacks) {
429
584
  super();
585
+ this.measureTokenSurface = config.measureTokenSurface;
430
586
  const supportsImages = getCapabilities().images;
431
587
  const followUpKey = keyDisplayText("app.message.followUp");
432
588
  let currentWarnings = { ...config.warnings };
@@ -437,6 +593,10 @@ export class SettingsSelectorComponent extends Container {
437
593
  const pinnedNote = (key) => projectPinned.has(key)
438
594
  ? ` This repo's .hoocode/settings.json sets ${key}, which overrides this row from the next session on.`
439
595
  : "";
596
+ // Which rows are gated on an external binary that is not on this machine.
597
+ // Built once so every row that needs it reads the same answer.
598
+ const rowGates = buildRowGates(config.externalTools);
599
+ const initialSurface = config.measureTokenSurface?.();
440
600
  const toolsOn = config.tools.filter((t) => t.enabled).length;
441
601
  const toolsOff = config.tools.length - toolsOn;
442
602
  const items = [
@@ -591,8 +751,26 @@ export class SettingsSelectorComponent extends Container {
591
751
  currentValue: config.blockImages ? "true" : "false",
592
752
  values: ["true", "false"],
593
753
  });
594
- // Skill commands toggle (insert after block-images)
595
- const blockImagesIndex = items.findIndex((item) => item.id === "block-images");
754
+ // Context GC (insert after block-images), a leaf the Context category picks up.
755
+ items.splice(items.findIndex((item) => item.id === "block-images") + 1, 0, {
756
+ id: "context-gc",
757
+ label: "Context GC",
758
+ description: "Stub superseded read results (files later edited or re-read) out of the outgoing context.",
759
+ currentValue: config.contextGc ? "true" : "false",
760
+ values: ["true", "false"],
761
+ });
762
+ // The light preset (insert after context GC). Read at startup to pick the
763
+ // tool set and the system prompt, so it lands on the next session.
764
+ const blockImagesIdx = items.findIndex((item) => item.id === "context-gc");
765
+ items.splice(blockImagesIdx + 1, 0, {
766
+ id: "light",
767
+ label: "Light preset",
768
+ description: "Low-token preset for small or local models: read/write/edit/bash only with stripped schemas, a terse system prompt, and no subagents/TodoWrite/skills/context files. Applies on the next session.",
769
+ currentValue: config.light ? "true" : "false",
770
+ values: ["true", "false"],
771
+ });
772
+ // Skill commands toggle (insert after the light preset)
773
+ const blockImagesIndex = items.findIndex((item) => item.id === "light");
596
774
  items.splice(blockImagesIndex + 1, 0, {
597
775
  id: "skill-commands",
598
776
  label: "Skill commands",
@@ -694,8 +872,9 @@ export class SettingsSelectorComponent extends Container {
694
872
  items.splice(terminalProgressIndex + 1, 0, {
695
873
  id: "voice-silence-ms",
696
874
  label: "Voice silence window",
697
- description: "Trailing-silence (ms) before voice capture auto-stops (300-10000). Env: VOICETOOLS_SILENCE_MS.",
875
+ description: `Trailing-silence (ms) before voice capture auto-stops (300-10000). Env: VOICETOOLS_SILENCE_MS.${gateNote(rowGates.get("voice-silence-ms"))}`,
698
876
  currentValue: String(config.voiceSilenceMs),
877
+ valueSuffix: gateSuffix(rowGates.get("voice-silence-ms")),
699
878
  values: presetValues([300, 500, 800, 1200, 2000, 3000, 5000, 8000, 10000], config.voiceSilenceMs),
700
879
  });
701
880
  // Webtools request timeout (insert after voice-silence-ms)
@@ -703,8 +882,9 @@ export class SettingsSelectorComponent extends Container {
703
882
  items.splice(voiceSilenceIndex + 1, 0, {
704
883
  id: "webtools-timeout-secs",
705
884
  label: "Web tools timeout",
706
- description: "Per-request timeout (secs) for webfetch/websearch (1-120). Env: HOOCODE_WEBTOOLS_TIMEOUT.",
885
+ description: `Per-request timeout (secs) for webfetch/websearch (1-120). Env: HOOCODE_WEBTOOLS_TIMEOUT.${gateNote(rowGates.get("webtools-timeout-secs"))}`,
707
886
  currentValue: String(config.webtoolsTimeoutSecs),
887
+ valueSuffix: gateSuffix(rowGates.get("webtools-timeout-secs")),
708
888
  values: presetValues([5, 10, 15, 30, 60, 120], config.webtoolsTimeoutSecs),
709
889
  });
710
890
  // The /learn thresholds, appended as leaf rows and gathered into their own
@@ -724,30 +904,28 @@ export class SettingsSelectorComponent extends Container {
724
904
  {
725
905
  id: "tools",
726
906
  label: "Tools",
727
- description: "Enable/disable tools and tool groups (web, semantic search). Changes persist across sessions.",
907
+ description: "Enable/disable tools and tool groups (web, semantic search), each priced by what its schema costs per turn. Changes persist across sessions.",
728
908
  currentValue: toolsOff > 0 ? `${toolsOn} on · ${toolsOff} off` : `${toolsOn} on`,
729
- submenu: (_currentValue, done) => new ToolsSubmenu(config.tools, config.toolGroups, (name, enabled) => callbacks.onToolEnabledChange(name, enabled), (id, enabled) => callbacks.onToolGroupChange(id, enabled), () => done()),
909
+ valueSuffix: initialSurface ? `${tokenCount(initialSurface.toolSchemaTokens)} tok/turn` : undefined,
910
+ submenu: (_currentValue, done) => new ToolsSubmenu(config.tools, config.toolGroups, rowGates, (name, enabled) => {
911
+ callbacks.onToolEnabledChange(name, enabled);
912
+ // Applied live by the host, so the surface below is already stale.
913
+ this.refreshTokenSurface();
914
+ }, (id, enabled) => callbacks.onToolGroupChange(id, enabled), () => done()),
730
915
  },
731
916
  {
732
- id: "tool-output-display",
733
- label: "Tool output display",
734
- description: "How tool results render. 'standard': shown (expandable). 'collapsed': hidden. 'peek': hidden with a reveal caret (press the expand key to reveal).",
917
+ id: "tool-output",
918
+ label: "Tool output",
919
+ description: "How much of a tool result is rendered, and where it is truncated.",
735
920
  currentValue: config.toolOutputDisplay,
736
- values: ["standard", "collapsed", "peek"],
737
- },
738
- {
739
- id: "tool-settings",
740
- label: "Tool settings",
741
- description: "Per-tool runtime settings: output truncation caps and context garbage collection.",
742
- currentValue: "configure",
743
921
  submenu: (_currentValue, done) => new ToolSettingsSubmenu({
922
+ toolOutputDisplay: config.toolOutputDisplay,
744
923
  toolOutputMaxBytes: config.toolOutputMaxBytes,
745
924
  toolOutputMaxLines: config.toolOutputMaxLines,
746
- contextGc: config.contextGc,
747
925
  }, {
926
+ onToolOutputDisplayChange: callbacks.onToolOutputDisplayChange,
748
927
  onToolOutputMaxBytesChange: callbacks.onToolOutputMaxBytesChange,
749
928
  onToolOutputMaxLinesChange: callbacks.onToolOutputMaxLinesChange,
750
- onContextGcChange: callbacks.onContextGcChange,
751
929
  }, () => done()),
752
930
  },
753
931
  ];
@@ -769,9 +947,6 @@ export class SettingsSelectorComponent extends Container {
769
947
  case "autocompact":
770
948
  callbacks.onAutoCompactChange(newValue === "true");
771
949
  break;
772
- case "tool-output-display":
773
- callbacks.onToolOutputDisplayChange(newValue);
774
- break;
775
950
  case "show-images":
776
951
  callbacks.onShowImagesChange(newValue === "true");
777
952
  break;
@@ -787,6 +962,9 @@ export class SettingsSelectorComponent extends Container {
787
962
  case "skill-commands":
788
963
  callbacks.onEnableSkillCommandsChange(newValue === "true");
789
964
  break;
965
+ case "light":
966
+ callbacks.onLightChange(newValue === "true");
967
+ break;
790
968
  case "plugin-tools":
791
969
  callbacks.onEnablePluginToolsChange(newValue === "true");
792
970
  break;
@@ -855,6 +1033,10 @@ export class SettingsSelectorComponent extends Container {
855
1033
  callbacks.onLearnSettingChange(id, parseInt(newValue, 10));
856
1034
  break;
857
1035
  }
1036
+ // Most rows leave the per-turn surface alone; the ones that do not (a tool
1037
+ // toggle, anything that rebuilds the system prompt) move it immediately.
1038
+ // Re-measuring after every change is cheaper than knowing which is which.
1039
+ this.refreshTokenSurface();
858
1040
  };
859
1041
  // Partition the flat leaf settings into named category submenus so the
860
1042
  // top level stays short. `items` holds autocompact + every leaf setting.
@@ -876,8 +1058,12 @@ export class SettingsSelectorComponent extends Container {
876
1058
  };
877
1059
  };
878
1060
  const topItems = [
879
- ...(byId.has("autocompact") ? [byId.get("autocompact")] : []),
880
1061
  ...toolFlagGroup,
1062
+ // What the model is sent, and how much of it. Auto-compact used to sit
1063
+ // alone at the top level and context GC was filed under tool settings,
1064
+ // which left the three settings that decide the token budget in three
1065
+ // different places - with the light preset in none of them.
1066
+ categoryRow("cat-context", "Context", "What the model is sent and how much of it: compaction, superseded reads, and the low-token preset.", ["autocompact", "context-gc", "light"]),
881
1067
  categoryRow("cat-behavior", "Behavior", "Agent and session behavior: steering, follow-up, thinking, escape, tree filter, transport.", ["steering-mode", "follow-up-mode", "thinking", "double-escape-action", "tree-filter-mode", "transport"]),
882
1068
  categoryRow("cat-interface", "Interface", "Appearance and editor: theme, thinking visibility, cursor, border, padding, autocomplete, terminal.", [
883
1069
  "theme",
@@ -902,6 +1088,21 @@ export class SettingsSelectorComponent extends Container {
902
1088
  // that decide whether /learn finds anything, and burying them is what
903
1089
  // made them undiscoverable in the first place.
904
1090
  categoryRow("cat-learn", "Learning", "Thresholds /learn mines sessions with: how far back to look, and how often something must repeat.", LEARN_SETTINGS.map((setting) => setting.key)),
1091
+ // The external binaries. Top level, not folded into Advanced: their whole
1092
+ // problem was that nothing in the product ever said they existed, and a
1093
+ // row two levels down would have been the same silence with extra steps.
1094
+ // This is the only category built from live machine state rather than
1095
+ // settings, so it is assembled here instead of going through categoryRow.
1096
+ {
1097
+ id: "cat-external",
1098
+ label: "External tools",
1099
+ description: `Optional Rust binaries that expand what hoocode can do: ${externalSummary(config.externalTools)}. hoocode runs without every one of them - each row says what it adds and what happens without it.`,
1100
+ currentValue: externalCount(config.externalTools),
1101
+ keywords: config.externalTools
1102
+ .flatMap((status) => [status.tool, status.label, ...status.settingsKeys])
1103
+ .join(" "),
1104
+ submenu: (_currentValue, done) => new ExternalToolsSubmenu(config.externalTools, () => done()),
1105
+ },
905
1106
  categoryRow("cat-advanced", "Advanced", "Startup, telemetry, skills, warnings, voice, and web tools.", [
906
1107
  "quiet-startup",
907
1108
  "collapse-changelog",
@@ -916,8 +1117,18 @@ export class SettingsSelectorComponent extends Container {
916
1117
  enableSearch: true,
917
1118
  });
918
1119
  this.addChild(this.settingsList);
1120
+ if (initialSurface) {
1121
+ this.surfaceLine = new Text(formatSurfaceLine(initialSurface), 0, 0);
1122
+ this.addChild(this.surfaceLine);
1123
+ }
919
1124
  this.addChild(new DynamicBorder());
920
1125
  }
1126
+ /** Re-price the pane after a change that may have altered what each turn sends. */
1127
+ refreshTokenSurface() {
1128
+ if (!this.surfaceLine || !this.measureTokenSurface)
1129
+ return;
1130
+ this.surfaceLine.setText(formatSurfaceLine(this.measureTokenSurface()));
1131
+ }
921
1132
  getSettingsList() {
922
1133
  return this.settingsList;
923
1134
  }