skydive-cli 0.1.0-beta.239 → 0.1.0-beta.276

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.
@@ -1,21 +1,23 @@
1
1
  #!/usr/bin/env node
2
- import { A as getSavedTheme, C as themesForMode, D as DEFAULT_API_URL, E as setActiveWorkspace, M as saveTheme, O as DEFAULT_APP_URL, S as themeVersion, T as listWorkspaces, _ as noColorRequested, a as parseOauthConnectParams, b as themeMode, c as parseConnectCard, d as HttpError, f as createRestClient, g as monoTheme, h as findTheme, i as parseExternalOauthConnectParams, j as resolveWebUrl, k as getConfigPath, l as errorMessage, m as applyTheme, n as MASK_CHAR, o as reconcileMaskedInput, p as DEFAULT_THEME_ID, r as cardActionErrorMessage, s as resolveConnectUrl, t as resolveAgent, u as isRecord, v as theme, w as getActiveWorkspaceId, x as themeModeFromColorFgBg, y as themeForMode } from "./bin.mjs";
2
+ import { A as themesForMode, C as monoTheme, D as themeMode, E as themeForMode, F as DEFAULT_APP_URL, I as getConfigPath, L as getSavedTheme, M as listWorkspaces, N as setActiveWorkspace, O as themeModeFromColorFgBg, P as DEFAULT_API_URL, R as resolveWebUrl, S as findTheme, T as theme, _ as isRecord, b as DEFAULT_THEME_ID, c as resolveAgent, d as parseExternalOauthConnectParams, f as parseOauthConnectParams, g as errorMessage, h as parseConnectCard, j as getActiveWorkspaceId, k as themeVersion, l as MASK_CHAR, m as resolveConnectUrl, p as reconcileMaskedInput, u as cardActionErrorMessage, v as HttpError, w as noColorRequested, x as applyTheme, y as createRestClient, z as saveTheme } from "./bin.mjs";
3
+ import { t as PortalClient } from "./client-_OL8-XGH.mjs";
3
4
  import path, { basename, isAbsolute, join, win32 } from "node:path";
4
5
  import { z } from "zod";
5
6
  import open from "open";
6
7
  import { execFile, spawn } from "node:child_process";
7
8
  import { createHash } from "node:crypto";
8
9
  import { constants } from "node:fs";
10
+ import * as os$1 from "node:os";
11
+ import { homedir, platform, release, tmpdir } from "node:os";
9
12
  import { MarkdownRenderable, RenderableEvents, SyntaxStyle, createCliRenderer, decodePasteBytes, detectLinks } from "@opentui/core";
10
13
  import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
11
14
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
12
15
  import { create } from "zustand";
13
16
  import { WebSocket } from "ws";
14
- import * as os$1 from "node:os";
15
- import os, { homedir, platform, release, tmpdir } from "node:os";
16
17
  import { createConnection } from "node:net";
17
18
  import { access, appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
18
19
  import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
20
+ import fuzzysort from "fuzzysort";
19
21
  import { fileURLToPath } from "node:url";
20
22
  import { fileTypeFromBuffer } from "file-type";
21
23
  import { structuredPatch } from "diff";
@@ -157,448 +159,6 @@ async function resolveInitialScreen({ rest, agentSelector }) {
157
159
  };
158
160
  }
159
161
 
