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/23 21:54:05 UTC

(maka) branch feat/runtime-host-rust updated: feat(tui): render plugin pages and manage Skills preferences

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 2b662cebd2 feat(tui): render plugin pages and manage Skills preferences
2b662cebd2 is described below

commit 2b662cebd2f9c0d524a04aa20443830558699745
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Sep 24 05:53:38 2026 +0800

    feat(tui): render plugin pages and manage Skills preferences
    
    Add bounded native page/form contracts, exact-registration Remote calls, keyboard and mouse controls, and localized plugin navigation. Save enabled and pinned preferences atomically in the Skills service. Preserve drafts and prevent replay after conflicts or uncertain writes.
    
    Generated-by: Codex
---
 Cargo.lock                                         |   1 +
 crates/client/src/operations.rs                    |   6 +-
 crates/client/src/plugin.rs                        |  27 +
 crates/client/tests/integration/plugin.rs          |  40 +
 crates/plugins/src/terminal_ui.rs                  |  19 +-
 crates/plugins/src/terminal_ui/page.rs             | 379 ++++++++++
 .../tests/integration/plugin_remote/javascript.rs  |  21 +-
 .../tests/integration/skills_plugin/native.rs      |   3 +
 .../integration/skills_plugin/native/terminal.rs   | 205 +++++
 crates/skills/src/api/mutation.rs                  |   7 +
 crates/skills/src/plugin.rs                        |   2 +
 crates/skills/src/plugin/mutation.rs               |  10 +-
 crates/skills/src/plugin/terminal.rs               | 302 ++++++++
 crates/tui/Cargo.toml                              |   1 +
 crates/tui/locales/en.ftl                          |  15 +
 crates/tui/locales/zh-CN.ftl                       |  15 +
 crates/tui/locales/zh-TW.ftl                       |  15 +
 crates/tui/src/app.rs                              |  36 +
 crates/tui/src/lib.rs                              |  18 +-
 crates/tui/src/navigation.rs                       |   5 +-
 crates/tui/src/navigation/state.rs                 |  18 +-
 crates/tui/src/pages.rs                            |   1 +
 crates/tui/src/pages/extensions.rs                 | 821 +++++++++++++++++++++
 crates/tui/src/pages/extensions/io.rs              | 138 ++++
 crates/tui/src/pages/extensions/view.rs            | 235 ++++++
 crates/tui/src/view.rs                             |  16 +-
 26 files changed, 2344 insertions(+), 12 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 4a8f938e4d..1052197778 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5323,6 +5323,7 @@ dependencies = [
  "libc",
  "maka-client",
  "maka-event-log",
+ "maka-plugins",
  "maka-protocol",
  "maka-runtime",
  "maka-sandbox",
diff --git a/crates/client/src/operations.rs b/crates/client/src/operations.rs
index 6c69ec6045..cb747e3a4f 100644
--- a/crates/client/src/operations.rs
+++ b/crates/client/src/operations.rs
@@ -29,7 +29,7 @@ impl OperationRegistry for Operations {
     fn decode_input(&self, operation: Operation, value: &Value) -> Result<Value> {
         if matches!(
             operation,
-            Operation::PluginRemote | Operation::PluginClientQuery
+            Operation::PluginRemote | Operation::PluginClientQuery | Operation::PluginPlatformQuery
         ) {
             maka_protocol::plugin::decode_input(operation, value)?;
             return Ok(value.clone());
@@ -140,7 +140,7 @@ impl OperationRegistry for Operations {
     fn decode_output(&self, operation: Operation, value: &Value) -> Result<Value> {
         if matches!(
             operation,
-            Operation::PluginRemote | Operation::PluginClientQuery
+            Operation::PluginRemote | Operation::PluginClientQuery | Operation::PluginPlatformQuery
         ) {
             return maka_protocol::plugin::decode_output(operation, value);
         }
@@ -227,7 +227,7 @@ impl OperationRegistry for Operations {
     fn error_codes(&self, operation: Operation) -> Option<&[OperationErrorCode]> {
         if matches!(
             operation,
-            Operation::PluginRemote | Operation::PluginClientQuery
+            Operation::PluginRemote | Operation::PluginClientQuery | Operation::PluginPlatformQuery
         ) {
             return Some(maka_protocol::plugin::ERRORS);
         }
diff --git a/crates/client/src/plugin.rs b/crates/client/src/plugin.rs
index 7505af90d6..160ae34acd 100644
--- a/crates/client/src/plugin.rs
+++ b/crates/client/src/plugin.rs
@@ -21,6 +21,33 @@ use crate::{Client, ClientError, RequestFailure};
 use maka_protocol::{Operation, plugin::*};
 
 impl Client {
+    pub async fn plugin_query(&self, input: Query) -> Result<QueryResult, RequestFailure> {
+        let value = self
+            .request(
+                Operation::PluginPlatformQuery,
+                serde_json::to_value(&input).expect("wire input"),
+            )
+            .await?;
+        let result: QueryResult = serde_json::from_value(value)
+            .map_err(|error| self.invalid_plugin_result(error.to_string()))?;
+        let actual = match &result {
+            QueryResult::Status(_) => View::Status,
+            QueryResult::Packages(_) => View::Packages,
+            QueryResult::Entries(_) => View::Entries,
+            QueryResult::Tools(_) => View::Tools,
+            QueryResult::Commands(_) => View::Commands,
+            QueryResult::Executors(_) => View::Executors,
+            QueryResult::TerminalViews(_) => View::TerminalViews,
+            QueryResult::Failures(_) => View::Failures,
+        };
+        if actual != input.view {
+            return Err(self.invalid_plugin_result(
+                "Plugin directory response does not match the requested view",
+            ));
+        }
+        Ok(result)
+    }
+
     /// Calls one operation on the current connection. Never binds a replacement or
     /// replays an unknown call; the caller owns document and stream lifetimes.
     pub async fn plugin_remote(
diff --git a/crates/client/tests/integration/plugin.rs b/crates/client/tests/integration/plugin.rs
index 172768e7d8..115e641ce1 100644
--- a/crates/client/tests/integration/plugin.rs
+++ b/crates/client/tests/integration/plugin.rs
@@ -187,3 +187,43 @@ async fn plugin_catalog_binds_page_progress_and_bundle_identity_to_the_request()
             .unwrap();
     }
 }
+
+#[tokio::test]
+async fn terminal_directory_rejects_a_different_valid_view_without_retrying() {
+    for valid in [true, false] {
+        let (client, _notices, mut reader, mut writer) = pair_with(maka_client::Operations).await;
+        let request = tokio::spawn({
+            let client = client.clone();
+            async move {
+                client
+                    .plugin_query(
+                        serde_json::from_value(
+                            json!({"view":"terminal_views","rootId":"profile","limit":16}),
+                        )
+                        .unwrap(),
+                    )
+                    .await
+            }
+        });
+        let frame = reader.read().await.unwrap().unwrap();
+        assert_eq!(frame["operation"], "plugin.platform.query");
+        let result = json!({"view":if valid { "terminal_views" } else { "tools" }, "items":[], "nextCursor":null});
+        writer.write(&json!({"requestId":frame["requestId"],"operation":frame["operation"],"ok":true,"result":result})).await.unwrap();
+        if valid {
+            request.await.unwrap().unwrap();
+            client.disconnect();
+        } else {
+            assert!(matches!(
+                request.await.unwrap(),
+                Err(RequestFailure::Unknown(ClientError::Protocol(_)))
+            ));
+        }
+        tokio::time::timeout(Duration::from_secs(1), client.closed())
+            .await
+            .unwrap();
+        assert!(
+            reader.read().await.unwrap().is_none(),
+            "directory failure cannot trigger a replacement request"
+        );
+    }
+}
diff --git a/crates/plugins/src/terminal_ui.rs b/crates/plugins/src/terminal_ui.rs
index a113a7172b..d8e7af9a8c 100644
--- a/crates/plugins/src/terminal_ui.rs
+++ b/crates/plugins/src/terminal_ui.rs
@@ -17,7 +17,9 @@
  * under the License.
  */
 
-//! Navigation metadata belongs to the Remote endpoint's exact registration.
+//! Native terminal views share the Remote endpoint's exact registration.
+
+pub mod page;
 
 use crate::Error;
 use serde::{Deserialize, Serialize};
@@ -34,6 +36,21 @@ pub struct Text {
     pub translations: BTreeMap<String, String>,
 }
 impl Text {
+    pub fn plain(value: impl Into<String>) -> Self {
+        Self {
+            fallback: value.into(),
+            translations: BTreeMap::new(),
+        }
+    }
+    pub fn localized(en: &str, zh_cn: &str, zh_tw: &str) -> Self {
+        Self {
+            fallback: en.into(),
+            translations: BTreeMap::from([
+                ("zh-CN".into(), zh_cn.into()),
+                ("zh-TW".into(), zh_tw.into()),
+            ]),
+        }
+    }
     pub fn resolve(&self, locale: &str) -> &str {
         self.translations
             .get(locale)
diff --git a/crates/plugins/src/terminal_ui/page.rs b/crates/plugins/src/terminal_ui/page.rs
new file mode 100644
index 0000000000..526add4b7f
--- /dev/null
+++ b/crates/plugins/src/terminal_ui/page.rs
@@ -0,0 +1,379 @@
+/*
+ * 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.
+ */
+
+use super::{Text, VERSION};
+use crate::Error;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::{BTreeMap, BTreeSet};
+
+/// Finite documents share Remote's payload budget; no executable expressions or nested widgets.
+pub const MAX_BYTES: usize = 64 * 1024;
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Page {
+    pub version: u32,
+    pub title: Text,
+    pub revision: String,
+    pub body: String,
+    pub rows: Vec<Row>,
+    pub fields: Vec<Field>,
+    pub actions: Vec<Action>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Row {
+    pub id: String,
+    pub title: Text,
+    pub description: String,
+    pub route: Value,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Field {
+    pub id: String,
+    pub label: Text,
+    pub enabled: bool,
+    pub control: Control,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum Control {
+    Toggle {
+        value: bool,
+    },
+    Text {
+        value: String,
+        max_bytes: usize,
+        multiline: bool,
+    },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Action {
+    pub id: String,
+    pub label: Text,
+    pub enabled: bool,
+    /// Only these fields are submitted; unrelated drafts are not implicit input.
+    pub fields: Vec<String>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum Request {
+    Read {
+        route: Value,
+    },
+    Submit {
+        route: Value,
+        revision: String,
+        action: String,
+        fields: BTreeMap<String, Value>,
+    },
+}
+
+/// A successful write is acknowledged independently of the following page read.
+/// Failure to refresh cannot turn a committed write into a retryable submission.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum Reply {
+    Page { page: Page },
+    Applied { route: Value },
+    Conflict,
+    Rejected { message: Text },
+}
+
+fn invalid() -> Error {
+    Error::Invalid("Invalid terminal page".into())
+}
+fn bounded(value: &impl Serialize, max: usize) -> Result<(), Error> {
+    if serde_json::to_vec(value).map_err(|_| invalid())?.len() > max {
+        return Err(invalid());
+    }
+    Ok(())
+}
+fn identifier(value: &str) -> Result<(), Error> {
+    if value.is_empty() || value.len() > 256 || !safe(value, false) {
+        return Err(invalid());
+    }
+    Ok(())
+}
+fn safe(value: &str, multiline: bool) -> bool {
+    !value.chars().any(|c| {
+        (c.is_control() && !(multiline && matches!(c, '\n' | '\t')))
+            || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
+    })
+}
+fn route(value: &Value) -> Result<(), Error> {
+    fn count(value: &Value, depth: usize, nodes: &mut usize) -> Result<(), Error> {
+        *nodes += 1;
+        if depth > 8 || *nodes > 128 {
+            return Err(invalid());
+        }
+        match value {
+            Value::Array(items) => {
+                for item in items {
+                    count(item, depth + 1, nodes)?;
+                }
+            }
+            Value::Object(items) => {
+                for (key, item) in items {
+                    if !safe(key, false) {
+                        return Err(invalid());
+                    }
+                    count(item, depth + 1, nodes)?;
+                }
+            }
+            Value::String(text) if !safe(text, false) => return Err(invalid()),
+            _ => {}
+        }
+        Ok(())
+    }
+    count(value, 0, &mut 0)?;
+    bounded(value, 8192)
+}
+
+impl Page {
+    pub fn validate(&self) -> Result<(), Error> {
+        if self.version != VERSION
+            || self.rows.len() > 64
+            || self.fields.len() > 16
+            || self.actions.len() > 16
+            || self.body.len() > 32768
+            || !safe(&self.body, true)
+        {
+            return Err(invalid());
+        }
+        self.title.validate()?;
+        identifier(&self.revision)?;
+        let mut ids = BTreeSet::new();
+        for row in &self.rows {
+            identifier(&row.id)?;
+            if !ids.insert(&row.id)
+                || row.description.len() > 1024
+                || !safe(&row.description, false)
+            {
+                return Err(invalid());
+            }
+            row.title.validate()?;
+            route(&row.route)?;
+        }
+        let mut fields = BTreeSet::new();
+        for field in &self.fields {
+            identifier(&field.id)?;
+            if !fields.insert(&field.id) {
+                return Err(invalid());
+            }
+            field.label.validate()?;
+            if let Control::Text {
+                value,
+                max_bytes,
+                multiline,
+            } = &field.control
+                && (*max_bytes == 0
+                    || *max_bytes > 16384
+                    || value.len() > *max_bytes
+                    || !safe(value, *multiline))
+            {
+                return Err(invalid());
+            }
+        }
+        let mut actions = BTreeSet::new();
+        for action in &self.actions {
+            identifier(&action.id)?;
+            action.label.validate()?;
+            let mut used = BTreeSet::new();
+            if !actions.insert(&action.id)
+                || action
+                    .fields
+                    .iter()
+                    .any(|id| !fields.contains(id) || !used.insert(id))
+            {
+                return Err(invalid());
+            }
+        }
+        bounded(self, MAX_BYTES - 64)
+    }
+
+    /// A presentation check, not authorization: the plugin must validate again.
+    pub fn submission(
+        &self,
+        route: Value,
+        action: &str,
+        fields: BTreeMap<String, Value>,
+    ) -> Result<Request, Error> {
+        self.validate()?;
+        let action = self
+            .actions
+            .iter()
+            .find(|item| item.id == action && item.enabled)
+            .ok_or_else(invalid)?;
+        if action.fields.len() != fields.len() {
+            return Err(invalid());
+        }
+        for id in &action.fields {
+            let field = self
+                .fields
+                .iter()
+                .find(|field| &field.id == id && field.enabled)
+                .ok_or_else(invalid)?;
+            let value = fields.get(id).ok_or_else(invalid)?;
+            match &field.control {
+                Control::Toggle { .. } if value.is_boolean() => {}
+                Control::Text {
+                    max_bytes,
+                    multiline,
+                    ..
+                } if value
+                    .as_str()
+                    .is_some_and(|text| text.len() <= *max_bytes && safe(text, *multiline)) => {}
+                _ => return Err(invalid()),
+            }
+        }
+        let request = Request::Submit {
+            route,
+            revision: self.revision.clone(),
+            action: action.id.clone(),
+            fields,
+        };
+        request.validate()?;
+        Ok(request)
+    }
+}
+impl Request {
+    pub fn validate(&self) -> Result<(), Error> {
+        match self {
+            Self::Read { route: value } => route(value)?,
+            Self::Submit {
+                route: value,
+                revision,
+                action,
+                fields,
+            } => {
+                route(value)?;
+                identifier(revision)?;
+                identifier(action)?;
+                if fields.len() > 16 {
+                    return Err(invalid());
+                }
+                for (id, value) in fields {
+                    identifier(id)?;
+                    match value {
+                        Value::Bool(_) => {}
+                        Value::String(text) if text.len() <= 16384 && safe(text, true) => {}
+                        _ => return Err(invalid()),
+                    }
+                }
+            }
+        }
+        bounded(self, MAX_BYTES)
+    }
+}
+impl Reply {
+    pub fn validate(&self) -> Result<(), Error> {
+        match self {
+            Self::Page { page } => page.validate()?,
+            Self::Applied { route: value } => route(value)?,
+            Self::Rejected { message } => message.validate()?,
+            Self::Conflict => {}
+        }
+        bounded(self, MAX_BYTES)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+
+    fn page() -> Page {
+        Page {
+            version: VERSION,
+            title: Text::plain("Preferences"),
+            revision: "r1".into(),
+            body: String::new(),
+            rows: vec![],
+            fields: vec![Field {
+                id: "enabled".into(),
+                label: Text::plain("Enabled"),
+                enabled: true,
+                control: Control::Toggle { value: true },
+            }],
+            actions: vec![Action {
+                id: "save".into(),
+                label: Text::plain("Save"),
+                enabled: true,
+                fields: vec!["enabled".into()],
+            }],
+        }
+    }
+    #[test]
+    fn page_contract_rejects_ambiguous_controls_unsafe_content_and_unbounded_routes() {
+        let valid = page();
+        valid.validate().unwrap();
+        let fields = BTreeMap::from([("enabled".into(), json!(false))]);
+        let request = valid
+            .submission(json!(null), "save", fields.clone())
+            .unwrap();
+        request.validate().unwrap();
+        for variant in 0..7 {
+            let mut invalid = valid.clone();
+            match variant {
+                0 => invalid.version += 1,
+                1 => invalid.fields.push(invalid.fields[0].clone()),
+                2 => invalid.actions[0].fields.push("absent".into()),
+                3 => invalid.body = "\u{001b}[2J".into(),
+                4 => invalid.body = "\u{202e}spoof".into(),
+                5 => invalid.body = "x".repeat(32769),
+                _ => invalid.actions.push(invalid.actions[0].clone()),
+            }
+            assert!(invalid.validate().is_err(), "{variant}");
+        }
+        assert!(valid.submission(json!(null), "other", fields).is_err());
+        assert!(
+            valid
+                .submission(
+                    json!(null),
+                    "save",
+                    BTreeMap::from([("enabled".into(), json!("false"))])
+                )
+                .is_err()
+        );
+        let mut deep = json!(null);
+        for _ in 0..10 {
+            deep = json!([deep]);
+        }
+        assert!(Request::Read { route: deep }.validate().is_err());
+        assert!(
+            Request::Read {
+                route: json!(vec![Value::Null; 129])
+            }
+            .validate()
+            .is_err()
+        );
+        let mut wire = serde_json::to_value(valid).unwrap();
+        wire["fields"][0]["control"]["kind"] = json!("execute");
+        assert!(serde_json::from_value::<Page>(wire).is_err());
+    }
+}
diff --git a/crates/runtime-host/tests/integration/plugin_remote/javascript.rs b/crates/runtime-host/tests/integration/plugin_remote/javascript.rs
index 81cc299554..88f78612c3 100644
--- a/crates/runtime-host/tests/integration/plugin_remote/javascript.rs
+++ b/crates/runtime-host/tests/integration/plugin_remote/javascript.rs
@@ -270,7 +270,26 @@ async fn scenario(vm: &str) {
             .await,
     );
     assert_eq!(last["items"][0]["method"], "terminal-extra");
-    assert!(last["nextCursor"].is_null());
+    // Builtins can contribute pages too; only this fixture's two registrations
+    // are fixed. Continue the real directory without assuming it ends here.
+    let mut cursor = last["nextCursor"].clone();
+    let mut cursors = std::collections::HashSet::new();
+    while !cursor.is_null() {
+        assert!(cursors.insert(cursor.as_str().unwrap().to_owned()));
+        assert!(cursors.len() < 64, "fixture directory must stay bounded");
+        let page = success(
+            peer.rpc(
+                "plugin.platform.query",
+                json!({
+                    "view":"terminal_views", "rootId":"profile", "limit":1, "cursor":cursor
+                }),
+            )
+            .await,
+        );
+        assert_eq!(page["items"].as_array().unwrap().len(), 1);
+        assert_ne!(page["items"][0]["packageId"], "example.remote");
+        cursor = page["nextCursor"].clone();
+    }
     // Discovery pins the actual registration; a native caller need not load any bundle.
     let native_binding = json!({"packageId":"example.remote", "method":"echo", "sessionId":null});
     assert_eq!(
diff --git a/crates/runtime-host/tests/integration/skills_plugin/native.rs b/crates/runtime-host/tests/integration/skills_plugin/native.rs
index 3add15a6b8..52378bef0d 100644
--- a/crates/runtime-host/tests/integration/skills_plugin/native.rs
+++ b/crates/runtime-host/tests/integration/skills_plugin/native.rs
@@ -17,6 +17,8 @@
  * under the License.
  */
 
+mod terminal;
+
 use super::{ClientFixture, Host, LocalListener, Peer, Provider, configure, converged, disabled};
 use maka_client::{Client, ClientError, Operations, RequestFailure};
 use maka_protocol::{
@@ -143,6 +145,7 @@ async fn native_candidates_page_by_session_and_reject_changed_catalog_and_retire
     assert_eq!(items.len(), 1);
     assert!(ids.insert(items[0].id.clone()));
     assert_eq!(ids.len(), MAX_ITEMS + 1);
+    terminal::exercise(&client, "candidates").await;
     // A real file change invalidates a cursor; no automatic restart hides the change.
     std::fs::write(
         fixture.workspace.join(".maka/skills/review-000/SKILL.md"),
diff --git a/crates/runtime-host/tests/integration/skills_plugin/native/terminal.rs b/crates/runtime-host/tests/integration/skills_plugin/native/terminal.rs
new file mode 100644
index 0000000000..d2f06537de
--- /dev/null
+++ b/crates/runtime-host/tests/integration/skills_plugin/native/terminal.rs
@@ -0,0 +1,205 @@
+/*
+ * 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.
+ */
+
+use maka_client::Client;
+use maka_plugins::terminal_ui::page::{Control, Page, Reply, Request};
+use maka_protocol::plugin::{Query, QueryResult, RemoteBinding, RemoteRequest, RemoteResult, View};
+use serde_json::{Value, json};
+use std::collections::{BTreeMap, BTreeSet};
+
+/// Discovery and mutation use the same real client/connection as candidate reads.
+pub(super) async fn exercise(client: &Client, session: &str) {
+    let directory = client
+        .plugin_query(Query {
+            view: View::TerminalViews,
+            root_id: Some(maka_plugins::composition::Scope::Profile),
+            cursor: None,
+            limit: None,
+        })
+        .await
+        .unwrap();
+    let QueryResult::TerminalViews(directory) = directory else {
+        panic!("terminal directory");
+    };
+    let view = directory
+        .items
+        .into_iter()
+        .find(|view| view.package_id == "maka.skills")
+        .unwrap();
+    assert_eq!(view.descriptor.title.resolve("zh-CN"), "技能");
+    let binding = RemoteBinding::Package {
+        package_id: view.package_id,
+        method: view.method,
+        session_id: Some(session.into()),
+    };
+    let RemoteResult::Document { document } = client
+        .plugin_remote(RemoteRequest::OpenDocument)
+        .await
+        .unwrap()
+    else {
+        panic!("document");
+    };
+    let call = |input: Request| RemoteRequest::Call {
+        binding: binding.clone(),
+        target: view.target.clone(),
+        document,
+        input: serde_json::to_value(input).unwrap(),
+    };
+    let decode = |result| {
+        let RemoteResult::Value { value } = result else {
+            panic!("value");
+        };
+        assert!(serde_json::to_vec(&value).unwrap().len() <= 64 * 1024);
+        let reply: Reply = serde_json::from_value(value).unwrap();
+        reply.validate().unwrap();
+        reply
+    };
+    // Traverse both the terminal's small windows and the domain catalog cursor.
+    let mut route = Value::Null;
+    let mut references = BTreeSet::new();
+    let mut chosen = None;
+    loop {
+        let Reply::Page { page } = decode(
+            client
+                .plugin_remote(call(Request::Read {
+                    route: route.clone(),
+                }))
+                .await
+                .unwrap(),
+        ) else {
+            panic!("list");
+        };
+        assert!(page.rows.len() <= 9);
+        for row in &page.rows {
+            if row.id == "next" {
+                continue;
+            }
+            assert!(references.insert(row.id.clone()), "repeated row");
+            if row.title.fallback == "Review 0" {
+                chosen = Some(row.route.clone());
+            }
+        }
+        let Some(next) = page.rows.iter().find(|row| row.id == "next") else {
+            break;
+        };
+        assert!(references.len() < 512, "bounded fixture must terminate");
+        route = next.route.clone();
+    }
+    assert!(references.len() >= 129);
+    let route = chosen.unwrap();
+    let Reply::Page { page } = decode(
+        client
+            .plugin_remote(call(Request::Read {
+                route: route.clone(),
+            }))
+            .await
+            .unwrap(),
+    ) else {
+        panic!("detail");
+    };
+    assert_eq!(page.fields.len(), 2);
+    let fields = BTreeMap::from([
+        ("enabled".into(), json!(false)),
+        ("pinned".into(), json!(true)),
+    ]);
+    let input = page
+        .submission(route.clone(), "save", fields.clone())
+        .unwrap();
+    assert!(matches!(
+        decode(client.plugin_remote(call(input.clone())).await.unwrap()),
+        Reply::Applied { .. }
+    ));
+    // A second writer must not partially apply either field from the old form.
+    assert!(matches!(
+        decode(
+            client
+                .plugin_remote(call(
+                    page.submission(
+                        route.clone(),
+                        "save",
+                        BTreeMap::from([
+                            ("enabled".into(), json!(true)),
+                            ("pinned".into(), json!(false))
+                        ])
+                    )
+                    .unwrap()
+                ))
+                .await
+                .unwrap()
+        ),
+        Reply::Conflict
+    ));
+    let Reply::Page { page: fresh } = decode(
+        client
+            .plugin_remote(call(Request::Read {
+                route: route.clone(),
+            }))
+            .await
+            .unwrap(),
+    ) else {
+        panic!("fresh detail");
+    };
+    assert_ne!(fresh.revision, page.revision);
+    assert_eq!(values(&fresh), fields);
+    // Unknown or extra fields are rejected at the plugin boundary too.
+    let mut forged = input;
+    if let Request::Submit {
+        revision, fields, ..
+    } = &mut forged
+    {
+        *revision = fresh.revision.clone();
+        fields.insert("execute".into(), json!(true));
+    }
+    assert!(client.plugin_remote(call(forged)).await.is_err());
+    assert!(matches!(
+        decode(
+            client
+                .plugin_remote(call(
+                    fresh
+                        .submission(
+                            route,
+                            "save",
+                            BTreeMap::from([
+                                ("enabled".into(), json!(true)),
+                                ("pinned".into(), json!(false))
+                            ])
+                        )
+                        .unwrap()
+                ))
+                .await
+                .unwrap()
+        ),
+        Reply::Applied { .. }
+    ));
+    client
+        .plugin_remote(RemoteRequest::CloseDocument { document })
+        .await
+        .unwrap();
+}
+fn values(page: &Page) -> BTreeMap<String, Value> {
+    page.fields
+        .iter()
+        .map(|field| {
+            let Control::Toggle { value } = field.control else {
+                panic!("toggle");
+            };
+            (field.id.clone(), json!(value))
+        })
+        .collect()
+}
diff --git a/crates/skills/src/api/mutation.rs b/crates/skills/src/api/mutation.rs
index 68603b3dac..e026b366bc 100644
--- a/crates/skills/src/api/mutation.rs
+++ b/crates/skills/src/api/mutation.rs
@@ -44,6 +44,12 @@ pub enum Mutation {
         reference: String,
         enabled: bool,
     },
+    SetPreferences {
+        #[serde(rename = "ref")]
+        reference: String,
+        enabled: bool,
+        pinned: bool,
+    },
     SetPinned {
         #[serde(rename = "ref")]
         reference: String,
@@ -55,6 +61,7 @@ impl Mutation {
         match self {
             Self::SetEnabled { reference, .. }
             | Self::SetPinned { reference, .. }
+            | Self::SetPreferences { reference, .. }
             | Self::Delete { reference } => Some(reference),
             Self::UpdateManaged(update) => Some(&update.reference),
             Self::CreateStarter | Self::Install { .. } => None,
diff --git a/crates/skills/src/plugin.rs b/crates/skills/src/plugin.rs
index b75dae9e7f..99d3a454fe 100644
--- a/crates/skills/src/plugin.rs
+++ b/crates/skills/src/plugin.rs
@@ -43,6 +43,7 @@ mod preferences;
 mod preview;
 pub mod remote;
 mod snapshot;
+mod terminal;
 mod tools;
 mod user;
 pub use snapshot::{InputPreparation, Snapshot};
@@ -206,6 +207,7 @@ impl Plugin for Builtin {
                 .map_err(|error| error.to_string())?;
             let mut staged = Staged::default();
             tools::publish(&skills, &mut staged)?;
+            terminal::publish(&skills, &mut staged)?;
             if let Some(client) = client {
                 remote::publish(&skills, &mut staged, client.clone())?;
                 services
diff --git a/crates/skills/src/plugin/mutation.rs b/crates/skills/src/plugin/mutation.rs
index 893ff37cfe..d7adae67e5 100644
--- a/crates/skills/src/plugin/mutation.rs
+++ b/crates/skills/src/plugin/mutation.rs
@@ -73,7 +73,9 @@ impl Skills {
         }
         if !matches!(
             input.mutation,
-            Mutation::SetEnabled { .. } | Mutation::SetPinned { .. }
+            Mutation::SetEnabled { .. }
+                | Mutation::SetPinned { .. }
+                | Mutation::SetPreferences { .. }
         ) {
             return self
                 .mutate_files(input, workspace, workspace_files, sources, revision)
@@ -108,6 +110,12 @@ impl Skills {
         match input.mutation {
             Mutation::SetEnabled { enabled, .. } => next.enabled = enabled,
             Mutation::SetPinned { pinned, .. } => next.pinned = pinned,
+            Mutation::SetPreferences {
+                enabled, pinned, ..
+            } => {
+                next.enabled = enabled;
+                next.pinned = pinned;
+            }
             _ => unreachable!("preference mutation"),
         }
         if prior == next {
diff --git a/crates/skills/src/plugin/terminal.rs b/crates/skills/src/plugin/terminal.rs
new file mode 100644
index 0000000000..c62f9aec78
--- /dev/null
+++ b/crates/skills/src/plugin/terminal.rs
@@ -0,0 +1,302 @@
+/*
+ * 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.
+ */
+
+use super::{ID, Skills, remote::failure};
+use crate::api::*;
+use futures_util::future::BoxFuture;
+use maka_plugins::{
+    contributions::Staged,
+    remote::{Caller, Endpoint, Error, Handler, Method, key},
+    terminal_ui::{
+        Context, Descriptor, Text, VERSION,
+        page::{Action, Control, Field, Page, Reply, Request, Row},
+    },
+};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::sync::Arc;
+
+const WINDOW: usize = 8;
+
+#[derive(Clone, Default, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+struct Route {
+    cursor: Option<Cursor>,
+    offset: usize,
+    reference: Option<String>,
+}
+#[derive(Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Cursor {
+    revision: String,
+    cursor: String,
+}
+
+pub(super) fn publish(skills: &Skills, staged: &mut Staged) -> Result<(), String> {
+    let endpoint = Endpoint::standalone(Handler::Method(Arc::new(View(skills.clone()))))
+        .with_terminal_view(Descriptor {
+            version: VERSION,
+            title: title(),
+            context: Context::Session,
+        })
+        .map_err(|e| e.to_string())?;
+    staged
+        .insert(key(ID, "terminal").map_err(|e| e.to_string())?, endpoint)
+        .map_err(|e| e.to_string())
+}
+fn title() -> Text {
+    Text::localized("Skills", "技能", "技能")
+}
+
+struct View(Skills);
+impl Method for View {
+    fn call(&self, input: Value, caller: Caller) -> BoxFuture<'static, Result<Value, Error>> {
+        let skills = self.0.clone();
+        Box::pin(async move {
+            let request: Request = serde_json::from_value(input).map_err(invalid)?;
+            request.validate().map_err(invalid)?;
+            let view = caller.views.session().await?;
+            let context = WorkspaceContext {
+                workspace: view.workspace.target.clone(),
+            };
+            let reply = match request {
+                Request::Read { route } => {
+                    let route: Route = if route.is_null() {
+                        Route::default()
+                    } else {
+                        serde_json::from_value(route).map_err(invalid)?
+                    };
+                    if route.offset >= MAX_ITEMS || !route.offset.is_multiple_of(WINDOW) {
+                        return Err(invalid("Invalid Skills page offset"));
+                    }
+                    let input = match &route.cursor {
+                        None => CatalogInput::Start {
+                            context,
+                            view: CatalogView::Governance,
+                        },
+                        Some(cursor) => CatalogInput::Continue {
+                            context,
+                            view: CatalogView::Governance,
+                            revision: cursor.revision.clone(),
+                            cursor: cursor.cursor.clone(),
+                        },
+                    };
+                    match skills
+                        .query(&input, view.workspace, view.files)
+                        .await
+                        .map_err(failure)?
+                    {
+                        CatalogResult::RevisionChanged { .. } => Reply::Conflict,
+                        CatalogResult::Page {
+                            revision,
+                            items,
+                            next_cursor,
+                            ..
+                        } => project(route, revision, items, next_cursor)?,
+                    }
+                }
+                Request::Submit {
+                    route,
+                    revision,
+                    action,
+                    fields,
+                } => {
+                    let route: Route = serde_json::from_value(route).map_err(invalid)?;
+                    let reference = route.reference.ok_or_else(|| invalid("Select a Skill"))?;
+                    if action != "save" || fields.len() != 2 {
+                        return Err(invalid("Unknown Skill action or fields"));
+                    }
+                    let enabled = fields
+                        .get("enabled")
+                        .and_then(Value::as_bool)
+                        .ok_or_else(|| invalid("Invalid enabled state"))?;
+                    let pinned = fields
+                        .get("pinned")
+                        .and_then(Value::as_bool)
+                        .ok_or_else(|| invalid("Invalid pin state"))?;
+                    let mutation = Mutation::SetPreferences {
+                        reference,
+                        enabled,
+                        pinned,
+                    };
+                    // The original service serializes the mutation, checks its revision,
+                    // resolves the reference again, and owns accepted-write cleanup.
+                    match skills
+                        .mutate(
+                            MutateInput {
+                                grant: None,
+                                context,
+                                expected_revision: revision,
+                                mutation,
+                            },
+                            view.workspace,
+                            view.files,
+                        )
+                        .await
+                        .map_err(failure)?
+                        .outcome
+                    {
+                        MutationOutcome::Committed { .. } | MutationOutcome::Unchanged { .. } => {
+                            Reply::Applied { route: Value::Null }
+                        }
+                        MutationOutcome::RevisionConflict { .. } => Reply::Conflict,
+                        MutationOutcome::Rejected { reason } => Reply::Rejected {
+                            message: rejection(reason),
+                        },
+                    }
+                }
+            };
+            reply.validate().map_err(invalid)?;
+            serde_json::to_value(reply).map_err(invalid)
+        })
+    }
+}
+
+fn project(
+    route: Route,
+    revision: String,
+    items: Vec<CatalogItem>,
+    next_cursor: Option<String>,
+) -> Result<Reply, Error> {
+    let mut page = Page {
+        version: VERSION,
+        title: title(),
+        revision: revision.clone(),
+        body: String::new(),
+        rows: vec![],
+        fields: vec![],
+        actions: vec![],
+    };
+    if let Some(reference) = &route.reference {
+        let item = items.iter().find_map(|item| match item {
+            CatalogItem::Skill(item) | CatalogItem::DiscoveryDiagnostic(item)
+                if &item.reference == reference =>
+            {
+                Some(item)
+            }
+            _ => None,
+        });
+        let Some(item) = item else {
+            return Ok(Reply::Rejected {
+                message: rejection(MutationRejection::NotFound),
+            });
+        };
+        page.title = Text::plain(display(&item.name, 256, false));
+        page.body = display(&item.description, 8192, true);
+        let enabled = !item.needs_review && item.runtime_status != SkillRuntimeStatus::StateError;
+        for (id, label, value) in [
+            (
+                "enabled",
+                Text::localized("Enabled", "启用", "啟用"),
+                item.enabled,
+            ),
+            (
+                "pinned",
+                Text::localized("Pinned", "置顶", "置頂"),
+                item.pinned,
+            ),
+        ] {
+            page.fields.push(Field {
+                id: id.into(),
+                label,
+                enabled,
+                control: Control::Toggle { value },
+            });
+        }
+        page.actions.push(Action {
+            id: "save".into(),
+            label: Text::localized("Save", "保存", "儲存"),
+            enabled,
+            fields: vec!["enabled".into(), "pinned".into()],
+        });
+    } else {
+        for item in items.iter().skip(route.offset).take(WINDOW) {
+            let item = match item {
+                CatalogItem::Skill(item) | CatalogItem::DiscoveryDiagnostic(item) => item,
+                _ => return Err(invalid("Unexpected Skills catalog row")),
+            };
+            page.rows.push(Row {
+                id: maka_runtime::artifact::content_digest(item.reference.as_bytes()),
+                title: Text::plain(display(&item.name, 256, false)),
+                description: display(&item.description, 160, false),
+                route: serde_json::to_value(Route {
+                    reference: Some(item.reference.clone()),
+                    ..route.clone()
+                })
+                .map_err(invalid)?,
+            });
+        }
+        let next = if route.offset + WINDOW < items.len() {
+            Some(Route {
+                offset: route.offset + WINDOW,
+                ..route
+            })
+        } else {
+            next_cursor.map(|cursor| Route {
+                cursor: Some(Cursor { revision, cursor }),
+                ..Route::default()
+            })
+        };
+        if let Some(next) = next {
+            page.rows.push(Row {
+                id: "next".into(),
+                title: Text::localized("Next", "下一页", "下一頁"),
+                description: String::new(),
+                route: serde_json::to_value(next).map_err(invalid)?,
+            });
+        }
+    }
+    Ok(Reply::Page { page })
+}
+fn display(value: &str, max: usize, multiline: bool) -> String {
+    let mut text = String::new();
+    for c in value.chars().filter(|c| {
+        (!c.is_control() || multiline && matches!(c, '\n' | '\t'))
+            && !matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
+    }) {
+        if text.len() + c.len_utf8() > max {
+            break;
+        }
+        text.push(c);
+    }
+    if !multiline && text.trim().is_empty() {
+        text = "—".into();
+    }
+    text
+}
+fn invalid(error: impl ToString) -> Error {
+    Error::Invalid(error.to_string())
+}
+fn rejection(reason: MutationRejection) -> Text {
+    match reason {
+        MutationRejection::NotFound => {
+            Text::localized("Skill no longer exists", "技能已不存在", "技能已不存在")
+        }
+        MutationRejection::NeedsReview => Text::localized(
+            "Review this Skill before changing it",
+            "请先审核此技能",
+            "請先審核此技能",
+        ),
+        _ => Text::localized(
+            "Skill preference could not be saved; refresh and check its availability",
+            "无法保存技能偏好,请刷新并检查其可用性",
+            "無法儲存技能偏好,請重新整理並檢查其可用性",
+        ),
+    }
+}
diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml
index 5d57b1e30e..e0bc5390ee 100644
--- a/crates/tui/Cargo.toml
+++ b/crates/tui/Cargo.toml
@@ -34,6 +34,7 @@ sys-locale.workspace = true
 maka-client.workspace = true
 maka-event-log.workspace = true
 maka-protocol.workspace = true
+maka-plugins.workspace = true
 maka-runtime.workspace = true
 maka-skills.workspace = true
 maka-sandbox.workspace = true
diff --git a/crates/tui/locales/en.ftl b/crates/tui/locales/en.ftl
index 6f67699b00..94ea0d06b0 100644
--- a/crates/tui/locales/en.ftl
+++ b/crates/tui/locales/en.ftl
@@ -804,3 +804,18 @@ recap-save-failed = Could not save or confirm the original request locally. Noth
 recap-forget = Forget retry
 recap-confirm-forget = Confirm forget
 recap-forget-note = Forgetting only removes local retry information. It does not cancel work already accepted by Host. A later new generation may incur another charge. Confirm only if you want to abandon this retry.
+route-extensions = Plugin pages
+extensions-discard = Discard draft and reopen
+extensions-open = Open
+extensions-edit = Edit
+extensions-save = Save
+extensions-loading = Loading…
+extensions-empty = No terminal pages are available.
+extensions-needs-session = Open from a session to manage its workspace.
+extensions-field-limit = This field has reached its size limit.
+extensions-conflict = This page changed. Your draft is preserved; reopen before saving.
+extensions-unknown = The result is unconfirmed. Check the current state before making another change.
+extensions-disconnected = Disconnected. Your draft is preserved; reopen after reconnecting.
+extensions-failed = This page could not be loaded or saved. Your draft is preserved.
+extensions-back = Back
+extensions-invalid-fields = Check the fields before saving.
diff --git a/crates/tui/locales/zh-CN.ftl b/crates/tui/locales/zh-CN.ftl
index fa4cc3ed82..acab0197f5 100644
--- a/crates/tui/locales/zh-CN.ftl
+++ b/crates/tui/locales/zh-CN.ftl
@@ -804,3 +804,18 @@ recap-save-failed = 无法保存或确认原请求的本地记录,未发出新
 recap-forget = 放弃重试
 recap-confirm-forget = 确认放弃
 recap-forget-note = 放弃只会删除本地重试信息,不会取消 Host 已接受的工作。之后重新生成可能再次收费。确认后才能放弃此次重试。
+route-extensions = 插件页面
+extensions-discard = 放弃草稿并重新打开
+extensions-open = 打开
+extensions-edit = 编辑
+extensions-save = 保存
+extensions-loading = 正在加载…
+extensions-empty = 暂无可用的终端页面。
+extensions-needs-session = 从会话中打开,即可管理其工作区。
+extensions-field-limit = 此字段已达到大小上限。
+extensions-conflict = 页面已变化。草稿已保留,请重新打开后再保存。
+extensions-unknown = 结果尚未确认。请先查看当前状态,再进行新的修改。
+extensions-disconnected = 连接已断开,草稿已保留。重新连接后请重新打开。
+extensions-failed = 无法加载或保存此页面,草稿已保留。
+extensions-back = 返回
+extensions-invalid-fields = 请检查表单内容后再保存。
diff --git a/crates/tui/locales/zh-TW.ftl b/crates/tui/locales/zh-TW.ftl
index 95bc7caa0f..64487f7c79 100644
--- a/crates/tui/locales/zh-TW.ftl
+++ b/crates/tui/locales/zh-TW.ftl
@@ -804,3 +804,18 @@ recap-save-failed = 無法儲存或確認原請求的本機記錄,未發出新
 recap-forget = 放棄重試
 recap-confirm-forget = 確認放棄
 recap-forget-note = 放棄只會刪除本機重試資訊,不會取消 Host 已接受的工作。之後重新產生可能再次收費。確認後才能放棄此次重試。
+route-extensions = 外掛頁面
+extensions-discard = 放棄草稿並重新開啟
+extensions-open = 開啟
+extensions-edit = 編輯
+extensions-save = 儲存
+extensions-loading = 載入中…
+extensions-empty = 暫無可用的終端頁面。
+extensions-needs-session = 從對話中開啟,即可管理其工作區。
+extensions-field-limit = 此欄位已達到大小上限。
+extensions-conflict = 頁面已變更。草稿已保留,請重新開啟後再儲存。
+extensions-unknown = 結果尚未確認。請先查看目前狀態,再進行新的修改。
+extensions-disconnected = 連線已中斷,草稿已保留。重新連線後請重新開啟。
+extensions-failed = 無法載入或儲存此頁面,草稿已保留。
+extensions-back = 返回
+extensions-invalid-fields = 請檢查表單內容後再儲存。
diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs
index 5d5f6b114a..fda4bc528b 100644
--- a/crates/tui/src/app.rs
+++ b/crates/tui/src/app.rs
@@ -64,6 +64,7 @@ pub enum Action {
     Recap(crate::pages::recap::Command),
     Attachment(crate::pages::attachments::Command),
     References,
+    Extension(crate::pages::extensions::Command),
     Skills(crate::pages::skills::Command),
     Revision(crate::pages::revision::Command),
     ToggleSymbols,
@@ -134,6 +135,7 @@ pub struct App {
     pub recap: crate::pages::recap::State,
     pub attachments: crate::pages::attachments::State,
     pub skills: crate::pages::skills::State,
+    pub extensions: crate::pages::extensions::State,
     pub directories:
         std::collections::BTreeMap<String, Vec<maka_protocol::turn::DirectoryReference>>,
     pub revision: crate::pages::revision::State,
@@ -184,6 +186,7 @@ impl App {
             recap: Default::default(),
             attachments: Default::default(),
             skills: Default::default(),
+            extensions: Default::default(),
             directories: Default::default(),
             revision: Default::default(),
             onboarding: Default::default(),
@@ -222,6 +225,10 @@ impl App {
             (Action::Visit(Route::Host), "command-host"),
             (Action::Visit(Route::Settings), "command-settings"),
             (Action::Visit(Route::Connections), "route-connections"),
+            (
+                Action::Extension(crate::pages::extensions::Command::Open),
+                "route-extensions",
+            ),
             (
                 Action::Onboard(crate::pages::onboarding::Command::Open),
                 "onboard-title",
@@ -234,6 +241,15 @@ impl App {
             (Action::ToggleSidebar, "command-sidebar"),
             (Action::Quit, "command-quit"),
         ];
+        if self.navigation.current() == Route::Extensions {
+            commands.extend(self.page_actions().into_iter().filter_map(|action| {
+                if let Action::Extension(command) = &action {
+                    Some((action.clone(), command.label()))
+                } else {
+                    None
+                }
+            }));
+        }
         commands.extend(self.management_commands());
         commands.extend(self.branch_commands());
         commands.extend(self.recap_commands());
@@ -369,6 +385,12 @@ impl App {
     }
     pub fn page_actions(&self) -> Vec<Action> {
         let mut actions = match self.navigation.current() {
+            Route::Extensions => vec![
+                Action::Extension(crate::pages::extensions::Command::Back),
+                Action::Extension(crate::pages::extensions::Command::Refresh),
+                Action::Extension(crate::pages::extensions::Command::Next),
+                Action::Extension(crate::pages::extensions::Command::Discard),
+            ],
             Route::Connections => self.connection_actions(),
             Route::Projects => self.project_actions(),
             Route::Inbox => {
@@ -421,6 +443,7 @@ impl App {
                 actions
             }
             Route::Host => vec![
+                Action::Extension(crate::pages::extensions::Command::Open),
                 if matches!(self.connection, ConnectionState::Connected { .. }) {
                     Action::Refresh
                 } else {
@@ -546,6 +569,9 @@ impl App {
     }
     fn refresh_action(&self) -> Option<Action> {
         match self.navigation.current() {
+            Route::Extensions => Some(Action::Extension(
+                crate::pages::extensions::Command::Refresh,
+            )),
             Route::Connections => Some(Action::Connection(
                 crate::pages::connections::Command::Refresh,
             )),
@@ -597,6 +623,7 @@ impl App {
             Action::Attachment(command) => return self.attachment_action(command),
             Action::References => self.open_references(),
             Action::Skills(command) => self.skills_action(command),
+            Action::Extension(command) => self.extensions_action(command),
             Action::Branch(command) => return self.branch_action(command),
             Action::Recap(command) => return self.recap_action(command),
             Action::Revision(command) => return self.revision_action(command),
@@ -782,6 +809,9 @@ impl App {
         None
     }
     pub fn enabled(&self, action: &Action) -> bool {
+        if let Action::Extension(command) = action {
+            return self.extensions_enabled(command);
+        }
         if let Action::Skills(command) = action {
             return self.skills_enabled(command);
         }
@@ -1059,6 +1089,7 @@ impl App {
     }
 
     pub fn invalidate_editor_geometry(&mut self) {
+        self.extensions.invalidate_geometry();
         self.modal_area = None;
         if let Some(editor) = &mut self.theme.editor {
             editor.invalidate();
@@ -1398,6 +1429,9 @@ impl App {
             }
             return (outcome, None);
         }
+        if self.extensions_input(&event) {
+            return (true, None);
+        }
         let action = match event {
             Event::Resize(_, _) => {
                 self.chat.area = None;
@@ -1522,6 +1556,7 @@ impl App {
                                                 | Route::Inbox
                                                 | Route::Projects
                                                 | Route::Connections
+                                                | Route::Extensions
                                         ) {
                                         Focus::List
                                     } else if !backwards
@@ -1567,6 +1602,7 @@ impl App {
                                                 | Route::Inbox
                                                 | Route::Projects
                                                 | Route::Connections
+                                                | Route::Extensions
                                         ) {
                                         Focus::List
                                     } else if backwards
diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs
index bf194521fc..794e4e6a9a 100644
--- a/crates/tui/src/lib.rs
+++ b/crates/tui/src/lib.rs
@@ -48,6 +48,10 @@ pub struct Options {
 }
 
 enum Completed {
+    Extension(
+        pages::extensions::Request,
+        Result<pages::extensions::Output, pages::extensions::io::Failure>,
+    ),
     Skills(
         pages::skills::Request,
         Result<
@@ -418,6 +422,13 @@ pub async fn run(options: Options) -> Result<(), Error> {
                     Completed::Session(request, result)
                 });
             }
+            if let Some(request) = app.extensions_request() {
+                let client = client.clone();
+                jobs.spawn(async move {
+                    let result = pages::extensions::execute(&client, &request).await;
+                    Completed::Extension(request, result)
+                });
+            }
             if let Some(request) = app.skills_request() {
                 let client = client.clone();
                 jobs.spawn(async move {
@@ -600,6 +611,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
                 Action::Quit => {
                     app.attachments.disconnect();
                     app.skills.disconnect();
+                    app.extensions.disconnect();
                     app.recap.disconnect();
                     app.branch.disconnect();
                     app.revision.disconnect();
@@ -629,6 +641,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
                     app.abandon_pending_submissions();
                     app.attachments.disconnect();
                     app.skills.disconnect();
+                    app.extensions.disconnect();
                     app.recap.disconnect();
                     app.creating = false;
                     jobs = JoinSet::new();
@@ -952,6 +965,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
                         if let Some(state) = &mut state { state.changed(); }
                     },
                     Some(Ok(Completed::Directory(request, result))) => app.directory_completed(request, result),
+                    Some(Ok(Completed::Extension(request, result))) => app.extensions_complete(request, result),
                     Some(Ok(Completed::Skills(request, result))) => app.skills_completed(request, result),
                     Some(Ok(Completed::ChooseProject(request, result))) => app.choose_project_completed(request, result),
                     Some(Ok(Completed::Locations(request,result))) => app.locations_completed(request,result),
@@ -1126,7 +1140,8 @@ pub async fn run(options: Options) -> Result<(), Error> {
                 app.abandon_pending_submissions();
                 app.attachments.disconnect();
                 app.skills.disconnect();
-                    app.recap.disconnect();
+                app.extensions.disconnect();
+                app.recap.disconnect();
                 app.abandon_management();
                 app.branch.disconnect();
                     app.revision.disconnect();
@@ -1150,6 +1165,7 @@ pub async fn run(options: Options) -> Result<(), Error> {
     }
     app.attachments.disconnect();
     app.skills.disconnect();
+    app.extensions.disconnect();
     app.recap.disconnect();
     attachment_jobs.abort_all();
     jobs.abort_all();
diff --git a/crates/tui/src/navigation.rs b/crates/tui/src/navigation.rs
index d6ab2d508b..fa8881bd65 100644
--- a/crates/tui/src/navigation.rs
+++ b/crates/tui/src/navigation.rs
@@ -38,10 +38,11 @@ pub enum Route {
     Inbox,
     Projects,
     Connections,
+    Extensions,
 }
 
 impl Route {
-    pub const PAGE_COUNT: usize = Self::ALL.len() + 1;
+    pub const PAGE_COUNT: usize = Self::ALL.len() + 2;
     pub const ALL: [Self; 6] = [
         Self::Workspace,
         Self::Host,
@@ -60,12 +61,14 @@ impl Route {
             Self::Inbox => "route-inbox",
             Self::Projects => "route-projects",
             Self::Connections => "route-connections",
+            Self::Extensions => "route-extensions",
         }
     }
     pub fn section(&self) -> Self {
         match self {
             Self::Session(_) => Self::Workspace,
             Self::Connections => Self::Settings,
+            Self::Extensions => Self::Host,
             _ => self.clone(),
         }
     }
diff --git a/crates/tui/src/navigation/state.rs b/crates/tui/src/navigation/state.rs
index b6736e76a6..dd2f95f8cb 100644
--- a/crates/tui/src/navigation/state.rs
+++ b/crates/tui/src/navigation/state.rs
@@ -68,7 +68,11 @@ impl Saved {
             Focus::Navigation => true,
             Focus::List => matches!(
                 route,
-                Route::Workspace | Route::Inbox | Route::Projects | Route::Connections
+                Route::Workspace
+                    | Route::Inbox
+                    | Route::Projects
+                    | Route::Connections
+                    | Route::Extensions
             ),
             Focus::Composer | Focus::Transcript => matches!(route, Route::Session(_)),
             Focus::Page => matches!(route, Route::Settings | Route::Host | Route::Help),
@@ -108,7 +112,11 @@ impl State {
         let focus = match (route, self.focus) {
             (Route::Session(_), Focus::Page | Focus::Queue) => Focus::Composer,
             (
-                Route::Workspace | Route::Inbox | Route::Projects | Route::Connections,
+                Route::Workspace
+                | Route::Inbox
+                | Route::Projects
+                | Route::Connections
+                | Route::Extensions,
                 Focus::Page,
             ) => Focus::List,
             (_, focus) => focus,
@@ -179,7 +187,11 @@ impl App {
             self.inbox.selected = self.inbox.items.first().map(|item| item.id.clone());
         }
         self.focus = match route {
-            Route::Workspace | Route::Inbox | Route::Projects | Route::Connections => Focus::List,
+            Route::Workspace
+            | Route::Inbox
+            | Route::Projects
+            | Route::Connections
+            | Route::Extensions => Focus::List,
             Route::Session(_) => Focus::Composer,
             _ => Focus::Page,
         };
diff --git a/crates/tui/src/pages.rs b/crates/tui/src/pages.rs
index 0dced15282..6af0d2f68b 100644
--- a/crates/tui/src/pages.rs
+++ b/crates/tui/src/pages.rs
@@ -22,6 +22,7 @@ pub mod branch;
 pub mod chat;
 pub mod commands;
 pub mod connections;
+pub mod extensions;
 pub mod interactions;
 pub mod manage;
 pub mod onboarding;
diff --git a/crates/tui/src/pages/extensions.rs b/crates/tui/src/pages/extensions.rs
new file mode 100644
index 0000000000..bb9ab3f055
--- /dev/null
+++ b/crates/tui/src/pages/extensions.rs
@@ -0,0 +1,821 @@
+/*
+ * 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.
+ */
+
+pub(crate) mod io;
+mod view;
+pub use io::{Output, execute};
+pub use view::draw;
+
+use crate::{
+    app::{Action, App, ConnectionState, Focus},
+    editor::Editor,
+    navigation::Route,
+};
+use maka_plugins::terminal_ui::{
+    Context,
+    page::{Control, Page, Reply, Request as Input},
+};
+use maka_protocol::plugin::TerminalViewProjection;
+use serde_json::Value;
+use std::collections::{BTreeMap, VecDeque};
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Command {
+    Open,
+    Choose(usize),
+    Row(usize),
+    Field(usize),
+    Submit(usize),
+    Back,
+    Refresh,
+    Discard,
+    Next,
+}
+impl Command {
+    pub fn label(&self) -> &'static str {
+        match self {
+            Self::Open | Self::Choose(_) => "route-extensions",
+            Self::Back => "extensions-back",
+            Self::Refresh => "command-refresh",
+            Self::Discard => "extensions-discard",
+            Self::Next => "sessions-next",
+            Self::Row(_) => "extensions-open",
+            Self::Field(_) => "extensions-edit",
+            Self::Submit(_) => "extensions-save",
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct Request {
+    generation: u64,
+    root: String,
+    epoch: String,
+    session: Option<String>,
+    pub(super) work: Work,
+}
+#[derive(Clone)]
+pub(super) enum Work {
+    Directory(Option<String>),
+    Page {
+        view: Box<TerminalViewProjection>,
+        input: Input,
+    },
+}
+pub(super) enum Message {
+    Local(&'static str),
+    Remote(maka_plugins::terminal_ui::Text),
+}
+
+#[derive(Default)]
+pub struct State {
+    generation: u64,
+    session: Option<String>,
+    pub(super) directory: Vec<TerminalViewProjection>,
+    next: Option<String>,
+    pub(super) view: Option<TerminalViewProjection>,
+    pub(super) page: Option<Page>,
+    route: Value,
+    history: VecDeque<Value>,
+    pub(super) drafts: BTreeMap<String, Value>,
+    pub(super) editors: BTreeMap<String, Editor>,
+    pending: Option<Work>,
+    pub(super) busy: bool,
+    writing: bool,
+    pub(super) blocked: bool,
+    pub(super) message: Option<Message>,
+    pub(super) selected: usize,
+    pub(super) top: usize,
+    pub(super) reveal: bool,
+    pub(super) area: Option<ratatui::layout::Rect>,
+}
+impl State {
+    fn dirty_field(&self, field: &maka_plugins::terminal_ui::page::Field) -> bool {
+        let value = match &field.control {
+            Control::Toggle { value } => Value::Bool(*value),
+            Control::Text { value, .. } => Value::String(value.clone()),
+        };
+        self.drafts.get(&field.id) != Some(&value)
+    }
+    fn dirty(&self) -> bool {
+        self.page
+            .as_ref()
+            .is_some_and(|page| page.fields.iter().any(|field| self.dirty_field(field)))
+    }
+    pub fn invalidate_geometry(&mut self) {
+        self.area = None;
+        for editor in self.editors.values_mut() {
+            editor.invalidate_geometry();
+        }
+    }
+    pub fn disconnect(&mut self) {
+        self.generation += 1;
+        self.pending = None;
+        self.busy = false;
+        self.blocked = self.view.is_some();
+        // Retain drafts, but revoke every operation on the old registration.
+        self.message = Some(Message::Local(if self.writing {
+            "extensions-unknown"
+        } else {
+            "extensions-disconnected"
+        }));
+        self.writing = false;
+    }
+    fn read(&mut self) {
+        self.pending = self.view.clone().map(|view| Work::Page {
+            view: Box::new(view),
+            input: Input::Read {
+                route: self.route.clone(),
+            },
+        });
+        self.selected = 0;
+        self.top = 0;
+        self.reveal = true;
+        self.area = None;
+    }
+    fn install(&mut self, page: Page) {
+        self.drafts.clear();
+        self.editors.clear();
+        for field in &page.fields {
+            let value = match &field.control {
+                Control::Toggle { value } => Value::Bool(*value),
+                Control::Text {
+                    value, max_bytes, ..
+                } => {
+                    let mut editor = Editor::bounded(*max_bytes, "extensions-field-limit");
+                    editor.insert(value);
+                    editor.clear_history();
+                    self.editors.insert(field.id.clone(), editor);
+                    Value::String(value.clone())
+                }
+            };
+            self.drafts.insert(field.id.clone(), value);
+        }
+        self.page = Some(page);
+        self.selected = 0;
+        self.top = 0;
+        self.reveal = true;
+        self.blocked = false;
+    }
+    fn controls(&self) -> usize {
+        self.page.as_ref().map_or(self.directory.len(), |page| {
+            page.rows.len() + page.fields.len() + page.actions.len()
+        })
+    }
+    fn selected_command(&self) -> Option<Command> {
+        if let Some(page) = &self.page {
+            let index = self.selected;
+            if index < page.rows.len() {
+                Some(Command::Row(index))
+            } else if index < page.rows.len() + page.fields.len() {
+                Some(Command::Field(index - page.rows.len()))
+            } else {
+                Some(Command::Submit(index - page.rows.len() - page.fields.len()))
+            }
+        } else {
+            Some(Command::Choose(self.selected))
+        }
+    }
+}
+
+impl App {
+    pub fn extensions_request(&mut self) -> Option<Request> {
+        let ConnectionState::Connected { root_id, epoch } = &self.connection else {
+            return None;
+        };
+        if self.extensions.busy {
+            return None;
+        }
+        let work = self.extensions.pending.take()?;
+        self.extensions.busy = true;
+        self.extensions.writing = matches!(
+            work,
+            Work::Page {
+                input: Input::Submit { .. },
+                ..
+            }
+        );
+        self.extensions.message = None;
+        Some(Request {
+            generation: self.extensions.generation,
+            root: root_id.clone(),
+            epoch: epoch.clone(),
+            session: self.extensions.session.clone(),
+            work,
+        })
+    }
+    pub fn extensions_complete(&mut self, request: Request, result: Result<Output, io::Failure>) {
+        if request.generation != self.extensions.generation
+            || !matches!(&self.connection,
+            ConnectionState::Connected { root_id, epoch } if *root_id == request.root && *epoch == request.epoch)
+        {
+            return;
+        }
+        let state = &mut self.extensions;
+        state.busy = false;
+        state.writing = false;
+        state.area = None;
+        match result {
+            Ok(Output::Directory(page)) => {
+                state.directory = page.items;
+                state.next = page.next_cursor;
+                state.page = None;
+                state.view = None;
+                state.blocked = false;
+                state.selected = 0;
+                state.top = 0;
+                state.reveal = true;
+            }
+            Ok(Output::Page(Reply::Page { page })) => state.install(page),
+            Ok(Output::Page(Reply::Applied { route })) => {
+                state.route = route;
+                state.history.clear();
+                state.page = None;
+                state.drafts.clear();
+                state.editors.clear();
+                state.read();
+            }
+            Ok(Output::Page(Reply::Conflict)) => {
+                state.blocked = true;
+                state.message = Some(Message::Local("extensions-conflict"));
+            }
+            Ok(Output::Page(Reply::Rejected { message })) => {
+                state.message = Some(Message::Remote(message));
+            }
+            Err(failure) => {
+                state.blocked = true;
+                state.message = Some(Message::Local(if failure.unknown {
+                    "extensions-unknown"
+                } else {
+                    "extensions-failed"
+                }));
+            }
+        }
+        self.hits.clear();
+        self.hover = None;
+    }
+    pub fn extensions_enabled(&self, command: &Command) -> bool {
+        let state = &self.extensions;
+        if *command == Command::Open {
+            return matches!(self.connection, ConnectionState::Connected { .. })
+                && !state.busy
+                && !state.dirty()
+                && !state.blocked;
+        }
+        if !matches!(self.connection, ConnectionState::Connected { .. })
+            || self.navigation.current() != Route::Extensions
+            || state.busy
+            || state.pending.is_some()
+        {
+            return false;
+        }
+        match command {
+            Command::Discard => state.dirty() || state.blocked,
+            Command::Back => !state.dirty(),
+            Command::Refresh => !state.dirty() && !state.blocked,
+            Command::Next => state.view.is_none() && state.next.is_some() && !state.blocked,
+            Command::Choose(index) => {
+                !state.blocked
+                    && state.directory.get(*index).is_some_and(|view| {
+                        view.descriptor.context == Context::Application || state.session.is_some()
+                    })
+            }
+            Command::Row(index) => {
+                !state.blocked
+                    && !state.dirty()
+                    && state
+                        .page
+                        .as_ref()
+                        .is_some_and(|page| *index < page.rows.len())
+            }
+            Command::Field(index) => {
+                !state.blocked
+                    && state
+                        .page
+                        .as_ref()
+                        .and_then(|page| page.fields.get(*index))
+                        .is_some_and(|field| field.enabled)
+            }
+            Command::Submit(index) => {
+                !state.blocked
+                    && state.page.as_ref().is_some_and(|page| {
+                        page.actions.get(*index).is_some_and(|action| {
+                            action.enabled
+                                && page.fields.iter().all(|field| {
+                                    !state.dirty_field(field) || action.fields.contains(&field.id)
+                                })
+                        })
+                    })
+            }
+            Command::Open => false,
+        }
+    }
+    pub fn extensions_action(&mut self, command: Command) {
+        if !self.extensions_enabled(&command) {
+            return;
+        }
+        if command == Command::Open {
+            let session = match self.navigation.current() {
+                Route::Session(id) => Some(id),
+                _ => None,
+            };
+            let generation = self.extensions.generation + 1;
+            self.extensions = State {
+                session,
+                generation,
+                pending: Some(Work::Directory(None)),
+                ..State::default()
+            };
+            self.apply(Action::Visit(Route::Extensions));
+            return;
+        }
+        self.focus = Focus::List;
+        let state = &mut self.extensions;
+        match command {
+            Command::Choose(index) => {
+                state.view = Some(state.directory[index].clone());
+                state.route = Value::Null;
+                state.history.clear();
+                state.read();
+            }
+            Command::Row(index) => {
+                let route = state.page.as_ref().unwrap().rows[index].route.clone();
+                if state.history.len() == 64 {
+                    state.history.pop_front();
+                }
+                state.history.push_back(state.route.clone());
+                state.route = route;
+                state.read();
+            }
+            Command::Field(index) => {
+                let page = state.page.as_ref().unwrap();
+                let field = &page.fields[index];
+                state.selected = page.rows.len() + index;
+                if let Some(Value::Bool(value)) = state.drafts.get_mut(&field.id) {
+                    *value = !*value;
+                }
+            }
+            Command::Submit(index) => {
+                let page = state.page.as_ref().unwrap();
+                let action = &page.actions[index];
+                let fields = action
+                    .fields
+                    .iter()
+                    .filter_map(|id| {
+                        state
+                            .drafts
+                            .get(id)
+                            .map(|value| (id.clone(), value.clone()))
+                    })
+                    .collect();
+                match page.submission(state.route.clone(), &action.id, fields) {
+                    Ok(input) => {
+                        state.pending = state.view.clone().map(|view| Work::Page {
+                            view: Box::new(view),
+                            input,
+                        })
+                    }
+                    Err(_) => state.message = Some(Message::Local("extensions-invalid-fields")),
+                }
+            }
+            Command::Back => {
+                if let Some(route) = state.history.pop_back() {
+                    state.route = route;
+                    state.read();
+                } else {
+                    state.page = None;
+                    state.view = None;
+                    state.drafts.clear();
+                    state.editors.clear();
+                    state.pending = Some(Work::Directory(None));
+                }
+            }
+            Command::Discard => {
+                state.generation += 1;
+                state.page = None;
+                state.view = None;
+                state.drafts.clear();
+                state.editors.clear();
+                state.blocked = false;
+                state.history.clear();
+                state.pending = Some(Work::Directory(None));
+            }
+            Command::Refresh => {
+                if state.view.is_some() {
+                    state.read();
+                } else {
+                    state.pending = Some(Work::Directory(None));
+                }
+            }
+            Command::Next => state.pending = Some(Work::Directory(state.next.clone())),
+            Command::Open => {}
+        }
+        state.area = None;
+        self.hits.clear();
+        self.hover = None;
+    }
+    pub fn extensions_input(&mut self, event: &crossterm::event::Event) -> bool {
+        use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
+        if self.palette.is_some() || self.navigation.current() != Route::Extensions {
+            return false;
+        }
+        if let Event::Mouse(mouse) = event
+            && self
+                .extensions
+                .area
+                .is_some_and(|area| area.contains((mouse.column, mouse.row).into()))
+            && matches!(
+                mouse.kind,
+                MouseEventKind::ScrollDown | MouseEventKind::ScrollUp
+            )
+        {
+            self.extensions.top = if mouse.kind == MouseEventKind::ScrollDown {
+                self.extensions.top.saturating_add(3)
+            } else {
+                self.extensions.top.saturating_sub(3)
+            };
+            return true;
+        }
+        if let Event::Mouse(mouse) = event
+            && !self.extensions.busy
+            && !self.extensions.blocked
+            && self.extensions.pending.is_none()
+            && let Some(field_id) = self.extensions.page.as_ref().and_then(|page| {
+                page.fields
+                    .iter()
+                    .find(|field| {
+                        field.enabled
+                            && self
+                                .extensions
+                                .editors
+                                .get(&field.id)
+                                .is_some_and(|editor| {
+                                    editor.contains((mouse.column, mouse.row).into())
+                                        || editor.dragging()
+                                })
+                    })
+                    .map(|field| field.id.clone())
+            })
+            && self
+                .extensions
+                .editors
+                .get_mut(&field_id)
+                .unwrap()
+                .mouse(*mouse)
+        {
+            self.focus = Focus::List;
+            let page = self.extensions.page.as_ref().unwrap();
+            self.extensions.selected = page.rows.len()
+                + page
+                    .fields
+                    .iter()
+                    .position(|field| field.id == field_id)
+                    .unwrap();
+            return true;
+        }
+        if self.focus != Focus::List || self.extensions.area.is_none() {
+            return false;
+        }
+        if let Event::Key(key) = event
+            && key.kind != KeyEventKind::Release
+            && !key
+                .modifiers
+                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
+        {
+            match key.code {
+                KeyCode::Tab | KeyCode::Down | KeyCode::BackTab | KeyCode::Up => {
+                    let n = self.extensions.controls();
+                    let backwards =
+                        key.code == KeyCode::BackTab || key.modifiers.contains(KeyModifiers::SHIFT);
+                    if matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
+                        && (n == 0
+                            || backwards && self.extensions.selected == 0
+                            || !backwards && self.extensions.selected + 1 >= n)
+                    {
+                        return false; // Continue through the page toolbar and navigation.
+                    }
+                    self.extensions.reveal = true;
+                    if n > 0 {
+                        self.extensions.selected =
+                            if matches!(key.code, KeyCode::Up | KeyCode::BackTab) {
+                                (self.extensions.selected + n - 1) % n
+                            } else {
+                                (self.extensions.selected + 1) % n
+                            };
+                    }
+                    return true;
+                }
+                KeyCode::PageDown | KeyCode::PageUp => {
+                    let height = self.extensions.area.unwrap().height as usize;
+                    self.extensions.top = if key.code == KeyCode::PageDown {
+                        self.extensions.top.saturating_add(height)
+                    } else {
+                        self.extensions.top.saturating_sub(height)
+                    };
+                    return true;
+                }
+                KeyCode::Char(' ') if matches!(self.extensions.selected_command(), Some(Command::Field(index)) if self.extensions.page.as_ref().is_some_and(|page| matches!(page.fields[index].control, Control::Toggle { .. }))) =>
+                {
+                    self.extensions_action(self.extensions.selected_command().unwrap());
+                    return true;
+                }
+                KeyCode::Esc => {
+                    self.extensions_action(Command::Back);
+                    return true;
+                }
+                KeyCode::Enter => {
+                    if let Some(command) = self.extensions.selected_command() {
+                        self.extensions_action(command);
+                    }
+                    return true;
+                }
+                _ => {}
+            }
+        }
+        let state = &mut self.extensions;
+        if state.busy || state.pending.is_some() || state.blocked {
+            return false;
+        }
+        let Some(page) = &state.page else {
+            return false;
+        };
+        let Some(index) = state.selected.checked_sub(page.rows.len()) else {
+            return false;
+        };
+        let Some(field) = page.fields.get(index).filter(|field| field.enabled) else {
+            return false;
+        };
+        let Some(editor) = state.editors.get_mut(&field.id) else {
+            return false;
+        };
+        let changed = match event {
+            Event::Key(key)
+                if key.kind != KeyEventKind::Release
+                    && !key.modifiers.contains(KeyModifiers::ALT) =>
+            {
+                if key.modifiers.contains(KeyModifiers::CONTROL)
+                    && !matches!(
+                        key.code,
+                        KeyCode::Char('a' | 'z' | 'y' | 'u' | 'k')
+                            | KeyCode::Left
+                            | KeyCode::Right
+                            | KeyCode::Backspace
+                            | KeyCode::Delete
+                    )
+                {
+                    return false;
+                }
+                editor.key(*key)
+            }
+            Event::Paste(text) => {
+                if matches!(
+                    field.control,
+                    Control::Text {
+                        multiline: false,
+                        ..
+                    }
+                ) {
+                    editor.insert(&crate::view::safe(text))
+                } else {
+                    editor.insert(text)
+                }
+            }
+            _ => return false,
+        };
+        if changed {
+            state
+                .drafts
+                .insert(field.id.clone(), Value::String(editor.text().into()));
+        }
+        true
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::i18n::{I18n, LocalePreference};
+    use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
+    use maka_plugins::{
+        composition::Scope,
+        remote::Target,
+        terminal_ui::{
+            Descriptor, Text, VERSION,
+            page::{Action as PageAction, Field},
+        },
+    };
+    use ratatui::{Terminal, backend::TestBackend};
+    use serde_json::json;
+
+    fn projection() -> TerminalViewProjection {
+        TerminalViewProjection {
+            package_id: "example.notes".into(),
+            scope_id: Scope::Profile,
+            method: "preferences".into(),
+            target: Target {
+                entry_id: "notes".into(),
+                activation: "a".into(),
+                registration: uuid::Uuid::new_v4(),
+            },
+            descriptor: Descriptor {
+                version: VERSION,
+                title: Text::localized("Notes", "笔记", "筆記"),
+                context: Context::Session,
+            },
+        }
+    }
+    fn form() -> Page {
+        Page {
+            version: VERSION,
+            title: Text::plain("Notebook"),
+            revision: "one".into(),
+            body: "A plugin-owned form.".into(),
+            rows: vec![],
+            fields: vec![
+                Field {
+                    id: "enabled".into(),
+                    label: Text::plain("Enabled"),
+                    enabled: true,
+                    control: Control::Toggle { value: true },
+                },
+                Field {
+                    id: "name".into(),
+                    label: Text::plain("Name"),
+                    enabled: true,
+                    control: Control::Text {
+                        value: "My notes".into(),
+                        max_bytes: 128,
+                        multiline: false,
+                    },
+                },
+            ],
+            actions: vec![PageAction {
+                id: "save".into(),
+                label: Text::plain("Save"),
+                enabled: true,
+                fields: vec!["enabled".into(), "name".into()],
+            }],
+        }
+    }
+    fn draw(app: &mut App, width: u16, height: u16) -> String {
+        let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
+        terminal
+            .draw(|frame| crate::view::draw(frame, app))
+            .unwrap();
+        terminal
+            .backend()
+            .buffer()
+            .content()
+            .iter()
+            .map(|cell| cell.symbol())
+            .collect()
+    }
+    fn app() -> App {
+        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())));
+        app.extensions_action(Command::Open);
+        let request = app.extensions_request().unwrap();
+        assert_eq!(request.session.as_deref(), Some("session"));
+        app.extensions_complete(
+            request,
+            Ok(Output::Directory(maka_protocol::plugin::Page {
+                items: vec![projection()],
+                next_cursor: None,
+            })),
+        );
+        app.extensions_action(Command::Choose(0));
+        let request = app.extensions_request().unwrap();
+        app.extensions_complete(request, Ok(Output::Page(Reply::Page { page: form() })));
+        app
+    }
+    #[test]
+    fn contributed_form_supports_mouse_keyboard_and_retains_conflicting_drafts_without_rebinding() {
+        let mut app = app();
+        assert!(draw(&mut app, 90, 26).contains("Notebook"));
+        let hit = app
+            .hits
+            .iter()
+            .find(|hit| hit.action == Action::Extension(Command::Field(0)))
+            .unwrap()
+            .area;
+        let event = Event::Mouse(crossterm::event::MouseEvent {
+            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
+            column: hit.x + hit.width / 2,
+            row: hit.y,
+            modifiers: KeyModifiers::NONE,
+        });
+        app.input(event);
+        assert_eq!(app.extensions.drafts["enabled"], json!(false));
+        draw(&mut app, 90, 26);
+        assert!(!app.extensions_enabled(&Command::Refresh));
+        app.input(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
+        app.input(Event::Key(KeyEvent::new(
+            KeyCode::Char('a'),
+            KeyModifiers::CONTROL,
+        )));
+        app.input(Event::Paste("Renamed".into()));
+        assert_eq!(app.extensions.drafts["name"], json!("Renamed"));
+        app.extensions.page.as_mut().unwrap().actions[0]
+            .fields
+            .pop();
+        assert!(
+            !app.extensions_enabled(&Command::Submit(0)),
+            "saving a subset must not discard other drafts"
+        );
+        app.extensions.page.as_mut().unwrap().actions[0]
+            .fields
+            .push("name".into());
+        app.extensions_action(Command::Submit(0));
+        let request = app.extensions_request().unwrap();
+        let Work::Page {
+            view,
+            input: Input::Submit {
+                fields, revision, ..
+            },
+        } = &request.work
+        else {
+            panic!("submit");
+        };
+        assert_eq!(view.target, app.extensions.view.as_ref().unwrap().target);
+        assert_eq!(fields["enabled"], json!(false));
+        assert_eq!(fields["name"], json!("Renamed"));
+        assert_eq!(revision, "one");
+        app.extensions_complete(request.clone(), Ok(Output::Page(Reply::Conflict)));
+        assert_eq!(app.extensions.drafts["name"], json!("Renamed"));
+        assert!(!app.extensions_enabled(&Command::Submit(0)));
+        assert!(
+            app.extensions_request().is_none(),
+            "no automatic refresh or retry"
+        );
+        assert!(draw(&mut app, 44, 18).contains("draft"));
+        app.extensions_action(Command::Discard);
+        let fresh = app.extensions_request().unwrap();
+        app.extensions_complete(request, Ok(Output::Page(Reply::Page { page: form() })));
+        assert!(
+            app.extensions.page.is_none(),
+            "late old result cannot replace new directory"
+        );
+        app.extensions_complete(
+            fresh,
+            Ok(Output::Directory(maka_protocol::plugin::Page {
+                items: vec![projection()],
+                next_cursor: None,
+            })),
+        );
+        assert_eq!(app.extensions.directory.len(), 1);
+    }
+
+    #[test]
+    fn disconnect_revokes_controls_preserves_drafts_and_tiny_layout_has_no_stale_clicks() {
+        let mut app = app();
+        draw(&mut app, 80, 24);
+        app.extensions_action(Command::Field(0));
+        app.extensions_action(Command::Submit(0));
+        let request = app.extensions_request().unwrap();
+        app.extensions.disconnect();
+        app.extensions_complete(
+            request,
+            Ok(Output::Page(Reply::Applied { route: Value::Null })),
+        );
+        assert_eq!(app.extensions.drafts["enabled"], json!(false));
+        assert!(matches!(
+            app.extensions.message,
+            Some(Message::Local("extensions-unknown"))
+        ));
+        assert!(!app.extensions_enabled(&Command::Submit(0)));
+        draw(&mut app, 25, 8);
+        assert!(app.extensions.area.is_none());
+        assert!(
+            !app.hits
+                .iter()
+                .any(|hit| matches!(hit.action, Action::Extension(_)))
+        );
+        assert!(app.extensions_request().is_none());
+    }
+}
diff --git a/crates/tui/src/pages/extensions/io.rs b/crates/tui/src/pages/extensions/io.rs
new file mode 100644
index 0000000000..363ebb6ea9
--- /dev/null
+++ b/crates/tui/src/pages/extensions/io.rs
@@ -0,0 +1,138 @@
+/*
+ * 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.
+ */
+
+use super::{Request, Work};
+use maka_client::{Client, ClientError, RequestFailure};
+use maka_plugins::terminal_ui::page::{Reply, Request as Input};
+use maka_protocol::plugin::{
+    Page, Query, QueryResult, RemoteBinding, RemoteRequest, RemoteResult, TerminalViewProjection,
+    View,
+};
+
+pub enum Output {
+    Directory(Page<TerminalViewProjection>),
+    Page(Reply),
+}
+pub struct Failure {
+    pub unknown: bool,
+}
+fn failure(error: RequestFailure, writing: bool) -> Failure {
+    let unknown = matches!(&error, RequestFailure::Unknown(_))
+        || matches!(&error,
+        RequestFailure::Rejected(ClientError::Rejected(error)) if matches!(error.code,
+        maka_protocol::OperationErrorCode::OutcomeUnknown | maka_protocol::OperationErrorCode::CommitOutcomeUnknown));
+    Failure {
+        unknown: writing && unknown,
+    }
+}
+pub async fn execute(client: &Client, request: &Request) -> Result<Output, Failure> {
+    match &request.work {
+        Work::Directory(cursor) => {
+            let result = client
+                .plugin_query(Query {
+                    view: View::TerminalViews,
+                    root_id: Some(maka_plugins::composition::Scope::Profile),
+                    cursor: cursor.clone(),
+                    limit: Some(16),
+                })
+                .await
+                .map_err(|error| failure(error, false))?;
+            match result {
+                QueryResult::TerminalViews(page) => Ok(Output::Directory(page)),
+                _ => Err(Failure { unknown: false }),
+            }
+        }
+        Work::Page { view, input } => {
+            input.validate().map_err(|_| Failure { unknown: false })?;
+            let RemoteResult::Document { document } = client
+                .plugin_remote(RemoteRequest::OpenDocument)
+                .await
+                .map_err(|error| failure(error, false))?
+            else {
+                return Err(Failure { unknown: false });
+            };
+            let result = client
+                .plugin_remote(RemoteRequest::Call {
+                    binding: RemoteBinding::Package {
+                        package_id: view.package_id.clone(),
+                        method: view.method.clone(),
+                        session_id: request.session.clone(),
+                    },
+                    target: view.target.clone(),
+                    document,
+                    input: serde_json::to_value(input).expect("terminal request"),
+                })
+                .await;
+            // The finite call always owns cleanup, even after navigation. Do not
+            // replace a known write receipt with a CloseDocument failure.
+            let closed = client
+                .plugin_remote(RemoteRequest::CloseDocument { document })
+                .await;
+            let writing = matches!(input, Input::Submit { .. });
+            let RemoteResult::Value { value } = result.map_err(|error| failure(error, writing))?
+            else {
+                return Err(Failure { unknown: writing });
+            };
+            let reply: Reply =
+                serde_json::from_value(value).map_err(|_| Failure { unknown: writing })?;
+            reply.validate().map_err(|_| Failure { unknown: writing })?;
+            if !matches!(
+                (&input, &reply),
+                (
+                    Input::Read { .. },
+                    Reply::Page { .. } | Reply::Conflict | Reply::Rejected { .. }
+                ) | (
+                    Input::Submit { .. },
+                    Reply::Applied { .. } | Reply::Conflict | Reply::Rejected { .. }
+                )
+            ) {
+                return Err(Failure { unknown: writing });
+            }
+            if !writing {
+                closed.map_err(|error| failure(error, false))?;
+            }
+            Ok(Output::Page(reply))
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use maka_protocol::{OperationError, OperationErrorCode};
+
+    #[test]
+    fn host_unknown_write_receipts_and_transport_loss_are_not_rejections() {
+        for (code, unknown) in [
+            (OperationErrorCode::OutcomeUnknown, true),
+            (OperationErrorCode::CommitOutcomeUnknown, true),
+            (OperationErrorCode::OperationConflict, false),
+            (OperationErrorCode::InvalidRequest, false),
+        ] {
+            let error = RequestFailure::Rejected(ClientError::Rejected(OperationError {
+                code,
+                message: "detail".into(),
+            }));
+            assert_eq!(failure(error.clone(), true).unknown, unknown);
+            assert!(!failure(error, false).unknown);
+        }
+        assert!(failure(RequestFailure::Unknown(ClientError::Timeout), true).unknown);
+        assert!(!failure(RequestFailure::NotDispatched(ClientError::Timeout), true).unknown);
+    }
+}
diff --git a/crates/tui/src/pages/extensions/view.rs b/crates/tui/src/pages/extensions/view.rs
new file mode 100644
index 0000000000..146fcd7c50
--- /dev/null
+++ b/crates/tui/src/pages/extensions/view.rs
@@ -0,0 +1,235 @@
+/*
+ * 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.
+ */
+
+use super::Command;
+use crate::{
+    app::{Action, App, Focus, Hit},
+    pages::chat::layout,
+};
+use maka_plugins::terminal_ui::page::Control;
+use ratatui::{
+    Frame,
+    layout::{Margin, Rect},
+    style::{Modifier, Style},
+    text::Line,
+    widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
+};
+
+pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
+    app.extensions.invalidate_geometry();
+    let area = area.inner(Margin::new(2, 1));
+    if area.is_empty() {
+        return;
+    }
+    let colors = app.theme.colors();
+    let locale = app.i18n.locale().id();
+    let mut lines = Vec::new();
+    let mut controls = Vec::new();
+    let state = &app.extensions;
+    if let Some(message) = &state.message {
+        let message = match message {
+            super::Message::Local(key) => app.i18n.text(key),
+            super::Message::Remote(text) => text.resolve(locale).into(),
+        };
+        for line in layout::plain(&message, area.width.saturating_sub(1))
+            .unwrap()
+            .lines
+        {
+            lines.push(line.line.style(Style::default().fg(colors.warning)));
+        }
+        lines.push(Line::default());
+    }
+    if state.busy {
+        lines.push(Line::styled(
+            app.i18n.text("extensions-loading"),
+            Style::default().fg(colors.muted),
+        ));
+        lines.push(Line::default());
+    }
+    if let Some(page) = &state.page {
+        lines.push(Line::styled(
+            page.title.resolve(locale).to_owned(),
+            Style::default()
+                .fg(colors.accent)
+                .add_modifier(Modifier::BOLD),
+        ));
+        lines.push(Line::default());
+        if !page.body.is_empty() {
+            lines.extend(
+                layout::plain(&page.body, area.width.saturating_sub(1))
+                    .unwrap()
+                    .lines
+                    .into_iter()
+                    .map(|line| line.line),
+            );
+            lines.push(Line::default());
+        }
+        for (index, row) in page.rows.iter().enumerate() {
+            controls.push((lines.len(), 2, Command::Row(index)));
+            lines.push(Line::raw(row.title.resolve(locale).to_owned()));
+            lines.push(Line::styled(
+                row.description.clone(),
+                Style::default().fg(colors.muted),
+            ));
+            lines.push(Line::default());
+        }
+        for (index, field) in page.fields.iter().enumerate() {
+            let start = lines.len();
+            lines.push(Line::styled(
+                field.label.resolve(locale).to_owned(),
+                Style::default().fg(colors.muted),
+            ));
+            match &field.control {
+                Control::Toggle { .. } => {
+                    let checked = state
+                        .drafts
+                        .get(&field.id)
+                        .and_then(serde_json::Value::as_bool)
+                        .unwrap_or(false);
+                    let mark = if checked {
+                        app.chrome.symbol("●", "[x]")
+                    } else {
+                        app.chrome.symbol("○", "[ ]")
+                    };
+                    lines.push(Line::raw(format!(" {mark}")));
+                }
+                Control::Text { multiline, .. } => {
+                    lines.push(Line::default());
+                    if *multiline {
+                        lines.extend([Line::default(), Line::default()]);
+                    }
+                }
+            }
+            controls.push((start, lines.len() - start, Command::Field(index)));
+            lines.push(Line::default());
+        }
+        for (index, action) in page.actions.iter().enumerate() {
+            controls.push((lines.len(), 1, Command::Submit(index)));
+            lines.push(Line::raw(action.label.resolve(locale).to_owned()).centered());
+            lines.push(Line::default());
+        }
+    } else {
+        for (index, view) in state.directory.iter().enumerate() {
+            controls.push((lines.len(), 2, Command::Choose(index)));
+            lines.push(Line::raw(view.descriptor.title.resolve(locale).to_owned()));
+            let detail = if view.descriptor.context == maka_plugins::terminal_ui::Context::Session
+                && state.session.is_none()
+            {
+                app.i18n.text("extensions-needs-session")
+            } else {
+                view.package_id.clone()
+            };
+            lines.push(Line::styled(detail, Style::default().fg(colors.muted)));
+            lines.push(Line::default());
+        }
+        if state.directory.is_empty() && !state.busy && state.message.is_none() {
+            lines.push(Line::styled(
+                app.i18n.text("extensions-empty"),
+                Style::default().fg(colors.muted),
+            ));
+        }
+    }
+    let state = &mut app.extensions;
+    if state.reveal {
+        if let Some((start, height, _)) = controls.get(state.selected) {
+            if *start < state.top {
+                state.top = *start;
+            } else if start + height > state.top + usize::from(area.height) {
+                state.top = (start + height).saturating_sub(usize::from(area.height));
+            }
+        }
+        state.reveal = false;
+    }
+    state.top = state
+        .top
+        .min(lines.len().saturating_sub(usize::from(area.height)));
+    state.area = Some(area);
+    let top = state.top;
+    let total = lines.len();
+    for (index, (start, height, command)) in controls.iter().enumerate() {
+        let action = Action::Extension(command.clone());
+        let enabled = app.enabled(&action);
+        let focused = app.palette.is_none()
+            && (app.focus == Focus::List && app.extensions.selected == index
+                || app.hover.as_ref() == Some(&action));
+        if focused || !enabled {
+            let style = Style::default().fg(if !enabled {
+                colors.muted
+            } else {
+                colors.accent
+            });
+            for line in lines.iter_mut().skip(*start).take(*height) {
+                *line = line.clone().style(style);
+            }
+        }
+    }
+    frame.render_widget(Paragraph::new(lines).scroll((top as u16, 0)), area);
+    for (index, (start, height, command)) in controls.iter().enumerate() {
+        let end = (start + height).min(top + usize::from(area.height));
+        if end <= top || *start >= top + usize::from(area.height) {
+            continue;
+        }
+        let y = start.saturating_sub(top);
+        let rect = Rect::new(
+            area.x,
+            area.y + y as u16,
+            area.width.saturating_sub(1),
+            (end - (*start).max(top)) as u16,
+        );
+        app.hits.push(Hit {
+            area: rect,
+            action: Action::Extension(command.clone()),
+        });
+        if let Command::Field(field_index) = command
+            && let Some(field) = app
+                .extensions
+                .page
+                .as_ref()
+                .and_then(|page| page.fields.get(*field_index))
+            && let Some(editor) = app.extensions.editors.get_mut(&field.id)
+            && *start >= top
+            && *start + height <= top + usize::from(area.height)
+        {
+            editor.draw(
+                frame,
+                Rect::new(
+                    rect.x,
+                    rect.y + 1,
+                    rect.width,
+                    rect.height.saturating_sub(1),
+                ),
+                app.focus == Focus::List
+                    && index == app.extensions.selected
+                    && app.palette.is_none()
+                    && !app.extensions.busy
+                    && !app.extensions.blocked,
+                colors,
+            );
+        }
+    }
+    if total > usize::from(area.height) {
+        frame.render_stateful_widget(
+            Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                .begin_symbol(None)
+                .end_symbol(None),
+            area,
+            &mut ScrollbarState::new(total.saturating_sub(usize::from(area.height))).position(top),
+        );
+    }
+}
diff --git a/crates/tui/src/view.rs b/crates/tui/src/view.rs
index a8cf667a8e..853efacf4d 100644
--- a/crates/tui/src/view.rs
+++ b/crates/tui/src/view.rs
@@ -270,6 +270,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
     let page = columns[1].inner(Margin::new(1, 0));
 
     match app.navigation.current() {
+        Route::Extensions => crate::pages::extensions::draw(frame, app, page),
         Route::Connections => crate::pages::connections::draw(frame, app, page),
         Route::Projects => crate::pages::projects::draw(frame, app, page),
         Route::Workspace | Route::Inbox => crate::pages::sessions::draw_catalog(frame, app, page),
@@ -490,6 +491,14 @@ fn icon(app: &App, action: &Action) -> &'static str {
         Action::Visit(Route::Settings) => ("⛭", "S"),
         Action::Visit(Route::Help) => ("?", "?"),
         Action::Visit(Route::Projects) => ("▦", "P"),
+        Action::Visit(Route::Extensions) => ("◇", "E"),
+        Action::Extension(command) => match command {
+            crate::pages::extensions::Command::Refresh => ("↻", "R"),
+            crate::pages::extensions::Command::Back => ("‹", "<"),
+            crate::pages::extensions::Command::Next => ("›", ">"),
+            crate::pages::extensions::Command::Discard => ("×", "x"),
+            _ => ("◇", "E"),
+        },
         Action::Visit(Route::Connections) => ("⇄", "C"),
         Action::Connection(command) => match command {
             crate::pages::connections::Command::Select(_) => ("⇄", "C"),
@@ -654,6 +663,7 @@ fn action_label(app: &App, action: &Action) -> String {
         Action::Attachment(command) => command.label(),
         Action::References => "references-title",
         Action::Skills(command) => command.label(),
+        Action::Extension(command) => command.label(),
         Action::NextTab => "tabs-next",
         Action::PreviousTab => "tabs-previous",
         Action::CloseTab(_) => "tabs-close",
@@ -744,7 +754,11 @@ fn page_lines(app: &App) -> Vec<Line<'static>> {
         )
     };
     match app.navigation.current() {
-        Route::Workspace | Route::Inbox | Route::Projects | Route::Connections => vec![],
+        Route::Workspace
+        | Route::Inbox
+        | Route::Projects
+        | Route::Connections
+        | Route::Extensions => vec![],
         Route::Session(_) => crate::pages::sessions::detail_lines(app),
         Route::Host => {
             let mut lines = vec![Line::raw(root()), Line::raw("")];


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org