diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 85d632d106..15644c7a8f 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -3,7 +3,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import type { GatewayServiceRuntime } from "./service-runtime.js"; -import { colorize, isRich, theme } from "../terminal/theme.js"; import { formatGatewayServiceDescription, GATEWAY_LAUNCH_AGENT_LABEL, @@ -14,16 +13,11 @@ import { buildLaunchAgentPlist as buildLaunchAgentPlistImpl, readLaunchAgentProgramArgumentsFromFile, } from "./launchd-plist.js"; +import { formatLine, toPosixPath } from "./output.js"; import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; const execFileAsync = promisify(execFile); -const toPosixPath = (value: string) => value.replace(/\\/g, "/"); - -const formatLine = (label: string, value: string) => { - const rich = isRich(); - return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; -}; function resolveLaunchAgentLabel(args?: { env?: Record }): string { const envLabel = args?.env?.OPENCLAW_LAUNCHD_LABEL?.trim(); diff --git a/src/daemon/output.ts b/src/daemon/output.ts new file mode 100644 index 0000000000..900f61184f --- /dev/null +++ b/src/daemon/output.ts @@ -0,0 +1,8 @@ +import { colorize, isRich, theme } from "../terminal/theme.js"; + +export const toPosixPath = (value: string) => value.replace(/\\/g, "/"); + +export function formatLine(label: string, value: string): string { + const rich = isRich(); + return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; +} diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index eb66626916..7b5c4f6bd2 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -1,17 +1,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { GatewayServiceRuntime } from "./service-runtime.js"; -import { colorize, isRich, theme } from "../terminal/theme.js"; import { formatGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; +import { formatLine } from "./output.js"; import { resolveGatewayStateDir } from "./paths.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; import { execSchtasks } from "./schtasks-exec.js"; -const formatLine = (label: string, value: string) => { - const rich = isRich(); - return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; -}; - function resolveTaskName(env: Record): string { const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim(); if (override) { diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 1a5dd72b96..e4d45368d9 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -3,12 +3,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import type { GatewayServiceRuntime } from "./service-runtime.js"; -import { colorize, isRich, theme } from "../terminal/theme.js"; import { formatGatewayServiceDescription, LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, resolveGatewaySystemdServiceName, } from "./constants.js"; +import { formatLine, toPosixPath } from "./output.js"; import { resolveHomeDir } from "./paths.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; import { @@ -23,12 +23,6 @@ import { } from "./systemd-unit.js"; const execFileAsync = promisify(execFile); -const toPosixPath = (value: string) => value.replace(/\\/g, "/"); - -const formatLine = (label: string, value: string) => { - const rich = isRich(); - return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; -}; function resolveSystemdUnitPathForName( env: Record, diff --git a/src/gateway/chat-sanitize.ts b/src/gateway/chat-sanitize.ts index d175c2e169..5f9e8f9828 100644 --- a/src/gateway/chat-sanitize.ts +++ b/src/gateway/chat-sanitize.ts @@ -1,52 +1,6 @@ -const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/; -const ENVELOPE_CHANNELS = [ - "WebChat", - "WhatsApp", - "Telegram", - "Signal", - "Slack", - "Discord", - "Google Chat", - "iMessage", - "Teams", - "Matrix", - "Zalo", - "Zalo Personal", - "BlueBubbles", -]; +import { stripEnvelope, stripMessageIdHints } from "../shared/chat-envelope.js"; -const MESSAGE_ID_LINE = /^\s*\[message_id:\s*[^\]]+\]\s*$/i; - -function looksLikeEnvelopeHeader(header: string): boolean { - if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) { - return true; - } - if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) { - return true; - } - return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); -} - -export function stripEnvelope(text: string): string { - const match = text.match(ENVELOPE_PREFIX); - if (!match) { - return text; - } - const header = match[1] ?? ""; - if (!looksLikeEnvelopeHeader(header)) { - return text; - } - return text.slice(match[0].length); -} - -function stripMessageIdHints(text: string): string { - if (!text.includes("[message_id:")) { - return text; - } - const lines = text.split(/\r?\n/); - const filtered = lines.filter((line) => !MESSAGE_ID_LINE.test(line)); - return filtered.length === lines.length ? text : filtered.join("\n"); -} +export { stripEnvelope }; function stripEnvelopeFromContent(content: unknown[]): { content: unknown[]; changed: boolean } { let changed = false; diff --git a/src/shared/chat-envelope.ts b/src/shared/chat-envelope.ts new file mode 100644 index 0000000000..8ab53ed9e2 --- /dev/null +++ b/src/shared/chat-envelope.ts @@ -0,0 +1,49 @@ +const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/; +const ENVELOPE_CHANNELS = [ + "WebChat", + "WhatsApp", + "Telegram", + "Signal", + "Slack", + "Discord", + "Google Chat", + "iMessage", + "Teams", + "Matrix", + "Zalo", + "Zalo Personal", + "BlueBubbles", +]; + +const MESSAGE_ID_LINE = /^\s*\[message_id:\s*[^\]]+\]\s*$/i; + +function looksLikeEnvelopeHeader(header: string): boolean { + if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) { + return true; + } + if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) { + return true; + } + return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); +} + +export function stripEnvelope(text: string): string { + const match = text.match(ENVELOPE_PREFIX); + if (!match) { + return text; + } + const header = match[1] ?? ""; + if (!looksLikeEnvelopeHeader(header)) { + return text; + } + return text.slice(match[0].length); +} + +export function stripMessageIdHints(text: string): string { + if (!text.includes("[message_id:")) { + return text; + } + const lines = text.split(/\r?\n/); + const filtered = lines.filter((line) => !MESSAGE_ID_LINE.test(line)); + return filtered.length === lines.length ? text : filtered.join("\n"); +} diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts index c4895dd6c3..d36ead000f 100644 --- a/ui/src/ui/chat/message-extract.ts +++ b/ui/src/ui/chat/message-extract.ts @@ -1,46 +1,9 @@ +import { stripEnvelope } from "../../../../src/shared/chat-envelope.js"; import { stripThinkingTags } from "../format.ts"; -const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/; -const ENVELOPE_CHANNELS = [ - "WebChat", - "WhatsApp", - "Telegram", - "Signal", - "Slack", - "Discord", - "iMessage", - "Teams", - "Matrix", - "Zalo", - "Zalo Personal", - "BlueBubbles", -]; - const textCache = new WeakMap(); const thinkingCache = new WeakMap(); -function looksLikeEnvelopeHeader(header: string): boolean { - if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) { - return true; - } - if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) { - return true; - } - return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); -} - -export function stripEnvelope(text: string): string { - const match = text.match(ENVELOPE_PREFIX); - if (!match) { - return text; - } - const header = match[1] ?? ""; - if (!looksLikeEnvelopeHeader(header)) { - return text; - } - return text.slice(match[0].length); -} - export function extractText(message: unknown): string | null { const m = message as Record; const role = typeof m.role === "string" ? m.role : ""; diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index ac1884f17c..063a94f93f 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -11,6 +11,11 @@ import { TOOL_SECTIONS, } from "./agents-utils.ts"; import { groupSkills } from "./skills-grouping.ts"; +import { + computeSkillMissing, + computeSkillReasons, + renderSkillStatusChips, +} from "./skills-shared.ts"; export function renderAgentTools(params: { agentId: string; @@ -436,37 +441,14 @@ function renderAgentSkillRow( }, ) { const enabled = params.usingAllowlist ? params.allowSet.has(skill.name) : true; - const missing = [ - ...skill.missing.bins.map((b) => `bin:${b}`), - ...skill.missing.env.map((e) => `env:${e}`), - ...skill.missing.config.map((c) => `config:${c}`), - ...skill.missing.os.map((o) => `os:${o}`), - ]; - const reasons: string[] = []; - if (skill.disabled) { - reasons.push("disabled"); - } - if (skill.blockedByAllowlist) { - reasons.push("blocked by allowlist"); - } + const missing = computeSkillMissing(skill); + const reasons = computeSkillReasons(skill); return html`
${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
${skill.description}
-
- ${skill.source} - - ${skill.eligible ? "eligible" : "blocked"} - - ${ - skill.disabled - ? html` - disabled - ` - : nothing - } -
+ ${renderSkillStatusChips({ skill })} ${ missing.length > 0 ? html`
Missing: ${missing.join(", ")}
` diff --git a/ui/src/ui/views/skills-shared.ts b/ui/src/ui/views/skills-shared.ts new file mode 100644 index 0000000000..e19f27c283 --- /dev/null +++ b/ui/src/ui/views/skills-shared.ts @@ -0,0 +1,52 @@ +import { html, nothing } from "lit"; +import type { SkillStatusEntry } from "../types.ts"; + +export function computeSkillMissing(skill: SkillStatusEntry): string[] { + return [ + ...skill.missing.bins.map((b) => `bin:${b}`), + ...skill.missing.env.map((e) => `env:${e}`), + ...skill.missing.config.map((c) => `config:${c}`), + ...skill.missing.os.map((o) => `os:${o}`), + ]; +} + +export function computeSkillReasons(skill: SkillStatusEntry): string[] { + const reasons: string[] = []; + if (skill.disabled) { + reasons.push("disabled"); + } + if (skill.blockedByAllowlist) { + reasons.push("blocked by allowlist"); + } + return reasons; +} + +export function renderSkillStatusChips(params: { + skill: SkillStatusEntry; + showBundledBadge?: boolean; +}) { + const skill = params.skill; + const showBundledBadge = Boolean(params.showBundledBadge); + return html` +
+ ${skill.source} + ${ + showBundledBadge + ? html` + bundled + ` + : nothing + } + + ${skill.eligible ? "eligible" : "blocked"} + + ${ + skill.disabled + ? html` + disabled + ` + : nothing + } +
+ `; +} diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index 4a82228896..9077a30df3 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -3,6 +3,11 @@ import type { SkillMessageMap } from "../controllers/skills.ts"; import type { SkillStatusEntry, SkillStatusReport } from "../types.ts"; import { clampText } from "../format.ts"; import { groupSkills } from "./skills-grouping.ts"; +import { + computeSkillMissing, + computeSkillReasons, + renderSkillStatusChips, +} from "./skills-shared.ts"; export type SkillsProps = { loading: boolean; @@ -94,19 +99,8 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { const message = props.messages[skill.skillKey] ?? null; const canInstall = skill.install.length > 0 && skill.missing.bins.length > 0; const showBundledBadge = Boolean(skill.bundled && skill.source !== "openclaw-bundled"); - const missing = [ - ...skill.missing.bins.map((b) => `bin:${b}`), - ...skill.missing.env.map((e) => `env:${e}`), - ...skill.missing.config.map((c) => `config:${c}`), - ...skill.missing.os.map((o) => `os:${o}`), - ]; - const reasons: string[] = []; - if (skill.disabled) { - reasons.push("disabled"); - } - if (skill.blockedByAllowlist) { - reasons.push("blocked by allowlist"); - } + const missing = computeSkillMissing(skill); + const reasons = computeSkillReasons(skill); return html`
@@ -114,26 +108,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { ${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
${clampText(skill.description, 140)}
-
- ${skill.source} - ${ - showBundledBadge - ? html` - bundled - ` - : nothing - } - - ${skill.eligible ? "eligible" : "blocked"} - - ${ - skill.disabled - ? html` - disabled - ` - : nothing - } -
+ ${renderSkillStatusChips({ skill, showBundledBadge })} ${ missing.length > 0 ? html`