160
- //#endregion
161
- //#region ../portal-protocol/src/index.ts
162
- const MAX_WS_FRAME_BYTES = 16 * 1024 * 1024;
163
- const T_DATA = 1;
164
- const T_CTRL = 2;
165
- const STREAM = {
166
- stdout: 0,
167
- stderr: 1,
168
- stdin: 2
169
- };
170
- const uuidToBytes = (id) => Buffer.from(id.replace(/-/g, ""), "hex");
171
- const bytesToUuid = (b) => {
172
- const h = b.toString("hex");
173
- return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
174
- };
175
- function encodeData(id, stream, seq, payload) {
176
- const head = Buffer.allocUnsafe(22);
177
- head[0] = T_DATA;
178
- uuidToBytes(id).copy(head, 1);
179
- head[17] = stream;
180
- head.writeUInt32BE(seq >>> 0, 18);
181
- return Buffer.concat([head, payload]);
182
- }
183
- const ctrlMessageSchema = z.discriminatedUnion("t", [
184
- z.object({
185
- t: z.literal("open"),
186
- argv: z.array(z.string()),
187
- env: z.record(z.string()).nullable()
188
- }),
189
- z.object({ t: z.literal("stdin_eof") }),
190
- z.object({ t: z.literal("pause") }),
191
- z.object({ t: z.literal("resume") }),
192
- z.object({ t: z.literal("cancel") }),
193
- z.object({
194
- t: z.literal("close"),
195
- exitCode: z.number()
196
- }),
197
- z.object({
198
- t: z.literal("error"),
199
- message: z.string()
200
- })
201
- ]);
202
- function encodeCtrl(id, obj) {
203
- const head = Buffer.allocUnsafe(17);
204
- head[0] = T_CTRL;
205
- uuidToBytes(id).copy(head, 1);
206
- return Buffer.concat([head, Buffer.from(JSON.stringify(obj), "utf8")]);
207
- }
208
- function decodeFrame(frame) {
209
- const id = bytesToUuid(frame.subarray(1, 17));
210
- if (frame[0] === T_DATA) return {
211
- kind: "data",
212
- id,
213
- stream: frame[17] ?? 0,
214
- seq: frame.readUInt32BE(18),
215
- payload: frame.subarray(22)
216
- };
217
- return {
218
- kind: "ctrl",
219
- id,
220
- obj: ctrlMessageSchema.parse(JSON.parse(frame.subarray(17).toString("utf8")))
221
- };
222
- }
223
-
224
- //#endregion
225
- //#region src/chat/portal/machine.ts
226
- /**
227
- * Identity this machine registers under when the CLI shares it via the portal.
228
- *
229
- * The `-cli` suffix / `(CLI)` label keep a CLI-shared machine a DISTINCT portal
230
- * device from the same host's Skydive Desktop app. `portal_device` is unique on
231
- * (org, user, machineName), and directives route to whichever socket holds the
232
- * device — if the CLI and desktop registered the same name they'd share a
233
- * device row and both execute every directive. Distinct names also make the
234
- * grant UI unambiguous about which surface is being authorized.
235
- */
236
- function machineIdentity() {
237
- const host = (os.hostname() || "machine").trim().replace(/\.local$/i, "") || "machine";
238
- return {
239
- machineName: `${host}-cli`,
240
- friendlyName: `${host} (CLI)`
241
- };
242
- }
243
- const INHERITED_ENV = [
244
- "HOME",
245
- "USER",
246
- "LOGNAME",
247
- "SHELL",
248
- "LANG",
249
- "LC_ALL",
250
- "TMPDIR",
251
- "TERM",
252
- "PATH"
253
- ];
254
- function buildEnv(extra) {
255
- const env = {};
256
- for (const key of INHERITED_ENV) {
257
- const value = process.env[key];
258
- if (value !== void 0) env[key] = value;
259
- }
260
- if (extra) for (const [key, value] of Object.entries(extra)) env[key] = value;
261
- return env;
262
- }
263
- /**
264
- * Build the desktop-portal WebSocket URL from the chat origin. Mirrors the Rust
265
- * desktop client: http→ws, https→wss, scheme-less defaults to wss, and the
266
- * machine/label ride as query pairs (percent-encoded by URL).
267
- */
268
- function portalWsUrl(appUrl, machine, label) {
269
- const base = appUrl.replace(/\/+$/, "");
270
- let wsBase;
271
- if (base.startsWith("https://")) wsBase = `wss://${base.slice(8)}`;
272
- else if (base.startsWith("http://")) wsBase = `ws://${base.slice(7)}`;
273
- else wsBase = `wss://${base}`;
274
- const url = new URL(`${wsBase}/api/v1/portal/desktop`);
275
- url.searchParams.set("machine", machine);
276
- url.searchParams.set("label", label);
277
- return url.toString();
278
- }
279
-
280
- //#endregion
281
- //#region src/chat/portal/exec.ts
282
- /**
283
- * Runs portal `exec` directives locally. Each `open` spawns a child process
284
- * whose stdout/stderr stream back as data frames and whose stdin is fed by
285
- * inbound data frames, with pause/resume backpressure and cancel/teardown that
286
- * kill the child. This is the TypeScript counterpart of the desktop's Rust
287
- * `portal/mod.rs` job machinery, minus the connection supervision (which lives
288
- * in the client).
289
- */
290
- var JobManager = class {
291
- jobs = /* @__PURE__ */ new Map();
292
- constructor(opts) {
293
- this.opts = opts;
294
- }
295
- handleFrame(raw) {
296
- let decoded;
297
- try {
298
- decoded = decodeFrame(raw);
299
- } catch (_error) {
300
- return;
301
- }
302
- if (decoded.kind === "ctrl") this.handleCtrl(decoded.id, decoded.obj);
303
- else if (decoded.stream === STREAM.stdin) this.jobs.get(decoded.id)?.child.stdin.write(decoded.payload);
304
- }
305
- killAll() {
306
- for (const job of this.jobs.values()) {
307
- job.settled = true;
308
- job.child.kill("SIGKILL");
309
- }
310
- this.jobs.clear();
311
- }
312
- handleCtrl(id, msg) {
313
- switch (msg.t) {
314
- case "open":
315
- this.startJob(id, msg.argv, msg.env);
316
- return;
317
- case "stdin_eof":
318
- this.jobs.get(id)?.child.stdin.end();
319
- return;
320
- case "pause":
321
- this.setPaused(id, true);
322
- return;
323
- case "resume":
324
- this.setPaused(id, false);
325
- return;
326
- case "cancel":
327
- this.jobs.get(id)?.child.kill("SIGKILL");
328
- return;
329
- case "close":
330
- case "error": return;
331
- default: return msg;
332
- }
333
- }
334
- setPaused(id, paused) {
335
- const job = this.jobs.get(id);
336
- if (!job) return;
337
- if (paused) {
338
- job.child.stdout.pause();
339
- job.child.stderr.pause();
340
- } else {
341
- job.child.stdout.resume();
342
- job.child.stderr.resume();
343
- }
344
- }
345
- startJob(id, argv, env) {
346
- const [program, ...args] = argv;
347
- if (!program) {
348
- this.opts.send(encodeCtrl(id, {
349
- t: "error",
350
- message: "empty argv"
351
- }));
352
- return;
353
- }
354
- let child;
355
- try {
356
- child = spawn(program, args, {
357
- cwd: this.opts.cwd,
358
- env: buildEnv(env),
359
- stdio: [
360
- "pipe",
361
- "pipe",
362
- "pipe"
363
- ]
364
- });
365
- } catch (err) {
366
- this.opts.send(encodeCtrl(id, {
367
- t: "error",
368
- message: `spawn failed: ${errorMessage(err)}`
369
- }));
370
- return;
371
- }
372
- const job = {
373
- child,
374
- seq: 0,
375
- settled: false
376
- };
377
- this.jobs.set(id, job);
378
- child.on("error", (err) => {
379
- if (job.settled) return;
380
- job.settled = true;
381
- this.jobs.delete(id);
382
- this.opts.send(encodeCtrl(id, {
383
- t: "error",
384
- message: `spawn failed: ${errorMessage(err)}`
385
- }));
386
- });
387
- child.stdout.on("data", (chunk) => this.sendData(job, id, STREAM.stdout, chunk));
388
- child.stderr.on("data", (chunk) => this.sendData(job, id, STREAM.stderr, chunk));
389
- child.on("close", (code) => {
390
- if (job.settled) return;
391
- job.settled = true;
392
- this.jobs.delete(id);
393
- this.opts.send(encodeCtrl(id, {
394
- t: "close",
395
- exitCode: code ?? -1
396
- }));
397
- });
398
- }
399
- sendData(job, id, stream, chunk) {
400
- if (job.settled) return;
401
- this.opts.send(encodeData(id, stream, job.seq, chunk));
402
- job.seq = job.seq + 1 >>> 0;
403
- }
404
- };
405
-
406
- //#endregion
407
- //#region src/chat/portal/client.ts
408
- const deviceTokenSchema = z.object({ token: z.string().min(1) });
409
- const devicesSchema = z.object({ devices: z.array(z.object({
410
- id: z.string(),
411
- machineName: z.string(),
412
- grantedAgentIds: z.array(z.string())
413
- })) });
414
- const INITIAL_BACKOFF_MS = 500;
415
- const MAX_BACKOFF_MS = 1e4;
416
- /**
417
- * Shares the local machine with agents over the portal: dials OUT to the api's
418
- * desktop-portal WebSocket (authenticating with a short-lived device token
419
- * minted from the CLI session), then runs inbound `exec` directives via a
420
- * `JobManager`. Reconnects with backoff while enabled; disabling drops presence
421
- * and kills any in-flight children. No inbound port is ever opened.
422
- *
423
- * Access stays default-deny: connecting only makes the machine reachable — an
424
- * agent can't run anything until the user grants it (`grantAgent`).
425
- */
426
- var PortalClient = class {
427
- enabled = false;
428
- disposed = false;
429
- ws = null;
430
- jobs = null;
431
- status = "off";
432
- error = null;
433
- deviceId = null;
434
- granted = /* @__PURE__ */ new Set();
435
- machineName;
436
- friendlyName;
437
- constructor(opts) {
438
- this.opts = opts;
439
- const identity = machineIdentity();
440
- this.machineName = identity.machineName;
441
- this.friendlyName = identity.friendlyName;
442
- }
443
- isEnabled() {
444
- return this.enabled;
445
- }
446
- isGranted(agentId) {
447
- return this.granted.has(agentId);
448
- }
449
- enable() {
450
- if (this.enabled || this.disposed) return;
451
- this.enabled = true;
452
- this.error = null;
453
- this.connectLoop();
454
- }
455
- disable() {
456
- if (!this.enabled) return;
457
- this.enabled = false;
458
- this.jobs?.killAll();
459
- this.ws?.close();
460
- this.ws = null;
461
- this.deviceId = null;
462
- this.granted = /* @__PURE__ */ new Set();
463
- this.setStatus("off");
464
- }
465
- /**
466
- * Tear down for good (app quit). Kills children synchronously and closes the
467
- * socket so it stops holding the event loop open — otherwise the process
468
- * would hang after the TUI is destroyed.
469
- */
470
- dispose() {
471
- this.disposed = true;
472
- this.enabled = false;
473
- this.jobs?.killAll();
474
- this.jobs = null;
475
- this.ws?.close();
476
- this.ws = null;
477
- }
478
- /** Grant one agent access to this machine (default-deny; user-initiated). */
479
- async grantAgent(agentId) {
480
- const deviceId = await this.ensureDeviceId();
481
- const res = await this.fetchJson(`/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants`, {
482
- method: "POST",
483
- body: JSON.stringify({ agentId })
484
- });
485
- if (!res.ok) throw new Error(`grant failed (${res.status}): ${await shortBody(res)}`);
486
- this.granted.add(agentId);
487
- this.emit();
488
- }
489
- setStatus(status, error = null) {
490
- this.status = status;
491
- this.error = error;
492
- this.emit();
493
- }
494
- emit() {
495
- this.opts.onState({
496
- status: this.status,
497
- machineName: this.machineName,
498
- friendlyName: this.friendlyName,
499
- error: this.error,
500
- grantedAgentIds: [...this.granted]
501
- });
502
- }
503
- async connectLoop() {
504
- let backoff = INITIAL_BACKOFF_MS;
505
- while (this.enabled && !this.disposed) {
506
- this.setStatus("connecting");
507
- try {
508
- const token = await this.mintDeviceToken();
509
- await this.runConnection(token);
510
- backoff = INITIAL_BACKOFF_MS;
511
- } catch (err) {
512
- if (!this.enabled || this.disposed) break;
513
- this.setStatus("error", errorMessage(err));
514
- }
515
- if (!this.enabled || this.disposed) break;
516
- await sleep(backoff);
517
- backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
518
- }
519
- }
520
- runConnection(token) {
521
- return new Promise((resolve) => {
522
- const ws = new WebSocket(portalWsUrl(this.opts.appUrl, this.machineName, this.friendlyName), {
523
- headers: { authorization: `Bearer ${token}` },
524
- maxPayload: MAX_WS_FRAME_BYTES
525
- });
526
- this.ws = ws;
527
- const jobs = new JobManager({
528
- cwd: this.opts.cwd,
529
- send: (frame) => {
530
- if (ws.readyState === WebSocket.OPEN) ws.send(frame);
531
- }
532
- });
533
- this.jobs = jobs;
534
- ws.on("open", () => {
535
- this.setStatus("connected");
536
- this.refreshDevice();
537
- });
538
- ws.on("message", (data, isBinary) => {
539
- if (isBinary) jobs.handleFrame(toBuffer$1(data));
540
- });
541
- ws.on("error", (err) => {
542
- this.error = errorMessage(err);
543
- });
544
- ws.on("close", () => {
545
- jobs.killAll();
546
- if (this.jobs === jobs) this.jobs = null;
547
- if (this.ws === ws) this.ws = null;
548
- resolve();
549
- });
550
- });
551
- }
552
- async mintDeviceToken() {
553
- const res = await this.fetchJson("/api/v1/portal/device-token", { method: "POST" });
554
- if (!res.ok) throw new Error(`device-token failed (${res.status}): ${await shortBody(res)}`);
555
- return deviceTokenSchema.parse(await res.json()).token;
556
- }
557
- async ensureDeviceId() {
558
- if (this.deviceId) return this.deviceId;
559
- for (let attempt = 0; attempt < 10; attempt += 1) {
560
- await this.refreshDevice();
561
- if (this.deviceId) return this.deviceId;
562
- await sleep(300);
563
- }
564
- throw new Error("this machine is not connected yet");
565
- }
566
- async refreshDevice() {
567
- try {
568
- const res = await this.fetchJson("/api/v1/portal/devices", { method: "GET" });
569
- if (!res.ok) return;
570
- const { devices } = devicesSchema.parse(await res.json());
571
- const mine = devices.find((device) => device.machineName === this.machineName);
572
- if (!mine) return;
573
- this.deviceId = mine.id;
574
- this.granted = new Set(mine.grantedAgentIds);
575
- this.emit();
576
- } catch (_error) {}
577
- }
578
- fetchJson(path, init) {
579
- return fetch(`${this.opts.appUrl}${path}`, {
580
- method: init.method,
581
- headers: {
582
- authorization: `Bearer ${this.opts.sessionToken}`,
583
- accept: "application/json",
584
- ...init.body ? { "content-type": "application/json" } : {}
585
- },
586
- ...init.body ? { body: init.body } : {}
587
- });
588
- }
589
- };
590
- function toBuffer$1(data) {
591
- if (Buffer.isBuffer(data)) return data;
592
- if (Array.isArray(data)) return Buffer.concat(data);
593
- return Buffer.from(data);
594
- }
595
- function shortBody(res) {
596
- return res.text().then((text) => text.slice(0, 120)).catch(() => "");
597
- }
598
- function sleep(ms) {
599
- return new Promise((resolve) => setTimeout(resolve, ms));
600
- }
601
-
602
162
  //#endregion
