@aragon-agent/cli 0.6.13 → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ were written.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ### Consistent TUI styling
11
+
12
+ - Interactive sessions always use Unicode and default to truecolor, including
13
+ PowerShell sessions without terminal or UTF locale markers. Ink's actual color
14
+ output follows the same policy, with renderer state restored on exit.
15
+ - Explicit color opt-outs keep Unicode borders and icons; the opening logo now
16
+ depends only on available space. Headless terminal detection is unchanged.
17
+ - Reloading model settings preserves session display preferences. Syntax
18
+ highlighting follows the same color policy, and status icons use consistent
19
+ single-column glyphs to prevent narrow-frame overflow.
20
+ - Hiding the editor caret while scrolling preserves draft geometry, preventing
21
+ a layout update loop when entering text above the full startup logo.
22
+ - Register exit cleanup before mounting the application, so immediate exits and
23
+ initial render failures restore terminal and color state reliably. Failed
24
+ mounts no longer mark an auto-installed build as healthy.
25
+
26
+ ### Model context window
27
+
28
+ - Discover context limits at startup and after connection changes without blocking
29
+ input. The status bar and compaction use the discovered limit; `/context` names
30
+ API versus catalog metadata. Unknown windows show `?`, not a fabricated 128k
31
+ capacity or percentage. Manual `contextWindow` overrides retain precedence.
32
+ - Window overrides now apply to tail relief and compaction progress as well as
33
+ the gauge. Workers on different models/endpoints do not inherit the lead's
34
+ override. API-key changes refresh metadata, and ending a session cancels its
35
+ pending model queries. `/context` also hides unknown capacities/percentages.
36
+
10
37
  ### TUI 输入与排队可靠性
11
38
 
12
39
  - Shift+Enter 与粘贴按输入顺序处理;普通 Enter 保持弹层兼容,混合输入最多提交一次,
package/README.md CHANGED
@@ -1316,9 +1316,9 @@ The readouts drop as the terminal narrows, in that order of priority:
1316
1316
  either no completed turn has reported usage yet (right after `/resume`, or
1317
1317
  right after a compaction), or tool results have been appended since the last
1318
1318
  measurement and their size is estimated.
1319
- - `?` sits on the **window** and means the *denominator* is a guess: the model is
1320
- not in the built-in table, so a flat 128 000 was substituted. See
1321
- `contextWindow` below.
1319
+ - `?` replaces the **window** when neither the API nor the catalog provides a
1320
+ limit. No percentage is shown in that case. The internal 128 000-token fallback
1321
+ remains a compaction budget, not a claim about model capacity. See `contextWindow` below.
1322
1322
 
1323
1323
  **The percentage keeps moving during a turn.** Every tool result nudges it,
1324
1324
  whether or not compaction is enabled — it is not a compaction feature, and a
@@ -1343,7 +1343,7 @@ Context
1343
1343
  ```
1344
1344
 
1345
1345
  Two lines are worth reading closely. **`Window`** always names where the number
1346
- came from — the model table, your own `contextWindow`, or the invented
1346
+ came from — the model API, the model table, your own `contextWindow`, or the invented
1347
1347
  placeholder — because those are indistinguishable everywhere else. And
1348
1348
  **`Compaction`** reports what is actually true of this session, including
1349
1349
  `off - not registered for this session (started with --no-compaction)`; it never
@@ -1351,13 +1351,21 @@ promises a threshold that nothing will act on.
1351
1351
 
1352
1352
  ### `contextWindow`
1353
1353
 
1354
- When the model is not in the built-in table — a custom `baseUrl`, a self-hosted
1355
- id, something released last week — the denominator is a **fabricated** 128 000
1356
- and the percentage is meaningless. Correct it:
1354
+ Window resolution uses your explicit override first, then API metadata from the
1355
+ configured endpoint, then the built-in catalog (including known aliases and dated
1356
+ snapshots). Startup, connection changes, and subsequent prompts refresh discovery
1357
+ in the background, with a five-minute cache. OpenAI-compatible endpoints may expose
1358
+ `context_length` or `context_window`; Anthropic uses `max_input_tokens`; Google uses
1359
+ `inputTokenLimit`. An ID-only models response cannot establish the real limit.
1360
+ Google/Anthropic lists are paginated (up to 20 pages within one 15-second deadline).
1361
+ Changing the connection or ending the session cancels its pending lookup.
1362
+
1363
+ If neither the endpoint nor the catalog knows your model, the status bar shows `?`.
1364
+ Set a verified provider limit explicitly:
1357
1365
 
1358
1366
  ```bash
1359
1367
  aragon config set contextWindow 1000000 # persisted; prints "Set contextWindow = 1000000"
1360
- aragon config set contextWindow auto # back to the model table / placeholder
1368
+ aragon config set contextWindow auto # back to API discovery / catalog
1361
1369
  ARAGON_CONTEXT_WINDOW=1000000 aragon # one session
1362
1370
  ```
1363
1371
 
@@ -1890,7 +1898,7 @@ env / `.env` → CLI flags**.
1890
1898
  | Config key | Default | Meaning |
1891
1899
  | --- | --- | --- |
1892
1900
  | `maxTokens` | `64000` | Output token cap, clamped to `[256, 200000]`. `null` means **auto** — use each model's own ceiling, never above 64000. Absent means the default. See [Output token limits](#output-token-limits). |
1893
- | `contextWindow` | `null` | The window the context gauge measures against, clamped to `[8000, 5000000]`. `null` means **auto** — the built-in model table, or a fabricated 128000 for a model it has never seen. Set it when the gauge shows `?` on the denominator. See [The context gauge](#the-context-gauge). |
1901
+ | `contextWindow` | `null` | The window the context gauge measures against, clamped to `[8000, 5000000]`. `null` means **auto** — API metadata, then the built-in catalog. Unknown windows show `?`; the internal fallback is 128000. See [The context gauge](#the-context-gauge). |
1894
1902
  | `showThinking` | `false` | Draw the reasoning the model returns. `thinkingLevel` is the effort the provider is asked to **spend**; this is whether the terminal **shows** it. Off by default: a settled turn that thought leaves one muted `thought for 12s` row in its place, so nothing is hidden silently. |
1895
1903
  | `liveToolOutput` | `true` | Draw up to eight sanitised rows of a **running** tool's output on its card, plus a `no output for Ns` row when the child goes quiet. On by default, unlike `showThinking`: this ADDS the information a long `bash` call otherwise hides, and its cost is bounded by construction — eight rows per call, sixteen calls, whatever the command emits. Turn it off and the card is a single `running` row again, with no store allocated and no recorder attached. |
1896
1904
  | `exitTranscript` | `true` | Replay a plain-text session summary after exiting (full-screen only). |
@@ -1968,7 +1976,7 @@ aragon config set maxTokens 32000 # persisted; prints "Set maxTokens = 32000"
1968
1976
  aragon config set maxTokens auto # persisted as null
1969
1977
  aragon config set maxTokens default # back to 64000
1970
1978
  aragon config set contextWindow 1000000 # the gauge's denominator
1971
- aragon config set contextWindow auto # back to the model table
1979
+ aragon config set contextWindow auto # back to API discovery / catalog
1972
1980
  aragon config get maxTokens # "64000" | "auto"
1973
1981
  ```
1974
1982
 
@@ -2272,12 +2280,15 @@ quiet output is required.
2272
2280
  `--theme` / `config.theme` / `/theme` pick a palette: `warm`, `cool`, `light`, or
