Fix managed agent prompt submission

This commit is contained in:
kuddev committed 2026-08-20 06:11:01 +08:00
1 parent 8e80043ba5
commit f0597861cc
10 files changed
+495 -256

No files matched your search

+1 -1
View File
@@ -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;
+9
View File
@@ -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<u8>,
}
#[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<RuntimeSubmitBarrier>,
pub last_committed: String,
pub awaiting_input: bool,
pub finished_unseen: bool,
+7 -16
View File
@@ -951,11 +951,7 @@ impl ApplicationHandler<Event> 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<Event> 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<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
}
},
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`.
+24
View File
@@ -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<u64>,
) {
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,
+20 -188
View File
@@ -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<crate::display::state::RuntimeSubmitBarrier>,
/// OSC 9 程序通知,攒到 render(那里才有 `Window` 判聚焦)再呈现。
pending_notify: Vec<String>,
/// 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<Self>) {
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>) {
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<Self>) {
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<Self>) {
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<String> {
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<Self>) {
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<Self>) {
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<u8>) {
if let Some(session) = &self.session {
session.notifier.notify(bytes);
@@ -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<String> {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<String> {
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<Self>) {
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);
}
}
+56
View File
@@ -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<u8> {
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;
+23 -32
View File
@@ -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<crate::tray::TrayAgent> {
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——条子已被用户撤掉、或
/// 新失败已顶掉旧请求时,迟到的响应直接丢弃。
+35
View File
@@ -0,0 +1,35 @@
//! legacy 壳向托盘投影的 Agent 视图。
use super::WindowContext;
impl WindowContext {
/// 托盘与侧栏读取同一 pane 事实源,不维护第二份 Agent 状态。
pub fn tray_agents(&self) -> Vec<crate::tray::TrayAgent> {
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()
}
}
+76 -12
View File
@@ -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<String> {
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<u64>) {
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<RuntimeAgent> {