Icon.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import { Box, Text } from "ink"
  2. import type { TextProps } from "ink"
  3. /**
  4. * Icon names supported by the Icon component.
  5. * Each icon has a Nerd Font glyph and an ASCII fallback.
  6. */
  7. export type IconName =
  8. | "folder"
  9. | "file"
  10. | "file-edit"
  11. | "check"
  12. | "cross"
  13. | "arrow-right"
  14. | "bullet"
  15. | "spinner"
  16. // Tool-related icons
  17. | "search"
  18. | "terminal"
  19. | "browser"
  20. | "switch"
  21. | "question"
  22. | "gear"
  23. | "diff"
  24. // TODO-related icons
  25. | "checkbox"
  26. | "checkbox-checked"
  27. | "checkbox-progress"
  28. | "todo-list"
  29. /**
  30. * Icon definitions with Nerd Font glyph and ASCII fallback.
  31. * Nerd Font glyphs are surrogate pairs (2 JS chars, 1 visual char).
  32. */
  33. const ICONS: Record<IconName, { nerd: string; fallback: string }> = {
  34. folder: { nerd: "\uf413", fallback: "▼" },
  35. file: { nerd: "\uf4a5", fallback: "●" },
  36. "file-edit": { nerd: "\uf4d2", fallback: "✎" },
  37. check: { nerd: "\uf42e", fallback: "✓" },
  38. cross: { nerd: "\uf517", fallback: "✗" },
  39. "arrow-right": { nerd: "\uf432", fallback: "→" },
  40. bullet: { nerd: "\uf444", fallback: "•" },
  41. spinner: { nerd: "\uf4e3", fallback: "*" },
  42. // Tool-related icons
  43. search: { nerd: "\uf422", fallback: "🔍" },
  44. terminal: { nerd: "\uf489", fallback: "$" },
  45. browser: { nerd: "\uf488", fallback: "🌐" },
  46. switch: { nerd: "\uf443", fallback: "⇄" },
  47. question: { nerd: "\uf420", fallback: "?" },
  48. gear: { nerd: "\uf423", fallback: "⚙" },
  49. diff: { nerd: "\uf4d2", fallback: "±" },
  50. // TODO-related icons
  51. checkbox: { nerd: "\uf4aa", fallback: "○" }, // Empty checkbox
  52. "checkbox-checked": { nerd: "\uf4a4", fallback: "✓" }, // Checked checkbox
  53. "checkbox-progress": { nerd: "\uf4aa", fallback: "→" }, // In progress (dot circle)
  54. "todo-list": { nerd: "\uf45e", fallback: "☑" }, // List icon for TODO header
  55. }
  56. /**
  57. * Check if a string contains surrogate pairs (characters outside BMP).
  58. * Surrogate pairs have .length of 2 but render as 1 visual character.
  59. */
  60. function containsSurrogatePair(str: string): boolean {
  61. // Surrogate pairs are in the range U+D800 to U+DFFF
  62. return /[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(str)
  63. }
  64. /**
  65. * Detect if Nerd Font icons are likely supported.
  66. *
  67. * Users can override this with the ROOCODE_NERD_FONT environment variable:
  68. * - ROOCODE_NERD_FONT=0 to force ASCII fallbacks (if icons don't render correctly)
  69. * - ROOCODE_NERD_FONT=1 to force Nerd Font icons
  70. *
  71. * Defaults to true because:
  72. * 1. Nerd Fonts are common in developer terminal setups
  73. * 2. Modern terminals handle missing glyphs gracefully
  74. * 3. Users can easily disable if icons don't render correctly
  75. */
  76. function detectNerdFontSupport(): boolean {
  77. // Allow explicit override via environment variable
  78. const envOverride = process.env.ROOCODE_NERD_FONT
  79. if (envOverride === "0" || envOverride === "false") return false
  80. if (envOverride === "1" || envOverride === "true") return true
  81. // Default to Nerd Font icons - they're common in developer setups
  82. // and users can set ROOCODE_NERD_FONT=0 if needed
  83. return true
  84. }
  85. // Cache the detection result
  86. let nerdFontSupported: boolean | null = null
  87. /**
  88. * Get whether Nerd Font icons are supported (cached).
  89. */
  90. export function isNerdFontSupported(): boolean {
  91. if (nerdFontSupported === null) {
  92. nerdFontSupported = detectNerdFontSupport()
  93. }
  94. return nerdFontSupported
  95. }
  96. /**
  97. * Reset the Nerd Font detection cache (useful for testing).
  98. */
  99. export function resetNerdFontCache(): void {
  100. nerdFontSupported = null
  101. }
  102. export interface IconProps extends Omit<TextProps, "children"> {
  103. /** The icon to display */
  104. name: IconName
  105. /** Override the automatic Nerd Font detection */
  106. useNerdFont?: boolean
  107. /** Custom width for the icon container (default: 2) */
  108. width?: number
  109. }
  110. /**
  111. * Icon component that renders Nerd Font icons with ASCII fallbacks.
  112. *
  113. * Renders icons in a fixed-width Box to handle surrogate pair width
  114. * calculation issues in Ink. Surrogate pairs (like Nerd Font glyphs)
  115. * have .length of 2 in JavaScript but render as 1 visual character.
  116. *
  117. * @example
  118. * ```tsx
  119. * <Icon name="folder" color="blue" />
  120. * <Icon name="file" />
  121. * <Icon name="check" color="green" useNerdFont={false} />
  122. * ```
  123. */
  124. export function Icon({ name, useNerdFont, width = 2, color, ...textProps }: IconProps) {
  125. const iconDef = ICONS[name]
  126. if (!iconDef) {
  127. return null
  128. }
  129. const shouldUseNerdFont = useNerdFont ?? isNerdFontSupported()
  130. const icon = shouldUseNerdFont ? iconDef.nerd : iconDef.fallback
  131. // Use fixed-width Box to isolate surrogate pair width calculation
  132. // from surrounding text. This prevents the off-by-one truncation bug.
  133. const needsWidthFix = containsSurrogatePair(icon)
  134. if (needsWidthFix) {
  135. return (
  136. <Box width={width}>
  137. <Text color={color} {...textProps}>
  138. {icon}
  139. </Text>
  140. </Box>
  141. )
  142. }
  143. // For BMP characters (no surrogate pairs), render directly
  144. return (
  145. <Text color={color} {...textProps}>
  146. {icon}
  147. </Text>
  148. )
  149. }
  150. /**
  151. * Get the raw icon character (useful for string concatenation).
  152. */
  153. export function getIconChar(name: IconName, useNerdFont?: boolean): string {
  154. const iconDef = ICONS[name]
  155. if (!iconDef) return ""
  156. const shouldUseNerdFont = useNerdFont ?? isNerdFontSupported()
  157. return shouldUseNerdFont ? iconDef.nerd : iconDef.fallback
  158. }