@north-light/crouter 0.3.159 → 0.3.160

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 (70) hide show
  1. package/dist/api/dto/nodes.d.ts +3 -0
  2. package/dist/builtin-memory/internal/INDEX.md +1 -0
  3. package/dist/builtin-memory/internal/workflow-codification.md +82 -0
  4. package/dist/clients/attach/__tests__/bash-call-width.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/bash-call-width.test.js +38 -0
  6. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +20 -0
  7. package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +3 -1
  8. package/dist/clients/attach/chrome/bash-jobs.d.ts +5 -3
  9. package/dist/clients/attach/chrome/bash-jobs.js +15 -8
  10. package/dist/clients/attach/chrome/canvas-panels.js +10 -3
  11. package/dist/clients/attach/chrome/roster.d.ts +7 -2
  12. package/dist/clients/attach/chrome/roster.js +87 -3
  13. package/dist/clients/attach/input/controller.d.ts +3 -0
  14. package/dist/clients/attach/input/controller.js +1 -0
  15. package/dist/clients/attach/render/assistant-message.d.ts +13 -0
  16. package/dist/clients/attach/render/assistant-message.js +55 -0
  17. package/dist/clients/attach/render/chat-view.d.ts +29 -0
  18. package/dist/clients/attach/render/chat-view.js +233 -12
  19. package/dist/clients/attach/render/context-message.js +1 -1
  20. package/dist/clients/attach/render/edit-diff.js +20 -1
  21. package/dist/clients/attach/render/tool-calls.d.ts +32 -0
  22. package/dist/clients/attach/render/tool-calls.js +251 -0
  23. package/dist/clients/attach/session/input-wiring.d.ts +3 -0
  24. package/dist/clients/attach/session/input-wiring.js +3 -0
  25. package/dist/clients/attach/slash/dispatch.d.ts +4 -0
  26. package/dist/clients/attach/slash/dispatch.js +8 -0
  27. package/dist/clients/attach/viewer.js +512 -508
  28. package/dist/commands/cron.js +2 -2
  29. package/dist/commands/node.js +3 -3
  30. package/dist/commands/search.js +1 -1
  31. package/dist/commands/sys/config.js +10 -2
  32. package/dist/commands/sys/doctor.js +22 -1
  33. package/dist/commands/sys/setup-core.d.ts +5 -1
  34. package/dist/commands/sys/setup-core.js +16 -2
  35. package/dist/commands/sys/setup-wizard.d.ts +11 -0
  36. package/dist/commands/sys/setup-wizard.js +111 -6
  37. package/dist/core/__tests__/tmux-surface.test.js +5 -2
  38. package/dist/core/canvas/canvas.js +1 -1
  39. package/dist/core/canvas/labels.d.ts +1 -0
  40. package/dist/core/canvas/labels.js +3 -1
  41. package/dist/core/canvas/nav-render.d.ts +6 -0
  42. package/dist/core/canvas/nav-render.js +8 -0
  43. package/dist/core/canvas/types.d.ts +6 -0
  44. package/dist/core/config.js +3 -2
  45. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  46. package/dist/core/keybindings/catalog.d.ts +2 -2
  47. package/dist/core/keybindings/catalog.js +2 -0
  48. package/dist/core/preview-registry.d.ts +5 -0
  49. package/dist/core/preview-registry.js +10 -0
  50. package/dist/core/runtime/canvas-extensions.d.ts +4 -0
  51. package/dist/core/runtime/canvas-extensions.js +4 -0
  52. package/dist/core/runtime/naming-persist.d.ts +4 -3
  53. package/dist/core/runtime/naming-persist.js +10 -9
  54. package/dist/core/runtime/naming.d.ts +28 -8
  55. package/dist/core/runtime/naming.js +168 -32
  56. package/dist/core/runtime/nerd-font.d.ts +28 -0
  57. package/dist/core/runtime/nerd-font.js +127 -0
  58. package/dist/core/runtime/tmux.js +16 -0
  59. package/dist/daemon/api/map.js +2 -0
  60. package/dist/pi-extensions/naming-tool.d.ts +24 -0
  61. package/dist/pi-extensions/naming-tool.js +67 -0
  62. package/dist/types.d.ts +5 -0
  63. package/dist/types.js +1 -0
  64. package/dist/web-client/assets/index-B76ZKfT_.js +79 -0
  65. package/dist/web-client/assets/{index-CpEl9LTS.css → index-CqLKj8Xu.css} +1 -1
  66. package/dist/web-client/index.html +2 -2
  67. package/dist/web-client/sw.js +1 -1
  68. package/package.json +1 -1
  69. package/runtime.lock.json +2 -2
  70. package/dist/web-client/assets/index-CsuwzlcQ.js +0 -79
