tmux-ide 2.9.0-beta.20 → 2.9.0-beta.21

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 (49) hide show
  1. package/bin/cli.js +100 -8
  2. package/package.json +3 -2
  3. package/packages/daemon/dist/command-center/diagnostics.js +65 -0
  4. package/packages/daemon/dist/command-center/log-stream.js +9 -0
  5. package/packages/daemon/dist/command-center/server.js +7 -0
  6. package/packages/daemon/dist/lib/app-config.js +4 -2
  7. package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
  8. package/packages/daemon/dist/lib/soak-verdict.js +185 -35
  9. package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
  10. package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
  11. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
  12. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
  13. package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
  14. package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
  15. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
  16. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
  17. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
  18. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
  19. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
  20. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
  21. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
  22. package/packages/daemon/dist/tui/mirror/theme.js +4 -31
  23. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
  24. package/packages/daemon/src/command-center/diagnostics.ts +75 -0
  25. package/packages/daemon/src/command-center/log-stream.ts +8 -0
  26. package/packages/daemon/src/command-center/server.ts +8 -0
  27. package/packages/daemon/src/lib/app-config.ts +11 -3
  28. package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
  29. package/packages/daemon/src/lib/soak-verdict.ts +314 -23
  30. package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
  31. package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
  32. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
  33. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
  34. package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
  35. package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
  36. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
  37. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +1 -0
  38. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +9 -2
  39. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
  40. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
  41. package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
  42. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
  43. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
  44. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
  45. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
  46. package/packages/daemon/src/tui/mirror/theme.ts +4 -39
  47. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
  48. package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
  49. package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
@@ -155,7 +155,11 @@ export interface OpenTuiWorkspaceRuntimePort extends WorkspaceClientRuntimePort<
155
155
  getLayout(): OpenTuiWorkspaceLayout | null;
156
156
  getLayoutSnapshot(): OpenTuiWorkspaceLayoutSnapshot;
157
157
  onLayout(listener: (layout: OpenTuiWorkspaceLayoutSnapshot) => void): () => void;
158
- fitViewport(cols: number, rows: number): Promise<"ok" | "geometry-authority-conflict">;
158
+ fitViewport(
159
+ cols: number,
160
+ rows: number,
161
+ semanticWindowId?: string,
162
+ ): Promise<"ok" | "geometry-authority-conflict">;
159
163
  }
160
164
 
161
165
  export interface ConnectOpenTuiWorkspaceRuntimePortOptions {
@@ -1395,8 +1399,17 @@ export async function connectOpenTuiWorkspaceRuntimePort(
1395
1399
  }
1396
1400
  }) ?? null;
1397
1401
  type FitResult = "ok" | "geometry-authority-conflict";
