diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 2323cb4..bd5b446 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -82,7 +82,7 @@ mod message_queue_entry; pub mod sftp_panel; pub mod side_panel; mod size_info; -mod state; +pub(crate) mod state; pub(crate) mod suggest_engine; mod surface_opacity; mod terminal_color; diff --git a/nebula_app/src/display/state.rs b/nebula_app/src/display/state.rs index 822aaaf..156eb8e 100644 --- a/nebula_app/src/display/state.rs +++ b/nebula_app/src/display/state.rs @@ -199,6 +199,12 @@ pub struct NebulaInlineImage { } /// Prompt metadata and overlays that must follow one concrete PTY/pane. +#[derive(Debug, Clone)] +pub(crate) struct RuntimeSubmitBarrier { + pub(crate) baseline_screen: String, + pub(crate) submit_bytes: Vec, +} + #[derive(Debug, Default, Clone)] pub struct NebulaPaneState { pub cwd: String, @@ -238,6 +244,9 @@ pub struct NebulaPaneState { /// 屏幕检测连续看到空闲提示符的拍数。Working 要连续两拍空闲才降级 /// (单拍可能是重绘间隙);任何非 idle 检测都会清零。 pub idle_screen_streak: u8, + /// Runtime 派活后的旧提示符不能在新回合出现任何证据前覆盖 Working。 + pub agent_runtime_submit_pending: bool, + pub(crate) runtime_submit_barrier: Option, pub last_committed: String, pub awaiting_input: bool, pub finished_unseen: bool, diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 1c7aee3..be8932d 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -951,11 +951,7 @@ impl ApplicationHandler for Processor { // AI-CLI lifecycle events (nebula-hook pipe) route by pane id, so // the windows resolve them themselves; the owner claims it. (EventType::AiHook(hook), _) => { - for window_context in self.windows.values_mut() { - if window_context.handle_ai_hook(&hook) { - break; - } - } + self.route_ai_hook(&hook); }, // Assistant fix results route by pane id the same way. (EventType::AiFixReady { pane, seq, fix }, _) => { @@ -1265,17 +1261,7 @@ impl ApplicationHandler for Processor { } }, (EventType::Terminal(TerminalEvent::Wakeup), Some(window_id)) => { - crate::input::latency::pty_wakeup(); - if let Some(window_context) = self.windows.get_mut(window_id) { - window_context.dirty = true; - // A typed `ssh` login is confirmed by remote output still - // arriving after the fast-failure window — save it to the - // sidebar now, not when the session eventually exits. - window_context.confirm_ssh_on_activity(tab_id); - if window_context.display.window.has_frame { - window_context.display.window.request_redraw(); - } - } + self.handle_terminal_wakeup(window_id, tab_id); }, (EventType::Terminal(TerminalEvent::Exit), Some(window_id)) => { if let Some(pane_id) = tab_id { @@ -3298,6 +3284,11 @@ impl input::Processor> { } }, TerminalEvent::CommandDone { exit_code } => { + // 新 PTY 初始化提示符也可能先发一个 CommandDone。Runtime + // 提交 barrier 尚未冲刷时,它不能结束当前请求。 + if self.ctx.nebula_state.runtime_submit_barrier.is_some() { + return; + } // Take (not just clear) the program: the toast below // names it, and reading the field after the reset used // to hand the toast a permanent `None`. diff --git a/nebula_app/src/event/agent_runtime.rs b/nebula_app/src/event/agent_runtime.rs index d7e4a3e..8f85453 100644 --- a/nebula_app/src/event/agent_runtime.rs +++ b/nebula_app/src/event/agent_runtime.rs @@ -6,6 +6,30 @@ use super::Processor; use crate::runtime_api::{ApiError, RuntimeCommand}; impl Processor { + pub(super) fn route_ai_hook(&mut self, hook: &crate::ai_hook::AiHookEvent) { + for window_context in self.windows.values_mut() { + if window_context.handle_ai_hook(hook) { + break; + } + } + } + + pub(super) fn handle_terminal_wakeup( + &mut self, + window_id: &winit::window::WindowId, + pane_id: Option, + ) { + crate::input::latency::pty_wakeup(); + let Some(window_context) = self.windows.get_mut(window_id) else { return }; + window_context.runtime_flush_pending_submit(pane_id); + window_context.dirty = true; + // 远端在快速失败窗口后仍有输出,才把手输的 ssh 视为真实连接。 + window_context.confirm_ssh_on_activity(pane_id); + if window_context.display.window.has_frame { + window_context.display.window.request_redraw(); + } + } + pub(super) fn execute_agent_runtime_command( &mut self, command: &RuntimeCommand, diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index ed5ed5f..a77bf6b 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -191,6 +191,10 @@ pub struct TerminalView { /// 屏幕检测连续看到空闲提示符的拍数;Working 连续两拍空闲才降级 /// (单拍可能是重绘间隙),非 idle 检测与任何 hook 边沿都清零。 idle_screen_streak: u8, + /// Runtime 派活后,旧输入框仍可能连续命中 `prompt_idle`。在看到本回合 + /// 的 working/blocked 屏幕或权威 hook 前,不允许它伪造完成边沿。 + agent_runtime_submit_pending: bool, + pending_runtime_submit: Option, /// OSC 9 程序通知,攒到 render(那里才有 `Window` 判聚焦)再呈现。 pending_notify: Vec, /// SSH 直连目的地(`user@host[:port]`);本地会话为 None。 @@ -628,6 +632,8 @@ impl TerminalView { agent_status_rule: None, agent_hook_seen: false, idle_screen_streak: 0, + agent_runtime_submit_pending: false, + pending_runtime_submit: None, pending_notify: Vec::new(), ssh_destination, ssh_stage: None, @@ -674,7 +680,11 @@ impl TerminalView { fn process_event(&mut self, event: TermEvent, cx: &mut Context) { match event { - TermEvent::Wakeup | TermEvent::MouseCursorDirty => { + TermEvent::Wakeup => { + self.flush_pending_runtime_submit(cx); + cx.notify(); + }, + TermEvent::MouseCursorDirty => { cx.notify(); }, TermEvent::CursorBlinkingChange => self.restart_cursor_blink(cx), @@ -789,6 +799,12 @@ impl TerminalView { // 退出码先只用来结束「运行中」;失败命令的未读标记要等 // `SidebarActivity` 扩出未读态再接,现在记下来也没有呈现位置。 TermEvent::CommandDone { exit_code } => { + // 新 PTY 初始化提示符也可能先发一个 CommandDone。Runtime + // 文本还在等待回显 barrier 时,这个边沿属于上一轮/初始化, + // 不能清掉尚未发送的 Enter 或把新请求提前投影成 idle。 + if self.pending_runtime_submit.is_some() { + return; + } // 旧壳同款收尾:CLI 退回提示符后,它不再是这个 pane 的前台 // 事实——hook 稍后若仍在跑会重新点亮(handle_ai_hook 覆写)。 if self.running_program.take().is_some() || self.ai_session.take().is_some() { @@ -804,6 +820,8 @@ impl TerminalView { self.agent_status_rule = None; self.agent_hook_seen = false; self.idle_screen_streak = 0; + self.agent_runtime_submit_pending = false; + self.pending_runtime_submit = None; cx.notify(); }, TermEvent::Notify(body) => { @@ -838,6 +856,7 @@ impl TerminalView { /// `Exited` 只对宿主发一次;重复的退出信号(ChildExit 之后必然跟 Exit)只更新文案。 fn mark_exited(&mut self, message: String, cx: &mut Context) { + self.pending_runtime_submit = None; if self.exited.is_none() { self.exited = Some(message); cx.emit(TerminalViewEvent::Exited); @@ -868,193 +887,6 @@ impl TerminalView { ) } - /// Apply one lifecycle event already routed to this pane by the workspace. - /// - /// 旧壳 `WindowContext::handle_ai_hook` 状态机的忠实移植:此前这里把 - /// 所有非 SessionEnd 事件压平成「点亮 running_program」,于是回合结束 - /// (TurnDone)后 spinner 也永远转下去——「CLI 都答完了侧栏还在转圈」 - /// 的病根就是这次压平。 - pub fn handle_ai_hook(&mut self, event: &crate::ai_hook::AiHookEvent, cx: &mut Context) { - use crate::ai_agents::AgentStatus; - use crate::ai_hook::AiHookKind; - - // SessionEnd 可能是第一次携带最终权威 id 的事件;必须先落盘再清理 - // pane 现场,否则 CLI 正常退出后反而无法冷恢复。 - if let Some(id) = event.session_id.as_deref() { - if let Err(error) = - crate::ai_sessions::record_hook_session(&event.source, id, &self.cwd, None) - { - log::warn!("agent session index: could not record {} {id}: {error}", event.source); - } - } - match event.kind { - AiHookKind::SessionEnd => { - self.ai_session = None; - self.running_program = None; - self.agent_status = AgentStatus::Unknown; - self.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown; - self.agent_status_rule = None; - self.agent_hook_seen = false; - self.idle_screen_streak = 0; - }, - kind => { - self.running_program = Some(event.source.clone()); - self.agent_hook_seen = true; - self.agent_status_source = crate::ai_agents::AgentStatusSource::Hook; - self.agent_status_rule = None; - // 精确边沿抵达 = 屏幕检测的空闲计数作废(上一回合攒下的 - // 拍数不能把新回合的第一个空闲闪现立即降级)。 - self.idle_screen_streak = 0; - if let Some(id) = event.session_id.as_deref() { - self.ai_session = Some(crate::display::AiSessionIdentity { - source: event.source.clone(), - session_id: id.to_owned(), - }); - } - match kind { - // 会话起来了但还没干活:亮图标,不转圈。 - AiHookKind::SessionStart => self.agent_status = AgentStatus::Idle, - AiHookKind::PromptSubmit => self.agent_status = AgentStatus::Working, - // 一个工具刚跑完 = agent 正在干活,这是无条件事实(回合 - // 可能经不发 PromptSubmit 的路径继续——授权点头、队列 - // 消息——状态却还挂在 Done:「还在执行却显示完成」)。 - AiHookKind::ToolComplete => self.agent_status = AgentStatus::Working, - AiHookKind::TurnDone | AiHookKind::NeedsAttention => { - // codex 的 notify 只有「回合完成」一种事件:弹交互式 - // 提问时发的也是 turn-complete。回合结束的瞬间看一眼 - // 屏幕尾部——还挂着选择框/确认提示,就按「等你批准」 - // 处理(旧壳同款嗅探)。 - let screen_asks = - event.kind == AiHookKind::TurnDone && self.screen_tail_asks(); - self.agent_status = - if event.kind == AiHookKind::NeedsAttention || screen_asks { - AgentStatus::Blocked - } else { - AgentStatus::Done - }; - }, - AiHookKind::SessionEnd => unreachable!("handled above"), - } - }, - } - cx.emit(TerminalViewEvent::TitleChanged); - cx.notify(); - } - - /// 旧壳 TurnDone 分支的屏尾提问嗅探:取可视区最后 ≤15 行喂 - /// `tail_looks_like_question`。 - fn screen_tail_asks(&self) -> bool { - let Some(session) = &self.session else { return false }; - let term = session.term.lock(); - let lines = term.screen_lines(); - let take = lines.min(15); - let start = TermPoint::new(Line((lines - take) as i32), Column(0)); - let end = TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1))); - crate::ai_hook::tail_looks_like_question(&term.bounds_to_string(start, end)) - } - - /// 1 Hz 声明式屏幕看门狗(旧壳 `refresh_agent_screen_states` 的单 pane - /// 版)。Hook 是精确边沿,但边会丢:打断的回合没有 Stop 事件、codex - /// 只有 turn-complete 一种事件。屏幕负责两件事——给无 hook 客户端补位, - /// 以及纠正与可见现场矛盾的僵尸状态: - /// - 可见的 working/blocked chrome 永远可以纠正(现场比旧事件可信); - /// - 空闲提示符降级分档:hook 报过的 Done/Blocked 精确终态不动; - /// Working 连续两拍空闲才按「完成未读」收场(单拍可能是重绘间隙)。 - pub fn refresh_agent_screen_state(&mut self, cx: &mut Context) { - use crate::ai_agents::AgentStatus; - let Some(program) = self.running_program.clone() else { - self.idle_screen_streak = 0; - return; - }; - if crate::ai_agents::AgentKind::parse(&program).is_none() { - return; - } - let Some(session) = &self.session else { return }; - let screen = { - let term = session.term.lock(); - let lines = term.screen_lines(); - if lines == 0 || term.columns() == 0 { - return; - } - let take = lines.min(24); - let start = TermPoint::new(Line((lines - take) as i32), Column(0)); - let end = - TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1))); - term.bounds_to_string(start, end) - }; - let Some(detection) = crate::ai_agents::detect(&program, &screen) else { return }; - - let next = match detection.status { - AgentStatus::Idle => { - if self.agent_hook_seen - && matches!(self.agent_status, AgentStatus::Done | AgentStatus::Blocked) - { - return; - } - if self.agent_status == AgentStatus::Working { - self.idle_screen_streak = self.idle_screen_streak.saturating_add(1); - if self.idle_screen_streak < 2 { - return; - } - // 连续空闲 = TurnDone 丢了:按「回合完成未读」收场 - // (蓝点),而不是无痕回 Idle——用户没盯着屏幕时这个 - // 结果仍然值得一个标记。 - AgentStatus::Done - } else { - AgentStatus::Idle - } - }, - status @ (AgentStatus::Working | AgentStatus::Blocked) => { - self.idle_screen_streak = 0; - status - }, - AgentStatus::Done | AgentStatus::Unknown => { - self.idle_screen_streak = 0; - return; - }, - }; - if next != self.agent_status { - log::debug!( - "agent screen state: pane={} program={} {:?}->{next:?} rule={}", - self.pane_id, - program, - self.agent_status, - detection.rule_id - ); - self.agent_status = next; - self.agent_status_source = crate::ai_agents::AgentStatusSource::Screen; - self.agent_status_rule = Some(detection.rule_id); - cx.emit(TerminalViewEvent::TitleChanged); - cx.notify(); - } - } - - pub fn ai_fork_command(&self) -> Option { - let identity = self.ai_session.as_ref()?; - crate::ai_agents::AgentKind::parse(&identity.source)?.fork_command(&identity.session_id) - } - - /// 冷恢复把快照里的 hook 身份种回 pane,右键分叉不必再等下一条事件。 - pub fn seed_ai_session(&mut self, source: String, session_id: String, cx: &mut Context) { - if session_id.is_empty() { - return; - } - self.ai_session = Some(crate::display::AiSessionIdentity { source, session_id }); - cx.emit(TerminalViewEvent::TitleChanged); - cx.notify(); - } - - /// Queue a full shell command. Like cold resume, input may arrive before - /// the first prompt; ConPTY preserves ordering until the shell reads it. - pub fn run_command(&mut self, command: String, cx: &mut Context) { - if command.is_empty() || self.exited.is_some() { - return; - } - let mut bytes = command.into_bytes(); - bytes.push(b'\r'); - self.write_input(bytes, cx); - } - fn write_bytes(&self, bytes: Vec) { if let Some(session) = &self.session { session.notifier.notify(bytes); diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index 6865e82..d8f40ae 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -1,6 +1,8 @@ //! GPUI 终端对 Runtime API 暴露的读取、输入与任务状态边界。 use gpui::{Context, EventEmitter as _}; +use nebula_terminal::grid::Dimensions as _; +use nebula_terminal::index::{Column, Line, Point as TermPoint}; use super::{SidebarActivity, TerminalView, TerminalViewEvent}; @@ -210,12 +212,23 @@ impl TerminalView { "the pane is already running a command", )); } - let mut bytes = command.as_bytes().to_vec(); - bytes.extend(self.runtime_key_sequence( + if self.pending_runtime_submit.is_some() { + return Err(crate::runtime_api::ApiError::new( + "input_in_progress", + "the pane is still committing previous runtime input", + )); + } + let bytes = + crate::input::terminal_input::build_runtime_text_sequence(&command, self.term_mode()); + let submit_bytes = self.runtime_key_sequence( crate::runtime_api::RuntimeKey::Enter, crate::runtime_api::RuntimeKeyModifiers::default(), 1, - )?); + )?; + self.pending_runtime_submit = Some(crate::display::state::RuntimeSubmitBarrier { + baseline_screen: self.runtime_screen_snapshot().unwrap_or_default(), + submit_bytes, + }); let run = crate::runtime_api::begin_runtime_run(); let run_id = run.run_id; self.active_run = Some(run); @@ -255,16 +268,34 @@ impl TerminalView { "terminal session is unavailable for this pane", )); } + if submit && self.pending_runtime_submit.is_some() { + return Err(crate::runtime_api::ApiError::new( + "input_in_progress", + "the pane is still committing previous runtime input", + )); + } let recognized_agent = submit && self.runtime_agent().is_some(); - let mut bytes = text.as_bytes().to_vec(); + let mut bytes = + crate::input::terminal_input::build_runtime_text_sequence(&text, self.term_mode()); if submit { // Codex/Claude 可启用 kitty 或 Win32 输入协议;裸 CR 只在 legacy VT - // 下等价于 Enter,必须按 pane 当前模式编码,才能真正提交输入框。 - bytes.extend(self.runtime_key_sequence( + // 下等价于 Enter。Win32 模式下文本也已编码为 VK_PACKET 记录, + // 整个提交因此是一条同质协议流,不依赖 ConPTY 的读取边界。 + let submit_bytes = self.runtime_key_sequence( crate::runtime_api::RuntimeKey::Enter, crate::runtime_api::RuntimeKeyModifiers::default(), 1, - )?); + )?; + if text.is_empty() { + bytes.extend(submit_bytes); + } else { + self.pending_runtime_submit = Some(crate::display::state::RuntimeSubmitBarrier { + baseline_screen: self.runtime_screen_snapshot().unwrap_or_default(), + submit_bytes, + }); + } + } + if submit { self.suggest.last_committed.clone_from(&text); } self.write_input(bytes, cx); @@ -278,6 +309,7 @@ impl TerminalView { self.agent_status = crate::ai_agents::AgentStatus::Working; self.agent_status_source = crate::ai_agents::AgentStatusSource::Process; self.agent_status_rule = None; + self.agent_runtime_submit_pending = true; self.idle_screen_streak = 0; } cx.emit(TerminalViewEvent::TitleChanged); @@ -285,4 +317,209 @@ impl TerminalView { } Ok(()) } + + pub fn ai_fork_command(&self) -> Option { + let identity = self.ai_session.as_ref()?; + crate::ai_agents::AgentKind::parse(&identity.source)?.fork_command(&identity.session_id) + } + + /// 冷恢复把快照里的 hook 身份种回 pane,右键分叉不必再等下一条事件。 + pub fn seed_ai_session(&mut self, source: String, session_id: String, cx: &mut Context) { + if session_id.is_empty() { + return; + } + self.ai_session = Some(crate::display::AiSessionIdentity { source, session_id }); + cx.emit(TerminalViewEvent::TitleChanged); + cx.notify(); + } + + /// Queue a full shell command. Like cold resume, input may arrive before + /// the first prompt; ConPTY preserves ordering until the shell reads it. + pub fn run_command(&mut self, command: String, cx: &mut Context) { + if command.is_empty() || self.exited.is_some() { + return; + } + let mut bytes = command.into_bytes(); + bytes.push(b'\r'); + self.write_input(bytes, cx); + } + + /// Apply one lifecycle event already routed to this pane by the workspace. + /// + /// 旧壳 `WindowContext::handle_ai_hook` 状态机的忠实移植:不能把所有 + /// 非 SessionEnd 事件压平成 running,否则 TurnDone 后 spinner 不会停。 + pub fn handle_ai_hook(&mut self, event: &crate::ai_hook::AiHookEvent, cx: &mut Context) { + use crate::ai_agents::AgentStatus; + use crate::ai_hook::AiHookKind; + + // SessionEnd 可能是第一次携带最终权威 id 的事件;必须先落盘再清理 + // pane 现场,否则 CLI 正常退出后反而无法冷恢复。 + if let Some(id) = event.session_id.as_deref() { + if let Err(error) = + crate::ai_sessions::record_hook_session(&event.source, id, &self.cwd, None) + { + log::warn!("agent session index: could not record {} {id}: {error}", event.source); + } + } + match event.kind { + AiHookKind::SessionEnd => { + self.ai_session = None; + self.running_program = None; + self.agent_status = AgentStatus::Unknown; + self.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown; + self.agent_status_rule = None; + self.agent_hook_seen = false; + self.idle_screen_streak = 0; + self.agent_runtime_submit_pending = false; + }, + kind => { + self.running_program = Some(event.source.clone()); + self.agent_hook_seen = true; + self.agent_status_source = crate::ai_agents::AgentStatusSource::Hook; + self.agent_status_rule = None; + // 精确边沿抵达 = 屏幕检测的空闲计数作废(上一回合攒下的 + // 拍数不能把新回合的第一个空闲闪现立即降级)。 + self.idle_screen_streak = 0; + if !matches!(kind, AiHookKind::SessionStart) { + self.agent_runtime_submit_pending = false; + } + if let Some(id) = event.session_id.as_deref() { + self.ai_session = Some(crate::display::AiSessionIdentity { + source: event.source.clone(), + session_id: id.to_owned(), + }); + } + match kind { + AiHookKind::SessionStart => self.agent_status = AgentStatus::Idle, + AiHookKind::PromptSubmit | AiHookKind::ToolComplete => { + self.agent_status = AgentStatus::Working; + }, + AiHookKind::TurnDone | AiHookKind::NeedsAttention => { + let screen_asks = + event.kind == AiHookKind::TurnDone && self.screen_tail_asks(); + self.agent_status = + if event.kind == AiHookKind::NeedsAttention || screen_asks { + AgentStatus::Blocked + } else { + AgentStatus::Done + }; + }, + AiHookKind::SessionEnd => unreachable!("handled above"), + } + }, + } + cx.emit(TerminalViewEvent::TitleChanged); + cx.notify(); + } + + fn screen_tail_asks(&self) -> bool { + let Some(session) = &self.session else { return false }; + let term = session.term.lock(); + let lines = term.screen_lines(); + let take = lines.min(15); + let start = TermPoint::new(Line((lines - take) as i32), Column(0)); + let end = TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1))); + crate::ai_hook::tail_looks_like_question(&term.bounds_to_string(start, end)) + } + + /// 1 Hz 屏幕看门狗:hook 提供精确边沿,屏幕补偿丢失边沿和无 hook 客户端。 + pub fn refresh_agent_screen_state(&mut self, cx: &mut Context) { + use crate::ai_agents::AgentStatus; + + // Wakeup 会被 synchronized-update 合并;1 Hz 看门狗提供可靠的 + // Grid 后置检查,确保 Runtime 文本回显后一定能补发 Enter。 + self.flush_pending_runtime_submit(cx); + let Some(program) = self.running_program.clone() else { + self.idle_screen_streak = 0; + return; + }; + if crate::ai_agents::AgentKind::parse(&program).is_none() { + return; + } + let Some(session) = &self.session else { return }; + let screen = { + let term = session.term.lock(); + let lines = term.screen_lines(); + if lines == 0 || term.columns() == 0 { + return; + } + let take = lines.min(24); + let start = TermPoint::new(Line((lines - take) as i32), Column(0)); + let end = + TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1))); + term.bounds_to_string(start, end) + }; + let Some(detection) = crate::ai_agents::detect(&program, &screen) else { return }; + + if detection.status == AgentStatus::Idle && self.agent_runtime_submit_pending { + self.idle_screen_streak = 0; + return; + } + if matches!(detection.status, AgentStatus::Working | AgentStatus::Blocked) { + self.agent_runtime_submit_pending = false; + } + + let next = match detection.status { + AgentStatus::Idle => { + if self.agent_hook_seen + && matches!(self.agent_status, AgentStatus::Done | AgentStatus::Blocked) + { + return; + } + if self.agent_status == AgentStatus::Working { + self.idle_screen_streak = self.idle_screen_streak.saturating_add(1); + if self.idle_screen_streak < 2 { + return; + } + AgentStatus::Done + } else { + AgentStatus::Idle + } + }, + status @ (AgentStatus::Working | AgentStatus::Blocked) => { + self.idle_screen_streak = 0; + status + }, + AgentStatus::Done | AgentStatus::Unknown => { + self.idle_screen_streak = 0; + return; + }, + }; + if next != self.agent_status { + log::debug!( + "agent screen state: pane={} program={} {:?}->{next:?} rule={}", + self.pane_id, + program, + self.agent_status, + detection.rule_id + ); + self.agent_status = next; + self.agent_status_source = crate::ai_agents::AgentStatusSource::Screen; + self.agent_status_rule = Some(detection.rule_id); + cx.emit(TerminalViewEvent::TitleChanged); + cx.notify(); + } + } + + fn runtime_screen_snapshot(&self) -> Option { + let session = self.session.as_ref()?; + let term = session.term.lock(); + let lines = term.screen_lines(); + if lines == 0 || term.columns() == 0 { + return None; + } + let start = TermPoint::new(Line(0), Column(0)); + let end = TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1))); + Some(term.bounds_to_string(start, end)) + } + + pub(super) fn flush_pending_runtime_submit(&mut self, cx: &mut Context) { + let Some(pending) = self.pending_runtime_submit.as_ref() else { return }; + let Some(screen) = self.runtime_screen_snapshot() else { return }; + if screen == pending.baseline_screen { + return; + } + let pending = self.pending_runtime_submit.take().expect("checked above"); + self.write_input(pending.submit_bytes, cx); + } } diff --git a/nebula_app/src/input/terminal_input.rs b/nebula_app/src/input/terminal_input.rs index 9e312b5..a7cabcd 100644 --- a/nebula_app/src/input/terminal_input.rs +++ b/nebula_app/src/input/terminal_input.rs @@ -282,6 +282,36 @@ pub(crate) fn build_runtime_sequence( result } +/// Encode text injected by the Runtime API for the pane's active input protocol. +/// +/// ConPTY's Win32 input mode deserializes `CSI Vk;Sc;Uc;Kd;Cs;Rc_` into +/// `KEY_EVENT_RECORD`s. Mixing raw UTF-8 text and a serialized Enter record in +/// one pipe batch can leave the control record in an interactive TUI's text +/// input batch. Encode the text as `VK_PACKET` records as well, so a submitted +/// prompt is one homogeneous protocol stream and needs no timing boundary. +pub(crate) fn build_runtime_text_sequence(text: &str, mode: TermMode) -> Vec { + if use_win32_input_mode(mode) { + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::VK_PACKET; + + let mut sequence = String::with_capacity(text.len().saturating_mul(40)); + for unicode_char in text.encode_utf16() { + for key_down in [1, 0] { + // VK_PACKET is the documented virtual key for Unicode + // supplied by a non-keyboard input source. A zero scan + // code and the UTF-16 unit preserve arbitrary prompt text. + let _ = + write!(sequence, "\x1b[{};0;{};{key_down};0;1_", VK_PACKET, unicode_char); + } + } + return sequence.into_bytes(); + } + } + + text.as_bytes().to_vec() +} + fn runtime_modifiers(modifiers: RuntimeKeyModifiers) -> ModifiersState { let mut state = ModifiersState::empty(); state.set(ModifiersState::SHIFT, modifiers.shift); @@ -1090,6 +1120,32 @@ mod vt_tests { assert!(records[1].contains(";27;0;0;1_")); } + #[cfg(target_os = "windows")] + #[test] + fn runtime_win32_text_and_enter_form_one_record_stream() { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::VK_PACKET; + + let mode = TermMode::WIN32_INPUT_MODE; + let mut bytes = build_runtime_text_sequence("A中", mode); + bytes.extend(build_runtime_sequence( + RuntimeKey::Enter, + RuntimeKeyModifiers::default(), + 1, + mode, + )); + + let text = String::from_utf8(bytes).unwrap(); + let records: Vec<_> = text.split_inclusive('_').collect(); + assert_eq!(records.len(), 6); + assert_eq!(records[0], format!("\x1b[{VK_PACKET};0;65;1;0;1_")); + assert_eq!(records[1], format!("\x1b[{VK_PACKET};0;65;0;0;1_")); + assert_eq!(records[2], format!("\x1b[{VK_PACKET};0;20013;1;0;1_")); + assert_eq!(records[3], format!("\x1b[{VK_PACKET};0;20013;0;0;1_")); + assert!(records[4].starts_with("\x1b[13;")); + assert!(records[4].contains(";13;1;0;1_")); + assert!(records[5].contains(";13;0;0;1_")); + } + #[test] fn kitty_event_types_mark_release() { let mode = TermMode::DISAMBIGUATE_ESC_CODES | TermMode::REPORT_EVENT_TYPES; diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 5bd7a00..e643aeb 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -49,6 +49,7 @@ use crate::{input, renderer, session}; mod model; mod nebula_fetch_art; +mod agents; mod runtime; /// New-tab welcome page (Windows logo + fastfetch intro). Stateless helpers. pub(crate) mod welcome; @@ -2177,6 +2178,9 @@ impl WindowContext { state.agent_hook_seen = true; state.agent_status_source = crate::ai_agents::AgentStatusSource::Hook; state.agent_status_rule = None; + if !matches!(ev.kind, crate::ai_hook::AiHookKind::SessionStart) { + state.agent_runtime_submit_pending = false; + } // 精确边沿抵达 = 屏幕检测的空闲计数作废(上一回合攒下的拍数 // 不能把新回合的第一个空闲闪现立即降级)。 state.idle_screen_streak = 0; @@ -2300,6 +2304,7 @@ impl WindowContext { state.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown; state.agent_status_rule = None; state.agent_hook_seen = false; + state.agent_runtime_submit_pending = false; state.ai_session = None; state.running_program = None; state.awaiting_input = false; @@ -2319,6 +2324,12 @@ impl WindowContext { /// /// 只读底部 24 行,规则已预编译;普通 shell 或未知程序立即跳过。 pub fn refresh_agent_screen_states(&mut self) { + // Wakeup 不是所有 synchronized PTY 输出的必发事件;NebulaTick 在做 + // Agent 检测前先冲刷所有已看到 Grid 变化的 Runtime 提交 barrier。 + let pane_ids: Vec<_> = self.panes.iter().map(|pane| pane.id).collect(); + for pane_id in pane_ids { + self.runtime_flush_pending_submit(Some(pane_id)); + } for pane in &mut self.panes { let Some(program) = pane.nebula_state.running_program.clone() else { continue; @@ -2343,6 +2354,18 @@ impl WindowContext { }; let state = &mut pane.nebula_state; + if detection.status == crate::ai_agents::AgentStatus::Idle + && state.agent_runtime_submit_pending + { + state.idle_screen_streak = 0; + continue; + } + if matches!( + detection.status, + crate::ai_agents::AgentStatus::Working | crate::ai_agents::AgentStatus::Blocked + ) { + state.agent_runtime_submit_pending = false; + } // 空闲提示符降级要分三档(#「转圈不停」的根修): // - hook 报过的 Done/Blocked 是精确终态,不被提示符降级; // - Working 可能是丢了 TurnDone 的僵尸态(打断的回合没有 Stop @@ -2413,38 +2436,6 @@ impl WindowContext { self.sync_chrome_tabs(); } - /// 托盘菜单的数据面(T1-3):本窗口所有正在跑 AI CLI 的 pane。与侧栏 - /// 徽章同一事实源(`running_program` + `needs_attention`),托盘因此 - /// 永远和 pane 徽章一致,不另立第二份 agent 状态。 - pub fn tray_agents(&self) -> Vec { - self.panes - .iter() - .filter_map(|pane| { - let state = &pane.nebula_state; - let program = state - .running_program - .as_deref() - .filter(|program| crate::ai_agents::AgentKind::parse(program).is_some())?; - // 「哪个项目」比「哪个 pane id」有意义:取 cwd 尾段。 - let place = std::path::Path::new(state.cwd.trim()) - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_default(); - let label = if place.is_empty() { - program.to_owned() - } else { - format!("{program} · {place}") - }; - Some(crate::tray::TrayAgent { - window: self.display.window.id(), - pane: pane.id, - label, - needs_attention: state.needs_attention, - }) - }) - .collect() - } - /// 后台修复请求(spec 001)的结果落地:pane 归属本窗口即认领(返回 /// true,AiHook 同款路由契约)。写入前校验 seq——条子已被用户撤掉、或 /// 新失败已顶掉旧请求时,迟到的响应直接丢弃。 diff --git a/nebula_app/src/window_context/agents.rs b/nebula_app/src/window_context/agents.rs new file mode 100644 index 0000000..1af21a4 --- /dev/null +++ b/nebula_app/src/window_context/agents.rs @@ -0,0 +1,35 @@ +//! legacy 壳向托盘投影的 Agent 视图。 + +use super::WindowContext; + +impl WindowContext { + /// 托盘与侧栏读取同一 pane 事实源,不维护第二份 Agent 状态。 + pub fn tray_agents(&self) -> Vec { + self.panes + .iter() + .filter_map(|pane| { + let state = &pane.nebula_state; + let program = state + .running_program + .as_deref() + .filter(|program| crate::ai_agents::AgentKind::parse(program).is_some())?; + // 项目目录比 pane id 更适合作为托盘中的人工识别信息。 + let place = std::path::Path::new(state.cwd.trim()) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let label = if place.is_empty() { + program.to_owned() + } else { + format!("{program} · {place}") + }; + Some(crate::tray::TrayAgent { + window: self.display.window.id(), + pane: pane.id, + label, + needs_attention: state.needs_attention, + }) + }) + .collect() + } +} diff --git a/nebula_app/src/window_context/runtime.rs b/nebula_app/src/window_context/runtime.rs index d19d84a..213580c 100644 --- a/nebula_app/src/window_context/runtime.rs +++ b/nebula_app/src/window_context/runtime.rs @@ -5,6 +5,8 @@ //! implementation types to transport threads. use nebula_terminal::event::Notify; +use nebula_terminal::grid::Dimensions; +use nebula_terminal::index::{Column, Line, Point}; use crate::display::SplitDirection; use crate::event::TabRequest; @@ -33,6 +35,17 @@ fn runtime_key_sequence( Ok(bytes) } +fn runtime_screen_snapshot(pane: &super::Pane) -> Option { + let term = pane.terminal.lock(); + let lines = term.screen_lines(); + if lines == 0 || term.columns() == 0 { + return None; + } + let start = Point::new(Line(0), Column(0)); + let end = Point::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1))); + Some(term.bounds_to_string(start, end)) +} + impl WindowContext { pub(crate) fn runtime_snapshot(&self) -> RuntimeWindow { let tabs = self @@ -186,19 +199,32 @@ impl WindowContext { )); }; let pane = &mut self.panes[index]; - let submit_bytes = submit - .then(|| { - runtime_key_sequence(pane, RuntimeKey::Enter, RuntimeKeyModifiers::default(), 1) - }) - .transpose()?; + if submit && pane.nebula_state.runtime_submit_barrier.is_some() { + return Err(ApiError::new( + "input_in_progress", + "the pane is still committing previous runtime input", + )); + } + let recognized_agent = submit && runtime_agent(pane).is_some(); + let mode = *pane.terminal.lock().mode(); + let mut bytes = crate::input::terminal_input::build_runtime_text_sequence(&text, mode); + if submit { + let submit_bytes = + runtime_key_sequence(pane, RuntimeKey::Enter, RuntimeKeyModifiers::default(), 1)?; + if text.is_empty() { + bytes.extend(submit_bytes); + } else { + pane.nebula_state.runtime_submit_barrier = + Some(crate::display::state::RuntimeSubmitBarrier { + baseline_screen: runtime_screen_snapshot(pane).unwrap_or_default(), + submit_bytes, + }); + } + } if submit { pane.nebula_state.last_committed.clone_from(&text); } - pane.notifier.notify(text.into_bytes()); - if let Some(bytes) = submit_bytes { - // TUI 可切换 kitty/Win32 键盘协议,裸 CR 不一定代表 Enter。 - pane.notifier.notify(bytes); - } + pane.notifier.notify(bytes); // Direct API input has the same semantic effect as keyboard input: // stale completion/attention badges must not survive a new turn. pane.nebula_state.touched = true; @@ -206,6 +232,14 @@ impl WindowContext { pane.nebula_state.needs_attention = false; pane.nebula_state.finished_unseen = false; pane.nebula_state.failed_unseen = false; + if recognized_agent { + pane.nebula_state.agent_status = crate::ai_agents::AgentStatus::Working; + pane.nebula_state.agent_status_source = crate::ai_agents::AgentStatusSource::Process; + pane.nebula_state.agent_status_rule = None; + pane.nebula_state.agent_runtime_submit_pending = true; + pane.nebula_state.idle_screen_streak = 0; + pane.nebula_state.command_started.get_or_insert_with(std::time::Instant::now); + } self.dirty = true; self.display.window.request_redraw(); Ok(()) @@ -292,8 +326,21 @@ impl WindowContext { if pane.nebula_state.command_started.is_some() || pane.nebula_state.active_run.is_some() { return Err(ApiError::new("run_in_progress", "the pane is already running a command")); } + if pane.nebula_state.runtime_submit_barrier.is_some() { + return Err(ApiError::new( + "input_in_progress", + "the pane is still committing previous runtime input", + )); + } + let mode = *pane.terminal.lock().mode(); + let bytes = crate::input::terminal_input::build_runtime_text_sequence(&command, mode); let submit_bytes = runtime_key_sequence(pane, RuntimeKey::Enter, RuntimeKeyModifiers::default(), 1)?; + pane.nebula_state.runtime_submit_barrier = + Some(crate::display::state::RuntimeSubmitBarrier { + baseline_screen: runtime_screen_snapshot(pane).unwrap_or_default(), + submit_bytes, + }); let run = crate::runtime_api::begin_runtime_run(); let run_id = run.run_id; pane.nebula_state.active_run = Some(run); @@ -301,8 +348,7 @@ impl WindowContext { pane.nebula_state.last_committed.clone_from(&command); pane.nebula_state.touched = true; pane.nebula_state.awaiting_input = false; - pane.notifier.notify(command.into_bytes()); - pane.notifier.notify(submit_bytes); + pane.notifier.notify(bytes); self.dirty = true; self.display.window.request_redraw(); Ok(run_id) @@ -339,6 +385,24 @@ impl WindowContext { // 能把用户并行操作创建或拆分出的 Tab 一并关闭。 let _ = self.close_tab(tab_index); } + + pub(crate) fn runtime_flush_pending_submit(&mut self, pane_id: Option) { + let pane_id = pane_id.unwrap_or_else(|| self.focused_pane_id()); + let Some(index) = self.pane_index(pane_id) else { return }; + let ready = { + let pane = &self.panes[index]; + let Some(pending) = pane.nebula_state.runtime_submit_barrier.as_ref() else { + return; + }; + runtime_screen_snapshot(pane).is_some_and(|screen| screen != pending.baseline_screen) + }; + if !ready { + return; + } + let pane = &mut self.panes[index]; + let pending = pane.nebula_state.runtime_submit_barrier.take().expect("checked above"); + pane.notifier.notify(pending.submit_bytes); + } } fn runtime_agent(pane: &super::Pane) -> Option {