603
163
  //#region src/chat/portal/use-portal.ts
604
164
  /**
@@ -1240,6 +800,68 @@ function windowStart(highlight, total, visible) {
1240
800
  return Math.max(0, Math.min(desired, total - visible));
1241
801
  }
1242
802
 
803
+ //#endregion
804
+ //#region src/chat/tui/fuzzy.ts
805
+ /**
806
+ * Rank `items` against `query` across multiple keys with fuzzysort — the
807
+ * OpenCode pattern. An empty/whitespace query returns everything in the
808
+ * caller's original order (fuzzysort has nothing to rank); otherwise items
809
+ * where no key matches are dropped and the rest come back best-first.
810
+ */
811
+ function fuzzyFilter(query, items, keys) {
812
+ const needle = query.trim();
813
+ if (!needle) return items.map((item) => ({
814
+ item,
815
+ highlights: keys.map(() => null)
816
+ }));
817
+ return fuzzysort.go(needle, items, { keys: [...keys] }).map((result) => ({
818
+ item: result.obj,
819
+ highlights: keys.map((_key, i) => {
820
+ const indexes = result[i]?.indexes;
821
+ return indexes !== void 0 && indexes.length > 0 ? indexes : null;
822
+ })
823
+ }));
824
+ }
825
+ /**
826
+ * Split `text` into contiguous segments for rendering, marking the ones
827
+ * covered by `indexes` (matched characters) so the view can color them.
828
+ * Null/absent indexes yield a single unmatched segment.
829
+ */
830
+ function highlightSegments(text, indexes) {
831
+ if (!text) return [];
832
+ if (!indexes || indexes.length === 0) return [{
833
+ text,
834
+ matched: false
835
+ }];
836
+ const matched = new Set(indexes);
837
+ const segments = [];
838
+ for (let i = 0; i < text.length; i++) {
839
+ const isMatch = matched.has(i);
840
+ const last = segments.at(-1);
841
+ if (last && last.matched === isMatch) last.text += text[i];
842
+ else segments.push({
843
+ text: text[i] ?? "",
844
+ matched: isMatch
845
+ });
846
+ }
847
+ return segments;
848
+ }
849
+
850
+ //#endregion
851
+ //#region src/chat/tui/fuzzy-text.tsx
852
+ /**
853
+ * Inline text with fuzzy-matched characters colored. Renders as spans, so it
854
+ * must sit inside a <text>. `fg` styles the unmatched characters (defaults to
855
+ * inheriting from the enclosing <text>); matches are always warning-colored,
856
+ * which reads on both the plain and the selected (accent) row.
857
+ */
858
+ function FuzzyText({ text, indexes, fg }) {
859
+ return /* @__PURE__ */ jsx(Fragment, { children: highlightSegments(text, indexes).map((segment, i) => /* @__PURE__ */ jsx("span", {
860
+ fg: segment.matched ? theme.warning : fg,
861
+ children: segment.text
862
+ }, i)) });
863
+ }
864
+
1243
865
  //#endregion
