tmux-ide 2.9.0-beta.17 → 2.9.0-beta.18

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 (27) hide show
  1. package/bin/cli.js +11 -8
  2. package/package.json +1 -1
  3. package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +9 -5
  4. package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +30 -1
  5. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +35 -78
  6. package/packages/daemon/dist/tui/mirror/runtime/application-palette-search-owner.js +22 -9
  7. package/packages/daemon/dist/tui/mirror/ui/keyboard-router.jsx +22 -0
  8. package/packages/daemon/dist/tui/mirror/ui/overlay-list-row.jsx +25 -2
  9. package/packages/daemon/dist/tui/mirror/workspace/application-command-description.js +23 -5
  10. package/packages/daemon/dist/tui/team/fuzzy.js +36 -15
  11. package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +12 -10
  12. package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +35 -2
  13. package/packages/daemon/src/tui/mirror/runtime/application-fleet-session-actions.tsx +12 -3
  14. package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +87 -113
  15. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +39 -92
  16. package/packages/daemon/src/tui/mirror/runtime/application-machine-overlays.tsx +8 -1
  17. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +32 -5
  18. package/packages/daemon/src/tui/mirror/runtime/application-palette-search-owner.ts +34 -7
  19. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +10 -2
  20. package/packages/daemon/src/tui/mirror/runtime/application-shell-catalog.tsx +16 -1
  21. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +178 -82
  22. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +6 -0
  23. package/packages/daemon/src/tui/mirror/ui/keyboard-router.tsx +22 -0
  24. package/packages/daemon/src/tui/mirror/ui/overlay-list-row.tsx +30 -2
  25. package/packages/daemon/src/tui/mirror/workspace/application-command-description.ts +29 -6
  26. package/packages/daemon/src/tui/team/fuzzy.ts +38 -14
  27. package/packages/daemon/dist/tui/mirror/runtime/application-fleet-switcher.jsx +0 -88
