- Suggest files, skills, and commands from composer triggers - Show git action progress, success, and error feedback
Unified · Split
| @@ -0,0 +1,211 @@ | |||
| 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | ||
| 2 | import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; | ||
| 3 | import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; | ||
| 4 | import { SymbolView } from "expo-symbols"; | ||
| 5 | import { memo } from "react"; | ||
| 6 | import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; | ||
| 7 | |||
| 8 | import { AppText as Text } from "../../components/AppText"; | ||
| 9 | |||
| 10 | export type ComposerCommandItem = | ||
| 11 | | { | ||
| 12 | readonly id: string; | ||
| 13 | readonly type: "path"; | ||
| 14 | readonly path: string; | ||
| 15 | readonly kind: "file" | "directory"; | ||
| 16 | readonly label: string; | ||
| 17 | readonly description: string; | ||
| 18 | } | ||
| 19 | | { | ||
| 20 | readonly id: string; | ||
| 21 | readonly type: "slash-command"; | ||
| 22 | readonly command: string; | ||
| 23 | readonly label: string; | ||
| 24 | readonly description: string; | ||
| 25 | } | ||
| 26 | | { | ||
| 27 | readonly id: string; | ||
| 28 | readonly type: "provider-slash-command"; | ||
| 29 | readonly command: ServerProviderSlashCommand; | ||
| 30 | readonly label: string; | ||
| 31 | readonly description: string; | ||
| 32 | } | ||
| 33 | | { | ||
| 34 | readonly id: string; | ||
| 35 | readonly type: "skill"; | ||
| 36 | readonly skill: ServerProviderSkill; | ||
| 37 | readonly label: string; | ||
| 38 | readonly description: string; | ||
| 39 | }; | ||
| 40 | |||
| 41 | interface ComposerCommandPopoverProps { | ||
| 42 | readonly items: ReadonlyArray<ComposerCommandItem>; | ||
| 43 | readonly triggerKind: ComposerTriggerKind | null; | ||
| 44 | readonly isLoading: boolean; | ||
| 45 | readonly onSelect: (item: ComposerCommandItem) => void; | ||
| 46 | } | ||
| 47 | |||
| 48 | function PopoverSurface(props: { | ||
| 49 | readonly children: React.ReactNode; | ||
| 50 | readonly isDarkMode: boolean; | ||
| 51 | readonly style?: ViewStyle; | ||
| 52 | }) { | ||
| 53 | const baseStyle: ViewStyle = { | ||
| 54 | borderRadius: 16, | ||
| 55 | overflow: "hidden", | ||
| 56 | ...props.style, | ||
| 57 | }; | ||
| 58 | |||
| 59 | if (isLiquidGlassSupported) { | ||
| 60 | return ( | ||
| 61 | <LiquidGlassView | ||
| 62 | effect="clear" | ||
| 63 | interactive={false} | ||
| 64 | tintColor={props.isDarkMode ? "rgba(30,30,32,0.95)" : "rgba(255,255,255,0.92)"} | ||
| 65 | colorScheme={props.isDarkMode ? "dark" : "light"} | ||
| 66 | style={baseStyle} | ||
| 67 | > | ||
| 68 | {props.children} | ||
| 69 | </LiquidGlassView> | ||
| 70 | ); | ||
| 71 | } | ||
| 72 | |||
| 73 | return ( | ||
| 74 | <View | ||
| 75 | style={[ | ||
| 76 | baseStyle, | ||
| 77 | { | ||
| 78 | backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)", | ||
| 79 | borderWidth: 1, | ||
| 80 | borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)", | ||
| 81 | }, | ||
| 82 | ]} | ||
| 83 | > | ||
| 84 | {props.children} | ||
| 85 | </View> | ||
| 86 | ); | ||
| 87 | } | ||
| 88 | |||
| 89 | function itemIcon(item: ComposerCommandItem) { | ||
| 90 | switch (item.type) { | ||
| 91 | case "path": | ||
| 92 | return item.kind === "directory" ? ("folder" as const) : ("doc" as const); | ||
| 93 | case "slash-command": | ||
| 94 | case "provider-slash-command": | ||
| 95 | return "terminal" as const; | ||
| 96 | case "skill": | ||
| 97 | return "cube" as const; | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | function groupLabel(triggerKind: ComposerTriggerKind | null): string | null { | ||
| 102 | switch (triggerKind) { | ||
| 103 | case "slash-command": | ||
| 104 | return "Commands"; | ||
| 105 | case "skill": | ||
| 106 | return "Skills"; | ||
| 107 | case "path": | ||
| 108 | return "Files"; | ||
| 109 | default: | ||
| 110 | return null; | ||
| 111 | } | ||
| 112 | } | ||
| 113 | |||
| 114 | function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): string { | ||
| 115 | if (isLoading) { | ||
| 116 | return triggerKind === "path" ? "Searching files…" : "Loading…"; | ||
| 117 | } | ||
| 118 | switch (triggerKind) { | ||
| 119 | case "path": | ||
| 120 | return "No matching files or folders."; | ||
| 121 | case "skill": | ||
| 122 | return "No skills found."; | ||
| 123 | case "slash-command": | ||
| 124 | return "No matching commands."; | ||
| 125 | default: | ||
| 126 | return "No results."; | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | const CommandRow = memo(function CommandRow(props: { | ||
| 131 | readonly item: ComposerCommandItem; | ||
| 132 | readonly onPress: () => void; | ||
| 133 | readonly isLast: boolean; | ||
| 134 | }) { | ||
| 135 | const iconName = itemIcon(props.item); | ||
| 136 | const iconColor = "#a1a1aa"; | ||
| 137 | |||
| 138 | return ( | ||
| 139 | <Pressable | ||
| 140 | onPress={props.onPress} | ||
| 141 | style={({ pressed }) => ({ | ||
| 142 | flexDirection: "row", | ||
| 143 | alignItems: "center", | ||
| 144 | paddingHorizontal: 14, | ||
| 145 | paddingVertical: 10, | ||
| 146 | gap: 10, | ||
| 147 | opacity: pressed ? 0.6 : 1, | ||
| 148 | borderBottomWidth: props.isLast ? 0 : 0.5, | ||
| 149 | borderBottomColor: "rgba(255,255,255,0.1)", | ||
| 150 | })} | ||
| 151 | > | ||
| 152 | <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" /> | ||
| 153 | <Text | ||
| 154 | className="text-[14px] font-t3-medium text-foreground" | ||
| 155 | numberOfLines={1} | ||
| 156 | style={{ flexShrink: 0 }} | ||
| 157 | > | ||
| 158 | {props.item.label} | ||
| 159 | </Text> | ||
| 160 | {props.item.description ? ( | ||
| 161 | <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, fontSize: 12, color: "#a1a1aa" }}> | ||
| 162 | {props.item.description} | ||
| 163 | </Text> | ||
| 164 | ) : null} | ||
| 165 | </Pressable> | ||
| 166 | ); | ||
| 167 | }); | ||
| 168 | |||
| 169 | export const ComposerCommandPopover = memo(function ComposerCommandPopover( | ||
| 170 | props: ComposerCommandPopoverProps, | ||
| 171 | ) { | ||
| 172 | const isDarkMode = useColorScheme() === "dark"; | ||
| 173 | const label = groupLabel(props.triggerKind); | ||
| 174 | |||
| 175 | return ( | ||
| 176 | <PopoverSurface isDarkMode={isDarkMode}> | ||
| 177 | {label ? ( | ||
| 178 | <View style={{ paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 }}> | ||
| 179 | <Text | ||
| 180 | className="text-[10px] font-t3-bold text-foreground-muted" | ||
| 181 | style={{ letterSpacing: 0.8, textTransform: "uppercase" }} | ||
| 182 | > | ||
| 183 | {label} | ||
| 184 | </Text> | ||
| 185 | </View> | ||
| 186 | ) : null} | ||
| 187 | {props.items.length > 0 ? ( | ||
| 188 | <ScrollView | ||
| 189 | style={{ maxHeight: 180 }} | ||
| 190 | keyboardShouldPersistTaps="always" | ||
| 191 | showsVerticalScrollIndicator={false} | ||
| 192 | > | ||
| 193 | {props.items.map((item, index) => ( | ||
| 194 | <CommandRow | ||
| 195 | key={item.id} | ||
| 196 | item={item} | ||
| 197 | onPress={() => props.onSelect(item)} | ||
| 198 | isLast={index === props.items.length - 1} | ||
| 199 | /> | ||
| 200 | ))} | ||
| 201 | </ScrollView> | ||
| 202 | ) : ( | ||
| 203 | <View style={{ paddingHorizontal: 14, paddingVertical: 10 }}> | ||
| 204 | <Text className="text-[12px] text-foreground-tertiary"> | ||
| 205 | {emptyText(props.triggerKind, props.isLoading)} | ||
| 206 | </Text> | ||
| 207 | </View> | ||
| 208 | )} | ||
| 209 | </PopoverSurface> | ||
| 210 | ); | ||
| 211 | }); | ||
| @@ -0,0 +1,121 @@ | |||
| 1 | import * as Haptics from "expo-haptics"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import { useCallback, useEffect, useRef } from "react"; | ||
| 4 | import { ActivityIndicator, Linking, Pressable, View } from "react-native"; | ||
| 5 | import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; | ||
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 7 | |||
| 8 | import { AppText as Text } from "../../components/AppText"; | ||
| 9 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 10 | import type { GitActionProgress } from "../../state/use-git-action-state"; | ||
| 11 | |||
| 12 | export function GitActionProgressOverlay(props: { | ||
| 13 | readonly progress: GitActionProgress; | ||
| 14 | readonly onDismiss: () => void; | ||
| 15 | }) { | ||
| 16 | const { progress, onDismiss } = props; | ||
| 17 | const insets = useSafeAreaInsets(); | ||
| 18 | const prevPhaseRef = useRef(progress.phase); | ||
| 19 | |||
| 20 | useEffect(() => { | ||
| 21 | const prev = prevPhaseRef.current; | ||
| 22 | prevPhaseRef.current = progress.phase; | ||
| 23 | |||
| 24 | if (prev === "running" && progress.phase === "success") { | ||
| 25 | Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); | ||
| 26 | } else if (prev === "running" && progress.phase === "error") { | ||
| 27 | Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); | ||
| 28 | } | ||
| 29 | }, [progress.phase]); | ||
| 30 | |||
| 31 | const handlePress = useCallback(() => { | ||
| 32 | if (progress.prUrl) { | ||
| 33 | void Linking.openURL(progress.prUrl); | ||
| 34 | return; | ||
| 35 | } | ||
| 36 | if (progress.phase === "success" || progress.phase === "error") { | ||
| 37 | onDismiss(); | ||
| 38 | } | ||
| 39 | }, [onDismiss, progress.phase, progress.prUrl]); | ||
| 40 | |||
| 41 | if (progress.phase === "idle") { | ||
| 42 | return null; | ||
| 43 | } | ||
| 44 | |||
| 45 | return ( | ||
| 46 | <Animated.View | ||
| 47 | entering={FadeIn.duration(200)} | ||
| 48 | exiting={FadeOut.duration(150)} | ||
| 49 | style={{ top: insets.top + 48, left: 12, right: 12, position: "absolute", zIndex: 100 }} | ||
| 50 | pointerEvents="box-none" | ||
| 51 | > | ||
| 52 | <Pressable onPress={handlePress}> | ||
| 53 | <OverlayContent progress={progress} /> | ||
| 54 | </Pressable> | ||
| 55 | </Animated.View> | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | function OverlayContent(props: { readonly progress: GitActionProgress }) { | ||
| 60 | const { progress } = props; | ||
| 61 | const iconColor = useThemeColor("--color-icon"); | ||
| 62 | |||
| 63 | const bgClass = | ||
| 64 | progress.phase === "error" | ||
| 65 | ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800" | ||
| 66 | : "bg-card border-border"; | ||
| 67 | |||
| 68 | return ( | ||
| 69 | <View | ||
| 70 | className={`flex-row items-center gap-2.5 rounded-2xl border px-3.5 py-3 shadow-lg shadow-black/10 ${bgClass}`} | ||
| 71 | > | ||
| 72 | <OverlayIcon phase={progress.phase} iconColor={iconColor} /> | ||
| 73 | |||
| 74 | <View className="flex-1 gap-0.5"> | ||
| 75 | {progress.label ? ( | ||
| 76 | <Text className="text-[13px] font-t3-bold text-foreground" numberOfLines={1}> | ||
| 77 | {progress.label} | ||
| 78 | </Text> | ||
| 79 | ) : null} | ||
| 80 | {progress.description ? ( | ||
| 81 | <Text className="text-[11px] text-foreground-muted" numberOfLines={1}> | ||
| 82 | {progress.description} | ||
| 83 | </Text> | ||
| 84 | ) : null} | ||
| 85 | </View> | ||
| 86 | |||
| 87 | {progress.prUrl ? ( | ||
| 88 | <SymbolView name="arrow.up.right" size={13} tintColor={iconColor} type="monochrome" /> | ||
| 89 | ) : null} | ||
| 90 | </View> | ||
| 91 | ); | ||
| 92 | } | ||
| 93 | |||
| 94 | function OverlayIcon(props: { | ||
| 95 | readonly phase: GitActionProgress["phase"]; | ||
| 96 | readonly iconColor: ReturnType<typeof useThemeColor>; | ||
| 97 | }) { | ||
| 98 | switch (props.phase) { | ||
| 99 | case "running": | ||
| 100 | return <ActivityIndicator size="small" />; | ||
| 101 | case "success": | ||
| 102 | return ( | ||
| 103 | <View className="h-6 w-6 items-center justify-center rounded-full bg-green-500"> | ||
| 104 | <SymbolView name="checkmark" size={12} tintColor="white" type="monochrome" /> | ||
| 105 | </View> | ||
| 106 | ); | ||
| 107 | case "error": | ||
| 108 | return ( | ||
| 109 | <View className="h-6 w-6 items-center justify-center rounded-full bg-red-500"> | ||
| 110 | <SymbolView | ||
| 111 | name="exclamationmark.triangle" | ||
| 112 | size={12} | ||
| 113 | tintColor="white" | ||
| 114 | type="monochrome" | ||
| 115 | /> | ||
| 116 | </View> | ||
| 117 | ); | ||
| 118 | default: | ||
| 119 | return null; | ||
| 120 | } | ||
| 121 | } | ||
| @@ -8,6 +8,11 @@ import type { · 7 unmodified lines | |||
| 8 | ServerConfig as T3ServerConfig, | 8 | ServerConfig as T3ServerConfig, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts"; | 10 | import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts"; |
| 11 | import { | ||
| 12 | detectComposerTrigger, | ||
| 13 | replaceTextRange, | ||
| 14 | type ComposerTrigger, | ||
| 15 | } from "@t3tools/shared/composerTrigger"; | ||
| 11 | import { TextInputWrapper } from "expo-paste-input"; | 16 | import { TextInputWrapper } from "expo-paste-input"; |
| 12 | import type { ReactNode } from "react"; | 17 | import type { ReactNode } from "react"; |
| 13 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 18 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| @@ -17,6 +22,8 @@ import { · 3 unmodified lines | |||
| 17 | TextInput as RNTextInput, | 22 | TextInput as RNTextInput, |
| 18 | useColorScheme, | 23 | useColorScheme, |
| 19 | View, | 24 | View, |
| 25 | type NativeSyntheticEvent, | ||
| 26 | type TextInputSelectionChangeEventData, | ||
| 20 | type ViewStyle, | 27 | type ViewStyle, |
| 21 | } from "react-native"; | 28 | } from "react-native"; |
| 22 | import ImageViewing from "react-native-image-viewing"; | 29 | import ImageViewing from "react-native-image-viewing"; |
| @@ -30,6 +37,13 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; · 7 unmodified lines | |||
| 30 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 37 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 31 | import type { RemoteClientConnectionState } from "../../lib/connection"; | 38 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 32 | import { useNativePaste } from "../../hooks/useNativePaste"; | 39 | import { useNativePaste } from "../../hooks/useNativePaste"; |
| 40 | import { | ||
| 41 | insertRankedSearchResult, | ||
| 42 | normalizeSearchQuery, | ||
| 43 | scoreQueryMatch, | ||
| 44 | } from "@t3tools/shared/searchRanking"; | ||
| 45 | import { getEnvironmentClient } from "../../state/use-remote-environment-registry"; | ||
| 46 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; | ||
| 33 | 47 | ||
| 34 | /** | 48 | /** |
| 35 | * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). | 49 | * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). |
| @@ -53,6 +67,8 @@ export interface ThreadComposerProps { · 17 unmodified lines | |||
| 53 | readonly serverConfig: T3ServerConfig | null; | 67 | readonly serverConfig: T3ServerConfig | null; |
| 54 | readonly queueCount: number; | 68 | readonly queueCount: number; |
| 55 | readonly activeThreadBusy: boolean; | 69 | readonly activeThreadBusy: boolean; |
| 70 | readonly environmentId: string; | ||
| 71 | readonly projectCwd: string | null; | ||
| 56 | readonly onChangeDraftMessage: (value: string) => void; | 72 | readonly onChangeDraftMessage: (value: string) => void; |
| 57 | readonly onPickDraftImages: () => Promise<void>; | 73 | readonly onPickDraftImages: () => Promise<void>; |
| 58 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 74 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| @@ -167,6 +183,281 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 108 unmodified lines | |||
| 167 | void props.onNativePasteImages(uris); | 183 | void props.onNativePasteImages(uris); |
| 168 | }); | 184 | }); |
| 169 | 185 | ||
| 186 | // ── Trigger detection ──────────────────────────────────── | ||
| 187 | const [cursorPosition, setCursorPosition] = useState(0); | ||
| 188 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(null); | ||
| 189 | const [pathSearchResults, setPathSearchResults] = useState< | ||
| 190 | ReadonlyArray<{ path: string; kind: "file" | "directory" }> | ||
| 191 | >([]); | ||
| 192 | const [pathSearchLoading, setPathSearchLoading] = useState(false); | ||
| 193 | const pathSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| 194 | |||
| 195 | const handleSelectionChange = useCallback( | ||
| 196 | (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { | ||
| 197 | const { start } = event.nativeEvent.selection; | ||
| 198 | setCursorPosition(start); | ||
| 199 | }, | ||
| 200 | [], | ||
| 201 | ); | ||
| 202 | |||
| 203 | useEffect(() => { | ||
| 204 | const trigger = detectComposerTrigger(props.draftMessage, cursorPosition); | ||
| 205 | setComposerTrigger(trigger); | ||
| 206 | }, [props.draftMessage, cursorPosition]); | ||
| 207 | |||
| 208 | // ── File search (debounced) ────────────────────────────── | ||
| 209 | useEffect(() => { | ||
| 210 | if (pathSearchTimerRef.current) { | ||
| 211 | clearTimeout(pathSearchTimerRef.current); | ||
| 212 | pathSearchTimerRef.current = null; | ||
| 213 | } | ||
| 214 | |||
| 215 | if (composerTrigger?.kind !== "path" || !composerTrigger.query || !props.projectCwd) { | ||
| 216 | setPathSearchResults([]); | ||
| 217 | setPathSearchLoading(false); | ||
| 218 | return; | ||
| 219 | } | ||
| 220 | |||
| 221 | setPathSearchLoading(true); | ||
| 222 | const query = composerTrigger.query; | ||
| 223 | const cwd = props.projectCwd; | ||
| 224 | const envId = props.environmentId; | ||
| 225 | |||
| 226 | pathSearchTimerRef.current = setTimeout(() => { | ||
| 227 | const client = getEnvironmentClient(envId); | ||
| 228 | if (!client) { | ||
| 229 | setPathSearchLoading(false); | ||
| 230 | return; | ||
| 231 | } | ||
| 232 | |||
| 233 | void client.projects | ||
| 234 | .searchEntries({ cwd, query, limit: 20 }) | ||
| 235 | .then((result) => { | ||
| 236 | setPathSearchResults( | ||
| 237 | result.entries.map((entry) => ({ | ||
| 238 | path: entry.path, | ||
| 239 | kind: entry.kind === "directory" ? ("directory" as const) : ("file" as const), | ||
| 240 | })), | ||
| 241 | ); | ||
| 242 | }) | ||
| 243 | .catch(() => { | ||
| 244 | setPathSearchResults([]); | ||
| 245 | }) | ||
| 246 | .finally(() => { | ||
| 247 | setPathSearchLoading(false); | ||
| 248 | }); | ||
| 249 | }, 200); | ||
| 250 | |||
| 251 | return () => { | ||
| 252 | if (pathSearchTimerRef.current) { | ||
| 253 | clearTimeout(pathSearchTimerRef.current); | ||
| 254 | } | ||
| 255 | }; | ||
| 256 | }, [composerTrigger?.kind, composerTrigger?.query, props.projectCwd, props.environmentId]); | ||
| 257 | |||
| 258 | // ── Build menu items ───────────────────────────────────── | ||
| 259 | const selectedProviderStatus = useMemo(() => { | ||
| 260 | if (!props.serverConfig) return null; | ||
| 261 | return ( | ||
| 262 | props.serverConfig.providers.find( | ||
| 263 | (p) => p.provider === props.selectedThread.modelSelection.provider, | ||
| 264 | ) ?? null | ||
| 265 | ); | ||
| 266 | }, [props.serverConfig, props.selectedThread.modelSelection.provider]); | ||
| 267 | |||
| 268 | const composerMenuItems: ComposerCommandItem[] = useMemo(() => { | ||
| 269 | if (!composerTrigger) return []; | ||
| 270 | |||
| 271 | if (composerTrigger.kind === "slash-command") { | ||
| 272 | const q = composerTrigger.query.toLowerCase(); | ||
| 273 | const allBuiltIn = [ | ||
| 274 | { | ||
| 275 | id: "cmd:model", | ||
| 276 | type: "slash-command" as const, | ||
| 277 | command: "model", | ||
| 278 | label: "/model", | ||
| 279 | description: "Switch model", | ||
| 280 | }, | ||
| 281 | { | ||
| 282 | id: "cmd:plan", | ||
| 283 | type: "slash-command" as const, | ||
| 284 | command: "plan", | ||
| 285 | label: "/plan", | ||
| 286 | description: "Switch to plan mode", | ||
| 287 | }, | ||
| 288 | { | ||
| 289 | id: "cmd:default", | ||
| 290 | type: "slash-command" as const, | ||
| 291 | command: "default", | ||
| 292 | label: "/default", | ||
| 293 | description: "Switch to default mode", | ||
| 294 | }, | ||
| 295 | ]; | ||
| 296 | const builtIn = allBuiltIn.filter((item) => item.command.includes(q)); | ||
| 297 | |||
| 298 | const providerCommands: ComposerCommandItem[] = (selectedProviderStatus?.slashCommands ?? []) | ||
| 299 | .filter((cmd) => cmd.name.toLowerCase().includes(q)) | ||
| 300 | .map((cmd) => ({ | ||
| 301 | id: `pcmd:${cmd.name}`, | ||
| 302 | type: "provider-slash-command" as const, | ||
| 303 | command: cmd, | ||
| 304 | label: `/${cmd.name}`, | ||
| 305 | description: cmd.description ?? "", | ||
| 306 | })); | ||
| 307 | |||
| 308 | return [...builtIn, ...providerCommands]; | ||
| 309 | } | ||
| 310 | |||
| 311 | if (composerTrigger.kind === "skill") { | ||
| 312 | const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); | ||
| 313 | const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { | ||
| 314 | trimLeadingPattern: /^\$+/, | ||
| 315 | }); | ||
| 316 | |||
| 317 | if (!normalizedQuery) { | ||
| 318 | return enabledSkills.slice(0, 20).map((skill) => ({ | ||
| 319 | id: `skill:${skill.name}`, | ||
| 320 | type: "skill" as const, | ||
| 321 | skill, | ||
| 322 | label: skill.displayName ?? skill.name, | ||
| 323 | description: skill.shortDescription ?? skill.description ?? "", | ||
| 324 | })); | ||
| 325 | } | ||
| 326 | |||
| 327 | const ranked: Array<{ | ||
| 328 | item: (typeof enabledSkills)[number]; | ||
| 329 | score: number; | ||
| 330 | tieBreaker: string; | ||
| 331 | }> = []; | ||
| 332 | for (const skill of enabledSkills) { | ||
| 333 | const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); | ||
| 334 | const scores = [ | ||
| 335 | scoreQueryMatch({ | ||
| 336 | value: skill.name.toLowerCase(), | ||
| 337 | query: normalizedQuery, | ||
| 338 | exactBase: 0, | ||
| 339 | prefixBase: 2, | ||
| 340 | boundaryBase: 4, | ||
| 341 | includesBase: 6, | ||
| 342 | fuzzyBase: 100, | ||
| 343 | boundaryMarkers: ["-", "_", "/"], | ||
| 344 | }), | ||
| 345 | scoreQueryMatch({ | ||
| 346 | value: displayLabel, | ||
| 347 | query: normalizedQuery, | ||
| 348 | exactBase: 1, | ||
| 349 | prefixBase: 3, | ||
| 350 | boundaryBase: 5, | ||
| 351 | includesBase: 7, | ||
| 352 | fuzzyBase: 110, | ||
| 353 | }), | ||
| 354 | scoreQueryMatch({ | ||
| 355 | value: skill.shortDescription?.toLowerCase() ?? "", | ||
| 356 | query: normalizedQuery, | ||
| 357 | exactBase: 20, | ||
| 358 | prefixBase: 22, | ||
| 359 | boundaryBase: 24, | ||
| 360 | includesBase: 26, | ||
| 361 | }), | ||
| 362 | scoreQueryMatch({ | ||
| 363 | value: skill.description?.toLowerCase() ?? "", | ||
| 364 | query: normalizedQuery, | ||
| 365 | exactBase: 30, | ||
| 366 | prefixBase: 32, | ||
| 367 | boundaryBase: 34, | ||
| 368 | includesBase: 36, | ||
| 369 | }), | ||
| 370 | ].filter((s): s is number => s !== null); | ||
| 371 | |||
| 372 | if (scores.length > 0) { | ||
| 373 | insertRankedSearchResult( | ||
| 374 | ranked, | ||
| 375 | { | ||
| 376 | item: skill, | ||
| 377 | score: Math.min(...scores), | ||
| 378 | tieBreaker: `${displayLabel}\u0000${skill.name}`, | ||
| 379 | }, | ||
| 380 | 20, | ||
| 381 | ); | ||
| 382 | } | ||
| 383 | } | ||
| 384 | |||
| 385 | return ranked.map(({ item: skill }) => ({ | ||
| 386 | id: `skill:${skill.name}`, | ||
| 387 | type: "skill" as const, | ||
| 388 | skill, | ||
| 389 | label: skill.displayName ?? skill.name, | ||
| 390 | description: skill.shortDescription ?? skill.description ?? "", | ||
| 391 | })); | ||
| 392 | } | ||
| 393 | |||
| 394 | if (composerTrigger.kind === "path") { | ||
| 395 | return pathSearchResults.map((entry) => { | ||
| 396 | const parts = entry.path.split("/"); | ||
| 397 | return { | ||
| 398 | id: `path:${entry.path}`, | ||
| 399 | type: "path" as const, | ||
| 400 | path: entry.path, | ||
| 401 | kind: entry.kind, | ||
| 402 | label: parts[parts.length - 1] ?? entry.path, | ||
| 403 | description: parts.length > 1 ? parts.slice(0, -1).join("/") : "", | ||
| 404 | }; | ||
| 405 | }); | ||
| 406 | } | ||
| 407 | |||
| 408 | return []; | ||
| 409 | }, [composerTrigger, selectedProviderStatus, pathSearchResults]); | ||
| 410 | |||
| 411 | // ── Handle command selection ────────────────────────────── | ||
| 412 | const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; | ||
| 413 | |||
| 414 | const handleSend = useCallback(() => { | ||
| 415 | onSendMessage(); | ||
| 416 | inputRef.current?.blur(); | ||
| 417 | }, [onSendMessage]); | ||
| 418 | const handleCommandSelect = useCallback( | ||
| 419 | (item: ComposerCommandItem) => { | ||
| 420 | if (!composerTrigger) return; | ||
| 421 | |||
| 422 | if ( | ||
| 423 | item.type === "slash-command" && | ||
| 424 | (item.command === "plan" || item.command === "default") | ||
| 425 | ) { | ||
| 426 | const result = replaceTextRange( | ||
| 427 | draftMessage, | ||
| 428 | composerTrigger.rangeStart, | ||
| 429 | composerTrigger.rangeEnd, | ||
| 430 | "", | ||
| 431 | ); | ||
| 432 | setCursorPosition(result.cursor); | ||
| 433 | onChangeDraftMessage(result.text); | ||
| 434 | void onUpdateInteractionMode(item.command); | ||
| 435 | return; | ||
| 436 | } | ||
| 437 | |||
| 438 | let replacement = ""; | ||
| 439 | if (item.type === "path") { | ||
| 440 | replacement = `@${item.path} `; | ||
| 441 | } else if (item.type === "skill") { | ||
| 442 | replacement = `$${item.skill.name} `; | ||
| 443 | } else if (item.type === "slash-command") { | ||
| 444 | replacement = `/${item.command} `; | ||
| 445 | } else if (item.type === "provider-slash-command") { | ||
| 446 | replacement = `/${item.command.name} `; | ||
| 447 | } | ||
| 448 | |||
| 449 | const result = replaceTextRange( | ||
| 450 | draftMessage, | ||
| 451 | composerTrigger.rangeStart, | ||
| 452 | composerTrigger.rangeEnd, | ||
| 453 | replacement, | ||
| 454 | ); | ||
| 455 | setCursorPosition(result.cursor); | ||
| 456 | onChangeDraftMessage(result.text); | ||
| 457 | }, | ||
| 458 | [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], | ||
| 459 | ); | ||
| 460 | |||
| 170 | // ── Model menu ─────────────────────────────────────────── | 461 | // ── Model menu ─────────────────────────────────────────── |
| 171 | const providerGroups = useMemo(() => { | 462 | const providerGroups = useMemo(() => { |
| 172 | const options = buildModelOptions(props.serverConfig, currentModelSelection); | 463 | const options = buildModelOptions(props.serverConfig, currentModelSelection); |
| @@ -346,8 +637,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 173 unmodified lines | |||
| 346 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)", | 637 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)", |
| 347 | }} | 638 | }} |
| 348 | > | 639 | > |
| 349 | <View className="w-full"> | 640 | <View className="w-full" style={{ position: "relative" }}> |
| 350 | {/* Input surface — the pill/card IS the glass element */} | 641 | {composerTrigger && composerMenuItems.length > 0 ? ( |
| 642 | <View | ||
| 643 | style={{ | ||
| 644 | position: "absolute", | ||
| 645 | bottom: "100%", | ||
| 646 | left: 0, | ||
| 647 | right: 0, | ||
| 648 | marginBottom: 8, | ||
| 649 | zIndex: 10, | ||
| 650 | }} | ||
| 651 | > | ||
| 652 | <ComposerCommandPopover | ||
| 653 | items={composerMenuItems} | ||
| 654 | triggerKind={composerTrigger.kind} | ||
| 655 | isLoading={pathSearchLoading} | ||
| 656 | onSelect={handleCommandSelect} | ||
| 657 | /> | ||
| 658 | </View> | ||
| 659 | ) : null} | ||
| 660 | |||
| 351 | <ComposerSurface | 661 | <ComposerSurface |
| 352 | isDarkMode={isDarkMode} | 662 | isDarkMode={isDarkMode} |
| 353 | style={ | 663 | style={ |
| @@ -387,6 +697,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 33 unmodified lines | |||
| 387 | multiline | 697 | multiline |
| 388 | value={props.draftMessage} | 698 | value={props.draftMessage} |
| 389 | onChangeText={props.onChangeDraftMessage} | 699 | onChangeText={props.onChangeDraftMessage} |
| 700 | onSelectionChange={handleSelectionChange} | ||
| 390 | placeholder={props.placeholder} | 701 | placeholder={props.placeholder} |
| 391 | placeholderTextColor={placeholderColor} | 702 | placeholderTextColor={placeholderColor} |
| 392 | editable={props.connectionState === "ready"} | 703 | editable={props.connectionState === "ready"} |
| @@ -463,7 +774,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 70 unmodified lines | |||
| 463 | icon="arrow.up" | 774 | icon="arrow.up" |
| 464 | variant="primary" | 775 | variant="primary" |
| 465 | disabled={!canSend} | 776 | disabled={!canSend} |
| 466 | onPress={props.onSendMessage} | 777 | onPress={handleSend} |
| 467 | /> | 778 | /> |
| 468 | ) | 779 | ) |
| 469 | ) : null} | 780 | ) : null} |
| @@ -508,7 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 38 unmodified lines | |||
| 508 | label={sendLabel} | 819 | label={sendLabel} |
| 509 | variant="primary" | 820 | variant="primary" |
| 510 | disabled={!canSend} | 821 | disabled={!canSend} |
| 511 | onPress={props.onSendMessage} | 822 | onPress={handleSend} |
| 512 | /> | 823 | /> |
| 513 | </View> | 824 | </View> |
| 514 | ) : null} | 825 | ) : null} |
| @@ -53,6 +53,7 @@ export interface ThreadDetailScreenProps { · 52 unmodified lines | |||
| 53 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 53 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 54 | readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; | 54 | readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; |
| 55 | readonly activeThreadBusy: boolean; | 55 | readonly activeThreadBusy: boolean; |
| 56 | readonly environmentId: string; | ||
| 56 | readonly projectWorkspaceRoot: string | null; | 57 | readonly projectWorkspaceRoot: string | null; |
| 57 | readonly selectedThreadQueueCount: number; | 58 | readonly selectedThreadQueueCount: number; |
| 58 | readonly serverConfig: T3ServerConfig | null; | 59 | readonly serverConfig: T3ServerConfig | null; |
| @@ -313,6 +314,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 254 unmodified lines | |||
| 313 | serverConfig={props.serverConfig} | 314 | serverConfig={props.serverConfig} |
| 314 | queueCount={props.selectedThreadQueueCount} | 315 | queueCount={props.selectedThreadQueueCount} |
| 315 | activeThreadBusy={props.activeThreadBusy} | 316 | activeThreadBusy={props.activeThreadBusy} |
| 317 | environmentId={props.environmentId} | ||
| 318 | projectCwd={props.projectWorkspaceRoot} | ||
| 316 | bottomInset={composerBottomInset} | 319 | bottomInset={composerBottomInset} |
| 317 | onChangeDraftMessage={props.onChangeDraftMessage} | 320 | onChangeDraftMessage={props.onChangeDraftMessage} |
| 318 | onPickDraftImages={props.onPickDraftImages} | 321 | onPickDraftImages={props.onPickDraftImages} |
| @@ -1,11 +1,12 @@ | |||
| 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 2 | import { useCallback, useState } from "react"; | 2 | import { useCallback, useMemo, useState } from "react"; |
| 3 | import * as Arr from "effect/Array"; | 3 | import * as Arr from "effect/Array"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import { pipe } from "effect/Function"; | 5 | import { pipe } from "effect/Function"; |
| 6 | import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; | 6 | import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; |
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | 7 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 8 | import { useGitStatus, gitStatusManager } from "../../state/use-git-status"; | 8 | import { useGitStatus, gitStatusManager } from "../../state/use-git-status"; |
| 9 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-git-action-state"; | ||
| 9 | 10 | ||
| 10 | import { EmptyState } from "../../components/EmptyState"; | 11 | import { EmptyState } from "../../components/EmptyState"; |
| 11 | import { LoadingScreen } from "../../components/LoadingScreen"; | 12 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| @@ -20,10 +21,10 @@ import { · 8 unmodified lines | |||
| 20 | } from "../../state/use-remote-environment-registry"; | 21 | } from "../../state/use-remote-environment-registry"; |
| 21 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; | 22 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 22 | import { useThreadSelection } from "../../state/use-thread-selection"; | 23 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 24 | import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; | ||
| 23 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; | 25 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; |
| 24 | import { ThreadGitControls } from "./ThreadGitControls"; | 26 | import { ThreadGitControls } from "./ThreadGitControls"; |
| 25 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; | 27 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; |
| 26 | import { screenTitle } from "./threadPresentation"; | ||
| 27 | import { useSelectedThreadCommands } from "./use-selected-thread-commands"; | 28 | import { useSelectedThreadCommands } from "./use-selected-thread-commands"; |
| 28 | import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions"; | 29 | import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions"; |
| 29 | import { useSelectedThreadGitState } from "./use-selected-thread-git-state"; | 30 | import { useSelectedThreadGitState } from "./use-selected-thread-git-state"; |
| @@ -82,6 +83,20 @@ export function ThreadRouteScreen() { · 52 unmodified lines | |||
| 82 | cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null, | 83 | cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null, |
| 83 | }); | 84 | }); |
| 84 | 85 | ||
| 86 | /* ─── Git action progress (for overlay banner) ──────────────────── */ | ||
| 87 | const gitActionProgressTarget = useMemo( | ||
| 88 | () => ({ | ||
| 89 | environmentId: selectedThread?.environmentId ?? null, | ||
| 90 | cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null, | ||
| 91 | }), | ||
| 92 | [ | ||
| 93 | selectedThread?.environmentId, | ||
| 94 | selectedThread?.worktreePath, | ||
| 95 | selectedThreadProject?.workspaceRoot, | ||
| 96 | ], | ||
| 97 | ); | ||
| 98 | const gitActionProgress = useGitActionProgress(gitActionProgressTarget); | ||
| 99 | |||
| 85 | const handleRefreshGitStatus = useCallback(async () => { | 100 | const handleRefreshGitStatus = useCallback(async () => { |
| 86 | if (!selectedThread) return; | 101 | if (!selectedThread) return; |
| 87 | await gitStatusManager.refresh({ | 102 | await gitStatusManager.refresh({ |
| @@ -152,7 +167,7 @@ export function ThreadRouteScreen() { · 64 unmodified lines | |||
| 152 | ); | 167 | ); |
| 153 | 168 | ||
| 154 | const headerSubtitle = [ | 169 | const headerSubtitle = [ |
| 155 | screenTitle(serverConfig, null), | 170 | selectedThreadProject?.title ?? null, |
| 156 | selectedEnvironmentConnection?.environmentLabel ?? null, | 171 | selectedEnvironmentConnection?.environmentLabel ?? null, |
| 157 | ] | 172 | ] |
| 158 | .filter(Boolean) | 173 | .filter(Boolean) |
| @@ -212,6 +227,8 @@ export function ThreadRouteScreen() { · 53 unmodified lines | |||
| 212 | onRunAction={gitActions.onRunSelectedThreadGitAction} | 227 | onRunAction={gitActions.onRunSelectedThreadGitAction} |
| 213 | /> | 228 | /> |
| 214 | 229 | ||
| 230 | <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} /> | ||
| 231 | |||
| 215 | <View className="flex-1 bg-screen"> | 232 | <View className="flex-1 bg-screen"> |
| 216 | <ThreadDetailScreen | 233 | <ThreadDetailScreen |
| 217 | selectedThread={selectedThreadDetail} | 234 | selectedThread={selectedThreadDetail} |
| @@ -231,6 +248,7 @@ export function ThreadRouteScreen() { · 13 unmodified lines | |||
| 231 | draftAttachments={composer.draftAttachments} | 248 | draftAttachments={composer.draftAttachments} |
| 232 | connectionStateLabel={routeConnectionState} | 249 | connectionStateLabel={routeConnectionState} |
| 233 | activeThreadBusy={composer.activeThreadBusy} | 250 | activeThreadBusy={composer.activeThreadBusy} |
| 251 | environmentId={selectedThread.environmentId} | ||
| 234 | projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} | 252 | projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} |
| 235 | selectedThreadQueueCount={composer.selectedThreadQueueCount} | 253 | selectedThreadQueueCount={composer.selectedThreadQueueCount} |
| 236 | onOpenDrawer={handleOpenDrawer} | 254 | onOpenDrawer={handleOpenDrawer} |
| @@ -12,7 +12,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; · 11 unmodified lines | |||
| 12 | import { useThemeColor } from "../../../lib/useThemeColor"; | 12 | import { useThemeColor } from "../../../lib/useThemeColor"; |
| 13 | 13 | ||
| 14 | import { AppText as Text } from "../../../components/AppText"; | 14 | import { AppText as Text } from "../../../components/AppText"; |
| 15 | import { StatusPill } from "../../../components/StatusPill"; | ||
| 16 | import { useGitStatus } from "../../../state/use-git-status"; | 15 | import { useGitStatus } from "../../../state/use-git-status"; |
| 17 | import { useThreadSelection } from "../../../state/use-thread-selection"; | 16 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| 18 | import { useSelectedThreadGitActions } from "../use-selected-thread-git-actions"; | 17 | import { useSelectedThreadGitActions } from "../use-selected-thread-git-actions"; |
| @@ -231,17 +230,6 @@ export function GitOverviewSheet() { · 212 unmodified lines | |||
| 231 | </View> | 230 | </View> |
| 232 | 231 | ||
| 233 | {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null} | 232 | {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null} |
| 234 | |||
| 235 | {gitOperationLabel ? ( | ||
| 236 | <View className="self-start"> | ||
| 237 | <StatusPill | ||
| 238 | label={gitOperationLabel} | ||
| 239 | pillClassName="bg-neutral-900/8" | ||
| 240 | textClassName="text-neutral-700" | ||
| 241 | size="compact" | ||
| 242 | /> | ||
| 243 | </View> | ||
| 244 | ) : null} | ||
| 245 | </ScrollView> | 233 | </ScrollView> |
| 246 | </View> | 234 | </View> |
| 247 | ); | 235 | ); |
| @@ -1,4 +1,3 @@ | |||
| 1 | import type { ServerConfig as T3ServerConfig } from "@t3tools/contracts"; | ||
| 2 | import type { StatusTone } from "../../components/StatusPill"; | 1 | import type { StatusTone } from "../../components/StatusPill"; |
| 3 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; | 2 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; |
| 4 | 3 | ||
| @@ -52,12 +51,3 @@ export function messageImageUrl(httpBaseUrl: string | null, attachmentId: string · 47 unmodified lines | |||
| 52 | const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl); | 51 | const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl); |
| 53 | return url.toString(); | 52 | return url.toString(); |
| 54 | } | 53 | } |
| 55 | |||
| 56 | export function screenTitle(config: T3ServerConfig | null, serverUrl: string | null): string { | ||
| 57 | if (config) { | ||
| 58 | const segments = config.cwd.split(/[/\\]/).filter(Boolean); | ||
| 59 | const candidate = segments.at(-1) ?? config.cwd; | ||
| 60 | return /^t3[-_\s]?code$/i.test(candidate) ? "T3 Code" : candidate; | ||
| 61 | } | ||
| 62 | return serverUrl ?? "T3 Code"; | ||
| 63 | } | ||
| @@ -96,77 +96,53 @@ export function useProjectActions() { · 95 unmodified lines | |||
| 96 | const initialMessageText = input.initialMessageText.trim(); | 96 | const initialMessageText = input.initialMessageText.trim(); |
| 97 | const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); | 97 | const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); |
| 98 | 98 | ||
| 99 | if (input.envMode === "worktree") { | 99 | if (initialMessageText.length === 0) { |
| 100 | if (!input.branch || initialMessageText.length === 0) { | 100 | return null; |
| 101 | return null; | 101 | } |
| 102 | } | 102 | if (input.envMode === "worktree" && !input.branch) { |
| 103 | return null; | ||
| 104 | } | ||
| 103 | 105 | ||
| 104 | await client.orchestration.dispatchCommand({ | 106 | const isWorktree = input.envMode === "worktree"; |
| 105 | type: "thread.turn.start", | ||
| 106 | commandId: CommandId.make(uuidv4()), | ||
| 107 | threadId, | ||
| 108 | message: { | ||
| 109 | messageId: MessageId.make(uuidv4()), | ||
| 110 | role: "user", | ||
| 111 | text: initialMessageText, | ||
| 112 | attachments: input.initialAttachments, | ||
| 113 | }, | ||
| 114 | modelSelection: input.modelSelection, | ||
| 115 | titleSeed: nextTitle, | ||
| 116 | runtimeMode: input.runtimeMode, | ||
| 117 | interactionMode: input.interactionMode, | ||
| 118 | bootstrap: { | ||
| 119 | createThread: { | ||
| 120 | projectId: input.project.id, | ||
| 121 | title: nextTitle, | ||
| 122 | modelSelection: input.modelSelection, | ||
| 123 | runtimeMode: input.runtimeMode, | ||
| 124 | interactionMode: input.interactionMode, | ||
| 125 | branch: input.branch, | ||
| 126 | worktreePath: null, | ||
| 127 | createdAt, | ||
| 128 | }, | ||
| 129 | prepareWorktree: { | ||
| 130 | projectCwd: input.project.workspaceRoot, | ||
| 131 | baseBranch: input.branch, | ||
| 132 | branch: buildTemporaryWorktreeBranchName(), | ||
| 133 | }, | ||
| 134 | runSetupScript: true, | ||
| 135 | }, | ||
| 136 | createdAt: new Date().toISOString(), | ||
| 137 | }); | ||
| 138 | } else { | ||
| 139 | await client.orchestration.dispatchCommand({ | ||
| 140 | type: "thread.create", | ||
| 141 | commandId: CommandId.make(uuidv4()), | ||
| 142 | threadId, | ||
| 143 | projectId: input.project.id, | ||
| 144 | title: nextTitle, | ||
| 145 | modelSelection: input.modelSelection, | ||
| 146 | runtimeMode: input.runtimeMode, | ||
| 147 | interactionMode: input.interactionMode, | ||
| 148 | branch: input.branch, | ||
| 149 | worktreePath: input.worktreePath, | ||
| 150 | createdAt, | ||
| 151 | }); | ||
| 152 | 107 | ||
| 153 | if (initialMessageText.length > 0 || input.initialAttachments.length > 0) { | 108 | await client.orchestration.dispatchCommand({ |
| 154 | await client.orchestration.dispatchCommand({ | 109 | type: "thread.turn.start", |
| 155 | type: "thread.turn.start", | 110 | commandId: CommandId.make(uuidv4()), |
| 156 | commandId: CommandId.make(uuidv4()), | 111 | threadId, |
| 157 | threadId, | 112 | message: { |
| 158 | message: { | 113 | messageId: MessageId.make(uuidv4()), |
| 159 | messageId: MessageId.make(uuidv4()), | 114 | role: "user", |
| 160 | role: "user", | 115 | text: initialMessageText, |
| 161 | text: initialMessageText, | 116 | attachments: input.initialAttachments, |
| 162 | attachments: input.initialAttachments, | 117 | }, |
| 163 | }, | 118 | modelSelection: input.modelSelection, |
| 119 | titleSeed: nextTitle, | ||
| 120 | runtimeMode: input.runtimeMode, | ||
| 121 | interactionMode: input.interactionMode, | ||
| 122 | bootstrap: { | ||
| 123 | createThread: { | ||
| 124 | projectId: input.project.id, | ||
| 125 | title: nextTitle, | ||
| 126 | modelSelection: input.modelSelection, | ||
| 164 | runtimeMode: input.runtimeMode, | 127 | runtimeMode: input.runtimeMode, |
| 165 | interactionMode: input.interactionMode, | 128 | interactionMode: input.interactionMode, |
| 166 | createdAt: new Date().toISOString(), | 129 | branch: input.branch, |
| 167 | }); | 130 | worktreePath: isWorktree ? null : input.worktreePath, |
| 168 | } | 131 | createdAt, |
| 169 | } | 132 | }, |
| 133 | ...(isWorktree | ||
| 134 | ? { | ||
| 135 | prepareWorktree: { | ||
| 136 | projectCwd: input.project.workspaceRoot, | ||
| 137 | baseBranch: input.branch!, | ||
| 138 | branch: buildTemporaryWorktreeBranchName(), | ||
| 139 | }, | ||
| 140 | runSetupScript: true, | ||
| 141 | } | ||
| 142 | : {}), | ||
| 143 | }, | ||
| 144 | createdAt: new Date().toISOString(), | ||
| 145 | }); | ||
| 170 | 146 | ||
| 171 | await refreshRemoteData([input.project.environmentId]); | 147 | await refreshRemoteData([input.project.environmentId]); |
| 172 | return { | 148 | return { |
| @@ -21,7 +21,7 @@ import { · 20 unmodified lines | |||
| 21 | getEnvironmentClient, | 21 | getEnvironmentClient, |
| 22 | setPendingConnectionError, | 22 | setPendingConnectionError, |
| 23 | } from "../../state/use-remote-environment-registry"; | 23 | } from "../../state/use-remote-environment-registry"; |
| 24 | import { gitActionManager } from "../../state/use-git-action-state"; | 24 | import { gitActionManager, showGitActionResult } from "../../state/use-git-action-state"; |
| 25 | import { gitBranchManager } from "../../state/use-git-branches"; | 25 | import { gitBranchManager } from "../../state/use-git-branches"; |
| 26 | import { gitStatusManager } from "../../state/use-git-status"; | 26 | import { gitStatusManager } from "../../state/use-git-status"; |
| 27 | import { useThreadSelection } from "../../state/use-thread-selection"; | 27 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| @@ -122,7 +122,9 @@ export function useSelectedThreadGitActions() { · 94 unmodified lines | |||
| 122 | cwd, | 122 | cwd, |
| 123 | }); | 123 | }); |
| 124 | } catch (error) { | 124 | } catch (error) { |
| 125 | setPendingConnectionError(error instanceof Error ? error.message : "Git action failed."); | 125 | const message = error instanceof Error ? error.message : "Git action failed."; |
| 126 | setPendingConnectionError(message); | ||
| 127 | showGitActionResult({ type: "error", title: "Git action failed", description: message }); | ||
| 126 | return null; | 128 | return null; |
| 127 | } | 129 | } |
| 128 | }, | 130 | }, |
| @@ -264,8 +266,17 @@ export function useSelectedThreadGitActions() { · 135 unmodified lines | |||
| 264 | 266 | ||
| 265 | const onPullSelectedThreadBranch = useCallback(async () => { | 267 | const onPullSelectedThreadBranch = useCallback(async () => { |
| 266 | await runSelectedThreadGitMutation(async ({ thread, cwd }) => { | 268 | await runSelectedThreadGitMutation(async ({ thread, cwd }) => { |
| 267 | await gitActionManager.pull({ environmentId: thread.environmentId, cwd }); | 269 | const result = await gitActionManager.pull({ environmentId: thread.environmentId, cwd }); |
| 268 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | 270 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); |
| 271 | if (result) { | ||
| 272 | showGitActionResult({ | ||
| 273 | type: "success", | ||
| 274 | title: | ||
| 275 | result.status === "skipped_up_to_date" | ||
| 276 | ? "Already up to date" | ||
| 277 | : `Pulled latest on ${result.branch}`, | ||
| 278 | }); | ||
| 279 | } | ||
| 269 | }); | 280 | }); |
| 270 | }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); | 281 | }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); |
| 271 | 282 | ||
| @@ -292,6 +303,13 @@ export function useSelectedThreadGitActions() { · 20 unmodified lines | |||
| 292 | return null; | 303 | return null; |
| 293 | } | 304 | } |
| 294 | 305 | ||
| 306 | showGitActionResult({ | ||
| 307 | type: "success", | ||
| 308 | title: result.toast.title, | ||
| 309 | description: result.toast.description, | ||
| 310 | prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined, | ||
| 311 | }); | ||
| 312 | |||
| 295 | if (result.branch.status === "created" && result.branch.name) { | 313 | if (result.branch.status === "created" && result.branch.name) { |
| 296 | await syncSelectedThreadBranchState({ | 314 | await syncSelectedThreadBranchState({ |
| 297 | thread, | 315 | thread, |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | getGitActionTargetKey, | 8 | getGitActionTargetKey, |
| 9 | gitActionStateAtom, | 9 | gitActionStateAtom, |
| 10 | } from "@t3tools/client-runtime"; | 10 | } from "@t3tools/client-runtime"; |
| 11 | import { useCallback, useEffect, useRef, useState } from "react"; | ||
| 11 | 12 | ||
| 12 | import { uuidv4 } from "../lib/uuid"; | 13 | import { uuidv4 } from "../lib/uuid"; |
| 13 | import { appAtomRegistry } from "./atom-registry"; | 14 | import { appAtomRegistry } from "./atom-registry"; |
| @@ -29,3 +30,133 @@ export function useGitActionState(target: GitActionTarget): GitActionState { · 15 unmodified lines | |||
| 29 | ); | 30 | ); |
| 30 | return targetKey === null ? EMPTY_GIT_ACTION_STATE : state; | 31 | return targetKey === null ? EMPTY_GIT_ACTION_STATE : state; |
| 31 | } | 32 | } |
| 33 | |||
| 34 | // --------------------------------------------------------------------------- | ||
| 35 | // Git action result notification | ||
| 36 | // --------------------------------------------------------------------------- | ||
| 37 | |||
| 38 | export interface GitActionResultNotification { | ||
| 39 | readonly type: "success" | "error"; | ||
| 40 | readonly title: string; | ||
| 41 | readonly description?: string; | ||
| 42 | readonly prUrl?: string; | ||
| 43 | } | ||
| 44 | |||
| 45 | const RESULT_DISMISS_MS = 5_000; | ||
| 46 | |||
| 47 | type ResultListener = (result: GitActionResultNotification | null) => void; | ||
| 48 | const resultListeners = new Set<ResultListener>(); | ||
| 49 | let currentResult: GitActionResultNotification | null = null; | ||
| 50 | let dismissTimer: ReturnType<typeof setTimeout> | null = null; | ||
| 51 | |||
| 52 | function broadcast(result: GitActionResultNotification | null): void { | ||
| 53 | currentResult = result; | ||
| 54 | for (const listener of resultListeners) { | ||
| 55 | listener(result); | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | export function showGitActionResult(result: GitActionResultNotification): void { | ||
| 60 | if (dismissTimer) clearTimeout(dismissTimer); | ||
| 61 | broadcast(result); | ||
| 62 | dismissTimer = setTimeout(() => broadcast(null), RESULT_DISMISS_MS); | ||
| 63 | } | ||
| 64 | |||
| 65 | export function dismissGitActionResult(): void { | ||
| 66 | if (dismissTimer) clearTimeout(dismissTimer); | ||
| 67 | broadcast(null); | ||
| 68 | } | ||
| 69 | |||
| 70 | export function useGitActionResultNotification(): { | ||
| 71 | readonly result: GitActionResultNotification | null; | ||
| 72 | readonly dismiss: () => void; | ||
| 73 | } { | ||
| 74 | const [result, setResult] = useState<GitActionResultNotification | null>(currentResult); | ||
| 75 | |||
| 76 | useEffect(() => { | ||
| 77 | resultListeners.add(setResult); | ||
| 78 | setResult(currentResult); | ||
| 79 | return () => { | ||
| 80 | resultListeners.delete(setResult); | ||
| 81 | }; | ||
| 82 | }, []); | ||
| 83 | |||
| 84 | return { result, dismiss: dismissGitActionResult }; | ||
| 85 | } | ||
| 86 | |||
| 87 | // --------------------------------------------------------------------------- | ||
| 88 | // Unified git action progress (combines running state + result notification) | ||
| 89 | // --------------------------------------------------------------------------- | ||
| 90 | |||
| 91 | export type GitActionProgressPhase = "idle" | "running" | "success" | "error"; | ||
| 92 | |||
| 93 | export interface GitActionProgress { | ||
| 94 | readonly phase: GitActionProgressPhase; | ||
| 95 | readonly label: string | null; | ||
| 96 | readonly description: string | null; | ||
| 97 | readonly prUrl?: string; | ||
| 98 | } | ||
| 99 | |||
| 100 | const EMPTY_PROGRESS: GitActionProgress = { | ||
| 101 | phase: "idle", | ||
| 102 | label: null, | ||
| 103 | description: null, | ||
| 104 | }; | ||
| 105 | |||
| 106 | function formatElapsedSeconds(ms: number | null): string | null { | ||
| 107 | if (ms === null) return null; | ||
| 108 | const elapsed = Math.max(0, Math.floor((Date.now() - ms) / 1000)); | ||
| 109 | if (elapsed < 2) return null; | ||
| 110 | return `Running for ${elapsed}s`; | ||
| 111 | } | ||
| 112 | |||
| 113 | export function useGitActionProgress(target: GitActionTarget): GitActionProgress { | ||
| 114 | const actionState = useGitActionState(target); | ||
| 115 | const { result } = useGitActionResultNotification(); | ||
| 116 | |||
| 117 | const [, forceUpdate] = useState(0); | ||
| 118 | const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); | ||
| 119 | |||
| 120 | const startElapsedTimer = useCallback(() => { | ||
| 121 | if (intervalRef.current) return; | ||
| 122 | intervalRef.current = setInterval(() => forceUpdate((n) => n + 1), 1000); | ||
| 123 | }, []); | ||
| 124 | |||
| 125 | const stopElapsedTimer = useCallback(() => { | ||
| 126 | if (intervalRef.current) { | ||
| 127 | clearInterval(intervalRef.current); | ||
| 128 | intervalRef.current = null; | ||
| 129 | } | ||
| 130 | }, []); | ||
| 131 | |||
| 132 | useEffect(() => { | ||
| 133 | if (actionState.isRunning) { | ||
| 134 | startElapsedTimer(); | ||
| 135 | } else { | ||
| 136 | stopElapsedTimer(); | ||
| 137 | } | ||
| 138 | return stopElapsedTimer; | ||
| 139 | }, [actionState.isRunning, startElapsedTimer, stopElapsedTimer]); | ||
| 140 | |||
| 141 | if (actionState.isRunning) { | ||
| 142 | const description = | ||
| 143 | actionState.lastOutputLine ?? | ||
| 144 | formatElapsedSeconds(actionState.hookStartedAtMs ?? actionState.phaseStartedAtMs); | ||
| 145 | return { | ||
| 146 | phase: "running", | ||
| 147 | label: actionState.currentLabel, | ||
| 148 | description, | ||
| 149 | }; | ||
| 150 | } | ||
| 151 | |||
| 152 | if (result) { | ||
| 153 | return { | ||
| 154 | phase: result.type, | ||
| 155 | label: result.title, | ||
| 156 | description: result.description ?? null, | ||
| 157 | prUrl: result.prUrl, | ||
| 158 | }; | ||
| 159 | } | ||
| 160 | |||
| 161 | return EMPTY_PROGRESS; | ||
| 162 | } | ||
| @@ -1,16 +1,18 @@ | |||
| 1 | import { | ||
| 2 | detectComposerTrigger as detectComposerTriggerBase, | ||
| 3 | type ComposerTrigger, | ||
| 4 | type ComposerTriggerKind, | ||
| 5 | type ComposerSlashCommand, | ||
| 6 | } from "@t3tools/shared/composerTrigger"; | ||
| 7 | export { | ||
| 8 | replaceTextRange, | ||
| 9 | parseStandaloneComposerSlashCommand, | ||
| 10 | } from "@t3tools/shared/composerTrigger"; | ||
| 11 | export type { ComposerTrigger, ComposerTriggerKind, ComposerSlashCommand }; | ||
| 12 | |||
| 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; | 13 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 2 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; | 14 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; |
| 3 | 15 | ||
| 4 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill"; | ||
| 5 | export type ComposerSlashCommand = "model" | "plan" | "default"; | ||
| 6 | |||
| 7 | export interface ComposerTrigger { | ||
| 8 | kind: ComposerTriggerKind; | ||
| 9 | query: string; | ||
| 10 | rangeStart: number; | ||
| 11 | rangeEnd: number; | ||
| 12 | } | ||
| 13 | |||
| 14 | const isInlineTokenSegment = ( | 16 | const isInlineTokenSegment = ( |
| 15 | segment: | 17 | segment: |
| 16 | | { type: "text"; text: string } | 18 | | { type: "text"; text: string } |
| @@ -216,83 +218,5 @@ export function isCollapsedCursorAdjacentToInlineToken( · 199 unmodified lines | |||
| 216 | export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken; | 218 | export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken; |
| 217 | 219 | ||
| 218 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { | 220 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { |
| 219 | const cursor = clampCursor(text, cursorInput); | 221 | return detectComposerTriggerBase(text, cursorInput, isWhitespace); |
| 220 | const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; | ||
| 221 | const linePrefix = text.slice(lineStart, cursor); | ||
| 222 | |||
| 223 | if (linePrefix.startsWith("/")) { | ||
| 224 | const commandMatch = /^\/(\S*)$/.exec(linePrefix); | ||
| 225 | if (commandMatch) { | ||
| 226 | const commandQuery = commandMatch[1] ?? ""; | ||
| 227 | if (commandQuery.toLowerCase() === "model") { | ||
| 228 | return { | ||
| 229 | kind: "slash-model", | ||
| 230 | query: "", | ||
| 231 | rangeStart: lineStart, | ||
| 232 | rangeEnd: cursor, | ||
| 233 | }; | ||
| 234 | } | ||
| 235 | return { | ||
| 236 | kind: "slash-command", | ||
| 237 | query: commandQuery, | ||
| 238 | rangeStart: lineStart, | ||
| 239 | rangeEnd: cursor, | ||
| 240 | }; | ||
| 241 | } | ||
| 242 | |||
| 243 | const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); | ||
| 244 | if (modelMatch) { | ||
| 245 | return { | ||
| 246 | kind: "slash-model", | ||
| 247 | query: (modelMatch[1] ?? "").trim(), | ||
| 248 | rangeStart: lineStart, | ||
| 249 | rangeEnd: cursor, | ||
| 250 | }; | ||
| 251 | } | ||
| 252 | } | ||
| 253 | |||
| 254 | const tokenStart = tokenStartForCursor(text, cursor); | ||
| 255 | const token = text.slice(tokenStart, cursor); | ||
| 256 | if (token.startsWith("$")) { | ||
| 257 | return { | ||
| 258 | kind: "skill", | ||
| 259 | query: token.slice(1), | ||
| 260 | rangeStart: tokenStart, | ||
| 261 | rangeEnd: cursor, | ||
| 262 | }; | ||
| 263 | } | ||
| 264 | if (!token.startsWith("@")) { | ||
| 265 | return null; | ||
| 266 | } | ||
| 267 | |||
| 268 | return { | ||
| 269 | kind: "path", | ||
| 270 | query: token.slice(1), | ||
| 271 | rangeStart: tokenStart, | ||
| 272 | rangeEnd: cursor, | ||
| 273 | }; | ||
| 274 | } | ||
| 275 | |||
| 276 | export function parseStandaloneComposerSlashCommand( | ||
| 277 | text: string, | ||
| 278 | ): Exclude<ComposerSlashCommand, "model"> | null { | ||
| 279 | const match = /^\/(plan|default)\s*$/i.exec(text.trim()); | ||
| 280 | if (!match) { | ||
| 281 | return null; | ||
| 282 | } | ||
| 283 | const command = match[1]?.toLowerCase(); | ||
| 284 | if (command === "plan") return "plan"; | ||
| 285 | return "default"; | ||
| 286 | } | ||
| 287 | |||
| 288 | export function replaceTextRange( | ||
| 289 | text: string, | ||
| 290 | rangeStart: number, | ||
| 291 | rangeEnd: number, | ||
| 292 | replacement: string, | ||
| 293 | ): { text: string; cursor: number } { | ||
| 294 | const safeStart = Math.max(0, Math.min(text.length, rangeStart)); | ||
| 295 | const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd)); | ||
| 296 | const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; | ||
| 297 | return { text: nextText, cursor: safeStart + replacement.length }; | ||
| 298 | } | 222 | } |
| @@ -67,6 +67,10 @@ · 66 unmodified lines | |||
| 67 | "./qrCode": { | 67 | "./qrCode": { |
| 68 | "types": "./src/qrCode.ts", | 68 | "types": "./src/qrCode.ts", |
| 69 | "import": "./src/qrCode.ts" | 69 | "import": "./src/qrCode.ts" |
| 70 | }, | ||
| 71 | "./composerTrigger": { | ||
| 72 | "types": "./src/composerTrigger.ts", | ||
| 73 | "import": "./src/composerTrigger.ts" | ||
| 70 | } | 74 | } |
| 71 | }, | 75 | }, |
| 72 | "scripts": { | 76 | "scripts": { |
| @@ -0,0 +1,117 @@ | |||
| 1 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill"; | ||
| 2 | export type ComposerSlashCommand = "model" | "plan" | "default"; | ||
| 3 | |||
| 4 | export interface ComposerTrigger { | ||
| 5 | kind: ComposerTriggerKind; | ||
| 6 | query: string; | ||
| 7 | rangeStart: number; | ||
| 8 | rangeEnd: number; | ||
| 9 | } | ||
| 10 | |||
| 11 | function clampCursor(text: string, cursor: number): number { | ||
| 12 | if (!Number.isFinite(cursor)) return text.length; | ||
| 13 | return Math.max(0, Math.min(text.length, Math.floor(cursor))); | ||
| 14 | } | ||
| 15 | |||
| 16 | function isWhitespace(char: string): boolean { | ||
| 17 | return char === " " || char === "\n" || char === "\t" || char === "\r"; | ||
| 18 | } | ||
| 19 | |||
| 20 | /** | ||
| 21 | * Detect an active trigger (@path, $skill, /command) at the cursor position. | ||
| 22 | * | ||
| 23 | * Accepts an optional `isWhitespaceChar` override so callers with inline | ||
| 24 | * placeholder characters (e.g. terminal context chips on web) can treat | ||
| 25 | * those as token boundaries. | ||
| 26 | */ | ||
| 27 | export function detectComposerTrigger( | ||
| 28 | text: string, | ||
| 29 | cursorInput: number, | ||
| 30 | isWhitespaceChar?: (char: string) => boolean, | ||
| 31 | ): ComposerTrigger | null { | ||
| 32 | const cursor = clampCursor(text, cursorInput); | ||
| 33 | const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; | ||
| 34 | const linePrefix = text.slice(lineStart, cursor); | ||
| 35 | |||
| 36 | if (linePrefix.startsWith("/")) { | ||
| 37 | const commandMatch = /^\/(\S*)$/.exec(linePrefix); | ||
| 38 | if (commandMatch) { | ||
| 39 | const commandQuery = commandMatch[1] ?? ""; | ||
| 40 | if (commandQuery.toLowerCase() === "model") { | ||
| 41 | return { | ||
| 42 | kind: "slash-model", | ||
| 43 | query: "", | ||
| 44 | rangeStart: lineStart, | ||
| 45 | rangeEnd: cursor, | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | return { | ||
| 49 | kind: "slash-command", | ||
| 50 | query: commandQuery, | ||
| 51 | rangeStart: lineStart, | ||
| 52 | rangeEnd: cursor, | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | |||
| 56 | const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); | ||
| 57 | if (modelMatch) { | ||
| 58 | return { | ||
| 59 | kind: "slash-model", | ||
| 60 | query: (modelMatch[1] ?? "").trim(), | ||
| 61 | rangeStart: lineStart, | ||
| 62 | rangeEnd: cursor, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | const wsCheck = isWhitespaceChar ?? isWhitespace; | ||
| 68 | let tokenIdx = cursor - 1; | ||
| 69 | while (tokenIdx >= 0 && !wsCheck(text[tokenIdx] ?? "")) { | ||
| 70 | tokenIdx -= 1; | ||
| 71 | } | ||
| 72 | const tokenStart = tokenIdx + 1; | ||
| 73 | |||
| 74 | const token = text.slice(tokenStart, cursor); | ||
| 75 | if (token.startsWith("$")) { | ||
| 76 | return { | ||
| 77 | kind: "skill", | ||
| 78 | query: token.slice(1), | ||
| 79 | rangeStart: tokenStart, | ||
| 80 | rangeEnd: cursor, | ||
| 81 | }; | ||
| 82 | } | ||
| 83 | if (!token.startsWith("@")) { | ||
| 84 | return null; | ||
| 85 | } | ||
| 86 | |||
| 87 | return { | ||
| 88 | kind: "path", | ||
| 89 | query: token.slice(1), | ||
| 90 | rangeStart: tokenStart, | ||
| 91 | rangeEnd: cursor, | ||
| 92 | }; | ||
| 93 | } | ||
| 94 | |||
| 95 | export function parseStandaloneComposerSlashCommand( | ||
| 96 | text: string, | ||
| 97 | ): Exclude<ComposerSlashCommand, "model"> | null { | ||
| 98 | const match = /^\/(plan|default)\s*$/i.exec(text.trim()); | ||
| 99 | if (!match) { | ||
| 100 | return null; | ||
| 101 | } | ||
| 102 | const command = match[1]?.toLowerCase(); | ||
| 103 | if (command === "plan") return "plan"; | ||
| 104 | return "default"; | ||
| 105 | } | ||
| 106 | |||
| 107 | export function replaceTextRange( | ||
| 108 | text: string, | ||
| 109 | rangeStart: number, | ||
| 110 | rangeEnd: number, | ||
| 111 | replacement: string, | ||
| 112 | ): { text: string; cursor: number } { | ||
| 113 | const safeStart = Math.max(0, Math.min(text.length, rangeStart)); | ||
| 114 | const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd)); | ||
| 115 | const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; | ||
| 116 | return { text: nextText, cursor: safeStart + replacement.length }; | ||
| 117 | } | ||