1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
2import type { CommandPaletteLinkedThreads } from "../commandPaletteBus";
3import {
4 type EnvironmentId,
5 type FilesystemBrowseEntry,
6 type KeybindingCommand,
7 THREAD_JUMP_KEYBINDING_COMMANDS,
8} from "@t3tools/contracts";
9import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem";
10import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
11import * as Arr from "effect/Array";
12import * as Result from "effect/Result";
13import { type ReactNode } from "react";
14import { getThreadSortTimestamp, sortThreads } from "../lib/threadSort";
15import { normalizeSearchText } from "../lib/utils";
16import { formatRelativeTimeLabel } from "../timestampFormat";
17import { type Project, type SidebarThreadSummary, type Thread } from "../types";
18
19export const RECENT_THREAD_LIMIT = 12;
20export const ITEM_ICON_CLASS = "size-4 text-icon-muted";
21export const ADDON_ICON_CLASS = "size-4";
22
23/** A PR's relations include archived threads that normal palette search omits. */
24export function buildLinkedThreadActionItems(
25 input: CommandPaletteLinkedThreads & {
26 query: string;
27 icon: ReactNode;
28 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
29 },
30): CommandPaletteActionItem[] {
31 return input.threads.map((thread) => ({
32 kind: "action",
33 value: `thread:${input.environmentId}:${thread.id}`,
34 title: thread.title || "Untitled thread",
35 description: thread.archivedAt === null ? "Linked thread" : "Archived thread",
36 searchTerms: [input.query, thread.title],
37 icon: input.icon,
38 run: () => input.runThread({ environmentId: input.environmentId, id: thread.id }),
39 }));
40}
41
42export function browseInputEndPaddingClass(input: {
43 readonly willCreateProjectPath: boolean;
44 readonly hasHighlightedBrowseItem: boolean;
45}): string {
46 if (input.willCreateProjectPath) {
47 return "*:data-[slot=autocomplete-input]:pe-38!";
48 }
49 if (input.hasHighlightedBrowseItem) {
50 return "*:data-[slot=autocomplete-input]:pe-30!";
51 }
52 return "*:data-[slot=autocomplete-input]:pe-24!";
53}
54
55/**
56 * The global search overlay hosts three mutually exclusive surfaces: the
57 * command palette (⌘K), the project file picker (⌘P), and project content
58 * search (⇧⌘F). One reducer owns open/mode state so the surfaces can never
59 * stack and re-triggering a mode's shortcut toggles it closed.
60 */
61export type SearchOverlayMode = "command" | "files" | "content";
62
63export type CommandPaletteOpenIntent =
64 | { readonly kind: "add-project" | "new-thread-in" | "change-theme" }
65 | {
66 readonly kind: "search";
67 readonly query: string;
68 readonly linkedThreads?: CommandPaletteLinkedThreads;
69 };
70
71export interface CommandPaletteUiState {
72 readonly open: boolean;
73 readonly mode: SearchOverlayMode;
74 readonly openIntent: CommandPaletteOpenIntent | null;
75}
76
77export type CommandPaletteUiAction =
78 | { readonly _tag: "SetOpen"; readonly open: boolean }
79 | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode }
80 | {
81 readonly _tag: "OpenSearch";
82 readonly query: string;
83 readonly linkedThreads?: CommandPaletteLinkedThreads;
84 }
85 | { readonly _tag: "OpenAddProject" }
86 | { readonly _tag: "OpenNewThreadIn" }
87 | { readonly _tag: "OpenChangeTheme" }
88 | { readonly _tag: "ClearOpenIntent" };
89
90export function reduceCommandPaletteUiState(
91 state: CommandPaletteUiState,
92 action: CommandPaletteUiAction,
93): CommandPaletteUiState {
94 switch (action._tag) {
95 case "SetOpen":
96 return action.open
97 ? { open: true, mode: "command", openIntent: state.openIntent }
98 : { ...state, open: false, openIntent: null };
99 case "ToggleMode":
100 return state.open && state.mode === action.mode
101 ? { ...state, open: false, openIntent: null }
102 : { open: true, mode: action.mode, openIntent: null };
103 case "OpenSearch":
104 return {
105 open: true,
106 mode: "command",
107 openIntent: {
108 kind: "search",
109 query: action.query,
110 ...(action.linkedThreads ? { linkedThreads: action.linkedThreads } : {}),
111 },
112 };
113 case "OpenAddProject":
114 return { open: true, mode: "command", openIntent: { kind: "add-project" } };
115 case "OpenNewThreadIn":
116 return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } };
117 case "OpenChangeTheme":
118 return { open: true, mode: "command", openIntent: { kind: "change-theme" } };
119 case "ClearOpenIntent":
120 return state.openIntent ? { ...state, openIntent: null } : state;
121 }
122}
123
124export interface CommandPaletteThreadContentMatch {
125 readonly source: "user" | "assistant";
126 readonly snippet: string;
127 readonly query: string;
128}
129
130export interface CommandPaletteItem {
131 readonly kind: "action" | "submenu";
132 readonly value: string;
133 readonly searchTerms: ReadonlyArray<string>;
134 readonly title: ReactNode;
135 readonly description?: ReactNode;
136 readonly threadContentMatch?: CommandPaletteThreadContentMatch;
137 readonly timestamp?: string;
138 readonly searchRecency?: number;
139 readonly icon: ReactNode;
140 readonly disabled?: boolean;
141 /** Optional content rendered inline before the title text. */
142 readonly titleLeadingContent?: ReactNode;
143 /** Optional content rendered inline after the title text (before the timestamp). */
144 readonly titleTrailingContent?: ReactNode;
145 readonly shortcutCommand?: KeybindingCommand;
146 /** Sorts after every other match in its group; see `SettingsSearchItem.secondary`. */
147 readonly secondary?: boolean;
148}
149
150export interface CommandPaletteActionItem extends CommandPaletteItem {
151 readonly kind: "action";
152 readonly keepOpen?: boolean;
153 readonly run: () => Promise<void>;
154}
155
156export interface CommandPaletteSubmenuItem extends CommandPaletteItem {
157 readonly kind: "submenu";
158 readonly addonIcon: ReactNode;
159 readonly groups: ReadonlyArray<CommandPaletteGroup>;
160 readonly initialQuery?: string;
161}
162
163export interface CommandPaletteGroup {
164 readonly value: string;
165 readonly label: string;
166 readonly items: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>;
167}
168
169export interface CommandPaletteView {
170 readonly addonIcon: ReactNode;
171 readonly groups: ReadonlyArray<CommandPaletteGroup>;
172 readonly initialQuery?: string;
173}
174
175export type CommandPaletteRow =
176 | {
177 readonly kind: "label";
178 readonly key: string;
179 readonly label: string;
180 readonly first: boolean;
181 }
182 | {
183 readonly kind: "item";
184 readonly key: string;
185 readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
186 /** Position among enabled items, or null for disabled rows the keyboard skips. */
187 readonly itemIndex: number | null;
188 };
189
190/**
191 * Flattens groups into the rows a virtualized list renders. `itemValues` is the
192 * highlightable item order Base UI navigates; `rowIndexByItemIndex` maps a
193 * highlight back to its row for scrolling.
194 */
195export function buildCommandPaletteRows(groups: ReadonlyArray<CommandPaletteGroup>) {
196 const rows: CommandPaletteRow[] = [];
197 const itemValues: string[] = [];
198 const rowIndexByItemIndex: number[] = [];
199 for (const group of groups) {
200 if (group.label) {
201 rows.push({
202 kind: "label",
203 key: `group:${group.value}`,
204 label: group.label,
205 first: rows.length === 0,
206 });
207 }
208 for (const item of group.items) {
209 const itemIndex = item.disabled ? null : itemValues.length;
210 if (itemIndex !== null) {
211 itemValues.push(item.value);
212 rowIndexByItemIndex.push(rows.length);
213 }
214 rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex });
215 }
216 }
217 return { rows, itemValues, rowIndexByItemIndex };
218}
219
220/** The enabled item Enter should run for a highlight, whether or not its row is mounted. */
221export function findHighlightedCommandPaletteItem(
222 groups: ReadonlyArray<CommandPaletteGroup>,
223 highlightedItemValue: string | null,
224): CommandPaletteActionItem | CommandPaletteSubmenuItem | null {
225 if (highlightedItemValue === null) return null;
226 for (const group of groups) {
227 for (const item of group.items) {
228 if (item.value === highlightedItemValue && !item.disabled) return item;
229 }
230 }
231 return null;
232}
233
234export function enumerateCommandPaletteItems(
235 items: ReadonlyArray<CommandPaletteActionItem>,
236): CommandPaletteActionItem[] {
237 return items.map((item, index) => {
238 const shortcutCommand = THREAD_JUMP_KEYBINDING_COMMANDS[index];
239 if (shortcutCommand) return { ...item, shortcutCommand };
240
241 const { shortcutCommand: _shortcutCommand, ...itemWithoutShortcut } = item;
242 return itemWithoutShortcut;
243 });
244}
245
246export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";
247
248// A project as the palette shows it. `displayName` is the grouped label (for
249// example "owner/repo" when projects are merged across machines). Keep `title`
250// as the real project title: the automatic project icon is derived from it, and
251// every other surface uses the real title, so overriding it desyncs the icon.
252export type CommandPaletteProject = Project & { readonly displayName: string };
253
254export function buildCommandPaletteProjectMetadata(input: {
255 readonly projects: ReadonlyArray<Pick<Project, "environmentId" | "title" | "workspaceRoot">>;
256 readonly locationByEnvironmentId: ReadonlyMap<EnvironmentId, { readonly label: string }>;
257}) {
258 const searchTerms: string[] = [];
259 const environmentLabels = new Set<string>();
260
261 for (const project of input.projects) {
262 const label = input.locationByEnvironmentId.get(project.environmentId)?.label ?? "Remote";
263 searchTerms.push(project.title, project.workspaceRoot, label);
264 environmentLabels.add(label);
265 }
266
267 return { searchTerms, environmentLabels: [...environmentLabels] };
268}
269
270export function buildProjectActionItems(input: {
271 projects: ReadonlyArray<CommandPaletteProject>;
272 valuePrefix: string;
273 icon: (project: CommandPaletteProject) => ReactNode;
274 runProject: (project: CommandPaletteProject) => Promise<void>;
275 searchTerms?: (project: CommandPaletteProject) => ReadonlyArray<string>;
276 renderDescription?: (project: CommandPaletteProject) => ReactNode;
277 shortcutCommand?: KeybindingCommand;
278}): CommandPaletteActionItem[] {
279 return input.projects.map((project) => ({
280 kind: "action",
281 value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,
282 searchTerms: [
283 project.displayName,
284 project.title,
285 project.workspaceRoot,
286 ...(input.searchTerms?.(project) ?? []),
287 ],
288 title: project.displayName,
289 description: input.renderDescription?.(project) ?? project.workspaceRoot,
290 icon: input.icon(project),
291 ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}),
292 run: async () => {
293 await input.runProject(project);
294 },
295 }));
296}
297
298export type BuildThreadActionItemsThread = Pick<
299 SidebarThreadSummary,
300 | "archivedAt"
301 | "branch"
302 | "createdAt"
303 | "environmentId"
304 | "id"
305 | "modelSelection"
306 | "projectId"
307 | "runtime"
308 | "title"
309 | "worktreePath"
310> & {
311 pullRequests?: SidebarThreadSummary["pullRequests"];
312 updatedAt: string;
313 latestUserMessageAt?: string | null;
314};
315
316export function buildThreadActionItems<TThread extends BuildThreadActionItemsThread>(input: {
317 threads: ReadonlyArray<TThread>;
318 activeThreadId?: Thread["id"];
319 projectTitleById: ReadonlyMap<Project["id"], string>;
320 sortOrder: SidebarThreadSortOrder;
321 icon: ReactNode;
322 /** Optional content rendered inline before the title text per-thread. */
323 renderLeadingContent?: (thread: TThread) => ReactNode;
324 /** Optional content rendered inline after the title text per-thread. */
325 renderTrailingContent?: (thread: TThread) => ReactNode;
326 /** Optional rich description (e.g. favicon + workspace icons). Falls back to text. */
327 renderDescription?: (thread: TThread, meta: { projectTitle: string | undefined }) => ReactNode;
328 getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined;
329 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
330 limit?: number;
331}): CommandPaletteActionItem[] {
332 const sortedThreads = sortThreads(
333 input.threads.filter((thread) => thread.archivedAt === null),
334 input.sortOrder,
335 );
336 const visibleThreads =
337 input.limit === undefined ? sortedThreads : sortedThreads.slice(0, input.limit);
338
339 return visibleThreads.map((thread) => {
340 const projectTitle = input.projectTitleById.get(thread.projectId);
341 const descriptionParts: string[] = [];
342
343 if (projectTitle) {
344 descriptionParts.push(projectTitle);
345 }
346 if (thread.branch) {
347 descriptionParts.push(`#${thread.branch}`);
348 }
349 if (thread.id === input.activeThreadId) {
350 descriptionParts.push("Current thread");
351 }
352
353 const leadingContent = input.renderLeadingContent?.(thread);
354 const trailingContent = input.renderTrailingContent?.(thread);
355 const contentMatch = input.getContentMatch?.(thread);
356 const description = input.renderDescription
357 ? input.renderDescription(thread, { projectTitle })
358 : descriptionParts.join(` · `);
359
360 return Object.assign(
361 {
362 kind: "action" as const,
363 value: `thread:${thread.id}`,
364 searchTerms: [
365 thread.title,
366 ...threadPullRequestSearchTerms(thread),
367 projectTitle ?? ``,
368 thread.branch ?? ``,
369 contentMatch?.snippet ?? ``,
370 // Last so pasted IDs never outrank title matches for shared substrings.
371 thread.id,
372 ],
373 title: thread.title,
374 description,
375 timestamp: formatRelativeTimeLabel(
376 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
377 ),
378 searchRecency: getThreadSortTimestamp(thread, "updated_at"),
379 icon: input.icon,
380 },
381 leadingContent ? { titleLeadingContent: leadingContent } : {},
382 trailingContent ? { titleTrailingContent: trailingContent } : {},
383 contentMatch ? { threadContentMatch: contentMatch } : {},
384 {
385 run: async () => {
386 await input.runThread(thread);
387 },
388 },
389 );
390 });
391}
392
393function rankSearchFieldMatch(
394 field: string,
395 normalizedQuery: string,
396 queryTokens: ReadonlyArray<string>,
397): number {
398 const normalizedField = normalizeSearchText(field);
399 if (
400 normalizedField.length === 0 ||
401 !queryTokens.every((token) => normalizedField.includes(token))
402 ) {
403 return Number.NEGATIVE_INFINITY;
404 }
405 if (normalizedField === normalizedQuery) {
406 return 3;
407 }
408 if (normalizedField.startsWith(normalizedQuery)) {
409 return 2;
410 }
411 if (normalizedField.includes(normalizedQuery)) {
412 return 1;
413 }
414 return 0;
415}
416
417function rankCommandPaletteItemMatch(
418 item: CommandPaletteActionItem | CommandPaletteSubmenuItem,
419 normalizedQuery: string,
420 queryTokens: ReadonlyArray<string>,
421): number {
422 const terms = item.searchTerms.filter((term) => term.length > 0);
423 if (terms.length === 0) {
424 return 0;
425 }
426
427 for (const [index, field] of terms.entries()) {
428 const fieldRank = rankSearchFieldMatch(field, normalizedQuery, queryTokens);
429 if (fieldRank !== Number.NEGATIVE_INFINITY) {
430 if (index === 0 && item.searchRecency !== undefined) {
431 // All non-exact thread title matches share a tier so recency breaks the tie.
432 return 1_000 + Number(fieldRank === 3);
433 }
434 return 1_000 - index * 100 + fieldRank;
435 }
436 }
437
438 return 0;
439}
440
441export function filterCommandPaletteGroups(input: {
442 activeGroups: ReadonlyArray<CommandPaletteGroup>;
443 query: string;
444 isInSubmenu: boolean;
445 projectSearchItems: ReadonlyArray<CommandPaletteActionItem>;
446 settingsSearchItems?: ReadonlyArray<CommandPaletteActionItem>;
447 threadSearchItems: ReadonlyArray<CommandPaletteActionItem>;
448}): CommandPaletteGroup[] {
449 const isActionsFilter = input.query.startsWith(">");
450 const searchQuery = isActionsFilter ? input.query.slice(1) : input.query;
451 const normalizedQuery = normalizeSearchText(searchQuery);
452
453 if (normalizedQuery.length === 0) {
454 if (isActionsFilter) {
455 return input.activeGroups.filter((group) => group.value === "actions");
456 }
457 return [...input.activeGroups];
458 }
459 const queryTokens = normalizedQuery.split(" ");
460
461 let baseGroups = [...input.activeGroups];
462 if (isActionsFilter) {
463 baseGroups = baseGroups.filter((group) => group.value === "actions");
464 } else if (!input.isInSubmenu) {
465 baseGroups = baseGroups.filter((group) => group.value !== "recent-threads");
466 }
467
468 const searchableGroups = [...baseGroups];
469 if (!input.isInSubmenu && !isActionsFilter) {
470 if (input.projectSearchItems.length > 0) {
471 searchableGroups.push({
472 value: "projects-search",
473 label: "Projects",
474 items: input.projectSearchItems,
475 });
476 }
477 if (input.settingsSearchItems && input.settingsSearchItems.length > 0) {
478 searchableGroups.push({
479 value: "settings-search",
480 label: "Settings",
481 items: input.settingsSearchItems,
482 });
483 }
484 if (input.threadSearchItems.length > 0) {
485 searchableGroups.push({
486 value: "threads-search",
487 label: "Threads",
488 items: input.threadSearchItems,
489 });
490 }
491 }
492
493 return searchableGroups.flatMap((group) => {
494 const items = Arr.filterMap(group.items, (item, index) => {
495 const haystack = normalizeSearchText(item.searchTerms.join(" "));
496 if (!queryTokens.every((token) => haystack.includes(token))) {
497 return Result.failVoid;
498 }
499
500 return Result.succeed({
501 item,
502 index,
503 rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens),
504 });
505 })
506 .toSorted(
507 (left, right) =>
508 Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false) ||
509 right.rank - left.rank ||
510 (right.item.searchRecency ?? 0) - (left.item.searchRecency ?? 0) ||
511 left.index - right.index,
512 )
513 .map((entry) => entry.item);
514
515 if (items.length === 0) {
516 return [];
517 }
518
519 return [{ value: group.value, label: group.label, items }];
520 });
521}
522
523export function buildBrowseGroups(input: {
524 browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
525 browseQuery: string;
526 canBrowseUp: boolean;
527 upIcon: ReactNode;
528 directoryIcon: ReactNode;
529 browseUp: () => void | Promise<void>;
530 browseTo: (name: string) => void | Promise<void>;
531}): CommandPaletteGroup[] {
532 const items: CommandPaletteActionItem[] = [];
533
534 if (input.canBrowseUp) {
535 items.push({
536 kind: "action",
537 value: "browse:up",
538 searchTerms: [input.browseQuery, ".."],
539 title: "..",
540 icon: input.upIcon,
541 keepOpen: true,
542 run: async () => {
543 await input.browseUp();
544 },
545 });
546 }
547
548 for (const entry of input.browseEntries) {
549 items.push({
550 kind: "action",
551 value: `browse:${entry.fullPath}`,
552 searchTerms: [input.browseQuery, entry.fullPath, entry.name],
553 title: entry.name,
554 icon: input.directoryIcon,
555 keepOpen: true,
556 run: async () => {
557 await input.browseTo(entry.name);
558 },
559 });
560 }
561
562 return [{ value: "directories", label: "Directories", items }];
563}
564
565export function filterPinnedBrowseEntries(input: {
566 browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
567 filterQuery: string;
568 pinnedDirectoryName: string;
569 caseSensitive: boolean;
570}): ReturnType<typeof filterFilesystemBrowseEntries> {
571 const namesMatch = (left: string, right: string) =>
572 input.caseSensitive ? left === right : left.toLowerCase() === right.toLowerCase();
573 const visibleFilterQuery = namesMatch(input.filterQuery, input.pinnedDirectoryName)
574 ? ""
575 : input.filterQuery;
576 const { visibleEntries } = filterFilesystemBrowseEntries(input.browseEntries, visibleFilterQuery);
577 const exactEntry =
578 input.filterQuery.length > 0
579 ? (input.browseEntries.find((entry) => namesMatch(entry.name, input.filterQuery)) ?? null)
580 : null;
581 return { visibleEntries, exactEntry };
582}
583
584export function getCommandPaletteMode(input: {
585 currentView: CommandPaletteView | null;
586 isBrowsing: boolean;
587}): CommandPaletteMode {
588 if (input.currentView) {
589 return input.isBrowsing ? "submenu-browse" : "submenu";
590 }
591 return input.isBrowsing ? "root-browse" : "root";
592}
593
594export function buildRootGroups(input: {
595 actionItems: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>;
596 recentThreadItems: ReadonlyArray<CommandPaletteActionItem>;
597}): CommandPaletteGroup[] {
598 const groups: CommandPaletteGroup[] = [];
599 if (input.actionItems.length > 0) {
600 groups.push({ value: "actions", label: "Actions", items: input.actionItems });
601 }
602 if (input.recentThreadItems.length > 0) {
603 groups.push({
604 value: "recent-threads",
605 label: "Recent Threads",
606 items: input.recentThreadItems,
607 });
608 }
609 return groups;
610}
611
612export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string {
613 switch (mode) {
614 case "root":
615 return "Search commands, projects, and threads...";
616 case "root-browse":
617 return "Enter project path (e.g. ~/projects/my-app)";
618 case "submenu":
619 return "Search...";
620 case "submenu-browse":
621 return "Enter path (e.g. ~/projects/my-app)";
622 }
623}