@animalabs/connectome-host 0.5.2 → 0.5.3

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
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.3 — 2026-07-26
6
+
7
+ ### Added
8
+
9
+ - **Dry-run buttons, and the resulting context in the main pane.** Settings now
10
+ has explicit `dry run` and `dry run + show context` buttons; the latter
11
+ renders the context those settings WOULD produce in a new main-pane view,
12
+ behind an unmissable "dry run — not applied" banner. Entries come from
13
+ context-manager's dry-run select, so it is the actual layout, not an estimate.
14
+ - Dry runs report how long they took, and the panel states the cost up front: a
15
+ full compile, seconds on a large store, briefly pausing the agent.
16
+
17
+ ### Fixed
18
+
19
+ - **Preview no longer fires on every keystroke.** It was debounced-on-input, but
20
+ a dry run is a real compile and `select()` is synchronous — so each one blocks
21
+ the agent's event loop (no heartbeat, no Discord, no MCPL). Typing a budget
22
+ stacked those stalls and made the UI look hung. It is now operator-initiated
23
+ only, with server-side single-flight and a 3s cooldown that returns 429 rather
24
+ than queueing more agent pauses.
25
+ - `middleChunkCount` was labelled "middle chunks" but the adaptive picker's unit
26
+ is the MESSAGE (14,057 for a store with 800 chunks). Relabelled "middle
27
+ messages (picker units)".
28
+ - The sidebar was `w-72` (288px) and clipped the dense numeric tables; now
29
+ `w-96`, wider still on xl displays.
30
+
5
31
  ## 0.5.2 — 2026-07-26
6
32
 
7
33
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/connectome-host",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "General-purpose agent TUI host with recipe-based configuration",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1200,6 +1200,18 @@ export class WebUiModule implements Module {
1200
1200
  * Returns 501 when the resolved context-manager predates dry-run support, so
1201
1201
  * the panel can say so instead of rendering an empty result.
1202
1202
  */
1203
+ /**
1204
+ * Single-flight + cooldown for preview.
1205
+ *
1206
+ * A preview is a real compile: ~8s on a large store, and `select()` is
1207
+ * synchronous so it BLOCKS the agent's event loop for that whole time (no
1208
+ * heartbeat, no Discord, no MCPL). Overlapping or rapid-fire previews
1209
+ * therefore don't just queue — they stack agent stalls. Reject instead.
1210
+ */
1211
+ private previewInFlight = false;
1212
+ private previewLastAt = 0;
1213
+ private static readonly PREVIEW_COOLDOWN_MS = 3_000;
1214
+
1203
1215
  private handleContextPreview(url: URL): Response {
1204
1216
  const app = sharedServer?.app;
1205
1217
  if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
@@ -1225,8 +1237,33 @@ export class WebUiModule implements Module {
1225
1237
  overrides.recentWindowTokens = tail;
1226
1238
  }
1227
1239
 
1240
+ // `render=1` additionally returns the rendered dry context for display.
1241
+ const wantRender = (() => {
1242
+ const v = url.searchParams.get('render');
1243
+ return v !== null && v !== '0' && v !== 'false';
1244
+ })();
1245
+
1246
+ if (this.previewInFlight) {
1247
+ return Response.json(
1248
+ { error: 'a preview is already running — it blocks the agent, so they are serialized' },
1249
+ { status: 429 },
1250
+ );
1251
+ }
1252
+ const sinceLast = Date.now() - this.previewLastAt;
1253
+ if (sinceLast < WebUiModule.PREVIEW_COOLDOWN_MS) {
1254
+ return Response.json(
1255
+ {
1256
+ error: `preview cooling down — ${Math.ceil((WebUiModule.PREVIEW_COOLDOWN_MS - sinceLast) / 1000)}s left. `
1257
+ + 'Each run is a full compile and briefly pauses the agent.',
1258
+ },
1259
+ { status: 429 },
1260
+ );
1261
+ }
1262
+
1228
1263
  const fw = app.framework as unknown as {
1229
- previewContextSettings?: (n: string, b: number, o?: Record<string, unknown>) => unknown;
1264
+ previewContextSettings?: (
1265
+ n: string, b: number, o?: Record<string, unknown>, x?: { render?: boolean },
1266
+ ) => unknown;
1230
1267
  };
1231
1268
  if (typeof fw.previewContextSettings !== 'function') {
1232
1269
  return Response.json(
@@ -1234,11 +1271,14 @@ export class WebUiModule implements Module {
1234
1271
  { status: 501 },
1235
1272
  );
1236
1273
  }
1274
+ this.previewInFlight = true;
1275
+ const startedAt = Date.now();
1237
1276
  try {
1238
1277
  const result = fw.previewContextSettings(
1239
1278
  agentName,
1240
1279
  budget,
1241
1280
  Object.keys(overrides).length > 0 ? overrides : undefined,
1281
+ wantRender ? { render: true } : undefined,
1242
1282
  );
1243
1283
  if (result === null || result === undefined) {
1244
1284
  return Response.json(
@@ -1284,6 +1324,8 @@ export class WebUiModule implements Module {
1284
1324
  /** Exhausted AND over the request => this budget is UNREACHABLE. */
1285
1325
  unreachable: r.exhausted === true && !fitsRequested,
1286
1326
  },
1327
+ /** How long the agent was blocked, so the operator sees the real cost. */
1328
+ elapsedMs: Date.now() - startedAt,
1287
1329
  preview: result,
1288
1330
  });
1289
1331
  } catch (err) {
@@ -1291,6 +1333,9 @@ export class WebUiModule implements Module {
1291
1333
  { error: err instanceof Error ? err.message : String(err) },
1292
1334
  { status: 500 },
1293
1335
  );
1336
+ } finally {
1337
+ this.previewInFlight = false;
1338
+ this.previewLastAt = Date.now();
1294
1339
  }
1295
1340
  }
1296
1341
 
package/web/src/App.tsx CHANGED
@@ -10,6 +10,7 @@ import { UsagePanel } from './Usage';
10
10
  import { LessonsPanel, type LessonRow } from './Lessons';
11
11
  import { McplPanel, type McplServerRow } from './Mcpl';
12
12
  import { SettingsPanel, type SettingsState } from './Settings';
13
+ import { DryContext, type DryContextData } from './DryContext';
13
14
  import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewer } from './Files';
