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/10/13 02:09:56 UTC
(maka) branch feat/runtime-host-rust updated: feat(maka): add task workbench and default composition updates
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 4a6cb4007d feat(maka): add task workbench and default composition updates
4a6cb4007d is described below
commit 4a6cb4007d321dfff407dd3324501c2d34f050d8
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Oct 13 10:09:33 2026 +0800
feat(maka): add task workbench and default composition updates
---
packages/plugin-sdk/src/generated/runtime.ts | 15 +
.../migrations/0017_application_product.sql | 21 ++
platform/event-log/src/applications/launch.rs | 36 ++
.../event-log/src/applications/launch/tests.rs | 67 +++-
platform/render-terminal/src/application/dialog.rs | 15 +
platform/render-terminal/src/application/paint.rs | 2 +-
.../src/application/tests/layout.rs | 39 ++
platform/ui-runtime/src/scene/terminal.rs | 4 +
plugins/agent/agent-screen/README.md | 1 +
plugins/agent/agent-screen/README.zh-CN.md | 1 +
plugins/agent/agent-screen/actions.ts | 138 +++++--
plugins/agent/agent-screen/component-events.ts | 17 +-
plugins/agent/agent-screen/conversation-view.ts | 320 +++++++++-------
plugins/agent/agent-screen/events.ts | 64 +++-
plugins/agent/agent-screen/frame.ts | 10 +
plugins/agent/agent-screen/header-view.ts | 51 +--
plugins/agent/agent-screen/index.ts | 1 +
plugins/agent/agent-screen/messages.ts | 1 +
plugins/agent/agent-screen/navigation-view.ts | 5 +
plugins/agent/agent-screen/outputs-view.ts | 204 ++++++++++
plugins/agent/agent-screen/package.ts | 1 -
plugins/agent/agent-screen/raw-events.ts | 5 +
plugins/agent/agent-screen/results.ts | 17 +-
plugins/agent/agent-screen/state.ts | 37 +-
plugins/agent/agent-screen/task-overview.ts | 145 +++++++
plugins/agent/execution-picker/index.ts | 2 +-
plugins/settings/connection-form/events.ts | 21 +-
plugins/settings/connection-form/view.ts | 37 +-
plugins/settings/credential/index.ts | 13 +-
plugins/settings/models/index.ts | 5 +-
plugins/shared/components.ts | 22 +-
plugins/shared/contracts.ts | 6 +
plugins/shared/message-view.ts | 6 +-
plugins/shared/tui-locale.ts | 93 ++++-
plugins/shared/view.ts | 10 +-
products/maka/bundle/README.md | 10 +-
products/maka/bundle/README.zh-CN.md | 10 +-
products/maka/bundle/src/composition.ts | 1 -
products/maka/bundle/tests/connections.test.mjs | 11 +
products/maka/bundle/tests/task-workbench.test.mjs | 416 +++++++++++++++++++++
products/maka/cli/notices.json | 52 +--
products/maka/cli/src/application.rs | 33 +-
products/maka/cli/src/application/product.rs | 23 +-
.../maka/cli/src/application/product/launch.rs | 37 +-
.../cli/src/application/product/launch/tests.rs | 2 +
.../maka/cli/src/application/product/restore.rs | 6 +
.../maka/cli/src/application/product/update.rs | 258 +++++++++++++
.../cli/src/application/product/update/tests.rs | 297 +++++++++++++++
.../tests/integration/terminal/bundled_product.rs | 18 +-
49 files changed, 2331 insertions(+), 275 deletions(-)
diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts
index 10c326d4ea..cd8668951b 100644
--- a/packages/plugin-sdk/src/generated/runtime.ts
+++ b/packages/plugin-sdk/src/generated/runtime.ts
@@ -687,6 +687,11 @@ export type TerminalScene =
copy_selection_label?: string | null;
background: TerminalScene;
content: TerminalScene;
+ /**
+ * Place a controlled dialog next to its background, preferring above it.
+ * The viewport bounds remain authoritative on small terminals.
+ */
+ anchor_to_background?: boolean;
ascii?: boolean;
label: string;
/**
@@ -705,6 +710,11 @@ export type TerminalScene =
background: TerminalScene;
content: TerminalScene;
open: boolean;
+ /**
+ * Place a controlled dialog next to its background, preferring above it.
+ * The viewport bounds remain authoritative on small terminals.
+ */
+ anchor_to_background?: boolean;
ascii?: boolean;
label: string;
/**
@@ -5476,6 +5486,11 @@ export interface TerminalChoiceOption {
* Appearance shared by controlled dialogs and transient adaptive panes.
*/
export interface TerminalDialogAppearance {
+ /**
+ * Place a controlled dialog next to its background, preferring above it.
+ * The viewport bounds remain authoritative on small terminals.
+ */
+ anchor_to_background?: boolean;
ascii?: boolean;
label: string;
/**
diff --git a/platform/event-log/migrations/0017_application_product.sql b/platform/event-log/migrations/0017_application_product.sql
new file mode 100644
index 0000000000..804490ad2f
--- /dev/null
+++ b/platform/event-log/migrations/0017_application_product.sql
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+
+-- Native launcher default baseline; user-authored heads remain independent.
+ALTER TABLE application_launch ADD COLUMN product TEXT CHECK(product IS NULL OR json_valid(product));
diff --git a/platform/event-log/src/applications/launch.rs b/platform/event-log/src/applications/launch.rs
index aad4043b64..a415344aaf 100644
--- a/platform/event-log/src/applications/launch.rs
+++ b/platform/event-log/src/applications/launch.rs
@@ -25,6 +25,7 @@ use sqlx::{Connection, Row};
use uuid::Uuid;
const MAX_CONFIGURATION: usize = 1024 * 1024;
+const MAX_PRODUCT: usize = 4 * MAX_CONFIGURATION;
/// Exact source and base64 package bytes for at most one unfinished initialization.
const MAX_PENDING: usize = 192 * 1024 * 1024;
@@ -34,6 +35,41 @@ pub struct Launch {
pub pending: Option<Vec<u8>>,
}
impl EventLog {
+ /// Native product authoring inputs and the last installed default sources.
+ pub async fn application_product(&self) -> Result<Option<Value>, StoreError> {
+ self.application_domain()?;
+ self.connection.run(|connection| Box::pin(async move {
+ let row = sqlx::query("SELECT product IS NOT NULL AS present, CASE WHEN length(CAST(product AS BLOB)) <= ? THEN product END AS product FROM application_launch WHERE singleton=1")
+ .bind(MAX_PRODUCT as i64).fetch_optional(connection).await?;
+ let Some(row) = row else { return Ok(None); };
+ let value = row.try_get::<Option<String>, _>("product")?;
+ if row.try_get::<bool, _>("present")? && value.is_none() {
+ return Err(invalid("launcher product exceeds budget"));
+ }
+ value.map(|value| serde_json::from_str(&value).map_err(StoreError::from)).transpose()
+ })).await
+ }
+
+ /// Publish an already prepared default upgrade only after every source commit.
+ /// Selection, baseline and preparation completion change atomically.
+ pub async fn complete_application_product(
+ &self,
+ deployment: Uuid,
+ configuration: Value,
+ product: Value,
+ ) -> Result<(), StoreError> {
+ self.application_domain()?;
+ let configuration = encode(&configuration, MAX_CONFIGURATION)?;
+ let product = encode(&product, MAX_PRODUCT)?;
+ self.connection.run(move |connection| Box::pin(async move {
+ let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+ let changed = sqlx::query("UPDATE application_launch SET configuration=?, product=?, pending=NULL WHERE singleton=1 AND deployment=? AND pending IS NOT NULL")
+ .bind(configuration).bind(product).bind(deployment.to_string()).execute(&mut *tx).await?.rows_affected();
+ if changed != 1 { return Err(invalid("launcher selection or preparation changed")); }
+ tx.commit().await.map_err(StoreError::CommitUnknown)
+ })).await
+ }
+
pub async fn application_launch(&self) -> Result<Option<Launch>, StoreError> {
self.application_domain()?;
self.connection.run(|connection| Box::pin(async move {
diff --git a/platform/event-log/src/applications/launch/tests.rs b/platform/event-log/src/applications/launch/tests.rs
index cf17766c93..a4fad1d52c 100644
--- a/platform/event-log/src/applications/launch/tests.rs
+++ b/platform/event-log/src/applications/launch/tests.rs
@@ -95,7 +95,7 @@ async fn launcher_requires_root_and_bounds_stored_configuration_before_reading()
.is_err()
);
store.connection.run(|connection| Box::pin(async move {
- sqlx::query("INSERT INTO application_launch VALUES (1,?,json_object('large',printf('%.*c',1048576,'x')),NULL)")
+ sqlx::query("INSERT INTO application_launch (singleton,deployment,configuration,pending) VALUES (1,?,json_object('large',printf('%.*c',1048576,'x')),NULL)")
.bind(Uuid::new_v4().to_string()).execute(connection).await?;
Ok(())
})).await.unwrap();
@@ -121,3 +121,68 @@ async fn unknown_commit_is_not_reported_as_success_or_replaced() {
assert!(store.application_launch().await.unwrap().is_none());
store.close().await.unwrap();
}
+
+#[tokio::test]
+async fn product_baseline_and_connections_publish_atomically_with_completion() {
+ let (_dir, store) = crate::applications::tests::store().await;
+ let id = Uuid::new_v4();
+ store
+ .begin_application_launch(Launch {
+ deployment: id,
+ configuration: json!({"old":true}),
+ pending: Some(vec![1]),
+ })
+ .await
+ .unwrap();
+ assert!(
+ store
+ .complete_application_product(Uuid::new_v4(), json!({}), json!({}))
+ .await
+ .is_err()
+ );
+ assert!(store.application_product().await.unwrap().is_none());
+ store.connection.run(|connection| Box::pin(async move {
+ sqlx::raw_sql("CREATE TABLE product_probe (id TEXT REFERENCES application_deployments(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER reject_product AFTER UPDATE ON application_launch BEGIN INSERT INTO product_probe VALUES ('missing'); END;").execute(connection).await?;
+ Ok(())
+ })).await.unwrap();
+ assert!(matches!(
+ store
+ .complete_application_product(id, json!({"new":true}), json!({"baseline":2}))
+ .await,
+ Err(StoreError::CommitUnknown(_))
+ ));
+ let original = store.application_launch().await.unwrap().unwrap();
+ assert_eq!(original.configuration, json!({"old":true}));
+ assert_eq!(original.pending, Some(vec![1]));
+ assert!(store.application_product().await.unwrap().is_none());
+ store
+ .connection
+ .run(|connection| {
+ Box::pin(async move {
+ sqlx::query("DROP TRIGGER reject_product")
+ .execute(connection)
+ .await?;
+ Ok(())
+ })
+ })
+ .await
+ .unwrap();
+ store
+ .complete_application_product(id, json!({"new":true}), json!({"baseline":2}))
+ .await
+ .unwrap();
+ assert_eq!(
+ store.application_product().await.unwrap(),
+ Some(json!({"baseline":2}))
+ );
+ let completed = store.application_launch().await.unwrap().unwrap();
+ assert_eq!(completed.configuration, json!({"new":true}));
+ assert!(completed.pending.is_none());
+ assert!(
+ store
+ .complete_application_product(id, json!({}), json!({}))
+ .await
+ .is_err()
+ );
+ store.close().await.unwrap();
+}
diff --git a/platform/render-terminal/src/application/dialog.rs b/platform/render-terminal/src/application/dialog.rs
index 6d563dbe60..1b9e3e3155 100644
--- a/platform/render-terminal/src/application/dialog.rs
+++ b/platform/render-terminal/src/application/dialog.rs
@@ -595,6 +595,21 @@ impl Presenter {
height,
);
+ if appearance.anchor_to_background {
+ if let Some(anchor) = deferred.anchor.map(|anchor| anchor.visible()) {
+ body.x = anchor.x.clamp(full.x, full.right().saturating_sub(width));
+ body.y = if anchor.y.saturating_sub(full.y) >= height {
+ anchor.y - height
+ } else if full.bottom().saturating_sub(anchor.bottom()) >= height {
+ anchor.bottom()
+ } else {
+ anchor
+ .y
+ .saturating_sub(height)
+ .clamp(full.y, full.bottom().saturating_sub(height))
+ };
+ }
+ }
if let Some(anchor) = deferred
.opening
.upgrade()
diff --git a/platform/render-terminal/src/application/paint.rs b/platform/render-terminal/src/application/paint.rs
index df4900d41e..f8ff5eabe9 100644
--- a/platform/render-terminal/src/application/paint.rs
+++ b/platform/render-terminal/src/application/paint.rs
@@ -1412,7 +1412,7 @@ impl Presenter {
key: key.clone(),
opening: std::rc::Rc::downgrade(opening),
parent_path: scene.input_path.clone(),
- anchor: None,
+ anchor: Some(area),
});
}
}
diff --git a/platform/render-terminal/src/application/tests/layout.rs b/platform/render-terminal/src/application/tests/layout.rs
index 0fde101846..7a96a34b5b 100644
--- a/platform/render-terminal/src/application/tests/layout.rs
+++ b/platform/render-terminal/src/application/tests/layout.rs
@@ -1415,3 +1415,42 @@ fn revealed_container_focus_waits_for_flush_and_preserves_user_navigation() {
.unwrap();
presenter.shutdown().unwrap();
}
+
+#[test]
+fn anchored_model_dialog_follows_its_trigger_across_viewport_sizes() {
+ let scene = json!({
+ "kind":"stack","key":"root","direction":"column","gap":0,"padding":0,"background":0,
+ "children":[
+ {"kind":"stack","key":"work","direction":"column","gap":0,"padding":0,"background":0,"layout":{"grow":1},"children":[]},
+ {"kind":"dialog","key":"models","label":"Models","open":true,"anchor_to_background":true,
+ "width":20,"height":8,"padding":0,"background_color":0,"border_color":0xffffff,
+ "background":{"kind":"button","key":"trigger","label":"Choose model"},
+ "content":{"kind":"button","key":"model","label":"Local model"}}
+ ]
+ });
+ let (_runtime, mut presenter) = native(View::Scene(scene), Rc::new(Cell::new(true)));
+ for (cols, rows) in [(80, 24), (65, 30), (14, 6)] {
+ flushed(&mut presenter, cols, rows);
+ let selected = presenter
+ .scene
+ .iter()
+ .find(|target| target.key == "model")
+ .unwrap()
+ .area
+ .visible();
+ assert_eq!(
+ selected.x, 1,
+ "model list stays next to the trigger's left edge"
+ );
+ assert_eq!(
+ selected.y,
+ rows - 3,
+ "model list opens directly above the footer"
+ );
+ assert!(selected.right() <= cols && selected.bottom() <= rows);
+ }
+ presenter
+ .surface_closed(&presenter.surface(), Ok(()))
+ .unwrap();
+ presenter.shutdown().unwrap();
+}
diff --git a/platform/ui-runtime/src/scene/terminal.rs b/platform/ui-runtime/src/scene/terminal.rs
index 13b44a291d..adb31ede3e 100644
--- a/platform/ui-runtime/src/scene/terminal.rs
+++ b/platform/ui-runtime/src/scene/terminal.rs
@@ -280,6 +280,10 @@ impl Choice {
#[serde(deny_unknown_fields)]
#[schemars(rename = "TerminalDialogAppearance")]
pub struct DialogAppearance {
+ /// Place a controlled dialog next to its background, preferring above it.
+ /// The viewport bounds remain authoritative on small terminals.
+ #[serde(default)]
+ pub anchor_to_background: bool,
#[serde(default)]
pub ascii: bool,
pub label: String,
diff --git a/plugins/agent/agent-screen/README.md b/plugins/agent/agent-screen/README.md
index fcf3235398..6f76d0304a 100644
--- a/plugins/agent/agent-screen/README.md
+++ b/plugins/agent/agent-screen/README.md
@@ -31,6 +31,7 @@
| `events.ts`, `component-events.ts`, `raw-events.ts` | Lifecycle/action routing, child callbacks and raw commands within one candidate-frame transaction |
| `frame.ts` | Retained root composition, dialogs and subscriptions |
| `conversation-view.ts` | Conversation, composer and its footer |
+| `task-overview.ts`, `outputs-view.ts` | Recorded task state and bounded output/evidence views |
| `navigation-view.ts`, `header-view.ts`, `pages-view.ts` | Navigation, header and retained product pages |
| `tui.ts`, `desktop.ts`, `package.ts` | Target adapters and package declaration |
diff --git a/plugins/agent/agent-screen/README.zh-CN.md b/plugins/agent/agent-screen/README.zh-CN.md
index b9cf29d04b..607487ca55 100644
--- a/plugins/agent/agent-screen/README.zh-CN.md
+++ b/plugins/agent/agent-screen/README.zh-CN.md
@@ -31,6 +31,7 @@
| `events.ts`、`component-events.ts`、`raw-events.ts` | 同一候选帧事务内的统一路由、子组件回调和原始命令 |
| `frame.ts` | 根组合、对话框及订阅 |
| `conversation-view.ts` | 对话、输入区和 footer |
+| `task-overview.ts`、`outputs-view.ts` | 真实任务状态与有界产物/验证视图 |
| `navigation-view.ts`、`header-view.ts`、`pages-view.ts` | 导航、标题栏和保留状态的产品页面 |
| `tui.ts`、`desktop.ts`、`package.ts` | 展示目标适配和插件声明 |
diff --git a/plugins/agent/agent-screen/actions.ts b/plugins/agent/agent-screen/actions.ts
index a745de2731..f6217cfa92 100644
--- a/plugins/agent/agent-screen/actions.ts
+++ b/plugins/agent/agent-screen/actions.ts
@@ -43,6 +43,8 @@ import {
import { draftReady } from '../../shared/drafts.js';
import { type Backend, type Request, type State, type RootCommand } from './state.js';
+import { toolCards } from '../../shared/tool-cards.js';
+import { readingKey } from './state.js';
import { messages } from './messages.js';
export function intent(
@@ -152,6 +154,10 @@ export function history(
extend = false,
) {
if (!state.selected) return;
+ if (latest && !implicit) {
+ const visit = state.visits.find((entry) => entry.id === state.selected?.id);
+ if (visit) visit.following = true;
+ }
if (!implicit && !extend) state.loading = true;
const navigation = ++state.navigation;
intent(
@@ -171,30 +177,45 @@ export function history(
'history',
{ sessionId: state.selected.id, through, position, includeThinking: true },
{},
- implicit ? [{ child: 'timeline', method: 'tail', input: null }] : [],
+ implicit ? [{ child: readingKey(state), method: 'tail', input: null }] : [],
);
}
export function follow(state: State, actions: Action[]) {
- const receipt = state.tailReceipt;
+ const window = state.window;
if (
- receipt?.landing === state.landing.sequence &&
- !state.source &&
- !state.searchOpen &&
- state.browse === null &&
- !state.image &&
- !state.loading &&
- state.window?.latest &&
- (state.latestKnown > state.window.page.through ||
+ state.source ||
+ state.inspector === 'outputs' ||
+ state.searchOpen ||
+ state.browse !== null ||
+ state.image ||
+ state.loading ||
+ state.notice === 'unavailable' ||
+ !window?.latest ||
+ !(
+ state.latestKnown > window.page.through ||
state.live.some(
- (fragment) => fragment.closedAt !== null && fragment.closedAt <= state.window!.page.through,
- )) &&
- !Object.values(state.pending).some(
+ (fragment) => fragment.closedAt !== null && fragment.closedAt <= window.page.through,
+ )
+ ) ||
+ Object.values(state.pending).some(
(request) =>
request.kind === 'history' &&
request.epoch === state.epoch &&
request.navigation === state.navigation,
- ) &&
+ )
+ )
+ return;
+ // The introductory view has no mounted reader or reading anchor. New facts
+ // must still appear, including failures that finish before a first text delta.
+ if (!window.page.chunks.length && !window.page.before && !window.page.after) {
+ history(state, actions, { kind: 'tail' }, state.latestKnown, true);
+ return;
+ }
+ const receipt = state.tailReceipt;
+ if (
+ receipt?.landing === state.landing.sequence &&
+ state.visits.find((visit) => visit.id === state.selected?.id)?.following !== false &&
!(
state.followAttempt?.through === state.latestKnown &&
state.followAttempt.landing === receipt.landing &&
@@ -207,14 +228,69 @@ export function follow(state: State, actions: Action[]) {
}
export function select(state: State, actions: Action[], session: AgentSession | null) {
- state.searchOpen = false;
- state.browse = null;
+ const previous = state.visits.find((visit) => visit.id === state.selected?.id);
+ if (previous)
+ Object.assign(previous, {
+ session: state.selected!,
+ window: state.window,
+ live: state.live,
+ source: state.source
+ ? { ...state.source, loading: false, failed: state.source.failed || state.source.loading }
+ : null,
+ image: state.image,
+ landing: state.landing,
+ searchOpen: state.searchOpen,
+ browse: state.browse,
+ inspector: state.inspector,
+ });
+ let visit = session
+ ? state.visits.find(
+ (entry) => entry.id === session.id && sameValue(entry.dataSource, session.dataSource),
+ )
+ : undefined;
+ if (session && !visit) {
+ visit = {
+ id: session.id,
+ dataSource: session.dataSource,
+ key: state.visits.length === 0 ? 'timeline' : `timeline-${++state.serial}`,
+ window: null,
+ live: [],
+ following: true,
+ source: null,
+ image: null,
+ landing: { sequence: 0, edge: 'end' },
+ searchOpen: false,
+ browse: null,
+ inspector: null,
+ output: null,
+ outputFocus: null,
+ session,
+ epoch: state.epoch + 1,
+ progress: null,
+ };
+ }
+ if (visit)
+ state.visits = [
+ ...state.visits.filter((entry) => entry !== visit && entry.id !== visit.id),
+ visit,
+ ].slice(-3);
+ state.searchOpen = visit?.searchOpen ?? false;
+ state.browse = visit?.browse ?? null;
+ state.inspector = visit?.inspector ?? null;
state.recallAnchor = null;
state.selected = session;
state.epoch++;
- state.window = null;
- state.source = null;
- state.image = null;
+ state.window = visit?.window ?? null;
+ if (visit?.window) visit.following = false;
+ state.source = visit?.source ?? null;
+ state.image = visit?.image ?? null;
+ state.live = visit?.live ?? [];
+ if (visit) {
+ visit.window = null;
+ visit.source = null;
+ visit.live = [];
+ }
+ state.landing = visit?.landing ?? { sequence: 0, edge: 'end' };
state.tailReceipt = null;
state.fileContext = null;
state.followAttempt = null;
@@ -225,8 +301,7 @@ export function select(state: State, actions: Action[], session: AgentSession |
state.interactions = null;
state.watch = null;
state.runThrough = 0;
- state.live = [];
- state.latestKnown = 0;
+ state.latestKnown = state.window?.page.through ?? 0;
state.loading = session !== null;
state.notice = null;
if (session) observe(state, actions);
@@ -430,6 +505,13 @@ export function rootCommands(
const commands: (PaletteCommand & { kind: RootCommand })[] = [
...(target === 'tui'
? [
+ {
+ kind: 'outputs-open' as const,
+ id: 'outputs-open',
+ label: tuiText(locale, 'task-outputs'),
+ hint: 'F4',
+ disabled: !route || !state.selected || state.loading || !!state.source || !!state.image,
+ },
{
kind: 'browse' as const,
id: 'browse',
@@ -725,7 +807,19 @@ export function rootCommand(
)
)
return false;
- if (kind === 'browse') {
+ if (kind === 'outputs-open') {
+ state.inspector = 'outputs';
+ const visit = state.visits.find((entry) => entry.id === state.selected?.id);
+ if (visit) {
+ const first = toolCards(messages(state)).find(
+ ({ message }) => message.role === 'assistant' || message.tool,
+ )?.message;
+ visit.outputFocus = {
+ sequence: ++state.serial,
+ target: first ? { kind: 'focus_item', key: 'output-item-' + first.id } : { kind: 'start' },
+ };
+ }
+ } else if (kind === 'browse') {
state.searchOpen = false;
state.browse = ++state.serial;
state.tailReceipt = null;
diff --git a/plugins/agent/agent-screen/component-events.ts b/plugins/agent/agent-screen/component-events.ts
index 15af6e9a44..2d11e7781c 100644
--- a/plugins/agent/agent-screen/component-events.ts
+++ b/plugins/agent/agent-screen/component-events.ts
@@ -85,6 +85,7 @@ import {
import { frame } from './frame.js';
import { utf8Bytes } from './messages.js';
import { type Page, type State } from './state.js';
+import { readingKey } from './state.js';
export function handleComponent<N extends Node>(
presentation: Presentation<N>,
@@ -454,7 +455,7 @@ export function handleComponent<N extends Node>(
{ sessionId: state.selected.id, expectedRevision: event.revision, edit },
);
}
- } else if (input.child === 'timeline' && input.name === 'messageCopy') {
+ } else if (input.child === readingKey(state) && input.name === 'messageCopy') {
const copied = input.event as MessageCopyObservation | null;
if (
!copied ||
@@ -465,7 +466,7 @@ export function handleComponent<N extends Node>(
copied.landing === state.landing.sequence)
)
state.messageCopy = copied;
- } else if (input.child === 'timeline' && input.name === 'fileContext') {
+ } else if (input.child === readingKey(state) && input.name === 'fileContext') {
const context = input.event as TimelineFileContext | null;
if (
!context ||
@@ -474,11 +475,11 @@ export function handleComponent<N extends Node>(
context.landing === state.landing.sequence)
)
state.fileContext = context;
- } else if (input.child === 'timeline' && input.name === 'browseClosed') {
+ } else if (input.child === readingKey(state) && input.name === 'browseClosed') {
if ((input.event as { request: number }).request === state.browse) state.browse = null;
- } else if (input.child === 'timeline' && input.name === 'searchClosed') {
+ } else if (input.child === readingKey(state) && input.name === 'searchClosed') {
state.searchOpen = false;
- } else if (input.child === 'timeline' && input.name === 'tailReady') {
+ } else if (input.child === readingKey(state) && input.name === 'tailReady') {
const receipt = input.event as NonNullable<State['tailReceipt']>;
if (receipt.landing === state.landing.sequence) {
state.tailReceipt = receipt;
@@ -644,7 +645,7 @@ export function handleComponent<N extends Node>(
state.followAttempt = null;
follow(state, actions);
} else if (
- input.child === 'timeline' &&
+ input.child === readingKey(state) &&
input.name === 'viewImage' &&
state.selected &&
state.window &&
@@ -660,7 +661,7 @@ export function handleComponent<N extends Node>(
through: state.window.page.through,
sequence: chunk.tool!.result!.sequence,
};
- } else if (input.child === 'timeline' && state.source) {
+ } else if (input.child === readingKey(state) && state.source) {
const source = state.source;
if (input.name === 'sourceBack') state.source = null;
else if (!source.loading && input.name === 'sourceField') {
@@ -689,7 +690,7 @@ export function handleComponent<N extends Node>(
: null;
if (position) readHistorySource(state, actions, position);
}
- } else if (input.child === 'timeline' && !state.loading && state.window) {
+ } else if (input.child === readingKey(state) && !state.loading && state.window) {
const page = state.window.page;
if (
input.name === 'markRead' &&
diff --git a/plugins/agent/agent-screen/conversation-view.ts b/plugins/agent/agent-screen/conversation-view.ts
index c7c289950f..f8797ef181 100644
--- a/plugins/agent/agent-screen/conversation-view.ts
+++ b/plugins/agent/agent-screen/conversation-view.ts
@@ -18,7 +18,7 @@
*/
import type { Action, RowsProps } from '../../../packages/plugin-sdk/src/index.js';
-import type { Theme } from '../../shared/contracts.js';
+import type { Theme, TimelineProps } from '../../shared/contracts.js';
import { copy } from '../../shared/locale.js';
import { tuiText } from '../../shared/tui-locale.js';
import { sameValue } from '../../shared/value.js';
@@ -34,12 +34,101 @@ import {
conversationRoute,
executionPending,
mutationPending,
- reviewTarget,
- rootCommands,
} from './actions.js';
+import { taskOverview } from './task-overview.js';
import { headerView } from './header-view.js';
import { messages, turnTimings, utf8Bytes } from './messages.js';
-import { type State } from './state.js';
+import { readingKey, type State } from './state.js';
+
+function readingProps(
+ state: State,
+ theme: Theme,
+ locale: string,
+ target: 'tui' | 'desktop',
+): TimelineProps {
+ const timelineMessages = messages(state);
+ return {
+ theme,
+ locale,
+ messages: timelineMessages,
+ timings: turnTimings(state, timelineMessages),
+ copyRequest: target === 'tui' && !state.image ? state.copyRequest : null,
+ browse: state.browse,
+ markReadEnabled:
+ !!state.selected?.hasUnread &&
+ !state.selected.archived &&
+ !mutationPending(state) &&
+ conversationRoute(state) &&
+ state.dialog === null &&
+ !state.source &&
+ !state.image,
+ search:
+ target === 'tui' && state.searchOpen && state.selected
+ ? {
+ sessionId: state.selected.id,
+ epoch:
+ state.visits.find((visit) => visit.id === state.selected?.id)?.epoch ?? state.epoch,
+ through: state.latestKnown,
+ }
+ : null,
+ source: state.source
+ ? {
+ kind: state.source.field === 'body' ? 'message' : state.source.field,
+ fields: state.source.fields,
+ format: state.source.format ?? 'text',
+ ready: state.source.ready,
+ key: `${state.source.field}-${state.source.through}-${state.source.chunk.sequence}-${state.source.chunk.offset}`,
+ text: state.source.chunk.text,
+ atStart: state.source.chunk.offset === 0,
+ atEnd:
+ state.source.chunk.offset + utf8Bytes(state.source.chunk.text) ===
+ state.source.chunk.totalBytes,
+ loading: state.source.loading,
+ failed: state.source.failed,
+ navigation: state.source.landing.sequence,
+ landing: state.source.landing.edge,
+ }
+ : null,
+ loading: state.loading,
+ older: !!state.window?.page.before,
+ newer: !!state.window?.page.after,
+ latest:
+ !!state.window &&
+ (!state.window.latest ||
+ state.latestKnown > state.window.page.through ||
+ state.live.some(
+ (fragment) =>
+ fragment.closedAt !== null && fragment.closedAt <= state.window!.page.through,
+ )),
+ landing: state.landing,
+ toolContext:
+ state.selected && state.window && state.window.page.through > 0
+ ? { sessionId: state.selected.id, through: state.window.page.through }
+ : null,
+ liveTools:
+ state.window?.latest && state.run
+ ? [
+ ...new Set(
+ state.window.page.chunks.flatMap((chunk) =>
+ chunk.turnId === state.run!.turnId && chunk.tool ? [chunk.tool.toolUseId] : [],
+ ),
+ ),
+ ]
+ : [],
+ waitingTools: state.window?.latest
+ ? (state.interactions?.entries ?? [])
+ .filter(
+ (entry) =>
+ !(
+ state.interaction?.record?.requestId === entry.requestId &&
+ state.interaction.record.outcome
+ ),
+ )
+ .flatMap((entry) => (entry.toolUseId === null ? [] : [entry.toolUseId]))
+ : [],
+ live: !!state.run && !!state.window?.latest,
+ };
+}
export function conversationView<N extends Node>(
presentation: Presentation<N>,
@@ -55,107 +144,77 @@ export function conversationView<N extends Node>(
ui = presentation.paint(theme),
busy = mutationPending(state);
const catalogBusy = busy || catalogPending(state);
- const timelineMessages = messages(state);
- const timeline = presentation.slot(
- 'timeline',
- 'timeline',
- {
- theme,
- locale,
- messages: timelineMessages,
- timings: turnTimings(state, timelineMessages),
- copyRequest: target === 'tui' && !state.image ? state.copyRequest : null,
- browse: state.browse,
- markReadEnabled:
- !!state.selected?.hasUnread &&
- !state.selected.archived &&
- !busy &&
- conversationRoute(state) &&
- state.dialog === null &&
- !state.source &&
- !state.image,
- search:
- target === 'tui' && state.searchOpen && state.selected
- ? { sessionId: state.selected.id, epoch: state.epoch, through: state.latestKnown }
- : null,
- source: state.source
- ? {
- kind: state.source.field === 'body' ? 'message' : state.source.field,
- fields: state.source.fields,
- format: state.source.format ?? 'text',
- ready: state.source.ready,
- key: `${state.source.field}-${state.source.through}-${state.source.chunk.sequence}-${state.source.chunk.offset}`,
- text: state.source.chunk.text,
- atStart: state.source.chunk.offset === 0,
- atEnd:
- state.source.chunk.offset + utf8Bytes(state.source.chunk.text) ===
- state.source.chunk.totalBytes,
- loading: state.source.loading,
- failed: state.source.failed,
- navigation: state.source.landing.sequence,
- landing: state.source.landing.edge,
- }
- : null,
- loading: state.loading,
- older: !!state.window?.page.before,
- newer: !!state.window?.page.after,
- latest:
- !!state.window &&
- (!state.window.latest ||
- state.latestKnown > state.window.page.through ||
- state.live.some(
- (fragment) =>
- fragment.closedAt !== null && fragment.closedAt <= state.window!.page.through,
- )),
- landing: state.landing,
- toolContext:
- state.selected && state.window && state.window.page.through > 0
- ? { sessionId: state.selected.id, through: state.window.page.through }
- : null,
- liveTools:
- state.window?.latest && state.run
- ? [
- ...new Set(
- state.window.page.chunks.flatMap((chunk) =>
- chunk.turnId === state.run!.turnId && chunk.tool ? [chunk.tool.toolUseId] : [],
- ),
- ),
- ]
- : [],
- waitingTools: state.window?.latest
- ? (state.interactions?.entries ?? [])
- .filter(
- (entry) =>
- !(
- state.interaction?.record?.requestId === entry.requestId &&
- state.interaction.record.outcome
- ),
- )
- .flatMap((entry) => (entry.toolUseId === null ? [] : [entry.toolUseId]))
- : [],
- live: !!state.run && !!state.window?.latest,
- },
- {
- older: 'older',
- newer: 'newer',
- latest: 'latest',
- markRead: 'mark-read',
- source: 'source',
- toolSource: 'tool-source',
- fileContext: 'fileContext',
- messageCopy: 'messageCopy',
- viewImage: 'view-image',
- sourceFirst: 'source-first',
- sourcePrevious: 'source-previous',
- sourceNext: 'source-next',
- sourceLast: 'source-last',
- sourceBack: 'source-back',
- sourceRetry: 'source-retry',
- sourceField: 'source-field',
- tailReady: 'tail-ready',
- searchClosed: 'search-closed',
- browseClosed: 'browse-closed',
- },
+ const timelineProps = readingProps(state, theme, locale, target);
+ const visit = state.visits.find((entry) => entry.key === readingKey(state));
+ // Keep bounded native readers, without serializing a second copy of every
+ // message and theme in component state. Evict oldest background content first.
+ while (
+ state.visits.length > 1 &&
+ utf8Bytes(JSON.stringify(state.visits.filter((entry) => entry !== visit))) > 160000
+ )
+ state.visits.splice(0, 1);
+ const timelineEvents = {
+ older: 'older',
+ newer: 'newer',
+ latest: 'latest',
+ markRead: 'mark-read',
+ source: 'source',
+ toolSource: 'tool-source',
+ fileContext: 'fileContext',
+ messageCopy: 'messageCopy',
+ viewImage: 'view-image',
+ sourceFirst: 'source-first',
+ sourcePrevious: 'source-previous',
+ sourceNext: 'source-next',
+ sourceLast: 'source-last',
+ sourceBack: 'source-back',
+ sourceRetry: 'source-retry',
+ sourceField: 'source-field',
+ tailReady: 'tail-ready',
+ searchClosed: 'search-closed',
+ browseClosed: 'browse-closed',
+ };
+ const timeline = ui.box(
+ 'task-readers',
+ [
+ ...state.visits.map((entry) => {
+ const props =
+ entry === visit
+ ? timelineProps
+ : readingProps(
+ {
+ ...state,
+ selected: entry.session,
+ window: entry.window,
+ source: entry.source,
+ image: entry.image,
+ landing: entry.landing,
+ searchOpen: entry.searchOpen,
+ browse: entry.browse,
+ run: null,
+ live: entry.live,
+ interactions: null,
+ interaction: null,
+ loading: false,
+ latestKnown: entry.window?.page.through ?? 0,
+ copyRequest: null,
+ },
+ theme,
+ locale,
+ target,
+ );
+ if (entry !== visit) props.markReadEnabled = false;
+ return ui.box(
+ 'task-reader-' + entry.key,
+ [presentation.slot(entry.key, 'timeline', props, timelineEvents)],
+ { visible: entry === visit, terminal: { grow: 1 }, style: { width: fill, height: fill } },
+ );
+ }),
+ ...(!visit
+ ? [presentation.slot('timeline-empty', 'timeline', timelineProps, timelineEvents)]
+ : []),
+ ],
+ { terminal: { grow: 1 }, style: { width: fill, height: fill } },
);
const imageViewer = presentation.slot(
'image-viewer',
@@ -220,7 +279,9 @@ export function conversationView<N extends Node>(
fileCommand: state.attachments.paste?.command ?? null,
running: !!state.run,
sendLabel: canExecute(state)
- ? text.send
+ ? state.run
+ ? tuiText(locale, 'task-queue')
+ : text.send
: state.modelSetup === 'connect'
? text.configureModels
: text.chooseModel,
@@ -269,7 +330,7 @@ export function conversationView<N extends Node>(
};
return {
key: entry.messageId,
- text: ` ${entry.kind === 'steering' ? (theme.ascii ? '^' : '↗') : entry.kind === 'in_flight' ? (theme.ascii ? '...' : '…') : theme.ascii ? '+' : '↳'} ${entry.preview}`,
+ text: `${tuiText(locale, entry.kind === 'steering' ? 'task-steering' : entry.kind === 'in_flight' ? 'task-current' : 'task-followup')} · ${entry.preview}`,
actions:
entry.kind === 'in_flight'
? []
@@ -379,6 +440,8 @@ export function conversationView<N extends Node>(
!state.loading &&
!!state.window &&
!state.window.page.chunks.length &&
+ !state.window.page.before &&
+ !state.window.page.after &&
!state.live.length &&
!state.run &&
!state.source &&
@@ -509,25 +572,22 @@ export function conversationView<N extends Node>(
ui.box(
'session-body',
[
+ taskOverview(presentation, state, theme, locale),
ui.box(
'conversation',
[
ui.box(
- 'welcome-mark',
+ 'task-introduction',
[
- presentation.slot(
- 'welcome',
- 'welcome',
- {
- background: theme.terminal?.background ?? theme.background >>> 8,
- foreground: theme.terminal?.foreground ?? theme.text >>> 8,
- motion: theme.motion,
- ascii: theme.ascii,
- },
- {},
+ ui.text('task-prompt', tuiText(locale, 'task-empty'), 18, theme.text, 650),
+ ui.text(
+ 'task-prompt-help',
+ tuiText(locale, 'task-empty-help'),
+ 13,
+ theme.muted,
),
],
- { visible: welcome, terminal: { grow: 1 } },
+ { visible: welcome, gap: 1 },
),
ui.box('retained-timeline', [timeline], {
visible: !state.image && !welcome,
@@ -544,16 +604,8 @@ export function conversationView<N extends Node>(
ui.box(
'retained-composer',
[
- ...(reviewTarget(state)
- ? [
- ui.button(
- 'interaction-open',
- `Ctrl G · ${text.reviewRequest}`,
- !rootCommands(state, locale, target).some(
- (command) => command.kind === 'interaction-open' && !command.disabled,
- ),
- ),
- ]
+ ...(state.run
+ ? [ui.text('queue-help', tuiText(locale, 'task-queue-help'), 12, theme.muted)]
: []),
presentation.slot('queue', 'rows', queueProps, {
activated: 'queue-action',
@@ -644,16 +696,6 @@ export function conversationView<N extends Node>(
ui.box('model-metadata', [models], {
terminal: { grow: 1, shrink: true },
}),
- ...(status
- ? [
- ui.text(
- 'status',
- status,
- 12,
- state.run?.state === 'waiting_for_user' ? theme.warning : theme.muted,
- ),
- ]
- : []),
],
{ row: true, gap: 1 },
),
diff --git a/plugins/agent/agent-screen/events.ts b/plugins/agent/agent-screen/events.ts
index 4b22c13af9..98737d6c4a 100644
--- a/plugins/agent/agent-screen/events.ts
+++ b/plugins/agent/agent-screen/events.ts
@@ -34,12 +34,20 @@ import { settingsCategory } from '../../shared/settings.js';
import { resolveLocale } from '../../shared/locale.js';
import { type Node, type Presentation } from '../../shared/view.js';
+import { messages } from './messages.js';
+import { toolCards } from '../../shared/tool-cards.js';
import { follow, select } from './actions.js';
import { handleComponent } from './component-events.js';
import { frame } from './frame.js';
import { handleRaw } from './raw-events.js';
import { delivery, queryFailed, querySucceeded, receipt } from './results.js';
-import { navigationBookmark, navigationKey, type Navigation, type State } from './state.js';
+import {
+ readingKey,
+ navigationBookmark,
+ navigationKey,
+ type Navigation,
+ type State,
+} from './state.js';
export function handleEvent<N extends Node>(
presentation: Presentation<N>,
@@ -90,6 +98,58 @@ export function handleEvent<N extends Node>(
state.settingsCategory = restored.category;
}
}
+ if (input.kind === 'raw' && input.key === 'outputs-latest')
+ input = { ...input, key: 'task-latest' };
+ if (
+ input.kind === 'component' &&
+ input.child === 'output-reading-' + readingKey(state) &&
+ input.name === 'input'
+ )
+ input = input.event as ComponentInput;
+ if (input.kind === 'raw' && state.inspector === 'outputs' && !state.dialog) {
+ if (
+ input.key.startsWith('output-item-') &&
+ (input.event as { kind?: string }).kind === 'submit'
+ ) {
+ const visit = state.visits.find((entry) => entry.id === state.selected?.id);
+ const id = input.key.slice('output-item-'.length);
+ const row = toolCards(messages(state)).find(({ message }) => message.id === id)?.message;
+ if (visit && row?.tool) visit.output = visit.output === id ? null : id;
+ else if (row?.source != null && !state.loading) {
+ state.inspector = null;
+ input = {
+ kind: 'component',
+ key: input.key,
+ child: readingKey(state),
+ name: 'source',
+ event: { sequence: row.source, field: 'body' },
+ };
+ }
+ } else if (input.key.startsWith('output-result-') || input.key.startsWith('output-source-')) {
+ const result = input.key.startsWith('output-result-');
+ const sequence = Number(
+ input.key.slice(result ? 'output-result-'.length : 'output-source-'.length),
+ );
+ if (Number.isSafeInteger(sequence) && sequence > 0 && !state.loading) {
+ state.inspector = null;
+ input = {
+ kind: 'component',
+ key: input.key,
+ child: readingKey(state),
+ name: result ? 'toolSource' : 'source',
+ event: { sequence, field: result ? 'result' : 'body' },
+ };
+ }
+ } else if (input.key === 'outputs-older' || input.key === 'outputs-newer') {
+ input = {
+ kind: 'component',
+ key: input.key,
+ child: readingKey(state),
+ name: input.key === 'outputs-older' ? 'older' : 'newer',
+ event: {},
+ };
+ }
+ }
if (input.kind === 'component' && input.child === 'attachment-list' && input.name === 'input')
input = input.event as ComponentInput;
if (input.kind === 'component' && input.child === 'composer' && input.name === 'attachments')
@@ -171,7 +231,7 @@ export function handleEvent<N extends Node>(
else queryFailed(state, request);
// A newer original paint/intent may have arrived while this read
// was pending. The last attempted basis prevents self-retry.
- if (request.kind === 'history' && request.implicit) follow(state, actions);
+ if (request.kind === 'history' && outcome.status === 'succeeded') follow(state, actions);
}
}
} else if (input.kind === 'component') {
diff --git a/plugins/agent/agent-screen/frame.ts b/plugins/agent/agent-screen/frame.ts
index 99f519eaee..5340a75930 100644
--- a/plugins/agent/agent-screen/frame.ts
+++ b/plugins/agent/agent-screen/frame.ts
@@ -63,6 +63,8 @@ import {
select,
uploadPastedFile,
} from './actions.js';
+import { taskProgress } from './task-overview.js';
+import { outputsView } from './outputs-view.js';
import { conversationView } from './conversation-view.js';
import { navigationView } from './navigation-view.js';
import { pagesView } from './pages-view.js';
@@ -134,6 +136,7 @@ export function frame<N extends Node>(
busy = mutationPending(state);
const catalogBusy = busy || catalogPending(state);
const commands = rootCommands(state, locale, target);
+ if (target === 'tui') taskProgress(state, locale);
const navigation = navigationView(presentation, state, theme, locale);
const { main, header, attached } = conversationView(
presentation,
@@ -231,6 +234,7 @@ export function frame<N extends Node>(
const inspectorOpen =
conversationRoute(state) && !!state.selected && (state.inspector !== null || inlineInteraction);
const inspectorSlots = [
+ outputsView(presentation, state, theme, locale),
presentation.slot(
'terminal-panel',
'terminals',
@@ -388,6 +392,12 @@ export function frame<N extends Node>(
),
},
{ key: 'f1', modifiers: [], input: 'help-open', enabled: true },
+ {
+ key: 'f4',
+ modifiers: [],
+ input: 'outputs-open',
+ enabled: commands.some((command) => command.kind === 'outputs-open' && !command.disabled),
+ },
{
key: 'g',
modifiers: ['primary'],
diff --git a/plugins/agent/agent-screen/header-view.ts b/plugins/agent/agent-screen/header-view.ts
index 2f5be15fcd..9ce3e13908 100644
--- a/plugins/agent/agent-screen/header-view.ts
+++ b/plugins/agent/agent-screen/header-view.ts
@@ -18,6 +18,7 @@
*/
import type { Theme } from '../../shared/contracts.js';
+import { tuiText } from '../../shared/tui-locale.js';
import { copy } from '../../shared/locale.js';
import { fill, pixels, type Presentation, type Node } from '../../shared/view.js';
@@ -149,13 +150,6 @@ export function headerView<N extends Node>(
ui.box(
'header-task',
[
- ...(state.selected && conversationRoute(state)
- ? [
- state.run?.state === 'waiting_for_user'
- ? ui.text('task-face', '-_-', 14, theme.warning)
- : ui.activity('task-face', state.run ? 'spring' : 'familiar', theme.accent),
- ]
- : []),
ui.box('header-title', [title], { terminal: { shrink: true } }),
ui.box('header-title-trailing', [], { terminal: { grow: 1 } }),
],
@@ -164,22 +158,33 @@ export function headerView<N extends Node>(
ui.box(
'toolbar',
[
- ...(['details-open', 'terminal-open', 'palette-open'] as const).map((key, at) =>
- ui.box(
- `${key}-cell`,
- [
- ui.button(
- key,
- [text.detailsOpen, text.terminalTitle, text.paletteTitle][at]!,
- key !== 'palette-open' && (!conversationRoute(state) || !state.selected),
- false,
- { background: 0 },
- undefined,
- [text.detailsOpen, text.terminalTitle, text.paletteTitle + ' · Ctrl+K'][at],
- ),
- ],
- { terminal: { shrink: false } },
- ),
+ ...(['outputs-open', 'details-open', 'terminal-open', 'palette-open'] as const).map(
+ (key, at) =>
+ ui.box(
+ `${key}-cell`,
+ [
+ ui.button(
+ key,
+ [
+ tuiText(locale, 'task-outputs'),
+ text.detailsOpen,
+ text.terminalTitle,
+ text.paletteTitle,
+ ][at]!,
+ key !== 'palette-open' && (!conversationRoute(state) || !state.selected),
+ false,
+ { background: 0 },
+ undefined,
+ [
+ tuiText(locale, 'task-outputs'),
+ text.detailsOpen,
+ text.terminalTitle,
+ text.paletteTitle + ' · Ctrl+K',
+ ][at],
+ ),
+ ],
+ { terminal: { shrink: false } },
+ ),
),
],
{ row: true, gap: 1, terminal: { shrink: true, wrap: true } },
diff --git a/plugins/agent/agent-screen/index.ts b/plugins/agent/agent-screen/index.ts
index 40b3c47c8f..4d47f1c391 100644
--- a/plugins/agent/agent-screen/index.ts
+++ b/plugins/agent/agent-screen/index.ts
@@ -89,6 +89,7 @@ export function agentScreen<N extends Node>(
inspector: null,
image: null,
window: null,
+ visits: [],
source: null,
navigation: 0,
landing: { sequence: 0, edge: 'end' },
diff --git a/plugins/agent/agent-screen/messages.ts b/plugins/agent/agent-screen/messages.ts
index b724967a31..8e82b1a23e 100644
--- a/plugins/agent/agent-screen/messages.ts
+++ b/plugins/agent/agent-screen/messages.ts
@@ -39,6 +39,7 @@ export function appendLive(
invocationId: string,
text: string,
) {
+ if (state.visits.find((visit) => visit.id === state.selected?.id)?.following === false) return;
const messageId = part.messageId;
const field = part.textKind === 'thinking' ? 'thinking' : 'body';
if (
diff --git a/plugins/agent/agent-screen/navigation-view.ts b/plugins/agent/agent-screen/navigation-view.ts
index b2a0c2d7ab..f08092d056 100644
--- a/plugins/agent/agent-screen/navigation-view.ts
+++ b/plugins/agent/agent-screen/navigation-view.ts
@@ -212,6 +212,11 @@ export function navigationView<N extends Node>(
theme,
locale,
sessions: state.catalog?.sessions ?? [],
+ previews: Object.fromEntries(
+ state.visits
+ .filter((visit) => visit.progress?.goal)
+ .map((visit) => [visit.id, visit.progress!.goal]),
+ ),
selected: state.selected?.id ?? null,
busy,
loading: catalogPending(state),
diff --git a/plugins/agent/agent-screen/outputs-view.ts b/plugins/agent/agent-screen/outputs-view.ts
new file mode 100644
index 0000000000..5957331fa5
--- /dev/null
+++ b/plugins/agent/agent-screen/outputs-view.ts
@@ -0,0 +1,204 @@
+/*
+ * 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.
+ */
+
+import type { Theme } from '../../shared/contracts.js';
+import { copy } from '../../shared/locale.js';
+import { toolLabel } from '../../shared/message-view.js';
+import { toolCards } from '../../shared/tool-cards.js';
+import { tuiText } from '../../shared/tui-locale.js';
+import { compactLabel, type Node, type Presentation } from '../../shared/view.js';
+import { toolPresentation } from '../tool-details/index.js';
+import { messages } from './messages.js';
+import type { State } from './state.js';
+
+export function outputsView<N extends Node>(
+ presentation: Presentation<N>,
+ state: State,
+ theme: Theme,
+ locale: string,
+): N {
+ const ui = presentation.paint(theme),
+ text = copy(locale);
+ const current = state.visits.find((visit) => visit.id === state.selected?.id);
+ const readers = state.visits
+ .filter((visit) => visit.outputFocus)
+ .map((visit) => {
+ const active = visit === current;
+ const window = active ? state.window : visit.window;
+ const displayed = messages(active ? state : { ...state, window, live: [] });
+ const rows = toolCards(displayed).filter(
+ ({ message }) =>
+ (message.role === 'assistant' && message.source !== null) || !!message.tool,
+ );
+ return ui.box(
+ 'output-pane-' + visit.key,
+ [
+ presentation.reading(
+ 'output-reading-' + visit.key,
+ {
+ command: visit.outputFocus,
+ content: ui.box(
+ 'output-records',
+ [
+ ...(!rows.length
+ ? [
+ ui.text(
+ 'output-empty',
+ tuiText(locale, 'task-outputs-empty'),
+ 13,
+ theme.muted,
+ ),
+ ]
+ : []),
+ ...rows.map(({ message }) => {
+ const tool = message.tool;
+ const label = tool ? toolLabel(message, locale) : null;
+ const expanded = visit.output === message.id;
+ return ui.box(
+ 'output-' + message.id,
+ [
+ ...(tool
+ ? [
+ ui.item!(
+ 'output-item-' + message.id,
+ ui.text(
+ 'output-label-' + message.id,
+ `${expanded ? '−' : '+'} ${label?.name ?? text.toolReturned}`,
+ 13,
+ theme.text,
+ 550,
+ ),
+ expanded,
+ !active,
+ { submit: true },
+ ),
+ ...(label?.status
+ ? [
+ ui.text(
+ 'output-state-' + message.id,
+ label.status,
+ 12,
+ tool.result?.isError ? theme.warning : theme.muted,
+ ),
+ ]
+ : []),
+ ]
+ : [
+ ui.item!(
+ 'output-item-' + message.id,
+ ui.text(
+ 'output-reply-' + message.id,
+ compactLabel(message.text.replace(/\s+/gu, ' '), 180),
+ 13,
+ theme.text,
+ ),
+ false,
+ !active,
+ { submit: true },
+ ),
+ ]),
+ ...(tool &&
+ toolPresentation(tool.call?.presentation) &&
+ window &&
+ window.page.through > 0
+ ? [
+ presentation.slot(
+ 'output-details-' + visit.key + '-' + message.id,
+ 'tools',
+ {
+ theme,
+ locale,
+ context: { sessionId: visit.id, through: window.page.through },
+ tool,
+ expanded,
+ focusOnPointer: true,
+ },
+ {},
+ ),
+ ]
+ : tool
+ ? [
+ ui.text(
+ 'output-summary-' + message.id,
+ compactLabel(message.text, 120),
+ 12,
+ theme.muted,
+ ),
+ ]
+ : []),
+ ...(message.source !== null
+ ? [
+ ui.button(
+ (tool?.result ? 'output-result-' : 'output-source-') +
+ message.source,
+ text.readSource,
+ !active || state.loading,
+ false,
+ { background: 0 },
+ ),
+ ]
+ : []),
+ ],
+ { gap: 0 },
+ );
+ }),
+ ],
+ { gap: 1, focusGroup: true, scroll: 'anchor', terminal: { grow: 1 } },
+ ),
+ },
+ { input: 'input' },
+ ),
+ ],
+ { visible: active, gap: 0, terminal: { grow: 1 } },
+ );
+ });
+ return ui.box(
+ 'outputs-panel',
+ [
+ ui.box(
+ 'outputs-heading',
+ [
+ ui.text('outputs-title', tuiText(locale, 'task-outputs'), 15, theme.text, 650),
+ ui.box('outputs-spacer', [], { terminal: { grow: 1 } }),
+ ui.button('inspector-close', text.closeModels),
+ ],
+ { row: true, gap: 1 },
+ ),
+ ui.text('outputs-explainer', tuiText(locale, 'task-evidence-hint'), 12, theme.muted),
+ ...readers,
+ ui.box(
+ 'outputs-navigation',
+ [
+ ...(state.window?.page.before
+ ? [ui.button('outputs-older', text.older, state.loading)]
+ : []),
+ ...(state.window?.page.after
+ ? [ui.button('outputs-newer', text.newer, state.loading)]
+ : []),
+ ...(state.window &&
+ (!state.window.latest || state.latestKnown > state.window.page.through)
+ ? [ui.button('outputs-latest', text.latest, state.loading)]
+ : []),
+ ],
+ { row: true, terminal: { wrap: true } },
+ ),
+ ],
+ { visible: state.inspector === 'outputs' && !state.dialog, gap: 1, terminal: { grow: 1 } },
+ );
+}
diff --git a/plugins/agent/agent-screen/package.ts b/plugins/agent/agent-screen/package.ts
index 55a557d8d8..92ae854d4e 100644
--- a/plugins/agent/agent-screen/package.ts
+++ b/plugins/agent/agent-screen/package.ts
@@ -89,7 +89,6 @@ Object.assign(entries.tui.imports, {
rows: one(sdk.contracts.rows),
checkpoint: one(sdk.contracts.valueCheckpoint),
themeFile: one(sdk.contracts.textFile),
- welcome: one(sdk.contracts.morphMark),
input: one(sdk.contracts.textInput),
});
for (const target of ['desktop', 'tui'] as const)
diff --git a/plugins/agent/agent-screen/raw-events.ts b/plugins/agent/agent-screen/raw-events.ts
index 4eb52e5c4a..006261bb63 100644
--- a/plugins/agent/agent-screen/raw-events.ts
+++ b/plugins/agent/agent-screen/raw-events.ts
@@ -46,6 +46,7 @@ import {
conversationRoute,
focusAttachments,
intent,
+ history,
mutationPending,
openInteraction,
rootCommand,
@@ -243,6 +244,10 @@ export function handleRaw<N extends Node>(
state.catalog = null;
catalog(state);
}
+ } else if (input.key === 'task-latest' && !state.loading && state.selected && !state.dialog) {
+ state.source = null;
+ state.image = null;
+ history(state, actions, { kind: 'tail' }, state.latestKnown, true);
} else if (input.key === 'catalog-refresh') {
if (!mutationPending(state) && !catalogPending(state)) catalog(state);
} else if (input.key === 'inspector-close' && !state.dialog) {
diff --git a/plugins/agent/agent-screen/results.ts b/plugins/agent/agent-screen/results.ts
index 900e46b06f..33550a72b7 100644
--- a/plugins/agent/agent-screen/results.ts
+++ b/plugins/agent/agent-screen/results.ts
@@ -43,6 +43,7 @@ import { sameFile } from '../../shared/attachments.js';
import { draftPage, draftReceipt, draftFailed } from '../../shared/drafts.js';
import { type Request, type Mutation, type DisplayPart, type State } from './state.js';
+import { readingKey } from './state.js';
import { utf8Bytes, appendLive, closeLive } from './messages.js';
import {
intent,
@@ -493,7 +494,21 @@ export function querySucceeded(state: State, actions: Action[], request: Request
includeThinking: true,
},
{},
- request.implicit ? [{ child: 'timeline', method: 'tail', input: null }] : [],
+ request.implicit ? [{ child: readingKey(state), method: 'tail', input: null }] : [],
+ );
+ break;
+ }
+ // A bounded canonical page may contain only non-text state rows. The
+ // latest visible result can be on the preceding page, including a failed
+ // turn with no assistant reply. Keep the same fence while finding it.
+ if (request.latest && !request.extend && !page.chunks.length && page.before) {
+ history(
+ state,
+ actions,
+ { kind: 'before', cursor: page.before },
+ page.through,
+ true,
+ request.implicit,
);
break;
}
diff --git a/plugins/agent/agent-screen/state.ts b/plugins/agent/agent-screen/state.ts
index 8d15ab2e26..b2e1337732 100644
--- a/plugins/agent/agent-screen/state.ts
+++ b/plugins/agent/agent-screen/state.ts
@@ -45,6 +45,7 @@ import type {
EditorCommand,
EditorObservation,
TerminalPaneCommand,
+ ReadingCommand,
} from '../../../packages/plugin-sdk/src/index.js';
import type {
AgentScreenProps,
@@ -176,7 +177,40 @@ export function navigationKey(state: State): string {
);
}
+// Bounded visited workspaces keep their original native reading owners. These
+// are presentation snapshots only; run, queue and permission facts are refreshed.
+export interface Visit {
+ id: string;
+ dataSource: AgentDataSource;
+ key: string;
+ window: State['window'];
+ live: State['live'];
+ following: boolean;
+ source: State['source'];
+ image: State['image'];
+ landing: State['landing'];
+ searchOpen: boolean;
+ browse: number | null;
+ inspector: State['inspector'];
+ output: string | null;
+ outputFocus: ReadingCommand | null;
+ session: AgentSession;
+ epoch: number;
+ progress: {
+ through: number;
+ goal: string;
+ action: string;
+ outcome: 'completed' | 'failed' | 'aborted' | null;
+ toolIssue: boolean;
+ } | null;
+}
+
+export function readingKey(state: State): string {
+ return state.visits.find((visit) => visit.id === state.selected?.id)?.key ?? 'timeline-empty';
+}
+
export interface State {
+ visits: Visit[];
bookmark: Bookmark<Navigation>;
embedding: AgentScreenProps;
externalSelection: AgentScreenProps['session'] | null;
@@ -216,7 +250,7 @@ export interface State {
settingsCategory: SettingsCategory;
insightsScope: InsightsProps['scope'];
sidebar: { open: boolean } | { command: TerminalPaneCommand | null };
- inspector: 'details' | 'terminal' | null;
+ inspector: 'details' | 'terminal' | 'outputs' | null;
terminals: boolean;
skills: boolean;
place: string;
@@ -307,6 +341,7 @@ export interface State {
export type RootCommand =
| 'browse'
+ | 'outputs-open'
| 'copy-file-path'
| 'copy-message'
| 'copy-source'
diff --git a/plugins/agent/agent-screen/task-overview.ts b/plugins/agent/agent-screen/task-overview.ts
new file mode 100644
index 0000000000..2db7ed7ced
--- /dev/null
+++ b/plugins/agent/agent-screen/task-overview.ts
@@ -0,0 +1,145 @@
+/*
+ * 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.
+ */
+
+import type { Theme } from '../../shared/contracts.js';
+import { copy } from '../../shared/locale.js';
+import { toolLabel } from '../../shared/message-view.js';
+import { tuiText } from '../../shared/tui-locale.js';
+import { compactLabel, type Node, type Presentation } from '../../shared/view.js';
+import { reviewTarget, rootCommands } from './actions.js';
+import type { State, Visit } from './state.js';
+
+// Only canonical tail pages update the summary. Reading older pages cannot
+// replace current progress, and an ended turn never claims a verified delivery.
+export function taskProgress(state: State, locale: string): Visit['progress'] {
+ const visit = state.visits.find((entry) => entry.id === state.selected?.id);
+ const previous = visit?.progress ?? null;
+ const queuedGoal = state.queue?.entries.find((entry) => entry.kind === 'in_flight')?.preview;
+ if (queuedGoal && previous) previous.goal = compactLabel(queuedGoal, 100);
+ const page = state.window?.page;
+ if (!page || !state.window?.latest || page.through < (previous?.through ?? 0)) return previous;
+ const last = page.chunks.at(-1);
+ const turn = last ? page.chunks.filter((chunk) => chunk.turnId === last.turnId) : [];
+ const goal = turn.find((chunk) => chunk.role === 'user' && chunk.field === 'body');
+ const tool = [...turn].reverse().find((chunk) => chunk.tool);
+ const timing = [...turn].reverse().find((chunk) => chunk.timing)?.timing;
+ const progress = {
+ through: page.through,
+ goal: queuedGoal
+ ? compactLabel(queuedGoal, 100)
+ : goal
+ ? compactLabel(goal.text.replace(/\s+/gu, ' ').trim(), 100)
+ : (previous?.goal ?? ''),
+ action: tool ? (toolLabel({ tool: tool.tool, live: !!state.run }, locale).name ?? '') : '',
+ outcome: timing?.finished?.outcome ?? null,
+ toolIssue: turn.some(
+ (chunk) =>
+ chunk.tool?.result?.isError ||
+ ['failed', 'unknown', 'rejected'].includes(chunk.tool?.result?.settlement ?? ''),
+ ),
+ };
+ if (visit) visit.progress = progress;
+ return progress;
+}
+
+export function taskOverview<N extends Node>(
+ presentation: Presentation<N>,
+ state: State,
+ theme: Theme,
+ locale: string,
+): N {
+ const ui = presentation.paint(theme),
+ text = copy(locale);
+ const progress = taskProgress(state, locale);
+ const review = reviewTarget(state);
+ const status = state.loading
+ ? text.loading
+ : state.notice && !['completed', 'failed'].includes(state.notice)
+ ? text[state.notice]
+ : review || state.run?.state === 'waiting_for_user'
+ ? tuiText(locale, 'task-waiting')
+ : state.run
+ ? tuiText(locale, 'task-running')
+ : state.selected?.archived
+ ? text.archivedConversation
+ : state.notice === 'failed' || progress?.outcome === 'failed'
+ ? tuiText(locale, 'task-failed')
+ : progress?.outcome === 'aborted'
+ ? tuiText(locale, 'task-stopped')
+ : progress?.outcome === 'completed' || state.notice === 'completed'
+ ? tuiText(locale, 'task-ended')
+ : tuiText(
+ locale,
+ progress?.goal || state.window?.page.chunks.length
+ ? 'task-idle'
+ : 'task-ready',
+ );
+ const needsLatest =
+ !!state.window && (!state.window.latest || state.latestKnown > state.window.page.through);
+ const available = (kind: 'interaction-open') =>
+ rootCommands(state, locale, presentation.target).some(
+ (command) => command.kind === kind && !command.disabled,
+ );
+ return ui.box(
+ 'task-overview',
+ [
+ ui.box(
+ 'task-state-actions',
+ [
+ ui.text(
+ 'task-state',
+ status,
+ 13,
+ review || progress?.toolIssue ? theme.warning : theme.muted,
+ 550,
+ { truncate: true },
+ ),
+ ui.box('task-state-space', [], { terminal: { grow: 1 } }),
+ ...(review
+ ? [
+ ui.button(
+ 'interaction-open',
+ text.reviewRequest + ' · Ctrl+G',
+ !available('interaction-open'),
+ ),
+ ]
+ : []),
+ ...(needsLatest ? [ui.button('task-latest', text.latest, state.loading)] : []),
+ ],
+ { row: true, gap: 1, terminal: { wrap: true } },
+ ),
+ ...(!state.run &&
+ (state.notice === 'failed' ||
+ progress?.outcome === 'failed' ||
+ progress?.outcome === 'aborted') &&
+ ui.focusButton
+ ? [ui.focusButton('task-continue', tuiText(locale, 'task-continue'), 'composer')]
+ : []),
+ ...(progress?.goal
+ ? [ui.text('task-goal', progress.goal, 13, theme.text, 400, { truncate: true })]
+ : []),
+ ...(progress?.toolIssue
+ ? [ui.text('task-tool-issue', tuiText(locale, 'task-tool-error'), 12, theme.warning)]
+ : state.run && progress?.action
+ ? [ui.text('task-progress', progress.action, 12, theme.muted, 400, { truncate: true })]
+ : []),
+ ],
+ { visible: !!state.selected && !state.source && !state.image, gap: 0 },
+ );
+}
diff --git a/plugins/agent/execution-picker/index.ts b/plugins/agent/execution-picker/index.ts
index 62d6f54db5..682ebd7ab3 100644
--- a/plugins/agent/execution-picker/index.ts
+++ b/plugins/agent/execution-picker/index.ts
@@ -458,7 +458,7 @@ function frame<N extends Node>(
content,
state.open,
undefined,
- { width: 64 },
+ { width: 64, anchorToBackground: true },
);
const out = output(state, view, events);
out.actions = actions;
diff --git a/plugins/settings/connection-form/events.ts b/plugins/settings/connection-form/events.ts
index 6dda327e0d..565fabc090 100644
--- a/plugins/settings/connection-form/events.ts
+++ b/plugins/settings/connection-form/events.ts
@@ -61,6 +61,14 @@ export function handleEvent<N extends Node>(
const target = presentation.target;
if (input.kind === 'component' && input.child === 'provider-list' && input.name === 'input')
input = input.event as ComponentInput;
+ if (
+ input.kind === 'component' &&
+ input.child === 'provider-query' &&
+ input.name === 'submitted'
+ ) {
+ const at = state.providers.findIndex((provider) => matchesProvider(state.search, provider));
+ input = { kind: 'raw', key: 'provider-' + at, event: { kind: 'submit' } };
+ }
state.revision++;
const actions: Action[] = [],
events: Emission[] = [];
@@ -211,7 +219,14 @@ export function handleEvent<N extends Node>(
? 'form-history-back'
: 'form-cancel'
: input.key;
- if (key === 'form-save') save(state, props, actions, validation);
+ if (key === 'provider-focus' && !state.provider && !state.baseline) {
+ const at = state.providers.findIndex((provider) => matchesProvider(state.search, provider));
+ if (at >= 0)
+ state.providerFocus = {
+ sequence: (state.providerFocus?.sequence ?? 0) + 1,
+ target: { kind: 'focus_item', key: 'provider-' + at },
+ };
+ } else if (key === 'form-save') save(state, props, actions, validation);
else if (
key === 'form-remove' &&
canRemove(state, props) &&
@@ -373,7 +388,9 @@ export function handleEvent<N extends Node>(
state.search.pending = null;
for (const name of ['name', 'slug', 'endpoint'] as FieldName[]) {
state.fields[name].enabled =
- name !== 'endpoint' || !!provider.descriptor.customEndpoint;
+ name !== 'endpoint' ||
+ !!provider.descriptor.customEndpoint ||
+ !!provider.descriptor.anonymous;
prefill(state, name);
}
}
diff --git a/plugins/settings/connection-form/view.ts b/plugins/settings/connection-form/view.ts
index 5a0f034521..826eadc200 100644
--- a/plugins/settings/connection-form/view.ts
+++ b/plugins/settings/connection-form/view.ts
@@ -162,10 +162,22 @@ export function frame<N extends Node>(
readOnly: false,
disabled: !!state.provider || !!state.baseline,
},
- { changed: 'provider-query' },
+ { changed: 'provider-query', submitted: 'submitted' },
),
],
- { gap: 0, style: { width: fill, height: pixels(36) } },
+ {
+ gap: 0,
+ shortcuts:
+ target === 'tui'
+ ? ['down', 'up'].map((key) => ({
+ key,
+ modifiers: [],
+ input: 'provider-focus',
+ enabled: !locked && providerRows.length > 0,
+ }))
+ : [],
+ style: { width: fill, height: pixels(36) },
+ },
),
...(state.search.error ? [ui.text('search-error', text[state.search.error])] : []),
],
@@ -242,7 +254,16 @@ export function frame<N extends Node>(
),
]),
...(!state.baseline && state.provider
- ? [ui.text('setup-help', text.connectionSetupHelp, 13, props.theme.muted)]
+ ? [
+ ui.text(
+ 'setup-help',
+ state.provider.descriptor.anonymous
+ ? tuiText(props.locale, 'anonymous-setup')
+ : text.connectionSetupHelp,
+ 13,
+ props.theme.muted,
+ ),
+ ]
: []),
// Keep native owners mounted when advanced settings are collapsed.
...names.map((name) => {
@@ -318,7 +339,8 @@ export function frame<N extends Node>(
readOnly: locked || !f.enabled,
disabled: false,
tabIndex: f.enabled ? 0 : -1,
- autoFocus: false,
+ autoFocus:
+ name === (state.provider?.descriptor.anonymous ? 'endpoint' : 'name'),
command: f.command,
...(['slug', 'name', 'endpoint'].includes(name) ? {} : { softWrap: true }),
},
@@ -361,7 +383,8 @@ export function frame<N extends Node>(
(name !== 'endpoint' ||
state.advanced ||
!!state.baseline ||
- !!state.provider?.descriptor.customEndpoint),
+ !!state.provider?.descriptor.customEndpoint ||
+ !!state.provider?.descriptor.anonymous),
gap: target === 'desktop' ? 8 : 1,
padding: 0,
style: { width: fill },
@@ -406,7 +429,9 @@ export function frame<N extends Node>(
[
ui.button(
'form-save',
- state.baseline ? text.saveConnection : text.continueSignIn,
+ state.baseline || state.provider?.descriptor.anonymous
+ ? text.saveConnection
+ : text.continueSignIn,
!canSave(state, props),
true,
),
diff --git a/plugins/settings/credential/index.ts b/plugins/settings/credential/index.ts
index 61d20a7f1e..d5f74e5147 100644
--- a/plugins/settings/credential/index.ts
+++ b/plugins/settings/credential/index.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { tuiText } from '../../shared/tui-locale.js';
import type {
Action,
ActionOutcome,
@@ -388,13 +389,21 @@ function frame<N extends Node>(
? text.keyLoading
: state.status.configured
? text.keyConfigured
- : text.keyAbsent;
+ : state.provider?.descriptor.anonymous
+ ? tuiText(props.locale, 'anonymous-credential')
+ : text.keyAbsent;
const canReview =
!busy(state) && !state.confirmation && props.target.connectionId === state.target.connectionId;
const form = ui.box(
'credential-form',
[
- ...pageHeader(presentation, state, props, events, text.signInConnection),
+ ...pageHeader(
+ presentation,
+ state,
+ props,
+ events,
+ state.provider?.descriptor.anonymous ? text.connectionProvider : text.signInConnection,
+ ),
ui.text(
'connection',
compactLabel(
diff --git a/plugins/settings/models/index.ts b/plugins/settings/models/index.ts
index 94ca6d7d76..970e812beb 100644
--- a/plugins/settings/models/index.ts
+++ b/plugins/settings/models/index.ts
@@ -590,7 +590,10 @@ function frame<N extends Node>(
radius: props.theme.menu.radius,
},
})
- : ui.dialog('model-popup', text.models, trigger, content, state.open, undefined, { width: 64 });
+ : ui.dialog('model-popup', text.models, trigger, content, state.open, undefined, {
+ width: 64,
+ anchorToBackground: true,
+ });
const out = output(state, view, events);
out.actions = actions;
return out;
diff --git a/plugins/shared/components.ts b/plugins/shared/components.ts
index c1cac9fe15..3348bfbef5 100644
--- a/plugins/shared/components.ts
+++ b/plugins/shared/components.ts
@@ -511,6 +511,18 @@ export function components<N extends Node>(
session.hasUnread ? 650 : 400,
{ truncate: true },
),
+ ...(props.previews?.[session.id]
+ ? [
+ ui.text(
+ `session-preview-${index}-${at}`,
+ props.previews[session.id]!,
+ 12,
+ props.theme.muted,
+ 400,
+ { truncate: true },
+ ),
+ ]
+ : []),
],
{ terminal: { grow: 1, shrink: true } },
),
@@ -848,9 +860,9 @@ export function components<N extends Node>(
if (!timing) return [messageNode];
const mark = timing.finished
? {
- completed: props.theme.ascii ? '+' : '✓',
- failed: '!',
- aborted: props.theme.ascii ? 'x' : '■',
+ completed: tuiText(props.locale, 'task-ended'),
+ failed: tuiText(props.locale, 'task-failed'),
+ aborted: tuiText(props.locale, 'task-stopped'),
}[timing.finished.outcome]
: props.theme.ascii
? '.'
@@ -895,7 +907,9 @@ export function components<N extends Node>(
),
]
: messages),
- ...(props.loading ? [ui.text('loading', text.loading, 14, props.theme.muted)] : []),
+ ...(props.loading && target !== 'tui'
+ ? [ui.text('loading', text.loading, 14, props.theme.muted)]
+ : []),
],
{
gap: target === 'desktop' ? 24 : 1,
diff --git a/plugins/shared/contracts.ts b/plugins/shared/contracts.ts
index 5745fd932a..785443942f 100644
--- a/plugins/shared/contracts.ts
+++ b/plugins/shared/contracts.ts
@@ -230,6 +230,7 @@ export interface ModelDefaultProps {
busy: boolean;
}
export interface NavigationProps {
+ previews?: Record<string, string>;
theme: Theme;
locale: string;
sessions: AgentSession[];
@@ -745,6 +746,11 @@ export const navigationContract = component(
'maka.agent.Navigation',
object(
{
+ previews: {
+ type: 'object',
+ maxProperties: 32,
+ additionalProperties: { type: 'string', maxLength: 512 },
+ },
theme: { $ref: '#/$defs/Theme' },
locale,
sessions: { type: 'array', maxItems: 32, items: { $ref: '#/$defs/AgentSession' } },
diff --git a/plugins/shared/message-view.ts b/plugins/shared/message-view.ts
index 3b873d68ae..08ff27d2f1 100644
--- a/plugins/shared/message-view.ts
+++ b/plugins/shared/message-view.ts
@@ -181,9 +181,9 @@ export function messageView<N extends Node>(
...(time && key === 'message-body-' + message.id
? {
wide: {
- min_width: 60,
+ min_width: 88,
direction: 'row' as const,
- children: { ['time-' + message.id]: { basis: Math.min(time.length + 1, 24) } },
+ children: { ['time-' + message.id]: { basis: Math.min(time.length + 2, 24) } },
},
}
: {}),
@@ -236,7 +236,7 @@ export function messageView<N extends Node>(
? [
ui.box(
'time-' + message.id,
- [ui.text('timestamp-' + message.id, time, 12, theme.subtle)],
+ [ui.text('timestamp-' + message.id, ' ' + time, 12, theme.subtle)],
{ surface: user, terminal: { basis: 0 } },
),
]
diff --git a/plugins/shared/tui-locale.ts b/plugins/shared/tui-locale.ts
index 2321dc0d41..7d319bcb68 100644
--- a/plugins/shared/tui-locale.ts
+++ b/plugins/shared/tui-locale.ts
@@ -20,6 +20,33 @@
// Original TUI catalogs (d8cff2b), preserving wording and interpolation keys.
const catalogs: Record<string, Record<string, string>> = {
en: {
+ 'task-outputs': 'Results',
+ 'task-evidence-hint':
+ 'Recorded files, commands and replies in this history window. Review them before accepting the result.',
+ 'task-outputs-empty': 'No output in this window. Earlier records remain available.',
+ 'task-continue': 'Add instructions and continue',
+
+ 'task-idle': 'Ready to continue',
+ 'task-ready': 'Ready to start',
+ 'task-running': 'Working',
+ 'task-waiting': 'Needs your decision',
+ 'task-ended': 'Turn ended',
+ 'task-failed': 'Run failed',
+ 'task-stopped': 'Stopped',
+ 'task-empty': 'What would you like to accomplish?',
+ 'task-empty-help':
+ 'Describe the result you want and how to check it. Enter sends; Shift+Enter adds a line.',
+ 'task-queue': 'Queue for next turn',
+ 'task-queue-help':
+ 'Sent while running: queued for the next turn. Use ↗ in the queue to bring it into the current run.',
+ 'task-current': 'Current run',
+ 'task-followup': 'Next turn',
+ 'task-steering': 'Current run · queued',
+ 'task-tool-error': 'A tool reported an error; inspect its result.',
+ 'anonymous-setup':
+ 'Confirm the endpoint to load models. Credentials are optional for this provider.',
+ 'anonymous-credential': 'No credentials saved. This provider supports anonymous connections.',
+
'settings-subagents': 'Subagents',
'subagents-help':
'Save reusable worker configurations for WorkHub. Each task keeps its original model and tool selection; later edits affect new tasks only.',
@@ -243,7 +270,7 @@ const catalogs: Record<string, Record<string, string>> = {
'oauth-request-failed': 'Could not complete the request. Check the Host and try again.',
'oauth-not-found':
'This Host cannot find the attempt. Check your connections before starting again.',
- 'route-agent-screen': 'Agent',
+ 'route-agent-screen': 'Tasks',
'state-open-failed':
'Cannot open local TUI state; existing data was preserved. Use another --profile for a concurrent terminal. { $error }',
'state-save-failed': 'Local changes not saved · check storage before closing',
@@ -280,7 +307,7 @@ const catalogs: Record<string, Record<string, string>> = {
'host-notification': '{ $kind } · revision { $revision }',
'language-auto': 'Automatic ({ $language })',
'localization-errors': 'Localization fallback: { $count } issue(s)',
- help: 'Tab / Shift+Tab Move focus between controls\nUp / Down Select a navigation, page or command item\nEnter Activate the focused item\nEnter Send from composer / queue for next turn\nShift+Enter New line in composer\nCtrl+J New line in composer (all terminals)\nCtrl+Enter Submit answers / run message revision\nCtrl+K / Ctrl+P Open command palette\nCtrl+F Find in conversation\nCtrl+N New s [...]
+ help: 'Tab / Shift+Tab Move focus between controls\nUp / Down Select a navigation, page or command item\nEnter Activate the focused item\nEnter Send from composer / queue for next turn\nShift+Enter New line in composer\nCtrl+J New line in composer (all terminals)\nCtrl+Enter Submit answers / run message revision\nCtrl+K / Ctrl+P Open command palette\nCtrl+F Find in conversation\nCtrl+N New s [...]
'command-agent-screen': 'Open Agent screen',
'command-host': 'Open Host connection',
'command-settings': 'Open settings',
@@ -1737,6 +1764,29 @@ const catalogs: Record<string, Record<string, string>> = {
'plugins-installed': 'Installed',
},
'zh-CN': {
+ 'task-outputs': '产物与验证',
+ 'task-evidence-hint': '查看文件、命令结果与回复;下方可载入更早的记录。',
+ 'task-outputs-empty': '当前窗口没有产出记录,可查看更早的记录。',
+ 'task-continue': '补充说明并继续',
+
+ 'task-idle': '待继续',
+ 'task-ready': '等待开始',
+ 'task-running': '正在执行',
+ 'task-waiting': '需要你决定',
+ 'task-ended': '本轮已结束',
+ 'task-failed': '本轮执行失败',
+ 'task-stopped': '已停止',
+ 'task-empty': '这次要完成什么?',
+ 'task-empty-help': '写下期望的结果和验收方式。Enter 发送,Shift+Enter 换行。',
+ 'task-queue': '排到下一轮',
+ 'task-queue-help': '执行中发送的内容会排到下一轮;可用队列中的 ↗ 提前加入当前执行。',
+ 'task-current': '正在处理',
+ 'task-followup': '下一轮',
+ 'task-steering': '当前执行 · 待处理',
+ 'task-tool-error': '有工具报告错误,请检查其结果。',
+ 'anonymous-setup': '确认端点后加载模型;此服务支持无凭据连接。',
+ 'anonymous-credential': '未保存凭据;此服务支持匿名连接。',
+
'settings-subagents': '子代理',
'subagents-help':
'为 WorkHub 保存可复用的代理配置。任务会保留派发时的模型和工具选择,后续修改只影响新任务。',
@@ -1942,7 +1992,7 @@ const catalogs: Record<string, Record<string, string>> = {
'oauth-save-failed': '无法保存恢复信息,未发送登录请求。请先核对本次授权,再开始新的授权。',
'oauth-request-failed': '请求未完成,请检查 Host 后重试。',
'oauth-not-found': 'Host 无法找到本次授权,请检查已有连接后再发起登录。',
- 'route-agent-screen': 'Agent 主界面',
+ 'route-agent-screen': '任务工作台',
'state-open-failed':
'无法打开 TUI 本地状态,已有数据未被覆盖。并行终端请使用不同的 --profile。{ $error }',
'state-save-failed': '本地更改未保存 · 关闭前请检查存储',
@@ -1978,8 +2028,8 @@ const catalogs: Record<string, Record<string, string>> = {
'host-notification': '{ $kind } · 修订 { $revision }',
'language-auto': '自动({ $language })',
'localization-errors': '本地化回退:{ $count } 个问题',
- help: 'Tab / Shift+Tab 在控件间移动焦点\n上 / 下 选择导航、页面或命令项\nEnter 激活焦点项\nEnter 发送消息 / 运行中排入下一轮\nShift+Enter 输入框内换行\nCtrl+J 消息输入框换行(所有终端)\nCtrl+Enter 提交问答 / 运行消息修订\nCtrl+K / Ctrl+P 打开命令面板\nCtrl+F 查找对话内容\nCtrl+N 新建会话\nCtrl+A / Ctrl+Z 编辑器内全选 / 撤销\nCtrl+G 查看待审批请求或问题\nCtrl+B 展开 / 折叠导航\nCtrl+PgUp/PgDn 切换会话页签\nCtrl+W 关闭页签(保留草稿与任务)\nF11 会话专注模式\nPa [...]
- 'command-agent-screen': '打开 Agent 主界面',
+ help: 'Tab / Shift+Tab 在控件间移动焦点\n上 / 下 选择导航、页面或命令项\nEnter 激活焦点项\nEnter 发送消息 / 运行中排入下一轮\nShift+Enter 输入框内换行\nCtrl+J 消息输入框换行(所有终端)\nCtrl+Enter 提交问答 / 运行消息修订\nCtrl+K / Ctrl+P 打开命令面板\nCtrl+F 查找对话内容\nCtrl+N 新建会话\nCtrl+A / Ctrl+Z 编辑器内全选 / 撤销\nF4 检查产物与执行证据\nCtrl+G 查看待审批请求或问题\nCtrl+B 展开 / 折叠导航\nCtrl+PgUp/PgDn 切换会话页签\nCtrl+W 关闭页签(保留草稿与任务)\ [...]
+ 'command-agent-screen': '打开 任务工作台',
'command-host': '打开 Host 连接',
'command-settings': '打开设置',
'command-help': '键盘和鼠标帮助',
@@ -2025,7 +2075,7 @@ const catalogs: Record<string, Record<string, string>> = {
'sessions-help': '会话 · 上下键选择,Enter 或点击打开;滚轮滚动。',
'sessions-next': '下一页',
'sessions-previous': '上一页',
- 'session-missing': '此会话已不存在,请返回 Agent 主界面。',
+ 'session-missing': '此会话已不存在,请返回 任务工作台。',
'session-id': '会话 ID:{ $value }',
'session-workspace': 'Host 工作目录:{ $value }',
'session-model': '模型:{ $value }',
@@ -2332,7 +2382,7 @@ const catalogs: Record<string, Record<string, string>> = {
'session-remove-unavailable': 'Host 暂时无法删除此状态的会话。',
'session-removed-draft': '此会话已不可用。本地草稿仍保留,可在下方选择并复制。',
'session-saving': '正在保存…',
- 'session-archive-note': '历史记录会保留。归档后可在 Agent 主界面恢复会话。',
+ 'session-archive-note': '历史记录会保留。归档后可在 任务工作台恢复会话。',
'session-name-too-large': '名称过长。',
'session-edit-conflict': '会话已在别处更改。请关闭并重新打开,确认最新版本。',
'session-edit-unknown': '结果尚未确认。请关闭并刷新,确认后再修改。',
@@ -3297,6 +3347,29 @@ const catalogs: Record<string, Record<string, string>> = {
'plugins-installed': '已安装',
},
'zh-TW': {
+ 'task-outputs': '產物與驗證',
+ 'task-evidence-hint': '檢視檔案、命令結果與回覆;下方可載入更早的記錄。',
+ 'task-outputs-empty': '目前視窗沒有產出記錄,可查看更早的記錄。',
+ 'task-continue': '補充說明並繼續',
+
+ 'task-idle': '待繼續',
+ 'task-ready': '等待開始',
+ 'task-running': '正在執行',
+ 'task-waiting': '需要你決定',
+ 'task-ended': '本輪已結束',
+ 'task-failed': '本輪執行失敗',
+ 'task-stopped': '已停止',
+ 'task-empty': '這次要完成什麼?',
+ 'task-empty-help': '寫下期望的結果和驗收方式。Enter 傳送,Shift+Enter 換行。',
+ 'task-queue': '排到下一輪',
+ 'task-queue-help': '執行中傳送的內容會排到下一輪;可用佇列中的 ↗ 提前加入目前執行。',
+ 'task-current': '正在處理',
+ 'task-followup': '下一輪',
+ 'task-steering': '目前執行 · 待處理',
+ 'task-tool-error': '有工具回報錯誤,請檢查其結果。',
+ 'anonymous-setup': '確認端點後載入模型;此服務支援無憑據連線。',
+ 'anonymous-credential': '未儲存憑據;此服務支援匿名連線。',
+
'settings-subagents': '子代理',
'subagents-help':
'為 WorkHub 儲存可重用的代理設定。任務會保留派發時的模型和工具選擇,後續修改只影響新任務。',
@@ -3502,7 +3575,7 @@ const catalogs: Record<string, Record<string, string>> = {
'oauth-save-failed': '無法儲存恢復資訊,未送出登入請求。請先核對本次授權,再開始新的授權。',
'oauth-request-failed': '請求未完成,請檢查 Host 後重試。',
'oauth-not-found': 'Host 無法找到本次授權,請檢查現有連線後再發起登入。',
- 'route-agent-screen': 'Agent 主畫面',
+ 'route-agent-screen': '任務工作台',
'state-open-failed':
'無法開啟 TUI 本機狀態,既有資料未被覆寫。並行終端請使用不同的 --profile。{ $error }',
'state-save-failed': '本機變更未儲存 · 關閉前請檢查儲存空間',
@@ -3538,8 +3611,8 @@ const catalogs: Record<string, Record<string, string>> = {
'host-notification': '{ $kind } · 修訂 { $revision }',
'language-auto': '自動({ $language })',
'localization-errors': '本地化回退:{ $count } 個問題',
- help: 'Tab / Shift+Tab 在控制項間移動焦點\n上 / 下 選擇導覽、頁面或命令項\nEnter 啟用焦點項\nEnter 傳送訊息 / 執行中排入下一輪\nShift+Enter 輸入框內換行\nCtrl+J 訊息輸入框換行(所有終端機)\nCtrl+Enter 提交問答 / 執行訊息修訂\nCtrl+K / Ctrl+P 開啟命令面板\nCtrl+F 尋找對話內容\nCtrl+N 建立對話\nCtrl+A / Ctrl+Z 編輯器內全選 / 復原\nCtrl+G 查看待審批請求或問題\nCtrl+B 展開 / 收合導覽\nCtrl+PgUp/PgDn 切換對話分頁\nCtrl+W 關閉分頁(保留草稿與工作)\nF11 工作階段專注模式 [...]
- 'command-agent-screen': '開啟 Agent 主畫面',
+ help: 'Tab / Shift+Tab 在控制項間移動焦點\n上 / 下 選擇導覽、頁面或命令項\nEnter 啟用焦點項\nEnter 傳送訊息 / 執行中排入下一輪\nShift+Enter 輸入框內換行\nCtrl+J 訊息輸入框換行(所有終端機)\nCtrl+Enter 提交問答 / 執行訊息修訂\nCtrl+K / Ctrl+P 開啟命令面板\nCtrl+F 尋找對話內容\nCtrl+N 建立對話\nCtrl+A / Ctrl+Z 編輯器內全選 / 復原\nF4 檢查產物與執行證據\nCtrl+G 查看待審批請求或問題\nCtrl+B 展開 / 收合導覽\nCtrl+PgUp/PgDn 切換對話分頁\nCtrl+W 關閉分頁(保留草稿與工作 [...]
+ 'command-agent-screen': '開啟 任務工作台',
'command-host': '開啟 Host 連線',
'command-settings': '開啟設定',
'command-help': '鍵盤和滑鼠說明',
diff --git a/plugins/shared/view.ts b/plugins/shared/view.ts
index ece1898af1..726e400053 100644
--- a/plugins/shared/view.ts
+++ b/plugins/shared/view.ts
@@ -126,7 +126,12 @@ interface Painter<N extends Node> {
content: N,
open: boolean,
style?: DesktopStyle,
- terminalSize?: { width?: number; height?: number; padding?: number },
+ terminalSize?: {
+ width?: number;
+ height?: number;
+ padding?: number;
+ anchorToBackground?: boolean;
+ },
): N;
box(key: string, children: N[], options?: BoxOptions): N;
}
@@ -270,7 +275,7 @@ function terminalPaint(theme: Theme): Painter<TerminalNode> {
content,
command,
pane_width: 32,
- inline_min_width: 60,
+ inline_min_width: 88,
auto_inline_min_width: 90,
motion: theme.motion,
overlay: {
@@ -393,6 +398,7 @@ function terminalPaint(theme: Theme): Painter<TerminalNode> {
content,
open,
width: terminalSize.width ?? 72,
+ anchor_to_background: terminalSize.anchorToBackground ?? false,
height: terminalSize.height ?? 20,
padding: terminalSize.padding ?? 1,
background_color: color(theme.background),
diff --git a/products/maka/bundle/README.md b/products/maka/bundle/README.md
index 07d6b1daf4..3e71b8c9e3 100644
--- a/products/maka/bundle/README.md
+++ b/products/maka/bundle/README.md
@@ -36,7 +36,9 @@ just run
just run --root /tmp/maka-workhub --product workhub
```
-The first launch confirms the workspace and installs the selected terminal product. Later launches reopen its saved configuration. The default UI Root is `~/.maka/application`, with its Host Root under `host`. Settings and Plugins edit the installed application; startup options do not overwrite an existing installation.
+The first launch confirms the workspace and installs the selected terminal product. Later launches automatically update an unchanged default composition to the bundled version, preserving installation identities, task data and saved settings. A customized composition stays unchanged. The default UI Root is `~/.maka/application`, with its Host Root under `host`; startup options cannot reconfigure an existing installation.
+
+For a customized composition, close Maka and run `maka application update-defaults` to preview a three-way merge of the previous defaults, local composition and new defaults. Use `--apply` to synchronize when the preview has no conflicts; `--root PATH` selects another installation. Independent changes merge by field; conflicting values, deletions and ordered lists report JSON Pointer paths and prevent the entire update. The preview validates the merged composition and actual packages. Co [...]
`just product` rebuilds the TypeScript bundle. Development binaries load `dist`, or the directory named by `MAKA_PRODUCT_BUNDLE`; TypeScript changes do not require Rust recompilation. Release binaries embed the bundle. Use a separate output directory when building an artifact alongside a running development build:
@@ -52,6 +54,12 @@ The task sidebar stays alongside the workspace. Drag its divider to resize it, o
Model, connection, project and task lists load or reveal further rows as they scroll. Rendered windows remain bounded, and overlapping rows keep the viewport anchored when the window moves. Scrolling does not take keyboard focus; arrows and Enter provide the corresponding list actions. Approvals and questions appear with the current task, while operation history remains available for recovery.
+The task header shows the current recorded state and goal. “Turn ended” describes execution, not verified delivery. F4 opens outputs and verification: expand file changes or command results, or read their original source. This panel follows the loaded history window; its earlier/newer controls expose other records.
+
+The three most recently visited tasks retain their reading owners, search and auxiliary context within a bounded content budget. Drafts use their existing independent storage. Returning to a task preserves the visible content, including a streaming prefix; “Latest” explicitly catches up with newer records. Running submissions are labelled “Queue next turn”; the queue can move a queued message into the current execution where supported.
+
+At narrow widths the task sidebar collapses automatically, retaining its chosen width for later. Model selection opens near the input area. Anonymous providers expose their endpoint without requiring advanced settings or sign-in.
+
## Author an installation
The CLI can produce checked sources and packages without Node at runtime:
diff --git a/products/maka/bundle/README.zh-CN.md b/products/maka/bundle/README.zh-CN.md
index a974581718..903521af44 100644
--- a/products/maka/bundle/README.zh-CN.md
+++ b/products/maka/bundle/README.zh-CN.md
@@ -36,7 +36,9 @@ just run
just run --root /tmp/maka-workhub --product workhub
```
-首次启动确认工作区并安装所选终端产品,之后重新打开保存的配置。默认 UI Root 为 `~/.maka/application`,Host Root 位于其 `host` 子目录。Settings 和 Plugins 修改已安装应用;启动选项不会覆盖已有安装。
+首次启动确认工作区并安装所选终端产品。后续启动会将未定制的默认组合自动更新为随软件发布的版本,保留安装身份、任务数据和已保存设置;用户定制过的组合保持不变。默认 UI Root 为 `~/.maka/application`,Host Root 位于其 `host` 子目录;启动选项不能重新配置已有安装。
+
+自定义组合可在关闭 Maka 后运行 `maka application update-defaults`,预览“上次默认组合、当前自定义组合、新版默认组合”的三方合并。没有冲突时加 `--apply` 执行同步;`--root PATH` 可选择其他安装。独立修改按字段合并;同一值、删除或有序列表发生冲突时,输出 JSON Pointer 路径,整次更新不写入。预览会校验合并后的组合和实际插件包。同步完成后记录新版默认基线;中断的写入会在重新启动时继续完成。尚未记录该基线的旧开发存储需要删除后重新初始化。
`just product` 重建 TypeScript bundle。开发二进制读取 `dist` 或 `MAKA_PRODUCT_BUNDLE` 指定的目录,仅修改 TypeScript 无需重新编译 Rust。发布二进制嵌入 bundle。需要与开发构建同时生成产物时,使用独立输出目录:
@@ -52,6 +54,12 @@ node products/maka/bundle/scripts/build.mjs --output /tmp/maka-product-bundle
模型、连接、项目和任务列表随滚动加载或显示后续条目。渲染窗口保持有界,移动窗口时用重叠条目保持视口位置。滚动不抢键盘焦点;方向键和 Enter 提供对应的列表操作。审批和问题随当前任务展示,操作历史仍可用于恢复未确认的结果。
+任务顶部显示记录中的当前状态和目标。“本轮已结束”表示执行结束,不代表已验收交付。F4 打开“产物与验证”,可展开文件修改、命令结果或读取原始内容。面板展示当前加载的历史窗口,使用较早/较新记录入口可继续查看。
+
+最近访问的三个任务在有界内容预算内保留阅读位置、搜索和辅助面板;草稿沿用独立存储。切回任务时保留当时可见的内容,包括流式文本片段,通过“最新消息”主动查看后续结果。执行中发送的内容标记为“排到下一轮”;支持时可在队列中将其提前加入当前执行。
+
+窄屏自动收起任务侧栏,恢复宽屏后保留先前宽度。模型选择靠近输入区打开。匿名服务直接显示端点,不必进入高级设置或登录。
+
## 生成安装配置
CLI 可以生成经过校验的配置和包,运行时无需 Node:
diff --git a/products/maka/bundle/src/composition.ts b/products/maka/bundle/src/composition.ts
index 76bac104d0..712eff9637 100644
--- a/products/maka/bundle/src/composition.ts
+++ b/products/maka/bundle/src/composition.ts
@@ -908,7 +908,6 @@ function agentComposition(configuration: CompositionConfiguration): ApplicationS
rows: [local('rows', 'Rows')],
checkpoint: [local('checkpoint', 'Checkpoint')],
themeFile: [local('text-file', 'TextFile')],
- welcome: [local('morph-mark', 'MorphMark')],
input: [local('input', 'TextInput')],
}
: {}),
diff --git a/products/maka/bundle/tests/connections.test.mjs b/products/maka/bundle/tests/connections.test.mjs
index 1403b3d1c6..dddf1b52f3 100644
--- a/products/maka/bundle/tests/connections.test.mjs
+++ b/products/maka/bundle/tests/connections.test.mjs
@@ -97,6 +97,17 @@ async function client(name, platform, extra = {}) {
};
}
for (const platform of ['tui', 'desktop']) {
+ test(`${platform} provider search Enter selects its visible result and exposes anonymous endpoints`, async () => {
+ const c = await client('connection-form', platform);
+ const anonymous = {
+ ...provider,
+ descriptor: { ...provider.descriptor, anonymous: true, authentication: [] },
+ };
+ c.reply(c.action('providers'), { providers: [anonymous], next: null });
+ c.event({ kind: 'component', child: 'provider-query', name: 'submitted', event: {} });
+ assert.deepEqual(c.state.provider, anonymous);
+ assert.equal(c.state.fields.endpoint.enabled, true);
+ });
test(`${platform} provider search scans all pages and ignores stale editor reads`, async () => {
const c = await client('connection-form', platform);
c.reply(c.action('providers'), {
diff --git a/products/maka/bundle/tests/task-workbench.test.mjs b/products/maka/bundle/tests/task-workbench.test.mjs
new file mode 100644
index 0000000000..f8d239a401
--- /dev/null
+++ b/products/maka/bundle/tests/task-workbench.test.mjs
@@ -0,0 +1,416 @@
+/*
+ * 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.
+ */
+
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+const appearance = await import('../dist/packages/appearance/tui.js');
+const theme = appearance.activate({ scheme: 'dark' }).Appearance.value;
+const module = await import('../dist/packages/agent-screen/tui.js');
+module.activate({ locale: 'en' });
+const values = { appearance: [theme] };
+const dataSource = {
+ stateDomain: 'a'.repeat(64),
+ deployment: '00000000-0000-0000-0000-000000000001',
+ namespace: ['00000000-0000-0000-0000-000000000002'],
+};
+const session = (id) => ({
+ id,
+ title: id,
+ labels: [],
+ revision: 1,
+ updatedAt: 1,
+ archived: false,
+ activity: 'idle',
+ boundaryRevision: 0,
+ hasUnread: false,
+ dataSource,
+ permissions: { access: 'workspace_write', requestPermissions: true },
+ workspace: { path: '/project', directoryIdentity: 'a'.repeat(64), identity: 'workspace-a' },
+});
+const chunk = (sequence, text, extra = {}) => ({
+ messageId: 'message-' + sequence,
+ turnId: 'turn-1',
+ timestamp: 1,
+ role: 'assistant',
+ field: 'body',
+ sequence,
+ offset: 0,
+ totalBytes: Buffer.byteLength(text),
+ text,
+ tool: null,
+ ...extra,
+});
+const page = (through, chunks, extra = {}) => ({
+ kind: 'ready',
+ through,
+ chunks,
+ before: null,
+ after: null,
+ ...extra,
+});
+function nodes(node) {
+ return node && typeof node === 'object'
+ ? [
+ node,
+ ...Object.values(node).flatMap((v) => (Array.isArray(v) ? v.flatMap(nodes) : nodes(v))),
+ ]
+ : [];
+}
+function client() {
+ let out = module.component.mount({}, values);
+ const c = {
+ get out() {
+ return out;
+ },
+ get state() {
+ return out.frame.state;
+ },
+ event(input) {
+ out = module.component.event(c.state, input, {}, values);
+ return c;
+ },
+ raw(key) {
+ return c.event({ kind: 'raw', key, event: { kind: 'activate' } });
+ },
+ child(child, name, event = {}) {
+ return c.event({ kind: 'component', key: name, child, name, event });
+ },
+ reply(action, value) {
+ assert.ok(action);
+ return c.event({ kind: 'action', key: action.key, event: { status: 'succeeded', value } });
+ },
+ action(method) {
+ return out.actions.find((a) => a.method === method);
+ },
+ observe(through, run = null) {
+ return c.reply(c.action('observe'), { session: c.state.selected, through, parts: [], run });
+ },
+ history(value) {
+ return c.reply(c.action('history'), value);
+ },
+ select(id) {
+ return c.child('navigation', 'select', { sessionId: id });
+ },
+ find(key) {
+ return nodes(out.frame.view).find((node) => node.key === key);
+ },
+ reader() {
+ return c.state.visits.find((visit) => visit.id === c.state.selected?.id).key;
+ },
+ };
+ c.reply(c.action('page'), { entries: [], nextAfter: 0 });
+ c.event({
+ kind: 'action',
+ key: c.state.catalogWatch.key,
+ event: {
+ status: 'updated',
+ value: {
+ sessions: [session('Task A'), session('Task B'), session('Task C'), session('Task D')],
+ before: null,
+ after: null,
+ },
+ },
+ });
+ return c;
+}
+
+test('switching tasks preserves the native reading owner, page, search basis and auxiliary context', () => {
+ const c = client()
+ .observe(20)
+ .history(page(20, [chunk(10, 'Older Chinese 中文🙂'), chunk(18, 'Newer answer')]));
+ const first = c.reader();
+ c.raw('search-open');
+ const searchEpoch = c.find(first).props.search.epoch;
+ c.raw('outputs-open');
+ assert.equal(c.state.inspector, 'outputs');
+ const landing = structuredClone(c.state.landing);
+ c.select('Task B')
+ .observe(2)
+ .history(page(2, [chunk(1, 'Task B answer')]));
+ assert.notEqual(c.reader(), first);
+ assert.ok(c.find(first), 'the original reading owner remains mounted while hidden');
+ assert.equal(c.find('task-reader-' + first).visible, false);
+ c.select('Task A');
+ assert.equal(c.reader(), first);
+ assert.deepEqual(c.state.landing, landing);
+ assert.equal(c.state.window.page.through, 20);
+ assert.equal(c.state.inspector, 'outputs');
+ assert.equal(
+ c.find(first).props.search.epoch,
+ searchEpoch,
+ 'returning does not reset the search input',
+ );
+ c.observe(25);
+ assert.equal(
+ c.action('history'),
+ undefined,
+ 'background progress does not replace the saved reading page',
+ );
+ assert.equal(c.find(first).props.latest, true);
+ assert.ok(c.find('task-latest'));
+ c.child('timeline-noncurrent', 'source', { sequence: 10 });
+ assert.equal(
+ c.state.source,
+ null,
+ 'late callbacks from another task do not redirect this reader',
+ );
+});
+
+test('ended turns describe completion of execution without asserting successful delivery', () => {
+ const c = client()
+ .observe(20)
+ .history(
+ page(20, [
+ chunk(10, 'Result', {
+ timing: { startedAt: 1, finished: { at: 2, outcome: 'completed' } },
+ }),
+ ]),
+ );
+ assert.equal(c.find('task-state').text, 'Turn ended');
+ assert.equal(c.find('task-face'), undefined);
+ c.select('Task B')
+ .observe(30)
+ .history(
+ page(30, [
+ chunk(20, 'Provider failed', {
+ timing: { startedAt: 1, finished: { at: 2, outcome: 'failed' } },
+ }),
+ ]),
+ );
+ assert.equal(c.find('task-state').text, 'Run failed');
+ assert.equal(c.find('task-continue').focus_child, 'composer');
+});
+
+test('a restored empty task loads new failure facts without requiring a tail receipt', () => {
+ const c = client().observe(0).history(page(0, []));
+ c.select('Task B')
+ .observe(1)
+ .history(page(1, [chunk(1, 'Another task')]));
+ c.select('Task A').observe(0);
+ c.event({
+ kind: 'action',
+ key: c.state.watch.key,
+ event: {
+ status: 'updated',
+ value: {
+ kind: 'events',
+ through: 30,
+ nextAfter: 30,
+ events: [
+ { sequence: 1, invocationId: 'failed-run', turnId: 'turn-1', fact: { kind: 'opened' } },
+ {
+ sequence: 30,
+ invocationId: 'failed-run',
+ turnId: 'turn-1',
+ fact: { kind: 'ended', failed: true },
+ },
+ ],
+ },
+ },
+ });
+ assert.equal(c.action('history').input.through, 30);
+ c.history(
+ page(30, [
+ chunk(20, 'Failed request', {
+ role: 'user',
+ timing: { startedAt: 1, finished: { at: 2, outcome: 'failed' } },
+ }),
+ ]),
+ );
+ assert.equal(c.find('task-state').text, 'Run failed');
+ assert.equal(c.find('task-continue').focus_child, 'composer');
+ assert.equal(c.find('task-introduction').visible, false);
+});
+
+test('failure arriving during the initial empty history read is not stranded behind the introduction', () => {
+ const c = client().observe(0);
+ const pending = c.action('history');
+ c.event({
+ kind: 'action',
+ key: c.state.watch.key,
+ event: {
+ status: 'updated',
+ value: {
+ kind: 'events',
+ through: 30,
+ nextAfter: 30,
+ events: [
+ { sequence: 1, invocationId: 'failed-run', turnId: 'turn-1', fact: { kind: 'opened' } },
+ {
+ sequence: 30,
+ invocationId: 'failed-run',
+ turnId: 'turn-1',
+ fact: { kind: 'ended', failed: true },
+ },
+ ],
+ },
+ },
+ });
+ c.reply(pending, page(0, []));
+ assert.equal(c.action('history').input.through, 30);
+ c.history(
+ page(30, [
+ chunk(20, 'Failed request', {
+ role: 'user',
+ timing: { startedAt: 1, finished: { at: 2, outcome: 'failed' } },
+ }),
+ ]),
+ );
+ assert.equal(c.find('task-state').text, 'Run failed');
+ assert.equal(c.find('task-introduction').visible, false);
+});
+
+test('a failure with no assistant reply traverses empty state pages and retains the failure timing', () => {
+ const c = client()
+ .observe(30)
+ .history(page(30, [], { before: { sequence: 25, offset: 0, field: 'body' } }));
+ assert.deepEqual(c.action('history').input.position, {
+ kind: 'before',
+ cursor: { sequence: 25, offset: 0, field: 'body' },
+ });
+ assert.equal(c.action('history').input.through, 30);
+ c.history(
+ page(
+ 30,
+ [
+ chunk(20, 'The failed request', {
+ role: 'user',
+ timing: { startedAt: 1, finished: { at: 2, outcome: 'failed' } },
+ }),
+ ],
+ { after: { sequence: 25, offset: 0, field: 'body' } },
+ ),
+ );
+ assert.equal(c.find('task-state').text, 'Run failed');
+ assert.equal(c.find('task-continue').focus_child, 'composer');
+ assert.equal(c.state.window.latest, true);
+});
+
+test('returning to a streaming task preserves its visible prefix until latest is explicitly requested', () => {
+ const c = client()
+ .observe(1, { invocationId: 'run-a', turnId: 'turn-1', state: 'running' })
+ .history(page(1, [chunk(1, 'Task goal', { role: 'user' })]));
+ c.event({
+ kind: 'action',
+ key: c.state.watch.key,
+ event: {
+ status: 'updated',
+ value: {
+ kind: 'events',
+ through: 3,
+ nextAfter: 3,
+ events: [
+ {
+ sequence: 2,
+ invocationId: 'run-a',
+ turnId: 'turn-1',
+ fact: {
+ kind: 'part_started',
+ messageId: 'stream-a',
+ stepId: 'step-a',
+ partId: 'text',
+ textKind: 'text',
+ },
+ },
+ {
+ sequence: 3,
+ invocationId: 'run-a',
+ turnId: 'turn-1',
+ fact: { kind: 'delta', stepId: 'step-a', partId: 'text', text: '正在阅读的中文🙂' },
+ },
+ ],
+ },
+ },
+ });
+ const reader = c.reader();
+ const visible = c.find(reader).props.messages;
+ assert.ok(visible.some((message) => message.text === '正在阅读的中文🙂'));
+ c.select('Task B').observe(1).history(page(1, []));
+ assert.deepEqual(
+ c.find(reader).props.messages,
+ visible,
+ 'hidden readers retain streaming content too',
+ );
+ c.select('Task A').observe(50);
+ c.child(reader, 'tailReady', { landing: c.state.landing.sequence, readingRevision: 9 });
+ assert.equal(
+ c.action('history'),
+ undefined,
+ 'returning at the old tail does not jump to new results',
+ );
+ assert.deepEqual(c.find(reader).props.messages, visible);
+ c.raw('task-latest');
+ assert.equal(c.action('history').input.through, 50);
+});
+
+test('output review opens original structured evidence and full output at the same history fence', () => {
+ const tool = {
+ toolUseId: 'write-1',
+ call: {
+ sequence: 4,
+ name: 'Write',
+ title: null,
+ presentation: 'maka.workspace.Files@1/write',
+ pending: false,
+ dispatched: true,
+ },
+ result: { sequence: 8, settlement: 'returned', isError: false, image: false },
+ };
+ const c = client()
+ .observe(20)
+ .history(
+ page(20, [
+ chunk(4, 'write arguments', { role: 'tool_call', tool }),
+ chunk(8, 'write result', { role: 'tool_result', tool }),
+ chunk(10, 'Delivery description'),
+ ]),
+ );
+ c.raw('outputs-open');
+ const evidence = nodes(c.out.frame.view).find((node) => node.binding === 'tools');
+ assert.equal(evidence.props.expanded, false);
+ assert.deepEqual(evidence.props.context, { sessionId: 'Task A', through: 20 });
+ c.event({ kind: 'raw', key: 'output-item-write-1', event: { kind: 'submit' } });
+ assert.equal(
+ nodes(c.out.frame.view).find((node) => node.binding === 'tools').props.expanded,
+ true,
+ );
+ c.raw('output-result-4');
+ assert.equal(c.state.inspector, null);
+ assert.equal(c.action('source').input.through, 20);
+ assert.equal(c.action('source').input.source.sequence, 8);
+ assert.equal(c.action('source').input.field, 'result');
+});
+
+test('long task visits stay within the UI call envelope while recent readers remain retained', () => {
+ const c = client()
+ .observe(20)
+ .history(page(20, [chunk(10, '界🙂'.repeat(12000))]));
+ c.select('Task B')
+ .observe(30)
+ .history(page(30, [chunk(20, '中文'.repeat(12000))]));
+ assert.ok(c.state.visits.length >= 2);
+ c.select('Task C')
+ .observe(40)
+ .history(page(40, [chunk(30, 'long output '.repeat(9000))]));
+ c.select('Task D')
+ .observe(50)
+ .history(page(50, [chunk(40, 'long output '.repeat(9000))]));
+ assert.ok(c.state.visits.length <= 3);
+ assert.ok(Buffer.byteLength(JSON.stringify(c.out)) < 1024 * 1024);
+});
diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json
index 2f4866c3b5..f662712e96 100644
--- a/products/maka/cli/notices.json
+++ b/products/maka/cli/notices.json
@@ -6,7 +6,7 @@
"package.json": "38baf3beeb5876849ca16e31868d59bfaee769f260c7555b4a8a1847b92e28a1",
"packages/plugin-sdk/src/configurations.ts": "ec70531da1097cae8d3835fcdeab73e2354e955b807a25f3cdbdab35005297a1",
"packages/plugin-sdk/src/contracts.ts": "00c20f6ad27f2054ceec8789ad3f6f3093c0d7945ecc6d9ddc6b98536a56c471",
- "packages/plugin-sdk/src/generated/runtime.ts": "0fa2f86cc07f930a281465a17f73b693152822754ec198d7d1af9cd731fa797d",
+ "packages/plugin-sdk/src/generated/runtime.ts": "763095f69add88f6ca0fee9e050907546fa81eee5ee89f32e78b1c0ab8351674",
"packages/plugin-sdk/src/index.ts": "27b68be5a29d1857f7fe361c8c678e77ba21413c79ec3870c1ce30d0faf9323f",
"packages/plugin-sdk/src/native.ts": "e207147ef7d3e2c39533c1ff24aae942c86da5350f8b282bae0d4fb417bbf37b",
"packages/plugin-sdk/src/ui.ts": "d6c64e9406c41ad6bbda73435b559aad24c31adb3c5bbb4ab6f49fff8a8330ca",
@@ -43,21 +43,23 @@
"plugins/agent/acp-executor/engine/tests/support/tui-auth.mjs": "b66436ace65b5874b6cd3ab66ece94984552c4a0772a0ae2d9b05db6b9f47d0f",
"plugins/agent/acp-executor/native/Cargo.toml": "6a896cb424d361ef3a98efa56a92ffc8680156473822bd1d38374b60f3f1da66",
"plugins/agent/acp-executor/package.ts": "e94a4474c44b1d8b779518dd87ff1b0189afe9adf7380a74bb0e4ea5f06aefdc",
- "plugins/agent/agent-screen/actions.ts": "b9bd6da21f04f96f6f3e8fa70593c29f3be649faaede874585d435ac77e91492",
- "plugins/agent/agent-screen/component-events.ts": "d3c5e37cbb7470655768348a3efc2276b85dd9980ac262079f07af8941bfda56",
- "plugins/agent/agent-screen/conversation-view.ts": "1da3f18727d259cb3d946196defe6ba8a4a2757c61b162d974d2ea19298abece",
+ "plugins/agent/agent-screen/actions.ts": "93d59bc6ec3108a96d7840c5f9a25c132fd99610c98c7250f7657b9f94df2ecf",
+ "plugins/agent/agent-screen/component-events.ts": "91a97fe4cdc654c9c218c1e0ad73b36a3b471fe88434b62d75d7078c5dd46960",
+ "plugins/agent/agent-screen/conversation-view.ts": "6082c6bedd560ca4044546967f828a9c4e50460f360ea05f1502b81cf96c5914",
"plugins/agent/agent-screen/desktop.ts": "d64f976e965c124ab736edf11adc5d998d22d021c8e85753f88a963d16f2834f",
- "plugins/agent/agent-screen/events.ts": "7c632cd184159653a6d9ad44e35228b4c0a23fd33c8806bde3439e4392695b58",
- "plugins/agent/agent-screen/frame.ts": "aa041bea6e65e9c6eb01e2ee8130331595b5b0911ed4cf9b24618835f8e7fb83",
- "plugins/agent/agent-screen/header-view.ts": "10146354753777deacf9318bbb52b3b5c2e0c2a7f6cad00b99820648c9c0a8f5",
- "plugins/agent/agent-screen/index.ts": "4d2758ab74dd264f38315dabb65ef5ac71094a56a7fb9a22100e82b47e369f24",
- "plugins/agent/agent-screen/messages.ts": "2446c4f62aa4f5c8f755b71ad5934f3f2843a09cf9dbc0918620a1650d997606",
- "plugins/agent/agent-screen/navigation-view.ts": "a72e2e501ccaffa0fbef1a326f8c891d5ee535c02ea7a45b27d1d9360f470a61",
- "plugins/agent/agent-screen/package.ts": "75d52e48fbf7ec6e4bb784cef84b14ef2e4f2a76e33d548d0b89778a6cc02e6d",
+ "plugins/agent/agent-screen/events.ts": "5b3a966990d8434729b2d01270aef5c57e7bdf42d08ab43c82e723c2002e3e10",
+ "plugins/agent/agent-screen/frame.ts": "90e820feefde9a37b06a942852296a199d702629feff8037dc423f13e36ecbb3",
+ "plugins/agent/agent-screen/header-view.ts": "e4f70605649117914440dca94e1ae3912bb1fcad4e38a855310bd459e2a361e0",
+ "plugins/agent/agent-screen/index.ts": "ef4ec3e1722887782d1d5f30b536ebb098abd026aa72e15067357ca3c23f98aa",
+ "plugins/agent/agent-screen/messages.ts": "3a4ec6dc2600580ecd8310990ac95370591b810b152b3fdfad4b8c6be6e7ef80",
+ "plugins/agent/agent-screen/navigation-view.ts": "8b62f5d810f9c1dc058dd94f88ed21c69583a866d3c0f1cfcc26e8a2b03f0281",
+ "plugins/agent/agent-screen/outputs-view.ts": "85458c8556b9a5da0c36e8a29b0c1208b4b9a0a7948732c990e8830d52490395",
+ "plugins/agent/agent-screen/package.ts": "bb200b182c6443d17f2453f82974d234635c5b3ba471b2f385999debca4546d7",
"plugins/agent/agent-screen/pages-view.ts": "2102dea3cc5c739fed04f6e4f0d17d1d22ac01c25fae924a5e46baad972e95b3",
- "plugins/agent/agent-screen/raw-events.ts": "bd84bd6613215199ba2c72637bd07117ab64106c9c2f7153bbee17804a2495ad",
- "plugins/agent/agent-screen/results.ts": "fca1dbd35f5c05edfc026080ad96869fbf86289348dd9539238cfca163f6c43b",
- "plugins/agent/agent-screen/state.ts": "826dbef1c59a56a237dafd927e5a68e23556802025fac06881b96cab66ab828e",
+ "plugins/agent/agent-screen/raw-events.ts": "b87b41494b30aa9847441496aa501798084df1d43500826f993bfd6e88b35182",
+ "plugins/agent/agent-screen/results.ts": "b24de0e4467e98ecf7dac6c7b804374193780f943293f46831b12ebde278bb5b",
+ "plugins/agent/agent-screen/state.ts": "f1ff2debdd571e7e80747549c05e18c3025bf04604094c932b935b4c8af04541",
+ "plugins/agent/agent-screen/task-overview.ts": "3970ba9d4421eabe7d694d8ea0b373de59a3d8c4edfb9d275c51fcdcf0cc3f93",
"plugins/agent/agent-screen/tui.ts": "c56f6d0f7e2382f67aef31a486b4524073a401d0ddc4dae4b92c7aaf9a7933db",
"plugins/agent/agent/package.ts": "d170e855397e518aa35edc79033b89dd65b58d0d4263925bae20117977e41c01",
"plugins/agent/api/Cargo.toml": "876a95a3f126bcd1c0f590b84e307bebcf714117f7f1d28fe4a8eb7b524d703a",
@@ -88,7 +90,7 @@
"plugins/agent/directory-picker/tui.ts": "d907ed4781f0d89c46575777d4b626c463efd9e6ea00df6c17a8500b5a89279b",
"plugins/agent/engine/Cargo.toml": "97acbfa42c37e8d711903db64d1a57261291cf34793129f5a2a2aeec4fdc9b67",
"plugins/agent/execution-picker/desktop.ts": "c13e6ab13581672c19d27cb63158e67ebe21ae362876ea7261fd9602b800dad9",
- "plugins/agent/execution-picker/index.ts": "ba1a808f95b35440937bd3e1aa83c0586b82a2959284b4353d84c70c6ba67ce1",
+ "plugins/agent/execution-picker/index.ts": "d4460c08811af35b6cce62dfd52ec51a12001c5e9fe350726b9698007304953b",
"plugins/agent/execution-picker/package.ts": "9a034413daa3d5a1514261feafc65b4abd72c01a4146d082ceb6e0ae31640fcc",
"plugins/agent/execution-picker/tui.ts": "7fea2eca0f72e5df06231f04da1a2971dd169a3e7308c0919cebc903104f0d55",
"plugins/agent/facts/Cargo.toml": "eb76a482034c54557d77e4f5e74b5a396a25b50b9cc6981e06f67ac24391a568",
@@ -226,20 +228,20 @@
"plugins/settings/authentication/package.ts": "731392c6b6df34ed6ef80a66efe5316809fbafc4c0f06d676780225bafceaad0",
"plugins/settings/connection-form/build.mjs": "84fcc47b15d92d2dde64292ab029ced56b669fa77d85f078c9e3332d95ea2bce",
"plugins/settings/connection-form/desktop.ts": "763fefee8ecf1e58a5b0e8002a9d464e87d59657a7798f1b30cc78b140a5dec2",
- "plugins/settings/connection-form/events.ts": "1b41f3ac8ce289fe9cc12e6be1a70dc6fb84fed636e7152650ae81064ed98fc2",
+ "plugins/settings/connection-form/events.ts": "23c051b1e868278c6c275f11bd2a56013170c43fbac5aff961ba1585c11ac93d",
"plugins/settings/connection-form/index.ts": "727db06d1c1aa9854705c121242b4bae416f27d1bc011098b7b930d04857a8f7",
"plugins/settings/connection-form/operations.ts": "d2713a6c4a517a10a7817eb64dbef5e609d8a1a3ba51e4822a16276c28d4f6aa",
"plugins/settings/connection-form/package.ts": "d6f35d2b397870d64449cd56facef296931a8d33f6a00498a81edde91530ec46",
"plugins/settings/connection-form/state.ts": "971a980457e180620ce607ded1c1675f9ad244deafa225d006ea9fb78c2ada33",
"plugins/settings/connection-form/tui.ts": "f9c33ce77775dd0de960fe2fbfa26ad275980667d51dd428db6ff0a38a978205",
"plugins/settings/connection-form/validation.d.ts": "392b1baa3772a3ef3e1b30b10d27c6088d8ab01b903c617b25565519797a9b15",
- "plugins/settings/connection-form/view.ts": "7a99b617911eb67356e723f89fcc0fcead0ced93e0868bb38834c7d7a6c0901c",
+ "plugins/settings/connection-form/view.ts": "251426ecec9020ec7a6757a73880be1b1de7f6dc5b508ae9ea52cb9bcd6eb188",
"plugins/settings/connections/desktop.ts": "e9b0106a88276fdb5be77a33147ac8fbeb72a44a09ec7c3f53cf53b12ef76aa3",
"plugins/settings/connections/index.ts": "bd563d8e3b4bcf324b54b1b5f74ecb07dcc9f1c5b4be2569e74e284b3d65ab88",
"plugins/settings/connections/package.ts": "786080def755574e6c8aaa7e23d069cc62834fff2bbf2f39640bcebf0e706d4c",
"plugins/settings/connections/tui.ts": "2a8dafd51c7b26fe9edab899728a5f776bd9e2900a636f800b0faf16d3086bfb",
"plugins/settings/credential/desktop.ts": "07cd0db5c4e311ac6125c2679d3b29d6d62a9bf8748a740270f30871e67457f9",
- "plugins/settings/credential/index.ts": "d3782ca3f82f85dbec9b3991003f9ea9f308cb6b708fedc0adcd5f5981a0cf03",
+ "plugins/settings/credential/index.ts": "97245f2e9bec8a522cdf3582968054ff88d89eea2a5f431cb21fa1a03300bd94",
"plugins/settings/credential/package.ts": "240b7d84ee5ea170028b35ab1d4ed9bf29bc272a740b5969f0d1a8ad538512ab",
"plugins/settings/credential/tui.ts": "8f381db68f43ec1c806c150e9596a8fc44014c9a8040bf35cee043d2e85dab91",
"plugins/settings/insights/desktop.ts": "9f8b396d04478e207fdabb48463bce5e39c1bf46dd12e5cb48b607b796b64a8c",
@@ -255,7 +257,7 @@
"plugins/settings/model-default/package.ts": "4d9ee305d2b5bae38415e460d16d2e0639fa0219656e28a69a2716846b0b490c",
"plugins/settings/model-default/tui.ts": "a4601ba20b67e1d12c6738e094d290b8d0d40bfee83afca911e98ceeca9de92e",
"plugins/settings/models/desktop.ts": "bf15c189eae2714e63c244a3f33543969b4a424af27f06251f45b8fd2a91c72e",
- "plugins/settings/models/index.ts": "fa56336a53dd6246905cc3bc52d4bc668171175224cdb775e723b937047137ed",
+ "plugins/settings/models/index.ts": "8bc904bd88103d92d0e76ae3489a5419e54f8537c1b5ad1d1b5aea2b7b2735cc",
"plugins/settings/models/package.ts": "1b0de4c50c71d13d3ccf41326aa5a2e23942000e478044881e9644aba12de573",
"plugins/settings/models/tui.ts": "112e16f3166a7d3f2eaf11d557c7a18e94141229b183b1ea3d73986a413e1c1b",
"plugins/settings/native/Cargo.toml": "fe9e5c419efb8b518d99f2fcf3250300e496c01f157d20ca753c51e049a93a08",
@@ -271,8 +273,8 @@
"plugins/shared/appearance-editor.ts": "f564f53cd126f1ebe4fe8cca638823769dd6ff84512bf42f074053afa76ee4b5",
"plugins/shared/attachments.ts": "9584769234c14281eb7cbdd9a816f4f47ca9b6fd20a6c58e4d5532b0183d93c9",
"plugins/shared/bookmark.ts": "e1fc0db13e0038b680174ed8e7430c551be2a15a90d76b769572d7a153897698",
- "plugins/shared/components.ts": "4eaab8c92a95f0e38658b6c8709e2db95e30f0c9d0517c39de599d3eb9d9a452",
- "plugins/shared/contracts.ts": "c3e8920729d5543cb2ee3ccc1b1a92f1d4b3cd3c39136d0266b84ffc7dc876d6",
+ "plugins/shared/components.ts": "e3decdfd96b1dcfc9b8c7b31630a888d9cf7909c15b1141ff13b5c1797ccfe54",
+ "plugins/shared/contracts.ts": "a5927f9d281e6057b2525fc5cb8e6f7f2073adfcaa8b25c26d4c60c7152c452a",
"plugins/shared/conversation-search.ts": "57c796ec95df84904a00d9d95a2b8378c410df5ce530f012273ffb0106ed2ab8",
"plugins/shared/deployment-document.ts": "0fd08cd83442f4300d8f4f6cadf18fada02f40bf46c523e93f99541a5319e0da",
"plugins/shared/deployment-import.ts": "ad1cd8550e36bb203f54de4540154a9c1a3dd7e3c8740c4b81d18e24a3d90659",
@@ -280,7 +282,7 @@
"plugins/shared/instance-settings.ts": "5b5bad8f3aabf6daa7aa696f72dba20337dbcdf70266ee0d761cea1e836280be",
"plugins/shared/locale.ts": "347fbeb4b1152eb68d66b11bc9abfd86451c5812382e56863491abcf84f16971",
"plugins/shared/message-copy.ts": "baad601787ef3b8db42aee852a80b74f71b38b80f2f61ac77512df82e42aa20c",
- "plugins/shared/message-view.ts": "f729e257588e39128b2c676304693dec6f7bfcebafbcb24267e9b3df1a2ef88d",
+ "plugins/shared/message-view.ts": "955b89979758e69c80569e77fdd0dc24f11de7f40851266bef394731f4026a49",
"plugins/shared/motion.ts": "b0c328cea67850426de62714b806a94f52056eab4438c2c3a48c8a8dd6ac41c2",
"plugins/shared/package.ts": "f6dbe2c00022d1364db6efa4f3f337f320787c3d0f3ebe6e97880555f6c43404",
"plugins/shared/page-shell.ts": "abf3be4d8a09d417bd0f889c4f0ae2aa530df80da7300d7c744facc459125e78",
@@ -288,9 +290,9 @@
"plugins/shared/skill-import.ts": "cdca0bf2631cb25f5e420fbff799b10d3a0ed5f3ab0dd2fdcafa7e916fbe97f0",
"plugins/shared/terminal-theme.ts": "9022a58c837bf39292f04b6383eed3cd3e875bd9635570f871b065c87f5ba395",
"plugins/shared/tool-cards.ts": "2fb0631f0ed68ab9b14e279a2adaae082fdc4b2cc59a8eea670eddc2bdbe0eef",
- "plugins/shared/tui-locale.ts": "b24f46dd429b996142a0c8aeadfd822d62fd8c8dbe424d00e8ce4d185e330b53",
+ "plugins/shared/tui-locale.ts": "4493d55c4e7e678f3187b552843d2295736ca16225a6fda19b82ab379090c525",
"plugins/shared/value.ts": "9b1b52b05ca45a4e37fbab6454810fca2a474c8532941e25c15ab897aaa2d607",
- "plugins/shared/view.ts": "db7f46702865f32d9e21a7b3ae386542d82a28e057baf91d7b2780fda714716a",
+ "plugins/shared/view.ts": "c4cf9fa7368adaf8d2da9541efcfc83b3328363c1ce617976af416e07fb316cc",
"plugins/shared/welcome.ts": "a5aa03797bcfb5c95e04f5c1ba1676cd59c1f85f6313ae6fca97cb69d699ce41",
"plugins/skills/api/Cargo.toml": "ddca1ffbbee64efc3051e61b3d1aacaaa7bacfd18be967ab167efa29fde5756d",
"plugins/skills/core/Cargo.toml": "2fdb7ac50558436fca5392d20847d2bf9f48708eb86dfb9f3395bff704e2fc7c",
@@ -360,7 +362,7 @@
"products/maka/bundle/package-inputs.json": "72f1f8912ac0105dbdd423ab1ea936a5d118b881fc1c2dc0b0ce417937d9ca40",
"products/maka/bundle/package.json": "dd17e0a82ab68c03cdfd8adb3e243d4c5e881530db66ff311cb642b3bdc1f7c0",
"products/maka/bundle/scripts/build.mjs": "db11d8cd6f29ef7ead087261848568da1ad3889a870a89f23fa969449d769d47",
- "products/maka/bundle/src/composition.ts": "ab309984bbe5735319338932f8e96f30979755f360bebb7caa1dfba72c5de73e",
+ "products/maka/bundle/src/composition.ts": "cb9de8e49cf5c812acbadd38007c6deeaad0fbeaea8ad3206ecfa6ece564418e",
"products/maka/bundle/src/installation.ts": "25908ab7c6a6416fa2c452e9f25840cb9c542c6b99a7eb28ea71725adf220738",
"products/maka/cli/Cargo.toml": "dff55e4f17eecb5107b75c179e0077f122409503993d19bd24c70e5c9380ffba",
"products/maka/cli/build.rs": "98452274bb0f20fa55fda5a20313f81740e759652aa4227fba0b0de5e3f4cc29",
diff --git a/products/maka/cli/src/application.rs b/products/maka/cli/src/application.rs
index c248a948b9..4cdc5ee968 100644
--- a/products/maka/cli/src/application.rs
+++ b/products/maka/cli/src/application.rs
@@ -46,6 +46,8 @@ pub(super) enum Command {
Compose(product::Compose),
/// Restore the originally installed product composition, preserving identities and stored data.
RestoreDefaults(product::Restore),
+ /// Preview new defaults merged with local customizations; use --apply to synchronize.
+ UpdateDefaults(product::Update),
/// Download, validate and install a plugin bundle into an inactive deployment.
InstallUrl(install::InstallUrl),
/// Initialize or reopen an ordinary bundled terminal product.
@@ -148,6 +150,7 @@ impl Command {
match self {
Self::Compose(args) => args.run().await?,
Self::RestoreDefaults(args) => args.run().await?,
+ Self::UpdateDefaults(args) => args.run().await?,
Self::InstallUrl(args) => args.run().await?,
Self::Launch(args) => args.run().await?,
Self::Serve(args) => serve::run(args).await?,
@@ -262,16 +265,40 @@ impl Check {
}
fn read_json<T: serde::de::DeserializeOwned>(source: &Path) -> Result<T, HostError> {
+ // A bundled product's remote declarations are part of its editable source
+ // and can exceed 512 KiB. Use the same bound as deployment documents.
+ const LIMIT: usize = maka_plugin_core::deployment::management::MAX_DOCUMENT_BYTES;
let mut bytes = Vec::new();
std::fs::File::open(source)?
- .take(512 * 1024 + 1)
+ .take((LIMIT + 1) as u64)
.read_to_end(&mut bytes)?;
- if bytes.len() > 512 * 1024 {
- return Err("application input exceeds 512 KiB".into());
+ if bytes.len() > LIMIT {
+ return Err("application input exceeds 1 MiB".into());
}
Ok(serde_json::from_slice(&bytes)?)
}
+#[cfg(test)]
+mod source_input_tests {
+ use super::*;
+
+ #[test]
+ fn authoring_reads_large_contract_documents_with_the_deployment_bound() {
+ let directory = tempfile::tempdir().unwrap();
+ let file = directory.path().join("remote-exports.json");
+ let value = serde_json::json!({"contract": "x".repeat(768 * 1024)});
+ std::fs::write(&file, serde_json::to_vec(&value).unwrap()).unwrap();
+ assert_eq!(read_json::<serde_json::Value>(&file).unwrap(), value);
+ std::fs::write(&file, vec![b' '; 1024 * 1024 + 1]).unwrap();
+ assert!(
+ read_json::<serde_json::Value>(&file)
+ .unwrap_err()
+ .to_string()
+ .contains("1 MiB")
+ );
+ }
+}
+
async fn open_store(root: PathBuf) -> Result<maka_event_log::EventLog, HostError> {
let owner = tokio::task::spawn_blocking(move || {
maka_event_log::root::RootOwner::open(
diff --git a/products/maka/cli/src/application/product.rs b/products/maka/cli/src/application/product.rs
index 4c52a0552b..d13545e6a9 100644
--- a/products/maka/cli/src/application/product.rs
+++ b/products/maka/cli/src/application/product.rs
@@ -27,9 +27,11 @@ use std::{collections::BTreeMap, io::Write, path::PathBuf};
mod headless;
mod launch;
mod restore;
+mod update;
pub(crate) use headless::{Roots, open_host};
pub(crate) use launch::Launch;
pub(super) use restore::Restore;
+pub(super) use update::Update;
pub(super) fn root_path(root: Option<PathBuf>) -> Result<PathBuf, HostError> {
match root {
@@ -47,7 +49,8 @@ struct Bundled {
}
include!(concat!(env!("OUT_DIR"), "/product-assets.rs"));
-#[derive(Clone, Copy, ValueEnum)]
+#[derive(Clone, Copy, ValueEnum, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
pub(super) enum Product {
Agent,
Studio,
@@ -66,7 +69,7 @@ pub(crate) struct Compose {
product: Product,
}
-#[derive(Serialize, Deserialize)]
+#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Sources {
host: Input,
@@ -165,7 +168,7 @@ async fn author(
let result: Result<Value, HostError> = match vm.load("maka-product-authoring".into(), source) {
Ok(module) => {
let result = async {
- if !values.contains_key("keys") {
+ {
// The ordinary authoring code supplies every installation
// identity, including configuration-dependent instances.
let names: Vec<String> = serde_json::from_value(
@@ -176,11 +179,15 @@ async fn author(
)
.await?,
)?;
- let keys: BTreeMap<_, _> = names
- .into_iter()
- .map(|name| (name, uuid::Uuid::new_v4().to_string()))
- .collect();
- values.insert("keys".into(), serde_json::to_value(keys)?);
+ let keys = values
+ .entry("keys")
+ .or_insert_with(|| serde_json::json!({}))
+ .as_object_mut()
+ .ok_or("installation keys must be an object")?;
+ for name in names {
+ keys.entry(name)
+ .or_insert_with(|| Value::String(uuid::Uuid::new_v4().to_string()));
+ }
}
let method = match product {
Product::Agent => "composition",
diff --git a/products/maka/cli/src/application/product/launch.rs b/products/maka/cli/src/application/product/launch.rs
index a2aaac72d6..4d13e5b03b 100644
--- a/products/maka/cli/src/application/product/launch.rs
+++ b/products/maka/cli/src/application/product/launch.rs
@@ -65,6 +65,10 @@ pub(crate) struct Launch {
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Preparation {
+ #[serde(default)]
+ pub(super) product: Option<super::update::Baseline>,
+ #[serde(default)]
+ pub(super) previous_connections: Option<Value>,
#[serde(default)]
pub(super) restore: BTreeMap<Uuid, u64>,
pub(super) sources: Sources,
@@ -98,7 +102,12 @@ impl Launch {
None => return Ok(None),
},
};
- prepare(&store, configuration, self.product.unwrap_or(Product::Agent)).await.map(Some)
+ let selected = prepare(&store, configuration, self.product.unwrap_or(Product::Agent)).await?;
+ if existing && super::update::begin(&store).await? {
+ prepare(&store, None, Product::Agent).await.map(Some)
+ } else {
+ Ok(Some(selected))
+ }
}.await;
let (deployment, configuration) = match result {
Ok(Some(value)) => value,
@@ -227,11 +236,16 @@ pub(super) async fn prepare(
.ok_or("launcher record missing after commit")?
}
};
- let connections: Configuration = serde_json::from_value(record.configuration.clone())?;
+ let mut connections: Configuration = serde_json::from_value(record.configuration.clone())?;
let (root, domain, host, management) = targets(&connections)?;
if let Some(bytes) = record.pending {
let preparation: Preparation = serde_json::from_slice(&bytes)?;
- if preparation.sources.connections != record.configuration {
+ if preparation
+ .previous_connections
+ .as_ref()
+ .unwrap_or(&preparation.sources.connections)
+ != &record.configuration
+ {
return Err("original launcher connection input changed".into());
}
let packages = decode_packages(preparation.packages)?;
@@ -285,7 +299,18 @@ pub(super) async fn prepare(
.await?;
}
}
- store.complete_application_launch(record.deployment).await?;
+ if let Some(baseline) = preparation.product {
+ store
+ .complete_application_product(
+ record.deployment,
+ preparation.sources.connections.clone(),
+ serde_json::to_value(baseline)?,
+ )
+ .await?;
+ connections = serde_json::from_value(preparation.sources.connections.clone())?;
+ } else {
+ store.complete_application_launch(record.deployment).await?;
+ }
Ok::<_, HostError>(())
}
.await;
@@ -324,7 +349,7 @@ async fn begin(
let owner = host_store.owned_root()?;
configuration["hostRoot"] = serde_json::to_value(owner.canonical_path())?;
let packages = tokio::task::spawn_blocking(packages).await??;
- let (mut sources, _) = author(product, configuration, &packages).await?;
+ let (mut sources, installation) = author(product, configuration, &packages).await?;
for host in sources.connections["hosts"].as_array_mut().ok_or("product connections missing")? {
if host["location"]["kind"] == "process" {
host["location"]["stateDomain"] = Value::String(owner.root_id().into());
@@ -339,6 +364,8 @@ async fn begin(
}
let selection = sources.connections.clone();
let preparation = Preparation {
+ product: Some(super::update::Baseline { product, installation, sources: sources.clone() }),
+ previous_connections: None,
restore: BTreeMap::new(),
sources,
packages: packages.values().map(|package| package.files().iter().map(|(path, bytes)| (path.clone(), STANDARD.encode(bytes))).collect()).collect(),
diff --git a/products/maka/cli/src/application/product/launch/tests.rs b/products/maka/cli/src/application/product/launch/tests.rs
index 4e0d9babe4..4eb36d32b6 100644
--- a/products/maka/cli/src/application/product/launch/tests.rs
+++ b/products/maka/cli/src/application/product/launch/tests.rs
@@ -44,6 +44,8 @@ async fn pending(store: &EventLog, host: &EventLog) -> (Uuid, Uuid, Uuid) {
]});
let selection = connections.clone();
let preparation = Preparation {
+ product: None,
+ previous_connections: None,
restore: BTreeMap::new(),
sources: Sources {
host: input(),
diff --git a/products/maka/cli/src/application/product/restore.rs b/products/maka/cli/src/application/product/restore.rs
index de18789484..af61e4c457 100644
--- a/products/maka/cli/src/application/product/restore.rs
+++ b/products/maka/cli/src/application/product/restore.rs
@@ -95,6 +95,8 @@ async fn restore(store: &EventLog) -> Result<DeploymentRef, HostError> {
let management = originals.pop().unwrap();
let host = originals.pop().unwrap();
let preparation = launch::Preparation {
+ product: None,
+ previous_connections: None,
restore: revisions,
sources: Sources {
host: host.source.input,
@@ -234,6 +236,8 @@ mod tests {
configuration: configuration.clone(),
pending: Some(
serde_json::to_vec(&launch::Preparation {
+ product: None,
+ previous_connections: None,
restore: BTreeMap::new(),
sources: Sources {
host: input(),
@@ -270,6 +274,8 @@ mod tests {
let host = launch::open(host_path.clone(), false, None).await.unwrap();
let preparation = launch::Preparation {
+ product: None,
+ previous_connections: None,
restore: BTreeMap::from([(host_id, 3), (manager, 3), (ui, 3)]),
sources: Sources {
host: input(),
diff --git a/products/maka/cli/src/application/product/update.rs b/products/maka/cli/src/application/product/update.rs
new file mode 100644
index 0000000000..0905e090df
--- /dev/null
+++ b/products/maka/cli/src/application/product/update.rs
@@ -0,0 +1,258 @@
+/*
+ * 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.
+ */
+
+//! Default upgrades and explicit three-way synchronization of customized products.
+use super::{HostError, Product, Sources, author, launch, packages};
+use base64::{Engine, engine::general_purpose::STANDARD};
+use clap::Args;
+use maka_event_log::EventLog;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ path::PathBuf,
+};
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(super) struct Baseline {
+ pub(super) product: Product,
+ pub(super) installation: Value,
+ pub(super) sources: Sources,
+}
+
+#[derive(Args)]
+pub(crate) struct Update {
+ /// UI State Root (default: ~/.maka/application). Close its UI and local Host first.
+ #[arg(long)]
+ root: Option<PathBuf>,
+ /// Apply the previewed merge only when there are no conflicts.
+ #[arg(long)]
+ apply: bool,
+}
+#[derive(Default, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Outcome {
+ customized: bool,
+ changed: bool,
+ applied: bool,
+ changes: Vec<String>,
+ conflicts: Vec<String>,
+}
+impl Update {
+ pub(crate) async fn run(self) -> Result<(), HostError> {
+ let store = launch::open(super::root_path(self.root)?, false, None).await?;
+ let result = async {
+ let mut outcome = synchronize(&store, true, self.apply).await?;
+ if self.apply && outcome.changed && outcome.conflicts.is_empty() {
+ launch::prepare(&store, None, Product::Agent).await?;
+ outcome.applied = true;
+ }
+ println!("{}", serde_json::to_string(&outcome)?);
+ if self.apply && !outcome.conflicts.is_empty() {
+ return Err("default update has conflicts; no update was written".into());
+ }
+ Ok(())
+ }
+ .await;
+ store.drain().await;
+ result
+ }
+}
+
+pub(super) async fn begin(store: &EventLog) -> Result<bool, HostError> {
+ let outcome = synchronize(store, false, true).await?;
+ Ok(outcome.changed && !outcome.customized)
+}
+
+async fn synchronize(store: &EventLog, custom: bool, apply: bool) -> Result<Outcome, HostError> {
+ let record = store
+ .application_launch()
+ .await?
+ .ok_or("product selection missing")?;
+ if record.pending.is_some() {
+ return Err("finish the prepared product operation before checking for updates".into());
+ }
+ let baseline: Baseline = serde_json::from_value(store.application_product().await?
+ .ok_or("product storage predates automatic updates; remove this development root and initialize it again")?)?;
+ let connections = serde_json::from_value(record.configuration.clone())?;
+ let (path, domain, host_id, management) = launch::targets(&connections)?;
+ let host = launch::open(path, false, Some(domain.clone())).await?;
+ let result = async {
+ let mut revisions = BTreeMap::new();
+ let mut current = Vec::new();
+ let mut available = BTreeMap::new();
+ for (target, deployment) in [
+ (&host, host_id),
+ (store, management),
+ (store, record.deployment),
+ ] {
+ let head = target
+ .load_application(deployment)
+ .await?
+ .ok_or("product source missing")?;
+ revisions.insert(deployment, head.source.reference.revision);
+ for package in &head.packages {
+ available.insert(package.application_ref(), package.clone());
+ }
+ current.push(head);
+ }
+ let local = Sources {
+ host: current[0].source.input.clone(),
+ management_host: current[1].source.input.clone(),
+ ui: current[2].source.input.clone(),
+ remote_exports: current[2].source.remote_exports.clone(),
+ connections: record.configuration.clone(),
+ };
+ let base = serde_json::to_value(&baseline.sources)?;
+ let local_value = serde_json::to_value(&local)?;
+ let mut outcome = Outcome {
+ customized: local_value != base,
+ ..Default::default()
+ };
+ if outcome.customized && !custom {
+ return Ok(outcome);
+ }
+ let packages = tokio::task::spawn_blocking(packages).await??;
+ let (mut next, installation) =
+ author(baseline.product, baseline.installation, &packages).await?;
+ for endpoint in next.connections["hosts"]
+ .as_array_mut()
+ .ok_or("product connections missing")?
+ {
+ if endpoint["location"]["kind"] == "process" {
+ endpoint["location"]["stateDomain"] = Value::String(domain.clone());
+ }
+ }
+ let next_value = serde_json::to_value(&next)?;
+ if next_value == base {
+ return Ok(outcome);
+ }
+ outcome.changed = true;
+ let merged = merge(
+ Some(&base),
+ Some(&local_value),
+ Some(&next_value),
+ "",
+ &mut outcome,
+ )
+ .ok_or("product merge removed its root")?;
+ if !outcome.conflicts.is_empty() {
+ return Ok(outcome);
+ }
+ let sources: Sources = serde_json::from_value(merged)?;
+ // Neither automatic nor explicit synchronization relocates installation data.
+ if launch::targets(&serde_json::from_value(sources.connections.clone())?)?
+ != launch::targets(&connections)?
+ {
+ return Err("default update changed installation identity".into());
+ }
+ for package in packages.values() {
+ available.insert(package.application_ref(), package.clone());
+ }
+ let available: Vec<_> = available.into_values().collect();
+ let mut selected = BTreeMap::new();
+ // Validate the complete merge and its actual package bytes before writing preparation.
+ for (input, observations) in [
+ (&sources.host, &[][..]),
+ (&sources.management_host, &[][..]),
+ (&sources.ui, sources.remote_exports.as_slice()),
+ ] {
+ let required = super::selected(input, observations, &available)?;
+ maka_event_log::applications::validate_application(input, observations, &required)?;
+ for package in required {
+ selected.insert(package.application_ref(), package);
+ }
+ }
+ if selected.len() > 128 {
+ return Err("updated product exceeds the prepared package budget".into());
+ }
+ if apply {
+ let preparation = launch::Preparation {
+ product: Some(Baseline {
+ product: baseline.product,
+ installation,
+ sources: next,
+ }),
+ previous_connections: Some(record.configuration),
+ restore: revisions,
+ sources,
+ packages: selected
+ .values()
+ .map(|package| {
+ package
+ .files()
+ .iter()
+ .map(|(path, bytes)| (path.clone(), STANDARD.encode(bytes)))
+ .collect()
+ })
+ .collect(),
+ };
+ store
+ .begin_application_restore(record.deployment, serde_json::to_vec(&preparation)?)
+ .await?;
+ }
+ Ok::<_, HostError>(outcome)
+ }
+ .await;
+ host.drain().await;
+ result
+}
+
+/// Objects merge by field. Ordered lists and scalar values remain atomic; an
+/// ambiguous edit is reported rather than silently changing user intent.
+fn merge(
+ base: Option<&Value>,
+ local: Option<&Value>,
+ next: Option<&Value>,
+ path: &str,
+ outcome: &mut Outcome,
+) -> Option<Value> {
+ if local == next || next == base {
+ return local.cloned();
+ }
+ if let (Some(Value::Object(base)), Some(Value::Object(local)), Some(Value::Object(next))) =
+ (base, local, next)
+ {
+ let keys: BTreeSet<_> = base.keys().chain(local.keys()).chain(next.keys()).collect();
+ let mut result = serde_json::Map::new();
+ for key in keys {
+ let escaped = key.replace('~', "~0").replace('/', "~1");
+ if let Some(value) = merge(
+ base.get(key),
+ local.get(key),
+ next.get(key),
+ &format!("{path}/{escaped}"),
+ outcome,
+ ) {
+ result.insert(key.clone(), value);
+ }
+ }
+ Some(Value::Object(result))
+ } else if local == base {
+ outcome.changes.push(path.into());
+ next.cloned()
+ } else {
+ outcome.conflicts.push(path.into());
+ local.cloned()
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/products/maka/cli/src/application/product/update/tests.rs b/products/maka/cli/src/application/product/update/tests.rs
new file mode 100644
index 0000000000..c061a98133
--- /dev/null
+++ b/products/maka/cli/src/application/product/update/tests.rs
@@ -0,0 +1,297 @@
+/*
+ * 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::*;
+use maka_event_log::applications::launch::Launch;
+use maka_packages::Package;
+use serde_json::json;
+use uuid::Uuid;
+
+#[test]
+fn three_way_merge_preserves_customizations_and_reports_ambiguous_changes() {
+ let base = json!({"plugin":{"package":"v1","values":{"theme":"dark","language":"en"}},"removed":1,"list":[1]});
+ let local = json!({"plugin":{"package":"v1","values":{"theme":"light","language":"en"}},"removed":1,"list":[1],"custom":true});
+ let next = json!({"plugin":{"package":"v2","values":{"theme":"dark","language":"zh-CN"}},"list":[1,2],"added":true});
+ let mut outcome = Outcome::default();
+ let merged = merge(Some(&base), Some(&local), Some(&next), "", &mut outcome).unwrap();
+ assert_eq!(
+ merged,
+ json!({"plugin":{"package":"v2","values":{"theme":"light","language":"zh-CN"}},"list":[1,2],"custom":true,"added":true})
+ );
+ assert!(outcome.conflicts.is_empty());
+ let mut local = local;
+ local["plugin"]["values"]["language"] = json!("zh-TW");
+ local["list"] = json!([3]);
+ local["removed"] = json!(2);
+ let mut outcome = Outcome::default();
+ merge(Some(&base), Some(&local), Some(&next), "", &mut outcome);
+ assert_eq!(
+ outcome.conflicts,
+ ["/list", "/plugin/values/language", "/removed"]
+ );
+ let mut outcome = Outcome::default();
+ merge(Some(&base), None, Some(&next), "/deleted", &mut outcome);
+ assert_eq!(outcome.conflicts, ["/deleted"]);
+}
+
+async fn old_installation(path: &std::path::Path) -> (EventLog, Uuid) {
+ let store = launch::open(path.join("ui"), true, None).await.unwrap();
+ let host = launch::open(path.join("host"), true, None).await.unwrap();
+ let options = json!({"workspace": path, "hostRoot": path.join("host"), "themeFile": path.join("theme.json"), "helper": std::env::current_exe().unwrap(), "locale":"zh-CN"});
+ let config = super::super::installation(options).await.unwrap();
+ let mut packages = packages().unwrap();
+ let package = packages.get("agent-screen").unwrap();
+ let mut files = package.files().clone();
+ files
+ .get_mut("tui.js")
+ .unwrap()
+ .extend_from_slice(b"\n// previous product release\n");
+ packages.insert("agent-screen".into(), Package::new(files).unwrap());
+ let (mut sources, installation) = author(Product::Agent, config, &packages).await.unwrap();
+ for endpoint in sources.connections["hosts"].as_array_mut().unwrap() {
+ if endpoint["location"]["kind"] == "process" {
+ endpoint["location"]["stateDomain"] = json!(host.owned_root().unwrap().root_id());
+ }
+ }
+ let ui = Uuid::new_v4();
+ let preparation = launch::Preparation {
+ product: Some(Baseline {
+ product: Product::Agent,
+ installation,
+ sources: sources.clone(),
+ }),
+ previous_connections: None,
+ restore: BTreeMap::new(),
+ sources,
+ packages: packages
+ .values()
+ .map(|package| {
+ package
+ .files()
+ .iter()
+ .map(|(path, bytes)| (path.clone(), STANDARD.encode(bytes)))
+ .collect()
+ })
+ .collect(),
+ };
+ store
+ .begin_application_launch(Launch {
+ deployment: ui,
+ configuration: preparation.sources.connections.clone(),
+ pending: Some(serde_json::to_vec(&preparation).unwrap()),
+ })
+ .await
+ .unwrap();
+ host.drain().await;
+ drop(host);
+ launch::prepare(&store, None, Product::Agent).await.unwrap();
+ (store, ui)
+}
+
+#[tokio::test]
+#[ignore = "requires the built bundled product"]
+async fn defaults_update_once_and_resume_an_acknowledgement_lost_after_commit() {
+ let directory = tempfile::tempdir().unwrap();
+ let (store, ui) = old_installation(directory.path()).await;
+ let original = store.load_application(ui).await.unwrap().unwrap();
+ let preview = synchronize(&store, true, false).await.unwrap();
+ assert!(preview.changed && !preview.customized && preview.conflicts.is_empty());
+ assert!(
+ preview
+ .changes
+ .iter()
+ .any(|path| path.contains("agent-screen"))
+ );
+ assert!(
+ store
+ .application_launch()
+ .await
+ .unwrap()
+ .unwrap()
+ .pending
+ .is_none()
+ );
+ assert!(begin(&store).await.unwrap());
+ let pending = store
+ .application_launch()
+ .await
+ .unwrap()
+ .unwrap()
+ .pending
+ .unwrap();
+ let preparation: launch::Preparation = serde_json::from_slice(&pending).unwrap();
+ let available = launch::decode_packages(preparation.packages).unwrap();
+ let selected = super::super::selected(
+ &preparation.sources.ui,
+ &preparation.sources.remote_exports,
+ &available,
+ )
+ .unwrap();
+ // Simulate a source commit whose acknowledgement was lost before publication.
+ store
+ .commit_application(
+ ui,
+ 1,
+ preparation.sources.ui.clone(),
+ preparation.sources.remote_exports.clone(),
+ selected,
+ )
+ .await
+ .unwrap();
+ launch::prepare(&store, None, Product::Agent).await.unwrap();
+ let current = store.load_application(ui).await.unwrap().unwrap();
+ assert_eq!(current.source.reference.revision, 2);
+ for (name, node) in &original.source.input.instances {
+ assert_eq!(node.key, current.source.input.instances[name].key);
+ }
+ assert!(
+ store
+ .load_application_at(&original.source.reference)
+ .await
+ .unwrap()
+ .is_some()
+ );
+ assert!(!begin(&store).await.unwrap());
+ assert!(
+ store
+ .application_launch()
+ .await
+ .unwrap()
+ .unwrap()
+ .pending
+ .is_none()
+ );
+ store.drain().await;
+}
+
+#[tokio::test]
+#[ignore = "requires the built bundled product"]
+async fn explicit_sync_keeps_custom_configuration_and_advances_the_default_baseline() {
+ let directory = tempfile::tempdir().unwrap();
+ let (store, ui) = old_installation(directory.path()).await;
+ let current = store.load_application(ui).await.unwrap().unwrap();
+ let mut customized = current.source.input;
+ customized
+ .instances
+ .get_mut("appearance")
+ .unwrap()
+ .configuration
+ .values
+ .insert("scheme".into(), json!("light"));
+ store
+ .commit_application(
+ ui,
+ 1,
+ customized,
+ current.source.remote_exports,
+ current.packages,
+ )
+ .await
+ .unwrap();
+ assert!(!begin(&store).await.unwrap()); // Normal reopen cannot change a customized product.
+ let preview = synchronize(&store, true, false).await.unwrap();
+ assert!(preview.customized && preview.changed && preview.conflicts.is_empty());
+ assert!(
+ store
+ .application_launch()
+ .await
+ .unwrap()
+ .unwrap()
+ .pending
+ .is_none()
+ );
+ synchronize(&store, true, true).await.unwrap();
+ launch::prepare(&store, None, Product::Agent).await.unwrap();
+ let current = store.load_application(ui).await.unwrap().unwrap();
+ assert_eq!(
+ current.source.input.instances["appearance"]
+ .configuration
+ .values["scheme"],
+ json!("light")
+ );
+ assert_eq!(current.source.reference.revision, 3);
+ let preview = synchronize(&store, true, false).await.unwrap();
+ assert!(preview.customized && !preview.changed);
+ assert!(!begin(&store).await.unwrap());
+ store.drain().await;
+}
+
+#[tokio::test]
+#[ignore = "requires the built bundled product"]
+async fn conflicting_custom_plugin_does_not_prepare_or_change_any_head() {
+ let directory = tempfile::tempdir().unwrap();
+ let (store, ui) = old_installation(directory.path()).await;
+ let current = store.load_application(ui).await.unwrap().unwrap();
+ let old_ref = match ¤t.source.input.instances["agent-screen"].package {
+ maka_plugin_core::package::PackageSelection::Pinned { package } => package.clone(),
+ _ => unreachable!(),
+ };
+ let mut packages = current.packages;
+ let at = packages
+ .iter()
+ .position(|p| p.application_ref() == old_ref)
+ .unwrap();
+ let mut files = packages[at].files().clone();
+ files
+ .get_mut("tui.js")
+ .unwrap()
+ .extend_from_slice(b"\n// user customization\n");
+ packages[at] = Package::new(files).unwrap();
+ let mut input = current.source.input;
+ for node in input.instances.values_mut() {
+ if node.package == maka_plugin_core::package::PackageSelection::from(old_ref.clone()) {
+ node.package = packages[at].application_ref().into();
+ }
+ }
+ store
+ .commit_application(ui, 1, input, current.source.remote_exports, packages)
+ .await
+ .unwrap();
+ let baseline = store.application_product().await.unwrap();
+ let result = synchronize(&store, true, true).await.unwrap();
+ assert!(result.changed && result.customized && !result.conflicts.is_empty());
+ assert!(
+ result
+ .conflicts
+ .iter()
+ .any(|p| p.contains("/agent-screen/package/"))
+ );
+ assert!(
+ store
+ .application_launch()
+ .await
+ .unwrap()
+ .unwrap()
+ .pending
+ .is_none()
+ );
+ assert_eq!(store.application_product().await.unwrap(), baseline);
+ assert_eq!(
+ store
+ .load_application(ui)
+ .await
+ .unwrap()
+ .unwrap()
+ .source
+ .reference
+ .revision,
+ 2
+ );
+ store.drain().await;
+}
diff --git a/products/maka/cli/tests/integration/terminal/bundled_product.rs b/products/maka/cli/tests/integration/terminal/bundled_product.rs
index 09fa3e9bde..10245667d3 100644
--- a/products/maka/cli/tests/integration/terminal/bundled_product.rs
+++ b/products/maka/cli/tests/integration/terminal/bundled_product.rs
@@ -666,13 +666,29 @@ fn bundled_connections_search_scroll_click_and_chatgpt_credentials() {
.chars()
.count()
+ 2;
+ let scroll_started = Instant::now();
tui.send(format!("\x1b[<65;{};{}M", column, row + 2).as_bytes());
tui.wait_until(|s| !s.contains("ChatGPT Subscription"));
+ eprintln!(
+ "provider wheel to visible change: {:?}",
+ scroll_started.elapsed()
+ );
click(&mut tui, "Search providers");
+ let search_started = Instant::now();
tui.send(b"chatgpt");
tui.wait_until(|s| s.contains("ChatGPT Subscription") && !s.contains("Anthropic"));
- click(&mut tui, "ChatGPT Subscription");
+ eprintln!(
+ "provider input to filtered frame: {:?}",
+ search_started.elapsed()
+ );
+ let selection_started = Instant::now();
+ tui.send(b"\x1b[B");
+ tui.send(b"\r");
tui.wait_for("Continue to sign in");
+ eprintln!(
+ "provider selection to form: {:?}",
+ selection_started.elapsed()
+ );
let screen = tui.screen.snapshot().unwrap().screen;
assert!(screen.contains("Name"), "{screen}");
assert!(!screen.contains("Next field"), "{screen}");
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org