veryfront 0.0.81 → 0.0.83

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 (157) hide show
  1. package/README.md +15 -1
  2. package/esm/deno.js +1 -1
  3. package/esm/proxy/cache/index.d.ts +41 -0
  4. package/esm/proxy/cache/index.d.ts.map +1 -0
  5. package/esm/proxy/cache/index.js +75 -0
  6. package/esm/proxy/cache/memory-cache.d.ts +18 -0
  7. package/esm/proxy/cache/memory-cache.d.ts.map +1 -0
  8. package/esm/proxy/cache/memory-cache.js +100 -0
  9. package/esm/proxy/cache/redis-cache.d.ts +27 -0
  10. package/esm/proxy/cache/redis-cache.d.ts.map +1 -0
  11. package/esm/proxy/cache/redis-cache.js +183 -0
  12. package/esm/proxy/cache/resilient-cache.d.ts +44 -0
  13. package/esm/proxy/cache/resilient-cache.d.ts.map +1 -0
  14. package/esm/proxy/cache/resilient-cache.js +178 -0
  15. package/esm/proxy/cache/types.d.ts +65 -0
  16. package/esm/proxy/cache/types.d.ts.map +1 -0
  17. package/esm/proxy/cache/types.js +7 -0
  18. package/esm/proxy/handler.d.ts +81 -0
  19. package/esm/proxy/handler.d.ts.map +1 -0
  20. package/esm/proxy/handler.js +417 -0
  21. package/esm/proxy/logger.d.ts +29 -0
  22. package/esm/proxy/logger.d.ts.map +1 -0
  23. package/esm/proxy/logger.js +258 -0
  24. package/esm/proxy/oauth-client.d.ts +15 -0
  25. package/esm/proxy/oauth-client.d.ts.map +1 -0
  26. package/esm/proxy/oauth-client.js +52 -0
  27. package/esm/proxy/token-manager.d.ts +59 -0
  28. package/esm/proxy/token-manager.d.ts.map +1 -0
  29. package/esm/proxy/token-manager.js +125 -0
  30. package/esm/proxy/tracing.d.ts +39 -0
  31. package/esm/proxy/tracing.d.ts.map +1 -0
  32. package/esm/proxy/tracing.js +194 -0
  33. package/esm/src/cache/backend.d.ts +22 -0
  34. package/esm/src/cache/backend.d.ts.map +1 -1
  35. package/esm/src/cache/backend.js +59 -0
  36. package/esm/src/cache/cache-key-builder.d.ts +0 -4
  37. package/esm/src/cache/cache-key-builder.d.ts.map +1 -1
  38. package/esm/src/cache/cache-key-builder.js +0 -6
  39. package/esm/src/cache/hash.d.ts +107 -0
  40. package/esm/src/cache/hash.d.ts.map +1 -0
  41. package/esm/src/cache/hash.js +166 -0
  42. package/esm/src/cache/index.d.ts +3 -0
  43. package/esm/src/cache/index.d.ts.map +1 -1
  44. package/esm/src/cache/index.js +3 -0
  45. package/esm/src/cache/module-cache.d.ts +82 -0
  46. package/esm/src/cache/module-cache.d.ts.map +1 -0
  47. package/esm/src/cache/module-cache.js +214 -0
  48. package/esm/src/cache/multi-tier.d.ts +148 -0
  49. package/esm/src/cache/multi-tier.d.ts.map +1 -0
  50. package/esm/src/cache/multi-tier.js +326 -0
  51. package/esm/src/cli/app/actions.d.ts +26 -0
  52. package/esm/src/cli/app/actions.d.ts.map +1 -0
  53. package/esm/src/cli/app/actions.js +152 -0
  54. package/esm/src/cli/app/components/inline-input.d.ts +35 -0
  55. package/esm/src/cli/app/components/inline-input.d.ts.map +1 -0
  56. package/esm/src/cli/app/components/inline-input.js +220 -0
  57. package/esm/src/cli/app/components/list-select.d.ts +69 -0
  58. package/esm/src/cli/app/components/list-select.d.ts.map +1 -0
  59. package/esm/src/cli/app/components/list-select.js +137 -0
  60. package/esm/src/cli/app/index.d.ts +45 -0
  61. package/esm/src/cli/app/index.d.ts.map +1 -0
  62. package/esm/src/cli/app/index.js +1252 -0
  63. package/esm/src/cli/app/state.d.ts +122 -0
  64. package/esm/src/cli/app/state.d.ts.map +1 -0
  65. package/esm/src/cli/app/state.js +232 -0
  66. package/esm/src/cli/app/views/dashboard.d.ts +19 -0
  67. package/esm/src/cli/app/views/dashboard.d.ts.map +1 -0
  68. package/esm/src/cli/app/views/dashboard.js +178 -0
  69. package/esm/src/cli/index/command-router.d.ts.map +1 -1
  70. package/esm/src/cli/index/command-router.js +9 -39
  71. package/esm/src/cli/index/start-handler.d.ts +3 -0
  72. package/esm/src/cli/index/start-handler.d.ts.map +1 -0
  73. package/esm/src/cli/index/start-handler.js +145 -0
  74. package/esm/src/cli/mcp/index.d.ts +11 -0
  75. package/esm/src/cli/mcp/index.d.ts.map +1 -0
  76. package/esm/src/cli/mcp/index.js +10 -0
  77. package/esm/src/cli/templates/integration-loader.d.ts.map +1 -1
  78. package/esm/src/cli/templates/integration-loader.js +2 -4
  79. package/esm/src/middleware/builtin/security/redis-rate-limit.d.ts +2 -0
  80. package/esm/src/middleware/builtin/security/redis-rate-limit.d.ts.map +1 -1
  81. package/esm/src/middleware/builtin/security/redis-rate-limit.js +23 -9
  82. package/esm/src/modules/react-loader/ssr-module-loader/cache/redis.d.ts +10 -0
  83. package/esm/src/modules/react-loader/ssr-module-loader/cache/redis.d.ts.map +1 -1
  84. package/esm/src/modules/react-loader/ssr-module-loader/cache/redis.js +30 -42
  85. package/esm/src/modules/react-loader/ssr-module-loader/loader.d.ts.map +1 -1
  86. package/esm/src/modules/react-loader/ssr-module-loader/loader.js +148 -20
  87. package/esm/src/observability/tracing/span-names.d.ts +2 -0
  88. package/esm/src/observability/tracing/span-names.d.ts.map +1 -1
  89. package/esm/src/observability/tracing/span-names.js +2 -0
  90. package/esm/src/platform/adapters/fs/cache/file-cache.d.ts.map +1 -1
  91. package/esm/src/platform/adapters/fs/cache/file-cache.js +9 -3
  92. package/esm/src/rendering/orchestrator/module-loader/cache.d.ts +10 -2
  93. package/esm/src/rendering/orchestrator/module-loader/cache.d.ts.map +1 -1
  94. package/esm/src/rendering/orchestrator/module-loader/cache.js +11 -6
  95. package/esm/src/rendering/orchestrator/module-loader/index.d.ts.map +1 -1
  96. package/esm/src/rendering/orchestrator/module-loader/index.js +72 -77
  97. package/esm/src/server/context/cache-invalidation.d.ts.map +1 -1
  98. package/esm/src/server/context/cache-invalidation.js +4 -0
  99. package/esm/src/server/handlers/dev/dashboard/api.js +4 -0
  100. package/esm/src/server/handlers/dev/projects/ui-handler.d.ts.map +1 -1
  101. package/esm/src/server/handlers/dev/projects/ui-handler.js +6 -0
  102. package/esm/src/transforms/esm/http-cache.d.ts.map +1 -1
  103. package/esm/src/transforms/esm/http-cache.js +145 -93
  104. package/esm/src/transforms/esm/transform-cache.d.ts +25 -0
  105. package/esm/src/transforms/esm/transform-cache.d.ts.map +1 -1
  106. package/esm/src/transforms/esm/transform-cache.js +45 -0
  107. package/esm/src/transforms/mdx/esm-module-loader/module-fetcher/index.d.ts.map +1 -1
  108. package/esm/src/transforms/mdx/esm-module-loader/module-fetcher/index.js +2 -36
  109. package/esm/src/utils/constants/cache.d.ts +4 -0
  110. package/esm/src/utils/constants/cache.d.ts.map +1 -1
  111. package/esm/src/utils/constants/cache.js +14 -1
  112. package/esm/src/utils/index.d.ts +1 -1
  113. package/esm/src/utils/index.d.ts.map +1 -1
  114. package/esm/src/utils/index.js +1 -1
  115. package/package.json +2 -1
  116. package/src/deno.js +1 -1
  117. package/src/proxy/cache/index.ts +93 -0
  118. package/src/proxy/cache/memory-cache.ts +120 -0
  119. package/src/proxy/cache/redis-cache.ts +203 -0
  120. package/src/proxy/cache/resilient-cache.ts +205 -0
  121. package/src/proxy/cache/types.ts +72 -0
  122. package/src/proxy/handler.ts +593 -0
  123. package/src/proxy/logger.ts +329 -0
  124. package/src/proxy/oauth-client.ts +91 -0
  125. package/src/proxy/token-manager.ts +174 -0
  126. package/src/proxy/tracing.ts +237 -0
  127. package/src/src/cache/backend.ts +65 -0
  128. package/src/src/cache/cache-key-builder.ts +0 -9
  129. package/src/src/cache/hash.ts +205 -0
  130. package/src/src/cache/index.ts +3 -0
  131. package/src/src/cache/module-cache.ts +252 -0
  132. package/src/src/cache/multi-tier.ts +462 -0
  133. package/src/src/cli/app/actions.ts +190 -0
  134. package/src/src/cli/app/components/inline-input.ts +255 -0
  135. package/src/src/cli/app/components/list-select.ts +215 -0
  136. package/src/src/cli/app/index.ts +1471 -0
  137. package/src/src/cli/app/state.ts +385 -0
  138. package/src/src/cli/app/views/dashboard.ts +212 -0
  139. package/src/src/cli/index/command-router.ts +9 -40
  140. package/src/src/cli/index/start-handler.ts +195 -0
  141. package/src/src/cli/mcp/index.ts +11 -0
  142. package/src/src/cli/templates/integration-loader.ts +2 -8
  143. package/src/src/middleware/builtin/security/redis-rate-limit.ts +24 -11
  144. package/src/src/modules/react-loader/ssr-module-loader/cache/redis.ts +36 -50
  145. package/src/src/modules/react-loader/ssr-module-loader/loader.ts +168 -25
  146. package/src/src/observability/tracing/span-names.ts +2 -0
  147. package/src/src/platform/adapters/fs/cache/file-cache.ts +9 -3
  148. package/src/src/rendering/orchestrator/module-loader/cache.ts +14 -8
  149. package/src/src/rendering/orchestrator/module-loader/index.ts +94 -89
  150. package/src/src/server/context/cache-invalidation.ts +4 -0
  151. package/src/src/server/handlers/dev/dashboard/api.ts +2 -0
  152. package/src/src/server/handlers/dev/projects/ui-handler.ts +6 -0
  153. package/src/src/transforms/esm/http-cache.ts +160 -105
  154. package/src/src/transforms/esm/transform-cache.ts +53 -0
  155. package/src/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts +2 -40
  156. package/src/src/utils/constants/cache.ts +21 -1
  157. package/src/src/utils/index.ts +0 -1