1398
- let pendingInitialFit: { cols: number; rows: number; result: Promise<FitResult> } | null = null;
1399
- const fitViewport = (cols: number, rows: number): Promise<FitResult> => {
1402
+ let pendingInitialFit: {
1403
+ cols: number;
1404
+ rows: number;
1405
+ semanticWindowId?: string;
1406
+ result: Promise<FitResult>;
1407
+ } | null = null;
1408
+ const fitViewport = (
1409
+ cols: number,
1410
+ rows: number,
1411
+ semanticWindowId?: string,
1412
+ ): Promise<FitResult> => {
1400
1413
  if (closed) return Promise.resolve("geometry-authority-conflict");
1401
1414
  // Keep only the latest requested geometry while hidden seeds arrive. All
1402
1415
  // callers share one wait; resize storms cannot accumulate deferred work.
@@ -1404,23 +1417,31 @@ export async function connectOpenTuiWorkspaceRuntimePort(
1404
1417
  if (pendingInitialFit) {
1405
1418
  pendingInitialFit.cols = cols;
1406
1419
  pendingInitialFit.rows = rows;
1420
+ pendingInitialFit.semanticWindowId = semanticWindowId;
1407
1421
  return pendingInitialFit.result;
1408
1422
  }
1409
1423
  const request = {
1410
1424
  cols,
1411
1425
  rows,
1426
+ semanticWindowId,
1412
1427
  result: Promise.resolve<FitResult>("geometry-authority-conflict"),
1413
1428
  };
1414
1429
  pendingInitialFit = request;
1415
1430
  request.result = allSeeds.then((ready) => {
1416
1431
  pendingInitialFit = null;
1417
- return ready ? fitViewport(request.cols, request.rows) : "geometry-authority-conflict";
1432
+ return ready
1433
+ ? fitViewport(request.cols, request.rows, request.semanticWindowId)
1434
+ : "geometry-authority-conflict";
1418
1435
  });
1419
1436
  return request.result;
1420
1437
  }
1421
1438
  if ([...endpoints.values()].some((endpoint) => !endpoint.inputReady))
1422
1439
  return Promise.resolve("geometry-authority-conflict");
1423
- return opened.fitViewport(cols, rows).catch((error: unknown) => {
1440
+ return (
1441
+ semanticWindowId === undefined
1442
+ ? opened.fitViewport(cols, rows)
1443
+ : opened.fitViewport(cols, rows, semanticWindowId)
1444
+ ).catch((error: unknown) => {
1424
1445
  if (error instanceof PaneStreamOperationError && error.code === "authority-rejected")
1425
1446
  return "geometry-authority-conflict" as const;
1426
1447
  throw error;
@@ -164,9 +164,8 @@ export interface PaneSurfaceOptions extends RenderableOptions<FrameBufferRendera
164
164
  /** Bumps when canonical presentation changed without terminal cell damage. */
165
165
  presentationVersion?: number;
166
166
  /**
167
- * Renderer-owned framebuffer generation. Unlike `contentVersion`, this
168
- * changes when retained canonical cells are still current but the native
169
- * presentation may have been invalidated by composition or visibility.
167
+ * Renderer-owned composition generation. Retained framebuffer cells survive
168
+ * visibility and position changes; the hardware cursor must be re-presented.
170
169
  */
171
170
  presentationGeneration?: string;
172
171
  /** Retained-source generation; forces a full blit even when content version restarts equal. */
@@ -641,11 +640,11 @@ class PaneSurfaceRenderable extends FrameBufferRenderable {
641
640
  set presentationGeneration(v: string) {
642
641
  if (v === this._presentationGeneration) return;
643
642
  this._presentationGeneration = v;
644
- // Framebuffer validity is independent from terminal content freshness. A
645
- // quiet pane must be able to repaint its retained canonical snapshot after
646
- // visibility/composition changes without waiting for another terminal byte.
647
- this._forceFull = true;
648
- this.invalidate();
643
+ // Composition reuses the retained framebuffer. Geometry reallocation,
644
+ // source replacement and palette changes independently force a full walk.
645
+ // Reclaim the hardware cursor at the new absolute position on visibility.
646
+ this._needsCursorPresentation = true;
647
+ this.requestRender();
649
648
  }
650
649
  set sourceEpoch(v: number) {
651
650
  if (v === this._sourceEpoch) return;
@@ -668,6 +667,8 @@ class PaneSurfaceRenderable extends FrameBufferRenderable {
668
667
  // Detailed focus correlation never owns renderer replacement.
669
668
  }
670
669
  this._rendererEpoch = v;
670
+ this._forceFull = true;
671
+ this.invalidate();
671
672
  }
672
673
  set hostFocusTransitionOwner(v: PaneSurfaceHostFocusTransitionOwner | null | undefined) {
673
674
  if (v !== this._hostFocusTransitionOwner) this.cancelPendingFocusTransition();
@@ -104,6 +104,7 @@ function sameTarget(
104
104
  ): boolean {
105
105
  return (
106
106
  state.operationId === observation.operationId &&
107
+ state.authorityGeneration === observation.authorityGeneration &&
107
108
  state.workspaceName === observation.workspaceName &&
108
109
  state.semanticPaneId === observation.semanticPaneId &&
109
110
  state.axis === observation.axis
@@ -113,9 +114,9 @@ function sameTarget(
113
114
  /**
114
115
  * Renderer-local pane-resize transaction.
115
116
  *
116
- * Pointer motion only changes the preview state. Release authors exactly one
117
- * semantic intent and keeps that preview visible until the matching observed
118
- * operation settles or rejects. The controller owns no timers, transport, or
117
+ * Pointer motion submits the first target immediately, then retains only the
118
+ * latest desired size while receipt and canonical layout settle the pending
119
+ * operation. Release closes the gesture and flushes that latest target. The controller owns no timers, transport, or
119
120
  * rendering effects beyond the functions supplied by its caller.
120
121
  */
121
122
  export class ResizeTransactionController {
@@ -129,6 +130,8 @@ export class ResizeTransactionController {
129
130
  #projection: OptimisticProjectionState<number, number> | null = null;
130
131
  #revision = 0;
131
132
  #disposed = false;
133
+ #dragOpen = false;
134
+ #submittedCells: number | null = null;
132
135
 
133
136
  constructor(options: ResizeTransactionControllerOptions) {
134
137
  if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
@@ -150,6 +153,8 @@ export class ResizeTransactionController {
150
153
  const workspaceName = nonEmpty(input.workspaceName, "workspaceName");
151
154
  const semanticPaneId = nonEmpty(input.semanticPaneId, "semanticPaneId");
152
155
  const canonicalCells = positiveCells(input.canonicalCells, "canonicalCells");
156
+ this.#dragOpen = true;
157
+ this.#submittedCells = null;
153
158
  this.#projection = createOptimisticProjection<number, number>({
154
159
  generation: authorityGeneration,
155
160
  revision: this.#revision,
@@ -168,28 +173,31 @@ export class ResizeTransactionController {
168
173
  return true;
169
174
  }
170
175
 
171
- /** Purely local preview update. It never calls submit or schedules work. */
176
+ /** First/latest coalescing: at most one mutation awaits canonical settlement. */
172
177
  move(previewCells: number): boolean {
173
- if (this.#state.phase !== "dragging") return false;
178
+ if (!this.#dragOpen || this.#state.phase === "idle") return false;
174
179
  const next = positiveCells(previewCells, "previewCells");
175
180
  if (next === this.#state.previewCells) return false;
176
181
  this.#emit({ ...this.#state, previewCells: next });
182
+ if (this.#state.phase === "dragging") this.#submit();
177
183
  return true;
178
184
  }
179
185
 
180
- /**
181
- * Submit once. Duplicate physical release/up/drop events return the existing
182
- * operation id without authoring another mutation.
183
- */
186
+ /** Duplicate releases never author another mutation. */
184
187
  release(): string | null {
188
+ this.#dragOpen = false;
185
189
  if (this.#state.phase === "pending") return this.#state.operationId;
186
190
  if (this.#state.phase !== "dragging") return null;
187
- const dragging = this.#state;
188
- if (dragging.previewCells === dragging.canonicalCells) {
189
- this.#emit({ phase: "idle", canonicalCells: dragging.canonicalCells, outcome: null });
190
- return null;
191
- }
191
+ const canonicalCells = this.#state.canonicalCells;
192
+ this.#emit({ phase: "idle", canonicalCells, outcome: null });
193
+ return null;
194
+ }
192
195
 
196
+ #submit(): string | null {
197
+ if (this.#state.phase !== "dragging") return null;
198
+ const dragging = this.#state;
199
+ if (dragging.previewCells === dragging.canonicalCells) return null;
200
+ this.#submittedCells = dragging.previewCells;
193
201
  const operationId = nonEmpty(this.#options.operationId(), "operationId");
194
202
  const submittedAt = this.#options.now();
195
203
  if (!this.#projection) throw new Error("resize projection is unavailable");
@@ -245,9 +253,14 @@ export class ResizeTransactionController {
245
253
  });
246
254
  }
247
255
 
248
- /** Escape/cancel is only meaningful before release. */
256
+ /** Discard queued motion; an already dispatched mutation cannot be undone. */
249
257
  cancelDrag(): boolean {
250
- if (this.#state.phase !== "dragging") return false;
258
+ if (!this.#dragOpen || this.#state.phase === "idle") return false;
259
+ this.#dragOpen = false;
260
+ if (this.#state.phase === "pending") {
261
+ this.#emit({ ...this.#state, previewCells: this.#submittedCells! });
262
+ return true;
263
+ }
251
264
  const canonicalCells = this.#state.canonicalCells;
252
265
  this.#emit({ phase: "idle", canonicalCells, outcome: null });
253
266
  return true;
@@ -260,6 +273,8 @@ export class ResizeTransactionController {
260
273
 
261
274
  /** Retire one runtime lane without permanently disposing the controller. */
262
275
  retire(): void {
276
+ this.#dragOpen = false;
277
+ this.#submittedCells = null;
263
278
  this.#clearTimeout();
264
279
  this.#projection = null;
265
280
  if (this.#state.phase !== "idle" || this.#state.canonicalCells !== null) {
@@ -277,7 +292,8 @@ export class ResizeTransactionController {
277
292
  )
278
293
  return false;
279
294
  const cells = positiveCells(observation.cells, "observed cells");
280
- const operationId = this.#state.operationId;
295
+ const pending = this.#state;
296
+ const operationId = pending.operationId;
281
297
  if (!this.#projection) return false;
282
298
  this.#revision += 1;
283
299
  this.#projection = replaceCommittedProjection(
@@ -290,11 +306,18 @@ export class ResizeTransactionController {
290
306
  { observedOperationIds: [operationId], nowMs: this.#options.now() },
291
307
  );
292
308
  this.#clearTimeout();
293
- this.#emit({
294
- phase: "idle",
295
- canonicalCells: cells,
296
- outcome: { kind: "settled", operationId, source, cells },
297
- });
309
+ const hasLatest =
310
+ pending.previewCells !== this.#submittedCells && pending.previewCells !== cells;
311
+ if (this.#dragOpen || hasLatest) {
312
+ this.#emit({ ...pending, phase: "dragging", canonicalCells: cells });
313
+ if (hasLatest) this.#submit();
314
+ } else {
315
+ this.#emit({
316
+ phase: "idle",
317
+ canonicalCells: cells,
318
+ outcome: { kind: "settled", operationId, source, cells },
319
+ });
320
+ }
298
321
  return true;
299
322
  }
300
323
 
@@ -321,6 +344,7 @@ export class ResizeTransactionController {
321
344
  );
322
345
  }
323
346
  const canonicalCells = this.#state.canonicalCells;
347
+ this.#dragOpen = false;
324
348
  this.#clearTimeout();
325
349
  this.#emit({
326
350
  phase: "idle",
@@ -1,3 +1,5 @@
1
+ import type { CliRenderer } from "@opentui/core";
2
+ import { createAutomaticContrastPass } from "../automatic-contrast.ts";
1
3
  import { VISUAL_THEME_PRESETS, findVisualThemePreset } from "@tmux-ide/contracts";
2
4
  import { batch, createSignal, type Accessor } from "solid-js";
3
5
 
@@ -26,6 +28,8 @@ export interface ApplicationAppearanceOwner {
26
28
  readonly palette: Accessor<TerminalPaletteProjection>;
27
29
  readonly hostPalette: Accessor<ApplicationTerminalPaletteSnapshot>;
28
30
  readonly setTransientNote: (note: string | null) => void;
31
+ readonly automaticContrast: Accessor<boolean>;
32
+ readonly toggleAutomaticContrast: () => void;
29
33
  readonly pickerOpen: Accessor<boolean>;
30
34
  readonly pickerError: Accessor<string | null>;
31
35
  readonly openPicker: () => void;
@@ -54,15 +58,36 @@ export interface ApplicationAppearanceSnapshot {
54
58
  }
55
59
 
56
60
  function hostDefaults(snapshot: ApplicationTerminalPaletteSnapshot) {
57
- if (snapshot.availability !== "available") return null;
58
- return deriveSystemVisualHostDefaults(snapshot);
61
+ return (
62
+ deriveSystemVisualHostDefaults(snapshot) ?? { appearance: snapshot.detectedMode, overrides: {} }
63
+ );
59
64
  }
60
65
 
61
66
  export function createAppearanceOwner(
62
67
  config: AppConfig,
63
- renderer: ThemeModeSource,
68
+ renderer: ThemeModeSource &
69
+ Partial<Pick<CliRenderer, "addPostProcessFn" | "removePostProcessFn" | "requestRender">>,
64
70
  terminalPaletteOwner: ApplicationTerminalPaletteOwner,
65
71
  ): ApplicationAppearanceOwner {
72
+ const [automaticContrast, setAutomaticContrast] = createSignal(
73
+ config.theme.automaticContrast ?? true,
74
+ );
75
+ const correct = createAutomaticContrastPass();
76
+ const postProcess: Parameters<CliRenderer["addPostProcessFn"]>[0] = (buffer) => {
77
+ if (automaticContrast()) correct(buffer);
78
+ };
79
+ renderer.addPostProcessFn?.(postProcess);
80
+ const setContrast = (enabled: boolean) => {
81
+ if (enabled === automaticContrast()) return;
82
+ setAutomaticContrast(enabled);
83
+ renderer.requestRender?.();
84
+ };
85
+ const toggleAutomaticContrast = () => {
86
+ if (pickerOpen()) {
87
+ setContrast(!automaticContrast());
88
+ setPickerError(null);
89
+ }
90
+ };
66
91
  const initialHostPalette = terminalPaletteOwner.getSnapshot();
67
92
  const store = createSemanticThemeStore(config.theme, {
68
93
  rendererMode: renderer.themeMode,
@@ -128,10 +153,17 @@ export function createAppearanceOwner(
128
153
  const apply = (id: string) => {
129
154
  const preset = findVisualThemePreset(id);
130
155
  const mode = preset?.appearance ?? (id as ThemeModeSetting);
131
- store.configure({ ...config.theme, mode, preset: preset?.id });
156
+ const wasSystem = theme().setting === "system";
157
+ store.configure({
158
+ ...config.theme,
159
+ mode,
160
+ preset: preset?.id,
161
+ });
162
+ if (mode === "system" && !wasSystem) void terminalPaletteOwner.refresh();
132
163
  setPickerSelection(id);
133
164
  };
134
165
  let originalSelection = pickerSelection();
166
+ let originalContrast = automaticContrast();
135
167
  const preview = (id: string): void => {
136
168
  if (!pickerOpen()) return;
137
169
  setPickerError(null);
@@ -139,6 +171,7 @@ export function createAppearanceOwner(
139
171
  };
140
172
  const cancelPicker = (): void => {
141
173
  if (!pickerOpen()) return;
174
+ setContrast(originalContrast);
142
175
  apply(originalSelection);
143
176
  setPickerOpen(false);
144
177
  setPickerError(null);
@@ -148,6 +181,7 @@ export function createAppearanceOwner(
148
181
  try {
149
182
  updateAppConfig({
150
183
  theme: {
184
+ automaticContrast: automaticContrast(),
151
185
  mode: theme().setting,
152
186
  preset: findVisualThemePreset(pickerSelection())?.id ?? "",
153
187
  },
@@ -165,7 +199,7 @@ export function createAppearanceOwner(
165
199
  const order: readonly ThemeModeSetting[] = ["dark", "light", "system"];
166
200
  const next = order[(order.indexOf(theme().setting) + 1) % order.length]!;
167
201
  apply(next);
168
- updateAppConfig({ theme: { mode: next, preset: "" } });
202
+ updateAppConfig({ theme: { mode: next, preset: "", automaticContrast: automaticContrast() } });
169
203
  notice.publish(`theme → ${next}`);
170
204
  };
171
205
  return {
@@ -176,6 +210,8 @@ export function createAppearanceOwner(
176
210
  palette,
177
211
  hostPalette,
178
212
  setTransientNote: notice.publish,
213
+ automaticContrast,
214
+ toggleAutomaticContrast,
179
215
  pickerOpen,
180
216
  pickerError,
181
217
  pickerQuery,
@@ -184,6 +220,7 @@ export function createAppearanceOwner(
184
220
  openPicker() {
185
221
  if (pickerOpen()) return;
186
222
  originalSelection = pickerSelection();
223
+ originalContrast = automaticContrast();
187
224
  setPickerQuery("");
188
225
  setPickerError(null);
189
226
  setPickerOpen(true);
@@ -196,7 +233,8 @@ export function createAppearanceOwner(
196
233
  if (event.eventType === "release" || event.repeated) return true;
197
234
  const name = event.name.toLowerCase();
198
235
  const choices = pickerOptions();
199
- if (name === "escape") cancelPicker();
236
+ if (event.ctrl && name === "a") toggleAutomaticContrast();
237
+ else if (name === "escape") cancelPicker();
200
238
  else if (name === "return" || name === "enter") savePicker();
201
239
  else if (name === "up" || name === "down" || name === "tab") {
202
240
  if (choices.length) {
@@ -219,6 +257,7 @@ export function createAppearanceOwner(
219
257
  },
220
258
  cycleTheme,
221
259
  dispose() {
260
+ renderer.removePostProcessFn?.(postProcess);
222
261
  stopTheme();
223
262
  stopRendererTheme();
224
263
  stopHostPalette();
@@ -175,6 +175,7 @@ export function ApplicationMachineSidebar(props: {
175
175
  : []),
176
176
  ]),
177
177
  );
178
+ const rowsByKey = createMemo(() => new Map(rows().map((row) => [row.key, row])));
178
179
  const index = () =>
179
180
  Math.max(
180
181
  0,
@@ -437,83 +438,106 @@ export function ApplicationMachineSidebar(props: {
437
438
  scrollY={true}
438
439
  horizontalScrollbarOptions={{ visible: false }}
439
440
  >
440
- <For each={rows()}>
441
- {(row) => (
442
- <box
443
- width={Math.max(1, props.width - 1)}
444
- height={row.agent ? (row.agentHeading ? 3 : 2) : 1}
445
- flexShrink={0}
446
- flexDirection="column"
447
- >
448
- <Show when={row.agentHeading}>
449
- <text height={1} fg={props.theme.roles.text.secondary}>
450
- {" "}
451
- Agents
452
- </text>
453
- </Show>
454
- <NavigationRow
455
- theme={props.theme}
456
- id={`machine:${row.key}`}
441
+ <For each={rows().map((row) => row.key)}>
442
+ {(key) => {
443
+ // The semantic key owns the native row; fresh snapshots update its
444
+ // props instead of destroying and recreating identical renderables.
445
+ const current = createMemo<Row>(
446
+ (previous) => rowsByKey().get(key) ?? previous,
447
+ rowsByKey().get(key)!,
448
+ );
449
+ const row: Row = {
450
+ key,
451
+ get group() {
452
+ return current().group;
453
+ },
454
+ get session() {
455
+ return current().session;
456
+ },
457
+ get agent() {
458
+ return current().agent;
459
+ },
460
+ get agentHeading() {
461
+ return current().agentHeading;
462
+ },
463
+ };
464
+ return (
465
+ <box
457
466
  width={Math.max(1, props.width - 1)}
458
- labelColor={!row.agent && !row.session ? fleetHostColor(row.group) : undefined}
459
- label={
460
- row.agent
461
- ? row.agent.name
462
- : row.session
463
- ? friendlySessionLabel(row.session.name)
464
- : row.group.label
465
- }
466
- marker={
467
- row.agent
468
- ? active(row)
469
- ? row.agent.attention
470
- ? "›!"
471
- : "›"
472
- : row.agent.attention
473
- ? "!"
474
- : "•"
475
- : row.session
476
- ? props.model.favorites?.().includes(row.session.id)
477
- ? " ★"
478
- : active(row)
479
- ? " ›"
480
- : " "
481
- : collapsed().has(preferenceKey(row.group))
482
- ? "▸"
483
- : "▾"
484
- }
485
- detail={
486
- row.agent
487
- ? row.group.state !== "ready" || row.agent.disabled
488
- ? "unavailable"
489
- : `[${terminalAgentStatusLabel(row.agent.activity)}]`
490
- : row.session
491
- ? row.group.state !== "ready" || row.session.disabled
467
+ height={row.agent ? (row.agentHeading ? 3 : 2) : 1}
468
+ flexShrink={0}
469
+ flexDirection="column"
470
+ >
471
+ <Show when={row.agentHeading}>
472
+ <text height={1} fg={props.theme.roles.text.secondary}>
473
+ {" "}
474
+ Agents
475
+ </text>
476
+ </Show>
477
+ <NavigationRow
478
+ theme={props.theme}
479
+ id={`machine:${row.key}`}
480
+ width={Math.max(1, props.width - 1)}
481
+ labelColor={!row.agent && !row.session ? fleetHostColor(row.group) : undefined}
482
+ label={
483
+ row.agent
484
+ ? row.agent.name
485
+ : row.session
486
+ ? friendlySessionLabel(row.session.name)
487
+ : row.group.label
488
+ }
489
+ marker={
490
+ row.agent
491
+ ? active(row)
492
+ ? row.agent.attention
493
+ ? "›!"
494
+ : "›"
495
+ : row.agent.attention
496
+ ? "!"
497
+ : "•"
498
+ : row.session
499
+ ? props.model.favorites?.().includes(row.session.id)
500
+ ? " ★"
501
+ : active(row)
502
+ ? " ›"
503
+ : " "
504
+ : collapsed().has(preferenceKey(row.group))
505
+ ? "▸"
506
+ : "▾"
507
+ }
508
+ detail={
509
+ row.agent
510
+ ? row.group.state !== "ready" || row.agent.disabled
492
511
  ? "unavailable"
493
- : `${row.session.paneCount}p${row.group.agents?.length ? ` ${activity(row).label}` : ""}`
494
- : row.group.agents?.length && row.group.state === "ready"
495
- ? activity(row).label
496
- : `${connectionDetail(row.group)}${row.group.agents?.length ? " ?" : ""}`
497
- }
498
- selected={Boolean((row.session || row.agent) && active(row))}
499
- focused={Boolean(focused() && row.key === selectedKey())}
500
- attention={row.agent?.attention ?? activity(row).kind === "attention"}
501
- onActivate={(source) => activate(row, source)}
502
- />
503
- <Show when={row.agent}>
504
- {(agent) => (
505
- <text
506
- height={1}
507
- fg={props.theme.roles.text.muted}
508
- content={clipTerminal(
509
- ` ${friendlySessionLabel(agent().sessionName)}`,
510
- Math.max(1, props.width - 1),
511
- )}
512
- />
513
- )}
514
- </Show>
515
- </box>
516
- )}
512
+ : `[${terminalAgentStatusLabel(row.agent.activity)}]`
513
+ : row.session
514
+ ? row.group.state !== "ready" || row.session.disabled
515
+ ? "unavailable"
516
+ : `${row.session.paneCount}p${row.group.agents?.length ? ` ${activity(row).label}` : ""}`
517
+ : row.group.agents?.length && row.group.state === "ready"
518
+ ? activity(row).label
519
+ : `${connectionDetail(row.group)}${row.group.agents?.length ? " ?" : ""}`
520
+ }
521
+ selected={Boolean((row.session || row.agent) && active(row))}
522
+ focused={Boolean(focused() && row.key === selectedKey())}
523
+ attention={row.agent?.attention ?? activity(row).kind === "attention"}
524
+ onActivate={(source) => activate(row, source)}
525
+ />
526
+ <Show when={row.agent}>
527
+ {(agent) => (
528
+ <text
529
+ height={1}
530
+ fg={props.theme.roles.text.muted}
531
+ content={clipTerminal(
532
+ ` ${friendlySessionLabel(agent().sessionName)}`,
533
+ Math.max(1, props.width - 1),
534
+ )}
535
+ />
536
+ )}
537
+ </Show>
538
+ </box>
539
+ );
540
+ }}
517
541
  </For>
518
542
  </scrollbox>
519
543
  <For each={agentHeight() > 0 ? [true] : []}>
@@ -622,6 +622,7 @@ export async function startApplicationRoot(options: StartApplicationRootOptions
622
622
  })}
623
623
  onResizePreview={recoverHostFocus(interaction.previewPaneResize)}
624
624
  onResizePane={recoverHostFocus(interaction.resizePane)}
625
+ onCancelResize={interaction.cancelPaneResize}
625
626
  onResizePointerIngress={recoverHostFocus.optional(resizeIngress)}
626
627
  onWheelObservation={tuiPerfWheelObservation}
627
628
  onTerminalInput={recoverHostFocus((paneId, input) =>
@@ -493,7 +493,7 @@ export function AppearanceDialog(props: {
493
493
  viewportWidth={props.width}
494
494
  viewportHeight={props.height}
495
495
  width={width()}
496
- height={Math.min(18, props.height)}
496
+ height={Math.min(19, props.height)}
497
497
  title="Appearance"
498
498
  footer={width() < 40 ? "Enter save · Esc back" : "↑↓ preview · Enter save · Esc cancel"}
499
499
  active={props.active}
@@ -505,10 +505,17 @@ export function AppearanceDialog(props: {
505
505
  fg={props.owner.theme().roles.text.primary}
506
506
  content={`Search: ${props.owner.pickerQuery() || "type to filter"}`}
507
507
  />
508
+ <TuiButton
509
+ theme={props.owner.theme()}
510
+ label={`^A Contrast: ${props.owner.automaticContrast() ? "On" : "Off"}`}
511
+ size="compact"
512
+ disabled={props.active === false}
513
+ onPress={props.owner.toggleAutomaticContrast}
514
+ />
508
515
  <For
509
516
  each={(() => {
510
517
  const options = props.owner.pickerOptions();
511
- const count = Math.max(1, Math.min(10, props.height - 8));
518
+ const count = Math.max(1, Math.min(10, props.height - 9));
512
519
  const index = options.findIndex((p) => p.id === props.owner.pickerSelection());
513
520
  const start = Math.max(
514
521
  0,
@@ -125,6 +125,7 @@ export interface ApplicationShellViewProps {
125
125
  readonly onSelectPane: TerminalWorkspaceProps["onSelectPane"];
126
126
  readonly onResizePreview: TerminalWorkspaceProps["onResizePreview"];
127
127
  readonly onResizePane: TerminalWorkspaceProps["onResizePane"];
128
+ readonly onCancelResize?: TerminalWorkspaceProps["onCancelResize"];
128
129
  readonly onResizePointerIngress?: TerminalWorkspaceProps["onResizePointerIngress"];
129
130
  readonly onWheelObservation?: TerminalWorkspaceProps["onWheelObservation"];
130
131
  readonly onTerminalInput?: TerminalWorkspaceProps["onTerminalInput"];
@@ -517,6 +518,7 @@ export function ApplicationShellView(props: ApplicationShellViewProps): JSX.Elem
517
518
  }}
518
519
  onResizePreview={props.onResizePreview}
519
520
  onResizePane={props.onResizePane}
521
+ onCancelResize={props.onCancelResize}
520
522
  onResizePointerIngress={props.onResizePointerIngress}
521
523
  onWheelObservation={props.onWheelObservation}
522
524
  onTerminalInput={props.onTerminalInput}