1244
866
  //#region src/chat/tui/screens/agent-picker.tsx
1245
867
  function AgentPickerScreen() {
@@ -1332,12 +954,12 @@ function FilterableAgentList({ agents, scope, onPick }) {
1332
954
  useEffect(() => {
1333
955
  setHighlight(0);
1334
956
  }, [scope]);
1335
- const q = query.trim().toLowerCase();
1336
- const filtered = q ? agents.filter((a) => agentHaystack(a).includes(q)) : agents;
957
+ const hits = fuzzyFilter(query, agents, agentKeys);
958
+ const filtered = hits.map((h) => h.item);
1337
959
  const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
1338
960
  const visibleRows = Math.max(3, height - 6);
1339
961
  const start = windowStart(clamped, filtered.length, visibleRows);
1340
- const windowed = filtered.slice(start, start + visibleRows);
962
+ const windowed = hits.slice(start, start + visibleRows);
1341
963
  useKeyboard((key) => {
1342
964
  if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
1343
965
  else if (key.name === "down") setHighlight(Math.min(Math.max(0, filtered.length - 1), clamped + 1));
@@ -1399,28 +1021,33 @@ function FilterableAgentList({ agents, scope, onPick }) {
1399
1021
  query,
1400
1022
  "”"
1401
1023
  ]
1402
- }) : windowed.map((a, i) => {
1024
+ }) : windowed.map((hit, i) => {
1025
+ const a = hit.item;
1403
1026
  const selected = start + i === clamped;
1404
1027
  return /* @__PURE__ */ jsxs("text", {
1405
1028
  fg: selected ? theme.accent : theme.fg,
1406
- children: [selected ? "› " : " ", agentLabel(a)]
1029
+ children: [
1030
+ selected ? "› " : " ",
1031
+ /* @__PURE__ */ jsx(FuzzyText, {
1032
+ text: a.name,
1033
+ indexes: hit.highlights[0]
1034
+ }),
1035
+ a.slug ? /* @__PURE__ */ jsxs(Fragment, { children: [" @", /* @__PURE__ */ jsx(FuzzyText, {
1036
+ text: a.slug,
1037
+ indexes: hit.highlights[1]
1038
+ })] }) : null
1039
+ ]
1407
1040
  }, a.id);
1408
1041
  })
1409
1042
  })