14
15
  import { ContextPanel } from './Context';
15
16
  import { ContextDocument } from './ContextDocument';
@@ -275,7 +276,9 @@ export function App() {
275
276
  * the default; lessons / mcp / files are operator-driven panels. */
276
277
  type SidebarTab = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'health';
277
278
  const [sidebarTab, setSidebarTab] = createSignal<SidebarTab>('tree');
278
- const [mainView, setMainView] = createSignal<'chat' | 'context'>('chat');
279
+ const [mainView, setMainView] = createSignal<'chat' | 'context' | 'dry'>('chat');
280
+ /** Rendered dry-run context awaiting display. Never applied — see DryContext. */
281
+ const [dryContext, setDryContext] = createSignal<DryContextData | null>(null);
279
282
 
280
283
  /** Scope shared by Lessons / Files / Recipe panels. 'local' means the
281
284
  * parent process; otherwise the fleet child's name. The scope is shared
@@ -1075,13 +1078,19 @@ export function App() {
1075
1078
  <div class="flex border-b border-neutral-800 bg-neutral-900/40 text-[11px] font-mono">
1076
1079
  <button type="button" class={`px-3 py-1.5 ${mainView() === 'chat' ? 'text-neutral-100 bg-neutral-900 border-b border-cyan-700' : 'text-neutral-500 hover:text-neutral-300'}`} onClick={() => setMainView('chat')}>Chat</button>
1077
1080
  <button type="button" class={`px-3 py-1.5 ${mainView() === 'context' ? 'text-neutral-100 bg-neutral-900 border-b border-cyan-700' : 'text-neutral-500 hover:text-neutral-300'}`} onClick={() => setMainView('context')}>Context</button>
1081
+ <Show when={dryContext()}>
1082
+ <button type="button" class={`px-3 py-1.5 ${mainView() === 'dry' ? 'text-amber-200 bg-neutral-900 border-b border-amber-600' : 'text-amber-500/70 hover:text-amber-300'}`} onClick={() => setMainView('dry')}>Dry run</button>
1083
+ </Show>
1078
1084
  </div>
1079
1085
  <div
1080
1086
  ref={scrollPane}
1081
1087
  class="flex-1 overflow-y-auto px-4 py-3 space-y-3"
1082
1088
  onScroll={onScrollPane}
1083
1089
  >
1084
- <Show when={mainView() === 'context'} fallback={<>
1090
+ <Show when={mainView() === 'dry'}>
1091
+ <DryContext data={dryContext()} onClose={() => { setDryContext(null); setMainView('chat'); }} />
1092
+ </Show>
1093
+ <Show when={mainView() === 'context'} fallback={<Show when={mainView() === 'chat'}>
1085
1094
  <Show when={(historyInfo()?.startIndex ?? 0) > 0}>
1086
1095
  <button
1087
1096
  type="button"
@@ -1102,7 +1111,7 @@ export function App() {
1102
1111
  <For each={messages}>{(m) => (
1103
1112
  <MessageView msg={m} results={toolResults()} toolUseIds={toolUseIds()} />
1104
1113
  )}</For>
1105
- </>}>
1114
+ </Show>}>
1106
1115
  <ContextDocument agent={panelScope() === 'local' ? undefined : panelScope()} />
1107
1116
  </Show>
1108
1117
  </div>
@@ -1179,7 +1188,9 @@ export function App() {
1179
1188
  onClose={closePanel}
1180
1189
  />
1181
1190
  </Show>
1182
- <aside class="w-72 border-l border-neutral-800 bg-neutral-950 shrink-0 flex flex-col">
1191
+ {/* Was w-72 (288px): the context/settings panels have dense numeric
1192
+ tables that did not fit. Widen, and give large displays more. */}
1193
+ <aside class="w-96 xl:w-[30rem] border-l border-neutral-800 bg-neutral-950 shrink-0 flex flex-col">
1183
1194
  <SidebarTabs
