@sooneocean/claude-hud 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/LICENSE +21 -0
  4. package/README.md +379 -0
  5. package/commands/configure.md +361 -0
  6. package/commands/export.md +43 -0
  7. package/commands/health.md +61 -0
  8. package/commands/setup.md +287 -0
  9. package/commands/theme.md +31 -0
  10. package/dist/alert.d.ts +31 -0
  11. package/dist/alert.d.ts.map +1 -0
  12. package/dist/alert.js +53 -0
  13. package/dist/alert.js.map +1 -0
  14. package/dist/burn-rate.d.ts +4 -0
  15. package/dist/burn-rate.d.ts.map +1 -0
  16. package/dist/burn-rate.js +36 -0
  17. package/dist/burn-rate.js.map +1 -0
  18. package/dist/cache.d.ts +6 -0
  19. package/dist/cache.d.ts.map +1 -0
  20. package/dist/cache.js +47 -0
  21. package/dist/cache.js.map +1 -0
  22. package/dist/claude-config-dir.d.ts +4 -0
  23. package/dist/claude-config-dir.d.ts.map +1 -0
  24. package/dist/claude-config-dir.js +24 -0
  25. package/dist/claude-config-dir.js.map +1 -0
  26. package/dist/config-io.d.ts +6 -0
  27. package/dist/config-io.d.ts.map +1 -0
  28. package/dist/config-io.js +27 -0
  29. package/dist/config-io.js.map +1 -0
  30. package/dist/config-reader.d.ts +8 -0
  31. package/dist/config-reader.d.ts.map +1 -0
  32. package/dist/config-reader.js +204 -0
  33. package/dist/config-reader.js.map +1 -0
  34. package/dist/config.d.ts +94 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +358 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/constants.d.ts +11 -0
  39. package/dist/constants.d.ts.map +1 -0
  40. package/dist/constants.js +11 -0
  41. package/dist/constants.js.map +1 -0
  42. package/dist/cost-tracker.d.ts +9 -0
  43. package/dist/cost-tracker.d.ts.map +1 -0
  44. package/dist/cost-tracker.js +46 -0
  45. package/dist/cost-tracker.js.map +1 -0
  46. package/dist/debug.d.ts +6 -0
  47. package/dist/debug.d.ts.map +1 -0
  48. package/dist/debug.js +15 -0
  49. package/dist/debug.js.map +1 -0
  50. package/dist/extra-cmd.d.ts +20 -0
  51. package/dist/extra-cmd.d.ts.map +1 -0
  52. package/dist/extra-cmd.js +112 -0
  53. package/dist/extra-cmd.js.map +1 -0
  54. package/dist/git.d.ts +16 -0
  55. package/dist/git.d.ts.map +1 -0
  56. package/dist/git.js +94 -0
  57. package/dist/git.js.map +1 -0
  58. package/dist/health-check.d.ts +12 -0
  59. package/dist/health-check.d.ts.map +1 -0
  60. package/dist/health-check.js +37 -0
  61. package/dist/health-check.js.map +1 -0
  62. package/dist/index.d.ts +24 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +198 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/providers/agent-teams-provider.d.ts +10 -0
  67. package/dist/providers/agent-teams-provider.d.ts.map +1 -0
  68. package/dist/providers/agent-teams-provider.js +57 -0
  69. package/dist/providers/agent-teams-provider.js.map +1 -0
  70. package/dist/providers/agw-provider.d.ts +10 -0
  71. package/dist/providers/agw-provider.d.ts.map +1 -0
  72. package/dist/providers/agw-provider.js +49 -0
  73. package/dist/providers/agw-provider.js.map +1 -0
  74. package/dist/providers/index.d.ts +14 -0
  75. package/dist/providers/index.d.ts.map +1 -0
  76. package/dist/providers/index.js +25 -0
  77. package/dist/providers/index.js.map +1 -0
  78. package/dist/render/agents-line.d.ts +3 -0
  79. package/dist/render/agents-line.d.ts.map +1 -0
  80. package/dist/render/agents-line.js +40 -0
  81. package/dist/render/agents-line.js.map +1 -0
  82. package/dist/render/alert-line.d.ts +3 -0
  83. package/dist/render/alert-line.d.ts.map +1 -0
  84. package/dist/render/alert-line.js +11 -0
  85. package/dist/render/alert-line.js.map +1 -0
  86. package/dist/render/colors.d.ts +39 -0
  87. package/dist/render/colors.d.ts.map +1 -0
  88. package/dist/render/colors.js +109 -0
  89. package/dist/render/colors.js.map +1 -0
  90. package/dist/render/framework-line.d.ts +3 -0
  91. package/dist/render/framework-line.d.ts.map +1 -0
  92. package/dist/render/framework-line.js +32 -0
  93. package/dist/render/framework-line.js.map +1 -0
  94. package/dist/render/index.d.ts +3 -0
  95. package/dist/render/index.d.ts.map +1 -0
  96. package/dist/render/index.js +435 -0
  97. package/dist/render/index.js.map +1 -0
  98. package/dist/render/lines/environment.d.ts +3 -0
  99. package/dist/render/lines/environment.d.ts.map +1 -0
  100. package/dist/render/lines/environment.js +30 -0
  101. package/dist/render/lines/environment.js.map +1 -0
  102. package/dist/render/lines/identity.d.ts +3 -0
  103. package/dist/render/lines/identity.d.ts.map +1 -0
  104. package/dist/render/lines/identity.js +93 -0
  105. package/dist/render/lines/identity.js.map +1 -0
  106. package/dist/render/lines/index.d.ts +5 -0
  107. package/dist/render/lines/index.d.ts.map +1 -0
  108. package/dist/render/lines/index.js +5 -0
  109. package/dist/render/lines/index.js.map +1 -0
  110. package/dist/render/lines/project.d.ts +3 -0
  111. package/dist/render/lines/project.d.ts.map +1 -0
  112. package/dist/render/lines/project.js +100 -0
  113. package/dist/render/lines/project.js.map +1 -0
  114. package/dist/render/lines/usage.d.ts +3 -0
  115. package/dist/render/lines/usage.d.ts.map +1 -0
  116. package/dist/render/lines/usage.js +65 -0
  117. package/dist/render/lines/usage.js.map +1 -0
  118. package/dist/render/session-line.d.ts +7 -0
  119. package/dist/render/session-line.d.ts.map +1 -0
  120. package/dist/render/session-line.js +227 -0
  121. package/dist/render/session-line.js.map +1 -0
  122. package/dist/render/todos-line.d.ts +3 -0
  123. package/dist/render/todos-line.d.ts.map +1 -0
  124. package/dist/render/todos-line.js +29 -0
  125. package/dist/render/todos-line.js.map +1 -0
  126. package/dist/render/tools-line.d.ts +3 -0
  127. package/dist/render/tools-line.d.ts.map +1 -0
  128. package/dist/render/tools-line.js +45 -0
  129. package/dist/render/tools-line.js.map +1 -0
  130. package/dist/session-history.d.ts +15 -0
  131. package/dist/session-history.d.ts.map +1 -0
  132. package/dist/session-history.js +46 -0
  133. package/dist/session-history.js.map +1 -0
  134. package/dist/session-stats.d.ts +11 -0
  135. package/dist/session-stats.d.ts.map +1 -0
  136. package/dist/session-stats.js +48 -0
  137. package/dist/session-stats.js.map +1 -0
  138. package/dist/speed-tracker.d.ts +7 -0
  139. package/dist/speed-tracker.d.ts.map +1 -0
  140. package/dist/speed-tracker.js +34 -0
  141. package/dist/speed-tracker.js.map +1 -0
  142. package/dist/stdin.d.ts +9 -0
  143. package/dist/stdin.d.ts.map +1 -0
  144. package/dist/stdin.js +142 -0
  145. package/dist/stdin.js.map +1 -0
  146. package/dist/themes.d.ts +10 -0
  147. package/dist/themes.d.ts.map +1 -0
  148. package/dist/themes.js +81 -0
  149. package/dist/themes.js.map +1 -0
  150. package/dist/transcript.d.ts +3 -0
  151. package/dist/transcript.d.ts.map +1 -0
  152. package/dist/transcript.js +221 -0
  153. package/dist/transcript.js.map +1 -0
  154. package/dist/types.d.ts +124 -0
  155. package/dist/types.d.ts.map +1 -0
  156. package/dist/types.js +5 -0
  157. package/dist/types.js.map +1 -0
  158. package/dist/usage-api.d.ts +62 -0
  159. package/dist/usage-api.d.ts.map +1 -0
  160. package/dist/usage-api.js +908 -0
  161. package/dist/usage-api.js.map +1 -0
  162. package/dist/utils/format.d.ts +9 -0
  163. package/dist/utils/format.d.ts.map +1 -0
  164. package/dist/utils/format.js +75 -0
  165. package/dist/utils/format.js.map +1 -0
  166. package/dist/utils/terminal.d.ts +5 -0
  167. package/dist/utils/terminal.d.ts.map +1 -0
  168. package/dist/utils/terminal.js +42 -0
  169. package/dist/utils/terminal.js.map +1 -0
  170. package/package.json +36 -0
  171. package/src/alert.ts +75 -0
  172. package/src/burn-rate.ts +45 -0
  173. package/src/cache.ts +57 -0
  174. package/src/claude-config-dir.ts +27 -0
  175. package/src/config-io.ts +26 -0
  176. package/src/config-reader.ts +236 -0
  177. package/src/config.ts +496 -0
  178. package/src/constants.ts +10 -0
  179. package/src/cost-tracker.ts +53 -0
  180. package/src/debug.ts +16 -0
  181. package/src/extra-cmd.ts +125 -0
  182. package/src/git.ts +126 -0
  183. package/src/health-check.ts +50 -0
  184. package/src/index.ts +234 -0
  185. package/src/providers/agent-teams-provider.ts +56 -0
  186. package/src/providers/agw-provider.ts +47 -0
  187. package/src/providers/index.ts +27 -0
  188. package/src/render/agents-line.ts +51 -0
  189. package/src/render/alert-line.ts +11 -0
  190. package/src/render/colors.ts +145 -0
  191. package/src/render/framework-line.ts +34 -0
  192. package/src/render/index.ts +512 -0
  193. package/src/render/lines/environment.ts +41 -0
  194. package/src/render/lines/identity.ts +109 -0
  195. package/src/render/lines/index.ts +4 -0
  196. package/src/render/lines/project.ts +113 -0
  197. package/src/render/lines/usage.ts +79 -0
  198. package/src/render/session-line.ts +253 -0
  199. package/src/render/todos-line.ts +35 -0
  200. package/src/render/tools-line.ts +58 -0
  201. package/src/session-history.ts +62 -0
  202. package/src/session-stats.ts +65 -0
  203. package/src/speed-tracker.ts +51 -0
  204. package/src/stdin.ts +169 -0
  205. package/src/themes.ts +90 -0
  206. package/src/transcript.ts +268 -0
  207. package/src/types.ts +146 -0
  208. package/src/usage-api.ts +1090 -0
  209. package/src/utils/format.ts +79 -0
  210. package/src/utils/terminal.ts +46 -0