1410
1043
  ]
1411
1044
  });
1412
1045
  }
1413
- function agentHaystack(a) {
1414
- return [
1415
- a.name,
1416
- a.slug ?? "",
1417
- a.title ?? ""
1418
- ].join(" ").toLowerCase();
1419
- }
1420
- function agentLabel(a) {
1421
- if (a.slug) return `${a.name} @${a.slug}`;
1422
- return a.name;
1423
- }
1046
+ const agentKeys = [
1047
+ (a) => a.name,
1048
+ (a) => a.slug ?? "",
1049
+ (a) => a.title ?? ""
1050
+ ];
1424
1051
 
1425
1052
  //#endregion
1426
1053
  //#region src/chat/tui/agent-name.ts
@@ -1755,11 +1382,10 @@ function ConversationList({ agent, conversations, onPick }) {
1755
1382
  const [list, setList] = useState(conversations);
1756
1383
  const [confirmId, setConfirmId] = useState(null);
1757
1384
  const [error, setError] = useState(null);
1758
- const q = query.trim().toLowerCase();
1759
- const filtered = q ? list.filter((c) => conversationHaystack(c).includes(q)) : list;
1760
- const rows = [{ kind: "new" }, ...filtered.map((conv) => ({
1385
+ const hits = fuzzyFilter(query, list, conversationKeys);
1386
+ const rows = [{ kind: "new" }, ...hits.map((hit) => ({
1761
1387
  kind: "conv",
1762
- conv
1388
+ hit
1763
1389
  }))];
1764
1390
  const clamped = Math.min(highlight, Math.max(0, rows.length - 1));
1765
1391
  const visibleRows = Math.max(3, height - 6);
@@ -1777,7 +1403,7 @@ function ConversationList({ agent, conversations, onPick }) {
1777
1403
  else onPick({
1778
1404
  kind: "chat",
1779
1405
  agent,
1780
- conversation: row.conv
1406
+ conversation: row.hit.item
1781
1407
  });
1782
1408
  };
1783
1409
  const deleteConversation = (id) => {
@@ -1804,7 +1430,7 @@ function ConversationList({ agent, conversations, onPick }) {
1804
1430
  const row = rows[clamped];
1805
1431
  if (row?.kind === "conv") {
1806
1432
  setError(null);
1807
- setConfirmId(row.conv.id);
1433
+ setConfirmId(row.hit.item.id);
1808
1434
  }
1809
1435
  } else if (key.name === "return") {
1810
1436
  const row = rows[clamped];
@@ -1849,7 +1475,7 @@ function ConversationList({ agent, conversations, onPick }) {
1849
1475
  }) : /* @__PURE__ */ jsxs("text", {
1850
1476
  fg: theme.dim,
1851
1477
  children: [
1852
- filtered.length,
1478
+ hits.length,
1853
1479
  "/",
1854
1480
  list.length,
1855
1481
  " · ↑/↓ move · ↵ open · ctrl+d delete · esc back"
@@ -1871,21 +1497,22 @@ function ConversationList({ agent, conversations, onPick }) {
1871
1497
  fg,
1872
1498
  children: [
1873
1499
  selected ? "› " : " ",
1874
- row.conv.title ?? "(untitled)",
1500
+ /* @__PURE__ */ jsx(FuzzyText, {
1501
+ text: row.hit.item.title ?? "(untitled)",
1502
+ indexes: row.hit.item.title ? row.hit.highlights[0] : null
1503
+ }),
1875
1504
  /* @__PURE__ */ jsxs("span", {
1876
1505
  fg: theme.dim,
1877
- children: [" ", previewLine(row.conv)]
1506
+ children: [" ", previewLine(row.hit.item)]
1878
1507
  })
1879
1508
  ]
1880
- }, row.conv.id);
1509
+ }, row.hit.item.id);
1881
1510
  })
1882
1511
  })
1883
1512
  ]
1884
1513
  });
