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/22 09:51:24 UTC
(maka) branch rust-preview updated (2dd07fbe2a -> d781180cf9)
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a change to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
from 2dd07fbe2a perf(event-log): reuse planned evidence queries across transcript reads
new 27509f24c8 style(terminal): format the activity history regression case
new 51a8c03eaa terminal: Pipeline complete Sixel frames through a bounded FIFO
new 93e277afd4 terminal: Check resize admission with spare frame capacity
new 6469bd0e17 build(terminal): use the latest NSH runtime and layout fixes
new ecaa3dd0f6 chore(terminal): refresh notices for the NSH runtime update
new 559635e022 Polish terminal composer and workspace tool presentation
new 3ecd15a7d9 terminal: Make the NSH shell the sole default terminal interface
new 21c7fc22b4 build(terminal): prune the removed renderer dependencies
new 09a0c14833 Localize the terminal interface with live Host language preferences
new 91e111f002 Align native UI fixtures with localized contracts and focus ownership
new 3bba9bd93f test(terminal): follow localized resource accessibility labels
new e482d30bbb Give image control captions an explicit text return type
new f3f67b2844 docs(terminal): remove stale legacy presenter claims
new 3eb63020b4 chore(terminal): refresh notices after removing the legacy renderer
new c848b5bd85 Account for the shared policy revision after changing interface language
new 02272488da fix(terminal): keep preview navigation buttons as focus stops
new 039294dc10 terminal: Keep session identifiers opaque without a private length cap
new 3b2df117eb fix(terminal): preserve reply ownership across list splices
new 2be941cb46 chore(terminal): bind notices to the list ownership fix
new f42f52b853 Merge remote-tracking branch 'origin/rust-preview' into feat/terminal-nsh-base
new 91bbbf1b1b test(cli): retain fresh Root ownership through store startup
new d781180cf9 perf(event-log): avoid repeated oversized transcript cache sweeps
The 22 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
Cargo.lock | 90 +-
Cargo.toml | 20 +-
README.md | 4 +-
README.zh-CN.md | 4 +-
packages/cli/README.md | 2 +-
packages/cli/README.zh-CN.md | 2 +-
platform/README.md | 6 +-
platform/README.zh-CN.md | 6 +-
platform/event-log/src/transcript/bodycache.rs | 13 +-
.../event-log/src/transcript/bodycache_tests.rs | 103 +-
platform/render-terminal/Cargo.toml | 66 -
platform/render-terminal/README.md | 36 -
platform/render-terminal/README.zh-CN.md | 36 -
platform/render-terminal/src/application.rs | 1625 ------------
.../render-terminal/src/application/activation.rs | 334 ---
.../render-terminal/src/application/adaptive.rs | 329 ---
platform/render-terminal/src/application/border.rs | 124 -
.../render-terminal/src/application/checkpoint.rs | 144 --
platform/render-terminal/src/application/choice.rs | 407 ---
.../src/application/context_menu.rs | 587 -----
platform/render-terminal/src/application/dialog.rs | 843 -------
platform/render-terminal/src/application/editor.rs | 380 ---
platform/render-terminal/src/application/file.rs | 778 ------
.../src/application/file/browser.rs | 652 -----
.../render-terminal/src/application/file/paths.rs | 181 --
platform/render-terminal/src/application/focus.rs | 510 ----
.../render-terminal/src/application/geometry.rs | 142 --
platform/render-terminal/src/application/input.rs | 437 ----
platform/render-terminal/src/application/layout.rs | 447 ----
.../src/application/markdown_text.rs | 96 -
.../render-terminal/src/application/morph_mark.rs | 164 --
.../src/application/morph_mark/geometry.rs | 207 --
.../src/application/morph_mark/render.rs | 140 --
.../render-terminal/src/application/mutations.rs | 231 --
platform/render-terminal/src/application/native.rs | 138 -
platform/render-terminal/src/application/paint.rs | 2363 -----------------
.../render-terminal/src/application/pointer.rs | 79 -
.../render-terminal/src/application/reading.rs | 910 -------
.../src/application/reading/find.rs | 251 --
.../render-terminal/src/application/resources.rs | 888 -------
platform/render-terminal/src/application/rows.rs | 636 -----
platform/render-terminal/src/application/run.rs | 881 -------
platform/render-terminal/src/application/scene.rs | 310 ---
platform/render-terminal/src/application/scroll.rs | 478 ----
.../render-terminal/src/application/selection.rs | 682 -----
.../src/application/selection/keyboard.rs | 295 ---
.../src/application/selection/layout.rs | 236 --
platform/render-terminal/src/application/source.rs | 921 -------
.../render-terminal/src/application/source/diff.rs | 319 ---
.../src/application/source/reveal.rs | 143 --
.../src/application/subscriptions.rs | 154 --
.../render-terminal/src/application/terminal.rs | 275 --
.../src/application/terminal/input.rs | 174 --
platform/render-terminal/src/application/tests.rs | 1541 ------------
.../src/application/tests/activation.rs | 746 ------
.../src/application/tests/adaptive.rs | 339 ---
.../src/application/tests/continuations.rs | 332 ---
.../src/application/tests/driver.rs | 111 -
.../render-terminal/src/application/tests/input.rs | 1180 ---------
.../src/application/tests/layout.rs | 1739 -------------
.../src/application/tests/reading.rs | 1014 --------
.../src/application/tests/reading/choice.rs | 232 --
.../src/application/tests/reading/context_menu.rs | 186 --
.../src/application/tests/reading/dialog.rs | 837 -------
.../src/application/tests/reading/find.rs | 482 ----
.../src/application/tests/reading/items.rs | 855 -------
.../src/application/tests/reading/keyboard.rs | 218 --
.../src/application/tests/reading/markdown.rs | 591 -----
.../src/application/tests/reconciliation.rs | 776 ------
.../src/application/tests/retained.rs | 490 ----
.../render-terminal/src/application/tests/rows.rs | 812 ------
.../src/application/tests/source.rs | 321 ---
.../src/application/tests/terminal.rs | 296 ---
.../src/application/tests/textarea.rs | 2642 --------------------
.../render-terminal/src/application/text_file.rs | 157 --
.../render-terminal/src/application/textarea.rs | 816 ------
.../src/application/textarea/clipboard.rs | 439 ----
.../src/application/textarea/paste.rs | 927 -------
platform/render-terminal/src/editor.rs | 906 -------
platform/render-terminal/src/editor/layout.rs | 208 --
.../render-terminal/src/editor/presentation.rs | 723 ------
platform/render-terminal/src/lib.rs | 26 -
platform/render-terminal/src/markdown.rs | 870 -------
platform/render-terminal/src/markdown/diff.rs | 295 ---
platform/render-terminal/src/markdown/fenced.rs | 193 --
platform/render-terminal/src/markdown/palette.rs | 87 -
platform/render-terminal/src/markdown/prepared.rs | 150 --
.../src/markdown/prepared/cursor.rs | 378 ---
.../src/markdown/prepared/cursor/checkpoints.rs | 75 -
.../src/markdown/prepared/cursor/seeking.rs | 142 --
.../src/markdown/prepared/operations.rs | 261 --
.../src/markdown/prepared/seeking.rs | 105 -
.../src/markdown/prepared/tables.rs | 274 --
.../src/markdown/prepared/tables/measure.rs | 134 -
.../src/markdown/prepared/tables/seeking.rs | 121 -
.../src/markdown/prepared/tables/window.rs | 214 --
.../render-terminal/src/markdown/prepared/tests.rs | 334 ---
.../src/markdown/prepared/tests/budget.rs | 98 -
platform/render-terminal/src/markdown/syntax.rs | 333 ---
platform/render-terminal/src/markdown/table.rs | 471 ----
platform/render-terminal/src/markdown/wrap.rs | 140 --
platform/render-terminal/src/startup.rs | 318 ---
platform/render-terminal/src/terminal.rs | 444 ----
plugins/agent/native/Cargo.toml | 3 -
plugins/agent/native/src/owner/tests/files.rs | 15 +-
plugins/agent/native/src/owner/tests/web.rs | 1 +
plugins/agent/native/src/tools.rs | 48 +-
plugins/agent/native/src/tools/title.rs | 60 +
products/maka/bundle/README.md | 24 +-
products/maka/bundle/README.zh-CN.md | 24 +-
products/maka/cli/Cargo.toml | 5 +-
products/maka/cli/README.md | 8 +-
products/maka/cli/README.zh-CN.md | 8 +-
products/maka/cli/THIRD_PARTY_NOTICES.txt | 42 +-
products/maka/cli/notices.json | 11 +-
products/maka/cli/src/application.rs | 168 +-
products/maka/cli/src/application/product.rs | 2 +-
.../maka/cli/src/application/product/headless.rs | 4 +-
.../application/product/headless/update/tests.rs | 2 +-
.../maka/cli/src/application/product/launch.rs | 243 +-
.../cli/src/application/product/launch/tests.rs | 84 +-
.../maka/cli/src/application/product/restore.rs | 20 +-
.../maka/cli/src/application/product/update.rs | 28 +-
.../cli/src/application/product/update/tests.rs | 84 +-
products/maka/cli/src/args.rs | 57 +-
products/maka/cli/tests/integration/main.rs | 1 -
products/maka/cli/tests/integration/terminal.rs | 378 ---
.../cli/tests/integration/terminal/application.rs | 718 ------
.../tests/integration/terminal/bundled_product.rs | 1217 ---------
.../integration/terminal/bundled_product/chat.rs | 585 -----
.../terminal/bundled_product/chat/attachments.rs | 190 --
.../terminal/bundled_product/chat/code_mode.rs | 226 --
.../terminal/bundled_product/chat/compaction.rs | 289 ---
.../terminal/bundled_product/chat/computer.rs | 197 --
.../terminal/bundled_product/chat/followup.rs | 157 --
.../terminal/bundled_product/chat/goal.rs | 170 --
.../terminal/bundled_product/chat/graph.rs | 352 ---
.../terminal/bundled_product/chat/health.rs | 254 --
.../terminal/bundled_product/chat/performance.rs | 479 ----
.../bundled_product/chat/performance/parity.rs | 820 ------
.../terminal/bundled_product/chat/plan.rs | 169 --
.../terminal/bundled_product/chat/recap.rs | 183 --
.../terminal/bundled_product/chat/title.rs | 148 --
.../terminal/bundled_product/chat/todo.rs | 135 -
.../terminal/bundled_product/chat/trace.rs | 207 --
.../terminal/bundled_product/host_logs.rs | 144 --
.../maka/cli/tests/integration/terminal/frames.rs | 83 -
.../integration/terminal/product/executor-ui.js | 118 -
.../tests/integration/terminal/product/executor.rs | 290 ---
.../integration/terminal/product/executor_setup.rs | 339 ---
.../maka/cli/tests/integration/terminal_shell.rs | 51 +-
products/maka/terminal-shell/README.md | 4 +-
products/maka/terminal-shell/README.zh-CN.md | 4 +-
products/maka/terminal-shell/app/App.tsx | 111 +-
products/maka/terminal-shell/app/Attachments.tsx | 45 +-
.../maka/terminal-shell/app/CapabilityRequest.tsx | 49 +-
products/maka/terminal-shell/app/Checklist.tsx | 20 +-
products/maka/terminal-shell/app/Composer.tsx | 95 +-
.../maka/terminal-shell/app/ConnectionForm.tsx | 73 +-
.../maka/terminal-shell/app/ConnectionLogin.tsx | 33 +-
.../maka/terminal-shell/app/ConnectionModel.tsx | 32 +-
.../maka/terminal-shell/app/ConnectionSettings.tsx | 52 +-
products/maka/terminal-shell/app/Conversation.tsx | 157 +-
products/maka/terminal-shell/app/CurrentDiff.tsx | 42 +-
products/maka/terminal-shell/app/CurrentFile.tsx | 107 +-
products/maka/terminal-shell/app/FinalActions.tsx | 18 +-
products/maka/terminal-shell/app/FormRequest.tsx | 37 +-
.../maka/terminal-shell/app/GeneralSettings.tsx | 75 +-
.../maka/terminal-shell/app/ImageThumbnail.tsx | 27 +-
products/maka/terminal-shell/app/ImageViewer.tsx | 46 +-
.../terminal-shell/app/InteractionComposer.tsx | 186 +-
products/maka/terminal-shell/app/MarkdownBlock.tsx | 13 +-
products/maka/terminal-shell/app/MarkdownBody.tsx | 8 +-
products/maka/terminal-shell/app/MessageCopy.tsx | 22 +-
products/maka/terminal-shell/app/MessageRow.tsx | 17 +-
products/maka/terminal-shell/app/ModelSelect.tsx | 79 +-
products/maka/terminal-shell/app/ModelSettings.tsx | 45 +-
.../maka/terminal-shell/app/NetworkSettings.tsx | 69 +-
products/maka/terminal-shell/app/Palette.tsx | 13 +-
.../maka/terminal-shell/app/PluginSettings.tsx | 83 +-
products/maka/terminal-shell/app/QueueDock.tsx | 86 +-
products/maka/terminal-shell/app/ResourcePane.tsx | 198 +-
.../maka/terminal-shell/app/SessionActions.tsx | 93 +-
products/maka/terminal-shell/app/Terminal.tsx | 75 +-
products/maka/terminal-shell/app/Terminals.tsx | 33 +-
products/maka/terminal-shell/app/ToolCard.tsx | 91 +-
products/maka/terminal-shell/app/ToolReader.tsx | 136 +-
products/maka/terminal-shell/app/ToolSource.tsx | 12 +-
.../maka/terminal-shell/app/TurnNavigation.tsx | 19 +-
products/maka/terminal-shell/app/TurnPanel.tsx | 34 +-
products/maka/terminal-shell/app/TurnUsage.tsx | 80 +-
products/maka/terminal-shell/app/Workbench.tsx | 5 +-
.../maka/terminal-shell/app/WorkspaceBrowser.tsx | 86 +-
.../maka/terminal-shell/app/WorkspaceChanges.tsx | 58 +-
.../maka/terminal-shell/app/WorkspaceFiles.tsx | 25 +-
.../maka/terminal-shell/app/WorkspacePicker.tsx | 60 +-
.../maka/terminal-shell/app/attachmentDraft.ts | 34 +-
products/maka/terminal-shell/app/browsing.ts | 28 +-
.../maka/terminal-shell/app/connectionLogin.ts | 21 +-
.../maka/terminal-shell/app/generalSettings.ts | 23 +-
products/maka/terminal-shell/app/host.ts | 19 +-
.../maka/terminal-shell/app/interaction-form.ts | 40 +-
products/maka/terminal-shell/app/locale-common.ts | 55 +
.../maka/terminal-shell/app/locale-conversation.ts | 1291 ++++++++++
.../maka/terminal-shell/app/locale-settings.ts | 869 +++++++
.../maka/terminal-shell/app/locale-workspace.ts | 546 ++++
products/maka/terminal-shell/app/locale.ts | 66 +
products/maka/terminal-shell/app/modelSelection.ts | 17 +-
.../maka/terminal-shell/app/networkSettings.ts | 17 +-
products/maka/terminal-shell/app/pluginSettings.ts | 27 +-
products/maka/terminal-shell/app/resources.ts | 23 +-
.../maka/terminal-shell/app/sessionLifecycle.ts | 23 +-
products/maka/terminal-shell/app/sessionStatus.ts | 25 +-
products/maka/terminal-shell/app/settings.ts | 67 +-
.../maka/terminal-shell/app/settingsOperations.ts | 29 +-
products/maka/terminal-shell/app/terminalState.ts | 67 +-
products/maka/terminal-shell/app/text.ts | 69 +
products/maka/terminal-shell/app/toolActivity.ts | 10 +-
products/maka/terminal-shell/app/toolCards.ts | 42 +-
products/maka/terminal-shell/app/ui.tsx | 17 +-
.../maka/terminal-shell/app/workspaceFileState.ts | 35 +-
products/maka/terminal-shell/build.rs | 3 +
products/maka/terminal-shell/src/bindings.rs | 10 +-
.../maka/terminal-shell/src/bindings/uploads.rs | 19 +-
products/maka/terminal-shell/src/entry.rs | 12 +-
.../maka/terminal-shell/src/terminal/pixels.rs | 9 +-
.../terminal-shell/src/terminal/pixels/input.rs | 323 ++-
.../terminal-shell/src/terminal/pixels/session.rs | 83 +-
products/maka/terminal-shell/tests/real_host.rs | 77 +-
.../tests/real_host/attachment_intake.rs | 5 +-
.../tests/real_host/terminal_workbench.rs | 2 +-
products/maka/terminal-shell/tests/support/pty.rs | 2 +-
scripts/asf-license-headers.test.mjs | 4 +-
scripts/pr-effort.test.mjs | 2 +-
scripts/rust/README.md | 53 +-
scripts/rust/README.zh-CN.md | 42 +-
scripts/rust/benchmark-startup.py | 208 --
scripts/rust/benchmark-tui-parity.py | 609 -----
scripts/rust/terminal-tool-activity.test.mjs | 43 +-
.../references/testing-and-shipping.md | 8 +-
240 files changed, 5692 insertions(+), 55434 deletions(-)
delete mode 100644 platform/render-terminal/Cargo.toml
delete mode 100644 platform/render-terminal/README.md
delete mode 100644 platform/render-terminal/README.zh-CN.md
delete mode 100644 platform/render-terminal/src/application.rs
delete mode 100644 platform/render-terminal/src/application/activation.rs
delete mode 100644 platform/render-terminal/src/application/adaptive.rs
delete mode 100644 platform/render-terminal/src/application/border.rs
delete mode 100644 platform/render-terminal/src/application/checkpoint.rs
delete mode 100644 platform/render-terminal/src/application/choice.rs
delete mode 100644 platform/render-terminal/src/application/context_menu.rs
delete mode 100644 platform/render-terminal/src/application/dialog.rs
delete mode 100644 platform/render-terminal/src/application/editor.rs
delete mode 100644 platform/render-terminal/src/application/file.rs
delete mode 100644 platform/render-terminal/src/application/file/browser.rs
delete mode 100644 platform/render-terminal/src/application/file/paths.rs
delete mode 100644 platform/render-terminal/src/application/focus.rs
delete mode 100644 platform/render-terminal/src/application/geometry.rs
delete mode 100644 platform/render-terminal/src/application/input.rs
delete mode 100644 platform/render-terminal/src/application/layout.rs
delete mode 100644 platform/render-terminal/src/application/markdown_text.rs
delete mode 100644 platform/render-terminal/src/application/morph_mark.rs
delete mode 100644 platform/render-terminal/src/application/morph_mark/geometry.rs
delete mode 100644 platform/render-terminal/src/application/morph_mark/render.rs
delete mode 100644 platform/render-terminal/src/application/mutations.rs
delete mode 100644 platform/render-terminal/src/application/native.rs
delete mode 100644 platform/render-terminal/src/application/paint.rs
delete mode 100644 platform/render-terminal/src/application/pointer.rs
delete mode 100644 platform/render-terminal/src/application/reading.rs
delete mode 100644 platform/render-terminal/src/application/reading/find.rs
delete mode 100644 platform/render-terminal/src/application/resources.rs
delete mode 100644 platform/render-terminal/src/application/rows.rs
delete mode 100644 platform/render-terminal/src/application/run.rs
delete mode 100644 platform/render-terminal/src/application/scene.rs
delete mode 100644 platform/render-terminal/src/application/scroll.rs
delete mode 100644 platform/render-terminal/src/application/selection.rs
delete mode 100644 platform/render-terminal/src/application/selection/keyboard.rs
delete mode 100644 platform/render-terminal/src/application/selection/layout.rs
delete mode 100644 platform/render-terminal/src/application/source.rs
delete mode 100644 platform/render-terminal/src/application/source/diff.rs
delete mode 100644 platform/render-terminal/src/application/source/reveal.rs
delete mode 100644 platform/render-terminal/src/application/subscriptions.rs
delete mode 100644 platform/render-terminal/src/application/terminal.rs
delete mode 100644 platform/render-terminal/src/application/terminal/input.rs
delete mode 100644 platform/render-terminal/src/application/tests.rs
delete mode 100644 platform/render-terminal/src/application/tests/activation.rs
delete mode 100644 platform/render-terminal/src/application/tests/adaptive.rs
delete mode 100644 platform/render-terminal/src/application/tests/continuations.rs
delete mode 100644 platform/render-terminal/src/application/tests/driver.rs
delete mode 100644 platform/render-terminal/src/application/tests/input.rs
delete mode 100644 platform/render-terminal/src/application/tests/layout.rs
delete mode 100644 platform/render-terminal/src/application/tests/reading.rs
delete mode 100644 platform/render-terminal/src/application/tests/reading/choice.rs
delete mode 100644 platform/render-terminal/src/application/tests/reading/context_menu.rs
delete mode 100644 platform/render-terminal/src/application/tests/reading/dialog.rs
delete mode 100644 platform/render-terminal/src/application/tests/reading/find.rs
delete mode 100644 platform/render-terminal/src/application/tests/reading/items.rs
delete mode 100644 platform/render-terminal/src/application/tests/reading/keyboard.rs
delete mode 100644 platform/render-terminal/src/application/tests/reading/markdown.rs
delete mode 100644 platform/render-terminal/src/application/tests/reconciliation.rs
delete mode 100644 platform/render-terminal/src/application/tests/retained.rs
delete mode 100644 platform/render-terminal/src/application/tests/rows.rs
delete mode 100644 platform/render-terminal/src/application/tests/source.rs
delete mode 100644 platform/render-terminal/src/application/tests/terminal.rs
delete mode 100644 platform/render-terminal/src/application/tests/textarea.rs
delete mode 100644 platform/render-terminal/src/application/text_file.rs
delete mode 100644 platform/render-terminal/src/application/textarea.rs
delete mode 100644 platform/render-terminal/src/application/textarea/clipboard.rs
delete mode 100644 platform/render-terminal/src/application/textarea/paste.rs
delete mode 100644 platform/render-terminal/src/editor.rs
delete mode 100644 platform/render-terminal/src/editor/layout.rs
delete mode 100644 platform/render-terminal/src/editor/presentation.rs
delete mode 100644 platform/render-terminal/src/lib.rs
delete mode 100644 platform/render-terminal/src/markdown.rs
delete mode 100644 platform/render-terminal/src/markdown/diff.rs
delete mode 100644 platform/render-terminal/src/markdown/fenced.rs
delete mode 100644 platform/render-terminal/src/markdown/palette.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared/cursor.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared/cursor/checkpoints.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared/cursor/seeking.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared/operations.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared/seeking.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared/tables.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared/tables/measure.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared/tables/seeking.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared/tables/window.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared/tests.rs
delete mode 100644 platform/render-terminal/src/markdown/prepared/tests/budget.rs
delete mode 100644 platform/render-terminal/src/markdown/syntax.rs
delete mode 100644 platform/render-terminal/src/markdown/table.rs
delete mode 100644 platform/render-terminal/src/markdown/wrap.rs
delete mode 100644 platform/render-terminal/src/startup.rs
delete mode 100644 platform/render-terminal/src/terminal.rs
create mode 100644 plugins/agent/native/src/tools/title.rs
delete mode 100644 products/maka/cli/tests/integration/terminal.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/application.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/attachments.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/code_mode.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/compaction.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/computer.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/followup.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/goal.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/graph.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/health.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/performance.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/performance/parity.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/plan.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/recap.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/title.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/todo.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/chat/trace.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/bundled_product/host_logs.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/frames.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/product/executor-ui.js
delete mode 100644 products/maka/cli/tests/integration/terminal/product/executor.rs
delete mode 100644 products/maka/cli/tests/integration/terminal/product/executor_setup.rs
create mode 100644 products/maka/terminal-shell/app/locale-common.ts
create mode 100644 products/maka/terminal-shell/app/locale-conversation.ts
create mode 100644 products/maka/terminal-shell/app/locale-settings.ts
create mode 100644 products/maka/terminal-shell/app/locale-workspace.ts
create mode 100644 products/maka/terminal-shell/app/locale.ts
create mode 100644 products/maka/terminal-shell/app/text.ts
delete mode 100644 scripts/rust/benchmark-startup.py
delete mode 100644 scripts/rust/benchmark-tui-parity.py
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 18/22: fix(terminal): preserve reply ownership across list splices
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 3b2df117eb0693ecd721d0233e6f69314c117fab
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 17:15:15 2026 +0800
fix(terminal): preserve reply ownership across list splices
---
Cargo.lock | 24 ++++++++++++------------
Cargo.toml | 18 +++++++++---------
2 files changed, 21 insertions(+), 21 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index dbf0d59b10..34661242c8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6313,7 +6313,7 @@ dependencies = [
[[package]]
name = "gpui-fast-wgpu"
version = "0.1.10"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"anyhow",
"bytemuck",
@@ -7714,7 +7714,7 @@ checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "icy_sixel"
version = "0.5.1"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"quantette",
"thiserror 2.0.20",
@@ -10922,7 +10922,7 @@ dependencies = [
[[package]]
name = "nsh-base"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"html5ever 0.27.0",
"log",
@@ -10940,7 +10940,7 @@ dependencies = [
[[package]]
name = "nsh-bundler"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"anyhow",
"dunce",
@@ -10965,7 +10965,7 @@ dependencies = [
[[package]]
name = "nsh-core"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"cap-std",
"flate2",
@@ -10988,7 +10988,7 @@ dependencies = [
[[package]]
name = "nsh-engine"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"crossbeam-channel",
"libc",
@@ -11006,7 +11006,7 @@ dependencies = [
[[package]]
name = "nsh-gpui-terminal"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -11023,7 +11023,7 @@ dependencies = [
[[package]]
name = "nsh-js"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"futures-util",
"getrandom 0.4.3",
@@ -11044,7 +11044,7 @@ dependencies = [
[[package]]
name = "nsh-macros"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"imagesize 0.15.0",
"proc-macro2",
@@ -11056,7 +11056,7 @@ dependencies = [
[[package]]
name = "nsh-package"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"base64 0.22.1",
"ed25519-dalek 2.2.0",
@@ -11071,7 +11071,7 @@ dependencies = [
[[package]]
name = "nsh-platform-gpui"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -11103,7 +11103,7 @@ dependencies = [
[[package]]
name = "nsh-platform-tui"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
+source = "git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d"
dependencies = [
"base64 0.22.1",
"crossbeam-channel",
diff --git a/Cargo.toml b/Cargo.toml
index 0382775237..912010e3a3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -91,13 +91,13 @@ publish = false
hex = "0.4.3"
maka-ui-authoring = { path = "plugins/application/authoring/native" }
maka-app-client = { path = "products/maka/client" }
-nsh-core = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
-nsh-engine = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
-nsh-platform-tui = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
-nsh-platform-gpui = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042", default-features = false, features = ["js"] }
-nsh-gpui-terminal = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
-nsh-bundler = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042", default-features = false }
-nsh-base = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
+nsh-core = { git = "https://github.com/M4n5ter/nsh", rev = "36c0fa75e81db858d62a776408f839e5a5333c8d" }
+nsh-engine = { git = "https://github.com/M4n5ter/nsh", rev = "36c0fa75e81db858d62a776408f839e5a5333c8d" }
+nsh-platform-tui = { git = "https://github.com/M4n5ter/nsh", rev = "36c0fa75e81db858d62a776408f839e5a5333c8d" }
+nsh-platform-gpui = { git = "https://github.com/M4n5ter/nsh", rev = "36c0fa75e81db858d62a776408f839e5a5333c8d", default-features = false, features = ["js"] }
+nsh-gpui-terminal = { git = "https://github.com/M4n5ter/nsh", rev = "36c0fa75e81db858d62a776408f839e5a5333c8d" }
+nsh-bundler = { git = "https://github.com/M4n5ter/nsh", rev = "36c0fa75e81db858d62a776408f839e5a5333c8d", default-features = false }
+nsh-base = { git = "https://github.com/M4n5ter/nsh", rev = "36c0fa75e81db858d62a776408f839e5a5333c8d" }
maka-agent-facts = { path = "plugins/agent/facts" }
maka-packages = { path = "platform/packages" }
maka-agent-ports = { path = "plugins/agent/ports" }
@@ -214,8 +214,8 @@ gpui-fast-macos = { path = "third-party/gpui-fast/crates/gpui_macos" }
gpui-fast-windows = { path = "third-party/gpui-fast/crates/gpui_windows" }
gpui-fast-linux = { path = "third-party/gpui-fast/crates/gpui_linux" }
# The terminal backend owns the offscreen renderer and image codec.
-gpui-fast-wgpu = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
-icy_sixel = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
+gpui-fast-wgpu = { git = "https://github.com/M4n5ter/nsh", rev = "36c0fa75e81db858d62a776408f839e5a5333c8d" }
+icy_sixel = { git = "https://github.com/M4n5ter/nsh", rev = "36c0fa75e81db858d62a776408f839e5a5333c8d" }
gpui-pre-scheduler = { path = "third-party/gpui-fast/crates/scheduler" }
[patch."https://github.com/longbridge/gpui-kit"]
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 02/22: terminal: Pipeline complete Sixel frames through a bounded FIFO
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 51a8c03eaa898cf2c5f551759fa2e950e4c1652c
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 15:30:09 2026 +0800
terminal: Pipeline complete Sixel frames through a bounded FIFO
---
.../maka/terminal-shell/src/terminal/pixels.rs | 9 +-
.../terminal-shell/src/terminal/pixels/input.rs | 322 ++++++++++++++++++++-
.../terminal-shell/src/terminal/pixels/session.rs | 83 +++---
3 files changed, 350 insertions(+), 64 deletions(-)
diff --git a/products/maka/terminal-shell/src/terminal/pixels.rs b/products/maka/terminal-shell/src/terminal/pixels.rs
index ed2ad6a187..28d547dee4 100644
--- a/products/maka/terminal-shell/src/terminal/pixels.rs
+++ b/products/maka/terminal-shell/src/terminal/pixels.rs
@@ -274,17 +274,10 @@ pub(super) fn run(
if let Some(bytes) = inbox.effects.take() {
output.submit(bytes)?;
phase = Phase::Effect;
- } else if let Some(encoded) = inbox.encoded.take() {
+ } else if let Some(encoded) = inbox.take_encoded() {
if let Some(wake) = &inbox.wake {
wake.unpark();
}
- let encoded = encoded?;
- if encoded.geometry != inbox.geometry {
- if inbox.in_flight.as_ref() == Some(&encoded.frame.token()) {
- inbox.in_flight = None;
- }
- continue;
- }
let offer = input::Offer {
token: encoded.frame.token(),
geometry: encoded.geometry,
diff --git a/products/maka/terminal-shell/src/terminal/pixels/input.rs b/products/maka/terminal-shell/src/terminal/pixels/input.rs
index 72af87e629..0aef053a9a 100644
--- a/products/maka/terminal-shell/src/terminal/pixels/input.rs
+++ b/products/maka/terminal-shell/src/terminal/pixels/input.rs
@@ -17,7 +17,7 @@
* under the License.
*/
-use super::probe::{Capabilities, SixelGeometry};
+use super::probe::{Capabilities, Protocol, SixelGeometry};
use nsh_gpui_terminal::{FrameToken, SubmittedFrame};
use std::{collections::VecDeque, time::Instant};
use termina::{
@@ -63,6 +63,31 @@ pub(super) struct Encoded {
pub geometry: u64,
}
+/// One admitted draw keeps its capacity through readback, codec and FIFO wait.
+/// Resize can discard completed work, but cannot cancel a running codec.
+struct FrameJob {
+ geometry: u64,
+ work: FrameWork,
+}
+
+enum FrameWork {
+ Reserved,
+ Submitted(Candidate),
+ Encoding(FrameToken),
+ Ready(Encoded),
+}
+
+impl FrameWork {
+ fn token(&self) -> Option<FrameToken> {
+ match self {
+ Self::Reserved => None,
+ Self::Submitted(candidate) => Some(candidate.frame.token()),
+ Self::Encoding(token) => Some(token.clone()),
+ Self::Ready(encoded) => Some(encoded.frame.token()),
+ }
+ }
+}
+
/// Send-only physical input and output state; native contacts stay on the UI owner.
pub(crate) struct Inbox {
pub capabilities: Capabilities,
@@ -70,12 +95,13 @@ pub(crate) struct Inbox {
tracing: bool,
/// Last current-viewport writer receipt, used only by output diagnostics.
pub published: Option<FrameToken>,
- pub(super) pending: Option<Candidate>,
- pub(super) encoded: Option<Result<Encoded, String>>,
+ frames: VecDeque<FrameJob>,
+ frame_capacity: usize,
/// Last image whose entire upload-and-retirement transaction was flushed.
pub last_fully_written_image: Option<u32>,
/// The terminal geometry of the last whole image transaction's flush.
pub last_fully_written_geometry: Option<u64>,
+ /// Sole writer transaction, retained until the native owner consumes its receipt.
pub in_flight: Option<FrameToken>,
pub(super) ready: Option<Offer>,
pub geometry: u64,
@@ -93,8 +119,16 @@ impl Inbox {
events: VecDeque::new(),
tracing,
published: None,
- pending: None,
- encoded: None,
+ frames: VecDeque::new(),
+ // A five-process whole-frame codec scout exceeded 120 encodes/s.
+ // Keep that finite candidate width; GUI/writer throughput is separate.
+ // Smaller hosts use the same hardware width as the native executor.
+ // Capacity gates GPU draws; it never rejects input or application data.
+ frame_capacity: std::thread::available_parallelism()
+ .map(usize::from)
+ .unwrap_or(1)
+ .saturating_sub(1)
+ .clamp(1, 5),
last_fully_written_image: None,
last_fully_written_geometry: None,
in_flight: None,
@@ -108,6 +142,117 @@ impl Inbox {
}
}
+ /// Admission is checked before drawing, so the native dirty bit coalesces
+ /// changes while these finite readback/codec/result slots are occupied.
+ pub(super) fn can_submit(&self) -> bool {
+ let parallel = self.capabilities.protocol == Some(Protocol::Sixel);
+ let capacity = if parallel { self.frame_capacity } else { 1 };
+ if self.resize.is_some()
+ || self.capabilities.viewport().is_none()
+ || self.frames.len() >= capacity
+ || (!parallel && self.in_flight.is_some())
+ {
+ return false;
+ }
+ // The first complete flush establishes opaque replacement eligibility
+ // for this geometry. Do not encode a speculative initial parallel burst.
+ self.last_fully_written_geometry == Some(self.geometry)
+ || (self.in_flight.is_none()
+ && !self.frames.iter().any(|job| job.geometry == self.geometry))
+ }
+
+ pub(super) fn reserve_frame(&mut self) -> bool {
+ if !self.can_submit() {
+ return false;
+ }
+ self.frames.push_back(FrameJob {
+ geometry: self.geometry,
+ work: FrameWork::Reserved,
+ });
+ true
+ }
+
+ pub(super) fn submitted(&mut self, candidate: Candidate) -> bool {
+ let Some(job) = self.frames.iter_mut().find(|job| {
+ job.geometry == candidate.geometry && matches!(job.work, FrameWork::Reserved)
+ }) else {
+ return false;
+ };
+ // A resize may arrive while draw submits. Keep that old readback's
+ // reservation until its worker returns, just like an active codec.
+ job.work = FrameWork::Submitted(candidate);
+ true
+ }
+
+ pub(super) fn release_reservation(&mut self) {
+ // A pump can produce no draw, or fail before producing a ticket.
+ self.frames
+ .retain(|job| !matches!(job.work, FrameWork::Reserved));
+ }
+
+ pub(super) fn oldest_frame(&self) -> Option<u64> {
+ self.frames
+ .front()?
+ .work
+ .token()
+ .map(|token| token.sequence())
+ }
+
+ pub(super) fn take_candidate(&mut self) -> Option<Candidate> {
+ let job = self
+ .frames
+ .iter_mut()
+ .find(|job| matches!(job.work, FrameWork::Submitted(_)))?;
+ let token = job.work.token().expect("submitted native frame");
+ let FrameWork::Submitted(candidate) =
+ std::mem::replace(&mut job.work, FrameWork::Encoding(token))
+ else {
+ unreachable!()
+ };
+ Some(candidate)
+ }
+
+ /// A worker can only finish its own immutable token. Stale and cancelled
+ /// jobs release capacity here, after their readback/codec actually returned.
+ pub(super) fn finished(&mut self, token: &FrameToken, encoded: Option<Encoded>) {
+ let Some(index) = self
+ .frames
+ .iter()
+ .position(|job| matches!(&job.work, FrameWork::Encoding(active) if active == token))
+ else {
+ return;
+ };
+ if let Some(encoded) = encoded.filter(|encoded| encoded.geometry == self.geometry) {
+ self.frames[index].work = FrameWork::Ready(encoded);
+ } else {
+ self.frames.remove(index);
+ }
+ }
+
+ /// Completion order cannot reorder terminal transactions. The native owner
+ /// must consume the previous writer receipt before another handoff.
+ pub(super) fn take_encoded(&mut self) -> Option<Encoded> {
+ if self.in_flight.is_some()
+ || !self
+ .frames
+ .front()
+ .is_some_and(|job| matches!(job.work, FrameWork::Ready(_)))
+ {
+ return None;
+ }
+ let FrameWork::Ready(encoded) = self.frames.pop_front()?.work else {
+ unreachable!()
+ };
+ self.in_flight = Some(encoded.frame.token());
+ Some(encoded)
+ }
+
+ pub(super) fn presented(&mut self, token: &FrameToken) {
+ if self.in_flight.as_ref() == Some(token) {
+ self.in_flight = None;
+ }
+ }
+
pub fn push(&mut self, event: Event) -> Result<bool, String> {
if let Event::Key(key) = &event
&& key.kind != KeyEventKind::Release
@@ -201,7 +346,10 @@ impl Inbox {
.ok_or("terminal geometry generation exhausted")?;
self.resize = Some(self.capabilities.size);
self.published = None;
- self.pending = None;
+ // A submitted readback or codec still consumes its original slot.
+ // Completed stale wire is safe to retire immediately.
+ self.frames
+ .retain(|job| !matches!(job.work, FrameWork::Ready(_)));
self.lose_pointer();
Ok(())
}
@@ -309,6 +457,168 @@ mod tests {
}
}
+ fn encoded(geometry: u64) -> Encoded {
+ Encoded {
+ frame: WireFrame::Inline(Default::default()),
+ geometry,
+ }
+ }
+
+ #[test]
+ fn completed_tail_waits_for_its_predecessor_and_the_native_writer_receipt() {
+ let mut inbox = Inbox::new(capabilities(), false);
+ let token = FrameToken::default();
+ inbox.frames.push_back(FrameJob {
+ geometry: 1,
+ work: FrameWork::Encoding(token.clone()),
+ });
+ inbox.frames.push_back(FrameJob {
+ geometry: 1,
+ work: FrameWork::Ready(encoded(1)),
+ });
+ assert!(
+ inbox.take_encoded().is_none(),
+ "the completed tail cannot pass the head"
+ );
+ assert_eq!(inbox.frames.len(), 2);
+ inbox.finished(&token, Some(encoded(1)));
+ let first = inbox.take_encoded().unwrap();
+ assert_eq!(inbox.frames.len(), 1);
+ assert!(inbox.take_encoded().is_none());
+ inbox.written(
+ Offer {
+ token: first.frame.token(),
+ geometry: 1,
+ },
+ None,
+ );
+ assert!(
+ inbox.take_encoded().is_none(),
+ "a flush cannot overwrite an unconsumed receipt"
+ );
+ let receipt = inbox.ready.take().unwrap();
+ inbox.presented(&receipt.token);
+ assert!(inbox.take_encoded().is_some());
+ assert!(inbox.frames.is_empty());
+ }
+
+ #[test]
+ fn first_flush_opens_parallel_admission_and_every_resize_closes_it() {
+ let mut inbox = Inbox::new(capabilities(), false);
+ inbox.frame_capacity = 5;
+ assert!(inbox.reserve_frame());
+ assert!(!inbox.can_submit(), "the first geometry admits one frame");
+ // A pump which produces no submission must return its reservation.
+ inbox.release_reservation();
+ assert!(inbox.reserve_frame());
+ let token = FrameToken::default();
+ inbox.frames.back_mut().unwrap().work = FrameWork::Encoding(token.clone());
+ inbox.finished(&token, Some(encoded(1)));
+ assert!(!inbox.can_submit(), "codec completion is not a flush");
+ let first = inbox.take_encoded().unwrap();
+ assert!(!inbox.can_submit(), "writer handoff is not a flush");
+ inbox.written(
+ Offer {
+ token: first.frame.token(),
+ geometry: 1,
+ },
+ None,
+ );
+ for _ in 0..5 {
+ assert!(inbox.reserve_frame());
+ }
+ assert!(
+ !inbox.can_submit(),
+ "all unhanded stages share the finite budget"
+ );
+ // Treat these reservations as executor-queued/active workers. Resize
+ // cannot recover their capacity before each worker really returns.
+ for job in &mut inbox.frames {
+ job.work = FrameWork::Encoding(token.clone());
+ }
+ inbox
+ .push(Event::WindowResized(capabilities().size))
+ .unwrap();
+ inbox.resize.take();
+ let receipt = inbox.ready.take().unwrap();
+ inbox.presented(&receipt.token);
+ assert_eq!(inbox.frames.len(), 5);
+ assert!(!inbox.can_submit());
+ inbox.finished(&token, Some(encoded(1)));
+ assert_eq!(
+ inbox.frames.len(),
+ 4,
+ "a completed stale result releases only its own slot"
+ );
+ assert!(inbox.reserve_frame());
+ assert!(
+ !inbox.can_submit(),
+ "new geometry needs its own whole flush"
+ );
+ inbox.release_reservation();
+ for _ in 0..4 {
+ inbox.finished(&token, None);
+ }
+ assert!(inbox.frames.is_empty());
+ assert!(inbox.can_submit());
+ assert_eq!(inbox.last_fully_written_geometry, Some(1));
+ }
+
+ #[test]
+ fn stale_ready_wire_retires_but_failure_and_cancel_return_only_the_finished_slot() {
+ let mut inbox = Inbox::new(capabilities(), false);
+ let token = FrameToken::default();
+ inbox.frames.push_back(FrameJob {
+ geometry: 1,
+ work: FrameWork::Ready(encoded(1)),
+ });
+ inbox.frames.push_back(FrameJob {
+ geometry: 1,
+ work: FrameWork::Encoding(token.clone()),
+ });
+ inbox
+ .push(Event::WindowResized(capabilities().size))
+ .unwrap();
+ assert_eq!(
+ inbox.frames.len(),
+ 1,
+ "only already completed wire is disposable"
+ );
+ inbox.finished(&token, None);
+ assert!(inbox.frames.is_empty());
+ assert!(inbox.take_encoded().is_none());
+ assert!(inbox.in_flight.is_none());
+ assert!(
+ inbox.ready.is_none(),
+ "failure/cancel cannot manufacture a writer receipt"
+ );
+ }
+
+ #[test]
+ fn protocols_with_previous_image_state_remain_serial_after_flush() {
+ for protocol in [Protocol::Kitty, Protocol::Iterm2] {
+ let mut caps = capabilities();
+ caps.protocol = Some(protocol);
+ let mut inbox = Inbox::new(caps, false);
+ inbox.frame_capacity = 5;
+ inbox.last_fully_written_geometry = Some(1);
+ assert!(inbox.reserve_frame());
+ assert!(!inbox.can_submit());
+ inbox.release_reservation();
+ let token = FrameToken::default();
+ inbox.in_flight = Some(token.clone());
+ inbox.written(Offer { token, geometry: 1 }, Some(41));
+ assert!(
+ !inbox.can_submit(),
+ "previous-image state waits for native receipt consumption"
+ );
+ let receipt = inbox.ready.take().unwrap();
+ inbox.presented(&receipt.token);
+ assert!(inbox.can_submit());
+ assert_eq!(inbox.last_fully_written_image, Some(41));
+ }
+ }
+
#[test]
fn late_sixel_maxima_resize_the_same_canvas_without_changing_terminal_units() {
let mut inbox = Inbox::new(capabilities(), false);
diff --git a/products/maka/terminal-shell/src/terminal/pixels/session.rs b/products/maka/terminal-shell/src/terminal/pixels/session.rs
index 041837e7b2..fe9c4b3dde 100644
--- a/products/maka/terminal-shell/src/terminal/pixels/session.rs
+++ b/products/maka/terminal-shell/src/terminal/pixels/session.rs
@@ -123,26 +123,16 @@ fn run(
)
});
let mut state = delivered.state.lock().unwrap();
- let pending_frame = timing.as_ref().and_then(|_| {
- state
- .pixels
- .as_ref()?
- .pending
- .as_ref()
- .map(|candidate| candidate.frame.token().sequence())
- });
+ let pending_frame = timing
+ .as_ref()
+ .and_then(|_| state.pixels.as_ref()?.oldest_frame());
let mut admitted = false;
- if let Some(inbox) = &mut state.pixels
- && drawn_geometry.get() == inbox.geometry
- && inbox.resize.is_none()
- && inbox.pending.is_none()
- {
- inbox.pending = Some(super::input::Candidate {
+ if let Some(inbox) = &mut state.pixels {
+ admitted = inbox.submitted(super::input::Candidate {
frame,
geometry: drawn_geometry.get(),
pump_started: timing.as_ref().map(|(start, ..)| *start),
});
- admitted = true;
}
if let Some((start, end, token, bytes, size)) = timing
&& let Some(trace) = &mut state.trace
@@ -270,14 +260,6 @@ fn run(
platform
.resize(viewport)
.map_err(|error| error.to_string())?;
- presentation
- .state
- .lock()
- .unwrap()
- .pixels
- .as_mut()
- .unwrap()
- .pending = None;
}
if cancel || resize.is_some() {
application.update(|cx| {
@@ -309,9 +291,9 @@ fn run(
}
let mut state = presentation.state.lock().unwrap();
let inbox = state.pixels.as_mut().unwrap();
- if inbox.in_flight.as_ref() == Some(&offer.token) {
- inbox.in_flight = None;
- }
+ inbox.presented(&offer.token);
+ drop(state);
+ presentation.wake_driver();
}
// Mount/focus callbacks and physical input share the native owner.
// Apply queued native work without waiting for output or drawing.
@@ -468,19 +450,22 @@ fn run(
}
}
let allow_draw = {
- let state = presentation.state.lock().unwrap();
- let inbox = state.pixels.as_ref().unwrap();
- inputs_before_draw == 0
- && inbox.pending.is_none()
- && inbox.encoded.is_none()
- && inbox.resize.is_none()
- && geometry.get() == inbox.geometry
- && inbox.capabilities.viewport().is_some()
+ let mut state = presentation.state.lock().unwrap();
+ let inbox = state.pixels.as_mut().unwrap();
+ inputs_before_draw == 0 && geometry.get() == inbox.geometry && inbox.reserve_frame()
};
if let Some(started) = &pump_started {
started.set(allow_draw.then(Instant::now));
}
worked |= platform.pump_with_draw(allow_draw);
+ presentation
+ .state
+ .lock()
+ .unwrap()
+ .pixels
+ .as_mut()
+ .unwrap()
+ .release_reservation();
if let Some(started) = &pump_started {
started.set(None);
}
@@ -539,9 +524,9 @@ fn run(
result
}
-/// Admit one readback-and-encoding job on the existing background executor.
-/// The native input/GPU owner keeps pumping independently of the in-flight
-/// output transaction. The job and executor shutdown cannot hold cleanup.
+/// Start an already reserved readback-and-encoding job on the existing executor.
+/// Its Inbox slot spans executor wait, readback, codec and completed FIFO wait.
+/// Native input keeps pumping; jobs and executor shutdown cannot hold cleanup.
fn encode_pending(presentation: &Presentation, background: &gpui::BackgroundExecutor) -> bool {
if presentation.stop.load(Ordering::Acquire) {
return false;
@@ -554,15 +539,9 @@ fn encode_pending(presentation: &Presentation, background: &gpui::BackgroundExec
let slots = state.pixel_probe.slots;
let tracing = state.trace.is_some();
let inbox = state.pixels.as_mut().unwrap();
- if inbox.in_flight.is_some() || inbox.encoded.is_some() || inbox.resize.is_some() {
- return false;
- }
- let Some(candidate) = inbox.pending.take() else {
+ let Some(candidate) = inbox.take_candidate() else {
return false;
};
- if candidate.geometry != inbox.geometry {
- return false;
- }
let previous = inbox.last_fully_written_image;
let same_viewport_flushed = inbox.last_fully_written_geometry == Some(candidate.geometry);
let image = if previous == Some(slots.first) {
@@ -571,7 +550,6 @@ fn encode_pending(presentation: &Presentation, background: &gpui::BackgroundExec
slots.first
};
let capabilities = inbox.capabilities;
- inbox.in_flight = Some(candidate.frame.token());
if let Some(trace) = &mut state.trace {
let at = Instant::now();
trace.record(
@@ -689,11 +667,16 @@ fn encode_pending(presentation: &Presentation, background: &gpui::BackgroundExec
state.quit || state.error.is_some() || presentation.stop.load(Ordering::Acquire);
let inbox = state.pixels.as_mut().unwrap();
let discarded = !current || cancelled || inbox.geometry != geometry;
- if let Some(result) = result.filter(|_| !discarded) {
- inbox.encoded = Some(result);
- } else if inbox.in_flight.as_ref() == Some(&token) {
- inbox.in_flight = None;
- }
+ let encoded = match result.filter(|_| !discarded) {
+ Some(Ok(encoded)) => Some(encoded),
+ Some(Err(error)) => {
+ state.error.get_or_insert(error);
+ None
+ }
+ None => None,
+ };
+ let inbox = state.pixels.as_mut().unwrap();
+ inbox.finished(&token, encoded);
let wake = inbox.wake.clone();
if let (Some(start), Some(end), Some(trace)) = (started, ended, &mut state.trace) {
let sample = trace.record(&token, geometry, "codec", start, end);
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 08/22: build(terminal): prune the removed renderer dependencies
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 21c7fc22b4b441d3ab34c34e87b59c956d8b3f61
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 16:46:40 2026 +0800
build(terminal): prune the removed renderer dependencies
---
Cargo.lock | 66 +-------------------------------------------------------------
1 file changed, 1 insertion(+), 65 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 4349b8d481..dbf0d59b10 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2580,7 +2580,6 @@ dependencies = [
"crossterm_winapi",
"derive_more",
"document-features",
- "filedescriptor",
"futures-core",
"mio",
"parking_lot",
@@ -6040,7 +6039,7 @@ dependencies = [
"serde",
"serde_json",
"serde_repr",
- "similar 2.7.0",
+ "similar",
"smallvec",
"smol",
"tracing",
@@ -9078,7 +9077,6 @@ version = "0.2.0"
dependencies = [
"base64 0.22.1",
"cap-std",
- "crossterm",
"futures-util",
"hex",
"image",
@@ -9114,7 +9112,6 @@ dependencies = [
"maka-presentation",
"maka-processes-native",
"maka-providers",
- "maka-render-terminal",
"maka-sandbox",
"maka-scheduler",
"maka-session-import",
@@ -9131,7 +9128,6 @@ dependencies = [
"maka-web-api",
"maka-workspace-api",
"maka-workspace-support",
- "ratatui",
"rusqlite",
"schemars 1.2.2",
"serde",
@@ -9291,14 +9287,12 @@ dependencies = [
"maka-process",
"maka-product-native",
"maka-providers",
- "maka-render-terminal",
"maka-sandbox",
"maka-schema",
"maka-settings-native",
"maka-skills-api",
"maka-terminal-shell",
"maka-transport",
- "maka-ui-js",
"maka-ui-runtime",
"maka-workspace-api",
"nsh-base",
@@ -9320,7 +9314,6 @@ dependencies = [
"tokio",
"tokio-util",
"tracing-subscriber",
- "unicode-width",
"uuid",
"windows 0.62.2",
"windows-sys 0.61.2",
@@ -9939,43 +9932,6 @@ dependencies = [
"url",
]
-[[package]]
-name = "maka-render-terminal"
-version = "0.2.0"
-dependencies = [
- "apply-patch",
- "base64 0.22.1",
- "clipboard-rs",
- "clipboard-win",
- "crossterm",
- "futures-util",
- "image",
- "libc",
- "maka-capabilities",
- "maka-event-log",
- "maka-packages",
- "maka-plugin-core",
- "maka-transport",
- "maka-ui-js",
- "maka-ui-runtime",
- "pulldown-cmark",
- "ratatui",
- "ratatui-crossterm",
- "schemars 1.2.2",
- "serde",
- "serde_json",
- "similar 3.2.0",
- "syntect",
- "tempfile",
- "tokio",
- "tokio-util",
- "unicode-linebreak",
- "unicode-segmentation",
- "unicode-width",
- "url",
- "uuid",
-]
-
[[package]]
name = "maka-responses"
version = "0.2.0"
@@ -13465,17 +13421,6 @@ dependencies = [
"syn 1.0.109",
]
-[[package]]
-name = "pulldown-cmark"
-version = "0.13.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
-dependencies = [
- "bitflags 2.13.2",
- "memchr",
- "unicase",
-]
-
[[package]]
name = "pulp"
version = "0.22.3"
@@ -16179,15 +16124,6 @@ dependencies = [
"unicode-segmentation",
]
-[[package]]
-name = "similar"
-version = "3.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987"
-dependencies = [
- "bstr",
-]
-
[[package]]
name = "simplecss"
version = "0.2.2"
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 16/22: fix(terminal): keep preview navigation buttons as focus stops
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 02272488da910c64942dc1516b3a3caca056cb1c
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 17:06:59 2026 +0800
fix(terminal): keep preview navigation buttons as focus stops
The native preview workflow reaches the captured image but fails its original Browse files focus assertion after closing a resized full-screen viewer. The English label is unchanged. Exact pinned native source selects the first visible keyboard stop, and the newly leading horizontal Scroll became that stop before its buttons.
Keep this navigation Scroll out of Tab stops with the existing focusable property. Preserve native dialog restoration, child button focus, wheel routing and descendant focus reveal. Keep the original focus predicate and print focused labels and frames if it fails again. The original failing run retained no focused AX snapshot, so the initial Scroll target is a source inference pending the root runtime gate.
---
products/maka/terminal-shell/app/ResourcePane.tsx | 2 +-
products/maka/terminal-shell/tests/real_host.rs | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/products/maka/terminal-shell/app/ResourcePane.tsx b/products/maka/terminal-shell/app/ResourcePane.tsx
index eb4e0eae1c..337c9f9f39 100644
--- a/products/maka/terminal-shell/app/ResourcePane.tsx
+++ b/products/maka/terminal-shell/app/ResourcePane.tsx
@@ -72,7 +72,7 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
style={{ flex: 1, minHeight: 0, backgroundColor: C.background, padding: 8, gap: 8 }}
>
<View style={{ flexDirection: 'row', alignItems: 'center', flexShrink: 0, gap: 4 }}>
- <Scroll horizontal style={{ flex: 1, minWidth: 0 }}>
+ <Scroll horizontal focusable={false} style={{ flex: 1, minWidth: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Button
accessibilityLabel={tr('Browse files')}
diff --git a/products/maka/terminal-shell/tests/real_host.rs b/products/maka/terminal-shell/tests/real_host.rs
index 75ad9c455a..86dfd4ec71 100644
--- a/products/maka/terminal-shell/tests/real_host.rs
+++ b/products/maka/terminal-shell/tests/real_host.rs
@@ -2505,7 +2505,7 @@ async fn current_workspace_previews_keep_real_contents_drafts_and_session_owners
view.handle_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
wait_view_state(&mut view, |view, screen| screen.contains("64 × 48 · Captured image") && !view.accessibility_snapshot().iter().any(|node| node.label == "Full screen image viewer")).await;
assert!(view.accessibility_snapshot().iter().any(|node| node.label == captured_version));
- assert!(view.accessibility_snapshot().iter().any(|node| node.focused && node.label == "Browse files"));
+ assert!(view.accessibility_snapshot().iter().any(|node| node.focused && node.label == "Browse files"), "closing the full-screen image must restore the preview navigation; focused native nodes: {:?}", view.accessibility_snapshot().into_iter().filter(|node| node.focused).map(|node| (node.label, node.frame)).collect::<Vec<_>>());
view.resize(120, 40);
wait_view_state(&mut view, |view, screen| screen.contains("64 × 48 · Captured image") && screen.contains("UNSENT_PREVIEW_DRAFT") && view.accessibility_snapshot().iter().any(|node| node.label == "Resize workspace preview")).await;
support::click_button(&mut view, "Close workspace preview").await;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 06/22: Polish terminal composer and workspace tool presentation
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 559635e0222c265388fe4793c4588e88955b1022
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 15:57:38 2026 +0800
Polish terminal composer and workspace tool presentation
---
plugins/agent/native/src/owner/tests/files.rs | 15 +-
plugins/agent/native/src/owner/tests/web.rs | 1 +
plugins/agent/native/src/tools.rs | 48 ++++--
plugins/agent/native/src/tools/title.rs | 60 +++++++
products/maka/terminal-shell/app/Composer.tsx | 56 +++----
products/maka/terminal-shell/app/ResourcePane.tsx | 185 +++++++++++-----------
products/maka/terminal-shell/app/ToolCard.tsx | 2 +-
7 files changed, 222 insertions(+), 145 deletions(-)
diff --git a/plugins/agent/native/src/owner/tests/files.rs b/plugins/agent/native/src/owner/tests/files.rs
index a2a1718739..6c97e235db 100644
--- a/plugins/agent/native/src/owner/tests/files.rs
+++ b/plugins/agent/native/src/owner/tests/files.rs
@@ -240,13 +240,13 @@ async fn native_read_text_pages_source_larger_than_one_mib() {
#[tokio::test]
async fn native_files_reuse_actual_read_search_write_edit_and_patch_executors() {
tokio::time::timeout(Duration::from_secs(90), async {
- for (method, input, expected) in [
- ("readText", json!({"path":"original.txt"}), "alpha needle\n"),
- ("glob", json!({"pattern":"*.txt","cwd":"."}), "original.txt"),
- ("grep", json!({"pattern":"needle","path":"."}), "needle"),
- ("write", json!({"path":"original.txt","content":"changed\n"}), "changed\n"),
- ("edit", json!({"path":"original.txt","old_string":"alpha","new_string":"changed"}), "changed needle\n"),
- ("applyPatch", json!({"callId":"patch","operation":{"type":"create_file","path":"new.txt","diff":"+created\n"}}), "created"),
+ for (method, input, expected, title) in [
+ ("readText", json!({"path":"original.txt"}), "alpha needle\n", "Read file · original.txt"),
+ ("glob", json!({"pattern":"*.txt","cwd":"."}), "original.txt", "Find files · *.txt"),
+ ("grep", json!({"pattern":"needle","path":"."}), "needle", "Search files · needle"),
+ ("write", json!({"path":"original.txt","content":"changed\n"}), "changed\n", "Write file · original.txt"),
+ ("edit", json!({"path":"original.txt","old_string":"alpha","new_string":"changed"}), "changed needle\n", "Edit file · original.txt"),
+ ("applyPatch", json!({"callId":"patch","operation":{"type":"create_file","path":"new.txt","diff":"+created\n"}}), "created", "Patch file · new.txt"),
] {
let (listener, f, session) = fixture(method).await;
std::fs::write(f.workspace.join("original.txt"), "alpha needle\n").unwrap();
@@ -267,6 +267,7 @@ async fn native_files_reuse_actual_read_search_write_edit_and_patch_executors()
let page = f.query("history", json!({"sessionId":session,"through":through,"position":{"kind":"start"}})).await.unwrap();
assert!(page["chunks"].as_array().unwrap().iter().any(|chunk| chunk["tool"]["call"]["presentation"] == format!("maka.workspace.Files@1/{method}")), "{page}");
let call = page["chunks"].as_array().unwrap().iter().find(|chunk| chunk["role"] == "tool_call").unwrap();
+ assert_eq!(call["tool"]["call"]["title"]["fallback"], title, "{method}: {call}");
if matches!(method, "glob" | "grep") {
let pattern = f.query("source", json!({"sessionId":session,"through":through,
"source":{"kind":"row","position":call["position"]},"field":"arguments",
diff --git a/plugins/agent/native/src/owner/tests/web.rs b/plugins/agent/native/src/owner/tests/web.rs
index 0ee7b12943..c00c75e6c2 100644
--- a/plugins/agent/native/src/owner/tests/web.rs
+++ b/plugins/agent/native/src/owner/tests/web.rs
@@ -202,6 +202,7 @@ async fn native_web_redirects_require_each_destination_and_cancel_withdraws_pend
assert_eq!(result["kind"], "completed", "{result}");
assert!(result["value"]["content"].as_str().unwrap().contains("Web result retained"));
let facts = service_tools::facts(&f, &session).await;
+ assert!(facts.iter().any(|fact| matches!(fact, Fact::ToolDispatched { name, title: Some(title), .. } if name == "WebFetch" && title.fallback == format!("Read web page · {url}"))));
assert_eq!(facts.iter().filter(|fact| matches!(fact, Fact::ToolDispatched { name, .. } if name == "Http")).count(), 2);
// A body remains owned until cancellation actually closes its socket.
let streaming = start(&model, &f, &session, url.clone()).await;
diff --git a/plugins/agent/native/src/tools.rs b/plugins/agent/native/src/tools.rs
index 8ca8b0ba40..bbfd14e5ac 100644
--- a/plugins/agent/native/src/tools.rs
+++ b/plugins/agent/native/src/tools.rs
@@ -22,6 +22,7 @@ mod approval;
mod computer;
pub(crate) mod image;
mod shell;
+mod title;
mod web;
use crate::contract::{ServiceTool, ToolConfirmation, ToolInput, ToolResult};
use futures_util::FutureExt;
@@ -147,6 +148,26 @@ pub(crate) fn catalog(
let method = methods
.get(&selection.method)
.ok_or("agent_tool_method_unavailable")?;
+ // The actual binding owns the title meaning, never a tool alias.
+ let title_kind = if binding.contract() == &file_contract {
+ match selection.method.as_str() {
+ "readText" | "readImage" => Some(title::Kind::Read),
+ "write" => Some(title::Kind::Write),
+ "edit" => Some(title::Kind::Edit),
+ "applyPatch" => Some(title::Kind::Patch),
+ "glob" => Some(title::Kind::Glob),
+ "grep" => Some(title::Kind::Grep),
+ _ => None,
+ }
+ } else if binding.contract() == &web_contract {
+ match selection.method.as_str() {
+ "fetch" => Some(title::Kind::Fetch),
+ "search" => Some(title::Kind::Search),
+ _ => None,
+ }
+ } else {
+ None
+ };
if (binding.contract() == &file_contract
&& matches!(
selection.method.as_str(),
@@ -248,6 +269,7 @@ pub(crate) fn catalog(
),
tool_names: tool_names.clone(),
selection: selection.clone(),
+ title_kind,
mode: method.mode,
web_basis: None,
};
@@ -342,6 +364,7 @@ struct Service {
binding: String,
target: String,
selection: ServiceTool,
+ title_kind: Option<title::Kind>,
mode: MethodMode,
}
impl ToolPreparer for Service {
@@ -362,6 +385,7 @@ impl ToolPreparer for Service {
let binding = self.binding.clone();
let target = self.target.clone();
let selection = self.selection.clone();
+ let default_title = self.title_kind;
let mode = self.mode;
Box::pin(async move {
if name != selection.name {
@@ -371,16 +395,20 @@ impl ToolPreparer for Service {
return Err(ToolRejection::Cancelled);
}
// No request or effect is admitted until the existing ToolJournal T1.
- let title = selection.title.clone().or_else(|| {
- if selection.input == ToolInput::Shell {
- shell::title(&input)
- } else if selection.input == ToolInput::Skill {
- maka_skills_api::model_title(&selection.method, &input)
- } else {
- (selection.result == ToolResult::Computer)
- .then(|| computer::title(&selection.method, &input))
- }
- });
+ let title = selection
+ .title
+ .clone()
+ .or_else(|| {
+ if selection.input == ToolInput::Shell {
+ shell::title(&input)
+ } else if selection.input == ToolInput::Skill {
+ maka_skills_api::model_title(&selection.method, &input)
+ } else {
+ (selection.result == ToolResult::Computer)
+ .then(|| computer::title(&selection.method, &input))
+ }
+ })
+ .or_else(|| default_title.and_then(|kind| kind.render(&input)));
let presentation = selection.presentation.clone();
let prepared = PreparedEffect::new(move |cancellation| {
Box::pin(async move {
diff --git a/plugins/agent/native/src/tools/title.rs b/plugins/agent/native/src/tools/title.rs
new file mode 100644
index 0000000000..ac89aa0dcb
--- /dev/null
+++ b/plugins/agent/native/src/tools/title.rs
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use maka_agent_facts::display::Text;
+use serde_json::Value;
+
+#[derive(Clone, Copy)]
+pub(super) enum Kind {
+ Read,
+ Write,
+ Edit,
+ Patch,
+ Glob,
+ Grep,
+ Fetch,
+ Search,
+}
+
+impl Kind {
+ pub(super) fn render(self, input: &Value) -> Option<Text> {
+ // ToolCatalog has validated this already parsed input against its schema.
+ let (target, en, zh, tw) = match self {
+ Self::Read => (&input["path"], "Read file", "读取文件", "讀取檔案"),
+ Self::Write => (&input["path"], "Write file", "写入文件", "寫入檔案"),
+ Self::Edit => (&input["path"], "Edit file", "编辑文件", "編輯檔案"),
+ Self::Patch => (
+ &input["operation"]["path"],
+ "Patch file",
+ "修补文件",
+ "修補檔案",
+ ),
+ Self::Glob => (&input["pattern"], "Find files", "查找文件", "尋找檔案"),
+ Self::Grep => (&input["pattern"], "Search files", "搜索文件", "搜尋檔案"),
+ Self::Fetch => (&input["url"], "Read web page", "读取网页", "讀取網頁"),
+ Self::Search => (&input["query"], "Search the web", "搜索网络", "搜尋網路"),
+ };
+ let target = Text::single_line(target.as_str()?)?.fallback;
+ Some(Text::localized(
+ &format!("{en} · {target}"),
+ &format!("{zh} · {target}"),
+ &format!("{tw} · {target}"),
+ ))
+ }
+}
diff --git a/products/maka/terminal-shell/app/Composer.tsx b/products/maka/terminal-shell/app/Composer.tsx
index dab1be2309..431d41ed35 100644
--- a/products/maka/terminal-shell/app/Composer.tsx
+++ b/products/maka/terminal-shell/app/Composer.tsx
@@ -90,6 +90,7 @@ export function Composer(props: {
(!props.session()?.draft().trim() && !props.session()?.attachments.files().length);
const sendLabel = () =>
props.session()?.invocation() != null ? 'Queue message' : 'Send message';
+ const primaryStops = () => props.session()?.invocation() != null && cannotSend();
let unbind = () => {};
cleanup(() => unbind());
const cardRoot = signal<YNode | null>(null);
@@ -409,41 +410,21 @@ export function Composer(props: {
<If when={props.session() === null}>
<View pointerEvents="none" style={{ flex: 1 }} />
</If>
- <If when={props.session()?.invocation() != null}>
- <Button
- disabled={props.session()!.stopping()}
- accessibilityLabel="Stop response"
- tooltip="Stop response · Esc"
- onPress={inputAction(props.session()!.stop, props.session()!.invocation())}
- style={{
- width: 32,
- height: 32,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: C.accent,
- borderRadius: 16,
- }}
- hoverStyle={{ backgroundColor: C.accentHover }}
- focusStyle={{ backgroundColor: C.accentHover }}
- activeStyle={{ backgroundColor: C.accent }}
- disabledStyle={{ backgroundColor: C.line }}
- >
- <Icon name="stop" color={props.session()!.stopping() ? C.muted : C.background} />
- </Button>
- </If>
<Button
- accessibilityLabel={sendLabel()}
- tooltip={`${sendLabel()} · Enter`}
- disabled={cannotSend()}
+ accessibilityLabel={primaryStops() ? 'Stop response' : sendLabel()}
+ tooltip={primaryStops() ? 'Stop response · Esc' : `${sendLabel()} · Enter`}
+ disabled={primaryStops() ? props.session()!.stopping() : cannotSend()}
onPress={
- props.session()
- ? inputAction(props.session()!.sendRendered, {
- owner: props.session()!.sessionId,
- text: props.session()!.draft(),
- backend: props.session()!.backend(),
- attachments: props.session()!.attachments.ids(),
- })
- : undefined
+ primaryStops()
+ ? inputAction(props.session()!.stop, props.session()!.invocation())
+ : props.session()
+ ? inputAction(props.session()!.sendRendered, {
+ owner: props.session()!.sessionId,
+ text: props.session()!.draft(),
+ backend: props.session()!.backend(),
+ attachments: props.session()!.attachments.ids(),
+ })
+ : undefined
}
style={{
width: 32,
@@ -458,7 +439,14 @@ export function Composer(props: {
activeStyle={{ backgroundColor: C.accent }}
disabledStyle={{ backgroundColor: C.line }}
>
- <Icon name="send" color={cannotSend() ? C.muted : C.background} />
+ <Icon
+ name={primaryStops() ? 'stop' : 'send'}
+ color={
+ (primaryStops() ? props.session()!.stopping() : cannotSend())
+ ? C.muted
+ : C.background
+ }
+ />
</Button>
</View>
</View>
diff --git a/products/maka/terminal-shell/app/ResourcePane.tsx b/products/maka/terminal-shell/app/ResourcePane.tsx
index 027efb96fb..d2f97052c2 100644
--- a/products/maka/terminal-shell/app/ResourcePane.tsx
+++ b/products/maka/terminal-shell/app/ResourcePane.tsx
@@ -70,95 +70,82 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
}}
style={{ flex: 1, minHeight: 0, backgroundColor: C.background, padding: 8, gap: 8 }}
>
- <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
- <Button
- accessibilityLabel="Browse files"
- selected={resources.active() === 'files'}
- ref={(node) => {
- control = node;
- if (resources.focusRequest()) {
- resources.focusHandled();
- if (props.docked) node.focus();
- }
- }}
- onPress={inputAction(resources.inventory, {
- sessionId: resources.sessionId()!,
- kind: 'files',
- })}
- style={{ ...controlStyle, paddingHorizontal: 8 }}
- selectedStyle={{ backgroundColor: C.raised }}
- hoverStyle={controlHoverStyle}
- focusStyle={controlFocusStyle}
- >
- <Text
- style={{
- ...captionTextStyle,
- color: resources.active() === 'files' ? C.text : C.muted,
- }}
- >
- Files
- </Text>
- </Button>
- <Button
- accessibilityLabel="Browse current changes"
- selected={resources.active() === 'changes'}
- onPress={inputAction(resources.inventory, {
- sessionId: resources.sessionId()!,
- kind: 'changes',
- })}
- style={{ ...controlStyle, paddingHorizontal: 8 }}
- selectedStyle={{ backgroundColor: C.raised }}
- hoverStyle={controlHoverStyle}
- focusStyle={controlFocusStyle}
- >
- <Text
- style={{
- ...captionTextStyle,
- color: resources.active() === 'changes' ? C.text : C.muted,
- }}
- >
- Changes
- </Text>
- </Button>
- <Button
- accessibilityLabel="Show conversation terminals"
- selected={resources.active() === 'terminals'}
- onPress={inputAction(resources.inventory, {
- sessionId: resources.sessionId()!,
- kind: 'terminals',
- })}
- style={{
- ...controlStyle,
- paddingHorizontal: 8,
- }}
- selectedStyle={{ backgroundColor: C.raised }}
- hoverStyle={controlHoverStyle}
- focusStyle={controlFocusStyle}
- >
- <Text
- style={{
- ...captionTextStyle,
- color: resources.active() === 'terminals' ? C.text : C.muted,
- }}
- >
- Terminal
- </Text>
- </Button>
- <View style={{ flex: 1 }} />
- <Button
- accessibilityLabel="Close workspace preview"
- onPress={inputAction(resources.close, closeTarget)}
- tooltip="Close workspace preview · Esc"
- style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
- hoverStyle={controlHoverStyle}
- focusStyle={controlFocusStyle}
- >
- <Icon name="close" color={C.muted} />
- </Button>
- </View>
- <If when={resources.tabs().length}>
- <Scroll horizontal style={{ flexGrow: 0, flexShrink: 0 }}>
- <View style={{ flexDirection: 'row', gap: 4 }}>
+ <View style={{ flexDirection: 'row', alignItems: 'center', flexShrink: 0, gap: 4 }}>
+ <Scroll horizontal style={{ flex: 1, minWidth: 0 }}>
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
+ <Button
+ accessibilityLabel="Browse files"
+ selected={resources.active() === 'files'}
+ ref={(node) => {
+ control = node;
+ if (resources.focusRequest()) {
+ resources.focusHandled();
+ if (props.docked) node.focus();
+ }
+ }}
+ onPress={inputAction(resources.inventory, {
+ sessionId: resources.sessionId()!,
+ kind: 'files',
+ })}
+ style={{ ...controlStyle, paddingHorizontal: 8 }}
+ selectedStyle={{ backgroundColor: C.raised }}
+ hoverStyle={controlHoverStyle}
+ focusStyle={controlFocusStyle}
+ >
+ <Text
+ style={{
+ ...captionTextStyle,
+ color: resources.active() === 'files' ? C.text : C.muted,
+ }}
+ >
+ Files
+ </Text>
+ </Button>
+ <Button
+ accessibilityLabel="Browse current changes"
+ selected={resources.active() === 'changes'}
+ onPress={inputAction(resources.inventory, {
+ sessionId: resources.sessionId()!,
+ kind: 'changes',
+ })}
+ style={{ ...controlStyle, paddingHorizontal: 8 }}
+ selectedStyle={{ backgroundColor: C.raised }}
+ hoverStyle={controlHoverStyle}
+ focusStyle={controlFocusStyle}
+ >
+ <Text
+ style={{
+ ...captionTextStyle,
+ color: resources.active() === 'changes' ? C.text : C.muted,
+ }}
+ >
+ Changes
+ </Text>
+ </Button>
+ <Button
+ accessibilityLabel="Show conversation terminals"
+ selected={resources.active() === 'terminals'}
+ onPress={inputAction(resources.inventory, {
+ sessionId: resources.sessionId()!,
+ kind: 'terminals',
+ })}
+ style={{
+ ...controlStyle,
+ paddingHorizontal: 8,
+ }}
+ selectedStyle={{ backgroundColor: C.raised }}
+ hoverStyle={controlHoverStyle}
+ focusStyle={controlFocusStyle}
+ >
+ <Text
+ style={{
+ ...captionTextStyle,
+ color: resources.active() === 'terminals' ? C.text : C.muted,
+ }}
+ >
+ Terminal
+ </Text>
+ </Button>
<Each of={resources.tabs()} key={(tab) => tab.instance}>
{(tab) => (
<View
@@ -198,7 +185,7 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
style={{
...captionTextStyle,
color: resources.active() === tab().instance ? C.text : C.muted,
- maxWidth: 200,
+ maxWidth: 160,
}}
>
{tab().title}
@@ -213,8 +200,10 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
tooltip={`Close ${tab().title}`}
style={{
...controlStyle,
- width: 32,
- paddingHorizontal: 8,
+ width: 28,
+ minHeight: 28,
+ paddingHorizontal: 5,
+ paddingVertical: 5,
alignItems: 'center',
}}
hoverStyle={controlHoverStyle}
@@ -227,7 +216,17 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
</Each>
</View>
</Scroll>
- </If>
+ <Button
+ accessibilityLabel="Close workspace preview"
+ onPress={inputAction(resources.close, closeTarget)}
+ tooltip="Close workspace preview · Esc"
+ style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
+ hoverStyle={controlHoverStyle}
+ focusStyle={controlFocusStyle}
+ >
+ <Icon name="close" color={C.muted} />
+ </Button>
+ </View>
<If when={resources.active() === 'terminals'}>
<Terminals model={resources.terminals} sessionId={resources.sessionId()!} />
</If>
diff --git a/products/maka/terminal-shell/app/ToolCard.tsx b/products/maka/terminal-shell/app/ToolCard.tsx
index 574a0f94c6..8f02b929a2 100644
--- a/products/maka/terminal-shell/app/ToolCard.tsx
+++ b/products/maka/terminal-shell/app/ToolCard.tsx
@@ -248,7 +248,7 @@ export function ToolCard(props: {
<Button
selected={props.expanded()}
accessibilityLabel={`Toggle tool ${tool()?.call?.name ?? 'details'}`}
- tooltip={tool()?.call?.name ?? 'Tool details'}
+ tooltip={title()}
onPress={inputAction(props.expand, {
sessionId: props.sessionId,
key: props.ownerKey(),
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 13/22: docs(terminal): remove stale legacy presenter claims
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit f3f67b28442e18a7c2cf5dfb7bfaac7ce9c90a1d
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 17:02:20 2026 +0800
docs(terminal): remove stale legacy presenter claims
---
README.md | 2 +-
README.zh-CN.md | 2 +-
products/maka/cli/README.md | 2 +-
products/maka/cli/README.zh-CN.md | 2 +-
products/maka/cli/src/args.rs | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 5d3d1d5e8e..180fa212c1 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
Apache Maka (Incubating) is a high-performance agent workspace that keeps a complete record of everything it did.
-Maka provides a plugin-composed terminal product and a native desktop shell. A Runtime Host owns execution, permissions and durable state. Model providers, tools and business capabilities use public plugin interfaces.
+Maka provides a terminal workbench and a native desktop shell. A Runtime Host owns execution, permissions and durable state. Model providers, tools and business capabilities use public plugin interfaces.
## Run from source
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 7cc1b53d67..ef16967e8c 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -23,7 +23,7 @@
Apache Maka (Incubating) 是一个高性能 Agent 工作台,完整记录它做过的每一件事。
-Maka 提供插件组合定义的终端产品和原生桌面 shell。Runtime Host 统一管理执行、权限和持久状态,模型提供商、工具与业务能力通过公共插件接口接入。
+Maka 提供终端工作台和原生桌面 shell。Runtime Host 统一管理执行、权限和持久状态,模型提供商、工具与业务能力通过公共插件接口接入。
## 从源码运行
diff --git a/products/maka/cli/README.md b/products/maka/cli/README.md
index 0e6faad679..d08fb98439 100644
--- a/products/maka/cli/README.md
+++ b/products/maka/cli/README.md
@@ -35,7 +35,7 @@ Run `maka` or `maka terminal-shell` for the [NSH terminal workbench](../terminal
Headless commands (`host`, `application serve`, `code` and `acp`) install process logging before runtime initialization. Warnings and errors, including SQLx slow-query warnings, go to stderr without ANSI styling; stdout remains available for command results and protocol frames. Ordinary DEBUG query logs are disabled. Interactive products keep control of their terminal output.
-Dedicated Host processes capture stderr in their Root's private control-cache file `host.stderr.log`: on-demand capture starts before spawning, while foreground serving starts after Root selection. Read it with `maka host logs --root-id ID`, or add `--follow` for live NDJSON and `--after CURSOR` to resume. Pages preserve raw bytes as Base64 and report missing capture, replacement or truncation; clearing the control cache removes these logs. This works without an installed service. The de [...]
+Dedicated Host processes capture stderr in their Root's private control-cache file `host.stderr.log`: on-demand capture starts before spawning, while foreground serving starts after Root selection. Read it with `maka host logs --root-id ID`, or add `--follow` for live NDJSON and `--after CURSOR` to resume. Pages preserve raw bytes as Base64 and report missing capture, replacement or truncation; clearing the control cache removes these logs. This works without an installed service.
## Product updates, recovery and URL installation
diff --git a/products/maka/cli/README.zh-CN.md b/products/maka/cli/README.zh-CN.md
index f648ed9738..a29ba8e338 100644
--- a/products/maka/cli/README.zh-CN.md
+++ b/products/maka/cli/README.zh-CN.md
@@ -35,7 +35,7 @@
无界面命令(`host`、`application serve`、`code` 和 `acp`)在运行时初始化前安装进程日志订阅器。警告和错误(包括 SQLx 慢查询警告)写入 stderr,不带 ANSI 样式;stdout 保留给命令结果和协议帧。默认不输出普通 DEBUG 查询日志。交互式产品仍自行管理终端输出。
-独立 Host 将 stderr 捕获到所属 Root 的私有控制缓存文件 `host.stderr.log`:按需启动在创建进程前接好捕获文件,前台服务在选定 Root 后开始捕获。使用 `maka host logs --root-id ID` 读取,添加 `--follow` 持续接收 NDJSON,添加 `--after CURSOR` 从游标续读。每页以 Base64 保留原始字节,并报告尚无捕获文件、文件替换或截断;清理控制缓存也会删除这些日志。读取不要求安装系统服务。默认产品的“诊断 → Host 日志”无需会话即可读取和跟随日志。
+独立 Host 将 stderr 捕获到所属 Root 的私有控制缓存文件 `host.stderr.log`:按需启动在创建进程前接好捕获文件,前台服务在选定 Root 后开始捕获。使用 `maka host logs --root-id ID` 读取,添加 `--follow` 持续接收 NDJSON,添加 `--after CURSOR` 从游标续读。每页以 Base64 保留原始字节,并报告尚无捕获文件、文件替换或截断;清理控制缓存也会删除这些日志。读取不要求安装系统服务。
## 产品更新、恢复与 URL 安装
diff --git a/products/maka/cli/src/args.rs b/products/maka/cli/src/args.rs
index 30bc70169d..57c266e618 100644
--- a/products/maka/cli/src/args.rs
+++ b/products/maka/cli/src/args.rs
@@ -44,7 +44,7 @@ enum Command {
Desktop(crate::desktop::Args),
/// Open the NSH terminal shell (connects to or starts the selected Host).
TerminalShell(maka_terminal_shell::entry::Args),
- /// Author, inspect and run plugin applications.
+ /// Author, inspect and serve plugin applications.
#[command(subcommand)]
Application(crate::application::Command),
/// Manage the native runtime host.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 15/22: Account for the shared policy revision after changing interface language
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit c848b5bd85cc413e038bfa95a2f20a7b21163ae2
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 17:02:35 2026 +0800
Account for the shared policy revision after changing interface language
---
products/maka/terminal-shell/tests/real_host.rs | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/products/maka/terminal-shell/tests/real_host.rs b/products/maka/terminal-shell/tests/real_host.rs
index 81988537db..75ad9c455a 100644
--- a/products/maka/terminal-shell/tests/real_host.rs
+++ b/products/maka/terminal-shell/tests/real_host.rs
@@ -660,7 +660,12 @@ async fn empty_settings_configure_a_real_connection_and_manual_reasoning_model_b
let restarted = fixture.client().await;
assert_eq!(restarted.query("settings", "assistantPreferences", json!({})).await.unwrap(), startup_preferences);
let persisted_network = restarted.query("settings", "network", json!({})).await.unwrap();
- assert_eq!(persisted_network, final_network);
+ // Language and network settings share the runtime policy revision.
+ // The final UI language change advances that revision while every
+ // proxy, credential and Root field must remain exactly as saved.
+ let mut expected_network = final_network.clone();
+ expected_network["revision"] = startup_preferences["revision"].clone();
+ assert_eq!(persisted_network, expected_network);
assert!(!persisted_network.to_string().contains(proxy_secret));
restarted.close().await.unwrap();
assert_eq!(fixture.request_count.load(Ordering::SeqCst), 1, "restoring the configured session must not resend");
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 09/22: Localize the terminal interface with live Host language preferences
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 09a0c14833623106f76e78bb0ae0a091c3ce1e72
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 16:45:13 2026 +0800
Localize the terminal interface with live Host language preferences
---
products/maka/terminal-shell/app/App.tsx | 111 +-
products/maka/terminal-shell/app/Attachments.tsx | 45 +-
.../maka/terminal-shell/app/CapabilityRequest.tsx | 49 +-
products/maka/terminal-shell/app/Checklist.tsx | 20 +-
products/maka/terminal-shell/app/Composer.tsx | 43 +-
.../maka/terminal-shell/app/ConnectionForm.tsx | 73 +-
.../maka/terminal-shell/app/ConnectionLogin.tsx | 33 +-
.../maka/terminal-shell/app/ConnectionModel.tsx | 32 +-
.../maka/terminal-shell/app/ConnectionSettings.tsx | 52 +-
products/maka/terminal-shell/app/Conversation.tsx | 157 +--
products/maka/terminal-shell/app/CurrentDiff.tsx | 42 +-
products/maka/terminal-shell/app/CurrentFile.tsx | 107 +-
products/maka/terminal-shell/app/FinalActions.tsx | 18 +-
products/maka/terminal-shell/app/FormRequest.tsx | 37 +-
.../maka/terminal-shell/app/GeneralSettings.tsx | 75 +-
.../maka/terminal-shell/app/ImageThumbnail.tsx | 27 +-
products/maka/terminal-shell/app/ImageViewer.tsx | 38 +-
.../terminal-shell/app/InteractionComposer.tsx | 186 +--
products/maka/terminal-shell/app/MarkdownBlock.tsx | 13 +-
products/maka/terminal-shell/app/MarkdownBody.tsx | 8 +-
products/maka/terminal-shell/app/MessageCopy.tsx | 22 +-
products/maka/terminal-shell/app/MessageRow.tsx | 17 +-
products/maka/terminal-shell/app/ModelSelect.tsx | 79 +-
products/maka/terminal-shell/app/ModelSettings.tsx | 45 +-
.../maka/terminal-shell/app/NetworkSettings.tsx | 69 +-
products/maka/terminal-shell/app/Palette.tsx | 13 +-
.../maka/terminal-shell/app/PluginSettings.tsx | 83 +-
products/maka/terminal-shell/app/QueueDock.tsx | 86 +-
products/maka/terminal-shell/app/ResourcePane.tsx | 29 +-
.../maka/terminal-shell/app/SessionActions.tsx | 93 +-
products/maka/terminal-shell/app/Terminal.tsx | 75 +-
products/maka/terminal-shell/app/Terminals.tsx | 33 +-
products/maka/terminal-shell/app/ToolCard.tsx | 89 +-
products/maka/terminal-shell/app/ToolReader.tsx | 136 ++-
products/maka/terminal-shell/app/ToolSource.tsx | 12 +-
.../maka/terminal-shell/app/TurnNavigation.tsx | 19 +-
products/maka/terminal-shell/app/TurnPanel.tsx | 34 +-
products/maka/terminal-shell/app/TurnUsage.tsx | 80 +-
products/maka/terminal-shell/app/Workbench.tsx | 5 +-
.../maka/terminal-shell/app/WorkspaceBrowser.tsx | 86 +-
.../maka/terminal-shell/app/WorkspaceChanges.tsx | 58 +-
.../maka/terminal-shell/app/WorkspaceFiles.tsx | 25 +-
.../maka/terminal-shell/app/WorkspacePicker.tsx | 60 +-
.../maka/terminal-shell/app/attachmentDraft.ts | 34 +-
products/maka/terminal-shell/app/browsing.ts | 28 +-
.../maka/terminal-shell/app/connectionLogin.ts | 21 +-
.../maka/terminal-shell/app/generalSettings.ts | 23 +-
products/maka/terminal-shell/app/host.ts | 19 +-
.../maka/terminal-shell/app/interaction-form.ts | 40 +-
products/maka/terminal-shell/app/locale-common.ts | 55 +
.../maka/terminal-shell/app/locale-conversation.ts | 1291 ++++++++++++++++++++
.../maka/terminal-shell/app/locale-settings.ts | 869 +++++++++++++
.../maka/terminal-shell/app/locale-workspace.ts | 546 +++++++++
products/maka/terminal-shell/app/locale.ts | 66 +
products/maka/terminal-shell/app/modelSelection.ts | 17 +-
.../maka/terminal-shell/app/networkSettings.ts | 17 +-
products/maka/terminal-shell/app/pluginSettings.ts | 27 +-
products/maka/terminal-shell/app/resources.ts | 23 +-
.../maka/terminal-shell/app/sessionLifecycle.ts | 23 +-
products/maka/terminal-shell/app/sessionStatus.ts | 25 +-
products/maka/terminal-shell/app/settings.ts | 67 +-
.../maka/terminal-shell/app/settingsOperations.ts | 29 +-
products/maka/terminal-shell/app/terminalState.ts | 67 +-
products/maka/terminal-shell/app/text.ts | 69 ++
products/maka/terminal-shell/app/toolActivity.ts | 10 +-
products/maka/terminal-shell/app/toolCards.ts | 42 +-
products/maka/terminal-shell/app/ui.tsx | 17 +-
.../maka/terminal-shell/app/workspaceFileState.ts | 35 +-
products/maka/terminal-shell/build.rs | 3 +
products/maka/terminal-shell/src/bindings.rs | 6 +
.../maka/terminal-shell/src/bindings/uploads.rs | 19 +-
products/maka/terminal-shell/tests/real_host.rs | 47 +-
72 files changed, 4722 insertions(+), 1227 deletions(-)
diff --git a/products/maka/terminal-shell/app/App.tsx b/products/maka/terminal-shell/app/App.tsx
index 93c5c523ca..9430cb7223 100644
--- a/products/maka/terminal-shell/app/App.tsx
+++ b/products/maka/terminal-shell/app/App.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { createText, UiError, errorText, message as uiMessage } from './text';
+import { applyLanguagePreference, tr } from './locale';
import { cleanup, effect, inputAction, mount, signal, Scroll, Text, View } from 'nsh';
import type { YNode } from 'nsh';
import { Button, Dialog } from 'nsh/base';
@@ -55,8 +57,8 @@ function App() {
const resources = createResources({ selected, blocked: () => overlay() !== null });
const sidebarExpanded = signal(true);
const browserFocus = signal(false);
- const error = signal('');
- const status = signal('');
+ const error = createText();
+ const status = createText();
const switching = signal(false);
const busy = signal(false);
const controls = signal<any[]>([]);
@@ -74,7 +76,7 @@ function App() {
alive = false;
});
const report = (reason: unknown) => {
- if (alive) error.set(reason instanceof Error ? reason.message : String(reason));
+ if (alive) error.set(errorText(reason));
};
const presentInteraction = (
original: { sessionId: string; requestId: string },
@@ -92,9 +94,13 @@ function App() {
status.set(interactionOutcomeSummary(record.outcome));
return true;
}
- report('The Host returned a different or incomplete original request outcome.');
+ report(new UiError('The Host returned a different or incomplete original request outcome.'));
} else {
- report(`Answer was not accepted: ${result.reason}. Refresh the request before trying again.`);
+ report(
+ new UiError('Answer was not accepted: {reason}. Refresh the request before trying again.', {
+ reason: result.reason,
+ }),
+ );
}
return false;
};
@@ -125,13 +131,13 @@ function App() {
const returned = (reply: any) => {
const receipt = reply?.receipt;
if (receipt?.kind !== 'recorded' || receipt.outcome?.kind !== 'returned')
- throw new Error('Confirmation is still unknown. Recover the original action below.');
+ throw new UiError('Confirmation is still unknown. Recover the original action below.');
const value = receipt.outcome.value;
return value;
};
const control: Control = async (method, input) => {
if (method !== 'stop' && !controlsKnown())
- throw new Error('Checking earlier actions. Try again in a moment.');
+ throw new UiError('Checking earlier actions. Try again in a moment.');
const originals: any[] = method === 'stop' ? await host.pendingControls() : controls();
const pending = originals.find(
(original) =>
@@ -143,11 +149,11 @@ function App() {
(method !== 'respond' || original.correlation.requestId === input.requestId),
);
if (pending)
- throw new Error(
+ throw new UiError(
'This action has an unresolved original. Recover it below before trying again.',
);
if (method !== 'stop' && busy())
- throw new Error('An action is being confirmed. Try again in a moment.');
+ throw new UiError('An action is being confirmed. Try again in a moment.');
if (method !== 'stop') busy.set(true);
try {
const result = returned((await host.control(method, input)).reply);
@@ -226,13 +232,15 @@ function App() {
handle() !== owner ||
overlay()?.instance !== instance
)
- throw new Error('This model selection is no longer open.');
+ throw new UiError('This model selection is no longer open.');
const result = await control('selectBackend', {
sessionId: owner.sessionId,
backend: { kind: 'model', ...backend },
});
if (result.kind !== 'committed')
- throw new Error(`Model selection rejected: ${result.reason ?? result.kind}`);
+ throw new UiError('Model selection rejected: {reason}', {
+ reason: result.reason ?? result.kind,
+ });
await owner.refresh();
};
const create = async (workspace: BrowserWorkspace, expectedSessionId = selected()) => {
@@ -240,14 +248,16 @@ function App() {
try {
error.set('');
const result = await control('create', {
- title: 'New conversation',
+ title: tr('New conversation'),
automaticTitle: true,
labels: [],
workspace,
});
if (!alive) return;
if (result.kind !== 'created')
- throw new Error(`Conversation creation rejected: ${result.reason ?? result.kind}`);
+ throw new UiError('Conversation creation rejected: {reason}', {
+ reason: result.reason ?? result.kind,
+ });
await switchSession(result.session.id, expectedSessionId);
if (selected() === result.session.id) open({ kind: 'models', sessionId: result.session.id });
await browsing.refresh();
@@ -267,7 +277,7 @@ function App() {
const owner = handle();
if (!alive || selected() !== sessionId || owner?.sessionId !== sessionId) return;
if (!(await owner.flushDraft()))
- throw new Error(
+ throw new UiError(
'The conversation was deleted, but its latest draft is not saved. Return to the draft to check its status.',
);
if (alive && selected() === sessionId && handle() === owner) {
@@ -277,18 +287,18 @@ function App() {
};
const changeBrowsedSession = async (action: SessionAction, origin: string | null) => {
if (!alive || selected() !== origin || switching() || busy())
- throw new Error('This conversation action is no longer current.');
+ throw new UiError('This conversation action is no longer current.');
const owner = handle();
if (
action.kind === 'remove' &&
owner?.sessionId === action.session.id &&
!(await owner.flushDraft())
)
- throw new Error(
+ throw new UiError(
'The draft is not ready to save. Return to the conversation before deleting it.',
);
if (!alive || selected() !== origin || handle() !== owner)
- throw new Error('This conversation action is no longer current.');
+ throw new UiError('This conversation action is no longer current.');
const result = await performSessionAction(control, action);
if (!alive) return;
if (result.kind === 'removed') await removeSelected(result.sessionId);
@@ -359,51 +369,51 @@ function App() {
const commands = (context: CommandContext): PaletteEntry[] => [
{
id: 'sessions',
- title: 'Sessions',
- detail: 'Resume a conversation',
+ title: tr('Sessions'),
+ detail: tr('Resume a conversation'),
hint: 'Ctrl+O',
run: inputAction(commandSessions, context),
},
{
id: 'new',
- title: 'New conversation',
- detail: 'A fresh start in your workspace',
+ title: tr('New conversation'),
+ detail: tr('A fresh start in your workspace'),
hint: 'Ctrl+N',
run: inputAction(commandNew, context),
},
{
id: 'workspace',
- title: 'Choose workspace',
- detail: 'Start a conversation in another folder',
+ title: tr('Choose workspace'),
+ detail: tr('Start a conversation in another folder'),
run: inputAction(commandWorkspace, context),
},
{
id: 'models',
- title: 'Models',
- detail: 'Choose the model for this conversation',
+ title: tr('Models'),
+ detail: tr('Choose the model for this conversation'),
hint: 'Ctrl+G',
run: inputAction(commandModels, context),
},
{
id: 'settings',
- title: 'Connections & models',
- detail: 'Add connections, API keys and models',
+ title: tr('Connections & models'),
+ detail: tr('Add connections, API keys and models'),
run: inputAction(commandSettings, context),
},
...(context.sessionId
? [
{
id: 'files',
- title: 'Files',
- detail: 'Browse workspace files and current changes',
+ title: tr('Files'),
+ detail: tr('Browse workspace files and current changes'),
run: inputAction(commandFiles, context),
},
]
: []),
{
id: 'latest',
- title: 'Jump to latest',
- detail: 'Return to the newest messages',
+ title: tr('Jump to latest'),
+ detail: tr('Return to the newest messages'),
run: inputAction(commandLatest, {
instance: context.instance,
sessionId: context.sessionId,
@@ -412,15 +422,15 @@ function App() {
},
{
id: 'history-focus',
- title: 'Focus conversation',
- detail: 'Scroll the whole conversation',
+ title: tr('Focus conversation'),
+ detail: tr('Scroll the whole conversation'),
hint: 'Alt+↑',
run: inputAction(commandHistory, context),
},
{
id: 'activity-focus',
- title: 'Focus activity',
- detail: 'Read this turn’s independent activity panel',
+ title: tr('Focus activity'),
+ detail: tr('Read this turn’s independent activity panel'),
hint: 'Alt+↓',
run: inputAction(commandActivity, {
instance: context.instance,
@@ -430,8 +440,8 @@ function App() {
},
{
id: 'stop',
- title: 'Stop',
- detail: 'Cancel the current run',
+ title: tr('Stop'),
+ detail: tr('Cancel the current run'),
hint: 'Esc',
run: inputAction(commandStop, {
instance: context.instance,
@@ -508,7 +518,7 @@ function App() {
const owner = handle();
if (owner && selected() === owner.sessionId) await owner.refresh();
await browsing.refresh();
- if (!responseConfirmed) status.set('Original actions checked.');
+ if (!responseConfirmed) status.set(uiMessage('Original actions checked.'));
} catch (reason) {
report(reason);
} finally {
@@ -583,6 +593,12 @@ function App() {
}
wasDocked = docked;
});
+ void host
+ .query('settings', 'assistantPreferences', {})
+ .then((preferences) => {
+ if (alive) applyLanguagePreference(preferences.language ?? 'auto', preferences.revision);
+ })
+ .catch(report);
void refreshControls().catch(report);
return (
<View style={{ flex: 1, backgroundColor: C.background }}>
@@ -662,7 +678,7 @@ function App() {
}
>
<Scroll
- accessibilityLabel="Action feedback"
+ accessibilityLabel={tr('Action feedback')}
style={{
minHeight: bodyTextStyle.lineHeight + 16,
flexShrink: 1,
@@ -677,7 +693,9 @@ function App() {
</If>
<If when={switching() || busy()}>
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- {switching() ? 'Saving draft before switching…' : 'Confirming action…'}
+ {switching()
+ ? tr('Saving draft before switching…')
+ : tr('Confirming action…')}
</Text>
</If>
<If when={status()}>
@@ -695,10 +713,10 @@ function App() {
>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
{recovering()
- ? 'Reading original action receipts…'
+ ? tr('Reading original action receipts…')
: controlsKnown()
- ? `Recover original actions (${controls().length})`
- : 'Recheck original actions'}
+ ? tr('Recover original actions ({count})', { count: controls().length })
+ : tr('Recheck original actions')}
</Text>
</Button>
<Each
@@ -713,7 +731,8 @@ function App() {
focusStyle={controlFocusStyle}
>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
- Abandon unconfirmed {original().method} · {original().id.slice(0, 8)}
+ {tr('Abandon unconfirmed {method}', { method: original().method })} ·{' '}
+ {original().id.slice(0, 8)}
</Text>
</Button>
)}
@@ -764,7 +783,9 @@ function App() {
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
>
- <Text style={{ color: C.accent }}>Open recovered fork · {recoveredFork()!.title}</Text>
+ <Text style={{ color: C.accent }}>
+ {tr('Open recovered fork')} · {recoveredFork()!.title}
+ </Text>
</Button>
</If>
<Each
diff --git a/products/maka/terminal-shell/app/Attachments.tsx b/products/maka/terminal-shell/app/Attachments.tsx
index e6dc91bb6b..e17d5cbfea 100644
--- a/products/maka/terminal-shell/app/Attachments.tsx
+++ b/products/maka/terminal-shell/app/Attachments.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { renderText } from './text';
+import { tr } from './locale';
import { inputAction, Scroll, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import { AttachmentPicker } from 'maka/host';
@@ -72,14 +74,14 @@ export function DraftAttachments(props: {
<If when={owner.picker()}>
<View style={{ flexDirection: 'row', justifyContent: 'flex-end' }}>
<Button
- accessibilityLabel="Close attachment picker"
+ accessibilityLabel={tr('Close attachment picker')}
onPress={inputAction(owner.open, { owner: owner.sessionId, value: false })}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={{ backgroundColor: C.line }}
focusStyle={{ backgroundColor: C.line }}
>
<Icon name="close" color={C.secondary} />
- <Text style={{ color: C.secondary }}>Close picker</Text>
+ <Text style={{ color: C.secondary }}>{tr('Close picker')}</Text>
</Button>
</View>
</If>
@@ -87,12 +89,17 @@ export function DraftAttachments(props: {
the original selected path and in-flight resource retain their owner. */}
<AttachmentPicker
client={owner.client}
+ pathLabel={tr('Attachment path')}
+ pathPlaceholder={tr('Absolute file path…')}
+ uploadLabel={tr('Upload selected file')}
+ uploadingLabel={tr('Uploading…')}
+ selectedFileLabel={tr('Selected file')}
visible={owner.picker()}
disabled={owner.restoring()}
onStatus={owner.status}
/>
<If when={owner.files().length > 0}>
- <Scroll horizontal accessibilityLabel="Draft attachments" style={{ flexShrink: 0 }}>
+ <Scroll horizontal accessibilityLabel={tr('Draft attachments')} style={{ flexShrink: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 8 }}>
<Each of={owner.files()} key={(file) => file.id}>
{(file) => {
@@ -128,14 +135,14 @@ export function DraftAttachments(props: {
offset: 0,
},
}}
- label={`Open attachment ${original.name}`}
+ label={tr('Open attachment {name}', { name: original.name })}
tooltip={`${original.name}\n${size(original.bytes)}`}
preview={props.preview}
/>
</If>
<Button
- accessibilityLabel={`Remove attachment ${original.name}`}
- tooltip={`Remove ${original.name}`}
+ accessibilityLabel={tr('Remove attachment {name}', { name: original.name })}
+ tooltip={tr('Remove {name}', { name: original.name })}
onPress={inputAction(owner.remove, {
owner: owner.sessionId,
id: original.id,
@@ -173,16 +180,16 @@ export function DraftAttachments(props: {
}}
>
{row().kind === 'reading'
- ? 'Reading selected file…'
+ ? tr('Reading selected file…')
: row().kind === 'uploading'
- ? 'Confirming upload…'
+ ? tr('Confirming upload…')
: row().kind === 'unknown'
- ? 'Original upload needs confirmation'
- : 'Upload failed'}
+ ? tr('Original upload needs confirmation')
+ : tr('Upload failed')}
</Text>
<If when={row().error}>
<Text selectable style={{ color: C.danger }}>
- {row().error}
+ {renderText(row().error!)}
</Text>
</If>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
@@ -190,7 +197,7 @@ export function DraftAttachments(props: {
<Button
disabled={owner.recovering() !== null}
accessibilityLabel={
- row().original ? 'Recover original upload' : 'Check original upload'
+ row().original ? tr('Recover original upload') : tr('Check original upload')
}
onPress={inputAction(row().original ? owner.recover : owner.inspect, {
owner: owner.sessionId,
@@ -201,7 +208,7 @@ export function DraftAttachments(props: {
focusStyle={{ backgroundColor: C.line }}
>
<Text style={{ color: C.accent }}>
- {row().original ? 'Recover original upload' : 'Check original upload'}
+ {row().original ? tr('Recover original upload') : tr('Check original upload')}
</Text>
</Button>
<If when={row().original && !row().original.reference}>
@@ -215,13 +222,13 @@ export function DraftAttachments(props: {
hoverStyle={{ backgroundColor: C.line }}
focusStyle={{ backgroundColor: C.line }}
>
- <Text style={{ color: C.warning }}>Abandon unconfirmed upload</Text>
+ <Text style={{ color: C.warning }}>{tr('Abandon unconfirmed upload')}</Text>
</Button>
</If>
</If>
<If when={row().kind === 'failed'}>
<Button
- accessibilityLabel={`Remove failed attachment ${row().name}`}
+ accessibilityLabel={tr('Remove failed attachment {name}', { name: row().name })}
onPress={inputAction(owner.remove, {
owner: owner.sessionId,
id: row().id,
@@ -230,7 +237,7 @@ export function DraftAttachments(props: {
hoverStyle={{ backgroundColor: C.line }}
focusStyle={{ backgroundColor: C.line }}
>
- <Text style={{ color: C.muted }}>Remove</Text>
+ <Text style={{ color: C.muted }}>{tr('Remove')}</Text>
</Button>
</If>
</View>
@@ -247,7 +254,7 @@ export function DraftAttachments(props: {
hoverStyle={{ backgroundColor: C.line }}
focusStyle={{ backgroundColor: C.line }}
>
- <Text style={{ color: C.accent }}>Retry attachment updates</Text>
+ <Text style={{ color: C.accent }}>{tr('Retry attachment updates')}</Text>
</Button>
</If>
</View>
@@ -262,7 +269,7 @@ export function HistoryAttachments(props: {
preview(image: ImagePreview): void;
}) {
return (
- <Scroll horizontal accessibilityLabel="Message attachments" style={{ flexShrink: 0 }}>
+ <Scroll horizontal accessibilityLabel={tr('Message attachments')} style={{ flexShrink: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 8 }}>
<Each
of={props.attachments.map((attachment, index) => ({ ...attachment, index }))}
@@ -305,7 +312,7 @@ export function HistoryAttachments(props: {
},
},
}}
- label={`Open attachment ${original.name}`}
+ label={tr('Open attachment {name}', { name: original.name })}
tooltip={`${original.name}\n${size(original.bytes)} · ${original.kind}`}
preview={props.preview}
/>
diff --git a/products/maka/terminal-shell/app/CapabilityRequest.tsx b/products/maka/terminal-shell/app/CapabilityRequest.tsx
index 40365cfbe4..0f82491863 100644
--- a/products/maka/terminal-shell/app/CapabilityRequest.tsx
+++ b/products/maka/terminal-shell/app/CapabilityRequest.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { tr } from './locale';
import { Text, View } from 'nsh';
import type { InteractionRequest } from '../../../../packages/plugin-sdk/src/generated/runtime';
import { Icon } from './Icon';
@@ -55,48 +56,56 @@ export function CapabilityRequest(props: { request: Request; sessionId: string }
<View style={{ flex: 1, minWidth: 0, gap: 8 }}>
<Text style={{ ...bodyTextStyle, color: C.text, fontWeight: '600' }}>
{computerCapability(props.request)
- ? 'Computer access for this session'
- : 'Requested client capability'}
+ ? tr('Computer access for this session')
+ : tr('Requested client capability')}
</Text>
<Text selectable style={{ ...codeTextStyle, color: C.secondary }}>
- {`${props.request.target.toolName} · Permission boundary: ${props.request.baseRevision ?? 'unavailable'}`}
+ {tr('{tool} · Permission boundary: {revision}', {
+ tool: props.request.target.toolName,
+ revision: props.request.baseRevision ?? tr('unavailable'),
+ })}
</Text>
<If when={computerCapability(props.request)}>
<Text selectable style={{ ...bodyTextStyle, color: C.secondary }}>
- This allows the requesting tool to inspect and control the local computer, including
- screenshots, keyboard and pointer actions. The grant lasts for this session's current
- permission boundary.
+ {tr(
+ "This allows the requesting tool to inspect and control the local computer, including screenshots, keyboard and pointer actions. The grant lasts for this session's current permission boundary.",
+ )}
</Text>
</If>
</View>
</View>
<View style={{ gap: 8 }}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Scope</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>{tr('Scope')}</Text>
<Text selectable style={{ ...bodyTextStyle, color: C.secondary }}>
{props.request.target.scope.kind === 'browser_origin'
- ? `Browser origin: ${props.request.target.scope.origin}`
+ ? tr('Browser origin: {origin}', { origin: props.request.target.scope.origin })
: props.request.target.scope.kind === 'mcp_tool'
- ? `Only ${props.request.target.scope.serverId} · ${props.request.target.scope.toolName}`
- : 'Applies to this capability within the current permission boundary.'}
+ ? tr('Only {server} · {tool}', {
+ server: props.request.target.scope.serverId,
+ tool: props.request.target.scope.toolName,
+ })
+ : tr('Applies to this capability within the current permission boundary.')}
</Text>
</View>
<If when={!computerCapability(props.request)}>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
- This Host cannot answer the requested capability. The original request remains pending.
+ {tr(
+ 'This Host cannot answer the requested capability. The original request remains pending.',
+ )}
</Text>
</If>
<View style={{ gap: 8, borderTopWidth: 1, borderTopColor: C.line, paddingTop: 16 }}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Request details</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>{tr('Request details')}</Text>
<Each
of={[
- ['Session', props.sessionId],
- ['Provider', props.request.target.providerId],
- ['Contract', props.request.target.contractId],
- ['Server', props.request.target.serverId],
- ['Tool', props.request.target.toolName],
- ['Capability', props.request.target.capability],
- ['Scope', JSON.stringify(props.request.target.scope)],
- ['Permission boundary', String(props.request.baseRevision ?? 'unavailable')],
+ [tr('Session'), props.sessionId],
+ [tr('Provider'), props.request.target.providerId],
+ [tr('Contract'), props.request.target.contractId],
+ [tr('Server'), props.request.target.serverId],
+ [tr('Tool'), props.request.target.toolName],
+ [tr('Capability'), props.request.target.capability],
+ [tr('Scope'), JSON.stringify(props.request.target.scope)],
+ [tr('Permission boundary'), String(props.request.baseRevision ?? tr('unavailable'))],
]}
>
{(detail) => (
diff --git a/products/maka/terminal-shell/app/Checklist.tsx b/products/maka/terminal-shell/app/Checklist.tsx
index 8413a03458..8eac6d8da7 100644
--- a/products/maka/terminal-shell/app/Checklist.tsx
+++ b/products/maka/terminal-shell/app/Checklist.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { createText, UiError, errorText } from './text';
+import { tr } from './locale';
import { batch, cleanup, inputAction, signal, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import { List } from 'nsh/list';
@@ -35,7 +37,7 @@ export function Checklist(props: { sessionId: string }) {
const nextOffset = signal<AgentChecklist['nextOffset']>(null);
const expanded = signal(false);
const loading = signal(false);
- const error = signal('');
+ const error = createText();
let alive = true;
let generation = 0;
let stream: Subscription | null = null;
@@ -61,7 +63,7 @@ export function Checklist(props: { sessionId: string }) {
nextOffset.set(next.nextOffset);
});
} catch (reason) {
- if (alive && currentGeneration === generation) error.set(String(reason));
+ if (alive && currentGeneration === generation) error.set(errorText(reason));
} finally {
if (alive && currentGeneration === generation) loading.set(false);
}
@@ -83,7 +85,7 @@ export function Checklist(props: { sessionId: string }) {
for (;;) {
const page: AgentChecklist | null = await opened.next();
if (!alive) return;
- if (page === null) throw new Error('Checklist updates ended. Reopen the conversation.');
+ if (page === null) throw new UiError('Checklist updates ended. Reopen the conversation.');
if (snapshot() && page.revision === snapshot()!.revision) continue;
generation += 1;
batch(() => {
@@ -101,16 +103,16 @@ export function Checklist(props: { sessionId: string }) {
}
}
})().catch((reason) => {
- if (alive) error.set(String(reason));
+ if (alive) error.set(errorText(reason));
});
return (
<If when={snapshot()?.enabled && snapshot()!.total > 0}>
<View
- accessibilityLabel="Conversation todo dock"
+ accessibilityLabel={tr('Conversation todo dock')}
style={{ backgroundColor: C.surface, borderRadius: 8 }}
>
<Button
- accessibilityLabel="Toggle conversation todo"
+ accessibilityLabel={tr('Toggle conversation todo')}
selected={expanded()}
onPress={inputAction(setExpanded, { sessionId, open: !expanded() })}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
@@ -120,13 +122,13 @@ export function Checklist(props: { sessionId: string }) {
>
<Icon name={expanded() ? 'down' : 'right'} color={C.secondary} />
<Text style={{ ...captionTextStyle, color: C.text }}>
- Todo {snapshot()!.completed}/{snapshot()!.total}
+ {tr('Todo')} {snapshot()!.completed}/{snapshot()!.total}
</Text>
<Text numberOfLines={1} style={{ ...captionTextStyle, flex: 1, color: C.muted }}>
{snapshot()!.current?.content ??
(snapshot()!.completed === snapshot()!.total
- ? 'All items complete'
- : 'No active item')}
+ ? tr('All items complete')
+ : tr('No active item'))}
</Text>
</Button>
<If when={expanded()}>
diff --git a/products/maka/terminal-shell/app/Composer.tsx b/products/maka/terminal-shell/app/Composer.tsx
index 431d41ed35..91e9f6bdab 100644
--- a/products/maka/terminal-shell/app/Composer.tsx
+++ b/products/maka/terminal-shell/app/Composer.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { tr } from './locale';
import { cleanup, effect, Input, inputAction, signal, Text, View } from 'nsh';
import type { JSX, YNode } from 'nsh';
import { Image } from 'nsh/image';
@@ -89,7 +90,7 @@ export function Composer(props: {
!!blocked() ||
(!props.session()?.draft().trim() && !props.session()?.attachments.files().length);
const sendLabel = () =>
- props.session()?.invocation() != null ? 'Queue message' : 'Send message';
+ props.session()?.invocation() != null ? tr('Queue message') : tr('Send message');
const primaryStops = () => props.session()?.invocation() != null && cannotSend();
let unbind = () => {};
cleanup(() => unbind());
@@ -106,7 +107,7 @@ export function Composer(props: {
.split(path.startsWith('/') ? '/' : /[\\/]/)
.filter(Boolean)
.at(-1) ?? path)
- : 'Choose workspace';
+ : tr('Choose workspace');
};
return (
<View
@@ -147,7 +148,7 @@ export function Composer(props: {
fontWeight: '500',
}}
>
- Make something remarkable.
+ {tr('Make something remarkable.')}
</Text>
</View>
<View
@@ -160,8 +161,8 @@ export function Composer(props: {
}}
>
<Button
- accessibilityLabel="Choose composer workspace"
- tooltip={props.session()?.workspace() || 'Choose workspace'}
+ accessibilityLabel={tr('Choose composer workspace')}
+ tooltip={props.session()?.workspace() || tr('Choose workspace')}
onPress={props.chooseWorkspace}
style={{
...controlStyle,
@@ -182,13 +183,13 @@ export function Composer(props: {
<Icon name="down" color={C.secondary} />
</Button>
<Button
- accessibilityLabel="Connections & models"
+ accessibilityLabel={tr('Connections & models')}
onPress={props.settings}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={{ backgroundColor: C.surface }}
focusStyle={{ backgroundColor: C.raised }}
>
- <Text style={{ color: C.muted, fontSize: 13 }}>Connections & models</Text>
+ <Text style={{ color: C.muted, fontSize: 13 }}>{tr('Connections & models')}</Text>
</Button>
</View>
</View>
@@ -228,8 +229,8 @@ export function Composer(props: {
>
<Text style={{ color: C.warning }}>
{owner.recovering()
- ? 'Reading original receipts…'
- : 'Recover original send receipt'}
+ ? tr('Reading original receipts…')
+ : tr('Recover original send receipt')}
</Text>
</Button>
</If>
@@ -243,7 +244,7 @@ export function Composer(props: {
focusStyle={{ backgroundColor: C.line }}
>
<Text style={{ color: C.warning }}>
- Abandon unconfirmed send · {id().slice(0, 8)}
+ {tr('Abandon unconfirmed send')} · {id().slice(0, 8)}
</Text>
</Button>
)}
@@ -269,7 +270,7 @@ export function Composer(props: {
{ when: 'always', within: node },
);
}}
- accessibilityLabel="Conversation composer"
+ accessibilityLabel={tr('Conversation composer')}
style={{
flexShrink: 0,
backgroundColor: C.raised,
@@ -302,7 +303,9 @@ export function Composer(props: {
<View
role="button"
accessibilityLabel={
- owner ? 'Focus message composer' : 'Choose workspace from composer card'
+ owner
+ ? tr('Focus message composer')
+ : tr('Choose workspace from composer card')
}
focusable={owner === null}
disabled={owner !== null && !owner.editable()}
@@ -324,7 +327,7 @@ export function Composer(props: {
props.attach(node);
}}
testID="message"
- accessibilityLabel="Message composer"
+ accessibilityLabel={tr('Message composer')}
autoFocus={owner !== null}
pointerEvents={owner ? 'auto' : 'none'}
multiline
@@ -334,7 +337,7 @@ export function Composer(props: {
onChange={owner?.changeDraft}
onSubmit={owner?.submit}
placeholder={
- owner ? 'Message or run a task…' : 'Choose a workspace to begin…'
+ owner ? tr('Message or run a task…') : tr('Choose a workspace to begin…')
}
style={{
minHeight: hero() && !compact() ? 40 : 24,
@@ -357,8 +360,8 @@ export function Composer(props: {
onPress={inputAction(props.commands, {
sessionId: props.session()?.sessionId ?? null,
})}
- accessibilityLabel="Open commands"
- tooltip="Commands · Ctrl+P"
+ accessibilityLabel={tr('Open commands')}
+ tooltip={tr('Commands · Ctrl+P')}
style={{
width: 32,
height: 32,
@@ -374,8 +377,8 @@ export function Composer(props: {
</Button>
<If when={props.session() !== null}>
<Button
- accessibilityLabel="Attach files"
- tooltip="Attach files · Ctrl+U"
+ accessibilityLabel={tr('Attach files')}
+ tooltip={tr('Attach files · Ctrl+U')}
disabled={props.session()!.attachments.restoring()}
onPress={inputAction(props.session()!.attachments.open, {
owner: props.session()!.sessionId,
@@ -411,8 +414,8 @@ export function Composer(props: {
<View pointerEvents="none" style={{ flex: 1 }} />
</If>
<Button
- accessibilityLabel={primaryStops() ? 'Stop response' : sendLabel()}
- tooltip={primaryStops() ? 'Stop response · Esc' : `${sendLabel()} · Enter`}
+ accessibilityLabel={primaryStops() ? tr('Stop response') : sendLabel()}
+ tooltip={primaryStops() ? tr('Stop response · Esc') : `${sendLabel()} · Enter`}
disabled={primaryStops() ? props.session()!.stopping() : cannotSend()}
onPress={
primaryStops()
diff --git a/products/maka/terminal-shell/app/ConnectionForm.tsx b/products/maka/terminal-shell/app/ConnectionForm.tsx
index 947ad82068..dded85c7a7 100644
--- a/products/maka/terminal-shell/app/ConnectionForm.tsx
+++ b/products/maka/terminal-shell/app/ConnectionForm.tsx
@@ -24,6 +24,7 @@ import { SettingsField } from './SettingsField';
import { ConnectionLogin } from './ConnectionLogin';
import { ChoiceIndicator } from './ChoiceIndicator';
import { Icon } from './Icon';
+import { tr } from './locale';
import {
C,
bodyTextStyle,
@@ -47,32 +48,34 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) {
{form().provider.descriptor.label}
</Text>
<SettingsField
- label="Connection name"
+ label={tr('Connection name')}
value={form().name}
change={(text) => owner.edit('name', text)}
disabled={disabled()}
/>
<SettingsField
- label="Connection ID"
+ label={tr('Connection ID')}
value={form().slug}
change={(text) => owner.edit('slug', text)}
disabled={disabled()}
readonly={!!form().baseline}
- hint="A unique lowercase name, such as openrouter-work. It stays fixed after creation."
+ hint={tr(
+ 'A unique lowercase name, such as openrouter-work. It stays fixed after creation.',
+ )}
/>
<If when={owner.editableBaseUrl()}>
<SettingsField
- label="Base URL"
+ label={tr('Base URL')}
value={form().baseUrl}
change={(text) => owner.edit('baseUrl', text)}
disabled={disabled()}
- hint="Use the API endpoint supplied by your provider."
+ hint={tr('Use the API endpoint supplied by your provider.')}
/>
</If>
<Button
disabled={disabled()}
selected={form().enabled}
- accessibilityLabel="Enable connection"
+ accessibilityLabel={tr('Enable connection')}
onPress={inputAction(owner.toggleEnabled, {
...owner.action(),
enabled: !form().enabled,
@@ -82,7 +85,9 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) {
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.text, flex: 1 }}>Connection enabled</Text>
+ <Text style={{ ...captionTextStyle, color: C.text, flex: 1 }}>
+ {tr('Connection enabled')}
+ </Text>
<ChoiceIndicator kind="switch" checked={form().enabled} disabled={disabled()} />
</Button>
<If
@@ -91,15 +96,17 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) {
)}
>
<SettingsField
- label="API key"
+ label={tr('API key')}
value={form().apiKey}
masked
change={(text) => owner.edit('apiKey', text)}
disabled={disabled()}
hint={
owner.credential()?.configured
- ? 'A provider credential is stored. Leave blank to keep it; enter a key to replace it.'
- : 'Your key is stored securely and is never displayed again.'
+ ? tr(
+ 'A provider credential is stored. Leave blank to keep it; enter a key to replace it.',
+ )
+ : tr('Your key is stored securely and is never displayed again.')
}
/>
</If>
@@ -107,7 +114,7 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) {
<If when={owner.credential()?.configured}>
<Button
disabled={disabled() || owner.dirty()}
- accessibilityLabel="Remove stored provider credential"
+ accessibilityLabel={tr('Remove stored provider credential')}
onPress={inputAction(owner.confirm, {
...owner.action(),
kind: 'key',
@@ -119,28 +126,28 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) {
disabledStyle={controlDisabledStyle}
>
<Text style={{ ...captionTextStyle, color: C.danger }}>
- Remove stored provider credential
+ {tr('Remove stored provider credential')}
</Text>
</Button>
<If when={owner.dirty()}>
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- Save or discard connection edits before removing the stored credential.
+ {tr('Save or discard connection edits before removing the stored credential.')}
</Text>
</If>
</If>
<SettingsField
- label="Advanced request JSON"
+ label={tr('Advanced request JSON')}
value={form().overlay}
multiline
change={(text) => owner.edit('overlay', text)}
disabled={disabled()}
- hint={
- 'Merged into model requests. For OpenRouter: {"provider":{"only":["openrouter","deepseek"]}}. Use null to clear.'
- }
+ hint={tr(
+ 'Merged into model requests. For OpenRouter: {"provider":{"only":["openrouter","deepseek"]}}. Use null to clear.',
+ )}
/>
<Button
disabled={disabled()}
- accessibilityLabel="Save connection"
+ accessibilityLabel={tr('Save connection')}
onPress={inputAction(owner.saveConnection, owner.action())}
style={{ ...controlStyle, alignSelf: 'flex-start', backgroundColor: C.accent }}
hoverStyle={{ backgroundColor: C.accentHover }}
@@ -148,18 +155,18 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) {
disabledStyle={controlDisabledStyle}
>
<Text style={{ ...captionTextStyle, color: C.background, fontWeight: '500' }}>
- {owner.busy() ? 'Saving…' : 'Save connection'}
+ {owner.busy() ? tr('Saving…') : tr('Save connection')}
</Text>
</Button>
<If when={form().baseline}>
<View style={{ height: 1, backgroundColor: C.line }} />
<View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Text style={{ ...bodyTextStyle, color: C.text, flex: 1, fontWeight: '500' }}>
- Models · {models().length}
+ {tr('Models · {count}', { count: models().length })}
</Text>
<Button
disabled={disabled() || owner.dirty()}
- accessibilityLabel="Add model manually"
+ accessibilityLabel={tr('Add model manually')}
onPress={inputAction(owner.editModel, { ...owner.action(), id: null })}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={controlHoverStyle}
@@ -167,30 +174,30 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) {
disabledStyle={controlDisabledStyle}
>
<Icon name="plus" color={C.accent} />
- <Text style={{ ...captionTextStyle, color: C.accent }}>Add model</Text>
+ <Text style={{ ...captionTextStyle, color: C.accent }}>{tr('Add model')}</Text>
</Button>
<If when={form().provider.descriptor.discovery}>
<Button
disabled={disabled() || owner.dirty()}
- accessibilityLabel="Fetch models"
+ accessibilityLabel={tr('Fetch models')}
onPress={inputAction(owner.discover, owner.action())}
style={controlStyle}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.accent }}>Fetch models</Text>
+ <Text style={{ ...captionTextStyle, color: C.accent }}>{tr('Fetch models')}</Text>
</Button>
</If>
</View>
<If when={!models().length}>
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- Fetch the provider’s catalog or add its exact model ID.
+ {tr('Fetch the provider’s catalog or add its exact model ID.')}
</Text>
</If>
<Button
disabled={disabled() || owner.dirty()}
- accessibilityLabel="Manage models"
+ accessibilityLabel={tr('Manage models')}
onPress={inputAction(owner.showModels, owner.action())}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={controlHoverStyle}
@@ -198,13 +205,13 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) {
disabledStyle={controlDisabledStyle}
>
<Text style={{ ...captionTextStyle, color: C.accent, flex: 1 }}>
- Manage models · {models().length}
+ {tr('Manage models · {count}', { count: models().length })}
</Text>
<Icon name="right" color={C.muted} />
</Button>
<If when={owner.dirty()}>
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- Save changes before fetching or editing models.
+ {tr('Save changes before fetching or editing models.')}
</Text>
</If>
<If when={owner.defaultTarget()?.connectionId === form().baseline?.target.connectionId}>
@@ -216,7 +223,9 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) {
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Clear default model</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>
+ {tr('Clear default model')}
+ </Text>
</Button>
</If>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
@@ -232,7 +241,7 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) {
disabledStyle={controlDisabledStyle}
>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Discard changes and reload
+ {tr('Discard changes and reload')}
</Text>
</Button>
<Button
@@ -247,7 +256,9 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) {
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.danger }}>Delete connection</Text>
+ <Text style={{ ...captionTextStyle, color: C.danger }}>
+ {tr('Delete connection')}
+ </Text>
</Button>
</View>
</If>
diff --git a/products/maka/terminal-shell/app/ConnectionLogin.tsx b/products/maka/terminal-shell/app/ConnectionLogin.tsx
index 11b71cbd26..c9f3e1eaee 100644
--- a/products/maka/terminal-shell/app/ConnectionLogin.tsx
+++ b/products/maka/terminal-shell/app/ConnectionLogin.tsx
@@ -20,6 +20,7 @@
import { inputAction, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import type { ModelSettingsOwner } from './settings';
+import { tr } from './locale';
import { C, controlStyle, controlHoverStyle, controlFocusStyle, controlDisabledStyle } from './ui';
export function ConnectionLogin(props: { owner: ModelSettingsOwner; disabled: boolean }) {
@@ -30,15 +31,17 @@ export function ConnectionLogin(props: { owner: ModelSettingsOwner; disabled: bo
return (
<If when={methods().length > 0}>
<View style={{ gap: 8 }}>
- <Text style={{ color: C.text, fontWeight: '700' }}>Provider sign-in</Text>
+ <Text style={{ color: C.text, fontWeight: '700' }}>{tr('Provider sign-in')}</Text>
<If when={!owner.editor()!.baseline}>
- <Text style={{ color: C.muted }}>Save this connection before starting sign-in.</Text>
+ <Text style={{ color: C.muted }}>
+ {tr('Save this connection before starting sign-in.')}
+ </Text>
</If>
<If when={owner.editor()!.baseline && !login()}>
<Text style={{ color: C.muted }}>
{owner.credential()?.configured
- ? 'A provider credential is stored. Sign in again to replace it.'
- : 'Complete sign-in with your provider. Credentials stay in the Host vault.'}
+ ? tr('A provider credential is stored. Sign in again to replace it.')
+ : tr('Complete sign-in with your provider. Credentials stay in the Host vault.')}
</Text>
<Each of={methods()} key={(method) => method.id}>
{(method) => (
@@ -57,19 +60,19 @@ export function ConnectionLogin(props: { owner: ModelSettingsOwner; disabled: bo
</Each>
<If when={owner.dirty()}>
<Text style={{ color: C.muted }}>
- Save or discard connection edits before signing in.
+ {tr('Save or discard connection edits before signing in.')}
</Text>
</If>
</If>
<If when={login()}>
<Text style={{ color: C.muted }}>
{login()!.cancelling
- ? 'Cancelling sign-in; waiting for the original result…'
+ ? tr('Cancelling sign-in; waiting for the original result…')
: login()!.prompt
? login()!.prompt!.userCode
- ? 'Open this URL in your browser and enter the code. Waiting for sign-in…'
- : 'Open this URL in your browser. Waiting for sign-in…'
- : 'Starting sign-in…'}
+ ? tr('Open this URL in your browser and enter the code. Waiting for sign-in…')
+ : tr('Open this URL in your browser. Waiting for sign-in…')
+ : tr('Starting sign-in…')}
</Text>
<If when={login()!.prompt}>
<Text selectable style={{ color: C.text }}>
@@ -82,7 +85,7 @@ export function ConnectionLogin(props: { owner: ModelSettingsOwner; disabled: bo
</If>
<View style={{ flexDirection: 'row', gap: 16 }}>
<Button
- accessibilityLabel="Copy sign-in URL"
+ accessibilityLabel={tr('Copy sign-in URL')}
onPress={inputAction(owner.login.copy, {
occurrence: login()!.occurrence,
field: 'url',
@@ -93,11 +96,11 @@ export function ConnectionLogin(props: { owner: ModelSettingsOwner; disabled: bo
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ color: C.accent }}>Copy URL</Text>
+ <Text style={{ color: C.accent }}>{tr('Copy URL')}</Text>
</Button>
<If when={login()!.prompt!.userCode}>
<Button
- accessibilityLabel="Copy sign-in code"
+ accessibilityLabel={tr('Copy sign-in code')}
onPress={inputAction(owner.login.copy, {
occurrence: login()!.occurrence,
field: 'userCode',
@@ -108,13 +111,13 @@ export function ConnectionLogin(props: { owner: ModelSettingsOwner; disabled: bo
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ color: C.accent }}>Copy code</Text>
+ <Text style={{ color: C.accent }}>{tr('Copy code')}</Text>
</Button>
</If>
</View>
</If>
<Button
- accessibilityLabel="Cancel provider sign-in"
+ accessibilityLabel={tr('Cancel provider sign-in')}
disabled={login()!.cancelling}
onPress={inputAction(owner.login.cancel, login()!.occurrence)}
style={controlStyle}
@@ -122,7 +125,7 @@ export function ConnectionLogin(props: { owner: ModelSettingsOwner; disabled: bo
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ color: C.warning }}>Cancel sign-in</Text>
+ <Text style={{ color: C.warning }}>{tr('Cancel sign-in')}</Text>
</Button>
</If>
</View>
diff --git a/products/maka/terminal-shell/app/ConnectionModel.tsx b/products/maka/terminal-shell/app/ConnectionModel.tsx
index fc2cdd4544..698bd53b57 100644
--- a/products/maka/terminal-shell/app/ConnectionModel.tsx
+++ b/products/maka/terminal-shell/app/ConnectionModel.tsx
@@ -22,6 +22,7 @@ import { Button } from 'nsh/base';
import { thinkingLevels, type ModelSettingsOwner } from './settings';
import { SettingsField } from './SettingsField';
import { ChoiceIndicator } from './ChoiceIndicator';
+import { tr } from './locale';
import {
C,
bodyTextStyle,
@@ -40,22 +41,22 @@ export function ConnectionModel(props: { settings: ModelSettingsOwner }) {
<Scroll style={{ flex: 1 }}>
<View style={{ padding: 8, gap: 16 }}>
<SettingsField
- label="Model ID"
+ label={tr('Model ID')}
value={model().id}
readonly={model().originalId !== null}
disabled={disabled()}
change={(id) => owner.changeModel({ id })}
- hint="The exact API model ID, including its provider prefix when required."
+ hint={tr('The exact API model ID, including its provider prefix when required.')}
/>
<SettingsField
- label="Model display name"
+ label={tr('Model display name')}
value={model().label}
disabled={disabled()}
change={(label) => owner.changeModel({ label })}
- hint="Optional. Empty uses the provider’s name."
+ hint={tr('Optional. Empty uses the provider’s name.')}
/>
<Text style={{ ...bodyTextStyle, color: C.text, fontWeight: '500' }}>
- Supported reasoning efforts
+ {tr('Supported reasoning efforts')}
</Text>
<Button
disabled={disabled()}
@@ -71,7 +72,7 @@ export function ConnectionModel(props: { settings: ModelSettingsOwner }) {
checked={model().thinkingLevels === null}
disabled={disabled()}
/>
- <Text style={{ ...captionTextStyle, color: C.text }}>Use provider defaults</Text>
+ <Text style={{ ...captionTextStyle, color: C.text }}>{tr('Use provider defaults')}</Text>
</Button>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 12 }}>
<Each of={thinkingLevels} key={(level) => level}>
@@ -79,7 +80,7 @@ export function ConnectionModel(props: { settings: ModelSettingsOwner }) {
<Button
disabled={disabled()}
selected={model().thinkingLevels?.includes(level()) ?? false}
- accessibilityLabel={`Support effort ${level()}`}
+ accessibilityLabel={tr('Support effort {level}', { level: level() })}
onPress={inputAction(owner.toggleThinking, {
...owner.modelAction(),
level: level(),
@@ -100,11 +101,12 @@ export function ConnectionModel(props: { settings: ModelSettingsOwner }) {
</Each>
</View>
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- Select the reasoning efforts supported by this model. They will be available in
- conversations.
+ {tr(
+ 'Select the reasoning efforts supported by this model. They will be available in conversations.',
+ )}
</Text>
<Text style={{ ...bodyTextStyle, color: C.text, fontWeight: '500' }}>
- Connection default reasoning effort
+ {tr('Connection default reasoning effort')}
</Text>
<Button
disabled={disabled()}
@@ -120,7 +122,7 @@ export function ConnectionModel(props: { settings: ModelSettingsOwner }) {
checked={model().defaultThinkingLevel === null}
disabled={disabled()}
/>
- <Text style={{ ...captionTextStyle, color: C.text }}>Provider default</Text>
+ <Text style={{ ...captionTextStyle, color: C.text }}>{tr('Provider default')}</Text>
</Button>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 12 }}>
<Each of={model().thinkingLevels ?? []} key={(level) => level}>
@@ -128,7 +130,7 @@ export function ConnectionModel(props: { settings: ModelSettingsOwner }) {
<Button
disabled={disabled()}
selected={model().defaultThinkingLevel === level()}
- accessibilityLabel={`Default effort ${level()}`}
+ accessibilityLabel={tr('Default effort {level}', { level: level() })}
onPress={inputAction(owner.defaultThinking, {
...owner.modelAction(),
level: level(),
@@ -150,7 +152,7 @@ export function ConnectionModel(props: { settings: ModelSettingsOwner }) {
</View>
<Button
disabled={disabled()}
- accessibilityLabel="Save model"
+ accessibilityLabel={tr('Save model')}
onPress={inputAction(owner.saveModel, owner.modelAction())}
style={{ ...controlStyle, alignSelf: 'flex-start', backgroundColor: C.accent }}
hoverStyle={{ backgroundColor: C.accentHover }}
@@ -158,7 +160,7 @@ export function ConnectionModel(props: { settings: ModelSettingsOwner }) {
disabledStyle={controlDisabledStyle}
>
<Text style={{ ...captionTextStyle, color: C.background, fontWeight: '500' }}>
- {owner.busy() ? 'Saving…' : 'Save model'}
+ {owner.busy() ? tr('Saving…') : tr('Save model')}
</Text>
</Button>
<If when={model().originalId !== null}>
@@ -174,7 +176,7 @@ export function ConnectionModel(props: { settings: ModelSettingsOwner }) {
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.danger }}>Remove model</Text>
+ <Text style={{ ...captionTextStyle, color: C.danger }}>{tr('Remove model')}</Text>
</Button>
</If>
</View>
diff --git a/products/maka/terminal-shell/app/ConnectionSettings.tsx b/products/maka/terminal-shell/app/ConnectionSettings.tsx
index 4d0a4bd4ee..a5c6b759a9 100644
--- a/products/maka/terminal-shell/app/ConnectionSettings.tsx
+++ b/products/maka/terminal-shell/app/ConnectionSettings.tsx
@@ -25,6 +25,7 @@ import { ConnectionForm } from './ConnectionForm';
import { ConnectionModel } from './ConnectionModel';
import { ChoiceIndicator } from './ChoiceIndicator';
import { Icon } from './Icon';
+import { tr } from './locale';
import {
C,
bodyTextStyle,
@@ -55,21 +56,23 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
>
<Text style={{ ...bodyTextStyle, color: C.text }}>
{owner.confirmation() === 'connection'
- ? 'Delete this connection, its stored provider credential and model settings? Existing conversations keep their saved selection.'
+ ? tr(
+ 'Delete this connection, its stored provider credential and model settings? Existing conversations keep their saved selection.',
+ )
: owner.confirmation() === 'key'
- ? 'Remove the stored provider credential? This connection may stop working.'
- : 'Remove this model from the connection?'}
+ ? tr('Remove the stored provider credential? This connection may stop working.')
+ : tr('Remove this model from the connection?')}
</Text>
<Button
disabled={disabled()}
- accessibilityLabel="Confirm removal"
+ accessibilityLabel={tr('Confirm removal')}
onPress={inputAction(owner.remove, owner.removalAction())}
style={controlStyle}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.danger }}>Confirm removal</Text>
+ <Text style={{ ...captionTextStyle, color: C.danger }}>{tr('Confirm removal')}</Text>
</Button>
<Button
disabled={owner.busy()}
@@ -79,7 +82,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Cancel</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>{tr('Cancel')}</Text>
</Button>
</View>
</If>
@@ -88,7 +91,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
<Button
disabled={disabled()}
- accessibilityLabel="Add connection"
+ accessibilityLabel={tr('Add connection')}
onPress={inputAction(owner.newConnection, null)}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={controlHoverStyle}
@@ -96,7 +99,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
disabledStyle={controlDisabledStyle}
>
<Icon name="plus" color={C.accent} />
- <Text style={{ ...captionTextStyle, color: C.accent }}>Add connection</Text>
+ <Text style={{ ...captionTextStyle, color: C.accent }}>{tr('Add connection')}</Text>
</Button>
<Button
disabled={owner.loading() || owner.busy()}
@@ -106,12 +109,14 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Refresh connections</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>
+ {tr('Refresh connections')}
+ </Text>
</Button>
</View>
<If when={!owner.loading() && !owner.connections().length}>
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- Add your first connection, then fetch or add a model.
+ {tr('Add your first connection, then fetch or add a model.')}
</Text>
</If>
<List
@@ -126,7 +131,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
{(item) => (
<Button
disabled={disabled() || !item().editable}
- accessibilityLabel={`Edit connection ${item().name}`}
+ accessibilityLabel={tr('Edit connection {name}', { name: item().name })}
onPress={inputAction(owner.openConnection, item().basis)}
style={{
height: 64,
@@ -152,7 +157,10 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
{item().name}
</Text>
<Text numberOfLines={1} style={{ ...captionTextStyle, color: C.muted }}>
- {item().enabled ? 'Enabled' : 'Disabled'} · {item().enabledModelCount} models
+ {tr('{status} · {count} models', {
+ status: item().enabled ? tr('Enabled') : tr('Disabled'),
+ count: item().enabledModelCount,
+ })}
</Text>
</View>
<Icon name="right" color={C.muted} />
@@ -162,7 +170,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
</If>
<If when={owner.page() === 'providers'}>
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- Choose the API provider for this connection.
+ {tr('Choose the API provider for this connection.')}
</Text>
<List
each={owner.providers()}
@@ -176,7 +184,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
{(item) => (
<Button
disabled={disabled()}
- accessibilityLabel={`Connect ${item().label}`}
+ accessibilityLabel={tr('Connect {name}', { name: item().label })}
onPress={inputAction(owner.chooseProvider, item().identity)}
style={{
height: 48,
@@ -209,7 +217,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
<If when={owner.page() === 'models' && owner.editor()}>
<Button
disabled={disabled()}
- accessibilityLabel="Add model manually"
+ accessibilityLabel={tr('Add model manually')}
onPress={inputAction(owner.editModel, { ...owner.action(), id: null })}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={controlHoverStyle}
@@ -217,7 +225,9 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
disabledStyle={controlDisabledStyle}
>
<Icon name="plus" color={C.secondary} />
- <Text style={{ ...captionTextStyle, color: C.secondary }}>Add model manually</Text>
+ <Text style={{ ...captionTextStyle, color: C.secondary }}>
+ {tr('Add model manually')}
+ </Text>
</Button>
<List
each={models()}
@@ -245,7 +255,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
<Button
disabled={disabled()}
style={{ ...controlStyle, height: 48, flex: 1, minWidth: 0 }}
- accessibilityLabel={`Edit model ${id()}`}
+ accessibilityLabel={tr('Edit model {id}', { id: id() })}
onPress={inputAction(owner.editModel, { ...owner.action(), id: id() })}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -271,7 +281,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
borderRadius: 4,
}}
>
- Default
+ {tr('Default')}
</Text>
</If>
</View>
@@ -288,8 +298,8 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
</Button>
<Button
disabled={disabled() || !owner.editor()?.enabled}
- accessibilityLabel={`Set default ${id()}`}
- tooltip={`Set default ${id()}`}
+ accessibilityLabel={tr('Set default {id}', { id: id() })}
+ tooltip={tr('Set default {id}', { id: id() })}
selected={isDefault()}
onPress={inputAction(owner.setDefault, { ...owner.action(), id: id() })}
style={{
@@ -317,7 +327,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) {
</If>
</If>
<If when={owner.loading()}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Loading settings…</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>{tr('Loading settings…')}</Text>
</If>
</View>
);
diff --git a/products/maka/terminal-shell/app/Conversation.tsx b/products/maka/terminal-shell/app/Conversation.tsx
index 0814b60123..f409eb9ea2 100644
--- a/products/maka/terminal-shell/app/Conversation.tsx
+++ b/products/maka/terminal-shell/app/Conversation.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { createText, UiError, errorText, type UiText, message as uiMessage } from './text';
+import { tr } from './locale';
import { batch, cleanup, computed, effect, inputAction, signal, Text, untracked, View } from 'nsh';
import type { YNode } from 'nsh';
import { Button } from 'nsh/base';
@@ -128,15 +130,17 @@ function indexRows(items: readonly Row[]) {
}
return { byId, byMessage };
}
-function receiptMessage(reply: any): string {
+function receiptMessage(reply: any): UiText {
const receipt = reply?.receipt;
if (receipt?.kind === 'recorded' && receipt.outcome?.kind === 'returned') {
const value = receipt.outcome.value;
return value?.kind === 'rejected'
- ? `Send rejected: ${value.reason}`
- : 'Host returned an unexpected send result.';
+ ? uiMessage('Send rejected: {reason}', { reason: value.reason })
+ : uiMessage('Host returned an unexpected send result.');
}
- return 'The original send has no confirmed result. Recover its receipt before sending again.';
+ return uiMessage(
+ 'The original send has no confirmed result. Recover its receipt before sending again.',
+ );
}
export function Conversation(props: ConversationProps) {
@@ -165,7 +169,7 @@ export function Conversation(props: ConversationProps) {
const runTurn = signal<string | null>(null);
const backend = signal<any>(null);
const interactions = signal<AgentPendingInteractions>({ revision: -1, entries: [] });
- const error = signal('');
+ const error = createText();
const ready = signal(false);
const transcriptStatus = signal<'loading' | 'live' | 'closing' | 'failed'>('loading');
const before = signal<any>(null);
@@ -185,7 +189,7 @@ export function Conversation(props: ConversationProps) {
const unresolved = signal<any[]>([]);
const receiptsKnown = signal(false);
const recovering = signal(false);
- const notice = signal('');
+ const notice = createText();
const reading = signal<'chat' | 'trajectory'>('chat');
const readingSize = signal({ width: 640, height: 192 });
const innerReader = signal<string | null>(null);
@@ -421,7 +425,7 @@ export function Conversation(props: ConversationProps) {
});
const report = (reason: unknown) => {
- if (alive) error.set(reason instanceof Error ? reason.message : String(reason));
+ if (alive) error.set(errorText(reason));
};
const publishHistory = (chunks: any[], through: number, older = false) => {
batch(() => {
@@ -462,7 +466,7 @@ export function Conversation(props: ConversationProps) {
includeThinking: true,
view: 'conversation',
});
- if (page.through !== through) throw new Error('History changed its requested fence.');
+ if (page.through !== through) throw new UiError('History changed its requested fence.');
return page;
};
const hydrateAttachmentMetadata = () => {
@@ -503,7 +507,7 @@ export function Conversation(props: ConversationProps) {
first.messageId !== row.messageId ||
first.role !== 'user'
)
- throw new Error('The original attachment metadata could not be verified.');
+ throw new UiError('The original attachment metadata could not be verified.');
publishMetadata([first], through);
})
.catch((reason) => {
@@ -536,7 +540,7 @@ export function Conversation(props: ConversationProps) {
.query('agent', 'historyLandmarks', { sessionId, through })
.then((page) => {
if (page.through !== through)
- throw new Error('Turn landmarks changed their requested fence.');
+ throw new UiError('Turn landmarks changed their requested fence.');
if (alive && page.through >= landmarks().through)
landmarks.set({ through: page.through, items: page.landmarks });
})
@@ -549,21 +553,13 @@ export function Conversation(props: ConversationProps) {
const observe = async () => {
const observed = await host.query('agent', 'observe', { sessionId });
if (!alive) return observed;
- if (observed.session.id !== sessionId) throw new Error('Host observed a different session.');
+ if (observed.session.id !== sessionId) throw new UiError('Host observed a different session.');
latestKnown = Math.max(latestKnown, observed.through);
readLandmarks(observed.through);
- title.set(observed.session.title || 'Untitled conversation');
+ title.set(observed.session.title);
archived.set(observed.session.archived);
workspace.set(observed.session.workspace.path);
- permissions.set(
- (
- {
- read_only: 'Read only',
- workspace_write: 'Workspace write',
- danger_full_access: 'Full access',
- } as any
- )[observed.session.permissions.access],
- );
+ permissions.set(observed.session.permissions.access);
backend.set(observed.session.backend ?? observed.rememberedBackend ?? null);
metrics.set(observed.metrics ?? null);
if (observed.through >= runThrough) {
@@ -853,7 +849,7 @@ export function Conversation(props: ConversationProps) {
for (;;) {
const page = await stream.next();
if (!alive) return;
- if (page === null) throw new Error('Live updates ended.');
+ if (page === null) throw new UiError('Live updates ended.');
batch(() => deliver(page));
}
} catch (reason) {
@@ -866,8 +862,12 @@ export function Conversation(props: ConversationProps) {
try {
await stream.close();
} catch (reason) {
- throw new Error(
- `${failure ? String(failure.reason) : 'Observation stopped'}; original observation cleanup remains unconfirmed: ${String(reason)}`,
+ throw new UiError(
+ '{observation}; original observation cleanup remains unconfirmed: {reason}',
+ {
+ observation: failure ? errorText(failure.reason) : uiMessage('Observation stopped'),
+ reason: errorText(reason),
+ },
);
} finally {
// This replaces only the read-only JS observer. Native Client retains
@@ -985,7 +985,7 @@ export function Conversation(props: ConversationProps) {
before().field === previous.field &&
before().offset === previous.offset
) {
- notice.set('Older history did not advance. This turn could not be reached.');
+ notice.set(uiMessage('Older history did not advance. This turn could not be reached.'));
return;
}
}
@@ -1015,7 +1015,7 @@ export function Conversation(props: ConversationProps) {
entry.group.rowIds.some((id) => targetRows.has(id)),
);
if (index < 0) {
- notice.set('This turn has no visible messages in the current reading mode.');
+ notice.set(uiMessage('This turn has no visible messages in the current reading mode.'));
return;
}
awayFromLatest.set(true);
@@ -1103,7 +1103,7 @@ export function Conversation(props: ConversationProps) {
details: ModelDetails;
}>({
route: null,
- details: { kind: 'unavailable', message: 'Choose a model.' },
+ details: { kind: 'unavailable', message: uiMessage('Choose a model.') },
});
let modelGeneration = 0;
let modelRead: {
@@ -1124,7 +1124,7 @@ export function Conversation(props: ConversationProps) {
modelRead = null;
modelProjection.set({
route: null,
- details: { kind: 'unavailable', message: 'Choose a model.' },
+ details: { kind: 'unavailable', message: uiMessage('Choose a model.') },
});
return Promise.resolve();
}
@@ -1147,14 +1147,14 @@ export function Conversation(props: ConversationProps) {
? { kind: 'ready', choice: result.choice }
: {
kind: 'unavailable',
- message: `Model unavailable: ${captured.modelId}.`,
+ message: uiMessage('Model unavailable: {model}.', { model: captured.modelId }),
},
});
} catch (reason) {
if (current())
modelProjection.set({
route,
- details: { kind: 'unavailable', message: String(reason) },
+ details: { kind: 'unavailable', message: errorText(reason) },
});
} finally {
if (modelRead?.generation === generation) modelRead = null;
@@ -1172,15 +1172,17 @@ export function Conversation(props: ConversationProps) {
if (!alive) return null;
const backend = observed.session.backend ?? observed.rememberedBackend;
if (expectedBackend !== undefined && !sameBackend(backendIdentity(backend), expectedBackend))
- throw new Error('The model changed before confirmation. Send again to confirm it.');
- if (backend?.kind !== 'model') throw new Error('Choose a model before sending.');
+ throw new UiError('The model changed before confirmation. Send again to confirm it.');
+ if (backend?.kind !== 'model') throw new UiError('Choose a model before sending.');
const resolved = await host.query('settings', 'resolveModel', {
connectionId: backend.connectionId,
modelId: backend.modelId,
});
if (!alive) return null;
if (resolved.kind !== 'ready')
- throw new Error(`Model unavailable: ${backend.modelId}. Choose another model.`);
+ throw new UiError('Model unavailable: {model}. Choose another model.', {
+ model: backend.modelId,
+ });
return { model: resolved.choice.selection, thinkingLevel: backend.thinkingLevel ?? null };
};
@@ -1223,9 +1225,9 @@ export function Conversation(props: ConversationProps) {
if (text !== undefined) attachments.sent(files.map((file) => file.id));
notice.set(
accepted.kind === 'queued'
- ? 'Message queued.'
+ ? uiMessage('Message queued.')
: text === undefined
- ? 'Message accepted.'
+ ? uiMessage('Message accepted.')
: '',
);
if (accepted.kind === 'queued') return;
@@ -1237,30 +1239,34 @@ export function Conversation(props: ConversationProps) {
const sendBlockedReason = () => {
if (transcriptStatus() === 'closing')
- return 'Closing disconnected updates… Your draft is kept.';
+ return tr('Closing disconnected updates… Your draft is kept.');
if (transcriptStatus() === 'failed')
return ready()
- ? 'Updates disconnected. Reconnecting automatically; your draft is kept.'
- : 'History unavailable. Retrying automatically; your draft is kept.';
+ ? tr('Updates disconnected. Reconnecting automatically; your draft is kept.')
+ : tr('History unavailable. Retrying automatically; your draft is kept.');
if (ready() && transcriptStatus() !== 'live')
- return 'Reconnecting updates… Your draft is kept.';
- if (!ready()) return 'Loading session history… Your draft is kept.';
+ return tr('Reconnecting updates… Your draft is kept.');
+ if (!ready()) return tr('Loading session history… Your draft is kept.');
if (archived())
- return 'This conversation is archived. Restore it in Sessions to send; your draft is kept.';
- if (!draftReady()) return 'Loading the saved draft…';
- if (savingFailed()) return 'The draft could not be saved. Sending is unavailable.';
+ return tr(
+ 'This conversation is archived. Restore it in Sessions to send; your draft is kept.',
+ );
+ if (!draftReady()) return tr('Loading the saved draft…');
+ if (savingFailed()) return tr('The draft could not be saved. Sending is unavailable.');
if (attachments.blocked()) return attachments.blocked();
- if (sending()) return 'Confirming send… New typing is kept.';
- if (recovering()) return 'Reading original receipts… New typing is kept.';
+ if (sending()) return tr('Confirming send… New typing is kept.');
+ if (recovering()) return tr('Reading original receipts… New typing is kept.');
if (!receiptsKnown() || unresolved().length)
- return 'Sending waits for the original receipt; it will not be replayed.';
+ return tr('Sending waits for the original receipt; it will not be replayed.');
return '';
};
const send = async (text: string, expectedBackend?: BackendIdentity) => {
if (!alive || (!text.trim() && attachments.files().length === 0)) return;
if (sendBlockedReason()) {
notice.set(
- 'This message was not sent. Your draft is kept; check the status below before trying again.',
+ uiMessage(
+ 'This message was not sent. Your draft is kept; check the status below before trying again.',
+ ),
);
return;
}
@@ -1274,14 +1280,14 @@ export function Conversation(props: ConversationProps) {
if (!alive || model === null) return;
await flushDraft();
if (!alive) return;
- if (savingFailed()) throw new Error('The draft could not be saved; it has not been sent.');
+ if (savingFailed()) throw new UiError('The draft could not be saved; it has not been sent.');
if (editEpoch !== epoch)
- throw new Error('The draft changed before confirmation. Send again to confirm it.');
+ throw new UiError('The draft changed before confirmation. Send again to confirm it.');
if (
expectedBackend !== undefined &&
!sameBackend(backendIdentity(backend()), expectedBackend)
)
- throw new Error('The model changed before confirmation. Send again to confirm it.');
+ throw new UiError('The model changed before confirmation. Send again to confirm it.');
// The native journal consumes only this exact revision. New edits keep
// saving while confirmation waits and never inherit this send attempt.
receiptsKnown.set(false);
@@ -1320,7 +1326,9 @@ export function Conversation(props: ConversationProps) {
for (const original of originals) {
if (!original.reference) {
notice.set(
- 'An attempt was not confirmed. Explicitly abandon it below before starting a new send.',
+ uiMessage(
+ 'An attempt was not confirmed. Explicitly abandon it below before starting a new send.',
+ ),
);
continue;
}
@@ -1350,7 +1358,9 @@ export function Conversation(props: ConversationProps) {
if (!alive) return;
if (original.abandoned) {
notice.set(
- 'Unconfirmed attempt abandoned. The draft is kept; press Enter explicitly to send a new attempt.',
+ uiMessage(
+ 'Unconfirmed attempt abandoned. The draft is kept; press Enter explicitly to send a new attempt.',
+ ),
);
} else if (original.reference) {
const reply = await recoverSend(client, operationId);
@@ -1411,11 +1421,11 @@ export function Conversation(props: ConversationProps) {
snapshot: queued,
running: () => invocation() !== null && !stopping(),
async read(messageId, expectedRevision) {
- if (!alive) throw new Error('This conversation is closed.');
+ if (!alive) throw new UiError('This conversation is closed.');
return host.query('agent', 'queueMessage', { sessionId, messageId, expectedRevision });
},
async change(expectedRevision, edit) {
- if (!alive) throw new Error('This conversation is closed.');
+ if (!alive) throw new UiError('This conversation is closed.');
return props.control('editQueue', { sessionId, expectedRevision, edit });
},
};
@@ -1431,7 +1441,12 @@ export function Conversation(props: ConversationProps) {
backend: () => backendIdentity(backend()),
modelDetails,
refreshModel,
- permissions,
+ permissions: () =>
+ ({
+ read_only: tr('Read only'),
+ workspace_write: tr('Workspace write'),
+ danger_full_access: tr('Full access'),
+ })[permissions() as 'read_only' | 'workspace_write' | 'danger_full_access'] ?? permissions(),
workspace,
empty: () =>
ready() &&
@@ -1466,7 +1481,7 @@ export function Conversation(props: ConversationProps) {
},
async flushDraft() {
if (!draftReady()) {
- notice.set('Wait for the saved draft before switching.');
+ notice.set(uiMessage('Wait for the saved draft before switching.'));
return false;
}
await flushDraft();
@@ -1595,11 +1610,13 @@ export function Conversation(props: ConversationProps) {
hoverStyle={{ backgroundColor: C.surface }}
focusStyle={{ backgroundColor: C.raised }}
>
- <Text style={{ ...captionTextStyle, color: C.accent }}>Retry attachment metadata</Text>
+ <Text style={{ ...captionTextStyle, color: C.accent }}>
+ {tr('Retry attachment metadata')}
+ </Text>
</Button>
</If>
<Header
- title={title()}
+ title={title() || tr('Untitled conversation')}
workspace={workspace()}
status={
stopping()
@@ -1617,7 +1634,7 @@ export function Conversation(props: ConversationProps) {
/>
<If when={transcriptStatus() === 'failed'}>
<Button
- accessibilityLabel={ready() ? 'Reconnect updates' : 'Retry loading conversation'}
+ accessibilityLabel={ready() ? tr('Reconnect updates') : tr('Retry loading conversation')}
onPress={inputAction(retryTranscript, sessionId)}
style={{
...controlStyle,
@@ -1628,7 +1645,7 @@ export function Conversation(props: ConversationProps) {
focusStyle={{ backgroundColor: C.line }}
>
<Text style={{ ...captionTextStyle, color: C.accent }}>
- {ready() ? 'Reconnect updates' : 'Retry loading conversation'}
+ {ready() ? tr('Reconnect updates') : tr('Retry loading conversation')}
</Text>
</Button>
</If>
@@ -1646,7 +1663,7 @@ export function Conversation(props: ConversationProps) {
>
<Button
role="tab"
- accessibilityLabel="Chat"
+ accessibilityLabel={tr('Chat')}
selected={reading() === 'chat'}
style={controlStyle}
onPress={inputAction(chooseReading, { owner: sessionId, mode: 'chat' })}
@@ -1660,12 +1677,12 @@ export function Conversation(props: ConversationProps) {
fontWeight: reading() === 'chat' ? '700' : '400',
}}
>
- Chat
+ {tr('Chat')}
</Text>
</Button>
<Button
role="tab"
- accessibilityLabel="Trajectory"
+ accessibilityLabel={tr('Trajectory')}
selected={reading() === 'trajectory'}
style={controlStyle}
onPress={inputAction(chooseReading, { owner: sessionId, mode: 'trajectory' })}
@@ -1679,7 +1696,7 @@ export function Conversation(props: ConversationProps) {
fontWeight: reading() === 'trajectory' ? '700' : '400',
}}
>
- Trajectory
+ {tr('Trajectory')}
</Text>
</Button>
</View>
@@ -1730,7 +1747,9 @@ export function Conversation(props: ConversationProps) {
if (event.offset.y <= 32) void loadOlder();
}}
empty={
- <Text style={{ color: C.muted, paddingVertical: 32 }}>Loading session history…</Text>
+ <Text style={{ color: C.muted, paddingVertical: 32 }}>
+ {tr('Loading session history…')}
+ </Text>
}
>
{(key) => (
@@ -1811,7 +1830,7 @@ export function Conversation(props: ConversationProps) {
!latestToolActivity(toolActivity(), invocation(), row().turnId ?? '')
}
>
- <ActivityStatus title={() => 'Writing response'} active={() => true} />
+ <ActivityStatus title={() => tr('Writing response')} active={() => true} />
</If>
<MessageRow
item={row}
@@ -1873,7 +1892,9 @@ function metricText(value: any): string {
const compact = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
const parts: string[] = [];
if (value.outputTokensPerSecond != null)
- parts.push(`${value.outputTokensPerSecond.toFixed(1)} tok/s (last request)`);
+ parts.push(
+ tr('{speed} tok/s (last request)', { speed: value.outputTokensPerSecond.toFixed(1) }),
+ );
if (value.contextTokens != null) {
const context = `${value.approximate ? '≈' : ''}${compact(value.contextTokens)}`;
const total = value.contextWindow != null ? ` / ${compact(value.contextWindow)}` : '';
@@ -1882,7 +1903,7 @@ function metricText(value: any): string {
? ` · ${Math.round((value.contextTokens / value.contextWindow) * 100)}%`
: '';
parts.push(
- `Context${value.contextLastKnown ? ' (last known)' : ''} ${context}${total}${percent}`,
+ `${tr(value.contextLastKnown ? 'Context (last known)' : 'Context')} ${context}${total}${percent}`,
);
}
return parts.join(' · ');
diff --git a/products/maka/terminal-shell/app/CurrentDiff.tsx b/products/maka/terminal-shell/app/CurrentDiff.tsx
index 2e977f38db..8dc51f8f0d 100644
--- a/products/maka/terminal-shell/app/CurrentDiff.tsx
+++ b/products/maka/terminal-shell/app/CurrentDiff.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { errorText, renderText, type UiText } from './text';
+import { tr } from './locale';
import { cleanup, computed, inputAction, signal, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import { List } from 'nsh/list';
@@ -35,9 +37,15 @@ import {
} from './ui';
export const gitAreaLabel: Record<GitArea, string> = {
- staged: 'Staged',
- worktree: 'Worktree',
- untracked: 'Untracked',
+ get staged() {
+ return tr('Staged');
+ },
+ get worktree() {
+ return tr('Worktree');
+ },
+ get untracked() {
+ return tr('Untracked');
+ },
};
export type CurrentDiffTarget = {
@@ -69,7 +77,7 @@ function diffRows(patch: string): DiffRow[] {
type DiffState =
| { kind: 'loading' }
| { kind: 'unavailable' }
- | { kind: 'error'; message: string }
+ | { kind: 'error'; message: UiText }
| { kind: 'text'; rows: DiffRow[] }
| Exclude<GitDiff, { kind: 'text' }>;
export type CurrentDiffModel = {
@@ -116,7 +124,7 @@ export function createCurrentDiff(target: CurrentDiffTarget): CurrentDiffModel {
: result,
);
} catch (reason) {
- if (alive && reading === generation) state.set({ kind: 'error', message: String(reason) });
+ if (alive && reading === generation) state.set({ kind: 'error', message: errorText(reason) });
}
};
const model: CurrentDiffModel = {
@@ -139,17 +147,17 @@ export function CurrentDiff(props: { model: CurrentDiffModel }) {
const state = model.state();
switch (state.kind) {
case 'loading':
- return 'Reading current diff…';
+ return tr('Reading current diff…');
case 'unavailable':
- return 'Not a Git workspace.';
+ return tr('Not a Git workspace.');
case 'binary':
- return 'Binary file. A text diff is unavailable.';
+ return tr('Binary file. A text diff is unavailable.');
case 'unchanged':
- return 'No current changes in this area.';
+ return tr('No current changes in this area.');
case 'unsupported':
- return 'This change does not support a text diff.';
+ return tr('This change does not support a text diff.');
case 'error':
- return state.message;
+ return renderText(state.message);
case 'text':
return '';
}
@@ -165,9 +173,11 @@ export function CurrentDiff(props: { model: CurrentDiffModel }) {
{model.target.title}
</Text>
<Button
- accessibilityLabel="Refresh current diff"
+ accessibilityLabel={tr('Refresh current diff')}
onPress={inputAction(model.refresh, null)}
- tooltip={model.state().kind === 'error' ? 'Retry current diff' : 'Refresh current diff'}
+ tooltip={
+ model.state().kind === 'error' ? tr('Retry current diff') : tr('Refresh current diff')
+ }
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -176,7 +186,7 @@ export function CurrentDiff(props: { model: CurrentDiffModel }) {
</Button>
</View>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Current {gitAreaLabel[model.target.area].toLowerCase()} diff
+ {tr('Current {area} diff', { area: gitAreaLabel[model.target.area].toLowerCase() })}
</Text>
<If when={notice()}>
<Text
@@ -214,7 +224,7 @@ function DiffRows(props: { model: CurrentDiffModel }) {
});
return (
<List
- accessibilityLabel="Current file diff"
+ accessibilityLabel={tr('Current file diff')}
each={rows()}
keyExtractor={(row: DiffRow) => row.key}
estimatedItemSize={codeTextStyle.lineHeight}
@@ -225,7 +235,7 @@ function DiffRows(props: { model: CurrentDiffModel }) {
style={{ flex: 1, minHeight: 0, backgroundColor: C.surface }}
empty={
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- The current patch has no displayable rows.
+ {tr('The current patch has no displayable rows.')}
</Text>
}
>
diff --git a/products/maka/terminal-shell/app/CurrentFile.tsx b/products/maka/terminal-shell/app/CurrentFile.tsx
index bb6d84ae4e..00c551d060 100644
--- a/products/maka/terminal-shell/app/CurrentFile.tsx
+++ b/products/maka/terminal-shell/app/CurrentFile.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { createText, errorText, renderText, UiError, type UiText } from './text';
+import { tr } from './locale';
import { cleanup, computed, inputAction, Scroll, signal, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import { Clipboard } from 'nsh/clipboard';
@@ -42,7 +44,7 @@ type FileState =
| FileView
| { kind: 'idle' }
| { kind: 'loading'; previous: FileView | null }
- | { kind: 'error'; message: string; previous: FileView | null };
+ | { kind: 'error'; message: UiText; previous: FileView | null };
export type CurrentFileModel = {
target: WorkspaceFileTarget;
state(): FileState;
@@ -74,7 +76,6 @@ const captured = (state: FileState): FileView | null =>
: state.kind === 'loading' || state.kind === 'error'
? state.previous
: state;
-const message = (reason: unknown) => (reason instanceof Error ? reason.message : String(reason));
const close = async (resource: { stream: { close(): Promise<void> } | null } | null) => {
const stream = resource?.stream;
if (resource) resource.stream = null;
@@ -107,7 +108,7 @@ export function createCurrentFile(target: WorkspaceFileTarget): CurrentFileModel
stale.set(true);
state.set({
kind: 'error',
- message: message(reason),
+ message: errorText(reason),
previous: captured(state()),
});
};
@@ -154,7 +155,7 @@ export function createCurrentFile(target: WorkspaceFileTarget): CurrentFileModel
if (ownsRead(request, watch))
state.set({
kind: 'error',
- message: message(reason),
+ message: errorText(reason),
previous: captured(state()),
});
})
@@ -181,9 +182,9 @@ export function createCurrentFile(target: WorkspaceFileTarget): CurrentFileModel
for (;;) {
const change = await opened.next();
if (!ownsWatch(watch)) return;
- if (change === null) throw new Error('File updates ended. Retry to reconnect.');
+ if (change === null) throw new UiError('File updates ended. Retry to reconnect.');
if (change.kind === 'unavailable')
- throw new Error('The parent directory changed or is unavailable. Retry to reopen it.');
+ throw new UiError('The parent directory changed or is unavailable. Retry to reopen it.');
if (change.kind === 'ready') {
watch.ready = true;
read(watch);
@@ -240,7 +241,7 @@ export function CurrentFile(props: { model: CurrentFileModel; openImageViewer: O
version: string;
digest: string;
failed: boolean;
- message: string;
+ message: UiText;
} | null>(null);
let mounted = true;
cleanup(() => {
@@ -261,7 +262,7 @@ export function CurrentFile(props: { model: CurrentFileModel; openImageViewer: O
version: original.version,
digest: original.digest,
failed: false,
- message: 'File copied',
+ message: '',
});
} catch (reason) {
if (owns())
@@ -269,7 +270,7 @@ export function CurrentFile(props: { model: CurrentFileModel; openImageViewer: O
version: original.version,
digest: original.digest,
failed: true,
- message: message(reason),
+ message: errorText(reason),
});
} finally {
if (mounted) copying.set(false);
@@ -298,30 +299,41 @@ export function CurrentFile(props: { model: CurrentFileModel; openImageViewer: O
};
const error = () => {
const value = model.state();
- return value.kind === 'error' ? value.message : '';
+ return value.kind === 'error' ? renderText(value.message) : '';
};
const failed = () => model.state().kind === 'error';
const notice = () => {
const value = view();
switch (value?.kind) {
case 'text':
- return value.bytes === 0 ? 'Empty file.' : '';
+ return value.bytes === 0 ? tr('Empty file.') : '';
case 'absent':
- return 'This file no longer exists.';
+ return tr('This file no longer exists.');
case 'changed':
- return 'This file changed while it was being read. Refresh to capture it again.';
+ return tr('This file changed while it was being read. Refresh to capture it again.');
case 'binary':
- return `Binary file · ${value.bytes} bytes. A text preview is unavailable.`;
+ return tr('Binary file · {bytes} bytes. A text preview is unavailable.', {
+ bytes: value.bytes,
+ });
case 'unsupported':
- return `This ${value.fileKind === 'symlink' ? 'symbolic link' : value.fileKind} cannot be previewed.`;
+ return tr('This {kind} cannot be previewed.', {
+ kind:
+ value.fileKind === 'symlink'
+ ? tr('symbolic link')
+ : value.fileKind === 'directory'
+ ? tr('directory')
+ : value.fileKind === 'file'
+ ? tr('file')
+ : tr('other'),
+ });
default:
return '';
}
};
const emptyMessage = () => {
if (failed()) return error();
- if (model.state().kind === 'loading') return 'Reading current file…';
- if (model.state().kind === 'idle') return 'File preview is inactive.';
+ if (model.state().kind === 'loading') return tr('Reading current file…');
+ if (model.state().kind === 'idle') return tr('File preview is inactive.');
return notice();
};
return (
@@ -335,10 +347,18 @@ export function CurrentFile(props: { model: CurrentFileModel; openImageViewer: O
{model.target.title}
</Text>
<Button
- accessibilityLabel="Copy captured file"
+ accessibilityLabel={tr('Copy captured file')}
disabled={textCapture() === null}
onPress={copyAction()}
- tooltip={copying() ? 'Copying captured file…' : copied()?.message || 'Copy captured file'}
+ tooltip={
+ copying()
+ ? tr('Copying captured file…')
+ : copied()
+ ? copied()!.failed
+ ? renderText(copied()!.message)
+ : tr('File copied')
+ : tr('Copy captured file')
+ }
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -351,9 +371,11 @@ export function CurrentFile(props: { model: CurrentFileModel; openImageViewer: O
</Button>
<Button
disabled={model.state().kind === 'loading'}
- accessibilityLabel="Refresh current file"
+ accessibilityLabel={tr('Refresh current file')}
onPress={inputAction(model.refresh, null)}
- tooltip={model.state().kind === 'error' ? 'Retry current file' : 'Refresh current file'}
+ tooltip={
+ model.state().kind === 'error' ? tr('Retry current file') : tr('Refresh current file')
+ }
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -368,38 +390,40 @@ export function CurrentFile(props: { model: CurrentFileModel; openImageViewer: O
when={version()}
else={
<Text style={{ ...captionTextStyle, flex: 1, color: C.muted }}>
- Captured contents
+ {tr('Captured contents')}
</Text>
}
>
<Text
selectable
numberOfLines={1}
- accessibilityLabel={`Captured version ${version()}`}
- tooltip={`Captured version ${version()}`}
+ accessibilityLabel={tr('Captured version {version}', { version: version() })}
+ tooltip={tr('Captured version {version}', { version: version() })}
style={{ ...captionTextStyle, flex: 1, minWidth: 0, color: C.muted }}
>
- Captured version {version()}
+ {tr('Captured version {version}', { version: version() })}
</Text>
</If>
<If when={copied()}>
<Text
- accessibilityLabel={copied()!.message}
- tooltip={copied()!.message}
+ accessibilityLabel={
+ copied()!.failed ? renderText(copied()!.message) : tr('File copied')
+ }
+ tooltip={copied()!.failed ? renderText(copied()!.message) : tr('File copied')}
style={{
...captionTextStyle,
flexShrink: 0,
color: copied()!.failed ? C.danger : C.muted,
}}
>
- {copied()!.failed ? 'Copy failed' : 'File copied'}
+ {copied()!.failed ? tr('Copy failed') : tr('File copied')}
</Text>
</If>
</View>
</If>
<If when={!readable()}>
<Scroll
- accessibilityLabel="File preview status"
+ accessibilityLabel={tr('File preview status')}
onLayout={(frame) => emptyHeight.set(frame.size.height)}
style={{ flex: 1, minHeight: 0 }}
>
@@ -441,7 +465,7 @@ export function CurrentFile(props: { model: CurrentFileModel; openImageViewer: O
<Text
style={{ ...bodyTextStyle, width: '100%', color: C.warning, textAlign: 'center' }}
>
- The workspace may have changed. Refresh for current contents.
+ {tr('The workspace may have changed. Refresh for current contents.')}
</Text>
</If>
<If when={failed() || view()?.kind === 'changed'}>
@@ -468,7 +492,7 @@ export function CurrentFile(props: { model: CurrentFileModel; openImageViewer: O
function RetryCurrentFile(props: { model: CurrentFileModel }) {
return (
<Button
- accessibilityLabel="Retry current file"
+ accessibilityLabel={tr('Retry current file')}
disabled={props.model.state().kind === 'loading'}
onPress={inputAction(props.model.refresh, null)}
style={{
@@ -483,7 +507,7 @@ function RetryCurrentFile(props: { model: CurrentFileModel }) {
disabledStyle={controlDisabledStyle}
>
<Icon name="refresh" color={C.secondary} />
- <Text style={{ ...captionTextStyle, color: C.secondary }}>Retry</Text>
+ <Text style={{ ...captionTextStyle, color: C.secondary }}>{tr('Retry')}</Text>
</Button>
);
}
@@ -495,15 +519,15 @@ function CapturedFileStatus(props: { model: CurrentFileModel }) {
<View style={{ padding: 8, gap: 8, alignItems: 'flex-start' }}>
<If when={model.stale()}>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
- The workspace may have changed. Refresh for current contents.
+ {tr('The workspace may have changed. Refresh for current contents.')}
</Text>
</If>
<If when={model.state().kind === 'loading'}>
- <Text style={{ ...bodyTextStyle, color: C.muted }}>Reading current file…</Text>
+ <Text style={{ ...bodyTextStyle, color: C.muted }}>{tr('Reading current file…')}</Text>
</If>
<If when={model.state().kind === 'error'}>
<Text selectable style={{ ...bodyTextStyle, color: C.danger }}>
- {(model.state() as Extract<FileState, { kind: 'error' }>).message}
+ {renderText((model.state() as Extract<FileState, { kind: 'error' }>).message)}
</Text>
<RetryCurrentFile model={model} />
</If>
@@ -527,7 +551,7 @@ function FileRows(props: { model: CurrentFileModel; view: Extract<FileView, { ki
onScroll={(event) => {
if (mounted) props.model.position = { x: event.offset.x, y: event.offset.y };
}}
- accessibilityLabel="Captured file text"
+ accessibilityLabel={tr('Captured file text')}
style={{ flex: 1, minHeight: 0, backgroundColor: C.surface }}
header={<CapturedFileStatus model={props.model} />}
>
@@ -572,7 +596,7 @@ function FileImage(props: {
view: Extract<FileView, { kind: 'image' }>;
openImageViewer: OpenImageViewer;
}) {
- const error = signal('');
+ const error = createText();
const dimensions = signal<{ width: number; height: number } | null>(null);
const viewportHeight = signal(0);
const footerHeight = signal(0);
@@ -596,7 +620,7 @@ function FileImage(props: {
onError={(reason) => {
if (mounted) error.set(reason.message);
}}
- accessibilityLabel="Captured workspace image"
+ accessibilityLabel={tr('Captured workspace image')}
style={{ flex: 1, minHeight: 0, width: '100%' }}
/>
</View>
@@ -604,7 +628,10 @@ function FileImage(props: {
<If when={dimensions()}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ ...captionTextStyle, flex: 1, color: C.muted }}>
- {dimensions()!.width} × {dimensions()!.height} · Captured image
+ {tr('{width} × {height} · Captured image', {
+ width: dimensions()!.width,
+ height: dimensions()!.height,
+ })}
</Text>
<ImageViewerButton
open={props.openImageViewer}
@@ -631,7 +658,7 @@ function FileImage(props: {
disabledStyle={controlDisabledStyle}
>
<Icon name="refresh" color={C.secondary} />
- <Text style={{ ...captionTextStyle, color: C.secondary }}>Retry image</Text>
+ <Text style={{ ...captionTextStyle, color: C.secondary }}>{tr('Retry image')}</Text>
</Button>
</If>
</View>
diff --git a/products/maka/terminal-shell/app/FinalActions.tsx b/products/maka/terminal-shell/app/FinalActions.tsx
index f959c4eec3..55f109c112 100644
--- a/products/maka/terminal-shell/app/FinalActions.tsx
+++ b/products/maka/terminal-shell/app/FinalActions.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { createText, UiError, errorText, message as uiMessage } from './text';
+import { tr } from './locale';
import { cleanup, computed, inputAction, signal, Text, View } from 'nsh';
import type { YNode } from 'nsh';
import { Button } from 'nsh/base';
@@ -55,7 +57,7 @@ export function FinalActions(props: {
fork(action: Extract<SessionAction, { kind: 'fork' }>): Promise<void>;
}) {
const pending = signal(false);
- const notice = signal('');
+ const notice = createText();
const usage = signal<Target | null>(null);
let usageButton: YNode | undefined;
let alive = true;
@@ -90,16 +92,16 @@ export function FinalActions(props: {
});
if (!owns(original)) return;
if (observed.session.id !== original.sessionId)
- throw new Error('The Host observed a different conversation.');
+ throw new UiError('The Host observed a different conversation.');
await props.fork({
kind: 'fork',
session: observed.session,
title: forkTitle(observed.session.title),
turnId: original.turnId,
});
- if (owns(original)) notice.set('Fork created. Open it in Sessions.');
+ if (owns(original)) notice.set(uiMessage('Fork created. Open it in Sessions.'));
} catch (reason) {
- if (owns(original)) notice.set(reason instanceof Error ? reason.message : String(reason));
+ if (owns(original)) notice.set(errorText(reason));
} finally {
if (alive) pending.set(false);
}
@@ -148,8 +150,8 @@ export function FinalActions(props: {
/>
<Button
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
- accessibilityLabel="Fork from this turn"
- tooltip={pending() ? 'Creating fork…' : 'Fork from this turn'}
+ accessibilityLabel={tr('Fork from this turn')}
+ tooltip={pending() ? tr('Creating fork…') : tr('Fork from this turn')}
disabled={pending() || target() === null}
onPress={inputAction(fork, target())}
hoverStyle={{ backgroundColor: C.surface }}
@@ -162,8 +164,8 @@ export function FinalActions(props: {
usageButton = node;
}}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
- accessibilityLabel="View this turn's model usage"
- tooltip="View this turn's model usage"
+ accessibilityLabel={tr("View this turn's model usage")}
+ tooltip={tr("View this turn's model usage")}
disabled={target() === null}
onPress={inputAction(showUsage, target())}
hoverStyle={{ backgroundColor: C.surface }}
diff --git a/products/maka/terminal-shell/app/FormRequest.tsx b/products/maka/terminal-shell/app/FormRequest.tsx
index 1c168f2a7e..af15e82cb1 100644
--- a/products/maka/terminal-shell/app/FormRequest.tsx
+++ b/products/maka/terminal-shell/app/FormRequest.tsx
@@ -22,6 +22,8 @@ import { Button } from 'nsh/base';
import type { FormField } from '../../../../packages/plugin-sdk/src/generated/runtime';
import type { FormDraft, FormRequest as Request } from './interaction-form';
import { ChoiceIndicator } from './ChoiceIndicator';
+import { tr } from './locale';
+import { renderText, type UiText } from './text';
import {
bodyTextStyle,
C,
@@ -75,7 +77,7 @@ function Choice(props: {
export function FormRequest(props: {
request: Request;
drafts: FormDraft[];
- problems: Array<string | null>;
+ problems: Array<UiText | null>;
blocked: boolean;
action: (index: number, change: Change) => () => void;
edit: (index: number, original: string, value: string) => void;
@@ -105,10 +107,10 @@ export function FormRequest(props: {
const placeholder = () => {
const current = field();
return current.kind === 'string'
- ? (current.format ?? 'Text')
+ ? (current.format ?? tr('Text'))
: current.kind === 'integer'
- ? 'Whole number'
- : 'Number';
+ ? tr('Whole number')
+ : tr('Number');
};
const options = () => {
const current = field();
@@ -124,13 +126,17 @@ export function FormRequest(props: {
{field().label}
</Text>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- {field().required ? 'Required' : 'Optional'}
+ {field().required ? tr('Required') : tr('Optional')}
</Text>
</View>
<If when={!field().required}>
<Choice
- label={draft()?.included ? `Omit ${field().label}` : `Include ${field().label}`}
- text={draft()?.included ? 'Included' : 'Include'}
+ label={
+ draft()?.included
+ ? tr('Omit {label}', { label: field().label })
+ : tr('Include {label}', { label: field().label })
+ }
+ text={draft()?.included ? tr('Included') : tr('Include')}
kind="switch"
disabled={props.blocked}
selected={draft()?.included}
@@ -170,8 +176,12 @@ export function FormRequest(props: {
<If when={field().kind === 'string'}>
<View style={{ alignItems: 'flex-end' }}>
<Choice
- label={`${draft()?.hidden ? 'Reveal' : 'Hide'} ${field().label}`}
- text={draft()?.hidden ? 'Reveal value' : 'Hide value'}
+ label={
+ draft()?.hidden
+ ? tr('Reveal {label}', { label: field().label })
+ : tr('Hide {label}', { label: field().label })
+ }
+ text={draft()?.hidden ? tr('Reveal value') : tr('Hide value')}
disabled={props.blocked}
onPress={props.action(index(), { hidden: !draft()?.hidden })}
/>
@@ -183,8 +193,11 @@ export function FormRequest(props: {
<Each of={[true, false]}>
{(value) => (
<Choice
- label={`${value() ? 'Yes' : 'No'} · ${field().label}`}
- text={value() ? 'Yes' : 'No'}
+ label={tr('{answer} · {label}', {
+ answer: value() ? tr('Yes') : tr('No'),
+ label: field().label,
+ })}
+ text={value() ? tr('Yes') : tr('No')}
kind="radio"
disabled={disabled()}
selected={draft()?.value === value()}
@@ -227,7 +240,7 @@ export function FormRequest(props: {
</If>
<If when={props.problems[index()]}>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
- {props.problems[index()]}
+ {renderText(props.problems[index()]!)}
</Text>
</If>
</If>
diff --git a/products/maka/terminal-shell/app/GeneralSettings.tsx b/products/maka/terminal-shell/app/GeneralSettings.tsx
index 339ebe366d..9a14933ce1 100644
--- a/products/maka/terminal-shell/app/GeneralSettings.tsx
+++ b/products/maka/terminal-shell/app/GeneralSettings.tsx
@@ -18,6 +18,7 @@
*/
import { inputAction, Scroll, Text, View } from 'nsh';
+import { tr } from './locale';
import { Button } from 'nsh/base';
import {
assistantLanguages,
@@ -43,7 +44,7 @@ export function GeneralSettings(props: { owner: GeneralSettingsOwner }) {
<Scroll style={{ flex: 1, minHeight: 0 }}>
<View style={{ padding: 8, gap: 16 }}>
<If when={owner.loading()}>
- <Text style={{ ...bodyTextStyle, color: C.muted }}>Reading preferences…</Text>
+ <Text style={{ ...bodyTextStyle, color: C.muted }}>{tr('Reading preferences…')}</Text>
</If>
<Each of={[owner.action()]} key={(request) => JSON.stringify(request)}>
{(action) => {
@@ -65,32 +66,34 @@ function GeneralRoot(props: { owner: GeneralSettingsOwner; request: GeneralActio
<Button
disabled={owner.loading() || owner.busy()}
onPress={inputAction(owner.reload, request)}
- accessibilityLabel="Refresh general preferences"
+ accessibilityLabel={tr('Refresh general preferences')}
focusStyle={controlFocusStyle}
style={{ ...controlStyle, alignSelf: 'flex-start' }}
hoverStyle={controlHoverStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...bodyTextStyle, color: C.secondary }}>Refresh preferences</Text>
+ <Text style={{ ...bodyTextStyle, color: C.secondary }}>{tr('Refresh preferences')}</Text>
</Button>
<If when={owner.facts()}>
<View style={{ gap: 8 }}>
<Text style={{ ...bodyTextStyle, color: C.text, fontWeight: '600' }}>
- Assistant language
+ {tr('Language')}
</Text>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Language used by the assistant. The interface remains English.
+ {tr('Language used by the interface and assistant.')}
</Text>
<If when={owner.facts()?.language == null}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Using the default language.</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>
+ {tr('Using the default language.')}
+ </Text>
</If>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
- <Each of={assistantLanguages} key={(language) => language.value}>
+ <Each of={assistantLanguages()} key={(language) => language.value}>
{(language) => (
<Button
- selected={owner.facts()?.language === language().value}
+ selected={(owner.facts()?.language ?? 'auto') === language().value}
disabled={owner.disabled()}
- accessibilityLabel={`Assistant language: ${language().label}`}
+ accessibilityLabel={tr('Language: {language}', { language: language().label })}
onPress={inputAction(owner.language, { request, preference: language().value })}
style={{
...controlStyle,
@@ -106,13 +109,16 @@ function GeneralRoot(props: { owner: GeneralSettingsOwner; request: GeneralActio
>
<ChoiceIndicator
kind="radio"
- checked={owner.facts()?.language === language().value}
+ checked={(owner.facts()?.language ?? 'auto') === language().value}
disabled={owner.disabled()}
/>
<Text
style={{
...bodyTextStyle,
- color: owner.facts()?.language === language().value ? C.accent : C.text,
+ color:
+ (owner.facts()?.language ?? 'auto') === language().value
+ ? C.accent
+ : C.text,
}}
>
{language().label}
@@ -127,28 +133,28 @@ function GeneralRoot(props: { owner: GeneralSettingsOwner; request: GeneralActio
owner={owner}
request={request}
kind="instructions"
- label="Workspace instructions"
+ label={tr('Workspace instructions')}
enabled={owner.facts()?.workspaceInstructions ?? false}
/>
<PreferenceToggle
owner={owner}
request={request}
kind="privacy"
- label="Incognito mode"
+ label={tr('Incognito mode')}
enabled={owner.facts()?.incognitoActive ?? false}
/>
<PreferenceToggle
owner={owner}
request={request}
kind="titles"
- label="Automatic shell titles"
+ label={tr('Automatic shell titles')}
enabled={owner.facts()?.shellTitles ?? false}
/>
</View>
<Button
disabled={owner.disabled()}
onPress={inputAction(owner.open, { request, kind: 'personalization' })}
- accessibilityLabel="Edit personalization"
+ accessibilityLabel={tr('Edit personalization')}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
style={{
@@ -161,9 +167,9 @@ function GeneralRoot(props: { owner: GeneralSettingsOwner; request: GeneralActio
disabledStyle={controlDisabledStyle}
>
<View pointerEvents="none" style={{ flex: 1, minWidth: 0, gap: 4 }}>
- <Text style={{ ...bodyTextStyle, color: C.text }}>Personalization</Text>
+ <Text style={{ ...bodyTextStyle, color: C.text }}>{tr('Personalization')}</Text>
<Text numberOfLines={1} style={{ ...captionTextStyle, color: C.muted }}>
- {owner.facts()?.displayName || 'Display name and assistant tone'}
+ {owner.facts()?.displayName || tr('Display name and assistant tone')}
</Text>
</View>
<Icon name="right" color={C.muted} />
@@ -171,7 +177,7 @@ function GeneralRoot(props: { owner: GeneralSettingsOwner; request: GeneralActio
<Button
disabled={owner.disabled()}
onPress={inputAction(owner.open, { request, kind: 'compaction' })}
- accessibilityLabel="Edit compaction prompt"
+ accessibilityLabel={tr('Edit compaction prompt')}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
style={{
@@ -184,9 +190,9 @@ function GeneralRoot(props: { owner: GeneralSettingsOwner; request: GeneralActio
disabledStyle={controlDisabledStyle}
>
<View pointerEvents="none" style={{ flex: 1, minWidth: 0, gap: 4 }}>
- <Text style={{ ...bodyTextStyle, color: C.text }}>Compaction prompt</Text>
+ <Text style={{ ...bodyTextStyle, color: C.text }}>{tr('Compaction prompt')}</Text>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Instructions for summarizing conversation context.
+ {tr('Instructions for summarizing conversation context.')}
</Text>
</View>
<Icon name="right" color={C.muted} />
@@ -207,7 +213,10 @@ function PreferenceToggle(props: {
<Button
selected={props.enabled}
disabled={props.owner.disabled()}
- accessibilityLabel={`${props.label}: ${props.enabled ? 'On' : 'Off'}`}
+ accessibilityLabel={tr('{label}: {state}', {
+ label: props.label,
+ state: props.enabled ? tr('On') : tr('Off'),
+ })}
onPress={inputAction(props.owner.toggle, { request: props.request, kind: props.kind })}
style={{
...controlStyle,
@@ -244,27 +253,27 @@ function PreferenceEditor(props: { owner: GeneralSettingsOwner; request: General
return (
<View style={{ gap: 16 }}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Changes apply when you save this page.
+ {tr('Changes apply when you save this page.')}
</Text>
<If when={request.kind === 'personalization'}>
<SettingsField
- label="Display name"
+ label={tr('Display name')}
value={name()}
change={(text) => owner.editName(request, 'displayName', text)}
disabled={owner.disabled()}
/>
<SettingsField
- label="Assistant tone"
+ label={tr('Assistant tone')}
value={tone()}
change={(text) => owner.editName(request, 'assistantTone', text)}
disabled={owner.disabled()}
multiline
- hint="How the assistant should respond."
+ hint={tr('How the assistant should respond.')}
/>
</If>
<If when={request.kind === 'compaction'}>
<SettingsField
- label="Compaction prompt"
+ label={tr('Compaction prompt')}
value={prompt()}
change={(text) => owner.editPrompt(request, text)}
disabled={owner.disabled()}
@@ -272,38 +281,40 @@ function PreferenceEditor(props: { owner: GeneralSettingsOwner; request: General
/>
<Button
disabled={owner.disabled()}
- accessibilityLabel="Use Host default"
+ accessibilityLabel={tr('Use Host default')}
onPress={inputAction(owner.useDefault, request)}
focusStyle={controlFocusStyle}
style={controlStyle}
hoverStyle={controlHoverStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...bodyTextStyle, color: C.secondary }}>Use default prompt</Text>
+ <Text style={{ ...bodyTextStyle, color: C.secondary }}>{tr('Use default prompt')}</Text>
</Button>
</If>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
<Button
disabled={owner.disabled() || !owner.dirty()}
onPress={inputAction(owner.save, request)}
- accessibilityLabel="Save this preference"
+ accessibilityLabel={tr('Save this preference')}
focusStyle={{ backgroundColor: C.accentHover }}
style={{ ...controlStyle, alignSelf: 'flex-start', backgroundColor: C.accent }}
hoverStyle={{ backgroundColor: C.accentHover }}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...bodyTextStyle, color: C.background, fontWeight: '500' }}>Save</Text>
+ <Text style={{ ...bodyTextStyle, color: C.background, fontWeight: '500' }}>
+ {tr('Save')}
+ </Text>
</Button>
<Button
disabled={owner.busy()}
- accessibilityLabel="Cancel"
+ accessibilityLabel={tr('Cancel')}
onPress={inputAction(owner.cancel, request)}
focusStyle={controlFocusStyle}
style={controlStyle}
hoverStyle={controlHoverStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...bodyTextStyle, color: C.secondary }}>Cancel</Text>
+ <Text style={{ ...bodyTextStyle, color: C.secondary }}>{tr('Cancel')}</Text>
</Button>
</View>
</View>
diff --git a/products/maka/terminal-shell/app/ImageThumbnail.tsx b/products/maka/terminal-shell/app/ImageThumbnail.tsx
index 8c848bc7ed..0a2f90848c 100644
--- a/products/maka/terminal-shell/app/ImageThumbnail.tsx
+++ b/products/maka/terminal-shell/app/ImageThumbnail.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { createText } from './text';
import { cleanup, inputAction, signal, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import { windowSize } from 'nsh/device';
@@ -25,6 +26,7 @@ import type { YNode } from 'nsh';
import { C, captionTextStyle, controlStyle } from './ui';
import { Icon } from './Icon';
import { immutableAsset } from './immutableAsset';
+import { tr } from './locale';
import type {
AgentToolImageRead,
AgentAttachmentImageRead,
@@ -55,7 +57,7 @@ export function ImageThumbnail(props: {
: immutableAsset('attachment-image', source.read);
const action = Object.freeze({ sessionId: source.read.sessionId, uri });
const size = signal<{ width: number; height: number } | null>(null);
- const error = signal('');
+ const error = createText();
const attempt = signal(0);
let alive = true;
let button: YNode | undefined;
@@ -96,15 +98,15 @@ export function ImageThumbnail(props: {
ref={(node) => {
button = node;
}}
- accessibilityLabel={props.label ?? 'Open image preview'}
+ accessibilityLabel={props.label ?? tr('Open image preview')}
tooltip={
props.compact
- ? `${props.tooltip ?? props.label ?? 'Open image preview'}\n${
+ ? `${props.tooltip ?? props.label ?? tr('Open image preview')}\n${
size()
? `${size()!.width} × ${size()!.height}`
: error()
- ? 'Image unavailable'
- : 'Loading image…'
+ ? tr('Image unavailable')
+ : tr('Loading image…')
}`
: undefined
}
@@ -136,10 +138,13 @@ export function ImageThumbnail(props: {
<If when={!props.compact}>
<Text style={{ ...captionTextStyle, color: size() ? C.accent : C.muted }}>
{size()
- ? `Open image · ${size()!.width} × ${size()!.height}`
+ ? tr('Open image · {width} × {height}', {
+ width: size()!.width,
+ height: size()!.height,
+ })
: error()
- ? 'Image unavailable'
- : 'Loading image…'}
+ ? tr('Image unavailable')
+ : tr('Loading image…')}
</Text>
</If>
<Each of={[attempt()]} key={(value: number) => value}>
@@ -193,9 +198,9 @@ export function ImageThumbnail(props: {
>
<If when={props.compact}>
<Text selectable style={{ ...captionTextStyle, color: C.text }}>
- {props.tooltip ?? props.label ?? 'Image'}
+ {props.tooltip ?? props.label ?? tr('Image')}
</Text>
- <Text style={{ ...captionTextStyle, color: C.danger }}>Image unavailable</Text>
+ <Text style={{ ...captionTextStyle, color: C.danger }}>{tr('Image unavailable')}</Text>
</If>
<Text selectable style={{ color: C.danger }}>
{error()}
@@ -206,7 +211,7 @@ export function ImageThumbnail(props: {
hoverStyle={{ backgroundColor: C.raised }}
focusStyle={{ backgroundColor: C.line }}
>
- <Text style={{ color: C.accent }}>Retry image</Text>
+ <Text style={{ color: C.accent }}>{tr('Retry image')}</Text>
</Button>
</View>
</If>
diff --git a/products/maka/terminal-shell/app/ImageViewer.tsx b/products/maka/terminal-shell/app/ImageViewer.tsx
index e6dcce8beb..c0d904ff76 100644
--- a/products/maka/terminal-shell/app/ImageViewer.tsx
+++ b/products/maka/terminal-shell/app/ImageViewer.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { createText } from './text';
import { cleanup, inputAction, signal, Text, View } from 'nsh';
import type { YNode } from 'nsh';
import { createAnimatedValue } from 'nsh/animated';
@@ -24,6 +25,7 @@ import { Button, Dialog } from 'nsh/base';
import { Image } from 'nsh/image';
import { keymap } from 'nsh/keymap';
import { Icon } from './Icon';
+import { tr } from './locale';
import { C, captionTextStyle, controlStyle, controlHoverStyle, controlFocusStyle } from './ui';
export type ViewerImage = { uri: string; width: number; height: number; title: string };
@@ -53,8 +55,8 @@ export function ImageViewerButton(props: { image: ViewerImage; open: OpenImageVi
ref={(node) => {
button = node;
}}
- accessibilityLabel="Open image full screen"
- tooltip="Open full screen"
+ accessibilityLabel={tr('Open image full screen')}
+ tooltip={tr('Open full screen')}
onPress={inputAction(show, image)}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
@@ -68,7 +70,7 @@ export function ImageViewerButton(props: { image: ViewerImage; open: OpenImageVi
export function ImageViewer(props: { image: ViewerImage; close(): void }) {
const image = props.image;
const viewport = signal({ width: 0, height: 0 });
- const error = signal('');
+ const error = createText();
const scale = createAnimatedValue(1);
const x = createAnimatedValue(0);
const y = createAnimatedValue(0);
@@ -122,11 +124,11 @@ export function ImageViewer(props: { image: ViewerImage; close(): void }) {
<Dialog animation="none" onOpenChange={props.close} style={{ flex: 1 }}>
<View
ref={bind}
- accessibilityLabel="Full screen image viewer"
+ accessibilityLabel={tr('Full screen image viewer')}
style={{ flex: 1, minWidth: 0, minHeight: 0, backgroundColor: '#000000b3' }}
>
<View
- accessibilityLabel="Image viewport"
+ accessibilityLabel={tr('Image viewport')}
pan={{ x, y }}
pinch={{ scale }}
onLayout={(event) => {
@@ -189,8 +191,8 @@ export function ImageViewer(props: { image: ViewerImage; close(): void }) {
</If>
</View>
<Button
- accessibilityLabel="Close full screen image"
- tooltip="Close · Esc"
+ accessibilityLabel={tr('Close full screen image')}
+ tooltip={tr('Close · Esc')}
onPress={props.close}
style={{
...controlStyle,
@@ -224,17 +226,25 @@ export function ImageViewer(props: { image: ViewerImage; close(): void }) {
>
<Each
of={[
- { kind: 'fit' as const, label: 'Fit image to window', caption: 'Fit' },
- { kind: 'actual' as const, label: 'Show image at actual size', caption: '100%' },
- { kind: 'out' as const, label: 'Zoom image out', caption: '−' },
- { kind: 'in' as const, label: 'Zoom image in', caption: '+' },
+ {
+ kind: 'fit' as const,
+ label: () => tr('Fit image to window'),
+ caption: () => tr('Fit'),
+ },
+ {
+ kind: 'actual' as const,
+ label: () => tr('Show image at actual size'),
+ caption: () => '100%',
+ },
+ { kind: 'out' as const, label: () => tr('Zoom image out'), caption: () => '−' },
+ { kind: 'in' as const, label: () => tr('Zoom image in'), caption: () => '+' },
]}
key={(item) => item.kind}
>
{(item) => (
<Button
- accessibilityLabel={item().label}
- tooltip={item().label}
+ accessibilityLabel={item().label()}
+ tooltip={item().label()}
onPress={inputAction(control, { uri: image.uri, kind: item().kind })}
style={{
...controlStyle,
@@ -247,7 +257,7 @@ export function ImageViewer(props: { image: ViewerImage; close(): void }) {
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
>
- <Text style={{ ...captionTextStyle, color: C.text }}>{item().caption}</Text>
+ <Text style={{ ...captionTextStyle, color: C.text }}>{item().caption()}</Text>
</Button>
)}
</Each>
diff --git a/products/maka/terminal-shell/app/InteractionComposer.tsx b/products/maka/terminal-shell/app/InteractionComposer.tsx
index 01c5e92ab6..f3e3755a81 100644
--- a/products/maka/terminal-shell/app/InteractionComposer.tsx
+++ b/products/maka/terminal-shell/app/InteractionComposer.tsx
@@ -17,6 +17,15 @@
* under the License.
*/
+import {
+ createText,
+ UiError,
+ errorText,
+ renderText,
+ type UiText,
+ message as uiMessage,
+} from './text';
+import { tr } from './locale';
import { cleanup, effect, inputAction, Input, signal, Scroll, Text, View } from 'nsh';
import type { YNode } from 'nsh';
import { host } from './host';
@@ -126,34 +135,48 @@ function InteractionAction(props: {
function interactionPermissions(value: Permissions): string {
const paths = value.filesystem.map(
(rule) =>
- `${rule.access === 'write' ? 'Read/write' : 'Read'} · ${rule.scope === 'exact' ? 'this exact path' : 'path and descendants'}\n ${rule.path}`,
+ `${tr(rule.access === 'write' ? 'Read/write' : 'Read')} · ${tr(rule.scope === 'exact' ? 'this exact path' : 'path and descendants')}\n ${rule.path}`,
);
const network =
value.network === 'allowed'
- ? 'Network: all destinations'
+ ? tr('Network: all destinations')
: value.network === 'denied'
- ? 'Network: no additional access'
- : `Network: listed destinations only\n${value.network.restricted.destinations.map((target) => ` ${target.host}:${target.port}`).join('\n')}`;
- return [...(paths.length ? paths : ['Filesystem: no additional access']), network].join('\n\n');
+ ? tr('Network: no additional access')
+ : `${tr('Network: listed destinations only')}\n${value.network.restricted.destinations.map((target) => ` ${target.host}:${target.port}`).join('\n')}`;
+ return [...(paths.length ? paths : [tr('Filesystem: no additional access')]), network].join(
+ '\n\n',
+ );
}
-export function interactionOutcomeSummary(outcome: InteractionOutcome): string {
+export function interactionOutcomeSummary(outcome: InteractionOutcome): UiText {
if (outcome.kind === 'form_answer')
return outcome.action === 'accept'
- ? `Form submitted · ${Object.keys(outcome.values).length} fields`
+ ? uiMessage('Form submitted · {count} fields', { count: Object.keys(outcome.values).length })
: outcome.action === 'decline'
- ? 'Form declined'
- : 'Form cancelled';
- if (outcome.kind === 'question_answer') return 'Answers submitted';
+ ? uiMessage('Form declined')
+ : uiMessage('Form cancelled');
+ if (outcome.kind === 'question_answer') return uiMessage('Answers submitted');
if (outcome.kind === 'permissions_decision')
return outcome.decision.decision === 'allow'
- ? `Access allowed · ${outcome.decision.scope === 'once' ? 'This call' : outcome.decision.scope === 'turn' ? 'This turn' : 'This session'}`
- : 'Access denied';
+ ? uiMessage('Access allowed · {scope}', {
+ scope: uiMessage(
+ outcome.decision.scope === 'once'
+ ? 'This call'
+ : outcome.decision.scope === 'turn'
+ ? 'This turn'
+ : 'This session',
+ ),
+ })
+ : uiMessage('Access denied');
if (outcome.kind === 'service_approval_decision')
- return outcome.decision === 'allow' ? 'Tool call allowed' : 'Tool call denied';
+ return outcome.decision === 'allow'
+ ? uiMessage('Tool call allowed')
+ : uiMessage('Tool call denied');
if (outcome.kind === 'client_capability_decision')
- return outcome.decision === 'allow' ? 'Capability allowed' : 'Capability denied';
- return `Request closed · ${outcome.reason.replaceAll('_', ' ')}`;
+ return outcome.decision === 'allow'
+ ? uiMessage('Capability allowed')
+ : uiMessage('Capability denied');
+ return uiMessage('Request closed · {reason}', { reason: outcome.reason.replaceAll('_', ' ') });
}
export function InteractionComposer({
@@ -174,7 +197,7 @@ export function InteractionComposer({
busy = signal(false),
uncertain = signal(false);
const pending = signal(false),
- error = signal('');
+ error = createText();
const scope = signal<InteractionScope>('once');
const answers = signal<Array<{ other: boolean; value: string | null | undefined }>>([]);
const formDrafts = signal<FormDraft[]>([]);
@@ -182,7 +205,7 @@ export function InteractionComposer({
const argumentsText = signal(''),
argumentTotal = signal<number | null>(null);
const argumentBusy = signal(false),
- argumentError = signal('');
+ argumentError = createText();
let argumentPosition: HistoryRowPosition | null = null;
let alive = true;
cleanup(() => {
@@ -361,7 +384,7 @@ export function InteractionComposer({
end > source.totalBytes ||
(end === offset && end !== source.totalBytes)
) {
- throw new Error(
+ throw new UiError(
'The original call arguments could not be verified. Allow remains disabled.',
);
}
@@ -369,7 +392,7 @@ export function InteractionComposer({
argumentTotal.set(source.totalBytes);
argumentsText.set(argumentsText() + source.text);
} catch (reason) {
- if (alive) argumentError.set(String(reason));
+ if (alive) argumentError.set(errorText(reason));
} finally {
if (alive) {
argumentBusy.set(false);
@@ -393,7 +416,7 @@ export function InteractionComposer({
originalPending(),
]);
if (!alive) return;
- if (!sameRecord(canonical)) throw new Error('The Host returned a different request.');
+ if (!sameRecord(canonical)) throw new UiError('The Host returned a different request.');
record.set(canonical);
uncertain.set(unresolved);
pending.set(list.entries.some((entry) => entry.requestId === requestId));
@@ -420,7 +443,7 @@ export function InteractionComposer({
} catch (reason) {
if (alive) {
uncertain.set(true);
- error.set(String(reason));
+ error.set(errorText(reason));
}
} finally {
if (alive) loading.set(false);
@@ -443,13 +466,17 @@ export function InteractionComposer({
if (result.record.request.kind === 'form') formDrafts.set([]);
} else if (result?.kind === 'rejected') {
error.set(
- `Answer was not accepted: ${result.reason}. Refresh the request before trying again.`,
+ uiMessage('Answer was not accepted: {reason}. Refresh the request before trying again.', {
+ reason: result.reason,
+ }),
);
pending.set(false);
} else {
uncertain.set(true);
error.set(
- 'Answer status is unknown. Recover its original receipt in the main view; do not submit again.',
+ uiMessage(
+ 'Answer status is unknown. Recover its original receipt in the main view; do not submit again.',
+ ),
);
}
} catch (reason) {
@@ -463,8 +490,11 @@ export function InteractionComposer({
uncertain.set(unresolved);
error.set(
unresolved
- ? `Answer status is unknown. Recover its original receipt in the main view. ${String(reason)}`
- : String(reason),
+ ? uiMessage(
+ 'Answer status is unknown. Recover its original receipt in the main view. {reason}',
+ { reason: errorText(reason) },
+ )
+ : errorText(reason),
);
}
} finally {
@@ -496,11 +526,11 @@ export function InteractionComposer({
const submit = () => {
if (blocked()) return;
if (request()?.kind === 'question' && !questionReady()) {
- error.set('Answer every question or explicitly choose Skip.');
+ error.set(uiMessage('Answer every question or explicitly choose Skip.'));
return;
}
if (request()?.kind === 'form' && !form().answer) {
- error.set('Complete the included fields before submitting.');
+ error.set(uiMessage('Complete the included fields before submitting.'));
return;
}
const answer = submissionAnswer();
@@ -587,18 +617,18 @@ export function InteractionComposer({
const current = request();
return current
? {
- service_approval: 'Review tool call',
- permissions: 'Review additional access',
+ service_approval: tr('Review tool call'),
+ permissions: tr('Review additional access'),
question: 'A question for you',
- form: 'Form request',
- client_capability: 'Capability request',
+ form: tr('Form request'),
+ client_capability: tr('Capability request'),
}[current.kind]
- : 'Review request';
+ : tr('Review request');
};
return (
<View
ref={attach}
- accessibilityLabel="Pending request"
+ accessibilityLabel={tr('Pending request')}
style={{
minHeight: 0,
flexShrink: 1,
@@ -635,13 +665,13 @@ export function InteractionComposer({
</Text>
</View>
<Scroll
- accessibilityLabel="Request content"
+ accessibilityLabel={tr('Request content')}
style={{ flexShrink: 1, minHeight: 0, maxHeight: 336 }}
contentStyle={{ padding: 16, gap: 16 }}
>
<If when={!record()}>
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- {loading() ? 'Reading the original request…' : 'Request not available.'}
+ {loading() ? tr('Reading the original request…') : tr('Request not available.')}
</Text>
</If>
<If
@@ -658,24 +688,31 @@ export function InteractionComposer({
</Text>
</View>
<Text style={{ ...bodyTextStyle, color: C.secondary }}>
- Approve this original call only. This does not grant access to future calls.
+ {tr(
+ 'Approve this original call only. This does not grant access to future calls.',
+ )}
</Text>
<View
style={{ gap: 8, padding: 12, backgroundColor: C.background, borderRadius: 8 }}
>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Original arguments</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>
+ {tr('Original arguments')}
+ </Text>
<Text selectable style={{ ...codeTextStyle, color: C.text }}>
{argumentsText()}
</Text>
</View>
<Text style={{ ...captionTextStyle, color: C.muted }}>
{completeArguments()
- ? 'Complete original arguments loaded.'
- : `${interactionBytes(argumentsText())} of ${argumentTotal() ?? '?'} argument bytes loaded. Read all before allowing.`}
+ ? tr('Complete original arguments loaded.')
+ : tr('{loaded} of {total} argument bytes loaded. Read all before allowing.', {
+ loaded: interactionBytes(argumentsText()),
+ total: argumentTotal() ?? '?',
+ })}
</Text>
<If when={argumentError()}>
<InteractionAction
- label="Retry original arguments"
+ label={tr('Retry original arguments')}
icon="refresh"
disabled={argumentBusy()}
onPress={inputAction(readRenderedArguments, {
@@ -704,12 +741,16 @@ export function InteractionComposer({
{permissionsRequest()?.request.command?.command}
</Text>
<Text selectable style={{ ...captionTextStyle, color: C.muted }}>
- {`Working directory: ${permissionsRequest()?.request.command?.cwd}`}
+ {tr('Working directory: {path}', {
+ path: permissionsRequest()?.request.command?.cwd ?? '',
+ })}
</Text>
</View>
</If>
<View style={{ gap: 8 }}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Requested access</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>
+ {tr('Requested access')}
+ </Text>
<Text selectable style={{ ...bodyTextStyle, color: C.text }}>
{permissionsRequest()
? interactionPermissions(permissionsRequest()!.request.permissions)
@@ -718,7 +759,7 @@ export function InteractionComposer({
</View>
<View style={{ gap: 8 }}>
<Text style={{ ...bodyTextStyle, color: C.secondary }}>
- Allow exactly these paths and network destinations for:
+ {tr('Allow exactly these paths and network destinations for:')}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
<Each
@@ -732,10 +773,10 @@ export function InteractionComposer({
<InteractionAction
label={
duration() === 'once'
- ? 'This call'
+ ? tr('This call')
: duration() === 'turn'
- ? 'This turn'
- : 'This session'
+ ? tr('This turn')
+ : tr('This session')
}
kind="radio"
disabled={blocked()}
@@ -810,7 +851,7 @@ export function InteractionComposer({
</Each>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
<InteractionAction
- label="Other answer"
+ label={tr('Other answer')}
kind="radio"
disabled={blocked()}
selected={answers()[index()]?.other}
@@ -824,7 +865,7 @@ export function InteractionComposer({
})}
/>
<InteractionAction
- label="Skip"
+ label={tr('Skip')}
kind="radio"
disabled={blocked()}
selected={answers()[index()]?.value === null}
@@ -840,10 +881,12 @@ export function InteractionComposer({
</View>
<If when={answers()[index()]?.other}>
<Input
- accessibilityLabel={`Other answer ${index() + 1}`}
+ accessibilityLabel={tr('Other answer {number}', {
+ number: index() + 1,
+ })}
value={String(answers()[index()]?.value ?? '')}
readonly={blocked()}
- placeholder="Your answer"
+ placeholder={tr('Your answer')}
onChange={(value) => choose(index(), value, true)}
style={{
...inputTextStyle,
@@ -893,7 +936,9 @@ export function InteractionComposer({
fontWeight: '600',
}}
>
- {record()?.outcome ? interactionOutcomeSummary(record()!.outcome!) : ''}
+ {record()?.outcome
+ ? renderText(interactionOutcomeSummary(record()!.outcome!))
+ : ''}
</Text>
</View>
</View>
@@ -905,18 +950,21 @@ export function InteractionComposer({
</If>
<If when={uncertain() && !record()?.outcome}>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
- An original answer receipt is unresolved. Refresh is read-only; further answers stay
- disabled.
+ {tr(
+ 'An original answer receipt is unresolved. Refresh is read-only; further answers stay disabled.',
+ )}
</Text>
</If>
<If when={request()?.kind === 'question' && !record()?.outcome && !questionReady()}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Choose an option, write another answer, or skip each question.
+ {tr('Choose an option, write another answer, or skip each question.')}
</Text>
</If>
<If when={record() && !record()!.outcome && !pending() && !loading()}>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
- This request is no longer listed as pending. Refresh to read its canonical outcome.
+ {tr(
+ 'This request is no longer listed as pending. Refresh to read its canonical outcome.',
+ )}
</Text>
</If>
</Scroll>
@@ -932,10 +980,10 @@ export function InteractionComposer({
}}
>
<InteractionAction
- label="Refresh status"
+ label={tr('Refresh status')}
icon="refresh"
iconOnly={windowSize().width < 720}
- tooltip="Refresh status · Ctrl+R"
+ tooltip={tr('Refresh status · Ctrl+R')}
disabled={loading() || busy()}
onPress={inputAction(refreshRendered, { sessionId, requestId })}
/>
@@ -950,13 +998,13 @@ export function InteractionComposer({
>
<If when={request()?.kind === 'form' && !record()?.outcome}>
<InteractionAction
- label="Cancel request"
+ label={tr('Cancel request')}
disabled={blocked()}
onPress={answerBinding({ kind: 'form', action: 'cancel' })}
/>
<InteractionAction
- label="Decline"
- tooltip="Decline · D"
+ label={tr('Decline')}
+ tooltip={tr('Decline · D')}
disabled={blocked()}
onPress={answerBinding(denialAnswer())}
/>
@@ -970,8 +1018,8 @@ export function InteractionComposer({
}
>
<InteractionAction
- label="Deny"
- tooltip="Deny · D"
+ label={tr('Deny')}
+ tooltip={tr('Deny · D')}
disabled={blocked()}
onPress={answerBinding(denialAnswer())}
/>
@@ -990,19 +1038,19 @@ export function InteractionComposer({
primary
tooltip={
request()?.kind === 'form'
- ? 'Submit form · Ctrl+Enter'
+ ? tr('Submit form · Ctrl+Enter')
: request()?.kind === 'question'
- ? 'Submit answers · Ctrl+Enter'
- : 'Allow · Ctrl+Enter'
+ ? tr('Submit answers · Ctrl+Enter')
+ : tr('Allow · Ctrl+Enter')
}
label={
busy()
- ? 'Recording answer…'
+ ? tr('Recording answer…')
: request()?.kind === 'form'
- ? 'Submit form'
+ ? tr('Submit form')
: request()?.kind === 'question'
- ? 'Submit answers'
- : 'Allow'
+ ? tr('Submit answers')
+ : tr('Allow')
}
disabled={
blocked() ||
diff --git a/products/maka/terminal-shell/app/MarkdownBlock.tsx b/products/maka/terminal-shell/app/MarkdownBlock.tsx
index 07fb0f42af..f0c5a79685 100644
--- a/products/maka/terminal-shell/app/MarkdownBlock.tsx
+++ b/products/maka/terminal-shell/app/MarkdownBlock.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { tr } from './locale';
import { cleanup, computed, inputAction, Scroll, signal, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import { Clipboard } from 'nsh/clipboard';
@@ -163,7 +164,7 @@ function TableBlock(props: { table(): MarkdownTable; compact?: boolean }) {
);
});
return (
- <Scroll horizontal accessibilityLabel="Markdown table" style={{ flexGrow: 0 }}>
+ <Scroll horizontal accessibilityLabel={tr('Markdown table')} style={{ flexGrow: 0 }}>
<View
style={{
display: 'grid',
@@ -275,11 +276,11 @@ function CodeBlock(props: { block: () => MarkdownBlock; compact?: boolean }) {
<Text style={{ ...captionTextStyle, color: C.muted, flex: 1 }}>
{block().language || 'code'}
</Text>
- <Text style={{ ...captionTextStyle, color: C.muted }}>{copied() ? 'Copied' : ''}</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>{copied() ? tr('Copied') : ''}</Text>
<Button
- accessibilityLabel="Wrap code"
+ accessibilityLabel={tr('Wrap code')}
selected={wrapped()}
- tooltip={wrapped() ? 'Show original columns' : 'Wrap long lines'}
+ tooltip={wrapped() ? tr('Show original columns') : tr('Wrap long lines')}
onPress={inputAction(toggleWrapped, { key: key(), wrapped: wrapped() })}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={{ backgroundColor: C.line }}
@@ -288,8 +289,8 @@ function CodeBlock(props: { block: () => MarkdownBlock; compact?: boolean }) {
<Icon name={wrapped() ? 'nowrap' : 'wrap'} color={C.secondary} />
</Button>
<Button
- accessibilityLabel="Copy code"
- tooltip={copied() ? 'Code copied' : 'Copy code'}
+ accessibilityLabel={tr('Copy code block')}
+ tooltip={copied() ? tr('Code copied') : tr('Copy code block')}
onPress={action()}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={{ backgroundColor: C.line }}
diff --git a/products/maka/terminal-shell/app/MarkdownBody.tsx b/products/maka/terminal-shell/app/MarkdownBody.tsx
index 910773eb4c..8aefb68cef 100644
--- a/products/maka/terminal-shell/app/MarkdownBody.tsx
+++ b/products/maka/terminal-shell/app/MarkdownBody.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { errorText, renderText, type UiText } from './text';
+import { tr } from './locale';
import { cleanup, computed, effect, signal, Text, View } from 'nsh';
import { markdown } from 'maka/markdown';
import type { MarkdownBlock } from 'maka/markdown';
@@ -36,7 +38,7 @@ const sameInput = (left: Input | null, right: Input | null) =>
export function MarkdownBody(props: { row: () => Row; compact?: boolean }) {
const projection = signal<Projection | null>(null);
- const failure = signal<{ input: Input; message: string } | null>(null);
+ const failure = signal<{ input: Input; message: UiText } | null>(null);
let wanted: Input | null = null;
let preparing = false;
let alive = true;
@@ -75,7 +77,7 @@ export function MarkdownBody(props: { row: () => Row; compact?: boolean }) {
} catch (reason) {
if (alive && sameInput(captured, wanted)) {
projection.set(null);
- failure.set({ input: captured, message: String(reason) });
+ failure.set({ input: captured, message: errorText(reason) });
}
} finally {
preparing = false;
@@ -117,7 +119,7 @@ export function MarkdownBody(props: { row: () => Row; compact?: boolean }) {
</Text>
<If when={failure()}>
<Text style={{ ...(props.compact ? secondaryTextStyle : {}), color: C.danger }}>
- Formatting unavailable: {failure()!.message}
+ {tr('Formatting unavailable: {reason}', { reason: renderText(failure()!.message) })}
</Text>
</If>
</View>
diff --git a/products/maka/terminal-shell/app/MessageCopy.tsx b/products/maka/terminal-shell/app/MessageCopy.tsx
index f8b9e7731b..d7550dd02f 100644
--- a/products/maka/terminal-shell/app/MessageCopy.tsx
+++ b/products/maka/terminal-shell/app/MessageCopy.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { createText, UiError, errorText, message as uiMessage } from './text';
+import { tr } from './locale';
import { cleanup, computed, inputAction, signal, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import { Clipboard } from 'nsh/clipboard';
@@ -51,7 +53,7 @@ export function MessageCopy(props: {
through(): number;
}) {
const busy = signal(false);
- const notice = signal('');
+ const notice = createText();
const failed = signal(false);
let alive = true;
cleanup(() => {
@@ -110,7 +112,7 @@ export function MessageCopy(props: {
end > source.totalBytes ||
(end === offset && end !== source.totalBytes)
) {
- throw new Error('The original message source could not be verified.');
+ throw new UiError('The original message source could not be verified.');
}
total = source.totalBytes;
content += source.text;
@@ -120,11 +122,13 @@ export function MessageCopy(props: {
if (!owns(original)) return;
await Clipboard.writeText(content);
if (owns(original))
- notice.set(original.role === 'user' ? 'User message copied' : 'Message copied');
+ notice.set(
+ original.role === 'user' ? uiMessage('User message copied') : uiMessage('Message copied'),
+ );
} catch (reason) {
if (owns(original)) {
failed.set(true);
- notice.set(reason instanceof Error ? reason.message : String(reason));
+ notice.set(errorText(reason));
}
} finally {
if (owns(original)) busy.set(false);
@@ -147,13 +151,15 @@ export function MessageCopy(props: {
</Text>
</If>
<Button
- accessibilityLabel={props.row().role === 'user' ? 'Copy user message' : 'Copy message'}
+ accessibilityLabel={
+ props.row().role === 'user' ? tr('Copy user message') : tr('Copy message')
+ }
tooltip={
busy()
- ? 'Reading original message…'
+ ? tr('Reading original message…')
: props.row().role === 'user'
- ? 'Copy user message'
- : 'Copy message'
+ ? tr('Copy user message')
+ : tr('Copy message')
}
disabled={target() === null}
onPress={inputAction(copy, target())}
diff --git a/products/maka/terminal-shell/app/MessageRow.tsx b/products/maka/terminal-shell/app/MessageRow.tsx
index 6540241d6b..23f3e82a5d 100644
--- a/products/maka/terminal-shell/app/MessageRow.tsx
+++ b/products/maka/terminal-shell/app/MessageRow.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { tr } from './locale';
import { computed, inputAction, signal, Text, View } from 'nsh';
import type { JSX } from 'nsh';
import { Button } from 'nsh/base';
@@ -87,7 +88,7 @@ export function MessageRow({
row().tool != null || row().role === 'tool_call' || row().role === 'tool_result';
const compact = () => thinking() || isTool();
const user = () => row().role === 'user';
- const summary = () => `Thinking${preview() ? ` · ${preview()}` : ''}`;
+ const summary = () => `${tr('Thinking')}${preview() ? ` · ${preview()}` : ''}`;
const timestamp = () => {
const value = (row() as Row & { timestamp?: number }).timestamp;
if (typeof value !== 'number') return '';
@@ -159,9 +160,11 @@ export function MessageRow({
</If>
<Button
accessibilityLabel={
- sourceMode() ? 'Show formatted message' : 'Show message source'
+ sourceMode() ? tr('Show formatted message') : tr('Show message source')
+ }
+ tooltip={
+ sourceMode() ? tr('Show formatted message') : tr('Show message source')
}
- tooltip={sourceMode() ? 'Show formatted message' : 'Show message source'}
selected={sourceMode()}
onPress={inputAction(setSourceMode, {
sessionId,
@@ -220,10 +223,14 @@ export function MessageRow({
<If when={expanded()}>
<Button
accessibilityLabel={
- sourceMode() ? 'Show formatted thinking' : 'Show thinking source'
+ sourceMode()
+ ? tr('Show formatted thinking')
+ : tr('Show thinking source')
}
tooltip={
- sourceMode() ? 'Show formatted thinking' : 'Show thinking source'
+ sourceMode()
+ ? tr('Show formatted thinking')
+ : tr('Show thinking source')
}
selected={sourceMode()}
onPress={inputAction(setSourceMode, {
diff --git a/products/maka/terminal-shell/app/ModelSelect.tsx b/products/maka/terminal-shell/app/ModelSelect.tsx
index 8865e8c601..8f017d2396 100644
--- a/products/maka/terminal-shell/app/ModelSelect.tsx
+++ b/products/maka/terminal-shell/app/ModelSelect.tsx
@@ -24,6 +24,8 @@ import { keymap } from 'nsh/keymap';
import { List } from 'nsh/list';
import { platform, windowSize } from 'nsh/device';
import { host } from './host';
+import { tr } from './locale';
+import { createText, errorText, UiError } from './text';
import type { ConversationHandle } from './Conversation';
import { effortCaption, modelCaption, sameRoute } from './modelSelection';
import type { BackendIdentity, ModelBackend, ModelChoice } from './modelSelection';
@@ -84,7 +86,7 @@ export function ModelSelect(props: {
const backend = owner.backend();
return backend
? `${modelCaption(backend, owner.modelDetails())} · ${effortCaption(backend, owner.modelDetails())}`
- : 'Choose a model';
+ : tr('Choose a model');
};
return (
<Popover
@@ -108,8 +110,8 @@ export function ModelSelect(props: {
}}
trigger={
<Button
- accessibilityLabel="Choose model and effort"
- tooltip="Choose model and effort · Ctrl+G"
+ accessibilityLabel={tr('Choose model and effort')}
+ tooltip={tr('Choose model and effort · Ctrl+G')}
onPress={inputAction(props.toggle, {
sessionId: owner.sessionId,
instance: props.instance(),
@@ -190,7 +192,7 @@ function ModelMenu(props: {
const selected = signal(0);
const loading = signal(false);
const pending = signal(false);
- const error = signal('');
+ const error = createText();
let alive = true;
let generation = 0;
let removeSearchKeys = () => {};
@@ -236,7 +238,7 @@ function ModelMenu(props: {
await props.select(request.backend);
if (active()) props.close(request.instance);
} catch (reason) {
- if (active()) error.set(reason instanceof Error ? reason.message : String(reason));
+ if (active()) error.set(errorText(reason));
} finally {
if (alive) pending.set(false);
}
@@ -296,7 +298,9 @@ function ModelMenu(props: {
if (!active() || reading !== generation) return;
if (result.kind === 'changed') {
if (changed)
- throw new Error('The model catalog keeps changing. Retry to read the current choices.');
+ throw new UiError(
+ 'The model catalog keeps changing. Retry to read the current choices.',
+ );
changed = true;
page.set(null);
selected.set(0);
@@ -310,8 +314,7 @@ function ModelMenu(props: {
break;
} while (active() && reading === generation);
} catch (reason) {
- if (active() && reading === generation)
- error.set(reason instanceof Error ? reason.message : String(reason));
+ if (active() && reading === generation) error.set(errorText(reason));
} finally {
if (alive && reading === generation) loading.set(false);
}
@@ -377,19 +380,19 @@ function ModelMenu(props: {
paddingHorizontal: 8,
}}
>
- Models
+ {tr('Models')}
</Text>
<Button
disabled={pending()}
accessibilityLabel={
- pane() === 'root' ? 'Close model selection' : 'Back to model settings'
+ pane() === 'root' ? tr('Close model selection') : tr('Back to model settings')
}
onPress={
pane() === 'root'
? inputAction(close, { instance: props.instance, from: pane() })
: inputAction(changePane, { instance: props.instance, from: pane(), to: 'root' })
}
- tooltip={pane() === 'root' ? 'Close' : 'Back'}
+ tooltip={pane() === 'root' ? tr('Close') : tr('Back')}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -402,7 +405,7 @@ function ModelMenu(props: {
<Button
ref={(node) => focusRoot('model', node)}
disabled={pending()}
- accessibilityLabel="Choose model"
+ accessibilityLabel={tr('Choose model')}
onPress={inputAction(changePane, {
instance: props.instance,
from: 'root',
@@ -418,7 +421,7 @@ function ModelMenu(props: {
focusStyle={{ backgroundColor: C.raised }}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.secondary }}>Model</Text>
+ <Text style={{ ...captionTextStyle, color: C.secondary }}>{tr('Model')}</Text>
<Text
numberOfLines={1}
style={{
@@ -436,7 +439,7 @@ function ModelMenu(props: {
<Button
ref={(node) => focusRoot('effort', node)}
disabled={pending() || !readyChoice()}
- accessibilityLabel="Choose reasoning effort"
+ accessibilityLabel={tr('Choose reasoning effort')}
onPress={inputAction(changePane, {
instance: props.instance,
from: 'root',
@@ -452,7 +455,7 @@ function ModelMenu(props: {
focusStyle={{ backgroundColor: C.raised }}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.secondary }}>Reasoning effort</Text>
+ <Text style={{ ...captionTextStyle, color: C.secondary }}>{tr('Reasoning effort')}</Text>
<Text
numberOfLines={1}
style={{
@@ -480,7 +483,7 @@ function ModelMenu(props: {
removeSearchKeys = () => unbind.forEach((remove) => remove());
}}
autoFocus
- accessibilityLabel="Search models"
+ accessibilityLabel={tr('Search models')}
value={query()}
readonly={pending()}
onChange={(text) => {
@@ -491,7 +494,7 @@ function ModelMenu(props: {
list?.scrollToIndex({ index: 0, animated: false });
void loadModels(true);
}}
- placeholder="Search models…"
+ placeholder={tr('Search models…')}
style={{
...inputTextStyle,
placeholderColor: C.muted,
@@ -525,7 +528,10 @@ function ModelMenu(props: {
const choice = () => byId().get(key()) ?? initial;
return (
<Button
- accessibilityLabel={`Use ${choice().label} · ${choice().connectionName}`}
+ accessibilityLabel={tr('Use {model} · {connection}', {
+ model: choice().label,
+ connection: choice().connectionName,
+ })}
disabled={pending()}
selected={sameRoute(owner.backend(), choice().selection)}
onPress={inputAction(submit, modelAction(choice()))}
@@ -569,7 +575,11 @@ function ModelMenu(props: {
)}
</List>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- {loading() ? 'Loading…' : `${choices().length} model${choices().length === 1 ? '' : 's'}`}
+ {loading()
+ ? tr('Loading…')
+ : choices().length === 1
+ ? tr('{count} model', { count: choices().length })
+ : tr('{count} models', { count: choices().length })}
</Text>
</If>
<If when={pane() === 'effort'}>
@@ -588,7 +598,9 @@ function ModelMenu(props: {
<Button
disabled={pending()}
accessibilityLabel={
- level() === null ? 'Use connection default' : `Use effort ${level()}`
+ level() === null
+ ? tr('Use connection default')
+ : tr('Use effort {level}', { level: level()! })
}
selected={owner.backend()?.thinkingLevel === level()}
onPress={inputAction(
@@ -616,7 +628,7 @@ function ModelMenu(props: {
disabled={pending()}
/>
<Text style={{ ...captionTextStyle, color: C.text }}>
- {level() === null ? 'Default · Use connection default' : level()}
+ {level() === null ? tr('Default · Use connection default') : level()}
</Text>
</Button>
)}
@@ -629,34 +641,39 @@ function ModelMenu(props: {
<If when={pane() !== 'models' && owner.modelDetails().kind !== 'ready'}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
{owner.modelDetails().kind === 'loading'
- ? 'Loading model policy…'
- : 'The saved model policy is unavailable. Its selection is preserved.'}
+ ? tr('Loading model policy…')
+ : tr('The saved model policy is unavailable. Its selection is preserved.')}
</Text>
<Button
disabled={pending()}
onPress={inputAction(retry, { instance: props.instance, from: pane() })}
- accessibilityLabel="Retry model policy"
+ accessibilityLabel={tr('Retry model policy')}
style={controlStyle}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.accent }}>Retry</Text>
+ <Text style={{ ...captionTextStyle, color: C.accent }}>{tr('Retry')}</Text>
</Button>
</If>
<Button
disabled={pending()}
- accessibilityLabel="Manage connections and models"
+ accessibilityLabel={tr('Manage connections and models')}
onPress={inputAction(props.manage, null)}
style={controlStyle}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.accent }}>Manage connections & models</Text>
+ <Text style={{ ...captionTextStyle, color: C.accent }}>
+ {tr('Manage connections & models')}
+ </Text>
</Button>
<If when={error()}>
- <Scroll accessibilityLabel="Model selection error" style={{ minHeight: 16, flexShrink: 1 }}>
+ <Scroll
+ accessibilityLabel={tr('Model selection error')}
+ style={{ minHeight: 16, flexShrink: 1 }}
+ >
<Text selectable style={{ ...captionTextStyle, color: C.danger }}>
{error()}
</Text>
@@ -665,18 +682,18 @@ function ModelMenu(props: {
<Button
disabled={pending()}
onPress={inputAction(retry, { instance: props.instance, from: pane() })}
- accessibilityLabel="Retry model catalog"
+ accessibilityLabel={tr('Retry model catalog')}
style={controlStyle}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.accent }}>Retry</Text>
+ <Text style={{ ...captionTextStyle, color: C.accent }}>{tr('Retry')}</Text>
</Button>
</If>
</If>
<If when={pending()}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Saving selection…</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>{tr('Saving selection…')}</Text>
</If>
</View>
);
diff --git a/products/maka/terminal-shell/app/ModelSettings.tsx b/products/maka/terminal-shell/app/ModelSettings.tsx
index 4b7b88d70d..8967e29838 100644
--- a/products/maka/terminal-shell/app/ModelSettings.tsx
+++ b/products/maka/terminal-shell/app/ModelSettings.tsx
@@ -40,15 +40,16 @@ import {
controlDisabledStyle,
} from './ui';
import { Icon } from './Icon';
+import { tr } from './locale';
type Section = 'general' | 'connections' | 'network' | 'plugins';
type BackAction = { section: Section; identity: string };
-const sections: { id: Section; label: string }[] = [
+const sections = [
{ id: 'general', label: 'General' },
{ id: 'connections', label: 'Connections & models' },
{ id: 'network', label: 'Network' },
{ id: 'plugins', label: 'Plugins' },
-];
+] as const satisfies readonly { id: Section; label: string }[];
// One native Dialog owns focus restoration. Forms keep their own canonical
// snapshot and unsaved edits; all writes share the original native journal.
@@ -113,11 +114,11 @@ export function ModelSettings(props: { close(): void; changed(): void }) {
const current = section();
return current === 'connections'
? {
- connections: 'Connections & models',
- providers: 'Add connection',
- connection: 'Connection settings',
- models: 'Connection models',
- model: 'Model settings',
+ connections: tr('Connections & models'),
+ providers: tr('Add connection'),
+ connection: tr('Connection settings'),
+ models: tr('Connection models'),
+ model: tr('Model settings'),
}[connections.page()]
: pages[current].title();
};
@@ -125,7 +126,7 @@ export function ModelSettings(props: { close(): void; changed(): void }) {
section() === 'connections'
? connections.page() !== 'connections' || !!connections.confirmation()
: section() === 'general'
- ? general.title() !== 'General settings'
+ ? general.editor() !== null
: section() === 'plugins' && plugins.selected() !== null;
return (
<Dialog
@@ -171,10 +172,12 @@ export function ModelSettings(props: { close(): void; changed(): void }) {
disabled={operations.busy()}
accessibilityLabel={
section() === 'connections'
- ? 'Back from connection settings'
- : `Back from ${section()} settings`
+ ? tr('Back from connection settings')
+ : section() === 'general'
+ ? tr('Back from general settings')
+ : tr('Back from plugin settings')
}
- tooltip="Back"
+ tooltip={tr('Back')}
onPress={inputAction(back, backAction())}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
@@ -189,8 +192,8 @@ export function ModelSettings(props: { close(): void; changed(): void }) {
</Text>
<Button
disabled={operations.busy()}
- accessibilityLabel="Close settings"
- tooltip="Close settings"
+ accessibilityLabel={tr('Close settings')}
+ tooltip={tr('Close settings')}
onPress={inputAction(close, null)}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
@@ -218,7 +221,9 @@ export function ModelSettings(props: { close(): void; changed(): void }) {
<Button
disabled={operations.busy()}
selected={section() === item().id}
- accessibilityLabel={`Settings section ${item().label}`}
+ accessibilityLabel={tr('Settings section {section}', {
+ section: tr(item().label),
+ })}
onPress={inputAction(choose, { from: section(), to: item().id })}
style={{ ...controlStyle, minHeight: rail() ? 40 : 32 }}
selectedStyle={{ backgroundColor: C.raised }}
@@ -232,7 +237,7 @@ export function ModelSettings(props: { close(): void; changed(): void }) {
color: section() === item().id ? C.text : C.muted,
}}
>
- {item().label}
+ {tr(item().label)}
</Text>
</Button>
)}
@@ -254,11 +259,11 @@ export function ModelSettings(props: { close(): void; changed(): void }) {
style={{ gap: 8, padding: 12, backgroundColor: C.background, borderRadius: 12 }}
>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
- Earlier settings actions must be resolved before another change.
+ {tr('Earlier settings actions must be resolved before another change.')}
</Text>
<Button
disabled={operations.busy()}
- accessibilityLabel="Recover settings actions"
+ accessibilityLabel={tr('Recover settings actions')}
onPress={inputAction(recover, null)}
style={{ ...controlStyle, alignSelf: 'flex-start' }}
hoverStyle={controlHoverStyle}
@@ -266,7 +271,9 @@ export function ModelSettings(props: { close(): void; changed(): void }) {
disabledStyle={controlDisabledStyle}
>
<Text style={{ ...captionTextStyle, color: C.accent }}>
- {operations.busy() ? 'Checking receipts…' : 'Recover original settings actions'}
+ {operations.busy()
+ ? tr('Checking receipts…')
+ : tr('Recover original settings actions')}
</Text>
</Button>
<Each
@@ -283,7 +290,7 @@ export function ModelSettings(props: { close(): void; changed(): void }) {
disabledStyle={controlDisabledStyle}
>
<Text style={{ ...captionTextStyle, color: C.warning }}>
- Abandon unconfirmed {item().method}
+ {tr('Abandon unconfirmed {method}', { method: item().method })}
</Text>
</Button>
)}
diff --git a/products/maka/terminal-shell/app/NetworkSettings.tsx b/products/maka/terminal-shell/app/NetworkSettings.tsx
index 01a45e7d3c..cadbd70bbd 100644
--- a/products/maka/terminal-shell/app/NetworkSettings.tsx
+++ b/products/maka/terminal-shell/app/NetworkSettings.tsx
@@ -19,6 +19,7 @@
import { inputAction, Scroll, Text, View } from 'nsh';
import { Button } from 'nsh/base';
+import { tr } from './locale';
import type { ProxyProtocol } from '../../../../packages/plugin-sdk/src/generated/runtime';
import { SettingsField } from './SettingsField';
import { ChoiceIndicator } from './ChoiceIndicator';
@@ -41,18 +42,18 @@ export function NetworkSettings(props: { owner: NetworkSettingsOwner }) {
return (
<View style={{ flex: 1, minHeight: 0, gap: 8 }}>
<If when={owner.loading()}>
- <Text style={{ ...bodyTextStyle, color: C.muted }}>Loading network settings…</Text>
+ <Text style={{ ...bodyTextStyle, color: C.muted }}>{tr('Loading network settings…')}</Text>
</If>
<If when={owner.draft() && owner.snapshot()}>
<Scroll style={{ flex: 1, minHeight: 0 }}>
<View style={{ gap: 12, padding: 8 }}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Applies to new requests. Requests already running keep their settings.
+ {tr('Applies to new requests. Requests already running keep their settings.')}
</Text>
<Button
disabled={disabled()}
selected={value().proxy.enabled}
- accessibilityLabel="Enable Host proxy"
+ accessibilityLabel={tr('Enable Host proxy')}
onPress={inputAction(owner.choose, {
...owner.action(),
kind: 'enabled',
@@ -69,7 +70,7 @@ export function NetworkSettings(props: { owner: NetworkSettingsOwner }) {
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...bodyTextStyle, color: C.text, flex: 1 }}>Proxy</Text>
+ <Text style={{ ...bodyTextStyle, color: C.text, flex: 1 }}>{tr('Proxy')}</Text>
<ChoiceIndicator
kind="switch"
checked={value().proxy.enabled}
@@ -82,7 +83,7 @@ export function NetworkSettings(props: { owner: NetworkSettingsOwner }) {
<Button
disabled={disabled()}
selected={value().proxy.protocol === protocol()}
- accessibilityLabel={`Proxy protocol ${protocol()}`}
+ accessibilityLabel={tr('Proxy protocol {protocol}', { protocol: protocol() })}
onPress={inputAction(owner.choose, {
...owner.action(),
kind: 'protocol',
@@ -111,14 +112,14 @@ export function NetworkSettings(props: { owner: NetworkSettingsOwner }) {
</Each>
</View>
<SettingsField
- label="Proxy host"
+ label={tr('Proxy host')}
value={value().proxy.host}
disabled={disabled()}
change={(text) => owner.edit('host', text)}
- hint="Hostname or IP address; enter the port separately."
+ hint={tr('Hostname or IP address; enter the port separately.')}
/>
<SettingsField
- label="Proxy port"
+ label={tr('Proxy port')}
value={value().port}
disabled={disabled()}
change={(text) => owner.edit('port', text)}
@@ -126,7 +127,7 @@ export function NetworkSettings(props: { owner: NetworkSettingsOwner }) {
<Button
disabled={disabled()}
selected={value().proxy.authEnabled}
- accessibilityLabel="Enable proxy authentication"
+ accessibilityLabel={tr('Enable proxy authentication')}
onPress={inputAction(owner.choose, {
...owner.action(),
kind: 'authentication',
@@ -143,7 +144,9 @@ export function NetworkSettings(props: { owner: NetworkSettingsOwner }) {
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...bodyTextStyle, color: C.text, flex: 1 }}>Proxy authentication</Text>
+ <Text style={{ ...bodyTextStyle, color: C.text, flex: 1 }}>
+ {tr('Proxy authentication')}
+ </Text>
<ChoiceIndicator
kind="switch"
checked={value().proxy.authEnabled}
@@ -151,11 +154,11 @@ export function NetworkSettings(props: { owner: NetworkSettingsOwner }) {
/>
</Button>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Saving with proxy authentication disabled deletes its stored password.
+ {tr('Saving with proxy authentication disabled deletes its stored password.')}
</Text>
<If when={value().proxy.authEnabled}>
<SettingsField
- label="Proxy username"
+ label={tr('Proxy username')}
value={value().proxy.username}
disabled={disabled()}
change={(text) => owner.edit('username', text)}
@@ -164,7 +167,7 @@ export function NetworkSettings(props: { owner: NetworkSettingsOwner }) {
<Button
disabled={disabled()}
selected={value().credential === 'keep'}
- accessibilityLabel="Keep proxy password"
+ accessibilityLabel={tr('Keep proxy password')}
onPress={inputAction(owner.choose, {
...owner.action(),
kind: 'credential',
@@ -187,12 +190,14 @@ export function NetworkSettings(props: { owner: NetworkSettingsOwner }) {
checked={value().credential === 'keep'}
disabled={disabled()}
/>
- <Text style={{ ...bodyTextStyle, color: C.text }}>Keep stored password</Text>
+ <Text style={{ ...bodyTextStyle, color: C.text }}>
+ {tr('Keep stored password')}
+ </Text>
</Button>
<Button
disabled={disabled()}
selected={value().credential === 'replace'}
- accessibilityLabel="Replace proxy password"
+ accessibilityLabel={tr('Replace proxy password')}
onPress={inputAction(owner.choose, {
...owner.action(),
kind: 'credential',
@@ -215,41 +220,43 @@ export function NetworkSettings(props: { owner: NetworkSettingsOwner }) {
checked={value().credential === 'replace'}
disabled={disabled()}
/>
- <Text style={{ ...bodyTextStyle, color: C.text }}>Replace password</Text>
+ <Text style={{ ...bodyTextStyle, color: C.text }}>{tr('Replace password')}</Text>
</Button>
</View>
<Text style={{ ...captionTextStyle, color: C.muted }}>
{owner.snapshot()?.credential.configured
- ? 'A proxy password is stored. Its value is never read back.'
- : 'No proxy password is stored.'}
+ ? tr('A proxy password is stored. Its value is never read back.')
+ : tr('No proxy password is stored.')}
</Text>
<If when={value().credential === 'replace'}>
<SettingsField
- label="Proxy password"
+ label={tr('Proxy password')}
value={value().secret}
masked
disabled={disabled()}
change={(text) => owner.edit('secret', text)}
- hint="Stored securely, separately from your proxy settings."
+ hint={tr('Stored securely, separately from your proxy settings.')}
/>
</If>
</If>
<SettingsField
- label="Proxy bypass rules"
+ label={tr('Proxy bypass rules')}
value={value().bypass}
multiline
disabled={disabled()}
change={(text) => owner.edit('bypass', text)}
- hint="One bypass rule per line. Automatic rules are retained."
+ hint={tr('One bypass rule per line. Automatic rules are retained.')}
/>
<If when={owner.snapshot()!.proxy.autoBypassDomains.length > 0}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Automatic bypass: {owner.snapshot()!.proxy.autoBypassDomains.join(', ')}
+ {tr('Automatic bypass: {domains}', {
+ domains: owner.snapshot()!.proxy.autoBypassDomains.join(', '),
+ })}
</Text>
</If>
<Button
disabled={disabled()}
- accessibilityLabel="Save network settings"
+ accessibilityLabel={tr('Save network settings')}
onPress={inputAction(owner.save, owner.action())}
style={{ ...controlStyle, alignSelf: 'flex-start', backgroundColor: C.accent }}
hoverStyle={{ backgroundColor: C.accentHover }}
@@ -257,33 +264,37 @@ export function NetworkSettings(props: { owner: NetworkSettingsOwner }) {
disabledStyle={controlDisabledStyle}
>
<Text style={{ ...bodyTextStyle, color: C.background, fontWeight: '500' }}>
- Save network settings
+ {tr('Save network settings')}
</Text>
</Button>
<Button
disabled={disabled()}
- accessibilityLabel="Reload network settings"
+ accessibilityLabel={tr('Reload network settings')}
onPress={inputAction(owner.reload, owner.action())}
style={{ ...controlStyle, alignSelf: 'flex-start' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...bodyTextStyle, color: C.secondary }}>Discard edits and reload</Text>
+ <Text style={{ ...bodyTextStyle, color: C.secondary }}>
+ {tr('Discard edits and reload')}
+ </Text>
</Button>
</View>
</Scroll>
</If>
<If when={!owner.draft() && !owner.loading()}>
<Button
- accessibilityLabel="Reload network settings"
+ accessibilityLabel={tr('Reload network settings')}
onPress={inputAction(owner.reload, owner.action())}
style={{ ...controlStyle, alignSelf: 'flex-start' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...bodyTextStyle, color: C.secondary }}>Reload network settings</Text>
+ <Text style={{ ...bodyTextStyle, color: C.secondary }}>
+ {tr('Reload network settings')}
+ </Text>
</Button>
</If>
</View>
diff --git a/products/maka/terminal-shell/app/Palette.tsx b/products/maka/terminal-shell/app/Palette.tsx
index 32d0aec120..709074b5a2 100644
--- a/products/maka/terminal-shell/app/Palette.tsx
+++ b/products/maka/terminal-shell/app/Palette.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { tr } from './locale';
import { cleanup, computed, Input, inputAction, signal, Text, View } from 'nsh';
import type { YNode } from 'nsh';
import { List } from 'nsh/list';
@@ -103,11 +104,11 @@ export function Palette(props: {
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<Text style={{ color: C.text, fontSize: 18, lineHeight: 24, fontWeight: '600' }}>
- Commands
+ {tr('Commands')}
</Text>
<Button
- accessibilityLabel="Close commands"
- tooltip="Close · Esc"
+ accessibilityLabel={tr('Close commands')}
+ tooltip={tr('Close · Esc')}
onPress={inputAction(props.close, props.instance)}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
@@ -128,7 +129,7 @@ export function Palette(props: {
removeSearchKeys = () => unbind.forEach((remove) => remove());
}}
testID="palette-search"
- accessibilityLabel="Search commands"
+ accessibilityLabel={tr('Search commands')}
autoFocus
value={query()}
onChange={(text) => {
@@ -136,7 +137,7 @@ export function Palette(props: {
query.set(text);
list?.scrollToIndex({ index: 0, animated: false });
}}
- placeholder="Search commands…"
+ placeholder={tr('Search commands…')}
style={{
placeholderColor: C.muted,
color: C.text,
@@ -154,7 +155,7 @@ export function Palette(props: {
}}
estimatedItemSize={48}
style={{ height: Math.max(1, ids().length) * 48, flexShrink: 1, minHeight: 0 }}
- empty={<Text style={{ color: C.muted }}>No matches.</Text>}
+ empty={<Text style={{ color: C.muted }}>{tr('No matches.')}</Text>}
>
{(id) => (
<Each of={[id()]} key={(key: string) => key}>
diff --git a/products/maka/terminal-shell/app/PluginSettings.tsx b/products/maka/terminal-shell/app/PluginSettings.tsx
index cd5ba44b64..5f740c5e13 100644
--- a/products/maka/terminal-shell/app/PluginSettings.tsx
+++ b/products/maka/terminal-shell/app/PluginSettings.tsx
@@ -19,6 +19,7 @@
import { Input, inputAction, List, Scroll, Text, View } from 'nsh';
import { Button } from 'nsh/base';
+import { tr } from './locale';
import type { SettingValue } from '../../../../packages/plugin-sdk/src/index.js';
import { sameValue } from '../../../../plugins/shared/value';
import { pluginTitle, settingCaption } from './pluginSettings.ts';
@@ -44,7 +45,7 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
<View style={{ flex: 1, minHeight: 0, gap: 8 }}>
<Button
disabled={owner.loading() || owner.busy()}
- accessibilityLabel="Refresh plugin configuration"
+ accessibilityLabel={tr('Refresh plugin configuration')}
onPress={inputAction(owner.reload, owner.action())}
style={{ ...controlStyle, alignSelf: 'flex-start' }}
hoverStyle={controlHoverStyle}
@@ -52,15 +53,15 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
disabledStyle={controlDisabledStyle}
>
<Text style={{ ...bodyTextStyle, color: C.secondary }}>
- {owner.editor() ? 'Discard edit and refresh plugins' : 'Refresh plugins'}
+ {owner.editor() ? tr('Discard edit and refresh plugins') : tr('Refresh plugins')}
</Text>
</Button>
<If when={owner.loading()}>
- <Text style={{ ...bodyTextStyle, color: C.muted }}>Reading plugins…</Text>
+ <Text style={{ ...bodyTextStyle, color: C.muted }}>{tr('Reading plugins…')}</Text>
</If>
<If when={!owner.loading() && !owner.inventory()}>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
- Plugin settings are unavailable on this connection.
+ {tr('Plugin settings are unavailable on this connection.')}
</Text>
</If>
<If when={owner.inventory()}>
@@ -72,13 +73,15 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
<If when={!owner.selected()}>
<View style={{ flex: 1, minHeight: 0, padding: 8, gap: 12 }}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Manage plugins for this connection. Your conversation and draft stay open.
+ {tr(
+ 'Manage plugins for this connection. Your conversation and draft stay open.',
+ )}
</Text>
<Input
- accessibilityLabel="Search Host plugins"
+ accessibilityLabel={tr('Search Host plugins')}
value={owner.query()}
onChange={(value) => owner.query.set(value)}
- placeholder="Search plugins…"
+ placeholder={tr('Search plugins…')}
style={{
...inputTextStyle,
color: C.text,
@@ -96,7 +99,7 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
separator={() => <View style={{ height: 12 }} />}
empty={
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- No matching plugins.
+ {tr('No matching plugins.')}
</Text>
}
style={{ flex: 1, minHeight: 0 }}
@@ -104,7 +107,9 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
{(instance) => (
<Button
disabled={owner.disabled()}
- accessibilityLabel={`Open plugin ${instance().path.join(' / ')}`}
+ accessibilityLabel={tr('Open plugin {name}', {
+ name: instance().path.join(' / '),
+ })}
onPress={inputAction(owner.open, {
request,
instance: instance().instance,
@@ -127,8 +132,8 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
</Text>
<Text style={{ ...captionTextStyle, color: C.muted }}>
{instance().path.join(' / ')} ·{' '}
- {instance().disabled ? 'Disabled' : 'Enabled'}
- {instance().parentDisabled ? ' · Parent disabled' : ''}
+ {instance().disabled ? tr('Disabled') : tr('Enabled')}
+ {instance().parentDisabled ? ` · ${tr('Parent disabled')}` : ''}
</Text>
</View>
<Icon name="right" color={C.muted} />
@@ -153,8 +158,10 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
{owner.selected()!.presentation?.description}
</Text>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Saved configuration · revision {owner.inventory()!.source.revision}. Enabled
- shows the saved setting. Changes may take a moment to apply.
+ {tr(
+ 'Saved configuration · revision {revision}. Enabled shows the saved setting. Changes may take a moment to apply.',
+ { revision: owner.inventory()!.source.revision },
+ )}
</Text>
<If when={!owner.editor()}>
<Button
@@ -164,8 +171,8 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
}
accessibilityLabel={
owner.selected()!.disabled
- ? 'Enable Host plugin'
- : 'Disable Host plugin'
+ ? tr('Enable Host plugin')
+ : tr('Disable Host plugin')
}
onPress={inputAction(owner.toggle, {
request,
@@ -182,7 +189,9 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...bodyTextStyle, color: C.text, flex: 1 }}>Enabled</Text>
+ <Text style={{ ...bodyTextStyle, color: C.text, flex: 1 }}>
+ {tr('Enabled')}
+ </Text>
<ChoiceIndicator
kind="switch"
checked={!owner.selected()!.disabled}
@@ -194,17 +203,17 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
</Button>
<If when={owner.selected()!.parentDisabled}>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
- Enable the parent plugin before enabling this child.
+ {tr('Enable the parent plugin before enabling this child.')}
</Text>
</If>
<If when={owner.selected()!.structural}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Open a plugin in this group to edit its settings.
+ {tr('Open a plugin in this group to edit its settings.')}
</Text>
</If>
<If when={owner.fields()}>
<Text style={{ ...bodyTextStyle, color: C.text, fontWeight: '600' }}>
- Configuration
+ {tr('Configuration')}
{owner.fields()!.path.length
? ` / ${owner.fields()!.path.join(' / ')}`
: ''}
@@ -215,7 +224,11 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
return (
<Button
disabled={owner.disabled() || !field().editable}
- accessibilityLabel={`${group() ? 'Open plugin group' : 'Edit plugin setting'} ${field().label}`}
+ accessibilityLabel={
+ group()
+ ? tr('Open plugin group {name}', { name: field().label })
+ : tr('Edit plugin setting {name}', { name: field().label })
+ }
onPress={
group()
? inputAction(owner.group, { request, key: field().key })
@@ -246,11 +259,11 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
>
{group()
? field().kind === 'array'
- ? 'List'
- : 'Group'
+ ? tr('List')
+ : tr('Group')
: field().editable
? settingCaption(field().value)
- : 'Read only or private'}
+ : tr('Read only or private')}
</Text>
</View>
<Icon name="right" color={C.muted} />
@@ -260,7 +273,7 @@ export function PluginSettings(props: { owner: PluginSettingsOwner }) {
</Each>
<If when={!owner.fields()!.fields.length}>
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- No editable fields in this group.
+ {tr('No editable fields in this group.')}
</Text>
</If>
</If>
@@ -298,7 +311,9 @@ function PluginField(props: { owner: PluginSettingsOwner; request: PluginAction
<Button
disabled={owner.disabled()}
selected={sameValue(value(), choice())}
- accessibilityLabel={`Choose plugin setting ${settingCaption(choice())}`}
+ accessibilityLabel={tr('Choose plugin setting {value}', {
+ value: settingCaption(choice()),
+ })}
onPress={inputAction(owner.change, { request, value: choice() })}
style={{
...controlStyle,
@@ -326,7 +341,7 @@ function PluginField(props: { owner: PluginSettingsOwner; request: PluginAction
</If>
<If when={field.options.length === 0 && (field.kind === 'text' || field.kind === 'number')}>
<SettingsField
- label={`Plugin value ${field.label}`}
+ label={tr('Plugin value {name}', { name: field.label })}
value={
value()?.kind === 'text' || value()?.kind === 'number'
? (value() as { text: string }).text
@@ -337,7 +352,9 @@ function PluginField(props: { owner: PluginSettingsOwner; request: PluginAction
change(field.kind === 'number' ? { kind: 'number', text } : { kind: 'text', text })
}
hint={
- field.kind === 'number' ? 'Enter a number. Decimal text is kept exactly.' : undefined
+ field.kind === 'number'
+ ? tr('Enter a number. Decimal text is kept exactly.')
+ : undefined
}
/>
</If>
@@ -372,7 +389,9 @@ function PluginField(props: { owner: PluginSettingsOwner; request: PluginAction
}
disabled={owner.disabled()}
/>
- <Text style={{ ...bodyTextStyle, color: C.text }}>{choice() ? 'On' : 'Off'}</Text>
+ <Text style={{ ...bodyTextStyle, color: C.text }}>
+ {choice() ? tr('On') : tr('Off')}
+ </Text>
</Button>
)}
</Each>
@@ -399,7 +418,7 @@ function PluginField(props: { owner: PluginSettingsOwner; request: PluginAction
checked={value()?.kind === 'null'}
disabled={owner.disabled()}
/>
- <Text style={{ ...bodyTextStyle, color: C.text }}>Set null</Text>
+ <Text style={{ ...bodyTextStyle, color: C.text }}>{tr('Set null')}</Text>
</Button>
</If>
<If when={!field.required}>
@@ -420,12 +439,12 @@ function PluginField(props: { owner: PluginSettingsOwner; request: PluginAction
disabledStyle={controlDisabledStyle}
>
<ChoiceIndicator kind="radio" checked={value() === null} disabled={owner.disabled()} />
- <Text style={{ ...bodyTextStyle, color: C.text }}>Remove explicit value</Text>
+ <Text style={{ ...bodyTextStyle, color: C.text }}>{tr('Remove explicit value')}</Text>
</Button>
</If>
<Button
disabled={owner.disabled()}
- accessibilityLabel="Save Host plugin setting"
+ accessibilityLabel={tr('Save Host plugin setting')}
onPress={inputAction(owner.save, { request, value: value() })}
style={{ ...controlStyle, alignSelf: 'flex-start', backgroundColor: C.accent }}
hoverStyle={{ backgroundColor: C.accentHover }}
@@ -433,7 +452,7 @@ function PluginField(props: { owner: PluginSettingsOwner; request: PluginAction
disabledStyle={controlDisabledStyle}
>
<Text style={{ ...bodyTextStyle, color: C.background, fontWeight: '500' }}>
- Save plugin setting
+ {tr('Save plugin setting')}
</Text>
</Button>
</View>
diff --git a/products/maka/terminal-shell/app/QueueDock.tsx b/products/maka/terminal-shell/app/QueueDock.tsx
index bb3053b84a..5b54cd61b3 100644
--- a/products/maka/terminal-shell/app/QueueDock.tsx
+++ b/products/maka/terminal-shell/app/QueueDock.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { createText, errorText, message as uiMessage } from './text';
+import { tr } from './locale';
import { cleanup, computed, effect, Input, inputAction, Scroll, signal, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import { List } from 'nsh/list';
@@ -73,7 +75,7 @@ export function QueueDock(props: { session: QueueSession }) {
const editing = signal<Editing | null>(null);
const review = signal<Review | null>(null);
const busy = signal<{ through: number | null } | null>(null);
- const error = signal('');
+ const error = createText();
const copied = signal<string | null>(null);
let alive = true;
cleanup(() => {
@@ -90,16 +92,16 @@ export function QueueDock(props: { session: QueueSession }) {
const entry = (messageId: string) => items().find((row) => row.messageId === messageId);
const revision = () => session.snapshot()?.revision ?? -1;
const report = (reason: unknown) => {
- if (alive) error.set(reason instanceof Error ? reason.message : String(reason));
+ if (alive) error.set(errorText(reason));
};
const changedMessage = () =>
editing()
- ? 'The queue changed. Your edit is kept; review the current message.'
- : 'The queue changed. Select the current message again.';
+ ? uiMessage('The queue changed. Your edit is kept; review the current message.')
+ : uiMessage('The queue changed. Select the current message again.');
const missingMessage = () =>
editing()
- ? 'This message is no longer queued. Your edit is kept.'
- : 'This message is no longer queued.';
+ ? uiMessage('This message is no longer queued. Your edit is kept.')
+ : uiMessage('This message is no longer queued.');
const change = async (
expectedRevision: number,
edit: Exclude<AgentQueueAction, { kind: 'reorder' }>,
@@ -125,12 +127,12 @@ export function QueueDock(props: { session: QueueSession }) {
result.kind === 'revision_conflict'
? changedMessage()
: result.reason === 'execution_changed'
- ? 'This turn cannot accept this message right now. It remains queued.'
+ ? uiMessage('This turn cannot accept this message right now. It remains queued.')
: result.reason === 'not_running'
- ? 'No turn is running. This message remains queued.'
+ ? uiMessage('No turn is running. This message remains queued.')
: result.reason === 'missing' || result.reason === 'in_flight'
? missingMessage()
- : 'The Host did not accept this queue change.',
+ : uiMessage('The Host did not accept this queue change.'),
);
} catch (reason) {
if (alive) busy.set(null);
@@ -150,7 +152,7 @@ export function QueueDock(props: { session: QueueSession }) {
error.set(
result.kind === 'missing'
? missingMessage()
- : 'The queue changed. Select Edit or Review again for its current message.',
+ : uiMessage('The queue changed. Select Edit or Review again for its current message.'),
);
} catch (reason) {
report(reason);
@@ -163,7 +165,7 @@ export function QueueDock(props: { session: QueueSession }) {
if (!alive || request.sessionId !== session.sessionId || busy()) return;
const row = entry(request.messageId);
if (!row || request.revision !== revision()) {
- error.set('The queue changed before this action. Select the current row again.');
+ error.set(uiMessage('The queue changed before this action. Select the current row again.'));
return;
}
if (request.kind === 'edit') {
@@ -210,7 +212,7 @@ export function QueueDock(props: { session: QueueSession }) {
const edit = editing();
const latest = review();
if (!edit || !latest || latest.revision !== revision()) {
- error.set('The queue changed again. Review its current message before saving.');
+ error.set(uiMessage('The queue changed again. Review its current message before saving.'));
return;
}
editing.set({
@@ -247,8 +249,8 @@ export function QueueDock(props: { session: QueueSession }) {
>
<If when={items().length > 1}>
<Button
- accessibilityLabel="Queued messages"
- tooltip="Messages waiting for a turn"
+ accessibilityLabel={tr('Queued messages')}
+ tooltip={tr('Messages waiting for a turn')}
selected={expanded()}
disabled={editing() !== null || busy() !== null}
onPress={inputAction(() => collapsed.set(!collapsed()), null)}
@@ -258,14 +260,14 @@ export function QueueDock(props: { session: QueueSession }) {
>
<Icon name="queue" color={C.muted} />
<Text style={{ ...captionTextStyle, color: C.secondary, flex: 1 }}>
- {items().length} queued messages
+ {tr('{count} queued messages', { count: items().length })}
</Text>
<Icon name={expanded() ? 'down' : 'right'} color={C.muted} />
</Button>
</If>
<If when={expanded() && editing() === null}>
<List
- accessibilityLabel="Message queue"
+ accessibilityLabel={tr('Message queue')}
each={items()}
keyExtractor={(row) => row.messageId}
estimatedItemSize={rowHeight}
@@ -296,12 +298,12 @@ export function QueueDock(props: { session: QueueSession }) {
style={{ ...captionTextStyle, color: C.secondary, flex: 1 }}
>
{row().kind === 'steering'
- ? `Sending to current turn · ${row().preview}`
+ ? tr('Sending to current turn · {preview}', { preview: row().preview })
: row().preview}
</Text>
<Button
- accessibilityLabel={`Edit queued message ${number()}`}
- tooltip="Edit message"
+ accessibilityLabel={tr('Edit queued message {number}', { number: number() })}
+ tooltip={tr('Edit message')}
disabled={busy() !== null || editing() !== null}
onPress={inputAction(act, request('edit'))}
style={actionStyle}
@@ -311,8 +313,8 @@ export function QueueDock(props: { session: QueueSession }) {
<Icon name="edit" color={C.muted} />
</Button>
<Button
- accessibilityLabel={`Remove queued message ${number()}`}
- tooltip="Remove message"
+ accessibilityLabel={tr('Remove queued message {number}', { number: number() })}
+ tooltip={tr('Remove message')}
disabled={busy() !== null || editing() !== null}
onPress={inputAction(act, request('retract'))}
style={actionStyle}
@@ -322,11 +324,13 @@ export function QueueDock(props: { session: QueueSession }) {
<Icon name="close" color={C.muted} />
</Button>
<Button
- accessibilityLabel={`Steer queued message ${number()} now`}
+ accessibilityLabel={tr('Steer queued message {number} now', {
+ number: number(),
+ })}
tooltip={
row().kind === 'steering'
- ? 'Waiting for the next step'
- : 'Steer now · use in the current turn'
+ ? tr('Waiting for the next step')
+ : tr('Steer now · use in the current turn')
}
disabled={
busy() !== null ||
@@ -361,12 +365,12 @@ export function QueueDock(props: { session: QueueSession }) {
<View style={{ padding: 12, gap: 8 }}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
{entry(edit().messageId)
- ? 'Edit queued message'
- : 'Message no longer queued · your edit is kept'}
+ ? tr('Edit queued message')
+ : tr('Message no longer queued · your edit is kept')}
{edit().hasAttachments ? ' · attachments kept' : ''}
</Text>
<Input
- accessibilityLabel="Queued message editor"
+ accessibilityLabel={tr('Queued message editor')}
autoFocus
multiline
submitBehavior="submit"
@@ -386,7 +390,7 @@ export function QueueDock(props: { session: QueueSession }) {
/>
<If when={review()}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Current queued message
+ {tr('Current queued message')}
</Text>
<Scroll style={{ maxHeight: readerHeight(), flexGrow: 0 }}>
<Text selectable style={{ ...bodyTextStyle, color: C.secondary }}>
@@ -395,26 +399,26 @@ export function QueueDock(props: { session: QueueSession }) {
</Scroll>
<View style={{ flexDirection: 'row', gap: 8 }}>
<Button
- accessibilityLabel="Keep my queued message edit"
+ accessibilityLabel={tr('Keep my queued message edit')}
disabled={busy() !== null}
onPress={inputAction(resolveReview, false)}
style={controlStyle}
>
- <Text style={{ color: C.secondary }}>Keep my edit</Text>
+ <Text style={{ color: C.secondary }}>{tr('Keep my edit')}</Text>
</Button>
<Button
- accessibilityLabel="Use current queued message"
+ accessibilityLabel={tr('Use current queued message')}
disabled={busy() !== null}
onPress={inputAction(resolveReview, true)}
style={controlStyle}
>
- <Text style={{ color: C.secondary }}>Use current message</Text>
+ <Text style={{ color: C.secondary }}>{tr('Use current message')}</Text>
</Button>
</View>
</If>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
<Button
- accessibilityLabel="Save queued message"
+ accessibilityLabel={tr('Save queued message')}
disabled={
busy() !== null ||
!entry(edit().messageId) ||
@@ -426,36 +430,36 @@ export function QueueDock(props: { session: QueueSession }) {
hoverStyle={{ backgroundColor: C.raised }}
focusStyle={{ backgroundColor: C.line }}
>
- <Text style={{ color: C.accent }}>Save</Text>
+ <Text style={{ color: C.accent }}>{tr('Save')}</Text>
</Button>
<If when={entry(edit().messageId) && edit().revision !== revision()}>
<Button
- accessibilityLabel="Review current queued message"
+ accessibilityLabel={tr('Review current queued message')}
disabled={busy() !== null}
onPress={inputAction(reviewLatest, null)}
style={controlStyle}
>
- <Text style={{ color: C.secondary }}>Review current message</Text>
+ <Text style={{ color: C.secondary }}>{tr('Review current message')}</Text>
</Button>
</If>
<If when={!entry(edit().messageId)}>
<Button
- accessibilityLabel="Copy queued message edit"
+ accessibilityLabel={tr('Copy queued message edit')}
onPress={inputAction(copyEdit, null)}
style={controlStyle}
>
<Text style={{ color: C.secondary }}>
- {copied() === edit().text ? 'Edit copied' : 'Copy edit'}
+ {copied() === edit().text ? tr('Edit copied') : tr('Copy edit')}
</Text>
</Button>
</If>
<Button
- accessibilityLabel="Cancel queued message edit"
+ accessibilityLabel={tr('Cancel queued message edit')}
disabled={busy() !== null}
onPress={inputAction(cancel, null)}
style={controlStyle}
>
- <Text style={{ color: C.muted }}>Cancel</Text>
+ <Text style={{ color: C.muted }}>{tr('Cancel')}</Text>
</Button>
</View>
</View>
@@ -465,7 +469,7 @@ export function QueueDock(props: { session: QueueSession }) {
</Each>
<If when={busy()}>
<Text style={{ ...captionTextStyle, paddingHorizontal: 12, color: C.muted }}>
- Updating queue…
+ {tr('Updating queue…')}
</Text>
</If>
<If when={error()}>
diff --git a/products/maka/terminal-shell/app/ResourcePane.tsx b/products/maka/terminal-shell/app/ResourcePane.tsx
index d2f97052c2..eb4e0eae1c 100644
--- a/products/maka/terminal-shell/app/ResourcePane.tsx
+++ b/products/maka/terminal-shell/app/ResourcePane.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { tr } from './locale';
import { cleanup, effect, inputAction, Scroll, signal, Text, View } from 'nsh';
import type { YNode } from 'nsh';
import { Button } from 'nsh/base';
@@ -74,7 +75,7 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
<Scroll horizontal style={{ flex: 1, minWidth: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Button
- accessibilityLabel="Browse files"
+ accessibilityLabel={tr('Browse files')}
selected={resources.active() === 'files'}
ref={(node) => {
control = node;
@@ -98,11 +99,11 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
color: resources.active() === 'files' ? C.text : C.muted,
}}
>
- Files
+ {tr('Files')}
</Text>
</Button>
<Button
- accessibilityLabel="Browse current changes"
+ accessibilityLabel={tr('Browse current changes')}
selected={resources.active() === 'changes'}
onPress={inputAction(resources.inventory, {
sessionId: resources.sessionId()!,
@@ -119,11 +120,11 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
color: resources.active() === 'changes' ? C.text : C.muted,
}}
>
- Changes
+ {tr('Changes')}
</Text>
</Button>
<Button
- accessibilityLabel="Show conversation terminals"
+ accessibilityLabel={tr('Show conversation terminals')}
selected={resources.active() === 'terminals'}
onPress={inputAction(resources.inventory, {
sessionId: resources.sessionId()!,
@@ -143,7 +144,7 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
color: resources.active() === 'terminals' ? C.text : C.muted,
}}
>
- Terminal
+ {tr('Terminal')}
</Text>
</Button>
<Each of={resources.tabs()} key={(tab) => tab.instance}>
@@ -162,7 +163,7 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
sessionId: tab().sessionId,
instance: tab().instance,
})}
- accessibilityLabel={`Preview ${tab().title}`}
+ accessibilityLabel={tr('Preview {name}', { name: tab().title })}
tooltip={tab().title}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={controlHoverStyle}
@@ -196,8 +197,8 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
sessionId: tab().sessionId,
instance: tab().instance,
})}
- accessibilityLabel={`Close ${tab().title}`}
- tooltip={`Close ${tab().title}`}
+ accessibilityLabel={tr('Close {name}', { name: tab().title })}
+ tooltip={tr('Close {name}', { name: tab().title })}
style={{
...controlStyle,
width: 28,
@@ -217,9 +218,9 @@ export function ResourcePane(props: { resources: Resources; docked: boolean }) {
</View>
</Scroll>
<Button
- accessibilityLabel="Close workspace preview"
+ accessibilityLabel={tr('Close workspace preview')}
onPress={inputAction(resources.close, closeTarget)}
- tooltip="Close workspace preview · Esc"
+ tooltip={tr('Close workspace preview · Esc')}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -264,7 +265,7 @@ function OriginalImage(props: { image: ImagePreview; openImageViewer: OpenImageV
return (
<View style={{ flex: 1, minHeight: 0, gap: 8 }}>
<Text style={{ ...captionTextStyle, color: C.secondary, fontWeight: '500' }}>
- Image preview
+ {tr('Image preview')}
</Text>
<Image
src={props.image.uri}
@@ -276,7 +277,7 @@ function OriginalImage(props: { image: ImagePreview; openImageViewer: OpenImageV
/>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ ...captionTextStyle, flex: 1, color: C.muted }}>
- {props.image.width} × {props.image.height} · Original image
+ {props.image.width} × {props.image.height} · {tr('Original image')}
</Text>
<ImageViewerButton
open={props.openImageViewer}
@@ -284,7 +285,7 @@ function OriginalImage(props: { image: ImagePreview; openImageViewer: OpenImageV
uri: props.image.uri,
width: props.image.width,
height: props.image.height,
- title: 'Original image',
+ title: tr('Original image'),
}}
/>
</View>
diff --git a/products/maka/terminal-shell/app/SessionActions.tsx b/products/maka/terminal-shell/app/SessionActions.tsx
index 3b8e0c7369..90bac1c016 100644
--- a/products/maka/terminal-shell/app/SessionActions.tsx
+++ b/products/maka/terminal-shell/app/SessionActions.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { errorText, message, renderText, type UiText } from './text';
+import { tr } from './locale';
import { cleanup, effect, Input, inputAction, Scroll, signal, Text, View } from 'nsh';
import type { YNode } from 'nsh';
import { Button, Dialog, Popover } from 'nsh/base';
@@ -45,14 +47,14 @@ export function SessionActions(props: {
anchor(): YNode | null;
current(): boolean;
close(): void;
- notice(message: string): void;
+ notice(message: UiText): void;
change(action: SessionAction): Promise<void>;
}) {
const session = signal(props.session);
const page = signal<Page>('actions');
const title = signal('');
const pending = signal(false);
- const error = signal('');
+ const error = signal<UiText>('');
let alive = true;
cleanup(() => {
alive = false;
@@ -105,7 +107,7 @@ export function SessionActions(props: {
if (active()) props.close();
} catch (reason) {
if (!active()) return;
- error.set(reason instanceof Error ? reason.message : String(reason));
+ error.set(errorText(reason));
if (reason instanceof SessionRevisionConflict) {
// Keep the typed title. The refreshed snapshot supplies the next
// explicitly requested action, never an automatic mutation retry.
@@ -115,7 +117,10 @@ export function SessionActions(props: {
} catch (refreshError) {
if (active())
error.set(
- `${error()} ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`,
+ message('{message} {detail}', {
+ message: errorText(reason),
+ detail: errorText(refreshError),
+ }),
);
}
}
@@ -126,7 +131,7 @@ export function SessionActions(props: {
return (
<View>
<If when={page() === 'actions' && !props.anchor() && pending()}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Updating conversation…</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>{tr('Updating conversation…')}</Text>
</If>
<If when={page() === 'actions'}>
<Popover
@@ -152,7 +157,7 @@ export function SessionActions(props: {
}}
>
<Scroll style={{ flexShrink: 1, minHeight: 0 }}>
- <View role="menu" accessibilityLabel="Conversation actions" style={{ gap: 2 }}>
+ <View role="menu" accessibilityLabel={tr('Conversation actions')} style={{ gap: 2 }}>
<Text
style={{
...captionTextStyle,
@@ -161,11 +166,11 @@ export function SessionActions(props: {
paddingVertical: 6,
}}
>
- Conversation actions
+ {tr('Conversation actions')}
</Text>
<Button
disabled={pending() || session().archived}
- accessibilityLabel="Rename conversation"
+ accessibilityLabel={tr('Rename conversation')}
role="menuitem"
style={menuRowStyle()}
hoverStyle={controlHoverStyle}
@@ -175,12 +180,12 @@ export function SessionActions(props: {
>
<Icon name="edit" color={C.secondary} />
<Text accessibilityElementsHidden style={{ ...secondaryTextStyle, color: C.text }}>
- Rename
+ {tr('Rename')}
</Text>
</Button>
<Button
disabled={pending()}
- accessibilityLabel="Fork conversation"
+ accessibilityLabel={tr('Fork conversation')}
role="menuitem"
style={menuRowStyle()}
hoverStyle={controlHoverStyle}
@@ -190,13 +195,13 @@ export function SessionActions(props: {
>
<Icon name="fork" color={C.secondary} />
<Text accessibilityElementsHidden style={{ ...secondaryTextStyle, color: C.text }}>
- Fork conversation
+ {tr('Fork conversation')}
</Text>
</Button>
<Button
disabled={pending()}
accessibilityLabel={
- session().archived ? 'Restore conversation' : 'Archive conversation'
+ session().archived ? tr('Restore conversation') : tr('Archive conversation')
}
role="menuitem"
style={menuRowStyle()}
@@ -210,7 +215,7 @@ export function SessionActions(props: {
>
<Icon name={session().archived ? 'latest' : 'archive'} color={C.secondary} />
<Text accessibilityElementsHidden style={{ ...secondaryTextStyle, color: C.text }}>
- {session().archived ? 'Restore' : 'Archive'}
+ {session().archived ? tr('Restore') : tr('Archive')}
</Text>
</Button>
<View
@@ -219,7 +224,7 @@ export function SessionActions(props: {
/>
<Button
disabled={pending()}
- accessibilityLabel="Delete conversation"
+ accessibilityLabel={tr('Delete conversation')}
role="menuitem"
style={menuRowStyle()}
hoverStyle={controlHoverStyle}
@@ -232,17 +237,17 @@ export function SessionActions(props: {
accessibilityElementsHidden
style={{ ...secondaryTextStyle, color: C.danger }}
>
- Delete…
+ {tr('Delete…')}
</Text>
</Button>
- <If when={error()}>
+ <If when={renderText(error())}>
<Text selectable style={{ ...bodyTextStyle, color: C.danger, padding: 8 }}>
- {error()}
+ {renderText(error())}
</Text>
</If>
<If when={pending()}>
<Text style={{ ...captionTextStyle, color: C.muted, padding: 8 }}>
- Updating conversation…
+ {tr('Updating conversation…')}
</Text>
</If>
</View>
@@ -280,8 +285,8 @@ export function SessionActions(props: {
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Button
disabled={pending()}
- accessibilityLabel="Back from conversation actions"
- tooltip="Back · Esc"
+ accessibilityLabel={tr('Back from conversation actions')}
+ tooltip={tr('Back · Esc')}
onPress={inputAction(back, page())}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
@@ -295,17 +300,17 @@ export function SessionActions(props: {
style={{ flex: 1, color: C.text, fontSize: 18, lineHeight: 24, fontWeight: '600' }}
>
{page() === 'rename'
- ? 'Rename conversation'
+ ? tr('Rename conversation')
: page() === 'fork'
- ? 'Fork conversation'
+ ? tr('Fork conversation')
: page() === 'remove'
- ? 'Delete conversation?'
- : 'Conversation actions'}
+ ? tr('Delete conversation?')
+ : tr('Conversation actions')}
</Text>
<Button
disabled={pending()}
- accessibilityLabel="Close conversation actions"
- tooltip="Close"
+ accessibilityLabel={tr('Close conversation actions')}
+ tooltip={tr('Close')}
onPress={inputAction(dismiss, page())}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
@@ -318,21 +323,21 @@ export function SessionActions(props: {
<Scroll style={{ flexShrink: 1, minHeight: 0 }}>
<View style={{ gap: 8 }}>
<Text selectable style={{ color: C.text }}>
- {session().title || 'Untitled conversation'}
+ {session().title || tr('Untitled conversation')}
</Text>
<Text numberOfLines={2} style={{ color: C.muted }}>
{session().workspace.path}
</Text>
<If when={session().archived}>
- <Text style={{ color: C.warning }}>Archived conversation</Text>
+ <Text style={{ color: C.warning }}>{tr('Archived conversation')}</Text>
</If>
- <If when={error()}>
+ <If when={renderText(error())}>
<Text selectable style={{ color: C.danger }}>
- {error()}
+ {renderText(error())}
</Text>
</If>
<If when={pending()}>
- <Text style={{ color: C.muted }}>Confirming the original action…</Text>
+ <Text style={{ color: C.muted }}>{tr('Confirming the original action…')}</Text>
</If>
<If when={page() === 'rename' || page() === 'fork'}>
<Input
@@ -341,11 +346,11 @@ export function SessionActions(props: {
node.setSelection(0, title().length);
}}
autoFocus
- accessibilityLabel="Conversation title"
+ accessibilityLabel={tr('Conversation title')}
value={title()}
readonly={pending()}
onChange={(value) => title.set(value)}
- placeholder="Conversation title"
+ placeholder={tr('Conversation title')}
style={{
height: 32,
color: C.text,
@@ -360,14 +365,17 @@ export function SessionActions(props: {
/>
<If when={page() === 'fork'}>
<Text style={{ color: C.muted }}>
- Copy the current history into an independent conversation. This draft stays
- with its original conversation.
+ {tr(
+ 'Copy the current history into an independent conversation. This draft stays with its original conversation.',
+ )}
</Text>
</If>
<Button
disabled={pending() || !title().trim()}
accessibilityLabel={
- page() === 'fork' ? 'Create conversation fork' : 'Save conversation title'
+ page() === 'fork'
+ ? tr('Create conversation fork')
+ : tr('Save conversation title')
}
style={{ ...controlStyle, alignSelf: 'flex-start', backgroundColor: C.accent }}
hoverStyle={{ backgroundColor: C.accentHover }}
@@ -381,25 +389,26 @@ export function SessionActions(props: {
)}
>
<Text style={{ color: C.background }}>
- {page() === 'fork' ? 'Create fork' : 'Save title'}
+ {page() === 'fork' ? tr('Create fork') : tr('Save title')}
</Text>
</Button>
</If>
<If when={page() === 'remove'}>
<Text style={{ color: C.warning }}>
- Permanently delete this conversation and its history. Workspace files and
- independent forks are kept.
+ {tr(
+ 'Permanently delete this conversation and its history. Workspace files and independent forks are kept.',
+ )}
</Text>
<Button
disabled={pending()}
- accessibilityLabel="Confirm delete conversation"
+ accessibilityLabel={tr('Confirm delete conversation')}
style={controlStyle}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
onPress={inputAction(submit, { kind: 'remove', session: session() })}
>
- <Text style={{ color: C.danger }}>Delete permanently</Text>
+ <Text style={{ color: C.danger }}>{tr('Delete permanently')}</Text>
</Button>
<Button
disabled={pending()}
@@ -409,7 +418,7 @@ export function SessionActions(props: {
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ color: C.text }}>Keep conversation</Text>
+ <Text style={{ color: C.text }}>{tr('Keep conversation')}</Text>
</Button>
</If>
</View>
diff --git a/products/maka/terminal-shell/app/Terminal.tsx b/products/maka/terminal-shell/app/Terminal.tsx
index 6a47f6ea22..005906738a 100644
--- a/products/maka/terminal-shell/app/Terminal.tsx
+++ b/products/maka/terminal-shell/app/Terminal.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { createText, errorText } from './text';
import {
cleanup,
computed,
@@ -39,7 +40,9 @@ import type {
TerminalSize,
} from '../../../../packages/plugin-sdk/src/index.js';
import type { TerminalModel } from './terminalState';
+import { terminalStatus } from './terminalState';
import { Icon } from './Icon';
+import { tr } from './locale';
import {
C,
bodyTextStyle,
@@ -123,7 +126,8 @@ export function Terminal(props: { model: TerminalModel }) {
const offset = signal(0);
const inputValue = signal('');
const confirmation = signal(false);
- const copyStatus = signal('');
+ const copyStatus = createText();
+ const copied = signal(false);
let input: YNode | null = null;
let scroll: YNode | null = null;
let body: YNode | null = null;
@@ -159,9 +163,15 @@ export function Terminal(props: { model: TerminalModel }) {
)
.join('\n'),
);
- if (mounted) copyStatus.set('Visible terminal copied.');
+ if (mounted) {
+ copied.set(true);
+ copyStatus.set('');
+ }
} catch (reason) {
- if (mounted) copyStatus.set(reason instanceof Error ? reason.message : String(reason));
+ if (mounted) {
+ copied.set(false);
+ copyStatus.set(errorText(reason));
+ }
}
};
const attach = (node: YNode) => {
@@ -341,8 +351,10 @@ export function Terminal(props: { model: TerminalModel }) {
model.takeControl();
input?.focus();
}}
- accessibilityLabel="Focus terminal input"
- tooltip={model.control() === 'held' ? 'Terminal input is active' : 'Focus terminal input'}
+ accessibilityLabel={tr('Focus terminal input')}
+ tooltip={
+ model.control() === 'held' ? tr('Terminal input is active') : tr('Focus terminal input')
+ }
selected={model.control() === 'held'}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
selectedStyle={{ backgroundColor: C.raised }}
@@ -351,24 +363,24 @@ export function Terminal(props: { model: TerminalModel }) {
disabledStyle={controlDisabledStyle}
>
<Icon name="terminal" color={C.secondary} />
- <Text style={{ ...captionTextStyle, color: C.secondary }}>Input</Text>
+ <Text style={{ ...captionTextStyle, color: C.secondary }}>{tr('Input')}</Text>
</Button>
<If when={model.control() === 'held'}>
<Button
onPress={inputAction(model.release, null)}
- tooltip="Release terminal input · Ctrl+Shift+Space"
+ tooltip={tr('Release terminal input · Ctrl+Shift+Space')}
style={controlStyle}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
>
- <Text style={{ ...captionTextStyle, color: C.secondary }}>Release input</Text>
+ <Text style={{ ...captionTextStyle, color: C.secondary }}>{tr('Release input')}</Text>
</Button>
</If>
<View style={{ flex: 1 }} />
<Button
onPress={() => void copy()}
- accessibilityLabel="Copy visible terminal"
- tooltip="Copy visible terminal · Ctrl+Shift+C"
+ accessibilityLabel={tr('Copy visible terminal')}
+ tooltip={tr('Copy visible terminal · Ctrl+Shift+C')}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -379,8 +391,8 @@ export function Terminal(props: { model: TerminalModel }) {
<Button
disabled={model.stopping()}
onPress={() => confirmation.set(true)}
- accessibilityLabel="Stop terminal process"
- tooltip={model.stopping() ? 'Stopping process…' : 'Stop terminal process'}
+ accessibilityLabel={tr('Stop terminal process')}
+ tooltip={model.stopping() ? tr('Stopping process…') : tr('Stop terminal process')}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -391,15 +403,15 @@ export function Terminal(props: { model: TerminalModel }) {
</If>
</View>
<Text
- tooltip="Terminal history · Shift+PageUp / Shift+PageDown"
+ tooltip={tr('Terminal history · Shift+PageUp / Shift+PageDown')}
style={{ ...captionTextStyle, color: C.muted }}
>
- {model.entry().result.status} ·{' '}
+ {terminalStatus(model.entry().result.status)} ·{' '}
{model.control() === 'held'
- ? 'input held'
+ ? tr('input held')
: model.local()
- ? 'viewing'
- : 'shared · view only'}
+ ? tr('viewing')
+ : tr('shared · view only')}
</Text>
<If when={model.error()}>
<Text selectable style={{ ...bodyTextStyle, color: C.danger }}>
@@ -421,17 +433,20 @@ export function Terminal(props: { model: TerminalModel }) {
>
<Icon name="refresh" color={C.secondary} />
<Text style={{ ...captionTextStyle, color: C.secondary }}>
- Retry terminal observation
+ {tr('Retry terminal observation')}
</Text>
</Button>
</If>
- <If when={copyStatus()}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>{copyStatus()}</Text>
+ <If when={copied() || copyStatus()}>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>
+ {copied() ? tr('Visible terminal copied.') : copyStatus()}
+ </Text>
</If>
<If when={model.state() === 'unavailable'}>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
- Live output is no longer retained. Input is released; the final display will appear after
- the process settles.
+ {tr(
+ 'Live output is no longer retained. Input is released; the final display will appear after the process settles.',
+ )}
</Text>
</If>
<View
@@ -451,7 +466,7 @@ export function Terminal(props: { model: TerminalModel }) {
onResponderMove={(event) => void mouse(event, 'move')}
onResponderRelease={(event) => void mouse(event, 'release')}
onResponderTerminate={() => model.release()}
- accessibilityLabel="Terminal output"
+ accessibilityLabel={tr('Terminal output')}
style={{ flex: 1, minHeight: LINE, backgroundColor: '#151517', overflow: 'hidden' }}
>
<If when={model.entry().result.mode === 'pty'}>
@@ -582,7 +597,7 @@ export function Terminal(props: { model: TerminalModel }) {
caret={false}
multiline
submitBehavior="newline"
- accessibilityLabel="Terminal keyboard input"
+ accessibilityLabel={tr('Terminal keyboard input')}
style={{
position: 'absolute',
width: CELL,
@@ -617,8 +632,8 @@ export function Terminal(props: { model: TerminalModel }) {
>
<Text style={{ ...bodyTextStyle, color: C.text }}>
{model.agentOwned()
- ? 'Stop the agent’s running terminal? This interrupts its command.'
- : 'Stop this terminal and its running process?'}
+ ? tr('Stop the agent’s running terminal? This interrupts its command.')
+ : tr('Stop this terminal and its running process?')}
</Text>
<View
style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-end', gap: 8 }}
@@ -629,10 +644,12 @@ export function Terminal(props: { model: TerminalModel }) {
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
>
- <Text style={{ ...captionTextStyle, color: C.secondary }}>Keep running</Text>
+ <Text style={{ ...captionTextStyle, color: C.secondary }}>
+ {tr('Keep running')}
+ </Text>
</Button>
<Button
- accessibilityLabel="Confirm stop terminal process"
+ accessibilityLabel={tr('Confirm stop terminal process')}
onPress={() => {
confirmation.set(false);
void model.stop();
@@ -641,7 +658,7 @@ export function Terminal(props: { model: TerminalModel }) {
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
>
- <Text style={{ ...captionTextStyle, color: C.danger }}>Stop process</Text>
+ <Text style={{ ...captionTextStyle, color: C.danger }}>{tr('Stop process')}</Text>
</Button>
</View>
</View>
diff --git a/products/maka/terminal-shell/app/Terminals.tsx b/products/maka/terminal-shell/app/Terminals.tsx
index eb39fae658..ea4141ac1e 100644
--- a/products/maka/terminal-shell/app/Terminals.tsx
+++ b/products/maka/terminal-shell/app/Terminals.tsx
@@ -17,10 +17,11 @@
* under the License.
*/
+import { tr } from './locale';
import { inputAction, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import { List } from 'nsh/list';
-import type { TerminalsModel } from './terminalState';
+import { terminalStatus, type TerminalsModel } from './terminalState';
import { Icon } from './Icon';
import {
C,
@@ -40,7 +41,7 @@ export function Terminals(props: { model: TerminalsModel; sessionId: string }) {
<Button
onPress={inputAction(model.start, null)}
disabled={model.starting() || !model.known() || !!model.pending().length}
- accessibilityLabel="Start interactive terminal"
+ accessibilityLabel={tr('Start interactive terminal')}
style={{
...controlStyle,
flexDirection: 'row',
@@ -54,13 +55,13 @@ export function Terminals(props: { model: TerminalsModel; sessionId: string }) {
>
<Icon name="plus" color={C.secondary} />
<Text style={{ ...captionTextStyle, color: C.text }}>
- {model.starting() ? 'Starting shell…' : 'New terminal'}
+ {model.starting() ? tr('Starting shell…') : tr('New terminal')}
</Text>
</Button>
<Button
onPress={inputAction(model.refresh, null)}
- accessibilityLabel="Refresh terminals"
- tooltip="Refresh terminals"
+ accessibilityLabel={tr('Refresh terminals')}
+ tooltip={tr('Refresh terminals')}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -77,7 +78,9 @@ export function Terminals(props: { model: TerminalsModel; sessionId: string }) {
{(operation) => (
<View style={{ gap: 8, padding: 12, borderRadius: 8, backgroundColor: C.surface }}>
<Text style={{ ...bodyTextStyle, color: C.warning }}>
- Unresolved {operation().method} · input has not been repeated.
+ {tr('Unresolved {method} · input has not been repeated.', {
+ method: operation().method,
+ })}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
<Button
@@ -92,7 +95,7 @@ export function Terminals(props: { model: TerminalsModel; sessionId: string }) {
focusStyle={controlFocusStyle}
disabledStyle={controlDisabledStyle}
>
- <Text style={{ ...captionTextStyle, color: C.text }}>Recover original</Text>
+ <Text style={{ ...captionTextStyle, color: C.text }}>{tr('Recover original')}</Text>
</Button>
<If when={!operation().reference}>
<Button
@@ -108,7 +111,7 @@ export function Terminals(props: { model: TerminalsModel; sessionId: string }) {
disabledStyle={controlDisabledStyle}
>
<Text style={{ ...captionTextStyle, color: C.secondary }}>
- Cancel undispatched action
+ {tr('Cancel undispatched action')}
</Text>
</Button>
</If>
@@ -117,24 +120,24 @@ export function Terminals(props: { model: TerminalsModel; sessionId: string }) {
)}
</Each>
<If when={model.loading() && !model.entries().length}>
- <Text style={{ ...bodyTextStyle, color: C.muted }}>Reading terminals…</Text>
+ <Text style={{ ...bodyTextStyle, color: C.muted }}>{tr('Reading terminals…')}</Text>
</If>
<If when={!model.loading() && !model.error() && !model.entries().length}>
<Text style={{ ...bodyTextStyle, color: C.muted }}>
- Start a shell in this conversation’s workspace. Agent terminals also appear here.
+ {tr('Start a shell in this conversation’s workspace. Agent terminals also appear here.')}
</Text>
</If>
<List
each={model.entries()}
keyExtractor={(entry) => entry.result.ref}
estimatedItemSize={64}
- accessibilityLabel="Conversation terminals"
+ accessibilityLabel={tr('Conversation terminals')}
style={{ flex: 1, minHeight: 0 }}
>
{(entry) => (
<Button
onPress={inputAction(model.open, entry())}
- accessibilityLabel={`Open terminal: ${entry().result.cmd}`}
+ accessibilityLabel={tr('Open terminal {name}', { name: entry().result.cmd })}
style={{
...controlStyle,
paddingHorizontal: 12,
@@ -152,11 +155,11 @@ export function Terminals(props: { model: TerminalsModel; sessionId: string }) {
numberOfLines={1}
style={{ ...captionTextStyle, color: C.text, fontWeight: '500' }}
>
- {entry().result.cmd || 'Interactive shell'}
+ {entry().result.cmd || tr('Interactive shell')}
</Text>
<Text numberOfLines={1} style={{ ...captionTextStyle, color: C.muted }}>
- {entry().result.status} · {entry().result.mode} ·{' '}
- {entry().ownership.kind === 'local' ? entry().result.cwd : 'shared · view only'}
+ {terminalStatus(entry().result.status)} · {entry().result.mode} ·{' '}
+ {entry().ownership.kind === 'local' ? entry().result.cwd : tr('shared · view only')}
</Text>
</View>
</Button>
diff --git a/products/maka/terminal-shell/app/ToolCard.tsx b/products/maka/terminal-shell/app/ToolCard.tsx
index 8f02b929a2..5548f509d7 100644
--- a/products/maka/terminal-shell/app/ToolCard.tsx
+++ b/products/maka/terminal-shell/app/ToolCard.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { displayText, tr } from './locale';
import { computed, inputAction, Scroll, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import type { HistoryTool } from '../../../../packages/plugin-sdk/src/index.js';
@@ -76,7 +77,7 @@ function ProcessOutcome(props: { reference: ToolSourceRef; sessionId: string; th
'status',
]);
return (
- <If when={status.complete() && !status.error()} else={<Status label="Recorded" />}>
+ <If when={status.complete() && !status.error()} else={<Status label={tr('Recorded')} />}>
<Each of={[status.content()]}>
{(value) => {
const phase = value();
@@ -92,16 +93,16 @@ function ProcessOutcome(props: { reference: ToolSourceRef; sessionId: string; th
['failed', 'timed_out', 'orphaned'].includes(phase) ||
(typeof code() === 'number' && code() !== 0);
const label = () => {
- if (phase === 'cancelled') return 'Stopped';
- if (phase === 'orphaned') return 'Unknown';
- if (failed()) return 'Error';
- if (phase === 'completed') return 'Completed';
- if (phase === 'starting') return 'Preparing at capture';
- return phase === 'running' ? 'Running at capture' : 'Recorded';
+ if (phase === 'cancelled') return tr('Stopped');
+ if (phase === 'orphaned') return tr('Unknown');
+ if (failed()) return tr('Error');
+ if (phase === 'completed') return tr('Completed');
+ if (phase === 'starting') return tr('Preparing at capture');
+ return phase === 'running' ? tr('Running at capture') : tr('Recorded');
};
return (
<Status
- label={`${label()}${typeof code() === 'number' ? ` · Exit ${code()}` : ''}`}
+ label={`${label()}${typeof code() === 'number' ? ` · ${tr('Exit {code}', { code: String(code()) })}` : ''}`}
success={phase === 'completed' && !failed()}
error={phase !== 'cancelled' && failed()}
warning={phase === 'cancelled'}
@@ -116,19 +117,19 @@ function ProcessOutcome(props: { reference: ToolSourceRef; sessionId: string; th
function Outcome(props: { reference: ToolSourceRef; sessionId: string; through: number }) {
const kind = createToolSource(props.reference, props.sessionId, props.through, ['kind']);
return (
- <If when={kind.complete() && !kind.error()} else={<Status label="Recorded" />}>
+ <If when={kind.complete() && !kind.error()} else={<Status label={tr('Recorded')} />}>
<If
when={kind.content() === 'completed'}
else={
<Status
- label={kind.content() === 'failed' ? 'Error' : 'Recorded'}
+ label={kind.content() === 'failed' ? tr('Error') : tr('Recorded')}
error={kind.content() === 'failed'}
/>
}
>
<If
when={processFamily(cardFamily(props.reference.presentation))}
- else={<Status label="Completed" success />}
+ else={<Status label={tr('Completed')} success />}
>
<ProcessOutcome
reference={props.reference}
@@ -160,7 +161,9 @@ function Field(props: {
else={
<OriginalToolSource
source={source}
- label={props.reference.field === 'arguments' ? 'Original arguments' : 'Original result'}
+ label={
+ props.reference.field === 'arguments' ? tr('Original arguments') : tr('Original result')
+ }
/>
}
>
@@ -185,35 +188,35 @@ export function ToolCard(props: {
const tool = (): HistoryTool | null => props.row().tool ?? null;
const family = () => cardFamily(tool()?.call?.presentation);
const title = () => {
- const supplied = tool()?.call?.title?.fallback;
- if (supplied) return supplied;
+ const supplied = tool()?.call?.title;
+ if (supplied) return displayText(supplied);
switch (family()) {
case 'read':
- return 'Read file';
+ return tr('Read file');
case 'write':
- return 'Write file';
+ return tr('Write file');
case 'edit':
- return 'Edit file';
+ return tr('Edit file');
case 'patch':
- return 'Patch file';
+ return tr('Patch file');
case 'glob':
- return 'Find files';
+ return tr('Find files');
case 'grep':
- return 'Search files';
+ return tr('Search files');
case 'execute':
- return 'Run command';
+ return tr('Run command');
case 'processRead':
- return 'Read process output';
+ return tr('Read process output');
case 'stdin':
- return 'Send process input';
+ return tr('Send process input');
case 'stop':
- return 'Stop process';
+ return tr('Stop process');
case 'fetch':
- return 'Read web page';
+ return tr('Read web page');
case 'webSearch':
- return 'Search the web';
+ return tr('Search the web');
default:
- return tool()?.call?.name ?? 'Tool';
+ return tool()?.call?.name ?? tr('Tool');
}
};
// Open at the recorded output; the complete original arguments remain below.
@@ -229,25 +232,27 @@ export function ToolCard(props: {
const value = tool();
if (value?.result)
return value.result.settlement === 'unknown'
- ? 'Unknown'
+ ? tr('Unknown')
: value.result.isError || value.result.settlement === 'failed'
- ? 'Error'
+ ? tr('Error')
: value.result.settlement === 'rejected'
- ? 'Rejected'
- : 'Recorded';
+ ? tr('Rejected')
+ : tr('Recorded');
// History references describe their fence, never current execution liveness.
return value?.call
? value.call.dispatched
- ? 'Dispatched'
- : 'Input recorded'
- : 'Not completed';
+ ? tr('Dispatched')
+ : tr('Input recorded')
+ : tr('Not completed');
};
return (
<View style={{ width: '100%', minWidth: 0, gap: detailsGap }}>
<View style={{ flexDirection: 'row', alignItems: 'center', minWidth: 0 }}>
<Button
selected={props.expanded()}
- accessibilityLabel={`Toggle tool ${tool()?.call?.name ?? 'details'}`}
+ accessibilityLabel={tr('Toggle tool {name}', {
+ name: tool()?.call?.name ?? tr('Tool details'),
+ })}
tooltip={title()}
onPress={inputAction(props.expand, {
sessionId: props.sessionId,
@@ -283,8 +288,8 @@ export function ToolCard(props: {
else={
<Status
label={fallbackState()}
- error={fallbackState() === 'Error'}
- warning={fallbackState() === 'Rejected' || fallbackState() === 'Unknown'}
+ error={fallbackState() === tr('Error')}
+ warning={fallbackState() === tr('Rejected') || fallbackState() === tr('Unknown')}
/>
}
>
@@ -301,8 +306,10 @@ export function ToolCard(props: {
</Button>
<If when={props.expanded()}>
<Button
- accessibilityLabel="Toggle original tool details"
- tooltip={props.original() ? 'Show readable tool details' : 'Show original tool data'}
+ accessibilityLabel={tr('Toggle original tool details')}
+ tooltip={
+ props.original() ? tr('Show readable tool details') : tr('Show original tool data')
+ }
selected={props.original()}
onPress={inputAction(props.showOriginal, {
sessionId: props.sessionId,
@@ -326,7 +333,7 @@ export function ToolCard(props: {
<If when={props.expanded()}>
<View style={{ paddingLeft: 16 }}>
<Scroll
- accessibilityLabel="Tool details"
+ accessibilityLabel={tr('Tool details')}
onScroll={() => props.reading?.()}
style={{
minHeight: 16,
@@ -355,7 +362,7 @@ export function ToolCard(props: {
</Each>
<If when={!references().length}>
<Text selectable style={{ color: C.muted }}>
- {props.row().text || 'Original tool source is unavailable in this history row.'}
+ {props.row().text || tr('Original tool source is unavailable in this history row.')}
</Text>
</If>
</Scroll>
diff --git a/products/maka/terminal-shell/app/ToolReader.tsx b/products/maka/terminal-shell/app/ToolReader.tsx
index 2c624d1cf9..a7afd52780 100644
--- a/products/maka/terminal-shell/app/ToolReader.tsx
+++ b/products/maka/terminal-shell/app/ToolReader.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { tr } from './locale';
import { cleanup, computed, inputAction, Scroll, signal, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import { Clipboard } from 'nsh/clipboard';
@@ -58,10 +59,10 @@ function Copy(props: { text(): string; label: string; source: ToolSource }) {
return (
<View style={{ alignItems: 'flex-end', minWidth: 32, flexShrink: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>{copied() ? 'Copied' : ''}</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>{copied() ? tr('Copied') : ''}</Text>
<Button
accessibilityLabel={props.label}
- tooltip={copied() ? 'Copied' : props.label}
+ tooltip={copied() ? tr('Copied') : props.label}
onPress={action()}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={{ backgroundColor: C.line }}
@@ -136,7 +137,7 @@ function ToolText(props: {
<Text
style={{ ...captionTextStyle, color: C.muted, paddingHorizontal: 12, paddingBottom: 12 }}
>
- Empty recorded text.
+ {tr('Empty recorded text.')}
</Text>
</If>
</View>
@@ -148,26 +149,31 @@ function Read(props: { value: ToolRead; source: ToolSource }) {
const lines = value.content.split('\n');
if (lines.at(-1) === '') lines.pop();
const gutter = lines.map((_, index) => String(value.offset + index + 1)).join('\n');
- const label = value.partialLine
- ? `Read lines · partial line · ${value.totalLines} total`
- : `Read lines ${value.returnedLines ? value.offset + 1 : value.offset}–${value.offset + value.returnedLines} / ${value.totalLines}`;
+ const label = () =>
+ value.partialLine
+ ? tr('Read lines · partial line · {total} total', { total: value.totalLines })
+ : tr('Read lines {first}–{last} / {total}', {
+ first: value.returnedLines ? value.offset + 1 : value.offset,
+ last: value.offset + value.returnedLines,
+ total: value.totalLines,
+ });
return (
<View style={{ gap: 8 }}>
<ToolText
- label={label}
+ label={label()}
text={value.content}
gutter={gutter}
- copyLabel="Copy read contents"
+ copyLabel={tr('Copy read contents')}
source={props.source}
/>
<If when={value.partialLine}>
<Text style={{ ...captionTextStyle, color: C.warning }}>
- This original result contains part of a line.
+ {tr('This original result contains part of a line.')}
</Text>
</If>
<If when={value.hasMore}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- The tool read a range of this file; this card preserves that original range.
+ {tr('The tool read a range of this file; this card preserves that original range.')}
</Text>
</If>
</View>
@@ -176,15 +182,16 @@ function Read(props: { value: ToolRead; source: ToolSource }) {
function Process(props: { value: ToolProcess; source: ToolSource }) {
const value = props.value;
- const label = {
- starting: 'Preparing at capture',
- running: 'Running at capture',
- completed: 'Completed',
- failed: 'Failed',
- timed_out: 'Timed out',
- cancelled: 'Stopped',
- orphaned: 'Outcome unknown',
- }[value.status];
+ const label = () =>
+ ({
+ starting: tr('Preparing at capture'),
+ running: tr('Running at capture'),
+ completed: tr('Completed'),
+ failed: tr('Failed'),
+ timed_out: tr('Timed out'),
+ cancelled: tr('Stopped'),
+ orphaned: tr('Outcome unknown'),
+ })[value.status];
const failed =
value.status !== 'cancelled' &&
(['failed', 'timed_out', 'orphaned'].includes(value.status) ||
@@ -200,18 +207,20 @@ function Process(props: { value: ToolProcess; source: ToolSource }) {
color: failed ? C.danger : value.status === 'cancelled' ? C.warning : C.secondary,
}}
>
- {label}
- {value.exitCode === null ? '' : ` · Exit ${value.exitCode}`}
+ {label()}
+ {value.exitCode === null
+ ? ''
+ : ` · ${tr('Exit {code}', { code: String(value.exitCode) })}`}
</Text>
</View>
<ToolText
- label="Recorded command"
+ label={tr('Recorded command')}
text={value.command}
- copyLabel="Copy recorded command"
+ copyLabel={tr('Copy recorded command')}
source={props.source}
/>
<Text selectable style={{ ...captionTextStyle, color: C.muted }}>
- Working directory · {value.cwd}
+ {tr('Working directory')} · {value.cwd}
</Text>
<If when={value.failure !== null}>
<Text selectable style={{ ...bodyTextStyle, color: C.danger }}>
@@ -220,7 +229,7 @@ function Process(props: { value: ToolProcess; source: ToolSource }) {
</If>
<If when={output === null}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- No output was included in this original result.
+ {tr('No output was included in this original result.')}
</Text>
</If>
{output?.mode === 'pipes' ? (
@@ -228,54 +237,54 @@ function Process(props: { value: ToolProcess; source: ToolSource }) {
<ToolText
label="stdout"
text={output.stdout}
- copyLabel="Copy stdout"
+ copyLabel={tr('Copy stdout')}
source={props.source}
/>
<ToolText
label="stderr"
text={output.stderr}
- copyLabel="Copy stderr"
+ copyLabel={tr('Copy stderr')}
source={props.source}
tone="warning"
/>
<If when={output.stdoutTruncated || output.stderrTruncated}>
<Text style={{ ...captionTextStyle, color: C.warning }}>
- The recorded process output is truncated.
+ {tr('The recorded process output is truncated.')}
</Text>
</If>
</View>
) : output?.mode === 'pty' ? (
<View style={{ gap: 8 }}>
<ToolText
- label="Terminal scrollback"
+ label={tr('Terminal scrollback')}
text={output.scrollback}
- copyLabel="Copy terminal scrollback"
+ copyLabel={tr('Copy terminal scrollback')}
source={props.source}
/>
<ToolText
- label="Terminal screen"
+ label={tr('Terminal screen')}
text={output.screen}
- copyLabel="Copy terminal screen"
+ copyLabel={tr('Copy terminal screen')}
source={props.source}
/>
<If when={output.lastAlternateScreen !== null}>
<ToolText
- label="Last alternate screen"
+ label={tr('Last alternate screen')}
text={output.lastAlternateScreen!}
- copyLabel="Copy last alternate screen"
+ copyLabel={tr('Copy last alternate screen')}
source={props.source}
/>
</If>
<If when={output.truncated}>
<Text style={{ ...captionTextStyle, color: C.warning }}>
- The recorded terminal output is truncated.
+ {tr('The recorded terminal output is truncated.')}
</Text>
</If>
</View>
) : null}
<If when={output?.redacted}>
<Text style={{ ...captionTextStyle, color: C.warning }}>
- The Host redacted parts of this recorded output.
+ {tr('The Host redacted parts of this recorded output.')}
</Text>
</If>
</View>
@@ -287,31 +296,31 @@ function Change(props: { value: ToolChange; source: ToolSource }) {
if (value.phase === 'requested')
return (
<View style={{ gap: 8 }}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>File</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>{tr('File')}</Text>
<Text selectable style={{ ...bodyTextStyle, color: C.secondary }}>
{value.path}
</Text>
{value.operation === 'write' ? (
<ToolText
- label="Requested file contents"
+ label={tr('Requested file contents')}
text={value.content}
- copyLabel="Copy requested file contents"
+ copyLabel={tr('Copy requested file contents')}
tone="added"
source={props.source}
/>
) : value.operation === 'edit' ? (
<View style={{ gap: 8 }}>
<ToolText
- label="Requested removal"
+ label={tr('Requested removal')}
text={value.before}
- copyLabel="Copy requested removal"
+ copyLabel={tr('Copy requested removal')}
tone="removed"
source={props.source}
/>
<ToolText
- label="Requested replacement"
+ label={tr('Requested replacement')}
text={value.after}
- copyLabel="Copy requested replacement"
+ copyLabel={tr('Copy requested replacement')}
tone="added"
source={props.source}
/>
@@ -319,13 +328,13 @@ function Change(props: { value: ToolChange; source: ToolSource }) {
) : (
<View style={{ gap: 8 }}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Requested operation · {value.type}
+ {tr('Requested operation')} · {value.type}
</Text>
<If when={value.diff !== null}>
<ToolText
- label="Requested patch"
+ label={tr('Requested patch')}
text={value.diff!}
- copyLabel="Copy requested patch"
+ copyLabel={tr('Copy requested patch')}
source={props.source}
/>
</If>
@@ -336,7 +345,9 @@ function Change(props: { value: ToolChange; source: ToolSource }) {
if (value.operation === 'patch')
return (
<Text style={{ ...captionTextStyle, color: C.secondary }}>
- Recorded patch result · Operation completed. The patch below is the original request.
+ {tr(
+ 'Recorded patch result · Operation completed. The patch below is the original request.',
+ )}
</Text>
);
return (
@@ -344,7 +355,7 @@ function Change(props: { value: ToolChange; source: ToolSource }) {
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Icon name="check" color={C.accent} />
<Text style={{ ...captionTextStyle, color: C.secondary }}>
- {value.operation === 'write' ? 'Recorded write' : 'Recorded edit'}
+ {value.operation === 'write' ? tr('Recorded write') : tr('Recorded edit')}
</Text>
</View>
<Text selectable style={{ ...bodyTextStyle, color: C.secondary }}>
@@ -352,19 +363,21 @@ function Change(props: { value: ToolChange; source: ToolSource }) {
</Text>
{value.operation === 'write' ? (
<View style={{ gap: 8 }}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>{value.bytes} bytes</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>
+ {tr('{count} bytes', { count: value.bytes })}
+ </Text>
<If
when={value.previous !== null}
else={
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Previous contents were not included in this original result.
+ {tr('Previous contents were not included in this original result.')}
</Text>
}
>
<ToolText
- label="Recorded previous contents"
+ label={tr('Recorded previous contents')}
text={value.previous!}
- copyLabel="Copy recorded previous contents"
+ copyLabel={tr('Copy recorded previous contents')}
source={props.source}
tone="removed"
/>
@@ -373,16 +386,19 @@ function Change(props: { value: ToolChange; source: ToolSource }) {
) : (
<View style={{ gap: 4 }}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- {value.replacements} replacements
+ {tr('{count} replacements', { count: value.replacements })}
</Text>
<If when={value.startLine !== null && value.endLine !== null}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Recorded lines {value.startLine}–{value.endLine}
+ {tr('Recorded lines {first}–{last}', {
+ first: value.startLine!,
+ last: value.endLine!,
+ })}
</Text>
</If>
<If when={value.matchedVia !== null}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- Match method · {value.matchedVia}
+ {tr('Match method')} · {value.matchedVia}
</Text>
</If>
</View>
@@ -420,7 +436,7 @@ function Sections(props: { items: ToolSection[] }) {
: C.text,
}}
>
- {section().text || '(empty)'}
+ {section().text || tr('(empty)')}
</Text>
</View>
)}
@@ -439,15 +455,17 @@ export function ToolReader(props: { value: ToolProjection; source: ToolSource })
case 'change':
return <Change value={value.value} source={props.source} />;
case 'sections':
- return <Sections items={value.sections} />;
+ return (
+ <Sections items={(props.value as Extract<ToolProjection, { kind: 'sections' }>).sections} />
+ );
case 'failure':
return (
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 8 }}>
<Icon name="close" color={C.danger} />
<View style={{ flex: 1, minWidth: 0, gap: 4 }}>
- <Text style={{ ...captionTextStyle, color: C.danger }}>Tool failed</Text>
+ <Text style={{ ...captionTextStyle, color: C.danger }}>{tr('Tool failed')}</Text>
<Text selectable style={{ ...bodyTextStyle, color: C.danger }}>
- {value.message}
+ {(props.value as Extract<ToolProjection, { kind: 'failure' }>).message}
</Text>
</View>
</View>
diff --git a/products/maka/terminal-shell/app/ToolSource.tsx b/products/maka/terminal-shell/app/ToolSource.tsx
index de5df274fd..fb569607c6 100644
--- a/products/maka/terminal-shell/app/ToolSource.tsx
+++ b/products/maka/terminal-shell/app/ToolSource.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { createText, UiError, errorText } from './text';
+import { tr } from './locale';
import { cleanup, inputAction, signal, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import type { AgentHistorySourceRead } from '../../../../packages/plugin-sdk/src/index.js';
@@ -57,7 +59,7 @@ export function createToolSource(
total = signal<number | null>(null),
format = signal('');
const busy = signal(false),
- error = signal('');
+ error = createText();
let alive = true;
cleanup(() => {
alive = false;
@@ -107,12 +109,12 @@ export function createToolSource(
!['text', 'json', 'image_descriptor'].includes(source.format) ||
(original.field === 'arguments' && original.path === null && source.format !== 'json')
)
- throw new Error('The original tool source could not be verified.');
+ throw new UiError('The original tool source could not be verified.');
total.set(source.totalBytes);
format.set(source.format);
content.set(content() + source.text);
} catch (reason) {
- if (alive) error.set(String(reason));
+ if (alive) error.set(errorText(reason));
} finally {
if (alive) {
busy.set(false);
@@ -157,8 +159,8 @@ export function OriginalToolSource(props: { source: ToolSource; label: string })
})}
>
<Text style={{ color: C.muted }}>
- {source.error() ? 'Retry original source' : 'Reading…'} ·{' '}
- {messageBytes(source.content())}/{source.total() ?? '?'} bytes
+ {source.error() ? tr('Retry original source') : tr('Reading…')} ·{' '}
+ {messageBytes(source.content())}/{source.total() ?? '?'} {tr('bytes')}
</Text>
</Button>
</If>
diff --git a/products/maka/terminal-shell/app/TurnNavigation.tsx b/products/maka/terminal-shell/app/TurnNavigation.tsx
index 9e2dd51901..9edf18ea32 100644
--- a/products/maka/terminal-shell/app/TurnNavigation.tsx
+++ b/products/maka/terminal-shell/app/TurnNavigation.tsx
@@ -25,6 +25,7 @@ import { platform, windowSize } from 'nsh/device';
import type { Position } from './history';
import { C, controlStyle, captionTextStyle } from './ui';
import { Icon } from './Icon';
+import { tr } from './locale';
// Match the desktop's dense pixel ticks while retaining whole-cell terminal targets.
const markPitch = platform.isTerminal ? 16 : 10;
@@ -117,7 +118,9 @@ function NavigationMark(props: {
}) {
return (
<Button
- accessibilityLabel={`Jump to turn: ${props.item().label || 'Recorded turn'}`}
+ accessibilityLabel={tr('Jump to turn: {label}', {
+ label: props.item().label || tr('Recorded turn'),
+ })}
tooltip={
<Overlay
hintOnFocus
@@ -138,10 +141,10 @@ function NavigationMark(props: {
}}
>
<Text numberOfLines={3} style={{ ...captionTextStyle, color: C.text, fontWeight: '500' }}>
- {props.item().label || 'Recorded turn'}
+ {props.item().label || tr('Recorded turn')}
</Text>
<If when={props.busy}>
- <Text style={{ ...captionTextStyle, color: C.muted }}>Navigating…</Text>
+ <Text style={{ ...captionTextStyle, color: C.muted }}>{tr('Navigating…')}</Text>
</If>
</Overlay>
}
@@ -215,7 +218,7 @@ export function TurnNavigation(props: {
<List
ref={rail}
testID="turn-navigation"
- accessibilityLabel="Turn navigation"
+ accessibilityLabel={tr('Turn navigation')}
each={props.items()}
keyExtractor={(item) => item.turnId}
estimatedItemSize={markPitch}
@@ -241,8 +244,8 @@ export function TurnNavigation(props: {
</If>
<If when={props.busy()}>
<Button
- accessibilityLabel="Cancel turn navigation"
- tooltip="Cancel navigation"
+ accessibilityLabel={tr('Cancel turn navigation')}
+ tooltip={tr('Cancel navigation')}
onPress={props.cancel}
style={{
position: 'absolute',
@@ -271,7 +274,7 @@ export function ReturnToLatest(props: { visible: boolean; activate(): void }) {
return (
<If when={props.visible}>
<Button
- accessibilityLabel="Return to latest"
+ accessibilityLabel={tr('Return to latest')}
onPress={props.activate}
style={{
...controlStyle,
@@ -287,7 +290,7 @@ export function ReturnToLatest(props: { visible: boolean; activate(): void }) {
focusStyle={{ backgroundColor: C.accent }}
>
<Icon name="latest" color={C.secondary} />
- <Text style={{ ...captionTextStyle, color: C.text }}>Latest</Text>
+ <Text style={{ ...captionTextStyle, color: C.text }}>{tr('Latest')}</Text>
</Button>
</If>
);
diff --git a/products/maka/terminal-shell/app/TurnPanel.tsx b/products/maka/terminal-shell/app/TurnPanel.tsx
index 3e0f9149e8..34a09ef26b 100644
--- a/products/maka/terminal-shell/app/TurnPanel.tsx
+++ b/products/maka/terminal-shell/app/TurnPanel.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { createText, errorText } from './text';
+import { tr } from './locale';
import { cleanup, computed, effect, inputAction, signal, Text, View } from 'nsh';
import type { YNode } from 'nsh';
import { Button } from 'nsh/base';
@@ -90,12 +92,12 @@ export function TurnSummary(props: {
const label = () => {
const outcome = turnOutcome(props.turn());
return outcome === 'completed'
- ? 'Completed'
+ ? tr('Completed')
: outcome === 'failed'
- ? 'Failed'
+ ? tr('Failed')
: outcome === 'aborted'
- ? 'Stopped'
- : 'Activity';
+ ? tr('Stopped')
+ : tr('Activity');
};
const choose = (action: { turnId: string; answerKey: string | undefined; open: boolean }) => {
const turn = props.turn();
@@ -104,7 +106,7 @@ export function TurnSummary(props: {
};
return (
<Button
- accessibilityLabel="Turn work details"
+ accessibilityLabel={tr('Turn work details')}
disabled={!props.canFold()}
selected={props.expanded()}
onPress={inputAction(choose, {
@@ -180,7 +182,7 @@ function ActivityLog(props: {
const lease = ++state.lease;
const initial = signal<{ y: number } | null>(null);
const measurements = signal(0);
- const error = signal('');
+ const error = createText();
// Reveal a user-opened source after its native height changes, so the outer
// List positions the grown group rather than its old one-line body.
const pendingReveal = signal<{ rowId: string; height: number } | null>(null);
@@ -242,13 +244,13 @@ function ActivityLog(props: {
}
})
.catch((reason) => {
- if (alive) error.set(String(reason));
+ if (alive) error.set(errorText(reason));
});
return (
<View style={{ height: bodyHeight() }}>
<If when={initial()}>
<List
- accessibilityLabel="Turn activity"
+ accessibilityLabel={tr('Turn activity')}
each={props.group().rowIds}
estimatedItemSize={controlStyle.minHeight + memberGap}
scrollValue={state.offset}
@@ -409,17 +411,19 @@ export function TurnPanel(props: {
const tools = props.group().toolIds.length;
return (
[
- thoughts ? `${thoughts} thought${thoughts === 1 ? '' : 's'}` : '',
- tools ? `${tools} tool${tools === 1 ? '' : 's'}` : '',
+ thoughts
+ ? tr(thoughts === 1 ? '{count} thought' : '{count} thoughts', { count: thoughts })
+ : '',
+ tools ? tr(tools === 1 ? '{count} tool' : '{count} tools', { count: tools }) : '',
]
.filter(Boolean)
- .join(' · ') || 'Process'
+ .join(' · ') || tr('Process')
);
};
const desiredTitle = computed(() => {
if (!active()) return counts();
const activity = props.activity();
- if (activity.kind === 'waiting') return 'Waiting for your response';
+ if (activity.kind === 'waiting') return tr('Waiting for your response');
const tool = props.toolActivity();
if (tool) return toolActivitySummary(tool);
if (
@@ -427,8 +431,8 @@ export function TurnPanel(props: {
activity.rowId &&
props.group().rowIds.includes(activity.rowId)
)
- return `Analyzing the request${activity.detail ? ` · ${activity.detail}` : ''}`;
- return 'Working…';
+ return `${tr('Analyzing the request')}${activity.detail ? ` · ${activity.detail}` : ''}`;
+ return tr('Working…');
});
const stableTitle = stableActivityTitle(desiredTitle, active);
const choose = (action: { sessionId: string; key: string; open: boolean }) => {
@@ -449,7 +453,7 @@ export function TurnPanel(props: {
return (
<View style={panelStyle}>
<Button
- accessibilityLabel="Process group"
+ accessibilityLabel={tr('Process group')}
disabled={props.group().rowIds.length === 0}
selected={props.state.open()}
onPress={inputAction(choose, {
diff --git a/products/maka/terminal-shell/app/TurnUsage.tsx b/products/maka/terminal-shell/app/TurnUsage.tsx
index 88e1c8d078..9f1e47ab18 100644
--- a/products/maka/terminal-shell/app/TurnUsage.tsx
+++ b/products/maka/terminal-shell/app/TurnUsage.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { UiError, errorText, renderText, type UiText } from './text';
+import { tr } from './locale';
import { cleanup, inputAction, Scroll, signal, Text, View } from 'nsh';
import { Button, Dialog } from 'nsh/base';
import type { ModelUsage, UsageModelAttempt } from '../../../../packages/plugin-sdk/src/index.js';
@@ -27,7 +29,7 @@ import { Icon } from './Icon';
type Reading =
| { kind: 'loading'; checked: number }
| { kind: 'ready'; attempts: UsageModelAttempt[] }
- | { kind: 'failed'; message: string };
+ | { kind: 'failed'; message: UiText };
type Counter =
| 'input_tokens'
| 'output_tokens'
@@ -39,9 +41,10 @@ function total(attempts: UsageModelAttempt[], key: Counter): string {
let value = 0;
for (const attempt of attempts) {
const next: ModelUsage[Counter] = attempt.usage[key];
- if (typeof next !== 'number' || next < 0 || !Number.isSafeInteger(next)) return 'Unavailable';
+ if (typeof next !== 'number' || next < 0 || !Number.isSafeInteger(next))
+ return tr('Unavailable');
value += next;
- if (!Number.isSafeInteger(value)) return 'Unavailable';
+ if (!Number.isSafeInteger(value)) return tr('Unavailable');
}
return String(value);
}
@@ -89,7 +92,7 @@ export function TurnUsage(props: {
end > page.total ||
(page.nextOffset == null ? end !== page.total : page.nextOffset !== end || end <= offset)
)
- throw new Error('The original turn usage page could not be verified.');
+ throw new UiError('The original turn usage page could not be verified.');
expectedTotal = page.total;
for (const activity of page.attempts) {
if (
@@ -97,7 +100,7 @@ export function TurnUsage(props: {
activity.attempt.sessionId !== props.sessionId ||
seen.has(activity.attempt.requestId)
)
- throw new Error('The Host returned inconsistent model usage.');
+ throw new UiError('The Host returned inconsistent model usage.');
const attempt = activity.attempt;
seen.add(attempt.requestId);
const origin = attempt.origin;
@@ -118,7 +121,7 @@ export function TurnUsage(props: {
if (active())
reading.set({
kind: 'failed',
- message: reason instanceof Error ? reason.message : String(reason),
+ message: errorText(reason),
});
}
};
@@ -127,19 +130,19 @@ export function TurnUsage(props: {
const result = reading();
return result.kind === 'ready' ? result.attempts : [];
};
- const counters: { key: Counter; label: string }[] = [
- { key: 'input_tokens', label: 'Input tokens' },
- { key: 'output_tokens', label: 'Output tokens' },
- { key: 'reasoning_tokens', label: 'Reasoning tokens' },
- { key: 'cache_read_tokens', label: 'Cache read tokens' },
- { key: 'cache_write_tokens', label: 'Cache write tokens' },
+ const counters = (): { key: Counter; label: string }[] => [
+ { key: 'input_tokens', label: tr('Input tokens') },
+ { key: 'output_tokens', label: tr('Output tokens') },
+ { key: 'reasoning_tokens', label: tr('Reasoning tokens') },
+ { key: 'cache_read_tokens', label: tr('Cache read tokens') },
+ { key: 'cache_write_tokens', label: tr('Cache write tokens') },
];
const cost = () => {
let value = 0;
for (const attempt of attempts()) {
- if (attempt.costUsd == null || !Number.isFinite(attempt.costUsd)) return 'Unavailable';
+ if (attempt.costUsd == null || !Number.isFinite(attempt.costUsd)) return tr('Unavailable');
value += attempt.costUsd;
- if (!Number.isFinite(value)) return 'Unavailable';
+ if (!Number.isFinite(value)) return tr('Unavailable');
}
return `$${value.toFixed(6)}`;
};
@@ -172,39 +175,42 @@ export function TurnUsage(props: {
}}
>
<View style={{ flexDirection: 'row', gap: 16, alignItems: 'center' }}>
- <Text style={{ flex: 1, color: C.text, fontWeight: '700' }}>Main model usage</Text>
+ <Text style={{ flex: 1, color: C.text, fontWeight: '700' }}>
+ {tr('Main model usage')}
+ </Text>
<Button
ref={(node) => node.focus()}
- accessibilityLabel="Close turn usage"
- tooltip="Close"
+ accessibilityLabel={tr('Close turn usage')}
+ tooltip={tr('Close')}
onPress={props.close}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
>
<Icon name="close" color={C.secondary} />
- <Text style={{ color: C.accent }}>Close</Text>
+ <Text style={{ color: C.accent }}>{tr('Close')}</Text>
</Button>
</View>
<Scroll style={{ flexShrink: 1, minHeight: 0 }}>
<View style={{ gap: 8 }}>
<If when={reading().kind === 'loading'}>
<Text style={{ color: C.muted }}>
- Reading original usage…{' '}
- {reading().kind === 'loading'
- ? (reading() as Extract<Reading, { kind: 'loading' }>).checked
- : 0}{' '}
- records checked
+ {tr('Reading original usage… {count} records checked', {
+ count:
+ reading().kind === 'loading'
+ ? (reading() as Extract<Reading, { kind: 'loading' }>).checked
+ : 0,
+ })}
</Text>
</If>
<If when={reading().kind === 'failed'}>
<Text selectable style={{ color: C.danger }}>
{reading().kind === 'failed'
- ? (reading() as Extract<Reading, { kind: 'failed' }>).message
+ ? renderText((reading() as Extract<Reading, { kind: 'failed' }>).message)
: ''}
</Text>
<Button
- accessibilityLabel="Retry original turn usage"
+ accessibilityLabel={tr('Retry original turn usage')}
style={controlStyle}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -214,21 +220,26 @@ export function TurnUsage(props: {
through: props.through,
})}
>
- <Text style={{ color: C.accent }}>Retry</Text>
+ <Text style={{ color: C.accent }}>{tr('Retry')}</Text>
</Button>
</If>
<If when={reading().kind === 'ready' && attempts().length === 0}>
<Text style={{ color: C.muted }}>
- No model usage is recorded for this turn in this conversation. Copied and imported
- history does not add model charges.
+ {tr(
+ 'No model usage is recorded for this turn in this conversation. Copied and imported history does not add model charges.',
+ )}
</Text>
</If>
<If when={reading().kind === 'ready' && attempts().length > 0}>
<Text style={{ color: C.muted }}>
- {attempts().length} main model {attempts().length === 1 ? 'request' : 'requests'} in
- this turn
+ {tr(
+ attempts().length === 1
+ ? '{count} main model request in this turn'
+ : '{count} main model requests in this turn',
+ { count: attempts().length },
+ )}
</Text>
- <Each of={counters} key={(counter) => counter.key}>
+ <Each of={counters()} key={(counter) => counter.key}>
{(counter) => (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 16 }}>
<Text style={{ flex: 1, color: C.secondary }}>{counter().label}</Text>
@@ -239,14 +250,15 @@ export function TurnUsage(props: {
)}
</Each>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 16 }}>
- <Text style={{ flex: 1, color: C.secondary }}>Recorded cost</Text>
+ <Text style={{ flex: 1, color: C.secondary }}>{tr('Recorded cost')}</Text>
<Text selectable style={{ color: C.text }}>
{cost()}
</Text>
</View>
<Text style={{ color: C.muted }}>
- Missing provider counters or prices are unavailable. Auxiliary requests and tools
- are counted separately.
+ {tr(
+ 'Missing provider counters or prices are unavailable. Auxiliary requests and tools are counted separately.',
+ )}
</Text>
</If>
</View>
diff --git a/products/maka/terminal-shell/app/Workbench.tsx b/products/maka/terminal-shell/app/Workbench.tsx
index ed0e3c3a63..732441bf53 100644
--- a/products/maka/terminal-shell/app/Workbench.tsx
+++ b/products/maka/terminal-shell/app/Workbench.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { tr } from './locale';
import { effect, inputAction, signal, View, type JSX } from 'nsh';
import { windowSize } from 'nsh/device';
import { keymap } from 'nsh/keymap';
@@ -113,7 +114,7 @@ export function Workbench(props: {
<WorkspaceBrowser {...props.browser} docked resize={resize} />
</View>
<View
- accessibilityLabel="Resize session browser"
+ accessibilityLabel={tr('Resize session browser')}
onStartShouldSetResponder={() => true}
onResponderGrant={(event) => {
drag = {
@@ -150,7 +151,7 @@ export function Workbench(props: {
<View style={{ flex: 1, minWidth: 0, minHeight: 0 }}>{props.children}</View>
<If when={preview()}>
<View
- accessibilityLabel="Resize workspace preview"
+ accessibilityLabel={tr('Resize workspace preview')}
onStartShouldSetResponder={() => true}
onResponderGrant={(event) => {
drag = {
diff --git a/products/maka/terminal-shell/app/WorkspaceBrowser.tsx b/products/maka/terminal-shell/app/WorkspaceBrowser.tsx
index a8fd155e87..74b164b6db 100644
--- a/products/maka/terminal-shell/app/WorkspaceBrowser.tsx
+++ b/products/maka/terminal-shell/app/WorkspaceBrowser.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { message, renderText, type UiText } from './text';
+import { tr } from './locale';
import {
batch,
cleanup,
@@ -76,7 +78,7 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
const selectedRow = signal<string | null>(null);
const actions = signal<{ session: SessionActionTarget; origin: string | null } | null>(null);
const actionsAnchor = signal<YNode | null>(null);
- const navigationNotice = signal<{ kind: 'draft' | 'action'; message: string } | null>(null);
+ const navigationNotice = signal<{ kind: 'draft' | 'action'; message: UiText } | null>(null);
const viewportHeight = signal(160);
let search: YNode | undefined;
let list: YNode | undefined;
@@ -91,21 +93,23 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
});
const scopeLabel = () => {
const scope = browser.scope();
- if (scope.kind === 'all') return 'All workspaces';
+ if (scope.kind === 'all') return tr('All workspaces');
if (scope.kind === 'project')
return (
browser.projects().find((project) => project.reference.id === scope.projectId)?.name ??
- 'Project'
+ tr('Project')
);
- return browser.sessions()[0]?.workspace.path.split('/').filter(Boolean).at(-1) ?? 'Workspace';
+ return (
+ browser.sessions()[0]?.workspace.path.split('/').filter(Boolean).at(-1) ?? tr('Workspace')
+ );
};
const scopes = computed(() => [
- { id: 'all', title: 'All workspaces', detail: '', scope: { kind: 'all' } as BrowserScope },
+ { id: 'all', title: tr('All workspaces'), detail: '', scope: { kind: 'all' } as BrowserScope },
...(props.selected()
? [
{
id: 'workspace',
- title: 'Current workspace',
+ title: tr('Current workspace'),
detail: '',
scope: { kind: 'workspace', sessionId: props.selected()! } as BrowserScope,
},
@@ -117,7 +121,7 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
.map((project) => ({
id: project.reference.id,
title: project.name,
- detail: project.path ?? 'Unavailable',
+ detail: project.path ?? tr('Unavailable'),
scope: { kind: 'project', projectId: project.reference.id } as BrowserScope,
})),
]);
@@ -143,7 +147,9 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
if (alive && id !== origin && props.selected() === origin)
navigationNotice.set({
kind: 'draft',
- message: 'The draft is not ready to save. Return to the conversation to check its status.',
+ message: message(
+ 'The draft is not ready to save. Return to the conversation to check its status.',
+ ),
});
};
const openActions = (
@@ -298,7 +304,7 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
</Text>
<Button
onPress={inputAction(props.close, null)}
- accessibilityLabel="Close session browser"
+ accessibilityLabel={tr('Close session browser')}
style={{
width: 32,
height: 32,
@@ -313,7 +319,7 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
</Button>
</View>
<Button
- accessibilityLabel="New conversation"
+ accessibilityLabel={tr('New conversation')}
disabled={props.switching()}
onPress={inputAction(props.newConversation, props.selected())}
style={{
@@ -331,10 +337,10 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
disabledStyle={controlDisabledStyle}
>
<Icon name="plus" color={C.secondary} />
- <Text style={{ color: C.text, fontSize: 14 }}>New conversation</Text>
+ <Text style={{ color: C.text, fontSize: 14 }}>{tr('New conversation')}</Text>
</Button>
<Button
- accessibilityLabel="Choose workspace scope"
+ accessibilityLabel={tr('Choose workspace scope')}
onPress={inputAction(toggleScopes, null)}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={{ backgroundColor: C.raised }}
@@ -374,12 +380,14 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
focusSearch();
}}
testID="session-search"
- accessibilityLabel="Search session titles"
+ accessibilityLabel={tr('Search session titles')}
autoFocus={!props.docked}
value={choosingWorkspace() ? '' : browser.titleQuery()}
readonly={choosingWorkspace()}
onChange={changeQuery}
- placeholder={choosingWorkspace() ? 'Choose a workspace below' : 'Search sessions…'}
+ placeholder={
+ choosingWorkspace() ? tr('Choose a workspace below') : tr('Search sessions…')
+ }
style={{
color: C.text,
placeholderColor: C.muted,
@@ -391,8 +399,8 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
/>
<If when={!choosingWorkspace() && browser.titleQuery() !== ''}>
<Button
- accessibilityLabel="Clear session search"
- tooltip="Clear search"
+ accessibilityLabel={tr('Clear session search')}
+ tooltip={tr('Clear search')}
onPress={clearSearch()}
style={{
width: 32,
@@ -420,18 +428,20 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
>
<Button
selected={browser.filter() === 'all'}
- accessibilityLabel="Show active conversations"
+ accessibilityLabel={tr('Show active conversations')}
style={{ ...controlStyle, flex: 1, alignItems: 'center', paddingHorizontal: 8 }}
selectedStyle={{ backgroundColor: C.raised }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
onPress={inputAction(chooseFilter, 'all')}
>
- <Text style={{ color: browser.filter() === 'all' ? C.text : C.muted }}>Active</Text>
+ <Text style={{ color: browser.filter() === 'all' ? C.text : C.muted }}>
+ {tr('Active')}
+ </Text>
</Button>
<Button
selected={browser.filter() === 'archived'}
- accessibilityLabel="Show archived conversations"
+ accessibilityLabel={tr('Show archived conversations')}
style={{ ...controlStyle, flex: 1, alignItems: 'center', paddingHorizontal: 8 }}
selectedStyle={{ backgroundColor: C.raised }}
hoverStyle={controlHoverStyle}
@@ -439,7 +449,7 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
onPress={inputAction(chooseFilter, 'archived')}
>
<Text style={{ color: browser.filter() === 'archived' ? C.text : C.muted }}>
- Archived
+ {tr('Archived')}
</Text>
</Button>
</View>
@@ -455,7 +465,9 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
>
<If when={!browser.loading() && !browser.error() && !browser.projectError()}>
<Text style={{ color: C.muted }}>
- {browser.titleQuery() ? 'No matching conversations.' : 'No conversations yet.'}
+ {browser.titleQuery()
+ ? tr('No matching conversations.')
+ : tr('No conversations yet.')}
</Text>
</If>
</View>
@@ -465,7 +477,7 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
if (!choosingWorkspace()) void browser.loadMore();
}}
style={{ flex: 1, minHeight: 32 }}
- accessibilityLabel={choosingWorkspace() ? 'Workspace scopes' : 'Conversations'}
+ accessibilityLabel={choosingWorkspace() ? tr('Workspace scopes') : tr('Conversations')}
>
{(id) => (
<Each of={[id()]} key={(key: string) => key}>
@@ -504,7 +516,9 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
</Button>
<If when={project()}>
<Button
- accessibilityLabel={`New conversation in ${project()?.name}`}
+ accessibilityLabel={tr('New conversation in {name}', {
+ name: project()?.name ?? '',
+ })}
disabled={
props.switching() || !project()?.available || !project()?.selection
}
@@ -556,7 +570,9 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
}}
>
<Button
- accessibilityLabel={`Open conversation ${session()?.title || 'Untitled conversation'}`}
+ accessibilityLabel={tr('Open conversation {title}', {
+ title: session()?.title || tr('Untitled conversation'),
+ })}
disabled={props.switching() || !session()}
selected={props.selected() === key()}
onPress={inputAction(select, { id: key(), origin: props.selected() })}
@@ -588,7 +604,7 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
flex: 1,
}}
>
- {session()?.title || 'Untitled conversation'}
+ {session()?.title || tr('Untitled conversation')}
</Text>
<If when={session()?.hasUnread}>
<Icon name="unread" color={C.accent} />
@@ -612,7 +628,9 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
actionTrigger = node;
untracked(trackActionTrigger);
}}
- accessibilityLabel={`Actions for ${session()?.title || 'Untitled conversation'}`}
+ accessibilityLabel={tr('Actions for {title}', {
+ title: session()?.title || tr('Untitled conversation'),
+ })}
disabled={props.switching() || !session()}
onPress={
session()
@@ -640,14 +658,14 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
)}
</List>
<If when={browser.loading()}>
- <Text style={{ color: C.muted }}>Loading…</Text>
+ <Text style={{ color: C.muted }}>{tr('Loading…')}</Text>
</If>
<If when={navigationNotice()}>
<Text
selectable
style={{ color: navigationNotice()?.kind === 'action' ? C.danger : C.warning }}
>
- {navigationNotice()?.message}
+ {renderText(navigationNotice()?.message ?? '')}
</Text>
<If when={navigationNotice()?.kind === 'draft'}>
<Button
@@ -656,7 +674,7 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
>
- <Text style={{ color: C.accent }}>Return to draft</Text>
+ <Text style={{ color: C.accent }}>{tr('Return to draft')}</Text>
</Button>
</If>
</If>
@@ -670,7 +688,7 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
>
- <Text style={{ color: C.accent }}>Refresh browser</Text>
+ <Text style={{ color: C.accent }}>{tr('Refresh browser')}</Text>
</Button>
</If>
<View
@@ -684,19 +702,19 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
}}
>
<Button
- accessibilityLabel="Open settings"
+ accessibilityLabel={tr('Open settings')}
onPress={inputAction(props.settings, props.selected())}
style={{ ...controlStyle, flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={{ backgroundColor: C.raised }}
focusStyle={{ backgroundColor: C.line }}
>
<Icon name="settings" color={C.secondary} />
- <Text style={{ color: C.secondary }}>Settings</Text>
+ <Text style={{ color: C.secondary }}>{tr('Settings')}</Text>
</Button>
<If when={props.docked}>
<Button
onPress={props.resize ? inputAction(props.resize, -2) : undefined}
- accessibilityLabel="Narrower session browser"
+ accessibilityLabel={tr('Narrower session browser')}
style={{
width: 24,
height: 32,
@@ -711,7 +729,7 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
</Button>
<Button
onPress={props.resize ? inputAction(props.resize, 2) : undefined}
- accessibilityLabel="Wider session browser"
+ accessibilityLabel={tr('Wider session browser')}
style={{
width: 24,
height: 32,
diff --git a/products/maka/terminal-shell/app/WorkspaceChanges.tsx b/products/maka/terminal-shell/app/WorkspaceChanges.tsx
index e355b22670..5e27d143a8 100644
--- a/products/maka/terminal-shell/app/WorkspaceChanges.tsx
+++ b/products/maka/terminal-shell/app/WorkspaceChanges.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { errorText, renderText, type UiText } from './text';
+import { tr } from './locale';
import { cleanup, effect, inputAction, signal, Text, untracked, View } from 'nsh';
import { Button } from 'nsh/base';
import { List } from 'nsh/list';
@@ -33,10 +35,28 @@ import {
controlFocusStyle,
} from './ui';
+const changeKindLabel: Record<GitStatusEntry['kind'], string> = {
+ get added() {
+ return tr('added');
+ },
+ get modified() {
+ return tr('modified');
+ },
+ get deleted() {
+ return tr('deleted');
+ },
+ get type_changed() {
+ return tr('type changed');
+ },
+ get conflicted() {
+ return tr('conflicted');
+ },
+};
+
type ChangesState =
| { kind: 'loading' }
| { kind: 'ready'; status: GitStatus | null }
- | { kind: 'error'; message: string };
+ | { kind: 'error'; message: UiText };
export function WorkspaceChanges(props: {
sessionId: string;
@@ -59,7 +79,7 @@ export function WorkspaceChanges(props: {
state.set({ kind: 'ready', status });
} catch (reason) {
if (alive && generation === reading && props.sessionId === sessionId)
- state.set({ kind: 'error', message: String(reason) });
+ state.set({ kind: 'error', message: errorText(reason) });
}
};
effect(() => {
@@ -85,12 +105,14 @@ export function WorkspaceChanges(props: {
<View style={{ flex: 1, minHeight: 0, gap: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ ...captionTextStyle, flex: 1, color: C.secondary, fontWeight: '500' }}>
- Current changes
+ {tr('Current changes')}
</Text>
<Button
- accessibilityLabel="Refresh current changes"
+ accessibilityLabel={tr('Refresh current changes')}
onPress={inputAction(refresh, props.sessionId)}
- tooltip={state().kind === 'error' ? 'Retry current changes' : 'Refresh current changes'}
+ tooltip={
+ state().kind === 'error' ? tr('Retry current changes') : tr('Refresh current changes')
+ }
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -99,32 +121,42 @@ export function WorkspaceChanges(props: {
</Button>
</View>
<If when={state().kind === 'loading'}>
- <Text style={{ ...bodyTextStyle, color: C.muted }}>Reading current changes…</Text>
+ <Text style={{ ...bodyTextStyle, color: C.muted }}>{tr('Reading current changes…')}</Text>
</If>
<If when={state().kind === 'error'}>
<Text selectable style={{ ...bodyTextStyle, color: C.danger }}>
- {(state() as Extract<ChangesState, { kind: 'error' }>).message}
+ {renderText((state() as Extract<ChangesState, { kind: 'error' }>).message)}
</Text>
</If>
<If when={state().kind === 'ready'}>
<If
when={status() !== null}
- else={<Text style={{ ...bodyTextStyle, color: C.muted }}>Not a Git workspace.</Text>}
+ else={
+ <Text style={{ ...bodyTextStyle, color: C.muted }}>{tr('Not a Git workspace.')}</Text>
+ }
>
<Text numberOfLines={1} style={{ ...captionTextStyle, color: C.muted }}>
- {status()?.head ?? 'No commits yet'} · {entries().length} changes
+ {tr('{head} · {count} changes', {
+ head: status()?.head ?? tr('No commits yet'),
+ count: entries().length,
+ })}
</Text>
<List
- accessibilityLabel="Current workspace changes"
+ accessibilityLabel={tr('Current workspace changes')}
each={entries()}
keyExtractor={(entry: GitStatusEntry) => `${entry.area}/${entry.path.bytesBase64}`}
estimatedItemSize={48}
style={{ flex: 1, minHeight: 0 }}
- empty={<Text style={{ ...bodyTextStyle, color: C.muted }}>No current changes.</Text>}
+ empty={
+ <Text style={{ ...bodyTextStyle, color: C.muted }}>{tr('No current changes.')}</Text>
+ }
>
{(entry) => (
<Button
- accessibilityLabel={`Open ${gitAreaLabel[entry().area]} diff: ${host.pathLabel(entry().path)}`}
+ accessibilityLabel={tr('Open {area} diff: {path}', {
+ area: gitAreaLabel[entry().area],
+ path: host.pathLabel(entry().path),
+ })}
onPress={inputAction(open, {
sessionId: props.sessionId,
area: entry().area,
@@ -148,7 +180,7 @@ export function WorkspaceChanges(props: {
{host.pathLabel(entry().path)}
</Text>
<Text numberOfLines={1} style={{ ...captionTextStyle, color: C.muted }}>
- {gitAreaLabel[entry().area]} · {entry().kind.replace('_', ' ')}
+ {gitAreaLabel[entry().area]} · {changeKindLabel[entry().kind]}
</Text>
</View>
</Button>
diff --git a/products/maka/terminal-shell/app/WorkspaceFiles.tsx b/products/maka/terminal-shell/app/WorkspaceFiles.tsx
index bd07ff8365..fd818693f6 100644
--- a/products/maka/terminal-shell/app/WorkspaceFiles.tsx
+++ b/products/maka/terminal-shell/app/WorkspaceFiles.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { tr } from './locale';
import { cleanup, effect, inputAction, Scroll, signal, Text, View } from 'nsh';
import { Button } from 'nsh/base';
import { List } from 'nsh/list';
@@ -59,7 +60,7 @@ export function WorkspaceFiles(props: { model: WorkspaceFilesModel }) {
{(directory, index) => (
<Button
disabled={!model.active()}
- accessibilityLabel={`Open ${directory().title}`}
+ accessibilityLabel={tr('Open {name}', { name: directory().title })}
onPress={inputAction(model.navigate, {
sessionId: model.sessionId()!,
path: directory().path,
@@ -92,9 +93,9 @@ export function WorkspaceFiles(props: { model: WorkspaceFilesModel }) {
</Scroll>
<Button
disabled={!model.active() || model.busy()}
- accessibilityLabel="Refresh workspace files"
+ accessibilityLabel={tr('Refresh workspace files')}
onPress={inputAction(model.refresh, null)}
- tooltip="Refresh workspace files"
+ tooltip={tr('Refresh workspace files')}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
focusStyle={controlFocusStyle}
@@ -143,7 +144,7 @@ export function WorkspaceFiles(props: { model: WorkspaceFilesModel }) {
onLoadMore={() => {
if (ownsView()) void model.loadMore();
}}
- accessibilityLabel="Workspace files"
+ accessibilityLabel={tr('Workspace files')}
style={{ flex: 1, minHeight: 32 }}
empty={<DirectoryStatus model={model} />}
footer={model.entries().length ? <DirectoryStatus model={model} /> : undefined}
@@ -155,7 +156,7 @@ export function WorkspaceFiles(props: { model: WorkspaceFilesModel }) {
!!model.error() ||
(entry().kind !== 'directory' && entry().kind !== 'file')
}
- accessibilityLabel={`${entry().kind}: ${label(entry().path)}`}
+ accessibilityLabel={`${entry().kind === 'directory' ? tr('Folder') : entry().kind === 'file' ? tr('File') : entry().kind === 'symlink' ? tr('Symbolic link') : tr('Unsupported')}: ${label(entry().path)}`}
onPress={inputAction(model.activate, {
sessionId: sessionId!,
parent: directory,
@@ -188,7 +189,7 @@ export function WorkspaceFiles(props: { model: WorkspaceFilesModel }) {
</Text>
<If when={entry().kind === 'symlink' || entry().kind === 'other'}>
<Text style={{ ...captionTextStyle, color: C.muted }}>
- {entry().kind === 'symlink' ? 'Link' : 'Unsupported'}
+ {entry().kind === 'symlink' ? tr('Link') : tr('Unsupported')}
</Text>
</If>
</Button>
@@ -205,10 +206,10 @@ function DirectoryStatus(props: { model: WorkspaceFilesModel }) {
const model = props.model;
const message = () => {
if (model.error()) return model.error();
- if (!model.sessionId()) return 'Select a conversation to browse its workspace.';
- if (model.loading() || (model.busy() && !model.entries().length)) return 'Loading files…';
- if (model.busy()) return 'Updating files…';
- return model.entries().length ? '' : 'This directory is empty.';
+ if (!model.sessionId()) return tr('Select a conversation to browse its workspace.');
+ if (model.loading() || (model.busy() && !model.entries().length)) return tr('Loading files…');
+ if (model.busy()) return tr('Updating files…');
+ return model.entries().length ? '' : tr('This directory is empty.');
};
return (
<If when={message()}>
@@ -221,7 +222,7 @@ function DirectoryStatus(props: { model: WorkspaceFilesModel }) {
</Text>
<If when={model.error()}>
<Button
- accessibilityLabel="Retry workspace files"
+ accessibilityLabel={tr('Retry workspace files')}
disabled={!model.active() || model.busy()}
onPress={inputAction(model.refresh, null)}
style={{
@@ -236,7 +237,7 @@ function DirectoryStatus(props: { model: WorkspaceFilesModel }) {
disabledStyle={controlDisabledStyle}
>
<Icon name="refresh" color={C.secondary} />
- <Text style={{ ...captionTextStyle, color: C.secondary }}>Retry</Text>
+ <Text style={{ ...captionTextStyle, color: C.secondary }}>{tr('Retry')}</Text>
</Button>
</If>
</View>
diff --git a/products/maka/terminal-shell/app/WorkspacePicker.tsx b/products/maka/terminal-shell/app/WorkspacePicker.tsx
index b0ed9fe656..6f16051de3 100644
--- a/products/maka/terminal-shell/app/WorkspacePicker.tsx
+++ b/products/maka/terminal-shell/app/WorkspacePicker.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
+import { createText, errorText, UiError } from './text';
+import { tr } from './locale';
import { batch, cleanup, computed, effect, Input, inputAction, signal, Text, View } from 'nsh';
import type { Signal, YNode } from 'nsh';
import { Button, Dialog } from 'nsh/base';
@@ -61,7 +63,7 @@ export function WorkspacePicker(props: {
const query = signal('');
const selected = signal(0);
const loading = signal(false);
- const error = signal('');
+ const error = createText();
const viewportHeight = signal(0);
let alive = true;
let serial = 0;
@@ -109,9 +111,9 @@ export function WorkspacePicker(props: {
);
if (!owns(read)) return;
if ((directory && page.kind !== 'children') || (!directory && page.kind !== 'roots'))
- throw new Error('The Host returned a different directory view. Reopen the folder.');
+ throw new UiError('The Host returned a different directory view. Reopen the folder.');
if (page.kind === 'children' && page.after !== null && page.after === after)
- throw new Error('Folder paging did not advance. Reopen the folder.');
+ throw new UiError('Folder paging did not advance. Reopen the folder.');
const rows = new Map(read.entries().map((entry) => [selectionKey(entry.selection), entry]));
for (const entry of page.kind === 'roots' ? page.roots : page.children)
rows.set(selectionKey(entry.selection), entry);
@@ -120,7 +122,7 @@ export function WorkspacePicker(props: {
read.entries.set([...rows.values()]);
});
} catch (reason) {
- if (owns(read)) error.set(reason instanceof Error ? reason.message : String(reason));
+ if (owns(read)) error.set(errorText(reason));
} finally {
read.reading = false;
if (owns(read)) loading.set(false);
@@ -214,16 +216,16 @@ export function WorkspacePicker(props: {
<View style={{ gap: 8, padding: 12 }}>
<If when={loading()}>
<Text style={{ color: C.muted, fontSize: 13, lineHeight: 16 }}>
- Loading folders…
+ {tr('Loading folders…')}
</Text>
</If>
<If when={!loading() && !error() && !matches().length}>
<Text style={{ color: C.muted, fontSize: 13, lineHeight: 16 }}>
{query()
- ? 'No matching folders.'
+ ? tr('No matching folders.')
: read.trail.length
- ? 'This folder has no subfolders.'
- : 'No workspace locations are published by this Host.'}
+ ? tr('This folder has no subfolders.')
+ : tr('No workspace locations are published by this Host.')}
</Text>
</If>
<If when={error()}>
@@ -231,7 +233,7 @@ export function WorkspacePicker(props: {
{error()}
</Text>
<Button
- accessibilityLabel="Retry folder"
+ accessibilityLabel={tr('Retry folder')}
onPress={inputAction(retry, { location: read.id })}
style={{
...controlStyle,
@@ -245,7 +247,7 @@ export function WorkspacePicker(props: {
disabledStyle={controlDisabledStyle}
>
<Icon name="refresh" color={C.secondary} />
- <Text style={{ color: C.secondary, fontSize: 13 }}>Retry folder</Text>
+ <Text style={{ color: C.secondary, fontSize: 13 }}>{tr('Retry folder')}</Text>
</Button>
</If>
</View>
@@ -297,11 +299,11 @@ export function WorkspacePicker(props: {
fontWeight: '600',
}}
>
- Choose workspace
+ {tr('Choose workspace')}
</Text>
<Button
- accessibilityLabel="Close workspace picker"
- tooltip="Close · Esc"
+ accessibilityLabel={tr('Close workspace picker')}
+ tooltip={tr('Close · Esc')}
onPress={inputAction(props.close, props.instance)}
style={{ ...controlStyle, width: 32, paddingHorizontal: 8, alignItems: 'center' }}
hoverStyle={controlHoverStyle}
@@ -316,8 +318,8 @@ export function WorkspacePicker(props: {
>
<Button
disabled={!read.trail.length}
- accessibilityLabel="Parent workspace folder"
- tooltip="Parent folder · Alt+Up"
+ accessibilityLabel={tr('Parent workspace folder')}
+ tooltip={tr('Parent folder · Alt+Up')}
onPress={inputAction(back, { location: read.id })}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={controlHoverStyle}
@@ -325,11 +327,11 @@ export function WorkspacePicker(props: {
disabledStyle={controlDisabledStyle}
>
<Icon name="back" color={C.secondary} />
- <Text style={{ color: C.secondary, fontSize: 13 }}>Parent</Text>
+ <Text style={{ color: C.secondary, fontSize: 13 }}>{tr('Parent')}</Text>
</Button>
<Button
disabled={!read.trail.length}
- accessibilityLabel="Workspace locations"
+ accessibilityLabel={tr('Workspace locations')}
onPress={inputAction(back, { location: read.id, roots: true })}
style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 8 }}
hoverStyle={controlHoverStyle}
@@ -337,7 +339,7 @@ export function WorkspacePicker(props: {
disabledStyle={controlDisabledStyle}
>
<Icon name="folder" color={C.secondary} />
- <Text style={{ color: C.secondary, fontSize: 13 }}>Locations</Text>
+ <Text style={{ color: C.secondary, fontSize: 13 }}>{tr('Locations')}</Text>
</Button>
</View>
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
@@ -360,7 +362,7 @@ export function WorkspacePicker(props: {
removeSearchKeys = () => unbind.forEach((remove) => remove());
}}
autoFocus
- accessibilityLabel="Search workspace folders"
+ accessibilityLabel={tr('Search workspace folders')}
value={query()}
onChange={(text) => {
if (!owns(read)) return;
@@ -368,7 +370,7 @@ export function WorkspacePicker(props: {
query.set(text);
list?.scrollToIndex({ index: 0, animated: false });
}}
- placeholder="Search workspaces…"
+ placeholder={tr('Search workspaces…')}
style={{
placeholderColor: C.muted,
color: C.text,
@@ -393,13 +395,15 @@ export function WorkspacePicker(props: {
style={{ color: C.text, fontSize: 13, lineHeight: 16, fontWeight: '600' }}
>
{read.trail.length
- ? `Folder · ${read.trail.at(-1)!.name}`
- : 'Workspace locations'}
+ ? tr('Folder · {name}', { name: read.trail.at(-1)!.name })
+ : tr('Workspace locations')}
</Text>
<Text selectable style={{ color: C.muted, fontSize: 12, lineHeight: 16 }}>
{currentDirectory(read)?.path ??
read.trail.at(-1)?.path ??
- 'Open a location, then choose the folder where the conversation can work.'}
+ tr(
+ 'Open a location, then choose the folder where the conversation can work.',
+ )}
</Text>
</View>
)}
@@ -413,13 +417,13 @@ export function WorkspacePicker(props: {
onLoadMore={() => {
void load(read);
}}
- accessibilityLabel="Workspace folders"
+ accessibilityLabel={tr('Workspace folders')}
style={{ flex: 1, minHeight: 32 }}
>
{(entry) => (
<Button
disabled={!!error()}
- accessibilityLabel={`Browse folder ${entry().name}`}
+ accessibilityLabel={tr('Browse folder {name}', { name: entry().name })}
tooltip={entry().path}
selected={isSelected(entry())}
onPress={inputAction(browse, {
@@ -458,8 +462,8 @@ export function WorkspacePicker(props: {
>
<Button
disabled={!currentDirectory(read) || !!error()}
- accessibilityLabel="Use this workspace folder"
- tooltip="Use this folder · Ctrl+Enter"
+ accessibilityLabel={tr('Use this workspace folder')}
+ tooltip={tr('Use this folder · Ctrl+Enter')}
onPress={
currentDirectory(read)
? inputAction(choose, {
@@ -481,7 +485,7 @@ export function WorkspacePicker(props: {
fontWeight: '600',
}}
>
- Use this folder
+ {tr('Use this folder')}
</Text>
</Button>
</View>
diff --git a/products/maka/terminal-shell/app/attachmentDraft.ts b/products/maka/terminal-shell/app/attachmentDraft.ts
index bc03286bf1..ccab37f33c 100644
--- a/products/maka/terminal-shell/app/attachmentDraft.ts
+++ b/products/maka/terminal-shell/app/attachmentDraft.ts
@@ -17,6 +17,8 @@
* under the License.
*/
+import { createText, UiError, errorText, type UiText, message as uiMessage } from './text';
+import { tr } from './locale';
import { cleanup, signal } from 'nsh';
import { findUpload, recoverUpload, abandonUpload, watchUploads, type ScopedHost } from './host';
import type { ClientDraftAttachment } from '../../../../packages/plugin-sdk/src/index.js';
@@ -33,12 +35,11 @@ export type UploadRow = {
id: string;
name: string;
kind: 'reading' | 'uploading' | 'failed' | 'unknown';
- error?: string;
+ error?: UiText;
original?: any;
};
export type AttachmentEdit = { attachments: DraftAttachment[]; settledUploads: string[] };
type UploadSubscription = { next(): Promise<any[] | null>; close(): void };
-const message = (reason: unknown) => (reason instanceof Error ? reason.message : String(reason));
// Native status is only progress. The original Journal receipt is the sole
// authority for readiness, recovery and whether an attempt exists at all.
@@ -66,7 +67,7 @@ export function createAttachmentDraft(options: {
const selecting = signal<UploadRow[]>([]);
const picker = signal(false);
const restoring = signal(true);
- const error = signal('');
+ const error = createText();
const recovering = signal<string | null>(null);
// Keep adopted IDs for this observer's lifetime: a Journal read that began
// before CAS must not resurrect a file after removal or a successful send.
@@ -153,7 +154,10 @@ export function createAttachmentDraft(options: {
result?.kind === 'rejected' || original.abandoned
? ('failed' as const)
: ('unknown' as const),
- error: result?.kind === 'rejected' ? `Upload rejected: ${result.reason}` : undefined,
+ error:
+ result?.kind === 'rejected'
+ ? uiMessage('Upload rejected: {reason}', { reason: result.reason })
+ : undefined,
original,
};
}),
@@ -171,13 +175,13 @@ export function createAttachmentDraft(options: {
while (alive && subscription === stream) {
const operations = await stream!.next();
if (!alive || subscription !== stream) return;
- if (operations === null) throw new Error('Attachment updates ended.');
+ if (operations === null) throw new UiError('Attachment updates ended.');
receive(operations);
}
} catch (reason) {
if (alive) {
watchFailed = true;
- error.set(message(reason));
+ error.set(errorText(reason));
}
} finally {
stream?.close();
@@ -209,7 +213,7 @@ export function createAttachmentDraft(options: {
if (alive)
selecting.set(
selecting().map((item) =>
- item.id === target.id ? { ...item, kind: 'unknown', error: message(reason) } : item,
+ item.id === target.id ? { ...item, kind: 'unknown', error: errorText(reason) } : item,
),
);
} finally {
@@ -261,7 +265,7 @@ export function createAttachmentDraft(options: {
const original = await recoverUpload(options.client, target.id);
if (alive) receiveOne(original);
} catch (reason) {
- if (alive) error.set(message(reason));
+ if (alive) error.set(errorText(reason));
} finally {
if (alive) recovering.set(null);
}
@@ -274,12 +278,12 @@ export function createAttachmentDraft(options: {
const original = await abandonUpload(options.client, target.id);
if (!alive) return;
if (!original.abandoned)
- throw new Error('This upload is prepared. Recover its original receipt.');
+ throw new UiError('This upload is prepared. Recover its original receipt.');
receiveOne(original);
settled.set(original.id, 'pending');
options.changed();
} catch (reason) {
- if (alive) error.set(message(reason));
+ if (alive) error.set(errorText(reason));
} finally {
if (alive) recovering.set(null);
}
@@ -328,13 +332,13 @@ export function createAttachmentDraft(options: {
}
},
blocked() {
- if (restoring()) return 'Restoring attachment drafts…';
- if (error()) return 'Attachment state needs attention. Your draft is kept.';
+ if (restoring()) return tr('Restoring attachment drafts…');
+ if (error()) return tr('Attachment state needs attention. Your draft is kept.');
if (rows().some((row) => row.kind === 'unknown'))
- return 'Recover the original upload receipt before sending.';
+ return tr('Recover the original upload receipt before sending.');
if (rows().some((row) => row.kind === 'reading' || row.kind === 'uploading'))
- return 'Uploading attachments… Your typing is kept.';
- if (rows().length) return 'Remove the failed attachment or choose it again.';
+ return tr('Uploading attachments… Your typing is kept.');
+ if (rows().length) return tr('Remove the failed attachment or choose it again.');
return '';
},
};
diff --git a/products/maka/terminal-shell/app/browsing.ts b/products/maka/terminal-shell/app/browsing.ts
index 08bcaa7eae..7df2de5ef5 100644
--- a/products/maka/terminal-shell/app/browsing.ts
+++ b/products/maka/terminal-shell/app/browsing.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { createText, errorText, UiError } from './text';
import { batch, cleanup, signal } from 'nsh';
import { host } from './host';
import type {
@@ -71,7 +72,6 @@ type CatalogRead = {
ready(): void;
};
-const message = (reason: unknown) => (reason instanceof Error ? reason.message : String(reason));
const uniqueSessions = (items: BrowserSession[]) => [
...new Map(items.map((session) => [session.id, session])).values(),
];
@@ -85,8 +85,8 @@ export function createBrowsing(): Browsing {
const sessions = signal<BrowserSession[]>([]);
const projects = signal<BrowserProject[]>([]);
const loading = signal(true);
- const error = signal('');
- const projectError = signal('');
+ const error = createText();
+ const projectError = createText();
let alive = true;
let current: CatalogRead | null = null;
let page: SessionPage | null = null;
@@ -111,7 +111,7 @@ export function createBrowsing(): Browsing {
});
sameRevision(next, previous.revision);
if (next.after === previous.after)
- throw new Error('Conversation paging did not advance. Refresh to continue.');
+ throw new UiError('Conversation paging did not advance. Refresh to continue.');
return next;
};
const close = (read: CatalogRead | null) => {
@@ -168,7 +168,7 @@ export function createBrowsing(): Browsing {
for (;;) {
const next = await opened.next();
if (!owns(read)) return;
- if (next === null) throw new Error('Conversation updates ended. Refresh to reconnect.');
+ if (next === null) throw new UiError('Conversation updates ended. Refresh to reconnect.');
if (page?.revision === next.revision) continue;
// Preserve the requested read range, never append across revisions.
// The old snapshot stays mounted while this revision is assembled.
@@ -202,7 +202,7 @@ export function createBrowsing(): Browsing {
if (!owns(read)) return;
// Subscription-before-read guarantees a newer catalog window for
// this conflict. Keep the gate until it arrives, without retry IO.
- if (message(reason) === 'agent_catalog_changed') continue;
+ if (errorText(reason) === 'agent_catalog_changed') continue;
throw reason;
}
const snapshot = [...rows.values()];
@@ -223,7 +223,7 @@ export function createBrowsing(): Browsing {
page = null;
continuation = null;
batch(() => {
- error.set(message(reason));
+ error.set(errorText(reason));
loading.set(false);
});
})
@@ -255,11 +255,11 @@ export function createBrowsing(): Browsing {
sessions.set(snapshot);
} catch (reason) {
if (!owns(read) || continuation !== original) return;
- if (message(reason) === 'agent_catalog_changed') {
+ if (errorText(reason) === 'agent_catalog_changed') {
waitForCatalog = true;
return;
}
- error.set(message(reason));
+ error.set(errorText(reason));
} finally {
if (owns(read) && continuation === original) {
if (!waitForCatalog) continuation = null;
@@ -285,11 +285,11 @@ export function createBrowsing(): Browsing {
} satisfies AgentProjectsRead);
if (!alive || projectRead !== reading) return;
if (next.kind === 'changed') break;
- if (next.kind !== 'projects') throw new Error('Projects are unavailable.');
+ if (next.kind !== 'projects') throw new UiError('Projects are unavailable.');
if (cursor && next.revision !== cursor.revision)
- throw new Error('The project list changed between pages. Refresh to try again.');
+ throw new UiError('The project list changed between pages. Refresh to try again.');
if (cursor && next.next?.after === cursor.after)
- throw new Error('Project paging did not advance. Refresh to try again.');
+ throw new UiError('Project paging did not advance. Refresh to try again.');
items.push(...next.projects);
cursor = next.next;
if (cursor === null) {
@@ -298,9 +298,9 @@ export function createBrowsing(): Browsing {
}
}
}
- throw new Error('Projects changed while reading. Refresh to try again.');
+ throw new UiError('Projects changed while reading. Refresh to try again.');
} catch (reason) {
- if (alive && projectRead === reading) projectError.set(message(reason));
+ if (alive && projectRead === reading) projectError.set(errorText(reason));
}
};
diff --git a/products/maka/terminal-shell/app/connectionLogin.ts b/products/maka/terminal-shell/app/connectionLogin.ts
index be92f58f50..395e848e28 100644
--- a/products/maka/terminal-shell/app/connectionLogin.ts
+++ b/products/maka/terminal-shell/app/connectionLogin.ts
@@ -25,6 +25,7 @@ import type {
SettingsLoginPrompt,
} from '../../../../packages/plugin-sdk/src/index.js';
import { host } from './host';
+import { errorText, message, UiError } from './text';
import { settingsRejection, settingsValue, type SettingsOperations } from './settingsOperations';
export type LoginAction = { editor: number; input: SettingsCredentialLogin };
@@ -68,9 +69,11 @@ export function createConnectionLogin(
for (;;) {
const event = await mutation.next();
if (!event)
- throw new Error('Sign-in ended without a final receipt. Recover the original action.');
+ throw new UiError(
+ 'Sign-in ended without a final receipt. Recover the original action.',
+ );
if (event.kind === 'not_dispatched') {
- if (alive) operations.notice.set('Sign-in cancelled before it started.');
+ if (alive) operations.notice.set(message('Sign-in cancelled before it started.'));
return;
}
if (event.kind === 'progress') {
@@ -81,14 +84,15 @@ export function createConnectionLogin(
continue;
}
const result = settingsValue(event.reply) as SettingsCredentialLoginResult;
- let failure: Error | null = null;
+ let failure: UiError | null = null;
if (result.kind === 'saved') {
// A saved business receipt remains saved if cleanup fails. Retire
// its old editable basis before reporting that independent error.
await owner.saved(action, result);
- if (alive) operations.notice.set('Sign-in saved in the Host credential vault.');
+ if (alive)
+ operations.notice.set(message('Sign-in saved in the Host credential vault.'));
} else if (result.kind === 'failed') {
- failure = new Error(
+ failure = new UiError(
result.reason === 'cancelled'
? 'Sign-in cancelled.'
: result.reason === 'expired'
@@ -97,8 +101,9 @@ export function createConnectionLogin(
);
} else failure = settingsRejection(result);
if (event.cleanupError)
- throw new Error(
- `${failure?.message ?? 'Sign-in was saved.'} Its original request could not finish its resource cleanup.`,
+ throw new UiError(
+ '{result} Its original request could not finish its resource cleanup.',
+ { result: failure ? errorText(failure) : message('Sign-in was saved.') },
);
if (failure) throw failure;
return;
@@ -129,7 +134,7 @@ export function createConnectionLogin(
await Clipboard.writeText(action.value);
if (alive && state()?.occurrence === action.occurrence)
operations.notice.set(
- action.field === 'url' ? 'Sign-in URL copied.' : 'Sign-in code copied.',
+ action.field === 'url' ? message('Sign-in URL copied.') : message('Sign-in code copied.'),
);
} catch (reason) {
if (alive && state()?.occurrence === action.occurrence) operations.report(reason);
diff --git a/products/maka/terminal-shell/app/generalSettings.ts b/products/maka/terminal-shell/app/generalSettings.ts
index 58a7869af2..3c4cb6c16e 100644
--- a/products/maka/terminal-shell/app/generalSettings.ts
+++ b/products/maka/terminal-shell/app/generalSettings.ts
@@ -19,6 +19,8 @@
import { batch, cleanup, signal } from 'nsh';
import { host } from './host';
+import { applyLanguagePreference, tr } from './locale';
+import { message, UiError } from './text';
import type {
LanguagePreference,
SettingsAssistantPreferences,
@@ -47,8 +49,8 @@ export type GeneralAction = {
revision: number | null;
kind: 'root' | Editor['kind'];
};
-export const assistantLanguages: { value: LanguagePreference; label: string }[] = [
- { value: 'auto', label: 'Automatic' },
+export const assistantLanguages = (): { value: LanguagePreference; label: string }[] => [
+ { value: 'auto', label: tr('Automatic') },
{ value: 'en', label: 'English' },
{ value: 'zh-CN', label: '简体中文' },
{ value: 'zh-TW', label: '繁體中文' },
@@ -104,6 +106,7 @@ export function createGeneralSettings(operations: SettingsOperations, active: ()
'assistantPreferences',
{},
);
+ applyLanguagePreference(result.language ?? 'auto', result.revision);
if (owns()) facts.set(result);
} catch (reason) {
if (owns()) operations.report(reason);
@@ -128,6 +131,8 @@ export function createGeneralSettings(operations: SettingsOperations, active: ()
if (!alive) return;
switch (result.kind) {
case 'committed':
+ if (mutation.kind === 'language')
+ applyLanguagePreference(mutation.preference, result.revision);
// Retire the submitted editor before reading new facts. A failed read
// cannot leave a committed draft available to submit a second time.
batch(() => {
@@ -135,16 +140,16 @@ export function createGeneralSettings(operations: SettingsOperations, active: ()
if (submitted && editor()?.id === submitted.id) editor.set(null);
cancelRead();
});
- operations.notice.set('Preferences saved.');
if (visible()) await load();
+ operations.notice.set(message('Preferences saved.'));
break;
case 'revision_conflict':
facts.set(null);
- throw new Error(
+ throw new UiError(
'Preferences changed elsewhere. Cancel this edit and refresh before saving again.',
);
case 'rejected':
- throw new Error('The Host rejected these preferences. Check the values before saving.');
+ throw new UiError('The Host rejected these preferences. Check the values before saving.');
}
});
};
@@ -214,10 +219,10 @@ export function createGeneralSettings(operations: SettingsOperations, active: ()
back,
title: () =>
editor()?.kind === 'personalization'
- ? 'Personalization'
+ ? tr('Personalization')
: editor()?.kind === 'compaction'
- ? 'Compaction prompt'
- : 'General settings',
+ ? tr('Compaction prompt')
+ : tr('General settings'),
open: ({ request, kind }: { request: GeneralAction; kind: Editor['kind'] }) =>
open(request, kind),
reload(request: GeneralAction) {
@@ -280,7 +285,7 @@ export function createGeneralSettings(operations: SettingsOperations, active: ()
);
}
if (!form.useDefault && !form.prompt.trim()) {
- operations.report(new Error('Enter a compaction prompt or choose the Host default.'));
+ operations.report(new UiError('Enter a compaction prompt or choose the Host default.'));
return;
}
return mutate(
diff --git a/products/maka/terminal-shell/app/host.ts b/products/maka/terminal-shell/app/host.ts
index a5553204a1..e663af18af 100644
--- a/products/maka/terminal-shell/app/host.ts
+++ b/products/maka/terminal-shell/app/host.ts
@@ -17,6 +17,8 @@
* under the License.
*/
+import { UiError } from './text';
+import { tr } from './locale';
import { host as nativeHost } from 'maka/host';
import type { CapturedFile, ScopedHost } from 'maka/host';
import type {
@@ -87,7 +89,7 @@ const count = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
function fileView(capture: CapturedFile): FileView {
const value: unknown = capture.metadata();
- if (!object(value)) throw new Error('Invalid native file presentation.');
+ if (!object(value)) throw new UiError('Invalid native file presentation.');
const { version, digest, bytes } = value;
if (
value.kind === 'text' &&
@@ -110,7 +112,7 @@ function fileView(capture: CapturedFile): FileView {
captured() &&
typeof value.uri === 'string' &&
typeof value.mimeType === 'string');
- if (!valid) throw new Error('Invalid native file presentation.');
+ if (!valid) throw new UiError('Invalid native file presentation.');
return value as FileView;
}
@@ -126,7 +128,7 @@ export type OriginalMutation = {
};
function attempt(value: unknown): Attempt {
if (!object(value) || typeof value.operationId !== 'string' || !('reply' in value))
- throw new Error('Invalid native operation observation.');
+ throw new UiError('Invalid native operation observation.');
return { operationId: value.operationId, reply: value.reply };
}
@@ -232,13 +234,16 @@ export async function terminalMutation<M extends Method<'terminal', 'mutation'>>
input: Input<'terminal', M>,
): Promise<Output<'terminal', M>> {
const observed = await scope.terminal(method, input);
- if (!object(observed) || !object(observed.reply)) throw new Error('Invalid terminal receipt.');
+ if (!object(observed) || !object(observed.reply)) throw new UiError('Invalid terminal receipt.');
const reply = observed.reply as unknown as OperationReply;
if (reply.receipt.kind !== 'recorded' || reply.receipt.outcome.kind !== 'returned')
- throw new Error('Terminal result is unknown. Recover the original action before trying again.');
+ throw new UiError(
+ 'Terminal result is unknown. Recover the original action before trying again.',
+ );
if (observed.cleanupError)
- throw new Error(
- `Terminal result was recorded, but its original call did not finish cleanup: ${String(observed.cleanupError)}. Do not repeat this action.`,
+ throw new UiError(
+ 'Terminal result was recorded, but its original call did not finish cleanup: {reason}. Do not repeat this action.',
+ { reason: String(observed.cleanupError) },
);
return reply.receipt.outcome.value as Output<'terminal', M>;
}
diff --git a/products/maka/terminal-shell/app/interaction-form.ts b/products/maka/terminal-shell/app/interaction-form.ts
index e57d79f08b..1622a10b35 100644
--- a/products/maka/terminal-shell/app/interaction-form.ts
+++ b/products/maka/terminal-shell/app/interaction-form.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { message as uiMessage, type UiText } from './text';
import type {
FormField,
FormValue,
@@ -81,21 +82,22 @@ function calendar(value: string, time: boolean): boolean {
(!time || (n(4) <= 23 && n(5) <= 59 && n(6) <= 59 && n(7) <= 23 && n(8) <= 59))
);
}
-function problem(field: FormField, value: FormValue | undefined): string | null {
+function problem(field: FormField, value: FormValue | undefined): UiText | null {
switch (field.kind) {
case 'string': {
- if (typeof value !== 'string') return 'Enter text.';
+ if (typeof value !== 'string') return uiMessage('Enter text.');
const length = [...value].length;
- if (length < (field.minLength ?? 0)) return `Use at least ${field.minLength} characters.`;
+ if (length < (field.minLength ?? 0))
+ return uiMessage('Use at least {count} characters.', { count: field.minLength! });
if (field.maxLength !== undefined && field.maxLength !== null && length > field.maxLength)
- return `Use at most ${field.maxLength} characters.`;
+ return uiMessage('Use at most {count} characters.', { count: field.maxLength! });
if (field.format === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
- return 'Enter an email address.';
+ return uiMessage('Enter an email address.');
if (field.format === 'uri') {
try {
new URL(value);
} catch {
- return 'Enter a complete URI, including its scheme.';
+ return uiMessage('Enter a complete URI, including its scheme.');
}
}
if (
@@ -103,8 +105,8 @@ function problem(field: FormField, value: FormValue | undefined): string | null
!calendar(value, field.format === 'date-time')
)
return field.format === 'date'
- ? 'Enter a valid date: YYYY-MM-DD.'
- : 'Enter a valid date and time, including seconds and a timezone.';
+ ? uiMessage('Enter a valid date: YYYY-MM-DD.')
+ : uiMessage('Enter a valid date and time, including seconds and a timezone.');
return null;
}
case 'number':
@@ -114,16 +116,21 @@ function problem(field: FormField, value: FormValue | undefined): string | null
!Number.isFinite(value) ||
(field.kind === 'integer' && !Number.isSafeInteger(value))
)
- return field.kind === 'integer' ? 'Enter a whole, safe integer.' : 'Enter a finite number.';
+ return field.kind === 'integer'
+ ? uiMessage('Enter a whole, safe integer.')
+ : uiMessage('Enter a finite number.');
if (value < (field.minimum ?? -Infinity) || value > (field.maximum ?? Infinity))
- return `Use a number from ${field.minimum ?? '−∞'} to ${field.maximum ?? '∞'}.`;
+ return uiMessage('Use a number from {minimum} to {maximum}.', {
+ minimum: field.minimum ?? '−∞',
+ maximum: field.maximum ?? '∞',
+ });
return null;
case 'boolean':
- return typeof value === 'boolean' ? null : 'Choose Yes or No.';
+ return typeof value === 'boolean' ? null : uiMessage('Choose Yes or No.');
case 'single_select':
return typeof value === 'string' && field.options.some((option) => option.value === value)
? null
- : 'Choose one option.';
+ : uiMessage('Choose one option.');
case 'multi_select':
return Array.isArray(value) &&
value.length >= (field.minItems ?? 0) &&
@@ -131,7 +138,10 @@ function problem(field: FormField, value: FormValue | undefined): string | null
new Set(value).size === value.length &&
value.every((part) => field.options.some((option) => option.value === part))
? null
- : `Choose ${field.minItems ?? 0}–${field.maxItems ?? field.options.length} options.`;
+ : uiMessage('Choose {minimum}–{maximum} options.', {
+ minimum: field.minItems ?? 0,
+ maximum: field.maxItems ?? field.options.length,
+ });
}
}
export function formSubmission(
@@ -139,12 +149,12 @@ export function formSubmission(
drafts: FormDraft[],
): {
answer: Extract<InteractionAnswer, { kind: 'form'; action: 'accept' }> | null;
- problems: Array<string | null>;
+ problems: Array<UiText | null>;
} {
const values: Record<string, FormValue> = Object.create(null);
const problems = fields.map((field, index) => {
const draft = drafts[index];
- if (!draft || (!draft.included && field.required)) return 'This field is required.';
+ if (!draft || (!draft.included && field.required)) return uiMessage('This field is required.');
if (!draft.included) return null;
const raw = draft.value;
const value =
diff --git a/products/maka/terminal-shell/app/locale-common.ts b/products/maka/terminal-shell/app/locale-common.ts
new file mode 100644
index 0000000000..c5af1428d2
--- /dev/null
+++ b/products/maka/terminal-shell/app/locale-common.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 { Catalog } from '../../../../plugins/shared/locale.js';
+import {
+ msg_cancel,
+ msg_checklistRetry,
+ msg_closeModels,
+ msg_graphStatus_failed,
+ msg_helpNew,
+ msg_models,
+ msg_thinkingUnavailable,
+} from '../../../../plugins/shared/locale-messages.js';
+
+/** Shared interface terms have one translation owner across every surface. */
+export const commonMessages = {
+ 'Abandon unconfirmed {method}': [
+ 'Abandon unconfirmed {method}',
+ '放弃尚未确认的 {method}',
+ '放棄尚未確認的 {method}',
+ ],
+ Cancel: msg_cancel,
+ 'Choose workspace': ['Choose workspace', '选择工作区', '選擇工作區'],
+ Close: msg_closeModels,
+ 'Close · Esc': ['Close · Esc', '关闭 · Esc', '關閉 · Esc'],
+ 'Connections & models': ['Connections & models', '连接与模型', '連線與模型'],
+ Failed: msg_graphStatus_failed,
+ 'Loading…': ['Loading…', '正在加载…', '正在載入…'],
+ Models: msg_models,
+ 'New conversation': msg_helpNew,
+ 'Original image': ['Original image', '原始图片', '原始圖片'],
+ Retry: msg_checklistRetry,
+ Save: ['Save', '保存', '儲存'],
+ 'shared · view only': ['shared · view only', '共享 · 仅查看', '共用 · 僅檢視'],
+ 'Stop process': ['Stop process', '停止进程', '停止處理程序'],
+ Terminal: ['Terminal', '终端', '終端機'],
+ Unavailable: msg_thinkingUnavailable,
+ 'Untitled conversation': ['Untitled conversation', '未命名对话', '未命名對話'],
+} as const satisfies Catalog;
diff --git a/products/maka/terminal-shell/app/locale-conversation.ts b/products/maka/terminal-shell/app/locale-conversation.ts
new file mode 100644
index 0000000000..8400a6ef33
--- /dev/null
+++ b/products/maka/terminal-shell/app/locale-conversation.ts
@@ -0,0 +1,1291 @@
+/*
+ * 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 { Catalog } from '../../../../plugins/shared/locale.js';
+import {
+ msg_connectionProvider,
+ msg_copyMessage,
+ msg_diagnosticsOutcomeUnknown,
+ msg_diagnosticsTimedOut,
+ msg_edit,
+ msg_findFieldTool,
+ msg_formCancel,
+ msg_formDecline,
+ msg_formSubmit,
+ msg_graphStatus_completed,
+ msg_graphStatus_stopped,
+ msg_helpCommands,
+ msg_helpSend,
+ msg_paletteClose,
+ msg_paletteTitle,
+ msg_permissionDenied,
+ msg_permissionDeny,
+ msg_permissionRead,
+ msg_permissionReadOnly,
+ msg_permissionRequested,
+ msg_permissionTurn,
+ msg_questionSubmit,
+ msg_queueEdit,
+ msg_reviewRequest,
+ msg_sandboxWorkspace,
+ msg_scheduleSkip,
+ msg_stop,
+ msg_terminalCommand,
+ msg_terminalNew,
+ msg_toolByteRange,
+ msg_toolEditAction,
+ msg_toolFilePath,
+ msg_toolPatch,
+ msg_toolProcessCwd,
+ msg_toolProcessRead,
+ msg_toolProcessRef,
+ msg_toolReadAction,
+ msg_toolWriteAction,
+ msg_working,
+} from '../../../../plugins/shared/locale-messages.js';
+
+export const conversationMessages = {
+ 'A fresh start in your workspace': [
+ 'A fresh start in your workspace',
+ '在工作区中开启新对话',
+ '在工作區中開啟新對話',
+ ],
+ 'Abandon unconfirmed send': ['Abandon unconfirmed send', '放弃未确认的发送', '放棄未確認的傳送'],
+ 'Abandon unconfirmed upload': [
+ 'Abandon unconfirmed upload',
+ '放弃未确认的上传',
+ '放棄未確認的上傳',
+ ],
+ 'Access allowed · {scope}': [
+ 'Access allowed · {scope}',
+ '已允许访问 · {scope}',
+ '已允許存取 · {scope}',
+ ],
+ 'Access denied': msg_permissionDenied,
+ 'Action feedback': ['Action feedback', '操作反馈', '操作回饋'],
+ Activity: ['Activity', '活动', '活動'],
+ 'Add connections, API keys and models': [
+ 'Add connections, API keys and models',
+ '添加连接、API 密钥和模型',
+ '新增連線、API 金鑰和模型',
+ ],
+ 'All items complete': ['All items complete', '所有项目已完成', '所有項目已完成'],
+ Allow: ['Allow', '允许', '允許'],
+ 'Allow exactly these paths and network destinations for:': [
+ 'Allow exactly these paths and network destinations for:',
+ '仅允许以下路径和网络目标,授权范围:',
+ '僅允許以下路徑和網路目標,授權範圍:',
+ ],
+ 'Allow · Ctrl+Enter': ['Allow · Ctrl+Enter', '允许 · Ctrl+Enter', '允許 · Ctrl+Enter'],
+ 'An action is being confirmed. Try again in a moment.': [
+ 'An action is being confirmed. Try again in a moment.',
+ '正在确认操作,请稍后重试。',
+ '正在確認操作,請稍後重試。',
+ ],
+ 'An attempt was not confirmed. Explicitly abandon it below before starting a new send.': [
+ 'An attempt was not confirmed. Explicitly abandon it below before starting a new send.',
+ '之前的尝试尚未确认。请先在下方放弃该尝试,再重新发送。',
+ '先前的嘗試尚未確認。請先在下方放棄該嘗試,再重新傳送。',
+ ],
+ 'An original answer receipt is unresolved. Refresh is read-only; further answers stay disabled.':
+ [
+ 'An original answer receipt is unresolved. Refresh is read-only; further answers stay disabled.',
+ '原回答回执尚未确认。刷新仅查询状态,暂时无法继续回答。',
+ '原回答回執尚未確認。重新整理僅查詢狀態,暫時無法繼續回答。',
+ ],
+ 'Analyzing the request': ['Analyzing the request', '正在分析请求', '正在分析請求'],
+ 'Answer every question or explicitly choose Skip.': [
+ 'Answer every question or explicitly choose Skip.',
+ '请回答每个问题,或选择跳过。',
+ '請回答每個問題,或選擇略過。',
+ ],
+ 'Answer status is unknown. Recover its original receipt in the main view. {reason}': [
+ 'Answer status is unknown. Recover its original receipt in the main view. {reason}',
+ '回答状态未知,请在主界面恢复原回执。{reason}',
+ '回答狀態未知,請在主畫面恢復原回執。{reason}',
+ ],
+ 'Answer status is unknown. Recover its original receipt in the main view; do not submit again.': [
+ 'Answer status is unknown. Recover its original receipt in the main view; do not submit again.',
+ '回答状态未知。请在主界面恢复原回执,不要再次提交。',
+ '回答狀態未知。請在主畫面恢復原回執,不要再次提交。',
+ ],
+ 'Answer was not accepted: {reason}. Refresh the request before trying again.': [
+ 'Answer was not accepted: {reason}. Refresh the request before trying again.',
+ '回答未被接受:{reason}。请刷新请求后重试。',
+ '回答未被接受:{reason}。請重新整理請求後重試。',
+ ],
+ 'Answers submitted': ['Answers submitted', '答案已提交', '答案已提交'],
+ 'Applies to this capability within the current permission boundary.': [
+ 'Applies to this capability within the current permission boundary.',
+ '适用于当前权限边界内的此项能力。',
+ '適用於目前權限邊界內的此項能力。',
+ ],
+ 'Approve this original call only. This does not grant access to future calls.': [
+ 'Approve this original call only. This does not grant access to future calls.',
+ '仅允许本次原始调用,不授权后续调用。',
+ '僅允許本次原始呼叫,不授權後續呼叫。',
+ ],
+ 'Attach files': ['Attach files', '添加附件', '新增附件'],
+ 'Attach files · Ctrl+U': ['Attach files · Ctrl+U', '添加附件 · Ctrl+U', '新增附件 · Ctrl+U'],
+ 'Attachment state needs attention. Your draft is kept.': [
+ 'Attachment state needs attention. Your draft is kept.',
+ '附件状态需要处理,草稿已保留。',
+ '附件狀態需要處理,草稿已保留。',
+ ],
+ 'Attachment updates ended.': [
+ 'Attachment updates ended.',
+ '附件更新已结束。',
+ '附件更新已結束。',
+ ],
+ 'Browse current changes': ['Browse current changes', '浏览当前更改', '瀏覽目前變更'],
+ 'Browse files': ['Browse files', '浏览文件', '瀏覽檔案'],
+ 'Browse sessions': ['Browse sessions', '浏览对话', '瀏覽對話'],
+ 'Browse sessions · Ctrl+O': [
+ 'Browse sessions · Ctrl+O',
+ '浏览对话 · Ctrl+O',
+ '瀏覽對話 · Ctrl+O',
+ ],
+ 'Browse workspace files': ['Browse workspace files', '浏览工作区文件', '瀏覽工作區檔案'],
+ 'Browse workspace files and current changes': [
+ 'Browse workspace files and current changes',
+ '浏览工作区文件和当前更改',
+ '瀏覽工作區檔案和目前變更',
+ ],
+ 'Browser origin: {origin}': [
+ 'Browser origin: {origin}',
+ '浏览器来源:{origin}',
+ '瀏覽器來源:{origin}',
+ ],
+ 'Cache read tokens': ['Cache read tokens', '缓存读取 Token', '快取讀取 Token'],
+ 'Cache write tokens': ['Cache write tokens', '缓存写入 Token', '快取寫入 Token'],
+ 'Cancel queued message edit': [
+ 'Cancel queued message edit',
+ '取消编辑队列消息',
+ '取消編輯佇列訊息',
+ ],
+ 'Cancel request': msg_formCancel,
+ 'Cancel the current run': ['Cancel the current run', '停止当前执行', '停止目前執行'],
+ 'Cancel undispatched action': [
+ 'Cancel undispatched action',
+ '取消尚未派发的操作',
+ '取消尚未派發的操作',
+ ],
+ Capability: ['Capability', '能力', '能力'],
+ 'Capability allowed': ['Capability allowed', '已允许此能力', '已允許此能力'],
+ 'Capability denied': ['Capability denied', '已拒绝此能力', '已拒絕此能力'],
+ 'Capability request': ['Capability request', '能力请求', '能力請求'],
+ Changes: ['Changes', '更改', '變更'],
+ Chat: ['Chat', '对话', '對話'],
+ 'Check original upload': ['Check original upload', '查询原上传结果', '查詢原上傳結果'],
+ 'Checking earlier actions. Try again in a moment.': [
+ 'Checking earlier actions. Try again in a moment.',
+ '正在检查之前的操作,请稍后重试。',
+ '正在檢查先前的操作,請稍後重試。',
+ ],
+ 'Checklist updates ended. Reopen the conversation.': [
+ 'Checklist updates ended. Reopen the conversation.',
+ '清单更新已结束,请重新打开对话。',
+ '清單更新已結束,請重新開啟對話。',
+ ],
+ 'Choose Yes or No.': ['Choose Yes or No.', '请选择是或否。', '請選擇是或否。'],
+ 'Choose a model before sending.': [
+ 'Choose a model before sending.',
+ '请先选择模型再发送。',
+ '請先選擇模型再傳送。',
+ ],
+ 'Choose a model.': ['Choose a model.', '请选择模型。', '請選擇模型。'],
+ 'Choose a workspace to begin…': [
+ 'Choose a workspace to begin…',
+ '选择工作区,开始对话…',
+ '選擇工作區,開始對話…',
+ ],
+ 'Choose an option, write another answer, or skip each question.': [
+ 'Choose an option, write another answer, or skip each question.',
+ '请为每个问题选择选项、填写其他答案,或跳过。',
+ '請為每個問題選擇選項、填寫其他答案,或略過。',
+ ],
+ 'Choose composer workspace': [
+ 'Choose composer workspace',
+ '选择输入区工作区',
+ '選擇輸入區工作區',
+ ],
+ 'Choose one option.': ['Choose one option.', '请选择一个选项。', '請選擇一個選項。'],
+ 'Choose the model for this conversation': [
+ 'Choose the model for this conversation',
+ '为此对话选择模型',
+ '為此對話選擇模型',
+ ],
+ 'Choose workspace from composer card': [
+ 'Choose workspace from composer card',
+ '从输入卡片选择工作区',
+ '從輸入卡片選擇工作區',
+ ],
+ 'Choose {minimum}–{maximum} options.': [
+ 'Choose {minimum}–{maximum} options.',
+ '请选择 {minimum}–{maximum} 个选项。',
+ '請選擇 {minimum}–{maximum} 個選項。',
+ ],
+ 'Close attachment picker': ['Close attachment picker', '关闭附件选择器', '關閉附件選擇器'],
+ 'Close commands': msg_paletteClose,
+ 'Close picker': ['Close picker', '关闭选择器', '關閉選擇器'],
+ 'Close turn usage': ['Close turn usage', '关闭本轮用量', '關閉本輪用量'],
+ 'Close workspace preview': ['Close workspace preview', '关闭工作区预览', '關閉工作區預覽'],
+ 'Close workspace preview · Esc': [
+ 'Close workspace preview · Esc',
+ '关闭工作区预览 · Esc',
+ '關閉工作區預覽 · Esc',
+ ],
+ 'Close {name}': ['Close {name}', '关闭 {name}', '關閉 {name}'],
+ 'Closing disconnected updates… Your draft is kept.': [
+ 'Closing disconnected updates… Your draft is kept.',
+ '正在关闭已断开的更新…草稿已保留。',
+ '正在關閉已中斷的更新…草稿已保留。',
+ ],
+ 'Code copied': ['Code copied', '代码已复制', '程式碼已複製'],
+ Commands: msg_paletteTitle,
+ 'Commands · Ctrl+P': ['Commands · Ctrl+P', '命令 · Ctrl+P', '命令 · Ctrl+P'],
+ 'Complete original arguments loaded.': [
+ 'Complete original arguments loaded.',
+ '已加载完整原始参数。',
+ '已載入完整原始參數。',
+ ],
+ 'Complete the included fields before submitting.': [
+ 'Complete the included fields before submitting.',
+ '请填完已选择的字段后提交。',
+ '請填完已選擇的欄位後提交。',
+ ],
+ Completed: msg_graphStatus_completed,
+ 'Computer access for this session': [
+ 'Computer access for this session',
+ '本次对话的电脑访问',
+ '本次對話的電腦存取',
+ ],
+ 'Confirmation is still unknown. Recover the original action below.': [
+ 'Confirmation is still unknown. Recover the original action below.',
+ '确认结果仍未知,请在下方恢复原操作。',
+ '確認結果仍未知,請在下方恢復原操作。',
+ ],
+ 'Confirming action…': ['Confirming action…', '正在确认操作…', '正在確認操作…'],
+ 'Confirming send… New typing is kept.': [
+ 'Confirming send… New typing is kept.',
+ '正在确认发送…新输入的内容已保留。',
+ '正在確認傳送…新輸入的內容已保留。',
+ ],
+ 'Confirming upload…': ['Confirming upload…', '正在确认上传…', '正在確認上傳…'],
+ Context: ['Context', '上下文', '上下文'],
+ 'Context (last known)': ['Context (last known)', '上下文(上次已知值)', '上下文(上次已知值)'],
+ Contract: ['Contract', '契约', '契約'],
+ 'Conversation composer': ['Conversation composer', '对话输入区', '對話輸入區'],
+ 'Conversation creation rejected: {reason}': [
+ 'Conversation creation rejected: {reason}',
+ '创建对话被拒绝:{reason}',
+ '建立對話遭拒:{reason}',
+ ],
+ 'Conversation terminals': ['Conversation terminals', '对话终端', '對話終端'],
+ 'Conversation todo dock': ['Conversation todo dock', '对话任务清单', '對話待辦清單'],
+ Copied: ['Copied', '已复制', '已複製'],
+ 'Copy code block': ['Copy code block', '复制代码块', '複製程式碼區塊'],
+ 'Copy edit': ['Copy edit', '复制编辑内容', '複製編輯內容'],
+ 'Copy last alternate screen': [
+ 'Copy last alternate screen',
+ '复制最后备用屏幕',
+ '複製最後備用畫面',
+ ],
+ 'Copy message': msg_copyMessage,
+ 'Copy queued message edit': [
+ 'Copy queued message edit',
+ '复制队列消息编辑内容',
+ '複製佇列訊息編輯內容',
+ ],
+ 'Copy read contents': ['Copy read contents', '复制读取的内容', '複製讀取的內容'],
+ 'Copy recorded command': ['Copy recorded command', '复制已记录的命令', '複製已記錄的命令'],
+ 'Copy recorded previous contents': [
+ 'Copy recorded previous contents',
+ '复制已记录的原内容',
+ '複製已記錄的原內容',
+ ],
+ 'Copy requested file contents': [
+ 'Copy requested file contents',
+ '复制请求的文件内容',
+ '複製請求的檔案內容',
+ ],
+ 'Copy requested patch': ['Copy requested patch', '复制请求的补丁', '複製請求的修補'],
+ 'Copy requested removal': ['Copy requested removal', '复制请求的删除内容', '複製請求的刪除內容'],
+ 'Copy requested replacement': [
+ 'Copy requested replacement',
+ '复制请求的替换内容',
+ '複製請求的取代內容',
+ ],
+ 'Copy stderr': ['Copy stderr', '复制标准错误输出', '複製標準錯誤輸出'],
+ 'Copy stdout': ['Copy stdout', '复制标准输出', '複製標準輸出'],
+ 'Copy terminal screen': ['Copy terminal screen', '复制终端屏幕', '複製終端畫面'],
+ 'Copy terminal scrollback': ['Copy terminal scrollback', '复制终端历史输出', '複製終端歷史輸出'],
+ 'Copy user message': ['Copy user message', '复制用户消息', '複製使用者訊息'],
+ 'Creating fork…': ['Creating fork…', '正在创建分支对话…', '正在建立分支對話…'],
+ 'Current queued message': ['Current queued message', '当前队列消息', '目前佇列訊息'],
+ Decline: msg_formDecline,
+ 'Decline · D': ['Decline · D', '拒绝 · D', '拒絕 · D'],
+ Deny: msg_permissionDeny,
+ 'Deny · D': ['Deny · D', '拒绝 · D', '拒絕 · D'],
+ Dispatched: ['Dispatched', '已派发', '已派發'],
+ 'Draft attachments': ['Draft attachments', '草稿附件', '草稿附件'],
+ 'Edit copied': ['Edit copied', '编辑内容已复制', '編輯內容已複製'],
+ 'Edit file': msg_toolEditAction,
+ 'Edit message': msg_edit,
+ 'Edit queued message': msg_queueEdit,
+ 'Edit queued message {number}': [
+ 'Edit queued message {number}',
+ '编辑队列消息 {number}',
+ '編輯佇列訊息 {number}',
+ ],
+ 'Empty recorded text.': ['Empty recorded text.', '记录的文本为空。', '記錄的文字為空。'],
+ 'Enter a complete URI, including its scheme.': [
+ 'Enter a complete URI, including its scheme.',
+ '请输入完整 URI,包括协议。',
+ '請輸入完整 URI,包括通訊協定。',
+ ],
+ 'Enter a finite number.': ['Enter a finite number.', '请输入有限数值。', '請輸入有限數值。'],
+ 'Enter a valid date and time, including seconds and a timezone.': [
+ 'Enter a valid date and time, including seconds and a timezone.',
+ '请输入有效的日期和时间,包括秒和时区。',
+ '請輸入有效的日期和時間,包括秒和時區。',
+ ],
+ 'Enter a valid date: YYYY-MM-DD.': [
+ 'Enter a valid date: YYYY-MM-DD.',
+ '请输入有效日期:YYYY-MM-DD。',
+ '請輸入有效日期:YYYY-MM-DD。',
+ ],
+ 'Enter a whole, safe integer.': [
+ 'Enter a whole, safe integer.',
+ '请输入安全范围内的整数。',
+ '請輸入安全範圍內的整數。',
+ ],
+ 'Enter an email address.': [
+ 'Enter an email address.',
+ '请输入电子邮件地址。',
+ '請輸入電子郵件地址。',
+ ],
+ 'Enter text.': ['Enter text.', '请输入文本。', '請輸入文字。'],
+ Error: ['Error', '错误', '錯誤'],
+ 'Exit {code}': ['Exit {code}', '退出码 {code}', '結束碼 {code}'],
+ File: msg_toolFilePath,
+ Files: ['Files', '文件', '檔案'],
+ 'Filesystem: no additional access': [
+ 'Filesystem: no additional access',
+ '文件系统:无额外访问权限',
+ '檔案系統:無額外存取權限',
+ ],
+ 'Find files': ['Find files', '查找文件', '尋找檔案'],
+ 'Focus activity': ['Focus activity', '聚焦活动面板', '聚焦活動面板'],
+ 'Focus conversation': ['Focus conversation', '聚焦对话', '聚焦對話'],
+ 'Focus message composer': ['Focus message composer', '聚焦消息输入区', '聚焦訊息輸入區'],
+ Folder: ['Folder', '文件夹', '資料夾'],
+ 'Fork created. Open it in Sessions.': [
+ 'Fork created. Open it in Sessions.',
+ '分支对话已创建,可在对话列表中打开。',
+ '分支對話已建立,可在對話清單中開啟。',
+ ],
+ 'Fork from this turn': ['Fork from this turn', '从本轮创建分支', '從本輪建立分支'],
+ 'Form cancelled': ['Form cancelled', '表单已取消', '表單已取消'],
+ 'Form declined': ['Form declined', '已拒绝填写表单', '已拒絕填寫表單'],
+ 'Form request': ['Form request', '表单请求', '表單請求'],
+ 'Form submitted · {count} fields': [
+ 'Form submitted · {count} fields',
+ '表单已提交 · {count} 个字段',
+ '表單已提交 · {count} 個欄位',
+ ],
+ 'Formatting unavailable: {reason}': [
+ 'Formatting unavailable: {reason}',
+ '格式化不可用:{reason}',
+ '格式化無法使用:{reason}',
+ ],
+ 'Full access': ['Full access', '完全访问', '完整存取'],
+ 'History changed its requested fence.': [
+ 'History changed its requested fence.',
+ '历史记录的请求边界已变化。',
+ '歷史記錄的請求邊界已變更。',
+ ],
+ 'History unavailable. Retrying automatically; your draft is kept.': [
+ 'History unavailable. Retrying automatically; your draft is kept.',
+ '历史记录暂不可用,正在自动重试;草稿已保留。',
+ '歷史記錄暫時無法使用,正在自動重試;草稿已保留。',
+ ],
+ 'Host observed a different session.': [
+ 'Host observed a different session.',
+ 'Host 读取了其他对话。',
+ 'Host 讀取了其他對話。',
+ ],
+ 'Host returned an unexpected send result.': [
+ 'Host returned an unexpected send result.',
+ 'Host 返回了意外的发送结果。',
+ 'Host 傳回了非預期的傳送結果。',
+ ],
+ 'Image preview': ['Image preview', '图片预览', '圖片預覽'],
+ 'Input recorded': ['Input recorded', '输入已记录', '輸入已記錄'],
+ 'Input tokens': ['Input tokens', '输入 Token', '輸入 Token'],
+ 'Interactive shell': ['Interactive shell', '交互式 Shell', '互動式 Shell'],
+ 'Invalid native file presentation.': [
+ 'Invalid native file presentation.',
+ '原生文件呈现无效。',
+ '原生檔案呈現無效。',
+ ],
+ 'Invalid native operation observation.': [
+ 'Invalid native operation observation.',
+ '原生操作记录无效。',
+ '原生操作記錄無效。',
+ ],
+ 'Invalid terminal receipt.': ['Invalid terminal receipt.', '终端回执无效。', '終端回執無效。'],
+ 'Jump to latest': ['Jump to latest', '跳到最新消息', '跳到最新訊息'],
+ 'Keep my edit': ['Keep my edit', '保留我的编辑', '保留我的編輯'],
+ 'Keep my queued message edit': [
+ 'Keep my queued message edit',
+ '保留我的队列消息编辑',
+ '保留我的佇列訊息編輯',
+ ],
+ 'Last alternate screen': ['Last alternate screen', '最后备用屏幕', '最後備用畫面'],
+ Link: ['Link', '链接', '連結'],
+ 'Live updates ended.': ['Live updates ended.', '实时更新已结束。', '即時更新已結束。'],
+ 'Loading files…': ['Loading files…', '正在加载文件…', '正在載入檔案…'],
+ 'Loading session history…': [
+ 'Loading session history…',
+ '正在加载对话历史…',
+ '正在載入對話歷史…',
+ ],
+ 'Loading session history… Your draft is kept.': [
+ 'Loading session history… Your draft is kept.',
+ '正在加载对话历史…草稿已保留。',
+ '正在載入對話歷史…草稿已保留。',
+ ],
+ 'Loading the saved draft…': [
+ 'Loading the saved draft…',
+ '正在加载已保存的草稿…',
+ '正在載入已儲存的草稿…',
+ ],
+ 'Main model usage': ['Main model usage', '主模型用量', '主模型用量'],
+ 'Make something remarkable.': ['Make something remarkable.', '创造不凡。', '創造不凡。'],
+ 'Markdown table': ['Markdown table', 'Markdown 表格', 'Markdown 表格'],
+ 'Match method': ['Match method', '匹配方式', '比對方式'],
+ 'Matching files': ['Matching files', '匹配的文件', '符合的檔案'],
+ 'Matching lines': ['Matching lines', '匹配的行', '符合的行'],
+ 'Message accepted.': ['Message accepted.', '消息已接收。', '訊息已接收。'],
+ 'Message attachments': ['Message attachments', '消息附件', '訊息附件'],
+ 'Message composer': ['Message composer', '消息输入区', '訊息輸入區'],
+ 'Message copied': ['Message copied', '消息已复制', '訊息已複製'],
+ 'Message no longer queued · your edit is kept': [
+ 'Message no longer queued · your edit is kept',
+ '消息已离开队列 · 编辑内容已保留',
+ '訊息已離開佇列 · 編輯內容已保留',
+ ],
+ 'Message or run a task…': [
+ 'Message or run a task…',
+ '发送消息,或开始一项任务…',
+ '傳送訊息,或開始一項任務…',
+ ],
+ 'Message queue': ['Message queue', '消息队列', '訊息佇列'],
+ 'Message queued.': ['Message queued.', '消息已加入队列。', '訊息已加入佇列。'],
+ 'Messages waiting for a turn': [
+ 'Messages waiting for a turn',
+ '等待处理的消息',
+ '等待處理的訊息',
+ ],
+ 'Missing provider counters or prices are unavailable. Auxiliary requests and tools are counted separately.':
+ [
+ 'Missing provider counters or prices are unavailable. Auxiliary requests and tools are counted separately.',
+ '提供方缺失的计数或价格显示为不可用。辅助请求和工具另行计费。',
+ '供應方缺少的計數或價格顯示為無法取得。輔助請求和工具另行計費。',
+ ],
+ 'Model selection rejected: {reason}': [
+ 'Model selection rejected: {reason}',
+ '模型选择被拒绝:{reason}',
+ '模型選擇遭拒:{reason}',
+ ],
+ 'Model unavailable: {model}.': [
+ 'Model unavailable: {model}.',
+ '模型不可用:{model}。',
+ '模型無法使用:{model}。',
+ ],
+ 'Model unavailable: {model}. Choose another model.': [
+ 'Model unavailable: {model}. Choose another model.',
+ '模型不可用:{model}。请选择其他模型。',
+ '模型無法使用:{model}。請選擇其他模型。',
+ ],
+ 'Network: all destinations': ['Network: all destinations', '网络:所有目标', '網路:所有目標'],
+ 'Network: listed destinations only': [
+ 'Network: listed destinations only',
+ '网络:仅列出的目标',
+ '網路:僅列出的目標',
+ ],
+ 'Network: no additional access': [
+ 'Network: no additional access',
+ '网络:无额外访问权限',
+ '網路:無額外存取權限',
+ ],
+ 'New terminal': msg_terminalNew,
+ 'No active item': ['No active item', '没有进行中的项目', '沒有進行中的項目'],
+ 'No matches.': ['No matches.', '没有匹配结果。', '沒有符合結果。'],
+ 'No model usage is recorded for this turn in this conversation. Copied and imported history does not add model charges.':
+ [
+ 'No model usage is recorded for this turn in this conversation. Copied and imported history does not add model charges.',
+ '此对话的本轮没有已记录的模型用量。复制或导入的历史不会增加模型费用。',
+ '此對話的本輪沒有已記錄的模型用量。複製或匯入的歷史不會增加模型費用。',
+ ],
+ 'No output was included in this original result.': [
+ 'No output was included in this original result.',
+ '此原始结果未包含输出。',
+ '此原始結果未包含輸出。',
+ ],
+ 'No results.': ['No results.', '没有结果。', '沒有結果。'],
+ 'No turn is running. This message remains queued.': [
+ 'No turn is running. This message remains queued.',
+ '当前没有正在运行的轮次,消息仍在队列中。',
+ '目前沒有正在執行的輪次,訊息仍在佇列中。',
+ ],
+ 'Not completed': ['Not completed', '未完成', '未完成'],
+ 'Observation stopped': ['Observation stopped', '更新已停止', '更新已停止'],
+ 'Older history did not advance. This turn could not be reached.': [
+ 'Older history did not advance. This turn could not be reached.',
+ '无法继续读取更早的历史,未能定位到本轮。',
+ '無法繼續讀取更早的歷史,未能定位到本輪。',
+ ],
+ 'Only {server} · {tool}': [
+ 'Only {server} · {tool}',
+ '仅 {server} · {tool}',
+ '僅 {server} · {tool}',
+ ],
+ 'Open attachment {name}': ['Open attachment {name}', '打开附件 {name}', '開啟附件 {name}'],
+ 'Open commands': msg_helpCommands,
+ 'Open recovered fork': ['Open recovered fork', '打开恢复的分支对话', '開啟恢復的分支對話'],
+ 'Open terminal {name}': ['Open terminal {name}', '打开终端 {name}', '開啟終端 {name}'],
+ 'Open {name}': ['Open {name}', '打开 {name}', '開啟 {name}'],
+ 'Original actions checked.': ['Original actions checked.', '已检查原操作。', '已檢查原操作。'],
+ 'Original arguments': ['Original arguments', '原始参数', '原始參數'],
+ 'Original result': ['Original result', '原始结果', '原始結果'],
+ 'Original tool source is unavailable in this history row.': [
+ 'Original tool source is unavailable in this history row.',
+ '此历史记录不包含原始工具数据。',
+ '此歷史記錄不包含原始工具資料。',
+ ],
+ 'Original upload needs confirmation': [
+ 'Original upload needs confirmation',
+ '原上传结果待确认',
+ '原上傳結果待確認',
+ ],
+ 'Other answer': ['Other answer', '其他答案', '其他答案'],
+ 'Other answer {number}': ['Other answer {number}', '其他答案 {number}', '其他答案 {number}'],
+ 'Outcome unknown': msg_diagnosticsOutcomeUnknown,
+ 'Output tokens': ['Output tokens', '输出 Token', '輸出 Token'],
+ 'Patch file': ['Patch file', '修改文件', '修改檔案'],
+ 'Pending request': ['Pending request', '待处理请求', '待處理請求'],
+ 'Permission boundary': ['Permission boundary', '权限边界', '權限邊界'],
+ 'Preparing at capture': ['Preparing at capture', '记录时正在准备', '記錄時正在準備'],
+ 'Preparing tool': ['Preparing tool', '正在准备工具', '正在準備工具'],
+ 'Preview {name}': ['Preview {name}', '预览 {name}', '預覽 {name}'],
+ 'Previous contents were not included in this original result.': [
+ 'Previous contents were not included in this original result.',
+ '此原始结果未包含原内容。',
+ '此原始結果未包含原內容。',
+ ],
+ Process: ['Process', '进程', '處理程序'],
+ 'Process group': ['Process group', '进程组', '處理程序群組'],
+ 'Process reference': msg_toolProcessRef,
+ Provider: msg_connectionProvider,
+ 'Queue message': ['Queue message', '加入消息队列', '加入訊息佇列'],
+ 'Queued message editor': ['Queued message editor', '队列消息编辑器', '佇列訊息編輯器'],
+ 'Queued messages': ['Queued messages', '队列消息', '佇列訊息'],
+ Read: msg_permissionRead,
+ 'Read file': msg_toolReadAction,
+ 'Read lines {first}–{last} / {total}': [
+ 'Read lines {first}–{last} / {total}',
+ '已读行 {first}–{last} / {total}',
+ '已讀行 {first}–{last} / {total}',
+ ],
+ 'Read lines · partial line · {total} total': [
+ 'Read lines · partial line · {total} total',
+ '已读行 · 不完整的行 · 共 {total} 行',
+ '已讀行 · 不完整的行 · 共 {total} 行',
+ ],
+ 'Read only': msg_permissionReadOnly,
+ 'Read process output': msg_toolProcessRead,
+ 'Read this turn’s independent activity panel': [
+ 'Read this turn’s independent activity panel',
+ '阅读本轮独立的活动面板',
+ '閱讀本輪獨立的活動面板',
+ ],
+ 'Read web page': ['Read web page', '读取网页', '讀取網頁'],
+ 'Read/write': ['Read/write', '读写', '讀寫'],
+ 'Reading original action receipts…': [
+ 'Reading original action receipts…',
+ '正在读取原操作回执…',
+ '正在讀取原操作回執…',
+ ],
+ 'Reading original message…': ['Reading original message…', '正在读取原消息…', '正在讀取原訊息…'],
+ 'Reading original receipts…': [
+ 'Reading original receipts…',
+ '正在读取原回执…',
+ '正在讀取原回執…',
+ ],
+ 'Reading original receipts… New typing is kept.': [
+ 'Reading original receipts… New typing is kept.',
+ '正在读取原回执…新输入的内容已保留。',
+ '正在讀取原回執…新輸入的內容已保留。',
+ ],
+ 'Reading original usage… {count} records checked': [
+ 'Reading original usage… {count} records checked',
+ '正在读取原始用量…已检查 {count} 条记录',
+ '正在讀取原始用量…已檢查 {count} 筆記錄',
+ ],
+ 'Reading selected file…': ['Reading selected file…', '正在读取所选文件…', '正在讀取所選檔案…'],
+ 'Reading terminals…': ['Reading terminals…', '正在读取终端…', '正在讀取終端…'],
+ 'Reading the original request…': [
+ 'Reading the original request…',
+ '正在读取原始请求…',
+ '正在讀取原始請求…',
+ ],
+ 'Reading…': ['Reading…', '正在读取…', '正在讀取…'],
+ 'Reasoning tokens': ['Reasoning tokens', '推理 Token', '推理 Token'],
+ 'Recheck original actions': ['Recheck original actions', '重新检查原操作', '重新檢查原操作'],
+ 'Reconnect updates': ['Reconnect updates', '重新连接更新', '重新連線更新'],
+ 'Reconnecting updates… Your draft is kept.': [
+ 'Reconnecting updates… Your draft is kept.',
+ '正在重新连接更新…草稿已保留。',
+ '正在重新連線更新…草稿已保留。',
+ ],
+ Recorded: ['Recorded', '已记录', '已記錄'],
+ 'Recorded command': ['Recorded command', '已记录的命令', '已記錄的命令'],
+ 'Recorded cost': ['Recorded cost', '已记录费用', '已記錄費用'],
+ 'Recorded edit': ['Recorded edit', '已记录的编辑', '已記錄的編輯'],
+ 'Recorded lines {first}–{last}': [
+ 'Recorded lines {first}–{last}',
+ '已记录行 {first}–{last}',
+ '已記錄行 {first}–{last}',
+ ],
+ 'Recorded page content': ['Recorded page content', '已记录的页面内容', '已記錄的頁面內容'],
+ 'Recorded patch result · Operation completed. The patch below is the original request.': [
+ 'Recorded patch result · Operation completed. The patch below is the original request.',
+ '已记录补丁结果 · 操作完成。下方为原始请求的补丁。',
+ '已記錄修補結果 · 操作完成。下方為原始請求的修補。',
+ ],
+ 'Recorded previous contents': ['Recorded previous contents', '已记录的原内容', '已記錄的原內容'],
+ 'Recorded write': ['Recorded write', '已记录的写入', '已記錄的寫入'],
+ 'Recording answer…': ['Recording answer…', '正在记录答案…', '正在記錄答案…'],
+ 'Recover original': ['Recover original', '恢复原操作', '恢復原操作'],
+ 'Recover original actions ({count})': [
+ 'Recover original actions ({count})',
+ '恢复原操作({count})',
+ '恢復原操作({count})',
+ ],
+ 'Recover original send receipt': [
+ 'Recover original send receipt',
+ '恢复原发送回执',
+ '恢復原傳送回執',
+ ],
+ 'Recover original upload': ['Recover original upload', '恢复原上传', '恢復原上傳'],
+ 'Recover the original upload receipt before sending.': [
+ 'Recover the original upload receipt before sending.',
+ '发送前请先恢复原上传回执。',
+ '傳送前請先恢復原上傳回執。',
+ ],
+ 'Refresh status': ['Refresh status', '刷新状态', '重新整理狀態'],
+ 'Refresh status · Ctrl+R': [
+ 'Refresh status · Ctrl+R',
+ '刷新状态 · Ctrl+R',
+ '重新整理狀態 · Ctrl+R',
+ ],
+ 'Refresh terminals': ['Refresh terminals', '刷新终端', '重新整理終端'],
+ 'Refresh workspace files': ['Refresh workspace files', '刷新工作区文件', '重新整理工作區檔案'],
+ Rejected: ['Rejected', '已拒绝', '已拒絕'],
+ Remove: ['Remove', '移除', '移除'],
+ 'Remove attachment {name}': ['Remove attachment {name}', '移除附件 {name}', '移除附件 {name}'],
+ 'Remove failed attachment {name}': [
+ 'Remove failed attachment {name}',
+ '移除失败的附件 {name}',
+ '移除失敗的附件 {name}',
+ ],
+ 'Remove message': ['Remove message', '移除消息', '移除訊息'],
+ 'Remove queued message {number}': [
+ 'Remove queued message {number}',
+ '移除队列消息 {number}',
+ '移除佇列訊息 {number}',
+ ],
+ 'Remove the failed attachment or choose it again.': [
+ 'Remove the failed attachment or choose it again.',
+ '请移除失败的附件,或重新选择。',
+ '請移除失敗的附件,或重新選擇。',
+ ],
+ 'Remove {name}': ['Remove {name}', '移除 {name}', '移除 {name}'],
+ 'Request closed · {reason}': [
+ 'Request closed · {reason}',
+ '请求已关闭 · {reason}',
+ '請求已關閉 · {reason}',
+ ],
+ 'Request content': ['Request content', '请求内容', '請求內容'],
+ 'Request details': ['Request details', '请求详情', '請求詳情'],
+ 'Request not available.': ['Request not available.', '请求暂不可用。', '請求暫時無法使用。'],
+ 'Requested URL': ['Requested URL', '请求的网址', '請求的網址'],
+ 'Requested access': msg_permissionRequested,
+ 'Requested client capability': [
+ 'Requested client capability',
+ '请求的客户端能力',
+ '請求的用戶端能力',
+ ],
+ 'Requested command': ['Requested command', '请求的命令', '請求的命令'],
+ 'Requested file contents': ['Requested file contents', '请求的文件内容', '請求的檔案內容'],
+ 'Requested operation': ['Requested operation', '请求的操作', '請求的操作'],
+ 'Requested patch': msg_toolPatch,
+ 'Requested removal': ['Requested removal', '请求删除的内容', '請求刪除的內容'],
+ 'Requested replacement': ['Requested replacement', '请求替换的内容', '請求取代的內容'],
+ 'Restoring attachment drafts…': [
+ 'Restoring attachment drafts…',
+ '正在恢复附件草稿…',
+ '正在恢復附件草稿…',
+ ],
+ 'Resume a conversation': ['Resume a conversation', '继续对话', '繼續對話'],
+ 'Retry attachment metadata': ['Retry attachment metadata', '重试附件信息', '重試附件資訊'],
+ 'Retry attachment updates': ['Retry attachment updates', '重试附件更新', '重試附件更新'],
+ 'Retry loading conversation': ['Retry loading conversation', '重新加载对话', '重新載入對話'],
+ 'Retry original arguments': ['Retry original arguments', '重新读取原始参数', '重新讀取原始參數'],
+ 'Retry original source': ['Retry original source', '重新读取原始数据', '重新讀取原始資料'],
+ 'Retry original turn usage': [
+ 'Retry original turn usage',
+ '重新读取本轮原始用量',
+ '重新讀取本輪原始用量',
+ ],
+ 'Retry workspace files': ['Retry workspace files', '重新读取工作区文件', '重新讀取工作區檔案'],
+ 'Return to the newest messages': [
+ 'Return to the newest messages',
+ '返回最新消息',
+ '返回最新訊息',
+ ],
+ 'Review additional access': ['Review additional access', '审核额外访问权限', '審核額外存取權限'],
+ 'Review current message': ['Review current message', '检查当前消息', '檢查目前訊息'],
+ 'Review current queued message': [
+ 'Review current queued message',
+ '检查当前队列消息',
+ '檢查目前佇列訊息',
+ ],
+ 'Review request': msg_reviewRequest,
+ 'Review tool call': ['Review tool call', '审核工具调用', '審核工具呼叫'],
+ 'Run command': msg_terminalCommand,
+ 'Running at capture': ['Running at capture', '记录时正在运行', '記錄時正在執行'],
+ 'Running tool': ['Running tool', '正在运行工具', '正在執行工具'],
+ 'Save queued message': ['Save queued message', '保存队列消息', '儲存佇列訊息'],
+ 'Saving draft before switching…': [
+ 'Saving draft before switching…',
+ '切换前正在保存草稿…',
+ '切換前正在儲存草稿…',
+ ],
+ Scope: ['Scope', '范围', '範圍'],
+ 'Scroll the whole conversation': [
+ 'Scroll the whole conversation',
+ '滚动整个对话',
+ '捲動整個對話',
+ ],
+ 'Search commands': ['Search commands', '搜索命令', '搜尋命令'],
+ 'Search commands · Ctrl+P': [
+ 'Search commands · Ctrl+P',
+ '搜索命令 · Ctrl+P',
+ '搜尋命令 · Ctrl+P',
+ ],
+ 'Search commands…': ['Search commands…', '搜索命令…', '搜尋命令…'],
+ 'Search files': ['Search files', '搜索文件', '搜尋檔案'],
+ 'Search notice': ['Search notice', '搜索提示', '搜尋提示'],
+ 'Search pattern': ['Search pattern', '搜索模式', '搜尋模式'],
+ 'Search the web': ['Search the web', '搜索网页', '搜尋網頁'],
+ 'Select a conversation to browse its workspace.': [
+ 'Select a conversation to browse its workspace.',
+ '请选择对话以浏览工作区。',
+ '請選擇對話以瀏覽工作區。',
+ ],
+ 'Send message': msg_helpSend,
+ 'Send process input': ['Send process input', '发送进程输入', '傳送處理程序輸入'],
+ 'Send rejected: {reason}': [
+ 'Send rejected: {reason}',
+ '发送被拒绝:{reason}',
+ '傳送遭拒:{reason}',
+ ],
+ 'Sending to current turn · {preview}': [
+ 'Sending to current turn · {preview}',
+ '正在发送到本轮 · {preview}',
+ '正在傳送到本輪 · {preview}',
+ ],
+ 'Sending waits for the original receipt; it will not be replayed.': [
+ 'Sending waits for the original receipt; it will not be replayed.',
+ '正在等待原发送回执,不会重复发送。',
+ '正在等待原傳送回執,不會重複傳送。',
+ ],
+ Server: ['Server', '服务器', '伺服器'],
+ Session: ['Session', '对话', '對話'],
+ Sessions: ['Sessions', '对话', '對話'],
+ 'Show conversation terminals': ['Show conversation terminals', '显示对话终端', '顯示對話終端'],
+ 'Show formatted message': ['Show formatted message', '显示格式化消息', '顯示格式化訊息'],
+ 'Show formatted thinking': ['Show formatted thinking', '显示格式化思考', '顯示格式化思考'],
+ 'Show message source': ['Show message source', '显示消息原文', '顯示訊息原文'],
+ 'Show original columns': ['Show original columns', '显示原始列', '顯示原始欄'],
+ 'Show original tool data': ['Show original tool data', '显示原始工具数据', '顯示原始工具資料'],
+ 'Show readable tool details': ['Show readable tool details', '显示工具详情', '顯示工具詳情'],
+ 'Show thinking source': ['Show thinking source', '显示思考原文', '顯示思考原文'],
+ Skip: msg_scheduleSkip,
+ 'Start a conversation in another folder': [
+ 'Start a conversation in another folder',
+ '在其他文件夹中开始对话',
+ '在其他資料夾中開始對話',
+ ],
+ 'Start a shell in this conversation’s workspace. Agent terminals also appear here.': [
+ 'Start a shell in this conversation’s workspace. Agent terminals also appear here.',
+ '在此对话的工作区中启动 Shell。Agent 的终端也会显示在这里。',
+ '在此對話的工作區中啟動 Shell。Agent 的終端也會顯示在這裡。',
+ ],
+ 'Start interactive terminal': ['Start interactive terminal', '启动交互终端', '啟動互動終端'],
+ 'Starting shell…': ['Starting shell…', '正在启动 Shell…', '正在啟動 Shell…'],
+ 'Steer now · use in the current turn': [
+ 'Steer now · use in the current turn',
+ '立即引导 · 用于当前轮次',
+ '立即引導 · 用於目前輪次',
+ ],
+ 'Steer queued message {number} now': [
+ 'Steer queued message {number} now',
+ '立即用队列消息 {number} 引导',
+ '立即用佇列訊息 {number} 引導',
+ ],
+ Stop: msg_stop,
+ 'Stop response': ['Stop response', '停止回复', '停止回覆'],
+ 'Stop response · Esc': ['Stop response · Esc', '停止回复 · Esc', '停止回覆 · Esc'],
+ Stopped: msg_graphStatus_stopped,
+ 'Submit answers': msg_questionSubmit,
+ 'Submit answers · Ctrl+Enter': [
+ 'Submit answers · Ctrl+Enter',
+ '提交答案 · Ctrl+Enter',
+ '提交答案 · Ctrl+Enter',
+ ],
+ 'Submit form': msg_formSubmit,
+ 'Submit form · Ctrl+Enter': [
+ 'Submit form · Ctrl+Enter',
+ '提交表单 · Ctrl+Enter',
+ '提交表單 · Ctrl+Enter',
+ ],
+ 'Symbolic link': ['Symbolic link', '符号链接', '符號連結'],
+ 'Terminal result is unknown. Recover the original action before trying again.': [
+ 'Terminal result is unknown. Recover the original action before trying again.',
+ '终端结果未知,请先恢复原操作再重试。',
+ '終端結果未知,請先恢復原操作再重試。',
+ ],
+ 'Terminal result was recorded, but its original call did not finish cleanup: {reason}. Do not repeat this action.':
+ [
+ 'Terminal result was recorded, but its original call did not finish cleanup: {reason}. Do not repeat this action.',
+ '终端结果已记录,但原调用尚未完成清理:{reason}。请勿重复此操作。',
+ '終端結果已記錄,但原呼叫尚未完成清理:{reason}。請勿重複此操作。',
+ ],
+ 'Terminal screen': ['Terminal screen', '终端屏幕', '終端畫面'],
+ 'Terminal scrollback': ['Terminal scrollback', '终端历史输出', '終端歷史輸出'],
+ 'The Host did not accept this queue change.': [
+ 'The Host did not accept this queue change.',
+ 'Host 未接受此次队列更改。',
+ 'Host 未接受此次佇列變更。',
+ ],
+ 'The Host observed a different conversation.': [
+ 'The Host observed a different conversation.',
+ 'Host 读取了其他对话。',
+ 'Host 讀取了其他對話。',
+ ],
+ 'The Host redacted parts of this recorded output.': [
+ 'The Host redacted parts of this recorded output.',
+ 'Host 已隐藏部分已记录的输出。',
+ 'Host 已隱藏部分已記錄的輸出。',
+ ],
+ 'The Host returned a different or incomplete original request outcome.': [
+ 'The Host returned a different or incomplete original request outcome.',
+ 'Host 返回的原请求结果不匹配或不完整。',
+ 'Host 傳回的原請求結果不符或不完整。',
+ ],
+ 'The Host returned a different request.': [
+ 'The Host returned a different request.',
+ 'Host 返回了其他请求。',
+ 'Host 傳回了其他請求。',
+ ],
+ 'The Host returned inconsistent model usage.': [
+ 'The Host returned inconsistent model usage.',
+ 'Host 返回的模型用量不一致。',
+ 'Host 傳回的模型用量不一致。',
+ ],
+ 'The conversation was deleted, but its latest draft is not saved. Return to the draft to check its status.':
+ [
+ 'The conversation was deleted, but its latest draft is not saved. Return to the draft to check its status.',
+ '对话已删除,但最新草稿尚未保存。请返回草稿检查状态。',
+ '對話已刪除,但最新草稿尚未儲存。請返回草稿檢查狀態。',
+ ],
+ 'The draft changed before confirmation. Send again to confirm it.': [
+ 'The draft changed before confirmation. Send again to confirm it.',
+ '确认前草稿已变化,请重新发送以确认。',
+ '確認前草稿已變更,請重新傳送以確認。',
+ ],
+ 'The draft could not be saved. Sending is unavailable.': [
+ 'The draft could not be saved. Sending is unavailable.',
+ '草稿无法保存,暂时无法发送。',
+ '草稿無法儲存,暫時無法傳送。',
+ ],
+ 'The draft could not be saved; it has not been sent.': [
+ 'The draft could not be saved; it has not been sent.',
+ '草稿无法保存,尚未发送。',
+ '草稿無法儲存,尚未傳送。',
+ ],
+ 'The draft is not ready to save. Return to the conversation before deleting it.': [
+ 'The draft is not ready to save. Return to the conversation before deleting it.',
+ '草稿尚未准备好保存。请先返回对话,再删除。',
+ '草稿尚未準備好儲存。請先返回對話,再刪除。',
+ ],
+ 'The extracted page content is truncated.': [
+ 'The extracted page content is truncated.',
+ '提取的页面内容已截断。',
+ '擷取的頁面內容已截斷。',
+ ],
+ 'The model changed before confirmation. Send again to confirm it.': [
+ 'The model changed before confirmation. Send again to confirm it.',
+ '确认前模型已变化,请重新发送以确认。',
+ '確認前模型已變更,請重新傳送以確認。',
+ ],
+ 'The original attachment metadata could not be verified.': [
+ 'The original attachment metadata could not be verified.',
+ '无法验证原附件信息。',
+ '無法驗證原附件資訊。',
+ ],
+ 'The original call arguments could not be verified. Allow remains disabled.': [
+ 'The original call arguments could not be verified. Allow remains disabled.',
+ '无法验证原调用参数,暂时无法授权。',
+ '無法驗證原呼叫參數,暫時無法授權。',
+ ],
+ 'The original message source could not be verified.': [
+ 'The original message source could not be verified.',
+ '无法验证原消息数据。',
+ '無法驗證原訊息資料。',
+ ],
+ 'The original send has no confirmed result. Recover its receipt before sending again.': [
+ 'The original send has no confirmed result. Recover its receipt before sending again.',
+ '原发送结果尚未确认,请先恢复回执再发送。',
+ '原傳送結果尚未確認,請先恢復回執再傳送。',
+ ],
+ 'The original tool source could not be verified.': [
+ 'The original tool source could not be verified.',
+ '无法验证原始工具数据。',
+ '無法驗證原始工具資料。',
+ ],
+ 'The original turn usage page could not be verified.': [
+ 'The original turn usage page could not be verified.',
+ '无法验证本轮原始用量分页。',
+ '無法驗證本輪原始用量分頁。',
+ ],
+ 'The queue changed again. Review its current message before saving.': [
+ 'The queue changed again. Review its current message before saving.',
+ '队列再次变化,请先检查当前消息再保存。',
+ '佇列再次變更,請先檢查目前訊息再儲存。',
+ ],
+ 'The queue changed before this action. Select the current row again.': [
+ 'The queue changed before this action. Select the current row again.',
+ '操作前队列已变化,请重新选择当前消息。',
+ '操作前佇列已變更,請重新選擇目前訊息。',
+ ],
+ 'The queue changed. Select Edit or Review again for its current message.': [
+ 'The queue changed. Select Edit or Review again for its current message.',
+ '队列已变化,请重新编辑或检查当前消息。',
+ '佇列已變更,請重新編輯或檢查目前訊息。',
+ ],
+ 'The queue changed. Select the current message again.': [
+ 'The queue changed. Select the current message again.',
+ '队列已变化,请重新选择当前消息。',
+ '佇列已變更,請重新選擇目前訊息。',
+ ],
+ 'The queue changed. Your edit is kept; review the current message.': [
+ 'The queue changed. Your edit is kept; review the current message.',
+ '队列已变化。编辑内容已保留,请检查当前消息。',
+ '佇列已變更。編輯內容已保留,請檢查目前訊息。',
+ ],
+ 'The recorded process output is truncated.': [
+ 'The recorded process output is truncated.',
+ '已记录的进程输出已截断。',
+ '已記錄的處理程序輸出已截斷。',
+ ],
+ 'The recorded terminal output is truncated.': [
+ 'The recorded terminal output is truncated.',
+ '已记录的终端输出已截断。',
+ '已記錄的終端輸出已截斷。',
+ ],
+ 'The search result is truncated · {count} omitted results.': [
+ 'The search result is truncated · {count} omitted results.',
+ '搜索结果已截断 · 已省略 {count} 个结果。',
+ '搜尋結果已截斷 · 已省略 {count} 個結果。',
+ ],
+ 'The tool read a range of this file; this card preserves that original range.': [
+ 'The tool read a range of this file; this card preserves that original range.',
+ '工具读取了此文件的一部分,本卡片保留该原始范围。',
+ '工具讀取了此檔案的一部分,本卡片保留該原始範圍。',
+ ],
+ 'The tool reported an incomplete search result.': [
+ 'The tool reported an incomplete search result.',
+ '工具返回的搜索结果不完整。',
+ '工具傳回的搜尋結果不完整。',
+ ],
+ Thinking: ['Thinking', '思考', '思考'],
+ 'This Host cannot answer the requested capability. The original request remains pending.': [
+ 'This Host cannot answer the requested capability. The original request remains pending.',
+ '当前 Host 无法处理此能力请求,原请求仍待处理。',
+ '目前 Host 無法處理此能力請求,原請求仍待處理。',
+ ],
+ 'This action has an unresolved original. Recover it below before trying again.': [
+ 'This action has an unresolved original. Recover it below before trying again.',
+ '此操作的原结果尚未确认,请先在下方恢复。',
+ '此操作的原結果尚未確認,請先在下方恢復。',
+ ],
+ "This allows the requesting tool to inspect and control the local computer, including screenshots, keyboard and pointer actions. The grant lasts for this session's current permission boundary.":
+ [
+ "This allows the requesting tool to inspect and control the local computer, including screenshots, keyboard and pointer actions. The grant lasts for this session's current permission boundary.",
+ '允许请求方工具查看和控制本机,包括截图、键盘和指针操作。授权持续至本次对话当前权限边界结束。',
+ '允許請求方工具檢視和控制本機,包括截圖、鍵盤和指標操作。授權持續至本次對話目前權限邊界結束。',
+ ],
+ 'This call': ['This call', '本次调用', '本次呼叫'],
+ 'This conversation action is no longer current.': [
+ 'This conversation action is no longer current.',
+ '此对话操作已过期。',
+ '此對話操作已過期。',
+ ],
+ 'This conversation is archived. Restore it in Sessions to send; your draft is kept.': [
+ 'This conversation is archived. Restore it in Sessions to send; your draft is kept.',
+ '此对话已归档,请在对话列表中恢复后发送;草稿已保留。',
+ '此對話已封存,請在對話清單中還原後傳送;草稿已保留。',
+ ],
+ 'This conversation is closed.': [
+ 'This conversation is closed.',
+ '此对话已关闭。',
+ '此對話已關閉。',
+ ],
+ 'This directory is empty.': ['This directory is empty.', '此目录为空。', '此目錄為空。'],
+ 'This field is required.': ['This field is required.', '此项为必填。', '此項為必填。'],
+ 'This message is no longer queued.': [
+ 'This message is no longer queued.',
+ '此消息已离开队列。',
+ '此訊息已離開佇列。',
+ ],
+ 'This message is no longer queued. Your edit is kept.': [
+ 'This message is no longer queued. Your edit is kept.',
+ '此消息已离开队列,编辑内容已保留。',
+ '此訊息已離開佇列,編輯內容已保留。',
+ ],
+ 'This message was not sent. Your draft is kept; check the status below before trying again.': [
+ 'This message was not sent. Your draft is kept; check the status below before trying again.',
+ '消息尚未发送。草稿已保留,请先检查下方状态再重试。',
+ '訊息尚未傳送。草稿已保留,請先檢查下方狀態再重試。',
+ ],
+ 'This model selection is no longer open.': [
+ 'This model selection is no longer open.',
+ '此模型选择已关闭。',
+ '此模型選擇已關閉。',
+ ],
+ 'This original result contains part of a line.': [
+ 'This original result contains part of a line.',
+ '此原始结果包含不完整的行。',
+ '此原始結果包含不完整的行。',
+ ],
+ 'This request is no longer listed as pending. Refresh to read its canonical outcome.': [
+ 'This request is no longer listed as pending. Refresh to read its canonical outcome.',
+ '此请求已不在待处理列表中,请刷新查看已记录的结果。',
+ '此請求已不在待處理清單中,請重新整理查看已記錄的結果。',
+ ],
+ 'This session': ['This session', '本次对话', '本次對話'],
+ 'This turn': msg_permissionTurn,
+ 'This turn cannot accept this message right now. It remains queued.': [
+ 'This turn cannot accept this message right now. It remains queued.',
+ '本轮暂时无法接收此消息,消息仍在队列中。',
+ '本輪暫時無法接收此訊息,訊息仍在佇列中。',
+ ],
+ 'This turn has no visible messages in the current reading mode.': [
+ 'This turn has no visible messages in the current reading mode.',
+ '当前视图中,本轮没有可见消息。',
+ '目前檢視中,本輪沒有可見訊息。',
+ ],
+ 'This upload is prepared. Recover its original receipt.': [
+ 'This upload is prepared. Recover its original receipt.',
+ '此上传已准备好,请恢复原回执。',
+ '此上傳已準備好,請恢復原回執。',
+ ],
+ 'Timed out': msg_diagnosticsTimedOut,
+ Todo: ['Todo', '任务', '待辦'],
+ 'Toggle conversation todo': [
+ 'Toggle conversation todo',
+ '展开或收起任务清单',
+ '展開或收合待辦清單',
+ ],
+ 'Toggle original tool details': [
+ 'Toggle original tool details',
+ '切换原始工具详情',
+ '切換原始工具詳情',
+ ],
+ 'Toggle tool {name}': ['Toggle tool {name}', '展开或收起工具 {name}', '展開或收合工具 {name}'],
+ Tool: msg_findFieldTool,
+ 'Tool call allowed': ['Tool call allowed', '已允许工具调用', '已允許工具呼叫'],
+ 'Tool call denied': ['Tool call denied', '已拒绝工具调用', '已拒絕工具呼叫'],
+ 'Tool details': ['Tool details', '工具详情', '工具詳情'],
+ 'Tool failed': ['Tool failed', '工具失败', '工具失敗'],
+ Trajectory: ['Trajectory', '执行过程', '執行過程'],
+ 'Turn activity': ['Turn activity', '轮次活动', '輪次活動'],
+ 'Turn landmarks changed their requested fence.': [
+ 'Turn landmarks changed their requested fence.',
+ '轮次定位的请求边界已变化。',
+ '輪次定位的請求邊界已變更。',
+ ],
+ 'Turn work details': ['Turn work details', '轮次工作详情', '輪次工作詳情'],
+ 'Unconfirmed attempt abandoned. The draft is kept; press Enter explicitly to send a new attempt.':
+ [
+ 'Unconfirmed attempt abandoned. The draft is kept; press Enter explicitly to send a new attempt.',
+ '已放弃未确认的尝试。草稿已保留,请按 Enter 发起新的发送。',
+ '已放棄未確認的嘗試。草稿已保留,請按 Enter 發起新的傳送。',
+ ],
+ Unknown: ['Unknown', '未知', '未知'],
+ 'Unresolved {method} · input has not been repeated.': [
+ 'Unresolved {method} · input has not been repeated.',
+ '{method} 结果尚未确认 · 未重复发送输入。',
+ '{method} 結果尚未確認 · 未重複傳送輸入。',
+ ],
+ Unsupported: ['Unsupported', '不支持', '不支援'],
+ 'Updates disconnected. Reconnecting automatically; your draft is kept.': [
+ 'Updates disconnected. Reconnecting automatically; your draft is kept.',
+ '更新连接已断开,正在自动重连;草稿已保留。',
+ '更新連線已中斷,正在自動重新連線;草稿已保留。',
+ ],
+ 'Updating files…': ['Updating files…', '正在更新文件…', '正在更新檔案…'],
+ 'Updating queue…': ['Updating queue…', '正在更新队列…', '正在更新佇列…'],
+ 'Upload failed': ['Upload failed', '上传失败', '上傳失敗'],
+ 'Upload rejected: {reason}': [
+ 'Upload rejected: {reason}',
+ '上传被拒绝:{reason}',
+ '上傳遭拒:{reason}',
+ ],
+ 'Uploading attachments… Your typing is kept.': [
+ 'Uploading attachments… Your typing is kept.',
+ '正在上传附件…输入的内容已保留。',
+ '正在上傳附件…輸入的內容已保留。',
+ ],
+ 'Use a number from {minimum} to {maximum}.': [
+ 'Use a number from {minimum} to {maximum}.',
+ '请输入 {minimum} 到 {maximum} 之间的数值。',
+ '請輸入 {minimum} 到 {maximum} 之間的數值。',
+ ],
+ 'Use at least {count} characters.': [
+ 'Use at least {count} characters.',
+ '请至少输入 {count} 个字符。',
+ '請至少輸入 {count} 個字元。',
+ ],
+ 'Use at most {count} characters.': [
+ 'Use at most {count} characters.',
+ '请最多输入 {count} 个字符。',
+ '請最多輸入 {count} 個字元。',
+ ],
+ 'Use current message': ['Use current message', '使用当前消息', '使用目前訊息'],
+ 'Use current queued message': [
+ 'Use current queued message',
+ '使用当前队列消息',
+ '使用目前佇列訊息',
+ ],
+ 'User message copied': ['User message copied', '用户消息已复制', '使用者訊息已複製'],
+ "View this turn's model usage": [
+ "View this turn's model usage",
+ '查看本轮模型用量',
+ '查看本輪模型用量',
+ ],
+ 'Wait for the saved draft before switching.': [
+ 'Wait for the saved draft before switching.',
+ '请等待草稿保存后再切换。',
+ '請等待草稿儲存後再切換。',
+ ],
+ 'Waiting for the next step': ['Waiting for the next step', '等待下一步', '等待下一步'],
+ 'Waiting for your response': ['Waiting for your response', '等待你的回复', '等待你的回覆'],
+ 'Web notice': ['Web notice', '网页提示', '網頁提示'],
+ 'Web page': ['Web page', '网页', '網頁'],
+ 'Web query': ['Web query', '网页查询', '網頁查詢'],
+ 'Web result': ['Web result', '网页结果', '網頁結果'],
+ 'Web results': ['Web results', '网页结果', '網頁結果'],
+ 'Working directory': msg_toolProcessCwd,
+ 'Working directory: {path}': [
+ 'Working directory: {path}',
+ '工作目录:{path}',
+ '工作目錄:{path}',
+ ],
+ 'Working…': msg_working,
+ 'Workspace files': ['Workspace files', '工作区文件', '工作區檔案'],
+ 'Workspace write': msg_sandboxWorkspace,
+ 'Wrap code': ['Wrap code', '代码自动换行', '程式碼自動換行'],
+ 'Wrap long lines': ['Wrap long lines', '长行自动换行', '長行自動換行'],
+ 'Write file': msg_toolWriteAction,
+ 'Writing response': ['Writing response', '正在撰写回复', '正在撰寫回覆'],
+ 'Your answer': ['Your answer', '你的答案', '你的答案'],
+ '[Snippet truncated by the Host]': [
+ '[Snippet truncated by the Host]',
+ '[摘要已被 Host 截断]',
+ '[摘要已被 Host 截斷]',
+ ],
+ bytes: msg_toolByteRange,
+ 'path and descendants': ['path and descendants', '此路径及其子路径', '此路徑及其子路徑'],
+ 'this exact path': ['this exact path', '此精确路径', '此精確路徑'],
+ unavailable: ['unavailable', '不可用', '無法取得'],
+ '{count} bytes': ['{count} bytes', '{count} 字节', '{count} 位元組'],
+ '{count} main model request in this turn': [
+ '{count} main model request in this turn',
+ '本轮有 {count} 个主模型请求',
+ '本輪有 {count} 個主模型請求',
+ ],
+ '{count} main model requests in this turn': [
+ '{count} main model requests in this turn',
+ '本轮有 {count} 个主模型请求',
+ '本輪有 {count} 個主模型請求',
+ ],
+ '{count} queued messages': [
+ '{count} queued messages',
+ '{count} 条队列消息',
+ '{count} 則佇列訊息',
+ ],
+ '{count} replacements': ['{count} replacements', '{count} 处替换', '{count} 處取代'],
+ '{count} thought': ['{count} thought', '{count} 次思考', '{count} 次思考'],
+ '{count} thoughts': ['{count} thoughts', '{count} 次思考', '{count} 次思考'],
+ '{count} tool': ['{count} tool', '{count} 个工具', '{count} 個工具'],
+ '{count} tools': ['{count} tools', '{count} 个工具', '{count} 個工具'],
+ '{loaded} of {total} argument bytes loaded. Read all before allowing.': [
+ '{loaded} of {total} argument bytes loaded. Read all before allowing.',
+ '已加载 {loaded} / {total} 字节参数。授权前请阅读全部内容。',
+ '已載入 {loaded} / {total} 位元組參數。授權前請閱讀全部內容。',
+ ],
+ '{observation}; original observation cleanup remains unconfirmed: {reason}': [
+ '{observation}; original observation cleanup remains unconfirmed: {reason}',
+ '{observation};原更新订阅的清理尚未确认:{reason}',
+ '{observation};原更新訂閱的清理尚未確認:{reason}',
+ ],
+ '{speed} tok/s (last request)': [
+ '{speed} tok/s (last request)',
+ '{speed} Token/秒(上次请求)',
+ '{speed} Token/秒(上次請求)',
+ ],
+ '{tool} · Permission boundary: {revision}': [
+ '{tool} · Permission boundary: {revision}',
+ '{tool} · 权限边界:{revision}',
+ '{tool} · 權限邊界:{revision}',
+ ],
+ 'Attachment path': ['Attachment path', '附件路径', '附件路徑'],
+ 'Absolute file path\u2026': ['Absolute file path…', '绝对文件路径…', '絕對檔案路徑…'],
+ 'Upload selected file': ['Upload selected file', '上传所选文件', '上傳所選檔案'],
+ 'Uploading\u2026': ['Uploading…', '正在上传…', '正在上傳…'],
+ 'Selected file': ['Selected file', '所选文件', '所選檔案'],
+ '(empty)': ['(empty)', '(空)', '(空)'],
+ '{message} {detail}': ['{message} {detail}', '{message} {detail}', '{message} {detail}'],
+} as const satisfies Catalog;
diff --git a/products/maka/terminal-shell/app/locale-settings.ts b/products/maka/terminal-shell/app/locale-settings.ts
new file mode 100644
index 0000000000..9073645883
--- /dev/null
+++ b/products/maka/terminal-shell/app/locale-settings.ts
@@ -0,0 +1,869 @@
+/*
+ * 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 { Catalog } from '../../../../plugins/shared/locale.js';
+import {
+ msg_back,
+ msg_chooseModel,
+ msg_connectionRemoved,
+ msg_connectionSlug,
+ msg_copyLoginCode,
+ msg_createConnection,
+ msg_defaultCleared,
+ msg_defaultSaved,
+ msg_deploymentTitle,
+ msg_formNo,
+ msg_formRequired,
+ msg_formYes,
+ msg_historyModel,
+ msg_loginCancelled,
+ msg_loginStarting,
+ msg_modelSettings,
+ msg_permissionNetwork,
+ msg_refreshConnections,
+ msg_saveConnection,
+ msg_settingOff,
+ msg_settingOn,
+ msg_settingUnset,
+ msg_skillsDisabled,
+ msg_skillsEnabled,
+} from '../../../../plugins/shared/locale-messages.js';
+import {
+ tui_assistant_saved,
+ tui_insights_confirm,
+ tui_model_profile_name,
+ tui_network_auth,
+ tui_network_host,
+ tui_network_replace,
+ tui_session_saving,
+ tui_settings_language,
+ tui_subagents_choose_model,
+ tui_theme_role_number,
+ tui_thinking_default,
+} from '../../../../plugins/shared/tui-messages.js';
+
+export const settingsMessages = {
+ 'A provider credential is stored. Leave blank to keep it; enter a key to replace it.': [
+ 'A provider credential is stored. Leave blank to keep it; enter a key to replace it.',
+ '已保存提供商凭据。留空可保留,输入密钥可替换。',
+ '已儲存供應商憑據。留空可保留,輸入金鑰可取代。',
+ ],
+ 'A provider credential is stored. Sign in again to replace it.': [
+ 'A provider credential is stored. Sign in again to replace it.',
+ '已保存提供商凭据。重新登录可替换凭据。',
+ '已儲存供應商憑據。重新登入可取代憑據。',
+ ],
+ 'A proxy password is stored. Its value is never read back.': [
+ 'A proxy password is stored. Its value is never read back.',
+ '已存储代理密码,且不会读取其值。',
+ '已儲存代理密碼,且不會讀取其值。',
+ ],
+ 'A unique lowercase name, such as openrouter-work. It stays fixed after creation.': [
+ 'A unique lowercase name, such as openrouter-work. It stays fixed after creation.',
+ '唯一的小写名称,例如 openrouter-work。创建后无法更改。',
+ '唯一的小寫名稱,例如 openrouter-work。建立後無法變更。',
+ ],
+ 'API key': ['API key', 'API 密钥', 'API 金鑰'],
+ 'Add an API key or finish provider sign-in before fetching models.': [
+ 'Add an API key or finish provider sign-in before fetching models.',
+ '获取模型前请添加 API 密钥或完成提供商登录。',
+ '取得模型前請新增 API 金鑰或完成提供商登入。',
+ ],
+ 'Add connection': msg_createConnection,
+ 'Add model': ['Add model', '添加模型', '新增模型'],
+ 'Add model manually': ['Add model manually', '手动添加模型', '手動新增模型'],
+ 'Add your first connection, then fetch or add a model.': [
+ 'Add your first connection, then fetch or add a model.',
+ '添加第一个连接,然后获取或添加模型。',
+ '新增第一個連線,然後取得或新增模型。',
+ ],
+ 'Advanced request JSON': ['Advanced request JSON', '高级请求 JSON', '進階請求 JSON'],
+ 'Advanced request JSON must be an object or null.': [
+ 'Advanced request JSON must be an object or null.',
+ '高级请求 JSON 必须是对象或 null。',
+ '進階請求 JSON 必須是物件或 null。',
+ ],
+ 'Advanced request JSON must be valid JSON.': [
+ 'Advanced request JSON must be valid JSON.',
+ '高级请求 JSON 必须是有效的 JSON。',
+ '進階請求 JSON 必須是有效的 JSON。',
+ ],
+ 'Applies to new requests. Requests already running keep their settings.': [
+ 'Applies to new requests. Requests already running keep their settings.',
+ '适用于新请求。正在运行的请求会保留原有设置。',
+ '適用於新請求。正在執行的請求會保留原有設定。',
+ ],
+ 'Assistant tone': ['Assistant tone', '助手语气', '助理語氣'],
+ Automatic: ['Automatic', '自动', '自動'],
+ 'Automatic bypass: {domains}': [
+ 'Automatic bypass: {domains}',
+ '自动绕过:{domains}',
+ '自動略過:{domains}',
+ ],
+ 'Automatic shell titles': [
+ 'Automatic shell titles',
+ '自动生成 Shell 标题',
+ '自動產生 Shell 標題',
+ ],
+ Back: msg_back,
+ 'Back from connection settings': [
+ 'Back from connection settings',
+ '从连接设置返回',
+ '從連線設定返回',
+ ],
+ 'Back from general settings': ['Back from general settings', '从常规设置返回', '從一般設定返回'],
+ 'Back from plugin settings': ['Back from plugin settings', '从插件设置返回', '從外掛設定返回'],
+ 'Back to model settings': ['Back to model settings', '返回模型设置', '返回模型設定'],
+ 'Base URL': ['Base URL', '基础 URL', '基底 URL'],
+ 'Cancel provider sign-in': ['Cancel provider sign-in', '取消提供商登录', '取消供應商登入'],
+ 'Cancel sign-in': ['Cancel sign-in', '取消登录', '取消登入'],
+ 'Cancelling sign-in; waiting for the original result…': [
+ 'Cancelling sign-in; waiting for the original result…',
+ '正在取消登录;等待原始结果…',
+ '正在取消登入;等待原始結果…',
+ ],
+ 'Changes apply when you save this page.': [
+ 'Changes apply when you save this page.',
+ '保存此页面后更改才会生效。',
+ '儲存此頁面後變更才會生效。',
+ ],
+ 'Checking receipts…': ['Checking receipts…', '正在检查回执…', '正在檢查回執…'],
+ 'Choose a model': tui_subagents_choose_model,
+ 'Choose model': msg_chooseModel,
+ 'Choose model and effort': [
+ 'Choose model and effort',
+ '选择模型和推理强度',
+ '選擇模型和推理強度',
+ ],
+ 'Choose model and effort · Ctrl+G': [
+ 'Choose model and effort · Ctrl+G',
+ '选择模型和推理强度 · Ctrl+G',
+ '選擇模型和推理強度 · Ctrl+G',
+ ],
+ 'Choose plugin setting {value}': [
+ 'Choose plugin setting {value}',
+ '选择插件设置 {value}',
+ '選擇外掛設定 {value}',
+ ],
+ 'Choose reasoning effort': ['Choose reasoning effort', '选择推理强度', '選擇推理強度'],
+ 'Choose the API provider for this connection.': [
+ 'Choose the API provider for this connection.',
+ '为此连接选择 API 提供商。',
+ '為此連線選擇 API 供應商。',
+ ],
+ 'Clear default model': ['Clear default model', '清除默认模型', '清除預設模型'],
+ 'Close model selection': ['Close model selection', '关闭模型选择', '關閉模型選擇'],
+ 'Close settings': ['Close settings', '关闭设置', '關閉設定'],
+ 'Compaction prompt': ['Compaction prompt', '压缩提示词', '壓縮提示詞'],
+ 'Complete sign-in with your provider. Credentials stay in the Host vault.': [
+ 'Complete sign-in with your provider. Credentials stay in the Host vault.',
+ '请完成提供商登录。凭据会保存在 Host 保险库中。',
+ '請完成供應商登入。憑據會儲存在 Host 保險庫中。',
+ ],
+ Configuration: ['Configuration', '配置', '設定'],
+ 'Confirm removal': tui_insights_confirm,
+ 'Connect {name}': ['Connect {name}', '连接 {name}', '連線至 {name}'],
+ 'Connection ID': msg_connectionSlug,
+ 'Connection and API key saved.': [
+ 'Connection and API key saved.',
+ '连接和 API 密钥已保存。',
+ '連線和 API 金鑰已儲存。',
+ ],
+ 'Connection default reasoning effort': [
+ 'Connection default reasoning effort',
+ '连接默认推理强度',
+ '連線預設推理強度',
+ ],
+ 'Connection enabled': ['Connection enabled', '连接已启用', '連線已啟用'],
+ 'Connection models': ['Connection models', '连接模型', '連線模型'],
+ 'Connection name': ['Connection name', '连接名称', '連線名稱'],
+ 'Connection removed.': msg_connectionRemoved,
+ 'Connection saved.': ['Connection saved.', '连接已保存。', '連線已儲存。'],
+ 'Connection saved; saving API key…': [
+ 'Connection saved; saving API key…',
+ '连接已保存;正在保存 API 密钥…',
+ '連線已儲存;正在儲存 API 金鑰…',
+ ],
+ 'Connection settings': ['Connection settings', '连接设置', '連線設定'],
+ 'Copy URL': ['Copy URL', '复制 URL', '複製 URL'],
+ 'Copy code': msg_copyLoginCode,
+ 'Copy sign-in URL': ['Copy sign-in URL', '复制登录 URL', '複製登入 URL'],
+ 'Copy sign-in code': ['Copy sign-in code', '复制登录代码', '複製登入代碼'],
+ 'Could not fetch models. Check the provider credential, endpoint and network, or add a model manually.':
+ [
+ 'Could not fetch models. Check the provider credential, endpoint and network, or add a model manually.',
+ '无法获取模型,请检查提供商凭据、端点和网络,或手动添加模型。',
+ '無法取得模型,請檢查提供商憑據、端點和網路,或手動新增模型。',
+ ],
+ Default: tui_thinking_default,
+ 'Default effort {level}': [
+ 'Default effort {level}',
+ '默认推理强度 {level}',
+ '預設推理強度 {level}',
+ ],
+ 'Default model cleared.': msg_defaultCleared,
+ 'Default model saved.': msg_defaultSaved,
+ 'Default · Use connection default': [
+ 'Default · Use connection default',
+ '默认 · 使用连接默认值',
+ '預設 · 使用連線預設值',
+ ],
+ 'Default · {level}': ['Default · {level}', '默认 · {level}', '預設 · {level}'],
+ 'Delete connection': ['Delete connection', '删除连接', '刪除連線'],
+ 'Delete this connection, its stored provider credential and model settings? Existing conversations keep their saved selection.':
+ [
+ 'Delete this connection, its stored provider credential and model settings? Existing conversations keep their saved selection.',
+ '删除此连接及其已保存的提供商凭据和模型设置?现有会话会保留已保存的选择。',
+ '刪除此連線及其已儲存的供應商憑據和模型設定?現有對話會保留已儲存的選擇。',
+ ],
+ 'Disable Host plugin': ['Disable Host plugin', '停用 Host 插件', '停用 Host 外掛'],
+ Disabled: msg_skillsDisabled,
+ 'Discard changes and reload': [
+ 'Discard changes and reload',
+ '放弃更改并重新加载',
+ '捨棄變更並重新載入',
+ ],
+ 'Discard edit and refresh plugins': [
+ 'Discard edit and refresh plugins',
+ '放弃编辑并刷新插件',
+ '捨棄編輯並重新整理外掛',
+ ],
+ 'Discard edits and reload': [
+ 'Discard edits and reload',
+ '放弃编辑并重新加载',
+ '捨棄編輯並重新載入',
+ ],
+ 'Display name': tui_model_profile_name,
+ 'Display name and assistant tone': [
+ 'Display name and assistant tone',
+ '显示名称和助手语气',
+ '顯示名稱和助理語氣',
+ ],
+ 'Earlier settings actions must be resolved before another change.': [
+ 'Earlier settings actions must be resolved before another change.',
+ '必须先解决先前的设置操作,才能进行其他更改。',
+ '必須先解決先前的設定操作,才能進行其他變更。',
+ ],
+ 'Edit compaction prompt': ['Edit compaction prompt', '编辑压缩提示词', '編輯壓縮提示詞'],
+ 'Edit connection {name}': ['Edit connection {name}', '编辑连接 {name}', '編輯連線 {name}'],
+ 'Edit model {id}': ['Edit model {id}', '编辑模型 {id}', '編輯模型 {id}'],
+ 'Edit personalization': ['Edit personalization', '编辑个性偏好', '編輯個人偏好'],
+ 'Edit plugin setting {name}': [
+ 'Edit plugin setting {name}',
+ '编辑插件设置 {name}',
+ '編輯外掛設定 {name}',
+ ],
+ 'Editable connection facts are unavailable. Editing is disabled.': [
+ 'Editable connection facts are unavailable. Editing is disabled.',
+ '无法获取可编辑的连接信息,已禁用编辑。',
+ '無法取得可編輯的連線資訊,已停用編輯。',
+ ],
+ 'Enable Host plugin': ['Enable Host plugin', '启用 Host 插件', '啟用 Host 外掛'],
+ 'Enable Host proxy': ['Enable Host proxy', '启用 Host 代理', '啟用 Host 代理'],
+ 'Enable connection': ['Enable connection', '启用连接', '啟用連線'],
+ 'Enable proxy authentication': ['Enable proxy authentication', '启用代理认证', '啟用代理驗證'],
+ 'Enable the connection and model before making it the default.': [
+ 'Enable the connection and model before making it the default.',
+ '设为默认前请先启用连接和模型。',
+ '設為預設前請先啟用連線和模型。',
+ ],
+ 'Enable the parent plugin before enabling this child.': [
+ 'Enable the parent plugin before enabling this child.',
+ '请先启用父插件,再启用此子插件。',
+ '請先啟用父外掛,再啟用此子外掛。',
+ ],
+ Enabled: msg_skillsEnabled,
+ 'Enter a compaction prompt or choose the Host default.': [
+ 'Enter a compaction prompt or choose the Host default.',
+ '请输入压缩提示词或选择 Host 默认值。',
+ '請輸入壓縮提示詞或選擇 Host 預設值。',
+ ],
+ 'Enter a connection name.': ['Enter a connection name.', '请输入连接名称。', '請輸入連線名稱。'],
+ 'Enter a number. Decimal text is kept exactly.': [
+ 'Enter a number. Decimal text is kept exactly.',
+ '输入数字,小数文本将原样保留。',
+ '輸入數字,小數文字將原樣保留。',
+ ],
+ 'Enter a proxy port from 1 to 65535, or turn off the proxy.': [
+ 'Enter a proxy port from 1 to 65535, or turn off the proxy.',
+ '输入 1 到 65535 之间的代理端口,或关闭代理。',
+ '輸入 1 到 65535 之間的代理連接埠,或關閉代理。',
+ ],
+ 'Enter the exact model ID accepted by the provider.': [
+ 'Enter the exact model ID accepted by the provider.',
+ '请输入提供商接受的准确模型 ID。',
+ '請輸入提供商接受的確切模型 ID。',
+ ],
+ 'Enter the proxy host.': ['Enter the proxy host.', '输入代理主机。', '輸入代理主機。'],
+ 'Enter the replacement proxy password.': [
+ 'Enter the replacement proxy password.',
+ '输入要替换的代理密码。',
+ '輸入要取代的代理密碼。',
+ ],
+ 'Fetch models': ['Fetch models', '获取模型', '取得模型'],
+ 'Fetch the provider’s catalog or add its exact model ID.': [
+ 'Fetch the provider’s catalog or add its exact model ID.',
+ '获取提供商的模型目录,或添加准确的模型 ID。',
+ '取得供應商的模型目錄,或新增正確的模型 ID。',
+ ],
+ General: ['General', '常规', '一般'],
+ 'General settings': ['General settings', '常规设置', '一般設定'],
+ Group: ['Group', '分组', '群組'],
+ 'Hide value': ['Hide value', '隐藏值', '隱藏值'],
+ 'Hide {label}': ['Hide {label}', '隐藏 {label}', '隱藏 {label}'],
+ 'Host services changed elsewhere. Reload before saving again.': [
+ 'Host services changed elsewhere. Reload before saving again.',
+ 'Host 服务已在别处更改,请重新加载后再保存。',
+ 'Host 服務已在別處變更,請重新載入後再儲存。',
+ ],
+ 'Hostname or IP address; enter the port separately.': [
+ 'Hostname or IP address; enter the port separately.',
+ '主机名或 IP 地址;端口请单独输入。',
+ '主機名稱或 IP 位址;連接埠請另行輸入。',
+ ],
+ 'How the assistant should respond.': [
+ 'How the assistant should respond.',
+ '助手应如何回应。',
+ '助理應如何回應。',
+ ],
+ Include: ['Include', '包含', '包含'],
+ 'Include {label}': ['Include {label}', '包含 {label}', '包含 {label}'],
+ Included: ['Included', '已包含', '已包含'],
+ 'Incognito mode': ['Incognito mode', '无痕模式', '無痕模式'],
+ 'Instructions for summarizing conversation context.': [
+ 'Instructions for summarizing conversation context.',
+ '用于总结对话上下文的指令。',
+ '用於摘要對話脈絡的指令。',
+ ],
+ 'Keep proxy password': ['Keep proxy password', '保留代理密码', '保留代理密碼'],
+ 'Keep stored password': ['Keep stored password', '保留已存密码', '保留已存密碼'],
+ Language: tui_settings_language,
+ 'Language used by the interface and assistant.': [
+ 'Language used by the interface and assistant.',
+ '界面和助手使用的语言。',
+ '介面和助理使用的語言。',
+ ],
+ 'Language: {language}': ['Language: {language}', '语言:{language}', '語言:{language}'],
+ List: ['List', '列表', '清單'],
+ 'Loading model policy…': ['Loading model policy…', '正在读取模型策略…', '正在讀取模型原則…'],
+ 'Loading network settings…': [
+ 'Loading network settings…',
+ '正在加载网络设置…',
+ '正在載入網路設定…',
+ ],
+ 'Loading settings…': ['Loading settings…', '正在加载设置…', '正在載入設定…'],
+ 'Manage connections & models': [
+ 'Manage connections & models',
+ '管理连接与模型',
+ '管理連線與模型',
+ ],
+ 'Manage connections and models': [
+ 'Manage connections and models',
+ '管理连接和模型',
+ '管理連線和模型',
+ ],
+ 'Manage models': ['Manage models', '管理模型', '管理模型'],
+ 'Manage models · {count}': [
+ 'Manage models · {count}',
+ '管理模型 · {count}',
+ '管理模型 · {count}',
+ ],
+ 'Manage plugins for this connection. Your conversation and draft stay open.': [
+ 'Manage plugins for this connection. Your conversation and draft stay open.',
+ '管理此连接的插件。对话和草稿会保持打开。',
+ '管理此連線的外掛。對話與草稿會保持開啟。',
+ ],
+ 'Merged into model requests. For OpenRouter: {"provider":{"only":["openrouter","deepseek"]}}. Use null to clear.':
+ [
+ 'Merged into model requests. For OpenRouter: {"provider":{"only":["openrouter","deepseek"]}}. Use null to clear.',
+ '合并到模型请求中。OpenRouter 示例:{"provider":{"only":["openrouter","deepseek"]}}。使用 null 清除。',
+ '合併至模型請求中。OpenRouter 範例:{"provider":{"only":["openrouter","deepseek"]}}。使用 null 清除。',
+ ],
+ Model: msg_historyModel,
+ 'Model ID': ['Model ID', '模型 ID', '模型 ID'],
+ 'Model display name': ['Model display name', '模型显示名称', '模型顯示名稱'],
+ 'Model removed from this connection.': [
+ 'Model removed from this connection.',
+ '已从此连接移除模型。',
+ '已從此連線移除模型。',
+ ],
+ 'Model saved.': ['Model saved.', '模型已保存。', '模型已儲存。'],
+ 'Model selection error': ['Model selection error', '模型选择错误', '模型選擇錯誤'],
+ 'Model settings': msg_modelSettings,
+ 'Models fetched and enabled.': [
+ 'Models fetched and enabled.',
+ '模型已获取并启用。',
+ '模型已取得並啟用。',
+ ],
+ 'Models · {count}': ['Models · {count}', '模型 · {count}', '模型 · {count}'],
+ Network: msg_permissionNetwork,
+ 'Network settings': ['Network settings', '网络设置', '網路設定'],
+ 'Network settings saved.': ['Network settings saved.', '网络设置已保存。', '網路設定已儲存。'],
+ No: msg_formNo,
+ 'No connection changes.': ['No connection changes.', '连接没有更改。', '連線沒有變更。'],
+ 'No editable fields in this group.': [
+ 'No editable fields in this group.',
+ '此分组中没有可编辑的字段。',
+ '此群組中沒有可編輯的欄位。',
+ ],
+ 'No matching plugins.': ['No matching plugins.', '没有匹配的插件。', '沒有符合的外掛。'],
+ 'No proxy password is stored.': [
+ 'No proxy password is stored.',
+ '未存储代理密码。',
+ '未儲存代理密碼。',
+ ],
+ 'No proxy password is stored. Choose Replace password and enter one.': [
+ 'No proxy password is stored. Choose Replace password and enter one.',
+ '未存储代理密码。请选择“替换密码”并输入密码。',
+ '未儲存代理密碼。請選擇「取代密碼」並輸入密碼。',
+ ],
+ 'Not set': msg_settingUnset,
+ Number: tui_theme_role_number,
+ Off: msg_settingOff,
+ 'Omit {label}': ['Omit {label}', '省略 {label}', '省略 {label}'],
+ On: msg_settingOn,
+ 'One bypass rule per line. Automatic rules are retained.': [
+ 'One bypass rule per line. Automatic rules are retained.',
+ '每行一条绕过规则。自动规则会保留。',
+ '每行一條略過規則。自動規則會保留。',
+ ],
+ 'Open a plugin in this group to edit its settings.': [
+ 'Open a plugin in this group to edit its settings.',
+ '打开此分组中的插件以编辑其设置。',
+ '開啟此群組中的外掛以編輯其設定。',
+ ],
+ 'Open plugin group {name}': [
+ 'Open plugin group {name}',
+ '打开插件分组 {name}',
+ '開啟外掛群組 {name}',
+ ],
+ 'Open plugin {name}': ['Open plugin {name}', '打开插件 {name}', '開啟外掛 {name}'],
+ 'Open this URL in your browser and enter the code. Waiting for sign-in…': [
+ 'Open this URL in your browser and enter the code. Waiting for sign-in…',
+ '请在浏览器中打开此 URL 并输入代码。正在等待登录…',
+ '請在瀏覽器中開啟此 URL 並輸入代碼。正在等待登入…',
+ ],
+ 'Open this URL in your browser. Waiting for sign-in…': [
+ 'Open this URL in your browser. Waiting for sign-in…',
+ '请在浏览器中打开此 URL。正在等待登录…',
+ '請在瀏覽器中開啟此 URL。正在等待登入…',
+ ],
+ Optional: ['Optional', '选填', '選填'],
+ 'Optional. Empty uses the provider’s name.': [
+ 'Optional. Empty uses the provider’s name.',
+ '可选。留空则使用提供商的名称。',
+ '選填。留空則使用供應商的名稱。',
+ ],
+ 'Original settings action receipts checked.': [
+ 'Original settings action receipts checked.',
+ '已检查原始设置操作的回执。',
+ '已檢查原始設定操作的回執。',
+ ],
+ 'Parent disabled': ['Parent disabled', '父插件已停用', '父外掛已停用'],
+ Personalization: ['Personalization', '个性偏好', '個人偏好'],
+ 'Plugin change rejected: {reason}': [
+ 'Plugin change rejected: {reason}',
+ '插件更改被拒绝:{reason}',
+ '外掛變更遭拒:{reason}',
+ ],
+ 'Plugin configuration changed elsewhere. Discard this edit and refresh before trying again.': [
+ 'Plugin configuration changed elsewhere. Discard this edit and refresh before trying again.',
+ '插件配置已在其他位置更改。请放弃此次编辑并刷新后重试。',
+ '外掛設定已在其他位置變更。請捨棄此次編輯並重新整理後重試。',
+ ],
+ 'Plugin configuration saved. Enabled is the desired state; services may still be reconciling.': [
+ 'Plugin configuration saved. Enabled is the desired state; services may still be reconciling.',
+ '插件配置已保存。“已启用”表示期望状态;服务可能仍在协调中。',
+ '外掛設定已儲存。「已啟用」表示期望狀態;服務可能仍在協調中。',
+ ],
+ 'Plugin inventory changed its source while reading.': [
+ 'Plugin inventory changed its source while reading.',
+ '读取插件清单时,其来源发生了变化。',
+ '讀取外掛清單時,其來源發生了變更。',
+ ],
+ 'Plugin inventory paging did not advance.': [
+ 'Plugin inventory paging did not advance.',
+ '插件清单分页未继续前进。',
+ '外掛清單分頁未繼續前進。',
+ ],
+ 'Plugin setting': ['Plugin setting', '插件设置', '外掛設定'],
+ 'Plugin settings are unavailable on this connection.': [
+ 'Plugin settings are unavailable on this connection.',
+ '此连接无法使用插件设置。',
+ '此連線無法使用外掛設定。',
+ ],
+ 'Plugin settings changed their source while reading. Refresh to continue.': [
+ 'Plugin settings changed their source while reading. Refresh to continue.',
+ '读取插件设置时,其来源发生了变化。请刷新后继续。',
+ '讀取外掛設定時,其來源發生了變更。請重新整理後繼續。',
+ ],
+ 'Plugin settings paging did not advance.': [
+ 'Plugin settings paging did not advance.',
+ '插件设置分页未继续前进。',
+ '外掛設定分頁未繼續前進。',
+ ],
+ 'Plugin settings returned a different target.': [
+ 'Plugin settings returned a different target.',
+ '插件设置返回了不同的目标。',
+ '外掛設定傳回了不同的目標。',
+ ],
+ 'Plugin value {name}': ['Plugin value {name}', '插件值 {name}', '外掛值 {name}'],
+ Plugins: msg_deploymentTitle,
+ 'Preferences changed elsewhere. Cancel this edit and refresh before saving again.': [
+ 'Preferences changed elsewhere. Cancel this edit and refresh before saving again.',
+ '偏好已在其他地方更改。请取消此次编辑并刷新,然后再保存。',
+ '偏好已在其他地方變更。請取消此次編輯並重新整理,然後再儲存。',
+ ],
+ 'Preferences saved.': tui_assistant_saved,
+ 'Provider default': ['Provider default', '提供商默认设置', '供應商預設設定'],
+ 'Provider sign-in': ['Provider sign-in', '提供商登录', '供應商登入'],
+ Proxy: ['Proxy', '代理', '代理'],
+ 'Proxy authentication': tui_network_auth,
+ 'Proxy bypass rules': ['Proxy bypass rules', '代理绕过规则', '代理略過規則'],
+ 'Proxy host': tui_network_host,
+ 'Proxy password': ['Proxy password', '代理密码', '代理密碼'],
+ 'Proxy port': ['Proxy port', '代理端口', '代理連接埠'],
+ 'Proxy protocol {protocol}': [
+ 'Proxy protocol {protocol}',
+ '代理协议 {protocol}',
+ '代理協定 {protocol}',
+ ],
+ 'Proxy username': ['Proxy username', '代理用户名', '代理使用者名稱'],
+ 'Read only or private': ['Read only or private', '只读或私有', '唯讀或私有'],
+ 'Reading plugins…': ['Reading plugins…', '正在读取插件…', '正在讀取外掛…'],
+ 'Reading preferences…': ['Reading preferences…', '正在读取偏好…', '正在讀取偏好…'],
+ 'Reasoning effort': ['Reasoning effort', '推理强度', '推理強度'],
+ 'Recover original settings actions': [
+ 'Recover original settings actions',
+ '恢复原始设置操作',
+ '復原原始設定操作',
+ ],
+ 'Recover settings actions': ['Recover settings actions', '恢复设置操作', '復原設定操作'],
+ 'Refresh connections': msg_refreshConnections,
+ 'Refresh general preferences': [
+ 'Refresh general preferences',
+ '刷新常规偏好',
+ '重新整理一般偏好',
+ ],
+ 'Refresh plugin configuration': [
+ 'Refresh plugin configuration',
+ '刷新插件配置',
+ '重新整理外掛設定',
+ ],
+ 'Refresh plugins': ['Refresh plugins', '刷新插件', '重新整理外掛'],
+ 'Refresh preferences': ['Refresh preferences', '刷新偏好', '重新整理偏好'],
+ 'Refresh the connection catalog before creating a connection.': [
+ 'Refresh the connection catalog before creating a connection.',
+ '创建连接前请刷新连接目录。',
+ '建立連線前請重新整理連線目錄。',
+ ],
+ 'Reload network settings': ['Reload network settings', '重新加载网络设置', '重新載入網路設定'],
+ 'Remove explicit value': ['Remove explicit value', '移除显式值', '移除明確設定的值'],
+ 'Remove model': ['Remove model', '移除模型', '移除模型'],
+ 'Remove stored provider credential': [
+ 'Remove stored provider credential',
+ '移除已保存的提供商凭据',
+ '移除已儲存的供應商憑據',
+ ],
+ 'Remove the stored provider credential? This connection may stop working.': [
+ 'Remove the stored provider credential? This connection may stop working.',
+ '移除已保存的提供商凭据?此连接可能会停止工作。',
+ '移除已儲存的供應商憑據?此連線可能會停止運作。',
+ ],
+ 'Remove this model from the connection?': [
+ 'Remove this model from the connection?',
+ '从连接中移除此模型?',
+ '從連線中移除此模型?',
+ ],
+ 'Replace password': tui_network_replace,
+ 'Replace proxy password': ['Replace proxy password', '替换代理密码', '取代代理密碼'],
+ Required: msg_formRequired,
+ 'Retry model catalog': ['Retry model catalog', '重试模型目录', '重試模型清單'],
+ 'Retry model policy': ['Retry model policy', '重试模型策略', '重試模型原則'],
+ 'Reveal value': ['Reveal value', '显示值', '顯示值'],
+ 'Reveal {label}': ['Reveal {label}', '显示 {label}', '顯示 {label}'],
+ 'Save Host plugin setting': [
+ 'Save Host plugin setting',
+ '保存 Host 插件设置',
+ '儲存 Host 外掛設定',
+ ],
+ 'Save changes before fetching or editing models.': [
+ 'Save changes before fetching or editing models.',
+ '获取或编辑模型前,请先保存更改。',
+ '取得或編輯模型前,請先儲存變更。',
+ ],
+ 'Save connection': msg_saveConnection,
+ 'Save model': ['Save model', '保存模型', '儲存模型'],
+ 'Save network settings': ['Save network settings', '保存网络设置', '儲存網路設定'],
+ 'Save or discard connection edits before removing the stored credential.': [
+ 'Save or discard connection edits before removing the stored credential.',
+ '移除已保存的凭据前,请先保存或放弃连接更改。',
+ '移除已儲存的憑據前,請先儲存或捨棄連線變更。',
+ ],
+ 'Save or discard connection edits before signing in.': [
+ 'Save or discard connection edits before signing in.',
+ '请先保存或放弃连接更改,再登录。',
+ '請先儲存或捨棄連線變更,再登入。',
+ ],
+ 'Save or discard the connection changes before editing models.': [
+ 'Save or discard the connection changes before editing models.',
+ '编辑模型前请先保存或放弃连接更改。',
+ '編輯模型前請先儲存或捨棄連線變更。',
+ ],
+ 'Save plugin setting': ['Save plugin setting', '保存插件设置', '儲存外掛設定'],
+ 'Save the connection before saving its API key.': [
+ 'Save the connection before saving its API key.',
+ '保存 API 密钥前请先保存连接。',
+ '儲存 API 金鑰前請先儲存連線。',
+ ],
+ 'Save the connection changes before fetching models.': [
+ 'Save the connection changes before fetching models.',
+ '获取模型前请先保存连接更改。',
+ '取得模型前請先儲存連線變更。',
+ ],
+ 'Save this connection before starting sign-in.': [
+ 'Save this connection before starting sign-in.',
+ '请先保存此连接,再开始登录。',
+ '請先儲存此連線,再開始登入。',
+ ],
+ 'Save this preference': ['Save this preference', '保存此偏好', '儲存此偏好'],
+ 'Saved configuration · revision {revision}. Enabled shows the saved setting. Changes may take a moment to apply.':
+ [
+ 'Saved configuration · revision {revision}. Enabled shows the saved setting. Changes may take a moment to apply.',
+ '已保存的配置 · 修订版本 {revision}。“已启用”显示已保存的设置。更改可能需要片刻才能生效。',
+ '已儲存的設定 · 修訂版本 {revision}。「已啟用」顯示已儲存的設定。變更可能需要片刻才會生效。',
+ ],
+ 'Saving selection…': ['Saving selection…', '正在保存选择…', '正在儲存選擇…'],
+ 'Saving with proxy authentication disabled deletes its stored password.': [
+ 'Saving with proxy authentication disabled deletes its stored password.',
+ '关闭代理认证后保存会删除已存储的密码。',
+ '關閉代理驗證後儲存會刪除已儲存的密碼。',
+ ],
+ 'Saving…': tui_session_saving,
+ 'Search Host plugins': ['Search Host plugins', '搜索 Host 插件', '搜尋 Host 外掛'],
+ 'Search models': ['Search models', '搜索模型', '搜尋模型'],
+ 'Search models…': ['Search models…', '搜索模型…', '搜尋模型…'],
+ 'Search plugins…': ['Search plugins…', '搜索插件…', '搜尋外掛…'],
+ 'Select the reasoning efforts supported by this model. They will be available in conversations.':
+ [
+ 'Select the reasoning efforts supported by this model. They will be available in conversations.',
+ '选择此模型支持的推理强度,这些选项将在会话中可用。',
+ '選擇此模型支援的推理強度,這些選項將可在對話中使用。',
+ ],
+ 'Set default {id}': ['Set default {id}', '将 {id} 设为默认', '將 {id} 設為預設'],
+ 'Set null': ['Set null', '设为 null', '設為 null'],
+ 'Settings section {section}': [
+ 'Settings section {section}',
+ '设置分区:{section}',
+ '設定區段:{section}',
+ ],
+ 'Sign-in URL copied.': ['Sign-in URL copied.', '登录网址已复制。', '登入網址已複製。'],
+ 'Sign-in cancelled before it started.': [
+ 'Sign-in cancelled before it started.',
+ '登录已在开始前取消。',
+ '登入已在開始前取消。',
+ ],
+ 'Sign-in cancelled.': msg_loginCancelled,
+ 'Sign-in code copied.': ['Sign-in code copied.', '登录码已复制。', '登入碼已複製。'],
+ 'Sign-in ended without a final receipt. Recover the original action.': [
+ 'Sign-in ended without a final receipt. Recover the original action.',
+ '登录结束但未收到最终回执,请恢复原始操作。',
+ '登入結束但未收到最終回執,請復原原始操作。',
+ ],
+ 'Sign-in failed. Check the provider and network, then try again.': [
+ 'Sign-in failed. Check the provider and network, then try again.',
+ '登录失败,请检查提供商和网络后重试。',
+ '登入失敗,請檢查提供商和網路後再試。',
+ ],
+ 'Sign-in saved in the Host credential vault.': [
+ 'Sign-in saved in the Host credential vault.',
+ '登录凭据已保存到 Host 凭据库。',
+ '登入憑據已儲存至 Host 憑據庫。',
+ ],
+ 'Sign-in was saved.': ['Sign-in was saved.', '登录已保存。', '登入已儲存。'],
+ 'Starting sign-in…': msg_loginStarting,
+ 'Stored provider credential removed.': [
+ 'Stored provider credential removed.',
+ '已移除保存的提供商凭据。',
+ '已移除儲存的提供商憑據。',
+ ],
+ 'Stored securely, separately from your proxy settings.': [
+ 'Stored securely, separately from your proxy settings.',
+ '安全存储,与代理设置分开保存。',
+ '安全儲存,與代理設定分開保存。',
+ ],
+ 'Support effort {level}': [
+ 'Support effort {level}',
+ '支持 {level} 推理强度',
+ '支援 {level} 推理強度',
+ ],
+ 'Supported reasoning efforts': [
+ 'Supported reasoning efforts',
+ '支持的推理强度',
+ '支援的推理強度',
+ ],
+ Text: ['Text', '文本', '文字'],
+ 'That connection ID is already in use. Choose another ID.': [
+ 'That connection ID is already in use. Choose another ID.',
+ '该连接标识已被使用,请选择其他标识。',
+ '該連線識別碼已被使用,請選擇其他識別碼。',
+ ],
+ 'That model is already enabled.': [
+ 'That model is already enabled.',
+ '该模型已启用。',
+ '該模型已啟用。',
+ ],
+ 'The Host rejected these preferences. Check the values before saving.': [
+ 'The Host rejected these preferences. Check the values before saving.',
+ 'Host 拒绝了这些偏好。请检查各项值后再保存。',
+ 'Host 拒絕了這些偏好。請檢查各項值後再儲存。',
+ ],
+ 'The Host rejected this action ({reason}).': [
+ 'The Host rejected this action ({reason}).',
+ 'Host 拒绝了此操作({reason})。',
+ 'Host 拒絕了此操作({reason})。',
+ ],
+ 'The Host rejected this service change. Reload and review it.': [
+ 'The Host rejected this service change. Reload and review it.',
+ 'Host 拒绝了此服务更改,请重新加载并检查。',
+ 'Host 拒絕了此服務變更,請重新載入並檢查。',
+ ],
+ 'The change was committed. Its editable snapshot could not be reopened; no further changes can be saved here.':
+ [
+ 'The change was committed. Its editable snapshot could not be reopened; no further changes can be saved here.',
+ '更改已提交,但无法重新打开可编辑快照;无法在此继续保存更改。',
+ '變更已提交,但無法重新開啟可編輯快照;無法在此繼續儲存變更。',
+ ],
+ 'The connection catalog did not advance.': [
+ 'The connection catalog did not advance.',
+ '连接目录未能继续加载。',
+ '連線目錄未能繼續載入。',
+ ],
+ 'The connection catalog keeps changing. Refresh to try again.': [
+ 'The connection catalog keeps changing. Refresh to try again.',
+ '连接目录持续变化,请刷新后重试。',
+ '連線目錄持續變更,請重新整理後再試。',
+ ],
+ 'The connection catalog revision is missing.': [
+ 'The connection catalog revision is missing.',
+ '缺少连接目录的修订版本。',
+ '缺少連線目錄的修訂版本。',
+ ],
+ 'The exact API model ID, including its provider prefix when required.': [
+ 'The exact API model ID, including its provider prefix when required.',
+ '准确的 API 模型 ID,必要时包含提供商前缀。',
+ '正確的 API 模型 ID,必要時包含供應商前綴。',
+ ],
+ 'The model catalog keeps changing. Retry to read the current choices.': [
+ 'The model catalog keeps changing. Retry to read the current choices.',
+ '模型目录不断变化。请重试以读取当前选项。',
+ '模型清單持續變更。請重試以讀取目前選項。',
... 2224 lines suppressed ...
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 17/22: terminal: Keep session identifiers opaque without a private length cap
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 039294dc101ac30881daa349cf6e8ba4d45304fe
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 17:08:20 2026 +0800
terminal: Keep session identifiers opaque without a private length cap
---
products/maka/terminal-shell/src/bindings.rs | 4 ++--
products/maka/terminal-shell/src/entry.rs | 8 ++------
2 files changed, 4 insertions(+), 8 deletions(-)
diff --git a/products/maka/terminal-shell/src/bindings.rs b/products/maka/terminal-shell/src/bindings.rs
index 81c508625d..043eaae5aa 100644
--- a/products/maka/terminal-shell/src/bindings.rs
+++ b/products/maka/terminal-shell/src/bindings.rs
@@ -216,8 +216,8 @@ impl ApplicationHost {
/// Capture a session without rebinding another view's editor or sends.
pub fn session(&self, session_id: String) -> Result<Instance<ScopedHost>, String> {
- if session_id.is_empty() || session_id.len() > 128 {
- return Err("session must contain between 1 and 128 bytes".into());
+ if session_id.is_empty() {
+ return Err("session must not be empty".into());
}
Ok(Instance::new(ScopedHost {
scope: Arc::new(Scope {
diff --git a/products/maka/terminal-shell/src/entry.rs b/products/maka/terminal-shell/src/entry.rs
index fca71e5f48..8c74ae8f8d 100644
--- a/products/maka/terminal-shell/src/entry.rs
+++ b/products/maka/terminal-shell/src/entry.rs
@@ -71,12 +71,8 @@ impl Args {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return Err("the terminal shell requires a terminal on stdin and stdout".into());
}
- if self
- .session
- .as_ref()
- .is_some_and(|session| session.is_empty() || session.len() > 128)
- {
- return Err("session must contain between 1 and 128 bytes".into());
+ if self.session.as_ref().is_some_and(String::is_empty) {
+ return Err("session must not be empty".into());
}
let stop = Arc::new(AtomicBool::new(false));
// Install before opening any Host session or entering terminal modes.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/22: style(terminal): format the activity history regression case
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 27509f24c89cc5455199753f5a3ffd65e607b729
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 15:31:54 2026 +0800
style(terminal): format the activity history regression case
Apply the repository-locked Biome formatter to the existing history test so the CI format check can proceed. Preserve its syntax tree, values and assertions.
---
scripts/rust/terminal-tool-activity.test.mjs | 43 ++++++++++++++++++++++------
1 file changed, 34 insertions(+), 9 deletions(-)
diff --git a/scripts/rust/terminal-tool-activity.test.mjs b/scripts/rust/terminal-tool-activity.test.mjs
index 86457c8449..2805d3df8c 100644
--- a/scripts/rust/terminal-tool-activity.test.mjs
+++ b/scripts/rust/terminal-tool-activity.test.mjs
@@ -50,8 +50,14 @@ test('thinking summary retains the last nonempty paragraph and its Unicode text'
test('unchanged history pages retain rows while fragments and fenced metadata still advance', () => {
const first = {
position: { sourceSequence: 10, itemIndex: 0 },
- messageId: 'message', turnId: 'turn', timestamp: 100,
- field: 'body', role: 'user', offset: 0, text: '界', totalBytes: 7,
+ messageId: 'message',
+ turnId: 'turn',
+ timestamp: 100,
+ field: 'body',
+ role: 'user',
+ offset: 0,
+ text: '界',
+ totalBytes: 7,
};
const initial = mergeChunks([], [first], false, 20);
assert.equal(initial[0].text, '界');
@@ -61,7 +67,10 @@ test('unchanged history pages retain rows while fragments and fenced metadata st
const extended = mergeChunks(initial, [{ ...first, offset: 3, text: '🙂' }], false, 21);
assert.equal(extended[0].text, '界🙂');
- assert.deepEqual(extended[0].fragments, [{ offset: 0, text: '界' }, { offset: 3, text: '🙂' }]);
+ assert.deepEqual(extended[0].fragments, [
+ { offset: 0, text: '界' },
+ { offset: 3, text: '🙂' },
+ ]);
assert.equal(extended[0].metadataThrough, 21);
const refreshed = {
...first,
@@ -69,8 +78,12 @@ test('unchanged history pages retain rows while fragments and fenced metadata st
attachments: [{ name: 'notes.txt', mimeType: 'text/plain', bytes: 12, kind: 'doc' }],
};
const reply = {
- ...first, position: { sourceSequence: 11, itemIndex: 0 },
- messageId: 'reply', role: 'assistant', text: 'reply', totalBytes: 5,
+ ...first,
+ position: { sourceSequence: 11, itemIndex: 0 },
+ messageId: 'reply',
+ role: 'assistant',
+ text: 'reply',
+ totalBytes: 5,
};
const withReply = mergeChunks(extended, [reply], false, 21);
const completed = {
@@ -82,13 +95,25 @@ test('unchanged history pages retain rows while fragments and fenced metadata st
const updated = updateHistoryMetadata(withReply, [refreshed, completed], 22);
assert.equal(updated[0].text, '界🙂');
assert.strictEqual(updated[0].fragments, extended[0].fragments);
- assert.deepEqual(updated[0].attachments, [{ name: 'notes.txt', mimeType: 'text/plain', bytes: 12, kind: 'doc' }]);
- assert.deepEqual(updated[1].timing, { startedAt: 100, finished: { at: 200, outcome: 'completed' } });
- assert.deepEqual(updated[1].model, { kind: 'native', requestedModelId: 'requested', reportedModelId: 'reported' });
+ assert.deepEqual(updated[0].attachments, [
+ { name: 'notes.txt', mimeType: 'text/plain', bytes: 12, kind: 'doc' },
+ ]);
+ assert.deepEqual(updated[1].timing, {
+ startedAt: 100,
+ finished: { at: 200, outcome: 'completed' },
+ });
+ assert.deepEqual(updated[1].model, {
+ kind: 'native',
+ requestedModelId: 'requested',
+ reportedModelId: 'reported',
+ });
assert.equal(updated[1].finalOutput, true);
assert.equal(updated[0].metadataThrough, 22);
assert.strictEqual(updateHistoryMetadata(updated, [], 99), updated);
- assert.strictEqual(updateHistoryMetadata(updated, structuredClone([refreshed, completed]), 22), updated);
+ assert.strictEqual(
+ updateHistoryMetadata(updated, structuredClone([refreshed, completed]), 22),
+ updated,
+ );
assert.strictEqual(updateHistoryMetadata(updated, [{ ...first, attachments: [] }], 21), updated);
const advanced = updateHistoryMetadata(updated, [refreshed, completed], 23);
assert.equal(advanced[0].metadataThrough, 23);
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 03/22: terminal: Check resize admission with spare frame capacity
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 93e277afd4dc5ee5bf4735b636c1b50f081bedbf
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 15:32:05 2026 +0800
terminal: Check resize admission with spare frame capacity
---
products/maka/terminal-shell/src/terminal/pixels/input.rs | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/products/maka/terminal-shell/src/terminal/pixels/input.rs b/products/maka/terminal-shell/src/terminal/pixels/input.rs
index 0aef053a9a..5532e5178d 100644
--- a/products/maka/terminal-shell/src/terminal/pixels/input.rs
+++ b/products/maka/terminal-shell/src/terminal/pixels/input.rs
@@ -551,14 +551,15 @@ mod tests {
"a completed stale result releases only its own slot"
);
assert!(inbox.reserve_frame());
+ for _ in 0..4 {
+ inbox.finished(&token, None);
+ }
+ assert_eq!(inbox.frames.len(), 1);
assert!(
!inbox.can_submit(),
- "new geometry needs its own whole flush"
+ "new geometry needs its own whole flush even with spare capacity"
);
inbox.release_reservation();
- for _ in 0..4 {
- inbox.finished(&token, None);
- }
assert!(inbox.frames.is_empty());
assert!(inbox.can_submit());
assert_eq!(inbox.last_fully_written_geometry, Some(1));
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 04/22: build(terminal): use the latest NSH runtime and layout fixes
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 6469bd0e17ee2ec0d869775fd10dfd955ddcba98
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 16:28:37 2026 +0800
build(terminal): use the latest NSH runtime and layout fixes
---
Cargo.lock | 24 ++++++++++++------------
Cargo.toml | 18 +++++++++---------
2 files changed, 21 insertions(+), 21 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 78a73025ad..4349b8d481 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6314,7 +6314,7 @@ dependencies = [
[[package]]
name = "gpui-fast-wgpu"
version = "0.1.10"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"anyhow",
"bytemuck",
@@ -7715,7 +7715,7 @@ checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "icy_sixel"
version = "0.5.1"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"quantette",
"thiserror 2.0.20",
@@ -10966,7 +10966,7 @@ dependencies = [
[[package]]
name = "nsh-base"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"html5ever 0.27.0",
"log",
@@ -10984,7 +10984,7 @@ dependencies = [
[[package]]
name = "nsh-bundler"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"anyhow",
"dunce",
@@ -11009,7 +11009,7 @@ dependencies = [
[[package]]
name = "nsh-core"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"cap-std",
"flate2",
@@ -11032,7 +11032,7 @@ dependencies = [
[[package]]
name = "nsh-engine"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"crossbeam-channel",
"libc",
@@ -11050,7 +11050,7 @@ dependencies = [
[[package]]
name = "nsh-gpui-terminal"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -11067,7 +11067,7 @@ dependencies = [
[[package]]
name = "nsh-js"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"futures-util",
"getrandom 0.4.3",
@@ -11088,7 +11088,7 @@ dependencies = [
[[package]]
name = "nsh-macros"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"imagesize 0.15.0",
"proc-macro2",
@@ -11100,7 +11100,7 @@ dependencies = [
[[package]]
name = "nsh-package"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"base64 0.22.1",
"ed25519-dalek 2.2.0",
@@ -11115,7 +11115,7 @@ dependencies = [
[[package]]
name = "nsh-platform-gpui"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -11147,7 +11147,7 @@ dependencies = [
[[package]]
name = "nsh-platform-tui"
version = "0.2.0"
-source = "git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e"
+source = "git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042"
dependencies = [
"base64 0.22.1",
"crossbeam-channel",
diff --git a/Cargo.toml b/Cargo.toml
index 67047fa510..1adbff48dc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -92,13 +92,13 @@ publish = false
hex = "0.4.3"
maka-ui-authoring = { path = "plugins/application/authoring/native" }
maka-app-client = { path = "products/maka/client" }
-nsh-core = { git = "https://github.com/M4n5ter/nsh", rev = "750fdf8b14b3eacaf2aa3ed75b282735dded6f7e" }
-nsh-engine = { git = "https://github.com/M4n5ter/nsh", rev = "750fdf8b14b3eacaf2aa3ed75b282735dded6f7e" }
-nsh-platform-tui = { git = "https://github.com/M4n5ter/nsh", rev = "750fdf8b14b3eacaf2aa3ed75b282735dded6f7e" }
-nsh-platform-gpui = { git = "https://github.com/M4n5ter/nsh", rev = "750fdf8b14b3eacaf2aa3ed75b282735dded6f7e", default-features = false, features = ["js"] }
-nsh-gpui-terminal = { git = "https://github.com/M4n5ter/nsh", rev = "750fdf8b14b3eacaf2aa3ed75b282735dded6f7e" }
-nsh-bundler = { git = "https://github.com/M4n5ter/nsh", rev = "750fdf8b14b3eacaf2aa3ed75b282735dded6f7e", default-features = false }
-nsh-base = { git = "https://github.com/M4n5ter/nsh", rev = "750fdf8b14b3eacaf2aa3ed75b282735dded6f7e" }
+nsh-core = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
+nsh-engine = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
+nsh-platform-tui = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
+nsh-platform-gpui = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042", default-features = false, features = ["js"] }
+nsh-gpui-terminal = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
+nsh-bundler = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042", default-features = false }
+nsh-base = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
maka-agent-facts = { path = "plugins/agent/facts" }
maka-packages = { path = "platform/packages" }
maka-agent-ports = { path = "plugins/agent/ports" }
@@ -216,8 +216,8 @@ gpui-fast-macos = { path = "third-party/gpui-fast/crates/gpui_macos" }
gpui-fast-windows = { path = "third-party/gpui-fast/crates/gpui_windows" }
gpui-fast-linux = { path = "third-party/gpui-fast/crates/gpui_linux" }
# The terminal backend owns the offscreen renderer and image codec.
-gpui-fast-wgpu = { git = "https://github.com/M4n5ter/nsh", rev = "750fdf8b14b3eacaf2aa3ed75b282735dded6f7e" }
-icy_sixel = { git = "https://github.com/M4n5ter/nsh", rev = "750fdf8b14b3eacaf2aa3ed75b282735dded6f7e" }
+gpui-fast-wgpu = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
+icy_sixel = { git = "https://github.com/M4n5ter/nsh", rev = "01ce8f98569d97d68a576abed5719c820fbdb042" }
gpui-pre-scheduler = { path = "third-party/gpui-fast/crates/scheduler" }
[patch."https://github.com/longbridge/gpui-kit"]
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 07/22: terminal: Make the NSH shell the sole default terminal interface
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 3ecd15a7d9adb4acb40cbde521e25bb45158c9f7
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 16:39:48 2026 +0800
terminal: Make the NSH shell the sole default terminal interface
---
Cargo.toml | 2 -
README.md | 2 +-
README.zh-CN.md | 2 +-
packages/cli/README.md | 2 +-
packages/cli/README.zh-CN.md | 2 +-
platform/README.md | 6 +-
platform/README.zh-CN.md | 6 +-
platform/render-terminal/Cargo.toml | 66 -
platform/render-terminal/README.md | 36 -
platform/render-terminal/README.zh-CN.md | 36 -
platform/render-terminal/src/application.rs | 1625 ------------
.../render-terminal/src/application/activation.rs | 334 ---
.../render-terminal/src/application/adaptive.rs | 329 ---
platform/render-terminal/src/application/border.rs | 124 -
.../render-terminal/src/application/checkpoint.rs | 144 --
platform/render-terminal/src/application/choice.rs | 407 ---
.../src/application/context_menu.rs | 587 -----
platform/render-terminal/src/application/dialog.rs | 843 -------
platform/render-terminal/src/application/editor.rs | 380 ---
platform/render-terminal/src/application/file.rs | 778 ------
.../src/application/file/browser.rs | 652 -----
.../render-terminal/src/application/file/paths.rs | 181 --
platform/render-terminal/src/application/focus.rs | 510 ----
.../render-terminal/src/application/geometry.rs | 142 --
platform/render-terminal/src/application/input.rs | 437 ----
platform/render-terminal/src/application/layout.rs | 447 ----
.../src/application/markdown_text.rs | 96 -
.../render-terminal/src/application/morph_mark.rs | 164 --
.../src/application/morph_mark/geometry.rs | 207 --
.../src/application/morph_mark/render.rs | 140 --
.../render-terminal/src/application/mutations.rs | 231 --
platform/render-terminal/src/application/native.rs | 138 -
platform/render-terminal/src/application/paint.rs | 2363 -----------------
.../render-terminal/src/application/pointer.rs | 79 -
.../render-terminal/src/application/reading.rs | 910 -------
.../src/application/reading/find.rs | 251 --
.../render-terminal/src/application/resources.rs | 888 -------
platform/render-terminal/src/application/rows.rs | 636 -----
platform/render-terminal/src/application/run.rs | 881 -------
platform/render-terminal/src/application/scene.rs | 310 ---
platform/render-terminal/src/application/scroll.rs | 478 ----
.../render-terminal/src/application/selection.rs | 682 -----
.../src/application/selection/keyboard.rs | 295 ---
.../src/application/selection/layout.rs | 236 --
platform/render-terminal/src/application/source.rs | 921 -------
.../render-terminal/src/application/source/diff.rs | 319 ---
.../src/application/source/reveal.rs | 143 --
.../src/application/subscriptions.rs | 154 --
.../render-terminal/src/application/terminal.rs | 275 --
.../src/application/terminal/input.rs | 174 --
platform/render-terminal/src/application/tests.rs | 1541 ------------
.../src/application/tests/activation.rs | 746 ------
.../src/application/tests/adaptive.rs | 339 ---
.../src/application/tests/continuations.rs | 332 ---
.../src/application/tests/driver.rs | 111 -
.../render-terminal/src/application/tests/input.rs | 1180 ---------
.../src/application/tests/layout.rs | 1739 -------------
.../src/application/tests/reading.rs | 1014 --------
.../src/application/tests/reading/choice.rs | 232 --
.../src/application/tests/reading/context_menu.rs | 186 --
.../src/application/tests/reading/dialog.rs | 837 -------
.../src/application/tests/reading/find.rs | 482 ----
.../src/application/tests/reading/items.rs | 855 -------
.../src/application/tests/reading/keyboard.rs | 218 --
.../src/application/tests/reading/markdown.rs | 591 -----
.../src/application/tests/reconciliation.rs | 776 ------
.../src/application/tests/retained.rs | 490 ----
.../render-terminal/src/application/tests/rows.rs | 812 ------
.../src/application/tests/source.rs | 321 ---
.../src/application/tests/terminal.rs | 296 ---
.../src/application/tests/textarea.rs | 2642 --------------------
.../render-terminal/src/application/text_file.rs | 157 --
.../render-terminal/src/application/textarea.rs | 816 ------
.../src/application/textarea/clipboard.rs | 439 ----
.../src/application/textarea/paste.rs | 927 -------
platform/render-terminal/src/editor.rs | 906 -------
platform/render-terminal/src/editor/layout.rs | 208 --
.../render-terminal/src/editor/presentation.rs | 723 ------
platform/render-terminal/src/lib.rs | 26 -
platform/render-terminal/src/markdown.rs | 870 -------
platform/render-terminal/src/markdown/diff.rs | 295 ---
platform/render-terminal/src/markdown/fenced.rs | 193 --
platform/render-terminal/src/markdown/palette.rs | 87 -
platform/render-terminal/src/markdown/prepared.rs | 150 --
.../src/markdown/prepared/cursor.rs | 378 ---
.../src/markdown/prepared/cursor/checkpoints.rs | 75 -
.../src/markdown/prepared/cursor/seeking.rs | 142 --
.../src/markdown/prepared/operations.rs | 261 --
.../src/markdown/prepared/seeking.rs | 105 -
.../src/markdown/prepared/tables.rs | 274 --
.../src/markdown/prepared/tables/measure.rs | 134 -
.../src/markdown/prepared/tables/seeking.rs | 121 -
.../src/markdown/prepared/tables/window.rs | 214 --
.../render-terminal/src/markdown/prepared/tests.rs | 334 ---
.../src/markdown/prepared/tests/budget.rs | 98 -
platform/render-terminal/src/markdown/syntax.rs | 333 ---
platform/render-terminal/src/markdown/table.rs | 471 ----
platform/render-terminal/src/markdown/wrap.rs | 140 --
platform/render-terminal/src/startup.rs | 318 ---
platform/render-terminal/src/terminal.rs | 444 ----
plugins/agent/native/Cargo.toml | 3 -
products/maka/bundle/README.md | 24 +-
products/maka/bundle/README.zh-CN.md | 24 +-
products/maka/cli/Cargo.toml | 5 +-
products/maka/cli/README.md | 6 +-
products/maka/cli/README.zh-CN.md | 6 +-
products/maka/cli/src/application.rs | 168 +-
products/maka/cli/src/application/product.rs | 2 +-
.../maka/cli/src/application/product/launch.rs | 226 +-
.../cli/src/application/product/launch/tests.rs | 66 +-
.../maka/cli/src/application/product/restore.rs | 6 +-
.../maka/cli/src/application/product/update.rs | 26 +-
.../cli/src/application/product/update/tests.rs | 80 +-
products/maka/cli/src/args.rs | 55 +-
products/maka/cli/tests/integration/main.rs | 1 -
products/maka/cli/tests/integration/terminal.rs | 378 ---
.../cli/tests/integration/terminal/application.rs | 718 ------
.../tests/integration/terminal/bundled_product.rs | 1217 ---------
.../integration/terminal/bundled_product/chat.rs | 585 -----
.../terminal/bundled_product/chat/attachments.rs | 190 --
.../terminal/bundled_product/chat/code_mode.rs | 226 --
.../terminal/bundled_product/chat/compaction.rs | 289 ---
.../terminal/bundled_product/chat/computer.rs | 197 --
.../terminal/bundled_product/chat/followup.rs | 157 --
.../terminal/bundled_product/chat/goal.rs | 170 --
.../terminal/bundled_product/chat/graph.rs | 352 ---
.../terminal/bundled_product/chat/health.rs | 254 --
.../terminal/bundled_product/chat/performance.rs | 479 ----
.../bundled_product/chat/performance/parity.rs | 820 ------
.../terminal/bundled_product/chat/plan.rs | 169 --
.../terminal/bundled_product/chat/recap.rs | 183 --
.../terminal/bundled_product/chat/title.rs | 148 --
.../terminal/bundled_product/chat/todo.rs | 135 -
.../terminal/bundled_product/chat/trace.rs | 207 --
.../terminal/bundled_product/host_logs.rs | 144 --
.../maka/cli/tests/integration/terminal/frames.rs | 83 -
.../integration/terminal/product/executor-ui.js | 118 -
.../tests/integration/terminal/product/executor.rs | 290 ---
.../integration/terminal/product/executor_setup.rs | 339 ---
.../maka/cli/tests/integration/terminal_shell.rs | 51 +-
products/maka/terminal-shell/README.md | 4 +-
products/maka/terminal-shell/README.zh-CN.md | 4 +-
products/maka/terminal-shell/src/entry.rs | 4 +-
products/maka/terminal-shell/tests/support/pty.rs | 2 +-
scripts/asf-license-headers.test.mjs | 4 +-
scripts/pr-effort.test.mjs | 2 +-
scripts/rust/README.md | 53 +-
scripts/rust/README.zh-CN.md | 42 +-
scripts/rust/benchmark-startup.py | 208 --
scripts/rust/benchmark-tui-parity.py | 609 -----
.../references/testing-and-shipping.md | 8 +-
151 files changed, 158 insertions(+), 53809 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 1adbff48dc..0382775237 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -73,7 +73,6 @@ members = [
"plugins/skills/core",
"plugins/agent/tool-catalog",
"plugins/agent/tools",
- "platform/render-terminal",
"platform/ui-runtime",
"platform/ui-js",
"plugins/workspace/workspace-web/native",
@@ -171,7 +170,6 @@ maka-session-title = { path = "plugins/agent/session-title" }
maka-skills = { path = "plugins/skills/core" }
maka-tool-catalog = { path = "plugins/agent/tool-catalog" }
maka-tools = { path = "plugins/agent/tools" }
-maka-render-terminal = { path = "platform/render-terminal" }
maka-terminal-shell = { path = "products/maka/terminal-shell" }
maka-ui-runtime = { path = "platform/ui-runtime" }
maka-ui-js = { path = "platform/ui-js" }
diff --git a/README.md b/README.md
index d57c6bbb89..5d3d1d5e8e 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ just setup
just run
```
-`just run` opens the terminal product. First launch confirms the current workspace and installs the bundled tools and skills; later launches reopen the saved application. Use `just run --help` for CLI commands. `just build --release` produces `target/release/maka`, `maka-desktop-shell` and the adjacent `app/` directory (executables use `.exe` on Windows; on macOS the desktop shell and app are staged in `Maka.app`). The build entry supports `--release` and `--target`. Node.js bundles prov [...]
+`just run` opens the [NSH terminal workbench](products/maka/terminal-shell/README.md), the default `maka` interface. It discovers or starts the Host at `~/.maka/application/host`; `--root PATH` selects another Host and `--session ID` opens an existing conversation. Use `just run --help` for CLI commands. `just build --release` produces `target/release/maka`, `maka-desktop-shell` and the adjacent `app/` directory (executables use `.exe` on Windows; on macOS the desktop shell and app are s [...]
In WSL, `just run` builds the CLI without the local `native-desktop` capability or its Linux X11 dependencies; Computer Use selects the Windows desktop by default. Source builds need `MAKA_CUA_WINDOWS_EXECUTABLE` set to the absolute WSL-visible path of a matching Windows `maka.exe`. `MAKA_CUA_BUILD_MODE=auto|native|bridge` controls this development build selection. See [Computer Use](platform/computer-use/README.md) for setup. `just build` and release packaging still build the full nativ [...]
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 3ef3830c04..7cc1b53d67 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -36,7 +36,7 @@ just setup
just run
```
-`just run` 打开终端产品,首次确认当前工作目录并安装内置工具与技能,后续重开已保存的应用。`just run --help` 查看 CLI 命令。`just build --release` 生成 `target/release/maka`、`maka-desktop-shell` 及相邻的 `app/` 目录,Windows 可执行文件使用 `.exe` 后缀,macOS 上桌面 shell 和 app 放在 `Maka.app` 中。构建入口支持 `--release` 和 `--target`。Node.js 在构建时为 CLI 打包提供商 SDK,CLI 在内嵌 V8 中运行 JavaScript,桌面 shell 通过 QuickJS 执行 app。用 `just run application run --root PATH --deployment UUID --connections FILE` 启动已校验的终端组合,编写和安装方式见[普通 Agent 产品](products/maka/bundle/README.zh-CN.md)。
+`just run` 打开 [NSH 终端工作台](products/maka/terminal-shell/README.zh-CN.md),即 `maka` 的默认界面。它自动发现或启动 `~/.maka/application/host` 的 Host;`--root PATH` 指定其他 Host,`--session ID` 打开已有会话。`just run --help` 查看 CLI 命令。`just build --release` 生成 `target/release/maka`、`maka-desktop-shell` 及相邻的 `app/` 目录,Windows 可执行文件使用 `.exe` 后缀,macOS 上桌面 shell 和 app 放在 `Maka.app` 中。构建入口支持 `--release` 和 `--target`。Node.js 在构建时为 CLI 打包提供商 SDK,CLI 在内嵌 V8 中运行 JavaScript,桌面 shell 通过 QuickJS 执行 app。编写和安装方式见[产品指南](products/mak [...]
在 WSL 中,`just run` 构建不含本地 `native-desktop` 能力及其 Linux X11 依赖的 CLI,Computer Use 默认选择 Windows 桌面。源码构建需通过 `MAKA_CUA_WINDOWS_EXECUTABLE` 指定匹配的 Windows `maka.exe`,使用 WSL 可见的绝对路径。`MAKA_CUA_BUILD_MODE=auto|native|bridge` 控制开发构建选择,设置方法见 [Computer Use](platform/computer-use/README.zh-CN.md)。`just build` 和发布打包仍构建完整原生产品,包含桌面 shell 及其 Linux GUI 依赖。
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 505b0bfbe9..58f90b9980 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -31,7 +31,7 @@ The launcher selects the matching native executable and forwards arguments, stan
The `latest` tags remain empty placeholders, including the separate `maka-agent` package. They contain no CLI, runtime, dependencies or install scripts. The placeholder source lives in [placeholder](placeholder/README.md); preview artifacts must stay on `rust-preview`.
-Run `maka` to start the terminal product. First launch confirms the workspace; later launches reopen the saved installation. See [the product guide](../../products/maka/bundle/README.md) for configuration and explicit composition commands.
+Run `maka` to open the NSH terminal workbench. It discovers or starts the Host at `~/.maka/application/host`; use `--root HOST_ROOT` to select another Host and `--session SESSION_ID` to open an existing conversation. `maka --help` lists automation commands. See [the product guide](../../products/maka/bundle/README.md) for composition and installation commands.
Launch the desktop with `maka desktop --root HOST_ROOT`. This required path selects the business Host: a missing Root gets the standard read-only Host, and an existing Root reopens its saved deployment. The launcher keeps its verified Host client until the shell exits; other connected clients keep the Host alive. The packaged shell reads its adjacent app independently of the working directory. See [desktop-shell](../../products/maka/desktop-shell/README.md) for direct development commands.
diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md
index a2839d96b7..368ffbc8e2 100644
--- a/packages/cli/README.zh-CN.md
+++ b/packages/cli/README.zh-CN.md
@@ -31,7 +31,7 @@ npm install --global @maka-agent/cli@rust-preview
`latest` 标签仍为空占位包,包括独立的 `maka-agent` 包;它们没有 CLI、运行时、依赖或安装脚本。占位源码见 [placeholder](placeholder/README.md),预览产物必须使用 `rust-preview`。
-运行 `maka` 启动终端产品。首次启动确认工作区,之后重新打开保存的安装。配置和显式组合命令见[产品指南](../../products/maka/bundle/README.zh-CN.md)。
+运行 `maka` 打开 NSH 终端工作台,自动发现或启动 `~/.maka/application/host` 的 Host。`--root HOST_ROOT` 指定其他 Host,`--session SESSION_ID` 打开已有会话。`maka --help` 列出自动化命令。组合和安装方式见[产品指南](../../products/maka/bundle/README.zh-CN.md)。
通过 `maka desktop --root HOST_ROOT` 启动桌面。这个必填路径选择业务 Host:缺失的 Root 初始化为标准只读 Host,已有 Root 使用已保存的部署。启动器在 shell 退出前保留经过校验的 Host 客户端;其他已连接客户端会继续保留 Host。打包的 shell 从可执行文件旁加载 app,不依赖工作目录。直接开发命令见 [desktop-shell](../../products/maka/desktop-shell/README.zh-CN.md)。
diff --git a/platform/README.md b/platform/README.md
index 42f650a0ff..6650e48531 100644
--- a/platform/README.md
+++ b/platform/README.md
@@ -29,10 +29,10 @@ The platform loads packages, checks compositions, owns execution resources and p
| Resource authority and persistence | [capabilities](capabilities/README.md), [event-log](event-log/README.md) |
| Service and component owners | [host-runtime](host-runtime/README.md), [ui-runtime](ui-runtime/README.md) |
| JavaScript execution | [js-runtime](js-runtime/README.md), [host-js](host-js/README.md), [ui-js](ui-js/README.md) |
-| Native presentation | [render-terminal](render-terminal/README.md), [presentation](presentation/README.md) |
+| Native presentation | [presentation](presentation/README.md) |
| I/O and isolation | [network](network/README.md), [process](process/README.md), [sandbox](sandbox/README.md), [computer-use](computer-use/README.md) |
| Connections and health | [transport](transport/README.md), [background-health](background-health/README.md) |
-Host and UI share the plugin lifecycle kernel. Optional execution adapters run JavaScript plugins. The terminal renderer consumes committed frames and owns native resources; it does not choose product pages. The desktop application lives in [desktop-shell](../products/maka/desktop-shell/README.md) and connects through public Host services.
+Host and UI share the plugin lifecycle kernel. Optional execution adapters run JavaScript plugins. The [terminal workbench](../products/maka/terminal-shell/README.md) uses NSH for native terminal rendering and connects through public Host services. The desktop application lives in [desktop-shell](../products/maka/desktop-shell/README.md) and connects through public Host services.
-The dependency boundary is deliberate: Host, UI and the terminal renderer can compile without V8 or the Agent engine. Some platform code still depends on shared Agent facts for storage and presentation. Those data dependencies are visible in the Cargo manifests.
+The dependency boundary is deliberate: Host and UI lifecycle crates can compile without V8 or the Agent engine. Some platform code still depends on shared Agent facts for storage and presentation. Those data dependencies are visible in the Cargo manifests.
diff --git a/platform/README.zh-CN.md b/platform/README.zh-CN.md
index 555ce5a2ed..a38bd3e763 100644
--- a/platform/README.zh-CN.md
+++ b/platform/README.zh-CN.md
@@ -29,10 +29,10 @@
| 资源权限与持久化 | [capabilities](capabilities/README.zh-CN.md)、[event-log](event-log/README.zh-CN.md) |
| 服务与组件所有者 | [host-runtime](host-runtime/README.zh-CN.md)、[ui-runtime](ui-runtime/README.zh-CN.md) |
| JavaScript 执行 | [js-runtime](js-runtime/README.zh-CN.md)、[host-js](host-js/README.zh-CN.md)、[ui-js](ui-js/README.zh-CN.md) |
-| 原生呈现 | [render-terminal](render-terminal/README.zh-CN.md)、[presentation](presentation/README.zh-CN.md) |
+| 原生呈现 | [presentation](presentation/README.zh-CN.md) |
| I/O 与隔离 | [network](network/README.zh-CN.md)、[process](process/README.zh-CN.md)、[sandbox](sandbox/README.zh-CN.md)、[computer-use](computer-use/README.zh-CN.md) |
| 连接与健康状态 | [transport](transport/README.zh-CN.md)、[background-health](background-health/README.zh-CN.md) |
-Host 和 UI 共用插件生命周期内核,通过可选的执行适配器运行 JavaScript 插件。终端渲染器消费已提交帧、管理原生资源,不选择产品页面。桌面应用位于 [desktop-shell](../products/maka/desktop-shell/README.zh-CN.md),通过公共 Host 服务连接。
+Host 和 UI 共用插件生命周期内核,通过可选的执行适配器运行 JavaScript 插件。[终端工作台](../products/maka/terminal-shell/README.zh-CN.md) 通过 NSH 完成原生终端渲染,并使用公共 Host 服务。桌面应用位于 [desktop-shell](../products/maka/desktop-shell/README.zh-CN.md),通过公共 Host 服务连接。
-依赖边界是明确的:Host、UI 和终端渲染器无需 V8 或 Agent 引擎即可编译。部分平台代码仍依赖存储与呈现所需的 Agent 共用事实类型;这些数据依赖明确列在 Cargo 配置中。
+依赖边界是明确的:Host 与 UI 生命周期 crate 无需 V8 或 Agent 引擎即可编译。部分平台代码仍依赖存储与呈现所需的 Agent 共用事实类型;这些数据依赖明确列在 Cargo 配置中。
diff --git a/platform/render-terminal/Cargo.toml b/platform/render-terminal/Cargo.toml
deleted file mode 100644
index 7475b862af..0000000000
--- a/platform/render-terminal/Cargo.toml
+++ /dev/null
@@ -1,66 +0,0 @@
-# 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.
-
-[package]
-name = "maka-render-terminal"
-version.workspace = true
-edition.workspace = true
-license.workspace = true
-publish.workspace = true
-
-[dependencies]
-maka-packages.workspace = true
-maka-capabilities.workspace = true
-maka-ui-runtime.workspace = true
-maka-plugin-core.workspace = true
-base64 = "0.22.1"
-uuid.workspace = true
-maka-event-log.workspace = true
-maka-transport.workspace = true
-ratatui.workspace = true
-ratatui-crossterm = { version = "0.1.2", default-features = false, features = ["unstable-backend-writer"] }
-pulldown-cmark.workspace = true
-apply-patch.workspace = true
-similar = "3.2.0"
-syntect = { version = "5.3.0", default-features = false, features = ["default-syntaxes", "regex-fancy"] }
-# Level-triggered Unix input retains unread keys when SIGWINCH is also ready.
-# The default mio source can return a resize while dropping the other readiness.
-crossterm = { workspace = true, features = ["use-dev-tty", "event-stream"] }
-futures-util.workspace = true
-tokio.workspace = true
-serde_json.workspace = true
-serde.workspace = true
-schemars.workspace = true
-libc.workspace = true
-unicode-segmentation.workspace = true
-unicode-linebreak = "0.1.5"
-unicode-width.workspace = true
-url = "2.5.8"
-image = { version = "0.25", default-features = false, features = ["png", "tiff", "bmp"] }
-
-[target.'cfg(any(target_os = "macos", target_os = "linux", windows))'.dependencies]
-clipboard-rs = { version = "0.3.5", features = ["wayland"] }
-
-[target.'cfg(windows)'.dependencies]
-clipboard-win = "5.4.1"
-
-[dev-dependencies]
-maka-ui-js.workspace = true
-
-tempfile.workspace = true
-tokio-util.workspace = true
-tokio = { workspace = true, features = ["test-util"] }
diff --git a/platform/render-terminal/README.md b/platform/render-terminal/README.md
deleted file mode 100644
index 3744737ec6..0000000000
--- a/platform/render-terminal/README.md
+++ /dev/null
@@ -1,36 +0,0 @@
-<!--
- 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.
--->
-
-# maka-render-terminal
-
-[简体中文](README.zh-CN.md)
-
-Presents checked UI compositions in a terminal using Ratatui and Crossterm. `application` drives input, committed frames, native editors and teardown; `markdown` renders text content. The product supplies its composition and services. This renderer does not select Agent pages or embed JavaScript execution.
-
-[Source](src/lib.rs) · [Cargo manifest](Cargo.toml)
-
-The foreground loop wakes for input, work completion and actual visible deadlines. Input, changed recipes and native observations request a frame; visible animations and elapsed labels schedule their next frame. An unchanged view does not repeat layout, buffer diffing or terminal output.
-
-Each native owner retains measurements and relative stack geometry for pure nodes in its immutable scene recipe. Queries are retained on demand for the current and preceding actual measurement pass; unused constraints retire instead of accumulating resize history. Replacing the recipe releases its memo. Components and adaptive panes keep dynamic measurement. Derived geometry does not consume native input or publication capacity; cache misses use ordinary layout. Native editors retain the [...]
-
-Multiline editors accept complete representable text and retain the newest undo step even when it exceeds the older-history retention target. Clipboard text is admitted against its actual returned allocation through the shared native budget; the platform clipboard getter performs that allocation internally. Captured snapshots retain the original bytes independently of later native edits.
-
-The sole bounded output owner writes complete ANSI buffers without blocking foreground input. Encoding advances Ratatui's diff baseline; the foreground issues no further diff until that buffer has been fully written and flushed. Only that completion promotes the corresponding hit geometry; it is not a terminal display acknowledgement. A partially written buffer cannot be discarded. If a newer business frame commits meanwhile, the written older frame supplies only the actual screen geomet [...]
-
-Required fallible native preparation precedes atomic logical publication. Each mount fixes its native recipe implementation type; implementation changes use a new child key/remount. Pure text parsing runs as bounded background preparation, and rejected or obsolete results preserve the committed document. Native observations use the shared bounded publication lifecycle; enqueueing does not acknowledge a raw command receipt.
diff --git a/platform/render-terminal/README.zh-CN.md b/platform/render-terminal/README.zh-CN.md
deleted file mode 100644
index 7fef1a32ea..0000000000
--- a/platform/render-terminal/README.zh-CN.md
+++ /dev/null
@@ -1,36 +0,0 @@
-<!--
- 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.
--->
-
-# maka-render-terminal
-
-[English](README.md)
-
-使用 Ratatui 和 Crossterm 在终端呈现经过校验的 UI 组合。`application` 驱动输入、已提交帧、原生编辑器和关闭流程,`markdown` 负责文本渲染。组合与服务由产品提供;渲染器不选择 Agent 页面,也不内嵌 JavaScript 执行。
-
-前台按输入、工作完成和实际可见的截止时间唤醒。输入、recipe 变化与原生观察请求新帧;可见动画和耗时标签安排下一帧。不变的画面不重复布局、缓冲区差分或终端输出。
-
-各原生所有者为不可变场景 recipe 中的纯节点保留测量结果和堆叠布局的相对几何。查询按需保留,范围限于当前及前一次实际测量过程的工作集;未使用的约束随过程退休,不累积历史窗口宽度。替换 recipe 会释放其缓存;组件和自适应面板仍动态测量。派生几何不占原生输入和发布容量,缓存未命中时使用普通布局。原生编辑器保留正文和滚动条探宽使用的两份文本布局。这些缓存不发布输入几何,也不绕过原有写入确认检查。
-
-多行编辑器接收完整、可表示的文本,即使最新编辑超过旧历史的保留目标,也保留这一步撤销。剪贴板文本按实际返回的分配大小通过共享原生预算准入;平台剪贴板读取器在内部完成该分配。捕获的快照保留原始字节,不受后续原生编辑影响。
-
-唯一且有界的输出所有者写入完整 ANSI 缓冲区,不阻塞前台输入。编码时 Ratatui 的差分基线已经推进;该缓冲区全部写完并刷新之前,前台不会生成下一次差分。只有写入完成才提升对应的命中几何,这不等于终端已经显示的确认。部分写入的缓冲区不能丢弃。若期间已有更新的业务帧提交,旧写完帧只提供实际屏幕几何,不能恢复已退役回调,也不能清除新帧的焦点、打开确认或重绘需求。陈旧业务输入按原身份拒绝。
-
-必要且可失败的原生准备先于逻辑原子发布。挂载固定一种原生 recipe 实现类型,切换实现通过新子键或重新挂载。纯文本解析作为有界后台准备执行,拒绝或过时的结果保留已提交文档。原生观察复用有界发布生命周期;入队不代表原命令回执已确认。
-
-[源码](src/lib.rs) · [Cargo 配置](Cargo.toml)
diff --git a/platform/render-terminal/src/application.rs b/platform/render-terminal/src/application.rs
deleted file mode 100644
index 0f039a4b6e..0000000000
--- a/platform/render-terminal/src/application.rs
+++ /dev/null
@@ -1,1625 +0,0 @@
-/*
- * 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 terminal presentation of the shared plugin application kernel.
-//! 统一插件应用内核的原生终端呈现器。
-mod activation;
-mod adaptive;
-mod border;
-mod checkpoint;
-mod choice;
-mod context_menu;
-mod dialog;
-mod editor;
-mod file;
-mod focus;
-mod geometry;
-mod input;
-mod layout;
-mod markdown_text;
-mod morph_mark;
-mod native;
-mod paint;
-mod pointer;
-mod reading;
-mod resources;
-mod rows;
-mod run;
-mod scene;
-mod scroll;
-mod selection;
-mod source;
-mod terminal;
-mod text_file;
-mod textarea;
-use crossterm::event::{
- Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
-};
-use futures_util::{Stream, stream::FuturesUnordered};
-pub use geometry::{CellRect, Placement};
-use maka_packages::Package;
-use maka_plugin_core::{
- composition::{Input, PresentationTarget, RootKind},
- runtime::Mount,
-};
-use maka_ui_runtime::{
- Application, Error, NativeRegistry,
- actions::{ActionTask, TaskCompletion},
-};
-pub use native::registry;
-pub use paint::{PaintedScene, Slots, sanitize};
-use ratatui::buffer::Buffer;
-#[cfg(test)]
-use ratatui::layout::Rect;
-pub use run::run;
-pub use scene::{Direction as TerminalDirection, Layout as TerminalLayout, Node as TerminalScene};
-use serde_json::{Value, json};
-use std::{future::Future, pin::Pin};
-use uuid::Uuid;
-
-/// Trusted immutable recipe; resources belong only to its separately retained
-/// `create` result. Measurement/paint stay pure; native input and command hooks
-/// run against the original presenter-owned state, never a replacement frame.
-/// 可信不可变 recipe;资源由独立原生 owner 持有,绘制不创建资源或执行命令。
-pub trait Widget: std::any::Any {
- /// Prove the complete immutable presentation recipe unchanged across a
- /// business revision. This permits an actual resize flush after native
- /// observations, while owner identity and opening admission stay separate.
- /// The original declarative scene, when this recipe uses the scene language.
- /// Deferred foreground painting resolves the original scene declaration.
- fn scene(&self) -> Option<&TerminalScene> {
- None
- }
- /// Interrupt an original pointer gesture without changing text or selection.
- fn cancel_pointer(&self, _: &mut dyn std::any::Any) {}
- /// Local pointer feedback against the last presented target. Leaving clears
- /// feedback; hovering never selects, focuses, or invokes guest code.
- /// Return whether native feedback changed and needs another paint.
- fn hover(
- &self,
- _: &mut dyn std::any::Any,
- _: &str,
- _: Option<ratatui::layout::Position>,
- ) -> bool {
- false
- }
- fn same_presentation(&self, _: &dyn std::any::Any) -> bool {
- false
- }
- /// Immutable root constraints participate in the parent's original cell flow.
- fn layout(&self, _: &Slots<'_>) -> maka_ui_runtime::Result<TerminalLayout> {
- Ok(TerminalLayout::default())
- }
- fn create(&self, _: NativeContext) -> Box<dyn std::any::Any> {
- Box::new(())
- }
- /// Upper bounds for reliable outcomes introduced by this candidate. Capacity
- /// is admitted before commit; original command receipts consume it in order.
- fn publication_capacity(&self, _: &dyn std::any::Any) -> Vec<(&'static str, usize)> {
- Vec::new()
- }
- fn commit(&self, _: &mut dyn std::any::Any) -> maka_ui_runtime::Result<Option<NativeInput>> {
- Ok(None)
- }
- /// Reconcile focus eligibility from the committed structural route. This
- /// cannot publish geometry, take focus, or consume a presentation offer.
- fn prepare_focus(&self, _: &mut dyn std::any::Any, _: bool, _: u64) {}
- /// Consume this exact eligible target after a successful flush and current
- /// original frame/owner admission. Geometry alone cannot consume an offer.
- /// Compact selectors may return to the original outer typing target.
- fn returns_focus(&self) -> bool {
- false
- }
- /// Exclusive native keyboard interaction, such as an embedded terminal.
- fn captures_keyboard(&self, _: &dyn std::any::Any) -> bool {
- false
- }
- /// Deliver all physical mouse buttons and motion to this native target.
- fn raw_pointer(&self, _: &dyn std::any::Any) -> bool {
- false
- }
- /// A native transient selector can consume Tab without acquiring focus-return ownership.
- fn handles_tab(&self, _: &dyn std::any::Any, _: &str) -> bool {
- false
- }
- /// A physical keyboard traversal enters this target. Returning false keeps
- /// the previous focus; restoration and ordinary paints do not call this.
- fn enter_focus(&self, _: &mut dyn std::any::Any, _: &str) -> bool {
- true
- }
- fn take_focus_exit(&self, _: &mut dyn std::any::Any) -> bool {
- false
- }
- fn present_focus(&self, _: &mut dyn std::any::Any) -> bool {
- false
- }
- /// Poll original native observations independently of recipe revisions.
- /// Confirm only an observation adopted by the original logical commit.
- fn published(&self, _: &mut dyn std::any::Any, _: &str, _: &Value) {}
- fn observe(&self, _: &mut dyn std::any::Any) -> Option<NativeInput> {
- None
- }
- fn input(
- &self,
- _: &mut dyn std::any::Any,
- _: &str,
- _: &Event,
- ) -> maka_ui_runtime::Result<WidgetInput> {
- Ok(WidgetInput::Unhandled)
- }
- fn pointer_capture(&self, _: &dyn std::any::Any) -> bool {
- false
- }
- fn take_redraw(&self, _: &mut dyn std::any::Any) -> bool {
- false
- }
- fn cancel_paste(&self, _: &mut dyn std::any::Any) {}
- fn editing_menu(
- &self,
- _: &dyn std::any::Any,
- ) -> Option<(
- maka_ui_runtime::textarea::TextareaMenu,
- maka_ui_runtime::editor::EditorSnapshot,
- [bool; 4],
- )> {
- None
- }
- fn copy(&self, _: &dyn std::any::Any, _: bool) -> maka_ui_runtime::Result<Option<String>> {
- Ok(None)
- }
- fn capture(
- &self,
- _: &dyn std::any::Any,
- _: &maka_ui_runtime::actions::CaptureTarget,
- ) -> maka_ui_runtime::Result<std::sync::Arc<dyn maka_capabilities::call::ReadResource>> {
- Err(Error::Invalid("terminal widget capture unavailable".into()))
- }
- /// Called only after a successful native flush. The original resource queue
- /// retains an observation until the ordinary component callback accepts it.
- fn present(&self, _: &mut dyn std::any::Any, _: Box<dyn std::any::Any>) -> Option<NativeInput> {
- None
- }
- fn capture_continuation(
- &self,
- _: &dyn std::any::Any,
- _: &maka_ui_runtime::actions::CaptureTarget,
- ) -> maka_ui_runtime::Result<maka_ui_runtime::actions::ContinuationWitness> {
- Err(Error::Invalid(
- "terminal widget continuation unavailable".into(),
- ))
- }
- /// Observe only: no input dispatch or presentation mutation during admission.
- fn validate_continuation(
- &self,
- _: &dyn std::any::Any,
- _: &maka_ui_runtime::actions::CaptureTarget,
- _: &maka_ui_runtime::actions::ContinuationWitness,
- ) -> maka_ui_runtime::Result<bool> {
- Ok(false)
- }
- /// Successful presentation may suspend physical input and geometry while
- /// retaining the editor, capture and pending command observations.
- fn presentation(&self, _: &mut dyn std::any::Any, _: bool) {}
- fn deactivate(&self, _: &mut dyn std::any::Any) {}
- fn measure(&self, slots: &Slots<'_>, width: u16) -> maka_ui_runtime::Result<(u16, u32)>;
- /// Logical traversal includes fully clipped placements; writes and physical
- /// targets must honor Placement.clip. Only successful flush admits geometry.
- fn paint(
- &self,
- slots: &mut Slots<'_>,
- area: Placement,
- buffer: &mut Buffer,
- ) -> maka_ui_runtime::Result<()>;
-}
-/// Original renderer custody. Workers retain tickets until their real resources
-/// settle; every owner shares its presenter's aggregate native budget.
-#[derive(Clone)]
-pub struct NativeContext {
- pub checkpoint: Option<maka_ui_runtime::checkpoint::Scope>,
- pub resources: std::sync::Arc<maka_capabilities::call::Resources>,
- pub budget: maka_ui_runtime::budget::NativeBudget,
-}
-
-/// Owner-originated observation; never a guest-created native resource.
-#[derive(Clone, PartialEq)]
-pub struct NativeInput {
- /// Only complete replaceable evidence may supersede another snapshot.
- pub replaceable: bool,
- pub key: String,
- pub value: Value,
-}
-pub enum WidgetInput {
- ExitFocus,
- Unhandled,
- /// Native editing yields a navigation gesture to its declared focus scope.
- Navigate,
- Consumed(Option<NativeInput>),
- /// Handled pointer work that preserves the original keyboard focus.
- PreserveFocus,
- /// An immediate business event; never replaces retryable native observations.
- Emitted(NativeInput),
- /// A physical native submission with an original, revision-checked default.
- SubmitCopy(NativeInput, maka_ui_runtime::input::SubmitCopy),
-}
-enum Completion {
- Action(Uuid, Box<TaskCompletion>),
- Input {
- mount: Mount,
- result: maka_ui_runtime::Result<()>,
- copy: Option<(paint::TargetIdentity, ClipboardCopy)>,
- },
-}
-enum ClipboardCopy {
- Button(std::rc::Rc<str>),
- Submit(maka_ui_runtime::input::SubmitCopy),
-}
-type Job = Pin<Box<dyn Future<Output = Completion>>>;
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum Retirement {
- Exit,
- Replace(RootKind),
-}
-enum SurfaceState {
- Live,
- Retiring {
- cause: Retirement,
- cleanup: Option<Result<(), String>>,
- },
- Closed {
- cause: Retirement,
- },
-}
-impl SurfaceState {
- fn is_live(&self) -> bool {
- matches!(self, Self::Live)
- }
- fn replacing(&self) -> bool {
- matches!(
- self,
- Self::Retiring {
- cause: Retirement::Replace(_),
- ..
- } | Self::Closed {
- cause: Retirement::Replace(_)
- }
- )
- }
-}
-
-pub struct Presenter {
- animation_origin: std::time::Instant,
- next_frame: Option<std::time::Instant>,
- window_focused: bool,
- application: Application<dyn Widget>,
- root: Mount,
- scene: Vec<paint::Target>,
- keyboard: Option<std::rc::Rc<paint::Ancestor>>,
- resources: resources::Resources,
- focus: Option<paint::Target>,
- drag: Option<paint::Target>,
- hover: Option<paint::Target>,
- native_return: Option<focus::Return>,
- modal_focus: Vec<dialog::FocusReturn>,
- restore_focus: Option<dialog::WeakTarget>,
- physical_path: Vec<dialog::Scope>,
- focus_before_flush: Option<dialog::WeakTarget>,
- jobs: FuturesUnordered<Job>,
- replacement: Option<Mount>,
- surface: SurfaceState,
- reported: std::collections::BTreeSet<String>,
- /// One bounded physical clipboard intent, flushed before the next OS event.
- clipboard: Option<std::rc::Rc<str>>,
- editor_menu: Option<context_menu::EditorMenu>,
- viewport: ratatui::layout::Rect,
-}
-impl Presenter {
- pub fn new(
- input: &Input,
- packages: Vec<Package>,
- scheduler: tokio::runtime::Handle,
- native: NativeRegistry<dyn Widget>,
- props: Value,
- execution: maka_ui_runtime::ExecutionOptions,
- ) -> maka_ui_runtime::Result<Self> {
- if input.environment.presentation != Some(PresentationTarget::Tui) {
- return Err(Error::Invalid("terminal deployment required".into()));
- }
- let application = Application::new(
- input,
- packages,
- scheduler,
- execution,
- native,
- Default::default(),
- )?;
- Self::mount(application, props)
- }
- /// Construct on the actual terminal owner thread from its owned source.
- pub async fn start_from_store(
- store: std::sync::Arc<maka_event_log::EventLog>,
- source: maka_plugin_core::deployment::DeploymentRef,
- scheduler: tokio::runtime::Handle,
- native: NativeRegistry<dyn Widget>,
- props: Value,
- registrations: Vec<maka_ui_runtime::actions::RemoteService>,
- execution: maka_ui_runtime::ExecutionOptions,
- ) -> maka_ui_runtime::Result<Self> {
- let application = Application::start_from_store(
- store,
- source,
- PresentationTarget::Tui,
- scheduler,
- execution,
- native,
- registrations,
- )
- .await?;
- Self::mount_async(application, props).await
- }
- /// Mount the original checked snapshot without loading it again.
- pub async fn start_loaded(
- store: std::sync::Arc<maka_event_log::EventLog>,
- loaded: maka_event_log::applications::LoadedDeployment,
- scheduler: tokio::runtime::Handle,
- native: NativeRegistry<dyn Widget>,
- props: Value,
- registrations: Vec<maka_ui_runtime::actions::RemoteService>,
- execution: maka_ui_runtime::ExecutionOptions,
- ) -> maka_ui_runtime::Result<Self> {
- let application = Application::start_loaded(
- store,
- loaded,
- PresentationTarget::Tui,
- scheduler,
- execution,
- native,
- registrations,
- )
- .await?;
- Self::mount_async(application, props).await
- }
- /// Load immutable desired input without borrowing this terminal owner.
- pub fn load_committed_update(
- &self,
- source: maka_plugin_core::deployment::DeploymentRef,
- ) -> maka_ui_runtime::Result<
- impl Future<Output = maka_ui_runtime::Result<maka_ui_runtime::CommittedUpdate>>
- + Send
- + 'static
- + use<>,
- > {
- self.application.load_committed_update(source)
- }
- /// Acknowledge desired input only. Native retirement is synchronous; actual
- /// terminal restoration and replacement remain the surface owner's work.
- pub fn adopt_update(
- &mut self,
- update: maka_ui_runtime::CommittedUpdate,
- registrations: Vec<maka_ui_runtime::actions::RemoteService>,
- ) -> maka_ui_runtime::Result<()> {
- let prepared = self.application.prepare_update(update)?;
- self.adopt_prepared(prepared, registrations)
- }
- pub fn adopt_prepared(
- &mut self,
- update: maka_ui_runtime::PreparedUpdate,
- registrations: Vec<maka_ui_runtime::actions::RemoteService>,
- ) -> maka_ui_runtime::Result<()> {
- let was_live = self.surface.is_live() && !self.application.is_retiring(&self.root);
- let kind = was_live
- .then(|| self.application.root_kind(&self.root))
- .transpose()?;
- self.application.adopt_prepared(update, registrations)?;
- if was_live && self.application.is_retiring(&self.root) {
- // Capture the install's effect now, not a later, unrelated realm fault.
- self.retire_surface(Retirement::Replace(kind.expect("live root")))?;
- }
- self.resources.deactivate(&self.application);
- Ok(())
- }
- pub fn deployment_status(&self) -> maka_ui_runtime::DeploymentStatus {
- self.application.deployment_status()
- }
- pub fn surface(&self) -> Mount {
- self.root.clone()
- }
- pub fn requires_surface_replacement(&self) -> bool {
- self.surface.replacing()
- }
- /// Resume the same application after actual old-surface cleanup. Pending
- /// original service work and its observers remain with this presenter.
- pub fn try_replace_surface(&mut self, props: Value) -> maka_ui_runtime::Result<bool> {
- if matches!(
- self.surface,
- SurfaceState::Retiring {
- cause: Retirement::Replace(_),
- cleanup: Some(Ok(())),
- }
- ) {
- self.pump()?;
- }
- let kind = match self.surface {
- // The terminal's physical receipt can precede the last native or
- // callback cleanup. Keep waiting for that original owner to close.
- SurfaceState::Retiring {
- cause: Retirement::Replace(_),
- cleanup: Some(Ok(())),
- } => return Ok(false),
- SurfaceState::Closed {
- cause: Retirement::Replace(kind),
- } => kind,
- _ => {
- return Err(Error::Invalid(
- "original terminal cleanup not confirmed for replacement".into(),
- ));
- }
- };
- if self.replacement.is_none() {
- self.replacement = self.application.mount_replacement_root(kind, props)?;
- }
- let Some(root) = self.replacement.as_ref() else {
- return Ok(false);
- };
- if self.application.frame(root).is_err() && self.application.prepared_frame(root).is_err() {
- if self.application.is_retiring(root) {
- self.replacement = None;
- return Err(Error::Invalid(
- "replacement mount preparation failed".into(),
- ));
- }
- return Ok(false);
- }
- self.root = self.replacement.take().expect("prepared replacement");
- self.resources.redraw = true;
- self.surface = SurfaceState::Live;
- self.sync_native()?;
- Ok(true)
- }
-
- fn mount(application: Application<dyn Widget>, props: Value) -> maka_ui_runtime::Result<Self> {
- let scheduler = application.scheduler().clone();
- scheduler.block_on(Self::mount_async(application, props))
- }
- async fn mount_async(
- mut application: Application<dyn Widget>,
- props: Value,
- ) -> maka_ui_runtime::Result<Self> {
- application.defer_native_commit();
- let root = match application.mount_bootstrap_prepared(props).await {
- Ok(root) => root,
- Err(error) => {
- let error = match application.deployment_status().error {
- Some(initial) => {
- Error::Invalid(format!("application activation failed: {initial}; {error}"))
- }
- None => error,
- };
- // This presenter has not created a terminal or renderer resources.
- // Retain the foreground application until its actual modules close.
- let mut reported = std::collections::BTreeSet::new();
- loop {
- match application.shutdown() {
- Ok(()) => break,
- Err(failure) => {
- for mount in application.retiring_mounts() {
- let _ = application.close(&mount, Ok(()));
- }
- if !matches!(
- failure,
- Error::Core(maka_plugin_core::Error::CleanupPending)
- ) {
- let failure = sanitize(&failure.to_string());
- if reported.insert(failure.clone()) {
- eprintln!("{failure}");
- }
- }
- }
- }
- std::thread::sleep(std::time::Duration::from_millis(50));
- }
- return Err(error);
- }
- };
- let mut presenter = Self {
- animation_origin: std::time::Instant::now(),
- next_frame: Some(std::time::Instant::now()),
- window_focused: true,
- application,
- root,
- scene: Vec::new(),
- keyboard: None,
- resources: Default::default(),
- focus: None,
- drag: None,
- hover: None,
- native_return: None,
- modal_focus: Vec::new(),
- restore_focus: None,
- physical_path: Vec::new(),
- focus_before_flush: None,
- jobs: FuturesUnordered::new(),
- replacement: None,
- surface: SurfaceState::Live,
- reported: Default::default(),
- clipboard: None,
- editor_menu: None,
- viewport: ratatui::layout::Rect::default(),
- };
- if let Err(error) = presenter.sync_native() {
- // No terminal was created. Native initialization may still have
- // acquired resources: retain this actual owner until cleanup is
- // confirmed, including a failed native initialization fence.
- let mut reported = std::collections::BTreeSet::new();
- if let Err(failure) = presenter.surface_closed(&presenter.surface(), Ok(())) {
- reported.insert(sanitize(&failure.to_string()));
- eprintln!("{}", sanitize(&failure.to_string()));
- }
- loop {
- if let Err(failure) = presenter.pump() {
- let failure = sanitize(&failure.to_string());
- if reported.insert(failure.clone()) {
- eprintln!("{failure}");
- }
- }
- if presenter.can_shutdown() {
- match presenter.shutdown() {
- Ok(()) => break,
- Err(Error::Core(maka_plugin_core::Error::CleanupPending)) => {}
- Err(failure) => {
- let failure = sanitize(&failure.to_string());
- if reported.insert(failure.clone()) {
- eprintln!("{failure}");
- }
- }
- }
- }
- std::thread::sleep(std::time::Duration::from_millis(50));
- }
- return Err(error);
- }
- Ok(presenter)
- }
- /// The embedding may reserve a surface-wide exit key only when the actual
- /// admitted native focus does not own exclusive keyboard interaction.
- pub fn captures_keyboard(&mut self) -> maka_ui_runtime::Result<bool> {
- let Some(target) = self.focus.clone() else {
- return Ok(false);
- };
- if !self.surface.is_live()
- || !self.target_admitted(&target)
- || self.application.is_retiring(&target.mount)
- || !self.application.mount_visible(&target.mount)
- {
- return Ok(false);
- }
- self.resources.captures_keyboard(&target)
- }
- /// Resolve raw coordinates immediately against the last successfully flushed
- /// scene. Crossterm supplies no screen epoch; never re-resolve a captured key.
- /// 输入立即绑定到最后成功输出的场景和真实 mount/revision。
- pub fn input(&mut self, event: Event) -> maka_ui_runtime::Result<()> {
- let result = self.input_inner(event, false, None);
- self.recover_result(result)
- }
- fn input_with_capture(
- &mut self,
- event: Event,
- ) -> maka_ui_runtime::Result<Option<activation::CapturedActivation>> {
- let mut captured = None;
- let result = self.input_inner(event, false, Some(&mut captured));
- self.recover_result(result)?;
- Ok(captured)
- }
- // The event loop publishes local scroll observations once per wheel batch.
- // Explicit guest commands still enter their ordered input queue immediately.
- pub(super) fn wheel(&mut self, event: Event) -> maka_ui_runtime::Result<()> {
- debug_assert!(matches!(event, Event::Mouse(mouse) if matches!(mouse.kind,
- MouseEventKind::ScrollUp | MouseEventKind::ScrollDown)));
- let result = self.input_inner(event, true, None);
- self.recover_result(result)
- }
- fn input_inner(
- &mut self,
- event: Event,
- defer_scroll: bool,
- mut capture: Option<&mut Option<activation::CapturedActivation>>,
- ) -> maka_ui_runtime::Result<()> {
- // Ordinary pointer motion can leave presentation entirely unchanged.
- // Native hover reports its own changes below; other input retains the
- // original redraw policy, including raw terminal pointer forwarding.
- self.resources.redraw |=
- !matches!(event, Event::Mouse(mouse) if mouse.kind == MouseEventKind::Moved);
- match event {
- Event::FocusLost => self.window_focused = false,
- Event::FocusGained => self.window_focused = true,
- _ => {}
- }
- if matches!(
- event,
- Event::Key(_) | Event::Paste(_) | Event::FocusLost | Event::Resize(..)
- ) {
- self.clear_hover()?;
- }
- if matches!(
- event,
- Event::FocusLost
- | Event::Resize(..)
- | Event::Key(KeyEvent {
- code: KeyCode::Tab | KeyCode::BackTab,
- ..
- })
- ) {
- self.resources.cancel_pastes(None)?;
- }
- if matches!(event, Event::FocusLost | Event::Resize(..)) {
- self.resources.cancel_pointer()?;
- self.drag = None;
- }
- if !self.surface.is_live() {
- return Ok(());
- }
- if !self.physical_admitted() {
- self.clear_hover()?;
- return Ok(());
- }
- self.cancel_reading_focus_requests(&event);
- self.cancel_reading_keyboard_reveal(&event);
- let raw_pointer_target = if let Event::Mouse(mouse) = &event {
- let target = self
- .scene
- .iter()
- .rev()
- .find(|t| {
- !matches!(
- t.kind,
- paint::TargetKind::ContextMenu { button: false } | paint::TargetKind::Hint
- ) && t.area.contains((mouse.column, mouse.row).into())
- })
- .cloned();
- match target {
- Some(target)
- if self.target_admitted(&target) && self.resources.raw_pointer(&target)? =>
- {
- Some(target)
- }
- _ => None,
- }
- } else {
- None
- };
- if raw_pointer_target.is_some() {
- self.resources.redraw = true;
- }
- if raw_pointer_target.is_none()
- && (self.editor_context_menu(&event)? || self.open_context_menu(&event)?)
- {
- return Ok(());
- }
- if let Event::Mouse(mouse) = &event
- && mouse.kind == MouseEventKind::Moved
- && raw_pointer_target.is_none()
- {
- return self.move_pointer((mouse.column, mouse.row).into());
- }
- if matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(_) | MouseEventKind::Drag(_)))
- {
- self.clear_hover()?;
- }
- if matches!(self.native_return, Some(focus::Return::Exited)) {
- if matches!(event, Event::Key(key) if key.kind != KeyEventKind::Press) {
- return Ok(());
- }
- if matches!(
- event,
- Event::Key(_)
- | Event::Mouse(crossterm::event::MouseEvent {
- kind: MouseEventKind::Down(_),
- ..
- })
- ) {
- self.native_return = None;
- }
- }
- self.track_focus_return()?;
- let returnable = match self.focus.as_ref() {
- Some(target) => self.resources.returns_focus(target)?,
- None => false,
- };
- let native_tab = match self.focus.as_ref() {
- Some(target) => self.resources.handles_tab(target)?,
- None => false,
- };
- if self.extend_item_selection(&event)?
- || self.navigate_items(&event, capture.as_deref_mut())?
- {
- return Ok(());
- }
- let target = if raw_pointer_target.is_some() {
- raw_pointer_target
- } else {
- match &event {
- Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
- KeyCode::Tab | KeyCode::BackTab if !returnable && !native_tab => {
- if self.dispatch_focused_shortcut_with_capture(
- &event,
- capture.as_deref_mut(),
- )? {
- return Ok(());
- }
- let reverse = key.code == KeyCode::BackTab
- || key.modifiers.contains(KeyModifiers::SHIFT);
- let stops = self.tab_stops();
- if !stops.is_empty() {
- let index = self.focus.as_ref().and_then(|focus| {
- stops.iter().position(|target| target.same(focus))
- });
- let n = stops.len();
- let next = match (index, reverse) {
- (Some(i), true) => (i + n - 1) % n,
- (Some(i), false) => (i + 1) % n,
- (None, true) => n - 1,
- (None, false) => 0,
- };
- let target = stops[next];
- if !self.focus.as_ref().is_some_and(|old| old.same(target)) {
- self.focus_item(target.clone())?;
- }
- }
- self.track_focus_return()?;
- None
- }
- KeyCode::Enter | KeyCode::Char(' ') if key.modifiers.is_empty() => {
- self.focus.clone()
- }
- _ => self.focus.clone(),
- },
- Event::Paste(_) => self.focus.clone(),
- Event::Mouse(mouse)
- if matches!(
- mouse.kind,
- MouseEventKind::Down(MouseButton::Left | MouseButton::Right)
- | MouseEventKind::ScrollUp
- | MouseEventKind::ScrollDown
- ) =>
- {
- self.scene
- .iter()
- .rev()
- .find(|t| {
- !matches!(
- t.kind,
- paint::TargetKind::ContextMenu { button: false }
- | paint::TargetKind::Hint
- ) && t.area.contains((mouse.column, mouse.row).into())
- })
- .cloned()
- }
- Event::Mouse(mouse)
- if matches!(
- mouse.kind,
- MouseEventKind::Drag(MouseButton::Left)
- | MouseEventKind::Up(MouseButton::Left)
- ) =>
- {
- self.drag.clone()
- }
- _ => None,
- }
- };
- if let Some(target) = target {
- if self.application.is_retiring(&target.mount) || !self.target_admitted(&target) {
- return Ok(());
- }
- if let paint::TargetKind::Backdrop { body } = &target.kind {
- if matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left | MouseButton::Right)) && !body.contains((mouse.column, mouse.row).into()))
- {
- self.dismiss(&target, maka_ui_runtime::dismiss::Reason::Outside)?;
- } else {
- self.dismiss_escape(&event)?;
- }
- return Ok(());
- }
- if let paint::TargetKind::SelectionCopy { text } = &target.kind {
- if !self.resources.admits(&target)
- || self.application.frame(&target.mount)?.revision != target.revision
- {
- return Ok(());
- }
- if matches!(event, Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left))
- || matches!(event, Event::Key(key) if key.kind != KeyEventKind::Release && key.modifiers.is_empty() && matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')))
- {
- self.clipboard = Some(text.clone());
- self.close_context_action(target.scope.as_ref());
- self.sync_native()?;
- } else {
- self.dismiss_escape(&event)?;
- }
- return Ok(());
- }
- // Logical offscreen controls remain in Tab order, but cannot edit
- // or activate until their revealed geometry has actually flushed.
- let native = if target.area.is_empty() {
- Some(WidgetInput::Unhandled)
- } else {
- self.resources.input(&target, &event)?
- };
- let Some(native) = native else {
- return Ok(());
- };
- let (native, submit_copy) = match native {
- WidgetInput::SubmitCopy(input, text) => (WidgetInput::Emitted(input), Some(text)),
- other => (other, None),
- };
- let native = match native {
- WidgetInput::ExitFocus => {
- self.exit_native_focus()?;
- return Ok(());
- }
- WidgetInput::Emitted(emitted) => {
- self.accept_native_input(
- &target.mount,
- emitted,
- submit_copy
- .map(|copy| (target.identity.clone(), ClipboardCopy::Submit(copy))),
- )?;
- if !matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::ScrollUp | MouseEventKind::ScrollDown))
- {
- self.focus = Some(target);
- }
- self.close_context_action(
- self.focus
- .clone()
- .and_then(|target| target.scope.clone())
- .as_ref(),
- );
- self.track_focus_return()?;
- self.sync_native()?;
- return Ok(());
- }
- other => other,
- };
- let preserve_focus = matches!(native, WidgetInput::PreserveFocus);
- let native = if preserve_focus {
- WidgetInput::Consumed(None)
- } else {
- native
- };
- if let WidgetInput::Consumed(observed) = native {
- if self.resources.pointer_capture(&target)? {
- self.drag = Some(target.clone());
- } else if self.drag.as_ref().is_some_and(|old| old.same(&target)) {
- self.drag = None;
- }
-
- if let Some(observed) = observed {
- self.resources.observe(&target.mount, observed)?;
- }
- if !preserve_focus
- && !matches!(event,Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::ScrollUp | MouseEventKind::ScrollDown | MouseEventKind::Drag(_) | MouseEventKind::Up(_) | MouseEventKind::Moved))
- {
- self.focus = Some(target);
- }
- self.track_focus_return()?;
- if !defer_scroll {
- self.sync_native()?;
- }
- return Ok(());
- }
- if self.dispatch_shortcut_with_capture(
- &target.ancestors,
- &event,
- matches!(native, WidgetInput::Navigate),
- capture.as_deref_mut(),
- )? {
- return Ok(());
- }
- // Bubble unconsumed navigation through the original flushed scroll ancestors.
- for ancestor in target.ancestors.iter().rev() {
- if self.application.is_retiring(&ancestor.mount) {
- continue;
- }
- let observed = match self.resources.input(ancestor, &event)? {
- Some(WidgetInput::Emitted(emitted)) => {
- self.accept_native_input(&ancestor.mount, emitted, None)?;
- self.sync_native()?;
- return Ok(());
- }
- Some(WidgetInput::Consumed(observed)) => observed,
- Some(WidgetInput::PreserveFocus) => None,
- _ => continue,
- };
- if let Some(observed) = observed {
- self.resources.observe(&ancestor.mount, observed)?;
- }
- if !defer_scroll {
- self.sync_native()?;
- }
- return Ok(());
- }
- if self.dismiss_escape(&event)? {
- return Ok(());
- }
- if target.area.is_empty() {
- return Ok(());
- }
- if !matches!(
- target.kind,
- paint::TargetKind::Action { .. }
- | paint::TargetKind::Focus(_)
- | paint::TargetKind::Item { .. }
- ) {
- if !matches!(target.kind, paint::TargetKind::Disabled)
- && matches!(event, Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left))
- {
- self.focus = Some(target);
- }
- return Ok(());
- }
- if !matches!(event, Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left))
- && !matches!(event,Event::Key(key) if (key.code==KeyCode::Enter || key.code==KeyCode::Char(' ')) && key.modifiers.is_empty())
- {
- return Ok(());
- }
- if let paint::TargetKind::Focus(binding) = &target.kind {
- if self.application.frame(&target.mount)?.revision == target.revision
- && self.application.mount_visible(&target.mount)
- && !matches!(event, Event::Key(key) if key.kind != KeyEventKind::Press)
- {
- self.focus_child(binding)?;
- }
- return Ok(());
- }
- if matches!(target.kind, paint::TargetKind::Item { .. })
- && (self.application.frame(&target.mount)?.revision != target.revision
- || matches!(event, Event::Key(key) if key.kind != KeyEventKind::Press))
- {
- return Ok(());
- }
- if self.application.frame(&target.mount)?.revision != target.revision {
- // A newer logical frame can commit while the original output
- // buffer is still writing. The displayed old binding is no
- // longer admitted; rejecting it is ordinary physical input.
- return Ok(());
- }
- self.clear_item_selection(&target);
- let kind = match target.kind {
- paint::TargetKind::Item {
- submit,
- submit_space,
- ..
- } if matches!(event, Event::Key(key) if (submit && key.code == KeyCode::Enter) || (submit_space && key.code == KeyCode::Char(' ')))
- || (submit
- && matches!(event, Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left))) =>
- {
- "submit"
- }
- paint::TargetKind::Item { .. } => "activate",
- _ => "click",
- };
- // Focus belongs to admitted physical input, even if its callback rejects.
- self.focus = Some(target.clone());
- if let paint::TargetKind::Action {
- copy_text: Some(text),
- } = &target.kind
- {
- self.accept_native_input(
- &target.mount,
- NativeInput {
- key: target.key.clone(),
- value: json!({"kind":kind}),
- replaceable: false,
- },
- Some((target.identity.clone(), ClipboardCopy::Button(text.clone()))),
- )?;
- } else {
- self.application.dispatch(
- &target.mount,
- target.revision,
- &target.key,
- json!({"kind":kind}),
- )?;
- }
- self.close_context_action(target.scope.as_ref());
- self.sync_native()?;
- }
- if self.dismiss_escape(&event)? {
- return Ok(());
- }
- if self.focus.is_none()
- && let Some(scope) = self.keyboard.clone()
- {
- self.dispatch_shortcut_with_capture(&[scope], &event, false, capture)?;
- }
- Ok(())
- }
- /// Allow a live declared command to own an otherwise reserved embedding key.
- #[cfg(test)]
- fn dispatch_focused_shortcut(&mut self, event: &Event) -> maka_ui_runtime::Result<bool> {
- self.dispatch_focused_shortcut_with_capture(event, None)
- }
- fn dispatch_focused_shortcut_with_capture(
- &mut self,
- event: &Event,
- capture: Option<&mut Option<activation::CapturedActivation>>,
- ) -> maka_ui_runtime::Result<bool> {
- if !self.surface.is_live() || !self.physical_admitted() {
- return Ok(false);
- }
- let ancestors = match self.focus.clone() {
- Some(target)
- if self.target_admitted(&target)
- && self.resources.admits(&target)
- && self.application.mount_visible(&target.mount)
- && !self.application.is_retiring(&target.mount) =>
- {
- // Native editing gets the first opportunity to consume Ctrl+C.
- // This uses the same owner and observation path as ordinary edits.
- if run::is_interrupt(event) && !target.area.is_empty() {
- match self.resources.input(&target, event)? {
- Some(WidgetInput::Consumed(observed)) => {
- if let Some(observed) = observed {
- self.resources.observe(&target.mount, observed)?;
- }
- self.sync_native()?;
- self.resources.redraw = true;
- return Ok(true);
- }
- Some(WidgetInput::Emitted(input)) => {
- self.accept_native_input(&target.mount, input, None)?;
- self.sync_native()?;
- return Ok(true);
- }
- _ => {}
- }
- }
- target.ancestors
- }
- Some(_) => return Ok(false),
- None => self.keyboard.clone().into_iter().collect(),
- };
- self.dispatch_shortcut_with_capture(&ancestors, event, false, capture)
- }
- fn dispatch_shortcut_with_capture(
- &mut self,
- ancestors: &[std::rc::Rc<paint::Ancestor>],
- event: &Event,
- navigation: bool,
- mut capture: Option<&mut Option<activation::CapturedActivation>>,
- ) -> maka_ui_runtime::Result<bool> {
- let Event::Key(key) = event else {
- return Ok(false);
- };
- let Some((key, modifiers)) = shortcut_chord(key) else {
- return Ok(false);
- };
- for ancestor in ancestors.iter().rev() {
- if navigation
- && let Some(binding) = ancestor
- .focus_shortcuts
- .iter()
- .find(|binding| binding.declaration.matches(&key, modifiers))
- {
- if binding.declaration.enabled
- && self.target_admitted(ancestor)
- && self.resources.admits(ancestor)
- && !self.application.is_retiring(&ancestor.mount)
- && self.application.mount_visible(&ancestor.mount)
- && self.application.frame(&ancestor.mount)?.revision == ancestor.revision
- {
- self.focus_child(&binding.target)?;
- }
- return Ok(true);
- }
- if let Some(shortcut) = ancestor
- .shortcuts
- .iter()
- .find(|shortcut| shortcut.matches(&key, modifiers, false))
- {
- if shortcut.enabled
- && self.target_admitted(ancestor)
- && self.resources.admits(ancestor)
- && self.application.mount_visible(&ancestor.mount)
- && !self.application.is_retiring(&ancestor.mount)
- {
- let current =
- self.application.frame(&ancestor.mount)?.revision == ancestor.revision;
- if let Some(capture) = capture.as_deref_mut()
- && (!current || self.application.ui_pending())
- {
- *capture = Some(self.capture_shortcut(
- ancestor.clone(),
- shortcut.clone(),
- event.clone(),
- navigation,
- )?);
- return Ok(true);
- }
- if !current {
- return Ok(true);
- }
- self.application.dispatch(
- &ancestor.mount,
- ancestor.revision,
- &shortcut.input,
- json!({"kind":"click"}),
- )?;
- self.sync_native()?;
- }
- return Ok(true);
- }
- }
- Ok(false)
- }
- /// Copy only the focused original live native scope through the bounded
- /// terminal writer. An OSC52 write is not an OS clipboard acknowledgement.
- pub fn copy(&mut self, writer: &mut impl std::io::Write) -> maka_ui_runtime::Result<bool> {
- self.copy_scope(writer, false)
- }
- fn copy_scope(
- &mut self,
- writer: &mut impl std::io::Write,
- selection_only: bool,
- ) -> maka_ui_runtime::Result<bool> {
- if !self.physical_admitted() {
- return Ok(false);
- }
- let Some(target) = self.focus.clone() else {
- return Ok(false);
- };
- if self.application.is_retiring(&target.mount) {
- return Ok(false);
- }
- if !self.target_admitted(&target) {
- return Ok(false);
- }
- let text = match self.resources.copy(&target, selection_only)? {
- Some(text) => Some(text),
- None => match self.item_reading_scope(&target) {
- Some((scope, _)) => scope.copy()?,
- None => None,
- },
- };
- let Some(text) = text else {
- return Ok(false);
- };
- crate::terminal::copy(writer, &text).map_err(|error| Error::Invalid(error.to_string()))?;
- Ok(true)
- }
- /// Complete the last accepted physical clipboard default action. The
- /// runner calls this immediately after input, before reading another event.
- /// OSC52 delivery does not prove that the terminal accepted the clipboard.
- pub fn flush_clipboard(
- &mut self,
- writer: &mut impl std::io::Write,
- ) -> maka_ui_runtime::Result<bool> {
- let Some(text) = self.clipboard.take() else {
- return Ok(false);
- };
- crate::terminal::copy(writer, &text).map_err(|error| Error::Invalid(error.to_string()))?;
- Ok(true)
- }
- fn report(&mut self, error: &impl std::fmt::Display) {
- // stdout and stderr may be the same physical terminal. The runner
- // publishes diagnostics only after restoring it, outside frame diffing.
- if self.reported.len() < 64 {
- self.reported
- .insert(sanitize(&error.to_string()).chars().take(2048).collect());
- }
- }
- pub fn pump(&mut self) -> maka_ui_runtime::Result<()> {
- self.pump_with_input_priority(false)
- }
- // Finish the transaction already ahead of physical input without admitting
- // another stream feedback transaction before that input can run.
- fn pump_with_input_priority(&mut self, input_waiting: bool) -> maka_ui_runtime::Result<()> {
- // This original launch candidate has no physical surface yet. Capture
- // its existing recovery permission before rollback can retire its mount.
- let replacement = self
- .replacement
- .as_ref()
- .filter(|mount| {
- matches!(
- self.surface,
- SurfaceState::Closed {
- cause: Retirement::Replace(RootKind::Launch)
- }
- ) && self.application.can_recover(mount)
- && !self.resources.owners.contains_key(&mount.id())
- })
- .cloned();
- let result = self.pump_inner(input_waiting);
- if let (Some(mount), Err(error @ (Error::Script(_) | Error::Invalid(_)))) =
- (replacement, &result)
- && !self.application.logical_pending()
- && self.application.frame(&mount).is_err()
- && self.application.prepared_frame(&mount).is_err()
- && !self.resources.owners.contains_key(&mount.id())
- {
- self.report(error);
- self.replacement = None;
- self.surface = SurfaceState::Closed {
- cause: Retirement::Replace(RootKind::Recovery),
- };
- return Ok(());
- }
- self.recover_result(result)
- }
- fn recover_result(
- &mut self,
- result: maka_ui_runtime::Result<()>,
- ) -> maka_ui_runtime::Result<()> {
- // Realm failure can retire the original surface before its callback's
- // completion arrives. That late script error belongs to the recovery
- // already selected, rather than turning recovery into terminal failure.
- if matches!(&result, Err(Error::Script(_)))
- && matches!(
- self.surface,
- SurfaceState::Retiring {
- cause: Retirement::Replace(RootKind::Recovery),
- ..
- }
- )
- && self.application.is_retiring(&self.root)
- {
- self.report(result.as_ref().unwrap_err());
- return Ok(());
- }
- if self.surface.is_live()
- && self.application.is_retiring(&self.root)
- && self.application.can_recover(&self.root)
- {
- if let Err(error) = &result {
- self.report(error);
- }
- self.retire_surface(Retirement::Replace(RootKind::Recovery))?;
- return Ok(());
- }
- if result.is_ok()
- && self.surface.is_live()
- && self.application.can_resume_launch(&self.root)
- {
- self.retire_surface(Retirement::Replace(RootKind::Launch))?;
- }
- result
- }
- fn pump_inner(&mut self, input_waiting: bool) -> maka_ui_runtime::Result<()> {
- let advancement = if self.surface.is_live() && !self.application.is_retiring(&self.root) {
- self.application.advance_deployment()
- } else {
- Ok(())
- };
- self.resources.deactivate(&self.application);
- if let Err(error) = advancement {
- self.report(&error);
- }
- if self.application.advance_ui()? {
- self.resources.redraw = true;
- }
- self.sync_native()?;
- // Each join observer stays with the actual owner, independently of UI.
- self.finish_jobs()?;
- if self.surface.is_live() && !self.application.is_retiring(&self.root) {
- while let Some(action) = self.application.start_task(&self.root)? {
- self.execute(action);
- }
- if !input_waiting {
- let resources = &mut self.resources;
- self.application.deliver_action_results(
- &self.root,
- |target, recipe, witness| {
- resources.validate_continuation(target, recipe, witness)
- },
- )?;
- }
- }
- self.progress_native()?;
- // Discard native hit references before
- // acknowledging retired children; root additionally requires Guard.
- let retired = self.application.retiring_mounts();
- if self
- .hover
- .as_ref()
- .is_some_and(|target| retired.contains(&target.mount))
- {
- self.clear_hover()?;
- }
- self.scene.retain(|target| !retired.contains(&target.mount));
- if self
- .drag
- .as_ref()
- .is_some_and(|target| retired.contains(&target.mount))
- {
- self.drag = None;
- }
- if self
- .focus
- .as_ref()
- .is_some_and(|target| retired.contains(&target.mount))
- {
- self.focus = None;
- }
- let mut failure = None;
- // A native child can still own a worker after its parent's resources
- // close. Keep both mounts until the original child actually settles.
- for mount in retired {
- if mount == self.root {
- if let SurfaceState::Retiring { cause, cleanup } = &mut self.surface
- && let Some(cleanup) = cleanup.as_ref()
- {
- let cause = *cause;
- let cleanup = cleanup.clone();
- let native = match self.resources.close(&mount) {
- Ok(false) => continue,
- Ok(true) => Ok(()),
- Err(error) => Err(error),
- };
- match self.application.close(&mount, native.and(cleanup)) {
- Ok(()) => self.surface = SurfaceState::Closed { cause },
- Err(Error::Core(maka_plugin_core::Error::CleanupPending)) => {}
- Err(error) => {
- failure.get_or_insert(error);
- }
- }
- }
- } else {
- let cleanup = match self.resources.close(&mount) {
- Ok(false) => continue,
- Ok(true) => Ok(()),
- Err(error) => Err(error),
- };
- match self.application.close(&mount, cleanup) {
- Ok(()) | Err(Error::Core(maka_plugin_core::Error::CleanupPending)) => {}
- Err(error) => {
- failure.get_or_insert(error);
- }
- }
- }
- }
- if self.surface.is_live() && failure.is_none() && !self.application.is_retiring(&self.root)
- {
- let advancement = self.application.advance_deployment();
- self.resources.deactivate(&self.application);
- if let Err(error) = advancement {
- self.report(&error);
- }
- self.progress_native()?;
- }
- self.finish_jobs()?;
- failure.map_or(Ok(()), Err)
- }
- fn finish_jobs(&mut self) -> maka_ui_runtime::Result<()> {
- let wake = self.application.waker();
- let mut cx = std::task::Context::from_waker(&wake);
- while let std::task::Poll::Ready(Some(completion)) =
- Pin::new(&mut self.jobs).poll_next(&mut cx)
- {
- match completion {
- Completion::Action(id, completion) => {
- if let Some(task) = self.application.finish_task(id, *completion)? {
- self.execute(task);
- }
- }
- Completion::Input {
- mount,
- result,
- copy,
- } => {
- if let Err(error) = result {
- if !matches!(error, Error::Core(maka_plugin_core::Error::Retired))
- || !self.application.is_retiring(&mount)
- {
- return Err(error);
- }
- } else if let Some((target, copy)) = copy
- && !self.application.is_retiring(&mount)
- && self.resources.owns(&target)
- {
- match copy {
- ClipboardCopy::Button(text) => self.clipboard = Some(text),
- ClipboardCopy::Submit(copy) => {
- if self.resources.owners.get(&mount.id()).is_some_and(|owner| {
- owner
- .state
- .downcast_ref::<editor::Owner>()
- .is_some_and(|owner| owner.snapshot().text == copy.basis)
- }) {
- self.clipboard = Some(copy.text.into());
- }
- }
- }
- }
- }
- }
- }
- Ok(())
- }
- pub fn retire(&mut self) -> maka_ui_runtime::Result<()> {
- self.retire_surface(Retirement::Exit)
- }
- fn retire_surface(&mut self, cause: Retirement) -> maka_ui_runtime::Result<()> {
- match &mut self.surface {
- SurfaceState::Live => {
- self.surface = SurfaceState::Retiring {
- cause,
- cleanup: None,
- }
- }
- SurfaceState::Retiring { cause: current, .. } => {
- if cause == Retirement::Exit {
- *current = cause;
- }
- }
- SurfaceState::Closed { cause: current } => {
- if cause == Retirement::Exit {
- *current = cause;
- }
- return Ok(());
- }
- }
- self.clear_hover()?;
- self.native_return = None;
- self.scene.clear();
- self.focus = None;
- self.drag = None;
- self.application.retire(&self.root)?;
- self.resources.deactivate(&self.application);
- Ok(())
- }
- fn execute(&mut self, action: ActionTask) {
- let id = action.id;
- let task = self.application.scheduler().spawn(action.future);
- self.jobs.push(Box::pin(async move {
- let completion = task
- .await
- .unwrap_or_else(|error| TaskCompletion::Interrupted(error.to_string()));
- Completion::Action(id, Box::new(completion))
- }));
- self.application.waker().wake_by_ref();
- }
- fn accept_native_input(
- &mut self,
- mount: &Mount,
- input: NativeInput,
- copy: Option<(paint::TargetIdentity, ClipboardCopy)>,
- ) -> maka_ui_runtime::Result<()> {
- let receipt = self.resources.capture_input(mount, input)?;
- let mount = mount.clone();
- self.jobs.push(Box::pin(async move {
- Completion::Input {
- mount,
- result: receipt.await,
- copy,
- }
- }));
- self.application.waker().wake_by_ref();
- Ok(())
- }
- /// Actual terminal restoration acknowledgement, not logical retirement.
- /// 接收实际终端恢复回执,不以逻辑退休冒充恢复完成。
- pub fn surface_closed(
- &mut self,
- surface: &Mount,
- result: Result<(), String>,
- ) -> maka_ui_runtime::Result<()> {
- if surface != &self.root {
- return Err(Error::Invalid(
- "terminal cleanup belongs to another surface".into(),
- ));
- }
- if self.surface.is_live() {
- self.retire_surface(Retirement::Exit)?;
- }
- let SurfaceState::Retiring { cleanup, .. } = &mut self.surface else {
- return Err(Error::Invalid("terminal cleanup already confirmed".into()));
- };
- if cleanup.is_some() {
- return Err(Error::Invalid(
- "terminal cleanup receipt already received".into(),
- ));
- }
- *cleanup = Some(result);
- self.pump()
- }
- /// Explicit successful restoration retry of the same failed physical surface.
- /// This cannot recover a different mount or acknowledge failed native resources.
- pub fn surface_recovered(&mut self, surface: &Mount) -> maka_ui_runtime::Result<()> {
- if surface != &self.root {
- return Err(Error::Invalid(
- "terminal recovery belongs to another surface".into(),
- ));
- }
- let SurfaceState::Retiring {
- cleanup: Some(cleanup),
- ..
- } = &mut self.surface
- else {
- return Err(Error::Invalid(
- "failed terminal restoration required".into(),
- ));
- };
- if cleanup.is_ok() {
- return Err(Error::Invalid(
- "failed terminal restoration required".into(),
- ));
- }
- *cleanup = Ok(());
- self.pump()
- }
- pub fn can_shutdown(&self) -> bool {
- self.application.can_shutdown() && self.jobs.is_empty()
- }
- pub fn shutdown(&mut self) -> maka_ui_runtime::Result<()> {
- self.application.shutdown()
- }
-}
-
-fn shortcut_chord(
- key: &crossterm::event::KeyEvent,
-) -> Option<(String, maka_ui_runtime::shortcut::Modifiers)> {
- if key.kind != KeyEventKind::Press
- || !(key.modifiers
- - (KeyModifiers::CONTROL
- | KeyModifiers::ALT
- | KeyModifiers::SHIFT
- | KeyModifiers::SUPER))
- .is_empty()
- {
- return None;
- }
- // Crossterm's Windows AltGr text events carry both Control and Alt.
- if cfg!(target_os = "windows")
- && matches!(key.code, KeyCode::Char(_))
- && key
- .modifiers
- .contains(KeyModifiers::CONTROL | KeyModifiers::ALT)
- {
- return None;
- }
- let name = match key.code {
- KeyCode::Char(' ') => "space".into(),
- KeyCode::Char(c) => c.to_ascii_lowercase().to_string(),
- KeyCode::Esc => "escape".into(),
- KeyCode::Enter => "enter".into(),
- KeyCode::Tab | KeyCode::BackTab => "tab".into(),
- KeyCode::Delete => "delete".into(),
- KeyCode::Up => "up".into(),
- KeyCode::Down => "down".into(),
- KeyCode::Left => "left".into(),
- KeyCode::Right => "right".into(),
- KeyCode::Home => "home".into(),
- KeyCode::End => "end".into(),
- KeyCode::PageUp => "pageup".into(),
- KeyCode::PageDown => "pagedown".into(),
- KeyCode::F(n) => format!("f{n}"),
- _ => return None,
- };
- Some((
- name,
- maka_ui_runtime::shortcut::Modifiers {
- control: key.modifiers.contains(KeyModifiers::CONTROL),
- alt: key.modifiers.contains(KeyModifiers::ALT),
- shift: key.code == KeyCode::BackTab || key.modifiers.contains(KeyModifiers::SHIFT),
- super_key: key.modifiers.contains(KeyModifiers::SUPER),
- },
- ))
-}
-
-#[cfg(test)]
-#[path = "application/tests.rs"]
-mod tests;
diff --git a/platform/render-terminal/src/application/activation.rs b/platform/render-terminal/src/application/activation.rs
deleted file mode 100644
index afb724333c..0000000000
--- a/platform/render-terminal/src/application/activation.rs
+++ /dev/null
@@ -1,334 +0,0 @@
-/*
- * 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.
- */
-
-//! Retain one physical activation across a logical commit and its output receipt.
-use super::{
- Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, Presenter, dialog,
- paint,
-};
-use maka_ui_runtime::shortcut::Shortcut;
-use ratatui::layout::Position;
-use std::rc::Rc;
-
-pub(super) struct CapturedActivation {
- binding: Binding,
- event: Event,
- point: Option<Position>,
- openings: Vec<dialog::Scope>,
-}
-enum Binding {
- Control(paint::Target),
- Shortcut {
- ancestor: Rc<paint::Ancestor>,
- declaration: Shortcut,
- navigation: bool,
- },
-}
-impl CapturedActivation {
- fn identity(&self) -> &paint::TargetIdentity {
- match &self.binding {
- Binding::Control(target) => target,
- Binding::Shortcut { ancestor, .. } => ancestor,
- }
- }
-
- pub(super) fn event(&self) -> Event {
- self.event.clone()
- }
-}
-#[derive(Debug, PartialEq, Eq)]
-pub(super) enum Admission {
- Pending,
- Ready,
- Discard,
-}
-fn same_action(original: &paint::TargetKind, current: &paint::TargetKind) -> bool {
- use paint::TargetKind::*;
- match (original, current) {
- (Action { copy_text: a }, Action { copy_text: b }) => a == b,
- (SelectionCopy { text: a }, SelectionCopy { text: b }) => a == b,
- (
- Item {
- submit: a,
- submit_space: sa,
- ..
- },
- Item {
- submit: b,
- submit_space: sb,
- ..
- },
- ) => a == b && sa == sb,
- (Focus(a), Focus(b)) => a.child == b.child && a.owner.ptr_eq(&b.owner),
- _ => false,
- }
-}
-impl Presenter {
- pub(super) fn release_activation(
- &mut self,
- input: CapturedActivation,
- ) -> maka_ui_runtime::Result<Option<Event>> {
- debug_assert_eq!(self.activation_admission(&input), Admission::Ready);
- match input.binding {
- Binding::Control(original) => {
- if input.point.is_none() {
- self.focus = self
- .scene
- .iter()
- .find(|target| target.same(&original))
- .cloned();
- }
- Ok(Some(input.event))
- }
- Binding::Shortcut {
- ancestor,
- declaration,
- ..
- } => {
- let revision = self.application.frame(&ancestor.mount)?.revision;
- self.application.dispatch(
- &ancestor.mount,
- revision,
- &declaration.input,
- serde_json::json!({"kind":"click"}),
- )?;
- self.sync_native()?;
- Ok(None)
- }
- }
- }
- pub(super) fn capture_shortcut(
- &self,
- ancestor: Rc<paint::Ancestor>,
- declaration: Shortcut,
- event: Event,
- navigation: bool,
- ) -> maka_ui_runtime::Result<CapturedActivation> {
- Ok(CapturedActivation {
- binding: Binding::Shortcut {
- ancestor,
- declaration,
- navigation,
- },
- event,
- point: None,
- openings: self.opening_witnesses()?,
- })
- }
- fn current_shortcut(
- &self,
- event: &Event,
- navigation: bool,
- ) -> Option<(&Rc<paint::Ancestor>, &Shortcut)> {
- let Event::Key(key) = event else {
- return None;
- };
- let (key, modifiers) = super::shortcut_chord(key)?;
- let ancestors = match &self.focus {
- Some(target) => target.ancestors.as_slice(),
- None => self.keyboard.as_slice(),
- };
- for ancestor in ancestors.iter().rev() {
- if navigation
- && ancestor
- .focus_shortcuts
- .iter()
- .any(|binding| binding.declaration.matches(&key, modifiers))
- {
- return None;
- }
- if let Some(declaration) = ancestor
- .shortcuts
- .iter()
- .find(|shortcut| shortcut.matches(&key, modifiers, false))
- {
- return Some((ancestor, declaration));
- }
- }
- None
- }
- /// Native editors retain their own input path. Only explicit scene actions
- /// need this fence; their labels and selection styling may legitimately change.
- pub(super) fn capture_activation(&self, event: &Event) -> Option<CapturedActivation> {
- if !self.surface.is_live() || !self.physical_admitted() {
- return None;
- }
- let (target, point) = match event {
- Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => {
- let point = (mouse.column, mouse.row).into();
- (
- self.scene.iter().rev().find(|target| {
- !matches!(
- target.kind,
- paint::TargetKind::ContextMenu { button: false }
- | paint::TargetKind::Hint
- ) && target.area.contains(point)
- })?,
- Some(point),
- )
- }
- Event::Key(key)
- if key.kind == KeyEventKind::Press
- && key.modifiers == KeyModifiers::NONE
- && matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) =>
- {
- (self.focus.as_ref()?, None)
- }
- _ => return None,
- };
- if !matches!(
- target.kind,
- paint::TargetKind::Action { .. }
- | paint::TargetKind::Item { .. }
- | paint::TargetKind::Focus(_)
- | paint::TargetKind::SelectionCopy { .. }
- ) || target.area.is_empty()
- || !self.target_admitted(target)
- || !self.resources.admits(target)
- || self.application.is_retiring(&target.mount)
- || !self.application.mount_visible(&target.mount)
- {
- return None;
- }
- if !self.application.ui_pending()
- && self
- .application
- .frame(&target.mount)
- .is_ok_and(|frame| frame.revision == target.revision)
- {
- return None;
- }
- // A declared shortcut owns this key before an ordinary scene action.
- if let Event::Key(event) = event
- && let Some((key, modifiers)) = super::shortcut_chord(event)
- && target.ancestors.iter().rev().any(|ancestor| {
- ancestor
- .shortcuts
- .iter()
- .any(|shortcut| shortcut.matches(&key, modifiers, false))
- })
- {
- return None;
- }
- Some(CapturedActivation {
- binding: Binding::Control(target.clone()),
- event: event.clone(),
- point,
- openings: self.opening_witnesses().ok()?,
- })
- }
- pub(super) fn activation_admission(&self, input: &CapturedActivation) -> Admission {
- let original = input.identity();
- if !self.surface.is_live()
- || self.application.is_retiring(&original.mount)
- || !self.application.mount_visible(&original.mount)
- || !self.resources.owns(original)
- || !self.scene_openings_current(&input.openings)
- {
- return Admission::Discard;
- }
- if self.application.ui_pending() || !self.physical_admitted() {
- return Admission::Pending;
- }
- if let Binding::Shortcut {
- ancestor,
- declaration,
- navigation,
- } = &input.binding
- {
- let Some((current, current_declaration)) =
- self.current_shortcut(&input.event, *navigation)
- else {
- return Admission::Discard;
- };
- if !self
- .application
- .frame(¤t.mount)
- .is_ok_and(|frame| frame.revision == current.revision)
- {
- return Admission::Pending;
- }
- return if current.mount == ancestor.mount
- && current.key == ancestor.key
- && Rc::ptr_eq(¤t.owner, &ancestor.owner)
- && dialog::same_scope(¤t.scope, &ancestor.scope)
- && current_declaration == declaration
- && self.target_admitted(current)
- && self.resources.admits(current)
- {
- Admission::Ready
- } else {
- Admission::Discard
- };
- }
- let Binding::Control(original) = &input.binding else {
- unreachable!()
- };
- let Some(current) = self.scene.iter().find(|target| {
- target.same(original) && dialog::same_scope(&target.scope, &original.scope)
- }) else {
- return Admission::Discard;
- };
- if !self
- .application
- .frame(¤t.mount)
- .is_ok_and(|frame| frame.revision == current.revision)
- {
- return Admission::Pending;
- }
- if !same_action(&original.kind, ¤t.kind)
- || !self.target_admitted(current)
- || !self.resources.admits(current)
- || current.area.is_empty()
- {
- return Admission::Discard;
- }
- if let Some(point) = input.point {
- let hit = self.scene.iter().rev().find(|target| {
- !matches!(
- target.kind,
- paint::TargetKind::ContextMenu { button: false } | paint::TargetKind::Hint
- ) && target.area.contains(point)
- });
- if !hit.is_some_and(|target| target.same(current)) {
- return Admission::Discard;
- }
- } else {
- if !self
- .focus
- .as_ref()
- .is_some_and(|target| target.same(current))
- {
- return Admission::Discard;
- }
- if let Event::Key(event) = &input.event
- && let Some((key, modifiers)) = super::shortcut_chord(event)
- && current.ancestors.iter().rev().any(|ancestor| {
- ancestor
- .shortcuts
- .iter()
- .any(|shortcut| shortcut.matches(&key, modifiers, false))
- })
- {
- return Admission::Discard;
- }
- }
- Admission::Ready
- }
-}
diff --git a/platform/render-terminal/src/application/adaptive.rs b/platform/render-terminal/src/application/adaptive.rs
deleted file mode 100644
index 1bbbb07a84..0000000000
--- a/platform/render-terminal/src/application/adaptive.rs
+++ /dev/null
@@ -1,329 +0,0 @@
-/*
- * 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.
- */
-
-//! Original Chrome sidebar mechanics over one retained native pane.
-use super::{dialog::Opening, scene::Node, scroll::SceneState};
-use maka_ui_runtime::scene::terminal::PaneOperation;
-use std::{rc::Rc, time::Instant};
-
-#[derive(Default)]
-pub(super) struct State {
- expanded: Option<bool>,
- sequence: u64,
- custom_width: Option<u16>,
- pub resizing: bool,
- transition: Option<Transition>,
- pub geometry: Option<Geometry>,
-}
-struct Transition {
- from: u16,
- to: u16,
- started: Instant,
-}
-#[derive(Clone)]
-pub(super) struct Geometry {
- pub key: String,
- pub width: u16,
- pub x: i64,
- pub inline_width: u16,
- pub target: u16,
- pub time: Instant,
-}
-impl State {
- pub fn target(&self, node: &Node, width: u16) -> u16 {
- let Node::AdaptivePane {
- pane_width,
- inline_min_width,
- auto_inline_min_width,
- ..
- } = node
- else {
- unreachable!()
- };
- if width >= *inline_min_width && self.expanded.unwrap_or(width >= *auto_inline_min_width) {
- self.custom_width.unwrap_or(*pane_width).clamp(
- (*pane_width).min(16),
- width
- .saturating_sub(inline_min_width.saturating_sub(*pane_width))
- .max((*pane_width).min(16)),
- )
- } else {
- 0
- }
- }
- pub fn animating(&self, node: &Node, width: u16, time: Instant) -> bool {
- matches!(node, Node::AdaptivePane { motion: true, .. })
- && self.transition.as_ref().is_some_and(|transition| {
- transition.to == self.target(node, width)
- && time
- .saturating_duration_since(transition.started)
- .as_secs_f32()
- < 0.16
- })
- }
- pub fn width(&self, node: &Node, width: u16, time: Instant) -> u16 {
- let Node::AdaptivePane { motion, .. } = node else {
- unreachable!()
- };
- let target = self.target(node, width);
- match &self.transition {
- Some(transition) if transition.to == target && *motion => {
- let t = (time
- .saturating_duration_since(transition.started)
- .as_secs_f32()
- / 0.16)
- .min(1.0);
- let eased = 1.0 - (1.0 - t).powi(3);
- (transition.from as f32 + (target as f32 - transition.from as f32) * eased).round()
- as u16
- }
- _ => target,
- }
- .min(width)
- }
- pub fn present(&mut self, geometry: Geometry) {
- if self.transition.as_ref().is_some_and(|transition| {
- geometry
- .time
- .saturating_duration_since(transition.started)
- .as_secs_f32()
- >= 0.16
- || geometry.target != transition.to
- }) {
- self.transition = None;
- }
- self.geometry = Some(geometry);
- }
-}
-impl SceneState {
- pub fn pane_input(
- &mut self,
- node: &Node,
- event: &crossterm::event::Event,
- ) -> super::WidgetInput {
- use crossterm::event::{Event, KeyCode, KeyEventKind, MouseButton, MouseEventKind};
- let Node::AdaptivePane {
- key,
- pane_width,
- inline_min_width,
- ..
- } = node
- else {
- return super::WidgetInput::Unhandled;
- };
- let Some(pane) = self.panes.get_mut(key) else {
- return super::WidgetInput::Unhandled;
- };
- let Some(geometry) = &pane.geometry else {
- return super::WidgetInput::Unhandled;
- };
- if geometry.inline_width == 0 {
- pane.resizing = false;
- return super::WidgetInput::Unhandled;
- }
- let width = match event {
- Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => {
- pane.resizing = true;
- return super::WidgetInput::PreserveFocus;
- }
- Event::Mouse(mouse)
- if mouse.kind == MouseEventKind::Drag(MouseButton::Left) && pane.resizing =>
- {
- (i64::from(mouse.column) - geometry.x + 1).clamp(0, i64::from(u16::MAX)) as u16
- }
- Event::Mouse(mouse)
- if mouse.kind == MouseEventKind::Up(MouseButton::Left) && pane.resizing =>
- {
- pane.resizing = false;
- return super::WidgetInput::PreserveFocus;
- }
- Event::Key(key) if key.kind != KeyEventKind::Release && key.modifiers.is_empty() => {
- match key.code {
- KeyCode::Left => geometry.inline_width.saturating_sub(1),
- KeyCode::Right => geometry.inline_width.saturating_add(1),
- KeyCode::Home => *pane_width,
- _ => return super::WidgetInput::Unhandled,
- }
- }
- _ => return super::WidgetInput::Unhandled,
- };
- let min = (*pane_width).min(16);
- let max = geometry
- .width
- .saturating_sub(inline_min_width.saturating_sub(*pane_width))
- .max(min);
- pane.custom_width = Some(width.clamp(min, max));
- pane.expanded = Some(true);
- pane.transition = None;
- super::WidgetInput::PreserveFocus
- }
- pub fn commit_panes(&mut self, node: &Node) {
- fn visit(state: &mut SceneState, node: &Node, keys: &mut Vec<String>) {
- match node {
- Node::AdaptivePane {
- key,
- background,
- content,
- pane_width,
- inline_min_width,
- auto_inline_min_width,
- motion,
- command,
- ..
- } => {
- keys.push(key.clone());
- let pane = state.panes.entry(key.clone()).or_default();
- if !motion {
- pane.transition = None;
- }
- if let Some(command) = command
- .as_ref()
- .filter(|command| command.sequence > pane.sequence)
- {
- pane.sequence = command.sequence;
- match command.op {
- PaneOperation::DismissTransient => {
- state.dialogs.remove(key);
- }
- PaneOperation::Toggle if state.dialogs.contains_key(key) => {
- state.dialogs.remove(key);
- }
- PaneOperation::Toggle => {
- if let Some(geometry) = &pane.geometry {
- if geometry.width < *inline_min_width {
- state.dialogs.insert(key.clone(), Rc::new(Opening::new()));
- } else {
- let expanded = !pane
- .expanded
- .unwrap_or(geometry.width >= *auto_inline_min_width);
- pane.expanded = Some(expanded);
- pane.transition = motion.then_some(Transition {
- from: geometry.inline_width,
- to: if expanded { *pane_width } else { 0 },
- started: Instant::now(),
- });
- }
- }
- }
- }
- }
- visit(state, background, keys);
- visit(state, content, keys);
- }
- Node::ContextMenu {
- background,
- content,
- ..
- }
- | Node::Dialog {
- background,
- content,
- ..
- } => {
- visit(state, background, keys);
- visit(state, content, keys);
- }
- Node::Item { content, .. } => visit(state, content, keys),
- Node::Stack { children, .. } => {
- for child in children {
- visit(state, child, keys);
- }
- }
- _ => {}
- }
- }
- let mut keys = Vec::new();
- visit(self, node, &mut keys);
- self.panes.retain(|key, _| keys.contains(key));
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::time::Duration;
- #[test]
- fn original_sidebar_transition_reverses_from_published_width_and_stops_on_resize() {
- let now = Instant::now();
- let mut node: Node = serde_json::from_value(serde_json::json!({
- "kind":"adaptive_pane","key":"pane","pane_width":32,"inline_min_width":60,"auto_inline_min_width":90,"motion":true,"command":null,
- "overlay":{"label":"Pane","width":40,"height":10,"background_color":0,"border_color":0},
- "background":{"kind":"text","key":"body","text":"","color":0},
- "content":{"kind":"text","key":"content","text":"","color":0}
- })).unwrap();
- let mut state = State {
- expanded: Some(false),
- transition: Some(Transition {
- from: 32,
- to: 0,
- started: now,
- }),
- ..Default::default()
- };
- let midway = now + Duration::from_millis(60);
- let width = state.width(&node, 120, midway);
- assert!((1..32).contains(&width));
- state.present(Geometry {
- key: "pane".into(),
- width: 120,
- x: 0,
- inline_width: width,
- target: 0,
- time: midway,
- });
- state.expanded = Some(true);
- state.transition = Some(Transition {
- from: state.geometry.as_ref().unwrap().inline_width,
- to: 32,
- started: midway,
- });
- assert_eq!(state.width(&node, 120, midway), width);
- assert_eq!(
- state.width(&node, 120, now + Duration::from_millis(220)),
- 32
- );
- state.present(Geometry {
- key: "pane".into(),
- width: 59,
- x: 0,
- inline_width: 0,
- target: 0,
- time: midway,
- });
- assert!(
- state.transition.is_none(),
- "original resize cancels a transition to another target"
- );
- assert_eq!(state.width(&node, 120, midway), 32);
- state.expanded = Some(false);
- state.transition = Some(Transition {
- from: 32,
- to: 0,
- started: now,
- });
- if let Node::AdaptivePane { motion, .. } = &mut node {
- *motion = false;
- }
- assert_eq!(
- state.width(&node, 120, now),
- 0,
- "reduced motion goes directly to the target"
- );
- }
-}
diff --git a/platform/render-terminal/src/application/border.rs b/platform/render-terminal/src/application/border.rs
deleted file mode 100644
index 6a71b27ce1..0000000000
--- a/platform/render-terminal/src/application/border.rs
+++ /dev/null
@@ -1,124 +0,0 @@
-/*
- * 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.
- */
-
-//! Border geometry and painting extracted from the original TUI boundary.
-use super::{CellRect, Placement};
-use maka_ui_runtime::scene::terminal::BorderStyle;
-use ratatui::{
- buffer::Buffer,
- style::{Color, Style},
-};
-
-pub(super) fn inner(area: Placement) -> Placement {
- Placement::new(
- CellRect::new(
- area.x + 1,
- area.y + 1,
- area.width.saturating_sub(2),
- area.height.saturating_sub(2),
- ),
- area.visible(),
- )
-}
-
-pub(super) fn paint(buffer: &mut Buffer, area: Placement, color: Color, style: BorderStyle) {
- let visible = area.visible();
- if visible.is_empty() {
- return;
- }
- let right = area.right() - 1;
- let bottom = area.bottom() - 1;
- let horizontal = [area.y, bottom]
- .iter()
- .any(|y| (i64::from(visible.top())..i64::from(visible.bottom())).contains(y));
- let vertical = [area.x, right]
- .iter()
- .any(|x| (i64::from(visible.left())..i64::from(visible.right())).contains(x));
- // A viewport may expose only the body. Never add a border at a clip edge.
- if !horizontal && !vertical {
- return;
- }
- let (corners, horizontal, vertical) = match style {
- BorderStyle::Rounded => (["╭", "╮", "╰", "╯"], "─", "│"),
- BorderStyle::Square => (["┌", "┐", "└", "┘"], "─", "│"),
- BorderStyle::Ascii => (["+"; 4], "-", "|"),
- };
- let style = Style::default().fg(color);
- for y in visible.top()..visible.bottom() {
- for x in visible.left()..visible.right() {
- let symbol = match (
- i64::from(y) == area.y,
- i64::from(y) == bottom,
- i64::from(x) == area.x,
- i64::from(x) == right,
- ) {
- (true, _, true, _) => corners[0],
- (true, _, _, true) => corners[1],
- (_, true, true, _) => corners[2],
- (_, true, _, true) => corners[3],
- (true, _, _, _) | (_, true, _, _) => horizontal,
- (_, _, true, _) | (_, _, _, true) => vertical,
- _ => continue,
- };
- buffer[(x, y)].set_symbol(symbol).set_style(style);
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use ratatui::layout::Rect;
- #[test]
- fn clipped_frames_keep_original_edges_and_leave_the_body_untouched() {
- let clip = Rect::new(0, 0, 6, 3);
- for (style, corner) in [
- (BorderStyle::Rounded, "╰"),
- (BorderStyle::Square, "└"),
- (BorderStyle::Ascii, "+"),
- ] {
- let mut buffer = Buffer::empty(clip);
- paint(
- &mut buffer,
- Placement::new(CellRect::new(1, -2, 7, 5), clip),
- Color::Red,
- style,
- );
- assert_eq!(buffer[(1, 2)].symbol(), corner);
- assert_eq!(buffer[(3, 0)].symbol(), " ");
- assert_eq!(buffer[(5, 1)].symbol(), " ");
- assert_eq!(buffer[(0, 2)].symbol(), " ");
- }
- for (width, height) in [(1, 1), (1, 3), (3, 1), (2, 2)] {
- let area = Placement::from(Rect::new(0, 0, width, height));
- assert!(inner(area).is_empty());
- let mut buffer = Buffer::empty(clip);
- paint(&mut buffer, area, Color::Red, BorderStyle::Rounded);
- assert_eq!(buffer[(0, 0)].symbol(), "╭");
- }
- let mut buffer = Buffer::empty(clip);
- paint(
- &mut buffer,
- Placement::new(CellRect::new(-1, -1, 8, 5), clip),
- Color::Red,
- BorderStyle::Rounded,
- );
- assert!(buffer.content.iter().all(|cell| cell.symbol() == " "));
- }
-}
diff --git a/platform/render-terminal/src/application/checkpoint.rs b/platform/render-terminal/src/application/checkpoint.rs
deleted file mode 100644
index af111a260a..0000000000
--- a/platform/render-terminal/src/application/checkpoint.rs
+++ /dev/null
@@ -1,144 +0,0 @@
-/*
- * 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::{NativeContext, NativeInput, Placement, Slots, Widget};
-use maka_ui_runtime::checkpoint::value::{
- ValueCheckpoint, ValueCheckpointObservation, ValueCheckpointProps, contract,
-};
-use maka_ui_runtime::{
- ComponentCall, ComponentOutput, Frame, NativeCapability, NativeComponent, NativeExport,
- NativeRegistry, NativeView,
-};
-use ratatui::buffer::Buffer;
-use std::{any::Any, collections::BTreeMap, rc::Rc};
-struct Recipe(ValueCheckpointProps);
-fn observation(owner: &mut ValueCheckpoint) -> Option<NativeInput> {
- owner.observe().map(|value| NativeInput {
- replaceable: false,
- key: "changed".into(),
- value: serde_json::to_value(value).unwrap(),
- })
-}
-impl Widget for Recipe {
- fn publication_capacity(&self, state: &dyn Any) -> Vec<(&'static str, usize)> {
- let slots = state
- .downcast_ref::<ValueCheckpoint>()
- .unwrap()
- .publication_capacity(&self.0);
- vec![("changed", 128 * 1024); slots]
- }
-
- fn create(&self, context: NativeContext) -> Box<dyn Any> {
- Box::new(ValueCheckpoint::new(
- context.checkpoint,
- &self.0,
- &context.resources,
- context.budget,
- ))
- }
- fn commit(&self, state: &mut dyn Any) -> maka_ui_runtime::Result<Option<NativeInput>> {
- let owner = state.downcast_mut::<ValueCheckpoint>().unwrap();
- owner.update(&self.0);
- Ok(observation(owner))
- }
- fn observe(&self, state: &mut dyn Any) -> Option<NativeInput> {
- observation(state.downcast_mut::<ValueCheckpoint>().unwrap())
- }
- fn deactivate(&self, state: &mut dyn Any) {
- state.downcast_mut::<ValueCheckpoint>().unwrap().close();
- }
- fn measure(&self, _: &Slots<'_>, _: u16) -> maka_ui_runtime::Result<(u16, u32)> {
- Ok((0, 0))
- }
- fn paint(
- &self,
- _: &mut Slots<'_>,
- _: Placement,
- _: &mut Buffer,
- ) -> maka_ui_runtime::Result<()> {
- Ok(())
- }
-}
-struct Component;
-impl NativeComponent<dyn Widget> for Component {
- fn call(
- &self,
- call: ComponentCall<'_>,
- _: &maka_ui_runtime::abi::ValueBindings,
- ) -> maka_ui_runtime::Result<ComponentOutput<NativeView<dyn Widget>>> {
- let mut events = vec![];
- let (props, revision) = match call {
- ComponentCall::Mount { props } => (props, 0),
- ComponentCall::Update { props, state } => {
- (props, state["revision"].as_u64().unwrap() + 1)
- }
- ComponentCall::Event {
- state,
- props,
- key: "changed",
- input,
- ..
- } => {
- events.push(maka_ui_runtime::Emission {
- name: "changed".into(),
- value: input.clone(),
- });
- (props, state["revision"].as_u64().unwrap() + 1)
- }
- ComponentCall::Event { .. } => {
- return Err(maka_ui_runtime::Error::Invalid(
- "checkpoint has no raw inputs".into(),
- ));
- }
- };
- let parsed: ValueCheckpointProps = serde_json::from_value(props.clone())
- .map_err(|e| maka_ui_runtime::Error::Invalid(e.to_string()))?;
- let view = NativeView::<dyn Widget>::new(
- Rc::new(Recipe(parsed)),
- BTreeMap::from([(
- "changed".into(),
- serde_json::to_value(schemars::schema_for!(ValueCheckpointObservation)).unwrap(),
- )]),
- vec![],
- )?;
- Ok(Frame {
- state: serde_json::json!({"revision":revision}),
- revision,
- surface: None,
- view,
- }
- .output(events))
- }
-}
-pub(super) fn register(registry: &mut NativeRegistry<dyn Widget>) {
- registry
- .register(
- maka_ui_runtime::checkpoint::value::IMPLEMENTATION,
- |_, _| {
- Ok(BTreeMap::from([(
- "Checkpoint".into(),
- NativeExport {
- contract: contract(),
- value: NativeCapability::Component(Rc::new(Component)),
- },
- )]))
- },
- )
- .expect("unique terminal checkpoint");
-}
diff --git a/platform/render-terminal/src/application/choice.rs b/platform/render-terminal/src/application/choice.rs
deleted file mode 100644
index a6c0717667..0000000000
--- a/platform/render-terminal/src/application/choice.rs
+++ /dev/null
@@ -1,407 +0,0 @@
-/*
- * 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.
- */
-
-//! Original Surface choice menu, using the presenter's existing modal custody.
-use super::{
- CellRect, NativeInput, PaintedScene, Placement, Presenter, WidgetInput, dialog, paint,
- scroll::SceneState,
-};
-use crossterm::event::{Event, KeyCode, KeyEventKind, MouseButton, MouseEventKind};
-use maka_ui_runtime::scene::terminal::{BorderStyle, Choice};
-use ratatui::{
- buffer::Buffer,
- layout::{Position, Rect},
- style::{Modifier, Style},
-};
-use serde_json::json;
-use std::rc::Rc;
-use unicode_width::UnicodeWidthStr;
-
-#[derive(Default)]
-pub(super) struct State {
- highlighted: Option<String>,
- pub geometry: Option<Geometry>,
-}
-pub(super) struct Geometry {
- pub choice: Choice,
- pub menu: Option<Menu>,
-}
-pub(super) struct Menu {
- pub rect: Rect,
- first: usize,
- rows: Vec<Rect>,
-}
-fn safe(text: &str) -> String {
- paint::sanitize(text).replace('\n', " ")
-}
-fn value(choice: &Choice) -> &str {
- choice
- .current
- .as_ref()
- .and_then(|key| choice.options.iter().find(|option| &option.key == key))
- .map_or("", |option| &option.label)
-}
-pub(super) fn size(choice: &Choice, width: u16) -> (u16, u32) {
- (
- (safe(&choice.label).width() + safe(value(choice)).width() + 3).min(usize::from(width))
- as u16,
- u32::from(width > 0),
- )
-}
-// Original Surface::choice_start keeps a clipped list's highlight visible.
-fn first(previous: Option<usize>, highlighted: usize, rows: usize, len: usize) -> usize {
- let first = previous.unwrap_or(0).min(len.saturating_sub(rows));
- if highlighted < first {
- highlighted
- } else {
- first.max((highlighted + 1).saturating_sub(rows))
- }
-}
-pub(super) fn bounds(choice: &Choice, anchor: Placement, area: Rect) -> Option<Rect> {
- let anchor = anchor.visible();
- if anchor.is_empty() {
- return None;
- }
- let width = choice
- .options
- .iter()
- .map(|option| safe(&option.label).width())
- .max()
- .unwrap_or(0)
- .saturating_add(6)
- .min(usize::from(area.width)) as u16;
- let rows = (choice.options.len() as u16).min(area.height.saturating_sub(2));
- if rows == 0 || width < 7 {
- return None;
- }
- let height = rows + 2;
- let y = if anchor.bottom().saturating_add(height) <= area.bottom() {
- anchor.bottom()
- } else {
- anchor.y.saturating_sub(height).max(area.y)
- };
- let x = anchor
- .right()
- .saturating_sub(width)
- .clamp(area.x, area.right().saturating_sub(width));
- Some(Rect::new(x, y, width, height).intersection(area))
-}
-impl SceneState {
- pub fn choice_input(&mut self, choice: &Choice, event: &Event) -> WidgetInput {
- let key = &choice.key;
- let Some(state) = self.choices.get_mut(key) else {
- return WidgetInput::Consumed(None);
- };
- let Some(geometry) = &state.geometry else {
- return WidgetInput::Consumed(None);
- };
- if geometry.choice != *choice || choice.disabled || choice.options.is_empty() {
- return WidgetInput::Consumed(None);
- }
- if !self.dialogs.contains_key(key) {
- if matches!(event, Event::Key(key) if key.kind != KeyEventKind::Release && key.modifiers.is_empty() && matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')))
- || matches!(event, Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left))
- {
- state.highlighted = choice
- .current
- .clone()
- .or_else(|| choice.options.first().map(|option| option.key.clone()));
- self.dialogs
- .insert(key.clone(), Rc::new(dialog::Opening::new()));
- return WidgetInput::Consumed(None);
- }
- return WidgetInput::Unhandled;
- }
- let Some(menu) = &geometry.menu else {
- return WidgetInput::Consumed(None);
- };
- let mut chosen = None;
- let mut close = false;
- match event {
- Event::Key(key) if key.kind != KeyEventKind::Release => {
- let index = choice
- .options
- .iter()
- .position(|option| Some(&option.key) == state.highlighted.as_ref())
- .unwrap_or(0);
- match key.code {
- KeyCode::Up | KeyCode::Down => {
- let index = if key.code == KeyCode::Up {
- index.saturating_sub(1)
- } else {
- (index + 1).min(choice.options.len() - 1)
- };
- state.highlighted = Some(choice.options[index].key.clone());
- }
- KeyCode::Enter | KeyCode::Char(' ') => {
- chosen = Some(choice.options[index].key.clone())
- }
- KeyCode::Esc | KeyCode::Tab | KeyCode::BackTab => close = true,
- _ => {}
- }
- }
- Event::Mouse(mouse) => {
- let point = Position::new(mouse.column, mouse.row);
- match mouse.kind {
- MouseEventKind::Down(MouseButton::Right) => close = true,
- MouseEventKind::Down(MouseButton::Left) => {
- if let Some(index) = menu.rows.iter().position(|row| row.contains(point)) {
- chosen = Some(choice.options[menu.first + index].key.clone());
- } else if !menu.rect.contains(point) {
- close = true;
- }
- }
- _ => {}
- }
- }
- _ => {}
- }
- if close || chosen.is_some() {
- self.dialogs.remove(&choice.key);
- }
- chosen.map_or(WidgetInput::Consumed(None), |key| {
- WidgetInput::Emitted(NativeInput {
- replaceable: false,
- key: choice.key.clone(),
- value: json!({"kind":"selected","key":key}),
- })
- })
- }
- pub fn choice_hover(&mut self, choice: &Choice, point: Option<Position>) -> bool {
- if !self.dialogs.contains_key(&choice.key) {
- return false;
- }
- let Some(state) = self.choices.get_mut(&choice.key) else {
- return false;
- };
- let Some(geometry) = state
- .geometry
- .as_ref()
- .filter(|geometry| geometry.choice == *choice)
- else {
- return false;
- };
- if let Some((menu, point)) = geometry.menu.as_ref().zip(point)
- && let Some(index) = menu.rows.iter().position(|row| row.contains(point))
- {
- let key = &choice.options[menu.first + index].key;
- if state.highlighted.as_ref() != Some(key) {
- state.highlighted = Some(key.clone());
- return true;
- }
- }
- false
- }
-}
-impl Presenter {
- pub(super) fn paint_choice(
- &self,
- mount: &maka_plugin_core::runtime::Mount,
- choice: &Choice,
- area: Placement,
- buffer: &mut Buffer,
- scene: &mut PaintedScene,
- choices: &mut Vec<Geometry>,
- ) -> maka_ui_runtime::Result<()> {
- let area = Placement::new(CellRect::new(area.x, area.y, area.width, 1), area.clip);
- let mut style = Style::default().fg(paint::color(choice.foreground));
- let target = paint::Target {
- identity: paint::TargetIdentity {
- mount: mount.clone(),
- key: choice.key.clone(),
- owner: self.resources.owners[&mount.id()].identity.clone(),
- scope: scene.input_scope.clone(),
- },
- revision: self.application.frame(mount)?.revision,
- area,
- tab_index: 0,
- autofocus: false,
- kind: paint::TargetKind::Native,
- ancestors: scene.ancestry.clone(),
- };
- if choice.disabled {
- style = style.add_modifier(Modifier::DIM);
- }
- let mut value_style = style.bg(paint::color(choice.background));
- if !choice.disabled && self.focus.as_ref().is_some_and(|focus| focus.same(&target)) {
- value_style = value_style
- .fg(paint::color(choice.accent))
- .add_modifier(Modifier::UNDERLINED);
- }
- let tail = format!(
- "{} {}",
- safe(value(choice)),
- if choice.ascii { "v" } else { "▾" }
- );
- let tail_width = tail.width().min(usize::from(area.width)) as u16;
- let label_width = area.width.saturating_sub(tail_width.saturating_add(1));
- paint::text(
- &safe(&choice.label),
- Placement::new(CellRect::new(area.x, area.y, label_width, 1), area.clip),
- buffer,
- style,
- );
- paint::text(
- &tail,
- Placement::new(
- CellRect::new(area.right() - i64::from(tail_width), area.y, tail_width, 1),
- area.clip,
- ),
- buffer,
- value_style,
- );
- if !choice.disabled && !choice.options.is_empty() && target.keyboard_reachable() {
- scene.targets.push(target);
- }
- let state = self.resources.owners[&mount.id()]
- .state
- .downcast_ref::<SceneState>()
- .unwrap();
- if let Some(opening) = state.dialogs.get(&choice.key) {
- scene.dialogs.push_back(dialog::Deferred {
- mount: mount.clone(),
- key: choice.key.clone(),
- opening: Rc::downgrade(opening),
- parent_path: scene.input_path.clone(),
- anchor: Some(area),
- });
- } else {
- choices.push(Geometry {
- choice: choice.clone(),
- menu: None,
- });
- }
- Ok(())
- }
- pub(super) fn paint_choice_menu(
- &self,
- mount: &maka_plugin_core::runtime::Mount,
- choice: &Choice,
- rect: Rect,
- buffer: &mut Buffer,
- scene: &mut PaintedScene,
- ) -> Geometry {
- let state = self.resources.owners[&mount.id()]
- .state
- .downcast_ref::<SceneState>()
- .unwrap();
- let state = &state.choices[&choice.key];
- let highlighted = choice
- .options
- .iter()
- .position(|option| Some(&option.key) == state.highlighted.as_ref())
- .unwrap_or(0);
- let rows = usize::from(rect.height.saturating_sub(2));
- let first = first(
- state
- .geometry
- .as_ref()
- .and_then(|geometry| geometry.menu.as_ref())
- .map(|menu| menu.first),
- highlighted,
- rows,
- choice.options.len(),
- );
- super::border::paint(
- buffer,
- rect.into(),
- paint::color(choice.border),
- if choice.ascii {
- BorderStyle::Ascii
- } else {
- BorderStyle::Rounded
- },
- );
- let mut painted = Vec::new();
- for (row, index) in (first..choice.options.len()).take(rows).enumerate() {
- let line = Rect::new(rect.x + 1, rect.y + 1 + row as u16, rect.width - 2, 1);
- let option = &choice.options[index];
- let current = choice.current.as_ref() == Some(&option.key);
- let marker = match (current, choice.ascii) {
- (true, false) => "● ",
- (false, false) => "○ ",
- (true, true) => "* ",
- (false, true) => " ",
- };
- let mut style = Style::default()
- .fg(paint::color(if current {
- choice.accent
- } else {
- choice.foreground
- }))
- .bg(paint::color(choice.background));
- if highlighted == index {
- style = style.bg(paint::color(choice.selected));
- }
- buffer.set_style(line, style);
- paint::text(
- &format!(" {marker}{}", safe(&option.label)),
- line.into(),
- buffer,
- style,
- );
- painted.push(line);
- }
- scene.targets.push(paint::Target {
- identity: paint::TargetIdentity {
- mount: mount.clone(),
- key: choice.key.clone(),
- owner: self.resources.owners[&mount.id()].identity.clone(),
- scope: scene.input_scope.clone(),
- },
- revision: self.application.frame(mount).unwrap().revision,
- area: rect.into(),
- tab_index: 0,
- autofocus: false,
- kind: paint::TargetKind::Native,
- ancestors: Vec::new(),
- });
- Geometry {
- choice: choice.clone(),
- menu: Some(Menu {
- rect,
- first,
- rows: painted,
- }),
- }
- }
- /// Reconcile local openings only from a fully validated successful batch.
- /// A clipped/unplaced trigger cannot resurrect a menu when it later returns.
- pub(super) fn reconcile_choices(&mut self, scene: &PaintedScene) {
- for (id, owner) in &mut self.resources.owners {
- let Some(state) = owner.state.downcast_mut::<SceneState>() else {
- continue;
- };
- for key in state.choices.keys() {
- let shown = scene
- .geometries
- .iter()
- .filter(|geometry| geometry.mount.id() == *id)
- .filter_map(|geometry| {
- geometry.geometry.downcast_ref::<super::scroll::Geometry>()
- })
- .flat_map(|geometry| &geometry.choices)
- .any(|geometry| geometry.choice.key == *key && geometry.menu.is_some());
- if !shown {
- state.dialogs.remove(key);
- }
- }
- }
- }
-}
diff --git a/platform/render-terminal/src/application/context_menu.rs b/platform/render-terminal/src/application/context_menu.rs
deleted file mode 100644
index 8589abdb02..0000000000
--- a/platform/render-terminal/src/application/context_menu.rs
+++ /dev/null
@@ -1,587 +0,0 @@
-/*
- * 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.
- */
-
-//! Object menus reuse dialog focus custody and ordinary physical action targets.
-use super::{
- CellRect, PaintedScene, Placement, Presenter, dialog, paint, scene::Node, scroll::SceneState,
-};
-use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
-use ratatui::{
- buffer::Buffer,
- layout::Position,
- style::{Modifier, Style},
-};
-use std::rc::Rc;
-
-impl Presenter {
- pub(super) fn open_context_menu(&mut self, event: &Event) -> maka_ui_runtime::Result<bool> {
- if self
- .physical_path
- .last()
- .and_then(|scope| scope.upgrade())
- .is_some_and(|opening| opening.anchor.is_some())
- && let Event::Key(key) = event
- && key.kind != KeyEventKind::Release
- && key.modifiers.is_empty()
- && matches!(
- key.code,
- KeyCode::Up | KeyCode::Down | KeyCode::Home | KeyCode::End
- )
- {
- let peers: Vec<_> = self
- .scene
- .iter()
- .filter(|target| target.tab_index >= 0 && self.target_admitted(target))
- .collect();
- if !peers.is_empty() {
- let at = self
- .focus
- .as_ref()
- .and_then(|focus| peers.iter().position(|target| target.same(focus)))
- .unwrap_or(0);
- let next = match key.code {
- KeyCode::Up => at.saturating_sub(1),
- KeyCode::Down => (at + 1).min(peers.len() - 1),
- KeyCode::Home => 0,
- _ => peers.len() - 1,
- };
- self.focus_item(peers[next].clone())?;
- }
- return Ok(true);
- }
- let target = match event {
- Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Right) => {
- self.scene.iter().rev().find(|target| {
- matches!(target.kind, paint::TargetKind::ContextMenu { .. })
- && target.area.contains((mouse.column, mouse.row).into())
- && self.target_admitted(target)
- })
- }
- Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => self
- .scene
- .iter()
- .rev()
- .find(|target| target.area.contains((mouse.column, mouse.row).into()))
- .filter(|target| {
- matches!(target.kind, paint::TargetKind::ContextMenu { button: true })
- }),
- Event::Key(key)
- if key.kind != KeyEventKind::Release
- && key.code == KeyCode::F(10)
- && key.modifiers == KeyModifiers::SHIFT =>
- {
- self.focus.as_ref().and_then(|focus| {
- self.scene.iter().rev().find(|target| {
- matches!(
- target.kind,
- paint::TargetKind::ContextMenu { button: false }
- ) && target.area.contains(focus.area.visible().as_position())
- && self.target_admitted(target)
- })
- })
- }
- Event::Key(key)
- if key.kind != KeyEventKind::Release
- && key.modifiers.is_empty()
- && matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) =>
- {
- self.focus.as_ref().filter(|target| {
- matches!(target.kind, paint::TargetKind::ContextMenu { button: true })
- })
- }
- _ => None,
- }
- .cloned();
- let Some(target) = target else {
- return Ok(false);
- };
- if !self.target_admitted(&target)
- || !self.resources.admits(&target)
- || self.application.frame(&target.mount)?.revision != target.revision
- {
- return Ok(true);
- }
- let mut opening = dialog::Opening::new();
- opening.anchor = Some(match event {
- Event::Mouse(mouse) => Position::new(mouse.column, mouse.row),
- _ => target.area.visible().as_position(),
- });
- if self
- .application
- .frame(&target.mount)?
- .view
- .view
- .scene()
- .and_then(|node| node.overlay(&target.key))
- .is_some_and(|node| {
- matches!(
- node,
- Node::ContextMenu {
- copy_selection_label: Some(_),
- ..
- }
- )
- })
- && let Some(focus) = self
- .focus
- .clone()
- .filter(|focus| self.target_admitted(focus))
- {
- opening.selection = match self.resources.copy(&focus, false)? {
- Some(text) => Some(text.into()),
- None => self
- .item_reading_scope(&focus)
- .map(|(scope, _)| scope.copy())
- .transpose()?
- .flatten()
- .map(Into::into),
- };
- }
- self.clear_hover()?;
- self.resources.cancel_pointer()?;
- self.drag = None;
- let state = self
- .resources
- .owners
- .get_mut(&target.mount.id())
- .unwrap()
- .state
- .downcast_mut::<SceneState>()
- .unwrap();
- state.dialogs.insert(target.key.clone(), Rc::new(opening));
- self.sync_native()?;
- Ok(true)
- }
- /// Close only the originating menu, including when the action opens another dialog.
- pub(super) fn close_context_action(&mut self, scope: Option<&dialog::Scope>) {
- let Some(scope) = scope else { return };
- for owner in self.resources.owners.values_mut() {
- if let Some(state) = owner.state.downcast_mut::<SceneState>() {
- state.dialogs.retain(|_, opening| {
- opening.anchor.is_none() || !scope.ptr_eq(&Rc::downgrade(opening))
- });
- }
- }
- }
- pub(super) fn paint_context_menu(
- &self,
- mount: &maka_plugin_core::runtime::Mount,
- node: &Node,
- area: Placement,
- buffer: &mut Buffer,
- scene: &mut PaintedScene,
- pass: &mut paint::NodePaint<'_>,
- ) -> maka_ui_runtime::Result<()> {
- let Node::ContextMenu {
- key,
- background,
- appearance,
- ..
- } = node
- else {
- unreachable!()
- };
- let target = paint::Target {
- identity: paint::TargetIdentity {
- mount: mount.clone(),
- key: key.clone(),
- owner: self.resources.owners[&mount.id()].identity.clone(),
- scope: scene.input_scope.clone(),
- },
- revision: self.application.frame(mount)?.revision,
- area,
- tab_index: -1,
- autofocus: false,
- kind: paint::TargetKind::ContextMenu { button: false },
- ancestors: scene.ancestry.clone(),
- };
- if !area.is_empty() {
- scene.targets.push(target.clone());
- }
- let width = area.width.saturating_sub(3);
- self.paint_node(
- mount,
- background,
- Placement::new(CellRect::new(area.x, area.y, width, area.height), area.clip),
- buffer,
- scene,
- pass,
- )?;
- // A clipped menu still carries logical Item targets for keyboard
- // navigation. Only its pointer targets and overlay require placement.
- if area.is_empty() {
- return Ok(());
- }
- let button = Placement::new(
- CellRect::new(area.x + i64::from(width), area.y, area.width - width, 1),
- area.clip,
- );
- let button_target = paint::Target {
- area: button,
- tab_index: 0,
- kind: paint::TargetKind::ContextMenu { button: true },
- ..target
- };
- let focused = self
- .focus
- .as_ref()
- .is_some_and(|focus| focus.same(&button_target));
- let mut style = Style::default().fg(paint::color(
- appearance.title_color.unwrap_or(appearance.border_color),
- ));
- if focused
- || self
- .hover
- .as_ref()
- .is_some_and(|hover| hover.same(&button_target))
- {
- style = style.add_modifier(Modifier::UNDERLINED);
- } else {
- style = style.add_modifier(Modifier::DIM);
- }
- paint::text(
- if appearance.ascii { "..." } else { " ⋯ " },
- button,
- buffer,
- style,
- );
- if !button.is_empty() {
- scene.targets.push(button_target);
- }
- let state = self.resources.owners[&mount.id()]
- .state
- .downcast_ref::<SceneState>()
- .unwrap();
- if let Some(opening) = state.dialogs.get(key) {
- scene.dialogs.push_back(dialog::Deferred {
- mount: mount.clone(),
- key: key.clone(),
- opening: Rc::downgrade(opening),
- parent_path: scene.input_path.clone(),
- anchor: None,
- });
- }
- Ok(())
- }
-}
-
-/// A physical editor menu retains the original native owner and snapshot. Its
-/// actions use the ordinary input and clipboard paths, never guest commands.
-pub(super) struct EditorMenu {
- target: paint::Target,
- snapshot: maka_ui_runtime::editor::EditorSnapshot,
- menu: maka_ui_runtime::textarea::TextareaMenu,
- enabled: [bool; 4],
- anchor: Position,
- selected: usize,
- identity: Rc<()>,
- presented: bool,
-}
-impl EditorMenu {
- fn labels(&self) -> [&str; 4] {
- let labels = &self.menu.labels;
- [&labels.copy, &labels.cut, &labels.paste, &labels.select_all]
- }
- fn area(&self, viewport: ratatui::layout::Rect) -> ratatui::layout::Rect {
- use unicode_width::UnicodeWidthStr;
- let width = (self
- .labels()
- .iter()
- .map(|label| label.width())
- .max()
- .unwrap_or(0)
- + 4)
- .min(usize::from(viewport.width)) as u16;
- let height = 6.min(viewport.height);
- ratatui::layout::Rect::new(
- self.anchor
- .x
- .min(viewport.right().saturating_sub(width))
- .max(viewport.x),
- self.anchor
- .y
- .min(viewport.bottom().saturating_sub(height))
- .max(viewport.y),
- width,
- height,
- )
- }
-}
-impl Presenter {
- fn editor_menu_current(&self, menu: &EditorMenu) -> bool {
- self.target_admitted(&menu.target)
- && self.resources.admits(&menu.target)
- && self.application.mount_visible(&menu.target.mount)
- && self
- .focus
- .as_ref()
- .is_some_and(|focus| focus.same(&menu.target))
- && self.scene.iter().any(|target| {
- target.same(&menu.target)
- && target.area == menu.target.area
- && !target.area.is_empty()
- })
- && !self.application.is_retiring(&menu.target.mount)
- && self
- .application
- .frame(&menu.target.mount)
- .is_ok_and(|frame| frame.revision == menu.target.revision)
- && self
- .resources
- .owners
- .get(&menu.target.mount.id())
- .is_some_and(|owner| {
- owner.recipe.editing_menu(owner.state.as_ref()).is_some_and(
- |(style, snapshot, enabled)| {
- style == menu.menu
- && snapshot == menu.snapshot
- && enabled == menu.enabled
- },
- )
- })
- }
- pub(super) fn editor_context_menu(&mut self, event: &Event) -> maka_ui_runtime::Result<bool> {
- if self
- .editor_menu
- .as_ref()
- .is_some_and(|menu| !self.editor_menu_current(menu))
- {
- self.editor_menu = None;
- }
- if let Some(mut menu) = self.editor_menu.take() {
- if !menu.presented
- && !matches!(
- event,
- Event::FocusLost
- | Event::Resize(..)
- | Event::Key(crossterm::event::KeyEvent {
- code: KeyCode::Esc,
- ..
- })
- )
- {
- self.editor_menu = Some(menu);
- return Ok(true);
- }
- let mut activate = None;
- match event {
- Event::FocusLost | Event::Resize(..) => return Ok(false),
- Event::Key(key) if key.kind == KeyEventKind::Release => {
- self.editor_menu = Some(menu);
- return Ok(true);
- }
- Event::Key(key) => match key.code {
- KeyCode::Esc | KeyCode::Tab | KeyCode::BackTab => return Ok(true),
- KeyCode::Up | KeyCode::Down | KeyCode::Home | KeyCode::End => {
- menu.selected = match key.code {
- KeyCode::Up => (menu.selected + 3) % 4,
- KeyCode::Down => (menu.selected + 1) % 4,
- KeyCode::Home => 0,
- _ => 3,
- };
- self.editor_menu = Some(menu);
- return Ok(true);
- }
- KeyCode::Enter | KeyCode::Char(' ') => activate = Some(menu.selected),
- _ => return Ok(true),
- },
- Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => {
- let area = menu.area(self.viewport);
- if area.contains((mouse.column, mouse.row).into())
- && mouse.row > area.y
- && mouse.row < area.bottom().saturating_sub(1)
- {
- activate = Some(usize::from(mouse.row - area.y - 1));
- } else {
- return Ok(true);
- }
- }
- Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Right) => {}
- _ => {
- self.editor_menu = Some(menu);
- return Ok(true);
- }
- }
- if let Some(index) = activate {
- if index >= 4 || !menu.enabled[index] {
- self.editor_menu = Some(menu);
- return Ok(true);
- }
- if index <= 1 {
- self.clipboard = self.resources.copy(&menu.target, true)?.map(Into::into);
- if self.clipboard.is_none() {
- return Ok(true);
- }
- }
- let key = match index {
- 1 => Some(crossterm::event::KeyEvent::new(
- KeyCode::Backspace,
- KeyModifiers::NONE,
- )),
- 2 => Some(crossterm::event::KeyEvent::new(
- KeyCode::Char('v'),
- KeyModifiers::CONTROL,
- )),
- 3 => Some(crossterm::event::KeyEvent::new(
- KeyCode::Char('a'),
- KeyModifiers::CONTROL,
- )),
- _ => None,
- };
- if let Some(key) = key {
- match self.resources.input(&menu.target, &Event::Key(key))? {
- Some(super::WidgetInput::Consumed(Some(observed))) => {
- self.resources.observe(&menu.target.mount, observed)?;
- }
- Some(super::WidgetInput::Emitted(input)) => {
- self.accept_native_input(&menu.target.mount, input, None)?;
- }
- _ => {}
- }
- }
- self.sync_native()?;
- return Ok(true);
- }
- }
- let target = match event {
- Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Right) => self
- .scene
- .iter()
- .rev()
- .find(|target| {
- !matches!(
- target.kind,
- paint::TargetKind::ContextMenu { .. } | paint::TargetKind::Hint
- ) && target.area.contains((mouse.column, mouse.row).into())
- })
- .cloned(),
- Event::Key(key)
- if key.kind == KeyEventKind::Press
- && key.code == KeyCode::F(10)
- && key.modifiers == KeyModifiers::SHIFT =>
- {
- self.focus.clone()
- }
- _ => None,
- };
- let Some(target) = target else {
- return Ok(false);
- };
- if !self.target_admitted(&target)
- || !self.resources.admits(&target)
- || self.application.is_retiring(&target.mount)
- {
- return Ok(false);
- }
- let Some((menu, snapshot, enabled)) = self.resources.editing_menu(&target)? else {
- return Ok(false);
- };
- let anchor = match event {
- Event::Mouse(mouse) => Position::new(mouse.column, mouse.row),
- _ => target.area.visible().as_position(),
- };
- self.clear_hover()?;
- self.resources.cancel_pointer()?;
- self.drag = None;
- self.focus = Some(target.clone());
- self.editor_menu = Some(EditorMenu {
- target,
- snapshot,
- menu,
- enabled,
- anchor,
- selected: 0,
- identity: Rc::new(()),
- presented: false,
- });
- Ok(true)
- }
- pub(super) fn present_editor_menu(&mut self, presented: Option<&std::rc::Weak<()>>) {
- if let Some(menu) = &mut self.editor_menu {
- menu.presented =
- presented.is_some_and(|identity| identity.ptr_eq(&Rc::downgrade(&menu.identity)));
- }
- }
- pub(super) fn paint_editor_menu(
- &self,
- area: ratatui::layout::Rect,
- buffer: &mut Buffer,
- scene: &mut PaintedScene,
- ) {
- let Some(menu) = self
- .editor_menu
- .as_ref()
- .filter(|menu| self.editor_menu_current(menu))
- else {
- return;
- };
- if !dialog::same_scope(&menu.target.scope, &scene.foreground_path.last().cloned()) {
- return;
- }
- scene.editor_menu = Some(Rc::downgrade(&menu.identity));
- let area = menu.area(area);
- let appearance = &menu.menu.appearance;
- let color = |value: maka_ui_runtime::appearance::Color| {
- ratatui::style::Color::Rgb(
- (value.0 >> 24) as u8,
- (value.0 >> 16) as u8,
- (value.0 >> 8) as u8,
- )
- };
- let base = Style::default()
- .fg(color(appearance.text.foreground))
- .bg(color(appearance.background));
- for row in area.y..area.bottom() {
- buffer.set_stringn(
- area.x,
- row,
- " ".repeat(usize::from(area.width)),
- usize::from(area.width),
- base,
- );
- }
- super::border::paint(
- buffer,
- area.into(),
- color(appearance.border),
- maka_ui_runtime::scene::terminal::BorderStyle::Rounded,
- );
- for (index, label) in menu.labels().iter().enumerate() {
- let y = area.y + 1 + index as u16;
- if y >= area.bottom().saturating_sub(1) {
- break;
- }
- let style = if !menu.enabled[index] {
- base.fg(color(appearance.disabled_foreground))
- } else if index == menu.selected {
- base.fg(color(appearance.active_foreground))
- .bg(color(appearance.active_background))
- } else {
- base
- };
- buffer.set_stringn(
- area.x + 1,
- y,
- format!(" {} ", paint::sanitize(label)),
- usize::from(area.width.saturating_sub(2)),
- style,
- );
- }
- }
-}
diff --git a/platform/render-terminal/src/application/dialog.rs b/platform/render-terminal/src/application/dialog.rs
deleted file mode 100644
index 935f9009f3..0000000000
--- a/platform/render-terminal/src/application/dialog.rs
+++ /dev/null
@@ -1,843 +0,0 @@
-/*
- * 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.
- */
-
-//! Controlled terminal dialogs retain only original opening and weak focus custody.
-use super::{
- Presenter,
- focus::{Exit, Return},
- paint::{Ancestor, Target, TargetIdentity, TargetKind},
- scene::Node,
-};
-use std::rc::{Rc, Weak};
-use unicode_width::UnicodeWidthStr;
-
-pub(super) struct Opening {
- pub pending_flush: std::cell::Cell<bool>,
- pub anchor: Option<ratatui::layout::Position>,
- pub selection: Option<Rc<str>>,
-}
-impl Opening {
- pub(super) fn new() -> Self {
- Self {
- pending_flush: std::cell::Cell::new(true),
- anchor: None,
- selection: None,
- }
- }
-}
-pub(super) type Scope = Weak<Opening>;
-
-pub(super) fn same_scope(a: &Option<Scope>, b: &Option<Scope>) -> bool {
- match (a, b) {
- (None, None) => true,
- (Some(a), Some(b)) => a.ptr_eq(b) && a.strong_count() != 0,
- _ => false,
- }
-}
-pub(super) fn same_path(a: &[Scope], b: &[Scope]) -> bool {
- a.len() == b.len()
- && a.iter()
- .zip(b)
- .all(|(a, b)| a.ptr_eq(b) && a.strong_count() != 0)
-}
-
-#[derive(Clone)]
-struct WeakAncestor {
- mount: maka_plugin_core::runtime::Mount,
- key: String,
- owner: Weak<()>,
- scope: Option<Scope>,
-}
-
-#[derive(Clone)]
-pub(super) struct WeakTarget {
- mount: maka_plugin_core::runtime::Mount,
- key: String,
- owner: Weak<()>,
- role: (std::mem::Discriminant<TargetKind>, bool),
- ancestor: Option<Box<WeakAncestor>>,
-}
-impl WeakTarget {
- pub(super) fn new(target: &Target) -> Self {
- Self {
- mount: target.mount.clone(),
- key: target.key.clone(),
- owner: Rc::downgrade(&target.owner),
- role: target.role(),
- ancestor: target
- .ancestors
- .iter()
- .rev()
- .find(|ancestor| !ancestor.shortcuts.is_empty() || ancestor.focus_group.is_some())
- .map(|ancestor| {
- Box::new(WeakAncestor {
- mount: ancestor.mount.clone(),
- key: ancestor.key.clone(),
- owner: Rc::downgrade(&ancestor.owner),
- scope: ancestor.scope.clone(),
- })
- }),
- }
- }
- pub(super) fn matches(&self, target: &Target) -> bool {
- target.mount == self.mount
- && target.key == self.key
- && self.owner.ptr_eq(&Rc::downgrade(&target.owner))
- && self.role == target.role()
- }
- pub(super) fn same(&self, other: &Self) -> bool {
- self.mount == other.mount
- && self.key == other.key
- && self.owner.ptr_eq(&other.owner)
- && self.role == other.role
- }
- pub(super) fn exited(&self, exits: &[Exit]) -> bool {
- exits
- .iter()
- .any(|exit| exit.mount == self.mount.id() && exit.owner.ptr_eq(&self.owner))
- }
- pub fn resolve(&self, targets: &[Target]) -> Option<Target> {
- targets.iter().find(|target| self.matches(target)).cloned()
- }
- /// The original trigger may be disabled while its operation is settling.
- /// Keep keyboard input inside its surviving original scope, never a new
- /// owner or another modal. Explicit native focus exits bypass this fallback.
- fn resolve_ancestor(&self, targets: &[Target]) -> Option<Target> {
- let ancestor = self.ancestor.as_ref()?;
- if self.owner.strong_count() == 0 {
- return None;
- }
- targets
- .iter()
- .filter(|target| {
- target.tab_index >= 0
- && !target.area.is_empty()
- && same_scope(&target.scope, &ancestor.scope)
- && target.ancestors.iter().any(|current| {
- current.mount == ancestor.mount
- && current.key == ancestor.key
- && ancestor.owner.ptr_eq(&Rc::downgrade(¤t.owner))
- && same_scope(¤t.scope, &ancestor.scope)
- })
- })
- .min_by_key(|target| (target.tab_index == 0, target.tab_index))
- .cloned()
- }
-}
-pub(super) struct FocusReturn {
- opening: Scope,
- target: Option<WeakTarget>,
- native_return: Option<Return>,
-}
-
-impl super::scroll::SceneState {
- pub fn prepare_dialogs(&mut self, node: &Node, visible: bool) {
- fn visit(
- node: &Node,
- visible: bool,
- keys: &mut Vec<String>,
- choices: &mut Vec<String>,
- openings: &std::collections::BTreeMap<String, Rc<Opening>>,
- ) {
- match node {
- Node::Item { content, .. } => visit(content, visible, keys, choices, openings),
- Node::Choice(choice) => {
- if visible && !choice.disabled && !choice.options.is_empty() {
- choices.push(choice.key.clone());
- if openings.contains_key(&choice.key) {
- keys.push(choice.key.clone());
- }
- }
- }
- Node::ContextMenu {
- key,
- background,
- content,
- ..
- }
- | Node::AdaptivePane {
- key,
- background,
- content,
- ..
- } => {
- visit(background, visible, keys, choices, openings);
- if visible && openings.contains_key(key) {
- keys.push(key.clone());
- }
- // Layout visibility is acknowledged by the complete paint
- // batch, including openings declared in an unplaced pane.
- visit(content, visible, keys, choices, openings);
- }
- Node::Dialog {
- key,
- background,
- content,
- open,
- ..
- } => {
- visit(background, visible, keys, choices, openings);
- if visible && *open {
- keys.push(key.clone());
- }
- visit(content, visible && *open, keys, choices, openings);
- }
- Node::Stack {
- children,
- visible: local,
- ..
- } => {
- for child in children {
- visit(child, visible && *local, keys, choices, openings);
- }
- }
- _ => {}
- }
- }
- let mut keys = Vec::new();
- let mut choices = Vec::new();
- visit(node, visible, &mut keys, &mut choices, &self.dialogs);
- self.choices.retain(|key, _| choices.contains(key));
- for key in choices {
- self.choices.entry(key).or_default();
- }
- self.dialogs.retain(|key, _| keys.contains(key));
- for key in keys {
- self.dialogs
- .entry(key)
- .or_insert_with(|| Rc::new(Opening::new()));
- }
- }
-}
-
-impl Presenter {
- /// Original opening witnesses include unplaced declarations. Their first
- /// successful batch acknowledges omission as well as actual placement.
- pub(super) fn opening_witnesses(&self) -> maka_ui_runtime::Result<Vec<Scope>> {
- let mut openings = Vec::new();
- for mount in self.application.mounted_subtree(&self.root)? {
- if self.application.is_retiring(&mount) {
- continue;
- }
- if let Some(state) = self
- .resources
- .owners
- .get(&mount.id())
- .filter(|owner| owner.live)
- .and_then(|owner| owner.state.downcast_ref::<super::scroll::SceneState>())
- {
- openings.extend(state.dialogs.values().map(Rc::downgrade));
- }
- }
- Ok(openings)
- }
- pub(super) fn physical_admitted(&self) -> bool {
- self.resources
- .owners
- .values()
- .filter(|owner| owner.live)
- .all(|owner| {
- owner
- .state
- .downcast_ref::<super::scroll::SceneState>()
- .is_none_or(|state| {
- state
- .dialogs
- .values()
- .all(|opening| !opening.pending_flush.get())
- })
- })
- && self
- .physical_path
- .iter()
- .all(|scope| scope.strong_count() != 0)
- }
- pub(super) fn target_admitted(&self, target: &TargetIdentity) -> bool {
- self.physical_admitted() && same_scope(&target.scope, &self.physical_path.last().cloned())
- }
- pub(super) fn scene_openings_current(&self, openings: &[Scope]) -> bool {
- self.opening_witnesses()
- .is_ok_and(|current| same_path(openings, ¤t))
- }
- pub(super) fn sync_modal_focus(&mut self) -> maka_ui_runtime::Result<()> {
- if !self.physical_admitted() {
- self.resources.cancel_pointer()?;
- self.drag = None;
- if self.focus_before_flush.is_none() {
- self.focus_before_flush = self.focus.as_ref().map(WeakTarget::new);
- }
- self.focus = None;
- }
- Ok(())
- }
- /// Consume invalidation against the captured native owner, including a
- /// background selector covered by a modal. Its foreground keeps focus.
- pub(super) fn apply_modal_focus_exits(&mut self, exits: &[Exit]) {
- for memory in &mut self.modal_focus {
- if memory
- .target
- .as_ref()
- .is_some_and(|target| target.exited(exits))
- {
- memory.target = match memory.native_return.take() {
- Some(Return::Active { source, target, .. }) if source.exited(exits) => target,
- _ => None,
- };
- memory.native_return = Some(Return::Exited);
- }
- }
- // The opening may not have flushed yet: the original target still
- // resides in pre-flush custody rather than in a modal memory.
- if self
- .focus_before_flush
- .as_ref()
- .is_some_and(|target| target.exited(exits))
- {
- self.focus_before_flush = match self.native_return.take() {
- Some(Return::Active { source, target, .. }) if source.exited(exits) => target,
- _ => None,
- };
- self.native_return = Some(Return::Exited);
- }
- }
- /// Mechanical focus memory follows actual published ancestry, not mount order.
- pub(super) fn publish_modal_focus(&mut self, path: &[Scope], targets: &[Target]) {
- let prior = self
- .focus_before_flush
- .take()
- .or_else(|| self.focus.as_ref().map(WeakTarget::new));
- let retained = prior
- .as_ref()
- // A pane can reappear inline with the same target identity after
- // its drawer closes. That must not defeat the modal focus return.
- .filter(|_| same_scope(&self.physical_path.last().cloned(), &path.last().cloned()))
- .and_then(|target| target.resolve(targets))
- .map(|target| (Some(target), self.native_return.clone()));
- let restore = |memory: &FocusReturn| {
- memory
- .target
- .as_ref()
- .and_then(|target| {
- target.resolve(targets).or_else(|| {
- if memory.native_return.is_none() {
- target.resolve_ancestor(targets)
- } else {
- None
- }
- })
- })
- .map(|target| (Some(target), memory.native_return.clone()))
- };
- let exited = self.modal_focus.iter().any(|memory| {
- memory.opening.strong_count() == 0
- && matches!(memory.native_return, Some(Return::Exited))
- });
- let departing_memory = self.physical_path.last().and_then(|scope| {
- self.modal_focus
- .iter()
- .find(|memory| memory.opening.ptr_eq(scope))
- });
- // An action can close its menu and open a dialog in the same frame.
- // That replacement inherits the original return, not the retired action.
- let replacement = departing_memory
- .filter(|memory| memory.opening.strong_count() == 0)
- .map(|memory| (memory.target.clone(), memory.native_return.clone()));
- let departing = departing_memory.and_then(restore);
- let closed = self
- .modal_focus
- .iter()
- .rev()
- .filter(|memory| memory.opening.strong_count() == 0)
- .find_map(restore);
- self.modal_focus
- .retain(|memory| memory.opening.strong_count() != 0);
- let mut opened = false;
- for (index, opening) in path.iter().enumerate() {
- if self
- .modal_focus
- .iter()
- .any(|memory| memory.opening.ptr_eq(opening))
- {
- continue;
- }
- opened = true;
- // A wrapper inherits the original target and its exact native return
- // together. A covering sibling captures the actual foreground.
- let inherited = path[index + 1..].iter().find_map(|descendant| {
- self.modal_focus
- .iter()
- .find(|memory| memory.opening.ptr_eq(descendant))
- .map(|memory| (memory.target.clone(), memory.native_return.clone()))
- });
- let (target, native_return) =
- inherited
- .or_else(|| replacement.clone())
- .unwrap_or_else(|| {
- let native_return = match &self.native_return {
- Some(Return::Active { source, .. })
- if prior.as_ref().is_some_and(|prior| source.same(prior)) =>
- {
- self.native_return.clone()
- }
- Some(Return::Exited) => Some(Return::Exited),
- _ => None,
- };
- (prior.clone(), native_return)
- });
- self.modal_focus.push(FocusReturn {
- opening: opening.clone(),
- target,
- native_return,
- });
- }
- // A newly opened foreground must receive its own focus. Any background
- // exit marker now belongs to that opening's captured return, not its input.
- if opened {
- self.native_return = None;
- }
- // Resolve every surviving original ancestor before suppressing fallback
- // for an invalidated inner return. Simultaneous closes cannot hide it.
- let restore = retained.or(if opened {
- None
- } else {
- departing
- .or(closed)
- .or(exited.then_some((None, Some(Return::Exited))))
- });
- if self.focus.is_none()
- && let Some((target, native_return)) = restore
- {
- self.restore_focus = target.as_ref().map(WeakTarget::new);
- self.native_return = native_return.or((!opened && exited).then_some(Return::Exited));
- }
- }
- pub(super) fn dismiss(
- &mut self,
- target: &Target,
- reason: maka_ui_runtime::dismiss::Reason,
- ) -> maka_ui_runtime::Result<()> {
- if !self.target_admitted(target) || !self.resources.admits(target) {
- return Ok(());
- }
- if self
- .application
- .frame(&target.mount)?
- .view
- .view
- .scene()
- .and_then(|node| node.overlay(&target.key))
- .is_some_and(|node| {
- matches!(node, Node::AdaptivePane { .. } | Node::ContextMenu { .. })
- })
- {
- let state = self
- .resources
- .owners
- .get_mut(&target.mount.id())
- .unwrap()
- .state
- .downcast_mut::<super::scroll::SceneState>()
- .unwrap();
- if state.dialogs.get(&target.key).is_some_and(|opening| {
- target
- .scope
- .as_ref()
- .is_some_and(|scope| scope.ptr_eq(&Rc::downgrade(opening)))
- }) {
- state.dialogs.remove(&target.key);
- }
- return self.sync_native();
- }
- self.application.dispatch(
- &target.mount,
- target.revision,
- &target.key,
- serde_json::to_value(maka_ui_runtime::dismiss::Input::Dismiss { reason })
- .expect("dismiss input"),
- )?;
- self.sync_native()
- }
- pub(super) fn dismiss_escape(
- &mut self,
- event: &crossterm::event::Event,
- ) -> maka_ui_runtime::Result<bool> {
- use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
- if !matches!(event, Event::Key(key) if key.code == KeyCode::Esc && key.kind != KeyEventKind::Release && key.modifiers == KeyModifiers::NONE)
- {
- return Ok(false);
- }
- if let Some(target) = self
- .scene
- .iter()
- .rev()
- .find(|target| matches!(target.kind, TargetKind::Backdrop { .. }))
- .cloned()
- {
- self.dismiss(&target, maka_ui_runtime::dismiss::Reason::Escape)?;
- return Ok(true);
- }
- Ok(false)
- }
-}
-
-/// Paint-local deferred surface; neither this nor its weak opening retains authority.
-pub(super) struct Deferred {
- pub mount: maka_plugin_core::runtime::Mount,
- pub key: String,
- pub opening: Scope,
- pub parent_path: Vec<Scope>,
- pub anchor: Option<super::Placement>,
-}
-impl Presenter {
- pub(super) fn paint_dialogs(
- &self,
- buffer: &mut ratatui::buffer::Buffer,
- scene: &mut super::PaintedScene,
- measurements: super::paint::MeasureContext<'_>,
- ) -> maka_ui_runtime::Result<()> {
- use ratatui::{
- style::Style,
- widgets::{Block, Borders, Widget as _},
- };
- while let Some(deferred) = scene.dialogs.pop_front() {
- let recipe = &self.application.frame(&deferred.mount)?.view.view;
- let node = recipe
- .scene()
- .and_then(|node| node.overlay(&deferred.key))
- .expect("original deferred overlay");
- let full = scene.viewport;
- let (body, padding) = match node {
- Node::Dialog { .. } | Node::ContextMenu { .. } | Node::AdaptivePane { .. } => {
- let (appearance, content) = node.dialog_content().unwrap();
- let maka_ui_runtime::scene::terminal::DialogAppearance {
- width,
- height,
- padding,
- ..
- } = appearance;
- let margin = if full.width >= 4 && full.height >= 4 {
- 1
- } else {
- 0
- };
- let mut width = (*width).min(full.width.saturating_sub(margin * 2));
- let maximum_height = (*height).min(full.height.saturating_sub(margin * 2));
- let padding = (*padding)
- .min(width.saturating_sub(3) / 2)
- .min(maximum_height.saturating_sub(3) / 2);
- let inset = padding.saturating_add(1).saturating_mul(2);
- let (content_width, content_height) = self.measure(
- &deferred.mount,
- content,
- width.saturating_sub(inset),
- measurements,
- )?;
- if matches!(node, Node::ContextMenu { .. }) {
- let copy_width = match node {
- Node::ContextMenu {
- copy_selection_label: Some(label),
- ..
- } if deferred
- .opening
- .upgrade()
- .is_some_and(|opening| opening.selection.is_some()) =>
- {
- super::paint::text_size(label, width).0
- }
- _ => 0,
- };
- width =
- width.min(content_width.max(copy_width).saturating_add(inset).max(4));
- }
- // Growing content needs a bounded viewport before flex allocation;
- // its zero-basis children intentionally have no intrinsic height.
- let selection_row = u32::from(
- deferred
- .opening
- .upgrade()
- .is_some_and(|opening| opening.selection.is_some()),
- );
- let height = if self
- .node_layout(&deferred.mount, content, measurements)?
- .grow
- > 0
- {
- maximum_height
- } else {
- maximum_height.min(
- content_height
- .saturating_add(selection_row)
- .saturating_add(u32::from(inset))
- .min(u32::from(u16::MAX)) as u16,
- )
- }
- .max(1);
- let mut body = ratatui::layout::Rect::new(
- full.x + full.width.saturating_sub(width) / 2,
- full.y + full.height.saturating_sub(height) / 2,
- width,
- height,
- );
-
- if appearance.anchor_to_background
- && 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()
- .and_then(|opening| opening.anchor)
- {
- body.x = anchor.x.clamp(full.x, full.right().saturating_sub(width));
- body.y = anchor.y.clamp(full.y, full.bottom().saturating_sub(height));
- }
- (body, padding)
- }
- Node::Choice(choice) => {
- let Some(body) = super::choice::bounds(
- choice,
- deferred.anchor.expect("choice anchor"),
- full,
- ) else {
- // Absence in this successful batch closes the local opening.
- continue;
- };
- (body, 0)
- }
- _ => unreachable!(),
- };
- let previous_scope = scene.scope.take();
- let previous_input = scene.input_scope.replace(deferred.opening.clone());
- let previous_path = std::mem::replace(&mut scene.input_path, deferred.parent_path);
- scene.input_path.push(deferred.opening.clone());
- scene.foreground_path = scene.input_path.clone();
- let previous_ancestry = std::mem::take(&mut scene.ancestry);
- let identity = TargetIdentity {
- mount: deferred.mount.clone(),
- key: deferred.key.clone(),
- owner: self.resources.owners[&deferred.mount.id()].identity.clone(),
- scope: scene.input_scope.clone(),
- };
- scene.keyboard = Some(Rc::new(Ancestor {
- identity: identity.clone(),
- revision: self.application.frame(&deferred.mount)?.revision,
- shortcuts: Vec::new(),
- focus_group: None,
- scroll: None,
- focus_shortcuts: Vec::new(),
- }));
- scene.targets.push(Target {
- identity,
- revision: self.application.frame(&deferred.mount)?.revision,
- area: full.into(),
- tab_index: -1,
- autofocus: false,
- kind: if matches!(node, Node::Choice(_)) {
- TargetKind::Native
- } else {
- TargetKind::Backdrop { body: body.into() }
- },
- ancestors: Vec::new(),
- });
- for y in body.y..body.bottom() {
- // A background glyph may start outside the overlay but occupy
- // its first column. Retaining that lead makes terminal diffing
- // skip the new border, so erase the exposed fragment as well.
- let mut x = buffer.area.x;
- while x < body.x {
- let width = buffer[(x, y)].symbol().width().max(1);
- let end = usize::from(x) + width;
- if end > usize::from(body.x) {
- for column in x..body.x {
- buffer[(column, y)].set_symbol(" ");
- }
- break;
- }
- x = end as u16;
- }
- for x in body.x..body.right() {
- buffer[(x, y)].reset();
- }
- }
- let remaining = std::mem::take(&mut scene.dialogs);
- let mut viewports = Vec::new();
- let mut choices = Vec::new();
- let mut panes = Vec::new();
- match node {
- Node::Choice(choice) => {
- buffer.set_style(
- body,
- Style::default().bg(super::paint::color(choice.background)),
- );
- choices.push(self.paint_choice_menu(
- &deferred.mount,
- choice,
- body,
- buffer,
- scene,
- ));
- }
- Node::Dialog { .. } | Node::ContextMenu { .. } | Node::AdaptivePane { .. } => {
- let (appearance, content) = node.dialog_content().unwrap();
- let maka_ui_runtime::scene::terminal::DialogAppearance {
- label,
- background_color,
- border_color,
- ..
- } = appearance;
- Block::new()
- .borders(Borders::ALL)
- .border_set(if appearance.ascii {
- ratatui::symbols::border::Set {
- top_left: "+",
- top_right: "+",
- bottom_left: "+",
- bottom_right: "+",
- horizontal_top: "-",
- horizontal_bottom: "-",
- vertical_left: "|",
- vertical_right: "|",
- }
- } else {
- ratatui::symbols::border::ROUNDED
- })
- .title(ratatui::text::Span::styled(
- if matches!(node, Node::ContextMenu { .. }) {
- String::new()
- } else {
- super::sanitize(label)
- },
- Style::default().fg(super::paint::color(
- appearance.title_color.unwrap_or(*border_color),
- )),
- ))
- .style(Style::default().bg(super::paint::color(*background_color)))
- .border_style(Style::default().fg(super::paint::color(*border_color)))
- .render(body, buffer);
- let mut content_area =
- super::Placement::from(body).inner(ratatui::layout::Margin::new(
- padding.saturating_add(1),
- padding.saturating_add(1),
- ));
- if let Node::ContextMenu {
- copy_selection_label: Some(label),
- ..
- } = node
- && let Some(text) = deferred
- .opening
- .upgrade()
- .and_then(|opening| opening.selection.clone())
- {
- let row = super::Placement::new(
- super::CellRect::new(
- content_area.x,
- content_area.y,
- content_area.width,
- 1,
- ),
- content_area.clip,
- );
- let target = Target {
- identity: TargetIdentity {
- mount: deferred.mount.clone(),
- key: deferred.key.clone(),
- owner: self.resources.owners[&deferred.mount.id()].identity.clone(),
- scope: scene.input_scope.clone(),
- },
- revision: self.application.frame(&deferred.mount)?.revision,
- area: row,
- tab_index: 0,
- autofocus: false,
- kind: TargetKind::SelectionCopy { text },
- ancestors: Vec::new(),
- };
- let mut style = Style::default().fg(super::paint::color(
- appearance.title_color.unwrap_or(appearance.border_color),
- ));
- if self.focus.as_ref().is_some_and(|focus| focus.same(&target))
- || self.hover.as_ref().is_some_and(|hover| hover.same(&target))
- {
- style = style.add_modifier(ratatui::style::Modifier::REVERSED);
- }
- buffer.set_style(row.visible(), style);
- super::paint::text(label, row, buffer, style);
- if !row.is_empty() {
- scene.targets.push(target);
- }
- content_area = super::Placement::new(
- super::CellRect::new(
- content_area.x,
- content_area.y + 1,
- content_area.width,
- content_area.height.saturating_sub(1),
- ),
- content_area.clip,
- );
- }
- self.paint_node(
- &deferred.mount,
- content,
- content_area,
- buffer,
- scene,
- &mut super::paint::NodePaint {
- measurements,
- viewports: &mut viewports,
- choices: &mut choices,
- panes: &mut panes,
- },
- )?;
- }
- _ => unreachable!(),
- }
- scene.geometries.push(super::paint::NativeGeometry {
- mount: deferred.mount.clone(),
- owner: self.resources.owners[&deferred.mount.id()].identity.clone(),
- geometry: Box::new(super::scroll::Geometry {
- generation: self.resources.owners[&deferred.mount.id()]
- .state
- .downcast_ref::<super::scroll::SceneState>()
- .unwrap()
- .generation,
- viewports,
- choices,
- panes,
- }),
- });
- self.paint_dialogs(buffer, scene, measurements)?;
- scene.dialogs = remaining;
- scene.scope = previous_scope;
- scene.input_scope = previous_input;
- scene.input_path = previous_path;
- scene.ancestry = previous_ancestry;
- }
- Ok(())
- }
-}
diff --git a/platform/render-terminal/src/application/editor.rs b/platform/render-terminal/src/application/editor.rs
deleted file mode 100644
index 5773f09c3c..0000000000
--- a/platform/render-terminal/src/application/editor.rs
+++ /dev/null
@@ -1,380 +0,0 @@
-/*
- * 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::NativeInput;
-use crate::editor::{Editor, Geometry};
-use maka_ui_runtime::editor::*;
-use serde_json::{Value, json};
-use std::{ops::Range, sync::Arc};
-pub(super) struct Owner {
- pub editor: Editor,
- pub redraw: bool,
- pub clipboard: Option<super::textarea::paste::Paste>,
- pub presented: Option<Geometry>,
- pub focus: maka_ui_runtime::focus::FocusOnReveal,
- id: String,
- revision: u64,
- last_edit: u64,
- last_selection: (Range<usize>, bool),
- pub last_request: Option<EditorCommand>,
- sequence: u64,
- receipt: Option<EditorReceipt>,
- observed_receipt: Option<EditorReceipt>,
- pub announced: bool,
-}
-impl Owner {
- pub fn invalidate_geometry(&mut self) {
- self.cancel_paste();
- self.editor.invalidate_geometry();
- self.presented = None;
- }
- pub fn clipboard(&mut self, context: super::NativeContext) {
- self.clipboard = Some(super::textarea::paste::Paste::new(context));
- }
- pub fn paste(&mut self, spaces: bool) {
- if let Some(paste) = &mut self.clipboard {
- paste.text(&self.editor, spaces);
- }
- }
- pub fn cancel_paste(&mut self) {
- if let Some(paste) = &mut self.clipboard {
- paste.cancel_text();
- }
- }
- pub fn poll_paste(&mut self, enabled: bool) {
- let before = self.editor.error;
- if let Some(paste) = &mut self.clipboard {
- paste.poll_text(&mut self.editor, enabled);
- }
- self.redraw |= before != self.editor.error;
- }
- pub fn new(mut editor: Editor, initial_text: &str) -> Self {
- editor.insert(initial_text);
- editor.clear_history();
- let last_edit = editor.revision();
- let last_selection = (editor.selection_range(), editor.selection_reversed());
- Self {
- editor,
- clipboard: None,
- redraw: false,
- presented: None,
- focus: Default::default(),
- id: uuid::Uuid::new_v4().to_string(),
- revision: 0,
- last_edit,
- last_selection,
- last_request: None,
- sequence: 0,
- receipt: None,
- observed_receipt: None,
- announced: false,
- }
- }
- pub fn refresh(&mut self) -> bool {
- let edit = self.editor.revision();
- let selection = (
- self.editor.selection_range(),
- self.editor.selection_reversed(),
- );
- if edit != self.last_edit || selection != self.last_selection {
- self.last_edit = edit;
- self.last_selection = selection;
- self.revision += 1;
- return true;
- }
- false
- }
- pub fn snapshot(&self) -> EditorSnapshot {
- let range = self.editor.selection_range();
- EditorSnapshot {
- text: EditorTextRef {
- owner: self.id.clone(),
- revision: self.revision,
- bytes: self.editor.text().len(),
- },
- selection: TextRange {
- start: self.editor.text()[..range.start].encode_utf16().count(),
- end: self.editor.text()[..range.end].encode_utf16().count(),
- },
- reversed: self.editor.selection_reversed(),
- marked: None,
- }
- }
- pub fn current_observation(&self) -> EditorObservation {
- EditorObservation {
- revision: self.revision,
- snapshot: self.snapshot(),
- receipt: self.receipt.clone(),
- }
- }
- pub fn observation(&mut self) -> NativeInput {
- let observation = self.current_observation();
- let replaceable = self.receipt == self.observed_receipt;
- self.observed_receipt.clone_from(&self.receipt);
- NativeInput {
- replaceable,
- key: "observe".into(),
- value: serde_json::to_value(observation).expect("native editor observation"),
- }
- }
- pub fn reject(&mut self, command: &EditorCommand, reason: EditorConflict) {
- self.last_request = Some(command.clone());
- self.receipt = Some(EditorReceipt {
- owner: command.owner.clone(),
- sequence: command.sequence,
- revision: self.revision,
- outcome: EditorOutcome::Conflict { reason },
- });
- }
- pub fn apply(&mut self, command: &EditorCommand) {
- self.last_request = Some(command.clone());
- let outcome = if command.owner != self.id {
- EditorOutcome::Conflict {
- reason: EditorConflict::Owner,
- }
- } else if command.sequence <= self.sequence {
- EditorOutcome::Conflict {
- reason: EditorConflict::Sequence,
- }
- } else {
- self.sequence = command.sequence;
- if command.based_on != self.revision {
- EditorOutcome::Conflict {
- reason: EditorConflict::Revision,
- }
- } else {
- let value = command.edit.replacement(self.editor.text());
- if value
- .as_ref()
- .is_none_or(|value| !self.editor.accepts_text(value))
- {
- EditorOutcome::Conflict {
- reason: EditorConflict::Representation,
- }
- } else {
- let value = value.expect("validated editor replacement");
- match &command.edit {
- EditorEdit::ReplaceRange { range, text } => {
- let range = range
- .utf8_range(self.editor.text())
- .expect("validated range");
- self.editor.replace_range(range, text);
- }
- EditorEdit::Replace { text } => {
- self.editor.select_all();
- self.editor.insert(text);
- }
- EditorEdit::Reset { .. } | EditorEdit::Append { .. } => {
- self.editor.clear();
- self.editor.insert(&value);
- self.editor.clear_history();
- }
- }
- self.refresh();
- EditorOutcome::Applied
- }
- }
- };
- self.receipt = Some(EditorReceipt {
- owner: command.owner.clone(),
- sequence: command.sequence,
- revision: self.revision,
- outcome,
- });
- }
-}
-impl Owner {
- pub fn capture(
- &self,
- target: &maka_ui_runtime::actions::CaptureTarget,
- ) -> maka_ui_runtime::Result<Arc<dyn maka_capabilities::call::ReadResource>> {
- if target.method() != "snapshot" {
- return Err(maka_ui_runtime::Error::Invalid(
- "unknown terminal editor capture".into(),
- ));
- }
- let owner = self;
- let capture: EditorCapture = serde_json::from_value(target.input().clone())
- .map_err(|e| maka_ui_runtime::Error::Invalid(e.to_string()))?;
- if matches!(capture,EditorCapture::AtRevision {owner:ref expected,revision} if expected!=&owner.id || revision!=owner.revision)
- {
- return Err(maka_ui_runtime::Error::Invalid(
- "editor revision changed".into(),
- ));
- }
- Ok(Arc::new(Snapshot {
- owner: owner.id.clone(),
- text: owner.editor.text().to_owned(),
- revision: owner.revision,
- }))
- }
-}
-
-pub(super) fn error_message(error: &str) -> &str {
- match error {
- "input-too-large" | "composer-too-large" => "Text exceeds the input limit",
- "input-control" => "This input requires a single line of text",
- "composer-control" => "Text contains unsupported control characters",
- other => other,
- }
-}
-
-pub(super) struct Snapshot {
- pub owner: String,
- pub text: String,
- pub revision: u64,
-}
-impl maka_capabilities::call::ReadResource for Snapshot {
- fn encoding(&self) -> maka_capabilities::call::ResourceEncoding {
- maka_capabilities::call::ResourceEncoding::Utf8
- }
- fn resource_type(&self) -> maka_plugin_core::contract::ResourceType {
- text_resource_type()
- }
- fn bytes(&self) -> usize {
- self.text.len()
- }
- fn metadata(&self) -> Value {
- json!({"owner":self.owner,"revision":self.revision,"bytes":self.text.len()})
- }
- fn read(&self, range: Range<usize>) -> Result<Vec<u8>, String> {
- self.text
- .get(range)
- .map(|text| text.as_bytes().to_vec())
- .ok_or_else(|| "invalid editor UTF-8 range".into())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- #[test]
- fn range_command_preserves_unicode_suffix_caret_and_one_undo_step() {
- let original = "🙂e\u{301}/old suffix";
- let mut owner = Owner::new(Editor::bounded(256, "too-large"), original);
- let command = EditorCommand {
- owner: owner.id.clone(),
- sequence: 1,
- based_on: owner.revision,
- edit: EditorEdit::ReplaceRange {
- range: TextRange { start: 5, end: 8 },
- text: "new技能".into(),
- },
- };
- owner.apply(&command);
- assert_eq!(
- owner.receipt.as_ref().unwrap().outcome,
- EditorOutcome::Applied
- );
- assert_eq!(owner.editor.text(), "🙂e\u{301}/new技能 suffix");
- assert_eq!(owner.snapshot().selection, TextRange { start: 10, end: 10 });
- let mut rejected = command.clone();
- rejected.sequence = 2;
- owner.apply(&rejected);
- assert_eq!(
- owner.receipt.as_ref().unwrap().outcome,
- EditorOutcome::Conflict {
- reason: EditorConflict::Revision,
- }
- );
- rejected.sequence = 3;
- rejected.based_on = owner.revision;
- rejected.edit = EditorEdit::ReplaceRange {
- range: TextRange { start: 2, end: 3 },
- text: "bad".into(),
- };
- owner.apply(&rejected);
- assert_eq!(
- owner.receipt.as_ref().unwrap().outcome,
- EditorOutcome::Conflict {
- reason: EditorConflict::Representation,
- }
- );
- owner.editor.key(crossterm::event::KeyEvent::new(
- crossterm::event::KeyCode::Char('z'),
- crossterm::event::KeyModifiers::CONTROL,
- ));
- assert_eq!(owner.editor.text(), original);
- }
-
- #[test]
- fn initialization_keeps_one_native_owner_and_rejects_stale_append() {
- let mut owner = Owner::new(Editor::default(), "");
- let identity = owner.id.clone();
- let chunk = "中🌟".repeat(9000);
- for sequence in 1..=8 {
- owner.apply(&EditorCommand {
- owner: identity.clone(),
- sequence,
- based_on: owner.revision,
- edit: if sequence == 1 {
- EditorEdit::Reset {
- text: chunk.clone(),
- }
- } else {
- EditorEdit::Append {
- text: chunk.clone(),
- }
- },
- });
- assert_eq!(
- owner.receipt.as_ref().unwrap().outcome,
- EditorOutcome::Applied
- );
- assert_eq!(owner.editor.text(), chunk.repeat(sequence as usize));
- }
- let before = owner.editor.text().to_owned();
- owner.editor.key(crossterm::event::KeyEvent::new(
- crossterm::event::KeyCode::Char('z'),
- crossterm::event::KeyModifiers::CONTROL,
- ));
- assert_eq!(
- owner.editor.text(),
- before,
- "initialization does not create partial-document undo steps"
- );
- owner.apply(&EditorCommand {
- owner: identity.clone(),
- sequence: 9,
- based_on: 0,
- edit: EditorEdit::Append {
- text: "stale".into(),
- },
- });
- assert_eq!(
- owner.receipt.as_ref().unwrap().outcome,
- EditorOutcome::Conflict {
- reason: EditorConflict::Revision
- }
- );
- owner.apply(&EditorCommand {
- owner: identity,
- sequence: 10,
- based_on: owner.revision,
- edit: EditorEdit::Append {
- text: "x".repeat(65537),
- },
- });
- assert_eq!(
- owner.receipt.as_ref().unwrap().outcome,
- EditorOutcome::Applied
- );
- assert!(owner.editor.text() == format!("{before}{}", "x".repeat(65537)));
- }
-}
diff --git a/platform/render-terminal/src/application/file.rs b/platform/render-terminal/src/application/file.rs
deleted file mode 100644
index 81b3143da0..0000000000
--- a/platform/render-terminal/src/application/file.rs
+++ /dev/null
@@ -1,778 +0,0 @@
-/*
- * 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 path input and original asynchronous selected-file custody.
-mod browser;
-pub(super) mod paths;
-use super::{NativeContext, NativeInput, Placement, Slots, Widget, WidgetInput};
-use crate::editor::{Editor, Geometry};
-use crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind};
-use maka_ui_runtime::{
- ComponentCall, ComponentOutput, NativeCapability, NativeComponent, NativeExport,
- NativeRegistry, NativeView, file::*,
-};
-use ratatui::{
- buffer::Buffer,
- style::{Color, Modifier, Style},
-};
-use std::{
- any::Any,
- collections::BTreeMap,
- path::PathBuf,
- rc::Rc,
- sync::{Arc, mpsc},
-};
-
-use unicode_width::UnicodeWidthStr;
-
-struct Owner {
- context: NativeContext,
- editor: Editor,
- geometry: Option<Geometry>,
- selection: FileSelection,
- path: Option<browser::Request>,
- browser: Option<browser::Browser>,
- receiver: Option<mpsc::Receiver<Result<browser::Selected, FileError>>>,
- visible: bool,
- announced: bool,
- focus_browser: bool,
-}
-impl Owner {
- fn observation(&self) -> NativeInput {
- NativeInput {
- replaceable: false,
- key: "observe".into(),
- value: serde_json::to_value(self.selection.observation()).expect("file observation"),
- }
- }
- fn stage(&mut self, path: PathBuf, directory: bool) -> WidgetInput {
- self.selection.cancel_intent();
- match self.selection.stage() {
- Some(choose) => {
- self.path = Some(browser::Request { path, directory });
- WidgetInput::Emitted(NativeInput {
- replaceable: false,
- key: "choose".into(),
- value: serde_json::to_value(choose).unwrap(),
- })
- }
- None => WidgetInput::Consumed(None),
- }
- }
- fn settle(&mut self, result: Result<browser::Selected, FileError>) {
- match result {
- Ok(browser::Selected::File { path, snapshot }) => {
- self.browser = None;
- self.editor.clear();
- self.editor
- .insert(path.to_str().expect("validated local path"));
- self.selection.settle(Ok(Some(snapshot)));
- }
- Ok(browser::Selected::Directory(directory)) => {
- self.selection.settle(Ok(None));
- let browser =
- browser::Browser::new(directory, self.selection.observation().revision);
- self.editor.clear();
- self.editor
- .insert(browser.path().to_str().expect("validated local path"));
- self.browser = Some(browser);
- self.focus_browser = self.visible;
- }
- Err(error) => {
- self.browser = None;
- self.selection.settle(Err(error));
- }
- }
- }
- fn start(&mut self) {
- let path = self.path.take().expect("original file gesture path");
- let budget = self.context.budget.clone();
- let mut ticket = match self.context.resources.reserve() {
- Ok(ticket) => ticket,
- Err(_) => {
- self.selection.settle(Err(FileError::WorkerFailed));
- return;
- }
- };
- let (sender, receiver) = mpsc::sync_channel(1);
- match std::thread::Builder::new()
- .name("native-file-read".into())
- .spawn(move || {
- ticket.start();
- let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
- browser::select(path, &budget)
- }));
- let (result, cleanup) = match result {
- Ok(result) => (result, Ok(())),
- Err(_) => (
- Err(FileError::WorkerFailed),
- Err("native file worker panicked".into()),
- ),
- };
- let _ = sender.send(result);
- ticket.complete(cleanup);
- }) {
- Ok(_) => self.receiver = Some(receiver),
- Err(_) => self.selection.settle(Err(FileError::WorkerFailed)),
- }
- }
-}
-struct Recipe(FileView);
-impl Widget for Recipe {
- fn publication_capacity(&self, state: &dyn Any) -> Vec<(&'static str, usize)> {
- if state
- .downcast_ref::<Owner>()
- .unwrap()
- .selection
- .will_accept(self.0.request.as_deref())
- {
- vec![("observe", 65536); 2]
- } else {
- Vec::new()
- }
- }
-
- fn create(&self, context: NativeContext) -> Box<dyn Any> {
- Box::new(Owner {
- context,
- editor: Editor::single_line(8192, false),
- geometry: None,
- selection: FileSelection::default(),
- path: None,
- browser: None,
- receiver: None,
- visible: true,
- announced: false,
- focus_browser: false,
- })
- }
- fn commit(&self, state: &mut dyn Any) -> maka_ui_runtime::Result<Option<NativeInput>> {
- let owner = state.downcast_mut::<Owner>().unwrap();
- if self.0.props.disabled || !owner.visible {
- owner.selection.cancel_intent();
- owner.path = None;
- }
- let accepted = owner.selection.accept(self.0.request.as_deref());
- if accepted {
- owner.start();
- }
- let announce = !owner.announced || accepted;
- owner.announced = true;
- Ok(announce.then(|| owner.observation()))
- }
- fn present_focus(&self, state: &mut dyn Any) -> bool {
- std::mem::take(&mut state.downcast_mut::<Owner>().unwrap().focus_browser)
- }
- fn observe(&self, state: &mut dyn Any) -> Option<NativeInput> {
- let owner = state.downcast_mut::<Owner>().unwrap();
- let receiver = owner.receiver.as_ref()?;
- match receiver.try_recv() {
- Ok(result) => {
- owner.receiver = None;
- owner.settle(result);
- Some(owner.observation())
- }
- Err(mpsc::TryRecvError::Disconnected) => {
- owner.receiver = None;
- owner.selection.settle(Err(FileError::WorkerFailed));
- Some(owner.observation())
- }
- Err(mpsc::TryRecvError::Empty) => None,
- }
- }
- fn input(
- &self,
- state: &mut dyn Any,
- key: &str,
- event: &Event,
- ) -> maka_ui_runtime::Result<WidgetInput> {
- let owner = state.downcast_mut::<Owner>().unwrap();
- if self.0.props.disabled || !owner.visible || owner.selection.busy() {
- return Ok(WidgetInput::Unhandled);
- }
- if key == "entries" {
- return Ok(
- match owner.browser.as_mut().map(|browser| browser.input(event)) {
- Some(browser::Input::Select(path)) => owner.stage(path, false),
- Some(browser::Input::Handled) => WidgetInput::Consumed(None),
- Some(browser::Input::PreserveFocus) => WidgetInput::PreserveFocus,
- _ => WidgetInput::Unhandled,
- },
- );
- }
- let control = matches!(key, "choose" | "browse" | "parent");
- let activate = matches!(event, Event::Key(k) if k.kind != crossterm::event::KeyEventKind::Release && (k.code == KeyCode::Enter || control && k.code == KeyCode::Char(' ')) && k.modifiers.is_empty())
- || control
- && matches!(event, Event::Mouse(m) if m.kind == MouseEventKind::Down(MouseButton::Left));
- if activate {
- if key == "parent" {
- return Ok(owner
- .browser
- .as_ref()
- .and_then(browser::Browser::parent)
- .map_or(WidgetInput::Consumed(None), |path| owner.stage(path, true)));
- }
- if key == "path" || control {
- return Ok(owner.stage(PathBuf::from(owner.editor.text()), key == "browse"));
- }
- }
- if key != "path" {
- return Ok(WidgetInput::Unhandled);
- }
- let handled = match event {
- Event::Key(key) => owner.editor.key(*key),
- Event::Paste(text) => owner.editor.insert(text),
- Event::Mouse(mouse) => owner
- .geometry
- .as_ref()
- .is_some_and(|g| owner.editor.mouse_presented(*mouse, g)),
- _ => false,
- };
- Ok(if handled {
- WidgetInput::Consumed(None)
- } else {
- WidgetInput::Unhandled
- })
- }
- fn cancel_pointer(&self, state: &mut dyn Any) {
- let owner = state.downcast_mut::<Owner>().unwrap();
- owner.editor.cancel_pointer();
- if let Some(browser) = &mut owner.browser {
- browser.cancel_pointer();
- }
- }
- fn pointer_capture(&self, state: &dyn Any) -> bool {
- let owner = state.downcast_ref::<Owner>().unwrap();
- owner.editor.dragging()
- || owner
- .browser
- .as_ref()
- .is_some_and(browser::Browser::dragging)
- }
- fn capture(
- &self,
- state: &dyn Any,
- target: &maka_ui_runtime::actions::CaptureTarget,
- ) -> maka_ui_runtime::Result<Arc<dyn maka_capabilities::call::ReadResource>> {
- let capture = serde_json::from_value(target.input().clone())
- .map_err(|e| maka_ui_runtime::Error::Invalid(e.to_string()))?;
- state
- .downcast_ref::<Owner>()
- .unwrap()
- .selection
- .capture(&capture)
- }
- fn layout(&self, slots: &Slots<'_>) -> maka_ui_runtime::Result<super::TerminalLayout> {
- Ok(super::TerminalLayout {
- grow: u16::from(slots.native_state::<Owner>()?.browser.is_some()),
- shrink: true,
- ..Default::default()
- })
- }
- fn measure(&self, slots: &Slots<'_>, width: u16) -> maka_ui_runtime::Result<(u16, u32)> {
- let owner = slots.native_state::<Owner>()?;
- let (_, controls) = controls(
- &self.0.props,
- width,
- owner
- .browser
- .as_ref()
- .is_some_and(browser::Browser::has_parent),
- );
- Ok((
- width,
- 2 + u32::from(self.0.props.appearance.border_width > 0.0) * 2
- + controls
- + owner
- .browser
- .as_ref()
- .map_or(0, |browser| browser.height(2)),
- ))
- }
- fn paint(
- &self,
- slots: &mut Slots<'_>,
- area: Placement,
- buffer: &mut Buffer,
- ) -> maka_ui_runtime::Result<()> {
- let owner = slots.native_state::<Owner>()?;
- let a = &self.0.props.appearance;
- let color = |c: maka_ui_runtime::appearance::Color| {
- Color::Rgb((c.0 >> 24) as u8, (c.0 >> 16) as u8, (c.0 >> 8) as u8)
- };
- let terminal = a.terminal.as_ref();
- let foreground = terminal.map_or_else(
- || color(a.text.foreground),
- |p| super::paint::color(p.foreground),
- );
- let background = terminal.map_or_else(
- || color(a.background),
- |p| super::paint::color(p.background),
- );
- let muted = terminal.map_or_else(
- || color(a.muted_foreground),
- |p| super::paint::color(p.subtle),
- );
- let selected =
- terminal.map_or_else(|| color(a.selection), |p| super::paint::color(p.selection));
- let style = Style::default().fg(foreground).bg(background);
- let path_focused = slots.focused_key() == Some("path");
- let inset = u32::from(a.border_width > 0.0) * 2;
- let chrome = 2 + inset;
- let row = |y| {
- Placement::new(
- super::CellRect::new(area.x, area.y + y, area.width, 1),
- area.visible(),
- )
- };
- super::paint::text(&self.0.props.path_label, row(0), buffer, style.fg(muted));
- let field = Placement::new(
- super::CellRect::new(area.x, area.y + 1, area.width, 1 + inset),
- area.visible(),
- );
- let path = if inset > 0 {
- let border = terminal.map_or_else(
- || {
- color(if path_focused {
- a.focused_border
- } else {
- a.border
- })
- },
- |p| super::paint::color(if path_focused { p.accent } else { p.border }),
- );
- super::border::paint(
- buffer,
- field,
- border,
- if a.ascii {
- maka_ui_runtime::scene::terminal::BorderStyle::Ascii
- } else {
- maka_ui_runtime::scene::terminal::BorderStyle::Rounded
- },
- );
- super::border::inner(field)
- } else {
- field
- };
- let geometry = owner.editor.paint_prepared(
- path,
- buffer,
- path_focused,
- style,
- style.bg(selected),
- style.add_modifier(Modifier::REVERSED),
- );
- if owner.editor.text().is_empty() {
- super::paint::text(&self.0.props.placeholder, path, buffer, style.fg(muted));
- }
- let enabled = !self.0.props.disabled && !owner.selection.busy();
- let (controls, control_rows) = controls(
- &self.0.props,
- area.width,
- owner
- .browser
- .as_ref()
- .is_some_and(browser::Browser::has_parent),
- );
- let mut targets = Vec::new();
- for control in controls {
- let bounds = Placement::new(
- super::CellRect::new(
- area.x + i64::from(control.x),
- area.y + i64::from(chrome + control.y),
- control.width,
- 1,
- ),
- area.clip,
- );
- let tone = if !enabled {
- style.fg(muted)
- } else if slots.focused_key() == Some(control.key) {
- style.add_modifier(Modifier::BOLD | Modifier::REVERSED)
- } else {
- style.add_modifier(Modifier::BOLD)
- };
- super::paint::text(&control.label, bounds, buffer, tone);
- targets.push((control.key, bounds));
- }
- let browser_geometry = owner.browser.as_ref().map(|browser| {
- browser.paint(
- &self.0.props,
- Placement::new(
- super::CellRect::new(
- area.x,
- area.y + i64::from(chrome + control_rows),
- area.width,
- area.height.saturating_sub(chrome + control_rows),
- ),
- area.clip,
- ),
- area.height.saturating_sub(chrome + 1 + control_rows) as usize,
- buffer,
- style,
- enabled && slots.focused_key() == Some("entries"),
- )
- });
- let entries = browser_geometry.as_ref().map(|geometry| geometry.area);
- let focus_browser = owner.focus_browser;
- slots.native_geometry(Box::new((geometry, browser_geometry)))?;
- // A pending local read keeps keyboard focus; input remains gated above.
- if !self.0.props.disabled {
- slots.native_target("path", field, self.0.props.tab_index, false)?;
- for (key, bounds) in targets {
- slots.native_target(key, bounds, self.0.props.tab_index, false)?;
- }
- if let Some(entries) = entries {
- slots.native_target("entries", entries, self.0.props.tab_index, focus_browser)?;
- }
- }
- Ok(())
- }
- fn present(&self, state: &mut dyn Any, geometry: Box<dyn Any>) -> Option<NativeInput> {
- let owner = state.downcast_mut::<Owner>().unwrap();
- let (geometry, browser_geometry) = *geometry
- .downcast::<(Geometry, Option<browser::Geometry>)>()
- .unwrap();
- owner.editor.present_geometry(geometry.clone());
- owner.geometry = Some(geometry);
- if let (Some(browser), Some(geometry)) = (owner.browser.as_mut(), browser_geometry) {
- browser.present(geometry);
- }
- None
- }
- fn presentation(&self, state: &mut dyn Any, visible: bool) {
- let owner = state.downcast_mut::<Owner>().unwrap();
- owner.visible = visible;
- if !visible {
- owner.focus_browser = false;
- owner.editor.invalidate_geometry();
- owner.geometry = None;
- if let Some(browser) = &mut owner.browser {
- browser.suspend();
- }
- owner.selection.cancel_intent();
- owner.path = None;
- }
- }
- fn deactivate(&self, state: &mut dyn Any) {
- self.presentation(state, false);
- }
-}
-struct Control {
- key: &'static str,
- label: String,
- x: u16,
- y: u32,
- width: u16,
-}
-fn controls(props: &FilePickerProps, width: u16, parent: bool) -> (Vec<Control>, u32) {
- if width == 0 {
- return (vec![], 0);
- }
- let mut controls = Vec::new();
- let mut x = 0u16;
- let mut y = 0u32;
- for (key, label) in [("choose", &props.label), ("browse", &props.browse_label)]
- .into_iter()
- .chain(parent.then_some(("parent", &props.parent_label)))
- {
- let label = format!(" {} ", super::paint::sanitize(label));
- let size = label.width().min(usize::from(width)) as u16;
- if x > 0 && x.saturating_add(size) > width {
- x = 0;
- y += 1;
- }
- controls.push(Control {
- key,
- label,
- x,
- y,
- width: size,
- });
- x = x.saturating_add(size).saturating_add(1);
- }
- (controls, y + 1)
-}
-
-struct Component;
-impl NativeComponent<dyn Widget> for Component {
- fn capture(
- &self,
- _: maka_ui_runtime::actions::CaptureCall<'_>,
- ) -> maka_ui_runtime::Result<maka_ui_runtime::actions::CapturePlan> {
- Ok(maka_ui_runtime::actions::CapturePlan::Native)
- }
- fn call(
- &self,
- call: ComponentCall<'_>,
- _: &maka_ui_runtime::abi::ValueBindings,
- ) -> maka_ui_runtime::Result<ComponentOutput<NativeView<dyn Widget>>> {
- let output = reduce(call)?;
- let view = NativeView::<dyn Widget>::new(
- Rc::new(Recipe(output.frame.view.clone())),
- BTreeMap::from([
- ("choose".into(), choose_schema()),
- ("observe".into(), observation_schema()),
- ]),
- vec![],
- )?;
- Ok(output.map_view(|_| view))
- }
-}
-pub(super) fn register(registry: &mut NativeRegistry<dyn Widget>) {
- registry
- .register(maka_ui_runtime::file::IMPLEMENTATION, |_, _| {
- Ok(BTreeMap::from([(
- "FilePicker".into(),
- NativeExport {
- contract: file_picker_contract(),
- value: NativeCapability::Component(Rc::new(Component)),
- },
- )]))
- })
- .expect("unique native file picker");
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- pub(super) fn props() -> FilePickerProps {
- serde_json::from_value(serde_json::json!({
- "appearance": {"text":{"font":{"family":".SystemUIFont","fallbacks":[],"size":14,"lineHeight":1.5,"weight":400},"foreground":0x242724ffu32},
- "mutedForeground":0x656a63ffu32,"background":0xfaf9f6ffu32,"selection":0xa7c4b0ffu32,"caret":0x242724ffu32,
- "border":0x656a63ffu32,"focusedBorder":0x416653ffu32,"borderWidth":1,"radius":8,"padding":{"top":8,"right":12,"bottom":8,"left":12}},
- "label":"Choose plugin file","pathLabel":"Local file path","browseLabel":"Browse","parentLabel":"Parent","emptyLabel":"No files or folders","truncatedLabel":"First 2048 entries","placeholder":"/absolute/path","disabled":false,"tabIndex":0
- })).unwrap()
- }
-
- #[test]
- fn native_path_is_private_and_one_committed_gesture_reads_an_immutable_file() {
- let dir = tempfile::tempdir().unwrap();
- let path = dir.path().join("selected.maka-extension");
- std::fs::write(&path, b"original").unwrap();
- let props = props();
- let resources = Arc::new(maka_capabilities::call::Resources::default());
- let mut state = Recipe(FileView {
- props: props.clone(),
- request: Some("forged".into()),
- })
- .create(NativeContext {
- checkpoint: None,
- resources: resources.clone(),
- budget: Default::default(),
- });
- let initial = FileView {
- props: props.clone(),
- request: None,
- };
- Recipe(initial.clone()).commit(state.as_mut()).unwrap();
- let owner = state.downcast_ref::<Owner>().unwrap();
- assert!(owner.receiver.is_none());
- let native = serde_json::to_value(&props).unwrap();
- let mut forged = native.clone();
- forged["path"] = serde_json::json!(path);
- assert!(reduce(ComponentCall::Mount { props: &forged }).is_err());
- Recipe(initial.clone())
- .input(
- state.as_mut(),
- "path",
- &Event::Paste(path.to_string_lossy().into()),
- )
- .unwrap();
- assert_eq!(
- state
- .downcast_ref::<Owner>()
- .unwrap()
- .selection
- .observation()
- .phase,
- FilePhase::Empty
- );
- let WidgetInput::Emitted(choose) = Recipe(initial.clone())
- .input(
- state.as_mut(),
- "path",
- &Event::Key(crossterm::event::KeyEvent::new(
- KeyCode::Enter,
- crossterm::event::KeyModifiers::NONE,
- )),
- )
- .unwrap()
- else {
- panic!()
- };
- assert!(state.downcast_ref::<Owner>().unwrap().receiver.is_none());
- let mounted = reduce(ComponentCall::Mount { props: &native }).unwrap();
- let committed = reduce(ComponentCall::Event {
- state: &mounted.frame.state,
- props: &native,
- key: "choose",
- input: &choose.value,
- origin: maka_ui_runtime::EventOrigin::Raw,
- })
- .unwrap();
- let recipe = Recipe(committed.frame.view);
- recipe.commit(state.as_mut()).unwrap();
- recipe.commit(state.as_mut()).unwrap();
- let timer = tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- .unwrap();
- timer.block_on(resources.finish()).unwrap();
- assert!(recipe.observe(state.as_mut()).is_some());
- let owner = state.downcast_ref::<Owner>().unwrap();
- let observed = owner.selection.observation();
- assert_eq!(observed.phase, FilePhase::Ready);
- assert_eq!(observed.file.unwrap().name, "selected.maka-extension");
- let captured = owner
- .selection
- .capture(&FileCapture {
- owner: observed.owner,
- revision: observed.revision,
- })
- .unwrap();
- std::fs::write(&path, b"replacement").unwrap();
- drop(state);
- assert_eq!(captured.read(0..8).unwrap(), b"original");
- }
- #[test]
- fn actual_presenter_routes_original_path_drag_and_space_activation() {
- use super::super::Presenter;
- use crossterm::event::{KeyEvent, KeyModifiers, MouseEvent};
- use ratatui::layout::Rect;
- let (runtime, input, packages) = super::super::tests::fixture(
- "return frame({count:state.count+(event.key==='choose'?1:0),revision:state.revision+1});",
- );
- let p = props();
- let native =
- NativeRegistry::with_surface(maka_plugin_core::composition::PresentationTarget::Tui)
- .with_scene_compiler(
- super::super::scene::language(),
- move |_| {
- maka_ui_runtime::PreparedScene::new(
- Recipe(FileView {
- props: p.clone(),
- request: None,
- }),
- BTreeMap::from([
- ("choose".into(), choose_schema()),
- ("observe".into(), observation_schema()),
- ]),
- vec![],
- )
- },
- |prepared| prepared.finish::<Recipe, dyn Widget>(|recipe| Rc::new(recipe)),
- )
- .unwrap();
- let mut tui = Presenter::new(
- &input,
- packages,
- runtime.handle().clone(),
- native,
- serde_json::json!({}),
- maka_ui_js::execution(Default::default()),
- )
- .unwrap();
- let draw = |tui: &mut Presenter| {
- let area = Rect::new(0, 0, 30, 6);
- let scene = tui.paint(area, &mut Buffer::empty(area)).unwrap();
- tui.present(scene);
- };
- draw(&mut tui);
- tui.input_settled(Event::Paste("abcdef".into())).unwrap();
- draw(&mut tui);
- let mouse = |kind, x| {
- Event::Mouse(MouseEvent {
- kind,
- column: x,
- row: 2,
- modifiers: KeyModifiers::NONE,
- })
- };
- tui.input_settled(mouse(MouseEventKind::Down(MouseButton::Left), 2))
- .unwrap();
- assert!(tui.drag.is_some());
- tui.input_settled(mouse(MouseEventKind::Drag(MouseButton::Left), 5))
- .unwrap();
- tui.input_settled(mouse(MouseEventKind::Up(MouseButton::Left), 5))
- .unwrap();
- assert!(tui.drag.is_none());
- let owner = tui.resources.owners[&tui.root.id()]
- .state
- .downcast_ref::<Owner>()
- .unwrap();
- assert_eq!(owner.editor.selected_text(), "bcd");
- assert!(!owner.editor.dragging());
- draw(&mut tui);
- tui.input_settled(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)))
- .unwrap();
- tui.input_settled(Event::Key(KeyEvent::new(
- KeyCode::Char(' '),
- KeyModifiers::NONE,
- )))
- .unwrap();
- assert_eq!(tui.application.frame(&tui.root).unwrap().state["count"], 1);
- // A full-page browser uses its allocated height, including after a resize.
- // The native file remains the same owner and does not recreate its selection.
- let directory = tempfile::tempdir().unwrap();
- for i in 0..40 {
- std::fs::write(
- directory.path().join(format!("file-{i:02}.maka-plugin")),
- b"package",
- )
- .unwrap();
- }
- let owner = tui
- .resources
- .owners
- .get_mut(&tui.root.id())
- .unwrap()
- .state
- .downcast_mut::<Owner>()
- .unwrap();
- let selected = browser::select(
- browser::Request {
- path: directory.path().to_path_buf(),
- directory: true,
- },
- &owner.context.budget,
- )
- .unwrap();
- owner.settle(Ok(selected));
- for (height, last_visible) in [(40, "file-33"), (12, "file-05"), (30, "file-23")] {
- let area = Rect::new(0, 0, 65, height);
- let mut buffer = Buffer::empty(area);
- let scene = tui.paint(area, &mut buffer).unwrap();
- tui.present(scene);
- let rendered = buffer
- .content
- .iter()
- .map(|cell| cell.symbol())
- .collect::<String>();
- assert!(
- rendered.contains(last_visible),
- "browser did not fill {height} rows: {rendered}"
- );
- }
- let root = tui.root.clone();
- tui.surface_closed(&root, Ok(())).unwrap();
- tui.shutdown_settled().unwrap();
- }
-}
diff --git a/platform/render-terminal/src/application/file/browser.rs b/platform/render-terminal/src/application/file/browser.rs
deleted file mode 100644
index 7baea19c89..0000000000
--- a/platform/render-terminal/src/application/file/browser.rs
+++ /dev/null
@@ -1,652 +0,0 @@
-/*
- * 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.
- */
-
-//! Original attachment browser bounds and ordering, owned by the local picker.
-use super::super::{CellRect, Placement, paint};
-use crossterm::event::{Event, KeyCode, KeyEventKind, MouseButton, MouseEventKind};
-use maka_ui_runtime::{
- budget::{Kind, NativeBudget, Permit},
- file::{FileError, FilePickerProps, FileSnapshot, read_selected},
-};
-use ratatui::{buffer::Buffer, layout::Rect, style::Style};
-use std::{
- path::{Path, PathBuf},
- sync::Arc,
-};
-
-const MAX_ENTRIES: usize = 2048;
-
-pub(super) struct Request {
- pub path: PathBuf,
- pub directory: bool,
-}
-pub(super) enum Selected {
- File {
- path: PathBuf,
- snapshot: Arc<FileSnapshot>,
- },
- Directory(Directory),
-}
-struct Entry {
- path: PathBuf,
- directory: bool,
- bytes: u64,
- _memory: Permit,
-}
-pub(super) struct Directory {
- path: PathBuf,
- entries: Vec<Entry>,
- truncated: bool,
- _memory: Permit,
-}
-/// Only an admitted physical gesture can call this, in its original worker.
-pub(super) fn select(request: Request, budget: &NativeBudget) -> Result<Selected, FileError> {
- let mut path = if request.path.as_os_str().is_empty() {
- std::env::current_dir().map_err(|_| FileError::ReadFailed)?
- } else {
- request.path
- };
- if !super::paths::valid(&path) {
- return Err(FileError::InvalidFile);
- }
- let metadata = std::fs::symlink_metadata(&path).map_err(|_| FileError::ReadFailed)?;
- if metadata.is_file() {
- if !request.directory {
- let snapshot = read_selected(&path, budget)?;
- return Ok(Selected::File { path, snapshot });
- }
- path = path.parent().ok_or(FileError::InvalidFile)?.to_path_buf();
- } else if !metadata.is_dir() {
- return Err(FileError::InvalidFile);
- }
- // Charge retained path/entry allocations to the original shared native budget.
- let memory = budget.reserve(
- Kind::Encoded,
- MAX_ENTRIES * std::mem::size_of::<Entry>() + path.capacity(),
- )?;
- let mut entries = Vec::with_capacity(MAX_ENTRIES);
- let mut truncated = false;
- for (index, entry) in std::fs::read_dir(&path)
- .map_err(|_| FileError::ReadFailed)?
- .enumerate()
- {
- if index == MAX_ENTRIES {
- truncated = true;
- break;
- }
- let entry = entry.map_err(|_| FileError::ReadFailed)?;
- let kind = entry.file_type().map_err(|_| FileError::ReadFailed)?;
- let path = entry.path();
- if !super::paths::valid(&path) || !(kind.is_dir() || kind.is_file()) {
- continue;
- }
- let metadata = entry.metadata().map_err(|_| FileError::ReadFailed)?;
- let memory = budget.reserve(Kind::Encoded, path.capacity())?;
- entries.push(Entry {
- path,
- directory: kind.is_dir(),
- bytes: metadata.len(),
- _memory: memory,
- });
- }
- entries.sort_by(|a, b| {
- b.directory
- .cmp(&a.directory)
- .then_with(|| a.path.file_name().cmp(&b.path.file_name()))
- });
- Ok(Selected::Directory(Directory {
- path,
- entries,
- truncated,
- _memory: memory,
- }))
-}
-
-pub(super) struct Browser {
- directory: Directory,
- revision: u64,
- selected: usize,
- offset: usize,
- reveal: bool,
- geometry: Option<Geometry>,
- drag: Option<Rect>,
-}
-pub(super) struct Geometry {
- revision: u64,
- pub area: Placement,
- hits: Vec<(usize, Placement)>,
- offset: usize,
- rows: usize,
- bar: Option<Rect>,
- intent: (usize, usize, bool),
-}
-pub(super) enum Input {
- Select(PathBuf),
- Handled,
- PreserveFocus,
- Unhandled,
-}
-impl Browser {
- pub fn new(directory: Directory, revision: u64) -> Self {
- Self {
- directory,
- revision,
- selected: 0,
- offset: 0,
- reveal: true,
- geometry: None,
- drag: None,
- }
- }
- pub fn path(&self) -> &Path {
- &self.directory.path
- }
- pub fn parent(&self) -> Option<PathBuf> {
- self.geometry
- .as_ref()
- .filter(|geometry| geometry.revision == self.revision)?;
- self.directory.path.parent().map(Path::to_path_buf)
- }
- pub fn has_parent(&self) -> bool {
- self.directory.path.parent().is_some()
- }
- pub fn height(&self, rows: usize) -> u32 {
- self.directory.entries.len().min(rows).max(1) as u32 + 1
- }
- pub fn suspend(&mut self) {
- self.geometry = None;
- self.drag = None;
- }
- pub fn dragging(&self) -> bool {
- self.drag.is_some()
- }
- pub fn cancel_pointer(&mut self) {
- self.drag = None;
- }
- pub fn input(&mut self, event: &Event) -> Input {
- if matches!(event, Event::Mouse(mouse) if mouse.kind == MouseEventKind::Up(MouseButton::Left))
- && self.drag.take().is_some()
- {
- return Input::PreserveFocus;
- }
- let Some(geometry) = self
- .geometry
- .as_ref()
- .filter(|geometry| geometry.revision == self.revision)
- else {
- return Input::Handled;
- };
- if let Event::Mouse(mouse) = event {
- let bar = match mouse.kind {
- MouseEventKind::Down(MouseButton::Left) => geometry
- .bar
- .filter(|bar| bar.contains((mouse.column, mouse.row).into())),
- MouseEventKind::Drag(MouseButton::Left) => {
- self.drag.filter(|bar| Some(*bar) == geometry.bar)
- }
- _ => None,
- };
- if let Some(bar) = bar {
- // Original Surface/Rows track mapping, including captured drags outside the pane.
- let maximum = self.directory.entries.len().saturating_sub(geometry.rows);
- let row = mouse
- .row
- .saturating_sub(bar.y)
- .min(bar.height.saturating_sub(1));
- self.offset =
- usize::from(row) * maximum / usize::from(bar.height.saturating_sub(1).max(1));
- self.reveal = false;
- self.drag = Some(bar);
- return Input::PreserveFocus;
- }
- }
- let last = self.directory.entries.len().saturating_sub(1);
- match event {
- Event::Mouse(mouse) if geometry.area.contains((mouse.column, mouse.row).into()) => {
- match mouse.kind {
- MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
- self.offset = if mouse.kind == MouseEventKind::ScrollDown {
- self.offset
- .saturating_add(3)
- .min(self.directory.entries.len().saturating_sub(geometry.rows))
- } else {
- self.offset.saturating_sub(3)
- };
- self.reveal = false;
- Input::Handled
- }
- MouseEventKind::Down(MouseButton::Left) => {
- if let Some((index, _)) = geometry
- .hits
- .iter()
- .find(|(_, area)| area.contains((mouse.column, mouse.row).into()))
- {
- self.selected = *index;
- Input::Select(self.directory.entries[*index].path.clone())
- } else {
- Input::Handled
- }
- }
- _ => Input::Unhandled,
- }
- }
- Event::Key(key) if key.kind != KeyEventKind::Release && key.modifiers.is_empty() => {
- self.drag = None;
- match key.code {
- KeyCode::Enter => {
- return geometry
- .hits
- .iter()
- .find(|(index, _)| *index == self.selected)
- .map_or(Input::Handled, |(index, _)| {
- Input::Select(self.directory.entries[*index].path.clone())
- });
- }
- KeyCode::Backspace => {
- return self.parent().map_or(Input::Handled, Input::Select);
- }
- KeyCode::Home => self.selected = 0,
- KeyCode::End => self.selected = last,
- KeyCode::Up => self.selected = self.selected.saturating_sub(1),
- KeyCode::Down => self.selected = self.selected.saturating_add(1).min(last),
- KeyCode::PageUp => self.selected = self.selected.saturating_sub(geometry.rows),
- KeyCode::PageDown => {
- self.selected = self.selected.saturating_add(geometry.rows).min(last)
- }
- _ => return Input::Unhandled,
- }
- self.reveal = true;
- Input::Handled
- }
- _ => Input::Unhandled,
- }
- }
- pub fn paint(
- &self,
- props: &FilePickerProps,
- area: Placement,
- rows: usize,
- buffer: &mut Buffer,
- style: Style,
- focused: bool,
- ) -> Geometry {
- let viewport = area.clipped(buffer.area).visible();
- let rows = self
- .directory
- .entries
- .len()
- .min(rows)
- .max(1)
- .min(usize::from(viewport.height.saturating_sub(1).max(1)))
- .min(usize::from(viewport.height));
- let mut offset = self
- .offset
- .min(self.directory.entries.len().saturating_sub(rows));
- let resized = focused && self.geometry.as_ref().is_some_and(|g| g.rows != rows);
- if self.reveal || resized {
- offset = offset
- .min(self.selected)
- .max((self.selected + 1).saturating_sub(rows));
- }
- let mut hits = Vec::new();
- let mut list = Placement::from(viewport);
- list.bounds.height = rows as u32;
- let viewport = list.visible();
- let bar =
- (self.directory.entries.len() > rows && viewport.height > 1 && viewport.width > 0)
- .then(|| Rect::new(viewport.right() - 1, viewport.y, 1, viewport.height));
- for (line, (index, entry)) in self
- .directory
- .entries
- .iter()
- .enumerate()
- .skip(offset)
- .take(rows)
- .enumerate()
- {
- let row = Placement::new(
- CellRect::new(
- list.x,
- list.y + line as i64,
- list.width.saturating_sub(1),
- 1,
- ),
- list.clip,
- )
- .clipped(buffer.area);
- let style = if focused && index == self.selected {
- style.add_modifier(ratatui::style::Modifier::REVERSED)
- } else {
- style
- };
- buffer.set_style(row.visible(), style);
- let name = entry.path.file_name().unwrap().to_str().unwrap();
- let size = (!entry.directory && row.width >= 24).then(|| {
- if entry.bytes >= 1024 * 1024 {
- format!("{:.1} MiB", entry.bytes as f64 / (1024.0 * 1024.0))
- } else if entry.bytes >= 1024 {
- format!("{:.1} KiB", entry.bytes as f64 / 1024.0)
- } else {
- format!("{} B", entry.bytes)
- }
- });
- let reserved = size.as_ref().map_or(0, |size| size.len() as u16 + 2);
- let label = if entry.directory {
- format!("/ {name}")
- } else {
- format!(" {name}")
- };
- paint::text(
- &paint::truncated_line(&label, row.width.saturating_sub(reserved)),
- row,
- buffer,
- style,
- );
- if let Some(size) = size {
- paint::text(
- &size,
- Placement::new(
- CellRect::new(
- row.right() - size.len() as i64 - 1,
- row.y,
- size.len() as u16,
- 1,
- ),
- row.clip,
- ),
- buffer,
- style,
- );
- }
- if !row.is_empty() {
- hits.push((index, row));
- }
- }
- if let Some(bar) = bar {
- let maximum = self.directory.entries.len() - rows;
- let visible = usize::from(bar.height);
- let thumb = (visible * visible / (maximum + visible)).max(1);
- let top = offset * (visible - thumb) / maximum;
- for line in 0..visible {
- buffer[(bar.x, bar.y + line as u16)]
- .set_char(
- match (props.appearance.ascii, (top..top + thumb).contains(&line)) {
- (true, true) => '#',
- (true, false) => '|',
- (false, true) => '┃',
- (false, false) => '│',
- },
- )
- .set_style(style);
- }
- }
- if self.directory.entries.is_empty() {
- paint::text(&props.empty_label, list, buffer, style);
- }
- let status = if self.directory.truncated {
- props.truncated_label.clone()
- } else {
- format!(
- "{}–{} / {}",
- if self.directory.entries.is_empty() {
- 0
- } else {
- offset + 1
- },
- (offset + rows).min(self.directory.entries.len()),
- self.directory.entries.len()
- )
- };
- paint::text(
- &paint::truncated_line(&status, list.width),
- Placement::new(
- CellRect::new(list.x, list.y + rows as i64, list.width, 1),
- list.clip,
- ),
- buffer,
- style,
- );
- Geometry {
- revision: self.revision,
- area: list,
- hits,
- offset,
- rows,
- bar,
- intent: (self.offset, self.selected, self.reveal),
- }
- }
- pub fn present(&mut self, geometry: Geometry) {
- if geometry.revision != self.revision {
- return;
- }
- if self.drag.is_some_and(|bar| Some(bar) != geometry.bar) {
- self.drag = None;
- }
- if geometry.intent == (self.offset, self.selected, self.reveal) {
- self.offset = geometry.offset;
- self.reveal = false;
- }
- self.geometry = Some(geometry);
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crossterm::event::{KeyEvent, KeyModifiers, MouseEvent};
- use ratatui::layout::Rect;
-
- fn listing(path: &Path, budget: &NativeBudget) -> Directory {
- let Selected::Directory(directory) = select(
- Request {
- path: path.into(),
- directory: true,
- },
- budget,
- )
- .unwrap() else {
- panic!("expected directory");
- };
- directory
- }
- #[test]
- fn original_ordering_limits_and_native_allocations_are_bounded() {
- let root = tempfile::tempdir().unwrap();
- std::fs::create_dir(root.path().join("z-folder")).unwrap();
- for name in ["z.txt", "a.txt"] {
- std::fs::write(root.path().join(name), b"abc").unwrap();
- }
- #[cfg(unix)]
- {
- std::os::unix::fs::symlink(root.path().join("a.txt"), root.path().join("symlink"))
- .unwrap();
- assert!(matches!(
- select(
- Request {
- path: root.path().join("symlink"),
- directory: false
- },
- &NativeBudget::default()
- ),
- Err(FileError::InvalidFile)
- ));
- }
- let budget = NativeBudget::default();
- let directory = listing(root.path(), &budget);
- assert_eq!(
- directory
- .entries
- .iter()
- .map(|entry| entry.path.file_name().unwrap().to_str().unwrap())
- .collect::<Vec<_>>(),
- ["z-folder", "a.txt", "z.txt"]
- );
- assert!(!directory.truncated);
- assert!(budget.usage().0 > 0);
- drop(directory);
- assert_eq!(budget.usage(), (0, 0, 0));
- let tiny = NativeBudget::new(maka_ui_runtime::budget::BudgetLimits {
- encoded_bytes: 1,
- ..Default::default()
- });
- assert!(matches!(
- select(
- Request {
- path: root.path().into(),
- directory: true
- },
- &tiny
- ),
- Err(FileError::BudgetExhausted)
- ));
- assert_eq!(tiny.usage(), (0, 0, 0));
- assert!(matches!(
- select(
- Request {
- path: "relative".into(),
- directory: true
- },
- &budget
- ),
- Err(FileError::InvalidFile)
- ));
- for index in 0..MAX_ENTRIES {
- std::fs::write(root.path().join(format!("entry-{index:04}")), b"").unwrap();
- }
- let directory = listing(root.path(), &budget);
- assert!(directory.truncated);
- assert!(directory.entries.len() <= MAX_ENTRIES);
- drop(directory);
- assert_eq!(budget.usage(), (0, 0, 0));
- }
- #[test]
- fn browser_uses_only_presented_rows_and_suspends_navigation_on_retirement() {
- let root = tempfile::tempdir().unwrap();
- for index in 0..20 {
- std::fs::write(root.path().join(format!("{index:02}.txt")), b"chosen").unwrap();
- }
- let budget = NativeBudget::default();
- let props = super::super::tests::props();
- let mut browser = Browser::new(listing(root.path(), &budget), 7);
- let area = Rect::new(0, 0, 30, 6);
- let paint = |browser: &Browser| {
- browser.paint(
- &props,
- Placement::new(CellRect::new(0, 0, 30, 6), area),
- 5,
- &mut Buffer::empty(area),
- Style::default(),
- true,
- )
- };
- let key = |code| Event::Key(KeyEvent::new(code, KeyModifiers::NONE));
- assert!(matches!(
- browser.input(&key(KeyCode::Enter)),
- Input::Handled
- ));
- browser.present(paint(&browser));
- browser.input(&key(KeyCode::End));
- assert!(
- matches!(browser.input(&key(KeyCode::Enter)), Input::Handled),
- "an unpainted offscreen row cannot activate"
- );
- browser.present(paint(&browser));
- let Input::Select(path) = browser.input(&key(KeyCode::Enter)) else {
- panic!("visible last file");
- };
- assert_eq!(path, root.path().join("19.txt"));
- // A parent can clip the list to a single visible row on terminal resize.
- let clipped = browser.paint(
- &props,
- Placement::new(CellRect::new(0, 0, 30, 6), Rect::new(0, 0, 30, 1)),
- 5,
- &mut Buffer::empty(area),
- Style::default(),
- true,
- );
- assert_eq!(clipped.rows, 1);
- browser.present(clipped);
- let Input::Select(path) = browser.input(&key(KeyCode::Enter)) else {
- panic!("resized selected row must be visible and actionable");
- };
- assert_eq!(path, root.path().join("19.txt"));
- browser.present(paint(&browser));
- browser.input(&Event::Mouse(MouseEvent {
- kind: MouseEventKind::ScrollUp,
- column: 1,
- row: 2,
- modifiers: KeyModifiers::NONE,
- }));
- browser.present(paint(&browser));
- let Input::Select(path) = browser.input(&Event::Mouse(MouseEvent {
- kind: MouseEventKind::Down(MouseButton::Left),
- column: 1,
- row: 0,
- modifiers: KeyModifiers::NONE,
- })) else {
- panic!("painted pointer row");
- };
- assert_eq!(path, root.path().join("12.txt"));
- assert!(matches!(
- browser.input(&Event::Mouse(MouseEvent {
- kind: MouseEventKind::Down(MouseButton::Left),
- column: 29,
- row: 0,
- modifiers: KeyModifiers::NONE
- })),
- Input::PreserveFocus
- ));
- assert!(browser.dragging());
- browser.input(&Event::Mouse(MouseEvent {
- kind: MouseEventKind::Drag(MouseButton::Left),
- column: 40,
- row: 30,
- modifiers: KeyModifiers::NONE,
- }));
- browser.present(paint(&browser));
- assert_eq!(
- browser.offset, 15,
- "captured outside drag reaches the final viewport"
- );
- browser.input(&Event::Mouse(MouseEvent {
- kind: MouseEventKind::Up(MouseButton::Left),
- column: 40,
- row: 30,
- modifiers: KeyModifiers::NONE,
- }));
- assert!(!browser.dragging());
- assert!(browser.parent().is_some());
- browser.suspend();
- assert!(browser.parent().is_none());
- assert!(matches!(
- browser.input(&key(KeyCode::Enter)),
- Input::Handled
- ));
- let mut stale = paint(&browser);
- stale.revision = 6;
- browser.present(stale);
- assert!(matches!(
- browser.input(&key(KeyCode::Enter)),
- Input::Handled
- ));
- drop(browser);
- assert_eq!(budget.usage(), (0, 0, 0));
- }
-}
diff --git a/platform/render-terminal/src/application/file/paths.rs b/platform/render-terminal/src/application/file/paths.rs
deleted file mode 100644
index 7f7722e1a5..0000000000
--- a/platform/render-terminal/src/application/file/paths.rs
+++ /dev/null
@@ -1,181 +0,0 @@
-/*
- * 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.
- */
-
-//! Original whole-paste local path grammar; no filesystem access or shell evaluation.
-use std::path::{Path, PathBuf};
-
-pub(in crate::application) fn valid(path: &Path) -> bool {
- path.is_absolute()
- && path
- .to_str()
- .is_some_and(|text| text.len() <= 4096 && !text.chars().any(char::is_control))
-}
-
-/// Interpret only a whole paste made of local paths; never run shell expansion.
-pub(in crate::application) fn parse(text: &str) -> Option<Vec<PathBuf>> {
- let text = text.trim();
- if text.is_empty() {
- return None;
- }
- let words = path_words(text, cfg!(windows))?;
- let parsed: Vec<_> = words
- .iter()
- .map(|word| local_path(word))
- .collect::<Option<_>>()?;
- (!parsed.is_empty()).then_some(parsed)
-}
-
-/// Path quoting, not a shell language: comments, operators and expansions stay
-/// literal tokens, so a prose suffix can never disappear during classification.
-fn path_words(text: &str, windows: bool) -> Option<Vec<String>> {
- let mut words = Vec::new();
- let mut word = String::new();
- let mut quote = None;
- let mut started = false;
- let mut chars = text.chars();
- while let Some(character) = chars.next() {
- if quote.is_none() && word.starts_with("file://") && !character.is_whitespace() {
- // URI apostrophes are path characters, not shell quote delimiters.
- word.push(character);
- } else if quote == Some('\'') {
- if character == '\'' {
- quote = None;
- } else {
- word.push(character);
- }
- } else if character == '\\' && !windows {
- let escaped = chars.next()?;
- if matches!(escaped, '\n' | '\r') {
- return None;
- }
- if quote == Some('"') && !matches!(escaped, '\\' | '"' | '$' | '`') {
- word.push('\\');
- }
- word.push(escaped);
- started = true;
- } else if Some(character) == quote {
- quote = None;
- } else if quote.is_none() && (character == '"' || character == '\'' && !windows) {
- quote = Some(character);
- started = true;
- } else if quote.is_none() && character.is_whitespace() {
- if started {
- words.push(std::mem::take(&mut word));
- started = false;
- }
- } else {
- word.push(character);
- started = true;
- }
- }
- if started {
- words.push(word);
- }
- quote.is_none().then_some(words)
-}
-pub(in crate::application) fn local_path(text: &str) -> Option<PathBuf> {
- let path = if text.starts_with("file://") {
- if text.chars().any(char::is_whitespace) {
- return None;
- }
- let url = url::Url::parse(text).ok()?;
- if url.host_str().is_some_and(|host| host != "localhost")
- || url.query().is_some()
- || url.fragment().is_some()
- {
- return None;
- }
- url.to_file_path().ok()?
- } else {
- PathBuf::from(text)
- };
- valid(&path).then_some(path)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn nine_individually_valid_paths_are_parsed_without_an_aggregate_count_budget() {
- let expected: Vec<_> = (0..9)
- .map(|index| {
- let prefix = if cfg!(windows) { r"C:\" } else { "/" };
- PathBuf::from(format!("{prefix}{}{index}.txt", "a".repeat(3700)))
- })
- .collect();
- assert!(expected.iter().all(|path| valid(path)));
- let text = expected
- .iter()
- .map(|path| format!("\"{}\"", path.display()))
- .collect::<Vec<_>>()
- .join("\n");
- assert!(text.len() > 32768);
- assert_eq!(parse(&text), Some(expected));
- }
- #[test]
- fn path_pastes_preserve_unicode_spaces_and_never_evaluate_shell_syntax() {
- let directory = tempfile::tempdir().unwrap();
- let first = directory.path().join("中文 image.png");
- let second = directory.path().join("it's a file.txt");
- let quoted = format!("\"{}\" \"{}\"", first.display(), second.display());
- #[cfg(not(windows))]
- assert_eq!(parse("ed), Some(vec![first.clone(), second.clone()]));
- let uri_list = format!(
- "{}\r\n{}",
- url::Url::from_file_path(&first).unwrap(),
- url::Url::from_file_path(&second).unwrap()
- );
- assert_eq!(parse(&uri_list), Some(vec![first.clone(), second.clone()]));
- assert_eq!(
- parse(&uri_list.replace("\r\n", " ")),
- Some(vec![first.clone(), second.clone()])
- );
- assert!(
- parse(first.to_str().unwrap()).is_none(),
- "unquoted prose is not guessed to be a path containing spaces"
- );
- for text in [
- "Explain /tmp/file.txt",
- "hello\nworld",
- "file://remote.example/file.txt",
- "file:///tmp/a%00b",
- "$(touch /tmp/not-an-operation)",
- "relative.txt",
- "/tmp/a.txt # explain this file",
- "/tmp/a.txt\n# notes",
- "file:///tmp/a.txt please explain",
- "\"file:///tmp/a b\"",
- "/tmp/a.txt \"\"",
- ] {
- assert!(parse(text).is_none(), "{text}");
- }
- #[cfg(not(windows))]
- for text in ["'/tmp/file # name.txt'", r"/tmp/file\ \#\ name.txt"] {
- assert_eq!(
- parse(text),
- Some(vec![PathBuf::from("/tmp/file # name.txt")])
- );
- }
- assert_eq!(
- path_words(r#""C:\My Files\a.png" "D:\中文\b.txt""#, true),
- Some(vec![r"C:\My Files\a.png".into(), r"D:\中文\b.txt".into()])
- );
- }
-}
diff --git a/platform/render-terminal/src/application/focus.rs b/platform/render-terminal/src/application/focus.rs
deleted file mode 100644
index 82686a5afc..0000000000
--- a/platform/render-terminal/src/application/focus.rs
+++ /dev/null
@@ -1,510 +0,0 @@
-/*
- * 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::{
- Presenter,
- dialog::{self, WeakTarget},
-};
-
-pub(super) struct Exit {
- pub mount: uuid::Uuid,
- pub owner: std::rc::Weak<()>,
-}
-#[derive(Clone)]
-pub(super) enum Return {
- Active {
- source: WeakTarget,
- target: Option<WeakTarget>,
- scope: Option<dialog::Scope>,
- },
- /// A completed return suppresses ordinal fallback and the held key's repeats.
- Exited,
-}
-impl Presenter {
- pub(super) fn track_focus_return(&mut self) -> maka_ui_runtime::Result<()> {
- let Some(focus) = self.focus.as_ref() else {
- return Ok(());
- };
- if !self.resources.returns_focus(focus)? {
- if matches!(self.native_return, Some(Return::Active { .. })) {
- self.native_return = None;
- }
- return Ok(());
- }
- if matches!(&self.native_return, Some(Return::Active { source, .. }) if source.matches(focus))
- {
- return Ok(());
- }
- let target = self
- .scene
- .iter()
- .filter(|target| {
- target.tab_index >= 0
- && target.mount != focus.mount
- && dialog::same_scope(&target.scope, &focus.scope)
- })
- .min_by_key(|target| (target.tab_index == 0, target.tab_index));
- self.native_return = Some(Return::Active {
- source: WeakTarget::new(focus),
- target: target.map(WeakTarget::new),
- scope: focus.scope.clone(),
- });
- Ok(())
- }
- pub(super) fn exit_native_focus(&mut self) -> maka_ui_runtime::Result<()> {
- self.clear_hover()?;
- let destination = match self.native_return.take() {
- Some(Return::Active {
- source,
- target,
- scope,
- }) if self
- .focus
- .as_ref()
- .is_some_and(|focus| source.matches(focus)) =>
- {
- target
- .and_then(|target| target.resolve(&self.scene))
- .filter(|target| {
- dialog::same_scope(&scope, &target.scope)
- && self.target_admitted(target)
- && self.resources.owns(target)
- && self.application.mount_visible(&target.mount)
- })
- }
- _ => None,
- };
- self.focus = destination;
- self.native_return = Some(Return::Exited);
- Ok(())
- }
- pub(super) fn apply_focus_exits(&mut self) -> maka_ui_runtime::Result<()> {
- // Drain every owner, so a loss while unfocused cannot exit a later entry.
- let exits = self.resources.take_focus_exits()?;
- self.apply_modal_focus_exits(&exits);
- if self
- .focus
- .as_ref()
- .is_some_and(|focus| WeakTarget::new(focus).exited(&exits))
- {
- self.exit_native_focus()?;
- }
- Ok(())
- }
-}
-
-impl Presenter {
- pub(super) fn freeze_focus_child(
- &self,
- parent: &maka_plugin_core::runtime::Mount,
- name: &str,
- ) -> maka_ui_runtime::Result<super::paint::FocusChild> {
- let child = self.application.child_mount(parent, name)?.clone();
- let owner = self.resources.owners.get(&child.id()).ok_or_else(|| {
- maka_ui_runtime::Error::Invalid("focus child native owner unavailable".into())
- })?;
- Ok(super::paint::FocusChild {
- child,
- owner: std::rc::Rc::downgrade(&owner.identity),
- })
- }
- pub(super) fn freeze_focus_shortcuts(
- &self,
- parent: &maka_plugin_core::runtime::Mount,
- bindings: &[maka_ui_runtime::shortcut::FocusShortcut],
- ) -> maka_ui_runtime::Result<Vec<super::paint::FocusBinding>> {
- bindings
- .iter()
- .map(|declaration| {
- Ok(super::paint::FocusBinding {
- declaration: declaration.clone(),
- target: self.freeze_focus_child(parent, &declaration.child)?,
- })
- })
- .collect()
- }
- pub(super) fn focus_child(
- &mut self,
- binding: &super::paint::FocusChild,
- ) -> maka_ui_runtime::Result<()> {
- if self.application.is_retiring(&binding.child)
- || !self.application.mount_visible(&binding.child)
- || !self
- .resources
- .owners
- .get(&binding.child.id())
- .is_some_and(|owner| {
- owner.live
- && binding
- .owner
- .ptr_eq(&std::rc::Rc::downgrade(&owner.identity))
- })
- {
- return Ok(());
- }
- let mounts = self.application.mounted_subtree(&binding.child)?;
- let target = self
- .focus_stops(|target| mounts.contains(&target.mount))
- .into_iter()
- .filter(|target| {
- target.tab_index >= 0
- && mounts.contains(&target.mount)
- && self.target_admitted(target)
- && self.resources.owns(target)
- && !self.application.is_retiring(&target.mount)
- && self.application.mount_visible(&target.mount)
- && self
- .application
- .frame(&target.mount)
- .is_ok_and(|frame| frame.revision == target.revision)
- })
- .min_by_key(|target| (target.tab_index == 0, target.tab_index))
- .cloned();
- if let Some(target) = target {
- self.focus_item(target)?;
- }
- Ok(())
- }
-}
-
-// Original Surface grouping: one Tab stop, declaration-order arrow navigation,
-// and focus-following activation. There is no second native selection cursor.
-impl super::paint::Target {
- fn group(&self) -> Option<&super::paint::Ancestor> {
- self.ancestors
- .iter()
- .rev()
- .find(|ancestor| ancestor.focus_group.is_some())
- .map(|a| a.as_ref())
- }
- fn same_group(&self, other: &Self) -> bool {
- self.group().zip(other.group()).is_some_and(|(a, b)| {
- a.mount == b.mount
- && a.key == b.key
- && std::rc::Rc::ptr_eq(&a.owner, &b.owner)
- && dialog::same_scope(&a.scope, &b.scope)
- })
- }
-}
-impl Presenter {
- pub(super) fn tab_stops(&self) -> Vec<&super::paint::Target> {
- self.focus_stops(|_| true)
- }
- fn focus_stops(
- &self,
- include: impl Fn(&super::paint::Target) -> bool,
- ) -> Vec<&super::paint::Target> {
- use super::paint::TargetKind;
- let priority = |target: &super::paint::Target| {
- (
- self.focus.as_ref().is_some_and(|focus| focus.same(target)),
- matches!(target.kind, TargetKind::Item { current: true, .. }),
- !target.area.is_empty(),
- )
- };
- let mut stops: Vec<&super::paint::Target> = Vec::new();
- for target in self
- .scene
- .iter()
- .filter(|target| target.tab_index >= 0 && include(target))
- {
- if let Some(stop) = stops.iter_mut().find(|stop| stop.same_group(target)) {
- if priority(target) > priority(stop) {
- *stop = target;
- }
- } else {
- stops.push(target);
- }
- }
- stops.sort_by_key(|target| (target.tab_index == 0, target.tab_index));
- stops
- }
- pub(super) fn focus_item(
- &mut self,
- target: super::paint::Target,
- ) -> maka_ui_runtime::Result<()> {
- use super::paint::TargetKind;
- if self.application.is_retiring(&target.mount)
- || !self.application.mount_visible(&target.mount)
- || !self.target_admitted(&target)
- || ((target.area.is_empty() || matches!(target.kind, TargetKind::Item { .. }))
- && self.application.frame(&target.mount)?.revision != target.revision)
- || !(if target.area.is_empty() || matches!(target.kind, TargetKind::Item { .. }) {
- self.resources.owns(&target)
- } else {
- self.resources.enter_focus(&target)?
- })
- {
- return Ok(());
- }
- let changed = !self.focus.as_ref().is_some_and(|old| old.same(&target));
- self.reveal_focus_target(&target, true)?;
- self.clear_item_selection(&target);
- self.focus = Some(target.clone());
- if let TargetKind::Item {
- current,
- follow_focus: true,
- ..
- } = target.kind
- && (changed || !current)
- {
- self.application.dispatch(
- &target.mount,
- target.revision,
- &target.key,
- serde_json::json!({"kind":"activate"}),
- )?;
- self.sync_native()?;
- }
- self.track_focus_return()
- }
- pub(super) fn item_reading_scope(
- &self,
- target: &super::paint::Target,
- ) -> Option<(super::selection::SelectionScope, Option<String>)> {
- if !matches!(target.kind, super::paint::TargetKind::Item { .. })
- || !self.target_admitted(target)
- || !self.resources.owns(target)
- {
- return None;
- }
- for ancestor in target.ancestors.iter().rev() {
- if !self.target_admitted(ancestor) || !self.resources.owns(ancestor) {
- continue;
- }
- if let Some(reading) = self
- .resources
- .owners
- .get(&ancestor.mount.id())
- .and_then(|owner| owner.state.downcast_ref::<super::scroll::SceneState>())
- .and_then(|state| state.reading.as_ref())
- .filter(|reading| reading.root_key == ancestor.key)
- {
- let super::paint::TargetKind::Item { reading_child, .. } = &target.kind else {
- return None;
- };
- return Some((reading.scope.clone(), reading_child.clone()));
- }
- }
- None
- }
- pub(super) fn clear_item_selection(&self, target: &super::paint::Target) {
- if let Some((scope, _)) = self.item_reading_scope(target) {
- scope.clear();
- }
- }
- pub(super) fn extend_item_selection(
- &mut self,
- event: &crossterm::event::Event,
- ) -> maka_ui_runtime::Result<bool> {
- use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
- let Event::Key(key) = event else {
- return Ok(false);
- };
- if key.kind == KeyEventKind::Release
- || key.modifiers != KeyModifiers::SHIFT
- || !matches!(
- key.code,
- KeyCode::Left
- | KeyCode::Right
- | KeyCode::Up
- | KeyCode::Down
- | KeyCode::Home
- | KeyCode::End
- | KeyCode::PageUp
- | KeyCode::PageDown
- )
- {
- return Ok(false);
- }
- let Some(focus) = self.focus.as_ref() else {
- return Ok(false);
- };
- if self.application.is_retiring(&focus.mount)
- || !self.application.mount_visible(&focus.mount)
- || self.application.frame(&focus.mount)?.revision != focus.revision
- {
- return Ok(false);
- }
- let Some((scope, Some(child))) = self.item_reading_scope(focus) else {
- return Ok(false);
- };
- Ok(scope.extend_child(&child, key.code))
- }
- pub(super) fn reveal_focus_target(
- &mut self,
- target: &super::paint::Target,
- physical: bool,
- ) -> maka_ui_runtime::Result<bool> {
- let mut changed = false;
- let mut bounds = target.area.bounds;
- for ancestor in target.ancestors.iter().rev() {
- if self.target_admitted(ancestor)
- && self.resources.owns(ancestor)
- && self.application.frame(&ancestor.mount)?.revision == ancestor.revision
- && let Some(viewport) = &ancestor.scroll
- && let Some(state) = self
- .resources
- .owners
- .get_mut(&ancestor.mount.id())
- .and_then(|owner| owner.state.downcast_mut::<super::scroll::SceneState>())
- {
- changed |= state.reveal(&ancestor.key, viewport, &mut bounds, physical);
- }
- }
- Ok(changed)
- }
- pub(super) fn navigate_items(
- &mut self,
- event: &crossterm::event::Event,
- capture: Option<&mut Option<super::activation::CapturedActivation>>,
- ) -> maka_ui_runtime::Result<bool> {
- use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
- let Event::Key(key) = event else {
- return Ok(false);
- };
- if key.kind == KeyEventKind::Release || key.modifiers != KeyModifiers::NONE {
- return Ok(false);
- }
- let Some(focus) = self
- .focus
- .as_ref()
- .filter(|target| matches!(target.kind, super::paint::TargetKind::Item { .. }))
- else {
- return Ok(false);
- };
- use maka_ui_runtime::scene::terminal::Direction;
- let along = match key.code {
- KeyCode::Left | KeyCode::Right => {
- focus.group().and_then(|group| group.focus_group) == Some(Direction::Row)
- }
- KeyCode::Up | KeyCode::Down => {
- focus.group().and_then(|group| group.focus_group) == Some(Direction::Column)
- }
- KeyCode::Home | KeyCode::End => true,
- _ => false,
- };
- if !along {
- return Ok(false);
- }
- let peers: Vec<_> = self
- .scene
- .iter()
- .filter(|target| {
- target.tab_index >= 0
- && target.same_group(focus)
- && matches!(target.kind, super::paint::TargetKind::Item { .. })
- })
- .collect();
- let Some(at) = peers.iter().position(|target| target.same(focus)) else {
- return Ok(false);
- };
- let next = match key.code {
- KeyCode::Up | KeyCode::Left => at.saturating_sub(1),
- KeyCode::Down | KeyCode::Right => (at + 1).min(peers.len() - 1),
- KeyCode::Home => 0,
- _ => peers.len() - 1,
- };
- let target = peers[next].clone();
- // At an item-group edge, let its declared action request another page.
- // Interior navigation and undeclared boundaries retain native behavior.
- if next == at && self.dispatch_focused_shortcut_with_capture(event, capture)? {
- return Ok(true);
- }
- self.focus_item(target)?;
- Ok(true)
- }
-}
-
-impl Presenter {
- /// Keep a keyboard range on its original reading scope as its head crosses
- /// sources and scrolling removes the old source from the physical viewport.
- pub(super) fn reading_selection_focus(
- &self,
- targets: &[super::paint::Target],
- ) -> Option<super::paint::Target> {
- use super::{selection::SelectionScope, source::Owner};
- use std::rc::Rc;
- let old = self.focus.as_ref()?;
- let owner = self.resources.owners.get(&old.mount.id())?;
- if !owner.live || !Rc::ptr_eq(&owner.identity, &old.owner) {
- return None;
- }
- let scope = owner
- .state
- .downcast_ref::<Owner>()
- .and_then(|source| SelectionScope::active(&source.document))
- .or_else(|| self.item_reading_scope(old).map(|(scope, _)| scope))?;
- let head = scope.keyboard_focus()?;
- let candidates = targets.iter().filter_map(|target| {
- let owner = self.resources.owners.get(&target.mount.id())?;
- if !self.target_admitted(target) || !self.resources.admits(target) {
- return None;
- }
- let source = owner.state.downcast_ref::<Owner>()?;
- let doc = source.document.borrow();
- (doc.alive && !doc.disabled && doc.binding.ptr_eq(&Rc::downgrade(&scope.0)))
- .then_some((target, head.ptr_eq(&Rc::downgrade(&source.document))))
- });
- let next = candidates.max_by_key(|(_, head)| *head);
- if next.as_ref().is_some_and(|(_, head)| *head) {
- scope.focus_revealed();
- }
- next.map(|(target, _)| target.clone())
- .or_else(|| Some(old.clone()))
- }
-}
-
-impl Presenter {
- pub(super) fn cancel_reading_keyboard_reveal(&self, event: &crossterm::event::Event) {
- use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
- let supersedes = match event {
- Event::Key(key) if key.kind != KeyEventKind::Release => {
- !(key.modifiers == KeyModifiers::SHIFT
- && matches!(
- key.code,
- KeyCode::Left
- | KeyCode::Right
- | KeyCode::Up
- | KeyCode::Down
- | KeyCode::Home
- | KeyCode::End
- | KeyCode::PageUp
- | KeyCode::PageDown
- ))
- }
- Event::Mouse(mouse) => {
- !matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Up(_))
- }
- Event::Paste(_) | Event::FocusLost => true,
- _ => false,
- };
- if supersedes
- && let Some(focus) = &self.focus
- && let Some(owner) = self.resources.owners.get(&focus.mount.id())
- && let Some(scope) = owner
- .state
- .downcast_ref::<super::source::Owner>()
- .and_then(|source| super::selection::SelectionScope::active(&source.document))
- .or_else(|| self.item_reading_scope(focus).map(|(scope, _)| scope))
- {
- scope.cancel_keyboard_reveal();
- }
- }
-}
diff --git a/platform/render-terminal/src/application/geometry.rs b/platform/render-terminal/src/application/geometry.rs
deleted file mode 100644
index 15f376e900..0000000000
--- a/platform/render-terminal/src/application/geometry.rs
+++ /dev/null
@@ -1,142 +0,0 @@
-/*
- * 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 ratatui::layout::{Position, Rect};
-use std::ops::Deref;
-
-/// Logical terminal cells. Signed origins retain content above/left of the
-/// viewport; full height never narrows to the physical terminal's u16 extent.
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub struct CellRect {
- pub x: i64,
- pub y: i64,
- pub width: u16,
- pub height: u32,
-}
-impl CellRect {
- pub fn new(x: i64, y: i64, width: u16, height: u32) -> Self {
- Self {
- x,
- y,
- width,
- height,
- }
- }
- pub fn right(self) -> i64 {
- self.x + i64::from(self.width)
- }
- pub fn bottom(self) -> i64 {
- self.y + i64::from(self.height)
- }
- pub fn translated(self, x: i64, y: i64) -> Self {
- Self {
- x: self.x + x,
- y: self.y + y,
- ..self
- }
- }
-}
-/// One logical placement plus its inherited physical clip. Clipping never
-/// changes wrapping width, source origin, full height or pointer byte mapping.
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub struct Placement {
- pub bounds: CellRect,
- pub clip: Rect,
-}
-impl Deref for Placement {
- type Target = CellRect;
- fn deref(&self) -> &CellRect {
- &self.bounds
- }
-}
-impl From<Rect> for Placement {
- fn from(area: Rect) -> Self {
- Self::new(
- CellRect::new(
- i64::from(area.x),
- i64::from(area.y),
- area.width,
- u32::from(area.height),
- ),
- area,
- )
- }
-}
-impl Placement {
- pub fn new(bounds: CellRect, clip: Rect) -> Self {
- Self { bounds, clip }
- }
- pub fn visible(self) -> Rect {
- let x = self.x.max(i64::from(self.clip.x));
- let y = self.y.max(i64::from(self.clip.y));
- let right = self.right().min(i64::from(self.clip.right()));
- let bottom = self.bottom().min(i64::from(self.clip.bottom()));
- if right <= x || bottom <= y {
- return Rect::default();
- }
- Rect::new(x as u16, y as u16, (right - x) as u16, (bottom - y) as u16)
- }
- pub fn is_empty(self) -> bool {
- self.visible().is_empty()
- }
- pub fn contains(self, point: Position) -> bool {
- self.visible().contains(point)
- }
- pub fn clipped(self, clip: Rect) -> Self {
- Self {
- clip: self.clip.intersection(clip),
- ..self
- }
- }
- pub fn inner(self, margin: ratatui::layout::Margin) -> Self {
- let x = margin.horizontal.min(self.width / 2);
- let y = u32::from(margin.vertical).min(self.height / 2);
- Self::new(
- CellRect::new(
- self.x + i64::from(x),
- self.y + i64::from(y),
- self.width - x * 2,
- self.height - y * 2,
- ),
- self.visible(),
- )
- }
- pub fn point(self, x: usize, y: usize) -> Option<Position> {
- let x = self.x + i64::try_from(x).ok()?;
- let y = self.y + i64::try_from(y).ok()?;
- let point = Position::new(u16::try_from(x).ok()?, u16::try_from(y).ok()?);
- self.contains(point).then_some(point)
- }
-}
-#[cfg(test)]
-mod tests {
- use super::*;
- #[test]
- fn clipping_preserves_negative_origin_and_full_height() {
- let placement = Placement::new(
- CellRect::new(-3, -70000, 20, 100000),
- Rect::new(0, 0, 10, 8),
- );
- assert_eq!(placement.visible(), Rect::new(0, 0, 10, 8));
- assert_eq!(placement.y, -70000);
- assert_eq!(placement.height, 100000);
- assert_eq!(placement.point(3, 70000), Some(Position::new(0, 0)));
- assert_eq!(placement.point(2, 70000), None);
- }
-}
diff --git a/platform/render-terminal/src/application/input.rs b/platform/render-terminal/src/application/input.rs
deleted file mode 100644
index a89e86f585..0000000000
--- a/platform/render-terminal/src/application/input.rs
+++ /dev/null
@@ -1,437 +0,0 @@
-/*
- * 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::Placement;
-use super::editor::Owner;
-use super::{NativeInput, Slots, Widget, WidgetInput};
-use crate::editor::{Editor, Geometry};
-use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
-use maka_ui_runtime::{
- ComponentCall, ComponentOutput, NativeCapability, NativeComponent, NativeExport,
- NativeRegistry, NativeView, editor::*, input::*, textarea::TextareaMenu,
-};
-use ratatui::{
- buffer::Buffer,
- style::{Color, Modifier, Style},
-};
-use std::{any::Any, collections::BTreeMap, rc::Rc, sync::Arc};
-
-fn color(color: maka_ui_runtime::appearance::Color) -> Color {
- let n = color.0;
- Color::Rgb((n >> 24) as u8, (n >> 16) as u8, (n >> 8) as u8)
-}
-struct Recipe {
- props: InputProps,
- secret: bool,
-}
-impl Widget for Recipe {
- fn publication_capacity(&self, state: &dyn Any) -> Vec<(&'static str, usize)> {
- let owner = state.downcast_ref::<Owner>().unwrap();
- if !owner.announced
- || self
- .props
- .command
- .as_ref()
- .is_some_and(|command| owner.last_request.as_ref() != Some(command))
- {
- vec![("observe", 4096)]
- } else {
- Vec::new()
- }
- }
-
- fn cancel_pointer(&self, state: &mut dyn Any) {
- state
- .downcast_mut::<Owner>()
- .unwrap()
- .editor
- .cancel_pointer();
- }
- fn create(&self, context: super::NativeContext) -> Box<dyn Any> {
- let mut owner = Owner::new(
- Editor::single_line(self.props.max_bytes as usize, self.secret),
- "",
- );
- owner.clipboard(context);
- Box::new(owner)
- }
- fn commit(&self, state: &mut dyn Any) -> maka_ui_runtime::Result<Option<NativeInput>> {
- let owner = state
- .downcast_mut::<Owner>()
- .expect("terminal editor owner");
- assert_eq!(owner.editor.secret(), self.secret, "input export changed");
- let changed = self
- .props
- .command
- .as_ref()
- .is_some_and(|command| owner.last_request.as_ref() != Some(command));
- if changed {
- owner.apply(self.props.command.as_ref().unwrap());
- }
- owner.poll_paste(!self.props.disabled && !self.props.read_only);
- let edited = owner.refresh();
- if changed || edited || !owner.announced {
- owner.announced = true;
- Ok(Some(owner.observation()))
- } else {
- Ok(None)
- }
- }
- fn observe(&self, state: &mut dyn Any) -> Option<NativeInput> {
- let owner = state.downcast_mut::<Owner>().unwrap();
- owner.poll_paste(!self.props.disabled && !self.props.read_only);
- owner.refresh().then(|| owner.observation())
- }
- fn present_focus(&self, state: &mut dyn Any) -> bool {
- state
- .downcast_mut::<Owner>()
- .expect("terminal editor owner")
- .focus
- .presented()
- }
- fn prepare_focus(&self, state: &mut dyn Any, visible: bool, _: u64) {
- state
- .downcast_mut::<Owner>()
- .expect("terminal editor owner")
- .focus
- .reconcile(visible && !self.props.disabled && self.props.auto_focus);
- }
- fn input(
- &self,
- state: &mut dyn Any,
- _: &str,
- event: &Event,
- ) -> maka_ui_runtime::Result<WidgetInput> {
- let owner = state
- .downcast_mut::<Owner>()
- .expect("terminal editor owner");
- if self.props.disabled {
- return Ok(if matches!(event, Event::Key(_)) {
- WidgetInput::Unhandled
- } else {
- WidgetInput::Consumed(None)
- });
- }
- if let Event::Key(key) = event
- && key.code == KeyCode::Char('v')
- && matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER)
- {
- if !self.props.read_only && key.kind == KeyEventKind::Press {
- owner.paste(matches!(self.props.terminal_paste, TerminalPaste::Spaces));
- }
- return Ok(WidgetInput::Consumed(None));
- }
- let handled = match event {
- Event::Key(key) => {
- if key.modifiers.is_empty() && matches!(key.code, KeyCode::Up | KeyCode::Down) {
- return Ok(WidgetInput::Navigate);
- }
- if key.code == KeyCode::Enter && key.modifiers.is_empty() && !self.props.read_only {
- let input = NativeInput {
- replaceable: false,
- key: "submit".into(),
- value: serde_json::json!({}),
- };
- owner.refresh();
- return Ok(match &self.props.submit_copy {
- Some(copy) if owner.snapshot().text == copy.basis => {
- WidgetInput::SubmitCopy(input, copy.clone())
- }
- Some(_) => WidgetInput::Consumed(Some(owner.observation())),
- None => WidgetInput::Emitted(input),
- });
- }
- let navigation = matches!(
- key.code,
- KeyCode::Left
- | KeyCode::Right
- | KeyCode::Up
- | KeyCode::Down
- | KeyCode::Home
- | KeyCode::End
- ) || key.code == KeyCode::Char('a')
- && key.modifiers.contains(KeyModifiers::CONTROL);
- if self.props.read_only && !navigation {
- false
- } else {
- owner.editor.key(*key)
- }
- }
- Event::Paste(text) if !self.props.read_only => match self.props.terminal_paste {
- TerminalPaste::RejectControls => owner.editor.insert(text),
- TerminalPaste::Spaces => owner
- .editor
- .insert(&text.replace("\r\n", " ").replace(['\r', '\n', '\t'], " ")),
- },
- Event::Mouse(mouse) => owner
- .presented
- .as_ref()
- .is_some_and(|geometry| owner.editor.mouse_presented(*mouse, geometry)),
- _ => false,
- };
- let observed = handled && owner.refresh();
- Ok(if handled {
- WidgetInput::Consumed(observed.then(|| owner.observation()))
- } else {
- WidgetInput::Unhandled
- })
- }
- fn pointer_capture(&self, state: &dyn Any) -> bool {
- state.downcast_ref::<Owner>().unwrap().editor.dragging()
- }
- fn capture(
- &self,
- state: &dyn Any,
- target: &maka_ui_runtime::actions::CaptureTarget,
- ) -> maka_ui_runtime::Result<Arc<dyn maka_capabilities::call::ReadResource>> {
- state
- .downcast_ref::<Owner>()
- .expect("terminal editor owner")
- .capture(target)
- }
-
- fn take_redraw(&self, state: &mut dyn Any) -> bool {
- let owner = state.downcast_mut::<Owner>().unwrap();
- std::mem::take(&mut owner.redraw)
- }
- fn cancel_paste(&self, state: &mut dyn Any) {
- let owner = state.downcast_mut::<Owner>().unwrap();
- owner.cancel_paste();
- }
- fn editing_menu(&self, state: &dyn Any) -> Option<(TextareaMenu, EditorSnapshot, [bool; 4])> {
- let owner = state.downcast_ref::<Owner>().unwrap();
- if self.props.disabled {
- return None;
- }
- let menu = self.props.menu.clone()?;
- let selected = !owner.editor.selected_text().is_empty() && !self.secret;
- let editable = !self.props.read_only;
- Some((
- menu,
- owner.snapshot(),
- [
- selected,
- selected && editable,
- editable,
- !owner.editor.text().is_empty(),
- ],
- ))
- }
- fn copy(&self, state: &dyn Any, _: bool) -> maka_ui_runtime::Result<Option<String>> {
- let owner = state.downcast_ref::<Owner>().unwrap();
- if self.secret || self.props.disabled {
- return Ok(None);
- }
- let text = owner.editor.selected_text();
- Ok((!text.is_empty()).then(|| text.to_owned()))
- }
- fn measure(&self, slots: &Slots<'_>, width: u16) -> maka_ui_runtime::Result<(u16, u32)> {
- let _ = slots.native_state::<Owner>()?;
- Ok((
- width,
- if self.props.appearance.border_width > 0.0 {
- 3
- } else {
- 1
- },
- ))
- }
-
- fn paint(
- &self,
- slots: &mut Slots<'_>,
- area: Placement,
- buffer: &mut Buffer,
- ) -> maka_ui_runtime::Result<()> {
- if area.is_empty() {
- if !self.props.disabled {
- slots.native_target("observe", area, self.props.tab_index, false)?;
- }
- return Ok(());
- }
- let owner = slots.native_state::<Owner>()?;
- let focused = slots.focused();
- let appearance = &self.props.appearance;
- let terminal = appearance.terminal.as_ref();
- let foreground = terminal.map_or_else(
- || color(appearance.text.foreground),
- |p| super::paint::color(p.foreground),
- );
- let background = terminal.map_or_else(
- || color(appearance.background),
- |p| super::paint::color(p.background),
- );
- let selection = terminal.map_or_else(
- || color(appearance.selection),
- |p| super::paint::color(p.selection),
- );
- let selection_text = terminal.map_or(foreground, |p| super::paint::color(p.selection_text));
- let muted = terminal.map_or_else(
- || color(appearance.muted_foreground),
- |p| super::paint::color(p.subtle),
- );
- let border = terminal.map_or_else(
- || {
- color(if focused {
- appearance.focused_border
- } else {
- appearance.border
- })
- },
- |p| super::paint::color(if focused { p.accent } else { p.border }),
- );
- buffer.set_style(
- area.visible(),
- Style::default().bg(background).fg(foreground),
- );
- let inner = if appearance.border_width > 0.0 {
- super::border::paint(
- buffer,
- area,
- border,
- if appearance.ascii {
- maka_ui_runtime::scene::terminal::BorderStyle::Ascii
- } else if appearance.radius > 0.0 {
- maka_ui_runtime::scene::terminal::BorderStyle::Rounded
- } else {
- maka_ui_runtime::scene::terminal::BorderStyle::Square
- },
- );
- super::border::inner(area)
- } else {
- area
- };
- let geometry = owner.editor.paint_prepared(
- inner,
- buffer,
- focused,
- Style::default().fg(foreground).bg(background),
- Style::default().bg(selection).fg(selection_text),
- Style::default().add_modifier(Modifier::REVERSED),
- );
- if owner.editor.text().is_empty() {
- super::paint::text(
- &self.props.placeholder,
- inner,
- buffer,
- Style::default().fg(muted),
- );
- }
- if let Some(error) = owner.editor.error {
- let error_area = ratatui::layout::Rect::new(
- inner.visible().x,
- inner.visible().bottom().saturating_sub(1),
- inner.visible().width,
- 1,
- );
- buffer.set_stringn(
- error_area.x,
- error_area.y,
- super::editor::error_message(error),
- usize::from(error_area.width),
- Style::default().fg(Color::Red).bg(background),
- );
- }
- let autofocus = owner.focus.pending();
- if !self.props.disabled {
- slots.native_cursor(geometry.cursor_position())?;
- }
- slots.native_geometry(Box::new(geometry))?;
- if !self.props.disabled {
- slots.native_target("observe", area, self.props.tab_index, autofocus)?;
- }
- Ok(())
- }
- fn present(&self, state: &mut dyn Any, geometry: Box<dyn Any>) -> Option<NativeInput> {
- let owner = state
- .downcast_mut::<Owner>()
- .expect("terminal editor owner");
- let geometry = *geometry.downcast::<Geometry>().expect("editor geometry");
- owner.editor.present_geometry(geometry.clone());
- owner.presented = Some(geometry);
- None
- }
- fn deactivate(&self, state: &mut dyn Any) {
- let owner = state
- .downcast_mut::<Owner>()
- .expect("terminal editor owner");
- owner.invalidate_geometry();
- }
- fn presentation(&self, state: &mut dyn Any, visible: bool) {
- if !visible {
- state
- .downcast_mut::<Owner>()
- .expect("terminal editor owner")
- .invalidate_geometry();
- }
- }
-}
-struct Component {
- secret: bool,
-}
-impl NativeComponent<dyn Widget> for Component {
- fn capture(
- &self,
- _: maka_ui_runtime::actions::CaptureCall<'_>,
- ) -> maka_ui_runtime::Result<maka_ui_runtime::actions::CapturePlan> {
- Ok(maka_ui_runtime::actions::CapturePlan::Native)
- }
- fn call(
- &self,
- call: ComponentCall<'_>,
- _: &maka_ui_runtime::abi::ValueBindings,
- ) -> maka_ui_runtime::Result<ComponentOutput<NativeView<dyn Widget>>> {
- let output = maka_ui_runtime::input::reduce(call)?;
- let props = output.frame.view.clone();
- let view = NativeView::<dyn Widget>::new(
- Rc::new(Recipe {
- props,
- secret: self.secret,
- }),
- BTreeMap::from([
- ("observe".into(), observation_schema()),
- ("submit".into(), submitted_schema()),
- ]),
- vec![],
- )?;
- Ok(output.map_view(|_| view))
- }
-}
-pub(super) fn register(registry: &mut NativeRegistry<dyn Widget>) {
- registry
- .register(maka_ui_runtime::input::IMPLEMENTATION, |_, _| {
- Ok(BTreeMap::from([
- (
- "TextInput".into(),
- NativeExport {
- contract: text_input_contract(),
- value: NativeCapability::Component(Rc::new(Component { secret: false })),
- },
- ),
- (
- "SecretInput".into(),
- NativeExport {
- contract: secret_input_contract(),
- value: NativeCapability::Component(Rc::new(Component { secret: true })),
- },
- ),
- ]))
- })
- .expect("unique terminal input");
-}
diff --git a/platform/render-terminal/src/application/layout.rs b/platform/render-terminal/src/application/layout.rs
deleted file mode 100644
index 2423411cf3..0000000000
--- a/platform/render-terminal/src/application/layout.rs
+++ /dev/null
@@ -1,447 +0,0 @@
-/*
- * 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::CellRect;
-use super::scene::{Direction, Layout};
-
-pub(super) struct Flow {
- pub size: (u16, u32),
- pub children: Vec<CellRect>,
-}
-fn basis(layout: Layout, available: Option<u32>, intrinsic: u32) -> u32 {
- if let Some(value) = layout.basis {
- return u32::from(value);
- }
- if let Some(fraction) = layout.basis_fraction {
- let value = available.map_or(intrinsic, |available| {
- (u64::from(available) * u64::from(fraction.numerator) / u64::from(fraction.denominator))
- as u32
- });
- return value
- .max(u32::from(fraction.min))
- .min(fraction.max.map_or(u32::MAX, u32::from));
- }
- intrinsic
-}
-/// Row width allocation precedes cross-axis measurement. Narrow basis/grow
-/// children and wrapped rows must report their height at the actual width.
-pub(super) fn measured<E>(
- direction: Direction,
- width: u16,
- height: Option<u32>,
- gap: u16,
- wrap: bool,
- layouts: &[Layout],
- mut measure: impl FnMut(usize, u16) -> Result<(u16, u32), E>,
-) -> Result<Flow, E> {
- let mut children = layouts
- .iter()
- .enumerate()
- .map(|(index, &layout)| measure(index, width).map(|size| (size, layout)))
- .collect::<Result<Vec<_>, _>>()?;
- let allocated = flow(direction, width, height, gap, wrap, &children);
- if matches!(direction, Direction::Row) {
- for (index, rect) in allocated.children.iter().enumerate() {
- if rect.width != width {
- children[index].0.1 = measure(index, rect.width)?.1;
- }
- }
- Ok(flow(direction, width, height, gap, wrap, &children))
- } else {
- Ok(allocated)
- }
-}
-/// One cell-allocation algorithm for measurement and paint. An unbounded
-/// column measures intrinsic rows; a bounded viewport distributes grow space.
-pub(super) fn flow(
- direction: Direction,
- width: u16,
- height: Option<u32>,
- gap: u16,
- wrap: bool,
- children: &[((u16, u32), Layout)],
-) -> Flow {
- if matches!(direction, Direction::Row) && wrap {
- let mut rows: Vec<Vec<((u16, u32), Layout)>> = Vec::new();
- let mut occupied = 0u32;
- for &(size, mut layout) in children {
- let basis = basis(layout, Some(u32::from(width)), u32::from(size.0))
- .min(u32::from(width)) as u16;
- if rows.last().is_some_and(|row| !row.is_empty())
- && occupied + u32::from(gap) + u32::from(basis) > u32::from(width)
- {
- occupied = 0;
- rows.push(Vec::new());
- }
- if rows.is_empty() {
- rows.push(Vec::new());
- }
- let row = rows.last_mut().unwrap();
- if !row.is_empty() {
- occupied += u32::from(gap);
- }
- occupied += u32::from(basis);
- layout.basis = Some(basis);
- layout.basis_fraction = None;
- row.push((size, layout));
- }
- let (mut y, mut widest) = (0u32, 0u16);
- let mut placed = Vec::with_capacity(children.len());
- for (index, row) in rows.iter().enumerate() {
- let line = flow(Direction::Row, width, None, gap, false, row);
- widest = widest.max(line.size.0);
- for mut rect in line.children {
- rect.y = i64::from(y);
- placed.push(rect);
- }
- y = y.saturating_add(line.size.1);
- if index + 1 < rows.len() {
- y = y.saturating_add(u32::from(gap));
- }
- }
- return Flow {
- size: (widest, y),
- children: placed,
- };
- }
- let horizontal = matches!(direction, Direction::Row);
- let available = if horizontal {
- Some(u32::from(width))
- } else {
- height
- };
- let bases: Vec<u32> = children
- .iter()
- .map(|&(size, layout)| {
- basis(
- layout,
- available,
- if layout.grow > 0 && available.is_some() {
- 0
- } else if horizontal {
- u32::from(size.0)
- } else {
- size.1
- },
- )
- })
- .collect();
- let spacing = u32::from(gap)
- .saturating_mul(u32::try_from(children.len().saturating_sub(1)).unwrap_or(u32::MAX));
- let occupied = bases
- .iter()
- .copied()
- .fold(0u32, u32::saturating_add)
- .saturating_add(spacing);
- let free = available.map_or(0, |n| n.saturating_sub(occupied));
- let shrinkable: u64 = children
- .iter()
- .zip(&bases)
- .filter(|((_, layout), _)| layout.shrink)
- .map(|(_, base)| u64::from(*base))
- .sum();
- let deficit = u64::from(available.map_or(0, |n| occupied.saturating_sub(n))).min(shrinkable);
- // With no shrinkable basis both cumulative numerators remain zero.
- let shrinkable = shrinkable.max(1);
- let weight = children
- .iter()
- .map(|(_, layout)| u64::from(layout.grow))
- .sum::<u64>();
- let (mut offset, mut cross, mut used_weight) = (0u32, 0u32, 0u64);
- let mut used_shrink = 0u64;
- let mut placed = Vec::with_capacity(children.len());
- for (&(size, layout), base) in children.iter().zip(bases) {
- let before = used_weight;
- used_weight += u64::from(layout.grow);
- let extra = if weight == 0 {
- 0
- } else {
- (u128::from(free) * u128::from(used_weight) / u128::from(weight)
- - u128::from(free) * u128::from(before) / u128::from(weight)) as u32
- };
- let before_shrink = used_shrink;
- if layout.shrink {
- used_shrink += u64::from(base);
- }
- let reduction = (u128::from(deficit) * u128::from(used_shrink) / u128::from(shrinkable)
- - u128::from(deficit) * u128::from(before_shrink) / u128::from(shrinkable))
- as u32;
- let length = base.saturating_sub(reduction).saturating_add(extra);
- let length = if horizontal {
- length.min(u32::from(width).saturating_sub(offset))
- } else {
- length
- };
- placed.push(if horizontal {
- CellRect::new(
- i64::from(offset),
- 0,
- length as u16,
- height.unwrap_or(size.1),
- )
- } else {
- CellRect::new(0, i64::from(offset), width, length)
- });
- cross = cross.max(if horizontal {
- size.1
- } else {
- u32::from(size.0)
- });
- offset = offset.saturating_add(length);
- if placed.len() < children.len() {
- offset = offset.saturating_add(u32::from(gap));
- }
- }
- let length = offset;
- Flow {
- size: if horizontal {
- (length.min(u32::from(width)) as u16, cross)
- } else {
- (cross.min(u32::from(width)) as u16, length)
- },
- children: placed,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- #[test]
- fn fractional_bases_use_the_allocated_axis_and_clamp_before_growth() {
- let list = Layout {
- basis_fraction: Some(maka_ui_runtime::scene::terminal::FractionalBasis {
- numerator: 1,
- denominator: 3,
- min: 1,
- max: Some(6),
- }),
- ..Default::default()
- };
- let children = [
- ((50, 100), list),
- (
- (1, 1),
- Layout {
- basis: Some(1),
- ..Default::default()
- },
- ),
- (
- (50, 80),
- Layout {
- grow: 1,
- basis: Some(0),
- ..Default::default()
- },
- ),
- ];
- for height in 2..40 {
- let flow = flow(Direction::Column, 50, Some(height), 0, false, &children);
- assert_eq!(flow.children[0].height, (height / 3).clamp(1, 6));
- assert_eq!(
- flow.children[2].height,
- height - flow.children[0].height - 1
- );
- }
- assert_eq!(
- flow(Direction::Column, 50, None, 0, false, &children).children[0].height,
- 6
- );
- let mut wide = children;
- wide[0].1.basis_fraction = Some(maka_ui_runtime::scene::terminal::FractionalBasis {
- numerator: 34,
- denominator: 100,
- min: 0,
- max: None,
- });
- for width in 100..200 {
- let flow = flow(Direction::Row, width, Some(24), 0, false, &wide);
- assert_eq!(flow.children[0].width, width * 34 / 100);
- assert_eq!(flow.children[2].width, width - flow.children[0].width - 1);
- }
- let halves = [(
- (90, 1),
- Layout {
- basis_fraction: Some(maka_ui_runtime::scene::terminal::FractionalBasis {
- numerator: 1,
- denominator: 2,
- min: 0,
- max: None,
- }),
- ..Default::default()
- },
- ); 2];
- let wrapped = flow(Direction::Row, 100, None, 1, true, &halves);
- assert_eq!(
- wrapped.children,
- [CellRect::new(0, 0, 50, 1), CellRect::new(0, 2, 50, 1)]
- );
- }
- #[test]
- fn shrinking_keeps_fixed_controls_and_distributes_every_cell() {
- let shrink = Layout {
- shrink: true,
- ..Default::default()
- };
- let children = [
- ((8, 8), shrink),
- ((4, 4), shrink),
- ((3, 3), Layout::default()),
- ];
- for available in 0..20 {
- let column = flow(Direction::Column, 20, Some(available), 1, false, &children);
- let row = flow(Direction::Row, available as u16, None, 1, false, &children);
- assert_eq!(column.children[2].height, 3);
- assert_eq!(column.size.1, available.clamp(5, 17));
- if available >= 5 {
- assert_eq!(row.size.0, column.size.1 as u16);
- for (a, b) in row.children.iter().zip(&column.children) {
- assert_eq!(u32::from(a.width), b.height);
- }
- }
- }
- // Measuring unbounded content and undeclared children keeps the
- // original intrinsic sizes, including overflow in scroll containers.
- assert_eq!(
- flow(Direction::Column, 20, None, 1, false, &children)
- .size
- .1,
- 17
- );
- let fixed = [((8, 8), Layout::default()), ((3, 3), Layout::default())];
- assert_eq!(
- flow(Direction::Column, 20, Some(5), 1, false, &fixed)
- .size
- .1,
- 12
- );
- }
- #[test]
- fn fixed_siblings_reserve_cells_before_long_flexible_content() {
- let children = [
- ((20, 2), Layout::default()),
- (
- (20, 8192),
- Layout {
- grow: 1,
- ..Layout::default()
- },
- ),
- ((20, 3), Layout::default()),
- ];
- let layout = flow(Direction::Column, 20, Some(12), 1, false, &children);
- assert_eq!(
- layout.children,
- [
- CellRect::new(0, 0, 20, 2),
- CellRect::new(0, 3, 20, 5),
- CellRect::new(0, 9, 20, 3)
- ]
- );
- assert_eq!(
- flow(Direction::Column, 20, None, 1, false, &children)
- .size
- .1,
- 8199
- );
- }
- #[test]
- fn weighted_growth_uses_every_remaining_cell_once() {
- let children = [
- (
- (1, 9),
- Layout {
- grow: 1,
- ..Layout::default()
- },
- ),
- (
- (1, 9),
- Layout {
- grow: 2,
- ..Layout::default()
- },
- ),
- ((1, 2), Layout::default()),
- ];
- let layout = flow(Direction::Column, 7, Some(10), 0, false, &children);
- assert_eq!(
- layout.children.iter().map(|r| r.height).collect::<Vec<_>>(),
- [2, 6, 2]
- );
- let layout = flow(Direction::Row, 10, Some(3), 0, false, &children);
- assert_eq!(
- layout.children.iter().map(|r| r.width).collect::<Vec<_>>(),
- [3, 6, 1]
- );
- }
- #[test]
- fn wrapping_preserves_control_order_and_reports_actual_rows() {
- let children = [
- ((8, 1), Layout::default()),
- ((9, 1), Layout::default()),
- ((6, 1), Layout::default()),
- ];
- let layout = flow(Direction::Row, 16, None, 1, true, &children);
- assert_eq!(
- layout.children,
- [
- CellRect::new(0, 0, 8, 1),
- CellRect::new(0, 2, 9, 1),
- CellRect::new(10, 2, 6, 1)
- ]
- );
- assert_eq!(layout.size, (16, 3));
- }
-}
-
-#[cfg(test)]
-mod wrap_growth_tests {
- use super::*;
- #[test]
- fn wrapped_rows_distribute_remaining_width_inside_each_row() {
- let children = [
- (
- (8, 1),
- Layout {
- grow: 1,
- ..Layout::default()
- },
- ),
- ((8, 1), Layout::default()),
- (
- (6, 1),
- Layout {
- grow: 2,
- ..Layout::default()
- },
- ),
- ];
- let layout = flow(Direction::Row, 18, None, 1, true, &children);
- assert_eq!(
- layout.children,
- [
- CellRect::new(0, 0, 9, 1),
- CellRect::new(10, 0, 8, 1),
- CellRect::new(0, 2, 18, 1)
- ]
- );
- assert_eq!(layout.size, (18, 3));
- }
-}
diff --git a/platform/render-terminal/src/application/markdown_text.rs b/platform/render-terminal/src/application/markdown_text.rs
deleted file mode 100644
index 406d72e551..0000000000
--- a/platform/render-terminal/src/application/markdown_text.rs
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * 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.
- */
-
-//! The original TUI CopyMessage formatter, as a bounded, effect-free query.
-use maka_ui_runtime::{
- NativeCapability, NativeExport, NativeRegistry,
- actions::{NativeService, ServiceCompletion, ServiceFuture, ServiceRequest},
- markdown::{MarkdownTextInput, MarkdownTextResult},
-};
-use std::{collections::BTreeMap, sync::Arc};
-struct Service;
-fn plain(source: &str) -> Result<MarkdownTextResult, String> {
- let document = crate::markdown::prepared::Document::markdown(
- source,
- false,
- crate::markdown::Palette::default(),
- )
- .map_err(str::to_owned)?;
- Ok(MarkdownTextResult::Ready {
- text: document.text().to_owned(),
- })
-}
-impl NativeService for Service {
- fn call(&self, request: ServiceRequest) -> ServiceFuture {
- Box::pin(async move {
- let result = (|| {
- if request.method != "plain" {
- return Err("unknown Markdown text method".into());
- }
- let input: MarkdownTextInput =
- serde_json::from_value(request.input).map_err(|e| e.to_string())?;
- serde_json::to_value(plain(&input.source)?).map_err(|e| e.to_string())
- })();
- ServiceCompletion {
- result,
- cleanup: Ok(()),
- }
- })
- }
-}
-pub(super) fn register(registry: &mut NativeRegistry<dyn super::Widget>) {
- registry
- .register(
- &maka_ui_runtime::markdown::native_markdown_text().implementation,
- |_, _| {
- Ok(BTreeMap::from([(
- "MarkdownText".into(),
- NativeExport {
- contract: maka_ui_runtime::markdown::text_contract(),
- value: NativeCapability::Service(Arc::new(Service)),
- },
- )]))
- },
- )
- .expect("unique terminal Markdown text formatter");
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- #[test]
- fn copy_reuses_native_markdown_semantics_for_complete_utf8() {
- let source =
- "# Heading\n\n**bold** [link](https://example.org)\n\n```rs\nlet x = 1;\n```\n";
- let MarkdownTextResult::Ready { text } = plain(source).unwrap();
- assert!(text.contains("Heading"));
- assert!(text.contains("bold link"));
- assert!(text.contains("let x = 1;"));
- assert!(!text.contains("**"));
- assert!(text.contains("https://example.org"));
- assert!(matches!(
- plain(&"中".repeat(21846)).unwrap(),
- MarkdownTextResult::Ready { .. }
- ));
- assert!(matches!(
- plain(&"a".repeat(65536)).unwrap(),
- MarkdownTextResult::Ready { .. }
- ));
- }
-}
diff --git a/platform/render-terminal/src/application/morph_mark.rs b/platform/render-terminal/src/application/morph_mark.rs
deleted file mode 100644
index 7c07008716..0000000000
--- a/platform/render-terminal/src/application/morph_mark.rs
+++ /dev/null
@@ -1,164 +0,0 @@
-/*
- * 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.
- */
-
-//! Original welcome projection, with product-supplied geometry and appearance.
-use super::{NativeContext, Placement, Slots, TerminalLayout, Widget};
-use maka_ui_runtime::{
- ComponentCall, ComponentOutput, Frame, NativeCapability, NativeComponent, NativeExport,
- NativeRegistry, NativeView, morph_mark::*,
-};
-use ratatui::{buffer::Buffer, layout::Rect};
-use serde_json::json;
-use std::{any::Any, cell::RefCell, collections::BTreeMap, rc::Rc};
-mod geometry;
-mod render;
-struct Data {
- formations: Vec<geometry::Drone>,
- color: maka_ui_runtime::scene::terminal::Color,
- period: u32,
-}
-struct Mark {
- data: Rc<Data>,
- props: MorphMarkProps,
-}
-impl Widget for Mark {
- fn create(&self, _: NativeContext) -> Box<dyn Any> {
- Box::new(RefCell::new(render::Renderer::default()))
- }
- fn same_presentation(&self, other: &dyn Any) -> bool {
- other
- .downcast_ref::<Self>()
- .is_some_and(|other| Rc::ptr_eq(&self.data, &other.data) && self.props == other.props)
- }
- fn layout(&self, _: &Slots<'_>) -> maka_ui_runtime::Result<TerminalLayout> {
- Ok(TerminalLayout {
- grow: 1,
- ..Default::default()
- })
- }
- fn measure(&self, _: &Slots<'_>, width: u16) -> maka_ui_runtime::Result<(u16, u32)> {
- Ok((width, 0))
- }
- fn paint(
- &self,
- slots: &mut Slots<'_>,
- area: Placement,
- buffer: &mut Buffer,
- ) -> maka_ui_runtime::Result<()> {
- let visible = area.visible();
- let rows = visible
- .height
- .saturating_sub(4)
- .min(22)
- .min(visible.width.saturating_sub(4) / 3);
- if rows < 12 {
- return Ok(());
- }
- let stage = Rect::new(
- visible.x + (visible.width - rows * 3) / 2,
- visible.y + (visible.height - rows) / 2,
- rows * 3,
- rows,
- );
- let phase = self
- .props
- .motion
- .then(|| slots.animation_phase(self.data.period))
- .flatten();
- slots
- .native_state::<RefCell<render::Renderer>>()?
- .borrow_mut()
- .draw(
- stage,
- buffer,
- [
- super::paint::color(self.props.background),
- super::paint::color(self.props.foreground),
- super::paint::color(self.data.color),
- ],
- &self.data.formations,
- self.props.ascii,
- phase,
- );
- Ok(())
- }
-}
-struct Component {
- data: Rc<Data>,
-}
-impl NativeComponent<dyn Widget> for Component {
- fn call(
- &self,
- call: ComponentCall<'_>,
- _: &maka_ui_runtime::abi::ValueBindings,
- ) -> maka_ui_runtime::Result<ComponentOutput<NativeView<dyn Widget>>> {
- let (props, revision) = match call {
- ComponentCall::Mount { props } => (props, 0),
- ComponentCall::Update { state, props } => {
- (props, state["revision"].as_u64().unwrap() + 1)
- }
- ComponentCall::Event { .. } => {
- return Err(maka_ui_runtime::Error::Invalid(
- "morphing mark has no inputs".into(),
- ));
- }
- };
- let props: MorphMarkProps = serde_json::from_value(props.clone())
- .map_err(|e| maka_ui_runtime::Error::Invalid(e.to_string()))?;
- Ok(Frame {
- revision,
- state: json!({"revision": revision}),
- surface: None,
- view: NativeView::<dyn Widget>::new(
- Rc::new(Mark {
- data: self.data.clone(),
- props,
- }),
- BTreeMap::new(),
- vec![],
- )?,
- }
- .output(vec![]))
- }
-}
-pub(super) fn register(registry: &mut NativeRegistry<dyn Widget>) {
- registry
- .register(
- maka_ui_runtime::morph_mark::IMPLEMENTATION,
- |configuration, _| {
- let configuration: MorphMarkConfiguration =
- serde_json::from_value(configuration.clone())
- .map_err(|e| maka_ui_runtime::Error::Invalid(e.to_string()))?;
- configuration.validate()?;
- let data = Rc::new(Data {
- formations: geometry::formations(&configuration.shapes),
- color: configuration.color,
- period: configuration.period,
- });
- Ok(BTreeMap::from([(
- "MorphMark".into(),
- NativeExport {
- contract: contract(),
- value: NativeCapability::Component(Rc::new(Component { data })),
- },
- )]))
- },
- )
- .expect("unique morphing mark factory");
-}
diff --git a/platform/render-terminal/src/application/morph_mark/geometry.rs b/platform/render-terminal/src/application/morph_mark/geometry.rs
deleted file mode 100644
index 08111da530..0000000000
--- a/platform/render-terminal/src/application/morph_mark/geometry.rs
+++ /dev/null
@@ -1,207 +0,0 @@
-/*
- * 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.
- */
-
-//! Surface sampling and spatial pairing copied from the original welcome mark.
-use maka_ui_runtime::morph_mark::MorphShape;
-#[derive(Clone, Copy)]
-pub(super) struct Point {
- pub position: [f32; 3],
- pub normal: [f32; 3],
-}
-pub(super) struct Drone {
- pub logo: Point,
- pub word: Point,
-}
-const STEP: f32 = 2.2 / 143.0;
-const DEPTH: f32 = 0.085;
-const BEVEL: f32 = 0.035;
-pub(super) fn formations(shapes: &[MorphShape; 2]) -> Vec<Drone> {
- let prepare = |shape: &MorphShape| {
- let paths: Vec<_> = shape.paths.iter().map(Vec::as_slice).collect();
- let miters: Vec<_> = shape
- .miters
- .iter()
- .map(|i| miter(paths[*i], shape.radius))
- .collect();
- surface(&paths, shape.radius, &miters, shape.columns)
- };
- let mut logo = prepare(&shapes[0]);
- let mut word = prepare(&shapes[1]);
- if logo.is_empty() || word.is_empty() {
- return vec![];
- }
- // Pair nearby groups in spatial order, so neighboring dots travel together
- // rather than randomly crossing the entire formation during the transition.
- let order = |points: &mut Vec<Point>, extent: f32| {
- let spread = |mut value: u32| {
- value = (value | value << 8) & 0x00ff00ff;
- value = (value | value << 4) & 0x0f0f0f0f;
- value = (value | value << 2) & 0x33333333;
- (value | value << 1) & 0x55555555
- };
- points.sort_by_key(|point| {
- let x = ((point.position[0] / extent + 1.0) * 32767.0).clamp(0.0, 65535.0) as u32;
- let y = ((point.position[1] + 1.1) / 2.2 * 65535.0).clamp(0.0, 65535.0) as u32;
- spread(x) | spread(y) << 1
- });
- };
- order(&mut logo, shapes[0].columns as f32 * STEP / 2.0);
- order(&mut word, shapes[1].columns as f32 * STEP / 2.0);
- let count = logo.len().max(word.len());
- (0..count)
- .map(|i| Drone {
- logo: logo[i * (logo.len() - 1) / (count - 1)],
- word: word[i * (word.len() - 1) / (count - 1)],
- })
- .collect()
-}
-fn segment(point: [f32; 2], a: [f32; 2], b: [f32; 2]) -> f32 {
- let [dx, dy] = [b[0] - a[0], b[1] - a[1]];
- let t =
- (((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
- (point[0] - a[0] - dx * t).hypot(point[1] - a[1] - dy * t)
-}
-
-fn miter(path: &[[f32; 2]], radius: f32) -> [[f32; 2]; 3] {
- let [a, b, c] = [path[0], path[1], path[2]];
- let normal = |from: [f32; 2], to: [f32; 2]| {
- let [x, y] = [to[0] - from[0], to[1] - from[1]];
- let length = x.hypot(y);
- [y / length, -x / length]
- };
- let mut n = [normal(a, b), normal(b, c)];
- if n[0][1] + n[1][1] > 0.0 {
- n = n.map(|v| v.map(|v| -v));
- }
- let sum = [n[0][0] + n[1][0], n[0][1] + n[1][1]];
- let denominator = sum[0] * n[0][0] + sum[1] * n[0][1];
- if denominator.abs() < 0.001 {
- return [b; 3];
- }
- let scale = radius / denominator;
- [
- [b[0] + n[0][0] * radius, b[1] + n[0][1] * radius],
- [b[0] + sum[0] * scale, b[1] + sum[1] * scale],
- [b[0] + n[1][0] * radius, b[1] + n[1][1] * radius],
- ]
-}
-
-fn triangle(point: [f32; 2], vertices: [[f32; 2]; 3]) -> f32 {
- let mut distance = f32::INFINITY;
- let mut signs = [0.0; 3];
- for i in 0..3 {
- let [a, b] = [vertices[i], vertices[(i + 1) % 3]];
- distance = distance.min(segment(point, a, b));
- signs[i] = (b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (point[0] - a[0]);
- }
- if signs.iter().all(|v| *v >= 0.0) || signs.iter().all(|v| *v <= 0.0) {
- distance
- } else {
- -distance
- }
-}
-
-fn surface(
- paths: &[&[[f32; 2]]],
- radius: f32,
- miters: &[[[f32; 2]; 3]],
- columns: usize,
-) -> Vec<Point> {
- let x0 = (columns - 1) as f32 * STEP / 2.0;
- let mut field = vec![-1.0_f32; 144 * columns];
- // Only each stroke's small neighborhood can contribute to the surface.
- // This avoids scanning every curve for every pixel on the first UI frame.
- let mut paint = |vertices: &[[f32; 2]], padding: f32, distance: &dyn Fn([f32; 2]) -> f32| {
- let bounds = vertices.iter().fold(
- [
- f32::INFINITY,
- f32::INFINITY,
- f32::NEG_INFINITY,
- f32::NEG_INFINITY,
- ],
- |[l, t, r, b], [x, y]| [l.min(*x), t.min(*y), r.max(*x), b.max(*y)],
- );
- let left = ((bounds[0] - padding + x0) / STEP).floor().max(0.0) as usize;
- let right = ((bounds[2] + padding + x0) / STEP)
- .ceil()
- .min((columns - 1) as f32) as usize;
- let top = ((bounds[1] - padding + 1.1) / STEP).floor().max(0.0) as usize;
- let bottom = ((bounds[3] + padding + 1.1) / STEP).ceil().min(143.0) as usize;
- for row in top..=bottom {
- for column in left..=right {
- let i = row * columns + column;
- field[i] = field[i].max(distance([
- column as f32 * STEP - x0,
- row as f32 * STEP - 1.1,
- ]));
- }
- }
- };
- for path in paths {
- paint(path, radius + STEP * 3.0, &|point| {
- path.windows(2)
- .map(|pair| radius - segment(point, pair[0], pair[1]))
- .fold(f32::NEG_INFINITY, f32::max)
- });
- }
- for vertices in miters {
- paint(vertices, STEP * 3.0, &|point| triangle(point, *vertices));
- }
- let mut points = Vec::new();
- for row in 1..143 {
- for column in 1..columns - 1 {
- let [x, y] = [column as f32 * STEP - x0, row as f32 * STEP - 1.1];
- let i = row * columns + column;
- let d = field[i];
- if d < -STEP {
- continue;
- }
- let [dx, dy] = [
- field[i + 1] - field[i - 1],
- field[i + columns] - field[i - columns],
- ];
- let length = dx.hypot(dy).max(f32::EPSILON);
- let [nx, ny] = [-dx / length, -dy / length];
- if d >= 0.0 {
- let edge = (1.0 - d / BEVEL).clamp(0.0, 1.0);
- let nz = (1.0 - edge * edge).sqrt();
- let z = DEPTH - BEVEL + BEVEL * nz;
- for sign in [-1.0, 1.0] {
- points.push(Point {
- position: [x, y, sign * z],
- normal: [nx * edge, ny * edge, sign * nz],
- });
- }
- }
- if d.abs() < STEP * 0.6 {
- for layer in 0..=8 {
- points.push(Point {
- position: [
- x + nx * d,
- y + ny * d,
- (DEPTH - BEVEL) * (layer as f32 / 4.0 - 1.0),
- ],
- normal: [nx, ny, 0.0],
- });
- }
- }
- }
- }
- points
-}
diff --git a/platform/render-terminal/src/application/morph_mark/render.rs b/platform/render-terminal/src/application/morph_mark/render.rs
deleted file mode 100644
index e2f71182f1..0000000000
--- a/platform/render-terminal/src/application/morph_mark/render.rs
+++ /dev/null
@@ -1,140 +0,0 @@
-/*
- * 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.
- */
-
-//! Bounded depth projection into ordinary terminal Braille cells; no image protocol.
-use super::geometry::Drone;
-use ratatui::{
- buffer::Buffer,
- layout::Rect,
- style::{Color, Modifier},
-};
-
-#[derive(Default)]
-pub(super) struct Renderer {
- dots: Vec<(f32, [f32; 3])>,
-}
-
-impl Renderer {
- pub(super) fn draw(
- &mut self,
- area: Rect,
- buffer: &mut Buffer,
- [background, foreground, tint]: [Color; 3],
- formations: &[Drone],
- ascii: bool,
- phase: Option<f32>,
- ) {
- let width = usize::from(area.width) * 2;
- let height = usize::from(area.height) * 4;
- self.dots
- .resize(width * height, (f32::NEG_INFINITY, [0.0; 3]));
- self.dots.fill((f32::NEG_INFINITY, [0.0; 3]));
- let phase_value = phase.unwrap_or(0.0);
- let smooth = |t: f32| {
- let t = t.clamp(0.0, 1.0);
- t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
- };
- let blend =
- smooth((phase_value - 0.12) / 0.24) * (1.0 - smooth((phase_value - 0.62) / 0.24));
- // Keep both poses legible. The formation rocks gently while the dots
- // travel; it never turns the word edge-on or backwards.
- let angle = (std::f32::consts::TAU * phase_value).sin() * 0.18;
- let (sy, cy) = angle.sin_cos();
- let (sx, cx) = (angle.sin() * 0.16).sin_cos();
- let (sz, cz) = (angle.sin() * -0.055).sin_cos();
- let rotate = |[x, y, z]: [f32; 3]| {
- let [x, z] = [x * cy + z * sy, z * cy - x * sy];
- let [y, z] = [y * cx - z * sx, z * cx + y * sx];
- [x * cz - y * sz, y * cz + x * sz, z]
- };
- // Product color; named terminal colors retain terminal rendering.
- let blue = match tint {
- Color::Rgb(r, g, b) => [r, g, b],
- _ => [0x80, 0x80, 0x80],
- };
- let rgb = match (background, foreground) {
- (Color::Rgb(r, g, b), Color::Rgb(fr, fg, fb)) => Some(([r, g, b], [fr, fg, fb])),
- _ => None,
- };
- let opacity = if phase.is_some() { 0.44 } else { 0.24 };
- for drone in formations.iter() {
- let interpolate =
- |a: [f32; 3], b: [f32; 3]| std::array::from_fn(|i| a[i] + (b[i] - a[i]) * blend);
- let [x, y, z] = rotate(interpolate(drone.logo.position, drone.word.position));
- let perspective = 4.5 / (4.5 - z);
- let scale = height as f32 / 2.5;
- let column = (width as f32 / 2.0 + x * perspective * scale) as isize;
- let row = (height as f32 / 2.0 + y * perspective * scale) as isize;
- if column < 0 || row < 0 || column >= width as isize || row >= height as isize {
- continue;
- }
- let dot = &mut self.dots[row as usize * width + column as usize];
- if z <= dot.0 {
- continue;
- }
- let [nx, ny, nz] = rotate(interpolate(drone.logo.normal, drone.word.normal));
- let length = nx.hypot(ny).hypot(nz).max(f32::EPSILON);
- let [nx, ny, nz] = [nx / length, ny / length, nz / length];
- let diffuse = (-nx * 0.35 - ny * 0.45 + nz * 0.82).max(0.0);
- let gloss = (-nx * 0.18 - ny * 0.25 + nz * 0.95).max(0.0).powi(14) * 0.25;
- let shade = if let Some((bg, fg)) = rgb {
- std::array::from_fn(|i| {
- let lit = f32::from(blue[i]) * (0.45 + 0.55 * diffuse);
- let lit = lit + (f32::from(fg[i]) - lit) * gloss;
- f32::from(bg[i]) + (lit - f32::from(bg[i])) * opacity
- })
- } else {
- [0.0; 3]
- };
- *dot = (z, shade);
- }
- const BITS: [u32; 8] = [1, 8, 2, 16, 4, 32, 64, 128];
- for y in 0..usize::from(area.height) {
- for x in 0..usize::from(area.width) {
- let (mut bits, mut count, mut color) = (0, 0, [0.0; 3]);
- for (point, bit) in BITS.iter().enumerate() {
- let dot = &self.dots[(y * 4 + point / 2) * width + x * 2 + point % 2];
- if dot.0.is_finite() {
- bits |= bit;
- count += 1;
- for (sum, channel) in color.iter_mut().zip(dot.1) {
- *sum += channel;
- }
- }
- }
- if count == 0 {
- continue;
- }
- let cell = &mut buffer[(area.x + x as u16, area.y + y as u16)];
- cell.set_char(if ascii {
- if count > 4 { '*' } else { '.' }
- } else {
- char::from_u32(0x2800 + bits).unwrap()
- });
- if rgb.is_some() {
- let [r, g, b] = color.map(|v| (v / count as f32).round() as u8);
- cell.set_fg(Color::Rgb(r, g, b));
- } else {
- cell.set_fg(tint)
- .set_style(ratatui::style::Style::default().add_modifier(Modifier::DIM));
- }
- }
- }
- }
-}
diff --git a/platform/render-terminal/src/application/mutations.rs b/platform/render-terminal/src/application/mutations.rs
deleted file mode 100644
index 3c9b3054b8..0000000000
--- a/platform/render-terminal/src/application/mutations.rs
+++ /dev/null
@@ -1,231 +0,0 @@
-/*
- * 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.
- */
-
-//! Presenter close successors; actual Host effects are tested at the Host boundary.
-use super::*;
-use maka_event_log::{
- EventLog,
- applications::{intents::RecordedIntent, operations::OperationRequest},
- root::{RootNamespaces, RootOwner},
-};
-use maka_plugin_core::{
- composition::InstanceId,
- contract::{Contract, Interface, Method, MethodMode, Transport},
-};
-use maka_ui_runtime::{
- NativeCapability, NativeExport,
- actions::{
- CleanupFuture, MutationFuture, NativeMutation, NativeService, ReferenceFuture,
- ServiceFuture, ServiceRequest,
- },
-};
-use std::{
- collections::BTreeMap,
- sync::{Arc, Mutex},
- time::{Duration, Instant},
-};
-struct Owner {
- request: OperationRequest,
- prepare: Arc<tokio::sync::Semaphore>,
- close: Arc<tokio::sync::Semaphore>,
- closing: Arc<tokio::sync::Semaphore>,
- payload: Mutex<Option<Arc<()>>>,
-}
-impl NativeMutation for Owner {
- fn prepare(self: Arc<Self>) -> ReferenceFuture {
- Box::pin(async move {
- self.prepare.acquire().await.unwrap().forget();
- self.request.reference().map_err(|e| e.to_string())
- })
- }
- fn dispatch(
- self: Arc<Self>,
- _: RecordedIntent,
- _progress: tokio::sync::mpsc::Sender<serde_json::Value>,
- ) -> MutationFuture {
- Box::pin(async { panic!("retired terminal must not dispatch a mutation") })
- }
- fn cancel(&self) {
- self.prepare.add_permits(1);
- }
- fn close(self: Arc<Self>) -> CleanupFuture {
- Box::pin(async move {
- self.closing.add_permits(1);
- self.close.acquire().await.unwrap().forget();
- self.payload.lock().unwrap().take();
- Ok(())
- })
- }
-}
-struct Service {
- contract: Contract,
- prepare: Arc<tokio::sync::Semaphore>,
- close: Arc<tokio::sync::Semaphore>,
- closing: Arc<tokio::sync::Semaphore>,
- payload: std::sync::mpsc::Sender<std::sync::Weak<()>>,
-}
-impl NativeService for Service {
- fn call(&self, _: ServiceRequest) -> ServiceFuture {
- Box::pin(async { panic!("query is not exported") })
- }
- fn mutation(&self, request: ServiceRequest) -> Result<Arc<dyn NativeMutation>, String> {
- let payload = Arc::new(());
- self.payload.send(Arc::downgrade(&payload)).unwrap();
- let original = OperationRequest {
- source: request.source.deployment().unwrap().clone(),
- namespace: InstanceId(vec![Uuid::from_u128(99)]),
- consumer: request.source.consumer().instance().clone(),
- export: "Service".into(),
- method: request.method,
- contract: self.contract.clone(),
- operation: request.source.request_id(),
- input: request.input,
- capture: None,
- origin: maka_capabilities::call::Identity::Remote {
- request_id: request.source.request_id(),
- },
- };
- Ok(Arc::new(Owner {
- request: original,
- prepare: self.prepare.clone(),
- close: self.close.clone(),
- closing: self.closing.clone(),
- payload: Mutex::new(Some(payload)),
- }))
- }
-}
-#[test]
-fn terminal_restoration_during_preparation_runs_close_successor_before_root_release() {
- let contract = Contract {
- name: "test.Service".into(),
- version: 1,
- transport: Transport::Local,
- interface: Interface::Service {
- methods: BTreeMap::from([(
- "run".into(),
- Method {
- progress: None,
- mode: MethodMode::Mutation,
- input: json!({}).into(),
- output: json!({}).into(),
- resources: BTreeMap::new(),
- },
- )]),
- },
- };
- let (runtime, input, packages) = service_fixture(
- "const out=frame({count:state.count+1,revision:state.revision+1});if(event.kind==='raw')out.actions.push({key:'finished',binding:'service',member:0,method:'run',input:{},resources:{}});return out;",
- &contract,
- );
- let dir = tempfile::tempdir().unwrap();
- let root = dir.path().join("root");
- let ns = RootNamespaces {
- ownership: dir.path().join("owners"),
- control: dir.path().join("control"),
- };
- let store = Arc::new(
- runtime
- .block_on(EventLog::for_root(Arc::new(
- RootOwner::create(&root, &ns).unwrap(),
- )))
- .unwrap(),
- );
- let deployment = Uuid::new_v4();
- let source = runtime
- .block_on(store.commit_application(deployment, 0, input, vec![], packages))
- .unwrap();
- let weak_store = Arc::downgrade(&store);
- let prepare = Arc::new(tokio::sync::Semaphore::new(0));
- let close = Arc::new(tokio::sync::Semaphore::new(0));
- let closing = Arc::new(tokio::sync::Semaphore::new(0));
- let (tx, rx) = std::sync::mpsc::channel();
- let service = Arc::new(Service {
- contract: contract.clone(),
- prepare: prepare.clone(),
- close: close.clone(),
- closing: closing.clone(),
- payload: tx,
- });
- let mut native = registry();
- native
- .register("test.service", move |_, _values| {
- Ok(BTreeMap::from([(
- "Service".into(),
- NativeExport {
- contract: contract.clone(),
- value: NativeCapability::Service(service.clone()),
- },
- )]))
- })
- .unwrap();
- let mut tui = runtime
- .block_on(Presenter::start_from_store(
- store,
- source.clone(),
- runtime.handle().clone(),
- native,
- json!({}),
- vec![],
- maka_ui_js::execution(Default::default()),
- ))
- .unwrap();
- draw(&mut tui, 30, 12);
- tui.input_settled(key(KeyCode::Enter)).unwrap();
- tui.pump_settled().unwrap();
- let payload = rx.recv_timeout(Duration::from_secs(3)).unwrap();
- assert_eq!(tui.application.accepted_action_count(), 0);
- tui.surface_closed(&tui.surface(), Ok(())).unwrap();
- assert!(!tui.can_shutdown());
- assert!(payload.upgrade().is_some());
- assert!(RootOwner::open(&root, &ns).is_err());
- prepare.add_permits(1);
- let deadline = Instant::now() + Duration::from_secs(5);
- while closing.available_permits() == 0 {
- tui.pump_settled().unwrap();
- assert!(Instant::now() < deadline);
- std::thread::yield_now();
- }
- assert!(!tui.can_shutdown());
- assert!(payload.upgrade().is_some());
- assert!(RootOwner::open(&root, &ns).is_err());
- let caller = weak_store.upgrade().unwrap();
- let intents = runtime
- .block_on(caller.application_intents(
- source.deployment,
- InstanceId(vec![Uuid::from_u128(2)]),
- 0,
- 128,
- ))
- .unwrap();
- assert_eq!(intents.len(), 1);
- assert_eq!(intents[0].caller, source);
- runtime.block_on(caller.shutdown()).unwrap();
- drop(caller);
- close.add_permits(1);
- while !tui.jobs.is_empty() {
- tui.pump_settled().unwrap();
- assert!(Instant::now() < deadline);
- std::thread::yield_now();
- }
- assert!(payload.upgrade().is_none());
- assert!(tui.can_shutdown());
- tui.shutdown_settled().unwrap();
- assert!(weak_store.upgrade().is_none());
- assert!(RootOwner::open(&root, &ns).is_ok());
-}
diff --git a/platform/render-terminal/src/application/native.rs b/platform/render-terminal/src/application/native.rs
deleted file mode 100644
index 2d34d36ecd..0000000000
--- a/platform/render-terminal/src/application/native.rs
+++ /dev/null
@@ -1,138 +0,0 @@
-/*
- * 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::{Slots, Widget, paint};
-use maka_ui_runtime::{
- ComponentCall, ComponentOutput, Frame, LabelProps, NativeCapability, NativeComponent,
- NativeExport, NativeRegistry, NativeView, label_contract,
-};
-use ratatui::{
- buffer::Buffer,
- style::{Color, Modifier, Style},
-};
-use serde_json::json;
-use std::{collections::BTreeMap, rc::Rc};
-struct Label {
- props: LabelProps,
-}
-impl Widget for Label {
- fn measure(&self, _: &Slots<'_>, width: u16) -> maka_ui_runtime::Result<(u16, u32)> {
- Ok(paint::text_size(&self.props.rendered(), width))
- }
- fn paint(
- &self,
- slots: &mut Slots<'_>,
- area: super::Placement,
- buffer: &mut Buffer,
- ) -> maka_ui_runtime::Result<()> {
- if self
- .props
- .elapsed
- .as_ref()
- .is_some_and(|elapsed| elapsed.to.is_none())
- && !area.is_empty()
- {
- slots
- .measurements
- .repaint_after(std::time::Duration::from_secs(1));
- }
- let rgba = self.props.appearance.foreground.0;
- let foreground = self
- .props
- .terminal_foreground
- .map(paint::color)
- .unwrap_or(Color::Rgb(
- (rgba >> 24) as u8,
- (rgba >> 16) as u8,
- (rgba >> 8) as u8,
- ));
- let mut style = Style::default().fg(foreground);
- if self.props.appearance.font.weight >= 600 {
- style = style.add_modifier(Modifier::BOLD);
- }
- paint::text(&self.props.rendered(), area, buffer, style);
- Ok(())
- }
-}
-struct LabelComponent;
-impl NativeComponent<dyn Widget> for LabelComponent {
- fn call(
- &self,
- call: ComponentCall<'_>,
- _values: &maka_ui_runtime::abi::ValueBindings,
- ) -> maka_ui_runtime::Result<ComponentOutput<NativeView<dyn Widget>>> {
- let (props, revision) = match call {
- ComponentCall::Mount { props } => (props, 0),
- ComponentCall::Update { state, props } => (
- props,
- state["revision"].as_u64().expect("native label revision") + 1,
- ),
- ComponentCall::Event { .. } => {
- return Err(maka_ui_runtime::Error::Invalid(
- "label has no inputs".into(),
- ));
- }
- };
- let props: LabelProps = serde_json::from_value(props.clone())
- .map_err(|e| maka_ui_runtime::Error::Invalid(e.to_string()))?;
- props.validate()?;
- Ok(Frame {
- revision,
- state: json!({"revision":revision}),
- surface: None,
- view: NativeView::<dyn Widget>::new(Rc::new(Label { props }), BTreeMap::new(), vec![])?,
- }
- .output(Vec::new()))
- }
-}
-/// Available compiled implementations; a composition still chooses each instance.
-/// 编译时提供实现,实例及绑定仍由产品 composition 决定。
-pub fn registry() -> NativeRegistry<dyn Widget> {
- let mut registry =
- NativeRegistry::with_surface(maka_plugin_core::composition::PresentationTarget::Tui)
- .with_scene_compiler(
- super::scene::language(),
- super::scene::prepare,
- super::scene::finish,
- )
- .expect("valid terminal scene language");
- registry
- .register(maka_ui_runtime::LABEL_IMPLEMENTATION, |_, _values| {
- Ok(BTreeMap::from([(
- "Label".into(),
- NativeExport {
- contract: label_contract(),
- value: NativeCapability::Component(Rc::new(LabelComponent)),
- },
- )]))
- })
- .expect("unique label factory");
- super::terminal::register(&mut registry);
- super::rows::register(&mut registry);
- super::checkpoint::register(&mut registry);
- super::text_file::register(&mut registry);
- super::morph_mark::register(&mut registry);
- super::textarea::register(&mut registry);
- super::input::register(&mut registry);
- super::file::register(&mut registry);
- super::source::register(&mut registry);
- super::markdown_text::register(&mut registry);
- super::reading::register(&mut registry);
- registry
-}
diff --git a/platform/render-terminal/src/application/paint.rs b/platform/render-terminal/src/application/paint.rs
deleted file mode 100644
index d246ed96b5..0000000000
--- a/platform/render-terminal/src/application/paint.rs
+++ /dev/null
@@ -1,2363 +0,0 @@
-/*
- * 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::scene::{Direction, Node};
-use super::{CellRect, Placement, Presenter};
-use maka_plugin_core::runtime::Mount;
-use ratatui::{
- buffer::Buffer,
- layout::{Position, Rect, Size},
- style::{Color, Modifier, Style},
-};
-use unicode_segmentation::UnicodeSegmentation;
-use unicode_width::UnicodeWidthStr;
-
-/// Remove terminal controls before measuring, painting or emitting diagnostics.
-/// 换行保留为布局数据;其他终端控制字符不会进入输出流。
-pub fn sanitize(text: &str) -> String {
- let mut output = String::with_capacity(text.len());
- for c in text.chars() {
- match c {
- '\t' => output.push_str(" "),
- '\n' => output.push(c),
- c if !c.is_control() => output.push(c),
- _ => {}
- }
- }
- output
-}
-fn lines(text: &str, width: u16) -> Vec<String> {
- if width == 0 {
- return Vec::new();
- }
- let text = sanitize(text);
- let mut result = Vec::new();
- for source in text.split('\n') {
- let mut line = String::new();
- let mut cells = 0;
- let mut start = 0;
- for (end, _) in unicode_linebreak::linebreaks(source) {
- let word = &source[start..end];
- start = end;
- let word_width = word.trim_end().width();
- // Use line boundaries rather than word boundaries so punctuation
- // stays with its word. Oversized tokens still break at graphemes.
- if cells > 0
- && word_width <= usize::from(width)
- && cells + word_width > usize::from(width)
- {
- result.push(std::mem::take(&mut line));
- cells = 0;
- }
- for grapheme in word.graphemes(true) {
- let size = grapheme.width();
- if size > usize::from(width) {
- continue;
- }
- if cells + size > usize::from(width) {
- result.push(std::mem::take(&mut line));
- cells = 0;
- if grapheme.chars().all(char::is_whitespace) {
- continue;
- }
- }
- line.push_str(grapheme);
- cells += size;
- }
- }
- result.push(line);
- }
- result
-}
-pub(super) fn text_size(text: &str, width: u16) -> (u16, u32) {
- let lines = lines(text, width);
- (
- lines
- .iter()
- .map(|s| s.width().min(usize::from(width)) as u16)
- .max()
- .unwrap_or(0),
- lines.len().min(u32::MAX as usize) as u32,
- )
-}
-/// Original session::fit cell/grapheme elision, over one sanitized line.
-pub(super) fn truncated_line(text: &str, width: u16) -> String {
- if width == 0 {
- return String::new();
- }
- let text = sanitize(text).replace('\n', " ");
- let width = usize::from(width);
- if text.width() <= width {
- return text;
- }
- let mut result = String::new();
- let mut cells = 0;
- for grapheme in text.graphemes(true) {
- cells += grapheme.width();
- if cells + 1 > width {
- break;
- }
- result.push_str(grapheme);
- }
- result.push('…');
- result
-}
-pub(super) fn text(text: &str, area: Placement, buffer: &mut Buffer, style: Style) {
- let area = area.clipped(buffer.area);
- let visible = area.visible();
- let first = (i64::from(visible.y) - area.y).max(0) as usize;
- for (row, line) in lines(text, area.width)
- .into_iter()
- .take(area.height as usize)
- .enumerate()
- .skip(first)
- .take(usize::from(visible.height))
- {
- let mut column = 0;
- for grapheme in line.graphemes(true) {
- let width = grapheme.width();
- if let Some(point) = area.point(column, row)
- && (width == 0 || area.point(column + width - 1, row).is_some())
- {
- buffer.set_stringn(point.x, point.y, grapheme, width.max(1), style);
- }
- column += width;
- }
- }
-}
-fn paint_button(
- label: &str,
- area: Placement,
- buffer: &mut Buffer,
- style: Style,
- align: Option<maka_ui_runtime::scene::terminal::Align>,
-) {
- let area = area.clipped(buffer.area);
- buffer.set_style(area.visible(), style.remove_modifier(Modifier::UNDERLINED));
- let spare = area.width.saturating_sub(label.width() as u16);
- let offset = match align.unwrap_or(maka_ui_runtime::scene::terminal::Align::Center) {
- maka_ui_runtime::scene::terminal::Align::Start => 0,
- maka_ui_runtime::scene::terminal::Align::Center => spare / 2,
- maka_ui_runtime::scene::terminal::Align::End => spare,
- };
- text(
- label,
- Placement::new(
- CellRect::new(area.x + i64::from(offset), area.y, area.width - offset, 1),
- area.clip,
- ),
- buffer,
- style,
- );
-}
-#[derive(Clone)]
-pub(super) enum TargetKind {
- Hint,
- /// Keep an existing focus anchor while a button awaits an operation result.
- Disabled,
- SelectionCopy {
- text: std::rc::Rc<str>,
- },
- ContextMenu {
- button: bool,
- },
- Action {
- copy_text: Option<std::rc::Rc<str>>,
- },
- Item {
- current: bool,
- follow_focus: bool,
- submit: bool,
- submit_space: bool,
- reading_child: Option<String>,
- },
- Focus(FocusChild),
- Native,
- Scroll,
- Backdrop {
- body: Placement,
- },
-}
-#[derive(Clone)]
-pub(super) struct TargetIdentity {
- pub mount: Mount,
- pub key: String,
- pub owner: std::rc::Rc<()>,
- pub scope: Option<super::dialog::Scope>,
-}
-/// Original successfully painted ancestry, including across component slots.
-#[derive(Clone)]
-pub(super) struct Ancestor {
- pub identity: TargetIdentity,
- pub revision: u64,
- pub shortcuts: Vec<maka_ui_runtime::shortcut::Shortcut>,
- pub focus_group: Option<maka_ui_runtime::scene::terminal::Direction>,
- pub scroll: Option<super::scroll::Viewport>,
- pub focus_shortcuts: Vec<FocusBinding>,
-}
-#[derive(Clone)]
-pub(super) struct FocusBinding {
- pub declaration: maka_ui_runtime::shortcut::FocusShortcut,
- pub target: FocusChild,
-}
-#[derive(Clone)]
-pub(super) struct FocusChild {
- pub child: Mount,
- pub owner: std::rc::Weak<()>,
-}
-impl std::ops::Deref for Ancestor {
- type Target = TargetIdentity;
- fn deref(&self) -> &TargetIdentity {
- &self.identity
- }
-}
-#[derive(Clone)]
-pub(super) struct Target {
- pub identity: TargetIdentity,
- pub revision: u64,
- pub area: Placement,
- pub tab_index: i16,
- pub autofocus: bool,
- pub kind: TargetKind,
- pub ancestors: Vec<std::rc::Rc<Ancestor>>,
-}
-impl std::ops::Deref for Target {
- type Target = TargetIdentity;
- fn deref(&self) -> &TargetIdentity {
- &self.identity
- }
-}
-impl Target {
- pub(super) fn keyboard_reachable(&self) -> bool {
- if !self.area.is_empty() {
- return true;
- }
- let mut bounds = self.area.bounds;
- let mut revealed = None;
- for viewport in self
- .ancestors
- .iter()
- .rev()
- .filter_map(|ancestor| ancestor.scroll.as_ref())
- {
- if viewport.area.width == 0 || viewport.area.height == 0 {
- return false;
- }
- viewport.reveal_offset(&mut bounds);
- revealed = Some(Placement::new(bounds, viewport.area.visible()));
- }
- revealed.is_some_and(|area| !area.is_empty())
- }
- pub fn role(&self) -> (std::mem::Discriminant<TargetKind>, bool) {
- (
- std::mem::discriminant(&self.kind),
- matches!(self.kind, TargetKind::ContextMenu { button: true }),
- )
- }
- pub fn same(&self, other: &Self) -> bool {
- self.mount == other.mount
- && self.key == other.key
- && std::rc::Rc::ptr_eq(&self.owner, &other.owner)
- && self.role() == other.role()
- }
-}
-/// A pending scene is not input geometry until the embedding confirms a flush.
-/// 绘制成功并完成实际 flush 后,才发布对应的命中区域。
-pub struct PaintedScene {
- cursor: Option<(Option<super::dialog::Scope>, Position)>,
- next_frame: Option<std::time::Instant>,
- tooltip: Option<(Option<super::dialog::Scope>, Placement, String, Style)>,
- pub(super) viewport: Rect,
- pub(super) editor_menu: Option<std::rc::Weak<()>>,
- pub(super) targets: Vec<Target>,
- pub(super) keyboard: Option<std::rc::Rc<Ancestor>>,
- focus_offers: Vec<(TargetIdentity, Target)>,
- placed: std::collections::BTreeSet<uuid::Uuid>,
- laid_out: std::collections::BTreeMap<uuid::Uuid, PaintedMount>,
- dependencies: std::collections::BTreeMap<uuid::Uuid, PaintedMount>,
- pub(super) scope: Option<super::selection::ScopePaint>,
- pub(super) geometries: Vec<NativeGeometry>,
- pub(super) ancestry: Vec<std::rc::Rc<Ancestor>>,
- pub(super) input_scope: Option<super::dialog::Scope>,
- pub(super) dialogs: std::collections::VecDeque<super::dialog::Deferred>,
- openings: Vec<super::dialog::Scope>,
- pub(super) input_path: Vec<super::dialog::Scope>,
- pub(super) foreground_path: Vec<super::dialog::Scope>,
-}
-
-impl PaintedScene {
- /// The focused editor's physical cursor, used by terminal IME positioning.
- pub fn cursor_position(&self) -> Option<Position> {
- self.cursor
- .as_ref()
- .filter(|(scope, _)| {
- self.editor_menu.is_none()
- && super::dialog::same_scope(scope, &self.foreground_path.last().cloned())
- })
- .map(|(_, position)| *position)
- }
-}
-
-struct PaintedMount {
- mount: Mount,
- revision: u64,
- view: maka_ui_runtime::NativeView<dyn super::Widget>,
- owner: std::rc::Weak<()>,
-}
-
-#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
-enum MeasurementKey {
- Mount,
- Node(usize),
-}
-type MeasurementCache =
- std::collections::BTreeMap<(uuid::Uuid, MeasurementKey, u16, (u16, u16)), (u16, u32)>;
-#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
-struct SizeConstraints {
- width: u16,
- viewport: (u16, u16),
-}
-
-#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
-struct FlowConstraints {
- width: u16,
- // Responsive declarations are selected before reserving the scrollbar.
- responsive_width: u16,
- height: Option<u32>,
- viewport: (u16, u16),
-}
-
-struct RetainedQuery<T> {
- value: T,
- used: bool,
-}
-
-/// Relative, immutable cell geometry only. Paint, targets and native admission
-/// are still rebuilt from the current owner and physical viewport on every frame.
-struct StackFlow {
- indices: Vec<usize>,
- flow: super::layout::Flow,
-}
-
-/// Owner-local memo for nodes actually owned by one immutable Node recipe.
-/// The weak recipe witness prevents node-address reuse from becoming a cache hit.
-/// Queries are allocated on demand and kept only while used in the current or
-/// preceding measurement pass; resizing cannot accumulate historical widths.
-#[derive(Default)]
-pub(super) struct RetainedMeasurements {
- recipe: Option<std::rc::Weak<dyn super::Widget>>,
- nodes: Box<[(usize, bool)]>,
- pass: Option<std::time::Instant>,
- sizes: std::collections::BTreeMap<(usize, SizeConstraints), RetainedQuery<(u16, u32)>>,
- flows: std::collections::BTreeMap<
- (usize, FlowConstraints),
- RetainedQuery<std::sync::Arc<StackFlow>>,
- >,
- #[cfg(test)]
- size_hits: usize,
- #[cfg(test)]
- flow_hits: usize,
-}
-
-impl RetainedMeasurements {
- fn prepare(&mut self, recipe: &std::rc::Rc<dyn super::Widget>, pass: std::time::Instant) {
- let witness = std::rc::Rc::downgrade(recipe);
- if !self
- .recipe
- .as_ref()
- .is_some_and(|previous| previous.ptr_eq(&witness))
- {
- *self = Self {
- recipe: Some(witness),
- ..Default::default()
- };
- // A native widget may construct temporary Nodes while measuring or
- // painting. Only pre-index the recipe's declared immutable scene;
- // temporary stack addresses under another Widget are never retained.
- if let Some(root) = recipe.scene() {
- let mut nodes = Vec::new();
- Self::index(root, &mut nodes);
- self.nodes = nodes.into_boxed_slice();
- self.nodes.sort_unstable_by_key(|(address, _)| *address);
- }
- }
- if self.pass != Some(pass) {
- // Keep the previous actual working set available for this pass.
- // A query unused for an entire pass retires at the next boundary.
- self.sizes
- .retain(|_, query| std::mem::replace(&mut query.used, false));
- self.flows
- .retain(|_, query| std::mem::replace(&mut query.used, false));
- self.pass = Some(pass);
- }
- }
-
- fn index(node: &Node, nodes: &mut Vec<(usize, bool)>) -> bool {
- let (pure, flow) = match node {
- Node::Component(_) => (false, false),
- Node::AdaptivePane {
- background,
- content,
- ..
- } => {
- Self::index(background, nodes);
- Self::index(content, nodes);
- (false, false)
- }
- Node::Dialog {
- background,
- content,
- ..
- }
- | Node::ContextMenu {
- background,
- content,
- ..
- } => {
- let pure = Self::index(background, nodes);
- Self::index(content, nodes);
- (pure, false)
- }
- Node::Item { content, .. } => (Self::index(content, nodes), false),
- Node::Stack {
- children, viewport, ..
- } => {
- let mut flow = true;
- for child in children {
- let pure = Self::index(child, nodes);
- flow &= pure;
- }
- (*viewport || flow, flow)
- }
- Node::Text { .. } | Node::Rule { .. } | Node::Button { .. } | Node::Choice(_) => {
- (true, false)
- }
- };
- if pure {
- nodes.push((node as *const Node as usize, flow));
- }
- pure
- }
-
- fn node(&self, node: &Node) -> Option<(usize, bool)> {
- let address = node as *const Node as usize;
- let index = self
- .nodes
- .binary_search_by_key(&address, |(key, _)| *key)
- .ok()?;
- Some(self.nodes[index])
- }
-
- fn size(&mut self, node: &Node, constraints: SizeConstraints) -> Option<(u16, u32)> {
- let (address, _) = self.node(node)?;
- let query = self.sizes.get_mut(&(address, constraints))?;
- query.used = true;
- let size = query.value;
- #[cfg(test)]
- {
- self.size_hits += 1;
- }
- Some(size)
- }
-
- fn retain_size(&mut self, node: &Node, constraints: SizeConstraints, size: (u16, u32)) {
- if let Some((address, _)) = self.node(node) {
- self.sizes.insert(
- (address, constraints),
- RetainedQuery {
- value: size,
- used: true,
- },
- );
- }
- }
-
- fn flow(
- &mut self,
- node: &Node,
- constraints: FlowConstraints,
- ) -> Option<std::sync::Arc<StackFlow>> {
- let (address, true) = self.node(node)? else {
- return None;
- };
- let query = self.flows.get_mut(&(address, constraints))?;
- query.used = true;
- let flow = query.value.clone();
- #[cfg(test)]
- {
- self.flow_hits += 1;
- }
- Some(flow)
- }
-
- fn retain_flow(
- &mut self,
- node: &Node,
- constraints: FlowConstraints,
- flow: StackFlow,
- ) -> std::sync::Arc<StackFlow> {
- let flow = std::sync::Arc::new(flow);
- if let Some((address, true)) = self.node(node) {
- self.flows.insert(
- (address, constraints),
- RetainedQuery {
- value: flow.clone(),
- used: true,
- },
- );
- }
- flow
- }
-
- #[cfg(test)]
- pub(super) fn stats(&self) -> (usize, usize, usize, usize, usize) {
- (
- self.nodes.len(),
- self.sizes.len(),
- self.flows.len(),
- self.size_hits,
- self.flow_hits,
- )
- }
-}
-
-/// Derived, paint-local memoization. No entry survives the immutable render pass
-/// or participates in input/continuation/geometry admission.
-pub(super) struct Measurements {
- time: std::time::Instant,
... 45607 lines suppressed ...
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 19/22: chore(terminal): bind notices to the list ownership fix
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 2be941cb46abe69f6269a700f7475de8e8b960f0
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 17:17:50 2026 +0800
chore(terminal): bind notices to the list ownership fix
---
products/maka/cli/THIRD_PARTY_NOTICES.txt | 12 ++++++------
products/maka/cli/notices.json | 6 +++---
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/products/maka/cli/THIRD_PARTY_NOTICES.txt b/products/maka/cli/THIRD_PARTY_NOTICES.txt
index a2fd5668c4..100eb85422 100644
--- a/products/maka/cli/THIRD_PARTY_NOTICES.txt
+++ b/products/maka/cli/THIRD_PARTY_NOTICES.txt
@@ -4023,7 +4023,7 @@ zeroize_derive@1.5.0 (Apache-2.0 OR MIT); authors declared by package: The RustC
zune-core@0.5.1 (MIT OR Apache-2.0 OR Zlib)
zune-jpeg@0.5.15 (MIT OR Apache-2.0 OR Zlib); authors declared by package: caleb <et...@gmail.com>
-git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042 :: third_party/llrt-types/LICENSE
+git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d :: third_party/llrt-types/LICENSE
https://crates.io/api/v1/crates/apple-cf/0.9.3/download :: LICENSE-APACHE
https://crates.io/api/v1/crates/apple-metal/0.8.8/download :: LICENSE-APACHE
https://crates.io/api/v1/crates/approx/0.5.1/download :: LICENSE
@@ -19731,7 +19731,7 @@ gpui-fast-windows@0.1.10 (Apache-2.0)
gpui-fast@0.1.10 (Apache-2.0); authors declared by package: Nathan Sobo <na...@zed.dev>
gpui-pre-scheduler@0.3.9 (Apache-2.0)
-git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042 :: LICENSE
+git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d :: LICENSE
https://crates.io/api/v1/crates/gpui-fast-apple/0.1.10/download :: LICENSE
https://crates.io/api/v1/crates/gpui-fast-macros/0.1.10/download :: LICENSE
https://crates.io/api/v1/crates/gpui-fast-platform/0.1.10/download :: LICENSE
@@ -19956,7 +19956,7 @@ Apache License
gpui-fast-wgpu@0.1.10 (Apache-2.0); authors declared by package: Longbridge
-git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042 :: test-assets/IBMPlexSans-license.txt
+git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d :: test-assets/IBMPlexSans-license.txt
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
@@ -22464,7 +22464,7 @@ ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation a
icy_sixel@0.5.1 (MIT OR Apache-2.0); authors declared by package: Mike Krüger <mk...@posteo.de>
-git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042 :: LICENSE-APACHE
+git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d :: LICENSE-APACHE
Apache License
Version 2.0, January 2004
@@ -22672,7 +22672,7 @@ Apache License
icy_sixel@0.5.1 (MIT OR Apache-2.0); authors declared by package: Mike Krüger <mk...@posteo.de>
-git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042 :: LICENSE-MIT
+git+https://github.com/M4n5ter/nsh?rev=36c0fa75e81db858d62a776408f839e5a5333c8d#36c0fa75e81db858d62a776408f839e5a5333c8d :: LICENSE-MIT
MIT License
@@ -27888,7 +27888,7 @@ nsh-package@0.2.0 (Apache-2.0)
nsh-platform-gpui@0.2.0 (Apache-2.0)
nsh-platform-tui@0.2.0 (Apache-2.0)
-https://raw.githubusercontent.com/M4n5ter/nsh/01ce8f98569d97d68a576abed5719c820fbdb042/LICENSE-APACHE
+https://raw.githubusercontent.com/M4n5ter/nsh/36c0fa75e81db858d62a776408f839e5a5333c8d/LICENSE-APACHE
Copyright 2026 Longbridge <https://longbridge.com>
diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json
index 30757ece30..f674842493 100644
--- a/products/maka/cli/notices.json
+++ b/products/maka/cli/notices.json
@@ -1,7 +1,7 @@
{
"inputs": {
- "Cargo.lock": "63f79ebca2bda37899abcf6883c9643f99a774dde6ea46c834174b48ad202755",
- "Cargo.toml": "614fa6e94d12780525ce72b8604c49f253c75bf500b5e4a140197c3eee402457",
+ "Cargo.lock": "4e112224a6916f1f21228f3e8583e3a2692bda7c299459ff816b684c31975b58",
+ "Cargo.toml": "9ccb40b7e27a8d61110203f0afd5b90bbcd222652cb4171c56b51edb09d63ede",
"package-lock.json": "a5af15aca21fabe0e1bdd48784167df4dc81cee17777d54861dcbd641168a4eb",
"package.json": "38baf3beeb5876849ca16e31868d59bfaee769f260c7555b4a8a1847b92e28a1",
"packages/plugin-sdk/src/configurations.ts": "93fcc2c070289ffd4b48dcbaee426d72abff74f95c3166ad73447eec588c011a",
@@ -1392,5 +1392,5 @@
"third-party/termina/upstream.json": "31ce8c2b8b45305bff1ce8a52db55f06e5887e770346dbc6c3ea3c574b58ebad",
"tools/schema/Cargo.toml": "d8f47765451f555f872ea034945abd7afd54dc4c188ad5d5553932f8f2bc2462"
},
- "sha256": "50391e1c9e6f2ab26b2fa3d1717687672dddd4c5e3e6de736918c172189985e9"
+ "sha256": "65a0d7c2a2db419e576c9771400d0b930ff33d3ffd7975952d9fd166be5188df"
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 12/22: Give image control captions an explicit text return type
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit e482d30bbbb9ad1c9f6d9cc3bfbeaed9f9f69095
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 17:00:01 2026 +0800
Give image control captions an explicit text return type
---
products/maka/terminal-shell/app/ImageViewer.tsx | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/products/maka/terminal-shell/app/ImageViewer.tsx b/products/maka/terminal-shell/app/ImageViewer.tsx
index c0d904ff76..cc2bb60730 100644
--- a/products/maka/terminal-shell/app/ImageViewer.tsx
+++ b/products/maka/terminal-shell/app/ImageViewer.tsx
@@ -229,15 +229,23 @@ export function ImageViewer(props: { image: ViewerImage; close(): void }) {
{
kind: 'fit' as const,
label: () => tr('Fit image to window'),
- caption: () => tr('Fit'),
+ caption: (): string => tr('Fit'),
},
{
kind: 'actual' as const,
label: () => tr('Show image at actual size'),
- caption: () => '100%',
+ caption: (): string => '100%',
+ },
+ {
+ kind: 'out' as const,
+ label: () => tr('Zoom image out'),
+ caption: (): string => '−',
+ },
+ {
+ kind: 'in' as const,
+ label: () => tr('Zoom image in'),
+ caption: (): string => '+',
},
- { kind: 'out' as const, label: () => tr('Zoom image out'), caption: () => '−' },
- { kind: 'in' as const, label: () => tr('Zoom image in'), caption: () => '+' },
]}
key={(item) => item.kind}
>
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 05/22: chore(terminal): refresh notices for the NSH runtime update
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit ecaa3dd0f6fa94b3c302196a6eebff48ea713884
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 16:33:09 2026 +0800
chore(terminal): refresh notices for the NSH runtime update
---
products/maka/cli/THIRD_PARTY_NOTICES.txt | 12 ++++++------
products/maka/cli/notices.json | 6 +++---
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/products/maka/cli/THIRD_PARTY_NOTICES.txt b/products/maka/cli/THIRD_PARTY_NOTICES.txt
index 87c8bef10c..cc866d65c7 100644
--- a/products/maka/cli/THIRD_PARTY_NOTICES.txt
+++ b/products/maka/cli/THIRD_PARTY_NOTICES.txt
@@ -4025,7 +4025,7 @@ zeroize_derive@1.5.0 (Apache-2.0 OR MIT); authors declared by package: The RustC
zune-core@0.5.1 (MIT OR Apache-2.0 OR Zlib)
zune-jpeg@0.5.15 (MIT OR Apache-2.0 OR Zlib); authors declared by package: caleb <et...@gmail.com>
-git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e :: third_party/llrt-types/LICENSE
+git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042 :: third_party/llrt-types/LICENSE
https://crates.io/api/v1/crates/apple-cf/0.9.3/download :: LICENSE-APACHE
https://crates.io/api/v1/crates/apple-metal/0.8.8/download :: LICENSE-APACHE
https://crates.io/api/v1/crates/approx/0.5.1/download :: LICENSE
@@ -19733,7 +19733,7 @@ gpui-fast-windows@0.1.10 (Apache-2.0)
gpui-fast@0.1.10 (Apache-2.0); authors declared by package: Nathan Sobo <na...@zed.dev>
gpui-pre-scheduler@0.3.9 (Apache-2.0)
-git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e :: LICENSE
+git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042 :: LICENSE
https://crates.io/api/v1/crates/gpui-fast-apple/0.1.10/download :: LICENSE
https://crates.io/api/v1/crates/gpui-fast-macros/0.1.10/download :: LICENSE
https://crates.io/api/v1/crates/gpui-fast-platform/0.1.10/download :: LICENSE
@@ -19958,7 +19958,7 @@ Apache License
gpui-fast-wgpu@0.1.10 (Apache-2.0); authors declared by package: Longbridge
-git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e :: test-assets/IBMPlexSans-license.txt
+git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042 :: test-assets/IBMPlexSans-license.txt
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
@@ -22466,7 +22466,7 @@ ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation a
icy_sixel@0.5.1 (MIT OR Apache-2.0); authors declared by package: Mike Krüger <mk...@posteo.de>
-git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e :: LICENSE-APACHE
+git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042 :: LICENSE-APACHE
Apache License
Version 2.0, January 2004
@@ -22674,7 +22674,7 @@ Apache License
icy_sixel@0.5.1 (MIT OR Apache-2.0); authors declared by package: Mike Krüger <mk...@posteo.de>
-git+https://github.com/M4n5ter/nsh?rev=750fdf8b14b3eacaf2aa3ed75b282735dded6f7e#750fdf8b14b3eacaf2aa3ed75b282735dded6f7e :: LICENSE-MIT
+git+https://github.com/M4n5ter/nsh?rev=01ce8f98569d97d68a576abed5719c820fbdb042#01ce8f98569d97d68a576abed5719c820fbdb042 :: LICENSE-MIT
MIT License
@@ -27890,7 +27890,7 @@ nsh-package@0.2.0 (Apache-2.0)
nsh-platform-gpui@0.2.0 (Apache-2.0)
nsh-platform-tui@0.2.0 (Apache-2.0)
-https://raw.githubusercontent.com/M4n5ter/nsh/750fdf8b14b3eacaf2aa3ed75b282735dded6f7e/LICENSE-APACHE
+https://raw.githubusercontent.com/M4n5ter/nsh/01ce8f98569d97d68a576abed5719c820fbdb042/LICENSE-APACHE
Copyright 2026 Longbridge <https://longbridge.com>
diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json
index 4757d0750b..a9c7ef0639 100644
--- a/products/maka/cli/notices.json
+++ b/products/maka/cli/notices.json
@@ -1,7 +1,7 @@
{
"inputs": {
- "Cargo.lock": "5d1fc6a0fd2d2769f09e5de06da8bc8404cfe1cb071e74efd029970f35dc9d8a",
- "Cargo.toml": "74e9b32133c45d7787d051fdb16bc33a6436a488c4b446561d7a6acb63668e95",
+ "Cargo.lock": "09e8af14866adc3afeb197b4d383fc5a61904d41a410ae412979389177bf1d76",
+ "Cargo.toml": "fa9ecab8bdb91f21aa10e502bcad261d8e86431d9b9e91386486841db17a54b7",
"package-lock.json": "a5af15aca21fabe0e1bdd48784167df4dc81cee17777d54861dcbd641168a4eb",
"package.json": "38baf3beeb5876849ca16e31868d59bfaee769f260c7555b4a8a1847b92e28a1",
"packages/plugin-sdk/src/configurations.ts": "93fcc2c070289ffd4b48dcbaee426d72abff74f95c3166ad73447eec588c011a",
@@ -1393,5 +1393,5 @@
"third-party/termina/upstream.json": "31ce8c2b8b45305bff1ce8a52db55f06e5887e770346dbc6c3ea3c574b58ebad",
"tools/schema/Cargo.toml": "d8f47765451f555f872ea034945abd7afd54dc4c188ad5d5553932f8f2bc2462"
},
- "sha256": "4b439ab9aed7093b0c20e4dc1becea041263a37a96ee17fb68935920dced4a16"
+ "sha256": "3e27682d8e98b11ecf6f267b9ca1e48bdc24e6b22ee1e7fafebf825d50c91fb3"
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 22/22: perf(event-log): avoid repeated oversized transcript cache sweeps
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit d781180cf91315d179d71355c24eb03731052150
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 17:39:16 2026 +0800
perf(event-log): avoid repeated oversized transcript cache sweeps
---
platform/event-log/src/transcript/bodycache.rs | 13 ++-
.../event-log/src/transcript/bodycache_tests.rs | 103 +++++++++++++++++++--
2 files changed, 108 insertions(+), 8 deletions(-)
diff --git a/platform/event-log/src/transcript/bodycache.rs b/platform/event-log/src/transcript/bodycache.rs
index aaacb82d5e..cd6b47007f 100644
--- a/platform/event-log/src/transcript/bodycache.rs
+++ b/platform/event-log/src/transcript/bodycache.rs
@@ -74,10 +74,21 @@ pub(super) fn run<'c, T: Send + 'c>(
) -> crate::connection::BoxFuture<'c, Result<T, StoreError>> {
Box::pin(async move {
let mut tx = db.begin().await?;
+ let before: i64 = sqlx::query_scalar("SELECT total_changes()")
+ .fetch_one(&mut *tx)
+ .await?;
match read(&mut tx).await {
Ok(value) => {
+ let after: i64 = sqlx::query_scalar("SELECT total_changes()")
+ .fetch_one(&mut *tx)
+ .await?;
tx.commit().await?;
- trim(db).await?;
+ // Cache fills and retained-source changes require maintenance.
+ // An unchanged fragment cannot add reclaimable bodies, even
+ // when its retained producer alone exceeds the page target.
+ if after != before {
+ trim(db).await?;
+ }
Ok(value)
}
Err(error) => {
diff --git a/platform/event-log/src/transcript/bodycache_tests.rs b/platform/event-log/src/transcript/bodycache_tests.rs
index 93ace0ae31..f8eda7ef56 100644
--- a/platform/event-log/src/transcript/bodycache_tests.rs
+++ b/platform/event-log/src/transcript/bodycache_tests.rs
@@ -25,6 +25,19 @@ use maka_agent_facts::{
use maka_agent_ports::session::history::{HistoryField, Page, Position};
async fn user(log: &EventLog, turn: &str, text: String) -> u64 {
+ user_input(
+ log,
+ turn,
+ InvocationInput::Message {
+ source_messages: vec![],
+ content: text.into(),
+ request_fingerprint: None,
+ },
+ )
+ .await
+}
+
+async fn user_input(log: &EventLog, turn: &str, input: InvocationInput) -> u64 {
let write = EventWrite::plain(RuntimeEvent::new(
Invocation {
session_id: "cache".into(),
@@ -34,11 +47,7 @@ async fn user(log: &EventLog, turn: &str, text: String) -> u64 {
},
Fact::InvocationOpened {
configuration: None,
- input: InvocationInput::Message {
- source_messages: vec![],
- content: text.into(),
- request_fingerprint: None,
- },
+ input,
},
))
.unwrap();
@@ -180,13 +189,52 @@ fn backing_bytes(db: &rusqlite::Connection) -> Option<i64> {
#[tokio::test]
async fn oversized_source_reuses_fragments_then_reclaims_backing_file_on_replacement() {
+ use maka_agent_facts::{
+ input::{DeliveredMessage, MessageInput},
+ message::{MessageDisposition, MessageOrigin, Placement, RootSourceMessage, aggregate},
+ };
+ use std::sync::{
+ Arc,
+ atomic::{AtomicUsize, Ordering},
+ };
+
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("oversized.sqlite");
let log = EventLog::open(&path).await.unwrap();
log.create_session("cache", "create", &serde_json::json!({}), 1)
.await
.unwrap();
- let through = user(&log, "large", "α".repeat(17 * 1024 * 1024)).await;
+ // One canonical producer owns 34 MiB across enough siblings to distinguish
+ // an indexed continuation from a repeated cache-wide maintenance scan.
+ let text: MessageInput = "α".repeat(17 * 1024).into();
+ let digest = text.content_digest().unwrap();
+ const SIBLINGS: usize = 1024;
+ let sources: Vec<_> = (0..SIBLINGS)
+ .map(|index| RootSourceMessage {
+ message: DeliveredMessage {
+ origin: Some(MessageOrigin::WorkhubTask {
+ task_id: format!("task-{index}"),
+ }),
+ message_id: format!("large-{index}"),
+ content: text.clone(),
+ submitted_content_digest: digest.clone(),
+ },
+ unprepared_content: text.clone(),
+ submitted_placement: Placement::CurrentTurn,
+ disposition: MessageDisposition::TurnStarted,
+ submitted_intent: None,
+ })
+ .collect();
+ let through = user_input(
+ &log,
+ "large",
+ InvocationInput::Message {
+ content: aggregate(sources.iter().map(|source| &source.message.content)),
+ source_messages: sources,
+ request_fingerprint: None,
+ },
+ )
+ .await;
let position = RowPosition {
source_sequence: through,
item_index: 0,
@@ -202,7 +250,17 @@ async fn oversized_source_reuses_fragments_then_reclaims_backing_file_on_replace
.await;
let Page::Ready { chunks, after, .. } = page;
assert_eq!(chunks[0].text, "α".repeat(4096));
- let before = inspect(&log, |db| {
+ let before = inspect(&log, move |db| {
+ assert_eq!(
+ db.query_row(
+ "SELECT count(*) FROM transcript_cache.rows WHERE session_id='cache' AND source_sequence=?",
+ [through as i64],
+ |row| row.get::<_, i64>(0),
+ )
+ .unwrap(),
+ SIBLINGS as i64 + 1,
+ "the opening must project each attributed message and its running state"
+ );
assert!(
db.query_row("PRAGMA transcript_cache.page_count", [], |row| row
.get::<_, i64>(0))
@@ -215,6 +273,20 @@ async fn oversized_source_reuses_fragments_then_reclaims_backing_file_on_replace
)
})
.await;
+ let steps = Arc::new(AtomicUsize::new(0));
+ let observed = steps.clone();
+ log.connection
+ .run(move |db| {
+ Box::pin(async move {
+ db.lock_handle().await?.set_progress_handler(1, move || {
+ observed.fetch_add(1, Ordering::Relaxed);
+ true
+ });
+ Ok(())
+ })
+ })
+ .await
+ .unwrap();
let Page::Ready { chunks, .. } = read(
&log,
through,
@@ -223,6 +295,23 @@ async fn oversized_source_reuses_fragments_then_reclaims_backing_file_on_replace
},
)
.await;
+ log.connection
+ .run(|db| {
+ Box::pin(async move {
+ db.lock_handle().await?.remove_progress_handler();
+ Ok(())
+ })
+ })
+ .await
+ .unwrap();
+ let steps = steps.load(Ordering::Relaxed);
+ // Even one correlated sweep of all siblings must read/compare both source
+ // key columns and advance each row (>8 VM ops/row). Stay below that sweep
+ // lower bound, rather than imposing a machine-dependent elapsed-time cap.
+ assert!(
+ steps > 0 && steps < SIBLINGS * 8,
+ "cached continuation must not scan all {SIBLINGS} siblings: {steps} VM steps"
+ );
assert_eq!(chunks[0].text, "α".repeat(4096));
inspect(&log, move |db| {
assert_eq!(
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 10/22: Align native UI fixtures with localized contracts and focus ownership
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 91e111f002715ee2b8da5ee9817aa1ee5284be71
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 16:53:34 2026 +0800
Align native UI fixtures with localized contracts and focus ownership
---
products/maka/terminal-shell/app/ModelSettings.tsx | 2 +-
products/maka/terminal-shell/app/locale-settings.ts | 2 +-
products/maka/terminal-shell/tests/real_host.rs | 13 ++++++++++++-
.../terminal-shell/tests/real_host/attachment_intake.rs | 5 ++++-
4 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/products/maka/terminal-shell/app/ModelSettings.tsx b/products/maka/terminal-shell/app/ModelSettings.tsx
index 8967e29838..d2c70e3ebc 100644
--- a/products/maka/terminal-shell/app/ModelSettings.tsx
+++ b/products/maka/terminal-shell/app/ModelSettings.tsx
@@ -175,7 +175,7 @@ export function ModelSettings(props: { close(): void; changed(): void }) {
? tr('Back from connection settings')
: section() === 'general'
? tr('Back from general settings')
- : tr('Back from plugin settings')
+ : tr('Back from plugins settings')
}
tooltip={tr('Back')}
onPress={inputAction(back, backAction())}
diff --git a/products/maka/terminal-shell/app/locale-settings.ts b/products/maka/terminal-shell/app/locale-settings.ts
index 9073645883..109424dff9 100644
--- a/products/maka/terminal-shell/app/locale-settings.ts
+++ b/products/maka/terminal-shell/app/locale-settings.ts
@@ -128,7 +128,7 @@ export const settingsMessages = {
'從連線設定返回',
],
'Back from general settings': ['Back from general settings', '从常规设置返回', '從一般設定返回'],
- 'Back from plugin settings': ['Back from plugin settings', '从插件设置返回', '從外掛設定返回'],
+ 'Back from plugins settings': ['Back from plugins settings', '从插件设置返回', '從外掛設定返回'],
'Back to model settings': ['Back to model settings', '返回模型设置', '返回模型設定'],
'Base URL': ['Base URL', '基础 URL', '基底 URL'],
'Cancel provider sign-in': ['Cancel provider sign-in', '取消提供商登录', '取消供應商登入'],
diff --git a/products/maka/terminal-shell/tests/real_host.rs b/products/maka/terminal-shell/tests/real_host.rs
index 8858dc4406..b549b13fe2 100644
--- a/products/maka/terminal-shell/tests/real_host.rs
+++ b/products/maka/terminal-shell/tests/real_host.rs
@@ -635,6 +635,10 @@ async fn empty_settings_configure_a_real_connection_and_manual_reasoning_model_b
wait_view(&mut view, |screen| screen.contains("常规设置") && screen.contains("偏好已保存。")).await;
support::click_button(&mut view, "关闭设置").await;
wait_view(&mut view, |screen| screen.contains("消息已复制") && screen.contains("发送消息,或开始一项任务…")).await;
+ // The second Settings dialog was opened from the copy action, so its
+ // native restoration belongs to that action. Explicitly focus the same
+ // resident composer before checking its identity and editing context.
+ tab_to_label(&mut view, "消息输入区").await;
editable(&mut view, "消息输入区").await;
assert_eq!(view.surface().focused(), Some(composer), "language changes replaced the resident composer");
assert_eq!(view.surface().input_context().unwrap().text.as_deref(), Some(""));
@@ -1124,6 +1128,13 @@ async fn the_home_palette_creates_switches_models_and_stops_a_real_session_witho
shortcut(&mut view, 'o');
wait_focused_input(&mut view, "Search session titles").await;
view.handle_event(Event::Paste("Browser page".into()));
+ assert_eq!(view.surface().input_context().unwrap().text.as_deref(), Some("Browser page"));
+ // The unfiltered catalog can already contain first_title. Settle the
+ // queued change handler before accepting that row for the new query,
+ // using the same native barrier as select_palette above.
+ let (done, committed) = tokio::sync::oneshot::channel();
+ view.control().unwrap().advance_clock(Duration::ZERO, move || { let _ = done.send(()); });
+ tokio::time::timeout(Duration::from_secs(20), committed).await.expect("the browser query did not commit").expect("the browser engine stopped");
let first_title = first_page["sessions"][0]["title"].as_str().unwrap();
wait_view(&mut view, |screen| screen.contains(first_title) && !screen.contains("Loading…")).await;
tokio::time::timeout(Duration::from_secs(20), async {
@@ -1136,7 +1147,7 @@ async fn the_home_palette_creates_switches_models_and_stops_a_real_session_witho
let row = first_page["sessions"].as_array().unwrap().iter()
.filter_map(|session| session["title"].as_str())
.find(|title| screen.contains(title))
- .expect("the browser must show a matching session row before its next page");
+ .unwrap_or_else(|| panic!("the browser must show a matching session row before its next page; app error: {:?}\n{}", view.last_error(), screen));
support::scroll_text(&mut view, row, MouseEventKind::ScrollDown);
tokio::time::sleep(Duration::from_millis(5)).await;
}
diff --git a/products/maka/terminal-shell/tests/real_host/attachment_intake.rs b/products/maka/terminal-shell/tests/real_host/attachment_intake.rs
index 4611e33bb1..1f8486b9d0 100644
--- a/products/maka/terminal-shell/tests/real_host/attachment_intake.rs
+++ b/products/maka/terminal-shell/tests/real_host/attachment_intake.rs
@@ -56,7 +56,10 @@ const watching = original.watchUploads();
count.set(operations.length);
}} }})();
mount(() => <View style={{{{width:"100%",padding:8,gap:8}}}}>
- <AttachmentPicker client={{selected()}} visible={{visible()}} onStatus={{status => {{
+ <AttachmentPicker client={{selected()}} visible={{visible()}}
+ pathLabel="Attachment path" pathPlaceholder="Absolute file path…"
+ uploadLabel="Upload selected file" uploadingLabel="Uploading…" selectedFileLabel="Selected file"
+ onStatus={{status => {{
report.set(`${{status.kind}}:${{status.operationId}}:${{status.name}}`);
if (status.kind === "reading") {{ visible.set(false); selected.set(other); }}
}}}} />
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 11/22: test(terminal): follow localized resource accessibility labels
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 3bba9bd93f508a86b0c02520f80ee977327a6f23
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 16:54:40 2026 +0800
test(terminal): follow localized resource accessibility labels
The current full Host run had 25 passes and six failures. The workspace-preview and original-PTY failures used two stale English accessibility selectors after localization: file: became File:, and Open terminal: {command} became Open terminal {command}.
Migrate those selectors, including the existing pagination and watch frame lookups. Keep the same physical clicks, complete command, original PTY identity, file contents, draft/session ownership assertions, viewport and time budgets.
---
products/maka/terminal-shell/tests/real_host.rs | 8 ++++----
.../maka/terminal-shell/tests/real_host/terminal_workbench.rs | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/products/maka/terminal-shell/tests/real_host.rs b/products/maka/terminal-shell/tests/real_host.rs
index b549b13fe2..81988537db 100644
--- a/products/maka/terminal-shell/tests/real_host.rs
+++ b/products/maka/terminal-shell/tests/real_host.rs
@@ -2368,7 +2368,7 @@ async fn current_workspace_previews_keep_real_contents_drafts_and_session_owners
let composer = view.surface().focused().unwrap();
support::click_label(&mut view, "Browse workspace files");
wait_view(&mut view, |screen| screen.contains("000-preview.txt") && screen.contains("001-scene.data") && !screen.contains("Loading files…") && !screen.contains("Updating files…")).await;
- support::click_label(&mut view, "file: 000-preview.txt");
+ support::click_label(&mut view, "File: 000-preview.txt");
let first = wait_view_state(&mut view, |view, screen| screen.contains("AFTER_CURRENT_EDIT") && screen.contains("Captured version") && !screen.contains("Reading current file…") && view.accessibility_snapshot().iter().any(|node| node.label == long_line && node.frame.size.height.0 > 16.0)).await;
assert!(!first.contains("BEFORE_CURRENT_EDIT"));
// One original line stays one native Text, including a combining
@@ -2484,7 +2484,7 @@ async fn current_workspace_previews_keep_real_contents_drafts_and_session_owners
assert!(view.accessibility_snapshot().iter().any(|node| node.id == composer));
support::click_label(&mut view, "Browse workspace files");
wait_view(&mut view, |screen| screen.contains("001-scene.data") && !screen.contains("Loading files…") && !screen.contains("Updating files…")).await;
- support::click_label(&mut view, "file: 001-scene.data");
+ support::click_label(&mut view, "File: 001-scene.data");
let image = wait_view(&mut view, |screen| screen.contains("64 × 48 · Captured image") && screen.contains('▀')).await;
evidence("workspace-current-image-dialog", &image);
support::evidence_cells("workspace-current-image-dialog", &view);
@@ -2533,10 +2533,10 @@ async fn current_workspace_previews_keep_real_contents_drafts_and_session_owners
tokio::time::sleep(Duration::from_millis(5)).await;
}
}).await.expect("native file scrolling did not load the second Host page");
- let tail_y = view.accessibility_snapshot().into_iter().find(|node| node.label == "file: page-139.txt").unwrap().frame.origin.y.0;
+ let tail_y = view.accessibility_snapshot().into_iter().find(|node| node.label == "File: page-139.txt").unwrap().frame.origin.y.0;
std::fs::rename(workspace.join("page-139.txt"), workspace.join("page-139v2.txt")).unwrap();
wait_view(&mut view, |screen| screen.contains("page-139v2.txt")).await;
- let refreshed_y = view.accessibility_snapshot().into_iter().find(|node| node.label == "file: page-139v2.txt").unwrap().frame.origin.y.0;
+ let refreshed_y = view.accessibility_snapshot().into_iter().find(|node| node.label == "File: page-139v2.txt").unwrap().frame.origin.y.0;
assert_eq!(tail_y, refreshed_y, "a watch refresh discarded the already requested prefix");
support::evidence_cells("workspace-files-automatic-pagination-and-watch", &view);
support::click_button(&mut view, "Close workspace preview").await;
diff --git a/products/maka/terminal-shell/tests/real_host/terminal_workbench.rs b/products/maka/terminal-shell/tests/real_host/terminal_workbench.rs
index 3ae733aa02..1982bc6373 100644
--- a/products/maka/terminal-shell/tests/real_host/terminal_workbench.rs
+++ b/products/maka/terminal-shell/tests/real_host/terminal_workbench.rs
@@ -156,7 +156,7 @@ async fn original_pty_workbench_keeps_its_output_and_chat_draft_across_reopening
support::click_button(&mut view, "Browse workspace files").await;
support::click_button(&mut view, "Show conversation terminals").await;
- support::click_button(&mut view, &format!("Open terminal: {command}")).await;
+ support::click_button(&mut view, &format!("Open terminal {command}")).await;
wait_view(&mut view, |screen| screen.contains("中文终端")).await;
assert_colored_output(&view);
let reopened = client
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 20/22: Merge remote-tracking branch 'origin/rust-preview' into feat/terminal-nsh-base
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit f42f52b85347f4ad74c0415e4d0a76f1b30ec05f
Merge: 2be941cb46 2dd07fbe2a
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 17:17:50 2026 +0800
Merge remote-tracking branch 'origin/rust-preview' into feat/terminal-nsh-base
.../0038_private_transcript_cache.sql} | 24 +-
platform/event-log/src/lib.rs | 28 ++
platform/event-log/src/schema.rs | 1 +
platform/event-log/src/sessions/copy/lifecycle.rs | 4 +-
platform/event-log/src/sessions/material.rs | 2 +-
platform/event-log/src/sessions/removal.rs | 4 +-
platform/event-log/src/transcript.rs | 1 +
platform/event-log/src/transcript/bodycache.rs | 118 ++++++++-
.../event-log/src/transcript/bodycache_tests.rs | 294 +++++++++++++++++++++
platform/event-log/src/transcript/directory.rs | 12 +-
platform/event-log/src/transcript/emit.rs | 66 ++++-
platform/event-log/src/transcript/evidence.rs | 229 +++++++++++++---
platform/event-log/src/transcript/history.rs | 66 ++---
platform/event-log/src/transcript/image.rs | 30 +--
platform/event-log/src/transcript/navigation.rs | 32 ++-
platform/event-log/src/transcript/prompt.rs | 14 +-
platform/event-log/src/transcript/read.rs | 45 ++--
platform/event-log/src/transcript/search.rs | 19 +-
platform/event-log/src/transcript/source.rs | 17 +-
platform/event-log/src/transcript/tools.rs | 17 +-
.../tests/compact_presentation/automatic.rs | 21 +-
.../tests/compact_presentation/fixtures.rs | 2 +-
.../mod.rs} | 27 +-
.../mod.rs} | 42 +--
platform/event-log/tests/migrations.rs | 39 +--
.../event-log/tests/migrations/prior_artifacts.rs | 4 +-
platform/event-log/tests/navigation.rs | 23 +-
platform/event-log/tests/root_messages.rs | 3 +-
.../{row_directory.rs => row_directory/mod.rs} | 15 +-
.../tests/{session_copy.rs => session_copy/mod.rs} | 25 +-
platform/event-log/tests/steering.rs | 2 +-
.../{src/transcript.rs => tests/support/owner.rs} | 43 +--
.../transcript_index/accepted_observations.rs | 46 +++-
.../event-log/tests/transcript_index/fixtures.rs | 2 +-
.../event-log/tests/transcript_index/history.rs | 157 +++++++----
platform/event-log/tests/transcript_index/image.rs | 3 +-
.../tests/transcript_index/interrupted_ancestry.rs | 12 +-
.../tests/transcript_index/large_siblings.rs | 19 +-
.../mod.rs} | 190 +++++++------
.../tests/transcript_index/observation_count.rs | 8 +-
.../tests/transcript_index/performance.rs | 10 +-
.../tests/transcript_index/performance_executor.rs | 4 +-
.../tests/transcript_index/prefix_replay.rs | 65 +++--
.../tests/transcript_index/sparse_directory.rs | 27 +-
.../mod.rs} | 39 +--
.../event-log/tests/transcript_tools/payloads.rs | 106 +++++---
46 files changed, 1419 insertions(+), 538 deletions(-)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 14/22: chore(terminal): refresh notices after removing the legacy renderer
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 3eb63020b46fe9fb6f625f09338cc0d3c475f5cc
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 17:06:17 2026 +0800
chore(terminal): refresh notices after removing the legacy renderer
---
products/maka/cli/THIRD_PARTY_NOTICES.txt | 30 ------------------------------
products/maka/cli/notices.json | 11 +++++------
2 files changed, 5 insertions(+), 36 deletions(-)
diff --git a/products/maka/cli/THIRD_PARTY_NOTICES.txt b/products/maka/cli/THIRD_PARTY_NOTICES.txt
index cc866d65c7..a2fd5668c4 100644
--- a/products/maka/cli/THIRD_PARTY_NOTICES.txt
+++ b/products/maka/cli/THIRD_PARTY_NOTICES.txt
@@ -1151,7 +1151,6 @@ signal-hook-registry@1.4.8 (MIT OR Apache-2.0); authors declared by package: Mic
signal-hook@0.3.18 (Apache-2.0/MIT); authors declared by package: Michal 'vorner' Vaner <vo...@vorner.cz>, Thomas Himmelstoss <th...@posteo.de>
signal-hook@0.4.4 (MIT OR Apache-2.0); authors declared by package: Michal 'vorner' Vaner <vo...@vorner.cz>, Thomas Himmelstoss <th...@posteo.de>
similar@2.7.0 (Apache-2.0); authors declared by package: Armin Ronacher <ar...@active-4.com>, Pierre-Étienne Meunier <pe...@pijul.org>, Brandon Williams <bw...@gmail.com>
-similar@3.2.0 (Apache-2.0); authors declared by package: Armin Ronacher <ar...@active-4.com>, Pierre-Étienne Meunier <pe...@pijul.org>, Brandon Williams <bw...@gmail.com>
simplecss@0.2.2 (Apache-2.0 OR MIT)
smallvec@1.16.0 (MIT OR Apache-2.0); authors declared by package: The Servo Project Developers
smol@2.0.2 (Apache-2.0 OR MIT); authors declared by package: Stjepan Glavina <st...@gmail.com>
@@ -1435,7 +1434,6 @@ https://crates.io/api/v1/crates/signal-hook-registry/1.4.8/download :: LICENSE-A
https://crates.io/api/v1/crates/signal-hook/0.3.18/download :: LICENSE-APACHE
https://crates.io/api/v1/crates/signal-hook/0.4.4/download :: LICENSE-APACHE
https://crates.io/api/v1/crates/similar/2.7.0/download :: LICENSE
-https://crates.io/api/v1/crates/similar/3.2.0/download :: LICENSE
https://crates.io/api/v1/crates/simplecss/0.2.2/download :: LICENSE-APACHE
https://crates.io/api/v1/crates/smallvec/1.16.0/download :: LICENSE-APACHE
https://crates.io/api/v1/crates/smol/2.0.2/download :: LICENSE-APACHE
@@ -31836,34 +31834,6 @@ DEALINGS IN THE SOFTWARE.
================================================================================
-pulldown-cmark@0.13.4 (MIT); authors declared by package: Raph Levien <ra...@gmail.com>, Marcus Klaas de Vries <ma...@marcusklaas.nl>
-
-https://crates.io/api/v1/crates/pulldown-cmark/0.13.4/download :: LICENSE
-
-The MIT License
-
-Copyright 2015 Google Inc. All rights reserved.
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-================================================================================
-
pulp-wasm-simd-flag@0.1.1 (MIT); authors declared by package: sarah quiñones <sa...@veganb.tw>
pulp@0.22.3 (MIT); authors declared by package: sarah quiñones <sa...@veganb.tw>
diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json
index a9c7ef0639..30757ece30 100644
--- a/products/maka/cli/notices.json
+++ b/products/maka/cli/notices.json
@@ -1,7 +1,7 @@
{
"inputs": {
- "Cargo.lock": "09e8af14866adc3afeb197b4d383fc5a61904d41a410ae412979389177bf1d76",
- "Cargo.toml": "fa9ecab8bdb91f21aa10e502bcad261d8e86431d9b9e91386486841db17a54b7",
+ "Cargo.lock": "63f79ebca2bda37899abcf6883c9643f99a774dde6ea46c834174b48ad202755",
+ "Cargo.toml": "614fa6e94d12780525ce72b8604c49f253c75bf500b5e4a140197c3eee402457",
"package-lock.json": "a5af15aca21fabe0e1bdd48784167df4dc81cee17777d54861dcbd641168a4eb",
"package.json": "38baf3beeb5876849ca16e31868d59bfaee769f260c7555b4a8a1847b92e28a1",
"packages/plugin-sdk/src/configurations.ts": "93fcc2c070289ffd4b48dcbaee426d72abff74f95c3166ad73447eec588c011a",
@@ -35,7 +35,6 @@
"platform/plugin-core/Cargo.toml": "fdf62719358de260a7f2d5c799868d26f0d5118c2542e0d55433d9ecbeb19fda",
"platform/presentation/Cargo.toml": "e443041376121b1548fcafdfe887679ceaff0dc5af60f833f9caf9e0a34b0a64",
"platform/process/Cargo.toml": "319f952cac326aa23da6de712d5a7289b6fafcb6578f6a6fa28a6579a0b2d272",
- "platform/render-terminal/Cargo.toml": "dd9f7148fcdc807476cb977edd237ee993d64a6a489ba341dc42b8feb8a9c8c2",
"platform/sandbox/Cargo.toml": "9f564d3ab6fa2e98f9cc4052409ab1f49cbe3897069297a0fcb15747e92a155f",
"platform/transport/Cargo.toml": "bfb11b8885f639bfe9e8dfdd63b334204b35f93a0bb34fea40f215d2dbbf3b4e",
"platform/ui-js/Cargo.toml": "9e14fefad630e56df7699cab071b0a02b5c5e34ac7bfd55cc2bb0209c9a98780",
@@ -124,7 +123,7 @@
"plugins/agent/insights/Cargo.toml": "e11b391c1304bb27d552322e9f31b9e1858340f81f49ebbb3d484b02c9238def",
"plugins/agent/model/Cargo.toml": "0fbd5b92911a7d3af54f10069d16b1b1b4b2d453b79aeb6ea63e66fd78d6ad85",
"plugins/agent/model/tests/fixtures/provider-errors.mjs": "fd92fde7fb6d524315cf583ed4bcf40c33e70de156a81909c919e430a0ad07ee",
- "plugins/agent/native/Cargo.toml": "4185e550666c6acf3c5e7f30d660b4955ebebe0ed36a93130a4e55e23b816f69",
+ "plugins/agent/native/Cargo.toml": "1c3d52ed44f77af20aac1a9c0c4a6bf9f5d3fe0b8594d88401f408de66d222b0",
"plugins/agent/navigation/package.ts": "eac5fb178bf93bc3d40cb1e9e184707cf3095b026da8c28c5682e636d0242bed",
"plugins/agent/navigation/tui.ts": "fbfefb89c7265f5e0e740ca81d05b300fd3d98eb26474e6ae6114245972ad75d",
"plugins/agent/permission-approval/index.ts": "25f24c0affb340f1245a3a4ed4eaf2df1dd27de8a05dc16e9564a95eaf44bfd3",
@@ -395,7 +394,7 @@
"products/maka/bundle/scripts/build.mjs": "db11d8cd6f29ef7ead087261848568da1ad3889a870a89f23fa969449d769d47",
"products/maka/bundle/src/composition.ts": "5f7e4beff05213e60957ce4039c0f6fd429f0e37cd1f9a56a31c437ab678de6f",
"products/maka/bundle/src/installation.ts": "f1819851114b4280252afcbd16ef82e08269901faeacc171120c5abff74be72e",
- "products/maka/cli/Cargo.toml": "4aa6ac5e83f5755afc6eeda506e377aeb955b250d7b4a8b76a865babbe2b9b2c",
+ "products/maka/cli/Cargo.toml": "6a47ccc9e3e7fe9a79e473917c2278db07cbced6d5ece102f3e34bcffcfeab62",
"products/maka/cli/build.rs": "97c68def305bc101788428268fd99b114e5229fa2400542b0fea2eced6edbecc",
"products/maka/cli/build/product.rs": "44d6ab9638f91a3db0615bd509e4386d5b3aef008f24e3cb1e4dc5a5247b3102",
"products/maka/client/Cargo.toml": "33a6ff74d79ce70081336cad1f552f7705d0bf10ad82c341f3c0739332c0503c",
@@ -1393,5 +1392,5 @@
"third-party/termina/upstream.json": "31ce8c2b8b45305bff1ce8a52db55f06e5887e770346dbc6c3ea3c574b58ebad",
"tools/schema/Cargo.toml": "d8f47765451f555f872ea034945abd7afd54dc4c188ad5d5553932f8f2bc2462"
},
- "sha256": "3e27682d8e98b11ecf6f267b9ca1e48bdc24e6b22ee1e7fafebf825d50c91fb3"
+ "sha256": "50391e1c9e6f2ab26b2fa3d1717687672dddd4c5e3e6de736918c172189985e9"
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 21/22: test(cli): retain fresh Root ownership through store startup
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 91bbbf1b1b6fc2276e5b33f1b068173f1fbf282f
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Oct 22 17:29:33 2026 +0800
test(cli): retain fresh Root ownership through store startup
---
products/maka/cli/src/application/product/headless.rs | 4 +---
.../src/application/product/headless/update/tests.rs | 2 +-
products/maka/cli/src/application/product/launch.rs | 17 ++++++++++++-----
.../maka/cli/src/application/product/launch/tests.rs | 18 +++++++-----------
products/maka/cli/src/application/product/restore.rs | 14 +++++---------
products/maka/cli/src/application/product/update.rs | 2 +-
.../maka/cli/src/application/product/update/tests.rs | 4 ++--
7 files changed, 29 insertions(+), 32 deletions(-)
diff --git a/products/maka/cli/src/application/product/headless.rs b/products/maka/cli/src/application/product/headless.rs
index d22a57a40a..dad38ea26a 100644
--- a/products/maka/cli/src/application/product/headless.rs
+++ b/products/maka/cli/src/application/product/headless.rs
@@ -394,9 +394,7 @@ mod tests {
#[tokio::test]
async fn saved_product_checks_original_source_before_accepting_prepared_input() {
let directory = tempfile::tempdir().unwrap();
- let store = launch::open(directory.path().join("host"), true, None)
- .await
- .unwrap();
+ let store = launch::create(directory.path().join("host")).await.unwrap();
let deployment = Uuid::new_v4();
let source: Input = serde_json::from_value(json!({
"environment":{"location":"host"},"instances":{}
diff --git a/products/maka/cli/src/application/product/headless/update/tests.rs b/products/maka/cli/src/application/product/headless/update/tests.rs
index a55fec224d..43b4548992 100644
--- a/products/maka/cli/src/application/product/headless/update/tests.rs
+++ b/products/maka/cli/src/application/product/headless/update/tests.rs
@@ -51,7 +51,7 @@ struct Fixture {
}
impl Fixture {
async fn new(path: &std::path::Path) -> Self {
- let store = launch::open(path.join("host"), true, None).await.unwrap();
+ let store = launch::create(path.join("host")).await.unwrap();
let deployment = Uuid::new_v4();
let key = Uuid::new_v4();
let previous = leaf("same-ID local fork", "string");
diff --git a/products/maka/cli/src/application/product/launch.rs b/products/maka/cli/src/application/product/launch.rs
index 37a4d3b931..92b02f3e3b 100644
--- a/products/maka/cli/src/application/product/launch.rs
+++ b/products/maka/cli/src/application/product/launch.rs
@@ -61,14 +61,10 @@ struct PreparedLaunch {
pub(super) async fn open(
root: PathBuf,
- initialize: bool,
expected_domain: Option<String>,
) -> Result<EventLog, HostError> {
let owner = tokio::task::spawn_blocking(move || {
let namespaces = RootNamespaces::for_current_account()?;
- if initialize {
- maka_event_log::root::initialize(&root, &namespaces)?;
- }
let owner = RootOwner::open(&root, &namespaces)?;
if expected_domain
.as_deref()
@@ -81,6 +77,17 @@ pub(super) async fn open(
.await??;
Ok(maka_transport::open_store(Arc::new(owner)).await?)
}
+// Test Roots are fresh. Keep the initial owner through SQL startup: dropping and
+// reacquiring it can race a parallel child's inherited OFD lease before exec.
+#[cfg(test)]
+pub(super) async fn create(root: PathBuf) -> Result<EventLog, HostError> {
+ let owner = tokio::task::spawn_blocking(move || {
+ RootOwner::create(&root, &RootNamespaces::for_current_account()?)
+ })
+ .await??;
+ Ok(maka_transport::open_store(Arc::new(owner)).await?)
+}
+
pub(super) fn targets(
configuration: &Configuration,
) -> Result<(PathBuf, String, Uuid, Uuid), HostError> {
@@ -288,7 +295,7 @@ async fn bootstrap_host(
connections: &Configuration,
prepared: PreparedApplication,
) -> Result<bool, HostError> {
- let host_store = match open(root.into(), false, Some(domain.into())).await {
+ let host_store = match open(root.into(), Some(domain.into())).await {
Ok(store) => store,
Err(error)
if error
diff --git a/products/maka/cli/src/application/product/launch/tests.rs b/products/maka/cli/src/application/product/launch/tests.rs
index baa9041908..5f039caade 100644
--- a/products/maka/cli/src/application/product/launch/tests.rs
+++ b/products/maka/cli/src/application/product/launch/tests.rs
@@ -75,8 +75,8 @@ async fn partial_initialization_resumes_original_inputs_and_preserves_new_heads(
let directory = tempfile::tempdir().unwrap();
let ui_path = directory.path().join("ui");
let host_path = directory.path().join("host");
- let store = open(ui_path.clone(), true, None).await.unwrap();
- let host = Arc::new(open(host_path.clone(), true, None).await.unwrap());
+ let store = create(ui_path.clone()).await.unwrap();
+ let host = Arc::new(create(host_path.clone()).await.unwrap());
let (ui, host_id, management) = pending(&store, &host).await;
ensure(&host, host_id, &input(), &[], &[]).await.unwrap();
let mut edited = input();
@@ -103,7 +103,7 @@ async fn partial_initialization_resumes_original_inputs_and_preserves_new_heads(
let serving = PublishedHost::start(host.clone(), host_id).await;
store.drain().await;
drop(store);
- let store = open(ui_path.clone(), false, None).await.unwrap();
+ let store = open(ui_path.clone(), None).await.unwrap();
let (selected, _, clients) = prepare(&store).await.unwrap();
selected.check_store(&store).unwrap();
assert_eq!(selected.source().reference.deployment, ui);
@@ -176,12 +176,8 @@ async fn partial_initialization_resumes_original_inputs_and_preserves_new_heads(
#[tokio::test]
async fn conflicting_original_commit_keeps_pending_record_and_does_not_create_ui() {
let directory = tempfile::tempdir().unwrap();
- let store = open(directory.path().join("ui"), true, None).await.unwrap();
- let host = Arc::new(
- open(directory.path().join("host"), true, None)
- .await
- .unwrap(),
- );
+ let store = create(directory.path().join("ui")).await.unwrap();
+ let host = Arc::new(create(directory.path().join("host")).await.unwrap());
let (ui, host_id, _) = pending(&store, &host).await;
let mut conflicting = input();
conflicting.environment = Environment {
@@ -213,8 +209,8 @@ async fn conflicting_original_commit_keeps_pending_record_and_does_not_create_ui
async fn replacement_host_root_cannot_resume_original_initialization() {
let directory = tempfile::tempdir().unwrap();
let host_path = directory.path().join("host");
- let store = open(directory.path().join("ui"), true, None).await.unwrap();
- let host = open(host_path.clone(), true, None).await.unwrap();
+ let store = create(directory.path().join("ui")).await.unwrap();
+ let host = create(host_path.clone()).await.unwrap();
let (ui, _, _) = pending(&store, &host).await;
host.drain().await;
drop(host);
diff --git a/products/maka/cli/src/application/product/restore.rs b/products/maka/cli/src/application/product/restore.rs
index fef6eba26c..5a51ec1398 100644
--- a/products/maka/cli/src/application/product/restore.rs
+++ b/products/maka/cli/src/application/product/restore.rs
@@ -35,7 +35,7 @@ pub(crate) struct Restore {
impl Restore {
pub(crate) async fn run(self) -> Result<(), HostError> {
let root = super::root_path(self.root)?;
- let store = launch::open(root, false, None).await?;
+ let store = launch::open(root, None).await?;
let result = restore(&store).await;
store.drain().await;
println!("{}", serde_json::to_string(&result?)?);
@@ -240,11 +240,9 @@ mod tests {
#[tokio::test]
async fn restore_keeps_each_original_source_inventory_without_catalog_leakage() {
let directory = tempfile::tempdir().unwrap();
- let store = launch::open(directory.path().join("ui"), true, None)
- .await
- .unwrap();
+ let store = launch::create(directory.path().join("ui")).await.unwrap();
let host_path = directory.path().join("host");
- let host = Arc::new(launch::open(host_path.clone(), true, None).await.unwrap());
+ let host = Arc::new(launch::create(host_path.clone()).await.unwrap());
let host_id = Uuid::new_v4();
let manager = Uuid::new_v4();
let ui = Uuid::new_v4();
@@ -319,11 +317,9 @@ mod tests {
#[tokio::test]
async fn restore_preserves_original_identity_history_and_resumes_without_replay() {
let directory = tempfile::tempdir().unwrap();
- let store = launch::open(directory.path().join("ui"), true, None)
- .await
- .unwrap();
+ let store = launch::create(directory.path().join("ui")).await.unwrap();
let host_path = directory.path().join("host");
- let host = Arc::new(launch::open(host_path.clone(), true, None).await.unwrap());
+ let host = Arc::new(launch::create(host_path.clone()).await.unwrap());
let ui = Uuid::new_v4();
let host_id = Uuid::new_v4();
let manager = Uuid::new_v4();
diff --git a/products/maka/cli/src/application/product/update.rs b/products/maka/cli/src/application/product/update.rs
index bb05866e30..ca57325e10 100644
--- a/products/maka/cli/src/application/product/update.rs
+++ b/products/maka/cli/src/application/product/update.rs
@@ -73,7 +73,7 @@ impl Update {
pub(crate) async fn run(self) -> Result<(), HostError> {
let root = super::root_path(self.root)?;
let store = if self.apply {
- launch::open(root, false, None).await?
+ launch::open(root, None).await?
} else {
super::super::open_application_inspection(root).await?
};
diff --git a/products/maka/cli/src/application/product/update/tests.rs b/products/maka/cli/src/application/product/update/tests.rs
index 9b72c70750..8db4951f81 100644
--- a/products/maka/cli/src/application/product/update/tests.rs
+++ b/products/maka/cli/src/application/product/update/tests.rs
@@ -56,8 +56,8 @@ async fn old_installation(
path: &std::path::Path,
standard: bool,
) -> (EventLog, Uuid, PublishedHost) {
- let store = launch::open(path.join("ui"), true, None).await.unwrap();
- let host = Arc::new(launch::open(path.join("host"), true, None).await.unwrap());
+ let store = launch::create(path.join("ui")).await.unwrap();
+ let host = Arc::new(launch::create(path.join("host")).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 mut config = super::super::installation(options.clone()).await.unwrap();
// Previous ceilings blocked network and external-write approval requests.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org