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:
ZacharyZhang-NY committed 2026-07-17 05:31:01 -04:00
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(&notice);
}
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(&notice);
}
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(&notice);
}
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(),
}]
}