2273
2281
  `auto` (which resolves to `warm` — terminals can't reliably report their
2274
2282
  background). `dark` remains accepted everywhere as an alias for `cool`.
2275
- Colors degrade automatically to your terminal's depth (truecolor → 256 → 16 →
2276
- monochrome) and glyphs fall back to ASCII on terminals without Unicode. Color is
2277
- disabled — and the UI renders plain monochrome with ASCII glyphs — when any of
2278
- `NO_COLOR`, `--no-color`, `TERM=dumb`, or `config.color=false` is set; `FORCE_COLOR`
2279
- is respected. `reducedMotion` (also `ARAGON_REDUCED_MOTION`, and implied by
2280
- `--no-color`) replaces spinners with a static glyph.
2283
+ Interactive sessions always use Unicode borders, icons and scrollbars, with
2284
+ truecolor and gradients by default. Missing terminal/locale markers, `TERM=dumb`,
2285
+ and low `FORCE_COLOR` levels do not simplify the TUI. `--no-color`, `NO_COLOR`
2286
+ (including an empty value), or `FORCE_COLOR=0/false` explicitly disable color;
2287
+ Unicode stays enabled. The opening logo adapts only to available space and
2288
+ remains visible in monochrome. `reducedMotion` (also `ARAGON_REDUCED_MOTION`,
2289
+ and implied by disabling color) replaces spinners with a static glyph.
2290
+ Headless commands retain their existing terminal detection. The terminal and
2291
+ font still need to support the characters and colors the TUI emits.
2281
2292
 
2282
2293
  ### Timeout invariant
2283
2294
 
