SlashCommandTrigger.tsx 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. import { Box, Text } from "ink"
  2. import fuzzysort from "fuzzysort"
  3. import { GlobalCommandAction } from "@/lib/utils/commands.js"
  4. import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
  5. export interface SlashCommandResult extends AutocompleteItem {
  6. name: string
  7. description?: string
  8. argumentHint?: string
  9. source: "global" | "project" | "built-in"
  10. /** Action to trigger for CLI global commands (e.g., clearTask for /new) */
  11. action?: GlobalCommandAction
  12. }
  13. export interface SlashCommandTriggerConfig {
  14. getCommands: () => SlashCommandResult[]
  15. maxResults?: number
  16. }
  17. /**
  18. * Create a slash command trigger for / commands.
  19. *
  20. * This trigger activates when the user types / at the start of a line,
  21. * and allows selecting commands with local fuzzy filtering.
  22. *
  23. * @param config - Configuration for the trigger
  24. * @returns AutocompleteTrigger for slash commands
  25. */
  26. export function createSlashCommandTrigger(config: SlashCommandTriggerConfig): AutocompleteTrigger<SlashCommandResult> {
  27. const { getCommands, maxResults = 20 } = config
  28. return {
  29. id: "slash-command",
  30. triggerChar: "/",
  31. position: "line-start",
  32. detectTrigger: (lineText: string): TriggerDetectionResult | null => {
  33. // Check if line starts with / (after optional whitespace)
  34. const trimmed = lineText.trimStart()
  35. if (!trimmed.startsWith("/")) {
  36. return null
  37. }
  38. // Extract query after /
  39. const query = trimmed.substring(1)
  40. // Close picker if query contains space (command complete)
  41. if (query.includes(" ")) {
  42. return null
  43. }
  44. // Calculate trigger index (position of / in original line)
  45. const triggerIndex = lineText.length - trimmed.length
  46. return { query, triggerIndex }
  47. },
  48. search: (query: string): SlashCommandResult[] => {
  49. const allCommands = getCommands()
  50. if (query.length === 0) {
  51. // Show all commands when just "/" is typed
  52. return allCommands.slice(0, maxResults)
  53. }
  54. // Fuzzy search by command name
  55. const results = fuzzysort.go(query, allCommands, {
  56. key: "name",
  57. limit: maxResults,
  58. threshold: -10000, // Be lenient with matching
  59. })
  60. return results.map((result) => result.obj)
  61. },
  62. renderItem: (item: SlashCommandResult, isSelected: boolean) => {
  63. // Source indicator icons:
  64. // ⚙️ for action commands (CLI global), ⚡ built-in, 📁 project, 🌐 global (content)
  65. const sourceIcon = item.action
  66. ? "⚙️"
  67. : item.source === "built-in"
  68. ? "⚡"
  69. : item.source === "project"
  70. ? "📁"
  71. : "🌐"
  72. return (
  73. <Box paddingLeft={2}>
  74. <Text color={isSelected ? "cyan" : undefined}>
  75. {sourceIcon} /{item.name}
  76. {item.description && <Text dimColor> - {item.description}</Text>}
  77. </Text>
  78. </Box>
  79. )
  80. },
  81. getReplacementText: (item: SlashCommandResult, lineText: string, triggerIndex: number): string => {
  82. const beforeSlash = lineText.substring(0, triggerIndex)
  83. return `${beforeSlash}/${item.name} `
  84. },
  85. emptyMessage: "No matching commands found",
  86. debounceMs: 150,
  87. }
  88. }
  89. /**
  90. * Convert external command data to SlashCommandResult.
  91. * Use this to adapt commands from the store to the trigger's expected type.
  92. */
  93. export function toSlashCommandResult(command: {
  94. name: string
  95. description?: string
  96. argumentHint?: string
  97. source: "global" | "project" | "built-in"
  98. action?: string
  99. }): SlashCommandResult {
  100. return {
  101. key: command.name,
  102. name: command.name,
  103. description: command.description,
  104. argumentHint: command.argumentHint,
  105. source: command.source,
  106. action: command.action as GlobalCommandAction | undefined,
  107. }
  108. }