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:
commit
d6c20fc13f
2612 files changed
+1353757
No files matched your search
@@ -0,0 +1,410 @@
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::app_view::{AppView, SessionPickerEntry};
|
||||
use crate::app::dispatch::ctx::get_active_agent_mut;
|
||||
use crate::app::effects::ConversationsPartial;
|
||||
use crate::views::modal::ActiveModal;
|
||||
use crate::views::picker::PickerState;
|
||||
use crate::views::session_picker::{
|
||||
PickerSelectionAnchor, SessionPickerLanes, SessionPickerPendingNotice, SourceFilter,
|
||||
capture_picker_selection, effective_filter_query, repo_name_from_cwd, restore_picker_selection,
|
||||
};
|
||||
|
||||
type SearchHit = kigi_shell::extensions::session_search::SearchSessionHit;
|
||||
|
||||
struct PickerSurface<'a> {
|
||||
entries: &'a mut Option<Vec<SessionPickerEntry>>,
|
||||
loading: &'a mut bool,
|
||||
lanes: &'a mut SessionPickerLanes,
|
||||
state: &'a mut PickerState,
|
||||
content_results: &'a mut Option<Vec<SearchHit>>,
|
||||
content_loading: &'a mut bool,
|
||||
entries_query: &'a mut Option<String>,
|
||||
source_filter: SourceFilter,
|
||||
grouped: bool,
|
||||
current_repo: String,
|
||||
}
|
||||
|
||||
impl PickerSurface<'_> {
|
||||
fn capture_selection(&self) -> PickerSelectionAnchor {
|
||||
capture_picker_selection(
|
||||
self.entries.as_deref(),
|
||||
self.content_results.as_deref(),
|
||||
self.state,
|
||||
effective_filter_query(&self.state.query, self.entries_query.as_deref()),
|
||||
self.grouped,
|
||||
*self.content_loading,
|
||||
self.source_filter,
|
||||
Some(&self.current_repo),
|
||||
)
|
||||
}
|
||||
|
||||
fn restore_selection(&mut self, anchor: PickerSelectionAnchor) {
|
||||
let filter_query =
|
||||
effective_filter_query(&self.state.query, self.entries_query.as_deref()).to_owned();
|
||||
restore_picker_selection(
|
||||
anchor,
|
||||
self.entries.as_deref(),
|
||||
self.content_results.as_deref(),
|
||||
self.state,
|
||||
&filter_query,
|
||||
self.grouped,
|
||||
*self.content_loading,
|
||||
self.source_filter,
|
||||
Some(&self.current_repo),
|
||||
);
|
||||
self.state.expanded.clear();
|
||||
}
|
||||
|
||||
fn native_loaded(
|
||||
&mut self,
|
||||
sessions: Vec<SessionPickerEntry>,
|
||||
query: Option<String>,
|
||||
chat_mode: bool,
|
||||
empty_notice: String,
|
||||
partial_notice: Option<&'static str>,
|
||||
) -> Option<String> {
|
||||
let anchor = self.capture_selection();
|
||||
let is_search = query.is_some();
|
||||
*self.loading = false;
|
||||
if is_search {
|
||||
*self.content_loading = false;
|
||||
}
|
||||
*self.entries_query = query;
|
||||
if chat_mode {
|
||||
*self.entries = (!sessions.is_empty()).then_some(sessions);
|
||||
} else {
|
||||
crate::app::foreign_sessions::replace_native_entries(self.entries, sessions);
|
||||
}
|
||||
if is_search && self.entries.is_none() {
|
||||
*self.entries = Some(Vec::new());
|
||||
}
|
||||
let notice = if self.entries.is_none() && !is_search {
|
||||
if self.lanes.foreign_loading {
|
||||
self.lanes.pending_notice = Some(SessionPickerPendingNotice::Empty(empty_notice));
|
||||
None
|
||||
} else {
|
||||
self.lanes.pending_notice = None;
|
||||
Some(empty_notice)
|
||||
}
|
||||
} else {
|
||||
self.lanes.pending_notice = None;
|
||||
if chat_mode {
|
||||
partial_notice.map(str::to_owned)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
self.restore_selection(anchor);
|
||||
notice
|
||||
}
|
||||
|
||||
fn native_failed(
|
||||
&mut self,
|
||||
error_notice: String,
|
||||
is_search: bool,
|
||||
chat_mode: bool,
|
||||
) -> Option<String> {
|
||||
let anchor = self.capture_selection();
|
||||
*self.loading = false;
|
||||
if is_search {
|
||||
*self.content_loading = false;
|
||||
}
|
||||
if chat_mode {
|
||||
*self.entries = None;
|
||||
} else {
|
||||
crate::app::foreign_sessions::replace_native_entries(self.entries, Vec::new());
|
||||
}
|
||||
*self.entries_query = None;
|
||||
let notice = if self.lanes.foreign_loading {
|
||||
self.lanes.pending_notice = Some(SessionPickerPendingNotice::Error(error_notice));
|
||||
None
|
||||
} else {
|
||||
self.lanes.pending_notice = None;
|
||||
Some(error_notice)
|
||||
};
|
||||
self.restore_selection(anchor);
|
||||
notice
|
||||
}
|
||||
|
||||
fn foreign_loaded(&mut self, scanned: Vec<SessionPickerEntry>) -> Option<String> {
|
||||
let anchor = self.capture_selection();
|
||||
crate::app::foreign_sessions::replace_foreign_entries(self.entries, scanned);
|
||||
self.lanes.foreign_loading = false;
|
||||
let notice = self.lanes.take_ready_notice(self.entries.is_some());
|
||||
self.restore_selection(anchor);
|
||||
notice
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::app::dispatch) fn dispatch_fetch_session_list(app: &mut AppView) -> Vec<Effect> {
|
||||
app.session_picker_detail_generation += 1;
|
||||
app.session_picker_loading = true;
|
||||
app.session_picker_entries = None;
|
||||
app.session_picker_state.selected = 0;
|
||||
app.session_picker_state.query.clear();
|
||||
app.session_picker_state.query_cursor = 0;
|
||||
app.session_picker_state.search_active = false;
|
||||
app.session_picker_state.expanded.clear();
|
||||
app.session_picker_content_results = None;
|
||||
app.session_picker_content_loading = false;
|
||||
app.session_picker_entries_query = None;
|
||||
if app.chat_mode {
|
||||
app.session_picker_list_seq += 1;
|
||||
}
|
||||
app.foreign_session_scan_seq += 1;
|
||||
let foreign_seq = app.foreign_session_scan_seq;
|
||||
let mut effects = vec![Effect::FetchSessionList {
|
||||
query: None,
|
||||
seq: app.session_picker_list_seq,
|
||||
}];
|
||||
let foreign_effect = if app.chat_mode {
|
||||
app.foreign_scan_coordinator.begin_request(foreign_seq);
|
||||
None
|
||||
} else {
|
||||
let kigi_home = kigi_tools::util::kigi_home::kigi_home();
|
||||
crate::app::foreign_sessions::scan_effect(
|
||||
&app.cwd,
|
||||
app.foreign_session_compat,
|
||||
&kigi_home,
|
||||
app.foreign_scan_coordinator.clone(),
|
||||
foreign_seq,
|
||||
)
|
||||
};
|
||||
let foreign_loading = foreign_effect.is_some();
|
||||
let mut modal_lanes_set = false;
|
||||
if let Some(agent) = get_active_agent_mut(app)
|
||||
&& let Some(ActiveModal::SessionPicker { lanes, .. }) = agent.active_modal.as_mut()
|
||||
{
|
||||
lanes.foreign_loading = foreign_loading;
|
||||
lanes.pending_notice = None;
|
||||
modal_lanes_set = true;
|
||||
}
|
||||
app.session_picker_lanes.foreign_loading = foreign_loading && !modal_lanes_set;
|
||||
app.session_picker_lanes.pending_notice = None;
|
||||
effects.extend(foreign_effect);
|
||||
effects
|
||||
}
|
||||
|
||||
pub(in crate::app::dispatch) fn handle_session_list_loaded(
|
||||
app: &mut AppView,
|
||||
sessions: Vec<SessionPickerEntry>,
|
||||
partial: Option<ConversationsPartial>,
|
||||
seq: u64,
|
||||
query: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
if seq != app.session_picker_list_seq {
|
||||
return vec![];
|
||||
}
|
||||
app.session_picker_detail_generation += 1;
|
||||
if let Some(partial) = partial {
|
||||
crate::unified_log::warn(
|
||||
"session.list.partial",
|
||||
None,
|
||||
Some(serde_json::json!({ "reason": format!("{partial:?}") })),
|
||||
);
|
||||
}
|
||||
let empty_notice = partial.map_or_else(
|
||||
|| "No sessions found for this directory".to_owned(),
|
||||
|partial| partial.picker_notice().to_owned(),
|
||||
);
|
||||
let partial_notice = partial.map(ConversationsPartial::picker_notice);
|
||||
let chat_mode = app.chat_mode;
|
||||
let mut sessions = Some(sessions);
|
||||
let mut notice = None;
|
||||
if let Some(agent) = get_active_agent_mut(app) {
|
||||
let current_repo = repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
|
||||
if let Some(ActiveModal::SessionPicker {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter,
|
||||
..
|
||||
}) = agent.active_modal.as_mut()
|
||||
{
|
||||
notice = PickerSurface {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter: *source_filter,
|
||||
grouped: true,
|
||||
current_repo,
|
||||
}
|
||||
.native_loaded(
|
||||
sessions.take().unwrap_or_default(),
|
||||
query.clone(),
|
||||
chat_mode,
|
||||
empty_notice.clone(),
|
||||
partial_notice,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(sessions) = sessions {
|
||||
let current_repo = repo_name_from_cwd(&app.cwd.to_string_lossy());
|
||||
notice = PickerSurface {
|
||||
entries: &mut app.session_picker_entries,
|
||||
loading: &mut app.session_picker_loading,
|
||||
lanes: &mut app.session_picker_lanes,
|
||||
state: &mut app.session_picker_state,
|
||||
content_results: &mut app.session_picker_content_results,
|
||||
content_loading: &mut app.session_picker_content_loading,
|
||||
entries_query: &mut app.session_picker_entries_query,
|
||||
source_filter: app.session_picker_source_filter,
|
||||
grouped: app.session_picker_grouped,
|
||||
current_repo,
|
||||
}
|
||||
.native_loaded(sessions, query, chat_mode, empty_notice, partial_notice);
|
||||
}
|
||||
if let Some(notice) = notice {
|
||||
app.show_toast(¬ice);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(in crate::app::dispatch) fn handle_session_list_failed(
|
||||
app: &mut AppView,
|
||||
error: String,
|
||||
seq: u64,
|
||||
query: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
if seq != app.session_picker_list_seq {
|
||||
return vec![];
|
||||
}
|
||||
app.session_picker_detail_generation += 1;
|
||||
tracing::warn!(error = %error, "session list fetch failed");
|
||||
let error_notice = format!("Couldn't load sessions: {error}");
|
||||
let is_search = query.is_some();
|
||||
let chat_mode = app.chat_mode;
|
||||
let mut handled = false;
|
||||
let mut notice = None;
|
||||
if let Some(agent) = get_active_agent_mut(app) {
|
||||
let current_repo = repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
|
||||
if let Some(ActiveModal::SessionPicker {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter,
|
||||
..
|
||||
}) = agent.active_modal.as_mut()
|
||||
{
|
||||
notice = PickerSurface {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter: *source_filter,
|
||||
grouped: true,
|
||||
current_repo,
|
||||
}
|
||||
.native_failed(error_notice.clone(), is_search, chat_mode);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
if !handled {
|
||||
let current_repo = repo_name_from_cwd(&app.cwd.to_string_lossy());
|
||||
notice = PickerSurface {
|
||||
entries: &mut app.session_picker_entries,
|
||||
loading: &mut app.session_picker_loading,
|
||||
lanes: &mut app.session_picker_lanes,
|
||||
state: &mut app.session_picker_state,
|
||||
content_results: &mut app.session_picker_content_results,
|
||||
content_loading: &mut app.session_picker_content_loading,
|
||||
entries_query: &mut app.session_picker_entries_query,
|
||||
source_filter: app.session_picker_source_filter,
|
||||
grouped: app.session_picker_grouped,
|
||||
current_repo,
|
||||
}
|
||||
.native_failed(error_notice, is_search, chat_mode);
|
||||
}
|
||||
if let Some(notice) = notice {
|
||||
app.show_toast(¬ice);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(in crate::app::dispatch) fn handle_foreign_sessions_scanned(
|
||||
app: &mut AppView,
|
||||
scanned: Vec<SessionPickerEntry>,
|
||||
seq: u64,
|
||||
) -> Vec<Effect> {
|
||||
if app.chat_mode || seq != app.foreign_session_scan_seq {
|
||||
return vec![];
|
||||
}
|
||||
app.session_picker_detail_generation += 1;
|
||||
let mut scanned = Some(scanned);
|
||||
let mut notice = None;
|
||||
let mut handled = false;
|
||||
if let Some(agent) = get_active_agent_mut(app) {
|
||||
let current_repo = repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
|
||||
if let Some(ActiveModal::SessionPicker {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter,
|
||||
..
|
||||
}) = agent.active_modal.as_mut()
|
||||
&& lanes.foreign_loading
|
||||
{
|
||||
handled = true;
|
||||
notice = PickerSurface {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
state,
|
||||
content_results,
|
||||
content_loading,
|
||||
entries_query,
|
||||
source_filter: *source_filter,
|
||||
grouped: true,
|
||||
current_repo,
|
||||
}
|
||||
.foreign_loaded(scanned.take().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
if !handled && app.session_picker_lanes.foreign_loading {
|
||||
let current_repo = repo_name_from_cwd(&app.cwd.to_string_lossy());
|
||||
notice = PickerSurface {
|
||||
entries: &mut app.session_picker_entries,
|
||||
loading: &mut app.session_picker_loading,
|
||||
lanes: &mut app.session_picker_lanes,
|
||||
state: &mut app.session_picker_state,
|
||||
content_results: &mut app.session_picker_content_results,
|
||||
content_loading: &mut app.session_picker_content_loading,
|
||||
entries_query: &mut app.session_picker_entries_query,
|
||||
source_filter: app.session_picker_source_filter,
|
||||
grouped: app.session_picker_grouped,
|
||||
current_repo,
|
||||
}
|
||||
.foreign_loaded(scanned.unwrap_or_default());
|
||||
}
|
||||
if let Some(notice) = notice {
|
||||
app.show_toast(¬ice);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(in crate::app::dispatch) fn invalidate_foreign_picker(app: &mut AppView) {
|
||||
app.foreign_session_scan_seq += 1;
|
||||
app.foreign_scan_coordinator
|
||||
.begin_request(app.foreign_session_scan_seq);
|
||||
app.session_picker_lanes = Default::default();
|
||||
app.session_picker_detail_generation += 1;
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
//! Fork and project-selection dispatchers and fork placeholder builders.
|
||||
use super::lifecycle::{dispatch_new_session_inner_with_id, refuse_chat_mode_build_agent};
|
||||
use crate::acp::tracker::AcpUpdateTracker;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::{AgentCommand, AgentId, AgentSession, AgentState};
|
||||
use crate::app::agent_view::{AgentView, McpInitProgress};
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::app::dispatch::ctx::{SwitchCause, switch_to_agent};
|
||||
use crate::app::dispatch::modes::inherit_auto_mode;
|
||||
use crate::app::dispatch::prompt::{
|
||||
consume_chat_kind, dispatch_send_prompt, supersede_open_reload_window,
|
||||
};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
use agent_client_protocol as acp;
|
||||
use std::time::Instant;
|
||||
/// Top-level `/fork` dispatcher. Resolves the worktree decision: an
|
||||
/// explicit `--worktree` / `--no-worktree` flag short-circuits to
|
||||
/// [`dispatch_fork_resolved`]. When no flag is given and a persisted
|
||||
/// `fork_worktree_mode` preference is set (`Always` / `Never`), the
|
||||
/// popup is skipped and the corresponding path is taken directly. The
|
||||
/// `Ask` default opens the [`open_fork_question`] modal so the user is
|
||||
/// asked.
|
||||
///
|
||||
/// When the parent session's working directory is **not** inside a git
|
||||
/// repository (indicated by the absence of a `git_head_changed`
|
||||
/// notification — `current_branch` is `None`):
|
||||
/// - `--worktree` is rejected with a toast (nothing to create a worktree from).
|
||||
/// - No flag (regardless of `fork_worktree_mode`): the worktree question
|
||||
/// is skipped and the fork proceeds with `worktree = false`.
|
||||
///
|
||||
/// Note: if the notification has not arrived yet (rare — user forks
|
||||
/// before the shell sends `git_head_changed`), the fallback to
|
||||
/// `worktree = false` is safe and the worktree can be created manually
|
||||
/// afterwards.
|
||||
///
|
||||
/// Two failure surfaces:
|
||||
/// - Active view is not an agent: toast and return.
|
||||
/// - Active agent has no `session_id` (still being created): toast and
|
||||
/// return. Both rejections are deliberate -- queueing the fork until
|
||||
/// `SessionLoaded` would require persisting `ForkArgs` across the
|
||||
/// `TaskResult` and is deferred to v2.
|
||||
pub(in crate::app::dispatch) fn dispatch_fork(
|
||||
app: &mut AppView,
|
||||
args: crate::slash::commands::fork::ForkArgs,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(parent_id) = app.active_view else {
|
||||
app.show_toast("/fork only works inside a session");
|
||||
return vec![];
|
||||
};
|
||||
let (has_session, in_git_repo) = app
|
||||
.agents
|
||||
.get(&parent_id)
|
||||
.map(|a| (a.session.session_id.is_some(), a.current_branch.is_some()))
|
||||
.unwrap_or((false, false));
|
||||
if !has_session {
|
||||
app.show_toast("Cannot fork: session is still being created");
|
||||
return vec![];
|
||||
}
|
||||
match args.worktree_override {
|
||||
Some(true) if !in_git_repo => {
|
||||
app.show_toast("Cannot create worktree: not in a git repository");
|
||||
vec![]
|
||||
}
|
||||
Some(worktree) => dispatch_fork_resolved(app, worktree, args.directive),
|
||||
None => {
|
||||
if in_git_repo {
|
||||
use crate::app::app_view::WorktreeMode;
|
||||
match app.fork_worktree_mode {
|
||||
WorktreeMode::Always => dispatch_fork_resolved(app, true, args.directive),
|
||||
WorktreeMode::Never => dispatch_fork_resolved(app, false, args.directive),
|
||||
WorktreeMode::Ask => open_fork_question(app, args.directive),
|
||||
}
|
||||
} else {
|
||||
dispatch_fork_resolved(app, false, args.directive)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// If `persist_mode` is `Some`, write `mode` into `*field` and append
|
||||
/// a [`Effect::PersistWorktreeMode`] to `effects` with the given
|
||||
/// `config_key`.
|
||||
pub(in crate::app::dispatch) fn apply_persist_worktree_mode(
|
||||
field: &mut crate::app::app_view::WorktreeMode,
|
||||
effects: &mut Vec<Effect>,
|
||||
persist_mode: Option<crate::app::app_view::WorktreeMode>,
|
||||
config_key: &'static str,
|
||||
) {
|
||||
if let Some(mode) = persist_mode {
|
||||
*field = mode;
|
||||
effects.push(Effect::PersistWorktreeMode { mode, config_key });
|
||||
}
|
||||
}
|
||||
/// Build the two persistence options shared by the fork and new-session
|
||||
/// worktree question modals ("Always worktree" / "Never worktree").
|
||||
pub(super) fn worktree_persist_options()
|
||||
-> [kigi_tools::implementations::grok_build::ask_user_question::QuestionOption; 2] {
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::QuestionOption;
|
||||
[
|
||||
QuestionOption {
|
||||
label: "Always worktree".into(),
|
||||
description: "Use worktree and stop asking (reset in config.toml)".into(),
|
||||
preview: None,
|
||||
id: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Never worktree".into(),
|
||||
description: "Skip worktree and stop asking (reset in config.toml)".into(),
|
||||
preview: None,
|
||||
id: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
/// Open the local worktree question modal on the active agent. Refuses
|
||||
/// if a question (ACP or local) is already on screen, surfacing a toast
|
||||
/// instead -- the modal-collision protocol.
|
||||
fn open_fork_question(app: &mut AppView, directive: Option<String>) -> Vec<Effect> {
|
||||
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
if agent.question_view.is_some() {
|
||||
app.show_toast("Finish answering the current question first");
|
||||
return vec![];
|
||||
}
|
||||
let mut options = vec![
|
||||
QuestionOption {
|
||||
label: "Yes".into(),
|
||||
description: "Fork in a new isolated git worktree".into(),
|
||||
preview: None,
|
||||
id: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "No".into(),
|
||||
description: "Fork in the current cwd".into(),
|
||||
preview: None,
|
||||
id: None,
|
||||
},
|
||||
];
|
||||
options.extend(worktree_persist_options());
|
||||
let question = Question {
|
||||
question: "Run this fork in an isolated git worktree?".into(),
|
||||
id: None,
|
||||
options,
|
||||
multi_select: Some(false),
|
||||
};
|
||||
let agent = app.agents.get_mut(&id).expect("agent present (re-borrow)");
|
||||
let stashed = agent.prompt.stash();
|
||||
let state = QuestionViewState::new(
|
||||
format!("fork-{}", uuid::Uuid::new_v4()),
|
||||
vec![question],
|
||||
stashed,
|
||||
)
|
||||
.with_local_kind(LocalQuestionKind::Fork { directive });
|
||||
agent.question_view = Some(state);
|
||||
agent.prompt.set_text("");
|
||||
vec![]
|
||||
}
|
||||
/// Construct the placeholder agent, push discoverability markers, flip
|
||||
/// the discovery gate, switch to the new agent, and emit the appropriate
|
||||
/// fork effect (worktree or no-worktree path).
|
||||
///
|
||||
/// `worktree == true` reuses the existing
|
||||
/// [`Effect::CreateWorktreeSession`] pipeline (with `load_session_id`
|
||||
/// set to the parent session id). `worktree == false` emits the new
|
||||
/// [`Effect::ForkSession`] which calls `x.ai/session/fork` directly.
|
||||
pub(in crate::app::dispatch) fn dispatch_fork_resolved(
|
||||
app: &mut AppView,
|
||||
worktree: bool,
|
||||
directive: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
let ActiveView::Agent(parent_id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(parent) = app.agents.get(&parent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(parent_session_id) = parent.session.session_id.clone() else {
|
||||
app.show_toast("Cannot fork: session not yet created");
|
||||
return vec![];
|
||||
};
|
||||
let parent_cwd = parent.session.cwd.clone();
|
||||
let parent_is_worktree = parent.session.is_worktree;
|
||||
let new_id = AgentId(app.next_agent_id);
|
||||
app.next_agent_id += 1;
|
||||
let new_agent = build_fork_placeholder(app, new_id, parent_id, &parent_cwd, worktree);
|
||||
let parent_marker = match directive.as_deref() {
|
||||
Some(d) => format!("Forked: {d}"),
|
||||
None => "Forked".to_string(),
|
||||
};
|
||||
let parent_chat_kind = parent.chat_kind || app.chat_mode;
|
||||
app.agents.insert(new_id, new_agent);
|
||||
{
|
||||
let agent = app
|
||||
.agents
|
||||
.get_mut(&new_id)
|
||||
.expect("just-inserted agent missing");
|
||||
agent.prompt.set_compact(app.appearance.prompt.compact);
|
||||
agent.prompt.adopt_slash_mru(app.slash_mru.clone());
|
||||
agent
|
||||
.prompt
|
||||
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
|
||||
agent.set_session_recap_available(app.session_recap_available);
|
||||
agent.apply_app_scoped_gates(
|
||||
app.sharing_enabled,
|
||||
app.usage_visible,
|
||||
app.chat_mode,
|
||||
app.screen_mode,
|
||||
&app.tier_restricted_commands,
|
||||
);
|
||||
agent.chat_kind = parent_chat_kind;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
agent
|
||||
.prompt
|
||||
.slash_controller
|
||||
.registry_mut()
|
||||
.set_plugins_visible(!app.appearance.disable_plugins);
|
||||
agent.pending_fork_banner = Some(crate::app::agent_view::PendingForkBanner {
|
||||
parent_sid: parent_session_id.0.to_string(),
|
||||
worktree,
|
||||
});
|
||||
if worktree {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system("Creating worktree\u{2026}".to_string()));
|
||||
}
|
||||
agent.pending_first_prompt = directive;
|
||||
}
|
||||
if let Some(parent_mut) = app.agents.get_mut(&parent_id) {
|
||||
parent_mut
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system(parent_marker));
|
||||
}
|
||||
switch_to_agent(app, new_id, SwitchCause::Fork);
|
||||
if worktree {
|
||||
vec![Effect::CreateWorktreeSession {
|
||||
agent_id: new_id,
|
||||
load_session_id: Some(parent_session_id.0.to_string()),
|
||||
label: None,
|
||||
git_ref: None,
|
||||
model_id: None,
|
||||
preferred_session_id: None,
|
||||
chat_kind: parent_chat_kind,
|
||||
}]
|
||||
} else {
|
||||
vec![Effect::ForkSession {
|
||||
agent_id: new_id,
|
||||
parent_session_id,
|
||||
parent_cwd,
|
||||
parent_is_worktree,
|
||||
new_session_id: None,
|
||||
}]
|
||||
}
|
||||
}
|
||||
pub(in crate::app::dispatch) fn open_project_question(
|
||||
app: &mut AppView,
|
||||
prompt_text: String,
|
||||
) -> Vec<Effect> {
|
||||
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
if agent.question_view.is_some() {
|
||||
return vec![];
|
||||
}
|
||||
let recent_dirs = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current()
|
||||
.block_on(crate::project_picker::sources::collect_recent_dirs(10))
|
||||
});
|
||||
let pq = crate::project_picker::build_project_question(&recent_dirs, &app.cwd);
|
||||
if pq.resolved_paths.len() <= 1 {
|
||||
return dispatch_project_selected(app, app.cwd.clone(), prompt_text, false);
|
||||
}
|
||||
let stashed = agent.prompt.stash();
|
||||
let state = QuestionViewState::new(
|
||||
format!("project-select-{}", uuid::Uuid::new_v4()),
|
||||
vec![pq.question],
|
||||
stashed,
|
||||
)
|
||||
.with_local_kind(LocalQuestionKind::ProjectSelect {
|
||||
resolved_paths: pq.resolved_paths,
|
||||
original_cwd: app.cwd.clone(),
|
||||
stashed_prompt: prompt_text,
|
||||
dont_ask_index: pq.dont_ask_index,
|
||||
});
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
agent.question_view = Some(state);
|
||||
agent.prompt.set_text("");
|
||||
crate::unified_log::info("project_picker.opened", None, None);
|
||||
vec![]
|
||||
}
|
||||
pub(in crate::app::dispatch) fn dispatch_project_selected(
|
||||
app: &mut AppView,
|
||||
path: std::path::PathBuf,
|
||||
stashed_prompt: String,
|
||||
disable_picker: bool,
|
||||
) -> Vec<Effect> {
|
||||
crate::unified_log::info(
|
||||
"project_picker.selected",
|
||||
None,
|
||||
Some(serde_json::json!(
|
||||
{ "path" : path.display().to_string(), "prompt_len" : stashed_prompt
|
||||
.len(), "disable_picker" : disable_picker }
|
||||
)),
|
||||
);
|
||||
app.mark_project_picker_done();
|
||||
let mut effects = Vec::new();
|
||||
if disable_picker {
|
||||
app.project_picker_disabled = true;
|
||||
app.show_toast("Won't ask about project directory again (reset in config.toml)");
|
||||
effects.push(Effect::PersistProjectPickerDisabled { disabled: true });
|
||||
}
|
||||
let path = if path.is_dir() {
|
||||
path
|
||||
} else {
|
||||
app.show_toast("Directory not found, continuing in current directory");
|
||||
app.cwd.clone()
|
||||
};
|
||||
app.cwd = path.clone();
|
||||
crate::git_info::populate_from_cwd_async(path.clone());
|
||||
effects.push(Effect::SetWorkingDir { path: path.clone() });
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
effects.extend(dispatch_send_prompt(app, stashed_prompt));
|
||||
return effects;
|
||||
};
|
||||
if let Some(agent) = app.agents.get_mut(&id) {
|
||||
let changed = agent.session.cwd != path;
|
||||
agent.session.cwd = path.clone();
|
||||
if changed {
|
||||
let display = crate::project_picker::sources::display_path(&path);
|
||||
agent.show_toast(&format!("Updated working directory to {display}"));
|
||||
}
|
||||
}
|
||||
if let Some(agent) = app.agents.get_mut(&id) {
|
||||
agent.mcp_init_progress = Some(McpInitProgress {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
agent.session.prompt_history_loading = true;
|
||||
}
|
||||
let preferred_session_id = app.deferred_startup.preferred_session_id.take();
|
||||
let chat_kind = consume_chat_kind(app);
|
||||
if let Some(agent) = app.agents.get_mut(&id) {
|
||||
agent.chat_kind = chat_kind;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
}
|
||||
effects.push(Effect::CreateSession {
|
||||
agent_id: id,
|
||||
cwd: path,
|
||||
model_id: None,
|
||||
preferred_session_id,
|
||||
chat_kind,
|
||||
});
|
||||
effects.extend(dispatch_send_prompt(app, stashed_prompt));
|
||||
effects
|
||||
}
|
||||
/// Build the placeholder [`AgentView`] for a fork. Centralises the
|
||||
/// `AgentSession`/spinner construction shared by both worktree and
|
||||
/// no-worktree branches so the parallel struct literal does not drift.
|
||||
fn build_fork_placeholder(
|
||||
app: &AppView,
|
||||
new_id: AgentId,
|
||||
parent_id: AgentId,
|
||||
parent_cwd: &std::path::Path,
|
||||
worktree: bool,
|
||||
) -> AgentView {
|
||||
let mut scrollback = ScrollbackState::new();
|
||||
scrollback.set_appearance(app.appearance.clone());
|
||||
let mut agent = AgentView::new(
|
||||
AgentSession {
|
||||
id: new_id,
|
||||
acp_tx: app.acp_tx.clone(),
|
||||
session_id: None,
|
||||
models: app.models.clone(),
|
||||
state: AgentState::Idle,
|
||||
tracker: AcpUpdateTracker::new(),
|
||||
cwd: parent_cwd.to_path_buf(),
|
||||
is_worktree: false,
|
||||
forked_from: Some(parent_id),
|
||||
pending_prompts: std::collections::VecDeque::new(),
|
||||
next_queue_id: 0,
|
||||
yolo_mode: app.default_yolo,
|
||||
auto_mode: inherit_auto_mode(app),
|
||||
prompt_history: Vec::new(),
|
||||
prompt_history_loading: false,
|
||||
loading_replay: false,
|
||||
restore_degree: None,
|
||||
rate_limited: false,
|
||||
model_incompatible: false,
|
||||
credit_limit_blocked: false,
|
||||
free_usage_blocked: false,
|
||||
available_commands: app.bootstrap_acp_commands.clone(),
|
||||
available_commands_generation: 1,
|
||||
available_tools: None,
|
||||
model_switch_pending: false,
|
||||
user_model_preference: None,
|
||||
deferred_model_switch: app.deferred_model_switch_from_cli(),
|
||||
bg_tasks: std::collections::BTreeMap::new(),
|
||||
bg_tool_call_to_task: std::collections::HashMap::new(),
|
||||
scheduled_tasks: std::collections::HashMap::new(),
|
||||
in_flight_prompt: None,
|
||||
current_prompt_id: None,
|
||||
created_via_new: false,
|
||||
},
|
||||
scrollback,
|
||||
);
|
||||
let cmd = if worktree {
|
||||
AgentCommand::CreateWorktree
|
||||
} else {
|
||||
AgentCommand::ForkSession
|
||||
};
|
||||
agent.session.start_command(cmd);
|
||||
agent.turn_started_at = Some(Instant::now());
|
||||
agent
|
||||
}
|
||||
/// Build the discoverability banner for the child agent. Includes the
|
||||
/// child's session id, the full parent session id, and — when
|
||||
/// `switch_hint` names a command (the caller's
|
||||
/// [`crate::views::dashboard::session_switch_hint_command`]: `/dashboard`
|
||||
/// normally, `/resume` in minimal mode where the dashboard is refused) —
|
||||
/// a session-switch tip so the user knows how to switch back. No-worktree
|
||||
/// case appends the dim continuation `(both agents share cwd)`.
|
||||
///
|
||||
/// Called in `TaskResult::SessionLoaded` (not at dispatch time) because
|
||||
/// the child's session id is not known until the backend responds.
|
||||
pub(in crate::app::dispatch) fn build_child_fork_marker(
|
||||
session_id: &str,
|
||||
parent_sid: &str,
|
||||
worktree: bool,
|
||||
switch_hint: Option<&str>,
|
||||
) -> String {
|
||||
let header = if let Some(cmd) = switch_hint {
|
||||
format!(
|
||||
"Session {session_id} (forked from {parent_sid}) \u{2014} use {cmd} to switch between sessions",
|
||||
)
|
||||
} else {
|
||||
format!("Session {session_id} (forked from {parent_sid})")
|
||||
};
|
||||
if worktree {
|
||||
header
|
||||
} else {
|
||||
format!("{header}\n (both agents share cwd)")
|
||||
}
|
||||
}
|
||||
pub(in crate::app::dispatch) fn dispatch_startup_fork_session(
|
||||
app: &mut AppView,
|
||||
parent_session_id: String,
|
||||
parent_cwd: Option<std::path::PathBuf>,
|
||||
new_session_id: Option<String>,
|
||||
) -> Vec<Effect> {
|
||||
if !app.session_startup_allowed() {
|
||||
app.deferred_startup.session =
|
||||
Some(crate::app::session_startup::DeferredSessionStartup::Fork {
|
||||
parent_session_id,
|
||||
parent_cwd,
|
||||
new_session_id,
|
||||
});
|
||||
return vec![];
|
||||
}
|
||||
let (_agent_id, mut effects) = dispatch_new_session_inner_with_id(app, None);
|
||||
let agent_id = app
|
||||
.agents
|
||||
.keys()
|
||||
.next_back()
|
||||
.copied()
|
||||
.expect("fork placeholder agent");
|
||||
effects.retain(|e| !matches!(e, Effect::CreateSession { .. }));
|
||||
let cwd = parent_cwd.unwrap_or_else(|| app.cwd.clone());
|
||||
let parent_is_worktree =
|
||||
crate::app::session_startup::parent_session_is_worktree(&parent_session_id, &cwd);
|
||||
effects.push(Effect::ForkSession {
|
||||
agent_id,
|
||||
parent_session_id: acp::SessionId::new(parent_session_id),
|
||||
parent_cwd: cwd,
|
||||
parent_is_worktree,
|
||||
new_session_id,
|
||||
});
|
||||
effects
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(in crate::app::dispatch) fn handle_worktree_forked(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
session_id: acp::SessionId,
|
||||
worktree_path: std::path::PathBuf,
|
||||
session_cwd: std::path::PathBuf,
|
||||
code_restored: bool,
|
||||
restore_summary: Option<String>,
|
||||
restore_degree: Option<kigi_workspace::session::git::RestoreDegree>,
|
||||
) -> Vec<Effect> {
|
||||
let session_id_str = session_id.0.to_string();
|
||||
let pending_entry = std::mem::take(&mut app.deferred_startup.pending_chat);
|
||||
let agent_entry = app.agents.get(&agent_id).is_some_and(|a| a.chat_kind);
|
||||
let conversation_entry = pending_entry || agent_entry;
|
||||
if crate::app::session_startup::chat_mode_refuses_local_build_load(
|
||||
app.chat_mode,
|
||||
conversation_entry,
|
||||
&session_id_str,
|
||||
&app.cwd,
|
||||
) {
|
||||
refuse_chat_mode_build_agent(app, agent_id);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
supersede_open_reload_window(agent, agent_id, "WorktreeForked");
|
||||
agent.session.finish_command();
|
||||
agent.mark_turn_finished();
|
||||
agent.bind_session_id(session_id);
|
||||
agent.scrollback.begin_batch();
|
||||
agent.begin_replay_window();
|
||||
agent.session.restore_degree = restore_degree;
|
||||
agent.session.cwd = session_cwd.clone();
|
||||
agent.session.is_worktree = true;
|
||||
app.restore_code = None;
|
||||
agent.prompt.file_search.retarget(&session_cwd);
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"Worktree ready: {}",
|
||||
worktree_path.display()
|
||||
)));
|
||||
match (code_restored, restore_summary.as_deref()) {
|
||||
(true, Some(s)) => {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system(format!("\u{2713} Code restored: {s}")));
|
||||
}
|
||||
(false, Some(s)) => {
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"\u{26A0} Code restore failed: {s}"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let effective_chat = conversation_entry || app.chat_mode;
|
||||
agent.chat_kind = effective_chat;
|
||||
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
|
||||
return vec![Effect::LoadSession {
|
||||
agent_id,
|
||||
session_id: session_id_str,
|
||||
session_cwd: Some(session_cwd),
|
||||
chat_kind: conversation_entry,
|
||||
}];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
pub(in crate::app::dispatch) fn handle_fork_session_ready(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
new_session_id: acp::SessionId,
|
||||
cwd: std::path::PathBuf,
|
||||
) -> Vec<Effect> {
|
||||
let session_id_str = new_session_id.0.to_string();
|
||||
let pending_entry = std::mem::take(&mut app.deferred_startup.pending_chat);
|
||||
let agent_entry = app.agents.get(&agent_id).is_some_and(|a| a.chat_kind);
|
||||
let conversation_entry = pending_entry || agent_entry;
|
||||
if crate::app::session_startup::chat_mode_refuses_local_build_load(
|
||||
app.chat_mode,
|
||||
conversation_entry,
|
||||
&session_id_str,
|
||||
&app.cwd,
|
||||
) {
|
||||
refuse_chat_mode_build_agent(app, agent_id);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
supersede_open_reload_window(agent, agent_id, "ForkSessionReady");
|
||||
agent.session.finish_command();
|
||||
agent.mark_turn_finished();
|
||||
agent.bind_session_id(new_session_id);
|
||||
agent.scrollback.begin_batch();
|
||||
agent.begin_replay_window();
|
||||
agent.session.cwd = cwd.clone();
|
||||
let effective_chat = conversation_entry || app.chat_mode;
|
||||
agent.chat_kind = effective_chat;
|
||||
return vec![Effect::LoadSession {
|
||||
agent_id,
|
||||
session_id: session_id_str,
|
||||
session_cwd: Some(cwd),
|
||||
chat_kind: conversation_entry,
|
||||
}];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
pub(in crate::app::dispatch) fn handle_fork_session_failed(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
) -> Vec<Effect> {
|
||||
tracing::error!(agent = ? agent_id, error = % error, "Fork session failed");
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
agent.pending_extensions_fetch = false;
|
||||
agent.session.finish_command();
|
||||
let elapsed = agent.turn_elapsed();
|
||||
agent.mark_turn_finished();
|
||||
agent.pending_first_prompt = None;
|
||||
agent.pending_fork_banner = None;
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::session_event(SessionEvent::TurnFailed {
|
||||
error,
|
||||
elapsed,
|
||||
}));
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
||||
//! Session lifecycle, loading, picking, modal, and fork dispatchers.
|
||||
|
||||
pub(in crate::app::dispatch) mod foreign;
|
||||
pub(in crate::app::dispatch) mod fork;
|
||||
pub(in crate::app::dispatch) mod lifecycle;
|
||||
pub(in crate::app::dispatch) mod load;
|
||||
pub(in crate::app::dispatch) mod modal;
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Session rename / close helpers (shared with the dashboard).
|
||||
//!
|
||||
//! The `/sessions` picker modal was removed; rename-via-slash and
|
||||
//! dashboard close still use these dispatchers.
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::app::dispatch::ctx::{SwitchCause, show_welcome, switch_to_agent};
|
||||
use crate::app::dispatch::task_result::unregister_session_effect;
|
||||
/// Remove an agent and clean up all references to it:
|
||||
/// `forked_from` pointers on surviving agents.
|
||||
pub(in crate::app::dispatch) fn remove_agent_and_cleanup(app: &mut AppView, agent_id: AgentId) {
|
||||
let removed = app.agents.shift_remove(&agent_id);
|
||||
for agent in app.agents.values_mut() {
|
||||
if agent.session.forked_from == Some(agent_id) {
|
||||
agent.session.forked_from = None;
|
||||
}
|
||||
}
|
||||
if removed.is_some() {
|
||||
drop(removed);
|
||||
crate::memory_release::release_retained_memory_with("agent-close");
|
||||
}
|
||||
}
|
||||
/// Close (drop from this pager's in-memory list) the given agent.
|
||||
///
|
||||
/// Order matters:
|
||||
/// 1. Refuse to close the only alive agent (toast "Cannot close the
|
||||
/// only session -- use /home to exit"). The user has nothing to
|
||||
/// fall back to inside the agent shell.
|
||||
/// 2. If the closed agent is currently active, switch first to a
|
||||
/// surviving peer (parent via `forked_from` if alive, else the
|
||||
/// first surviving entry) using `SwitchCause::Picker`. If no peer
|
||||
/// survives, fall back to Welcome (already covered by case 1 --
|
||||
/// this is a defensive belt).
|
||||
/// 3. Drop the agent from `app.agents` (`shift_remove` to preserve
|
||||
/// insertion order on every other entry) and clear `forked_from`
|
||||
/// references on surviving agents so dangling parent pointers
|
||||
/// cannot resurface.
|
||||
pub(in crate::app::dispatch) fn dispatch_sessions_confirm_close(
|
||||
app: &mut AppView,
|
||||
closed_id: AgentId,
|
||||
) -> Vec<Effect> {
|
||||
if !app.agents.contains_key(&closed_id) {
|
||||
return vec![];
|
||||
}
|
||||
if app.agents.len() == 1 {
|
||||
app.show_toast("Cannot close the only session -- use /home to exit");
|
||||
return vec![];
|
||||
}
|
||||
if matches!(app.active_view, ActiveView::Agent(id) if id == closed_id) {
|
||||
let parent = app
|
||||
.agents
|
||||
.get(&closed_id)
|
||||
.and_then(|a| a.session.forked_from)
|
||||
.filter(|p| app.agents.contains_key(p));
|
||||
let fallback = parent.or_else(|| app.agents.keys().copied().find(|id| *id != closed_id));
|
||||
if let Some(target) = fallback {
|
||||
switch_to_agent(app, target, SwitchCause::Picker);
|
||||
} else {
|
||||
show_welcome(app);
|
||||
}
|
||||
}
|
||||
let effects = unregister_session_effect(
|
||||
app.agents
|
||||
.get(&closed_id)
|
||||
.and_then(|a| a.session.session_id.clone()),
|
||||
);
|
||||
remove_agent_and_cleanup(app, closed_id);
|
||||
effects
|
||||
}
|
||||
/// Rename the current session via x.ai/session/rename.
|
||||
///
|
||||
/// Produces Effect::RenameSession which spawns an async ACP ext request.
|
||||
/// On completion, TaskResult::RenameSessionComplete shows the result.
|
||||
pub(in crate::app::dispatch) fn dispatch_rename_session(
|
||||
app: &mut AppView,
|
||||
title: String,
|
||||
) -> 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![];
|
||||
};
|
||||
agent.display_name = Some(title.clone());
|
||||
vec![Effect::RenameSession {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
title,
|
||||
cwd: agent.session.cwd.clone(),
|
||||
}]
|
||||
}
|
||||
Reference in new issue
Block a user