M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,867 @@
|
||||
//! Conversation rewind dispatchers and prompt-entry lookup helpers.
|
||||
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
use crate::views::prompt_widget::{PromptWidget, StashedPrompt};
|
||||
|
||||
/// User prompt that participates in the shell's prompt numbering.
|
||||
/// Interjections render as user prompts but the shell never numbers them,
|
||||
/// so counting them would skew the positional prompt↔entry mapping.
|
||||
///
|
||||
/// Known approximation: an interjection the shell converted into its own
|
||||
/// `interject-fallback-` turn IS shell-numbered, but its live block (rendered
|
||||
/// from the interjection broadcast) is flagged `is_interjection` and carries
|
||||
/// no index, so the positional fallback under-counts around it until a
|
||||
/// resume replays it as an indexed prompt. The primary path (explicit
|
||||
/// `prompt_index` matches) is unaffected.
|
||||
fn is_indexed_user_prompt(block: &RenderBlock) -> bool {
|
||||
matches!(block, RenderBlock::UserPrompt(b) if !b.is_interjection)
|
||||
}
|
||||
|
||||
fn stash_prompt(prompt: &mut PromptWidget) -> Option<StashedPrompt> {
|
||||
if prompt.text().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(prompt.stash())
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::app) fn shell_prompt_index_at(
|
||||
scrollback: &ScrollbackState,
|
||||
entry_idx: usize,
|
||||
) -> Option<usize> {
|
||||
for idx in (0..=entry_idx).rev() {
|
||||
if let Some(e) = scrollback.get(idx)
|
||||
&& let RenderBlock::UserPrompt(ref block) = e.block
|
||||
{
|
||||
// A mid-turn interjection belongs to the enclosing turn — keep
|
||||
// walking back to that turn's starting prompt.
|
||||
if block.is_interjection {
|
||||
continue;
|
||||
}
|
||||
if let Some(pi) = block.prompt_index {
|
||||
return Some(pi);
|
||||
}
|
||||
let count = (0..=idx)
|
||||
.filter(|&i| {
|
||||
scrollback
|
||||
.get(i)
|
||||
.is_some_and(|e2| is_indexed_user_prompt(&e2.block))
|
||||
})
|
||||
.count();
|
||||
return if count > 0 { Some(count - 1) } else { None };
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(in crate::app) fn find_user_prompt_entry_for_shell_index(
|
||||
scrollback: &ScrollbackState,
|
||||
target_prompt_index: usize,
|
||||
) -> Option<usize> {
|
||||
for idx in (0..scrollback.len()).rev() {
|
||||
if let Some(entry) = scrollback.get(idx)
|
||||
&& let RenderBlock::UserPrompt(ref block) = entry.block
|
||||
&& block.prompt_index == Some(target_prompt_index)
|
||||
{
|
||||
return Some(idx);
|
||||
}
|
||||
}
|
||||
let mut count = 0usize;
|
||||
for idx in 0..scrollback.len() {
|
||||
if let Some(e) = scrollback.get(idx)
|
||||
&& is_indexed_user_prompt(&e.block)
|
||||
{
|
||||
if count == target_prompt_index {
|
||||
return Some(idx);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
app.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Rewind takes input priority over the `/jump` picker; close a lingering
|
||||
// one first so it can't reappear (stale) after rewind finishes.
|
||||
agent.dismiss_jump_picker();
|
||||
|
||||
let selected_idx = agent.scrollback.selected();
|
||||
let selected_shell_idx =
|
||||
selected_idx.and_then(|idx| shell_prompt_index_at(&agent.scrollback, idx));
|
||||
|
||||
if agent.session.state.is_busy() {
|
||||
let anchor = agent.scrollback.len().saturating_sub(1);
|
||||
let draft = stash_prompt(&mut agent.prompt);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState::new_cancel_offer(
|
||||
anchor,
|
||||
draft,
|
||||
selected_shell_idx,
|
||||
));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let draft = stash_prompt(&mut agent.prompt);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Loading,
|
||||
anchor_entry_idx: selected_idx.unwrap_or(0),
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: selected_shell_idx,
|
||||
});
|
||||
|
||||
vec![Effect::FetchRewindPoints {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
}]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_show_picker(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
app.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Rewind takes input priority over the `/jump` picker; close a lingering
|
||||
// one first so it can't reappear (stale) after rewind finishes.
|
||||
agent.dismiss_jump_picker();
|
||||
|
||||
if agent.session.state.is_busy() {
|
||||
let anchor = agent.scrollback.len().saturating_sub(1);
|
||||
let draft = stash_prompt(&mut agent.prompt);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState::new_cancel_offer(
|
||||
anchor, draft, None,
|
||||
));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let draft = stash_prompt(&mut agent.prompt);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Loading,
|
||||
anchor_entry_idx: 0,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
|
||||
vec![Effect::FetchRewindPoints {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
}]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_picker_select(app: &mut AppView, prompt_index: usize) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let point = agent.rewind_points.as_ref().and_then(
|
||||
|pts: &Vec<crate::views::rewind::RewindPointInfo>| {
|
||||
pts.iter().find(|p| p.prompt_index == prompt_index)
|
||||
},
|
||||
);
|
||||
let has_file_changes = point.map(|p| p.has_file_changes).unwrap_or(false);
|
||||
|
||||
let anchor = find_user_prompt_entry_for_shell_index(&agent.scrollback, prompt_index);
|
||||
if let Some(entry_idx) = anchor {
|
||||
agent.scrollback.set_selected(Some(entry_idx));
|
||||
}
|
||||
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::ModeSelect {
|
||||
target_prompt_index: prompt_index,
|
||||
has_file_changes,
|
||||
// Inline edit-and-resubmit: the conversation rewind is a given,
|
||||
// so a files-only option makes no sense there.
|
||||
offer_files_only: agent.inline_edit.is_none(),
|
||||
active_idx: 0,
|
||||
},
|
||||
anchor_entry_idx: anchor.unwrap_or(0),
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: Some(prompt_index),
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_cancel_offer(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let selected = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.and_then(|s| s.selected_prompt_index);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Loading,
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: selected,
|
||||
});
|
||||
let mut effects = vec![Effect::CancelTurn {
|
||||
session_id: session_id.clone(),
|
||||
cancel_subagents: true,
|
||||
trigger: None,
|
||||
// The rewind picker owns history via `handle_rewind`; this pre-cancel
|
||||
// must not also pop the in-flight prompt.
|
||||
rewind_if_pristine: false,
|
||||
}];
|
||||
effects.push(Effect::FetchRewindPoints {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
});
|
||||
effects
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_select_mode(
|
||||
app: &mut AppView,
|
||||
mode: crate::views::rewind::RewindMode,
|
||||
target: usize,
|
||||
) -> Vec<Effect> {
|
||||
use crate::views::rewind::{RewindMode, RewindPhase, RewindState};
|
||||
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
match mode {
|
||||
RewindMode::ConversationOnly if target == 0 => {
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let preview = agent
|
||||
.rewind_points
|
||||
.as_ref()
|
||||
.and_then(|pts| pts.iter().find(|p| p.prompt_index == target))
|
||||
.and_then(|p| p.prompt_preview.clone());
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(RewindState {
|
||||
phase: RewindPhase::ConversationOnlyConfirm {
|
||||
target_prompt_index: target,
|
||||
active_idx: 0,
|
||||
prompt_preview: preview,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
RewindMode::ConversationOnly => {
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(RewindState {
|
||||
phase: RewindPhase::Executing {
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
stash_inline_resubmit_if_editing(agent);
|
||||
vec![Effect::RewindExecute {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
}]
|
||||
}
|
||||
RewindMode::All | RewindMode::FilesOnly => {
|
||||
let has_files = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.and_then(|s| match &s.phase {
|
||||
RewindPhase::ModeSelect {
|
||||
has_file_changes, ..
|
||||
} => Some(*has_file_changes),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
|
||||
if !has_files {
|
||||
agent.rewind_state = Some(RewindState {
|
||||
phase: RewindPhase::Executing {
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
stash_inline_resubmit_if_editing(agent);
|
||||
vec![Effect::RewindExecute {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
}]
|
||||
} else {
|
||||
agent.rewind_state = Some(RewindState {
|
||||
phase: RewindPhase::Previewing {
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
vec![Effect::RewindPreview {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_confirm(
|
||||
app: &mut AppView,
|
||||
target: usize,
|
||||
mode: crate::views::rewind::RewindMode,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Executing {
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
stash_inline_resubmit_if_editing(agent);
|
||||
vec![Effect::RewindExecute {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
target_prompt_index: target,
|
||||
mode,
|
||||
}]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_conversation_only_confirm(
|
||||
app: &mut AppView,
|
||||
target: usize,
|
||||
) -> Vec<Effect> {
|
||||
dispatch_rewind_confirm(
|
||||
app,
|
||||
target,
|
||||
crate::views::rewind::RewindMode::ConversationOnly,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_dismiss(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
if let Some(d) = draft {
|
||||
agent.prompt.restore(d);
|
||||
}
|
||||
agent.rewind_points = None;
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_back_to_mode_select(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
if let Some(ref state) = agent.rewind_state {
|
||||
let anchor = state.anchor_entry_idx;
|
||||
let sel_pi = state.selected_prompt_index;
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
|
||||
let (target, has_file_changes) = agent
|
||||
.rewind_points
|
||||
.as_ref()
|
||||
.and_then(|pts| {
|
||||
sel_pi
|
||||
.and_then(|pi| pts.iter().find(|p| p.prompt_index == pi))
|
||||
.or_else(|| pts.iter().max_by_key(|p| p.prompt_index))
|
||||
})
|
||||
.map(|p| (p.prompt_index, p.has_file_changes))
|
||||
.unwrap_or((0, false));
|
||||
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::ModeSelect {
|
||||
target_prompt_index: target,
|
||||
has_file_changes,
|
||||
// Re-derive the inline context: while the inline editor is
|
||||
// open the files-only row stays hidden on the way back too.
|
||||
offer_files_only: agent.inline_edit.is_none(),
|
||||
active_idx: 0,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: sel_pi,
|
||||
});
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_dismiss_error(app: &mut AppView) -> Vec<Effect> {
|
||||
dispatch_rewind_dismiss(app)
|
||||
}
|
||||
|
||||
/// The single place the inline-edit resubmit gets armed: called right
|
||||
/// before every `Effect::RewindExecute` emission in the rewind flow. If the
|
||||
/// inline editor is open, the (trimmed) edited text is stashed for
|
||||
/// `dispatch_rewind_success` to resubmit after the rewind lands. Dismiss /
|
||||
/// error / empty-points paths never arm it, so they need no clearing — the
|
||||
/// editor simply stays open there.
|
||||
fn stash_inline_resubmit_if_editing(agent: &mut crate::app::agent_view::AgentView) {
|
||||
if let Some(ref edit) = agent.inline_edit {
|
||||
agent.pending_inline_resubmit = Some(edit.textarea.text().trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit an inline edit: enter the exact same rewind flow as `/rewind`,
|
||||
/// pre-targeted at the edited prompt (points fetch → ModeSelect with the
|
||||
/// file-revert question → optional preview/confirm → execute; cancel-offer
|
||||
/// first when a turn is running). The editor stays open behind the rewind
|
||||
/// overlays; `stash_inline_resubmit_if_editing` arms the resubmit only when
|
||||
/// a rewind actually executes, and `dispatch_rewind_success` sends the
|
||||
/// edited text from the rewound point.
|
||||
pub(super) fn dispatch_inline_edit_submit(app: &mut AppView) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
app.show_toast("No active session");
|
||||
return vec![];
|
||||
};
|
||||
let Some(edit) = agent.inline_edit.as_ref() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Unchanged/empty edits have nothing to submit: just close the editor.
|
||||
let text = edit.textarea.text().trim().to_string();
|
||||
if text.is_empty() || text == edit.original.trim() {
|
||||
agent.exit_inline_edit();
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let target = edit.prompt_index;
|
||||
let anchor = agent
|
||||
.scrollback
|
||||
.index_of_id(edit.entry_id)
|
||||
.or_else(|| agent.scrollback.selected())
|
||||
.unwrap_or(0);
|
||||
let draft = stash_prompt(&mut agent.prompt);
|
||||
|
||||
if agent.session.state.is_busy() {
|
||||
// Mid-turn submit: the same cancel-offer `/rewind` raises, over the
|
||||
// still-open editor. Confirm cancels the turn and re-enters the
|
||||
// flow; dismiss returns to the editor.
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState::new_cancel_offer(
|
||||
anchor,
|
||||
draft,
|
||||
Some(target),
|
||||
));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Loading,
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: Some(target),
|
||||
});
|
||||
|
||||
vec![Effect::FetchRewindPoints {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
}]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_rewind_success(
|
||||
app: &mut AppView,
|
||||
agent_id: crate::app::agent::AgentId,
|
||||
response: crate::views::rewind::RewindResponse,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Inline-edit resubmit text; taken unconditionally so a failed rewind
|
||||
// drops it.
|
||||
let inline_resubmit = agent.pending_inline_resubmit.take();
|
||||
|
||||
if !response.success {
|
||||
let err = response.error.unwrap_or_else(|| "unknown error".into());
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Error { message: err },
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
// Note: the inline editor (if any) stays open — dismissing the
|
||||
// error returns to editing.
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// The rewind went through: the inline editor's job is done. Close it
|
||||
// before the truncation below removes its entry.
|
||||
if inline_resubmit.is_some() {
|
||||
agent.inline_edit = None;
|
||||
agent.scrollback.set_inline_edit_height(None);
|
||||
}
|
||||
|
||||
let mode_str = response.mode.as_deref().unwrap_or("all");
|
||||
let target = response.target_prompt_index;
|
||||
let is_files_only = mode_str == "files_only";
|
||||
|
||||
let stashed_draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
|
||||
if !is_files_only {
|
||||
let target_idx = find_user_prompt_entry_for_shell_index(&agent.scrollback, target);
|
||||
if let Some(anchor_idx) = target_idx {
|
||||
let removed = agent.scrollback.remove_from(anchor_idx);
|
||||
// Explicit drop BEFORE the purge: the rewound tail (entries +
|
||||
// their render caches — potentially most of a long transcript)
|
||||
// must be freed for the release below to return its pages.
|
||||
drop(removed);
|
||||
crate::memory_release::release_retained_memory_with("rewind-truncate");
|
||||
}
|
||||
}
|
||||
|
||||
// An inline resubmit skips the confirmation — the edited prompt
|
||||
// re-appearing at the same spot is self-explanatory. (Files-only keeps
|
||||
// it: nothing is resubmitted there, so the revert needs its signal.)
|
||||
if inline_resubmit.is_none() || is_files_only {
|
||||
let msg = match mode_str {
|
||||
"conversation_only" => "Reverted conversation",
|
||||
"files_only" => "Reverted file changes",
|
||||
_ => "Reverted conversation and file changes",
|
||||
};
|
||||
if app.screen_mode.is_minimal() {
|
||||
// Minimal has no toast surface and can't erase committed lines, so the confirmation stays in scrollback there.
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system(msg.to_string()));
|
||||
} else {
|
||||
agent.show_toast(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref text) = inline_resubmit
|
||||
&& is_files_only
|
||||
{
|
||||
// Files-only: no conversation rewind happened, so there is nothing
|
||||
// to resubmit from — surface the edited text in the composer
|
||||
// instead of silently dropping the edit.
|
||||
agent.prompt.set_text(text);
|
||||
} else if inline_resubmit.is_some() {
|
||||
// Restore the full draft before a non-consuming resubmit.
|
||||
if let Some(draft) = stashed_draft {
|
||||
agent.prompt.restore(draft);
|
||||
}
|
||||
} else if let Some(ref prompt_text) = response.prompt_text
|
||||
&& !is_files_only
|
||||
{
|
||||
agent.prompt.set_text(prompt_text);
|
||||
} else if let Some(draft) = stashed_draft {
|
||||
agent.prompt.restore(draft);
|
||||
}
|
||||
|
||||
if !is_files_only {
|
||||
agent.set_active_pane(crate::app::agent_view::ActivePane::Prompt, false);
|
||||
}
|
||||
|
||||
agent.rewind_points = None;
|
||||
agent.scrollback.goto_bottom();
|
||||
|
||||
if let Some(text) = inline_resubmit
|
||||
&& !is_files_only
|
||||
{
|
||||
if app.active_view == ActiveView::Agent(agent_id) {
|
||||
// Resubmit from the rewound point; `consume_input=false` keeps
|
||||
// the composer draft, `literal=true` sends slash-lookalike text
|
||||
// as a prompt (the transcript is already truncated — running it
|
||||
// as a command would swallow the resubmit).
|
||||
return super::prompt::dispatch_send_prompt_inner(
|
||||
app, text, /* consume_input */ false, /* literal */ true,
|
||||
/* is_follow_up */ false,
|
||||
);
|
||||
}
|
||||
// View switched mid-rewind: fall back to prefilling that composer,
|
||||
// appending so an existing draft isn't clobbered.
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
if agent.prompt.text().trim().is_empty() {
|
||||
agent.prompt.set_text(&text);
|
||||
} else {
|
||||
agent.prompt.append_text(&format!("\n{text}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vec![]
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_rewind_points_loaded(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
points: Vec<crate::views::rewind::RewindPointInfo>,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
agent.rewind_points = Some(points.clone());
|
||||
|
||||
let desired_target = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.and_then(|s| s.selected_prompt_index);
|
||||
let stashed = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
|
||||
if points.is_empty() {
|
||||
if let Some(stashed) = stashed {
|
||||
agent.prompt.restore(stashed);
|
||||
}
|
||||
app.show_toast("No undoable prompts");
|
||||
return vec![];
|
||||
}
|
||||
|
||||
if let Some(dt) = desired_target {
|
||||
let resolved = points
|
||||
.iter()
|
||||
.find(|p| p.prompt_index == dt)
|
||||
.or_else(|| points.iter().max_by_key(|p| p.prompt_index))
|
||||
.cloned();
|
||||
|
||||
if let Some(point) = resolved {
|
||||
let target = point.prompt_index;
|
||||
let has_file_changes = point.has_file_changes;
|
||||
let anchor = find_user_prompt_entry_for_shell_index(&agent.scrollback, target);
|
||||
let draft = stashed.or_else(|| stash_prompt(&mut agent.prompt));
|
||||
if let Some(entry_idx) = anchor {
|
||||
agent.scrollback.set_selected(Some(entry_idx));
|
||||
}
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState::new_mode_select(
|
||||
anchor.unwrap_or(0),
|
||||
target,
|
||||
has_file_changes,
|
||||
// Inline edit-and-resubmit: the conversation rewind is a
|
||||
// given — hide the "File changes only" row entirely.
|
||||
agent.inline_edit.is_none(),
|
||||
draft,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let mut sorted = points.clone();
|
||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.prompt_index));
|
||||
let draft = stashed.or_else(|| stash_prompt(&mut agent.prompt));
|
||||
let initial_anchor = sorted
|
||||
.first()
|
||||
.map(|p| {
|
||||
find_user_prompt_entry_for_shell_index(&agent.scrollback, p.prompt_index)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Picker {
|
||||
points: sorted,
|
||||
selected: 0,
|
||||
},
|
||||
anchor_entry_idx: initial_anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
agent.scrollback.scroll_to_entry_center(initial_anchor);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_rewind_preview_complete(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
response: crate::views::rewind::RewindResponse,
|
||||
target_prompt_index: usize,
|
||||
mode: crate::views::rewind::RewindMode,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
if response.error.is_some() && response.clean_files.is_empty() && response.conflicts.is_empty()
|
||||
{
|
||||
let err = response.error.unwrap_or_default();
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Error { message: err },
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
let conflicts: Vec<_> = response
|
||||
.conflicts
|
||||
.iter()
|
||||
.map(crate::views::rewind::ConflictDisplay::from_conflict)
|
||||
.collect();
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let preview = agent
|
||||
.rewind_points
|
||||
.as_ref()
|
||||
.and_then(|pts| pts.iter().find(|p| p.prompt_index == target_prompt_index))
|
||||
.and_then(|p| p.prompt_preview.clone());
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Confirm {
|
||||
target_prompt_index,
|
||||
mode,
|
||||
clean_files: response.clean_files,
|
||||
conflicts,
|
||||
active_idx: 0,
|
||||
prompt_preview: preview,
|
||||
},
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_rewind_preview_failed(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Error { message: error },
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_rewind_execute_failed(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
// A pending inline resubmit dies with its rewind; the editor itself
|
||||
// stays open so dismissing the error returns to editing.
|
||||
agent.pending_inline_resubmit = None;
|
||||
let anchor = agent
|
||||
.rewind_state
|
||||
.as_ref()
|
||||
.map(|s| s.anchor_entry_idx)
|
||||
.unwrap_or(0);
|
||||
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
|
||||
agent.rewind_state = Some(crate::views::rewind::RewindState {
|
||||
phase: crate::views::rewind::RewindPhase::Error { message: error },
|
||||
anchor_entry_idx: anchor,
|
||||
stashed_draft: draft,
|
||||
selected_prompt_index: None,
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
Reference in New Issue
Block a user