History

Add composer suggestions and git action overlay

- Suggest files, skills, and commands from composer triggers - Show git action progress, success, and error feedback

Julius Marminge committed 7 months ago

Commit fbcf04a1 parent 99190d713 files · +1000 −188Browse files

Unified · Split

13 files
  1. apps/mobile/src/features/threads/ComposerCommandPopover.tsx +211 −0
  2. apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +121 −0
  3. apps/mobile/src/features/threads/ThreadComposer.tsx +315 −4
  4. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +3 −0
  5. apps/mobile/src/features/threads/ThreadRouteScreen.tsx +21 −3
  6. apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +0 −12
  7. apps/mobile/src/features/threads/threadPresentation.ts +0 −10
  8. apps/mobile/src/features/threads/use-project-actions.ts +43 −67
  9. apps/mobile/src/features/threads/use-selected-thread-git-actions.ts +21 −3
  10. apps/mobile/src/state/use-git-action-state.ts +131 −0
  11. apps/web/src/composer-logic.ts +13 −89
  12. packages/shared/package.json +4 −0
  13. packages/shared/src/composerTrigger.ts +117 −0
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+211−0
@@ -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+});
apps/mobile/src/features/threads/GitActionProgressOverlay.tsx+121−0
@@ -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+}
apps/mobile/src/features/threads/ThreadComposer.tsx+315−4
@@ -8,6 +8,11 @@ import type { · 7 unmodified lines
88 ServerConfig as T3ServerConfig,
99 } from "@t3tools/contracts";
1010 import { CLAUDE_CODE_EFFORT_OPTIONS } from "@t3tools/contracts";
11+import {
12+ detectComposerTrigger,
13+ replaceTextRange,
14+ type ComposerTrigger,
15+} from "@t3tools/shared/composerTrigger";
1116 import { TextInputWrapper } from "expo-paste-input";
1217 import type { ReactNode } from "react";
1318 import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -17,6 +22,8 @@ import { · 3 unmodified lines
1722 TextInput as RNTextInput,
1823 useColorScheme,
1924 View,
25+ type NativeSyntheticEvent,
26+ type TextInputSelectionChangeEventData,
2027 type ViewStyle,
2128 } from "react-native";
2229 import ImageViewing from "react-native-image-viewing";
@@ -30,6 +37,13 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; · 7 unmodified lines
3037 import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
3138 import type { RemoteClientConnectionState } from "../../lib/connection";
3239 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";
3347
3448 /**
3549 * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
@@ -53,6 +67,8 @@ export interface ThreadComposerProps { · 17 unmodified lines
5367 readonly serverConfig: T3ServerConfig | null;
5468 readonly queueCount: number;
5569 readonly activeThreadBusy: boolean;
70+ readonly environmentId: string;
71+ readonly projectCwd: string | null;
5672 readonly onChangeDraftMessage: (value: string) => void;
5773 readonly onPickDraftImages: () => Promise<void>;
5874 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
@@ -167,6 +183,281 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 108 unmodified lines
167183 void props.onNativePasteImages(uris);
168184 });
169185
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+
170461 // ── Model menu ───────────────────────────────────────────
171462 const providerGroups = useMemo(() => {
172463 const options = buildModelOptions(props.serverConfig, currentModelSelection);
@@ -346,8 +637,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 173 unmodified lines
346637 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",
347638 }}
348639 >
349− <View className="w-full">
350− {/* Input surface — the pill/card IS the glass element */}
640+ <View className="w-full" style={{ position: "relative" }}>
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+
351661 <ComposerSurface
352662 isDarkMode={isDarkMode}
353663 style={
@@ -387,6 +697,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 33 unmodified lines
387697 multiline
388698 value={props.draftMessage}
389699 onChangeText={props.onChangeDraftMessage}
700+ onSelectionChange={handleSelectionChange}
390701 placeholder={props.placeholder}
391702 placeholderTextColor={placeholderColor}
392703 editable={props.connectionState === "ready"}
@@ -463,7 +774,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 70 unmodified lines
463774 icon="arrow.up"
464775 variant="primary"
465776 disabled={!canSend}
466− onPress={props.onSendMessage}
777+ onPress={handleSend}
467778 />
468779 )
469780 ) : null}
@@ -508,7 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 38 unmodified lines
508819 label={sendLabel}
509820 variant="primary"
510821 disabled={!canSend}
511− onPress={props.onSendMessage}
822+ onPress={handleSend}
512823 />
513824 </View>
514825 ) : null}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+3−0
@@ -53,6 +53,7 @@ export interface ThreadDetailScreenProps { · 52 unmodified lines
5353 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
5454 readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";
5555 readonly activeThreadBusy: boolean;
56+ readonly environmentId: string;
5657 readonly projectWorkspaceRoot: string | null;
5758 readonly selectedThreadQueueCount: number;
5859 readonly serverConfig: T3ServerConfig | null;
@@ -313,6 +314,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 254 unmodified lines
313314 serverConfig={props.serverConfig}
314315 queueCount={props.selectedThreadQueueCount}
315316 activeThreadBusy={props.activeThreadBusy}
317+ environmentId={props.environmentId}
318+ projectCwd={props.projectWorkspaceRoot}
316319 bottomInset={composerBottomInset}
317320 onChangeDraftMessage={props.onChangeDraftMessage}
318321 onPickDraftImages={props.onPickDraftImages}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+21−3
@@ -1,11 +1,12 @@
11 import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2−import { useCallback, useState } from "react";
2+import { useCallback, useMemo, useState } from "react";
33 import * as Arr from "effect/Array";
44 import * as Option from "effect/Option";
55 import { pipe } from "effect/Function";
66 import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
77 import { useThemeColor } from "../../lib/useThemeColor";
88 import { useGitStatus, gitStatusManager } from "../../state/use-git-status";
9+import { dismissGitActionResult, useGitActionProgress } from "../../state/use-git-action-state";
910
1011 import { EmptyState } from "../../components/EmptyState";
1112 import { LoadingScreen } from "../../components/LoadingScreen";
@@ -20,10 +21,10 @@ import { · 8 unmodified lines
2021 } from "../../state/use-remote-environment-registry";
2122 import { useSelectedThreadDetail } from "../../state/use-thread-detail";
2223 import { useThreadSelection } from "../../state/use-thread-selection";
24+import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
2325 import { ThreadDetailScreen } from "./ThreadDetailScreen";
2426 import { ThreadGitControls } from "./ThreadGitControls";
2527 import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
26−import { screenTitle } from "./threadPresentation";
2728 import { useSelectedThreadCommands } from "./use-selected-thread-commands";
2829 import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions";
2930 import { useSelectedThreadGitState } from "./use-selected-thread-git-state";
@@ -82,6 +83,20 @@ export function ThreadRouteScreen() { · 52 unmodified lines
8283 cwd: selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null,
8384 });
8485
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+
85100 const handleRefreshGitStatus = useCallback(async () => {
86101 if (!selectedThread) return;
87102 await gitStatusManager.refresh({
@@ -152,7 +167,7 @@ export function ThreadRouteScreen() { · 64 unmodified lines
152167 );
153168
154169 const headerSubtitle = [
155− screenTitle(serverConfig, null),
170+ selectedThreadProject?.title ?? null,
156171 selectedEnvironmentConnection?.environmentLabel ?? null,
157172 ]
158173 .filter(Boolean)
@@ -212,6 +227,8 @@ export function ThreadRouteScreen() { · 53 unmodified lines
212227 onRunAction={gitActions.onRunSelectedThreadGitAction}
213228 />
214229
230+ <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />
231+
215232 <View className="flex-1 bg-screen">
216233 <ThreadDetailScreen
217234 selectedThread={selectedThreadDetail}
@@ -231,6 +248,7 @@ export function ThreadRouteScreen() { · 13 unmodified lines
231248 draftAttachments={composer.draftAttachments}
232249 connectionStateLabel={routeConnectionState}
233250 activeThreadBusy={composer.activeThreadBusy}
251+ environmentId={selectedThread.environmentId}
234252 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
235253 selectedThreadQueueCount={composer.selectedThreadQueueCount}
236254 onOpenDrawer={handleOpenDrawer}
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+0−12
@@ -12,7 +12,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; · 11 unmodified lines
1212 import { useThemeColor } from "../../../lib/useThemeColor";
1313
1414 import { AppText as Text } from "../../../components/AppText";
15−import { StatusPill } from "../../../components/StatusPill";
1615 import { useGitStatus } from "../../../state/use-git-status";
1716 import { useThreadSelection } from "../../../state/use-thread-selection";
1817 import { useSelectedThreadGitActions } from "../use-selected-thread-git-actions";
@@ -231,17 +230,6 @@ export function GitOverviewSheet() { · 212 unmodified lines
231230 </View>
232231
233232 {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}
245233 </ScrollView>
246234 </View>
247235 );
apps/mobile/src/features/threads/threadPresentation.ts+0−10
@@ -1,4 +1,3 @@
1−import type { ServerConfig as T3ServerConfig } from "@t3tools/contracts";
21 import type { StatusTone } from "../../components/StatusPill";
32 import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
43
@@ -52,12 +51,3 @@ export function messageImageUrl(httpBaseUrl: string | null, attachmentId: string · 47 unmodified lines
5251 const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl);
5352 return url.toString();
5453 }
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−}
apps/mobile/src/features/threads/use-project-actions.ts+43−67
@@ -96,77 +96,53 @@ export function useProjectActions() { · 95 unmodified lines
9696 const initialMessageText = input.initialMessageText.trim();
9797 const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText);
9898
99− if (input.envMode === "worktree") {
100− if (!input.branch || initialMessageText.length === 0) {
101− return null;
102− }
99+ if (initialMessageText.length === 0) {
100+ return null;
101+ }
102+ if (input.envMode === "worktree" && !input.branch) {
103+ return null;
104+ }
103105
104− await client.orchestration.dispatchCommand({
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− });
106+ const isWorktree = input.envMode === "worktree";
152107
153− if (initialMessageText.length > 0 || input.initialAttachments.length > 0) {
154− await client.orchestration.dispatchCommand({
155− type: "thread.turn.start",
156− commandId: CommandId.make(uuidv4()),
157− threadId,
158− message: {
159− messageId: MessageId.make(uuidv4()),
160− role: "user",
161− text: initialMessageText,
162− attachments: input.initialAttachments,
163− },
108+ await client.orchestration.dispatchCommand({
109+ type: "thread.turn.start",
110+ commandId: CommandId.make(uuidv4()),
111+ threadId,
112+ message: {
113+ messageId: MessageId.make(uuidv4()),
114+ role: "user",
115+ text: initialMessageText,
116+ attachments: input.initialAttachments,
117+ },
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,
164127 runtimeMode: input.runtimeMode,
165128 interactionMode: input.interactionMode,
166− createdAt: new Date().toISOString(),
167− });
168− }
169− }
129+ branch: input.branch,
130+ worktreePath: isWorktree ? null : input.worktreePath,
131+ createdAt,
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+ });
170146
171147 await refreshRemoteData([input.project.environmentId]);
172148 return {
apps/mobile/src/features/threads/use-selected-thread-git-actions.ts+21−3
@@ -21,7 +21,7 @@ import { · 20 unmodified lines
2121 getEnvironmentClient,
2222 setPendingConnectionError,
2323 } 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";
2525 import { gitBranchManager } from "../../state/use-git-branches";
2626 import { gitStatusManager } from "../../state/use-git-status";
2727 import { useThreadSelection } from "../../state/use-thread-selection";
@@ -122,7 +122,9 @@ export function useSelectedThreadGitActions() { · 94 unmodified lines
122122 cwd,
123123 });
124124 } 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 });
126128 return null;
127129 }
128130 },
@@ -264,8 +266,17 @@ export function useSelectedThreadGitActions() { · 135 unmodified lines
264266
265267 const onPullSelectedThreadBranch = useCallback(async () => {
266268 await runSelectedThreadGitMutation(async ({ thread, cwd }) => {
267− await gitActionManager.pull({ environmentId: thread.environmentId, cwd });
269+ const result = await gitActionManager.pull({ environmentId: thread.environmentId, cwd });
268270 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+ }
269280 });
270281 }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]);
271282
@@ -292,6 +303,13 @@ export function useSelectedThreadGitActions() { · 20 unmodified lines
292303 return null;
293304 }
294305
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+
295313 if (result.branch.status === "created" && result.branch.name) {
296314 await syncSelectedThreadBranchState({
297315 thread,
apps/mobile/src/state/use-git-action-state.ts+131−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 getGitActionTargetKey,
99 gitActionStateAtom,
1010 } from "@t3tools/client-runtime";
11+import { useCallback, useEffect, useRef, useState } from "react";
1112
1213 import { uuidv4 } from "../lib/uuid";
1314 import { appAtomRegistry } from "./atom-registry";
@@ -29,3 +30,133 @@ export function useGitActionState(target: GitActionTarget): GitActionState { · 15 unmodified lines
2930 );
3031 return targetKey === null ? EMPTY_GIT_ACTION_STATE : state;
3132 }
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+}
apps/web/src/composer-logic.ts+13−89
@@ -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+
113 import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
214 import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
315
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−
1416 const isInlineTokenSegment = (
1517 segment:
1618 | { type: "text"; text: string }
@@ -216,83 +218,5 @@ export function isCollapsedCursorAdjacentToInlineToken( · 199 unmodified lines
216218 export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken;
217219
218220 export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
219− const cursor = clampCursor(text, cursorInput);
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 };
221+ return detectComposerTriggerBase(text, cursorInput, isWhitespace);
298222 }
packages/shared/package.json+4−0
@@ -67,6 +67,10 @@ · 66 unmodified lines
6767 "./qrCode": {
6868 "types": "./src/qrCode.ts",
6969 "import": "./src/qrCode.ts"
70+ },
71+ "./composerTrigger": {
72+ "types": "./src/composerTrigger.ts",
73+ "import": "./src/composerTrigger.ts"
7074 }
7175 },
7276 "scripts": {
packages/shared/src/composerTrigger.ts+117−0
@@ -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+}