tmux-ide 2.9.0-beta.19 → 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 (90) hide show
  1. package/bin/cli.js +1979 -783
  2. package/package.json +4 -2
  3. package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
  4. package/packages/contracts/src/daemon-events.ts +12 -12
  5. package/packages/contracts/src/daemon-wire.ts +30 -0
  6. package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
  7. package/packages/daemon/dist/command-center/diagnostics.js +65 -0
  8. package/packages/daemon/dist/command-center/log-stream.js +9 -0
  9. package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
  10. package/packages/daemon/dist/command-center/server.js +7 -0
  11. package/packages/daemon/dist/doctor.js +62 -0
  12. package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
  13. package/packages/daemon/dist/lib/app-config.js +4 -2
  14. package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
  15. package/packages/daemon/dist/lib/daemon-embed.js +24 -0
  16. package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
  17. package/packages/daemon/dist/lib/headless-daemon.js +1 -0
  18. package/packages/daemon/dist/lib/log-sanitize.js +199 -0
  19. package/packages/daemon/dist/lib/log.js +123 -13
  20. package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
  21. package/packages/daemon/dist/lib/soak-verdict.js +472 -0
  22. package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
  23. package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
  24. package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
  25. package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
  26. package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
  27. package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
  28. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
  29. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
  30. package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
  31. package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
  32. package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
  33. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
  34. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
  35. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
  36. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
  37. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
  38. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
  39. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
  40. package/packages/daemon/dist/tui/mirror/theme.js +4 -31
  41. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
  42. package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
  43. package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
  44. package/packages/daemon/src/command-center/diagnostics.ts +75 -0
  45. package/packages/daemon/src/command-center/log-stream.ts +8 -0
  46. package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
  47. package/packages/daemon/src/command-center/server.ts +8 -0
  48. package/packages/daemon/src/doctor.ts +70 -0
  49. package/packages/daemon/src/lib/app-config.ts +11 -3
  50. package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
  51. package/packages/daemon/src/lib/daemon-embed.ts +30 -0
  52. package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
  53. package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
  54. package/packages/daemon/src/lib/headless-daemon.ts +1 -0
  55. package/packages/daemon/src/lib/log-sanitize.ts +248 -0
  56. package/packages/daemon/src/lib/log.ts +165 -14
  57. package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
  58. package/packages/daemon/src/lib/soak-verdict.ts +785 -0
  59. package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
  60. package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
  61. package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
  62. package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
  63. package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
  64. package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
  65. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
  66. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
  67. package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
  68. package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
  69. package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
  70. package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
  71. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
  72. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
  73. package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
  74. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
  75. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
  76. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
  77. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
  78. package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
  79. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
  80. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
  81. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
  82. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
  83. package/packages/daemon/src/tui/mirror/theme.ts +4 -39
  84. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
  85. package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
  86. package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
  87. package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
  88. package/packages/tmux-bridge/src/index.ts +2 -0
  89. package/packages/tmux-bridge/src/runner.test.ts +41 -0
  90. package/packages/tmux-bridge/src/runner.ts +41 -2
