mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
Diff wrapping: add one extra space to continuation hang indent\n\n- Continuation lines now reserve sign column + trailing space before content\n- Aligns wrapped rows exactly under the first-line content
This commit is contained in:
@@ -1508,9 +1508,9 @@ async fn submission_loop(
|
|||||||
|
|
||||||
// Patch restored state into the newly created session.
|
// Patch restored state into the newly created session.
|
||||||
if let Some(sess_arc) = &sess {
|
if let Some(sess_arc) = &sess {
|
||||||
if restored_items.is_some() {
|
if let Some(items) = &restored_items {
|
||||||
let mut st = sess_arc.state.lock().unwrap();
|
let mut st = sess_arc.state.lock().unwrap();
|
||||||
st.history.record_items(restored_items.unwrap().iter());
|
st.history.record_items(items.iter());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1534,6 +1534,16 @@ async fn submission_loop(
|
|||||||
error!("failed to send event: {e:?}");
|
error!("failed to send event: {e:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If we resumed from a rollout, replay the prior transcript into the UI.
|
||||||
|
if let Some(items) = restored_items {
|
||||||
|
let event = Event {
|
||||||
|
id: sub.id.clone(),
|
||||||
|
msg: EventMsg::ReplayHistory(crate::protocol::ReplayHistoryEvent { items }),
|
||||||
|
};
|
||||||
|
if let Err(e) = tx_event.send(event).await {
|
||||||
|
warn!("failed to send ReplayHistory event: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize agent manager after SessionConfigured is sent
|
// Initialize agent manager after SessionConfigured is sent
|
||||||
if !agent_manager_initialized {
|
if !agent_manager_initialized {
|
||||||
|
|||||||
@@ -463,6 +463,11 @@ pub enum EventMsg {
|
|||||||
|
|
||||||
/// Notification that the agent is shutting down.
|
/// Notification that the agent is shutting down.
|
||||||
ShutdownComplete,
|
ShutdownComplete,
|
||||||
|
|
||||||
|
/// Replay a previously recorded transcript into the UI.
|
||||||
|
/// Used after resuming from a rollout file so the user sees the full
|
||||||
|
/// history for that session without re-executing any actions.
|
||||||
|
ReplayHistory(ReplayHistoryEvent),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Individual event payload types matching each `EventMsg` variant.
|
// Individual event payload types matching each `EventMsg` variant.
|
||||||
@@ -519,6 +524,14 @@ pub struct FinalOutput {
|
|||||||
pub token_usage: TokenUsage,
|
pub token_usage: TokenUsage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Payload for `ReplayHistory` containing prior `ResponseItem`s.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ReplayHistoryEvent {
|
||||||
|
/// Items to render in order. Front-ends should render these as static
|
||||||
|
/// history without triggering any tool execution.
|
||||||
|
pub items: Vec<codex_protocol::models::ResponseItem>,
|
||||||
|
}
|
||||||
|
|
||||||
impl From<TokenUsage> for FinalOutput {
|
impl From<TokenUsage> for FinalOutput {
|
||||||
fn from(token_usage: TokenUsage) -> Self {
|
fn from(token_usage: TokenUsage) -> Self {
|
||||||
Self { token_usage }
|
Self { token_usage }
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ pub struct SessionMeta {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub timestamp: String,
|
pub timestamp: String,
|
||||||
pub instructions: Option<String>,
|
pub instructions: Option<String>,
|
||||||
|
/// Absolute working directory for the session. Optional for back-compat
|
||||||
|
/// with older rollout files that did not record this information.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -114,6 +118,7 @@ impl RolloutRecorder {
|
|||||||
timestamp,
|
timestamp,
|
||||||
id: session_id,
|
id: session_id,
|
||||||
instructions,
|
instructions,
|
||||||
|
cwd: Some(config.cwd.to_string_lossy().to_string()),
|
||||||
}),
|
}),
|
||||||
cwd,
|
cwd,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -530,6 +530,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
|||||||
EventMsg::GetHistoryEntryResponse(_) => {
|
EventMsg::GetHistoryEntryResponse(_) => {
|
||||||
// Currently ignored in exec output.
|
// Currently ignored in exec output.
|
||||||
}
|
}
|
||||||
|
EventMsg::ReplayHistory(_) => {
|
||||||
|
// Replay is a TUI concern; ignore in headless output
|
||||||
|
}
|
||||||
EventMsg::BrowserScreenshotUpdate(_) => {
|
EventMsg::BrowserScreenshotUpdate(_) => {
|
||||||
// Currently ignored in exec output.
|
// Currently ignored in exec output.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ async fn run_codex_tool_session_inner(
|
|||||||
| EventMsg::WebSearchBegin(_)
|
| EventMsg::WebSearchBegin(_)
|
||||||
| EventMsg::WebSearchComplete(_)
|
| EventMsg::WebSearchComplete(_)
|
||||||
| EventMsg::GetHistoryEntryResponse(_)
|
| EventMsg::GetHistoryEntryResponse(_)
|
||||||
|
| EventMsg::ReplayHistory(_)
|
||||||
| EventMsg::PlanUpdate(_)
|
| EventMsg::PlanUpdate(_)
|
||||||
| EventMsg::BrowserScreenshotUpdate(_)
|
| EventMsg::BrowserScreenshotUpdate(_)
|
||||||
| EventMsg::AgentStatusUpdate(_)
|
| EventMsg::AgentStatusUpdate(_)
|
||||||
|
|||||||
@@ -433,6 +433,11 @@ impl App<'_> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
|
SlashCommand::Resume => {
|
||||||
|
if let AppState::Chat { widget } = &mut self.app_state {
|
||||||
|
widget.show_resume_picker();
|
||||||
|
}
|
||||||
|
}
|
||||||
SlashCommand::New => {
|
SlashCommand::New => {
|
||||||
// Clear the current conversation and start fresh
|
// Clear the current conversation and start fresh
|
||||||
if let AppState::Chat { widget } = &mut self.app_state {
|
if let AppState::Chat { widget } = &mut self.app_state {
|
||||||
@@ -598,6 +603,23 @@ impl App<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppEvent::ResumeFrom(path) => {
|
||||||
|
// Replace the current chat widget with a new one configured to resume
|
||||||
|
let mut cfg = self.config.clone();
|
||||||
|
cfg.experimental_resume = Some(path);
|
||||||
|
if let AppState::Chat { .. } = &self.app_state {
|
||||||
|
let new_widget = Box::new(ChatWidget::new(
|
||||||
|
cfg,
|
||||||
|
self.app_event_tx.clone(),
|
||||||
|
None,
|
||||||
|
Vec::new(),
|
||||||
|
self.enhanced_keys_supported,
|
||||||
|
self.terminal_info.clone(),
|
||||||
|
));
|
||||||
|
self.app_state = AppState::Chat { widget: new_widget };
|
||||||
|
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||||
|
}
|
||||||
|
}
|
||||||
AppEvent::PrepareAgents => {
|
AppEvent::PrepareAgents => {
|
||||||
if let AppState::Chat { widget } = &mut self.app_state {
|
if let AppState::Chat { widget } = &mut self.app_state {
|
||||||
widget.prepare_agents();
|
widget.prepare_agents();
|
||||||
|
|||||||
@@ -103,4 +103,7 @@ pub(crate) enum AppEvent {
|
|||||||
|
|
||||||
/// Chrome launch option selected by user
|
/// Chrome launch option selected by user
|
||||||
ChromeLaunchOptionSelected(ChromeLaunchOption, Option<u16>),
|
ChromeLaunchOptionSelected(ChromeLaunchOption, Option<u16>),
|
||||||
|
|
||||||
|
/// Start a new chat session by resuming from the given rollout file
|
||||||
|
ResumeFrom(std::path::PathBuf),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ mod reasoning_selection_view;
|
|||||||
mod scroll_state;
|
mod scroll_state;
|
||||||
mod selection_popup_common;
|
mod selection_popup_common;
|
||||||
pub mod list_selection_view;
|
pub mod list_selection_view;
|
||||||
|
use list_selection_view::{ListSelectionView, SelectionItem, SelectionAction};
|
||||||
mod textarea;
|
mod textarea;
|
||||||
mod theme_selection_view;
|
mod theme_selection_view;
|
||||||
mod verbosity_selection_view;
|
mod verbosity_selection_view;
|
||||||
@@ -389,6 +390,34 @@ impl BottomPane<'_> {
|
|||||||
self.request_redraw()
|
self.request_redraw()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show the resume selection UI given a list of (rows, path) tuples.
|
||||||
|
pub fn show_resume_selection(
|
||||||
|
&mut self,
|
||||||
|
title: String,
|
||||||
|
subtitle: Option<String>,
|
||||||
|
entries: Vec<(String, Option<String>, std::path::PathBuf)>,
|
||||||
|
) {
|
||||||
|
let items: Vec<SelectionItem> = entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, description, path)| SelectionItem {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
is_current: false,
|
||||||
|
actions: vec![Box::new(move |tx: &AppEventSender| tx.send(AppEvent::ResumeFrom(path.clone()))) as SelectionAction],
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let view = ListSelectionView::new(
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
Some("Enter: Select Esc: Cancel".to_string()),
|
||||||
|
items,
|
||||||
|
self.app_event_tx.clone(),
|
||||||
|
);
|
||||||
|
self.active_view = Some(Box::new(view));
|
||||||
|
self.status_view_active = false;
|
||||||
|
self.request_redraw()
|
||||||
|
}
|
||||||
|
|
||||||
/// Height (terminal rows) required by the current bottom pane.
|
/// Height (terminal rows) required by the current bottom pane.
|
||||||
pub(crate) fn request_redraw(&self) {
|
pub(crate) fn request_redraw(&self) {
|
||||||
self.app_event_tx.send(AppEvent::RequestRedraw)
|
self.app_event_tx.send(AppEvent::RequestRedraw)
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ use crate::history_cell;
|
|||||||
use crate::history_cell::CommandOutput;
|
use crate::history_cell::CommandOutput;
|
||||||
use crate::history_cell::ExecCell;
|
use crate::history_cell::ExecCell;
|
||||||
use crate::history_cell::HistoryCell;
|
use crate::history_cell::HistoryCell;
|
||||||
|
use codex_protocol::models::{ContentItem, ResponseItem};
|
||||||
use crate::history_cell::PatchEventType;
|
use crate::history_cell::PatchEventType;
|
||||||
use crate::live_wrap::RowBuilder;
|
use crate::live_wrap::RowBuilder;
|
||||||
use crate::user_approval_widget::ApprovalRequest;
|
use crate::user_approval_widget::ApprovalRequest;
|
||||||
@@ -458,6 +459,75 @@ impl ChatWidget<'_> {
|
|||||||
HistoryCellType::Loading => "Loading".to_string(),
|
HistoryCellType::Loading => "Loading".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn show_resume_picker(&mut self) {
|
||||||
|
// Discover candidates
|
||||||
|
let cwd = self.config.cwd.clone();
|
||||||
|
let codex_home = self.config.codex_home.clone();
|
||||||
|
let candidates = crate::resume::discovery::list_sessions_for_cwd(&cwd, &codex_home);
|
||||||
|
if candidates.is_empty() {
|
||||||
|
self.add_to_history(crate::history_cell::new_background_event(
|
||||||
|
"No past sessions found for this folder".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Convert to simple rows
|
||||||
|
let items: Vec<(String, Option<String>, std::path::PathBuf)> = candidates
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| (c.title, c.subtitle, c.path))
|
||||||
|
.collect();
|
||||||
|
let title = "Resume Session".to_string();
|
||||||
|
let subtitle = Some(format!("{}", cwd.display()));
|
||||||
|
self.bottom_pane.show_resume_selection(title, subtitle, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a single recorded ResponseItem into history without executing tools
|
||||||
|
fn render_replay_item(&mut self, item: ResponseItem) {
|
||||||
|
match item {
|
||||||
|
ResponseItem::Message { role, content, .. } => {
|
||||||
|
let mut text = String::new();
|
||||||
|
for c in content {
|
||||||
|
match c {
|
||||||
|
ContentItem::OutputText { text: t }
|
||||||
|
| ContentItem::InputText { text: t } => {
|
||||||
|
if !text.is_empty() { text.push('\n'); }
|
||||||
|
text.push_str(&t);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if role == "user" {
|
||||||
|
self.add_to_history(crate::history_cell::new_user_prompt(text));
|
||||||
|
} else {
|
||||||
|
// Build a PlainHistoryCell with Assistant kind; header line hidden by renderer
|
||||||
|
use crate::history_cell::{PlainHistoryCell, HistoryCellType};
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.push(ratatui::text::Line::from("assistant"));
|
||||||
|
for l in text.lines() { lines.push(ratatui::text::Line::from(l.to_string())); }
|
||||||
|
self.add_to_history(PlainHistoryCell { lines, kind: HistoryCellType::Assistant });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponseItem::Reasoning { summary, .. } => {
|
||||||
|
for s in summary {
|
||||||
|
if let codex_protocol::models::ReasoningItemReasoningSummary::SummaryText { text } = s {
|
||||||
|
// Reasoning cell – use the existing reasoning output styling
|
||||||
|
let sink = crate::streaming::controller::AppEventHistorySink(self.app_event_tx.clone());
|
||||||
|
self.current_stream_kind = Some(crate::streaming::StreamKind::Reasoning);
|
||||||
|
let _ = self.stream.apply_final_reasoning(&text, &sink);
|
||||||
|
// finalize immediately for static replay
|
||||||
|
self.stream.finalize(crate::streaming::StreamKind::Reasoning, true, &sink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponseItem::FunctionCallOutput { output, .. } => {
|
||||||
|
// Show as a background event for now
|
||||||
|
self.add_to_history(crate::history_cell::new_background_event(output.content));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Ignore other item kinds for replay (tool calls, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Trigger fade on the welcome cell when the composer expands (e.g., slash popup).
|
/// Trigger fade on the welcome cell when the composer expands (e.g., slash popup).
|
||||||
pub(crate) fn on_composer_expanded(&mut self) {
|
pub(crate) fn on_composer_expanded(&mut self) {
|
||||||
for cell in &self.history_cells {
|
for cell in &self.history_cells {
|
||||||
@@ -2263,6 +2333,13 @@ impl ChatWidget<'_> {
|
|||||||
self.last_assistant_message = Some(message);
|
self.last_assistant_message = Some(message);
|
||||||
self.mark_needs_redraw();
|
self.mark_needs_redraw();
|
||||||
}
|
}
|
||||||
|
EventMsg::ReplayHistory(ev) => {
|
||||||
|
// Render prior transcript items statically without executing tools
|
||||||
|
for item in ev.items {
|
||||||
|
self.render_replay_item(item);
|
||||||
|
}
|
||||||
|
self.request_redraw();
|
||||||
|
}
|
||||||
EventMsg::WebSearchComplete(ev) => {
|
EventMsg::WebSearchComplete(ev) => {
|
||||||
// Replace the specific running web search cell with a completed one
|
// Replace the specific running web search cell with a completed one
|
||||||
if let Some((idx, maybe_query)) = self.running_web_search.remove(&ev.call_id) {
|
if let Some((idx, maybe_query)) = self.running_web_search.remove(&ev.call_id) {
|
||||||
|
|||||||
@@ -354,7 +354,10 @@ fn push_wrapped_diff_line_with_width(
|
|||||||
// at a UTF-8 character boundary so this row's chunk fits exactly.
|
// at a UTF-8 character boundary so this row's chunk fits exactly.
|
||||||
// First line includes a visible sign plus a trailing space after it.
|
// First line includes a visible sign plus a trailing space after it.
|
||||||
// Continuation lines include only the hanging space (no sign).
|
// Continuation lines include only the hanging space (no sign).
|
||||||
let base_prefix = if first { prefix_cols + 2 } else { prefix_cols + 1 + continuation_indent };
|
// First line reserves 1 col for the sign ('+'/'-') and 1 space after it.
|
||||||
|
// Continuation lines must reserve BOTH columns as well (sign column + its trailing space)
|
||||||
|
// before applying the hanging indent equal to the content's leading spaces.
|
||||||
|
let base_prefix = if first { prefix_cols + 2 } else { prefix_cols + 2 + continuation_indent };
|
||||||
let available_content_cols = term_cols
|
let available_content_cols = term_cols
|
||||||
.saturating_sub(base_prefix)
|
.saturating_sub(base_prefix)
|
||||||
.max(1);
|
.max(1);
|
||||||
@@ -401,7 +404,7 @@ fn push_wrapped_diff_line_with_width(
|
|||||||
} else {
|
} else {
|
||||||
// Continuation lines keep a space for the sign column so content aligns
|
// Continuation lines keep a space for the sign column so content aligns
|
||||||
let hang_prefix = format!(
|
let hang_prefix = format!(
|
||||||
"{indent}{}{} {}",
|
"{indent}{}{} {}",
|
||||||
" ".repeat(ln_str.len()),
|
" ".repeat(ln_str.len()),
|
||||||
" ".repeat(gap_after_ln),
|
" ".repeat(gap_after_ln),
|
||||||
" ".repeat(continuation_indent)
|
" ".repeat(continuation_indent)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ mod render;
|
|||||||
mod session_log;
|
mod session_log;
|
||||||
mod shimmer;
|
mod shimmer;
|
||||||
mod slash_command;
|
mod slash_command;
|
||||||
|
mod resume;
|
||||||
mod streaming;
|
mod streaming;
|
||||||
mod terminal_info;
|
mod terminal_info;
|
||||||
// mod text_block; // Orphaned after trait-based HistoryCell migration
|
// mod text_block; // Orphaned after trait-based HistoryCell migration
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// One candidate session for the picker
|
||||||
|
pub struct ResumeCandidate {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub title: String,
|
||||||
|
pub subtitle: Option<String>,
|
||||||
|
pub sort_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct MetaLine {
|
||||||
|
#[serde(default)]
|
||||||
|
id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
timestamp: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
cwd: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return rollout files under ~/.codex/sessions matching the provided cwd.
|
||||||
|
/// Reads only the first line of each file to avoid heavy IO.
|
||||||
|
pub fn list_sessions_for_cwd(cwd: &Path, codex_home: &Path) -> Vec<ResumeCandidate> {
|
||||||
|
let mut out: Vec<ResumeCandidate> = Vec::new();
|
||||||
|
let mut sessions_dir = codex_home.to_path_buf();
|
||||||
|
sessions_dir.push("sessions");
|
||||||
|
let target = match cwd.canonicalize() { Ok(p) => p, Err(_) => cwd.to_path_buf() };
|
||||||
|
|
||||||
|
let paths = walk_sessions(&sessions_dir);
|
||||||
|
for path in paths {
|
||||||
|
if let Some(candidate) = read_first_line(&path).and_then(|v| build_candidate(&path, v, &target)) {
|
||||||
|
out.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp descending (string compare works for RFC3339-like format)
|
||||||
|
out.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
|
||||||
|
out.into_iter().take(200).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_sessions(root: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
if !root.exists() { return files; }
|
||||||
|
let years = match fs::read_dir(root) { Ok(r) => r, Err(_) => return files };
|
||||||
|
for y in years.flatten() {
|
||||||
|
if !y.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
|
||||||
|
let months = match fs::read_dir(y.path()) { Ok(r) => r, Err(_) => continue };
|
||||||
|
for m in months.flatten() {
|
||||||
|
if !m.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
|
||||||
|
let days = match fs::read_dir(m.path()) { Ok(r) => r, Err(_) => continue };
|
||||||
|
for d in days.flatten() {
|
||||||
|
if !d.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
|
||||||
|
let entries = match fs::read_dir(d.path()) { Ok(r) => r, Err(_) => continue };
|
||||||
|
for e in entries.flatten() {
|
||||||
|
if let Some(name) = e.file_name().to_str() {
|
||||||
|
if name.starts_with("rollout-") && name.ends_with(".jsonl") {
|
||||||
|
files.push(e.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_first_line(path: &Path) -> Option<Value> {
|
||||||
|
let f = fs::File::open(path).ok()?;
|
||||||
|
let mut reader = BufReader::new(f);
|
||||||
|
let mut first = String::new();
|
||||||
|
let _ = reader.read_line(&mut first).ok()?;
|
||||||
|
serde_json::from_str::<Value>(&first).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_candidate(path: &Path, v: Value, target_cwd: &Path) -> Option<ResumeCandidate> {
|
||||||
|
// The first line is SessionMetaWithGit – meta fields flattened, plus optional git.
|
||||||
|
let meta: MetaLine = serde_json::from_value(v.clone()).ok()?;
|
||||||
|
// Filter by cwd (abs).
|
||||||
|
if let Some(cwd_str) = meta.cwd.as_ref() {
|
||||||
|
let same_cwd = Path::new(cwd_str).canonicalize().ok().as_deref() == Some(target_cwd);
|
||||||
|
if !same_cwd { return None; }
|
||||||
|
} else {
|
||||||
|
// If cwd missing, skip (old file). Could include later with relaxed rules.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build fields for UI (title + subtitle)
|
||||||
|
let ts = meta.timestamp.unwrap_or_default();
|
||||||
|
let model = meta.model.unwrap_or_default();
|
||||||
|
let id = meta.id.unwrap_or_default();
|
||||||
|
|
||||||
|
let title = format!("{} • {}", ts, model);
|
||||||
|
let subtitle = Some(truncate_middle(&id, 12));
|
||||||
|
Some(ResumeCandidate { path: path.to_path_buf(), title, subtitle, sort_key: ts })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_middle(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max { return s.to_string(); }
|
||||||
|
let half = max / 2;
|
||||||
|
format!("{}…{}", &s[..half], &s[s.len()-half..])
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod discovery;
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ pub enum SlashCommand {
|
|||||||
Verbosity,
|
Verbosity,
|
||||||
Prompts,
|
Prompts,
|
||||||
Perf,
|
Perf,
|
||||||
|
Resume,
|
||||||
// Prompt-expanding commands
|
// Prompt-expanding commands
|
||||||
Plan,
|
Plan,
|
||||||
Solve,
|
Solve,
|
||||||
@@ -41,6 +42,7 @@ impl SlashCommand {
|
|||||||
match self {
|
match self {
|
||||||
SlashCommand::Chrome => "connect to Chrome",
|
SlashCommand::Chrome => "connect to Chrome",
|
||||||
SlashCommand::Browser => "open internal browser",
|
SlashCommand::Browser => "open internal browser",
|
||||||
|
SlashCommand::Resume => "resume a past session for this folder",
|
||||||
SlashCommand::Plan => "create a comprehensive plan (multiple agents)",
|
SlashCommand::Plan => "create a comprehensive plan (multiple agents)",
|
||||||
SlashCommand::Solve => "solve a challenging problem (multiple agents)",
|
SlashCommand::Solve => "solve a challenging problem (multiple agents)",
|
||||||
SlashCommand::Code => "perform a coding task (multiple agents)",
|
SlashCommand::Code => "perform a coding task (multiple agents)",
|
||||||
|
|||||||
Reference in New Issue
Block a user