Fix managed agent prompt submission
This commit is contained in:
1 parent
8e80043ba5
commit
f0597861cc
10 files changed
+495
-256
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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——条子已被用户撤掉、或
|
||||
/// 新失败已顶掉旧请求时,迟到的响应直接丢弃。
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user