@@ -0,0 +1,161 @@
1
+ /** Quantized sRGB contrast; Oklab matrices: https://bottosson.github.io/posts/oklab/ . */
2
+ const LINEAR = Float64Array.from({ length: 256 }, (_, n) => {
3
+ const c = n / 255;
4
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
5
+ });
6
+ const luminance = (rgb) => 0.2126 * LINEAR[(rgb >>> 16) & 255] +
7
+ 0.7152 * LINEAR[(rgb >>> 8) & 255] +
8
+ 0.0722 * LINEAR[rgb & 255];
9
+ export function packedContrastRatio(a, b) {
10
+ const x = luminance(a) + 0.05, y = luminance(b) + 0.05;
11
+ return Math.max(x, y) / Math.min(x, y);
12
+ }
13
+ function byte(c) {
14
+ return Math.round(Math.max(0, Math.min(1, c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)) * 255);
15
+ }
16
+ function candidate(L, a, b) {
17
+ // Reduce chroma along the same hue ray until inside sRGB, then quantize.
18
+ let lo = 0, hi = 1, result = -1;
19
+ for (let i = 0; i < 10; i++) {
20
+ const scale = i === 0 ? 1 : (lo + hi) / 2;
21
+ const l = (L + 0.3963377774 * a * scale + 0.2158037573 * b * scale) ** 3;
22
+ const m = (L - 0.1055613458 * a * scale - 0.0638541728 * b * scale) ** 3;
23
+ const s = (L - 0.0894841775 * a * scale - 1.291485548 * b * scale) ** 3;
24
+ const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
25
+ const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
26
+ const blue = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
27
+ if (Math.min(r, g, blue) >= 0 && Math.max(r, g, blue) <= 1) {
28
+ result = (byte(r) << 16) | (byte(g) << 8) | byte(blue);
29
+ if (i === 0)
30
+ return result;
31
+ lo = scale;
32
+ }
33
+ else
34
+ hi = scale;
35
+ }
36
+ return result < 0 ? byte(L ** 3) * 0x10101 : result;
37
+ }
38
+ /** Leave passing pairs exact. Search both lightness directions with a verified endpoint fallback. */
39
+ export function correctContrastForeground(fg, bg) {
40
+ if (packedContrastRatio(fg, bg) >= 4.5)
41
+ return fg;
42
+ const r = LINEAR[(fg >>> 16) & 255], g = LINEAR[(fg >>> 8) & 255], b = LINEAR[fg & 255];
43
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
44
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
45
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
46
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
47
+ const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
48
+ const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
49
+ let best = 0, distance = Infinity;
50
+ for (const end of [0, 1]) {
51
+ let passing = end, failing = L;
52
+ let rgb = end === 0 ? 0 : 0xffffff;
53
+ if (packedContrastRatio(rgb, bg) < 4.5)
54
+ continue;
55
+ for (let i = 0; i < 12; i++) {
56
+ const mid = (passing + failing) / 2;
57
+ const trial = candidate(mid, a, bb);
58
+ if (packedContrastRatio(trial, bg) >= 4.5) {
59
+ passing = mid;
60
+ rgb = trial;
61
+ }
62
+ else
63
+ failing = mid;
64
+ }
65
+ if (Math.abs(passing - L) < distance) {
66
+ best = rgb;
67
+ distance = Math.abs(passing - L);
68
+ }
69
+ }
70
+ return best;
71
+ }
72
+ /** Cheap high-color-churn fallback: linear-sRGB blend to the nearer passing endpoint. */
73
+ export function fastContrastForeground(fg, bg) {
74
+ if (packedContrastRatio(fg, bg) >= 4.5)
75
+ return fg;
76
+ const y = luminance(fg), base = luminance(bg);
77
+ const darkTarget = (base + 0.05) / 4.5 - 0.05;
78
+ const lightTarget = (base + 0.05) * 4.5 - 0.05;
79
+ const darkMix = darkTarget >= 0 ? (y - darkTarget) / y : Infinity;
80
+ const lightMix = lightTarget <= 1 ? (lightTarget - y) / (1 - y) : Infinity;
81
+ const end = darkMix <= lightMix ? 0 : 1;
82
+ // A small quantization margin usually avoids endpoint fallback. Always verify.
83
+ const amount = Math.min(1, Math.max(0, Math.min(darkMix, lightMix)) + 0.012);
84
+ const r = LINEAR[(fg >>> 16) & 255], g = LINEAR[(fg >>> 8) & 255], b = LINEAR[fg & 255];
85
+ const result = (byte(r + (end - r) * amount) << 16) |
86
+ (byte(g + (end - g) * amount) << 8) |
87
+ byte(b + (end - b) * amount);
88
+ return packedContrastRatio(result, bg) >= 4.5 ? result : end === 0 ? 0 : 0xffffff;
89
+ }
90
+ export function createContrastPairCache(limit = 4096) {
91
+ const pairs = new Map();
92
+ let remaining = Infinity;
93
+ return {
94
+ beginFrame() {
95
+ remaining = 128;
96
+ },
97
+ get size() {
98
+ return pairs.size;
99
+ },
100
+ correct(fg, bg) {
101
+ const key = fg * 0x1000000 + bg;
102
+ const cached = pairs.get(key);
103
+ if (cached !== undefined)
104
+ return cached;
105
+ const corrected = packedContrastRatio(fg, bg) >= 4.5
106
+ ? fg
107
+ : remaining-- > 0
108
+ ? correctContrastForeground(fg, bg)
109
+ : fastContrastForeground(fg, bg);
110
+ if (pairs.size >= limit)
111
+ pairs.clear();
112
+ pairs.set(key, corrected);
113
+ return corrected;
114
+ },
115
+ };
116
+ }
117
+ const read = (c, i) => ((c[i] & 255) << 16) | ((c[i + 1] & 255) << 8) | (c[i + 2] & 255);
118
+ /** Final composed surface only: no canonical state or scheduling; allocation-free cache hits. */
119
+ export function createAutomaticContrastPass() {
120
+ const cache = createContrastPairCache();
121
+ return (buffer) => {
122
+ cache.beginFrame();
123
+ const { char, fg, bg, attributes } = buffer.buffers;
124
+ for (let cell = 0; cell < char.length; cell++) {
125
+ const cp = char[cell], attr = attributes[cell], i = cell * 4;
126
+ // Preserve concealment, whitespace and pixel-art coverage. Grapheme IDs and
127
+ // wide continuations are opaque and retained byte-for-byte.
128
+ if (attr & 64 ||
129
+ cp === 0 ||
130
+ cp === 32 ||
131
+ cp === 160 ||
132
+ (cp >= 0x2580 && cp <= 0x259f) ||
133
+ (cp >= 0x2800 && cp <= 0x28ff))
134
+ continue;
135
+ if ((fg[i + 3] & 255) !== 255 || (bg[i + 3] & 255) !== 255)
136
+ continue;
137
+ const foreground = attr & 32 ? bg : fg;
138
+ const background = attr & 32 ? fg : bg;
139
+ const original = read(foreground, i);
140
+ const base = read(background, i);
141
+ // DIM is emulator-dependent. Materialize a deterministic 50% sRGB blend
142
+ // before correction and clear only the final composed DIM bit.
143
+ let visible = original;
144
+ if (attr & 2) {
145
+ visible =
146
+ (Math.round(((original >>> 16) + (base >>> 16)) / 2) << 16) |
147
+ (Math.round((((original >>> 8) & 255) + ((base >>> 8) & 255)) / 2) << 8) |
148
+ Math.round(((original & 255) + (base & 255)) / 2);
149
+ attributes[cell] = attr & ~2;
150
+ }
151
+ const corrected = cache.correct(visible, base);
152
+ if (corrected === original && !(attr & 2))
153
+ continue;
154
+ // RGB intent is zero. Indexed/default metadata must not override correction.
155
+ foreground[i] = corrected >>> 16;
156
+ foreground[i + 1] = (corrected >>> 8) & 255;
157
+ foreground[i + 2] = corrected & 255;
158
+ foreground[i + 3] = 255;
159
+ }
160
+ };
161
+ }
@@ -1124,7 +1124,7 @@ export async function connectOpenTuiWorkspaceRuntimePort(options) {
1124
1124
  }