@@ -138,7 +138,7 @@ const addLeaf = defineLeaf({
138
138
  { kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA zone (e.g. "America/New_York") for a bare-ISO --at or a calendar --every. Defaults to the host zone.' },
139
139
  { kind: 'flag', name: 'on-output', type: 'enum', choices: ['silent', 'on-failure', 'always', 'on-change'], required: false, default: 'on-failure', constraint: 'What each run does with its output. silent: record in the run log, tell nobody — never escalate. on-failure (default): no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run\'s — the shape polling wants.' },
140
140
  { kind: 'flag', name: 'sink', type: 'string', required: false, constraint: 'Where always/on-change stdout deliveries go: "node:<id>" (an existing node\'s inbox — must exist now; gone/finalized at fire time is a failure), "spawn:<kind>" (each delivered output creates a self-finishing node with stdout as kickoff — a managed child while its creator exists, otherwise a terminal parentless root), or "human" (the humanloop inbox). Required by always/on-change, rejected otherwise.' },
141
- { kind: 'flag', name: 'tier', type: 'enum', choices: ['normal', 'urgent', 'critical'], required: false, default: 'normal', constraint: 'Inbox priority of a node-sink delivery.' },
141
+ { kind: 'flag', name: 'tier', type: 'enum', choices: ['normal', 'urgent', 'critical'], required: false, default: 'urgent', constraint: 'Inbox priority of a node-sink delivery. urgent (default): steers the target mid-turn, or wakes it when dormant — a fire is news the node asked for on a schedule, so it should land now. normal: waits for the running turn to settle. critical: aborts the running turn outright.' },
142
142
  { kind: 'flag', name: 'expires', type: 'string', required: false, constraint: 'Clock bound (same grammar as --at): the row is deleted once this instant passes. The self-limiting half of a poll-until pattern.' },
143
143
  { kind: 'flag', name: 'anchor', type: 'string', required: false, constraint: 'Couple the cron\'s lifetime to a node: the row is deleted when that node is deleted. Without an anchor it outlives every node; it ends through its one-shot run, --expires, explicit cancellation, or self-cancellation from its bash.' },
144
144
  { kind: 'flag', name: 'anchor-self', type: 'bool', required: false, constraint: 'Anchor to the calling node ($CRTR_NODE_ID). Errors outside a node.' },
@@ -259,7 +259,7 @@ const addLeaf = defineLeaf({
259
259
  scope,
260
260
  on_output: input['onOutput'] ?? 'on-failure',
261
261
  sink: typeof input['sink'] === 'string' && input['sink'] !== '' ? input['sink'] : null,
262
- tier: input['tier'] ?? 'normal',
262
+ tier: input['tier'] ?? 'urgent',
263
263
  expires_at: expiresAt,
264
264
  anchor_node: anchorNode,
265
265
  cancel_on_wake: cancelOnWake,
@@ -912,7 +912,7 @@ function stopJobGroup(pgid) {
912
912
  const nodeBashKill = defineLeaf({
913
913
  name: 'kill',
914
914
  description: 'stop one of a node’s background bash jobs',
915
- whenToUse: 'a human watching the Inspector wants a long-running background job to stop now, or an agent is abandoning work it backgrounded',
915
+ whenToUse: 'a human watching the Inspector or the attach cockpit’s bash row wants a long-running background job to stop now, or an agent is abandoning work it backgrounded',
916
916
  tier: 'hidden',
917
917
  help: {
918
918
  name: 'node bash kill',
@@ -930,7 +930,7 @@ const nodeBashKill = defineLeaf({
930
930
  outputKind: 'object',
931
931
  effects: [
932
932
  'Sends SIGTERM (then SIGKILL) to the job’s whole process group, stopping the command and anything it forked.',
933
- 'Records the job’s exit so it stops showing as live, and sends the owning node an urgent inbox message saying the job was canceled — which steers that node mid-turn.',
933
+ 'Records the job’s exit so it stops showing as live, and sends the owning node an urgent inbox message saying the job was stopped — which steers that node mid-turn.',
934
934
  ],
935
935
  },
936
936
  run: async (input) => {
@@ -973,7 +973,7 @@ const nodeBashKill = defineLeaf({
973
973
  if (!existsSync(paths.jobExit))
974
974
  writeFileSync(paths.jobExit, '143\n');
975
975
  await cliClient().sendMessage(node.node_id, {
976
- body: `Background bash job ${jobId} was canceled from the Inspector before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`,
976
+ body: `Background bash job ${jobId} was stopped by the human before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`,
977
977
  tier: 'urgent',
978
978
  });
979
979
  return { node_id: node.node_id, job_id: jobId, signaled };
@@ -12,7 +12,7 @@ export function registerSearch() {
12
12
  rootEntry: {
13
13
  concept: 'web search for agents — find pages, get grounded answers, extract page content (Exa)',
14
14
  desc: 'search the web, answer a question with citations, or extract content from known URLs',
15
- useWhen: 'you need information from the live web — current events, documentation, sources, facts beyond your training. Use web for relevant pages with excerpts, answer for one synthesized cited answer, and contents for clean text from URLs you already hold. Needs an Exa API key (EXA_API_KEY or ~/.crouter/exa.key).',
15
+ useWhen: 'you need information from the live web — current events, documentation, sources, facts beyond your training. Use web for relevant pages with excerpts, answer for one synthesized cited answer, and contents for clean text from URLs you already hold. This reaches PUBLIC pages read-only: for anything behind the user’s login, or any action inside a web app, drive their own browser with `crtr device`, and to call a service’s API server-side use `crtr connector`. Needs an Exa API key (EXA_API_KEY or ~/.crouter/exa.key).',
16
16
  },
17
17
  help: {
18
18
  name: 'search',
@@ -16,6 +16,7 @@ const TOP_LEVEL_KEYS = new Set([
16
16
  'completion_bell',
17
17
  'live_cycles',
18
18
  'condensed_history',
19
+ 'fold_finished_tools',
19
20
  'modelLadders',
20
21
  ]);
21
22
  const MODEL_PROVIDERS = new Set(['anthropic', 'openai']);
@@ -153,6 +154,13 @@ function setNestedValue(cfg, key, rawValue, value) {
153
154
  cfg.condensed_history = rawValue;
154
155
  return;
155
156
  }
157
+ if (key === 'fold_finished_tools') {
158
+ if (typeof value !== 'boolean') {
159
+ throw usage(`fold_finished_tools must be true or false`);
160
+ }
161
+ cfg.fold_finished_tools = value;
162
+ return;
163
+ }
156
164
  if (parts.length === 1) {
157
165
  cfg[topKey] = value;
158
166
  return;
@@ -175,7 +183,7 @@ const configGet = defineLeaf({
175
183
  name: 'sys config get',
176
184
  summary: 'read a config value by dotted key',
177
185
  params: [
178
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window, brokerThresholds (user only; warning and automaticReviveCap), completion_bell, live_cycles, condensed_history, modelLadders, or the whole keybindings object. Keybindings are edited only through sys setup.' },
186
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window, brokerThresholds (user only; warning and automaticReviveCap), completion_bell, live_cycles, condensed_history, fold_finished_tools, modelLadders, or the whole keybindings object. Keybindings are edited only through sys setup.' },
179
187
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to read from. Default: user.' },
180
188
  ],
181
189
  output: [
@@ -209,7 +217,7 @@ const configSet = defineLeaf({
209
217
  name: 'sys config set',
210
218
  summary: 'write a config value by dotted key',
211
219
  params: [
212
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window, brokerThresholds.warning, brokerThresholds.automaticReviveCap (user scope; positive integers), completion_bell, live_cycles (integer >= 1 — trailing cycles the attach viewer renders at full fidelity; older cycles condense to prose), condensed_history (none|user|agent|both — which messages of a condensed cycle still render; none keeps only the cycle dividers), modelLadders.defaultProvider, modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. Keybindings are edited only through sys setup.' },
220
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window, brokerThresholds.warning, brokerThresholds.automaticReviveCap (user scope; positive integers), completion_bell, live_cycles (integer >= 1 — trailing cycles the attach viewer renders at full fidelity; older cycles condense to prose), condensed_history (none|user|agent|both — which messages of a condensed cycle still render; none keeps only the cycle dividers), fold_finished_tools (true|false — fold every settled tool call in the attach viewer to its single call line), modelLadders.defaultProvider, modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. Keybindings are edited only through sys setup.' },
213
221
  { kind: 'flag', name: 'value', type: 'string', required: true, constraint: 'value VALUE — string, required. Stored as-is if quoted; coerced to number or boolean when unambiguous.' },
214
222
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to write to. Default: user.' },
215
223
  ],
@@ -10,6 +10,8 @@ import { lsRemote } from '../../core/git.js';
10
10
  import { isPidAlive } from '../../core/canvas/pid.js';
11
11
  import { cliClient } from '../api-client.js';
12
12
  import { readFault } from '../../core/runtime/fault.js';
13
+ import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '../../core/runtime/nerd-font.js';
14
+ import { detectPackageManager } from './setup-core.js';
13
15
  import { validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
14
16
  import { buildExternalCommandSnapshot } from '../../core/configured-clis/discovery.js';
15
17
  import { effectiveConfiguredClis } from '../../core/configured-clis/registration.js';
@@ -277,6 +279,23 @@ async function verifySealed(root) {
277
279
  }
278
280
  await walk(root);
279
281
  }
282
+ /** The viewer draws every tool and command icon with Nerd Font codepoints, so a
283
+ * machine with no Nerd Font installed renders tofu boxes. Nothing else in
284
+ * crtr notices — this is the one place a fresh install learns why. Never
285
+ * auto-fixed: installing a font, and pointing the terminal profile at it, is a
286
+ * human action. */
287
+ function runNerdFontCheck() {
288
+ const detection = detectNerdFont();
289
+ if (detection.installed) {
290
+ return pass('user', 'terminal:nerd-font', `installed (${detection.evidence}) — if icons still render as boxes, ${NERD_FONT_SELECT_HINT}`);
291
+ }
292
+ const plan = nerdFontInstallCommand(detectPackageManager());
293
+ return failCheck('user', 'terminal:nerd-font', 'no Nerd Font installed — viewer tool/command icons will render as tofu boxes', {
294
+ kind: 'human_action',
295
+ description: `Install a Nerd Font (${plan.hint}), ${NERD_FONT_SELECT_HINT}`,
296
+ ...(plan.command ? { command: plan.command } : {}),
297
+ });
298
+ }
280
299
  async function generationChecks(label, lookup, allowAbsent) {
281
300
  if (lookup.generation === null) {
282
301
  const message = lookup.error ?? 'no runtime generation';
@@ -486,7 +505,7 @@ export const sysDoctorLeaf = defineLeaf({
486
505
  { kind: 'flag', name: 'remote', type: 'bool', required: false, constraint: 'Check git remotes with ls-remote (slow — makes network calls).' },
487
506
  ],
488
507
  output: [
489
- { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. Command-plugin manifest checks appear as plugin:<name>:commands (validated statically, never executed); their remediation is always a human_action (disable/update/remove the plugin). remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
508
+ { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. terminal:nerd-font (user scope) reports whether a Nerd Font is installed for the viewer\'s icon glyphs; its remediation is always a human_action carrying the platform install command, never auto-fixed. Command-plugin manifest checks appear as plugin:<name>:commands (validated statically, never executed); their remediation is always a human_action (disable/update/remove the plugin). remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
490
509
  { name: 'ok', type: 'boolean', required: true, constraint: 'True when no unresolved fail checks remain.' },
491
510
  ],
492
511
  outputKind: 'object',
@@ -506,6 +525,8 @@ export const sysDoctorLeaf = defineLeaf({
506
525
  const results = runChecksForScope(scope, { fix, remote });
507
526
  allResults.push(...results);
508
527
  }
528
+ if (scopes.includes('user'))
529
+ allResults.push(runNerdFontCheck());
509
530
  allResults.push(...runCommandPluginChecks(scopes));
510
531
  allResults.push(...runConfiguredCliChecks(scopes));
511
532
  allResults.push(...await runFaultChecks());
@@ -1,6 +1,10 @@
1
1
  import { type BindingId, type BindingResolution, type KeybindingDiagnostic, type SparseKeybindingOverrides } from '../../core/keybindings/index.js';
2
2
  export type SetupPackageId = 'crtr-extensions' | 'mode-switch' | 'oauth-adapter' | 'capture-plugin';
3
- export type SetupTool = 'tmux' | 'nvim';
3
+ /** `nerd-font` is not a command on PATH like the other two — it is an installed
4
+ * font face, detected by fontconfig/font-directory scan. The viewer renders
5
+ * every tool and command icon with Nerd Font codepoints, so without it a fresh
6
+ * machine shows tofu boxes. */
7
+ export type SetupTool = 'tmux' | 'nvim' | 'nerd-font';
4
8
  /** `marketplace-plugin` installs a crtr plugin from a registered marketplace
5
9
  * (`installSource` is a `<marketplace>/<plugin>` ref) instead of running the
6
10
  * bundled pi CLI. Offered in setup and NOT installed by first-run bootstrap:
@@ -9,6 +9,7 @@ import { builtinPiPackageDir } from '../../core/scope.js';
9
9
  import { readJsonIfExists } from '../../core/fs-utils.js';
10
10
  import { resolveBundledPiCliPath } from '../../core/runtime/pi-cli.js';
11
11
  import { installTmuxBindings } from '../../core/runtime/tmux-chrome.js';
12
+ import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '../../core/runtime/nerd-font.js';
12
13
  import { globalHumanloopHealth, globalHumanloopInstallCommand } from '../../core/runtime/package-health.js';
13
14
  import { registerCrouterRoot } from '../human/shared.js';
14
15
  import { installFromMarketplace } from '../pkg/plugin-manage.js';
@@ -301,6 +302,19 @@ export function buildSystemInstallPlan(tool, packageManager, installed) {
301
302
  autoInstall: false,
302
303
  };
303
304
  }
305
+ if (tool === 'nerd-font') {
306
+ const plan = nerdFontInstallCommand(packageManager);
307
+ return {
308
+ tool,
309
+ label,
310
+ installed: false,
311
+ checked: plan.autoInstall,
312
+ packageManager,
313
+ command: plan.command,
314
+ hint: `${plan.hint} — ${NERD_FONT_SELECT_HINT}`,
315
+ autoInstall: plan.autoInstall,
316
+ };
317
+ }
304
318
  const packageName = tool === 'nvim' ? 'neovim' : 'tmux';
305
319
  switch (packageManager) {
306
320
  case 'brew':
@@ -361,7 +375,7 @@ export function buildSystemInstallPlan(tool, packageManager, installed) {
361
375
  }
362
376
  }
363
377
  export function buildSystemToolState(tool, packageManager, commandExists = detectCommandOnPath) {
364
- const installed = commandExists(tool);
378
+ const installed = tool === 'nerd-font' ? detectNerdFont().installed : commandExists(tool);
365
379
  return {
366
380
  tool,
367
381
  installed,
@@ -397,7 +411,7 @@ export function buildSetupInitialState(opts = {}) {
397
411
  installedPackageIds.add(id);
398
412
  const commandExists = opts.commandExists ?? detectCommandOnPath;
399
413
  const packageManager = opts.packageManager ?? detectPackageManager(process.platform, commandExists);
400
- const systemTools = ['tmux', 'nvim'].map((tool) => buildSystemToolState(tool, packageManager, commandExists));
414
+ const systemTools = ['tmux', 'nvim', 'nerd-font'].map((tool) => buildSystemToolState(tool, packageManager, commandExists));
401
415
  return { manifest, installedPackageIds, systemTools, exaKeyPath: resolveExaKeyPath(homeDir) };
402
416
  }
403
417
  export function buildSetupStaticInstructions(opts) {
@@ -62,6 +62,8 @@ export declare class SetupWizard implements Component, Focusable {
62
62
  private keyNotice;
63
63
  private brokerLimitIndex;
64
64
  private brokerLimitNotice;
65
+ private displayIndex;
66
+ private displayNotice;
65
67
  private cachedWidth;
66
68
  private cachedHeight;
67
69
  private cachedLines;
@@ -74,6 +76,14 @@ export declare class SetupWizard implements Component, Focusable {
74
76
  invalidate(): void;
75
77
  private setTab;
76
78
  private moveTab;
79
+ private moveDisplayRow;
80
+ /** Advance the selected display setting to its next value and persist it to user
81
+ * config immediately — these are one-keystroke view preferences, so there is no
82
+ * separate save gesture to forget. A viewer picks the new value up on its next
83
+ * attach (`live_cycles` / `condensed_history` / `fold_finished_tools` are all read
84
+ * per ChatView), and `completion_bell` on the next turn end. */
85
+ private cycleDisplaySetting;
86
+ private handleDisplayInput;
77
87
  private cancel;
78
88
  dispose(): void;
79
89
  private matches;
@@ -124,6 +134,7 @@ export declare class SetupWizard implements Component, Focusable {
124
134
  private renderKeybindings;
125
135
  private renderModels;
126
136
  private renderBrokerLimits;
137
+ private renderDisplay;
127
138
  render(width: number): string[];
128
139
  }
129
140
  export declare function runSetup(): Promise<void>;
@@ -13,6 +13,7 @@ import { removeProviderRow } from '../../core/provider-management.js';
13
13
  import { readConfig, readRawConfigAtRoot, readRawScopeConfig, updateRawConfigAtomically, persistDefaultKindModel, persistModelLadders, readMergedLaunchConfig } from '../../core/config.js';
14
14
  import { findProjectScopeRoots, userScopeRoot } from '../../core/scope.js';
15
15
  import { loadProfileManifest, profileRoot } from '../../core/profiles/manifest.js';
16
+ import { CONDENSED_HISTORY_MODES } from '../../types.js';
16
17
  import { BINDING_CATALOG, formatBinding, formatGesture, matchesPiTuiInput, persistUserKeybindings, resolveKeybindings, KeybindingConcurrentEditError, KeybindingValidationError, } from '../../core/keybindings/index.js';
17
18
  import { configuredLadderProviders, inferPortableModelSelection } from '../../core/runtime/model-selection.js';
18
19
  import { cliClient } from '../api-client.js';
@@ -79,7 +80,11 @@ function currentWidth() {
79
80
  return Math.max(1, process.stdout.columns ?? 80);
80
81
  }
81
82
  function toolLabel(tool) {
82
- return tool === 'nvim' ? 'nvim' : 'tmux';
83
+ if (tool === 'nvim')
84
+ return 'nvim';
85
+ if (tool === 'nerd-font')
86
+ return 'Nerd Font';
87
+ return 'tmux';
83
88
  }
84
89
  function positiveInteger(value) {
85
90
  if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1)
@@ -652,6 +657,45 @@ class ProviderPanel {
652
657
  return useFrame ? framed(body, width) : body.map((line) => truncateToWidth(line, width));
653
658
  }
654
659
  }
660
+ const TAB_LABELS = ['Providers', 'Plugins', 'System', 'Exa', 'Keybindings', 'Models', 'Broker limits', 'Display'];
661
+ const DISPLAY_TAB = 7;
662
+ /** The viewer display settings the Display tab edits, in row order. Each row owns
663
+ * its own value cycling and its own formatting, so adding a setting here is the
664
+ * whole change — the tab's input and render loops stay generic. Every one is a
665
+ * user-scope `ScopeConfig` key persisted the moment it changes. */
666
+ const DISPLAY_ROWS = [
667
+ {
668
+ key: 'fold_finished_tools',
669
+ label: 'Fold finished tools',
670
+ description: 'Fold every settled tool call to its call line (path included). Toggle live with /fold-tools.',
671
+ format: (cfg) => (cfg.fold_finished_tools ? 'on' : 'off'),
672
+ next: (cfg) => ({ fold_finished_tools: !cfg.fold_finished_tools }),
673
+ },
674
+ {
675
+ key: 'completion_bell',
676
+ label: 'Completion bell',
677
+ description: 'Ring a short sound in the viewer when the node you are watching finishes a turn.',
678
+ format: (cfg) => (cfg.completion_bell ? 'on' : 'off'),
679
+ next: (cfg) => ({ completion_bell: !cfg.completion_bell }),
680
+ },
681
+ {
682
+ key: 'live_cycles',
683
+ label: 'Live cycles',
684
+ description: 'Trailing refresh cycles rendered at full fidelity; older cycles condense to text. Default 2.',
685
+ format: (cfg) => String(cfg.live_cycles),
686
+ next: (cfg, delta) => ({ live_cycles: ((cfg.live_cycles - 1 + delta + 6) % 6) + 1 }),
687
+ },
688
+ {
689
+ key: 'condensed_history',
690
+ label: 'Condensed history',
691
+ description: 'Which messages of a condensed cycle still render. Tool calls are never kept.',
692
+ format: (cfg) => cfg.condensed_history,
693
+ next: (cfg, delta) => ({
694
+ condensed_history: CONDENSED_HISTORY_MODES[(CONDENSED_HISTORY_MODES.indexOf(cfg.condensed_history) + delta + CONDENSED_HISTORY_MODES.length)
695
+ % CONDENSED_HISTORY_MODES.length],
696
+ }),
697
+ },
698
+ ];
655
699
  const STRENGTHS = ['ultra', 'strong', 'medium', 'light'];
656
700
  const LADDER_PROVIDER_KEYS = ['anthropic', 'openai'];
657
701
  const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
@@ -1265,6 +1309,8 @@ export class SetupWizard {
1265
1309
  keyNotice;
1266
1310
  brokerLimitIndex = 0;
1267
1311
  brokerLimitNotice;
1312
+ displayIndex = 0;
1313
+ displayNotice;
1268
1314
  cachedWidth;
1269
1315
  cachedHeight;
1270
1316
  cachedLines;
@@ -1336,9 +1382,39 @@ export class SetupWizard {
1336
1382
  this.invalidate();
1337
1383
  }
1338
1384
  moveTab(delta) {
1339
- const next = ((this.activeTab + delta + 7) % 7);
1385
+ const next = ((this.activeTab + delta + TAB_LABELS.length) % TAB_LABELS.length);
1340
1386
  this.setTab(next);
1341
1387
  }
1388
+ moveDisplayRow(delta) {
1389
+ this.displayIndex = (this.displayIndex + delta + DISPLAY_ROWS.length) % DISPLAY_ROWS.length;
1390
+ this.displayNotice = undefined;
1391
+ this.invalidate();
1392
+ }
1393
+ /** Advance the selected display setting to its next value and persist it to user
1394
+ * config immediately — these are one-keystroke view preferences, so there is no
1395
+ * separate save gesture to forget. A viewer picks the new value up on its next
1396
+ * attach (`live_cycles` / `condensed_history` / `fold_finished_tools` are all read
1397
+ * per ChatView), and `completion_bell` on the next turn end. */
1398
+ cycleDisplaySetting(delta) {
1399
+ const row = DISPLAY_ROWS[this.displayIndex];
1400
+ const current = readConfig('user');
1401
+ const next = row.next(current, delta);
1402
+ updateRawConfigAtomically('user', (latest) => ({ ...latest, ...next }));
1403
+ this.displayNotice = `Saved ${row.key} = ${row.format({ ...current, ...next })}. Applies on the next attach.`;
1404
+ this.invalidate();
1405
+ }
1406
+ handleDisplayInput(data) {
1407
+ if (this.matches('crtr.setup.list-down', data))
1408
+ this.moveDisplayRow(1);
1409
+ else if (this.matches('crtr.setup.list-up', data))
1410
+ this.moveDisplayRow(-1);
1411
+ else if (this.matches('crtr.setup.toggle', data) || this.matches('crtr.setup.cell-next', data))
1412
+ this.cycleDisplaySetting(1);
1413
+ else if (this.matches('crtr.setup.cell-previous', data))
1414
+ this.cycleDisplaySetting(-1);
1415
+ else if (this.matches('crtr.setup.advance', data))
1416
+ this.moveTab(1);
1417
+ }
1342
1418
  cancel() {
1343
1419
  if (this.confirming) {
1344
1420
  this.confirming = false;
@@ -1899,11 +1975,15 @@ export class SetupWizard {
1899
1975
  this.invalidate();
1900
1976
  return;
1901
1977
  }
1978
+ if (this.activeTab === DISPLAY_TAB) {
1979
+ this.handleDisplayInput(data);
1980
+ return;
1981
+ }
1902
1982
  this.handleBrokerLimitsInput(data);
1903
1983
  }
1904
1984
  renderHeader(width) {
1905
- const tabs = ['Providers', 'Plugins', 'System', 'Exa', 'Keybindings', 'Models', 'Broker limits'];
1906
- const fullTabLine = tabs
1985
+ const tabs = TAB_LABELS;
1986
+ const fullTabLine = tabs.slice()
1907
1987
  .map((label, index) => tabPill(label, index === this.activeTab))
1908
1988
  .join(theme.fg('dim', ' · '));
1909
1989
  const tabLine = visibleWidth(fullTabLine) <= width
@@ -1923,6 +2003,9 @@ export class SetupWizard {
1923
2003
  : `${this.label('crtr.setup.list-down')}/${this.label('crtr.setup.list-up')} row · ${this.label('crtr.setup.cell-previous')}/${this.label('crtr.setup.cell-next')} column · ${this.label('crtr.setup.models.scope-next')} scope · ${this.label('crtr.setup.models.open')} edit · ${this.label('crtr.setup.tab-next')} next · ${this.label('crtr.setup.cancel')} cancel`;
1924
2004
  return [truncateToWidth(theme.fg('dim', hint), width)];
1925
2005
  }
2006
+ if (this.activeTab === DISPLAY_TAB) {
2007
+ return [truncateToWidth(theme.fg('dim', `${this.label('crtr.setup.list-down')}/${this.label('crtr.setup.list-up')} row · ${this.label('crtr.setup.toggle')} or ${this.label('crtr.setup.cell-next')}/${this.label('crtr.setup.cell-previous')} change value · ${tabs} · ${this.label('crtr.setup.cancel')} cancel`), width)];
2008
+ }
1926
2009
  if (this.activeTab === 6) {
1927
2010
  return [truncateToWidth(theme.fg('dim', `${this.label('crtr.setup.list-down')}/${this.label('crtr.setup.list-up')} field · type value · Enter save · ${this.fieldSafeLabel('crtr.setup.tab-next')}/${this.fieldSafeLabel('crtr.setup.tab-previous')} switch tabs · ${this.label('crtr.setup.cancel')} cancel`), width)];
1928
2011
  }
@@ -1973,7 +2056,7 @@ export class SetupWizard {
1973
2056
  }
1974
2057
  renderSystems(width) {
1975
2058
  const out = [];
1976
- out.push(truncateToWidth(`${theme.fg('accent', theme.bold('System deps'))}${theme.fg('dim', ' tmux + nvim check')}`, width));
2059
+ out.push(truncateToWidth(`${theme.fg('accent', theme.bold('System deps'))}${theme.fg('dim', ' tmux + nvim + Nerd Font check')}`, width));
1977
2060
  for (const [index, tool] of this.systemTools.entries()) {
1978
2061
  const selected = index === this.systemIndex;
1979
2062
  const marker = selected ? theme.fg('accent', '▶') : theme.fg('dim', ' ');
@@ -2172,6 +2255,26 @@ export class SetupWizard {
2172
2255
  }
2173
2256
  return out;
2174
2257
  }
2258
+ renderDisplay(width) {
2259
+ const out = [];
2260
+ const cfg = readConfig('user');
2261
+ out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Display'))}${theme.fg('dim', ' user scope · attach viewer')}`, width));
2262
+ out.push(truncateToWidth(theme.fg('dim', 'Each change saves immediately and applies to viewers attached from now on.'), width));
2263
+ out.push('');
2264
+ for (const [index, row] of DISPLAY_ROWS.entries()) {
2265
+ const selected = index === this.displayIndex;
2266
+ const marker = selected ? theme.fg('accent', '▶') : ' ';
2267
+ const name = selected ? theme.fg('accent', row.label) : row.label;
2268
+ out.push(truncateToWidth(`${marker} ${padAnsi(name, 24)} ${theme.fg('success', row.format(cfg))}`, width));
2269
+ out.push(truncateToWidth(theme.fg('dim', ` ${row.description}`), width));
2270
+ }
2271
+ if (this.displayNotice) {
2272
+ out.push('');
2273
+ for (const line of wrapText(this.displayNotice, width))
2274
+ out.push(theme.fg('success', line));
2275
+ }
2276
+ return out;
2277
+ }
2175
2278
  render(width) {
2176
2279
  const bypass = this.activeTab === 5 && this.models.isPickerOpen();
2177
2280
  const height = this.terminalRows();
@@ -2194,7 +2297,9 @@ export class SetupWizard {
2194
2297
  ? this.renderKeybindings(width)
2195
2298
  : this.activeTab === 5
2196
2299
  ? this.renderModels(width)
2197
- : this.renderBrokerLimits(width)),
2300
+ : this.activeTab === 6
2301
+ ? this.renderBrokerLimits(width)
2302
+ : this.renderDisplay(width)),
2198
2303
  '',
2199
2304
  ...this.renderFooter(width),
2200
2305
  ];
@@ -205,7 +205,7 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
205
205
  const currentNode = defaultMenu.indexOf('-#[bold]Current node');
206
206
  const lifecycle = defaultMenu.indexOf('-#[bold]Lifecycle');
207
207
  const system = defaultMenu.indexOf('-#[bold]System');
208
- for (const item of ['resume node', 'graph view', 'focus manager', 'show ›']) {
208
+ for (const item of ['resume node', 'graph view', 'focus manager', 'show ›', 'view settings ›']) {
209
209
  assert.ok(defaultMenu.indexOf(item) > explore && defaultMenu.indexOf(item) < currentNode, `${item} belongs in Explore`);
210
210
  }
211
211
  assert.match(defaultMenu, /"show ›" s .*display-menu.*metadata.*context \+ reports/);
@@ -213,7 +213,10 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
213
213
  assert.ok(defaultMenu.indexOf(item) > currentNode && defaultMenu.indexOf(item) < lifecycle, `${item} belongs to the current node`);
214
214
  }
215
215
  assert.ok(defaultMenu.indexOf('fork into idle root') > lifecycle && defaultMenu.indexOf('fork into idle root') < system, 'fork belongs in Lifecycle');
216
- assert.ok(defaultMenu.indexOf('settings') > system, 'settings belongs in System');
216
+ assert.match(defaultMenu, /"view settings ›" w .*display-menu.*fold finished tool calls/);
217
+ // Matched with its selector, so the Explore group's "view settings ›" item
218
+ // (selector `w`) cannot satisfy this assertion by substring.
219
+ assert.ok(defaultMenu.indexOf('settings p ') > system, 'settings belongs in System');
217
220
  assert.ok(defaultMenu.indexOf('create ›') > system, 'create belongs in System');
218
221
  assert.match(tmuxBinding(socket, 'root', 'M-v') ?? '', /switch-client/);
219
222
  // The inbox toggle installs at its default key (alt+i → M-i) in the same sweep.
@@ -21,7 +21,7 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, }
21
21
  /** The identity keys meta.json persists. Listed explicitly so no runtime field
22
22
  * can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
23
23
  const IDENTITY_KEYS = [
24
- 'node_id', 'name', 'description', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
24
+ 'node_id', 'name', 'description', 'icon', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
25
25
  'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'human_work_target', 'private_fork_phase',
26
26
  'private_fork_recipe', 'passive_default', 'pi_session_id', 'pi_session_file', 'cycle_pending', 'model_override',
27
27
  'launch', 'managed_worktree',
@@ -9,6 +9,7 @@ export interface NamedNode {
9
9
  name: string;
10
10
  kind: string;
11
11
  description?: string;
12
+ icon?: string;
12
13
  }
13
14
  /** A node's human label: its explicit handle plus the pi-generated description
14
15
  * of its first task, e.g. `fix-auth refactor-login-flow`. The handle is
@@ -34,5 +34,7 @@ export function personaLabel(meta) {
34
34
  * badge. Recomputed from meta on every revive and pushed live when naming
35
35
  * generates the first task description. */
36
36
  export function editorLabel(meta) {
37
- return fullName(meta);
37
+ const icon = (meta.icon ?? '').trim();
38
+ const name = fullName(meta);
39
+ return icon !== '' ? `${icon} ${name}` : name;
38
40
  }
@@ -23,6 +23,12 @@ export declare const GRAY = "\u001B[90m";
23
23
  * `isPidAlive` / `isBusy` reads entirely (a remote node id may collide with a
24
24
  * local one) and renders the bare status glyph instead. */
25
25
  export declare function nodeGlyph(node: NodeMeta | null, remote?: boolean): string;
26
+ /** Drop every SGR sequence, keeping the visible text. The selection bar uses
27
+ * this: reverse-video INVERTS a coloured cell into a coloured block, so a row
28
+ * built from tinted spans highlights as a patchwork of background swatches
29
+ * instead of one bar. Stripping colour first leaves a single clean bar — the
30
+ * glyphs (●/○/✓/✗/⚙/◷) still carry the signal, exactly as under NO_COLOR. */
31
+ export declare function stripSgr(s: string): string;
26
32
  /** Visible width, ignoring ANSI escapes. */
27
33
  export declare function visibleWidth(s: string): number;
28
34
  /** Truncate to `max` VISIBLE columns: escape sequences are copied through
@@ -71,6 +71,14 @@ export function nodeGlyph(node, remote = false) {
71
71
  return `${ESC}${v.color}m${v.glyph}${RESET}`;
72
72
  }
73
73
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
74
+ /** Drop every SGR sequence, keeping the visible text. The selection bar uses
75
+ * this: reverse-video INVERTS a coloured cell into a coloured block, so a row
76
+ * built from tinted spans highlights as a patchwork of background swatches
77
+ * instead of one bar. Stripping colour first leaves a single clean bar — the
78
+ * glyphs (●/○/✓/✗/⚙/◷) still carry the signal, exactly as under NO_COLOR. */
79
+ export function stripSgr(s) {
80
+ return s.replace(ANSI_RE, '');
81
+ }
74
82
  /** Visible width, ignoring ANSI escapes. */
75
83
  export function visibleWidth(s) {
76
84
  return s.replace(ANSI_RE, '').length;
@@ -87,6 +87,12 @@ export interface NodeIdentity {
87
87
  /** A 2-4 word kebab-case handle derived from the node's first prompt (named
88
88
  * headlessly by pi; see runtime/naming.ts). Shown in the editor label. */
89
89
  description?: string;
90
+ /** A single Nerd Font glyph depicting the node's work, chosen by the same
91
+ * headless namer that produced `description` (see runtime/naming.ts). Kept as
92
+ * its own field rather than baked into the description so each surface decides
93
+ * whether to render it; absent on a node named before icons existed, or when
94
+ * the model returned nothing usable. */
95
+ icon?: string;
90
96
  /** How many times this node has been (re)launched — born at 0, bumped on
91
97
  * every revive. Exposed to the pi footer as CRTR_CYCLES so a refresh/crash
92
98
  * cycle reads at a glance there. */
@@ -359,12 +359,13 @@ function mergeConfig(partial) {
359
359
  const condensed_history = CONDENSED_HISTORY_MODES.includes(partial.condensed_history)
360
360
  ? partial.condensed_history
361
361
  : defaults.condensed_history;
362
+ const fold_finished_tools = typeof partial.fold_finished_tools === 'boolean' ? partial.fold_finished_tools : defaults.fold_finished_tools;
362
363
  const keybindings = normalizeKeybindings(partial.keybindings);
363
364
  const modelLadders = mergeModelLadders(partial.modelLadders);
364
365
  const kinds = mergeKinds(partial.kinds);
365
366
  const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
366
367
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
367
- return { schema_version, marketplaces, plugins, configuredClis, auto_update, max_panes_per_window, brokerThresholds, completion_bell, live_cycles, condensed_history, keybindings, modelLadders, kinds, remoteCanvas, spawnEnv };
368
+ return { schema_version, marketplaces, plugins, configuredClis, auto_update, max_panes_per_window, brokerThresholds, completion_bell, live_cycles, condensed_history, fold_finished_tools, keybindings, modelLadders, kinds, remoteCanvas, spawnEnv };
368
369
  }
369
370
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
370
371
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -497,7 +498,7 @@ export function subKindsAvailableTo(kind) {
497
498
  * an untouched key is never rewritten. */
498
499
  const CONFIG_TOP_LEVEL_KEYS = [
499
500
  'schema_version', 'marketplaces', 'plugins', 'configuredClis', 'auto_update',
500
- 'max_panes_per_window', 'brokerThresholds', 'completion_bell', 'live_cycles', 'condensed_history', 'keybindings', 'modelLadders', 'kinds',
501
+ 'max_panes_per_window', 'brokerThresholds', 'completion_bell', 'live_cycles', 'condensed_history', 'fold_finished_tools', 'keybindings', 'modelLadders', 'kinds',
501
502
  'remoteCanvas', 'spawnEnv',
502
503
  ];
503
504
  /** The SPARSE `modelLadders` to persist after a config mutation: the raw
@@ -14,7 +14,7 @@ function ids(prefix, actions) {
14
14
  return actions.map((action) => `${prefix}.${action}`);
15
15
  }
16
16
  const REQUIRED_IDS = [
17
- ...ids('crtr.tmux.menu', ['open', 'promote', 'resume', 'fork', 'demote', 'detach', 'close-subtree', 'background-bash', 'file-review', 'pin', 'show', 'metadata', 'context', 'providers', 'graph', 'focus-manager', 'copy-id', 'reload-viewer', 'issues']),
17
+ ...ids('crtr.tmux.menu', ['open', 'promote', 'resume', 'fork', 'demote', 'detach', 'close-subtree', 'background-bash', 'file-review', 'pin', 'show', 'view-settings', 'fold-tools', 'metadata', 'context', 'providers', 'graph', 'focus-manager', 'copy-id', 'reload-viewer', 'issues']),
18
18
  ...ids('crtr.tmux.menu.issue', ['idea', 'bug']),
19
19
  ...ids('crtr.tmux.node', ['next', 'previous']),
20
20
  'crtr.tmux.inbox.toggle',