diff --git a/CHANGELOG.md b/CHANGELOG.md index 48fd42952..964544789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,7 +63,7 @@ Docs: https://docs.clawd.bot - Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting. - Agents: clarify node_modules read-only guidance in agent instructions. - TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07. -- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. +- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512. ### Fixes - UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba. diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index dd6394fd8..cb63eb7ec 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -13,578 +13,581 @@ import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; export type PromptMode = "full" | "minimal" | "none"; function buildSkillsSection(params: { - skillsPrompt?: string; - isMinimal: boolean; - readToolName: string; + skillsPrompt?: string; + isMinimal: boolean; + readToolName: string; }) { - const trimmed = params.skillsPrompt?.trim(); - if (!trimmed || params.isMinimal) return []; - return [ - "## Skills (mandatory)", - "Before replying: scan entries.", - `- If exactly one skill clearly applies: read its SKILL.md at with \`${params.readToolName}\`, then follow it.`, - "- If multiple could apply: choose the most specific one, then read/follow it.", - "- If none clearly apply: do not read any SKILL.md.", - "Constraints: never read more than one skill up front; only read after selecting.", - trimmed, - "", - ]; + const trimmed = params.skillsPrompt?.trim(); + if (!trimmed || params.isMinimal) return []; + return [ + "## Skills (mandatory)", + "Before replying: scan entries.", + `- If exactly one skill clearly applies: read its SKILL.md at with \`${params.readToolName}\`, then follow it.`, + "- If multiple could apply: choose the most specific one, then read/follow it.", + "- If none clearly apply: do not read any SKILL.md.", + "Constraints: never read more than one skill up front; only read after selecting.", + trimmed, + "", + ]; } function buildMemorySection(params: { isMinimal: boolean; availableTools: Set }) { - if (params.isMinimal) return []; - if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) { - return []; - } - return [ - "## Memory Recall", - "Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.", - "", - ]; + if (params.isMinimal) return []; + if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) { + return []; + } + return [ + "## Memory Recall", + "Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.", + "", + ]; } function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) { - if (!ownerLine || isMinimal) return []; - return ["## User Identity", ownerLine, ""]; + if (!ownerLine || isMinimal) return []; + return ["## User Identity", ownerLine, ""]; } function buildTimeSection(params: { - userTimezone?: string; - userTime?: string; - userTimeFormat?: ResolvedTimeFormat; + userTimezone?: string; + userTime?: string; + userTimeFormat?: ResolvedTimeFormat; }) { - if (!params.userTimezone && !params.userTime) return []; - return [ - "## Current Date & Time", - params.userTime - ? `${params.userTime} (${params.userTimezone ?? "unknown"})` - : `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`, - params.userTimeFormat - ? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}` - : "", - "", - ]; + if (!params.userTimezone && !params.userTime) return []; + return [ + "## Current Date & Time", + params.userTime + ? `${params.userTime} (${params.userTimezone ?? "unknown"})` + : `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`, + params.userTimeFormat + ? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}` + : "", + "", + ]; } function buildReplyTagsSection(isMinimal: boolean) { - if (isMinimal) return []; - return [ - "## Reply Tags", - "To request a native reply/quote on supported surfaces, include one tag in your reply:", - "- [[reply_to_current]] replies to the triggering message.", - "- [[reply_to:]] replies to a specific message id when you have it.", - "Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).", - "Tags are stripped before sending; support depends on the current channel config.", - "", - ]; + if (isMinimal) return []; + return [ + "## Reply Tags", + "To request a native reply/quote on supported surfaces, include one tag in your reply:", + "- [[reply_to_current]] replies to the triggering message.", + "- [[reply_to:]] replies to a specific message id when you have it.", + "Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).", + "Tags are stripped before sending; support depends on the current channel config.", + "", + ]; } function buildMessagingSection(params: { - isMinimal: boolean; - availableTools: Set; - messageChannelOptions: string; - inlineButtonsEnabled: boolean; - runtimeChannel?: string; + isMinimal: boolean; + availableTools: Set; + messageChannelOptions: string; + inlineButtonsEnabled: boolean; + runtimeChannel?: string; }) { - if (params.isMinimal) return []; - return [ - "## Messaging", - "- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)", - "- Cross-session messaging → use sessions_send(sessionKey, message)", - "- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.", - params.availableTools.has("message") - ? [ - "", - "### message tool", - "- Use `message` for proactive sends + channel actions (polls, reactions, etc.).", - "- For `action=send`, include `to` and `message`.", - `- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`, - `- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`, - params.inlineButtonsEnabled - ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." - : params.runtimeChannel - ? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").` - : "", - ] - .filter(Boolean) - .join("\n") - : "", - "", - ]; + if (params.isMinimal) return []; + return [ + "## Messaging", + "- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)", + "- Cross-session messaging → use sessions_send(sessionKey, message)", + "- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.", + params.availableTools.has("message") + ? [ + "", + "### message tool", + "- Use `message` for proactive sends + channel actions (polls, reactions, etc.).", + "- For `action=send`, include `to` and `message`.", + `- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`, + `- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`, + params.inlineButtonsEnabled + ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." + : params.runtimeChannel + ? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").` + : "", + ] + .filter(Boolean) + .join("\n") + : "", + "", + ]; } function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) { - const docsPath = params.docsPath?.trim(); - if (!docsPath || params.isMinimal) return []; - return [ - "## Documentation", - `Clawdbot docs: ${docsPath}`, - "Mirror: https://docs.clawd.bot", - "Source: https://github.com/clawdbot/clawdbot", - "Community: https://discord.com/invite/clawd", - "Find new skills: https://clawdhub.com", - "For Clawdbot behavior, commands, config, or architecture: consult local docs first.", - "When diagnosing issues, run `clawdbot status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).", - "", - ]; + const docsPath = params.docsPath?.trim(); + if (!docsPath || params.isMinimal) return []; + return [ + "## Documentation", + `Clawdbot docs: ${docsPath}`, + "Mirror: https://docs.clawd.bot", + "Source: https://github.com/clawdbot/clawdbot", + "Community: https://discord.com/invite/clawd", + "Find new skills: https://clawdhub.com", + "For Clawdbot behavior, commands, config, or architecture: consult local docs first.", + "When diagnosing issues, run `clawdbot status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).", + "", + ]; } export function buildAgentSystemPrompt(params: { - workspaceDir: string; - defaultThinkLevel?: ThinkLevel; - reasoningLevel?: ReasoningLevel; - extraSystemPrompt?: string; - ownerNumbers?: string[]; - reasoningTagHint?: boolean; - toolNames?: string[]; - toolSummaries?: Record; - modelAliasLines?: string[]; - userTimezone?: string; - userTime?: string; - userTimeFormat?: ResolvedTimeFormat; - contextFiles?: EmbeddedContextFile[]; - skillsPrompt?: string; - heartbeatPrompt?: string; - docsPath?: string; - /** Controls which hardcoded sections to include. Defaults to "full". */ - promptMode?: PromptMode; - runtimeInfo?: { - agentId?: string; - host?: string; - os?: string; - arch?: string; - node?: string; - model?: string; - channel?: string; - capabilities?: string[]; - }; - sandboxInfo?: { - enabled: boolean; - workspaceDir?: string; - workspaceAccess?: "none" | "ro" | "rw"; - agentWorkspaceMount?: string; - browserControlUrl?: string; - browserNoVncUrl?: string; - hostBrowserAllowed?: boolean; - allowedControlUrls?: string[]; - allowedControlHosts?: string[]; - allowedControlPorts?: number[]; - elevated?: { - allowed: boolean; - defaultLevel: "on" | "off"; - }; - }; - /** Reaction guidance for the agent (for Telegram minimal/extensive modes). */ - reactionGuidance?: { - level: "minimal" | "extensive"; - channel: string; - }; + workspaceDir: string; + defaultThinkLevel?: ThinkLevel; + reasoningLevel?: ReasoningLevel; + extraSystemPrompt?: string; + ownerNumbers?: string[]; + reasoningTagHint?: boolean; + toolNames?: string[]; + toolSummaries?: Record; + modelAliasLines?: string[]; + userTimezone?: string; + userTime?: string; + userTimeFormat?: ResolvedTimeFormat; + contextFiles?: EmbeddedContextFile[]; + skillsPrompt?: string; + heartbeatPrompt?: string; + docsPath?: string; + /** Controls which hardcoded sections to include. Defaults to "full". */ + promptMode?: PromptMode; + runtimeInfo?: { + agentId?: string; + host?: string; + os?: string; + arch?: string; + node?: string; + model?: string; + defaultModel?: string; + channel?: string; + capabilities?: string[]; + }; + sandboxInfo?: { + enabled: boolean; + workspaceDir?: string; + workspaceAccess?: "none" | "ro" | "rw"; + agentWorkspaceMount?: string; + browserControlUrl?: string; + browserNoVncUrl?: string; + hostBrowserAllowed?: boolean; + allowedControlUrls?: string[]; + allowedControlHosts?: string[]; + allowedControlPorts?: number[]; + elevated?: { + allowed: boolean; + defaultLevel: "on" | "off"; + }; + }; + /** Reaction guidance for the agent (for Telegram minimal/extensive modes). */ + reactionGuidance?: { + level: "minimal" | "extensive"; + channel: string; + }; }) { - const coreToolSummaries: Record = { - read: "Read file contents", - write: "Create or overwrite files", - edit: "Make precise edits to files", - apply_patch: "Apply multi-file patches", - grep: "Search file contents for patterns", - find: "Find files by glob pattern", - ls: "List directory contents", - exec: "Run shell commands (pty available for TTY-required CLIs)", - process: "Manage background exec sessions", - web_search: "Search the web (Brave API)", - web_fetch: "Fetch and extract readable content from a URL", - // Channel docking: add login tools here when a channel needs interactive linking. - browser: "Control web browser", - canvas: "Present/eval/snapshot the Canvas", - nodes: "List/describe/notify/camera/screen on paired nodes", - cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", - message: "Send messages and channel actions", - gateway: "Restart, apply config, or run updates on the running Clawdbot process", - agents_list: "List agent ids allowed for sessions_spawn", - sessions_list: "List other sessions (incl. sub-agents) with filters/last", - sessions_history: "Fetch history for another session/sub-agent", - sessions_send: "Send a message to another session/sub-agent", - sessions_spawn: "Spawn a sub-agent session", - session_status: - "Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); optional per-session model override", - image: "Analyze an image with the configured image model", - }; + const coreToolSummaries: Record = { + read: "Read file contents", + write: "Create or overwrite files", + edit: "Make precise edits to files", + apply_patch: "Apply multi-file patches", + grep: "Search file contents for patterns", + find: "Find files by glob pattern", + ls: "List directory contents", + exec: "Run shell commands (pty available for TTY-required CLIs)", + process: "Manage background exec sessions", + web_search: "Search the web (Brave API)", + web_fetch: "Fetch and extract readable content from a URL", + // Channel docking: add login tools here when a channel needs interactive linking. + browser: "Control web browser", + canvas: "Present/eval/snapshot the Canvas", + nodes: "List/describe/notify/camera/screen on paired nodes", + cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", + message: "Send messages and channel actions", + gateway: "Restart, apply config, or run updates on the running Clawdbot process", + agents_list: "List agent ids allowed for sessions_spawn", + sessions_list: "List other sessions (incl. sub-agents) with filters/last", + sessions_history: "Fetch history for another session/sub-agent", + sessions_send: "Send a message to another session/sub-agent", + sessions_spawn: "Spawn a sub-agent session", + session_status: + "Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); optional per-session model override", + image: "Analyze an image with the configured image model", + }; - const toolOrder = [ - "read", - "write", - "edit", - "apply_patch", - "grep", - "find", - "ls", - "exec", - "process", - "web_search", - "web_fetch", - "browser", - "canvas", - "nodes", - "cron", - "message", - "gateway", - "agents_list", - "sessions_list", - "sessions_history", - "sessions_send", - "session_status", - "image", - ]; + const toolOrder = [ + "read", + "write", + "edit", + "apply_patch", + "grep", + "find", + "ls", + "exec", + "process", + "web_search", + "web_fetch", + "browser", + "canvas", + "nodes", + "cron", + "message", + "gateway", + "agents_list", + "sessions_list", + "sessions_history", + "sessions_send", + "session_status", + "image", + ]; - const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim()); - const canonicalToolNames = rawToolNames.filter(Boolean); - // Preserve caller casing while deduping tool names by lowercase. - const canonicalByNormalized = new Map(); - for (const name of canonicalToolNames) { - const normalized = name.toLowerCase(); - if (!canonicalByNormalized.has(normalized)) { - canonicalByNormalized.set(normalized, name); - } - } - const resolveToolName = (normalized: string) => - canonicalByNormalized.get(normalized) ?? normalized; + const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim()); + const canonicalToolNames = rawToolNames.filter(Boolean); + // Preserve caller casing while deduping tool names by lowercase. + const canonicalByNormalized = new Map(); + for (const name of canonicalToolNames) { + const normalized = name.toLowerCase(); + if (!canonicalByNormalized.has(normalized)) { + canonicalByNormalized.set(normalized, name); + } + } + const resolveToolName = (normalized: string) => + canonicalByNormalized.get(normalized) ?? normalized; - const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase()); - const availableTools = new Set(normalizedTools); - const externalToolSummaries = new Map(); - for (const [key, value] of Object.entries(params.toolSummaries ?? {})) { - const normalized = key.trim().toLowerCase(); - if (!normalized || !value?.trim()) continue; - externalToolSummaries.set(normalized, value.trim()); - } - const extraTools = Array.from( - new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))), - ); - const enabledTools = toolOrder.filter((tool) => availableTools.has(tool)); - const toolLines = enabledTools.map((tool) => { - const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool); - const name = resolveToolName(tool); - return summary ? `- ${name}: ${summary}` : `- ${name}`; - }); - for (const tool of extraTools.sort()) { - const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool); - const name = resolveToolName(tool); - toolLines.push(summary ? `- ${name}: ${summary}` : `- ${name}`); - } + const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase()); + const availableTools = new Set(normalizedTools); + const externalToolSummaries = new Map(); + for (const [key, value] of Object.entries(params.toolSummaries ?? {})) { + const normalized = key.trim().toLowerCase(); + if (!normalized || !value?.trim()) continue; + externalToolSummaries.set(normalized, value.trim()); + } + const extraTools = Array.from( + new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))), + ); + const enabledTools = toolOrder.filter((tool) => availableTools.has(tool)); + const toolLines = enabledTools.map((tool) => { + const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool); + const name = resolveToolName(tool); + return summary ? `- ${name}: ${summary}` : `- ${name}`; + }); + for (const tool of extraTools.sort()) { + const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool); + const name = resolveToolName(tool); + toolLines.push(summary ? `- ${name}: ${summary}` : `- ${name}`); + } - const hasGateway = availableTools.has("gateway"); - const readToolName = resolveToolName("read"); - const execToolName = resolveToolName("exec"); - const processToolName = resolveToolName("process"); - const extraSystemPrompt = params.extraSystemPrompt?.trim(); - const ownerNumbers = (params.ownerNumbers ?? []).map((value) => value.trim()).filter(Boolean); - const ownerLine = - ownerNumbers.length > 0 - ? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user.` - : undefined; - const reasoningHint = params.reasoningTagHint - ? [ - "ALL internal reasoning MUST be inside ....", - "Do not output any analysis outside .", - "Format every reply as ... then ..., with no other text.", - "Only the final user-visible reply may appear inside .", - "Only text inside is shown to the user; everything else is discarded and never seen by the user.", - "Example:", - "Short internal reasoning.", - "Hey there! What would you like to do next?", - ].join(" ") - : undefined; - const reasoningLevel = params.reasoningLevel ?? "off"; - const userTimezone = params.userTimezone?.trim(); - const userTime = params.userTime?.trim(); - const skillsPrompt = params.skillsPrompt?.trim(); - const heartbeatPrompt = params.heartbeatPrompt?.trim(); - const heartbeatPromptLine = heartbeatPrompt - ? `Heartbeat prompt: ${heartbeatPrompt}` - : "Heartbeat prompt: (configured)"; - const runtimeInfo = params.runtimeInfo; - const runtimeChannel = runtimeInfo?.channel?.trim().toLowerCase(); - const runtimeCapabilities = (runtimeInfo?.capabilities ?? []) - .map((cap) => String(cap).trim()) - .filter(Boolean); - const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase())); - const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); - const messageChannelOptions = listDeliverableMessageChannels().join("|"); - const promptMode = params.promptMode ?? "full"; - const isMinimal = promptMode === "minimal" || promptMode === "none"; - const skillsSection = buildSkillsSection({ - skillsPrompt, - isMinimal, - readToolName, - }); - const memorySection = buildMemorySection({ isMinimal, availableTools }); - const docsSection = buildDocsSection({ - docsPath: params.docsPath, - isMinimal, - readToolName, - }); + const hasGateway = availableTools.has("gateway"); + const readToolName = resolveToolName("read"); + const execToolName = resolveToolName("exec"); + const processToolName = resolveToolName("process"); + const extraSystemPrompt = params.extraSystemPrompt?.trim(); + const ownerNumbers = (params.ownerNumbers ?? []).map((value) => value.trim()).filter(Boolean); + const ownerLine = + ownerNumbers.length > 0 + ? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user.` + : undefined; + const reasoningHint = params.reasoningTagHint + ? [ + "ALL internal reasoning MUST be inside ....", + "Do not output any analysis outside .", + "Format every reply as ... then ..., with no other text.", + "Only the final user-visible reply may appear inside .", + "Only text inside is shown to the user; everything else is discarded and never seen by the user.", + "Example:", + "Short internal reasoning.", + "Hey there! What would you like to do next?", + ].join(" ") + : undefined; + const reasoningLevel = params.reasoningLevel ?? "off"; + const userTimezone = params.userTimezone?.trim(); + const userTime = params.userTime?.trim(); + const skillsPrompt = params.skillsPrompt?.trim(); + const heartbeatPrompt = params.heartbeatPrompt?.trim(); + const heartbeatPromptLine = heartbeatPrompt + ? `Heartbeat prompt: ${heartbeatPrompt}` + : "Heartbeat prompt: (configured)"; + const runtimeInfo = params.runtimeInfo; + const runtimeChannel = runtimeInfo?.channel?.trim().toLowerCase(); + const runtimeCapabilities = (runtimeInfo?.capabilities ?? []) + .map((cap) => String(cap).trim()) + .filter(Boolean); + const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase())); + const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); + const messageChannelOptions = listDeliverableMessageChannels().join("|"); + const promptMode = params.promptMode ?? "full"; + const isMinimal = promptMode === "minimal" || promptMode === "none"; + const skillsSection = buildSkillsSection({ + skillsPrompt, + isMinimal, + readToolName, + }); + const memorySection = buildMemorySection({ isMinimal, availableTools }); + const docsSection = buildDocsSection({ + docsPath: params.docsPath, + isMinimal, + readToolName, + }); - // For "none" mode, return just the basic identity line - if (promptMode === "none") { - return "You are a personal assistant running inside Clawdbot."; - } + // For "none" mode, return just the basic identity line + if (promptMode === "none") { + return "You are a personal assistant running inside Clawdbot."; + } - const lines = [ - "You are a personal assistant running inside Clawdbot.", - "", - "## Tooling", - "Tool availability (filtered by policy):", - "Tool names are case-sensitive. Call tools exactly as listed.", - toolLines.length > 0 - ? toolLines.join("\n") - : [ - "Pi lists the standard tools above. This runtime enables:", - "- grep: search file contents for patterns", - "- find: find files by glob pattern", - "- ls: list directory contents", - "- apply_patch: apply multi-file patches", - `- ${execToolName}: run shell commands (supports background via yieldMs/background)`, - `- ${processToolName}: manage background exec sessions`, - "- browser: control clawd's dedicated browser", - "- canvas: present/eval/snapshot the Canvas", - "- nodes: list/describe/notify/camera/screen on paired nodes", - "- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", - "- sessions_list: list sessions", - "- sessions_history: fetch session history", - "- sessions_send: send to another session", - ].join("\n"), - "TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", - "If a task is more complex or takes longer, spawn a sub-agent. It will do the work for you and ping you when it's done. You can always check up on it.", - "", - "## Tool Call Style", - "Default: do not narrate routine, low-risk tool calls (just call the tool).", - "Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.", - "Keep narration brief and value-dense; avoid repeating obvious steps.", - "Use plain human language for narration unless in a technical context.", - "", - "## Clawdbot CLI Quick Reference", - "Clawdbot is controlled via subcommands. Do not invent commands.", - "To manage the Gateway daemon service (start/stop/restart):", - "- clawdbot daemon status", - "- clawdbot daemon start", - "- clawdbot daemon stop", - "- clawdbot daemon restart", - "If unsure, ask the user to run `clawdbot help` (or `clawdbot daemon --help`) and paste the output.", - "", - ...skillsSection, - ...memorySection, - // Skip self-update for subagent/none modes - hasGateway && !isMinimal ? "## Clawdbot Self-Update" : "", - hasGateway && !isMinimal - ? [ - "Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.", - "Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.", - "Actions: config.get, config.schema, config.apply (validate + write full config, then restart), update.run (update deps or git, then restart).", - "After restart, Clawdbot pings the last active session automatically.", - ].join("\n") - : "", - hasGateway && !isMinimal ? "" : "", - "", - // Skip model aliases for subagent/none modes - params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal - ? "## Model Aliases" - : "", - params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal - ? "Prefer aliases when specifying model overrides; full provider/model is also accepted." - : "", - params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal - ? params.modelAliasLines.join("\n") - : "", - params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "", - "## Workspace", - `Your working directory is: ${params.workspaceDir}`, - "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", - "", - ...docsSection, - params.sandboxInfo?.enabled ? "## Sandbox" : "", - params.sandboxInfo?.enabled - ? [ - "You are running in a sandboxed runtime (tools execute in Docker).", - "Some tools may be unavailable due to sandbox policy.", - "Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.", - params.sandboxInfo.workspaceDir - ? `Sandbox workspace: ${params.sandboxInfo.workspaceDir}` - : "", - params.sandboxInfo.workspaceAccess - ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${ - params.sandboxInfo.agentWorkspaceMount - ? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})` - : "" - }` - : "", - params.sandboxInfo.browserControlUrl - ? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}` - : "", - params.sandboxInfo.browserNoVncUrl - ? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}` - : "", - params.sandboxInfo.hostBrowserAllowed === true - ? "Host browser control: allowed." - : params.sandboxInfo.hostBrowserAllowed === false - ? "Host browser control: blocked." - : "", - params.sandboxInfo.allowedControlUrls?.length - ? `Browser control URL allowlist: ${params.sandboxInfo.allowedControlUrls.join(", ")}` - : "", - params.sandboxInfo.allowedControlHosts?.length - ? `Browser control host allowlist: ${params.sandboxInfo.allowedControlHosts.join(", ")}` - : "", - params.sandboxInfo.allowedControlPorts?.length - ? `Browser control port allowlist: ${params.sandboxInfo.allowedControlPorts.join(", ")}` - : "", - params.sandboxInfo.elevated?.allowed - ? "Elevated exec is available for this session." - : "", - params.sandboxInfo.elevated?.allowed ? "User can toggle with /elevated on|off." : "", - params.sandboxInfo.elevated?.allowed - ? "You may also send /elevated on|off when needed." - : "", - params.sandboxInfo.elevated?.allowed - ? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (on runs exec on host; off runs in sandbox).` - : "", - ] - .filter(Boolean) - .join("\n") - : "", - params.sandboxInfo?.enabled ? "" : "", - ...buildUserIdentitySection(ownerLine, isMinimal), - ...buildTimeSection({ - userTimezone, - userTime, - userTimeFormat: params.userTimeFormat, - }), - "## Workspace Files (injected)", - "These user-editable files are loaded by Clawdbot and included below in Project Context.", - "", - ...buildReplyTagsSection(isMinimal), - ...buildMessagingSection({ - isMinimal, - availableTools, - messageChannelOptions, - inlineButtonsEnabled, - runtimeChannel, - }), - ]; + const lines = [ + "You are a personal assistant running inside Clawdbot.", + "", + "## Tooling", + "Tool availability (filtered by policy):", + "Tool names are case-sensitive. Call tools exactly as listed.", + toolLines.length > 0 + ? toolLines.join("\n") + : [ + "Pi lists the standard tools above. This runtime enables:", + "- grep: search file contents for patterns", + "- find: find files by glob pattern", + "- ls: list directory contents", + "- apply_patch: apply multi-file patches", + `- ${execToolName}: run shell commands (supports background via yieldMs/background)`, + `- ${processToolName}: manage background exec sessions`, + "- browser: control clawd's dedicated browser", + "- canvas: present/eval/snapshot the Canvas", + "- nodes: list/describe/notify/camera/screen on paired nodes", + "- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", + "- sessions_list: list sessions", + "- sessions_history: fetch session history", + "- sessions_send: send to another session", + ].join("\n"), + "TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", + "If a task is more complex or takes longer, spawn a sub-agent. It will do the work for you and ping you when it's done. You can always check up on it.", + "", + "## Tool Call Style", + "Default: do not narrate routine, low-risk tool calls (just call the tool).", + "Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.", + "Keep narration brief and value-dense; avoid repeating obvious steps.", + "Use plain human language for narration unless in a technical context.", + "", + "## Clawdbot CLI Quick Reference", + "Clawdbot is controlled via subcommands. Do not invent commands.", + "To manage the Gateway daemon service (start/stop/restart):", + "- clawdbot daemon status", + "- clawdbot daemon start", + "- clawdbot daemon stop", + "- clawdbot daemon restart", + "If unsure, ask the user to run `clawdbot help` (or `clawdbot daemon --help`) and paste the output.", + "", + ...skillsSection, + ...memorySection, + // Skip self-update for subagent/none modes + hasGateway && !isMinimal ? "## Clawdbot Self-Update" : "", + hasGateway && !isMinimal + ? [ + "Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.", + "Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.", + "Actions: config.get, config.schema, config.apply (validate + write full config, then restart), update.run (update deps or git, then restart).", + "After restart, Clawdbot pings the last active session automatically.", + ].join("\n") + : "", + hasGateway && !isMinimal ? "" : "", + "", + // Skip model aliases for subagent/none modes + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal + ? "## Model Aliases" + : "", + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal + ? "Prefer aliases when specifying model overrides; full provider/model is also accepted." + : "", + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal + ? params.modelAliasLines.join("\n") + : "", + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "", + "## Workspace", + `Your working directory is: ${params.workspaceDir}`, + "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", + "", + ...docsSection, + params.sandboxInfo?.enabled ? "## Sandbox" : "", + params.sandboxInfo?.enabled + ? [ + "You are running in a sandboxed runtime (tools execute in Docker).", + "Some tools may be unavailable due to sandbox policy.", + "Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.", + params.sandboxInfo.workspaceDir + ? `Sandbox workspace: ${params.sandboxInfo.workspaceDir}` + : "", + params.sandboxInfo.workspaceAccess + ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${ + params.sandboxInfo.agentWorkspaceMount + ? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})` + : "" + }` + : "", + params.sandboxInfo.browserControlUrl + ? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}` + : "", + params.sandboxInfo.browserNoVncUrl + ? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}` + : "", + params.sandboxInfo.hostBrowserAllowed === true + ? "Host browser control: allowed." + : params.sandboxInfo.hostBrowserAllowed === false + ? "Host browser control: blocked." + : "", + params.sandboxInfo.allowedControlUrls?.length + ? `Browser control URL allowlist: ${params.sandboxInfo.allowedControlUrls.join(", ")}` + : "", + params.sandboxInfo.allowedControlHosts?.length + ? `Browser control host allowlist: ${params.sandboxInfo.allowedControlHosts.join(", ")}` + : "", + params.sandboxInfo.allowedControlPorts?.length + ? `Browser control port allowlist: ${params.sandboxInfo.allowedControlPorts.join(", ")}` + : "", + params.sandboxInfo.elevated?.allowed + ? "Elevated exec is available for this session." + : "", + params.sandboxInfo.elevated?.allowed ? "User can toggle with /elevated on|off." : "", + params.sandboxInfo.elevated?.allowed + ? "You may also send /elevated on|off when needed." + : "", + params.sandboxInfo.elevated?.allowed + ? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (on runs exec on host; off runs in sandbox).` + : "", + ] + .filter(Boolean) + .join("\n") + : "", + params.sandboxInfo?.enabled ? "" : "", + ...buildUserIdentitySection(ownerLine, isMinimal), + ...buildTimeSection({ + userTimezone, + userTime, + userTimeFormat: params.userTimeFormat, + }), + "## Workspace Files (injected)", + "These user-editable files are loaded by Clawdbot and included below in Project Context.", + "", + ...buildReplyTagsSection(isMinimal), + ...buildMessagingSection({ + isMinimal, + availableTools, + messageChannelOptions, + inlineButtonsEnabled, + runtimeChannel, + }), + ]; - if (extraSystemPrompt) { - // Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context" - const contextHeader = - promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context"; - lines.push(contextHeader, extraSystemPrompt, ""); - } - if (params.reactionGuidance) { - const { level, channel } = params.reactionGuidance; - const guidanceText = - level === "minimal" - ? [ - `Reactions are enabled for ${channel} in MINIMAL mode.`, - "React ONLY when truly relevant:", - "- Acknowledge important user requests or confirmations", - "- Express genuine sentiment (humor, appreciation) sparingly", - "- Avoid reacting to routine messages or your own replies", - "Guideline: at most 1 reaction per 5-10 exchanges.", - ].join("\n") - : [ - `Reactions are enabled for ${channel} in EXTENSIVE mode.`, - "Feel free to react liberally:", - "- Acknowledge messages with appropriate emojis", - "- Express sentiment and personality through reactions", - "- React to interesting content, humor, or notable events", - "- Use reactions to confirm understanding or agreement", - "Guideline: react whenever it feels natural.", - ].join("\n"); - lines.push("## Reactions", guidanceText, ""); - } - if (reasoningHint) { - lines.push("## Reasoning Format", reasoningHint, ""); - } + if (extraSystemPrompt) { + // Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context" + const contextHeader = + promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context"; + lines.push(contextHeader, extraSystemPrompt, ""); + } + if (params.reactionGuidance) { + const { level, channel } = params.reactionGuidance; + const guidanceText = + level === "minimal" + ? [ + `Reactions are enabled for ${channel} in MINIMAL mode.`, + "React ONLY when truly relevant:", + "- Acknowledge important user requests or confirmations", + "- Express genuine sentiment (humor, appreciation) sparingly", + "- Avoid reacting to routine messages or your own replies", + "Guideline: at most 1 reaction per 5-10 exchanges.", + ].join("\n") + : [ + `Reactions are enabled for ${channel} in EXTENSIVE mode.`, + "Feel free to react liberally:", + "- Acknowledge messages with appropriate emojis", + "- Express sentiment and personality through reactions", + "- React to interesting content, humor, or notable events", + "- Use reactions to confirm understanding or agreement", + "Guideline: react whenever it feels natural.", + ].join("\n"); + lines.push("## Reactions", guidanceText, ""); + } + if (reasoningHint) { + lines.push("## Reasoning Format", reasoningHint, ""); + } - const contextFiles = params.contextFiles ?? []; - if (contextFiles.length > 0) { - lines.push( - "# Project Context", - "", - "The following project context files have been loaded:", - "", - ); - for (const file of contextFiles) { - lines.push(`## ${file.path}`, "", file.content, ""); - } - } + const contextFiles = params.contextFiles ?? []; + if (contextFiles.length > 0) { + lines.push( + "# Project Context", + "", + "The following project context files have been loaded:", + "", + ); + for (const file of contextFiles) { + lines.push(`## ${file.path}`, "", file.content, ""); + } + } - // Skip silent replies for subagent/none modes - if (!isMinimal) { - lines.push( - "## Silent Replies", - `When you have nothing to say, respond with ONLY: ${SILENT_REPLY_TOKEN}`, - "", - "⚠️ Rules:", - "- It must be your ENTIRE message — nothing else", - `- Never append it to an actual response (never include "${SILENT_REPLY_TOKEN}" in real replies)`, - "- Never wrap it in markdown or code blocks", - "", - `❌ Wrong: "Here's help... ${SILENT_REPLY_TOKEN}"`, - `❌ Wrong: "${SILENT_REPLY_TOKEN}"`, - `✅ Right: ${SILENT_REPLY_TOKEN}`, - "", - ); - } + // Skip silent replies for subagent/none modes + if (!isMinimal) { + lines.push( + "## Silent Replies", + `When you have nothing to say, respond with ONLY: ${SILENT_REPLY_TOKEN}`, + "", + "⚠️ Rules:", + "- It must be your ENTIRE message — nothing else", + `- Never append it to an actual response (never include "${SILENT_REPLY_TOKEN}" in real replies)`, + "- Never wrap it in markdown or code blocks", + "", + `❌ Wrong: "Here's help... ${SILENT_REPLY_TOKEN}"`, + `❌ Wrong: "${SILENT_REPLY_TOKEN}"`, + `✅ Right: ${SILENT_REPLY_TOKEN}`, + "", + ); + } - // Skip heartbeats for subagent/none modes - if (!isMinimal) { - lines.push( - "## Heartbeats", - heartbeatPromptLine, - "If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:", - "HEARTBEAT_OK", - 'Clawdbot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).', - 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', - "", - ); - } + // Skip heartbeats for subagent/none modes + if (!isMinimal) { + lines.push( + "## Heartbeats", + heartbeatPromptLine, + "If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:", + "HEARTBEAT_OK", + 'Clawdbot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).', + 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', + "", + ); + } - lines.push( - "## Runtime", - buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel), - `Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`, - ); + lines.push( + "## Runtime", + buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel), + `Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`, + ); - return lines.filter(Boolean).join("\n"); + return lines.filter(Boolean).join("\n"); } export function buildRuntimeLine( - runtimeInfo?: { - agentId?: string; - host?: string; - os?: string; - arch?: string; - node?: string; - model?: string; - }, - runtimeChannel?: string, - runtimeCapabilities: string[] = [], - defaultThinkLevel?: ThinkLevel, + runtimeInfo?: { + agentId?: string; + host?: string; + os?: string; + arch?: string; + node?: string; + model?: string; + defaultModel?: string; + }, + runtimeChannel?: string, + runtimeCapabilities: string[] = [], + defaultThinkLevel?: ThinkLevel, ): string { - return `Runtime: ${[ - runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "", - runtimeInfo?.host ? `host=${runtimeInfo.host}` : "", - runtimeInfo?.os - ? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}` - : runtimeInfo?.arch - ? `arch=${runtimeInfo.arch}` - : "", - runtimeInfo?.node ? `node=${runtimeInfo.node}` : "", - runtimeInfo?.model ? `model=${runtimeInfo.model}` : "", - runtimeChannel ? `channel=${runtimeChannel}` : "", - runtimeChannel - ? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}` - : "", - `thinking=${defaultThinkLevel ?? "off"}`, - ] - .filter(Boolean) - .join(" | ")}`; + return `Runtime: ${[ + runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "", + runtimeInfo?.host ? `host=${runtimeInfo.host}` : "", + runtimeInfo?.os + ? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}` + : runtimeInfo?.arch + ? `arch=${runtimeInfo.arch}` + : "", + runtimeInfo?.node ? `node=${runtimeInfo.node}` : "", + runtimeInfo?.model ? `model=${runtimeInfo.model}` : "", + runtimeInfo?.defaultModel ? `default_model=${runtimeInfo.defaultModel}` : "", + runtimeChannel ? `channel=${runtimeChannel}` : "", + runtimeChannel + ? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}` + : "", + `thinking=${defaultThinkLevel ?? "off"}`, + ] + .filter(Boolean) + .join(" | ")}`; } diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 26a8370ee..8e2ad67c6 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -3,308 +3,341 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { - readFirstUserMessageFromTranscript, - readLastMessagePreviewFromTranscript, + readFirstUserMessageFromTranscript, + readLastMessagePreviewFromTranscript, } from "./session-utils.fs.js"; describe("readFirstUserMessageFromTranscript", () => { - let tmpDir: string; - let storePath: string; + let tmpDir: string; + let storePath: string; - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-")); - storePath = path.join(tmpDir, "sessions.json"); - }); + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-")); + storePath = path.join(tmpDir, "sessions.json"); + }); - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); - test("returns null when transcript file does not exist", () => { - const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath); - expect(result).toBeNull(); - }); + test("returns null when transcript file does not exist", () => { + const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath); + expect(result).toBeNull(); + }); - test("returns first user message from transcript with string content", () => { - const sessionId = "test-session-1"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ message: { role: "user", content: "Hello world" } }), - JSON.stringify({ message: { role: "assistant", content: "Hi there" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + test("returns first user message from transcript with string content", () => { + const sessionId = "test-session-1"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "Hello world" } }), + JSON.stringify({ message: { role: "assistant", content: "Hi there" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - const result = readFirstUserMessageFromTranscript(sessionId, storePath); - expect(result).toBe("Hello world"); - }); + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Hello world"); + }); - test("returns first user message from transcript with array content", () => { - const sessionId = "test-session-2"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ - message: { - role: "user", - content: [{ type: "text", text: "Array message content" }], - }, - }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + test("returns first user message from transcript with array content", () => { + const sessionId = "test-session-2"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ + message: { + role: "user", + content: [{ type: "text", text: "Array message content" }], + }, + }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - const result = readFirstUserMessageFromTranscript(sessionId, storePath); - expect(result).toBe("Array message content"); - }); + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Array message content"); + }); - test("skips non-user messages to find first user message", () => { - const sessionId = "test-session-3"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ message: { role: "system", content: "System prompt" } }), - JSON.stringify({ message: { role: "assistant", content: "Greeting" } }), - JSON.stringify({ message: { role: "user", content: "First user question" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + test("returns first user message from transcript with input_text content", () => { + const sessionId = "test-session-2b"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ + message: { + role: "user", + content: [{ type: "input_text", text: "Input text content" }], + }, + }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - const result = readFirstUserMessageFromTranscript(sessionId, storePath); - expect(result).toBe("First user question"); - }); + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Input text content"); + }); + test("skips non-user messages to find first user message", () => { + const sessionId = "test-session-3"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "system", content: "System prompt" } }), + JSON.stringify({ message: { role: "assistant", content: "Greeting" } }), + JSON.stringify({ message: { role: "user", content: "First user question" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - test("returns null when no user messages exist", () => { - const sessionId = "test-session-4"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ message: { role: "system", content: "System prompt" } }), - JSON.stringify({ message: { role: "assistant", content: "Greeting" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("First user question"); + }); - const result = readFirstUserMessageFromTranscript(sessionId, storePath); - expect(result).toBeNull(); - }); + test("returns null when no user messages exist", () => { + const sessionId = "test-session-4"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "system", content: "System prompt" } }), + JSON.stringify({ message: { role: "assistant", content: "Greeting" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - test("handles malformed JSON lines gracefully", () => { - const sessionId = "test-session-5"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - "not valid json", - JSON.stringify({ message: { role: "user", content: "Valid message" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBeNull(); + }); - const result = readFirstUserMessageFromTranscript(sessionId, storePath); - expect(result).toBe("Valid message"); - }); + test("handles malformed JSON lines gracefully", () => { + const sessionId = "test-session-5"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + "not valid json", + JSON.stringify({ message: { role: "user", content: "Valid message" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - test("uses sessionFile parameter when provided", () => { - const sessionId = "test-session-6"; - const customPath = path.join(tmpDir, "custom-transcript.jsonl"); - const lines = [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ message: { role: "user", content: "Custom file message" } }), - ]; - fs.writeFileSync(customPath, lines.join("\n"), "utf-8"); + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Valid message"); + }); - const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath); - expect(result).toBe("Custom file message"); - }); + test("uses sessionFile parameter when provided", () => { + const sessionId = "test-session-6"; + const customPath = path.join(tmpDir, "custom-transcript.jsonl"); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "Custom file message" } }), + ]; + fs.writeFileSync(customPath, lines.join("\n"), "utf-8"); - test("trims whitespace from message content", () => { - const sessionId = "test-session-7"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath); + expect(result).toBe("Custom file message"); + }); - const result = readFirstUserMessageFromTranscript(sessionId, storePath); - expect(result).toBe("Padded message"); - }); + test("trims whitespace from message content", () => { + const sessionId = "test-session-7"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - test("returns null for empty content", () => { - const sessionId = "test-session-8"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ message: { role: "user", content: "" } }), - JSON.stringify({ message: { role: "user", content: "Second message" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Padded message"); + }); - const result = readFirstUserMessageFromTranscript(sessionId, storePath); - expect(result).toBe("Second message"); - }); + test("returns null for empty content", () => { + const sessionId = "test-session-8"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "user", content: "" } }), + JSON.stringify({ message: { role: "user", content: "Second message" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Second message"); + }); }); describe("readLastMessagePreviewFromTranscript", () => { - let tmpDir: string; - let storePath: string; + let tmpDir: string; + let storePath: string; - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-")); - storePath = path.join(tmpDir, "sessions.json"); - }); + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-")); + storePath = path.join(tmpDir, "sessions.json"); + }); - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); - test("returns null when transcript file does not exist", () => { - const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath); - expect(result).toBeNull(); - }); + test("returns null when transcript file does not exist", () => { + const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath); + expect(result).toBeNull(); + }); - test("returns null for empty file", () => { - const sessionId = "test-last-empty"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - fs.writeFileSync(transcriptPath, "", "utf-8"); + test("returns null for empty file", () => { + const sessionId = "test-last-empty"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + fs.writeFileSync(transcriptPath, "", "utf-8"); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBeNull(); - }); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBeNull(); + }); - test("returns last user message from transcript", () => { - const sessionId = "test-last-user"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ message: { role: "user", content: "First user" } }), - JSON.stringify({ message: { role: "assistant", content: "First assistant" } }), - JSON.stringify({ message: { role: "user", content: "Last user message" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + test("returns last user message from transcript", () => { + const sessionId = "test-last-user"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "user", content: "First user" } }), + JSON.stringify({ message: { role: "assistant", content: "First assistant" } }), + JSON.stringify({ message: { role: "user", content: "Last user message" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Last user message"); - }); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Last user message"); + }); - test("returns last assistant message from transcript", () => { - const sessionId = "test-last-assistant"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ message: { role: "user", content: "User question" } }), - JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + test("returns last assistant message from transcript", () => { + const sessionId = "test-last-assistant"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "user", content: "User question" } }), + JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Final assistant reply"); - }); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Final assistant reply"); + }); - test("skips system messages to find last user/assistant", () => { - const sessionId = "test-last-skip-system"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ message: { role: "user", content: "Real last" } }), - JSON.stringify({ message: { role: "system", content: "System at end" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + test("skips system messages to find last user/assistant", () => { + const sessionId = "test-last-skip-system"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "user", content: "Real last" } }), + JSON.stringify({ message: { role: "system", content: "System at end" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Real last"); - }); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Real last"); + }); - test("returns null when no user/assistant messages exist", () => { - const sessionId = "test-last-no-match"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ message: { role: "system", content: "Only system" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + test("returns null when no user/assistant messages exist", () => { + const sessionId = "test-last-no-match"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "system", content: "Only system" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBeNull(); - }); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBeNull(); + }); - test("handles malformed JSON lines gracefully", () => { - const sessionId = "test-last-malformed"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ message: { role: "user", content: "Valid first" } }), - "not valid json at end", - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + test("handles malformed JSON lines gracefully", () => { + const sessionId = "test-last-malformed"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "user", content: "Valid first" } }), + "not valid json at end", + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Valid first"); - }); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Valid first"); + }); - test("handles array content format", () => { - const sessionId = "test-last-array"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ - message: { - role: "assistant", - content: [{ type: "text", text: "Array content response" }], - }, - }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + test("handles array content format", () => { + const sessionId = "test-last-array"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ + message: { + role: "assistant", + content: [{ type: "text", text: "Array content response" }], + }, + }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Array content response"); - }); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Array content response"); + }); - test("uses sessionFile parameter when provided", () => { - const sessionId = "test-last-custom"; - const customPath = path.join(tmpDir, "custom-last.jsonl"); - const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })]; - fs.writeFileSync(customPath, lines.join("\n"), "utf-8"); + test("handles output_text content format", () => { + const sessionId = "test-last-output-text"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ + message: { + role: "assistant", + content: [{ type: "output_text", text: "Output text response" }], + }, + }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath, customPath); - expect(result).toBe("Custom file last"); - }); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Output text response"); + }); + test("uses sessionFile parameter when provided", () => { + const sessionId = "test-last-custom"; + const customPath = path.join(tmpDir, "custom-last.jsonl"); + const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })]; + fs.writeFileSync(customPath, lines.join("\n"), "utf-8"); - test("trims whitespace from message content", () => { - const sessionId = "test-last-trim"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ message: { role: "assistant", content: " Padded response " } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath, customPath); + expect(result).toBe("Custom file last"); + }); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Padded response"); - }); + test("trims whitespace from message content", () => { + const sessionId = "test-last-trim"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "assistant", content: " Padded response " } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - test("skips empty content to find previous message", () => { - const sessionId = "test-last-skip-empty"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ message: { role: "assistant", content: "Has content" } }), - JSON.stringify({ message: { role: "user", content: "" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Padded response"); + }); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Has content"); - }); + test("skips empty content to find previous message", () => { + const sessionId = "test-last-skip-empty"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "assistant", content: "Has content" } }), + JSON.stringify({ message: { role: "user", content: "" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - test("reads from end of large file (16KB window)", () => { - const sessionId = "test-last-large"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const padding = JSON.stringify({ message: { role: "user", content: "x".repeat(500) } }); - const lines: string[] = []; - for (let i = 0; i < 50; i++) { - lines.push(padding); - } - lines.push(JSON.stringify({ message: { role: "assistant", content: "Last in large file" } })); - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Has content"); + }); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Last in large file"); - }); + test("reads from end of large file (16KB window)", () => { + const sessionId = "test-last-large"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const padding = JSON.stringify({ message: { role: "user", content: "x".repeat(500) } }); + const lines: string[] = []; + for (let i = 0; i < 50; i++) { + lines.push(padding); + } + lines.push(JSON.stringify({ message: { role: "assistant", content: "Last in large file" } })); + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - test("handles valid UTF-8 content", () => { - const sessionId = "test-last-utf8"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const validLine = JSON.stringify({ - message: { role: "user", content: "Valid UTF-8: 你好世界 🌍" }, - }); - fs.writeFileSync(transcriptPath, validLine, "utf-8"); + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Last in large file"); + }); - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Valid UTF-8: 你好世界 🌍"); - }); + test("handles valid UTF-8 content", () => { + const sessionId = "test-last-utf8"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const validLine = JSON.stringify({ + message: { role: "user", content: "Valid UTF-8: 你好世界 🌍" }, + }); + fs.writeFileSync(transcriptPath, validLine, "utf-8"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Valid UTF-8: 你好世界 🌍"); + }); }); diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 73e43a69c..eb8912359 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -91,8 +91,10 @@ function extractTextFromContent(content: TranscriptMessage["content"]): string | if (typeof content === "string") return content.trim() || null; if (!Array.isArray(content)) return null; for (const part of content) { - if (part.type === "text" && typeof part.text === "string" && part.text.trim()) { - return part.text.trim(); + if (!part || typeof part.text !== "string") continue; + if (part.type === "text" || part.type === "output_text" || part.type === "input_text") { + const trimmed = part.text.trim(); + if (trimmed) return trimmed; } } return null; diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 79dc3f9a6..b77e44817 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -84,7 +84,8 @@ export function deriveSessionTitle( } if (firstUserMessage?.trim()) { - return truncateTitle(firstUserMessage.trim(), DERIVED_TITLE_MAX_LEN); + const normalized = firstUserMessage.replace(/\s+/g, " ").trim(); + return truncateTitle(normalized, DERIVED_TITLE_MAX_LEN); } if (entry.sessionId) { diff --git a/src/tui/components/filterable-select-list.ts b/src/tui/components/filterable-select-list.ts index c611c6319..67361bcf1 100644 --- a/src/tui/components/filterable-select-list.ts +++ b/src/tui/components/filterable-select-list.ts @@ -1,24 +1,24 @@ import { - Input, - matchesKey, - type SelectItem, - SelectList, - type SelectListTheme, - getEditorKeybindings, + Input, + matchesKey, + type SelectItem, + SelectList, + type SelectListTheme, + getEditorKeybindings, } from "@mariozechner/pi-tui"; import type { Component } from "@mariozechner/pi-tui"; import chalk from "chalk"; import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js"; export interface FilterableSelectItem extends SelectItem { - /** Additional searchable fields beyond label */ - searchText?: string; - /** Pre-computed lowercase search text (label + description + searchText) for filtering */ - searchTextLower?: string; + /** Additional searchable fields beyond label */ + searchText?: string; + /** Pre-computed lowercase search text (label + description + searchText) for filtering */ + searchTextLower?: string; } export interface FilterableSelectListTheme extends SelectListTheme { - filterLabel: (text: string) => string; + filterLabel: (text: string) => string; } /** @@ -26,108 +26,118 @@ export interface FilterableSelectListTheme extends SelectListTheme { * User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel. */ export class FilterableSelectList implements Component { - private input: Input; - private selectList: SelectList; - private allItems: FilterableSelectItem[]; - private maxVisible: number; - private theme: FilterableSelectListTheme; - private filterText = ""; + private input: Input; + private selectList: SelectList; + private allItems: FilterableSelectItem[]; + private maxVisible: number; + private theme: FilterableSelectListTheme; + private filterText = ""; - onSelect?: (item: SelectItem) => void; - onCancel?: () => void; + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; - constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) { - this.allItems = prepareSearchItems(items); - this.maxVisible = maxVisible; - this.theme = theme; - this.input = new Input(); - this.selectList = new SelectList(this.allItems, maxVisible, theme); - } + constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) { + this.allItems = prepareSearchItems(items); + this.maxVisible = maxVisible; + this.theme = theme; + this.input = new Input(); + this.selectList = new SelectList(this.allItems, maxVisible, theme); + } - private applyFilter(): void { - const queryLower = this.filterText.toLowerCase(); - if (!queryLower.trim()) { - this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme); - return; - } - const filtered = fuzzyFilterLower(this.allItems, queryLower); - this.selectList = new SelectList(filtered, this.maxVisible, this.theme); - } + private applyFilter(): void { + const queryLower = this.filterText.toLowerCase(); + if (!queryLower.trim()) { + this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme); + return; + } + const filtered = fuzzyFilterLower(this.allItems, queryLower); + this.selectList = new SelectList(filtered, this.maxVisible, this.theme); + } - invalidate(): void { - this.input.invalidate(); - this.selectList.invalidate(); - } + invalidate(): void { + this.input.invalidate(); + this.selectList.invalidate(); + } - render(width: number): string[] { - const lines: string[] = []; + render(width: number): string[] { + const lines: string[] = []; - // Filter input row - const filterLabel = this.theme.filterLabel("Filter: "); - const inputLines = this.input.render(width - 8); - const inputText = inputLines[0] ?? ""; - lines.push(filterLabel + inputText); + // Filter input row + const filterLabel = this.theme.filterLabel("Filter: "); + const inputLines = this.input.render(width - 8); + const inputText = inputLines[0] ?? ""; + lines.push(filterLabel + inputText); - // Separator - lines.push(chalk.dim("─".repeat(width))); + // Separator + lines.push(chalk.dim("─".repeat(width))); - // Select list - const listLines = this.selectList.render(width); - lines.push(...listLines); + // Select list + const listLines = this.selectList.render(width); + lines.push(...listLines); - return lines; - } + return lines; + } - handleInput(keyData: string): void { - // Navigation: arrows, vim j/k, or ctrl+p/ctrl+n - if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") { - this.selectList.handleInput("\x1b[A"); - return; - } + handleInput(keyData: string): void { + const allowVimNav = !this.filterText.trim(); - if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") { - this.selectList.handleInput("\x1b[B"); - return; - } + // Navigation: arrows, vim j/k, or ctrl+p/ctrl+n + if ( + matchesKey(keyData, "up") || + matchesKey(keyData, "ctrl+p") || + (allowVimNav && keyData === "k") + ) { + this.selectList.handleInput("\x1b[A"); + return; + } - // Enter selects - if (matchesKey(keyData, "enter")) { - const selected = this.selectList.getSelectedItem(); - if (selected) { - this.onSelect?.(selected); - } - return; - } + if ( + matchesKey(keyData, "down") || + matchesKey(keyData, "ctrl+n") || + (allowVimNav && keyData === "j") + ) { + this.selectList.handleInput("\x1b[B"); + return; + } - // Escape: clear filter or cancel - const kb = getEditorKeybindings(); - if (kb.matches(keyData, "selectCancel")) { - if (this.filterText) { - this.filterText = ""; - this.input.setValue(""); - this.applyFilter(); - } else { - this.onCancel?.(); - } - return; - } + // Enter selects + if (matchesKey(keyData, "enter")) { + const selected = this.selectList.getSelectedItem(); + if (selected) { + this.onSelect?.(selected); + } + return; + } - // All other input goes to filter - const prevValue = this.input.getValue(); - this.input.handleInput(keyData); - const newValue = this.input.getValue(); + // Escape: clear filter or cancel + const kb = getEditorKeybindings(); + if (kb.matches(keyData, "selectCancel")) { + if (this.filterText) { + this.filterText = ""; + this.input.setValue(""); + this.applyFilter(); + } else { + this.onCancel?.(); + } + return; + } - if (newValue !== prevValue) { - this.filterText = newValue; - this.applyFilter(); - } - } + // All other input goes to filter + const prevValue = this.input.getValue(); + this.input.handleInput(keyData); + const newValue = this.input.getValue(); - getSelectedItem(): SelectItem | null { - return this.selectList.getSelectedItem(); - } + if (newValue !== prevValue) { + this.filterText = newValue; + this.applyFilter(); + } + } - getFilterText(): string { - return this.filterText; - } + getSelectedItem(): SelectItem | null { + return this.selectList.getSelectedItem(); + } + + getFilterText(): string { + return this.filterText; + } } diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index ec08282c1..37ff21ebc 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -1,290 +1,300 @@ import { - type Component, - fuzzyFilter, - getEditorKeybindings, - Input, - isKeyRelease, - matchesKey, - type SelectItem, - type SelectListTheme, - truncateToWidth, + type Component, + fuzzyFilter, + getEditorKeybindings, + Input, + isKeyRelease, + matchesKey, + type SelectItem, + type SelectListTheme, + truncateToWidth, } from "@mariozechner/pi-tui"; import { visibleWidth } from "../../terminal/ansi.js"; import { findWordBoundaryIndex } from "./fuzzy-filter.js"; export interface SearchableSelectListTheme extends SelectListTheme { - searchPrompt: (text: string) => string; - searchInput: (text: string) => string; - matchHighlight: (text: string) => string; + searchPrompt: (text: string) => string; + searchInput: (text: string) => string; + matchHighlight: (text: string) => string; } /** * A select list with a search input at the top for fuzzy filtering. */ export class SearchableSelectList implements Component { - private items: SelectItem[]; - private filteredItems: SelectItem[]; - private selectedIndex = 0; - private maxVisible: number; - private theme: SearchableSelectListTheme; - private searchInput: Input; + private items: SelectItem[]; + private filteredItems: SelectItem[]; + private selectedIndex = 0; + private maxVisible: number; + private theme: SearchableSelectListTheme; + private searchInput: Input; - onSelect?: (item: SelectItem) => void; - onCancel?: () => void; - onSelectionChange?: (item: SelectItem) => void; + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; + onSelectionChange?: (item: SelectItem) => void; - constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) { - this.items = items; - this.filteredItems = items; - this.maxVisible = maxVisible; - this.theme = theme; - this.searchInput = new Input(); - } + constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) { + this.items = items; + this.filteredItems = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.searchInput = new Input(); + } - private updateFilter() { - const query = this.searchInput.getValue().trim(); + private updateFilter() { + const query = this.searchInput.getValue().trim(); - if (!query) { - this.filteredItems = this.items; - } else { - this.filteredItems = this.smartFilter(query); - } + if (!query) { + this.filteredItems = this.items; + } else { + this.filteredItems = this.smartFilter(query); + } - // Reset selection when filter changes - this.selectedIndex = 0; - this.notifySelectionChange(); - } + // Reset selection when filter changes + this.selectedIndex = 0; + this.notifySelectionChange(); + } - /** - * Smart filtering that prioritizes: - * 1. Exact substring match in label (highest priority) - * 2. Word-boundary prefix match in label - * 3. Exact substring match in description - * 4. Fuzzy match (lowest priority) - */ - private smartFilter(query: string): SelectItem[] { - const q = query.toLowerCase(); - type ScoredItem = { item: SelectItem; score: number }; - const exactLabel: ScoredItem[] = []; - const wordBoundary: ScoredItem[] = []; - const descriptionMatches: ScoredItem[] = []; - const fuzzyCandidates: SelectItem[] = []; + /** + * Smart filtering that prioritizes: + * 1. Exact substring match in label (highest priority) + * 2. Word-boundary prefix match in label + * 3. Exact substring match in description + * 4. Fuzzy match (lowest priority) + */ + private smartFilter(query: string): SelectItem[] { + const q = query.toLowerCase(); + type ScoredItem = { item: SelectItem; score: number }; + const exactLabel: ScoredItem[] = []; + const wordBoundary: ScoredItem[] = []; + const descriptionMatches: ScoredItem[] = []; + const fuzzyCandidates: SelectItem[] = []; - for (const item of this.items) { - const label = item.label.toLowerCase(); - const desc = (item.description ?? "").toLowerCase(); + for (const item of this.items) { + const label = item.label.toLowerCase(); + const desc = (item.description ?? "").toLowerCase(); - // Tier 1: Exact substring in label (score 0-99) - const labelIndex = label.indexOf(q); - if (labelIndex !== -1) { - // Earlier match = better score - exactLabel.push({ item, score: labelIndex }); - continue; - } - // Tier 2: Word-boundary prefix in label (score 100-199) - const wordBoundaryIndex = findWordBoundaryIndex(label, q); - if (wordBoundaryIndex !== null) { - wordBoundary.push({ item, score: wordBoundaryIndex }); - continue; - } - // Tier 3: Exact substring in description (score 200-299) - const descIndex = desc.indexOf(q); - if (descIndex !== -1) { - descriptionMatches.push({ item, score: descIndex }); - continue; - } - // Tier 4: Fuzzy match (score 300+) - fuzzyCandidates.push(item); - } + // Tier 1: Exact substring in label (score 0-99) + const labelIndex = label.indexOf(q); + if (labelIndex !== -1) { + // Earlier match = better score + exactLabel.push({ item, score: labelIndex }); + continue; + } + // Tier 2: Word-boundary prefix in label (score 100-199) + const wordBoundaryIndex = findWordBoundaryIndex(label, q); + if (wordBoundaryIndex !== null) { + wordBoundary.push({ item, score: wordBoundaryIndex }); + continue; + } + // Tier 3: Exact substring in description (score 200-299) + const descIndex = desc.indexOf(q); + if (descIndex !== -1) { + descriptionMatches.push({ item, score: descIndex }); + continue; + } + // Tier 4: Fuzzy match (score 300+) + fuzzyCandidates.push(item); + } - exactLabel.sort(this.compareByScore); - wordBoundary.sort(this.compareByScore); - descriptionMatches.sort(this.compareByScore); - const fuzzyMatches = fuzzyFilter( - fuzzyCandidates, - query, - (i) => `${i.label} ${i.description ?? ""}`, - ); - return [ - ...exactLabel.map((s) => s.item), - ...wordBoundary.map((s) => s.item), - ...descriptionMatches.map((s) => s.item), - ...fuzzyMatches, - ]; - } + exactLabel.sort(this.compareByScore); + wordBoundary.sort(this.compareByScore); + descriptionMatches.sort(this.compareByScore); + const fuzzyMatches = fuzzyFilter( + fuzzyCandidates, + query, + (i) => `${i.label} ${i.description ?? ""}`, + ); + return [ + ...exactLabel.map((s) => s.item), + ...wordBoundary.map((s) => s.item), + ...descriptionMatches.map((s) => s.item), + ...fuzzyMatches, + ]; + } - private escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - } + private escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } - private compareByScore = ( - a: { item: SelectItem; score: number }, - b: { item: SelectItem; score: number }, - ) => { - if (a.score !== b.score) return a.score - b.score; - return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item)); - }; + private compareByScore = ( + a: { item: SelectItem; score: number }, + b: { item: SelectItem; score: number }, + ) => { + if (a.score !== b.score) return a.score - b.score; + return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item)); + }; - private getItemLabel(item: SelectItem): string { - return item.label || item.value; - } + private getItemLabel(item: SelectItem): string { + return item.label || item.value; + } - private highlightMatch(text: string, query: string): string { - const tokens = query - .trim() - .split(/\s+/) - .map((token) => token.toLowerCase()) - .filter((token) => token.length > 0); - if (tokens.length === 0) return text; + private highlightMatch(text: string, query: string): string { + const tokens = query + .trim() + .split(/\s+/) + .map((token) => token.toLowerCase()) + .filter((token) => token.length > 0); + if (tokens.length === 0) return text; - const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length); - let result = text; - for (const token of uniqueTokens) { - const regex = new RegExp(this.escapeRegex(token), "gi"); - result = result.replace(regex, (match) => this.theme.matchHighlight(match)); - } - return result; - } + const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length); + let result = text; + for (const token of uniqueTokens) { + const regex = new RegExp(this.escapeRegex(token), "gi"); + result = result.replace(regex, (match) => this.theme.matchHighlight(match)); + } + return result; + } - setSelectedIndex(index: number) { - this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); - } + setSelectedIndex(index: number) { + this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); + } - invalidate() { - this.searchInput.invalidate(); - } + invalidate() { + this.searchInput.invalidate(); + } - render(width: number): string[] { - const lines: string[] = []; + render(width: number): string[] { + const lines: string[] = []; - // Search input line - const promptText = "search: "; - const prompt = this.theme.searchPrompt(promptText); - const inputWidth = Math.max(1, width - visibleWidth(prompt)); - const inputLines = this.searchInput.render(inputWidth); - const inputText = inputLines[0] ?? ""; - lines.push(`${prompt}${this.theme.searchInput(inputText)}`); - lines.push(""); // Spacer + // Search input line + const promptText = "search: "; + const prompt = this.theme.searchPrompt(promptText); + const inputWidth = Math.max(1, width - visibleWidth(prompt)); + const inputLines = this.searchInput.render(inputWidth); + const inputText = inputLines[0] ?? ""; + lines.push(`${prompt}${this.theme.searchInput(inputText)}`); + lines.push(""); // Spacer - const query = this.searchInput.getValue().trim(); + const query = this.searchInput.getValue().trim(); - // If no items match filter, show message - if (this.filteredItems.length === 0) { - lines.push(this.theme.noMatch(" No matches")); - return lines; - } + // If no items match filter, show message + if (this.filteredItems.length === 0) { + lines.push(this.theme.noMatch(" No matches")); + return lines; + } - // Calculate visible range with scrolling - const startIndex = Math.max( - 0, - Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - this.filteredItems.length - this.maxVisible, - ), - ); - const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min( + this.selectedIndex - Math.floor(this.maxVisible / 2), + this.filteredItems.length - this.maxVisible, + ), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); - // Render visible items - for (let i = startIndex; i < endIndex; i++) { - const item = this.filteredItems[i]; - if (!item) continue; - const isSelected = i === this.selectedIndex; - lines.push(this.renderItemLine(item, isSelected, width, query)); - } + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = this.filteredItems[i]; + if (!item) continue; + const isSelected = i === this.selectedIndex; + lines.push(this.renderItemLine(item, isSelected, width, query)); + } - // Show scroll indicator if needed - if (this.filteredItems.length > this.maxVisible) { - const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`; - lines.push(this.theme.scrollInfo(` ${scrollInfo}`)); - } + // Show scroll indicator if needed + if (this.filteredItems.length > this.maxVisible) { + const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`; + lines.push(this.theme.scrollInfo(` ${scrollInfo}`)); + } - return lines; - } + return lines; + } - private renderItemLine( - item: SelectItem, - isSelected: boolean, - width: number, - query: string, - ): string { - const prefix = isSelected ? "→ " : " "; - const prefixWidth = prefix.length; - const displayValue = this.getItemLabel(item); + private renderItemLine( + item: SelectItem, + isSelected: boolean, + width: number, + query: string, + ): string { + const prefix = isSelected ? "→ " : " "; + const prefixWidth = prefix.length; + const displayValue = this.getItemLabel(item); - if (item.description && width > 40) { - const maxValueWidth = Math.min(30, width - prefixWidth - 4); - const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); - const valueText = this.highlightMatch(truncatedValue, query); - const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText))); - const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length; - const remainingWidth = width - descriptionStart - 2; - if (remainingWidth > 10) { - const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); - const descText = isSelected - ? this.highlightMatch(truncatedDesc, query) - : this.highlightMatch(this.theme.description(truncatedDesc), query); - const line = `${prefix}${valueText}${spacing}${descText}`; - return isSelected ? this.theme.selectedText(line) : line; - } - } + if (item.description && width > 40) { + const maxValueWidth = Math.min(30, width - prefixWidth - 4); + const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); + const valueText = this.highlightMatch(truncatedValue, query); + const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText))); + const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length; + const remainingWidth = width - descriptionStart - 2; + if (remainingWidth > 10) { + const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); + const descText = isSelected + ? this.highlightMatch(truncatedDesc, query) + : this.highlightMatch(this.theme.description(truncatedDesc), query); + const line = `${prefix}${valueText}${spacing}${descText}`; + return isSelected ? this.theme.selectedText(line) : line; + } + } - const maxWidth = width - prefixWidth - 2; - const truncatedValue = truncateToWidth(displayValue, maxWidth, ""); - const valueText = this.highlightMatch(truncatedValue, query); - const line = `${prefix}${valueText}`; - return isSelected ? this.theme.selectedText(line) : line; - } + const maxWidth = width - prefixWidth - 2; + const truncatedValue = truncateToWidth(displayValue, maxWidth, ""); + const valueText = this.highlightMatch(truncatedValue, query); + const line = `${prefix}${valueText}`; + return isSelected ? this.theme.selectedText(line) : line; + } - handleInput(keyData: string): void { - if (isKeyRelease(keyData)) return; + handleInput(keyData: string): void { + if (isKeyRelease(keyData)) return; - // Navigation keys - if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - this.notifySelectionChange(); - return; - } + const allowVimNav = !this.searchInput.getValue().trim(); - if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") { - this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); - this.notifySelectionChange(); - return; - } + // Navigation keys + if ( + matchesKey(keyData, "up") || + matchesKey(keyData, "ctrl+p") || + (allowVimNav && keyData === "k") + ) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.notifySelectionChange(); + return; + } - if (matchesKey(keyData, "enter")) { - const item = this.filteredItems[this.selectedIndex]; - if (item && this.onSelect) { - this.onSelect(item); - } - return; - } + if ( + matchesKey(keyData, "down") || + matchesKey(keyData, "ctrl+n") || + (allowVimNav && keyData === "j") + ) { + this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); + this.notifySelectionChange(); + return; + } - const kb = getEditorKeybindings(); - if (kb.matches(keyData, "selectCancel")) { - if (this.onCancel) { - this.onCancel(); - } - return; - } + if (matchesKey(keyData, "enter")) { + const item = this.filteredItems[this.selectedIndex]; + if (item && this.onSelect) { + this.onSelect(item); + } + return; + } - // Pass other keys to search input - const prevValue = this.searchInput.getValue(); - this.searchInput.handleInput(keyData); - const newValue = this.searchInput.getValue(); + const kb = getEditorKeybindings(); + if (kb.matches(keyData, "selectCancel")) { + if (this.onCancel) { + this.onCancel(); + } + return; + } - if (prevValue !== newValue) { - this.updateFilter(); - } - } + // Pass other keys to search input + const prevValue = this.searchInput.getValue(); + this.searchInput.handleInput(keyData); + const newValue = this.searchInput.getValue(); - private notifySelectionChange() { - const item = this.filteredItems[this.selectedIndex]; - if (item && this.onSelectionChange) { - this.onSelectionChange(item); - } - } + if (prevValue !== newValue) { + this.updateFilter(); + } + } - getSelectedItem(): SelectItem | null { - return this.filteredItems[this.selectedIndex] ?? null; - } + private notifySelectionChange() { + const item = this.filteredItems[this.selectedIndex]; + if (item && this.onSelectionChange) { + this.onSelectionChange(item); + } + } + + getSelectedItem(): SelectItem | null { + return this.filteredItems[this.selectedIndex] ?? null; + } } diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index eaf3f44f6..40584da0e 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -1,462 +1,463 @@ import type { Component, TUI } from "@mariozechner/pi-tui"; import { - formatThinkingLevels, - normalizeUsageDisplay, - resolveResponseUsageMode, + formatThinkingLevels, + normalizeUsageDisplay, + resolveResponseUsageMode, } from "../auto-reply/thinking.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { formatRelativeTime } from "../utils/time-format.js"; import { helpText, parseCommand } from "./commands.js"; import type { ChatLog } from "./components/chat-log.js"; import { - createFilterableSelectList, - createSearchableSelectList, - createSettingsList, + createFilterableSelectList, + createSearchableSelectList, + createSettingsList, } from "./components/selectors.js"; import type { GatewayChatClient } from "./gateway-chat.js"; import { formatStatusSummary } from "./tui-status-summary.js"; import type { - AgentSummary, - GatewayStatusSummary, - TuiOptions, - TuiStateAccess, + AgentSummary, + GatewayStatusSummary, + TuiOptions, + TuiStateAccess, } from "./tui-types.js"; type CommandHandlerContext = { - client: GatewayChatClient; - chatLog: ChatLog; - tui: TUI; - opts: TuiOptions; - state: TuiStateAccess; - deliverDefault: boolean; - openOverlay: (component: Component) => void; - closeOverlay: () => void; - refreshSessionInfo: () => Promise; - loadHistory: () => Promise; - setSession: (key: string) => Promise; - refreshAgents: () => Promise; - abortActive: () => Promise; - setActivityStatus: (text: string) => void; - formatSessionKey: (key: string) => string; + client: GatewayChatClient; + chatLog: ChatLog; + tui: TUI; + opts: TuiOptions; + state: TuiStateAccess; + deliverDefault: boolean; + openOverlay: (component: Component) => void; + closeOverlay: () => void; + refreshSessionInfo: () => Promise; + loadHistory: () => Promise; + setSession: (key: string) => Promise; + refreshAgents: () => Promise; + abortActive: () => Promise; + setActivityStatus: (text: string) => void; + formatSessionKey: (key: string) => string; }; export function createCommandHandlers(context: CommandHandlerContext) { - const { - client, - chatLog, - tui, - opts, - state, - deliverDefault, - openOverlay, - closeOverlay, - refreshSessionInfo, - loadHistory, - setSession, - refreshAgents, - abortActive, - setActivityStatus, - formatSessionKey, - } = context; + const { + client, + chatLog, + tui, + opts, + state, + deliverDefault, + openOverlay, + closeOverlay, + refreshSessionInfo, + loadHistory, + setSession, + refreshAgents, + abortActive, + setActivityStatus, + formatSessionKey, + } = context; - const setAgent = async (id: string) => { - state.currentAgentId = normalizeAgentId(id); - await setSession(""); - }; + const setAgent = async (id: string) => { + state.currentAgentId = normalizeAgentId(id); + await setSession(""); + }; - const openModelSelector = async () => { - try { - const models = await client.listModels(); - if (models.length === 0) { - chatLog.addSystem("no models available"); - tui.requestRender(); - return; - } - const items = models.map((model) => ({ - value: `${model.provider}/${model.id}`, - label: `${model.provider}/${model.id}`, - description: model.name && model.name !== model.id ? model.name : "", - })); - const selector = createSearchableSelectList(items, 9); - selector.onSelect = (item) => { - void (async () => { - try { - await client.patchSession({ - key: state.currentSessionKey, - model: item.value, - }); - chatLog.addSystem(`model set to ${item.value}`); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`model set failed: ${String(err)}`); - } - closeOverlay(); - tui.requestRender(); - })(); - }; - selector.onCancel = () => { - closeOverlay(); - tui.requestRender(); - }; - openOverlay(selector); - tui.requestRender(); - } catch (err) { - chatLog.addSystem(`model list failed: ${String(err)}`); - tui.requestRender(); - } - }; + const openModelSelector = async () => { + try { + const models = await client.listModels(); + if (models.length === 0) { + chatLog.addSystem("no models available"); + tui.requestRender(); + return; + } + const items = models.map((model) => ({ + value: `${model.provider}/${model.id}`, + label: `${model.provider}/${model.id}`, + description: model.name && model.name !== model.id ? model.name : "", + })); + const selector = createSearchableSelectList(items, 9); + selector.onSelect = (item) => { + void (async () => { + try { + await client.patchSession({ + key: state.currentSessionKey, + model: item.value, + }); + chatLog.addSystem(`model set to ${item.value}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`model set failed: ${String(err)}`); + } + closeOverlay(); + tui.requestRender(); + })(); + }; + selector.onCancel = () => { + closeOverlay(); + tui.requestRender(); + }; + openOverlay(selector); + tui.requestRender(); + } catch (err) { + chatLog.addSystem(`model list failed: ${String(err)}`); + tui.requestRender(); + } + }; - const openAgentSelector = async () => { - await refreshAgents(); - if (state.agents.length === 0) { - chatLog.addSystem("no agents found"); - tui.requestRender(); - return; - } - const items = state.agents.map((agent: AgentSummary) => ({ - value: agent.id, - label: agent.name ? `${agent.id} (${agent.name})` : agent.id, - description: agent.id === state.agentDefaultId ? "default" : "", - })); - const selector = createSearchableSelectList(items, 9); - selector.onSelect = (item) => { - void (async () => { - closeOverlay(); - await setAgent(item.value); - tui.requestRender(); - })(); - }; - selector.onCancel = () => { - closeOverlay(); - tui.requestRender(); - }; - openOverlay(selector); - tui.requestRender(); - }; + const openAgentSelector = async () => { + await refreshAgents(); + if (state.agents.length === 0) { + chatLog.addSystem("no agents found"); + tui.requestRender(); + return; + } + const items = state.agents.map((agent: AgentSummary) => ({ + value: agent.id, + label: agent.name ? `${agent.id} (${agent.name})` : agent.id, + description: agent.id === state.agentDefaultId ? "default" : "", + })); + const selector = createSearchableSelectList(items, 9); + selector.onSelect = (item) => { + void (async () => { + closeOverlay(); + await setAgent(item.value); + tui.requestRender(); + })(); + }; + selector.onCancel = () => { + closeOverlay(); + tui.requestRender(); + }; + openOverlay(selector); + tui.requestRender(); + }; - const openSessionSelector = async () => { - try { - const result = await client.listSessions({ - includeGlobal: false, - includeUnknown: false, - includeDerivedTitles: true, - includeLastMessage: true, - agentId: state.currentAgentId, - }); - const items = result.sessions.map((session) => { - const title = session.derivedTitle ?? session.displayName; - const formattedKey = formatSessionKey(session.key); - // Avoid redundant "title (key)" when title matches key - const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey; - // Build description: time + message preview - const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : ""; - const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim(); - const description = preview ? `${timePart} · ${preview}` : timePart; - return { - value: session.key, - label, - description, - searchText: [ - session.displayName, - session.label, - session.subject, - session.sessionId, - session.key, - session.lastMessagePreview, - ] - .filter(Boolean) - .join(" "), - }; - }); - const selector = createFilterableSelectList(items, 9); - selector.onSelect = (item) => { - void (async () => { - closeOverlay(); - await setSession(item.value); - tui.requestRender(); - })(); - }; - selector.onCancel = () => { - closeOverlay(); - tui.requestRender(); - }; - openOverlay(selector); - tui.requestRender(); - } catch (err) { - chatLog.addSystem(`sessions list failed: ${String(err)}`); - tui.requestRender(); - } - }; + const openSessionSelector = async () => { + try { + const result = await client.listSessions({ + includeGlobal: false, + includeUnknown: false, + includeDerivedTitles: true, + includeLastMessage: true, + agentId: state.currentAgentId, + }); + const items = result.sessions.map((session) => { + const title = session.derivedTitle ?? session.displayName; + const formattedKey = formatSessionKey(session.key); + // Avoid redundant "title (key)" when title matches key + const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey; + // Build description: time + message preview + const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : ""; + const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim(); + const description = + timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart); + return { + value: session.key, + label, + description, + searchText: [ + session.displayName, + session.label, + session.subject, + session.sessionId, + session.key, + session.lastMessagePreview, + ] + .filter(Boolean) + .join(" "), + }; + }); + const selector = createFilterableSelectList(items, 9); + selector.onSelect = (item) => { + void (async () => { + closeOverlay(); + await setSession(item.value); + tui.requestRender(); + })(); + }; + selector.onCancel = () => { + closeOverlay(); + tui.requestRender(); + }; + openOverlay(selector); + tui.requestRender(); + } catch (err) { + chatLog.addSystem(`sessions list failed: ${String(err)}`); + tui.requestRender(); + } + }; - const openSettings = () => { - const items = [ - { - id: "tools", - label: "Tool output", - currentValue: state.toolsExpanded ? "expanded" : "collapsed", - values: ["collapsed", "expanded"], - }, - { - id: "thinking", - label: "Show thinking", - currentValue: state.showThinking ? "on" : "off", - values: ["off", "on"], - }, - ]; - const settings = createSettingsList( - items, - (id, value) => { - if (id === "tools") { - state.toolsExpanded = value === "expanded"; - chatLog.setToolsExpanded(state.toolsExpanded); - } - if (id === "thinking") { - state.showThinking = value === "on"; - void loadHistory(); - } - tui.requestRender(); - }, - () => { - closeOverlay(); - tui.requestRender(); - }, - ); - openOverlay(settings); - tui.requestRender(); - }; + const openSettings = () => { + const items = [ + { + id: "tools", + label: "Tool output", + currentValue: state.toolsExpanded ? "expanded" : "collapsed", + values: ["collapsed", "expanded"], + }, + { + id: "thinking", + label: "Show thinking", + currentValue: state.showThinking ? "on" : "off", + values: ["off", "on"], + }, + ]; + const settings = createSettingsList( + items, + (id, value) => { + if (id === "tools") { + state.toolsExpanded = value === "expanded"; + chatLog.setToolsExpanded(state.toolsExpanded); + } + if (id === "thinking") { + state.showThinking = value === "on"; + void loadHistory(); + } + tui.requestRender(); + }, + () => { + closeOverlay(); + tui.requestRender(); + }, + ); + openOverlay(settings); + tui.requestRender(); + }; - const handleCommand = async (raw: string) => { - const { name, args } = parseCommand(raw); - if (!name) return; - switch (name) { - case "help": - chatLog.addSystem( - helpText({ - provider: state.sessionInfo.modelProvider, - model: state.sessionInfo.model, - }), - ); - break; - case "status": - try { - const status = await client.getStatus(); - if (typeof status === "string") { - chatLog.addSystem(status); - break; - } - if (status && typeof status === "object") { - const lines = formatStatusSummary(status as GatewayStatusSummary); - for (const line of lines) chatLog.addSystem(line); - break; - } - chatLog.addSystem("status: unknown response"); - } catch (err) { - chatLog.addSystem(`status failed: ${String(err)}`); - } - break; - case "agent": - if (!args) { - await openAgentSelector(); - } else { - await setAgent(args); - } - break; - case "agents": - await openAgentSelector(); - break; - case "session": - if (!args) { - await openSessionSelector(); - } else { - await setSession(args); - } - break; - case "sessions": - await openSessionSelector(); - break; - case "model": - if (!args) { - await openModelSelector(); - } else { - try { - await client.patchSession({ - key: state.currentSessionKey, - model: args, - }); - chatLog.addSystem(`model set to ${args}`); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`model set failed: ${String(err)}`); - } - } - break; - case "models": - await openModelSelector(); - break; - case "think": - if (!args) { - const levels = formatThinkingLevels( - state.sessionInfo.modelProvider, - state.sessionInfo.model, - "|", - ); - chatLog.addSystem(`usage: /think <${levels}>`); - break; - } - try { - await client.patchSession({ - key: state.currentSessionKey, - thinkingLevel: args, - }); - chatLog.addSystem(`thinking set to ${args}`); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`think failed: ${String(err)}`); - } - break; - case "verbose": - if (!args) { - chatLog.addSystem("usage: /verbose "); - break; - } - try { - await client.patchSession({ - key: state.currentSessionKey, - verboseLevel: args, - }); - chatLog.addSystem(`verbose set to ${args}`); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`verbose failed: ${String(err)}`); - } - break; - case "reasoning": - if (!args) { - chatLog.addSystem("usage: /reasoning "); - break; - } - try { - await client.patchSession({ - key: state.currentSessionKey, - reasoningLevel: args, - }); - chatLog.addSystem(`reasoning set to ${args}`); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`reasoning failed: ${String(err)}`); - } - break; - case "usage": { - const normalized = args ? normalizeUsageDisplay(args) : undefined; - if (args && !normalized) { - chatLog.addSystem("usage: /usage "); - break; - } - const currentRaw = state.sessionInfo.responseUsage; - const current = resolveResponseUsageMode(currentRaw); - const next = - normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); - try { - await client.patchSession({ - key: state.currentSessionKey, - responseUsage: next === "off" ? null : next, - }); - chatLog.addSystem(`usage footer: ${next}`); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`usage failed: ${String(err)}`); - } - break; - } - case "elevated": - if (!args) { - chatLog.addSystem("usage: /elevated "); - break; - } - try { - await client.patchSession({ - key: state.currentSessionKey, - elevatedLevel: args, - }); - chatLog.addSystem(`elevated set to ${args}`); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`elevated failed: ${String(err)}`); - } - break; - case "activation": - if (!args) { - chatLog.addSystem("usage: /activation "); - break; - } - try { - await client.patchSession({ - key: state.currentSessionKey, - groupActivation: args === "always" ? "always" : "mention", - }); - chatLog.addSystem(`activation set to ${args}`); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`activation failed: ${String(err)}`); - } - break; - case "new": - case "reset": - try { - await client.resetSession(state.currentSessionKey); - chatLog.addSystem(`session ${state.currentSessionKey} reset`); - await loadHistory(); - } catch (err) { - chatLog.addSystem(`reset failed: ${String(err)}`); - } - break; - case "abort": - await abortActive(); - break; - case "settings": - openSettings(); - break; - case "exit": - case "quit": - client.stop(); - tui.stop(); - process.exit(0); - break; - default: - chatLog.addSystem(`unknown command: /${name}`); - break; - } - tui.requestRender(); - }; + const handleCommand = async (raw: string) => { + const { name, args } = parseCommand(raw); + if (!name) return; + switch (name) { + case "help": + chatLog.addSystem( + helpText({ + provider: state.sessionInfo.modelProvider, + model: state.sessionInfo.model, + }), + ); + break; + case "status": + try { + const status = await client.getStatus(); + if (typeof status === "string") { + chatLog.addSystem(status); + break; + } + if (status && typeof status === "object") { + const lines = formatStatusSummary(status as GatewayStatusSummary); + for (const line of lines) chatLog.addSystem(line); + break; + } + chatLog.addSystem("status: unknown response"); + } catch (err) { + chatLog.addSystem(`status failed: ${String(err)}`); + } + break; + case "agent": + if (!args) { + await openAgentSelector(); + } else { + await setAgent(args); + } + break; + case "agents": + await openAgentSelector(); + break; + case "session": + if (!args) { + await openSessionSelector(); + } else { + await setSession(args); + } + break; + case "sessions": + await openSessionSelector(); + break; + case "model": + if (!args) { + await openModelSelector(); + } else { + try { + await client.patchSession({ + key: state.currentSessionKey, + model: args, + }); + chatLog.addSystem(`model set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`model set failed: ${String(err)}`); + } + } + break; + case "models": + await openModelSelector(); + break; + case "think": + if (!args) { + const levels = formatThinkingLevels( + state.sessionInfo.modelProvider, + state.sessionInfo.model, + "|", + ); + chatLog.addSystem(`usage: /think <${levels}>`); + break; + } + try { + await client.patchSession({ + key: state.currentSessionKey, + thinkingLevel: args, + }); + chatLog.addSystem(`thinking set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`think failed: ${String(err)}`); + } + break; + case "verbose": + if (!args) { + chatLog.addSystem("usage: /verbose "); + break; + } + try { + await client.patchSession({ + key: state.currentSessionKey, + verboseLevel: args, + }); + chatLog.addSystem(`verbose set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`verbose failed: ${String(err)}`); + } + break; + case "reasoning": + if (!args) { + chatLog.addSystem("usage: /reasoning "); + break; + } + try { + await client.patchSession({ + key: state.currentSessionKey, + reasoningLevel: args, + }); + chatLog.addSystem(`reasoning set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`reasoning failed: ${String(err)}`); + } + break; + case "usage": { + const normalized = args ? normalizeUsageDisplay(args) : undefined; + if (args && !normalized) { + chatLog.addSystem("usage: /usage "); + break; + } + const currentRaw = state.sessionInfo.responseUsage; + const current = resolveResponseUsageMode(currentRaw); + const next = + normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); + try { + await client.patchSession({ + key: state.currentSessionKey, + responseUsage: next === "off" ? null : next, + }); + chatLog.addSystem(`usage footer: ${next}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`usage failed: ${String(err)}`); + } + break; + } + case "elevated": + if (!args) { + chatLog.addSystem("usage: /elevated "); + break; + } + try { + await client.patchSession({ + key: state.currentSessionKey, + elevatedLevel: args, + }); + chatLog.addSystem(`elevated set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`elevated failed: ${String(err)}`); + } + break; + case "activation": + if (!args) { + chatLog.addSystem("usage: /activation "); + break; + } + try { + await client.patchSession({ + key: state.currentSessionKey, + groupActivation: args === "always" ? "always" : "mention", + }); + chatLog.addSystem(`activation set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`activation failed: ${String(err)}`); + } + break; + case "new": + case "reset": + try { + await client.resetSession(state.currentSessionKey); + chatLog.addSystem(`session ${state.currentSessionKey} reset`); + await loadHistory(); + } catch (err) { + chatLog.addSystem(`reset failed: ${String(err)}`); + } + break; + case "abort": + await abortActive(); + break; + case "settings": + openSettings(); + break; + case "exit": + case "quit": + client.stop(); + tui.stop(); + process.exit(0); + break; + default: + chatLog.addSystem(`unknown command: /${name}`); + break; + } + tui.requestRender(); + }; - const sendMessage = async (text: string) => { - try { - chatLog.addUser(text); - tui.requestRender(); - setActivityStatus("sending"); - const { runId } = await client.sendChat({ - sessionKey: state.currentSessionKey, - message: text, - thinking: opts.thinking, - deliver: deliverDefault, - timeoutMs: opts.timeoutMs, - }); - state.activeChatRunId = runId; - setActivityStatus("waiting"); - } catch (err) { - chatLog.addSystem(`send failed: ${String(err)}`); - setActivityStatus("error"); - } - tui.requestRender(); - }; + const sendMessage = async (text: string) => { + try { + chatLog.addUser(text); + tui.requestRender(); + setActivityStatus("sending"); + const { runId } = await client.sendChat({ + sessionKey: state.currentSessionKey, + message: text, + thinking: opts.thinking, + deliver: deliverDefault, + timeoutMs: opts.timeoutMs, + }); + state.activeChatRunId = runId; + setActivityStatus("waiting"); + } catch (err) { + chatLog.addSystem(`send failed: ${String(err)}`); + setActivityStatus("error"); + } + tui.requestRender(); + }; - return { - handleCommand, - sendMessage, - openModelSelector, - openAgentSelector, - openSessionSelector, - openSettings, - setAgent, - }; + return { + handleCommand, + sendMessage, + openModelSelector, + openAgentSelector, + openSessionSelector, + openSettings, + setAgent, + }; }