diff --git a/runtime/_test-settings.test.js b/runtime/_test-settings.test.js index 8c3d248..3e06713 100644 --- a/runtime/_test-settings.test.js +++ b/runtime/_test-settings.test.js @@ -84,7 +84,8 @@ function loadForkProjectHelpers(document) { extractFunction("enabledForkSessionActions"), extractFunction("forkedSessionPath"), extractFunction("codexAppServerHostId"), - "return { nativeProjectTargets, forkTargetsForAction, enabledForkSessionActions, forkedSessionPath, codexAppServerHostId };", + extractFunction("codexThreadId"), + "return { nativeProjectTargets, forkTargetsForAction, enabledForkSessionActions, forkedSessionPath, sessionProjectContext, codexAppServerHostId, codexThreadId };", ].join("\n"), )(document, document.Element); } @@ -183,6 +184,7 @@ test("disabling port forwarding stops managed tunnels", () => { test("settings page groups options by feature area", () => { expect(source).toContain('codex-helper-settings-section-title text-sm font-medium text-token-text-primary">Basic'); + expect(source).toContain('codex-helper-settings-section-title text-sm font-medium text-token-text-primary">Auto naming'); expect(source).toContain('codex-helper-settings-section-title text-sm font-medium text-token-text-primary">Sessions'); expect(source).toContain('codex-helper-settings-section-title text-sm font-medium text-token-text-primary">Port forwarding'); expect(source).toContain('sectionHeading("Loaded scripts"'); @@ -199,7 +201,13 @@ test("settings page groups options by feature area", () => { expect(source).toContain('forkRemoteProject: "Fork into Remote Project..."'); expect(source).toContain('forkLocalProject: "Fork into Local Project..."'); expect(source).toContain('forkAnotherProject: "Fork into Another Project..."'); - expect(source).toContain('const order = ["export", "fork"]'); + expect(source).toContain('const order = ["autoRename", "export", "fork"]'); + expect(source).toContain('autoRename: "Regenerate chat title"'); + expect(source).toContain('bridge("/auto-rename-chat"'); + expect(source).toContain('logDiagnostic("auto_rename_chat_succeeded"'); + expect(source).toContain('logDiagnostic("auto_rename_chat_failed"'); + expect(source).toContain("await setSidebarConversationTitleForHost("); + expect(source).toContain("autoNamingRangePayload()"); expect(source).not.toContain('move: "Move Session"'); expect(source).not.toContain('copy: "Copy Session"'); expect(source).not.toContain('const order = ["export", "copy", "move", "delete"]'); @@ -209,6 +217,15 @@ test("settings page groups options by feature area", () => { expect(source).not.toContain('">Other'); }); +test("number settings validate the configured character range before saving", () => { + expect(source).toContain("if (value < 1 || value > 20)"); + expect(source).toContain( + "Settings value for ${key} must be between 1 and 20", + ); + expect(source).toContain('logDiagnostic("settings_update_failed", { key, message })'); + expect(source).toContain('applySettings({ status: "ok", settings: featureSettings })'); +}); + test("account menu no longer exposes helper settings dialog entry", () => { expect(source).toContain("Helper Settings"); expect(source).not.toContain("data-codex-helper-account-settings-entry"); @@ -454,6 +471,9 @@ test("session context menu extends Codex native electronBridge menu", () => { expect(source).toContain("buildCodexSessionNativeMenuItems"); expect(source).toContain("openProjectForkMenu"); expect(source).toContain("navigateAfterFork(result, target)"); + expect(source).toContain("Regenerate chat title"); + expect(source).toContain("markdown_friendly_filename_succeeded"); + expect(source).toContain("markdown_friendly_filename_failed"); expect(source).toContain("showHelperToast(result.warning || result.message || \"Forked\")"); expect(source).toContain("window.location.assign(path)"); expect(source).toContain("nativeProjectTargets"); @@ -695,6 +715,42 @@ test("fork success refreshes sidebar through Codex recent conversations manager" expect(source).toContain('"sidebar_refresh_manager_missing"'); }); +test("auto rename updates Codex sidebar manager before refreshing", () => { + expect(source).toContain("function codexThreadId(sessionId)"); + expect(source).toContain( + "async function setSidebarConversationTitleForHost(hostId, sessionId, title)", + ); + expect(source).not.toContain('manager.sendRequest("thread/name/set"'); + expect(source).toContain("manager.applyThreadTitleUpdateAndNotify({"); + expect(source).toContain('"sidebar_title_update_failed"'); + expect(source).toContain("await setSidebarConversationTitleForHost("); + expect(source).toContain("await refreshSidebarConversationsForHost(context.hostId)"); +}); + +test("auto rename preserves remote host context for sidebar title updates", () => { + const document = fakeProjectDocument([], "/srv/current"); + const helpers = loadForkProjectHelpers(document); + const row = new document.Element({ + "data-app-action-sidebar-thread-id": "remote:thread-1", + "data-app-action-sidebar-thread-cwd": "/srv/current", + "data-app-action-sidebar-thread-host-id": "remote-ssh-codex-managed:box", + }); + + expect(helpers.sessionProjectContext(row)).toEqual({ + hostId: "remote-ssh-codex-managed:box", + remote: true, + path: "/srv/current", + }); + expect(helpers.codexAppServerHostId("remote-ssh-codex-managed:box")).toBe( + "remote-ssh-codex-managed:box", + ); + expect(helpers.codexThreadId("remote:thread-1")).toBe("thread-1"); + expect(source).toContain("host_id: context.hostId"); + expect(source).toContain( + "setSidebarConversationTitleForHost(\n context.hostId", + ); +}); + test("codex app-server helpers normalize host ids", () => { const helpers = loadForkProjectHelpers(fakeProjectDocument([])); diff --git a/runtime/bootstrap.js b/runtime/bootstrap.js index 07e28d7..4a009e1 100644 --- a/runtime/bootstrap.js +++ b/runtime/bootstrap.js @@ -144,6 +144,22 @@ function onHelperRuntimeKeydown(event) { function onHelperRuntimeChange(event) { const target = event.target; if (!(target instanceof HTMLInputElement)) return; + if (target.hasAttribute(helperNumberAttribute)) { + event.preventDefault(); + event.stopPropagation(); + handleHelperNumberInput(target).catch((error) => { + target.disabled = false; + setHelperText( + "[data-codex-helper-backend]", + error?.message || String(error), + ); + logDiagnostic("settings_update_failed", { + key: target.getAttribute(helperNumberAttribute), + error: error?.message || String(error), + }); + }); + return; + } if (!target.hasAttribute(helperToggleAttribute)) return; event.preventDefault(); event.stopPropagation(); diff --git a/runtime/constants.js b/runtime/constants.js index 023597f..9132dca 100644 --- a/runtime/constants.js +++ b/runtime/constants.js @@ -4,6 +4,7 @@ const helperContentHostAttribute = "data-codex-helper-content-host"; const helperPageAttribute = "data-codex-helper-settings-page"; const helperCommandAttribute = "data-codex-helper-command"; const helperToggleAttribute = "data-codex-helper-setting-toggle"; +const helperNumberAttribute = "data-codex-helper-setting-number"; const helperToastAttribute = "data-codex-helper-toast"; const helperSessionActionPrefix = "codex-helper-session-"; const helperSettingsSectionAttribute = "data-codex-helper-settings-section"; @@ -56,6 +57,10 @@ const suppressedPortMappings = new Set(); let featureSettings = { markdownExportEnabled: false, sessionMoveEnabled: false, + autoRenameMenuEnabled: false, + markdownFriendlyFilenameEnabled: true, + autoNamingMinChars: 4, + autoNamingMaxChars: 10, portForwardingEnabled: false, portAutoForwardWeb: true, portSameLocalPort: true, diff --git a/runtime/native-settings.js b/runtime/native-settings.js index 7ffab5e..384f0db 100644 --- a/runtime/native-settings.js +++ b/runtime/native-settings.js @@ -447,6 +447,17 @@ function nativeSettingsSwitchRow(title, description, descKey, toggleKey, ariaLab `; } +function nativeSettingsNumberRow(title, description, numberKey, ariaLabel) { + return ` +
+
+
${title}
+
${description}
+
+ +
`; +} + function nativeSettingsActionRow(title, detail, command, buttonLabel, detailAttr = "") { return `
@@ -529,6 +540,12 @@ function nativeSettingsPageContent(pageId) { ${nativeSettingsSwitchRow("Markdown export", "Export conversations as Markdown from the session menu.", "markdownExportEnabled", "markdownExportEnabled", "Markdown export")} ${nativeSettingsSwitchRow("Fork sessions", "Fork sessions into local, remote, or another project from the sidebar context menu.", "sessionMoveEnabled", "sessionMoveEnabled", "Fork sessions")} `)} + ${nativeSettingsPanel(` + ${nativeSettingsSwitchRow("Regenerate chat title", "Show Regenerate chat title in the session context menu.", "autoRenameMenuEnabled", "autoRenameMenuEnabled", "Regenerate chat title")} + ${nativeSettingsSwitchRow("Friendly Markdown filenames", "Use Codex auto naming for exported Markdown filenames.", "markdownFriendlyFilenameEnabled", "markdownFriendlyFilenameEnabled", "Friendly Markdown filenames")} + ${nativeSettingsNumberRow("Minimum characters", "Smallest expected auto name length.", "autoNamingMinChars", "Minimum auto naming characters")} + ${nativeSettingsNumberRow("Maximum characters", "Largest expected auto name length; 10 works well for Chinese names.", "autoNamingMaxChars", "Maximum auto naming characters")} + `)} ${nativeSettingsPanel(` ${nativeSettingsSwitchRow("Enable port forwarding", "Detect and forward ports from agent sessions.", "portForwardingEnabled", "portForwardingEnabled", "Enable port forwarding")} ${nativeSettingsSwitchRow("Auto-forward detected web ports", "Open forwarded web URLs when a common dev port is detected.", "portAutoForwardWeb", "portAutoForwardWeb", "Auto-forward detected web ports")} diff --git a/runtime/sessions.js b/runtime/sessions.js index 31941f5..f66aa79 100644 --- a/runtime/sessions.js +++ b/runtime/sessions.js @@ -1,7 +1,8 @@ // Session context menu, actions, project forks, and toast UI function enabledSessionActions() { - const order = ["export", "fork"]; + const order = ["autoRename", "export", "fork"]; return order.filter((action) => { + if (action === "autoRename") return featureSettings.autoRenameMenuEnabled; if (action === "export") return featureSettings.markdownExportEnabled; if (action === "fork") return featureSettings.sessionMoveEnabled; return false; @@ -85,6 +86,13 @@ return sessionRemoteHostId(hostId) || "local"; } + function codexThreadId(sessionId) { + return String(sessionId || "") + .trim() + .replace(/^local:/, "") + .replace(/^remote:/, ""); + } + function sessionRowIsPinned(row) { return row.getAttribute("data-app-action-sidebar-thread-pinned") === "true"; } @@ -556,6 +564,7 @@ function sessionActionMenuLabels() { return { + autoRename: "Regenerate chat title", export: "Export Markdown", forkRemoteProject: "Fork into Remote Project...", forkLocalProject: "Fork into Local Project...", @@ -567,6 +576,8 @@ const svgs = { export: '', + autoRename: + '', forkRemoteProject: '', forkLocalProject: @@ -750,24 +761,114 @@ } } + async function setSidebarConversationTitleForHost(hostId, sessionId, title) { + const normalizedHostId = codexAppServerHostId(hostId); + const threadId = codexThreadId(sessionId); + const name = String(title || "").trim(); + if (!threadId || !name) return false; + const manager = collectSidebarConversationManagers().find( + (candidate) => candidate.hostId === normalizedHostId, + ); + if (!manager) { + logDiagnostic("sidebar_title_manager_missing", { + host_id: normalizedHostId, + session_id: threadId, + }); + return false; + } + try { + const conversation = + typeof manager.getConversation === "function" + ? manager.getConversation(threadId) + : null; + if ( + conversation && + typeof manager.applyThreadTitleUpdateAndNotify === "function" + ) { + manager.applyThreadTitleUpdateAndNotify({ + ...conversation, + title: name, + }); + } + return true; + } catch (error) { + logDiagnostic("sidebar_title_update_failed", { + host_id: normalizedHostId, + session_id: threadId, + message: error?.message || String(error), + }); + return false; + } + } + async function refreshSidebarAfterFork(target) { await refreshSidebarConversationsForHost(target?.hostId || ""); } + function autoNamingRangePayload() { + return { + autoNamingMinChars: featureSettings.autoNamingMinChars, + autoNamingMaxChars: featureSettings.autoNamingMaxChars, + }; + } + async function handleSessionAction(action, row, ref) { + if (action === "autoRename") { + const context = sessionProjectContext(row); + const payload = { + ...ref, + host_id: context.hostId, + ...autoNamingRangePayload(), + }; + const result = await bridge("/auto-rename-chat", payload); + if (result?.status !== "renamed") { + logDiagnostic("auto_rename_chat_failed", { + session_id: ref.session_id, + message: result?.message || "Auto rename failed", + }); + throw new Error(result?.message || "Auto rename failed"); + } + logDiagnostic("auto_rename_chat_succeeded", { + session_id: ref.session_id, + name: result.name || "", + source: result.source || "", + }); + await setSidebarConversationTitleForHost( + context.hostId, + ref.session_id, + result.name || "", + ); + await refreshSidebarConversationsForHost(context.hostId); + showHelperToast(result.message || "Regenerated chat title"); + return; + } if (action === "export") { const context = sessionProjectContext(row); const result = await bridge("/export-markdown", { ...ref, host_id: context.hostId, + friendlyFilename: featureSettings.markdownFriendlyFilenameEnabled, + ...autoNamingRangePayload(), }); if ( result?.status !== "exported" || typeof result.markdown !== "string" || !result.filename ) { + if (featureSettings.markdownFriendlyFilenameEnabled) { + logDiagnostic("markdown_friendly_filename_failed", { + session_id: ref.session_id, + message: result?.message || "Export failed", + }); + } throw new Error(result?.message || "Export failed"); } + if (featureSettings.markdownFriendlyFilenameEnabled) { + logDiagnostic("markdown_friendly_filename_succeeded", { + session_id: ref.session_id, + filename: result.filename, + }); + } downloadMarkdown(result.filename, result.markdown); showHelperToast(result.message || "Exported"); return; diff --git a/runtime/settings.js b/runtime/settings.js index 09b074b..7de8c7a 100644 --- a/runtime/settings.js +++ b/runtime/settings.js @@ -37,6 +37,14 @@ function renderHelperPage(host, options = {}) {
`; + const numberRow = (title, description, numberKey, ariaLabel) => ` +
+
+
${title}
+
${description}
+
+ +
`; const sectionLinkIcon = ``; const sectionHeading = (title, command, ariaLabel) => `
@@ -91,6 +99,15 @@ function renderHelperPage(host, options = {}) { ${switchRow("Fork sessions", "Fork sessions into local, remote, or another project from the sidebar context menu.", "sessionMoveEnabled", "sessionMoveEnabled", "Fork sessions")} `)} +
+
Auto naming
+ ${settingsPanel(` + ${switchRow("Regenerate chat title", "Show Regenerate chat title in the session context menu.", "autoRenameMenuEnabled", "autoRenameMenuEnabled", "Regenerate chat title")} + ${switchRow("Friendly Markdown filenames", "Use Codex auto naming for exported Markdown filenames.", "markdownFriendlyFilenameEnabled", "markdownFriendlyFilenameEnabled", "Friendly Markdown filenames")} + ${numberRow("Minimum characters", "Smallest expected auto name length.", "autoNamingMinChars", "Minimum auto naming characters")} + ${numberRow("Maximum characters", "Largest expected auto name length; 10 works well for Chinese names.", "autoNamingMaxChars", "Maximum auto naming characters")} + `)} +
Port forwarding
${settingsPanel(` @@ -319,6 +336,11 @@ function applySettings(result) { const key = input.getAttribute(helperToggleAttribute) || ""; input.checked = settings[key] === true; } + for (const input of root.querySelectorAll(`[${helperNumberAttribute}]`)) { + if (!(input instanceof HTMLInputElement)) continue; + const key = input.getAttribute(helperNumberAttribute) || ""; + if (Number.isInteger(settings[key])) input.value = String(settings[key]); + } } maintainPortsPanel(); if (featureSettings.portForwardingEnabled) schedulePortScan(); @@ -378,6 +400,33 @@ async function handleHelperToggle(input) { applySettings(result); } +async function handleHelperNumberInput(input) { + const key = input.getAttribute(helperNumberAttribute) || ""; + if (!key) return; + const value = Number(input.value); + if (!Number.isInteger(value)) throw new Error(`Settings value for ${key} must be an integer`); + if (value < 1 || value > 20) { + const message = `Settings value for ${key} must be between 1 and 20`; + setHelperText("[data-codex-helper-backend]", message); + logDiagnostic("settings_update_failed", { key, message }); + applySettings({ status: "ok", settings: featureSettings }); + return; + } + input.disabled = true; + const result = await bridge("/settings/set", { [key]: value }); + input.disabled = false; + if (result?.status !== "ok") { + setHelperText( + "[data-codex-helper-backend]", + result?.message || "Settings update failed", + ); + logDiagnostic("settings_update_failed", { key, result }); + applySettings({ status: "ok", settings: featureSettings }); + return; + } + applySettings(result); +} + async function refreshFeatureSettings() { const result = await bridge("/settings/get"); if (result?.status === "ok" && result.settings) { diff --git a/runtime/styles.js b/runtime/styles.js index 13ee2c3..526f0cb 100644 --- a/runtime/styles.js +++ b/runtime/styles.js @@ -104,6 +104,16 @@ function installHelperStyles() { [${helperNativeSettingsPageAttribute}] .codex-helper-switch input:checked + span > span { transform: translateX(14px); } + [${helperNativeSettingsPageAttribute}] .codex-helper-number-input { + width: 64px; + border: 1px solid color-mix(in srgb, currentColor 16%, transparent); + border-radius: 6px; + background: transparent; + color: inherit; + padding: 3px 6px; + font: inherit; + font-size: 13px; + } [${helperNativeSettingsPageAttribute}] pre[data-codex-helper-log] { margin: 0; padding: 12px; @@ -314,6 +324,16 @@ function installHelperStyles() { [${helperPageAttribute}] .codex-helper-switch input:checked + span > span { transform: translateX(14px); } + [${helperPageAttribute}] .codex-helper-number-input { + width: 64px; + border: 1px solid color-mix(in srgb, currentColor 16%, transparent); + border-radius: 6px; + background: transparent; + color: inherit; + padding: 3px 6px; + font: inherit; + font-size: 13px; + } [${helperPageAttribute}] pre[data-codex-helper-log] { margin: 0; padding: 12px; diff --git a/src-tauri/src/bridge_cli.rs b/src-tauri/src/bridge_cli.rs index dae7b93..43c77b2 100644 --- a/src-tauri/src/bridge_cli.rs +++ b/src-tauri/src/bridge_cli.rs @@ -1,4 +1,6 @@ -use codex_helper::session_actions::{export_markdown_response, fork_thread_project_response}; +use codex_helper::session_actions::{ + auto_rename_chat_response, export_markdown_response, fork_thread_project_response, +}; use codex_helper::zed::remote_projects_response; use serde_json::json; use std::env; @@ -19,6 +21,7 @@ fn main() { json!({}) }; let response = match path { + "/auto-rename-chat" => auto_rename_chat_response(&payload), "/export-markdown" => export_markdown_response(&payload), "/fork-thread-project" => fork_thread_project_response(&payload), "/projects/remote-list" => remote_projects_response(&payload), diff --git a/src-tauri/src/codex_app_server.rs b/src-tauri/src/codex_app_server.rs index 1b5dbfd..4941de0 100644 --- a/src-tauri/src/codex_app_server.rs +++ b/src-tauri/src/codex_app_server.rs @@ -8,6 +8,8 @@ use serde_json::{json, Value}; use crate::zed::SshTarget; +const GENERATED_TITLE_TIMEOUT_SECS: u64 = 120; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ForkedThread { pub session_id: String, @@ -259,15 +261,147 @@ impl CodexAppServerClient { } fn rename_thread_best_effort(&self, thread_id: &str, name: &str) -> Option { - let output = self.run_requests(&[ - build_initialize_request(1), - build_thread_name_request(2, thread_id, name), - ]); - match output.and_then(|output| json_rpc_response_by_id(&output, 2).map(|_| ())) { + match self.set_thread_name(thread_id, name) { Ok(()) => None, Err(error) => Some(format!("Conversation forked, but rename failed: {error}")), } } + + pub fn set_thread_name(&self, thread_id: &str, name: &str) -> anyhow::Result<()> { + let thread_id = normalize_thread_id(thread_id); + if thread_id.is_empty() { + anyhow::bail!("Thread id is empty"); + } + let name = name.trim(); + if name.is_empty() { + anyhow::bail!("Thread name is empty"); + } + let output = self.run_requests(&[ + build_initialize_request(1), + build_thread_name_request(2, &thread_id, name), + ])?; + json_rpc_response_by_id(&output, 2).map(|_| ()) + } + + pub fn generate_thread_name( + &self, + thread_id: &str, + min_chars: u8, + max_chars: u8, + ) -> anyhow::Result { + let thread_id = normalize_thread_id(thread_id); + if thread_id.is_empty() { + anyhow::bail!("Thread id is empty"); + } + let transcript = self.thread_transcript(&thread_id)?; + self.generate_name_from_transcript(&transcript, min_chars, max_chars) + } + + fn thread_transcript(&self, thread_id: &str) -> anyhow::Result { + let output = self.run_requests(&[ + build_initialize_request(1), + build_thread_read_request(2, thread_id), + ])?; + let response = json_rpc_response_by_id(&output, 2)?; + thread_transcript_from_read_response(&response) + } + + fn generate_name_from_transcript( + &self, + transcript: &str, + min_chars: u8, + max_chars: u8, + ) -> anyhow::Result { + let prompt = title_generation_prompt(transcript, min_chars, max_chars); + let mut child = self + .app_server_command() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("Codex app-server stdout is unavailable"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| anyhow::anyhow!("Codex app-server stderr is unavailable"))?; + let stderr_thread = std::thread::spawn(move || { + let mut output = String::new(); + let mut reader = BufReader::new(stderr); + let _ = reader.read_to_string(&mut output); + output + }); + let (line_tx, line_rx) = mpsc::channel(); + let stdout_thread = std::thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + if line_tx.send(line).is_err() { + break; + } + } + }); + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("Codex app-server stdin is unavailable"))?; + let result = (|| -> anyhow::Result { + let mut output = String::new(); + send_json_line(&mut stdin, &build_initialize_request(1))?; + recv_json_rpc_response_by_id(&line_rx, &mut output, 1) + .and_then(|response| json_rpc_response_error(&response))?; + send_json_line(&mut stdin, &build_initialized_notification())?; + send_json_line(&mut stdin, &build_title_generation_thread_request(2))?; + let thread_response = recv_json_rpc_response_by_id(&line_rx, &mut output, 2)?; + json_rpc_response_error(&thread_response)?; + let generation_thread_id = thread_response + .get("result") + .and_then(|result| result.get("thread")) + .and_then(|thread| thread.get("id")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("Title generation thread id is missing"))?; + send_json_line( + &mut stdin, + &build_title_generation_turn_request(3, generation_thread_id, &prompt), + )?; + let turn_response = recv_json_rpc_response_by_id(&line_rx, &mut output, 3)?; + json_rpc_response_error(&turn_response)?; + recv_generated_title(&line_rx) + })(); + drop(stdin); + if result.is_err() { + let _ = child.kill(); + } + let status = child.wait()?; + let _ = stdout_thread.join(); + let stderr = stderr_thread.join().unwrap_or_default(); + match result { + Ok(name) => { + if !status.success() { + let stderr = stderr.trim().to_string(); + anyhow::bail!( + "{}", + if stderr.is_empty() { + format!("Codex title generation failed with status {status}") + } else { + stderr + } + ); + } + Ok(name) + } + Err(error) => { + let stderr = stderr.trim().to_string(); + if !status.success() && !stderr.is_empty() { + anyhow::bail!("{stderr}"); + } + Err(error) + } + } + } } impl ThreadForker for CodexAppServerClient { @@ -472,6 +606,144 @@ pub fn build_thread_name_request(id: i64, thread_id: &str, name: &str) -> Value }) } +pub fn build_thread_read_request(id: i64, thread_id: &str) -> Value { + json!({ + "id": id, + "method": "thread/read", + "params": { + "threadId": thread_id, + "includeTurns": true + } + }) +} + +fn build_title_generation_thread_request(id: i64) -> Value { + json!({ + "id": id, + "method": "thread/start", + "params": { + "cwd": "/tmp", + "ephemeral": true, + "approvalPolicy": "never", + "sandbox": "read-only", + "baseInstructions": "You generate concise, friendly, accurate chat titles. Respond with only the title." + } + }) +} + +fn build_title_generation_turn_request(id: i64, thread_id: &str, prompt: &str) -> Value { + json!({ + "id": id, + "method": "turn/start", + "params": { + "threadId": thread_id, + "input": [{ + "type": "text", + "text": prompt, + "text_elements": [] + }], + "approvalPolicy": "never", + "sandboxPolicy": { + "type": "readOnly", + "networkAccess": false + } + } + }) +} + +fn title_generation_prompt(transcript: &str, min_chars: u8, max_chars: u8) -> String { + let transcript = compact_title_transcript(transcript); + format!( + "Generate one friendly, accurate title for this Codex chat.\n\ +Rules:\n\ +- Output only the title, with no quotes or punctuation wrapper.\n\ +- Prefer Chinese when the transcript is mainly Chinese; use English when it is mainly English.\n\ +- Target {min_chars}-{max_chars} Chinese characters, or no more than 5 English words.\n\ +- Avoid generic prompt prefixes such as \"Check the\", \"Help me\", or \"Please\".\n\ +- Capture the actual topic, project, product, or task.\n\n\ +Transcript:\n{transcript}" + ) +} + +fn compact_title_transcript(transcript: &str) -> String { + let cleaned = transcript + .replace(['\r', '\t'], " ") + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n"); + let chars = cleaned.chars().collect::>(); + if chars.len() <= 6000 { + return cleaned; + } + let head = chars.iter().take(3000).collect::(); + let tail = chars + .iter() + .skip(chars.len().saturating_sub(3000)) + .collect::(); + format!("{head}\n...\n{tail}") +} + +fn thread_transcript_from_read_response(response: &Value) -> anyhow::Result { + let turns = response + .get("result") + .and_then(|result| result.get("thread")) + .and_then(|thread| thread.get("turns")) + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("thread/read response missing thread turns"))?; + let mut messages = Vec::new(); + for turn in turns { + let Some(items) = turn.get("items").and_then(Value::as_array) else { + continue; + }; + for item in items { + match item.get("type").and_then(Value::as_str) { + Some("userMessage") => { + let text = user_message_text(item); + if !text.trim().is_empty() { + messages.push(format!("User: {}", collapse_prompt_text(&text))); + } + } + Some("agentMessage") => { + let text = item.get("text").and_then(Value::as_str).unwrap_or_default(); + if !text.trim().is_empty() { + messages.push(format!("Assistant: {}", collapse_prompt_text(text))); + } + } + _ => {} + } + } + } + let transcript = messages.join("\n"); + if transcript.trim().is_empty() { + anyhow::bail!("Thread transcript is empty"); + } + Ok(transcript) +} + +fn user_message_text(item: &Value) -> String { + item.get("content") + .and_then(Value::as_array) + .map(|content| { + content + .iter() + .filter(|part| part.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n") + }) + .unwrap_or_default() +} + +fn collapse_prompt_text(value: &str) -> String { + value + .replace(['\r', '\t'], " ") + .split_whitespace() + .collect::>() + .join(" ") +} + #[cfg(test)] pub fn request_batch(requests: &[Value]) -> anyhow::Result { let mut input = String::new(); @@ -518,6 +790,64 @@ fn recv_json_rpc_response_by_id( } } +fn recv_generated_title(lines: &Receiver) -> anyhow::Result { + let mut latest_title = String::new(); + loop { + let line = lines + .recv_timeout(Duration::from_secs(GENERATED_TITLE_TIMEOUT_SECS)) + .map_err(|_| anyhow::anyhow!("Codex title generation timed out"))?; + let response: Value = serde_json::from_str(line.trim())?; + if response.get("method").and_then(Value::as_str) == Some("item/completed") { + if let Some(item) = response.get("params").and_then(|params| params.get("item")) { + if item.get("type").and_then(Value::as_str) == Some("agentMessage") { + latest_title = item + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + } + } + } + if response.get("method").and_then(Value::as_str) != Some("turn/completed") { + continue; + } + let status = response + .get("params") + .and_then(|params| params.get("turn")) + .and_then(|turn| turn.get("status")) + .and_then(Value::as_str) + .unwrap_or_default(); + if status != "completed" { + anyhow::bail!("Codex title generation turn failed"); + } + return clean_generated_title(&latest_title); + } +} + +fn clean_generated_title(value: &str) -> anyhow::Result { + let title = value + .replace(['\r', '\n', '\t'], " ") + .split_whitespace() + .collect::>() + .join(" ") + .trim_matches(|ch: char| { + ch.is_whitespace() + || ch == '"' + || ch == '\'' + || ch == '`' + || ch == '“' + || ch == '”' + || ch == '‘' + || ch == '’' + }) + .trim_matches(|ch: char| ch.is_ascii_punctuation()) + .to_string(); + if title.is_empty() { + anyhow::bail!("Codex generated title is empty"); + } + Ok(title) +} + pub fn json_rpc_response_by_id(output: &str, id: i64) -> anyhow::Result { for line in output .lines() @@ -641,6 +971,24 @@ mod tests { ); } + #[test] + fn generated_title_prompt_uses_transcript_only() { + let prompt = super::title_generation_prompt( + "User: Check the current codebase.\nAssistant: Confirmed Admin preset API wiring.", + 4, + 10, + ); + + assert!(prompt.contains("Transcript:")); + assert!(prompt.contains("Confirmed Admin preset API wiring")); + assert!(!prompt.contains("Existing title")); + } + + #[test] + fn generated_title_timeout_matches_bridge_budget() { + assert_eq!(super::GENERATED_TITLE_TIMEOUT_SECS, 120); + } + #[test] fn extracts_forked_thread_id_from_response() { let response = json!({ diff --git a/src-tauri/src/markdown.rs b/src-tauri/src/markdown.rs index 4fffbb1..5cd8a7a 100644 --- a/src-tauri/src/markdown.rs +++ b/src-tauri/src/markdown.rs @@ -1,101 +1,51 @@ -use crate::models::{ExportResult, ExportStatus, SessionRef}; -use rusqlite::Connection; +use crate::models::{ExportResult, ExportStatus}; use serde_json::Value; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; -#[derive(Debug, Clone)] -pub struct MarkdownExportService { - db_path: Option, +#[derive(Debug)] +pub struct ExportableRollout { + messages: Vec, } -impl MarkdownExportService { - pub fn new(db_path: Option>) -> Self { - Self { - db_path: db_path.map(Into::into), - } - } - - pub fn export(&self, session: &SessionRef) -> ExportResult { - let Some(db_path) = &self.db_path else { - return failed(&session.session_id, "Local Codex database not configured"); - }; - if !db_path.exists() { - return failed( - &session.session_id, - format!("Database does not exist:{}", db_path.to_string_lossy()), - ); - } - let thread_id = normalize_session_id(&session.session_id); - let result = (|| -> anyhow::Result { - let db = Connection::open(db_path)?; - if !supports_codex_threads(&db)? { - return Ok(failed( - &thread_id, - "Current local storage structure not supported", - )); - } - let row = db.query_row( - "SELECT id, title, rollout_path FROM threads WHERE id = ?1", - [&thread_id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - )) - }, - ); - let (_, title, rollout_path) = match row { - Ok(row) => row, - Err(rusqlite::Error::QueryReturnedNoRows) => { - return Ok(failed(&thread_id, "Corresponding session not found")); - } - Err(err) => return Err(err.into()), - }; - let title = display_title(title.as_deref().unwrap_or(&session.title)); - let Some(rollout_path) = rollout_path.filter(|path| !path.is_empty()) else { - return Ok(failed(&thread_id, "Session missing rollout file path")); - }; - if !Path::new(&rollout_path).is_file() { - return Ok(failed( - &thread_id, - format!("Rollout file does not exist:{rollout_path}"), - )); - } - Ok(export_rollout(&thread_id, &title, Path::new(&rollout_path))) - })(); - result.unwrap_or_else(|err| failed(&thread_id, format!("Failed to read rollout:{err}"))) +pub fn export_validated_rollout( + thread_id: &str, + title: &str, + rollout: &ExportableRollout, +) -> ExportResult { + let title = display_title(title); + let filename = build_filename(&title, thread_id); + let markdown = render_markdown(&title, &rollout.messages); + ExportResult { + status: ExportStatus::Exported, + session_id: thread_id.to_string(), + message: format!("Exported as Markdown:{filename}"), + filename: Some(filename), + markdown: Some(markdown), } } -pub fn export_rollout(thread_id: &str, title: &str, rollout_path: &Path) -> ExportResult { - let title = display_title(title); - let result = (|| -> anyhow::Result { - if !rollout_path.is_file() { - anyhow::bail!( +pub fn validate_exportable_rollout( + thread_id: &str, + rollout_path: &Path, +) -> Result { + if !rollout_path.is_file() { + return Err(failed( + thread_id, + format!( "Rollout file does not exist:{}", rollout_path.to_string_lossy() - ); - } - let messages = load_messages(rollout_path)?; - if messages.is_empty() { - return Ok(failed( - thread_id, - "No exportable user or assistant messages found", - )); - } - let filename = build_filename(&title, thread_id); - let markdown = render_markdown(&title, &messages); - Ok(ExportResult { - status: ExportStatus::Exported, - session_id: thread_id.to_string(), - message: format!("Exported as Markdown:{filename}"), - filename: Some(filename), - markdown: Some(markdown), - }) - })(); - result.unwrap_or_else(|err| failed(thread_id, format!("Failed to read rollout:{err}"))) + ), + )); + } + match load_messages(rollout_path) { + Ok(messages) if messages.is_empty() => Err(failed( + thread_id, + "No exportable user or assistant messages found", + )), + Ok(messages) => Ok(ExportableRollout { messages }), + Err(error) => Err(failed(thread_id, format!("Failed to read rollout:{error}"))), + } } #[derive(Debug)] @@ -115,26 +65,6 @@ fn failed(session_id: &str, message: impl Into) -> ExportResult { } } -fn supports_codex_threads(db: &Connection) -> anyhow::Result { - let has_threads = db - .query_row( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'threads'", - [], - |_| Ok(()), - ) - .is_ok(); - if !has_threads { - return Ok(false); - } - let mut stmt = db.prepare("PRAGMA table_info(\"threads\")")?; - let columns = stmt - .query_map([], |row| row.get::<_, String>(1))? - .collect::>>()?; - Ok(["id", "title", "rollout_path"] - .iter() - .all(|column| columns.iter().any(|existing| existing == column))) -} - fn load_messages(path: &Path) -> anyhow::Result> { let mut messages = Vec::new(); for raw in fs::read_to_string(path)?.lines() { @@ -266,13 +196,6 @@ fn render_markdown(title: &str, messages: &[Message]) -> String { format!("{}\n", lines.join("\n").trim_end()) } -fn normalize_session_id(session_id: &str) -> String { - session_id - .strip_prefix("local:") - .unwrap_or(session_id) - .to_string() -} - fn normalize_newlines(value: &str) -> String { value.replace("\r\n", "\n").replace('\r', "\n") } @@ -292,3 +215,35 @@ fn replace_windows_filename_chars(value: &str, replacement: &str) -> String { fn collapse_whitespace(value: &str) -> String { value.split_whitespace().collect::>().join(" ") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_exportable_rollout_rejects_empty_rollouts() { + let file = tempfile::NamedTempFile::new().expect("rollout"); + + let result = validate_exportable_rollout("thread-1", file.path()) + .expect_err("empty rollout should not be exportable"); + + assert_eq!(result.status, ExportStatus::Failed); + assert!(result + .message + .contains("No exportable user or assistant messages")); + } + + #[test] + fn validate_exportable_rollout_accepts_message_rollouts() { + let file = tempfile::NamedTempFile::new().expect("rollout"); + fs::write( + file.path(), + r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}"#, + ) + .expect("write rollout"); + + let result = validate_exportable_rollout("thread-1", file.path()); + + assert!(result.is_ok()); + } +} diff --git a/src-tauri/src/routes.rs b/src-tauri/src/routes.rs index 2a37c39..4204ddf 100644 --- a/src-tauri/src/routes.rs +++ b/src-tauri/src/routes.rs @@ -8,7 +8,9 @@ use crate::ports::{ discover_remote_listening_ports, discovery_request_from_payload, request_from_payload, PortForwardManager, }; -use crate::session_actions::{export_markdown_response, fork_thread_project_response}; +use crate::session_actions::{ + auto_rename_chat_response, export_markdown_response, fork_thread_project_response, +}; use crate::settings::{read_settings, update_settings}; use crate::state_dir::StateDir; use crate::zed::{ @@ -63,6 +65,7 @@ pub async fn handle_bridge_request(ctx: BridgeContext, path: &str, payload: Valu "/state/reveal" => reveal_path_response(&ctx.state_dir.root), "/devtools/open" => open_devtools_response(ctx.debug_port).await, "/url/open-external" => open_external_local_url_response(&payload), + "/auto-rename-chat" => auto_rename_chat_response(&payload), "/export-markdown" => export_markdown_response(&payload), "/fork-thread-project" => fork_thread_project_response(&payload), "/ports/list" => ctx.port_manager.list().await, diff --git a/src-tauri/src/session_actions.rs b/src-tauri/src/session_actions.rs index ad5017f..95980f5 100644 --- a/src-tauri/src/session_actions.rs +++ b/src-tauri/src/session_actions.rs @@ -6,7 +6,7 @@ use rusqlite::Connection; use serde_json::{json, Value}; use crate::codex_app_server::{CodexAppServerClient, ThreadForker}; -use crate::markdown::{export_rollout, MarkdownExportService}; +use crate::markdown::{export_validated_rollout, validate_exportable_rollout}; use crate::models::SessionRef; use crate::zed::{resolve_ssh_target_for_host_id, SshTarget}; @@ -15,23 +15,38 @@ pub fn export_markdown_response(payload: &Value) -> Value { Ok(session) => { let host_id = string_payload(payload, "host_id") .or_else_nonempty(|| string_payload(payload, "hostId")); - if host_id.is_empty() { - return match default_codex_db_path() { - Ok(db_path) => serde_json::to_value( - MarkdownExportService::new(Some(db_path)).export(&session), + let result = (|| -> anyhow::Result { + let thread_id = crate::codex_app_server::normalize_thread_id(&session.session_id); + let (base_title, rollout) = if host_id.is_empty() { + let db_path = default_codex_db_path()?; + if !db_path.exists() { + anyhow::bail!("Database does not exist:{}", db_path.to_string_lossy()); + } + let record = local_thread_record(&db_path, &session)?; + ( + record.title.unwrap_or_else(|| session.title.clone()), + SourceRollout::Local(PathBuf::from(record.rollout_path)), + ) + } else { + let target = resolve_ssh_target_for_host_id(&host_id, None)?; + let record = remote_thread_record(&target, &session)?; + let rollout = download_remote_rollout(&target, &record.rollout_path)?; + ( + record.title.unwrap_or_else(|| session.title.clone()), + SourceRollout::Temp(rollout), ) - .unwrap_or_else(failed_value), - Err(error) => failed_export_value(&session.session_id, error.to_string()), }; - } - let result = (|| -> anyhow::Result { - let target = resolve_ssh_target_for_host_id(&host_id, None)?; - let record = remote_thread_record(&target, &session)?; - let rollout = download_remote_rollout(&target, &record.rollout_path)?; - Ok(serde_json::to_value(export_rollout( - &crate::codex_app_server::normalize_thread_id(&session.session_id), - &record.title.unwrap_or_else(|| session.title.clone()), - rollout.path(), + let exportable_rollout = + match validate_exportable_rollout(&thread_id, rollout.path()) { + Ok(exportable_rollout) => exportable_rollout, + Err(result) => return Ok(serde_json::to_value(result)?), + }; + let title = + friendly_title_for_export(payload, &session, &host_id)?.unwrap_or(base_title); + Ok(serde_json::to_value(export_validated_rollout( + &thread_id, + &title, + &exportable_rollout, ))?) })(); result @@ -41,6 +56,36 @@ pub fn export_markdown_response(payload: &Value) -> Value { } } +pub fn auto_rename_chat_response(payload: &Value) -> Value { + let result = (|| -> anyhow::Result { + let session = session_from_payload(payload)?; + let options = auto_naming_options_from_payload(payload)?; + let host_id = string_payload(payload, "host_id") + .or_else_nonempty(|| string_payload(payload, "hostId")); + let client = app_server_client_for_host(&host_id)?; + let name = client.generate_thread_name( + &session.session_id, + options.min_chars, + options.max_chars, + )?; + client.set_thread_name(&session.session_id, &name)?; + Ok(json!({ + "status": "renamed", + "session_id": crate::codex_app_server::normalize_thread_id(&session.session_id), + "name": name, + "source": "generated", + "message": format!("Regenerated chat title: {name}"), + })) + })(); + result.unwrap_or_else(|error| { + json!({ + "status": "failed", + "session_id": payload.get("session_id").or_else(|| payload.get("sessionId")).and_then(Value::as_str).unwrap_or(""), + "message": error.to_string(), + }) + }) +} + pub fn fork_thread_project_response(payload: &Value) -> Value { let session = match session_from_payload(payload) { Ok(session) => session, @@ -141,6 +186,64 @@ fn string_payload(payload: &Value, key: &str) -> String { .to_string() } +#[derive(Debug, Clone, Copy)] +struct AutoNamingOptions { + min_chars: u8, + max_chars: u8, +} + +fn auto_naming_options_from_payload(payload: &Value) -> anyhow::Result { + let min_chars = char_count_payload_alias(payload, "autoNamingMinChars", "autoNamingMinWords")?; + let max_chars = char_count_payload_alias(payload, "autoNamingMaxChars", "autoNamingMaxWords")?; + if min_chars > max_chars { + anyhow::bail!("autoNamingMinChars must be less than or equal to autoNamingMaxChars"); + } + Ok(AutoNamingOptions { + min_chars, + max_chars, + }) +} + +fn char_count_payload(payload: &Value, key: &str) -> anyhow::Result { + let value = payload + .get(key) + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("{key} must be an integer"))?; + if !(1..=20).contains(&value) { + anyhow::bail!("{key} must be between 1 and 20"); + } + Ok(value as u8) +} + +fn char_count_payload_alias(payload: &Value, key: &str, legacy_key: &str) -> anyhow::Result { + if payload.get(key).is_some() { + return char_count_payload(payload, key); + } + char_count_payload(payload, legacy_key) +} + +fn friendly_title_for_export( + payload: &Value, + session: &SessionRef, + host_id: &str, +) -> anyhow::Result> { + if payload + .get("friendlyFilename") + .and_then(Value::as_bool) + .unwrap_or(false) + != true + { + return Ok(None); + } + let options = auto_naming_options_from_payload(payload)?; + let client = app_server_client_for_host(host_id)?; + Ok(Some(client.generate_thread_name( + &session.session_id, + options.min_chars, + options.max_chars, + )?)) +} + #[derive(Debug, Clone)] struct ThreadRecord { title: Option, @@ -190,20 +293,50 @@ fn source_rollout_local_path( fn local_thread_record(db_path: &Path, session: &SessionRef) -> anyhow::Result { let thread_id = crate::codex_app_server::normalize_thread_id(&session.session_id); let db = Connection::open(db_path)?; - let (title, rollout_path): (Option, String) = db.query_row( + if !supports_codex_threads(&db)? { + anyhow::bail!("Current local storage structure not supported"); + } + let row = db.query_row( "SELECT title, rollout_path FROM threads WHERE id = ?1", [&thread_id], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; + |row| Ok((row.get::<_, Option>(0)?, row.get::<_, String>(1)?)), + ); + let (title, rollout_path) = match row { + Ok(row) => row, + Err(rusqlite::Error::QueryReturnedNoRows) => { + anyhow::bail!("Corresponding session not found") + } + Err(error) => return Err(error.into()), + }; if rollout_path.trim().is_empty() { anyhow::bail!("Session missing rollout file path for thread {thread_id}"); } Ok(ThreadRecord { title, - rollout_path, + rollout_path: rollout_path.trim().to_string(), }) } +fn supports_codex_threads(db: &Connection) -> anyhow::Result { + let has_threads = db + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'threads'", + [], + |_| Ok(()), + ) + .is_ok(); + if !has_threads { + return Ok(false); + } + let mut stmt = db.prepare("PRAGMA table_info(\"threads\")")?; + let columns = stmt + .query_map([], |row| row.get::<_, String>(1))? + .collect::>>()?; + Ok(["id", "title", "rollout_path"] + .iter() + .all(|column| columns.iter().any(|existing| existing == column))) +} + fn remote_thread_record(target: &SshTarget, session: &SessionRef) -> anyhow::Result { let candidates = remote_state_db_candidates(target)?; let mut errors = Vec::new(); @@ -472,10 +605,6 @@ fn default_codex_db_path() -> anyhow::Result { Ok(home.join(".codex").join("state_5.sqlite")) } -fn failed_value(error: serde_json::Error) -> Value { - failed_export_value("", error.to_string()) -} - fn failed_export_value(session_id: &str, message: impl Into) -> Value { json!({ "status": "failed", diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 49608c5..3b60fcf 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -10,6 +10,10 @@ use serde_json::Value; pub struct HelperSettings { pub markdown_export_enabled: bool, pub session_move_enabled: bool, + pub auto_rename_menu_enabled: bool, + pub markdown_friendly_filename_enabled: bool, + pub auto_naming_min_chars: u8, + pub auto_naming_max_chars: u8, pub port_forwarding_enabled: bool, pub port_auto_forward_web: bool, pub port_same_local_port: bool, @@ -20,6 +24,10 @@ impl Default for HelperSettings { Self { markdown_export_enabled: false, session_move_enabled: false, + auto_rename_menu_enabled: false, + markdown_friendly_filename_enabled: true, + auto_naming_min_chars: 4, + auto_naming_max_chars: 10, port_forwarding_enabled: false, port_auto_forward_web: true, port_same_local_port: true, @@ -53,21 +61,36 @@ fn settings_from_value(value: &Value) -> anyhow::Result { .ok_or_else(|| anyhow::anyhow!("Settings file must contain a JSON object"))?; let mut settings = HelperSettings::default(); for (key, value) in object { - let enabled = match value.as_bool() { - Some(enabled) => enabled, - None if LEGACY_SETTINGS_KEYS.contains(&key.as_str()) => continue, - None => anyhow::bail!("Settings value for {key} must be a boolean"), - }; match key.as_str() { - "markdownExportEnabled" => settings.markdown_export_enabled = enabled, - "sessionMoveEnabled" => settings.session_move_enabled = enabled, - "portForwardingEnabled" => settings.port_forwarding_enabled = enabled, - "portAutoForwardWeb" => settings.port_auto_forward_web = enabled, - "portSameLocalPort" => settings.port_same_local_port = enabled, + "markdownExportEnabled" => settings.markdown_export_enabled = bool_setting(key, value)?, + "sessionMoveEnabled" => settings.session_move_enabled = bool_setting(key, value)?, + "autoRenameMenuEnabled" => { + settings.auto_rename_menu_enabled = bool_setting(key, value)? + } + "markdownFriendlyFilenameEnabled" => { + settings.markdown_friendly_filename_enabled = bool_setting(key, value)? + } + "autoNamingMinChars" => { + settings.auto_naming_min_chars = char_count_setting(key, value)? + } + "autoNamingMaxChars" => { + settings.auto_naming_max_chars = char_count_setting(key, value)? + } + "autoNamingMinWords" if !object.contains_key("autoNamingMinChars") => { + settings.auto_naming_min_chars = char_count_setting("autoNamingMinChars", value)? + } + "autoNamingMaxWords" if !object.contains_key("autoNamingMaxChars") => { + settings.auto_naming_max_chars = char_count_setting("autoNamingMaxChars", value)? + } + "autoNamingMinWords" | "autoNamingMaxWords" => {} + "portForwardingEnabled" => settings.port_forwarding_enabled = bool_setting(key, value)?, + "portAutoForwardWeb" => settings.port_auto_forward_web = bool_setting(key, value)?, + "portSameLocalPort" => settings.port_same_local_port = bool_setting(key, value)?, key if LEGACY_SETTINGS_KEYS.contains(&key) => {} _ => anyhow::bail!("Unknown settings key: {key}"), } } + validate_auto_naming_range(&settings)?; Ok(settings) } @@ -78,23 +101,63 @@ pub fn update_settings(path: &Path, payload: &Value) -> anyhow::Result settings.markdown_export_enabled = enabled, - "sessionMoveEnabled" => settings.session_move_enabled = enabled, - "portForwardingEnabled" => settings.port_forwarding_enabled = enabled, - "portAutoForwardWeb" => settings.port_auto_forward_web = enabled, - "portSameLocalPort" => settings.port_same_local_port = enabled, + "markdownExportEnabled" => settings.markdown_export_enabled = bool_setting(key, value)?, + "sessionMoveEnabled" => settings.session_move_enabled = bool_setting(key, value)?, + "autoRenameMenuEnabled" => { + settings.auto_rename_menu_enabled = bool_setting(key, value)? + } + "markdownFriendlyFilenameEnabled" => { + settings.markdown_friendly_filename_enabled = bool_setting(key, value)? + } + "autoNamingMinChars" => { + settings.auto_naming_min_chars = char_count_setting(key, value)? + } + "autoNamingMaxChars" => { + settings.auto_naming_max_chars = char_count_setting(key, value)? + } + "autoNamingMinWords" if !object.contains_key("autoNamingMinChars") => { + settings.auto_naming_min_chars = char_count_setting("autoNamingMinChars", value)? + } + "autoNamingMaxWords" if !object.contains_key("autoNamingMaxChars") => { + settings.auto_naming_max_chars = char_count_setting("autoNamingMaxChars", value)? + } + "autoNamingMinWords" | "autoNamingMaxWords" => {} + "portForwardingEnabled" => settings.port_forwarding_enabled = bool_setting(key, value)?, + "portAutoForwardWeb" => settings.port_auto_forward_web = bool_setting(key, value)?, + "portSameLocalPort" => settings.port_same_local_port = bool_setting(key, value)?, _ => return Err(anyhow::anyhow!("Unknown settings key: {key}")), } } + validate_auto_naming_range(&settings)?; write_settings(path, &settings)?; Ok(settings) } +fn bool_setting(key: &str, value: &Value) -> anyhow::Result { + value + .as_bool() + .ok_or_else(|| anyhow::anyhow!("Settings value for {key} must be a boolean")) +} + +fn char_count_setting(key: &str, value: &Value) -> anyhow::Result { + let count = value + .as_u64() + .ok_or_else(|| anyhow::anyhow!("Settings value for {key} must be an integer"))?; + if !(1..=20).contains(&count) { + anyhow::bail!("Settings value for {key} must be between 1 and 20"); + } + Ok(count as u8) +} + +fn validate_auto_naming_range(settings: &HelperSettings) -> anyhow::Result<()> { + if settings.auto_naming_min_chars > settings.auto_naming_max_chars { + anyhow::bail!("autoNamingMinChars must be less than or equal to autoNamingMaxChars"); + } + Ok(()) +} + pub fn write_settings(path: &Path, settings: &HelperSettings) -> anyhow::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) @@ -117,6 +180,10 @@ mod tests { assert!(!settings.port_forwarding_enabled); assert!(settings.port_auto_forward_web); assert!(settings.port_same_local_port); + assert!(!settings.auto_rename_menu_enabled); + assert!(settings.markdown_friendly_filename_enabled); + assert_eq!(settings.auto_naming_min_chars, 4); + assert_eq!(settings.auto_naming_max_chars, 10); } #[test] @@ -173,6 +240,28 @@ mod tests { assert!(error.to_string().contains("Unknown settings key")); } + #[test] + fn read_settings_prefers_canonical_auto_naming_keys() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let path = temp_dir.path().join("config.json"); + fs::write( + &path, + r#"{ + "autoNamingMinWords": 12, + "autoNamingMinChars": 3, + "autoNamingMaxWords": 18, + "autoNamingMaxChars": 7 +} +"#, + ) + .expect("settings"); + + let settings = read_settings(&path).expect("settings should load"); + + assert_eq!(settings.auto_naming_min_chars, 3); + assert_eq!(settings.auto_naming_max_chars, 7); + } + #[test] fn read_settings_rejects_invalid_value_types() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -197,6 +286,10 @@ mod tests { "portForwardingEnabled": true, "portAutoForwardWeb": false, "portSameLocalPort": true, + "autoRenameMenuEnabled": true, + "markdownFriendlyFilenameEnabled": false, + "autoNamingMinChars": 3, + "autoNamingMaxChars": 7, }), ) .expect("updated settings"); @@ -207,9 +300,52 @@ mod tests { assert!(settings.port_forwarding_enabled); assert!(!settings.port_auto_forward_web); assert!(settings.port_same_local_port); + assert!(settings.auto_rename_menu_enabled); + assert!(!settings.markdown_friendly_filename_enabled); + assert_eq!(settings.auto_naming_min_chars, 3); + assert_eq!(settings.auto_naming_max_chars, 7); assert_eq!(settings, persisted); } + #[test] + fn update_settings_rejects_invalid_auto_naming_range() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let path = temp_dir.path().join("config.json"); + ensure_settings_file(&path).expect("initial settings"); + + let error = update_settings( + &path, + &serde_json::json!({ + "autoNamingMinChars": 9, + "autoNamingMaxChars": 4 + }), + ) + .expect_err("invalid range should fail"); + + assert!(error.to_string().contains("autoNamingMinChars")); + } + + #[test] + fn update_settings_prefers_canonical_auto_naming_keys() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let path = temp_dir.path().join("config.json"); + ensure_settings_file(&path).expect("initial settings"); + + let settings = update_settings( + &path, + &serde_json::json!({ + "autoNamingMinWords": 12, + "autoNamingMinChars": 3, + "autoNamingMaxWords": 18, + "autoNamingMaxChars": 7 + }), + ) + .expect("updated settings"); + + assert_eq!(settings.auto_naming_min_chars, 3); + assert_eq!(settings.auto_naming_max_chars, 7); + } + #[test] fn update_settings_rejects_unknown_keys() { let temp_dir = tempfile::tempdir().expect("temp dir"); diff --git a/src/bridge.ts b/src/bridge.ts index 326b1ec..bdf5262 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -6,7 +6,8 @@ import { handleBridgeRequest } from "./routes"; const BRIDGE_BINDING_NAME = "codexHelperBridgeV1"; const CDP_COMMAND_TIMEOUT_MS = 5000; -const BRIDGE_REQUEST_TIMEOUT_MS = 10000; +const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 10000; +const LONG_BRIDGE_REQUEST_TIMEOUT_MS = 120000; type JsonValue = | null @@ -81,14 +82,11 @@ async function withBridgeRequestTimeout( path: string, ): Promise { let timer: ReturnType | undefined; + const timeoutMs = bridgeRequestTimeoutMs(path); const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { - reject( - new Error( - `Bridge request ${path || "(unknown)"} timed out after ${BRIDGE_REQUEST_TIMEOUT_MS}ms`, - ), - ); - }, BRIDGE_REQUEST_TIMEOUT_MS); + reject(new Error(bridgeRequestTimeoutMessage(path, timeoutMs))); + }, timeoutMs); }); try { return await Promise.race([promise, timeoutPromise]); @@ -97,6 +95,31 @@ async function withBridgeRequestTimeout( } } +export function bridgeRequestTimeoutMs(path: string): number { + switch (path) { + case "/auto-rename-chat": + case "/export-markdown": + return LONG_BRIDGE_REQUEST_TIMEOUT_MS; + default: + return DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS; + } +} + +export function bridgeRequestTimeoutMessage( + path: string, + timeoutMs = bridgeRequestTimeoutMs(path), +): string { + const seconds = Math.round(timeoutMs / 1000); + switch (path) { + case "/auto-rename-chat": + return `Regenerate chat title is still running after ${seconds}s. The chat may be large, the model request may be slow, or the remote host may be unreachable. Please retry when the connection is stable.`; + case "/export-markdown": + return `Markdown export is still running after ${seconds}s. The chat may be large, the model request may be slow, or the remote host may be unreachable. Please retry when the connection is stable.`; + default: + return `Bridge request ${path || "(unknown)"} timed out after ${timeoutMs}ms`; + } +} + class BindingCdpSession { private socket: WebSocket; private responses = new Map(); diff --git a/src/routes.test.ts b/src/routes.test.ts index 06f119d..6060191 100644 --- a/src/routes.test.ts +++ b/src/routes.test.ts @@ -3,6 +3,10 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + bridgeRequestTimeoutMessage, + bridgeRequestTimeoutMs, +} from "./bridge"; import { handleBridgeRequest } from "./routes"; test("dev bridge exposes port forwarding list route", async () => { @@ -120,6 +124,10 @@ test("dev bridge accepts known removed settings keys", async () => { portForwardingEnabled: false, portAutoForwardWeb: true, portSameLocalPort: true, + autoRenameMenuEnabled: false, + markdownFriendlyFilenameEnabled: true, + autoNamingMinChars: 4, + autoNamingMaxChars: 10, }, }); } finally { @@ -128,6 +136,84 @@ test("dev bridge accepts known removed settings keys", async () => { } }); +test("dev bridge accepts auto naming settings", async () => { + const previous = process.env.CODEX_HELPER_HOME; + const root = mkdtempSync(join(tmpdir(), "codex-helper-routes-")); + try { + process.env.CODEX_HELPER_HOME = root; + + const result = await handleBridgeRequest("/settings/set", { + autoRenameMenuEnabled: true, + markdownFriendlyFilenameEnabled: false, + autoNamingMinChars: 3, + autoNamingMaxChars: 7, + }); + + expect(result).toMatchObject({ + status: "ok", + settings: { + autoRenameMenuEnabled: true, + markdownFriendlyFilenameEnabled: false, + autoNamingMinChars: 3, + autoNamingMaxChars: 7, + }, + }); + } finally { + if (previous === undefined) delete process.env.CODEX_HELPER_HOME; + else process.env.CODEX_HELPER_HOME = previous; + } +}); + +test("dev bridge prefers canonical auto naming settings over legacy keys", async () => { + const previous = process.env.CODEX_HELPER_HOME; + const root = mkdtempSync(join(tmpdir(), "codex-helper-routes-")); + try { + process.env.CODEX_HELPER_HOME = root; + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.json"), + '{ "autoNamingMinWords": 12, "autoNamingMinChars": 3, "autoNamingMaxWords": 18, "autoNamingMaxChars": 7 }', + "utf8", + ); + + const readResult = await handleBridgeRequest("/settings/get", {}); + const updateResult = await handleBridgeRequest("/settings/set", { + autoNamingMinWords: 14, + autoNamingMinChars: 4, + }); + + expect(readResult).toMatchObject({ + status: "ok", + settings: { + autoNamingMinChars: 3, + autoNamingMaxChars: 7, + }, + }); + expect(updateResult).toMatchObject({ + status: "ok", + settings: { + autoNamingMinChars: 4, + autoNamingMaxChars: 7, + }, + }); + } finally { + if (previous === undefined) delete process.env.CODEX_HELPER_HOME; + else process.env.CODEX_HELPER_HOME = previous; + } +}); + +test("dev bridge uses longer friendly timeouts for naming routes", () => { + expect(bridgeRequestTimeoutMs("/settings/get")).toBe(10000); + expect(bridgeRequestTimeoutMs("/auto-rename-chat")).toBe(120000); + expect(bridgeRequestTimeoutMs("/export-markdown")).toBe(120000); + expect(bridgeRequestTimeoutMessage("/auto-rename-chat")).toContain( + "Regenerate chat title is still running after 120s", + ); + expect(bridgeRequestTimeoutMessage("/export-markdown")).toContain( + "Markdown export is still running after 120s", + ); +}); + test("dev bridge no longer exposes helper session delete lifecycle routes", async () => { for (const path of [ "/delete", diff --git a/src/routes.ts b/src/routes.ts index c66aa46..3e64c35 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -37,6 +37,10 @@ type JsonValue = type HelperSettings = { markdownExportEnabled: boolean; sessionMoveEnabled: boolean; + autoRenameMenuEnabled: boolean; + markdownFriendlyFilenameEnabled: boolean; + autoNamingMinChars: number; + autoNamingMaxChars: number; portForwardingEnabled: boolean; portAutoForwardWeb: boolean; portSameLocalPort: boolean; @@ -45,6 +49,10 @@ type HelperSettings = { const defaultSettings: HelperSettings = { markdownExportEnabled: false, sessionMoveEnabled: false, + autoRenameMenuEnabled: false, + markdownFriendlyFilenameEnabled: true, + autoNamingMinChars: 4, + autoNamingMaxChars: 10, portForwardingEnabled: false, portAutoForwardWeb: true, portSameLocalPort: true, @@ -122,15 +130,21 @@ function readSettings(): HelperSettings { const object = settings as Record; for (const [key, value] of Object.entries(object)) { if (key in defaultSettings) { - if (typeof value !== "boolean") { - throw new Error(`Settings value for ${key} must be a boolean`); + setSettingValue(next, key, value); + continue; + } + const canonicalAutoNamingKey = canonicalAutoNamingSettingKey(key); + if (canonicalAutoNamingKey) { + if (Object.prototype.hasOwnProperty.call(object, canonicalAutoNamingKey)) { + continue; } - (next as Record)[key] = value; + setSettingValue(next, key, value); continue; } if (legacySettingsKeys.has(key)) continue; throw new Error(`Unknown settings key: ${key}`); } + validateAutoNamingRange(next); return next; } @@ -139,13 +153,17 @@ function updateSettings(payload: Record): HelperSettings { const next: HelperSettings = { ...current }; for (const [key, value] of Object.entries(payload)) { if (!(key in defaultSettings)) { - throw new Error(`Unknown settings key: ${key}`); - } - if (typeof value !== "boolean") { - throw new Error(`Settings value for ${key} must be a boolean`); + const canonicalAutoNamingKey = canonicalAutoNamingSettingKey(key); + if (!canonicalAutoNamingKey) { + throw new Error(`Unknown settings key: ${key}`); + } + if (Object.prototype.hasOwnProperty.call(payload, canonicalAutoNamingKey)) { + continue; + } } - (next as Record)[key] = value; + setSettingValue(next, key, value); } + validateAutoNamingRange(next); writeFileSync( helperConfigPath(), `${JSON.stringify(next, null, 2)}\n`, @@ -154,6 +172,57 @@ function updateSettings(payload: Record): HelperSettings { return next; } +function canonicalAutoNamingSettingKey(key: string): string { + if (key === "autoNamingMinWords") return "autoNamingMinChars"; + if (key === "autoNamingMaxWords") return "autoNamingMaxChars"; + return ""; +} + +function setSettingValue( + settings: HelperSettings, + key: string, + value: JsonValue, +): void { + if ( + key === "autoNamingMinChars" || + key === "autoNamingMaxChars" || + key === "autoNamingMinWords" || + key === "autoNamingMaxWords" + ) { + if (!Number.isInteger(value)) { + throw new Error(`Settings value for ${key} must be an integer`); + } + const count = Number(value); + if (count < 1 || count > 20) { + throw new Error(`Settings value for ${key} must be between 1 and 20`); + } + if (key === "autoNamingMinChars" || key === "autoNamingMinWords") + settings.autoNamingMinChars = count; + else settings.autoNamingMaxChars = count; + return; + } + if (typeof value !== "boolean") { + throw new Error(`Settings value for ${key} must be a boolean`); + } + if (key === "markdownExportEnabled") settings.markdownExportEnabled = value; + else if (key === "sessionMoveEnabled") settings.sessionMoveEnabled = value; + else if (key === "autoRenameMenuEnabled") settings.autoRenameMenuEnabled = value; + else if (key === "markdownFriendlyFilenameEnabled") + settings.markdownFriendlyFilenameEnabled = value; + else if (key === "portForwardingEnabled") settings.portForwardingEnabled = value; + else if (key === "portAutoForwardWeb") settings.portAutoForwardWeb = value; + else if (key === "portSameLocalPort") settings.portSameLocalPort = value; + else throw new Error(`Unknown settings key: ${key}`); +} + +function validateAutoNamingRange(settings: HelperSettings): void { + if (settings.autoNamingMinChars > settings.autoNamingMaxChars) { + throw new Error( + "autoNamingMinChars must be less than or equal to autoNamingMaxChars", + ); + } +} + function listUserScripts(): string[] { ensureHelperRoot(); const files = readdirSync(helperScriptsDir()); diff --git a/src/rust-bridge.ts b/src/rust-bridge.ts index 7e64b0b..4c5f9f3 100644 --- a/src/rust-bridge.ts +++ b/src/rust-bridge.ts @@ -14,6 +14,7 @@ type JsonValue = | { [key: string]: JsonValue }; const RUST_BRIDGE_PATHS = new Set([ + "/auto-rename-chat", "/export-markdown", "/fork-thread-project", "/projects/remote-list",