diff --git a/README.md b/README.md index b97242a..4700f05 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ subdir = "agent-plugin" Kit stores each conversation as an append-only JSONL transcript in `~/.kit/sessions`. Kit synchronizes each item to disk before it adds the item to memory. -You can resume a session from the TUI or with the `--resume` option of `kit prompt`. ACP clients can use `session/load` or `session/resume`. +You can list and persistently rename workspace sessions with `kit sessions`, `kit sessions rename "Name"`, and `kit sessions rename --clear`. In the TUI, `/sessions` opens the picker, `R` renames the selected session, and Enter resumes it. Custom names override generated titles in listings without changing session IDs or previews. You can also resume with the `--resume` option of `kit prompt`; ACP clients can use `session/load` or `session/resume`. When the provider reports 80% context-window use, Kit converts older history into a structured note. Kit persists the replacement. Kit retains the bootstrap instructions and a tool-safe tail. Use `/compact` to compact the history on demand. See [TUI interaction and sessions](docs/user/tui-and-sessions.md#manage-sessions-and-compact-from-the-tui). diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 24e6668..93670fb 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -14,10 +14,12 @@ List sessions for the workspace, then resume the ID shown in the header or catal ```sh kit sessions --root /path/to/project +kit sessions rename "OAuth token bug" --root /path/to/project +kit sessions rename --clear --root /path/to/project kit tui --root /path/to/project --resume ``` -The catalog requires an existing directory and is workspace-filtered and newest-first. It reports each durable top-level session ID and updated time; sessions created as subagents are omitted based on structured origin metadata in their initial transcript. Sessions created before Kit recorded that metadata remain visible. Filtering affects discovery only; a known omitted ID can still be resumed explicitly. The title comes from the earliest retained useful user text so compaction does not rename a session; the preview describes the current retained history. Display metadata removes terminal controls and Unicode default-ignorable formatting characters. +The catalog requires an existing directory and is workspace-filtered and newest-first. It reports each durable top-level session ID and updated time; sessions created as subagents are omitted based on structured origin metadata in their initial transcript. Sessions created before Kit recorded that metadata remain visible. Filtering affects discovery only; a known omitted ID can still be resumed explicitly. The generated title comes from the earliest retained useful user text so compaction does not rename a session; the preview describes the current retained history. A custom display name overrides that title in CLI, TUI, and ACP listings without changing the immutable session ID or preview. Names may contain Unicode, are trimmed, may be at most 100 characters, and may not contain line breaks or terminal control characters. Use `--clear` to restore the generated title. A session ID must be 1–128 ASCII letters, digits, `-`, or `_`. `kit prompt` uses the same durable sessions: it prints `session_id: ` after its answer, and that ID can be continued by either `kit prompt --resume ` or `kit tui --resume `. @@ -101,7 +103,7 @@ The TUI handles `/new`, `/resume`, `/sessions`, `/close`, `/model`, `/effort`, a /agents ``` -These local commands are available only while the session is idle. `/agents` toggles the agent roster without starting a model turn. `/new` closes the current session and starts a fresh persisted session. It clears the visible transcript but does not delete or alter the previous session, which remains resumable by its ID. Text following `/new` becomes the new session's first prompt. `/resume ` closes the current session, resumes the requested durable session, and replays its transcript; selecting the already-active ID is a no-op. `/sessions` opens a visible newest-first selector for the same workspace; Up and Down move, Enter uses the existing resume flow, and Esc closes the dialog. `/close` closes the current session and exits the TUI. +These local commands are available only while the session is idle. `/agents` toggles the agent roster without starting a model turn. `/new` closes the current session and starts a fresh persisted session. It clears the visible transcript but does not delete or alter the previous session, which remains resumable by its ID. Text following `/new` becomes the new session's first prompt. `/resume ` closes the current session, resumes the requested durable session, and replays its transcript; selecting the already-active ID is a no-op. `/sessions` opens a visible newest-first selector for the same workspace. Up and Down move, Enter uses the existing resume flow, `R` opens an inline rename field, and Esc cancels renaming or closes the dialog. Submit an empty rename and confirm to clear the custom name. After a save, the picker remains open on the selected session and refreshes its displayed name. `/close` closes the current session and exits the TUI. `/model` opens the model selector. `/effort` opens the advertised ACP reasoning-effort selector; `/effort default|low|medium|high` selects directly. In either dialog, Tab toggles saving the selection to `~/.kit/config.toml`, Enter selects, and Esc closes. Saving `default` removes top-level `reasoning_effort`; other values update it without replacing unrelated TOML. A new or resumed process starts from the resolved CLI/TOML default unless the selection was saved. @@ -114,10 +116,11 @@ Kit stores durable JSONL transcripts, locks, and session-associated fatal error ```text ~/.kit/sessions/w-/.jsonl ~/.kit/sessions/w-/.lock +~/.kit/sessions/w-/.metadata.json ~/.kit/errors//.json ``` -The workspace hash is the BLAKE3 digest of the canonical workspace-root path. It keeps identical session IDs in different workspaces in separate storage directories. +The workspace hash is the BLAKE3 digest of the canonical workspace-root path. It keeps identical session IDs in different workspaces in separate storage directories. The optional metadata sidecar stores only the custom display name, is replaced atomically, and does not modify or lock the append-only transcript. Missing or malformed metadata falls back to the generated title without hiding the session. Fatal error records use their own versioned JSON schema and are not transcript content. Schema v2 adds optional structured transport diagnostics; schema v1 records remain readable. Transport diagnostics contain only bounded, allowlisted request/stream stage, retry, attempt, the provider's strictly validated `x-request-id` value, reqwest classification, and typed Hyper, HTTP/2, and I/O fields. Unknown or truncated source chains are identified without storing source text. Kit never stores raw error display/debug text, arbitrary headers, prompts, tool arguments, response bodies, credentials, URLs, or peer-controlled HTTP/2 debug text in these records. Files are written atomically with owner-only permissions on Unix, and Kit retains the newest 50 records per session. Cancellation is not a fatal error and does not create a record. When persistence succeeds, local prompt and ACP terminal errors include the log path; A2A records stay server-local. diff --git a/src/main.rs b/src/main.rs index e31f18e..4097115 100644 --- a/src/main.rs +++ b/src/main.rs @@ -453,6 +453,21 @@ enum AuthAction { }, } +#[derive(Subcommand)] +enum SessionsAction { + /// Set or clear a session's custom display name. + Rename { + /// Durable session ID. + session_id: String, + /// New display name. + #[arg(required_unless_present = "clear", conflicts_with = "clear")] + name: Option, + /// Clear the custom name and restore the generated title. + #[arg(long)] + clear: bool, + }, +} + #[derive(Subcommand)] enum Command { /// Write the recommended configuration to ~/.kit/config.toml. @@ -464,10 +479,12 @@ enum Command { #[command(flatten)] credentials: CredentialArgs, }, - /// List durable sessions for a workspace, newest first. + /// List or rename durable sessions for a workspace. Sessions { + #[command(subcommand)] + action: Option, /// Working directory and project context (defaults to config or `.`). - #[arg(long)] + #[arg(long, global = true)] root: Option, }, /// Serve ACP on stdio with A2A, remote ACP, or both over HTTP. @@ -834,9 +851,24 @@ async fn main() -> Result<(), Box> { return Ok(()); } let config = Config::load_default()?; - if let Command::Sessions { root } = &cli.command { + if let Command::Sessions { action, root } = &cli.command { let root = config.root(root.clone()); - print!("{}", format_sessions(&kit::session::catalog(&root)?)); + match action { + None => print!("{}", format_sessions(&kit::session::catalog(&root)?)), + Some(SessionsAction::Rename { + session_id, + name, + clear, + }) => { + let display_name = if *clear { None } else { name.as_deref() }; + kit::session::set_display_name(&root, session_id, display_name)?; + if *clear { + println!("Cleared name for session {session_id}"); + } else if let Some(name) = name { + println!("Renamed session {session_id} to \"{}\"", name.trim()); + } + } + } return Ok(()); } let openrouter_api_key = @@ -1139,8 +1171,8 @@ mod tests { use super::{ AuthAction, AuthProvider, Cli, Command, Config, CredentialArgs, CredentialStoreKind, - McpArgs, OTEL_CAPTURE_MESSAGE_CONTENT_ENV, ReasoningEffortArg, format_sessions, - init_config, resolve_openrouter_api_key, supervise_serve_with_trigger, + McpArgs, OTEL_CAPTURE_MESSAGE_CONTENT_ENV, ReasoningEffortArg, SessionsAction, + format_sessions, init_config, resolve_openrouter_api_key, supervise_serve_with_trigger, validate_auth_storage, }; @@ -1617,20 +1649,76 @@ future_option = true let cli = Cli::try_parse_from(["kit", "sessions", "--root", "/tmp/project"]).unwrap(); assert!(matches!( cli.command, - Command::Sessions { root: Some(root) } + Command::Sessions { action: None, root: Some(root) } if root.as_path() == std::path::Path::new("/tmp/project") )); let output = format_sessions(&[kit::session::CatalogEntry { id: "session-1".into(), - title: Some("Fix tests".into()), + title: Some("OAuth token bug".into()), preview: Some("Fix tests in the catalog".into()), is_subagent: false, updated_at: 0, }]); assert_eq!( output, - "UPDATED\tID\tTITLE\tPREVIEW\n1970-01-01T00:00:00.000Z\tsession-1\tFix tests\tFix tests in the catalog\n" + "UPDATED\tID\tTITLE\tPREVIEW\n1970-01-01T00:00:00.000Z\tsession-1\tOAuth token bug\tFix tests in the catalog\n" + ); + } + + #[test] + fn sessions_rename_accepts_names_clear_and_root_in_either_position() { + let named = Cli::try_parse_from([ + "kit", + "sessions", + "rename", + "s-abc123", + "OAuth token bug", + "--root", + "/tmp/project", + ]) + .unwrap(); + assert!(matches!( + named.command, + Command::Sessions { + action: Some(SessionsAction::Rename { + session_id, + name: Some(name), + clear: false, + }), + root: Some(root), + } if session_id == "s-abc123" + && name == "OAuth token bug" + && root == std::path::Path::new("/tmp/project") + )); + + let cleared = Cli::try_parse_from([ + "kit", + "sessions", + "--root", + "/tmp/project", + "rename", + "s-abc123", + "--clear", + ]) + .unwrap(); + assert!(matches!( + cleared.command, + Command::Sessions { + action: Some(SessionsAction::Rename { + session_id, + name: None, + clear: true, + }), + root: Some(root), + } if session_id == "s-abc123" + && root == std::path::Path::new("/tmp/project") + )); + + assert!(Cli::try_parse_from(["kit", "sessions", "rename", "s-abc123"]).is_err()); + assert!( + Cli::try_parse_from(["kit", "sessions", "rename", "s-abc123", "name", "--clear",]) + .is_err() ); } diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index cf39f91..e90586c 100644 --- a/src/protocols/acp.rs +++ b/src/protocols/acp.rs @@ -762,7 +762,7 @@ impl Server { .iter() .map(|entry| { SessionInfo::new(entry.id.clone(), cwd.clone()) - .title(entry.title.clone()) + .title(entry.title.as_deref().map(str::to_owned)) .updated_at(entry.updated_at_rfc3339()) }) .collect(); diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index ee2e944..c76903c 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -1568,7 +1568,7 @@ fn set_v2_config( fn catalog_session_info(entry: &crate::session::CatalogEntry, cwd: &Path) -> wire::SessionInfo { let mut info = wire::SessionInfo::new(wire::SessionId::new(entry.id.clone()), cwd.to_path_buf()) - .title(entry.title.clone()) + .title(entry.title.as_deref().map(str::to_owned)) .updated_at(entry.updated_at_rfc3339()); if entry.is_subagent { info = info.meta(serde_json::Map::from_iter([( diff --git a/src/session.rs b/src/session.rs index 093ae0b..38ffca9 100644 --- a/src/session.rs +++ b/src/session.rs @@ -41,6 +41,13 @@ struct Record { redirect: Option, } +#[derive(Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SessionMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, +} + /// A loaded transcript together with the observer that owns its mutation lock. pub struct OpenSession { pub transcript: Vec, @@ -51,6 +58,7 @@ pub struct OpenSession { #[derive(Clone, Debug, PartialEq, Eq)] pub struct CatalogEntry { pub id: String, + /// User-defined display name, falling back to a generated title. pub title: Option, pub preview: Option, pub is_subagent: bool, @@ -749,13 +757,40 @@ fn read_records_following( fn read_records_direct(path: &Path, session_id: &str) -> Result { let file = File::open(path).map_err(|error| format!("could not read {}: {error}", path.display()))?; + let lines = BufReader::new(file) + .lines() + .enumerate() + .map(|(index, line)| { + line.map_err(|error| format!("could not read transcript line {}: {error}", index + 1)) + }); + read_record_lines(path, session_id, lines) +} + +fn read_records_bytes( + path: &Path, + session_id: &str, + bytes: &[u8], +) -> Result { + let text = std::str::from_utf8(bytes) + .map_err(|error| format!("invalid transcript {}: {error}", path.display()))?; + read_record_lines( + path, + session_id, + text.lines().map(|line| Ok(line.to_string())), + ) +} + +fn read_record_lines( + path: &Path, + session_id: &str, + lines: impl Iterator>, +) -> Result { let mut items = Vec::new(); let mut expected = 1_u64; let mut states = Vec::new(); let mut redirect = None; - for (index, line) in BufReader::new(file).lines().enumerate() { - let line = - line.map_err(|error| format!("could not read transcript line {}: {error}", index + 1))?; + for (index, line) in lines.enumerate() { + let line = line?; let record: Record = serde_json::from_str(&line) .map_err(|error| format!("invalid transcript line {}: {error}", index + 1))?; if !matches!( @@ -903,22 +938,134 @@ fn transcript_workspace_bytes( Ok(workspace) } -fn ensure_workspace(path: &Path, session_id: &str, root: &Path) -> Result<(), String> { - if let Some(stored) = transcript_workspace(path, session_id)? - && stored != root - { +/// Lists durable sessions bound to one workspace, newest first, without taking mutation locks. +pub fn catalog(root: &Path) -> Result, String> { + catalog_for_workspace(root, &default_directory()?) +} + +/// Sets or clears a durable session's custom display name. +/// +/// Passing `None` clears the custom name. The transcript and its mutation lock are +/// not modified, so active sessions can be renamed safely. +pub fn set_display_name( + root: &Path, + session_id: &str, + display_name: Option<&str>, +) -> Result<(), String> { + set_display_name_and_title(root, session_id, display_name).map(|_| ()) +} + +pub(crate) fn set_display_name_and_title( + root: &Path, + session_id: &str, + display_name: Option<&str>, +) -> Result, String> { + set_display_name_in(root, &default_directory()?, session_id, display_name) +} + +fn set_display_name_in( + root: &Path, + global_directory: &Path, + session_id: &str, + display_name: Option<&str>, +) -> Result, String> { + validate_id(session_id)?; + let root = root.canonicalize().map_err(|error| { + format!( + "could not resolve workspace root {}: {error}", + root.display() + ) + })?; + if !root.is_dir() { return Err(format!( - "session {session_id:?} belongs to workspace {}, not {}", - stored.display(), + "workspace root {} is not a directory", root.display() )); } - Ok(()) + + let authority = select_authority_for_rename(global_directory, &root, session_id)? + .ok_or_else(|| format!("session {session_id} was not found in {}", root.display()))?; + if catalog_is_subagent(&authority.historical_items, &authority.items) { + return Err(format!( + "session {session_id} was not found in {}", + root.display() + )); + } + + let generated_title = catalog_text(&authority.historical_items, &authority.items).0; + let display_name = display_name.map(validate_display_name).transpose()?; + let effective_title = display_name.clone().or(generated_title); + let metadata = SessionMetadata { display_name }; + let mut output = serde_json::to_vec(&metadata) + .map_err(|error| format!("could not encode metadata for session {session_id}: {error}"))?; + output.push(b'\n'); + + let directory = workspace_storage_directory(global_directory, &root); + fs::create_dir_all(&directory).map_err(|error| { + format!( + "could not create session directory {}: {error}", + directory.display() + ) + })?; + let path = metadata_path(&directory, session_id); + atomicwrites::AtomicFile::new(&path, atomicwrites::AllowOverwrite) + .write(|file| { + file.write_all(&output)?; + file.sync_all() + }) + .map_err(|error| { + format!( + "could not replace session metadata {}: {error}", + path.display() + ) + })?; + Ok(effective_title) } -/// Lists durable sessions bound to one workspace, newest first, without taking mutation locks. -pub fn catalog(root: &Path) -> Result, String> { - catalog_for_workspace(root, &default_directory()?) +fn select_authority_for_rename( + directory: &Path, + root: &Path, + session_id: &str, +) -> Result, String> { + select_authority_with(directory, root, session_id, false, true) +} + +pub(crate) fn is_safe_display_name_character(character: char) -> bool { + !character.is_control() + && !matches!( + character as u32, + 0x061c | 0x200e..=0x200f | 0x2028..=0x202e | 0x2066..=0x2069 + ) +} + +fn validate_display_name(value: &str) -> Result { + if !value.chars().all(is_safe_display_name_character) { + return Err("session name must not contain line breaks or control characters".into()); + } + let value = value.trim(); + let length = value.chars().count(); + if length == 0 + || !value + .chars() + .any(|character| !character.is_whitespace() && !is_default_ignorable(character)) + { + return Err("session name must not be empty; use --clear to remove it".into()); + } + if length > 100 { + return Err("session name must be at most 100 characters".into()); + } + Ok(value.to_string()) +} + +fn read_display_name(directory: &Path, session_id: &str) -> Option { + let input = fs::read(metadata_path(directory, session_id)).ok()?; + let metadata: SessionMetadata = serde_json::from_slice(&input).ok()?; + metadata + .display_name + .as_deref() + .map(validate_display_name) + .transpose() + .ok()? } fn catalog_for_workspace( @@ -942,7 +1089,8 @@ fn catalog_for_workspace( for id in ids { // Discovery is best-effort per transcript: a damaged file or one caught // mid-append must not hide every other session in the workspace. - let Ok(Some(authority)) = select_authority_with(global_directory, &root, &id, false) else { + let Ok(Some(authority)) = select_authority_with(global_directory, &root, &id, false, false) + else { continue; }; if catalog_is_subagent(&authority.historical_items, &authority.items) { @@ -966,6 +1114,8 @@ fn catalog_for_workspace( .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)) .unwrap_or(0); + let directory = workspace_storage_directory(global_directory, &root); + let title = read_display_name(&directory, &id).or(title); entries.push(CatalogEntry { id, title, @@ -1221,7 +1371,45 @@ fn select_authority( root: &Path, session_id: &str, ) -> Result, String> { - select_authority_with(directory, root, session_id, true) + select_authority_with(directory, root, session_id, true, false) +} + +fn transcript_snapshot(path: &Path, tolerate_incomplete_tail: bool) -> Result, String> { + let mut bytes = + fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?; + if tolerate_incomplete_tail && !bytes.ends_with(b"\n") { + let tail_start = bytes + .iter() + .rposition(|byte| *byte == b'\n') + .map_or(0, |index| index + 1); + let tail = &bytes[tail_start..]; + let incomplete = match std::str::from_utf8(tail) { + Ok(_) => serde_json::from_slice::(tail) + .is_err_and(|error| error.classify() == serde_json::error::Category::Eof), + Err(error) => error.error_len().is_none(), + }; + if incomplete { + bytes.truncate(tail_start); + } + } + Ok(bytes) +} + +fn read_authority_candidate( + path: &Path, + session_id: &str, + tolerate_incomplete_tail: bool, +) -> Result<(Option, StoredTranscript), String> { + if tolerate_incomplete_tail { + let bytes = transcript_snapshot(path, true)?; + let workspace = transcript_workspace_bytes(path, session_id, &bytes)?; + let transcript = read_records_bytes(path, session_id, &bytes)?; + Ok((workspace, transcript)) + } else { + let workspace = transcript_workspace(path, session_id)?; + let transcript = read_records_direct(path, session_id)?; + Ok((workspace, transcript)) + } } fn select_authority_with( @@ -1229,6 +1417,7 @@ fn select_authority_with( root: &Path, session_id: &str, include_unbound_global: bool, + tolerate_incomplete_tail: bool, ) -> Result, String> { let scoped = transcript_path(&workspace_storage_directory(directory, root), session_id); let global = transcript_path(directory, session_id); @@ -1248,7 +1437,8 @@ fn select_authority_with( { continue; } - let workspace = transcript_workspace(path, session_id)?; + let (workspace, transcript) = + read_authority_candidate(path, session_id, tolerate_incomplete_tail)?; if is_global && (workspace.as_deref().is_some_and(|stored| stored != root) || workspace.is_none() && !include_unbound_global) @@ -1263,7 +1453,7 @@ fn select_authority_with( root.display() )); } - match read_records_direct(path, session_id)? { + match transcript { StoredTranscript::History(history) => { let candidate = HistoryCandidate { path: path.clone(), @@ -1284,9 +1474,18 @@ fn select_authority_with( if target != scoped_target { return Err(format!("invalid session redirect in {}", path.display())); } - ensure_workspace(&target, session_id, root)?; - let StoredTranscript::History(history) = read_records_direct(&target, session_id)? - else { + let (target_workspace, target_transcript) = + read_authority_candidate(&target, session_id, tolerate_incomplete_tail)?; + if let Some(stored) = target_workspace + && stored != root + { + return Err(format!( + "session {session_id:?} belongs to workspace {}, not {}", + stored.display(), + root.display() + )); + } + let StoredTranscript::History(history) = target_transcript else { return Err(format!( "scoped transcript {} is a redirect", target.display() @@ -1667,6 +1866,10 @@ fn transcript_path(directory: &Path, session_id: &str) -> PathBuf { directory.join(format!("{session_id}.jsonl")) } +fn metadata_path(directory: &Path, session_id: &str) -> PathBuf { + directory.join(format!("{session_id}.metadata.json")) +} + fn legacy_transcript(root: &Path, session_id: &str) -> PathBuf { transcript_path(&workspace_directory(root), session_id) } @@ -1690,6 +1893,8 @@ pub(crate) fn validate_id(value: &str) -> Result<(), String> { #[cfg(test)] mod tests { + use std::io::{BufRead, BufReader}; + use super::*; use agentkit_core::{ItemKind, MetadataMap, Part, ReasoningPart}; use serde_json::json; @@ -2948,6 +3153,313 @@ mod tests { drop((legacy, top_level, malformed, subagent)); } + #[test] + fn display_name_can_be_set_and_cleared_while_session_is_active() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let opened = open_in( + root.path(), + storage.path(), + "active", + false, + false, + vec![Item::text(ItemKind::User, "Generated title")], + ) + .unwrap(); + + set_display_name_in( + root.path(), + storage.path(), + "active", + Some(" OAuth token bug "), + ) + .unwrap(); + let entries = catalog_for_workspace(root.path(), storage.path()).unwrap(); + assert_eq!(entries[0].title.as_deref(), Some("OAuth token bug")); + + let directory = + workspace_storage_directory(storage.path(), &canonical_workspace(root.path())); + let path = metadata_path(&directory, "active"); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "{\"display_name\":\"OAuth token bug\"}\n" + ); + + set_display_name_in(root.path(), storage.path(), "active", None).unwrap(); + assert_eq!(fs::read_to_string(path).unwrap(), "{}\n"); + let entries = catalog_for_workspace(root.path(), storage.path()).unwrap(); + assert_eq!(entries[0].title.as_deref(), Some("Generated title")); + drop(opened); + } + + #[test] + fn display_name_tolerates_an_incomplete_concurrent_append() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let opened = open_in( + root.path(), + storage.path(), + "active", + false, + false, + vec![Item::text(ItemKind::User, "Generated title")], + ) + .unwrap(); + + let path = opened.observer.0.lock().unwrap().path.clone(); + let record = Record { + schema_version: SCHEMA_VERSION, + session_id: "active".into(), + generation: 2, + workspace_root: Some(canonical_workspace(root.path())), + item: Some(Item::text(ItemKind::Assistant, "concurrent append")), + replacement: None, + redirect: None, + }; + let mut encoded = serde_json::to_vec(&record).unwrap(); + encoded.push(b'\n'); + let split = encoded.len() / 2; + let mut file = OpenOptions::new().append(true).open(path).unwrap(); + file.write_all(&encoded[..split]).unwrap(); + file.sync_all().unwrap(); + assert_eq!( + set_display_name_in(root.path(), storage.path(), "active", Some("Renamed")) + .unwrap() + .as_deref(), + Some("Renamed") + ); + assert_eq!( + set_display_name_in(root.path(), storage.path(), "active", None) + .unwrap() + .as_deref(), + Some("Generated title") + ); + set_display_name_in(root.path(), storage.path(), "active", Some("Renamed")).unwrap(); + file.write_all(&encoded[split..]).unwrap(); + file.sync_all().unwrap(); + assert_eq!( + catalog_for_workspace(root.path(), storage.path()).unwrap()[0] + .title + .as_deref(), + Some("Renamed") + ); + drop(opened); + } + + #[test] + fn display_name_rejects_a_completed_malformed_append() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let opened = open_in( + root.path(), + storage.path(), + "active", + false, + false, + vec![Item::text(ItemKind::User, "Generated title")], + ) + .unwrap(); + let path = opened.observer.0.lock().unwrap().path.clone(); + OpenOptions::new() + .append(true) + .open(path) + .unwrap() + .write_all(b"not json\n") + .unwrap(); + + let error = set_display_name_in(root.path(), storage.path(), "active", Some("Renamed")) + .unwrap_err(); + assert!(error.contains("invalid transcript line"), "{error}"); + drop(opened); + } + + #[test] + fn display_name_rejects_a_complete_invalid_record_without_a_newline() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let opened = open_in( + root.path(), + storage.path(), + "active", + false, + false, + vec![Item::text(ItemKind::User, "Generated title")], + ) + .unwrap(); + let path = opened.observer.0.lock().unwrap().path.clone(); + OpenOptions::new() + .append(true) + .open(path) + .unwrap() + .write_all(b"{}") + .unwrap(); + + let error = set_display_name_in(root.path(), storage.path(), "active", Some("Renamed")) + .unwrap_err(); + assert!(error.contains("invalid transcript line"), "{error}"); + drop(opened); + } + + #[test] + fn display_name_validation_trims_unicode_and_rejects_unsafe_values() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let opened = open_in( + root.path(), + storage.path(), + "named", + false, + false, + vec![Item::text(ItemKind::User, "Title")], + ) + .unwrap(); + + let hundred = "界".repeat(100); + set_display_name_in( + root.path(), + storage.path(), + "named", + Some(&format!(" {hundred} ")), + ) + .unwrap(); + assert_eq!( + catalog_for_workspace(root.path(), storage.path()).unwrap()[0] + .title + .as_deref(), + Some(hundred.as_str()) + ); + + for invalid in [ + "", + " ", + "line\nbreak", + "line\u{2028}break", + "paragraph\u{2029}break", + "\u{200b}", + "\u{200b}\u{200d}\u{fe0f}", + "tab\tname", + "escape\u{1b}name", + "bidi\u{061c}name", + "bidi\u{200e}name", + "bidi\u{200f}name", + "bidi\u{202e}name", + "bidi\u{2066}name", + ] { + assert!( + set_display_name_in(root.path(), storage.path(), "named", Some(invalid),).is_err(), + "accepted {invalid:?}" + ); + } + for valid in ["👩\u{200d}💻", "✈\u{fe0f}"] { + set_display_name_in(root.path(), storage.path(), "named", Some(valid)).unwrap(); + } + assert!( + set_display_name_in(root.path(), storage.path(), "named", Some(&"x".repeat(101)),) + .is_err() + ); + assert!( + set_display_name_in(root.path(), storage.path(), "missing", Some("Name")) + .unwrap_err() + .contains("was not found") + ); + drop(opened); + } + + #[test] + fn malformed_display_name_metadata_falls_back_without_hiding_session() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let opened = open_in( + root.path(), + storage.path(), + "valid", + false, + false, + vec![Item::text(ItemKind::User, "Generated title")], + ) + .unwrap(); + let directory = + workspace_storage_directory(storage.path(), &canonical_workspace(root.path())); + let path = metadata_path(&directory, "valid"); + + for malformed in [ + "not json", + r#"{"display_name":42}"#, + r#"{"unknown":true}"#, + r#"{"display_name":"line\nbreak"}"#, + ] { + fs::write(&path, malformed).unwrap(); + let entries = catalog_for_workspace(root.path(), storage.path()).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].title.as_deref(), Some("Generated title")); + assert_eq!(fs::read_to_string(&path).unwrap(), malformed); + } + drop(opened); + } + + #[test] + fn display_names_are_workspace_scoped_and_concurrent_writes_are_atomic() { + let storage = tempfile::tempdir().unwrap(); + let roots = tempfile::tempdir().unwrap(); + let first = roots.path().join("first"); + let second = roots.path().join("second"); + fs::create_dir_all(&first).unwrap(); + fs::create_dir_all(&second).unwrap(); + let first_session = open_in( + &first, + storage.path(), + "shared", + false, + false, + vec![Item::text(ItemKind::User, "First title")], + ) + .unwrap(); + let second_session = open_in( + &second, + storage.path(), + "shared", + false, + false, + vec![Item::text(ItemKind::User, "Second title")], + ) + .unwrap(); + set_display_name_in(&second, storage.path(), "shared", Some("Second name")).unwrap(); + + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + let writers = ["First name", "Latest name"].map(|name| { + let root = first.clone(); + let storage = storage.path().to_path_buf(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + set_display_name_in(&root, &storage, "shared", Some(name)) + }) + }); + barrier.wait(); + for writer in writers { + writer.join().unwrap().unwrap(); + } + + let first_name = catalog_for_workspace(&first, storage.path()).unwrap()[0] + .title + .clone() + .unwrap(); + assert!(["First name", "Latest name"].contains(&first_name.as_str())); + assert_eq!( + catalog_for_workspace(&second, storage.path()).unwrap()[0] + .title + .as_deref(), + Some("Second name") + ); + let first_directory = + workspace_storage_directory(storage.path(), &canonical_workspace(&first)); + let metadata: SessionMetadata = + serde_json::from_slice(&fs::read(metadata_path(&first_directory, "shared")).unwrap()) + .unwrap(); + assert_eq!(metadata.display_name.as_deref(), Some(first_name.as_str())); + drop((first_session, second_session)); + } + #[test] fn catalog_timestamps_are_rfc3339() { assert_eq!(timestamp_rfc3339(0), "1970-01-01T00:00:00.000Z"); diff --git a/src/tui/app.rs b/src/tui/app.rs index 56b2dd4..bccf8f3 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -20,6 +20,7 @@ use crossterm::event::{ KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::{layout::Rect, text::Line}; +use unicode_segmentation::UnicodeSegmentation; #[cfg(test)] use crate::compaction::is_compaction_summary; @@ -44,6 +45,12 @@ pub enum Update { A2aAddress(String), /// Result of listing sessions without blocking the terminal event loop. SessionCatalog(Result, String>), + /// Result of changing one session's custom display name. + SessionRenamed { + session_id: String, + display_name: Option, + result: Result, String>, + }, /// A steer was accepted but has not been delivered into the transcript yet. SteerAccepted { id: String, text: String }, /// A user message delivered or replayed by the agent. @@ -207,6 +214,13 @@ pub struct EffortDialog { pub struct SessionDialog { pub selected: usize, + pub rename: Option, +} + +pub enum SessionRename { + Editing(String), + ConfirmClear, + Saving, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -245,6 +259,10 @@ pub enum Action { }, New(Option), ListSessions, + RenameSession { + session_id: String, + display_name: Option, + }, Resume(String), Close, SelectModel { @@ -529,6 +547,7 @@ pub struct App { pub effort_dialog: Option, pub session_choices: Vec, pub session_dialog: Option, + session_catalog_pending: bool, pub available_commands: Vec, pub command_completion_selected: usize, command_completion_query: Option, @@ -818,6 +837,7 @@ impl App { effort_dialog: None, session_choices: Vec::new(), session_dialog: None, + session_catalog_pending: false, available_commands: Vec::new(), command_completion_selected: 0, command_completion_query: None, @@ -1653,15 +1673,48 @@ impl App { pub fn apply(&mut self, update: Update) { match update { Update::A2aAddress(address) => self.a2a = address, - Update::SessionCatalog(result) => match result { - Ok(entries) if entries.is_empty() => { - self.toast("no sessions found for this workspace"); + Update::SessionCatalog(result) => { + self.session_catalog_pending = false; + match result { + Ok(entries) if entries.is_empty() => { + self.toast("no sessions found for this workspace"); + } + Ok(entries) => { + self.session_choices = entries; + self.session_dialog = Some(SessionDialog { + selected: 0, + rename: None, + }); + } + Err(error) => self.toast(format!("could not list sessions: {error}")), } - Ok(entries) => { - self.session_choices = entries; - self.session_dialog = Some(SessionDialog { selected: 0 }); + } + Update::SessionRenamed { + session_id, + display_name, + result, + } => match result { + Ok(title) => { + if let Some(dialog) = &mut self.session_dialog { + dialog.rename = None; + } + if let Some(entry) = self + .session_choices + .iter_mut() + .find(|entry| entry.id == session_id) + { + entry.title = title; + } + } + Err(error) => { + if let Some(dialog) = &mut self.session_dialog { + dialog.rename = Some(match display_name { + Some(name) => SessionRename::Editing(name), + None => SessionRename::ConfirmClear, + }); + } + self.toast(format!("could not rename session: {error}")); } - Err(error) => self.toast(format!("could not list sessions: {error}")), }, Update::AvailableCommands { session_id, @@ -1993,6 +2046,7 @@ impl App { /// history and diagnostics remain useful, while transcript-derived state /// starts empty. pub fn start_session(&mut self, session_id: String) { + self.session_catalog_pending = false; self.session_id = Some(session_id); self.available_commands.clear(); self.command_completion_selected = 0; @@ -2294,7 +2348,32 @@ impl App { /// A paste never sends: the newlines in it are part of the text. Multi-line /// pastes say so, because the prompt box shows only its last rows and the /// rest is easy to miss. + pub fn session_rename_active(&self) -> bool { + self.session_dialog + .as_ref() + .is_some_and(|dialog| dialog.rename.is_some()) + } + pub fn paste(&mut self, text: &str) { + // An explicit bracketed paste is not part of the unbracketed key-burst heuristic. + self.last_key = None; + if let Some(rename) = self + .session_dialog + .as_mut() + .and_then(|dialog| dialog.rename.as_mut()) + { + if let SessionRename::Editing(input) = rename { + let remaining = 100_usize.saturating_sub(input.chars().count()); + input.extend( + text.chars() + .filter(|character| { + crate::session::is_safe_display_name_character(*character) + }) + .take(remaining), + ); + } + return; + } self.editor.insert_str(text); self.sync_command_completion(); let lines = text.lines().count(); @@ -2438,26 +2517,78 @@ impl App { Action::None } - fn handle_session_key(&mut self, key: KeyEvent) -> Action { + fn handle_session_key(&mut self, key: KeyEvent, pasted: bool) -> Action { + let Some(dialog) = &mut self.session_dialog else { + return Action::None; + }; + + if let Some(rename) = &mut dialog.rename { + match rename { + SessionRename::Saving => {} + SessionRename::ConfirmClear => match key.code { + KeyCode::Enter if pasted => {} + KeyCode::Esc => { + dialog.rename = Some(SessionRename::Editing(String::new())); + } + KeyCode::Enter => { + let selected = dialog.selected; + dialog.rename = Some(SessionRename::Saving); + if let Some(entry) = self.session_choices.get(selected) { + return Action::RenameSession { + session_id: entry.id.clone(), + display_name: None, + }; + } + } + _ => {} + }, + SessionRename::Editing(input) => match key.code { + KeyCode::Enter if pasted => {} + KeyCode::Esc => dialog.rename = None, + KeyCode::Backspace => { + if let Some((index, _)) = input.grapheme_indices(true).next_back() { + input.truncate(index); + } + } + KeyCode::Enter if input.trim().is_empty() => { + dialog.rename = Some(SessionRename::ConfirmClear); + } + KeyCode::Enter => { + let selected = dialog.selected; + let display_name = input.clone(); + dialog.rename = Some(SessionRename::Saving); + if let Some(entry) = self.session_choices.get(selected) { + return Action::RenameSession { + session_id: entry.id.clone(), + display_name: Some(display_name), + }; + } + } + KeyCode::Char(character) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && crate::session::is_safe_display_name_character(character) + && input.chars().count() < 100 => + { + input.push(character); + } + _ => {} + }, + } + return Action::None; + } + match key.code { KeyCode::Esc => self.session_dialog = None, - KeyCode::Up => { - if let Some(dialog) = &mut self.session_dialog { - dialog.selected = dialog.selected.saturating_sub(1); - } - } + KeyCode::Up => dialog.selected = dialog.selected.saturating_sub(1), KeyCode::Down => { - if let Some(dialog) = &mut self.session_dialog { - dialog.selected = - (dialog.selected + 1).min(self.session_choices.len().saturating_sub(1)); - } + dialog.selected = + (dialog.selected + 1).min(self.session_choices.len().saturating_sub(1)); + } + KeyCode::Char('r' | 'R') => { + dialog.rename = Some(SessionRename::Editing(String::new())); } KeyCode::Enter => { - let selected = self - .session_dialog - .as_ref() - .map_or(0, |dialog| dialog.selected); - if let Some(entry) = self.session_choices.get(selected) { + if let Some(entry) = self.session_choices.get(dialog.selected) { let id = entry.id.clone(); self.session_dialog = None; return Action::Resume(id); @@ -2474,7 +2605,11 @@ impl App { return Action::None; } if self.session_dialog.is_some() { - return self.handle_session_key(key); + // Terminals without bracketed paste deliver a paste as a key burst, so + // the arrival gap is the only thing separating it from typing. + let pasted = self.last_key.is_some_and(|last| last.elapsed() < PASTE_GAP); + self.last_key = Some(Instant::now()); + return self.handle_session_key(key, pasted); } if self.model_dialog.is_some() { return self.handle_model_key(key); @@ -2616,7 +2751,15 @@ impl App { self.toast("usage: /resume "); Action::None } - Parsed::Sessions => Action::ListSessions, + Parsed::Sessions => { + if self.session_catalog_pending { + self.toast("session catalog scan is already in progress"); + Action::None + } else { + self.session_catalog_pending = true; + Action::ListSessions + } + } Parsed::Close => Action::Close, Parsed::Agents => { self.toggle_agents(); @@ -4198,6 +4341,15 @@ mod tests { Action::ListSessions )); assert!(app.session_dialog.is_none()); + app.editor.insert_str("/sessions"); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert!(app.session_catalog_pending); + + app.start_session("replacement".into()); + assert!(!app.session_catalog_pending); } #[test] @@ -4229,7 +4381,10 @@ mod tests { updated_at: 0, }) .collect(); - app.session_dialog = Some(super::SessionDialog { selected: 0 }); + app.session_dialog = Some(super::SessionDialog { + selected: 0, + rename: None, + }); assert!(matches!(app.handle_key(press(KeyCode::Down)), Action::None)); assert!(matches!( @@ -4239,6 +4394,161 @@ mod tests { assert!(app.session_dialog.is_none()); } + #[test] + fn session_dialog_renames_in_place_and_preserves_selection() { + let mut app = app(); + app.session_choices = ["newer", "older"] + .into_iter() + .map(|id| crate::session::CatalogEntry { + id: id.into(), + title: Some(format!("{id} title")), + preview: None, + is_subagent: false, + updated_at: 0, + }) + .collect(); + app.session_dialog = Some(super::SessionDialog { + selected: 1, + rename: None, + }); + + assert!(matches!( + app.handle_key(press(KeyCode::Char('R'))), + Action::None + )); + app.paste("OAuth bug"); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::RenameSession { session_id, display_name: Some(name) } + if session_id == "older" && name == "OAuth bug" + )); + assert_eq!(app.session_dialog.as_ref().unwrap().selected, 1); + assert!(matches!( + app.session_dialog.as_ref().unwrap().rename, + Some(super::SessionRename::Saving) + )); + assert!(matches!( + app.handle_key(press(KeyCode::Char('r'))), + Action::None + )); + + app.apply(Update::SessionRenamed { + session_id: "older".into(), + display_name: Some("OAuth bug".into()), + result: Ok(Some("OAuth bug".into())), + }); + assert_eq!(app.session_dialog.as_ref().unwrap().selected, 1); + assert_eq!(app.session_choices[1].title.as_deref(), Some("OAuth bug")); + + app.session_dialog.as_mut().unwrap().rename = Some(super::SessionRename::Saving); + app.apply(Update::SessionRenamed { + session_id: "older".into(), + display_name: Some("Retry me".into()), + result: Err("disk full".into()), + }); + assert!(matches!( + app.session_dialog.as_ref().unwrap().rename.as_ref(), + Some(super::SessionRename::Editing(input)) if input == "Retry me" + )); + } + + #[test] + fn session_dialog_confirms_before_clearing_a_name() { + let mut app = app(); + app.session_choices = vec![crate::session::CatalogEntry { + id: "saved".into(), + title: Some("Generated".into()), + preview: None, + is_subagent: false, + updated_at: 0, + }]; + app.session_dialog = Some(super::SessionDialog { + selected: 0, + rename: Some(super::SessionRename::Editing(String::new())), + }); + + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert!(matches!( + app.session_dialog.as_ref().unwrap().rename, + Some(super::SessionRename::ConfirmClear) + )); + app.last_key = None; + assert!(matches!(app.handle_key(press(KeyCode::Esc)), Action::None)); + assert!(matches!( + app.session_dialog.as_ref().unwrap().rename, + Some(super::SessionRename::Editing(_)) + )); + app.last_key = None; + app.handle_key(press(KeyCode::Enter)); + app.last_key = None; + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::RenameSession { session_id, display_name: None } if session_id == "saved" + )); + assert!(app.session_dialog.is_some()); + } + + #[test] + fn session_rename_backspace_removes_a_complete_grapheme() { + let mut app = app(); + app.session_dialog = Some(super::SessionDialog { + selected: 0, + rename: Some(super::SessionRename::Editing("e\u{301} 👨‍👩‍👧".into())), + }); + + assert!(matches!( + app.handle_key(press(KeyCode::Backspace)), + Action::None + )); + assert!(matches!( + app.session_dialog.as_ref().unwrap().rename.as_ref(), + Some(super::SessionRename::Editing(input)) if input == "e\u{301} " + )); + } + + #[test] + fn session_rename_clear_confirmation_ignores_enter_from_a_paste_burst() { + let mut app = app(); + app.session_dialog = Some(super::SessionDialog { + selected: 0, + rename: Some(super::SessionRename::ConfirmClear), + }); + app.last_key = Some(Instant::now()); + + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert!(matches!( + app.session_dialog.as_ref().unwrap().rename, + Some(super::SessionRename::ConfirmClear) + )); + } + + #[test] + fn session_rename_ignores_enter_from_an_unbracketed_paste_burst() { + for input in [String::new(), "Pasted name".into()] { + let mut app = app(); + app.session_dialog = Some(super::SessionDialog { + selected: 0, + rename: Some(super::SessionRename::Editing(input.clone())), + }); + app.last_key = Some(Instant::now()); + + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert!(matches!( + app.session_dialog.as_ref().unwrap().rename.as_ref(), + Some(super::SessionRename::Editing(actual)) if actual == &input + )); + } + } + #[test] fn effort_command_selects_directly_and_dialog_keys_toggle_and_select() { let mut app = app(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index abdda68..ede135c 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -677,6 +677,45 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( )); }); } + Action::RenameSession { + session_id: requested_id, + display_name, + } => { + let Ok(route) = transition_session.lock() else { + app.note("could not start session rename"); + continue; + }; + let generation = route.generation; + drop(route); + let root = root.clone(); + let updates = updates_tx.clone(); + tokio::spawn(async move { + let name_for_write = display_name.clone(); + let id_for_write = requested_id.clone(); + let result = tokio::task::spawn_blocking(move || { + crate::session::set_display_name_and_title( + &root, + &id_for_write, + name_for_write.as_deref(), + ) + }) + .await + .map_err(|error| { + format!("session rename worker failed: {error}") + }) + .and_then(|result| result); + let display_name = display_name + .map(|name| name.trim().to_string()); + let _ = updates.send(QueuedUpdate::for_session( + generation, + Update::SessionRenamed { + session_id: requested_id, + display_name, + result, + }, + )); + }); + } Action::Resume(requested_id) => { if let Err(error) = crate::session::validate_id(&requested_id) { app.note(format!("invalid session id: {error}")); @@ -1068,7 +1107,9 @@ fn handle(app: &mut App, event: Event) -> Action { Event::Key(key) => app.handle_key(key), Event::Mouse(mouse) => app.handle_mouse(mouse), Event::Paste(text) => { - if let Some(attachments) = attachments_from_paste(&app.root, &text) { + if app.session_rename_active() { + app.paste(&text); + } else if let Some(attachments) = attachments_from_paste(&app.root, &text) { app.prune_attachments(); let pending_bytes = app .attachments @@ -1705,7 +1746,7 @@ mod tests { save_model_defaults_to, transition_route, translate, translate_for_session, user_message_of, wire, }; - use crate::tui::app::{App, SubmittedPrompt, Update}; + use crate::tui::app::{App, SessionDialog, SessionRename, SubmittedPrompt, Update}; #[cfg(unix)] #[tokio::test] @@ -1944,6 +1985,57 @@ mod tests { assert_eq!(app.attachments.len(), MAX_ATTACHMENTS); } + #[test] + fn session_rename_paste_is_not_interpreted_as_an_attachment() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("image.png"); + std::fs::write(&path, b"png").unwrap(); + let mut app = App::new( + directory.path().into(), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.session_dialog = Some(SessionDialog { + selected: 0, + rename: Some(SessionRename::Editing(String::new())), + }); + + handle(&mut app, Event::Paste(path.display().to_string())); + + assert!(app.attachments.is_empty()); + assert!(matches!( + app.session_dialog.as_ref().unwrap().rename.as_ref(), + Some(SessionRename::Editing(input)) if input == path.to_str().unwrap() + )); + } + + #[test] + fn session_rename_confirmation_and_saving_consume_paste() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("image.png"); + std::fs::write(&path, b"png").unwrap(); + + for rename in [SessionRename::ConfirmClear, SessionRename::Saving] { + let mut app = App::new( + directory.path().into(), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.session_dialog = Some(SessionDialog { + selected: 0, + rename: Some(rename), + }); + + handle(&mut app, Event::Paste(path.display().to_string())); + + assert!(app.attachments.is_empty()); + assert!(app.editor.is_empty()); + assert!(app.session_rename_active()); + } + } + #[test] fn image_attachment_is_resolved_into_an_acp_prompt_block() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 9a2275a..bb6456a 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -28,7 +28,7 @@ use crate::events::{GenerationOutcome, SubagentStatus}; use super::{ app::{ AgentTreeRow, App, Block, CachedTranscriptBlock, CachedTranscriptImage, - CachedTranscriptRow, Child, CodeHit, Phase, ToolCall, UserMessage, + CachedTranscriptRow, Child, CodeHit, Phase, SessionRename, ToolCall, UserMessage, }, command, image::{ImageRuntime, RESERVED_ROWS}, @@ -172,7 +172,7 @@ fn model_dialog_viewport( fn visible_query_tail(query: &str, width: usize) -> &str { let mut tail = query; while UnicodeWidthStr::width(tail) > width { - let Some((index, _)) = tail.char_indices().nth(1) else { + let Some((index, _)) = tail.grapheme_indices(true).nth(1) else { return ""; }; tail = &tail[index..]; @@ -187,9 +187,16 @@ fn draw_session_dialog(frame: &mut Frame<'_>, app: &App) { } else { outer.width }; - let height = outer - .height - .min((app.session_choices.len() as u16).saturating_add(3).min(22)); + let rename = app + .session_dialog + .as_ref() + .and_then(|dialog| dialog.rename.as_ref()); + let prompt_rows = u16::from(rename.is_some()); + let height = outer.height.min( + (app.session_choices.len() as u16) + .saturating_add(3 + prompt_rows) + .min(23), + ); let area = Rect::new( outer.x + outer.width.saturating_sub(width) / 2, outer.y + outer.height.saturating_sub(height) / 2, @@ -202,9 +209,13 @@ fn draw_session_dialog(frame: &mut Frame<'_>, app: &App) { .map_or(0, |dialog| dialog.selected); let panel = Panel::bordered().title(" sessions "); let inner = panel.inner(area); - let footer_rows = u16::from(inner.height > 1); - let [list, footer] = - Layout::vertical([Constraint::Min(0), Constraint::Length(footer_rows)]).areas(inner); + let footer_rows = u16::from(inner.height > prompt_rows.saturating_add(1)); + let [list, prompt, footer] = Layout::vertical([ + Constraint::Min(0), + Constraint::Length(prompt_rows), + Constraint::Length(footer_rows), + ]) + .areas(inner); let visible = list.height as usize; let start = selected .saturating_sub(visible / 2) @@ -220,15 +231,24 @@ fn draw_session_dialog(frame: &mut Frame<'_>, app: &App) { .as_deref() .or(entry.preview.as_deref()) .unwrap_or("untitled"); + let detail = entry + .preview + .as_deref() + .filter(|preview| *preview != label) + .map_or_else( + || label.to_string(), + |preview| format!("{label} — {preview}"), + ); let updated = entry.updated_at_rfc3339(); + let text = format!( + "{}{} · {} · {}", + if is_selected { "› " } else { " " }, + &updated[..10], + entry.id, + detail + ); Line::from(Span::styled( - format!( - "{}{} · {} · {}", - if is_selected { "› " } else { " " }, - &updated[..10], - entry.id, - label - ), + truncate_to_width(&text, list.width as usize), if is_selected { theme::accent() } else { @@ -240,10 +260,52 @@ fn draw_session_dialog(frame: &mut Frame<'_>, app: &App) { frame.render_widget(Clear, area); frame.render_widget(panel, area); frame.render_widget(Paragraph::new(lines), list); + + let footer_text = match rename { + Some(SessionRename::Editing(_)) => "enter save · esc cancel", + Some(SessionRename::ConfirmClear) => "enter clear name · esc cancel", + Some(SessionRename::Saving) => "saving…", + None => "↑/↓ select · enter resume · r rename · esc close", + }; frame.render_widget( - Paragraph::new(Span::styled("enter resume · esc close", theme::dim())), + Paragraph::new(Span::styled(footer_text, theme::dim())), footer, ); + + match rename { + Some(SessionRename::Editing(input)) if prompt.width > 0 => { + let prefix = "rename: "; + let prefix_width = UnicodeWidthStr::width(prefix).min(prompt.width as usize); + let value = visible_query_tail( + input, + (prompt.width as usize) + .saturating_sub(prefix_width) + .saturating_sub(1), + ); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(prefix, theme::dim()), + Span::styled(value.to_string(), theme::text()), + ])), + prompt, + ); + let column = prefix_width + UnicodeWidthStr::width(value); + frame.set_cursor_position(Position::new( + prompt.x + + u16::try_from(column) + .unwrap_or(u16::MAX) + .min(prompt.width.saturating_sub(1)), + prompt.y, + )); + } + Some(SessionRename::ConfirmClear) => { + frame.render_widget( + Paragraph::new(Span::styled("Clear the custom name?", theme::text())), + prompt, + ); + } + _ => {} + } } fn draw_effort_dialog(frame: &mut Frame<'_>, app: &App) { @@ -1868,7 +1930,7 @@ mod tests { events::{GenerationOutcome, RuntimeEvent, SubagentStatus}, tui::app::{ Action, AgentRow, AgentTreeRow, App, Block, EffortChoice, EffortDialog, ModelDialog, - Phase, Update, UserImage, UserMessage, + Phase, SessionDialog, SessionRename, Update, UserImage, UserMessage, }, }; @@ -2339,6 +2401,57 @@ mod tests { assert!(!dismissed.contains(" commands "), "{dismissed}"); } + #[test] + fn session_dialog_renders_custom_names_and_inline_rename_states() { + let mut app = App::new( + PathBuf::from("/tmp/project"), + "provider".into(), + "model".into(), + "127.0.0.1:7331".into(), + ); + app.session_choices = vec![crate::session::CatalogEntry { + id: "s-abc123".into(), + title: Some("OAuth token bug".into()), + preview: Some("Preview remains available".into()), + is_subagent: false, + updated_at: 0, + }]; + app.session_dialog = Some(SessionDialog { + selected: 0, + rename: None, + }); + + let browsing = render(&mut app, 88, 10); + assert!(browsing.contains("OAuth token bug"), "{browsing}"); + assert!(browsing.contains("Preview remains available"), "{browsing}"); + + assert!(browsing.contains("r rename"), "{browsing}"); + let tiny_browsing = render(&mut app, 40, 3); + assert!(tiny_browsing.contains("s-abc123"), "{tiny_browsing}"); + + app.session_dialog.as_mut().unwrap().rename = + Some(SessionRename::Editing("New name".into())); + let editing = render(&mut app, 54, 10); + assert!(editing.contains("rename: New name"), "{editing}"); + assert!(editing.contains("enter save"), "{editing}"); + let tiny_editing = render(&mut app, 40, 4); + assert!(tiny_editing.contains("s-abc123"), "{tiny_editing}"); + assert!(tiny_editing.contains("rename: New name"), "{tiny_editing}"); + + app.session_dialog.as_mut().unwrap().rename = Some(SessionRename::ConfirmClear); + let confirming = render(&mut app, 32, 8); + assert!( + confirming.contains("Clear the custom name?"), + "{confirming}" + ); + assert!(confirming.contains("enter clear name"), "{confirming}"); + + app.session_choices[0].title = Some("界".repeat(100)); + app.session_dialog.as_mut().unwrap().rename = None; + let narrow = render(&mut app, 20, 6); + assert!(narrow.contains('…'), "{narrow}"); + } + #[test] fn agents_panel_is_hidden_before_the_first_transcript_block() { let mut app = panel_app(1); diff --git a/tests/cli.rs b/tests/cli.rs index 95eb9d0..8818be9 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,4 +1,28 @@ -use std::{fs, process::Command}; +use std::{fs, path::Path, process::Command}; + +use agentkit_core::{Item, ItemKind}; + +fn write_session(home: &Path, root: &Path, id: &str) -> std::path::PathBuf { + let root = root.canonicalize().unwrap(); + let identity = blake3::hash(root.as_os_str().as_encoded_bytes()); + let directory = home + .join(".kit/sessions") + .join(format!("w-{}", identity.to_hex())); + fs::create_dir_all(&directory).unwrap(); + let record = serde_json::json!({ + "schema_version": 3, + "session_id": id, + "generation": 1, + "workspace_root": root, + "item": Item::text(ItemKind::User, "Generated title"), + }); + fs::write( + directory.join(format!("{id}.jsonl")), + format!("{}\n", serde_json::to_string(&record).unwrap()), + ) + .unwrap(); + directory.join(format!("{id}.metadata.json")) +} #[test] fn sessions_rejects_missing_and_non_directory_roots() { @@ -25,3 +49,72 @@ fn sessions_rejects_missing_and_non_directory_roots() { assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("is not a directory")); } + +#[test] +fn sessions_rename_sets_replaces_lists_and_clears_a_display_name() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join("project"); + fs::create_dir(&root).unwrap(); + let metadata = write_session(home.path(), &root, "s-abc123"); + + let renamed = Command::new(env!("CARGO_BIN_EXE_kit")) + .env("HOME", home.path()) + .args([ + "sessions", + "rename", + "s-abc123", + " OAuth token bug ", + "--root", + ]) + .arg(&root) + .output() + .unwrap(); + assert!(renamed.status.success(), "{:?}", renamed); + assert_eq!( + String::from_utf8(renamed.stdout).unwrap(), + "Renamed session s-abc123 to \"OAuth token bug\"\n" + ); + assert_eq!( + fs::read_to_string(&metadata).unwrap(), + "{\"display_name\":\"OAuth token bug\"}\n" + ); + + let listed = Command::new(env!("CARGO_BIN_EXE_kit")) + .env("HOME", home.path()) + .args(["sessions", "--root"]) + .arg(&root) + .output() + .unwrap(); + assert!(listed.status.success()); + let listing = String::from_utf8(listed.stdout).unwrap(); + assert!( + listing.contains("s-abc123\tOAuth token bug\tGenerated title"), + "{listing}" + ); + + let replaced = Command::new(env!("CARGO_BIN_EXE_kit")) + .env("HOME", home.path()) + .args(["sessions", "--root"]) + .arg(&root) + .args(["rename", "s-abc123", "Auth refresh"]) + .output() + .unwrap(); + assert!(replaced.status.success(), "{:?}", replaced); + assert_eq!( + fs::read_to_string(&metadata).unwrap(), + "{\"display_name\":\"Auth refresh\"}\n" + ); + + let cleared = Command::new(env!("CARGO_BIN_EXE_kit")) + .env("HOME", home.path()) + .args(["sessions", "rename", "s-abc123", "--clear", "--root"]) + .arg(&root) + .output() + .unwrap(); + assert!(cleared.status.success(), "{:?}", cleared); + assert_eq!( + String::from_utf8(cleared.stdout).unwrap(), + "Cleared name for session s-abc123\n" + ); + assert_eq!(fs::read_to_string(metadata).unwrap(), "{}\n"); +}