You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/24 17:37:24 UTC
(maka) branch feat/runtime-host-rust updated: feat(tui): session panels beside the conversation, status above it
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/feat/runtime-host-rust by this push:
new 7984b3beb2 feat(tui): session panels beside the conversation, status above it
7984b3beb2 is described below
commit 7984b3beb2e27f0914bc4ba9c5ced6ef05e30207
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Fri Sep 25 01:37:15 2026 +0800
feat(tui): session panels beside the conversation, status above it
Session views placed as panels now stack in an inspector to the right of
the conversation, and views placed as status fill one quiet line above
the composer. Both open with the session on screen and follow it.
- The inspector appears when the session has panels and the page holds
it and a comfortable conversation side by side; it gives way to
narrow terminals and to session details. A header button and the
palette show or hide it, and the choice persists.
- Each panel keeps its own route with a Back of its own, its notice and
recovery commands, and fields that edit in place. Keyboard focus
reads the conversation first, then the panels, then the toolbar; Esc
returns to the composer.
- A status line shows each view's icon and first line, aligned with the
composer's text. The icon reveals the package's panel, or opens its
page when it has no panel.
- A panel's actions and consent belong to it only while it is on
screen.
Rows in plugin views keep their children's natural widths when they
fit, so short status lines and toolbars no longer spread into equal
columns; fields, bars and splits still stretch.
Generated-by: Claude Code
---
crates/tui/locales/en.ftl | 2 +
crates/tui/locales/zh-CN.ftl | 2 +
crates/tui/locales/zh-TW.ftl | 2 +
crates/tui/src/app.rs | 53 +++++
crates/tui/src/apps.rs | 136 ++++++++++++-
crates/tui/src/apps/page.rs | 2 +-
crates/tui/src/apps/panels.rs | 390 +++++++++++++++++++++++++++++++++++++
crates/tui/src/apps/tree.rs | 47 ++++-
crates/tui/src/chrome.rs | 3 +
crates/tui/src/navigation/state.rs | 6 +-
crates/tui/src/state/snapshot.rs | 8 +
crates/tui/src/ui.rs | 2 +
crates/tui/src/ui/layout.rs | 2 +-
crates/tui/src/ui/surface.rs | 6 +
crates/tui/src/view.rs | 8 +
crates/tui/src/view/session.rs | 31 +++
16 files changed, 686 insertions(+), 14 deletions(-)
diff --git a/crates/tui/locales/en.ftl b/crates/tui/locales/en.ftl
index 9dda1c34d9..b2a574d2e0 100644
--- a/crates/tui/locales/en.ftl
+++ b/crates/tui/locales/en.ftl
@@ -120,6 +120,8 @@ command-refresh = Refresh current page · F5
command-sidebar = Expand / collapse navigation · Ctrl+B
command-fullscreen = Session focus mode · F11
command-details = Show / hide session details
+command-inspector-show = Show panels
+command-inspector-hide = Hide panels
command-symbols = Switch Unicode / ASCII icons
command-motion = Enable / reduce motion
command-quit = Quit Maka
diff --git a/crates/tui/locales/zh-CN.ftl b/crates/tui/locales/zh-CN.ftl
index 1059d521db..9d9c069540 100644
--- a/crates/tui/locales/zh-CN.ftl
+++ b/crates/tui/locales/zh-CN.ftl
@@ -120,6 +120,8 @@ command-refresh = 刷新当前页 · F5
command-sidebar = 展开 / 折叠导航 · Ctrl+B
command-fullscreen = 会话专注模式 · F11
command-details = 展开 / 收起会话详情
+command-inspector-show = 显示面板
+command-inspector-hide = 隐藏面板
command-symbols = 切换 Unicode / ASCII 图标
command-motion = 启用 / 减少动态效果
command-quit = 退出 Maka
diff --git a/crates/tui/locales/zh-TW.ftl b/crates/tui/locales/zh-TW.ftl
index 8f0e868c05..d647eb7ffb 100644
--- a/crates/tui/locales/zh-TW.ftl
+++ b/crates/tui/locales/zh-TW.ftl
@@ -120,6 +120,8 @@ command-refresh = 重新整理目前頁面 · F5
command-sidebar = 展開 / 收合導覽 · Ctrl+B
command-fullscreen = 工作階段專注模式 · F11
command-details = 展開 / 收合工作階段詳情
+command-inspector-show = 顯示面板
+command-inspector-hide = 隱藏面板
command-symbols = 切換 Unicode / ASCII 圖示
command-motion = 啟用 / 減少動態效果
command-quit = 離開 Maka
diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs
index 6e2343adb5..b81154908a 100644
--- a/crates/tui/src/app.rs
+++ b/crates/tui/src/app.rs
@@ -39,6 +39,8 @@ pub enum Focus {
Transcript,
Queue,
Page,
+ /// A session's panels beside its conversation.
+ Inspector,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -56,6 +58,7 @@ pub enum Action {
ToggleSidebar,
ToggleFullscreen,
ToggleDetails,
+ ToggleInspector,
ToggleTrace,
BrowseTranscript,
Search(crate::pages::chat::render::search::Command),
@@ -343,6 +346,16 @@ impl App {
}
commands.push((Action::ToggleFullscreen, "command-fullscreen"));
commands.push((Action::ToggleDetails, "command-details"));
+ if self.inspector_available() {
+ commands.push((
+ Action::ToggleInspector,
+ if self.chrome.inspector {
+ "command-inspector-hide"
+ } else {
+ "command-inspector-show"
+ },
+ ));
+ }
commands.push((Action::BrowseTranscript, "chat-browse"));
for mode in [
crate::pages::chat::render::selection::CopyMode::Selection,
@@ -425,6 +438,9 @@ impl App {
Action::ToggleDetails,
Action::ToggleFullscreen,
];
+ if self.inspector_available() {
+ actions.push(Action::ToggleInspector);
+ }
if self.stop_target().is_some() && self.enabled(&Action::SendMessage) {
actions.insert(1, Action::SendMessage);
actions.insert(2, Action::SteerMessage);
@@ -687,6 +703,14 @@ impl App {
self.focus = Focus::Page;
}
}
+ Action::ToggleInspector => {
+ self.chrome.inspector = !self.chrome.inspector;
+ if !self.chrome.inspector && self.focus == Focus::Inspector {
+ self.focus = Focus::Composer;
+ }
+ self.apps.inspector.invalidate();
+ self.invalidate_editor_geometry();
+ }
Action::BrowseTranscript => {
self.chat.view.search = None;
self.chrome.details = false;
@@ -927,6 +951,7 @@ impl App {
.get(&id)
.is_some_and(|sent| sent.delivery.blocks_send())
}
+ Action::ToggleInspector => self.inspector_available(),
Action::ToggleFullscreen
| Action::ToggleDetails
| Action::ToggleTrace
@@ -1169,6 +1194,13 @@ impl App {
Route::Extensions | Route::App(_) => self
.apps_surface()
.is_some_and(|surface| surface.captures()),
+ // A session's panels and status lines take what is theirs; the
+ // conversation keeps the rest.
+ Route::Session(_) => {
+ return self
+ .inspector_input(event)
+ .or_else(|| self.status_input(event));
+ }
_ => return None,
};
let paste = matches!(event, Event::Paste(_));
@@ -1490,11 +1522,25 @@ impl App {
Focus::Transcript => {
self.focus = if backwards {
Focus::Composer
+ } else if self.inspector_shown() {
+ Focus::Inspector
} else {
Focus::Page
};
self.selected_control = 0;
}
+ // Panels read after the conversation they belong to.
+ Focus::Inspector => {
+ self.focus = if !backwards {
+ Focus::Page
+ } else if self.chrome.details {
+ Focus::Composer
+ } else {
+ self.chat.view.enter();
+ Focus::Transcript
+ };
+ self.selected_control = 0;
+ }
Focus::Page if backwards && self.selected_control > 0 => {
self.selected_control -= 1
}
@@ -1508,6 +1554,8 @@ impl App {
Route::Inbox | Route::Projects | Route::Connections
) {
Focus::List
+ } else if backwards && self.inspector_shown() {
+ Focus::Inspector
} else if backwards
&& matches!(self.navigation.current(), Route::Session(_))
{
@@ -1532,6 +1580,7 @@ impl App {
}
match (self.focus, self.navigation.current()) {
(Focus::Navigation, _) => self.sidebar.surface.enter(backwards),
+ (Focus::Inspector, _) => self.apps.inspector.enter(backwards),
(Focus::Page, Route::Settings) => {
self.settings.surface.enter(backwards)
}
@@ -1551,6 +1600,10 @@ impl App {
self.focus = Focus::Page;
None
}
+ KeyCode::Esc if self.focus == Focus::Inspector => {
+ self.focus = Focus::Composer;
+ None
+ }
KeyCode::Esc if self.focus == Focus::Transcript => {
self.focus = Focus::Page;
None
diff --git a/crates/tui/src/apps.rs b/crates/tui/src/apps.rs
index b95d1dc762..e88e8fa58a 100644
--- a/crates/tui/src/apps.rs
+++ b/crates/tui/src/apps.rs
@@ -30,6 +30,7 @@ mod instance;
pub(crate) mod io;
mod key;
pub(crate) mod page;
+pub(crate) mod panels;
mod region;
mod saved;
mod tree;
@@ -62,12 +63,14 @@ pub enum Message {
Reload,
/// Visit a page, opening it if it is not open.
Open(Key),
+ /// Show what belongs with a status line: its panel, else its page.
+ Reveal(Key),
Instance(Key, Command),
}
impl Message {
pub fn label(&self) -> &'static str {
match self {
- Self::Directory | Self::Open(_) => "route-extensions",
+ Self::Directory | Self::Open(_) | Self::Reveal(_) => "route-extensions",
Self::Reload => "extensions-refresh",
Self::Instance(_, command) => command.label(),
}
@@ -141,6 +144,13 @@ pub struct Apps {
confirming: Option<(Key, String)>,
/// Focus and scroll of the directory page.
pub surface: ui::Surface<Message>,
+ /// The session inspector: its surface, fields, and whether the last
+ /// frame had room to show it.
+ pub inspector: ui::Surface<Message>,
+ pub(super) inspector_wells: Vec<tree::Well>,
+ pub inspector_visible: bool,
+ /// The status line above a session's composer.
+ pub status: ui::Surface<Message>,
}
impl Apps {
@@ -287,10 +297,14 @@ impl App {
return vec![];
};
let (root, epoch) = (root_id.clone(), epoch.clone());
- // A page restored or reached through history opens on arrival.
+ // A page restored or reached through history opens on arrival, and
+ // a session opens the views it shows.
if let Route::App(key) = self.navigation.current() {
self.apps.open(&key);
}
+ if self.apps.loaded {
+ self.open_session_views();
+ }
let apps = &mut self.apps;
let mut requests = vec![];
if !apps.loaded && !apps.listing && apps.loading.is_none() && apps.due.is_none() {
@@ -559,7 +573,20 @@ impl App {
if let Some(within) = &key.within {
return self.app_visible(&within.0);
}
- self.navigation.current() == Route::App(key.clone())
+ let placement = self
+ .apps
+ .instances
+ .get(key)
+ .and_then(|instance| instance.entry.as_ref())
+ .map_or(Placement::Page, |entry| entry.descriptor.placement.clone());
+ let current = self.navigation.current();
+ let in_session = matches!(¤t, Route::Session(id) if key.session.as_ref() == Some(id));
+ match placement {
+ Placement::Page => current == Route::App(key.clone()),
+ Placement::Panel => in_session && self.inspector_shown(),
+ Placement::Status => in_session,
+ Placement::Settings | Placement::Slot { .. } => false,
+ }
}
/// The language changed: open, untouched views read themselves again.
pub(crate) fn apps_relocalize(&mut self) {
@@ -601,6 +628,7 @@ impl App {
let (key, command) = match message {
Message::Directory => return connected,
Message::Reload => return connected && !apps.listing,
+ Message::Reveal(key) => return apps.instances.contains_key(key),
Message::Open(key) => {
return connected
&& key.within.is_none()
@@ -714,6 +742,7 @@ impl App {
self.apps.due = Some(None);
return None;
}
+ Message::Reveal(key) => return self.reveal(&key),
Message::Open(key) => {
self.apps.open(&key);
return self.apply(Action::Visit(Route::App(key)));
@@ -1599,4 +1628,105 @@ pub(crate) mod tests {
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].key.as_ref(), Some(&page));
}
+
+ #[test]
+ fn a_session_shows_its_panels_beside_it_and_one_status_line_above_the_composer() {
+ use maka_plugins::terminal_ui::view::build::*;
+ let placed = |method: &str, placement: Placement| TerminalViewProjection {
+ package_id: "example.goal".into(),
+ method: method.into(),
+ descriptor: Descriptor::new(Text::plain("Goal"), Context::Session)
+ .placement(placement)
+ .icon("◎", "G"),
+ ..projection()
+ };
+ let (panel, status) = (
+ placed("panel", Placement::Panel),
+ placed("status", Placement::Status),
+ );
+ let mut app = App::new(
+ "/test".into(),
+ I18n::new(
+ LocalePreference::Explicit(crate::i18n::Locale::En),
+ crate::i18n::Locale::En,
+ ),
+ );
+ app.connection = ConnectionState::Connected {
+ root_id: "root".into(),
+ epoch: "epoch".into(),
+ };
+ app.apply(Action::Visit(Route::Session("session".into())));
+ draw(&mut app, 170, 40);
+ list(&mut app, vec![(vec![panel.clone(), status.clone()], None)]);
+ let reads = app.apps_requests();
+ assert_eq!(
+ reads.len(),
+ 2,
+ "the status line and the panel open with the session"
+ );
+ for read in reads {
+ let key = read.key.clone().unwrap();
+ let view = View {
+ version: maka_plugins::terminal_ui::VERSION,
+ title: "Goal".into(),
+ revision: "r1".into(),
+ fields: vec![],
+ actions: vec![action("pause", "Pause")],
+ root: if key.method == "panel" {
+ column(
+ "root",
+ vec![
+ heading("objective", "Ship the inspector"),
+ progress("progress", 3, 5, "Criteria"),
+ button("pause", "pause", Role::Normal),
+ ],
+ )
+ } else {
+ row(
+ "root",
+ vec![
+ text("objective", "Ship the inspector", Tone::Normal),
+ text("count", "3/5", Tone::Muted),
+ ],
+ )
+ },
+ };
+ app.apps_complete(read, Ok(Output::Reply(Reply::View { view })));
+ }
+ let screen = draw(&mut app, 170, 40);
+ assert!(
+ screen.contains("Criteria") && screen.contains("60%"),
+ "{screen}"
+ );
+ assert!(screen.contains("◎ Ship the inspector") && screen.contains("3/5"));
+ // A panel's actions work while it is on screen.
+ let panel = Key::of(&panel, Some("session")).unwrap();
+ let pause = Message::Instance(panel.clone(), Command::View(Intent::Submit("pause".into())));
+ assert!(app.apps_enabled(&pause));
+ // The status icon reveals its panel and gives it the keyboard.
+ let icon = app
+ .apps
+ .status
+ .rect(&format!(
+ "status/{}/icon",
+ Key::of(&status, Some("session")).unwrap().node()
+ ))
+ .unwrap();
+ app.input(Event::Mouse(MouseEvent {
+ kind: crossterm::event::MouseEventKind::Down(MouseButton::Left),
+ column: icon.x,
+ row: icon.y,
+ modifiers: KeyModifiers::NONE,
+ }));
+ assert_eq!(app.focus, Focus::Inspector);
+ // Put away, the panels leave the conversation its full width, and
+ // their actions stop with them.
+ app.apply(Action::ToggleInspector);
+ let screen = draw(&mut app, 170, 40);
+ assert!(!screen.contains("Criteria") && screen.contains("3/5"));
+ assert!(!app.apps_enabled(&pause));
+ // Too narrow for both, the conversation keeps the room.
+ app.apply(Action::ToggleInspector);
+ assert!(!draw(&mut app, 100, 40).contains("Criteria"));
+ }
}
diff --git a/crates/tui/src/apps/page.rs b/crates/tui/src/apps/page.rs
index 6ad9cf352b..cea8354b84 100644
--- a/crates/tui/src/apps/page.rs
+++ b/crates/tui/src/apps/page.rs
@@ -182,7 +182,7 @@ fn bar(app: &App, key: &Key, width: u16) -> Option<Node<Message>> {
}
/// What went wrong or needs a decision, with the commands that resolve it.
-fn notice(app: &App, key: &Key, width: u16) -> Option<Node<Message>> {
+pub(super) fn notice(app: &App, key: &Key, width: u16) -> Option<Node<Message>> {
let instance = app.apps.instances.get(key)?;
let i18n = &app.i18n;
let message = instance.message.as_ref().map(|message| match message {
diff --git a/crates/tui/src/apps/panels.rs b/crates/tui/src/apps/panels.rs
new file mode 100644
index 0000000000..fe9e41fd23
--- /dev/null
+++ b/crates/tui/src/apps/panels.rs
@@ -0,0 +1,390 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! A session's views beside its conversation: panels stacked in the
+//! inspector, and quiet status lines above the composer. Both follow the
+//! session on screen and open with it.
+
+use super::{Command, Intent, Key, Message, region, tree};
+use crate::{
+ app::{Action, App, Focus},
+ navigation::Route,
+ ui::{self, Node, On, Size, Tone},
+};
+use crossterm::event::{Event, MouseEventKind};
+use maka_plugins::terminal_ui::{Context, Placement};
+use maka_protocol::plugin::TerminalViewProjection;
+use ratatui::{Frame, layout::Rect};
+
+/// Where the inspector's content starts.
+const BODY: &str = "inspector/body/panels";
+/// The inspector's width, and the least the conversation keeps beside it.
+pub(crate) const INSPECTOR: u16 = 40;
+const CONVERSATION: u16 = 72;
+
+impl super::Apps {
+ /// Session views of one placement, in their declared order.
+ pub(crate) fn session_views(&self, placement: &Placement) -> Vec<&TerminalViewProjection> {
+ let mut views: Vec<_> = self
+ .directory
+ .iter()
+ .filter(|entry| {
+ entry.descriptor.context == Context::Session
+ && &entry.descriptor.placement == placement
+ })
+ .collect();
+ views.sort_by(|left, right| {
+ (left.descriptor.order, &left.descriptor.title.fallback)
+ .cmp(&(right.descriptor.order, &right.descriptor.title.fallback))
+ });
+ views
+ }
+ /// The open instances of a session's views of one placement.
+ fn session_keys(&self, session: &str, placement: &Placement) -> Vec<Key> {
+ self.session_views(placement)
+ .into_iter()
+ .filter_map(|entry| Key::of(entry, Some(session)))
+ .filter(|key| self.instances.contains_key(key))
+ .collect()
+ }
+}
+
+impl App {
+ /// The session on screen, if the page is a session's conversation.
+ fn session_on_screen(&self) -> Option<String> {
+ match self.navigation.current() {
+ Route::Session(id) => Some(id),
+ _ => None,
+ }
+ }
+ /// Whether the session on screen wants its inspector: panels exist and
+ /// the reader has not put them away.
+ pub(crate) fn inspector_wanted(&self) -> bool {
+ self.chrome.inspector && !self.chrome.details && self.inspector_available()
+ }
+ /// Whether the inspector is beside the conversation now; the last frame
+ /// decided whether both fit.
+ pub(crate) fn inspector_shown(&self) -> bool {
+ self.inspector_wanted() && self.apps.inspector_visible
+ }
+ /// Whether a page this wide holds the conversation and the inspector.
+ pub(crate) fn inspector_fits(width: u16) -> bool {
+ width >= INSPECTOR + CONVERSATION
+ }
+ /// Whether toggling the inspector means anything here.
+ pub(crate) fn inspector_available(&self) -> bool {
+ self.session_on_screen().is_some() && !self.apps.session_views(&Placement::Panel).is_empty()
+ }
+ /// Opens the views the session on screen shows: its status lines, and
+ /// its panels while the inspector is shown.
+ pub(super) fn open_session_views(&mut self) {
+ let Some(session) = self.session_on_screen() else {
+ return;
+ };
+ let mut placements = vec![Placement::Status];
+ if self.inspector_wanted() {
+ placements.push(Placement::Panel);
+ }
+ for placement in placements {
+ let keys: Vec<_> = self
+ .apps
+ .session_views(&placement)
+ .into_iter()
+ .filter_map(|entry| Key::of(entry, Some(&session)))
+ .collect();
+ for key in keys {
+ self.apps.open(&key);
+ }
+ }
+ }
+ /// Rows the status lines take above the composer: one, when any has
+ /// something to say.
+ pub(crate) fn status_rows(&self, session: &str) -> u16 {
+ u16::from(
+ self.apps
+ .session_keys(session, &Placement::Status)
+ .iter()
+ .any(|key| self.apps.instances[key].view.is_some()),
+ )
+ }
+}
+
+fn context(app: &App, focused: bool) -> ui::Context {
+ ui::Context {
+ colors: app.theme.colors(),
+ ascii: app.chrome.ascii,
+ focused: focused && app.overlay().is_none(),
+ }
+}
+
+/// Panels stacked under their titles; each keeps its own route and Back.
+pub fn draw_inspector(frame: &mut Frame<'_>, app: &mut App, area: Rect, session: &str) {
+ if area.width < 12 || area.height < 3 {
+ app.apps.inspector.invalidate();
+ app.apps.inspector_wells.clear();
+ return;
+ }
+ let width = area.width.saturating_sub(1);
+ let keys = app.apps.session_keys(session, &Placement::Panel);
+ let mut panels = vec![];
+ let mut wells = vec![];
+ for key in &keys {
+ let (node, found) = panel(app, key, width);
+ panels.push(node);
+ wells.extend(found);
+ }
+ let tree = Node::scroll(
+ "body",
+ Node::column("panels", panels)
+ .gap(2)
+ .size(Size::Fixed(width)),
+ );
+ let tree = Node::column("inspector", vec![tree]);
+ let context = context(app, app.focus == Focus::Inspector);
+ let mut surface = std::mem::take(&mut app.apps.inspector);
+ surface.render(frame, area, tree, context);
+ let focused = context
+ .focused
+ .then(|| surface.focused().map(str::to_owned))
+ .flatten();
+ region::paint(
+ frame,
+ &mut app.apps,
+ &surface,
+ &wells,
+ focused.as_deref(),
+ context.colors,
+ );
+ surface.repaint_popover(frame, &context);
+ app.apps.inspector = surface;
+ app.apps.inspector_wells = wells;
+}
+
+fn panel(app: &App, key: &Key, width: u16) -> (Node<Message>, Vec<tree::Well>) {
+ let node = key.node();
+ let Some(instance) = app.apps.instances.get(key) else {
+ return (Node::column(node, vec![]), vec![]);
+ };
+ let locale = app.i18n.locale().id();
+ let message = |command: Command| Message::Instance(key.clone(), command);
+ let mut head = vec![];
+ if !instance.history.is_empty() {
+ let back = message(Command::Back);
+ let enabled = app.apps_offered(&back);
+ head.push(
+ Node::text(
+ "back",
+ vec![(
+ (if app.chrome.ascii { "<" } else { "‹" }).into(),
+ Tone::Accent,
+ )],
+ )
+ .on(On::Activate(back))
+ .enabled(enabled)
+ .hint(app.i18n.text(Command::Back.label())),
+ );
+ }
+ head.push(
+ Node::text(
+ "title",
+ vec![(instance.title(locale).unwrap_or_default(), Tone::Muted)],
+ )
+ .clip()
+ .size(Size::Fill),
+ );
+ if instance.busy {
+ head.push(Node::text(
+ "busy",
+ vec![(
+ (if app.chrome.ascii { "..." } else { "…" }).into(),
+ Tone::Subtle,
+ )],
+ ));
+ }
+ let mut children = vec![Node::row("head", head).gap(1)];
+ children.extend(super::page::notice(app, key, width));
+ let mut wells = vec![];
+ if let Some(view) = &instance.view
+ && instance.review.is_none()
+ {
+ let offered = |intent: &Intent| instance.offered(intent);
+ let env = tree::Env {
+ key,
+ drafts: &instance.drafts,
+ ascii: app.chrome.ascii,
+ offered: &offered,
+ applied: instance.applied.as_deref(),
+ };
+ let wrap = |intent| message(Command::View(intent));
+ let parent = format!("inspector/body/panels/{node}/content");
+ let (view, found) = tree::build(view, &env, &parent, width, &wrap);
+ wells = found;
+ children.push(Node::column("content", vec![view]));
+ } else if instance.review.is_some() {
+ children.push(Node::column("content", super::drafts::nodes(app, key)));
+ }
+ (Node::column(node, children).gap(1), wells)
+}
+
+/// One quiet line: each status view's icon, which reveals its panel, and
+/// its first line.
+pub fn draw_status(frame: &mut Frame<'_>, app: &mut App, area: Rect, session: &str) {
+ if area.is_empty() {
+ app.apps.status.invalidate();
+ return;
+ }
+ let keys: Vec<_> = app
+ .apps
+ .session_keys(session, &Placement::Status)
+ .into_iter()
+ .filter(|key| app.apps.instances[key].view.is_some())
+ .collect();
+ let share = area.width / (keys.len().max(1) as u16);
+ let mut items = vec![];
+ for (index, key) in keys.iter().enumerate() {
+ if index > 0 {
+ items.push(Node::text(
+ format!("dot-{index}"),
+ vec![("·".into(), Tone::Subtle)],
+ ));
+ }
+ items.push(status(app, key, share.saturating_sub(2)));
+ }
+ let tree = Node::row("status", items).gap(1);
+ let context = context(app, false);
+ app.apps.status.render(frame, area, tree, context);
+}
+
+fn status(app: &App, key: &Key, width: u16) -> Node<Message> {
+ let node = key.node();
+ let Some(instance) = app.apps.instances.get(key) else {
+ return Node::column(node, vec![]);
+ };
+ let icon = match instance
+ .entry
+ .as_ref()
+ .and_then(|entry| entry.descriptor.icon.as_ref())
+ {
+ Some(icon) if app.chrome.ascii => icon.ascii.clone(),
+ Some(icon) => icon.glyph.clone(),
+ None => (if app.chrome.ascii { "*" } else { "◇" }).to_owned(),
+ };
+ let reveal = Message::Reveal(key.clone());
+ let enabled = app.apps_offered(&reveal);
+ let mut children = vec![
+ Node::text("icon", vec![(icon, Tone::Accent)])
+ .on(On::Activate(reveal))
+ .enabled(enabled)
+ .hint(instance.title(app.i18n.locale().id()).unwrap_or_default()),
+ ];
+ if let Some(view) = &instance.view {
+ let offered = |intent: &Intent| instance.offered(intent);
+ let env = tree::Env {
+ key,
+ drafts: &instance.drafts,
+ ascii: app.chrome.ascii,
+ offered: &offered,
+ applied: instance.applied.as_deref(),
+ };
+ let wrap = |intent| Message::Instance(key.clone(), Command::View(intent));
+ let parent = format!("status/{node}/line");
+ let (view, _) = tree::build(view, &env, &parent, width.saturating_sub(3), &wrap);
+ // A status is one line; the rest of a view stays in its panel.
+ children.push(Node::column("line", vec![view]).size(Size::Upto(width.saturating_sub(3))));
+ }
+ Node::row(node, children).gap(1).size(Size::Upto(width))
+}
+
+impl App {
+ /// The inspector's fields, then its surface, for the pointer over it
+ /// or the keyboard while it has focus.
+ pub(crate) fn inspector_input(&mut self, event: &Event) -> Option<(bool, Option<Action>)> {
+ if !self.inspector_shown() {
+ return None;
+ }
+ let keyboard = self.focus == Focus::Inspector;
+ let over = matches!(event, Event::Mouse(mouse)
+ if self.apps.inspector.rect("inspector").is_some_and(|rect|
+ rect.contains((mouse.column, mouse.row).into())));
+ if !keyboard && !over && !self.apps.inspector.captures() {
+ return None;
+ }
+ let mut surface = std::mem::take(&mut self.apps.inspector);
+ let wells = std::mem::take(&mut self.apps.inspector_wells);
+ let owned = region::input(&mut self.apps, &mut surface, &wells, event, keyboard);
+ let outcome = owned.is_none().then(|| surface.input(event));
+ self.apps.inspector = surface;
+ self.apps.inspector_wells = wells;
+ if matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(_)))
+ && (owned.is_some() || outcome.as_ref().is_some_and(|outcome| outcome.consumed))
+ {
+ self.focus = Focus::Inspector;
+ }
+ if let Some(redraw) = owned {
+ return Some((redraw, None));
+ }
+ let outcome = outcome?.map(Action::Apps);
+ if !outcome.consumed {
+ return None;
+ }
+ let action = outcome.message.and_then(|action| self.apply(action));
+ Some((outcome.redraw || action.is_some(), action))
+ }
+ /// A status icon under the pointer reveals its panel.
+ pub(crate) fn status_input(&mut self, event: &Event) -> Option<(bool, Option<Action>)> {
+ if !matches!(event, Event::Mouse(_)) || self.session_on_screen().is_none() {
+ return None;
+ }
+ let outcome = self.apps.status.input(event).map(Action::Apps);
+ if !outcome.consumed {
+ return None;
+ }
+ let action = outcome.message.and_then(|action| self.apply(action));
+ Some((outcome.redraw || action.is_some(), action))
+ }
+ /// Shows the panel that belongs with a status line, or the page when
+ /// the package has no panel.
+ pub(super) fn reveal(&mut self, key: &Key) -> Option<Action> {
+ let session = key.session.as_deref()?;
+ let panel = self
+ .apps
+ .session_views(&Placement::Panel)
+ .into_iter()
+ .find(|entry| entry.package_id == key.package)
+ .and_then(|entry| Key::of(entry, Some(session)));
+ if let Some(panel) = panel {
+ self.chrome.inspector = true;
+ self.focus = Focus::Inspector;
+ self.apps.open(&panel);
+ self.apps
+ .inspector
+ .focus_within(format!("{BODY}/{}", panel.node()));
+ return None;
+ }
+ let page = self
+ .apps
+ .directory
+ .iter()
+ .find(|entry| {
+ entry.package_id == key.package && entry.descriptor.placement == Placement::Page
+ })
+ .and_then(|entry| Key::of(entry, Some(session)))?;
+ self.apps_action(Message::Open(page))
+ }
+}
diff --git a/crates/tui/src/apps/tree.rs b/crates/tui/src/apps/tree.rs
index c36afffcec..b11543ba03 100644
--- a/crates/tui/src/apps/tree.rs
+++ b/crates/tui/src/apps/tree.rs
@@ -302,7 +302,9 @@ impl<M> Builder<'_, M> {
}
}
- /// Buttons keep their width; everything else shares the rest equally.
+ /// Children keep their natural widths when the row holds them all;
+ /// otherwise buttons keep theirs and everything else shares the rest
+ /// equally. Fields, bars and splits always stretch.
fn row(
&mut self,
key: String,
@@ -322,16 +324,31 @@ impl<M> Builder<'_, M> {
.count() as u16;
let gaps = gap.saturating_mul(children.len().saturating_sub(1) as u16);
let share = width.saturating_sub(fixed.saturating_add(gaps)) / fills.max(1);
- let children = children
+ let stretch = children.iter().any(stretchy);
+ let nodes: Vec<_> = children
.iter()
.map(|child| {
- let fill = self.button_width(child).is_none();
let path = format!("{path}/{}", child.key());
- let node = self.node(child, path, share, Axis::Row);
- if fill { node.size(Size::Fill) } else { node }
+ self.node(child, path, share, Axis::Row)
})
.collect();
- Node::row(key, children).gap(gap)
+ let natural = nodes
+ .iter()
+ .map(ui::natural_width)
+ .fold(gaps, u16::saturating_add);
+ let fill = stretch || natural > width;
+ let nodes = nodes
+ .into_iter()
+ .zip(children)
+ .map(|(node, child)| {
+ if fill && self.button_width(child).is_none() {
+ node.size(Size::Fill)
+ } else {
+ node
+ }
+ })
+ .collect();
+ Node::row(key, nodes).gap(gap)
}
fn split(
@@ -508,7 +525,9 @@ impl<M> Builder<'_, M> {
let percent = format!("{:>3}%", value.saturating_mul(100) / max.max(1));
let caption = (!label.is_empty()).then(|| label.width() as u16 + 2);
let caption_width = caption.unwrap_or(0).min(width / 3);
- let bar = width.saturating_sub(caption_width + percent.len() as u16 + 1);
+ // The label, the bar and the percentage, a column apart.
+ let gaps = if caption.is_some() { 2 } else { 1 };
+ let bar = width.saturating_sub(caption_width + percent.len() as u16 + gaps);
let filled = (u64::from(bar) * value / max.max(1)) as usize;
let (full, empty) = if self.env.ascii {
("#", "-")
@@ -542,6 +561,20 @@ impl<M> Builder<'_, M> {
}
}
+/// Whether a node takes whatever width it is given rather than its content's.
+fn stretchy(node: &wire::Node) -> bool {
+ matches!(
+ node,
+ wire::Node::Input { .. }
+ | wire::Node::Progress { .. }
+ | wire::Node::Split { .. }
+ | wire::Node::Scroll { .. }
+ | wire::Node::Markdown { .. }
+ | wire::Node::Code { .. }
+ | wire::Node::Item { .. }
+ ) || node.children().into_iter().any(stretchy)
+}
+
fn interactive(node: &wire::Node) -> bool {
matches!(
node,
diff --git a/crates/tui/src/chrome.rs b/crates/tui/src/chrome.rs
index fcd3ee0e7e..c0b1600fce 100644
--- a/crates/tui/src/chrome.rs
+++ b/crates/tui/src/chrome.rs
@@ -24,6 +24,8 @@ pub struct Chrome {
pub sidebar_expanded: Option<bool>,
pub session_fullscreen: bool,
pub details: bool,
+ /// The reader keeps a session's panels beside its conversation.
+ pub inspector: bool,
pub ascii: bool,
pub motion: bool,
pub window_focused: bool,
@@ -44,6 +46,7 @@ impl Default for Chrome {
sidebar_expanded: None,
session_fullscreen: false,
details: false,
+ inspector: true,
ascii: false,
motion: true,
window_focused: true,
diff --git a/crates/tui/src/navigation/state.rs b/crates/tui/src/navigation/state.rs
index 4611c38d41..2923a3869e 100644
--- a/crates/tui/src/navigation/state.rs
+++ b/crates/tui/src/navigation/state.rs
@@ -70,7 +70,9 @@ impl Saved {
route,
Route::Workspace | Route::Inbox | Route::Projects | Route::Connections
),
- Focus::Composer | Focus::Transcript => matches!(route, Route::Session(_)),
+ Focus::Composer | Focus::Transcript | Focus::Inspector => {
+ matches!(route, Route::Session(_))
+ }
Focus::Page => matches!(
route,
Route::Workspace
@@ -118,7 +120,7 @@ impl State {
fn saved(&self, route: &Route, app: &App) -> Saved {
let focus = match (route, self.focus) {
- (Route::Session(_), Focus::Page | Focus::Queue) => Focus::Composer,
+ (Route::Session(_), Focus::Page | Focus::Queue | Focus::Inspector) => Focus::Composer,
(
Route::Workspace | Route::Inbox | Route::Projects | Route::Connections,
Focus::Page,
diff --git a/crates/tui/src/state/snapshot.rs b/crates/tui/src/state/snapshot.rs
index aa8c7c16e9..fc72d027e6 100644
--- a/crates/tui/src/state/snapshot.rs
+++ b/crates/tui/src/state/snapshot.rs
@@ -44,6 +44,8 @@ pub struct Snapshot {
motion: bool,
sidebar: Option<bool>,
fullscreen: bool,
+ #[serde(default = "shown")]
+ inspector: bool,
readings: Vec<(String, crate::pages::chat::reading::Checkpoint)>,
navigation: crate::navigation::Navigation,
pages: Vec<(Route, crate::navigation::state::Saved)>,
@@ -56,6 +58,10 @@ pub struct Snapshot {
apps: Vec<crate::apps::Checkpoint>,
}
+fn shown() -> bool {
+ true
+}
+
impl Snapshot {
pub fn capture(app: &App, root: &str) -> Self {
let mut unresolved: Vec<_> = app
@@ -84,6 +90,7 @@ impl Snapshot {
motion: app.chrome.motion,
sidebar: app.chrome.sidebar_expanded,
fullscreen: app.chrome.session_fullscreen,
+ inspector: app.chrome.inspector,
readings: app.chat.checkpoints(),
navigation: app.navigation.clone(),
pages: app.saved_pages(),
@@ -268,6 +275,7 @@ impl Snapshot {
app.chrome.motion = self.motion;
app.chrome.sidebar_expanded = self.sidebar;
app.chrome.session_fullscreen = self.fullscreen;
+ app.chrome.inspector = self.inspector;
if !keep_locale {
app.i18n.preference = self.locale;
}
diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs
index c784670a02..9ba36c2b1f 100644
--- a/crates/tui/src/ui.rs
+++ b/crates/tui/src/ui.rs
@@ -25,6 +25,8 @@ mod node;
mod sheet;
mod surface;
+/// A node's width at its natural size, before any sharing.
+pub(crate) use layout::width as natural_width;
pub use node::{Align, Choice, Node, On, Role, Size, Tone};
pub use sheet::{Layer, Sheet, content_width};
pub use surface::{Context, Outcome, Surface};
diff --git a/crates/tui/src/ui/layout.rs b/crates/tui/src/ui/layout.rs
index f44ac1d0a6..a14af3a0de 100644
--- a/crates/tui/src/ui/layout.rs
+++ b/crates/tui/src/ui/layout.rs
@@ -453,7 +453,7 @@ pub(super) fn height<M>(node: &Node<M>, width: u16) -> u16 {
}
}
-pub(super) fn width<M>(node: &Node<M>) -> u16 {
+pub(crate) fn width<M>(node: &Node<M>) -> u16 {
match &node.kind {
Kind::Column { children, .. } => children.iter().map(width).max().unwrap_or(0),
Kind::Row { children, gap } => children.iter().map(width).fold(
diff --git a/crates/tui/src/ui/surface.rs b/crates/tui/src/ui/surface.rs
index d7eb3fa331..b1d0bbc340 100644
--- a/crates/tui/src/ui/surface.rs
+++ b/crates/tui/src/ui/surface.rs
@@ -404,6 +404,12 @@ impl<M: Clone> Surface<M> {
self.start = Some(prefix.into());
}
+ /// Focus moves to the first stop under `prefix` once one is drawn.
+ pub fn focus_within(&mut self, prefix: impl Into<String>) {
+ self.focus = None;
+ self.start = Some(prefix.into());
+ }
+
/// Keyboard focus arrives from outside the page (Tab from navigation).
pub fn enter(&mut self, last: bool) {
self.start = None;
diff --git a/crates/tui/src/view.rs b/crates/tui/src/view.rs
index 60fb5c90e3..ca83c11be9 100644
--- a/crates/tui/src/view.rs
+++ b/crates/tui/src/view.rs
@@ -284,6 +284,11 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
Route::Settings => app.settings.surface.hint(app.focus == Focus::Page),
Route::Workspace => app.home.surface.hint(app.focus == Focus::Page),
Route::Extensions => app.apps.surface.hint(app.focus == Focus::Page),
+ Route::Session(_) => app
+ .apps
+ .inspector
+ .hint(app.focus == Focus::Inspector)
+ .or_else(|| app.apps.status.hint(false)),
Route::App(key) => app
.apps
.instance(&key)
@@ -550,6 +555,7 @@ pub(crate) fn icon(app: &App, action: &Action) -> &'static str {
Action::ToggleFullscreen if app.fullscreen() => ("⊡", "-"),
Action::ToggleFullscreen => ("⛶", "+"),
Action::ToggleDetails => ("ⓘ", "i"),
+ Action::ToggleInspector => ("◨", "]"),
Action::ToggleTrace => ("⋯", "."),
Action::BrowseTranscript => ("▤", "B"),
Action::Search(command) => match command {
@@ -706,6 +712,8 @@ pub(crate) fn action_label(app: &App, action: &Action) -> String {
Action::ToggleSidebar => "command-sidebar",
Action::ToggleFullscreen => "command-fullscreen",
Action::ToggleDetails => "command-details",
+ Action::ToggleInspector if app.chrome.inspector => "command-inspector-hide",
+ Action::ToggleInspector => "command-inspector-show",
Action::ToggleTrace if app.chat.view.trace => "command-trace-hide",
Action::ToggleTrace => "command-trace-show",
Action::BrowseTranscript => "chat-browse",
diff --git a/crates/tui/src/view/session.rs b/crates/tui/src/view/session.rs
index 0174001040..a525d22ddf 100644
--- a/crates/tui/src/view/session.rs
+++ b/crates/tui/src/view/session.rs
@@ -31,6 +31,32 @@ mod search;
/// One reading surface, one input surface. Metadata lives on the input boundary.
pub(super) fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect, id: &str) {
+ // Panels sit beside the conversation when both fit; the conversation
+ // never narrows below a comfortable reading width for them.
+ let inspector = app.inspector_wanted() && App::inspector_fits(area.width);
+ app.apps.inspector_visible = inspector;
+ let area = if inspector {
+ let columns = Layout::horizontal([
+ Constraint::Min(1),
+ Constraint::Length(crate::apps::panels::INSPECTOR + 2),
+ ])
+ .split(area);
+ let edge = Block::default()
+ .borders(Borders::LEFT)
+ .border_style(Style::default().fg(app.theme.colors().border));
+ let inner = edge.inner(columns[1]).inner(Margin::new(1, 0));
+ frame.render_widget(edge, columns[1]);
+ crate::apps::panels::draw_inspector(frame, app, inner, id);
+ Rect::new(
+ columns[0].x,
+ columns[0].y,
+ columns[0].width.saturating_sub(1),
+ columns[0].height,
+ )
+ } else {
+ app.apps.inspector.invalidate();
+ area
+ };
let extras = app.stop_target().is_some() && app.enabled(&Action::SendMessage);
let control_width = if extras { 9 } else { 3 };
let width = area.width.saturating_sub(6 + control_width).max(1);
@@ -40,13 +66,18 @@ pub(super) fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect, id: &str) {
let attachment_rows = u16::from(app.attachments.has(id));
let directory_rows = u16::from(app.has_directories(id));
let skill_rows = u16::from(app.has_skills(id));
+ let status_rows = app.status_rows(id);
let parts = Layout::vertical([
Constraint::Min(1),
Constraint::Length(super::queue::height(app, area.height)),
Constraint::Length(1),
+ Constraint::Length(status_rows),
Constraint::Length(editor_height + 2 + attachment_rows + directory_rows + skill_rows),
])
.split(area);
+ // Status lines align with the composer's text, inside its border.
+ crate::apps::panels::draw_status(frame, app, parts[3].inner(Margin::new(2, 0)), id);
+ let parts = [parts[0], parts[1], parts[2], parts[4]];
if app.chrome.details {
let mut lines = crate::pages::sessions::detail_lines(app);
if let Detail::Ready(item) = &app.sessions.detail {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org