1125
1125
  }) ?? null;
1126
1126
  let pendingInitialFit = null;
1127
- const fitViewport = (cols, rows) => {
1127
+ const fitViewport = (cols, rows, semanticWindowId) => {
1128
1128
  if (closed)
1129
1129
  return Promise.resolve("geometry-authority-conflict");
1130
1130
  // Keep only the latest requested geometry while hidden seeds arrive. All
@@ -1133,23 +1133,29 @@ export async function connectOpenTuiWorkspaceRuntimePort(options) {
1133
1133
  if (pendingInitialFit) {
1134
1134
  pendingInitialFit.cols = cols;
1135
1135
  pendingInitialFit.rows = rows;
1136
+ pendingInitialFit.semanticWindowId = semanticWindowId;
1136
1137
  return pendingInitialFit.result;
1137
1138
  }
1138
1139
  const request = {
1139
1140
  cols,
1140
1141
  rows,
1142
+ semanticWindowId,
1141
1143
  result: Promise.resolve("geometry-authority-conflict"),
1142
1144
  };
1143
1145
  pendingInitialFit = request;
1144
1146
  request.result = allSeeds.then((ready) => {
1145
1147
  pendingInitialFit = null;
1146
- return ready ? fitViewport(request.cols, request.rows) : "geometry-authority-conflict";
1148
+ return ready
1149
+ ? fitViewport(request.cols, request.rows, request.semanticWindowId)
1150
+ : "geometry-authority-conflict";
1147
1151
  });
1148
1152
  return request.result;
1149
1153
  }
1150
1154
  if ([...endpoints.values()].some((endpoint) => !endpoint.inputReady))
1151
1155
  return Promise.resolve("geometry-authority-conflict");
1152
- return opened.fitViewport(cols, rows).catch((error) => {
1156
+ return (semanticWindowId === undefined
1157
+ ? opened.fitViewport(cols, rows)
1158
+ : opened.fitViewport(cols, rows, semanticWindowId)).catch((error) => {
1153
1159
  if (error instanceof PaneStreamOperationError && error.code === "authority-rejected")
1154
1160
  return "geometry-authority-conflict";
1155
1161
  throw error;
@@ -422,11 +422,11 @@ class PaneSurfaceRenderable extends FrameBufferRenderable {
422
422
  if (v === this._presentationGeneration)
423
423
  return;
424
424
  this._presentationGeneration = v;
425
- // Framebuffer validity is independent from terminal content freshness. A
426
- // quiet pane must be able to repaint its retained canonical snapshot after
427
- // visibility/composition changes without waiting for another terminal byte.
428
- this._forceFull = true;
429
- this.invalidate();
425
+ // Composition reuses the retained framebuffer. Geometry reallocation,
426
+ // source replacement and palette changes independently force a full walk.
427
+ // Reclaim the hardware cursor at the new absolute position on visibility.
428
+ this._needsCursorPresentation = true;
429
+ this.requestRender();
430
430
  }
431
431
  set sourceEpoch(v) {
432
432
  if (v === this._sourceEpoch)
@@ -453,6 +453,8 @@ class PaneSurfaceRenderable extends FrameBufferRenderable {
453
453
  // Detailed focus correlation never owns renderer replacement.
454
454
  }
455
455
  this._rendererEpoch = v;
456
+ this._forceFull = true;
457
+ this.invalidate();
456
458
  }
457
459
  set hostFocusTransitionOwner(v) {
458
460
  if (v !== this._hostFocusTransitionOwner)
@@ -12,6 +12,7 @@ function nonEmpty(value, label) {
12
12
  }
13
13
  function sameTarget(state, observation) {
14
14
  return (state.operationId === observation.operationId &&
15
+ state.authorityGeneration === observation.authorityGeneration &&
15
16
  state.workspaceName === observation.workspaceName &&
16
17
  state.semanticPaneId === observation.semanticPaneId &&
17
18
  state.axis === observation.axis);
@@ -19,9 +20,9 @@ function sameTarget(state, observation) {
19
20
  /**
20
21
  * Renderer-local pane-resize transaction.
21
22
  *
22
- * Pointer motion only changes the preview state. Release authors exactly one
23
- * semantic intent and keeps that preview visible until the matching observed
24
- * operation settles or rejects. The controller owns no timers, transport, or
23
+ * Pointer motion submits the first target immediately, then retains only the
24
+ * latest desired size while receipt and canonical layout settle the pending
25
+ * operation. Release closes the gesture and flushes that latest target. The controller owns no timers, transport, or
25
26
  * rendering effects beyond the functions supplied by its caller.
26
27
  */
27
28
  export class ResizeTransactionController {
@@ -35,6 +36,8 @@ export class ResizeTransactionController {
35
36
  #projection = null;
36
37
  #revision = 0;
37
38
  #disposed = false;
39
+ #dragOpen = false;
40
+ #submittedCells = null;
38
41
  constructor(options) {
39
42
  if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
40
43
  throw new RangeError("resize transaction timeout must be positive");
@@ -54,6 +57,8 @@ export class ResizeTransactionController {
54
57
  const workspaceName = nonEmpty(input.workspaceName, "workspaceName");
55
58
  const semanticPaneId = nonEmpty(input.semanticPaneId, "semanticPaneId");
56
59
  const canonicalCells = positiveCells(input.canonicalCells, "canonicalCells");
60
+ this.#dragOpen = true;
61
+ this.#submittedCells = null;
57
62
  this.#projection = createOptimisticProjection({
58
63
  generation: authorityGeneration,
59
64
  revision: this.#revision,
@@ -71,30 +76,36 @@ export class ResizeTransactionController {
71
76
  });
72
77
  return true;
73
78
  }
74
- /** Purely local preview update. It never calls submit or schedules work. */
79
+ /** First/latest coalescing: at most one mutation awaits canonical settlement. */
75
80
  move(previewCells) {
76
- if (this.#state.phase !== "dragging")
81
+ if (!this.#dragOpen || this.#state.phase === "idle")
77
82
  return false;
78
83
  const next = positiveCells(previewCells, "previewCells");
79
84
  if (next === this.#state.previewCells)
80
85
  return false;
81
86
  this.#emit({ ...this.#state, previewCells: next });
87
+ if (this.#state.phase === "dragging")
88
+ this.#submit();
82
89
  return true;
83
90
  }
84
- /**
85
- * Submit once. Duplicate physical release/up/drop events return the existing
86
- * operation id without authoring another mutation.
87
- */
91
+ /** Duplicate releases never author another mutation. */
88
92
  release() {
93
+ this.#dragOpen = false;
89
94
  if (this.#state.phase === "pending")
90
95
  return this.#state.operationId;
96
+ if (this.#state.phase !== "dragging")
97
+ return null;
98
+ const canonicalCells = this.#state.canonicalCells;
99
+ this.#emit({ phase: "idle", canonicalCells, outcome: null });
100
+ return null;
101
+ }
102
+ #submit() {
91
103
  if (this.#state.phase !== "dragging")
92
104
  return null;
93
105
  const dragging = this.#state;
94
- if (dragging.previewCells === dragging.canonicalCells) {
95
- this.#emit({ phase: "idle", canonicalCells: dragging.canonicalCells, outcome: null });
106
+ if (dragging.previewCells === dragging.canonicalCells)
96
107
  return null;
97
- }
108
+ this.#submittedCells = dragging.previewCells;
98
109
  const operationId = nonEmpty(this.#options.operationId(), "operationId");
99
110
  const submittedAt = this.#options.now();
100
111
  if (!this.#projection)
@@ -146,10 +157,15 @@ export class ResizeTransactionController {
146
157
  message: rejection.message,
147
158
  });
148
159
  }
149
- /** Escape/cancel is only meaningful before release. */
160
+ /** Discard queued motion; an already dispatched mutation cannot be undone. */
150
161
  cancelDrag() {
151
- if (this.#state.phase !== "dragging")
162
+ if (!this.#dragOpen || this.#state.phase === "idle")
152
163
  return false;
164
+ this.#dragOpen = false;
165
+ if (this.#state.phase === "pending") {
166
+ this.#emit({ ...this.#state, previewCells: this.#submittedCells });
167
+ return true;
168
+ }
153
169
  const canonicalCells = this.#state.canonicalCells;
154
170
  this.#emit({ phase: "idle", canonicalCells, outcome: null });
155
171
  return true;
@@ -160,6 +176,8 @@ export class ResizeTransactionController {
160
176
  }
161
177
  /** Retire one runtime lane without permanently disposing the controller. */
162
178
  retire() {
179
+ this.#dragOpen = false;
180
+ this.#submittedCells = null;
163
181
  this.#clearTimeout();
164
182
  this.#projection = null;
165
183
  if (this.#state.phase !== "idle" || this.#state.canonicalCells !== null) {
@@ -172,7 +190,8 @@ export class ResizeTransactionController {
172
190
  !this.#projection?.pending.some((operation) => operation.operationId === observation.operationId))
173
191
  return false;
174
192
  const cells = positiveCells(observation.cells, "observed cells");
175
- const operationId = this.#state.operationId;
193
+ const pending = this.#state;
194
+ const operationId = pending.operationId;
176
195
  if (!this.#projection)
177
196
  return false;
178
197
  this.#revision += 1;
@@ -182,11 +201,19 @@ export class ResizeTransactionController {
182
201
  value: cells,
183
202
  }, { observedOperationIds: [operationId], nowMs: this.#options.now() });
184
203
  this.#clearTimeout();
185
- this.#emit({
186
- phase: "idle",
187
- canonicalCells: cells,
188
- outcome: { kind: "settled", operationId, source, cells },
189
- });
204
+ const hasLatest = pending.previewCells !== this.#submittedCells && pending.previewCells !== cells;
205
+ if (this.#dragOpen || hasLatest) {
206
+ this.#emit({ ...pending, phase: "dragging", canonicalCells: cells });
207
+ if (hasLatest)
208
+ this.#submit();
209
+ }
210
+ else {
211
+ this.#emit({
212
+ phase: "idle",
213
+ canonicalCells: cells,
214
+ outcome: { kind: "settled", operationId, source, cells },
215
+ });
216
+ }
190
217
  return true;
191
218
  }
192
219
  #revertTimedOut(operationId) {
@@ -202,6 +229,7 @@ export class ResizeTransactionController {
202
229
  this.#projection = reconcileOptimisticOperation(this.#projection, operationId, reason.kind === "timed-out" ? "timed-out" : "rejected");
203
230
  }
204
231
  const canonicalCells = this.#state.canonicalCells;
232
+ this.#dragOpen = false;
205
233
  this.#clearTimeout();
206
234
  this.#emit({
207
235
  phase: "idle",
@@ -1,14 +1,32 @@
1
+ import { createAutomaticContrastPass } from "../automatic-contrast.js";
1
2
  import { VISUAL_THEME_PRESETS, findVisualThemePreset } from "@tmux-ide/contracts";
2
3
  import { batch, createSignal } from "solid-js";
3
4
  import { updateAppConfig } from "../../../lib/app-config.js";
4
5
  import { createSemanticThemeStore, createTerminalPaletteProjection, deriveSystemVisualHostDefaults, } from "../theme.js";
5
6
  import { createApplicationTransientNoteOwner } from "./application-transient-note-owner.js";
6
7
  function hostDefaults(snapshot) {
7
- if (snapshot.availability !== "available")
8
- return null;
9
- return deriveSystemVisualHostDefaults(snapshot);
8
+ return (deriveSystemVisualHostDefaults(snapshot) ?? { appearance: snapshot.detectedMode, overrides: {} });
10
9
  }
11
10
  export function createAppearanceOwner(config, renderer, terminalPaletteOwner) {
11
+ const [automaticContrast, setAutomaticContrast] = createSignal(config.theme.automaticContrast ?? true);
12
+ const correct = createAutomaticContrastPass();
13
+ const postProcess = (buffer) => {
14
+ if (automaticContrast())
15
+ correct(buffer);
16
+ };
17
+ renderer.addPostProcessFn?.(postProcess);
18
+ const setContrast = (enabled) => {
19
+ if (enabled === automaticContrast())
20
+ return;
21
+ setAutomaticContrast(enabled);
22
+ renderer.requestRender?.();
23
+ };
24
+ const toggleAutomaticContrast = () => {
25
+ if (pickerOpen()) {
26
+ setContrast(!automaticContrast());
27
+ setPickerError(null);
28
+ }
29
+ };
12
30
  const initialHostPalette = terminalPaletteOwner.getSnapshot();
13
31
  const store = createSemanticThemeStore(config.theme, {
14
32
  rendererMode: renderer.themeMode,
@@ -65,10 +83,18 @@ export function createAppearanceOwner(config, renderer, terminalPaletteOwner) {
65
83
  const apply = (id) => {
66
84
  const preset = findVisualThemePreset(id);
67
85
  const mode = preset?.appearance ?? id;
68
- store.configure({ ...config.theme, mode, preset: preset?.id });
86
+ const wasSystem = theme().setting === "system";
87
+ store.configure({
88
+ ...config.theme,
89
+ mode,
90
+ preset: preset?.id,
91
+ });
92
+ if (mode === "system" && !wasSystem)
93
+ void terminalPaletteOwner.refresh();
69
94
  setPickerSelection(id);
70
95
  };
71
96
  let originalSelection = pickerSelection();
97
+ let originalContrast = automaticContrast();
72
98
  const preview = (id) => {
73
99
  if (!pickerOpen())
74
100
  return;
@@ -79,6 +105,7 @@ export function createAppearanceOwner(config, renderer, terminalPaletteOwner) {
79
105
  const cancelPicker = () => {
80
106
  if (!pickerOpen())
81
107
  return;
108
+ setContrast(originalContrast);
82
109
  apply(originalSelection);
83
110
  setPickerOpen(false);
84
111
  setPickerError(null);
@@ -89,6 +116,7 @@ export function createAppearanceOwner(config, renderer, terminalPaletteOwner) {
89
116
  try {
90
117
  updateAppConfig({
91
118
  theme: {
119
+ automaticContrast: automaticContrast(),
92
120
  mode: theme().setting,
93
121
  preset: findVisualThemePreset(pickerSelection())?.id ?? "",
94
122
  },
@@ -105,7 +133,7 @@ export function createAppearanceOwner(config, renderer, terminalPaletteOwner) {
105
133
  const order = ["dark", "light", "system"];
106
134
  const next = order[(order.indexOf(theme().setting) + 1) % order.length];
107
135
  apply(next);
108
- updateAppConfig({ theme: { mode: next, preset: "" } });
136
+ updateAppConfig({ theme: { mode: next, preset: "", automaticContrast: automaticContrast() } });
109
137
  notice.publish(`theme → ${next}`);
110
138
  };
111
139
  return {
@@ -116,6 +144,8 @@ export function createAppearanceOwner(config, renderer, terminalPaletteOwner) {
116
144
  palette,
117
145
  hostPalette,
118
146
  setTransientNote: notice.publish,
147
+ automaticContrast,
148
+ toggleAutomaticContrast,
119
149
  pickerOpen,
120
150
  pickerError,
121
151
  pickerQuery,
@@ -125,6 +155,7 @@ export function createAppearanceOwner(config, renderer, terminalPaletteOwner) {
125
155
  if (pickerOpen())
126
156
  return;
127
157
  originalSelection = pickerSelection();
158
+ originalContrast = automaticContrast();
128
159
  setPickerQuery("");
129
160
  setPickerError(null);
130
161
  setPickerOpen(true);
@@ -139,7 +170,9 @@ export function createAppearanceOwner(config, renderer, terminalPaletteOwner) {
139
170
  return true;
140
171
  const name = event.name.toLowerCase();
141
172
  const choices = pickerOptions();
142
- if (name === "escape")
173
+ if (event.ctrl && name === "a")
174
+ toggleAutomaticContrast();
175
+ else if (name === "escape")
143
176
  cancelPicker();
144
177
  else if (name === "return" || name === "enter")
145
178
  savePicker();
@@ -166,6 +199,7 @@ export function createAppearanceOwner(config, renderer, terminalPaletteOwner) {
166
199
  },
167
200
  cycleTheme,
168
201
  dispose() {
202
+ renderer.removePostProcessFn?.(postProcess);
169
203
  stopTheme();
170
204
  stopRendererTheme();
171
205
  stopHostPalette();
@@ -1,7 +1,7 @@
1
1
  import { canonicalDaemonUrl } from "../../../lib/canonical-daemon.js";
2
2
  import { stripVTControlCharacters } from "node:util";
3
3
  /** One-shot snapshot pinned to this route's exact authority; never the selected-machine singleton. */
4
- export async function readFleetWindowPreview(handle, liveSessionId, signal, windowId) {
4
+ export async function readFleetWindowPreview(handle, liveSessionId, signal, windowId, paneId) {
5
5
  const daemon = handle.read();
6
6
  const epoch = handle.endpoint().epoch;
7
7
  if (!daemon?.authToken || handle.endpoint().state !== "ready")
@@ -18,6 +18,7 @@ export async function readFleetWindowPreview(handle, liveSessionId, signal, wind
18
18
  body: JSON.stringify({
19
19
  expectedInstanceId: daemon.instanceId,
20
20
  liveSessionId,
21
+ ...(paneId ? { paneId } : {}),
21
22
  ...(windowId ? { windowId } : {}),
22
23
  }),
23
24
  });
@@ -50,6 +51,7 @@ export async function readFleetWindowPreview(handle, liveSessionId, signal, wind
50
51
  body.daemon?.instanceId !== daemon.instanceId ||
51
52
  body.daemon?.startedAt !== daemon.startedAt ||
52
53
  body.liveSessionId !== liveSessionId ||
54
+ (paneId && body.selectedPaneId !== paneId) ||
53
55
  typeof body.text !== "string" ||
54
56
  body.text.length > 8192)
55
57
  return { status: "unavailable" };
@@ -87,6 +89,7 @@ export async function readFleetWindowPreview(handle, liveSessionId, signal, wind
87
89
  status: "ready",
88
90
  snapshot: {
89
91
  text: clean(body.text),
92
+ ...(body.selectedPaneId ? { selectedPaneId: body.selectedPaneId } : {}),
90
93
  selectedWindowId,
91
94
  windows: windows.map((w) => ({
92
95
  id: w.id,
@@ -68,6 +68,7 @@ export function ApplicationMachineSidebar(props) {
68
68
  }))
69
69
  : []),
70
70
  ]));
71
+ const rowsByKey = createMemo(() => new Map(rows().map((row) => [row.key, row])));
71
72
  const index = () => Math.max(0, rows().findIndex((row) => row.key === selectedKey()));
72
73
  const controlGroup = () => {
73
74
  const group = rows()[index()]?.group;
@@ -281,49 +282,69 @@ export function ApplicationMachineSidebar(props) {
281
282
  revealFocusedRow();
282
283
  };
283
284
  }} height={machineHeight()} width={props.width} scrollX={false} scrollY={true} horizontalScrollbarOptions={{ visible: false }}>
284
- <For each={rows()}>
285
- {(row) => (<box width={Math.max(1, props.width - 1)} height={row.agent ? (row.agentHeading ? 3 : 2) : 1} flexShrink={0} flexDirection="column">
286
- <Show when={row.agentHeading}>
287
- <text height={1} fg={props.theme.roles.text.secondary}>
288
- {" "}
289
- Agents
290
- </text>
291
- </Show>
292
- <NavigationRow theme={props.theme} id={`machine:${row.key}`} width={Math.max(1, props.width - 1)} labelColor={!row.agent && !row.session ? fleetHostColor(row.group) : undefined} label={row.agent
293
- ? row.agent.name
294
- : row.session
295
- ? friendlySessionLabel(row.session.name)
296
- : row.group.label} marker={row.agent
297
- ? active(row)
298
- ? row.agent.attention
299
- ? "›!"
300
- : "›"
301
- : row.agent.attention
302
- ? "!"
303
- : "•"
304
- : row.session
305
- ? props.model.favorites?.().includes(row.session.id)
306
- ? " ★"
307
- : active(row)
308
- ? " ›"
309
- : " "
310
- : collapsed().has(preferenceKey(row.group))
311
- ? "▸"
312
- : "▾"} detail={row.agent
313
- ? row.group.state !== "ready" || row.agent.disabled
314
- ? "unavailable"
315
- : `[${terminalAgentStatusLabel(row.agent.activity)}]`
316
- : row.session
317
- ? row.group.state !== "ready" || row.session.disabled
285
+ <For each={rows().map((row) => row.key)}>
286
+ {(key) => {
287
+ // The semantic key owns the native row; fresh snapshots update its
288
+ // props instead of destroying and recreating identical renderables.
289
+ const current = createMemo((previous) => rowsByKey().get(key) ?? previous, rowsByKey().get(key));
290
+ const row = {
291
+ key,
292
+ get group() {
293
+ return current().group;
294
+ },
295
+ get session() {
296
+ return current().session;
297
+ },
298
+ get agent() {
299
+ return current().agent;
300
+ },
301
+ get agentHeading() {
302
+ return current().agentHeading;
303
+ },
304
+ };
305
+ return (<box width={Math.max(1, props.width - 1)} height={row.agent ? (row.agentHeading ? 3 : 2) : 1} flexShrink={0} flexDirection="column">
306
+ <Show when={row.agentHeading}>
307
+ <text height={1} fg={props.theme.roles.text.secondary}>
308
+ {" "}
309
+ Agents
310
+ </text>
311
+ </Show>
312
+ <NavigationRow theme={props.theme} id={`machine:${row.key}`} width={Math.max(1, props.width - 1)} labelColor={!row.agent && !row.session ? fleetHostColor(row.group) : undefined} label={row.agent
313
+ ? row.agent.name
314
+ : row.session
315
+ ? friendlySessionLabel(row.session.name)
316
+ : row.group.label} marker={row.agent
317
+ ? active(row)
318
+ ? row.agent.attention
319
+ ? "›!"
320
+ : "›"
321
+ : row.agent.attention
322
+ ? "!"
323
+ : "•"
324
+ : row.session
325
+ ? props.model.favorites?.().includes(row.session.id)
326
+ ? " ★"
327
+ : active(row)
328
+ ? " ›"
329
+ : " "
330
+ : collapsed().has(preferenceKey(row.group))
331
+ ? "▸"
332
+ : "▾"} detail={row.agent
333
+ ? row.group.state !== "ready" || row.agent.disabled
318
334
  ? "unavailable"
319
- : `${row.session.paneCount}p${row.group.agents?.length ? ` ${activity(row).label}` : ""}`
320
- : row.group.agents?.length && row.group.state === "ready"
321
- ? activity(row).label
322
- : `${connectionDetail(row.group)}${row.group.agents?.length ? " ?" : ""}`} selected={Boolean((row.session || row.agent) && active(row))} focused={Boolean(focused() && row.key === selectedKey())} attention={row.agent?.attention ?? activity(row).kind === "attention"} onActivate={(source) => activate(row, source)}/>
323
- <Show when={row.agent}>
324
- {(agent) => (<text height={1} fg={props.theme.roles.text.muted} content={clipTerminal(` ${friendlySessionLabel(agent().sessionName)}`, Math.max(1, props.width - 1))}/>)}
325
- </Show>
326
- </box>)}
335
+ : `[${terminalAgentStatusLabel(row.agent.activity)}]`
336
+ : row.session
337
+ ? row.group.state !== "ready" || row.session.disabled
338
+ ? "unavailable"
339
+ : `${row.session.paneCount}p${row.group.agents?.length ? ` ${activity(row).label}` : ""}`
340
+ : row.group.agents?.length && row.group.state === "ready"
341
+ ? activity(row).label
342
+ : `${connectionDetail(row.group)}${row.group.agents?.length ? " ?" : ""}`} selected={Boolean((row.session || row.agent) && active(row))} focused={Boolean(focused() && row.key === selectedKey())} attention={row.agent?.attention ?? activity(row).kind === "attention"} onActivate={(source) => activate(row, source)}/>
343
+ <Show when={row.agent}>
344
+ {(agent) => (<text height={1} fg={props.theme.roles.text.muted} content={clipTerminal(` ${friendlySessionLabel(agent().sessionName)}`, Math.max(1, props.width - 1))}/>)}
345
+ </Show>
346
+ </box>);
347
+ }}
327
348
  </For>
328
349
  </scrollbox>
329
350
  <For each={agentHeight() > 0 ? [true] : []}>