@@ -123,6 +123,10 @@ export declare class AgentController {
123
123
  private readonly agent;
124
124
  private readonly providerRegistry;
125
125
  private readonly modelRegistry;
126
+ private modelMetadataEnabled;
127
+ private modelMetadataGeneration;
128
+ private modelMetadataAbort;
129
+ private disposed;
126
130
  private readonly tools;
127
131
  private readonly skills;
128
132
  private readonly skillsEnabled;
@@ -608,7 +612,11 @@ export declare class AgentController {
608
612
  * cost table for the model that actually billed" cannot drift from "the cost
609
613
  * table for the session's model" as two separate bodies.
610
614
  */
611
- getModelInfoFor(ref: Pick<ModelRef, 'providerId' | 'modelId'>): ModelInfo;
615
+ getModelInfoFor(ref: Pick<ModelRef, 'providerId' | 'modelId' | 'baseUrl'>): ModelInfo;
616
+ /** Best-effort metadata lookup; never block typing or a model request. */
617
+ refreshModelMetadata(): Promise<void>;
618
+ /** Invalidate an old connection without restarting a lookup on every prompt. */
619
+ private resetModelMetadataRequest;
612
620
  /**
613
621
  * Whether the STATIC table knows this model's price (C-11 / RV-4).
614
622
  *
@@ -51,6 +51,10 @@ export class AgentController {
51
51
  agent;
52
52
  providerRegistry;
53
53
  modelRegistry;
54
+ modelMetadataEnabled = false;
55
+ modelMetadataGeneration = 0;
56
+ modelMetadataAbort = new AbortController();
57
+ disposed = false;
54
58
  tools;
55
59
  skills;
56
60
  skillsEnabled;
@@ -529,15 +533,8 @@ export class AgentController {
529
533
  getMessages: () => this.agent.state.messages,
530
534
  getSystemPrompt: () => this.agent.state.systemPrompt,
531
535
  getModelInfo: () => this.getModelInfo(),
532
- // ONE PREDICATE FOR "IS THE DENOMINATOR REAL". `isPricedModel` is
533
- // `modelRegistry.getModel(...) !== undefined`, and `getModelInfoFor` falls
534
- // back to `buildRuntimeModel` - a 128k placeholder AND a zero cost table -
535
- // for exactly the models it answers `false` for. The two are the same
536
- // question about the same static table, not a coincidence.
537
- isWindowKnown: () => this.isPricedModel({
538
- providerId: this.config.provider,
539
- modelId: this.config.model,
540
- }),
536
+ // Context discovery does not imply that the model's price is known.
537
+ isWindowKnown: () => this.getModelInfo().contextWindowSource !== 'fallback',
541
538
  // LIVE, so a settings-screen edit moves the denominator without a relaunch.
542
539
  getWindowOverride: () => this.config.contextWindow,
543
540
  });
@@ -863,6 +860,8 @@ export class AgentController {
863
860
  if (this.modelSettingsBlocked) {
864
861
  return { status: 'not-started', reason: 'failed' };
865
862
  }
863
+ if (this.modelMetadataEnabled)
864
+ void this.refreshModelMetadata();
866
865
  const request = ++this.startupSequence;
867
866
  try {
868
867
  if (this.agent.state.isRunning) {
@@ -1257,6 +1256,9 @@ export class AgentController {
1257
1256
  }
1258
1257
  /** Abort every child and clear timers. Idempotent; safe to call twice. */
1259
1258
  dispose() {
1259
+ this.disposed = true;
1260
+ this.modelMetadataGeneration += 1;
1261
+ this.modelMetadataAbort.abort();
1260
1262
  this.teamRuntime?.dispose();
1261
1263
  this.fast?.dispose();
1262
1264
  this.compaction?.dispose();
@@ -1419,6 +1421,12 @@ export class AgentController {
1419
1421
  this.agent.setModel(snapshot.model);
1420
1422
  this.agent.setThinkingLevel(snapshot.config.thinkingLevel);
1421
1423
  this.agent.setMaxTokens(snapshot.config.maxTokens);
1424
+ // Credentials may change even when endpoint/model do not. Invalidate
1425
+ // before publishing a window, including when the new profile has no key.
1426
+ if (this.modelMetadataEnabled) {
1427
+ this.resetModelMetadataRequest();
1428
+ this.modelRegistry.clearCache();
1429
+ }
1422
1430
  this.fast?.onConfigChanged(false, snapshot.config.fast.enabled);
1423
1431
  this.compaction?.onConfigChanged(snapshot.config.compaction.enabled);
1424
1432
  this.contextMeter.onWindowChanged();
@@ -1427,6 +1435,8 @@ export class AgentController {
1427
1435
  finally {
1428
1436
  this.preparedSystemPrompt = undefined;
1429
1437
  }
1438
+ if (this.modelMetadataEnabled)
1439
+ void this.refreshModelMetadata();
1430
1440
  }
1431
1441
  /** Potentially failing ordinary-setting side effects are isolated from model application. */
1432
1442
  applyModelSettingsEffects() { getLogger().reconfigure(this.config.log); }
@@ -1434,12 +1444,15 @@ export class AgentController {
1434
1444
  blockModelSettingsRequests() {
1435
1445
  this.modelSettingsBlocked = true;
1436
1446
  this.config.modelSettingsRestartRequired = true;
1447
+ this.resetModelMetadataRequest();
1437
1448
  }
1438
1449
  /** The UI uses this before accepting or draining queued prompts. */
1439
1450
  areModelSettingsBlocked() { return this.modelSettingsBlocked; }
1440
1451
  setModel(provider, model, baseUrl) {
1441
1452
  if (this.isModelSettingsBusy())
1442
1453
  throw new Error('Model settings are busy.');
1454
+ if (this.modelMetadataEnabled)
1455
+ this.resetModelMetadataRequest();
1443
1456
  this.settingsRevision += 1;
1444
1457
  this.config = { ...this.config, provider, model, baseUrl };
1445
1458
  this.config.modelProfileState = resolveModelProfileState(this.config);
@@ -1462,6 +1475,8 @@ export class AgentController {
1462
1475
  // numerator's calibration is one turn stale until the next `turn_end`
1463
1476
  // (RV-13), which is the trade this call names.
1464
1477
  this.contextMeter.onWindowChanged();
1478
+ if (this.modelMetadataEnabled)
1479
+ void this.refreshModelMetadata();
1465
1480
  }
1466
1481
  setThinkingLevel(level) {
1467
1482
  this.settingsRevision += 1;
@@ -1481,6 +1496,12 @@ export class AgentController {
1481
1496
  ...this.config,
1482
1497
  apiKeys: { ...this.config.apiKeys, [provider]: key },
1483
1498
  };
1499
+ if (this.modelMetadataEnabled) {
1500
+ this.resetModelMetadataRequest();
1501
+ this.modelRegistry.clearCache(provider);
1502
+ this.contextMeter.onWindowChanged();
1503
+ void this.refreshModelMetadata();
1504
+ }
1484
1505
  // Rule 5 of §3.2 reads `apiKeys`, so a key edit is a tier event: this is the
1485
1506
  // path that turns `no_key` back into a working tier without a relaunch.
1486
1507
  this.fast?.onConfigChanged();
@@ -1639,9 +1660,38 @@ export class AgentController {
1639
1660
  */
1640
1661
  getModelInfoFor(ref) {
1641
1662
  const found = this.modelRegistry.getModel(ref.providerId, ref.modelId);
1642
- if (found)
1643
- return found;
1644
- return this.modelRegistry.buildRuntimeModel(ref.providerId, ref.modelId);
1663
+ const baseUrl = ref.baseUrl ?? (ref.providerId === this.config.provider && ref.modelId === this.config.model
1664
+ ? this.config.baseUrl : undefined);
1665
+ return {
1666
+ ...(found ?? this.modelRegistry.buildRuntimeModel(ref.providerId, ref.modelId)),
1667
+ ...this.modelRegistry.getContextWindow(ref.providerId, ref.modelId, baseUrl),
1668
+ };
1669
+ }
1670
+ /** Best-effort metadata lookup; never block typing or a model request. */
1671
+ async refreshModelMetadata() {
1672
+ if (this.disposed || this.modelSettingsBlocked)
1673
+ return;
1674
+ this.modelMetadataEnabled = true;
1675
+ const generation = ++this.modelMetadataGeneration;
1676
+ const { provider, baseUrl } = this.config;
1677
+ try {
1678
+ const key = this.resolveKey(provider);
1679
+ if (!key)
1680
+ return;
1681
+ await this.modelRegistry.discoverModels(provider, key, baseUrl, this.modelMetadataAbort.signal);
1682
+ if (this.disposed || generation !== this.modelMetadataGeneration)
1683
+ return;
1684
+ this.contextMeter.onWindowChanged();
1685
+ }
1686
+ catch {
1687
+ // Offline or unsupported discovery leaves catalog/unknown metadata intact.
1688
+ }
1689
+ }
1690
+ /** Invalidate an old connection without restarting a lookup on every prompt. */
1691
+ resetModelMetadataRequest() {
1692
+ this.modelMetadataGeneration += 1;
1693
+ this.modelMetadataAbort.abort();
1694
+ this.modelMetadataAbort = new AbortController();
1645
1695
  }
1646
1696
  /**
1647
1697
  * Whether the STATIC table knows this model's price (C-11 / RV-4).
@@ -182,8 +182,17 @@ export function reloadModelSettings(controller) {
182
182
  if (validateModelProfiles(disk.config?.modelProfiles).length)
183
183
  return rejected('invalid');
184
184
  try {
185
- const resolved = loadConfig({ cwd: controller.getConfig().cwd });
186
- resolved.liveToolOutput = controller.getConfig().liveToolOutput;
185
+ const live = controller.getConfig();
186
+ const resolved = loadConfig({ cwd: live.cwd });
187
+ resolved.liveToolOutput = live.liveToolOutput;
188
+ // The mounted renderer owns these session preferences. Reloading model
189
+ // settings must not re-detect the shell or discard a startup --no-color.
190
+ resolved.color = live.color;
191
+ if (live.colorLevel !== undefined)
192
+ resolved.colorLevel = live.colorLevel;
193
+ if (live.unicode !== undefined)
194
+ resolved.unicode = live.unicode;
195
+ resolved.reducedMotion ||= !live.color;
187
196
  const after = readModelSettingsDisk();
188
197
  if (!after.ok)
189
198
  return rejected('read_failed');
@@ -1190,6 +1190,7 @@ export function viewReducer(state, action) {
1190
1190
  prev.source === next.source &&
1191
1191
  prev.deltaTokens === next.deltaTokens &&
1192
1192
  prev.windowKnown === next.windowKnown &&
1193
+ prev.windowSource === next.windowSource &&
1193
1194
  prev.windowOverridden === next.windowOverridden) {
1194
1195
  return state;
1195
1196
  }
@@ -109,9 +109,9 @@ export declare function runBootGuard(deps?: BootGuardDeps): void;
109
109
  *
110
110
  * Called from two places, and both matter:
111
111
  *
112
- * - `cli.tsx::runInteractive`, immediately after `render()` returns - the
113
- * earliest moment at which "this build starts" is PROVEN: the module graph
114
- * loaded, config resolved, Ink mounted. This is the primary, and it is what
112
+ * - `cli.tsx::runInteractive`, after mount-time exit promises settle, while
113
+ * the app is still live (or has exited successfully): the module graph
114
+ * loaded, config resolved, Ink mounted successfully. This primary path
115
115
  * makes a long session that is later `SIGKILL`ed (terminal window closed,
116
116
  * machine slept badly, OOM killer) still count as healthy.
117
117
  * - the exit hook installed by `runBootGuard`, when the code is `0` - the
@@ -178,9 +178,9 @@ export function runBootGuard(deps = {}) {
178
178
  *
179
179
  * Called from two places, and both matter:
180
180
  *
181
- * - `cli.tsx::runInteractive`, immediately after `render()` returns - the
182
- * earliest moment at which "this build starts" is PROVEN: the module graph
183
- * loaded, config resolved, Ink mounted. This is the primary, and it is what
181
+ * - `cli.tsx::runInteractive`, after mount-time exit promises settle, while
182
+ * the app is still live (or has exited successfully): the module graph
183
+ * loaded, config resolved, Ink mounted successfully. This primary path
184
184
  * makes a long session that is later `SIGKILL`ed (terminal window closed,
185
185
  * machine slept badly, OOM killer) still count as healthy.
186
186
  * - the exit hook installed by `runBootGuard`, when the code is `0` - the
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
16
16
  import process from 'node:process';
17
17
  import '../runtime/insecure-tls-warning.cjs';
18
18
  // Must precede every other dependency that imports Ink: CI detection is cached.
19
- import { render } from './ui/ink-runtime.js';
19
+ import { renderTui as render } from './ui/ink-runtime.js';
20
20
  import { Command } from 'commander';
21
21
  import { ModelProfileConfigError } from './config/model-profile-store.js';
22
22
  import { loadConfig } from './config/load.js';
@@ -54,7 +54,7 @@ import { supportsWindowsVtInput } from './ui/win-vt-input.js';
54
54
  import { forceWindowsVtInput } from './ui/win-vt-force.js';
55
55
  import { readExitSnapshot } from './ui/exit-snapshot.js';
56
56
  import { renderTranscriptText } from './ui/transcript-text.js';
57
- import { detectCapabilities } from './ui/capabilities.js';
57
+ import { resolveTuiCapabilities } from './ui/capabilities.js';
58
58
  import { pickGlyphs } from './ui/glyphs.js';
59
59
  import { getTheme } from './ui/theme.js';
60
60
  import { createSelectionController, } from './ui/selection/selection-controller.js';
@@ -224,7 +224,7 @@ function toFlags(opts) {
224
224
  * instead of writing to disk unattended.
225
225
  */
226
226
  function makeController(flags, opts = { interactive: true }) {
227
- const config = loadConfig(flags);
227
+ const config = loadConfig(flags, { interactive: opts.interactive });
228
228
  // The authoritative log level lands here (§4.4.5). `installLogging()` only
229
229
  // resolved a bootstrap level — it ran before commander and before `.env` were
230
230
  // readable — and without this hand-off the fully resolved one never applies.
@@ -283,6 +283,7 @@ function makeController(flags, opts = { interactive: true }) {
283
283
  */
284
284
  function makeExecController(flags, deps) {
285
285
  const { controller } = makeController(flags, { interactive: false, ...deps });
286
+ void controller.refreshModelMetadata();
286
287
  const logger = getLogger();
287
288
  logger.info('cli', 'run_exec', {});
288
289
  logger.onFailure((reason) => process.stderr.write(`[log] ${reason}\n`));
@@ -388,9 +389,14 @@ function runInteractive(flags, extras = {}) {
388
389
  return;
389
390
  }
390
391
  const { controller, confirmBridge, humanInputBridge } = makeController(flags);
392
+ void controller.refreshModelMetadata();
391
393
  const config = controller.getConfig();
394
+ const caps = resolveTuiCapabilities({}, config.color);
392
395
  const logger = getLogger();
393
- logger.info('cli', 'run_interactive', { mode: 'fullscreen', provider: config.provider, model: config.model });
396
+ logger.info('cli', 'run_interactive', {
397
+ mode: 'fullscreen', provider: config.provider, model: config.model,
398
+ unicode: caps.unicode, colorLevel: caps.colorLevel,
399
+ });
394
400
  attachAgentEvents(logger, controller);
395
401
  // The team stream is CLI-local (D-10), so `attachAgentEvents` cannot see it.
396
402
  // Without this a dispatch leaves NO trace in the log file at all — the wrong
@@ -548,7 +554,7 @@ function runInteractive(flags, extras = {}) {
548
554
  return; // Never published (instant exit) — skip, do not throw.
549
555
  writeExitTranscript(process.stdout, renderTranscriptText(snapshot.entries, {
550
556
  // Same terminal the TUI just left, so the same glyph tier applies.
551
- glyphs: pickGlyphs(detectCapabilities(process.env, process.stdout)),
557
+ glyphs: pickGlyphs(caps),
552
558
  usageTotal: snapshot.usageTotal,
553
559
  provider: snapshot.provider,
554
560
  model: snapshot.model,
@@ -635,8 +641,8 @@ function runInteractive(flags, extras = {}) {
635
641
  // the SAME `copyText` door and the SAME bridge `onCopied` toast
636
642
  // funnel -- the controller keeps only the selection state machine.
637
643
  // Read at PAINT time, not captured: `/theme` rebuilds the theme mid-session.
638
- theme: () => getTheme(config.theme, detectCapabilities(process.env, process.stdout)),
639
- caps: detectCapabilities(process.env, process.stdout),
644
+ theme: () => getTheme(config.theme, caps),
645
+ caps,
640
646
  cols: () => process.stdout.columns ?? 80,
641
647
  // The STATIC half of the gate. The dynamic half — "no overlay is open" —
642
648
  // belongs to `App`, which owns that state and calls `setEnabled`.
@@ -718,14 +724,15 @@ function runInteractive(flags, extras = {}) {
718
724
  // every existing path is provably unchanged.
719
725
  stdin: filter?.stdin ?? process.stdin,
720
726
  stdout: frameObserver.stdout,
721
- });
727
+ }, caps);
722
728
  }
723
729
  catch (error) {
724
730
  failInteractive(error);
725
731
  throw error;
726
732
  }
727
- // H1's primary disarm, and this is the earliest moment at which "this build
728
- // STARTS" is proven: the module graph loaded, config resolved, Ink mounted
733
+ // H1's primary disarm. Let mount-time exit promises settle first: Ink may
734
+ // return from render after its error boundary has already unmounted the app.
735
+ // A live app on the next turn has loaded, resolved config, and mounted
729
736
  // (cli-auto-update-hardening section 5.1.4). It is what makes a long session
730
737
  // that is later `SIGKILL`ed - terminal window closed, machine slept badly, OOM
731
738
  // killer - still count as healthy.
@@ -735,7 +742,8 @@ function runInteractive(flags, extras = {}) {
735
742
  // never armed it this is one read and one string compare. Gating it would mean
736
743
  // a user who switches `mode` to `off` between the install and the next launch
737
744
  // leaves the guard armed with nothing able to disarm it.
738
- markBootHealthy(VERSION);
745
+ setImmediate(() => { if (!interactiveClosed)
746
+ markBootHealthy(VERSION); });
739
747
  if (updateEligible) {
740
748
  // DYNAMIC, and fire-and-forget AFTER `render()` (D-25). Dynamic because a
741
749
  // static import would make AC-1 false for `aragon -p`, which shares this
@@ -778,6 +786,9 @@ function runInteractive(flags, extras = {}) {
778
786
  .waitUntilExit()
779
787
  .then(() => {
780
788
  interactiveClosed = true;
789
+ // Also cover a successful immediate exit when launched via dist/cli.js,
790
+ // which bypasses the launcher's code-zero boot-guard backstop.
791
+ markBootHealthy(VERSION);
781
792
  screen?.restore();
782
793
  // BEFORE `disposeStdinFilter()`, which clears the listener set it subscribed to,
783
794
  // and after `restore()` for the reason that call records: the terminal must
@@ -813,6 +824,7 @@ async function runOneShot(flags, prompt, quiet) {
813
824
  // install. The gate must know that up front rather than discovering a null
814
825
  // handler and defaulting to "yes" (§7.4 / AC-13).
815
826
  const { controller } = makeController(flags, { interactive: false });
827
+ void controller.refreshModelMetadata();
816
828
  const logger = getLogger();
817
829
  logger.info('cli', 'run_oneshot', { quiet });
818
830
  // Headless has no frame, so a logging failure can safely reach stderr — the
@@ -831,10 +843,16 @@ async function runOneShot(flags, prompt, quiet) {
831
843
  // `followThrough` is read off the REAL controller here rather than through
832
844
  // `HeadlessController`, which is deliberately the minimal surface a test can
833
845
  // satisfy with an object literal (todo-plan-followthrough §3.6).
834
- const code = await runHeadless(controller, prompt, {
835
- quiet,
836
- followThrough: controller.getTodoConfig().followThrough,
837
- });
846
+ let code;
847
+ try {
848
+ code = await runHeadless(controller, prompt, {
849
+ quiet,
850
+ followThrough: controller.getTodoConfig().followThrough,
851
+ });
852
+ }
853
+ finally {
854
+ controller.dispose();
855
+ }
838
856
  // Flushed HERE and not inside `runHeadless`: that module is deliberately
839
857
  // injectable (its own controller interface, its own stdout/stderr) and has an
840
858
  // early `return 2`, so a singleton dependency in there would be both a new
@@ -53,11 +53,15 @@ export function createChildContextManager(req, deps) {
53
53
  */
54
54
  const childConfig = () => {
55
55
  const base = deps.getConfig();
56
+ const sameConnection = base.provider === req.model.providerId &&
57
+ base.model === req.model.modelId && base.baseUrl === req.model.baseUrl;
56
58
  return {
57
59
  ...base,
58
60
  provider: req.model.providerId,
59
61
  model: req.model.modelId,
60
62
  baseUrl: req.model.baseUrl,
63
+ // The lead's manual limit describes its connection, not every worker.
64
+ contextWindow: sameConnection ? base.contextWindow : null,
61
65
  compaction: {
62
66
  ...base.compaction,
63
67
  keepRecentTurns: COMPACTION_LIMITS.childKeepRecentTurns,
@@ -468,7 +468,7 @@ export class Compactor {
468
468
  from,
469
469
  systemPrompt,
470
470
  budget: tailBudgetTokens({
471
- contextWindow: this.modelInfo().contextWindow,
471
+ contextWindow: this.meter().current().contextWindow,
472
472
  maxOutputTokens: this.effectiveMaxOutputTokens(),
473
473
  }),
474
474
  log: (event, fields) => this.log.info(event, fields),
@@ -569,7 +569,7 @@ export class Compactor {
569
569
  */
570
570
  checkProgress(ctx, uiTrigger, after) {
571
571
  const config = this.deps.getConfig().compaction;
572
- const info = this.modelInfo();
572
+ const pressure = this.meter().current();
573
573
  // THE OFFSET NOW LIVES ON THE METER (W1), and it is read from the last
574
574
  // PUBLISHED pressure rather than re-derived: `Pressure.estimateOffset` is
575
575
  // carried on every measurement, so this is the same number the field used to
@@ -578,8 +578,8 @@ export class Compactor {
578
578
  const projected = computePressure({
579
579
  messages: after,
580
580
  systemPrompt: ctx.systemPrompt,
581
- contextWindow: info.contextWindow,
582
- windowKnown: true,
581
+ contextWindow: pressure.contextWindow,
582
+ windowKnown: pressure.windowKnown,
583
583
  ...(estimateOffset !== undefined ? { estimateOffset } : {}),
584
584
  });
585
585
  const target = config.threshold - COMPACTION_LIMITS.minReclaimRatio;
@@ -792,7 +792,10 @@ export class Compactor {
792
792
  getMessages: () => this.deps.getMessages(),
793
793
  getSystemPrompt: () => this.deps.getSystemPrompt(),
794
794
  getModelInfo: () => this.modelInfo(),
795
- isWindowKnown: () => this.deps.isPricedModel(this.mainRef()),
795
+ isWindowKnown: () => {
796
+ const source = this.modelInfo().contextWindowSource;
797
+ return source === undefined ? this.deps.isPricedModel(this.mainRef()) : source !== 'fallback';
798
+ },
796
799
  getWindowOverride: () => this.deps.getConfig().contextWindow,
797
800
  });
798
801
  }
@@ -69,6 +69,8 @@ function occupancyLine(usage) {
69
69
  : usage.deltaTokens > 0
70
70
  ? `[measured ${formatTokens(measured)} + ${formatTokens(usage.deltaTokens)} estimated]`
71
71
  : `[measured ${formatTokens(measured)}]`;
72
+ if (!usage.windowKnown)
73
+ return `${formatTokens(usage.occupied)} of ? ${breakdown}`;
72
74
  return `${pct} ${formatTokens(usage.occupied)} of ${formatTokens(usage.window)} ${breakdown}`;
73
75
  }
74
76
  /**
@@ -83,13 +85,13 @@ function occupancyLine(usage) {
83
85
  */
84
86
  function windowLine(usage) {
85
87
  if (usage.windowOverridden) {
86
- return `${usage.window} from contextWindow in config.json (overrides the model table)`;
88
+ return `${usage.window} from contextWindow configuration (overrides API and model table)`;
87
89
  }
88
90
  if (!usage.windowKnown) {
89
- return (`${usage.window} PLACEHOLDER - this model is not in the table; ` +
91
+ return (`UNKNOWN - no verified model limit; internal fallback budget ${usage.window}; ` +
90
92
  'set contextWindow to correct it');
91
93
  }
92
- return `${usage.window} from the model table`;
94
+ return `${usage.window} from ${usage.windowSource === 'api' ? 'the model API' : 'the model table'}`;
93
95
  }
94
96
  /**
95
97
  * How stale the measured half is.
@@ -53,6 +53,7 @@ export const CONTEXT_METER_TICK_MS = 400;
53
53
  export function toContextUsage(p) {
54
54
  return {
55
55
  occupied: p.occupied,
56
+ ...(p.windowSource ? { windowSource: p.windowSource } : {}),
56
57
  window: p.contextWindow,
57
58
  // FROM `ratio`, NOT FROM A SECOND DIVISION. `computePressure` already
58
59
  // clamped it to [0, 1] and already decided what a zero window means.
@@ -380,6 +381,7 @@ export class ContextMeter {
380
381
  messages: this.deps.getMessages(),
381
382
  systemPrompt: this.readSystemPrompt(),
382
383
  contextWindow: window.contextWindow,
384
+ windowSource: this.deps.getModelInfo().contextWindowSource,
383
385
  windowKnown: window.windowKnown,
384
386
  windowOverridden: window.windowOverridden,
385
387
  ...(this.estimateOffset !== undefined ? { estimateOffset: this.estimateOffset } : {}),
@@ -50,6 +50,7 @@ export declare function occupiedTokens(usage: TokenUsage): number;
50
50
  */
51
51
  export declare function computeEstimateOffset(usage: TokenUsage, messages: readonly Message[], systemPrompt: string): number;
52
52
  export interface PressureInput {
53
+ windowSource?: Pressure['windowSource'];
53
54
  /** Authoritative usage of the last completed turn, or `undefined` (§3.4.2). */
54
55
  lastUsage?: TokenUsage;
55
56
  messages: readonly Message[];
@@ -133,6 +133,7 @@ export function computePressure(input) {
133
133
  const ratio = window > 0 ? Math.max(0, Math.min(1, occupied / window)) : 0;
134
134
  return {
135
135
  occupied,
136
+ ...(input.windowSource ? { windowSource: input.windowSource } : {}),
136
137
  contextWindow: window,
137
138
  ratio,
138
139
  headroom: Math.max(0, window - occupied),
@@ -51,6 +51,7 @@ export type CompactionUiTrigger = 'pressure' | 'overflow' | 'manual';
51
51
  /** Whether a figure was measured by the provider or estimated by us. */
52
52
  export type PressureSource = 'usage' | 'estimate';
53
53
  export interface Pressure {
54
+ windowSource?: 'api' | 'catalog' | 'fallback';
54
55
  occupied: number;
55
56
  contextWindow: number;
56
57
  /** `occupied / contextWindow`, clamped to [0, 1]. */
@@ -115,6 +116,7 @@ export interface Pressure {
115
116
  * view state, and `toContextUsage` in `meter.ts` is the ONLY converter.
116
117
  */
117
118
  export interface ContextUsageSnapshot {
119
+ windowSource?: Pressure['windowSource'];
118
120
  /** Occupied tokens (measured, plus an estimate of anything appended since). */
119
121
  occupied: number;
120
122
  /** The denominator actually used. */
@@ -135,7 +135,10 @@ export class CompactionWiring {
135
135
  getMessages: deps.getMessages,
136
136
  getSystemPrompt: deps.getSystemPrompt,
137
137
  getModelInfo: () => deps.getModelInfoFor(this.mainRef()),
138
- isWindowKnown: () => deps.isPricedModel(this.mainRef()),
138
+ isWindowKnown: () => {
139
+ const source = deps.getModelInfoFor(this.mainRef()).contextWindowSource;
140
+ return source === undefined ? deps.isPricedModel(this.mainRef()) : source !== 'fallback';
141
+ },
139
142
  getWindowOverride: () => deps.getConfig().contextWindow,
140
143
  });
141
144
  this.compactor = new Compactor({
@@ -241,7 +241,9 @@ export interface CliFlags {
241
241
  * Resolve the effective config by merging all layers, then derive the runtime
242
242
  * (non-persisted) fields and enforce the timeout invariant.
243
243
  */
244
- export declare function loadConfig(flags?: CliFlags): CliConfig;
244
+ export declare function loadConfig(flags?: CliFlags, options?: {
245
+ interactive?: boolean;
246
+ }): CliConfig;
245
247
  /**
246
248
  * Build the `getApiKey(providerId)` resolver the core `Agent` consumes.
247
249
  * The one-shot `--api-key` override wins, but only for the active provider.
@@ -16,7 +16,7 @@ import { setEntryRetain } from '../agent/entry-limits.js';
16
16
  import { readSubmitCount } from './ui-state.js';
17
17
  import { getLogger } from '../logging/logger.js';
18
18
  import { registerSecret, registerSecretsFrom } from '../logging/secret-registry.js';
19
- import { detectCapabilities } from '../ui/capabilities.js';
19
+ import { detectCapabilities, resolveTuiCapabilities } from '../ui/capabilities.js';
20
20
  /** Interpret a boolean-ish env value (`1/true/on/yes`) as `true`. */
21
21
  function envFlagTrue(value) {
22
22
  if (!value)
@@ -436,7 +436,7 @@ function resolveContextWindow(env, file) {
436
436
  * Resolve the effective config by merging all layers, then derive the runtime
437
437
  * (non-persisted) fields and enforce the timeout invariant.
438
438
  */
439
- export function loadConfig(flags = {}) {
439
+ export function loadConfig(flags = {}, options = {}) {
440
440
  const cwd = flags.cwd ? flags.cwd : process.cwd();
441
441
  // `.env` must be loaded before reading env overrides.
442
442
  loadDotenv(cwd);
@@ -519,13 +519,17 @@ export function loadConfig(flags = {}) {
519
519
  // registering twice costs a `Set.add`, missing once costs a credential.
520
520
  registerSecretsFrom(apiKeys);
521
521
  registerSecret(flags.apiKey);
522
- const color = flags.color !== undefined ? flags.color : !process.env.NO_COLOR;
522
+ const caps = options.interactive
523
+ ? resolveTuiCapabilities(process.env, flags.color)
524
+ : detectCapabilities(process.env, process.stdout);
525
+ const color = options.interactive
526
+ ? caps.colorLevel !== 0
527
+ : flags.color !== undefined ? flags.color : !process.env.NO_COLOR;
523
528
  // `--no-color` implies calmer chrome; env / file may also opt in explicitly.
524
529
  const reducedMotion = envFlagTrue(process.env.ARAGON_REDUCED_MOTION) ||
525
530
  (file.reducedMotion ?? DEFAULT_CONFIG.reducedMotion) ||
526
531
  !color;
527
532
  // Detect terminal capabilities once; force monochrome when color is disabled.
528
- const caps = detectCapabilities(process.env, process.stdout);
529
533
  const colorLevel = color ? caps.colorLevel : 0;
530
534
  // No flag and no env var: a privacy switch you set once, not a per-run knob.
531
535
  const historyEnabled = file.historyEnabled ?? DEFAULT_CONFIG.historyEnabled;
@@ -1097,9 +1097,9 @@ export interface CliConfig {
1097
1097
  cwd: string;
1098
1098
  /** Whether ANSI color is enabled. */
1099
1099
  color: boolean;
1100
- /** Detected terminal color depth (0 none · 1 16 · 2 256 · 3 truecolor). */
1100
+ /** Resolved color depth: TUI policy or non-interactive terminal detection. */
1101
1101
  colorLevel?: 0 | 1 | 2 | 3;
1102
- /** Detected Unicode support (box/round glyphs & spinners). */
1102
+ /** TUI always enables Unicode; non-interactive output uses detection. */
1103
1103
  unicode?: boolean;
1104
1104
  /** One-shot `--api-key` override; applies to the active provider only. */
1105
1105
  apiKeyOverride?: string;
package/dist/ui/App.js CHANGED
@@ -8,7 +8,6 @@ import { selectTranscriptEntries, mergePendingEntries } from '../agent/queued-me
8
8
  * input / toast stack / status bar / overlays.
9
9
  */
10
10
  import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
11
- import process from 'node:process';
12
11
  import { Text, useApp, useInput, useStdout } from 'ink';
13
12
  import { advanceInterruptGesture, cancelInterruptConfirmation, } from '../input/interrupt-gesture.js';
14
13
  import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
@@ -22,7 +21,7 @@ import { getLogger } from '../logging/logger.js';
22
21
  import { isTerminalStatus } from '../proc/types.js';
23
22
  import { getSecrets } from '../logging/secret-registry.js';
24
23
  import { redactText } from '../logging/redact.js';
25
- import { detectCapabilities } from './capabilities.js';
24
+ import { resolveTuiCapabilities } from './capabilities.js';
26
25
  import { useStartupNotices, MOUSE_NOTICE_VERSION, VT_INPUT_NOTICE_VERSION, } from './use-startup-notices.js';
27
26
  import { getTheme } from './theme.js';
28
27
  import { pickGlyphs } from './glyphs.js';
@@ -206,13 +205,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
206
205
  // the completion list are memoized against it, so a skill installed mid-session
207
206
  // gets its `/<name>` command and its autocomplete entry immediately.
208
207
  const [skillsNonce, setSkillsNonce] = useState(0);
209
- const caps = useMemo(() => {
210
- const detected = detectCapabilities(process.env, stdout);
211
- return {
212
- colorLevel: cfg.color === false ? 0 : cfg.colorLevel ?? detected.colorLevel,
213
- unicode: cfg.unicode ?? detected.unicode,
214
- };
215
- }, [cfg.color, cfg.colorLevel, cfg.unicode, stdout]);
208
+ const caps = useMemo(() => resolveTuiCapabilities({}, cfg.color), [cfg.color]);
216
209
  const theme = useMemo(() => getTheme(cfg.theme, caps), [cfg.theme, caps]);
217
210
  const glyphs = useMemo(() => pickGlyphs(caps), [caps]);
218
211
  const reducedMotion = cfg.reducedMotion ?? false;
@@ -1956,7 +1949,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1956
1949
  // The wordmark + getting-started card now live INSIDE the viewport, as its
1957
1950
  // first block of content, so they scroll away instead of being yanked out of
1958
1951
  // the header the moment the first message lands.
1959
- const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(viewportBudget, contentCols, caps), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: viewportBudget, theme: theme, caps: caps })) : null;
1952
+ const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(viewportBudget, contentCols), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: viewportBudget, theme: theme, caps: caps })) : null;
1960
1953
  const density = cfg.density;
1961
1954
  const transcriptWindow = cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW;
1962
1955
  // The fixed status bar remains visible underneath every overlay and toast.
package/dist/ui/Logo.d.ts CHANGED
@@ -19,12 +19,10 @@ export declare function pickHeaderVariant(cols: number): HeaderVariant;
19
19
  * now spends the viewport's budget, not the frame's: the test has to be against
20
20
  * the account being charged.
21
21
  *
22
- * No hand-drawn ASCII fallback for `art`: figlet-style art is hard to keep
23
- * column-aligned, and a terminal without Unicode should not be paying six rows
24
- * for a wordmark in the first place. `banner` still carries the brand there, and
25
- * the always-present header bar carries it even at `none` (R1).
22
+ * Color preferences must not change geometry: a monochrome TUI gets the same
23
+ * wordmark as a colored one when there is room for it.
26
24
  */
27
- export declare function pickOpenerVariant(viewportRows: number, cols: number, caps: TermCapabilities): OpenerVariant;
25
+ export declare function pickOpenerVariant(viewportRows: number, cols: number): OpenerVariant;
28
26
  export interface LogoProps {
29
27
  /** `art` / `banner` come from `SessionOpener`; `bar` / `mini` from `Header`. */
30
28
  variant: HeaderVariant | OpenerVariant;
package/dist/ui/Logo.js CHANGED
@@ -32,16 +32,12 @@ export function pickHeaderVariant(cols) {
32
32
  * now spends the viewport's budget, not the frame's: the test has to be against
33
33
  * the account being charged.
34
34
  *
35
- * No hand-drawn ASCII fallback for `art`: figlet-style art is hard to keep
36
- * column-aligned, and a terminal without Unicode should not be paying six rows
37
- * for a wordmark in the first place. `banner` still carries the brand there, and
38
- * the always-present header bar carries it even at `none` (R1).
35
+ * Color preferences must not change geometry: a monochrome TUI gets the same
36
+ * wordmark as a colored one when there is room for it.
39
37
  */
40
- export function pickOpenerVariant(viewportRows, cols, caps) {
38
+ export function pickOpenerVariant(viewportRows, cols) {
41
39
  if (viewportRows >= ART_MIN_VIEWPORT_ROWS &&
42
- cols >= ART_MIN_COLS &&
43
- caps.unicode &&
44
- caps.colorLevel >= 2) {
40
+ cols >= ART_MIN_COLS) {
45
41
  return 'art';
46
42
  }
47
43
  if (cols >= BANNER_MIN_COLS)
@@ -334,7 +334,9 @@ export function PromptInput({ cols: colsProp, cursorVisible = true, onInteractio
334
334
  cursor,
335
335
  cols: wrapCols,
336
336
  maxRows: draftMaxRows(terminalRows),
337
- active: isActive && cursorVisible,
337
+ // Scrolling may hide the caret, but must not change the measured draft.
338
+ // Otherwise visibility can toggle wrapping and feed back into scrolling.
339
+ active: isActive,
338
340
  });
339
341
  // TWO PASSES, AND THE SECOND ONE IS LOAD-BEARING (I-8). The overflow indicator
340
342
  // rides the last rendered row INSIDE the box the draft wraps in, so its cells
@@ -22,4 +22,6 @@ interface StdoutLike {
22
22
  * for future TTY-aware refinements and kept in the signature for callers.
23
23
  */
24
24
  export declare function detectCapabilities(env: EnvLike, _stdout?: StdoutLike): TermCapabilities;
25
+ /** Full TUI styling is a policy, not a guess based on inherited shell markers. */
26
+ export declare function resolveTuiCapabilities(env: EnvLike, color?: boolean): TermCapabilities;
25
27
  export {};
@@ -57,3 +57,9 @@ export function detectCapabilities(env, _stdout) {
57
57
  unicode: detectUnicode(env),
58
58
  };
59
59
  }
60
+ /** Full TUI styling is a policy, not a guess based on inherited shell markers. */
61
+ export function resolveTuiCapabilities(env, color) {
62
+ const disabled = color === false || env.NO_COLOR !== undefined ||
63
+ env.FORCE_COLOR === '0' || env.FORCE_COLOR === 'false';
64
+ return { unicode: true, colorLevel: disabled ? 0 : 3 };
65
+ }
@@ -0,0 +1,5 @@
1
+ import type { TermCapabilities } from './capabilities.js';
2
+ /** Highlight cache entries must not cross color preferences between sessions. */
3
+ export declare function syntaxColorLevel(): number;
4
+ /** Apply session colors without changing environment inherited by agent tools. */
5
+ export declare function useTuiColors(level: TermCapabilities['colorLevel']): () => void;
@@ -0,0 +1,29 @@
1
+ import { createRequire } from 'node:module';
2
+ import { pathToFileURL } from 'node:url';
3
+ import chalk from 'chalk';
4
+ const require = createRequire(import.meta.url);
5
+ /** Resolve the actual dependency instance even when npm nests its Chalk. */
6
+ async function dependencyChalk(packageName) {
7
+ const dependency = createRequire(require.resolve(packageName));
8
+ return (await import(pathToFileURL(dependency.resolve('chalk')).href)).default;
9
+ }
10
+ const [inkChalk, highlightChalk] = await Promise.all([
11
+ dependencyChalk('ink'), dependencyChalk('cli-highlight'),
12
+ ]);
13
+ /** Highlight cache entries must not cross color preferences between sessions. */
14
+ export function syntaxColorLevel() { return highlightChalk.level; }
15
+ /** Apply session colors without changing environment inherited by agent tools. */
16
+ export function useTuiColors(level) {
17
+ const renderers = new Set([chalk, inkChalk, highlightChalk]);
18
+ const previous = new Map([...renderers].map(renderer => [renderer, renderer.level]));
19
+ for (const renderer of renderers)
20
+ renderer.level = level;
21
+ let restored = false;
22
+ return () => {
23
+ if (restored)
24
+ return;
25
+ restored = true;
26
+ for (const [renderer, original] of previous)
27
+ renderer.level = original;
28
+ };
29
+ }
package/dist/ui/glyphs.js CHANGED
@@ -24,18 +24,20 @@ const UNICODE_GLYPHS = {
24
24
  thinking: '✱',
25
25
  toolPending: '◦',
26
26
  toolRunning: '◍',
27
- toolDone: '✔',
28
- toolError: '✖',
29
- info: 'ℹ',
27
+ // U+2714 has conflicting emoji widths in Ink and string-width. U+2713 is
28
+ // consistently one cell, including beside the right-edge scrollbar.
29
+ toolDone: '✓',
30
+ toolError: '×',
31
+ info: 'ⓘ',
30
32
  warn: '▲',
31
- error: '✖',
33
+ error: '×',
32
34
  bullet: '•',
33
35
  spinnerStill: '·',
34
36
  gaugeFull: '█',
35
37
  gaugeEmpty: '░',
36
38
  keyOn: '●',
37
39
  keyOff: '○',
38
- boxChecked: '☑',
40
+ boxChecked: '☒',
39
41
  boxEmpty: '☐',
40
42
  wordmark: '◇',
41
43
  ellipsis: '…',
@@ -60,10 +62,10 @@ const UNICODE_GLYPHS = {
60
62
  maskDot: '•',
61
63
  boxStyle: 'round',
62
64
  teamAgent: '◆',
63
- teamMail: '✉',
65
+ teamMail: '⇄',
64
66
  todoPending: '○',
65
67
  todoActive: '▸',
66
- todoDone: '✔',
68
+ todoDone: '✓',
67
69
  retry: '↻',
68
70
  compaction: '⤓',
69
71
  queued: '◷',
@@ -78,7 +80,7 @@ const UNICODE_GLYPHS = {
78
80
  ask_user: '?',
79
81
  submit_plan: '◈',
80
82
  task: '◆',
81
- todo_write: '☰',
83
+ todo_write: '≡',
82
84
  },
83
85
  toolDefault: '•',
84
86
  };
@@ -1 +1,4 @@
1
+ import type { TermCapabilities } from './capabilities.js';
1
2
  export declare const render: (node: import("react").ReactNode, options?: NodeJS.WriteStream | import("ink").RenderOptions) => import("ink").Instance;
3
+ /** Mount the TUI with its resolved colors, restoring all renderers on exit. */
4
+ export declare function renderTui(node: Parameters<typeof render>[0], options: Parameters<typeof render>[1], caps: TermCapabilities): ReturnType<typeof render>;
@@ -19,3 +19,33 @@ async function loadInk() {
19
19
  }
20
20
  }
21
21
  export const { render } = await loadInk();
22
+ // Keep asynchronous dependency resolution after Ink's CI initialization. A
23
+ // static import here would let sibling UI modules load Ink before CI is reset.
24
+ const { useTuiColors } = await import('./color-runtime.js');
25
+ /** Mount the TUI with its resolved colors, restoring all renderers on exit. */
26
+ export function renderTui(node, options, caps) {
27
+ const restore = useTuiColors(caps.colorLevel);
28
+ try {
29
+ // Ink creates its exit promise lazily and does not replay earlier exits.
30
+ // Mount the host first, then subscribe before application render or layout
31
+ // effects can fail or call useApp().exit().
32
+ const instance = render(null, options);
33
+ void instance.waitUntilExit().then(restore, restore);
34
+ instance.rerender(node);
35
+ return {
36
+ ...instance,
37
+ unmount() {
38
+ try {
39
+ instance.unmount();
40
+ }
41
+ finally {
42
+ restore();
43
+ }
44
+ },
45
+ };
46
+ }
47
+ catch (error) {
48
+ restore();
49
+ throw error;
50
+ }
51
+ }
@@ -41,7 +41,8 @@ function contextText(context, detail = false, exponent = false) {
41
41
  return copy.contextUnknown;
42
42
  const number = exponent ? scientific : detail ? formatTokens : compactNumber;
43
43
  const approximate = context.source === 'estimate' || context.deltaTokens > 0 ? '~' : '';
44
- return `${copy.context} ${approximate}${number(context.occupied)}/${number(context.window)}${context.windowKnown ? '' : '?'} ${detail ? 'tokens' : 'tok'}`;
44
+ const limit = context.windowKnown ? number(context.window) : '?';
45
+ return `${copy.context} ${approximate}${number(context.occupied)}/${limit} ${detail ? 'tokens' : 'tok'}`;
45
46
  }
46
47
  function secondaryFields(input) {
47
48
  const list = [];
@@ -60,7 +61,7 @@ function secondaryFields(input) {
60
61
  if (input.thinkingLevel !== 'off')
61
62
  list.push(field('thinking', `think:${cleanStatusText(input.thinkingLevel)}`));
62
63
  list.push(field('usage', `${copy.sessionInput} ${compactNumber(promptTokensOf(input.usageTotal))} ${copy.output} ${compactNumber(input.usageTotal.outputTokens)} tok`));
63
- if (Number.isFinite(input.context.pct))
64
+ if (input.context.windowKnown && Number.isFinite(input.context.pct))
64
65
  list.push(field('percentage', `${input.context.source === 'estimate' || input.context.deltaTokens > 0 || !input.context.windowKnown ? '~' : ''}${Math.round(input.context.pct)}%`));
65
66
  if (input.ecoRung)
66
67
  list.push(field('eco', 'eco'));
@@ -12,7 +12,7 @@
12
12
  * - `Markdown` re-split and re-regexed the whole answer into one React element
13
13
  * per line on every render.
14
14
  *
15
- * Everything cached here is a function of its INPUT STRING ALONE. Theme and
15
+ * Cached text depends on its input and, for highlighting, the color level. Theme and
16
16
  * glyphs are applied at render time and are never cached, because a themed
17
17
  * React element in a cache makes `/theme` a no-op until the entry evicts (K-5) —
18
18
  * exactly the class of silent bug this package writes comments to avoid.
@@ -50,7 +50,7 @@ export interface RenderCacheStats {
50
50
  misses: number;
51
51
  }
52
52
  /**
53
- * Syntax-highlight `code`, memoised on `(lang, code)`.
53
+ * Syntax-highlight `code`, memoised on `(colorLevel, lang, code)`.
54
54
  *
55
55
  * `cli-highlight` emits chalk-level ANSI that does not depend on `theme.*`, so
56
56
  * unlike a rendered element this string IS safe to cache across a `/theme`
@@ -12,7 +12,7 @@
12
12
  * - `Markdown` re-split and re-regexed the whole answer into one React element
13
13
  * per line on every render.
14
14
  *
15
- * Everything cached here is a function of its INPUT STRING ALONE. Theme and
15
+ * Cached text depends on its input and, for highlighting, the color level. Theme and
16
16
  * glyphs are applied at render time and are never cached, because a themed
17
17
  * React element in a cache makes `/theme` a no-op until the entry evicts (K-5) —
18
18
  * exactly the class of silent bug this package writes comments to avoid.
@@ -22,6 +22,7 @@
22
22
  * caches would miss precisely when a scroll brings an old entry back.
23
23
  */
24
24
  import { highlight } from 'cli-highlight';
25
+ import { syntaxColorLevel } from './color-runtime.js';
25
26
  import { parseMarkdownBlocks } from './markdown-blocks.js';
26
27
  import { pickGlyphs } from './glyphs.js';
27
28
  export const RENDER_CACHE_LIMITS = {
@@ -113,7 +114,7 @@ const markdownCache = new LruCache(RENDER_CACHE_LIMITS.markdownEntries, RENDER_C
113
114
  */
114
115
  const lineCache = new LruCache(RENDER_CACHE_LIMITS.linesEntries, RENDER_CACHE_LIMITS.markdownBytes);
115
116
  /**
116
- * Syntax-highlight `code`, memoised on `(lang, code)`.
117
+ * Syntax-highlight `code`, memoised on `(colorLevel, lang, code)`.
117
118
  *
118
119
  * `cli-highlight` emits chalk-level ANSI that does not depend on `theme.*`, so
119
120
  * unlike a rendered element this string IS safe to cache across a `/theme`
@@ -129,7 +130,7 @@ export function highlightCached(code, lang) {
129
130
  // ripgrep, which silently hides every definition in it from the tool this
130
131
  // codebase navigates itself with. NUL rather than a space because a space
131
132
  // still lets ("a b", "ts") and ("b", "ts a") collide on one key.
132
- const key = `${lang}\u0000${code}`;
133
+ const key = `${syntaxColorLevel()}\u0000${lang}\u0000${code}`;
133
134
  const hit = highlightCache.get(key);
134
135
  if (hit !== undefined)
135
136
  return hit;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.13",
3
+ "version": "0.6.14",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@aragon-agent/core": "^0.2.25",
59
+ "@aragon-agent/core": "^0.2.26",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",