package/bin/cli.js CHANGED
@@ -10272,7 +10272,7 @@ var require_package = __commonJS({
10272
10272
  "package.json"(exports, module) {
10273
10273
  module.exports = {
10274
10274
  name: "tmux-ide",
10275
- version: "2.9.0-beta.17",
10275
+ version: "2.9.0-beta.18",
10276
10276
  description: "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
10277
10277
  type: "module",
10278
10278
  bin: {
@@ -18446,12 +18446,14 @@ function createFleetPreviewCapture(run) {
18446
18446
  ],
18447
18447
  signal
18448
18448
  )).trim().split("\n").map((line) => line.split(" "));
18449
- const rows = await readPanes();
18450
18449
  const names = /* @__PURE__ */ new Map();
18451
- const windowNames = await run(
18452
- ["list-windows", "-t", `=${session.sessionName}`, "-F", "#{window_id} #{window_name}"],
18453
- signal
18454
- );
18450
+ const [rows, windowNames] = await Promise.all([
18451
+ readPanes(),
18452
+ run(
18453
+ ["list-windows", "-t", `=${session.sessionName}`, "-F", "#{window_id} #{window_name}"],
18454
+ signal
18455
+ )
18456
+ ]);
18455
18457
  for (const line of windowNames.split("\n")) {
18456
18458
  const tab = line.indexOf(" ");
18457
18459
  if (tab > 0)
@@ -18498,8 +18500,9 @@ function createFleetPreviewCapture(run) {
18498
18500
  const pane = candidates.find((r) => r[2] === "1")?.[0] ?? candidates[0]?.[0];
18499
18501
  if (!pane || !/^%\d+$/u.test(pane)) return null;
18500
18502
  const captured = await run(["capture-pane", "-p", "-t", pane, "-S", "-24"], signal);
18501
- if (!(await readSessions()).some((s) => s.liveSessionId === liveSessionId2)) return null;
18502
- if (selectedWindowId && !(await readPanes()).some((r) => r[3] === selectedWindowId && r[0] === pane))
18503
+ const [currentSessions, currentPanes] = await Promise.all([readSessions(), readPanes()]);
18504
+ if (!currentSessions.some((s) => s.liveSessionId === liveSessionId2)) return null;
18505
+ if (selectedWindowId && !currentPanes.some((r) => r[3] === selectedWindowId && r[0] === pane))
18503
18506
  return null;
18504
18507
  return {
18505
18508
  windows,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.0-beta.17",
3
+ "version": "2.9.0-beta.18",
4
4
  "description": "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,11 +24,15 @@ export function createFleetPreviewCapture(run) {
24
24
  .trim()
25
25
  .split("\n")
26
26
  .map((line) => line.split("\t"));
27
- const rows = await readPanes();
28
27
  // Names are untrusted display text. Keep them out of the pane membership
29
28
  // format so embedded newlines cannot manufacture a capture target.
30
29
  const names = new Map();
31
- const windowNames = await run(["list-windows", "-t", `=${session.sessionName}`, "-F", "#{window_id}\t#{window_name}"], signal);
30
+ // Independent metadata reads share one capture's deadline and run in two
31
+ // bounded lanes. Incarnation checks still surround the actual capture.
32
+ const [rows, windowNames] = await Promise.all([
33
+ readPanes(),
34
+ run(["list-windows", "-t", `=${session.sessionName}`, "-F", "#{window_id}\t#{window_name}"], signal),
35
+ ]);
32
36
  for (const line of windowNames.split("\n")) {
33
37
  const tab = line.indexOf("\t");
34
38
  if (tab > 0)
@@ -81,10 +85,10 @@ export function createFleetPreviewCapture(run) {
81
85
  return null;
82
86
  // Passive capture never changes active windows, size, input or terminal ownership.
83
87
  const captured = await run(["capture-pane", "-p", "-t", pane, "-S", "-24"], signal);
84
- if (!(await readSessions()).some((s) => s.liveSessionId === liveSessionId))
88
+ const [currentSessions, currentPanes] = await Promise.all([readSessions(), readPanes()]);
89
+ if (!currentSessions.some((s) => s.liveSessionId === liveSessionId))
85
90
  return null;
86
- if (selectedWindowId &&
87
- !(await readPanes()).some((r) => r[3] === selectedWindowId && r[0] === pane))
91
+ if (selectedWindowId && !currentPanes.some((r) => r[3] === selectedWindowId && r[0] === pane))
88
92
  return null;
89
93
  return {
90
94
  windows,
@@ -145,6 +145,32 @@ export function createFleetPreviewOwner(publish, delay = 180) {
145
145
  dispose: clear,
146
146
  };
147
147
  }
148
+ /** Short-lived, bounded memory only. Keys must include route, incarnation and transport epoch. */
149
+ export function createFleetPreviewCache(now = Date.now) {
150
+ const entries = new Map();
151
+ return {
152
+ get(key) {
153
+ const value = entries.get(key);
154
+ if (!value || now() - value.at > 30_000) {
155
+ entries.delete(key);
156
+ return null;
157
+ }
158
+ entries.delete(key);
159
+ entries.set(key, value);
160
+ return value.snapshot;
161
+ },
162
+ set(key, snapshot) {
163
+ entries.delete(key);
164
+ entries.set(key, { snapshot, at: now() });
165
+ while (entries.size > 24)
166
+ entries.delete(entries.keys().next().value);
167
+ },
168
+ clear() {
169
+ entries.clear();
170
+ },
171
+ };
172
+ }
173
+ export const fleetPreviewMemory = createFleetPreviewCache();
148
174
  /** One selected request, scheduled after settlement; no hidden polling or terminal streams. */
149
175
  export function createAdaptiveFleetPreviewOwner(publish, options = {}) {
150
176
  let key;
@@ -197,6 +223,8 @@ export function createAdaptiveFleetPreviewOwner(publish, options = {}) {
197
223
  return;
198
224
  if (result.status === "ready") {
199
225
  snapshot = result.snapshot;
226
+ if (key)
227
+ options.cache?.set(key, snapshot);
200
228
  failures = 0;
201
229
  emit({ status: "ready", snapshot, stale: false });
202
230
  }
@@ -215,7 +243,8 @@ export function createAdaptiveFleetPreviewOwner(publish, options = {}) {
215
243
  key = nextRead ? nextKey : undefined;
216
244
  snapshot = null;
217
245
  failures = 0;
218
- emit({ status: "idle", snapshot: null, stale: false });
246
+ snapshot = read && key ? (options.cache?.get(key) ?? null) : null;
247
+ emit({ status: snapshot ? "loading" : "idle", snapshot, stale: snapshot !== null });
219
248
  if (!read)
220
249
  return;
221
250
  controller = new AbortController();
@@ -1,6 +1,5 @@
1
1
  import { fleetHostColor } from "./fleet-presentation.js";
2
2
  import { createFleetTabs } from "./application-fleet-tabs.js";
3
- import { readFleetPreview } from "./application-fleet-preview.js";
4
3
  import { saveMachineProfiles } from "../../../lib/local-fleet-request.js";
5
4
  import { createApplicationFleetPreferences } from "./application-fleet-preferences.js";
6
5
  import { createApplicationMachineAgents, } from "./application-machine-agents.js";
@@ -244,28 +243,42 @@ export function createApplicationMachineNavigation(options) {
244
243
  },
245
244
  closeSwitcher: () => setSwitching(false),
246
245
  paletteCommands() {
246
+ const groupsById = new Map(agentGroups().map((group) => [group.machineId, group]));
247
247
  return snapshot().groups.flatMap((group) => {
248
+ const agentGroup = groupsById.get(group.id);
249
+ const agentsBySession = new Map();
250
+ for (const agent of agentGroup?.agents ?? []) {
251
+ if (!agent.liveSessionId || !agent.paneId)
252
+ continue;
253
+ const list = agentsBySession.get(agent.liveSessionId) ?? [];
254
+ list.push(agent);
255
+ agentsBySession.set(agent.liveSessionId, list);
256
+ }
248
257
  const daemon = manager.getMachine(group.id)?.read();
249
258
  const sessions = group.sessions.flatMap((session) => {
250
259
  if (!session.liveSessionId)
251
260
  return [];
252
261
  const fleet = {
253
262
  machineId: group.id,
263
+ favorite: saved().favorites.includes(session.id),
264
+ recentRank: saved().recent.includes(session.id)
265
+ ? saved().recent.indexOf(session.id)
266
+ : 1000,
254
267
  liveSessionId: session.liveSessionId,
255
268
  hostLabel: group.label,
256
- agentActivities: agentGroups().find((g) => g.machineId === group.id)?.available
257
- ? (agentGroups().find((g) => g.machineId === group.id)?.agents ?? [])
258
- .filter((a) => a.liveSessionId === session.liveSessionId && a.paneId)
259
- .map((a) => ({ paneId: a.paneId, attention: a.attention, activity: a.activity }))
269
+ agentActivities: agentGroup?.available
270
+ ? (agentsBySession.get(session.liveSessionId) ?? []).map((a) => ({
271
+ paneId: a.paneId,
272
+ attention: a.attention,
273
+ activity: a.activity,
274
+ }))
260
275
  : undefined,
261
276
  daemonInstanceId: daemon?.instanceId ?? "",
262
277
  disabled: session.disabled || group.state !== "ready",
263
278
  };
264
279
  return [
265
280
  { kind: "open-session", sessionName: session.name, label: session.name, fleet },
266
- ...(agentGroups().find((g) => g.machineId === group.id)?.agents ?? [])
267
- .filter((a) => a.liveSessionId === session.liveSessionId && a.paneId)
268
- .map((a) => ({
281
+ ...(agentsBySession.get(session.liveSessionId) ?? []).map((a) => ({
269
282
  kind: "jump-agent",
270
283
  sessionName: session.name,
271
284
  paneId: a.paneId,
@@ -291,6 +304,20 @@ export function createApplicationMachineNavigation(options) {
291
304
  ];
292
305
  });
293
306
  },
307
+ togglePaletteFavorite(command) {
308
+ if (typeof command !== "object" || command.kind !== "open-session" || !command.fleet)
309
+ return;
310
+ const target = command.fleet;
311
+ const session = snapshot()
312
+ .groups.find((g) => g.id === target.machineId)
313
+ ?.sessions.find((s) => s.liveSessionId === target.liveSessionId);
314
+ if (session)
315
+ preferences.change({
316
+ type: "favorite",
317
+ key: session.id,
318
+ enabled: !saved().favorites.includes(session.id),
319
+ });
320
+ },
294
321
  async openPalette(command, source) {
295
322
  const target = command.fleet;
296
323
  if (!target)
@@ -317,76 +344,6 @@ export function createApplicationMachineNavigation(options) {
317
344
  else
318
345
  await open(target.machineId, session.name, source, true, target.liveSessionId);
319
346
  },
320
- switcherRows: () => {
321
- const favorite = new Set(saved().favorites);
322
- const recent = saved().recent;
323
- const rows = snapshot().groups.flatMap((group) => [
324
- {
325
- key: `machine:${group.id}`,
326
- label: group.label,
327
- detail: `Machine · ${group.state}`,
328
- favorite: false,
329
- attention: false,
330
- disabled: false,
331
- canFavorite: false,
332
- open: () => sidebar.onSelectMachine(group.id, "keyboard"),
333
- toggleFavorite: () => { },
334
- },
335
- ...group.sessions.map((session) => ({
336
- key: session.id,
337
- previewKey: JSON.stringify([
338
- session.id,
339
- manager.getMachine(group.id)?.endpoint().epoch,
340
- ]),
341
- preview: session.liveSessionId
342
- ? (signal) => {
343
- const handle = manager.getMachine(group.id);
344
- return handle
345
- ? readFleetPreview(handle, session.liveSessionId, signal)
346
- : Promise.resolve("Preview unavailable");
347
- }
348
- : undefined,
349
- label: session.name,
350
- detail: group.label,
351
- favorite: favorite.has(session.id),
352
- attention: false,
353
- disabled: session.disabled,
354
- canFavorite: true,
355
- open: () => {
356
- const current = catalog
357
- .getSnapshot()
358
- .groups.find((g) => g.id === group.id)
359
- ?.sessions.find((s) => s.id === session.id && !s.disabled);
360
- if (current)
361
- void open(group.id, current.name, "keyboard");
362
- },
363
- toggleFavorite: () => preferences.change({
364
- type: "favorite",
365
- key: session.id,
366
- enabled: !favorite.has(session.id),
367
- }),
368
- })),
369
- ...(agentGroups().find((g) => g.machineId === group.id)?.agents ?? []).map((agent) => ({
370
- key: agent.id,
371
- label: agent.name,
372
- detail: `${group.label} / ${agent.sessionName}`,
373
- favorite: false,
374
- attention: agent.attention && !agent.disabled,
375
- disabled: agent.disabled || group.state !== "ready",
376
- canFavorite: false,
377
- open: () => {
378
- if (agent.paneId)
379
- sidebar.onOpenAgent?.(group.id, agent.sessionName, agent.paneId, "keyboard");
380
- },
381
- toggleFavorite: () => { },
382
- })),
383
- ]);
384
- return rows.sort((a, b) => Number(b.favorite) - Number(a.favorite) ||
385
- Number(b.attention) - Number(a.attention) ||
386
- (recent.includes(a.key) ? recent.indexOf(a.key) : 1000) -
387
- (recent.includes(b.key) ? recent.indexOf(b.key) : 1000) ||
388
- a.label.localeCompare(b.label));
389
- },
390
347
  adding,
391
348
  alias,
392
349
  error,
@@ -4,11 +4,16 @@ import { applicationPaletteKeyboardDisposition, } from "./application-palette-in
4
4
  import { applicationPaneRenameKeyAction, applicationPaneRenamePaste, } from "./application-pane-rename-input.js";
5
5
  /** Query/selection/input only. No physical listeners or terminal subscriptions. */
6
6
  export function createApplicationPaletteSearchOwner(options) {
7
+ const [localOnly, setLocalOnly] = createSignal(false);
8
+ const [viewport, setViewport] = createSignal(10);
9
+ const pageSize = () => options.pageSize?.() ?? viewport();
7
10
  const [normal, setNormal] = createSignal(false);
8
11
  const [help, setHelp] = createSignal(false);
9
12
  const [query, setQuery] = createSignal("");
10
13
  const [selectedId, setSelectedId] = createSignal(null);
11
- const commands = createMemo(() => filterApplicationCommands(options.commands(), query()));
14
+ const commands = createMemo(() => filterApplicationCommands(options
15
+ .commands()
16
+ .filter((c) => !localOnly() || typeof c === "string" || !c.fleet || c.fleet.machineId === "local"), query()));
12
17
  const selection = () => Math.max(0, commands().findIndex((command) => applicationCommandDescription(command).id === selectedId()));
13
18
  const select = (index) => {
14
19
  const command = commands()[index];
@@ -28,11 +33,14 @@ export function createApplicationPaletteSearchOwner(options) {
28
33
  };
29
34
  return {
30
35
  query,
31
- keyboardHint: () => help()
32
- ? "Normal: j/k g/G Ctrl-U/D · i search · Ctrl-←/→ windows · Ctrl-P preview · Ctrl-E expand"
33
- : normal()
34
- ? "NORMAL · i search · ? help · Ctrl-Space mode"
35
- : "SEARCH · Ctrl-Space navigation · Ctrl-←/→ windows",
36
+ setViewport,
37
+ setQuery: updateQuery,
38
+ keyboardHint: () => (localOnly() ? "LOCAL ONLY · ^H all hosts · " : "") +
39
+ (help()
40
+ ? "j/k g/G ^U/D · i search · ^N new · ^X close · ^F favorite · ^←/→ windows · ^P hide · ^E expand"
41
+ : normal()
42
+ ? "NORMAL · i search · ? help · Ctrl-Space mode"
43
+ : "SEARCH · ^Space navigation · ^H local/all hosts"),
36
44
  commands,
37
45
  selection,
38
46
  select,
@@ -49,6 +57,10 @@ export function createApplicationPaletteSearchOwner(options) {
49
57
  if (event.eventType === "release")
50
58
  return true;
51
59
  const name = event.name.toLowerCase();
60
+ if (event.ctrl && name === "h") {
61
+ setLocalOnly(!localOnly());
62
+ return true;
63
+ }
52
64
  if (event.ctrl && name === "space") {
53
65
  setNormal(!normal());
54
66
  return true;
@@ -67,14 +79,15 @@ export function createApplicationPaletteSearchOwner(options) {
67
79
  return true;
68
80
  }
69
81
  const last = Math.max(0, commands().length - 1);
82
+ const halfPage = Math.max(1, Math.floor(pageSize() / 2));
70
83
  const delta = name === "j"
71
84
  ? 1
72
85
  : name === "k"
73
86
  ? -1
74
87
  : event.ctrl && name === "d"
75
- ? 5
88
+ ? halfPage
76
89
  : event.ctrl && name === "u"
77
- ? -5
90
+ ? -halfPage
78
91
  : 0;
79
92
  if (name === "g") {
80
93
  select(event.shift ? last : 0);
@@ -93,7 +106,7 @@ export function createApplicationPaletteSearchOwner(options) {
93
106
  ? 0
94
107
  : name === "end"
95
108
  ? last
96
- : Math.max(0, Math.min(last, selection() + (name === "pageup" ? -5 : 5))));
109
+ : Math.max(0, Math.min(last, selection() + (name === "pageup" ? -1 : 1) * Math.max(1, pageSize()))));
97
110
  return true;
98
111
  }
99
112
  const action = !event.ctrl && !event.meta
@@ -2,7 +2,22 @@ import { createContext, onCleanup, useContext } from "solid-js";
2
2
  /** One application keyboard ingress with component-local semantic routes. */
3
3
  export function createKeyboardRouteOwner() {
4
4
  const routes = [];
5
+ const pasteRoutes = [];
5
6
  return {
7
+ registerPaste(route) {
8
+ pasteRoutes.push(route);
9
+ return () => {
10
+ const index = pasteRoutes.lastIndexOf(route);
11
+ if (index >= 0)
12
+ pasteRoutes.splice(index, 1);
13
+ };
14
+ },
15
+ routePaste(bytes) {
16
+ for (let index = pasteRoutes.length - 1; index >= 0; index--)
17
+ if (pasteRoutes[index](bytes))
18
+ return true;
19
+ return false;
20
+ },
6
21
  register(route) {
7
22
  routes.push(route);
8
23
  let active = true;
@@ -24,6 +39,7 @@ export function createKeyboardRouteOwner() {
24
39
  },
25
40
  dispose() {
26
41
  routes.length = 0;
42
+ pasteRoutes.length = 0;
27
43
  },
28
44
  get size() {
29
45
  return routes.length;
@@ -44,3 +60,9 @@ export function useKeyboardRoute(route) {
44
60
  const unregister = owner.register(route);
45
61
  onCleanup(unregister);
46
62
  }
63
+ /** Component-local paste ownership; physical ingress remains at the application root. */
64
+ export function usePasteRoute(route) {
65
+ const owner = useContext(KeyboardRouteContext);
66
+ if (owner)
67
+ onCleanup(owner.registerPaste(route));
68
+ }
@@ -1,3 +1,6 @@
1
+ /* @jsxImportSource @opentui/solid */
2
+ import { For, createMemo } from "solid-js";
3
+ import { fuzzyTermsMatch as commandSearchMatch } from "../../team/fuzzy.js";
1
4
  import { clipTerminal, terminalDisplayWidth } from "../terminal-text.js";
2
5
  import { componentPalette } from "./state.js";
3
6
  export function OverlayListRow(props) {
@@ -13,7 +16,21 @@ export function OverlayListRow(props) {
13
16
  terminalDisplayWidth(shortcut));
14
17
  return clipTerminal(`${prefix}${label}${" ".repeat(gap)}${shortcut}`, props.width);
15
18
  };
16
- return (<text id={`ui-overlay-row:${props.id}`} width={props.width} height={1} overflow="hidden" content={content()} fg={palette().foreground} bg={palette().background} onMouseOver={() => {
19
+ const segments = createMemo(() => {
20
+ const value = content();
21
+ const matches = new Set(props.query ? (commandSearchMatch(value, props.query)?.indices ?? []) : []);
22
+ const runs = [];
23
+ for (const [index, character] of Array.from(value).entries()) {
24
+ const match = matches.has(index);
25
+ const previous = runs.at(-1);
26
+ if (previous?.match === match)
27
+ previous.text += character;
28
+ else
29
+ runs.push({ text: character, match });
30
+ }
31
+ return runs;
32
+ });
33
+ return (<text id={`ui-overlay-row:${props.id}`} width={props.width} height={1} overflow="hidden" fg={palette().foreground} bg={palette().background} onMouseOver={() => {
17
34
  if (!props.disabled)
18
35
  props.onHighlight?.();
19
36
  }} onMouseMove={() => {
@@ -25,5 +42,11 @@ export function OverlayListRow(props) {
25
42
  event.preventDefault();
26
43
  event.stopPropagation();
27
44
  props.onPress();
28
- }}/>);
45
+ }}>
46
+ <For each={segments()}>
47
+ {(segment) => segment.match ? (<strong>
48
+ <u>{segment.text}</u>
49
+ </strong>) : (segment.text)}
50
+ </For>
51
+ </text>);
29
52
  }
@@ -1,3 +1,5 @@
1
+ import { fuzzyTermsMatch as commandSearchMatch } from "../../team/fuzzy.js";
2
+ export { fuzzyTermsMatch as commandSearchMatch } from "../../team/fuzzy.js";
1
3
  import { PANE_ACTION_MENU_ITEMS } from "./pane-action-menu-model.js";
2
4
  /** Presentation only: execution remains in the existing application owners. */
3
5
  export function applicationCommandDescription(command) {
@@ -52,10 +54,26 @@ export function applicationCommandDescription(command) {
52
54
  };
53
55
  }
54
56
  export function filterApplicationCommands(commands, query) {
55
- const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
56
- return commands.filter((command) => {
57
+ return commands
58
+ .map((command, index) => {
57
59
  const { label, detail } = applicationCommandDescription(command);
58
- const text = `${label} ${detail}`.toLocaleLowerCase();
59
- return terms.every((term) => text.includes(term));
60
- });
60
+ const score = commandSearchMatch(`${label} ${detail}`, query)?.score;
61
+ const fleet = typeof command === "object" ? command.fleet : undefined;
62
+ return {
63
+ command,
64
+ index,
65
+ score,
66
+ favorite: Boolean(fleet?.favorite),
67
+ recent: fleet?.recentRank ?? 1000,
68
+ };
69
+ })
70
+ .filter((row) => row.score !== undefined)
71
+ .sort((a, b) => (!query.trim()
72
+ ? Number(typeof a.command !== "string") - Number(typeof b.command !== "string")
73
+ : 0) ||
74
+ b.score - a.score ||
75
+ Number(b.favorite) - Number(a.favorite) ||
76
+ a.recent - b.recent ||
77
+ a.index - b.index)
78
+ .map((row) => row.command);
61
79
  }
@@ -71,25 +71,25 @@ export function fuzzyMatch(query, target) {
71
71
  const fi = f[i];
72
72
  const fnext = f[i + 1];
73
73
  const ni = nextPos[i];
74
- for (let j = 0; j < n; j++) {
74
+ // Scan backwards with the best suffix. This preserves the exact scoring
75
+ // and leftmost tie-break while reducing O(query * target²) to O(query * target).
76
+ let suffixScore = NEG;
77
+ let suffixIndex = -1;
78
+ for (let j = n - 1; j >= 0; j--) {
79
+ const candidate = j + 2;
80
+ if (candidate < n && fnext[candidate] !== NEG && fnext[candidate] >= suffixScore) {
81
+ suffixScore = fnext[candidate];
82
+ suffixIndex = candidate;
83
+ }
75
84
  if (t[j] !== ch)
76
85
  continue;
77
- let best = NEG;
78
- let bestNext = -1;
79
- for (let j2 = j + 1; j2 < n; j2++) {
80
- const sub = fnext[j2];
81
- if (sub === NEG)
82
- continue;
83
- const val = (j2 === j + 1 ? CONTIGUOUS_BONUS : 0) + sub;
84
- if (val > best) {
85
- best = val;
86
- bestNext = j2;
87
- }
88
- }
86
+ const adjacent = j + 1 < n ? fnext[j + 1] : NEG;
87
+ const contiguous = adjacent === NEG ? NEG : adjacent + CONTIGUOUS_BONUS;
88
+ const best = Math.max(contiguous, suffixScore);
89
89
  if (best === NEG)
90
- continue; // q[i+1..] cannot be placed after j
90
+ continue;
91
91
  fi[j] = BASE + positionBonus(j) + best;
92
- ni[j] = bestNext;
92
+ ni[j] = contiguous >= suffixScore ? j + 1 : suffixIndex;
93
93
  }
94
94
  }
95
95
  // Best starting placement for q[0] (leftmost on ties, keeping it deterministic).
@@ -139,3 +139,24 @@ export function fuzzyFilter(query, items, key) {
139
139
  matches.sort((a, b) => b.score - a.score);
140
140
  return matches;
141
141
  }
142
+ /** Multiple search terms can match in any order. Indices address displayed code points. */
143
+ export function fuzzyTermsMatch(text, query) {
144
+ let score = 0;
145
+ const positions = new Set();
146
+ for (const term of query.trim().split(/\s+/u).filter(Boolean)) {
147
+ const match = fuzzyMatch(term, text);
148
+ if (!match)
149
+ return null;
150
+ score += match.score;
151
+ for (const position of match.positions)
152
+ positions.add(position);
153
+ }
154
+ const indices = [];
155
+ let offset = 0;
156
+ for (const [index, character] of Array.from(text).entries()) {
157
+ if ([...Array(character.length).keys()].some((i) => positions.has(offset + i)))
158
+ indices.push(index);
159
+ offset += character.length;
160
+ }
161
+ return { score, indices };
162
+ }
@@ -53,14 +53,18 @@ export function createFleetPreviewCapture(
53
53
  .trim()
54
54
  .split("\n")
55
55
  .map((line) => line.split("\t"));
56
- const rows = await readPanes();
57
56
  // Names are untrusted display text. Keep them out of the pane membership
58
57
  // format so embedded newlines cannot manufacture a capture target.
59
58
  const names = new Map<string, string>();
60
- const windowNames = await run(
61
- ["list-windows", "-t", `=${session.sessionName}`, "-F", "#{window_id}\t#{window_name}"],
62
- signal,
63
- );
59
+ // Independent metadata reads share one capture's deadline and run in two
60
+ // bounded lanes. Incarnation checks still surround the actual capture.
61
+ const [rows, windowNames] = await Promise.all([
62
+ readPanes(),
63
+ run(
64
+ ["list-windows", "-t", `=${session.sessionName}`, "-F", "#{window_id}\t#{window_name}"],
65
+ signal,
66
+ ),
67
+ ]);
64
68
  for (const line of windowNames.split("\n")) {
65
69
  const tab = line.indexOf("\t");
66
70
  if (tab > 0)
@@ -116,11 +120,9 @@ export function createFleetPreviewCapture(
116
120
  if (!pane || !/^%\d+$/u.test(pane)) return null;
117
121
  // Passive capture never changes active windows, size, input or terminal ownership.
118
122
  const captured = await run(["capture-pane", "-p", "-t", pane, "-S", "-24"], signal);
119
- if (!(await readSessions()).some((s) => s.liveSessionId === liveSessionId)) return null;
120
- if (
121
- selectedWindowId &&
122
- !(await readPanes()).some((r) => r[3] === selectedWindowId && r[0] === pane)
123
- )
123
+ const [currentSessions, currentPanes] = await Promise.all([readSessions(), readPanes()]);
124
+ if (!currentSessions.some((s) => s.liveSessionId === liveSessionId)) return null;
125
+ if (selectedWindowId && !currentPanes.some((r) => r[3] === selectedWindowId && r[0] === pane))
124
126
  return null;
125
127
  return {
126
128
  windows,
@@ -170,10 +170,41 @@ export interface AdaptiveFleetPreviewState {
170
170
  snapshot: FleetPreviewSnapshot | null;
171
171
  stale: boolean;
172
172
  }
173
+ /** Short-lived, bounded memory only. Keys must include route, incarnation and transport epoch. */
174
+ export function createFleetPreviewCache(now = Date.now) {
175
+ const entries = new Map<string, { snapshot: FleetPreviewSnapshot; at: number }>();
176
+ return {
177
+ get(key: string) {
178
+ const value = entries.get(key);
179
+ if (!value || now() - value.at > 30_000) {
180
+ entries.delete(key);
181
+ return null;
182
+ }
183
+ entries.delete(key);
184
+ entries.set(key, value);
185
+ return value.snapshot;
186
+ },
187
+ set(key: string, snapshot: FleetPreviewSnapshot) {
188
+ entries.delete(key);
189
+ entries.set(key, { snapshot, at: now() });
190
+ while (entries.size > 24) entries.delete(entries.keys().next().value!);
191
+ },
192
+ clear() {
193
+ entries.clear();
194
+ },
195
+ };
196
+ }
197
+ export const fleetPreviewMemory = createFleetPreviewCache();
198
+
173
199
  /** One selected request, scheduled after settlement; no hidden polling or terminal streams. */
174
200
  export function createAdaptiveFleetPreviewOwner(
175
201
  publish: (state: AdaptiveFleetPreviewState) => void,
176
- options: { debounceMs?: number; refreshMs?: number; maxBackoffMs?: number } = {},
202
+ options: {
203
+ debounceMs?: number;
204
+ refreshMs?: number;
205
+ maxBackoffMs?: number;
206
+ cache?: ReturnType<typeof createFleetPreviewCache>;
207
+ } = {},
177
208
  ) {
178
209
  let key: string | undefined;
179
210
  let read: ((signal: AbortSignal) => Promise<FleetPreviewResult>) | undefined;
@@ -217,6 +248,7 @@ export function createAdaptiveFleetPreviewOwner(
217
248
  if (owned.signal.aborted || controller !== owned) return;
218
249
  if (result.status === "ready") {
219
250
  snapshot = result.snapshot;
251
+ if (key) options.cache?.set(key, snapshot);
220
252
  failures = 0;
221
253
  emit({ status: "ready", snapshot, stale: false });
222
254
  } else {
@@ -236,7 +268,8 @@ export function createAdaptiveFleetPreviewOwner(
236
268
  key = nextRead ? nextKey : undefined;
237
269
  snapshot = null;
238
270
  failures = 0;
239
- emit({ status: "idle", snapshot: null, stale: false });
271
+ snapshot = read && key ? (options.cache?.get(key) ?? null) : null;
272
+ emit({ status: snapshot ? "loading" : "idle", snapshot, stale: snapshot !== null });
240
273
  if (!read) return;
241
274
  controller = new AbortController();
242
275
  schedule(Math.max(0, options.debounceMs ?? 180), controller);