1184
1195
  current={sidebarTab()}
1185
1196
  onSelect={(tab) => {
@@ -1237,6 +1248,7 @@ export function App() {
1237
1248
  onReset={(keys, persist) =>
1238
1249
  wire.send({ type: 'settings-reset', ...(keys ? { keys } : {}), persist })}
1239
1250
  onCancelTransition={() => wire.send({ type: 'settings-cancel-transition' })}
1251
+ onDryContext={(ctx) => { setDryContext(ctx as DryContextData); setMainView('dry'); }}
1240
1252
  />
1241
1253
  </Show>
1242
1254
  <Show when={sidebarTab() === 'files'}>
@@ -0,0 +1,163 @@
1
+ /**
2
+ * DryContext — the context a hypothetical settings change WOULD produce,
3
+ * rendered in the main pane.
4
+ *
5
+ * Distinct from `ContextDocument`, which shows the agent's live context. This
6
+ * one shows the output of a dry run: nothing here has been applied, and the
7
+ * agent is still running the settings shown in the sidebar. The banner says so,
8
+ * because a rendered context that looks exactly like the real thing is a good
9
+ * way for an operator to lose track of which is which.
10
+ *
11
+ * Entries come straight from context-manager's dry-run select, so this is the
12
+ * actual layout that budget would produce — not an approximation.
13
+ */
14
+
15
+ import { createSignal, For, Show } from 'solid-js';
16
+
17
+ interface Seg { messages: number; tokens: number }
18
+ interface DryStats {
19
+ head: Seg; tail: Seg; middleRaw: Seg;
20
+ summaries?: Record<string, { count: number; tokens: number }>;
21
+ total: Seg;
22
+ }
23
+
24
+ export interface DryContextData {
25
+ entries: unknown[];
26
+ stats?: unknown;
27
+ label: string;
28
+ }
29
+
30
+ const fmt = (n: number) => n.toLocaleString();
31
+
32
+ function textOf(content: unknown): string {
33
+ if (Array.isArray(content)) {
34
+ return content
35
+ .map((b) => {
36
+ if (!b || typeof b !== 'object') return String(b ?? '');
37
+ const t = (b as { type?: string }).type;
38
+ if (t === 'text') return (b as { text?: string }).text ?? '';
39
+ if (t === 'image') return '[image]';
40
+ if (t === 'thinking' || t === 'redacted_thinking') return '[thinking]';
41
+ if (t === 'tool_use') return `[tool_use ${(b as { name?: string }).name ?? ''}]`;
42
+ if (t === 'tool_result') return '[tool_result]';
43
+ return '';
44
+ })
45
+ .join('');
46
+ }
47
+ return String(content ?? '');
48
+ }
49
+
50
+ /** Recall pairs render as a CM prompt followed by the summary text. */
51
+ const RECALL_MARKERS = ['what do you remember', 'recall memory', '[cm]'];
52
+ const isRecallPrompt = (s: string) =>
53
+ s.length < 400 && RECALL_MARKERS.some((m) => s.toLowerCase().includes(m));
54
+
55
+ export function DryContext(props: { data: DryContextData | null; onClose(): void }) {
56
+ const [expanded, setExpanded] = createSignal<Set<number>>(new Set());
57
+ const toggle = (i: number) => {
58
+ const next = new Set(expanded());
59
+ next.has(i) ? next.delete(i) : next.add(i);
60
+ setExpanded(next);
61
+ };
62
+
63
+ const rows = () =>
64
+ (props.data?.entries ?? []).map((e, i) => {
65
+ const o = (e ?? {}) as { participant?: string; role?: string; content?: unknown };
66
+ const body = textOf(o.content);
67
+ return {
68
+ i,
69
+ who: o.participant ?? o.role ?? '?',
70
+ body,
71
+ recall: isRecallPrompt(body),
72
+ chars: body.length,
73
+ };
74
+ });
75
+
76
+ const stats = () => props.data?.stats as DryStats | undefined;
77
+
78
+ return (
79
+ <div class="h-full overflow-y-auto">
80
+ <Show
81
+ when={props.data}
82
+ fallback={
83
+ <div class="p-6 text-neutral-500 text-sm">
84
+ No dry run yet. Open <span class="font-mono text-neutral-400">Settings</span> in the
85
+ sidebar, set a budget, and press{' '}
86
+ <span class="font-mono text-cyan-300">dry run + show context</span>.
87
+ </div>
88
+ }
89
+ >
90
+ {/* Unmissable: this is NOT the live context. */}
91
+ <div class="sticky top-0 z-10 bg-amber-950/80 backdrop-blur border-b border-amber-800
92
+ px-4 py-2 text-[11px] text-amber-200 flex items-center gap-3 flex-wrap">
93
+ <span class="font-semibold uppercase tracking-wider">dry run — not applied</span>
94
+ <span class="font-mono text-amber-300/90">{props.data!.label}</span>
95
+ <span class="text-amber-300/70">
96
+ the agent is still running its current settings
97
+ </span>
98
+ <button
99
+ type="button"
100
+ class="ml-auto px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700
101
+ text-neutral-300 font-mono text-[10px]"
102
+ onClick={() => props.onClose()}
103
+ >
104
+ close
105
+ </button>
106
+ </div>
107
+
108
+ <Show when={stats()}>
109
+ <div class="px-4 py-2 border-b border-neutral-800 text-[11px] font-mono
110
+ text-neutral-400 flex gap-4 flex-wrap">
111
+ <span>entries <span class="text-neutral-200">{fmt(rows().length)}</span></span>
112
+ <span>head <span class="text-neutral-200">{fmt(stats()!.head?.tokens ?? 0)}</span></span>
113
+ <span>middle <span class="text-neutral-200">{fmt(stats()!.middleRaw?.tokens ?? 0)}</span></span>
114
+ <span>tail <span class="text-neutral-200">{fmt(stats()!.tail?.tokens ?? 0)}</span></span>
115
+ <span>total <span class="text-neutral-200">{fmt(stats()!.total?.tokens ?? 0)}</span></span>
116
+ </div>
117
+ </Show>
118
+
119
+ <div class="px-4 py-3 space-y-2">
120
+ <For each={rows()}>
121
+ {(r) => (
122
+ <div
123
+ class={`rounded border px-2.5 py-1.5 ${
124
+ r.recall
125
+ ? 'border-orange-900/60 bg-orange-950/20'
126
+ : 'border-neutral-800 bg-neutral-900/40'
127
+ }`}
128
+ >
129
+ <div class="flex items-baseline gap-2 mb-0.5">
130
+ <span class="text-[10px] font-mono text-neutral-500">#{r.i}</span>
131
+ <span
132
+ class={`text-[11px] font-mono ${
133
+ r.recall ? 'text-orange-300' : 'text-cyan-300'
134
+ }`}
135
+ >
136
+ {r.who}
137
+ </span>
138
+ <span class="text-[10px] text-neutral-600">{fmt(r.chars)} chars</span>
139
+ <Show when={r.chars > 600}>
140
+ <button
141
+ type="button"
142
+ class="ml-auto text-[10px] font-mono text-neutral-500 hover:text-neutral-300"
143
+ onClick={() => toggle(r.i)}
144
+ >
145
+ {expanded().has(r.i) ? 'collapse' : 'expand'}
146
+ </button>
147
+ </Show>
148
+ </div>
149
+ <div
150
+ class="text-[12px] text-neutral-300 whitespace-pre-wrap break-words leading-relaxed"
151
+ >
152
+ {expanded().has(r.i) || r.chars <= 600
153
+ ? r.body
154
+ : r.body.slice(0, 600) + ' …'}
155
+ </div>
156
+ </div>
157
+ )}
158
+ </For>
159
+ </div>
160
+ </Show>
161
+ </div>
162
+ );
163
+ }
@@ -15,7 +15,7 @@
15
15
  * that looks like "nothing would change".
16
16
  */
17
17
 
18
- import { createSignal, Show, For, onCleanup } from 'solid-js';
18
+ import { createSignal, Show, For } from 'solid-js';
19
19
 
20
20
  export interface SettingsSnapshot {
21
21
  contextBudgetTokens: number;
@@ -101,6 +101,8 @@ export function SettingsPanel(props: {
101
101
  }): void;
102
102
  onReset(keys: string[] | undefined, persist: boolean): void;
103
103
  onCancelTransition(): void;
104
+ /** Hand a rendered dry context to the main pane for display. */
105
+ onDryContext?(ctx: { entries: unknown[]; stats?: unknown; label: string }): void;
104
106
  }) {
105
107
  const [budget, setBudget] = createSignal<string>('');
106
108
  const [tail, setTail] = createSignal<string>('');
@@ -113,8 +115,7 @@ export function SettingsPanel(props: {
113
115
  const [previewErr, setPreviewErr] = createSignal<string | null>(null);
114
116
  const [previewing, setPreviewing] = createSignal(false);
115
117
 
116
- let debounce: number | undefined;
117
- onCleanup(() => { if (debounce) window.clearTimeout(debounce); });
118
+ const [elapsed, setElapsed] = createSignal<number | null>(null);
118
119
 
119
120
  const s = () => props.state?.settings;
120
121
  const isHot = (k: string) => props.state?.hotKeys.includes(k) ?? false;
@@ -134,38 +135,55 @@ export function SettingsPanel(props: {
134
135
  return Number.isSafeInteger(n) && n > 0 ? n : undefined;
135
136
  };
136
137
 
137
- /** Debounced: an operator dragging a value should not fire a compile per keystroke.
138
- * The request itself is non-committing, but it is still real CPU on a live agent. */
139
- const runPreview = () => {
140
- if (!props.state?.previewAvailable) return;
138
+ /**
139
+ * Explicit dry run. NOT wired to input events on purpose.
140
+ *
141
+ * A dry run is a real compile: seconds on a large store, and the compile is
142
+ * synchronous, so it briefly blocks the agent's event loop — no heartbeat, no
143
+ * Discord, no MCPL for the duration. An earlier version of this panel fired it
144
+ * on every keystroke (debounced), which stacked agent stalls and made the UI
145
+ * look hung. The operator asks for it now.
146
+ *
147
+ * `render` also fetches the resulting context for the main pane.
148
+ */
149
+ const runDryRun = async (render: boolean) => {
150
+ if (!props.state?.previewAvailable || previewing()) return;
141
151
  const b = num(budget());
142
- if (b === undefined) { setPreview(null); setPreviewErr(null); return; }
143
- if (debounce) window.clearTimeout(debounce);
144
- debounce = window.setTimeout(async () => {
145
- setPreviewing(true);
146
- setPreviewErr(null);
147
- try {
148
- const qs = new URLSearchParams({ budget: String(b), agent: props.state!.agent });
149
- const t = num(tail());
150
- if (t !== undefined) qs.set('tail', String(t));
151
- const res = await fetch(`/debug/context/preview?${qs}`, { credentials: 'same-origin' });
152
- const body = await res.json();
153
- if (!res.ok) {
154
- setPreview(null);
155
- setAcct(null);
156
- setPreviewErr(body?.error ?? `HTTP ${res.status}`);
157
- return;
158
- }
159
- setPreview(body.preview as PreviewResult);
160
- setAcct((body.accounting ?? null) as PreviewAccounting | null);
161
- } catch (e) {
152
+ if (b === undefined) { setPreviewErr('budget must be a positive integer'); return; }
153
+ setPreviewing(true);
154
+ setPreviewErr(null);
155
+ setElapsed(null);
156
+ try {
157
+ const qs = new URLSearchParams({ budget: String(b), agent: props.state!.agent });
158
+ const t = num(tail());
159
+ if (t !== undefined) qs.set('tail', String(t));
160
+ if (render) qs.set('render', '1');
161
+ const res = await fetch(`/debug/context/preview?${qs}`, { credentials: 'same-origin' });
162
+ const body = await res.json();
163
+ if (!res.ok) {
162
164
  setPreview(null);
163
165
  setAcct(null);
164
- setPreviewErr(e instanceof Error ? e.message : String(e));
165
- } finally {
166
- setPreviewing(false);
166
+ // 429 is the single-flight / cooldown guard, not a failure.
167
+ setPreviewErr(body?.error ?? `HTTP ${res.status}`);
168
+ return;
167
169
  }
168
- }, 350) as unknown as number;
170
+ setPreview(body.preview as PreviewResult);
171
+ setAcct((body.accounting ?? null) as PreviewAccounting | null);
172
+ setElapsed(typeof body.elapsedMs === 'number' ? body.elapsedMs : null);
173
+ if (render && body.preview?.entries) {
174
+ props.onDryContext?.({
175
+ entries: body.preview.entries,
176
+ stats: body.preview.stats,
177
+ label: `budget ${b.toLocaleString()}${t !== undefined ? `, tail ${t.toLocaleString()}` : ''}`,
178
+ });
179
+ }
180
+ } catch (e) {
181
+ setPreview(null);
182
+ setAcct(null);
183
+ setPreviewErr(e instanceof Error ? e.message : String(e));
184
+ } finally {
185
+ setPreviewing(false);
186
+ }
169
187
  };
170
188
 
171
189
  const apply = () => {
@@ -297,7 +315,7 @@ export function SettingsPanel(props: {
297
315
  disabled={!isHot(key)}
298
316
  value={get()}
299
317
  placeholder={isHot(key) ? 'tokens' : 'restart only'}
300
- onInput={(e) => { set(e.currentTarget.value); runPreview(); }}
318
+ onInput={(e) => set(e.currentTarget.value)}
301
319
  class="flex-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
302
320
  font-mono text-[11px] text-neutral-100 disabled:opacity-40"
303
321
  />
@@ -356,13 +374,40 @@ export function SettingsPanel(props: {
356
374
 
357
375
  {/* ---- preview ---- */}
358
376
  <div class="border-t border-neutral-800 pt-2">
359
- <div class="flex items-center gap-2 mb-1">
377
+ <div class="flex items-center gap-2 mb-1 flex-wrap">
360
378
  <span class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold">
361
- preview
379
+ dry run
362
380
  </span>
381
+ <button
382
+ type="button"
383
+ disabled={previewing() || !props.state!.previewAvailable}
384
+ class="px-2 py-0.5 text-[10px] rounded font-mono bg-neutral-800 hover:bg-neutral-700
385
+ text-neutral-200 disabled:opacity-30"
386
+ onClick={() => void runDryRun(false)}
387
+ title="Compile at these settings without applying them. Does not commit anything."
388
+ >
389
+ dry run
390
+ </button>
391
+ <button
392
+ type="button"
393
+ disabled={previewing() || !props.state!.previewAvailable}
394
+ class="px-2 py-0.5 text-[10px] rounded font-mono bg-cyan-900/40 hover:bg-cyan-900/60
395
+ text-cyan-200 disabled:opacity-30"
396
+ onClick={() => void runDryRun(true)}
397
+ title="Dry run and show the resulting context in the main pane."
398
+ >
399
+ dry run + show context
400
+ </button>
363
401
  <Show when={previewing()}>
364
- <span class="text-neutral-600 text-[10px]">computing…</span>
402
+ <span class="text-amber-400/90 text-[10px]">running — agent paused…</span>
365
403
  </Show>
404
+ <Show when={!previewing() && elapsed() !== null}>
405
+ <span class="text-neutral-600 text-[10px]">{(elapsed()! / 1000).toFixed(1)}s</span>
406
+ </Show>
407
+ </div>
408
+ <div class="text-[10px] text-neutral-600 mb-1.5 leading-relaxed">
409
+ A dry run is a full compile — seconds on a large store — and it briefly pauses the
410
+ agent. It commits nothing: no fold resolutions, no compression queued.
366
411
  </div>
367
412
 
368
413
  <Show when={!props.state!.previewAvailable}>
@@ -432,7 +477,7 @@ export function SettingsPanel(props: {
432
477
  ['head (verbatim)', p().headTokens],
433
478
  ['tail (verbatim)', p().tailTokens],
434
479
  ['middle (foldable)', p().middleTokens],
435
- ['middle chunks', p().middleChunkCount],
480
+ ['middle messages (picker units)', p().middleChunkCount],
436
481
  ['deepest fold level', `L${p().deepestLevel}`],
437
482
  ['folds applied', p().appliedCount],
438
483
  ] as Array<[string, unknown]>}>