1885
1514
  }
1886
- function conversationHaystack(c) {
1887
- return [c.title ?? "", c.preview ?? ""].join(" ").toLowerCase();
1888
- }
1515
+ const conversationKeys = [(c) => c.title ?? "", (c) => c.preview ?? ""];
1889
1516
  function previewLine(c) {
1890
1517
  const when = relativeTime(c.updatedAt);
1891
1518
  const prefix = c.preview ? c.preview.replace(/\s+/g, " ").trim() : "";
@@ -4311,12 +3938,12 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
4311
3938
  const [saving, setSaving] = useState(false);
4312
3939
  const [saveError, setSaveError] = useState(null);
4313
3940
  const rest = useStore((s) => s.rest);
4314
- const q = query.trim().toLowerCase();
4315
- const filtered = q ? models.filter((m) => modelHaystack(m).includes(q)) : models;
3941
+ const hits = fuzzyFilter(query, models, modelKeys);
3942
+ const filtered = hits.map((h) => h.item);
4316
3943
  const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
4317
3944
  const visibleRows = Math.max(3, height - 8);
4318
3945
  const start = windowStart(clamped, filtered.length, visibleRows);
4319
- const windowed = filtered.slice(start, start + visibleRows);
3946
+ const windowed = hits.slice(start, start + visibleRows);
4320
3947
  async function select(model) {
4321
3948
  if (!rest || saving) return;
4322
3949
  if (model.id === currentModel) {
@@ -4395,7 +4022,8 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
4395
4022
  query,
4396
4023
  "”"
4397
4024
  ]
4398
- }) : windowed.map((m, i) => {
4025
+ }) : windowed.map((hit, i) => {
4026
+ const m = hit.item;
4399
4027
  const selected = start + i === clamped;
4400
4028
  const isCurrent = m.id === currentModel;
4401
4029
  return /* @__PURE__ */ jsxs("text", {
@@ -4403,7 +4031,19 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
4403
4031
  children: [
4404
4032
  selected ? "› " : " ",
4405
4033
  isCurrent ? "● " : " ",
4406
- modelLabel(m)
4034
+ m.providerDisplay ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(FuzzyText, {
4035
+ text: m.providerDisplay,
4036
+ indexes: hit.highlights[1]
4037
+ }), " · "] }) : null,
4038
+ /* @__PURE__ */ jsx(FuzzyText, {
4039
+ text: m.displayName,
4040
+ indexes: hit.highlights[0]
4041
+ }),
4042
+ " ",
4043
+ /* @__PURE__ */ jsx(FuzzyText, {
4044
+ text: m.id,
4045
+ indexes: hit.highlights[2]
4046
+ })
4407
4047
  ]
4408
4048
  }, m.id);
4409
4049
  })
@@ -4415,16 +4055,11 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
4415
4055
  ]
4416
4056
  });
4417
4057
  }
4418
- function modelHaystack(m) {
4419
- return [
4420
- m.displayName,
4421
- m.providerDisplay ?? "",
4422
- m.id
4423
- ].join(" ").toLowerCase();
4424
- }
4425
- function modelLabel(m) {
4426
- return `${m.providerDisplay ? `${m.providerDisplay} · ` : ""}${m.displayName} ${m.id}`;
4427
- }
4058
+ const modelKeys = [
4059
+ (m) => m.displayName,
4060
+ (m) => m.providerDisplay ?? "",
4061
+ (m) => m.id
4062
+ ];
4428
4063
  /**
4429
4064
  * The REST error routes return `{ "error": "message" }`. Pull that out for a
4430
4065
  * human-readable status line; fall back to the raw body on anything else.