//! Full-screen local code review TUI mode.
use bcode_plugin_sdk::path::display_from_current_dir;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as FmtWrite;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use crate::async_values::{AsyncValue, AsyncValueStore};
use bcode_client::BcodeClient;
use bcode_code_review_models::{
CODE_REVIEW_SERVICE_INTERFACE_ID, DeleteReviewAiExchangeRequest,
DeleteReviewAiExchangeResponse, DraftAnchor as ModelDraftAnchor, ExternalAnchorMappingStatus,
ExternalReviewImportRecord, ExternalReviewImportRequest, ExternalReviewImportResponse,
ExternalReviewThreadState, ListExternalReviewImportsRequest, ListExternalReviewImportsResponse,
ListPublishRecordsRequest, ListPublishRecordsResponse, ListReviewAiExchangesRequest,
ListReviewAiExchangesResponse, ListReviewSuggestionsRequest, ListReviewSuggestionsResponse,
MaterializeReviewWorkspaceRequest, MaterializeReviewWorkspaceResponse,
OP_REVIEW_AI_EXCHANGE_DELETE, OP_REVIEW_AI_EXCHANGE_SAVE, OP_REVIEW_AI_EXCHANGES_LIST,
OP_REVIEW_BUNDLE_GET, OP_REVIEW_EXTERNAL_IMPORT_SAVE, OP_REVIEW_EXTERNAL_IMPORTS_LIST,
OP_REVIEW_IMPORTER_IMPORT, OP_REVIEW_IMPORTER_MANIFEST, OP_REVIEW_PUBLISH_PREVIEW,
OP_REVIEW_PUBLISH_RECORD_SAVE, OP_REVIEW_PUBLISH_RECORDS_LIST, OP_REVIEW_PUBLISH_SUBMIT,
OP_REVIEW_PUBLISHER_MANIFEST, OP_REVIEW_PUBLISHER_PREVIEW, OP_REVIEW_PUBLISHER_SUBMIT,
OP_REVIEW_PUBLISHERS_LIST, OP_REVIEW_REPO_FILE_GET, OP_REVIEW_SUGGESTION_SAVE,
OP_REVIEW_SUGGESTIONS_LIST, OP_REVIEW_WORKSPACE_MATERIALIZE, OP_REVIEW_WORKSPACE_UPDATE,
REVIEW_IMPORTER_INTERFACE_ID, REVIEW_PUBLISHER_INTERFACE_ID,
ReviewAiExchange as ModelReviewAiExchange,
ReviewAiExchangeStatus as ModelReviewAiExchangeStatus, ReviewAnchorKind, ReviewBundle,
ReviewImporterManifest, ReviewRepositoryCommit, ReviewScope as ModelReviewScope, ReviewSource,
ReviewSourceDiagnostic, ReviewSourceDiagnosticSeverity, ReviewSourceKind,
ReviewSuggestion as ModelReviewSuggestion,
ReviewSuggestionStatus as ModelReviewSuggestionStatus, ReviewSurface, ReviewSurfaceKind,
ReviewTarget as ModelReviewTarget, ReviewTarget as ReviewOpenTarget, ReviewThreadKind,
ReviewThreadSeverity, ReviewWorkspace, ReviewWorkspacePresentationState,
SaveExternalReviewImportRequest, SaveExternalReviewImportResponse, SavePublishRecordRequest,
SavePublishRecordResponse, SaveReviewAiExchangeRequest, SaveReviewAiExchangeResponse,
SaveReviewSuggestionRequest, SaveReviewSuggestionResponse, UpdateReviewWorkspaceRequest,
};
use bcode_ipc::PluginServiceResponse;
use bcode_plugin_sdk::tui::{
FocusedTextFieldAdapter, FocusedTextFieldEdit, FocusedTextFieldEditResult,
FocusedTextFieldSnapshot, PluginSessionViewSubscription, PluginSessionViewSubscriptionRequest,
PluginSessionViewUpdate, PluginTuiAction, PluginTuiEventResult,
PluginTuiFocusedTextFieldEditResult, PluginTuiHost, PluginTuiSurface, PluginTuiTheme,
};
use bcode_session_models::{
ProjectionWindowAnchor, ProjectionWindowDirection, ProjectionWindowLimits,
ProjectionWindowRequest, ProjectionWindowTarget, SessionId, SessionProjectionKind,
};
use bcode_session_view_models::{
SessionConnectionViewStatus, SessionViewSnapshot, TextFormat, ToolInvocationView,
ToolInvocationViewStatus, ToolResultView, TranscriptViewItemKind, ViewRevision,
};
use bmux_keyboard::{KeyCode, KeyStroke};
use bmux_text_edit::TextEditBuffer;
use bmux_tui::event::{Event, FocusEvent, MouseButton, MouseEvent, MouseEventKind};
use bmux_tui::geometry::Rect;
use bmux_tui::paint::PaintCx;
use bmux_tui::terminal::Terminal;
use bmux_tui_components::text_input::{
EnterBehavior as TextInputEnterBehavior, TextInputOutcome as TextInputKeyOutcome,
};
use serde::{Deserialize, Serialize};
use crate::code_review_tui_render::materialized_file_surface_rows;
use crate::code_review_tui_view::{
DiffContextKey, DiffContextLoadState, ReviewThreadAction, ReviewThreadAnchor, ReviewViewBlock,
ReviewViewDocument, ReviewViewTarget,
};
use crate::tui_host_types::{TuiError, helpers};
const SERVICE_INTERFACE_ID: &str = CODE_REVIEW_SERVICE_INTERFACE_ID;
const CREATE_REVIEW_OPERATION: &str = "create_review";
const LIST_DRAFTS_OPERATION: &str = "draft.list";
const SAVE_DRAFT_OPERATION: &str = "draft.save";
const DELETE_DRAFT_OPERATION: &str = "draft.delete";
const UPDATE_DRAFT_OPERATION: &str = "draft.update";
const LINK_THREAD_SESSION_OPERATION: &str = "thread.link_session";
const THREAD_RESOLVE_OPERATION: &str = "thread.resolve";
const PUBLISH_SUBMIT_OPERATION: &str = OP_REVIEW_PUBLISH_SUBMIT;
const PUBLISH_RECORD_SAVE_OPERATION: &str = OP_REVIEW_PUBLISH_RECORD_SAVE;
const PUBLISH_RECORDS_LIST_OPERATION: &str = OP_REVIEW_PUBLISH_RECORDS_LIST;
const PUBLISHERS_LIST_OPERATION: &str = OP_REVIEW_PUBLISHERS_LIST;
const PUBLISH_PREVIEW_OPERATION: &str = OP_REVIEW_PUBLISH_PREVIEW;
const REVIEW_BUNDLE_GET_OPERATION: &str = OP_REVIEW_BUNDLE_GET;
const REVIEW_REPO_FILE_GET_OPERATION: &str = OP_REVIEW_REPO_FILE_GET;
const REVIEW_WORKSPACE_MATERIALIZE_OPERATION: &str = OP_REVIEW_WORKSPACE_MATERIALIZE;
const REVIEW_WORKSPACE_UPDATE_OPERATION: &str = OP_REVIEW_WORKSPACE_UPDATE;
const REVIEW_PUBLISHER_MANIFEST_OPERATION: &str = OP_REVIEW_PUBLISHER_MANIFEST;
const REVIEW_PUBLISHER_PREVIEW_OPERATION: &str = OP_REVIEW_PUBLISHER_PREVIEW;
const REVIEW_PUBLISHER_SUBMIT_OPERATION: &str = OP_REVIEW_PUBLISHER_SUBMIT;
const DEFAULT_PUBLISHER_ID: &str = "markdown_file";
const MAX_AI_THREAD_SUMMARIES: usize = 12;
const MAX_AI_THREAD_BODY_CHARS: usize = 160;
const MAX_AI_SELECTED_LINES: usize = 80;
const MAX_AI_HUNK_LINES: usize = 120;
const MAX_AI_CONTEXT_CHARS: usize = 12_000;
const MAX_LINKED_SESSION_ITEMS: usize = 64;
const MAX_LINKED_SESSION_EVENTS_SCANNED: usize = 2_048;
const MAX_LINKED_SESSION_BYTES: usize = 512 * 1024;
const FILE_SIDEBAR_WIDTH: u16 = 34;
fn syntax_palette(
theme: bcode_plugin_sdk::tui::PluginTuiSyntaxTheme,
) -> bcode_syntax_render::SyntaxPalette {
let color = |color: bcode_plugin_sdk::tui::PluginTuiSyntaxColor| {
bcode_syntax_render::SyntaxColor::from_tui(color.into())
};
bcode_syntax_render::SyntaxPalette {
text: color(theme.text),
comment: color(theme.comment),
keyword: color(theme.keyword),
function: color(theme.function),
variable: color(theme.variable),
string: color(theme.string),
number: color(theme.number),
type_name: color(theme.type_name),
operator: color(theme.operator),
punctuation: color(theme.punctuation),
heading: color(theme.heading),
link: color(theme.link),
raw: color(theme.raw),
}
}
const fn linked_session_projection_request() -> ProjectionWindowRequest {
ProjectionWindowRequest {
projection: SessionProjectionKind::Transcript,
anchor: ProjectionWindowAnchor::Latest,
direction: ProjectionWindowDirection::Backward,
target: ProjectionWindowTarget {
min_items: Some(24),
min_estimated_rows: Some(80),
min_bytes: None,
width_columns: Some(100),
},
limits: ProjectionWindowLimits {
max_items: MAX_LINKED_SESSION_ITEMS,
max_events_scanned: MAX_LINKED_SESSION_EVENTS_SCANNED,
max_bytes: MAX_LINKED_SESSION_BYTES,
},
}
}
#[allow(dead_code)]
const fn review_open_target_id(target: &ReviewOpenTarget) -> &'static str {
match target {
ReviewOpenTarget::WorkingTreeUnstaged => "working-tree-unstaged",
ReviewOpenTarget::IndexStaged => "index-staged",
ReviewOpenTarget::WorkingTreeAndIndex => "working-tree-and-index",
ReviewOpenTarget::LastCommit => "last-commit",
ReviewOpenTarget::CommitRange { .. } => "commit-range",
ReviewOpenTarget::BranchCompare { .. } => "branch-compare",
ReviewOpenTarget::Repository => "repository",
}
}
fn model_target_from_source_kind(
kind: &ReviewSourceKind,
) -> bcode_code_review_models::ReviewTarget {
match kind {
ReviewSourceKind::WorkingTreeUnstaged => {
bcode_code_review_models::ReviewTarget::WorkingTreeUnstaged
}
ReviewSourceKind::IndexStaged => bcode_code_review_models::ReviewTarget::IndexStaged,
ReviewSourceKind::WorkingTreeAndIndex => {
bcode_code_review_models::ReviewTarget::WorkingTreeAndIndex
}
ReviewSourceKind::LastCommit => bcode_code_review_models::ReviewTarget::LastCommit,
ReviewSourceKind::Commit { rev } => bcode_code_review_models::ReviewTarget::CommitRange {
base: format!("{rev}^"),
head: rev.clone(),
merge_base: false,
},
ReviewSourceKind::CommitRange {
base,
head,
merge_base,
} => bcode_code_review_models::ReviewTarget::CommitRange {
base: base.clone(),
head: head.clone(),
merge_base: *merge_base,
},
ReviewSourceKind::BranchCompare {
base_branch,
head_branch,
merge_base,
} => bcode_code_review_models::ReviewTarget::BranchCompare {
base_branch: base_branch.clone(),
head_branch: head_branch.clone(),
merge_base: *merge_base,
},
ReviewSourceKind::File { .. }
| ReviewSourceKind::FileRange { .. }
| ReviewSourceKind::Repository => bcode_code_review_models::ReviewTarget::Repository,
}
}
/// Native TUI surface wrapper for the code review experience.
pub struct CodeReviewSurface {
client: BcodeClient,
repo_path: PathBuf,
review_target: ReviewOpenTarget,
app: ReviewApp,
theme: Option<bcode_plugin_sdk::tui::PluginTuiTheme>,
file_store: AsyncValueStore<String, CachedReviewFile>,
agent_streams: ReviewAgentStreamStore,
}
impl CodeReviewSurface {
/// Load a code review surface from plugin-backed review data.
///
/// # Errors
///
/// Returns an error when initial review data cannot be loaded.
pub async fn load(
repo_path: PathBuf,
target: ReviewOpenTarget,
workspace: Option<ReviewWorkspace>,
build_mode: bool,
) -> Result<Self, TuiError> {
let client = BcodeClient::default_endpoint();
let review_target = target;
let mut app =
load_review_app(&client, repo_path.clone(), review_target.clone(), workspace).await?;
if build_mode {
app.set_build_mode();
app.status_message =
Some("build mode: add sources with A, then m to review".to_string());
}
let mut surface = Self {
client,
repo_path,
review_target,
app,
theme: None,
file_store: AsyncValueStore::new(),
agent_streams: ReviewAgentStreamStore::default(),
};
surface.app.queue_selected_file_load();
Ok(surface)
}
/// Return whether the surface requested exit.
#[must_use]
pub const fn should_exit(&self) -> bool {
self.app.should_exit
}
/// Take a session that should be opened after the surface exits.
pub const fn take_session_to_open(&mut self) -> Option<SessionId> {
self.app.take_session_to_open()
}
fn ensure_selected_repository_file_load(&mut self, host: &dyn PluginTuiHost) -> bool {
ensure_selected_repository_file_load(
host,
&self.client,
&self.repo_path,
&mut self.app,
&mut self.file_store,
)
}
fn ensure_linked_agent_streams(&mut self, host: &dyn PluginTuiHost) -> bool {
self.agent_streams.ensure_subscriptions(host, &mut self.app)
}
#[allow(clippy::too_many_lines)]
pub async fn drain_inline_effects(&mut self) -> bool {
let mut needs_redraw = false;
match drain_pending_workspace_changes(
&self.client,
&self.repo_path,
&self.review_target,
&mut self.app,
)
.await
{
WorkspaceDrainOutcome::Idle => {}
WorkspaceDrainOutcome::Applied | WorkspaceDrainOutcome::Failed => needs_redraw = true,
}
if handle_pending_draft_save(
&self.client,
&self.repo_path,
&self.review_target,
&mut self.app,
)
.await
{
needs_redraw = true;
}
if let Some(delete) = self.app.take_pending_draft_delete() {
match delete_draft(&self.client, self.repo_path.clone(), delete.clone()).await {
Ok(()) => {
self.app.status_message = Some("deleted draft comment".to_string());
}
Err(error) => {
self.app.restore_deleted_draft(delete);
self.app.status_message =
Some(format!("delete failed; restored local draft: {error}"));
}
}
needs_redraw = true;
}
if let Some(update) = self.app.take_pending_draft_update() {
match update_draft(&self.client, self.repo_path.clone(), update.clone()).await {
Ok(()) => {
self.app.status_message = Some("updated draft comment".to_string());
}
Err(error) => {
self.app.restore_updated_draft(update);
self.app.status_message =
Some(format!("update failed; restored local draft: {error}"));
}
}
needs_redraw = true;
}
needs_redraw |= drain_pending_ai_exchange_deletes(
&self.client,
&self.repo_path,
&self.review_target,
&mut self.app,
)
.await;
let ai_exchange_save_changed = drain_pending_ai_exchange_saves(
&self.client,
&self.repo_path,
&self.review_target,
&mut self.app,
)
.await;
needs_redraw |= ai_exchange_save_changed;
let ai_exchange_save_blocked = !self.app.pending_ai_exchange_saves.is_empty();
needs_redraw |= drain_pending_suggestion_saves(
&self.client,
&self.repo_path,
&self.review_target,
&mut self.app,
)
.await;
if let Some(resolve) = self.app.take_pending_thread_resolve() {
match resolve_thread(&self.client, self.repo_path.clone(), resolve.clone()).await {
Ok(()) => {
self.app.status_message = Some(if resolve.resolved {
"resolved review thread".to_string()
} else {
"reopened review thread".to_string()
});
}
Err(error) => {
self.app.restore_thread_resolution(&resolve);
self.app.status_message = Some(format!(
"thread resolution failed; restored local state: {error}"
));
}
}
needs_redraw = true;
}
if !ai_exchange_save_blocked && let Some(ask) = self.app.take_pending_agent_session() {
handle_pending_agent_session(
&self.client,
self.repo_path.clone(),
self.review_target.clone(),
&mut self.app,
ask,
)
.await;
needs_redraw = true;
}
needs_redraw |= self.drain_review_service_effects().await;
needs_redraw
}
async fn drain_review_service_effects(&mut self) -> bool {
let imported = if let Some(request) = self.app.take_external_import_request() {
handle_external_import_request(
&self.client,
self.repo_path.clone(),
&mut self.app,
request,
)
.await;
true
} else {
false
};
let published = if let Some(request) = self.app.take_publish_request() {
handle_publish_request(
&self.client,
self.repo_path.clone(),
self.review_target.clone(),
&mut self.app,
request,
)
.await;
true
} else {
false
};
imported || published
}
}
impl PluginTuiSurface for CodeReviewSurface {
fn id(&self) -> &'static str {
"bcode.code-review"
}
fn title(&self) -> &'static str {
"Code Review"
}
fn render(&mut self, _area: Rect, frame: &mut PaintCx<'_, '_>) {
crate::code_review_tui_render::render(&mut self.app, frame, self.theme.as_ref());
}
fn render_with_theme(
&mut self,
_area: Rect,
frame: &mut PaintCx<'_, '_>,
theme: Option<&bcode_plugin_sdk::tui::PluginTuiTheme>,
) {
self.theme = theme.copied();
self.app.set_tui_theme(theme);
crate::code_review_tui_render::render(&mut self.app, frame, self.theme.as_ref());
}
fn render_with_host(
&mut self,
area: Rect,
frame: &mut PaintCx<'_, '_>,
theme: Option<&PluginTuiTheme>,
host: &dyn PluginTuiHost,
) {
let scope = crate::keybindings::review_scope(&self.app);
crate::keybindings::set_hints(crate::keybindings::effective_hints(scope, |action| {
host.keymap_keys(scope, action)
}));
self.render_with_theme(area, frame, theme);
crate::keybindings::clear_hints();
}
fn handle_event(&mut self, event: &Event, host: &dyn PluginTuiHost) -> PluginTuiAction {
if self.app.comment_editor.is_some()
&& self.app.prompt_state.is_none()
&& self.app.import_state.is_none()
&& self.app.publish_state.is_none()
{
return PluginTuiAction::None;
}
handle_review_surface_event(self, event, host).action
}
fn handle_event_result(
&mut self,
event: &Event,
host: &dyn PluginTuiHost,
) -> PluginTuiEventResult {
if self.app.comment_editor.is_some()
&& self.app.prompt_state.is_none()
&& self.app.import_state.is_none()
&& self.app.publish_state.is_none()
{
return PluginTuiEventResult {
outcome: bmux_tui::event::EventOutcome::Ignored,
action: PluginTuiAction::None,
};
}
handle_review_surface_event(self, event, host)
}
fn focused_text_field_event(
&mut self,
event: &Event,
host: &dyn PluginTuiHost,
) -> PluginTuiEventResult {
if self.app.comment_editor.is_none()
|| self.app.prompt_state.is_some()
|| self.app.import_state.is_some()
|| self.app.publish_state.is_some()
{
return PluginTuiEventResult {
outcome: bmux_tui::event::EventOutcome::Ignored,
action: PluginTuiAction::None,
};
}
handle_review_surface_event(self, event, host)
}
fn focused_text_field(&self) -> Option<FocusedTextFieldSnapshot> {
self.app.focused_comment_input()
}
fn apply_focused_text_field_edit_result(
&mut self,
edit: &FocusedTextFieldEdit,
_host: &dyn PluginTuiHost,
) -> PluginTuiFocusedTextFieldEditResult {
self.app.apply_comment_input(edit)
}
fn session_navigation_finished(&mut self, session_id: SessionId, result: Result<(), String>) {
match result {
Ok(()) => {
self.app.status_message = Some("returned from linked Bcode session".to_string());
}
Err(message) => self.app.navigation_failed(session_id, &message),
}
}
fn poll(&mut self, host: &dyn PluginTuiHost) -> PluginTuiAction {
let linked_loaded = self.ensure_linked_agent_streams(host);
let mut needs_redraw = self.ensure_selected_repository_file_load(host) || linked_loaded;
needs_redraw |= self.agent_streams.drain(&mut self.app);
while let Ok(update) = self.file_store.try_recv() {
self.file_store.apply(update);
sync_repository_file_store(&mut self.app, &self.file_store);
needs_redraw = true;
}
if needs_redraw {
PluginTuiAction::Redraw
} else {
PluginTuiAction::None
}
}
fn drain_effects<'a>(
&'a mut self,
_host: &'a dyn PluginTuiHost,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = PluginTuiAction> + Send + 'a>> {
Box::pin(async move {
if self.drain_inline_effects().await {
PluginTuiAction::Redraw
} else {
PluginTuiAction::None
}
})
}
}
fn handle_review_surface_event(
surface: &mut CodeReviewSurface,
event: &Event,
host: &dyn PluginTuiHost,
) -> PluginTuiEventResult {
let before = surface
.app
.comment_editor
.as_ref()
.map(|editor| (editor.buffer.edit_state(), editor.preview, editor.action));
let status_before = surface.app.status_message.clone();
let handled = handle_effective_event(&mut surface.app, event, host);
if !handled {
return PluginTuiEventResult {
outcome: bmux_tui::event::EventOutcome::Ignored,
action: PluginTuiAction::None,
};
}
let after = surface
.app
.comment_editor
.as_ref()
.map(|editor| (editor.buffer.edit_state(), editor.preview, editor.action));
let mut needs_redraw = before.is_none()
|| before != after
|| status_before != surface.app.status_message
|| matches!(event, Event::Resize(_));
needs_redraw |= surface.app.flush_pending_clipboard_text(host);
surface.app.queue_selected_file_load();
if surface.ensure_selected_repository_file_load(host) {
needs_redraw = true;
}
let action = if let Some(session_id) = surface.app.take_session_to_open() {
PluginTuiAction::OpenSession { session_id }
} else if surface.app.should_exit {
PluginTuiAction::Close { outcome: None }
} else if needs_redraw {
PluginTuiAction::Redraw
} else {
PluginTuiAction::None
};
PluginTuiEventResult {
outcome: if needs_redraw {
bmux_tui::event::EventOutcome::Redraw
} else {
bmux_tui::event::EventOutcome::Handled
},
action,
}
}
/// Code review TUI surface factory backed by the current in-crate implementation.
#[derive(Debug, Default)]
pub struct CodeReviewSurfaceFactory;
impl bcode_plugin_sdk::tui::PluginTuiSurfaceFactory for CodeReviewSurfaceFactory {
fn surface_kind(&self) -> &'static str {
"code-review"
}
fn open(
&self,
request: bcode_plugin_sdk::tui::PluginTuiSurfaceOpenRequest,
) -> bcode_plugin_sdk::tui::PluginTuiSurfaceFuture {
Box::pin(async move {
let repo_path = request
.repo_path
.ok_or("code review surface requires repo_path")?;
let target = match request.target.as_deref() {
Some("repository") => ReviewOpenTarget::Repository,
Some("working-tree-unstaged") => ReviewOpenTarget::WorkingTreeUnstaged,
Some("index-staged") => ReviewOpenTarget::IndexStaged,
Some("working-tree-and-index") => ReviewOpenTarget::WorkingTreeAndIndex,
Some("last-commit") => ReviewOpenTarget::LastCommit,
Some(target) => {
return Err(format!("unsupported code review target: {target}").into());
}
None => request
.options
.get("target")
.cloned()
.map(serde_json::from_value)
.transpose()?
.unwrap_or(ReviewOpenTarget::Repository),
};
let build_mode = request
.options
.get("build_mode")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let workspace = request
.options
.get("workspace")
.cloned()
.and_then(|value| serde_json::from_value(value).ok());
let surface = CodeReviewSurface::load(repo_path, target, workspace, build_mode).await?;
Ok(Box::new(surface) as Box<dyn bcode_plugin_sdk::tui::PluginTuiSurface>)
})
}
}
/// Registry for code review TUI surfaces backed by current TUI implementation.
#[must_use]
pub fn current_code_review_tui_registry() -> bcode_plugin_sdk::tui::PluginTuiRegistry {
let mut registry = bcode_plugin_sdk::tui::PluginTuiRegistry::default();
registry.register_factory(Box::new(CodeReviewSurfaceFactory));
registry
}
/// Run a full-screen local Git review from a durable workspace.
///
/// # Errors
///
/// Returns an error when review data cannot be loaded or terminal I/O fails.
#[allow(clippy::future_not_send)]
pub async fn run_workspace<W: Write>(
terminal: &mut Terminal<&mut W>,
workspace: ReviewWorkspace,
build_mode: bool,
) -> Result<Option<SessionId>, TuiError> {
let target = target_from_workspace(&workspace);
run_with_workspace(
terminal,
workspace.repo_root.clone(),
target,
Some(workspace),
build_mode,
)
.await
}
/// Run a full-screen local Git review.
///
/// # Errors
///
/// Returns an error when review data cannot be loaded or terminal I/O fails.
#[allow(clippy::future_not_send)]
pub async fn run<W: Write>(
terminal: &mut Terminal<&mut W>,
repo_path: PathBuf,
target: ReviewOpenTarget,
) -> Result<Option<SessionId>, TuiError> {
run_with_workspace(terminal, repo_path, target, None, false).await
}
#[allow(clippy::future_not_send)]
#[allow(
clippy::unused_async,
clippy::needless_pass_by_ref_mut,
unused_variables,
dead_code
)]
async fn run_with_workspace<W: Write>(
terminal: &mut Terminal<&mut W>,
repo_path: PathBuf,
target: ReviewOpenTarget,
workspace: Option<ReviewWorkspace>,
build_mode: bool,
) -> Result<Option<SessionId>, TuiError> {
let options = serde_json::json!({ "build_mode": build_mode, "workspace": workspace });
let _ = (terminal, repo_path, target, workspace, build_mode);
Err(TuiError::PluginService {
code: "unsupported_embedded_runner".to_string(),
message: "code review plugin surfaces must be hosted by bcode_tui".to_string(),
})
}
/// Result of draining pending workspace changes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorkspaceDrainOutcome {
/// No workspace work was pending.
Idle,
/// Workspace was saved or reloaded successfully.
Applied,
/// Workspace save/reload failed and remains pending.
Failed,
}
async fn drain_pending_workspace_changes(
client: &BcodeClient,
repo_path: &Path,
fallback_target: &ReviewOpenTarget,
app: &mut ReviewApp,
) -> WorkspaceDrainOutcome {
app.sync_presentation_state();
if app.presentation_state_dirty {
app.pending_workspace_save = true;
app.presentation_state_dirty = false;
}
let mut changed = false;
if app.take_pending_workspace_save() {
match save_workspace(client, app.workspace.clone()).await {
Ok(()) => {
changed = true;
app.status_message = Some("saved review workspace".to_string());
}
Err(error) => {
app.pending_workspace_save = true;
app.status_message = Some(format!("workspace save failed: {error}"));
return WorkspaceDrainOutcome::Failed;
}
}
}
if app.take_pending_workspace_reload() {
match load_workspace_review(
client,
repo_path.to_path_buf(),
fallback_target.clone(),
app.workspace.clone(),
)
.await
{
Ok(review) => {
app.replace_review(review);
app.workspace_materialization_in_progress = false;
app.workspace_materialization_failed = false;
changed = true;
let diagnostics = app.review.diagnostics.len();
app.status_message = Some(if diagnostics == 0 {
"updated review content".to_string()
} else {
format!("updated review content with {diagnostics} diagnostic(s)")
});
}
Err(error) => {
app.pending_workspace_reload = false;
app.workspace_materialization_in_progress = false;
app.workspace_materialization_failed = true;
app.status_message = Some(format!(
"review materialization failed: {error}; fix the source diagnostic and press R to retry"
));
return WorkspaceDrainOutcome::Failed;
}
}
}
if changed {
WorkspaceDrainOutcome::Applied
} else {
WorkspaceDrainOutcome::Idle
}
}
async fn handle_pending_draft_save(
client: &BcodeClient,
repo_path: &Path,
review_target: &ReviewOpenTarget,
app: &mut ReviewApp,
) -> bool {
let Some(save) = app.take_pending_draft_save() else {
return false;
};
match save_draft(
client,
repo_path.to_path_buf(),
review_target.clone(),
Some(review_scope_for_workspace(&app.workspace)),
save,
)
.await
{
Ok(()) => app.status_message = Some("saved draft comment".to_string()),
Err(error) => {
app.status_message = Some(format!("saved locally; draft persistence failed: {error}"));
}
}
true
}
fn ensure_selected_repository_file_load(
host: &dyn PluginTuiHost,
client: &BcodeClient,
repo_path: &Path,
app: &mut ReviewApp,
file_store: &mut AsyncValueStore<String, CachedReviewFile>,
) -> bool {
let retrying_context_load = app.pending_context_file_load.clone();
let path = app
.take_pending_file_load()
.or_else(|| app.take_pending_context_file_load());
let Some(path) = path else {
return false;
};
if retrying_context_load.as_ref() == Some(&path) {
file_store.remove(&path);
}
let client = client.clone();
let repo_path = repo_path.to_path_buf();
let started = file_store.ensure(host, path, move |path| async move {
load_repository_file(&client, repo_path, path)
.await
.map_err(|error| error.to_string())
});
sync_repository_file_store(app, file_store);
started
}
fn sync_repository_file_store(
app: &mut ReviewApp,
file_store: &AsyncValueStore<String, CachedReviewFile>,
) {
let Some(path) = app.selected_file_path() else {
return;
};
match file_store.get(&path) {
AsyncValue::Ready(file) => app.store_loaded_file(file.clone()),
AsyncValue::Error(error) => app.store_file_load_error(path, error),
AsyncValue::Missing | AsyncValue::Loading => {
if app.pending_context_file_load.as_deref() == Some(&path) {
app.status_message = Some("loading hidden diff context".to_string());
}
}
}
}
async fn load_external_imports(
client: &BcodeClient,
repo_path: PathBuf,
workspace_id: String,
) -> Result<Vec<ExternalReviewImportRecord>, TuiError> {
let payload = serde_json::to_vec(&ListExternalReviewImportsRequest {
repo_path,
workspace_id,
})
.map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
OP_REVIEW_EXTERNAL_IMPORTS_LIST.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: ListExternalReviewImportsResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(response.records)
}
async fn load_publish_records(
client: &BcodeClient,
repo_path: PathBuf,
workspace_id: String,
) -> Result<Vec<bcode_code_review_models::ReviewPublishRecord>, TuiError> {
let payload = serde_json::to_vec(&ListPublishRecordsRequest {
repo_path,
workspace_id,
})
.map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
PUBLISH_RECORDS_LIST_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: ListPublishRecordsResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(response.records)
}
async fn load_review_app(
client: &BcodeClient,
repo_path: PathBuf,
review_target: ReviewOpenTarget,
workspace: Option<ReviewWorkspace>,
) -> Result<ReviewApp, TuiError> {
let review = if let Some(workspace) = workspace.clone() {
load_workspace_review(client, repo_path.clone(), review_target.clone(), workspace).await?
} else {
load_review(client, repo_path.clone(), review_target.clone()).await?
};
let drafts = load_drafts(
client,
repo_path.clone(),
review_target.clone(),
workspace.as_ref().map(review_scope_for_workspace),
)
.await;
let ai_exchanges = load_ai_exchanges(
client,
repo_path.clone(),
review_target.clone(),
workspace.as_ref().map(review_scope_for_workspace),
)
.await;
let suggestions = load_suggestions(
client,
repo_path.clone(),
review_target,
workspace.as_ref().map(review_scope_for_workspace),
)
.await;
let (publish_records, external_imports) = if let Some(workspace) = &workspace {
(
load_publish_records(client, repo_path.clone(), workspace.id.clone()).await,
load_external_imports(client, repo_path, workspace.id.clone()).await,
)
} else {
(Ok(Vec::new()), Ok(Vec::new()))
};
let mut app = ReviewApp::new(review);
if app.workspace.sources.is_empty()
&& let Some(workspace) = workspace
{
app.workspace = workspace;
}
match drafts {
Ok(drafts) => app.load_persisted_drafts(drafts),
Err(error) => {
app.status_message = Some(format!("failed to load persisted drafts: {error}"));
}
}
match ai_exchanges {
Ok(exchanges) => app.load_persisted_ai_exchanges(exchanges),
Err(error) => {
app.status_message = Some(format!("failed to load review AI exchanges: {error}"));
}
}
match suggestions {
Ok(suggestions) => app.load_persisted_suggestions(suggestions),
Err(error) => {
app.status_message = Some(format!("failed to load persisted suggestions: {error}"));
}
}
match publish_records {
Ok(records) => app.load_publish_records(records),
Err(error) => {
app.status_message = Some(format!("failed to load publish history: {error}"));
}
}
match external_imports {
Ok(records) => app.load_external_imports(records),
Err(error) => {
app.status_message = Some(format!("failed to load external reviews: {error}"));
}
}
Ok(app)
}
fn review_scope_for_workspace(workspace: &ReviewWorkspace) -> ModelReviewScope {
ModelReviewScope::Workspace {
workspace_id: workspace.id.clone(),
target: workspace
.sources
.iter()
.find(|source| source.included)
.map_or(
bcode_code_review_models::ReviewTarget::Repository,
|source| model_target_from_source_kind(&source.kind),
),
}
}
async fn save_workspace(client: &BcodeClient, workspace: ReviewWorkspace) -> Result<(), TuiError> {
let payload = serde_json::to_vec(&UpdateReviewWorkspaceRequest {
repo_path: workspace.repo_root.clone(),
workspace,
})
.map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
REVIEW_WORKSPACE_UPDATE_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
Ok(())
}
async fn handle_pending_agent_session(
client: &BcodeClient,
repo_path: PathBuf,
review_target: ReviewOpenTarget,
app: &mut ReviewApp,
ask: PendingAgentSession,
) {
app.set_agent_phase(&ask.anchor, ReviewAgentThreadPhase::Running);
app.set_agent_status(&ask.anchor, "sending follow-up to linked session");
if let Some(session_id) = app.session_id_for_anchor(&ask.anchor) {
if let Ok(session_id) = session_id.parse::<SessionId>() {
let prompt = app.agent_session_prompt(&ask);
match client
.send_user_message(session_id, prompt, bcode_ipc::PromptPlacement::FollowUp)
.await
{
Ok(_) => {
app.set_agent_status(&ask.anchor, format!("sent follow-up to {session_id}"));
app.status_message = Some(format!(
"sent review follow-up to linked session {session_id}"
));
}
Err(error) => {
app.mark_ai_exchange_failed(&ask.exchange_id, &error.to_string());
app.fail_agent_thread(&ask.anchor, format!("follow-up failed: {error}"));
app.status_message = Some(format!("failed to send review follow-up: {error}"));
}
}
} else {
app.mark_ai_exchange_failed(&ask.exchange_id, "linked session id is invalid");
app.fail_agent_thread(&ask.anchor, "linked session id is invalid");
app.status_message = Some("linked session id is invalid".to_string());
}
} else {
app.set_agent_phase(&ask.anchor, ReviewAgentThreadPhase::CreatingSession);
app.set_agent_status(&ask.anchor, "creating Bcode session");
match create_agent_session(client, repo_path, review_target, app, ask.clone()).await {
Ok(session_id) => {
app.mark_thread_session(&ask.exchange_id, &ask.anchor, &session_id.to_string());
app.set_agent_status(&ask.anchor, format!("session linked: {session_id}"));
app.status_message = Some(format!("created Bcode session {session_id}"));
}
Err(error) => {
app.mark_ai_exchange_failed(&ask.exchange_id, &error.to_string());
app.fail_agent_thread(&ask.anchor, format!("session failed: {error}"));
app.status_message = Some(format!("failed to create Bcode session: {error}"));
}
}
}
}
async fn handle_external_import_request(
client: &BcodeClient,
repo_path: PathBuf,
app: &mut ReviewApp,
request: PendingExternalImportRequest,
) {
match request {
PendingExternalImportRequest::Discover => match discover_external_importers(client).await {
Ok(importers) => app.show_external_importers(importers),
Err(error) => {
app.status_message = Some(format!("external importer discovery failed: {error}"));
}
},
PendingExternalImportRequest::Invoke {
importer_index,
options,
} => {
let Some(importer) = app.external_importers.get(importer_index).cloned() else {
app.status_message = Some("selected external importer is unavailable".to_string());
return;
};
match invoke_external_importer(
client,
repo_path.clone(),
app,
&importer,
options_json(options),
)
.await
{
Ok(import) => match save_external_import_snapshot(
client,
repo_path,
app.workspace.id.clone(),
import,
)
.await
{
Ok(record) => {
let count = record.threads.len();
app.external_review_imports
.retain(|existing| existing.importer_id != record.importer_id);
app.external_review_imports.push(record);
app.import_state = None;
app.status_message =
Some(format!("imported {count} external review thread(s)"));
}
Err(error) => {
app.status_message =
Some(format!("external review persistence failed: {error}"));
}
},
Err(error) => {
app.status_message = Some(format!("external review import failed: {error}"));
}
}
}
}
}
async fn discover_external_importers(
client: &BcodeClient,
) -> Result<Vec<ExternalImporterRoute>, TuiError> {
let services = client.plugin_services().await?;
let mut importers = Vec::new();
for service in services
.into_iter()
.filter(|service| service.interface_id == REVIEW_IMPORTER_INTERFACE_ID)
{
let response = client
.invoke_plugin_service(
service.plugin_id.clone(),
service.interface_id.clone(),
OP_REVIEW_IMPORTER_MANIFEST.to_string(),
serde_json::to_vec(&serde_json::json!({})).map_err(TuiError::Json)?,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
importers.push(ExternalImporterRoute {
manifest: serde_json::from_slice(&response.payload).map_err(TuiError::Json)?,
plugin_id: service.plugin_id,
interface_id: service.interface_id,
});
}
Ok(importers)
}
async fn invoke_external_importer(
client: &BcodeClient,
repo_path: PathBuf,
app: &ReviewApp,
importer: &ExternalImporterRoute,
options: serde_json::Value,
) -> Result<ExternalReviewImportResponse, TuiError> {
let files = app.review.files.iter().map(ReviewFile::to_model).collect();
let request = ExternalReviewImportRequest {
repo_root: repo_path,
files,
options,
};
let response = client
.invoke_plugin_service(
importer.plugin_id.clone(),
importer.interface_id.clone(),
OP_REVIEW_IMPORTER_IMPORT.to_string(),
serde_json::to_vec(&request).map_err(TuiError::Json)?,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let imported_response: ExternalReviewImportResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
if imported_response.importer_id != importer.manifest.id {
return Err(TuiError::PluginService {
code: "importer_id_mismatch".to_string(),
message: "import response id does not match importer manifest".to_string(),
});
}
Ok(imported_response)
}
async fn save_external_import_snapshot(
client: &BcodeClient,
repo_path: PathBuf,
workspace_id: String,
import: ExternalReviewImportResponse,
) -> Result<ExternalReviewImportRecord, TuiError> {
let request = SaveExternalReviewImportRequest {
repo_path,
workspace_id,
import,
};
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
OP_REVIEW_EXTERNAL_IMPORT_SAVE.to_string(),
serde_json::to_vec(&request).map_err(TuiError::Json)?,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: SaveExternalReviewImportResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(response.record)
}
async fn handle_publish_request(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
app: &mut ReviewApp,
request: PendingPublishRequest,
) {
match request {
PendingPublishRequest::ListPublishers => match list_publishers(client).await {
Ok(mut publishers) => {
match list_external_publishers(client).await {
Ok(external) => publishers.extend(external),
Err(error) => {
app.status_message =
Some(format!("external publisher discovery failed: {error}"));
}
}
app.show_publishers(publishers);
}
Err(error) => app.status_message = Some(format!("publisher list failed: {error}")),
},
PendingPublishRequest::Preview {
publisher_id,
options,
excluded_thread_anchors,
} => match preview_review(
client,
PublishInvocation {
repo_path,
target,
workspace: Some(app.workspace.clone()),
excluded_thread_anchors,
route: app
.publisher_for_id(&publisher_id)
.and_then(|publisher| publisher.route.clone()),
publisher_id,
options,
},
)
.await
{
Ok(response) => app.show_publish_preview(response.publisher_id, response.preview),
Err(error) => app.fail_publish_preview(&error.to_string()),
},
PendingPublishRequest::Submit {
publisher_id,
options,
excluded_thread_anchors,
} => match publish_review(
client,
PublishInvocation {
repo_path,
target,
workspace: Some(app.workspace.clone()),
excluded_thread_anchors,
route: app
.publisher_for_id(&publisher_id)
.and_then(|publisher| publisher.route.clone()),
publisher_id,
options,
},
)
.await
{
Ok(response) => app.finish_publish(response),
Err(error) => app.fail_publish_submit(&error.to_string()),
},
}
}
async fn list_publishers(client: &BcodeClient) -> Result<Vec<ReviewPublisherManifest>, TuiError> {
let payload = serde_json::to_vec(&serde_json::json!({})).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
PUBLISHERS_LIST_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: ListReviewPublishersResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(response.publishers)
}
#[derive(Debug, Clone)]
struct PublishInvocation {
repo_path: PathBuf,
target: ReviewOpenTarget,
workspace: Option<ReviewWorkspace>,
excluded_thread_anchors: Vec<ModelDraftAnchor>,
route: Option<ReviewPublisherRoute>,
publisher_id: String,
options: Vec<ReviewPublishOption>,
}
async fn preview_review(
client: &BcodeClient,
invocation: PublishInvocation,
) -> Result<PublishReviewPreviewResponse, TuiError> {
let PublishInvocation {
repo_path,
target,
workspace,
excluded_thread_anchors,
route,
publisher_id,
options,
} = invocation;
let options = options_json(options);
if let Some(route) = route {
let bundle = load_review_bundle(
client,
repo_path,
target,
workspace.clone(),
excluded_thread_anchors.clone(),
)
.await?;
let response = invoke_external_publisher(
client,
route,
REVIEW_PUBLISHER_PREVIEW_OPERATION.to_string(),
&bundle,
options,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
return serde_json::from_slice(&response.payload).map_err(TuiError::Json);
}
let request = PublishReviewRequest {
repo_path,
target,
workspace,
excluded_thread_anchors,
publisher_id,
options,
};
let payload = serde_json::to_vec(&request).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
PUBLISH_PREVIEW_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
serde_json::from_slice(&response.payload).map_err(TuiError::Json)
}
async fn publish_review(
client: &BcodeClient,
invocation: PublishInvocation,
) -> Result<PublishReviewResponse, TuiError> {
let PublishInvocation {
repo_path,
target,
workspace,
excluded_thread_anchors,
route,
publisher_id,
options,
} = invocation;
let options = options_json(options);
if let Some(route) = route {
let bundle = load_review_bundle(
client,
repo_path.clone(),
target,
workspace.clone(),
excluded_thread_anchors.clone(),
)
.await?;
let response = invoke_external_publisher(
client,
route,
REVIEW_PUBLISHER_SUBMIT_OPERATION.to_string(),
&bundle,
options,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let publish_response: PublishReviewResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
if let Some(workspace) = workspace {
save_external_publish_record(
client,
workspace,
bundle.review_id,
bundle
.threads
.iter()
.map(|thread| thread.anchor.clone())
.collect(),
&publish_response,
)
.await?;
}
return Ok(publish_response);
}
let request = PublishReviewRequest {
repo_path,
target,
workspace,
excluded_thread_anchors,
publisher_id,
options,
};
let payload = serde_json::to_vec(&request).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
PUBLISH_SUBMIT_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
serde_json::from_slice(&response.payload).map_err(TuiError::Json)
}
async fn save_external_publish_record(
client: &BcodeClient,
workspace: ReviewWorkspace,
review_id: String,
published_thread_anchors: Vec<ModelDraftAnchor>,
response: &PublishReviewResponse,
) -> Result<(), TuiError> {
let request = SavePublishRecordRequest {
workspace,
review_id,
publisher_id: response.publisher_id.clone(),
submitted: response.submitted,
published_thread_anchors,
output: response.output.clone(),
message: response.message.clone(),
};
let payload = serde_json::to_vec(&request).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
PUBLISH_RECORD_SAVE_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let _: SavePublishRecordResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(())
}
fn cycle_option(
options: &mut [ReviewPublishOption],
selected: usize,
offset: isize,
status_message: &mut Option<String>,
) -> bool {
let Some(option) = options.get_mut(selected) else {
return false;
};
if option.choices.is_empty() {
*status_message = Some("selected option has no choices".to_string());
return true;
}
let current = option
.choices
.iter()
.position(|choice| choice == &option.value)
.unwrap_or(0);
let len = option.choices.len();
let next = if offset.is_negative() {
current
.saturating_add(len)
.saturating_sub(offset.unsigned_abs() % len)
% len
} else {
current.saturating_add(offset.unsigned_abs()) % len
};
option.value.clone_from(&option.choices[next]);
*status_message = Some(format!("{} = {}", option.name, option.value));
true
}
fn edit_option(options: &mut [ReviewPublishOption], selected: usize, stroke: KeyStroke) -> bool {
let Some(option) = options.get_mut(selected) else {
return false;
};
match stroke.key {
KeyCode::Char(ch) if stroke.modifiers.is_empty() && option.choices.is_empty() => {
option.value.push(ch);
true
}
KeyCode::Backspace if stroke.modifiers.is_empty() && option.choices.is_empty() => {
option.value.pop();
true
}
_ => false,
}
}
fn options_from_schema(schema: &serde_json::Value) -> Vec<ReviewPublishOption> {
let required = schema
.get("required")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.collect::<BTreeSet<_>>();
schema
.get("properties")
.and_then(serde_json::Value::as_object)
.map(|properties| {
properties
.iter()
.filter_map(|(name, schema)| publish_option_from_schema(name, schema, &required))
.collect()
})
.unwrap_or_default()
}
fn publish_option_from_schema(
name: &str,
schema: &serde_json::Value,
required: &BTreeSet<&str>,
) -> Option<ReviewPublishOption> {
let option_type = schema
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if option_type != "string" && option_type != "boolean" {
return None;
}
Some(ReviewPublishOption {
name: name.to_string(),
label: publish_option_label(name, schema, required.contains(name)),
value: publish_option_default(schema),
choices: publish_option_choices(option_type, schema),
})
}
fn publish_option_label(name: &str, schema: &serde_json::Value, required: bool) -> String {
let mut label = schema
.get("description")
.and_then(serde_json::Value::as_str)
.unwrap_or(name)
.to_string();
if required {
label.push_str(" [required]");
}
let choices = publish_option_choices(
schema
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default(),
schema,
)
.join("|");
if !choices.is_empty() {
let _ = write!(label, " [{choices}]");
}
label
}
fn publish_option_default(schema: &serde_json::Value) -> String {
schema
.get("default")
.and_then(|value| {
value
.as_str()
.map(ToString::to_string)
.or_else(|| value.as_bool().map(|value| value.to_string()))
})
.unwrap_or_default()
}
fn publish_option_choices(option_type: &str, schema: &serde_json::Value) -> Vec<String> {
let mut choices = schema
.get("enum")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|value| {
value
.as_str()
.map(ToString::to_string)
.or_else(|| value.as_bool().map(|value| value.to_string()))
})
.collect::<Vec<_>>();
if option_type == "boolean" && choices.is_empty() {
choices.extend(["false".to_string(), "true".to_string()]);
}
choices
}
fn options_json(options: Vec<ReviewPublishOption>) -> serde_json::Value {
let mut object = serde_json::Map::new();
for option in options {
if option.value.is_empty() {
continue;
}
if option.choices == ["false".to_string(), "true".to_string()] {
object.insert(
option.name,
serde_json::Value::Bool(option.value.eq_ignore_ascii_case("true")),
);
} else {
object.insert(option.name, serde_json::Value::String(option.value));
}
}
serde_json::Value::Object(object)
}
async fn list_external_publishers(
client: &BcodeClient,
) -> Result<Vec<ReviewPublisherManifest>, TuiError> {
let services = client.plugin_services().await?;
let mut publishers = Vec::new();
for service in services
.into_iter()
.filter(|service| service.interface_id == REVIEW_PUBLISHER_INTERFACE_ID)
{
let response = client
.invoke_plugin_service(
service.plugin_id.clone(),
service.interface_id.clone(),
REVIEW_PUBLISHER_MANIFEST_OPERATION.to_string(),
serde_json::to_vec(&serde_json::json!({})).map_err(TuiError::Json)?,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let mut publisher: ReviewPublisherManifest =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
publisher.route = Some(ReviewPublisherRoute {
plugin_id: service.plugin_id,
interface_id: service.interface_id,
});
publishers.push(publisher);
}
Ok(publishers)
}
async fn load_review_bundle(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
workspace: Option<ReviewWorkspace>,
excluded_thread_anchors: Vec<ModelDraftAnchor>,
) -> Result<ReviewBundle, TuiError> {
let request = ReviewBundleRequest {
repo_path,
target,
workspace,
excluded_thread_anchors,
};
let payload = serde_json::to_vec(&request).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
REVIEW_BUNDLE_GET_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
serde_json::from_slice(&response.payload).map_err(TuiError::Json)
}
async fn invoke_external_publisher(
client: &BcodeClient,
route: ReviewPublisherRoute,
operation: String,
bundle: &ReviewBundle,
options: serde_json::Value,
) -> Result<PluginServiceResponse, TuiError> {
let payload = serde_json::to_vec(&serde_json::json!({
"bundle": bundle,
"options": options,
}))
.map_err(TuiError::Json)?;
client
.invoke_plugin_service(route.plugin_id, route.interface_id, operation, payload)
.await
.map_err(TuiError::from)
}
async fn load_workspace_review(
client: &BcodeClient,
repo_path: PathBuf,
_fallback_target: ReviewOpenTarget,
workspace: ReviewWorkspace,
) -> Result<ReviewSummary, TuiError> {
let payload = serde_json::to_vec(&MaterializeReviewWorkspaceRequest {
repo_path: repo_path.clone(),
workspace,
})
.map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
REVIEW_WORKSPACE_MATERIALIZE_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: MaterializeReviewWorkspaceResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
let materialization = response.materialization;
let mut files = Vec::new();
let mut surfaces = Vec::new();
for surface in materialization.surfaces {
if let Some(file) = surface.file.clone() {
files.push(ReviewFile::from_model(file));
surfaces.push(surface);
}
}
let diagnostics = materialization.diagnostics;
let repository_files = materialization.repository_files;
let repository_branches = materialization.repository_branches;
let repository_commits = materialization.repository_commits;
if files.is_empty() {
return Ok(ReviewSummary {
title: materialization.workspace.title.clone(),
repo_root: materialization.workspace.repo_root.clone(),
files,
additions: materialization.additions,
deletions: materialization.deletions,
workspace: Some(materialization.workspace),
surfaces,
diagnostics,
repository_files,
repository_branches,
repository_commits,
});
}
Ok(ReviewSummary {
title: materialization.workspace.title.clone(),
repo_root: materialization.workspace.repo_root.clone(),
files,
additions: materialization.additions,
deletions: materialization.deletions,
workspace: Some(materialization.workspace),
surfaces,
diagnostics,
repository_files,
repository_branches,
repository_commits,
})
}
async fn load_review(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
) -> Result<ReviewSummary, TuiError> {
let request = CreateReviewRequest {
repo_path,
target: target.clone(),
};
let payload = serde_json::to_vec(&request).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
CREATE_REVIEW_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let mut summary: ReviewSummary =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
summary.surfaces = summary.surfaces();
Ok(summary)
}
async fn load_repository_file(
client: &BcodeClient,
repo_path: PathBuf,
file_path: String,
) -> Result<CachedReviewFile, TuiError> {
let payload = serde_json::to_vec(&serde_json::json!({
"repo_path": repo_path,
"file_path": file_path,
}))
.map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
REVIEW_REPO_FILE_GET_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: RepositoryFileResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(CachedReviewFile::from_response(response))
}
async fn load_drafts(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
scope: Option<ModelReviewScope>,
) -> Result<Vec<DraftComment>, TuiError> {
let request = ListDraftsRequest {
repo_path,
target: target.clone(),
scope,
};
let payload = serde_json::to_vec(&request).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
LIST_DRAFTS_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: ListDraftsResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(response.drafts)
}
async fn load_ai_exchanges(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
scope: Option<ModelReviewScope>,
) -> Result<Vec<ModelReviewAiExchange>, TuiError> {
let payload = serde_json::to_vec(&ListReviewAiExchangesRequest {
repo_path,
target,
scope,
})
.map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
OP_REVIEW_AI_EXCHANGES_LIST.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: ListReviewAiExchangesResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(response.exchanges)
}
async fn save_ai_exchange(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
scope: Option<ModelReviewScope>,
exchange: ModelReviewAiExchange,
) -> Result<ModelReviewAiExchange, TuiError> {
let payload = serde_json::to_vec(&SaveReviewAiExchangeRequest {
repo_path,
target,
scope,
exchange,
})
.map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
OP_REVIEW_AI_EXCHANGE_SAVE.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: SaveReviewAiExchangeResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(response.exchange)
}
async fn delete_ai_exchange(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
scope: Option<ModelReviewScope>,
exchange_id: String,
) -> Result<bool, TuiError> {
let payload = serde_json::to_vec(&DeleteReviewAiExchangeRequest {
repo_path,
target,
scope,
exchange_id,
})
.map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
OP_REVIEW_AI_EXCHANGE_DELETE.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: DeleteReviewAiExchangeResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(response.deleted)
}
async fn drain_pending_ai_exchange_deletes(
client: &BcodeClient,
repo_path: &Path,
review_target: &ReviewOpenTarget,
app: &mut ReviewApp,
) -> bool {
let Some((exchange_id, anchor)) = app.take_pending_ai_exchange_delete() else {
return false;
};
match delete_ai_exchange(
client,
repo_path.to_path_buf(),
review_target.clone(),
Some(review_scope_for_workspace(&app.workspace)),
exchange_id.clone(),
)
.await
{
Ok(_) => true,
Err(error) => {
app.requeue_ai_exchange_delete(exchange_id, anchor);
app.status_message = Some(format!("failed to delete AI exchange; will retry: {error}"));
true
}
}
}
fn record_unverified_ai_exchange_save(app: &mut ReviewApp, exchange_id: &str) {
app.mark_ai_exchange_failed(exchange_id, "Saving the review exchange did not produce a verified result; inspect its current state before submitting again.");
// Failure presentation must not itself schedule a second mutation of the ambiguous record.
app.pending_ai_exchange_saves.remove(exchange_id);
app.pending_agent_session = None;
}
async fn drain_pending_ai_exchange_saves(
client: &BcodeClient,
repo_path: &Path,
review_target: &ReviewOpenTarget,
app: &mut ReviewApp,
) -> bool {
let mut changed = false;
while let Some(exchange) = app.take_pending_ai_exchange_save() {
let exchange_id = exchange.exchange_id.clone();
match save_ai_exchange(
client,
repo_path.to_path_buf(),
review_target.clone(),
Some(review_scope_for_workspace(&app.workspace)),
exchange,
)
.await
{
Ok(persisted) => app.mark_ai_exchange_persisted(&persisted),
Err(error) => {
record_unverified_ai_exchange_save(app, &exchange_id);
app.status_message = Some(format!(
"failed to save review AI exchange; not retried: {error}"
));
return true;
}
}
changed = true;
}
changed
}
async fn drain_pending_suggestion_saves(
client: &BcodeClient,
repo_path: &Path,
review_target: &ReviewOpenTarget,
app: &mut ReviewApp,
) -> bool {
let mut changed = false;
while let Some(suggestion) = app.take_pending_suggestion_save() {
let suggestion_id = suggestion.suggestion_id.clone();
match save_suggestion(
client,
repo_path.to_path_buf(),
review_target.clone(),
Some(review_scope_for_workspace(&app.workspace)),
suggestion,
)
.await
{
Ok(persisted) => {
app.mark_suggestion_persisted(&persisted);
app.status_message = Some("saved suggested comment".to_string());
}
Err(error) => {
app.requeue_suggestion_save(&suggestion_id);
app.status_message = Some(format!(
"failed to save suggested comment; will retry: {error}"
));
return true;
}
}
changed = true;
}
changed
}
async fn load_suggestions(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
scope: Option<ModelReviewScope>,
) -> Result<Vec<ModelReviewSuggestion>, TuiError> {
let payload = serde_json::to_vec(&ListReviewSuggestionsRequest {
repo_path,
target,
scope,
})
.map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
OP_REVIEW_SUGGESTIONS_LIST.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: ListReviewSuggestionsResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(response.suggestions)
}
async fn save_suggestion(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
scope: Option<ModelReviewScope>,
suggestion: ModelReviewSuggestion,
) -> Result<ModelReviewSuggestion, TuiError> {
let payload = serde_json::to_vec(&SaveReviewSuggestionRequest {
repo_path,
target,
scope,
suggestion,
})
.map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
OP_REVIEW_SUGGESTION_SAVE.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: SaveReviewSuggestionResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(response.suggestion)
}
async fn save_draft(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
scope: Option<ModelReviewScope>,
save: PendingDraftSave,
) -> Result<(), TuiError> {
let request = SaveDraftRequest {
repo_path,
target: target.clone(),
scope,
anchor: save.anchor.into(),
body: save.body,
};
let payload = serde_json::to_vec(&request).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
SAVE_DRAFT_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let _: SaveDraftResponse = serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(())
}
async fn delete_draft(
client: &BcodeClient,
repo_path: PathBuf,
delete: PendingDraftDelete,
) -> Result<(), TuiError> {
let Some(comment_id) = delete.comment.id else {
return Ok(());
};
let request = DeleteDraftRequest {
repo_path,
target: delete.target,
scope: delete.scope,
comment_id,
};
let payload = serde_json::to_vec(&request).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
DELETE_DRAFT_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let _: DeleteDraftResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(())
}
async fn update_draft(
client: &BcodeClient,
repo_path: PathBuf,
update: PendingDraftUpdate,
) -> Result<(), TuiError> {
let request = UpdateDraftRequest {
repo_path,
target: update.target,
scope: update.scope,
comment_id: update.comment_id,
body: update.new_body,
};
let payload = serde_json::to_vec(&request).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
UPDATE_DRAFT_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let _: UpdateDraftResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(())
}
async fn create_agent_session(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
app: &ReviewApp,
ask: PendingAgentSession,
) -> Result<SessionId, TuiError> {
let session = client
.create_session_in_working_directory(
Some(format!("Review: {}", ask.anchor.path)),
app.review.repo_root.clone(),
)
.await?;
let prompt = app.agent_session_prompt(&ask);
client
.send_user_message(session.id, prompt, bcode_ipc::PromptPlacement::FollowUp)
.await?;
link_thread_session(client, repo_path, target, app, ask.anchor, session.id).await?;
Ok(session.id)
}
async fn link_thread_session(
client: &BcodeClient,
repo_path: PathBuf,
target: ReviewOpenTarget,
app: &ReviewApp,
anchor: ReviewCommentAnchor,
session_id: SessionId,
) -> Result<(), TuiError> {
let request = LinkThreadSessionRequest {
repo_path,
target: target.clone(),
scope: Some(review_scope_for_workspace(&app.workspace)),
anchor: anchor.into(),
session_id: session_id.to_string(),
};
let payload = serde_json::to_vec(&request).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
LINK_THREAD_SESSION_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let _: LinkThreadSessionResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
Ok(())
}
async fn resolve_thread(
client: &BcodeClient,
repo_path: PathBuf,
resolve: PendingThreadResolve,
) -> Result<(), TuiError> {
let request = ResolveThreadRequest {
repo_path,
target: resolve.target,
scope: resolve.scope,
anchor: resolve.anchor.into(),
resolved: resolve.resolved,
};
let payload = serde_json::to_vec(&request).map_err(TuiError::Json)?;
let response = client
.call_plugin_service(
SERVICE_INTERFACE_ID.to_string(),
THREAD_RESOLVE_OPERATION.to_string(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
let response: ResolveThreadResponse =
serde_json::from_slice(&response.payload).map_err(TuiError::Json)?;
let _ = (response.thread_id, response.resolved_at_ms);
Ok(())
}
fn handle_import_event(app: &mut ReviewApp, event: &Event) -> bool {
match event {
Event::Key(stroke) => handle_import_key(app, *stroke),
Event::Paste(text) => app.insert_import_option_text(text),
Event::Resize(_) | Event::Focus(_) | Event::Tick => true,
Event::Mouse(_) | Event::User(_) => false,
}
}
fn handle_import_key(app: &mut ReviewApp, stroke: KeyStroke) -> bool {
if stroke.key == KeyCode::Escape && stroke.modifiers.is_empty() {
app.import_state = None;
return true;
}
if app.import_options_active() {
if stroke.key == KeyCode::Enter && stroke.modifiers.is_empty() {
return app.confirm_import_selection();
}
if stroke.key == KeyCode::Tab && stroke.modifiers.is_empty() {
return app.import_down(1);
}
if stroke.key == KeyCode::Tab && stroke.modifiers.shift {
return app.import_up(1);
}
if matches!(stroke.key, KeyCode::Right | KeyCode::Char(' ')) && stroke.modifiers.is_empty()
{
return app.cycle_selected_import_option(1);
}
if stroke.key == KeyCode::Left && stroke.modifiers.is_empty() {
return app.cycle_selected_import_option(-1);
}
return app.edit_import_option(stroke);
}
if !stroke.modifiers.is_empty() {
return false;
}
match stroke.key {
KeyCode::Char('j') | KeyCode::Down => app.import_down(1),
KeyCode::Char('k') | KeyCode::Up => app.import_up(1),
KeyCode::Enter => app.confirm_import_selection(),
_ => false,
}
}
fn handle_publish_event(app: &mut ReviewApp, event: &Event) -> bool {
match event {
Event::Key(stroke) => handle_publish_key(app, *stroke),
Event::Paste(text) => app.insert_publish_option_text(text),
Event::Resize(_) | Event::Focus(_) | Event::Tick => true,
Event::Mouse(_) | Event::User(_) => false,
}
}
fn handle_publish_key(app: &mut ReviewApp, stroke: KeyStroke) -> bool {
if matches!(app.publish_state, Some(ReviewPublishState::Checklist))
&& stroke.modifiers.is_empty()
{
match stroke.key {
KeyCode::Char('!') => {
app.publish_state = None;
return app.show_attention_sidebar();
}
KeyCode::Char('W') => {
app.publish_state = None;
return app.select_next_unviewed_file();
}
KeyCode::Char('P') => {
app.publish_state = None;
return app.select_next_open_thread_global();
}
_ => {}
}
}
if app.publish_options_active() {
if stroke.key == KeyCode::Escape && stroke.modifiers.is_empty() {
app.publish_state = None;
return true;
}
if stroke.key == KeyCode::Enter && stroke.modifiers.is_empty() {
return app.confirm_publish_selection();
}
if stroke.key == KeyCode::Tab && stroke.modifiers.is_empty() {
return app.publish_down(1);
}
if stroke.key == KeyCode::Tab && stroke.modifiers.shift {
return app.publish_up(1);
}
if stroke.key == KeyCode::Right && stroke.modifiers.is_empty() {
return app.cycle_selected_publish_option(1);
}
if stroke.key == KeyCode::Left && stroke.modifiers.is_empty() {
return app.cycle_selected_publish_option(-1);
}
if stroke.key == KeyCode::Char(' ') && stroke.modifiers.is_empty() {
return app.cycle_selected_publish_option(1);
}
return app.edit_publish_option(stroke);
}
if !stroke.modifiers.is_empty() {
return false;
}
match stroke.key {
KeyCode::Escape => {
app.publish_state = None;
true
}
KeyCode::Char('j') | KeyCode::Down => app.publish_down(1),
KeyCode::Char('k') | KeyCode::Up => app.publish_up(1),
KeyCode::Char('p') => app.back_to_publish_preview(),
KeyCode::Char(' ') => app.toggle_selected_publish_thread(),
KeyCode::Enter => app.confirm_publish_selection(),
_ => false,
}
}
fn handle_effective_event(app: &mut ReviewApp, event: &Event, host: &dyn PluginTuiHost) -> bool {
#[cfg(test)]
if app.comment_editor.is_some() {
app.comment_input_invocations += 1;
}
let scope = crate::keybindings::review_scope(app);
let resolved_event;
let event = if let Event::Key(stroke) = event {
let text = app.prompt_state.is_some()
|| app.import_options_active()
|| app.publish_options_active()
|| app.comment_editor.is_some();
let Some(native) = crate::keybindings::from_host(host, scope, *stroke, text) else {
return false;
};
resolved_event = if host.keymap_action(scope, *stroke).is_none() {
match native.key {
KeyCode::Char(character) => Event::Paste(character.to_string()),
_ => Event::Key(native),
}
} else {
Event::Key(native)
};
&resolved_event
} else {
event
};
let handled = handle_event_no_resize(app, event);
app.observe_comment_input();
handled
}
fn handle_event_no_resize(app: &mut ReviewApp, event: &Event) -> bool {
if let Event::Focus(focus) = event {
app.terminal_focused = matches!(focus, FocusEvent::Gained);
}
let handled = handle_event_no_resize_inner(app, event);
app.observe_comment_input();
handled
}
fn handle_event_no_resize_inner(app: &mut ReviewApp, event: &Event) -> bool {
if let Event::Key(stroke) = event
&& is_review_exit_key(*stroke)
{
app.should_exit = true;
return true;
}
if app.prompt_state.is_some() {
return handle_prompt_event(app, event);
}
if app.import_state.is_some() {
return handle_import_event(app, event);
}
if app.publish_state.is_some() {
return handle_publish_event(app, event);
}
if app.comment_editor.is_some() {
return handle_comment_editor_event(app, event);
}
match event {
Event::Resize(_) | Event::Focus(FocusEvent::Gained | FocusEvent::Lost) | Event::Tick => {
true
}
Event::Key(stroke) => handle_key(app, *stroke),
Event::Mouse(mouse) => handle_mouse(app, *mouse),
Event::Paste(_) | Event::User(_) => false,
}
}
fn handle_prompt_event(app: &mut ReviewApp, event: &Event) -> bool {
match event {
Event::Key(stroke) => handle_prompt_key(app, *stroke),
Event::Paste(text) => {
if let Some(prompt) = &mut app.prompt_state {
prompt.buffer.insert_str(text);
prompt.selected = 0;
return true;
}
false
}
Event::Focus(FocusEvent::Gained | FocusEvent::Lost) | Event::Tick | Event::Resize(_) => {
true
}
Event::Mouse(_) | Event::User(_) => false,
}
}
fn handle_prompt_key(app: &mut ReviewApp, stroke: KeyStroke) -> bool {
if stroke.key == KeyCode::Escape && stroke.modifiers.is_empty() {
app.cancel_prompt();
return true;
}
if stroke.key == KeyCode::Enter && stroke.modifiers.is_empty() {
app.submit_prompt();
return true;
}
if stroke.key == KeyCode::Down && stroke.modifiers.is_empty() {
return app.move_prompt_selection_down();
}
if stroke.key == KeyCode::Up && stroke.modifiers.is_empty() {
return app.move_prompt_selection_up();
}
if let Some(prompt) = &mut app.prompt_state {
let outcome = helpers::handle_default_text_key(
&mut prompt.buffer,
stroke,
TextInputEnterBehavior::Submit,
);
if matches!(outcome, TextInputKeyOutcome::Edited) {
prompt.selected = 0;
}
return matches!(
outcome,
TextInputKeyOutcome::Edited | TextInputKeyOutcome::Submitted
);
}
false
}
fn handle_comment_editor_event(app: &mut ReviewApp, event: &Event) -> bool {
match event {
Event::Key(stroke) => handle_comment_editor_key(app, *stroke),
Event::Paste(text) => {
if let Some(editor) = &mut app.comment_editor {
editor.buffer.insert_str(text);
return true;
}
false
}
Event::Focus(FocusEvent::Gained | FocusEvent::Lost) | Event::Tick | Event::Resize(_) => {
true
}
Event::Mouse(_) | Event::User(_) => false,
}
}
fn handle_comment_editor_key(app: &mut ReviewApp, stroke: KeyStroke) -> bool {
if stroke.key == KeyCode::Escape && stroke.modifiers.is_empty() {
app.comment_editor = None;
app.status_message = Some("comment draft canceled".to_string());
return true;
}
if stroke.key == KeyCode::Char('s') && stroke.modifiers.ctrl {
app.save_comment_editor();
return true;
}
if stroke.key == KeyCode::Char('p') && stroke.modifiers.ctrl {
if let Some(editor) = &mut app.comment_editor {
editor.preview = !editor.preview;
app.status_message = Some(if editor.preview {
"showing Markdown preview".to_string()
} else {
"editing Markdown comment".to_string()
});
}
return true;
}
if stroke.key == KeyCode::Enter && !stroke.modifiers.ctrl && !stroke.modifiers.alt {
app.save_comment_editor();
return true;
}
if matches!(stroke.key, KeyCode::Tab | KeyCode::Right) && stroke.modifiers.is_empty() {
return app.select_next_comment_action();
}
if stroke.key == KeyCode::Left && stroke.modifiers.is_empty() {
return app.select_previous_comment_action();
}
if let Some(editor) = &mut app.comment_editor {
if editor.preview {
return true;
}
return matches!(
helpers::handle_default_text_key(
&mut editor.buffer,
stroke,
TextInputEnterBehavior::InsertNewline,
),
TextInputKeyOutcome::Edited
| TextInputKeyOutcome::Submitted
| TextInputKeyOutcome::Redraw
| TextInputKeyOutcome::EdgeUp
| TextInputKeyOutcome::EdgeDown
);
}
false
}
fn handle_build_key(app: &mut ReviewApp, key: KeyCode) -> Option<bool> {
if app.ux_mode != ReviewUxMode::Build {
return None;
}
Some(match key {
KeyCode::Char('R') => app.rematerialize_workspace(),
KeyCode::Char('+') => app.open_add_file_source_picker(),
KeyCode::Char('A' | 'a') => app.open_add_source_prompt(),
KeyCode::Char('u') => app.add_quick_source(ReviewSourceKind::WorkingTreeUnstaged),
KeyCode::Char('s') => app.add_quick_source(ReviewSourceKind::IndexStaged),
KeyCode::Char('w') => app.add_quick_source(ReviewSourceKind::WorkingTreeAndIndex),
KeyCode::Char('l') => app.add_quick_source(ReviewSourceKind::LastCommit),
KeyCode::Char('g') => app.select_first_build_row(),
KeyCode::Char('G') => app.select_last_build_row(),
KeyCode::Char('I') => app.include_all_sources(),
KeyCode::Char('E') => app.exclude_all_sources(),
KeyCode::Char('V') => app.invert_source_inclusion(),
KeyCode::Char('n') => app.select_next_empty_source(),
KeyCode::Char('N') => app.select_previous_empty_source(),
KeyCode::Char('z') => app.exclude_empty_sources(),
KeyCode::Char('d') => app.select_next_diagnostic_source(),
KeyCode::Char('Z') => app.exclude_sources_with_errors(),
KeyCode::Char('C') => app.open_edit_source_spec_prompt(),
KeyCode::Char('M') => app.toggle_selected_source_merge_base(),
KeyCode::Char('X') => app.remove_excluded_sources(),
KeyCode::Char('P') => app.remove_duplicate_sources(),
KeyCode::Char(' ') => app.toggle_selected_build_source(),
KeyCode::Char('T') => app.open_rename_workspace_prompt(),
KeyCode::Char('r') => app.open_rename_source_prompt(),
KeyCode::Char('[') => app.move_selected_source_up(),
KeyCode::Char(']') => app.move_selected_source_down(),
KeyCode::Char('-') => app.remove_selected_build_source(),
KeyCode::Char('O') => app.open_selected_source_surface(),
KeyCode::Char('Y') => app.select_source_for_current_surface(),
KeyCode::Enter => app.activate_selected_build_row(),
_ => return None,
})
}
fn is_review_exit_key(stroke: KeyStroke) -> bool {
stroke.key == KeyCode::Char('d')
&& stroke.modifiers.ctrl
&& !stroke.modifiers.alt
&& !stroke.modifiers.shift
&& !stroke.modifiers.super_key
&& !stroke.modifiers.hyper
&& !stroke.modifiers.meta
}
fn handle_key(app: &mut ReviewApp, stroke: KeyStroke) -> bool {
if is_review_exit_key(stroke) {
app.should_exit = true;
return true;
}
if stroke.modifiers.ctrl {
if stroke.key == KeyCode::Char('p') {
return app.open_file_picker();
}
return false;
}
if stroke.modifiers.alt
|| stroke.modifiers.super_key
|| stroke.modifiers.hyper
|| stroke.modifiers.meta
{
return false;
}
let key = normalized_shortcut_key(stroke);
if let Some(handled) = handle_build_key(app, key) {
return handled;
}
match key {
KeyCode::Escape => {
app.clear_range_selection()
|| app.clear_selected_view_target()
|| app.expand_all_inline_threads()
}
KeyCode::Char('b') => {
app.sidebar_visible = !app.sidebar_visible;
true
}
KeyCode::Char('B') => app.set_build_mode(),
KeyCode::Char('m') => app.toggle_ux_mode(),
KeyCode::Char('M') => app.select_next_attention_item(),
KeyCode::Char('+') => app.open_add_file_source_picker(),
KeyCode::Char('A') => app.open_add_source_prompt(),
KeyCode::Char('t') => app.toggle_sidebar_mode(),
KeyCode::Char('f') => app.open_file_picker(),
KeyCode::Char(':') => app.open_jump_to_line_prompt(),
KeyCode::Char('/') => app.open_file_search_prompt(),
KeyCode::Char('N') => app.search_previous_match(),
KeyCode::Enter => {
if app.activate_selected_review_target() {
true
} else if app.sidebar_mode == ReviewSidebarMode::Repository
&& app.review.is_repository_review()
{
app.activate_selected_tree_row()
} else {
app.jump_to_selected_thread()
}
}
KeyCode::Char('j') | KeyCode::Down => app.move_down(1),
KeyCode::Char('k') | KeyCode::Up => app.move_up(1),
KeyCode::Char('g') => app.scroll_to_top(),
KeyCode::Char('G') => app.scroll_to_bottom(),
KeyCode::Char('n') => {
if app.has_active_search() {
app.search_next_match()
} else {
app.select_next_file()
}
}
KeyCode::Right => app.expand_selected_tree_row(),
KeyCode::Char('p') => app.select_previous_file(),
KeyCode::Left => app.collapse_selected_tree_row(),
key => handle_review_navigation_key(app, key),
}
}
fn handle_review_navigation_key(app: &mut ReviewApp, key: KeyCode) -> bool {
match key {
KeyCode::Char('}') => app.select_next_inline_draft(),
KeyCode::Char('{') => app.select_previous_inline_draft(),
KeyCode::Char(']') => app.select_next_inline_thread(),
KeyCode::Char('[') => app.select_previous_inline_thread(),
KeyCode::Char('!') => app.show_attention_sidebar(),
KeyCode::Char('T') => app.cycle_thread_filter(),
KeyCode::Char('E') => app.mark_all_files_unviewed(),
KeyCode::Char('V') => app.mark_all_files_viewed(),
KeyCode::Char('I') => app.select_previous_unviewed_file(),
KeyCode::Char('W') => app.select_next_unviewed_file(),
KeyCode::Char('w') => app.toggle_selected_file_viewed(),
KeyCode::Char('u') => app.select_next_open_thread(),
KeyCode::Char('i') => app.select_previous_open_thread(),
KeyCode::Char('O') => app.select_previous_open_thread_global(),
KeyCode::Char('P') => app.select_next_open_thread_global(),
KeyCode::Char('L') => app.select_next_ai_linked_thread(),
KeyCode::Char('B') => app.select_previous_ai_linked_thread(),
KeyCode::Char('R') => app.toggle_show_resolved_threads(),
KeyCode::Char('r') => app.toggle_selected_thread_resolved(),
KeyCode::Char('H') => app.toggle_hide_viewed_files(),
KeyCode::Char('U') => app.expand_all_inline_threads(),
KeyCode::Char('Z') => app.collapse_all_inline_threads(),
KeyCode::Char('J') => app.select_next_hunk(),
KeyCode::Char('K') => app.select_previous_hunk(),
KeyCode::Char('v') => app.toggle_range_selection(),
KeyCode::Char('C') => app.open_review_comment_editor(),
KeyCode::Char('F') => app.open_file_comment_editor(),
KeyCode::Char('c') => app.open_comment_editor(),
KeyCode::Char('e') => app.open_latest_draft_editor(),
KeyCode::Char('D') => app.delete_latest_draft_at_selection(),
KeyCode::Char('d') => app.delete_latest_ai_exchange_at_selection(),
KeyCode::Char('x') => {
if app.activate_selected_inline_action() {
true
} else {
app.publish_review()
}
}
KeyCode::Char('X') => app.import_external_review(),
KeyCode::Char('a') => app.open_comment_editor_with_action(ReviewCommentAction::AskBcode),
KeyCode::Char('A') => app.toggle_selected_agent_answer_expanded(),
KeyCode::Char('Y') => app.queue_copy_agent_answer_at_selection(),
KeyCode::Char('s') => app.suggest_comment_from_agent_answer_at_selection(),
KeyCode::Char(';') => app.accept_suggestion_at_selection(),
KeyCode::Char('f') => app.refine_suggestion_at_selection(),
KeyCode::Char(':') => app.reject_suggestion_at_selection(),
KeyCode::Char('m') => app.convert_agent_answer_to_draft_at_selection(),
KeyCode::Char('y') => app.retry_linked_session_stream_at_selection(),
KeyCode::Char('o') => app.open_linked_session_at_selection(),
KeyCode::Char('1') => app.run_ai_command_at_selection(ReviewAiCommand::ExplainChange),
KeyCode::Char('2') => app.run_ai_command_at_selection(ReviewAiCommand::FindRisk),
KeyCode::Char('3') => app.run_ai_command_at_selection(ReviewAiCommand::SuggestComment),
KeyCode::Char('4') => app.run_ai_command_at_selection(ReviewAiCommand::CheckTests),
KeyCode::Char('5') => app.run_ai_command_at_selection(ReviewAiCommand::SummarizeFile),
KeyCode::Char('?') => {
app.help_visible = !app.help_visible;
true
}
_ => false,
}
}
const fn normalized_shortcut_key(stroke: KeyStroke) -> KeyCode {
if !stroke.modifiers.shift {
return stroke.key;
}
match stroke.key {
KeyCode::Char('=') => KeyCode::Char('+'),
KeyCode::Char('/') => KeyCode::Char('?'),
KeyCode::Char(ch) if ch.is_ascii_lowercase() => KeyCode::Char(ch.to_ascii_uppercase()),
key => key,
}
}
fn handle_mouse(app: &mut ReviewApp, mouse: MouseEvent) -> bool {
match mouse.kind {
MouseEventKind::ScrollUp => {
if app.file_area_contains(mouse.position.x, mouse.position.y) {
if matches!(
app.sidebar_mode,
ReviewSidebarMode::Threads | ReviewSidebarMode::NeedsAttention
) {
app.select_previous_thread(3)
} else {
app.scroll_files_up(3)
}
} else {
app.scroll_up(3)
}
}
MouseEventKind::ScrollDown => {
if app.file_area_contains(mouse.position.x, mouse.position.y) {
if matches!(
app.sidebar_mode,
ReviewSidebarMode::Threads | ReviewSidebarMode::NeedsAttention
) {
app.select_next_thread(3)
} else {
app.scroll_files_down(3)
}
} else {
app.scroll_down(3)
}
}
MouseEventKind::Down(MouseButton::Left) => {
if let Some(action) = app.mouse_action_at(mouse.position.x, mouse.position.y) {
return app.activate_mouse_action(action);
}
app.view_visual_row_at(mouse.position.x, mouse.position.y)
.is_some_and(|visual_row| app.handle_review_view_click(visual_row))
}
MouseEventKind::Drag(MouseButton::Left) => app
.commentable_diff_line_index_at(mouse.position.x, mouse.position.y)
.is_some_and(|index| app.update_mouse_range_selection(index)),
MouseEventKind::Up(MouseButton::Left) => app.finish_mouse_range_selection(),
MouseEventKind::Down(MouseButton::Right | MouseButton::Middle | MouseButton::Other(_))
| MouseEventKind::Up(MouseButton::Right | MouseButton::Middle | MouseButton::Other(_))
| MouseEventKind::Drag(MouseButton::Right | MouseButton::Middle | MouseButton::Other(_))
| MouseEventKind::Move
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewMouseAction {
SidebarMode(ReviewSidebarMode),
ThreadFilter(ReviewThreadFilter),
ShowDraftThreads,
ShowAllThreads,
ShowOpenThreads,
ShowResolvedThreads,
CycleThreadFilter,
SelectThread(usize),
SelectFile(usize),
ToggleFileViewed(usize),
ActivateTreeRow(usize),
JumpSelectedThread,
NewReviewComment,
FileComment,
Publish,
AskBcode,
ReplyThread,
ToggleThreadResolved,
EditThread,
DeleteThread,
OpenThreadSession,
CommentAction(ReviewCommentAction),
CancelComment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReviewMouseRegion {
pub rect: Rect,
pub action: ReviewMouseAction,
pub label: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct ListReviewPublishersResponse {
publishers: Vec<ReviewPublisherManifest>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ReviewPublisherManifest {
/// Publisher id.
pub id: String,
/// Human-readable label.
pub label: String,
/// Human-readable description.
pub description: String,
/// Publisher capabilities.
pub capabilities: ReviewPublisherCapabilities,
/// Publisher option schema.
pub options_schema: serde_json::Value,
/// Optional external plugin route.
#[serde(default)]
pub route: Option<ReviewPublisherRoute>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ReviewPublisherRoute {
/// Plugin id for external publisher.
pub plugin_id: String,
/// Service interface id.
pub interface_id: String,
}
impl ReviewPublisherManifest {
/// Return compact capability labels.
#[must_use]
pub fn capability_labels(&self) -> Vec<&'static str> {
let mut labels = Vec::new();
if self.capabilities.preview {
labels.push("preview");
}
if self.capabilities.supports_threads {
labels.push("threads");
}
if self.capabilities.supports_ranges {
labels.push("ranges");
}
if self.capabilities.supports_inline_comments {
labels.push("inline");
}
labels
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct ReviewPublisherCapabilities {
preview: bool,
submit: bool,
update_existing: bool,
supports_threads: bool,
supports_ranges: bool,
supports_inline_comments: bool,
supports_summary_comment: bool,
}
fn relative_index(current_index: Option<usize>, len: usize, offset: isize) -> usize {
let Some(max_index) = len.checked_sub(1) else {
return 0;
};
let start = current_index.unwrap_or_else(|| if offset.is_negative() { len } else { 0 });
if offset.is_negative() {
start.saturating_sub(offset.unsigned_abs()).min(max_index)
} else {
start.saturating_add(offset.unsigned_abs()).min(max_index)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct PublishReviewPreviewResponse {
publisher_id: String,
preview: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ReviewBundleRequest {
repo_path: PathBuf,
target: ReviewOpenTarget,
#[serde(default)]
workspace: Option<ReviewWorkspace>,
#[serde(default)]
excluded_thread_anchors: Vec<ModelDraftAnchor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct PublishReviewRequest {
repo_path: PathBuf,
target: ReviewOpenTarget,
#[serde(default)]
workspace: Option<ReviewWorkspace>,
#[serde(default)]
excluded_thread_anchors: Vec<ModelDraftAnchor>,
publisher_id: String,
#[serde(default)]
options: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct PublishReviewResponse {
publisher_id: String,
submitted: bool,
output: Option<String>,
message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct CreateReviewRequest {
repo_path: PathBuf,
target: bcode_code_review_models::ReviewTarget,
}
fn target_from_workspace(workspace: &ReviewWorkspace) -> ReviewOpenTarget {
workspace
.sources
.iter()
.find(|source| source.included)
.map_or(ReviewOpenTarget::Repository, |source| {
target_from_source_kind(&source.kind)
})
}
fn target_from_source_kind(kind: &ReviewSourceKind) -> ReviewOpenTarget {
match kind {
ReviewSourceKind::WorkingTreeUnstaged => ReviewOpenTarget::WorkingTreeUnstaged,
ReviewSourceKind::IndexStaged => ReviewOpenTarget::IndexStaged,
ReviewSourceKind::WorkingTreeAndIndex => ReviewOpenTarget::WorkingTreeAndIndex,
ReviewSourceKind::LastCommit => ReviewOpenTarget::LastCommit,
ReviewSourceKind::CommitRange {
base,
head,
merge_base,
} => ReviewOpenTarget::CommitRange {
base: base.clone(),
head: head.clone(),
merge_base: *merge_base,
},
ReviewSourceKind::BranchCompare {
base_branch,
head_branch,
merge_base,
} => ReviewOpenTarget::BranchCompare {
base_branch: base_branch.clone(),
head_branch: head_branch.clone(),
merge_base: *merge_base,
},
ReviewSourceKind::Commit { rev } => ReviewOpenTarget::CommitRange {
base: format!("{rev}^"),
head: rev.clone(),
merge_base: false,
},
ReviewSourceKind::File { .. }
| ReviewSourceKind::FileRange { .. }
| ReviewSourceKind::Repository => ReviewOpenTarget::Repository,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ListDraftsRequest {
repo_path: PathBuf,
target: ModelReviewTarget,
#[serde(default)]
scope: Option<ModelReviewScope>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct SaveDraftRequest {
repo_path: PathBuf,
target: ModelReviewTarget,
#[serde(default)]
scope: Option<ModelReviewScope>,
anchor: DraftAnchor,
body: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct DeleteDraftRequest {
repo_path: PathBuf,
target: ModelReviewTarget,
#[serde(default)]
scope: Option<ModelReviewScope>,
comment_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct DeleteDraftResponse {
deleted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct UpdateDraftRequest {
repo_path: PathBuf,
target: ModelReviewTarget,
#[serde(default)]
scope: Option<ModelReviewScope>,
comment_id: String,
body: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct UpdateDraftResponse {
updated: bool,
updated_at_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct LinkThreadSessionRequest {
repo_path: PathBuf,
target: ModelReviewTarget,
#[serde(default)]
scope: Option<ModelReviewScope>,
anchor: DraftAnchor,
session_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct LinkThreadSessionResponse {
thread_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ResolveThreadRequest {
repo_path: PathBuf,
target: ModelReviewTarget,
#[serde(default)]
scope: Option<ModelReviewScope>,
anchor: DraftAnchor,
resolved: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct ResolveThreadResponse {
thread_id: String,
resolved_at_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct DraftAnchor {
#[serde(default)]
kind: ReviewAnchorKind,
file_path: String,
diff_row: u64,
old_line: Option<u32>,
start_diff_row: Option<u64>,
end_diff_row: Option<u64>,
old_start: Option<u32>,
old_end: Option<u32>,
new_start: Option<u32>,
new_end: Option<u32>,
new_line: Option<u32>,
line_kind: ReviewLineKind,
#[serde(default)]
is_file_anchor: bool,
/// Surface id for normalized mixed-surface anchors.
#[serde(default)]
surface_id: Option<String>,
/// Source id for normalized mixed-surface anchors.
#[serde(default)]
source_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct DraftComment {
comment_id: String,
thread_id: String,
anchor: DraftAnchor,
body: String,
created_at_ms: u64,
updated_at_ms: u64,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
resolved_at_ms: Option<u64>,
#[serde(default)]
thread_kind: ReviewThreadKind,
#[serde(default)]
severity: ReviewThreadSeverity,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct ListDraftsResponse {
drafts: Vec<DraftComment>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct SaveDraftResponse {
draft: DraftComment,
}
impl From<ReviewCommentAnchor> for ModelDraftAnchor {
fn from(anchor: ReviewCommentAnchor) -> Self {
let local: DraftAnchor = anchor.into();
Self {
kind: local.kind,
file_path: local.file_path,
diff_row: local.diff_row,
start_diff_row: local.start_diff_row,
end_diff_row: local.end_diff_row,
old_start: local.old_start,
old_end: local.old_end,
new_start: local.new_start,
new_end: local.new_end,
old_line: local.old_line,
new_line: local.new_line,
line_kind: local.line_kind.to_model(),
is_file_anchor: local.is_file_anchor,
surface_id: local.surface_id,
source_id: local.source_id,
}
}
}
impl From<ModelDraftAnchor> for DraftAnchor {
fn from(anchor: ModelDraftAnchor) -> Self {
Self {
kind: anchor.kind,
file_path: anchor.file_path,
diff_row: anchor.diff_row,
start_diff_row: anchor.start_diff_row,
end_diff_row: anchor.end_diff_row,
old_start: anchor.old_start,
old_end: anchor.old_end,
new_start: anchor.new_start,
new_end: anchor.new_end,
old_line: anchor.old_line,
new_line: anchor.new_line,
line_kind: ReviewLineKind::from_model(anchor.line_kind),
is_file_anchor: anchor.is_file_anchor,
surface_id: anchor.surface_id,
source_id: anchor.source_id,
}
}
}
impl From<ReviewCommentAnchor> for DraftAnchor {
fn from(anchor: ReviewCommentAnchor) -> Self {
Self {
kind: if anchor.path == REVIEW_LEVEL_ANCHOR_PATH {
ReviewAnchorKind::Review
} else if anchor.is_file_anchor {
ReviewAnchorKind::File
} else {
ReviewAnchorKind::Range
},
file_path: anchor.path.clone(),
diff_row: u64::try_from(anchor.diff_row).unwrap_or(u64::MAX),
start_diff_row: Some(u64::try_from(anchor.start_diff_row()).unwrap_or(u64::MAX)),
end_diff_row: Some(u64::try_from(anchor.end_diff_row()).unwrap_or(u64::MAX)),
old_start: anchor.old_start,
old_end: anchor.old_end,
new_start: anchor.new_start,
new_end: anchor.new_end,
old_line: anchor.old_line,
new_line: anchor.new_line,
line_kind: anchor.line_kind,
is_file_anchor: anchor.is_file_anchor,
surface_id: anchor.surface_id,
source_id: anchor.source_id,
}
}
}
pub(crate) const fn review_thread_kind_label(kind: ReviewThreadKind) -> &'static str {
match kind {
ReviewThreadKind::Note => "note",
ReviewThreadKind::Question => "question",
ReviewThreadKind::Todo => "todo",
ReviewThreadKind::Finding => "finding",
ReviewThreadKind::Summary => "summary",
}
}
pub(crate) const fn review_thread_severity_label(severity: ReviewThreadSeverity) -> &'static str {
match severity {
ReviewThreadSeverity::Info => "info",
ReviewThreadSeverity::Nit => "nit",
ReviewThreadSeverity::Warning => "warning",
ReviewThreadSeverity::Blocker => "blocker",
}
}
/// Review interaction mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewUxMode {
/// Build the review by adding/removing sources.
Build,
/// Review/comment/publish included and context files.
Review,
}
/// Full review summary displayed by the TUI.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ReviewSummary {
/// Human-readable review title.
pub title: String,
/// Git repository root.
pub repo_root: PathBuf,
/// Changed files.
pub files: Vec<ReviewFile>,
/// Total additions.
pub additions: u32,
/// Total deletions.
pub deletions: u32,
/// Workspace that owns this review session.
#[serde(default)]
pub workspace: Option<ReviewWorkspace>,
/// Materialized review surfaces corresponding to files.
#[serde(default)]
pub surfaces: Vec<ReviewSurface>,
/// Materialization diagnostics from workspace sources.
#[serde(default)]
pub diagnostics: Vec<ReviewSourceDiagnostic>,
/// Repository file paths available for source/file pickers.
#[serde(default)]
pub repository_files: Vec<String>,
/// Repository branch names available for source pickers.
#[serde(default)]
pub repository_branches: Vec<String>,
/// Recent repository commits available for source pickers.
#[serde(default)]
pub repository_commits: Vec<ReviewRepositoryCommit>,
}
impl ReviewSummary {
/// Return true when this review is browsing repository files instead of a diff.
#[must_use]
pub fn is_repository_review(&self) -> bool {
self.title == "Repository Review"
|| self.workspace.as_ref().is_some_and(|workspace| {
workspace.sources.iter().any(|source| {
source.included && matches!(source.kind, ReviewSourceKind::Repository)
})
})
}
/// Return workspace, creating a transient workspace for legacy single-target reviews.
#[must_use]
pub fn workspace(&self) -> ReviewWorkspace {
self.workspace.clone().unwrap_or_else(|| {
let target = if self.is_repository_review() {
ReviewOpenTarget::Repository
} else {
ReviewOpenTarget::WorkingTreeAndIndex
};
ReviewWorkspace::from_target(self.repo_root.clone(), target)
})
}
/// Return a compact source label for a review file/surface.
#[must_use]
pub fn file_source_label(&self, file_index: usize) -> Option<String> {
let surface = self.surfaces().get(file_index)?.clone();
let source = self
.workspace
.as_ref()?
.sources
.iter()
.find(|source| source.id == surface.source_id)?;
Some(source_kind_short_label_for_ui(&source.kind).to_string())
}
/// Return normalized surfaces visible for this review.
#[must_use]
pub fn surfaces(&self) -> Vec<ReviewSurface> {
if !self.surfaces.is_empty() {
return self.surfaces.clone();
}
self.files
.iter()
.enumerate()
.map(|(index, file)| ReviewSurface {
id: format!("surface-{index}"),
source_id: "source-1".to_string(),
path: file.display_path().to_string(),
kind: if self.is_repository_review() {
ReviewSurfaceKind::File
} else {
ReviewSurfaceKind::Diff
},
file: None,
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RepositoryFileResponse {
file_path: String,
content: Option<String>,
size_bytes: u64,
#[serde(default)]
mtime_ms: Option<u64>,
is_binary: bool,
#[serde(default)]
unavailable_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CachedReviewFile {
pub path: String,
pub content: String,
pub line_spans: Vec<(usize, usize)>,
pub size_bytes: u64,
pub mtime_ms: Option<u64>,
pub is_binary: bool,
pub unavailable_reason: Option<String>,
}
impl CachedReviewFile {
#[must_use]
fn from_response(response: RepositoryFileResponse) -> Self {
let content = response.content.unwrap_or_default();
let line_spans = line_spans(&content);
Self {
path: response.file_path,
content,
line_spans,
size_bytes: response.size_bytes,
mtime_ms: response.mtime_ms,
is_binary: response.is_binary,
unavailable_reason: response.unavailable_reason,
}
}
#[must_use]
pub fn line(&self, index: usize) -> Option<&str> {
let (start, end) = *self.line_spans.get(index)?;
self.content.get(start..end)
}
}
fn line_spans(content: &str) -> Vec<(usize, usize)> {
if content.is_empty() {
return Vec::new();
}
let mut spans = Vec::new();
let mut start = 0;
for line in content.split_inclusive('\n') {
let end = start + line.trim_end_matches('\n').trim_end_matches('\r').len();
spans.push((start, end));
start += line.len();
}
if !content.ends_with('\n') && spans.is_empty() {
spans.push((0, content.len()));
}
spans
}
#[derive(Debug, Clone, Default)]
pub struct ReviewFileCache {
entries: BTreeMap<String, CachedReviewFile>,
lru: Vec<String>,
total_bytes: usize,
}
impl ReviewFileCache {
const MAX_FILES: usize = 128;
const MAX_BYTES: usize = 32 * 1024 * 1024;
#[must_use]
pub fn get(&self, path: &str) -> Option<&CachedReviewFile> {
self.entries.get(path)
}
pub fn insert(&mut self, file: CachedReviewFile) {
let path = file.path.clone();
if let Some(existing) = self.entries.remove(&path) {
self.total_bytes = self.total_bytes.saturating_sub(existing.content.len());
}
self.total_bytes = self.total_bytes.saturating_add(file.content.len());
self.entries.insert(path.clone(), file);
self.lru.retain(|entry| entry != &path);
self.lru.push(path);
self.evict();
}
pub fn remove(&mut self, path: &str) {
if let Some(existing) = self.entries.remove(path) {
self.total_bytes = self.total_bytes.saturating_sub(existing.content.len());
}
self.lru.retain(|entry| entry != path);
}
fn evict(&mut self) {
while self.entries.len() > Self::MAX_FILES || self.total_bytes > Self::MAX_BYTES {
let Some(path) = self.lru.first().cloned() else {
break;
};
self.lru.remove(0);
if let Some(existing) = self.entries.remove(&path) {
self.total_bytes = self.total_bytes.saturating_sub(existing.content.len());
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchDirection {
Next,
Previous,
}
fn find_file_match(
file: &CachedReviewFile,
query: &str,
selected_line: usize,
direction: SearchDirection,
) -> Option<usize> {
let len = file.line_spans.len();
if len == 0 {
return None;
}
match direction {
SearchDirection::Next => {
let start = selected_line.saturating_add(1).min(len);
(start..len)
.chain(0..start)
.find(|index| file.line(*index).is_some_and(|line| line.contains(query)))
}
SearchDirection::Previous => {
let start = selected_line.min(len.saturating_sub(1));
(0..=start)
.rev()
.chain((start.saturating_add(1)..len).rev())
.find(|index| file.line(*index).is_some_and(|line| line.contains(query)))
}
}
}
/// Sidebar file-tree row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewFileTreeRow {
/// Directory row.
Directory {
/// Directory path.
path: PathBuf,
/// Nesting depth.
depth: usize,
},
/// File row.
File {
/// File index in review files.
index: usize,
/// Nesting depth.
depth: usize,
},
}
/// Changed file displayed by the TUI.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ReviewFile {
/// Old path.
pub old_path: Option<String>,
/// New path.
pub new_path: Option<String>,
/// File status.
pub status: ReviewFileStatus,
/// Additions.
pub additions: u32,
/// Deletions.
pub deletions: u32,
/// Hunks.
pub hunks: Vec<ReviewHunk>,
/// Binary marker.
pub is_binary: bool,
}
impl ReviewFile {
fn from_model(file: bcode_code_review_models::ReviewFile) -> Self {
Self {
old_path: file.old_path,
new_path: file.new_path,
status: ReviewFileStatus::from_model(file.status),
additions: file.additions,
deletions: file.deletions,
hunks: file.hunks.into_iter().map(ReviewHunk::from_model).collect(),
is_binary: file.is_binary,
}
}
fn to_model(&self) -> bcode_code_review_models::ReviewFile {
bcode_code_review_models::ReviewFile {
old_path: self.old_path.clone(),
new_path: self.new_path.clone(),
status: self.status.to_model(),
additions: self.additions,
deletions: self.deletions,
hunks: self.hunks.iter().map(ReviewHunk::to_model).collect(),
is_binary: self.is_binary,
}
}
/// Return the display path.
#[must_use]
pub fn display_path(&self) -> &str {
self.new_path
.as_deref()
.or(self.old_path.as_deref())
.unwrap_or("<unknown>")
}
}
/// Review file status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewFileStatus {
/// Modified file.
Modified,
/// Added file.
Added,
/// Deleted file.
Deleted,
/// Renamed file.
Renamed,
/// Copied file.
Copied,
/// Unknown status.
Unknown,
}
impl ReviewFileStatus {
const fn to_model(self) -> bcode_code_review_models::ReviewFileStatus {
match self {
Self::Modified => bcode_code_review_models::ReviewFileStatus::Modified,
Self::Added => bcode_code_review_models::ReviewFileStatus::Added,
Self::Deleted => bcode_code_review_models::ReviewFileStatus::Deleted,
Self::Renamed => bcode_code_review_models::ReviewFileStatus::Renamed,
Self::Copied => bcode_code_review_models::ReviewFileStatus::Copied,
Self::Unknown => bcode_code_review_models::ReviewFileStatus::Unknown,
}
}
const fn from_model(status: bcode_code_review_models::ReviewFileStatus) -> Self {
match status {
bcode_code_review_models::ReviewFileStatus::Modified => Self::Modified,
bcode_code_review_models::ReviewFileStatus::Added => Self::Added,
bcode_code_review_models::ReviewFileStatus::Deleted => Self::Deleted,
bcode_code_review_models::ReviewFileStatus::Renamed => Self::Renamed,
bcode_code_review_models::ReviewFileStatus::Copied => Self::Copied,
bcode_code_review_models::ReviewFileStatus::Unknown => Self::Unknown,
}
}
/// Return a compact status label.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Modified => "M",
Self::Added => "A",
Self::Deleted => "D",
Self::Renamed => "R",
Self::Copied => "C",
Self::Unknown => "?",
}
}
}
/// Review hunk.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ReviewHunk {
/// Old start line.
pub old_start: u32,
/// Old line count.
pub old_count: u32,
/// New start line.
pub new_start: u32,
/// New line count.
pub new_count: u32,
/// Optional heading.
pub heading: Option<String>,
/// Lines.
pub lines: Vec<ReviewLine>,
}
impl ReviewHunk {
fn from_model(hunk: bcode_code_review_models::ReviewHunk) -> Self {
Self {
old_start: hunk.old_start,
old_count: hunk.old_count,
new_start: hunk.new_start,
new_count: hunk.new_count,
heading: hunk.heading,
lines: hunk.lines.into_iter().map(ReviewLine::from_model).collect(),
}
}
fn to_model(&self) -> bcode_code_review_models::ReviewHunk {
bcode_code_review_models::ReviewHunk {
old_start: self.old_start,
old_count: self.old_count,
new_start: self.new_start,
new_count: self.new_count,
heading: self.heading.clone(),
lines: self.lines.iter().map(ReviewLine::to_model).collect(),
}
}
}
/// Review diff line.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ReviewLine {
/// Line kind.
pub kind: ReviewLineKind,
/// Old line number.
pub old_line: Option<u32>,
/// New line number.
pub new_line: Option<u32>,
/// Content without diff marker.
pub content: String,
}
impl ReviewLine {
fn from_model(line: bcode_code_review_models::ReviewLine) -> Self {
Self {
kind: ReviewLineKind::from_model(line.kind),
old_line: line.old_line,
new_line: line.new_line,
content: line.content,
}
}
fn to_model(&self) -> bcode_code_review_models::ReviewLine {
bcode_code_review_models::ReviewLine {
kind: self.kind.to_model(),
old_line: self.old_line,
new_line: self.new_line,
content: self.content.clone(),
}
}
}
/// Review diff line kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewLineKind {
/// Context line.
Context,
/// Added line.
Added,
/// Removed line.
Removed,
}
impl ReviewLineKind {
const fn to_model(self) -> bcode_code_review_models::ReviewLineKind {
match self {
Self::Context => bcode_code_review_models::ReviewLineKind::Context,
Self::Added => bcode_code_review_models::ReviewLineKind::Added,
Self::Removed => bcode_code_review_models::ReviewLineKind::Removed,
}
}
const fn from_model(kind: bcode_code_review_models::ReviewLineKind) -> Self {
match kind {
bcode_code_review_models::ReviewLineKind::Context => Self::Context,
bcode_code_review_models::ReviewLineKind::Added => Self::Added,
bcode_code_review_models::ReviewLineKind::Removed => Self::Removed,
}
}
}
const REVIEW_LEVEL_ANCHOR_PATH: &str = "<review>";
/// Draft comment line anchor.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ReviewCommentAnchor {
/// File index in the current review.
pub file_index: usize,
/// Display path for the commented file.
pub path: String,
/// Rendered diff row in the selected file.
pub diff_row: usize,
/// Rendered diff range end row in the selected file.
pub end_diff_row: Option<usize>,
/// Old line number, when present.
pub old_line: Option<u32>,
/// New line number, when present.
pub new_line: Option<u32>,
/// Old range start line, when present.
pub old_start: Option<u32>,
/// Old range end line, when present.
pub old_end: Option<u32>,
/// New range start line, when present.
pub new_start: Option<u32>,
/// New range end line, when present.
pub new_end: Option<u32>,
/// Anchored diff line kind.
pub line_kind: ReviewLineKind,
/// Whether this comment points at a file surface line rather than a diff row.
pub is_file_anchor: bool,
/// Surface id for normalized mixed-surface anchors.
pub surface_id: Option<String>,
/// Source id for normalized mixed-surface anchors.
pub source_id: Option<String>,
}
impl ReviewCommentAnchor {
const fn file(path: String) -> Self {
Self {
file_index: 0,
path,
diff_row: 0,
end_diff_row: None,
old_line: None,
new_line: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: true,
surface_id: None,
source_id: None,
}
}
fn from_draft_anchor(anchor: &DraftAnchor) -> Self {
Self {
file_index: 0,
path: anchor.file_path.clone(),
diff_row: usize::try_from(anchor.diff_row).unwrap_or(usize::MAX),
end_diff_row: anchor
.end_diff_row
.and_then(|row| usize::try_from(row).ok()),
old_line: anchor.old_line,
new_line: anchor.new_line,
old_start: anchor.old_start,
old_end: anchor.old_end,
new_start: anchor.new_start,
new_end: anchor.new_end,
line_kind: anchor.line_kind,
is_file_anchor: anchor.is_file_anchor,
surface_id: anchor.surface_id.clone(),
source_id: anchor.source_id.clone(),
}
}
/// Return this anchor's review scope kind.
#[must_use]
pub fn kind(&self) -> ReviewAnchorKind {
if self.path == REVIEW_LEVEL_ANCHOR_PATH {
ReviewAnchorKind::Review
} else if self.is_file_anchor {
ReviewAnchorKind::File
} else {
ReviewAnchorKind::Range
}
}
/// Return true when this anchor applies to the entire review.
#[must_use]
pub fn is_review_level(&self) -> bool {
self.kind() == ReviewAnchorKind::Review
}
/// Return a display label for this anchor's path/scope.
#[must_use]
pub fn scope_label(&self) -> &str {
if self.is_review_level() {
"Review"
} else {
self.path.as_str()
}
}
/// Return the first rendered diff row for this anchor.
#[must_use]
pub const fn start_diff_row(&self) -> usize {
self.diff_row
}
/// Return the final rendered diff row for this anchor.
#[must_use]
pub const fn end_diff_row(&self) -> usize {
match self.end_diff_row {
Some(row) => row,
None => self.diff_row,
}
}
}
/// Suggested review comment lifecycle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewSuggestionStatus {
/// Suggested but not yet accepted or rejected.
Suggested,
/// Suggestion is being refined through the linked Bcode session.
Refining,
/// Accepted into a normal draft comment.
Accepted,
/// Rejected by the reviewer.
Rejected,
}
impl From<ReviewSuggestionStatus> for ModelReviewSuggestionStatus {
fn from(status: ReviewSuggestionStatus) -> Self {
match status {
ReviewSuggestionStatus::Suggested => Self::Suggested,
ReviewSuggestionStatus::Refining => Self::Refining,
ReviewSuggestionStatus::Accepted => Self::Accepted,
ReviewSuggestionStatus::Rejected => Self::Rejected,
}
}
}
impl From<ModelReviewSuggestionStatus> for ReviewSuggestionStatus {
fn from(status: ModelReviewSuggestionStatus) -> Self {
match status {
ModelReviewSuggestionStatus::Suggested => Self::Suggested,
ModelReviewSuggestionStatus::Refining => Self::Refining,
ModelReviewSuggestionStatus::Accepted => Self::Accepted,
ModelReviewSuggestionStatus::Rejected => Self::Rejected,
}
}
}
/// Local AI-suggested review comment.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewSuggestedComment {
/// Local suggestion id.
pub id: String,
/// Suggested comment body.
pub body: String,
/// Linked Bcode session id that produced the suggestion.
pub session_id: Option<String>,
/// Suggestion lifecycle status.
pub status: ReviewSuggestionStatus,
/// Optional short rationale or provenance.
pub rationale: Option<String>,
/// Creation timestamp in milliseconds since Unix epoch, when persisted.
pub created_at_ms: Option<u64>,
/// Last update timestamp in milliseconds since Unix epoch, when persisted.
pub updated_at_ms: Option<u64>,
}
/// Review draft comment metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewDraftComment {
/// Persisted comment id, when saved in the plugin database.
pub id: Option<String>,
/// Draft body.
pub body: String,
/// Whether the draft is known to be persisted.
pub persisted: bool,
/// Creation timestamp in milliseconds since Unix epoch.
pub created_at_ms: Option<u64>,
/// Last update timestamp in milliseconds since Unix epoch.
pub updated_at_ms: Option<u64>,
/// Linked Bcode session id.
pub session_id: Option<String>,
/// Thread kind.
pub thread_kind: ReviewThreadKind,
/// Thread severity.
pub severity: ReviewThreadSeverity,
}
/// Local review thread lifecycle state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalReviewThreadStatus {
/// Thread still needs review attention.
Open,
/// Thread has been resolved locally.
Resolved,
}
impl LocalReviewThreadStatus {
/// Return true when the thread is resolved.
#[must_use]
pub const fn is_resolved(self) -> bool {
matches!(self, Self::Resolved)
}
}
/// Durable local review comment.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalReviewComment {
/// Persisted comment id, when saved in the plugin database.
pub id: Option<String>,
/// Comment body.
pub body: String,
/// Whether the comment is known to be persisted.
pub persisted: bool,
/// Creation timestamp in milliseconds since Unix epoch.
pub created_at_ms: Option<u64>,
/// Last update timestamp in milliseconds since Unix epoch.
pub updated_at_ms: Option<u64>,
/// Linked Bcode session id, when this comment is associated with one.
pub session_id: Option<String>,
/// Thread kind.
pub thread_kind: ReviewThreadKind,
/// Thread severity.
pub severity: ReviewThreadSeverity,
}
impl From<ReviewDraftComment> for LocalReviewComment {
fn from(comment: ReviewDraftComment) -> Self {
Self {
id: comment.id,
body: comment.body,
persisted: comment.persisted,
created_at_ms: comment.created_at_ms,
updated_at_ms: comment.updated_at_ms,
session_id: comment.session_id,
thread_kind: comment.thread_kind,
severity: comment.severity,
}
}
}
/// Durable local review thread anchored to review content.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalReviewThread {
/// Stable local thread key derived from the anchor.
pub key: String,
/// Thread anchor.
pub anchor: ReviewCommentAnchor,
/// Thread comments.
pub comments: Vec<LocalReviewComment>,
/// Local lifecycle state.
pub status: LocalReviewThreadStatus,
/// Linked Bcode session id, when present.
pub session_id: Option<String>,
/// Thread kind.
pub thread_kind: ReviewThreadKind,
/// Thread severity.
pub severity: ReviewThreadSeverity,
}
impl LocalReviewThread {
/// Return true when this thread still needs review attention.
#[must_use]
pub const fn is_open(&self) -> bool {
matches!(self.status, LocalReviewThreadStatus::Open)
}
/// Return a compact line label for the thread anchor.
#[must_use]
pub fn line_label(&self) -> String {
if self.anchor.is_review_level() {
return "review".to_string();
}
self.anchor.new_start.or(self.anchor.old_start).map_or_else(
|| format!("@{}", self.anchor.diff_row),
|line| format!("+{line}"),
)
}
/// Return latest comment body, if any.
#[must_use]
pub fn latest_body(&self) -> Option<&str> {
self.comments.last().map(|comment| comment.body.as_str())
}
}
const fn source_kind_short_label_for_ui(kind: &ReviewSourceKind) -> &'static str {
match kind {
ReviewSourceKind::WorkingTreeUnstaged => "unstaged",
ReviewSourceKind::IndexStaged => "staged",
ReviewSourceKind::WorkingTreeAndIndex => "worktree",
ReviewSourceKind::LastCommit => "last",
ReviewSourceKind::Commit { .. } => "commit",
ReviewSourceKind::CommitRange { .. } => "range",
ReviewSourceKind::BranchCompare { .. } => "branch",
ReviewSourceKind::File { .. } => "file",
ReviewSourceKind::FileRange { .. } => "file-range",
ReviewSourceKind::Repository => "repo",
}
}
fn bounded_lines(text: &str, max_lines: usize) -> String {
let mut lines = text.lines();
let bounded = lines
.by_ref()
.take(max_lines)
.collect::<Vec<_>>()
.join("\n");
if lines.next().is_some() {
format!("{bounded}\n… (context truncated)")
} else {
bounded
}
}
fn truncate_chars(mut text: String, max_chars: usize) -> String {
const MARKER: &str = "\n… (context truncated)";
if text.chars().count() <= max_chars {
return text;
}
let keep_chars = max_chars.saturating_sub(MARKER.chars().count());
let boundary = text
.char_indices()
.nth(keep_chars)
.map_or(text.len(), |(index, _)| index);
text.truncate(boundary);
text.push_str(MARKER);
text
}
fn parse_range_spec(text: &str) -> Option<(&str, &str, bool)> {
text.split_once("...")
.map(|(base, head)| (base, head, true))
.or_else(|| {
text.split_once("..")
.map(|(base, head)| (base, head, false))
})
}
fn parse_file_range_spec(text: &str) -> Option<(String, u32, u32)> {
let (path, range) = text.rsplit_once(':')?;
let (start, end) = range.split_once('-')?;
let (Ok(start), Ok(end)) = (start.parse::<u32>(), end.parse::<u32>()) else {
return None;
};
let path = path.trim();
if path.is_empty() || start == 0 || end == 0 || start > end {
return None;
}
Some((path.to_string(), start, end))
}
fn edit_source_prompt_value(kind: &ReviewSourceKind) -> Option<(EditSourceTargetKind, String)> {
match kind {
ReviewSourceKind::Commit { rev } => Some((EditSourceTargetKind::Commit, rev.clone())),
ReviewSourceKind::CommitRange {
base,
head,
merge_base,
} => {
let separator = if *merge_base { "..." } else { ".." };
Some((
EditSourceTargetKind::CommitRange,
format!("{base}{separator}{head}"),
))
}
ReviewSourceKind::BranchCompare {
base_branch,
head_branch,
merge_base,
} => {
let separator = if *merge_base { "..." } else { ".." };
Some((
EditSourceTargetKind::BranchCompare,
format!("{base_branch}{separator}{head_branch}"),
))
}
ReviewSourceKind::File { path } => Some((EditSourceTargetKind::File, path.clone())),
ReviewSourceKind::FileRange { path, start, end } => Some((
EditSourceTargetKind::FileRange,
format!("{path}:{start}-{end}"),
)),
ReviewSourceKind::WorkingTreeUnstaged
| ReviewSourceKind::IndexStaged
| ReviewSourceKind::WorkingTreeAndIndex
| ReviewSourceKind::LastCommit
| ReviewSourceKind::Repository => None,
}
}
/// Pending draft comment persistence request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingDraftSave {
/// Saved anchor.
pub anchor: ReviewCommentAnchor,
/// Saved body.
pub body: String,
}
/// Pending draft comment delete request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingDraftDelete {
/// Review target for persistence scope.
pub target: ModelReviewTarget,
/// Review scope for persistence.
pub scope: Option<ModelReviewScope>,
/// Deleted anchor.
pub anchor: ReviewCommentAnchor,
/// Deleted comment.
pub comment: ReviewDraftComment,
}
/// Pending draft comment update request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingDraftUpdate {
/// Review target for persistence scope.
pub target: ModelReviewTarget,
/// Review scope for persistence.
pub scope: Option<ModelReviewScope>,
/// Edited anchor.
pub anchor: ReviewCommentAnchor,
/// Persisted comment id.
pub comment_id: String,
/// Previous body for failure restore.
pub previous_body: String,
/// New body.
pub new_body: String,
}
/// Pending thread resolution persistence request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingThreadResolve {
/// Review target for persistence scope.
pub target: ModelReviewTarget,
/// Review scope for persistence.
pub scope: Option<ModelReviewScope>,
/// Thread anchor.
pub anchor: ReviewCommentAnchor,
/// Whether the thread should be resolved.
pub resolved: bool,
}
/// Structured AI review action.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewAiCommand {
/// General free-form review analysis.
Analyze,
/// Explain the selected change.
ExplainChange,
/// Identify concrete review risks.
FindRisk,
/// Produce a concise review comment suggestion.
SuggestComment,
/// Evaluate test coverage and missing tests.
CheckTests,
/// Summarize the selected file/change.
SummarizeFile,
}
impl ReviewAiCommand {
const fn instruction(self) -> &'static str {
match self {
Self::Analyze => "Analyze this review thread and answer the reviewer's question.",
Self::ExplainChange => {
"Explain what the selected change does, why it may exist, and any important behavior changes."
}
Self::FindRisk => {
"Identify concrete correctness, security, performance, and maintainability risks in the selected change."
}
Self::SuggestComment => {
"Draft one concise, actionable code review comment for the strongest supported issue; say when no comment is warranted."
}
Self::CheckTests => {
"Assess test coverage for the selected change and identify specific missing or weak tests."
}
Self::SummarizeFile => {
"Summarize the selected file's changes, intent, and review-relevant consequences."
}
}
}
const fn label(self) -> &'static str {
match self {
Self::Analyze => "analyze",
Self::ExplainChange => "explain change",
Self::FindRisk => "find risk",
Self::SuggestComment => "suggest comment",
Self::CheckTests => "check tests",
Self::SummarizeFile => "summarize file",
}
}
}
/// Pending Bcode agent session request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingAgentSession {
/// Stable durable exchange id.
pub exchange_id: String,
/// Thread anchor.
pub anchor: ReviewCommentAnchor,
/// Optional selected draft body.
pub draft_body: Option<String>,
/// Structured review action for the request.
pub command: ReviewAiCommand,
}
/// Lifecycle phase for a linked Bcode review thread.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewAgentThreadPhase {
/// Question was queued locally but no session work has started yet.
Pending,
/// A Bcode session is being created.
CreatingSession,
/// Prompt was sent and the assistant may be working.
Running,
/// Assistant produced a completed answer.
Complete,
/// Session creation, follow-up, or history loading failed.
Failed,
}
impl ReviewAgentThreadPhase {
/// Return a user-facing phase label.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::CreatingSession => "creating session",
Self::Running => "running",
Self::Complete => "complete",
Self::Failed => "failed",
}
}
}
/// Inline Bcode state for a review thread.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewAgentThreadState {
/// Current lifecycle phase.
pub phase: ReviewAgentThreadPhase,
/// Linked Bcode session id, when known.
pub session_id: Option<String>,
/// User question that started or continued the session.
pub question: String,
/// Compact metadata describing review context sent to Bcode.
pub context_summary: String,
/// Latest short status message.
pub status: String,
/// Latest assistant answer preview.
pub answer: String,
/// Ordered bounded semantic mini-session rows.
pub session_items: Vec<ReviewAgentSessionItem>,
/// Stable revision of the authoritative semantic session snapshot.
pub session_revision: ViewRevision,
/// Latest stream warning, such as dropped live updates.
pub stream_warning: Option<String>,
/// Latest compact tool/progress activity.
pub activity: Option<String>,
/// Last failure message.
pub error: Option<String>,
}
impl ReviewAgentThreadState {
/// Create pending Bcode state for a question.
#[must_use]
pub fn pending(question: String) -> Self {
Self {
phase: ReviewAgentThreadPhase::Pending,
session_id: None,
question,
context_summary: String::new(),
status: "looking into this…".to_string(),
answer: String::new(),
session_items: Vec::new(),
session_revision: 0,
stream_warning: None,
activity: None,
error: None,
}
}
/// Return a review-oriented live state label.
#[must_use]
pub fn live_state_label(&self) -> &'static str {
if self.error.is_some() {
return "failed";
}
match self.phase {
ReviewAgentThreadPhase::Pending => "waiting to create session",
ReviewAgentThreadPhase::CreatingSession => "creating session",
ReviewAgentThreadPhase::Running if self.answer.trim().is_empty() => "working",
ReviewAgentThreadPhase::Running => "writing answer",
ReviewAgentThreadPhase::Complete => "answered",
ReviewAgentThreadPhase::Failed => "failed",
}
}
/// Return a concise display status.
#[must_use]
pub fn display_status(&self) -> String {
if let Some(error) = &self.error {
return format!("{}: {error}", self.phase.label());
}
if self.answer.is_empty() {
self.status.clone()
} else {
format!(
"{} · {}",
self.phase.label(),
self.answer.lines().next().unwrap_or_default()
)
}
}
}
/// Live subscriptions for linked Bcode review-thread sessions.
#[derive(Debug, Default)]
struct ReviewAgentStreamStore {
subscriptions: BTreeMap<String, PluginSessionViewSubscription>,
session_states: BTreeMap<String, ReviewAgentSessionViewState>,
failed_sessions: BTreeSet<String>,
}
impl ReviewAgentStreamStore {
fn ensure_subscriptions(&mut self, host: &dyn PluginTuiHost, app: &mut ReviewApp) -> bool {
let mut changed = false;
for session_id in app.take_pending_agent_stream_retries() {
self.subscriptions.remove(&session_id);
self.session_states.remove(&session_id);
self.failed_sessions.remove(&session_id);
changed = true;
}
for session_id in app.linked_agent_session_ids() {
if self.subscriptions.contains_key(&session_id)
|| self.failed_sessions.contains(&session_id)
{
continue;
}
let Ok(parsed_session_id) = session_id.parse::<SessionId>() else {
app.mark_agent_session_failed(&session_id, "invalid linked Bcode session id");
self.failed_sessions.insert(session_id);
changed = true;
continue;
};
match host.subscribe_session_view(PluginSessionViewSubscriptionRequest {
session_id: parsed_session_id,
projection: linked_session_projection_request(),
reasoning_policy: bcode_session_view_models::ReasoningPresentationPolicy::Summary,
buffer: 32,
}) {
Ok(subscription) => {
self.subscriptions.insert(session_id.clone(), subscription);
self.session_states.entry(session_id.clone()).or_default();
changed = true;
}
Err(error) => {
app.mark_agent_session_failed(
&session_id,
&format!("semantic session observation unavailable: {error}"),
);
self.failed_sessions.insert(session_id);
changed = true;
}
}
}
changed
}
fn drain(&mut self, app: &mut ReviewApp) -> bool {
let mut changed = false;
let mut disconnected = Vec::new();
for (session_id, subscription) in &mut self.subscriptions {
loop {
match subscription.receiver.try_recv() {
Ok(PluginSessionViewUpdate::Snapshot(snapshot)) => {
let state = self.session_states.entry(session_id.clone()).or_default();
if state.replace_snapshot(*snapshot) {
app.apply_agent_stream_state(session_id, state);
changed = true;
}
}
Ok(PluginSessionViewUpdate::Disconnected { message }) => {
disconnected.push(session_id.clone());
app.mark_agent_session_failed(session_id, &message);
changed = true;
break;
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
disconnected.push(session_id.clone());
app.mark_agent_session_failed(
session_id,
"session event stream disconnected",
);
changed = true;
break;
}
}
}
}
for session_id in disconnected {
self.subscriptions.remove(&session_id);
self.failed_sessions.insert(session_id);
}
changed
}
}
/// Semantic category of one bounded linked-session row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewAgentSessionItemKind {
/// Assistant response content.
Assistant,
/// Allowed renderer-selected reasoning or activity.
Reasoning,
/// Generic tool lifecycle and result summary.
Tool,
/// Pending permission or interaction requiring the native session.
ActionNeeded,
/// Stream, connection, cancellation, or failure state.
Status,
}
impl ReviewAgentSessionItemKind {
/// Return a compact user-facing row label.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Assistant => "answer",
Self::Reasoning => "activity",
Self::Tool => "tool",
Self::ActionNeeded => "action",
Self::Status => "status",
}
}
}
/// One bounded renderer-neutral row in Code Review's linked-session mini-view.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewAgentSessionItem {
/// Stable identity supplied by the semantic session projection.
pub id: String,
/// Source semantic item revision.
pub revision: ViewRevision,
/// Semantic display category.
pub kind: ReviewAgentSessionItemKind,
/// Compact display label.
pub label: String,
/// Bounded renderer-safe summary or message content.
pub text: String,
/// Text format for assistant content.
pub format: TextFormat,
/// Whether this row is still streaming.
pub streaming: bool,
/// Whether this row indicates degraded or failed state.
pub degraded: bool,
}
/// Code Review presentation derived only from an authoritative semantic session snapshot.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewAgentSessionViewState {
snapshot: SessionViewSnapshot,
phase: ReviewAgentThreadPhase,
status: String,
stream_warning: Option<String>,
activity: Option<String>,
answer: String,
items: Vec<ReviewAgentSessionItem>,
error: Option<String>,
}
impl Default for ReviewAgentSessionViewState {
fn default() -> Self {
Self::from_snapshot(SessionViewSnapshot::empty())
}
}
impl ReviewAgentSessionViewState {
fn replace_snapshot(&mut self, snapshot: SessionViewSnapshot) -> bool {
let next = Self::from_snapshot(snapshot);
if *self == next {
return false;
}
*self = next;
true
}
#[allow(clippy::too_many_lines)]
fn from_snapshot(snapshot: SessionViewSnapshot) -> Self {
let answer = latest_completed_assistant_response(&snapshot);
let items = bounded_session_items(&snapshot);
let failed_outcome = matches!(
snapshot.runtime.last_turn_outcome,
Some(
bcode_session_models::ModelTurnOutcome::Cancelled
| bcode_session_models::ModelTurnOutcome::Error
| bcode_session_models::ModelTurnOutcome::IdleTimeout
| bcode_session_models::ModelTurnOutcome::ProviderUnavailable
)
);
let has_active_work = snapshot.runtime_work.iter().any(|work| !work.is_terminal());
let has_streaming_items = snapshot.transcript.items.iter().any(|item| item.streaming);
let connection_error = match &snapshot.connection_status {
SessionConnectionViewStatus::Error(message) => Some(message.clone()),
_ => None,
};
let phase = if connection_error.is_some() || failed_outcome {
ReviewAgentThreadPhase::Failed
} else if snapshot.runtime.active_turn_id.is_some()
|| has_active_work
|| has_streaming_items
|| answer.is_empty()
|| matches!(
snapshot.connection_status,
SessionConnectionViewStatus::Reconnecting | SessionConnectionViewStatus::Resyncing
)
{
ReviewAgentThreadPhase::Running
} else {
ReviewAgentThreadPhase::Complete
};
let stream_warning = session_stream_warning(&snapshot);
let activity = items
.iter()
.rev()
.find(|item| {
matches!(
item.kind,
ReviewAgentSessionItemKind::Reasoning
| ReviewAgentSessionItemKind::Tool
| ReviewAgentSessionItemKind::ActionNeeded
)
})
.map(|item| format!("{}: {}", item.label, item.text));
let error = connection_error.or_else(|| {
failed_outcome.then(|| {
snapshot
.runtime
.last_turn_message
.clone()
.unwrap_or_else(|| "linked Bcode turn failed".to_string())
})
});
let status = if let Some(error) = &error {
error.clone()
} else if !snapshot.permissions.is_empty() || !snapshot.interactions.is_empty() {
"action needed in linked Bcode session".to_string()
} else if let Some(progress) = &snapshot.runtime.provider_progress {
progress.detail.clone()
} else if has_active_work {
"running linked Bcode work…".to_string()
} else if snapshot.runtime.active_turn_id.is_some() || has_streaming_items {
"receiving model response…".to_string()
} else if answer.is_empty() {
"waiting for Bcode…".to_string()
} else {
"answered".to_string()
};
Self {
snapshot,
phase,
status,
stream_warning,
activity,
answer,
items,
error,
}
}
fn display_answer(&self) -> String {
self.answer.clone()
}
fn session_items(&self) -> &[ReviewAgentSessionItem] {
&self.items
}
}
const MAX_REVIEW_SESSION_ITEMS: usize = 24;
const MAX_REVIEW_SESSION_TEXT_CHARS: usize = 1_200;
const MAX_REVIEW_TOOL_TEXT_CHARS: usize = 320;
fn latest_completed_assistant_response(snapshot: &SessionViewSnapshot) -> String {
snapshot
.transcript
.items
.iter()
.rev()
.find_map(|item| match &item.kind {
TranscriptViewItemKind::AssistantMessage { message }
if !item.streaming && !message.text.trim().is_empty() =>
{
Some(message.text.clone())
}
_ => None,
})
.unwrap_or_default()
}
fn semantic_tail_start(items: &[bcode_session_view_models::TranscriptViewItem]) -> usize {
let hard_start = items.len().saturating_sub(MAX_REVIEW_SESSION_ITEMS);
if hard_start == 0 {
return 0;
}
let boundary_turn = items
.get(hard_start)
.and_then(|item| item.output_location.as_ref())
.map(|location| location.turn_id.as_str());
let Some(boundary_turn) = boundary_turn else {
return hard_start;
};
let turn_start = (0..=hard_start)
.rev()
.take_while(|index| {
items[*index]
.output_location
.as_ref()
.is_some_and(|location| location.turn_id == boundary_turn)
})
.last()
.unwrap_or(hard_start);
if items.len().saturating_sub(turn_start) <= MAX_REVIEW_SESSION_ITEMS.saturating_mul(2) {
turn_start
} else {
hard_start
}
}
#[allow(clippy::too_many_lines)]
fn bounded_session_items(snapshot: &SessionViewSnapshot) -> Vec<ReviewAgentSessionItem> {
let start = semantic_tail_start(&snapshot.transcript.items);
let mut rows = snapshot.transcript.items[start..]
.iter()
.filter_map(session_item_from_transcript)
.collect::<Vec<_>>();
rows.extend(
snapshot
.permissions
.iter()
.filter(|permission| !permission.resolved)
.map(|permission| ReviewAgentSessionItem {
id: format!("permission:{}", permission.permission_id),
revision: snapshot.revision,
kind: ReviewAgentSessionItemKind::ActionNeeded,
label: permission
.title
.clone()
.unwrap_or_else(|| format!("permission for {}", permission.tool_name)),
text: bounded_text(
permission
.detail
.as_deref()
.unwrap_or("open the native session to respond"),
MAX_REVIEW_TOOL_TEXT_CHARS,
),
format: TextFormat::PlainText,
streaming: false,
degraded: false,
}),
);
rows.extend(
snapshot
.interactions
.iter()
.filter(|interaction| !interaction.resolved)
.map(|interaction| ReviewAgentSessionItem {
id: format!("interaction:{}", interaction.interaction_id),
revision: snapshot.revision,
kind: ReviewAgentSessionItemKind::ActionNeeded,
label: interaction
.title
.clone()
.unwrap_or_else(|| interaction.kind.clone()),
text: bounded_text(
interaction
.status_detail
.as_deref()
.unwrap_or("open the native session to respond"),
MAX_REVIEW_TOOL_TEXT_CHARS,
),
format: TextFormat::PlainText,
streaming: false,
degraded: matches!(
interaction.state,
bcode_session_view_models::InteractionViewState::ValidationError
| bcode_session_view_models::InteractionViewState::ActionError
),
}),
);
rows.extend(
snapshot
.runtime_work
.iter()
.filter(|work| !work.is_terminal())
.map(|work| ReviewAgentSessionItem {
id: format!("runtime-work:{}", work.work_id),
revision: snapshot.revision,
kind: ReviewAgentSessionItemKind::Tool,
label: if work.label.is_empty() {
"runtime work".to_string()
} else {
work.label.clone()
},
text: bounded_text(
work.message.as_deref().unwrap_or("running"),
MAX_REVIEW_TOOL_TEXT_CHARS,
),
format: TextFormat::PlainText,
streaming: true,
degraded: false,
}),
);
if let Some(warning) = session_stream_warning(snapshot) {
rows.push(ReviewAgentSessionItem {
id: "session:stream-status".to_string(),
revision: snapshot.revision,
kind: ReviewAgentSessionItemKind::Status,
label: "stream".to_string(),
text: warning,
format: TextFormat::PlainText,
streaming: false,
degraded: true,
});
}
let excess = rows.len().saturating_sub(MAX_REVIEW_SESSION_ITEMS);
if excess > 0 {
rows.drain(..excess);
}
rows
}
fn session_stream_warning(snapshot: &SessionViewSnapshot) -> Option<String> {
match snapshot.connection_status {
SessionConnectionViewStatus::Reconnecting => {
Some("reconnecting to linked Bcode session".to_string())
}
SessionConnectionViewStatus::Resyncing => {
Some("resynchronizing linked Bcode session".to_string())
}
SessionConnectionViewStatus::Error(ref message) => Some(message.clone()),
_ if snapshot.transcript.has_newer_history => {
Some("newer session history is outside this bounded view".to_string())
}
_ if snapshot.text_streams.values().any(|stream| {
matches!(
stream.status,
bcode_session_view_models::TextStreamViewStatus::Incomplete
| bcode_session_view_models::TextStreamViewStatus::Degraded
)
}) =>
{
Some("linked Bcode text stream is incomplete or degraded".to_string())
}
_ => None,
}
}
fn session_item_from_transcript(
item: &bcode_session_view_models::TranscriptViewItem,
) -> Option<ReviewAgentSessionItem> {
let (kind, label, text, format, degraded) = match &item.kind {
TranscriptViewItemKind::AssistantMessage { message } => (
ReviewAgentSessionItemKind::Assistant,
if item.streaming {
"answer · streaming"
} else {
"answer"
}
.to_string(),
bounded_preserving_text(&message.text, MAX_REVIEW_SESSION_TEXT_CHARS),
message.format,
false,
),
TranscriptViewItemKind::ReasoningMessage { message } => (
ReviewAgentSessionItemKind::Reasoning,
"activity".to_string(),
bounded_text(&message.text, MAX_REVIEW_TOOL_TEXT_CHARS),
message.format,
false,
),
TranscriptViewItemKind::ReasoningActivity { activity } => (
ReviewAgentSessionItemKind::Reasoning,
format!("activity · {:?}", activity.status),
bounded_text(&activity.text(), MAX_REVIEW_TOOL_TEXT_CHARS),
TextFormat::PlainText,
matches!(
activity.status,
bcode_session_models::ReasoningActivityStatus::Interrupted
| bcode_session_models::ReasoningActivityStatus::Failed
),
),
TranscriptViewItemKind::ToolInvocation { tool }
| TranscriptViewItemKind::ToolRequest { tool } => tool_session_item(tool),
TranscriptViewItemKind::ToolRequestDraft { draft } => (
ReviewAgentSessionItemKind::Tool,
format!("{} · assembling", draft.tool_name),
bounded_text(&draft.preview, MAX_REVIEW_TOOL_TEXT_CHARS),
TextFormat::Json,
draft.truncated,
),
TranscriptViewItemKind::Permission { permission } => (
ReviewAgentSessionItemKind::ActionNeeded,
permission
.title
.clone()
.unwrap_or_else(|| format!("permission for {}", permission.tool_name)),
bounded_text(
permission
.detail
.as_deref()
.unwrap_or("open the native session to respond"),
MAX_REVIEW_TOOL_TEXT_CHARS,
),
TextFormat::PlainText,
false,
),
TranscriptViewItemKind::Interaction { interaction } => (
ReviewAgentSessionItemKind::ActionNeeded,
interaction
.title
.clone()
.unwrap_or_else(|| interaction.kind.clone()),
bounded_text(
interaction
.status_detail
.as_deref()
.unwrap_or("open the native session to respond"),
MAX_REVIEW_TOOL_TEXT_CHARS,
),
TextFormat::PlainText,
matches!(
interaction.state,
bcode_session_view_models::InteractionViewState::ValidationError
| bcode_session_view_models::InteractionViewState::ActionError
),
),
_ => return None,
};
(!text.is_empty()).then(|| ReviewAgentSessionItem {
id: item.id.get().to_string(),
revision: item.revision,
kind,
label,
text,
format,
streaming: item.streaming,
degraded,
})
}
fn tool_session_item(
tool: &ToolInvocationView,
) -> (ReviewAgentSessionItemKind, String, String, TextFormat, bool) {
let name = tool.tool_name.as_deref().unwrap_or("tool");
let mut details = Vec::new();
if let Some(arguments) = &tool.arguments_json
&& !arguments.is_empty()
{
details.push(format!(
"args: {}",
bounded_text(arguments, MAX_REVIEW_TOOL_TEXT_CHARS)
));
}
if let Some(result) = tool_result_summary(tool) {
details.push(result);
}
if let Some(duration_ms) = tool.timing.duration_ms {
details.push(format!("{duration_ms} ms"));
}
let text = bounded_text(&details.join(" · "), MAX_REVIEW_TOOL_TEXT_CHARS);
(
ReviewAgentSessionItemKind::Tool,
format!("{name} · {:?}", tool.status),
text,
TextFormat::PlainText,
tool.is_error.unwrap_or(false)
|| matches!(
tool.status,
ToolInvocationViewStatus::Cancelled | ToolInvocationViewStatus::Failed
),
)
}
fn tool_result_summary(tool: &ToolInvocationView) -> Option<String> {
if let Some(result) = &tool.result {
let summary = match result {
ToolResultView::Text { text } => bounded_text(text, MAX_REVIEW_TOOL_TEXT_CHARS),
ToolResultView::Json { value } => bounded_text(value, MAX_REVIEW_TOOL_TEXT_CHARS),
ToolResultView::Artifact { artifact } => {
let artifact = &artifact.artifact;
format!(
"artifact: {} [{} v{}; {} ref(s)]",
artifact.title.as_deref().unwrap_or(&artifact.artifact_id),
artifact.schema,
artifact.schema_version,
artifact.refs.len()
)
}
};
return (!summary.is_empty()).then_some(summary);
}
tool.result_text
.as_deref()
.filter(|text| !text.is_empty())
.map(|text| bounded_text(text, MAX_REVIEW_TOOL_TEXT_CHARS))
}
fn bounded_preserving_text(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let mut bounded = text
.chars()
.take(max_chars.saturating_sub(1))
.collect::<String>();
bounded.push('…');
bounded
}
fn bounded_text(text: &str, max_chars: usize) -> String {
let mut compact = text.split_whitespace().collect::<Vec<_>>().join(" ");
if compact.chars().count() <= max_chars {
return compact;
}
compact = compact.chars().take(max_chars.saturating_sub(1)).collect();
compact.push('…');
compact
}
/// Draft comment editor mode.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewCommentEditorMode {
/// Creating a new draft.
Create,
/// Editing an existing persisted draft.
Edit {
/// Existing comment id.
comment_id: String,
/// Previous body for failure restore.
previous_body: String,
},
}
/// Comment composer submit action.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewCommentAction {
/// Save the text as a local draft review comment.
SaveDraft,
/// Ask Bcode using the text as the review question.
AskBcode,
/// Publish the text as a review comment when a publisher supports it.
Publish,
}
impl ReviewCommentAction {
/// Return the next selectable composer action.
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::SaveDraft => Self::AskBcode,
Self::AskBcode => Self::Publish,
Self::Publish => Self::SaveDraft,
}
}
/// Return the previous selectable composer action.
#[must_use]
pub const fn previous(self) -> Self {
match self {
Self::SaveDraft => Self::Publish,
Self::AskBcode => Self::SaveDraft,
Self::Publish => Self::AskBcode,
}
}
/// Return a user-facing action label.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::SaveDraft => "save draft",
Self::AskBcode => "ask Bcode",
Self::Publish => "publish",
}
}
}
/// Active draft comment editor.
#[derive(Debug)]
pub struct ReviewCommentEditor {
/// Completion metadata for this editor lifetime; geometry is retained from painting.
pub(crate) focused_input: FocusedTextFieldAdapter,
/// Anchor being commented on.
pub anchor: ReviewCommentAnchor,
/// Editable comment buffer.
pub buffer: TextEditBuffer,
/// Whether the editor is showing Markdown preview.
pub preview: bool,
/// Selected submit action.
pub action: ReviewCommentAction,
/// Existing draft comments on the target thread when composer opened.
pub existing_comment_count: usize,
/// Editor mode.
pub mode: ReviewCommentEditorMode,
}
impl Clone for ReviewCommentEditor {
fn clone(&self) -> Self {
Self {
focused_input: FocusedTextFieldAdapter::new("bcode.code-review", "comment"),
anchor: self.anchor.clone(),
buffer: self.buffer.clone(),
preview: self.preview,
action: self.action,
existing_comment_count: self.existing_comment_count,
mode: self.mode.clone(),
}
}
}
impl ReviewCommentEditor {
/// Create an editor for an anchor.
#[must_use]
pub fn new(anchor: ReviewCommentAnchor, action: ReviewCommentAction) -> Self {
Self {
focused_input: FocusedTextFieldAdapter::new("bcode.code-review", "comment"),
anchor,
buffer: TextEditBuffer::new(),
preview: false,
action,
existing_comment_count: 0,
mode: ReviewCommentEditorMode::Create,
}
}
/// Create an editor for updating an existing draft.
#[must_use]
pub fn edit(anchor: ReviewCommentAnchor, comment_id: String, body: String) -> Self {
Self {
focused_input: FocusedTextFieldAdapter::new("bcode.code-review", "comment"),
anchor,
buffer: TextEditBuffer::from_text(&body),
preview: false,
action: ReviewCommentAction::SaveDraft,
existing_comment_count: 0,
mode: ReviewCommentEditorMode::Edit {
comment_id,
previous_body: body,
},
}
}
}
/// Review sidebar mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewSidebarMode {
/// Included review workspace sources/sidebar.
Included,
/// Full repository file browser.
Repository,
/// Review thread list sidebar.
Threads,
/// Whole-review/general discussion thread sidebar.
General,
/// Review summary/sidebar.
Summary,
/// Review source list sidebar.
Sources,
/// Files with local review work remaining.
NeedsAttention,
}
impl ReviewSidebarMode {
/// Return a user-facing sidebar label.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Included => "included",
Self::Repository => "repo",
Self::Threads => "threads",
Self::General => "general",
Self::Summary => "summary",
Self::Sources => "sources",
Self::NeedsAttention => "attention",
}
}
}
/// Review thread sidebar filter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewThreadFilter {
/// Show all threads.
All,
/// Show open threads only.
Open,
/// Show resolved threads only.
Resolved,
}
impl ReviewThreadFilter {
/// Return the next filter in cycle order.
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::All => Self::Open,
Self::Open => Self::Resolved,
Self::Resolved => Self::All,
}
}
/// Return a compact filter label.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::All => "all",
Self::Open => "open",
Self::Resolved => "resolved",
}
}
}
/// Review thread row summary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewThreadSummary {
/// Thread anchor.
pub anchor: ReviewCommentAnchor,
/// Number of draft comments.
pub draft_count: usize,
/// Latest draft body.
pub latest_body: String,
/// Linked Bcode session id, when present.
pub session_id: Option<String>,
/// Whether the thread is locally resolved.
pub resolved: bool,
/// Thread kind.
pub thread_kind: ReviewThreadKind,
/// Thread severity.
pub severity: ReviewThreadSeverity,
/// Pending suggested comment count.
pub pending_suggestion_count: usize,
/// Refining suggested comment count.
pub refining_suggestion_count: usize,
/// Accepted suggested comment count.
pub accepted_suggestion_count: usize,
/// Rejected suggested comment count.
pub rejected_suggestion_count: usize,
/// External provider/importer id when this row came from imported review state.
pub external_provider_id: Option<String>,
/// External anchor mapping status for imported review state.
pub external_mapping_status: Option<ExternalAnchorMappingStatus>,
}
impl ReviewThreadSummary {
/// Return a compact line label for the thread anchor.
#[must_use]
pub fn line_label(&self) -> String {
if self.anchor.is_review_level() {
return "review".to_string();
}
if self.anchor.kind() == ReviewAnchorKind::File {
return "file".to_string();
}
self.anchor.new_start.or(self.anchor.old_start).map_or_else(
|| format!("@{}", self.anchor.diff_row),
|line| format!("+{line}"),
)
}
}
fn thread_path(thread: &bcode_code_review_models::ExternalReviewThread) -> String {
thread.mapping.anchor.as_ref().map_or_else(
|| "<unmapped external>".to_string(),
|anchor| anchor.file_path.clone(),
)
}
/// Pending external review import request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PendingExternalImportRequest {
/// Discover available provider importers.
Discover,
/// Invoke one selected importer with configured options.
Invoke {
/// Importer index in the discovered importer list.
importer_index: usize,
/// Configured provider options.
options: Vec<ReviewPublishOption>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ExternalImporterRoute {
manifest: ReviewImporterManifest,
plugin_id: String,
interface_id: String,
}
/// Pending publish request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PendingPublishRequest {
/// List available publishers.
ListPublishers,
/// Preview a publisher output.
Preview {
/// Publisher id.
publisher_id: String,
/// Publisher options.
options: Vec<ReviewPublishOption>,
/// Excluded thread anchors.
excluded_thread_anchors: Vec<ModelDraftAnchor>,
},
/// Submit publisher output.
Submit {
/// Publisher id.
publisher_id: String,
/// Publisher options.
options: Vec<ReviewPublishOption>,
/// Excluded thread anchors.
excluded_thread_anchors: Vec<ModelDraftAnchor>,
},
}
/// Publish option field.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewPublishOption {
/// Option name.
pub name: String,
/// Option label.
pub label: String,
/// Option value.
pub value: String,
/// Enumerated choices, when the publisher exposes them.
pub choices: Vec<String>,
}
/// Source kind that should be edited.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditSourceTargetKind {
/// Commit source.
Commit,
/// Commit range source.
CommitRange,
/// Branch compare source.
BranchCompare,
/// File source.
File,
/// File range source.
FileRange,
}
/// Active review prompt kind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewPromptKind {
/// Fuzzy file picker.
FilePicker,
/// Jump to a line number.
JumpToLine,
/// Search within the current file.
FileSearch,
/// Add a source to the workspace.
AddSourceKind,
/// Add an advanced source to the workspace.
AddAdvancedSourceKind,
/// Pick a commit source from recent repository commits.
AddCommitPicker,
/// Add a commit source to the workspace.
AddCommitSource,
/// Pick a base commit for a commit range source.
AddCommitRangeBasePicker,
/// Pick a head commit for a commit range source.
AddCommitRangeHeadPicker { base_rev: String },
/// Add a commit range source to the workspace.
AddCommitRangeSource,
/// Pick a base branch for a branch compare source.
AddBranchCompareBasePicker,
/// Pick a head branch for a branch compare source.
AddBranchCompareHeadPicker { base_branch: String },
/// Add a branch compare source to the workspace.
AddBranchCompareSource,
/// Add a file source using the fuzzy file picker.
AddFileSourcePicker,
/// Pick a file before entering a file range source.
AddFileRangePathPicker,
/// Add a file range source to the workspace.
AddFileRangeSource,
/// Rename the review workspace.
RenameWorkspace,
/// Edit the selected source specification.
EditSourceSpec { target_kind: EditSourceTargetKind },
/// Rename the selected source.
RenameSource,
}
/// Add-source menu item kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddSourceMenuKind {
/// File source.
File,
/// File range source.
FileRange,
/// Commit source.
Commit,
/// Commit range source.
CommitRange,
/// Branch compare source.
BranchCompare,
/// Staged changes source.
Staged,
/// Unstaged changes source.
Unstaged,
/// Working tree source.
WorkingTree,
/// Last commit source.
LastCommit,
/// Repository source.
Repository,
/// Open advanced source menu.
Advanced,
}
/// Add-source menu item.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AddSourceMenuItem {
/// Kind submitted by this menu item.
pub kind: AddSourceMenuKind,
/// Short label.
pub label: &'static str,
/// Help text.
pub help: &'static str,
}
/// Add-source menu items.
#[must_use]
pub const fn add_source_menu_items() -> &'static [AddSourceMenuItem] {
&[
AddSourceMenuItem {
kind: AddSourceMenuKind::Unstaged,
label: "unstaged changes",
help: "changes in the working tree that are not staged",
},
AddSourceMenuItem {
kind: AddSourceMenuKind::Staged,
label: "staged changes",
help: "index changes ready to commit",
},
AddSourceMenuItem {
kind: AddSourceMenuKind::WorkingTree,
label: "working tree",
help: "all staged and unstaged changes",
},
AddSourceMenuItem {
kind: AddSourceMenuKind::LastCommit,
label: "last commit",
help: "changes introduced by HEAD",
},
AddSourceMenuItem {
kind: AddSourceMenuKind::File,
label: "file",
help: "whole file from repository tree",
},
AddSourceMenuItem {
kind: AddSourceMenuKind::Repository,
label: "repository browser",
help: "browse repository files as review context",
},
AddSourceMenuItem {
kind: AddSourceMenuKind::Advanced,
label: "advanced…",
help: "commit/range, branch comparison, or file range",
},
]
}
/// Advanced source menu items disclosed from the common-source menu.
#[must_use]
pub const fn advanced_source_menu_items() -> &'static [AddSourceMenuItem] {
&[
AddSourceMenuItem {
kind: AddSourceMenuKind::BranchCompare,
label: "branch compare",
help: "compare base..head or base...head branches",
},
AddSourceMenuItem {
kind: AddSourceMenuKind::FileRange,
label: "file range",
help: "file slice path:start-end",
},
AddSourceMenuItem {
kind: AddSourceMenuKind::Commit,
label: "commit",
help: "single commit revision",
},
AddSourceMenuItem {
kind: AddSourceMenuKind::CommitRange,
label: "commit range",
help: "compare base..head or base...head revisions",
},
]
}
/// Active one-line prompt state.
#[derive(Debug, Clone)]
pub struct ReviewPromptState {
/// Prompt kind.
pub kind: ReviewPromptKind,
/// Editable prompt buffer.
pub buffer: TextEditBuffer,
/// Selected match index.
pub selected: usize,
}
impl ReviewPromptState {
/// Create a prompt.
#[must_use]
pub const fn new(kind: ReviewPromptKind) -> Self {
Self {
kind,
buffer: TextEditBuffer::new(),
selected: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewImportState {
/// Select an importer.
Picker,
/// Configure selected importer options.
Options {
importer_index: usize,
options: Vec<ReviewPublishOption>,
selected: usize,
},
}
/// Publish modal state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewPublishState {
/// Publish readiness checklist.
Checklist,
/// Publisher picker.
Picker,
/// Options editor.
Options {
/// Publisher id.
publisher_id: String,
/// Options.
options: Vec<ReviewPublishOption>,
/// Selected option index.
selected: usize,
},
/// Preview content.
Preview {
/// Publisher id.
publisher_id: String,
/// Publisher options.
options: Vec<ReviewPublishOption>,
/// Provider preview text.
preview: String,
/// Thread selection summary captured for this preview.
selection_summary: Vec<String>,
/// Top visible preview line.
scroll: usize,
},
/// Submit confirmation.
ConfirmSubmit {
/// Publisher id.
publisher_id: String,
/// Publisher options.
options: Vec<ReviewPublishOption>,
/// Provider preview text.
preview: String,
/// Thread selection summary captured for this preview.
selection_summary: Vec<String>,
/// Top visible preview line.
scroll: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct ReviewViewportState {
diff_scroll: usize,
selected_diff_line: usize,
}
#[derive(Debug, Clone)]
struct ReviewViewDocumentCache {
key: ReviewViewDocumentCacheKey,
document: ReviewViewDocument,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ReviewViewDocumentCacheKey {
content_width: u16,
selected_file: usize,
selected_path: Option<String>,
surface_kind: Option<ReviewSurfaceKind>,
repository_review: bool,
file_signature: String,
draft_signature: String,
expanded_agent_answers: Vec<String>,
collapsed_threads: Vec<String>,
resolved_threads: Vec<String>,
expanded_contexts: Vec<(DiffContextKey, DiffContextLoadState)>,
show_resolved_threads: bool,
agent_state_revision: u64,
theme_fingerprint: u64,
}
/// Stateful review app model.
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct ReviewApp {
/// Workspace that owns this review session.
pub workspace: ReviewWorkspace,
/// Review data.
pub review: ReviewSummary,
/// Current review UX mode.
pub ux_mode: ReviewUxMode,
/// Selected file index.
pub selected_file: usize,
/// Top visible file row.
pub file_scroll: usize,
/// Top visible repository tree row.
pub tree_scroll: usize,
/// Top visible rendered diff row.
pub diff_scroll: usize,
/// Selected rendered diff row.
pub selected_diff_line: usize,
/// Whether the file sidebar is visible.
pub sidebar_visible: bool,
/// Active sidebar mode.
pub sidebar_mode: ReviewSidebarMode,
/// Selected review thread index.
pub selected_thread: usize,
/// Active thread sidebar filter.
pub thread_filter: ReviewThreadFilter,
/// Top visible review thread row.
pub thread_scroll: usize,
/// Whether help is visible.
pub help_visible: bool,
/// Whether to exit.
pub should_exit: bool,
/// Last transient status message.
pub status_message: Option<String>,
/// Draft comments keyed by anchor.
pub draft_comments: BTreeMap<ReviewCommentAnchor, Vec<ReviewDraftComment>>,
/// Local AI-suggested comments keyed by anchor.
pub suggested_comments: BTreeMap<ReviewCommentAnchor, Vec<ReviewSuggestedComment>>,
/// Durable, non-publishable AI exchanges keyed by review anchor.
pub ai_exchanges: BTreeMap<ReviewCommentAnchor, Vec<ModelReviewAiExchange>>,
/// AI exchanges awaiting durable persistence, keyed by exchange id.
pub pending_ai_exchange_saves: BTreeMap<String, ReviewCommentAnchor>,
/// AI exchange awaiting explicit durable deletion.
pub pending_ai_exchange_delete: Option<(String, ReviewCommentAnchor)>,
/// Text awaiting a host clipboard write.
pub pending_clipboard_text: Option<String>,
/// Suggestions awaiting durable persistence, keyed by suggestion id.
pub pending_suggestion_saves: BTreeMap<String, ReviewCommentAnchor>,
/// Review thread keys excluded from the next publish.
pub excluded_publish_threads: BTreeSet<String>,
/// Publisher id recorded for locally published review threads.
pub published_review_threads: BTreeMap<String, String>,
/// Durable provider-neutral external review snapshots.
pub external_review_imports: Vec<ExternalReviewImportRecord>,
/// Whether workspace materialization is currently queued/running.
pub workspace_materialization_in_progress: bool,
/// Whether the most recent materialization attempt failed.
pub workspace_materialization_failed: bool,
/// Active draft editor, if open.
pub comment_editor: Option<ReviewCommentEditor>,
/// Whether the terminal currently owns focus.
pub(crate) terminal_focused: bool,
pub(crate) comment_input_visible: bool,
#[cfg(test)]
comment_input_invocations: usize,
/// External review import awaiting provider discovery/invocation.
pub pending_external_import_request: Option<PendingExternalImportRequest>,
/// Discovered external review importers.
external_importers: Vec<ExternalImporterRoute>,
/// Active importer picker/options state.
pub import_state: Option<ReviewImportState>,
/// Selected importer index.
pub selected_importer: usize,
/// Draft comment awaiting persistence.
pub pending_draft_save: Option<PendingDraftSave>,
/// Draft comment awaiting deletion.
pub pending_draft_delete: Option<PendingDraftDelete>,
/// Draft comment awaiting update.
pub pending_draft_update: Option<PendingDraftUpdate>,
/// Thread resolution awaiting persistence.
pub pending_thread_resolve: Option<PendingThreadResolve>,
/// Pending publish request.
pub pending_publish_request: Option<PendingPublishRequest>,
/// Available publishers.
pub publishers: Vec<ReviewPublisherManifest>,
/// Files marked viewed by display path.
pub viewed_files: BTreeSet<String>,
/// Repository file cache for read-only file browsing.
pub file_cache: ReviewFileCache,
/// Per-file viewport state keyed by stable file/surface path.
file_viewports: BTreeMap<String, ReviewViewportState>,
/// Repository file path awaiting lazy load.
pub pending_file_load: Option<String>,
/// Review file path queued for hidden diff context loading.
pub pending_context_file_load: Option<String>,
/// Diff context load state keyed by typed context identity.
pub diff_context_load_states: BTreeMap<DiffContextKey, DiffContextLoadState>,
/// Selected publisher index.
pub selected_publisher: usize,
/// Active publish UI state.
pub publish_state: Option<ReviewPublishState>,
/// Whether incomplete-review publish warning has been acknowledged.
pub publish_readiness_ack: bool,
/// Active one-line prompt, if any.
pub prompt_state: Option<ReviewPromptState>,
/// Last current-file search query.
pub last_search_query: Option<String>,
/// Expanded repository sidebar directories.
pub expanded_dirs: BTreeSet<PathBuf>,
/// Selected repository file-tree row.
pub selected_tree_row: usize,
/// Active build-mode row.
pub selected_build_row: usize,
/// Top visible build selectable row.
pub build_scroll: usize,
/// Whether workspace changes should be persisted.
pub pending_workspace_save: bool,
/// Whether workspace content should be rematerialized.
pub pending_workspace_reload: bool,
/// Review thread awaiting Bcode session creation.
pub pending_agent_session: Option<PendingAgentSession>,
/// Linked Bcode session ids that should be resubscribed.
pub pending_agent_stream_retries: BTreeSet<String>,
/// Structured Bcode agent/session state by review thread key.
pub agent_thread_states: BTreeMap<String, ReviewAgentThreadState>,
/// Revision bump for linked Bcode agent state.
pub agent_state_revision: u64,
/// Active range selection start row, if any.
pub range_selection_start: Option<usize>,
mouse_range_selection_start: Option<usize>,
mouse_range_selection_dragged: bool,
/// Selected inline review target, if selection is on a non-source row.
pub selected_view_target: Option<ReviewViewTarget>,
/// Collapsed inline review thread keys.
pub collapsed_review_threads: BTreeSet<String>,
/// Linked Bcode answer thread keys expanded beyond the compact preview.
pub expanded_agent_answers: BTreeSet<String>,
/// Locally resolved inline review thread keys.
pub resolved_review_threads: BTreeSet<String>,
/// Whether resolved inline review threads are visible.
pub show_resolved_threads: bool,
/// Whether viewed files are hidden from the file sidebar.
pub hide_viewed_files: bool,
/// Whether stable presentation state has changed since the last workspace save.
presentation_state_dirty: bool,
/// Session id to open while retaining review mode.
pub session_to_open: Option<SessionId>,
last_file_area: Option<Rect>,
last_diff_area: Option<Rect>,
mouse_regions: Vec<ReviewMouseRegion>,
syntax_palette: Option<bcode_syntax_render::SyntaxPalette>,
theme_fingerprint: u64,
view_document_cache: Arc<RwLock<Option<ReviewViewDocumentCache>>>,
}
impl ReviewApp {
pub(crate) fn observe_comment_input(&mut self) {
let eligible = self.terminal_focused
&& self.comment_input_visible
&& self.prompt_state.is_none()
&& self.import_state.is_none()
&& self.publish_state.is_none()
&& !self.should_exit;
if let Some(editor) = &mut self.comment_editor {
editor
.focused_input
.observe(&editor.buffer, eligible && !editor.preview);
}
}
fn focused_comment_input(&self) -> Option<FocusedTextFieldSnapshot> {
if !self.terminal_focused
|| !self.comment_input_visible
|| self.prompt_state.is_some()
|| self.import_state.is_some()
|| self.publish_state.is_some()
|| self.should_exit
{
return None;
}
let editor = self.comment_editor.as_ref()?;
if editor.preview {
return None;
}
editor.focused_input.snapshot(&editor.buffer)
}
fn apply_comment_input(
&mut self,
edit: &FocusedTextFieldEdit,
) -> PluginTuiFocusedTextFieldEditResult {
self.observe_comment_input();
if self.focused_comment_input().is_none() {
return PluginTuiFocusedTextFieldEditResult::rejected(
bcode_plugin_sdk::composer::ComposerValidationError::Stale,
);
}
let editor = self
.comment_editor
.as_mut()
.expect("validated comment editor");
let edit_result = editor.focused_input.apply(&mut editor.buffer, edit);
let event = if edit_result == FocusedTextFieldEditResult::Applied {
PluginTuiEventResult::from_legacy(PluginTuiAction::Redraw)
} else {
PluginTuiEventResult::consumed()
};
PluginTuiFocusedTextFieldEditResult { edit_result, event }
}
/// Create a new review app.
#[must_use]
pub fn new(review: ReviewSummary) -> Self {
let workspace = review.workspace();
let viewed_files = workspace.viewed_files.clone();
let mut app = Self {
workspace,
review,
ux_mode: ReviewUxMode::Review,
selected_file: 0,
file_scroll: 0,
tree_scroll: 0,
diff_scroll: 0,
selected_diff_line: 0,
sidebar_visible: true,
sidebar_mode: ReviewSidebarMode::Included,
selected_thread: 0,
thread_filter: ReviewThreadFilter::All,
thread_scroll: 0,
help_visible: false,
should_exit: false,
status_message: None,
draft_comments: BTreeMap::new(),
suggested_comments: BTreeMap::new(),
ai_exchanges: BTreeMap::new(),
pending_ai_exchange_saves: BTreeMap::new(),
pending_ai_exchange_delete: None,
pending_clipboard_text: None,
pending_suggestion_saves: BTreeMap::new(),
excluded_publish_threads: BTreeSet::new(),
published_review_threads: BTreeMap::new(),
external_review_imports: Vec::new(),
workspace_materialization_in_progress: false,
workspace_materialization_failed: false,
comment_editor: None,
terminal_focused: true,
comment_input_visible: false,
#[cfg(test)]
comment_input_invocations: 0,
pending_external_import_request: None,
external_importers: Vec::new(),
import_state: None,
selected_importer: 0,
pending_draft_save: None,
pending_draft_delete: None,
pending_draft_update: None,
pending_thread_resolve: None,
pending_publish_request: None,
publishers: Vec::new(),
viewed_files,
file_cache: ReviewFileCache::default(),
file_viewports: BTreeMap::new(),
pending_file_load: None,
pending_context_file_load: None,
diff_context_load_states: BTreeMap::new(),
selected_publisher: 0,
publish_state: None,
publish_readiness_ack: false,
prompt_state: None,
last_search_query: None,
expanded_dirs: BTreeSet::new(),
selected_tree_row: 0,
selected_build_row: 0,
build_scroll: 0,
pending_workspace_save: false,
pending_workspace_reload: false,
pending_agent_session: None,
pending_agent_stream_retries: BTreeSet::new(),
agent_thread_states: BTreeMap::new(),
agent_state_revision: 0,
range_selection_start: None,
mouse_range_selection_start: None,
mouse_range_selection_dragged: false,
selected_view_target: None,
collapsed_review_threads: BTreeSet::new(),
expanded_agent_answers: BTreeSet::new(),
resolved_review_threads: BTreeSet::new(),
show_resolved_threads: true,
hide_viewed_files: false,
presentation_state_dirty: false,
session_to_open: None,
last_file_area: None,
last_diff_area: None,
mouse_regions: Vec::new(),
syntax_palette: None,
theme_fingerprint: 0,
view_document_cache: Arc::new(RwLock::new(None)),
};
app.restore_presentation_state();
app
}
/// Update renderer-owned syntax presentation and invalidate derived display rows when changed.
pub fn set_tui_theme(&mut self, theme: Option<&bcode_plugin_sdk::tui::PluginTuiTheme>) {
use std::hash::{Hash, Hasher};
let fingerprint = theme.map_or(0, |theme| {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
format!("{theme:?}").hash(&mut hasher);
hasher.finish()
});
if self.theme_fingerprint == fingerprint {
return;
}
self.syntax_palette = theme.map(|theme| syntax_palette(theme.syntax));
self.theme_fingerprint = fingerprint;
if let Ok(mut cache) = self.view_document_cache.write() {
*cache = None;
}
}
/// Switch directly to build mode.
pub fn set_build_mode(&mut self) -> bool {
self.ux_mode = ReviewUxMode::Build;
self.sidebar_mode = ReviewSidebarMode::Sources;
self.sidebar_visible = true;
self.status_message = Some("build mode: assemble review sources".to_string());
true
}
/// Toggle between build and review UX modes.
pub fn toggle_ux_mode(&mut self) -> bool {
if self.ux_mode == ReviewUxMode::Build && self.workspace.sources.is_empty() {
self.status_message =
Some("add at least one source before switching to review mode".to_string());
return true;
}
if self.ux_mode == ReviewUxMode::Build
&& self.workspace.sources.iter().all(|source| !source.included)
{
self.status_message =
Some("include at least one source before switching to review mode".to_string());
return true;
}
if self.has_materialization_errors() {
self.status_message = Some(
"review has source errors; fix or exclude them before review mode".to_string(),
);
return true;
}
self.ux_mode = match self.ux_mode {
ReviewUxMode::Build => ReviewUxMode::Review,
ReviewUxMode::Review => ReviewUxMode::Build,
};
self.status_message = Some(match self.ux_mode {
ReviewUxMode::Build => "build mode: assemble review sources".to_string(),
ReviewUxMode::Review => "review mode: comment, ask, publish".to_string(),
});
true
}
/// Open add-source prompt appropriate for the selected build row.
pub fn open_add_source_prompt(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
self.status_message = Some("switch to build mode with m to add sources".to_string());
return true;
}
self.prompt_state = Some(ReviewPromptState::new(ReviewPromptKind::AddSourceKind));
self.status_message = Some(
"add common source; choose advanced… for ranges, commits, and branch comparisons"
.to_string(),
);
true
}
/// Open rename-workspace prompt.
pub fn open_rename_workspace_prompt(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let mut prompt = ReviewPromptState::new(ReviewPromptKind::RenameWorkspace);
prompt.buffer.insert_str(&self.workspace.title);
self.prompt_state = Some(prompt);
self.status_message = Some("rename review workspace".to_string());
true
}
/// Open edit-source-spec prompt for the selected workspace source.
pub fn open_edit_source_spec_prompt(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let Some(index) = self.selected_build_source_index() else {
self.status_message = Some("select an editable source to change".to_string());
return true;
};
let Some(source) = self.workspace.sources.get(index) else {
self.status_message = Some("select an editable source to change".to_string());
return true;
};
let Some((target_kind, value)) = edit_source_prompt_value(&source.kind) else {
self.status_message = Some("selected source has no editable spec".to_string());
return true;
};
let mut prompt = ReviewPromptState::new(ReviewPromptKind::EditSourceSpec { target_kind });
prompt.buffer.insert_str(&value);
self.prompt_state = Some(prompt);
self.status_message = Some("edit source spec".to_string());
true
}
/// Open rename-source prompt for the selected workspace source.
pub fn open_rename_source_prompt(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let Some(index) = self.selected_build_source_index() else {
self.status_message = Some("select an included source to rename".to_string());
return true;
};
let Some(source) = self.workspace.sources.get(index) else {
self.status_message = Some("select an included source to rename".to_string());
return true;
};
let mut prompt = ReviewPromptState::new(ReviewPromptKind::RenameSource);
prompt.buffer.insert_str(&source.label);
self.prompt_state = Some(prompt);
self.status_message = Some("rename source".to_string());
true
}
/// Open fuzzy file picker prompt.
pub fn open_file_picker(&mut self) -> bool {
self.prompt_state = Some(ReviewPromptState::new(ReviewPromptKind::FilePicker));
self.status_message = Some("file picker: type path, enter open, esc cancel".to_string());
true
}
/// Open add-commit picker.
pub fn open_add_commit_picker(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
if self.review.repository_commits.is_empty() {
self.prompt_state = Some(ReviewPromptState::new(ReviewPromptKind::AddCommitSource));
self.status_message = Some("add commit: enter revision".to_string());
return true;
}
self.prompt_state = Some(ReviewPromptState::new(ReviewPromptKind::AddCommitPicker));
self.status_message = Some("add commit: pick recent commit".to_string());
true
}
/// Open add-commit-range base picker.
pub fn open_add_commit_range_base_picker(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
if self.review.repository_commits.is_empty() {
self.prompt_state = Some(ReviewPromptState::new(
ReviewPromptKind::AddCommitRangeSource,
));
self.status_message = Some("add commit range: base..head or base...head".to_string());
return true;
}
self.prompt_state = Some(ReviewPromptState::new(
ReviewPromptKind::AddCommitRangeBasePicker,
));
self.status_message = Some("add commit range: pick base commit".to_string());
true
}
/// Open add-branch-compare base picker.
pub fn open_add_branch_compare_base_picker(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
if self.review.repository_branches.is_empty() {
self.prompt_state = Some(ReviewPromptState::new(
ReviewPromptKind::AddBranchCompareSource,
));
self.status_message = Some("add branch compare: base...head or base..head".to_string());
return true;
}
self.prompt_state = Some(ReviewPromptState::new(
ReviewPromptKind::AddBranchCompareBasePicker,
));
self.status_message = Some("add branch compare: pick base branch".to_string());
true
}
/// Open add-file-source picker.
pub fn open_add_file_source_picker(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
if self.review.repository_files.is_empty() {
return self.add_selected_file_to_workspace();
}
self.prompt_state = Some(ReviewPromptState::new(
ReviewPromptKind::AddFileSourcePicker,
));
self.status_message = Some("add file source: type to filter, enter add".to_string());
true
}
/// Open add-file-range path picker.
pub fn open_add_file_range_path_picker(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
if self.review.repository_files.is_empty() {
self.prompt_state = Some(ReviewPromptState::new(ReviewPromptKind::AddFileRangeSource));
self.status_message = Some("add file range: path:start-end".to_string());
return true;
}
self.prompt_state = Some(ReviewPromptState::new(
ReviewPromptKind::AddFileRangePathPicker,
));
self.status_message = Some("add file range: pick file, then enter start-end".to_string());
true
}
/// Open jump-to-line prompt.
pub fn open_jump_to_line_prompt(&mut self) -> bool {
self.prompt_state = Some(ReviewPromptState::new(ReviewPromptKind::JumpToLine));
self.status_message = Some("jump to line".to_string());
true
}
/// Open current-file search prompt.
pub fn open_file_search_prompt(&mut self) -> bool {
self.prompt_state = Some(ReviewPromptState::new(ReviewPromptKind::FileSearch));
self.status_message = Some("search current file".to_string());
true
}
/// Cancel active prompt.
pub fn cancel_prompt(&mut self) {
self.prompt_state = None;
self.status_message = Some("prompt canceled".to_string());
}
/// Submit active prompt.
pub fn submit_prompt(&mut self) -> bool {
let Some(prompt) = self.prompt_state.take() else {
return false;
};
let text = prompt.buffer.text().trim().to_string();
match prompt.kind.clone() {
ReviewPromptKind::FilePicker => self.submit_file_picker(&text, prompt.selected),
ReviewPromptKind::JumpToLine => self.submit_jump_to_line(&text),
ReviewPromptKind::FileSearch => self.submit_file_search(&text),
ReviewPromptKind::AddSourceKind => self.submit_add_source_kind(&text, prompt.selected),
ReviewPromptKind::AddAdvancedSourceKind => {
self.submit_add_advanced_source_kind(&text, prompt.selected)
}
ReviewPromptKind::AddCommitPicker => {
self.submit_add_commit_picker(&text, prompt.selected)
}
ReviewPromptKind::AddCommitSource => self.submit_add_commit_source(&text),
ReviewPromptKind::AddCommitRangeBasePicker => {
self.submit_add_commit_range_base_picker(&text, prompt.selected)
}
ReviewPromptKind::AddCommitRangeHeadPicker { base_rev } => {
self.submit_add_commit_range_head_picker(&base_rev, &text, prompt.selected)
}
ReviewPromptKind::AddCommitRangeSource => self.submit_add_commit_range_source(&text),
ReviewPromptKind::AddBranchCompareBasePicker => {
self.submit_add_branch_compare_base_picker(&text, prompt.selected)
}
ReviewPromptKind::AddBranchCompareHeadPicker { base_branch } => {
self.submit_add_branch_compare_head_picker(&base_branch, &text, prompt.selected)
}
ReviewPromptKind::AddBranchCompareSource => {
self.submit_add_branch_compare_source(&text)
}
ReviewPromptKind::AddFileSourcePicker => {
self.submit_add_file_source_picker(&text, prompt.selected)
}
ReviewPromptKind::AddFileRangePathPicker => {
self.submit_add_file_range_path_picker(&text, prompt.selected)
}
ReviewPromptKind::AddFileRangeSource => self.submit_add_file_range_source(&text),
ReviewPromptKind::EditSourceSpec { target_kind } => {
self.submit_edit_source_spec(target_kind, &text)
}
ReviewPromptKind::RenameWorkspace => self.submit_rename_workspace(&text),
ReviewPromptKind::RenameSource => self.submit_rename_source(&text),
}
}
fn start_add_source_kind(&mut self, kind: AddSourceMenuKind) -> bool {
match kind {
AddSourceMenuKind::File => self.open_add_file_source_picker(),
AddSourceMenuKind::FileRange => self.open_add_file_range_path_picker(),
AddSourceMenuKind::Commit => self.open_add_commit_picker(),
AddSourceMenuKind::CommitRange => self.open_add_commit_range_base_picker(),
AddSourceMenuKind::BranchCompare => self.open_add_branch_compare_base_picker(),
AddSourceMenuKind::Staged => self.push_workspace_source(ReviewSourceKind::IndexStaged),
AddSourceMenuKind::Unstaged => {
self.push_workspace_source(ReviewSourceKind::WorkingTreeUnstaged)
}
AddSourceMenuKind::WorkingTree => {
self.push_workspace_source(ReviewSourceKind::WorkingTreeAndIndex)
}
AddSourceMenuKind::LastCommit => {
self.push_workspace_source(ReviewSourceKind::LastCommit)
}
AddSourceMenuKind::Repository => {
self.push_workspace_source(ReviewSourceKind::Repository)
}
AddSourceMenuKind::Advanced => {
self.prompt_state = Some(ReviewPromptState::new(
ReviewPromptKind::AddAdvancedSourceKind,
));
self.status_message = Some("choose an advanced review source".to_string());
true
}
}
}
fn submit_add_advanced_source_kind(&mut self, text: &str, selected: usize) -> bool {
if text.trim().is_empty()
&& let Some(kind) = advanced_source_menu_items()
.get(selected)
.map(|item| item.kind)
{
return self.start_add_source_kind(kind);
}
match text.trim().to_ascii_lowercase().as_str() {
"file-range" | "range-file" | "fr" => {
self.start_add_source_kind(AddSourceMenuKind::FileRange)
}
"commit" | "c" => self.start_add_source_kind(AddSourceMenuKind::Commit),
"range" | "commit-range" | "r" => {
self.start_add_source_kind(AddSourceMenuKind::CommitRange)
}
"branch" | "branch-compare" | "compare" | "bc" => {
self.start_add_source_kind(AddSourceMenuKind::BranchCompare)
}
_ => {
self.prompt_state = Some(ReviewPromptState::new(
ReviewPromptKind::AddAdvancedSourceKind,
));
self.status_message = Some(
"advanced source must be branch, commit, range, or file-range".to_string(),
);
true
}
}
}
fn submit_add_source_kind(&mut self, text: &str, selected: usize) -> bool {
if text.trim().is_empty()
&& let Some(kind) = add_source_menu_items().get(selected).map(|item| item.kind)
{
return self.start_add_source_kind(kind);
}
match text.trim().to_ascii_lowercase().as_str() {
"file" | "f" => return self.start_add_source_kind(AddSourceMenuKind::File),
"advanced" | "advanced…" => {
return self.start_add_source_kind(AddSourceMenuKind::Advanced);
}
"file-range" | "range-file" | "fr" => {
return self.start_add_source_kind(AddSourceMenuKind::FileRange);
}
"commit" | "c" => return self.start_add_source_kind(AddSourceMenuKind::Commit),
"range" | "commit-range" | "r" => {
return self.start_add_source_kind(AddSourceMenuKind::CommitRange);
}
"branch" | "branch-compare" | "compare" | "bc" => {
return self.start_add_source_kind(AddSourceMenuKind::BranchCompare);
}
"staged" | "index" => return self.start_add_source_kind(AddSourceMenuKind::Staged),
"unstaged" => return self.start_add_source_kind(AddSourceMenuKind::Unstaged),
"working-tree" | "worktree" | "working" | "all" => {
return self.start_add_source_kind(AddSourceMenuKind::WorkingTree);
}
"last" | "last-commit" => {
return self.start_add_source_kind(AddSourceMenuKind::LastCommit);
}
"repo" | "repository" => {
return self.start_add_source_kind(AddSourceMenuKind::Repository);
}
_ => {
self.status_message = Some(
"unknown source kind; use file, file-range, commit, range, branch, staged, unstaged, working-tree".to_string(),
);
}
}
true
}
fn validate_non_empty_pair(&mut self, left: &str, right: &str, message: &str) -> bool {
if left.trim().is_empty() || right.trim().is_empty() {
self.status_message = Some(message.to_string());
return false;
}
true
}
fn submit_add_commit_picker(&mut self, query: &str, selected: usize) -> bool {
let matches = self.repository_commit_picker_matches(query);
let Some(commit) = matches
.get(selected)
.cloned()
.or_else(|| matches.first().cloned())
else {
self.status_message = Some(format!("no commit matches `{query}`"));
return true;
};
self.push_workspace_source(ReviewSourceKind::Commit { rev: commit.rev })
}
fn submit_add_commit_source(&mut self, text: &str) -> bool {
let rev = text.trim();
if rev.is_empty() {
self.status_message = Some("enter a commit revision".to_string());
return true;
}
self.push_workspace_source(ReviewSourceKind::Commit {
rev: rev.to_string(),
})
}
fn submit_add_commit_range_base_picker(&mut self, query: &str, selected: usize) -> bool {
let matches = self.repository_commit_picker_matches(query);
let Some(commit) = matches
.get(selected)
.cloned()
.or_else(|| matches.first().cloned())
else {
self.status_message = Some(format!("no commit matches `{query}`"));
return true;
};
self.prompt_state = Some(ReviewPromptState::new(
ReviewPromptKind::AddCommitRangeHeadPicker {
base_rev: commit.rev,
},
));
self.status_message = Some("add commit range: pick head commit".to_string());
true
}
fn submit_add_commit_range_head_picker(
&mut self,
base_rev: &str,
query: &str,
selected: usize,
) -> bool {
let matches = self.repository_commit_picker_matches(query);
let Some(commit) = matches
.get(selected)
.cloned()
.or_else(|| matches.first().cloned())
else {
self.status_message = Some(format!("no commit matches `{query}`"));
return true;
};
if base_rev == commit.rev {
self.status_message = Some("base and head commit must differ".to_string());
return true;
}
self.push_workspace_source(ReviewSourceKind::CommitRange {
base: base_rev.to_string(),
head: commit.rev,
merge_base: true,
})
}
fn submit_add_commit_range_source(&mut self, text: &str) -> bool {
let Some((base, head, merge_base)) = parse_range_spec(text) else {
self.status_message = Some("enter range as base..head or base...head".to_string());
return true;
};
if !self.validate_non_empty_pair(base, head, "range endpoints cannot be empty") {
return true;
}
self.push_workspace_source(ReviewSourceKind::CommitRange {
base: base.trim().to_string(),
head: head.trim().to_string(),
merge_base,
})
}
fn submit_add_branch_compare_source(&mut self, text: &str) -> bool {
let Some((base_branch, head_branch, merge_base)) = parse_range_spec(text) else {
self.status_message =
Some("enter branch compare as base..head or base...head".to_string());
return true;
};
if !self.validate_non_empty_pair(base_branch, head_branch, "branch names cannot be empty") {
return true;
}
self.push_workspace_source(ReviewSourceKind::BranchCompare {
base_branch: base_branch.trim().to_string(),
head_branch: head_branch.trim().to_string(),
merge_base,
})
}
fn submit_add_branch_compare_base_picker(&mut self, query: &str, selected: usize) -> bool {
let matches = self.repository_branch_picker_matches(query);
let Some(base_branch) = matches
.get(selected)
.cloned()
.or_else(|| matches.first().cloned())
else {
self.status_message = Some(format!("no branch matches `{query}`"));
return true;
};
self.prompt_state = Some(ReviewPromptState::new(
ReviewPromptKind::AddBranchCompareHeadPicker { base_branch },
));
self.status_message = Some("add branch compare: pick head branch".to_string());
true
}
fn submit_add_branch_compare_head_picker(
&mut self,
base_branch: &str,
query: &str,
selected: usize,
) -> bool {
let matches = self.repository_branch_picker_matches(query);
let Some(head_branch) = matches
.get(selected)
.cloned()
.or_else(|| matches.first().cloned())
else {
self.status_message = Some(format!("no branch matches `{query}`"));
return true;
};
if base_branch == head_branch {
self.status_message = Some("base and head branch must differ".to_string());
return true;
}
self.push_workspace_source(ReviewSourceKind::BranchCompare {
base_branch: base_branch.to_string(),
head_branch,
merge_base: true,
})
}
fn submit_add_file_source_picker(&mut self, query: &str, selected: usize) -> bool {
let matches = self.repository_file_picker_matches(query);
let Some(path) = matches
.get(selected)
.cloned()
.or_else(|| matches.first().cloned())
else {
self.status_message = Some(format!("no file matches `{query}`"));
return true;
};
self.push_workspace_source(ReviewSourceKind::File { path })
}
fn submit_add_file_range_path_picker(&mut self, query: &str, selected: usize) -> bool {
let matches = self.repository_file_picker_matches(query);
let Some(path) = matches
.get(selected)
.cloned()
.or_else(|| matches.first().cloned())
else {
self.status_message = Some(format!("no file matches `{query}`"));
return true;
};
let mut prompt = ReviewPromptState::new(ReviewPromptKind::AddFileRangeSource);
prompt.buffer.insert_str(&format!("{path}:"));
self.prompt_state = Some(prompt);
self.status_message = Some("add file range: enter start-end".to_string());
true
}
fn submit_add_file_range_source(&mut self, text: &str) -> bool {
let Some((path, start, end)) = parse_file_range_spec(text) else {
self.status_message = Some("enter file range as path:start-end".to_string());
return true;
};
self.push_workspace_source(ReviewSourceKind::FileRange { path, start, end })
}
fn sync_review_workspace(&mut self) {
self.review.workspace = Some(self.workspace.clone());
}
fn next_source_id(&self) -> String {
let mut next = self.workspace.sources.len().saturating_add(1);
loop {
let source_id = format!("source-{next}");
if self
.workspace
.sources
.iter()
.all(|source| source.id != source_id)
{
return source_id;
}
next = next.saturating_add(1);
}
}
fn push_workspace_source(&mut self, kind: ReviewSourceKind) -> bool {
if matches!(kind, ReviewSourceKind::Repository)
&& self
.workspace
.sources
.iter()
.any(|source| matches!(source.kind, ReviewSourceKind::Repository))
{
self.status_message = Some("repository source is already included".to_string());
return true;
}
if self
.workspace
.sources
.iter()
.any(|source| source.kind == kind)
{
self.status_message = Some(format!("{} is already included", kind.label()));
return true;
}
let label = kind.label();
let source_id = self.next_source_id();
self.workspace.sources.push(ReviewSource {
id: source_id,
kind,
label: label.clone(),
included: true,
});
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
self.status_message = Some(format!("added {label}"));
true
}
fn replace_selected_source_kind(&mut self, kind: ReviewSourceKind) -> bool {
let Some(index) = self.selected_build_source_index() else {
self.status_message = Some("select an editable source to change".to_string());
return true;
};
if self
.workspace
.sources
.iter()
.enumerate()
.any(|(source_index, source)| source_index != index && source.kind == kind)
{
self.status_message = Some(format!("{} is already included", kind.label()));
return true;
}
let label = kind.label();
let Some(source) = self.workspace.sources.get_mut(index) else {
self.status_message = Some("select an editable source to change".to_string());
return true;
};
source.kind = kind;
source.label.clone_from(&label);
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
self.status_message = Some(format!("updated source to {label}"));
true
}
fn submit_edit_source_spec(&mut self, target_kind: EditSourceTargetKind, text: &str) -> bool {
match target_kind {
EditSourceTargetKind::Commit => self.submit_edit_commit_source(text),
EditSourceTargetKind::CommitRange => self.submit_edit_commit_range_source(text),
EditSourceTargetKind::BranchCompare => self.submit_edit_branch_compare_source(text),
EditSourceTargetKind::File => self.submit_edit_file_source(text),
EditSourceTargetKind::FileRange => self.submit_edit_file_range_source(text),
}
}
fn submit_edit_commit_source(&mut self, text: &str) -> bool {
let rev = text.trim();
if rev.is_empty() {
self.status_message = Some("commit revision cannot be empty".to_string());
return true;
}
self.replace_selected_source_kind(ReviewSourceKind::Commit {
rev: rev.to_string(),
})
}
fn submit_edit_commit_range_source(&mut self, text: &str) -> bool {
let Some((base, head, merge_base)) = parse_range_spec(text) else {
self.status_message = Some("enter range as base..head or base...head".to_string());
return true;
};
if !self.validate_non_empty_pair(base, head, "range endpoints cannot be empty") {
return true;
}
self.replace_selected_source_kind(ReviewSourceKind::CommitRange {
base: base.trim().to_string(),
head: head.trim().to_string(),
merge_base,
})
}
fn submit_edit_branch_compare_source(&mut self, text: &str) -> bool {
let Some((base_branch, head_branch, merge_base)) = parse_range_spec(text) else {
self.status_message =
Some("enter branch compare as base..head or base...head".to_string());
return true;
};
if !self.validate_non_empty_pair(
base_branch,
head_branch,
"branch compare endpoints cannot be empty",
) {
return true;
}
self.replace_selected_source_kind(ReviewSourceKind::BranchCompare {
base_branch: base_branch.trim().to_string(),
head_branch: head_branch.trim().to_string(),
merge_base,
})
}
fn submit_edit_file_source(&mut self, text: &str) -> bool {
let path = text.trim();
if path.is_empty() {
self.status_message = Some("file path cannot be empty".to_string());
return true;
}
self.replace_selected_source_kind(ReviewSourceKind::File {
path: path.to_string(),
})
}
fn submit_edit_file_range_source(&mut self, text: &str) -> bool {
let Some((path, start, end)) = parse_file_range_spec(text) else {
self.status_message = Some("enter file range as path:start-end".to_string());
return true;
};
self.replace_selected_source_kind(ReviewSourceKind::FileRange { path, start, end })
}
fn submit_rename_workspace(&mut self, text: &str) -> bool {
let title = text.trim();
if title.is_empty() {
self.status_message = Some("workspace title cannot be empty".to_string());
return true;
}
self.workspace.title = title.to_string();
self.review.workspace = Some(self.workspace.clone());
self.sync_review_workspace();
self.pending_workspace_save = true;
self.status_message = Some(format!("renamed workspace to {title}"));
true
}
fn submit_rename_source(&mut self, text: &str) -> bool {
let label = text.trim();
if label.is_empty() {
self.status_message = Some("source label cannot be empty".to_string());
return true;
}
let Some(index) = self.selected_build_source_index() else {
self.status_message = Some("select an included source to rename".to_string());
return true;
};
let Some(source) = self.workspace.sources.get_mut(index) else {
self.status_message = Some("select an included source to rename".to_string());
return true;
};
source.label = label.to_string();
self.sync_review_workspace();
self.pending_workspace_save = true;
self.status_message = Some(format!("renamed source to {label}"));
true
}
fn submit_file_picker(&mut self, query: &str, selected: usize) -> bool {
let matches = self.file_picker_matches(query);
let Some(index) = matches
.get(selected)
.copied()
.or_else(|| matches.first().copied())
else {
self.status_message = Some(format!("no file matches `{query}`"));
return true;
};
self.select_file(index);
self.status_message = Some(format!(
"opened {}",
self.review.files[index].display_path()
));
true
}
fn submit_jump_to_line(&mut self, text: &str) -> bool {
let Ok(line) = text.parse::<usize>() else {
self.status_message = Some(format!("invalid line number `{text}`"));
return true;
};
self.select_diff_line(line.saturating_sub(1));
self.status_message = Some(format!("jumped to line {line}"));
true
}
fn submit_file_search(&mut self, query: &str) -> bool {
if query.is_empty() {
self.status_message = Some("empty search query".to_string());
return true;
}
let Some(file) = self.selected_file_data() else {
return false;
};
let Some(cached) = self.file_cache.get(file.display_path()) else {
self.status_message = Some("current file is not loaded".to_string());
return true;
};
let next = find_file_match(
cached,
query,
self.selected_diff_line,
SearchDirection::Next,
);
match next {
Some(index) => {
self.last_search_query = Some(query.to_string());
self.select_diff_line(index);
self.status_message = Some(format!("found `{query}`"));
}
None => self.status_message = Some(format!("no match for `{query}`")),
}
true
}
/// Return true when file search has an active query.
#[must_use]
pub const fn has_active_search(&self) -> bool {
self.last_search_query.is_some()
}
/// Jump to next current-file search match.
pub fn search_next_match(&mut self) -> bool {
self.search_match(SearchDirection::Next)
}
/// Jump to previous current-file search match.
pub fn search_previous_match(&mut self) -> bool {
self.search_match(SearchDirection::Previous)
}
fn search_match(&mut self, direction: SearchDirection) -> bool {
let Some(query) = self.last_search_query.clone() else {
self.status_message = Some("no active search; press / first".to_string());
return true;
};
let Some(file) = self.selected_file_data() else {
return false;
};
let Some(cached) = self.file_cache.get(file.display_path()) else {
self.status_message = Some("current file is not loaded".to_string());
return true;
};
match find_file_match(cached, &query, self.selected_diff_line, direction) {
Some(index) => {
self.select_diff_line(index);
self.status_message = Some(format!("found `{query}`"));
}
None => self.status_message = Some(format!("no match for `{query}`")),
}
true
}
/// Move prompt selected row down.
pub fn move_prompt_selection_down(&mut self) -> bool {
let Some(prompt) = &self.prompt_state else {
return false;
};
if !matches!(
prompt.kind,
ReviewPromptKind::FilePicker
| ReviewPromptKind::AddSourceKind
| ReviewPromptKind::AddAdvancedSourceKind
| ReviewPromptKind::AddCommitPicker
| ReviewPromptKind::AddCommitRangeBasePicker
| ReviewPromptKind::AddCommitRangeHeadPicker { .. }
| ReviewPromptKind::AddFileSourcePicker
| ReviewPromptKind::AddFileRangePathPicker
| ReviewPromptKind::AddBranchCompareBasePicker
| ReviewPromptKind::AddBranchCompareHeadPicker { .. }
) {
return false;
}
let max = match &prompt.kind {
ReviewPromptKind::AddSourceKind => add_source_menu_items().len().saturating_sub(1),
ReviewPromptKind::AddAdvancedSourceKind => {
advanced_source_menu_items().len().saturating_sub(1)
}
ReviewPromptKind::AddCommitPicker
| ReviewPromptKind::AddCommitRangeBasePicker
| ReviewPromptKind::AddCommitRangeHeadPicker { .. } => self
.repository_commit_picker_matches(prompt.buffer.text())
.len()
.saturating_sub(1),
ReviewPromptKind::AddFileSourcePicker | ReviewPromptKind::AddFileRangePathPicker => {
self.repository_file_picker_matches(prompt.buffer.text())
.len()
.saturating_sub(1)
}
ReviewPromptKind::AddBranchCompareBasePicker
| ReviewPromptKind::AddBranchCompareHeadPicker { .. } => self
.repository_branch_picker_matches(prompt.buffer.text())
.len()
.saturating_sub(1),
_ => self
.file_picker_matches(prompt.buffer.text())
.len()
.saturating_sub(1),
};
let Some(prompt) = &mut self.prompt_state else {
return false;
};
prompt.selected = prompt.selected.saturating_add(1).min(max);
true
}
/// Move prompt selected row up.
pub const fn move_prompt_selection_up(&mut self) -> bool {
let Some(prompt) = &mut self.prompt_state else {
return false;
};
if !matches!(
prompt.kind,
ReviewPromptKind::FilePicker
| ReviewPromptKind::AddSourceKind
| ReviewPromptKind::AddAdvancedSourceKind
| ReviewPromptKind::AddCommitPicker
| ReviewPromptKind::AddCommitRangeBasePicker
| ReviewPromptKind::AddCommitRangeHeadPicker { .. }
| ReviewPromptKind::AddFileSourcePicker
| ReviewPromptKind::AddFileRangePathPicker
| ReviewPromptKind::AddBranchCompareBasePicker
| ReviewPromptKind::AddBranchCompareHeadPicker { .. }
) {
return false;
}
prompt.selected = prompt.selected.saturating_sub(1);
true
}
/// Return file picker matches for a query.
#[must_use]
pub fn file_picker_matches(&self, query: &str) -> Vec<usize> {
let query = query.to_lowercase();
self.review
.files
.iter()
.enumerate()
.filter_map(|(index, file)| {
let path = file.display_path().to_lowercase();
(query.is_empty() || path.contains(&query)).then_some(index)
})
.take(12)
.collect()
}
/// Return repository file matches for source pickers.
#[must_use]
pub fn repository_file_picker_matches(&self, query: &str) -> Vec<String> {
let query = query.to_lowercase();
self.review
.repository_files
.iter()
.filter(|path| query.is_empty() || path.to_lowercase().contains(&query))
.take(12)
.cloned()
.collect()
}
/// Return repository commit matches for source pickers.
#[must_use]
pub fn repository_commit_picker_matches(&self, query: &str) -> Vec<ReviewRepositoryCommit> {
let query = query.to_lowercase();
self.review
.repository_commits
.iter()
.filter(|commit| {
query.is_empty()
|| commit.rev.to_lowercase().contains(&query)
|| commit.short_rev.to_lowercase().contains(&query)
|| commit.subject.to_lowercase().contains(&query)
})
.take(12)
.cloned()
.collect()
}
/// Return repository branch matches for source pickers.
#[must_use]
pub fn repository_branch_picker_matches(&self, query: &str) -> Vec<String> {
let query = query.to_lowercase();
self.review
.repository_branches
.iter()
.filter(|branch| query.is_empty() || branch.to_lowercase().contains(&query))
.take(12)
.cloned()
.collect()
}
fn review_item_count(&self) -> usize {
self.review.files.len().max(self.review.surfaces().len())
}
/// Return visible file-tree rows for repository review.
#[must_use]
pub fn file_tree_rows(&self) -> Vec<ReviewFileTreeRow> {
let mut rows = Vec::new();
self.push_tree_rows(Path::new(""), 0, &mut rows);
rows
}
fn push_tree_rows(&self, prefix: &Path, depth: usize, rows: &mut Vec<ReviewFileTreeRow>) {
let mut dirs = BTreeSet::new();
let mut files = Vec::new();
for index in 0..self.review_item_count() {
let Some(path) = self.review_path_for_index(index) else {
continue;
};
let path = Path::new(&path);
let rest = if prefix.as_os_str().is_empty() {
path
} else if let Ok(rest) = path.strip_prefix(prefix) {
rest
} else {
continue;
};
let mut components = rest.components();
let Some(first) = components.next() else {
continue;
};
if components.next().is_some() {
dirs.insert(prefix.join(first.as_os_str()));
} else {
files.push(index);
}
}
for dir in dirs {
rows.push(ReviewFileTreeRow::Directory {
path: dir.clone(),
depth,
});
if self.expanded_dirs.contains(&dir) {
self.push_tree_rows(&dir, depth.saturating_add(1), rows);
}
}
for index in files {
rows.push(ReviewFileTreeRow::File { index, depth });
}
}
/// Activate the selected repository tree row.
pub fn activate_selected_tree_row(&mut self) -> bool {
let rows = self.file_tree_rows();
match rows.get(self.selected_tree_row).cloned() {
Some(ReviewFileTreeRow::Directory { path, .. }) => {
self.toggle_file_tree_directory(&path)
}
Some(ReviewFileTreeRow::File { index, .. }) => self.select_file(index),
None => false,
}
}
/// Activate a repository tree row by absolute rendered tree index.
pub fn activate_tree_row(&mut self, index: usize) -> bool {
self.selected_tree_row = index.min(self.file_tree_rows().len().saturating_sub(1));
self.activate_selected_tree_row()
}
/// Toggle a directory in the repository sidebar.
pub fn toggle_file_tree_directory(&mut self, path: &Path) -> bool {
if self.expanded_dirs.remove(path) {
return true;
}
self.expanded_dirs.insert(path.to_path_buf());
true
}
/// Clear registered mouse hit regions for a new frame.
pub fn clear_mouse_regions(&mut self) {
self.mouse_regions.clear();
}
/// Register a mouse hit region.
pub fn register_mouse_region(
&mut self,
rect: Rect,
action: ReviewMouseAction,
label: &'static str,
) {
if rect.is_empty() {
return;
}
self.mouse_regions.push(ReviewMouseRegion {
rect,
action,
label,
});
}
/// Discard portions of hit regions outside the painted frame.
pub fn confine_mouse_regions(&mut self, area: Rect) {
self.mouse_regions.retain_mut(|region| {
let x = region.rect.x.max(area.x);
let y = region.rect.y.max(area.y);
let right = region.rect.right().min(area.right());
let bottom = region.rect.bottom().min(area.bottom());
region.rect = Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y));
!region.rect.is_empty()
});
}
/// Return registered mouse action at terminal coordinates.
#[must_use]
pub fn mouse_action_at(&self, x: u16, y: u16) -> Option<ReviewMouseAction> {
self.mouse_regions
.iter()
.rev()
.find(|region| {
x >= region.rect.x
&& x < region.rect.right()
&& y >= region.rect.y
&& y < region.rect.bottom()
})
.map(|region| region.action)
}
/// Activate a registered mouse action.
pub fn activate_mouse_action(&mut self, action: ReviewMouseAction) -> bool {
match action {
ReviewMouseAction::SidebarMode(mode) => self.set_sidebar_mode(mode),
ReviewMouseAction::ThreadFilter(filter) => self.set_thread_filter(filter),
ReviewMouseAction::ShowDraftThreads => self.show_draft_threads(),
ReviewMouseAction::ShowAllThreads => self.show_all_threads(),
ReviewMouseAction::ShowOpenThreads => self.show_open_threads(),
ReviewMouseAction::ShowResolvedThreads => self.show_resolved_threads(),
ReviewMouseAction::CycleThreadFilter => self.cycle_thread_filter(),
ReviewMouseAction::SelectThread(index) => {
let selected = self.select_thread(index);
self.jump_to_selected_thread() || selected
}
ReviewMouseAction::SelectFile(index) => self.select_visible_file(index),
ReviewMouseAction::ToggleFileViewed(index) => {
if !self.select_visible_file(index) && index >= self.visible_file_indices().len() {
return false;
}
self.toggle_selected_file_viewed()
}
ReviewMouseAction::ActivateTreeRow(index) => self.activate_tree_row(index),
ReviewMouseAction::JumpSelectedThread => self.jump_to_selected_thread(),
ReviewMouseAction::NewReviewComment => self.open_review_comment_editor(),
ReviewMouseAction::FileComment => self.open_file_comment_editor(),
ReviewMouseAction::Publish => self.publish_review(),
ReviewMouseAction::AskBcode => {
self.open_comment_editor_with_action(ReviewCommentAction::AskBcode)
}
ReviewMouseAction::ReplyThread => self.open_comment_editor(),
ReviewMouseAction::ToggleThreadResolved => self.toggle_selected_thread_resolved(),
ReviewMouseAction::EditThread => self.open_latest_draft_editor(),
ReviewMouseAction::DeleteThread => self.delete_latest_draft_at_selection(),
ReviewMouseAction::OpenThreadSession => self.open_linked_session_at_selection(),
ReviewMouseAction::CommentAction(action) => self.submit_comment_editor_action(action),
ReviewMouseAction::CancelComment => {
self.comment_editor = None;
self.status_message = Some("comment draft canceled".to_string());
true
}
}
}
/// Return the last rendered diff area, if known.
#[must_use]
pub const fn diff_area(&self) -> Option<Rect> {
self.last_diff_area
}
/// Return the visual row for the selected review target.
#[must_use]
pub fn selected_visual_row(&self) -> usize {
if let Some(target) = &self.selected_view_target
&& let Some(document) = self.current_review_view_document()
&& let Some(row) = document.rows.iter().find(|row| &row.target == target)
{
return row.visual_row;
}
self.selected_diff_line
}
/// Return true when an inline-review publisher is currently available.
#[must_use]
pub fn has_inline_comment_publisher(&self) -> bool {
self.publishers
.iter()
.any(|publisher| publisher.capabilities.supports_inline_comments)
}
/// Set sidebar mode directly.
pub fn set_sidebar_mode(&mut self, mode: ReviewSidebarMode) -> bool {
if self.sidebar_mode == mode {
return false;
}
self.sidebar_mode = mode;
self.selected_thread = 0;
self.thread_scroll = 0;
self.status_message = Some(format!("sidebar: {}", mode.label()));
true
}
/// Set thread filter directly.
pub fn set_thread_filter(&mut self, filter: ReviewThreadFilter) -> bool {
self.sidebar_visible = true;
self.sidebar_mode = ReviewSidebarMode::Threads;
self.show_resolved_threads = filter != ReviewThreadFilter::Open;
if self.thread_filter == filter {
return false;
}
self.thread_filter = filter;
self.selected_thread = 0;
self.thread_scroll = 0;
self.status_message = Some(format!("showing {} review threads", filter.label()));
true
}
/// Store the current file hit area.
pub const fn set_file_area(&mut self, area: Option<Rect>) {
self.last_file_area = area;
}
/// Store the current diff hit area.
pub fn set_diff_area(&mut self, area: Rect) {
if self.last_diff_area == Some(area) {
return;
}
let text_anchor = self
.current_review_view_document()
.and_then(|document| document.text_anchor(self.diff_scroll));
let anchor = self.current_review_view_document().and_then(|document| {
let target = document.target_for_visual_row(self.diff_scroll)?.clone();
let first = document.visual_row_for_target(&target)?;
Some((target, self.diff_scroll.saturating_sub(first)))
});
self.last_diff_area = Some(area);
if let Some((target, offset)) = anchor
&& let Some(document) = self.current_review_view_document()
&& let Some(first) = document.visual_row_for_target(&target)
{
let count = document
.rows
.iter()
.skip(first)
.take_while(|row| row.target == target)
.count();
self.diff_scroll = first.saturating_add(offset.min(count.saturating_sub(1)));
}
if let Some((target, id, byte)) = text_anchor
&& let Some(document) = self.current_review_view_document()
&& let Some(row) = document.row_for_text_anchor(&target, &id, byte)
{
self.diff_scroll = row;
}
self.diff_scroll = self.diff_scroll.min(self.max_diff_scroll());
}
/// Return currently selected surface.
#[must_use]
pub fn selected_surface(&self) -> Option<ReviewSurface> {
self.review.surfaces().get(self.selected_file).cloned()
}
fn selected_surface_ids(&self) -> (Option<String>, Option<String>) {
self.selected_surface().map_or((None, None), |surface| {
(Some(surface.id), Some(surface.source_id))
})
}
/// Return currently selected file.
#[must_use]
pub fn selected_file_data(&self) -> Option<&ReviewFile> {
self.review.files.get(self.selected_file)
}
/// Return review file/surface path for an index.
#[must_use]
pub fn review_path_for_index(&self, index: usize) -> Option<String> {
self.review
.files
.get(index)
.map(|file| file.display_path().to_string())
.or_else(|| {
self.review
.surfaces()
.get(index)
.map(|surface| surface.path.clone())
})
}
/// Return currently selected file path.
#[must_use]
pub fn selected_file_path(&self) -> Option<String> {
self.selected_file_data()
.map(|file| file.display_path().to_string())
.or_else(|| self.selected_surface().map(|surface| surface.path))
}
/// Replace review content after rematerialization.
pub fn replace_review(&mut self, review: ReviewSummary) {
self.save_current_file_viewport();
let previous_path = self.selected_file_path();
let workspace = review.workspace();
self.review = review;
self.workspace = workspace;
self.sync_review_workspace();
self.selected_file = previous_path
.as_deref()
.and_then(|path| self.review_file_index_for_path(path))
.unwrap_or(self.selected_file)
.min(self.review_item_count().saturating_sub(1));
self.selected_build_row = self
.selected_build_row
.min(self.build_row_count().saturating_sub(1));
self.build_scroll = self.build_scroll.min(self.selected_build_row);
self.file_scroll = self
.file_scroll
.min(self.review.files.len().saturating_sub(1));
self.tree_scroll = self
.tree_scroll
.min(self.file_tree_rows().len().saturating_sub(1));
self.restore_current_file_viewport();
self.queue_selected_file_load();
}
/// Focus the attention sidebar.
pub fn show_attention_sidebar(&mut self) -> bool {
self.sidebar_mode = ReviewSidebarMode::NeedsAttention;
self.sidebar_visible = true;
self.selected_thread = self
.selected_thread
.min(self.visible_thread_summaries().len().saturating_sub(1));
self.thread_scroll = self.thread_scroll.min(self.selected_thread);
self.status_message = Some("sidebar: attention".to_string());
true
}
/// Toggle sidebar between included, repository, threads, sources, and attention.
pub fn toggle_sidebar_mode(&mut self) -> bool {
self.sidebar_mode = match self.sidebar_mode {
ReviewSidebarMode::Included => ReviewSidebarMode::Repository,
ReviewSidebarMode::Repository => ReviewSidebarMode::Threads,
ReviewSidebarMode::Threads => ReviewSidebarMode::General,
ReviewSidebarMode::General => ReviewSidebarMode::Summary,
ReviewSidebarMode::Summary => ReviewSidebarMode::Sources,
ReviewSidebarMode::Sources => ReviewSidebarMode::NeedsAttention,
ReviewSidebarMode::NeedsAttention => ReviewSidebarMode::Included,
};
self.sidebar_visible = true;
self.status_message = Some(format!("sidebar: {}", self.sidebar_mode.label()));
true
}
/// Move the active selection down.
pub fn move_down(&mut self, rows: usize) -> bool {
if self.ux_mode == ReviewUxMode::Build {
self.select_next_build_row(rows)
} else if matches!(
self.sidebar_mode,
ReviewSidebarMode::Threads
| ReviewSidebarMode::General
| ReviewSidebarMode::NeedsAttention
) && self.sidebar_visible
{
self.select_next_thread(rows)
} else if self.review.is_repository_review()
&& self.sidebar_mode == ReviewSidebarMode::Repository
&& self.sidebar_visible
{
self.select_next_tree_row(rows)
} else {
self.select_next_view_row(rows)
}
}
/// Move the active selection up.
pub fn move_up(&mut self, rows: usize) -> bool {
if self.ux_mode == ReviewUxMode::Build {
self.select_previous_build_row(rows)
} else if matches!(
self.sidebar_mode,
ReviewSidebarMode::Threads
| ReviewSidebarMode::General
| ReviewSidebarMode::NeedsAttention
) && self.sidebar_visible
{
self.select_previous_thread(rows)
} else if self.review.is_repository_review()
&& self.sidebar_mode == ReviewSidebarMode::Repository
&& self.sidebar_visible
{
self.select_previous_tree_row(rows)
} else {
self.select_previous_view_row(rows)
}
}
#[must_use]
pub fn source_diagnostics(&self, source_id: &str) -> Vec<&ReviewSourceDiagnostic> {
self.review
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.source_id == source_id)
.collect()
}
fn has_materialization_errors(&self) -> bool {
self.review
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == ReviewSourceDiagnosticSeverity::Error)
}
/// Count included workspace sources.
#[must_use]
pub fn included_source_count(&self) -> usize {
self.workspace
.sources
.iter()
.filter(|source| source.included)
.count()
}
/// Count workspace sources by diagnostic severity.
#[must_use]
pub fn diagnostic_source_counts(&self) -> (usize, usize, usize) {
let mut info_sources = BTreeSet::new();
let mut warning_sources = BTreeSet::new();
let mut error_sources = BTreeSet::new();
for diagnostic in &self.review.diagnostics {
match diagnostic.severity {
ReviewSourceDiagnosticSeverity::Info => {
info_sources.insert(diagnostic.source_id.clone());
}
ReviewSourceDiagnosticSeverity::Warning => {
warning_sources.insert(diagnostic.source_id.clone());
}
ReviewSourceDiagnosticSeverity::Error => {
error_sources.insert(diagnostic.source_id.clone());
}
}
}
(
info_sources.len(),
warning_sources.len(),
error_sources.len(),
)
}
/// Return number of rows in build mode.
#[must_use]
pub fn build_row_count(&self) -> usize {
self.workspace
.sources
.len()
.saturating_add(self.review.surfaces().len())
.max(1)
}
/// Select first build row.
pub fn select_first_build_row(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
self.set_selected_build_row(0);
true
}
/// Select last build row.
pub fn select_last_build_row(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
self.set_selected_build_row(self.build_row_count().saturating_sub(1));
true
}
/// Select next build row.
pub fn select_next_build_row(&mut self, rows: usize) -> bool {
if self.workspace.sources.is_empty() {
self.status_message = Some("no sources yet; press A or u/s/w/l to add one".to_string());
return true;
}
let max = self.build_row_count().saturating_sub(1);
let next = self.selected_build_row.saturating_add(rows).min(max);
if next == self.selected_build_row {
return false;
}
self.set_selected_build_row(next);
true
}
/// Select previous build row.
pub fn select_previous_build_row(&mut self, rows: usize) -> bool {
if self.workspace.sources.is_empty() {
self.status_message = Some("no sources yet; press A or u/s/w/l to add one".to_string());
return true;
}
let next = self.selected_build_row.saturating_sub(rows);
if next == self.selected_build_row {
return false;
}
self.set_selected_build_row(next);
true
}
fn set_selected_build_row(&mut self, row: usize) {
self.selected_build_row = row.min(self.build_row_count().saturating_sub(1));
if row == 0 {
self.build_scroll = 0;
} else {
self.build_scroll = self.build_scroll.min(self.selected_build_row);
}
}
fn selected_build_source_index(&self) -> Option<usize> {
(self.selected_build_row < self.workspace.sources.len()).then_some(self.selected_build_row)
}
fn selected_build_surface_index(&self) -> Option<usize> {
self.selected_build_row
.checked_sub(self.workspace.sources.len())
.filter(|index| *index < self.review.surfaces().len())
}
fn first_surface_index_for_source(&self, source_id: &str) -> Option<usize> {
self.review
.surfaces()
.iter()
.position(|surface| surface.source_id == source_id)
}
/// Activate review mode at the first surface for the selected source.
pub fn open_selected_source_surface(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let Some(source_index) = self.selected_build_source_index() else {
self.status_message = Some("select a source to open its first surface".to_string());
return true;
};
let Some(source_id) = self
.workspace
.sources
.get(source_index)
.map(|source| source.id.clone())
else {
self.status_message = Some("select a source to open its first surface".to_string());
return true;
};
let Some(surface_index) = self.first_surface_index_for_source(&source_id) else {
self.status_message = Some("selected source has no materialized surfaces".to_string());
return true;
};
self.ux_mode = ReviewUxMode::Review;
let _ = self.select_file(surface_index);
self.status_message = Some("opened source surface".to_string());
true
}
/// Select the build source for the current review surface.
pub fn select_source_for_current_surface(&mut self) -> bool {
let Some(source_id) = self.selected_surface().map(|surface| surface.source_id) else {
self.status_message = Some("current surface has no source".to_string());
return true;
};
let Some(source_index) = self
.workspace
.sources
.iter()
.position(|source| source.id == source_id)
else {
self.status_message =
Some("current surface source is no longer in workspace".to_string());
return true;
};
self.ux_mode = ReviewUxMode::Build;
self.set_selected_build_row(source_index);
self.status_message = Some("selected source for current surface".to_string());
true
}
/// Activate selected build-mode row.
pub fn activate_selected_build_row(&mut self) -> bool {
if self.selected_build_source_index().is_some() {
return self.toggle_selected_build_source();
}
let Some(surface_index) = self.selected_build_surface_index() else {
return false;
};
self.ux_mode = ReviewUxMode::Review;
let _ = self.select_file(surface_index);
self.status_message = Some("opened review surface".to_string());
true
}
/// Synchronize stable semantic presentation state into the durable workspace.
pub fn sync_presentation_state(&mut self) {
let selected_thread_key = match &self.selected_view_target {
Some(
ReviewViewTarget::Thread { thread_key }
| ReviewViewTarget::AgentThread { thread_key }
| ReviewViewTarget::Comment { thread_key, .. },
) => Some(thread_key.clone()),
_ => None,
};
let state = ReviewWorkspacePresentationState {
schema_version: bcode_code_review_models::REVIEW_WORKSPACE_PRESENTATION_SCHEMA_VERSION,
selected_path: self.selected_file_path(),
selected_thread_key,
selected_line: self
.selected_comment_anchor()
.and_then(|anchor| anchor.new_start.or(anchor.old_start)),
sidebar_mode: self.sidebar_mode.label().to_string(),
sidebar_visible: self.sidebar_visible,
thread_filter: self.thread_filter.label().to_string(),
show_resolved_threads: self.show_resolved_threads,
hide_viewed_files: self.hide_viewed_files,
collapsed_thread_keys: self.collapsed_review_threads.clone(),
expanded_agent_answer_keys: self.expanded_agent_answers.clone(),
};
if self.workspace.presentation_state.as_ref() != Some(&state) {
self.workspace.presentation_state = Some(state);
self.review.workspace = Some(self.workspace.clone());
self.presentation_state_dirty = true;
}
}
fn restore_presentation_state(&mut self) {
let Some(state) = self.workspace.presentation_state.clone() else {
return;
};
if state.schema_version
!= bcode_code_review_models::REVIEW_WORKSPACE_PRESENTATION_SCHEMA_VERSION
{
self.status_message = Some(format!(
"workspace presentation state version {} is unsupported; using default location",
state.schema_version
));
return;
}
self.sidebar_visible = state.sidebar_visible;
self.sidebar_mode = match state.sidebar_mode.as_str() {
"repo" => ReviewSidebarMode::Repository,
"threads" => ReviewSidebarMode::Threads,
"general" => ReviewSidebarMode::General,
"summary" => ReviewSidebarMode::Summary,
"sources" => ReviewSidebarMode::Sources,
"attention" => ReviewSidebarMode::NeedsAttention,
_ => ReviewSidebarMode::Included,
};
self.thread_filter = match state.thread_filter.as_str() {
"open" => ReviewThreadFilter::Open,
"resolved" => ReviewThreadFilter::Resolved,
_ => ReviewThreadFilter::All,
};
self.show_resolved_threads = state.show_resolved_threads;
self.hide_viewed_files = state.hide_viewed_files;
self.collapsed_review_threads = state.collapsed_thread_keys;
self.expanded_agent_answers = state.expanded_agent_answer_keys;
if let Some(path) = state.selected_path {
self.selected_file = self.review_file_index_for_path(&path).unwrap_or(0);
}
if let Some(line) = state.selected_line {
self.selected_diff_line = self
.current_review_view_document()
.and_then(|document| {
document.rows.iter().position(|row| match row.target {
ReviewViewTarget::SourceLine {
old_line, new_line, ..
} => new_line == Some(line) || old_line == Some(line),
_ => false,
})
})
.unwrap_or(0);
}
if let Some(thread_key) = state.selected_thread_key {
self.selected_view_target = Some(ReviewViewTarget::Thread { thread_key });
}
}
/// Take pending workspace save flag.
pub const fn take_pending_workspace_save(&mut self) -> bool {
let pending = self.pending_workspace_save;
self.pending_workspace_save = false;
pending
}
/// Take pending workspace reload flag.
pub const fn take_pending_workspace_reload(&mut self) -> bool {
let pending = self.pending_workspace_reload;
self.pending_workspace_reload = false;
pending
}
/// Rematerialize workspace content.
pub fn rematerialize_workspace(&mut self) -> bool {
self.pending_workspace_reload = true;
self.workspace_materialization_in_progress = true;
self.workspace_materialization_failed = false;
self.status_message = Some("materializing review sources…".to_string());
true
}
/// Add a quick source while in build mode.
pub fn add_quick_source(&mut self, kind: ReviewSourceKind) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
self.push_workspace_source(kind)
}
/// Add the selected file as an included workspace source.
pub fn add_selected_file_to_workspace(&mut self) -> bool {
let Some(path) = self
.selected_file_data()
.map(|file| file.display_path().to_string())
else {
self.status_message = Some("no selected file to add".to_string());
return true;
};
if self
.workspace
.sources
.iter()
.any(|source| matches!(&source.kind, ReviewSourceKind::File { path: source_path } if source_path == &path))
{
self.status_message = Some(format!("{path} is already included"));
return true;
}
self.push_workspace_source(ReviewSourceKind::File { path })
}
fn set_all_sources_included(&mut self, included: bool) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
if self.workspace.sources.is_empty() {
self.status_message = Some("no sources yet; press A or u/s/w/l to add one".to_string());
return true;
}
let mut changed = false;
for source in &mut self.workspace.sources {
if source.included != included {
source.included = included;
changed = true;
}
}
if !changed {
self.status_message = Some(if included {
"all sources are already included".to_string()
} else {
"all sources are already excluded".to_string()
});
return true;
}
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
self.status_message = Some(if included {
"included all sources".to_string()
} else {
"excluded all sources".to_string()
});
true
}
/// Include all workspace sources.
pub fn include_all_sources(&mut self) -> bool {
self.set_all_sources_included(true)
}
/// Exclude all workspace sources.
pub fn exclude_all_sources(&mut self) -> bool {
self.set_all_sources_included(false)
}
/// Invert source inclusion for all workspace sources.
pub fn invert_source_inclusion(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
if self.workspace.sources.is_empty() {
self.status_message = Some("no sources yet; press A or u/s/w/l to add one".to_string());
return true;
}
for source in &mut self.workspace.sources {
source.included = !source.included;
}
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
self.status_message = Some("inverted source inclusion".to_string());
true
}
fn source_has_surfaces(&self, source_id: &str) -> bool {
self.review
.surfaces()
.iter()
.any(|surface| surface.source_id == source_id)
}
fn select_empty_source(&mut self, forward: bool) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let source_count = self.workspace.sources.len();
if source_count == 0 {
self.status_message = Some("no sources yet; press A or u/s/w/l to add one".to_string());
return true;
}
let step = if forward {
1
} else {
source_count.saturating_sub(1)
};
let mut index = self.selected_build_row % source_count;
for _ in 0..source_count {
index = index.saturating_add(step) % source_count;
let source = &self.workspace.sources[index];
if source.included && !self.source_has_surfaces(&source.id) {
self.set_selected_build_row(index);
self.status_message = Some("selected included source with no surfaces".to_string());
return true;
}
}
self.status_message = Some("no included sources without surfaces".to_string());
true
}
/// Select next included source that materialized no surfaces.
pub fn select_next_empty_source(&mut self) -> bool {
self.select_empty_source(true)
}
/// Select previous included source that materialized no surfaces.
pub fn select_previous_empty_source(&mut self) -> bool {
self.select_empty_source(false)
}
/// Exclude included sources that currently materialize no surfaces.
pub fn exclude_empty_sources(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let surface_source_ids: BTreeSet<String> = self
.review
.surfaces()
.iter()
.map(|surface| surface.source_id.clone())
.collect();
let mut excluded = 0usize;
for source in &mut self.workspace.sources {
if source.included && !surface_source_ids.contains(&source.id) {
source.included = false;
excluded = excluded.saturating_add(1);
}
}
if excluded == 0 {
self.status_message = Some("no empty included sources to exclude".to_string());
return true;
}
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
self.status_message = Some(format!("excluded {excluded} empty source(s)"));
true
}
/// Select the next source with diagnostics.
pub fn select_next_diagnostic_source(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
if self.review.diagnostics.is_empty() {
self.status_message = Some("no source diagnostics".to_string());
return true;
}
let source_count = self.workspace.sources.len();
if source_count == 0 {
self.status_message =
Some("diagnostics have no matching workspace sources".to_string());
return true;
}
let start = self.selected_build_row.saturating_add(1);
for offset in 0..source_count {
let index = start.saturating_add(offset) % source_count;
let source_id = self.workspace.sources[index].id.clone();
if self
.review
.diagnostics
.iter()
.any(|diagnostic| diagnostic.source_id == source_id)
{
self.set_selected_build_row(index);
self.status_message = Some("selected source with diagnostics".to_string());
return true;
}
}
self.status_message = Some("diagnostics have no matching workspace sources".to_string());
true
}
/// Exclude all sources that have error diagnostics.
pub fn exclude_sources_with_errors(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let error_source_ids: BTreeSet<String> = self
.review
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == ReviewSourceDiagnosticSeverity::Error)
.map(|diagnostic| diagnostic.source_id.clone())
.collect();
if error_source_ids.is_empty() {
self.status_message = Some("no source errors to exclude".to_string());
return true;
}
let mut excluded = 0usize;
for source in &mut self.workspace.sources {
if source.included && error_source_ids.contains(&source.id) {
source.included = false;
excluded = excluded.saturating_add(1);
}
}
if excluded == 0 {
self.status_message = Some("error sources are already excluded".to_string());
return true;
}
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
self.status_message = Some(format!("excluded {excluded} source(s) with errors"));
true
}
/// Toggle merge-base semantics for selected range/compare source.
pub fn toggle_selected_source_merge_base(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let Some(index) = self.selected_build_source_index() else {
self.status_message =
Some("select a commit range or branch compare source".to_string());
return true;
};
let Some(source) = self.workspace.sources.get_mut(index) else {
self.status_message =
Some("select a commit range or branch compare source".to_string());
return true;
};
let status = match &mut source.kind {
ReviewSourceKind::CommitRange { merge_base, .. }
| ReviewSourceKind::BranchCompare { merge_base, .. } => {
*merge_base = !*merge_base;
if *merge_base { "merge-base" } else { "direct" }
}
_ => {
self.status_message =
Some("selected source does not support merge-base toggling".to_string());
return true;
}
};
source.label = source.kind.label();
let label = source.label.clone();
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
self.status_message = Some(format!("set {label} comparison to {status}"));
true
}
/// Remove all excluded sources from the workspace.
pub fn remove_excluded_sources(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let before = self.workspace.sources.len();
self.workspace.sources.retain(|source| source.included);
let removed = before.saturating_sub(self.workspace.sources.len());
if removed == 0 {
self.status_message = Some("no excluded sources to remove".to_string());
return true;
}
self.set_selected_build_row(self.selected_build_row);
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
self.status_message = Some(format!("removed {removed} excluded source(s)"));
true
}
/// Remove duplicate sources from the workspace, preserving the first occurrence.
pub fn remove_duplicate_sources(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let mut seen = Vec::new();
let before = self.workspace.sources.len();
self.workspace.sources.retain(|source| {
if seen.contains(&source.kind) {
false
} else {
seen.push(source.kind.clone());
true
}
});
let removed = before.saturating_sub(self.workspace.sources.len());
if removed == 0 {
self.status_message = Some("no duplicate sources to remove".to_string());
return true;
}
self.set_selected_build_row(self.selected_build_row);
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
self.status_message = Some(format!("removed {removed} duplicate source(s)"));
true
}
/// Toggle whether the selected workspace source is included.
pub fn toggle_selected_build_source(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let Some(index) = self.selected_build_source_index() else {
self.status_message = Some("select an included source to toggle".to_string());
return true;
};
let Some(source) = self.workspace.sources.get_mut(index) else {
self.status_message = Some("select an included source to toggle".to_string());
return true;
};
source.included = !source.included;
let included = source.included;
let label = source.label.clone();
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
self.status_message = Some(format!(
"{} {}",
if included { "included" } else { "excluded" },
label
));
true
}
/// Move the selected workspace source earlier.
pub fn move_selected_source_up(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let Some(index) = self.selected_build_source_index() else {
return false;
};
if index == 0 {
return false;
}
self.workspace.sources.swap(index, index - 1);
self.set_selected_build_row(index - 1);
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
true
}
/// Move the selected workspace source later.
pub fn move_selected_source_down(&mut self) -> bool {
let Some(index) = self.selected_build_source_index() else {
return false;
};
if index.saturating_add(1) >= self.workspace.sources.len() {
return false;
}
self.workspace.sources.swap(index, index + 1);
self.set_selected_build_row(index + 1);
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
true
}
/// Remove selected build source from the workspace.
pub fn remove_selected_build_source(&mut self) -> bool {
if self.ux_mode != ReviewUxMode::Build {
return false;
}
let Some(index) = self.selected_build_source_index() else {
self.status_message = Some("select an included source to remove".to_string());
return true;
};
let source = self.workspace.sources.remove(index);
self.set_selected_build_row(self.selected_build_row);
self.status_message = Some(format!("removed {}", source.label));
self.sync_review_workspace();
self.pending_workspace_save = true;
self.pending_workspace_reload = true;
true
}
/// Select next repository tree row.
pub fn select_next_tree_row(&mut self, rows: usize) -> bool {
let max = self.file_tree_rows().len().saturating_sub(1);
let next = self.selected_tree_row.saturating_add(rows).min(max);
if next == self.selected_tree_row {
return false;
}
self.selected_tree_row = next;
true
}
/// Select previous repository tree row.
pub const fn select_previous_tree_row(&mut self, rows: usize) -> bool {
let next = self.selected_tree_row.saturating_sub(rows);
if next == self.selected_tree_row {
return false;
}
self.selected_tree_row = next;
true
}
/// Expand selected directory row.
pub fn expand_selected_tree_row(&mut self) -> bool {
match self.file_tree_rows().get(self.selected_tree_row).cloned() {
Some(ReviewFileTreeRow::Directory { path, .. }) => self.expanded_dirs.insert(path),
_ => false,
}
}
/// Collapse selected directory row or selected file parent.
pub fn collapse_selected_tree_row(&mut self) -> bool {
match self.file_tree_rows().get(self.selected_tree_row).cloned() {
Some(ReviewFileTreeRow::Directory { path, .. }) => self.expanded_dirs.remove(&path),
Some(ReviewFileTreeRow::File { index, .. }) => {
let Some(path) = self.review_path_for_index(index) else {
return false;
};
let Some(parent) = Path::new(&path).parent().map(Path::to_path_buf) else {
return false;
};
self.expanded_dirs.remove(&parent)
}
None => false,
}
}
/// Return open and resolved local review thread counts.
#[must_use]
pub fn local_review_thread_status_counts(&self) -> (usize, usize) {
let threads = self.local_review_threads();
let resolved = threads
.iter()
.filter(|thread| thread.status.is_resolved())
.count();
(threads.len().saturating_sub(resolved), resolved)
}
/// Return durable local review threads in deterministic order.
#[must_use]
pub fn local_review_threads(&self) -> Vec<LocalReviewThread> {
let anchors = self
.draft_comments
.keys()
.chain(self.ai_exchanges.keys())
.cloned()
.collect::<BTreeSet<_>>();
anchors
.into_iter()
.map(|anchor| {
let key = Self::thread_key_for_anchor(&anchor);
let status = if self.resolved_review_threads.contains(&key) {
LocalReviewThreadStatus::Resolved
} else {
LocalReviewThreadStatus::Open
};
let comments = self
.draft_comments
.get(&anchor)
.into_iter()
.flatten()
.cloned()
.map(LocalReviewComment::from)
.collect::<Vec<_>>();
let exchange = self
.ai_exchanges
.get(&anchor)
.and_then(|exchanges| exchanges.last());
let session_id = exchange
.and_then(|exchange| exchange.session_id.clone())
.or_else(|| {
comments
.iter()
.rev()
.find_map(|comment| comment.session_id.clone())
});
let thread_kind = if exchange.is_some() {
ReviewThreadKind::Question
} else {
comments
.last()
.map_or(ReviewThreadKind::Note, |comment| comment.thread_kind)
};
let severity = comments
.last()
.map_or(ReviewThreadSeverity::Info, |comment| comment.severity);
LocalReviewThread {
key,
anchor,
comments,
status,
session_id,
thread_kind,
severity,
}
})
.collect()
}
/// Return open durable local review threads.
#[must_use]
pub fn open_local_review_threads(&self) -> Vec<LocalReviewThread> {
self.local_review_threads()
.into_iter()
.filter(LocalReviewThread::is_open)
.collect()
}
/// Return open and resolved thread counts.
#[must_use]
pub fn thread_status_counts(&self) -> (usize, usize) {
self.local_review_thread_status_counts()
}
/// Return suggestion lifecycle counts for an anchor.
#[must_use]
pub fn suggestion_status_counts_for_anchor(
&self,
anchor: &ReviewCommentAnchor,
) -> (usize, usize, usize, usize) {
let mut pending = 0usize;
let mut refining = 0usize;
let mut accepted = 0usize;
let mut rejected = 0usize;
for suggestion in self.suggested_comments.get(anchor).into_iter().flatten() {
match suggestion.status {
ReviewSuggestionStatus::Suggested => pending = pending.saturating_add(1),
ReviewSuggestionStatus::Refining => refining = refining.saturating_add(1),
ReviewSuggestionStatus::Accepted => accepted = accepted.saturating_add(1),
ReviewSuggestionStatus::Rejected => rejected = rejected.saturating_add(1),
}
}
(pending, refining, accepted, rejected)
}
/// Return review thread summaries in deterministic order.
#[must_use]
pub fn thread_summaries(&self) -> Vec<ReviewThreadSummary> {
self.local_review_threads()
.into_iter()
.map(|thread| {
let latest_body = thread.latest_body().map_or_else(
|| {
self.ai_exchanges
.get(&thread.anchor)
.and_then(|exchanges| exchanges.last())
.map_or_else(
|| "Bcode exchange".to_string(),
|exchange| exchange.question.clone(),
)
},
ToString::to_string,
);
let (
pending_suggestion_count,
refining_suggestion_count,
accepted_suggestion_count,
rejected_suggestion_count,
) = self.suggestion_status_counts_for_anchor(&thread.anchor);
ReviewThreadSummary {
anchor: thread.anchor,
draft_count: thread.comments.len(),
latest_body,
session_id: thread.session_id,
resolved: thread.status.is_resolved(),
thread_kind: thread.thread_kind,
severity: thread.severity,
pending_suggestion_count,
refining_suggestion_count,
accepted_suggestion_count,
rejected_suggestion_count,
external_provider_id: None,
external_mapping_status: None,
}
})
.chain(self.external_thread_summaries())
.collect()
}
fn external_thread_summaries(&self) -> Vec<ReviewThreadSummary> {
self.external_review_imports
.iter()
.flat_map(|record| {
record.threads.iter().map(|thread| {
let anchor = thread
.mapping
.anchor
.clone()
.map(DraftAnchor::from)
.map_or_else(
|| ReviewCommentAnchor::file(thread_path(thread)),
|anchor| ReviewCommentAnchor::from_draft_anchor(&anchor),
);
ReviewThreadSummary {
anchor,
draft_count: thread.comments.len(),
latest_body: thread.comments.last().map_or_else(
|| "external review thread".to_string(),
|comment| comment.body.clone(),
),
session_id: None,
resolved: thread.state == ExternalReviewThreadState::Resolved,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
pending_suggestion_count: 0,
refining_suggestion_count: 0,
accepted_suggestion_count: 0,
rejected_suggestion_count: 0,
external_provider_id: Some(record.importer_id.clone()),
external_mapping_status: Some(thread.mapping.status),
}
})
})
.collect()
}
/// Show discovered importer picker.
fn show_external_importers(&mut self, importers: Vec<ExternalImporterRoute>) {
self.external_importers = importers;
self.selected_importer = 0;
if self.external_importers.is_empty() {
self.import_state = None;
self.status_message = Some("no external review importer is available".to_string());
} else {
self.import_state = Some(ReviewImportState::Picker);
self.status_message = None;
}
}
/// Move importer picker/options selection down.
pub fn import_down(&mut self, rows: usize) -> bool {
match &mut self.import_state {
Some(ReviewImportState::Picker) => {
let next = self
.selected_importer
.saturating_add(rows)
.min(self.external_importers.len().saturating_sub(1));
if next == self.selected_importer {
return false;
}
self.selected_importer = next;
true
}
Some(ReviewImportState::Options {
options, selected, ..
}) => {
let next = selected
.saturating_add(rows)
.min(options.len().saturating_sub(1));
if next == *selected {
return false;
}
*selected = next;
true
}
None => false,
}
}
/// Move importer picker/options selection up.
pub const fn import_up(&mut self, rows: usize) -> bool {
match &mut self.import_state {
Some(ReviewImportState::Picker) => {
let next = self.selected_importer.saturating_sub(rows);
if next == self.selected_importer {
return false;
}
self.selected_importer = next;
true
}
Some(ReviewImportState::Options { selected, .. }) => {
let next = selected.saturating_sub(rows);
if next == *selected {
return false;
}
*selected = next;
true
}
None => false,
}
}
/// Confirm importer picker/options selection.
pub fn confirm_import_selection(&mut self) -> bool {
match &self.import_state {
Some(ReviewImportState::Picker) => {
let Some(importer) = self.external_importers.get(self.selected_importer) else {
return false;
};
let options = options_from_schema(&importer.manifest.options_schema);
if options.is_empty() {
self.pending_external_import_request =
Some(PendingExternalImportRequest::Invoke {
importer_index: self.selected_importer,
options,
});
self.status_message = Some("importing external review".to_string());
} else {
self.import_state = Some(ReviewImportState::Options {
importer_index: self.selected_importer,
options,
selected: 0,
});
}
true
}
Some(ReviewImportState::Options {
importer_index,
options,
..
}) => {
self.pending_external_import_request = Some(PendingExternalImportRequest::Invoke {
importer_index: *importer_index,
options: options.clone(),
});
self.status_message = Some("importing external review".to_string());
true
}
None => false,
}
}
pub(crate) const fn import_options_active(&self) -> bool {
matches!(self.import_state, Some(ReviewImportState::Options { .. }))
}
fn cycle_selected_import_option(&mut self, offset: isize) -> bool {
let Some(ReviewImportState::Options {
options, selected, ..
}) = &mut self.import_state
else {
return false;
};
cycle_option(options, *selected, offset, &mut self.status_message)
}
fn edit_import_option(&mut self, stroke: KeyStroke) -> bool {
let Some(ReviewImportState::Options {
options, selected, ..
}) = &mut self.import_state
else {
return false;
};
edit_option(options, *selected, stroke)
}
fn insert_import_option_text(&mut self, text: &str) -> bool {
let Some(ReviewImportState::Options {
options, selected, ..
}) = &mut self.import_state
else {
return false;
};
let Some(option) = options.get_mut(*selected) else {
return false;
};
if option.choices.is_empty() {
option.value.push_str(text);
true
} else {
false
}
}
#[must_use]
pub fn external_importers(&self) -> Vec<&ReviewImporterManifest> {
self.external_importers
.iter()
.map(|importer| &importer.manifest)
.collect()
}
/// Queue read-only external review import discovery.
pub fn import_external_review(&mut self) -> bool {
if self.workspace.id.is_empty() {
self.status_message = Some("external import requires a durable workspace".to_string());
return true;
}
self.pending_external_import_request = Some(PendingExternalImportRequest::Discover);
self.status_message = Some("discovering external review importers".to_string());
true
}
/// Take pending external review import request.
pub const fn take_external_import_request(&mut self) -> Option<PendingExternalImportRequest> {
self.pending_external_import_request.take()
}
/// Replace durable provider-neutral external review snapshots.
pub fn load_external_imports(&mut self, records: Vec<ExternalReviewImportRecord>) {
self.external_review_imports = records;
self.selected_thread = self
.selected_thread
.min(self.visible_thread_summaries().len().saturating_sub(1));
}
/// Return review thread summaries visible in the sidebar.
#[must_use]
pub fn visible_thread_summaries(&self) -> Vec<ReviewThreadSummary> {
if self.sidebar_mode == ReviewSidebarMode::NeedsAttention {
return self
.thread_summaries()
.into_iter()
.filter(|thread| !thread.resolved)
.collect();
}
self.thread_summaries()
.into_iter()
.filter(|thread| match self.sidebar_mode {
ReviewSidebarMode::General => thread.anchor.is_review_level(),
_ => match self.thread_filter {
ReviewThreadFilter::All => true,
ReviewThreadFilter::Open => !thread.resolved,
ReviewThreadFilter::Resolved => thread.resolved,
},
})
.collect()
}
/// Show all local draft threads in the sidebar.
pub fn show_draft_threads(&mut self) -> bool {
if self.draft_comment_count() == 0 {
self.status_message = Some("no draft review threads".to_string());
return true;
}
self.sidebar_visible = true;
self.sidebar_mode = ReviewSidebarMode::Threads;
self.thread_filter = ReviewThreadFilter::All;
self.show_resolved_threads = true;
self.selected_thread = 0;
self.thread_scroll = 0;
self.status_message = Some("showing draft review threads".to_string());
true
}
/// Show all review threads in the sidebar.
pub fn show_all_threads(&mut self) -> bool {
self.sidebar_visible = true;
self.sidebar_mode = ReviewSidebarMode::Threads;
self.show_resolved_threads = true;
self.set_thread_filter(ReviewThreadFilter::All);
if self.visible_thread_summaries().is_empty() {
self.status_message = Some("no review threads".to_string());
}
true
}
/// Show open review threads in the sidebar.
pub fn show_open_threads(&mut self) -> bool {
let (open_threads, _) = self.thread_status_counts();
if open_threads == 0 {
self.status_message = Some("no open review threads".to_string());
return true;
}
self.sidebar_visible = true;
self.sidebar_mode = ReviewSidebarMode::Threads;
self.show_resolved_threads = false;
self.set_thread_filter(ReviewThreadFilter::Open);
true
}
/// Show resolved review threads in the sidebar.
pub fn show_resolved_threads(&mut self) -> bool {
let (_, resolved_threads) = self.thread_status_counts();
if resolved_threads == 0 {
self.status_message = Some("no resolved review threads".to_string());
return true;
}
self.sidebar_visible = true;
self.sidebar_mode = ReviewSidebarMode::Threads;
self.show_resolved_threads = true;
self.set_thread_filter(ReviewThreadFilter::Resolved);
true
}
/// Cycle thread sidebar filter.
pub fn cycle_thread_filter(&mut self) -> bool {
self.sidebar_visible = true;
self.sidebar_mode = ReviewSidebarMode::Threads;
self.thread_filter = self.thread_filter.next();
self.show_resolved_threads = self.thread_filter != ReviewThreadFilter::Open;
self.selected_thread = 0;
self.thread_scroll = 0;
self.status_message = Some(format!(
"showing {} review threads",
self.thread_filter.label()
));
true
}
/// Select next thread.
pub fn select_next_thread(&mut self, rows: usize) -> bool {
let max = self.visible_thread_summaries().len().saturating_sub(1);
let next = self.selected_thread.saturating_add(rows).min(max);
if next == self.selected_thread {
return false;
}
self.selected_thread = next;
true
}
/// Select a thread by absolute index.
pub fn select_thread(&mut self, index: usize) -> bool {
if index >= self.visible_thread_summaries().len() || index == self.selected_thread {
return false;
}
self.selected_thread = index;
self.sync_view_target_to_selected_thread();
true
}
fn sync_view_target_to_selected_thread(&mut self) {
let threads = self.visible_thread_summaries();
let Some(thread) = threads.get(self.selected_thread) else {
return;
};
let thread_key = Self::thread_key_for_anchor(&thread.anchor);
self.selected_view_target = Some(ReviewViewTarget::Thread { thread_key });
}
/// Select previous thread.
pub const fn select_previous_thread(&mut self, rows: usize) -> bool {
let next = self.selected_thread.saturating_sub(rows);
if next == self.selected_thread {
return false;
}
self.selected_thread = next;
true
}
/// Clear selected inline review target.
pub fn clear_selected_view_target(&mut self) -> bool {
if self.selected_view_target.is_none() {
return false;
}
self.selected_view_target = None;
self.status_message = Some("cleared inline selection".to_string());
true
}
/// Select next draft comment in the main pane.
pub fn select_next_inline_draft(&mut self) -> bool {
self.select_relative_inline_comment(1)
}
/// Select previous draft comment in the main pane.
pub fn select_previous_inline_draft(&mut self) -> bool {
self.select_relative_inline_comment(-1)
}
fn select_relative_inline_comment(&mut self, offset: isize) -> bool {
let Some(document) = self.current_review_view_document() else {
self.status_message = Some("no review comments".to_string());
return true;
};
let comment_rows = document
.rows
.iter()
.filter(|row| {
matches!(
row.block,
ReviewViewBlock::InlineComment {
body_line_index: 0,
..
}
)
})
.collect::<Vec<_>>();
if comment_rows.is_empty() {
self.status_message = Some("no review comments".to_string());
return true;
}
let current_visual = self.selected_diff_visual_row();
let current_index = comment_rows
.iter()
.position(|row| row.visual_row >= current_visual)
.unwrap_or_else(|| comment_rows.len().saturating_sub(1));
let next_index = if offset.is_negative() {
current_index.saturating_sub(offset.unsigned_abs())
} else {
let step = offset.unsigned_abs();
let start_index = if comment_rows[current_index].visual_row == current_visual {
current_index.saturating_add(step)
} else {
current_index
};
start_index.min(comment_rows.len().saturating_sub(1))
};
let Some(row) = comment_rows.get(next_index) else {
return false;
};
self.select_view_visual_row(row.visual_row)
}
/// Select next unresolved review thread in the main pane.
pub fn select_next_open_thread(&mut self) -> bool {
self.select_relative_open_thread(1)
}
/// Select previous unresolved review thread in the main pane.
pub fn select_previous_open_thread(&mut self) -> bool {
self.select_relative_open_thread(-1)
}
fn select_relative_open_thread(&mut self, offset: isize) -> bool {
let summaries = self.open_inline_thread_summaries();
if summaries.is_empty() {
self.status_message = Some("no unresolved review threads".to_string());
return true;
}
let current_anchor = self.selected_comment_anchor();
let current_index = current_anchor
.as_ref()
.and_then(|anchor| summaries.iter().position(|thread| &thread.anchor == anchor));
let next_index = relative_index(current_index, summaries.len(), offset);
self.jump_to_thread_summary(&summaries[next_index])
}
/// Select next unresolved review thread across all files.
pub fn select_next_open_thread_global(&mut self) -> bool {
self.select_relative_open_thread_global(1)
}
/// Select previous unresolved review thread across all files.
pub fn select_previous_open_thread_global(&mut self) -> bool {
self.select_relative_open_thread_global(-1)
}
fn select_relative_open_thread_global(&mut self, offset: isize) -> bool {
let summaries = self.open_thread_summaries();
if summaries.is_empty() {
self.status_message = Some("no unresolved review threads".to_string());
return true;
}
let current_anchor = self.selected_comment_anchor();
let current_index = current_anchor
.as_ref()
.and_then(|anchor| summaries.iter().position(|thread| &thread.anchor == anchor));
let next_index = relative_index(current_index, summaries.len(), offset);
self.jump_to_thread_summary(&summaries[next_index])
}
/// Select next AI-linked review thread across all files.
pub fn select_next_ai_linked_thread(&mut self) -> bool {
self.select_relative_ai_linked_thread(1)
}
/// Select previous AI-linked review thread across all files.
pub fn select_previous_ai_linked_thread(&mut self) -> bool {
self.select_relative_ai_linked_thread(-1)
}
fn select_relative_ai_linked_thread(&mut self, offset: isize) -> bool {
let summaries = self.ai_linked_thread_summaries();
if summaries.is_empty() {
self.status_message = Some("no AI-linked review threads".to_string());
return true;
}
let current_anchor = self.selected_comment_anchor();
let current_index = current_anchor
.as_ref()
.and_then(|anchor| summaries.iter().position(|thread| &thread.anchor == anchor));
let next_index = relative_index(current_index, summaries.len(), offset);
self.jump_to_thread_summary(&summaries[next_index])
}
/// Select next review thread in the main pane.
pub fn select_next_inline_thread(&mut self) -> bool {
let Some(current_anchor) = self.selected_comment_anchor() else {
return self.jump_to_thread_index(0);
};
let summaries = self.inline_thread_summaries();
let Some(current_index) = summaries
.iter()
.position(|thread| thread.anchor == current_anchor)
else {
return self.jump_to_thread_index(0);
};
self.jump_to_thread_summary_index(
current_index
.saturating_add(1)
.min(summaries.len().saturating_sub(1)),
)
}
/// Select previous review thread in the main pane.
pub fn select_previous_inline_thread(&mut self) -> bool {
let Some(current_anchor) = self.selected_comment_anchor() else {
return self.jump_to_thread_index(0);
};
let summaries = self.inline_thread_summaries();
let Some(current_index) = summaries
.iter()
.position(|thread| thread.anchor == current_anchor)
else {
return self.jump_to_thread_index(0);
};
self.jump_to_thread_summary_index(current_index.saturating_sub(1))
}
fn jump_to_thread_index(&mut self, index: usize) -> bool {
let Some(thread) = self.visible_thread_summaries().get(index).cloned() else {
self.status_message = Some("no review threads".to_string());
return true;
};
self.selected_thread = index;
self.focus_thread_summary(&thread);
true
}
fn jump_to_thread_summary_index(&mut self, index: usize) -> bool {
let Some(thread) = self.inline_thread_summaries().get(index).cloned() else {
self.status_message = Some("no review threads".to_string());
return true;
};
self.jump_to_thread_summary(&thread)
}
fn jump_to_thread_summary(&mut self, thread: &ReviewThreadSummary) -> bool {
self.sync_selected_thread_to_specific_anchor(&thread.anchor);
self.focus_thread_summary(thread);
true
}
fn open_inline_thread_summaries(&self) -> Vec<ReviewThreadSummary> {
self.inline_thread_summaries()
.into_iter()
.filter(|thread| !thread.resolved)
.collect()
}
fn open_thread_summaries(&self) -> Vec<ReviewThreadSummary> {
self.thread_summaries()
.into_iter()
.filter(|thread| !thread.resolved)
.collect()
}
fn ai_linked_thread_summaries(&self) -> Vec<ReviewThreadSummary> {
self.thread_summaries()
.into_iter()
.filter(|thread| {
thread.session_id.is_some()
|| self
.agent_state_for_anchor(&thread.anchor)
.is_some_and(|state| state.session_id.is_some())
})
.collect()
}
fn inline_thread_summaries(&self) -> Vec<ReviewThreadSummary> {
let mut summaries = self
.thread_summaries()
.into_iter()
.filter(|thread| thread.anchor.file_index == self.selected_file)
.collect::<Vec<_>>();
summaries.sort_by_key(|thread| {
(
thread.anchor.file_index,
thread.anchor.start_diff_row(),
thread.anchor.end_diff_row(),
thread.anchor.path.clone(),
)
});
summaries
}
fn focus_thread_summary(&mut self, thread: &ReviewThreadSummary) {
if thread.anchor.is_review_level() {
self.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: Self::thread_key_for_anchor(&thread.anchor),
});
self.status_message = Some("selected review-level thread".to_string());
return;
}
self.select_anchor(&thread.anchor);
self.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: Self::thread_key_for_anchor(&thread.anchor),
});
self.ensure_selected_diff_line_visible();
self.status_message = Some("selected review thread".to_string());
}
/// Jump to the selected thread in the diff.
pub fn jump_to_selected_thread(&mut self) -> bool {
if !matches!(
self.sidebar_mode,
ReviewSidebarMode::Threads | ReviewSidebarMode::NeedsAttention
) {
return false;
}
let Some(thread) = self
.visible_thread_summaries()
.get(self.selected_thread)
.cloned()
else {
self.status_message = Some("no review thread selected".to_string());
return true;
};
if thread.anchor.is_review_level() {
self.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: Self::thread_key_for_anchor(&thread.anchor),
});
self.status_message = Some("jumped to review-level thread".to_string());
return true;
}
self.select_anchor(&thread.anchor);
self.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: Self::thread_key_for_anchor(&thread.anchor),
});
self.ensure_selected_diff_line_visible();
self.status_message = Some("jumped to review thread".to_string());
true
}
/// Select an anchor in the diff.
pub fn select_anchor(&mut self, anchor: &ReviewCommentAnchor) {
self.selected_file = anchor.file_index;
self.selected_diff_line = anchor.diff_row;
self.ensure_selected_diff_line_visible();
}
fn review_file_index_for_path(&self, path: &str) -> Option<usize> {
self.review
.files
.iter()
.position(|file| file.display_path() == path)
.or_else(|| {
self.review
.surfaces()
.iter()
.position(|surface| surface.path == path)
})
}
fn save_current_file_viewport(&mut self) {
let Some(path) = self.selected_file_path() else {
return;
};
self.file_viewports.insert(
path,
ReviewViewportState {
diff_scroll: self.diff_scroll,
selected_diff_line: self.selected_diff_line,
},
);
}
fn restore_current_file_viewport(&mut self) {
let Some(path) = self.selected_file_path() else {
self.diff_scroll = 0;
self.selected_diff_line = 0;
return;
};
if let Some(viewport) = self.file_viewports.get(&path).copied() {
self.diff_scroll = viewport.diff_scroll.min(self.max_diff_scroll());
self.selected_diff_line = viewport
.selected_diff_line
.min(self.source_rendered_diff_len().saturating_sub(1));
self.ensure_selected_diff_line_visible();
} else {
self.diff_scroll = 0;
self.selected_diff_line = 0;
}
}
/// Return indices for files visible in the changed-file sidebar.
#[must_use]
pub fn visible_file_indices(&self) -> Vec<usize> {
self.review
.files
.iter()
.enumerate()
.filter_map(|(index, file)| {
(!self.hide_viewed_files || !self.viewed_files.contains(file.display_path()))
.then_some(index)
})
.collect()
}
/// Select a file by visible changed-file sidebar index.
pub fn select_visible_file(&mut self, visible_index: usize) -> bool {
let Some(index) = self.visible_file_indices().get(visible_index).copied() else {
return false;
};
self.select_file(index)
}
/// Return whether the file at index is marked viewed.
#[must_use]
pub fn file_viewed(&self, index: usize) -> bool {
self.review
.files
.get(index)
.is_some_and(|file| self.viewed_files.contains(file.display_path()))
}
/// Return unviewed file display paths.
#[must_use]
pub fn unviewed_file_paths(&self) -> Vec<String> {
self.review
.files
.iter()
.filter(|file| !self.viewed_files.contains(file.display_path()))
.map(|file| file.display_path().to_string())
.collect()
}
/// Return reviewed file progress.
#[must_use]
pub fn viewed_file_counts(&self) -> (usize, usize) {
let total = self.review.files.len();
let viewed = self
.review
.files
.iter()
.filter(|file| self.viewed_files.contains(file.display_path()))
.count();
(viewed, total)
}
/// Toggle whether viewed files are hidden from the changed-file sidebar.
pub fn toggle_hide_viewed_files(&mut self) -> bool {
self.hide_viewed_files = !self.hide_viewed_files;
if self.hide_viewed_files {
self.status_message = Some("hiding viewed files in sidebar".to_string());
if self.file_viewed(self.selected_file) && !self.unviewed_file_indices().is_empty() {
let _ = self.select_next_unviewed_file();
}
} else {
self.status_message = Some("showing viewed files in sidebar".to_string());
}
true
}
/// Toggle viewed state for the selected review file.
pub fn toggle_selected_file_viewed(&mut self) -> bool {
let Some(path) = self.selected_file_path() else {
self.status_message = Some("no review file selected".to_string());
return true;
};
if self.viewed_files.remove(&path) {
self.workspace.viewed_files.remove(&path);
self.pending_workspace_save = true;
self.status_message = Some(format!("marked {path} unviewed"));
} else {
self.viewed_files.insert(path.clone());
self.workspace.viewed_files.insert(path.clone());
self.pending_workspace_save = true;
self.status_message = Some(format!("marked {path} viewed"));
if self.hide_viewed_files && !self.unviewed_file_indices().is_empty() {
let _ = self.select_next_unviewed_file();
}
}
true
}
/// Return whether the file at index has draft comments.
#[must_use]
pub fn file_has_drafts(&self, index: usize) -> bool {
self.draft_comment_count_for_file(index) > 0
}
/// Select a file by index.
pub fn select_file(&mut self, index: usize) -> bool {
if index >= self.review_item_count() || index == self.selected_file {
return false;
}
self.save_current_file_viewport();
self.selected_file = index;
self.range_selection_start = None;
self.mouse_range_selection_start = None;
self.mouse_range_selection_dragged = false;
self.selected_view_target = None;
self.restore_current_file_viewport();
self.queue_selected_file_load();
self.expand_selected_file_dirs();
self.sync_tree_row_to_selected_file();
true
}
/// Return true when a repository file load is pending.
#[must_use]
pub const fn has_pending_file_load(&self) -> bool {
self.pending_file_load.is_some()
}
/// Take pending repository file load request.
pub const fn take_pending_file_load(&mut self) -> Option<String> {
self.pending_file_load.take()
}
/// Take pending hidden-context file load request.
pub const fn take_pending_context_file_load(&mut self) -> Option<String> {
self.pending_context_file_load.take()
}
fn mark_contexts_for_path(&mut self, path: &str, state: &DiffContextLoadState) {
let Some(file_index) = self
.review
.files
.iter()
.position(|file| file.display_path() == path)
else {
return;
};
let keys = self
.diff_context_load_states
.keys()
.filter(|key| key.file_index == file_index)
.cloned()
.collect::<Vec<_>>();
for key in keys {
self.diff_context_load_states.insert(key, state.clone());
}
}
/// Store a lazily loaded repository file.
pub fn store_loaded_file(&mut self, file: CachedReviewFile) {
let viewport_target = self.top_visible_target();
let loaded_path = file.path.clone();
self.pending_context_file_load = self
.pending_context_file_load
.take()
.filter(|path| path != &loaded_path);
self.file_cache.insert(file);
self.mark_contexts_for_path(&loaded_path, &DiffContextLoadState::Loaded);
self.restore_top_visible_target(viewport_target.as_ref());
}
/// Store a repository file load failure.
pub fn store_file_load_error(&mut self, path: String, error: String) {
let viewport_target = self.top_visible_target();
self.pending_context_file_load = self
.pending_context_file_load
.take()
.filter(|pending_path| pending_path != &path);
let path_for_state = path.clone();
self.file_cache.insert(CachedReviewFile {
path,
content: String::new(),
line_spans: Vec::new(),
size_bytes: 0,
mtime_ms: None,
is_binary: false,
unavailable_reason: Some(error.clone()),
});
self.mark_contexts_for_path(&path_for_state, &DiffContextLoadState::Unavailable(error));
self.restore_top_visible_target(viewport_target.as_ref());
}
/// Sync selected tree row to selected file.
pub fn sync_tree_row_to_selected_file(&mut self) {
if let Some(row) = self.file_tree_rows().iter().position(|row| {
matches!(row, ReviewFileTreeRow::File { index, .. } if *index == self.selected_file)
}) {
self.selected_tree_row = row;
}
}
/// Expand ancestor directories for the selected file.
pub fn expand_selected_file_dirs(&mut self) {
let Some(path) = self.selected_file_path() else {
return;
};
let path = Path::new(&path);
let mut prefix = PathBuf::new();
if let Some(parent) = path.parent() {
for component in parent.components() {
prefix.push(component.as_os_str());
self.expanded_dirs.insert(prefix.clone());
}
}
}
/// Queue selected repository file for loading when needed.
pub fn queue_selected_file_load(&mut self) {
if !self.review.is_repository_review() {
return;
}
let Some(path) = self.selected_file_path() else {
return;
};
if self.file_cache.get(&path).is_none() && self.pending_file_load.as_deref() != Some(&path)
{
self.pending_file_load = Some(path);
}
}
/// Select next file.
pub fn select_next_file(&mut self) -> bool {
self.select_file((self.selected_file + 1).min(self.review_item_count().saturating_sub(1)))
}
/// Scroll file sidebar down.
pub fn scroll_files_down(&mut self, rows: usize) -> bool {
if self.sidebar_mode == ReviewSidebarMode::Repository && self.review.is_repository_review()
{
return self.scroll_tree_down(rows);
}
let max = self.review.files.len().saturating_sub(
self.last_file_area
.map_or(1, |area| usize::from(area.height).max(1)),
);
let next = self.file_scroll.saturating_add(rows).min(max);
if next == self.file_scroll {
return false;
}
self.file_scroll = next;
true
}
/// Scroll file sidebar up.
pub fn scroll_files_up(&mut self, rows: usize) -> bool {
if self.sidebar_mode == ReviewSidebarMode::Repository && self.review.is_repository_review()
{
return self.scroll_tree_up(rows);
}
let next = self.file_scroll.saturating_sub(rows);
if next == self.file_scroll {
return false;
}
self.file_scroll = next;
true
}
/// Scroll repository tree sidebar down.
pub fn scroll_tree_down(&mut self, rows: usize) -> bool {
let max = self.file_tree_rows().len().saturating_sub(
self.last_file_area
.map_or(1, |area| usize::from(area.height).max(1)),
);
let next = self.tree_scroll.saturating_add(rows).min(max);
if next == self.tree_scroll {
return false;
}
self.tree_scroll = next;
true
}
/// Scroll repository tree sidebar up.
pub const fn scroll_tree_up(&mut self, rows: usize) -> bool {
let next = self.tree_scroll.saturating_sub(rows);
if next == self.tree_scroll {
return false;
}
self.tree_scroll = next;
true
}
/// Mark every review file unviewed.
pub fn mark_all_files_unviewed(&mut self) -> bool {
if self.viewed_files.is_empty() && self.workspace.viewed_files.is_empty() {
self.status_message = Some("all files already unviewed".to_string());
return true;
}
self.viewed_files.clear();
self.workspace.viewed_files.clear();
self.sync_review_workspace();
self.pending_workspace_save = true;
let (_, total) = self.viewed_file_counts();
self.status_message = Some(format!("marked 0/{total} files viewed"));
true
}
/// Mark every review file viewed.
pub fn mark_all_files_viewed(&mut self) -> bool {
self.viewed_files = self
.review
.files
.iter()
.map(|file| file.display_path().to_string())
.collect();
self.workspace.viewed_files.clone_from(&self.viewed_files);
self.sync_review_workspace();
self.pending_workspace_save = true;
let (viewed, total) = self.viewed_file_counts();
self.status_message = Some(format!("marked {viewed}/{total} files viewed"));
true
}
/// Select next item needing review attention.
pub fn select_next_attention_item(&mut self) -> bool {
if !self.unviewed_file_indices().is_empty() {
return self.select_next_unviewed_file();
}
self.select_next_open_thread_global()
}
/// Select next file not yet marked viewed.
pub fn select_next_unviewed_file(&mut self) -> bool {
self.select_relative_unviewed_file(1)
}
/// Select previous file not yet marked viewed.
pub fn select_previous_unviewed_file(&mut self) -> bool {
self.select_relative_unviewed_file(-1)
}
fn select_relative_unviewed_file(&mut self, offset: isize) -> bool {
let files = self.unviewed_file_indices();
if files.is_empty() {
self.status_message = Some("all files viewed".to_string());
return true;
}
let current_index = files.iter().position(|index| *index == self.selected_file);
let next_index = files[relative_index(current_index, files.len(), offset)];
let _ = self.select_file(next_index);
self.status_message = Some("selected unviewed file".to_string());
true
}
fn unviewed_file_indices(&self) -> Vec<usize> {
self.review
.files
.iter()
.enumerate()
.filter_map(|(index, file)| {
(!self.viewed_files.contains(file.display_path())).then_some(index)
})
.collect()
}
/// Select previous file.
pub fn select_previous_file(&mut self) -> bool {
self.select_file(self.selected_file.saturating_sub(1))
}
/// Move visual review selection down.
pub fn select_next_view_row(&mut self, rows: usize) -> bool {
let Some(document) = self.current_review_view_document() else {
return self.scroll_down(rows);
};
let current = self.selected_diff_visual_row();
let next = current
.saturating_add(rows)
.min(document.rows.len().saturating_sub(1));
if next == current {
return false;
}
self.select_view_visual_row(next)
}
/// Move visual review selection up.
pub fn select_previous_view_row(&mut self, rows: usize) -> bool {
let Some(_) = self.current_review_view_document() else {
return self.scroll_up(rows);
};
let current = self.selected_diff_visual_row();
let previous = current.saturating_sub(rows);
if previous == current {
return false;
}
self.select_view_visual_row(previous)
}
/// Scroll diff down.
pub fn scroll_down(&mut self, rows: usize) -> bool {
let max = self.max_diff_scroll();
let next = self.diff_scroll.saturating_add(rows).min(max);
if next == self.diff_scroll {
return false;
}
self.diff_scroll = next;
true
}
/// Scroll diff up.
pub const fn scroll_up(&mut self, rows: usize) -> bool {
let next = self.diff_scroll.saturating_sub(rows);
if next == self.diff_scroll {
return false;
}
self.diff_scroll = next;
true
}
fn top_visible_target(&self) -> Option<ReviewViewTarget> {
self.current_review_view_document()
.and_then(|document| document.target_for_visual_row(self.diff_scroll).cloned())
}
fn preserve_viewport_while(&mut self, change: impl FnOnce(&mut Self) -> bool) -> bool {
let viewport_target = self.top_visible_target();
let changed = change(self);
if changed {
self.restore_top_visible_target(viewport_target.as_ref());
}
changed
}
fn restore_top_visible_target(&mut self, target: Option<&ReviewViewTarget>) {
let Some(target) = target else {
self.diff_scroll = self.diff_scroll.min(self.max_diff_scroll());
return;
};
if let Some(visual_row) = self.current_review_view_document().and_then(|document| {
document
.rows
.iter()
.find(|row| &row.target == target)
.map(|row| row.visual_row)
}) {
self.diff_scroll = visual_row.min(self.max_diff_scroll());
} else {
self.diff_scroll = self.diff_scroll.min(self.max_diff_scroll());
}
}
/// Scroll to top.
pub fn scroll_to_top(&mut self) -> bool {
if self.diff_scroll == 0 {
return false;
}
self.selected_view_target = None;
self.diff_scroll = 0;
self.selected_diff_line = 0;
true
}
/// Scroll to bottom.
pub fn scroll_to_bottom(&mut self) -> bool {
let max = self.max_diff_scroll();
if self.diff_scroll == max {
return false;
}
self.selected_view_target = None;
self.diff_scroll = max;
self.selected_view_target = self
.current_review_view_document()
.and_then(|document| document.target_for_visual_row(max).cloned());
self.selected_diff_line = self.source_rendered_diff_len().saturating_sub(1);
true
}
/// Select next hunk.
pub fn select_next_hunk(&mut self) -> bool {
let Some(document) = self.current_review_view_document() else {
return false;
};
let current_visual_row = self.selected_diff_visual_row();
let Some(target) = document.next_hunk_target_after_visual_row(current_visual_row) else {
return false;
};
self.select_target_and_scroll(target)
}
/// Select previous hunk.
pub fn select_previous_hunk(&mut self) -> bool {
let Some(document) = self.current_review_view_document() else {
return false;
};
let current_visual_row = self.selected_diff_visual_row();
let Some(target) = document.previous_hunk_target_before_visual_row(current_visual_row)
else {
return false;
};
self.select_target_and_scroll(target)
}
fn select_target_and_scroll(&mut self, target: ReviewViewTarget) -> bool {
self.select_view_target(target)
}
/// Return whether a visual row target is an inline action.
#[must_use]
pub fn is_view_visual_row_action(&self, visual_row: usize) -> bool {
self.current_review_view_document()
.and_then(|document| document.row_for_visual_row(visual_row).cloned())
.is_some_and(|row| matches!(row.target, ReviewViewTarget::ThreadAction { .. }))
}
/// Return whether a visual row target is an inline thread header.
#[must_use]
pub fn is_view_visual_row_thread(&self, visual_row: usize) -> bool {
self.current_review_view_document()
.and_then(|document| document.row_for_visual_row(visual_row).cloned())
.is_some_and(|row| matches!(row.target, ReviewViewTarget::Thread { .. }))
}
/// Handle primary click on a review document visual row.
pub fn handle_review_view_click(&mut self, visual_row: usize) -> bool {
let Some(document) = self.current_review_view_document() else {
return self.begin_mouse_range_selection(visual_row);
};
let Some(row) = document.row_for_visual_row(visual_row) else {
return false;
};
let selected = self.select_view_visual_row(visual_row);
match &row.target {
ReviewViewTarget::SourceLine { source_row, .. } => {
self.begin_mouse_range_selection(*source_row) || selected
}
ReviewViewTarget::HunkHeader { .. } => {
self.status_message = Some("select a diff line to comment".to_string());
selected
}
ReviewViewTarget::OmittedContext { key } => self.expand_diff_context(key) || selected,
ReviewViewTarget::ExpandedContextLine { .. } => {
self.status_message =
Some("expanded context lines are not commentable".to_string());
selected
}
ReviewViewTarget::Thread { .. } => self.toggle_selected_inline_thread() || selected,
ReviewViewTarget::AgentThread { .. } | ReviewViewTarget::Comment { .. } => selected,
ReviewViewTarget::ThreadAction { .. } => {
self.activate_selected_inline_action() || selected
}
}
}
/// Select a visible diff line by rendered row index.
pub fn select_diff_line(&mut self, index: usize) -> bool {
let clamped = index.min(self.source_rendered_diff_len().saturating_sub(1));
let next_target = self
.current_review_view_document()
.and_then(|document| document.target_for_source_row(clamped).cloned());
if clamped == self.selected_diff_line && self.selected_view_target == next_target {
return false;
}
self.selected_diff_line = clamped;
self.selected_view_target = next_target;
self.scroll_selected_target_into_view();
true
}
fn select_view_target(&mut self, target: ReviewViewTarget) -> bool {
if self.selected_view_target.as_ref() == Some(&target) {
return false;
}
if let Some(source_row) = self
.current_review_view_document()
.and_then(|document| document.source_row_for_target(&target))
{
self.selected_diff_line = source_row;
}
self.sync_selected_thread_to_view_target(&target);
self.selected_view_target = Some(target);
self.scroll_selected_target_into_view();
true
}
/// Select a semantic row in the current review view document.
pub fn select_view_visual_row(&mut self, visual_row: usize) -> bool {
let Some(document) = self.current_review_view_document() else {
return self.select_diff_line(visual_row);
};
let Some(target) = document.target_for_visual_row(visual_row).cloned() else {
return false;
};
self.select_view_target(target)
}
/// Return the selected semantic target in the current review view.
#[must_use]
pub fn selected_review_view_target(&self) -> Option<ReviewViewTarget> {
self.selected_view_target.clone().or_else(|| {
self.current_review_view_document().and_then(|document| {
document
.target_for_source_row(self.selected_diff_line)
.cloned()
})
})
}
/// Return whether the provided view target is selected.
#[must_use]
pub fn is_view_target_selected(&self, target: &ReviewViewTarget) -> bool {
self.selected_review_view_target().as_ref() == Some(target)
}
/// Start a mouse-driven range selection from a rendered diff row.
pub fn begin_mouse_range_selection(&mut self, index: usize) -> bool {
self.mouse_range_selection_start =
Some(index.min(self.source_rendered_diff_len().saturating_sub(1)));
self.mouse_range_selection_dragged = false;
self.range_selection_start = None;
self.select_diff_line(index)
}
/// Extend a mouse-driven range selection to a rendered diff row.
pub fn update_mouse_range_selection(&mut self, index: usize) -> bool {
let Some(start) = self.mouse_range_selection_start else {
return false;
};
self.mouse_range_selection_dragged = true;
self.range_selection_start = Some(start);
let changed = self.select_diff_line(index);
if changed {
self.status_message = self.range_selection_label();
}
true
}
/// Complete a mouse-driven range selection.
pub fn finish_mouse_range_selection(&mut self) -> bool {
let Some(_) = self.mouse_range_selection_start.take() else {
return false;
};
if self.mouse_range_selection_dragged {
self.mouse_range_selection_dragged = false;
self.status_message = self.range_selection_label();
true
} else {
self.mouse_range_selection_dragged = false;
self.range_selection_start = None;
false
}
}
/// Return whether file sidebar contains terminal coordinates.
#[must_use]
pub fn file_area_contains(&self, x: u16, y: u16) -> bool {
self.last_file_area
.is_some_and(|area| x >= area.x && x < area.right() && y >= area.y && y < area.bottom())
}
/// Return visible file tree row under terminal coordinates.
#[must_use]
pub fn file_tree_row_at(&self, x: u16, y: u16) -> Option<ReviewFileTreeRow> {
let area = self.last_file_area?;
if x < area.x || x >= area.right() || y < area.y || y >= area.bottom() {
return None;
}
let index = self.tree_scroll + usize::from(y.saturating_sub(area.y));
self.file_tree_rows().get(index).cloned()
}
/// Return visible file index under terminal coordinates.
#[must_use]
pub fn file_index_at(&self, x: u16, y: u16) -> Option<usize> {
let area = self.last_file_area?;
if x < area.x || x >= area.right() || y < area.y || y >= area.bottom() {
return None;
}
let index = self.file_scroll + usize::from(y.saturating_sub(area.y));
(index < self.review.files.len()).then_some(index)
}
/// Return visible thread index under terminal coordinates.
#[must_use]
pub fn thread_index_at(&self, x: u16, y: u16) -> Option<usize> {
let area = self.last_file_area?;
if x < area.x || x >= area.right() || y < area.y || y >= area.bottom() {
return None;
}
let index = self.thread_scroll + usize::from(y.saturating_sub(area.y));
(index < self.visible_thread_summaries().len()).then_some(index)
}
/// Return visible diff row index under terminal coordinates.
#[must_use]
pub fn diff_line_index_at(&self, x: u16, y: u16) -> Option<usize> {
let area = self.last_diff_area?;
if x < area.x || x >= area.right() || y < area.y || y >= area.bottom() {
return None;
}
let visual_row = self.diff_scroll + usize::from(y.saturating_sub(area.y));
if let Some(document) = self.current_review_view_document() {
return document
.row_for_visual_row(visual_row)
.and_then(|row| row.source_row);
}
Some(visual_row)
}
/// Return visible commentable diff source row under terminal coordinates.
#[must_use]
pub fn commentable_diff_line_index_at(&self, x: u16, y: u16) -> Option<usize> {
let visual_row = self.view_visual_row_at(x, y)?;
let document = self.current_review_view_document()?;
match document.target_for_visual_row(visual_row)? {
ReviewViewTarget::SourceLine { source_row, .. } => Some(*source_row),
ReviewViewTarget::HunkHeader { .. }
| ReviewViewTarget::OmittedContext { .. }
| ReviewViewTarget::ExpandedContextLine { .. }
| ReviewViewTarget::Thread { .. }
| ReviewViewTarget::AgentThread { .. }
| ReviewViewTarget::Comment { .. }
| ReviewViewTarget::ThreadAction { .. } => None,
}
}
/// Return visible semantic row index under terminal coordinates.
#[must_use]
pub fn view_visual_row_at(&self, x: u16, y: u16) -> Option<usize> {
let area = self.last_diff_area?;
if x < area.x || x >= area.right() || y < area.y || y >= area.bottom() {
return None;
}
Some(self.diff_scroll + usize::from(y.saturating_sub(area.y)))
}
/// Return total draft comment count.
#[must_use]
pub fn draft_comment_count(&self) -> usize {
self.draft_comments.values().map(Vec::len).sum()
}
/// Return draft comment count for a file.
#[must_use]
pub fn draft_comment_count_for_file(&self, file_index: usize) -> usize {
self.draft_comments
.iter()
.filter(|(anchor, _)| anchor.file_index == file_index)
.map(|(_, comments)| comments.len())
.sum()
}
/// Return unresolved thread count for a file.
#[must_use]
pub fn open_thread_count_for_file(&self, file_index: usize) -> usize {
self.draft_comments
.keys()
.filter(|anchor| {
anchor.file_index == file_index
&& !self
.resolved_review_threads
.contains(&Self::thread_key_for_anchor(anchor))
})
.count()
}
/// Clear active range selection.
pub fn clear_range_selection(&mut self) -> bool {
if self.range_selection_start.is_none() {
return false;
}
self.range_selection_start = None;
self.status_message = Some("cleared range selection".to_string());
true
}
/// Toggle range selection from the selected diff line.
pub fn toggle_range_selection(&mut self) -> bool {
if self.range_selection_start.is_some() {
self.range_selection_start = None;
self.status_message = Some("cleared range selection".to_string());
return true;
}
if self.selected_comment_anchor().is_none() {
self.status_message = Some("select a diff line to start range selection".to_string());
return true;
}
let Some(ReviewViewTarget::SourceLine { source_row, .. }) =
self.selected_review_view_target()
else {
self.status_message = Some("select a diff line to start range selection".to_string());
return true;
};
self.range_selection_start = Some(source_row);
self.status_message = Some(
"range selection started; move to extend, c comment, a ask Bcode, esc clear"
.to_string(),
);
true
}
/// Return selected range bounds, if active.
#[must_use]
pub fn selected_range_bounds(&self) -> Option<(usize, usize)> {
let start = self.range_selection_start?;
let Some(ReviewViewTarget::SourceLine { source_row, .. }) =
self.selected_review_view_target()
else {
return None;
};
Some(if start <= source_row {
(start, source_row)
} else {
(source_row, start)
})
}
/// Return true when a rendered row is within the active range selection.
#[must_use]
pub fn is_row_in_range_selection(&self, file_index: usize, diff_row: usize) -> bool {
if file_index != self.selected_file {
return false;
}
let Some((start, end)) = self.selected_range_bounds() else {
return false;
};
(start..=end).contains(&diff_row)
}
/// Return a status label for an active range selection.
#[must_use]
pub fn range_selection_label(&self) -> Option<String> {
let (start, end) = self.selected_range_bounds()?;
let count = end.saturating_sub(start).saturating_add(1);
Some(format!(
"range rows {}-{} ({count} line{}) selected",
start.saturating_add(1),
end.saturating_add(1),
if count == 1 { "" } else { "s" }
))
}
/// Return a visual marker for a row in the active range selection.
#[must_use]
pub fn range_selection_marker_at(
&self,
file_index: usize,
diff_row: usize,
) -> Option<&'static str> {
if file_index != self.selected_file {
return None;
}
let (start, end) = self.selected_range_bounds()?;
if !(start..=end).contains(&diff_row) {
return None;
}
if start == end {
Some("â—†")
} else if diff_row == start {
Some("â•")
} else if diff_row == end {
Some("â•°")
} else {
Some("│")
}
}
/// Return true when a diff row has draft comments.
#[must_use]
pub fn has_draft_comment_at(&self, file_index: usize, diff_row: usize) -> bool {
self.draft_comments.keys().any(|anchor| {
anchor.file_index == file_index
&& (anchor.start_diff_row()..=anchor.end_diff_row()).contains(&diff_row)
})
}
/// Return the draft marker for a rendered diff row.
#[must_use]
pub fn draft_marker_at(&self, file_index: usize, diff_row: usize) -> Option<String> {
let mut count = 0usize;
let mut linked = false;
for (anchor, comments) in &self.draft_comments {
if anchor.file_index != file_index
|| !(anchor.start_diff_row()..=anchor.end_diff_row()).contains(&diff_row)
{
continue;
}
count = count.saturating_add(comments.len());
linked |= comments.iter().any(|comment| comment.session_id.is_some());
}
if count == 0 {
None
} else if linked {
Some(if count > 1 {
format!("🤖💬{count}")
} else {
"🤖💬".to_string()
})
} else {
Some(if count > 1 {
format!("💬{count}")
} else {
"💬".to_string()
})
}
}
/// Open the draft comment editor for a whole-review thread.
pub fn open_review_comment_editor(&mut self) -> bool {
let anchor = self.review_comment_anchor();
self.comment_editor = Some(ReviewCommentEditor::new(
anchor,
ReviewCommentAction::SaveDraft,
));
self.status_message = Some("compose review-level note".to_string());
true
}
fn review_comment_anchor(&self) -> ReviewCommentAnchor {
ReviewCommentAnchor {
file_index: self.selected_file,
path: REVIEW_LEVEL_ANCHOR_PATH.to_string(),
diff_row: 0,
end_diff_row: None,
old_line: None,
new_line: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: false,
surface_id: None,
source_id: None,
}
}
/// Open the draft comment editor for the selected file.
pub fn open_file_comment_editor(&mut self) -> bool {
let Some(anchor) = self.file_comment_anchor() else {
self.status_message = Some("select a review file to comment on".to_string());
return true;
};
let path = anchor.path.clone();
self.comment_editor = Some(ReviewCommentEditor::new(
anchor,
ReviewCommentAction::SaveDraft,
));
self.status_message = Some(format!("compose file-level note on {path}"));
true
}
fn file_comment_anchor(&self) -> Option<ReviewCommentAnchor> {
let path = self.selected_file_path()?;
let (surface_id, source_id) = self.selected_surface_ids();
Some(ReviewCommentAnchor {
file_index: self.selected_file,
path,
diff_row: 0,
end_diff_row: None,
old_line: None,
new_line: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: true,
surface_id,
source_id,
})
}
/// Open the comment composer for the selected diff line.
pub fn open_comment_editor(&mut self) -> bool {
self.open_comment_editor_with_action(ReviewCommentAction::SaveDraft)
}
fn open_comment_editor_with_action(&mut self, action: ReviewCommentAction) -> bool {
let Some(anchor) = self.selected_comment_anchor() else {
self.status_message = Some(match action {
ReviewCommentAction::SaveDraft => {
"select a diff line or range to comment".to_string()
}
ReviewCommentAction::AskBcode => {
"select a diff line or range to ask Bcode".to_string()
}
ReviewCommentAction::Publish => {
"select a diff line or range to publish".to_string()
}
});
return true;
};
let existing_comment_count = self.draft_comments.get(&anchor).map_or(0, Vec::len);
let mut editor = ReviewCommentEditor::new(anchor, action);
editor.existing_comment_count = existing_comment_count;
self.comment_editor = Some(editor);
self.sync_selected_thread_to_anchor();
self.status_message = Some(format!(
"{}; tab switches action, enter submits {}",
if existing_comment_count > 0 {
"compose thread reply"
} else {
"compose review comment"
},
action.label()
));
true
}
/// Select the next submit action in the active composer.
pub fn select_next_comment_action(&mut self) -> bool {
let Some(editor) = &mut self.comment_editor else {
return false;
};
if !matches!(editor.mode, ReviewCommentEditorMode::Create) {
self.status_message =
Some("editing an existing draft; enter saves changes".to_string());
return true;
}
editor.action = editor.action.next();
self.status_message = Some(format!("comment action: {}", editor.action.label()));
true
}
/// Select the previous submit action in the active composer.
pub fn select_previous_comment_action(&mut self) -> bool {
let Some(editor) = &mut self.comment_editor else {
return false;
};
if !matches!(editor.mode, ReviewCommentEditorMode::Create) {
self.status_message =
Some("editing an existing draft; enter saves changes".to_string());
return true;
}
editor.action = editor.action.previous();
self.status_message = Some(format!("comment action: {}", editor.action.label()));
true
}
/// Open the latest persisted draft for editing.
pub fn open_latest_draft_editor(&mut self) -> bool {
let Some((anchor, selected_index)) = self
.selected_draft_anchor_and_comment_index()
.or_else(|| self.selected_comment_anchor().map(|anchor| (anchor, None)))
else {
self.status_message = Some("select a commented line to edit a draft".to_string());
return true;
};
let Some(comments) = self.draft_comments.get(&anchor) else {
self.status_message = Some("no draft comment at selected line".to_string());
return true;
};
let index = selected_index.unwrap_or_else(|| comments.len().saturating_sub(1));
let Some(comment) = comments.get(index) else {
self.status_message = Some("no draft comment at selected line".to_string());
return true;
};
let Some(comment_id) = comment.id.clone() else {
self.status_message =
Some("draft is not persisted yet; try again after save".to_string());
return true;
};
self.comment_editor = Some(ReviewCommentEditor::edit(
anchor,
comment_id,
comment.body.clone(),
));
self.sync_selected_thread_to_anchor();
self.status_message =
Some("editing draft comment; enter/ctrl+s saves, esc cancels".to_string());
true
}
/// Submit the active comment composer with an explicit action.
pub fn submit_comment_editor_action(&mut self, action: ReviewCommentAction) -> bool {
let Some(editor) = &mut self.comment_editor else {
return false;
};
if !matches!(editor.mode, ReviewCommentEditorMode::Create) {
self.status_message =
Some("editing an existing draft; enter saves changes".to_string());
return true;
}
editor.action = action;
self.save_comment_editor()
}
/// Save the active draft comment editor.
pub fn save_comment_editor(&mut self) -> bool {
let Some(editor) = self.comment_editor.take() else {
return false;
};
let text = editor.buffer.text().trim().to_string();
if text.is_empty() {
self.status_message = Some("empty comment discarded".to_string());
return true;
}
let anchor = editor.anchor;
match editor.mode {
ReviewCommentEditorMode::Create => match editor.action {
ReviewCommentAction::SaveDraft => {
self.draft_comments.entry(anchor.clone()).or_default().push(
ReviewDraftComment {
id: None,
body: text.clone(),
persisted: false,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
},
);
self.pending_draft_save = Some(PendingDraftSave { anchor, body: text });
self.sync_selected_thread_to_anchor();
let count = self.draft_comment_count();
self.status_message = Some(format!("saved draft comment ({count} total)"));
}
ReviewCommentAction::AskBcode => {
self.queue_bcode_question(&anchor, text);
}
ReviewCommentAction::Publish => {
self.draft_comments.entry(anchor.clone()).or_default().push(
ReviewDraftComment {
id: None,
body: text.clone(),
persisted: false,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
},
);
self.pending_draft_save = Some(PendingDraftSave { anchor, body: text });
self.sync_selected_thread_to_anchor();
self.publish_readiness_ack = true;
self.publish_state = Some(ReviewPublishState::Checklist);
self.status_message =
Some("saved draft comment; ready to publish review".to_string());
}
},
ReviewCommentEditorMode::Edit {
comment_id,
previous_body,
} => {
self.update_local_draft_body(&anchor, &comment_id, text.clone());
self.pending_draft_update = Some(PendingDraftUpdate {
target: review_scope_for_workspace(&self.workspace).target().clone(),
scope: Some(review_scope_for_workspace(&self.workspace)),
anchor,
comment_id,
previous_body,
new_body: text,
});
self.status_message = Some("updated draft comment".to_string());
}
}
true
}
/// Take the pending draft save request, if present.
pub const fn take_pending_draft_save(&mut self) -> Option<PendingDraftSave> {
self.pending_draft_save.take()
}
/// Take the pending draft delete request, if present.
pub const fn take_pending_draft_delete(&mut self) -> Option<PendingDraftDelete> {
self.pending_draft_delete.take()
}
/// Take the pending draft update request, if present.
pub const fn take_pending_draft_update(&mut self) -> Option<PendingDraftUpdate> {
self.pending_draft_update.take()
}
/// Take the pending thread resolution request, if present.
pub const fn take_pending_thread_resolve(&mut self) -> Option<PendingThreadResolve> {
self.pending_thread_resolve.take()
}
/// Take the pending Bcode agent session request, if present.
pub const fn take_pending_agent_session(&mut self) -> Option<PendingAgentSession> {
self.pending_agent_session.take()
}
/// Take pending linked-session stream retry ids.
pub fn take_pending_agent_stream_retries(&mut self) -> Vec<String> {
std::mem::take(&mut self.pending_agent_stream_retries)
.into_iter()
.collect()
}
const fn touch_agent_state(&mut self) {
self.agent_state_revision = self.agent_state_revision.saturating_add(1);
}
/// Latest visible Bcode agent/session status for an anchor.
#[must_use]
pub fn agent_status_for_anchor(&self, anchor: &ReviewCommentAnchor) -> Option<String> {
self.agent_state_for_anchor(anchor)
.map(ReviewAgentThreadState::display_status)
}
/// Latest structured Bcode agent/session state for an anchor.
#[must_use]
pub fn agent_state_for_anchor(
&self,
anchor: &ReviewCommentAnchor,
) -> Option<&ReviewAgentThreadState> {
let key = Self::thread_key_for_anchor(anchor);
self.agent_thread_states.get(&key)
}
/// Set latest visible Bcode agent/session status for an anchor.
pub fn set_agent_status(&mut self, anchor: &ReviewCommentAnchor, status: impl Into<String>) {
let key = Self::thread_key_for_anchor(anchor);
let state = self
.agent_thread_states
.entry(key)
.or_insert_with(|| ReviewAgentThreadState::pending(String::new()));
state.status = status.into();
state.error = None;
self.touch_agent_state();
}
fn set_agent_phase(&mut self, anchor: &ReviewCommentAnchor, phase: ReviewAgentThreadPhase) {
let key = Self::thread_key_for_anchor(anchor);
let state = self
.agent_thread_states
.entry(key)
.or_insert_with(|| ReviewAgentThreadState::pending(String::new()));
state.phase = phase;
state.error = None;
self.touch_agent_state();
}
fn fail_agent_thread(&mut self, anchor: &ReviewCommentAnchor, error: impl Into<String>) {
let key = Self::thread_key_for_anchor(anchor);
let state = self
.agent_thread_states
.entry(key)
.or_insert_with(|| ReviewAgentThreadState::pending(String::new()));
let error = error.into();
state.phase = ReviewAgentThreadPhase::Failed;
state.status.clone_from(&error);
state.error = Some(error);
self.touch_agent_state();
}
/// Return linked session id for an anchor.
#[must_use]
pub fn session_id_for_anchor(&self, anchor: &ReviewCommentAnchor) -> Option<&str> {
self.ai_exchanges
.get(anchor)
.and_then(|exchanges| exchanges.last())
.and_then(|exchange| exchange.session_id.as_deref())
.or_else(|| {
self.draft_comments
.get(anchor)?
.last()?
.session_id
.as_deref()
})
}
pub(crate) fn create_ai_exchange(
&mut self,
anchor: ReviewCommentAnchor,
question: String,
session_id: Option<String>,
) -> String {
let ordinal = self
.ai_exchanges
.get(&anchor)
.map_or(1, |exchanges| exchanges.len().saturating_add(1));
let exchange_id = format!(
"exchange-{}-{ordinal}",
Self::thread_key_for_anchor(&anchor)
);
let status = if session_id.is_some() {
ModelReviewAiExchangeStatus::Linked
} else {
ModelReviewAiExchangeStatus::Pending
};
self.ai_exchanges
.entry(anchor.clone())
.or_default()
.push(ModelReviewAiExchange {
schema_version: bcode_code_review_models::REVIEW_AI_EXCHANGE_SCHEMA_VERSION,
exchange_id: exchange_id.clone(),
anchor: anchor.clone().into(),
question,
session_id,
status,
error: None,
created_at_ms: 0,
updated_at_ms: 0,
});
self.pending_ai_exchange_saves
.insert(exchange_id.clone(), anchor);
exchange_id
}
fn update_ai_exchange(
&mut self,
exchange_id: &str,
update: impl FnOnce(&mut ModelReviewAiExchange),
) {
let Some((anchor, exchange)) =
self.ai_exchanges
.iter_mut()
.find_map(|(anchor, exchanges)| {
exchanges
.iter_mut()
.find(|exchange| exchange.exchange_id == exchange_id)
.map(|exchange| (anchor.clone(), exchange))
})
else {
return;
};
update(exchange);
self.pending_ai_exchange_saves
.insert(exchange_id.to_string(), anchor);
}
/// Explicitly delete the latest AI exchange for the selected review anchor.
pub fn delete_latest_ai_exchange_at_selection(&mut self) -> bool {
let selected_thread_key = self.selected_thread_key();
let anchor = selected_thread_key.as_ref().and_then(|thread_key| {
self.ai_exchanges
.keys()
.find(|anchor| Self::thread_key_for_anchor(anchor) == *thread_key)
.cloned()
});
let Some(anchor) = anchor.or_else(|| self.selected_comment_anchor()) else {
self.status_message = Some("select an AI exchange to delete".to_string());
return true;
};
let Some(exchanges) = self.ai_exchanges.get_mut(&anchor) else {
self.status_message = Some("no AI exchange at selected location".to_string());
return true;
};
let Some(exchange) = exchanges.pop() else {
self.status_message = Some("no AI exchange at selected location".to_string());
return true;
};
if exchanges.is_empty() {
self.ai_exchanges.remove(&anchor);
}
self.pending_ai_exchange_saves.remove(&exchange.exchange_id);
self.pending_ai_exchange_delete = Some((exchange.exchange_id, anchor.clone()));
let key = Self::thread_key_for_anchor(&anchor);
if !self.ai_exchanges.contains_key(&anchor) {
self.agent_thread_states.remove(&key);
self.expanded_agent_answers.remove(&key);
}
self.touch_agent_state();
self.status_message = Some("deleted AI exchange; syncing cleanup".to_string());
true
}
/// Take an explicit AI exchange deletion request.
pub const fn take_pending_ai_exchange_delete(
&mut self,
) -> Option<(String, ReviewCommentAnchor)> {
self.pending_ai_exchange_delete.take()
}
/// Requeue a failed explicit AI exchange deletion.
pub fn requeue_ai_exchange_delete(&mut self, exchange_id: String, anchor: ReviewCommentAnchor) {
self.pending_ai_exchange_delete = Some((exchange_id, anchor));
}
/// Take the next AI exchange awaiting durable persistence.
pub fn take_pending_ai_exchange_save(&mut self) -> Option<ModelReviewAiExchange> {
let exchange_id = self.pending_ai_exchange_saves.keys().next()?.clone();
let anchor = self.pending_ai_exchange_saves.remove(&exchange_id)?;
self.ai_exchanges
.get(&anchor)?
.iter()
.find(|exchange| exchange.exchange_id == exchange_id)
.cloned()
}
/// Requeue a failed AI exchange persistence attempt.
pub fn requeue_ai_exchange_save(&mut self, exchange_id: &str) {
let Some(anchor) = self.ai_exchanges.iter().find_map(|(anchor, exchanges)| {
exchanges
.iter()
.any(|exchange| exchange.exchange_id == exchange_id)
.then(|| anchor.clone())
}) else {
return;
};
self.pending_ai_exchange_saves
.insert(exchange_id.to_string(), anchor);
}
/// Apply timestamps and lifecycle returned after persisting an AI exchange.
pub fn mark_ai_exchange_persisted(&mut self, persisted: &ModelReviewAiExchange) {
for exchanges in self.ai_exchanges.values_mut() {
if let Some(exchange) = exchanges
.iter_mut()
.find(|exchange| exchange.exchange_id == persisted.exchange_id)
{
exchange.clone_from(persisted);
return;
}
}
}
/// Mark an AI exchange as failed without persisting session transcript content.
pub fn mark_ai_exchange_failed(&mut self, exchange_id: &str, error: &str) {
self.update_ai_exchange(exchange_id, |exchange| {
exchange.status = ModelReviewAiExchangeStatus::Failed;
exchange.error = Some(error.to_string());
});
}
fn queue_suggestion_save(&mut self, anchor: ReviewCommentAnchor, id: String) {
self.pending_suggestion_saves.insert(id, anchor);
}
/// Take the next suggestion awaiting durable persistence.
pub fn take_pending_suggestion_save(&mut self) -> Option<ModelReviewSuggestion> {
let id = self.pending_suggestion_saves.keys().next()?.clone();
let anchor = self.pending_suggestion_saves.remove(&id)?;
let suggestion = self
.suggested_comments
.get(&anchor)?
.iter()
.find(|suggestion| suggestion.id == id)?;
Some(ModelReviewSuggestion {
suggestion_id: suggestion.id.clone(),
anchor: anchor.into(),
body: suggestion.body.clone(),
session_id: suggestion.session_id.clone(),
status: suggestion.status.into(),
rationale: suggestion.rationale.clone(),
created_at_ms: suggestion.created_at_ms.unwrap_or(0),
updated_at_ms: suggestion.updated_at_ms.unwrap_or(0),
})
}
/// Requeue a failed suggestion persistence attempt.
pub fn requeue_suggestion_save(&mut self, suggestion_id: &str) {
if let Some(anchor) = self
.suggested_comments
.iter()
.find_map(|(anchor, suggestions)| {
suggestions
.iter()
.any(|suggestion| suggestion.id == suggestion_id)
.then(|| anchor.clone())
})
{
self.queue_suggestion_save(anchor, suggestion_id.to_string());
}
}
/// Apply timestamps returned after persisting a suggestion.
pub fn mark_suggestion_persisted(&mut self, persisted: &ModelReviewSuggestion) {
for suggestions in self.suggested_comments.values_mut() {
if let Some(suggestion) = suggestions
.iter_mut()
.find(|suggestion| suggestion.id == persisted.suggestion_id)
{
suggestion.created_at_ms = Some(persisted.created_at_ms);
suggestion.updated_at_ms = Some(persisted.updated_at_ms);
return;
}
}
}
fn latest_pending_suggestion_mut(
&mut self,
anchor: &ReviewCommentAnchor,
) -> Option<&mut ReviewSuggestedComment> {
self.suggested_comments
.get_mut(anchor)
.and_then(|suggestions| {
suggestions.iter_mut().rev().find(|suggestion| {
matches!(
suggestion.status,
ReviewSuggestionStatus::Suggested | ReviewSuggestionStatus::Refining
)
})
})
}
/// Accept the latest suggested comment at the selected thread into normal drafts.
pub fn accept_suggestion_at_selection(&mut self) -> bool {
let Some(anchor) = self.selected_comment_anchor() else {
self.status_message = Some("select a suggested comment to accept".to_string());
return true;
};
let Some(suggestion) = self.latest_pending_suggestion_mut(&anchor) else {
self.status_message = Some("selected thread has no pending suggestion".to_string());
return true;
};
let body = suggestion.body.clone();
let session_id = suggestion.session_id.clone();
let suggestion_id = suggestion.id.clone();
suggestion.status = ReviewSuggestionStatus::Accepted;
self.queue_suggestion_save(anchor.clone(), suggestion_id);
self.draft_comments
.entry(anchor.clone())
.or_default()
.push(ReviewDraftComment {
id: None,
body: body.clone(),
persisted: false,
created_at_ms: None,
updated_at_ms: None,
session_id,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
});
self.pending_draft_save = Some(PendingDraftSave { anchor, body });
self.sync_selected_thread_to_anchor();
self.status_message = Some("accepted suggestion as draft comment".to_string());
true
}
/// Refine the latest suggested comment through the linked Bcode session.
pub fn refine_suggestion_at_selection(&mut self) -> bool {
let Some(anchor) = self.selected_comment_anchor() else {
self.status_message = Some("select a suggested comment to refine".to_string());
return true;
};
let Some(suggestion) = self.latest_pending_suggestion_mut(&anchor) else {
self.status_message = Some("selected thread has no pending suggestion".to_string());
return true;
};
let body = suggestion.body.clone();
let suggestion_id = suggestion.id.clone();
suggestion.status = ReviewSuggestionStatus::Refining;
self.queue_suggestion_save(anchor.clone(), suggestion_id);
let question = format!(
"Refine this suggested review comment. Keep it concise, actionable, and suitable for a code review comment.\n\n{body}"
);
self.queue_bcode_question(&anchor, question);
self.status_message = Some("asked Bcode to refine suggestion".to_string());
true
}
/// Reject the latest suggested comment at the selected thread.
pub fn reject_suggestion_at_selection(&mut self) -> bool {
let Some(anchor) = self.selected_comment_anchor() else {
self.status_message = Some("select a suggested comment to reject".to_string());
return true;
};
let Some(suggestion) = self.latest_pending_suggestion_mut(&anchor) else {
self.status_message = Some("selected thread has no pending suggestion".to_string());
return true;
};
let suggestion_id = suggestion.id.clone();
suggestion.status = ReviewSuggestionStatus::Rejected;
self.queue_suggestion_save(anchor.clone(), suggestion_id);
self.sync_selected_thread_to_anchor();
self.status_message = Some("rejected suggested comment".to_string());
true
}
/// Queue the latest visible Bcode answer for a host clipboard write.
pub fn queue_copy_agent_answer_at_selection(&mut self) -> bool {
let Some(anchor) = self.selected_comment_anchor() else {
self.status_message = Some("select a linked Bcode answer to copy".to_string());
return true;
};
let Some(state) = self.agent_state_for_anchor(&anchor) else {
self.status_message = Some("selected thread has no Bcode answer".to_string());
return true;
};
let answer = state.answer.trim().to_string();
if answer.is_empty() {
self.status_message = Some("selected Bcode thread has no answer to copy".to_string());
return true;
}
self.pending_clipboard_text = Some(answer);
self.status_message = Some("copying Bcode answer…".to_string());
true
}
/// Write queued clipboard text through the generic TUI host.
pub fn flush_pending_clipboard_text(&mut self, host: &dyn PluginTuiHost) -> bool {
let Some(text) = self.pending_clipboard_text.take() else {
return false;
};
self.status_message = Some(match host.copy_text(text) {
Ok(()) => "copied Bcode answer to clipboard".to_string(),
Err(error) => format!("failed to copy Bcode answer: {error}"),
});
true
}
/// Add a local suggested comment at the selected anchor from the latest Bcode answer.
pub fn suggest_comment_from_agent_answer_at_selection(&mut self) -> bool {
let Some(anchor) = self.selected_comment_anchor() else {
self.status_message = Some("select a linked Bcode answer to suggest".to_string());
return true;
};
let Some(state) = self.agent_state_for_anchor(&anchor) else {
self.status_message = Some("selected thread has no Bcode answer".to_string());
return true;
};
let body = state.answer.trim().to_string();
if body.is_empty() {
self.status_message =
Some("selected Bcode thread has no answer to suggest".to_string());
return true;
}
let session_id = state.session_id.clone();
let suggestions = self.suggested_comments.entry(anchor.clone()).or_default();
if suggestions.iter().any(|suggestion| {
suggestion.body.trim() == body && suggestion.status == ReviewSuggestionStatus::Suggested
}) {
self.status_message = Some("Bcode answer is already suggested".to_string());
return true;
}
let id = format!(
"suggestion-{}-{}",
Self::thread_key_for_anchor(&anchor),
suggestions.len().saturating_add(1)
);
suggestions.push(ReviewSuggestedComment {
id: id.clone(),
body,
session_id,
status: ReviewSuggestionStatus::Suggested,
rationale: Some("from linked Bcode answer".to_string()),
created_at_ms: None,
updated_at_ms: None,
});
self.queue_suggestion_save(anchor.clone(), id);
self.sync_selected_thread_to_anchor();
self.status_message = Some("added suggested review comment".to_string());
true
}
/// Retry the linked Bcode event stream for the selected thread.
pub fn retry_linked_session_stream_at_selection(&mut self) -> bool {
let Some(anchor) = self.selected_comment_anchor() else {
self.status_message = Some("select a linked Bcode thread to retry".to_string());
return true;
};
let session_id = self
.agent_state_for_anchor(&anchor)
.and_then(|state| state.session_id.clone())
.or_else(|| self.session_id_for_anchor(&anchor).map(ToString::to_string));
let Some(session_id) = session_id else {
self.status_message = Some("selected thread has no linked Bcode session".to_string());
return true;
};
self.pending_agent_stream_retries.insert(session_id.clone());
let key = Self::thread_key_for_anchor(&anchor);
let state = self
.agent_thread_states
.entry(key)
.or_insert_with(|| ReviewAgentThreadState::pending(String::new()));
state.session_id = Some(session_id.clone());
state.phase = ReviewAgentThreadPhase::Running;
state.status = "reconnecting linked session stream…".to_string();
state.stream_warning = None;
state.activity = Some("reconnecting stream".to_string());
state.error = None;
self.touch_agent_state();
self.status_message = Some(format!("retrying linked session stream {session_id}"));
true
}
/// Toggle full/compact rendering for the selected linked Bcode answer.
pub fn toggle_selected_agent_answer_expanded(&mut self) -> bool {
let Some(anchor) = self.selected_comment_anchor() else {
self.status_message = Some("select a linked Bcode answer to expand".to_string());
return true;
};
let Some(state) = self.agent_state_for_anchor(&anchor) else {
self.status_message = Some("selected thread has no Bcode answer".to_string());
return true;
};
if state.answer.trim().is_empty() {
self.status_message = Some("selected Bcode thread has no answer yet".to_string());
return true;
}
let thread_key = Self::thread_key_for_anchor(&anchor);
if self.expanded_agent_answers.remove(&thread_key) {
self.status_message = Some("collapsed Bcode answer preview".to_string());
} else {
self.expanded_agent_answers.insert(thread_key);
self.status_message = Some("expanded full Bcode answer".to_string());
}
self.ensure_selected_diff_line_visible();
true
}
/// Convert the latest visible Bcode answer at the selected thread into a draft comment.
pub fn convert_agent_answer_to_draft_at_selection(&mut self) -> bool {
let Some(anchor) = self.selected_comment_anchor() else {
self.status_message = Some("select a linked Bcode thread with an answer".to_string());
return true;
};
let Some(state) = self.agent_state_for_anchor(&anchor) else {
self.status_message = Some("selected thread has no Bcode answer".to_string());
return true;
};
let answer = state.answer.trim().to_string();
let session_id = state.session_id.clone();
if answer.is_empty() {
self.status_message = Some("selected Bcode thread has no answer to draft".to_string());
return true;
}
if self
.draft_comments
.get(&anchor)
.is_some_and(|comments| comments.iter().any(|comment| comment.body.trim() == answer))
{
self.status_message = Some("Bcode answer is already a draft comment".to_string());
return true;
}
self.draft_comments
.entry(anchor.clone())
.or_default()
.push(ReviewDraftComment {
id: None,
body: answer.clone(),
persisted: false,
created_at_ms: None,
updated_at_ms: None,
session_id,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
});
self.pending_draft_save = Some(PendingDraftSave {
anchor,
body: answer,
});
self.sync_selected_thread_to_anchor();
let count = self.draft_comment_count();
self.status_message = Some(format!("drafted Bcode answer ({count} total)"));
true
}
/// Mark the selected exchange as linked to a Bcode session.
pub fn mark_thread_session(
&mut self,
exchange_id: &str,
anchor: &ReviewCommentAnchor,
session_id: &str,
) {
self.update_ai_exchange(exchange_id, |exchange| {
exchange.session_id = Some(session_id.to_string());
exchange.status = ModelReviewAiExchangeStatus::Linked;
exchange.error = None;
});
let key = Self::thread_key_for_anchor(anchor);
let state = self
.agent_thread_states
.entry(key)
.or_insert_with(|| ReviewAgentThreadState::pending(String::new()));
state.session_id = Some(session_id.to_string());
state.phase = ReviewAgentThreadPhase::Running;
state.status = format!("session linked: {session_id}");
self.touch_agent_state();
}
/// Return linked Bcode session ids used by review threads.
#[must_use]
pub fn linked_agent_session_ids(&self) -> Vec<String> {
let mut ids = BTreeSet::new();
for exchanges in self.ai_exchanges.values() {
for exchange in exchanges {
if let Some(session_id) = &exchange.session_id {
ids.insert(session_id.clone());
}
}
}
for comments in self.draft_comments.values() {
for comment in comments {
if let Some(session_id) = &comment.session_id {
ids.insert(session_id.clone());
}
}
}
ids.into_iter().collect()
}
/// Apply a linked-session semantic snapshot presentation.
pub fn apply_agent_stream_state(
&mut self,
session_id: &str,
stream_state: &ReviewAgentSessionViewState,
) {
let mut changed = false;
let mut anchors = self
.ai_exchanges
.iter()
.filter(|(_, exchanges)| {
exchanges
.iter()
.any(|exchange| exchange.session_id.as_deref() == Some(session_id))
})
.map(|(anchor, _)| anchor.clone())
.collect::<BTreeSet<_>>();
anchors.extend(
self.draft_comments
.iter()
.filter(|(_, comments)| {
comments
.iter()
.any(|comment| comment.session_id.as_deref() == Some(session_id))
})
.map(|(anchor, _)| anchor.clone()),
);
for anchor in anchors {
let key = Self::thread_key_for_anchor(&anchor);
let state = self
.agent_thread_states
.entry(key)
.or_insert_with(|| ReviewAgentThreadState::pending(String::new()));
let answer = stream_state.display_answer();
if state.session_id.as_deref() != Some(session_id)
|| state.phase != stream_state.phase
|| state.status != stream_state.status
|| state.stream_warning != stream_state.stream_warning
|| state.activity != stream_state.activity
|| state.answer != answer
|| state.session_items != stream_state.items
|| state.session_revision != stream_state.snapshot.revision
|| state.error != stream_state.error
{
state.session_id = Some(session_id.to_string());
state.phase = stream_state.phase;
state.status.clone_from(&stream_state.status);
state
.stream_warning
.clone_from(&stream_state.stream_warning);
state.activity.clone_from(&stream_state.activity);
state.answer = answer;
state.session_items = stream_state.session_items().to_vec();
state.session_revision = stream_state.snapshot.revision;
state.error.clone_from(&stream_state.error);
changed = true;
}
}
if changed {
self.touch_agent_state();
}
}
/// Mark every thread for a linked session as failed.
pub fn mark_agent_session_failed(&mut self, session_id: &str, error: &str) {
let mut anchors = self
.ai_exchanges
.iter()
.filter(|(_, exchanges)| {
exchanges
.iter()
.any(|exchange| exchange.session_id.as_deref() == Some(session_id))
})
.map(|(anchor, _)| anchor.clone())
.collect::<BTreeSet<_>>();
anchors.extend(
self.draft_comments
.iter()
.filter(|(_, comments)| {
comments
.iter()
.any(|comment| comment.session_id.as_deref() == Some(session_id))
})
.map(|(anchor, _)| anchor.clone()),
);
for anchor in anchors {
self.fail_agent_thread(&anchor, error.to_string());
if let Some(exchanges) = self.ai_exchanges.get_mut(&anchor) {
for exchange in exchanges
.iter_mut()
.filter(|exchange| exchange.session_id.as_deref() == Some(session_id))
{
exchange.status = ModelReviewAiExchangeStatus::Failed;
exchange.error = Some(error.to_string());
self.pending_ai_exchange_saves
.insert(exchange.exchange_id.clone(), anchor.clone());
}
}
}
}
/// Restore a locally updated draft after persistence failure.
pub fn restore_updated_draft(&mut self, update: PendingDraftUpdate) {
self.update_local_draft_body(&update.anchor, &update.comment_id, update.previous_body);
self.sync_selected_thread_to_anchor();
}
/// Restore a locally deleted draft after persistence failure.
pub fn restore_deleted_draft(&mut self, delete: PendingDraftDelete) {
self.draft_comments
.entry(delete.anchor)
.or_default()
.push(delete.comment);
self.sync_selected_thread_to_anchor();
}
fn update_local_draft_body(
&mut self,
anchor: &ReviewCommentAnchor,
comment_id: &str,
body: String,
) {
let Some(comment) = self.draft_comments.get_mut(anchor).and_then(|comments| {
comments
.iter_mut()
.rev()
.find(|comment| comment.id.as_deref() == Some(comment_id))
}) else {
return;
};
comment.body = body;
comment.persisted = false;
}
fn sync_selected_thread_to_anchor(&mut self) {
let Some(anchor) = self.selected_comment_anchor() else {
return;
};
self.sync_selected_thread_to_specific_anchor(&anchor);
}
fn sync_selected_thread_to_view_target(&mut self, target: &ReviewViewTarget) {
let Some(anchor) = self.anchor_for_view_target(target) else {
return;
};
self.selected_file = anchor.file_index;
self.selected_diff_line = anchor.diff_row;
self.sync_selected_thread_to_specific_anchor(&anchor);
}
fn sync_selected_thread_to_specific_anchor(&mut self, anchor: &ReviewCommentAnchor) {
if let Some(index) = self
.thread_summaries()
.iter()
.position(|thread| thread.anchor == *anchor)
{
self.selected_thread = index;
}
}
fn anchor_for_view_target(&self, target: &ReviewViewTarget) -> Option<ReviewCommentAnchor> {
match target {
ReviewViewTarget::Thread { thread_key }
| ReviewViewTarget::AgentThread { thread_key }
| ReviewViewTarget::Comment { thread_key, .. }
| ReviewViewTarget::ThreadAction { thread_key, .. } => {
self.draft_anchor_for_thread_key(thread_key)
}
ReviewViewTarget::HunkHeader { .. }
| ReviewViewTarget::SourceLine { .. }
| ReviewViewTarget::OmittedContext { .. }
| ReviewViewTarget::ExpandedContextLine { .. } => None,
}
}
/// Render a local-first markdown review report.
#[must_use]
pub fn local_review_report_markdown(&self) -> String {
let (viewed, total) = self.viewed_file_counts();
let (open_threads, resolved_threads) = self.local_review_thread_status_counts();
let mut report = String::new();
let _ = write!(report, "# Local review: {}\n\n", self.review.title);
report.push_str("## Summary\n\n");
let _ = writeln!(report, "* Files viewed: {viewed}/{total}");
let _ = writeln!(report, "* Open threads: {open_threads}");
let _ = writeln!(report, "* Resolved threads: {resolved_threads}");
let _ = writeln!(report, "* Draft comments: {}", self.draft_comment_count());
report.push('\n');
let unviewed = self.unviewed_file_paths();
if !unviewed.is_empty() {
report.push_str("## Unviewed files\n\n");
for path in unviewed {
let _ = writeln!(report, "* `{path}`");
}
report.push('\n');
}
let threads = self.local_review_threads();
if threads.is_empty() {
report.push_str("## Review threads\n\nNo local review threads.\n");
return report;
}
report.push_str("## Review threads\n\n");
for thread in threads {
let status = if thread.is_open() { "open" } else { "resolved" };
let heading = if thread.anchor.is_review_level() {
"Review".to_string()
} else {
format!("`{}` {}", thread.anchor.path, thread.line_label())
};
let _ = write!(report, "### {heading} ({status})\n\n");
let _ = writeln!(
report,
"* Type: `{}` / `{}`",
review_thread_kind_label(thread.thread_kind),
review_thread_severity_label(thread.severity)
);
if let Some(session_id) = &thread.session_id {
let _ = write!(report, "Linked Bcode session: `{session_id}`\n\n");
}
for comment in thread.comments {
report.push_str("* ");
report.push_str(&comment.body.replace('\n', "\n "));
report.push('\n');
}
report.push('\n');
}
report
}
/// Return a concise review readiness label.
#[must_use]
pub fn review_readiness_label(&self) -> String {
let (viewed, total) = self.viewed_file_counts();
let unviewed = total.saturating_sub(viewed);
let (open_threads, _) = self.thread_status_counts();
if unviewed == 0 && open_threads == 0 {
return "ready".to_string();
}
let mut issues = Vec::new();
if unviewed > 0 {
issues.push(format!("{unviewed} unviewed"));
}
if open_threads > 0 {
issues.push(format!("{open_threads} open"));
}
format!("incomplete: {}", issues.join(", "))
}
/// Return warning message for publishing before review is complete.
#[must_use]
pub fn publish_readiness_warning(&self) -> Option<String> {
let (viewed, total) = self.viewed_file_counts();
let (open_threads, _) = self.thread_status_counts();
if viewed == total && open_threads == 0 {
return None;
}
let mut warnings = Vec::new();
if viewed < total {
warnings.push(format!("{viewed}/{total} files viewed"));
}
if open_threads > 0 {
warnings.push(format!("{open_threads} unresolved thread(s)"));
}
Some(format!(
"review incomplete: {}; press x again to publish anyway",
warnings.join(", ")
))
}
#[cfg(test)]
pub(crate) fn record_published_thread_for_test(
&mut self,
anchor: &ReviewCommentAnchor,
publisher_id: &str,
) {
self.published_review_threads.insert(
Self::thread_key_for_anchor(anchor),
publisher_id.to_string(),
);
}
/// Return provider-neutral local publish state for a thread.
#[must_use]
pub fn thread_publish_label(&self, anchor: &ReviewCommentAnchor) -> &'static str {
if self
.published_review_threads
.contains_key(&Self::thread_key_for_anchor(anchor))
{
"published"
} else {
"unpublished"
}
}
/// Return publisher id recorded for a published thread.
#[must_use]
pub fn thread_publisher_id(&self, anchor: &ReviewCommentAnchor) -> Option<&str> {
self.published_review_threads
.get(&Self::thread_key_for_anchor(anchor))
.map(String::as_str)
}
/// Toggle whether the selected review thread is included in publishing.
pub fn toggle_selected_publish_thread(&mut self) -> bool {
if !matches!(self.publish_state, Some(ReviewPublishState::Checklist)) {
return false;
}
let threads = self.thread_summaries();
let Some(thread) = threads.get(self.selected_thread.min(threads.len().saturating_sub(1)))
else {
self.status_message = Some("no review thread available to select".to_string());
return true;
};
let key = Self::thread_key_for_anchor(&thread.anchor);
if self.excluded_publish_threads.remove(&key) {
self.status_message = Some("included selected thread in publish".to_string());
} else {
self.excluded_publish_threads.insert(key);
self.status_message = Some("excluded selected thread from publish".to_string());
}
true
}
/// Return thread selection lines for publish preview/checklist.
#[must_use]
pub fn publish_thread_selection_lines(&self) -> Vec<String> {
self.thread_summaries()
.into_iter()
.enumerate()
.map(|(index, thread)| {
let key = Self::thread_key_for_anchor(&thread.anchor);
let marker = if self.excluded_publish_threads.contains(&key) {
"[ ] excluded"
} else {
"[x] included"
};
format!(
"{} {marker} {}:{} {}",
if index == self.selected_thread {
">"
} else {
" "
},
thread.anchor.scope_label(),
thread.line_label(),
truncate_chars(thread.latest_body, 80),
)
})
.collect()
}
fn excluded_publish_anchors(&self) -> Vec<ModelDraftAnchor> {
self.thread_summaries()
.into_iter()
.filter(|thread| {
self.excluded_publish_threads
.contains(&Self::thread_key_for_anchor(&thread.anchor))
})
.map(|thread| thread.anchor.into())
.collect()
}
/// Queue generic review publish.
pub fn publish_review(&mut self) -> bool {
if !self.publish_readiness_ack
&& let Some(warning) = self.publish_readiness_warning()
{
self.publish_readiness_ack = true;
self.status_message = Some(warning);
return true;
}
self.publish_state = Some(ReviewPublishState::Checklist);
self.status_message = None;
true
}
/// Show loaded publishers.
pub fn show_publishers(&mut self, publishers: Vec<ReviewPublisherManifest>) {
self.publishers = publishers;
self.selected_publisher = self
.publishers
.iter()
.position(|publisher| publisher.id == DEFAULT_PUBLISHER_ID)
.unwrap_or(0);
self.publish_state = Some(ReviewPublishState::Picker);
self.status_message = None;
}
/// Return publisher for id.
#[must_use]
pub fn publisher_for_id(&self, publisher_id: &str) -> Option<&ReviewPublisherManifest> {
self.publishers
.iter()
.find(|publisher| publisher.id == publisher_id)
}
/// Show publisher preview.
pub fn show_publish_preview(&mut self, publisher_id: String, preview: String) {
self.publish_state = Some(ReviewPublishState::Preview {
publisher_id,
options: self.current_publish_options(),
preview,
selection_summary: self.publish_thread_selection_lines(),
scroll: 0,
});
self.status_message = None;
}
/// Keep the configured options available after a preview failure.
pub fn fail_publish_preview(&mut self, error: &str) {
let Some(ReviewPublishState::Options {
publisher_id,
options,
selected,
}) = self.publish_state.take()
else {
self.status_message = Some(format!("publish preview failed: {error}"));
return;
};
self.publish_state = Some(ReviewPublishState::Options {
publisher_id,
options,
selected,
});
self.status_message = Some(format!("publish preview failed: {error}"));
}
/// Return to preview after a submission failure so it can be retried safely.
pub fn fail_publish_submit(&mut self, error: &str) {
if let Some(ReviewPublishState::ConfirmSubmit {
publisher_id,
options,
preview,
selection_summary,
scroll,
}) = self.publish_state.take()
{
self.publish_state = Some(ReviewPublishState::Preview {
publisher_id,
options,
preview,
selection_summary,
scroll,
});
}
self.status_message = Some(format!("publish failed: {error}"));
}
/// Finish publish flow.
pub fn finish_publish(&mut self, response: PublishReviewResponse) {
if response.submitted {
for thread in self.thread_summaries() {
let key = Self::thread_key_for_anchor(&thread.anchor);
if !self.excluded_publish_threads.contains(&key) {
self.published_review_threads
.insert(key, response.publisher_id.clone());
}
}
}
self.publish_state = None;
self.publish_readiness_ack = false;
self.status_message = Some(response.message);
}
/// Move publish UI selection down.
pub fn publish_down(&mut self, rows: usize) -> bool {
match &mut self.publish_state {
Some(ReviewPublishState::Picker) => {
let max = self.publishers.len().saturating_sub(1);
let next = self.selected_publisher.saturating_add(rows).min(max);
if next == self.selected_publisher {
return false;
}
self.selected_publisher = next;
true
}
Some(ReviewPublishState::Options {
selected, options, ..
}) => {
let max = options.len().saturating_sub(1);
let next = selected.saturating_add(rows).min(max);
if next == *selected {
return false;
}
*selected = next;
true
}
Some(
ReviewPublishState::Preview {
scroll, preview, ..
}
| ReviewPublishState::ConfirmSubmit {
scroll, preview, ..
},
) => {
let max = preview.lines().count().saturating_sub(1);
let next = scroll.saturating_add(rows).min(max);
if next == *scroll {
return false;
}
*scroll = next;
true
}
Some(ReviewPublishState::Checklist) | None => false,
}
}
/// Move publish UI selection up.
pub const fn publish_up(&mut self, rows: usize) -> bool {
match &mut self.publish_state {
Some(ReviewPublishState::Picker) => {
let next = self.selected_publisher.saturating_sub(rows);
if next == self.selected_publisher {
return false;
}
self.selected_publisher = next;
true
}
Some(ReviewPublishState::Options { selected, .. }) => {
let next = selected.saturating_sub(rows);
if next == *selected {
return false;
}
*selected = next;
true
}
Some(
ReviewPublishState::Preview { scroll, .. }
| ReviewPublishState::ConfirmSubmit { scroll, .. },
) => {
let next = scroll.saturating_sub(rows);
if next == *scroll {
return false;
}
*scroll = next;
true
}
Some(ReviewPublishState::Checklist) | None => false,
}
}
/// Return true when publisher options are active.
#[must_use]
pub const fn publish_options_active(&self) -> bool {
matches!(self.publish_state, Some(ReviewPublishState::Options { .. }))
}
/// Return selected publisher option index.
#[must_use]
pub const fn selected_publish_option_index(&self) -> usize {
match &self.publish_state {
Some(ReviewPublishState::Options { selected, .. }) => *selected,
_ => 0,
}
}
/// Insert text into the selected publisher option.
pub fn insert_publish_option_text(&mut self, text: &str) -> bool {
let Some(ReviewPublishState::Options {
options, selected, ..
}) = &mut self.publish_state
else {
return false;
};
let Some(option) = options.get_mut(*selected) else {
return false;
};
option.value.push_str(text);
true
}
/// Cycle the selected publish option when it has enumerated choices.
pub fn cycle_selected_publish_option(&mut self, offset: isize) -> bool {
let Some(ReviewPublishState::Options {
options, selected, ..
}) = &mut self.publish_state
else {
return false;
};
cycle_option(options, *selected, offset, &mut self.status_message)
}
/// Edit selected publisher option.
pub fn edit_publish_option(&mut self, stroke: KeyStroke) -> bool {
let Some(ReviewPublishState::Options {
options, selected, ..
}) = &mut self.publish_state
else {
return false;
};
edit_option(options, *selected, stroke)
}
/// Return publish readiness checklist lines.
#[must_use]
pub fn publish_checklist_lines(&self) -> Vec<String> {
let (viewed, total) = self.viewed_file_counts();
let unviewed = total.saturating_sub(viewed);
let (open_threads, resolved_threads) = self.thread_status_counts();
let drafts = self.draft_comment_count();
let readiness = if unviewed == 0 && open_threads == 0 {
"✓ ready to publish".to_string()
} else {
"! review has remaining attention items".to_string()
};
let mut lines = vec![
readiness,
format!("files viewed: {viewed}/{total}"),
format!("unviewed files: {unviewed} (W jump)"),
];
lines.extend(
self.unviewed_file_paths()
.into_iter()
.take(3)
.map(|path| format!(" • {path}")),
);
lines.push(format!("open threads: {open_threads} (P jump)"));
lines.extend(
self.open_local_review_threads()
.into_iter()
.take(3)
.map(|thread| format!(" • {} {}", thread.anchor.path, thread.line_label())),
);
lines.extend([
format!("resolved threads: {resolved_threads}"),
format!("draft comments: {drafts}"),
"press ! for attention sidebar".to_string(),
]);
lines
}
fn current_publish_options(&self) -> Vec<ReviewPublishOption> {
match &self.publish_state {
Some(
ReviewPublishState::Options { options, .. }
| ReviewPublishState::Preview { options, .. }
| ReviewPublishState::ConfirmSubmit { options, .. },
) => options.clone(),
Some(ReviewPublishState::Checklist | ReviewPublishState::Picker) | None => Vec::new(),
}
}
/// Return from submit confirmation to preview.
pub fn back_to_publish_preview(&mut self) -> bool {
let Some(ReviewPublishState::ConfirmSubmit {
publisher_id,
options,
preview,
selection_summary,
scroll,
}) = self.publish_state.take()
else {
return false;
};
self.publish_state = Some(ReviewPublishState::Preview {
publisher_id,
options,
preview,
selection_summary,
scroll,
});
self.status_message = Some("returned to review publish preview".to_string());
true
}
/// Confirm current publish UI selection.
pub fn confirm_publish_selection(&mut self) -> bool {
match &self.publish_state {
Some(ReviewPublishState::Checklist) => {
self.pending_publish_request = Some(PendingPublishRequest::ListPublishers);
self.publish_readiness_ack = false;
self.status_message = Some("loading review publishers".to_string());
true
}
Some(ReviewPublishState::Picker) => {
let Some(publisher) = self.publishers.get(self.selected_publisher) else {
self.status_message = Some("no publisher selected".to_string());
return true;
};
let options = options_from_schema(&publisher.options_schema);
if options.is_empty() {
let publisher_id = publisher.id.clone();
let publisher_label = publisher.label.clone();
self.pending_publish_request = Some(PendingPublishRequest::Preview {
publisher_id,
options,
excluded_thread_anchors: self.excluded_publish_anchors(),
});
self.status_message = Some(format!("previewing publisher {publisher_label}"));
} else {
self.publish_state = Some(ReviewPublishState::Options {
publisher_id: publisher.id.clone(),
options,
selected: 0,
});
self.status_message = None;
}
true
}
Some(ReviewPublishState::Options {
publisher_id,
options,
..
}) => {
self.pending_publish_request = Some(PendingPublishRequest::Preview {
publisher_id: publisher_id.clone(),
options: options.clone(),
excluded_thread_anchors: self.excluded_publish_anchors(),
});
self.status_message = Some(format!("previewing publisher {publisher_id}"));
true
}
Some(ReviewPublishState::Preview { .. }) => {
let Some(ReviewPublishState::Preview {
publisher_id,
options,
preview,
selection_summary,
scroll,
}) = self.publish_state.take()
else {
return false;
};
self.status_message = Some(format!("confirm submit via {publisher_id}"));
self.publish_state = Some(ReviewPublishState::ConfirmSubmit {
publisher_id,
options,
preview,
selection_summary,
scroll,
});
true
}
Some(ReviewPublishState::ConfirmSubmit {
publisher_id,
options,
..
}) => {
self.pending_publish_request = Some(PendingPublishRequest::Submit {
publisher_id: publisher_id.clone(),
options: options.clone(),
excluded_thread_anchors: self.excluded_publish_anchors(),
});
self.status_message = Some(format!("submitting review via {publisher_id}"));
true
}
None => false,
}
}
/// Take pending review publish request.
pub const fn take_publish_request(&mut self) -> Option<PendingPublishRequest> {
self.pending_publish_request.take()
}
/// Take pending linked session open request.
pub const fn take_session_to_open(&mut self) -> Option<SessionId> {
self.session_to_open.take()
}
/// Open linked session for the selected thread.
pub fn open_linked_session_at_selection(&mut self) -> bool {
let Some(anchor) = self.selected_comment_anchor() else {
self.status_message = Some("select a linked review thread to open".to_string());
return true;
};
let Some(session_id) = self.session_id_for_anchor(&anchor) else {
self.status_message = Some("no linked session for selected thread".to_string());
return true;
};
match session_id.parse::<SessionId>() {
Ok(session_id) => {
self.session_to_open = Some(session_id);
self.status_message = Some("opening linked Bcode session…".to_string());
}
Err(_) => {
self.status_message = Some("linked session id is invalid".to_string());
}
}
true
}
/// Report a failed native-session navigation without discarding retained review state.
pub fn navigation_failed(&mut self, session_id: SessionId, message: &str) {
self.status_message = Some(format!(
"could not open linked session {session_id}: {message}"
));
}
/// Run a structured AI review action at the selected anchor.
pub fn run_ai_command_at_selection(&mut self, command: ReviewAiCommand) -> bool {
let Some(anchor) = self.selected_comment_anchor() else {
self.status_message = Some(format!(
"select a review line, range, or thread to {}",
command.label()
));
return true;
};
let question = command.instruction().to_string();
let existing_session = self.session_id_for_anchor(&anchor).map(ToString::to_string);
let exchange_id =
self.create_ai_exchange(anchor.clone(), question.clone(), existing_session.clone());
let ask = PendingAgentSession {
exchange_id,
anchor: anchor.clone(),
draft_body: Some(question.clone()),
command,
};
self.pending_agent_session = Some(ask.clone());
let mut state = ReviewAgentThreadState::pending(question);
state.context_summary = self.ai_context_summary(&anchor);
let prompt_preview = self.agent_session_prompt(&ask);
state.context_summary = format!(
"{} · {} chars",
state.context_summary,
prompt_preview.chars().count()
);
if let Some(session_id) = existing_session {
state.session_id = Some(session_id);
state.phase = ReviewAgentThreadPhase::Running;
state.status = format!("sending {} request…", command.label());
}
self.agent_thread_states
.insert(Self::thread_key_for_anchor(&anchor), state);
self.touch_agent_state();
self.sync_selected_thread_to_specific_anchor(&anchor);
self.status_message = Some(format!("asked Bcode to {}", command.label()));
true
}
/// Ask Bcode about the selected review line/thread.
pub fn ask_bcode_about_selection(&mut self) -> bool {
self.open_comment_editor_with_action(ReviewCommentAction::AskBcode)
}
fn queue_bcode_question(&mut self, anchor: &ReviewCommentAnchor, question: String) {
let existing_session = self.session_id_for_anchor(anchor).map(ToString::to_string);
let exchange_id =
self.create_ai_exchange(anchor.clone(), question.clone(), existing_session.clone());
let ask = PendingAgentSession {
exchange_id,
anchor: anchor.clone(),
draft_body: Some(question.clone()),
command: ReviewAiCommand::Analyze,
};
self.pending_agent_session = Some(ask.clone());
let mut state = ReviewAgentThreadState::pending(question);
state.context_summary = format!(
"{} · {} chars",
self.ai_context_summary(anchor),
self.agent_session_prompt(&ask).chars().count()
);
if let Some(session_id) = &existing_session {
state.session_id = Some(session_id.clone());
state.phase = ReviewAgentThreadPhase::Running;
state.status = "sending follow-up…".to_string();
}
self.agent_thread_states
.insert(Self::thread_key_for_anchor(anchor), state);
self.touch_agent_state();
self.sync_selected_thread_to_anchor();
self.status_message = Some(existing_session.map_or_else(
|| "asked Bcode; creating linked review session".to_string(),
|session_id| format!("asked Bcode in linked session {session_id}"),
));
}
/// Expand all inline review threads.
pub fn expand_all_inline_threads(&mut self) -> bool {
if self.collapsed_review_threads.is_empty() {
return false;
}
self.collapsed_review_threads.clear();
self.status_message = Some("expanded all review threads".to_string());
self.ensure_selected_diff_line_visible();
true
}
/// Collapse all inline review threads in the current view.
pub fn collapse_all_inline_threads(&mut self) -> bool {
let keys = self
.inline_thread_summaries()
.into_iter()
.map(|thread| Self::thread_key_for_anchor(&thread.anchor))
.collect::<BTreeSet<_>>();
if keys.is_empty() || keys == self.collapsed_review_threads {
return false;
}
self.collapsed_review_threads = keys;
self.status_message = Some("collapsed all review threads".to_string());
self.ensure_selected_diff_line_visible();
true
}
/// Toggle selected inline thread collapsed state.
pub fn toggle_selected_inline_thread(&mut self) -> bool {
let Some(ReviewViewTarget::Thread { thread_key }) = self.selected_view_target.clone()
else {
return false;
};
self.preserve_viewport_while(|app| {
if app.collapsed_review_threads.remove(&thread_key) {
app.status_message = Some("expanded review thread".to_string());
} else {
app.collapsed_review_threads.insert(thread_key);
app.status_message = Some("collapsed review thread".to_string());
}
true
})
}
/// Expand a collapsed unchanged diff context block.
pub fn expand_diff_context(&mut self, key: &DiffContextKey) -> bool {
self.preserve_viewport_while(|app| {
if matches!(
app.diff_context_load_states.get(key),
Some(DiffContextLoadState::Loading)
) {
return false;
}
if let Some(path) = app.selected_file_path() {
let cached_file_available = app
.file_cache
.get(&path)
.is_some_and(|file| file.unavailable_reason.is_none());
if cached_file_available {
app.diff_context_load_states
.insert(key.clone(), DiffContextLoadState::Loaded);
} else {
app.file_cache.remove(&path);
app.pending_context_file_load = Some(path);
app.diff_context_load_states
.insert(key.clone(), DiffContextLoadState::Loading);
}
}
app.selected_view_target = None;
app.status_message = Some("expanded hidden diff context".to_string());
true
})
}
/// Activate the currently selected inline action, if any.
pub fn activate_selected_inline_action(&mut self) -> bool {
if let Some(ReviewViewTarget::OmittedContext { key }) = self.selected_view_target.clone() {
return self.expand_diff_context(&key);
}
let Some(ReviewViewTarget::ThreadAction { action, .. }) = self.selected_view_target.clone()
else {
return false;
};
match ReviewThreadAction::from_id(&action) {
Some(ReviewThreadAction::Reply) => self.open_comment_editor(),
Some(ReviewThreadAction::Edit) => self.open_latest_draft_editor(),
Some(ReviewThreadAction::Delete) => self.delete_latest_draft_at_selection(),
Some(ReviewThreadAction::DeleteExchange) => {
self.delete_latest_ai_exchange_at_selection()
}
Some(ReviewThreadAction::AskBcode | ReviewThreadAction::FollowUp) => {
self.ask_bcode_about_selection()
}
Some(ReviewThreadAction::OpenSession) => self.open_linked_session_at_selection(),
Some(ReviewThreadAction::RetrySession) => {
self.retry_linked_session_stream_at_selection()
}
Some(ReviewThreadAction::ToggleAnswer) => self.toggle_selected_agent_answer_expanded(),
Some(ReviewThreadAction::CopyAnswer) => self.queue_copy_agent_answer_at_selection(),
Some(ReviewThreadAction::SuggestAnswer) => {
self.suggest_comment_from_agent_answer_at_selection()
}
Some(ReviewThreadAction::AcceptSuggestion) => self.accept_suggestion_at_selection(),
Some(ReviewThreadAction::RefineSuggestion) => self.refine_suggestion_at_selection(),
Some(ReviewThreadAction::RejectSuggestion) => self.reject_suggestion_at_selection(),
Some(ReviewThreadAction::DraftAnswer) => {
self.convert_agent_answer_to_draft_at_selection()
}
Some(ReviewThreadAction::Publish) => self.publish_review(),
Some(ReviewThreadAction::Resolve) => self.resolve_selected_thread(),
Some(ReviewThreadAction::Reopen) => self.reopen_selected_thread(),
None => false,
}
}
/// Activate selected review target, if it has a primary action.
pub fn activate_selected_review_target(&mut self) -> bool {
self.activate_selected_inline_action() || self.toggle_selected_inline_thread()
}
/// Toggle resolved inline review thread visibility.
pub fn toggle_show_resolved_threads(&mut self) -> bool {
self.show_resolved_threads = !self.show_resolved_threads;
if self.show_resolved_threads {
self.status_message = Some("showing resolved review threads".to_string());
} else {
self.status_message = Some("hiding resolved review threads".to_string());
if self.selected_view_target.as_ref().is_some_and(|target| {
self.anchor_for_view_target(target).is_some_and(|anchor| {
self.resolved_review_threads
.contains(&Self::thread_key_for_anchor(&anchor))
})
}) {
self.selected_view_target = None;
}
}
self.ensure_selected_diff_line_visible();
true
}
/// Toggle selected thread resolved state locally.
pub fn toggle_selected_thread_resolved(&mut self) -> bool {
let Some(thread_key) = self.selected_thread_key() else {
self.status_message = Some("select a review thread to resolve".to_string());
return true;
};
if self.resolved_review_threads.contains(&thread_key) {
self.reopen_thread_key(&thread_key)
} else {
self.resolve_thread_key(&thread_key)
}
}
/// Resolve selected thread locally.
pub fn resolve_selected_thread(&mut self) -> bool {
let Some(thread_key) = self.selected_thread_key() else {
self.status_message = Some("select a review thread to resolve".to_string());
return true;
};
self.resolve_thread_key(&thread_key)
}
/// Reopen selected thread locally.
pub fn reopen_selected_thread(&mut self) -> bool {
let Some(thread_key) = self.selected_thread_key() else {
self.status_message = Some("select a review thread to reopen".to_string());
return true;
};
self.reopen_thread_key(&thread_key)
}
fn resolve_thread_key(&mut self, thread_key: &str) -> bool {
let Some(anchor) = self.anchor_for_thread_key(thread_key) else {
self.status_message = Some("select a review thread to resolve".to_string());
return true;
};
self.resolved_review_threads.insert(thread_key.to_string());
self.pending_thread_resolve = Some(PendingThreadResolve {
target: review_scope_for_workspace(&self.workspace).target().clone(),
scope: Some(review_scope_for_workspace(&self.workspace)),
anchor,
resolved: true,
});
self.status_message = Some("resolved review thread".to_string());
true
}
fn reopen_thread_key(&mut self, thread_key: &str) -> bool {
let Some(anchor) = self.anchor_for_thread_key(thread_key) else {
self.status_message = Some("select a review thread to reopen".to_string());
return true;
};
self.resolved_review_threads.remove(thread_key);
self.pending_thread_resolve = Some(PendingThreadResolve {
target: review_scope_for_workspace(&self.workspace).target().clone(),
scope: Some(review_scope_for_workspace(&self.workspace)),
anchor,
resolved: false,
});
self.status_message = Some("reopened review thread".to_string());
true
}
fn anchor_for_thread_key(&self, thread_key: &str) -> Option<ReviewCommentAnchor> {
self.draft_comments
.keys()
.find(|anchor| Self::thread_key_for_anchor(anchor) == thread_key)
.cloned()
}
fn selected_thread_key(&self) -> Option<String> {
match self.selected_view_target.as_ref()? {
ReviewViewTarget::Thread { thread_key }
| ReviewViewTarget::AgentThread { thread_key }
| ReviewViewTarget::Comment { thread_key, .. }
| ReviewViewTarget::ThreadAction { thread_key, .. } => Some(thread_key.clone()),
ReviewViewTarget::HunkHeader { .. }
| ReviewViewTarget::SourceLine { .. }
| ReviewViewTarget::OmittedContext { .. }
| ReviewViewTarget::ExpandedContextLine { .. } => self
.selected_comment_anchor()
.map(|anchor| Self::thread_key_for_anchor(&anchor)),
}
}
/// Return a prompt for a pending Bcode agent session.
#[must_use]
pub fn agent_session_prompt(&self, ask: &PendingAgentSession) -> String {
let hunk = bounded_lines(
&self.hunk_context_for_anchor(&ask.anchor),
MAX_AI_HUNK_LINES,
);
let selected_lines = bounded_lines(
&self.selected_lines_for_anchor(&ask.anchor),
MAX_AI_SELECTED_LINES,
);
let thread_summaries = self.bounded_thread_summaries(&ask.anchor);
let source_summary = self.review_source_summary();
let context_summary = self.ai_context_summary(&ask.anchor);
let prompt = format!(
"You are helping with a local code review in Bcode.\n\nReview: {}\nRepository: {}\nTarget/sources: {}\nFile: {}\nDiff rows: {}-{}\nOld range: {}-{}\nNew range: {}-{}\nLine kind: {:?}\nContext sent: {}\n\nOther review threads (bounded):\n{}\n\nCurrent draft/comment:\n{}\n\nSelected diff lines:\n```diff\n{}\n```\n\nNearby diff hunk/context:\n```diff\n{}\n```\n\nReview context is also available through the code-review plugin service. The relevant interface is `bcode.code_review/v1`; useful operations are `review.context.get`, `review.comments.list`, `review.thread.get`, and `review.diff.get`. Request payloads include `repo_path` plus the review `target`; `review.thread.get` accepts `thread_id` or `anchor`, and `review.diff.get` accepts optional `file_path`.\n\nRequested review action: {}\nKeep the anchored file and line context in mind. If broader context is needed, query the typed review services or inspect the repository from the session working directory.",
self.review.title,
display_from_current_dir(&self.review.repo_root),
source_summary,
ask.anchor.path,
ask.anchor.start_diff_row(),
ask.anchor.end_diff_row(),
ask.anchor
.old_start
.map_or_else(|| "none".to_string(), |line| line.to_string()),
ask.anchor
.old_end
.map_or_else(|| "none".to_string(), |line| line.to_string()),
ask.anchor
.new_start
.map_or_else(|| "none".to_string(), |line| line.to_string()),
ask.anchor
.new_end
.map_or_else(|| "none".to_string(), |line| line.to_string()),
ask.anchor.line_kind,
context_summary,
thread_summaries,
ask.draft_body.as_deref().unwrap_or("(no draft body yet)"),
selected_lines,
hunk,
ask.command.instruction(),
);
truncate_chars(prompt, MAX_AI_CONTEXT_CHARS)
}
/// Return compact user-facing metadata for context sent at an anchor.
#[must_use]
pub fn ai_context_summary(&self, anchor: &ReviewCommentAnchor) -> String {
let thread_count = self.thread_summaries().len();
let included_sources = self
.workspace
.sources
.iter()
.filter(|source| source.included)
.count();
format!(
"{} selected row(s), nearby hunk, {}/{} thread summaries, {} source(s)",
anchor
.end_diff_row()
.saturating_sub(anchor.start_diff_row())
.saturating_add(1),
thread_count.min(MAX_AI_THREAD_SUMMARIES),
thread_count,
included_sources,
)
}
fn review_source_summary(&self) -> String {
let labels = self
.workspace
.sources
.iter()
.filter(|source| source.included)
.map(|source| source.kind.label())
.take(8)
.collect::<Vec<_>>();
if labels.is_empty() {
self.review.title.clone()
} else {
labels.join(", ")
}
}
fn bounded_thread_summaries(&self, current: &ReviewCommentAnchor) -> String {
let summaries = self
.thread_summaries()
.into_iter()
.filter(|thread| thread.anchor != *current)
.take(MAX_AI_THREAD_SUMMARIES)
.map(|thread| {
let state = if thread.resolved { "resolved" } else { "open" };
format!(
"* {}:{} [{state}] {}",
thread.anchor.scope_label(),
thread.line_label(),
truncate_chars(thread.latest_body, MAX_AI_THREAD_BODY_CHARS),
)
})
.collect::<Vec<_>>();
if summaries.is_empty() {
"* none".to_string()
} else {
summaries.join("\n")
}
}
fn selected_lines_for_anchor(&self, anchor: &ReviewCommentAnchor) -> String {
let Some(file) = self.review.files.get(anchor.file_index) else {
return String::new();
};
let rows = rendered_rows_for_prompt(file);
let start = anchor.start_diff_row();
let end = anchor.end_diff_row();
rows.into_iter()
.enumerate()
.filter_map(|(index, row)| (start..=end).contains(&index).then_some(row))
.collect::<Vec<_>>()
.join("\n")
}
fn hunk_context_for_anchor(&self, anchor: &ReviewCommentAnchor) -> String {
let Some(file) = self.review.files.get(anchor.file_index) else {
return String::new();
};
let mut row = 0usize;
for hunk in &file.hunks {
let hunk_start_row = row;
row = row.saturating_add(1);
let hunk_end_row = row.saturating_add(hunk.lines.len());
if anchor.diff_row < hunk_start_row || anchor.diff_row >= hunk_end_row {
row = hunk_end_row;
continue;
}
let mut lines = Vec::with_capacity(hunk.lines.len().saturating_add(1));
lines.push(format!(
"@@ -{},{} +{},{} @@{}",
hunk.old_start,
hunk.old_count,
hunk.new_start,
hunk.new_count,
hunk.heading
.as_ref()
.map_or(String::new(), |heading| format!(" {heading}")),
));
lines.extend(hunk.lines.iter().map(|line| {
let marker = match line.kind {
ReviewLineKind::Context => ' ',
ReviewLineKind::Added => '+',
ReviewLineKind::Removed => '-',
};
format!("{marker}{}", line.content)
}));
return lines.join("\n");
}
String::new()
}
/// Delete the latest draft comment at the selected line.
pub fn delete_latest_draft_at_selection(&mut self) -> bool {
let Some((anchor, selected_index)) = self
.selected_draft_anchor_and_comment_index()
.or_else(|| self.selected_comment_anchor().map(|anchor| (anchor, None)))
else {
self.status_message = Some("select a commented line to delete a draft".to_string());
return true;
};
let Some(comments) = self.draft_comments.get_mut(&anchor) else {
self.status_message = Some("no draft comment at selected line".to_string());
return true;
};
if comments.is_empty() {
self.status_message = Some("no draft comment at selected line".to_string());
return true;
}
let index = selected_index.unwrap_or_else(|| comments.len().saturating_sub(1));
if index >= comments.len() {
self.status_message = Some("no draft comment at selected line".to_string());
return true;
}
let comment = comments.remove(index);
let was_persisted = comment.persisted && comment.id.is_some();
if comments.is_empty() {
self.draft_comments.remove(&anchor);
self.selected_view_target = None;
}
self.pending_draft_delete = Some(PendingDraftDelete {
target: review_scope_for_workspace(&self.workspace).target().clone(),
scope: Some(review_scope_for_workspace(&self.workspace)),
anchor,
comment,
});
self.status_message = Some(if was_persisted {
"deleted draft comment; syncing delete".to_string()
} else {
"discarded unsaved draft comment".to_string()
});
true
}
/// Restore local thread resolution state after failed persistence.
pub fn restore_thread_resolution(&mut self, resolve: &PendingThreadResolve) {
let thread_key = Self::thread_key_for_anchor(&resolve.anchor);
if resolve.resolved {
self.resolved_review_threads.remove(&thread_key);
} else {
self.resolved_review_threads.insert(thread_key);
}
}
/// Return a footer preview for the selected thread.
#[must_use]
pub fn selected_thread_preview(&self) -> Option<String> {
let thread = self
.visible_thread_summaries()
.get(self.selected_thread)?
.clone();
let range = if thread.anchor.start_diff_row() == thread.anchor.end_diff_row() {
format!("@{}", thread.anchor.start_diff_row())
} else {
format!(
"@{}-{}",
thread.anchor.start_diff_row(),
thread.anchor.end_diff_row()
)
};
let linked = thread
.session_id
.as_deref()
.map_or(String::new(), |session_id| format!(" 🤖 {session_id}"));
let status = if thread.resolved { "resolved" } else { "open" };
let external =
thread
.external_provider_id
.as_deref()
.map_or_else(String::new, |provider| {
let mapping =
thread
.external_mapping_status
.map_or("external", |status| match status {
ExternalAnchorMappingStatus::Exact => "mapped",
ExternalAnchorMappingStatus::Approximate => "stale",
ExternalAnchorMappingStatus::Unmappable => "unmappable",
});
format!(" external:{provider}/{mapping}")
});
Some(format!(
" {status} thread {} {range} x{}:{linked}{external} {} Enter jump r resolve/reopen a ask/follow up o open ",
thread.anchor.path, thread.draft_count, thread.latest_body
))
}
/// Return a short preview for the selected commentable source target.
#[must_use]
pub fn selected_comment_target_preview(&self) -> Option<String> {
if self.selected_draft_preview().is_some() || self.selected_suggestion_preview().is_some() {
return None;
}
let anchor = self.selected_comment_anchor()?;
if anchor.is_review_level() {
return Some("review-level target selected".to_string());
}
if anchor.kind() == ReviewAnchorKind::File {
return Some(format!("file target selected: {}", anchor.path));
}
let line = anchor.new_line.or(anchor.old_line).map_or_else(
|| format!("row {}", anchor.diff_row.saturating_add(1)),
|line| format!("line {line}"),
);
Some(format!("comment target selected: {} {line}", anchor.path))
}
/// Return a short preview for the selected line's latest suggested comment.
#[must_use]
pub fn selected_suggestion_preview(&self) -> Option<String> {
let anchor = self.selected_comment_anchor()?;
let suggestions = self.suggested_comments.get(&anchor)?;
let suggestion = suggestions.last()?;
let status = match suggestion.status {
ReviewSuggestionStatus::Suggested => "suggested",
ReviewSuggestionStatus::Refining => "refining",
ReviewSuggestionStatus::Accepted => "accepted suggestion",
ReviewSuggestionStatus::Rejected => "rejected suggestion",
};
Some(format!(
"{} {status}: {}",
suggestions.len(),
suggestion.body
))
}
/// Return a short preview for the selected line's latest draft comment.
#[must_use]
pub fn selected_draft_preview(&self) -> Option<String> {
let (anchor, selected_index) = self
.selected_draft_anchor_and_comment_index()
.or_else(|| self.selected_comment_anchor().map(|anchor| (anchor, None)))?;
let comments = self.draft_comments.get(&anchor)?;
let index = selected_index.unwrap_or_else(|| comments.len().saturating_sub(1));
let comment = comments.get(index)?;
let persistence = if comment.persisted {
"saved"
} else {
"unsaved"
};
Some(format!(
"draft {}/{} ({persistence}): {}",
index.saturating_add(1),
comments.len(),
comment.body
))
}
/// Return linked session id for the selected line's latest draft comment.
#[must_use]
pub fn selected_draft_session_id(&self) -> Option<&str> {
let (anchor, selected_index) = self
.selected_draft_anchor_and_comment_index()
.or_else(|| self.selected_comment_anchor().map(|anchor| (anchor, None)))?;
let comments = self.draft_comments.get(&anchor)?;
let index = selected_index.unwrap_or_else(|| comments.len().saturating_sub(1));
comments.get(index)?.session_id.as_deref()
}
/// Restore provider-neutral per-thread publish state from durable records.
pub fn load_publish_records(
&mut self,
records: Vec<bcode_code_review_models::ReviewPublishRecord>,
) {
for record in records.into_iter().filter(|record| record.submitted) {
for anchor in record.published_thread_anchors {
let local: DraftAnchor = anchor.into();
let draft = DraftComment {
comment_id: String::new(),
thread_id: String::new(),
anchor: local,
body: String::new(),
created_at_ms: record.created_at_ms,
updated_at_ms: record.created_at_ms,
session_id: None,
resolved_at_ms: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
};
if let Some(anchor) = self.anchor_from_persisted_draft(&draft) {
self.published_review_threads.insert(
Self::thread_key_for_anchor(&anchor),
record.publisher_id.clone(),
);
}
}
}
}
/// Load persisted, non-publishable AI exchanges into local review state.
fn load_persisted_ai_exchanges(&mut self, exchanges: Vec<ModelReviewAiExchange>) {
for exchange in exchanges {
let draft = DraftComment {
comment_id: String::new(),
thread_id: String::new(),
anchor: exchange.anchor.clone().into(),
body: String::new(),
created_at_ms: exchange.created_at_ms,
updated_at_ms: exchange.updated_at_ms,
session_id: exchange.session_id.clone(),
resolved_at_ms: None,
thread_kind: ReviewThreadKind::Question,
severity: ReviewThreadSeverity::Info,
};
if let Some(anchor) = self.anchor_from_persisted_draft(&draft) {
let key = Self::thread_key_for_anchor(&anchor);
let state = self
.agent_thread_states
.entry(key)
.or_insert_with(|| ReviewAgentThreadState::pending(exchange.question.clone()));
if state.question.is_empty() {
state.question.clone_from(&exchange.question);
}
match (&exchange.session_id, exchange.status) {
(Some(session_id), _) => {
state.session_id = Some(session_id.clone());
state.phase = ReviewAgentThreadPhase::Running;
state.status = "reconnecting linked session…".to_string();
state.error = None;
}
(None, ModelReviewAiExchangeStatus::Pending) => {
state.phase = ReviewAgentThreadPhase::Pending;
state.status = "question saved; waiting to create session".to_string();
state.error = None;
}
(None, ModelReviewAiExchangeStatus::Failed) => {
state.phase = ReviewAgentThreadPhase::Failed;
state.status = "linked session failed".to_string();
state.error.clone_from(&exchange.error);
}
(None, ModelReviewAiExchangeStatus::Linked) => {
state.phase = ReviewAgentThreadPhase::Failed;
state.status = "linked session metadata is incomplete".to_string();
state.error = Some("linked exchange is missing its session id".to_string());
}
}
self.ai_exchanges.entry(anchor).or_default().push(exchange);
}
}
self.touch_agent_state();
}
/// Load persisted suggested comments into local state.
fn load_persisted_suggestions(&mut self, suggestions: Vec<ModelReviewSuggestion>) {
for suggestion in suggestions {
let draft = DraftComment {
comment_id: String::new(),
thread_id: String::new(),
anchor: suggestion.anchor.clone().into(),
body: String::new(),
created_at_ms: suggestion.created_at_ms,
updated_at_ms: suggestion.updated_at_ms,
session_id: suggestion.session_id.clone(),
resolved_at_ms: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
};
if let Some(anchor) = self.anchor_from_persisted_draft(&draft) {
self.suggested_comments
.entry(anchor)
.or_default()
.push(ReviewSuggestedComment {
id: suggestion.suggestion_id,
body: suggestion.body,
session_id: suggestion.session_id,
status: suggestion.status.into(),
rationale: suggestion.rationale,
created_at_ms: Some(suggestion.created_at_ms),
updated_at_ms: Some(suggestion.updated_at_ms),
});
}
}
}
/// Load persisted draft comments into local state.
fn load_persisted_drafts(&mut self, drafts: Vec<DraftComment>) {
for draft in drafts {
if let Some(anchor) = self.anchor_from_persisted_draft(&draft) {
self.draft_comments
.entry(anchor.clone())
.or_default()
.push(ReviewDraftComment {
id: Some(draft.comment_id),
body: draft.body,
persisted: true,
created_at_ms: Some(draft.created_at_ms),
updated_at_ms: Some(draft.updated_at_ms),
session_id: draft.session_id,
thread_kind: draft.thread_kind,
severity: draft.severity,
});
if draft.resolved_at_ms.is_some() {
self.resolved_review_threads
.insert(Self::thread_key_for_anchor(&anchor));
}
}
}
}
fn anchor_from_persisted_draft(&self, draft: &DraftComment) -> Option<ReviewCommentAnchor> {
if draft.anchor.kind == ReviewAnchorKind::Review {
return Some(ReviewCommentAnchor {
file_index: self
.selected_file
.min(self.review.files.len().saturating_sub(1)),
path: REVIEW_LEVEL_ANCHOR_PATH.to_string(),
diff_row: 0,
end_diff_row: None,
old_line: None,
new_line: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: false,
surface_id: draft.anchor.surface_id.clone(),
source_id: draft.anchor.source_id.clone(),
});
}
let diff_row = usize::try_from(draft.anchor.diff_row).ok()?;
let end_diff_row = draft
.anchor
.end_diff_row
.and_then(|row| usize::try_from(row).ok())
.filter(|row| *row != diff_row);
let file_index = self
.review
.files
.iter()
.position(|file| file.display_path() == draft.anchor.file_path)?;
Some(ReviewCommentAnchor {
file_index,
path: draft.anchor.file_path.clone(),
diff_row,
end_diff_row,
old_line: draft.anchor.old_line,
new_line: draft.anchor.new_line,
old_start: draft.anchor.old_start.or(draft.anchor.old_line),
old_end: draft.anchor.old_end.or(draft.anchor.old_line),
new_start: draft.anchor.new_start.or(draft.anchor.new_line),
new_end: draft.anchor.new_end.or(draft.anchor.new_line),
line_kind: draft.anchor.line_kind,
is_file_anchor: draft.anchor.is_file_anchor,
surface_id: draft.anchor.surface_id.clone(),
source_id: draft.anchor.source_id.clone(),
})
}
/// Return the selected diff line comment anchor, if the selected row is commentable.
#[must_use]
pub fn selected_comment_anchor(&self) -> Option<ReviewCommentAnchor> {
self.selected_draft_anchor_and_comment_index()
.map(|(anchor, _)| anchor)
.or_else(|| {
self.selected_review_view_target()
.as_ref()
.and_then(|target| self.comment_anchor_for_target(target))
})
}
fn comment_anchor_for_target(&self, target: &ReviewViewTarget) -> Option<ReviewCommentAnchor> {
match target {
ReviewViewTarget::SourceLine { source_row, .. } => {
self.comment_anchor_for_row(*source_row)
}
ReviewViewTarget::HunkHeader { .. }
| ReviewViewTarget::OmittedContext { .. }
| ReviewViewTarget::ExpandedContextLine { .. }
| ReviewViewTarget::Thread { .. }
| ReviewViewTarget::AgentThread { .. }
| ReviewViewTarget::Comment { .. }
| ReviewViewTarget::ThreadAction { .. } => None,
}
}
fn selected_draft_anchor_and_comment_index(
&self,
) -> Option<(ReviewCommentAnchor, Option<usize>)> {
match self.selected_view_target.as_ref()? {
ReviewViewTarget::Thread { thread_key }
| ReviewViewTarget::AgentThread { thread_key }
| ReviewViewTarget::ThreadAction { thread_key, .. } => self
.draft_anchor_for_thread_key(thread_key)
.map(|anchor| (anchor, None)),
ReviewViewTarget::Comment {
thread_key,
comment_index,
} => self
.draft_anchor_for_thread_key(thread_key)
.map(|anchor| (anchor, Some(*comment_index))),
ReviewViewTarget::HunkHeader { .. }
| ReviewViewTarget::SourceLine { .. }
| ReviewViewTarget::OmittedContext { .. }
| ReviewViewTarget::ExpandedContextLine { .. } => None,
}
}
fn draft_anchor_for_thread_key(&self, thread_key: &str) -> Option<ReviewCommentAnchor> {
self.draft_comments
.keys()
.chain(self.ai_exchanges.keys())
.chain(self.suggested_comments.keys())
.find(|anchor| Self::thread_key_for_anchor(anchor) == thread_key)
.cloned()
}
fn thread_key_for_anchor(anchor: &ReviewCommentAnchor) -> String {
ReviewThreadAnchor {
file_index: anchor.file_index,
path: anchor.path.clone(),
source_row: anchor.diff_row,
end_source_row: anchor.end_diff_row,
}
.thread_key()
}
/// Return a comment anchor for a rendered diff row.
#[must_use]
pub fn comment_anchor_for_row(&self, diff_row: usize) -> Option<ReviewCommentAnchor> {
let file = self.selected_file_data()?;
let (surface_id, source_id) = self.selected_surface_ids();
if self.review.is_repository_review() {
let (start_row, end_row) = self.selected_range_bounds().unwrap_or((diff_row, diff_row));
let start_line = u32::try_from(start_row.saturating_add(1)).ok()?;
let end_line = u32::try_from(end_row.saturating_add(1)).ok()?;
return Some(ReviewCommentAnchor {
file_index: self.selected_file,
path: file.display_path().to_string(),
diff_row: start_row,
end_diff_row: (end_row != start_row).then_some(end_row),
old_line: None,
new_line: Some(start_line),
old_start: None,
old_end: None,
new_start: Some(start_line),
new_end: Some(end_line),
line_kind: ReviewLineKind::Context,
is_file_anchor: true,
surface_id,
source_id,
});
}
if self
.selected_surface()
.is_some_and(|surface| surface.kind == ReviewSurfaceKind::File)
{
let (start_row, end_row) = self.selected_range_bounds().unwrap_or((diff_row, diff_row));
let start_line = self.materialized_file_line_number(start_row)?;
let end_line = self.materialized_file_line_number(end_row)?;
return Some(ReviewCommentAnchor {
file_index: self.selected_file,
path: file.display_path().to_string(),
diff_row: start_row,
end_diff_row: (end_row != start_row).then_some(end_row),
old_line: None,
new_line: Some(start_line),
old_start: None,
old_end: None,
new_start: Some(start_line),
new_end: Some(end_line),
line_kind: ReviewLineKind::Context,
is_file_anchor: true,
surface_id,
source_id,
});
}
let (start_row, end_row) = self.selected_range_bounds().unwrap_or((diff_row, diff_row));
let start_line = self.diff_line_for_render_row(start_row)?;
let end_line = self.diff_line_for_render_row(end_row)?;
let (surface_id, source_id) = self.selected_surface_ids();
Some(ReviewCommentAnchor {
file_index: self.selected_file,
path: file.display_path().to_string(),
diff_row: start_row,
end_diff_row: (end_row != start_row).then_some(end_row),
old_line: start_line.old_line,
new_line: start_line.new_line,
old_start: start_line.old_line.or(end_line.old_line),
old_end: end_line.old_line.or(start_line.old_line),
new_start: start_line.new_line.or(end_line.new_line),
new_end: end_line.new_line.or(start_line.new_line),
line_kind: start_line.kind,
is_file_anchor: false,
surface_id,
source_id,
})
}
fn materialized_file_line_number(&self, diff_row: usize) -> Option<u32> {
let file = self.selected_file_data()?;
let rows = materialized_file_surface_rows(file);
rows.get(diff_row).and_then(|(line, _)| *line)
}
fn diff_line_for_render_row(&self, diff_row: usize) -> Option<&ReviewLine> {
let file = self.selected_file_data()?;
if file.is_binary {
return None;
}
let mut row = 0usize;
for hunk in &file.hunks {
if diff_row == row {
return None;
}
row = row.saturating_add(1);
let hunk_line_index = diff_row.checked_sub(row)?;
if hunk_line_index < hunk.lines.len() {
return hunk.lines.get(hunk_line_index);
}
row = row.saturating_add(hunk.lines.len());
}
None
}
/// Return current hunk position as one-based `(current, total)`.
#[must_use]
pub fn hunk_position(&self) -> (usize, usize) {
let Some(document) = self.current_review_view_document() else {
return (1, 0);
};
let targets = document.hunk_targets();
let total = targets.len();
let selected_visual_row = self.selected_diff_visual_row();
let current = targets
.iter()
.position(|target| {
document
.visual_row_for_target(target)
.is_some_and(|row| row > selected_visual_row)
})
.unwrap_or(total)
.max(1);
(current, total)
}
fn scroll_selected_target_into_view(&mut self) {
let height = self
.last_diff_area
.map_or(1, |area| usize::from(area.height).max(1));
let selected_visual_row = self.selected_diff_visual_row();
if selected_visual_row < self.diff_scroll {
self.diff_scroll = selected_visual_row;
} else if selected_visual_row >= self.diff_scroll.saturating_add(height) {
self.diff_scroll = selected_visual_row.saturating_sub(height.saturating_sub(1));
}
self.diff_scroll = self.diff_scroll.min(self.max_diff_scroll());
}
fn ensure_selected_diff_line_visible(&mut self) {
self.scroll_selected_target_into_view();
}
fn selected_diff_visual_row(&self) -> usize {
let document = self.current_review_view_document();
if let Some(target) = &self.selected_view_target
&& let Some(visual_row) = document
.as_ref()
.and_then(|document| document.visual_row_for_target(target))
{
return visual_row;
}
document
.as_ref()
.and_then(|document| document.visual_row_for_source_row(self.selected_diff_line))
.or_else(|| {
document.as_ref().and_then(|document| {
document.rows.iter().find_map(|row| match &row.target {
ReviewViewTarget::Thread { thread_key }
if self.collapsed_review_threads.contains(thread_key) =>
{
Some(row.visual_row)
}
_ => None,
})
})
})
.unwrap_or(self.selected_diff_line)
}
fn max_diff_scroll(&self) -> usize {
self.rendered_diff_len().saturating_sub(
self.last_diff_area
.map_or(1, |area| usize::from(area.height).max(1)),
)
}
fn rendered_diff_len(&self) -> usize {
self.current_review_view_document()
.map_or_else(
|| self.source_rendered_diff_len(),
|document| document.rows.len(),
)
.max(1)
}
fn source_rendered_diff_len(&self) -> usize {
if self.review.is_repository_review() {
let Some(path) = self.selected_file_path() else {
return 1;
};
return self
.file_cache
.get(&path)
.map_or(1, |file| file.line_spans.len().max(1));
}
let Some(file) = self.selected_file_data() else {
return 1;
};
if self
.selected_surface()
.is_some_and(|surface| surface.kind == ReviewSurfaceKind::File)
{
return materialized_file_surface_len(file).max(1);
}
if file.is_binary {
return 1;
}
file.hunks
.iter()
.map(|hunk| hunk.lines.len().saturating_add(1))
.sum::<usize>()
.max(1)
}
/// Build the semantic view document for the current main review pane.
#[must_use]
pub fn current_review_view_document(&self) -> Option<ReviewViewDocument> {
let cache_key = self.review_view_document_cache_key()?;
if let Ok(cache) = self.view_document_cache.read()
&& let Some(cached) = cache.as_ref()
&& cached.key == cache_key
{
return Some(cached.document.clone());
}
let mut inline_comments = self.draft_comments.clone();
for (anchor, exchanges) in &self.ai_exchanges {
inline_comments
.entry(anchor.clone())
.or_default()
.extend(exchanges.iter().map(|exchange| ReviewDraftComment {
id: Some(exchange.exchange_id.clone()),
body: exchange.question.clone(),
persisted: exchange.created_at_ms != 0,
created_at_ms: (exchange.created_at_ms != 0).then_some(exchange.created_at_ms),
updated_at_ms: (exchange.updated_at_ms != 0).then_some(exchange.updated_at_ms),
session_id: exchange.session_id.clone(),
thread_kind: ReviewThreadKind::Question,
severity: ReviewThreadSeverity::Info,
}));
}
let mut document = if self.review.is_repository_review() {
let path = self.selected_file_path()?;
let cached = self.file_cache.get(&path)?;
if cached.unavailable_reason.is_some() {
return None;
}
ReviewViewDocument::build_repository_file(self.selected_file, cached)
} else {
let file = self.selected_file_data()?;
if file.is_binary {
return None;
}
if self
.selected_surface()
.is_some_and(|surface| surface.kind == ReviewSurfaceKind::File)
{
ReviewViewDocument::build_materialized_file_surface(self.selected_file, file)
} else {
let selected_path = self.selected_file_path();
let cached_file = selected_path
.as_ref()
.and_then(|path| self.file_cache.get(path));
ReviewViewDocument::build_diff_file(
self.selected_file,
file,
true,
self.syntax_palette,
cached_file,
&self.diff_context_load_states,
)
}
};
document = document.with_inline_draft_threads(
self.selected_file,
inline_comments.iter().map(|(anchor, comments)| {
(
ReviewThreadAnchor {
file_index: anchor.file_index,
path: anchor.path.clone(),
source_row: anchor.diff_row,
end_source_row: anchor.end_diff_row,
},
comments.clone(),
)
}),
self.suggested_comments.iter().map(|(anchor, suggestions)| {
(
ReviewThreadAnchor {
file_index: anchor.file_index,
path: anchor.path.clone(),
source_row: anchor.diff_row,
end_source_row: anchor.end_diff_row,
},
suggestions.clone(),
)
}),
&self.agent_thread_states,
&self.expanded_agent_answers,
&self.collapsed_review_threads,
&self.resolved_review_threads,
self.show_resolved_threads,
);
document = document.layout_inline_threads(cache_key.content_width);
if let Ok(mut cache) = self.view_document_cache.write() {
*cache = Some(ReviewViewDocumentCache {
key: cache_key,
document: document.clone(),
});
}
Some(document)
}
#[allow(clippy::too_many_lines)]
fn review_view_document_cache_key(&self) -> Option<ReviewViewDocumentCacheKey> {
let selected_path = self.selected_file_path();
let surface_kind = self.selected_surface().map(|surface| surface.kind);
let file_signature = if self.review.is_repository_review() {
let path = selected_path.as_ref()?;
let cached = self.file_cache.get(path)?;
format!(
"repo:{}:{}",
cached.line_spans.len(),
cached.unavailable_reason.as_deref().unwrap_or_default()
)
} else {
let file = self.selected_file_data()?;
let path = file.display_path();
let cache_state = self.file_cache.get(path).map_or_else(
|| {
if self.pending_context_file_load.as_deref() == Some(path) {
"loading".to_string()
} else {
"missing".to_string()
}
},
|cached| {
format!(
"loaded:{}:{}",
cached.line_spans.len(),
cached.unavailable_reason.as_deref().unwrap_or_default()
)
},
);
format!(
"diff:{}:{}:{}:{}:{}",
path,
file.additions,
file.deletions,
file.hunks
.iter()
.map(|hunk| hunk.lines.len().saturating_add(1))
.sum::<usize>(),
cache_state
)
};
let mut draft_signature = String::new();
for (anchor, comments) in self
.draft_comments
.iter()
.filter(|(anchor, _)| anchor.file_index == self.selected_file)
{
write!(
draft_signature,
"{}:{}-{:?}:",
anchor.path, anchor.diff_row, anchor.end_diff_row
)
.expect("writing to String cannot fail");
for (index, comment) in comments.iter().enumerate() {
if index > 0 {
draft_signature.push(',');
}
write!(
draft_signature,
"{:?}:{}:{}:{}",
comment.id,
comment.body.len(),
comment.persisted,
comment.session_id.as_deref().unwrap_or_default()
)
.expect("writing to String cannot fail");
}
draft_signature.push(';');
}
for (anchor, exchanges) in self
.ai_exchanges
.iter()
.filter(|(anchor, _)| anchor.file_index == self.selected_file)
{
write!(
draft_signature,
"ai:{}:{}-{:?}:",
anchor.path, anchor.diff_row, anchor.end_diff_row
)
.expect("writing to String cannot fail");
for exchange in exchanges {
write!(
draft_signature,
"{}:{}:{:?}:{};",
exchange.exchange_id,
exchange.question.len(),
exchange.status,
exchange.session_id.as_deref().unwrap_or_default()
)
.expect("writing to String cannot fail");
}
}
for (anchor, suggestions) in self
.suggested_comments
.iter()
.filter(|(anchor, _)| anchor.file_index == self.selected_file)
{
write!(
draft_signature,
"suggest:{}:{}-{:?}:",
anchor.path, anchor.diff_row, anchor.end_diff_row
)
.expect("writing to String cannot fail");
for suggestion in suggestions {
write!(
draft_signature,
"{}:{}:{:?};",
suggestion.id,
suggestion.body.len(),
suggestion.status
)
.expect("writing to String cannot fail");
}
}
Some(ReviewViewDocumentCacheKey {
content_width: self.last_diff_area.map_or(80, |area| area.width),
selected_file: self.selected_file,
selected_path,
surface_kind,
repository_review: self.review.is_repository_review(),
file_signature,
draft_signature,
expanded_agent_answers: self.expanded_agent_answers.iter().cloned().collect(),
collapsed_threads: self.collapsed_review_threads.iter().cloned().collect(),
resolved_threads: self.resolved_review_threads.iter().cloned().collect(),
expanded_contexts: self
.diff_context_load_states
.iter()
.map(|(key, state)| (key.clone(), state.clone()))
.collect(),
show_resolved_threads: self.show_resolved_threads,
agent_state_revision: self.agent_state_revision,
theme_fingerprint: self.theme_fingerprint,
})
}
}
fn materialized_file_surface_len(file: &ReviewFile) -> usize {
materialized_file_surface_rows(file).len()
}
/// Return current sidebar width for an app and terminal width.
#[must_use]
pub fn sidebar_width(app: &ReviewApp, width: u16) -> u16 {
if app.sidebar_visible && width >= 80 {
FILE_SIDEBAR_WIDTH.min(width.saturating_sub(30))
} else {
0
}
}
fn rendered_rows_for_prompt(file: &ReviewFile) -> Vec<String> {
let mut rows = Vec::new();
for hunk in &file.hunks {
rows.push(format!(
"@@ -{},{} +{},{} @@{}",
hunk.old_start,
hunk.old_count,
hunk.new_start,
hunk.new_count,
hunk.heading
.as_ref()
.map_or(String::new(), |heading| format!(" {heading}")),
));
rows.extend(hunk.lines.iter().map(|line| {
let marker = match line.kind {
ReviewLineKind::Context => ' ',
ReviewLineKind::Added => '+',
ReviewLineKind::Removed => '-',
};
format!("{marker}{}", line.content)
}));
}
rows
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct ClipboardTestHost {
copied: Mutex<Vec<String>>,
keymap: bcode_plugin_sdk::tui::keymap::KeymapRegistry,
}
impl PluginTuiHost for ClipboardTestHost {
fn keymap_action(&self, scope: &str, stroke: KeyStroke) -> Option<String> {
self.keymap.action_for_key(scope, stroke).map(str::to_owned)
}
fn keymap_is_overridden(&self, scope: &str, stroke: KeyStroke) -> bool {
self.keymap.is_overridden_key(scope, stroke)
}
fn spawn(&self, _task: bcode_plugin_sdk::tui::PluginTask) {}
fn spawn_blocking(&self, _task: Box<dyn FnOnce() + Send + 'static>) {}
fn request_redraw(&self) {}
fn copy_text(&self, text: String) -> Result<(), bcode_plugin_sdk::tui::PluginTuiHostError> {
self.copied.lock().expect("clipboard lock").push(text);
Ok(())
}
}
fn test_plugin_theme(red: u8) -> bcode_plugin_sdk::tui::PluginTuiTheme {
let style = bmux_tui::style::Style::new();
let syntax_color = bcode_plugin_sdk::tui::PluginTuiSyntaxColor::rgb(red, 34, 56);
bcode_plugin_sdk::tui::PluginTuiTheme {
component_theme_version: bcode_plugin_sdk::tui::PLUGIN_TUI_COMPONENT_THEME_VERSION,
canvas: style,
text: style,
muted: style,
border: style,
focused: style,
selection: style.add_modifier(bmux_tui::style::Modifier::REVERSED),
source: bcode_plugin_sdk::tui::PluginTuiSourceTheme {
source: style,
border: style,
gutter: style,
truncated: style,
},
diff: bcode_plugin_sdk::tui::PluginTuiDiffTheme {
text: style,
muted: style,
title: style,
label: style,
added: style,
removed: style,
hunk: style,
added_row: style,
removed_row: style,
added_emphasis: style,
removed_emphasis: style,
},
syntax: bcode_plugin_sdk::tui::PluginTuiSyntaxTheme {
text: syntax_color,
comment: syntax_color,
keyword: syntax_color,
function: syntax_color,
variable: syntax_color,
string: syntax_color,
number: syntax_color,
type_name: syntax_color,
operator: syntax_color,
punctuation: syntax_color,
heading: syntax_color,
link: syntax_color,
raw: syntax_color,
},
}
}
#[test]
fn theme_fingerprint_invalidates_bounded_review_document_cache() {
let mut app = sample_app();
let first = app
.current_review_view_document()
.expect("initial document");
assert!(
app.view_document_cache
.read()
.expect("cache read")
.is_some()
);
app.set_tui_theme(Some(&test_plugin_theme(12)));
assert!(
app.view_document_cache
.read()
.expect("cache read")
.is_none()
);
let themed = app.current_review_view_document().expect("themed document");
assert_eq!(first.rows.len(), themed.rows.len());
let themed_fingerprint = app.theme_fingerprint;
assert_ne!(themed_fingerprint, 0);
assert_eq!(
app.view_document_cache
.read()
.expect("cache read")
.as_ref()
.expect("one cached document")
.key
.theme_fingerprint,
themed_fingerprint
);
app.set_tui_theme(Some(&test_plugin_theme(12)));
assert!(
app.view_document_cache
.read()
.expect("same identity preserves cache")
.is_some()
);
app.set_tui_theme(Some(&test_plugin_theme(99)));
assert!(
app.view_document_cache
.read()
.expect("changed identity invalidates cache")
.is_none()
);
}
fn paint_comment_input(app: &mut ReviewApp, width: u16, height: u16) {
let mut buffer = bmux_tui::buffer::Buffer::empty(Rect::new(0, 0, width, height));
let mut frame = bmux_tui::frame::Frame::new(&mut buffer);
crate::code_review_tui_render::render(
app,
&mut bmux_tui::paint::PaintCx::new(&mut frame),
None,
);
}
fn comment_completion(app: &ReviewApp, replacement: &str) -> FocusedTextFieldEdit {
use bcode_plugin_sdk::composer::{ComposerEdit, ComposerTextRange};
let expected = app
.focused_comment_input()
.expect("visible focused comment");
let edit = ComposerEdit {
document_id: expected.document.document_id.clone(),
revision: expected.document.revision,
range: ComposerTextRange {
start: 0,
end: expected.document.text.len(),
},
expected_text: expected.document.text.clone(),
replacement: replacement.to_owned(),
cursor: replacement.len(),
};
FocusedTextFieldEdit { expected, edit }
}
#[test]
fn comment_completion_updates_live_buffer_and_history_without_submitting() {
let mut app = sample_app();
app.open_review_comment_editor();
app.comment_editor.as_mut().unwrap().buffer.insert_str("#é");
paint_comment_input(&mut app, 100, 30);
let request = comment_completion(&app, "review 界🙂");
let result = app.apply_comment_input(&request);
assert_eq!(result.edit_result, FocusedTextFieldEditResult::Applied);
assert!(app.draft_comments.is_empty());
let editor = app.comment_editor.as_mut().unwrap();
assert_eq!(editor.buffer.text(), "review 界🙂");
assert!(editor.buffer.undo());
assert_eq!(editor.buffer.text(), "#é");
assert!(editor.buffer.redo());
assert_eq!(editor.buffer.text(), "review 界🙂");
assert!(app.focused_comment_input().is_none());
paint_comment_input(&mut app, 100, 30);
assert!(app.save_comment_editor());
assert!(
app.draft_comments
.values()
.flatten()
.any(|comment| comment.body == "review 界🙂")
);
}
#[test]
fn comment_completion_noop_and_selection_rejection_are_consumed() {
let mut app = sample_app();
app.open_review_comment_editor();
app.comment_editor.as_mut().unwrap().buffer = TextEditBuffer::from_text("hello");
paint_comment_input(&mut app, 100, 30);
let request = comment_completion(&app, "hello");
let state = app.comment_editor.as_ref().unwrap().buffer.edit_state();
let result = app.apply_comment_input(&request);
assert_eq!(result.edit_result, FocusedTextFieldEditResult::Noop);
assert_eq!(result.event.outcome, bmux_tui::event::EventOutcome::Handled);
assert_eq!(
app.comment_editor.as_ref().unwrap().buffer.edit_state(),
state
);
app.comment_editor
.as_mut()
.unwrap()
.buffer
.select_to_wrapped_position(70, 0, 0);
let result = app.apply_comment_input(&request);
assert!(matches!(
result.edit_result,
FocusedTextFieldEditResult::Rejected(_)
));
assert_eq!(result.event.outcome, bmux_tui::event::EventOutcome::Handled);
assert!(app.draft_comments.is_empty());
assert!(
app.comment_editor
.as_ref()
.unwrap()
.buffer
.selection()
.is_some()
);
}
#[test]
fn comment_completion_requires_retained_visible_focus_and_current_lifetime() {
let mut app = sample_app();
app.open_file_comment_editor();
app.comment_editor
.as_mut()
.unwrap()
.buffer
.insert_str("note");
assert!(app.focused_comment_input().is_none());
paint_comment_input(&mut app, 100, 30);
let request = comment_completion(&app, "completed");
handle_event_no_resize(&mut app, &Event::Focus(FocusEvent::Lost));
handle_event_no_resize(&mut app, &Event::Focus(FocusEvent::Gained));
paint_comment_input(&mut app, 100, 30);
assert!(matches!(
app.apply_comment_input(&request).edit_result,
FocusedTextFieldEditResult::Rejected(_)
));
app.comment_editor.as_mut().unwrap().preview = true;
paint_comment_input(&mut app, 100, 30);
assert!(app.focused_comment_input().is_none());
app.comment_editor.as_mut().unwrap().preview = false;
paint_comment_input(&mut app, 0, 0);
assert!(app.focused_comment_input().is_none());
paint_comment_input(&mut app, 10, 4);
assert!(app.focused_comment_input().is_none());
app.open_file_comment_editor();
app.comment_editor
.as_mut()
.unwrap()
.buffer
.insert_str("note");
paint_comment_input(&mut app, 100, 30);
assert!(matches!(
app.apply_comment_input(&request).edit_result,
FocusedTextFieldEditResult::Rejected(_)
));
app.comment_editor.as_mut().unwrap().mode = ReviewCommentEditorMode::Edit {
comment_id: "draft-id".into(),
previous_body: "note".into(),
};
paint_comment_input(&mut app, 100, 30);
let request = comment_completion(&app, "draft update");
assert_eq!(
app.apply_comment_input(&request).edit_result,
FocusedTextFieldEditResult::Applied
);
assert!(matches!(
app.comment_editor.as_ref().unwrap().mode,
ReviewCommentEditorMode::Edit { .. }
));
}
#[test]
fn hidden_then_revealed_comment_rejects_pre_hide_completion() {
let mut app = sample_app();
app.open_review_comment_editor();
app.comment_editor.as_mut().unwrap().buffer = TextEditBuffer::from_text("same text");
paint_comment_input(&mut app, 100, 30);
let request = comment_completion(&app, "completed");
paint_comment_input(&mut app, 10, 4);
assert!(app.focused_comment_input().is_none());
paint_comment_input(&mut app, 100, 30);
assert!(app.focused_comment_input().is_some());
let state = app.comment_editor.as_ref().unwrap().buffer.edit_state();
let result = app.apply_comment_input(&request);
assert!(matches!(
result.edit_result,
FocusedTextFieldEditResult::Rejected(_)
));
assert_eq!(result.event.outcome, bmux_tui::event::EventOutcome::Handled);
assert_eq!(
app.comment_editor.as_ref().unwrap().buffer.edit_state(),
state
);
assert!(app.draft_comments.is_empty());
let current = comment_completion(&app, "completed");
assert_eq!(
app.apply_comment_input(¤t).edit_result,
FocusedTextFieldEditResult::Applied
);
}
#[test]
fn comment_owner_shared_conformance_preserves_semantics() {
let mut app = sample_app();
app.open_review_comment_editor();
let editor = app.comment_editor.as_mut().unwrap();
editor.existing_comment_count = 2;
editor.buffer.insert_str("reply 界🙂");
paint_comment_input(&mut app, 100, 30);
bcode_plugin_sdk::tui::focused_input::test_support::assert_noop_and_stale_preserve_field(
&mut app,
ReviewApp::focused_comment_input,
|owner, edit| owner.apply_comment_input(edit).edit_result,
);
assert!(app.draft_comments.is_empty());
assert_eq!(
app.comment_editor.as_ref().unwrap().existing_comment_count,
2
);
let request = comment_completion(&app, "completed reply");
assert_eq!(
app.apply_comment_input(&request).edit_result,
FocusedTextFieldEditResult::Applied
);
assert_eq!(
app.comment_editor.as_ref().unwrap().existing_comment_count,
2
);
assert!(app.draft_comments.is_empty());
app.open_file_picker();
paint_comment_input(&mut app, 100, 30);
assert!(app.focused_comment_input().is_none());
assert!(matches!(
app.apply_comment_input(&request).edit_result,
FocusedTextFieldEditResult::Rejected(_)
));
}
#[test]
fn ignored_comment_input_reaches_parent_once_and_never_retries_editor() {
let mut app = sample_app();
app.open_review_comment_editor();
let mut surface = CodeReviewSurface {
client: BcodeClient::default_endpoint(),
repo_path: PathBuf::from("/repo"),
review_target: ReviewOpenTarget::WorkingTreeUnstaged,
app,
theme: None,
file_store: AsyncValueStore::new(),
agent_streams: ReviewAgentStreamStore::default(),
};
let host = ClipboardTestHost {
keymap: bcode_plugin_sdk::tui::keymap::controls::resolve_registry(
crate::keybindings::actions(),
BTreeMap::new(),
)
.expect("effective review keymap"),
..ClipboardTestHost::default()
};
let event = Event::Paste("live input".into());
let result = surface.focused_text_field_event(&event, &host);
assert_ne!(result.outcome, bmux_tui::event::EventOutcome::Ignored);
assert_eq!(
surface.app.comment_editor.as_ref().unwrap().buffer.text(),
"live input"
);
assert!(matches!(
surface.handle_event(&event, &host),
PluginTuiAction::None
));
assert_eq!(
surface.app.comment_editor.as_ref().unwrap().buffer.text(),
"live input"
);
let invocations_before = surface.app.comment_input_invocations;
let event = Event::Key(bmux_keyboard::parse_key_stroke("alt+z").unwrap());
let result = surface.focused_text_field_event(&event, &host);
assert_eq!(result.outcome, bmux_tui::event::EventOutcome::Ignored);
let mut parent_calls = 0;
if result.outcome == bmux_tui::event::EventOutcome::Ignored
&& surface.handle_event_result(&event, &host).outcome
== bmux_tui::event::EventOutcome::Ignored
{
parent_calls += 1;
}
assert_eq!(parent_calls, 1);
assert_eq!(
surface.app.comment_input_invocations,
invocations_before + 1
);
surface.app.comment_editor.as_mut().unwrap().buffer = TextEditBuffer::new();
surface.app.selected_file = usize::MAX; // no unrelated file-load redraw
let event = Event::Key(bmux_keyboard::parse_key_stroke("backspace").unwrap());
let before = surface
.app
.comment_editor
.as_ref()
.unwrap()
.buffer
.edit_state();
let result = surface.focused_text_field_event(&event, &host);
assert_eq!(result.outcome, bmux_tui::event::EventOutcome::Handled);
assert!(matches!(result.action, PluginTuiAction::None));
assert_eq!(
surface
.app
.comment_editor
.as_ref()
.unwrap()
.buffer
.edit_state(),
before
);
assert!(surface.app.draft_comments.is_empty());
}
pub fn sample_app() -> ReviewApp {
ReviewApp::new(ReviewSummary {
title: "test".to_string(),
repo_root: PathBuf::from("/repo"),
additions: 2,
deletions: 1,
workspace: None,
surfaces: Vec::new(),
diagnostics: Vec::new(),
repository_files: Vec::new(),
repository_branches: Vec::new(),
repository_commits: Vec::new(),
files: vec![
ReviewFile {
old_path: Some("a.rs".to_string()),
new_path: Some("a.rs".to_string()),
status: ReviewFileStatus::Modified,
additions: 2,
deletions: 1,
is_binary: false,
hunks: vec![
ReviewHunk {
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 2,
heading: None,
lines: vec![
ReviewLine {
kind: ReviewLineKind::Removed,
old_line: Some(1),
new_line: None,
content: "old".to_string(),
},
ReviewLine {
kind: ReviewLineKind::Added,
old_line: None,
new_line: Some(1),
content: "new".to_string(),
},
],
},
ReviewHunk {
old_start: 10,
old_count: 1,
new_start: 11,
new_count: 1,
heading: Some("next".to_string()),
lines: vec![ReviewLine {
kind: ReviewLineKind::Context,
old_line: Some(10),
new_line: Some(11),
content: "ctx".to_string(),
}],
},
],
},
ReviewFile {
old_path: Some("b.rs".to_string()),
new_path: Some("b.rs".to_string()),
status: ReviewFileStatus::Modified,
additions: 0,
deletions: 0,
is_binary: false,
hunks: Vec::new(),
},
],
})
}
#[test]
fn large_review_keyboard_and_mouse_navigation_remains_bounded() {
let hunks = (0_u32..500)
.map(|hunk_index| ReviewHunk {
old_start: hunk_index.saturating_mul(20).saturating_add(1),
old_count: 10,
new_start: hunk_index.saturating_mul(20).saturating_add(1),
new_count: 10,
heading: Some(format!("section_{hunk_index}")),
lines: (0_u32..10)
.map(|line_index| ReviewLine {
kind: ReviewLineKind::Context,
old_line: Some(hunk_index * 20 + line_index + 1),
new_line: Some(hunk_index * 20 + line_index + 1),
content: format!("line_{hunk_index}_{line_index}"),
})
.collect(),
})
.collect();
let mut app = ReviewApp::new(ReviewSummary {
title: "large".to_string(),
repo_root: PathBuf::from("/repo"),
additions: 0,
deletions: 0,
workspace: None,
surfaces: Vec::new(),
diagnostics: Vec::new(),
repository_files: Vec::new(),
repository_branches: Vec::new(),
repository_commits: Vec::new(),
files: vec![ReviewFile {
old_path: Some("large.rs".to_string()),
new_path: Some("large.rs".to_string()),
status: ReviewFileStatus::Modified,
additions: 0,
deletions: 0,
is_binary: false,
hunks,
}],
});
app.last_diff_area = Some(Rect::new(10, 5, 100, 30));
assert!(app.select_next_view_row(5_499));
assert!(app.selected_diff_visual_row() >= 5_499);
assert!(app.diff_scroll <= app.max_diff_scroll());
assert!(app.scroll_up(2_000));
let before_mouse = app.diff_scroll;
assert!(app.scroll_down(3));
assert_eq!(app.diff_scroll, before_mouse + 3);
assert!(app.diff_line_index_at(20, 5).is_some());
assert!(app.commentable_diff_line_index_at(20, 5).is_some());
}
#[test]
fn inline_draft_navigation_selects_comment_targets() {
let mut app = sample_app();
for (index, diff_row) in [1_usize, 2_usize].into_iter().enumerate() {
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row,
end_diff_row: None,
old_line: (diff_row == 1).then_some(1),
new_line: (diff_row == 2).then_some(1),
old_start: (diff_row == 1).then_some(1),
old_end: (diff_row == 1).then_some(1),
new_start: (diff_row == 2).then_some(1),
new_end: (diff_row == 2).then_some(1),
line_kind: if diff_row == 1 {
ReviewLineKind::Removed
} else {
ReviewLineKind::Added
},
is_file_anchor: false,
surface_id: None,
source_id: None,
};
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some(format!("comment-{index}")),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
}
assert!(app.select_next_inline_draft());
assert!(matches!(
app.selected_view_target,
Some(ReviewViewTarget::Comment {
comment_index: 0,
..
})
));
assert!(app.select_next_inline_draft());
assert_eq!(app.selected_diff_line, 2);
}
#[test]
fn selecting_next_view_row_keeps_selection_on_visible_semantic_rows() {
let mut app = sample_app();
app.last_diff_area = Some(Rect::new(0, 0, 80, 2));
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment-1".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
assert!(app.select_next_view_row(3));
assert!(app.selected_diff_visual_row() >= app.diff_scroll);
assert!(app.selected_diff_visual_row() < app.diff_scroll + 2);
}
#[test]
fn clicking_inline_thread_header_toggles_collapse() {
let mut app = sample_app();
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let thread_key = ReviewApp::thread_key_for_anchor(&anchor);
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment-1".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
let document = app.current_review_view_document().expect("document");
let thread_row = document
.rows
.iter()
.find(|row| matches!(row.target, ReviewViewTarget::Thread { .. }))
.expect("thread row")
.visual_row;
assert!(app.handle_review_view_click(thread_row));
assert!(app.collapsed_review_threads.contains(&thread_key));
}
#[test]
fn clicking_inline_action_activates_action() {
let mut app = sample_app();
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment-1".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
let document = app.current_review_view_document().expect("document");
let resolve_row = document
.rows
.iter()
.find(|row| {
matches!(
&row.target,
ReviewViewTarget::ThreadAction { action, .. } if action == "resolve"
)
})
.expect("resolve action")
.visual_row;
assert!(app.handle_review_view_click(resolve_row));
assert_eq!(app.resolved_review_threads.len(), 1);
}
#[test]
fn selected_inline_thread_can_toggle_resolved_state() {
let mut app = sample_app();
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let thread_key = ReviewApp::thread_key_for_anchor(&anchor);
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment-1".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
app.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: thread_key.clone(),
});
assert!(app.toggle_selected_thread_resolved());
assert!(app.resolved_review_threads.contains(&thread_key));
let document = app.current_review_view_document().expect("document");
assert!(document.rows.iter().any(|row| matches!(
row.block,
ReviewViewBlock::InlineThreadHeader { resolved: true, .. }
)));
assert!(app.toggle_selected_thread_resolved());
assert!(!app.resolved_review_threads.contains(&thread_key));
}
#[test]
fn hiding_resolved_threads_removes_them_from_document() {
let mut app = sample_app();
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let thread_key = ReviewApp::thread_key_for_anchor(&anchor);
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment-1".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
app.resolved_review_threads.insert(thread_key.clone());
app.thread_filter = ReviewThreadFilter::Open;
assert!(app.visible_thread_summaries().is_empty());
app.thread_filter = ReviewThreadFilter::Resolved;
assert_eq!(app.visible_thread_summaries().len(), 1);
assert!(app.toggle_show_resolved_threads());
let document = app.current_review_view_document().expect("document");
assert!(!document.rows.iter().any(|row| {
row.target
== ReviewViewTarget::Thread {
thread_key: thread_key.clone(),
}
}));
}
#[test]
fn selected_inline_thread_can_toggle_collapsed_state() {
let mut app = sample_app();
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let thread_key = ReviewApp::thread_key_for_anchor(&anchor);
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment-1".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
app.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: thread_key.clone(),
});
assert!(app.toggle_selected_inline_thread());
assert!(app.collapsed_review_threads.contains(&thread_key));
assert!(app.toggle_selected_inline_thread());
assert!(!app.collapsed_review_threads.contains(&thread_key));
}
#[test]
fn inline_thread_navigation_selects_next_thread_target() {
let mut app = sample_app();
let first = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 1,
end_diff_row: None,
old_line: Some(1),
new_line: None,
old_start: Some(1),
old_end: Some(1),
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Removed,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let second = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
for anchor in [first.clone(), second.clone()] {
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
}
app.select_anchor(&first);
app.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: ReviewApp::thread_key_for_anchor(&first),
});
assert!(app.select_next_inline_thread());
assert_eq!(app.selected_diff_line, second.diff_row);
assert_eq!(
app.selected_view_target,
Some(ReviewViewTarget::Thread {
thread_key: ReviewApp::thread_key_for_anchor(&second),
})
);
}
#[test]
fn open_thread_navigation_skips_resolved_threads() {
let mut app = sample_app();
let first = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 1,
end_diff_row: None,
old_line: Some(1),
new_line: None,
old_start: Some(1),
old_end: Some(1),
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Removed,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let second = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
for anchor in [first.clone(), second.clone()] {
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
}
app.resolved_review_threads
.insert(ReviewApp::thread_key_for_anchor(&first));
app.select_anchor(&first);
assert!(app.select_next_open_thread());
assert_eq!(app.selected_diff_line, second.diff_row);
assert_eq!(
app.selected_view_target,
Some(ReviewViewTarget::Thread {
thread_key: ReviewApp::thread_key_for_anchor(&second),
})
);
}
#[test]
fn open_thread_navigation_selects_previous_thread() {
let mut app = sample_app();
let first = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 1,
end_diff_row: None,
old_line: Some(1),
new_line: None,
old_start: Some(1),
old_end: Some(1),
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Removed,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let second = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
for anchor in [first.clone(), second.clone()] {
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
}
app.select_anchor(&second);
app.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: ReviewApp::thread_key_for_anchor(&second),
});
assert!(app.select_previous_open_thread());
assert_eq!(app.selected_diff_line, first.diff_row);
}
#[test]
fn show_attention_sidebar_focuses_attention_mode() {
let mut app = sample_app();
app.sidebar_visible = false;
assert!(app.show_attention_sidebar());
assert!(app.sidebar_visible);
assert_eq!(app.sidebar_mode, ReviewSidebarMode::NeedsAttention);
assert_eq!(app.status_message.as_deref(), Some("sidebar: attention"));
}
#[test]
fn attention_sidebar_shows_only_open_threads() {
let mut app = sample_app();
let open = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 1,
end_diff_row: None,
old_line: Some(1),
new_line: None,
old_start: Some(1),
old_end: Some(1),
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Removed,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let resolved = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
for anchor in [open.clone(), resolved.clone()] {
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
}
app.resolved_review_threads
.insert(ReviewApp::thread_key_for_anchor(&resolved));
app.sidebar_mode = ReviewSidebarMode::NeedsAttention;
let threads = app.visible_thread_summaries();
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].anchor, open);
}
#[test]
fn global_open_thread_navigation_crosses_files() {
let mut app = sample_app();
let first = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 1,
end_diff_row: None,
old_line: Some(1),
new_line: None,
old_start: Some(1),
old_end: Some(1),
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Removed,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let second = ReviewCommentAnchor {
file_index: 1,
path: "b.rs".to_string(),
diff_row: 0,
end_diff_row: None,
old_line: None,
new_line: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: true,
surface_id: None,
source_id: None,
};
for anchor in [first.clone(), second.clone()] {
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
}
app.select_anchor(&first);
app.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: ReviewApp::thread_key_for_anchor(&first),
});
assert!(app.select_next_open_thread_global());
assert_eq!(app.selected_file, 1);
assert_eq!(
app.selected_view_target,
Some(ReviewViewTarget::Thread {
thread_key: ReviewApp::thread_key_for_anchor(&second),
})
);
}
#[test]
fn semantic_view_document_includes_inline_thread_actions() {
let mut app = sample_app();
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
app.draft_comments.insert(
anchor.clone(),
vec![ReviewDraftComment {
id: Some("comment-1".to_string()),
body: "Looks risky".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
let document = app.current_review_view_document().expect("document");
let thread_key = ReviewApp::thread_key_for_anchor(&anchor);
assert!(document.rows.iter().any(|row| {
row.target
== ReviewViewTarget::Thread {
thread_key: thread_key.clone(),
}
}));
assert!(document.rows.iter().any(|row| {
row.target
== ReviewViewTarget::ThreadAction {
thread_key: thread_key.clone(),
action: "reply".to_string(),
}
}));
}
#[test]
fn file_navigation_resets_diff_state() {
let mut app = sample_app();
app.diff_scroll = 2;
app.selected_diff_line = 2;
assert!(app.select_next_file());
assert_eq!(app.selected_file, 1);
assert_eq!(app.diff_scroll, 0);
assert_eq!(app.selected_diff_line, 0);
}
#[test]
fn hunk_navigation_tracks_selected_line() {
let mut app = sample_app();
app.set_diff_area(Rect::new(0, 0, 80, 2));
assert!(app.select_next_hunk());
assert_eq!(app.selected_diff_line, 3);
assert_eq!(app.diff_scroll, 3);
assert_eq!(app.hunk_position(), (2, 2));
}
#[test]
fn creates_anchor_for_selected_diff_line() {
let mut app = sample_app();
app.selected_diff_line = 2;
let anchor = app
.selected_comment_anchor()
.expect("added line should be commentable");
assert_eq!(anchor.path, "a.rs");
assert_eq!(anchor.diff_row, 2);
assert_eq!(anchor.old_line, None);
assert_eq!(anchor.new_line, Some(1));
assert_eq!(anchor.line_kind, ReviewLineKind::Added);
}
#[test]
fn hunk_header_is_not_commentable() {
let app = sample_app();
assert_eq!(app.comment_anchor_for_row(0), None);
}
#[test]
fn comment_composer_opens_for_selected_diff_line() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
let editor = app.comment_editor.expect("comment composer should open");
assert_eq!(editor.anchor.diff_row, 2);
assert_eq!(editor.anchor.end_diff_row, None);
assert_eq!(editor.action, ReviewCommentAction::SaveDraft);
}
#[test]
fn comment_composer_uses_selected_range_anchor() {
let mut app = sample_app();
app.selected_diff_line = 1;
assert!(app.toggle_range_selection());
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
let editor = app.comment_editor.expect("comment composer should open");
assert_eq!(editor.anchor.diff_row, 1);
assert_eq!(editor.anchor.end_diff_row, Some(2));
assert_eq!(editor.anchor.new_start, Some(1));
assert_eq!(editor.anchor.new_end, Some(1));
}
#[test]
fn escape_never_exits_review() {
let mut app = sample_app();
assert!(!handle_key(&mut app, KeyStroke::simple(KeyCode::Escape)));
assert!(!app.should_exit);
}
#[test]
fn effective_sidebar_binding_remap_and_unbind_change_dispatch() {
use bcode_plugin_sdk::tui::keymap::controls::resolve_registry;
use std::collections::BTreeMap;
struct Host(bcode_plugin_sdk::tui::keymap::KeymapRegistry);
impl bcode_plugin_sdk::tui::PluginTuiHost for Host {
fn spawn(&self, _task: bcode_plugin_sdk::tui::PluginTask) {}
fn spawn_blocking(&self, _task: Box<dyn FnOnce() + Send + 'static>) {}
fn request_redraw(&self) {}
fn keymap_action(&self, scope: &str, stroke: KeyStroke) -> Option<String> {
self.0.action_for_key(scope, stroke).map(str::to_owned)
}
fn keymap_keys(&self, scope: &str, action: &str) -> Vec<String> {
self.0
.keys_for_action(scope, action)
.into_iter()
.map(str::to_owned)
.collect()
}
fn keymap_is_overridden(&self, scope: &str, stroke: KeyStroke) -> bool {
self.0.is_overridden_key(scope, stroke)
}
}
let scope = crate::keybindings::REVIEW;
let host = Host(
resolve_registry(
crate::keybindings::actions(),
BTreeMap::from([(
scope.into(),
BTreeMap::from([("ctrl+b".into(), "bcode.code_review.sidebar".into())]),
)]),
)
.unwrap(),
);
let mut app = sample_app();
let before = app.sidebar_visible;
assert!(!handle_effective_event(
&mut app,
&Event::Key(bmux_keyboard::parse_key_stroke("b").unwrap()),
&host
));
assert_eq!(app.sidebar_visible, before);
assert!(handle_effective_event(
&mut app,
&Event::Key(bmux_keyboard::parse_key_stroke("ctrl+b").unwrap()),
&host
));
assert_eq!(app.sidebar_visible, !before);
let host = Host(
resolve_registry(
crate::keybindings::actions(),
BTreeMap::from([(scope.into(), BTreeMap::from([("b".into(), String::new())]))]),
)
.unwrap(),
);
assert!(!handle_effective_event(
&mut app,
&Event::Key(bmux_keyboard::parse_key_stroke("b").unwrap()),
&host
));
assert_eq!(app.sidebar_visible, !before);
}
#[test]
fn control_d_exits_review() {
let mut app = sample_app();
let stroke = KeyStroke {
key: KeyCode::Char('d'),
modifiers: bmux_keyboard::Modifiers {
ctrl: true,
..bmux_keyboard::Modifiers::NONE
},
};
assert!(handle_key(&mut app, stroke));
assert!(app.should_exit);
}
#[test]
fn comment_composer_cancel_does_not_create_draft() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
app.comment_editor
.as_mut()
.expect("comment composer should open")
.buffer
.insert_str("Never mind");
assert!(handle_comment_editor_key(
&mut app,
KeyStroke {
key: KeyCode::Escape,
modifiers: bmux_keyboard::Modifiers::NONE,
},
));
assert!(app.comment_editor.is_none());
assert_eq!(app.draft_comment_count(), 0);
assert!(app.take_pending_draft_save().is_none());
}
#[test]
fn reopen_restores_pending_and_failed_ai_exchange_presentations() {
let mut app = sample_app();
app.selected_diff_line = 2;
let anchor = app.selected_comment_anchor().expect("anchor");
let model_anchor: ModelDraftAnchor = anchor.clone().into();
app.load_persisted_ai_exchanges(vec![
ModelReviewAiExchange {
schema_version: bcode_code_review_models::REVIEW_AI_EXCHANGE_SCHEMA_VERSION,
exchange_id: "pending".to_string(),
anchor: model_anchor.clone(),
question: "Still pending?".to_string(),
session_id: None,
status: ModelReviewAiExchangeStatus::Pending,
error: None,
created_at_ms: 1,
updated_at_ms: 1,
},
ModelReviewAiExchange {
schema_version: bcode_code_review_models::REVIEW_AI_EXCHANGE_SCHEMA_VERSION,
exchange_id: "failed".to_string(),
anchor: model_anchor,
question: "Why did this fail?".to_string(),
session_id: None,
status: ModelReviewAiExchangeStatus::Failed,
error: Some("daemon unavailable".to_string()),
created_at_ms: 2,
updated_at_ms: 2,
},
]);
let state = app
.agent_state_for_anchor(&anchor)
.expect("restored visible exchange state");
assert_eq!(state.question, "Still pending?");
assert_eq!(state.phase, ReviewAgentThreadPhase::Failed);
assert_eq!(state.error.as_deref(), Some("daemon unavailable"));
assert_eq!(app.ai_exchanges[&anchor].len(), 2);
}
#[test]
fn unverified_exchange_save_never_requeues_or_creates_session() {
let mut app = sample_app();
app.selected_diff_line = 2;
let anchor = app.selected_comment_anchor().expect("anchor");
let id = app.create_ai_exchange(anchor, "question".to_owned(), None);
let _ = app.take_pending_ai_exchange_save().expect("initial save");
record_unverified_ai_exchange_save(&mut app, &id);
assert!(app.take_pending_ai_exchange_save().is_none());
assert!(app.take_pending_agent_session().is_none());
}
#[test]
fn ai_exchange_failure_retry_follow_up_reopen_and_conversion_remain_isolated() {
let mut app = sample_app();
app.selected_diff_line = 2;
let anchor = app.selected_comment_anchor().expect("anchor");
let exchange_id =
app.create_ai_exchange(anchor.clone(), "first question".to_string(), None);
let initial = app
.take_pending_ai_exchange_save()
.expect("create persistence");
app.mark_ai_exchange_persisted(&ModelReviewAiExchange {
created_at_ms: 10,
updated_at_ms: 10,
..initial
});
app.mark_ai_exchange_failed(&exchange_id, "provider unavailable");
let failed = app
.take_pending_ai_exchange_save()
.expect("failure persistence");
assert_eq!(failed.status, ModelReviewAiExchangeStatus::Failed);
app.requeue_ai_exchange_save(&exchange_id);
assert!(app.take_pending_ai_exchange_save().is_some());
let session_id = SessionId::new().to_string();
app.mark_thread_session(&exchange_id, &anchor, &session_id);
let follow_up_id = app.create_ai_exchange(
anchor.clone(),
"follow up".to_string(),
Some(session_id.clone()),
);
assert_ne!(follow_up_id, exchange_id);
assert_eq!(
app.ai_exchanges
.get(&anchor)
.expect("exchanges")
.iter()
.filter(|exchange| exchange.session_id.as_deref() == Some(&session_id))
.count(),
2
);
let persisted = app
.ai_exchanges
.values()
.flatten()
.cloned()
.collect::<Vec<_>>();
let mut reopened = sample_app();
reopened.load_persisted_ai_exchanges(persisted);
assert_eq!(reopened.ai_exchanges.values().flatten().count(), 2);
let key = ReviewApp::thread_key_for_anchor(&anchor);
reopened.agent_thread_states.insert(
key,
ReviewAgentThreadState {
phase: ReviewAgentThreadPhase::Complete,
session_id: Some(session_id),
question: "follow up".to_string(),
context_summary: String::new(),
status: "answered".to_string(),
answer: "Use the checked result.".to_string(),
session_items: Vec::new(),
session_revision: 0,
stream_warning: None,
activity: None,
error: None,
},
);
reopened.selected_diff_line = 2;
reopened.selected_view_target = None;
assert!(reopened.convert_agent_answer_to_draft_at_selection());
assert!(reopened.delete_latest_ai_exchange_at_selection());
let (deleted_id, _) = reopened
.take_pending_ai_exchange_delete()
.expect("explicit cleanup");
assert_eq!(deleted_id, follow_up_id);
assert_eq!(reopened.ai_exchanges.values().flatten().count(), 1);
assert_eq!(reopened.draft_comment_count(), 1);
}
#[test]
fn ask_bcode_supports_line_range_file_and_review_anchors() {
let cases = [
("line", None),
("range", Some(AnchorSetup::Range)),
("file", Some(AnchorSetup::File)),
("review", Some(AnchorSetup::Review)),
];
for (label, setup) in cases {
let mut app = sample_app();
app.selected_diff_line = 2;
match setup {
None => assert!(app.open_comment_editor()),
Some(AnchorSetup::Range) => {
app.selected_diff_line = 1;
assert!(app.toggle_range_selection());
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
}
Some(AnchorSetup::File) => assert!(app.open_file_comment_editor()),
Some(AnchorSetup::Review) => assert!(app.open_review_comment_editor()),
}
let editor = app.comment_editor.as_mut().expect("composer");
editor.action = ReviewCommentAction::AskBcode;
editor.buffer.insert_str(&format!("question for {label}"));
assert!(app.save_comment_editor());
let pending = app.take_pending_agent_session().expect("queued question");
match label {
"line" => {
assert!(!pending.anchor.is_file_anchor);
assert_eq!(
pending.anchor.start_diff_row(),
pending.anchor.end_diff_row()
);
}
"range" => assert!(pending.anchor.end_diff_row() > pending.anchor.start_diff_row()),
"file" => assert!(pending.anchor.is_file_anchor),
"review" => assert!(pending.anchor.is_review_level()),
_ => unreachable!(),
}
let state = app
.agent_state_for_anchor(&pending.anchor)
.expect("immediate visible state");
assert_eq!(state.question, format!("question for {label}"));
assert_eq!(state.phase, ReviewAgentThreadPhase::Pending);
assert_eq!(state.status, "looking into this…");
}
}
#[derive(Clone, Copy)]
enum AnchorSetup {
Range,
File,
Review,
}
#[test]
fn comment_composer_ask_action_queues_bcode_question() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
let editor = app
.comment_editor
.as_mut()
.expect("comment composer should open");
editor.buffer.insert_str("Why this change?");
editor.action = ReviewCommentAction::AskBcode;
assert!(app.save_comment_editor());
assert_eq!(app.draft_comment_count(), 0);
assert_eq!(app.ai_exchanges.values().flatten().count(), 1);
let exchange = app
.ai_exchanges
.values()
.flatten()
.next()
.expect("AI exchange should be visible");
assert_eq!(exchange.question, "Why this change?");
assert_eq!(exchange.status, ModelReviewAiExchangeStatus::Pending);
assert!(app.take_pending_ai_exchange_save().is_some());
let pending = app
.take_pending_agent_session()
.expect("ask action should queue an agent session");
assert_eq!(pending.anchor.diff_row, 2);
assert_eq!(pending.draft_body.as_deref(), Some("Why this change?"));
}
#[test]
fn saves_in_memory_draft_comment() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
app.comment_editor
.as_mut()
.expect("editor should open")
.buffer
.insert_str("Needs a test");
assert!(app.save_comment_editor());
assert_eq!(app.draft_comment_count(), 1);
assert!(app.has_draft_comment_at(0, 2));
assert_eq!(app.draft_comment_count_for_file(0), 1);
assert_eq!(app.open_thread_count_for_file(0), 1);
let pending = app
.take_pending_draft_save()
.expect("draft should be pending persistence");
assert_eq!(pending.anchor.diff_row, 2);
assert_eq!(pending.body, "Needs a test");
}
#[test]
fn edits_persisted_draft_comment() {
let mut app = sample_app();
app.selected_diff_line = 2;
app.load_persisted_drafts(vec![DraftComment {
comment_id: "comment-1".to_string(),
thread_id: "thread-1".to_string(),
anchor: DraftAnchor {
kind: ReviewAnchorKind::Range,
file_path: "a.rs".to_string(),
diff_row: 2,
start_diff_row: Some(2),
end_diff_row: Some(2),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
old_line: None,
new_line: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: Some("surface-0".to_string()),
source_id: Some("source-1".to_string()),
},
body: "Before".to_string(),
created_at_ms: 1,
updated_at_ms: 1,
session_id: None,
resolved_at_ms: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}]);
assert!(app.open_latest_draft_editor());
let editor = app.comment_editor.as_mut().expect("editor should open");
editor.buffer = TextEditBuffer::from_text("After");
assert!(app.save_comment_editor());
let pending = app
.take_pending_draft_update()
.expect("update should be pending persistence");
assert_eq!(pending.comment_id, "comment-1");
assert_eq!(pending.previous_body, "Before");
assert_eq!(pending.new_body, "After");
assert_eq!(
app.selected_draft_preview().as_deref(),
Some("draft 1/1 (unsaved): After")
);
}
#[test]
fn loads_persisted_drafts_into_local_state() {
let mut app = sample_app();
app.load_persisted_drafts(vec![DraftComment {
comment_id: "comment-1".to_string(),
thread_id: "thread-1".to_string(),
anchor: DraftAnchor {
kind: ReviewAnchorKind::Range,
file_path: "a.rs".to_string(),
diff_row: 2,
start_diff_row: Some(2),
end_diff_row: Some(2),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
old_line: None,
new_line: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: Some("surface-0".to_string()),
source_id: Some("source-1".to_string()),
},
body: "Persisted".to_string(),
created_at_ms: 1,
updated_at_ms: 1,
session_id: None,
resolved_at_ms: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}]);
assert_eq!(app.draft_comment_count(), 1);
assert!(app.has_draft_comment_at(0, 2));
}
#[test]
fn selecting_file_preserves_sidebar_mode() {
let mut app = sample_app();
app.review.files.push(ReviewFile {
old_path: Some("b.rs".to_string()),
new_path: Some("b.rs".to_string()),
status: ReviewFileStatus::Modified,
additions: 0,
deletions: 0,
is_binary: false,
hunks: Vec::new(),
});
app.sidebar_mode = ReviewSidebarMode::Repository;
assert!(app.select_file(1));
assert_eq!(app.sidebar_mode, ReviewSidebarMode::Repository);
}
#[test]
fn selecting_file_restores_previous_viewport() {
let mut app = sample_app();
app.review.files.push(ReviewFile {
old_path: Some("b.rs".to_string()),
new_path: Some("b.rs".to_string()),
status: ReviewFileStatus::Modified,
additions: 0,
deletions: 0,
is_binary: false,
hunks: vec![ReviewHunk {
old_start: 1,
old_count: 0,
new_start: 1,
new_count: 1,
heading: None,
lines: vec![ReviewLine {
kind: ReviewLineKind::Added,
old_line: None,
new_line: Some(1),
content: "b".to_string(),
}],
}],
});
app.diff_scroll = 1;
app.selected_diff_line = 2;
app.set_diff_area(Rect::new(0, 0, 80, 3));
assert!(app.select_file(1));
assert_eq!(app.diff_scroll, 0);
assert_eq!(app.selected_diff_line, 0);
assert!(app.select_file(0));
assert_eq!(app.diff_scroll, 1);
assert_eq!(app.selected_diff_line, 2);
}
#[test]
fn repository_tree_rows_use_surface_paths() {
let app = build_workspace_app(
vec![build_source("source-1", ReviewSourceKind::Repository, true)],
vec![build_file_surface("surface-1", "source-1")],
Vec::new(),
);
assert_eq!(
app.file_tree_rows(),
vec![ReviewFileTreeRow::File { index: 0, depth: 0 }]
);
}
#[test]
fn moving_repository_tree_focus_does_not_open_file() {
let mut app = build_workspace_app(
vec![build_source("source-1", ReviewSourceKind::Repository, true)],
vec![
ReviewSurface {
id: "surface-1".to_string(),
source_id: "source-1".to_string(),
path: "a.rs".to_string(),
kind: ReviewSurfaceKind::File,
file: None,
},
ReviewSurface {
id: "surface-2".to_string(),
source_id: "source-1".to_string(),
path: "b.rs".to_string(),
kind: ReviewSurfaceKind::File,
file: None,
},
],
Vec::new(),
);
assert!(app.select_next_tree_row(1));
assert_eq!(app.selected_tree_row, 1);
assert_eq!(app.selected_file, 0);
}
#[test]
fn tree_scroll_is_independent_from_file_scroll() {
let mut app = build_workspace_app(
vec![build_source("source-1", ReviewSourceKind::Repository, true)],
vec![
ReviewSurface {
id: "surface-1".to_string(),
source_id: "source-1".to_string(),
path: "a.rs".to_string(),
kind: ReviewSurfaceKind::File,
file: None,
},
ReviewSurface {
id: "surface-2".to_string(),
source_id: "source-1".to_string(),
path: "b.rs".to_string(),
kind: ReviewSurfaceKind::File,
file: None,
},
],
Vec::new(),
);
app.file_scroll = 7;
assert!(app.scroll_tree_down(1));
assert_eq!(app.tree_scroll, 1);
assert_eq!(app.file_scroll, 7);
}
#[test]
fn activating_repository_tree_file_opens_it() {
let mut app = build_workspace_app(
vec![build_source("source-1", ReviewSourceKind::Repository, true)],
vec![
ReviewSurface {
id: "surface-1".to_string(),
source_id: "source-1".to_string(),
path: "a.rs".to_string(),
kind: ReviewSurfaceKind::File,
file: None,
},
ReviewSurface {
id: "surface-2".to_string(),
source_id: "source-1".to_string(),
path: "b.rs".to_string(),
kind: ReviewSurfaceKind::File,
file: None,
},
],
Vec::new(),
);
app.selected_tree_row = 1;
assert!(app.activate_selected_tree_row());
assert_eq!(app.selected_file, 1);
}
#[test]
fn repository_workspace_is_repository_review() {
let app = build_workspace_app(
vec![build_source("source-1", ReviewSourceKind::Repository, true)],
vec![build_file_surface("surface-1", "source-1")],
Vec::new(),
);
assert!(app.review.is_repository_review());
}
#[test]
fn repository_workspace_queues_selected_file_load() {
let mut app = build_workspace_app(
vec![build_source("source-1", ReviewSourceKind::Repository, true)],
vec![build_file_surface("surface-1", "source-1")],
Vec::new(),
);
app.queue_selected_file_load();
assert_eq!(app.pending_file_load.as_deref(), Some("a.rs"));
}
#[test]
fn thread_sidebar_toggle_and_jump() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
app.comment_editor
.as_mut()
.expect("editor should open")
.buffer
.insert_str("Needs a test");
assert!(app.save_comment_editor());
assert!(app.toggle_sidebar_mode());
assert!(app.toggle_sidebar_mode());
assert_eq!(app.sidebar_mode, ReviewSidebarMode::Threads);
assert_eq!(app.thread_summaries().len(), 1);
app.selected_diff_line = 0;
assert!(app.jump_to_selected_thread());
assert_eq!(app.selected_diff_line, 2);
}
fn build_source(id: &str, kind: ReviewSourceKind, included: bool) -> ReviewSource {
ReviewSource {
id: id.to_string(),
label: kind.label(),
kind,
included,
}
}
fn build_workspace_app(
sources: Vec<ReviewSource>,
surfaces: Vec<ReviewSurface>,
diagnostics: Vec<ReviewSourceDiagnostic>,
) -> ReviewApp {
ReviewApp::new(ReviewSummary {
title: "workspace".to_string(),
repo_root: PathBuf::from("/repo"),
additions: 0,
deletions: 0,
workspace: Some(ReviewWorkspace {
id: "workspace-1".to_string(),
title: "Workspace".to_string(),
repo_root: PathBuf::from("/repo"),
sources,
created_at_ms: None,
updated_at_ms: None,
viewed_files: BTreeSet::new(),
archived_at_ms: None,
presentation_state: None,
}),
surfaces,
diagnostics,
repository_files: Vec::new(),
repository_branches: Vec::new(),
repository_commits: Vec::new(),
files: Vec::new(),
})
}
fn build_surface(id: &str, source_id: &str) -> ReviewSurface {
ReviewSurface {
id: id.to_string(),
source_id: source_id.to_string(),
path: "a.rs".to_string(),
kind: ReviewSurfaceKind::Diff,
file: None,
}
}
fn build_file_surface(id: &str, source_id: &str) -> ReviewSurface {
ReviewSurface {
id: id.to_string(),
source_id: source_id.to_string(),
path: "a.rs".to_string(),
kind: ReviewSurfaceKind::File,
file: None,
}
}
fn build_diagnostic(
source_id: &str,
severity: ReviewSourceDiagnosticSeverity,
) -> ReviewSourceDiagnostic {
ReviewSourceDiagnostic {
source_id: source_id.to_string(),
severity,
code: "test".to_string(),
message: "diagnostic".to_string(),
}
}
#[test]
fn removes_duplicate_sources_preserving_first() {
let mut app = build_workspace_app(
vec![
build_source("source-1", ReviewSourceKind::LastCommit, true),
build_source("source-2", ReviewSourceKind::WorkingTreeUnstaged, true),
build_source("source-3", ReviewSourceKind::LastCommit, true),
],
Vec::new(),
Vec::new(),
);
app.set_build_mode();
app.selected_build_row = 2;
assert!(app.remove_duplicate_sources());
assert_eq!(app.workspace.sources.len(), 2);
assert_eq!(app.workspace.sources[0].id, "source-1");
assert_eq!(app.workspace.sources[1].id, "source-2");
assert!(app.pending_workspace_save);
assert!(app.pending_workspace_reload);
assert_eq!(app.review.workspace, Some(app.workspace.clone()));
}
#[test]
fn excludes_empty_included_sources() {
let mut app = build_workspace_app(
vec![
build_source("source-1", ReviewSourceKind::LastCommit, true),
build_source("source-2", ReviewSourceKind::WorkingTreeUnstaged, true),
build_source("source-3", ReviewSourceKind::IndexStaged, false),
],
vec![build_surface("surface-1", "source-1")],
Vec::new(),
);
app.set_build_mode();
assert!(app.exclude_empty_sources());
assert!(app.workspace.sources[0].included);
assert!(!app.workspace.sources[1].included);
assert!(!app.workspace.sources[2].included);
assert!(app.pending_workspace_save);
assert!(app.pending_workspace_reload);
}
#[test]
fn selects_empty_sources_with_wraparound() {
let mut app = build_workspace_app(
vec![
build_source("source-1", ReviewSourceKind::LastCommit, true),
build_source("source-2", ReviewSourceKind::WorkingTreeUnstaged, true),
build_source("source-3", ReviewSourceKind::IndexStaged, true),
],
vec![build_surface("surface-1", "source-1")],
Vec::new(),
);
app.set_build_mode();
app.selected_build_row = 2;
assert!(app.select_next_empty_source());
assert_eq!(app.selected_build_row, 1);
assert!(app.select_previous_empty_source());
assert_eq!(app.selected_build_row, 2);
}
#[test]
fn diagnostic_source_actions_target_errors() {
let mut app = build_workspace_app(
vec![
build_source("source-1", ReviewSourceKind::LastCommit, true),
build_source("source-2", ReviewSourceKind::WorkingTreeUnstaged, true),
build_source("source-3", ReviewSourceKind::IndexStaged, true),
],
Vec::new(),
vec![
build_diagnostic("source-2", ReviewSourceDiagnosticSeverity::Warning),
build_diagnostic("source-3", ReviewSourceDiagnosticSeverity::Error),
],
);
app.set_build_mode();
assert!(app.select_next_diagnostic_source());
assert_eq!(app.selected_build_row, 1);
assert!(app.exclude_sources_with_errors());
assert!(app.workspace.sources[1].included);
assert!(!app.workspace.sources[2].included);
assert!(app.pending_workspace_save);
assert!(app.pending_workspace_reload);
}
#[test]
fn diagnostic_source_counts_are_grouped_by_source() {
let app = build_workspace_app(
vec![build_source("source-1", ReviewSourceKind::LastCommit, true)],
Vec::new(),
vec![
build_diagnostic("source-1", ReviewSourceDiagnosticSeverity::Info),
build_diagnostic("source-1", ReviewSourceDiagnosticSeverity::Info),
build_diagnostic("source-2", ReviewSourceDiagnosticSeverity::Warning),
build_diagnostic("source-3", ReviewSourceDiagnosticSeverity::Error),
],
);
assert_eq!(app.diagnostic_source_counts(), (1, 1, 1));
}
#[test]
fn linked_session_user_action_matrix_queues_each_explicit_transition() {
let completed = || {
let mut app = sample_app();
app.selected_diff_line = 2;
let anchor = app.selected_comment_anchor().expect("anchor");
let session_id = SessionId::new().to_string();
let exchange_id = app.create_ai_exchange(
anchor.clone(),
"question".to_string(),
Some(session_id.clone()),
);
app.mark_thread_session(&exchange_id, &anchor, &session_id);
app.agent_thread_states.insert(
ReviewApp::thread_key_for_anchor(&anchor),
ReviewAgentThreadState {
phase: ReviewAgentThreadPhase::Complete,
session_id: Some(session_id.clone()),
question: "question".to_string(),
context_summary: String::new(),
status: "answered".to_string(),
answer: "Actionable answer".to_string(),
session_items: Vec::new(),
session_revision: 1,
stream_warning: None,
activity: None,
error: None,
},
);
(app, anchor, session_id)
};
let (mut copy, _, _) = completed();
assert!(copy.queue_copy_agent_answer_at_selection());
assert_eq!(
copy.pending_clipboard_text.as_deref(),
Some("Actionable answer")
);
let (mut draft, _, _) = completed();
assert!(draft.convert_agent_answer_to_draft_at_selection());
assert!(draft.take_pending_draft_save().is_some());
let (mut accept, _, _) = completed();
assert!(accept.suggest_comment_from_agent_answer_at_selection());
assert!(accept.take_pending_suggestion_save().is_some());
assert!(accept.accept_suggestion_at_selection());
assert!(accept.take_pending_draft_save().is_some());
let (mut refine, _, _) = completed();
assert!(refine.suggest_comment_from_agent_answer_at_selection());
let _ = refine.take_pending_suggestion_save();
assert!(refine.refine_suggestion_at_selection());
assert_eq!(
refine
.take_pending_suggestion_save()
.expect("refining transition")
.status,
ModelReviewSuggestionStatus::Refining
);
let (mut reject, _, _) = completed();
assert!(reject.suggest_comment_from_agent_answer_at_selection());
let _ = reject.take_pending_suggestion_save();
assert!(reject.reject_suggestion_at_selection());
assert_eq!(
reject
.take_pending_suggestion_save()
.expect("rejected transition")
.status,
ModelReviewSuggestionStatus::Rejected
);
let (mut retry, _, session_id) = completed();
retry.mark_agent_session_failed(&session_id, "disconnected");
assert!(retry.retry_linked_session_stream_at_selection());
assert_eq!(
retry.take_pending_agent_stream_retries(),
vec![session_id.clone()]
);
let (mut follow_up, anchor, session_id) = completed();
follow_up.queue_bcode_question(&anchor, "follow up".to_string());
let pending = follow_up
.take_pending_agent_session()
.expect("follow-up request");
assert_eq!(pending.draft_body.as_deref(), Some("follow up"));
assert_eq!(
follow_up.session_id_for_anchor(&anchor),
Some(session_id.as_str())
);
}
#[test]
fn suggestion_lifecycle_queues_every_persistence_transition() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
app.comment_editor
.as_mut()
.expect("editor should open")
.buffer
.insert_str("Can Bcode check this?");
assert!(app.save_comment_editor());
let anchor = app.selected_comment_anchor().expect("anchor");
let key = ReviewApp::thread_key_for_anchor(&anchor);
app.agent_thread_states.insert(
key,
ReviewAgentThreadState {
phase: ReviewAgentThreadPhase::Complete,
session_id: Some(SessionId::new().to_string()),
question: "Can Bcode check this?".to_string(),
context_summary: String::new(),
status: "answered".to_string(),
answer: "Handle this error explicitly.".to_string(),
session_items: Vec::new(),
session_revision: 0,
stream_warning: None,
activity: None,
error: None,
},
);
assert!(app.suggest_comment_from_agent_answer_at_selection());
let suggested = app.take_pending_suggestion_save().expect("suggestion save");
assert_eq!(suggested.status, ModelReviewSuggestionStatus::Suggested);
app.mark_suggestion_persisted(&ModelReviewSuggestion {
created_at_ms: 10,
updated_at_ms: 10,
..suggested
});
assert!(app.refine_suggestion_at_selection());
let refining = app.take_pending_suggestion_save().expect("refining save");
assert_eq!(refining.status, ModelReviewSuggestionStatus::Refining);
assert_eq!(refining.created_at_ms, 10);
assert!(app.reject_suggestion_at_selection());
let rejected = app.take_pending_suggestion_save().expect("rejected save");
assert_eq!(rejected.status, ModelReviewSuggestionStatus::Rejected);
}
#[test]
fn persisted_suggestions_restore_all_lifecycle_states() {
let mut app = sample_app();
let anchor = ModelDraftAnchor {
kind: ReviewAnchorKind::Range,
file_path: "a.rs".to_string(),
diff_row: 2,
start_diff_row: Some(2),
end_diff_row: Some(2),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
old_line: None,
new_line: Some(1),
line_kind: bcode_code_review_models::ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
let statuses = [
ModelReviewSuggestionStatus::Suggested,
ModelReviewSuggestionStatus::Refining,
ModelReviewSuggestionStatus::Accepted,
ModelReviewSuggestionStatus::Rejected,
];
app.load_persisted_suggestions(
statuses
.into_iter()
.enumerate()
.map(|(index, status)| ModelReviewSuggestion {
suggestion_id: format!("suggestion-{index}"),
anchor: anchor.clone(),
body: format!("body-{index}"),
session_id: None,
status,
rationale: None,
created_at_ms: 10,
updated_at_ms: 20,
})
.collect(),
);
let restored = app
.suggested_comments
.values()
.next()
.expect("restored suggestions");
assert_eq!(restored.len(), 4);
assert_eq!(restored[0].status, ReviewSuggestionStatus::Suggested);
assert_eq!(restored[1].status, ReviewSuggestionStatus::Refining);
assert_eq!(restored[2].status, ReviewSuggestionStatus::Accepted);
assert_eq!(restored[3].status, ReviewSuggestionStatus::Rejected);
assert!(app.pending_suggestion_saves.is_empty());
}
#[test]
fn file_source_label_distinguishes_multi_source_surfaces() {
let mut app = sample_app();
app.workspace.sources = vec![
ReviewSource {
id: "source-a".to_string(),
kind: ReviewSourceKind::WorkingTreeUnstaged,
label: "Unstaged".to_string(),
included: true,
},
ReviewSource {
id: "source-b".to_string(),
kind: ReviewSourceKind::BranchCompare {
base_branch: "main".to_string(),
head_branch: "feature".to_string(),
merge_base: true,
},
label: "Feature".to_string(),
included: true,
},
];
app.review.workspace = Some(app.workspace.clone());
app.review.surfaces = vec![
ReviewSurface {
id: "surface-a".to_string(),
source_id: "source-a".to_string(),
path: "a.rs".to_string(),
kind: ReviewSurfaceKind::Diff,
file: None,
},
ReviewSurface {
id: "surface-b".to_string(),
source_id: "source-b".to_string(),
path: "b.rs".to_string(),
kind: ReviewSurfaceKind::Diff,
file: None,
},
];
assert_eq!(app.review.file_source_label(0).as_deref(), Some("unstaged"));
assert_eq!(app.review.file_source_label(1).as_deref(), Some("branch"));
}
#[test]
fn ai_prompt_contains_bounded_context_and_source_metadata() {
let mut app = sample_app();
app.workspace.sources = vec![ReviewSource {
id: "source-1".to_string(),
kind: ReviewSourceKind::BranchCompare {
base_branch: "main".to_string(),
head_branch: "feature".to_string(),
merge_base: true,
},
label: "feature comparison".to_string(),
included: true,
}];
app.selected_diff_line = 2;
let anchor = app.selected_comment_anchor().expect("anchor");
for index in 0_usize..30 {
let thread_anchor = ReviewCommentAnchor {
diff_row: index.saturating_add(10),
new_line: u32::try_from(index.saturating_add(10)).ok(),
new_start: u32::try_from(index.saturating_add(10)).ok(),
new_end: u32::try_from(index.saturating_add(10)).ok(),
..anchor.clone()
};
app.draft_comments.insert(
thread_anchor.clone(),
vec![ReviewDraftComment {
id: Some(format!("comment-{index}")),
body: "x".repeat(500),
persisted: true,
created_at_ms: Some(1),
updated_at_ms: Some(1),
session_id: None,
thread_kind: ReviewThreadKind::Finding,
severity: ReviewThreadSeverity::Warning,
}],
);
if index % 2 == 0 {
app.resolved_review_threads
.insert(ReviewApp::thread_key_for_anchor(&thread_anchor));
}
}
let ask = PendingAgentSession {
exchange_id: "exchange-1".to_string(),
anchor,
draft_body: Some("Review this change".to_string()),
command: ReviewAiCommand::FindRisk,
};
let prompt = app.agent_session_prompt(&ask);
assert!(prompt.contains("Target/sources: Compare main...feature"));
assert!(prompt.contains("Other review threads (bounded):"));
assert!(prompt.contains("[open]") || prompt.contains("[resolved]"));
assert!(prompt.contains(ReviewAiCommand::FindRisk.instruction()));
assert!(prompt.chars().count() <= MAX_AI_CONTEXT_CHARS);
assert!(prompt.matches("\n* ").count() <= MAX_AI_THREAD_SUMMARIES);
}
#[test]
fn advanced_sources_are_progressively_disclosed() {
let common = add_source_menu_items();
let advanced = advanced_source_menu_items();
assert!(
common
.iter()
.any(|item| item.kind == AddSourceMenuKind::Advanced)
);
assert!(!common.iter().any(|item| matches!(
item.kind,
AddSourceMenuKind::Commit
| AddSourceMenuKind::CommitRange
| AddSourceMenuKind::BranchCompare
| AddSourceMenuKind::FileRange
)));
assert!(
advanced
.iter()
.any(|item| item.kind == AddSourceMenuKind::BranchCompare)
);
assert!(
advanced
.iter()
.any(|item| item.kind == AddSourceMenuKind::CommitRange)
);
let mut app = sample_app();
app.set_build_mode();
assert!(app.open_add_source_prompt());
assert_eq!(
app.prompt_state.as_ref().map(|prompt| &prompt.kind),
Some(&ReviewPromptKind::AddSourceKind)
);
assert!(app.start_add_source_kind(AddSourceMenuKind::Advanced));
assert_eq!(
app.prompt_state.as_ref().map(|prompt| &prompt.kind),
Some(&ReviewPromptKind::AddAdvancedSourceKind)
);
}
#[test]
fn rematerialize_workspace_exposes_explicit_progress_state() {
let mut app = sample_app();
assert!(app.rematerialize_workspace());
assert!(app.pending_workspace_reload);
assert!(app.workspace_materialization_in_progress);
assert!(!app.workspace_materialization_failed);
assert_eq!(
app.status_message.as_deref(),
Some("materializing review sources…")
);
}
#[test]
fn external_import_picker_supports_multiple_providers_and_options() {
let mut app = sample_app();
app.show_external_importers(vec![
ExternalImporterRoute {
manifest: ReviewImporterManifest {
id: "first".to_string(),
label: "First".to_string(),
description: "First importer".to_string(),
options_schema: serde_json::json!({"type":"object","properties":{}}),
},
plugin_id: "plugin.first".to_string(),
interface_id: REVIEW_IMPORTER_INTERFACE_ID.to_string(),
},
ExternalImporterRoute {
manifest: ReviewImporterManifest {
id: "github".to_string(),
label: "GitHub".to_string(),
description: "GitHub importer".to_string(),
options_schema: serde_json::json!({
"type":"object",
"properties": {
"repository": {"type":"string"},
"pull_request": {"type":"string"}
}
}),
},
plugin_id: "plugin.github".to_string(),
interface_id: REVIEW_IMPORTER_INTERFACE_ID.to_string(),
},
]);
assert!(matches!(app.import_state, Some(ReviewImportState::Picker)));
assert!(app.import_down(1));
assert!(app.confirm_import_selection());
assert!(matches!(
app.import_state,
Some(ReviewImportState::Options { .. })
));
assert!(app.edit_import_option(KeyStroke::simple(KeyCode::Char('o'))));
assert!(app.edit_import_option(KeyStroke::simple(KeyCode::Char('w'))));
assert!(app.confirm_import_selection());
assert!(matches!(
app.take_external_import_request(),
Some(PendingExternalImportRequest::Invoke { importer_index: 1, options })
if options.first().is_some_and(|option| option.value == "ow")
));
}
#[test]
fn queues_external_import_only_for_durable_workspace() {
let mut app = sample_app();
assert!(app.import_external_review());
assert_eq!(
app.take_external_import_request(),
Some(PendingExternalImportRequest::Discover)
);
app.workspace.id.clear();
assert!(app.import_external_review());
assert!(app.take_external_import_request().is_none());
assert_eq!(
app.status_message.as_deref(),
Some("external import requires a durable workspace")
);
}
#[test]
fn review_files_round_trip_for_external_import_mapping() {
let app = sample_app();
let restored = app
.review
.files
.iter()
.map(ReviewFile::to_model)
.map(ReviewFile::from_model)
.collect::<Vec<_>>();
assert_eq!(restored, app.review.files);
}
#[test]
fn persisted_external_threads_restore_distinct_mapping_states() {
let mut app = sample_app();
let mapped_anchor = ModelDraftAnchor {
kind: ReviewAnchorKind::Range,
file_path: "a.rs".to_string(),
diff_row: 2,
start_diff_row: Some(2),
end_diff_row: Some(2),
old_start: None,
old_end: None,
new_start: Some(2),
new_end: Some(2),
old_line: None,
new_line: Some(2),
line_kind: bcode_code_review_models::ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
app.load_external_imports(vec![ExternalReviewImportRecord {
workspace_id: app.workspace.id.clone(),
importer_id: "github_pr_review".to_string(),
threads: vec![
external_test_thread(
"thread:1",
ExternalReviewThreadState::Open,
ExternalAnchorMappingStatus::Exact,
Some(mapped_anchor),
),
external_test_thread(
"thread:2",
ExternalReviewThreadState::Outdated,
ExternalAnchorMappingStatus::Unmappable,
None,
),
],
warnings: vec!["one unmappable thread".to_string()],
updated_at_ms: 10,
}]);
let external = app
.thread_summaries()
.into_iter()
.filter(|thread| thread.external_provider_id.is_some())
.collect::<Vec<_>>();
assert_eq!(external.len(), 2);
assert!(external.iter().any(|thread| {
thread.external_mapping_status == Some(ExternalAnchorMappingStatus::Exact)
&& thread.anchor.path == "a.rs"
}));
assert!(external.iter().any(|thread| {
thread.external_mapping_status == Some(ExternalAnchorMappingStatus::Unmappable)
&& thread.anchor.path == "<unmapped external>"
}));
let preview = app.selected_thread_preview().expect("external preview");
assert!(preview.contains("external:github_pr_review/mapped"));
}
fn external_test_thread(
id: &str,
state: ExternalReviewThreadState,
status: ExternalAnchorMappingStatus,
anchor: Option<ModelDraftAnchor>,
) -> bcode_code_review_models::ExternalReviewThread {
bcode_code_review_models::ExternalReviewThread {
identity: bcode_code_review_models::ExternalReviewIdentity {
provider_id: "github".to_string(),
external_id: id.to_string(),
url: None,
},
state,
mapping: bcode_code_review_models::ExternalReviewAnchorMapping {
status,
anchor,
message: None,
},
comments: vec![bcode_code_review_models::ExternalReviewComment {
identity: bcode_code_review_models::ExternalReviewIdentity {
provider_id: "github".to_string(),
external_id: format!("comment:{id}"),
url: None,
},
author: None,
author_label: Some("reviewer".to_string()),
body: "External finding".to_string(),
created_at_ms: None,
updated_at_ms: None,
}],
}
}
#[test]
fn durable_publish_records_restore_per_thread_state() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
app.comment_editor
.as_mut()
.expect("editor")
.buffer
.insert_str("Publish me");
assert!(app.save_comment_editor());
let anchor = app.selected_comment_anchor().expect("anchor");
let model_anchor: ModelDraftAnchor = anchor.clone().into();
app.load_publish_records(vec![bcode_code_review_models::ReviewPublishRecord {
id: "record-1".to_string(),
workspace_id: Some(app.workspace.id.clone()),
review_id: "review-1".to_string(),
publisher_id: "github".to_string(),
submitted: true,
published_thread_anchors: vec![model_anchor],
output: Some("https://example.test/review".to_string()),
message: "submitted".to_string(),
created_at_ms: 10,
}]);
assert_eq!(app.thread_publish_label(&anchor), "published");
assert_eq!(app.thread_publisher_id(&anchor), Some("github"));
}
#[test]
fn publish_selection_excludes_threads_and_marks_only_included_as_published() {
let mut app = sample_app();
for (row, body) in [(1_usize, "first"), (2_usize, "second")] {
app.selected_diff_line = row;
assert!(app.open_comment_editor());
app.comment_editor
.as_mut()
.expect("editor")
.buffer
.insert_str(body);
assert!(app.save_comment_editor());
}
app.publish_state = Some(ReviewPublishState::Checklist);
app.selected_thread = 0;
assert!(app.toggle_selected_publish_thread());
let selection = app.publish_thread_selection_lines();
assert!(selection.iter().any(|line| line.contains("excluded")));
assert!(selection.iter().any(|line| line.contains("included")));
assert_eq!(app.excluded_publish_anchors().len(), 1);
app.finish_publish(PublishReviewResponse {
publisher_id: "local_markdown".to_string(),
submitted: true,
output: None,
message: "published".to_string(),
});
let threads = app.thread_summaries();
let published = threads
.iter()
.filter(|thread| app.thread_publish_label(&thread.anchor) == "published")
.count();
assert_eq!(published, 1);
assert_eq!(app.published_review_threads.len(), 1);
}
#[test]
fn ai_exchange_has_an_explicit_inline_delete_action() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.run_ai_command_at_selection(ReviewAiCommand::Analyze));
let anchor = app.selected_comment_anchor().expect("anchor");
let document = app.current_review_view_document().expect("review document");
let delete_target = document.rows.iter().find_map(|row| match &row.target {
ReviewViewTarget::ThreadAction { action, .. } if action == "delete-exchange" => {
Some(row.target.clone())
}
_ => None,
});
app.selected_view_target = delete_target;
assert!(app.activate_selected_inline_action());
assert!(!app.ai_exchanges.contains_key(&anchor));
assert!(app.take_pending_ai_exchange_delete().is_some());
}
#[test]
fn structured_ai_commands_queue_expected_action_and_context_metadata() {
let commands = [
ReviewAiCommand::ExplainChange,
ReviewAiCommand::FindRisk,
ReviewAiCommand::SuggestComment,
ReviewAiCommand::CheckTests,
ReviewAiCommand::SummarizeFile,
];
for command in commands {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.run_ai_command_at_selection(command));
let queued = app
.take_pending_agent_session()
.expect("AI action should queue a session");
assert_eq!(queued.command, command);
assert_eq!(queued.draft_body.as_deref(), Some(command.instruction()));
let state = app
.agent_state_for_anchor(&queued.anchor)
.expect("agent state");
assert!(state.context_summary.contains("selected row(s)"));
assert!(state.context_summary.contains("chars"));
}
}
#[test]
fn copies_agent_answer_through_tui_host() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
app.comment_editor
.as_mut()
.expect("editor should open")
.buffer
.insert_str("Can Bcode check this?");
assert!(app.save_comment_editor());
let anchor = app.selected_comment_anchor().expect("anchor");
let key = ReviewApp::thread_key_for_anchor(&anchor);
app.agent_thread_states.insert(
key,
ReviewAgentThreadState {
phase: ReviewAgentThreadPhase::Complete,
session_id: None,
question: "Can Bcode check this?".to_string(),
context_summary: String::new(),
status: "answered".to_string(),
answer: " Use a clearer error message. ".to_string(),
session_items: Vec::new(),
session_revision: 0,
stream_warning: None,
activity: None,
error: None,
},
);
assert!(app.queue_copy_agent_answer_at_selection());
let host = ClipboardTestHost::default();
assert!(app.flush_pending_clipboard_text(&host));
assert_eq!(
*host.copied.lock().expect("clipboard lock"),
vec!["Use a clearer error message.".to_string()]
);
assert_eq!(
app.status_message.as_deref(),
Some("copied Bcode answer to clipboard")
);
}
#[test]
fn converts_agent_answer_to_draft_comment() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
app.comment_editor
.as_mut()
.expect("editor should open")
.buffer
.insert_str("Can Bcode check this?");
assert!(app.save_comment_editor());
let anchor = app.selected_comment_anchor().expect("anchor");
let exchange_id =
app.create_ai_exchange(anchor.clone(), "Can Bcode check this?".to_string(), None);
app.mark_thread_session(&exchange_id, &anchor, &SessionId::new().to_string());
let key = ReviewApp::thread_key_for_anchor(&anchor);
app.agent_thread_states.insert(
key,
ReviewAgentThreadState {
phase: ReviewAgentThreadPhase::Complete,
session_id: app.session_id_for_anchor(&anchor).map(ToString::to_string),
question: "Can Bcode check this?".to_string(),
context_summary: String::new(),
status: "answered".to_string(),
answer: "Use a clearer error message.".to_string(),
session_items: Vec::new(),
session_revision: 0,
stream_warning: None,
activity: None,
error: None,
},
);
assert!(app.convert_agent_answer_to_draft_at_selection());
let comments = app.draft_comments.get(&anchor).expect("comments");
assert_eq!(
comments.last().map(|comment| comment.body.as_str()),
Some("Use a clearer error message.")
);
assert_eq!(
app.take_pending_draft_save()
.expect("draft save should be pending")
.body,
"Use a clearer error message."
);
}
#[test]
fn thread_preview_shows_linked_session() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
app.comment_editor
.as_mut()
.expect("editor should open")
.buffer
.insert_str("Needs a test");
assert!(app.save_comment_editor());
let anchor = app.selected_comment_anchor().expect("anchor");
let exchange_id = app.create_ai_exchange(anchor.clone(), "Needs a test".to_string(), None);
app.mark_thread_session(&exchange_id, &anchor, &SessionId::new().to_string());
app.toggle_sidebar_mode();
let preview = app.selected_thread_preview().expect("thread preview");
assert!(preview.contains("🤖"));
assert!(preview.contains("Needs a test"));
}
#[test]
fn thread_summary_tracks_resolved_state() {
let mut app = sample_app();
app.selected_diff_line = 2;
assert!(app.open_comment_editor());
app.comment_editor
.as_mut()
.expect("editor should open")
.buffer
.insert_str("Needs a test");
assert!(app.save_comment_editor());
let anchor = app.selected_comment_anchor().expect("anchor");
app.resolved_review_threads
.insert(ReviewApp::thread_key_for_anchor(&anchor));
let summary = app.thread_summaries().pop().expect("thread summary");
assert!(summary.resolved);
assert_eq!(app.thread_status_counts(), (0, 1));
assert!(
app.selected_thread_preview()
.expect("thread preview")
.contains("resolved thread")
);
}
#[test]
fn markdown_publish_flow_covers_picker_preview_confirm_submit_and_failure() {
let mut app = sample_app();
app.publish_state = Some(ReviewPublishState::Checklist);
assert!(app.confirm_publish_selection());
assert_eq!(
app.take_publish_request(),
Some(PendingPublishRequest::ListPublishers)
);
app.show_publishers(vec![ReviewPublisherManifest {
id: "markdown_file".to_string(),
label: "Markdown file".to_string(),
description: "Export Markdown".to_string(),
capabilities: ReviewPublisherCapabilities {
preview: true,
submit: true,
update_existing: true,
supports_threads: true,
supports_ranges: true,
supports_inline_comments: true,
supports_summary_comment: true,
},
options_schema: serde_json::json!({"type":"object","properties":{}}),
route: None,
}]);
assert!(app.confirm_publish_selection());
assert!(matches!(
app.take_publish_request(),
Some(PendingPublishRequest::Preview { publisher_id, .. }) if publisher_id == "markdown_file"
));
app.show_publish_preview("markdown_file".to_string(), "# Preview".to_string());
assert!(matches!(
app.publish_state,
Some(ReviewPublishState::Preview { .. })
));
assert!(app.confirm_publish_selection());
assert!(matches!(
app.publish_state,
Some(ReviewPublishState::ConfirmSubmit { .. })
));
assert!(app.confirm_publish_selection());
assert!(matches!(
app.take_publish_request(),
Some(PendingPublishRequest::Submit { publisher_id, .. }) if publisher_id == "markdown_file"
));
app.fail_publish_submit("disk full");
assert!(matches!(
app.publish_state,
Some(ReviewPublishState::Preview { .. })
));
assert_eq!(
app.status_message.as_deref(),
Some("publish failed: disk full")
);
}
#[test]
fn github_publish_flow_covers_options_preview_submit_and_failure() {
let mut app = sample_app();
app.show_publishers(vec![ReviewPublisherManifest {
id: "github".to_string(),
label: "GitHub PR review".to_string(),
description: "Publish to GitHub".to_string(),
capabilities: ReviewPublisherCapabilities {
preview: true,
submit: true,
update_existing: false,
supports_threads: true,
supports_ranges: true,
supports_inline_comments: true,
supports_summary_comment: true,
},
options_schema: serde_json::json!({
"type":"object",
"properties": {
"submit_event": {"type":"string","default":"COMMENT","enum":["COMMENT","REQUEST_CHANGES","APPROVE"]},
"fallback_unmapped_to_summary": {"type":"boolean","default":false}
}
}),
route: Some(ReviewPublisherRoute {
plugin_id: "bcode.github-review-publisher".to_string(),
interface_id: REVIEW_PUBLISHER_INTERFACE_ID.to_string(),
}),
}]);
app.selected_publisher = 0;
assert!(app.confirm_publish_selection());
assert!(matches!(
app.publish_state,
Some(ReviewPublishState::Options { .. })
));
assert!(app.cycle_selected_publish_option(1));
assert!(app.publish_down(1));
assert!(app.cycle_selected_publish_option(1));
assert!(app.confirm_publish_selection());
assert!(matches!(
app.take_publish_request(),
Some(PendingPublishRequest::Preview { publisher_id, options, .. })
if publisher_id == "github" && options.iter().any(|option| option.value == "REQUEST_CHANGES")
));
app.show_publish_preview(
"github".to_string(),
"# GitHub PR review preview\n\n## Warnings\n* unmappable anchor".to_string(),
);
assert!(app.confirm_publish_selection());
assert!(app.confirm_publish_selection());
assert!(matches!(
app.take_publish_request(),
Some(PendingPublishRequest::Submit { publisher_id, .. }) if publisher_id == "github"
));
app.publish_state = Some(ReviewPublishState::Options {
publisher_id: "github".to_string(),
options: vec![ReviewPublishOption {
name: "submit_event".to_string(),
label: "GitHub review event".to_string(),
value: "REQUEST_CHANGES".to_string(),
choices: vec!["COMMENT".to_string(), "REQUEST_CHANGES".to_string()],
}],
selected: 0,
});
app.fail_publish_preview("GitHub unavailable");
assert!(matches!(
app.publish_state,
Some(ReviewPublishState::Options { .. })
));
assert!(
app.status_message
.as_deref()
.is_some_and(|message| message.contains("GitHub unavailable"))
);
}
#[test]
fn cycles_enumerated_publish_options() {
let mut app = sample_app();
app.publish_state = Some(ReviewPublishState::Options {
publisher_id: "github".to_string(),
options: vec![ReviewPublishOption {
name: "submit_event".to_string(),
label: "GitHub review event".to_string(),
value: "COMMENT".to_string(),
choices: vec![
"COMMENT".to_string(),
"REQUEST_CHANGES".to_string(),
"APPROVE".to_string(),
],
}],
selected: 0,
});
assert!(app.cycle_selected_publish_option(1));
let Some(ReviewPublishState::Options { options, .. }) = &app.publish_state else {
panic!("options state expected");
};
assert_eq!(options[0].value, "REQUEST_CHANGES");
}
#[test]
fn toggles_selected_file_viewed() {
let mut app = sample_app();
assert_eq!(app.viewed_file_counts(), (0, app.review.files.len()));
assert!(app.toggle_selected_file_viewed());
assert!(app.file_viewed(0));
assert_eq!(app.viewed_file_counts(), (1, app.review.files.len()));
assert!(app.toggle_selected_file_viewed());
assert!(!app.file_viewed(0));
}
#[test]
fn selects_next_unviewed_file() {
let mut app = sample_app();
assert!(app.toggle_selected_file_viewed());
assert!(app.select_next_unviewed_file());
assert_eq!(app.selected_file, 1);
assert_eq!(
app.status_message.as_deref(),
Some("selected unviewed file")
);
}
#[test]
fn selects_previous_unviewed_file() {
let mut app = sample_app();
app.selected_file = 1;
app.viewed_files.insert("b.rs".to_string());
assert!(app.select_previous_unviewed_file());
assert_eq!(app.selected_file, 0);
assert_eq!(
app.status_message.as_deref(),
Some("selected unviewed file")
);
}
#[test]
fn marks_all_files_viewed() {
let mut app = sample_app();
assert!(app.mark_all_files_viewed());
assert_eq!(
app.viewed_file_counts(),
(app.review.files.len(), app.review.files.len())
);
assert_eq!(app.workspace.viewed_files, app.viewed_files);
assert!(app.pending_workspace_save);
}
#[test]
fn marks_all_files_unviewed() {
let mut app = sample_app();
assert!(app.mark_all_files_viewed());
app.pending_workspace_save = false;
assert!(app.mark_all_files_unviewed());
assert_eq!(app.viewed_file_counts(), (0, app.review.files.len()));
assert!(app.workspace.viewed_files.is_empty());
assert!(app.viewed_files.is_empty());
assert!(app.pending_workspace_save);
}
#[test]
fn review_readiness_tracks_unviewed_and_open_threads() {
let mut app = sample_app();
assert_eq!(app.review_readiness_label(), "incomplete: 2 unviewed");
assert!(app.mark_all_files_viewed());
assert_eq!(app.review_readiness_label(), "ready");
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
assert_eq!(app.review_readiness_label(), "incomplete: 1 open");
}
#[test]
fn publish_warns_when_review_incomplete() {
let mut app = sample_app();
assert!(app.publish_review());
assert!(app.pending_publish_request.is_none());
assert_eq!(
app.status_message.as_deref(),
Some("review incomplete: 0/2 files viewed; press x again to publish anyway")
);
assert!(app.publish_review());
assert!(matches!(
app.publish_state,
Some(ReviewPublishState::Checklist)
));
assert!(app.pending_publish_request.is_none());
assert!(app.confirm_publish_selection());
assert!(matches!(
app.pending_publish_request,
Some(PendingPublishRequest::ListPublishers)
));
}
#[test]
fn publish_checklist_summarizes_readiness() {
let mut app = sample_app();
assert_eq!(
app.publish_checklist_lines(),
vec![
"! review has remaining attention items".to_string(),
"files viewed: 0/2".to_string(),
"unviewed files: 2 (W jump)".to_string(),
" • a.rs".to_string(),
" • b.rs".to_string(),
"open threads: 0 (P jump)".to_string(),
"resolved threads: 0".to_string(),
"draft comments: 0".to_string(),
"press ! for attention sidebar".to_string(),
]
);
assert!(app.mark_all_files_viewed());
assert_eq!(
app.publish_checklist_lines().first().map(String::as_str),
Some("✓ ready to publish")
);
}
#[test]
fn review_level_thread_summary_uses_scope_label() {
let mut app = sample_app();
assert!(app.open_review_comment_editor());
let editor = app.comment_editor.as_mut().expect("editor should open");
editor.buffer = TextEditBuffer::from_text("overall");
assert!(app.save_comment_editor());
let summary = app.thread_summaries().pop().expect("thread summary");
assert_eq!(summary.anchor.scope_label(), "Review");
assert_eq!(summary.line_label(), "review");
assert!(
app.local_review_report_markdown()
.contains("### Review (open)")
);
}
#[test]
fn loads_persisted_review_level_drafts() {
let mut app = sample_app();
app.load_persisted_drafts(vec![DraftComment {
comment_id: "comment-review".to_string(),
thread_id: "thread-review".to_string(),
anchor: DraftAnchor {
kind: ReviewAnchorKind::Review,
file_path: REVIEW_LEVEL_ANCHOR_PATH.to_string(),
diff_row: 0,
start_diff_row: None,
end_diff_row: None,
old_start: None,
old_end: None,
new_start: None,
new_end: None,
old_line: None,
new_line: None,
line_kind: ReviewLineKind::Context,
is_file_anchor: false,
surface_id: None,
source_id: None,
},
body: "overall".to_string(),
created_at_ms: 1,
updated_at_ms: 1,
session_id: None,
resolved_at_ms: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}]);
let threads = app.local_review_threads();
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].anchor.path, REVIEW_LEVEL_ANCHOR_PATH);
assert_eq!(threads[0].line_label(), "review");
assert_eq!(threads[0].latest_body(), Some("overall"));
}
#[test]
fn uppercase_c_opens_review_level_comment_editor() {
let mut app = sample_app();
assert!(handle_review_navigation_key(&mut app, KeyCode::Char('C')));
assert_eq!(
app.comment_editor
.as_ref()
.map(|editor| editor.anchor.path.as_str()),
Some(REVIEW_LEVEL_ANCHOR_PATH)
);
}
#[test]
fn review_level_comment_saves_as_global_thread() {
let mut app = sample_app();
assert!(app.open_review_comment_editor());
let editor = app.comment_editor.as_mut().expect("editor should open");
editor.buffer = TextEditBuffer::from_text("overall review note");
assert!(app.save_comment_editor());
let pending = app
.take_pending_draft_save()
.expect("global comment should be pending persistence");
assert_eq!(pending.anchor.path, REVIEW_LEVEL_ANCHOR_PATH);
let draft_anchor = DraftAnchor::from(pending.anchor);
assert_eq!(draft_anchor.kind, ReviewAnchorKind::Review);
assert_eq!(draft_anchor.file_path, REVIEW_LEVEL_ANCHOR_PATH);
assert_eq!(app.local_review_threads()[0].line_label(), "review");
}
#[test]
fn local_review_report_includes_threads_and_attention_state() {
let mut app = sample_app();
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
app.draft_comments.insert(
anchor,
vec![ReviewDraftComment {
id: Some("comment".to_string()),
body: "first line\nsecond line".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: Some("session-1".to_string()),
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
let report = app.local_review_report_markdown();
assert!(report.contains("# Local review: test"));
assert!(report.contains("* Files viewed: 0/2"));
assert!(report.contains("* `a.rs`"));
assert!(report.contains("### `a.rs` +1 (open)"));
assert!(report.contains("Linked Bcode session: `session-1`"));
assert!(report.contains("first line\n second line"));
}
#[test]
fn local_review_threads_project_persisted_comment_state() {
let mut app = sample_app();
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
app.draft_comments.insert(
anchor.clone(),
vec![ReviewDraftComment {
id: Some("comment".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: Some(1),
updated_at_ms: Some(2),
session_id: Some("session".to_string()),
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
app.resolved_review_threads
.insert(ReviewApp::thread_key_for_anchor(&anchor));
let threads = app.local_review_threads();
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].anchor, anchor);
assert_eq!(threads[0].status, LocalReviewThreadStatus::Resolved);
assert_eq!(threads[0].session_id.as_deref(), Some("session"));
assert_eq!(threads[0].latest_body(), Some("note"));
assert_eq!(app.local_review_thread_status_counts(), (0, 1));
}
#[test]
fn attention_navigation_prioritizes_unviewed_then_open_threads() {
let mut app = sample_app();
assert!(app.select_next_attention_item());
assert_eq!(app.selected_file, 1);
assert_eq!(
app.status_message.as_deref(),
Some("selected unviewed file")
);
assert!(app.mark_all_files_viewed());
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
app.draft_comments.insert(
anchor.clone(),
vec![ReviewDraftComment {
id: Some("comment".to_string()),
body: "note".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
}],
);
assert!(app.select_next_attention_item());
assert_eq!(app.selected_file, anchor.file_index);
assert_eq!(app.selected_diff_line, anchor.diff_row);
}
#[test]
fn publish_checklist_shortcuts_jump_to_attention_items() {
let mut app = sample_app();
app.publish_state = Some(ReviewPublishState::Checklist);
assert!(handle_publish_key(
&mut app,
KeyStroke {
key: KeyCode::Char('W'),
modifiers: bmux_keyboard::Modifiers::NONE,
},
));
assert!(app.publish_state.is_none());
assert_eq!(app.selected_file, 1);
assert_eq!(
app.status_message.as_deref(),
Some("selected unviewed file")
);
app.publish_state = Some(ReviewPublishState::Checklist);
assert!(handle_publish_key(
&mut app,
KeyStroke {
key: KeyCode::Char('!'),
modifiers: bmux_keyboard::Modifiers::NONE,
},
));
assert!(app.publish_state.is_none());
assert_eq!(app.sidebar_mode, ReviewSidebarMode::NeedsAttention);
}
fn linked_agent_app(session_id: &str) -> (ReviewApp, ReviewCommentAnchor) {
let mut app = sample_app();
let anchor = ReviewCommentAnchor {
file_index: 0,
path: "a.rs".to_string(),
diff_row: 2,
end_diff_row: None,
old_line: None,
new_line: Some(1),
old_start: None,
old_end: None,
new_start: Some(1),
new_end: Some(1),
line_kind: ReviewLineKind::Added,
is_file_anchor: false,
surface_id: None,
source_id: None,
};
app.draft_comments.insert(
anchor.clone(),
vec![ReviewDraftComment {
id: Some("comment".to_string()),
body: "question".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: Some(session_id.to_string()),
thread_kind: ReviewThreadKind::Question,
severity: ReviewThreadSeverity::Info,
}],
);
(app, anchor)
}
#[test]
fn multiple_threads_keep_independent_sessions_and_bounded_context() {
let mut app = sample_app();
let first_anchor = app.comment_anchor_for_row(1).expect("first anchor");
let second_anchor = app.comment_anchor_for_row(2).expect("second anchor");
let first_session = SessionId::new().to_string();
let second_session = SessionId::new().to_string();
let first_exchange = app.create_ai_exchange(
first_anchor.clone(),
"first question".to_string(),
Some(first_session.clone()),
);
app.mark_thread_session(&first_exchange, &first_anchor, &first_session);
let second_exchange = app.create_ai_exchange(
second_anchor.clone(),
"second question".to_string(),
Some(second_session.clone()),
);
app.mark_thread_session(&second_exchange, &second_anchor, &second_session);
let ids = app.linked_agent_session_ids();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&first_session));
assert!(ids.contains(&second_session));
assert_ne!(
app.agent_state_for_anchor(&first_anchor)
.and_then(|state| state.session_id.as_deref()),
app.agent_state_for_anchor(&second_anchor)
.and_then(|state| state.session_id.as_deref())
);
let context = app.agent_session_prompt(&PendingAgentSession {
exchange_id: second_exchange,
anchor: second_anchor,
draft_body: Some("second question".to_string()),
command: ReviewAiCommand::Analyze,
});
assert!(context.chars().count() <= MAX_AI_CONTEXT_CHARS + 2_000);
assert!(context.matches("first question").count() <= 1);
}
#[test]
fn close_and_reopen_restores_linked_exchange_and_stable_location() {
let session_id = SessionId::new().to_string();
let (mut app, anchor) = linked_agent_app(&session_id);
app.load_persisted_ai_exchanges(vec![ModelReviewAiExchange {
schema_version: bcode_code_review_models::REVIEW_AI_EXCHANGE_SCHEMA_VERSION,
exchange_id: "exchange-reopen".to_string(),
anchor: ModelDraftAnchor::from(anchor.clone()),
question: "Remember this question?".to_string(),
session_id: Some(session_id.clone()),
status: ModelReviewAiExchangeStatus::Linked,
error: None,
created_at_ms: 1,
updated_at_ms: 1,
}]);
app.sidebar_mode = ReviewSidebarMode::Threads;
app.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: ReviewApp::thread_key_for_anchor(&anchor),
});
app.sync_presentation_state();
let mut review = app.review.clone();
review.workspace = Some(app.workspace.clone());
let mut reopened = ReviewApp::new(review);
reopened
.load_persisted_ai_exchanges(app.ai_exchanges.values().flatten().cloned().collect());
assert_eq!(reopened.selected_file, anchor.file_index);
assert_eq!(reopened.sidebar_mode, ReviewSidebarMode::Threads);
assert_eq!(reopened.linked_agent_session_ids(), vec![session_id]);
let state = reopened
.agent_state_for_anchor(&anchor)
.expect("restored linked exchange");
assert_eq!(state.question, "Remember this question?");
assert_eq!(state.status, "reconnecting linked session…");
}
#[test]
fn workspace_presentation_state_restores_stable_location_best_effort() {
let base = sample_app();
let mut review = base.review;
let path = review.files[0].display_path().to_string();
let mut workspace = review.workspace();
workspace.presentation_state = Some(ReviewWorkspacePresentationState {
schema_version: bcode_code_review_models::REVIEW_WORKSPACE_PRESENTATION_SCHEMA_VERSION,
selected_path: Some(path),
selected_thread_key: Some("missing-thread".to_string()),
selected_line: Some(1),
sidebar_mode: "threads".to_string(),
sidebar_visible: false,
thread_filter: "open".to_string(),
show_resolved_threads: false,
hide_viewed_files: true,
collapsed_thread_keys: BTreeSet::from(["collapsed".to_string()]),
expanded_agent_answer_keys: BTreeSet::from(["expanded".to_string()]),
});
review.workspace = Some(workspace);
let app = ReviewApp::new(review);
assert_eq!(app.sidebar_mode, ReviewSidebarMode::Threads);
assert!(!app.sidebar_visible);
assert_eq!(app.thread_filter, ReviewThreadFilter::Open);
assert!(app.hide_viewed_files);
assert!(app.collapsed_review_threads.contains("collapsed"));
assert!(app.expanded_agent_answers.contains("expanded"));
assert!(matches!(
app.selected_view_target,
Some(ReviewViewTarget::Thread { .. })
));
}
#[test]
fn navigation_failure_retains_review_state_for_retry() {
let session_id = SessionId::new();
let mut app = sample_app();
app.selected_file = 0;
app.selected_diff_line = 2;
app.diff_scroll = 7;
app.sidebar_mode = ReviewSidebarMode::Threads;
app.navigation_failed(session_id, "session not found");
assert_eq!(app.selected_file, 0);
assert_eq!(app.selected_diff_line, 2);
assert_eq!(app.diff_scroll, 7);
assert_eq!(app.sidebar_mode, ReviewSidebarMode::Threads);
assert!(
app.status_message
.as_deref()
.is_some_and(|message| message.contains("session not found"))
);
assert!(!app.should_exit);
}
#[test]
fn linked_session_return_restores_exact_in_process_review_location() {
let session_id = SessionId::new();
let (mut app, anchor) = linked_agent_app(&session_id.to_string());
let exchange_id = app.create_ai_exchange(
anchor.clone(),
"question".to_string(),
Some(session_id.to_string()),
);
app.mark_thread_session(&exchange_id, &anchor, &session_id.to_string());
app.selected_file = 0;
app.selected_diff_line = 2;
app.diff_scroll = 7;
app.sidebar_mode = ReviewSidebarMode::Threads;
app.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: ReviewApp::thread_key_for_anchor(&anchor),
});
let expected_target = app.selected_view_target.clone();
assert!(app.open_linked_session_at_selection());
assert_eq!(app.take_session_to_open(), Some(session_id));
app.status_message = Some("returned from linked Bcode session".to_string());
assert_eq!(app.selected_file, 0);
assert_eq!(app.selected_diff_line, 2);
assert_eq!(app.diff_scroll, 7);
assert_eq!(app.sidebar_mode, ReviewSidebarMode::Threads);
assert_eq!(app.selected_view_target, expected_target);
assert!(!app.should_exit);
}
#[test]
fn linked_session_open_queues_navigation_without_exiting_review() {
let session_id = SessionId::new();
let (mut app, anchor) = linked_agent_app(&session_id.to_string());
let exchange_id = app.create_ai_exchange(
anchor.clone(),
"question".to_string(),
Some(session_id.to_string()),
);
app.mark_thread_session(&exchange_id, &anchor, &session_id.to_string());
app.selected_view_target = Some(ReviewViewTarget::Thread {
thread_key: ReviewApp::thread_key_for_anchor(&anchor),
});
assert!(app.open_linked_session_at_selection());
assert_eq!(app.take_session_to_open(), Some(session_id));
assert!(!app.should_exit);
}
#[test]
fn semantic_session_snapshot_populates_answer_and_completion() {
let session_id = SessionId::new();
let session_key = session_id.to_string();
let (mut app, anchor) = linked_agent_app(&session_key);
let mut snapshot = SessionViewSnapshot::empty();
snapshot.session_id = Some(session_id);
snapshot.connection_status = SessionConnectionViewStatus::Attached;
snapshot
.transcript
.items
.push(bcode_session_view_models::TranscriptViewItem {
id: bcode_session_view_models::TranscriptViewItemId::new("assistant:1"),
revision: 1,
sequence: Some(1),
timestamp_ms: Some(1),
output_location: None,
streaming: false,
kind: TranscriptViewItemKind::AssistantMessage {
message: bcode_session_view_models::ChatMessageView::markdown("Looks good"),
},
});
let state = ReviewAgentSessionViewState::from_snapshot(snapshot);
app.apply_agent_stream_state(&session_key, &state);
let thread_state = app
.agent_state_for_anchor(&anchor)
.expect("thread should have agent state");
assert_eq!(thread_state.phase, ReviewAgentThreadPhase::Complete);
assert_eq!(thread_state.answer, "Looks good");
assert_eq!(thread_state.status, "answered");
}
#[test]
fn semantic_session_snapshot_replacement_is_authoritative() {
let session_id = SessionId::new();
let mut first = SessionViewSnapshot::empty();
first.session_id = Some(session_id);
first.connection_status = SessionConnectionViewStatus::Attached;
first
.transcript
.items
.push(bcode_session_view_models::TranscriptViewItem {
id: bcode_session_view_models::TranscriptViewItemId::new("assistant:live"),
revision: 1,
sequence: None,
timestamp_ms: None,
output_location: None,
streaming: true,
kind: TranscriptViewItemKind::AssistantMessage {
message: bcode_session_view_models::ChatMessageView::markdown("Hello wor"),
},
});
first.runtime.active_turn_id = Some("turn-1".to_string());
let mut state = ReviewAgentSessionViewState::from_snapshot(first);
assert_eq!(state.phase, ReviewAgentThreadPhase::Running);
assert!(state.display_answer().is_empty());
assert_eq!(
state.session_items().last().map(|item| item.text.as_str()),
Some("Hello wor")
);
let mut durable = SessionViewSnapshot::empty();
durable.session_id = Some(session_id);
durable.connection_status = SessionConnectionViewStatus::Attached;
durable
.transcript
.items
.push(bcode_session_view_models::TranscriptViewItem {
id: bcode_session_view_models::TranscriptViewItemId::new("assistant:durable"),
revision: 2,
sequence: Some(2),
timestamp_ms: Some(2),
output_location: None,
streaming: false,
kind: TranscriptViewItemKind::AssistantMessage {
message: bcode_session_view_models::ChatMessageView::markdown("Hello world"),
},
});
assert!(state.replace_snapshot(durable));
assert_eq!(state.phase, ReviewAgentThreadPhase::Complete);
assert_eq!(state.display_answer(), "Hello world");
}
#[test]
fn completed_markdown_preserves_headings_lists_and_code_fences() {
let markdown = "## Result\n\n* first\n* second\n\n```rust\nfn main() {}\n```";
let mut snapshot = SessionViewSnapshot::empty();
snapshot.connection_status = SessionConnectionViewStatus::Attached;
snapshot
.transcript
.items
.push(bcode_session_view_models::TranscriptViewItem {
id: bcode_session_view_models::TranscriptViewItemId::new("assistant:markdown"),
revision: 1,
sequence: Some(1),
timestamp_ms: Some(1),
output_location: None,
streaming: false,
kind: TranscriptViewItemKind::AssistantMessage {
message: bcode_session_view_models::ChatMessageView::markdown(markdown),
},
});
let state = ReviewAgentSessionViewState::from_snapshot(snapshot);
assert_eq!(state.display_answer(), markdown);
let answer = state
.session_items()
.iter()
.find(|item| item.kind == ReviewAgentSessionItemKind::Assistant)
.expect("assistant markdown");
assert_eq!(answer.format, TextFormat::Markdown);
assert!(answer.text.contains("```rust"));
assert!(answer.text.contains("* second"));
}
#[test]
fn semantic_tail_preserves_a_bounded_turn_boundary() {
let items = (0..30)
.map(|index| bcode_session_view_models::TranscriptViewItem {
id: bcode_session_view_models::TranscriptViewItemId::new(format!("item:{index}")),
revision: index,
sequence: Some(index),
timestamp_ms: Some(index),
output_location: Some(bcode_session_view_models::TurnOutputLocation {
turn_id: if index < 4 { "old" } else { "current" }.to_string(),
position: bcode_session_models::TurnOutputPosition::new(index),
}),
streaming: false,
kind: TranscriptViewItemKind::AssistantMessage {
message: bcode_session_view_models::ChatMessageView::plain(index.to_string()),
},
})
.collect::<Vec<_>>();
assert_eq!(semantic_tail_start(&items), 4);
}
#[test]
fn semantic_mini_session_preserves_order_and_bounds_tool_content() {
let mut snapshot = SessionViewSnapshot::empty();
snapshot.connection_status = SessionConnectionViewStatus::Attached;
snapshot.transcript.items = vec![
bcode_session_view_models::TranscriptViewItem {
id: bcode_session_view_models::TranscriptViewItemId::new("reasoning:1"),
revision: 1,
sequence: Some(1),
timestamp_ms: Some(1),
output_location: None,
streaming: false,
kind: TranscriptViewItemKind::ReasoningMessage {
message: bcode_session_view_models::ChatMessageView::plain("checking tests"),
},
},
bcode_session_view_models::TranscriptViewItem {
id: bcode_session_view_models::TranscriptViewItemId::new("tool:1"),
revision: 2,
sequence: Some(2),
timestamp_ms: Some(2),
output_location: None,
streaming: false,
kind: TranscriptViewItemKind::ToolInvocation {
tool: Box::new(bcode_session_view_models::ToolInvocationView {
tool_call_id: "tool-1".to_string(),
producer_plugin_id: Some("shell".to_string()),
tool_name: Some("shell.run".to_string()),
arguments_json: Some("x".repeat(600)),
working_directory: None,
request_draft: None,
status: ToolInvocationViewStatus::Finished,
result_text: Some("tests passed".to_string()),
is_error: Some(false),
result: None,
presentation: None,
timing: bcode_session_view_models::ToolTimingView {
duration_ms: Some(25),
..bcode_session_view_models::ToolTimingView::default()
},
}),
},
},
bcode_session_view_models::TranscriptViewItem {
id: bcode_session_view_models::TranscriptViewItemId::new("assistant:1"),
revision: 3,
sequence: Some(3),
timestamp_ms: Some(3),
output_location: None,
streaming: false,
kind: TranscriptViewItemKind::AssistantMessage {
message: bcode_session_view_models::ChatMessageView::markdown(
"## Done\n\n* tests passed",
),
},
},
];
let state = ReviewAgentSessionViewState::from_snapshot(snapshot);
assert_eq!(state.session_items().len(), 3);
assert_eq!(
state.session_items()[0].kind,
ReviewAgentSessionItemKind::Reasoning
);
assert_eq!(
state.session_items()[1].kind,
ReviewAgentSessionItemKind::Tool
);
assert!(state.session_items()[1].text.chars().count() < 400);
assert_eq!(
state.session_items()[2].kind,
ReviewAgentSessionItemKind::Assistant
);
assert_eq!(state.display_answer(), "## Done\n\n* tests passed");
assert_eq!(
state
.session_items()
.iter()
.map(|item| item.label.as_str())
.collect::<Vec<_>>(),
vec!["activity", "shell.run · Finished", "answer"]
);
}
#[test]
fn file_modifying_tool_is_bounded_in_mini_view_and_complete_in_canonical_snapshot() {
let temp = tempfile::tempdir().expect("tempdir");
let changed_file = temp.path().join("changed.rs");
let full_contents = "pub fn changed() {\n println!(\"complete detail\");\n}\n";
std::fs::write(&changed_file, full_contents).expect("exercise file-modifying tool");
let display_context = bcode_plugin_sdk::tui::PluginTuiVisualRenderContext::new(
80,
bcode_plugin_sdk::tui::PluginTuiDiffLayout::Auto { breakpoint: 120 },
Some(temp.path().to_path_buf()),
);
let full_result = format!(
"wrote {} bytes to {}\n{}",
full_contents.len(),
display_context.display_path(&changed_file),
"full tool detail ".repeat(80)
);
let arguments = serde_json::json!({
"path": changed_file,
"contents": full_contents,
})
.to_string();
let mut snapshot = SessionViewSnapshot::empty();
snapshot.revision = 4;
snapshot.connection_status = SessionConnectionViewStatus::Attached;
snapshot.transcript.items = vec![
bcode_session_view_models::TranscriptViewItem {
id: bcode_session_view_models::TranscriptViewItemId::new("tool:file-write"),
revision: 3,
sequence: Some(3),
timestamp_ms: Some(3),
output_location: None,
streaming: false,
kind: TranscriptViewItemKind::ToolInvocation {
tool: Box::new(bcode_session_view_models::ToolInvocationView {
tool_call_id: "write-1".to_string(),
producer_plugin_id: Some("filesystem".to_string()),
tool_name: Some("filesystem.write".to_string()),
arguments_json: Some(arguments.clone()),
working_directory: Some(temp.path().to_path_buf()),
request_draft: None,
status: ToolInvocationViewStatus::Finished,
result_text: Some(full_result.clone()),
is_error: Some(false),
result: None,
presentation: None,
timing: bcode_session_view_models::ToolTimingView {
duration_ms: Some(8),
..bcode_session_view_models::ToolTimingView::default()
},
}),
},
},
bcode_session_view_models::TranscriptViewItem {
id: bcode_session_view_models::TranscriptViewItemId::new("assistant:file-write"),
revision: 4,
sequence: Some(4),
timestamp_ms: Some(4),
output_location: None,
streaming: false,
kind: TranscriptViewItemKind::AssistantMessage {
message: bcode_session_view_models::ChatMessageView::markdown(
"Updated `changed.rs` and retained complete details in the session.",
),
},
},
];
let state = ReviewAgentSessionViewState::from_snapshot(snapshot);
let mini_tool = state
.session_items()
.iter()
.find(|item| item.kind == ReviewAgentSessionItemKind::Tool)
.expect("bounded mini-view tool row");
assert!(mini_tool.text.chars().count() <= MAX_REVIEW_TOOL_TEXT_CHARS);
assert!(mini_tool.label.contains("filesystem.write"));
assert_eq!(
std::fs::read_to_string(&changed_file).expect("modified file"),
full_contents
);
let TranscriptViewItemKind::ToolInvocation { tool } =
&state.snapshot.transcript.items[0].kind
else {
panic!("canonical tool detail");
};
assert_eq!(tool.arguments_json.as_deref(), Some(arguments.as_str()));
assert_eq!(tool.result_text.as_deref(), Some(full_result.as_str()));
assert_eq!(state.snapshot.transcript.items.len(), 2);
}
#[test]
fn permission_state_is_action_needed_and_full_control_stays_in_native_session() {
let session_id = SessionId::new();
let mut snapshot = SessionViewSnapshot::empty();
snapshot.session_id = Some(session_id);
snapshot.connection_status = SessionConnectionViewStatus::Attached;
snapshot
.permissions
.push(bcode_session_view_models::PermissionView {
permission_id: "permission-1".to_string(),
session_id: Some(session_id),
tool_call_id: "tool-1".to_string(),
tool_name: "filesystem.write".to_string(),
arguments_json: "{\"path\":\"src/lib.rs\"}".to_string(),
batch: None,
agent_id: "agent".to_string(),
title: Some("Allow file write?".to_string()),
policy_source: Some("workspace".to_string()),
detail: Some("Open the native session to approve or deny".to_string()),
resolved: false,
approved: None,
can_remember: true,
});
let state = ReviewAgentSessionViewState::from_snapshot(snapshot);
assert_eq!(state.status, "action needed in linked Bcode session");
let action = state
.session_items()
.iter()
.find(|item| item.kind == ReviewAgentSessionItemKind::ActionNeeded)
.expect("action-needed row");
assert_eq!(action.label, "Allow file write?");
assert!(action.text.contains("native session"));
}
#[test]
fn semantic_session_snapshot_surfaces_reconnect_and_failure() {
let mut reconnecting = SessionViewSnapshot::empty();
reconnecting.connection_status = SessionConnectionViewStatus::Reconnecting;
let state = ReviewAgentSessionViewState::from_snapshot(reconnecting);
assert_eq!(state.phase, ReviewAgentThreadPhase::Running);
assert_eq!(
state.stream_warning.as_deref(),
Some("reconnecting to linked Bcode session")
);
let mut failed = SessionViewSnapshot::empty();
failed.connection_status = SessionConnectionViewStatus::Attached;
failed.runtime.last_turn_outcome =
Some(bcode_session_models::ModelTurnOutcome::ProviderUnavailable);
failed.runtime.last_turn_message = Some("provider unavailable".to_string());
let state = ReviewAgentSessionViewState::from_snapshot(failed);
assert_eq!(state.phase, ReviewAgentThreadPhase::Failed);
assert_eq!(state.error.as_deref(), Some("provider unavailable"));
}
#[test]
fn disconnected_stream_retry_replaces_degraded_state_with_authoritative_snapshot() {
let session_id = SessionId::new().to_string();
let (mut app, anchor) = linked_agent_app(&session_id);
app.selected_diff_line = anchor.diff_row;
app.selected_view_target = None;
app.agent_thread_states.insert(
ReviewApp::thread_key_for_anchor(&anchor),
ReviewAgentThreadState {
phase: ReviewAgentThreadPhase::Running,
session_id: Some(session_id.clone()),
question: "question".to_string(),
context_summary: String::new(),
status: "running".to_string(),
answer: String::new(),
session_items: Vec::new(),
session_revision: 0,
stream_warning: None,
activity: None,
error: None,
},
);
app.mark_agent_session_failed(&session_id, "daemon disconnected");
assert_eq!(
app.agent_state_for_anchor(&anchor)
.and_then(|state| state.error.as_deref()),
Some("daemon disconnected")
);
assert!(app.retry_linked_session_stream_at_selection());
assert!(
app.take_pending_agent_stream_retries()
.iter()
.any(|retry| retry == &session_id)
);
let mut snapshot = SessionViewSnapshot::empty();
snapshot.revision = 9;
snapshot.connection_status = SessionConnectionViewStatus::Attached;
snapshot
.transcript
.items
.push(bcode_session_view_models::TranscriptViewItem {
id: bcode_session_view_models::TranscriptViewItemId::new("assistant:reconnected"),
revision: 9,
sequence: Some(9),
timestamp_ms: Some(9),
output_location: None,
streaming: false,
kind: TranscriptViewItemKind::AssistantMessage {
message: bcode_session_view_models::ChatMessageView::markdown(
"## Reconnected\n\n* no duplicate output",
),
},
});
app.apply_agent_stream_state(
&session_id,
&ReviewAgentSessionViewState::from_snapshot(snapshot),
);
let state = app
.agent_state_for_anchor(&anchor)
.expect("restored stream");
assert_eq!(state.session_revision, 9);
assert_eq!(state.answer, "## Reconnected\n\n* no duplicate output");
assert!(state.error.is_none());
assert!(state.stream_warning.is_none());
assert_eq!(state.session_items.len(), 1);
}
#[test]
fn agent_stream_failed_session_marks_linked_thread_failed() {
let session_id = "not-a-session";
let (mut app, anchor) = linked_agent_app(session_id);
app.create_ai_exchange(
anchor.clone(),
"question".to_string(),
Some(session_id.to_string()),
);
let _ = app.take_pending_ai_exchange_save();
app.mark_agent_session_failed(session_id, "invalid linked Bcode session id");
let thread_state = app
.agent_state_for_anchor(&anchor)
.expect("thread should have agent state");
assert_eq!(thread_state.phase, ReviewAgentThreadPhase::Failed);
assert_eq!(
thread_state.error.as_deref(),
Some("invalid linked Bcode session id")
);
let failed_exchange = app
.take_pending_ai_exchange_save()
.expect("failed linked exchange should be persisted");
assert_eq!(failed_exchange.status, ModelReviewAiExchangeStatus::Failed);
assert_eq!(
failed_exchange.error.as_deref(),
Some("invalid linked Bcode session id")
);
}
#[test]
fn publish_without_warning_when_review_complete() {
let mut app = sample_app();
assert!(app.mark_all_files_viewed());
app.pending_workspace_save = false;
assert!(app.publish_review());
assert!(matches!(
app.publish_state,
Some(ReviewPublishState::Checklist)
));
assert!(app.pending_publish_request.is_none());
assert!(app.confirm_publish_selection());
assert!(matches!(
app.pending_publish_request,
Some(PendingPublishRequest::ListPublishers)
));
}
}