@@ -0,0 +1,190 @@
1
+ /**
2
+ * CLI App Actions
3
+ *
4
+ * Handlers for opening projects in browser, Studio, and IDE.
5
+ * Uses cross-runtime platform abstractions for filesystem and command execution.
6
+ */
7
+
8
+ import { openBrowser } from "../auth/browser.js";
9
+ import { createFileSystem } from "../../platform/compat/fs.js";
10
+ import { getOsType, runCommand } from "../../platform/compat/process.js";
11
+ import { join } from "../../platform/compat/path/index.js";
12
+ import type { ProjectInfo } from "./state.js";
13
+ import { getRuntimeEnv, type RuntimeEnv } from "../../config/runtime-env.js";
14
+
15
+ export type IDE = "cursor" | "code" | "zed" | "idea" | "webstorm";
16
+
17
+ export interface ActionResult {
18
+ success: boolean;
19
+ message?: string;
20
+ }
21
+
22
+ /** IDE command-line executables */
23
+ const IDE_COMMANDS: Record<IDE, string> = {
24
+ cursor: "cursor",
25
+ code: "code",
26
+ zed: "zed",
27
+ idea: "idea",
28
+ webstorm: "webstorm",
29
+ };
30
+
31
+ /** IDE display names */
32
+ const IDE_NAMES: Record<IDE, string> = {
33
+ cursor: "Cursor",
34
+ code: "VS Code",
35
+ zed: "Zed",
36
+ idea: "IntelliJ IDEA",
37
+ webstorm: "WebStorm",
38
+ };
39
+
40
+ /** IDE detection order (preferred first) */
41
+ const IDE_DETECTION_ORDER: IDE[] = ["cursor", "code", "zed", "idea", "webstorm"];
42
+
43
+ /** Cache directories to clear relative to project path */
44
+ const PROJECT_CACHE_DIRS = [".cache", "node_modules/.cache"];
45
+
46
+ function formatError(error: unknown): string {
47
+ return error instanceof Error ? error.message : String(error);
48
+ }
49
+
50
+ async function commandExists(cmd: string): Promise<boolean> {
51
+ try {
52
+ const whichCmd = getOsType() === "windows" ? "where" : "which";
53
+ const result = await runCommand(whichCmd, { args: [cmd] });
54
+ return result.success;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ async function runCommandLocal(cmd: string, args: string[]): Promise<boolean> {
61
+ try {
62
+ const result = await runCommand(cmd, { args });
63
+ return result.success;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ export async function openInBrowser(project: ProjectInfo, port: number): Promise<ActionResult> {
70
+ const url = `http://${project.slug}.veryfront.me:${port}`;
71
+
72
+ try {
73
+ await openBrowser(url);
74
+ return { success: true, message: `Opened ${url}` };
75
+ } catch (error) {
76
+ return { success: false, message: `Failed to open browser: ${formatError(error)}` };
77
+ }
78
+ }
79
+
80
+ export async function openInStudio(project: ProjectInfo): Promise<ActionResult> {
81
+ const url = `https://veryfront.com/projects/${project.slug}`;
82
+
83
+ try {
84
+ await openBrowser(url);
85
+ return { success: true, message: `Opened Studio for ${project.slug}` };
86
+ } catch (error) {
87
+ return { success: false, message: `Failed to open Studio: ${formatError(error)}` };
88
+ }
89
+ }
90
+
91
+ export async function detectIDEs(): Promise<IDE[]> {
92
+ const available: IDE[] = [];
93
+
94
+ for (const ide of IDE_DETECTION_ORDER) {
95
+ if (await commandExists(IDE_COMMANDS[ide])) {
96
+ available.push(ide);
97
+ }
98
+ }
99
+
100
+ return available;
101
+ }
102
+
103
+ export async function getPreferredIDE(): Promise<IDE | null> {
104
+ const ides = await detectIDEs();
105
+ return ides[0] ?? null;
106
+ }
107
+
108
+ async function openPathInIDE(path: string, ide?: IDE): Promise<ActionResult> {
109
+ const targetIDE = ide ?? (await getPreferredIDE());
110
+
111
+ if (!targetIDE) {
112
+ return {
113
+ success: false,
114
+ message: "No supported IDE found. Install VS Code, Cursor, or Zed.",
115
+ };
116
+ }
117
+
118
+ const cmd = IDE_COMMANDS[targetIDE];
119
+ const name = IDE_NAMES[targetIDE];
120
+
121
+ if (await runCommandLocal(cmd, [path])) {
122
+ return { success: true, message: `Opened in ${name}` };
123
+ }
124
+
125
+ return { success: false, message: `Failed to open ${name}` };
126
+ }
127
+
128
+ export async function openInIDE(project: ProjectInfo, ide?: IDE): Promise<ActionResult> {
129
+ const result = await openPathInIDE(project.path, ide);
130
+ if (!result.success) return result;
131
+
132
+ const ideName = result.message?.split(" in ")[1];
133
+ return { success: true, message: `Opened ${project.slug} in ${ideName}` };
134
+ }
135
+
136
+ export function openFileInIDE(filePath: string, ide?: IDE): Promise<ActionResult> {
137
+ return openPathInIDE(filePath, ide);
138
+ }
139
+
140
+ export async function clearProjectCache(project: ProjectInfo): Promise<ActionResult> {
141
+ const fs = createFileSystem();
142
+ let cleared = 0;
143
+
144
+ for (const relativeDir of PROJECT_CACHE_DIRS) {
145
+ const dir = join(project.path, relativeDir);
146
+ try {
147
+ await fs.remove(dir, { recursive: true });
148
+ cleared++;
149
+ } catch {
150
+ // Directory doesn't exist
151
+ }
152
+ }
153
+
154
+ const message = cleared > 0 ? `Cleared ${cleared} cache directories` : "No caches to clear";
155
+ return { success: true, message };
156
+ }
157
+
158
+ export async function openMCPSettings(env: RuntimeEnv = getRuntimeEnv()): Promise<ActionResult> {
159
+ const home = env.homeDir || "";
160
+ const claudeDir = join(home, ".claude");
161
+ const settingsPath = join(claudeDir, "settings.json");
162
+ const fs = createFileSystem();
163
+
164
+ try {
165
+ await fs.mkdir(claudeDir, { recursive: true });
166
+ } catch {
167
+ // Already exists
168
+ }
169
+
170
+ if (!(await fs.exists(settingsPath))) {
171
+ const defaultSettings = { mcpServers: {} };
172
+ await fs.writeTextFile(settingsPath, JSON.stringify(defaultSettings, null, 2));
173
+ }
174
+
175
+ return openFileInIDE(settingsPath);
176
+ }
177
+
178
+ export function quickOpen(
179
+ projects: Array<{ slug: string; path: string }>,
180
+ num: number,
181
+ port: number,
182
+ ): Promise<ActionResult> {
183
+ const index = num - 1;
184
+ if (index < 0 || index >= projects.length) {
185
+ return Promise.resolve({ success: false, message: `No project at position ${num}` });
186
+ }
187
+
188
+ const project = projects[index]!;
189
+ return openInBrowser({ slug: project.slug, path: project.path, type: "local" }, port);
190
+ }
@@ -0,0 +1,255 @@
1
+ /****
2
+ * Inline Text Input Component
3
+ *
4
+ * Renders an input prompt at the bottom of the TUI that stays inline
5
+ * without exiting alternate screen mode.
6
+ */
7
+
8
+ import { brand, dim, muted } from "../../ui/colors.js";
9
+ import type { InputState, LogEntry } from "../state.js";
10
+
11
+ export interface InlineInputOptions {
12
+ maxWidth?: number;
13
+ }
14
+
15
+ /**
16
+ * Render the inline input prompt
17
+ */
18
+ export function renderInput(input: InputState, _options: InlineInputOptions = {}): string {
19
+ if (!input.active) return "";
20
+
21
+ // Build the input line with cursor
22
+ const prompt = ` ${brand(">")} ${input.prompt}: `;
23
+ const beforeCursor = input.value.slice(0, input.cursorPos);
24
+ const cursorChar = input.value[input.cursorPos] ?? " ";
25
+ const afterCursor = input.value.slice(input.cursorPos + 1);
26
+
27
+ // Cursor is rendered as inverse video
28
+ const cursor = `\x1b[7m${cursorChar}\x1b[27m`;
29
+
30
+ const inputLine = `${prompt}${beforeCursor}${cursor}${afterCursor}`;
31
+
32
+ // Hint line
33
+ const hintLine = ` ${dim("Enter")} ${muted("to submit")} ${dim("Esc")} ${muted("to cancel")}`;
34
+
35
+ return `${inputLine}\n${hintLine}`;
36
+ }
37
+
38
+ export interface RenderLogsOptions {
39
+ maxLines?: number;
40
+ maxWidth?: number;
41
+ scroll?: number;
42
+ expanded?: boolean;
43
+ }
44
+
45
+ /**
46
+ * Render the logs area with optional scrolling
47
+ */
48
+ export function renderLogs(
49
+ logs: LogEntry[],
50
+ options: RenderLogsOptions = {},
51
+ ): string {
52
+ const { maxLines = 5, maxWidth = 80, scroll = 0, expanded = false } = options;
53
+
54
+ if (logs.length === 0) return "";
55
+
56
+ const visibleLines = expanded ? Math.max(maxLines, 15) : maxLines;
57
+ const end = logs.length - scroll;
58
+ const start = Math.max(0, end - visibleLines);
59
+ const visibleLogs = logs.slice(start, end);
60
+
61
+ const lines: string[] = [];
62
+
63
+ for (const log of visibleLogs) {
64
+ const time = formatTime(log.time);
65
+ const levelColor = getLevelColor(log.level);
66
+ const levelPrefix = getLevelPrefix(log.level);
67
+
68
+ if (expanded) {
69
+ // When expanded, show structured info if available
70
+ if (log.meta?.method) {
71
+ // Request log - show all details in clean format
72
+ const meta = log.meta;
73
+ const statusColor = getStatusColor(meta.status || 200);
74
+ const methodStr = (meta.method || "GET").padEnd(7);
75
+ const pathStr = meta.path || "/";
76
+ const statusStr = String(meta.status || 200);
77
+ const durationStr = `${meta.durationMs || 0}ms`.padStart(6);
78
+
79
+ // Line 1: time + method + path
80
+ lines.push(
81
+ ` ${dim(time)} ${levelColor(levelPrefix)} ${methodStr}${pathStr}`,
82
+ );
83
+
84
+ // Line 2: status + duration + project info
85
+ const projectInfo: string[] = [];
86
+ if (meta.project) projectInfo.push(brand(meta.project));
87
+ if (meta.env) projectInfo.push(dim(meta.env));
88
+ if (meta.releaseId) projectInfo.push(dim(`#${meta.releaseId.slice(0, 8)}`));
89
+
90
+ lines.push(
91
+ ` ${"".padEnd(12)}${statusColor(statusStr)} ${dim(durationStr)}${
92
+ projectInfo.length ? ` ${projectInfo.join(" ")}` : ""
93
+ }`,
94
+ );
95
+ } else {
96
+ // Regular log - show full message (may wrap to multiple lines)
97
+ const prefix = ` ${dim(time)} ${levelColor(levelPrefix)} `;
98
+ const msgLines = wrapText(log.message, maxWidth - 15);
99
+ lines.push(`${prefix}${msgLines[0] || ""}`);
100
+ // Indent continuation lines
101
+ for (let i = 1; i < msgLines.length; i++) {
102
+ lines.push(` ${"".padEnd(12)}${msgLines[i]}`);
103
+ }
104
+ }
105
+ } else {
106
+ // When collapsed, truncate
107
+ const maxMsgLen = maxWidth - 15;
108
+ const msg = log.message.length > maxMsgLen
109
+ ? `${log.message.slice(0, maxMsgLen - 3)}...`
110
+ : log.message;
111
+ lines.push(` ${dim(time)} ${levelColor(levelPrefix)} ${msg}`);
112
+ }
113
+ }
114
+
115
+ if (expanded && logs.length > visibleLines) {
116
+ const canScrollUp = start > 0;
117
+ const canScrollDown = scroll > 0;
118
+ const scrollHint = [];
119
+ if (canScrollUp) scrollHint.push("↑");
120
+ if (canScrollDown) scrollHint.push("↓");
121
+ if (scrollHint.length > 0) {
122
+ lines.push(` ${dim(`[${scrollHint.join(" ")}] ${logs.length} total`)}`);
123
+ }
124
+ }
125
+
126
+ return lines.join("\n");
127
+ }
128
+
129
+ function wrapText(text: string, maxWidth: number): string[] {
130
+ if (text.length <= maxWidth) return [text];
131
+
132
+ const lines: string[] = [];
133
+ let remaining = text;
134
+
135
+ while (remaining.length > maxWidth) {
136
+ let breakPoint = remaining.lastIndexOf(" ", maxWidth);
137
+ if (breakPoint <= 0) breakPoint = maxWidth;
138
+ lines.push(remaining.slice(0, breakPoint));
139
+ remaining = remaining.slice(breakPoint).trimStart();
140
+ }
141
+
142
+ if (remaining) lines.push(remaining);
143
+ return lines;
144
+ }
145
+
146
+ /**
147
+ * Format time as HH:MM:SS
148
+ */
149
+ function formatTime(date: Date): string {
150
+ const h = String(date.getHours()).padStart(2, "0");
151
+ const m = String(date.getMinutes()).padStart(2, "0");
152
+ const s = String(date.getSeconds()).padStart(2, "0");
153
+ return `${h}:${m}:${s}`;
154
+ }
155
+
156
+ /**
157
+ * Get color function for log level
158
+ */
159
+ function getLevelColor(level: LogEntry["level"]): (s: string) => string {
160
+ switch (level) {
161
+ case "error":
162
+ return (s: string) => `\x1b[31m${s}\x1b[0m`; // red
163
+ case "warn":
164
+ return (s: string) => `\x1b[33m${s}\x1b[0m`; // yellow
165
+ case "info":
166
+ return (s: string) => `\x1b[36m${s}\x1b[0m`; // cyan
167
+ case "debug":
168
+ return dim;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Get color function for HTTP status code
174
+ */
175
+ function getStatusColor(status: number): (s: string) => string {
176
+ if (status >= 500) return (s: string) => `\x1b[31m${s}\x1b[0m`; // red
177
+ if (status >= 400) return (s: string) => `\x1b[33m${s}\x1b[0m`; // yellow
178
+ if (status >= 300) return (s: string) => `\x1b[36m${s}\x1b[0m`; // cyan
179
+ return (s: string) => `\x1b[32m${s}\x1b[0m`; // green
180
+ }
181
+
182
+ /**
183
+ * Get prefix for log level
184
+ */
185
+ function getLevelPrefix(level: LogEntry["level"]): string {
186
+ switch (level) {
187
+ case "error":
188
+ return "ERR";
189
+ case "warn":
190
+ return "WRN";
191
+ case "info":
192
+ return "INF";
193
+ case "debug":
194
+ return "DBG";
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Handle input key press
200
+ * Returns the new value and cursor position, or null if the key should end input
201
+ */
202
+ export function handleInputKey(
203
+ key: string,
204
+ value: string,
205
+ cursorPos: number,
206
+ ): { value: string; cursorPos: number } | { action: "submit" | "cancel" } {
207
+ if (key === "\r" || key === "\n") return { action: "submit" };
208
+ if (key === "\x1b" || key === "\x03") return { action: "cancel" };
209
+
210
+ if (key === "\x7f" || key === "\b") {
211
+ if (cursorPos === 0) return { value, cursorPos };
212
+
213
+ return {
214
+ value: value.slice(0, cursorPos - 1) + value.slice(cursorPos),
215
+ cursorPos: cursorPos - 1,
216
+ };
217
+ }
218
+
219
+ if (key === "\x1b[3~") {
220
+ if (cursorPos >= value.length) return { value, cursorPos };
221
+
222
+ return {
223
+ value: value.slice(0, cursorPos) + value.slice(cursorPos + 1),
224
+ cursorPos,
225
+ };
226
+ }
227
+
228
+ if (key === "\x1b[D") return { value, cursorPos: Math.max(0, cursorPos - 1) };
229
+ if (key === "\x1b[C") return { value, cursorPos: Math.min(value.length, cursorPos + 1) };
230
+ if (key === "\x01" || key === "\x1b[H") return { value, cursorPos: 0 };
231
+ if (key === "\x05" || key === "\x1b[F") return { value, cursorPos: value.length };
232
+ if (key === "\x15") return { value: "", cursorPos: 0 };
233
+
234
+ if (key === "\x17") {
235
+ if (cursorPos === 0) return { value, cursorPos };
236
+
237
+ let newPos = cursorPos - 1;
238
+ while (newPos > 0 && value[newPos] === " ") newPos--;
239
+ while (newPos > 0 && value[newPos - 1] !== " ") newPos--;
240
+
241
+ return {
242
+ value: value.slice(0, newPos) + value.slice(cursorPos),
243
+ cursorPos: newPos,
244
+ };
245
+ }
246
+
247
+ if (key.length === 1 && key >= " " && key <= "~") {
248
+ return {
249
+ value: value.slice(0, cursorPos) + key + value.slice(cursorPos),
250
+ cursorPos: cursorPos + 1,
251
+ };
252
+ }
253
+
254
+ return { value, cursorPos };
255
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Interactive List Select Component
3
+ *
4
+ * Keyboard-navigable list with selection support.
5
+ * Supports arrow keys, j/k vim bindings, and number shortcuts.
6
+ */
7
+
8
+ import { brand, dim } from "../../ui/colors.js";
9
+ import { truncate } from "../../ui/layout.js";
10
+
11
+ export interface ListItem<T = unknown> {
12
+ /** Unique identifier */
13
+ id: string;
14
+ /** Display label */
15
+ label: string;
16
+ /** Optional description */
17
+ description?: string;
18
+ /** Optional path or metadata */
19
+ meta?: string;
20
+ /** Associated data */
21
+ data?: T;
22
+ }
23
+
24
+ export interface ListSelectOptions {
25
+ /** Maximum width for the list */
26
+ maxWidth?: number;
27
+ /** Number of visible items (for scrolling) */
28
+ visibleCount?: number;
29
+ /** Show number shortcuts (1-9) */
30
+ showNumbers?: boolean;
31
+ /** Offset for number shortcuts (e.g., 1 means start at [2]) */
32
+ numberOffset?: number;
33
+ /** Empty state message */
34
+ emptyMessage?: string;
35
+ /** Show selection cursor (default true). Set false for inactive sections */
36
+ showSelection?: boolean;
37
+ }
38
+
39
+ export interface ListSelectState<T = unknown> {
40
+ /** All items in the list */
41
+ items: ListItem<T>[];
42
+ /** Currently selected index */
43
+ selectedIndex: number;
44
+ /** Scroll offset for long lists */
45
+ scrollOffset: number;
46
+ }
47
+
48
+ /**
49
+ * Create initial list state
50
+ */
51
+ export function createListState<T>(items: ListItem<T>[]): ListSelectState<T> {
52
+ return {
53
+ items,
54
+ selectedIndex: 0,
55
+ scrollOffset: 0,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Move selection up
61
+ */
62
+ export function moveUp<T>(state: ListSelectState<T>): ListSelectState<T> {
63
+ if (state.items.length === 0) return state;
64
+
65
+ const newIndex = state.selectedIndex > 0 ? state.selectedIndex - 1 : state.items.length - 1;
66
+
67
+ const scrollOffset = newIndex < state.scrollOffset ? newIndex : state.scrollOffset;
68
+
69
+ return { ...state, selectedIndex: newIndex, scrollOffset };
70
+ }
71
+
72
+ /**
73
+ * Move selection down
74
+ */
75
+ export function moveDown<T>(
76
+ state: ListSelectState<T>,
77
+ visibleCount = 10,
78
+ ): ListSelectState<T> {
79
+ if (state.items.length === 0) return state;
80
+
81
+ const newIndex = state.selectedIndex < state.items.length - 1 ? state.selectedIndex + 1 : 0;
82
+
83
+ let scrollOffset = state.scrollOffset;
84
+
85
+ if (newIndex === 0) {
86
+ scrollOffset = 0;
87
+ } else if (newIndex >= scrollOffset + visibleCount) {
88
+ scrollOffset = newIndex - visibleCount + 1;
89
+ }
90
+
91
+ return { ...state, selectedIndex: newIndex, scrollOffset };
92
+ }
93
+
94
+ /**
95
+ * Select item by number (1-9)
96
+ */
97
+ export function selectByNumber<T>(
98
+ state: ListSelectState<T>,
99
+ num: number,
100
+ ): ListSelectState<T> {
101
+ const index = num - 1;
102
+ if (index < 0 || index >= state.items.length) return state;
103
+ return { ...state, selectedIndex: index };
104
+ }
105
+
106
+ /**
107
+ * Get currently selected item
108
+ */
109
+ export function getSelectedItem<T>(
110
+ state: ListSelectState<T>,
111
+ ): ListItem<T> | undefined {
112
+ return state.items[state.selectedIndex];
113
+ }
114
+
115
+ /**
116
+ * Render the list as a string
117
+ */
118
+ export function renderList<T>(
119
+ state: ListSelectState<T>,
120
+ options: ListSelectOptions = {},
121
+ ): string {
122
+ const {
123
+ maxWidth = 60,
124
+ visibleCount = 10,
125
+ showNumbers = true,
126
+ numberOffset = 0,
127
+ emptyMessage = "No items",
128
+ showSelection = true,
129
+ } = options;
130
+
131
+ if (state.items.length === 0) return ` ${dim(emptyMessage)}`;
132
+
133
+ const lines: string[] = [];
134
+ const start = state.scrollOffset;
135
+ const end = Math.min(start + visibleCount, state.items.length);
136
+ const visibleItems = state.items.slice(start, end);
137
+
138
+ const numberWidth = showNumbers ? 4 : 0; // " [1] "
139
+ const cursorWidth = 2; // "› " or " "
140
+ const prefixWidth = numberWidth + cursorWidth;
141
+
142
+ for (let i = 0; i < visibleItems.length; i++) {
143
+ const item = visibleItems[i];
144
+ if (!item) continue;
145
+
146
+ const actualIndex = start + i;
147
+ const isSelected = showSelection && actualIndex === state.selectedIndex;
148
+ const displayNum = actualIndex + 1 + numberOffset;
149
+
150
+ const parts: string[] = [];
151
+
152
+ parts.push(isSelected ? brand("›") : " ", " ");
153
+
154
+ if (showNumbers) {
155
+ if (displayNum <= 35) {
156
+ const shortcut = displayNum <= 9
157
+ ? String(displayNum)
158
+ : String.fromCharCode(96 + displayNum - 9); // 10='a', 11='b', etc.
159
+ parts.push(isSelected ? brand(`[${shortcut}]`) : dim(`[${shortcut}]`), " ");
160
+ } else {
161
+ parts.push(" ");
162
+ }
163
+ }
164
+
165
+ // Render label, then use remaining space for meta
166
+ const labelText = item.label;
167
+ const availableForContent = maxWidth - prefixWidth;
168
+
169
+ if (item.meta) {
170
+ // Split space between label and meta dynamically
171
+ const metaText = item.meta;
172
+ const totalNeeded = labelText.length + 1 + metaText.length; // 1 for space
173
+
174
+ if (totalNeeded <= availableForContent) {
175
+ // Both fit - no truncation needed
176
+ parts.push(isSelected ? labelText : dim(labelText));
177
+ const padding = availableForContent - labelText.length - metaText.length;
178
+ parts.push(" ".repeat(Math.max(1, padding)), dim(metaText));
179
+ } else {
180
+ // Need to truncate - prioritize label, give rest to meta
181
+ const labelMax = Math.min(labelText.length, Math.floor(availableForContent * 0.4));
182
+ const metaMax = availableForContent - labelMax - 1;
183
+ const label = truncate(labelText, labelMax);
184
+ parts.push(isSelected ? label : dim(label));
185
+ parts.push(" ", dim(truncate(metaText, metaMax)));
186
+ }
187
+ } else {
188
+ const label = truncate(labelText, availableForContent);
189
+ parts.push(isSelected ? label : dim(label));
190
+ }
191
+
192
+ lines.push(parts.join(""));
193
+
194
+ if (isSelected && item.description) {
195
+ lines.push(` ${dim(truncate(item.description, maxWidth - 5))}`);
196
+ }
197
+ }
198
+
199
+ if (start > 0) lines.unshift(` ${dim("↑ more above")}`);
200
+ if (end < state.items.length) lines.push(` ${dim("↓ more below")}`);
201
+
202
+ return lines.join("\n");
203
+ }
204
+
205
+ /**
206
+ * Create a list section with title
207
+ */
208
+ export function listSection<T>(
209
+ title: string,
210
+ state: ListSelectState<T>,
211
+ options: ListSelectOptions = {},
212
+ ): string {
213
+ const header = ` ${dim(title)} ${dim(`(${state.items.length})`)}`;
214
+ return `${header}\n${renderList(state, options)}`;
215
+ }