@@ -0,0 +1,145 @@
1
+ import type { HudColorName, HudColorValue, HudColorOverrides } from '../config.js';
2
+
3
+ export const RESET = '\x1b[0m';
4
+
5
+ export const BAR_CHARS = {
6
+ classic: { filled: '█', empty: '░' },
7
+ modern: { filled: '▰', empty: '▱' },
8
+ } as const;
9
+
10
+ const DIM = '\x1b[2m';
11
+ const RED = '\x1b[31m';
12
+ const GREEN = '\x1b[32m';
13
+ const YELLOW = '\x1b[33m';
14
+ const MAGENTA = '\x1b[35m';
15
+ const CYAN = '\x1b[36m';
16
+ const BRIGHT_BLUE = '\x1b[94m';
17
+ const BRIGHT_MAGENTA = '\x1b[95m';
18
+ const CLAUDE_ORANGE = '\x1b[38;5;208m';
19
+
20
+ const ANSI_BY_NAME: Record<HudColorName, string> = {
21
+ red: RED,
22
+ green: GREEN,
23
+ yellow: YELLOW,
24
+ magenta: MAGENTA,
25
+ cyan: CYAN,
26
+ brightBlue: BRIGHT_BLUE,
27
+ brightMagenta: BRIGHT_MAGENTA,
28
+ };
29
+
30
+ /** Convert a hex color string (#rrggbb) to a truecolor ANSI escape sequence. */
31
+ function hexToAnsi(hex: string): string {
32
+ const r = parseInt(hex.slice(1, 3), 16);
33
+ const g = parseInt(hex.slice(3, 5), 16);
34
+ const b = parseInt(hex.slice(5, 7), 16);
35
+ return `\x1b[38;2;${r};${g};${b}m`;
36
+ }
37
+
38
+ /**
39
+ * Resolve a color value to an ANSI escape sequence.
40
+ * Accepts named presets, 256-color indices (0-255), or hex strings (#rrggbb).
41
+ */
42
+ function resolveAnsi(value: HudColorValue | undefined, fallback: string): string {
43
+ if (value === undefined || value === null) {
44
+ return fallback;
45
+ }
46
+ if (typeof value === 'number') {
47
+ return `\x1b[38;5;${value}m`;
48
+ }
49
+ if (typeof value === 'string' && value.startsWith('#') && value.length === 7) {
50
+ return hexToAnsi(value);
51
+ }
52
+ return ANSI_BY_NAME[value as HudColorName] ?? fallback;
53
+ }
54
+
55
+ export function colorize(text: string, color: string): string {
56
+ return `${color}${text}${RESET}`;
57
+ }
58
+
59
+ export function green(text: string): string {
60
+ return colorize(text, GREEN);
61
+ }
62
+
63
+ export function yellow(text: string): string {
64
+ return colorize(text, YELLOW);
65
+ }
66
+
67
+ export function red(text: string): string {
68
+ return colorize(text, RED);
69
+ }
70
+
71
+ export function cyan(text: string): string {
72
+ return colorize(text, CYAN);
73
+ }
74
+
75
+ export function magenta(text: string): string {
76
+ return colorize(text, MAGENTA);
77
+ }
78
+
79
+ export function dim(text: string): string {
80
+ return colorize(text, DIM);
81
+ }
82
+
83
+ export function claudeOrange(text: string): string {
84
+ return colorize(text, CLAUDE_ORANGE);
85
+ }
86
+
87
+ export function warning(text: string, colors?: Partial<HudColorOverrides>): string {
88
+ return colorize(text, resolveAnsi(colors?.warning, YELLOW));
89
+ }
90
+
91
+ export function critical(text: string, colors?: Partial<HudColorOverrides>): string {
92
+ return colorize(text, resolveAnsi(colors?.critical, RED));
93
+ }
94
+
95
+ export function getContextColor(
96
+ percent: number,
97
+ colors?: Partial<HudColorOverrides>,
98
+ thresholds?: { warningThreshold: number; criticalThreshold: number },
99
+ ): string {
100
+ if (percent >= (thresholds?.criticalThreshold ?? 85)) return resolveAnsi(colors?.critical, RED);
101
+ if (percent >= (thresholds?.warningThreshold ?? 70)) return resolveAnsi(colors?.warning, YELLOW);
102
+ return resolveAnsi(colors?.context, GREEN);
103
+ }
104
+
105
+ export function getQuotaColor(
106
+ percent: number,
107
+ colors?: Partial<HudColorOverrides>,
108
+ thresholds?: { warningThreshold: number; criticalThreshold: number },
109
+ ): string {
110
+ if (percent >= (thresholds?.criticalThreshold ?? 90)) return resolveAnsi(colors?.critical, RED);
111
+ if (percent >= (thresholds?.warningThreshold ?? 75)) return resolveAnsi(colors?.usageWarning, BRIGHT_MAGENTA);
112
+ return resolveAnsi(colors?.usage, BRIGHT_BLUE);
113
+ }
114
+
115
+ export function quotaBar(
116
+ percent: number,
117
+ width: number = 10,
118
+ colors?: Partial<HudColorOverrides>,
119
+ barStyle: 'classic' | 'modern' = 'classic',
120
+ thresholds?: { warningThreshold: number; criticalThreshold: number },
121
+ ): string {
122
+ const safeWidth = Number.isFinite(width) ? Math.max(0, Math.round(width)) : 0;
123
+ const safePercent = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0;
124
+ const filled = Math.round((safePercent / 100) * safeWidth);
125
+ const empty = safeWidth - filled;
126
+ const color = getQuotaColor(safePercent, colors, thresholds);
127
+ const chars = BAR_CHARS[barStyle];
128
+ return `${color}${chars.filled.repeat(filled)}${DIM}${chars.empty.repeat(empty)}${RESET}`;
129
+ }
130
+
131
+ export function coloredBar(
132
+ percent: number,
133
+ width: number = 10,
134
+ colors?: Partial<HudColorOverrides>,
135
+ barStyle: 'classic' | 'modern' = 'classic',
136
+ thresholds?: { warningThreshold: number; criticalThreshold: number },
137
+ ): string {
138
+ const safeWidth = Number.isFinite(width) ? Math.max(0, Math.round(width)) : 0;
139
+ const safePercent = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0;
140
+ const filled = Math.round((safePercent / 100) * safeWidth);
141
+ const empty = safeWidth - filled;
142
+ const color = getContextColor(safePercent, colors, thresholds);
143
+ const chars = BAR_CHARS[barStyle];
144
+ return `${color}${chars.filled.repeat(filled)}${DIM}${chars.empty.repeat(empty)}${RESET}`;
145
+ }
@@ -0,0 +1,34 @@
1
+ import type { FrameworkStatus } from '../types.js';
2
+ import { colorize, claudeOrange, green, dim } from './colors.js';
3
+
4
+ const STATUS_ICONS: Record<string, string> = {
5
+ running: claudeOrange('⟳'),
6
+ completed: green('✓'),
7
+ error: colorize('✘', '\x1b[31m'),
8
+ waiting: dim('⏳'),
9
+ };
10
+
11
+ export function renderFrameworkLine(statuses: FrameworkStatus[]): string | null {
12
+ if (statuses.length === 0) return null;
13
+ const parts: string[] = [];
14
+
15
+ for (const status of statuses) {
16
+ if (status.provider === 'AGW') {
17
+ for (const entry of status.entries) {
18
+ const icon = STATUS_ICONS[entry.status] || dim('?');
19
+ const progress = entry.progress ? dim(` (${entry.progress})`) : '';
20
+ parts.push(`${icon} AGW: ${entry.label}${progress}`);
21
+ }
22
+ } else if (status.provider === 'Teams') {
23
+ const agentParts = status.entries.map(e => {
24
+ const icon = e.status === 'completed' ? green('✓') :
25
+ e.status === 'running' ? claudeOrange('◐') :
26
+ e.status === 'error' ? colorize('✘', '\x1b[31m') : dim('⏳');
27
+ return `${e.label}${icon}`;
28
+ }).join(' ');
29
+ parts.push(`${green('⬡')} Teams: ${agentParts}`);
30
+ }
31
+ }
32
+
33
+ return parts.length > 0 ? parts.join(` ${dim('│')} `) : null;
34
+ }
@@ -0,0 +1,512 @@
1
+ import type { HudElement } from '../config.js';
2
+ import { DEFAULT_ELEMENT_ORDER } from '../config.js';
3
+ import type { RenderContext } from '../types.js';
4
+ import { renderSessionLine } from './session-line.js';
5
+ import { renderToolsLine } from './tools-line.js';
6
+ import { renderAgentsLine } from './agents-line.js';
7
+ import { renderTodosLine } from './todos-line.js';
8
+ import { renderFrameworkLine } from './framework-line.js';
9
+ import { renderAlertLine } from './alert-line.js';
10
+ import {
11
+ renderIdentityLine,
12
+ renderProjectLine,
13
+ renderEnvironmentLine,
14
+ renderUsageLine,
15
+ } from './lines/index.js';
16
+ import { dim, RESET } from './colors.js';
17
+ import { getTerminalWidth } from '../utils/terminal.js';
18
+
19
+ // eslint-disable-next-line no-control-regex
20
+ const ANSI_ESCAPE_PATTERN = /^\x1b\[[0-9;]*m/;
21
+ const ANSI_ESCAPE_GLOBAL = /\x1b\[[0-9;]*m/g;
22
+ const GRAPHEME_SEGMENTER = typeof Intl.Segmenter === 'function'
23
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
24
+ : null;
25
+
26
+ function stripAnsi(str: string): string {
27
+ return str.replace(ANSI_ESCAPE_GLOBAL, '');
28
+ }
29
+
30
+ function splitAnsiTokens(str: string): Array<{ type: 'ansi' | 'text'; value: string }> {
31
+ const tokens: Array<{ type: 'ansi' | 'text'; value: string }> = [];
32
+ let i = 0;
33
+
34
+ while (i < str.length) {
35
+ const ansiMatch = ANSI_ESCAPE_PATTERN.exec(str.slice(i));
36
+ if (ansiMatch) {
37
+ tokens.push({ type: 'ansi', value: ansiMatch[0] });
38
+ i += ansiMatch[0].length;
39
+ continue;
40
+ }
41
+
42
+ let j = i;
43
+ while (j < str.length) {
44
+ const nextAnsi = ANSI_ESCAPE_PATTERN.exec(str.slice(j));
45
+ if (nextAnsi) {
46
+ break;
47
+ }
48
+ j += 1;
49
+ }
50
+ tokens.push({ type: 'text', value: str.slice(i, j) });
51
+ i = j;
52
+ }
53
+
54
+ return tokens;
55
+ }
56
+
57
+ function segmentGraphemes(text: string): string[] {
58
+ if (!text) {
59
+ return [];
60
+ }
61
+ if (!GRAPHEME_SEGMENTER) {
62
+ return Array.from(text);
63
+ }
64
+ return Array.from(GRAPHEME_SEGMENTER.segment(text), segment => segment.segment);
65
+ }
66
+
67
+ function isWideCodePoint(codePoint: number): boolean {
68
+ return codePoint >= 0x1100 && (
69
+ codePoint <= 0x115F || // Hangul Jamo
70
+ codePoint === 0x2329 ||
71
+ codePoint === 0x232A ||
72
+ (codePoint >= 0x2E80 && codePoint <= 0xA4CF && codePoint !== 0x303F) ||
73
+ (codePoint >= 0xAC00 && codePoint <= 0xD7A3) ||
74
+ (codePoint >= 0xF900 && codePoint <= 0xFAFF) ||
75
+ (codePoint >= 0xFE10 && codePoint <= 0xFE19) ||
76
+ (codePoint >= 0xFE30 && codePoint <= 0xFE6F) ||
77
+ (codePoint >= 0xFF00 && codePoint <= 0xFF60) ||
78
+ (codePoint >= 0xFFE0 && codePoint <= 0xFFE6) ||
79
+ (codePoint >= 0x1F300 && codePoint <= 0x1FAFF) ||
80
+ (codePoint >= 0x20000 && codePoint <= 0x3FFFD)
81
+ );
82
+ }
83
+
84
+ function graphemeWidth(grapheme: string): number {
85
+ if (!grapheme || /^\p{Control}$/u.test(grapheme)) {
86
+ return 0;
87
+ }
88
+
89
+ // Emoji glyphs and ZWJ sequences generally render as double-width.
90
+ if (/\p{Extended_Pictographic}/u.test(grapheme)) {
91
+ return 2;
92
+ }
93
+
94
+ let hasVisibleBase = false;
95
+ let width = 0;
96
+ for (const char of Array.from(grapheme)) {
97
+ if (/^\p{Mark}$/u.test(char) || char === '\u200D' || char === '\uFE0F') {
98
+ continue;
99
+ }
100
+ hasVisibleBase = true;
101
+ const codePoint = char.codePointAt(0);
102
+ if (codePoint !== undefined && isWideCodePoint(codePoint)) {
103
+ width = Math.max(width, 2);
104
+ } else {
105
+ width = Math.max(width, 1);
106
+ }
107
+ }
108
+
109
+ return hasVisibleBase ? width : 0;
110
+ }
111
+
112
+ function visualLength(str: string): number {
113
+ let width = 0;
114
+ for (const token of splitAnsiTokens(str)) {
115
+ if (token.type === 'ansi') {
116
+ continue;
117
+ }
118
+ for (const grapheme of segmentGraphemes(token.value)) {
119
+ width += graphemeWidth(grapheme);
120
+ }
121
+ }
122
+ return width;
123
+ }
124
+
125
+ function sliceVisible(str: string, maxVisible: number): string {
126
+ if (maxVisible <= 0) {
127
+ return '';
128
+ }
129
+
130
+ let result = '';
131
+ let visibleWidth = 0;
132
+ let done = false;
133
+ let i = 0;
134
+
135
+ while (i < str.length && !done) {
136
+ const ansiMatch = ANSI_ESCAPE_PATTERN.exec(str.slice(i));
137
+ if (ansiMatch) {
138
+ result += ansiMatch[0];
139
+ i += ansiMatch[0].length;
140
+ continue;
141
+ }
142
+
143
+ let j = i;
144
+ while (j < str.length) {
145
+ const nextAnsi = ANSI_ESCAPE_PATTERN.exec(str.slice(j));
146
+ if (nextAnsi) {
147
+ break;
148
+ }
149
+ j += 1;
150
+ }
151
+
152
+ const plainChunk = str.slice(i, j);
153
+ for (const grapheme of segmentGraphemes(plainChunk)) {
154
+ const graphemeCellWidth = graphemeWidth(grapheme);
155
+ if (visibleWidth + graphemeCellWidth > maxVisible) {
156
+ done = true;
157
+ break;
158
+ }
159
+ result += grapheme;
160
+ visibleWidth += graphemeCellWidth;
161
+ }
162
+
163
+ i = j;
164
+ }
165
+
166
+ return result;
167
+ }
168
+
169
+ function truncateToWidth(str: string, maxWidth: number): string {
170
+ if (maxWidth <= 0 || visualLength(str) <= maxWidth) {
171
+ return str;
172
+ }
173
+
174
+ const suffix = maxWidth >= 3 ? '...' : '.'.repeat(maxWidth);
175
+ const keep = Math.max(0, maxWidth - suffix.length);
176
+ return `${sliceVisible(str, keep)}${suffix}${RESET}`;
177
+ }
178
+
179
+ function splitLineBySeparators(line: string): { segments: string[]; separators: string[] } {
180
+ const segments: string[] = [];
181
+ const separators: string[] = [];
182
+ let currentStart = 0;
183
+ let i = 0;
184
+
185
+ while (i < line.length) {
186
+ const ansiMatch = ANSI_ESCAPE_PATTERN.exec(line.slice(i));
187
+ if (ansiMatch) {
188
+ i += ansiMatch[0].length;
189
+ continue;
190
+ }
191
+
192
+ const separator = line.startsWith(' | ', i)
193
+ ? ' | '
194
+ : (line.startsWith(' │ ', i) ? ' │ ' : null);
195
+
196
+ if (separator) {
197
+ segments.push(line.slice(currentStart, i));
198
+ separators.push(separator);
199
+ i += separator.length;
200
+ currentStart = i;
201
+ continue;
202
+ }
203
+
204
+ i += 1;
205
+ }
206
+
207
+ segments.push(line.slice(currentStart));
208
+ return { segments, separators };
209
+ }
210
+
211
+ function splitWrapParts(line: string): Array<{ separator: string; segment: string }> {
212
+ const { segments, separators } = splitLineBySeparators(line);
213
+ if (segments.length === 0) {
214
+ return [];
215
+ }
216
+
217
+ let parts: Array<{ separator: string; segment: string }> = [{
218
+ separator: '',
219
+ segment: segments[0],
220
+ }];
221
+ for (let segmentIndex = 1; segmentIndex < segments.length; segmentIndex += 1) {
222
+ parts.push({
223
+ separator: separators[segmentIndex - 1] ?? ' | ',
224
+ segment: segments[segmentIndex],
225
+ });
226
+ }
227
+
228
+ // Keep the leading [model | provider] block together.
229
+ // This avoids splitting inside the model badge while still splitting
230
+ // separators elsewhere in the line.
231
+ const firstVisible = stripAnsi(parts[0].segment).trimStart();
232
+ const firstHasOpeningBracket = firstVisible.startsWith('[');
233
+ const firstHasClosingBracket = stripAnsi(parts[0].segment).includes(']');
234
+ if (firstHasOpeningBracket && !firstHasClosingBracket && parts.length > 1) {
235
+ let mergedSegment = parts[0].segment;
236
+ let consumeIndex = 1;
237
+ while (consumeIndex < parts.length) {
238
+ const nextPart = parts[consumeIndex];
239
+ mergedSegment += `${nextPart.separator}${nextPart.segment}`;
240
+ consumeIndex += 1;
241
+ if (stripAnsi(nextPart.segment).includes(']')) {
242
+ break;
243
+ }
244
+ }
245
+ parts = [
246
+ { separator: '', segment: mergedSegment },
247
+ ...parts.slice(consumeIndex),
248
+ ];
249
+ }
250
+
251
+ return parts;
252
+ }
253
+
254
+ function wrapSingleSegment(str: string, maxWidth: number): string[] {
255
+ const lines: string[] = [];
256
+ let remaining = str;
257
+ while (visualLength(remaining) > maxWidth) {
258
+ const sliced = sliceVisible(remaining, maxWidth);
259
+ lines.push(sliced + RESET);
260
+ remaining = remaining.slice(sliced.length);
261
+ // Skip any leading ANSI escape at the boundary so color continuity is preserved
262
+ let ansiMatch = ANSI_ESCAPE_PATTERN.exec(remaining);
263
+ while (ansiMatch && remaining.startsWith(ansiMatch[0])) {
264
+ remaining = remaining.slice(ansiMatch[0].length);
265
+ ansiMatch = ANSI_ESCAPE_PATTERN.exec(remaining);
266
+ }
267
+ }
268
+ if (remaining) {
269
+ lines.push(remaining);
270
+ }
271
+ return lines.length > 0 ? lines : [str];
272
+ }
273
+
274
+ function wrapLineToWidth(line: string, maxWidth: number): string[] {
275
+ if (maxWidth <= 0 || visualLength(line) <= maxWidth) {
276
+ return [line];
277
+ }
278
+
279
+ const parts = splitWrapParts(line);
280
+ if (parts.length <= 1) {
281
+ // Single segment — wrap at character level instead of truncating
282
+ return wrapSingleSegment(line, maxWidth);
283
+ }
284
+
285
+ const wrapped: string[] = [];
286
+ let current = parts[0].segment;
287
+
288
+ for (const part of parts.slice(1)) {
289
+ const candidate = `${current}${part.separator}${part.segment}`;
290
+ if (visualLength(candidate) <= maxWidth) {
291
+ current = candidate;
292
+ continue;
293
+ }
294
+
295
+ // Push current line, wrapping if still too wide
296
+ if (visualLength(current) > maxWidth) {
297
+ wrapped.push(...wrapSingleSegment(current, maxWidth));
298
+ } else {
299
+ wrapped.push(current);
300
+ }
301
+ current = part.segment;
302
+ }
303
+
304
+ if (current) {
305
+ if (visualLength(current) > maxWidth) {
306
+ wrapped.push(...wrapSingleSegment(current, maxWidth));
307
+ } else {
308
+ wrapped.push(current);
309
+ }
310
+ }
311
+
312
+ return wrapped;
313
+ }
314
+
315
+ function makeSeparator(length: number): string {
316
+ return dim('─'.repeat(Math.max(length, 1)));
317
+ }
318
+
319
+ function addTreePrefixes(lines: string[], useTree: boolean): string[] {
320
+ if (!useTree || lines.length === 0) return lines;
321
+ return lines.map((line, i) => {
322
+ const prefix = i === lines.length - 1 ? dim('└─ ') : dim('├─ ');
323
+ return prefix + line;
324
+ });
325
+ }
326
+
327
+ const ACTIVITY_ELEMENTS = new Set<HudElement>(['tools', 'agents', 'todos']);
328
+
329
+ function collectActivityLines(ctx: RenderContext): string[] {
330
+ const activityLines: string[] = [];
331
+ const display = ctx.config?.display;
332
+
333
+ if (display?.showTools !== false) {
334
+ const toolsLine = renderToolsLine(ctx);
335
+ if (toolsLine) {
336
+ activityLines.push(toolsLine);
337
+ }
338
+ }
339
+
340
+ if (display?.showAgents !== false) {
341
+ const agentsLine = renderAgentsLine(ctx);
342
+ if (agentsLine) {
343
+ activityLines.push(agentsLine);
344
+ }
345
+ }
346
+
347
+ if (display?.showTodos !== false) {
348
+ const todosLine = renderTodosLine(ctx);
349
+ if (todosLine) {
350
+ activityLines.push(todosLine);
351
+ }
352
+ }
353
+
354
+ return activityLines;
355
+ }
356
+
357
+ function renderElementLine(ctx: RenderContext, element: HudElement): string | null {
358
+ const display = ctx.config?.display;
359
+
360
+ switch (element) {
361
+ case 'project':
362
+ return renderProjectLine(ctx);
363
+ case 'context':
364
+ return renderIdentityLine(ctx);
365
+ case 'usage':
366
+ return renderUsageLine(ctx);
367
+ case 'environment':
368
+ return renderEnvironmentLine(ctx);
369
+ case 'tools':
370
+ return display?.showTools === false ? null : renderToolsLine(ctx);
371
+ case 'agents':
372
+ return display?.showAgents === false ? null : renderAgentsLine(ctx);
373
+ case 'todos':
374
+ return display?.showTodos === false ? null : renderTodosLine(ctx);
375
+ case 'framework':
376
+ if (ctx.config.display.showFrameworks && ctx.frameworkStatus.length > 0) {
377
+ const line = renderFrameworkLine(ctx.frameworkStatus);
378
+ if (line) return line;
379
+ }
380
+ return null;
381
+ case 'alert':
382
+ if (ctx.config.display.showAlerts && ctx.alerts.length > 0) {
383
+ const line = renderAlertLine(ctx.alerts);
384
+ if (line) return line;
385
+ }
386
+ return null;
387
+ }
388
+ }
389
+
390
+ function renderCompact(ctx: RenderContext): string[] {
391
+ const lines: string[] = [];
392
+
393
+ const sessionLine = renderSessionLine(ctx);
394
+ if (sessionLine) {
395
+ lines.push(sessionLine);
396
+ }
397
+
398
+ return lines;
399
+ }
400
+
401
+ function renderExpanded(ctx: RenderContext): Array<{ line: string; isActivity: boolean }> {
402
+ const elementOrder = ctx.config?.elementOrder ?? DEFAULT_ELEMENT_ORDER;
403
+ const seen = new Set<HudElement>();
404
+ const lines: Array<{ line: string; isActivity: boolean }> = [];
405
+
406
+ for (let index = 0; index < elementOrder.length; index += 1) {
407
+ const element = elementOrder[index];
408
+ if (seen.has(element)) {
409
+ continue;
410
+ }
411
+
412
+ const nextElement = elementOrder[index + 1];
413
+ if (
414
+ (element === 'context' && nextElement === 'usage' && !seen.has('usage'))
415
+ || (element === 'usage' && nextElement === 'context' && !seen.has('context'))
416
+ ) {
417
+ seen.add(element);
418
+ seen.add(nextElement);
419
+
420
+ const firstLine = renderElementLine(ctx, element);
421
+ const secondLine = renderElementLine(ctx, nextElement);
422
+
423
+ if (firstLine && secondLine) {
424
+ lines.push({ line: `${firstLine} │ ${secondLine}`, isActivity: false });
425
+ } else if (firstLine) {
426
+ lines.push({ line: firstLine, isActivity: false });
427
+ } else if (secondLine) {
428
+ lines.push({ line: secondLine, isActivity: false });
429
+ }
430
+
431
+ continue;
432
+ }
433
+
434
+ seen.add(element);
435
+
436
+ const line = renderElementLine(ctx, element);
437
+ if (!line) {
438
+ continue;
439
+ }
440
+
441
+ lines.push({
442
+ line,
443
+ isActivity: ACTIVITY_ELEMENTS.has(element),
444
+ });
445
+ }
446
+
447
+ return lines;
448
+ }
449
+
450
+ export function render(ctx: RenderContext): void {
451
+ const lineLayout = ctx.config?.lineLayout ?? 'expanded';
452
+ const showSeparators = ctx.config?.showSeparators ?? false;
453
+ const terminalWidth = getTerminalWidth();
454
+
455
+ let lines: string[];
456
+
457
+ if (lineLayout === 'expanded') {
458
+ const renderedLines = renderExpanded(ctx);
459
+ const useTree = ctx.config?.display?.treePrefixes ?? false;
460
+
461
+ const firstActivityIndex = renderedLines.findIndex(({ isActivity }) => isActivity);
462
+ const activityLineStrings = renderedLines
463
+ .filter(({ isActivity }) => isActivity)
464
+ .map(({ line }) => line);
465
+ const prefixedActivityLines = addTreePrefixes(activityLineStrings, useTree);
466
+
467
+ let prefixCursor = 0;
468
+ lines = renderedLines.map(({ line, isActivity }) => {
469
+ if (isActivity) {
470
+ return prefixedActivityLines[prefixCursor++] ?? line;
471
+ }
472
+ return line;
473
+ });
474
+
475
+ if (showSeparators) {
476
+ if (firstActivityIndex > 0) {
477
+ const separatorBaseWidth = Math.max(
478
+ ...renderedLines
479
+ .slice(0, firstActivityIndex)
480
+ .map(({ line }) => visualLength(line)),
481
+ 20
482
+ );
483
+ const separatorWidth = terminalWidth
484
+ ? Math.min(separatorBaseWidth, terminalWidth)
485
+ : separatorBaseWidth;
486
+ lines.splice(firstActivityIndex, 0, makeSeparator(separatorWidth));
487
+ }
488
+ }
489
+ } else {
490
+ const headerLines = renderCompact(ctx);
491
+ const activityLines = collectActivityLines(ctx);
492
+ lines = [...headerLines];
493
+
494
+ if (showSeparators && activityLines.length > 0) {
495
+ const maxWidth = Math.max(...headerLines.map(visualLength), 20);
496
+ const separatorWidth = terminalWidth ? Math.min(maxWidth, terminalWidth) : maxWidth;
497
+ lines.push(makeSeparator(separatorWidth));
498
+ }
499
+
500
+ lines.push(...activityLines);
501
+ }
502
+
503
+ const physicalLines = lines.flatMap(line => line.split('\n'));
504
+ const visibleLines = terminalWidth
505
+ ? physicalLines.flatMap(line => wrapLineToWidth(line, terminalWidth))
506
+ : physicalLines;
507
+
508
+ for (const line of visibleLines) {
509
+ const outputLine = `${RESET}${line}`;
510
+ console.log(outputLine);
511
+ }
512
+ }