@makefinks/daemon 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.
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/cli.js +22 -0
- package/package.json +79 -0
- package/src/ai/agent-turn-runner.ts +130 -0
- package/src/ai/daemon-ai.ts +403 -0
- package/src/ai/exa-client.ts +21 -0
- package/src/ai/exa-fetch-cache.ts +104 -0
- package/src/ai/model-config.ts +99 -0
- package/src/ai/sanitize-messages.ts +83 -0
- package/src/ai/system-prompt.ts +363 -0
- package/src/ai/tools/fetch-urls.ts +187 -0
- package/src/ai/tools/grounding-manager.ts +94 -0
- package/src/ai/tools/index.ts +52 -0
- package/src/ai/tools/read-file.ts +100 -0
- package/src/ai/tools/render-url.ts +275 -0
- package/src/ai/tools/run-bash.ts +224 -0
- package/src/ai/tools/subagents.ts +195 -0
- package/src/ai/tools/todo-manager.ts +150 -0
- package/src/ai/tools/web-search.ts +91 -0
- package/src/app/App.tsx +711 -0
- package/src/app/components/AppOverlays.tsx +131 -0
- package/src/app/components/AvatarLayer.tsx +51 -0
- package/src/app/components/ConversationPane.tsx +476 -0
- package/src/avatar/DaemonAvatarRenderable.ts +343 -0
- package/src/avatar/daemon-avatar-rig.ts +1165 -0
- package/src/avatar-preview.ts +186 -0
- package/src/cli.ts +26 -0
- package/src/components/ApiKeyInput.tsx +99 -0
- package/src/components/ApiKeyStep.tsx +95 -0
- package/src/components/ApprovalPicker.tsx +109 -0
- package/src/components/ContentBlockView.tsx +141 -0
- package/src/components/DaemonText.tsx +34 -0
- package/src/components/DeviceMenu.tsx +166 -0
- package/src/components/GroundingBadge.tsx +21 -0
- package/src/components/GroundingMenu.tsx +310 -0
- package/src/components/HotkeysPane.tsx +115 -0
- package/src/components/InlineStatusIndicator.tsx +106 -0
- package/src/components/ModelMenu.tsx +411 -0
- package/src/components/OnboardingOverlay.tsx +446 -0
- package/src/components/ProviderMenu.tsx +177 -0
- package/src/components/SessionMenu.tsx +297 -0
- package/src/components/SettingsMenu.tsx +291 -0
- package/src/components/StatusBar.tsx +126 -0
- package/src/components/TokenUsageDisplay.tsx +92 -0
- package/src/components/ToolCallView.tsx +113 -0
- package/src/components/TypingInputBar.tsx +131 -0
- package/src/components/tool-layouts/components.tsx +120 -0
- package/src/components/tool-layouts/defaults.ts +9 -0
- package/src/components/tool-layouts/index.ts +22 -0
- package/src/components/tool-layouts/layouts/bash.ts +110 -0
- package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
- package/src/components/tool-layouts/layouts/index.ts +8 -0
- package/src/components/tool-layouts/layouts/read-file.ts +59 -0
- package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
- package/src/components/tool-layouts/layouts/system-info.ts +8 -0
- package/src/components/tool-layouts/layouts/todo.tsx +139 -0
- package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
- package/src/components/tool-layouts/layouts/web-search.ts +110 -0
- package/src/components/tool-layouts/registry.ts +17 -0
- package/src/components/tool-layouts/types.ts +94 -0
- package/src/hooks/daemon-event-handlers.ts +944 -0
- package/src/hooks/keyboard-handlers.ts +399 -0
- package/src/hooks/menu-navigation.ts +147 -0
- package/src/hooks/use-app-audio-devices-loader.ts +71 -0
- package/src/hooks/use-app-callbacks.ts +202 -0
- package/src/hooks/use-app-context-builder.ts +159 -0
- package/src/hooks/use-app-display-state.ts +162 -0
- package/src/hooks/use-app-menus.ts +51 -0
- package/src/hooks/use-app-model-pricing-loader.ts +45 -0
- package/src/hooks/use-app-model.ts +123 -0
- package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
- package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
- package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
- package/src/hooks/use-app-sessions.ts +105 -0
- package/src/hooks/use-app-settings.ts +62 -0
- package/src/hooks/use-conversation-manager.ts +163 -0
- package/src/hooks/use-copy-on-select.ts +50 -0
- package/src/hooks/use-daemon-events.ts +396 -0
- package/src/hooks/use-daemon-keyboard.ts +397 -0
- package/src/hooks/use-grounding.ts +46 -0
- package/src/hooks/use-input-history.ts +92 -0
- package/src/hooks/use-menu-keyboard.ts +93 -0
- package/src/hooks/use-playwright-notification.ts +23 -0
- package/src/hooks/use-reasoning-animation.ts +97 -0
- package/src/hooks/use-response-timer.ts +55 -0
- package/src/hooks/use-tool-approval.tsx +202 -0
- package/src/hooks/use-typing-mode.ts +137 -0
- package/src/hooks/use-voice-dependencies-notification.ts +37 -0
- package/src/index.tsx +48 -0
- package/src/scripts/setup-browsers.ts +42 -0
- package/src/state/app-context.tsx +160 -0
- package/src/state/daemon-events.ts +67 -0
- package/src/state/daemon-state.ts +493 -0
- package/src/state/migrations/001-init.ts +33 -0
- package/src/state/migrations/index.ts +8 -0
- package/src/state/model-history-store.ts +45 -0
- package/src/state/runtime-context.ts +21 -0
- package/src/state/session-store.ts +359 -0
- package/src/types/index.ts +405 -0
- package/src/types/theme.ts +52 -0
- package/src/ui/constants.ts +157 -0
- package/src/utils/clipboard.ts +89 -0
- package/src/utils/debug-logger.ts +69 -0
- package/src/utils/formatters.ts +242 -0
- package/src/utils/js-rendering.ts +77 -0
- package/src/utils/markdown-tables.ts +234 -0
- package/src/utils/model-metadata.ts +191 -0
- package/src/utils/openrouter-endpoints.ts +212 -0
- package/src/utils/openrouter-models.ts +205 -0
- package/src/utils/openrouter-pricing.ts +59 -0
- package/src/utils/openrouter-reported-cost.ts +16 -0
- package/src/utils/paste.ts +33 -0
- package/src/utils/preferences.ts +289 -0
- package/src/utils/text-fragment.ts +39 -0
- package/src/utils/tool-output-preview.ts +250 -0
- package/src/utils/voice-dependencies.ts +107 -0
- package/src/utils/workspace-manager.ts +85 -0
- package/src/voice/audio-recorder.ts +579 -0
- package/src/voice/mic-level.ts +35 -0
- package/src/voice/tts/openai-tts-stream.ts +222 -0
- package/src/voice/tts/speech-controller.ts +64 -0
- package/src/voice/tts/tts-player.ts +257 -0
- package/src/voice/voice-input-controller.ts +96 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logger for TUI development.
|
|
3
|
+
* Writes to a file instead of stdout to avoid UI glitches.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* import { debug } from "../utils/debug-logger";
|
|
7
|
+
* debug.log("message", someObject);
|
|
8
|
+
*
|
|
9
|
+
* Then run `tail -f debug.log` in a separate terminal.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { getAppConfigDir } from "./preferences";
|
|
15
|
+
|
|
16
|
+
const LOG_FILE = path.join(getAppConfigDir(), "debug.log");
|
|
17
|
+
const ENABLED = process.env.DEBUG_LOG === "1" || process.env.DEBUG_LOG === "true";
|
|
18
|
+
|
|
19
|
+
function ensureLogDir(): void {
|
|
20
|
+
try {
|
|
21
|
+
fs.mkdirSync(getAppConfigDir(), { recursive: true });
|
|
22
|
+
} catch {
|
|
23
|
+
// Silently fail if we can't create the directory
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatValue(value: unknown): string {
|
|
28
|
+
if (value === undefined) return "undefined";
|
|
29
|
+
if (value === null) return "null";
|
|
30
|
+
if (typeof value === "string") return value;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.stringify(value, null, 2);
|
|
33
|
+
} catch {
|
|
34
|
+
return String(value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeLog(level: string, args: unknown[]): void {
|
|
39
|
+
if (!ENABLED) return;
|
|
40
|
+
|
|
41
|
+
const timestamp = new Date().toISOString();
|
|
42
|
+
const formatted = args.map(formatValue).join(" ");
|
|
43
|
+
const line = `[${timestamp}] [${level}] ${formatted}\n`;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
ensureLogDir();
|
|
47
|
+
fs.appendFileSync(LOG_FILE, line);
|
|
48
|
+
} catch {
|
|
49
|
+
// Silently fail if we can't write
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const debug = {
|
|
54
|
+
log: (...args: unknown[]) => writeLog("LOG", args),
|
|
55
|
+
info: (...args: unknown[]) => writeLog("INFO", args),
|
|
56
|
+
warn: (...args: unknown[]) => writeLog("WARN", args),
|
|
57
|
+
error: (...args: unknown[]) => writeLog("ERROR", args),
|
|
58
|
+
|
|
59
|
+
/** Clear the log file */
|
|
60
|
+
clear: () => {
|
|
61
|
+
if (!ENABLED) return;
|
|
62
|
+
try {
|
|
63
|
+
ensureLogDir();
|
|
64
|
+
fs.writeFileSync(LOG_FILE, "");
|
|
65
|
+
} catch {
|
|
66
|
+
// Silently fail
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for formatting display values in the UI.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { TodoItem } from "../types";
|
|
6
|
+
import { REASONING_ANIMATION } from "../ui/constants";
|
|
7
|
+
|
|
8
|
+
const MAX_TOOL_INPUT_LINES = 10;
|
|
9
|
+
const MAX_TOOL_INPUT_LINE_CHARS = 140;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format reasoning content as a single-line preview for ticker/history.
|
|
13
|
+
*/
|
|
14
|
+
export function formatReasoningPreview(content: string): string {
|
|
15
|
+
const normalized = content.replace(/\n/g, " ");
|
|
16
|
+
if (!normalized) return "";
|
|
17
|
+
if (normalized.length <= REASONING_ANIMATION.LINE_WIDTH) return normalized;
|
|
18
|
+
return normalized.slice(-REASONING_ANIMATION.LINE_WIDTH);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if content has any non-whitespace characters.
|
|
23
|
+
*/
|
|
24
|
+
export function hasVisibleText(content: string): boolean {
|
|
25
|
+
return content.trim().length > 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ElapsedFormatStyle = "compact" | "detailed";
|
|
29
|
+
|
|
30
|
+
interface FormatElapsedOptions {
|
|
31
|
+
style?: ElapsedFormatStyle;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Format elapsed time from milliseconds into s / m+s / h+m+s.
|
|
36
|
+
*/
|
|
37
|
+
export function formatElapsedTime(ms: number, options: FormatElapsedOptions = {}): string {
|
|
38
|
+
const style = options.style ?? "compact";
|
|
39
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
40
|
+
return style === "detailed" ? "0.01s" : "0s";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (style === "detailed") {
|
|
44
|
+
const seconds = ms / 1000;
|
|
45
|
+
if (seconds < 1) {
|
|
46
|
+
const clamped = Math.max(0.01, Math.round(seconds * 100) / 100);
|
|
47
|
+
return `${clamped.toFixed(2)}s`;
|
|
48
|
+
}
|
|
49
|
+
if (seconds < 10) {
|
|
50
|
+
const value = seconds.toFixed(1).replace(/\.0$/, "");
|
|
51
|
+
return `${value}s`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
56
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
57
|
+
|
|
58
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
59
|
+
const seconds = totalSeconds % 60;
|
|
60
|
+
if (totalMinutes < 60) {
|
|
61
|
+
return `${totalMinutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
65
|
+
const minutes = totalMinutes % 60;
|
|
66
|
+
return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Format a value for display in tool input
|
|
71
|
+
*/
|
|
72
|
+
export function formatValue(value: unknown): string {
|
|
73
|
+
if (value === null || value === undefined) return "";
|
|
74
|
+
if (typeof value === "string") return value;
|
|
75
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
if (value.length === 0) return "[]";
|
|
78
|
+
// For arrays of strings, show inline if short enough
|
|
79
|
+
if (value.every((v) => typeof v === "string")) {
|
|
80
|
+
const joined = value.join(", ");
|
|
81
|
+
if (joined.length < 80) return joined;
|
|
82
|
+
}
|
|
83
|
+
return `[${value.length} items]`;
|
|
84
|
+
}
|
|
85
|
+
return JSON.stringify(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format tool input as display lines
|
|
90
|
+
*/
|
|
91
|
+
export function formatToolInputLines(input: unknown): string[] {
|
|
92
|
+
try {
|
|
93
|
+
if (typeof input !== "object" || input === null) {
|
|
94
|
+
return [String(input)];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const lines: string[] = [];
|
|
98
|
+
for (const [key, value] of Object.entries(input)) {
|
|
99
|
+
const formatted = formatValue(value);
|
|
100
|
+
const line = formatted ? `${key}: ${formatted}` : key;
|
|
101
|
+
// Truncate long lines
|
|
102
|
+
if (line.length > MAX_TOOL_INPUT_LINE_CHARS) {
|
|
103
|
+
lines.push(`${line.slice(0, MAX_TOOL_INPUT_LINE_CHARS - 1)}…`);
|
|
104
|
+
} else {
|
|
105
|
+
lines.push(line);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (lines.length > MAX_TOOL_INPUT_LINES) {
|
|
110
|
+
return [...lines.slice(0, MAX_TOOL_INPUT_LINES), "… (truncated)"];
|
|
111
|
+
}
|
|
112
|
+
return lines;
|
|
113
|
+
} catch (error: unknown) {
|
|
114
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
115
|
+
return [`[error: ${err.message}]`];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Todo input structure for the todoManager tool
|
|
121
|
+
*/
|
|
122
|
+
export interface TodoInput {
|
|
123
|
+
action: "write" | "update" | "list";
|
|
124
|
+
todos?: Array<{ content: string; status?: string }>;
|
|
125
|
+
index?: number;
|
|
126
|
+
status?: string;
|
|
127
|
+
sessionId?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Formatted todo item with display info
|
|
132
|
+
*/
|
|
133
|
+
export interface FormattedTodoItem {
|
|
134
|
+
text: string;
|
|
135
|
+
status: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const STATUS_ICON: Record<string, string> = {
|
|
139
|
+
pending: "○",
|
|
140
|
+
in_progress: "◐",
|
|
141
|
+
completed: "●",
|
|
142
|
+
cancelled: "✕",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Format a single todo item for display
|
|
147
|
+
*/
|
|
148
|
+
export function formatTodoItem(todo: { content: string; status?: string }, idx: number): FormattedTodoItem {
|
|
149
|
+
const status = todo.status || "pending";
|
|
150
|
+
const icon = STATUS_ICON[status] || "[ ]";
|
|
151
|
+
return {
|
|
152
|
+
text: `${icon} ${todo.content}`,
|
|
153
|
+
status,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Format todo list for display based on tool input.
|
|
159
|
+
* If a snapshot is provided, use it instead of fetching current state.
|
|
160
|
+
* @param input - The todo tool input
|
|
161
|
+
* @param snapshot - Optional snapshot of todos for historical display
|
|
162
|
+
* @param currentTodos - Current todos from the store (for live display of update/list actions)
|
|
163
|
+
*/
|
|
164
|
+
export function formatTodoDisplayLines(
|
|
165
|
+
input: TodoInput,
|
|
166
|
+
snapshot?: Array<{ content: string; status: string }>,
|
|
167
|
+
currentTodos: TodoItem[] = []
|
|
168
|
+
): FormattedTodoItem[] {
|
|
169
|
+
// If we have a snapshot, always use it (for historical display in conversation)
|
|
170
|
+
if (snapshot && snapshot.length > 0) {
|
|
171
|
+
return snapshot.map((todo, idx) => formatTodoItem(todo, idx));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (input.action === "write" && input.todos) {
|
|
175
|
+
return input.todos.map((todo, idx) => formatTodoItem(todo, idx));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (input.action === "update" || input.action === "list") {
|
|
179
|
+
// Use passed-in todos for live display
|
|
180
|
+
if (currentTodos.length === 0) {
|
|
181
|
+
return [{ text: "(no todos)", status: "pending" }];
|
|
182
|
+
}
|
|
183
|
+
// For update, apply the pending status change to display what it will look like
|
|
184
|
+
if (input.action === "update" && input.index !== undefined && input.status) {
|
|
185
|
+
const idx = input.index - 1;
|
|
186
|
+
return currentTodos.map((todo: TodoItem, i: number) => {
|
|
187
|
+
if (i === idx) {
|
|
188
|
+
return formatTodoItem({ content: todo.content, status: input.status }, i);
|
|
189
|
+
}
|
|
190
|
+
return formatTodoItem(todo, i);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
// For list or update without changes, just show current state
|
|
194
|
+
return currentTodos.map((todo: TodoItem, idx: number) => formatTodoItem(todo, idx));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return [{ text: "(unknown action)", status: "pending" }];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Type guard for TodoInput
|
|
202
|
+
*/
|
|
203
|
+
export function isTodoInput(input: unknown): input is TodoInput {
|
|
204
|
+
return (
|
|
205
|
+
typeof input === "object" &&
|
|
206
|
+
input !== null &&
|
|
207
|
+
"action" in input &&
|
|
208
|
+
typeof (input as TodoInput).action === "string"
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Format token count with K suffix for thousands
|
|
214
|
+
*/
|
|
215
|
+
export function formatTokenCount(count: number): string {
|
|
216
|
+
// if (count >= 1000) {
|
|
217
|
+
// return `${(count / 1000).toFixed(1)}k`;
|
|
218
|
+
// }
|
|
219
|
+
return String(count);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function formatContextWindowK(contextLength: number): string {
|
|
223
|
+
if (!Number.isFinite(contextLength) || contextLength <= 0) return "";
|
|
224
|
+
if (contextLength < 1000) return String(Math.floor(contextLength));
|
|
225
|
+
|
|
226
|
+
const asK = contextLength % 1024 === 0 ? contextLength / 1024 : Math.round(contextLength / 1000);
|
|
227
|
+
|
|
228
|
+
return `${asK}K`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Format price per 1M tokens for display.
|
|
233
|
+
*/
|
|
234
|
+
export function formatPrice(price: number): string {
|
|
235
|
+
if (price === 0) {
|
|
236
|
+
return "FREE";
|
|
237
|
+
}
|
|
238
|
+
if (price < 0.01) {
|
|
239
|
+
return `$${price.toFixed(4)}`;
|
|
240
|
+
}
|
|
241
|
+
return `$${price.toFixed(2)}`;
|
|
242
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export type JsRenderingUnavailableReason = "package-not-installed" | "binaries-missing";
|
|
7
|
+
|
|
8
|
+
export interface JsRenderingCapability {
|
|
9
|
+
available: boolean;
|
|
10
|
+
reason: string;
|
|
11
|
+
hint?: string;
|
|
12
|
+
unavailableReason?: JsRenderingUnavailableReason;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getGlobalNodeModulesPath(): string | null {
|
|
16
|
+
try {
|
|
17
|
+
return execSync("npm root -g", { encoding: "utf-8" }).trim();
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function tryImportPlaywright(): Promise<any | null> {
|
|
24
|
+
try {
|
|
25
|
+
return await import("playwright");
|
|
26
|
+
} catch {}
|
|
27
|
+
|
|
28
|
+
const globalPath = getGlobalNodeModulesPath();
|
|
29
|
+
if (!globalPath) return null;
|
|
30
|
+
|
|
31
|
+
const playwrightPath = path.join(globalPath, "playwright");
|
|
32
|
+
if (!fs.existsSync(playwrightPath)) return null;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
return require(playwrightPath);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function detectLocalPlaywrightChromium(): Promise<JsRenderingCapability> {
|
|
43
|
+
const playwright = await tryImportPlaywright();
|
|
44
|
+
|
|
45
|
+
if (!playwright) {
|
|
46
|
+
return {
|
|
47
|
+
available: false,
|
|
48
|
+
reason: "Playwright is not installed.",
|
|
49
|
+
hint: "Run: npm i -g playwright && npx playwright install chromium",
|
|
50
|
+
unavailableReason: "package-not-installed",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const executablePath = playwright.chromium.executablePath();
|
|
56
|
+
if (!executablePath || !fs.existsSync(executablePath)) {
|
|
57
|
+
return {
|
|
58
|
+
available: false,
|
|
59
|
+
reason: "Playwright Chromium binaries are not installed.",
|
|
60
|
+
hint: "Run: npx playwright install chromium",
|
|
61
|
+
unavailableReason: "binaries-missing",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
available: true,
|
|
67
|
+
reason: "Playwright Chromium is installed.",
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
return {
|
|
71
|
+
available: false,
|
|
72
|
+
reason: "Playwright Chromium binaries are not installed.",
|
|
73
|
+
hint: "Run: npx playwright install chromium",
|
|
74
|
+
unavailableReason: "binaries-missing",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown table formatter based on OpenCode's table alignment fix.
|
|
3
|
+
* Ensures cell widths match what the terminal actually renders with concealment.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const widthCache = new Map<string, number>();
|
|
7
|
+
let cacheOperationCount = 0;
|
|
8
|
+
|
|
9
|
+
export interface MarkdownTableFormatOptions {
|
|
10
|
+
maxWidth?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatMarkdownTables(text: string, options: MarkdownTableFormatOptions = {}): string {
|
|
14
|
+
const lines = text.split("\n");
|
|
15
|
+
const result: string[] = [];
|
|
16
|
+
let i = 0;
|
|
17
|
+
|
|
18
|
+
while (i < lines.length) {
|
|
19
|
+
const line = lines[i];
|
|
20
|
+
if (line === undefined) break;
|
|
21
|
+
|
|
22
|
+
if (isTableRow(line)) {
|
|
23
|
+
const tableLines: string[] = [line];
|
|
24
|
+
i++;
|
|
25
|
+
|
|
26
|
+
while (i < lines.length) {
|
|
27
|
+
const nextLine = lines[i];
|
|
28
|
+
if (nextLine === undefined || !isTableRow(nextLine)) break;
|
|
29
|
+
tableLines.push(nextLine);
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (isValidTable(tableLines)) {
|
|
34
|
+
result.push(...formatTable(tableLines, options));
|
|
35
|
+
} else {
|
|
36
|
+
result.push(...tableLines);
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
result.push(line);
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
incrementOperationCount();
|
|
45
|
+
return result.join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isTableRow(line: string): boolean {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
return trimmed.startsWith("|") && trimmed.endsWith("|") && trimmed.split("|").length > 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isSeparatorRow(line: string): boolean {
|
|
54
|
+
const trimmed = line.trim();
|
|
55
|
+
if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return false;
|
|
56
|
+
const cells = trimmed.split("|").slice(1, -1);
|
|
57
|
+
return cells.length > 0 && cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isValidTable(lines: string[]): boolean {
|
|
61
|
+
if (lines.length < 2) return false;
|
|
62
|
+
|
|
63
|
+
const rows = lines.map((line) =>
|
|
64
|
+
line
|
|
65
|
+
.split("|")
|
|
66
|
+
.slice(1, -1)
|
|
67
|
+
.map((cell) => cell.trim())
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const firstRow = rows[0];
|
|
71
|
+
if (!firstRow || firstRow.length === 0) return false;
|
|
72
|
+
|
|
73
|
+
const firstRowCellCount = firstRow.length;
|
|
74
|
+
const allSameColumnCount = rows.every((row) => row.length === firstRowCellCount);
|
|
75
|
+
if (!allSameColumnCount) return false;
|
|
76
|
+
|
|
77
|
+
const hasSeparator = lines.some((line) => isSeparatorRow(line));
|
|
78
|
+
return hasSeparator;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatTable(lines: string[], options: MarkdownTableFormatOptions): string[] {
|
|
82
|
+
const separatorIndices = new Set<number>();
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
const line = lines[i];
|
|
85
|
+
if (line && isSeparatorRow(line)) separatorIndices.add(i);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const rows = lines.map((line) =>
|
|
89
|
+
line
|
|
90
|
+
.split("|")
|
|
91
|
+
.slice(1, -1)
|
|
92
|
+
.map((cell) => cell.trim())
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (rows.length === 0) return lines;
|
|
96
|
+
|
|
97
|
+
const colCount = Math.max(...rows.map((row) => row.length));
|
|
98
|
+
|
|
99
|
+
const colAlignments: Array<"left" | "center" | "right"> = Array(colCount).fill("left");
|
|
100
|
+
for (const rowIndex of separatorIndices) {
|
|
101
|
+
const row = rows[rowIndex];
|
|
102
|
+
if (!row) continue;
|
|
103
|
+
for (let col = 0; col < row.length; col++) {
|
|
104
|
+
const cell = row[col] ?? "";
|
|
105
|
+
colAlignments[col] = getAlignment(cell);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const colWidths: number[] = Array(colCount).fill(3);
|
|
110
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
111
|
+
if (separatorIndices.has(rowIndex)) continue;
|
|
112
|
+
const row = rows[rowIndex];
|
|
113
|
+
if (!row) continue;
|
|
114
|
+
for (let col = 0; col < row.length; col++) {
|
|
115
|
+
const displayWidth = calculateDisplayWidth(row[col] ?? "");
|
|
116
|
+
const currentWidth = colWidths[col] ?? 0;
|
|
117
|
+
colWidths[col] = Math.max(currentWidth, displayWidth);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const totalWidth =
|
|
122
|
+
2 + colWidths.reduce((sum, width) => sum + width, 0) + (colCount > 1 ? 3 * (colCount - 1) : 0) + 2;
|
|
123
|
+
|
|
124
|
+
if (options.maxWidth && totalWidth > options.maxWidth) {
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return rows.map((row, rowIndex) => {
|
|
129
|
+
const cells: string[] = [];
|
|
130
|
+
for (let col = 0; col < colCount; col++) {
|
|
131
|
+
const cell = row[col] ?? "";
|
|
132
|
+
const align = colAlignments[col] ?? "left";
|
|
133
|
+
|
|
134
|
+
if (separatorIndices.has(rowIndex)) {
|
|
135
|
+
cells.push(formatSeparatorCell(colWidths[col] ?? 3));
|
|
136
|
+
} else {
|
|
137
|
+
cells.push(padCell(cell, colWidths[col] ?? 3, align));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (separatorIndices.has(rowIndex)) {
|
|
141
|
+
return "╞═" + cells.join("═╪═") + "═╡";
|
|
142
|
+
}
|
|
143
|
+
return "│ " + cells.join(" │ ") + " │";
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getAlignment(delimiterCell: string): "left" | "center" | "right" {
|
|
148
|
+
const trimmed = delimiterCell.trim();
|
|
149
|
+
const hasLeftColon = trimmed.startsWith(":");
|
|
150
|
+
const hasRightColon = trimmed.endsWith(":");
|
|
151
|
+
|
|
152
|
+
if (hasLeftColon && hasRightColon) return "center";
|
|
153
|
+
if (hasRightColon) return "right";
|
|
154
|
+
return "left";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function calculateDisplayWidth(text: string): number {
|
|
158
|
+
if (widthCache.has(text)) {
|
|
159
|
+
return widthCache.get(text)!;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const width = getStringWidth(text);
|
|
163
|
+
widthCache.set(text, width);
|
|
164
|
+
return width;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getStringWidth(text: string): number {
|
|
168
|
+
// Strip markdown symbols for concealment mode.
|
|
169
|
+
// Content inside backticks should preserve its inner markdown symbols.
|
|
170
|
+
|
|
171
|
+
const codeBlocks: string[] = [];
|
|
172
|
+
const textWithPlaceholders = text.replace(/`(.+?)`/g, (_match, content) => {
|
|
173
|
+
codeBlocks.push(content);
|
|
174
|
+
return `__DAEMON_CODE_BLOCK_${codeBlocks.length - 1}__`;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
let visualText = textWithPlaceholders;
|
|
178
|
+
let previousText = "";
|
|
179
|
+
|
|
180
|
+
while (visualText !== previousText) {
|
|
181
|
+
previousText = visualText;
|
|
182
|
+
visualText = visualText
|
|
183
|
+
.replace(/\*\*\*(.+?)\*\*\*/g, "$1")
|
|
184
|
+
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
185
|
+
.replace(/\*(.+?)\*/g, "$1")
|
|
186
|
+
.replace(/~~(.+?)~~/g, "$1")
|
|
187
|
+
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1")
|
|
188
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)")
|
|
189
|
+
.replace(/\[([^\]]+)\]\s*\[([^\]]*)\]/g, "$1")
|
|
190
|
+
.replace(/\[(g\d+|\d+)\]/g, "$1");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
visualText = visualText.replace(/__DAEMON_CODE_BLOCK_(\d+)__/g, (_match, index) => {
|
|
194
|
+
return codeBlocks[Number(index)] ?? "";
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const bun = (globalThis as { Bun?: { stringWidth: (value: string) => number } }).Bun;
|
|
198
|
+
if (bun?.stringWidth) {
|
|
199
|
+
return bun.stringWidth(visualText);
|
|
200
|
+
}
|
|
201
|
+
return Array.from(visualText).length;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function padCell(text: string, width: number, align: "left" | "center" | "right"): string {
|
|
205
|
+
const displayWidth = calculateDisplayWidth(text);
|
|
206
|
+
const totalPadding = Math.max(0, width - displayWidth);
|
|
207
|
+
|
|
208
|
+
if (align === "center") {
|
|
209
|
+
const leftPad = Math.floor(totalPadding / 2);
|
|
210
|
+
const rightPad = totalPadding - leftPad;
|
|
211
|
+
return " ".repeat(leftPad) + text + " ".repeat(rightPad);
|
|
212
|
+
}
|
|
213
|
+
if (align === "right") {
|
|
214
|
+
return " ".repeat(totalPadding) + text;
|
|
215
|
+
}
|
|
216
|
+
return text + " ".repeat(totalPadding);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function formatSeparatorCell(width: number): string {
|
|
220
|
+
return "═".repeat(width);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function incrementOperationCount() {
|
|
224
|
+
cacheOperationCount++;
|
|
225
|
+
|
|
226
|
+
if (cacheOperationCount > 100 || widthCache.size > 1000) {
|
|
227
|
+
cleanupCache();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function cleanupCache() {
|
|
232
|
+
widthCache.clear();
|
|
233
|
+
cacheOperationCount = 0;
|
|
234
|
+
}
|