Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 24 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "hop"
version = "0.2.8"
edition = "2021"
edition = "2024"
description = "Fast full-text search and resume for coding-agent sessions"
license = "MIT"
# Resume uses Unix exec(2) to replace the process; not portable to Windows.
Expand All @@ -17,7 +17,6 @@ path = "src/lib.rs"

[dependencies]
anyhow = "1"
thiserror = "2"
clap = { version = "4.6", features = ["derive"] }
tantivy = { version = "0.26", default-features = false, features = ["mmap", "lz4-compression", "columnar-zstd-compression"] }
ratatui = "0.30"
Expand Down Expand Up @@ -45,3 +44,26 @@ lto = true
codegen-units = 1
strip = true
panic = "abort"

# Lint policy, kept in-tree so contributors and IDEs see the same contract CI
# enforces (`cargo clippy --all-targets --all-features -- -D warnings`). Deliberately
# pragmatic: we take `clippy::all` plus a small set of high-signal restriction lints
# rather than the full `pedantic`/`nursery` groups. Any future opt-out belongs here
# with a one-line reason.
[lints.rust]
unsafe_op_in_unsafe_fn = "warn"
unused_unsafe = "warn"

[lints.clippy]
# Enable the whole group at low priority so individual rules below can override it.
all = { level = "warn", priority = -1 }
# Avoid accidental heap clones of ref-counted pointers; forces explicit Rc/Arc::clone.
clone_on_ref_ptr = "warn"
# `s.push_str(&format!(..))` allocates an intermediate String; use `write!`/`push_str`.
format_push_string = "warn"
# Debugging and stub macros must never reach a release build.
dbg_macro = "warn"
todo = "warn"
unimplemented = "warn"
# Every `unsafe` block must carry a `// SAFETY:` justification.
undocumented_unsafe_blocks = "warn"
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ rows and surface as sync status warnings.
`engine.set_sort`), so the Tantivy layer is unaware of the mode.
- `src/enrich/`: per-session display enrichment. Fast enrichers are local and
synchronous; slow enrichers run through `EnrichmentService`.
- `src/columns.rs`: column definitions and responsive width solving.
- `src/tui/columns.rs`: column definitions and responsive width solving.
- `src/resume.rs`: terminal-safe process handoff through `exec`, plus an
optional run-and-wait prepare step (e.g. `codex unarchive <id>` for archived
sessions) executed after terminal restore and before the resume `exec`.
Expand Down
6 changes: 6 additions & 0 deletions rustfmt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Formatting config, mirroring the discipline of larger Rust projects (e.g. oxc).
# Kept minimal: only stable options plus the 2024 style edition.
style_edition = "2024"
# Wider layouts read better on modern screens and match rustc's own config.
use_small_heuristics = "Max"
use_field_init_shorthand = true
70 changes: 25 additions & 45 deletions src/adapters/claude.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::adapters::{file_mtime_ms, git_remote_url, parse_ts_secs, Adapter, GitFieldCache};
use crate::adapters::{Adapter, GitFieldCache, file_mtime_ms, git_remote_url, parse_ts_secs};
use crate::core::{
derive_session_title, is_command_tag_line, AgentId, ScanEntry, Session, SessionId,
SessionSummary,
AgentId, ScanEntry, Session, SessionId, SessionSummary, derive_session_title,
is_command_tag_line,
};
use anyhow::{Context, Result};
use serde::Deserialize;
Expand All @@ -17,10 +17,7 @@ pub struct ClaudeAdapter {

impl ClaudeAdapter {
pub fn new(root: PathBuf) -> Self {
Self {
root,
repo_cache: GitFieldCache::new(git_remote_url),
}
Self { root, repo_cache: GitFieldCache::new(git_remote_url) }
}
}

Expand Down Expand Up @@ -73,7 +70,7 @@ struct Extracted {

impl ClaudeAdapter {
fn extract(&self, path: &Path) -> Result<Extracted> {
use crate::core::{split_blocks, Message, Role};
use crate::core::{Message, Role, split_blocks};
let raw =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let mut directory = String::new();
Expand All @@ -96,22 +93,19 @@ impl ClaudeAdapter {
if let Some(t) = nonempty_text(parsed.ai_title.as_deref()) {
title = Some(t.to_string());
has_ai_title = true;
} else if !has_ai_title {
if let Some(t) = nonempty_text(parsed.summary.as_deref()) {
title = Some(t.to_string());
}
} else if !has_ai_title && let Some(t) = nonempty_text(parsed.summary.as_deref()) {
title = Some(t.to_string());
}
if directory.is_empty() {
if let Some(cwd) = &parsed.cwd {
directory = cwd.clone();
}
if directory.is_empty()
&& let Some(cwd) = &parsed.cwd
{
directory = cwd.clone();
}
if branch.is_none() {
if let Some(b) = parsed.git_branch.as_deref() {
if !b.trim().is_empty() {
branch = Some(b.to_string());
}
}
if branch.is_none()
&& let Some(b) = parsed.git_branch.as_deref()
&& !b.trim().is_empty()
{
branch = Some(b.to_string());
}
let kind = parsed.kind.as_deref().unwrap_or("");
let is_user = kind == "user";
Expand All @@ -126,12 +120,12 @@ impl ClaudeAdapter {
// synthetic sentinels like "<synthetic>" that Claude writes for
// injected turns. Runs after the meta/tool-result skip so an injected
// assistant line can't overwrite the model the user conversed with.
if is_assistant {
if let Some(m) = parsed.message.as_ref().and_then(|m| m.model.as_deref()) {
let m = m.trim();
if !m.is_empty() && !m.starts_with('<') {
model = Some(m.to_string());
}
if is_assistant
&& let Some(m) = parsed.message.as_ref().and_then(|m| m.model.as_deref())
{
let m = m.trim();
if !m.is_empty() && !m.starts_with('<') {
model = Some(m.to_string());
}
}
let text = parsed
Expand All @@ -147,19 +141,9 @@ impl ClaudeAdapter {
first_ts = parsed.timestamp.as_deref().and_then(parse_ts_secs);
}
let blocks = split_blocks(&text);
messages.push(Message {
role: if is_user { Role::User } else { Role::Agent },
blocks,
});
messages.push(Message { role: if is_user { Role::User } else { Role::Agent }, blocks });
}
Ok(Extracted {
messages,
directory,
branch,
title,
first_ts,
model,
})
Ok(Extracted { messages, directory, branch, title, first_ts, model })
}
}

Expand Down Expand Up @@ -272,11 +256,7 @@ fn extract_text(content: &Content, is_user: bool) -> Option<String> {
.filter(|b| b.kind == "text")
.filter_map(|b| b.text.as_deref())
.collect();
if joined.is_empty() {
None
} else {
Some(joined.join(" "))
}
if joined.is_empty() { None } else { Some(joined.join(" ")) }
}
}
}
Expand Down
74 changes: 25 additions & 49 deletions src/adapters/codex.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::adapters::{file_mtime_ms, git_remote_url, parse_ts_secs, Adapter, GitFieldCache};
use crate::adapters::{Adapter, GitFieldCache, file_mtime_ms, git_remote_url, parse_ts_secs};
use crate::core::{
derive_session_title, is_command_tag_line, AgentId, ScanEntry, Session, SessionId,
SessionSummary,
AgentId, ScanEntry, Session, SessionId, SessionSummary, derive_session_title,
is_command_tag_line,
};
use anyhow::{Context, Result};
use serde::Deserialize;
Expand All @@ -18,21 +18,15 @@ pub struct CodexAdapter {

impl CodexAdapter {
pub fn new(root: PathBuf) -> Self {
Self {
root,
repo_cache: GitFieldCache::new(git_remote_url),
}
Self { root, repo_cache: GitFieldCache::new(git_remote_url) }
}

fn session_roots(&self) -> Vec<PathBuf> {
vec![
self.root.join("sessions"),
self.root.join("archived_sessions"),
]
vec![self.root.join("sessions"), self.root.join("archived_sessions")]
}

fn extract(&self, path: &Path) -> Result<Extracted> {
use crate::core::{split_blocks, Message, Role};
use crate::core::{Message, Role, split_blocks};
let raw = read_rollout(path)?;
let mut directory = String::new();
let mut branch = None;
Expand All @@ -55,10 +49,10 @@ impl CodexAdapter {
Ok(l) => l,
Err(_) => continue,
};
if first_ts.is_none() {
if let Some(ts) = parsed.timestamp.as_deref() {
first_ts = parse_ts_secs(ts);
}
if first_ts.is_none()
&& let Some(ts) = parsed.timestamp.as_deref()
{
first_ts = parse_ts_secs(ts);
}
let Some(p) = parsed.payload else { continue };
match parsed.kind.as_str() {
Expand All @@ -76,10 +70,10 @@ impl CodexAdapter {
// A non-interactive thread classification (subagent /
// memory_consolidation) also means the session isn't
// user-resumable; let it drive the filter signal.
if let Some(ts) = normalize_source(p.thread_source) {
if is_non_interactive_source(Some(&ts)) {
source = Some(ts);
}
if let Some(ts) = normalize_source(p.thread_source)
&& is_non_interactive_source(Some(&ts))
{
source = Some(ts);
}
}
"turn_context" => {
Expand All @@ -92,10 +86,10 @@ impl CodexAdapter {
// First non-empty model wins (matches branch/dir): later
// turns may be a trailing "codex-auto-review" turn rather
// than the model the user actually ran.
if model.is_none() {
if let Some(m) = p.model.filter(|m| !m.trim().is_empty()) {
model = Some(m);
}
if model.is_none()
&& let Some(m) = p.model.filter(|m| !m.trim().is_empty())
{
model = Some(m);
}
}
"event_msg" => {
Expand Down Expand Up @@ -134,10 +128,7 @@ impl CodexAdapter {
let Some(text) = clean_event_message(&text) else {
continue;
};
response_messages.push(Message {
role,
blocks: split_blocks(&text),
});
response_messages.push(Message { role, blocks: split_blocks(&text) });
}
_ => {}
}
Expand Down Expand Up @@ -238,9 +229,7 @@ fn clean_event_message(text: &str) -> Option<String> {
continue;
}

if let Some((_, end)) = DROP_XML_BLOCKS
.iter()
.find(|(start, _)| trimmed.starts_with(start))
if let Some((_, end)) = DROP_XML_BLOCKS.iter().find(|(start, _)| trimmed.starts_with(start))
{
if !trimmed.contains(end) {
skip_xml_until = Some(*end);
Expand All @@ -257,11 +246,7 @@ fn clean_event_message(text: &str) -> Option<String> {
.map(|idx| &cleaned[idx + USER_MESSAGE_BEGIN.len()..])
.unwrap_or(&cleaned)
.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
}

fn strip_codex_wrappers(line: &str) -> String {
Expand Down Expand Up @@ -305,9 +290,7 @@ fn read_rollout(path: &Path) -> Result<String> {
}

fn is_compressed_rollout(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".jsonl.zst"))
path.file_name().and_then(|name| name.to_str()).is_some_and(|name| name.ends_with(".jsonl.zst"))
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -411,9 +394,7 @@ impl Adapter for CodexAdapter {
let content = flatten_messages(&ex.messages);
// Prefer the remote recorded in session_meta; fall back to resolving it
// from the cwd so older rollouts without git metadata still get a repo.
let repo_url = ex
.repo_url
.or_else(|| self.repo_cache.resolve(&ex.directory));
let repo_url = ex.repo_url.or_else(|| self.repo_cache.resolve(&ex.directory));
Ok(Session {
meta: SessionSummary {
id,
Expand All @@ -428,11 +409,7 @@ impl Adapter for CodexAdapter {
source_path: Some(path.to_path_buf()),
archived: is_archived_path(path),
worktree: None,
permission_mode: if ex.yolo {
Some("yolo".into())
} else {
Some("default".into())
},
permission_mode: if ex.yolo { Some("yolo".into()) } else { Some("default".into()) },
model: ex.model,
commit: ex.commit,
source: ex.source,
Expand Down Expand Up @@ -476,8 +453,7 @@ impl Adapter for CodexAdapter {
/// Codex archives by moving the rollout file there; the JSONL itself carries no
/// archive flag, so the directory is the only signal.
fn is_archived_path(path: &Path) -> bool {
path.components()
.any(|c| c.as_os_str() == "archived_sessions")
path.components().any(|c| c.as_os_str() == "archived_sessions")
}

/// Extract the session id from a `rollout-<timestamp>-<uuid>` filename stem.
Expand Down
Loading
Loading