diff --git a/crates/app-iced/src/app/update/agent.rs b/crates/app-iced/src/app/update/agent.rs index ecfef69..7a2d2ac 100644 --- a/crates/app-iced/src/app/update/agent.rs +++ b/crates/app-iced/src/app/update/agent.rs @@ -106,13 +106,16 @@ impl App { self.view_dirty = true; } agent::Message::Tab { shift } => { - let busy = self.agent_host.rail().busy; - self.agent.step_focus(shift, busy); + let rail = self.agent_host.rail(); + let (busy, pending) = (rail.busy, rail.confirm.is_some()); + self.agent.step_focus(shift, busy, pending); } agent::Message::Activate => self.activate_focused(catalog), agent::Message::Key(event) => return self.on_agent_key(event), } + // 待确认请求出现/消失时,把键盘焦点挪到(或挪离)确认条的按钮上 + self.sync_confirm_focus(); // 档位 / 路由变了就要把「栏可见吗」写回 presenter(未读口径的输入) self.sync_agent_presenter(); if geometry_changed { @@ -184,6 +187,27 @@ impl App { }) } + /// 待确认请求出现 ⇒ 焦点直接落在「允许」并打开键盘巡航;消失 ⇒ 收回输入框。 + /// + /// 为什么要**打开** `keyboard_mode`(而不是只挪焦点):待确认时 `busy` 锁着发送键, + /// `Stop` 又走主后端、清不掉 presenter 的 pending——键盘用户若够不着这两个按钮,这一轮对话 + /// 就再也推不动(复审 P2-2 的原话)。所以这是一个**阻塞态**:焦点环主动亮出来, + /// 按 `Enter` 或 `Space` 就能裁决。 + /// + /// 唯一写入口:`on_agent` 末尾(任何会改动 rail 的分支之后都要经过它)。 + fn sync_confirm_focus(&mut self) { + let pending = self.agent_host.rail().confirm.is_some(); + let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny); + if pending && !on_confirm { + self.agent.focus = Focus::ConfirmAllow; + self.agent.keyboard_mode = true; + eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Enter/Space 裁决)"); + } else if !pending && on_confirm { + self.agent.focus = Focus::Composer; + self.agent.keyboard_mode = false; + } + } + /// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。 /// /// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变, @@ -235,8 +259,9 @@ impl App { self.drain_agent(); } Fixture::Focused => { - let busy = self.agent_host.rail().busy; - self.agent.step_focus(false, busy); + let rail = self.agent_host.rail(); + let (busy, pending) = (rail.busy, rail.confirm.is_some()); + self.agent.step_focus(false, busy, pending); } } } @@ -269,7 +294,11 @@ impl App { }, // `Activate` 只在软焦点落在**按钮**上时才动手(输入框的 Enter 由 // `text_editor::key_binding` 分流成「发送」,不能在这里重复发一次)。 - Key::Named(key::Named::Enter) => agent::Message::Activate, + // + // M119 r1 / P2-2:`Space` 与 `Enter` 等价(键盘用户对确认条的习惯动作是空格)。 + // 它不会抢输入框里的空格:`activate_focused` 头一行就是 `keyboard_mode` 闸, + // 而用鼠标点进输入框时那道闸是关的(`AgentUi::edit` 的 `Click` 分支)。 + Key::Named(key::Named::Enter | key::Named::Space) => agent::Message::Activate, _ => return Task::none(), }; @@ -315,6 +344,8 @@ impl App { agent::Message::Detach }) } + (_, Focus::ConfirmAllow) => Some(agent::Message::Confirm(true)), + (_, Focus::ConfirmDeny) => Some(agent::Message::Confirm(false)), (_, Focus::Send) => Some(agent::Message::Submit), (_, Focus::Stop) => Some(agent::Message::Stop), // 折叠档没有「拆出」、独立页没有「收起」:焦点环按 `Focus::order` 不会落到这两格, diff --git a/crates/app-iced/src/app/update/agent/tests.rs b/crates/app-iced/src/app/update/agent/tests.rs index a532030..4232332 100644 --- a/crates/app-iced/src/app/update/agent/tests.rs +++ b/crates/app-iced/src/app/update/agent/tests.rs @@ -22,11 +22,14 @@ use unlucky_presenter::dto::{AgentEvent, AppEvent, ChatRole}; use unlucky_presenter::mock::MockBackend; use unlucky_presenter::settings_bridge::SettingsBridge; +use iced::keyboard::key::{Code, Named, Physical}; +use iced::keyboard::{key, Location, Modifiers}; + use crate::app::state::presenter::transport::Script; use crate::app::state::presenter::AgentHost; use crate::app::Message; use crate::cli::Options; -use crate::shell::agent::{Message as AgentMessage, RailState}; +use crate::shell::agent::{Focus, Message as AgentMessage, RailState}; use crate::theme::Families; use super::App; @@ -202,6 +205,27 @@ fn a_turn_level_failure_keeps_the_partial_answer_and_the_reason() { } /// 输入框的草稿会同步给 presenter(切形态不丢的语义在 presenter 那边)。 +/// **P2-1**:默认配置下后端是脚本化 rig ⇒ 视图必须知道「这是模拟」(界面据此常驻标记)。 +/// +/// 抓得住:`is_simulated` 被写死成 `false`、或者视图拿不到这个事实(用户就会以为模型接上了)。 +/// 抓不住:标记在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/08-*`)。 +#[test] +fn the_default_backend_reports_itself_as_simulated() { + let app = app_with(Script::Qa); + assert!( + app.agent_host.is_simulated(), + "脚本化 rig 必须自报「模拟」,否则界面就能假装模型接上了" + ); + // 空会话(最容易被误读成一屏)也要能拿到:视图入参 `Session.simulated` 就是它 + let session = crate::shell::agent::Session { + agent: &app.agent, + rail: app.agent_host.rail(), + failure: app.agent_failure(), + simulated: app.agent_host.is_simulated(), + }; + assert!(session.simulated); +} + #[test] fn typing_syncs_the_draft_into_the_presenter() { let mut app = app_with(Script::Qa); @@ -220,3 +244,61 @@ fn typing_syncs_the_draft_into_the_presenter() { assert!(app.agent.draft_is_blank(), "编辑器清空"); assert!(app.agent_host.rail().draft.is_empty(), "presenter 侧也清空"); } + +/// 手搓一条按键事件(订阅层搬过来的就是它;`on_agent_key` 只看 `KeyPressed`)。 +fn press(named: key::Named) -> iced::keyboard::Event { + let key = key::Key::Named(named); + iced::keyboard::Event::KeyPressed { + key: key.clone(), + modified_key: key, + physical_key: Physical::Code(Code::Enter), + location: Location::Standard, + modifiers: Modifiers::default(), + text: None, + repeat: false, + } +} + +/// **P2-2 的回归闸**:不碰鼠标、只用键盘消息,能完成一次写操作确认。 +/// +/// 为什么这条重要:确认条原本只有鼠标路径,而待确认时 `busy` 锁着发送、`Stop` 又走主后端 +/// (清不掉 presenter 的 pending)——键盘用户会在这一步**永久卡死**。 +/// +/// 抓得住:①焦点没主动落到确认条(`Enter` 够不着);②`Enter` / `Space` 没映射成 `Activate`; +/// ③`Tab` 环里没有确认条那两格;④裁决完焦点没收回输入框(下一句话打不进去)。 +/// 抓不住:焦点环在像素上的样子(那要出图,见 `DevDocs/artifacts/m119/09-*`)。 +#[test] +fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() { + let mut app = app_with(Script::WriteOp); + submit(&mut app, "把下载并发改小"); + assert!( + app.agent_host.rail().confirm.is_some(), + "先要有一个待确认请求" + ); + // ① 焦点主动落在「允许」,并且键盘巡航已经打开(否则 Enter 那道闸过不去) + assert_eq!( + app.agent.focus, + Focus::ConfirmAllow, + "焦点要落在可操作元素上" + ); + assert!(app.agent.keyboard_mode, "阻塞态要主动亮出焦点环"); + + // ② Enter = 允许 + let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter)))); + assert!( + app.agent_host.rail().confirm.is_none(), + "Enter 必须真的裁决掉" + ); + assert_eq!(app.agent_host.audit_len(), 1, "裁决进审计"); + assert_eq!(app.agent.focus, Focus::Composer, "裁决完焦点收回输入框"); + assert!(!app.agent.keyboard_mode, "阻塞态结束就不再亮焦点环"); + + // ③ 第二轮走「Tab 到拒绝 + Space 触发」:键盘用户不必背 Enter 是哪一个 + submit(&mut app, "那把并发改回来"); + assert_eq!(app.agent.focus, Focus::ConfirmAllow); + let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false })); + assert_eq!(app.agent.focus, Focus::ConfirmDeny, "Tab 要能走到「拒绝」"); + let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space)))); + assert!(app.agent_host.rail().confirm.is_none(), "Space 也要能裁决"); + assert_eq!(app.agent_host.audit_len(), 2); +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index b87b3c5..919c256 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -58,10 +58,15 @@ impl App { let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent)); // M119:会话内容 / 未读 / busy / 待确认全部来自 presenter 的 rail,失败横幅是 app 层 // 备好的**展示输入**(标题 catalog 键 + 已脱敏正文)。壳在这里不做任何脱敏。 - let rail = self.agent_host.rail(); - let failure = self.agent_failure(); // 界面语言的真值源是 `App::lang`(M106):右栏的静态文案也走它,不再有 `const LANG` let agent_catalog = self.lang().catalog(); + // 会话入参:rail 借 presenter、失败横幅由 app 层备好、`simulated` 是后端的事实 + let session = agent::Session { + agent: &self.agent, + rail: self.agent_host.rail(), + failure: self.agent_failure(), + simulated: self.agent_host.is_simulated(), + }; let account = self.rail_account(); match pages::Screen::of(self.state.route()) { @@ -85,15 +90,8 @@ impl App { self.language(), ) .map(Message::Page), - agent::docked( - &self.ui, - &self.agent, - rail, - failure, - agent_catalog, - self.width, - ) - .map(|element| element.map(Message::Agent)), + agent::docked(&self.ui, session, agent_catalog, self.width) + .map(|element| element.map(Message::Agent)), overlay, self.options.force_state, self.lang(), @@ -112,15 +110,7 @@ impl App { }, APP_TITLE, background, - agent::page( - &self.ui, - &self.agent, - rail, - failure, - agent_catalog, - self.width, - ) - .map(Message::Agent), + agent::page(&self.ui, session, agent_catalog, self.width).map(Message::Agent), None, overlay, self.options.force_state, diff --git a/crates/app-iced/src/app/view/account_page.rs b/crates/app-iced/src/app/view/account_page.rs index 75a81dd..a90ca99 100644 --- a/crates/app-iced/src/app/view/account_page.rs +++ b/crates/app-iced/src/app/view/account_page.rs @@ -55,9 +55,12 @@ impl App { // M119:右栗的会话内容 / 未读 / busy 全部来自 presenter 的 rail(`agent_host`) agent::docked( &self.ui, - &self.agent, - self.agent_host.rail(), - self.agent_failure(), + agent::Session { + agent: &self.agent, + rail: self.agent_host.rail(), + failure: self.agent_failure(), + simulated: self.agent_host.is_simulated(), + }, self.lang().catalog(), self.width, ) diff --git a/crates/app-iced/src/pages/agent/conversation.rs b/crates/app-iced/src/pages/agent/conversation.rs index fd3c9cd..8c85e78 100644 --- a/crates/app-iced/src/pages/agent/conversation.rs +++ b/crates/app-iced/src/pages/agent/conversation.rs @@ -90,6 +90,11 @@ pub struct Conversation<'a> { pub confirm: Option<&'a ConfirmRequest>, /// 失败横幅(`Some` = 画具名错误;见 [`Failure`])。 pub failure: Option>, + /// 这套后端是不是模拟的(`true` ⇒ 标题旁常驻「模拟」标记 + 副标题说清楚)。 + /// + /// M119 r1 / 复审 P2-1:生产默认后端是脚本化 rig,而界面上曾**没有任何模拟标识**—— + /// 用户敲一句话得到一段像是模型编的回答。这个字段就是那个事实的渲染入口(不是硬编码)。 + pub simulated: bool, /// 三档状态(决定头部画哪组动作)。 pub rail: RailState, /// 宿主。 @@ -121,7 +126,7 @@ pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element // 确认条贴着输入区(动作在下面,读完就能按)。 if let Some(request) = spec.confirm { - body = body.push(banners::confirm(ui, request, catalog)); + body = body.push(banners::confirm(ui, request, catalog, spec.focus)); } body.push(divider(ui)) @@ -132,19 +137,21 @@ pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element // ---------------------------------------------------------------- 头部 fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> { - let title = row![ - text_role( - ui, - TypeRole::TitleLarge, - true, - catalog.text(Key::AgentTitle), - ui.color(Role::OnSurface), - Length::Shrink, - ), - unread_badge(ui, spec.unread, catalog), - ] + let mut title = row![text_role( + ui, + TypeRole::TitleLarge, + true, + catalog.text(Key::AgentTitle), + ui.color(Role::OnSurface), + Length::Shrink, + )] .spacing(ui.space(Space::X2)) .align_y(iced::Alignment::Center); + // 常驻标记:与标题同一行,不点开、不 hover、不藏在设置里(P2-1 的判据) + if spec.simulated { + title = title.push(simulated_chip(ui, catalog)); + } + title = title.push(unread_badge(ui, spec.unread, catalog)); let titles = column![ title, @@ -268,6 +275,28 @@ pub(super) fn focused_state<'a>(button: Button<'a, Message>, focused: bool) -> B } } +/// 「模拟」标记:`SurfaceVariant` / `OnSurfaceVariant` 的 tonal 小胶囊(中性信息,不是告警)。 +fn simulated_chip<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { + let style = ui.style(TypeRole::LabelMedium); + + container( + text(catalog.text(Key::AgentSimulated)) + .size(style.size_px) + .font(ui.font_of(style)) + .color(ui.color(Role::OnSurfaceVariant)), + ) + .padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)])) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::SurfaceVariant))), + border: Border { + radius: ui.radius(ShapeScale::Full, ui.metrics().control_height / 2.0), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + fn unread_badge<'a>(ui: &'a Ui, unread: usize, catalog: Catalog) -> Element<'a, Message> { if unread == 0 { return space::horizontal().into(); diff --git a/crates/app-iced/src/pages/agent/conversation/banners.rs b/crates/app-iced/src/pages/agent/conversation/banners.rs index 9585a44..b6d0861 100644 --- a/crates/app-iced/src/pages/agent/conversation/banners.rs +++ b/crates/app-iced/src/pages/agent/conversation/banners.rs @@ -35,7 +35,9 @@ use unlucky_presenter::dto::ConfirmRequest; use crate::app::components::buttons::{self, Button}; use crate::theme::Ui; +use super::super::state::Focus; use super::super::{Failure, Message}; +use super::focused_state; use super::text_role_owned; /// 失败横幅:`ErrorContainer` 图标块 + 标题/正文(M3 内联 alert 的形状,与账户页横幅同源)。 @@ -57,6 +59,7 @@ pub(super) fn confirm<'a>( ui: &'a Ui, request: &'a ConfirmRequest, catalog: Catalog, + focus: Option, ) -> Element<'a, Message> { // 摘要来自 presenter(已过 `scrub_text`):它是「要改什么」的唯一描述,必须显示, // 否则用户是在盲签。句框走 catalog,摘要本身是正文。 @@ -64,21 +67,67 @@ pub(super) fn confirm<'a>( Key::AgentConfirmRequest, &[("summary", request.summary.as_str())], ); + // 两个按钮都在 Tab 环上(`Focus::ConfirmAllow` / `Focus::ConfirmDeny`):键盘用户 + // 用 Enter/Space 就能裁决,不必去找鼠标(P2-2 的那条死路) + let deny = focus == Some(Focus::ConfirmDeny); + let allow = focus == Some(Focus::ConfirmAllow); let actions: Vec> = vec![ - buttons::outlined( + ring( ui, - Button::new(catalog.text(Key::AgentConfirmDeny), Message::Confirm(false)) - .leading(IconKind::Close), + buttons::outlined( + ui, + focused_state( + Button::new(catalog.text(Key::AgentConfirmDeny), Message::Confirm(false)) + .leading(IconKind::Close), + deny, + ), + ), + deny, ), - buttons::filled( + ring( ui, - Button::new(catalog.text(Key::AgentConfirmAllow), Message::Confirm(true)) - .leading(IconKind::Check), + buttons::filled( + ui, + focused_state( + Button::new(catalog.text(Key::AgentConfirmAllow), Message::Confirm(true)) + .leading(IconKind::Check), + allow, + ), + ), + allow, ), ]; banner(ui, title, String::new(), actions) } +/// 焦点环:`Primary` 的 2dp 描边(与输入框聚焦同一套语言)。 +/// +/// 为什么不能只用 `Interaction::Focused`(10% 状态层):在**实心主色**按钮上那层几乎看不出来 +/// ——M119 r1 复审要求「焦点确实落在可操作元素上」且要能出图证明,10% 层在浅色图里等于没有。 +/// 所以键盘焦点在这两个按钮上额外加一圈描边(鼠标用户的界面不受影响:只有 `focused` 才画)。 +fn ring<'a>(ui: &'a Ui, button: Element<'a, Message>, focused: bool) -> Element<'a, Message> { + // 外圈留 3px 空气:环与按钮之间不贴边,才看得成「一圈」而不是「两条重合的描边」。 + // 两个按钮**都**加这层 padding(`focused` 与否一样),所以焦点变化不移动布局。 + let gap = ui.space(Space::X1) * 0.75; + let outer = ui.metrics().control_height + gap * 2.0; + container(button) + .padding(gap) + .style(move |_theme| container::Style { + border: if focused { + iced::Border { + color: ui.color(Role::Primary), + width: crate::theme::STROKE_PX * 2.0, + // 与按钮同一种「药丸」:`Full` 的半径 = 边长 / 2(这里用加过 padding 的边长) + radius: ui.radius(ShapeScale::Full, outer), + } + } else { + iced::Border::default() + }, + ..container::Style::default() + }) + .into() +} + /// 横幅本体:图标块 + 标题/正文 + 右侧动作。 /// /// 形状与 `pages::account::widgets::alert` 同源(面板底走卡片层 + 1dp 描边, diff --git a/crates/app-iced/src/pages/agent/mod.rs b/crates/app-iced/src/pages/agent/mod.rs index 15a8477..d5d91fc 100644 --- a/crates/app-iced/src/pages/agent/mod.rs +++ b/crates/app-iced/src/pages/agent/mod.rs @@ -126,23 +126,38 @@ pub struct Failure<'a> { pub body: &'a str, } +/// 两个宿主共用的**会话入参**(`Session` = 从 presenter 借来的那一份 + 两个展示事实)。 +/// +/// 为什么是一个结构体而不是继续加形参:`docked`/`page` 已经收到 6 个形参,这一轮还要加 +/// 「是不是模拟后端」——那会变成 7 个,正是塔台登记过的可维护性观察项。收成结构体之后两个宿主 +/// 各 3 个形参,且以后再加展示事实只改这个结构体。 +#[derive(Clone, Copy)] +pub struct Session<'a> { + /// 三档/草稿/焦点(纯 UI 态)。 + pub agent: &'a AgentUi, + /// 会话内容 / 未读 / busy / 待确认(presenter 的唯一真相)。 + pub rail: &'a AgentRail, + /// 失败横幅的展示输入。 + pub failure: Option>, + /// 这套后端是不是模拟的(M119 r1 / 复审 P2-1)。`true` ⇒ 头部常驻「模拟」标记。 + pub simulated: bool, +} + /// 停靠档的右栗(`None` = 已拆成独立页,右栗不占地)。 /// /// 两档共用同一条 `Element` 通道交给外壳的 `row!`:折叠是 48 的条,展开是「8 拖拽缝 + 面板」。 pub fn docked<'a>( ui: &'a Ui, - agent: &'a AgentUi, - rail: &'a AgentRail, - failure: Option>, + session: Session<'a>, catalog: Catalog, window_width: f32, ) -> Option> { // 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 280 时,`Expanded` 事实上就是折叠条 // (否则会画一条 48 宽、还白扣 8px 拖拽缝的「面板」)。 - match agent.effective_rail(window_width) { + match session.agent.effective_rail(window_width) { RailState::Detached => None, - RailState::Collapsed => Some(strip::strip(ui, rail, catalog)), - RailState::Expanded => Some(panel(ui, agent, rail, failure, catalog, window_width)), + RailState::Collapsed => Some(strip::strip(ui, session.rail, catalog)), + RailState::Expanded => Some(panel(ui, session, catalog, window_width)), } } @@ -151,15 +166,13 @@ pub fn docked<'a>( /// 页级骨架(页边距 + 正文列宽)与其它页面同源:列宽取 [`layout::frame_metrics`],单列 ≤424。 pub fn page<'a>( ui: &'a Ui, - agent: &'a AgentUi, - rail: &'a AgentRail, - failure: Option>, + session: Session<'a>, catalog: Catalog, window_width: f32, ) -> Element<'a, Message> { let metrics = layout::frame_metrics(pages::PaneKit::Single, window_width); - let body = conversation::view(ui, spec(agent, rail, failure, Host::Page), catalog); + let body = conversation::view(ui, spec(session, Host::Page), catalog); container( container(body) @@ -211,31 +224,25 @@ pub fn drag_overlay<'a>(agent: &AgentUi) -> Option> { fn panel<'a>( ui: &'a Ui, - agent: &'a AgentUi, - rail: &'a AgentRail, - failure: Option>, + session: Session<'a>, catalog: Catalog, window_width: f32, ) -> Element<'a, Message> { - let width = agent.rail_width(window_width); + let width = session.agent.rail_width(window_width); - let body = container(conversation::view( - ui, - spec(agent, rail, failure, Host::Rail), - catalog, - )) - .width(Length::Fixed(width)) - .height(Length::Fill) - .padding(Padding { - top: ui.space(Space::X6), - right: ui.space(Space::X4), - bottom: ui.space(Space::X4), - left: ui.space(Space::X4), - }) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))), - ..container::Style::default() - }); + let body = container(conversation::view(ui, spec(session, Host::Rail), catalog)) + .width(Length::Fixed(width)) + .height(Length::Fill) + .padding(Padding { + top: ui.space(Space::X6), + right: ui.space(Space::X4), + bottom: ui.space(Space::X4), + left: ui.space(Space::X4), + }) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))), + ..container::Style::default() + }); row![splitter(ui, width, catalog), body] .width(Length::Shrink) @@ -293,24 +300,20 @@ fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message } /// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。 -fn spec<'a>( - agent: &'a AgentUi, - rail: &'a AgentRail, - failure: Option>, - host: Host, -) -> Conversation<'a> { +fn spec<'a>(session: Session<'a>, host: Host) -> Conversation<'a> { Conversation { // 会话内容、未读、busy、待确认:**全部**来自 presenter(M119) - messages: &rail.messages, - busy: rail.busy, - unread: rail.unread, - confirm: rail.confirm.as_ref(), - failure, - draft: &agent.draft, - blank: agent.draft_is_blank(), - rail: agent.rail, + messages: &session.rail.messages, + busy: session.rail.busy, + unread: session.rail.unread, + confirm: session.rail.confirm.as_ref(), + failure: session.failure, + simulated: session.simulated, + draft: &session.agent.draft, + blank: session.agent.draft_is_blank(), + rail: session.agent.rail, host, // 只有键盘巡航过才画焦点环(鼠标操作时那是噪音,而且会被误读成 tonal 按钮) - focus: agent.keyboard_mode.then_some(agent.focus), + focus: session.agent.keyboard_mode.then_some(session.agent.focus), } } diff --git a/crates/app-iced/src/pages/agent/state.rs b/crates/app-iced/src/pages/agent/state.rs index 33fff82..a667193 100644 --- a/crates/app-iced/src/pages/agent/state.rs +++ b/crates/app-iced/src/pages/agent/state.rs @@ -72,6 +72,13 @@ pub enum Focus { Collapse, /// `⇥` 拆出(独立页上则是 `⇤` 收回,同一个焦点位)。 Detach, + /// 确认条的「允许」(只在有待确认写操作时存在于环上)。 + /// + /// M119 r1 / 复审 P2-2:确认条原本只有鼠标路径——键盘用户在写操作确认这一步**卡死** + /// (`busy` 锁住发送,`Stop` 走的是主后端、清不掉 presenter 的待确认)。这两格就是那条键盘通路。 + ConfirmAllow, + /// 确认条的「拒绝」。 + ConfirmDeny, /// 发送。 Send, /// 中断(只在流式中出现)。 @@ -86,14 +93,19 @@ impl Focus { match self { Focus::Collapse => "collapse", Focus::Detach => "detach", + Focus::ConfirmAllow => "confirm-allow", + Focus::ConfirmDeny => "confirm-deny", Focus::Send => "send", Focus::Stop => "stop", Focus::Composer => "composer", } } - /// Tab 环上「这一档真正存在的控件」(流式中才多一个中断键;折叠档没有会话动作)。 - fn order(state: RailState, busy: bool) -> Vec { + /// Tab 环上「这一档真正存在的控件」。 + /// + /// `confirm_pending` = 有待确认的写操作(那时条上多两格:允许 / 拒绝)。`busy` 在等确认时 + /// 也是真(presenter 的 `busy = streaming || confirm.is_some()`),所以环上还会多一格中断。 + fn order(state: RailState, busy: bool, confirm_pending: bool) -> Vec { let mut order = Vec::new(); match state { RailState::Collapsed => order.push(Focus::Collapse), @@ -103,6 +115,10 @@ impl Focus { } RailState::Detached => order.push(Focus::Detach), } + if confirm_pending { + order.push(Focus::ConfirmAllow); + order.push(Focus::ConfirmDeny); + } if state != RailState::Collapsed { order.push(Focus::Send); if busy { @@ -116,8 +132,8 @@ impl Focus { /// 下一个焦点(`shift` = 反向)。当前焦点不在环上时从头/尾进。 /// /// `busy` 由 presenter 的 rail 给(壳里不自己数):流式中环上多一格 `Stop`。 - pub fn step(self, shift: bool, state: RailState, busy: bool) -> Focus { - let order = Self::order(state, busy); + pub fn step(self, shift: bool, state: RailState, busy: bool, confirm_pending: bool) -> Focus { + let order = Self::order(state, busy, confirm_pending); let Some(at) = order.iter().position(|focus| *focus == self) else { return if shift { order.last() } else { order.first() } .copied() @@ -283,9 +299,9 @@ impl AgentUi { self.pointer = None; } - /// Tab 走一格(走一次就把键盘巡航打开)。`busy` 由 presenter 的 rail 给。 - pub fn step_focus(&mut self, shift: bool, busy: bool) { - self.focus = self.focus.step(shift, self.rail, busy); + /// Tab 走一格(走一次就把键盘巡航打开)。`busy` / `confirm_pending` 都由 presenter 的 rail 给。 + pub fn step_focus(&mut self, shift: bool, busy: bool, confirm_pending: bool) { + self.focus = self.focus.step(shift, self.rail, busy, confirm_pending); self.keyboard_mode = true; } } diff --git a/crates/app-iced/src/pages/agent/tests.rs b/crates/app-iced/src/pages/agent/tests.rs index 7c83a25..2f7e748 100644 --- a/crates/app-iced/src/pages/agent/tests.rs +++ b/crates/app-iced/src/pages/agent/tests.rs @@ -192,6 +192,9 @@ fn every_action_has_a_readable_label() { Key::AgentErrorTitle, Key::AgentErrorUnavailable, Key::AgentErrorNotImplemented, + // M119 r1 / P2-1:「这是模拟」的常驻标记也必须有词(用户不点就要能看到) + Key::AgentSimulated, + Key::AgentSubtitle, ] { assert!( !catalog.text(key).trim().is_empty(), @@ -234,7 +237,7 @@ fn the_focus_ring_walks_the_controls_that_exist() { let mut focus = Focus::Collapse; let mut walked = vec![focus]; for _ in 0..4 { - focus = focus.step(false, RailState::Expanded, false); + focus = focus.step(false, RailState::Expanded, false, false); walked.push(focus); } assert_eq!( @@ -250,33 +253,53 @@ fn the_focus_ring_walks_the_controls_that_exist() { // 反向:Collapse 往回走是 Composer assert_eq!( - Focus::Collapse.step(true, RailState::Expanded, false), + Focus::Collapse.step(true, RailState::Expanded, false, false), Focus::Composer ); // 流式中多一档中断键(`busy` 由 presenter 的 rail 给) assert_eq!( - Focus::Send.step(false, RailState::Expanded, true), + Focus::Send.step(false, RailState::Expanded, true, false), Focus::Stop ); // 独立页只有「收回 + 输入区」 assert_eq!( - Focus::Detach.step(false, RailState::Detached, false), + Focus::Detach.step(false, RailState::Detached, false, false), Focus::Send ); // 折叠档没有会话动作:环上只有「展开」一格 assert_eq!( - Focus::Collapse.step(false, RailState::Collapsed, false), + Focus::Collapse.step(false, RailState::Collapsed, false, false), Focus::Collapse ); // `Tab` 真的把键盘巡航打开(`Enter` 的闸门) let mut agent = AgentUi::new(); assert!(!agent.keyboard_mode); - agent.step_focus(false, false); + agent.step_focus(false, false, false); assert!(agent.keyboard_mode); + + // M119 r1 / P2-2:有待确认写操作时,环上多两格(允许 / 拒绝),且**排在发送之前**—— + // 阻塞态该先被看见。这也是键盘用户唯一能推进这一轮的路径。 + assert_eq!( + Focus::Detach.step(false, RailState::Expanded, true, true), + Focus::ConfirmAllow + ); + assert_eq!( + Focus::ConfirmAllow.step(false, RailState::Expanded, true, true), + Focus::ConfirmDeny + ); + assert_eq!( + Focus::ConfirmDeny.step(false, RailState::Expanded, true, true), + Focus::Send + ); + // 没有待确认时这两格不在环上(鼠标用户不该 Tab 到不存在的按钮) + assert_eq!( + Focus::Detach.step(false, RailState::Expanded, false, false), + Focus::Send + ); } /// 视图契约:`spec()` 把 presenter 的三件事**原样借**给视图(零拷贝、不复制)。 @@ -288,12 +311,19 @@ fn the_spec_borrows_the_presenter_rail_verbatim() { let mut agent = AgentUi::new(); agent.expand(); let rail = conversation_rail(); - let spec = super::spec(&agent, &rail, None, Host::Rail); + let session = Session { + agent: &agent, + rail: &rail, + failure: None, + simulated: true, + }; + let spec = super::spec(session, Host::Rail); assert_eq!(spec.messages.len(), rail.messages.len()); assert_eq!(spec.messages[0].text, rail.messages[0].text, "同一条消息"); assert_eq!(spec.unread, rail.unread); assert_eq!(spec.busy, rail.busy); assert!(spec.failure.is_none()); + assert!(spec.simulated, "模拟事实要原样传给视图(标记不是硬编码)"); } /// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 880~2560 的宽度。 @@ -330,9 +360,14 @@ fn every_state_builds_in_both_hosts() { let mut agent = AgentUi::new(); agent.rail = state; for shown in [None, Some(failure)] { - let _: iced::Element<'_, Message> = - page(&ui, &agent, rail, shown, catalog, window); - let _ = docked(&ui, &agent, rail, shown, catalog, window); + let session = Session { + agent: &agent, + rail, + failure: shown, + simulated: true, + }; + let _: iced::Element<'_, Message> = page(&ui, session, catalog, window); + let _ = docked(&ui, session, catalog, window); let spec = Conversation { messages: &rail.messages, draft: &agent.draft, @@ -341,6 +376,7 @@ fn every_state_builds_in_both_hosts() { blank: agent.draft_is_blank(), confirm: rail.confirm.as_ref(), failure: shown, + simulated: true, rail: agent.rail, host: Host::Rail, focus: agent.keyboard_mode.then_some(agent.focus), diff --git a/crates/app-iced/src/pages/agent/tests/drag.rs b/crates/app-iced/src/pages/agent/tests/drag.rs index 371cb2b..5dbbeb5 100644 --- a/crates/app-iced/src/pages/agent/tests/drag.rs +++ b/crates/app-iced/src/pages/agent/tests/drag.rs @@ -32,11 +32,15 @@ fn the_detached_state_frees_the_rail_slot() { let ui = ui(); let mut agent = AgentUi::new(); agent.detach(); + let session = super::super::Session { + agent: &agent, + rail: &AgentRail::new(), + failure: None, + simulated: true, + }; assert!(docked( &ui, - &agent, - &AgentRail::new(), - None, + session, unlucky_i18n::Catalog::of(unlucky_i18n::Lang::En), 1180.0 ) diff --git a/crates/app-iced/src/presenter/agent.rs b/crates/app-iced/src/presenter/agent.rs index 4f8c775..dbadc0e 100644 --- a/crates/app-iced/src/presenter/agent.rs +++ b/crates/app-iced/src/presenter/agent.rs @@ -85,6 +85,12 @@ pub(crate) enum SubmitOutcome { /// AI 助手栏的 presenter 句柄。 pub(crate) struct AgentHost { presenter: Presenter, + /// 这套后端是不是**模拟**的(M119 r1 / 复审 P2-1)。 + /// + /// 今天恒 `true`:transport 只有 `RigTransport` 一种(脚本化,没有真模型)。接真 `AgentTransport` + /// 的那一天,这里传 `false`,界面上的「模拟」标记会自动消失——**标记不是硬编码在视图里的**, + /// 它是这个事实字段的渲染。 + simulated: bool, queue: EventQueue, failure: FailureSlot, /// 最近一次提交失败(具名;下一次成功提交或重试用尽时清掉)。 @@ -101,6 +107,8 @@ impl AgentHost { let failure = transport.failure(); Self { presenter: Presenter::new(Box::new(transport)), + // 脚本化 rig ⇒ 一定是模拟的(见字段文档) + simulated: true, queue, failure, last_failure: None, @@ -118,6 +126,11 @@ impl AgentHost { self.presenter.agent_rail() } + /// 这套后端是不是模拟的(界面据此常驻「模拟」标记;见字段文档)。 + pub(crate) fn is_simulated(&self) -> bool { + self.simulated + } + /// 最近一次提交失败的具名原因。 pub(crate) fn last_failure(&self) -> Option { self.last_failure diff --git a/crates/i18n/src/catalog/agent.rs b/crates/i18n/src/catalog/agent.rs index a48bc31..8e168bf 100644 --- a/crates/i18n/src/catalog/agent.rs +++ b/crates/i18n/src/catalog/agent.rs @@ -14,9 +14,10 @@ macro_rules! agent_entries { $($acc)* // ── AI 助手(右栗 + 独立页共用) AgentTitle => ("Assistant", "助手"), - // M119:接线之前这条写的是「后端未接」。真接进 presenter 之后那句话成了假话, - // 改成中性的「会话」;「跑的是什么后端」由端点标签与错误横幅说,不在标题下重复。 - AgentSubtitle => ("Conversation", "会话"), + // M119 r1 复审 P2-1:这条一度被改成中性的「会话」,理由是「跑什么后端由端点标签说」—— + // 但端点标签在壳里根本不存在,于是默认配置下用户看到的是一个像是接上了模型的助手。 + // 现在把诚实的话写回来,而且它是**常驻**的(标题下就一行,不点也能看到)。 + AgentSubtitle => ("Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"), AgentCollapse => ("Collapse", "收起"), AgentDetach => ("Pop out", "拆出"), AgentDock => ("Dock back", "收回"), @@ -24,10 +25,11 @@ macro_rules! agent_entries { AgentStripLabel => ("AI", "助手"), AgentSend => ("Send", "发送"), AgentStop => ("Stop", "中断"), - AgentPlaceholder => ("Message the assistant…", "给助手发消息…"), + // 输入框上再说一次:用户真要动手的位置就在这儿 + AgentPlaceholder => ("Message the simulated assistant…", "给模拟助手发消息…"), AgentEmptyTitle => ("Nothing in this session yet", "这个会话还没有内容"), - // M119:同上——「模型还没接上」这句在接线后是假话。真接的是什么由端点标签说。 - AgentEmptyBody => ("Ask about instances, downloads or logs.", "可以问实例、下载或日志。"), + // 空会话是最容易被误读的一屏(没有人说过话,很容易以为是「连上了但还没问」) + AgentEmptyBody => ("Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"), AgentNotePending => ("Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"), AgentNoteStopped => ("Stopped by you.", "你中断了这次回复。"), AgentUnread => ("{count} unread", "{count} 条未读"), @@ -46,6 +48,8 @@ macro_rules! agent_entries { AgentConfirmRequest => ("The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"), AgentConfirmAllow => ("Allow", "允许"), AgentConfirmDeny => ("Deny", "拒绝"), + // 头部常驻小标记(P2-1):与副标题/占位符互为冗余,三个位置都能看到「这是模拟」 + AgentSimulated => ("Simulated", "模拟"), } } }; } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 6fadf73..db77d40 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -124,7 +124,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"), ("LogsEmptyAction", "Choose an instance", "选择实例"), ("AgentTitle", "Assistant", "助手"), - ("AgentSubtitle", "Conversation", "会话"), + ("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"), ("AgentCollapse", "Collapse", "收起"), ("AgentDetach", "Pop out", "拆出"), ("AgentDock", "Dock back", "收回"), @@ -132,9 +132,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("AgentStripLabel", "AI", "助手"), ("AgentSend", "Send", "发送"), ("AgentStop", "Stop", "中断"), - ("AgentPlaceholder", "Message the assistant…", "给助手发消息…"), + ("AgentPlaceholder", "Message the simulated assistant…", "给模拟助手发消息…"), ("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"), - ("AgentEmptyBody", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"), + ("AgentEmptyBody", "Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"), ("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"), ("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"), ("AgentUnread", "{count} unread", "{count} 条未读"), @@ -149,6 +149,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("AgentConfirmRequest", "The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"), ("AgentConfirmAllow", "Allow", "允许"), ("AgentConfirmDeny", "Deny", "拒绝"), + ("AgentSimulated", "Simulated", "模拟"), // ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**, // 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。 ("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),