1import {
2 isListedThreadLineage,
3 type EnvironmentId,
4 type EnvironmentMachineKind,
5 type ScopedThreadRef,
6 type ThreadId,
7} from "@t3tools/contracts";
8import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch";
9
10import type { EnvironmentThreadShell } from "./state/models.ts";
11import { threadSearchSubject, type ThreadSearchShell } from "./state/threadSearch.ts";
12
13const COMPOSER_THREAD_RESULT_LIMIT = 5;
14
15export type ComposerThreadCandidate = ThreadSearchShell &
16 Pick<EnvironmentThreadShell, "archivedAt" | "lineage">;
17
18export interface ComposerThreadItem {
19 readonly id: string;
20 readonly type: "thread";
21 readonly thread: ScopedThreadRef;
22 readonly label: string;
23 readonly description: string;
24}
25
26/**
27 * Threads to reference from the composer, matched and ranked like the command
28 * palette, without subagent or side chat threads. The agent reads threads on its own server
29 * and on the machines it links to, so candidates stay within those, and a linked
30 * machine's threads say which machine they are on.
31 */
32export function matchComposerThreadItems(input: {
33 shells: ReadonlyArray<ComposerThreadCandidate>;
34 environmentId: EnvironmentId;
35 /** The machines the composer's environment links to, by name. */
36 linkedEnvironments: ReadonlyMap<EnvironmentId, string>;
37 excludeThreadId: ThreadId | null;
38 query: string;
39 /** Recency is measured against this time. */
40 now: number;
41}): ComposerThreadItem[] {
42 const query = foldSearchQuery(input.query);
43 if (query.length === 0) return [];
44 const candidates = input.shells.filter(
45 (shell) =>
46 (shell.environmentId === input.environmentId ||
47 input.linkedEnvironments.has(shell.environmentId)) &&
48 shell.id !== input.excludeThreadId &&
49 shell.archivedAt === null &&
50 isListedThreadLineage(shell.lineage),
51 );
52 return rankSearchItems(candidates, (shell, options) =>
53 scoreThreadSearch(query, threadSearchSubject(shell, null), input.now, {
54 ...options,
55 picks: false,
56 }),
57 )
58 .slice(0, COMPOSER_THREAD_RESULT_LIMIT)
59 .map(({ item: shell }) => {
60 const machine =
61 shell.environmentId === input.environmentId
62 ? undefined
63 : input.linkedEnvironments.get(shell.environmentId);
64 return {
65 id: `thread:${shell.environmentId}:${shell.id}`,
66 type: "thread",
67 thread: { environmentId: shell.environmentId, threadId: shell.id },
68 label: shell.title,
69 description: machine === undefined ? "Thread" : `Thread on ${machine}`,
70 };
71 });
72}
73
74export interface ComposerEnvironmentCandidate {
75 readonly environmentId: EnvironmentId;
76 readonly label: string;
77 readonly machine: EnvironmentMachineKind;
78}
79
80export interface ComposerEnvironmentItem {
81 readonly id: string;
82 readonly type: "environment";
83 readonly environmentId: EnvironmentId;
84 readonly machine: EnvironmentMachineKind;
85 readonly label: string;
86 readonly description: string;
87}
88
89/**
90 * The user's machines the `@` picker offers, so the agent can link or work there. The
91 * composer's own environment is left out. Like threads, they need a query: bare `@` stays a
92 * file picker.
93 */
94export function matchComposerEnvironmentItems(input: {
95 environments: ReadonlyArray<ComposerEnvironmentCandidate>;
96 environmentId: EnvironmentId;
97 query: string;
98}): ComposerEnvironmentItem[] {
99 const query = input.query.trim().toLowerCase();
100 if (query.length === 0) return [];
101 return input.environments
102 .filter(
103 (environment) =>
104 environment.environmentId !== input.environmentId &&
105 environment.label.toLowerCase().includes(query),
106 )
107 .map((environment) => ({
108 id: `environment:${environment.environmentId}`,
109 type: "environment",
110 environmentId: environment.environmentId,
111 machine: environment.machine,
112 label: environment.label,
113 description: "Machine",
114 }));
115}