Skip to content
Source
Sign inSign up

CommandPalette.logic.ts

937 lines32.0 KB

1import {
2 foldSearchQuery,
3 PALETTE_SECONDARY_LIMIT,
4 PALETTE_THREAD_LIMIT,
5 rankSearchGroups,
6 scoreSearchTerms,
7 scoreThreadSearch,
8 type SearchMatchHint,
9 type SearchRange,
10 type ThreadSearchSubject,
11} from "@t3tools/shared/paletteSearch";
12import { threadSearchSubject } from "@t3tools/client-runtime/state/thread-search";
13import type { CommandPaletteLinkedThreads } from "../commandPaletteBus";
14import {
15 addProjectRemoteSourceLabel,
16 addProjectRemoteSourcePathHint,
17 buildAddProjectRemoteSourceReadiness,
18 type AddProjectRemoteSource,
19 type AddProjectRemoteSourceReadiness,
20} from "@t3tools/client-runtime/operations/projects";
21import {
22 type ScopedThreadRef,
23 type EnvironmentId,
24 type ProjectId,
25 type EnvironmentMachineKind,
26 type FilesystemBrowseEntry,
27 type KeybindingCommand,
28 type SourceControlDiscoveryResult,
29 type SourceControlRepositoryInfo,
30 THREAD_JUMP_KEYBINDING_COMMANDS,
31} from "@t3tools/contracts";
32import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem";
33import { createElement, type ReactNode } from "react";
34import { formatRelativeTimeLabel } from "../timestampFormat";
35import { type Project, type SidebarThreadSummary, type Thread } from "../types";
36import { isSidebarHiddenThread } from "./Sidebar.logic";
37import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
38​
39export const RECENT_THREAD_LIMIT = 12;
40export const ITEM_ICON_CLASS = "size-3.5 text-icon-muted";
41export const ADDON_ICON_CLASS = "size-4";
42​
43export function buildLinkedThreadActionItems(
44 input: CommandPaletteLinkedThreads & {
45 query: string;
46 icon: ReactNode;
47 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
48 },
49): CommandPaletteActionItem[] {
50 return input.threads.map((thread) => ({
51 kind: "action",
52 value: `thread:${input.environmentId}:${thread.id}`,
53 threadRef: { environmentId: input.environmentId, threadId: thread.id },
54 title: thread.title || "Untitled thread",
55 description: thread.archivedAt === null ? "Linked thread" : "Archived thread",
56 searchTerms: [input.query, thread.title],
57 icon: input.icon,
58 run: () => input.runThread({ environmentId: input.environmentId, id: thread.id }),
59 }));
61​
62export function browseInputEndPaddingClass(input: {
63 readonly willCreateProjectPath: boolean;
64 readonly hasHighlightedBrowseItem: boolean;
65}): string {
66 if (input.willCreateProjectPath) {
67 return "*:data-[slot=autocomplete-input]:pe-38!";
68 }
69 if (input.hasHighlightedBrowseItem) {
70 return "*:data-[slot=autocomplete-input]:pe-30!";
71 }
72 return "*:data-[slot=autocomplete-input]:pe-24!";
74​
75export type SearchOverlayMode = "command" | "files" | "content";
76​
77export type CommandPaletteOpenIntent =
78 | { readonly kind: "add-project" | "change-theme" }
79 | { readonly kind: "new-thread-in"; readonly onSelectProject?: (project: Project) => void }
80 | {
81 readonly kind: "search";
82 readonly query: string;
83 readonly linkedThreads?: CommandPaletteLinkedThreads;
84 };
85​
86export interface CommandPaletteUiState {
87 readonly open: boolean;
88 readonly mode: SearchOverlayMode;
89 /** What the current palette session opened to; read once when the palette view mounts. */
90 readonly openIntent: CommandPaletteOpenIntent | null;
91 /** Bumped by every intent so an open palette starts over from the new one. */
92 readonly session: number;
93 readonly parentSearch?: CommandPaletteReturnContext;
94 readonly restoredSearch?: CommandPaletteReturnContext;
96​
97export type CommandPaletteUiAction =
98 | { readonly _tag: "SetOpen"; readonly open: boolean }
99 | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode }
100 | {
101 readonly _tag: "OpenSearch";
102 readonly query: string;
103 readonly linkedThreads?: CommandPaletteLinkedThreads;
104 }
105 | { readonly _tag: "OpenAddProject" }
106 | { readonly _tag: "OpenNewThreadIn"; readonly onSelectProject?: (project: Project) => void }
107 | { readonly _tag: "OpenChangeTheme" }
108 | {
109 readonly _tag: "OpenChildSearch";
110 readonly mode: "files" | "content";
111 readonly context: CommandPaletteReturnContext;
112 }
113 | { readonly _tag: "BackToCommand" };
114​
115export function reduceCommandPaletteUiState(
116 state: CommandPaletteUiState,
117 action: CommandPaletteUiAction,
118): CommandPaletteUiState {
119 const { session } = state;
120 const openWithIntent = (openIntent: CommandPaletteOpenIntent): CommandPaletteUiState => ({
121 open: true,
122 mode: "command",
123 openIntent,
124 session: session + 1,
125 });
126 switch (action._tag) {
127 case "SetOpen":
128 return action.open
129 ? { open: true, mode: "command", openIntent: state.openIntent, session }
130 : { open: false, mode: state.mode, openIntent: null, session };
131 case "ToggleMode":
132 return state.open && state.mode === action.mode
133 ? { open: false, mode: state.mode, openIntent: null, session }
134 : { open: true, mode: action.mode, openIntent: null, session };
135 case "OpenChildSearch":
136 return {
137 open: true,
138 mode: action.mode,
139 openIntent: null,
140 session,
141 parentSearch: action.context,
142 };
143 case "BackToCommand":
144 if (state.open && state.mode !== "command" && !state.parentSearch) {
145 return { open: false, mode: state.mode, openIntent: null, session };
146 }
147 return state.open && state.mode !== "command"
148 ? {
149 open: true,
150 mode: "command",
151 openIntent: null,
152 session,
153 ...(state.parentSearch ? { restoredSearch: state.parentSearch } : {}),
154 }
155 : state;
156 case "OpenSearch":
157 return openWithIntent({
158 kind: "search",
159 query: action.query,
160 ...(action.linkedThreads ? { linkedThreads: action.linkedThreads } : {}),
161 });
162 case "OpenAddProject":
163 return openWithIntent({ kind: "add-project" });
164 case "OpenNewThreadIn":
165 return openWithIntent({
166 kind: "new-thread-in",
167 ...(action.onSelectProject ? { onSelectProject: action.onSelectProject } : {}),
168 });
169 case "OpenChangeTheme":
170 return openWithIntent({ kind: "change-theme" });
171 }
173​
174/** Where "Add project" starts, given the environments that can take a new project. */
175export type AddProjectEntry =
176 | { readonly kind: "connect-environment" }
177 | { readonly kind: "choose-environment" }
178 | { readonly kind: "choose-source"; readonly environmentId: EnvironmentId };
179​
180export function resolveAddProjectEntry(
181 options: ReadonlyArray<Pick<AddProjectEnvironmentOption, "environmentId">>,
182 defaultEnvironmentId: EnvironmentId | null,
183): AddProjectEntry {
184 if (options.length === 0) return { kind: "connect-environment" };
185 if (options.length > 1 || defaultEnvironmentId === null) return { kind: "choose-environment" };
186 return { kind: "choose-source", environmentId: defaultEnvironmentId };
188​
189export interface CommandPaletteThreadContentMatch {
190 readonly source: "user" | "assistant";
191 readonly snippet: string;
192 readonly query: string;
194​
195export interface CommandPaletteItem {
196 readonly kind: "action" | "submenu";
197 readonly value: string;
198 readonly searchTerms: ReadonlyArray<string>;
199 readonly title: ReactNode;
200 /** The title as plain text, for searching, when `title` is styled. */
201 readonly searchTitle?: string;
202 readonly description?: ReactNode;
203 readonly descriptionBelow?: boolean;
204 readonly threadContentMatch?: CommandPaletteThreadContentMatch;
205 readonly timestamp?: string;
206 /** Thread rows rank by this, with use history, instead of by `searchTerms`. */
207 readonly threadSearch?: ThreadSearchSubject;
208 readonly threadRef?: ScopedThreadRef;
209 /** Shown in place of the description when the match is not in the title. */
210 readonly matchHint?: SearchMatchHint;
211 readonly icon: ReactNode;
212 readonly disabled?: boolean;
213 readonly titleLeadingContent?: ReactNode;
214 readonly titleTrailingContent?: ReactNode;
215 readonly shortcutCommand?: KeybindingCommand;
216 readonly secondary?: boolean;
218​
219export interface CommandPaletteActionItem extends CommandPaletteItem {
220 readonly kind: "action";
221 readonly keepOpen?: boolean;
222 readonly run: () => Promise<void>;
223 /** A pin toggle on the row, also reached with the pin shortcut while the row is highlighted. */
224 readonly pin?: CommandPaletteItemPin;
226​
227export interface CommandPaletteItemPin {
228 readonly name: string;
229 readonly pinned: boolean;
230 readonly toggle: () => void;
232​
233export interface CommandPaletteSubmenuItem extends CommandPaletteItem {
234 readonly kind: "submenu";
235 readonly addonIcon: ReactNode;
236 readonly groups: ReadonlyArray<CommandPaletteGroup>;
237 readonly initialQuery?: string;
239​
240export interface CommandPaletteGroup {
241 readonly value: string;
242 readonly label: string;
243 readonly items: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>;
245​
246export interface CommandPaletteView {
247 readonly addonIcon: ReactNode;
248 readonly groups: ReadonlyArray<CommandPaletteGroup>;
249 readonly initialQuery?: string;
251​
252// Child search unmounts the palette; retain search data without its view closures.
253export interface CommandPaletteReturnContext {
254 readonly query: string;
255 readonly linkedThreadSearch: Extract<CommandPaletteOpenIntent, { kind: "search" }> | null;
257​
258export interface CommandPaletteSearchState {
259 readonly query: string;
260 readonly views: ReadonlyArray<{
261 readonly view: CommandPaletteView;
262 readonly parentQuery: string;
263 }>;
265​
266export function createCommandPaletteSearchState(query = ""): CommandPaletteSearchState {
267 return { query, views: [] };
269​
270export function reduceCommandPaletteSearchState(
271 state: CommandPaletteSearchState,
272 action:
273 | { readonly type: "query"; readonly query: string }
274 | { readonly type: "push"; readonly view: CommandPaletteView }
275 | { readonly type: "back" }
276 | { readonly type: "reset"; readonly query?: string },
277): CommandPaletteSearchState {
278 switch (action.type) {
279 case "query":
280 return { ...state, query: action.query };
281 case "push":
282 return {
283 query: action.view.initialQuery ?? "",
284 views: [...state.views, { view: action.view, parentQuery: state.query }],
285 };
286 case "back": {
287 const parent = state.views.at(-1);
288 return parent ? { query: parent.parentQuery, views: state.views.slice(0, -1) } : state;
289 }
290 case "reset":
291 return createCommandPaletteSearchState(action.query);
292 }
294​
295export type CommandPaletteRow =
296 | {
297 readonly kind: "label";
298 readonly key: string;
299 readonly label: string;
300 readonly first: boolean;
301 }
302 | {
303 readonly kind: "item";
304 readonly key: string;
305 readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
306 readonly itemIndex: number | null;
307 };
308​
309export function buildCommandPaletteRows(groups: ReadonlyArray<CommandPaletteGroup>) {
310 const rows: CommandPaletteRow[] = [];
311 const itemValues: string[] = [];
312 const rowIndexByItemIndex: number[] = [];
313 for (const group of groups) {
314 if (group.label) {
315 rows.push({
316 kind: "label",
317 key: `group:${group.value}`,
318 label: group.label,
319 first: rows.length === 0,
320 });
321 }
322 for (const item of group.items) {
323 const itemIndex = item.disabled ? null : itemValues.length;
324 if (itemIndex !== null) {
325 itemValues.push(item.value);
326 rowIndexByItemIndex.push(rows.length);
327 }
328 rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex });
329 }
330 }
331 return { rows, itemValues, rowIndexByItemIndex };
333​
334export function findHighlightedCommandPaletteItem(
335 groups: ReadonlyArray<CommandPaletteGroup>,
336 highlightedItemValue: string | null,
337): CommandPaletteActionItem | CommandPaletteSubmenuItem | null {
338 if (highlightedItemValue === null) return null;
339 for (const group of groups) {
340 for (const item of group.items) {
341 if (item.value === highlightedItemValue && !item.disabled) return item;
342 }
343 }
344 return null;
346​
347export function enumerateCommandPaletteItems(
348 items: ReadonlyArray<CommandPaletteActionItem>,
349): CommandPaletteActionItem[] {
350 return items.map((item, index) => {
351 const shortcutCommand = THREAD_JUMP_KEYBINDING_COMMANDS[index];
352 if (shortcutCommand) return { ...item, shortcutCommand };
353​
354 const { shortcutCommand: _shortcutCommand, ...itemWithoutShortcut } = item;
355 return itemWithoutShortcut;
356 });
358​
359export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";
360​
361export type CommandPaletteProject = Project & { readonly displayName: string };
362​
363export function buildCommandPaletteProjectMetadata(input: {
364 readonly projects: ReadonlyArray<Pick<Project, "environmentId" | "title" | "workspaceRoot">>;
365 readonly locationByEnvironmentId: ReadonlyMap<EnvironmentId, { readonly label: string }>;
366}) {
367 const searchTerms: string[] = [];
368 const environmentLabels = new Set<string>();
369​
370 for (const project of input.projects) {
371 const label = input.locationByEnvironmentId.get(project.environmentId)?.label ?? "Remote";
372 searchTerms.push(project.title, project.workspaceRoot, label);
373 environmentLabels.add(label);
374 }
375​
376 return { searchTerms, environmentLabels: [...environmentLabels] };
378​
379export function buildProjectActionItems(input: {
380 projects: ReadonlyArray<CommandPaletteProject>;
381 valuePrefix: string;
382 icon: (project: CommandPaletteProject) => ReactNode;
383 runProject: (project: CommandPaletteProject) => Promise<void>;
384 searchTerms?: (project: CommandPaletteProject) => ReadonlyArray<string>;
385 renderDescription?: (project: CommandPaletteProject) => ReactNode;
386 shortcutCommand?: KeybindingCommand;
387 pin?: (project: CommandPaletteProject) => CommandPaletteItemPin | null;
388}): CommandPaletteActionItem[] {
389 return input.projects.map((project) => {
390 const pin = input.pin?.(project) ?? null;
391 return {
392 kind: "action",
393 value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,
394 searchTerms: [
395 project.displayName,
396 project.title,
397 project.workspaceRoot,
398 ...(input.searchTerms?.(project) ?? []),
399 ],
400 title: project.displayName,
401 description: input.renderDescription?.(project) ?? project.workspaceRoot,
402 descriptionBelow: true,
403 icon: input.icon(project),
404 ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}),
405 ...(pin ? { pin } : {}),
406 run: async () => {
407 await input.runProject(project);
408 },
409 };
410 });
412​
413export type BuildThreadActionItemsThread = Pick<
414 SidebarThreadSummary,
415 | "archivedAt"
416 | "branch"
417 | "createdAt"
418 | "environmentId"
419 | "id"
420 | "lineage"
421 | "modelSelection"
422 | "projectId"
423 | "runtime"
424 | "title"
425 | "worktreePath"
426> &
427 Partial<
428 Pick<
429 SidebarThreadSummary,
430 "pullRequests" | "linkedPullRequest" | "branchPullRequest" | "opens" | "latestUserMessageAt"
431 >
432 > & {
433 updatedAt: string;
434 };
435​
436export function buildThreadActionItems<TThread extends BuildThreadActionItemsThread>(input: {
437 threads: ReadonlyArray<TThread>;
438 activeThread?: Pick<Thread, "environmentId" | "id">;
439 projectByKey: ReadonlyMap<string, Pick<Project, "title">>;
440 nowMs?: number;
441 icon: ReactNode;
442 renderLeadingContent?: (thread: TThread) => ReactNode;
443 renderTrailingContent?: (thread: TThread) => ReactNode;
444 renderDescription?: (thread: TThread, meta: { projectTitle: string | undefined }) => ReactNode;
445 runThread: (thread: TThread) => Promise<void>;
446 /** Makes the environment searchable; pass it only with two or more environments. */
447 environmentLabelOf?: (thread: TThread) => string | null;
448}): CommandPaletteActionItem[] {
449 // Callers order these: recents by last use, search results by rank.
450 const visibleThreads = input.threads.filter(
451 (thread) => thread.archivedAt === null && !isSidebarHiddenThread(thread),
452 );
453​
454 return visibleThreads.map((thread) => {
455 const projectTitle = input.projectByKey.get(
456 `${thread.environmentId}:${thread.projectId}`,
457 )?.title;
458 const descriptionParts: string[] = [];
459​
460 if (projectTitle) {
461 descriptionParts.push(projectTitle);
462 }
463 if (thread.branch) {
464 descriptionParts.push(`#${thread.branch}`);
465 }
466 const isCurrent =
467 thread.id === input.activeThread?.id &&
468 thread.environmentId === input.activeThread.environmentId;
469 if (isCurrent) {
470 descriptionParts.push("Current thread");
471 }
472​
473 const leadingContent = input.renderLeadingContent?.(thread);
474 const trailingContent = input.renderTrailingContent?.(thread);
475 const description = input.renderDescription
476 ? input.renderDescription(thread, { projectTitle })
477 : createElement(
478 Tooltip,
479 null,
480 createElement(
481 TooltipTrigger,
482 { render: createElement("span", { tabIndex: 0 }) },
483 descriptionParts[0] ?? "Thread",
484 ),
485 createElement(
486 TooltipPopup,
487 null,
488 descriptionParts.map((part) =>
489 createElement("span", { key: part, className: "block" }, part),
490 ),
491 ),
492 );
493​
494 return Object.assign(
495 {
496 kind: "action" as const,
497 value: `thread:${thread.environmentId}:${thread.id}`,
498 threadRef: { environmentId: thread.environmentId, threadId: thread.id },
499 searchTerms: [thread.title],
500 threadSearch: threadSearchSubject(
501 {
502 ...thread,
503 latestUserMessageAt: thread.latestUserMessageAt ?? null,
504 opens: thread.opens ?? null,
505 },
506 projectTitle,
507 input.environmentLabelOf?.(thread),
508 ),
509 title: thread.title,
510 description,
511 timestamp: formatRelativeTimeLabel(
512 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
513 input.nowMs,
514 ),
515 icon: input.icon,
516 },
517 leadingContent ? { titleLeadingContent: leadingContent } : {},
518 trailingContent ? { titleTrailingContent: trailingContent } : {},
519 // Last in its group: picking the thread already open does nothing.
520 isCurrent ? { secondary: true } : {},
521 {
522 run: async () => {
523 await input.runThread(thread);
524 },
525 },
526 );
527 });
529​
530type PaletteItem = CommandPaletteActionItem | CommandPaletteSubmenuItem;
531​
532function rankPaletteItem(
533 item: PaletteItem,
534 normalizedQuery: string,
535 now: number,
536 options: { readonly typos: boolean },
537): {
538 readonly score: number;
539 readonly quality: number;
540 readonly groupQuality?: number;
541 readonly strong: boolean;
542 readonly ranges: ReadonlyArray<SearchRange>;
543 readonly explain?: () => SearchMatchHint | undefined;
544 readonly pickedAt?: number;
545 readonly pickedExactly?: boolean;
546} | null {
547 const match = scorePaletteItem(item, normalizedQuery, now, options);
548 // A row listed last in its group (a keybinding setting, the open thread)
549 // does not decide where the group sits.
550 return match && item.secondary ? { ...match, groupQuality: 0 } : match;
552​
553function scorePaletteItem(
554 item: PaletteItem,
555 normalizedQuery: string,
556 now: number,
557 options: { readonly typos: boolean },
558) {
559 if (item.threadSearch) return scoreThreadSearch(normalizedQuery, item.threadSearch, now, options);
560 // The title names the item; search terms are keywords. Ranges index the
561 // first term, so they only highlight a title that is that term.
562 const name = typeof item.title === "string" ? item.title : item.searchTitle;
563 const terms =
564 name !== undefined && name !== item.searchTerms[0]
565 ? [name, ...item.searchTerms]
566 : item.searchTerms;
567 const match = scoreSearchTerms(normalizedQuery, terms, options);
568 return match && (item.title === terms[0] ? match : { ...match, ranges: [] });
570​
571/**
572 * Equal match classes put commands before threads, so "new" runs "New thread".
573 * Server message matches stay in a final group so late results cannot move earlier rows.
574 */
575export function filterCommandPaletteGroups(input: {
576 activeGroups: ReadonlyArray<CommandPaletteGroup>;
577 query: string;
578 isInSubmenu: boolean;
579 /** Submenu options join the root search, and a submenu with no match falls back to it. */
580 allOptions?: {
581 readonly rootGroups: ReadonlyArray<CommandPaletteGroup>;
582 readonly nestedItems: ReadonlyArray<CommandPaletteActionItem>;
583 };
584 projectSearchItems: ReadonlyArray<CommandPaletteActionItem>;
585 settingsSearchItems?: ReadonlyArray<CommandPaletteActionItem>;
586 threadSearchItems: ReadonlyArray<CommandPaletteActionItem>;
587 /** Threads whose messages match, best first; threads already listed are skipped. */
588 threadMessageItems?: ReadonlyArray<CommandPaletteActionItem>;
589 now?: number;
590}): CommandPaletteGroup[] {
591 if (input.allOptions && input.isInSubmenu) {
592 const { allOptions, ...submenuInput } = input;
593 const scoped = filterCommandPaletteGroups(submenuInput);
594 if (scoped.length > 0 || input.query.trim().length === 0) return scoped;
595 return filterCommandPaletteGroups({
596 ...input,
597 activeGroups: allOptions.rootGroups,
598 isInSubmenu: false,
599 });
600 }
601​
602 const isActionsFilter = input.query.startsWith(">");
603 const searchQuery = isActionsFilter ? input.query.slice(1) : input.query;
604 const normalizedQuery = foldSearchQuery(searchQuery);
605​
606 if (normalizedQuery.length === 0) {
607 if (isActionsFilter) {
608 return input.activeGroups.filter((group) => group.value === "actions");
609 }
610 return [...input.activeGroups];
611 }
612​
613 let baseGroups = [...input.activeGroups];
614 if (isActionsFilter) {
615 baseGroups = baseGroups.filter((group) => group.value === "actions");
616 } else if (!input.isInSubmenu) {
617 baseGroups = baseGroups.filter((group) => group.value !== "recent-threads");
618 }
619​
620 const searchableGroups = [...baseGroups];
621 const searchesEverything = !input.isInSubmenu && !isActionsFilter;
622 // On equal match classes, groups keep this order: threads, what people search
623 // for most, right after commands.
624 if (searchesEverything) {
625 if (input.allOptions && input.allOptions.nestedItems.length > 0) {
626 searchableGroups.push({
627 value: "nested-search",
628 label: "Suggestions",
629 items: input.allOptions.nestedItems,
630 });
631 }
632 if (input.threadSearchItems.length > 0) {
633 searchableGroups.push({
634 value: "threads-search",
635 label: "Threads",
636 items: input.threadSearchItems,
637 });
638 }
639 if (input.projectSearchItems.length > 0) {
640 searchableGroups.push({
641 value: "projects-search",
642 label: "Projects",
643 items: input.projectSearchItems,
644 });
645 }
646 if (input.settingsSearchItems && input.settingsSearchItems.length > 0) {
647 searchableGroups.push({
648 value: "settings-search",
649 label: "Settings",
650 items: input.settingsSearchItems,
651 });
652 }
653 }
654​
655 const now = input.now ?? Date.now();
656 const ranked = rankSearchGroups(
657 searchableGroups.map((group) => ({ group, items: group.items })),
658 (item, options) => rankPaletteItem(item, normalizedQuery, now, options),
659 );
660 const threadsMatch = ranked.some(({ group }) => group.value === "threads-search");
661 const groups = ranked.map(({ group, ranked }): CommandPaletteGroup => {
662 // Secondary items stay below the rest; the sort is stable, so score order holds.
663 const limit =
664 group.value === "threads-search"
665 ? PALETTE_THREAD_LIMIT
666 : group.value === "nested-search" || (searchesEverything && threadsMatch)
667 ? PALETTE_SECONDARY_LIMIT
668 : ranked.length;
669 // Hints are built only for the rows shown.
670 const items = ranked
671 .toSorted(
672 (left, right) =>
673 Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false),
674 )
675 .slice(0, limit)
676 .map(({ item, match }) => {
677 if (match.ranges.length > 0) return item;
678 // Built only for the rows shown.
679 const matchHint = match.explain?.();
680 return matchHint ? { ...item, matchHint } : item;
681 });
682 return { value: group.value, label: group.label, items };
683 });
684​
685 if (!searchesEverything) return groups;
686 const listed = new Set(groups.flatMap((group) => group.items.map((item) => item.value)));
687 // The items arrive cut to what the palette shows; see `nextShownOrder`.
688 const messageItems = (input.threadMessageItems ?? []).filter((item) => !listed.has(item.value));
689 return messageItems.length === 0
690 ? groups
691 : [...groups, { value: "threads-messages", label: "In messages", items: messageItems }];
693​
694/** Every option inside a submenu as one row, e.g. "New thread in t3code" or "Change theme: Dracula". */
695export function buildNestedSearchItems(
696 items: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>,
697 query: string,
698): CommandPaletteActionItem[] {
699 const nameOf = (item: CommandPaletteItem) =>
700 typeof item.title === "string" ? item.title : item.searchTitle;
701 const topLevelNames = new Set(items.map(nameOf));
702 const foldedQuery = foldSearchQuery(query);
703 return items.flatMap((parent) => {
704 if (parent.kind !== "submenu" || typeof parent.title !== "string") return [];
705 const stem = parent.title.replace(/(\.\.\.|…)$/u, "");
706 // A query that only names the submenu ("new thread in") wants the submenu row itself.
707 if (foldSearchQuery(stem).startsWith(foldedQuery)) return [];
708 const prefix = stem === parent.title ? `${stem}: ` : `${stem.trimEnd()} `;
709 return parent.groups.flatMap((group) =>
710 group.items.flatMap((child) => {
711 const name = nameOf(child);
712 if (child.kind !== "action" || name === undefined) return [];
713 const title = `${prefix}${name}`;
714 if (topLevelNames.has(title)) return [];
715 const { searchTitle: _searchTitle, ...rest } = child;
716 // Only the combined title: child keywords like "theme" would list every theme.
717 return [{ ...rest, title, searchTerms: [title] }];
718 }),
719 );
720 });
722​
723export function buildBrowseGroups(input: {
724 browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
725 browseQuery: string;
726 canBrowseUp: boolean;
727 upIcon: ReactNode;
728 directoryIcon: ReactNode;
729 browseUp: () => void | Promise<void>;
730 browseTo: (name: string) => void | Promise<void>;
731}): CommandPaletteGroup[] {
732 const items: CommandPaletteActionItem[] = [];
733​
734 if (input.canBrowseUp) {
735 items.push({
736 kind: "action",
737 value: "browse:up",
738 searchTerms: [input.browseQuery, ".."],
739 title: "..",
740 icon: input.upIcon,
741 keepOpen: true,
742 run: async () => {
743 await input.browseUp();
744 },
745 });
746 }
747​
748 for (const entry of input.browseEntries) {
749 items.push({
750 kind: "action",
751 value: `browse:${entry.fullPath}`,
752 searchTerms: [input.browseQuery, entry.fullPath, entry.name],
753 title: entry.name,
754 icon: input.directoryIcon,
755 keepOpen: true,
756 run: async () => {
757 await input.browseTo(entry.name);
758 },
759 });
760 }
761​
762 return [{ value: "directories", label: "Directories", items }];
764​
765export function filterPinnedBrowseEntries(input: {
766 browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
767 filterQuery: string;
768 pinnedDirectoryName: string;
769 caseSensitive: boolean;
770}): ReturnType<typeof filterFilesystemBrowseEntries> {
771 const namesMatch = (left: string, right: string) =>
772 input.caseSensitive ? left === right : left.toLowerCase() === right.toLowerCase();
773 const visibleFilterQuery = namesMatch(input.filterQuery, input.pinnedDirectoryName)
774 ? ""
775 : input.filterQuery;
776 const { visibleEntries } = filterFilesystemBrowseEntries(input.browseEntries, visibleFilterQuery);
777 const exactEntry =
778 input.filterQuery.length > 0
779 ? (input.browseEntries.find((entry) => namesMatch(entry.name, input.filterQuery)) ?? null)
780 : null;
781 return { visibleEntries, exactEntry };
783​
784export function getCommandPaletteMode(input: {
785 currentView: CommandPaletteView | null;
786 isBrowsing: boolean;
787}): CommandPaletteMode {
788 if (input.currentView) {
789 return input.isBrowsing ? "submenu-browse" : "submenu";
790 }
791 return input.isBrowsing ? "root-browse" : "root";
793​
794export function buildRootGroups(input: {
795 actionItems: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>;
796 recentThreadItems: ReadonlyArray<CommandPaletteActionItem>;
797}): CommandPaletteGroup[] {
798 const groups: CommandPaletteGroup[] = [];
799 if (input.actionItems.length > 0) {
800 groups.push({ value: "actions", label: "Actions", items: input.actionItems });
801 }
802 if (input.recentThreadItems.length > 0) {
803 groups.push({
804 value: "recent-threads",
805 label: "Recent Threads",
806 items: input.recentThreadItems,
807 });
808 }
809 return groups;
811​
812export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string {
813 switch (mode) {
814 case "root":
815 return "Search threads, projects, and commands…";
816 case "root-browse":
817 return "Enter project path (e.g. ~/projects/my-app)";
818 case "submenu":
819 return "Search...";
820 case "submenu-browse":
821 return "Enter path (e.g. ~/projects/my-app)";
822 }
824​
825export function getEnvironmentBrowsePlatform(os: string | null | undefined): string {
826 if (os === "windows") {
827 return "Win32";
828 }
829 if (os === "darwin") {
830 return "MacIntel";
831 }
832 if (os === "linux") {
833 return "Linux";
834 }
835 return typeof navigator === "undefined" ? "" : navigator.platform;
837​
838export interface AddProjectEnvironmentOption {
839 readonly environmentId: EnvironmentId;
840 readonly label: string;
841 readonly machine: EnvironmentMachineKind;
842 readonly isPrimary: boolean;
843 readonly isConnected: boolean;
844 readonly status: string;
846​
847export type AddProjectCloneFlow =
848 | {
849 readonly step: "repository";
850 readonly environmentId: EnvironmentId;
851 readonly source: AddProjectRemoteSource;
852 }
853 | {
854 readonly step: "confirm";
855 readonly environmentId: EnvironmentId;
856 readonly source: AddProjectRemoteSource;
857 readonly repositoryInput: string;
858 readonly repository: SourceControlRepositoryInfo | null;
859 readonly remoteUrl: string;
860 };
861​
862export function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null {
863 if (!flow) return null;
864 if (flow.step === "confirm") return null;
865 if (flow.source === "url") {
866 return "Enter Git clone URL";
867 }
868 return `Enter ${addProjectRemoteSourceLabel(flow.source)} repository (${addProjectRemoteSourcePathHint(flow.source)})`;
870​
871export function buildPaletteRemoteSourceReadiness(
872 discovery: SourceControlDiscoveryResult | null,
873): AddProjectRemoteSourceReadiness {
874 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
875 return (source) => {
876 const entry = readiness(source);
877 return entry.hint === null
878 ? entry
879 : {
880 ...entry,
881 hint: entry.hint.replace(
882 "Open source control settings",
883 "Open Settings -> Source control",
884 ),
885 };
886 };
888​
889export function errorMessage(error: unknown): string {
890 if (error instanceof Error && error.message.trim().length > 0) {
891 return error.message;
892 }
893 return "An error occurred.";
895​
896const OVERLAY_MODE_BY_COMMAND = {
897 "commandPalette.toggle": "command",
898 "filePicker.toggle": "files",
899 "projectSearch.toggle": "content",
900} as const satisfies Partial<Record<string, SearchOverlayMode>>;
901​
902export function overlayModeForCommand(command: string | null): SearchOverlayMode | null {
903 if (command === null) return null;
904 return command in OVERLAY_MODE_BY_COMMAND
905 ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND]
906 : null;
908​
909export function resolveCommandPaletteProject<TProject>(
910 projects: ReadonlyMap<string, TProject>,
911 environmentId: EnvironmentId | null,
912 projectId: ProjectId | null,
913) {
914 return environmentId && projectId ? projects.get(`${environmentId}:${projectId}`) : undefined;
916​
917/**
918 * Where "No project" goes among the new-thread picker's real projects: at the
919 * scratch project's own place when it is pinned or current, else after the
920 * pinned projects and the first unpinned one.
921 */
922export function newThreadNoProjectIndex(
923 entries: ReadonlyArray<{
924 readonly isScratch: boolean;
925 readonly pinned: boolean;
926 readonly isPreferred: boolean;
927 }>,
928): number {
929 const projects = entries.filter((entry) => !entry.isScratch);
930 const scratchAt = entries.findIndex((entry) => entry.isScratch);
931 const scratch = entries[scratchAt];
932 if (scratch && (scratch.pinned || scratch.isPreferred)) {
933 return entries.slice(0, scratchAt).filter((entry) => !entry.isScratch).length;
934 }
935 const pinnedCount = projects.filter((entry) => entry.pinned).length;
936 return Math.min(pinnedCount + 1, projects.length);