Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/dispatch/notes.rs
T
ZacharyZhang-NY 0dc98a34a6 /feedback opens the Kigi GitHub issues page
Feedback about an unofficial community build belongs on its own issue
tracker, not Moonshot's feedback endpoint — and this mirrors the
official kimi-cli, whose /feedback opens its repo's issues page
(ISSUE_URL in ui/shell/slash.py). /feedback now returns
Action::OpenUrl(https://github.com/ZacharyZhang-NY/Kigi-CLI/issues),
the same battle-tested browser path /docs web uses.

The now-dead TUI text-feedback pipeline is excised: PromptInputMode::
Feedback (~ prefix composer mode), Action::{EnterFeedbackMode,
SendFeedback}, Effect::SendFeedback (the kigi/feedback ACP POST),
TaskResult::{FeedbackComplete,FeedbackFailed}, and their dispatchers.
The shell-side kigi/feedback ACP extension stays: it is protocol
surface for editor embeddings, OAuth-gated, and shared with kigi/btw.

Gates: workspace check/clippy --all-targets 0/0, fmt, kigi-tui lib
6621 passed / 0 failed.
2026-07-18 11:51:13 -04:00

414 lines
14 KiB
Rust

//! Remember-note, btw, and recap dispatchers.
use super::ctx::with_active_agent;
use crate::app::actions::Effect;
use crate::app::agent::AgentId;
use crate::app::agent_view::{AgentView, PromptInputMode};
use crate::app::app_view::{ActiveView, AppView};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::{SessionEvent, ToolCallBlock};
use std::sync::atomic::{AtomicU64, Ordering};
/// Monotonic counter for correlating async rewrite responses with the modal
/// that requested them. Prevents stale results from populating a different
/// note's review modal when the user closes and re-opens quickly.
static REWRITE_NONCE: AtomicU64 = AtomicU64::new(0);
fn next_rewrite_nonce() -> u64 {
REWRITE_NONCE.fetch_add(1, Ordering::Relaxed)
}
/// Enter remember mode: visual change to prompt bar (remember accent, `#` prefix).
/// No side effects — the user types a memory note and presses Enter to send.
pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> {
with_active_agent(app, |agent| {
agent.prompt_input_mode = PromptInputMode::Remember;
agent.prompt.set_text("");
});
vec![]
}
/// Send a raw remember note for LLM-powered rewriting via `kigi/memory/rewrite`.
/// Clears remember mode and prompts the LLM to reformat the note with session
/// context. Falls back to direct `SaveMemoryNote` when no session is available.
pub(super) fn dispatch_send_remember_note(app: &mut AppView, text: String) -> Vec<Effect> {
use crate::views::modal::ActiveModal;
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
agent.prompt_input_mode = PromptInputMode::Normal;
agent.prompt.set_text("");
// Submitting a memory note retires any edit-contextual ephemeral tip.
agent.ephemeral_tip.clear_on_submit();
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
agent.scrollback.push_block(RenderBlock::system(
"Please provide a memory note.".to_string(),
));
return vec![];
}
let cwd = agent.session.cwd.clone();
let Some(session_id) = agent.session.session_id.clone() else {
// No session — open modal with raw content only (no LLM rewrite).
agent.active_modal = Some(ActiveModal::RememberNoteReview {
raw_content: trimmed.clone(),
enhanced_content: None, // no session → no LLM rewrite, Tab disabled
showing_enhanced: false,
scroll: 0,
window: crate::views::modal_window::ModalWindowState::new(),
cached_lines: None,
cwd,
agent_id: id,
rewrite_nonce: 0, // no rewrite in flight, nonce unused
});
return vec![];
};
// Open modal with raw content, LLM rewrite in flight.
let nonce = next_rewrite_nonce();
agent.active_modal = Some(ActiveModal::RememberNoteReview {
raw_content: trimmed.clone(),
enhanced_content: None,
showing_enhanced: false,
scroll: 0,
window: crate::views::modal_window::ModalWindowState::new(),
cached_lines: None,
cwd: cwd.clone(),
agent_id: id,
rewrite_nonce: nonce,
});
let context_summary = extract_session_context(agent);
vec![Effect::RewriteMemoryNote {
agent_id: id,
session_id,
raw_text: trimmed,
context_summary,
nonce,
}]
}
/// Save the currently displayed remember note from the review modal.
pub(super) fn dispatch_save_remember_note_from_modal(app: &mut AppView) -> Vec<Effect> {
use crate::views::modal::ActiveModal;
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
let (content, cwd) = if let Some(ActiveModal::RememberNoteReview {
ref raw_content,
ref enhanced_content,
showing_enhanced,
ref cwd,
..
}) = agent.active_modal
{
let text = if showing_enhanced {
enhanced_content.as_deref().unwrap_or(raw_content)
} else {
raw_content
};
(text.trim().to_string(), cwd.clone())
} else {
return vec![];
};
agent.active_modal = None;
agent
.scrollback
.push_block(RenderBlock::system("Saving memory note...".to_string()));
vec![Effect::SaveMemoryNote {
agent_id: id,
text: content,
cwd,
}]
}
/// Extract session context for the LLM memory rewrite request.
///
/// Walks scrollback in reverse, collecting:
/// - Last 5 user prompts
/// - File paths from recent tool calls (Read, Edit, ListDir)
/// - CWD and git branch
fn extract_session_context(agent: &AgentView) -> String {
let mut user_prompts: Vec<String> = Vec::new();
let mut file_paths: Vec<String> = Vec::new();
// Walk scrollback entries in reverse to collect recent context.
let len = agent.scrollback.len();
for i in (0..len).rev() {
let Some(entry) = agent.scrollback.entry(i) else {
continue;
};
match &entry.block {
RenderBlock::UserPrompt(prompt) => {
if user_prompts.len() < 5 {
let text = if prompt.text.len() > 200 {
let end = prompt
.text
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= 200)
.last()
.unwrap_or(0);
format!("{}...", &prompt.text[..end])
} else {
prompt.text.clone()
};
user_prompts.push(text);
}
}
RenderBlock::ToolCall(tc) if file_paths.len() < 20 => match tc {
ToolCallBlock::Read(b) => {
file_paths.push(b.path.clone());
}
ToolCallBlock::Edit(b) => {
file_paths.push(b.path.clone());
}
ToolCallBlock::ListDir(b) => {
file_paths.push(b.path.clone());
}
_ => {}
},
_ => {}
}
// Stop early once we have enough context.
if user_prompts.len() >= 5 && file_paths.len() >= 20 {
break;
}
}
let mut parts: Vec<String> = Vec::new();
// CWD
parts.push(format!("CWD: {}", agent.session.cwd.display()));
// Git branch
if let Some(ref branch) = agent.current_branch {
parts.push(format!("Branch: {branch}"));
}
// Recent prompts (chronological order)
if !user_prompts.is_empty() {
user_prompts.reverse();
parts.push("Recent prompts:".to_string());
for p in &user_prompts {
parts.push(format!("- {p}"));
}
}
// Recent file paths (deduplicated, preserving first-seen order)
if !file_paths.is_empty() {
let mut seen = std::collections::HashSet::new();
file_paths.retain(|p| seen.insert(p.clone()));
parts.push("Recent files:".to_string());
for p in &file_paths {
parts.push(format!("- {p}"));
}
}
parts.join("\n")
}
/// Send a /btw side question. Bypasses the prompt queue — works even while
/// the agent is mid-turn. Fires an ACP ext method and shows a loading overlay.
pub(super) fn dispatch_send_btw(app: &mut AppView, question: 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 {
agent.show_toast("No active session");
return vec![];
};
agent.prompt.set_text("");
agent.btw_state = Some(crate::views::btw_overlay::BtwOverlayState::Loading {
question: question.clone(),
});
// Prompt keeps focus while the answer is in flight (panel focuses on Done).
agent.btw_focused = false;
vec![Effect::SendBtw {
agent_id: id,
session_id,
question,
}]
}
/// Toast when a manual `/recap` produces no summary. Empty sessions get a clear
/// empty-state message; anything else (model failure, empty summary, etc.) keeps
/// the generic failure toast.
pub(crate) fn recap_unavailable_toast(has_user_messages: bool) -> &'static str {
if has_user_messages {
"Couldn't generate recap"
} else {
"No messages yet"
}
}
/// Whether scrollback already has a user prompt. Scans entries (not
/// `turn_count`) so it stays correct during `begin_batch`/`end_batch` session
/// load, when `push` defers `rebuild_turns` and `turn_count` can stay 0 while
/// replayed prompts are already present.
pub(crate) fn scrollback_has_user_messages(
scrollback: &crate::scrollback::state::ScrollbackState,
) -> bool {
scrollback
.iter_entries()
.any(|(_, entry)| entry.block.is_user_prompt())
}
/// Request a session recap. Bypasses the prompt queue — works even while the
/// agent is mid-turn. Fires the `kigi/recap` ext method; the recap arrives
/// asynchronously as a `SessionRecap` notification (rendered in scrollback).
///
/// `auto` is `false` for an explicit `/recap` and `true` for the automatic
/// return-from-away recap. For the manual path we clear the prompt and, when
/// no session exists yet, surface a toast; the auto path is best-effort and
/// silently no-ops without an active session.
pub(super) fn dispatch_send_recap(app: &mut AppView, auto: bool) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
// Shell is authoritative (remote settings / config / env). Skip client requests
// entirely when the feature is off so we never hit `kigi/recap`.
if !app.session_recap_available {
if !auto {
agent.show_toast("Session recap is not enabled");
}
return vec![];
}
let Some(session_id) = agent.session.session_id.clone() else {
if !auto {
agent.show_toast("No active session");
}
return vec![];
};
if !auto {
agent.prompt.set_text("");
// Nothing to summarize yet — show a clear empty-state toast instead of
// a spinner that ends in "Couldn't generate recap".
//
// Skip the short-circuit while session replay is still loading (prompts
// may not have arrived yet). Prefer an entry scan over `turn_count()`
// so mid-batch resume (deferred `rebuild_turns`) still sees history.
if !agent.session.loading_replay && !scrollback_has_user_messages(&agent.scrollback) {
agent.show_toast(recap_unavailable_toast(false));
return vec![];
}
// Show an immediate loading block with the animated "running" sidebar so
// the user has feedback that a recap is being generated. The
// `SessionRecap` handler fills this entry in and stops the animation.
// Reuse an existing in-flight loading block instead of stacking spinners
// when `/recap` is pressed repeatedly.
let already_loading = agent.pending_recap_entry.is_some_and(|eid| {
agent
.scrollback
.get_by_id(eid)
.is_some_and(|entry| entry.is_running)
});
if !already_loading {
let entry_id =
agent
.scrollback
.push(crate::scrollback::entry::ScrollbackEntry::running(
RenderBlock::session_event(SessionEvent::Recap {
summary: String::new(),
auto: false,
}),
));
agent.pending_recap_entry = Some(entry_id);
}
} else {
// Retry backoff only — do not consume the away period on dispatch.
// The shell often no-ops auto recap until ≥3 min since the last main
// turn; mark_recap_shown runs when any SessionRecap arrives (auto or
// manual `/recap`).
app.notification_service
.focus_tracker
.note_auto_recap_attempt();
}
vec![Effect::SendRecap { session_id, auto }]
}
// TaskResult handlers.
pub(super) fn handle_memory_note_saved(
app: &mut AppView,
agent_id: AgentId,
result: Result<(), String>,
) -> Vec<Effect> {
if let Some(agent) = app.agents.get_mut(&agent_id) {
match result {
Ok(()) => {
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::system(format!(
"Memory saved to {}",
crate::util::display_user_kigi_path("memory/MEMORY.md")
)));
}
Err(error) => {
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::system(format!(
"Couldn't save memory note: {error}"
)));
}
}
}
vec![]
}
pub(super) fn handle_btw_response(
app: &mut AppView,
agent_id: AgentId,
result: Result<String, String>,
) -> Vec<Effect> {
if let Some(agent) = app.agents.get_mut(&agent_id) {
use crate::views::btw_overlay::BtwOverlayState;
let question = match &agent.btw_state {
Some(BtwOverlayState::Loading { question }) => question.clone(),
_ => String::new(),
};
match result {
Ok(response) => {
// Answer arrived: show it (until Esc) and focus the panel
// so Up/Down scroll it until the user returns to the prompt.
agent.btw_state = Some(BtwOverlayState::done(question, response));
agent.btw_focused = true;
}
Err(error) => {
// Error stays until Esc; nothing to scroll, keep prompt focus.
agent.btw_state = Some(BtwOverlayState::Error { question, error });
agent.btw_focused = false;
}
}
}
vec![]
}