@@ -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